Compare commits

...

65 Commits

Author SHA1 Message Date
姚章浩 9871760bc0 fix(deploy): make clone installer build dependencies reliably 2026-09-11 11:20:27 +08:00
姚章浩 f533c385ea feat(deploy): add clone-and-run docker installer 2026-09-11 11:12:28 +08:00
姚章浩 bcb2f303ff feat(deploy): bootstrap compose managed postgres stack 2026-09-11 10:51:45 +08:00
zheromiku a31156dc4e Merge pull request 'docs: 清理历史计划并说明仓库目录结构' (#13) from feat/cli-admin-bootstrap into main 2026-09-11 10:18:28 +08:00
姚章浩 f408096611 docs: document repository structure and remove stale plan 2026-09-11 10:17:03 +08:00
hermes-bot 218ab657d6 Merge pull request 'Feat/cli admin bootstrap' (#12) from feat/cli-admin-bootstrap into main
Reviewed-on: #12
2026-09-10 22:38:04 +08:00
姚章浩 5c5e4494cb test(migrations): cover non-knowledge response reason 2026-09-10 22:25:48 +08:00
姚章浩 e584b17929 docs(dobotai): add container deployment guide 2026-09-10 22:25:19 +08:00
姚章浩 8027635330 fix(coverage): skip alerts for non-knowledge text turns 2026-09-10 22:02:59 +08:00
姚章浩 563329320b docs(dobotai): guide container credential input 2026-09-10 18:31:55 +08:00
姚章浩 9abbb2cb2b feat(dobotai): containerize test runtime and admin bootstrap 2026-09-10 18:22:21 +08:00
hermes-bot 4c5b1e3b5a Merge pull request 'docs: 中文化文档与环境示例注释' (#11) from docs/chinese-documentation-r1 into main
Reviewed-on: #11
2026-09-10 17:04:31 +08:00
姚章浩 fa938ca1eb docs: translate documentation and environment comments to Chinese 2026-09-10 16:59:22 +08:00
zheromiku 7661e1b8f7 Merge pull request 'feat(dingtalk): merge tested multimodal, reasoning, and support workflows' (#10) from feat/think-mode-support-alerts into main 2026-09-10 16:40:42 +08:00
姚章浩 bfa1b1b25f feat(dingtalk): add reasoning mode and support evidence 2026-09-10 16:24:56 +08:00
姚章浩 0798a2dac7 feat(dingtalk): stabilize multimodal answer workflows 2026-09-09 13:57:43 +08:00
姚章浩 18c81232ce fix: keep only setting command entries 2026-08-14 12:35:33 +08:00
姚章浩 19ed4b50df feat: add guided administrator management 2026-08-14 12:12:33 +08:00
姚章浩 9f65c3eef0 docs: update AI table binding example 2026-08-14 11:46:37 +08:00
姚章浩 50379b3b87 fix: parse AI table iframe query links 2026-08-14 11:40:28 +08:00
姚章浩 3f9ff359b9 feat: accept trusted AI table link tails 2026-08-14 11:20:15 +08:00
姚章浩 a3defdd1c0 fix: report AI table binding validation failures 2026-08-14 11:11:07 +08:00
姚章浩 b4b68d8b21 fix: accept AI table link cards in settings 2026-08-14 10:46:51 +08:00
姚章浩 365f1b8c7d fix: detect missing-product coverage responses 2026-08-14 10:02:58 +08:00
姚章浩 725db721a8 fix: refresh table dispatchers after binding activation 2026-08-13 18:39:15 +08:00
姚章浩 5d68b22636 feat: add operational component status dashboard 2026-08-13 18:33:52 +08:00
姚章浩 6f24743be2 feat: verify and activate support group binding 2026-08-13 18:22:43 +08:00
姚章浩 4dbc20ee74 fix: validate card business responses and trim help 2026-08-13 15:10:09 +08:00
姚章浩 e5be9ee780 fix: stop enqueueing disabled external table exports 2026-08-13 14:49:44 +08:00
姚章浩 0f30f8cec8 fix: route help and repeated-slash settings locally 2026-08-13 14:39:56 +08:00
姚章浩 44d9398c78 fix: keep setup available without active DingTalk app 2026-08-13 13:55:25 +08:00
姚章浩 b207ee4089 feat: switch DingTalk applications with rollback 2026-08-13 13:36:26 +08:00
姚章浩 0fc64ba77d feat: validate and activate AI table bindings 2026-08-13 13:14:06 +08:00
姚章浩 a7432aed49 feat: wire settings admin into DobotAI runtime 2026-08-13 12:59:20 +08:00
姚章浩 5036071ac2 feat: keep table binding messages out of QA 2026-08-13 12:55:00 +08:00
姚章浩 2d9490f424 feat: apply per-user answer language 2026-08-13 12:50:07 +08:00
姚章浩 462ee1f3ff feat: add admin settings control flow 2026-08-13 12:45:08 +08:00
姚章浩 8d2d4b469b feat: add secure settings foundation 2026-08-13 12:38:01 +08:00
姚章浩 9363d10e44 feat: publish outbound answer images to private OSS 2026-08-13 12:34:39 +08:00
姚章浩 74c9af9944 fix: acknowledge DingTalk callbacks before async processing 2026-08-13 12:34:16 +08:00
姚章浩 5c35a49486 fix: recreate stale WeKnora sessions once 2026-08-13 12:34:05 +08:00
姚章浩 357e9bad5c docs: plan DobotAI settings administration 2026-08-13 12:33:55 +08:00
姚章浩 2c0ed7cc67 feat: expose runtime build version 2026-08-13 10:44:16 +08:00
姚章浩 aa0513eb59 feat: add FAQ uncovered workflow 2026-07-27 11:54:34 +08:00
姚章浩 e582d851a4 fix: wrap Notable record fields payload 2026-07-23 18:55:03 +08:00
姚章浩 0a3f62618e feat: sync DingTalk QA to Notable question table 2026-07-23 14:38:50 +08:00
姚章浩 5e0694835a feat: add DingTalk directory-backed session titles 2026-07-23 10:55:25 +08:00
姚章浩 39c5fbbaef fix: detect retrieval gaps with human escalation 2026-07-22 14:17:39 +08:00
姚章浩 d29b28be13 fix: detect unavailable authoritative knowledge answers 2026-07-22 13:21:59 +08:00
姚章浩 774ccd9454 feat: wire coverage support notifications 2026-07-22 12:48:30 +08:00
姚章浩 86593df57c feat: add durable support notification dispatcher 2026-07-22 12:36:33 +08:00
姚章浩 913a65b16d fix: persist coverage reference count 2026-07-22 12:19:53 +08:00
姚章浩 a6569ee4bf feat: add DingTalk support group sender 2026-07-22 12:17:04 +08:00
姚章浩 5b5865dc07 feat: add coverage decision persistence 2026-07-22 12:00:32 +08:00
zheromiku 4366c018f9 Merge pull request 'feat: add exact custom card feedback reasons' (#9) from feature/custom-card-feedback-actions into main 2026-07-21 21:23:15 +08:00
姚章浩 a952232cc9 feat: add exact custom card feedback reasons 2026-07-21 21:21:41 +08:00
zheromiku 4dc3f22784 Merge pull request 'feat: handle native DingTalk card feedback' (#8) from feature/native-card-feedback into main 2026-07-21 21:00:04 +08:00
姚章浩 b8f572cc84 feat: handle native DingTalk card feedback 2026-07-21 20:41:12 +08:00
zheromiku fcf4559c9a Merge pull request 'feat: enable per-user DingTalk card debugging' (#7) from feature/card-feedback-debug into main 2026-07-21 18:06:42 +08:00
姚章浩 9fa2eac6a4 feat: enable per-user DingTalk card debugging 2026-07-21 18:02:14 +08:00
zheromiku da69226481 Merge pull request 'feat: add DingTalk answer feedback workflow' (#6) from feature/answer-feedback into main 2026-07-21 16:00:17 +08:00
姚章浩 38560c5f41 feat: add DingTalk answer feedback workflow 2026-07-21 15:23:57 +08:00
zheromiku f0d3d5b50d Merge pull request 'feat: add tenant-scoped audit query API' (#5) from feature/audit-query-api into main 2026-07-21 14:11:37 +08:00
姚章浩 56e3bf4686 feat: add tenant-scoped audit query API 2026-07-21 13:57:31 +08:00
zheromiku 257a55a3f0 Merge pull request 'feat: close DingTalk delivery audit lifecycle' (#4) from feature/message-delivery-audit into main 2026-07-21 12:56:53 +08:00
229 changed files with 24970 additions and 391 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.env
.env.*
!.env.example
bin/
.acceptance-canary/
.hermes/
__pycache__/
*.pyc
+37 -9
View File
@@ -1,24 +1,52 @@
# DobotAI configuration
# DobotAI 基础配置
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
DOBOTAI_INGRESS_TOKEN=replace-me
# 可选的仅本机审计 API。必须使用不同于 DOBOTAI_INGRESS_TOKEN 的独立密钥。
# 设置后必须同时设置 DOBOTAI_TENANT_KEY,且监听地址必须仅为回环地址。
DOBOTAI_ADMIN_TOKEN=
DOBOTAI_GROUP_SESSION_POLICY=shared
DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"replace-with-agent-id"}
# `/think` 路由必填。上面的基础绑定保持稳定的会话身份;以下配置仅选择每个 Turn 的执行 Agent。
DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer
DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning
# Official WeKnora API only
# 仅使用 WeKnora 官方 API
WEKNORA_BASE_URL=http://127.0.0.1:8080
WEKNORA_API_KEY=replace-me
# DingTalk Stream mode (official SDK). Leave all three empty to disable the
# DingTalk channel; set all three to enable it. Never commit real values.
# 钉钉 Stream 模式(官方 SDK)。三项均留空时禁用钉钉渠道;三项同时设置时启用。不得提交真实值。
DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET=
# Tenant key this DingTalk ingress serves (matches tenant_key used by QA).
# 该钉钉接入服务的租户键(必须与 QA 使用的 tenant_key 一致)。
DOBOTAI_TENANT_KEY=
# Optional DingTalk AI card template ID. Leave empty to use markdown-only replies.
# When set, a successful QA answer will be delivered as an AI card; markdown
# is only sent as a fallback when card delivery fails. Never commit real values.
# 可选的钉钉 AI 卡片模板 ID。留空时仅使用 Markdown 回复。
# 设置后,成功的 QA 回答优先通过 AI 卡片发送;仅当卡片发送失败时才回退 Markdown。不得提交真实值。
DINGTALK_CARD_TEMPLATE_ID=
# 可选、临时的钉钉卡片调试白名单。设置后,只有该指定钉钉用户会在 per-user privateData 中收到 _CARD_DEBUG_TOOL_ENTRY=show。
# 非受控调试期间必须留空;不得提交真实用户 ID。
DINGTALK_CARD_DEBUG_USER_ID=
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
# 可选的钉钉 FAQ 表工作流,用于未覆盖问题。四项必须同时设置;四项均留空时禁用。
# 启用下方人工支持群通知时,此工作流为必需项。不得提交真实 ID。
DINGTALK_FAQ_TABLE_BASE_ID=
DINGTALK_FAQ_TABLE_SHEET_ID=
DINGTALK_FAQ_TABLE_VIEW_ID=
DINGTALK_FAQ_TABLE_OPERATOR_ID=
# 可选的钉钉卡片回答图片外发。任一项设置后,以下五项都必须设置。
# DobotAI 仅从 WEKNORA_BASE_URL 的 /files 鉴权下载 resource:// 图片,在 dobotai/im-images/ 下写入内容哈希副本,并生成有效期2小时的私有 OSS GET URL。
# Bucket 必须保持私有,并配置 OSS 生命周期规则,在1天后删除 dobotai/im-images/ 对象。不得提交真实凭据。
DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT=
DOBOTAI_OUTBOUND_IMAGE_OSS_REGION=
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID=
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET=
DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET=
# 可选的未覆盖回答人工支持群通知。该值是群的 openConversationId,不是 webhook。
# 留空时仍记录覆盖判定和待处理 Support Case,但不发送群通知。
# 启用后必须同时配置上方 FAQ 表工作流,使 ActionCard 能链接到准确的 FAQ 记录。不得提交真实群 ID。
DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID=
# 独立的 DobotAI PostgreSQL;不得指向 WeKnora 数据库
DATABASE_URL=postgres://dobotai:replace-me@127.0.0.1:5432/dobotai?sslmode=disable
+11
View File
@@ -5,6 +5,17 @@
*.local
coverage.out
# Local deployment, backup, and production diagnostic artifacts
/.deploy/
/backups/
/check-live-dobotai.py
# Transient local validation and legacy test-deployment artifacts
/dobotai
/.acceptance-canary/
/.hermes/
/deploy_stable_images_test.py
# Editors and OS
.idea/
.vscode/
+22
View File
@@ -0,0 +1,22 @@
FROM golang:1.26.5-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
ARG GOPROXY=https://goproxy.cn,direct
RUN GOPROXY=$GOPROXY go mod download
COPY . ./
ARG VERSION=dev
ARG COMMIT_SHA=unknown
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION} -X main.commitSHA=${COMMIT_SHA} -X main.buildTime=${BUILD_TIME}" \
-o /out/dobotai ./cmd/dobotai
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /out/dobotai /usr/local/bin/dobotai
USER 10001:10001
ENTRYPOINT ["/usr/local/bin/dobotai"]
+214 -50
View File
@@ -1,76 +1,240 @@
# 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.
## 目录结构
## Run
```text
.
├── cmd/
│ ├── dobotai/ 主程序入口、运行时装配与后台 Worker
│ └── directory-import/ 钉钉通讯录导入命令
├── internal/
│ ├── channels/dingtalk/ 钉钉 Stream、卡片、媒体、反馈与群通知
│ ├── qa/ 问答主流程、WeKnora 调用和检索信号记录
│ ├── weknora/ SSE、工具结果和引用解析
│ ├── coverage/ 覆盖判定、Support Case 和人工提醒
│ ├── reasoningmode/ /think 推理模式
│ ├── settings/ /setting 与首位管理员绑定
│ ├── outboundimage/ resource:// 图片私有 OSS 外发
│ ├── questiontable/ 问题表工作流
│ ├── faqtable/ FAQ 表工作流
│ ├── directory/ 钉钉通讯录查询与导入
│ ├── audit/ 审计查询
│ ├── httpapi/ /healthz、/version、/v1/qa 与审计 API
│ ├── core/ 核心领域类型
│ ├── session/ 会话键和会话标题
│ ├── agentbinding/ 渠道到 WeKnora Agent 的绑定
│ ├── feedback/ 点赞/点踩领域逻辑
│ ├── secretstore/ 本地密钥引用存储
│ ├── dingtalkconfig/ 钉钉凭据校验
│ ├── runtime/ 运行时管理
│ └── store/postgres/ PostgreSQL 持久化实现
├── db/migrations/ SQL 迁移及其契约测试
├── deploy/dobotai-dingtalk/ 容器 Compose、安装/配置/启动/绑定脚本和部署手册
├── docs/ 架构说明与 API 边界文档
├── scripts/ 本地部署与部署资产契约测试
├── Dockerfile DobotAI 容器镜像构建
├── .env.example 无真实凭据的配置示例
└── README.md 项目功能、边界和开发说明
```
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:
目录职责规则:`cmd/`只装配进程,`internal/`承载业务模块,`internal/store/postgres/`只实现数据库持久化,`db/migrations/`只放Schema变更,`deploy/`只放容器运行资产。历史计划、备份、镜像归档和真实环境文件均不进入Git。
## 部署方式
| 方式 | 适用场景 | 入口 | 数据库与迁移 | 推荐程度 |
| --- | --- | --- | --- | --- |
| 源码/原生二进制 | 本地开发、快速调试、临时问题定位 | `go run ./cmd/dobotai` 或受控构建的二进制 | 操作人员准备独立PostgreSQL并显式执行迁移 | 仅开发 |
| Docker Compose | 测试、预发布、生产候选 | 仓库根目录`install-docker.sh` | 脚本构建镜像、创建`dobotai-postgres`和`dobotai-postgres-data`,并按序执行迁移 | 推荐 |
### 源码/原生部署
```bash
# 开发机:使用Git外的环境文件注入真实配置
go run ./cmd/dobotai
curl http://127.0.0.1:8090/healthz
```
源码方式不自动创建数据库、不管理密钥目录、不自动执行SQL迁移,也不适合作为长期测试或生产运行方式。
### Docker Compose 部署
```bash
# 克隆后进入仓库根目录执行:自动安装Docker/Compose(如缺失)、构建镜像、
# 准备目录、独立PostgreSQL、命名卷、env模板和迁移。
sudo bash ./install-docker.sh
# 配置WeKnora和钉钉后自动启动应用
sudo dobotai-dingtalk-configure
# 仅首次且没有有效管理员时执行
sudo dobotai-dingtalk-bind-admin
```
完整的安装、现有数据库迁移、备份、回滚和验收说明见 [Docker 部署手册](deploy/dobotai-dingtalk/README.md)。
## 钉钉 Stream
使用官方 `dingtalk-stream-sdk-go`,不暴露公网回调入口。
- 群聊仅在机器人被 `@` 时处理;
- 私聊按用户保持长期会话;群聊默认按群共享会话;
- 回调 `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` 隔离。
## 首位管理员终端绑定
首位管理员不通过网页生成验证码。先确保钉钉 Stream 机器人已经正常运行,登录部署服务器后执行:
```bash
dobotai bootstrap-admin
```
若二进制未加入 `PATH`,使用其实际绝对路径执行。该命令只读取 `DATABASE_URL` 和 `DOBOTAI_TENANT_KEY`,不会启动 WeKnora、钉钉消费者或问答服务。
命令会在终端使用加密随机数生成一个有效期10分钟的六码绑定码,并保持等待。候选管理员必须私聊机器人发送:
```text
/setting <六码绑定码>
```
验证码正确后,终端自动将该钉钉用户绑定为 `super_admin` 并显示成功。已有有效管理员时命令会拒绝执行,防止通过该入口追加或替换管理员。DobotAI 不提供网页管理面;钉钉凭据和运行配置由部署环境文件管理。
## 覆盖与人工支持
- 有结构化检索引用的回答标记为已覆盖;
- 没有引用但已执行检索的回答标记为未覆盖;
- 没有引用且未执行检索的文本标记为 `covered_non_knowledge_response`,不创建 Support Case;
- 纯图片视觉识别单独记录为 `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
000021_coverage_non_knowledge_response_reason
```
迁移前应执行逻辑备份并验证恢复;迁移、二进制部署和钉钉渠道切换是独立步骤,不能把健康检查代替真实渠道验收。
## 本地运行
```bash
go run ./cmd/dobotai
```
Health check:
健康检查:
```bash
curl http://127.0.0.1:8090/healthz
```
Internal QA request:
运行版本:
```bash
curl -X POST http://127.0.0.1:8090/v1/qa \
-H "Authorization: Bearer $DOBOTAI_INGRESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"tenant_key":"default",
"channel":"dingtalk",
"conversation_type":"direct",
"external_user_id":"user-1",
"external_message_id":"message-1",
"query":"hello"
}'
curl http://127.0.0.1:8090/version
```
## Hard boundaries
部署脚本应将 `version`、`commit_sha` 和 `build_time` 写入二进制,并仅在 `/version` 返回预期提交时接受部署。候选源码必须干净,避免运行版本掩盖未提交改动。
- No WeKnora source modifications.
- No custom WeKnora tables or columns.
- No direct WeKnora database access.
- No private WeKnora package imports.
- All compatibility logic stays in `internal/weknora`.
- Business data stays in DobotAI PostgreSQL.
- Redis will be used only for queues, retries, idempotency leases, and temporary state.
## 安全要求
See `docs/architecture.md` for ownership and migration decisions.
- 不修改 WeKnora 源码、Schema 或私有包;
- 不直接连接 WeKnora 数据库;
- 不将凭据、Token、预签名 URL 或知识正文写入日志;
- 不接受任意外部图片 URL 作为可信媒体;
- 对钉钉图片、WeKnora `resource://` 图片和 OSS 返回体均执行来源、大小、类型和签名校验;
- 外发 OSS Bucket 保持私有;
- 生产、测试的钉钉应用、数据库、OSS Bucket 和凭据必须相互隔离;
- 生产上线必须使用已验收的同一构建产物,禁止在生产重新编译。
更多设计与边界见 `docs/architecture.md`。
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(args []string, out io.Writer) error {
flags := flag.NewFlagSet("directory-import", flag.ContinueOnError)
flags.SetOutput(io.Discard)
csvPath := flags.String("csv", "", "path to tested DingTalk directory CSV output")
tenant := flags.String("tenant", "", "tenant key")
databaseURLFile := flags.String("database-url-file", "", "file containing DATABASE_URL")
validateOnly := flags.Bool("validate-only", false, "validate CSV without connecting to PostgreSQL")
if err := flags.Parse(args); err != nil {
return errors.New("invalid directory import arguments")
}
if strings.TrimSpace(*csvPath) == "" || strings.TrimSpace(*tenant) == "" {
return errors.New("-csv and -tenant are required")
}
input, err := os.Open(*csvPath)
if err != nil {
return errors.New("open directory CSV failed")
}
defer input.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if *validateOnly {
count, importErr := directory.ImportCSV(ctx, *tenant, input, validationWriter{})
if importErr != nil {
return errors.New("directory CSV validation failed")
}
_, _ = fmt.Fprintf(out, "validated_profiles=%d\n", count)
return nil
}
if strings.TrimSpace(*databaseURLFile) == "" {
return errors.New("-database-url-file is required for import")
}
databaseURL, err := readSecretFile(*databaseURLFile)
if err != nil {
return err
}
store, err := postgres.Open(ctx, databaseURL)
if err != nil {
return errors.New("open directory database failed")
}
defer store.Close()
count, err := directory.ImportCSV(ctx, *tenant, input, store)
if err != nil {
return errors.New("directory CSV import failed")
}
_, _ = fmt.Fprintf(out, "imported_profiles=%d\n", count)
return nil
}
type validationWriter struct{}
func (validationWriter) UpsertDirectoryProfiles(context.Context, string, []directory.Profile) error {
return nil
}
func readSecretFile(path string) (string, error) {
raw, err := os.ReadFile(strings.TrimSpace(path))
if err != nil { return "", errors.New("read database URL file failed") }
if len(raw) > 16<<10 { return "", errors.New("database URL file is too large") }
value := strings.TrimSpace(string(raw))
if value == "" { return "", errors.New("database URL file is empty") }
return value, nil
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestReadSecretFileTrimsValue(t *testing.T) {
path := filepath.Join(t.TempDir(), "database_url")
if err := os.WriteFile(path, []byte(" postgres://secret-value \n"), 0o600); err != nil { t.Fatal(err) }
value, err := readSecretFile(path)
if err != nil { t.Fatalf("readSecretFile: %v", err) }
if value != "postgres://secret-value" { t.Fatalf("value=%q", value) }
}
func TestReadSecretFileErrorDoesNotLeakContents(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty")
if err := os.WriteFile(path, []byte(""), 0o600); err != nil { t.Fatal(err) }
_, err := readSecretFile(path)
if err == nil { t.Fatal("expected error") }
if strings.Contains(err.Error(), "secret-value") { t.Fatalf("error leaked secret: %v", err) }
}
func TestRunValidateOnlyDoesNotRequireDatabase(t *testing.T) {
csvPath := filepath.Join(t.TempDir(), "directory.csv")
if err := os.WriteFile(csvPath, []byte("user_id,union_id,name,nickname,title,department_ids,dept_name\nstaff-1,,张三,,工程师,100,技术支持部\n"), 0o600); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
err := run([]string{"-csv", csvPath, "-tenant", "tenant-a", "-validate-only"}, &out)
if err != nil {
t.Fatalf("run validate-only: %v", err)
}
if !strings.Contains(out.String(), "validated_profiles=1") {
t.Fatalf("output=%q", out.String())
}
}
func TestRunRequiresCSVAndDatabaseURLFileBeforeConnecting(t *testing.T) {
var out bytes.Buffer
for _, args := range [][]string{{}, {"-csv", "snapshot.csv"}} {
err := run(args, &out)
if err == nil { t.Fatalf("run(%v) expected error", args) }
}
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"context"
cryptorand "crypto/rand"
"errors"
"fmt"
"io"
"math/big"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
const bootstrapAdminTTL = 10 * time.Minute
type bootstrapAdminRepository interface {
AdministratorCount(context.Context, string) (int, error)
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
ConfirmBootstrap(context.Context, string) error
}
var createBootstrapChallenge = func(ctx context.Context, repo bootstrapAdminRepository, tenant, code string, expiry time.Time) error {
return repo.CreateBootstrapChallenge(ctx, tenant, code, expiry)
}
func runBootstrapAdminCommand(ctx context.Context, output io.Writer) error {
tenant := strings.TrimSpace(os.Getenv("DOBOTAI_TENANT_KEY"))
store, err := postgres.Open(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
return err
}
defer store.Close()
repository, err := postgres.NewSettingsRepository(store)
if err != nil {
return err
}
return runBootstrapAdmin(ctx, output, repository, tenant, cryptorand.Reader, time.Now, time.Second)
}
func runBootstrapAdmin(ctx context.Context, output io.Writer, repository bootstrapAdminRepository, tenant string, random io.Reader, now func() time.Time, pollInterval time.Duration) error {
if repository == nil || strings.TrimSpace(tenant) == "" || random == nil || now == nil || pollInterval <= 0 {
return errors.New("bootstrap administrator command requires repository, tenant, random source, clock, and poll interval")
}
count, err := repository.AdministratorCount(ctx, tenant)
if err != nil {
return fmt.Errorf("check active administrators: %w", err)
}
if count > 0 {
return errors.New("an active administrator already exists; use administrator management instead")
}
codeNumber, err := cryptorand.Int(random, big.NewInt(1_000_000))
if err != nil {
return fmt.Errorf("generate bootstrap code: %w", err)
}
code := fmt.Sprintf("%06d", codeNumber.Int64())
expiresAt := now().Add(bootstrapAdminTTL)
if err := createBootstrapChallenge(ctx, repository, tenant, code, expiresAt); err != nil {
return fmt.Errorf("create bootstrap challenge: %w", err)
}
fmt.Fprintf(output, "首位管理员绑定已开始。\n绑定码:%s\n请在%s前私聊钉钉机器人发送:/setting %s\n终端正在等待绑定完成;按 Ctrl+C 取消。\n", code, expiresAt.Local().Format("2006-01-02 15:04:05"), code)
waitFor := expiresAt.Sub(now())
if waitFor <= 0 {
return errors.New("bootstrap binding expiry must be in the future")
}
waitCtx, cancel := context.WithTimeout(ctx, waitFor)
defer cancel()
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
err := repository.ConfirmBootstrap(waitCtx, tenant)
if err == nil {
fmt.Fprintln(output, "首位管理员绑定成功:候选用户已设为 super_admin。")
return nil
}
if !errors.Is(err, settings.ErrBootstrapCandidatePending) {
return fmt.Errorf("confirm bootstrap candidate: %w", err)
}
select {
case <-waitCtx.Done():
return errors.New("bootstrap binding expired or cancelled before a candidate was verified")
case <-ticker.C:
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type bootstrapAdminRepoStub struct {
administratorCount int
createdCode string
createdExpiry time.Time
confirmCalls int
}
func (r *bootstrapAdminRepoStub) AdministratorCount(context.Context, string) (int, error) {
return r.administratorCount, nil
}
func (r *bootstrapAdminRepoStub) CreateBootstrapChallenge(_ context.Context, _ string, code string, expiry time.Time) error {
r.createdCode, r.createdExpiry = code, expiry
return nil
}
func (r *bootstrapAdminRepoStub) ConfirmBootstrap(context.Context, string) error {
r.confirmCalls++
if r.confirmCalls == 1 {
return settings.ErrBootstrapCandidatePending
}
return nil
}
func TestRunBootstrapAdminGeneratesCodeWaitsAndConfirmsCandidate(t *testing.T) {
now := time.Date(2026, time.September, 10, 8, 0, 0, 0, time.UTC)
repo := &bootstrapAdminRepoStub{}
var output bytes.Buffer
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := runBootstrapAdmin(ctx, &output, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), func() time.Time { return now }, time.Millisecond)
if err != nil {
t.Fatal(err)
}
if repo.createdCode != "000000" {
t.Fatalf("code=%q", repo.createdCode)
}
if !repo.createdExpiry.Equal(now.Add(10 * time.Minute)) {
t.Fatalf("expiry=%s", repo.createdExpiry)
}
if repo.confirmCalls < 2 {
t.Fatalf("confirm calls=%d", repo.confirmCalls)
}
for _, required := range []string{"/setting 000000", "绑定成功", "super_admin"} {
if !strings.Contains(output.String(), required) {
t.Fatalf("output=%q missing %q", output.String(), required)
}
}
}
func TestRunBootstrapAdminRefusesWhenAdministratorAlreadyExists(t *testing.T) {
repo := &bootstrapAdminRepoStub{administratorCount: 1}
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("err=%v", err)
}
if repo.createdCode != "" || repo.confirmCalls != 0 {
t.Fatalf("repo mutated: %+v", repo)
}
}
func TestRunBootstrapAdminPropagatesChallengeCreationFailure(t *testing.T) {
repo := &bootstrapAdminRepoStub{}
original := createBootstrapChallenge
createBootstrapChallenge = func(context.Context, bootstrapAdminRepository, string, string, time.Time) error {
return errors.New("database unavailable")
}
t.Cleanup(func() { createBootstrapChallenge = original })
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "database unavailable") {
t.Fatalf("err=%v", err)
}
}
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
)
func TestMainWiresDirectoryResolverIntoDingTalkHandler(t *testing.T) {
raw, err := os.ReadFile("main.go")
if err != nil {
t.Fatalf("read main.go: %v", err)
}
src := string(raw)
for _, required := range []string{
"directory.NewDingTalkLookup",
"directory.NewResolver",
} {
if !strings.Contains(src, required) {
t.Errorf("main.go missing %q", required)
}
}
if !regexp.MustCompile(`Directory:\s*directoryResolver`).MatchString(src) {
t.Error("main.go does not wire directoryResolver into HandlerOptions.Directory")
}
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"context"
"errors"
"log"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
type faqConfig struct {
BaseID string
SheetID string
ViewID string
OperatorID string
FieldMapping map[string]string
}
func readFAQConfig() (faqConfig, error) {
cfg := faqConfig{
BaseID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_BASE_ID")),
SheetID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_SHEET_ID")),
ViewID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_VIEW_ID")),
OperatorID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_OPERATOR_ID")),
}
blank := cfg.BaseID == "" && cfg.SheetID == "" && cfg.ViewID == "" && cfg.OperatorID == ""
complete := cfg.BaseID != "" && cfg.SheetID != "" && cfg.ViewID != "" && cfg.OperatorID != ""
if blank {
return faqConfig{}, nil
}
if !complete {
return faqConfig{}, errors.New("DingTalk FAQ table config must be complete or disabled")
}
return cfg, nil
}
func buildFAQDispatcher(repo faqtable.ExportRepository, clientID, clientSecret string, cfg faqConfig) (*faqtable.Dispatcher, error) {
if cfg.BaseID == "" {
return nil, nil
}
if repo == nil {
return nil, errors.New("FAQ dispatcher repository is required")
}
client, err := questiontable.NewClient(questiontable.Config{
ClientID: clientID, Secret: clientSecret, BaseID: cfg.BaseID,
SheetID: cfg.SheetID, OperatorID: cfg.OperatorID, FieldMapping: cfg.FieldMapping,
}, nil)
if err != nil {
return nil, err
}
return faqtable.NewDispatcher(repo, client, nil)
}
type faqPollerRunner interface {
RunOnce(context.Context) (faqtable.DispatcherStats, error)
}
func runFAQPoller(ctx context.Context, runner faqPollerRunner, interval time.Duration) {
if runner == nil || interval <= 0 {
return
}
run := func() {
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
stats, err := runner.RunOnce(runCtx)
cancel()
if err != nil {
log.Printf("FAQ table dispatch failed")
return
}
if stats.Claimed > 0 {
log.Printf("FAQ table dispatch: claimed=%d exported=%d failed=%d", stats.Claimed, stats.Exported, stats.Failed)
}
}
if ctx.Err() != nil {
return
}
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
type faqRepoStub struct{}
func (*faqRepoStub) Claim(context.Context, int, int, time.Time) ([]faqtable.PendingExport, error) {
return nil, nil
}
func (*faqRepoStub) MarkExported(context.Context, string, string) error { return nil }
func (*faqRepoStub) MarkFailed(context.Context, string, string) error { return nil }
func TestReadFAQConfigDisabledWhenAllBlank(t *testing.T) {
for _, key := range []string{"DINGTALK_FAQ_TABLE_BASE_ID", "DINGTALK_FAQ_TABLE_SHEET_ID", "DINGTALK_FAQ_TABLE_VIEW_ID", "DINGTALK_FAQ_TABLE_OPERATOR_ID"} {
t.Setenv(key, "")
}
cfg, err := readFAQConfig()
if err != nil || cfg.BaseID != "" {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
}
func TestReadFAQConfigRejectsPartialValues(t *testing.T) {
t.Setenv("DINGTALK_FAQ_TABLE_BASE_ID", "base")
t.Setenv("DINGTALK_FAQ_TABLE_SHEET_ID", "")
t.Setenv("DINGTALK_FAQ_TABLE_VIEW_ID", "view")
t.Setenv("DINGTALK_FAQ_TABLE_OPERATOR_ID", "operator")
if _, err := readFAQConfig(); err == nil {
t.Fatal("expected partial-config error")
}
}
func TestBuildFAQDispatcherRequiresRepositoryWhenEnabled(t *testing.T) {
cfg := faqConfig{BaseID: "base", SheetID: "sheet", ViewID: "view", OperatorID: "operator"}
if _, err := buildFAQDispatcher(nil, "client", "secret", cfg); err == nil {
t.Fatal("expected repository error")
}
if d, err := buildFAQDispatcher(&faqRepoStub{}, "client", "secret", cfg); err != nil || d == nil {
t.Fatalf("dispatcher=%v err=%v", d, err)
}
}
func TestMainWiresFAQExportBeforeSupportNotification(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
"readFAQConfig()", "postgres.NewFAQExportRepository(store)",
"buildFAQDispatcher(", "runFAQPoller(ctx, faqDispatcher, 5*time.Second)",
"faqtable.NewRecordLinkBuilder(", "buildSupportDispatcher(",
} {
if !strings.Contains(source, fragment) {
t.Errorf("main FAQ wiring missing %q", fragment)
}
}
if strings.Index(source, "runFAQPoller(ctx") > strings.Index(source, "runSupportDispatcher(ctx") {
t.Fatal("FAQ exporter must start before support notifications")
}
}
func TestEnvExampleDocumentsFAQWorkflowConfig(t *testing.T) {
data, err := os.ReadFile("../../.env.example")
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"DINGTALK_FAQ_TABLE_BASE_ID", "DINGTALK_FAQ_TABLE_SHEET_ID", "DINGTALK_FAQ_TABLE_VIEW_ID", "DINGTALK_FAQ_TABLE_OPERATOR_ID"} {
if !strings.Contains(string(data), key+"=") {
t.Errorf(".env.example missing %s", key)
}
}
}
+296 -11
View File
@@ -8,21 +8,49 @@ import (
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
"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()
if len(os.Args) == 2 && os.Args[1] == "bootstrap-admin" {
if err := runBootstrapAdminCommand(ctx, os.Stdout); err != nil {
log.Fatal(err)
}
return
}
var restartRequested atomic.Bool
storeCtx, storeCancel := context.WithTimeout(ctx, 10*time.Second)
defer storeCancel()
store, err := postgres.Open(storeCtx, os.Getenv("DATABASE_URL"))
@@ -30,6 +58,19 @@ 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) == "" {
log.Fatal("DingTalk credentials are required; configure DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET in the protected runtime environment")
}
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second)
if err != nil {
log.Fatal(err)
@@ -38,6 +79,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
@@ -45,25 +94,181 @@ 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)
}
api, err := httpapi.New(qaService, os.Getenv("DOBOTAI_INGRESS_TOKEN"))
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)
}
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)
@@ -74,22 +279,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)
}
@@ -97,13 +321,19 @@ 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)
}
address := strings.TrimSpace(os.Getenv("DOBOTAI_LISTEN_ADDR"))
if address == "" {
address = "127.0.0.1:8090"
close(runtimeReady)
rootHandler := http.NewServeMux()
rootHandler.Handle("/", api.Handler())
if outboundImages.handler != nil {
rootHandler.Handle(outboundImageRoute, outboundImages.handler)
}
server := &http.Server{Addr: address, Handler: api.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
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) {
@@ -117,4 +347,59 @@ 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) {
adminToken = strings.TrimSpace(adminToken)
if adminToken == "" {
return httpapi.Options{}, nil
}
if repo == nil {
return httpapi.Options{}, errors.New("audit repository is required when DOBOTAI_ADMIN_TOKEN is set")
}
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" {
return httpapi.Options{}, errors.New("DOBOTAI_TENANT_KEY is required when DOBOTAI_ADMIN_TOKEN is set")
}
if err := httpapi.ValidateLoopbackListenAddress(address); err != nil {
return httpapi.Options{}, err
}
service, err := audit.NewService(tenantKey, repo)
if err != nil {
return httpapi.Options{}, err
}
return httpapi.Options{AuditService: service, AdminToken: adminToken}, nil
}
+201
View File
@@ -0,0 +1,201 @@
package main
import (
"context"
"os"
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
)
type auditRepoStub struct {
tenant string
}
func (r *auditRepoStub) ListTurns(_ context.Context, tenant string, _ audit.ListQuery) ([]audit.TurnSummary, error) {
r.tenant = tenant
return nil, nil
}
func (r *auditRepoStub) GetTurn(context.Context, string, string) (audit.TurnDetail, error) {
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 {
t.Fatal(err)
}
if opts.AuditService != nil || opts.AdminToken != "" {
t.Fatalf("options=%+v", opts)
}
}
func TestBuildAuditOptionsRequiresTenantRepositoryAndLoopback(t *testing.T) {
repo := &auditRepoStub{}
for _, tc := range []struct {
name string
repo audit.TurnRepository
tenant string
address string
}{
{name: "missing repository", tenant: "tenant", address: "127.0.0.1:8090"},
{name: "missing tenant", repo: repo, address: "127.0.0.1:8090"},
{name: "public listener", repo: repo, tenant: "tenant", address: "0.0.0.0:8090"},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := buildAuditOptions(tc.repo, tc.tenant, "admin-secret", tc.address); err == nil {
t.Fatal("expected configuration error")
}
})
}
}
func TestBuildAuditOptionsFixesServerSideTenant(t *testing.T) {
repo := &auditRepoStub{}
opts, err := buildAuditOptions(repo, " tenant-a ", " admin-secret ", "127.0.0.1:8090")
if err != nil {
t.Fatal(err)
}
if opts.AdminToken != "admin-secret" || opts.AuditService == nil {
t.Fatalf("options=%+v", opts)
}
if _, err := opts.AuditService.ListTurns(context.Background(), audit.ListTurnsRequest{}); err != nil {
t.Fatal(err)
}
if repo.tenant != "tenant-a" {
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")
}
}
func TestMainDoesNotExposeWebAdministrationOrSetupOnlyMode(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, forbidden := range []string{"internal/adminweb", "adminweb.", `"/admin/"`, "runSetupOnly"} {
if strings.Contains(source, forbidden) {
t.Fatalf("main must not expose web administration: found %q", forbidden)
}
}
if !strings.Contains(source, "DingTalk credentials are required") {
t.Fatal("main must fail closed when DingTalk credentials are absent")
}
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage"
)
const (
outboundImageMaxCount = 5
outboundImageMaxBytes = 10 << 20
outboundImagePrefix = "dobotai/im-images"
outboundImageRoute = "/dingtalk/i/"
)
type outboundImageConfig struct {
WeKnoraBaseURL string
WeKnoraAPIKey string
Endpoint string
Region string
AccessKeyID string
AccessKeySecret string
Bucket string
PublicBaseURL string
}
type outboundImageRuntime struct {
rewriter dingtalk.OutboundContentRewriter
publisher *outboundimage.StableHashPublisher
handler http.Handler
}
func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, error) {
ossValues := []string{cfg.Endpoint, cfg.Region, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.Bucket, cfg.PublicBaseURL}
enabled := false
for _, value := range ossValues {
if strings.TrimSpace(value) != "" {
enabled = true
break
}
}
if !enabled {
return outboundImageRuntime{}, nil
}
for _, value := range append([]string{cfg.WeKnoraBaseURL, cfg.WeKnoraAPIKey}, ossValues...) {
if strings.TrimSpace(value) == "" {
return outboundImageRuntime{}, errors.New("outbound image: complete WeKnora, OSS, and public URL configuration is required")
}
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
fetcher, err := outboundimage.NewWeKnoraFetcher(
cfg.WeKnoraBaseURL,
cfg.WeKnoraAPIKey,
&http.Client{Timeout: 30 * time.Second, Transport: transport},
outboundImageMaxBytes,
)
if err != nil {
return outboundImageRuntime{}, err
}
store, err := outboundimage.NewAliyunOSSStore(
cfg.Endpoint,
cfg.Region,
cfg.AccessKeyID,
cfg.AccessKeySecret,
cfg.Bucket,
)
if err != nil {
return outboundImageRuntime{}, err
}
publisher, err := outboundimage.NewStableHashPublisher(store, outboundImagePrefix, cfg.PublicBaseURL)
if err != nil {
return outboundImageRuntime{}, err
}
handler, err := outboundimage.NewStableImageHandler(store, outboundImagePrefix, outboundImageRoute, outboundImageMaxBytes)
if err != nil {
return outboundImageRuntime{}, err
}
return outboundImageRuntime{
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount),
publisher: publisher,
handler: handler,
}, nil
}
func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundContentRewriter, error) {
runtime, err := buildOutboundImageRuntime(cfg)
return runtime.rewriter, err
}
+164
View File
@@ -0,0 +1,164 @@
package main
import (
"context"
"errors"
"log"
"os"
"reflect"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
// questionTablePollerRunner is the interface expected by the poller loop.
type questionTablePollerRunner interface {
RunOnce(context.Context) (questiontable.DispatcherStats, error)
}
// questionTableConfig holds the optional Notable question-table env values.
type questionTableConfig struct {
BaseID string
SheetID string
OperatorID string
FieldMapping map[string]string
}
// readQuestionTableConfig reads the three dedicated env vars and returns
// a validated config. All three blank => disabled (no error). Any partial
// configuration => stable configuration error (no values echoed).
func readQuestionTableConfig() (questionTableConfig, error) {
cfg := questionTableConfig{
BaseID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_BASE_ID")),
SheetID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_SHEET_ID")),
OperatorID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID")),
}
allBlank := cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == ""
allSet := cfg.BaseID != "" && cfg.SheetID != "" && cfg.OperatorID != ""
if allBlank {
return questionTableConfig{}, nil
}
if allSet {
return cfg, nil
}
return questionTableConfig{}, errors.New("DingTalk question table config: all three env vars must be set or all blank (DINGTALK_QUESTION_TABLE_BASE_ID, DINGTALK_QUESTION_TABLE_SHEET_ID, DINGTALK_QUESTION_TABLE_OPERATOR_ID)")
}
// buildQuestionTableDispatcher creates a validated question-table dispatcher
// when the repo is non-nil and all required credentials are present.
// The optional answer rewriter converts the stored answer into the same
// outbound form used for DingTalk delivery (resource images to stable HTTPS,
// document links, anchor cleanup). No HTTP request occurs at build time.
// It returns (nil, nil) when the question-table feature is disabled.
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig, rewriter questiontable.AnswerRewriter) (*questiontable.Dispatcher, error) {
// Disabled: all blank config.
if cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == "" {
return nil, nil
}
if repo == nil {
return nil, errors.New("question table dispatcher: repository is required")
}
client, err := questiontable.NewClient(questiontable.Config{
ClientID: clientID,
Secret: clientSecret,
BaseID: cfg.BaseID,
SheetID: cfg.SheetID,
OperatorID: cfg.OperatorID,
FieldMapping: cfg.FieldMapping,
}, nil)
if err != nil {
return nil, err
}
return questiontable.NewDispatcher(repo, client, &questiontable.DispatcherOptions{
Rewriter: rewriter,
SendTimeout: 60 * time.Second,
})
}
// answerRewriterAdapter adapts a blocking outbound content rewriter to the
// AnswerRewriter contract (caller never gets an error, so the original answer
// is always written when conversion cannot run).
type answerRewriterAdapter struct {
rewriter dingtalk.OutboundContentRewriter
}
func (a answerRewriterAdapter) Rewrite(ctx context.Context, content string) (string, error) {
if a.rewriter == nil {
return content, nil
}
return a.rewriter.Rewrite(ctx, content), nil
}
// runQuestionTablePoller runs the dispatcher immediately, then every interval.
// Non-positive interval or nil/typed-nil runner returns without calling.
// Continues after a RunOnce error. Exits promptly on context cancellation.
// Logging is safe: success may log only Claimed/Exported/Failed integers;
// errors log stable stage text only, never raw errors.
func runQuestionTablePoller(ctx context.Context, runner questionTablePollerRunner, interval time.Duration) {
if runner == nil || interval <= 0 {
return
}
// Guard against typed nil.
if isNilPoller(runner) {
return
}
runOnce := func() {
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
stats, err := runner.RunOnce(runCtx)
cancel()
if err != nil {
log.Printf("question table dispatch failed")
return
}
if stats.Claimed > 0 || stats.Exported > 0 || stats.Failed > 0 {
log.Printf("question table dispatch: claimed=%d exported=%d failed=%d", stats.Claimed, stats.Exported, stats.Failed)
}
}
if ctx.Err() != nil {
return
}
runOnce()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runOnce()
}
}
}
// isNilPoller returns true when runner is a typed nil.
func isNilPoller(runner questionTablePollerRunner) bool {
if runner == nil {
return true
}
// reflect-based typed-nil check for the interface.
return isInterfaceNil(runner)
}
// isInterfaceNil returns true when v is a typed nil wrapped in an interface.
func isInterfaceNil(v interface{}) bool {
if v == nil {
return true
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return rv.IsNil()
default:
return false
}
}
+377
View File
@@ -0,0 +1,377 @@
package main
import (
"context"
"errors"
"log"
"os"
"strings"
"sync"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
// ---------------------------------------------------------------------------
// Test 1: disabled all blank returns nil/no error
// ---------------------------------------------------------------------------
func TestReadQuestionTableConfigDisabledAllBlank(t *testing.T) {
t.Setenv("DINGTALK_QUESTION_TABLE_BASE_ID", "")
t.Setenv("DINGTALK_QUESTION_TABLE_SHEET_ID", "")
t.Setenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID", "")
cfg, err := readQuestionTableConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.BaseID != "" || cfg.SheetID != "" || cfg.OperatorID != "" {
t.Errorf("expected empty config, got %+v", cfg)
}
}
func TestBuildQuestionTableDispatcherDisabled(t *testing.T) {
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d != nil {
t.Errorf("expected nil dispatcher for disabled config, got %v", d)
}
}
// ---------------------------------------------------------------------------
// Test 2: every partial combination errors and does not echo sentinel config values
// ---------------------------------------------------------------------------
func TestReadQuestionTableConfigPartialErrors(t *testing.T) {
tests := []struct {
name string
baseID string
sheetID string
operator string
}{
{"only base", "base-1", "", ""},
{"only sheet", "", "sheet-1", ""},
{"only operator", "", "", "op-1"},
{"base+sheet", "base-1", "sheet-1", ""},
{"base+operator", "base-1", "", "op-1"},
{"sheet+operator", "", "sheet-1", "op-1"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DINGTALK_QUESTION_TABLE_BASE_ID", tc.baseID)
t.Setenv("DINGTALK_QUESTION_TABLE_SHEET_ID", tc.sheetID)
t.Setenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID", tc.operator)
_, err := readQuestionTableConfig()
if err == nil {
t.Fatal("expected error for partial config")
}
errStr := err.Error()
// Must not echo any sentinel config value.
for _, sentinel := range []string{"base-1", "sheet-1", "op-1"} {
if strings.Contains(errStr, sentinel) {
t.Errorf("error must not echo sentinel value %q: %q", sentinel, errStr)
}
}
})
}
}
// ---------------------------------------------------------------------------
// Test 3: enabled missing repo/client/secret fails closed; complete config builds
// ---------------------------------------------------------------------------
func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg, nil)
if err == nil {
t.Fatal("expected error when repo is nil")
}
if !strings.Contains(err.Error(), "repository is required") {
t.Errorf("error = %v, want 'repository is required'", err)
}
}
func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg, nil)
if err == nil {
t.Fatal("expected error when client id is empty")
}
}
func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg, nil)
if err == nil {
t.Fatal("expected error when secret is empty")
}
}
func TestBuildQuestionTableDispatcherEnabledCompleteConfig(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d == nil {
t.Fatal("expected non-nil dispatcher for complete config")
}
}
// ---------------------------------------------------------------------------
// Test 4: immediate run + periodic repeat + cancel stop
// ---------------------------------------------------------------------------
func TestRunQuestionTablePollerImmediateAndPeriodic(t *testing.T) {
runner := &countingPollerRunner{}
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
}()
// Wait for at least 2 calls (immediate + one tick).
time.Sleep(120 * time.Millisecond)
cancel()
wg.Wait()
runner.mu.Lock()
calls := runner.calls
runner.mu.Unlock()
if calls < 2 {
t.Errorf("expected at least 2 calls, got %d", calls)
}
}
func TestRunQuestionTablePollerCancelledContext(t *testing.T) {
runner := &countingPollerRunner{}
ctx, cancel := context.WithCancel(context.Background())
cancel() // already cancelled
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
runner.mu.Lock()
calls := runner.calls
runner.mu.Unlock()
if calls != 0 {
t.Errorf("expected 0 calls (cancelled context), got %d", calls)
}
}
// ---------------------------------------------------------------------------
// Test 5: continue after error and log/output security contract
// ---------------------------------------------------------------------------
func TestRunQuestionTablePollerContinuesAfterError(t *testing.T) {
runner := &errorThenSuccessPoller{}
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runQuestionTablePoller(ctx, runner, 30*time.Millisecond)
}()
time.Sleep(120 * time.Millisecond)
cancel()
wg.Wait()
runner.mu.Lock()
calls := runner.calls
runner.mu.Unlock()
if calls < 2 {
t.Errorf("expected at least 2 calls after error, got %d", calls)
}
}
// Test logging contract: the runOnce function in runQuestionTablePoller logs
// only stable stage text for errors (never raw errors via %v) and only
// Claimed/Exported/Failed integers for success.
func TestPollerLoggingContract(t *testing.T) {
// Behavioral log-capture test: runner returns an error containing unique
// sentinel values; the captured log must contain the stable stage text
// and none of those sentinels.
sentinel := "s3nt1n3l-op3r4t0r-t0k3n-q"
runner := &sentinelErrorPoller{errMsg: "questiontable dispatcher: " + sentinel}
ctx, cancel := context.WithCancel(context.Background())
// Save and restore global log output.
originalOutput := log.Writer()
originalFlags := log.Flags()
var buf strings.Builder
log.SetOutput(&buf)
log.SetFlags(0)
defer func() {
log.SetOutput(originalOutput)
log.SetFlags(originalFlags)
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
}()
time.Sleep(120 * time.Millisecond)
cancel()
wg.Wait()
output := buf.String()
if !strings.Contains(output, "question table dispatch failed") {
t.Errorf("log must contain stable stage text, got: %s", output)
}
if strings.Contains(output, sentinel) {
t.Errorf("log must not contain sentinel %q, got: %s", sentinel, output)
}
}
// sentinelErrorPoller always returns an error with the given message.
type sentinelErrorPoller struct {
errMsg string
}
func (r *sentinelErrorPoller) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
return questiontable.DispatcherStats{}, errors.New(r.errMsg)
}
// ---------------------------------------------------------------------------
// Test 6: nil/typed-nil runner and nonpositive interval safe
// ---------------------------------------------------------------------------
func TestRunQuestionTablePollerNilRunner(t *testing.T) {
ctx := context.Background()
// Should not panic, just return immediately.
runQuestionTablePoller(ctx, nil, 50*time.Millisecond)
// If we reach here without panic, test passes.
}
func TestRunQuestionTablePollerNonPositiveInterval(t *testing.T) {
runner := &countingPollerRunner{}
ctx := context.Background()
runQuestionTablePoller(ctx, runner, 0)
runQuestionTablePoller(ctx, runner, -1*time.Second)
runner.mu.Lock()
calls := runner.calls
runner.mu.Unlock()
if calls != 0 {
t.Errorf("expected 0 calls for non-positive interval, got %d", calls)
}
}
func TestRunQuestionTablePollerTypedNilInterface(t *testing.T) {
// The runner is nil through the interface but not at the concrete type level.
var typedNil *questiontable.Dispatcher = nil
var runner questionTablePollerRunner = typedNil
ctx := context.Background()
// Should not panic, just return immediately.
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
}
// ---------------------------------------------------------------------------
// Test 7: main source wiring contract
// ---------------------------------------------------------------------------
func TestMainWiresQuestionTableEnvNames(t *testing.T) {
mainData, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
syncData, err := os.ReadFile("question_table_sync.go")
if err != nil {
t.Fatal(err)
}
mainSource := string(mainData)
syncSource := string(syncData)
for _, fragment := range []string{
`"DINGTALK_QUESTION_TABLE_BASE_ID"`,
`"DINGTALK_QUESTION_TABLE_SHEET_ID"`,
`"DINGTALK_QUESTION_TABLE_OPERATOR_ID"`,
} {
if !strings.Contains(syncSource, fragment) {
t.Errorf("question table config reader missing %q", fragment)
}
}
for _, fragment := range []string{
`NewQuestionTableExportRepository`,
`5*time.Second`,
`"DingTalk question table dispatcher started"`,
} {
if !strings.Contains(mainSource, fragment) {
t.Errorf("main wiring missing %q", fragment)
}
}
shutdownAt := strings.Index(mainSource, "server.Shutdown(shutdownCtx)")
waitAt := strings.Index(mainSource, "case <-questionTableDone:")
if shutdownAt < 0 || waitAt < 0 || waitAt <= shutdownAt {
t.Fatal("main must wait for question table poller after HTTP shutdown and before returning")
}
}
// ---------------------------------------------------------------------------
// Test doubles
// ---------------------------------------------------------------------------
// questionTableExportRepoStub implements questiontable.ExportRepository.
type questionTableExportRepoStub struct{}
func (r *questionTableExportRepoStub) Claim(ctx context.Context, limit int, maxAttempts int, staleBefore time.Time) ([]questiontable.PendingExport, error) {
return nil, nil
}
func (r *questionTableExportRepoStub) MarkExported(ctx context.Context, exportID, location string) error {
return nil
}
func (r *questionTableExportRepoStub) MarkFailed(ctx context.Context, exportID, failureCode string) error {
return nil
}
// countingPollerRunner counts calls to RunOnce.
type countingPollerRunner struct {
mu sync.Mutex
calls int
}
func (r *countingPollerRunner) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
r.mu.Lock()
r.calls++
r.mu.Unlock()
return questiontable.DispatcherStats{}, nil
}
// errorThenSuccessPoller returns an error on the first call, then succeeds.
type errorThenSuccessPoller struct {
mu sync.Mutex
calls int
}
func (r *errorThenSuccessPoller) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
r.mu.Lock()
r.calls++
call := r.calls
r.mu.Unlock()
if call == 1 {
return questiontable.DispatcherStats{}, errors.New("questiontable dispatcher: claim failed")
}
return questiontable.DispatcherStats{Claimed: 1, Exported: 1}, nil
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"context"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
type dingTalkReasoningRouter struct {
service *reasoningmode.Service
}
func (r *dingTalkReasoningRouter) Prepare(ctx context.Context, message dingtalk.ReasoningMessage) (dingtalk.ReasoningDecision, error) {
decision, err := r.service.Prepare(ctx, reasoningmode.Scope{
TenantKey: message.TenantKey, Channel: message.Channel, ExternalActorID: message.StaffID,
}, message.Text)
if err != nil {
return dingtalk.ReasoningDecision{}, err
}
return dingtalk.ReasoningDecision{
Handled: decision.Handled, Reply: decision.Reply, Query: decision.Query,
AgentID: decision.AgentID, Execution: decision.Execution, Expired: decision.Expired,
}, nil
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"os"
"strings"
"testing"
)
func TestMainWiresReasoningModeRoutingAndCompletion(t *testing.T) {
mainSource, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
wiringSource, err := os.ReadFile("reasoning_wiring.go")
if err != nil {
t.Fatal(err)
}
mainText := string(mainSource)
for _, required := range []string{
`os.Getenv("DOBOTAI_QUICK_AGENT_ID")`,
`os.Getenv("DOBOTAI_REASONING_AGENT_ID")`,
`reasoningmode.NewService`,
`qa.WithReasoningModeFinalizer`,
`ReasoningRouter:`,
} {
if !strings.Contains(mainText, required) {
t.Errorf("main reasoning wiring missing %q", required)
}
}
for _, required := range []string{
"dingTalkReasoningRouter",
"ReasoningMessage",
"service.Prepare",
} {
if !strings.Contains(string(wiringSource), required) {
t.Errorf("reasoning adapter missing %q", required)
}
}
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"errors"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
type validatedSettingsRepository struct {
*postgres.SettingsRepository
clientID string
secret string
testSupport func(context.Context, settings.ResourceBinding) error
validateTable func(context.Context, settings.ResourceBinding) (map[string]string, error)
saveBinding func(context.Context, settings.ResourceBinding) error
restart func()
restartDelay time.Duration
}
func (r *validatedSettingsRepository) TestAndActivateSupportGroup(ctx context.Context, binding settings.ResourceBinding) error {
if r.testSupport != nil {
if err := r.testSupport(ctx, binding); err != nil {
return errors.New("support_group_test_failed")
}
} else {
sender, err := dingtalk.NewSupportGroupSender(r.clientID, r.secret, binding.ConversationID, nil)
if err != nil {
return errors.New("support_group_test_invalid_config")
}
if err = sender.SendTest(ctx); err != nil {
return errors.New("support_group_test_failed")
}
}
binding.ValidationCode = "support_test_ok"
binding.Activate = true
var err error
if r.saveBinding != nil {
err = r.saveBinding(ctx, binding)
} else if r.SettingsRepository != nil {
err = r.SettingsRepository.SaveBinding(ctx, binding)
} else {
err = errors.New("support_group_binding_repository_unavailable")
}
if err != nil {
return err
}
if r.restart != nil {
go func() {
if r.restartDelay > 0 {
time.Sleep(r.restartDelay)
}
r.restart()
}()
}
return nil
}
func (r *validatedSettingsRepository) SaveBinding(ctx context.Context, binding settings.ResourceBinding) error {
activate := false
switch binding.Kind {
case "question_table", "faq_table":
var mapping map[string]string
var err error
if r.validateTable != nil {
mapping, err = r.validateTable(ctx, binding)
} else {
mapping, err = r.readAndMapTable(ctx, binding)
}
if err != nil {
return err
}
binding.FieldMapping = mapping
binding.ValidationCode = "schema_ok"
binding.Activate = true
activate = true
case "support_group":
binding.ValidationCode = "pending_test_message"
binding.Activate = false
default:
return errors.New("unsupported_binding_kind")
}
var err error
if r.saveBinding != nil {
err = r.saveBinding(ctx, binding)
} else if r.SettingsRepository != nil {
err = r.SettingsRepository.SaveBinding(ctx, binding)
} else {
err = errors.New("binding_repository_unavailable")
}
if err != nil {
return err
}
if activate {
r.requestRestart()
}
return nil
}
func (r *validatedSettingsRepository) readAndMapTable(ctx context.Context, binding settings.ResourceBinding) (map[string]string, error) {
client, err := questiontable.NewClient(questiontable.Config{
ClientID: r.clientID, Secret: r.secret, BaseID: binding.BaseID,
SheetID: binding.SheetID, OperatorID: binding.OperatorID,
}, nil)
if err != nil {
return nil, errors.New("table_binding_invalid_config")
}
fields, err := client.ReadFields(ctx)
if err != nil {
return nil, err
}
required := questiontable.QuestionSchema()
if binding.Kind == "faq_table" {
required = questiontable.FAQSchema()
}
return questiontable.MapRequiredFields(fields, required)
}
func (r *validatedSettingsRepository) requestRestart() {
if r.restart == nil {
return
}
go func() {
if r.restartDelay > 0 {
time.Sleep(r.restartDelay)
}
r.restart()
}()
}
@@ -0,0 +1,117 @@
package main
import (
"context"
"errors"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
func TestTableBindingRestartsOnlyAfterActiveSave(t *testing.T) {
restarted := make(chan struct{}, 1)
var saved settings.ResourceBinding
repo := &validatedSettingsRepository{
validateTable: func(_ context.Context, binding settings.ResourceBinding) (map[string]string, error) {
return map[string]string{"answer": "field-answer"}, nil
},
saveBinding: func(_ context.Context, binding settings.ResourceBinding) error {
saved = binding
return nil
},
restart: func() { restarted <- struct{}{} },
restartDelay: 0,
}
if err := repo.SaveBinding(context.Background(), settings.ResourceBinding{Kind: "question_table", TenantKey: "tenant", BaseID: "base", SheetID: "sheet", OperatorID: "operator"}); err != nil {
t.Fatal(err)
}
if !saved.Activate || saved.ValidationCode != "schema_ok" || saved.FieldMapping["answer"] != "field-answer" {
t.Fatalf("binding=%+v", saved)
}
select {
case <-restarted:
case <-time.After(50 * time.Millisecond):
t.Fatal("restart not requested")
}
}
func TestTableBindingDoesNotRestartOnValidationOrSaveFailure(t *testing.T) {
for _, tc := range []struct {
name string
validateErr error
saveErr error
}{
{name: "validation failure", validateErr: errors.New("schema failed")},
{name: "save failure", saveErr: errors.New("save failed")},
} {
t.Run(tc.name, func(t *testing.T) {
restarted := make(chan struct{}, 1)
repo := &validatedSettingsRepository{
validateTable: func(context.Context, settings.ResourceBinding) (map[string]string, error) {
return map[string]string{"answer": "field"}, tc.validateErr
},
saveBinding: func(context.Context, settings.ResourceBinding) error { return tc.saveErr },
restart: func() { restarted <- struct{}{} },
}
if err := repo.SaveBinding(context.Background(), settings.ResourceBinding{Kind: "question_table"}); err == nil {
t.Fatal("expected failure")
}
select {
case <-restarted:
t.Fatal("must not restart after failed activation")
case <-time.After(50 * time.Millisecond):
}
})
}
}
func TestSupportGroupActivationRestartsOnlyAfterTestAndSaveSucceed(t *testing.T) {
for _, tc := range []struct {
name string
testErr error
saveErr error
wantSaved bool
wantRestart bool
}{
{name: "test fails", testErr: errors.New("send failed")},
{name: "save fails", saveErr: errors.New("save failed"), wantSaved: true},
{name: "success", wantSaved: true, wantRestart: true},
} {
t.Run(tc.name, func(t *testing.T) {
var saved settings.ResourceBinding
restarted := make(chan struct{}, 1)
repo := &validatedSettingsRepository{
testSupport: func(context.Context, settings.ResourceBinding) error { return tc.testErr },
saveBinding: func(_ context.Context, binding settings.ResourceBinding) error {
saved = binding
return tc.saveErr
},
restart: func() { restarted <- struct{}{} },
restartDelay: 0,
}
err := repo.TestAndActivateSupportGroup(context.Background(), settings.ResourceBinding{
Kind: "support_group", TenantKey: "tenant", ConversationID: "group",
})
if (err != nil) != (tc.testErr != nil || tc.saveErr != nil) {
t.Fatalf("err=%v", err)
}
if got := saved.Kind != ""; got != tc.wantSaved {
t.Fatalf("saved=%v want=%v", got, tc.wantSaved)
}
if tc.wantSaved && (saved.ValidationCode != "support_test_ok" || !saved.Activate) {
t.Fatalf("binding=%+v", saved)
}
select {
case <-restarted:
if !tc.wantRestart {
t.Fatal("unexpected restart")
}
case <-time.After(50 * time.Millisecond):
if tc.wantRestart {
t.Fatal("restart not requested")
}
}
})
}
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"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
clientID string
secret string
secretRef string
}
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
}
if _, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey); 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
}
}
validatedRepo := &validatedSettingsRepository{SettingsRepository: repo, clientID: clientID, secret: secret}
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), clientID: clientID, secret: secret, secretRef: secretRef}, nil
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"errors"
"log"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
type supportDispatcherRunner interface {
RunOnce(context.Context) (coverage.DispatcherStats, error)
}
func buildSupportDispatcher(repo coverage.NotificationRepository, clientID, clientSecret, openConversationID string, recordLinks *faqtable.RecordLinkBuilder) (*coverage.Dispatcher, error) {
openConversationID = strings.TrimSpace(openConversationID)
if openConversationID == "" {
return nil, nil
}
if repo == nil {
return nil, errors.New("support notification repository is required")
}
if recordLinks == nil {
return nil, nil
}
sender, err := dingtalk.NewSupportGroupSender(clientID, clientSecret, openConversationID, nil, dingtalk.WithFAQRecordLinkBuilder(recordLinks))
if err != nil {
return nil, err
}
return coverage.NewDispatcher(repo, sender)
}
func runSupportDispatcher(ctx context.Context, dispatcher supportDispatcherRunner, interval time.Duration) {
if dispatcher == nil || interval <= 0 {
return
}
runOnce := func() {
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
stats, err := dispatcher.RunOnce(runCtx)
cancel()
if err != nil {
log.Printf("support notification dispatch failed: %v", err)
return
}
if stats.Claimed > 0 {
log.Printf("support notification dispatch: claimed=%d notified=%d failed=%d", stats.Claimed, stats.Notified, stats.Failed)
}
}
runOnce()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runOnce()
}
}
}
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
type supportNotificationRepoStub struct{}
func (*supportNotificationRepoStub) Claim(context.Context, int, int, time.Time) ([]coverage.PendingSupportCase, error) {
return nil, nil
}
func (*supportNotificationRepoStub) MarkNotified(context.Context, string) error { return nil }
func (*supportNotificationRepoStub) MarkFailed(context.Context, string, string) error { return nil }
func testFAQRecordLinks(t *testing.T) *faqtable.RecordLinkBuilder {
t.Helper()
builder, err := faqtable.NewRecordLinkBuilder("base", "sheet", "view")
if err != nil {
t.Fatal(err)
}
return builder
}
type supportDispatcherStub struct {
mu sync.Mutex
calls int
errs []error
callCh chan int
}
func (s *supportDispatcherStub) RunOnce(context.Context) (coverage.DispatcherStats, error) {
s.mu.Lock()
s.calls++
call := s.calls
var err error
if call <= len(s.errs) {
err = s.errs[call-1]
}
s.mu.Unlock()
if s.callCh != nil {
select {
case s.callCh <- call:
default:
}
}
return coverage.DispatcherStats{Claimed: 1, Notified: 1}, err
}
func TestBuildSupportDispatcherDisabledWithoutGroup(t *testing.T) {
dispatcher, err := buildSupportDispatcher(nil, "", "", " ", nil)
if err != nil || dispatcher != nil {
t.Fatalf("dispatcher=%v err=%v, want disabled", dispatcher, err)
}
}
func TestBuildSupportDispatcherBlocksWithoutFAQLinks(t *testing.T) {
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, "client", "secret", "group", nil)
if err != nil || dispatcher != nil {
t.Fatalf("dispatcher=%v err=%v, want blocked without process failure", dispatcher, err)
}
}
func TestBuildSupportDispatcherEnabledFailsClosed(t *testing.T) {
repo := &supportNotificationRepoStub{}
links := testFAQRecordLinks(t)
for _, tc := range []struct {
name string
repo coverage.NotificationRepository
clientID, secret, gid string
links *faqtable.RecordLinkBuilder
}{
{name: "missing repo", clientID: "client", secret: "secret", gid: "group", links: links},
{name: "missing client", repo: repo, secret: "secret", gid: "group", links: links},
{name: "missing secret", repo: repo, clientID: "client", gid: "group", links: links},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := buildSupportDispatcher(tc.repo, tc.clientID, tc.secret, tc.gid, tc.links); err == nil {
t.Fatal("expected configuration error")
}
})
}
}
func TestBuildSupportDispatcherValidDoesNotSend(t *testing.T) {
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, " client ", " secret ", " group ", testFAQRecordLinks(t))
if err != nil {
t.Fatalf("buildSupportDispatcher: %v", err)
}
if dispatcher == nil {
t.Fatal("expected dispatcher")
}
}
func TestRunSupportDispatcherRunsImmediatelyAndStops(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
stub := &supportDispatcherStub{callCh: make(chan int, 4)}
done := make(chan struct{})
go func() {
runSupportDispatcher(ctx, stub, time.Hour)
close(done)
}()
select {
case <-stub.callCh:
case <-time.After(500 * time.Millisecond):
t.Fatal("dispatcher did not run immediately")
}
cancel()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("dispatcher did not stop after context cancellation")
}
}
func TestRunSupportDispatcherContinuesAfterError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stub := &supportDispatcherStub{
errs: []error{errors.New("temporary db error")},
callCh: make(chan int, 4),
}
done := make(chan struct{})
go func() {
runSupportDispatcher(ctx, stub, 10*time.Millisecond)
close(done)
}()
for want := 1; want <= 2; want++ {
select {
case got := <-stub.callCh:
if got != want {
t.Fatalf("call=%d, want %d", got, want)
}
case <-time.After(500 * time.Millisecond):
t.Fatalf("missing dispatcher call %d", want)
}
}
cancel()
<-done
}
func TestRunSupportDispatcherRejectsNonPositiveInterval(t *testing.T) {
stub := &supportDispatcherStub{}
runSupportDispatcher(context.Background(), stub, 0)
stub.mu.Lock()
defer stub.mu.Unlock()
if stub.calls != 0 {
t.Fatalf("calls=%d, want 0", stub.calls)
}
}
func TestMainWiresCoverageSupportNotifications(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID")`,
`coverage.NewService(store)`,
`qa.WithSucceededTurnObserver`,
`case <-runtimeReady:`,
`runSupportDispatcher(ctx, supportDispatcher, 5*time.Second)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main support notification wiring missing %q", fragment)
}
}
}
@@ -0,0 +1,7 @@
-- 000005_answer_feedback down migration
-- Drop in reverse order: indexes first, then table.
DROP INDEX IF EXISTS idx_messages_card_provider_unique;
DROP INDEX IF EXISTS idx_answer_feedback_tenant_reason_updated;
DROP INDEX IF EXISTS idx_answer_feedback_tenant_value_updated;
DROP TABLE IF EXISTS answer_feedback;
@@ -0,0 +1,32 @@
-- 000005_answer_feedback: user feedback on answers via DingTalk card
-- Each (tenant_key, channel, answer_message_id, actor_id) holds the current
-- feedback state: up or down with optional reason_code for down.
-- Depends on: 000004_delivery_audit (and its 000003 message-audit baseline).
CREATE TABLE IF NOT EXISTS answer_feedback (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
answer_message_id TEXT NOT NULL REFERENCES messages(id),
turn_id TEXT NOT NULL REFERENCES turns(id),
actor_id TEXT NOT NULL REFERENCES channel_actors(id),
value TEXT NOT NULL CHECK (value IN ('up', 'down')),
reason_code TEXT NOT NULL DEFAULT '' CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other')),
source TEXT NOT NULL CHECK (source IN ('dingtalk_card')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, answer_message_id, actor_id),
CHECK (value != 'up' OR reason_code = '')
);
-- Queries that filter by value or reason_code within a tenant use these indexes.
CREATE INDEX IF NOT EXISTS idx_answer_feedback_tenant_value_updated
ON answer_feedback (tenant_key, value, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_answer_feedback_tenant_reason_updated
ON answer_feedback (tenant_key, reason_code, updated_at DESC);
-- A DingTalk outTrackId must identify exactly one delivered card answer.
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_card_provider_unique
ON messages (tenant_key, channel, provider_message_id)
WHERE provider_message_id != '' AND delivery_method = 'card';
@@ -0,0 +1,296 @@
// Package migrations_test contains source-level contract tests for the
// 000005_answer_feedback migration. No real database connection is used.
package migrations_test
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func read005UpMigration(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000005_answer_feedback.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
return string(data)
}
func read005DownMigration(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000005_answer_feedback.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
return string(data)
}
// --- RED: These tests will fail until the migration files exist ---
// TestUp005CreatesAnswerFeedbackTable ensures the table is created with IF NOT EXISTS.
func TestUp005CreatesAnswerFeedbackTable(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "CREATE TABLE IF NOT EXISTS ANSWER_FEEDBACK") {
t.Error("up migration must create answer_feedback table with IF NOT EXISTS")
}
}
// TestUp005AnswerFeedbackColumns ensures all required columns are present.
func TestUp005AnswerFeedbackColumns(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
required := []string{
"ID TEXT PRIMARY KEY",
"TENANT_KEY TEXT NOT NULL",
"CHANNEL TEXT NOT NULL",
"ANSWER_MESSAGE_ID TEXT NOT NULL",
"TURN_ID TEXT NOT NULL",
"ACTOR_ID TEXT NOT NULL",
"VALUE TEXT NOT NULL",
"REASON_CODE TEXT NOT NULL DEFAULT ''",
"SOURCE TEXT NOT NULL",
"CREATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW()",
"UPDATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW()",
}
for _, col := range required {
if !strings.Contains(upper, col) {
t.Errorf("up migration must contain column %q", col)
}
}
}
// TestUp005ValueCheck ensures value is constrained to 'up' or 'down'.
func TestUp005ValueCheck(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "VALUE IN ('UP'") || !strings.Contains(upper, "'DOWN'") {
t.Error("value must be CHECK constrained to 'up' and 'down'")
}
}
// TestUp005ReasonCodeCheck ensures reason_code is constrained to allowed values.
func TestUp005ReasonCodeCheck(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
allowed := []string{"''", "INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"}
for _, r := range allowed {
if !strings.Contains(upper, r) {
t.Errorf("reason_code CHECK must include %q", r)
}
}
}
// TestUp005SourceCheck ensures source is constrained to 'dingtalk_card'.
func TestUp005SourceCheck(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "DINGTALK_CARD") {
t.Error("source must be CHECK constrained to 'dingtalk_card'")
}
}
// TestUp005UpValueMustHaveNoReason ensures CHECK that value='up' implies reason_code=”.
func TestUp005UpValueMustHaveNoReason(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Must have a CHECK that when value='up', reason_code must be empty
hasCheck := strings.Contains(upper, "VALUE = 'UP'") && strings.Contains(upper, "REASON_CODE = ''")
// Also check for the inverse: "VALUE != 'UP' OR REASON_CODE = ''"
hasOr := strings.Contains(upper, "OR") && strings.Contains(upper, "REASON_CODE = ''")
if !hasCheck && !hasOr {
t.Error("must have CHECK ensuring value='up' implies reason_code=''")
}
}
// TestUp005ForeignKeyConstraints ensures FK constraints on answer_message_id, turn_id, actor_id.
func TestUp005ForeignKeyConstraints(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Must reference messages(id), turns(id), channel_actors(id)
if !strings.Contains(upper, "REFERENCES MESSAGES(ID)") {
t.Error("answer_message_id must have FK REFERENCES messages(id)")
}
if !strings.Contains(upper, "REFERENCES TURNS(ID)") {
t.Error("turn_id must have FK REFERENCES turns(id)")
}
if !strings.Contains(upper, "REFERENCES CHANNEL_ACTORS(ID)") {
t.Error("actor_id must have FK REFERENCES channel_actors(id)")
}
}
// TestUp005UniqueConstraint ensures UNIQUE on (tenant_key, channel, answer_message_id, actor_id).
func TestUp005UniqueConstraint(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
hasUnique := strings.Contains(upper, "UNIQUE") &&
strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "CHANNEL") &&
strings.Contains(upper, "ANSWER_MESSAGE_ID") &&
strings.Contains(upper, "ACTOR_ID")
if !hasUnique {
t.Error("must have UNIQUE constraint on (tenant_key, channel, answer_message_id, actor_id)")
}
}
// TestUp005Indexes ensures required indexes are created.
func TestUp005Indexes(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Index on (tenant_key, value, updated_at DESC)
hasValueIdx := strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "VALUE") &&
strings.Contains(upper, "UPDATED_AT") &&
strings.Contains(upper, "DESC")
if !hasValueIdx {
t.Error("must have index on (tenant_key, value, updated_at DESC)")
}
// Index on (tenant_key, reason_code, updated_at DESC)
hasReasonIdx := strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "REASON_CODE") &&
strings.Contains(upper, "UPDATED_AT") &&
strings.Contains(upper, "DESC")
if !hasReasonIdx {
t.Error("must have index on (tenant_key, reason_code, updated_at DESC)")
}
// Must use CREATE INDEX IF NOT EXISTS
if got := countMatchesRe(up, `(?i)CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS`); got < 2 {
t.Errorf("expected at least 2 CREATE INDEX IF NOT EXISTS, got %d", got)
}
}
// TestUp005UpMigrationUsesIfNotExists ensures idempotency.
func TestUp005UpMigrationUsesIfNotExists(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if strings.Contains(upper, "CREATE TABLE") && !strings.Contains(upper, "CREATE TABLE IF NOT EXISTS") {
t.Error("CREATE TABLE must use IF NOT EXISTS for idempotency")
}
// Must have exactly 1 CREATE TABLE IF NOT EXISTS
if got := countMatchesRe(up, `(?i)CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS`); got != 1 {
t.Errorf("expected 1 CREATE TABLE IF NOT EXISTS, got %d", got)
}
}
func TestUp005CreatesUniqueCardProviderIndex(t *testing.T) {
up := strings.ToUpper(read005UpMigration(t))
for _, fragment := range []string{
"CREATE UNIQUE INDEX IF NOT EXISTS IDX_MESSAGES_CARD_PROVIDER_UNIQUE",
"ON MESSAGES (TENANT_KEY, CHANNEL, PROVIDER_MESSAGE_ID)",
"PROVIDER_MESSAGE_ID != ''",
"DELIVERY_METHOD = 'CARD'",
} {
if !strings.Contains(up, fragment) {
t.Errorf("provider binding index missing %q", fragment)
}
}
down := strings.ToUpper(read005DownMigration(t))
if !strings.Contains(down, "DROP INDEX IF EXISTS IDX_MESSAGES_CARD_PROVIDER_UNIQUE") {
t.Error("down migration must remove the B2 provider binding index")
}
}
// TestUp005NoForbiddenColumns ensures no secrets/protocol columns.
func TestUp005NoForbiddenColumns(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
forbidden := []string{
"WEBHOOK", "TOKEN", "RAW_CALLBACK", "RAW_PAYLOAD",
"CONTENT", "CALLBACK", "EXTENSION", "CORP_ID",
"SESSIONWEBHOOK", "DOWNLOAD_CODE", "DATAURI", "DATA_URI",
"RAW_RESPONSE", "RAW_ERROR", "PROVIDER_RESPONSE", "API_RESPONSE",
}
for _, col := range forbidden {
if strings.Contains(upper, col) {
t.Errorf("migration must not contain forbidden column %q", col)
}
}
}
// TestUp005DoesNotModifyBaselineTables ensures migration only touches answer_feedback.
func TestUp005DoesNotModifyBaselineTables(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Must not ALTER other tables
baseline := []string{"MESSAGES", "TURNS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS", "SESSION_MAPPINGS"}
for _, tbl := range baseline {
if strings.Contains(upper, "ALTER TABLE "+tbl) {
t.Errorf("migration must not ALTER TABLE %s", tbl)
}
}
}
// TestDown005DropsAnswerFeedbackTable ensures down drops only the new table and indexes.
func TestDown005DropsAnswerFeedbackTable(t *testing.T) {
down := read005DownMigration(t)
upper := strings.ToUpper(down)
if !strings.Contains(upper, "DROP TABLE IF EXISTS ANSWER_FEEDBACK") {
t.Error("down migration must drop answer_feedback with IF EXISTS")
}
}
// TestDown005DoesNotDropBaselineTables ensures down does not touch B1 tables.
func TestDown005DoesNotDropBaselineTables(t *testing.T) {
down := read005DownMigration(t)
upper := strings.ToUpper(down)
baseline := []string{"MESSAGES", "TURNS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS", "SESSION_MAPPINGS"}
for _, tbl := range baseline {
if strings.Contains(upper, "DROP TABLE IF EXISTS "+tbl) {
t.Errorf("down migration must not drop baseline table %s", tbl)
}
}
}
// TestDown005DoesNotDropBaselineColumns ensures down does not drop baseline columns.
func TestDown005DoesNotDropBaselineColumns(t *testing.T) {
down := read005DownMigration(t)
upper := strings.ToUpper(down)
if strings.Contains(upper, "DROP COLUMN") || strings.Contains(upper, "ALTER TABLE") {
t.Error("down migration must not drop/alter columns — only drop the answer_feedback table and indexes")
}
}
// TestUp005NoReservedWords ensures no bare reserved words as column names.
func TestUp005NoReservedWords(t *testing.T) {
up := read005UpMigration(t)
// Check for bare "references" as column name (not in FK context)
bareRefs := regexp.MustCompile(`(?im)^\s*REFERENCES\s+`)
if bareRefs.MatchString(up) {
lines := strings.Split(up, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
upperLine := strings.ToUpper(trimmed)
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "FOREIGN KEY") {
t.Errorf("reserved word 'references' used as column name: %s", trimmed)
}
}
}
}
// --- Helpers ---
func countMatchesRe(haystack, pattern string) int {
return len(regexp.MustCompile(pattern).FindAllString(haystack, -1))
}
@@ -0,0 +1,29 @@
-- 000006_native_card_feedback_reasons down migration
-- A schema rollback cannot retain values rejected by the legacy constraint,
-- so map each native reason to its closest B2 legacy category first.
UPDATE answer_feedback
SET reason_code = CASE reason_code
WHEN 'content_mismatch' THEN 'irrelevant'
WHEN 'content_incomplete' THEN 'not_resolved'
WHEN 'illogical_expression' THEN 'unclear'
WHEN 'factual_error' THEN 'incorrect'
WHEN 'sensitive_content' THEN 'other'
WHEN 'answer_not_wanted' THEN 'not_resolved'
ELSE reason_code
END
WHERE reason_code IN (
'content_mismatch',
'content_incomplete',
'illogical_expression',
'factual_error',
'sensitive_content',
'answer_not_wanted'
);
ALTER TABLE answer_feedback
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
ALTER TABLE answer_feedback
ADD CONSTRAINT answer_feedback_reason_code_check
CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other'));
@@ -0,0 +1,24 @@
-- 000006_native_card_feedback_reasons up migration
-- Preserve the original B2 reasons and add the six exact reason codes emitted
-- by DingTalk's built-in AI feedback component.
ALTER TABLE answer_feedback
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
ALTER TABLE answer_feedback
ADD CONSTRAINT answer_feedback_reason_code_check
CHECK (reason_code IN (
'',
'incorrect',
'not_resolved',
'irrelevant',
'unclear',
'outdated',
'other',
'content_mismatch',
'content_incomplete',
'illogical_expression',
'factual_error',
'sensitive_content',
'answer_not_wanted'
));
@@ -0,0 +1,78 @@
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read006Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000006_native_card_feedback_reasons."+direction+".sql"))
if err != nil {
t.Fatalf("read 000006 %s migration: %v", direction, err)
}
return strings.Join(strings.Fields(strings.ToUpper(string(data))), " ")
}
func TestUp006ExpandsNamedReasonConstraint(t *testing.T) {
up := read006Migration(t, "up")
for _, fragment := range []string{
"ALTER TABLE ANSWER_FEEDBACK DROP CONSTRAINT IF EXISTS ANSWER_FEEDBACK_REASON_CODE_CHECK",
"ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK",
"CHECK (REASON_CODE IN",
"CONTENT_MISMATCH",
"CONTENT_INCOMPLETE",
"ILLOGICAL_EXPRESSION",
"FACTUAL_ERROR",
"SENSITIVE_CONTENT",
"ANSWER_NOT_WANTED",
} {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
for _, legacy := range []string{"INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"} {
if !strings.Contains(up, legacy) {
t.Errorf("up migration must preserve legacy reason %q", legacy)
}
}
}
func TestUp006OnlyChangesReasonConstraint(t *testing.T) {
up := read006Migration(t, "up")
for _, forbidden := range []string{"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM", "UPDATE ANSWER_FEEDBACK"} {
if strings.Contains(up, forbidden) {
t.Errorf("up migration contains forbidden operation %q", forbidden)
}
}
}
func TestDown006MapsNativeReasonsBeforeRestoringConstraint(t *testing.T) {
down := read006Migration(t, "down")
mappings := map[string]string{
"CONTENT_MISMATCH": "IRRELEVANT",
"CONTENT_INCOMPLETE": "NOT_RESOLVED",
"ILLOGICAL_EXPRESSION": "UNCLEAR",
"FACTUAL_ERROR": "INCORRECT",
"SENSITIVE_CONTENT": "OTHER",
"ANSWER_NOT_WANTED": "NOT_RESOLVED",
}
updateAt := strings.Index(down, "UPDATE ANSWER_FEEDBACK")
constraintAt := strings.Index(down, "ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK")
if updateAt < 0 || constraintAt < 0 || updateAt > constraintAt {
t.Fatal("down migration must map native reasons before restoring the legacy constraint")
}
for from, to := range mappings {
if !strings.Contains(down, "WHEN '"+from+"' THEN '"+to+"'") {
t.Errorf("down migration missing mapping %s -> %s", from, to)
}
}
for _, native := range []string{"CONTENT_MISMATCH", "CONTENT_INCOMPLETE", "ILLOGICAL_EXPRESSION", "FACTUAL_ERROR", "SENSITIVE_CONTENT", "ANSWER_NOT_WANTED"} {
constraint := down[constraintAt:]
if strings.Contains(constraint, native) {
t.Errorf("legacy down constraint must exclude native reason %q", native)
}
}
}
@@ -0,0 +1,5 @@
-- 000007_coverage_decisions down migration
-- Drop support_cases first (child table), then coverage_decisions (parent table).
DROP TABLE IF EXISTS support_cases;
DROP TABLE IF EXISTS coverage_decisions;
@@ -0,0 +1,49 @@
-- 000007_coverage_decisions up migration
-- Creates coverage_decisions and support_cases tables for B3.1 persistence.
CREATE TABLE IF NOT EXISTS coverage_decisions (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
evaluator_version TEXT NOT NULL DEFAULT 'v1',
verdict TEXT NOT NULL CHECK (verdict IN ('covered', 'uncovered')),
reason_code TEXT NOT NULL CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer'
)),
reference_count INTEGER NOT NULL DEFAULT 0 CHECK (reference_count >= 0),
top_score DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_tenant_channel
ON coverage_decisions (tenant_key, channel, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_verdict
ON coverage_decisions (verdict) WHERE verdict = 'uncovered';
CREATE TABLE IF NOT EXISTS support_cases (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
coverage_decision_id TEXT NOT NULL REFERENCES coverage_decisions(id),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'notifying', 'notification_failed', 'notified', 'resolved')),
notification_attempts INTEGER NOT NULL DEFAULT 0 CHECK (notification_attempts >= 0),
last_notification_failure_code TEXT NOT NULL DEFAULT '',
last_notification_at TIMESTAMPTZ,
notified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
CREATE INDEX IF NOT EXISTS idx_support_cases_notification_pending
ON support_cases (status, created_at, id) WHERE status IN ('pending', 'notification_failed');
CREATE INDEX IF NOT EXISTS idx_support_cases_notifying_stale
ON support_cases (status, last_notification_at) WHERE status = 'notifying';
@@ -0,0 +1,179 @@
// Package migrations_test contains source-level contract tests for the
// 000007_coverage_decisions migration. No real database connection is used.
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read007Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000007_coverage_decisions."+direction+".sql"))
if err != nil {
t.Fatalf("read 000007 %s migration: %v", direction, err)
}
return string(data)
}
func TestUp007CreatesCoverageDecisionsTable(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
required := []string{
"CREATE TABLE IF NOT EXISTS COVERAGE_DECISIONS",
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
"EVALUATOR_VERSION", "VERDICT", "REASON_CODE", "REFERENCE_COUNT", "TOP_SCORE",
"CREATED_AT", "UPDATED_AT",
}
for _, fragment := range required {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
}
func TestUp007CoverageDecisionsConstraints(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
// REFERENCES turns(id)
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
t.Error("coverage_decisions.turn_id must reference turns(id)")
}
// UNIQUE (tenant_key, channel, turn_id)
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
t.Error("coverage_decisions must have UNIQUE (tenant_key, channel, turn_id)")
}
// verdict CHECK
if !strings.Contains(up, "COVERED") || !strings.Contains(up, "UNCOVERED") {
t.Error("verdict CHECK must include 'covered' and 'uncovered'")
}
// reason_code CHECK
for _, code := range []string{"COVERED_HAS_REFERENCES", "UNCOVERED_NO_REFERENCES", "UNCOVERED_EXPLICIT_NO_ANSWER"} {
if !strings.Contains(up, code) {
t.Errorf("reason_code CHECK missing %q", code)
}
}
}
func TestUp007CreatesSupportCasesTable(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
required := []string{
"CREATE TABLE IF NOT EXISTS SUPPORT_CASES",
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
"COVERAGE_DECISION_ID", "STATUS", "CREATED_AT", "UPDATED_AT",
}
for _, fragment := range required {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
// Notification fields must be present
notificationFields := []string{
"NOTIFICATION_ATTEMPTS", "LAST_NOTIFICATION_FAILURE_CODE",
"LAST_NOTIFICATION_AT", "NOTIFIED_AT",
}
for _, fragment := range notificationFields {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing notification field %q", fragment)
}
}
}
func TestUp007SupportCasesConstraints(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
// FK to coverage_decisions
if !strings.Contains(up, "REFERENCES COVERAGE_DECISIONS(ID)") {
t.Error("support_cases.coverage_decision_id must reference coverage_decisions(id)")
}
// FK to turns
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
t.Error("support_cases.turn_id must reference turns(id)")
}
// UNIQUE (tenant_key, channel, turn_id)
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
t.Error("support_cases must have UNIQUE (tenant_key, channel, turn_id)")
}
// status CHECK
for _, status := range []string{"PENDING", "NOTIFYING", "NOTIFICATION_FAILED", "NOTIFIED", "RESOLVED"} {
if !strings.Contains(up, status) {
t.Errorf("status CHECK must include %q", status)
}
}
}
func TestUp007CreatesQueryIndexes(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
// Pending/retry query index and stale notifying index
if !strings.Contains(up, "IDX_SUPPORT_CASES_NOTIFICATION_PENDING") ||
!strings.Contains(up, "WHERE STATUS IN ('PENDING', 'NOTIFICATION_FAILED')") {
t.Error("up migration must index pending and notification_failed support_cases")
}
if !strings.Contains(up, "IDX_SUPPORT_CASES_NOTIFYING_STALE") ||
!strings.Contains(up, "WHERE STATUS = 'NOTIFYING'") {
t.Error("up migration must index stale notifying support_cases")
}
// Coverage decisions listing index
if !strings.Contains(up, "IDX_COVERAGE_DECISIONS_TENANT_CHANNEL") {
t.Error("up migration must create index on coverage_decisions (tenant_key, channel, created_at DESC)")
}
// Uncovered verdict index
if !strings.Contains(up, "WHERE VERDICT = 'UNCOVERED'") {
t.Error("up migration must create partial index on uncovered coverage_decisions")
}
}
func TestUp007CoverageNoReservedWords(t *testing.T) {
up := read007Migration(t, "up")
// Check for bare "references" as column name in CREATE TABLE statements
lines := strings.Split(up, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
upperLine := strings.ToUpper(trimmed)
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "REFERENCES TURNS") && !strings.Contains(upperLine, "REFERENCES COVERAGE") {
t.Errorf("reserved word 'references' used as column name in: %s", trimmed)
}
}
}
func TestUp007NoForbiddenOperations(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
for _, forbidden := range []string{
"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM",
"ALTER TABLE", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
"RAW_RESPONSE", "RAW_ERROR",
} {
if strings.Contains(up, forbidden) {
t.Errorf("up migration contains forbidden operation %q", forbidden)
}
}
}
func TestUp007NoQuestionAnswerBodies(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
for _, forbidden := range []string{"QUESTION", "ANSWER_TEXT", "REFERENCE_CONTENT", "ANSWER_CONTENT"} {
if strings.Contains(up, forbidden) {
t.Errorf("coverage/support tables must not duplicate question/answer/reference bodies: found %q", forbidden)
}
}
}
func TestDown007DropsInSafeOrder(t *testing.T) {
down := strings.ToUpper(read007Migration(t, "down"))
supportAt := strings.Index(down, "DROP TABLE IF EXISTS SUPPORT_CASES")
coverageAt := strings.Index(down, "DROP TABLE IF EXISTS COVERAGE_DECISIONS")
if supportAt < 0 || coverageAt < 0 {
t.Fatal("down migration must drop both tables")
}
if supportAt > coverageAt {
t.Fatal("down migration must drop support_cases (child) before coverage_decisions (parent)")
}
}
func TestDown007DoesNotDropBaselineTables(t *testing.T) {
down := strings.ToUpper(read007Migration(t, "down"))
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
t.Errorf("down migration must not drop baseline table %q", table)
}
}
}
@@ -0,0 +1,2 @@
ALTER TABLE session_mappings
DROP COLUMN IF EXISTS display_title;
@@ -0,0 +1,2 @@
ALTER TABLE session_mappings
ADD COLUMN IF NOT EXISTS display_title TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,28 @@
package migrations
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSessionDisplayTitleMigration(t *testing.T) {
up, err := os.ReadFile(filepath.Join("000008_session_display_title.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
down, err := os.ReadFile(filepath.Join("000008_session_display_title.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
upper := strings.ToUpper(string(up))
for _, required := range []string{"ALTER TABLE SESSION_MAPPINGS", "ADD COLUMN IF NOT EXISTS DISPLAY_TITLE", "TEXT NOT NULL", "DEFAULT ''"} {
if !strings.Contains(upper, required) {
t.Errorf("up migration missing %q", required)
}
}
if !strings.Contains(strings.ToUpper(string(down)), "DROP COLUMN IF EXISTS DISPLAY_TITLE") {
t.Error("down migration must drop display_title")
}
}
@@ -0,0 +1,7 @@
ALTER TABLE messages
DROP COLUMN IF EXISTS region_snapshot;
ALTER TABLE messages
DROP COLUMN IF EXISTS department_snapshot;
DROP TABLE IF EXISTS directory_profiles;
@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS directory_profiles (
tenant_key TEXT NOT NULL,
staff_id TEXT NOT NULL,
union_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
nickname TEXT NOT NULL DEFAULT '',
job_title TEXT NOT NULL DEFAULT '',
department_ids JSONB NOT NULL DEFAULT '[]'::JSONB,
department_name TEXT NOT NULL DEFAULT '',
main_department_name TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
employment_status TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL CHECK (source IN ('full_sync','on_demand')),
synced_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_key, staff_id)
);
CREATE INDEX IF NOT EXISTS idx_directory_profiles_tenant_name
ON directory_profiles (tenant_key, name);
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS department_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS region_snapshot TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,42 @@
package migrations
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDirectoryProfilesMigration(t *testing.T) {
up, err := os.ReadFile(filepath.Join("000009_directory_profiles.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
down, err := os.ReadFile(filepath.Join("000009_directory_profiles.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
upper := strings.ToUpper(string(up))
for _, required := range []string{
"CREATE TABLE IF NOT EXISTS DIRECTORY_PROFILES",
"PRIMARY KEY (TENANT_KEY, STAFF_ID)",
"DEPARTMENT_IDS JSONB",
"REGION TEXT",
"SOURCE TEXT",
"ADD COLUMN IF NOT EXISTS DEPARTMENT_SNAPSHOT",
"ADD COLUMN IF NOT EXISTS REGION_SNAPSHOT",
} {
if !strings.Contains(upper, required) {
t.Errorf("up migration missing %q", required)
}
}
for _, forbidden := range []string{"MOBILE", "EMAIL", "CLIENT_SECRET", "ACCESS_TOKEN", "RAW_PAYLOAD"} {
if strings.Contains(upper, forbidden) {
t.Errorf("up migration contains forbidden sensitive field %q", forbidden)
}
}
downUpper := strings.ToUpper(string(down))
if !strings.Contains(downUpper, "DROP TABLE IF EXISTS DIRECTORY_PROFILES") || !strings.Contains(downUpper, "DROP COLUMN IF EXISTS REGION_SNAPSHOT") {
t.Error("down migration must remove directory table and snapshot columns")
}
}
@@ -0,0 +1,9 @@
-- 000010_question_table_exports down migration
-- Drop in reverse dependency order: first drop export table, then remove column
DROP TABLE IF EXISTS question_table_exports;
ALTER TABLE messages
DROP COLUMN IF EXISTS trigger_person_snapshot;
ALTER TABLE messages
DROP COLUMN IF EXISTS actor_union_id_snapshot;
@@ -0,0 +1,40 @@
-- 000010_question_table_exports: durable outbox for Notable question table async sync
-- Add trigger_person_snapshot and actor_union_id_snapshot to messages and create question_table_exports table
-- 1. Add trigger_person_snapshot and actor_union_id_snapshot columns to messages
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS trigger_person_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS actor_union_id_snapshot TEXT NOT NULL DEFAULT '';
-- 2. Create question_table_exports outbox table
CREATE TABLE IF NOT EXISTS question_table_exports (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'exporting', 'exported', 'export_failed')),
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TIMESTAMPTZ,
next_attempt_at TIMESTAMPTZ,
exported_at TIMESTAMPTZ,
failure_code TEXT NOT NULL DEFAULT '',
external_record_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
-- 3. Index for claim query: pending/export_failed/exporting ordered by created_at
-- Covers pending, export_failed (when next_attempt_at is NULL or <= NOW()),
-- and stale exporting (when last_attempt_at < staleBefore).
CREATE INDEX IF NOT EXISTS idx_question_table_exports_claim
ON question_table_exports (status, next_attempt_at, created_at, id)
WHERE status IN ('pending', 'export_failed')
OR (status = 'exporting' AND last_attempt_at IS NOT NULL);
-- 4. Index for stale exporting recovery
CREATE INDEX IF NOT EXISTS idx_question_table_exports_stale
ON question_table_exports (status, last_attempt_at)
WHERE status = 'exporting';
@@ -0,0 +1,161 @@
// Package migrations_test contains source-level contract tests for the
// 000010_question_table_exports migration. No real database connection is used.
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read010Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000010_question_table_exports."+direction+".sql"))
if err != nil {
t.Fatalf("read 000010 %s migration: %v", direction, err)
}
return string(data)
}
func TestUp010AddsTriggerPersonSnapshot(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "ALTER TABLE MESSAGES") {
t.Fatal("up migration must alter messages")
}
if !strings.Contains(up, "ADD COLUMN IF NOT EXISTS TRIGGER_PERSON_SNAPSHOT") {
t.Error("up migration must add trigger_person_snapshot column")
}
if !strings.Contains(up, "ADD COLUMN IF NOT EXISTS ACTOR_UNION_ID_SNAPSHOT") {
t.Error("up migration must add actor_union_id_snapshot column")
}
if !strings.Contains(up, "TEXT NOT NULL DEFAULT ''") {
t.Error("snapshot columns must be TEXT NOT NULL DEFAULT ''")
}
}
func TestUp010CreatesQuestionTableExports(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
required := []string{
"CREATE TABLE IF NOT EXISTS QUESTION_TABLE_EXPORTS",
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
"STATUS", "ATTEMPTS", "LAST_ATTEMPT_AT", "NEXT_ATTEMPT_AT", "EXPORTED_AT",
"FAILURE_CODE", "EXTERNAL_RECORD_ID",
"CREATED_AT", "UPDATED_AT",
}
for _, fragment := range required {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
}
func TestUp010QuestionTableExportsConstraints(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
// FK to turns
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
t.Error("question_table_exports.turn_id must reference turns(id)")
}
// UNIQUE (tenant_key, channel, turn_id)
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
t.Error("question_table_exports must have UNIQUE (tenant_key, channel, turn_id)")
}
// status CHECK
for _, status := range []string{"PENDING", "EXPORTING", "EXPORTED", "EXPORT_FAILED"} {
if !strings.Contains(up, status) {
t.Errorf("status CHECK must include %q", status)
}
}
}
func TestUp010ClaimIndexes(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "IDX_QUESTION_TABLE_EXPORTS_CLAIM") {
t.Error("up migration must create claim index")
}
if !strings.Contains(up, "WHERE STATUS IN ('PENDING', 'EXPORT_FAILED')") {
t.Error("claim index must include pending and export_failed statuses")
}
if !strings.Contains(up, "IDX_QUESTION_TABLE_EXPORTS_STALE") {
t.Error("up migration must create stale exporting index")
}
if !strings.Contains(up, "WHERE STATUS = 'EXPORTING'") {
t.Error("stale index must filter exporting status")
}
}
func TestUp010HasNextAttemptAtColumn(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "NEXT_ATTEMPT_AT TIMESTAMPTZ") {
t.Error("question_table_exports must have next_attempt_at column for retry backoff")
}
}
func TestUp010HasExternalRecordID(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "EXTERNAL_RECORD_ID TEXT NOT NULL DEFAULT ''") {
t.Error("question_table_exports must have external_record_id column (not a1_notation)")
}
if strings.Contains(up, "A1_NOTATION") {
t.Error("question_table_exports must not have stale a1_notation column")
}
}
func TestUp010ClaimIndexCoversNextAttemptAt(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "NEXT_ATTEMPT_AT") {
t.Error("claim index must cover next_attempt_at for retry backoff filtering")
}
}
func TestDown010DropsInSafeOrder(t *testing.T) {
down := strings.ToUpper(read010Migration(t, "down"))
dropExportAt := strings.Index(down, "DROP TABLE IF EXISTS QUESTION_TABLE_EXPORTS")
dropTriggerAt := strings.Index(down, "DROP COLUMN IF EXISTS TRIGGER_PERSON_SNAPSHOT")
dropUnionAt := strings.Index(down, "DROP COLUMN IF EXISTS ACTOR_UNION_ID_SNAPSHOT")
if dropExportAt < 0 || dropTriggerAt < 0 || dropUnionAt < 0 {
t.Fatal("down migration must drop export table and both snapshot columns")
}
if dropExportAt > dropTriggerAt || dropTriggerAt > dropUnionAt {
t.Fatal("down migration must drop export table before snapshot columns")
}
}
func TestDown010DoesNotDropBaselineTables(t *testing.T) {
down := strings.ToUpper(read010Migration(t, "down"))
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
t.Errorf("down migration must not drop baseline table %q", table)
}
}
}
func TestUp010ExportsNoFormulaColumns(t *testing.T) {
up := read010Migration(t, "up")
// Check that the CREATE TABLE body does not contain user/AI content columns
lines := strings.Split(up, "\n")
inTable := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
upper := strings.ToUpper(trimmed)
if strings.HasPrefix(upper, "CREATE TABLE IF NOT EXISTS QUESTION_TABLE_EXPORTS") {
inTable = true
continue
}
if inTable && trimmed == ");" {
inTable = false
continue
}
if !inTable {
continue
}
for _, forbidden := range []string{
"QUESTION", "ANSWER_TEXT", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
"RAW_RESPONSE", "RAW_ERROR", "ACCESS_TOKEN", "APP_SECRET",
} {
if strings.Contains(upper, forbidden) {
t.Errorf("question_table_exports column must not contain %q", forbidden)
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
DROP INDEX IF EXISTS idx_support_cases_faq_export_stale;
DROP INDEX IF EXISTS idx_support_cases_faq_export_claim;
ALTER TABLE support_cases
DROP COLUMN IF EXISTS faq_record_id,
DROP COLUMN IF EXISTS faq_failure_code,
DROP COLUMN IF EXISTS faq_exported_at,
DROP COLUMN IF EXISTS faq_next_attempt_at,
DROP COLUMN IF EXISTS faq_last_attempt_at,
DROP COLUMN IF EXISTS faq_export_attempts,
DROP COLUMN IF EXISTS faq_export_status;
+24
View File
@@ -0,0 +1,24 @@
-- Durable FAQ export state for uncovered support cases.
ALTER TABLE support_cases
ADD COLUMN IF NOT EXISTS faq_export_status TEXT NOT NULL DEFAULT 'not_applicable'
CHECK (faq_export_status IN ('not_applicable', 'pending', 'exporting', 'exported', 'export_failed')),
ADD COLUMN IF NOT EXISTS faq_export_attempts INTEGER NOT NULL DEFAULT 0
CHECK (faq_export_attempts >= 0),
ADD COLUMN IF NOT EXISTS faq_last_attempt_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS faq_next_attempt_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS faq_exported_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS faq_failure_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS faq_record_id TEXT NOT NULL DEFAULT '';
-- Existing cases are intentionally not backfilled. Only cases created after
-- this migration enter the FAQ workflow automatically.
ALTER TABLE support_cases
ALTER COLUMN faq_export_status SET DEFAULT 'pending';
CREATE INDEX IF NOT EXISTS idx_support_cases_faq_export_claim
ON support_cases (faq_export_status, faq_next_attempt_at, created_at, id)
WHERE faq_export_status IN ('pending', 'export_failed');
CREATE INDEX IF NOT EXISTS idx_support_cases_faq_export_stale
ON support_cases (faq_export_status, faq_last_attempt_at)
WHERE faq_export_status = 'exporting';
@@ -0,0 +1,74 @@
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read011Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000011_faq_exports."+direction+".sql"))
if err != nil {
t.Fatalf("read 000011 %s migration: %v", direction, err)
}
return strings.ToUpper(string(data))
}
func TestUp011AddsDurableFAQExportStateToSupportCases(t *testing.T) {
up := read011Migration(t, "up")
for _, fragment := range []string{
"ALTER TABLE SUPPORT_CASES",
"FAQ_EXPORT_STATUS", "FAQ_EXPORT_ATTEMPTS", "FAQ_LAST_ATTEMPT_AT",
"FAQ_NEXT_ATTEMPT_AT", "FAQ_EXPORTED_AT", "FAQ_FAILURE_CODE", "FAQ_RECORD_ID",
"PENDING", "EXPORTING", "EXPORTED", "EXPORT_FAILED",
} {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
for _, forbidden := range []string{"FAQ_QUESTION", "FAQ_ANSWER", "RAW_RESPONSE", "ACCESS_TOKEN"} {
if strings.Contains(up, forbidden) {
t.Errorf("migration must not duplicate business or secret field %q", forbidden)
}
}
}
func TestUp011DoesNotBackfillHistoricalSupportCases(t *testing.T) {
up := read011Migration(t, "up")
if !strings.Contains(up, "DEFAULT 'NOT_APPLICABLE'") {
t.Fatal("existing support cases must start as not_applicable")
}
if !strings.Contains(up, "ALTER COLUMN FAQ_EXPORT_STATUS SET DEFAULT 'PENDING'") {
t.Fatal("future support cases must default to pending")
}
}
func TestUp011AddsBoundedClaimIndexes(t *testing.T) {
up := read011Migration(t, "up")
for _, fragment := range []string{
"IDX_SUPPORT_CASES_FAQ_EXPORT_CLAIM",
"FAQ_NEXT_ATTEMPT_AT", "FAQ_LAST_ATTEMPT_AT",
"WHERE FAQ_EXPORT_STATUS IN ('PENDING', 'EXPORT_FAILED')",
} {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing claim index fragment %q", fragment)
}
}
}
func TestDown011OnlyRemovesFAQExportState(t *testing.T) {
down := read011Migration(t, "down")
for _, column := range []string{
"FAQ_EXPORT_STATUS", "FAQ_EXPORT_ATTEMPTS", "FAQ_LAST_ATTEMPT_AT",
"FAQ_NEXT_ATTEMPT_AT", "FAQ_EXPORTED_AT", "FAQ_FAILURE_CODE", "FAQ_RECORD_ID",
} {
if !strings.Contains(down, "DROP COLUMN IF EXISTS "+column) {
t.Errorf("down migration missing %s", column)
}
}
if strings.Contains(down, "DROP TABLE") {
t.Fatal("down migration must not drop baseline tables")
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS config_audit_events; DROP TABLE IF EXISTS system_resource_bindings; DROP TABLE IF EXISTS administrators; DROP TABLE IF EXISTS system_config_revisions;
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS system_config_revisions (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0),
state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')), public_config JSONB NOT NULL DEFAULT '{}'::JSONB,
secret_ref TEXT NOT NULL DEFAULT '', validation_status TEXT NOT NULL DEFAULT 'pending', validation_code TEXT NOT NULL DEFAULT '',
validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ, created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_key,kind,revision));
CREATE UNIQUE INDEX IF NOT EXISTS uq_system_config_active ON system_config_revisions(tenant_key,kind) WHERE state='active';
CREATE TABLE IF NOT EXISTS administrators (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, union_id TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '',
department TEXT NOT NULL DEFAULT '', role TEXT NOT NULL CHECK(role IN ('super_admin','admin')), status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','disabled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,staff_id));
CREATE TABLE IF NOT EXISTS system_resource_bindings (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0), state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')),
public_config JSONB NOT NULL DEFAULT '{}'::JSONB, validation_code TEXT NOT NULL DEFAULT '', validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ,
created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,kind,revision));
CREATE UNIQUE INDEX IF NOT EXISTS uq_resource_binding_active ON system_resource_bindings(tenant_key,kind) WHERE state='active';
CREATE TABLE IF NOT EXISTS config_audit_events (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, actor_staff_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, target_kind TEXT NOT NULL,
target_revision INTEGER NOT NULL DEFAULT 0, result_code TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
@@ -0,0 +1,25 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp012CreatesSettingsWithoutSecrets(t *testing.T) {
b, e := os.ReadFile("000012_system_settings.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
for _, x := range []string{"SYSTEM_CONFIG_REVISIONS", "ADMINISTRATORS", "SYSTEM_RESOURCE_BINDINGS", "CONFIG_AUDIT_EVENTS", "PUBLIC_CONFIG"} {
if !strings.Contains(s, x) {
t.Errorf("missing %s", x)
}
}
for _, x := range []string{"CLIENT_SECRET JSON", "ACCESS_TOKEN JSON", "API_KEY JSON"} {
if strings.Contains(s, x) {
t.Errorf("forbidden %s", x)
}
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS admin_bootstrap_challenges;
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS admin_bootstrap_challenges (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, code_hash TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts>=0), max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts BETWEEN 1 AND 10),
candidate_staff_id TEXT NOT NULL DEFAULT '', candidate_union_id TEXT NOT NULL DEFAULT '', candidate_display_name TEXT NOT NULL DEFAULT '', candidate_department TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK(status IN ('pending','candidate_detected','confirmed','expired','locked')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
CREATE UNIQUE INDEX IF NOT EXISTS uq_admin_bootstrap_pending ON admin_bootstrap_challenges(tenant_key) WHERE status IN ('pending','candidate_detected');
@@ -0,0 +1,18 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp013StoresOnlyBootstrapHash(t *testing.T) {
b, e := os.ReadFile("000013_admin_bootstrap.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
if !strings.Contains(s, "CODE_HASH") || strings.Contains(s, " CODE TEXT") {
t.Fatal("bootstrap must store hash only")
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS setting_sessions;
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS setting_sessions (
tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, conversation_id TEXT NOT NULL, conversation_type TEXT NOT NULL CHECK(conversation_type IN ('direct','group')),
current_step TEXT NOT NULL, pending_action TEXT NOT NULL DEFAULT '', draft_config JSONB NOT NULL DEFAULT '{}'::JSONB, expires_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY(tenant_key,staff_id));
CREATE INDEX IF NOT EXISTS idx_setting_sessions_expiry ON setting_sessions(expires_at);
@@ -0,0 +1,20 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp014CreatesExpiringSettingSessions(t *testing.T) {
b, e := os.ReadFile("000014_setting_sessions.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
for _, x := range []string{"SETTING_SESSIONS", "EXPIRES_AT", "DRAFT_CONFIG"} {
if !strings.Contains(s, x) {
t.Errorf("missing %s", x)
}
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS user_preferences;
@@ -0,0 +1,3 @@
CREATE TABLE IF NOT EXISTS user_preferences (
tenant_key TEXT NOT NULL, channel TEXT NOT NULL, external_actor_id TEXT NOT NULL, language_mode TEXT NOT NULL DEFAULT 'auto' CHECK(language_mode IN ('auto','fixed')),
locale TEXT NOT NULL DEFAULT 'auto' CHECK(locale IN ('auto','zh-CN','en','ja','ko','es')), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY(tenant_key,channel,external_actor_id));
@@ -0,0 +1,20 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp015ScopesPreferencesByTenantAndActor(t *testing.T) {
b, e := os.ReadFile("000015_user_preferences.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
for _, x := range []string{"USER_PREFERENCES", "TENANT_KEY", "EXTERNAL_ACTOR_ID", "LANGUAGE_MODE", "LOCALE"} {
if !strings.Contains(s, x) {
t.Errorf("missing %s", x)
}
}
}
@@ -0,0 +1,3 @@
-- 000016_question_image_urls down migration.
ALTER TABLE turns
DROP COLUMN IF EXISTS question_image_urls_json;
@@ -0,0 +1,6 @@
-- 000016_question_image_urls: stable published URLs for inbound question images.
-- Stores the object-storage HTTPS URLs of images a user sent in a question,
-- so the question-table export can render a readable, traceable record without
-- persisting download codes or data URIs.
ALTER TABLE turns
ADD COLUMN IF NOT EXISTS question_image_urls_json JSONB NOT NULL DEFAULT '[]'::JSONB;
@@ -0,0 +1,3 @@
-- 000017_knowledge_searched down migration.
ALTER TABLE turns
DROP COLUMN IF EXISTS knowledge_searched;
@@ -0,0 +1,6 @@
-- 000017_knowledge_searched: records whether a turn actually triggered a
-- knowledge-base search (knowledge_search tool call). Coverage classification
-- uses this to distinguish pure visual recognition from a searched-but-missed
-- knowledge question.
ALTER TABLE turns
ADD COLUMN IF NOT EXISTS knowledge_searched BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,11 @@
-- 000018_coverage_visual_recognition_reason down migration.
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer'
));
@@ -0,0 +1,14 @@
-- 000018_coverage_visual_recognition_reason: extend the coverage_decisions
-- reason_code allowlist with the new image-visual-recognition reason emitted by
-- coverage Evaluator v3 (visual recognition is not a knowledge-coverage miss).
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition'
));
@@ -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")
}
}
@@ -0,0 +1,18 @@
-- 000021_coverage_non_knowledge_response_reason down migration.
-- Map the v4 covered reason to the pre-v4 covered code before restoring the
-- legacy allowlist, so rollback remains valid with persisted v4 decisions.
UPDATE coverage_decisions
SET reason_code = 'covered_has_references'
WHERE reason_code = 'covered_non_knowledge_response';
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,14 @@
-- 000021_coverage_non_knowledge_response_reason: distinguish text turns that
-- did not invoke knowledge search from actual retrieval misses.
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',
'covered_non_knowledge_response'
));
@@ -0,0 +1,44 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp021AddsCoveredNonKnowledgeResponseReason(t *testing.T) {
data, err := os.ReadFile("000021_coverage_non_knowledge_response_reason.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ALTER TABLE COVERAGE_DECISIONS",
"DROP CONSTRAINT IF EXISTS COVERAGE_DECISIONS_REASON_CODE_CHECK",
"COVERED_NON_KNOWLEDGE_RESPONSE",
"COVERED_VISUAL_RECOGNITION",
} {
if !strings.Contains(source, required) {
t.Errorf("up migration missing %q", required)
}
}
}
func TestDown021MapsNewCoveredReasonBeforeRestoringLegacyConstraint(t *testing.T) {
data, err := os.ReadFile("000021_coverage_non_knowledge_response_reason.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"UPDATE COVERAGE_DECISIONS",
"COVERED_NON_KNOWLEDGE_RESPONSE",
"COVERED_HAS_REFERENCES",
"ALTER TABLE COVERAGE_DECISIONS",
"DROP CONSTRAINT IF EXISTS COVERAGE_DECISIONS_REASON_CODE_CHECK",
} {
if !strings.Contains(source, required) {
t.Errorf("down migration missing %q", required)
}
}
}
+262
View File
@@ -0,0 +1,262 @@
# DobotAI DingTalk Docker 部署手册
> 适用范围:DobotAI 的 Docker Compose 部署。容器名固定为 `dobotai-dingtalk`,数据库容器名固定为 `dobotai-postgres`。
>
> 本文面向测试/预发布。生产必须使用已验收的同一镜像、独立备份和独立审批,禁止在生产重新编译。
## 1. 定位
DobotAI 是钉钉渠道的对话编排与交付层,不是知识库、文档解析器或模型平台。
```text
DobotAI 负责:
钉钉 Stream、富文本/图片、会话映射、WeKnora Agent 调用、卡片交付、
/think、/setting、覆盖判定、Support Case、反馈、渠道审计。
DobotAI 不负责:
WeKnora 文档/Chunk/向量/图谱/解析/模型;钉钉应用创建;知识库治理。
```
## 2. Docker 架构
```text
钉钉用户/群
│ DingTalk Stream(出站长连接)
▼
┌────────────────────────────────────────┐
│ dobotai-dingtalk │
│ 消息、会话、卡片、覆盖与支持流程 │
│ 仅映射到宿主机 127.0.0.1:8090 │
└───────┬──────────────────────┬───────────┘
│ Compose 网络 │ host.docker.internal
▼ ▼
┌────────────────────┐ 已有 WeKnora 服务
│ dobotai-postgres │ Agent / 检索 / 知识 / 模型
│ dobotai 独立数据库 │
│ 命名卷持久化 │
└────────────────────┘
```
Compose 管理的资源:
| 资源 | 名称 | 数据策略 |
| --- | --- | --- |
| 应用容器 | `dobotai-dingtalk` | 可重建,不保存业务数据卷 |
| PostgreSQL容器 | `dobotai-postgres` | 可重建,数据在命名卷 |
| PostgreSQL命名卷 | `dobotai-postgres-data` | 持久数据,禁止常规删除 |
| Compose网络 | `dobotai-network` | 应用与数据库内部通信 |
| 宿主机密钥目录 | `<DOBOTAI_HOME>/secrets` | bind mount,不进入Git |
关键Compose属性:
```yaml
services:
dobotai-postgres:
container_name: dobotai-postgres
volumes:
- dobotai-postgres-data:/var/lib/postgresql/data
dobotai-dingtalk:
container_name: dobotai-dingtalk
depends_on:
dobotai-postgres:
condition: service_healthy
ports:
- "127.0.0.1:8090:8090"
extra_hosts:
- "host.docker.internal:host-gateway"
```
应用不使用 `network_mode: host`:它以`dobotai-postgres:5432`访问Compose数据库,以`http://host.docker.internal:8080`访问宿主机的WeKnora。
## 3. 自动安装准备
`install-docker.sh`自动完成:
```text
检查 Docker、Docker Compose 和已导入的候选镜像
创建 <DOBOTAI_HOME>/container、config、secrets、backups、migrations
生成权限0600的 config/postgres.env 与 config/dobotai.env
生成独立数据库密码、Ingress Token、Admin Token
创建并启动 dobotai-postgres
等待 PostgreSQL healthcheck
按顺序应用 db/migrations/*.up.sql
在 dobotai_schema_migrations 记录版本、名称和时间
安装 configure / start / bind-admin 三条短命令
```
它不会:
```text
启动尚未配置的钉钉应用容器
覆盖已有配置或数据库数据
删除任何 Docker 卷
输出密码、API Key、Token 或连接串
```
默认安装根目录:
```text
/opt/dobotai
├── container/ Compose、脚本、SQL迁移、运行参数
├── config/ dobotai.env、postgres.env(0600)
├── secrets/ DobotAI SecretStore(0700)
└── backups/ 逻辑备份和SHA清单
```
可以用`DOBOTAI_HOME=/绝对路径`改安装位置。测试环境建议使用独立目录,例如`/opt/dobotai-test-compose`。
## 4. 前置条件
```text
部署用户具有 sudo 和 Docker daemon 访问权限
宿主机 WeKnora 已健康,容器可经 host.docker.internal:8080 访问
```
Docker与Docker Compose缺失时由根安装脚本自动安装(当前支持APT系统)。不必手工构建、导入或命名镜像。
## 5. 全新安装
从源码仓库克隆后,进入仓库根目录执行唯一安装入口:
```bash
sudo bash ./install-docker.sh
```
脚本内部以当前Git提交构建固定内部镜像名:
```text
dobotai-dingtalk:local-<当前Git短SHA>
```
镜像名、PostgreSQL镜像和构建元数据只由根目录`install-docker.sh`维护,用户不传`DOBOTAI_IMAGE`。脚本完成后数据库和迁移已就绪,应用尚未启动。然后执行:
构建期Go依赖使用脚本内置的公开模块代理`https://goproxy.cn,direct`;它只存在于镜像构建命令,不写入DobotAI运行配置。
```bash
sudo dobotai-dingtalk-configure
```
配置器逐项显示用途和脱敏格式示例;已有值不会回显,直接回车表示保留。数据库连接串、数据库密码、Ingress Token和Admin Token由安装脚本生成,交互配置器不要求输入。
主要业务连接配置:
| 变量 | 用途 | 示例 |
| --- | --- | --- |
| `WEKNORA_BASE_URL` | Docker内访问宿主机WeKnora | `http://host.docker.internal:8080` |
| `WEKNORA_API_KEY` | DobotAI专用WeKnora API Key | `<你的WeKnora API Key>` |
| `DOBOTAI_TENANT_KEY` | DobotAI租户隔离键 | `dobotai` |
| `DOBOTAI_AGENT_BINDINGS` | 渠道到WeKnora Agent映射 | `{"dingtalk:*":"你的Agent ID"}` |
| `DINGTALK_CLIENT_ID` | 钉钉应用Client ID/AppKey | `dingxxxxxxxx` |
| `DINGTALK_CLIENT_SECRET` | 钉钉应用Secret/AppSecret | `<你的钉钉 AppSecret>` |
配置器成功后自动启动`dobotai-dingtalk`,并检查PostgreSQL健康、`http://127.0.0.1:8090/healthz`和容器运行状态。
## 7. 首位管理员绑定
仅在租户没有有效管理员时执行:
```bash
sudo dobotai-dingtalk-bind-admin
```
终端显示10分钟有效的一次性六码。候选管理员私聊钉钉机器人:
```text
/setting <六码绑定码>
```
终端自动确认后写入`super_admin/active`。已有管理员时命令拒绝执行,防止意外提权。
## 8. 迁移现有DobotAI实例
旧实例已有Turn、会话、Support Case、管理员或反馈时,不能直接新建空库后切换。先安装目标Compose基础设施,再运行:
```bash
sudo DOBOTAI_HOME=/opt/dobotai-compose \
SOURCE_POSTGRES_CONTAINER=dobotai-test-postgres \
SOURCE_APP_CONTAINER=dobotai-dingtalk \
SOURCE_DOBOTAI_ENV=/etc/dobotai-test/dobotai.env \
/opt/dobotai-compose/container/migrate-existing-database.sh
```
迁移脚本固定顺序:
```text
启动目标 dobotai-postgres 并等待健康
→ 停止并改名旧应用容器(不删除)
→ pg_dump -Fc 导出旧数据库
→ pg_restore --list 验证备份可读
→ 写入备份SHA-256
→ 复制旧应用配置,只替换数据库、监听和WeKnora地址
→ 恢复到 dobotai-postgres-data
→ 应用缺失迁移
→ 启动新 dobotai-dingtalk
→ 健康失败则恢复旧容器名称和旧容器
```
源数据库不会删除。成功后旧应用容器会以`*-legacy-*`保留,确认稳定前不得删除旧容器或旧数据库。
## 9. 数据库迁移
DobotAI应用本身不扫描SQL。安装/迁移流程由:
```bash
sudo /opt/dobotai/container/apply-migrations.sh
```
执行`migrations/*.up.sql`,每个成功迁移在同一事务写入:
```text
dobotai_schema_migrations(version, name, applied_at)
```
任何数据库升级前都必须有经`pg_restore --list`验证的逻辑备份。`.down.sql`只能在审查通过后用于数据库回滚,不能用应用镜像回滚替代数据库回滚。
## 10. 日常运行与升级
```bash
# 健康与版本
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
curl --noproxy '*' -fsS http://127.0.0.1:8090/version
# Compose状态
sudo docker compose --env-file /opt/dobotai/container/runtime.env \
-f /opt/dobotai/container/compose.yaml ps
# 日志:不要转发含用户内容或凭据的原始上下文
sudo docker logs --since 10m dobotai-dingtalk
# 数据库状态
sudo docker inspect -f '{{.State.Health.Status}}|{{.RestartCount}}' dobotai-postgres
```
升级顺序:导入候选镜像 → 备份并应用必要迁移 → 更新`runtime.env`中的`DOBOTAI_IMAGE` → 执行:
```bash
sudo dobotai-dingtalk-start
```
应用健康失败时启动器恢复上一应用镜像;数据库迁移仍必须按审查过的SQL回滚流程处理。
## 11. 覆盖判定边界
```text
有结构化引用 → covered_has_references
实际检索过但没有引用 → uncovered_no_references → Support Case
明确无权威知识/无答案 → uncovered_explicit_no_answer → Support Case
图片未检索、纯视觉识别 → covered_visual_recognition
文本未检索、闲聊/普通非知识响应 → covered_non_knowledge_response
```
因此“在吗”“你好”“谢谢”等未触发知识检索的文本不会创建Support Case或发送未覆盖提醒。
## 12. 禁止操作
```text
docker compose down -v
docker volume rm dobotai-postgres-data
docker system prune --volumes
把 config/*.env、secrets/、备份Dump、真实Token或凭据提交Git
在生产重新编译或使用未经测试的镜像
```
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
compose_file="$container_dir/compose.yaml"
runtime_env="$container_dir/runtime.env"
migrations_dir="$container_dir/migrations"
if [[ ! -r "$compose_file" || ! -r "$runtime_env" || ! -d "$migrations_dir" ]]; then
echo "迁移运行文件未安装。请先执行 install-docker.sh。" >&2
exit 1
fi
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
"${compose[@]}" up -d --no-build dobotai-postgres
for _ in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' dobotai-postgres 2>/dev/null || true)
[[ "$status" == "healthy" ]] && break
sleep 1
done
[[ "${status:-}" == "healthy" ]] || { echo "PostgreSQL 未健康,拒绝迁移。" >&2; exit 1; }
psql_db() {
"${compose[@]}" exec -T dobotai-postgres psql -v ON_ERROR_STOP=1 -U dobotai -d dobotai "$@"
}
psql_db -c 'CREATE TABLE IF NOT EXISTS dobotai_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())'
for file in "$migrations_dir"/*.up.sql; do
[[ -f "$file" ]] || continue
base="$(basename "$file")"
version_text="${base%%_*}"
version=$((10#$version_text))
name="${base#*_}"
name="${name%.up.sql}"
case "$name" in *[!a-z0-9_]* ) echo "非法迁移文件名:$base" >&2; exit 1;; esac
applied=$(psql_db -At -c "SELECT EXISTS(SELECT 1 FROM dobotai_schema_migrations WHERE version=$version)")
[[ "$applied" == "t" ]] && continue
{
printf 'BEGIN;\n'
cat "$file"
printf "\nINSERT INTO dobotai_schema_migrations(version,name) VALUES (%s,'%s');\nCOMMIT;\n" "$version" "$name"
} | "${compose[@]}" exec -T dobotai-postgres psql -v ON_ERROR_STOP=1 -U dobotai -d dobotai
echo "已应用迁移:$base"
done
+56
View File
@@ -0,0 +1,56 @@
services:
dobotai-postgres:
image: ${DOBOTAI_POSTGRES_IMAGE:-postgres:16-alpine}
container_name: dobotai-postgres
env_file:
- ${DOBOTAI_POSTGRES_ENV_FILE:?set DOBOTAI_POSTGRES_ENV_FILE in runtime.env}
volumes:
- dobotai-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 30
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- dobotai-network
dobotai-dingtalk:
image: ${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE in runtime.env}
container_name: dobotai-dingtalk
depends_on:
dobotai-postgres:
condition: service_healthy
env_file:
- ${DOBOTAI_ENV_FILE:?set DOBOTAI_ENV_FILE in runtime.env}
environment:
DOBOTAI_SECRET_DIR: /var/lib/dobotai/secrets
volumes:
- type: bind
source: ${DOBOTAI_SECRET_DIR_HOST:?set DOBOTAI_SECRET_DIR_HOST in runtime.env}
target: /var/lib/dobotai/secrets
read_only: false
ports:
- "127.0.0.1:8090:8090"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
init: true
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=32m
security_opt:
- no-new-privileges:true
networks:
- dobotai-network
volumes:
dobotai-postgres-data:
name: dobotai-postgres-data
networks:
dobotai-network:
name: dobotai-network
driver: bridge
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
import argparse
import getpass
import os
from pathlib import Path
import sys
FIELDS = (
("WEKNORA_BASE_URL", "WeKnora 地址", "Docker 容器访问宿主机 WeKnora 时使用 host.docker.internal。", "http://host.docker.internal:8080", False),
("WEKNORA_API_KEY", "WeKnora API Key", "在 WeKnora 中为 DobotAI 创建的独立 API Key。", "<你的WeKnora API Key>", True),
("DOBOTAI_INGRESS_TOKEN", "DobotAI Ingress Token", "调用 /v1/qa 时使用的独立长随机令牌。", "<随机长字符串>", True),
("DOBOTAI_TENANT_KEY", "DobotAI 租户键", "同一 DobotAI 数据库内的渠道隔离键。", "dobotai", False),
("DOBOTAI_AGENT_BINDINGS", "DobotAI Agent Bindings JSON", "钉钉会话匹配到 WeKnora Agent 的映射。", '{"dingtalk:*":"你的Agent ID"}', False),
("DINGTALK_CLIENT_ID", "钉钉 Client ID", "钉钉应用的 Client ID/AppKey。", "dingxxxxxxxx", False),
("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", "钉钉应用的 Client Secret/AppSecret。", "<你的钉钉 AppSecret>", True),
)
def parse_env(path: Path):
lines = path.read_text(encoding="utf-8").splitlines()
values = {}
for line in lines:
if line and not line.lstrip().startswith("#") and "=" in line:
key, value = line.split("=", 1)
values[key.strip()] = value
return lines, values
def prompt(label: str, description: str, example: str, secret: bool, configured: bool) -> str:
suffix = "(已配置,直接回车保留)" if configured else "(必填)"
print(f"\n{label}\n用途:{description}\n格式示例:{example}")
if secret:
return getpass.getpass(f"{label}{suffix}: ").strip()
return input(f"{label}{suffix}: ").strip()
def write_env(path: Path, lines, values):
rendered = []
written = set()
for line in lines:
if line and not line.lstrip().startswith("#") and "=" in line:
key = line.split("=", 1)[0].strip()
if key in values:
rendered.append(f"{key}={values[key]}")
written.add(key)
continue
rendered.append(line)
for key, value in values.items():
if key not in written:
rendered.append(f"{key}={value}")
temporary = path.with_name(path.name + ".new")
temporary.write_text("\n".join(rendered) + "\n", encoding="utf-8")
os.chmod(temporary, 0o600)
os.replace(temporary, path)
def main():
parser = argparse.ArgumentParser(description="配置 DobotAI Docker 应用连接")
parser.add_argument("--env-file", required=True)
parser.add_argument("--start-script", required=True)
args = parser.parse_args()
env_file = Path(args.env_file)
if not env_file.is_file():
raise SystemExit("DobotAI 环境文件不存在,请先完成 Docker 安装。")
lines, values = parse_env(env_file)
for key, label, description, example, secret in FIELDS:
entered = prompt(label, description, example, secret, bool(values.get(key, "").strip()))
if entered:
values[key] = entered
missing = [label for key, label, _, _, _ in FIELDS if not values.get(key, "").strip()]
if missing:
raise SystemExit("以下必填项未配置:" + "、".join(missing))
write_env(env_file, lines, values)
os.execv(args.start_script, [args.start_script])
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
if [[ "$(docker inspect --format '{{.State.Running}}' dobotai-dingtalk 2>/dev/null || true)" != "true" ]]; then
echo "DobotAI 容器未运行。请先执行:sudo dobotai-dingtalk-configure" >&2
exit 1
fi
exec docker exec -it dobotai-dingtalk /usr/local/bin/dobotai bootstrap-admin
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
exec python3 /opt/dobotai-test/container/configure_env.py \
--env-file /etc/dobotai-test/dobotai.env \
--start-script /usr/local/sbin/dobotai-dingtalk-start
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
compose_file="$container_dir/compose.yaml"
runtime_env="$container_dir/runtime.env"
health_url="http://127.0.0.1:8090/healthz"
if [[ ! -r "$compose_file" || ! -r "$runtime_env" ]]; then
echo "Docker 运行文件未安装。请先执行 install-docker.sh。" >&2
exit 1
fi
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
previous_image="$(docker inspect --format '{{.Config.Image}}' dobotai-dingtalk 2>/dev/null || true)"
set_runtime_image() {
local image="$1"
python3 - "$runtime_env" "$image" <<'PY'
from pathlib import Path
import os
import sys
path = Path(sys.argv[1])
image = sys.argv[2]
lines = path.read_text(encoding="utf-8").splitlines()
rendered = ["DOBOTAI_IMAGE=" + image if line.startswith("DOBOTAI_IMAGE=") else line for line in lines]
if not any(line.startswith("DOBOTAI_IMAGE=") for line in lines):
rendered.append("DOBOTAI_IMAGE=" + image)
temporary = path.with_name(path.name + ".new")
temporary.write_text("\n".join(rendered) + "\n", encoding="utf-8")
os.chmod(temporary, 0o600)
os.replace(temporary, path)
PY
}
rollback() {
"${compose[@]}" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true
if [[ -n "$previous_image" ]]; then
set_runtime_image "$previous_image"
"${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk >/dev/null 2>&1 || true
fi
}
trap rollback ERR
"${compose[@]}" up -d --no-build dobotai-postgres
"${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk
for _ in $(seq 1 30); do
if curl --noproxy '*' --fail --silent --max-time 2 "$health_url" >/dev/null; then
trap - ERR
echo "DobotAI 容器已启动:dobotai-dingtalk"
exit 0
fi
sleep 1
done
echo "DobotAI 容器健康检查失败,已回滚到上一容器镜像(如存在)。" >&2
exit 1
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source_root="${DOBOTAI_SOURCE_ROOT:-$(cd "$script_dir/../.." && pwd)}"
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
config_dir="$home/config"
secret_dir="$home/secrets"
backup_dir="$home/backups"
app_env="$config_dir/dobotai.env"
postgres_env="$config_dir/postgres.env"
runtime_env="$container_dir/runtime.env"
image="${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE to a reviewed DobotAI image tag}"
postgres_image="${DOBOTAI_POSTGRES_IMAGE:-postgres:16-alpine}"
bin_dir="${DOBOTAI_BIN_DIR:-/usr/local/sbin}"
if [[ ${EUID} -ne 0 ]]; then
echo "请使用 sudo 执行 Docker 安装脚本。" >&2
exit 1
fi
command -v docker >/dev/null || { echo "Docker 未安装。" >&2; exit 1; }
docker compose version >/dev/null || { echo "Docker Compose 未安装。" >&2; exit 1; }
docker image inspect "$image" >/dev/null 2>&1 || { echo "候选应用镜像不存在:$image" >&2; exit 1; }
install -d -m 0750 "$home" "$container_dir" "$config_dir" "$backup_dir"
install -d -m 0700 -o 10001 -g 10001 "$secret_dir"
install -d -m 0750 "$container_dir/migrations"
install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml"
install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py"
install -m 0750 "$script_dir/dobotai-dingtalk-start" "$container_dir/dobotai-dingtalk-start"
install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" "$container_dir/dobotai-dingtalk-bind-admin"
install -m 0750 "$script_dir/apply-migrations.sh" "$container_dir/apply-migrations.sh"
install -m 0750 "$script_dir/migrate-existing-database.sh" "$container_dir/migrate-existing-database.sh"
install -m 0644 "$source_root"/db/migrations/*.up.sql "$container_dir/migrations/"
install -m 0644 "$source_root"/db/migrations/*.down.sql "$container_dir/migrations/"
if [[ ! -f "$postgres_env" || ! -f "$app_env" ]]; then
umask 077
python3 - "$postgres_env" "$app_env" <<'PY'
from pathlib import Path
import os
import secrets
import sys
postgres_env = Path(sys.argv[1])
app_env = Path(sys.argv[2])
password = secrets.token_urlsafe(32)
ingress = secrets.token_urlsafe(32)
admin = secrets.token_urlsafe(32)
postgres_env.write_text(
"POSTGRES_DB=dobotai\n"
"POSTGRES_USER=dobotai\n"
f"POSTGRES_PASSWORD={password}\n",
encoding="utf-8",
)
os.chmod(postgres_env, 0o600)
app_env.write_text(
"DOBOTAI_LISTEN_ADDR=0.0.0.0:8090\n"
f"DATABASE_URL=postgres://dobotai:{password}@dobotai-postgres:5432/dobotai?sslmode=disable\n"
f"DOBOTAI_INGRESS_TOKEN={ingress}\n"
f"DOBOTAI_ADMIN_TOKEN={admin}\n"
"DOBOTAI_GROUP_SESSION_POLICY=shared\n"
"DOBOTAI_TENANT_KEY=dobotai\n"
"DOBOTAI_AGENT_BINDINGS=\n"
"DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer\n"
"DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning\n"
"WEKNORA_BASE_URL=http://host.docker.internal:8080\n"
"WEKNORA_API_KEY=\n"
"DINGTALK_CLIENT_ID=\n"
"DINGTALK_CLIENT_SECRET=\n",
encoding="utf-8",
)
os.chmod(app_env, 0o600)
PY
fi
umask 077
cat > "$runtime_env" <<EOF
DOBOTAI_IMAGE=$image
DOBOTAI_POSTGRES_IMAGE=$postgres_image
DOBOTAI_ENV_FILE=$app_env
DOBOTAI_POSTGRES_ENV_FILE=$postgres_env
DOBOTAI_SECRET_DIR_HOST=$secret_dir
DOBOTAI_MIGRATIONS_DIR=$container_dir/migrations
EOF
chmod 0600 "$runtime_env"
install -d -m 0755 "$bin_dir"
cat > "$bin_dir/dobotai-dingtalk-configure" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec python3 '$container_dir/configure_env.py' --env-file '$app_env' --start-script '$bin_dir/dobotai-dingtalk-start'
EOF
cat > "$bin_dir/dobotai-dingtalk-start" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec '$container_dir/dobotai-dingtalk-start'
EOF
cat > "$bin_dir/dobotai-dingtalk-bind-admin" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec '$container_dir/dobotai-dingtalk-bind-admin'
EOF
chmod 0750 "$bin_dir/dobotai-dingtalk-configure" "$bin_dir/dobotai-dingtalk-start" "$bin_dir/dobotai-dingtalk-bind-admin"
compose=(docker compose --env-file "$runtime_env" -f "$container_dir/compose.yaml")
"${compose[@]}" up -d dobotai-postgres
for _ in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' dobotai-postgres 2>/dev/null || true)
[[ "$status" == "healthy" ]] && break
sleep 1
done
[[ "${status:-}" == "healthy" ]] || { echo "PostgreSQL 初始化失败。" >&2; exit 1; }
DOBOTAI_HOME="$home" "$container_dir/apply-migrations.sh"
echo "Docker 基础设施已准备完成:数据库与迁移已就绪,应用尚未启动。"
echo "下一步执行:sudo dobotai-dingtalk-configure"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$script_dir/install-docker.sh" "$@"
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
config_dir="$home/config"
runtime_env="$container_dir/runtime.env"
compose_file="$container_dir/compose.yaml"
source_container="${SOURCE_POSTGRES_CONTAINER:-dobotai-test-postgres}"
source_app_container="${SOURCE_APP_CONTAINER:-dobotai-dingtalk}"
source_env="${SOURCE_DOBOTAI_ENV:-/etc/dobotai-test/dobotai.env}"
app_env="$config_dir/dobotai.env"
backup_dir="$home/backups"
stamp="$(date -u '+%Y%m%dT%H%M%SZ')"
legacy_app="${source_app_container}-legacy-${stamp}"
[[ ${EUID} -eq 0 ]] || { echo "请使用 sudo 执行现有数据库迁移脚本。" >&2; exit 1; }
[[ -r "$runtime_env" && -r "$compose_file" && -r "$source_env" ]] || { echo "运行文件或旧环境文件不存在。请先执行 install-docker.sh。" >&2; exit 1; }
docker inspect "$source_container" >/dev/null 2>&1 || { echo "源 PostgreSQL 容器不存在:$source_container" >&2; exit 1; }
docker inspect "$source_app_container" >/dev/null 2>&1 || { echo "源 DobotAI 容器不存在:$source_app_container" >&2; exit 1; }
source_pg_user=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$source_container" | python3 -c "import sys; print(next((x.strip().split('=',1)[1] for x in sys.stdin if x.startswith('POSTGRES_USER=')), ''))")
source_pg_db=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$source_container" | python3 -c "import sys; print(next((x.strip().split('=',1)[1] for x in sys.stdin if x.startswith('POSTGRES_DB=')), ''))")
test -n "$source_pg_user" && test -n "$source_pg_db"
install -d -m 0700 "$backup_dir"
dump="$backup_dir/pre-compose-adoption-${stamp}.dump"
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
"${compose[@]}" up -d dobotai-postgres
for _ in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' dobotai-postgres 2>/dev/null || true)
[[ "$status" == "healthy" ]] && break
sleep 1
done
[[ "${status:-}" == "healthy" ]] || { echo "目标 PostgreSQL 未健康。" >&2; exit 1; }
old_running=$(docker inspect -f '{{.State.Running}}' "$source_app_container")
rollback() {
docker compose --env-file "$runtime_env" -f "$compose_file" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true
if docker inspect "$legacy_app" >/dev/null 2>&1; then
docker rename "$legacy_app" "$source_app_container" || true
[[ "$old_running" == "true" ]] && docker start "$source_app_container" >/dev/null 2>&1 || true
fi
}
trap rollback ERR
[[ "$old_running" == "true" ]] && docker stop "$source_app_container" >/dev/null
docker rename "$source_app_container" "$legacy_app"
docker exec -e PGUSER="$source_pg_user" "$source_container" pg_dump -Fc --no-owner -d "$source_pg_db" > "$dump"
test -s "$dump"
docker exec -i dobotai-postgres pg_restore --list < "$dump" >/dev/null
sha256sum "$dump" > "$dump.sha256"
# Replace only deployment-managed endpoints; preserve all existing application credentials.
python3 - "$source_env" "$app_env" "$config_dir/postgres.env" <<'PY'
from pathlib import Path
import os
import sys
source, target, postgres_env = map(Path, sys.argv[1:])
password = next(line.split('=', 1)[1].strip() for line in postgres_env.read_text().splitlines() if line.startswith('POSTGRES_PASSWORD='))
values = {}
for line in source.read_text().splitlines():
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
values[key] = value
values['DOBOTAI_LISTEN_ADDR'] = '0.0.0.0:8090'
values['DATABASE_URL'] = f'postgres://dobotai:{password}@dobotai-postgres:5432/dobotai?sslmode=disable'
values['WEKNORA_BASE_URL'] = 'http://host.docker.internal:8080'
temporary = target.with_name(target.name + '.new')
temporary.write_text('\n'.join(f'{k}={v}' for k, v in values.items()) + '\n', encoding='utf-8')
os.chmod(temporary, 0o600)
os.replace(temporary, target)
PY
"${compose[@]}" exec -T dobotai-postgres psql -v ON_ERROR_STOP=1 -U dobotai -d dobotai -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
docker exec -i dobotai-postgres pg_restore -U dobotai -d dobotai --no-owner < "$dump"
DOBOTAI_HOME="$home" "$container_dir/apply-migrations.sh"
"${compose[@]}" up -d --no-build dobotai-dingtalk
for _ in $(seq 1 30); do
if curl --noproxy '*' --fail --silent --max-time 2 http://127.0.0.1:8090/healthz >/dev/null; then
trap - ERR
echo "现有DobotAI数据库已迁移到 dobotai-postgres-data;旧应用容器保留为 $legacy_app。"
exit 0
fi
sleep 1
done
echo "迁移后的应用健康检查失败,正在恢复旧容器。" >&2
exit 1
@@ -0,0 +1,7 @@
# 非敏感运行参数;由 install-docker.sh 生成实际文件。
DOBOTAI_IMAGE=dobotai-dingtalk:test
DOBOTAI_POSTGRES_IMAGE=postgres:16-alpine
DOBOTAI_ENV_FILE=/opt/dobotai/config/dobotai.env
DOBOTAI_POSTGRES_ENV_FILE=/opt/dobotai/config/postgres.env
DOBOTAI_SECRET_DIR_HOST=/opt/dobotai/secrets
DOBOTAI_MIGRATIONS_DIR=/opt/dobotai/container/migrations
+16 -16
View File
@@ -1,21 +1,21 @@
# WeKnora API Capability Gaps
# WeKnora API 能力缺口
These gaps must not be solved with private WeKnora source or database changes.
以下能力缺口不得通过修改 WeKnora 私有源码或数据库解决。
| Capability | Current external handling |
| 能力 | 当前外部处理方式 |
|---|---|
| Like/dislike and unresolved reason | DobotAI PostgreSQL and API |
| Formal covered/not-covered decision | DobotAI evaluation rules using references, scores, answer status, and offline calibration |
| Support case, owner, SLA, and status | DobotAI workflow |
| Human reply to original channel | DobotAI channel adapter; not written to WeKnora history by default |
| Stable external turn correlation | DobotAI mapping of external message, turn, WeKnora session/request, and gateway trace |
| Complete multi-call token usage | Model gateway/Langfuse; unknown remains null/unknown |
| Strict image failure semantics | Adapter contract test; reject image traffic on failure |
| Arbitrary human assistant-message append | Keep in DobotAI and submit an upstream API request only if context injection becomes necessary |
| 点赞、点踩和未解决原因 | DobotAI PostgreSQL 与 API |
| 正式的已覆盖/未覆盖判定 | DobotAI 基于引用、分数、回答状态和离线校准的评估规则 |
| Support Case、负责人、SLA 和状态 | DobotAI 工作流 |
| 向原始渠道发送人工回复 | DobotAI 渠道适配器;默认不写回 WeKnora 历史 |
| 稳定的外部 Turn 关联 | DobotAI 映射外部消息、Turn、WeKnora 会话/请求和网关追踪 |
| 完整的多调用 Token 用量 | 模型网关/Langfuse;未知值保持 `null/unknown` |
| 严格的图片失败语义 | Adapter 契约测试;图片流量失败时拒绝处理 |
| 任意人工助手消息追加 | 保留在 DobotAI;仅当确有上下文注入需求时向上游提交 API 请求 |
Allowed responses to a gap:
面对能力缺口时,只允许:
1. adjust the product design;
2. implement it outside WeKnora;
3. open an upstream issue;
4. submit a general upstream pull request.
1. 调整产品设计;
2. 在 WeKnora 外部实现;
3. 提交上游 Issue;
4. 提交通用的上游 Pull Request。
+74 -49
View File
@@ -1,73 +1,98 @@
# 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:
首位管理员绑定由部署服务器终端发起,而非网页发码。钉钉 Stream 机器人已正常运行且当前不存在有效管理员时,操作人员执行:
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.
```bash
dobotai bootstrap-admin
```
No WeKnora source patch is allowed as a workaround.
命令只连接 DobotAI PostgreSQL,使用加密随机数创建一个有效期10分钟、最多5次错误尝试的六码挑战,并在终端持续等待。候选用户必须私聊机器人发送:
## Service ownership
```text
/setting <六码绑定码>
```
- `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.
验证码匹配后,渠道侧只记录候选身份;终端轮询并事务性将该候选用户写为 `super_admin/active`,然后显示成功并退出。已有有效管理员时该命令拒绝执行,避免任意用户通过终端绑定流程被提权。DobotAI 不提供网页管理面,钉钉凭据和运行配置由部署环境文件管理。
## Upgrade contract
## 图片失败策略
For every official WeKnora upgrade:
启用图片流量前,WeKnora Adapter 必须针对选定的官方版本和视觉模型执行契约测试。
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.
任一图片路径失败时:
1. 不得去掉图片后重发同一问题;
2. 不得将部分模型输出当作成功结果;
3. 向渠道适配器返回类型化 `vision_unavailable` 错误;
4. 钉钉提示用户图片识别暂不可用,并请其重试或发送文本;
5. 发送运维告警,核查 Agent 图片上传设置、视觉模型、供应商连通性和模型能力;
6. 契约测试通过前保持图片流量禁用。
不得通过修改 WeKnora 源码规避上述失败。
对钉钉 richText 中的图片,DobotAI 仅通过官方接口下载,验证来源、大小、媒体类型和文件签名后,才转换为 WeKnora 通用多模态请求。对回答中的 `resource://` 图片,仅可在鉴权获取、内容哈希转存私有 OSS 后外发;不得把任意外部 URL 当作可信图片。
## 服务职责
- `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:仅用于队列、重试、短期租约和临时流状态。
## 升级契约
每次升级官方 WeKnora 时:
1. 更新固定的官方 Release/Tag;
2. 仅对 WeKnora 执行官方数据库迁移;
3. 执行 DobotAI Adapter 契约测试;
4. 验证文本 Agent Chat、引用、终止错误、停止、图片、导入和 FAQ;
5. 仅在契约测试通过后推广官方镜像。
WeKnora 升级、DobotAI 迁移、钉钉单消费者切换和生产部署是独立变更边界,必须各自备份、演练和验收。
+2
View File
@@ -3,6 +3,7 @@ module gitea.zheromiku.top/Weknora/dobotai-platform
go 1.25.0
require (
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1
github.com/jackc/pgx/v5 v5.9.2
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
)
@@ -15,4 +16,5 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.4.0 // indirect
)
+4
View File
@@ -1,3 +1,5 @@
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1 h1:vtiFd0hhPAbyYJjztl0wYUq/PqEGkIlDmVuTIy6zw8Y=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -26,6 +28,8 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY=
golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
# 唯一安装入口:镜像仓库名与标签策略只在本脚本维护。
postgres_image="postgres:16-alpine"
if [[ ${EUID} -ne 0 ]]; then
echo "请在克隆仓库根目录执行:sudo bash ./install-docker.sh" >&2
exit 1
fi
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$repo_root"
[[ -f Dockerfile && -f deploy/dobotai-dingtalk/install-docker.sh ]] || {
echo "安装脚本必须从完整DobotAI源码仓库根目录运行。" >&2
exit 1
}
command -v git >/dev/null || { echo "未找到 git,无法确定当前源码版本。" >&2; exit 1; }
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null || {
echo "请从 git clone 得到的仓库中执行本脚本。" >&2
exit 1
}
install_docker_if_missing() {
if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then
return
fi
command -v apt-get >/dev/null || {
echo "本脚本仅自动安装基于 apt 的 Docker;请先安装 Docker Engine 和 Docker Compose v2。" >&2
exit 1
}
export DEBIAN_FRONTEND=noninteractive
apt-get update
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
apt-get install -y docker.io docker-compose-v2
elif apt-cache show docker-compose-plugin >/dev/null 2>&1; then
apt-get install -y docker.io docker-compose-plugin
else
echo "当前APT源没有 Docker Compose v2 插件,无法安全继续。" >&2
exit 1
fi
systemctl enable --now docker
docker compose version >/dev/null
}
install_docker_if_missing
commit_sha="$(git -C "$repo_root" rev-parse HEAD)"
commit_short="$(git -C "$repo_root" rev-parse --short=12 HEAD)"
build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
image="dobotai-dingtalk:local-${commit_short}"
echo "[1/3] 构建 DobotAI 镜像:$image"
docker build \
--build-arg "GOPROXY=https://goproxy.cn,direct" \
--build-arg "VERSION=local-${commit_short}" \
--build-arg "COMMIT_SHA=${commit_sha}" \
--build-arg "BUILD_TIME=${build_time}" \
--label "org.opencontainers.image.revision=${commit_sha}" \
--tag "$image" \
"$repo_root"
echo "[2/3] 准备独立PostgreSQL、目录、配置文件和迁移"
export DOBOTAI_IMAGE="$image"
export DOBOTAI_POSTGRES_IMAGE="$postgres_image"
export DOBOTAI_SOURCE_ROOT="$repo_root"
exec bash "$repo_root/deploy/dobotai-dingtalk/install-docker.sh"
+138
View File
@@ -0,0 +1,138 @@
package audit
import (
"context"
"errors"
"time"
)
var (
ErrNotFound = errors.New("turn not found")
ErrInvalidArgument = errors.New("invalid audit query")
)
// TurnStatus represents the status of a turn.
type TurnStatus string
const (
StatusProcessing TurnStatus = "processing"
StatusSucceeded TurnStatus = "succeeded"
StatusFailed TurnStatus = "failed"
StatusCancelled TurnStatus = "cancelled"
)
// TurnSummary is a lightweight representation of a turn for list views.
// 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"`
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.
// Content and Raw are never included.
type SafeReference struct {
ID string `json:"id"`
KnowledgeID string `json:"knowledge_id"`
KnowledgeTitle string `json:"knowledge_title"`
Score float64 `json:"score"`
}
// SafeMedia is a media audit entry with only safe metadata.
type SafeMedia struct {
Type string `json:"type"`
MIMEType string `json:"mime_type"`
SizeBytes int64 `json:"size_bytes"`
Width int `json:"width"`
Height int `json:"height"`
}
// MessageMeta provides request/answer message metadata.
type MessageMeta struct {
ID string `json:"id"`
Direction string `json:"direction"`
Role string `json:"role"`
MessageType string `json:"message_type"`
Status string `json:"status"`
ActorName string `json:"actor_name,omitempty"`
CreatedAt time.Time `json:"created_at"`
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
DeliveryMethod string `json:"delivery_method,omitempty"`
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"`
Feedback []AnswerFeedback `json:"feedback,omitempty"`
}
// ListTurnsRequest is the input for listing turns.
type ListTurnsRequest struct {
Limit int
Cursor string
Channel string
Status TurnStatus
}
// ListTurnsResult is the output of a list turns query.
type ListTurnsResult struct {
Items []TurnSummary `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
}
type ListQuery struct {
Limit int
Channel string
Status TurnStatus
BeforeCreatedAt *time.Time
BeforeID string
}
// TurnRepository is the data access interface for audit queries.
type TurnRepository interface {
ListTurns(ctx context.Context, tenantKey string, query ListQuery) ([]TurnSummary, error)
GetTurn(ctx context.Context, tenantKey, turnID string) (TurnDetail, error)
}
+54
View File
@@ -0,0 +1,54 @@
package audit
import (
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
const maxCursorLength = 512
type cursorData struct {
CreatedAt time.Time `json:"t"`
ID string `json:"id"`
}
func encodeCursor(createdAt time.Time, id string) string {
data := cursorData{
CreatedAt: createdAt,
ID: id,
}
payload, err := json.Marshal(data)
if err != nil {
// This should never happen for a simple struct.
panic("encodeCursor marshal: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(payload)
}
func encodeCursorString(payload string) string {
return base64.RawURLEncoding.EncodeToString([]byte(payload))
}
func decodeCursor(cursor string) (cursorData, error) {
if strings.TrimSpace(cursor) == "" {
return cursorData{}, errors.New("empty cursor")
}
if len(cursor) > maxCursorLength {
return cursorData{}, errors.New("cursor too long")
}
payload, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return cursorData{}, errors.New("invalid cursor encoding")
}
var data cursorData
if err := json.Unmarshal(payload, &data); err != nil {
return cursorData{}, errors.New("invalid cursor payload")
}
if data.CreatedAt.IsZero() || data.ID == "" {
return cursorData{}, errors.New("incomplete cursor data")
}
return data, nil
}
+63
View File
@@ -0,0 +1,63 @@
package audit
import (
"strings"
"testing"
"time"
)
func TestCursorRoundTrip(t *testing.T) {
now := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
cursor := encodeCursor(now, "turn-abc-123")
decoded, err := decodeCursor(cursor)
if err != nil {
t.Fatalf("decodeCursor: %v", err)
}
if !decoded.CreatedAt.Equal(now) {
t.Fatalf("CreatedAt=%v, want %v", decoded.CreatedAt, now)
}
if decoded.ID != "turn-abc-123" {
t.Fatalf("ID=%q, want %q", decoded.ID, "turn-abc-123")
}
}
func TestDecodeCursorRejectsEmpty(t *testing.T) {
_, err := decodeCursor("")
if err == nil {
t.Fatal("expected error for empty cursor")
}
}
func TestDecodeCursorRejectsInvalidBase64(t *testing.T) {
_, err := decodeCursor("!!!not-valid-base64!!!")
if err == nil {
t.Fatal("expected error for invalid base64")
}
}
func TestDecodeCursorRejectsTruncated(t *testing.T) {
// Create a valid cursor then truncate it
cursor := encodeCursor(time.Now().UTC(), "id")
truncated := cursor[:len(cursor)-3]
_, err := decodeCursor(truncated)
if err == nil {
t.Fatal("expected error for truncated cursor")
}
}
func TestDecodeCursorRejectsTooLong(t *testing.T) {
// 1024 bytes should exceed max cursor length
_, err := decodeCursor(strings.Repeat("A", 1024))
if err == nil {
t.Fatal("expected error for too-long cursor")
}
}
func TestDecodeCursorRejectsMissingFields(t *testing.T) {
// Invalid JSON payload (not a valid cursor struct)
// We create a cursor-like string that decodes but has missing fields
_, err := decodeCursor(encodeCursorString("{}"))
if err == nil {
t.Fatal("expected error for missing fields in cursor")
}
}
+43
View File
@@ -0,0 +1,43 @@
package audit
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestFeedbackProjectionJSONUsesOnlySafeFields(t *testing.T) {
updated := time.Date(2026, 7, 21, 15, 0, 0, 0, time.UTC)
detail := TurnDetail{Feedback: []AnswerFeedback{{
ActorExternalID: "user-1", ActorDisplayName: "Alice", Value: "down", ReasonCode: "not_resolved", UpdatedAt: updated,
}}}
raw, err := json.Marshal(detail)
if err != nil {
t.Fatal(err)
}
body := string(raw)
for _, want := range []string{`"feedback"`, `"actor_external_id":"user-1"`, `"actor_display_name":"Alice"`, `"value":"down"`, `"reason_code":"not_resolved"`, `"updated_at"`} {
if !strings.Contains(body, want) {
t.Errorf("feedback JSON missing %s: %s", want, body)
}
}
for _, forbidden := range []string{"out_track", "provider_message", "answer_message_id", "actor_id", "token", "callback", "params"} {
if strings.Contains(strings.ToLower(body), forbidden) {
t.Errorf("feedback JSON leaked %q: %s", forbidden, body)
}
}
}
func TestTurnSummaryFeedbackCountsJSON(t *testing.T) {
raw, err := json.Marshal(TurnSummary{FeedbackUpCount: 2, FeedbackDownCount: 3, FeedbackUnresolvedCount: 1})
if err != nil {
t.Fatal(err)
}
body := string(raw)
for _, want := range []string{`"feedback_up_count":2`, `"feedback_down_count":3`, `"feedback_unresolved_count":1`} {
if !strings.Contains(body, want) {
t.Errorf("summary JSON missing %s: %s", want, body)
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package audit
import (
"context"
"fmt"
"strings"
)
const (
defaultLimit = 20
maxLimit = 100
maxChannelLen = 128
maxTurnIDLen = 128
maxSummaryLen = 200
)
type Service struct {
tenantKey string
repo TurnRepository
}
func NewService(tenantKey string, repo TurnRepository) (*Service, error) {
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" || repo == nil {
return nil, fmt.Errorf("%w: tenant and repository are required", ErrInvalidArgument)
}
return &Service{tenantKey: tenantKey, repo: repo}, nil
}
func (s *Service) ListTurns(ctx context.Context, req ListTurnsRequest) (ListTurnsResult, error) {
if req.Limit == 0 {
req.Limit = defaultLimit
}
if req.Limit < 1 || req.Limit > maxLimit {
return ListTurnsResult{}, fmt.Errorf("%w: limit must be between 1 and %d", ErrInvalidArgument, maxLimit)
}
req.Channel = strings.TrimSpace(req.Channel)
if len(req.Channel) > maxChannelLen {
return ListTurnsResult{}, fmt.Errorf("%w: channel too long", ErrInvalidArgument)
}
if !validStatus(req.Status) {
return ListTurnsResult{}, fmt.Errorf("%w: invalid status", ErrInvalidArgument)
}
query := ListQuery{Limit: req.Limit + 1, Channel: req.Channel, Status: req.Status}
if req.Cursor != "" {
cursor, err := decodeCursor(req.Cursor)
if err != nil {
return ListTurnsResult{}, fmt.Errorf("%w: invalid cursor", ErrInvalidArgument)
}
query.BeforeCreatedAt = &cursor.CreatedAt
query.BeforeID = cursor.ID
}
items, err := s.repo.ListTurns(ctx, s.tenantKey, query)
if err != nil {
return ListTurnsResult{}, err
}
for i := range items {
items[i].QuestionSummary = truncateRunes(items[i].QuestionSummary, maxSummaryLen)
items[i].AnswerSummary = truncateRunes(items[i].AnswerSummary, maxSummaryLen)
}
result := ListTurnsResult{Items: items}
if result.Items == nil {
result.Items = []TurnSummary{}
}
if len(result.Items) > req.Limit {
result.Items = result.Items[:req.Limit]
last := result.Items[len(result.Items)-1]
result.NextCursor = encodeCursor(last.CreatedAt, last.ID)
}
return result, nil
}
func (s *Service) GetTurn(ctx context.Context, turnID string) (TurnDetail, error) {
turnID = strings.TrimSpace(turnID)
if turnID == "" || len(turnID) > maxTurnIDLen {
return TurnDetail{}, fmt.Errorf("%w: invalid turn id", ErrInvalidArgument)
}
return s.repo.GetTurn(ctx, s.tenantKey, turnID)
}
func validStatus(status TurnStatus) bool {
switch status {
case "", StatusProcessing, StatusSucceeded, StatusFailed, StatusCancelled:
return true
default:
return false
}
}
func truncateRunes(value string, limit int) string {
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}
+179
View File
@@ -0,0 +1,179 @@
package audit
import (
"context"
"errors"
"strings"
"testing"
"time"
)
type stubRepo struct {
listFn func(context.Context, string, ListQuery) ([]TurnSummary, error)
getFn func(context.Context, string, string) (TurnDetail, error)
}
func (r *stubRepo) ListTurns(ctx context.Context, tenant string, query ListQuery) ([]TurnSummary, error) {
if r.listFn == nil {
return nil, nil
}
return r.listFn(ctx, tenant, query)
}
func (r *stubRepo) GetTurn(ctx context.Context, tenant, id string) (TurnDetail, error) {
if r.getFn == nil {
return TurnDetail{}, ErrNotFound
}
return r.getFn(ctx, tenant, id)
}
func TestNewServiceRequiresTenantAndRepository(t *testing.T) {
repo := &stubRepo{}
if _, err := NewService("", repo); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("empty tenant error=%v", err)
}
if _, err := NewService("tenant", nil); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("nil repository error=%v", err)
}
}
func TestServiceListUsesServerTenantDefaultLimitAndFetchesExtra(t *testing.T) {
var gotTenant string
var gotQuery ListQuery
repo := &stubRepo{listFn: func(_ context.Context, tenant string, query ListQuery) ([]TurnSummary, error) {
gotTenant, gotQuery = tenant, query
return []TurnSummary{}, nil
}}
svc, err := NewService(" tenant-a ", repo)
if err != nil {
t.Fatal(err)
}
page, err := svc.ListTurns(context.Background(), ListTurnsRequest{})
if err != nil {
t.Fatal(err)
}
if gotTenant != "tenant-a" || gotQuery.Limit != 21 {
t.Fatalf("tenant=%q query=%+v", gotTenant, gotQuery)
}
if page.Items == nil || page.NextCursor != "" {
t.Fatalf("page=%+v", page)
}
}
func TestServiceListBuildsStableNextCursor(t *testing.T) {
t3 := time.Date(2026, 7, 21, 3, 0, 0, 0, time.UTC)
t2 := t3.Add(-time.Minute)
t1 := t2.Add(-time.Minute)
repo := &stubRepo{listFn: func(_ context.Context, _ string, query ListQuery) ([]TurnSummary, error) {
if query.Limit != 3 {
t.Fatalf("repository limit=%d want=3", query.Limit)
}
return []TurnSummary{{ID: "turn-3", CreatedAt: t3}, {ID: "turn-2", CreatedAt: t2}, {ID: "turn-1", CreatedAt: t1}}, nil
}}
svc, _ := NewService("tenant", repo)
page, err := svc.ListTurns(context.Background(), ListTurnsRequest{Limit: 2})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 2 || page.Items[1].ID != "turn-2" || page.NextCursor == "" {
t.Fatalf("page=%+v", page)
}
decoded, err := decodeCursor(page.NextCursor)
if err != nil || decoded.ID != "turn-2" || !decoded.CreatedAt.Equal(t2) {
t.Fatalf("next cursor=%+v err=%v", decoded, err)
}
}
func TestServiceListDecodesCursorForRepository(t *testing.T) {
created := time.Date(2026, 7, 21, 2, 0, 0, 123, time.UTC)
cursor := encodeCursor(created, "turn-before")
repo := &stubRepo{listFn: func(_ context.Context, _ string, query ListQuery) ([]TurnSummary, error) {
if query.BeforeID != "turn-before" || query.BeforeCreatedAt == nil || !query.BeforeCreatedAt.Equal(created) {
t.Fatalf("decoded query=%+v", query)
}
return nil, nil
}}
svc, _ := NewService("tenant", repo)
if _, err := svc.ListTurns(context.Background(), ListTurnsRequest{Cursor: cursor}); err != nil {
t.Fatal(err)
}
}
func TestServiceListValidation(t *testing.T) {
svc, _ := NewService("tenant", &stubRepo{})
for _, tc := range []struct {
name string
req ListTurnsRequest
}{
{name: "negative limit", req: ListTurnsRequest{Limit: -1}},
{name: "too large limit", req: ListTurnsRequest{Limit: 101}},
{name: "bad status", req: ListTurnsRequest{Status: "bogus"}},
{name: "long channel", req: ListTurnsRequest{Channel: strings.Repeat("x", 129)}},
{name: "bad cursor", req: ListTurnsRequest{Cursor: "not-a-cursor"}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := svc.ListTurns(context.Background(), tc.req); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("error=%v", err)
}
})
}
}
func TestServiceListAcceptsMaximumLimitAndFilters(t *testing.T) {
repo := &stubRepo{listFn: func(_ context.Context, _ string, query ListQuery) ([]TurnSummary, error) {
if query.Limit != 101 || query.Channel != "dingtalk" || query.Status != StatusSucceeded {
t.Fatalf("query=%+v", query)
}
return nil, nil
}}
svc, _ := NewService("tenant", repo)
if _, err := svc.ListTurns(context.Background(), ListTurnsRequest{Limit: 100, Channel: " dingtalk ", Status: StatusSucceeded}); err != nil {
t.Fatal(err)
}
}
func TestServiceListTruncatesUnicodeSummariesTo200Runes(t *testing.T) {
long := strings.Repeat("初", 250)
repo := &stubRepo{listFn: func(context.Context, string, ListQuery) ([]TurnSummary, error) {
return []TurnSummary{{ID: "turn", CreatedAt: time.Now(), QuestionSummary: long, AnswerSummary: long}}, nil
}}
svc, _ := NewService("tenant", repo)
page, err := svc.ListTurns(context.Background(), ListTurnsRequest{})
if err != nil {
t.Fatal(err)
}
if len([]rune(page.Items[0].QuestionSummary)) != 200 || len([]rune(page.Items[0].AnswerSummary)) != 200 {
t.Fatalf("summary lengths=%d/%d", len([]rune(page.Items[0].QuestionSummary)), len([]rune(page.Items[0].AnswerSummary)))
}
}
func TestServiceGetUsesServerTenant(t *testing.T) {
var gotTenant, gotID string
repo := &stubRepo{getFn: func(_ context.Context, tenant, id string) (TurnDetail, error) {
gotTenant, gotID = tenant, id
return TurnDetail{ID: id}, nil
}}
svc, _ := NewService("tenant-a", repo)
if _, err := svc.GetTurn(context.Background(), " turn-1 "); err != nil {
t.Fatal(err)
}
if gotTenant != "tenant-a" || gotID != "turn-1" {
t.Fatalf("tenant=%q id=%q", gotTenant, gotID)
}
}
func TestServiceGetValidationAndNotFound(t *testing.T) {
repo := &stubRepo{getFn: func(context.Context, string, string) (TurnDetail, error) {
return TurnDetail{}, ErrNotFound
}}
svc, _ := NewService("tenant", repo)
if _, err := svc.GetTurn(context.Background(), " "); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("blank id error=%v", err)
}
if _, err := svc.GetTurn(context.Background(), strings.Repeat("x", 129)); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("long id error=%v", err)
}
if _, err := svc.GetTurn(context.Background(), "missing"); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing error=%v", err)
}
}
+71 -17
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
@@ -17,8 +18,9 @@ import (
)
const (
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
)
// CardRequest is the input for sending a final AI card.
@@ -39,26 +41,38 @@ type CardSender struct {
streamURL string
apiClient *http.Client
allowTestURL bool
debugUserID string
mu sync.Mutex
token string
tokenTill time.Time
}
// CardSenderOption configures optional CardSender behavior.
type CardSenderOption func(*CardSender)
// WithCardDebugUserID enables DingTalk's official card debug entry only for
// the matching user through per-user privateData. A blank value keeps it off.
func WithCardDebugUserID(userID string) CardSenderOption {
return func(sender *CardSender) {
sender.debugUserID = strings.TrimSpace(userID)
}
}
// NewCardSender creates a CardSender. All three config values are required
// after trimming whitespace. A nil http.Client installs a hardened default
// (no redirects, Proxy=nil, 15s timeout).
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client) (*CardSender, error) {
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false)
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, options ...CardSenderOption) (*CardSender, error) {
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false, options...)
}
// newCardSenderForTest is used by tests to inject httptest URLs.
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string) (*CardSender, error) {
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string, options ...CardSenderOption) (*CardSender, error) {
streamURL := strings.TrimSuffix(createURL, "/create") + "/stream"
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true)
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true, options...)
}
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool) (*CardSender, error) {
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool, options ...CardSenderOption) (*CardSender, error) {
clientID = strings.TrimSpace(clientID)
clientSecret = strings.TrimSpace(clientSecret)
cardTemplateID = strings.TrimSpace(cardTemplateID)
@@ -91,7 +105,7 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
apiClient.Transport = transport
}
return &CardSender{
sender := &CardSender{
clientID: clientID,
clientSecret: clientSecret,
cardTemplateID: cardTemplateID,
@@ -100,7 +114,13 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
streamURL: streamURL,
apiClient: &apiClient,
allowTestURL: allowTest,
}, nil
}
for _, option := range options {
if option != nil {
option(sender)
}
}
return sender, nil
}
// SendFinalCard sends the AI card to DingTalk and returns the generated outTrackID.
@@ -134,9 +154,10 @@ func (s *CardSender) StartCard(ctx context.Context, req CardRequest) (string, er
return "", errors.New("dingtalk card: content exceeds 64 KiB")
}
// Validate identity fields
// Validate identity fields. ExternalUserID is also available for group
// askers and is used only for per-user card private data.
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
if req.ConversationType == core.ConversationDirect {
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
if req.ExternalUserID == "" {
return "", errors.New("dingtalk card: ExternalUserID is required for direct conversations")
}
@@ -186,6 +207,7 @@ func (s *CardSender) UpdateCard(ctx context.Context, outTrackID, content string,
if len(content) > 64*1024 {
return errors.New("dingtalk card: content exceeds 64 KiB")
}
log.Print(formatCardUpdateDiagnostic(content, isFinalize, isError))
token, err := s.accessToken(ctx)
if err != nil {
@@ -202,7 +224,11 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
"callbackType": "STREAM",
"cardData": map[string]any{
"cardParamMap": map[string]any{
"content": req.Content,
"content": req.Content,
"feedbackSelected": "",
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
},
},
"userIdType": 1,
@@ -228,6 +254,15 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
"extension": map[string]any{},
}
}
if s.debugUserID != "" && req.ExternalUserID == s.debugUserID {
body["privateData"] = map[string]any{
req.ExternalUserID: map[string]any{
"cardParamMap": map[string]any{
cardDebugPrivateKey: "show",
},
},
}
}
return body
}
@@ -272,9 +307,25 @@ func (s *CardSender) postCard(ctx context.Context, body map[string]any, token st
if err != nil {
return err
}
// Discard successful response body
if len(payload) > 0 {
// Body is already bounded by postJSON→readBoundedAPIResponse; just discard
return validateDingTalkBusinessResponse(payload)
}
func validateDingTalkBusinessResponse(payload []byte) error {
if len(bytes.TrimSpace(payload)) == 0 {
return nil
}
var response struct {
Success *bool `json:"success"`
ErrCode any `json:"errcode"`
}
if json.Unmarshal(payload, &response) != nil {
return nil
}
if response.Success != nil && !*response.Success {
return errors.New("dingtalk card: API business failure")
}
if response.ErrCode != nil && fmt.Sprint(response.ErrCode) != "0" {
return errors.New("dingtalk card: API business failure")
}
return nil
}
@@ -293,8 +344,11 @@ func (s *CardSender) finalizeCard(ctx context.Context, outTrackID, content, toke
"isFinalize": isFinalize,
"isError": isError,
}
_, err = s.doJSON(ctx, http.MethodPut, s.streamURL, body, token)
return err
payload, err := s.doJSON(ctx, http.MethodPut, s.streamURL, body, token)
if err != nil {
return err
}
return validateDingTalkBusinessResponse(payload)
}
func (s *CardSender) postJSON(ctx context.Context, endpoint string, value any, token string) ([]byte, error) {
@@ -0,0 +1,64 @@
package dingtalk
import (
"fmt"
"strings"
)
type cardContentShape struct {
bytes int
lines int
pipeLines int
leadingPipeLines int
delimiterLines int
confirmedTable bool
confirmedTablePos int
}
func inspectCardContent(content string) cardContentShape {
shape := cardContentShape{
bytes: len(content),
confirmedTablePos: -1,
}
if content == "" {
return shape
}
shape.lines = strings.Count(content, "\n") + 1
lines := splitMarkdownStreamLines(content)
for i, line := range lines {
if strings.Contains(line.text, "|") {
shape.pipeLines++
}
if isTableHeaderCandidate(line.text) {
shape.leadingPipeLines++
}
if !line.inFence && isCompleteTableDelimiter(line.text) {
shape.delimiterLines++
}
if shape.confirmedTable || i == 0 || line.inFence || lines[i-1].inFence {
continue
}
if isTableHeaderCandidate(lines[i-1].text) && isCompleteTableDelimiter(line.text) {
shape.confirmedTable = true
shape.confirmedTablePos = lines[i-1].start
}
}
return shape
}
func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string {
shape := inspectCardContent(content)
return fmt.Sprintf(
"DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d",
isFinalize,
isError,
shape.bytes,
shape.lines,
shape.pipeLines,
shape.leadingPipeLines,
shape.delimiterLines,
shape.confirmedTable,
shape.confirmedTablePos,
)
}
@@ -0,0 +1,93 @@
package dingtalk
import (
"bytes"
"context"
"fmt"
"log"
"strings"
"testing"
)
func TestCardSenderUpdateCardLogsOnlySafeContentShape(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest("client-id", "client-secret", "template-id", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
previousWriter := log.Writer()
previousFlags := log.Flags()
previousPrefix := log.Prefix()
log.SetOutput(&output)
log.SetFlags(0)
log.SetPrefix("")
t.Cleanup(func() {
log.SetOutput(previousWriter)
log.SetFlags(previousFlags)
log.SetPrefix(previousPrefix)
})
content := "DO_NOT_LOG_BOUNDARY_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
if err := sender.UpdateCard(context.Background(), "track-diagnostic", content, false, false); err != nil {
t.Fatal(err)
}
got := output.String()
if !strings.Contains(got, "DingTalk card update shape:") || !strings.Contains(got, "confirmed_table=true") || !strings.Contains(got, "finalize=false") {
t.Fatalf("missing safe boundary diagnostic: %q", got)
}
if strings.Contains(got, "DO_NOT_LOG_BOUNDARY_TEXT") || strings.Contains(got, "CR5") || strings.Contains(got, "track-diagnostic") {
t.Fatalf("boundary diagnostic leaked content or card identifier: %q", got)
}
}
func TestFormatCardUpdateDiagnosticReportsStructureWithoutContent(t *testing.T) {
content := "DO_NOT_LOG_ANSWER_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
got := formatCardUpdateDiagnostic(content, false, false)
wantFields := []string{
"DingTalk card update shape:",
"finalize=false",
"error=false",
fmt.Sprintf("bytes=%d", len(content)),
"lines=5",
"pipe_lines=3",
"leading_pipe_lines=3",
"delimiter_lines=1",
"confirmed_table=true",
fmt.Sprintf("table_start=%d", strings.Index(content, "| 型号")),
}
for _, field := range wantFields {
if !strings.Contains(got, field) {
t.Fatalf("diagnostic %q missing field %q", got, field)
}
}
if strings.Contains(got, "DO_NOT_LOG_ANSWER_TEXT") || strings.Contains(got, "CR5") {
t.Fatalf("diagnostic leaked answer content: %q", got)
}
}
func TestFormatCardUpdateDiagnosticDistinguishesFinalPlainText(t *testing.T) {
got := formatCardUpdateDiagnostic("普通回答", true, false)
wantFields := []string{
"finalize=true",
"error=false",
"lines=1",
"pipe_lines=0",
"leading_pipe_lines=0",
"delimiter_lines=0",
"confirmed_table=false",
"table_start=-1",
}
for _, field := range wantFields {
if !strings.Contains(got, field) {
t.Fatalf("diagnostic %q missing field %q", got, field)
}
}
if strings.Contains(got, "普通回答") {
t.Fatalf("diagnostic leaked answer content: %q", got)
}
}
+6
View File
@@ -39,6 +39,7 @@ func (p *cardUpdatePump) run(ctx context.Context, ticks <-chan time.Time, stopTi
if stopTicks != nil {
defer stopTicks()
}
lastSuccessful := ""
for {
select {
case <-ctx.Done():
@@ -52,9 +53,14 @@ func (p *cardUpdatePump) run(ctx context.Context, ticks <-chan time.Time, stopTi
default:
continue
}
if latest == lastSuccessful {
continue
}
if err := update(ctx, latest); err != nil {
log.Printf("DingTalk async card update failed, continuing")
continue
}
lastSuccessful = latest
}
}
}
+137 -1
View File
@@ -35,6 +35,7 @@ type cardTestServer struct {
createBodySize int64 // Content-Length for oversized response
streamStatus int
streamBodySize int64
streamResponse any
}
func newCardTestServer() *cardTestServer {
@@ -42,6 +43,7 @@ func newCardTestServer() *cardTestServer {
tokenStatus: http.StatusOK,
createStatus: http.StatusOK,
streamStatus: http.StatusOK,
streamResponse: map[string]any{"result": "ok"},
tokenResponse: map[string]any{"accessToken": "test-access-token", "expireIn": 3600},
createResponse: map[string]any{"result": "ok"},
}
@@ -91,7 +93,7 @@ func newCardTestServer() *cardTestServer {
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"result": "ok"})
json.NewEncoder(w).Encode(ts.streamResponse)
default:
http.NotFound(w, r)
}
@@ -218,6 +220,16 @@ func TestCardSendDirectPayload(t *testing.T) {
if paramMap["content"] != "Hello from AI" {
t.Errorf("content=%v", paramMap["content"])
}
for key, want := range map[string]any{
"feedbackSelected": "",
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
} {
if paramMap[key] != want {
t.Errorf("%s=%v, want %v", key, paramMap[key], want)
}
}
if body["userIdType"] != float64(1) {
t.Errorf("userIdType=%v", body["userIdType"])
}
@@ -261,6 +273,117 @@ func TestCardSendDirectPayload(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Test: official DingTalk card debug private data for one allowlisted user
// ---------------------------------------------------------------------------
func TestCardSendDirectAddsDebugPrivateDataForSelectedUser(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest(
"client-id",
"client-secret",
"card-template-123",
ts.Client(),
ts.URL()+"/token",
ts.URL()+"/create",
WithCardDebugUserID(" user-debug "),
)
if err != nil {
t.Fatal(err)
}
_, err = sender.SendFinalCard(context.Background(), CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-debug",
Content: "debug this card",
})
if err != nil {
t.Fatalf("SendFinalCard: %v", err)
}
privateData, ok := ts.lastCreateBody["privateData"].(map[string]any)
if !ok {
t.Fatal("privateData missing or wrong type")
}
userData, ok := privateData["user-debug"].(map[string]any)
if !ok {
t.Fatal("selected user's privateData missing or wrong type")
}
paramMap, ok := userData["cardParamMap"].(map[string]any)
if !ok {
t.Fatal("selected user's cardParamMap missing or wrong type")
}
if got := paramMap["_CARD_DEBUG_TOOL_ENTRY"]; got != "show" {
t.Fatalf("_CARD_DEBUG_TOOL_ENTRY=%v, want show", got)
}
if len(privateData) != 1 {
t.Fatalf("privateData has %d users, want 1", len(privateData))
}
}
func TestCardSendOmitsDebugPrivateDataForOtherUser(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest(
"client-id", "client-secret", "card-template-123",
ts.Client(), ts.URL()+"/token", ts.URL()+"/create",
WithCardDebugUserID("user-debug"),
)
if err != nil {
t.Fatal(err)
}
_, err = sender.SendFinalCard(context.Background(), CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-other",
Content: "ordinary card",
})
if err != nil {
t.Fatalf("SendFinalCard: %v", err)
}
if _, ok := ts.lastCreateBody["privateData"]; ok {
t.Fatal("privateData must be absent for a non-allowlisted user")
}
}
func TestCardSendGroupAddsDebugPrivateDataOnlyForAsker(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest(
"client-id", "client-secret", "card-template-123",
ts.Client(), ts.URL()+"/token", ts.URL()+"/create",
WithCardDebugUserID("user-debug"),
)
if err != nil {
t.Fatal(err)
}
_, err = sender.SendFinalCard(context.Background(), CardRequest{
ConversationType: core.ConversationGroup,
ExternalUserID: " user-debug ",
ExternalChatID: "group-789",
Content: "group debug card",
})
if err != nil {
t.Fatalf("SendFinalCard: %v", err)
}
privateData, ok := ts.lastCreateBody["privateData"].(map[string]any)
if !ok {
t.Fatal("privateData missing or wrong type")
}
if _, ok := privateData["user-debug"]; !ok {
t.Fatal("group asker's privateData missing")
}
if len(privateData) != 1 {
t.Fatalf("privateData has %d users, want 1", len(privateData))
}
}
// ---------------------------------------------------------------------------
// Test: group conversation full payload
// ---------------------------------------------------------------------------
@@ -848,6 +971,19 @@ func TestSendFinalCardCreateStreamOnce(t *testing.T) {
// Test: UpdateCard with non-final / error flags
// ---------------------------------------------------------------------------
func TestUpdateCardRejectsHTTP200BusinessFailure(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
ts.streamResponse = map[string]any{"success": false, "code": "InvalidParameter", "message": "sensitive detail"}
sender, err := newCardSenderForTest("client-id", "client-secret", "card-template-123", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
if err := sender.UpdateCard(context.Background(), "track-1", "answer", true, false); err == nil {
t.Fatal("HTTP 200 business failure must be rejected")
}
}
func TestUpdateCardNonFinalAndError(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
@@ -0,0 +1,31 @@
package dingtalk
import (
"regexp"
"strings"
)
const displayReferenceMarkerPattern = `\[\[ref[\t ]+id[\t ]*=[\t ]*["']c[0-9]+["'][\t ]*\]\]`
var (
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
leadingReferenceGroupColonRE = regexp.MustCompile(`(?m)^[ ]*(?:` + displayReferenceMarkerPattern + `[ ]*)+[::][ ]*`)
spacedReferenceGroupRE = regexp.MustCompile(`[ ]+(?:` + displayReferenceMarkerPattern + `[ ]*)+`)
spaceBeforeDisplayPunctuation = regexp.MustCompile(`[ ]+([,。;:!?、,.;:!?])`)
spaceAfterDisplayColon = regexp.MustCompile(`([::])[\t ]+`)
)
func sanitizeDisplayAnswer(content string) string {
if index := strings.LastIndex(content, "[[ref"); index >= 0 && !strings.Contains(content[index:], "]]") {
content = content[:index]
}
if !displayReferenceMarkerRE.MatchString(content) {
return content
}
content = leadingReferenceGroupColonRE.ReplaceAllString(content, "")
content = spacedReferenceGroupRE.ReplaceAllString(content, " ")
content = displayReferenceMarkerRE.ReplaceAllString(content, "")
content = spaceBeforeDisplayPunctuation.ReplaceAllString(content, "$1")
content = spaceAfterDisplayColon.ReplaceAllString(content, "$1")
return content
}

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