Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa938ca1eb | |||
| 7661e1b8f7 | |||
| bfa1b1b25f | |||
| 0798a2dac7 | |||
| 18c81232ce | |||
| 19ed4b50df | |||
| 9f65c3eef0 | |||
| 50379b3b87 | |||
| 3f9ff359b9 | |||
| a3defdd1c0 | |||
| b4b68d8b21 | |||
| 365f1b8c7d | |||
| 725db721a8 | |||
| 5d68b22636 | |||
| 6f24743be2 | |||
| 4dbc20ee74 | |||
| e5be9ee780 | |||
| 0f30f8cec8 | |||
| 44d9398c78 | |||
| b207ee4089 | |||
| 0fc64ba77d | |||
| a7432aed49 | |||
| 5036071ac2 | |||
| 2d9490f424 | |||
| 462ee1f3ff | |||
| 8d2d4b469b | |||
| 9363d10e44 | |||
| 74c9af9944 | |||
| 5c35a49486 | |||
| 357e9bad5c | |||
| 2c0ed7cc67 | |||
| aa0513eb59 | |||
| e582d851a4 | |||
| 0a3f62618e | |||
| 5e0694835a | |||
| 39c5fbbaef | |||
| d29b28be13 | |||
| 774ccd9454 | |||
| 86593df57c | |||
| 913a65b16d | |||
| a6569ee4bf | |||
| 5b5865dc07 | |||
| 4366c018f9 | |||
| a952232cc9 | |||
| 4dc3f22784 | |||
| b8f572cc84 | |||
| fcf4559c9a | |||
| 9fa2eac6a4 | |||
| da69226481 | |||
| 38560c5f41 | |||
| f0d3d5b50d |
+36
-11
@@ -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
@@ -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
@@ -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`。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+295
-10
@@ -8,22 +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"))
|
||||
@@ -31,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)
|
||||
@@ -39,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
|
||||
@@ -46,33 +90,186 @@ 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)
|
||||
}
|
||||
feedbackHandler, err := dingtalk.NewCardFeedbackHandler(feedbackService)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
sender, err := dingtalk.NewWebhookSender(nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -83,22 +280,41 @@ 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)
|
||||
}
|
||||
streamClient, err = dingtalk.NewStreamClient(dingCfg, handler)
|
||||
streamClient, err = dingtalk.NewStreamClient(dingCfg, handler, feedbackHandler)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -106,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) {
|
||||
@@ -122,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) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
|
||||
@@ -19,6 +21,20 @@ func (r *auditRepoStub) GetTurn(context.Context, string, string) (audit.TurnDeta
|
||||
return audit.TurnDetail{}, audit.ErrNotFound
|
||||
}
|
||||
|
||||
func TestRuntimeBuildInfoUsesLinkerVariables(t *testing.T) {
|
||||
oldVersion, oldCommitSHA, oldBuildTime := version, commitSHA, buildTime
|
||||
t.Cleanup(func() {
|
||||
version, commitSHA, buildTime = oldVersion, oldCommitSHA, oldBuildTime
|
||||
})
|
||||
version = "2.1.0"
|
||||
commitSHA = "abc123def456"
|
||||
buildTime = "2026-07-27T12:00:00Z"
|
||||
got := runtimeBuildInfo()
|
||||
if got.Version != version || got.CommitSHA != commitSHA || got.BuildTime != buildTime {
|
||||
t.Fatalf("build info=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuditOptionsDisabledIsBackwardCompatible(t *testing.T) {
|
||||
opts, err := buildAuditOptions(nil, "", "", "0.0.0.0:8090")
|
||||
if err != nil {
|
||||
@@ -65,3 +81,105 @@ func TestBuildAuditOptionsFixesServerSideTenant(t *testing.T) {
|
||||
t.Fatalf("tenant=%q", repo.tenant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainWiresFeedbackIntoDingTalkStream(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{
|
||||
`"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"`,
|
||||
`feedback.NewService(dingCfg.TenantKey, "dingtalk", store)`,
|
||||
`dingtalk.NewCardFeedbackHandler(feedbackService)`,
|
||||
`dingtalk.NewStreamClient(dingCfg, handler, feedbackHandler)`,
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("main feedback wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainWiresOptionalDingTalkCardDebugUser(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{
|
||||
`os.Getenv("DINGTALK_CARD_DEBUG_USER_ID")`,
|
||||
`dingtalk.WithCardDebugUserID(cardDebugUserID)`,
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("main card debug wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutboundImageRuntimeConfig(t *testing.T) {
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
runtime, err := buildOutboundImageRuntime(outboundImageConfig{})
|
||||
if err != nil || runtime.rewriter != nil || runtime.handler != nil {
|
||||
t.Fatalf("runtime=%+v err=%v", runtime, err)
|
||||
}
|
||||
})
|
||||
t.Run("partial configuration rejected", func(t *testing.T) {
|
||||
_, err := buildOutboundImageRuntime(outboundImageConfig{Bucket: "private-bucket"})
|
||||
if err == nil {
|
||||
t.Fatal("expected configuration error")
|
||||
}
|
||||
})
|
||||
t.Run("complete configuration", func(t *testing.T) {
|
||||
runtime, err := buildOutboundImageRuntime(outboundImageConfig{
|
||||
WeKnoraBaseURL: "http://127.0.0.1:8080",
|
||||
WeKnoraAPIKey: "api-key",
|
||||
Endpoint: "https://oss-cn-shenzhen.aliyuncs.com",
|
||||
Region: "cn-shenzhen",
|
||||
AccessKeyID: "access-key-id",
|
||||
AccessKeySecret: "access-key-secret",
|
||||
Bucket: "private-bucket",
|
||||
PublicBaseURL: "https://weknora-api-test.dobot.cn/dingtalk/i",
|
||||
})
|
||||
if err != nil || runtime.rewriter == nil || runtime.handler == nil {
|
||||
t.Fatalf("runtime=%+v err=%v", runtime, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMainWiresStableOutboundImageRuntime(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{
|
||||
`buildOutboundImageRuntime(outboundImageConfig{`,
|
||||
`PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL")`,
|
||||
`OutboundRewriter: outboundImages.rewriter`,
|
||||
`rootHandler.Handle(outboundImageRoute, outboundImages.handler)`,
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("main stable image wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET",
|
||||
} {
|
||||
t.Setenv(key, "configured")
|
||||
}
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "")
|
||||
if outboundOSSConfigured() {
|
||||
t.Fatal("stable public base URL must be required")
|
||||
}
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "https://img.example/dingtalk/i")
|
||||
if !outboundOSSConfigured() {
|
||||
t.Fatal("complete stable image configuration should be active")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
)
|
||||
|
||||
type supportNotificationRepoStub struct{}
|
||||
|
||||
func (*supportNotificationRepoStub) Claim(context.Context, int, int, time.Time) ([]coverage.PendingSupportCase, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*supportNotificationRepoStub) MarkNotified(context.Context, string) error { return nil }
|
||||
func (*supportNotificationRepoStub) MarkFailed(context.Context, string, string) error { return nil }
|
||||
|
||||
func testFAQRecordLinks(t *testing.T) *faqtable.RecordLinkBuilder {
|
||||
t.Helper()
|
||||
builder, err := faqtable.NewRecordLinkBuilder("base", "sheet", "view")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
type supportDispatcherStub struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
errs []error
|
||||
callCh chan int
|
||||
}
|
||||
|
||||
func (s *supportDispatcherStub) RunOnce(context.Context) (coverage.DispatcherStats, error) {
|
||||
s.mu.Lock()
|
||||
s.calls++
|
||||
call := s.calls
|
||||
var err error
|
||||
if call <= len(s.errs) {
|
||||
err = s.errs[call-1]
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if s.callCh != nil {
|
||||
select {
|
||||
case s.callCh <- call:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return coverage.DispatcherStats{Claimed: 1, Notified: 1}, err
|
||||
}
|
||||
|
||||
func TestBuildSupportDispatcherDisabledWithoutGroup(t *testing.T) {
|
||||
dispatcher, err := buildSupportDispatcher(nil, "", "", " ", nil)
|
||||
if err != nil || dispatcher != nil {
|
||||
t.Fatalf("dispatcher=%v err=%v, want disabled", dispatcher, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportDispatcherBlocksWithoutFAQLinks(t *testing.T) {
|
||||
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, "client", "secret", "group", nil)
|
||||
if err != nil || dispatcher != nil {
|
||||
t.Fatalf("dispatcher=%v err=%v, want blocked without process failure", dispatcher, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportDispatcherEnabledFailsClosed(t *testing.T) {
|
||||
repo := &supportNotificationRepoStub{}
|
||||
links := testFAQRecordLinks(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
repo coverage.NotificationRepository
|
||||
clientID, secret, gid string
|
||||
links *faqtable.RecordLinkBuilder
|
||||
}{
|
||||
{name: "missing repo", clientID: "client", secret: "secret", gid: "group", links: links},
|
||||
{name: "missing client", repo: repo, secret: "secret", gid: "group", links: links},
|
||||
{name: "missing secret", repo: repo, clientID: "client", gid: "group", links: links},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := buildSupportDispatcher(tc.repo, tc.clientID, tc.secret, tc.gid, tc.links); err == nil {
|
||||
t.Fatal("expected configuration error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportDispatcherValidDoesNotSend(t *testing.T) {
|
||||
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, " client ", " secret ", " group ", testFAQRecordLinks(t))
|
||||
if err != nil {
|
||||
t.Fatalf("buildSupportDispatcher: %v", err)
|
||||
}
|
||||
if dispatcher == nil {
|
||||
t.Fatal("expected dispatcher")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSupportDispatcherRunsImmediatelyAndStops(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
stub := &supportDispatcherStub{callCh: make(chan int, 4)}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runSupportDispatcher(ctx, stub, time.Hour)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-stub.callCh:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("dispatcher did not run immediately")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("dispatcher did not stop after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSupportDispatcherContinuesAfterError(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
stub := &supportDispatcherStub{
|
||||
errs: []error{errors.New("temporary db error")},
|
||||
callCh: make(chan int, 4),
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runSupportDispatcher(ctx, stub, 10*time.Millisecond)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
for want := 1; want <= 2; want++ {
|
||||
select {
|
||||
case got := <-stub.callCh:
|
||||
if got != want {
|
||||
t.Fatalf("call=%d, want %d", got, want)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatalf("missing dispatcher call %d", want)
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestRunSupportDispatcherRejectsNonPositiveInterval(t *testing.T) {
|
||||
stub := &supportDispatcherStub{}
|
||||
runSupportDispatcher(context.Background(), stub, 0)
|
||||
stub.mu.Lock()
|
||||
defer stub.mu.Unlock()
|
||||
if stub.calls != 0 {
|
||||
t.Fatalf("calls=%d, want 0", stub.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainWiresCoverageSupportNotifications(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{
|
||||
`os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID")`,
|
||||
`coverage.NewService(store)`,
|
||||
`qa.WithSucceededTurnObserver`,
|
||||
`case <-runtimeReady:`,
|
||||
`runSupportDispatcher(ctx, supportDispatcher, 5*time.Second)`,
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("main support notification wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 000005_answer_feedback down migration
|
||||
-- Drop in reverse order: indexes first, then table.
|
||||
|
||||
DROP INDEX IF EXISTS idx_messages_card_provider_unique;
|
||||
DROP INDEX IF EXISTS idx_answer_feedback_tenant_reason_updated;
|
||||
DROP INDEX IF EXISTS idx_answer_feedback_tenant_value_updated;
|
||||
DROP TABLE IF EXISTS answer_feedback;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- 000005_answer_feedback: user feedback on answers via DingTalk card
|
||||
-- Each (tenant_key, channel, answer_message_id, actor_id) holds the current
|
||||
-- feedback state: up or down with optional reason_code for down.
|
||||
-- Depends on: 000004_delivery_audit (and its 000003 message-audit baseline).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS answer_feedback (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
answer_message_id TEXT NOT NULL REFERENCES messages(id),
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
actor_id TEXT NOT NULL REFERENCES channel_actors(id),
|
||||
value TEXT NOT NULL CHECK (value IN ('up', 'down')),
|
||||
reason_code TEXT NOT NULL DEFAULT '' CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other')),
|
||||
source TEXT NOT NULL CHECK (source IN ('dingtalk_card')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, answer_message_id, actor_id),
|
||||
CHECK (value != 'up' OR reason_code = '')
|
||||
);
|
||||
|
||||
-- Queries that filter by value or reason_code within a tenant use these indexes.
|
||||
CREATE INDEX IF NOT EXISTS idx_answer_feedback_tenant_value_updated
|
||||
ON answer_feedback (tenant_key, value, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_answer_feedback_tenant_reason_updated
|
||||
ON answer_feedback (tenant_key, reason_code, updated_at DESC);
|
||||
|
||||
-- A DingTalk outTrackId must identify exactly one delivered card answer.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_card_provider_unique
|
||||
ON messages (tenant_key, channel, provider_message_id)
|
||||
WHERE provider_message_id != '' AND delivery_method = 'card';
|
||||
@@ -0,0 +1,296 @@
|
||||
// Package migrations_test contains source-level contract tests for the
|
||||
// 000005_answer_feedback migration. No real database connection is used.
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read005UpMigration(t *testing.T) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000005_answer_feedback.up.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read up migration: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func read005DownMigration(t *testing.T) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000005_answer_feedback.down.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read down migration: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// --- RED: These tests will fail until the migration files exist ---
|
||||
|
||||
// TestUp005CreatesAnswerFeedbackTable ensures the table is created with IF NOT EXISTS.
|
||||
func TestUp005CreatesAnswerFeedbackTable(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
upper := strings.ToUpper(up)
|
||||
|
||||
if !strings.Contains(upper, "CREATE TABLE IF NOT EXISTS ANSWER_FEEDBACK") {
|
||||
t.Error("up migration must create answer_feedback table with IF NOT EXISTS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUp005AnswerFeedbackColumns ensures all required columns are present.
|
||||
func TestUp005AnswerFeedbackColumns(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
upper := strings.ToUpper(up)
|
||||
|
||||
required := []string{
|
||||
"ID TEXT PRIMARY KEY",
|
||||
"TENANT_KEY TEXT NOT NULL",
|
||||
"CHANNEL TEXT NOT NULL",
|
||||
"ANSWER_MESSAGE_ID TEXT NOT NULL",
|
||||
"TURN_ID TEXT NOT NULL",
|
||||
"ACTOR_ID TEXT NOT NULL",
|
||||
"VALUE TEXT NOT NULL",
|
||||
"REASON_CODE TEXT NOT NULL DEFAULT ''",
|
||||
"SOURCE TEXT NOT NULL",
|
||||
"CREATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW()",
|
||||
"UPDATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW()",
|
||||
}
|
||||
for _, col := range required {
|
||||
if !strings.Contains(upper, col) {
|
||||
t.Errorf("up migration must contain column %q", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUp005ValueCheck ensures value is constrained to 'up' or 'down'.
|
||||
func TestUp005ValueCheck(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
upper := strings.ToUpper(up)
|
||||
|
||||
if !strings.Contains(upper, "VALUE IN ('UP'") || !strings.Contains(upper, "'DOWN'") {
|
||||
t.Error("value must be CHECK constrained to 'up' and 'down'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUp005ReasonCodeCheck ensures reason_code is constrained to allowed values.
|
||||
func TestUp005ReasonCodeCheck(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
upper := strings.ToUpper(up)
|
||||
|
||||
allowed := []string{"''", "INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"}
|
||||
for _, r := range allowed {
|
||||
if !strings.Contains(upper, r) {
|
||||
t.Errorf("reason_code CHECK must include %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUp005SourceCheck ensures source is constrained to 'dingtalk_card'.
|
||||
func TestUp005SourceCheck(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
upper := strings.ToUpper(up)
|
||||
|
||||
if !strings.Contains(upper, "DINGTALK_CARD") {
|
||||
t.Error("source must be CHECK constrained to 'dingtalk_card'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUp005UpValueMustHaveNoReason ensures CHECK that value='up' implies reason_code=”.
|
||||
func TestUp005UpValueMustHaveNoReason(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
upper := strings.ToUpper(up)
|
||||
|
||||
// Must have a CHECK that when value='up', reason_code must be empty
|
||||
hasCheck := strings.Contains(upper, "VALUE = 'UP'") && strings.Contains(upper, "REASON_CODE = ''")
|
||||
// Also check for the inverse: "VALUE != 'UP' OR REASON_CODE = ''"
|
||||
hasOr := strings.Contains(upper, "OR") && strings.Contains(upper, "REASON_CODE = ''")
|
||||
if !hasCheck && !hasOr {
|
||||
t.Error("must have CHECK ensuring value='up' implies reason_code=''")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUp005ForeignKeyConstraints ensures FK constraints on answer_message_id, turn_id, actor_id.
|
||||
func TestUp005ForeignKeyConstraints(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
upper := strings.ToUpper(up)
|
||||
|
||||
// Must reference messages(id), turns(id), channel_actors(id)
|
||||
if !strings.Contains(upper, "REFERENCES MESSAGES(ID)") {
|
||||
t.Error("answer_message_id must have FK REFERENCES messages(id)")
|
||||
}
|
||||
if !strings.Contains(upper, "REFERENCES TURNS(ID)") {
|
||||
t.Error("turn_id must have FK REFERENCES turns(id)")
|
||||
}
|
||||
if !strings.Contains(upper, "REFERENCES CHANNEL_ACTORS(ID)") {
|
||||
t.Error("actor_id must have FK REFERENCES channel_actors(id)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUp005UniqueConstraint ensures UNIQUE on (tenant_key, channel, answer_message_id, actor_id).
|
||||
func TestUp005UniqueConstraint(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
upper := strings.ToUpper(up)
|
||||
|
||||
hasUnique := strings.Contains(upper, "UNIQUE") &&
|
||||
strings.Contains(upper, "TENANT_KEY") &&
|
||||
strings.Contains(upper, "CHANNEL") &&
|
||||
strings.Contains(upper, "ANSWER_MESSAGE_ID") &&
|
||||
strings.Contains(upper, "ACTOR_ID")
|
||||
if !hasUnique {
|
||||
t.Error("must have UNIQUE constraint on (tenant_key, channel, answer_message_id, actor_id)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUp005Indexes ensures required indexes are created.
|
||||
func TestUp005Indexes(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
upper := strings.ToUpper(up)
|
||||
|
||||
// Index on (tenant_key, value, updated_at DESC)
|
||||
hasValueIdx := strings.Contains(upper, "TENANT_KEY") &&
|
||||
strings.Contains(upper, "VALUE") &&
|
||||
strings.Contains(upper, "UPDATED_AT") &&
|
||||
strings.Contains(upper, "DESC")
|
||||
if !hasValueIdx {
|
||||
t.Error("must have index on (tenant_key, value, updated_at DESC)")
|
||||
}
|
||||
|
||||
// Index on (tenant_key, reason_code, updated_at DESC)
|
||||
hasReasonIdx := strings.Contains(upper, "TENANT_KEY") &&
|
||||
strings.Contains(upper, "REASON_CODE") &&
|
||||
strings.Contains(upper, "UPDATED_AT") &&
|
||||
strings.Contains(upper, "DESC")
|
||||
if !hasReasonIdx {
|
||||
t.Error("must have index on (tenant_key, reason_code, updated_at DESC)")
|
||||
}
|
||||
|
||||
// Must use CREATE INDEX IF NOT EXISTS
|
||||
if got := countMatchesRe(up, `(?i)CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS`); got < 2 {
|
||||
t.Errorf("expected at least 2 CREATE INDEX IF NOT EXISTS, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUp005UpMigrationUsesIfNotExists ensures idempotency.
|
||||
func TestUp005UpMigrationUsesIfNotExists(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
upper := strings.ToUpper(up)
|
||||
|
||||
if strings.Contains(upper, "CREATE TABLE") && !strings.Contains(upper, "CREATE TABLE IF NOT EXISTS") {
|
||||
t.Error("CREATE TABLE must use IF NOT EXISTS for idempotency")
|
||||
}
|
||||
|
||||
// Must have exactly 1 CREATE TABLE IF NOT EXISTS
|
||||
if got := countMatchesRe(up, `(?i)CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS`); got != 1 {
|
||||
t.Errorf("expected 1 CREATE TABLE IF NOT EXISTS, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp005CreatesUniqueCardProviderIndex(t *testing.T) {
|
||||
up := strings.ToUpper(read005UpMigration(t))
|
||||
for _, fragment := range []string{
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS IDX_MESSAGES_CARD_PROVIDER_UNIQUE",
|
||||
"ON MESSAGES (TENANT_KEY, CHANNEL, PROVIDER_MESSAGE_ID)",
|
||||
"PROVIDER_MESSAGE_ID != ''",
|
||||
"DELIVERY_METHOD = 'CARD'",
|
||||
} {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("provider binding index missing %q", fragment)
|
||||
}
|
||||
}
|
||||
down := strings.ToUpper(read005DownMigration(t))
|
||||
if !strings.Contains(down, "DROP INDEX IF EXISTS IDX_MESSAGES_CARD_PROVIDER_UNIQUE") {
|
||||
t.Error("down migration must remove the B2 provider binding index")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUp005NoForbiddenColumns ensures no secrets/protocol columns.
|
||||
func TestUp005NoForbiddenColumns(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
upper := strings.ToUpper(up)
|
||||
|
||||
forbidden := []string{
|
||||
"WEBHOOK", "TOKEN", "RAW_CALLBACK", "RAW_PAYLOAD",
|
||||
"CONTENT", "CALLBACK", "EXTENSION", "CORP_ID",
|
||||
"SESSIONWEBHOOK", "DOWNLOAD_CODE", "DATAURI", "DATA_URI",
|
||||
"RAW_RESPONSE", "RAW_ERROR", "PROVIDER_RESPONSE", "API_RESPONSE",
|
||||
}
|
||||
for _, col := range forbidden {
|
||||
if strings.Contains(upper, col) {
|
||||
t.Errorf("migration must not contain forbidden column %q", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUp005DoesNotModifyBaselineTables ensures migration only touches answer_feedback.
|
||||
func TestUp005DoesNotModifyBaselineTables(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
upper := strings.ToUpper(up)
|
||||
|
||||
// Must not ALTER other tables
|
||||
baseline := []string{"MESSAGES", "TURNS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS", "SESSION_MAPPINGS"}
|
||||
for _, tbl := range baseline {
|
||||
if strings.Contains(upper, "ALTER TABLE "+tbl) {
|
||||
t.Errorf("migration must not ALTER TABLE %s", tbl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDown005DropsAnswerFeedbackTable ensures down drops only the new table and indexes.
|
||||
func TestDown005DropsAnswerFeedbackTable(t *testing.T) {
|
||||
down := read005DownMigration(t)
|
||||
upper := strings.ToUpper(down)
|
||||
|
||||
if !strings.Contains(upper, "DROP TABLE IF EXISTS ANSWER_FEEDBACK") {
|
||||
t.Error("down migration must drop answer_feedback with IF EXISTS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDown005DoesNotDropBaselineTables ensures down does not touch B1 tables.
|
||||
func TestDown005DoesNotDropBaselineTables(t *testing.T) {
|
||||
down := read005DownMigration(t)
|
||||
upper := strings.ToUpper(down)
|
||||
|
||||
baseline := []string{"MESSAGES", "TURNS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS", "SESSION_MAPPINGS"}
|
||||
for _, tbl := range baseline {
|
||||
if strings.Contains(upper, "DROP TABLE IF EXISTS "+tbl) {
|
||||
t.Errorf("down migration must not drop baseline table %s", tbl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDown005DoesNotDropBaselineColumns ensures down does not drop baseline columns.
|
||||
func TestDown005DoesNotDropBaselineColumns(t *testing.T) {
|
||||
down := read005DownMigration(t)
|
||||
upper := strings.ToUpper(down)
|
||||
|
||||
if strings.Contains(upper, "DROP COLUMN") || strings.Contains(upper, "ALTER TABLE") {
|
||||
t.Error("down migration must not drop/alter columns — only drop the answer_feedback table and indexes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUp005NoReservedWords ensures no bare reserved words as column names.
|
||||
func TestUp005NoReservedWords(t *testing.T) {
|
||||
up := read005UpMigration(t)
|
||||
|
||||
// Check for bare "references" as column name (not in FK context)
|
||||
bareRefs := regexp.MustCompile(`(?im)^\s*REFERENCES\s+`)
|
||||
if bareRefs.MatchString(up) {
|
||||
lines := strings.Split(up, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
upperLine := strings.ToUpper(trimmed)
|
||||
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "FOREIGN KEY") {
|
||||
t.Errorf("reserved word 'references' used as column name: %s", trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func countMatchesRe(haystack, pattern string) int {
|
||||
return len(regexp.MustCompile(pattern).FindAllString(haystack, -1))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 000006_native_card_feedback_reasons down migration
|
||||
-- A schema rollback cannot retain values rejected by the legacy constraint,
|
||||
-- so map each native reason to its closest B2 legacy category first.
|
||||
|
||||
UPDATE answer_feedback
|
||||
SET reason_code = CASE reason_code
|
||||
WHEN 'content_mismatch' THEN 'irrelevant'
|
||||
WHEN 'content_incomplete' THEN 'not_resolved'
|
||||
WHEN 'illogical_expression' THEN 'unclear'
|
||||
WHEN 'factual_error' THEN 'incorrect'
|
||||
WHEN 'sensitive_content' THEN 'other'
|
||||
WHEN 'answer_not_wanted' THEN 'not_resolved'
|
||||
ELSE reason_code
|
||||
END
|
||||
WHERE reason_code IN (
|
||||
'content_mismatch',
|
||||
'content_incomplete',
|
||||
'illogical_expression',
|
||||
'factual_error',
|
||||
'sensitive_content',
|
||||
'answer_not_wanted'
|
||||
);
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
ADD CONSTRAINT answer_feedback_reason_code_check
|
||||
CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other'));
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 000006_native_card_feedback_reasons up migration
|
||||
-- Preserve the original B2 reasons and add the six exact reason codes emitted
|
||||
-- by DingTalk's built-in AI feedback component.
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
ADD CONSTRAINT answer_feedback_reason_code_check
|
||||
CHECK (reason_code IN (
|
||||
'',
|
||||
'incorrect',
|
||||
'not_resolved',
|
||||
'irrelevant',
|
||||
'unclear',
|
||||
'outdated',
|
||||
'other',
|
||||
'content_mismatch',
|
||||
'content_incomplete',
|
||||
'illogical_expression',
|
||||
'factual_error',
|
||||
'sensitive_content',
|
||||
'answer_not_wanted'
|
||||
));
|
||||
@@ -0,0 +1,78 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read006Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000006_native_card_feedback_reasons."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000006 %s migration: %v", direction, err)
|
||||
}
|
||||
return strings.Join(strings.Fields(strings.ToUpper(string(data))), " ")
|
||||
}
|
||||
|
||||
func TestUp006ExpandsNamedReasonConstraint(t *testing.T) {
|
||||
up := read006Migration(t, "up")
|
||||
for _, fragment := range []string{
|
||||
"ALTER TABLE ANSWER_FEEDBACK DROP CONSTRAINT IF EXISTS ANSWER_FEEDBACK_REASON_CODE_CHECK",
|
||||
"ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK",
|
||||
"CHECK (REASON_CODE IN",
|
||||
"CONTENT_MISMATCH",
|
||||
"CONTENT_INCOMPLETE",
|
||||
"ILLOGICAL_EXPRESSION",
|
||||
"FACTUAL_ERROR",
|
||||
"SENSITIVE_CONTENT",
|
||||
"ANSWER_NOT_WANTED",
|
||||
} {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
for _, legacy := range []string{"INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"} {
|
||||
if !strings.Contains(up, legacy) {
|
||||
t.Errorf("up migration must preserve legacy reason %q", legacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp006OnlyChangesReasonConstraint(t *testing.T) {
|
||||
up := read006Migration(t, "up")
|
||||
for _, forbidden := range []string{"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM", "UPDATE ANSWER_FEEDBACK"} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("up migration contains forbidden operation %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown006MapsNativeReasonsBeforeRestoringConstraint(t *testing.T) {
|
||||
down := read006Migration(t, "down")
|
||||
mappings := map[string]string{
|
||||
"CONTENT_MISMATCH": "IRRELEVANT",
|
||||
"CONTENT_INCOMPLETE": "NOT_RESOLVED",
|
||||
"ILLOGICAL_EXPRESSION": "UNCLEAR",
|
||||
"FACTUAL_ERROR": "INCORRECT",
|
||||
"SENSITIVE_CONTENT": "OTHER",
|
||||
"ANSWER_NOT_WANTED": "NOT_RESOLVED",
|
||||
}
|
||||
updateAt := strings.Index(down, "UPDATE ANSWER_FEEDBACK")
|
||||
constraintAt := strings.Index(down, "ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK")
|
||||
if updateAt < 0 || constraintAt < 0 || updateAt > constraintAt {
|
||||
t.Fatal("down migration must map native reasons before restoring the legacy constraint")
|
||||
}
|
||||
for from, to := range mappings {
|
||||
if !strings.Contains(down, "WHEN '"+from+"' THEN '"+to+"'") {
|
||||
t.Errorf("down migration missing mapping %s -> %s", from, to)
|
||||
}
|
||||
}
|
||||
for _, native := range []string{"CONTENT_MISMATCH", "CONTENT_INCOMPLETE", "ILLOGICAL_EXPRESSION", "FACTUAL_ERROR", "SENSITIVE_CONTENT", "ANSWER_NOT_WANTED"} {
|
||||
constraint := down[constraintAt:]
|
||||
if strings.Contains(constraint, native) {
|
||||
t.Errorf("legacy down constraint must exclude native reason %q", native)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 000007_coverage_decisions down migration
|
||||
-- Drop support_cases first (child table), then coverage_decisions (parent table).
|
||||
|
||||
DROP TABLE IF EXISTS support_cases;
|
||||
DROP TABLE IF EXISTS coverage_decisions;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- 000007_coverage_decisions up migration
|
||||
-- Creates coverage_decisions and support_cases tables for B3.1 persistence.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coverage_decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
evaluator_version TEXT NOT NULL DEFAULT 'v1',
|
||||
verdict TEXT NOT NULL CHECK (verdict IN ('covered', 'uncovered')),
|
||||
reason_code TEXT NOT NULL CHECK (reason_code IN (
|
||||
'covered_has_references',
|
||||
'uncovered_no_references',
|
||||
'uncovered_explicit_no_answer'
|
||||
)),
|
||||
reference_count INTEGER NOT NULL DEFAULT 0 CHECK (reference_count >= 0),
|
||||
top_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_tenant_channel
|
||||
ON coverage_decisions (tenant_key, channel, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_verdict
|
||||
ON coverage_decisions (verdict) WHERE verdict = 'uncovered';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS support_cases (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
coverage_decision_id TEXT NOT NULL REFERENCES coverage_decisions(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'notifying', 'notification_failed', 'notified', 'resolved')),
|
||||
notification_attempts INTEGER NOT NULL DEFAULT 0 CHECK (notification_attempts >= 0),
|
||||
last_notification_failure_code TEXT NOT NULL DEFAULT '',
|
||||
last_notification_at TIMESTAMPTZ,
|
||||
notified_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_notification_pending
|
||||
ON support_cases (status, created_at, id) WHERE status IN ('pending', 'notification_failed');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_notifying_stale
|
||||
ON support_cases (status, last_notification_at) WHERE status = 'notifying';
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package migrations_test contains source-level contract tests for the
|
||||
// 000007_coverage_decisions migration. No real database connection is used.
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read007Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000007_coverage_decisions."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000007 %s migration: %v", direction, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestUp007CreatesCoverageDecisionsTable(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
required := []string{
|
||||
"CREATE TABLE IF NOT EXISTS COVERAGE_DECISIONS",
|
||||
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
|
||||
"EVALUATOR_VERSION", "VERDICT", "REASON_CODE", "REFERENCE_COUNT", "TOP_SCORE",
|
||||
"CREATED_AT", "UPDATED_AT",
|
||||
}
|
||||
for _, fragment := range required {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CoverageDecisionsConstraints(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
// REFERENCES turns(id)
|
||||
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
|
||||
t.Error("coverage_decisions.turn_id must reference turns(id)")
|
||||
}
|
||||
// UNIQUE (tenant_key, channel, turn_id)
|
||||
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
|
||||
t.Error("coverage_decisions must have UNIQUE (tenant_key, channel, turn_id)")
|
||||
}
|
||||
// verdict CHECK
|
||||
if !strings.Contains(up, "COVERED") || !strings.Contains(up, "UNCOVERED") {
|
||||
t.Error("verdict CHECK must include 'covered' and 'uncovered'")
|
||||
}
|
||||
// reason_code CHECK
|
||||
for _, code := range []string{"COVERED_HAS_REFERENCES", "UNCOVERED_NO_REFERENCES", "UNCOVERED_EXPLICIT_NO_ANSWER"} {
|
||||
if !strings.Contains(up, code) {
|
||||
t.Errorf("reason_code CHECK missing %q", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CreatesSupportCasesTable(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
required := []string{
|
||||
"CREATE TABLE IF NOT EXISTS SUPPORT_CASES",
|
||||
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
|
||||
"COVERAGE_DECISION_ID", "STATUS", "CREATED_AT", "UPDATED_AT",
|
||||
}
|
||||
for _, fragment := range required {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
// Notification fields must be present
|
||||
notificationFields := []string{
|
||||
"NOTIFICATION_ATTEMPTS", "LAST_NOTIFICATION_FAILURE_CODE",
|
||||
"LAST_NOTIFICATION_AT", "NOTIFIED_AT",
|
||||
}
|
||||
for _, fragment := range notificationFields {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing notification field %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007SupportCasesConstraints(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
// FK to coverage_decisions
|
||||
if !strings.Contains(up, "REFERENCES COVERAGE_DECISIONS(ID)") {
|
||||
t.Error("support_cases.coverage_decision_id must reference coverage_decisions(id)")
|
||||
}
|
||||
// FK to turns
|
||||
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
|
||||
t.Error("support_cases.turn_id must reference turns(id)")
|
||||
}
|
||||
// UNIQUE (tenant_key, channel, turn_id)
|
||||
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
|
||||
t.Error("support_cases must have UNIQUE (tenant_key, channel, turn_id)")
|
||||
}
|
||||
// status CHECK
|
||||
for _, status := range []string{"PENDING", "NOTIFYING", "NOTIFICATION_FAILED", "NOTIFIED", "RESOLVED"} {
|
||||
if !strings.Contains(up, status) {
|
||||
t.Errorf("status CHECK must include %q", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CreatesQueryIndexes(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
// Pending/retry query index and stale notifying index
|
||||
if !strings.Contains(up, "IDX_SUPPORT_CASES_NOTIFICATION_PENDING") ||
|
||||
!strings.Contains(up, "WHERE STATUS IN ('PENDING', 'NOTIFICATION_FAILED')") {
|
||||
t.Error("up migration must index pending and notification_failed support_cases")
|
||||
}
|
||||
if !strings.Contains(up, "IDX_SUPPORT_CASES_NOTIFYING_STALE") ||
|
||||
!strings.Contains(up, "WHERE STATUS = 'NOTIFYING'") {
|
||||
t.Error("up migration must index stale notifying support_cases")
|
||||
}
|
||||
// Coverage decisions listing index
|
||||
if !strings.Contains(up, "IDX_COVERAGE_DECISIONS_TENANT_CHANNEL") {
|
||||
t.Error("up migration must create index on coverage_decisions (tenant_key, channel, created_at DESC)")
|
||||
}
|
||||
// Uncovered verdict index
|
||||
if !strings.Contains(up, "WHERE VERDICT = 'UNCOVERED'") {
|
||||
t.Error("up migration must create partial index on uncovered coverage_decisions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CoverageNoReservedWords(t *testing.T) {
|
||||
up := read007Migration(t, "up")
|
||||
// Check for bare "references" as column name in CREATE TABLE statements
|
||||
lines := strings.Split(up, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
upperLine := strings.ToUpper(trimmed)
|
||||
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "REFERENCES TURNS") && !strings.Contains(upperLine, "REFERENCES COVERAGE") {
|
||||
t.Errorf("reserved word 'references' used as column name in: %s", trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007NoForbiddenOperations(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
for _, forbidden := range []string{
|
||||
"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM",
|
||||
"ALTER TABLE", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
|
||||
"RAW_RESPONSE", "RAW_ERROR",
|
||||
} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("up migration contains forbidden operation %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007NoQuestionAnswerBodies(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
for _, forbidden := range []string{"QUESTION", "ANSWER_TEXT", "REFERENCE_CONTENT", "ANSWER_CONTENT"} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("coverage/support tables must not duplicate question/answer/reference bodies: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown007DropsInSafeOrder(t *testing.T) {
|
||||
down := strings.ToUpper(read007Migration(t, "down"))
|
||||
supportAt := strings.Index(down, "DROP TABLE IF EXISTS SUPPORT_CASES")
|
||||
coverageAt := strings.Index(down, "DROP TABLE IF EXISTS COVERAGE_DECISIONS")
|
||||
if supportAt < 0 || coverageAt < 0 {
|
||||
t.Fatal("down migration must drop both tables")
|
||||
}
|
||||
if supportAt > coverageAt {
|
||||
t.Fatal("down migration must drop support_cases (child) before coverage_decisions (parent)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown007DoesNotDropBaselineTables(t *testing.T) {
|
||||
down := strings.ToUpper(read007Migration(t, "down"))
|
||||
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
|
||||
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
|
||||
t.Errorf("down migration must not drop baseline table %q", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE session_mappings
|
||||
DROP COLUMN IF EXISTS display_title;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE session_mappings
|
||||
ADD COLUMN IF NOT EXISTS display_title TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,28 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionDisplayTitleMigration(t *testing.T) {
|
||||
up, err := os.ReadFile(filepath.Join("000008_session_display_title.up.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read up migration: %v", err)
|
||||
}
|
||||
down, err := os.ReadFile(filepath.Join("000008_session_display_title.down.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read down migration: %v", err)
|
||||
}
|
||||
upper := strings.ToUpper(string(up))
|
||||
for _, required := range []string{"ALTER TABLE SESSION_MAPPINGS", "ADD COLUMN IF NOT EXISTS DISPLAY_TITLE", "TEXT NOT NULL", "DEFAULT ''"} {
|
||||
if !strings.Contains(upper, required) {
|
||||
t.Errorf("up migration missing %q", required)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.ToUpper(string(down)), "DROP COLUMN IF EXISTS DISPLAY_TITLE") {
|
||||
t.Error("down migration must drop display_title")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS region_snapshot;
|
||||
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS department_snapshot;
|
||||
|
||||
DROP TABLE IF EXISTS directory_profiles;
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE IF NOT EXISTS directory_profiles (
|
||||
tenant_key TEXT NOT NULL,
|
||||
staff_id TEXT NOT NULL,
|
||||
union_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
nickname TEXT NOT NULL DEFAULT '',
|
||||
job_title TEXT NOT NULL DEFAULT '',
|
||||
department_ids JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
department_name TEXT NOT NULL DEFAULT '',
|
||||
main_department_name TEXT NOT NULL DEFAULT '',
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
employment_status TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL CHECK (source IN ('full_sync','on_demand')),
|
||||
synced_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_key, staff_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_directory_profiles_tenant_name
|
||||
ON directory_profiles (tenant_key, name);
|
||||
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS department_snapshot TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS region_snapshot TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,42 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDirectoryProfilesMigration(t *testing.T) {
|
||||
up, err := os.ReadFile(filepath.Join("000009_directory_profiles.up.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read up migration: %v", err)
|
||||
}
|
||||
down, err := os.ReadFile(filepath.Join("000009_directory_profiles.down.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read down migration: %v", err)
|
||||
}
|
||||
upper := strings.ToUpper(string(up))
|
||||
for _, required := range []string{
|
||||
"CREATE TABLE IF NOT EXISTS DIRECTORY_PROFILES",
|
||||
"PRIMARY KEY (TENANT_KEY, STAFF_ID)",
|
||||
"DEPARTMENT_IDS JSONB",
|
||||
"REGION TEXT",
|
||||
"SOURCE TEXT",
|
||||
"ADD COLUMN IF NOT EXISTS DEPARTMENT_SNAPSHOT",
|
||||
"ADD COLUMN IF NOT EXISTS REGION_SNAPSHOT",
|
||||
} {
|
||||
if !strings.Contains(upper, required) {
|
||||
t.Errorf("up migration missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"MOBILE", "EMAIL", "CLIENT_SECRET", "ACCESS_TOKEN", "RAW_PAYLOAD"} {
|
||||
if strings.Contains(upper, forbidden) {
|
||||
t.Errorf("up migration contains forbidden sensitive field %q", forbidden)
|
||||
}
|
||||
}
|
||||
downUpper := strings.ToUpper(string(down))
|
||||
if !strings.Contains(downUpper, "DROP TABLE IF EXISTS DIRECTORY_PROFILES") || !strings.Contains(downUpper, "DROP COLUMN IF EXISTS REGION_SNAPSHOT") {
|
||||
t.Error("down migration must remove directory table and snapshot columns")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 000010_question_table_exports down migration
|
||||
-- Drop in reverse dependency order: first drop export table, then remove column
|
||||
|
||||
DROP TABLE IF EXISTS question_table_exports;
|
||||
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS trigger_person_snapshot;
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS actor_union_id_snapshot;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- 000010_question_table_exports: durable outbox for Notable question table async sync
|
||||
-- Add trigger_person_snapshot and actor_union_id_snapshot to messages and create question_table_exports table
|
||||
|
||||
-- 1. Add trigger_person_snapshot and actor_union_id_snapshot columns to messages
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS trigger_person_snapshot TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS actor_union_id_snapshot TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 2. Create question_table_exports outbox table
|
||||
CREATE TABLE IF NOT EXISTS question_table_exports (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'exporting', 'exported', 'export_failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at TIMESTAMPTZ,
|
||||
next_attempt_at TIMESTAMPTZ,
|
||||
exported_at TIMESTAMPTZ,
|
||||
failure_code TEXT NOT NULL DEFAULT '',
|
||||
external_record_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
-- 3. Index for claim query: pending/export_failed/exporting ordered by created_at
|
||||
-- Covers pending, export_failed (when next_attempt_at is NULL or <= NOW()),
|
||||
-- and stale exporting (when last_attempt_at < staleBefore).
|
||||
CREATE INDEX IF NOT EXISTS idx_question_table_exports_claim
|
||||
ON question_table_exports (status, next_attempt_at, created_at, id)
|
||||
WHERE status IN ('pending', 'export_failed')
|
||||
OR (status = 'exporting' AND last_attempt_at IS NOT NULL);
|
||||
|
||||
-- 4. Index for stale exporting recovery
|
||||
CREATE INDEX IF NOT EXISTS idx_question_table_exports_stale
|
||||
ON question_table_exports (status, last_attempt_at)
|
||||
WHERE status = 'exporting';
|
||||
@@ -0,0 +1,161 @@
|
||||
// Package migrations_test contains source-level contract tests for the
|
||||
// 000010_question_table_exports migration. No real database connection is used.
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read010Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000010_question_table_exports."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000010 %s migration: %v", direction, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestUp010AddsTriggerPersonSnapshot(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "ALTER TABLE MESSAGES") {
|
||||
t.Fatal("up migration must alter messages")
|
||||
}
|
||||
if !strings.Contains(up, "ADD COLUMN IF NOT EXISTS TRIGGER_PERSON_SNAPSHOT") {
|
||||
t.Error("up migration must add trigger_person_snapshot column")
|
||||
}
|
||||
if !strings.Contains(up, "ADD COLUMN IF NOT EXISTS ACTOR_UNION_ID_SNAPSHOT") {
|
||||
t.Error("up migration must add actor_union_id_snapshot column")
|
||||
}
|
||||
if !strings.Contains(up, "TEXT NOT NULL DEFAULT ''") {
|
||||
t.Error("snapshot columns must be TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010CreatesQuestionTableExports(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
required := []string{
|
||||
"CREATE TABLE IF NOT EXISTS QUESTION_TABLE_EXPORTS",
|
||||
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
|
||||
"STATUS", "ATTEMPTS", "LAST_ATTEMPT_AT", "NEXT_ATTEMPT_AT", "EXPORTED_AT",
|
||||
"FAILURE_CODE", "EXTERNAL_RECORD_ID",
|
||||
"CREATED_AT", "UPDATED_AT",
|
||||
}
|
||||
for _, fragment := range required {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010QuestionTableExportsConstraints(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
// FK to turns
|
||||
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
|
||||
t.Error("question_table_exports.turn_id must reference turns(id)")
|
||||
}
|
||||
// UNIQUE (tenant_key, channel, turn_id)
|
||||
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
|
||||
t.Error("question_table_exports must have UNIQUE (tenant_key, channel, turn_id)")
|
||||
}
|
||||
// status CHECK
|
||||
for _, status := range []string{"PENDING", "EXPORTING", "EXPORTED", "EXPORT_FAILED"} {
|
||||
if !strings.Contains(up, status) {
|
||||
t.Errorf("status CHECK must include %q", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010ClaimIndexes(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "IDX_QUESTION_TABLE_EXPORTS_CLAIM") {
|
||||
t.Error("up migration must create claim index")
|
||||
}
|
||||
if !strings.Contains(up, "WHERE STATUS IN ('PENDING', 'EXPORT_FAILED')") {
|
||||
t.Error("claim index must include pending and export_failed statuses")
|
||||
}
|
||||
if !strings.Contains(up, "IDX_QUESTION_TABLE_EXPORTS_STALE") {
|
||||
t.Error("up migration must create stale exporting index")
|
||||
}
|
||||
if !strings.Contains(up, "WHERE STATUS = 'EXPORTING'") {
|
||||
t.Error("stale index must filter exporting status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010HasNextAttemptAtColumn(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "NEXT_ATTEMPT_AT TIMESTAMPTZ") {
|
||||
t.Error("question_table_exports must have next_attempt_at column for retry backoff")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010HasExternalRecordID(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "EXTERNAL_RECORD_ID TEXT NOT NULL DEFAULT ''") {
|
||||
t.Error("question_table_exports must have external_record_id column (not a1_notation)")
|
||||
}
|
||||
if strings.Contains(up, "A1_NOTATION") {
|
||||
t.Error("question_table_exports must not have stale a1_notation column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010ClaimIndexCoversNextAttemptAt(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "NEXT_ATTEMPT_AT") {
|
||||
t.Error("claim index must cover next_attempt_at for retry backoff filtering")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown010DropsInSafeOrder(t *testing.T) {
|
||||
down := strings.ToUpper(read010Migration(t, "down"))
|
||||
dropExportAt := strings.Index(down, "DROP TABLE IF EXISTS QUESTION_TABLE_EXPORTS")
|
||||
dropTriggerAt := strings.Index(down, "DROP COLUMN IF EXISTS TRIGGER_PERSON_SNAPSHOT")
|
||||
dropUnionAt := strings.Index(down, "DROP COLUMN IF EXISTS ACTOR_UNION_ID_SNAPSHOT")
|
||||
if dropExportAt < 0 || dropTriggerAt < 0 || dropUnionAt < 0 {
|
||||
t.Fatal("down migration must drop export table and both snapshot columns")
|
||||
}
|
||||
if dropExportAt > dropTriggerAt || dropTriggerAt > dropUnionAt {
|
||||
t.Fatal("down migration must drop export table before snapshot columns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown010DoesNotDropBaselineTables(t *testing.T) {
|
||||
down := strings.ToUpper(read010Migration(t, "down"))
|
||||
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
|
||||
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
|
||||
t.Errorf("down migration must not drop baseline table %q", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010ExportsNoFormulaColumns(t *testing.T) {
|
||||
up := read010Migration(t, "up")
|
||||
// Check that the CREATE TABLE body does not contain user/AI content columns
|
||||
lines := strings.Split(up, "\n")
|
||||
inTable := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
upper := strings.ToUpper(trimmed)
|
||||
if strings.HasPrefix(upper, "CREATE TABLE IF NOT EXISTS QUESTION_TABLE_EXPORTS") {
|
||||
inTable = true
|
||||
continue
|
||||
}
|
||||
if inTable && trimmed == ");" {
|
||||
inTable = false
|
||||
continue
|
||||
}
|
||||
if !inTable {
|
||||
continue
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"QUESTION", "ANSWER_TEXT", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
|
||||
"RAW_RESPONSE", "RAW_ERROR", "ACCESS_TOKEN", "APP_SECRET",
|
||||
} {
|
||||
if strings.Contains(upper, forbidden) {
|
||||
t.Errorf("question_table_exports column must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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 迁移、钉钉单消费者切换和生产部署是独立变更边界,必须各自备份、演练和验收。
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package adminweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
)
|
||||
|
||||
type fakeService struct {
|
||||
snapshot Snapshot
|
||||
verified bool
|
||||
saved bool
|
||||
disabled string
|
||||
disableErr error
|
||||
}
|
||||
|
||||
func (f *fakeService) Snapshot(context.Context) (Snapshot, error) { return f.snapshot, nil }
|
||||
func (f *fakeService) SaveCredentials(context.Context, string, string) error {
|
||||
f.saved = true
|
||||
return nil
|
||||
}
|
||||
func (f *fakeService) VerifyCredentials(context.Context, string, string) Verification {
|
||||
f.verified = true
|
||||
return Verification{OK: true, Code: "ok", CheckedAt: time.Unix(1, 0)}
|
||||
}
|
||||
func (f *fakeService) DisableAdministrator(_ context.Context, staffID string) error {
|
||||
f.disabled = staffID
|
||||
return f.disableErr
|
||||
}
|
||||
func csrfTokenFromPage(t *testing.T, h http.Handler) string {
|
||||
t.Helper()
|
||||
get := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
get.Header.Set("Authorization", "Bearer admin-token")
|
||||
page := httptest.NewRecorder()
|
||||
h.ServeHTTP(page, get)
|
||||
marker := `name="csrf_token" value="`
|
||||
start := strings.Index(page.Body.String(), marker)
|
||||
if start < 0 {
|
||||
t.Fatalf("csrf token missing: %s", page.Body.String())
|
||||
}
|
||||
start += len(marker)
|
||||
end := strings.Index(page.Body.String()[start:], `"`)
|
||||
if end < 0 {
|
||||
t.Fatal("csrf token terminator missing")
|
||||
}
|
||||
return page.Body.String()[start : start+end]
|
||||
}
|
||||
func TestDashboardNeverRendersSecrets(t *testing.T) {
|
||||
svc := &fakeService{snapshot: Snapshot{SystemState: "active", ClientIDMasked: "ding***", SecretConfigured: true}}
|
||||
h := New(svc, "admin-token")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "admin-token") || strings.Contains(strings.ToLower(body), "client secret value") {
|
||||
t.Fatal("secret leaked")
|
||||
}
|
||||
if !strings.Contains(body, "ding***") {
|
||||
t.Fatal("masked client id missing")
|
||||
}
|
||||
}
|
||||
func TestDashboardRendersOperationalComponentFields(t *testing.T) {
|
||||
checked := time.Date(2026, 8, 13, 18, 0, 0, 0, time.UTC)
|
||||
svc := &fakeService{snapshot: Snapshot{Components: []Component{{
|
||||
Name: "WeKnora", State: "ok", Code: "health_ok", Detail: "HTTP健康探针正常",
|
||||
LastSuccess: checked.Add(-time.Minute), CheckedAt: checked,
|
||||
}}}}
|
||||
h := New(svc, "admin-token")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"WeKnora", "health_ok", "HTTP健康探针正常", "2026-08-13 17:59:00", "2026-08-13 18:00:00"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("dashboard missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCredentialsRequiresVerification(t *testing.T) {
|
||||
svc := &fakeService{}
|
||||
h := New(svc, "admin-token")
|
||||
csrf := csrfTokenFromPage(t, h)
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/setup/credentials", strings.NewReader("client_id=id&client_secret=secret&csrf_token="+csrf))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusSeeOther || !svc.verified || !svc.saved {
|
||||
t.Fatalf("status=%d verified=%v saved=%v", w.Code, svc.verified, svc.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllAdminPOSTRoutesRejectMissingCSRF(t *testing.T) {
|
||||
svc := &fakeService{}
|
||||
h := New(svc, "admin-token")
|
||||
for _, route := range []string{"/admin/setup/credentials", "/admin/setup/candidate/activate", "/admin/setup/bootstrap", "/admin/setup/bootstrap/confirm", "/admin/settings/administrators/disable"} {
|
||||
req := httptest.NewRequest(http.MethodPost, route, strings.NewReader("client_id=id&client_secret=secret&code=123456&staff_id=staff-old"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("route=%s status=%d body=%q", route, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if svc.saved || svc.disabled != "" {
|
||||
t.Fatalf("business service called saved=%v disabled=%q", svc.saved, svc.disabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardRendersAdministratorReplacementWorkflow(t *testing.T) {
|
||||
svc := &fakeService{snapshot: Snapshot{Administrators: []AdministratorView{{StaffID: "staff-old", StaffIDMasked: "sta***old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active", CanDisable: true}}}}
|
||||
h := New(svc, "admin-token")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
for _, want := range []string{"新增/更换管理员", "先加后停", "旧管理员", "技术部", "sta***old", "生成6位绑定码", "停用此管理员"} {
|
||||
if !strings.Contains(w.Body.String(), want) {
|
||||
t.Fatalf("dashboard missing %q: %s", want, w.Body.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(w.Body.String(), ">staff-old<") {
|
||||
t.Fatalf("raw staff ID must not be rendered as visible text: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableAdministratorRequiresCSRFAndMapsLastAdminConflict(t *testing.T) {
|
||||
svc := &fakeService{}
|
||||
h := New(svc, "admin-token")
|
||||
post := func(body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/settings/administrators/disable", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
if w := post("staff_id=staff-old"); w.Code != http.StatusForbidden || svc.disabled != "" {
|
||||
t.Fatalf("missing csrf status=%d disabled=%q", w.Code, svc.disabled)
|
||||
}
|
||||
|
||||
csrf := csrfTokenFromPage(t, h)
|
||||
|
||||
svc.disableErr = settings.ErrLastAdministrator
|
||||
w := post("staff_id=staff-old&csrf_token=" + csrf)
|
||||
if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "至少保留一位有效管理员") {
|
||||
t.Fatalf("last-admin status=%d body=%q", w.Code, w.Body.String())
|
||||
}
|
||||
svc.disableErr = nil
|
||||
w = post("staff_id=staff-old&csrf_token=" + csrf)
|
||||
if w.Code != http.StatusSeeOther || svc.disabled != "staff-old" {
|
||||
t.Fatalf("success status=%d disabled=%q body=%q", w.Code, svc.disabled, w.Body.String())
|
||||
}
|
||||
}
|
||||
+44
-30
@@ -25,19 +25,22 @@ const (
|
||||
// It must never contain full question/answer text, error_message, WeKnora
|
||||
// session/request IDs, or provider_message_id.
|
||||
type TurnSummary struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Channel string `json:"channel"`
|
||||
Status TurnStatus `json:"status"`
|
||||
ConversationType string `json:"conversation_type"`
|
||||
ConversationTitle string `json:"conversation_title,omitempty"`
|
||||
ActorExternalID string `json:"actor_external_id,omitempty"`
|
||||
ActorDisplayName string `json:"actor_display_name,omitempty"`
|
||||
QuestionSummary string `json:"question_summary"`
|
||||
AnswerSummary string `json:"answer_summary"`
|
||||
ReferenceCount int `json:"reference_count"`
|
||||
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
|
||||
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Channel string `json:"channel"`
|
||||
Status TurnStatus `json:"status"`
|
||||
ConversationType string `json:"conversation_type"`
|
||||
ConversationTitle string `json:"conversation_title,omitempty"`
|
||||
ActorExternalID string `json:"actor_external_id,omitempty"`
|
||||
ActorDisplayName string `json:"actor_display_name,omitempty"`
|
||||
QuestionSummary string `json:"question_summary"`
|
||||
AnswerSummary string `json:"answer_summary"`
|
||||
ReferenceCount int `json:"reference_count"`
|
||||
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
|
||||
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
|
||||
FeedbackUpCount int `json:"feedback_up_count"`
|
||||
FeedbackDownCount int `json:"feedback_down_count"`
|
||||
FeedbackUnresolvedCount int `json:"feedback_unresolved_count"`
|
||||
}
|
||||
|
||||
// SafeReference is a reference with only safe fields exposed.
|
||||
@@ -72,27 +75,38 @@ type MessageMeta struct {
|
||||
FailureCode string `json:"failure_code,omitempty"`
|
||||
}
|
||||
|
||||
// AnswerFeedback is the safe current feedback state for one actor. Provider
|
||||
// tracking IDs, internal IDs and raw callback fields are intentionally absent.
|
||||
type AnswerFeedback struct {
|
||||
ActorExternalID string `json:"actor_external_id"`
|
||||
ActorDisplayName string `json:"actor_display_name,omitempty"`
|
||||
Value string `json:"value"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TurnDetail is the full detail view of a turn. It must never include
|
||||
// error_message, WeKnora session/request ID, provider_message_id,
|
||||
// sessionWebhook, tokens, raw API/error, or Reference.Content/Raw.
|
||||
type TurnDetail struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Channel string `json:"channel"`
|
||||
Status TurnStatus `json:"status"`
|
||||
ConversationType string `json:"conversation_type"`
|
||||
ConversationTitle string `json:"conversation_title,omitempty"`
|
||||
ActorExternalID string `json:"actor_external_id,omitempty"`
|
||||
ActorDisplayName string `json:"actor_display_name,omitempty"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
References []SafeReference `json:"references,omitempty"`
|
||||
Media []SafeMedia `json:"media,omitempty"`
|
||||
RequestMessage *MessageMeta `json:"request_message,omitempty"`
|
||||
AnswerMessage *MessageMeta `json:"answer_message,omitempty"`
|
||||
ReferenceCount int `json:"reference_count"`
|
||||
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
|
||||
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Channel string `json:"channel"`
|
||||
Status TurnStatus `json:"status"`
|
||||
ConversationType string `json:"conversation_type"`
|
||||
ConversationTitle string `json:"conversation_title,omitempty"`
|
||||
ActorExternalID string `json:"actor_external_id,omitempty"`
|
||||
ActorDisplayName string `json:"actor_display_name,omitempty"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
References []SafeReference `json:"references,omitempty"`
|
||||
Media []SafeMedia `json:"media,omitempty"`
|
||||
RequestMessage *MessageMeta `json:"request_message,omitempty"`
|
||||
AnswerMessage *MessageMeta `json:"answer_message,omitempty"`
|
||||
ReferenceCount int `json:"reference_count"`
|
||||
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
|
||||
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
|
||||
Feedback []AnswerFeedback `json:"feedback,omitempty"`
|
||||
}
|
||||
|
||||
// ListTurnsRequest is the input for listing turns.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFeedbackProjectionJSONUsesOnlySafeFields(t *testing.T) {
|
||||
updated := time.Date(2026, 7, 21, 15, 0, 0, 0, time.UTC)
|
||||
detail := TurnDetail{Feedback: []AnswerFeedback{{
|
||||
ActorExternalID: "user-1", ActorDisplayName: "Alice", Value: "down", ReasonCode: "not_resolved", UpdatedAt: updated,
|
||||
}}}
|
||||
raw, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, want := range []string{`"feedback"`, `"actor_external_id":"user-1"`, `"actor_display_name":"Alice"`, `"value":"down"`, `"reason_code":"not_resolved"`, `"updated_at"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("feedback JSON missing %s: %s", want, body)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"out_track", "provider_message", "answer_message_id", "actor_id", "token", "callback", "params"} {
|
||||
if strings.Contains(strings.ToLower(body), forbidden) {
|
||||
t.Errorf("feedback JSON leaked %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnSummaryFeedbackCountsJSON(t *testing.T) {
|
||||
raw, err := json.Marshal(TurnSummary{FeedbackUpCount: 2, FeedbackDownCount: 3, FeedbackUnresolvedCount: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, want := range []string{`"feedback_up_count":2`, `"feedback_down_count":3`, `"feedback_unresolved_count":1`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("summary JSON missing %s: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -17,8 +18,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
|
||||
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
|
||||
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
|
||||
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
|
||||
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
|
||||
)
|
||||
|
||||
// CardRequest is the input for sending a final AI card.
|
||||
@@ -39,26 +41,38 @@ type CardSender struct {
|
||||
streamURL string
|
||||
apiClient *http.Client
|
||||
allowTestURL bool
|
||||
debugUserID string
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenTill time.Time
|
||||
}
|
||||
|
||||
// CardSenderOption configures optional CardSender behavior.
|
||||
type CardSenderOption func(*CardSender)
|
||||
|
||||
// WithCardDebugUserID enables DingTalk's official card debug entry only for
|
||||
// the matching user through per-user privateData. A blank value keeps it off.
|
||||
func WithCardDebugUserID(userID string) CardSenderOption {
|
||||
return func(sender *CardSender) {
|
||||
sender.debugUserID = strings.TrimSpace(userID)
|
||||
}
|
||||
}
|
||||
|
||||
// NewCardSender creates a CardSender. All three config values are required
|
||||
// after trimming whitespace. A nil http.Client installs a hardened default
|
||||
// (no redirects, Proxy=nil, 15s timeout).
|
||||
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client) (*CardSender, error) {
|
||||
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false)
|
||||
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, options ...CardSenderOption) (*CardSender, error) {
|
||||
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false, options...)
|
||||
}
|
||||
|
||||
// newCardSenderForTest is used by tests to inject httptest URLs.
|
||||
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string) (*CardSender, error) {
|
||||
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string, options ...CardSenderOption) (*CardSender, error) {
|
||||
streamURL := strings.TrimSuffix(createURL, "/create") + "/stream"
|
||||
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true)
|
||||
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true, options...)
|
||||
}
|
||||
|
||||
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool) (*CardSender, error) {
|
||||
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool, options ...CardSenderOption) (*CardSender, error) {
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
clientSecret = strings.TrimSpace(clientSecret)
|
||||
cardTemplateID = strings.TrimSpace(cardTemplateID)
|
||||
@@ -91,7 +105,7 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
|
||||
apiClient.Transport = transport
|
||||
}
|
||||
|
||||
return &CardSender{
|
||||
sender := &CardSender{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
cardTemplateID: cardTemplateID,
|
||||
@@ -100,7 +114,13 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
|
||||
streamURL: streamURL,
|
||||
apiClient: &apiClient,
|
||||
allowTestURL: allowTest,
|
||||
}, nil
|
||||
}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
option(sender)
|
||||
}
|
||||
}
|
||||
return sender, nil
|
||||
}
|
||||
|
||||
// SendFinalCard sends the AI card to DingTalk and returns the generated outTrackID.
|
||||
@@ -134,9 +154,10 @@ func (s *CardSender) StartCard(ctx context.Context, req CardRequest) (string, er
|
||||
return "", errors.New("dingtalk card: content exceeds 64 KiB")
|
||||
}
|
||||
|
||||
// Validate identity fields
|
||||
// Validate identity fields. ExternalUserID is also available for group
|
||||
// askers and is used only for per-user card private data.
|
||||
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
|
||||
if req.ConversationType == core.ConversationDirect {
|
||||
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
|
||||
if req.ExternalUserID == "" {
|
||||
return "", errors.New("dingtalk card: ExternalUserID is required for direct conversations")
|
||||
}
|
||||
@@ -186,6 +207,7 @@ func (s *CardSender) UpdateCard(ctx context.Context, outTrackID, content string,
|
||||
if len(content) > 64*1024 {
|
||||
return errors.New("dingtalk card: content exceeds 64 KiB")
|
||||
}
|
||||
log.Print(formatCardUpdateDiagnostic(content, isFinalize, isError))
|
||||
|
||||
token, err := s.accessToken(ctx)
|
||||
if err != nil {
|
||||
@@ -202,7 +224,11 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
|
||||
"callbackType": "STREAM",
|
||||
"cardData": map[string]any{
|
||||
"cardParamMap": map[string]any{
|
||||
"content": req.Content,
|
||||
"content": req.Content,
|
||||
"feedbackSelected": "",
|
||||
"feedbackReasonSelected": "",
|
||||
"showFeedbackReasons": "false",
|
||||
"feedbackMessage": "",
|
||||
},
|
||||
},
|
||||
"userIdType": 1,
|
||||
@@ -228,6 +254,15 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
|
||||
"extension": map[string]any{},
|
||||
}
|
||||
}
|
||||
if s.debugUserID != "" && req.ExternalUserID == s.debugUserID {
|
||||
body["privateData"] = map[string]any{
|
||||
req.ExternalUserID: map[string]any{
|
||||
"cardParamMap": map[string]any{
|
||||
cardDebugPrivateKey: "show",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -272,9 +307,25 @@ func (s *CardSender) postCard(ctx context.Context, body map[string]any, token st
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Discard successful response body
|
||||
if len(payload) > 0 {
|
||||
// Body is already bounded by postJSON→readBoundedAPIResponse; just discard
|
||||
return validateDingTalkBusinessResponse(payload)
|
||||
}
|
||||
|
||||
func validateDingTalkBusinessResponse(payload []byte) error {
|
||||
if len(bytes.TrimSpace(payload)) == 0 {
|
||||
return nil
|
||||
}
|
||||
var response struct {
|
||||
Success *bool `json:"success"`
|
||||
ErrCode any `json:"errcode"`
|
||||
}
|
||||
if json.Unmarshal(payload, &response) != nil {
|
||||
return nil
|
||||
}
|
||||
if response.Success != nil && !*response.Success {
|
||||
return errors.New("dingtalk card: API business failure")
|
||||
}
|
||||
if response.ErrCode != nil && fmt.Sprint(response.ErrCode) != "0" {
|
||||
return errors.New("dingtalk card: API business failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -293,8 +344,11 @@ func (s *CardSender) finalizeCard(ctx context.Context, outTrackID, content, toke
|
||||
"isFinalize": isFinalize,
|
||||
"isError": isError,
|
||||
}
|
||||
_, err = s.doJSON(ctx, http.MethodPut, s.streamURL, body, token)
|
||||
return err
|
||||
payload, err := s.doJSON(ctx, http.MethodPut, s.streamURL, body, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDingTalkBusinessResponse(payload)
|
||||
}
|
||||
|
||||
func (s *CardSender) postJSON(ctx context.Context, endpoint string, value any, token string) ([]byte, error) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type cardContentShape struct {
|
||||
bytes int
|
||||
lines int
|
||||
pipeLines int
|
||||
leadingPipeLines int
|
||||
delimiterLines int
|
||||
confirmedTable bool
|
||||
confirmedTablePos int
|
||||
}
|
||||
|
||||
func inspectCardContent(content string) cardContentShape {
|
||||
shape := cardContentShape{
|
||||
bytes: len(content),
|
||||
confirmedTablePos: -1,
|
||||
}
|
||||
if content == "" {
|
||||
return shape
|
||||
}
|
||||
shape.lines = strings.Count(content, "\n") + 1
|
||||
|
||||
lines := splitMarkdownStreamLines(content)
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line.text, "|") {
|
||||
shape.pipeLines++
|
||||
}
|
||||
if isTableHeaderCandidate(line.text) {
|
||||
shape.leadingPipeLines++
|
||||
}
|
||||
if !line.inFence && isCompleteTableDelimiter(line.text) {
|
||||
shape.delimiterLines++
|
||||
}
|
||||
if shape.confirmedTable || i == 0 || line.inFence || lines[i-1].inFence {
|
||||
continue
|
||||
}
|
||||
if isTableHeaderCandidate(lines[i-1].text) && isCompleteTableDelimiter(line.text) {
|
||||
shape.confirmedTable = true
|
||||
shape.confirmedTablePos = lines[i-1].start
|
||||
}
|
||||
}
|
||||
return shape
|
||||
}
|
||||
|
||||
func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string {
|
||||
shape := inspectCardContent(content)
|
||||
return fmt.Sprintf(
|
||||
"DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d",
|
||||
isFinalize,
|
||||
isError,
|
||||
shape.bytes,
|
||||
shape.lines,
|
||||
shape.pipeLines,
|
||||
shape.leadingPipeLines,
|
||||
shape.delimiterLines,
|
||||
shape.confirmedTable,
|
||||
shape.confirmedTablePos,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCardSenderUpdateCardLogsOnlySafeContentShape(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
sender, err := newCardSenderForTest("client-id", "client-secret", "template-id", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
previousWriter := log.Writer()
|
||||
previousFlags := log.Flags()
|
||||
previousPrefix := log.Prefix()
|
||||
log.SetOutput(&output)
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("")
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(previousWriter)
|
||||
log.SetFlags(previousFlags)
|
||||
log.SetPrefix(previousPrefix)
|
||||
})
|
||||
|
||||
content := "DO_NOT_LOG_BOUNDARY_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
|
||||
if err := sender.UpdateCard(context.Background(), "track-diagnostic", content, false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := output.String()
|
||||
if !strings.Contains(got, "DingTalk card update shape:") || !strings.Contains(got, "confirmed_table=true") || !strings.Contains(got, "finalize=false") {
|
||||
t.Fatalf("missing safe boundary diagnostic: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "DO_NOT_LOG_BOUNDARY_TEXT") || strings.Contains(got, "CR5") || strings.Contains(got, "track-diagnostic") {
|
||||
t.Fatalf("boundary diagnostic leaked content or card identifier: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCardUpdateDiagnosticReportsStructureWithoutContent(t *testing.T) {
|
||||
content := "DO_NOT_LOG_ANSWER_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
|
||||
|
||||
got := formatCardUpdateDiagnostic(content, false, false)
|
||||
wantFields := []string{
|
||||
"DingTalk card update shape:",
|
||||
"finalize=false",
|
||||
"error=false",
|
||||
fmt.Sprintf("bytes=%d", len(content)),
|
||||
"lines=5",
|
||||
"pipe_lines=3",
|
||||
"leading_pipe_lines=3",
|
||||
"delimiter_lines=1",
|
||||
"confirmed_table=true",
|
||||
fmt.Sprintf("table_start=%d", strings.Index(content, "| 型号")),
|
||||
}
|
||||
for _, field := range wantFields {
|
||||
if !strings.Contains(got, field) {
|
||||
t.Fatalf("diagnostic %q missing field %q", got, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "DO_NOT_LOG_ANSWER_TEXT") || strings.Contains(got, "CR5") {
|
||||
t.Fatalf("diagnostic leaked answer content: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCardUpdateDiagnosticDistinguishesFinalPlainText(t *testing.T) {
|
||||
got := formatCardUpdateDiagnostic("普通回答", true, false)
|
||||
wantFields := []string{
|
||||
"finalize=true",
|
||||
"error=false",
|
||||
"lines=1",
|
||||
"pipe_lines=0",
|
||||
"leading_pipe_lines=0",
|
||||
"delimiter_lines=0",
|
||||
"confirmed_table=false",
|
||||
"table_start=-1",
|
||||
}
|
||||
for _, field := range wantFields {
|
||||
if !strings.Contains(got, field) {
|
||||
t.Fatalf("diagnostic %q missing field %q", got, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "普通回答") {
|
||||
t.Fatalf("diagnostic leaked answer content: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type cardTestServer struct {
|
||||
createBodySize int64 // Content-Length for oversized response
|
||||
streamStatus int
|
||||
streamBodySize int64
|
||||
streamResponse any
|
||||
}
|
||||
|
||||
func newCardTestServer() *cardTestServer {
|
||||
@@ -42,6 +43,7 @@ func newCardTestServer() *cardTestServer {
|
||||
tokenStatus: http.StatusOK,
|
||||
createStatus: http.StatusOK,
|
||||
streamStatus: http.StatusOK,
|
||||
streamResponse: map[string]any{"result": "ok"},
|
||||
tokenResponse: map[string]any{"accessToken": "test-access-token", "expireIn": 3600},
|
||||
createResponse: map[string]any{"result": "ok"},
|
||||
}
|
||||
@@ -91,7 +93,7 @@ func newCardTestServer() *cardTestServer {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"result": "ok"})
|
||||
json.NewEncoder(w).Encode(ts.streamResponse)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -218,6 +220,16 @@ func TestCardSendDirectPayload(t *testing.T) {
|
||||
if paramMap["content"] != "Hello from AI" {
|
||||
t.Errorf("content=%v", paramMap["content"])
|
||||
}
|
||||
for key, want := range map[string]any{
|
||||
"feedbackSelected": "",
|
||||
"feedbackReasonSelected": "",
|
||||
"showFeedbackReasons": "false",
|
||||
"feedbackMessage": "",
|
||||
} {
|
||||
if paramMap[key] != want {
|
||||
t.Errorf("%s=%v, want %v", key, paramMap[key], want)
|
||||
}
|
||||
}
|
||||
if body["userIdType"] != float64(1) {
|
||||
t.Errorf("userIdType=%v", body["userIdType"])
|
||||
}
|
||||
@@ -261,6 +273,117 @@ func TestCardSendDirectPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: official DingTalk card debug private data for one allowlisted user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCardSendDirectAddsDebugPrivateDataForSelectedUser(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
sender, err := newCardSenderForTest(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"card-template-123",
|
||||
ts.Client(),
|
||||
ts.URL()+"/token",
|
||||
ts.URL()+"/create",
|
||||
WithCardDebugUserID(" user-debug "),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = sender.SendFinalCard(context.Background(), CardRequest{
|
||||
ConversationType: core.ConversationDirect,
|
||||
ExternalUserID: "user-debug",
|
||||
Content: "debug this card",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendFinalCard: %v", err)
|
||||
}
|
||||
|
||||
privateData, ok := ts.lastCreateBody["privateData"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("privateData missing or wrong type")
|
||||
}
|
||||
userData, ok := privateData["user-debug"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("selected user's privateData missing or wrong type")
|
||||
}
|
||||
paramMap, ok := userData["cardParamMap"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("selected user's cardParamMap missing or wrong type")
|
||||
}
|
||||
if got := paramMap["_CARD_DEBUG_TOOL_ENTRY"]; got != "show" {
|
||||
t.Fatalf("_CARD_DEBUG_TOOL_ENTRY=%v, want show", got)
|
||||
}
|
||||
if len(privateData) != 1 {
|
||||
t.Fatalf("privateData has %d users, want 1", len(privateData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardSendOmitsDebugPrivateDataForOtherUser(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
sender, err := newCardSenderForTest(
|
||||
"client-id", "client-secret", "card-template-123",
|
||||
ts.Client(), ts.URL()+"/token", ts.URL()+"/create",
|
||||
WithCardDebugUserID("user-debug"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = sender.SendFinalCard(context.Background(), CardRequest{
|
||||
ConversationType: core.ConversationDirect,
|
||||
ExternalUserID: "user-other",
|
||||
Content: "ordinary card",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendFinalCard: %v", err)
|
||||
}
|
||||
if _, ok := ts.lastCreateBody["privateData"]; ok {
|
||||
t.Fatal("privateData must be absent for a non-allowlisted user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardSendGroupAddsDebugPrivateDataOnlyForAsker(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
sender, err := newCardSenderForTest(
|
||||
"client-id", "client-secret", "card-template-123",
|
||||
ts.Client(), ts.URL()+"/token", ts.URL()+"/create",
|
||||
WithCardDebugUserID("user-debug"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = sender.SendFinalCard(context.Background(), CardRequest{
|
||||
ConversationType: core.ConversationGroup,
|
||||
ExternalUserID: " user-debug ",
|
||||
ExternalChatID: "group-789",
|
||||
Content: "group debug card",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendFinalCard: %v", err)
|
||||
}
|
||||
|
||||
privateData, ok := ts.lastCreateBody["privateData"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("privateData missing or wrong type")
|
||||
}
|
||||
if _, ok := privateData["user-debug"]; !ok {
|
||||
t.Fatal("group asker's privateData missing")
|
||||
}
|
||||
if len(privateData) != 1 {
|
||||
t.Fatalf("privateData has %d users, want 1", len(privateData))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: group conversation full payload
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -848,6 +971,19 @@ func TestSendFinalCardCreateStreamOnce(t *testing.T) {
|
||||
// Test: UpdateCard with non-final / error flags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUpdateCardRejectsHTTP200BusinessFailure(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
ts.streamResponse = map[string]any{"success": false, "code": "InvalidParameter", "message": "sensitive detail"}
|
||||
sender, err := newCardSenderForTest("client-id", "client-secret", "card-template-123", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sender.UpdateCard(context.Background(), "track-1", "answer", true, false); err == nil {
|
||||
t.Fatal("HTTP 200 business failure must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCardNonFinalAndError(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const displayReferenceMarkerPattern = `\[\[ref[\t ]+id[\t ]*=[\t ]*["']c[0-9]+["'][\t ]*\]\]`
|
||||
|
||||
var (
|
||||
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
|
||||
leadingReferenceGroupColonRE = regexp.MustCompile(`(?m)^[ ]*(?:` + displayReferenceMarkerPattern + `[ ]*)+[::][ ]*`)
|
||||
spacedReferenceGroupRE = regexp.MustCompile(`[ ]+(?:` + displayReferenceMarkerPattern + `[ ]*)+`)
|
||||
spaceBeforeDisplayPunctuation = regexp.MustCompile(`[ ]+([,。;:!?、,.;:!?])`)
|
||||
spaceAfterDisplayColon = regexp.MustCompile(`([::])[\t ]+`)
|
||||
)
|
||||
|
||||
func sanitizeDisplayAnswer(content string) string {
|
||||
if index := strings.LastIndex(content, "[[ref"); index >= 0 && !strings.Contains(content[index:], "]]") {
|
||||
content = content[:index]
|
||||
}
|
||||
if !displayReferenceMarkerRE.MatchString(content) {
|
||||
return content
|
||||
}
|
||||
content = leadingReferenceGroupColonRE.ReplaceAllString(content, "")
|
||||
content = spacedReferenceGroupRE.ReplaceAllString(content, " ")
|
||||
content = displayReferenceMarkerRE.ReplaceAllString(content, "")
|
||||
content = spaceBeforeDisplayPunctuation.ReplaceAllString(content, "$1")
|
||||
content = spaceAfterDisplayColon.ReplaceAllString(content, "$1")
|
||||
return content
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package dingtalk
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeDisplayAnswerRemovesInternalReferenceMarkers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "adjacent inline markers",
|
||||
input: "结论支持该功能[[ref id=\"c2\"]][[ref id=\"c4\"]]。",
|
||||
want: "结论支持该功能。",
|
||||
},
|
||||
{
|
||||
name: "leading marker group and chinese colon",
|
||||
input: "[[ref id=\"c2\"]][[ref id=\"c4\"]]:建议先检查配置。",
|
||||
want: "建议先检查配置。",
|
||||
},
|
||||
{
|
||||
name: "leading marker group and ascii colon on second line",
|
||||
input: "第一段。\n[[ref id='c12']] : Second paragraph.",
|
||||
want: "第一段。\nSecond paragraph.",
|
||||
},
|
||||
{
|
||||
name: "normal punctuation remains natural",
|
||||
input: "标题:[[ref id=\"c1\"]] 详细说明",
|
||||
want: "标题:详细说明",
|
||||
},
|
||||
{
|
||||
name: "spaces around marker collapse to one",
|
||||
input: "Feature A [[ref id=\"c3\"]] remains supported.",
|
||||
want: "Feature A remains supported.",
|
||||
},
|
||||
{
|
||||
name: "unrelated double bracket syntax is preserved",
|
||||
input: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
|
||||
want: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
|
||||
},
|
||||
{
|
||||
name: "content without references remains byte for byte unchanged",
|
||||
input: "Protocol: value ; keep spacing\n indented",
|
||||
want: "Protocol: value ; keep spacing\n indented",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sanitizeDisplayAnswer(tt.input); got != tt.want {
|
||||
t.Fatalf("sanitizeDisplayAnswer()=%q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeDisplayAnswerHoldsBackDanglingStreamingReference(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"答案正文[[ref",
|
||||
"答案正文[[ref id=\"c",
|
||||
"答案正文[[ref id=\"c2\"",
|
||||
} {
|
||||
if got := sanitizeDisplayAnswer(input); got != "答案正文" {
|
||||
t.Fatalf("sanitizeDisplayAnswer(%q)=%q", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEffectiveDownloadCodesDeduplicatesAndPreservesOrder(t *testing.T) {
|
||||
cb := BotCallback{
|
||||
DownloadCode: "code-first",
|
||||
DownloadCodes: []string{"code-first", "code-second", " code-third "},
|
||||
}
|
||||
codes := effectiveDownloadCodes(cb)
|
||||
if len(codes) != 3 {
|
||||
t.Fatalf("codes=%v, want 3", codes)
|
||||
}
|
||||
if codes[0] != "code-first" || codes[1] != "code-second" || codes[2] != "code-third" {
|
||||
t.Fatalf("codes=%v, want ordered de-dup", codes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveDownloadCodesSingleOnly(t *testing.T) {
|
||||
cb := BotCallback{DownloadCode: " solo "}
|
||||
codes := effectiveDownloadCodes(cb)
|
||||
if len(codes) != 1 || codes[0] != "solo" {
|
||||
t.Fatalf("codes=%v, want [solo]", codes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveDownloadCodesEmpty(t *testing.T) {
|
||||
if codes := effectiveDownloadCodes(BotCallback{}); len(codes) != 0 {
|
||||
t.Fatalf("codes=%v, want empty", codes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||||
|
||||
feedbackdomain "gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionNativeFeedback = "sys_action_like_component"
|
||||
ActionFeedbackUp = "dobotai_feedback_up"
|
||||
ActionFeedbackDown = "dobotai_feedback_down"
|
||||
ActionReasonIncorrect = "dobotai_reason_incorrect"
|
||||
ActionReasonNotResolved = "dobotai_reason_not_resolved"
|
||||
ActionReasonIrrelevant = "dobotai_reason_irrelevant"
|
||||
ActionReasonUnclear = "dobotai_reason_unclear"
|
||||
ActionReasonOutdated = "dobotai_reason_outdated"
|
||||
ActionReasonOther = "dobotai_reason_other"
|
||||
ActionReasonContentMismatch = "dobotai_reason_content_mismatch"
|
||||
ActionReasonContentIncomplete = "dobotai_reason_content_incomplete"
|
||||
ActionReasonIllogical = "dobotai_reason_illogical_expression"
|
||||
ActionReasonFactualError = "dobotai_reason_factual_error"
|
||||
ActionReasonSensitiveContent = "dobotai_reason_sensitive_content"
|
||||
ActionReasonAnswerNotWanted = "dobotai_reason_answer_not_wanted"
|
||||
)
|
||||
|
||||
var nativeReasonByTag = map[string]string{
|
||||
"1": feedbackdomain.ReasonContentMismatch,
|
||||
"2": feedbackdomain.ReasonContentIncomplete,
|
||||
"3": feedbackdomain.ReasonIllogical,
|
||||
"4": feedbackdomain.ReasonFactualError,
|
||||
"5": feedbackdomain.ReasonSensitiveContent,
|
||||
"6": feedbackdomain.ReasonAnswerNotWanted,
|
||||
}
|
||||
|
||||
type feedbackService interface {
|
||||
Apply(context.Context, feedbackdomain.Command) (feedbackdomain.Result, error)
|
||||
}
|
||||
|
||||
type CardFeedbackHandler struct {
|
||||
service feedbackService
|
||||
}
|
||||
|
||||
func NewCardFeedbackHandler(service feedbackService) (*CardFeedbackHandler, error) {
|
||||
if service == nil {
|
||||
return nil, errors.New("dingtalk card feedback: service is required")
|
||||
}
|
||||
return &CardFeedbackHandler{service: service}, nil
|
||||
}
|
||||
|
||||
func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.CardRequest) (*sdkcard.CardResponse, error) {
|
||||
if request == nil {
|
||||
return nil, errors.New("dingtalk card feedback: request is nil")
|
||||
}
|
||||
value, reason, recognized := feedbackAction(
|
||||
request.CardActionData.CardPrivateData.ActionIdList,
|
||||
request.CardActionData.CardPrivateData.Params,
|
||||
)
|
||||
if !recognized {
|
||||
return &sdkcard.CardResponse{}, nil
|
||||
}
|
||||
_, err := h.service.Apply(ctx, feedbackdomain.Command{
|
||||
ExternalActorID: request.UserId,
|
||||
OutTrackID: request.OutTrackId,
|
||||
Value: value,
|
||||
ReasonCode: reason,
|
||||
Source: feedbackdomain.SourceDingTalkCard,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
showReasons := "false"
|
||||
message := "感谢反馈,我们会继续改进"
|
||||
if value == feedbackdomain.ValueUp {
|
||||
message = "感谢反馈"
|
||||
}
|
||||
return &sdkcard.CardResponse{
|
||||
CardUpdateOptions: &sdkcard.CardUpdateOptions{UpdatePrivateDataByKey: true},
|
||||
UserPrivateData: &sdkcard.CardDataDto{CardParamMap: map[string]string{
|
||||
"feedbackSelected": value,
|
||||
"feedbackReasonSelected": reason,
|
||||
"showFeedbackReasons": showReasons,
|
||||
"feedbackMessage": message,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func feedbackAction(actions []string, params map[string]any) (string, string, bool) {
|
||||
value, reason, matches := "", "", 0
|
||||
for _, action := range actions {
|
||||
candidateValue, candidateReason, ok := oneFeedbackAction(action)
|
||||
if action == ActionNativeFeedback {
|
||||
candidateValue, candidateReason, ok = nativeFeedbackAction(params)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
matches++
|
||||
value, reason = candidateValue, candidateReason
|
||||
}
|
||||
return value, reason, matches == 1
|
||||
}
|
||||
|
||||
func nativeFeedbackAction(params map[string]any) (string, string, bool) {
|
||||
key, keyOK := params["variableKey"].(string)
|
||||
if !keyOK || !strings.HasPrefix(key, "node_") {
|
||||
return "", "", false
|
||||
}
|
||||
if nativeVariableKey(key, "_likeOrDislike") {
|
||||
value, ok := params["variableValue"].(string)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
switch value {
|
||||
case "like":
|
||||
return feedbackdomain.ValueUp, feedbackdomain.ReasonEmpty, true
|
||||
case "dislike":
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonEmpty, true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
if nativeVariableKey(key, "_dislikeTags") {
|
||||
values, ok := params["variableValue"].([]any)
|
||||
if !ok || len(values) != 1 {
|
||||
return "", "", false
|
||||
}
|
||||
tag, ok := values[0].(string)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
reason, ok := nativeReasonByTag[tag]
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return feedbackdomain.ValueDown, reason, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func nativeVariableKey(key, suffix string) bool {
|
||||
if !strings.HasPrefix(key, "node_") || !strings.HasSuffix(key, suffix) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSuffix(strings.TrimPrefix(key, "node_"), suffix) != ""
|
||||
}
|
||||
|
||||
func oneFeedbackAction(action string) (string, string, bool) {
|
||||
switch action {
|
||||
case ActionFeedbackUp:
|
||||
return feedbackdomain.ValueUp, feedbackdomain.ReasonEmpty, true
|
||||
case ActionFeedbackDown:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonEmpty, true
|
||||
case ActionReasonIncorrect:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonIncorrect, true
|
||||
case ActionReasonNotResolved:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonNotResolved, true
|
||||
case ActionReasonIrrelevant:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonIrrelevant, true
|
||||
case ActionReasonUnclear:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonUnclear, true
|
||||
case ActionReasonOutdated:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonOutdated, true
|
||||
case ActionReasonOther:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonOther, true
|
||||
case ActionReasonContentMismatch:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonContentMismatch, true
|
||||
case ActionReasonContentIncomplete:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonContentIncomplete, true
|
||||
case ActionReasonIllogical:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonIllogical, true
|
||||
case ActionReasonFactualError:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonFactualError, true
|
||||
case ActionReasonSensitiveContent:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonSensitiveContent, true
|
||||
case ActionReasonAnswerNotWanted:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonAnswerNotWanted, true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/utils"
|
||||
|
||||
feedbackdomain "gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
|
||||
)
|
||||
|
||||
type fakeFeedbackService struct {
|
||||
calls int
|
||||
command feedbackdomain.Command
|
||||
result feedbackdomain.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFeedbackService) Apply(_ context.Context, command feedbackdomain.Command) (feedbackdomain.Result, error) {
|
||||
f.calls++
|
||||
f.command = command
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func cardRequest(action string) *sdkcard.CardRequest {
|
||||
return &sdkcard.CardRequest{
|
||||
OutTrackId: "track-1",
|
||||
UserId: "user-1",
|
||||
CorpId: "must-not-be-stored",
|
||||
Content: "must-not-be-stored",
|
||||
Extension: "must-not-be-stored",
|
||||
CardActionData: sdkcard.PrivateCardActionData{CardPrivateData: sdkcard.CardPrivateData{
|
||||
ActionIdList: []string{action},
|
||||
Params: map[string]any{
|
||||
"tenant_key": "attacker", "turn_id": "attacker", "user_id": "attacker", "token": "attacker",
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsNativeLike(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueUp}}
|
||||
handler, err := NewCardFeedbackHandler(service)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := cardRequest(ActionNativeFeedback)
|
||||
request.CardActionData.CardPrivateData.Params = map[string]any{
|
||||
"variableKey": "node_ocmrjfmlsra_likeOrDislike",
|
||||
"variableValue": "like",
|
||||
}
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 || service.command.Value != feedbackdomain.ValueUp || service.command.ReasonCode != feedbackdomain.ReasonEmpty {
|
||||
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackSelected"] != feedbackdomain.ValueUp {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsNativeDislike(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueDown}}
|
||||
handler, err := NewCardFeedbackHandler(service)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := cardRequest(ActionNativeFeedback)
|
||||
request.CardActionData.CardPrivateData.Params = map[string]any{
|
||||
"variableKey": "node_ocmrjfmlsra_likeOrDislike",
|
||||
"variableValue": "dislike",
|
||||
}
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonEmpty {
|
||||
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if response == nil || response.UserPrivateData == nil ||
|
||||
response.UserPrivateData.CardParamMap["showFeedbackReasons"] != "false" ||
|
||||
response.UserPrivateData.CardParamMap["feedbackMessage"] != "感谢反馈,我们会继续改进" {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsNativeReasons(t *testing.T) {
|
||||
tests := []struct {
|
||||
tag string
|
||||
reason string
|
||||
}{
|
||||
{"1", feedbackdomain.ReasonContentMismatch},
|
||||
{"2", feedbackdomain.ReasonContentIncomplete},
|
||||
{"3", feedbackdomain.ReasonIllogical},
|
||||
{"4", feedbackdomain.ReasonFactualError},
|
||||
{"5", feedbackdomain.ReasonSensitiveContent},
|
||||
{"6", feedbackdomain.ReasonAnswerNotWanted},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.tag, func(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: tc.reason}}
|
||||
handler, err := NewCardFeedbackHandler(service)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := cardRequest(ActionNativeFeedback)
|
||||
request.CardActionData.CardPrivateData.Params = map[string]any{
|
||||
"variableKey": "node_ocmrjfmlsra_dislikeTags",
|
||||
"variableValue": []any{tc.tag},
|
||||
}
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != tc.reason {
|
||||
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackReasonSelected"] != tc.reason {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackRejectsMalformedNativeEvents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
actions []string
|
||||
params map[string]any
|
||||
}{
|
||||
{name: "empty node ID", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node__likeOrDislike", "variableValue": "like"}},
|
||||
{name: "unknown suffix", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_other", "variableValue": "like"}},
|
||||
{name: "unknown vote", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": "LIKE"}},
|
||||
{name: "wrong vote type", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": []any{"like"}}},
|
||||
{name: "empty tags", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{}}},
|
||||
{name: "multiple tags", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{"1", "2"}}},
|
||||
{name: "numeric tag", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{1}}},
|
||||
{name: "unknown tag", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{"7"}}},
|
||||
{name: "ambiguous actions", actions: []string{ActionNativeFeedback, ActionFeedbackUp}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": "like"}},
|
||||
{name: "nil params", actions: []string{ActionNativeFeedback}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
service := &fakeFeedbackService{}
|
||||
handler, _ := NewCardFeedbackHandler(service)
|
||||
request := cardRequest("unknown")
|
||||
request.CardActionData.CardPrivateData.ActionIdList = tc.actions
|
||||
request.CardActionData.CardPrivateData.Params = tc.params
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil || response == nil || service.calls != 0 {
|
||||
t.Fatalf("response=%+v error=%v calls=%d", response, err, service.calls)
|
||||
}
|
||||
if response.CardUpdateOptions != nil || response.UserPrivateData != nil {
|
||||
t.Fatalf("safe ACK must not update card: %+v", response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsCustomExactReasons(t *testing.T) {
|
||||
tests := []struct {
|
||||
action string
|
||||
reason string
|
||||
}{
|
||||
{ActionReasonContentMismatch, feedbackdomain.ReasonContentMismatch},
|
||||
{ActionReasonContentIncomplete, feedbackdomain.ReasonContentIncomplete},
|
||||
{ActionReasonIllogical, feedbackdomain.ReasonIllogical},
|
||||
{ActionReasonFactualError, feedbackdomain.ReasonFactualError},
|
||||
{ActionReasonSensitiveContent, feedbackdomain.ReasonSensitiveContent},
|
||||
{ActionReasonAnswerNotWanted, feedbackdomain.ReasonAnswerNotWanted},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.action, func(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: tc.reason}}
|
||||
handler, _ := NewCardFeedbackHandler(service)
|
||||
response, err := handler.HandleSDK(context.Background(), cardRequest(tc.action))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != tc.reason {
|
||||
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackReasonSelected"] != tc.reason {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsWhitelistedActions(t *testing.T) {
|
||||
tests := []struct {
|
||||
action string
|
||||
value string
|
||||
reason string
|
||||
showReasons string
|
||||
message string
|
||||
}{
|
||||
{ActionFeedbackUp, feedbackdomain.ValueUp, "", "false", "感谢反馈"},
|
||||
{ActionFeedbackDown, feedbackdomain.ValueDown, "", "false", "感谢反馈,我们会继续改进"},
|
||||
{ActionReasonIncorrect, feedbackdomain.ValueDown, feedbackdomain.ReasonIncorrect, "false", "感谢反馈,我们会继续改进"},
|
||||
{ActionReasonNotResolved, feedbackdomain.ValueDown, feedbackdomain.ReasonNotResolved, "false", "感谢反馈,我们会继续改进"},
|
||||
{ActionReasonIrrelevant, feedbackdomain.ValueDown, feedbackdomain.ReasonIrrelevant, "false", "感谢反馈,我们会继续改进"},
|
||||
{ActionReasonUnclear, feedbackdomain.ValueDown, feedbackdomain.ReasonUnclear, "false", "感谢反馈,我们会继续改进"},
|
||||
{ActionReasonOutdated, feedbackdomain.ValueDown, feedbackdomain.ReasonOutdated, "false", "感谢反馈,我们会继续改进"},
|
||||
{ActionReasonOther, feedbackdomain.ValueDown, feedbackdomain.ReasonOther, "false", "感谢反馈,我们会继续改进"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.action, func(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: tc.value, ReasonCode: tc.reason}}
|
||||
handler, err := NewCardFeedbackHandler(service)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := handler.HandleSDK(context.Background(), cardRequest(tc.action))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 {
|
||||
t.Fatalf("calls=%d", service.calls)
|
||||
}
|
||||
if service.command.ExternalActorID != "user-1" || service.command.OutTrackID != "track-1" || service.command.Value != tc.value || service.command.ReasonCode != tc.reason || service.command.Source != feedbackdomain.SourceDingTalkCard {
|
||||
t.Fatalf("command=%+v", service.command)
|
||||
}
|
||||
if response == nil || response.CardUpdateOptions == nil || !response.CardUpdateOptions.UpdatePrivateDataByKey || response.CardUpdateOptions.UpdateCardDataByKey || response.CardData != nil || response.UserPrivateData == nil {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
params := response.UserPrivateData.CardParamMap
|
||||
if params["feedbackSelected"] != tc.value || params["feedbackReasonSelected"] != tc.reason || params["showFeedbackReasons"] != tc.showReasons || params["feedbackMessage"] != tc.message {
|
||||
t.Fatalf("params=%+v", params)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackUnknownOrAmbiguousActionIsSafeAck(t *testing.T) {
|
||||
for _, actions := range [][]string{{"unknown"}, {ActionFeedbackUp, ActionFeedbackDown}, nil} {
|
||||
service := &fakeFeedbackService{}
|
||||
handler, _ := NewCardFeedbackHandler(service)
|
||||
request := cardRequest("unknown")
|
||||
request.CardActionData.CardPrivateData.ActionIdList = actions
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil || response == nil || service.calls != 0 {
|
||||
t.Fatalf("actions=%v response=%+v error=%v calls=%d", actions, response, err, service.calls)
|
||||
}
|
||||
if response.CardUpdateOptions != nil || response.UserPrivateData != nil {
|
||||
t.Fatalf("safe ACK must not update card: %+v", response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackValidationAndServiceError(t *testing.T) {
|
||||
if _, err := NewCardFeedbackHandler(nil); err == nil {
|
||||
t.Fatal("nil service must fail")
|
||||
}
|
||||
serviceErr := errors.New("store failed")
|
||||
service := &fakeFeedbackService{err: serviceErr}
|
||||
handler, _ := NewCardFeedbackHandler(service)
|
||||
if _, err := handler.HandleSDK(context.Background(), nil); err == nil {
|
||||
t.Fatal("nil request must fail")
|
||||
}
|
||||
response, err := handler.HandleSDK(context.Background(), cardRequest(ActionFeedbackUp))
|
||||
if !errors.Is(err, serviceErr) || response != nil || service.calls != 1 {
|
||||
t.Fatalf("response=%+v error=%v calls=%d", response, err, service.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamClientRegistersOptionalCardFeedbackRouter(t *testing.T) {
|
||||
config := StreamConfig{ClientID: "id", ClientSecret: "secret", TenantKey: "tenant"}
|
||||
chatHandler := mustTestHandler(t)
|
||||
feedbackHandler, _ := NewCardFeedbackHandler(&fakeFeedbackService{})
|
||||
client, err := NewStreamClient(config, chatHandler, feedbackHandler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic); err != nil {
|
||||
t.Fatalf("card callback router not registered: %v", err)
|
||||
}
|
||||
|
||||
withoutFeedback, err := NewStreamClient(config, chatHandler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := withoutFeedback.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic); err == nil {
|
||||
t.Fatal("card callback router must remain optional for old callers")
|
||||
}
|
||||
if _, err := NewStreamClient(config, chatHandler, nil); err == nil {
|
||||
t.Fatal("explicit nil feedback handler must fail")
|
||||
}
|
||||
if _, err := NewStreamClient(config, chatHandler, feedbackHandler, feedbackHandler); err == nil {
|
||||
t.Fatal("multiple feedback handlers must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCardRouterHandlesNativeSDKDataFrameEnvelope(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: feedbackdomain.ReasonContentMismatch}}
|
||||
feedbackHandler, _ := NewCardFeedbackHandler(service)
|
||||
client, err := NewStreamClient(
|
||||
StreamConfig{ClientID: "id", ClientSecret: "secret", TenantKey: "tenant"},
|
||||
mustTestHandler(t), feedbackHandler,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router, err := client.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actionData, _ := json.Marshal(map[string]any{"cardPrivateData": map[string]any{
|
||||
"actionIds": []string{ActionNativeFeedback},
|
||||
"params": map[string]any{
|
||||
"variableKey": "node_ocmrjfmlsra_dislikeTags",
|
||||
"variableValue": []string{"1"},
|
||||
},
|
||||
}})
|
||||
outer, _ := json.Marshal(map[string]any{
|
||||
"outTrackId": "track-native-frame", "userId": "user-native-frame", "content": string(actionData),
|
||||
"corpId": "must-not-be-used", "extension": "must-not-be-used",
|
||||
})
|
||||
frameResponse, err := router(context.Background(), &payload.DataFrame{Data: string(outer)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frameResponse == nil || frameResponse.Code != payload.DataFrameResponseStatusCodeKOK {
|
||||
t.Fatalf("frame response=%+v", frameResponse)
|
||||
}
|
||||
if service.calls != 1 || service.command.ExternalActorID != "user-native-frame" || service.command.OutTrackID != "track-native-frame" || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonContentMismatch {
|
||||
t.Fatalf("service calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCardRouterHandlesRealSDKDataFrameEnvelope(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueDown, ReasonCode: feedbackdomain.ReasonNotResolved}}
|
||||
feedbackHandler, _ := NewCardFeedbackHandler(service)
|
||||
client, err := NewStreamClient(
|
||||
StreamConfig{ClientID: "id", ClientSecret: "secret", TenantKey: "tenant"},
|
||||
mustTestHandler(t), feedbackHandler,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router, err := client.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actionData, _ := json.Marshal(map[string]any{"cardPrivateData": map[string]any{
|
||||
"actionIds": []string{ActionReasonNotResolved},
|
||||
"params": map[string]any{"tenant_key": "attacker", "token": "attacker"},
|
||||
}})
|
||||
outer, _ := json.Marshal(map[string]any{
|
||||
"outTrackId": "track-frame", "userId": "user-frame", "content": string(actionData),
|
||||
"corpId": "must-not-be-used", "extension": "must-not-be-used",
|
||||
})
|
||||
frameResponse, err := router(context.Background(), &payload.DataFrame{Data: string(outer)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frameResponse == nil || frameResponse.Code != payload.DataFrameResponseStatusCodeKOK {
|
||||
t.Fatalf("frame response=%+v", frameResponse)
|
||||
}
|
||||
if service.calls != 1 || service.command.ExternalActorID != "user-frame" || service.command.OutTrackID != "track-frame" || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonNotResolved {
|
||||
t.Fatalf("service calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if strings.Contains(frameResponse.Data, "attacker") || !strings.Contains(frameResponse.Data, "feedbackReasonSelected") {
|
||||
t.Fatalf("unsafe or incomplete frame response: %s", frameResponse.Data)
|
||||
}
|
||||
}
|
||||
@@ -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 = ©Client
|
||||
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)), ¶ms); 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)), ¶ms); 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
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"},
|
||||
}
|
||||
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"
|
||||
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user