Compare commits

..

48 Commits

Author SHA1 Message Date
姚章浩 0798a2dac7 feat(dingtalk): stabilize multimodal answer workflows 2026-09-09 13:57:43 +08:00
姚章浩 18c81232ce fix: keep only setting command entries 2026-08-14 12:35:33 +08:00
姚章浩 19ed4b50df feat: add guided administrator management 2026-08-14 12:12:33 +08:00
姚章浩 9f65c3eef0 docs: update AI table binding example 2026-08-14 11:46:37 +08:00
姚章浩 50379b3b87 fix: parse AI table iframe query links 2026-08-14 11:40:28 +08:00
姚章浩 3f9ff359b9 feat: accept trusted AI table link tails 2026-08-14 11:20:15 +08:00
姚章浩 a3defdd1c0 fix: report AI table binding validation failures 2026-08-14 11:11:07 +08:00
姚章浩 b4b68d8b21 fix: accept AI table link cards in settings 2026-08-14 10:46:51 +08:00
姚章浩 365f1b8c7d fix: detect missing-product coverage responses 2026-08-14 10:02:58 +08:00
姚章浩 725db721a8 fix: refresh table dispatchers after binding activation 2026-08-13 18:39:15 +08:00
姚章浩 5d68b22636 feat: add operational component status dashboard 2026-08-13 18:33:52 +08:00
姚章浩 6f24743be2 feat: verify and activate support group binding 2026-08-13 18:22:43 +08:00
姚章浩 4dbc20ee74 fix: validate card business responses and trim help 2026-08-13 15:10:09 +08:00
姚章浩 e5be9ee780 fix: stop enqueueing disabled external table exports 2026-08-13 14:49:44 +08:00
姚章浩 0f30f8cec8 fix: route help and repeated-slash settings locally 2026-08-13 14:39:56 +08:00
姚章浩 44d9398c78 fix: keep setup available without active DingTalk app 2026-08-13 13:55:25 +08:00
姚章浩 b207ee4089 feat: switch DingTalk applications with rollback 2026-08-13 13:36:26 +08:00
姚章浩 0fc64ba77d feat: validate and activate AI table bindings 2026-08-13 13:14:06 +08:00
姚章浩 a7432aed49 feat: wire settings admin into DobotAI runtime 2026-08-13 12:59:20 +08:00
姚章浩 5036071ac2 feat: keep table binding messages out of QA 2026-08-13 12:55:00 +08:00
姚章浩 2d9490f424 feat: apply per-user answer language 2026-08-13 12:50:07 +08:00
姚章浩 462ee1f3ff feat: add admin settings control flow 2026-08-13 12:45:08 +08:00
姚章浩 8d2d4b469b feat: add secure settings foundation 2026-08-13 12:38:01 +08:00
姚章浩 9363d10e44 feat: publish outbound answer images to private OSS 2026-08-13 12:34:39 +08:00
姚章浩 74c9af9944 fix: acknowledge DingTalk callbacks before async processing 2026-08-13 12:34:16 +08:00
姚章浩 5c35a49486 fix: recreate stale WeKnora sessions once 2026-08-13 12:34:05 +08:00
姚章浩 357e9bad5c docs: plan DobotAI settings administration 2026-08-13 12:33:55 +08:00
姚章浩 2c0ed7cc67 feat: expose runtime build version 2026-08-13 10:44:16 +08:00
姚章浩 aa0513eb59 feat: add FAQ uncovered workflow 2026-07-27 11:54:34 +08:00
姚章浩 e582d851a4 fix: wrap Notable record fields payload 2026-07-23 18:55:03 +08:00
姚章浩 0a3f62618e feat: sync DingTalk QA to Notable question table 2026-07-23 14:38:50 +08:00
姚章浩 5e0694835a feat: add DingTalk directory-backed session titles 2026-07-23 10:55:25 +08:00
姚章浩 39c5fbbaef fix: detect retrieval gaps with human escalation 2026-07-22 14:17:39 +08:00
姚章浩 d29b28be13 fix: detect unavailable authoritative knowledge answers 2026-07-22 13:21:59 +08:00
姚章浩 774ccd9454 feat: wire coverage support notifications 2026-07-22 12:48:30 +08:00
姚章浩 86593df57c feat: add durable support notification dispatcher 2026-07-22 12:36:33 +08:00
姚章浩 913a65b16d fix: persist coverage reference count 2026-07-22 12:19:53 +08:00
姚章浩 a6569ee4bf feat: add DingTalk support group sender 2026-07-22 12:17:04 +08:00
姚章浩 5b5865dc07 feat: add coverage decision persistence 2026-07-22 12:00:32 +08:00
zheromiku 4366c018f9 Merge pull request 'feat: add exact custom card feedback reasons' (#9) from feature/custom-card-feedback-actions into main 2026-07-21 21:23:15 +08:00
姚章浩 a952232cc9 feat: add exact custom card feedback reasons 2026-07-21 21:21:41 +08:00
zheromiku 4dc3f22784 Merge pull request 'feat: handle native DingTalk card feedback' (#8) from feature/native-card-feedback into main 2026-07-21 21:00:04 +08:00
姚章浩 b8f572cc84 feat: handle native DingTalk card feedback 2026-07-21 20:41:12 +08:00
zheromiku fcf4559c9a Merge pull request 'feat: enable per-user DingTalk card debugging' (#7) from feature/card-feedback-debug into main 2026-07-21 18:06:42 +08:00
姚章浩 9fa2eac6a4 feat: enable per-user DingTalk card debugging 2026-07-21 18:02:14 +08:00
zheromiku da69226481 Merge pull request 'feat: add DingTalk answer feedback workflow' (#6) from feature/answer-feedback into main 2026-07-21 16:00:17 +08:00
姚章浩 38560c5f41 feat: add DingTalk answer feedback workflow 2026-07-21 15:23:57 +08:00
zheromiku f0d3d5b50d Merge pull request 'feat: add tenant-scoped audit query API' (#5) from feature/audit-query-api into main 2026-07-21 14:11:37 +08:00
186 changed files with 22328 additions and 284 deletions
+31
View File
@@ -22,6 +22,37 @@ DOBOTAI_TENANT_KEY=
# When set, a successful QA answer will be delivered as an AI card; markdown
# is only sent as a fallback when card delivery fails. Never commit real values.
DINGTALK_CARD_TEMPLATE_ID=
# Optional, temporary DingTalk card-debug allowlist. When set, only this exact
# DingTalk user receives _CARD_DEBUG_TOOL_ENTRY=show in per-user privateData.
# Keep empty outside a controlled debug session; never commit a real user ID.
DINGTALK_CARD_DEBUG_USER_ID=
# Optional DingTalk FAQ table workflow for uncovered questions. Set all four
# values together; leave all four empty to disable. Required when the support
# group notification below is enabled. Never commit real IDs.
DINGTALK_FAQ_TABLE_BASE_ID=
DINGTALK_FAQ_TABLE_SHEET_ID=
DINGTALK_FAQ_TABLE_VIEW_ID=
DINGTALK_FAQ_TABLE_OPERATOR_ID=
# Optional PDF-answer image egress for DingTalk cards. When any value is set,
# all five values are required. DobotAI downloads only authenticated resource://
# images from WEKNORA_BASE_URL /files, writes content-hashed copies under
# dobotai/im-images/, and emits private OSS GET URLs valid for 2 hours.
# Keep the bucket private and configure an OSS lifecycle rule to delete
# dobotai/im-images/ objects after 1 day. Never commit real credentials.
DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT=
DOBOTAI_OUTBOUND_IMAGE_OSS_REGION=
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID=
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET=
DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET=
# Optional support group for uncovered-answer notifications. This is the
# group's openConversationId, not a webhook. Leave empty to record coverage
# decisions and pending support cases without sending group notifications.
# When enabled, the FAQ table workflow above must also be configured so the
# ActionCard can link to the exact FAQ record. Never commit a real group ID.
DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID=
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
DATABASE_URL=postgres://dobotai:replace-me@127.0.0.1:5432/dobotai?sslmode=disable
+5
View File
@@ -5,6 +5,11 @@
*.local
coverage.out
# Local deployment, backup, and production diagnostic artifacts
/.deploy/
/backups/
/check-live-dobotai.py
# Editors and OS
.idea/
.vscode/
File diff suppressed because it is too large Load Diff
+39
View File
@@ -15,7 +15,9 @@ Implemented:
- external-message idempotency;
- independent PostgreSQL session/turn storage;
- authenticated internal `POST /v1/qa` ingress endpoint.
- unauthenticated read-only `GET /version` runtime build identity endpoint.
- DingTalk Stream text ingress (official SDK) wired into `cmd/dobotai`.
- v1 knowledge coverage decisions, durable support cases, and optional DingTalk support-group notifications.
## DingTalk channel (Stream, text only)
@@ -25,6 +27,32 @@ Implemented:
- Sessions: direct chats use one long-lived session per user; group chats share one session per group (default `shared` policy).
- Replies are sent via the callback's `sessionWebhook`, which is fail-closed: only `https` URLs on `*.dingtalk.com` (port 443, no redirects) are accepted.
- Required env vars to enable (all three or none): `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, `DOBOTAI_TENANT_KEY`. Partial configuration is a fatal startup error.
- Optional `DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID` enables durable uncovered-answer ActionCard notifications through the official group-message API. It requires the FAQ table workflow below so every “去回答” button targets an exact record. When empty, coverage decisions and support cases are still recorded but no group message is sent.
## DingTalk question table sync (Notable v1)
Successful DingTalk QA turns can be appended asynchronously to a DingTalk Notable AI table through a durable PostgreSQL outbox. Notable failures never change the successful QA result; the worker retries with bounded backoff and stores only stable failure codes.
Apply `db/migrations/000010_question_table_exports.up.sql` before enabling the worker. The three table settings are optional as a group: all blank disables dispatch, while a partial configuration fails startup closed.
- `DINGTALK_QUESTION_TABLE_BASE_ID`
- `DINGTALK_QUESTION_TABLE_SHEET_ID`
- `DINGTALK_QUESTION_TABLE_OPERATOR_ID` (DingTalk unionId)
The worker reuses `DINGTALK_CLIENT_ID` and `DINGTALK_CLIENT_SECRET`. The DingTalk application must have `Notable.Base.Read.All` and `Notable.Base.Write.All`. Only successful `channel=dingtalk` turns are enqueued; other channels are excluded. The first release writes the nine basic text/date/user fields and omits the five select-classification fields.
## Uncovered-question FAQ workflow
Apply `db/migrations/000011_faq_exports.up.sql` before enabling this workflow. Existing support cases are marked `not_applicable` and are not backfilled; only support cases created after the migration default to `pending` FAQ export.
Set all four values together, or leave all four blank to disable FAQ export. Partial configuration fails startup closed:
- `DINGTALK_FAQ_TABLE_BASE_ID`
- `DINGTALK_FAQ_TABLE_SHEET_ID`
- `DINGTALK_FAQ_TABLE_VIEW_ID`
- `DINGTALK_FAQ_TABLE_OPERATOR_ID` (DingTalk unionId)
For every newly detected uncovered answer, the asynchronous worker creates one FAQ record with standard question, original question, detected language, and asker. After DingTalk returns the `recordId`, DobotAI stores it and sends a built-in `sampleActionCard` containing only the current question and up to five earlier questions from the same stable actor. The “去回答” button opens that exact FAQ record. FAQ insert failures are retried with bounded backoff and never block the customer reply.
## WeKnora baseline
@@ -47,6 +75,17 @@ Health check:
curl http://127.0.0.1:8090/healthz
```
Runtime version check:
```bash
curl http://127.0.0.1:8090/version
```
`scripts/deploy-local.sh` builds from its own worktree (or the explicit
`DOBOTAI_SOURCE_ROOT`), injects `version`, `commit_sha`, and `build_time`, and
accepts the deployment only when `/version` reports the exact source commit.
It refuses dirty worktrees so the reported SHA cannot hide uncommitted source.
Internal QA request:
```bash
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(args []string, out io.Writer) error {
flags := flag.NewFlagSet("directory-import", flag.ContinueOnError)
flags.SetOutput(io.Discard)
csvPath := flags.String("csv", "", "path to tested DingTalk directory CSV output")
tenant := flags.String("tenant", "", "tenant key")
databaseURLFile := flags.String("database-url-file", "", "file containing DATABASE_URL")
validateOnly := flags.Bool("validate-only", false, "validate CSV without connecting to PostgreSQL")
if err := flags.Parse(args); err != nil {
return errors.New("invalid directory import arguments")
}
if strings.TrimSpace(*csvPath) == "" || strings.TrimSpace(*tenant) == "" {
return errors.New("-csv and -tenant are required")
}
input, err := os.Open(*csvPath)
if err != nil {
return errors.New("open directory CSV failed")
}
defer input.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if *validateOnly {
count, importErr := directory.ImportCSV(ctx, *tenant, input, validationWriter{})
if importErr != nil {
return errors.New("directory CSV validation failed")
}
_, _ = fmt.Fprintf(out, "validated_profiles=%d\n", count)
return nil
}
if strings.TrimSpace(*databaseURLFile) == "" {
return errors.New("-database-url-file is required for import")
}
databaseURL, err := readSecretFile(*databaseURLFile)
if err != nil {
return err
}
store, err := postgres.Open(ctx, databaseURL)
if err != nil {
return errors.New("open directory database failed")
}
defer store.Close()
count, err := directory.ImportCSV(ctx, *tenant, input, store)
if err != nil {
return errors.New("directory CSV import failed")
}
_, _ = fmt.Fprintf(out, "imported_profiles=%d\n", count)
return nil
}
type validationWriter struct{}
func (validationWriter) UpsertDirectoryProfiles(context.Context, string, []directory.Profile) error {
return nil
}
func readSecretFile(path string) (string, error) {
raw, err := os.ReadFile(strings.TrimSpace(path))
if err != nil { return "", errors.New("read database URL file failed") }
if len(raw) > 16<<10 { return "", errors.New("database URL file is too large") }
value := strings.TrimSpace(string(raw))
if value == "" { return "", errors.New("database URL file is empty") }
return value, nil
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestReadSecretFileTrimsValue(t *testing.T) {
path := filepath.Join(t.TempDir(), "database_url")
if err := os.WriteFile(path, []byte(" postgres://secret-value \n"), 0o600); err != nil { t.Fatal(err) }
value, err := readSecretFile(path)
if err != nil { t.Fatalf("readSecretFile: %v", err) }
if value != "postgres://secret-value" { t.Fatalf("value=%q", value) }
}
func TestReadSecretFileErrorDoesNotLeakContents(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty")
if err := os.WriteFile(path, []byte(""), 0o600); err != nil { t.Fatal(err) }
_, err := readSecretFile(path)
if err == nil { t.Fatal("expected error") }
if strings.Contains(err.Error(), "secret-value") { t.Fatalf("error leaked secret: %v", err) }
}
func TestRunValidateOnlyDoesNotRequireDatabase(t *testing.T) {
csvPath := filepath.Join(t.TempDir(), "directory.csv")
if err := os.WriteFile(csvPath, []byte("user_id,union_id,name,nickname,title,department_ids,dept_name\nstaff-1,,张三,,工程师,100,技术支持部\n"), 0o600); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
err := run([]string{"-csv", csvPath, "-tenant", "tenant-a", "-validate-only"}, &out)
if err != nil {
t.Fatalf("run validate-only: %v", err)
}
if !strings.Contains(out.String(), "validated_profiles=1") {
t.Fatalf("output=%q", out.String())
}
}
func TestRunRequiresCSVAndDatabaseURLFileBeforeConnecting(t *testing.T) {
var out bytes.Buffer
for _, args := range [][]string{{}, {"-csv", "snapshot.csv"}} {
err := run(args, &out)
if err == nil { t.Fatalf("run(%v) expected error", args) }
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"context"
"errors"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type candidateConfigRepository interface {
CandidateDingTalkConfig(context.Context, string) (string, string, error)
ActivateDingTalkCandidate(context.Context, string, string) error
}
type candidateCredentialVerifier interface {
Verify(context.Context, string, string) dingtalkconfig.Result
}
var (
candidateTokenURL = ""
candidateTableBaseURL = ""
)
type dingTalkCandidateActivator struct {
tenant string
repo candidateConfigRepository
secrets *secretstore.Store
verifier candidateCredentialVerifier
bindings map[string]settings.ResourceBinding
restart func()
}
func (a *dingTalkCandidateActivator) ActivateCandidate(ctx context.Context) error {
if a == nil || a.repo == nil || a.secrets == nil || a.verifier == nil || a.restart == nil {
return errors.New("candidate_activation_unavailable")
}
clientID, secretRef, err := a.repo.CandidateDingTalkConfig(ctx, a.tenant)
if err != nil {
return errors.New("candidate_not_found")
}
secret, err := a.secrets.Get(secretRef)
if err != nil {
return errors.New("candidate_secret_unavailable")
}
if result := a.verifier.Verify(ctx, clientID, secret); !result.OK {
return errors.New("candidate_oauth_failed")
}
for _, kind := range []string{"question_table", "faq_table"} {
binding, ok := a.bindings[kind]
if !ok {
continue
}
client, err := questiontable.NewClient(questiontable.Config{ClientID: clientID, Secret: secret, BaseID: binding.BaseID, SheetID: binding.SheetID, OperatorID: binding.OperatorID, TokenURL: candidateTokenURL, BaseURL: candidateTableBaseURL}, nil)
if err != nil {
return errors.New("candidate_table_config_failed")
}
fields, err := client.ReadFields(ctx)
if err != nil {
return errors.New("candidate_table_probe_failed")
}
required := questiontable.QuestionSchema()
if kind == "faq_table" {
required = questiontable.FAQSchema()
}
if _, err = questiontable.MapRequiredFields(fields, required); err != nil {
return errors.New("candidate_table_schema_failed")
}
}
if err := a.repo.ActivateDingTalkCandidate(ctx, a.tenant, secretRef); err != nil {
return errors.New("candidate_database_activation_failed")
}
go func() {
time.Sleep(300 * time.Millisecond)
a.restart()
}()
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type candidateRepoStub struct {
activated bool
ref string
}
func (r *candidateRepoStub) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
return "app", r.ref, nil
}
func (r *candidateRepoStub) ActivateDingTalkCandidate(context.Context, string, string) error {
r.activated = true
return nil
}
type candidateVerifierStub struct{ ok bool }
func (v candidateVerifierStub) Verify(context.Context, string, string) dingtalkconfig.Result {
return dingtalkconfig.Result{OK: v.ok}
}
func TestCandidateActivatorRejectsOAuthWithoutActivation(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
ref, _ := secrets.Put("dingtalk", "secret")
repo := &candidateRepoStub{ref: ref}
a := &dingTalkCandidateActivator{tenant: "t", repo: repo, secrets: secrets, verifier: candidateVerifierStub{}, bindings: map[string]settings.ResourceBinding{}, restart: func() {}}
if err := a.ActivateCandidate(context.Background()); err == nil {
t.Fatal("expected error")
}
if repo.activated {
t.Fatal("must not activate")
}
}
func TestCandidateActivatorValidatesSchemasThenActivates(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"accessToken":"token","expireIn":7200}`))
})
mux.HandleFunc("/v1.0/notable/bases/base/sheets/sheet/fields", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"value":[{"id":"q","name":"中文问题","type":"text"},{"id":"tm","name":"提问时间","type":"date"},{"id":"u","name":"提问人","type":"user"},{"id":"o","name":"原始问题","type":"text"},{"id":"a","name":"AI答案","type":"text"},{"id":"p","name":"流程触发人员信息","type":"text"},{"id":"l","name":"语言","type":"text"},{"id":"d","name":"部门","type":"text"},{"id":"r","name":"区域","type":"text"}]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
secrets, _ := secretstore.New(t.TempDir())
ref, _ := secrets.Put("dingtalk", "secret")
repo := &candidateRepoStub{ref: ref}
restarted := make(chan struct{}, 1)
a := &dingTalkCandidateActivator{tenant: "t", repo: repo, secrets: secrets, verifier: candidateVerifierStub{ok: true}, bindings: map[string]settings.ResourceBinding{"question_table": {BaseID: "base", SheetID: "sheet", OperatorID: "u"}}, restart: func() { restarted <- struct{}{} }}
oldTokenURL, oldBaseURL := candidateTokenURL, candidateTableBaseURL
candidateTokenURL, candidateTableBaseURL = server.URL+"/token", server.URL
defer func() { candidateTokenURL, candidateTableBaseURL = oldTokenURL, oldBaseURL }()
if err := a.ActivateCandidate(context.Background()); err != nil {
t.Fatal(err)
}
if !repo.activated {
t.Fatal("expected activation")
}
select {
case <-restarted:
case <-time.After(time.Second):
t.Fatal("restart not requested")
}
}
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
)
func TestMainWiresDirectoryResolverIntoDingTalkHandler(t *testing.T) {
raw, err := os.ReadFile("main.go")
if err != nil {
t.Fatalf("read main.go: %v", err)
}
src := string(raw)
for _, required := range []string{
"directory.NewDingTalkLookup",
"directory.NewResolver",
} {
if !strings.Contains(src, required) {
t.Errorf("main.go missing %q", required)
}
}
if !regexp.MustCompile(`Directory:\s*directoryResolver`).MatchString(src) {
t.Error("main.go does not wire directoryResolver into HandlerOptions.Directory")
}
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"context"
"errors"
"log"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
type faqConfig struct {
BaseID string
SheetID string
ViewID string
OperatorID string
FieldMapping map[string]string
}
func readFAQConfig() (faqConfig, error) {
cfg := faqConfig{
BaseID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_BASE_ID")),
SheetID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_SHEET_ID")),
ViewID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_VIEW_ID")),
OperatorID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_OPERATOR_ID")),
}
blank := cfg.BaseID == "" && cfg.SheetID == "" && cfg.ViewID == "" && cfg.OperatorID == ""
complete := cfg.BaseID != "" && cfg.SheetID != "" && cfg.ViewID != "" && cfg.OperatorID != ""
if blank {
return faqConfig{}, nil
}
if !complete {
return faqConfig{}, errors.New("DingTalk FAQ table config must be complete or disabled")
}
return cfg, nil
}
func buildFAQDispatcher(repo faqtable.ExportRepository, clientID, clientSecret string, cfg faqConfig) (*faqtable.Dispatcher, error) {
if cfg.BaseID == "" {
return nil, nil
}
if repo == nil {
return nil, errors.New("FAQ dispatcher repository is required")
}
client, err := questiontable.NewClient(questiontable.Config{
ClientID: clientID, Secret: clientSecret, BaseID: cfg.BaseID,
SheetID: cfg.SheetID, OperatorID: cfg.OperatorID, FieldMapping: cfg.FieldMapping,
}, nil)
if err != nil {
return nil, err
}
return faqtable.NewDispatcher(repo, client, nil)
}
type faqPollerRunner interface {
RunOnce(context.Context) (faqtable.DispatcherStats, error)
}
func runFAQPoller(ctx context.Context, runner faqPollerRunner, interval time.Duration) {
if runner == nil || interval <= 0 {
return
}
run := func() {
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
stats, err := runner.RunOnce(runCtx)
cancel()
if err != nil {
log.Printf("FAQ table dispatch failed")
return
}
if stats.Claimed > 0 {
log.Printf("FAQ table dispatch: claimed=%d exported=%d failed=%d", stats.Claimed, stats.Exported, stats.Failed)
}
}
if ctx.Err() != nil {
return
}
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
type faqRepoStub struct{}
func (*faqRepoStub) Claim(context.Context, int, int, time.Time) ([]faqtable.PendingExport, error) {
return nil, nil
}
func (*faqRepoStub) MarkExported(context.Context, string, string) error { return nil }
func (*faqRepoStub) MarkFailed(context.Context, string, string) error { return nil }
func TestReadFAQConfigDisabledWhenAllBlank(t *testing.T) {
for _, key := range []string{"DINGTALK_FAQ_TABLE_BASE_ID", "DINGTALK_FAQ_TABLE_SHEET_ID", "DINGTALK_FAQ_TABLE_VIEW_ID", "DINGTALK_FAQ_TABLE_OPERATOR_ID"} {
t.Setenv(key, "")
}
cfg, err := readFAQConfig()
if err != nil || cfg.BaseID != "" {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
}
func TestReadFAQConfigRejectsPartialValues(t *testing.T) {
t.Setenv("DINGTALK_FAQ_TABLE_BASE_ID", "base")
t.Setenv("DINGTALK_FAQ_TABLE_SHEET_ID", "")
t.Setenv("DINGTALK_FAQ_TABLE_VIEW_ID", "view")
t.Setenv("DINGTALK_FAQ_TABLE_OPERATOR_ID", "operator")
if _, err := readFAQConfig(); err == nil {
t.Fatal("expected partial-config error")
}
}
func TestBuildFAQDispatcherRequiresRepositoryWhenEnabled(t *testing.T) {
cfg := faqConfig{BaseID: "base", SheetID: "sheet", ViewID: "view", OperatorID: "operator"}
if _, err := buildFAQDispatcher(nil, "client", "secret", cfg); err == nil {
t.Fatal("expected repository error")
}
if d, err := buildFAQDispatcher(&faqRepoStub{}, "client", "secret", cfg); err != nil || d == nil {
t.Fatalf("dispatcher=%v err=%v", d, err)
}
}
func TestMainWiresFAQExportBeforeSupportNotification(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
"readFAQConfig()", "postgres.NewFAQExportRepository(store)",
"buildFAQDispatcher(", "runFAQPoller(ctx, faqDispatcher, 5*time.Second)",
"faqtable.NewRecordLinkBuilder(", "buildSupportDispatcher(",
} {
if !strings.Contains(source, fragment) {
t.Errorf("main FAQ wiring missing %q", fragment)
}
}
if strings.Index(source, "runFAQPoller(ctx") > strings.Index(source, "runSupportDispatcher(ctx") {
t.Fatal("FAQ exporter must start before support notifications")
}
}
func TestEnvExampleDocumentsFAQWorkflowConfig(t *testing.T) {
data, err := os.ReadFile("../../.env.example")
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"DINGTALK_FAQ_TABLE_BASE_ID", "DINGTALK_FAQ_TABLE_SHEET_ID", "DINGTALK_FAQ_TABLE_VIEW_ID", "DINGTALK_FAQ_TABLE_OPERATOR_ID"} {
if !strings.Contains(string(data), key+"=") {
t.Errorf(".env.example missing %s", key)
}
}
}
+282 -10
View File
@@ -8,12 +8,18 @@ import (
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/agentbinding"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/httpapi"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/qa"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
@@ -21,9 +27,24 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
var (
version = "dev"
commitSHA = "unknown"
buildTime = "unknown"
)
func runtimeBuildInfo() httpapi.BuildInfo {
return httpapi.BuildInfo{
Version: version,
CommitSHA: commitSHA,
BuildTime: buildTime,
}
}
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
var restartRequested atomic.Bool
storeCtx, storeCancel := context.WithTimeout(ctx, 10*time.Second)
defer storeCancel()
store, err := postgres.Open(storeCtx, os.Getenv("DATABASE_URL"))
@@ -31,6 +52,20 @@ func main() {
log.Fatal(err)
}
defer store.Close()
settingsRuntime, err := buildSettingsRuntime(storeCtx, store, os.Getenv("DOBOTAI_TENANT_KEY"))
if err != nil {
log.Fatal(err)
}
settingsRuntime.bindings.restartDelay = 300 * time.Millisecond
settingsRuntime.bindings.restart = func() { restartRequested.Store(true); cancel() }
address := strings.TrimSpace(os.Getenv("DOBOTAI_LISTEN_ADDR"))
if address == "" {
address = "127.0.0.1:8090"
}
if strings.TrimSpace(settingsRuntime.clientID) == "" || strings.TrimSpace(settingsRuntime.secret) == "" {
runSetupOnly(ctx, cancel, settingsRuntime, address, os.Getenv("DOBOTAI_TENANT_KEY"), &restartRequested)
return
}
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second)
if err != nil {
log.Fatal(err)
@@ -46,33 +81,183 @@ func main() {
if groupPolicy != session.GroupShared && groupPolicy != session.GroupPerUser {
log.Fatal("DOBOTAI_GROUP_SESSION_POLICY must be shared or per_user")
}
qaService, err := qa.New(store, bindings, client, groupPolicy)
coverageService, err := coverage.NewService(store)
if err != nil {
log.Fatal(err)
}
address := strings.TrimSpace(os.Getenv("DOBOTAI_LISTEN_ADDR"))
if address == "" {
address = "127.0.0.1:8090"
qaService, err := qa.New(
store,
bindings,
client,
groupPolicy,
qa.WithSucceededTurnObserver(coverageService, func(err error) {
log.Printf("coverage persistence failed: %v", err)
}),
)
if err != nil {
log.Fatal(err)
}
auditOptions, err := buildAuditOptions(store, os.Getenv("DOBOTAI_TENANT_KEY"), os.Getenv("DOBOTAI_ADMIN_TOKEN"), address)
if err != nil {
log.Fatal(err)
}
auditOptions.BuildInfo = runtimeBuildInfo()
api, err := httpapi.NewWithOptions(qaService, os.Getenv("DOBOTAI_INGRESS_TOKEN"), auditOptions)
if err != nil {
log.Fatal(err)
}
dingCfg := dingtalk.StreamConfig{
ClientID: os.Getenv("DINGTALK_CLIENT_ID"),
ClientSecret: os.Getenv("DINGTALK_CLIENT_SECRET"),
ClientID: settingsRuntime.clientID,
ClientSecret: settingsRuntime.secret,
TenantKey: os.Getenv("DOBOTAI_TENANT_KEY"),
}
if err := dingCfg.Validate(); err != nil {
log.Fatal(err)
}
activeBindings, err := settingsRuntime.repository.ActiveBindings(storeCtx, dingCfg.TenantKey)
if err != nil {
log.Fatal(err)
}
settingsRuntime.admin.SetCandidateActivator(&dingTalkCandidateActivator{
tenant: dingCfg.TenantKey, repo: settingsRuntime.repository, secrets: settingsRuntime.secrets,
verifier: settingsRuntime.verifier, bindings: activeBindings,
restart: func() { restartRequested.Store(true); cancel() },
})
runtimeReady := make(chan struct{})
faqCfg, err := readFAQConfig()
if err != nil {
log.Fatal(err)
}
if binding, ok := activeBindings["faq_table"]; ok {
faqCfg = faqConfig{BaseID: binding.BaseID, SheetID: binding.SheetID, ViewID: binding.ViewID, OperatorID: binding.OperatorID, FieldMapping: binding.FieldMapping}
}
var faqWorkflowDone chan struct{}
var faqRecordLinks *faqtable.RecordLinkBuilder
if faqCfg.BaseID != "" {
faqRecordLinks, err = faqtable.NewRecordLinkBuilder(faqCfg.BaseID, faqCfg.SheetID, faqCfg.ViewID)
if err != nil {
log.Fatal(err)
}
faqRepo, err := postgres.NewFAQExportRepository(store)
if err != nil {
log.Fatal(err)
}
faqDispatcher, err := buildFAQDispatcher(faqRepo, dingCfg.ClientID, dingCfg.ClientSecret, faqCfg)
if err != nil {
log.Fatalf("DingTalk FAQ table dispatcher init failed: %v", err)
}
faqWorkflowDone = make(chan struct{})
go func() {
defer close(faqWorkflowDone)
select {
case <-runtimeReady:
case <-ctx.Done():
return
}
runFAQPoller(ctx, faqDispatcher, 5*time.Second)
}()
log.Printf("DingTalk FAQ table dispatcher started")
}
supportConversationID := os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID")
if binding, ok := activeBindings["support_group"]; ok {
supportConversationID = binding.ConversationID
}
supportDispatcher, err := buildSupportDispatcher(
store,
dingCfg.ClientID,
dingCfg.ClientSecret,
supportConversationID,
faqRecordLinks,
)
if err != nil {
log.Fatalf("DingTalk support notification init failed: %v", err)
}
if supportDispatcher != nil {
go func() {
select {
case <-runtimeReady:
case <-ctx.Done():
return
}
runSupportDispatcher(ctx, supportDispatcher, 5*time.Second)
}()
log.Printf("DingTalk support notification dispatcher started")
}
// Outbound image runtime (stable-image publishing) is built from environment
// variables independently of the Stream ingress, so both the question-table
// answer rewriter and the inbound-image publisher can share it.
outboundImages, err := buildOutboundImageRuntime(outboundImageConfig{
WeKnoraBaseURL: os.Getenv("WEKNORA_BASE_URL"),
WeKnoraAPIKey: os.Getenv("WEKNORA_API_KEY"),
Endpoint: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
Region: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
AccessKeyID: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
AccessKeySecret: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
Bucket: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"),
PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL"),
})
if err != nil {
log.Fatalf("DingTalk outbound image init failed: %v", err)
}
// Question table dispatcher (optional, fail-closed).
qtCfg, err := readQuestionTableConfig()
if err != nil {
log.Fatal(err)
}
if binding, ok := activeBindings["question_table"]; ok {
qtCfg = questionTableConfig{BaseID: binding.BaseID, SheetID: binding.SheetID, OperatorID: binding.OperatorID, FieldMapping: binding.FieldMapping}
}
store.SetExternalTableExports(qtCfg.BaseID != "", faqCfg.BaseID != "")
var questionTableDone chan struct{}
if qtCfg.BaseID != "" {
qtRepo, err := postgres.NewQuestionTableExportRepository(store)
if err != nil {
log.Fatal(err)
}
qtDispatcher, err := buildQuestionTableDispatcher(qtRepo, dingCfg.ClientID, dingCfg.ClientSecret, qtCfg, answerRewriterAdapter{rewriter: outboundImages.rewriter})
if err != nil {
log.Fatalf("DingTalk question table dispatcher init failed: %v", err)
}
questionTableDone = make(chan struct{})
go func() {
defer close(questionTableDone)
select {
case <-runtimeReady:
case <-ctx.Done():
return
}
runQuestionTablePoller(ctx, qtDispatcher, 5*time.Second)
}()
log.Printf("DingTalk question table dispatcher started")
}
var streamClient *dingtalk.StreamClient
if dingCfg.Enabled() {
directoryLookup, err := directory.NewDingTalkLookup(directory.DingTalkLookupConfig{
TokenURL: "https://oapi.dingtalk.com/gettoken",
APIBaseURL: "https://oapi.dingtalk.com",
ClientID: dingCfg.ClientID,
ClientSecret: dingCfg.ClientSecret,
Timeout: 10 * time.Second,
})
if err != nil {
log.Fatalf("DingTalk directory lookup init failed: %v", err)
}
directoryResolver, err := directory.NewResolver(store, directoryLookup)
if err != nil {
log.Fatalf("DingTalk directory resolver init failed: %v", err)
}
feedbackService, err := feedback.NewService(dingCfg.TenantKey, "dingtalk", store)
if err != nil {
log.Fatal(err)
}
feedbackHandler, err := dingtalk.NewCardFeedbackHandler(feedbackService)
if err != nil {
log.Fatal(err)
}
sender, err := dingtalk.NewWebhookSender(nil)
if err != nil {
log.Fatal(err)
@@ -83,22 +268,40 @@ func main() {
}
var cardSender dingtalk.FinalCardSender
if cardTemplateID := strings.TrimSpace(os.Getenv("DINGTALK_CARD_TEMPLATE_ID")); cardTemplateID != "" {
cs, err := dingtalk.NewCardSender(dingCfg.ClientID, dingCfg.ClientSecret, cardTemplateID, nil)
cardDebugUserID := os.Getenv("DINGTALK_CARD_DEBUG_USER_ID")
cs, err := dingtalk.NewCardSender(
dingCfg.ClientID,
dingCfg.ClientSecret,
cardTemplateID,
nil,
dingtalk.WithCardDebugUserID(cardDebugUserID),
)
if err != nil {
log.Fatalf("DingTalk card sender init failed: %v", err)
}
if strings.TrimSpace(cardDebugUserID) != "" {
log.Printf("DingTalk card debug enabled for one allowlisted user")
}
cardSender = cs
}
handler, err := dingtalk.NewHandlerWithOptions(
dingtalk.TenantConfig{TenantKey: dingCfg.TenantKey, Channel: "dingtalk"},
qaService,
sender,
dingtalk.HandlerOptions{ImageDownloader: downloader, CardSender: cardSender},
dingtalk.HandlerOptions{
ImageDownloader: downloader,
ImagePublisher: outboundImages.publisher,
CardSender: cardSender,
Directory: directoryResolver,
OutboundRewriter: outboundImages.rewriter,
SettingsRouter: &dingTalkSettingsRouter{controller: settingsRuntime.controller},
LanguageResolver: settingsRuntime.repository,
},
)
if err != nil {
log.Fatal(err)
}
streamClient, err = dingtalk.NewStreamClient(dingCfg, handler)
streamClient, err = dingtalk.NewStreamClient(dingCfg, handler, feedbackHandler)
if err != nil {
log.Fatal(err)
}
@@ -106,9 +309,45 @@ func main() {
log.Fatal(err)
}
defer streamClient.Close()
if err := settingsRuntime.repository.MarkDingTalkRuntimeHealthy(context.Background(), dingCfg.TenantKey, settingsRuntime.secretRef); err != nil {
streamClient.Close()
log.Fatal(err)
}
log.Printf("DingTalk Stream ingress started for tenant %s", dingCfg.TenantKey)
}
server := &http.Server{Addr: address, Handler: api.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
close(runtimeReady)
_, questionBound := activeBindings["question_table"]
_, faqBound := activeBindings["faq_table"]
_, supportBound := activeBindings["support_group"]
weknoraHealthURL := strings.TrimRight(strings.TrimSpace(os.Getenv("WEKNORA_BASE_URL")), "/")
if weknoraHealthURL != "" {
weknoraHealthURL += "/health"
}
settingsRuntime.admin.SetOperationalStatusProvider(&operationalStatusProvider{
metrics: store,
tenant: dingCfg.TenantKey,
weknoraHealthURL: weknoraHealthURL,
httpClient: &http.Client{Timeout: 3 * time.Second},
now: time.Now,
version: runtimeBuildInfo().Version,
streamRunning: streamClient != nil,
questionRunning: questionTableDone != nil,
faqRunning: faqWorkflowDone != nil,
supportRunning: supportDispatcher != nil,
questionBound: questionBound,
faqBound: faqBound,
supportBound: supportBound,
ossConfigured: outboundOSSConfigured(),
})
rootHandler := http.NewServeMux()
rootHandler.Handle("/", api.Handler())
if outboundImages.handler != nil {
rootHandler.Handle(outboundImageRoute, outboundImages.handler)
}
if strings.TrimSpace(os.Getenv("DOBOTAI_ADMIN_TOKEN")) != "" {
rootHandler.Handle("/admin/", adminweb.New(settingsRuntime.admin, os.Getenv("DOBOTAI_ADMIN_TOKEN")))
}
server := &http.Server{Addr: address, Handler: rootHandler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("DobotAI ingress listening on %s", address)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -122,6 +361,39 @@ func main() {
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP shutdown: %v", err)
}
if faqWorkflowDone != nil {
select {
case <-faqWorkflowDone:
case <-shutdownCtx.Done():
log.Printf("FAQ table poller shutdown timed out")
}
}
if questionTableDone != nil {
select {
case <-questionTableDone:
case <-shutdownCtx.Done():
log.Printf("question table poller shutdown timed out")
}
}
if streamClient != nil {
streamClient.Close()
}
if restartRequested.Load() {
os.Exit(75)
}
}
func outboundOSSConfigured() bool {
for _, key := range []string{
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT", "DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID", "DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET", "DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL",
} {
if strings.TrimSpace(os.Getenv(key)) == "" {
return false
}
}
return true
}
func buildAuditOptions(repo audit.TurnRepository, tenantKey, adminToken, address string) (httpapi.Options, error) {
+118
View File
@@ -2,6 +2,8 @@ package main
import (
"context"
"os"
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
@@ -19,6 +21,20 @@ func (r *auditRepoStub) GetTurn(context.Context, string, string) (audit.TurnDeta
return audit.TurnDetail{}, audit.ErrNotFound
}
func TestRuntimeBuildInfoUsesLinkerVariables(t *testing.T) {
oldVersion, oldCommitSHA, oldBuildTime := version, commitSHA, buildTime
t.Cleanup(func() {
version, commitSHA, buildTime = oldVersion, oldCommitSHA, oldBuildTime
})
version = "2.1.0"
commitSHA = "abc123def456"
buildTime = "2026-07-27T12:00:00Z"
got := runtimeBuildInfo()
if got.Version != version || got.CommitSHA != commitSHA || got.BuildTime != buildTime {
t.Fatalf("build info=%+v", got)
}
}
func TestBuildAuditOptionsDisabledIsBackwardCompatible(t *testing.T) {
opts, err := buildAuditOptions(nil, "", "", "0.0.0.0:8090")
if err != nil {
@@ -65,3 +81,105 @@ func TestBuildAuditOptionsFixesServerSideTenant(t *testing.T) {
t.Fatalf("tenant=%q", repo.tenant)
}
}
func TestMainWiresFeedbackIntoDingTalkStream(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"`,
`feedback.NewService(dingCfg.TenantKey, "dingtalk", store)`,
`dingtalk.NewCardFeedbackHandler(feedbackService)`,
`dingtalk.NewStreamClient(dingCfg, handler, feedbackHandler)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main feedback wiring missing %q", fragment)
}
}
}
func TestMainWiresOptionalDingTalkCardDebugUser(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`os.Getenv("DINGTALK_CARD_DEBUG_USER_ID")`,
`dingtalk.WithCardDebugUserID(cardDebugUserID)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main card debug wiring missing %q", fragment)
}
}
}
func TestBuildOutboundImageRuntimeConfig(t *testing.T) {
t.Run("disabled", func(t *testing.T) {
runtime, err := buildOutboundImageRuntime(outboundImageConfig{})
if err != nil || runtime.rewriter != nil || runtime.handler != nil {
t.Fatalf("runtime=%+v err=%v", runtime, err)
}
})
t.Run("partial configuration rejected", func(t *testing.T) {
_, err := buildOutboundImageRuntime(outboundImageConfig{Bucket: "private-bucket"})
if err == nil {
t.Fatal("expected configuration error")
}
})
t.Run("complete configuration", func(t *testing.T) {
runtime, err := buildOutboundImageRuntime(outboundImageConfig{
WeKnoraBaseURL: "http://127.0.0.1:8080",
WeKnoraAPIKey: "api-key",
Endpoint: "https://oss-cn-shenzhen.aliyuncs.com",
Region: "cn-shenzhen",
AccessKeyID: "access-key-id",
AccessKeySecret: "access-key-secret",
Bucket: "private-bucket",
PublicBaseURL: "https://weknora-api-test.dobot.cn/dingtalk/i",
})
if err != nil || runtime.rewriter == nil || runtime.handler == nil {
t.Fatalf("runtime=%+v err=%v", runtime, err)
}
})
}
func TestMainWiresStableOutboundImageRuntime(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`buildOutboundImageRuntime(outboundImageConfig{`,
`PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL")`,
`OutboundRewriter: outboundImages.rewriter`,
`rootHandler.Handle(outboundImageRoute, outboundImages.handler)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main stable image wiring missing %q", fragment)
}
}
}
func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
for _, key := range []string{
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT",
"DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET",
} {
t.Setenv(key, "configured")
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "")
if outboundOSSConfigured() {
t.Fatal("stable public base URL must be required")
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "https://img.example/dingtalk/i")
if !outboundOSSConfigured() {
t.Fatal("complete stable image configuration should be active")
}
}
+164
View File
@@ -0,0 +1,164 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
type adminComponent = adminweb.Component
type operationalMetrics = postgres.OperationalMetrics
type operationalMetricsSource interface {
Ping(context.Context) error
ReadOperationalMetrics(context.Context, string) (postgres.OperationalMetrics, error)
}
type operationalStatusProvider struct {
metrics operationalMetricsSource
tenant string
weknoraHealthURL string
httpClient *http.Client
now func() time.Time
version string
streamRunning bool
questionRunning bool
faqRunning bool
supportRunning bool
questionBound bool
faqBound bool
supportBound bool
ossConfigured bool
}
func (p *operationalStatusProvider) Components(ctx context.Context) ([]adminweb.Component, error) {
now := time.Now()
if p.now != nil {
now = p.now()
}
components := []adminweb.Component{{Name: "运行版本", State: "ok", Code: "version_ok", Detail: "版本:" + safeVersion(p.version), LastSuccess: now, CheckedAt: now}}
metrics := operationalMetrics{}
if p.metrics == nil || p.metrics.Ping(ctx) != nil {
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "error", Code: "database_unavailable", Detail: "数据库健康检查失败", CheckedAt: now})
} else {
var err error
metrics, err = p.metrics.ReadOperationalMetrics(ctx, p.tenant)
if err != nil {
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "error", Code: "metrics_query_failed", Detail: "数据库可连接,但状态聚合失败", LastSuccess: now, CheckedAt: now})
} else {
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "ok", Code: "database_ok", Detail: "连接与状态聚合正常", LastSuccess: now, CheckedAt: now})
}
}
components = append(components,
booleanComponent("钉钉Stream", p.streamRunning, "stream_running", "stream_stopped", "正式Stream消费者运行中", "正式Stream消费者未运行", now),
p.weknoraComponent(ctx, now),
outboxComponent("问题表Outbox", metrics.QuestionPending, metrics.QuestionFailed, metrics.LastQuestionSuccess, now),
outboxComponent("FAQ Outbox", metrics.FAQPending, metrics.FAQFailed, metrics.LastFAQSuccess, now),
supportComponent(p, metrics, now),
dispatcherComponent("问题表Dispatcher", p.questionBound, p.questionRunning, now),
dispatcherComponent("FAQ Dispatcher", p.faqBound, p.faqRunning, now),
booleanComponent("Private OSS图片", p.ossConfigured, "oss_configured", "oss_unconfigured", "图片出站配置完整", "图片出站未配置", now),
)
return components, nil
}
func (p *operationalStatusProvider) weknoraComponent(ctx context.Context, now time.Time) adminweb.Component {
component := adminweb.Component{Name: "WeKnora", State: "error", Code: "health_probe_failed", Detail: "健康探针失败", CheckedAt: now}
if strings.TrimSpace(p.weknoraHealthURL) == "" {
component.Code = "health_url_missing"
component.Detail = "健康探针地址未配置"
return component
}
client := p.httpClient
if client == nil {
client = &http.Client{Timeout: 3 * time.Second}
}
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, p.weknoraHealthURL, nil)
if err != nil {
return component
}
resp, err := client.Do(req)
if err != nil {
return component
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
component.Code = "health_http_error"
component.Detail = fmt.Sprintf("健康探针HTTP %d", resp.StatusCode)
return component
}
component.State, component.Code, component.Detail = "ok", "health_ok", "HTTP健康探针正常"
component.LastSuccess = now
return component
}
func outboxComponent(name string, pending, failed int, lastSuccess, now time.Time) adminweb.Component {
component := adminweb.Component{Name: name, State: "ok", Code: "outbox_empty", Detail: "pending=0,failed=0", LastSuccess: lastSuccess, CheckedAt: now}
if failed > 0 {
component.State, component.Code = "error", "outbox_failed"
} else if pending > 0 {
component.State, component.Code = "warning", "outbox_pending"
}
component.Detail = fmt.Sprintf("pending=%d,failed=%d", pending, failed)
return component
}
func supportComponent(p *operationalStatusProvider, metrics operationalMetrics, now time.Time) adminweb.Component {
component := adminweb.Component{Name: "技术支持通知", CheckedAt: now, LastSuccess: metrics.LastSupportSuccess}
switch {
case !p.supportBound:
component.State, component.Code, component.Detail = "disabled", "binding_missing", "技术支持群未绑定"
case !p.faqBound:
component.State, component.Code, component.Detail = "blocked", "faq_binding_required", "已绑定支持群;需先激活FAQ表"
case metrics.SupportFailed > 0:
component.State, component.Code = "error", "notification_failed"
component.Detail = fmt.Sprintf("pending=%d,failed=%d", metrics.SupportPending, metrics.SupportFailed)
case metrics.SupportPending > 0:
component.State, component.Code = "warning", "notification_pending"
component.Detail = fmt.Sprintf("pending=%d,failed=0", metrics.SupportPending)
case p.supportRunning:
component.State, component.Code, component.Detail = "ok", "dispatcher_running", "通知Dispatcher运行中;pending=0,failed=0"
default:
component.State, component.Code, component.Detail = "error", "dispatcher_stopped", "绑定与依赖完整,但Dispatcher未运行"
}
return component
}
func dispatcherComponent(name string, bound, running bool, now time.Time) adminweb.Component {
if !bound {
return adminweb.Component{Name: name, State: "disabled", Code: "binding_missing", Detail: "无active绑定", CheckedAt: now}
}
if !running {
return adminweb.Component{Name: name, State: "error", Code: "dispatcher_stopped", Detail: "已有active绑定,但Dispatcher未运行", CheckedAt: now}
}
return adminweb.Component{Name: name, State: "ok", Code: "dispatcher_running", Detail: "Dispatcher运行中", LastSuccess: now, CheckedAt: now}
}
func booleanComponent(name string, ok bool, successCode, failureCode, successDetail, failureDetail string, now time.Time) adminweb.Component {
if ok {
return adminweb.Component{Name: name, State: "ok", Code: successCode, Detail: successDetail, LastSuccess: now, CheckedAt: now}
}
return adminweb.Component{Name: name, State: "disabled", Code: failureCode, Detail: failureDetail, CheckedAt: now}
}
func safeVersion(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "unknown"
}
if len(value) > 80 {
return value[:80]
}
return value
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestOperationalStatusProviderBuildsLiveComponents(t *testing.T) {
health := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer health.Close()
checked := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
provider := &operationalStatusProvider{
metrics: operationalMetricsStub{value: operationalMetrics{
QuestionPending: 2, FAQFailed: 1, SupportPending: 3,
LastQuestionSuccess: checked.Add(-time.Minute),
}},
weknoraHealthURL: health.URL,
httpClient: health.Client(),
now: func() time.Time { return checked },
version: "settings-test",
streamRunning: true,
questionRunning: true,
faqRunning: false,
supportRunning: false,
questionBound: true,
faqBound: false,
supportBound: true,
ossConfigured: true,
}
components, err := provider.Components(context.Background())
if err != nil {
t.Fatal(err)
}
byName := componentMap(components)
assertComponent(t, byName, "运行版本", "ok", "version_ok")
assertComponent(t, byName, "PostgreSQL", "ok", "database_ok")
assertComponent(t, byName, "钉钉Stream", "ok", "stream_running")
assertComponent(t, byName, "WeKnora", "ok", "health_ok")
assertComponent(t, byName, "问题表Outbox", "warning", "outbox_pending")
assertComponent(t, byName, "FAQ Outbox", "error", "outbox_failed")
assertComponent(t, byName, "技术支持通知", "blocked", "faq_binding_required")
assertComponent(t, byName, "问题表Dispatcher", "ok", "dispatcher_running")
assertComponent(t, byName, "FAQ Dispatcher", "disabled", "binding_missing")
assertComponent(t, byName, "Private OSS图片", "ok", "oss_configured")
for _, component := range components {
for _, forbidden := range []string{health.URL, "cid-secret-group", "base-secret", "access-token"} {
if strings.Contains(component.Detail, forbidden) {
t.Fatalf("component %q leaks sensitive identifier %q", component.Name, forbidden)
}
}
}
}
func TestOperationalStatusProviderIsolatesWeKnoraFailure(t *testing.T) {
provider := &operationalStatusProvider{
metrics: operationalMetricsStub{value: operationalMetrics{}},
weknoraHealthURL: "http://127.0.0.1:1/health",
httpClient: &http.Client{Timeout: 50 * time.Millisecond},
now: time.Now,
}
components, err := provider.Components(context.Background())
if err != nil {
t.Fatal(err)
}
byName := componentMap(components)
assertComponent(t, byName, "PostgreSQL", "ok", "database_ok")
assertComponent(t, byName, "WeKnora", "error", "health_probe_failed")
}
type operationalMetricsStub struct {
value operationalMetrics
err error
}
func (s operationalMetricsStub) ReadOperationalMetrics(context.Context, string) (operationalMetrics, error) {
return s.value, s.err
}
func (s operationalMetricsStub) Ping(context.Context) error { return s.err }
func componentMap(values []adminComponent) map[string]adminComponent {
out := make(map[string]adminComponent, len(values))
for _, value := range values {
out[value.Name] = value
}
return out
}
func assertComponent(t *testing.T, values map[string]adminComponent, name, state, code string) {
t.Helper()
value, ok := values[name]
if !ok {
t.Fatalf("missing component %q: %+v", name, values)
}
if value.State != state || value.Code != code {
t.Fatalf("component %q=%+v want state=%q code=%q", name, value, state, code)
}
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage"
)
const (
outboundImageMaxCount = 5
outboundImageMaxBytes = 10 << 20
outboundImagePrefix = "dobotai/im-images"
outboundImageRoute = "/dingtalk/i/"
)
type outboundImageConfig struct {
WeKnoraBaseURL string
WeKnoraAPIKey string
Endpoint string
Region string
AccessKeyID string
AccessKeySecret string
Bucket string
PublicBaseURL string
}
type outboundImageRuntime struct {
rewriter dingtalk.OutboundContentRewriter
publisher *outboundimage.StableHashPublisher
handler http.Handler
}
func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, error) {
ossValues := []string{cfg.Endpoint, cfg.Region, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.Bucket, cfg.PublicBaseURL}
enabled := false
for _, value := range ossValues {
if strings.TrimSpace(value) != "" {
enabled = true
break
}
}
if !enabled {
return outboundImageRuntime{}, nil
}
for _, value := range append([]string{cfg.WeKnoraBaseURL, cfg.WeKnoraAPIKey}, ossValues...) {
if strings.TrimSpace(value) == "" {
return outboundImageRuntime{}, errors.New("outbound image: complete WeKnora, OSS, and public URL configuration is required")
}
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
fetcher, err := outboundimage.NewWeKnoraFetcher(
cfg.WeKnoraBaseURL,
cfg.WeKnoraAPIKey,
&http.Client{Timeout: 30 * time.Second, Transport: transport},
outboundImageMaxBytes,
)
if err != nil {
return outboundImageRuntime{}, err
}
store, err := outboundimage.NewAliyunOSSStore(
cfg.Endpoint,
cfg.Region,
cfg.AccessKeyID,
cfg.AccessKeySecret,
cfg.Bucket,
)
if err != nil {
return outboundImageRuntime{}, err
}
publisher, err := outboundimage.NewStableHashPublisher(store, outboundImagePrefix, cfg.PublicBaseURL)
if err != nil {
return outboundImageRuntime{}, err
}
handler, err := outboundimage.NewStableImageHandler(store, outboundImagePrefix, outboundImageRoute, outboundImageMaxBytes)
if err != nil {
return outboundImageRuntime{}, err
}
return outboundImageRuntime{
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount),
publisher: publisher,
handler: handler,
}, nil
}
func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundContentRewriter, error) {
runtime, err := buildOutboundImageRuntime(cfg)
return runtime.rewriter, err
}
+164
View File
@@ -0,0 +1,164 @@
package main
import (
"context"
"errors"
"log"
"os"
"reflect"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
// questionTablePollerRunner is the interface expected by the poller loop.
type questionTablePollerRunner interface {
RunOnce(context.Context) (questiontable.DispatcherStats, error)
}
// questionTableConfig holds the optional Notable question-table env values.
type questionTableConfig struct {
BaseID string
SheetID string
OperatorID string
FieldMapping map[string]string
}
// readQuestionTableConfig reads the three dedicated env vars and returns
// a validated config. All three blank => disabled (no error). Any partial
// configuration => stable configuration error (no values echoed).
func readQuestionTableConfig() (questionTableConfig, error) {
cfg := questionTableConfig{
BaseID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_BASE_ID")),
SheetID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_SHEET_ID")),
OperatorID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID")),
}
allBlank := cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == ""
allSet := cfg.BaseID != "" && cfg.SheetID != "" && cfg.OperatorID != ""
if allBlank {
return questionTableConfig{}, nil
}
if allSet {
return cfg, nil
}
return questionTableConfig{}, errors.New("DingTalk question table config: all three env vars must be set or all blank (DINGTALK_QUESTION_TABLE_BASE_ID, DINGTALK_QUESTION_TABLE_SHEET_ID, DINGTALK_QUESTION_TABLE_OPERATOR_ID)")
}
// buildQuestionTableDispatcher creates a validated question-table dispatcher
// when the repo is non-nil and all required credentials are present.
// The optional answer rewriter converts the stored answer into the same
// outbound form used for DingTalk delivery (resource images to stable HTTPS,
// document links, anchor cleanup). No HTTP request occurs at build time.
// It returns (nil, nil) when the question-table feature is disabled.
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig, rewriter questiontable.AnswerRewriter) (*questiontable.Dispatcher, error) {
// Disabled: all blank config.
if cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == "" {
return nil, nil
}
if repo == nil {
return nil, errors.New("question table dispatcher: repository is required")
}
client, err := questiontable.NewClient(questiontable.Config{
ClientID: clientID,
Secret: clientSecret,
BaseID: cfg.BaseID,
SheetID: cfg.SheetID,
OperatorID: cfg.OperatorID,
FieldMapping: cfg.FieldMapping,
}, nil)
if err != nil {
return nil, err
}
return questiontable.NewDispatcher(repo, client, &questiontable.DispatcherOptions{
Rewriter: rewriter,
SendTimeout: 60 * time.Second,
})
}
// answerRewriterAdapter adapts a blocking outbound content rewriter to the
// AnswerRewriter contract (caller never gets an error, so the original answer
// is always written when conversion cannot run).
type answerRewriterAdapter struct {
rewriter dingtalk.OutboundContentRewriter
}
func (a answerRewriterAdapter) Rewrite(ctx context.Context, content string) (string, error) {
if a.rewriter == nil {
return content, nil
}
return a.rewriter.Rewrite(ctx, content), nil
}
// runQuestionTablePoller runs the dispatcher immediately, then every interval.
// Non-positive interval or nil/typed-nil runner returns without calling.
// Continues after a RunOnce error. Exits promptly on context cancellation.
// Logging is safe: success may log only Claimed/Exported/Failed integers;
// errors log stable stage text only, never raw errors.
func runQuestionTablePoller(ctx context.Context, runner questionTablePollerRunner, interval time.Duration) {
if runner == nil || interval <= 0 {
return
}
// Guard against typed nil.
if isNilPoller(runner) {
return
}
runOnce := func() {
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
stats, err := runner.RunOnce(runCtx)
cancel()
if err != nil {
log.Printf("question table dispatch failed")
return
}
if stats.Claimed > 0 || stats.Exported > 0 || stats.Failed > 0 {
log.Printf("question table dispatch: claimed=%d exported=%d failed=%d", stats.Claimed, stats.Exported, stats.Failed)
}
}
if ctx.Err() != nil {
return
}
runOnce()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runOnce()
}
}
}
// isNilPoller returns true when runner is a typed nil.
func isNilPoller(runner questionTablePollerRunner) bool {
if runner == nil {
return true
}
// reflect-based typed-nil check for the interface.
return isInterfaceNil(runner)
}
// isInterfaceNil returns true when v is a typed nil wrapped in an interface.
func isInterfaceNil(v interface{}) bool {
if v == nil {
return true
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return rv.IsNil()
default:
return false
}
}
+377
View File
@@ -0,0 +1,377 @@
package main
import (
"context"
"errors"
"log"
"os"
"strings"
"sync"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
// ---------------------------------------------------------------------------
// Test 1: disabled all blank returns nil/no error
// ---------------------------------------------------------------------------
func TestReadQuestionTableConfigDisabledAllBlank(t *testing.T) {
t.Setenv("DINGTALK_QUESTION_TABLE_BASE_ID", "")
t.Setenv("DINGTALK_QUESTION_TABLE_SHEET_ID", "")
t.Setenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID", "")
cfg, err := readQuestionTableConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.BaseID != "" || cfg.SheetID != "" || cfg.OperatorID != "" {
t.Errorf("expected empty config, got %+v", cfg)
}
}
func TestBuildQuestionTableDispatcherDisabled(t *testing.T) {
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d != nil {
t.Errorf("expected nil dispatcher for disabled config, got %v", d)
}
}
// ---------------------------------------------------------------------------
// Test 2: every partial combination errors and does not echo sentinel config values
// ---------------------------------------------------------------------------
func TestReadQuestionTableConfigPartialErrors(t *testing.T) {
tests := []struct {
name string
baseID string
sheetID string
operator string
}{
{"only base", "base-1", "", ""},
{"only sheet", "", "sheet-1", ""},
{"only operator", "", "", "op-1"},
{"base+sheet", "base-1", "sheet-1", ""},
{"base+operator", "base-1", "", "op-1"},
{"sheet+operator", "", "sheet-1", "op-1"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DINGTALK_QUESTION_TABLE_BASE_ID", tc.baseID)
t.Setenv("DINGTALK_QUESTION_TABLE_SHEET_ID", tc.sheetID)
t.Setenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID", tc.operator)
_, err := readQuestionTableConfig()
if err == nil {
t.Fatal("expected error for partial config")
}
errStr := err.Error()
// Must not echo any sentinel config value.
for _, sentinel := range []string{"base-1", "sheet-1", "op-1"} {
if strings.Contains(errStr, sentinel) {
t.Errorf("error must not echo sentinel value %q: %q", sentinel, errStr)
}
}
})
}
}
// ---------------------------------------------------------------------------
// Test 3: enabled missing repo/client/secret fails closed; complete config builds
// ---------------------------------------------------------------------------
func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg, nil)
if err == nil {
t.Fatal("expected error when repo is nil")
}
if !strings.Contains(err.Error(), "repository is required") {
t.Errorf("error = %v, want 'repository is required'", err)
}
}
func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg, nil)
if err == nil {
t.Fatal("expected error when client id is empty")
}
}
func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg, nil)
if err == nil {
t.Fatal("expected error when secret is empty")
}
}
func TestBuildQuestionTableDispatcherEnabledCompleteConfig(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d == nil {
t.Fatal("expected non-nil dispatcher for complete config")
}
}
// ---------------------------------------------------------------------------
// Test 4: immediate run + periodic repeat + cancel stop
// ---------------------------------------------------------------------------
func TestRunQuestionTablePollerImmediateAndPeriodic(t *testing.T) {
runner := &countingPollerRunner{}
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
}()
// Wait for at least 2 calls (immediate + one tick).
time.Sleep(120 * time.Millisecond)
cancel()
wg.Wait()
runner.mu.Lock()
calls := runner.calls
runner.mu.Unlock()
if calls < 2 {
t.Errorf("expected at least 2 calls, got %d", calls)
}
}
func TestRunQuestionTablePollerCancelledContext(t *testing.T) {
runner := &countingPollerRunner{}
ctx, cancel := context.WithCancel(context.Background())
cancel() // already cancelled
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
runner.mu.Lock()
calls := runner.calls
runner.mu.Unlock()
if calls != 0 {
t.Errorf("expected 0 calls (cancelled context), got %d", calls)
}
}
// ---------------------------------------------------------------------------
// Test 5: continue after error and log/output security contract
// ---------------------------------------------------------------------------
func TestRunQuestionTablePollerContinuesAfterError(t *testing.T) {
runner := &errorThenSuccessPoller{}
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runQuestionTablePoller(ctx, runner, 30*time.Millisecond)
}()
time.Sleep(120 * time.Millisecond)
cancel()
wg.Wait()
runner.mu.Lock()
calls := runner.calls
runner.mu.Unlock()
if calls < 2 {
t.Errorf("expected at least 2 calls after error, got %d", calls)
}
}
// Test logging contract: the runOnce function in runQuestionTablePoller logs
// only stable stage text for errors (never raw errors via %v) and only
// Claimed/Exported/Failed integers for success.
func TestPollerLoggingContract(t *testing.T) {
// Behavioral log-capture test: runner returns an error containing unique
// sentinel values; the captured log must contain the stable stage text
// and none of those sentinels.
sentinel := "s3nt1n3l-op3r4t0r-t0k3n-q"
runner := &sentinelErrorPoller{errMsg: "questiontable dispatcher: " + sentinel}
ctx, cancel := context.WithCancel(context.Background())
// Save and restore global log output.
originalOutput := log.Writer()
originalFlags := log.Flags()
var buf strings.Builder
log.SetOutput(&buf)
log.SetFlags(0)
defer func() {
log.SetOutput(originalOutput)
log.SetFlags(originalFlags)
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
}()
time.Sleep(120 * time.Millisecond)
cancel()
wg.Wait()
output := buf.String()
if !strings.Contains(output, "question table dispatch failed") {
t.Errorf("log must contain stable stage text, got: %s", output)
}
if strings.Contains(output, sentinel) {
t.Errorf("log must not contain sentinel %q, got: %s", sentinel, output)
}
}
// sentinelErrorPoller always returns an error with the given message.
type sentinelErrorPoller struct {
errMsg string
}
func (r *sentinelErrorPoller) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
return questiontable.DispatcherStats{}, errors.New(r.errMsg)
}
// ---------------------------------------------------------------------------
// Test 6: nil/typed-nil runner and nonpositive interval safe
// ---------------------------------------------------------------------------
func TestRunQuestionTablePollerNilRunner(t *testing.T) {
ctx := context.Background()
// Should not panic, just return immediately.
runQuestionTablePoller(ctx, nil, 50*time.Millisecond)
// If we reach here without panic, test passes.
}
func TestRunQuestionTablePollerNonPositiveInterval(t *testing.T) {
runner := &countingPollerRunner{}
ctx := context.Background()
runQuestionTablePoller(ctx, runner, 0)
runQuestionTablePoller(ctx, runner, -1*time.Second)
runner.mu.Lock()
calls := runner.calls
runner.mu.Unlock()
if calls != 0 {
t.Errorf("expected 0 calls for non-positive interval, got %d", calls)
}
}
func TestRunQuestionTablePollerTypedNilInterface(t *testing.T) {
// The runner is nil through the interface but not at the concrete type level.
var typedNil *questiontable.Dispatcher = nil
var runner questionTablePollerRunner = typedNil
ctx := context.Background()
// Should not panic, just return immediately.
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
}
// ---------------------------------------------------------------------------
// Test 7: main source wiring contract
// ---------------------------------------------------------------------------
func TestMainWiresQuestionTableEnvNames(t *testing.T) {
mainData, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
syncData, err := os.ReadFile("question_table_sync.go")
if err != nil {
t.Fatal(err)
}
mainSource := string(mainData)
syncSource := string(syncData)
for _, fragment := range []string{
`"DINGTALK_QUESTION_TABLE_BASE_ID"`,
`"DINGTALK_QUESTION_TABLE_SHEET_ID"`,
`"DINGTALK_QUESTION_TABLE_OPERATOR_ID"`,
} {
if !strings.Contains(syncSource, fragment) {
t.Errorf("question table config reader missing %q", fragment)
}
}
for _, fragment := range []string{
`NewQuestionTableExportRepository`,
`5*time.Second`,
`"DingTalk question table dispatcher started"`,
} {
if !strings.Contains(mainSource, fragment) {
t.Errorf("main wiring missing %q", fragment)
}
}
shutdownAt := strings.Index(mainSource, "server.Shutdown(shutdownCtx)")
waitAt := strings.Index(mainSource, "case <-questionTableDone:")
if shutdownAt < 0 || waitAt < 0 || waitAt <= shutdownAt {
t.Fatal("main must wait for question table poller after HTTP shutdown and before returning")
}
}
// ---------------------------------------------------------------------------
// Test doubles
// ---------------------------------------------------------------------------
// questionTableExportRepoStub implements questiontable.ExportRepository.
type questionTableExportRepoStub struct{}
func (r *questionTableExportRepoStub) Claim(ctx context.Context, limit int, maxAttempts int, staleBefore time.Time) ([]questiontable.PendingExport, error) {
return nil, nil
}
func (r *questionTableExportRepoStub) MarkExported(ctx context.Context, exportID, location string) error {
return nil
}
func (r *questionTableExportRepoStub) MarkFailed(ctx context.Context, exportID, failureCode string) error {
return nil
}
// countingPollerRunner counts calls to RunOnce.
type countingPollerRunner struct {
mu sync.Mutex
calls int
}
func (r *countingPollerRunner) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
r.mu.Lock()
r.calls++
r.mu.Unlock()
return questiontable.DispatcherStats{}, nil
}
// errorThenSuccessPoller returns an error on the first call, then succeeds.
type errorThenSuccessPoller struct {
mu sync.Mutex
calls int
}
func (r *errorThenSuccessPoller) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
r.mu.Lock()
r.calls++
call := r.calls
r.mu.Unlock()
if call == 1 {
return questiontable.DispatcherStats{}, errors.New("questiontable dispatcher: claim failed")
}
return questiontable.DispatcherStats{Claimed: 1, Exported: 1}, nil
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"errors"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
type validatedSettingsRepository struct {
*postgres.SettingsRepository
clientID string
secret string
testSupport func(context.Context, settings.ResourceBinding) error
validateTable func(context.Context, settings.ResourceBinding) (map[string]string, error)
saveBinding func(context.Context, settings.ResourceBinding) error
restart func()
restartDelay time.Duration
}
func (r *validatedSettingsRepository) TestAndActivateSupportGroup(ctx context.Context, binding settings.ResourceBinding) error {
if r.testSupport != nil {
if err := r.testSupport(ctx, binding); err != nil {
return errors.New("support_group_test_failed")
}
} else {
sender, err := dingtalk.NewSupportGroupSender(r.clientID, r.secret, binding.ConversationID, nil)
if err != nil {
return errors.New("support_group_test_invalid_config")
}
if err = sender.SendTest(ctx); err != nil {
return errors.New("support_group_test_failed")
}
}
binding.ValidationCode = "support_test_ok"
binding.Activate = true
var err error
if r.saveBinding != nil {
err = r.saveBinding(ctx, binding)
} else if r.SettingsRepository != nil {
err = r.SettingsRepository.SaveBinding(ctx, binding)
} else {
err = errors.New("support_group_binding_repository_unavailable")
}
if err != nil {
return err
}
if r.restart != nil {
go func() {
if r.restartDelay > 0 {
time.Sleep(r.restartDelay)
}
r.restart()
}()
}
return nil
}
func (r *validatedSettingsRepository) SaveBinding(ctx context.Context, binding settings.ResourceBinding) error {
activate := false
switch binding.Kind {
case "question_table", "faq_table":
var mapping map[string]string
var err error
if r.validateTable != nil {
mapping, err = r.validateTable(ctx, binding)
} else {
mapping, err = r.readAndMapTable(ctx, binding)
}
if err != nil {
return err
}
binding.FieldMapping = mapping
binding.ValidationCode = "schema_ok"
binding.Activate = true
activate = true
case "support_group":
binding.ValidationCode = "pending_test_message"
binding.Activate = false
default:
return errors.New("unsupported_binding_kind")
}
var err error
if r.saveBinding != nil {
err = r.saveBinding(ctx, binding)
} else if r.SettingsRepository != nil {
err = r.SettingsRepository.SaveBinding(ctx, binding)
} else {
err = errors.New("binding_repository_unavailable")
}
if err != nil {
return err
}
if activate {
r.requestRestart()
}
return nil
}
func (r *validatedSettingsRepository) readAndMapTable(ctx context.Context, binding settings.ResourceBinding) (map[string]string, error) {
client, err := questiontable.NewClient(questiontable.Config{
ClientID: r.clientID, Secret: r.secret, BaseID: binding.BaseID,
SheetID: binding.SheetID, OperatorID: binding.OperatorID,
}, nil)
if err != nil {
return nil, errors.New("table_binding_invalid_config")
}
fields, err := client.ReadFields(ctx)
if err != nil {
return nil, err
}
required := questiontable.QuestionSchema()
if binding.Kind == "faq_table" {
required = questiontable.FAQSchema()
}
return questiontable.MapRequiredFields(fields, required)
}
func (r *validatedSettingsRepository) requestRestart() {
if r.restart == nil {
return
}
go func() {
if r.restartDelay > 0 {
time.Sleep(r.restartDelay)
}
r.restart()
}()
}
@@ -0,0 +1,117 @@
package main
import (
"context"
"errors"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
func TestTableBindingRestartsOnlyAfterActiveSave(t *testing.T) {
restarted := make(chan struct{}, 1)
var saved settings.ResourceBinding
repo := &validatedSettingsRepository{
validateTable: func(_ context.Context, binding settings.ResourceBinding) (map[string]string, error) {
return map[string]string{"answer": "field-answer"}, nil
},
saveBinding: func(_ context.Context, binding settings.ResourceBinding) error {
saved = binding
return nil
},
restart: func() { restarted <- struct{}{} },
restartDelay: 0,
}
if err := repo.SaveBinding(context.Background(), settings.ResourceBinding{Kind: "question_table", TenantKey: "tenant", BaseID: "base", SheetID: "sheet", OperatorID: "operator"}); err != nil {
t.Fatal(err)
}
if !saved.Activate || saved.ValidationCode != "schema_ok" || saved.FieldMapping["answer"] != "field-answer" {
t.Fatalf("binding=%+v", saved)
}
select {
case <-restarted:
case <-time.After(50 * time.Millisecond):
t.Fatal("restart not requested")
}
}
func TestTableBindingDoesNotRestartOnValidationOrSaveFailure(t *testing.T) {
for _, tc := range []struct {
name string
validateErr error
saveErr error
}{
{name: "validation failure", validateErr: errors.New("schema failed")},
{name: "save failure", saveErr: errors.New("save failed")},
} {
t.Run(tc.name, func(t *testing.T) {
restarted := make(chan struct{}, 1)
repo := &validatedSettingsRepository{
validateTable: func(context.Context, settings.ResourceBinding) (map[string]string, error) {
return map[string]string{"answer": "field"}, tc.validateErr
},
saveBinding: func(context.Context, settings.ResourceBinding) error { return tc.saveErr },
restart: func() { restarted <- struct{}{} },
}
if err := repo.SaveBinding(context.Background(), settings.ResourceBinding{Kind: "question_table"}); err == nil {
t.Fatal("expected failure")
}
select {
case <-restarted:
t.Fatal("must not restart after failed activation")
case <-time.After(50 * time.Millisecond):
}
})
}
}
func TestSupportGroupActivationRestartsOnlyAfterTestAndSaveSucceed(t *testing.T) {
for _, tc := range []struct {
name string
testErr error
saveErr error
wantSaved bool
wantRestart bool
}{
{name: "test fails", testErr: errors.New("send failed")},
{name: "save fails", saveErr: errors.New("save failed"), wantSaved: true},
{name: "success", wantSaved: true, wantRestart: true},
} {
t.Run(tc.name, func(t *testing.T) {
var saved settings.ResourceBinding
restarted := make(chan struct{}, 1)
repo := &validatedSettingsRepository{
testSupport: func(context.Context, settings.ResourceBinding) error { return tc.testErr },
saveBinding: func(_ context.Context, binding settings.ResourceBinding) error {
saved = binding
return tc.saveErr
},
restart: func() { restarted <- struct{}{} },
restartDelay: 0,
}
err := repo.TestAndActivateSupportGroup(context.Background(), settings.ResourceBinding{
Kind: "support_group", TenantKey: "tenant", ConversationID: "group",
})
if (err != nil) != (tc.testErr != nil || tc.saveErr != nil) {
t.Fatalf("err=%v", err)
}
if got := saved.Kind != ""; got != tc.wantSaved {
t.Fatalf("saved=%v want=%v", got, tc.wantSaved)
}
if tc.wantSaved && (saved.ValidationCode != "support_test_ok" || !saved.Activate) {
t.Fatalf("binding=%+v", saved)
}
select {
case <-restarted:
if !tc.wantRestart {
t.Fatal("unexpected restart")
}
case <-time.After(50 * time.Millisecond):
if tc.wantRestart {
t.Fatal("restart not requested")
}
}
})
}
}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
type dingTalkSettingsRouter struct{ controller *settings.Controller }
func (r *dingTalkSettingsRouter) Handle(ctx context.Context, m dingtalk.SettingsMessage) (bool, string, error) {
if r == nil || r.controller == nil {
return false, "", nil
}
if !r.controller.ShouldHandle(ctx, m.TenantKey, m.StaffID, m.Text) {
return false, "", nil
}
out, err := r.controller.Handle(ctx, settings.SettingInput{TenantKey: m.TenantKey, Channel: m.Channel, StaffID: m.StaffID, UnionID: m.UnionID, DisplayName: m.DisplayName, Department: m.Department, Text: m.Text, ConversationType: m.ConversationType, ConversationID: m.ConversationID, ConversationTitle: m.ConversationTitle})
return true, strings.TrimSpace(out), err
}
type settingsRuntime struct {
repository *postgres.SettingsRepository
bindings *validatedSettingsRepository
controller *settings.Controller
admin *adminweb.ControlService
clientID string
secret string
secretRef string
secrets *secretstore.Store
verifier *dingtalkconfig.Verifier
pendingActivation bool
}
func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey string) (*settingsRuntime, error) {
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" {
return nil, errors.New("DOBOTAI_TENANT_KEY is required")
}
repo, err := postgres.NewSettingsRepository(store)
if err != nil {
return nil, err
}
root := strings.TrimSpace(os.Getenv("DOBOTAI_SECRET_DIR"))
if root == "" {
home, homeErr := os.UserHomeDir()
if homeErr != nil {
return nil, homeErr
}
root = filepath.Join(home, ".config", "dobotai", "secrets")
}
secrets, err := secretstore.New(root)
if err != nil {
return nil, err
}
pendingActivation, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey)
if err != nil {
return nil, err
}
clientID, secretRef, _, err := repo.ActiveDingTalkConfig(ctx, tenantKey)
if err != nil {
return nil, err
}
var secret string
if secretRef != "" {
secret, err = secrets.Get(secretRef)
if err != nil {
return nil, errors.New("active DingTalk secret is unavailable")
}
}
if clientID == "" && strings.TrimSpace(os.Getenv("DINGTALK_CLIENT_ID")) != "" && strings.TrimSpace(os.Getenv("DINGTALK_CLIENT_SECRET")) != "" {
clientID, secret = strings.TrimSpace(os.Getenv("DINGTALK_CLIENT_ID")), strings.TrimSpace(os.Getenv("DINGTALK_CLIENT_SECRET"))
secretRef, err = secrets.Put("dingtalk", secret)
if err != nil {
return nil, err
}
if err = repo.SaveDingTalkConfig(ctx, tenantKey, clientID, secretRef, true); err != nil {
_ = secrets.Delete(secretRef)
return nil, err
}
}
verifier := dingtalkconfig.NewVerifier("https://oapi.dingtalk.com/gettoken", nil)
control, err := adminweb.NewControlService(tenantKey, repo, secrets, verifier)
if err != nil {
return nil, err
}
validatedRepo := &validatedSettingsRepository{SettingsRepository: repo, clientID: clientID, secret: secret}
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), admin: control, clientID: clientID, secret: secret, secretRef: secretRef, secrets: secrets, verifier: verifier, pendingActivation: pendingActivation}, nil
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
func newSetupOnlyHandler(runtime *settingsRuntime, token string) (http.Handler, error) {
if runtime == nil || runtime.admin == nil {
return nil, errors.New("setup runtime unavailable")
}
if strings.TrimSpace(token) == "" {
return nil, errors.New("DOBOTAI_ADMIN_TOKEN is required in setup-only mode")
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "mode": "setup_required"})
})
mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(runtimeBuildInfo())
})
mux.Handle("/admin/", adminweb.New(runtime.admin, token))
return mux, nil
}
func runSetupOnly(ctx context.Context, cancel context.CancelFunc, runtime *settingsRuntime, address, tenant string, restartRequested *atomic.Bool) {
runtime.admin.SetCandidateActivator(&dingTalkCandidateActivator{
tenant: tenant, repo: runtime.repository, secrets: runtime.secrets, verifier: runtime.verifier,
bindings: map[string]settings.ResourceBinding{},
restart: func() { restartRequested.Store(true); cancel() },
})
handler, err := newSetupOnlyHandler(runtime, os.Getenv("DOBOTAI_ADMIN_TOKEN"))
if err != nil {
log.Fatal(err)
}
server := &http.Server{Addr: address, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 20 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("DobotAI setup-only listener on %s", address)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("setup HTTP server failed: %v", err)
cancel()
}
}()
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("setup HTTP shutdown: %v", err)
}
if restartRequested.Load() {
os.Exit(75)
}
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type setupRepoStub struct{}
func (setupRepoStub) ActiveDingTalkConfig(context.Context, string) (string, string, bool, error) {
return "", "", false, nil
}
func (setupRepoStub) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
return "", "", nil
}
func (setupRepoStub) SaveDingTalkConfig(context.Context, string, string, string, bool) error {
return nil
}
func (setupRepoStub) MarkDingTalkCandidateFailed(context.Context, string, string, string) error {
return nil
}
func (setupRepoStub) AdministratorCount(context.Context, string) (int, error) { return 0, nil }
func (setupRepoStub) ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error) {
return map[string]settings.ResourceBinding{}, nil
}
func (setupRepoStub) CreateBootstrapChallenge(context.Context, string, string, time.Time) error {
return nil
}
func (setupRepoStub) ConfirmBootstrap(context.Context, string) error { return nil }
type setupVerifierStub struct{}
func (setupVerifierStub) Verify(context.Context, string, string) dingtalkconfig.Result {
return dingtalkconfig.Result{}
}
func setupControl(t *testing.T) *adminweb.ControlService {
t.Helper()
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
control, err := adminweb.NewControlService("tenant", setupRepoStub{}, secrets, setupVerifierStub{})
if err != nil {
t.Fatal(err)
}
return control
}
func TestSetupOnlyHandlerExposesHealthVersionAndProtectedAdmin(t *testing.T) {
runtime := &settingsRuntime{admin: setupControl(t)}
h, err := newSetupOnlyHandler(runtime, "admin-token")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("health status=%d", resp.Code)
}
var health map[string]string
if err = json.Unmarshal(resp.Body.Bytes(), &health); err != nil {
t.Fatal(err)
}
if health["status"] != "ok" || health["mode"] != "setup_required" {
t.Fatalf("health=%v", health)
}
resp = httptest.NewRecorder()
h.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/version", nil))
if resp.Code != http.StatusOK {
t.Fatalf("version status=%d", resp.Code)
}
resp = httptest.NewRecorder()
h.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/admin/settings", nil))
if resp.Code != http.StatusUnauthorized {
t.Fatalf("unauth status=%d", resp.Code)
}
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
resp = httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("auth status=%d body=%s", resp.Code, resp.Body.String())
}
}
func TestSetupOnlyHandlerRequiresAdminToken(t *testing.T) {
_, err := newSetupOnlyHandler(&settingsRuntime{admin: setupControl(t)}, "")
if err == nil {
t.Fatal("expected token error")
}
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"errors"
"log"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
type supportDispatcherRunner interface {
RunOnce(context.Context) (coverage.DispatcherStats, error)
}
func buildSupportDispatcher(repo coverage.NotificationRepository, clientID, clientSecret, openConversationID string, recordLinks *faqtable.RecordLinkBuilder) (*coverage.Dispatcher, error) {
openConversationID = strings.TrimSpace(openConversationID)
if openConversationID == "" {
return nil, nil
}
if repo == nil {
return nil, errors.New("support notification repository is required")
}
if recordLinks == nil {
return nil, nil
}
sender, err := dingtalk.NewSupportGroupSender(clientID, clientSecret, openConversationID, nil, dingtalk.WithFAQRecordLinkBuilder(recordLinks))
if err != nil {
return nil, err
}
return coverage.NewDispatcher(repo, sender)
}
func runSupportDispatcher(ctx context.Context, dispatcher supportDispatcherRunner, interval time.Duration) {
if dispatcher == nil || interval <= 0 {
return
}
runOnce := func() {
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
stats, err := dispatcher.RunOnce(runCtx)
cancel()
if err != nil {
log.Printf("support notification dispatch failed: %v", err)
return
}
if stats.Claimed > 0 {
log.Printf("support notification dispatch: claimed=%d notified=%d failed=%d", stats.Claimed, stats.Notified, stats.Failed)
}
}
runOnce()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runOnce()
}
}
}
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
type supportNotificationRepoStub struct{}
func (*supportNotificationRepoStub) Claim(context.Context, int, int, time.Time) ([]coverage.PendingSupportCase, error) {
return nil, nil
}
func (*supportNotificationRepoStub) MarkNotified(context.Context, string) error { return nil }
func (*supportNotificationRepoStub) MarkFailed(context.Context, string, string) error { return nil }
func testFAQRecordLinks(t *testing.T) *faqtable.RecordLinkBuilder {
t.Helper()
builder, err := faqtable.NewRecordLinkBuilder("base", "sheet", "view")
if err != nil {
t.Fatal(err)
}
return builder
}
type supportDispatcherStub struct {
mu sync.Mutex
calls int
errs []error
callCh chan int
}
func (s *supportDispatcherStub) RunOnce(context.Context) (coverage.DispatcherStats, error) {
s.mu.Lock()
s.calls++
call := s.calls
var err error
if call <= len(s.errs) {
err = s.errs[call-1]
}
s.mu.Unlock()
if s.callCh != nil {
select {
case s.callCh <- call:
default:
}
}
return coverage.DispatcherStats{Claimed: 1, Notified: 1}, err
}
func TestBuildSupportDispatcherDisabledWithoutGroup(t *testing.T) {
dispatcher, err := buildSupportDispatcher(nil, "", "", " ", nil)
if err != nil || dispatcher != nil {
t.Fatalf("dispatcher=%v err=%v, want disabled", dispatcher, err)
}
}
func TestBuildSupportDispatcherBlocksWithoutFAQLinks(t *testing.T) {
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, "client", "secret", "group", nil)
if err != nil || dispatcher != nil {
t.Fatalf("dispatcher=%v err=%v, want blocked without process failure", dispatcher, err)
}
}
func TestBuildSupportDispatcherEnabledFailsClosed(t *testing.T) {
repo := &supportNotificationRepoStub{}
links := testFAQRecordLinks(t)
for _, tc := range []struct {
name string
repo coverage.NotificationRepository
clientID, secret, gid string
links *faqtable.RecordLinkBuilder
}{
{name: "missing repo", clientID: "client", secret: "secret", gid: "group", links: links},
{name: "missing client", repo: repo, secret: "secret", gid: "group", links: links},
{name: "missing secret", repo: repo, clientID: "client", gid: "group", links: links},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := buildSupportDispatcher(tc.repo, tc.clientID, tc.secret, tc.gid, tc.links); err == nil {
t.Fatal("expected configuration error")
}
})
}
}
func TestBuildSupportDispatcherValidDoesNotSend(t *testing.T) {
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, " client ", " secret ", " group ", testFAQRecordLinks(t))
if err != nil {
t.Fatalf("buildSupportDispatcher: %v", err)
}
if dispatcher == nil {
t.Fatal("expected dispatcher")
}
}
func TestRunSupportDispatcherRunsImmediatelyAndStops(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
stub := &supportDispatcherStub{callCh: make(chan int, 4)}
done := make(chan struct{})
go func() {
runSupportDispatcher(ctx, stub, time.Hour)
close(done)
}()
select {
case <-stub.callCh:
case <-time.After(500 * time.Millisecond):
t.Fatal("dispatcher did not run immediately")
}
cancel()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("dispatcher did not stop after context cancellation")
}
}
func TestRunSupportDispatcherContinuesAfterError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stub := &supportDispatcherStub{
errs: []error{errors.New("temporary db error")},
callCh: make(chan int, 4),
}
done := make(chan struct{})
go func() {
runSupportDispatcher(ctx, stub, 10*time.Millisecond)
close(done)
}()
for want := 1; want <= 2; want++ {
select {
case got := <-stub.callCh:
if got != want {
t.Fatalf("call=%d, want %d", got, want)
}
case <-time.After(500 * time.Millisecond):
t.Fatalf("missing dispatcher call %d", want)
}
}
cancel()
<-done
}
func TestRunSupportDispatcherRejectsNonPositiveInterval(t *testing.T) {
stub := &supportDispatcherStub{}
runSupportDispatcher(context.Background(), stub, 0)
stub.mu.Lock()
defer stub.mu.Unlock()
if stub.calls != 0 {
t.Fatalf("calls=%d, want 0", stub.calls)
}
}
func TestMainWiresCoverageSupportNotifications(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID")`,
`coverage.NewService(store)`,
`qa.WithSucceededTurnObserver`,
`case <-runtimeReady:`,
`runSupportDispatcher(ctx, supportDispatcher, 5*time.Second)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main support notification wiring missing %q", fragment)
}
}
}
@@ -0,0 +1,7 @@
-- 000005_answer_feedback down migration
-- Drop in reverse order: indexes first, then table.
DROP INDEX IF EXISTS idx_messages_card_provider_unique;
DROP INDEX IF EXISTS idx_answer_feedback_tenant_reason_updated;
DROP INDEX IF EXISTS idx_answer_feedback_tenant_value_updated;
DROP TABLE IF EXISTS answer_feedback;
@@ -0,0 +1,32 @@
-- 000005_answer_feedback: user feedback on answers via DingTalk card
-- Each (tenant_key, channel, answer_message_id, actor_id) holds the current
-- feedback state: up or down with optional reason_code for down.
-- Depends on: 000004_delivery_audit (and its 000003 message-audit baseline).
CREATE TABLE IF NOT EXISTS answer_feedback (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
answer_message_id TEXT NOT NULL REFERENCES messages(id),
turn_id TEXT NOT NULL REFERENCES turns(id),
actor_id TEXT NOT NULL REFERENCES channel_actors(id),
value TEXT NOT NULL CHECK (value IN ('up', 'down')),
reason_code TEXT NOT NULL DEFAULT '' CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other')),
source TEXT NOT NULL CHECK (source IN ('dingtalk_card')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, answer_message_id, actor_id),
CHECK (value != 'up' OR reason_code = '')
);
-- Queries that filter by value or reason_code within a tenant use these indexes.
CREATE INDEX IF NOT EXISTS idx_answer_feedback_tenant_value_updated
ON answer_feedback (tenant_key, value, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_answer_feedback_tenant_reason_updated
ON answer_feedback (tenant_key, reason_code, updated_at DESC);
-- A DingTalk outTrackId must identify exactly one delivered card answer.
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_card_provider_unique
ON messages (tenant_key, channel, provider_message_id)
WHERE provider_message_id != '' AND delivery_method = 'card';
@@ -0,0 +1,296 @@
// Package migrations_test contains source-level contract tests for the
// 000005_answer_feedback migration. No real database connection is used.
package migrations_test
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func read005UpMigration(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000005_answer_feedback.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
return string(data)
}
func read005DownMigration(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000005_answer_feedback.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
return string(data)
}
// --- RED: These tests will fail until the migration files exist ---
// TestUp005CreatesAnswerFeedbackTable ensures the table is created with IF NOT EXISTS.
func TestUp005CreatesAnswerFeedbackTable(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "CREATE TABLE IF NOT EXISTS ANSWER_FEEDBACK") {
t.Error("up migration must create answer_feedback table with IF NOT EXISTS")
}
}
// TestUp005AnswerFeedbackColumns ensures all required columns are present.
func TestUp005AnswerFeedbackColumns(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
required := []string{
"ID TEXT PRIMARY KEY",
"TENANT_KEY TEXT NOT NULL",
"CHANNEL TEXT NOT NULL",
"ANSWER_MESSAGE_ID TEXT NOT NULL",
"TURN_ID TEXT NOT NULL",
"ACTOR_ID TEXT NOT NULL",
"VALUE TEXT NOT NULL",
"REASON_CODE TEXT NOT NULL DEFAULT ''",
"SOURCE TEXT NOT NULL",
"CREATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW()",
"UPDATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW()",
}
for _, col := range required {
if !strings.Contains(upper, col) {
t.Errorf("up migration must contain column %q", col)
}
}
}
// TestUp005ValueCheck ensures value is constrained to 'up' or 'down'.
func TestUp005ValueCheck(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "VALUE IN ('UP'") || !strings.Contains(upper, "'DOWN'") {
t.Error("value must be CHECK constrained to 'up' and 'down'")
}
}
// TestUp005ReasonCodeCheck ensures reason_code is constrained to allowed values.
func TestUp005ReasonCodeCheck(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
allowed := []string{"''", "INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"}
for _, r := range allowed {
if !strings.Contains(upper, r) {
t.Errorf("reason_code CHECK must include %q", r)
}
}
}
// TestUp005SourceCheck ensures source is constrained to 'dingtalk_card'.
func TestUp005SourceCheck(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "DINGTALK_CARD") {
t.Error("source must be CHECK constrained to 'dingtalk_card'")
}
}
// TestUp005UpValueMustHaveNoReason ensures CHECK that value='up' implies reason_code=”.
func TestUp005UpValueMustHaveNoReason(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Must have a CHECK that when value='up', reason_code must be empty
hasCheck := strings.Contains(upper, "VALUE = 'UP'") && strings.Contains(upper, "REASON_CODE = ''")
// Also check for the inverse: "VALUE != 'UP' OR REASON_CODE = ''"
hasOr := strings.Contains(upper, "OR") && strings.Contains(upper, "REASON_CODE = ''")
if !hasCheck && !hasOr {
t.Error("must have CHECK ensuring value='up' implies reason_code=''")
}
}
// TestUp005ForeignKeyConstraints ensures FK constraints on answer_message_id, turn_id, actor_id.
func TestUp005ForeignKeyConstraints(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Must reference messages(id), turns(id), channel_actors(id)
if !strings.Contains(upper, "REFERENCES MESSAGES(ID)") {
t.Error("answer_message_id must have FK REFERENCES messages(id)")
}
if !strings.Contains(upper, "REFERENCES TURNS(ID)") {
t.Error("turn_id must have FK REFERENCES turns(id)")
}
if !strings.Contains(upper, "REFERENCES CHANNEL_ACTORS(ID)") {
t.Error("actor_id must have FK REFERENCES channel_actors(id)")
}
}
// TestUp005UniqueConstraint ensures UNIQUE on (tenant_key, channel, answer_message_id, actor_id).
func TestUp005UniqueConstraint(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
hasUnique := strings.Contains(upper, "UNIQUE") &&
strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "CHANNEL") &&
strings.Contains(upper, "ANSWER_MESSAGE_ID") &&
strings.Contains(upper, "ACTOR_ID")
if !hasUnique {
t.Error("must have UNIQUE constraint on (tenant_key, channel, answer_message_id, actor_id)")
}
}
// TestUp005Indexes ensures required indexes are created.
func TestUp005Indexes(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Index on (tenant_key, value, updated_at DESC)
hasValueIdx := strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "VALUE") &&
strings.Contains(upper, "UPDATED_AT") &&
strings.Contains(upper, "DESC")
if !hasValueIdx {
t.Error("must have index on (tenant_key, value, updated_at DESC)")
}
// Index on (tenant_key, reason_code, updated_at DESC)
hasReasonIdx := strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "REASON_CODE") &&
strings.Contains(upper, "UPDATED_AT") &&
strings.Contains(upper, "DESC")
if !hasReasonIdx {
t.Error("must have index on (tenant_key, reason_code, updated_at DESC)")
}
// Must use CREATE INDEX IF NOT EXISTS
if got := countMatchesRe(up, `(?i)CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS`); got < 2 {
t.Errorf("expected at least 2 CREATE INDEX IF NOT EXISTS, got %d", got)
}
}
// TestUp005UpMigrationUsesIfNotExists ensures idempotency.
func TestUp005UpMigrationUsesIfNotExists(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if strings.Contains(upper, "CREATE TABLE") && !strings.Contains(upper, "CREATE TABLE IF NOT EXISTS") {
t.Error("CREATE TABLE must use IF NOT EXISTS for idempotency")
}
// Must have exactly 1 CREATE TABLE IF NOT EXISTS
if got := countMatchesRe(up, `(?i)CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS`); got != 1 {
t.Errorf("expected 1 CREATE TABLE IF NOT EXISTS, got %d", got)
}
}
func TestUp005CreatesUniqueCardProviderIndex(t *testing.T) {
up := strings.ToUpper(read005UpMigration(t))
for _, fragment := range []string{
"CREATE UNIQUE INDEX IF NOT EXISTS IDX_MESSAGES_CARD_PROVIDER_UNIQUE",
"ON MESSAGES (TENANT_KEY, CHANNEL, PROVIDER_MESSAGE_ID)",
"PROVIDER_MESSAGE_ID != ''",
"DELIVERY_METHOD = 'CARD'",
} {
if !strings.Contains(up, fragment) {
t.Errorf("provider binding index missing %q", fragment)
}
}
down := strings.ToUpper(read005DownMigration(t))
if !strings.Contains(down, "DROP INDEX IF EXISTS IDX_MESSAGES_CARD_PROVIDER_UNIQUE") {
t.Error("down migration must remove the B2 provider binding index")
}
}
// TestUp005NoForbiddenColumns ensures no secrets/protocol columns.
func TestUp005NoForbiddenColumns(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
forbidden := []string{
"WEBHOOK", "TOKEN", "RAW_CALLBACK", "RAW_PAYLOAD",
"CONTENT", "CALLBACK", "EXTENSION", "CORP_ID",
"SESSIONWEBHOOK", "DOWNLOAD_CODE", "DATAURI", "DATA_URI",
"RAW_RESPONSE", "RAW_ERROR", "PROVIDER_RESPONSE", "API_RESPONSE",
}
for _, col := range forbidden {
if strings.Contains(upper, col) {
t.Errorf("migration must not contain forbidden column %q", col)
}
}
}
// TestUp005DoesNotModifyBaselineTables ensures migration only touches answer_feedback.
func TestUp005DoesNotModifyBaselineTables(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Must not ALTER other tables
baseline := []string{"MESSAGES", "TURNS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS", "SESSION_MAPPINGS"}
for _, tbl := range baseline {
if strings.Contains(upper, "ALTER TABLE "+tbl) {
t.Errorf("migration must not ALTER TABLE %s", tbl)
}
}
}
// TestDown005DropsAnswerFeedbackTable ensures down drops only the new table and indexes.
func TestDown005DropsAnswerFeedbackTable(t *testing.T) {
down := read005DownMigration(t)
upper := strings.ToUpper(down)
if !strings.Contains(upper, "DROP TABLE IF EXISTS ANSWER_FEEDBACK") {
t.Error("down migration must drop answer_feedback with IF EXISTS")
}
}
// TestDown005DoesNotDropBaselineTables ensures down does not touch B1 tables.
func TestDown005DoesNotDropBaselineTables(t *testing.T) {
down := read005DownMigration(t)
upper := strings.ToUpper(down)
baseline := []string{"MESSAGES", "TURNS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS", "SESSION_MAPPINGS"}
for _, tbl := range baseline {
if strings.Contains(upper, "DROP TABLE IF EXISTS "+tbl) {
t.Errorf("down migration must not drop baseline table %s", tbl)
}
}
}
// TestDown005DoesNotDropBaselineColumns ensures down does not drop baseline columns.
func TestDown005DoesNotDropBaselineColumns(t *testing.T) {
down := read005DownMigration(t)
upper := strings.ToUpper(down)
if strings.Contains(upper, "DROP COLUMN") || strings.Contains(upper, "ALTER TABLE") {
t.Error("down migration must not drop/alter columns — only drop the answer_feedback table and indexes")
}
}
// TestUp005NoReservedWords ensures no bare reserved words as column names.
func TestUp005NoReservedWords(t *testing.T) {
up := read005UpMigration(t)
// Check for bare "references" as column name (not in FK context)
bareRefs := regexp.MustCompile(`(?im)^\s*REFERENCES\s+`)
if bareRefs.MatchString(up) {
lines := strings.Split(up, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
upperLine := strings.ToUpper(trimmed)
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "FOREIGN KEY") {
t.Errorf("reserved word 'references' used as column name: %s", trimmed)
}
}
}
}
// --- Helpers ---
func countMatchesRe(haystack, pattern string) int {
return len(regexp.MustCompile(pattern).FindAllString(haystack, -1))
}
@@ -0,0 +1,29 @@
-- 000006_native_card_feedback_reasons down migration
-- A schema rollback cannot retain values rejected by the legacy constraint,
-- so map each native reason to its closest B2 legacy category first.
UPDATE answer_feedback
SET reason_code = CASE reason_code
WHEN 'content_mismatch' THEN 'irrelevant'
WHEN 'content_incomplete' THEN 'not_resolved'
WHEN 'illogical_expression' THEN 'unclear'
WHEN 'factual_error' THEN 'incorrect'
WHEN 'sensitive_content' THEN 'other'
WHEN 'answer_not_wanted' THEN 'not_resolved'
ELSE reason_code
END
WHERE reason_code IN (
'content_mismatch',
'content_incomplete',
'illogical_expression',
'factual_error',
'sensitive_content',
'answer_not_wanted'
);
ALTER TABLE answer_feedback
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
ALTER TABLE answer_feedback
ADD CONSTRAINT answer_feedback_reason_code_check
CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other'));
@@ -0,0 +1,24 @@
-- 000006_native_card_feedback_reasons up migration
-- Preserve the original B2 reasons and add the six exact reason codes emitted
-- by DingTalk's built-in AI feedback component.
ALTER TABLE answer_feedback
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
ALTER TABLE answer_feedback
ADD CONSTRAINT answer_feedback_reason_code_check
CHECK (reason_code IN (
'',
'incorrect',
'not_resolved',
'irrelevant',
'unclear',
'outdated',
'other',
'content_mismatch',
'content_incomplete',
'illogical_expression',
'factual_error',
'sensitive_content',
'answer_not_wanted'
));
@@ -0,0 +1,78 @@
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read006Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000006_native_card_feedback_reasons."+direction+".sql"))
if err != nil {
t.Fatalf("read 000006 %s migration: %v", direction, err)
}
return strings.Join(strings.Fields(strings.ToUpper(string(data))), " ")
}
func TestUp006ExpandsNamedReasonConstraint(t *testing.T) {
up := read006Migration(t, "up")
for _, fragment := range []string{
"ALTER TABLE ANSWER_FEEDBACK DROP CONSTRAINT IF EXISTS ANSWER_FEEDBACK_REASON_CODE_CHECK",
"ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK",
"CHECK (REASON_CODE IN",
"CONTENT_MISMATCH",
"CONTENT_INCOMPLETE",
"ILLOGICAL_EXPRESSION",
"FACTUAL_ERROR",
"SENSITIVE_CONTENT",
"ANSWER_NOT_WANTED",
} {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
for _, legacy := range []string{"INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"} {
if !strings.Contains(up, legacy) {
t.Errorf("up migration must preserve legacy reason %q", legacy)
}
}
}
func TestUp006OnlyChangesReasonConstraint(t *testing.T) {
up := read006Migration(t, "up")
for _, forbidden := range []string{"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM", "UPDATE ANSWER_FEEDBACK"} {
if strings.Contains(up, forbidden) {
t.Errorf("up migration contains forbidden operation %q", forbidden)
}
}
}
func TestDown006MapsNativeReasonsBeforeRestoringConstraint(t *testing.T) {
down := read006Migration(t, "down")
mappings := map[string]string{
"CONTENT_MISMATCH": "IRRELEVANT",
"CONTENT_INCOMPLETE": "NOT_RESOLVED",
"ILLOGICAL_EXPRESSION": "UNCLEAR",
"FACTUAL_ERROR": "INCORRECT",
"SENSITIVE_CONTENT": "OTHER",
"ANSWER_NOT_WANTED": "NOT_RESOLVED",
}
updateAt := strings.Index(down, "UPDATE ANSWER_FEEDBACK")
constraintAt := strings.Index(down, "ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK")
if updateAt < 0 || constraintAt < 0 || updateAt > constraintAt {
t.Fatal("down migration must map native reasons before restoring the legacy constraint")
}
for from, to := range mappings {
if !strings.Contains(down, "WHEN '"+from+"' THEN '"+to+"'") {
t.Errorf("down migration missing mapping %s -> %s", from, to)
}
}
for _, native := range []string{"CONTENT_MISMATCH", "CONTENT_INCOMPLETE", "ILLOGICAL_EXPRESSION", "FACTUAL_ERROR", "SENSITIVE_CONTENT", "ANSWER_NOT_WANTED"} {
constraint := down[constraintAt:]
if strings.Contains(constraint, native) {
t.Errorf("legacy down constraint must exclude native reason %q", native)
}
}
}
@@ -0,0 +1,5 @@
-- 000007_coverage_decisions down migration
-- Drop support_cases first (child table), then coverage_decisions (parent table).
DROP TABLE IF EXISTS support_cases;
DROP TABLE IF EXISTS coverage_decisions;
@@ -0,0 +1,49 @@
-- 000007_coverage_decisions up migration
-- Creates coverage_decisions and support_cases tables for B3.1 persistence.
CREATE TABLE IF NOT EXISTS coverage_decisions (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
evaluator_version TEXT NOT NULL DEFAULT 'v1',
verdict TEXT NOT NULL CHECK (verdict IN ('covered', 'uncovered')),
reason_code TEXT NOT NULL CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer'
)),
reference_count INTEGER NOT NULL DEFAULT 0 CHECK (reference_count >= 0),
top_score DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_tenant_channel
ON coverage_decisions (tenant_key, channel, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_verdict
ON coverage_decisions (verdict) WHERE verdict = 'uncovered';
CREATE TABLE IF NOT EXISTS support_cases (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
coverage_decision_id TEXT NOT NULL REFERENCES coverage_decisions(id),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'notifying', 'notification_failed', 'notified', 'resolved')),
notification_attempts INTEGER NOT NULL DEFAULT 0 CHECK (notification_attempts >= 0),
last_notification_failure_code TEXT NOT NULL DEFAULT '',
last_notification_at TIMESTAMPTZ,
notified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
CREATE INDEX IF NOT EXISTS idx_support_cases_notification_pending
ON support_cases (status, created_at, id) WHERE status IN ('pending', 'notification_failed');
CREATE INDEX IF NOT EXISTS idx_support_cases_notifying_stale
ON support_cases (status, last_notification_at) WHERE status = 'notifying';
@@ -0,0 +1,179 @@
// Package migrations_test contains source-level contract tests for the
// 000007_coverage_decisions migration. No real database connection is used.
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read007Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000007_coverage_decisions."+direction+".sql"))
if err != nil {
t.Fatalf("read 000007 %s migration: %v", direction, err)
}
return string(data)
}
func TestUp007CreatesCoverageDecisionsTable(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
required := []string{
"CREATE TABLE IF NOT EXISTS COVERAGE_DECISIONS",
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
"EVALUATOR_VERSION", "VERDICT", "REASON_CODE", "REFERENCE_COUNT", "TOP_SCORE",
"CREATED_AT", "UPDATED_AT",
}
for _, fragment := range required {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
}
func TestUp007CoverageDecisionsConstraints(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
// REFERENCES turns(id)
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
t.Error("coverage_decisions.turn_id must reference turns(id)")
}
// UNIQUE (tenant_key, channel, turn_id)
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
t.Error("coverage_decisions must have UNIQUE (tenant_key, channel, turn_id)")
}
// verdict CHECK
if !strings.Contains(up, "COVERED") || !strings.Contains(up, "UNCOVERED") {
t.Error("verdict CHECK must include 'covered' and 'uncovered'")
}
// reason_code CHECK
for _, code := range []string{"COVERED_HAS_REFERENCES", "UNCOVERED_NO_REFERENCES", "UNCOVERED_EXPLICIT_NO_ANSWER"} {
if !strings.Contains(up, code) {
t.Errorf("reason_code CHECK missing %q", code)
}
}
}
func TestUp007CreatesSupportCasesTable(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
required := []string{
"CREATE TABLE IF NOT EXISTS SUPPORT_CASES",
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
"COVERAGE_DECISION_ID", "STATUS", "CREATED_AT", "UPDATED_AT",
}
for _, fragment := range required {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
// Notification fields must be present
notificationFields := []string{
"NOTIFICATION_ATTEMPTS", "LAST_NOTIFICATION_FAILURE_CODE",
"LAST_NOTIFICATION_AT", "NOTIFIED_AT",
}
for _, fragment := range notificationFields {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing notification field %q", fragment)
}
}
}
func TestUp007SupportCasesConstraints(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
// FK to coverage_decisions
if !strings.Contains(up, "REFERENCES COVERAGE_DECISIONS(ID)") {
t.Error("support_cases.coverage_decision_id must reference coverage_decisions(id)")
}
// FK to turns
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
t.Error("support_cases.turn_id must reference turns(id)")
}
// UNIQUE (tenant_key, channel, turn_id)
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
t.Error("support_cases must have UNIQUE (tenant_key, channel, turn_id)")
}
// status CHECK
for _, status := range []string{"PENDING", "NOTIFYING", "NOTIFICATION_FAILED", "NOTIFIED", "RESOLVED"} {
if !strings.Contains(up, status) {
t.Errorf("status CHECK must include %q", status)
}
}
}
func TestUp007CreatesQueryIndexes(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
// Pending/retry query index and stale notifying index
if !strings.Contains(up, "IDX_SUPPORT_CASES_NOTIFICATION_PENDING") ||
!strings.Contains(up, "WHERE STATUS IN ('PENDING', 'NOTIFICATION_FAILED')") {
t.Error("up migration must index pending and notification_failed support_cases")
}
if !strings.Contains(up, "IDX_SUPPORT_CASES_NOTIFYING_STALE") ||
!strings.Contains(up, "WHERE STATUS = 'NOTIFYING'") {
t.Error("up migration must index stale notifying support_cases")
}
// Coverage decisions listing index
if !strings.Contains(up, "IDX_COVERAGE_DECISIONS_TENANT_CHANNEL") {
t.Error("up migration must create index on coverage_decisions (tenant_key, channel, created_at DESC)")
}
// Uncovered verdict index
if !strings.Contains(up, "WHERE VERDICT = 'UNCOVERED'") {
t.Error("up migration must create partial index on uncovered coverage_decisions")
}
}
func TestUp007CoverageNoReservedWords(t *testing.T) {
up := read007Migration(t, "up")
// Check for bare "references" as column name in CREATE TABLE statements
lines := strings.Split(up, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
upperLine := strings.ToUpper(trimmed)
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "REFERENCES TURNS") && !strings.Contains(upperLine, "REFERENCES COVERAGE") {
t.Errorf("reserved word 'references' used as column name in: %s", trimmed)
}
}
}
func TestUp007NoForbiddenOperations(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
for _, forbidden := range []string{
"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM",
"ALTER TABLE", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
"RAW_RESPONSE", "RAW_ERROR",
} {
if strings.Contains(up, forbidden) {
t.Errorf("up migration contains forbidden operation %q", forbidden)
}
}
}
func TestUp007NoQuestionAnswerBodies(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
for _, forbidden := range []string{"QUESTION", "ANSWER_TEXT", "REFERENCE_CONTENT", "ANSWER_CONTENT"} {
if strings.Contains(up, forbidden) {
t.Errorf("coverage/support tables must not duplicate question/answer/reference bodies: found %q", forbidden)
}
}
}
func TestDown007DropsInSafeOrder(t *testing.T) {
down := strings.ToUpper(read007Migration(t, "down"))
supportAt := strings.Index(down, "DROP TABLE IF EXISTS SUPPORT_CASES")
coverageAt := strings.Index(down, "DROP TABLE IF EXISTS COVERAGE_DECISIONS")
if supportAt < 0 || coverageAt < 0 {
t.Fatal("down migration must drop both tables")
}
if supportAt > coverageAt {
t.Fatal("down migration must drop support_cases (child) before coverage_decisions (parent)")
}
}
func TestDown007DoesNotDropBaselineTables(t *testing.T) {
down := strings.ToUpper(read007Migration(t, "down"))
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
t.Errorf("down migration must not drop baseline table %q", table)
}
}
}
@@ -0,0 +1,2 @@
ALTER TABLE session_mappings
DROP COLUMN IF EXISTS display_title;
@@ -0,0 +1,2 @@
ALTER TABLE session_mappings
ADD COLUMN IF NOT EXISTS display_title TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,28 @@
package migrations
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSessionDisplayTitleMigration(t *testing.T) {
up, err := os.ReadFile(filepath.Join("000008_session_display_title.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
down, err := os.ReadFile(filepath.Join("000008_session_display_title.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
upper := strings.ToUpper(string(up))
for _, required := range []string{"ALTER TABLE SESSION_MAPPINGS", "ADD COLUMN IF NOT EXISTS DISPLAY_TITLE", "TEXT NOT NULL", "DEFAULT ''"} {
if !strings.Contains(upper, required) {
t.Errorf("up migration missing %q", required)
}
}
if !strings.Contains(strings.ToUpper(string(down)), "DROP COLUMN IF EXISTS DISPLAY_TITLE") {
t.Error("down migration must drop display_title")
}
}
@@ -0,0 +1,7 @@
ALTER TABLE messages
DROP COLUMN IF EXISTS region_snapshot;
ALTER TABLE messages
DROP COLUMN IF EXISTS department_snapshot;
DROP TABLE IF EXISTS directory_profiles;
@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS directory_profiles (
tenant_key TEXT NOT NULL,
staff_id TEXT NOT NULL,
union_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
nickname TEXT NOT NULL DEFAULT '',
job_title TEXT NOT NULL DEFAULT '',
department_ids JSONB NOT NULL DEFAULT '[]'::JSONB,
department_name TEXT NOT NULL DEFAULT '',
main_department_name TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
employment_status TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL CHECK (source IN ('full_sync','on_demand')),
synced_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_key, staff_id)
);
CREATE INDEX IF NOT EXISTS idx_directory_profiles_tenant_name
ON directory_profiles (tenant_key, name);
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS department_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS region_snapshot TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,42 @@
package migrations
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDirectoryProfilesMigration(t *testing.T) {
up, err := os.ReadFile(filepath.Join("000009_directory_profiles.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
down, err := os.ReadFile(filepath.Join("000009_directory_profiles.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
upper := strings.ToUpper(string(up))
for _, required := range []string{
"CREATE TABLE IF NOT EXISTS DIRECTORY_PROFILES",
"PRIMARY KEY (TENANT_KEY, STAFF_ID)",
"DEPARTMENT_IDS JSONB",
"REGION TEXT",
"SOURCE TEXT",
"ADD COLUMN IF NOT EXISTS DEPARTMENT_SNAPSHOT",
"ADD COLUMN IF NOT EXISTS REGION_SNAPSHOT",
} {
if !strings.Contains(upper, required) {
t.Errorf("up migration missing %q", required)
}
}
for _, forbidden := range []string{"MOBILE", "EMAIL", "CLIENT_SECRET", "ACCESS_TOKEN", "RAW_PAYLOAD"} {
if strings.Contains(upper, forbidden) {
t.Errorf("up migration contains forbidden sensitive field %q", forbidden)
}
}
downUpper := strings.ToUpper(string(down))
if !strings.Contains(downUpper, "DROP TABLE IF EXISTS DIRECTORY_PROFILES") || !strings.Contains(downUpper, "DROP COLUMN IF EXISTS REGION_SNAPSHOT") {
t.Error("down migration must remove directory table and snapshot columns")
}
}
@@ -0,0 +1,9 @@
-- 000010_question_table_exports down migration
-- Drop in reverse dependency order: first drop export table, then remove column
DROP TABLE IF EXISTS question_table_exports;
ALTER TABLE messages
DROP COLUMN IF EXISTS trigger_person_snapshot;
ALTER TABLE messages
DROP COLUMN IF EXISTS actor_union_id_snapshot;
@@ -0,0 +1,40 @@
-- 000010_question_table_exports: durable outbox for Notable question table async sync
-- Add trigger_person_snapshot and actor_union_id_snapshot to messages and create question_table_exports table
-- 1. Add trigger_person_snapshot and actor_union_id_snapshot columns to messages
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS trigger_person_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS actor_union_id_snapshot TEXT NOT NULL DEFAULT '';
-- 2. Create question_table_exports outbox table
CREATE TABLE IF NOT EXISTS question_table_exports (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'exporting', 'exported', 'export_failed')),
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TIMESTAMPTZ,
next_attempt_at TIMESTAMPTZ,
exported_at TIMESTAMPTZ,
failure_code TEXT NOT NULL DEFAULT '',
external_record_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
-- 3. Index for claim query: pending/export_failed/exporting ordered by created_at
-- Covers pending, export_failed (when next_attempt_at is NULL or <= NOW()),
-- and stale exporting (when last_attempt_at < staleBefore).
CREATE INDEX IF NOT EXISTS idx_question_table_exports_claim
ON question_table_exports (status, next_attempt_at, created_at, id)
WHERE status IN ('pending', 'export_failed')
OR (status = 'exporting' AND last_attempt_at IS NOT NULL);
-- 4. Index for stale exporting recovery
CREATE INDEX IF NOT EXISTS idx_question_table_exports_stale
ON question_table_exports (status, last_attempt_at)
WHERE status = 'exporting';
@@ -0,0 +1,161 @@
// Package migrations_test contains source-level contract tests for the
// 000010_question_table_exports migration. No real database connection is used.
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read010Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000010_question_table_exports."+direction+".sql"))
if err != nil {
t.Fatalf("read 000010 %s migration: %v", direction, err)
}
return string(data)
}
func TestUp010AddsTriggerPersonSnapshot(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "ALTER TABLE MESSAGES") {
t.Fatal("up migration must alter messages")
}
if !strings.Contains(up, "ADD COLUMN IF NOT EXISTS TRIGGER_PERSON_SNAPSHOT") {
t.Error("up migration must add trigger_person_snapshot column")
}
if !strings.Contains(up, "ADD COLUMN IF NOT EXISTS ACTOR_UNION_ID_SNAPSHOT") {
t.Error("up migration must add actor_union_id_snapshot column")
}
if !strings.Contains(up, "TEXT NOT NULL DEFAULT ''") {
t.Error("snapshot columns must be TEXT NOT NULL DEFAULT ''")
}
}
func TestUp010CreatesQuestionTableExports(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
required := []string{
"CREATE TABLE IF NOT EXISTS QUESTION_TABLE_EXPORTS",
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
"STATUS", "ATTEMPTS", "LAST_ATTEMPT_AT", "NEXT_ATTEMPT_AT", "EXPORTED_AT",
"FAILURE_CODE", "EXTERNAL_RECORD_ID",
"CREATED_AT", "UPDATED_AT",
}
for _, fragment := range required {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
}
func TestUp010QuestionTableExportsConstraints(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
// FK to turns
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
t.Error("question_table_exports.turn_id must reference turns(id)")
}
// UNIQUE (tenant_key, channel, turn_id)
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
t.Error("question_table_exports must have UNIQUE (tenant_key, channel, turn_id)")
}
// status CHECK
for _, status := range []string{"PENDING", "EXPORTING", "EXPORTED", "EXPORT_FAILED"} {
if !strings.Contains(up, status) {
t.Errorf("status CHECK must include %q", status)
}
}
}
func TestUp010ClaimIndexes(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "IDX_QUESTION_TABLE_EXPORTS_CLAIM") {
t.Error("up migration must create claim index")
}
if !strings.Contains(up, "WHERE STATUS IN ('PENDING', 'EXPORT_FAILED')") {
t.Error("claim index must include pending and export_failed statuses")
}
if !strings.Contains(up, "IDX_QUESTION_TABLE_EXPORTS_STALE") {
t.Error("up migration must create stale exporting index")
}
if !strings.Contains(up, "WHERE STATUS = 'EXPORTING'") {
t.Error("stale index must filter exporting status")
}
}
func TestUp010HasNextAttemptAtColumn(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "NEXT_ATTEMPT_AT TIMESTAMPTZ") {
t.Error("question_table_exports must have next_attempt_at column for retry backoff")
}
}
func TestUp010HasExternalRecordID(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "EXTERNAL_RECORD_ID TEXT NOT NULL DEFAULT ''") {
t.Error("question_table_exports must have external_record_id column (not a1_notation)")
}
if strings.Contains(up, "A1_NOTATION") {
t.Error("question_table_exports must not have stale a1_notation column")
}
}
func TestUp010ClaimIndexCoversNextAttemptAt(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "NEXT_ATTEMPT_AT") {
t.Error("claim index must cover next_attempt_at for retry backoff filtering")
}
}
func TestDown010DropsInSafeOrder(t *testing.T) {
down := strings.ToUpper(read010Migration(t, "down"))
dropExportAt := strings.Index(down, "DROP TABLE IF EXISTS QUESTION_TABLE_EXPORTS")
dropTriggerAt := strings.Index(down, "DROP COLUMN IF EXISTS TRIGGER_PERSON_SNAPSHOT")
dropUnionAt := strings.Index(down, "DROP COLUMN IF EXISTS ACTOR_UNION_ID_SNAPSHOT")
if dropExportAt < 0 || dropTriggerAt < 0 || dropUnionAt < 0 {
t.Fatal("down migration must drop export table and both snapshot columns")
}
if dropExportAt > dropTriggerAt || dropTriggerAt > dropUnionAt {
t.Fatal("down migration must drop export table before snapshot columns")
}
}
func TestDown010DoesNotDropBaselineTables(t *testing.T) {
down := strings.ToUpper(read010Migration(t, "down"))
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
t.Errorf("down migration must not drop baseline table %q", table)
}
}
}
func TestUp010ExportsNoFormulaColumns(t *testing.T) {
up := read010Migration(t, "up")
// Check that the CREATE TABLE body does not contain user/AI content columns
lines := strings.Split(up, "\n")
inTable := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
upper := strings.ToUpper(trimmed)
if strings.HasPrefix(upper, "CREATE TABLE IF NOT EXISTS QUESTION_TABLE_EXPORTS") {
inTable = true
continue
}
if inTable && trimmed == ");" {
inTable = false
continue
}
if !inTable {
continue
}
for _, forbidden := range []string{
"QUESTION", "ANSWER_TEXT", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
"RAW_RESPONSE", "RAW_ERROR", "ACCESS_TOKEN", "APP_SECRET",
} {
if strings.Contains(upper, forbidden) {
t.Errorf("question_table_exports column must not contain %q", forbidden)
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
DROP INDEX IF EXISTS idx_support_cases_faq_export_stale;
DROP INDEX IF EXISTS idx_support_cases_faq_export_claim;
ALTER TABLE support_cases
DROP COLUMN IF EXISTS faq_record_id,
DROP COLUMN IF EXISTS faq_failure_code,
DROP COLUMN IF EXISTS faq_exported_at,
DROP COLUMN IF EXISTS faq_next_attempt_at,
DROP COLUMN IF EXISTS faq_last_attempt_at,
DROP COLUMN IF EXISTS faq_export_attempts,
DROP COLUMN IF EXISTS faq_export_status;
+24
View File
@@ -0,0 +1,24 @@
-- Durable FAQ export state for uncovered support cases.
ALTER TABLE support_cases
ADD COLUMN IF NOT EXISTS faq_export_status TEXT NOT NULL DEFAULT 'not_applicable'
CHECK (faq_export_status IN ('not_applicable', 'pending', 'exporting', 'exported', 'export_failed')),
ADD COLUMN IF NOT EXISTS faq_export_attempts INTEGER NOT NULL DEFAULT 0
CHECK (faq_export_attempts >= 0),
ADD COLUMN IF NOT EXISTS faq_last_attempt_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS faq_next_attempt_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS faq_exported_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS faq_failure_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS faq_record_id TEXT NOT NULL DEFAULT '';
-- Existing cases are intentionally not backfilled. Only cases created after
-- this migration enter the FAQ workflow automatically.
ALTER TABLE support_cases
ALTER COLUMN faq_export_status SET DEFAULT 'pending';
CREATE INDEX IF NOT EXISTS idx_support_cases_faq_export_claim
ON support_cases (faq_export_status, faq_next_attempt_at, created_at, id)
WHERE faq_export_status IN ('pending', 'export_failed');
CREATE INDEX IF NOT EXISTS idx_support_cases_faq_export_stale
ON support_cases (faq_export_status, faq_last_attempt_at)
WHERE faq_export_status = 'exporting';
@@ -0,0 +1,74 @@
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read011Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000011_faq_exports."+direction+".sql"))
if err != nil {
t.Fatalf("read 000011 %s migration: %v", direction, err)
}
return strings.ToUpper(string(data))
}
func TestUp011AddsDurableFAQExportStateToSupportCases(t *testing.T) {
up := read011Migration(t, "up")
for _, fragment := range []string{
"ALTER TABLE SUPPORT_CASES",
"FAQ_EXPORT_STATUS", "FAQ_EXPORT_ATTEMPTS", "FAQ_LAST_ATTEMPT_AT",
"FAQ_NEXT_ATTEMPT_AT", "FAQ_EXPORTED_AT", "FAQ_FAILURE_CODE", "FAQ_RECORD_ID",
"PENDING", "EXPORTING", "EXPORTED", "EXPORT_FAILED",
} {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
for _, forbidden := range []string{"FAQ_QUESTION", "FAQ_ANSWER", "RAW_RESPONSE", "ACCESS_TOKEN"} {
if strings.Contains(up, forbidden) {
t.Errorf("migration must not duplicate business or secret field %q", forbidden)
}
}
}
func TestUp011DoesNotBackfillHistoricalSupportCases(t *testing.T) {
up := read011Migration(t, "up")
if !strings.Contains(up, "DEFAULT 'NOT_APPLICABLE'") {
t.Fatal("existing support cases must start as not_applicable")
}
if !strings.Contains(up, "ALTER COLUMN FAQ_EXPORT_STATUS SET DEFAULT 'PENDING'") {
t.Fatal("future support cases must default to pending")
}
}
func TestUp011AddsBoundedClaimIndexes(t *testing.T) {
up := read011Migration(t, "up")
for _, fragment := range []string{
"IDX_SUPPORT_CASES_FAQ_EXPORT_CLAIM",
"FAQ_NEXT_ATTEMPT_AT", "FAQ_LAST_ATTEMPT_AT",
"WHERE FAQ_EXPORT_STATUS IN ('PENDING', 'EXPORT_FAILED')",
} {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing claim index fragment %q", fragment)
}
}
}
func TestDown011OnlyRemovesFAQExportState(t *testing.T) {
down := read011Migration(t, "down")
for _, column := range []string{
"FAQ_EXPORT_STATUS", "FAQ_EXPORT_ATTEMPTS", "FAQ_LAST_ATTEMPT_AT",
"FAQ_NEXT_ATTEMPT_AT", "FAQ_EXPORTED_AT", "FAQ_FAILURE_CODE", "FAQ_RECORD_ID",
} {
if !strings.Contains(down, "DROP COLUMN IF EXISTS "+column) {
t.Errorf("down migration missing %s", column)
}
}
if strings.Contains(down, "DROP TABLE") {
t.Fatal("down migration must not drop baseline tables")
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS config_audit_events; DROP TABLE IF EXISTS system_resource_bindings; DROP TABLE IF EXISTS administrators; DROP TABLE IF EXISTS system_config_revisions;
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS system_config_revisions (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0),
state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')), public_config JSONB NOT NULL DEFAULT '{}'::JSONB,
secret_ref TEXT NOT NULL DEFAULT '', validation_status TEXT NOT NULL DEFAULT 'pending', validation_code TEXT NOT NULL DEFAULT '',
validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ, created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_key,kind,revision));
CREATE UNIQUE INDEX IF NOT EXISTS uq_system_config_active ON system_config_revisions(tenant_key,kind) WHERE state='active';
CREATE TABLE IF NOT EXISTS administrators (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, union_id TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '',
department TEXT NOT NULL DEFAULT '', role TEXT NOT NULL CHECK(role IN ('super_admin','admin')), status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','disabled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,staff_id));
CREATE TABLE IF NOT EXISTS system_resource_bindings (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0), state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')),
public_config JSONB NOT NULL DEFAULT '{}'::JSONB, validation_code TEXT NOT NULL DEFAULT '', validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ,
created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,kind,revision));
CREATE UNIQUE INDEX IF NOT EXISTS uq_resource_binding_active ON system_resource_bindings(tenant_key,kind) WHERE state='active';
CREATE TABLE IF NOT EXISTS config_audit_events (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, actor_staff_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, target_kind TEXT NOT NULL,
target_revision INTEGER NOT NULL DEFAULT 0, result_code TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
@@ -0,0 +1,25 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp012CreatesSettingsWithoutSecrets(t *testing.T) {
b, e := os.ReadFile("000012_system_settings.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
for _, x := range []string{"SYSTEM_CONFIG_REVISIONS", "ADMINISTRATORS", "SYSTEM_RESOURCE_BINDINGS", "CONFIG_AUDIT_EVENTS", "PUBLIC_CONFIG"} {
if !strings.Contains(s, x) {
t.Errorf("missing %s", x)
}
}
for _, x := range []string{"CLIENT_SECRET JSON", "ACCESS_TOKEN JSON", "API_KEY JSON"} {
if strings.Contains(s, x) {
t.Errorf("forbidden %s", x)
}
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS admin_bootstrap_challenges;
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS admin_bootstrap_challenges (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, code_hash TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts>=0), max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts BETWEEN 1 AND 10),
candidate_staff_id TEXT NOT NULL DEFAULT '', candidate_union_id TEXT NOT NULL DEFAULT '', candidate_display_name TEXT NOT NULL DEFAULT '', candidate_department TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK(status IN ('pending','candidate_detected','confirmed','expired','locked')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
CREATE UNIQUE INDEX IF NOT EXISTS uq_admin_bootstrap_pending ON admin_bootstrap_challenges(tenant_key) WHERE status IN ('pending','candidate_detected');
@@ -0,0 +1,18 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp013StoresOnlyBootstrapHash(t *testing.T) {
b, e := os.ReadFile("000013_admin_bootstrap.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
if !strings.Contains(s, "CODE_HASH") || strings.Contains(s, " CODE TEXT") {
t.Fatal("bootstrap must store hash only")
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS setting_sessions;
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS setting_sessions (
tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, conversation_id TEXT NOT NULL, conversation_type TEXT NOT NULL CHECK(conversation_type IN ('direct','group')),
current_step TEXT NOT NULL, pending_action TEXT NOT NULL DEFAULT '', draft_config JSONB NOT NULL DEFAULT '{}'::JSONB, expires_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY(tenant_key,staff_id));
CREATE INDEX IF NOT EXISTS idx_setting_sessions_expiry ON setting_sessions(expires_at);
@@ -0,0 +1,20 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp014CreatesExpiringSettingSessions(t *testing.T) {
b, e := os.ReadFile("000014_setting_sessions.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
for _, x := range []string{"SETTING_SESSIONS", "EXPIRES_AT", "DRAFT_CONFIG"} {
if !strings.Contains(s, x) {
t.Errorf("missing %s", x)
}
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS user_preferences;
@@ -0,0 +1,3 @@
CREATE TABLE IF NOT EXISTS user_preferences (
tenant_key TEXT NOT NULL, channel TEXT NOT NULL, external_actor_id TEXT NOT NULL, language_mode TEXT NOT NULL DEFAULT 'auto' CHECK(language_mode IN ('auto','fixed')),
locale TEXT NOT NULL DEFAULT 'auto' CHECK(locale IN ('auto','zh-CN','en','ja','ko','es')), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY(tenant_key,channel,external_actor_id));
@@ -0,0 +1,20 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp015ScopesPreferencesByTenantAndActor(t *testing.T) {
b, e := os.ReadFile("000015_user_preferences.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
for _, x := range []string{"USER_PREFERENCES", "TENANT_KEY", "EXTERNAL_ACTOR_ID", "LANGUAGE_MODE", "LOCALE"} {
if !strings.Contains(s, x) {
t.Errorf("missing %s", x)
}
}
}
@@ -0,0 +1,3 @@
-- 000016_question_image_urls down migration.
ALTER TABLE turns
DROP COLUMN IF EXISTS question_image_urls_json;
@@ -0,0 +1,6 @@
-- 000016_question_image_urls: stable published URLs for inbound question images.
-- Stores the object-storage HTTPS URLs of images a user sent in a question,
-- so the question-table export can render a readable, traceable record without
-- persisting download codes or data URIs.
ALTER TABLE turns
ADD COLUMN IF NOT EXISTS question_image_urls_json JSONB NOT NULL DEFAULT '[]'::JSONB;
@@ -0,0 +1,3 @@
-- 000017_knowledge_searched down migration.
ALTER TABLE turns
DROP COLUMN IF EXISTS knowledge_searched;
@@ -0,0 +1,6 @@
-- 000017_knowledge_searched: records whether a turn actually triggered a
-- knowledge-base search (knowledge_search tool call). Coverage classification
-- uses this to distinguish pure visual recognition from a searched-but-missed
-- knowledge question.
ALTER TABLE turns
ADD COLUMN IF NOT EXISTS knowledge_searched BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,11 @@
-- 000018_coverage_visual_recognition_reason down migration.
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer'
));
@@ -0,0 +1,14 @@
-- 000018_coverage_visual_recognition_reason: extend the coverage_decisions
-- reason_code allowlist with the new image-visual-recognition reason emitted by
-- coverage Evaluator v3 (visual recognition is not a knowledge-coverage miss).
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition'
));
+1
View File
@@ -56,6 +56,7 @@ No WeKnora source patch is allowed as a workaround.
- `internal/qa`: business orchestration and terminal-state ownership.
- `internal/session`: external-to-WeKnora session policy.
- `internal/store/postgres`: DobotAI business persistence only.
- `internal/coverage`: v1 covered/uncovered evaluation, durable support-case dispatch, finite retries, and stable failure codes. Notification failures never change a successful QA result.
- `internal/channels/dingtalk`: DingTalk Stream ingress (official SDK), text-only robot callbacks, fail-closed session webhook replies (`https`, `*.dingtalk.com`, no redirects). Wired in `cmd/dobotai`; enabled only when `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, and `DOBOTAI_TENANT_KEY` are all set. Group messages require an @-mention; direct chats map to one long-lived session per user, groups to one shared session per group. Images, OpenAPI fallback, and card replies are not implemented yet.
- future `channels/*`: platform callbacks and outbound replies.
- future `content-sync/*`: DingTalk KB, NAS, and CMS synchronization.
+2
View File
@@ -3,6 +3,7 @@ module gitea.zheromiku.top/Weknora/dobotai-platform
go 1.25.0
require (
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1
github.com/jackc/pgx/v5 v5.9.2
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
)
@@ -15,4 +16,5 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.4.0 // indirect
)
+4
View File
@@ -1,3 +1,5 @@
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1 h1:vtiFd0hhPAbyYJjztl0wYUq/PqEGkIlDmVuTIy6zw8Y=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -26,6 +28,8 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY=
golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+205
View File
@@ -0,0 +1,205 @@
package adminweb
import (
"context"
"errors"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type ConfigRepository interface {
ActiveDingTalkConfig(context.Context, string) (string, string, bool, error)
CandidateDingTalkConfig(context.Context, string) (string, string, error)
SaveDingTalkConfig(context.Context, string, string, string, bool) error
MarkDingTalkCandidateFailed(context.Context, string, string, string) error
AdministratorCount(context.Context, string) (int, error)
ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error)
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
ConfirmBootstrap(context.Context, string) error
}
type AdministratorRepository interface {
ListAdministrators(context.Context, string) ([]settings.Administrator, error)
DisableAdministrator(context.Context, string, string) error
}
type CandidateActivator interface{ ActivateCandidate(context.Context) error }
type OperationalStatusProvider interface {
Components(context.Context) ([]Component, error)
}
type CredentialVerifier interface {
Verify(context.Context, string, string) dingtalkconfig.Result
}
type ControlService struct {
tenant string
repo ConfigRepository
secrets *secretstore.Store
verifier CredentialVerifier
activator CandidateActivator
status OperationalStatusProvider
}
func NewControlService(tenant string, repo ConfigRepository, secrets *secretstore.Store, verifier CredentialVerifier) (*ControlService, error) {
if strings.TrimSpace(tenant) == "" || repo == nil || secrets == nil || verifier == nil {
return nil, errors.New("admin control dependencies are required")
}
return &ControlService{tenant: strings.TrimSpace(tenant), repo: repo, secrets: secrets, verifier: verifier}, nil
}
func (s *ControlService) Snapshot(ctx context.Context) (Snapshot, error) {
id, ref, verified, err := s.repo.ActiveDingTalkConfig(ctx, s.tenant)
if err != nil {
return Snapshot{}, err
}
admins, err := s.repo.AdministratorCount(ctx, s.tenant)
if err != nil {
return Snapshot{}, err
}
bindings, err := s.repo.ActiveBindings(ctx, s.tenant)
if err != nil {
return Snapshot{}, err
}
administratorViews := []AdministratorView{}
if administratorRepo, ok := s.repo.(AdministratorRepository); ok {
administrators, listErr := administratorRepo.ListAdministrators(ctx, s.tenant)
if listErr != nil {
return Snapshot{}, listErr
}
activeCount := 0
for _, administrator := range administrators {
if administrator.Status == "active" {
activeCount++
}
}
for _, administrator := range administrators {
administratorViews = append(administratorViews, AdministratorView{
StaffID: administrator.StaffID, StaffIDMasked: maskID(administrator.StaffID),
DisplayName: administrator.DisplayName, Department: administrator.Department,
Role: administrator.Role, Status: administrator.Status,
CanDisable: administrator.Status == "active" && activeCount > 1,
})
}
}
state := settings.DetermineState(settings.Snapshot{ClientID: id, SecretConfigured: s.secrets.Configured(ref), CredentialsVerified: verified, AdministratorCount: admins})
now := time.Now()
components := []Component{
{Name: "配置仓库", State: "ok", Code: "repository_ok", Detail: "PostgreSQL配置查询成功", LastSuccess: now, CheckedAt: now},
{Name: "钉钉OAuth", State: status(verified), Code: ternaryCode(verified, "oauth_verified", "oauth_unconfigured"), Detail: "当前应用凭据验证状态", CheckedAt: now},
{Name: "管理员", State: countStatus(admins), Code: ternaryCode(admins > 0, "admin_configured", "admin_missing"), Detail: itoa(admins) + " 位有效管理员", CheckedAt: now},
{Name: "技术支持群绑定", State: bindingStatus(bindings, "support_group"), Code: bindingCode(bindings, "support_group"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
{Name: "问题AI表格绑定", State: bindingStatus(bindings, "question_table"), Code: bindingCode(bindings, "question_table"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
{Name: "FAQ表格绑定", State: bindingStatus(bindings, "faq_table"), Code: bindingCode(bindings, "faq_table"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
}
if s.status != nil {
operational, probeErr := s.status.Components(ctx)
if probeErr != nil {
components = append(components, Component{Name: "运行探针", State: "error", Code: "component_probe_failed", Detail: "运行状态探针失败;未显示原始错误", CheckedAt: time.Now()})
} else {
components = append(components, operational...)
}
}
return Snapshot{SystemState: string(state), ClientIDMasked: maskID(id), SecretConfigured: s.secrets.Configured(ref), Components: components, Administrators: administratorViews}, nil
}
func (s *ControlService) VerifyCredentials(ctx context.Context, id, secret string) Verification {
r := s.verifier.Verify(ctx, id, secret)
return Verification{OK: r.OK, Code: r.Code, CheckedAt: r.CheckedAt}
}
func (s *ControlService) SaveCredentials(ctx context.Context, id, secret string) error {
_, oldRef, _ := s.repo.CandidateDingTalkConfig(ctx, s.tenant)
ref, err := s.secrets.Put("dingtalk", secret)
if err != nil {
return err
}
if err = s.repo.SaveDingTalkConfig(ctx, s.tenant, strings.TrimSpace(id), ref, false); err != nil {
_ = s.secrets.Delete(ref)
return err
}
if oldRef != "" && oldRef != ref {
_ = s.secrets.Delete(oldRef)
}
return nil
}
func (s *ControlService) SetCandidateActivator(activator CandidateActivator) { s.activator = activator }
func (s *ControlService) SetOperationalStatusProvider(provider OperationalStatusProvider) {
s.status = provider
}
func (s *ControlService) ActivateCandidate(ctx context.Context) error {
if s.activator == nil {
return errors.New("candidate activator unavailable")
}
_, ref, err := s.repo.CandidateDingTalkConfig(ctx, s.tenant)
if err != nil {
return err
}
if err = s.activator.ActivateCandidate(ctx); err != nil {
_ = s.repo.MarkDingTalkCandidateFailed(ctx, s.tenant, ref, "activation_failed")
return errors.New("candidate activation failed")
}
return nil
}
func (s *ControlService) CreateBootstrap(ctx context.Context, code string) error {
return s.repo.CreateBootstrapChallenge(ctx, s.tenant, code, time.Now().Add(10*time.Minute))
}
func (s *ControlService) ConfirmBootstrap(ctx context.Context) error {
return s.repo.ConfirmBootstrap(ctx, s.tenant)
}
func (s *ControlService) DisableAdministrator(ctx context.Context, staffID string) error {
staffID = strings.TrimSpace(staffID)
if staffID == "" {
return settings.ErrAdministratorNotFound
}
repo, ok := s.repo.(AdministratorRepository)
if !ok {
return errors.New("administrator management unavailable")
}
return repo.DisableAdministrator(ctx, s.tenant, staffID)
}
func maskID(v string) string {
v = strings.TrimSpace(v)
if len(v) <= 6 {
return v
}
return v[:3] + "***" + v[len(v)-3:]
}
func status(ok bool) string {
if ok {
return "ok"
}
return "unconfigured"
}
func countStatus(n int) string {
if n > 0 {
return "ok"
}
return "unconfigured"
}
func bindingStatus(m map[string]settings.ResourceBinding, k string) string {
if _, ok := m[k]; ok {
return "configured"
}
return "unconfigured"
}
func bindingCode(m map[string]settings.ResourceBinding, k string) string {
if _, ok := m[k]; ok {
return "binding_active"
}
return "binding_missing"
}
func ternaryCode(ok bool, success, failure string) string {
if ok {
return success
}
return failure
}
func itoa(n int) string {
if n == 0 {
return "0"
}
b := make([]byte, 0, 8)
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
+210
View File
@@ -0,0 +1,210 @@
package adminweb
import (
"context"
"errors"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type controlRepo struct {
savedVerified bool
savedRef string
candidateRef string
failedCode string
admins []settings.Administrator
disabledStaff string
disableErr error
}
func (*controlRepo) ActiveDingTalkConfig(context.Context, string) (string, string, bool, error) {
return "old", "old-ref", true, nil
}
func (r *controlRepo) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
return "new", r.candidateRef, nil
}
func (r *controlRepo) SaveDingTalkConfig(_ context.Context, _, _, ref string, verified bool) error {
r.savedRef = ref
r.savedVerified = verified
r.candidateRef = ref
return nil
}
func (r *controlRepo) MarkDingTalkCandidateFailed(_ context.Context, _, _, code string) error {
r.failedCode = code
return nil
}
func (r *controlRepo) AdministratorCount(context.Context, string) (int, error) {
if r.admins == nil {
return 1, nil
}
n := 0
for _, admin := range r.admins {
if admin.Status == "active" {
n++
}
}
return n, nil
}
func (r *controlRepo) ListAdministrators(context.Context, string) ([]settings.Administrator, error) {
return r.admins, nil
}
func (r *controlRepo) DisableAdministrator(_ context.Context, _, staffID string) error {
r.disabledStaff = staffID
return r.disableErr
}
func (*controlRepo) ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error) {
return map[string]settings.ResourceBinding{}, nil
}
func (*controlRepo) CreateBootstrapChallenge(context.Context, string, string, time.Time) error {
return nil
}
func (*controlRepo) ConfirmBootstrap(context.Context, string) error { return nil }
type verifyOK struct{}
func (verifyOK) Verify(context.Context, string, string) dingtalkconfig.Result {
return dingtalkconfig.Result{OK: true, Code: "oauth_ok"}
}
type activatorStub struct {
err error
calls int
}
func (a *activatorStub) ActivateCandidate(context.Context) error { a.calls++; return a.err }
type operationalStatusStub struct {
components []Component
err error
}
func (s operationalStatusStub) Components(context.Context) ([]Component, error) {
return s.components, s.err
}
func TestSnapshotIncludesOperationalComponents(t *testing.T) {
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
service, err := NewControlService("tenant", &controlRepo{}, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
checked := time.Unix(100, 0)
service.SetOperationalStatusProvider(operationalStatusStub{components: []Component{{
Name: "WeKnora", State: "ok", Code: "health_ok", CheckedAt: checked,
}}})
snapshot, err := service.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
found := false
for _, component := range snapshot.Components {
if component.Name == "WeKnora" && component.Code == "health_ok" && component.CheckedAt.Equal(checked) {
found = true
}
}
if !found {
t.Fatalf("components=%+v", snapshot.Components)
}
}
func TestSnapshotKeepsPageAvailableWhenOperationalProbeFails(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
service, err := NewControlService("tenant", &controlRepo{}, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
service.SetOperationalStatusProvider(operationalStatusStub{err: errors.New("probe failed")})
snapshot, err := service.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
last := snapshot.Components[len(snapshot.Components)-1]
if last.Name != "运行探针" || last.State != "error" || last.Code != "component_probe_failed" {
t.Fatalf("component=%+v", last)
}
}
func TestSaveCredentialsCreatesCandidateNotActive(t *testing.T) {
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
repo := &controlRepo{}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
if err = service.SaveCredentials(context.Background(), "new", "secret"); err != nil {
t.Fatal(err)
}
if repo.savedVerified {
t.Fatal("candidate must not be active")
}
if repo.savedRef == "" || !secrets.Configured(repo.savedRef) {
t.Fatalf("ref=%q", repo.savedRef)
}
}
func TestActivateCandidateDelegatesAndMarksFailure(t *testing.T) {
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
repo := &controlRepo{candidateRef: "candidate-ref"}
activator := &activatorStub{err: errors.New("candidate_start_failed_rolled_back")}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
service.SetCandidateActivator(activator)
if err = service.ActivateCandidate(context.Background()); err == nil {
t.Fatal("expected failure")
}
if activator.calls != 1 || repo.failedCode != "activation_failed" {
t.Fatalf("calls=%d code=%q", activator.calls, repo.failedCode)
}
}
func TestSnapshotIncludesAdministratorsAndOnlyAllowsDisableWhenAnotherIsActive(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
repo := &controlRepo{admins: []settings.Administrator{
{StaffID: "staff-old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active"},
{StaffID: "staff-new", DisplayName: "新管理员", Department: "产品部", Role: "super_admin", Status: "active"},
{StaffID: "staff-off", DisplayName: "停用管理员", Role: "admin", Status: "disabled"},
}}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
snapshot, err := service.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if len(snapshot.Administrators) != 3 || !snapshot.Administrators[0].CanDisable || !snapshot.Administrators[1].CanDisable || snapshot.Administrators[2].CanDisable {
t.Fatalf("administrators=%+v", snapshot.Administrators)
}
if snapshot.Administrators[0].StaffIDMasked == "staff-old" || snapshot.Administrators[0].StaffIDMasked == "" {
t.Fatalf("staff ID must be masked: %+v", snapshot.Administrators[0])
}
}
func TestControlServiceDelegatesAdministratorDisable(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
repo := &controlRepo{}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
if err = service.DisableAdministrator(context.Background(), "staff-old"); err != nil {
t.Fatal(err)
}
if repo.disabledStaff != "staff-old" {
t.Fatalf("disabled=%q", repo.disabledStaff)
}
}
+253
View File
@@ -0,0 +1,253 @@
package adminweb
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"html/template"
"net/http"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type Snapshot struct {
SystemState, ClientIDMasked string
SecretConfigured bool
Components []Component
Administrators []AdministratorView
}
type AdministratorView struct {
StaffID, StaffIDMasked, DisplayName, Department, Role, Status string
CanDisable bool
}
type Component struct {
Name, State, Code, Detail string
LastSuccess, CheckedAt time.Time
}
type Verification struct {
OK bool
Code string
CheckedAt time.Time
}
type Service interface {
Snapshot(context.Context) (Snapshot, error)
SaveCredentials(context.Context, string, string) error
VerifyCredentials(context.Context, string, string) Verification
}
type BootstrapService interface {
CreateBootstrap(context.Context, string) error
ConfirmBootstrap(context.Context) error
}
type CandidateService interface {
ActivateCandidate(context.Context) error
}
type AdministratorService interface {
DisableAdministrator(context.Context, string) error
}
type server struct {
service Service
token string
csrfToken string
mux *http.ServeMux
page *template.Template
}
type pageData struct {
Snapshot
CSRFToken string
}
const settingsPage = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>DobotAI 设置</title>
<style>
body{font-family:system-ui,sans-serif;background:#f5f8ff;color:#17324d;max-width:1080px;margin:36px auto;padding:0 20px;line-height:1.55}
.card{background:#fff;border:1px solid #dbe7f4;border-radius:14px;padding:20px;margin:14px 0;box-shadow:0 4px 16px rgba(32,77,120,.05)}
input{box-sizing:border-box;padding:10px;width:100%;margin:6px 0 12px;border:1px solid #bfd0e2;border-radius:8px}
button{padding:10px 18px;background:#1677ff;color:#fff;border:0;border-radius:8px;margin:5px 0;cursor:pointer}.danger{background:#c73535}.muted{color:#55708a}.warn{background:#fff7e6;border-left:4px solid #f0a020;padding:10px 12px;border-radius:6px}.meta{color:#55708a;font-size:13px;line-height:1.6}
table{width:100%;border-collapse:collapse;margin-top:12px}th,td{text-align:left;padding:10px;border-bottom:1px solid #e4edf6;vertical-align:middle}.inline{margin:0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}@media(max-width:760px){.grid{grid-template-columns:1fr}table{font-size:13px}}
</style>
</head>
<body>
<h1>DobotAI 配置中心</h1>
<div class="card"><b>系统状态:</b>{{.SystemState}}<br><b>Client ID:</b>{{.ClientIDMasked}}<br><b>Client Secret:</b>{{if .SecretConfigured}}已配置(不可查看){{else}}未配置{{end}}</div>
<div class="card"><h2>配置/切换钉钉应用</h2><p>新凭据先在线验证并保存为候选。激活后受控重启整组钉钉组件,失败自动回滚。</p><form method="post" action="/admin/setup/credentials"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Client ID / AppKey</label><input name="client_id" required><label>Client Secret / AppSecret</label><input type="password" name="client_secret" required autocomplete="new-password"><button>验证并保存候选</button></form><form method="post" action="/admin/setup/candidate/activate"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button>激活候选并重启</button></form></div>
<div class="card"><h2>新增/更换管理员</h2><p class="warn"><b>必须先加后停:</b>先确认新管理员已生效,再停用旧管理员。系统禁止停用最后一位有效管理员。</p><ol><li>输入任意6位一次性绑定码并生成,有效期10分钟。</li><li>新管理员私聊机器人发送 <code>/setting 6位码</code>。</li><li>回到本页确认检测到的新管理员。</li><li>在下方确认新管理员状态为active,再停用旧管理员。</li></ol><div class="grid"><form method="post" action="/admin/setup/bootstrap"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>一次性绑定码</label><input name="code" pattern="[0-9]{6}" placeholder="输入任意6位数字" required><button>生成6位绑定码</button></form><form method="post" action="/admin/setup/bootstrap/confirm"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><p class="muted">新管理员私聊验证后再点击:</p><button>确认检测到的新管理员</button></form></div><h3>管理员列表</h3><table><thead><tr><th>姓名</th><th>部门</th><th>Staff ID</th><th>角色</th><th>状态</th><th>操作</th></tr></thead><tbody>{{range .Administrators}}<tr><td>{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td><td>{{if .Department}}{{.Department}}{{else}}—{{end}}</td><td>{{.StaffIDMasked}}</td><td>{{.Role}}</td><td>{{.Status}}</td><td>{{if .CanDisable}}<form class="inline" method="post" action="/admin/settings/administrators/disable"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="staff_id" value="{{.StaffID}}"><button class="danger" type="submit">停用此管理员</button></form>{{else}}—{{end}}</td></tr>{{else}}<tr><td colspan="6" class="muted">暂无管理员记录</td></tr>{{end}}</tbody></table></div>
<h2>组件检查</h2>{{range .Components}}<div class="card"><b>{{.Name}}</b>:{{.State}}<div class="meta">错误码:{{if .Code}}{{.Code}}{{else}}—{{end}}<br>最后成功:{{timeText .LastSuccess}}<br>最后检查:{{timeText .CheckedAt}}</div><p>{{.Detail}}</p></div>{{end}}
</body></html>`
func New(service Service, token string) http.Handler {
functions := template.FuncMap{"timeText": func(value time.Time) string {
if value.IsZero() {
return "—"
}
return value.UTC().Format("2006-01-02 15:04:05")
}}
csrfBytes := make([]byte, 32)
if _, err := rand.Read(csrfBytes); err != nil {
panic("admin CSRF token unavailable")
}
s := &server{service: service, token: strings.TrimSpace(token), csrfToken: hex.EncodeToString(csrfBytes), mux: http.NewServeMux(), page: template.Must(template.New("settings").Funcs(functions).Parse(settingsPage))}
s.mux.HandleFunc("GET /admin/settings", s.settings)
s.mux.HandleFunc("GET /admin/setup", s.settings)
s.mux.HandleFunc("POST /admin/setup/credentials", s.saveCredentials)
s.mux.HandleFunc("POST /admin/setup/candidate/activate", s.activateCandidate)
s.mux.HandleFunc("POST /admin/setup/bootstrap", s.createBootstrap)
s.mux.HandleFunc("POST /admin/setup/bootstrap/confirm", s.confirmBootstrap)
s.mux.HandleFunc("POST /admin/settings/administrators/disable", s.disableAdministrator)
return s
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !s.authorized(r) {
w.Header().Set("WWW-Authenticate", `Basic realm="DobotAI Admin"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
s.mux.ServeHTTP(w, r)
}
func (s *server) authorized(r *http.Request) bool {
if s.token == "" {
return false
}
if _, password, ok := r.BasicAuth(); ok && len(password) == len(s.token) && subtle.ConstantTimeCompare([]byte(password), []byte(s.token)) == 1 {
return true
}
v := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
return len(v) == len(s.token) && subtle.ConstantTimeCompare([]byte(v), []byte(s.token)) == 1
}
func (s *server) requireCSRFForm(w http.ResponseWriter, r *http.Request, maxBytes int64) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return false
}
provided := strings.TrimSpace(r.FormValue("csrf_token"))
if len(provided) != len(s.csrfToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.csrfToken)) != 1 {
http.Error(w, "forbidden", http.StatusForbidden)
return false
}
return true
}
func (s *server) settings(w http.ResponseWriter, r *http.Request) {
snap, err := s.service.Snapshot(r.Context())
if err != nil {
http.Error(w, "settings unavailable", 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = s.page.Execute(w, pageData{Snapshot: snap, CSRFToken: s.csrfToken})
}
func (s *server) saveCredentials(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 16<<10) {
return
}
id, secret := strings.TrimSpace(r.FormValue("client_id")), strings.TrimSpace(r.FormValue("client_secret"))
result := s.service.VerifyCredentials(r.Context(), id, secret)
if !result.OK {
http.Error(w, fmt.Sprintf("credential verification failed: %s", result.Code), 422)
return
}
if err := s.service.SaveCredentials(r.Context(), id, secret); err != nil {
http.Error(w, "credential save failed", 500)
return
}
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
}
func (s *server) activateCandidate(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(CandidateService)
if !ok {
http.Error(w, "candidate unavailable", http.StatusNotImplemented)
return
}
if err := service.ActivateCandidate(r.Context()); err != nil {
http.Error(w, "candidate activation failed", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("候选已激活,DobotAI正在受控重启。\n"))
}
func (s *server) createBootstrap(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(BootstrapService)
if !ok {
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
return
}
code := strings.TrimSpace(r.FormValue("code"))
if !sixDigits(code) {
http.Error(w, "binding code must be 6 digits", 400)
return
}
if err := service.CreateBootstrap(r.Context(), code); err != nil {
http.Error(w, "create binding code failed", 500)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "请在10分钟内私聊机器人发送:/setting %s\n", code)
}
func (s *server) confirmBootstrap(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(BootstrapService)
if !ok {
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
return
}
if err := service.ConfirmBootstrap(r.Context()); err != nil {
http.Error(w, "尚未检测到可确认的管理员", http.StatusConflict)
return
}
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
}
func (s *server) disableAdministrator(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(AdministratorService)
if !ok {
http.Error(w, "administrator management unavailable", http.StatusNotImplemented)
return
}
staffID := strings.TrimSpace(r.FormValue("staff_id"))
if staffID == "" {
http.Error(w, "administrator not found", http.StatusNotFound)
return
}
err := service.DisableAdministrator(r.Context(), staffID)
switch {
case err == nil:
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
case errors.Is(err, settings.ErrLastAdministrator):
http.Error(w, "至少保留一位有效管理员;请先确认新管理员已生效。", http.StatusConflict)
case errors.Is(err, settings.ErrAdministratorAlreadyDisabled):
http.Error(w, "管理员已经停用。", http.StatusConflict)
case errors.Is(err, settings.ErrAdministratorNotFound):
http.Error(w, "administrator not found", http.StatusNotFound)
default:
http.Error(w, "administrator update failed", http.StatusInternalServerError)
}
}
func sixDigits(value string) bool {
if len(value) != 6 {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
+166
View File
@@ -0,0 +1,166 @@
package adminweb
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type fakeService struct {
snapshot Snapshot
verified bool
saved bool
disabled string
disableErr error
}
func (f *fakeService) Snapshot(context.Context) (Snapshot, error) { return f.snapshot, nil }
func (f *fakeService) SaveCredentials(context.Context, string, string) error {
f.saved = true
return nil
}
func (f *fakeService) VerifyCredentials(context.Context, string, string) Verification {
f.verified = true
return Verification{OK: true, Code: "ok", CheckedAt: time.Unix(1, 0)}
}
func (f *fakeService) DisableAdministrator(_ context.Context, staffID string) error {
f.disabled = staffID
return f.disableErr
}
func csrfTokenFromPage(t *testing.T, h http.Handler) string {
t.Helper()
get := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
get.Header.Set("Authorization", "Bearer admin-token")
page := httptest.NewRecorder()
h.ServeHTTP(page, get)
marker := `name="csrf_token" value="`
start := strings.Index(page.Body.String(), marker)
if start < 0 {
t.Fatalf("csrf token missing: %s", page.Body.String())
}
start += len(marker)
end := strings.Index(page.Body.String()[start:], `"`)
if end < 0 {
t.Fatal("csrf token terminator missing")
}
return page.Body.String()[start : start+end]
}
func TestDashboardNeverRendersSecrets(t *testing.T) {
svc := &fakeService{snapshot: Snapshot{SystemState: "active", ClientIDMasked: "ding***", SecretConfigured: true}}
h := New(svc, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status=%d", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "admin-token") || strings.Contains(strings.ToLower(body), "client secret value") {
t.Fatal("secret leaked")
}
if !strings.Contains(body, "ding***") {
t.Fatal("masked client id missing")
}
}
func TestDashboardRendersOperationalComponentFields(t *testing.T) {
checked := time.Date(2026, 8, 13, 18, 0, 0, 0, time.UTC)
svc := &fakeService{snapshot: Snapshot{Components: []Component{{
Name: "WeKnora", State: "ok", Code: "health_ok", Detail: "HTTP健康探针正常",
LastSuccess: checked.Add(-time.Minute), CheckedAt: checked,
}}}}
h := New(svc, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
body := w.Body.String()
for _, want := range []string{"WeKnora", "health_ok", "HTTP健康探针正常", "2026-08-13 17:59:00", "2026-08-13 18:00:00"} {
if !strings.Contains(body, want) {
t.Fatalf("dashboard missing %q: %s", want, body)
}
}
}
func TestSaveCredentialsRequiresVerification(t *testing.T) {
svc := &fakeService{}
h := New(svc, "admin-token")
csrf := csrfTokenFromPage(t, h)
req := httptest.NewRequest(http.MethodPost, "/admin/setup/credentials", strings.NewReader("client_id=id&client_secret=secret&csrf_token="+csrf))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther || !svc.verified || !svc.saved {
t.Fatalf("status=%d verified=%v saved=%v", w.Code, svc.verified, svc.saved)
}
}
func TestAllAdminPOSTRoutesRejectMissingCSRF(t *testing.T) {
svc := &fakeService{}
h := New(svc, "admin-token")
for _, route := range []string{"/admin/setup/credentials", "/admin/setup/candidate/activate", "/admin/setup/bootstrap", "/admin/setup/bootstrap/confirm", "/admin/settings/administrators/disable"} {
req := httptest.NewRequest(http.MethodPost, route, strings.NewReader("client_id=id&client_secret=secret&code=123456&staff_id=staff-old"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("route=%s status=%d body=%q", route, w.Code, w.Body.String())
}
}
if svc.saved || svc.disabled != "" {
t.Fatalf("business service called saved=%v disabled=%q", svc.saved, svc.disabled)
}
}
func TestDashboardRendersAdministratorReplacementWorkflow(t *testing.T) {
svc := &fakeService{snapshot: Snapshot{Administrators: []AdministratorView{{StaffID: "staff-old", StaffIDMasked: "sta***old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active", CanDisable: true}}}}
h := New(svc, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
for _, want := range []string{"新增/更换管理员", "先加后停", "旧管理员", "技术部", "sta***old", "生成6位绑定码", "停用此管理员"} {
if !strings.Contains(w.Body.String(), want) {
t.Fatalf("dashboard missing %q: %s", want, w.Body.String())
}
}
if strings.Contains(w.Body.String(), ">staff-old<") {
t.Fatalf("raw staff ID must not be rendered as visible text: %s", w.Body.String())
}
}
func TestDisableAdministratorRequiresCSRFAndMapsLastAdminConflict(t *testing.T) {
svc := &fakeService{}
h := New(svc, "admin-token")
post := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/admin/settings/administrators/disable", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
if w := post("staff_id=staff-old"); w.Code != http.StatusForbidden || svc.disabled != "" {
t.Fatalf("missing csrf status=%d disabled=%q", w.Code, svc.disabled)
}
csrf := csrfTokenFromPage(t, h)
svc.disableErr = settings.ErrLastAdministrator
w := post("staff_id=staff-old&csrf_token=" + csrf)
if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "至少保留一位有效管理员") {
t.Fatalf("last-admin status=%d body=%q", w.Code, w.Body.String())
}
svc.disableErr = nil
w = post("staff_id=staff-old&csrf_token=" + csrf)
if w.Code != http.StatusSeeOther || svc.disabled != "staff-old" {
t.Fatalf("success status=%d disabled=%q body=%q", w.Code, svc.disabled, w.Body.String())
}
}
+44 -30
View File
@@ -25,19 +25,22 @@ const (
// It must never contain full question/answer text, error_message, WeKnora
// session/request IDs, or provider_message_id.
type TurnSummary struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Channel string `json:"channel"`
Status TurnStatus `json:"status"`
ConversationType string `json:"conversation_type"`
ConversationTitle string `json:"conversation_title,omitempty"`
ActorExternalID string `json:"actor_external_id,omitempty"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
QuestionSummary string `json:"question_summary"`
AnswerSummary string `json:"answer_summary"`
ReferenceCount int `json:"reference_count"`
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Channel string `json:"channel"`
Status TurnStatus `json:"status"`
ConversationType string `json:"conversation_type"`
ConversationTitle string `json:"conversation_title,omitempty"`
ActorExternalID string `json:"actor_external_id,omitempty"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
QuestionSummary string `json:"question_summary"`
AnswerSummary string `json:"answer_summary"`
ReferenceCount int `json:"reference_count"`
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
FeedbackUpCount int `json:"feedback_up_count"`
FeedbackDownCount int `json:"feedback_down_count"`
FeedbackUnresolvedCount int `json:"feedback_unresolved_count"`
}
// SafeReference is a reference with only safe fields exposed.
@@ -72,27 +75,38 @@ type MessageMeta struct {
FailureCode string `json:"failure_code,omitempty"`
}
// AnswerFeedback is the safe current feedback state for one actor. Provider
// tracking IDs, internal IDs and raw callback fields are intentionally absent.
type AnswerFeedback struct {
ActorExternalID string `json:"actor_external_id"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
Value string `json:"value"`
ReasonCode string `json:"reason_code"`
UpdatedAt time.Time `json:"updated_at"`
}
// TurnDetail is the full detail view of a turn. It must never include
// error_message, WeKnora session/request ID, provider_message_id,
// sessionWebhook, tokens, raw API/error, or Reference.Content/Raw.
type TurnDetail struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Channel string `json:"channel"`
Status TurnStatus `json:"status"`
ConversationType string `json:"conversation_type"`
ConversationTitle string `json:"conversation_title,omitempty"`
ActorExternalID string `json:"actor_external_id,omitempty"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
Question string `json:"question"`
Answer string `json:"answer"`
References []SafeReference `json:"references,omitempty"`
Media []SafeMedia `json:"media,omitempty"`
RequestMessage *MessageMeta `json:"request_message,omitempty"`
AnswerMessage *MessageMeta `json:"answer_message,omitempty"`
ReferenceCount int `json:"reference_count"`
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Channel string `json:"channel"`
Status TurnStatus `json:"status"`
ConversationType string `json:"conversation_type"`
ConversationTitle string `json:"conversation_title,omitempty"`
ActorExternalID string `json:"actor_external_id,omitempty"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
Question string `json:"question"`
Answer string `json:"answer"`
References []SafeReference `json:"references,omitempty"`
Media []SafeMedia `json:"media,omitempty"`
RequestMessage *MessageMeta `json:"request_message,omitempty"`
AnswerMessage *MessageMeta `json:"answer_message,omitempty"`
ReferenceCount int `json:"reference_count"`
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
Feedback []AnswerFeedback `json:"feedback,omitempty"`
}
// ListTurnsRequest is the input for listing turns.
+43
View File
@@ -0,0 +1,43 @@
package audit
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestFeedbackProjectionJSONUsesOnlySafeFields(t *testing.T) {
updated := time.Date(2026, 7, 21, 15, 0, 0, 0, time.UTC)
detail := TurnDetail{Feedback: []AnswerFeedback{{
ActorExternalID: "user-1", ActorDisplayName: "Alice", Value: "down", ReasonCode: "not_resolved", UpdatedAt: updated,
}}}
raw, err := json.Marshal(detail)
if err != nil {
t.Fatal(err)
}
body := string(raw)
for _, want := range []string{`"feedback"`, `"actor_external_id":"user-1"`, `"actor_display_name":"Alice"`, `"value":"down"`, `"reason_code":"not_resolved"`, `"updated_at"`} {
if !strings.Contains(body, want) {
t.Errorf("feedback JSON missing %s: %s", want, body)
}
}
for _, forbidden := range []string{"out_track", "provider_message", "answer_message_id", "actor_id", "token", "callback", "params"} {
if strings.Contains(strings.ToLower(body), forbidden) {
t.Errorf("feedback JSON leaked %q: %s", forbidden, body)
}
}
}
func TestTurnSummaryFeedbackCountsJSON(t *testing.T) {
raw, err := json.Marshal(TurnSummary{FeedbackUpCount: 2, FeedbackDownCount: 3, FeedbackUnresolvedCount: 1})
if err != nil {
t.Fatal(err)
}
body := string(raw)
for _, want := range []string{`"feedback_up_count":2`, `"feedback_down_count":3`, `"feedback_unresolved_count":1`} {
if !strings.Contains(body, want) {
t.Errorf("summary JSON missing %s: %s", want, body)
}
}
}
+71 -17
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
@@ -17,8 +18,9 @@ import (
)
const (
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
)
// CardRequest is the input for sending a final AI card.
@@ -39,26 +41,38 @@ type CardSender struct {
streamURL string
apiClient *http.Client
allowTestURL bool
debugUserID string
mu sync.Mutex
token string
tokenTill time.Time
}
// CardSenderOption configures optional CardSender behavior.
type CardSenderOption func(*CardSender)
// WithCardDebugUserID enables DingTalk's official card debug entry only for
// the matching user through per-user privateData. A blank value keeps it off.
func WithCardDebugUserID(userID string) CardSenderOption {
return func(sender *CardSender) {
sender.debugUserID = strings.TrimSpace(userID)
}
}
// NewCardSender creates a CardSender. All three config values are required
// after trimming whitespace. A nil http.Client installs a hardened default
// (no redirects, Proxy=nil, 15s timeout).
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client) (*CardSender, error) {
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false)
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, options ...CardSenderOption) (*CardSender, error) {
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false, options...)
}
// newCardSenderForTest is used by tests to inject httptest URLs.
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string) (*CardSender, error) {
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string, options ...CardSenderOption) (*CardSender, error) {
streamURL := strings.TrimSuffix(createURL, "/create") + "/stream"
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true)
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true, options...)
}
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool) (*CardSender, error) {
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool, options ...CardSenderOption) (*CardSender, error) {
clientID = strings.TrimSpace(clientID)
clientSecret = strings.TrimSpace(clientSecret)
cardTemplateID = strings.TrimSpace(cardTemplateID)
@@ -91,7 +105,7 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
apiClient.Transport = transport
}
return &CardSender{
sender := &CardSender{
clientID: clientID,
clientSecret: clientSecret,
cardTemplateID: cardTemplateID,
@@ -100,7 +114,13 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
streamURL: streamURL,
apiClient: &apiClient,
allowTestURL: allowTest,
}, nil
}
for _, option := range options {
if option != nil {
option(sender)
}
}
return sender, nil
}
// SendFinalCard sends the AI card to DingTalk and returns the generated outTrackID.
@@ -134,9 +154,10 @@ func (s *CardSender) StartCard(ctx context.Context, req CardRequest) (string, er
return "", errors.New("dingtalk card: content exceeds 64 KiB")
}
// Validate identity fields
// Validate identity fields. ExternalUserID is also available for group
// askers and is used only for per-user card private data.
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
if req.ConversationType == core.ConversationDirect {
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
if req.ExternalUserID == "" {
return "", errors.New("dingtalk card: ExternalUserID is required for direct conversations")
}
@@ -186,6 +207,7 @@ func (s *CardSender) UpdateCard(ctx context.Context, outTrackID, content string,
if len(content) > 64*1024 {
return errors.New("dingtalk card: content exceeds 64 KiB")
}
log.Print(formatCardUpdateDiagnostic(content, isFinalize, isError))
token, err := s.accessToken(ctx)
if err != nil {
@@ -202,7 +224,11 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
"callbackType": "STREAM",
"cardData": map[string]any{
"cardParamMap": map[string]any{
"content": req.Content,
"content": req.Content,
"feedbackSelected": "",
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
},
},
"userIdType": 1,
@@ -228,6 +254,15 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
"extension": map[string]any{},
}
}
if s.debugUserID != "" && req.ExternalUserID == s.debugUserID {
body["privateData"] = map[string]any{
req.ExternalUserID: map[string]any{
"cardParamMap": map[string]any{
cardDebugPrivateKey: "show",
},
},
}
}
return body
}
@@ -272,9 +307,25 @@ func (s *CardSender) postCard(ctx context.Context, body map[string]any, token st
if err != nil {
return err
}
// Discard successful response body
if len(payload) > 0 {
// Body is already bounded by postJSON→readBoundedAPIResponse; just discard
return validateDingTalkBusinessResponse(payload)
}
func validateDingTalkBusinessResponse(payload []byte) error {
if len(bytes.TrimSpace(payload)) == 0 {
return nil
}
var response struct {
Success *bool `json:"success"`
ErrCode any `json:"errcode"`
}
if json.Unmarshal(payload, &response) != nil {
return nil
}
if response.Success != nil && !*response.Success {
return errors.New("dingtalk card: API business failure")
}
if response.ErrCode != nil && fmt.Sprint(response.ErrCode) != "0" {
return errors.New("dingtalk card: API business failure")
}
return nil
}
@@ -293,8 +344,11 @@ func (s *CardSender) finalizeCard(ctx context.Context, outTrackID, content, toke
"isFinalize": isFinalize,
"isError": isError,
}
_, err = s.doJSON(ctx, http.MethodPut, s.streamURL, body, token)
return err
payload, err := s.doJSON(ctx, http.MethodPut, s.streamURL, body, token)
if err != nil {
return err
}
return validateDingTalkBusinessResponse(payload)
}
func (s *CardSender) postJSON(ctx context.Context, endpoint string, value any, token string) ([]byte, error) {
@@ -0,0 +1,64 @@
package dingtalk
import (
"fmt"
"strings"
)
type cardContentShape struct {
bytes int
lines int
pipeLines int
leadingPipeLines int
delimiterLines int
confirmedTable bool
confirmedTablePos int
}
func inspectCardContent(content string) cardContentShape {
shape := cardContentShape{
bytes: len(content),
confirmedTablePos: -1,
}
if content == "" {
return shape
}
shape.lines = strings.Count(content, "\n") + 1
lines := splitMarkdownStreamLines(content)
for i, line := range lines {
if strings.Contains(line.text, "|") {
shape.pipeLines++
}
if isTableHeaderCandidate(line.text) {
shape.leadingPipeLines++
}
if !line.inFence && isCompleteTableDelimiter(line.text) {
shape.delimiterLines++
}
if shape.confirmedTable || i == 0 || line.inFence || lines[i-1].inFence {
continue
}
if isTableHeaderCandidate(lines[i-1].text) && isCompleteTableDelimiter(line.text) {
shape.confirmedTable = true
shape.confirmedTablePos = lines[i-1].start
}
}
return shape
}
func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string {
shape := inspectCardContent(content)
return fmt.Sprintf(
"DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d",
isFinalize,
isError,
shape.bytes,
shape.lines,
shape.pipeLines,
shape.leadingPipeLines,
shape.delimiterLines,
shape.confirmedTable,
shape.confirmedTablePos,
)
}
@@ -0,0 +1,93 @@
package dingtalk
import (
"bytes"
"context"
"fmt"
"log"
"strings"
"testing"
)
func TestCardSenderUpdateCardLogsOnlySafeContentShape(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest("client-id", "client-secret", "template-id", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
previousWriter := log.Writer()
previousFlags := log.Flags()
previousPrefix := log.Prefix()
log.SetOutput(&output)
log.SetFlags(0)
log.SetPrefix("")
t.Cleanup(func() {
log.SetOutput(previousWriter)
log.SetFlags(previousFlags)
log.SetPrefix(previousPrefix)
})
content := "DO_NOT_LOG_BOUNDARY_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
if err := sender.UpdateCard(context.Background(), "track-diagnostic", content, false, false); err != nil {
t.Fatal(err)
}
got := output.String()
if !strings.Contains(got, "DingTalk card update shape:") || !strings.Contains(got, "confirmed_table=true") || !strings.Contains(got, "finalize=false") {
t.Fatalf("missing safe boundary diagnostic: %q", got)
}
if strings.Contains(got, "DO_NOT_LOG_BOUNDARY_TEXT") || strings.Contains(got, "CR5") || strings.Contains(got, "track-diagnostic") {
t.Fatalf("boundary diagnostic leaked content or card identifier: %q", got)
}
}
func TestFormatCardUpdateDiagnosticReportsStructureWithoutContent(t *testing.T) {
content := "DO_NOT_LOG_ANSWER_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
got := formatCardUpdateDiagnostic(content, false, false)
wantFields := []string{
"DingTalk card update shape:",
"finalize=false",
"error=false",
fmt.Sprintf("bytes=%d", len(content)),
"lines=5",
"pipe_lines=3",
"leading_pipe_lines=3",
"delimiter_lines=1",
"confirmed_table=true",
fmt.Sprintf("table_start=%d", strings.Index(content, "| 型号")),
}
for _, field := range wantFields {
if !strings.Contains(got, field) {
t.Fatalf("diagnostic %q missing field %q", got, field)
}
}
if strings.Contains(got, "DO_NOT_LOG_ANSWER_TEXT") || strings.Contains(got, "CR5") {
t.Fatalf("diagnostic leaked answer content: %q", got)
}
}
func TestFormatCardUpdateDiagnosticDistinguishesFinalPlainText(t *testing.T) {
got := formatCardUpdateDiagnostic("普通回答", true, false)
wantFields := []string{
"finalize=true",
"error=false",
"lines=1",
"pipe_lines=0",
"leading_pipe_lines=0",
"delimiter_lines=0",
"confirmed_table=false",
"table_start=-1",
}
for _, field := range wantFields {
if !strings.Contains(got, field) {
t.Fatalf("diagnostic %q missing field %q", got, field)
}
}
if strings.Contains(got, "普通回答") {
t.Fatalf("diagnostic leaked answer content: %q", got)
}
}
+6
View File
@@ -39,6 +39,7 @@ func (p *cardUpdatePump) run(ctx context.Context, ticks <-chan time.Time, stopTi
if stopTicks != nil {
defer stopTicks()
}
lastSuccessful := ""
for {
select {
case <-ctx.Done():
@@ -52,9 +53,14 @@ func (p *cardUpdatePump) run(ctx context.Context, ticks <-chan time.Time, stopTi
default:
continue
}
if latest == lastSuccessful {
continue
}
if err := update(ctx, latest); err != nil {
log.Printf("DingTalk async card update failed, continuing")
continue
}
lastSuccessful = latest
}
}
}
+137 -1
View File
@@ -35,6 +35,7 @@ type cardTestServer struct {
createBodySize int64 // Content-Length for oversized response
streamStatus int
streamBodySize int64
streamResponse any
}
func newCardTestServer() *cardTestServer {
@@ -42,6 +43,7 @@ func newCardTestServer() *cardTestServer {
tokenStatus: http.StatusOK,
createStatus: http.StatusOK,
streamStatus: http.StatusOK,
streamResponse: map[string]any{"result": "ok"},
tokenResponse: map[string]any{"accessToken": "test-access-token", "expireIn": 3600},
createResponse: map[string]any{"result": "ok"},
}
@@ -91,7 +93,7 @@ func newCardTestServer() *cardTestServer {
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"result": "ok"})
json.NewEncoder(w).Encode(ts.streamResponse)
default:
http.NotFound(w, r)
}
@@ -218,6 +220,16 @@ func TestCardSendDirectPayload(t *testing.T) {
if paramMap["content"] != "Hello from AI" {
t.Errorf("content=%v", paramMap["content"])
}
for key, want := range map[string]any{
"feedbackSelected": "",
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
} {
if paramMap[key] != want {
t.Errorf("%s=%v, want %v", key, paramMap[key], want)
}
}
if body["userIdType"] != float64(1) {
t.Errorf("userIdType=%v", body["userIdType"])
}
@@ -261,6 +273,117 @@ func TestCardSendDirectPayload(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Test: official DingTalk card debug private data for one allowlisted user
// ---------------------------------------------------------------------------
func TestCardSendDirectAddsDebugPrivateDataForSelectedUser(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest(
"client-id",
"client-secret",
"card-template-123",
ts.Client(),
ts.URL()+"/token",
ts.URL()+"/create",
WithCardDebugUserID(" user-debug "),
)
if err != nil {
t.Fatal(err)
}
_, err = sender.SendFinalCard(context.Background(), CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-debug",
Content: "debug this card",
})
if err != nil {
t.Fatalf("SendFinalCard: %v", err)
}
privateData, ok := ts.lastCreateBody["privateData"].(map[string]any)
if !ok {
t.Fatal("privateData missing or wrong type")
}
userData, ok := privateData["user-debug"].(map[string]any)
if !ok {
t.Fatal("selected user's privateData missing or wrong type")
}
paramMap, ok := userData["cardParamMap"].(map[string]any)
if !ok {
t.Fatal("selected user's cardParamMap missing or wrong type")
}
if got := paramMap["_CARD_DEBUG_TOOL_ENTRY"]; got != "show" {
t.Fatalf("_CARD_DEBUG_TOOL_ENTRY=%v, want show", got)
}
if len(privateData) != 1 {
t.Fatalf("privateData has %d users, want 1", len(privateData))
}
}
func TestCardSendOmitsDebugPrivateDataForOtherUser(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest(
"client-id", "client-secret", "card-template-123",
ts.Client(), ts.URL()+"/token", ts.URL()+"/create",
WithCardDebugUserID("user-debug"),
)
if err != nil {
t.Fatal(err)
}
_, err = sender.SendFinalCard(context.Background(), CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-other",
Content: "ordinary card",
})
if err != nil {
t.Fatalf("SendFinalCard: %v", err)
}
if _, ok := ts.lastCreateBody["privateData"]; ok {
t.Fatal("privateData must be absent for a non-allowlisted user")
}
}
func TestCardSendGroupAddsDebugPrivateDataOnlyForAsker(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest(
"client-id", "client-secret", "card-template-123",
ts.Client(), ts.URL()+"/token", ts.URL()+"/create",
WithCardDebugUserID("user-debug"),
)
if err != nil {
t.Fatal(err)
}
_, err = sender.SendFinalCard(context.Background(), CardRequest{
ConversationType: core.ConversationGroup,
ExternalUserID: " user-debug ",
ExternalChatID: "group-789",
Content: "group debug card",
})
if err != nil {
t.Fatalf("SendFinalCard: %v", err)
}
privateData, ok := ts.lastCreateBody["privateData"].(map[string]any)
if !ok {
t.Fatal("privateData missing or wrong type")
}
if _, ok := privateData["user-debug"]; !ok {
t.Fatal("group asker's privateData missing")
}
if len(privateData) != 1 {
t.Fatalf("privateData has %d users, want 1", len(privateData))
}
}
// ---------------------------------------------------------------------------
// Test: group conversation full payload
// ---------------------------------------------------------------------------
@@ -848,6 +971,19 @@ func TestSendFinalCardCreateStreamOnce(t *testing.T) {
// Test: UpdateCard with non-final / error flags
// ---------------------------------------------------------------------------
func TestUpdateCardRejectsHTTP200BusinessFailure(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
ts.streamResponse = map[string]any{"success": false, "code": "InvalidParameter", "message": "sensitive detail"}
sender, err := newCardSenderForTest("client-id", "client-secret", "card-template-123", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
if err := sender.UpdateCard(context.Background(), "track-1", "answer", true, false); err == nil {
t.Fatal("HTTP 200 business failure must be rejected")
}
}
func TestUpdateCardNonFinalAndError(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
@@ -0,0 +1,31 @@
package dingtalk
import (
"regexp"
"strings"
)
const displayReferenceMarkerPattern = `\[\[ref[\t ]+id[\t ]*=[\t ]*["']c[0-9]+["'][\t ]*\]\]`
var (
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
leadingReferenceGroupColonRE = regexp.MustCompile(`(?m)^[ ]*(?:` + displayReferenceMarkerPattern + `[ ]*)+[::][ ]*`)
spacedReferenceGroupRE = regexp.MustCompile(`[ ]+(?:` + displayReferenceMarkerPattern + `[ ]*)+`)
spaceBeforeDisplayPunctuation = regexp.MustCompile(`[ ]+([,。;:!?、,.;:!?])`)
spaceAfterDisplayColon = regexp.MustCompile(`([::])[\t ]+`)
)
func sanitizeDisplayAnswer(content string) string {
if index := strings.LastIndex(content, "[[ref"); index >= 0 && !strings.Contains(content[index:], "]]") {
content = content[:index]
}
if !displayReferenceMarkerRE.MatchString(content) {
return content
}
content = leadingReferenceGroupColonRE.ReplaceAllString(content, "")
content = spacedReferenceGroupRE.ReplaceAllString(content, " ")
content = displayReferenceMarkerRE.ReplaceAllString(content, "")
content = spaceBeforeDisplayPunctuation.ReplaceAllString(content, "$1")
content = spaceAfterDisplayColon.ReplaceAllString(content, "$1")
return content
}
@@ -0,0 +1,66 @@
package dingtalk
import "testing"
func TestSanitizeDisplayAnswerRemovesInternalReferenceMarkers(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "adjacent inline markers",
input: "结论支持该功能[[ref id=\"c2\"]][[ref id=\"c4\"]]。",
want: "结论支持该功能。",
},
{
name: "leading marker group and chinese colon",
input: "[[ref id=\"c2\"]][[ref id=\"c4\"]]:建议先检查配置。",
want: "建议先检查配置。",
},
{
name: "leading marker group and ascii colon on second line",
input: "第一段。\n[[ref id='c12']] : Second paragraph.",
want: "第一段。\nSecond paragraph.",
},
{
name: "normal punctuation remains natural",
input: "标题:[[ref id=\"c1\"]] 详细说明",
want: "标题:详细说明",
},
{
name: "spaces around marker collapse to one",
input: "Feature A [[ref id=\"c3\"]] remains supported.",
want: "Feature A remains supported.",
},
{
name: "unrelated double bracket syntax is preserved",
input: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
want: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
},
{
name: "content without references remains byte for byte unchanged",
input: "Protocol: value ; keep spacing\n indented",
want: "Protocol: value ; keep spacing\n indented",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sanitizeDisplayAnswer(tt.input); got != tt.want {
t.Fatalf("sanitizeDisplayAnswer()=%q, want %q", got, tt.want)
}
})
}
}
func TestSanitizeDisplayAnswerHoldsBackDanglingStreamingReference(t *testing.T) {
for _, input := range []string{
"答案正文[[ref",
"答案正文[[ref id=\"c",
"答案正文[[ref id=\"c2\"",
} {
if got := sanitizeDisplayAnswer(input); got != "答案正文" {
t.Fatalf("sanitizeDisplayAnswer(%q)=%q", input, got)
}
}
}
@@ -0,0 +1,33 @@
package dingtalk
import (
"testing"
)
func TestEffectiveDownloadCodesDeduplicatesAndPreservesOrder(t *testing.T) {
cb := BotCallback{
DownloadCode: "code-first",
DownloadCodes: []string{"code-first", "code-second", " code-third "},
}
codes := effectiveDownloadCodes(cb)
if len(codes) != 3 {
t.Fatalf("codes=%v, want 3", codes)
}
if codes[0] != "code-first" || codes[1] != "code-second" || codes[2] != "code-third" {
t.Fatalf("codes=%v, want ordered de-dup", codes)
}
}
func TestEffectiveDownloadCodesSingleOnly(t *testing.T) {
cb := BotCallback{DownloadCode: " solo "}
codes := effectiveDownloadCodes(cb)
if len(codes) != 1 || codes[0] != "solo" {
t.Fatalf("codes=%v, want [solo]", codes)
}
}
func TestEffectiveDownloadCodesEmpty(t *testing.T) {
if codes := effectiveDownloadCodes(BotCallback{}); len(codes) != 0 {
t.Fatalf("codes=%v, want empty", codes)
}
}
+186
View File
@@ -0,0 +1,186 @@
package dingtalk
import (
"context"
"errors"
"strings"
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
feedbackdomain "gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
)
const (
ActionNativeFeedback = "sys_action_like_component"
ActionFeedbackUp = "dobotai_feedback_up"
ActionFeedbackDown = "dobotai_feedback_down"
ActionReasonIncorrect = "dobotai_reason_incorrect"
ActionReasonNotResolved = "dobotai_reason_not_resolved"
ActionReasonIrrelevant = "dobotai_reason_irrelevant"
ActionReasonUnclear = "dobotai_reason_unclear"
ActionReasonOutdated = "dobotai_reason_outdated"
ActionReasonOther = "dobotai_reason_other"
ActionReasonContentMismatch = "dobotai_reason_content_mismatch"
ActionReasonContentIncomplete = "dobotai_reason_content_incomplete"
ActionReasonIllogical = "dobotai_reason_illogical_expression"
ActionReasonFactualError = "dobotai_reason_factual_error"
ActionReasonSensitiveContent = "dobotai_reason_sensitive_content"
ActionReasonAnswerNotWanted = "dobotai_reason_answer_not_wanted"
)
var nativeReasonByTag = map[string]string{
"1": feedbackdomain.ReasonContentMismatch,
"2": feedbackdomain.ReasonContentIncomplete,
"3": feedbackdomain.ReasonIllogical,
"4": feedbackdomain.ReasonFactualError,
"5": feedbackdomain.ReasonSensitiveContent,
"6": feedbackdomain.ReasonAnswerNotWanted,
}
type feedbackService interface {
Apply(context.Context, feedbackdomain.Command) (feedbackdomain.Result, error)
}
type CardFeedbackHandler struct {
service feedbackService
}
func NewCardFeedbackHandler(service feedbackService) (*CardFeedbackHandler, error) {
if service == nil {
return nil, errors.New("dingtalk card feedback: service is required")
}
return &CardFeedbackHandler{service: service}, nil
}
func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.CardRequest) (*sdkcard.CardResponse, error) {
if request == nil {
return nil, errors.New("dingtalk card feedback: request is nil")
}
value, reason, recognized := feedbackAction(
request.CardActionData.CardPrivateData.ActionIdList,
request.CardActionData.CardPrivateData.Params,
)
if !recognized {
return &sdkcard.CardResponse{}, nil
}
_, err := h.service.Apply(ctx, feedbackdomain.Command{
ExternalActorID: request.UserId,
OutTrackID: request.OutTrackId,
Value: value,
ReasonCode: reason,
Source: feedbackdomain.SourceDingTalkCard,
})
if err != nil {
return nil, err
}
showReasons := "false"
message := "感谢反馈,我们会继续改进"
if value == feedbackdomain.ValueUp {
message = "感谢反馈"
}
return &sdkcard.CardResponse{
CardUpdateOptions: &sdkcard.CardUpdateOptions{UpdatePrivateDataByKey: true},
UserPrivateData: &sdkcard.CardDataDto{CardParamMap: map[string]string{
"feedbackSelected": value,
"feedbackReasonSelected": reason,
"showFeedbackReasons": showReasons,
"feedbackMessage": message,
}},
}, nil
}
func feedbackAction(actions []string, params map[string]any) (string, string, bool) {
value, reason, matches := "", "", 0
for _, action := range actions {
candidateValue, candidateReason, ok := oneFeedbackAction(action)
if action == ActionNativeFeedback {
candidateValue, candidateReason, ok = nativeFeedbackAction(params)
}
if !ok {
continue
}
matches++
value, reason = candidateValue, candidateReason
}
return value, reason, matches == 1
}
func nativeFeedbackAction(params map[string]any) (string, string, bool) {
key, keyOK := params["variableKey"].(string)
if !keyOK || !strings.HasPrefix(key, "node_") {
return "", "", false
}
if nativeVariableKey(key, "_likeOrDislike") {
value, ok := params["variableValue"].(string)
if !ok {
return "", "", false
}
switch value {
case "like":
return feedbackdomain.ValueUp, feedbackdomain.ReasonEmpty, true
case "dislike":
return feedbackdomain.ValueDown, feedbackdomain.ReasonEmpty, true
default:
return "", "", false
}
}
if nativeVariableKey(key, "_dislikeTags") {
values, ok := params["variableValue"].([]any)
if !ok || len(values) != 1 {
return "", "", false
}
tag, ok := values[0].(string)
if !ok {
return "", "", false
}
reason, ok := nativeReasonByTag[tag]
if !ok {
return "", "", false
}
return feedbackdomain.ValueDown, reason, true
}
return "", "", false
}
func nativeVariableKey(key, suffix string) bool {
if !strings.HasPrefix(key, "node_") || !strings.HasSuffix(key, suffix) {
return false
}
return strings.TrimSuffix(strings.TrimPrefix(key, "node_"), suffix) != ""
}
func oneFeedbackAction(action string) (string, string, bool) {
switch action {
case ActionFeedbackUp:
return feedbackdomain.ValueUp, feedbackdomain.ReasonEmpty, true
case ActionFeedbackDown:
return feedbackdomain.ValueDown, feedbackdomain.ReasonEmpty, true
case ActionReasonIncorrect:
return feedbackdomain.ValueDown, feedbackdomain.ReasonIncorrect, true
case ActionReasonNotResolved:
return feedbackdomain.ValueDown, feedbackdomain.ReasonNotResolved, true
case ActionReasonIrrelevant:
return feedbackdomain.ValueDown, feedbackdomain.ReasonIrrelevant, true
case ActionReasonUnclear:
return feedbackdomain.ValueDown, feedbackdomain.ReasonUnclear, true
case ActionReasonOutdated:
return feedbackdomain.ValueDown, feedbackdomain.ReasonOutdated, true
case ActionReasonOther:
return feedbackdomain.ValueDown, feedbackdomain.ReasonOther, true
case ActionReasonContentMismatch:
return feedbackdomain.ValueDown, feedbackdomain.ReasonContentMismatch, true
case ActionReasonContentIncomplete:
return feedbackdomain.ValueDown, feedbackdomain.ReasonContentIncomplete, true
case ActionReasonIllogical:
return feedbackdomain.ValueDown, feedbackdomain.ReasonIllogical, true
case ActionReasonFactualError:
return feedbackdomain.ValueDown, feedbackdomain.ReasonFactualError, true
case ActionReasonSensitiveContent:
return feedbackdomain.ValueDown, feedbackdomain.ReasonSensitiveContent, true
case ActionReasonAnswerNotWanted:
return feedbackdomain.ValueDown, feedbackdomain.ReasonAnswerNotWanted, true
default:
return "", "", false
}
}
+373
View File
@@ -0,0 +1,373 @@
package dingtalk
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/utils"
feedbackdomain "gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
)
type fakeFeedbackService struct {
calls int
command feedbackdomain.Command
result feedbackdomain.Result
err error
}
func (f *fakeFeedbackService) Apply(_ context.Context, command feedbackdomain.Command) (feedbackdomain.Result, error) {
f.calls++
f.command = command
return f.result, f.err
}
func cardRequest(action string) *sdkcard.CardRequest {
return &sdkcard.CardRequest{
OutTrackId: "track-1",
UserId: "user-1",
CorpId: "must-not-be-stored",
Content: "must-not-be-stored",
Extension: "must-not-be-stored",
CardActionData: sdkcard.PrivateCardActionData{CardPrivateData: sdkcard.CardPrivateData{
ActionIdList: []string{action},
Params: map[string]any{
"tenant_key": "attacker", "turn_id": "attacker", "user_id": "attacker", "token": "attacker",
},
}},
}
}
func TestCardFeedbackMapsNativeLike(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueUp}}
handler, err := NewCardFeedbackHandler(service)
if err != nil {
t.Fatal(err)
}
request := cardRequest(ActionNativeFeedback)
request.CardActionData.CardPrivateData.Params = map[string]any{
"variableKey": "node_ocmrjfmlsra_likeOrDislike",
"variableValue": "like",
}
response, err := handler.HandleSDK(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if service.calls != 1 || service.command.Value != feedbackdomain.ValueUp || service.command.ReasonCode != feedbackdomain.ReasonEmpty {
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
}
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackSelected"] != feedbackdomain.ValueUp {
t.Fatalf("response=%+v", response)
}
}
func TestCardFeedbackMapsNativeDislike(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueDown}}
handler, err := NewCardFeedbackHandler(service)
if err != nil {
t.Fatal(err)
}
request := cardRequest(ActionNativeFeedback)
request.CardActionData.CardPrivateData.Params = map[string]any{
"variableKey": "node_ocmrjfmlsra_likeOrDislike",
"variableValue": "dislike",
}
response, err := handler.HandleSDK(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonEmpty {
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
}
if response == nil || response.UserPrivateData == nil ||
response.UserPrivateData.CardParamMap["showFeedbackReasons"] != "false" ||
response.UserPrivateData.CardParamMap["feedbackMessage"] != "感谢反馈,我们会继续改进" {
t.Fatalf("response=%+v", response)
}
}
func TestCardFeedbackMapsNativeReasons(t *testing.T) {
tests := []struct {
tag string
reason string
}{
{"1", feedbackdomain.ReasonContentMismatch},
{"2", feedbackdomain.ReasonContentIncomplete},
{"3", feedbackdomain.ReasonIllogical},
{"4", feedbackdomain.ReasonFactualError},
{"5", feedbackdomain.ReasonSensitiveContent},
{"6", feedbackdomain.ReasonAnswerNotWanted},
}
for _, tc := range tests {
t.Run(tc.tag, func(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: tc.reason}}
handler, err := NewCardFeedbackHandler(service)
if err != nil {
t.Fatal(err)
}
request := cardRequest(ActionNativeFeedback)
request.CardActionData.CardPrivateData.Params = map[string]any{
"variableKey": "node_ocmrjfmlsra_dislikeTags",
"variableValue": []any{tc.tag},
}
response, err := handler.HandleSDK(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != tc.reason {
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
}
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackReasonSelected"] != tc.reason {
t.Fatalf("response=%+v", response)
}
})
}
}
func TestCardFeedbackRejectsMalformedNativeEvents(t *testing.T) {
tests := []struct {
name string
actions []string
params map[string]any
}{
{name: "empty node ID", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node__likeOrDislike", "variableValue": "like"}},
{name: "unknown suffix", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_other", "variableValue": "like"}},
{name: "unknown vote", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": "LIKE"}},
{name: "wrong vote type", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": []any{"like"}}},
{name: "empty tags", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{}}},
{name: "multiple tags", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{"1", "2"}}},
{name: "numeric tag", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{1}}},
{name: "unknown tag", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{"7"}}},
{name: "ambiguous actions", actions: []string{ActionNativeFeedback, ActionFeedbackUp}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": "like"}},
{name: "nil params", actions: []string{ActionNativeFeedback}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
service := &fakeFeedbackService{}
handler, _ := NewCardFeedbackHandler(service)
request := cardRequest("unknown")
request.CardActionData.CardPrivateData.ActionIdList = tc.actions
request.CardActionData.CardPrivateData.Params = tc.params
response, err := handler.HandleSDK(context.Background(), request)
if err != nil || response == nil || service.calls != 0 {
t.Fatalf("response=%+v error=%v calls=%d", response, err, service.calls)
}
if response.CardUpdateOptions != nil || response.UserPrivateData != nil {
t.Fatalf("safe ACK must not update card: %+v", response)
}
})
}
}
func TestCardFeedbackMapsCustomExactReasons(t *testing.T) {
tests := []struct {
action string
reason string
}{
{ActionReasonContentMismatch, feedbackdomain.ReasonContentMismatch},
{ActionReasonContentIncomplete, feedbackdomain.ReasonContentIncomplete},
{ActionReasonIllogical, feedbackdomain.ReasonIllogical},
{ActionReasonFactualError, feedbackdomain.ReasonFactualError},
{ActionReasonSensitiveContent, feedbackdomain.ReasonSensitiveContent},
{ActionReasonAnswerNotWanted, feedbackdomain.ReasonAnswerNotWanted},
}
for _, tc := range tests {
t.Run(tc.action, func(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: tc.reason}}
handler, _ := NewCardFeedbackHandler(service)
response, err := handler.HandleSDK(context.Background(), cardRequest(tc.action))
if err != nil {
t.Fatal(err)
}
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != tc.reason {
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
}
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackReasonSelected"] != tc.reason {
t.Fatalf("response=%+v", response)
}
})
}
}
func TestCardFeedbackMapsWhitelistedActions(t *testing.T) {
tests := []struct {
action string
value string
reason string
showReasons string
message string
}{
{ActionFeedbackUp, feedbackdomain.ValueUp, "", "false", "感谢反馈"},
{ActionFeedbackDown, feedbackdomain.ValueDown, "", "false", "感谢反馈,我们会继续改进"},
{ActionReasonIncorrect, feedbackdomain.ValueDown, feedbackdomain.ReasonIncorrect, "false", "感谢反馈,我们会继续改进"},
{ActionReasonNotResolved, feedbackdomain.ValueDown, feedbackdomain.ReasonNotResolved, "false", "感谢反馈,我们会继续改进"},
{ActionReasonIrrelevant, feedbackdomain.ValueDown, feedbackdomain.ReasonIrrelevant, "false", "感谢反馈,我们会继续改进"},
{ActionReasonUnclear, feedbackdomain.ValueDown, feedbackdomain.ReasonUnclear, "false", "感谢反馈,我们会继续改进"},
{ActionReasonOutdated, feedbackdomain.ValueDown, feedbackdomain.ReasonOutdated, "false", "感谢反馈,我们会继续改进"},
{ActionReasonOther, feedbackdomain.ValueDown, feedbackdomain.ReasonOther, "false", "感谢反馈,我们会继续改进"},
}
for _, tc := range tests {
t.Run(tc.action, func(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: tc.value, ReasonCode: tc.reason}}
handler, err := NewCardFeedbackHandler(service)
if err != nil {
t.Fatal(err)
}
response, err := handler.HandleSDK(context.Background(), cardRequest(tc.action))
if err != nil {
t.Fatal(err)
}
if service.calls != 1 {
t.Fatalf("calls=%d", service.calls)
}
if service.command.ExternalActorID != "user-1" || service.command.OutTrackID != "track-1" || service.command.Value != tc.value || service.command.ReasonCode != tc.reason || service.command.Source != feedbackdomain.SourceDingTalkCard {
t.Fatalf("command=%+v", service.command)
}
if response == nil || response.CardUpdateOptions == nil || !response.CardUpdateOptions.UpdatePrivateDataByKey || response.CardUpdateOptions.UpdateCardDataByKey || response.CardData != nil || response.UserPrivateData == nil {
t.Fatalf("response=%+v", response)
}
params := response.UserPrivateData.CardParamMap
if params["feedbackSelected"] != tc.value || params["feedbackReasonSelected"] != tc.reason || params["showFeedbackReasons"] != tc.showReasons || params["feedbackMessage"] != tc.message {
t.Fatalf("params=%+v", params)
}
})
}
}
func TestCardFeedbackUnknownOrAmbiguousActionIsSafeAck(t *testing.T) {
for _, actions := range [][]string{{"unknown"}, {ActionFeedbackUp, ActionFeedbackDown}, nil} {
service := &fakeFeedbackService{}
handler, _ := NewCardFeedbackHandler(service)
request := cardRequest("unknown")
request.CardActionData.CardPrivateData.ActionIdList = actions
response, err := handler.HandleSDK(context.Background(), request)
if err != nil || response == nil || service.calls != 0 {
t.Fatalf("actions=%v response=%+v error=%v calls=%d", actions, response, err, service.calls)
}
if response.CardUpdateOptions != nil || response.UserPrivateData != nil {
t.Fatalf("safe ACK must not update card: %+v", response)
}
}
}
func TestCardFeedbackValidationAndServiceError(t *testing.T) {
if _, err := NewCardFeedbackHandler(nil); err == nil {
t.Fatal("nil service must fail")
}
serviceErr := errors.New("store failed")
service := &fakeFeedbackService{err: serviceErr}
handler, _ := NewCardFeedbackHandler(service)
if _, err := handler.HandleSDK(context.Background(), nil); err == nil {
t.Fatal("nil request must fail")
}
response, err := handler.HandleSDK(context.Background(), cardRequest(ActionFeedbackUp))
if !errors.Is(err, serviceErr) || response != nil || service.calls != 1 {
t.Fatalf("response=%+v error=%v calls=%d", response, err, service.calls)
}
}
func TestStreamClientRegistersOptionalCardFeedbackRouter(t *testing.T) {
config := StreamConfig{ClientID: "id", ClientSecret: "secret", TenantKey: "tenant"}
chatHandler := mustTestHandler(t)
feedbackHandler, _ := NewCardFeedbackHandler(&fakeFeedbackService{})
client, err := NewStreamClient(config, chatHandler, feedbackHandler)
if err != nil {
t.Fatal(err)
}
if _, err := client.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic); err != nil {
t.Fatalf("card callback router not registered: %v", err)
}
withoutFeedback, err := NewStreamClient(config, chatHandler)
if err != nil {
t.Fatal(err)
}
if _, err := withoutFeedback.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic); err == nil {
t.Fatal("card callback router must remain optional for old callers")
}
if _, err := NewStreamClient(config, chatHandler, nil); err == nil {
t.Fatal("explicit nil feedback handler must fail")
}
if _, err := NewStreamClient(config, chatHandler, feedbackHandler, feedbackHandler); err == nil {
t.Fatal("multiple feedback handlers must fail")
}
}
func TestStreamCardRouterHandlesNativeSDKDataFrameEnvelope(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: feedbackdomain.ReasonContentMismatch}}
feedbackHandler, _ := NewCardFeedbackHandler(service)
client, err := NewStreamClient(
StreamConfig{ClientID: "id", ClientSecret: "secret", TenantKey: "tenant"},
mustTestHandler(t), feedbackHandler,
)
if err != nil {
t.Fatal(err)
}
router, err := client.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic)
if err != nil {
t.Fatal(err)
}
actionData, _ := json.Marshal(map[string]any{"cardPrivateData": map[string]any{
"actionIds": []string{ActionNativeFeedback},
"params": map[string]any{
"variableKey": "node_ocmrjfmlsra_dislikeTags",
"variableValue": []string{"1"},
},
}})
outer, _ := json.Marshal(map[string]any{
"outTrackId": "track-native-frame", "userId": "user-native-frame", "content": string(actionData),
"corpId": "must-not-be-used", "extension": "must-not-be-used",
})
frameResponse, err := router(context.Background(), &payload.DataFrame{Data: string(outer)})
if err != nil {
t.Fatal(err)
}
if frameResponse == nil || frameResponse.Code != payload.DataFrameResponseStatusCodeKOK {
t.Fatalf("frame response=%+v", frameResponse)
}
if service.calls != 1 || service.command.ExternalActorID != "user-native-frame" || service.command.OutTrackID != "track-native-frame" || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonContentMismatch {
t.Fatalf("service calls=%d command=%+v", service.calls, service.command)
}
}
func TestStreamCardRouterHandlesRealSDKDataFrameEnvelope(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueDown, ReasonCode: feedbackdomain.ReasonNotResolved}}
feedbackHandler, _ := NewCardFeedbackHandler(service)
client, err := NewStreamClient(
StreamConfig{ClientID: "id", ClientSecret: "secret", TenantKey: "tenant"},
mustTestHandler(t), feedbackHandler,
)
if err != nil {
t.Fatal(err)
}
router, err := client.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic)
if err != nil {
t.Fatal(err)
}
actionData, _ := json.Marshal(map[string]any{"cardPrivateData": map[string]any{
"actionIds": []string{ActionReasonNotResolved},
"params": map[string]any{"tenant_key": "attacker", "token": "attacker"},
}})
outer, _ := json.Marshal(map[string]any{
"outTrackId": "track-frame", "userId": "user-frame", "content": string(actionData),
"corpId": "must-not-be-used", "extension": "must-not-be-used",
})
frameResponse, err := router(context.Background(), &payload.DataFrame{Data: string(outer)})
if err != nil {
t.Fatal(err)
}
if frameResponse == nil || frameResponse.Code != payload.DataFrameResponseStatusCodeKOK {
t.Fatalf("frame response=%+v", frameResponse)
}
if service.calls != 1 || service.command.ExternalActorID != "user-frame" || service.command.OutTrackID != "track-frame" || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonNotResolved {
t.Fatalf("service calls=%d command=%+v", service.calls, service.command)
}
if strings.Contains(frameResponse.Data, "attacker") || !strings.Contains(frameResponse.Data, "feedbackReasonSelected") {
t.Fatalf("unsafe or incomplete frame response: %s", frameResponse.Data)
}
}
+360
View File
@@ -0,0 +1,360 @@
package dingtalk
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
const (
groupSendURL = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
// maxGroupPayloadBytes caps the serialized JSON body sent to the group
// message endpoint.
maxGroupPayloadBytes = 64 << 10 // 64 KiB
// questionRuneLimit caps the displayed current question.
questionRuneLimit = 500
// previousQuestionRuneLimit caps each displayed historical question.
previousQuestionRuneLimit = 300
)
// SupportGroupSender sends DingTalk group Markdown notifications via the
// official groupMessages/send OpenAPI. It is safe by construction: all
// config fields are trimmed, nil client installs hardened defaults, and
// all error paths are sanitised.
type SupportGroupSender struct {
clientID string
clientSecret string
openConversation string
recordLinks *faqtable.RecordLinkBuilder
tokenURL string
groupURL string
apiClient *http.Client
mu sync.Mutex
token string
tokenTill time.Time
}
type SupportGroupSenderOption func(*SupportGroupSender)
func WithFAQRecordLinkBuilder(builder *faqtable.RecordLinkBuilder) SupportGroupSenderOption {
return func(sender *SupportGroupSender) { sender.recordLinks = builder }
}
var _ coverage.Notifier = (*SupportGroupSender)(nil)
// NewSupportGroupSender creates a SupportGroupSender. All three config values
// are required after trimming. A nil http.Client installs a hardened default
// (no redirects, Proxy=nil, 15s timeout).
func NewSupportGroupSender(clientID, clientSecret, openConversationID string, client *http.Client, opts ...SupportGroupSenderOption) (*SupportGroupSender, error) {
return newSupportGroupSender(clientID, clientSecret, openConversationID, client, oauthTokenURL, groupSendURL, false, opts...)
}
// newSupportGroupSenderForTest is used by tests to inject httptest URLs.
func newSupportGroupSenderForTest(clientID, clientSecret, openConversationID string, client *http.Client, tokenURL, groupURL string, opts ...SupportGroupSenderOption) (*SupportGroupSender, error) {
return newSupportGroupSender(clientID, clientSecret, openConversationID, client, tokenURL, groupURL, true, opts...)
}
func newSupportGroupSender(clientID, clientSecret, openConversationID string, client *http.Client, tokenURL, groupURL string, allowTest bool, opts ...SupportGroupSenderOption) (*SupportGroupSender, error) {
clientID = strings.TrimSpace(clientID)
clientSecret = strings.TrimSpace(clientSecret)
openConversationID = strings.TrimSpace(openConversationID)
if clientID == "" {
return nil, errors.New("dingtalk group sender: clientID is required")
}
if clientSecret == "" {
return nil, errors.New("dingtalk group sender: clientSecret is required")
}
if openConversationID == "" {
return nil, errors.New("dingtalk group sender: openConversationId is required")
}
base := &http.Client{Timeout: 15 * time.Second}
if client != nil {
copyClient := *client
base = &copyClient
if base.Timeout <= 0 {
base.Timeout = 15 * time.Second
}
}
apiClient := *base
apiClient.CheckRedirect = func(*http.Request, []*http.Request) error {
return errors.New("dingtalk API redirect refused")
}
if !allowTest {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
apiClient.Transport = transport
}
sender := &SupportGroupSender{
clientID: clientID,
clientSecret: clientSecret,
openConversation: openConversationID,
tokenURL: tokenURL,
groupURL: groupURL,
apiClient: &apiClient,
}
for _, opt := range opts {
if opt != nil {
opt(sender)
}
}
if allowTest && sender.recordLinks == nil {
sender.recordLinks, _ = faqtable.NewRecordLinkBuilder("test-base", "test-sheet", "test-view")
}
return sender, nil
}
// Send sends a support-case notification to the DingTalk group.
func (s *SupportGroupSender) Send(ctx context.Context, notif coverage.SupportNotification) error {
if notif.CaseID == "" {
return errors.New("dingtalk group sender: CaseID is required")
}
if s.recordLinks == nil {
return errors.New("dingtalk group sender: FAQ record links are required")
}
recordURL, err := s.recordLinks.Build(notif.FAQRecordID)
if err != nil {
return errors.New("dingtalk group sender: FAQ record link failed")
}
title, text := s.renderMarkdown(notif)
msgParam := map[string]string{
"title": title,
"text": text,
"singleTitle": "去回答",
"singleURL": recordURL,
}
msgParamJSON, err := json.Marshal(msgParam)
if err != nil {
return errors.New("dingtalk group sender: encode msgParam failed")
}
body := map[string]any{
"msgKey": "sampleActionCard",
"msgParam": string(msgParamJSON),
"openConversationId": s.openConversation,
"robotCode": s.clientID,
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return errors.New("dingtalk group sender: encode request body failed")
}
if len(bodyJSON) > maxGroupPayloadBytes {
return fmt.Errorf("dingtalk group sender: payload exceeds %d bytes", maxGroupPayloadBytes)
}
// Get access token
token, err := s.accessToken(ctx)
if err != nil {
return err
}
// POST to groupMessages/send
respPayload, err := s.doJSON(ctx, http.MethodPost, s.groupURL, body, token)
if err != nil {
return err
}
// Check business response
var bizResp struct {
ProcessQueryKey string `json:"processQueryKey"`
ErrCode int `json:"errcode"`
}
if err := json.Unmarshal(respPayload, &bizResp); err != nil {
return errors.New("dingtalk group sender: invalid API response")
}
if bizResp.ErrCode != 0 {
return errors.New("dingtalk group sender: API returned business error")
}
if strings.TrimSpace(bizResp.ProcessQueryKey) == "" {
return errors.New("dingtalk group sender: incomplete API response")
}
return nil
}
// SendTest sends a fixed, non-sensitive message used to verify that the
// configured robot can reach the candidate support group. It deliberately
// does not require an FAQ-table binding.
func (s *SupportGroupSender) SendTest(ctx context.Context) error {
msgParam, err := json.Marshal(map[string]string{
"title": "DobotAI 技术支持群绑定测试",
"text": "DobotAI 技术支持群绑定测试成功。",
})
if err != nil {
return errors.New("dingtalk group sender: encode test message failed")
}
body := map[string]any{
"msgKey": "sampleMarkdown",
"msgParam": string(msgParam),
"openConversationId": s.openConversation,
"robotCode": s.clientID,
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return errors.New("dingtalk group sender: encode test request failed")
}
if len(bodyJSON) > maxGroupPayloadBytes {
return fmt.Errorf("dingtalk group sender: payload exceeds %d bytes", maxGroupPayloadBytes)
}
token, err := s.accessToken(ctx)
if err != nil {
return err
}
respPayload, err := s.doJSON(ctx, http.MethodPost, s.groupURL, body, token)
if err != nil {
return err
}
var response struct {
ProcessQueryKey string `json:"processQueryKey"`
ErrCode int `json:"errcode"`
}
if json.Unmarshal(respPayload, &response) != nil {
return errors.New("dingtalk group sender: invalid API response")
}
if response.ErrCode != 0 {
return errors.New("dingtalk group sender: API returned business error")
}
if strings.TrimSpace(response.ProcessQueryKey) == "" {
return errors.New("dingtalk group sender: incomplete API response")
}
return nil
}
// renderMarkdown produces the compact customer-question context shown in the action card.
func (s *SupportGroupSender) renderMarkdown(notif coverage.SupportNotification) (title, text string) {
title = "知识库未覆盖提醒"
question := escapeMarkdown(truncateRunes(notif.Question, questionRuneLimit))
sender := escapeMarkdown(notif.SenderName)
askedAt := "未知"
if !notif.AskedAt.IsZero() {
askedAt = notif.AskedAt.In(time.FixedZone("CST", 8*60*60)).Format("2006-01-02 15:04:05")
}
var b strings.Builder
b.WriteString("## 【知识库未覆盖提醒】\n\n")
b.WriteString(fmt.Sprintf("**提问人:** %s\n\n", sender))
b.WriteString(fmt.Sprintf("**时间:** %s\n\n", askedAt))
b.WriteString(fmt.Sprintf("**当前问题:**\n> %s\n\n", question))
b.WriteString(fmt.Sprintf("**语言:** %s\n\n", faqtable.DetectLanguage(notif.Question)))
b.WriteString("**最近5次历史问题:**\n")
if len(notif.PreviousQuestions) == 0 {
b.WriteString("暂无历史问题\n")
} else {
for i, previous := range notif.PreviousQuestions {
if i >= 5 {
break
}
b.WriteString(fmt.Sprintf("%d. %s\n", i+1, escapeMarkdown(truncateRunes(previous, previousQuestionRuneLimit))))
}
}
return title, b.String()
}
func escapeMarkdown(value string) string {
value = strings.Join(strings.Fields(value), " ")
replacer := strings.NewReplacer(
"\\", "\\\\", "`", "\\`", "*", "\\*", "_", "\\_",
"[", "\\[", "]", "\\]", "(", "\\(", ")", "\\)",
"#", "\\#", ">", "\\>",
)
return replacer.Replace(value)
}
func truncateRunes(s string, limit int) string {
if limit <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= limit {
return s
}
return string(runes[:limit]) + "..."
}
func (s *SupportGroupSender) accessToken(ctx context.Context) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.token != "" && time.Now().Before(s.tokenTill) {
return s.token, nil
}
payload, err := s.doJSON(ctx, http.MethodPost, s.tokenURL, map[string]string{
"appKey": s.clientID,
"appSecret": s.clientSecret,
}, "")
if err != nil {
return "", err
}
var response struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"`
}
if json.Unmarshal(payload, &response) != nil || strings.TrimSpace(response.AccessToken) == "" {
return "", errors.New("dingtalk group sender: invalid OAuth response")
}
ttl := time.Duration(response.ExpireIn) * time.Second
refreshAfter := ttl - 5*time.Minute
if refreshAfter <= 0 {
refreshAfter = ttl / 2
}
if refreshAfter <= 0 {
refreshAfter = time.Minute
}
s.token = response.AccessToken
s.tokenTill = time.Now().Add(refreshAfter)
return s.token, nil
}
func (s *SupportGroupSender) doJSON(ctx context.Context, method, endpoint string, value any, token string) ([]byte, error) {
body, err := json.Marshal(value)
if err != nil {
return nil, errors.New("dingtalk group sender: encode API request failed")
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return nil, errors.New("dingtalk group sender: create API request failed")
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("x-acs-dingtalk-access-token", token)
}
resp, err := s.apiClient.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Propagate redirect errors so callers can detect them.
if strings.Contains(err.Error(), "redirect") {
return nil, err
}
return nil, errors.New("dingtalk group sender: API request failed")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Discard body without reading it — sanitised error
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxAPIResponseBytes))
return nil, fmt.Errorf("dingtalk group sender: API HTTP %d", resp.StatusCode)
}
return readBoundedAPIResponse(resp)
}
@@ -0,0 +1,60 @@
package dingtalk
import (
"context"
"encoding/json"
"net/url"
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
func TestSupportGroupSenderSendsActionCardWithExactFAQRecordLink(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
builder, err := faqtable.NewRecordLinkBuilder("jb9-base", "hERWDMS", "qvGDAH2")
if err != nil {
t.Fatal(err)
}
s, err := newSupportGroupSenderForTest(
"appKey", "appSecret", "cid-abc", gs.server.Client(),
gs.URL()+"/token", gs.URL()+"/groupMessages/send",
WithFAQRecordLinkBuilder(builder),
)
if err != nil {
t.Fatal(err)
}
if err := s.Send(context.Background(), newTestNotification()); err != nil {
t.Fatal(err)
}
if gs.lastSendBody["msgKey"] != "sampleActionCard" {
t.Fatalf("msgKey=%v", gs.lastSendBody["msgKey"])
}
var params map[string]string
if err := json.Unmarshal([]byte(gs.lastSendBody["msgParam"].(string)), &params); err != nil {
t.Fatal(err)
}
if params["singleTitle"] != "去回答" {
t.Fatalf("singleTitle=%q", params["singleTitle"])
}
parsed, err := url.Parse(params["singleURL"])
if err != nil {
t.Fatal(err)
}
q := parsed.Query()
if parsed.Host != "docs.dingtalk.com" || q.Get("rowId") != "record-001" || q.Get("dentryUuid") != "jb9-base" {
t.Fatalf("singleURL=%s", params["singleURL"])
}
text := params["text"]
for _, required := range []string{"张三", "2026-07-24 10:16:35", "怎么配置钉钉机器人", "之前的问题一", "之前的问题二", "Chinese"} {
if !strings.Contains(text, required) {
t.Errorf("text missing %q: %s", required, text)
}
}
for _, forbidden := range []string{"回答摘要", "相关结果", "Score", "置信度", "case-001"} {
if strings.Contains(text, forbidden) {
t.Errorf("text contains forbidden %q: %s", forbidden, text)
}
}
}
@@ -0,0 +1,599 @@
package dingtalk
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
)
// groupTestServer provides a configurable httptest server for group message
// tests, with token and groupMessages/send endpoints.
type groupTestServer struct {
server *httptest.Server
tokenCalls int
sendCalls int
lastTokenBody map[string]string
lastSendBody map[string]any
lastSendHeader string
tokenStatus int
tokenResponse any
sendStatus int
sendResponse any
}
func newGroupTestServer() *groupTestServer {
gs := &groupTestServer{
tokenStatus: http.StatusOK,
tokenResponse: map[string]any{"accessToken": "test-token", "expireIn": 3600},
sendStatus: http.StatusOK,
sendResponse: map[string]any{"processQueryKey": "pqk-123"},
}
gs.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
gs.tokenCalls++
gs.lastTokenBody = make(map[string]string)
json.NewDecoder(r.Body).Decode(&gs.lastTokenBody)
w.Header().Set("Content-Type", "application/json")
if gs.tokenStatus != http.StatusOK {
w.WriteHeader(gs.tokenStatus)
return
}
json.NewEncoder(w).Encode(gs.tokenResponse)
case "/groupMessages/send":
gs.sendCalls++
gs.lastSendHeader = r.Header.Get("x-acs-dingtalk-access-token")
gs.lastSendBody = make(map[string]any)
json.NewDecoder(r.Body).Decode(&gs.lastSendBody)
w.Header().Set("Content-Type", "application/json")
if gs.sendStatus != http.StatusOK {
w.WriteHeader(gs.sendStatus)
return
}
json.NewEncoder(w).Encode(gs.sendResponse)
default:
http.NotFound(w, r)
}
}))
return gs
}
func (gs *groupTestServer) Close() { gs.server.Close() }
func (gs *groupTestServer) URL() string { return gs.server.URL }
func newTestNotification() coverage.SupportNotification {
return coverage.SupportNotification{
CaseID: "case-001",
Question: "怎么配置钉钉机器人?",
SenderName: "张三",
Channel: "dingtalk",
ConversationType: core.ConversationGroup,
AskedAt: time.Date(2026, 7, 24, 10, 16, 35, 0, time.FixedZone("CST", 8*60*60)),
PreviousQuestions: []string{"之前的问题一", "之前的问题二"},
FAQRecordID: "record-001",
}
}
// helper to create a sender with test URLs.
func newTestGroupSender(t *testing.T, clientID, clientSecret, openConvID string, client *http.Client, tokenURL, groupURL string) *SupportGroupSender {
t.Helper()
s, err := newSupportGroupSenderForTest(clientID, clientSecret, openConvID, client, tokenURL, groupURL)
if err != nil {
t.Fatalf("newSupportGroupSenderForTest: %v", err)
}
return s
}
// -------- 1. fail closed on missing config --------
func TestSupportGroupSenderMissingConfigFailsClosed(t *testing.T) {
tests := []struct {
name, clientID, clientSecret, openConvID string
wantErr string
}{
{name: "empty clientID", clientSecret: "s", openConvID: "c", wantErr: "clientID"},
{name: "empty clientSecret", clientID: "a", openConvID: "c", wantErr: "clientSecret"},
{name: "empty openConversationId", clientID: "a", clientSecret: "s", wantErr: "openConversationId"},
{name: "all empty", wantErr: "clientID"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewSupportGroupSender(tt.clientID, tt.clientSecret, tt.openConvID, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
}
})
}
}
// -------- 2. exact endpoint, body, header --------
func TestSupportGroupSenderExactEndpointBodyHeader(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
notif := newTestNotification()
ctx := context.Background()
if err := s.Send(ctx, notif); err != nil {
t.Fatalf("Send: %v", err)
}
// Verify token endpoint was called
if gs.tokenCalls != 1 {
t.Fatalf("tokenCalls = %d, want 1", gs.tokenCalls)
}
if gs.lastTokenBody["appKey"] != "appKey" || gs.lastTokenBody["appSecret"] != "appSecret" {
t.Fatalf("token body = %+v", gs.lastTokenBody)
}
// Verify send endpoint
if gs.sendCalls != 1 {
t.Fatalf("sendCalls = %d, want 1", gs.sendCalls)
}
if gs.lastSendHeader != "test-token" {
t.Fatalf("send header = %q, want test-token", gs.lastSendHeader)
}
body := gs.lastSendBody
if body["msgKey"] != "sampleActionCard" {
t.Fatalf("msgKey = %v, want sampleActionCard", body["msgKey"])
}
if body["openConversationId"] != "cid-abc" {
t.Fatalf("openConversationId = %v, want cid-abc", body["openConversationId"])
}
if body["robotCode"] != "appKey" {
t.Fatalf("robotCode = %v, want appKey", body["robotCode"])
}
// msgParam must be a JSON string
msgParamRaw, ok := body["msgParam"].(string)
if !ok {
t.Fatalf("msgParam is not a string, got %T", body["msgParam"])
}
// Parse msgParam as JSON
var msgParam map[string]string
if err := json.Unmarshal([]byte(msgParamRaw), &msgParam); err != nil {
t.Fatalf("msgParam is not valid JSON: %v (raw=%s)", err, msgParamRaw)
}
if msgParam["title"] == "" {
t.Fatal("msgParam.title is empty")
}
if msgParam["text"] == "" {
t.Fatal("msgParam.text is empty")
}
}
// -------- 3. msgParam is valid JSON string --------
func TestSupportGroupSenderMsgParamIsValidJSON(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
if err := s.Send(context.Background(), newTestNotification()); err != nil {
t.Fatalf("Send: %v", err)
}
raw := gs.lastSendBody["msgParam"].(string)
var mp map[string]any
if err := json.Unmarshal([]byte(raw), &mp); err != nil {
t.Fatalf("msgParam not valid JSON: %v", err)
}
// Verify required keys exist
if _, ok := mp["title"]; !ok {
t.Fatal("msgParam missing title")
}
if _, ok := mp["text"]; !ok {
t.Fatal("msgParam missing text")
}
}
// -------- 4. OAuth token reuse --------
func TestSupportGroupSenderTokenReuse(t *testing.T) {
gs := newGroupTestServer()
gs.tokenResponse = map[string]any{"accessToken": "reused-token", "expireIn": 7200}
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
// Send twice
if err := s.Send(context.Background(), newTestNotification()); err != nil {
t.Fatalf("first Send: %v", err)
}
if err := s.Send(context.Background(), newTestNotification()); err != nil {
t.Fatalf("second Send: %v", err)
}
// Token should only be fetched once
if gs.tokenCalls != 1 {
t.Fatalf("tokenCalls = %d, want 1 (token should be reused)", gs.tokenCalls)
}
if gs.sendCalls != 2 {
t.Fatalf("sendCalls = %d, want 2", gs.sendCalls)
}
}
// -------- 5. no redirects --------
func TestSupportGroupSenderRejectsRedirect(t *testing.T) {
// Create a target server
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
// Create a redirector
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/groupMessages/send", http.StatusFound)
}))
defer redirector.Close()
_, err := newSupportGroupSenderForTest("appKey", "appSecret", "cid-abc", redirector.Client(),
redirector.URL+"/token", redirector.URL+"/groupMessages/send")
if err != nil {
t.Fatalf("newSupportGroupSenderForTest: %v", err)
}
// token endpoint also redirects
redirector2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/token", http.StatusFound)
}))
defer redirector2.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", redirector2.Client(),
redirector2.URL+"/token", redirector2.URL+"/groupMessages/send")
err = s.Send(context.Background(), newTestNotification())
if err == nil {
t.Fatal("expected redirect error, got nil")
}
if !strings.Contains(err.Error(), "redirect") {
t.Fatalf("error = %v, want containing 'redirect'", err)
}
}
// -------- 6. non-2xx sanitized error --------
func TestSupportGroupSenderNon2xxSanitized(t *testing.T) {
gs := newGroupTestServer()
gs.sendStatus = http.StatusInternalServerError
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
err := s.Send(context.Background(), newTestNotification())
if err == nil {
t.Fatal("expected error for 500, got nil")
}
errStr := err.Error()
// Error must NOT contain clientSecret
if strings.Contains(errStr, "appSecret") {
t.Fatalf("error leaks clientSecret: %s", errStr)
}
// Error must NOT contain access token
if strings.Contains(errStr, "test-token") {
t.Fatalf("error leaks access token: %s", errStr)
}
// Error must NOT contain conversation ID
if strings.Contains(errStr, "cid-abc") {
t.Fatalf("error leaks openConversationId: %s", errStr)
}
// Error must NOT contain question
if strings.Contains(errStr, "怎么配置") {
t.Fatalf("error leaks question: %s", errStr)
}
// Error must NOT contain answer
if strings.Contains(errStr, "钉钉开放平台") {
t.Fatalf("error leaks answer: %s", errStr)
}
}
// -------- 7. business failure (non-success response) sanitized --------
func TestSupportGroupSenderBusinessFailureSanitized(t *testing.T) {
gs := newGroupTestServer()
gs.sendStatus = http.StatusOK
// Simulate a business error: DingTalk returns 200 but with error code
gs.sendResponse = map[string]any{
"errcode": 40035,
"errmsg": "不合法的参数",
}
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
err := s.Send(context.Background(), newTestNotification())
if err == nil {
t.Fatal("expected business error, got nil")
}
errStr := err.Error()
if strings.Contains(errStr, "appSecret") {
t.Fatalf("error leaks clientSecret: %s", errStr)
}
if strings.Contains(errStr, "test-token") {
t.Fatalf("error leaks access token: %s", errStr)
}
if strings.Contains(errStr, "cid-abc") {
t.Fatalf("error leaks openConversationId: %s", errStr)
}
if strings.Contains(errStr, "怎么配置") {
t.Fatalf("error leaks question: %s", errStr)
}
}
// -------- 8. long Unicode truncation / 64KiB limit --------
func TestSupportGroupSenderLongUnicodeTruncation(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
// Create a notification with a very long current and historical question.
longQ := strings.Repeat("你好", 300)
longPrevious := strings.Repeat("世界", 600)
notif := newTestNotification()
notif.Question = longQ
notif.PreviousQuestions = []string{longPrevious}
if err := s.Send(context.Background(), notif); err != nil {
t.Fatalf("Send with long content: %v", err)
}
// The msgParam text should contain truncated versions
raw := gs.lastSendBody["msgParam"].(string)
var mp map[string]string
if err := json.Unmarshal([]byte(raw), &mp); err != nil {
t.Fatalf("msgParam not valid JSON: %v", err)
}
text := mp["text"]
// Both current and historical questions are present and rune-truncated.
if !strings.Contains(text, "你好") {
t.Fatal("text missing current question content")
}
if !strings.Contains(text, "世界") {
t.Fatal("text missing historical question content")
}
// The full body size must be <= 64KiB
bodyBytes, _ := json.Marshal(gs.lastSendBody)
if len(bodyBytes) > 64*1024 {
t.Fatalf("body size = %d, exceeds 64KiB", len(bodyBytes))
}
}
func TestSupportGroupSenderPayloadExceeds64KiB(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
notif := newTestNotification()
// Use a very long SenderName to push the rendered markdown + JSON
// serialization beyond 64 KiB. Question/Answer are truncated to 500/1000
// runes, so they won't contribute much. The SenderName is not truncated.
notif.SenderName = strings.Repeat("张", 70*1024) // ~70KB of UTF-8
err := s.Send(context.Background(), notif)
if err == nil {
t.Fatal("expected error for oversized payload, got nil")
}
if !strings.Contains(err.Error(), "exceeds") && !strings.Contains(err.Error(), "64") && !strings.Contains(err.Error(), "KiB") {
t.Fatalf("error = %v, want size-limit error", err)
}
}
// -------- 9. error JSON response does not leak --------
func TestSupportGroupSenderErrorJSONNotLeaked(t *testing.T) {
gs := newGroupTestServer()
gs.sendStatus = http.StatusBadRequest
// Return a large error body
gs.sendResponse = nil
// Override handler to return raw error JSON
gs.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
gs.tokenCalls++
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(gs.tokenResponse)
return
}
gs.sendCalls++
w.WriteHeader(http.StatusBadRequest)
// Write raw error JSON with sensitive-looking content
io.WriteString(w, `{"errcode":40035,"errmsg":"invalid parameter cid-abc","raw":"user appSecret leaked"}`)
})
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
err := s.Send(context.Background(), newTestNotification())
if err == nil {
t.Fatal("expected error, got nil")
}
errStr := err.Error()
// Error must not contain the raw response body
if strings.Contains(errStr, "40035") || strings.Contains(errStr, "invalid parameter") {
t.Fatalf("error leaks raw API response: %s", errStr)
}
if strings.Contains(errStr, "appSecret") {
t.Fatalf("error leaks clientSecret: %s", errStr)
}
if strings.Contains(errStr, "cid-abc") {
t.Fatalf("error leaks conversation ID: %s", errStr)
}
}
// -------- 10. validation: empty CaseID --------
func TestSupportGroupSenderRejectsEmptyCaseID(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
notif := newTestNotification()
notif.CaseID = ""
err := s.Send(context.Background(), notif)
if err == nil {
t.Fatal("expected error for empty CaseID, got nil")
}
if !strings.Contains(err.Error(), "CaseID") {
t.Fatalf("error = %v, want containing 'CaseID'", err)
}
}
// -------- 11. nil client installs hardened defaults --------
func TestSupportGroupSenderNilClientDefaults(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
// Use nil client and verify it still works with httptest URLs
s, err := newSupportGroupSenderForTest("appKey", "appSecret", "cid-abc", nil, gs.URL()+"/token", gs.URL()+"/groupMessages/send")
if err != nil {
t.Fatalf("newSupportGroupSenderForTest with nil client: %v", err)
}
if err := s.Send(context.Background(), newTestNotification()); err != nil {
t.Fatalf("Send with nil client: %v", err)
}
}
// -------- 12. Markdown content verification --------
func TestSupportGroupSenderMarkdownContent(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
notif := newTestNotification()
if err := s.Send(context.Background(), notif); err != nil {
t.Fatalf("Send: %v", err)
}
raw := gs.lastSendBody["msgParam"].(string)
var mp map[string]string
if err := json.Unmarshal([]byte(raw), &mp); err != nil {
t.Fatalf("msgParam not valid JSON: %v", err)
}
text := mp["text"]
title := mp["title"]
if title != "知识库未覆盖提醒" {
t.Fatalf("title = %q", title)
}
for _, want := range []string{"张三", "2026-07-24 10:16:35", "怎么配置", "之前的问题一", "之前的问题二", "Chinese"} {
if !strings.Contains(text, want) {
t.Fatalf("action-card text missing %q: %s", want, text)
}
}
for _, forbidden := range []string{"case-001", "回答摘要", "相关结果", "Score", "置信度"} {
if strings.Contains(text, forbidden) {
t.Fatalf("action-card text contains forbidden %q: %s", forbidden, text)
}
}
if mp["singleTitle"] != "去回答" || mp["singleURL"] == "" {
t.Fatalf("missing FAQ action: %#v", mp)
}
}
func TestSupportGroupSenderNoPreviousQuestions(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
notif := newTestNotification()
notif.PreviousQuestions = nil
if err := s.Send(context.Background(), notif); err != nil {
t.Fatalf("Send: %v", err)
}
if raw := gs.lastSendBody["msgParam"].(string); !strings.Contains(raw, "暂无历史问题") {
t.Fatalf("missing empty-history copy: %s", raw)
}
}
func TestSupportGroupSenderSendTestDoesNotRequireFAQBinding(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
sender, err := newSupportGroupSenderForTest(
"appKey", "appSecret", "cid-abc",
gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send",
)
if err != nil {
t.Fatal(err)
}
if err = sender.SendTest(context.Background()); err != nil {
t.Fatalf("SendTest: %v", err)
}
if gs.sendCalls != 1 {
t.Fatalf("sendCalls=%d want=1", gs.sendCalls)
}
if gs.lastSendBody["openConversationId"] != "cid-abc" || gs.lastSendBody["robotCode"] != "appKey" {
t.Fatalf("body=%v", gs.lastSendBody)
}
if gs.lastSendBody["msgKey"] != "sampleMarkdown" {
t.Fatalf("msgKey=%v want=sampleMarkdown", gs.lastSendBody["msgKey"])
}
var params map[string]string
if err = json.Unmarshal([]byte(gs.lastSendBody["msgParam"].(string)), &params); err != nil {
t.Fatal(err)
}
if params["title"] != "DobotAI 技术支持群绑定测试" || !strings.Contains(params["text"], "测试成功") {
t.Fatalf("params=%v", params)
}
}
func TestSupportGroupSenderRejectsMalformedOrIncompleteSuccessResponse(t *testing.T) {
tests := []struct {
name string
response any
}{
{name: "malformed JSON", response: json.RawMessage(`{`)},
{name: "missing processQueryKey", response: map[string]any{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gs := newGroupTestServer()
gs.sendResponse = tt.response
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
err := s.Send(context.Background(), newTestNotification())
if err == nil {
t.Fatal("expected fail-closed error, got nil")
}
if strings.Contains(err.Error(), "cid-abc") || strings.Contains(err.Error(), "怎么配置") {
t.Fatalf("error leaks notification data: %v", err)
}
})
}
}
+294 -31
View File
@@ -2,6 +2,7 @@ package dingtalk
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log"
@@ -9,6 +10,7 @@ import (
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
@@ -32,17 +34,19 @@ type TenantConfig struct {
}
type BotCallback struct {
ConversationType string
ConversationID string
Msgtype string
MsgID string
SenderNick string
SenderStaffID string
SenderID string
IsInAtList bool
SessionWebhook string
Text string
DownloadCode string
ConversationType string
ConversationID string
ConversationTitle string
Msgtype string
MsgID string
SenderNick string
SenderStaffID string
SenderID string
IsInAtList bool
SessionWebhook string
Text string
DownloadCode string
DownloadCodes []string
}
type QAAsker interface {
@@ -73,6 +77,45 @@ type ImageDownloader interface {
Download(ctx context.Context, downloadCode string) (core.ImageInput, error)
}
// ImagePublisher publishes a downloaded image to object storage and returns a
// stable HTTPS URL. Implementation satisfies outboundimage.Publisher.
type ImagePublisher interface {
Publish(ctx context.Context, data []byte, contentType string) (string, error)
}
type UserDirectory interface {
ResolveUser(ctx context.Context, tenantKey, staffID, fallbackName string) (directory.Profile, error)
}
type OutboundContentRewriter interface {
Rewrite(ctx context.Context, content string) string
}
type OutboundPartialSanitizer interface {
SanitizePartial(content string) string
}
type SettingsMessage struct {
TenantKey string
Channel string
StaffID string
UnionID string
DisplayName string
Department string
Text string
ConversationType string
ConversationID string
ConversationTitle string
}
type SettingsRouter interface {
Handle(context.Context, SettingsMessage) (handled bool, reply string, err error)
}
type LanguagePreferenceResolver interface {
GetPreference(context.Context, string, string, string) (string, error)
}
// FinalCardSender sends an AI card as the final reply. Implementations must
// not log err, outTrackID, or content at any level.
type FinalCardSender interface {
@@ -87,8 +130,13 @@ type StreamingCardSender interface {
}
type HandlerOptions struct {
ImageDownloader ImageDownloader
CardSender FinalCardSender
ImageDownloader ImageDownloader
ImagePublisher ImagePublisher
CardSender FinalCardSender
Directory UserDirectory
OutboundRewriter OutboundContentRewriter
SettingsRouter SettingsRouter
LanguageResolver LanguagePreferenceResolver
}
type Handler struct {
@@ -96,7 +144,12 @@ type Handler struct {
qa QAAsker
reply MarkdownSender
downloader ImageDownloader
imagePublisher ImagePublisher
card FinalCardSender
directory UserDirectory
outboundRewriter OutboundContentRewriter
settingsRouter SettingsRouter
languageResolver LanguagePreferenceResolver
now func() time.Time
cardUpdateInterval time.Duration
finalDeliveryTimeout time.Duration
@@ -136,7 +189,12 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
qa: qa,
reply: reply,
downloader: opts.ImageDownloader,
imagePublisher: opts.ImagePublisher,
card: opts.CardSender,
directory: opts.Directory,
outboundRewriter: opts.OutboundRewriter,
settingsRouter: opts.SettingsRouter,
languageResolver: opts.LanguageResolver,
now: time.Now,
cardUpdateInterval: 500 * time.Millisecond,
finalDeliveryTimeout: defaultHTTPTimeout,
@@ -170,13 +228,60 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
return errors.New("dingtalk handler: sender staffId/senderId is required")
}
triggerPerson := strings.TrimSpace(cb.SenderNick)
senderName := triggerPerson
department := ""
region := ""
actorUnionID := ""
if h.directory != nil {
profile, _ := h.directory.ResolveUser(ctx, h.cfg.TenantKey, externalUserID, senderName)
if name := strings.TrimSpace(profile.Name); name != "" {
senderName = name
department = strings.TrimSpace(profile.DepartmentName)
region = strings.TrimSpace(profile.Region)
}
actorUnionID = strings.TrimSpace(profile.UnionID)
}
// Trigger person snapshot falls back to official name when callback nick is blank.
if triggerPerson == "" {
triggerPerson = senderName
}
if h.settingsRouter != nil && strings.EqualFold(strings.TrimSpace(cb.Msgtype), msgTypeText) {
handled, settingsReply, err := h.settingsRouter.Handle(ctx, SettingsMessage{
TenantKey: h.cfg.TenantKey, Channel: h.cfg.Channel, StaffID: externalUserID,
UnionID: actorUnionID, DisplayName: senderName, Department: department, Text: strings.TrimSpace(cb.Text),
ConversationType: mapDingTalkConversationType(cb.ConversationType),
ConversationID: strings.TrimSpace(cb.ConversationID), ConversationTitle: strings.TrimSpace(cb.ConversationTitle),
})
if err != nil {
return fmt.Errorf("dingtalk settings: %w", err)
}
if handled {
if strings.TrimSpace(settingsReply) == "" {
return nil
}
return h.sendReply(ctx, webhookURL, settingsReply)
}
}
req := core.QARequest{
TenantKey: h.cfg.TenantKey,
Channel: h.cfg.Channel,
ExternalUserID: externalUserID,
ExternalMessageID: strings.TrimSpace(cb.MsgID),
SenderName: cb.SenderNick,
MessageType: strings.ToLower(strings.TrimSpace(cb.Msgtype)),
TenantKey: h.cfg.TenantKey,
Channel: h.cfg.Channel,
ExternalUserID: externalUserID,
ExternalMessageID: strings.TrimSpace(cb.MsgID),
SenderName: senderName,
TriggerPersonSnapshot: triggerPerson,
ActorUnionIDSnapshot: actorUnionID,
Department: department,
Region: region,
MessageType: strings.ToLower(strings.TrimSpace(cb.Msgtype)),
ConversationTitle: strings.TrimSpace(cb.ConversationTitle),
}
if h.languageResolver != nil {
if locale, err := h.languageResolver.GetPreference(ctx, h.cfg.TenantKey, h.cfg.Channel, externalUserID); err == nil {
req.ResponseLocale = strings.TrimSpace(locale)
}
}
switch cb.ConversationType {
case conversationTypeGroup:
@@ -187,6 +292,7 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
req.ExternalChatID = strings.TrimSpace(cb.ConversationID)
default:
req.ConversationType = core.ConversationDirect
req.ConversationTitle = ""
}
switch strings.ToLower(strings.TrimSpace(cb.Msgtype)) {
@@ -196,7 +302,8 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
return errors.New("dingtalk handler: text message content is empty")
}
case msgTypePicture:
if strings.TrimSpace(cb.DownloadCode) == "" {
codes := effectiveDownloadCodes(cb)
if len(codes) == 0 {
log.Printf("DingTalk image preparation failed: stage=missing_download_code")
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
@@ -204,16 +311,25 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
log.Printf("DingTalk image preparation failed: stage=downloader_unavailable")
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
image, err := h.downloader.Download(ctx, strings.TrimSpace(cb.DownloadCode))
if err != nil {
log.Printf("DingTalk image preparation failed: stage=%s", imageFailureStage(err))
return h.sendReply(ctx, webhookURL, imageErrorReply)
images := make([]core.ImageInput, 0, len(codes))
for _, code := range codes {
image, err := h.downloader.Download(ctx, code)
if err != nil {
log.Printf("DingTalk image preparation failed: stage=%s", imageFailureStage(err))
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
if h.imagePublisher != nil {
if url := h.publishQuestionImage(ctx, image); url != "" {
image.PublishedURL = url
}
}
images = append(images, image)
}
req.Query = strings.TrimSpace(cb.Text)
if req.Query == "" {
req.Query = imageQuery
}
req.Images = []core.ImageInput{image}
req.Images = images
default:
return h.sendReply(ctx, webhookURL, unsupportedMessageReply)
}
@@ -229,6 +345,13 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
return h.handleNonStreaming(ctx, webhookURL, req)
}
func mapDingTalkConversationType(value string) string {
if value == conversationTypeGroup {
return "group"
}
return "direct"
}
func (h *Handler) eventStreamingCapable() (StreamingCardSender, EventQAAsker) {
sc, ok := h.card.(StreamingCardSender)
if !ok {
@@ -256,6 +379,7 @@ func (h *Handler) streamingCapable() (StreamingCardSender, StreamingQAAsker) {
func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req core.QARequest) error {
result, err := h.qa.Ask(ctx, req)
if err != nil {
logQAFailure("non_streaming", err)
if replyErr := h.sendReply(ctx, webhookURL, qaErrorReply); replyErr != nil {
return replyErr
}
@@ -264,13 +388,14 @@ func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req
if result.Replayed && result.DeliveryStatus == core.DeliverySent {
return nil
}
content := h.rewriteOutboundContent(ctx, result.Answer)
if h.card != nil {
cardReq := CardRequest{
ConversationType: req.ConversationType,
ExternalUserID: req.ExternalUserID,
ExternalChatID: req.ExternalChatID,
Content: result.Answer,
Content: content,
}
if outTrackID, cardErr := h.card.SendFinalCard(ctx, cardReq); cardErr == nil {
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodCard, outTrackID, "")
@@ -278,7 +403,7 @@ func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req
}
log.Printf("DingTalk card delivery failed, falling back to markdown")
}
if err := h.sendReply(ctx, webhookURL, result.Answer); err != nil {
if err := h.sendReply(ctx, webhookURL, content); err != nil {
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
return err
}
@@ -299,6 +424,7 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
// Fallback: Ask → Markdown only (no SendFinalCard, no AskStream)
result, askErr := h.qa.Ask(ctx, req)
if askErr != nil {
logQAFailure("streaming_fallback", askErr)
if replyErr := h.sendReply(ctx, webhookURL, qaErrorReply); replyErr != nil {
return replyErr
}
@@ -307,7 +433,8 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
if result.Replayed && result.DeliveryStatus == core.DeliverySent {
return nil
}
if replyErr := h.sendReply(ctx, webhookURL, result.Answer); replyErr != nil {
content := h.rewriteOutboundContent(ctx, result.Answer)
if replyErr := h.sendReply(ctx, webhookURL, content); replyErr != nil {
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
return replyErr
}
@@ -317,24 +444,32 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
// Streaming observer with the configured update throttle (500ms by default).
var lastUpdate time.Time
var lastPartialContent string
observer := func(fullContent string) {
if strings.TrimSpace(fullContent) == "" {
return
}
now := h.now()
if lastUpdate.IsZero() || now.Sub(lastUpdate) >= h.cardUpdateInterval {
partialContent := h.sanitizeOutboundPartial(fullContent)
if strings.TrimSpace(partialContent) == "" || partialContent == lastPartialContent {
return
}
// Advance the throttle window before I/O so a failing DingTalk API
// cannot be hammered once per model chunk. The final update below is
// always attempted regardless of this timestamp.
lastUpdate = now
if updateErr := streamingCard.UpdateCard(ctx, outTrackID, fullContent, false, false); updateErr != nil {
if updateErr := streamingCard.UpdateCard(ctx, outTrackID, partialContent, false, false); updateErr != nil {
log.Printf("DingTalk streaming card update failed, continuing")
return
}
lastPartialContent = partialContent
}
}
result, err := streamingQA.AskStream(ctx, req, observer)
if err != nil || strings.TrimSpace(result.Answer) == "" {
logQAFailure("streaming", errOrEmptyAnswer(err, result.Answer))
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, core.QAResult{}, qaErrorReply, true)
}
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result, result.Answer, false)
@@ -352,12 +487,14 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
log.Printf("DingTalk event card start failed, falling back to markdown")
result, askErr := h.qa.Ask(ctx, req)
if askErr != nil {
logQAFailure("event_fallback", askErr)
return h.sendReply(ctx, webhookURL, qaErrorReply)
}
if result.Replayed && result.DeliveryStatus == core.DeliverySent {
return nil
}
if replyErr := h.sendReply(ctx, webhookURL, result.Answer); replyErr != nil {
content := h.rewriteOutboundContent(ctx, result.Answer)
if replyErr := h.sendReply(ctx, webhookURL, content); replyErr != nil {
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
return replyErr
}
@@ -372,7 +509,9 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
result, err := eventQA.AskEvents(ctx, req, func(event weknora.StreamEvent) {
if event.ResponseType == "answer" && strings.TrimSpace(event.FullAnswer) != "" {
answerStarted = true
pump.Offer(event.FullAnswer)
if partialContent := h.sanitizeOutboundPartial(event.FullAnswer); strings.TrimSpace(partialContent) != "" {
pump.Offer(partialContent)
}
return
}
if answerStarted {
@@ -387,6 +526,7 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
pump.Stop()
if err != nil || strings.TrimSpace(result.Answer) == "" {
logQAFailure("event_streaming", errOrEmptyAnswer(err, result.Answer))
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, core.QAResult{}, qaErrorReply, true)
}
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result, result.Answer, false)
@@ -398,6 +538,10 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
timeout = defaultHTTPTimeout
}
if !isError {
content = h.rewriteOutboundContent(ctx, content)
}
// Final card convergence must still be attempted when the QA context was
// canceled or timed out; otherwise the card can remain "processing".
finalCtx, cancelFinal := context.WithTimeout(context.WithoutCancel(ctx), timeout)
@@ -426,6 +570,27 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
return nil
}
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
if h.outboundRewriter == nil {
return sanitizeDisplayAnswer(content)
}
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
timeout = defaultHTTPTimeout
}
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
return sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content))
}
func (h *Handler) sanitizeOutboundPartial(content string) string {
sanitizer, ok := h.outboundRewriter.(OutboundPartialSanitizer)
if ok {
content = sanitizer.SanitizePartial(content)
}
return stableMarkdownPrefix(sanitizeDisplayAnswer(content))
}
func (h *Handler) recordDelivery(ctx context.Context, result core.QAResult, status, method, providerMessageID, failureCode string) {
if strings.TrimSpace(result.TurnID) == "" || strings.TrimSpace(result.AnswerMessageID) == "" {
return
@@ -477,6 +642,50 @@ func renderProgressEvent(event weknora.StreamEvent) string {
return ""
}
func errOrEmptyAnswer(err error, answer string) error {
if err != nil {
return err
}
if strings.TrimSpace(answer) == "" {
return weknora.ErrEmptyAnswer
}
return nil
}
func logQAFailure(mode string, err error) {
log.Printf("DingTalk QA failed: mode=%s stage=%s", mode, qaFailureStage(err))
}
func qaFailureStage(err error) string {
switch {
case err == nil:
return "unknown"
case errors.Is(err, context.Canceled):
return "context_canceled"
case errors.Is(err, context.DeadlineExceeded):
return "context_deadline"
case errors.Is(err, core.ErrTurnInProgress):
return "duplicate_or_in_progress"
case errors.Is(err, weknora.ErrEmptyAnswer):
return "empty_answer"
}
message := strings.ToLower(err.Error())
switch {
case strings.Contains(message, "begin turn"):
return "begin_turn"
case strings.Contains(message, "agent") && strings.Contains(message, "binding"):
return "agent_binding"
case strings.Contains(message, "session"):
return "session"
case strings.Contains(message, "weknora agent chat"):
return "agent_chat"
case strings.Contains(message, "required") || strings.Contains(message, "unsupported"):
return "validation"
default:
return "unknown"
}
}
func imageFailureStage(err error) string {
message := strings.ToLower(err.Error())
switch {
@@ -504,3 +713,57 @@ func (h *Handler) sendReply(ctx context.Context, webhookURL, text string) error
}
return nil
}
// publishQuestionImage best-effort publishes a downloaded image's bytes to
// object storage for traceability. It returns the stable HTTPS URL, or an
// empty string when publishing is unavailable or fails, so that an image
// question is never blocked by storage.
func (h *Handler) publishQuestionImage(ctx context.Context, image core.ImageInput) string {
if h.imagePublisher == nil {
return ""
}
data, err := decodeImageDataURI(image.DataURI)
if err != nil || len(data) == 0 {
log.Printf("DingTalk question image publish failed: stage=decode")
return ""
}
url, err := h.imagePublisher.Publish(ctx, data, image.MIMEType)
if err != nil || strings.TrimSpace(url) == "" {
log.Printf("DingTalk question image publish failed: stage=publish")
return ""
}
return strings.TrimSpace(url)
}
// decodeImageDataURI strips the "data:<mime>;base64," prefix and decodes the
// payload into raw bytes. It is used only to publish inbound images and never
// writes the data URI to storage.
func decodeImageDataURI(dataURI string) ([]byte, error) {
const prefix = "base64,"
index := strings.Index(dataURI, prefix)
if index < 0 {
return nil, errors.New("dingtalk image: data URI missing base64 payload")
}
return base64.StdEncoding.DecodeString(dataURI[index+len(prefix):])
}
// effectiveDownloadCodes returns the ordered, de-duplicated list of image
// download codes for a picture callback. DownloadCode equals the first code
// and DownloadCodes holds every code (richText with several pictures), so the
// single value is used to canonicalize the order without duplication.
func effectiveDownloadCodes(cb BotCallback) []string {
var codes []string
seen := make(map[string]struct{})
for _, code := range append([]string{cb.DownloadCode}, cb.DownloadCodes...) {
code = strings.TrimSpace(code)
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
codes = append(codes, code)
}
return codes
}
+434 -6
View File
@@ -3,11 +3,13 @@ package dingtalk
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
@@ -31,6 +33,17 @@ func (f *fakeQA) RecordDelivery(_ context.Context, update core.DeliveryUpdate) e
return f.deliveryErr
}
type fakeDirectory struct {
profile directory.Profile
err error
calls int
}
func (f *fakeDirectory) ResolveUser(context.Context, string, string, string) (directory.Profile, error) {
f.calls++
return f.profile, f.err
}
type fakeReply struct {
called bool
lastURL string
@@ -192,6 +205,44 @@ func TestHandleDirectTextCallsQAAndRepliesAnswer(t *testing.T) {
}
}
func TestHandleDirectUsesDirectoryProfile(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
profiles := &fakeDirectory{profile: directory.Profile{Name: "张三", DepartmentName: "技术支持部", Region: "APAC"}}
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
if err != nil {
t.Fatalf("NewHandlerWithOptions: %v", err)
}
data := directTextCallback()
data.SenderNick = "钉钉昵称"
if err := h.Handle(context.Background(), data); err != nil {
t.Fatalf("Handle: %v", err)
}
if profiles.calls != 1 || qa.lastReq.SenderName != "张三" || qa.lastReq.Department != "技术支持部" || qa.lastReq.Region != "APAC" {
t.Fatalf("calls=%d request=%+v", profiles.calls, qa.lastReq)
}
}
// TestHandleDirectoryFailureFallsBackToSenderNick validates that when directory
// resolution fails, SenderName falls back to the callback SenderNick.
func TestHandleDirectoryFailureFallsBackToSenderNick(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
profiles := &fakeDirectory{err: errors.New("directory unavailable")}
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
if err != nil {
t.Fatalf("NewHandlerWithOptions: %v", err)
}
data := directTextCallback()
data.SenderNick = " 回调昵称 "
if err := h.Handle(context.Background(), data); err != nil {
t.Fatalf("Handle: %v", err)
}
if qa.lastReq.SenderName != "回调昵称" || qa.lastReq.Department != "" || qa.lastReq.Region != "" {
t.Fatalf("request=%+v", qa.lastReq)
}
}
func TestHandleDirectTextFallsBackToSenderID(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
@@ -676,6 +727,46 @@ func TestHandleCardSuccessDirectFieldsCorrectNoMarkdown(t *testing.T) {
}
}
func TestHandleNonStreamingRewritesFinalCardAndMarkdownFallback(t *testing.T) {
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
signed := "https://img.example/signed.jpg"
answer := "说明\n![图](" + resource + ")"
for _, tc := range []struct {
name string
cardErr error
wantReply bool
}{
{name: "card"},
{name: "markdown fallback", cardErr: errors.New("card failed"), wantReply: true},
} {
t.Run(tc.name, func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: answer}}
reply := &fakeReply{}
card := &fakeCard{returnID: "track", err: tc.cardErr}
h := newTestHandlerWithCard(t, qa, reply, card)
rewriter := &fakeOutboundRewriter{from: resource, to: signed}
h.outboundRewriter = rewriter
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if !card.called || reply.called != tc.wantReply {
t.Fatalf("card=%t reply=%t", card.called, reply.called)
}
content := card.lastReq.Content
if tc.wantReply {
content = reply.lastText
}
if strings.Contains(content, resource) || !strings.Contains(content, signed) {
t.Fatalf("outbound content=%q", content)
}
if rewriter.calls != 1 {
t.Fatalf("rewrite calls=%d, want 1", rewriter.calls)
}
})
}
}
func TestHandleCardSuccessGroupChatIDCorrect(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "group card answer"}}
reply := &fakeReply{}
@@ -890,6 +981,23 @@ type cardUpdate struct {
isError bool
}
type fakeOutboundRewriter struct {
calls int
partialCalls int
from string
to string
}
func (f *fakeOutboundRewriter) Rewrite(_ context.Context, content string) string {
f.calls++
return strings.ReplaceAll(content, f.from, f.to)
}
func (f *fakeOutboundRewriter) SanitizePartial(content string) string {
f.partialCalls++
return strings.ReplaceAll(content, f.from, "")
}
func (f *fakeStreamingCard) SendFinalCard(_ context.Context, req CardRequest) (string, error) {
f.finalCalled = true
f.finalReq = req
@@ -1073,11 +1181,14 @@ func TestHandleStreamingThrottleClockAdvances(t *testing.T) {
func TestHandleStreamingStartFailsFallbackAskMarkdown(t *testing.T) {
card := &fakeStreamingCard{startErr: errors.New("card API unavailable")}
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
qa := &fakeStreamingQA{
result: core.QAResult{Answer: "**fallback markdown**"},
result: core.QAResult{Answer: "**fallback markdown**\n![图](" + resource + ")"},
}
reply := &fakeReply{}
h := newTestHandlerWithStreaming(t, qa, reply, card)
rewriter := &fakeOutboundRewriter{from: resource, to: "https://img.example/signed.jpg"}
h.outboundRewriter = rewriter
err := h.Handle(context.Background(), directTextCallback())
if err != nil {
@@ -1106,8 +1217,11 @@ func TestHandleStreamingStartFailsFallbackAskMarkdown(t *testing.T) {
if !reply.called {
t.Fatal("SendMarkdown must be called on Start failure")
}
if reply.lastText != "**fallback markdown**" {
t.Fatalf("markdown text = %q, want fallback markdown", reply.lastText)
if strings.Contains(reply.lastText, resource) || !strings.Contains(reply.lastText, "https://img.example/signed.jpg") {
t.Fatalf("markdown text = %q, want rewritten fallback markdown", reply.lastText)
}
if rewriter.calls != 1 {
t.Fatalf("rewrite calls=%d, want 1", rewriter.calls)
}
// No card updates
@@ -1561,15 +1675,16 @@ func TestRenderProgressEventUsesOnlySafeFixedText(t *testing.T) {
}
func TestHandleEventStreamingFinalizesLatestAnswerWithoutMarkdown(t *testing.T) {
rawAnswer := "完整答案[[ref id=\"c8\"]]"
card := &fakeStreamingCard{startID: "track-events"}
qa := &fakeEventQA{
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: "完整答案"}},
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: rawAnswer}},
events: []weknora.StreamEvent{
{ResponseType: "tool_call", ToolName: "query_understand"},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "references", ReferenceCount: 3},
{ResponseType: "answer", FullAnswer: "部分"},
{ResponseType: "answer", FullAnswer: "完整答案", Done: true},
{ResponseType: "answer", FullAnswer: "部分[[ref"},
{ResponseType: "answer", FullAnswer: rawAnswer, Done: true},
},
}
reply := &fakeReply{}
@@ -1591,6 +1706,9 @@ func TestHandleEventStreamingFinalizesLatestAnswerWithoutMarkdown(t *testing.T)
if last.content != "完整答案" || !last.isFinalize || last.isError {
t.Fatalf("final update=%+v", last)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
if reply.called {
t.Fatal("SendMarkdown must not be called")
}
@@ -1796,3 +1914,313 @@ func TestDeliveryAuditEventStreamingProductionPath(t *testing.T) {
}
requireDelivery(t, qa.deliveries, core.DeliverySent, core.DeliveryMethodCard, "event-track-1", "")
}
func TestHandlePassesConversationTitleToQARequest(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
h := newTestHandler(t, qa, reply)
t.Run("group conversation title propagated", func(t *testing.T) {
cb := groupTextCallback()
cb.ConversationTitle = "项目讨论群"
if err := h.Handle(context.Background(), cb); err != nil {
t.Fatalf("Handle: %v", err)
}
if qa.lastReq.ConversationTitle != "项目讨论群" {
t.Fatalf("ConversationTitle = %q, want 项目讨论群", qa.lastReq.ConversationTitle)
}
})
t.Run("direct conversation title is empty", func(t *testing.T) {
qa2 := &fakeQA{result: core.QAResult{Answer: "ok"}}
h2 := newTestHandler(t, qa2, &fakeReply{})
cb := directTextCallback()
cb.ConversationTitle = "should not appear"
if err := h2.Handle(context.Background(), cb); err != nil {
t.Fatalf("Handle: %v", err)
}
if qa2.lastReq.ConversationTitle != "" {
t.Fatalf("ConversationTitle = %q, want empty for direct", qa2.lastReq.ConversationTitle)
}
})
}
// TestActorUnionIDSnapshotPropagation verifies the actor_union_id_snapshot
// is populated from the directory profile UnionID when available.
func TestActorUnionIDSnapshotPropagation(t *testing.T) {
t.Run("resolved profile with union ID captures snapshot", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
profiles := &fakeDirectory{profile: directory.Profile{
Name: "张三", UnionID: "union-abc-123", DepartmentName: "技术部", Region: "APAC",
}}
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
if err != nil {
t.Fatalf("NewHandlerWithOptions: %v", err)
}
data := directTextCallback()
data.SenderNick = "钉钉昵称"
if err := h.Handle(context.Background(), data); err != nil {
t.Fatalf("Handle: %v", err)
}
if qa.lastReq.ActorUnionIDSnapshot != "union-abc-123" {
t.Fatalf("ActorUnionIDSnapshot = %q, want union-abc-123", qa.lastReq.ActorUnionIDSnapshot)
}
if qa.lastReq.SenderName != "张三" {
t.Fatalf("SenderName = %q, want 张三", qa.lastReq.SenderName)
}
if qa.lastReq.TriggerPersonSnapshot != "钉钉昵称" {
t.Fatalf("TriggerPersonSnapshot = %q, want 钉钉昵称", qa.lastReq.TriggerPersonSnapshot)
}
})
t.Run("blank profile union leaves snapshot empty", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
profiles := &fakeDirectory{profile: directory.Profile{
Name: "张三", UnionID: "", DepartmentName: "技术部", Region: "APAC",
}}
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
if err != nil {
t.Fatalf("NewHandlerWithOptions: %v", err)
}
data := directTextCallback()
data.SenderNick = "钉钉昵称"
if err := h.Handle(context.Background(), data); err != nil {
t.Fatalf("Handle: %v", err)
}
if qa.lastReq.ActorUnionIDSnapshot != "" {
t.Fatalf("ActorUnionIDSnapshot = %q, want empty", qa.lastReq.ActorUnionIDSnapshot)
}
})
t.Run("directory failure leaves union snapshot empty", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
profiles := &fakeDirectory{err: errors.New("directory unavailable")}
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
if err != nil {
t.Fatalf("NewHandlerWithOptions: %v", err)
}
data := directTextCallback()
data.SenderNick = "钉钉昵称"
if err := h.Handle(context.Background(), data); err != nil {
t.Fatalf("Handle: %v", err)
}
if qa.lastReq.ActorUnionIDSnapshot != "" {
t.Fatalf("ActorUnionIDSnapshot = %q, want empty on directory failure", qa.lastReq.ActorUnionIDSnapshot)
}
})
t.Run("no directory leaves union snapshot empty", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
h := newTestHandler(t, qa, reply)
data := directTextCallback()
data.SenderNick = "钉钉昵称"
if err := h.Handle(context.Background(), data); err != nil {
t.Fatalf("Handle: %v", err)
}
if qa.lastReq.ActorUnionIDSnapshot != "" {
t.Fatalf("ActorUnionIDSnapshot = %q, want empty without directory", qa.lastReq.ActorUnionIDSnapshot)
}
})
}
// TestTriggerPersonSnapshotRegression proves the trigger_person_snapshot
// contract: the callback SenderNick becomes TriggerPersonSnapshot, the
// directory official Name remains SenderName, and a blank callback nick
// falls back to the official name.
func TestTriggerPersonSnapshotRegression(t *testing.T) {
t.Run("callback nick becomes trigger person snapshot", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
profiles := &fakeDirectory{profile: directory.Profile{Name: "张三", DepartmentName: "技术部", Region: "APAC"}}
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
if err != nil {
t.Fatalf("NewHandlerWithOptions: %v", err)
}
data := directTextCallback()
data.SenderNick = "钉钉昵称"
if err := h.Handle(context.Background(), data); err != nil {
t.Fatalf("Handle: %v", err)
}
if qa.lastReq.TriggerPersonSnapshot != "钉钉昵称" {
t.Fatalf("TriggerPersonSnapshot = %q, want 钉钉昵称", qa.lastReq.TriggerPersonSnapshot)
}
if qa.lastReq.SenderName != "张三" {
t.Fatalf("SenderName = %q, want 张三", qa.lastReq.SenderName)
}
})
t.Run("blank callback nick falls back to official name", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
profiles := &fakeDirectory{profile: directory.Profile{Name: "张三", DepartmentName: "技术部", Region: "APAC"}}
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
if err != nil {
t.Fatalf("NewHandlerWithOptions: %v", err)
}
data := directTextCallback()
data.SenderNick = ""
if err := h.Handle(context.Background(), data); err != nil {
t.Fatalf("Handle: %v", err)
}
if qa.lastReq.TriggerPersonSnapshot != "张三" {
t.Fatalf("TriggerPersonSnapshot = %q, want 张三 on blank nick", qa.lastReq.TriggerPersonSnapshot)
}
if qa.lastReq.SenderName != "张三" {
t.Fatalf("SenderName = %q, want 张三", qa.lastReq.SenderName)
}
})
}
func TestHandleStreamingRewritesOnlyFinalOutboundImageContent(t *testing.T) {
card := &fakeStreamingCard{startID: "track-image-final"}
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
answer := "操作说明\n![步骤图](" + resource + ")"
qa := &fakeStreamingQA{chunks: []string{answer}, result: core.QAResult{Answer: answer}}
reply := &fakeReply{}
rewriter := &fakeOutboundRewriter{from: resource, to: "https://img.example/signed.jpg"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
h.outboundRewriter = rewriter
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if len(card.updates) < 2 {
t.Fatalf("updates=%d", len(card.updates))
}
if strings.Contains(card.updates[0].content, resource) {
t.Fatalf("streaming intermediate content leaked internal resource: %q", card.updates[0].content)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || strings.Contains(final.content, resource) || !strings.Contains(final.content, "https://img.example/signed.jpg") {
t.Fatalf("final content=%q finalize=%t", final.content, final.isFinalize)
}
if rewriter.calls != 1 {
t.Fatalf("rewrite calls=%d, want 1", rewriter.calls)
}
if rewriter.partialCalls == 0 {
t.Fatal("streaming intermediate content was not sanitized")
}
}
func TestHandleFiltersReferenceMarkersAtDingTalkDisplayBoundary(t *testing.T) {
rawAnswer := "结论[[ref id=\"c2\"]][[ref id=\"c4\"]]。"
wantDisplay := "结论。"
t.Run("final card", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: rawAnswer}}
reply := &fakeReply{}
card := &fakeCard{returnID: "track-reference-filter"}
h := newTestHandlerWithCard(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if card.lastReq.Content != wantDisplay {
t.Fatalf("card content=%q, want %q", card.lastReq.Content, wantDisplay)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
})
t.Run("markdown fallback", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: rawAnswer}}
reply := &fakeReply{}
card := &fakeCard{err: errors.New("card unavailable")}
h := newTestHandlerWithCard(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if reply.lastText != wantDisplay {
t.Fatalf("markdown content=%q, want %q", reply.lastText, wantDisplay)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
})
}
func TestHandleStreamingHoldsBackPartialReferenceMarkers(t *testing.T) {
rawAnswer := "答案正文[[ref id=\"c2\"]]继续。"
qa := &fakeStreamingQA{
chunks: []string{"答案正文[[ref", " id=\"c2\"]]继续。"},
result: core.QAResult{Answer: rawAnswer},
}
reply := &fakeReply{}
card := &fakeStreamingCard{startID: "track-stream-reference-filter"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if len(card.updates) < 2 {
t.Fatalf("updates=%d, want at least one partial plus final", len(card.updates))
}
for i, update := range card.updates {
if strings.Contains(update.content, "[[ref") {
t.Fatalf("update[%d] leaked reference marker: %q", i, update.content)
}
}
if card.updates[0].content != "答案正文" {
t.Fatalf("first partial=%q, want dangling marker held back", card.updates[0].content)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || final.content != "答案正文继续。" {
t.Fatalf("final content=%q finalize=%t", final.content, final.isFinalize)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
}
func TestHandleStreamingPublishesTableOnlyOnFinalUpdate(t *testing.T) {
answer := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n结论"
qa := &fakeStreamingQA{
chunks: []string{
"说明\n\n",
"| 检查项",
" | 操作要求 |\n",
"| --- | --",
"- |\n",
"| 接地检查 | 上电前确",
"认 |\n\n结论",
},
result: core.QAResult{Answer: answer},
}
reply := &fakeReply{}
card := &fakeStreamingCard{startID: "track-stream-table"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
callCount := int64(0)
h.now = func() time.Time {
callCount++
return time.Unix(0, callCount*int64(time.Second))
}
h.cardUpdateInterval = time.Millisecond
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
var partials []string
for _, update := range card.updates {
if !update.isFinalize {
partials = append(partials, update.content)
}
}
wantPartials := []string{"说明\n\n"}
if !reflect.DeepEqual(partials, wantPartials) {
t.Fatalf("partial updates=%q, want only pre-table stream %q", partials, wantPartials)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || final.content != answer {
t.Fatalf("final=%+v, want unchanged complete answer", final)
}
if qa.result.Answer != answer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
}
@@ -0,0 +1,80 @@
package dingtalk
import (
"context"
"encoding/base64"
"errors"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
type fakeImagePublisher struct {
published []byte
mime string
url string
err error
calls int
}
func (f *fakeImagePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
f.calls++
f.published = data
f.mime = contentType
return f.url, f.err
}
func TestDecodeImageDataURI(t *testing.T) {
raw := []byte("hello-image-bytes")
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
decoded, err := decodeImageDataURI(uri)
if err != nil {
t.Fatalf("decodeImageDataURI: %v", err)
}
if string(decoded) != string(raw) {
t.Fatalf("decoded=%q, want %q", decoded, raw)
}
if _, err := decodeImageDataURI("data:image/png;base64"); err == nil {
t.Fatal("expected error for missing base64 payload")
}
if _, err := decodeImageDataURI("no comma at all"); err == nil {
t.Fatal("expected error for non-data URI")
}
}
func TestPublishQuestionImagePublishesDecodedBytes(t *testing.T) {
pub := &fakeImagePublisher{url: "https://weknora-api.example/i/001"}
raw := []byte{0x89, 0x50, 0x4e, 0x47}
image := core.ImageInput{
DataURI: "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw),
MIMEType: "image/png",
}
h := &Handler{imagePublisher: pub}
got := h.publishQuestionImage(context.Background(), image)
if got != "https://weknora-api.example/i/001" {
t.Fatalf("url=%q, want published", got)
}
if pub.calls != 1 || pub.mime != "image/png" || string(pub.published) != string(raw) {
t.Fatalf("publisher calls=%d mime=%q bytes=%q", pub.calls, pub.mime, pub.published)
}
}
func TestPublishQuestionImageFailsClosedWithoutURL(t *testing.T) {
h := &Handler{imagePublisher: nil}
if got := h.publishQuestionImage(context.Background(), core.ImageInput{}); got != "" {
t.Fatalf("expected empty URL without publisher, got %q", got)
}
pub := &fakeImagePublisher{err: errors.New("oss down")}
h2 := &Handler{imagePublisher: pub}
image := core.ImageInput{
DataURI: "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("x")),
MIMEType: "image/png",
}
if got := h2.publishQuestionImage(context.Background(), image); got != "" {
t.Fatalf("expected empty URL on publish error, got %q", got)
}
}
@@ -0,0 +1,114 @@
package dingtalk
import "strings"
type markdownStreamLine struct {
start int
text string
inFence bool
}
// stableMarkdownPrefix returns the cumulative content that is safe to render
// while Markdown is still streaming. Once a table is confirmed, the table and
// every following token stay hidden until the terminal card update.
func stableMarkdownPrefix(content string) string {
lines := splitMarkdownStreamLines(content)
for i := 0; i < len(lines); i++ {
if lines[i].inFence || !isTableHeaderCandidate(lines[i].text) {
continue
}
if i == len(lines)-1 {
return content[:lines[i].start]
}
if lines[i+1].inFence || !isTableDelimiterCandidate(lines[i+1].text) {
continue
}
if !isCompleteTableDelimiter(lines[i+1].text) {
return content[:lines[i].start]
}
// DingTalk only lays out Markdown tables reliably on the terminal
// isFinalize=true update. Keep the table and every following partial
// token hidden; the authoritative final update bypasses this helper.
return content[:lines[i].start]
}
return content
}
func splitMarkdownStreamLines(content string) []markdownStreamLine {
lines := make([]markdownStreamLine, 0, strings.Count(content, "\n")+1)
fence := ""
for start := 0; start < len(content); {
end := strings.IndexByte(content[start:], '\n')
if end < 0 {
end = len(content)
} else {
end += start + 1
}
text := strings.TrimSuffix(content[start:end], "\n")
text = strings.TrimSuffix(text, "\r")
trimmed := strings.TrimSpace(text)
line := markdownStreamLine{start: start, text: text, inFence: fence != ""}
if marker := markdownFenceMarker(trimmed); marker != "" {
line.inFence = true
if fence == "" {
fence = marker
} else if marker == fence {
fence = ""
}
}
lines = append(lines, line)
start = end
}
return lines
}
func markdownFenceMarker(trimmed string) string {
switch {
case strings.HasPrefix(trimmed, "```"):
return "```"
case strings.HasPrefix(trimmed, "~~~"):
return "~~~"
default:
return ""
}
}
func isTableHeaderCandidate(line string) bool {
return strings.HasPrefix(strings.TrimSpace(line), "|")
}
func isTableDelimiterCandidate(line string) bool {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "|") {
return false
}
for _, r := range trimmed {
switch r {
case '|', ':', '-', ' ', '\t':
default:
return false
}
}
return true
}
func isCompleteTableDelimiter(line string) bool {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
cells := strings.Split(trimmed, "|")
if len(cells) < 2 {
return false
}
for _, cell := range cells {
cell = strings.TrimSpace(cell)
cell = strings.TrimPrefix(cell, ":")
cell = strings.TrimSuffix(cell, ":")
if len(cell) < 3 || strings.Trim(cell, "-") != "" {
return false
}
}
return true
}
@@ -0,0 +1,65 @@
package dingtalk
import (
"context"
"reflect"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
type pacedTableEventQA struct {
fakeStreamingQA
events []weknora.StreamEvent
pause time.Duration
}
func (f *pacedTableEventQA) AskEvents(_ context.Context, req core.QARequest, observer weknora.StreamObserver) (core.QAResult, error) {
f.lastReq = req
for _, event := range f.events {
if observer != nil {
observer(event)
}
time.Sleep(f.pause)
}
return f.result, f.err
}
func TestHandleEventStreamingPublishesTableOnlyOnFinalUpdate(t *testing.T) {
answer := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n结论"
qa := &pacedTableEventQA{
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: answer}},
events: []weknora.StreamEvent{
{ResponseType: "answer", FullAnswer: "说明\n\n"},
{ResponseType: "answer", FullAnswer: "说明\n\n| 检查项"},
{ResponseType: "answer", FullAnswer: "说明\n\n| 检查项 | 操作要求 |\n| --- | --"},
{ResponseType: "answer", FullAnswer: answer, Done: true},
},
pause: 20 * time.Millisecond,
}
reply := &fakeReply{}
card := &fakeStreamingCard{startID: "track-event-table"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
h.cardUpdateInterval = time.Millisecond
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
var partials []string
for _, update := range card.updates {
if !update.isFinalize {
partials = append(partials, update.content)
}
}
wantPartials := []string{"说明\n\n"}
if !reflect.DeepEqual(partials, wantPartials) {
t.Fatalf("event partial updates=%q, want only pre-table stream %q", partials, wantPartials)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || final.content != answer {
t.Fatalf("final=%+v, want unchanged complete answer", final)
}
}
@@ -0,0 +1,101 @@
package dingtalk
import "testing"
func TestStableMarkdownPrefixHoldsCompletedTableUntilFinalization(t *testing.T) {
content := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n表后正文仍在生成"
want := "说明\n\n"
if got := stableMarkdownPrefix(content); got != want {
t.Fatalf("stableMarkdownPrefix()=%q, want table and trailing stream held as %q", got, want)
}
}
func TestStableMarkdownPrefixHoldsBackTableUntilFinalization(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{
name: "split header",
content: "说明\n\n| 检查项",
want: "说明\n\n",
},
{
name: "header waiting for delimiter",
content: "说明\n\n| 检查项 | 操作要求 |\n",
want: "说明\n\n",
},
{
name: "partial delimiter",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --",
want: "说明\n\n",
},
{
name: "partial data row",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确",
want: "说明\n\n",
},
{
name: "complete table without terminator",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n",
want: "说明\n\n",
},
{
name: "complete table followed by blank line",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n后文",
want: "说明\n\n",
},
{
name: "complete table followed by heading",
content: "| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n## 后续",
want: "",
},
{
name: "second consecutive table remains hidden",
content: "| A | B |\n| --- | --- |\n| 1 | 2 |\n\n| C | D",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := stableMarkdownPrefix(tt.content); got != tt.want {
t.Fatalf("stableMarkdownPrefix()=%q, want %q", got, tt.want)
}
})
}
}
func TestStableMarkdownPrefixLeavesNonTablesUnchanged(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "ordinary text",
content: "普通段落继续流式显示。",
},
{
name: "ordinary inline pipe",
content: "状态使用 ready | running | done 表示。",
},
{
name: "pipe inside fenced code",
content: "```text\n| 这不是 | 表格 |\n| --- | --- |\n```",
},
{
name: "completed prose line starting with pipe",
content: "| 这是引用样式文本\n下一段普通文字",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := stableMarkdownPrefix(tt.content); got != tt.content {
t.Fatalf("stableMarkdownPrefix()=%q, want unchanged %q", got, tt.content)
}
})
}
}
@@ -0,0 +1,55 @@
package dingtalk
import (
"context"
"testing"
)
type fakeSettingsRouter struct {
called bool
handled bool
reply string
}
func (f *fakeSettingsRouter) Handle(_ context.Context, in SettingsMessage) (bool, string, error) {
f.called = true
if in.StaffID != "staff-123" {
panic("wrong identity")
}
return f.handled, f.reply, nil
}
func TestSettingsCommandBypassesQA(t *testing.T) {
qa := &fakeQA{}
reply := &fakeReply{}
router := &fakeSettingsRouter{handled: true, reply: "settings menu"}
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{SettingsRouter: router})
if err != nil {
t.Fatal(err)
}
cb := directTextCallback()
cb.Text = "/setting"
if err = h.Handle(context.Background(), cb); err != nil {
t.Fatal(err)
}
if !router.called {
t.Fatal("router not called")
}
if qa.called {
t.Fatal("settings must bypass QA")
}
if !reply.called || reply.lastText != "settings menu" {
t.Fatalf("reply=%+v", reply)
}
}
func TestOrdinaryQuestionStillCallsQA(t *testing.T) {
qa := &fakeQA{}
reply := &fakeReply{}
router := &fakeSettingsRouter{}
h, _ := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{SettingsRouter: router})
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if !qa.called {
t.Fatal("ordinary question must call QA")
}
}
+184 -43
View File
@@ -5,8 +5,13 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net/url"
"strings"
"sync"
"time"
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
dtsdk "github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
)
@@ -68,22 +73,37 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
}
msgtype := strings.ToLower(strings.TrimSpace(data.Msgtype))
cb := BotCallback{
ConversationType: data.ConversationType,
ConversationID: data.ConversationId,
Msgtype: msgtype,
MsgID: data.MsgId,
SenderNick: data.SenderNick,
SenderStaffID: data.SenderStaffId,
SenderID: data.SenderId,
IsInAtList: data.IsInAtList,
SessionWebhook: data.SessionWebhook,
Text: data.Text.Content,
ConversationType: data.ConversationType,
ConversationID: data.ConversationId,
ConversationTitle: data.ConversationTitle,
Msgtype: msgtype,
MsgID: data.MsgId,
SenderNick: data.SenderNick,
SenderStaffID: data.SenderStaffId,
SenderID: data.SenderId,
IsInAtList: data.IsInAtList,
SessionWebhook: data.SessionWebhook,
Text: data.Text.Content,
}
if msgtype == "richtext" {
rtMsgtype, rtText, rtCode := parseRichText(data.Content)
rtMsgtype, rtText, rtCodes := parseRichText(data.Content)
cb.Msgtype = rtMsgtype
cb.Text = rtText
cb.DownloadCode = rtCode
cb.DownloadCodes = rtCodes
if len(rtCodes) > 0 {
cb.DownloadCode = rtCodes[0]
}
return cb, nil
}
if msgtype == "link" {
linkURL := extractLinkMessageURL(data.Content)
if linkURL == "" {
linkURL = safeHTTPURL(data.Text.Content)
}
if linkURL != "" {
cb.Msgtype = msgTypeText
cb.Text = linkURL
}
return cb, nil
}
if msgtype == "picture" {
@@ -92,6 +112,68 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
return cb, nil
}
// extractLinkMessageURL extracts an HTTP(S) target from DingTalk link-card
// content. Only known URL and envelope keys are traversed so a card image URL
// (for example picUrl) cannot be mistaken for the message target.
func extractLinkMessageURL(raw interface{}) string {
if raw == nil {
return ""
}
b, err := json.Marshal(raw)
if err != nil {
return ""
}
var value interface{}
if json.Unmarshal(b, &value) != nil {
return ""
}
return findLinkMessageURL(value)
}
func findLinkMessageURL(value interface{}) string {
object, ok := value.(map[string]interface{})
if !ok {
return ""
}
for key, candidate := range object {
switch normalizeLinkField(key) {
case "messageurl", "linkurl", "singletitleurl", "href", "url":
if text, ok := candidate.(string); ok {
if target := safeHTTPURL(text); target != "" {
return target
}
}
}
}
for key, nested := range object {
switch normalizeLinkField(key) {
case "content", "link", "data", "card":
if target := findLinkMessageURL(nested); target != "" {
return target
}
}
}
return ""
}
func normalizeLinkField(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, "_", "")
return strings.ReplaceAll(value, "-", "")
}
func safeHTTPURL(raw string) string {
raw = strings.TrimSpace(raw)
u, err := url.ParseRequestURI(raw)
if err != nil || u.Host == "" {
return ""
}
if !strings.EqualFold(u.Scheme, "https") && !strings.EqualFold(u.Scheme, "http") {
return ""
}
return raw
}
// parseRichText parses the SDK Content field for richText messages.
// It handles three content shapes:
// 1. Direct object: {"richText": [...]}
@@ -99,19 +181,19 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
// 3. Nested envelope: {"content":{"richText":[...]}}
//
// Picture items are identified by a "type" field (case-insensitive trimmed)
// equal to "picture". The first non-empty downloadCode is used; subsequent
// picture items are ignored. Text items are joined with newlines.
// equal to "picture". All non-empty download codes are collected in order;
// text items are joined with newlines.
//
// Returns: (msgtype, text, downloadCode). Msgtype is "picture" if any picture
// Returns: (msgtype, text, downloadCodes). Msgtype is "picture" if any picture
// item exists, "text" if only text items, "richtext" if malformed/empty.
func parseRichText(raw interface{}) (string, string, string) {
func parseRichText(raw interface{}) (string, string, []string) {
items := extractRichTextItems(raw)
if len(items) == 0 {
return "richtext", "", ""
return "richtext", "", nil
}
var textParts []string
var downloadCode string
var downloadCodes []string
hasPicture := false
for _, item := range items {
@@ -119,11 +201,9 @@ func parseRichText(raw interface{}) (string, string, string) {
if itemType == "picture" {
hasPicture = true
if downloadCode == "" {
code := strings.TrimSpace(item.DownloadCode)
if code != "" {
downloadCode = code
}
code := strings.TrimSpace(item.DownloadCode)
if code != "" {
downloadCodes = append(downloadCodes, code)
}
continue
}
@@ -137,12 +217,12 @@ func parseRichText(raw interface{}) (string, string, string) {
text := strings.Join(textParts, "\n")
if hasPicture {
return "picture", text, downloadCode
return "picture", text, downloadCodes
}
if text != "" {
return "text", text, ""
return "text", text, nil
}
return "richtext", "", ""
return "richtext", "", nil
}
// richTextItem is a single item in a richText array.
@@ -220,19 +300,78 @@ func extractDownloadCode(raw interface{}) string {
return strings.TrimSpace(nested.Content.DownloadCode)
}
// StreamClient wraps the official DingTalk Stream SDK client. It wires
// robot callbacks into the existing Handler synchronously (no goroutines,
// no queue). The SDK default auto-reconnect stays enabled while running;
// Close disables it so the connection stays closed.
const callbackDedupTTL = 5 * time.Minute
type chatCallbackHandler interface {
Handle(context.Context, BotCallback) error
}
// newAsyncChatCallbackRouter acknowledges valid DingTalk frames immediately,
// then processes them outside the SDK callback lifetime. DingTalk retries slow
// callbacks, so retaining MsgIDs for the platform's five-minute retry window
// prevents a retry from creating a second streaming card.
func newAsyncChatCallbackRouter(handler chatCallbackHandler) func(context.Context, *chatbot.BotCallbackDataModel) ([]byte, error) {
var mu sync.Mutex
seen := make(map[string]time.Time)
return func(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
cb, err := BotCallbackFromSDK(data)
if err != nil {
return nil, err
}
sourceType := safeCallbackMessageType(data.Msgtype)
if sourceType == "link" || sourceType == "richtext" {
log.Printf("DingTalk callback normalized: source_msgtype=%s normalized_msgtype=%s text_present=%t", sourceType, safeCallbackMessageType(cb.Msgtype), strings.TrimSpace(cb.Text) != "")
}
messageID := strings.TrimSpace(cb.MsgID)
if messageID == "" {
return nil, errors.New("dingtalk stream: msgId is required")
}
now := time.Now()
mu.Lock()
for id, expiresAt := range seen {
if !expiresAt.After(now) {
delete(seen, id)
}
}
if expiresAt, exists := seen[messageID]; exists && expiresAt.After(now) {
mu.Unlock()
return nil, nil
}
seen[messageID] = now.Add(callbackDedupTTL)
mu.Unlock()
workCtx := context.WithoutCancel(ctx)
go func() {
if err := handler.Handle(workCtx, cb); err != nil {
log.Printf("DingTalk async callback failed: stage=handler")
}
}()
return nil, nil
}
}
func safeCallbackMessageType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "text", "picture", "richtext", "link":
return strings.ToLower(strings.TrimSpace(raw))
default:
return "other"
}
}
// StreamClient wraps the official DingTalk Stream SDK client. Robot callbacks
// are acknowledged before the potentially slow QA/card work begins. The SDK
// default auto-reconnect stays enabled while running; Close disables it so the
// connection stays closed.
type StreamClient struct {
inner *dtsdk.StreamClient
}
// NewStreamClient validates the config, builds the SDK client with the app
// credential and registers the chatbot callback router. The handler is
// invoked synchronously from the SDK callback; its return value only
// acknowledges the frame, replies always go through the session webhook.
func NewStreamClient(config StreamConfig, handler *Handler) (*StreamClient, error) {
// acknowledged immediately; replies always go through the session webhook.
func NewStreamClient(config StreamConfig, handler *Handler, feedbackHandlers ...*CardFeedbackHandler) (*StreamClient, error) {
if err := config.Validate(); err != nil {
return nil, err
}
@@ -242,22 +381,24 @@ func NewStreamClient(config StreamConfig, handler *Handler) (*StreamClient, erro
if handler == nil {
return nil, errors.New("dingtalk stream: handler is required")
}
if len(feedbackHandlers) > 1 {
return nil, errors.New("dingtalk stream: at most one card feedback handler is allowed")
}
if len(feedbackHandlers) == 1 && feedbackHandlers[0] == nil {
return nil, errors.New("dingtalk stream: card feedback handler is nil")
}
cred := dtsdk.NewAppCredentialConfig(strings.TrimSpace(config.ClientID), strings.TrimSpace(config.ClientSecret))
// Keep the SDK default AutoReconnect=true so runtime disconnects
// reconnect automatically; Close flips it off before closing.
inner := dtsdk.NewStreamClient(
dtsdk.WithAppCredential(cred),
)
inner.RegisterChatBotCallbackRouter(func(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
cb, err := BotCallbackFromSDK(data)
if err != nil {
return nil, err
}
if err := handler.Handle(ctx, cb); err != nil {
return nil, err
}
return nil, nil
})
inner.RegisterChatBotCallbackRouter(newAsyncChatCallbackRouter(handler))
if len(feedbackHandlers) == 1 {
inner.RegisterCardCallbackRouter(func(ctx context.Context, request *sdkcard.CardRequest) (*sdkcard.CardResponse, error) {
return feedbackHandlers[0].HandleSDK(ctx, request)
})
}
return &StreamClient{inner: inner}, nil
}
+153 -2
View File
@@ -1,12 +1,80 @@
package dingtalk
import (
"context"
"reflect"
"strings"
"sync"
"testing"
"time"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
)
type blockingCallbackHandler struct {
started chan struct{}
release chan struct{}
mu sync.Mutex
calls int
}
func (h *blockingCallbackHandler) Handle(_ context.Context, _ BotCallback) error {
h.mu.Lock()
h.calls++
if h.calls == 1 {
close(h.started)
}
h.mu.Unlock()
<-h.release
return nil
}
func (h *blockingCallbackHandler) callCount() int {
h.mu.Lock()
defer h.mu.Unlock()
return h.calls
}
func TestAsyncChatCallbackAcknowledgesBeforeWorkAndDeduplicatesInflightMessage(t *testing.T) {
handler := &blockingCallbackHandler{started: make(chan struct{}), release: make(chan struct{})}
router := newAsyncChatCallbackRouter(handler)
data := &chatbot.BotCallbackDataModel{
ConversationType: "1",
Msgtype: "text",
MsgId: "same-message",
SenderStaffId: "staff-1",
SessionWebhook: "https://oapi.dingtalk.com/robot/sendBySession?session=test",
Text: chatbot.BotCallbackDataTextModel{Content: "hello"},
}
done := make(chan error, 1)
go func() {
_, err := router(context.Background(), data)
done <- err
}()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("callback ACK waited for slow QA work")
}
select {
case <-handler.started:
case <-time.After(time.Second):
t.Fatal("async handler did not start")
}
if _, err := router(context.Background(), data); err != nil {
t.Fatal(err)
}
time.Sleep(20 * time.Millisecond)
if got := handler.callCount(); got != 1 {
t.Fatalf("same in-flight MsgID handled %d times, want 1", got)
}
close(handler.release)
}
func TestStreamConfigValidate(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -119,7 +187,7 @@ func TestBotCallbackFromSDK(t *testing.T) {
SessionWebhook: "https://oapi.dingtalk.com/robot/sendBySession?session=abc",
Text: " 你好 ",
}
if cb != want {
if !reflect.DeepEqual(cb, want) {
t.Fatalf("BotCallbackFromSDK() = %+v, want %+v", cb, want)
}
})
@@ -142,6 +210,46 @@ func TestBotCallbackFromSDK(t *testing.T) {
})
}
func TestBotCallbackFromSDKLinkCardPromotesTrustedURLToText(t *testing.T) {
t.Parallel()
const notableURL = "https://alidocs.dingtalk.com/i/nodes/base?sheetId=sheet&viewId=view"
tests := []struct {
name string
content interface{}
}{
{name: "direct content", content: map[string]interface{}{"title": "FAQ表", "messageUrl": notableURL}},
{name: "nested callback envelope", content: map[string]interface{}{"content": map[string]interface{}{"messageUrl": notableURL}}},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cb, err := BotCallbackFromSDK(&chatbot.BotCallbackDataModel{Msgtype: "link", MsgId: "link-1", Content: tt.content})
if err != nil {
t.Fatal(err)
}
if cb.Msgtype != "text" || cb.Text != notableURL {
t.Fatalf("callback=%+v, want promoted text URL", cb)
}
})
}
}
func TestBotCallbackFromSDKLinkCardRejectsUnsafeURL(t *testing.T) {
t.Parallel()
cb, err := BotCallbackFromSDK(&chatbot.BotCallbackDataModel{
Msgtype: "link",
MsgId: "link-unsafe",
Content: map[string]interface{}{"messageUrl": "javascript:alert(1)"},
})
if err != nil {
t.Fatal(err)
}
if cb.Msgtype != "link" || cb.Text != "" {
t.Fatalf("unsafe link must not be promoted: %+v", cb)
}
}
func TestBotCallbackFromSDKPicture(t *testing.T) {
t.Parallel()
@@ -343,7 +451,7 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
}
})
t.Run("first non-empty downloadCode used, subsequent ignored", func(t *testing.T) {
t.Run("all picture downloadCodes collected in order", func(t *testing.T) {
t.Parallel()
data := &chatbot.BotCallbackDataModel{
ConversationType: "1",
@@ -361,6 +469,9 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
if cb.DownloadCode != "code-first" {
t.Fatalf("DownloadCode = %q, want first code-first", cb.DownloadCode)
}
if len(cb.DownloadCodes) != 2 || cb.DownloadCodes[0] != "code-first" || cb.DownloadCodes[1] != "code-second" {
t.Fatalf("DownloadCodes = %+v, want [code-first code-second]", cb.DownloadCodes)
}
})
t.Run("picture item missing downloadCode still picture", func(t *testing.T) {
@@ -542,6 +653,46 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
})
}
func TestBotCallbackFromSDKConversationTitle(t *testing.T) {
t.Parallel()
t.Run("conversation title survives conversion", func(t *testing.T) {
t.Parallel()
data := &chatbot.BotCallbackDataModel{
ConversationType: "2",
ConversationId: "cid123",
Msgtype: "text",
MsgId: "msg-1",
ConversationTitle: "项目讨论群",
Text: chatbot.BotCallbackDataTextModel{Content: "hello"},
}
cb, err := BotCallbackFromSDK(data)
if err != nil {
t.Fatalf("BotCallbackFromSDK() error = %v", err)
}
if cb.ConversationTitle != "项目讨论群" {
t.Fatalf("ConversationTitle = %q, want 项目讨论群", cb.ConversationTitle)
}
})
t.Run("conversation title empty for direct", func(t *testing.T) {
t.Parallel()
data := &chatbot.BotCallbackDataModel{
ConversationType: "1",
Msgtype: "text",
MsgId: "msg-2",
Text: chatbot.BotCallbackDataTextModel{Content: "hi"},
}
cb, err := BotCallbackFromSDK(data)
if err != nil {
t.Fatalf("BotCallbackFromSDK() error = %v", err)
}
if cb.ConversationTitle != "" {
t.Fatalf("ConversationTitle = %q, want empty for direct", cb.ConversationTitle)
}
})
}
func TestNewStreamClientValidation(t *testing.T) {
t.Parallel()
h := mustTestHandler(t)
+60 -41
View File
@@ -17,13 +17,15 @@ var ErrTurnInProgress = errors.New("turn is already processing")
// ImageInput contains the image data for a QA request. The DataURI is a
// full data: URI (e.g. "data:image/png;base64,...") and must never be
// persisted to the audit log.
// persisted to the audit log. PublishedURL is an optional stable HTTPS URL
// produced after the image is published to object storage for traceability.
type ImageInput struct {
DataURI string
MIMEType string
SizeBytes int64
Width int
Height int
DataURI string
MIMEType string
SizeBytes int64
Width int
Height int
PublishedURL string
}
// MediaAudit records metadata about media in a turn for audit purposes.
@@ -37,16 +39,22 @@ type MediaAudit struct {
}
type QARequest struct {
TenantKey string
Channel string
ConversationType ConversationType
ExternalChatID string
ExternalUserID string
ExternalMessageID string
SenderName string
Query string
Images []ImageInput
MessageType string
TenantKey string
Channel string
ConversationType ConversationType
ConversationTitle string
ExternalChatID string
ExternalUserID string
ExternalMessageID string
SenderName string
TriggerPersonSnapshot string
ActorUnionIDSnapshot string
Department string
Region string
ResponseLocale string
Query string
Images []ImageInput
MessageType string
}
type Reference struct {
@@ -77,34 +85,44 @@ type SessionMapping struct {
ConversationType ConversationType
ExternalSubjectID string
WeKnoraSessionID string
DisplayTitle string
}
type SessionTitleUpdate func(ctx context.Context, sessionID, title string) error
type Turn struct {
ID string
TenantKey string
Channel string
ExternalMessageID string
SessionKey string
WeKnoraSessionID string
WeKnoraRequestID string
AgentID string
ConversationType ConversationType
ExternalChatID string
ExternalUserID string
SenderName string
MessageType string
ActorID string
ConversationID string
RequestMessageID string
AnswerMessageID string
AnswerDeliveryStatus string
AnswerDeliveryMethod string
Question string
Answer string
References []Reference
Media []MediaAudit
Status string
ErrorMessage string
ID string
TenantKey string
Channel string
ExternalMessageID string
SessionKey string
WeKnoraSessionID string
WeKnoraRequestID string
AgentID string
ConversationType ConversationType
ConversationTitle string
ExternalChatID string
ExternalUserID string
SenderName string
TriggerPersonSnapshot string
ActorUnionIDSnapshot string
DepartmentSnapshot string
RegionSnapshot string
MessageType string
ActorID string
ConversationID string
RequestMessageID string
AnswerMessageID string
AnswerDeliveryStatus string
AnswerDeliveryMethod string
Question string
Answer string
References []Reference
Media []MediaAudit
QuestionImageURLs []string
KnowledgeSearched bool
Status string
ErrorMessage string
}
// Delivery status constants for outbound messages.
@@ -143,6 +161,7 @@ var ErrDeliveryUpdateFailed = errors.New("delivery update failed: RowsAffected !
type Repository interface {
BeginTurn(ctx context.Context, turn Turn) (Turn, bool, error)
ResolveSession(ctx context.Context, mapping SessionMapping, create func(context.Context) (string, error)) (string, error)
SyncSessionTitle(ctx context.Context, mapping SessionMapping, title string, update SessionTitleUpdate) error
FinishTurn(ctx context.Context, turn Turn) error
RecordDelivery(ctx context.Context, du DeliveryUpdate) error
}
+63
View File
@@ -0,0 +1,63 @@
package coverage
import (
"context"
"fmt"
"time"
)
const (
// FailureCodeDingtalkGroupSendFailed is the stable failure code used when
// a dingtalk group send fails. Raw errors must never be persisted.
FailureCodeDingtalkGroupSendFailed = "dingtalk_group_send_failed"
)
// RunOnce claims a batch of pending support cases and sends each one via the
// Notifier. The parent context is checked before the claim. Each item gets a
// per-item timeout derived from d.SendTimeout. On success, the item is marked
// notified; on failure, it is marked with a stable failure code — raw errors
// are never persisted. Mark/Claim infrastructure errors are returned with
// stable wrapping. An empty batch is a successful no-op.
func (d *Dispatcher) RunOnce(ctx context.Context) (DispatcherStats, error) {
if err := ctx.Err(); err != nil {
return DispatcherStats{}, err
}
staleBefore := time.Now().Add(-d.ClaimLease)
cases, err := d.repo.Claim(ctx, d.BatchSize, d.MaxAttempts, staleBefore)
if err != nil {
return DispatcherStats{}, fmt.Errorf("dispatcher claim: %w", err)
}
stats := DispatcherStats{Claimed: len(cases)}
if stats.Claimed == 0 {
return stats, nil
}
for _, sc := range cases {
// Check parent context before each item.
if err := ctx.Err(); err != nil {
return stats, err
}
sendCtx, cancel := context.WithTimeout(ctx, d.SendTimeout)
sendErr := d.notifier.Send(sendCtx, sc.SupportNotification)
cancel()
if sendErr != nil {
// Only persist the stable failure code, never the raw error.
if err := d.repo.MarkFailed(ctx, sc.CaseID, FailureCodeDingtalkGroupSendFailed); err != nil {
return stats, fmt.Errorf("dispatcher mark failed: %w", err)
}
stats.Failed++
continue
}
if err := d.repo.MarkNotified(ctx, sc.CaseID); err != nil {
return stats, fmt.Errorf("dispatcher mark notified: %w", err)
}
stats.Notified++
}
return stats, nil
}
+427
View File
@@ -0,0 +1,427 @@
package coverage
import (
"context"
"errors"
"sync"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
// stubNotificationRepository is a test double for NotificationRepository.
type stubNotificationRepository struct {
mu sync.Mutex
cases []PendingSupportCase
claimErr error
markErr error
markFailedCalls map[string]string // caseID -> failureCode
markNotifiedIDs []string
}
func (s *stubNotificationRepository) Claim(ctx context.Context, limit, maxAttempts int, staleBefore time.Time) ([]PendingSupportCase, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.claimErr != nil {
return nil, s.claimErr
}
// Return a copy of the current cases up to limit
available := make([]PendingSupportCase, len(s.cases))
copy(available, s.cases)
if len(available) > limit {
available = available[:limit]
}
s.cases = s.cases[len(available):]
return available, nil
}
func (s *stubNotificationRepository) MarkNotified(ctx context.Context, caseID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.markErr != nil {
return s.markErr
}
s.markNotifiedIDs = append(s.markNotifiedIDs, caseID)
return nil
}
func (s *stubNotificationRepository) MarkFailed(ctx context.Context, caseID string, failureCode string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.markErr != nil {
return s.markErr
}
if s.markFailedCalls == nil {
s.markFailedCalls = make(map[string]string)
}
s.markFailedCalls[caseID] = failureCode
return nil
}
// stubNotifier is a test double for Notifier.
type stubNotifier struct {
mu sync.Mutex
sendErr error
sent []SupportNotification
// failOn maps caseID -> error to simulate per-item failure.
failOn map[string]error
}
func (s *stubNotifier) Send(ctx context.Context, notif SupportNotification) error {
s.mu.Lock()
defer s.mu.Unlock()
if err, ok := s.failOn[notif.CaseID]; ok {
return err
}
if s.sendErr != nil {
return s.sendErr
}
s.sent = append(s.sent, notif)
return nil
}
// blockingNotifier blocks until ctx is done, then returns ctx.Err().
// Used for timeout tests.
type blockingNotifier struct {
mu sync.Mutex
blocked bool
}
func (b *blockingNotifier) Send(ctx context.Context, notif SupportNotification) error {
b.mu.Lock()
b.blocked = true
b.mu.Unlock()
<-ctx.Done()
return ctx.Err()
}
func makePendingCase(id string, attempts int) PendingSupportCase {
return PendingSupportCase{
SupportNotification: SupportNotification{
CaseID: id,
Question: "test question " + id,
SenderName: "Sender " + id,
Channel: "dingtalk",
ConversationType: core.ConversationGroup,
AskedAt: time.Unix(1, 0),
PreviousQuestions: []string{"previous question"},
FAQRecordID: "record-" + id,
},
Attempts: attempts,
}
}
// TestDispatcherEmptyQueue verifies that RunOnce on an empty queue returns
// a zero stats with no error.
func TestDispatcherEmptyQueue(t *testing.T) {
repo := &stubNotificationRepository{}
notifier := &stubNotifier{}
dispatcher, err := NewDispatcher(repo, notifier)
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
stats, err := dispatcher.RunOnce(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stats.Claimed != 0 || stats.Notified != 0 || stats.Failed != 0 {
t.Fatalf("empty queue: got %+v, want all zero", stats)
}
}
// TestDispatcherAllSuccess verifies that all claimed cases are sent and
// marked as notified.
func TestDispatcherAllSuccess(t *testing.T) {
repo := &stubNotificationRepository{
cases: []PendingSupportCase{
makePendingCase("case-1", 0),
makePendingCase("case-2", 0),
makePendingCase("case-3", 0),
},
}
notifier := &stubNotifier{}
dispatcher, err := NewDispatcher(repo, notifier, WithBatchSize(10))
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
stats, err := dispatcher.RunOnce(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stats.Claimed != 3 {
t.Fatalf("Claimed = %d, want 3", stats.Claimed)
}
if stats.Notified != 3 {
t.Fatalf("Notified = %d, want 3", stats.Notified)
}
if stats.Failed != 0 {
t.Fatalf("Failed = %d, want 0", stats.Failed)
}
if len(notifier.sent) != 3 {
t.Fatalf("sent %d, want 3", len(notifier.sent))
}
if len(repo.markNotifiedIDs) != 3 {
t.Fatalf("markNotifiedIDs = %d, want 3", len(repo.markNotifiedIDs))
}
}
// TestDispatcherOneFailureContinues verifies that when one item fails,
// the dispatcher continues with the rest and marks the failed one.
func TestDispatcherOneFailureContinues(t *testing.T) {
repo := &stubNotificationRepository{
cases: []PendingSupportCase{
makePendingCase("case-1", 0),
makePendingCase("case-2", 0), // will fail
makePendingCase("case-3", 0),
},
}
notifier := &stubNotifier{
failOn: map[string]error{
"case-2": errors.New("simulated send failure"),
},
}
dispatcher, err := NewDispatcher(repo, notifier, WithBatchSize(10))
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
stats, err := dispatcher.RunOnce(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stats.Claimed != 3 {
t.Fatalf("Claimed = %d, want 3", stats.Claimed)
}
if stats.Notified != 2 {
t.Fatalf("Notified = %d, want 2", stats.Notified)
}
if stats.Failed != 1 {
t.Fatalf("Failed = %d, want 1", stats.Failed)
}
if notifier.sent[0].CaseID != "case-1" {
t.Fatalf("first sent = %s, want case-1", notifier.sent[0].CaseID)
}
if notifier.sent[1].CaseID != "case-3" {
t.Fatalf("second sent = %s, want case-3", notifier.sent[1].CaseID)
}
// case-2 must be marked as failed with stable code, not raw error
if code, ok := repo.markFailedCalls["case-2"]; !ok {
t.Fatal("case-2 must be marked as failed")
} else if code != "dingtalk_group_send_failed" {
t.Fatalf("failure code = %q, want dingtalk_group_send_failed", code)
}
// case-1 and case-3 must be marked notified
foundNotified := make(map[string]bool)
for _, id := range repo.markNotifiedIDs {
foundNotified[id] = true
}
if !foundNotified["case-1"] {
t.Fatal("case-1 must be marked notified")
}
if !foundNotified["case-3"] {
t.Fatal("case-3 must be marked notified")
}
}
// TestDispatcherFailureStableCodeOnly verifies that the raw error text is
// never written to the repository — only the stable failure code.
func TestDispatcherFailureStableCodeOnly(t *testing.T) {
repo := &stubNotificationRepository{
cases: []PendingSupportCase{
makePendingCase("case-1", 0),
},
}
notifier := &stubNotifier{
failOn: map[string]error{
"case-1": errors.New("secret token xyz123 leaked in error"),
},
}
dispatcher, err := NewDispatcher(repo, notifier)
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
_, err = dispatcher.RunOnce(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if code := repo.markFailedCalls["case-1"]; code != "dingtalk_group_send_failed" {
t.Fatalf("failure code = %q, want dingtalk_group_send_failed (raw error must never leak)", code)
}
}
// TestDispatcherMarkNotifiedErrorPropagation verifies that when
// MarkNotified fails, the error is returned to the caller.
func TestDispatcherMarkNotifiedErrorPropagation(t *testing.T) {
repo := &stubNotificationRepository{
cases: []PendingSupportCase{makePendingCase("case-1", 0)},
markErr: errors.New("db unreachable"),
}
notifier := &stubNotifier{}
dispatcher, err := NewDispatcher(repo, notifier)
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
_, err = dispatcher.RunOnce(context.Background())
if err == nil {
t.Fatal("expected error from MarkNotified, got nil")
}
}
// TestDispatcherMarkFailedErrorPropagation verifies that when MarkFailed
// fails, the error is returned to the caller.
func TestDispatcherMarkFailedErrorPropagation(t *testing.T) {
repo := &stubNotificationRepository{
cases: []PendingSupportCase{
makePendingCase("case-1", 0),
},
markErr: errors.New("db unreachable"),
}
notifier := &stubNotifier{
failOn: map[string]error{
"case-1": errors.New("send failed"),
},
}
dispatcher, err := NewDispatcher(repo, notifier)
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
_, err = dispatcher.RunOnce(context.Background())
if err == nil {
t.Fatal("expected error from MarkFailed, got nil")
}
}
// TestDispatcherClaimErrorPropagation verifies claim errors are returned.
func TestDispatcherClaimErrorPropagation(t *testing.T) {
repo := &stubNotificationRepository{
claimErr: errors.New("db unreachable"),
}
notifier := &stubNotifier{}
dispatcher, err := NewDispatcher(repo, notifier)
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
_, err = dispatcher.RunOnce(context.Background())
if err == nil {
t.Fatal("expected error from Claim, got nil")
}
}
// TestDispatcherPerItemTimeout verifies that the dispatcher applies a per-item
// timeout: when a notifier blocks forever, RunOnce completes within a
// reasonable time, Failed=1, and the stable failure code is used.
func TestDispatcherPerItemTimeout(t *testing.T) {
repo := &stubNotificationRepository{
cases: []PendingSupportCase{makePendingCase("case-1", 0)},
}
notifier := &blockingNotifier{}
// Use a 50ms timeout so the test is not flaky but still fast.
dispatcher, err := NewDispatcher(repo, notifier, WithSendTimeout(50*time.Millisecond))
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
start := time.Now()
stats, err := dispatcher.RunOnce(context.Background())
elapsed := time.Since(start)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stats.Failed != 1 {
t.Fatalf("Failed = %d, want 1", stats.Failed)
}
if stats.Claimed != 1 {
t.Fatalf("Claimed = %d, want 1", stats.Claimed)
}
// Verify the stable failure code was used
if code := repo.markFailedCalls["case-1"]; code != "dingtalk_group_send_failed" {
t.Fatalf("failure code = %q, want dingtalk_group_send_failed", code)
}
// RunOnce must complete in a reasonable time (not stuck forever).
// Use a generous upper bound: 5s is enough for any CI environment.
if elapsed > 5*time.Second {
t.Fatalf("RunOnce took %v, expected to complete within 5s", elapsed)
}
if !notifier.blocked {
t.Fatal("notifier was not called — timeout did not trigger")
}
}
// TestDispatcherContextCancel verifies the dispatcher respects context cancellation.
func TestDispatcherContextCancel(t *testing.T) {
repo := &stubNotificationRepository{
cases: []PendingSupportCase{makePendingCase("case-1", 0)},
}
notifier := &stubNotifier{}
dispatcher, err := NewDispatcher(repo, notifier, WithSendTimeout(10*time.Second))
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = dispatcher.RunOnce(ctx)
if err == nil {
t.Fatal("expected context error, got nil")
}
}
// TestDispatcherConstructorValidation verifies constructor input validation.
func TestDispatcherConstructorValidation(t *testing.T) {
tests := []struct {
name string
opts []DispatcherOption
wantErr bool
}{
{"nil repo", nil, true},
{"nil notifier only", nil, true},
{"batch too small", []DispatcherOption{WithBatchSize(0)}, true},
{"batch too large", []DispatcherOption{WithBatchSize(101)}, true},
{"maxAttempts too small", []DispatcherOption{WithMaxAttempts(0)}, true},
{"maxAttempts too large", []DispatcherOption{WithMaxAttempts(11)}, true},
{"valid defaults", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var repo *stubNotificationRepository
var notifier *stubNotifier
if tt.name != "nil repo" {
repo = &stubNotificationRepository{}
}
if tt.name != "nil notifier only" {
notifier = &stubNotifier{}
}
_, err := NewDispatcher(repo, notifier, tt.opts...)
if tt.wantErr && err == nil {
t.Fatal("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
// TestDispatcherBatchSizeLimit verifies batch size is capped at 100.
func TestDispatcherBatchSizeLimit(t *testing.T) {
repo := &stubNotificationRepository{
cases: []PendingSupportCase{},
}
notifier := &stubNotifier{}
d, err := NewDispatcher(repo, notifier, WithBatchSize(100))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.BatchSize != 100 {
t.Fatalf("BatchSize = %d, want 100", d.BatchSize)
}
}
+193
View File
@@ -0,0 +1,193 @@
package coverage
import (
"strings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
// noAnswerPhrases lists explicit "no answer" phrases that the knowledge base
// returns when it cannot find relevant information. These are matched as
// substrings after trimming whitespace.
var noAnswerPhrases = []string{
"未找到",
"没有找到",
"无法确定",
"暂无相关",
"暂无已发布的权威知识",
"知识库中没有",
"产品不存在于资料中",
}
// retrievalGapPhrases are only considered a no-answer signal when the answer
// also directs the user to a human channel. Requiring both signals avoids
// misclassifying valid negative results such as "未查询到异常记录".
var retrievalGapPhrases = []string{
"未查询到",
"未检索到",
"未能查询到",
"未能检索到",
}
var humanEscalationPhrases = []string{
"建议您联系",
"建议联系",
"请联系",
"联系技术支持",
"联系官方销售",
}
// Evaluator classifies a successful turn into a coverage verdict.
type Evaluator struct{}
// NewEvaluator creates the current Evaluator.
func NewEvaluator() *Evaluator {
return &Evaluator{}
}
// Version returns the current evaluator version.
func (e *Evaluator) Version() string {
return EvaluatorVersion
}
// Evaluate classifies a turn. It rejects non-succeeded turns. The evaluation
// rules (in priority order):
// 1. Failed/processing turns → ErrTurnNotSucceeded
// 2. Answer contains an explicit "no answer" phrase → uncovered_explicit_no_answer
// 3. Image turns (image media present) are classified by whether the question
// actually triggered a knowledge-base search:
// - has references → covered_has_references
// - no references + knowledge searched → uncovered_no_references
// - no references + no knowledge search → covered_visual_recognition
// (pure visual recognition is not a coverage miss)
// 4. Text turns: zero references → uncovered_no_references
// 5. Has references → covered_has_references
//
// v1 never uses a score threshold. TopScore captures the highest reference score
// (0 if no references).
func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
if turn.Status != "succeeded" {
return Decision{}, ErrTurnNotSucceeded
}
// Normalize answer for no-answer phrase detection.
trimmed := strings.TrimSpace(turn.Answer)
// Check for explicit no-answer phrases first — this takes priority
// over reference presence.
if isNoAnswer(trimmed) {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictUncovered,
ReasonCode: ReasonUncoveredExplicitNoAnswer,
ReferenceCount: len(turn.References),
TopScore: maxScore(turn.References),
}, nil
}
// Image turns are classified by whether knowledge search occurred.
if hasImageMedia(turn.Media) {
if len(turn.References) > 0 {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredHasReferences,
ReferenceCount: len(turn.References),
TopScore: maxScore(turn.References),
}, nil
}
if turn.KnowledgeSearched {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictUncovered,
ReasonCode: ReasonUncoveredNoReferences,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredVisualRecognition,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
// No references → uncovered.
if len(turn.References) == 0 {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictUncovered,
ReasonCode: ReasonUncoveredNoReferences,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
// Has references → covered (v1 never uses score threshold).
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredHasReferences,
ReferenceCount: len(turn.References),
TopScore: maxScore(turn.References),
}, nil
}
// hasImageMedia reports whether the turn carried at least one image input.
func hasImageMedia(media []core.MediaAudit) bool {
for _, m := range media {
if strings.EqualFold(strings.TrimSpace(m.Type), "image") {
return true
}
}
return false
}
func isNoAnswer(answer string) bool {
lower := strings.ToLower(answer)
for _, phrase := range noAnswerPhrases {
if strings.Contains(lower, strings.ToLower(phrase)) {
return true
}
}
return containsAny(lower, retrievalGapPhrases) && containsAny(lower, humanEscalationPhrases)
}
func containsAny(value string, phrases []string) bool {
for _, phrase := range phrases {
if strings.Contains(value, strings.ToLower(phrase)) {
return true
}
}
return false
}
func maxScore(refs []core.Reference) float64 {
var max float64
for _, r := range refs {
if r.Score > max {
max = r.Score
}
}
return max
}
+85
View File
@@ -0,0 +1,85 @@
package coverage
import (
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
func imageTurn(answer string, refs []core.Reference, searched bool) core.Turn {
return core.Turn{
ID: "img-turn",
Status: "succeeded",
MessageType: "image",
Answer: answer,
References: refs,
Media: []core.MediaAudit{{Type: "image", MIMEType: "image/jpeg"}},
KnowledgeSearched: searched,
}
}
func TestEvaluator_ImageVisualRecognitionNoSearch(t *testing.T) {
e := NewEvaluator()
turn := imageTurn("这张图片展示了一个手写的知识库数据处理与发布流程图。", nil, false)
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered {
t.Fatalf("verdict=%s, want covered (visual recognition is not a coverage miss)", d.Verdict)
}
if d.ReasonCode != ReasonCoveredVisualRecognition {
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonCoveredVisualRecognition)
}
}
func TestEvaluator_ImageSearchButNoReferencesIsUncovered(t *testing.T) {
e := NewEvaluator()
turn := imageTurn("根据图片中的错误信息,未能在知识库中找到对应的解决方案。", nil, true)
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered {
t.Fatalf("verdict=%s, want uncovered (image turn searched KB but got no refs)", d.Verdict)
}
if d.ReasonCode != ReasonUncoveredNoReferences {
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonUncoveredNoReferences)
}
}
func TestEvaluator_ImageWithReferencesIsCovered(t *testing.T) {
e := NewEvaluator()
turn := imageTurn("根据知识库资料,这个是故障代码E5,请检查...", []core.Reference{{ID: "r1", Score: 0.9}}, true)
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered {
t.Fatalf("verdict=%s, want covered", d.Verdict)
}
if d.ReasonCode != ReasonCoveredHasReferences {
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
}
}
func TestEvaluator_TextTurnNoReferencesStillUncovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "text-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
MessageType: "text",
References: []core.Reference{},
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredNoReferences {
t.Fatalf("verdict=%s reason=%s, want uncovered_no_references (text turn unchanged)", d.Verdict, d.ReasonCode)
}
}

Some files were not shown because too many files have changed in this diff Show More