Compare commits

..

49 Commits

Author SHA1 Message Date
姚章浩 fa938ca1eb docs: translate documentation and environment comments to Chinese 2026-09-10 16:59:22 +08:00
zheromiku 7661e1b8f7 Merge pull request 'feat(dingtalk): merge tested multimodal, reasoning, and support workflows' (#10) from feat/think-mode-support-alerts into main 2026-09-10 16:40:42 +08:00
姚章浩 bfa1b1b25f feat(dingtalk): add reasoning mode and support evidence 2026-09-10 16:24:56 +08:00
姚章浩 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
208 changed files with 23644 additions and 405 deletions
+36 -11
View File
@@ -1,27 +1,52 @@
# DobotAI configuration
# DobotAI 基础配置
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
DOBOTAI_INGRESS_TOKEN=replace-me
# Optional local-only audit API. Use a distinct secret from DOBOTAI_INGRESS_TOKEN.
# When set, DOBOTAI_TENANT_KEY must be set and the listen address must be loopback-only.
# 可选的仅本机审计 API。必须使用不同于 DOBOTAI_INGRESS_TOKEN 的独立密钥。
# 设置后必须同时设置 DOBOTAI_TENANT_KEY,且监听地址必须仅为回环地址。
DOBOTAI_ADMIN_TOKEN=
DOBOTAI_GROUP_SESSION_POLICY=shared
DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"replace-with-agent-id"}
# `/think` 路由必填。上面的基础绑定保持稳定的会话身份;以下配置仅选择每个 Turn 的执行 Agent。
DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer
DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning
# Official WeKnora API only
# 仅使用 WeKnora 官方 API
WEKNORA_BASE_URL=http://127.0.0.1:8080
WEKNORA_API_KEY=replace-me
# DingTalk Stream mode (official SDK). Leave all three empty to disable the
# DingTalk channel; set all three to enable it. Never commit real values.
# 钉钉 Stream 模式(官方 SDK)。三项均留空时禁用钉钉渠道;三项同时设置时启用。不得提交真实值。
DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET=
# Tenant key this DingTalk ingress serves (matches tenant_key used by QA).
# 该钉钉接入服务的租户键(必须与 QA 使用的 tenant_key 一致)。
DOBOTAI_TENANT_KEY=
# Optional DingTalk AI card template ID. Leave empty to use markdown-only replies.
# 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.
# 可选的钉钉 AI 卡片模板 ID。留空时仅使用 Markdown 回复。
# 设置后,成功的 QA 回答优先通过 AI 卡片发送;仅当卡片发送失败时才回退 Markdown。不得提交真实值。
DINGTALK_CARD_TEMPLATE_ID=
# 可选、临时的钉钉卡片调试白名单。设置后,只有该指定钉钉用户会在 per-user privateData 中收到 _CARD_DEBUG_TOOL_ENTRY=show。
# 非受控调试期间必须留空;不得提交真实用户 ID。
DINGTALK_CARD_DEBUG_USER_ID=
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
# 可选的钉钉 FAQ 表工作流,用于未覆盖问题。四项必须同时设置;四项均留空时禁用。
# 启用下方人工支持群通知时,此工作流为必需项。不得提交真实 ID。
DINGTALK_FAQ_TABLE_BASE_ID=
DINGTALK_FAQ_TABLE_SHEET_ID=
DINGTALK_FAQ_TABLE_VIEW_ID=
DINGTALK_FAQ_TABLE_OPERATOR_ID=
# 可选的钉钉卡片回答图片外发。任一项设置后,以下五项都必须设置。
# DobotAI 仅从 WEKNORA_BASE_URL 的 /files 鉴权下载 resource:// 图片,在 dobotai/im-images/ 下写入内容哈希副本,并生成有效期2小时的私有 OSS GET URL。
# Bucket 必须保持私有,并配置 OSS 生命周期规则,在1天后删除 dobotai/im-images/ 对象。不得提交真实凭据。
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=
# 可选的未覆盖回答人工支持群通知。该值是群的 openConversationId,不是 webhook。
# 留空时仍记录覆盖判定和待处理 Support Case,但不发送群通知。
# 启用后必须同时配置上方 FAQ 表工作流,使 ActionCard 能链接到准确的 FAQ 记录。不得提交真实群 ID。
DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID=
# 独立的 DobotAI PostgreSQL;不得指向 WeKnora 数据库
DATABASE_URL=postgres://dobotai:replace-me@127.0.0.1:5432/dobotai?sslmode=disable
+11
View File
@@ -5,6 +5,17 @@
*.local
coverage.out
# Local deployment, backup, and production diagnostic artifacts
/.deploy/
/backups/
/check-live-dobotai.py
# Transient local validation and legacy test-deployment artifacts
/dobotai
/.acceptance-canary/
/.hermes/
/deploy_stable_images_test.py
# Editors and OS
.idea/
.vscode/
File diff suppressed because it is too large Load Diff
+122 -50
View File
@@ -1,76 +1,148 @@
# DobotAI Platform
# DobotAI 平台
DobotAI is the external enterprise integration layer for official WeKnora. It does not import WeKnora packages, modify WeKnora source, or access the WeKnora database.
DobotAI 是 WeKnora 的企业外部渠道集成层,负责钉钉协议、身份与会话映射、消息编排、流式卡片、媒体收发、覆盖判定和人工支持通知。它只通过 WeKnora 官方 REST/SSE API 调用知识问答与 Agent Chat:不导入 WeKnora 私有包、不修改 WeKnora 源码,也不访问 WeKnora 数据库。
Current milestone: channel-bound Agent QA core.
## 当前能力
Implemented:
- 通过官方 `POST /api/v1/sessions` 与 `POST /api/v1/agent-chat/:session_id` 调用 WeKnora;
- 严格解析 SSE 的 `answer`、`references`、`tool_call`、`tool_result`、`error` 与终止事件;`error + done=false` 被视为可恢复的工具错误,不会提前结束钉钉卡片;
- 解析普通引用及智能推理工具结果中的检索证据,供覆盖判定与后端审计使用;
- 按渠道、会话和用户维护长期 WeKnora Session,并保证回调幂等;
- 钉钉 Stream 机器人支持文本、richText 以及多图片输入;图片下载经过钉钉官方接口、大小与文件签名校验后,才以 WeKnora 通用多模态请求发送;
- 钉钉 AI 流式卡片与最终消息回退;通道侧保留原始回答,不修改 WeKnora 持久化消息;
- 默认快速问答,支持 `/think <问题>` 单次推理、`/think on` 持续推理、`/think off` 或 `/rag` 关闭推理;快速问答与智能推理共用同一 WeKnora Session;
- 用户级推理轮次、空闲到期、提示开关和首次使用指引;
- 成功回答的覆盖判定、持久化 Support Case、FAQ Outbox 与钉钉人工支持群通知;
- 对已覆盖答案点踩时创建 `feedback_down` 人工处理提醒;对已先因未覆盖创建 Case 的同一问题保持“一题一个 Case”幂等;
- 可选的钉钉问题表异步写入;写入失败不会改变问答成功状态;
- 可选的回答图片外发:仅将 WeKnora `resource://` 图片通过鉴权下载、内容哈希转存到私有 OSS 后,以受控 HTTPS 地址提供给钉钉。
- official `POST /api/v1/sessions` adapter;
- official `POST /api/v1/agent-chat/:session_id` adapter;
- fail-closed SSE parsing for `references`, `answer`, `error`, and terminal `done`;
- per-channel/per-conversation Agent binding;
- long-lived direct-user sessions;
- shared-group sessions, with optional group+user policy;
- external-message idempotency;
- independent PostgreSQL session/turn storage;
- authenticated internal `POST /v1/qa` ingress endpoint.
- DingTalk Stream text ingress (official SDK) wired into `cmd/dobotai`.
## 架构边界
## DingTalk channel (Stream, text only)
```text
钉钉 / 内部调用方
↓
DobotAI
├─ 钉钉 Stream、richText、图片、AI 卡片、反馈与通知
├─ 独立 PostgreSQL:渠道身份、消息、会话映射、Turn、覆盖与支持流程
└─ WeKnora 官方 REST / SSE API
↓
WeKnora:检索、Agent、知识与引用
```
- Uses the official DingTalk Stream SDK (`dingtalk-stream-sdk-go`); no public webhook endpoint is exposed.
- Text messages only for now. Images, OpenAPI fallback, and card replies are not implemented yet.
- Group chats are handled only when the bot is @-mentioned; other group messages are ignored.
- 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.
责任边界:
## WeKnora baseline
- **DobotAI**:钉钉协议、媒体收发、会话映射、命令路由、卡片、覆盖判定、人工支持、渠道审计。
- **WeKnora**:通用 Agent Chat、检索、知识存储、引用与模型调用。
- **DobotAI PostgreSQL**:只保存渠道与业务编排数据,不保存 WeKnora 知识正文。
- **Redis**:仅用于队列、重试、幂等租约和短期状态。
Target policy: latest stable official Release/Tag. The newest locally fetched stable tag is `v0.6.3`. Online GitHub verification is pending because DNS/proxy access was unavailable during initialization.
## 钉钉 Stream
## Run
使用官方 `dingtalk-stream-sdk-go`,不暴露公网回调入口。
1. Create an independent PostgreSQL database named `dobotai`.
2. Apply `db/migrations/000001_core.up.sql` to that database only.
3. Copy `.env.example` to a local `.env` and supply secrets outside Git.
4. Export the variables and run:
- 群聊仅在机器人被 `@` 时处理;
- 私聊按用户保持长期会话;群聊默认按群共享会话;
- 回调 `sessionWebhook` 严格限制为 `https`、`*.dingtalk.com`、443端口且不允许重定向;
- 启用 Stream 必须同时设置以下三项;三者均为空时渠道禁用,部分配置会失败关闭:
```text
DINGTALK_CLIENT_ID
DINGTALK_CLIENT_SECRET
DOBOTAI_TENANT_KEY
```
## 推理模式命令
```text
/think <问题> 仅当前问题使用智能推理
/think on 开启持续智能推理
/think off 或 /rag 关闭持续智能推理
/setting rounds <1-50> 设置持续推理自动关闭轮数
/setting hint <on|off> 设置剩余轮次提示
/help、/帮助、/h 查看当前模式和帮助
```
持续推理默认最多10轮,成功回答才消耗轮次;10分钟无交互后逻辑到期,下一次问题自动回到快速问答。状态按 `tenant + channel + external_actor_id` 隔离。
## 覆盖与人工支持
- 有结构化检索引用的回答标记为已覆盖;
- 没有引用但已执行检索的回答标记为未覆盖;
- 纯图片视觉识别单独记录为 `covered_visual_recognition`,不计入“未覆盖”;
- 无权威知识时,产品统一回复:`当前暂无已发布的权威知识,暂不建议操作。`;
- 人工通知只带当前问题和同一用户此前最多5个问题,不包含回答、引用或置信度;
- 未覆盖与点踩共用幂等 Support Case,避免对同一 Turn 重复通知。
## 配置
复制 `.env.example` 为仅本机可读的运行时环境文件,并在 Git 外注入真实值。不得提交 API Key、数据库密码、钉钉凭据、OSS AccessKey、预签名 URL 或真实用户/群组标识。
关键配置:
```text
DOBOTAI_LISTEN_ADDR
DOBOTAI_INGRESS_TOKEN
DOBOTAI_AGENT_BINDINGS
DOBOTAI_QUICK_AGENT_ID
DOBOTAI_REASONING_AGENT_ID
WEKNORA_BASE_URL
WEKNORA_API_KEY
DATABASE_URL
```
可选能力必须整组配置或整组留空:
- 钉钉 Stream;
- 钉钉 AI 卡片;
- 未覆盖问题 FAQ 表;
- 人工支持群通知;
- 问题表异步写入;
- 回答图片私有 OSS 外发。
任何部分配置均应在启动时失败关闭,而不是以不完整权限运行。
## 数据库迁移
DobotAI 使用独立 PostgreSQL,禁止指向 WeKnora 数据库。生产部署前必须按版本顺序执行并演练 `db/migrations/` 中的迁移;当前发布范围包含:
```text
000001_core ... 000018_coverage_visual_recognition_reason
000019_reasoning_mode_state
000020_feedback_support_trigger
```
迁移前应执行逻辑备份并验证恢复;迁移、二进制部署和钉钉渠道切换是独立步骤,不能把健康检查代替真实渠道验收。
## 本地运行
```bash
go run ./cmd/dobotai
```
Health check:
健康检查:
```bash
curl http://127.0.0.1:8090/healthz
```
Internal QA request:
运行版本:
```bash
curl -X POST http://127.0.0.1:8090/v1/qa \
-H "Authorization: Bearer $DOBOTAI_INGRESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"tenant_key":"default",
"channel":"dingtalk",
"conversation_type":"direct",
"external_user_id":"user-1",
"external_message_id":"message-1",
"query":"hello"
}'
curl http://127.0.0.1:8090/version
```
## Hard boundaries
部署脚本应将 `version`、`commit_sha` 和 `build_time` 写入二进制,并仅在 `/version` 返回预期提交时接受部署。候选源码必须干净,避免运行版本掩盖未提交改动。
- No WeKnora source modifications.
- No custom WeKnora tables or columns.
- No direct WeKnora database access.
- No private WeKnora package imports.
- All compatibility logic stays in `internal/weknora`.
- Business data stays in DobotAI PostgreSQL.
- Redis will be used only for queues, retries, idempotency leases, and temporary state.
## 安全要求
See `docs/architecture.md` for ownership and migration decisions.
- 不修改 WeKnora 源码、Schema 或私有包;
- 不直接连接 WeKnora 数据库;
- 不将凭据、Token、预签名 URL 或知识正文写入日志;
- 不接受任意外部图片 URL 作为可信媒体;
- 对钉钉图片、WeKnora `resource://` 图片和 OSS 返回体均执行来源、大小、类型和签名校验;
- 外发 OSS Bucket 保持私有;
- 生产、测试的钉钉应用、数据库、OSS Bucket 和凭据必须相互隔离;
- 生产上线必须使用已验收的同一构建产物,禁止在生产重新编译。
更多设计与边界见 `docs/architecture.md`。
+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)
}
}
}
+285 -9
View File
@@ -8,23 +8,44 @@ 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/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
"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"))
@@ -32,6 +53,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)
@@ -40,6 +75,14 @@ func main() {
if err != nil {
log.Fatal(err)
}
reasoningService, err := reasoningmode.NewService(
store,
os.Getenv("DOBOTAI_QUICK_AGENT_ID"),
os.Getenv("DOBOTAI_REASONING_AGENT_ID"),
)
if err != nil {
log.Fatal(err)
}
groupPolicy := session.GroupPolicy(strings.TrimSpace(os.Getenv("DOBOTAI_GROUP_SESSION_POLICY")))
if groupPolicy == "" {
groupPolicy = session.GroupShared
@@ -47,33 +90,178 @@ 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)
}),
qa.WithReasoningModeFinalizer(reasoningService, func(err error) {
log.Printf("reasoning mode completion failed: %T", 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)
@@ -92,17 +280,36 @@ 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},
ReasoningRouter: &dingTalkReasoningRouter{service: reasoningService},
LanguageResolver: settingsRuntime.repository,
},
)
if err != nil {
log.Fatal(err)
@@ -115,9 +322,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) {
@@ -131,6 +374,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) {
+98
View File
@@ -21,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 {
@@ -85,3 +99,87 @@ func TestMainWiresFeedbackIntoDingTalkStream(t *testing.T) {
}
}
}
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
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"context"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
type dingTalkReasoningRouter struct {
service *reasoningmode.Service
}
func (r *dingTalkReasoningRouter) Prepare(ctx context.Context, message dingtalk.ReasoningMessage) (dingtalk.ReasoningDecision, error) {
decision, err := r.service.Prepare(ctx, reasoningmode.Scope{
TenantKey: message.TenantKey, Channel: message.Channel, ExternalActorID: message.StaffID,
}, message.Text)
if err != nil {
return dingtalk.ReasoningDecision{}, err
}
return dingtalk.ReasoningDecision{
Handled: decision.Handled, Reply: decision.Reply, Query: decision.Query,
AgentID: decision.AgentID, Execution: decision.Execution, Expired: decision.Expired,
}, nil
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"os"
"strings"
"testing"
)
func TestMainWiresReasoningModeRoutingAndCompletion(t *testing.T) {
mainSource, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
wiringSource, err := os.ReadFile("reasoning_wiring.go")
if err != nil {
t.Fatal(err)
}
mainText := string(mainSource)
for _, required := range []string{
`os.Getenv("DOBOTAI_QUICK_AGENT_ID")`,
`os.Getenv("DOBOTAI_REASONING_AGENT_ID")`,
`reasoningmode.NewService`,
`qa.WithReasoningModeFinalizer`,
`ReasoningRouter:`,
} {
if !strings.Contains(mainText, required) {
t.Errorf("main reasoning wiring missing %q", required)
}
}
for _, required := range []string{
"dingTalkReasoningRouter",
"ReasoningMessage",
"service.Prepare",
} {
if !strings.Contains(string(wiringSource), required) {
t.Errorf("reasoning adapter missing %q", required)
}
}
}
+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,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'
));
@@ -0,0 +1,3 @@
-- 000019_reasoning_mode_state down migration.
DROP TABLE IF EXISTS reasoning_mode_turns;
DROP TABLE IF EXISTS reasoning_mode_states;
@@ -0,0 +1,43 @@
-- 000019_reasoning_mode_state: actor-scoped /think state and
-- per-turn idempotency for successful reasoning-mode completions.
CREATE TABLE IF NOT EXISTS reasoning_mode_states (
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
external_actor_id TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'quick'
CHECK (mode IN ('quick', 'reasoning')),
max_rounds INTEGER NOT NULL DEFAULT 10
CHECK (max_rounds BETWEEN 1 AND 50),
rounds_used INTEGER NOT NULL DEFAULT 0
CHECK (rounds_used >= 0 AND rounds_used <= max_rounds),
hint_enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_activity_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
onboarding_shown_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_key, channel, external_actor_id)
);
CREATE INDEX IF NOT EXISTS idx_reasoning_mode_states_expiry
ON reasoning_mode_states (expires_at)
WHERE mode = 'reasoning' AND expires_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS reasoning_mode_turns (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
external_actor_id TEXT NOT NULL,
execution_mode TEXT NOT NULL
CHECK (execution_mode IN ('quick', 'reasoning', 'reasoning_single')),
round_number INTEGER NOT NULL DEFAULT 0 CHECK (round_number >= 0),
showed_hint BOOLEAN NOT NULL DEFAULT FALSE,
auto_closed BOOLEAN NOT NULL DEFAULT FALSE,
showed_onboarding BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
CREATE INDEX IF NOT EXISTS idx_reasoning_mode_turns_actor_created
ON reasoning_mode_turns (tenant_key, channel, external_actor_id, created_at DESC);
@@ -0,0 +1,79 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp019CreatesActorScopedReasoningModeState(t *testing.T) {
data, err := os.ReadFile("000019_reasoning_mode_state.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"REASONING_MODE_STATES",
"TENANT_KEY",
"CHANNEL",
"EXTERNAL_ACTOR_ID",
"MODE",
"MAX_ROUNDS",
"ROUNDS_USED",
"HINT_ENABLED",
"EXPIRES_AT",
"ONBOARDING_SHOWN_AT",
"PRIMARY KEY (TENANT_KEY, CHANNEL, EXTERNAL_ACTOR_ID)",
"MODE IN ('QUICK', 'REASONING')",
"MAX_ROUNDS BETWEEN 1 AND 50",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning state migration missing %q", required)
}
}
}
func TestUp019CreatesTurnIdempotencyLedger(t *testing.T) {
data, err := os.ReadFile("000019_reasoning_mode_state.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"REASONING_MODE_TURNS",
"TURN_ID",
"EXTERNAL_ACTOR_ID",
"EXECUTION_MODE",
"ROUND_NUMBER",
"SHOWED_HINT",
"AUTO_CLOSED",
"SHOWED_ONBOARDING",
"UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)",
"REFERENCES TURNS(ID)",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning turn ledger migration missing %q", required)
}
}
}
func TestDown019RemovesOnlyReasoningModeObjects(t *testing.T) {
data, err := os.ReadFile("000019_reasoning_mode_state.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"DROP TABLE IF EXISTS REASONING_MODE_TURNS",
"DROP TABLE IF EXISTS REASONING_MODE_STATES",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning down migration missing %q", required)
}
}
for _, forbidden := range []string{"DROP TABLE TURNS", "DROP TABLE USER_PREFERENCES"} {
if strings.Contains(source, forbidden) {
t.Errorf("reasoning down migration must not contain %q", forbidden)
}
}
}
@@ -0,0 +1,5 @@
-- 000020_feedback_support_trigger down migration.
DROP INDEX IF EXISTS idx_support_cases_trigger_type_created;
ALTER TABLE support_cases
DROP COLUMN IF EXISTS trigger_feedback_id,
DROP COLUMN IF EXISTS trigger_type;
@@ -0,0 +1,11 @@
-- 000020_feedback_support_trigger: let an existing support-case workflow be
-- triggered by a downvote while preserving one case per successful Turn.
ALTER TABLE support_cases
ALTER COLUMN coverage_decision_id DROP NOT NULL,
ADD COLUMN IF NOT EXISTS trigger_type TEXT NOT NULL DEFAULT 'coverage_uncovered'
CHECK (trigger_type IN ('coverage_uncovered', 'feedback_down')),
ADD COLUMN IF NOT EXISTS trigger_feedback_id TEXT REFERENCES answer_feedback(id);
CREATE INDEX IF NOT EXISTS idx_support_cases_trigger_type_created
ON support_cases (trigger_type, created_at DESC);
@@ -0,0 +1,50 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp020AddsFeedbackSupportTriggerAndMakesCoverageDecisionOptional(t *testing.T) {
data, err := os.ReadFile("000020_feedback_support_trigger.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ALTER TABLE SUPPORT_CASES",
"TRIGGER_TYPE",
"COVERAGE_UNCOVERED",
"FEEDBACK_DOWN",
"TRIGGER_FEEDBACK_ID",
"REFERENCES ANSWER_FEEDBACK(ID)",
"ALTER COLUMN COVERAGE_DECISION_ID DROP NOT NULL",
} {
if !strings.Contains(source, required) {
t.Errorf("feedback support migration missing %q", required)
}
}
if strings.Contains(source, "DROP CONSTRAINT SUPPORT_CASES_COVERAGE_DECISION_ID_FKEY") {
t.Fatal("feedback support must retain the coverage decision foreign-key contract")
}
}
func TestDown020RemovesOnlyFeedbackSupportColumns(t *testing.T) {
data, err := os.ReadFile("000020_feedback_support_trigger.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"DROP COLUMN IF EXISTS TRIGGER_FEEDBACK_ID",
"DROP COLUMN IF EXISTS TRIGGER_TYPE",
} {
if !strings.Contains(source, required) {
t.Errorf("feedback support down migration missing %q", required)
}
}
if strings.Contains(source, "DROP TABLE") {
t.Fatal("feedback support down migration must not drop shared tables")
}
}
+16 -16
View File
@@ -1,21 +1,21 @@
# WeKnora API Capability Gaps
# WeKnora API 能力缺口
These gaps must not be solved with private WeKnora source or database changes.
以下能力缺口不得通过修改 WeKnora 私有源码或数据库解决。
| Capability | Current external handling |
| 能力 | 当前外部处理方式 |
|---|---|
| Like/dislike and unresolved reason | DobotAI PostgreSQL and API |
| Formal covered/not-covered decision | DobotAI evaluation rules using references, scores, answer status, and offline calibration |
| Support case, owner, SLA, and status | DobotAI workflow |
| Human reply to original channel | DobotAI channel adapter; not written to WeKnora history by default |
| Stable external turn correlation | DobotAI mapping of external message, turn, WeKnora session/request, and gateway trace |
| Complete multi-call token usage | Model gateway/Langfuse; unknown remains null/unknown |
| Strict image failure semantics | Adapter contract test; reject image traffic on failure |
| Arbitrary human assistant-message append | Keep in DobotAI and submit an upstream API request only if context injection becomes necessary |
| 点赞、点踩和未解决原因 | DobotAI PostgreSQL 与 API |
| 正式的已覆盖/未覆盖判定 | DobotAI 基于引用、分数、回答状态和离线校准的评估规则 |
| Support Case、负责人、SLA 和状态 | DobotAI 工作流 |
| 向原始渠道发送人工回复 | DobotAI 渠道适配器;默认不写回 WeKnora 历史 |
| 稳定的外部 Turn 关联 | DobotAI 映射外部消息、Turn、WeKnora 会话/请求和网关追踪 |
| 完整的多调用 Token 用量 | 模型网关/Langfuse;未知值保持 `null/unknown` |
| 严格的图片失败语义 | Adapter 契约测试;图片流量失败时拒绝处理 |
| 任意人工助手消息追加 | 保留在 DobotAI;仅当确有上下文注入需求时向上游提交 API 请求 |
Allowed responses to a gap:
面对能力缺口时,只允许:
1. adjust the product design;
2. implement it outside WeKnora;
3. open an upstream issue;
4. submit a general upstream pull request.
1. 调整产品设计;
2. 在 WeKnora 外部实现;
3. 提交上游 Issue;
4. 提交通用的上游 Pull Request。
+58 -49
View File
@@ -1,73 +1,82 @@
# DobotAI External Architecture
# DobotAI 外部集成架构
## Boundary
## 系统边界
```text
DingTalk / WeCom / Web / Software
-> DobotAI ingress and workflows
钉钉 / 企业微信 / Web / 业务软件
-> DobotAI 接入层与工作流
-> WeKnora Adapter
-> official WeKnora REST API
-> official WeKnora RAG and Agent
-> WeKnora 官方 REST API
-> WeKnora 官方 RAG 与 Agent
```
DobotAI PostgreSQL owns channel identities, external messages, session mappings, turns, feedback, coverage decisions, support cases, human replies, FAQ workflow state, and content-source mappings. WeKnora owns only its official data model.
DobotAI PostgreSQL 负责渠道身份、外部消息、会话映射、Turn、反馈、覆盖判定、Support Case、人工回复、FAQ 工作流状态和内容源映射。WeKnora 仅负责其官方数据模型。
## Session policy
## 会话策略
Default policy:
默认策略:
- direct: one long-lived session per tenant + channel + Agent + external user;
- group: one shared session per tenant + channel + Agent + external group;
- group messages are admitted only when the bot is mentioned or an explicit command triggers it;
- sender display name is prefixed to the query so a shared Agent can distinguish speakers;
- future controls must include idle rotation and an explicit new-conversation command;
- confidential channels may switch to `per_user`, yielding group + user sessions.
- 私聊:每个 `tenant + channel + Agent + 外部用户` 使用一个长期会话;
- 群聊:每个 `tenant + channel + Agent + 外部群` 使用一个共享会话;
- 群消息仅在机器人被提及或命中显式命令时进入处理;
- 将发送人显示名添加到问题前缀,使共享 Agent 能区分发言者;
- 后续控制项必须包含空闲轮换和显式新建会话命令;
- 保密渠道可切换为 `per_user`,形成群组加用户维度的会话。
Shared group sessions are the common collaboration-bot pattern because all members see the same conversation. Group+user sessions provide stronger context isolation but make replies less coherent for collaborative group discussions.
共享群会话符合协作机器人场景:所有成员能看到相同上下文。群组加用户会话能增强上下文隔离,但会削弱协作讨论中的回复连贯性。
## Agent binding
快速问答与智能推理必须复用同一个 WeKnora Session;`/think` 仅改变当前 Turn 的执行 Agent,不得重建会话映射。
Agents are configuration-owned. Channel callers cannot choose an arbitrary `agent_id`.
## Agent 绑定
Resolution order:
Agent 由配置控制,渠道调用方不得任意指定 `agent_id`。
1. exact `<channel>:<external_chat_id>` binding;
2. channel wildcard `<channel>:*`;
3. fail closed when no binding exists.
解析顺序:
## Image failure policy
1. 精确 `<channel>:<external_chat_id>` 绑定;
2. 渠道通配 `<channel>:*`;
3. 没有任何绑定时失败关闭。
Before enabling image traffic, the WeKnora Adapter must run a contract test against the selected official release and configured vision model.
## 图片失败策略
If any image path fails:
启用图片流量前,WeKnora Adapter 必须针对选定的官方版本和视觉模型执行契约测试。
1. do not resend the question without the image;
2. do not return partial model output as success;
3. return a typed `vision_unavailable` failure to the channel adapter;
4. DingTalk tells the user that image recognition is temporarily unavailable and asks them to retry or send text;
5. emit an operational alert to verify Agent image upload settings, selected vision model, provider connectivity, and model capability;
6. keep image traffic disabled until the contract test passes.
任一图片路径失败时:
No WeKnora source patch is allowed as a workaround.
1. 不得去掉图片后重发同一问题;
2. 不得将部分模型输出当作成功结果;
3. 向渠道适配器返回类型化 `vision_unavailable` 错误;
4. 钉钉提示用户图片识别暂不可用,并请其重试或发送文本;
5. 发送运维告警,核查 Agent 图片上传设置、视觉模型、供应商连通性和模型能力;
6. 契约测试通过前保持图片流量禁用。
## Service ownership
不得通过修改 WeKnora 源码规避上述失败。
- `internal/weknora`: official REST/SSE compatibility only.
- `internal/qa`: business orchestration and terminal-state ownership.
- `internal/session`: external-to-WeKnora session policy.
- `internal/store/postgres`: DobotAI business persistence only.
- `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.
- model gateway: token accounting, routing, quotas, circuit breaking, and provider observability.
- Redis: queue, retry, short leases, and temporary stream state only.
对钉钉 richText 中的图片,DobotAI 仅通过官方接口下载,验证来源、大小、媒体类型和文件签名后,才转换为 WeKnora 通用多模态请求。对回答中的 `resource://` 图片,仅可在鉴权获取、内容哈希转存私有 OSS 后外发;不得把任意外部 URL 当作可信图片。
## Upgrade contract
## 服务职责
For every official WeKnora upgrade:
- `internal/weknora`:仅处理 WeKnora 官方 REST/SSE 兼容;解析普通引用和智能推理工具结果引用;
- `internal/qa`:业务编排与 Turn 终态归属;
- `internal/session`:外部渠道到 WeKnora 的会话策略;
- `internal/store/postgres`:仅保存 DobotAI 业务数据;
- `internal/coverage`:已覆盖/未覆盖/纯视觉识别判定、持久化 Support Case、有限重试和稳定错误码;通知失败不得改变成功问答的结果;
- `internal/channels/dingtalk`:钉钉 Stream 接入、richText 与多图片归一化、AI 流式卡片、命令路由、反馈与通知。仅在 `DINGTALK_CLIENT_ID`、`DINGTALK_CLIENT_SECRET` 和 `DOBOTAI_TENANT_KEY` 均已设置时启用。群聊必须 `@` 机器人;私聊按用户映射长期会话,群聊默认映射共享会话;`sessionWebhook` 必须是 `https`、`*.dingtalk.com`、443端口且不允许重定向;
- `internal/reasoningmode`:`/think` 单次与持续推理、轮数、空闲到期、提示和首次使用引导;
- `internal/outboundimage`:WeKnora `resource://` 图片的受控鉴权下载和私有 OSS 外发;
- `future channels/*`:其他平台的回调与外发回复;
- `future content-sync/*`:钉钉知识库、NAS 与 CMS 同步;
- 模型网关:Token 统计、路由、配额、熔断和供应商可观测性;
- Redis:仅用于队列、重试、短期租约和临时流状态。
1. update the pinned official release/tag;
2. run official database migrations against WeKnora only;
3. run DobotAI Adapter contract tests;
4. verify text Agent Chat, references, terminal errors, Stop, images, ingestion, and FAQ;
5. promote the official image only after the contract suite passes.
## 升级契约
每次升级官方 WeKnora 时:
1. 更新固定的官方 Release/Tag;
2. 仅对 WeKnora 执行官方数据库迁移;
3. 执行 DobotAI Adapter 契约测试;
4. 验证文本 Agent Chat、引用、终止错误、停止、图片、导入和 FAQ;
5. 仅在契约测试通过后推广官方镜像。
WeKnora 升级、DobotAI 迁移、钉钉单消费者切换和生产部署是独立变更边界,必须各自备份、演练和验收。
+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())
}
}
+66 -16
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 {
@@ -232,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
}
@@ -276,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
}
@@ -297,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
}
}
}
+127 -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)
}
@@ -271,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
// ---------------------------------------------------------------------------
@@ -858,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)
}
}
+89 -14
View File
@@ -3,6 +3,7 @@ package dingtalk
import (
"context"
"errors"
"strings"
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
@@ -10,16 +11,32 @@ import (
)
const (
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"
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)
}
@@ -39,7 +56,10 @@ func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.Ca
if request == nil {
return nil, errors.New("dingtalk card feedback: request is nil")
}
value, reason, recognized := feedbackAction(request.CardActionData.CardPrivateData.ActionIdList)
value, reason, recognized := feedbackAction(
request.CardActionData.CardPrivateData.ActionIdList,
request.CardActionData.CardPrivateData.Params,
)
if !recognized {
return &sdkcard.CardResponse{}, nil
}
@@ -59,10 +79,6 @@ func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.Ca
if value == feedbackdomain.ValueUp {
message = "感谢反馈"
}
if value == feedbackdomain.ValueDown && reason == feedbackdomain.ReasonEmpty {
showReasons = "true"
message = "请选择未解决原因"
}
return &sdkcard.CardResponse{
CardUpdateOptions: &sdkcard.CardUpdateOptions{UpdatePrivateDataByKey: true},
UserPrivateData: &sdkcard.CardDataDto{CardParamMap: map[string]string{
@@ -74,10 +90,13 @@ func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.Ca
}, nil
}
func feedbackAction(actions []string) (string, string, bool) {
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
}
@@ -87,6 +106,50 @@ func feedbackAction(actions []string) (string, string, bool) {
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:
@@ -105,6 +168,18 @@ func oneFeedbackAction(action string) (string, string, bool) {
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
}
+189 -1
View File
@@ -43,6 +43,157 @@ func cardRequest(action string) *sdkcard.CardRequest {
}
}
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
@@ -52,7 +203,7 @@ func TestCardFeedbackMapsWhitelistedActions(t *testing.T) {
message string
}{
{ActionFeedbackUp, feedbackdomain.ValueUp, "", "false", "感谢反馈"},
{ActionFeedbackDown, feedbackdomain.ValueDown, "", "true", "请选择未解决原因"},
{ActionFeedbackDown, feedbackdomain.ValueDown, "", "false", "感谢反馈,我们会继续改进"},
{ActionReasonIncorrect, feedbackdomain.ValueDown, feedbackdomain.ReasonIncorrect, "false", "感谢反馈,我们会继续改进"},
{ActionReasonNotResolved, feedbackdomain.ValueDown, feedbackdomain.ReasonNotResolved, "false", "感谢反馈,我们会继续改进"},
{ActionReasonIrrelevant, feedbackdomain.ValueDown, feedbackdomain.ReasonIrrelevant, "false", "感谢反馈,我们会继续改进"},
@@ -147,6 +298,43 @@ func TestStreamClientRegistersOptionalCardFeedbackRouter(t *testing.T) {
}
}
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)
+364
View File
@@ -0,0 +1,364 @@
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) {
label := "知识库未覆盖提醒"
if notif.TriggerType == coverage.SupportTriggerFeedbackDown {
label = "回答被点踩提醒"
}
title = label
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("## 【" + label + "】\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)
}
})
}
}
@@ -0,0 +1,24 @@
package dingtalk
import (
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
)
func TestSupportGroupSenderRendersFeedbackDownTrigger(t *testing.T) {
notification := newTestNotification()
notification.TriggerType = coverage.SupportTriggerFeedbackDown
title, text := (&SupportGroupSender{}).renderMarkdown(notification)
if title != "回答被点踩提醒" || !strings.Contains(text, "【回答被点踩提醒】") {
t.Fatalf("title=%q text=%q", title, text)
}
}
func TestSupportGroupSenderDefaultsHistoricalTriggerToCoverage(t *testing.T) {
title, text := (&SupportGroupSender{}).renderMarkdown(newTestNotification())
if title != "知识库未覆盖提醒" || !strings.Contains(text, "【知识库未覆盖提醒】") {
t.Fatalf("title=%q text=%q", title, text)
}
}
+351 -31
View File
@@ -2,6 +2,7 @@ package dingtalk
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log"
@@ -9,6 +10,8 @@ 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/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
@@ -32,17 +35,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 +78,66 @@ 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 ReasoningMessage struct {
TenantKey string
Channel string
StaffID string
Text string
ConversationID string
}
type ReasoningDecision struct {
Handled bool
Reply string
Query string
AgentID string
Execution reasoningmode.ExecutionMode
Expired bool
}
type ReasoningRouter interface {
Prepare(context.Context, ReasoningMessage) (ReasoningDecision, 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 +152,14 @@ type StreamingCardSender interface {
}
type HandlerOptions struct {
ImageDownloader ImageDownloader
CardSender FinalCardSender
ImageDownloader ImageDownloader
ImagePublisher ImagePublisher
CardSender FinalCardSender
Directory UserDirectory
OutboundRewriter OutboundContentRewriter
SettingsRouter SettingsRouter
ReasoningRouter ReasoningRouter
LanguageResolver LanguagePreferenceResolver
}
type Handler struct {
@@ -96,7 +167,13 @@ type Handler struct {
qa QAAsker
reply MarkdownSender
downloader ImageDownloader
imagePublisher ImagePublisher
card FinalCardSender
directory UserDirectory
outboundRewriter OutboundContentRewriter
settingsRouter SettingsRouter
reasoningRouter ReasoningRouter
languageResolver LanguagePreferenceResolver
now func() time.Time
cardUpdateInterval time.Duration
finalDeliveryTimeout time.Duration
@@ -136,7 +213,13 @@ 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,
reasoningRouter: opts.ReasoningRouter,
languageResolver: opts.LanguageResolver,
now: time.Now,
cardUpdateInterval: 500 * time.Millisecond,
finalDeliveryTimeout: defaultHTTPTimeout,
@@ -170,13 +253,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 +317,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 +327,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,20 +336,49 @@ 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)
}
if h.reasoningRouter != nil {
decision, err := h.reasoningRouter.Prepare(ctx, ReasoningMessage{
TenantKey: h.cfg.TenantKey, Channel: h.cfg.Channel, StaffID: externalUserID,
Text: req.Query, ConversationID: req.ExternalChatID,
})
if err != nil {
return fmt.Errorf("dingtalk reasoning mode: %w", err)
}
if decision.Handled {
if strings.TrimSpace(decision.Reply) == "" {
return nil
}
return h.sendReply(ctx, webhookURL, decision.Reply)
}
req.Query = strings.TrimSpace(decision.Query)
req.AgentIDOverride = strings.TrimSpace(decision.AgentID)
req.ReasoningExecution = string(decision.Execution)
req.ReasoningExpired = decision.Expired
}
if eventCard, eventQA := h.eventStreamingCapable(); eventCard != nil && eventQA != nil {
return h.handleEventStreaming(ctx, webhookURL, req, eventCard, eventQA)
}
@@ -229,6 +390,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 +424,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 +433,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 +448,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 +469,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 +478,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 +489,33 @@ 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
}
fullContent = decorateReasoningPartial(fullContent, req.ReasoningExecution)
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 +533,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 +555,10 @@ 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)
visibleAnswer := decorateReasoningPartial(event.FullAnswer, req.ReasoningExecution)
if partialContent := h.sanitizeOutboundPartial(visibleAnswer); strings.TrimSpace(partialContent) != "" {
pump.Offer(partialContent)
}
return
}
if answerStarted {
@@ -387,17 +573,32 @@ 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)
}
func decorateReasoningPartial(content, execution string) string {
switch reasoningmode.ExecutionMode(strings.TrimSpace(execution)) {
case reasoningmode.ExecutionReasoning, reasoningmode.ExecutionReasoningSingle:
if !strings.HasPrefix(content, "【智能推理模式】") {
return "【智能推理模式】\n\n" + content
}
}
return content
}
func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string, streamingCard StreamingCardSender, outTrackID string, result core.QAResult, content string, isError bool) error {
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
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 +627,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 +699,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 +770,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,21 @@
package dingtalk
import (
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
func TestDecorateReasoningPartialMarksReasoningFromFirstChunk(t *testing.T) {
got := decorateReasoningPartial("部分答案", string(reasoningmode.ExecutionReasoning))
want := "【智能推理模式】\n\n部分答案"
if got != want {
t.Fatalf("got=%q want=%q", got, want)
}
}
func TestDecorateReasoningPartialLeavesQuickAnswerUnchanged(t *testing.T) {
if got := decorateReasoningPartial("快速答案", string(reasoningmode.ExecutionQuick)); got != "快速答案" {
t.Fatalf("got=%q", got)
}
}
@@ -0,0 +1,81 @@
package dingtalk
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
type fakeReasoningRouter struct {
called bool
message ReasoningMessage
decision ReasoningDecision
err error
}
func (f *fakeReasoningRouter) Prepare(_ context.Context, message ReasoningMessage) (ReasoningDecision, error) {
f.called = true
f.message = message
return f.decision, f.err
}
func TestReasoningControlCommandBypassesQA(t *testing.T) {
qa := &fakeQA{}
reply := &fakeReply{}
router := &fakeReasoningRouter{decision: ReasoningDecision{Handled: true, Reply: "mode enabled"}}
handler, err := NewHandlerWithOptions(
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
qa,
reply,
HandlerOptions{ReasoningRouter: router},
)
if err != nil {
t.Fatal(err)
}
callback := directTextCallback()
callback.Text = "/think on"
if err = handler.Handle(context.Background(), callback); err != nil {
t.Fatal(err)
}
if !router.called || router.message.StaffID != "staff-123" || router.message.Text != "/think on" {
t.Fatalf("router=%+v", router)
}
if qa.called {
t.Fatal("reasoning control command must bypass QA")
}
if !reply.called || reply.lastText != "mode enabled" {
t.Fatalf("reply=%+v", reply)
}
}
func TestReasoningRouteAppliesQueryAgentAndExecutionToQARequest(t *testing.T) {
qa := &fakeQA{result: core.QAResult{TurnID: "turn-1", AnswerMessageID: "answer-1", Answer: "结果"}}
reply := &fakeReply{}
router := &fakeReasoningRouter{decision: ReasoningDecision{
Query: "分析根因", AgentID: "builtin-smart-reasoning",
Execution: reasoningmode.ExecutionReasoningSingle, Expired: true,
}}
handler, err := NewHandlerWithOptions(
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
qa,
reply,
HandlerOptions{ReasoningRouter: router},
)
if err != nil {
t.Fatal(err)
}
callback := directTextCallback()
callback.Text = "/think 分析根因"
if err = handler.Handle(context.Background(), callback); err != nil {
t.Fatal(err)
}
if !qa.called {
t.Fatal("single-turn reasoning must call QA")
}
if qa.lastReq.Query != "分析根因" || qa.lastReq.AgentIDOverride != "builtin-smart-reasoning" ||
qa.lastReq.ReasoningExecution != string(reasoningmode.ExecutionReasoningSingle) || !qa.lastReq.ReasoningExpired {
t.Fatalf("QA request=%+v", qa.lastReq)
}
}
@@ -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")
}
}

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