mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
fix(selfhost): pass MULTICA_WECOM_DEFAULT_LOCALE into the backend container (#9691)
router.go reads MULTICA_WECOM_DEFAULT_LOCALE to pick the language the bot writes its own copy in for readers it cannot look a language up for (a group chat, anyone not linked to a Multica account). Neither .env.example nor docker-compose.selfhost.yml named it, so a self-hosted operator had no way to find the knob, and setting it on the host did nothing: compose forwards only what its environment: block lists, and the deployment silently stayed on zh-Hans. Add it to both, and a test that parses router.go for every MULTICA_WECOM_* literal it reads and fails when either file is missing one.
This commit is contained in:
@@ -301,6 +301,21 @@ GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback
|
||||
# and the wecom Web-UI endpoints return 503.
|
||||
# Generate with: openssl rand -base64 32
|
||||
#
|
||||
# MULTICA_WECOM_DEFAULT_LOCALE
|
||||
# Which language the bot writes its OWN copy in
|
||||
# — binding prompt, "the agent is offline",
|
||||
# inbox cards — for readers it cannot look a
|
||||
# language up for: a group chat, and anyone who
|
||||
# has not linked a Multica account yet. A linked
|
||||
# person always overrides this with the language
|
||||
# on their Multica profile, so this is the
|
||||
# fallback, not a switch that overrides anybody.
|
||||
# Accepts zh-Hans (or zh) and en. Empty, or
|
||||
# anything else, = zh-Hans, which is what every
|
||||
# existing deployment already gets.
|
||||
# It does NOT touch the agent's answers: those
|
||||
# come back in whatever language the model
|
||||
# replied in, the same as today.
|
||||
# MULTICA_WECOM_MEDIA_ALLOW_CIDRS
|
||||
# Comma-separated ranges the media fetcher may
|
||||
# dial even though they look reserved. Empty by
|
||||
@@ -351,6 +366,7 @@ GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback
|
||||
# the lease are dropped and the user sees nothing. Until cross-replica outbound
|
||||
# routing lands, run the WeCom-enabled backend as a SINGLE replica.
|
||||
MULTICA_WECOM_SECRET_KEY=
|
||||
MULTICA_WECOM_DEFAULT_LOCALE=
|
||||
MULTICA_WECOM_MEDIA_ALLOW_CIDRS=
|
||||
MULTICA_WECOM_TRACE=
|
||||
|
||||
|
||||
@@ -211,6 +211,12 @@ services:
|
||||
# for a debugging session and unset it when that session ends, because
|
||||
# what it records is user message content.
|
||||
MULTICA_WECOM_TRACE: ${MULTICA_WECOM_TRACE:-}
|
||||
# Which language the bot's own copy is written in for readers whose
|
||||
# language cannot be looked up — a group chat, or anyone not linked to a
|
||||
# Multica account yet. A linked person reads their own profile language
|
||||
# instead. zh-Hans (default) or en; an unrecognised value leaves the
|
||||
# default in place and the resolved one is logged at boot.
|
||||
MULTICA_WECOM_DEFAULT_LOCALE: ${MULTICA_WECOM_DEFAULT_LOCALE:-}
|
||||
# Telegram bot integration. MULTICA_TELEGRAM_SECRET_KEY is the
|
||||
# opt-in: unset = integration disabled. It decrypts the
|
||||
# per-installation bot token stored in channel_installation.config
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
// wecom_env_documented_test.go — every MULTICA_WECOM_* knob the boot block
|
||||
// reads has to be reachable from a self-hosted deployment.
|
||||
//
|
||||
// Two files stand between an operator and one of these variables, and both are
|
||||
// load-bearing. .env.example is where they find out the knob exists at all;
|
||||
// there is no other list. docker-compose.selfhost.yml is what passes it into
|
||||
// the backend container — exporting it on the host does nothing on its own,
|
||||
// because compose only forwards what its `environment:` block names, so a
|
||||
// variable missing there is set, ignored, and silent about it.
|
||||
//
|
||||
// MULTICA_WECOM_DEFAULT_LOCALE was missing from both. router.go has read it
|
||||
// since the copy pack started following the reader's language, and a
|
||||
// self-hosted deployment that set it on the host got the built-in zh-Hans with
|
||||
// nothing saying why.
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// routerSourceFile is the file the WeCom boot block lives in, relative to this
|
||||
// package.
|
||||
const routerSourceFile = "router.go"
|
||||
|
||||
// repoRoot is where .env.example and the compose files live, relative to this
|
||||
// package (server/cmd/server). Go runs a test with its package directory as
|
||||
// the working directory, so this is stable wherever the tree is checked out.
|
||||
const repoRoot = "../../.."
|
||||
|
||||
var wecomEnvName = regexp.MustCompile(`^MULTICA_WECOM_[A-Z0-9_]+$`)
|
||||
|
||||
func TestEveryWecomEnvVarIsDocumentedAndPassedIntoTheContainer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The names as the code asks for them: string literals in router.go, not
|
||||
// words in a comment. secretbox.LoadKey("MULTICA_WECOM_SECRET_KEY") counts
|
||||
// the same as os.Getenv — what matters is that the process reads it, not
|
||||
// which helper does the reading.
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, routerSourceFile, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", routerSourceFile, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
for _, arg := range call.Args {
|
||||
if name := stringLit(arg); wecomEnvName.MatchString(name) {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
var read []string
|
||||
for name := range seen {
|
||||
read = append(read, name)
|
||||
}
|
||||
sort.Strings(read)
|
||||
|
||||
if len(read) < 4 {
|
||||
t.Fatalf("found only %v in %s — the boot block reads more than that, so this test is no longer looking where the variables are",
|
||||
read, routerSourceFile)
|
||||
}
|
||||
|
||||
envExample := readRepoFile(t, ".env.example")
|
||||
compose := readRepoFile(t, "docker-compose.selfhost.yml")
|
||||
|
||||
for _, name := range read {
|
||||
// A settable line, not a mention in the comment block above it: an
|
||||
// operator copies .env.example to .env and fills values in, so a
|
||||
// variable that appears only in prose is one they have to know to add
|
||||
// by hand.
|
||||
if !hasLinePrefix(envExample, name+"=") {
|
||||
t.Errorf("router.go reads %s, but .env.example has no %s= line.\n"+
|
||||
"An operator has no other list of these; add the line and the paragraph above it that says what the value does.",
|
||||
name, name)
|
||||
}
|
||||
if !hasLinePrefix(compose, name+":") {
|
||||
t.Errorf("router.go reads %s, but the backend service in docker-compose.selfhost.yml does not pass it in.\n"+
|
||||
"Compose forwards only what its environment: block names, so setting %s on the host reaches the container as an empty string and the deployment silently runs on the built-in default.",
|
||||
name, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readRepoFile(t *testing.T, name string) []string {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(filepath.Clean(filepath.Join(repoRoot, name)))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return strings.Split(string(body), "\n")
|
||||
}
|
||||
|
||||
// hasLinePrefix looks for a line that STARTS with prefix once its indentation
|
||||
// is stripped — so a commented-out "# NAME=" does not count as documented and
|
||||
// a YAML key nested under environment: does.
|
||||
func hasLinePrefix(lines []string, prefix string) bool {
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stringLit is the value of a plain string literal, or "" for anything else.
|
||||
func stringLit(e ast.Expr) string {
|
||||
lit, ok := e.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING || len(lit.Value) < 2 {
|
||||
return ""
|
||||
}
|
||||
return lit.Value[1 : len(lit.Value)-1]
|
||||
}
|
||||
Reference in New Issue
Block a user