Compare commits

..

43 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
160 changed files with 10835 additions and 400 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.env
.env.*
!.env.example
bin/
.acceptance-canary/
.hermes/
__pycache__/
*.pyc
+28 -22
View File
@@ -1,46 +1,52 @@
# DobotAI configuration
# DobotAI 基础配置
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
DOBOTAI_INGRESS_TOKEN=replace-me
# Optional local-only audit API. Use a distinct secret from DOBOTAI_INGRESS_TOKEN.
# When set, DOBOTAI_TENANT_KEY must be set and the listen address must be loopback-only.
# 可选的仅本机审计 API。必须使用不同于 DOBOTAI_INGRESS_TOKEN 的独立密钥。
# 设置后必须同时设置 DOBOTAI_TENANT_KEY,且监听地址必须仅为回环地址。
DOBOTAI_ADMIN_TOKEN=
DOBOTAI_GROUP_SESSION_POLICY=shared
DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"replace-with-agent-id"}
# `/think` 路由必填。上面的基础绑定保持稳定的会话身份;以下配置仅选择每个 Turn 的执行 Agent。
DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer
DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning
# Official WeKnora API only
# 仅使用 WeKnora 官方 API
WEKNORA_BASE_URL=http://127.0.0.1:8080
WEKNORA_API_KEY=replace-me
# DingTalk Stream mode (official SDK). Leave all three empty to disable the
# DingTalk channel; set all three to enable it. Never commit real values.
# 钉钉 Stream 模式(官方 SDK)。三项均留空时禁用钉钉渠道;三项同时设置时启用。不得提交真实值。
DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET=
# Tenant key this DingTalk ingress serves (matches tenant_key used by QA).
# 该钉钉接入服务的租户键(必须与 QA 使用的 tenant_key 一致)。
DOBOTAI_TENANT_KEY=
# Optional DingTalk AI card template ID. Leave empty to use markdown-only replies.
# When set, a successful QA answer will be delivered as an AI card; markdown
# is only sent as a fallback when card delivery fails. Never commit real values.
# 可选的钉钉 AI 卡片模板 ID。留空时仅使用 Markdown 回复。
# 设置后,成功的 QA 回答优先通过 AI 卡片发送;仅当卡片发送失败时才回退 Markdown。不得提交真实值。
DINGTALK_CARD_TEMPLATE_ID=
# Optional, temporary DingTalk card-debug allowlist. When set, only this exact
# DingTalk user receives _CARD_DEBUG_TOOL_ENTRY=show in per-user privateData.
# Keep empty outside a controlled debug session; never commit a real user ID.
# 可选、临时的钉钉卡片调试白名单。设置后,只有该指定钉钉用户会在 per-user privateData 中收到 _CARD_DEBUG_TOOL_ENTRY=show。
# 非受控调试期间必须留空;不得提交真实用户 ID。
DINGTALK_CARD_DEBUG_USER_ID=
# Optional DingTalk FAQ table workflow for uncovered questions. Set all four
# values together; leave all four empty to disable. Required when the support
# group notification below is enabled. Never commit real IDs.
# 可选的钉钉 FAQ 表工作流,用于未覆盖问题。四项必须同时设置;四项均留空时禁用。
# 启用下方人工支持群通知时,此工作流为必需项。不得提交真实 ID。
DINGTALK_FAQ_TABLE_BASE_ID=
DINGTALK_FAQ_TABLE_SHEET_ID=
DINGTALK_FAQ_TABLE_VIEW_ID=
DINGTALK_FAQ_TABLE_OPERATOR_ID=
# Optional support group for uncovered-answer notifications. This is the
# group's openConversationId, not a webhook. Leave empty to record coverage
# decisions and pending support cases without sending group notifications.
# When enabled, the FAQ table workflow above must also be configured so the
# ActionCard can link to the exact FAQ record. Never commit a real group ID.
# 可选的钉钉卡片回答图片外发。任一项设置后,以下五项都必须设置。
# 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=
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
# 独立的 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"]
+204 -67
View File
@@ -1,103 +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`.
- v1 knowledge coverage decisions, durable support cases, and optional DingTalk support-group notifications.
## 架构边界
## 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.
- Optional `DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID` enables durable uncovered-answer ActionCard notifications through the official group-message API. It requires the FAQ table workflow below so every “去回答” button targets an exact record. When empty, coverage decisions and support cases are still recorded but no group message is sent.
责任边界:
## DingTalk question table sync (Notable v1)
- **DobotAI**:钉钉协议、媒体收发、会话映射、命令路由、卡片、覆盖判定、人工支持、渠道审计。
- **WeKnora**:通用 Agent Chat、检索、知识存储、引用与模型调用。
- **DobotAI PostgreSQL**:只保存渠道与业务编排数据,不保存 WeKnora 知识正文。
- **Redis**:仅用于队列、重试、幂等租约和短期状态。
Successful DingTalk QA turns can be appended asynchronously to a DingTalk Notable AI table through a durable PostgreSQL outbox. Notable failures never change the successful QA result; the worker retries with bounded backoff and stores only stable failure codes.
## 目录结构
Apply `db/migrations/000010_question_table_exports.up.sql` before enabling the worker. The three table settings are optional as a group: all blank disables dispatch, while a partial configuration fails startup closed.
```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 项目功能、边界和开发说明
```
- `DINGTALK_QUESTION_TABLE_BASE_ID`
- `DINGTALK_QUESTION_TABLE_SHEET_ID`
- `DINGTALK_QUESTION_TABLE_OPERATOR_ID` (DingTalk unionId)
目录职责规则:`cmd/`只装配进程,`internal/`承载业务模块,`internal/store/postgres/`只实现数据库持久化,`db/migrations/`只放Schema变更,`deploy/`只放容器运行资产。历史计划、备份、镜像归档和真实环境文件均不进入Git。
The worker reuses `DINGTALK_CLIENT_ID` and `DINGTALK_CLIENT_SECRET`. The DingTalk application must have `Notable.Base.Read.All` and `Notable.Base.Write.All`. Only successful `channel=dingtalk` turns are enqueued; other channels are excluded. The first release writes the nine basic text/date/user fields and omits the five select-classification fields.
## 部署方式
## Uncovered-question FAQ workflow
| 方式 | 适用场景 | 入口 | 数据库与迁移 | 推荐程度 |
| --- | --- | --- | --- | --- |
| 源码/原生二进制 | 本地开发、快速调试、临时问题定位 | `go run ./cmd/dobotai` 或受控构建的二进制 | 操作人员准备独立PostgreSQL并显式执行迁移 | 仅开发 |
| Docker Compose | 测试、预发布、生产候选 | 仓库根目录`install-docker.sh` | 脚本构建镜像、创建`dobotai-postgres`和`dobotai-postgres-data`,并按序执行迁移 | 推荐 |
Apply `db/migrations/000011_faq_exports.up.sql` before enabling this workflow. Existing support cases are marked `not_applicable` and are not backfilled; only support cases created after the migration default to `pending` FAQ export.
### 源码/原生部署
Set all four values together, or leave all four blank to disable FAQ export. Partial configuration fails startup closed:
```bash
# 开发机:使用Git外的环境文件注入真实配置
go run ./cmd/dobotai
curl http://127.0.0.1:8090/healthz
```
- `DINGTALK_FAQ_TABLE_BASE_ID`
- `DINGTALK_FAQ_TABLE_SHEET_ID`
- `DINGTALK_FAQ_TABLE_VIEW_ID`
- `DINGTALK_FAQ_TABLE_OPERATOR_ID` (DingTalk unionId)
源码方式不自动创建数据库、不管理密钥目录、不自动执行SQL迁移,也不适合作为长期测试或生产运行方式。
For every newly detected uncovered answer, the asynchronous worker creates one FAQ record with standard question, original question, detected language, and asker. After DingTalk returns the `recordId`, DobotAI stores it and sends a built-in `sampleActionCard` containing only the current question and up to five earlier questions from the same stable actor. The “去回答” button opens that exact FAQ record. FAQ insert failures are retried with bounded backoff and never block the customer reply.
### Docker Compose 部署
## WeKnora baseline
```bash
# 克隆后进入仓库根目录执行:自动安装Docker/Compose(如缺失)、构建镜像、
# 准备目录、独立PostgreSQL、命名卷、env模板和迁移。
sudo bash ./install-docker.sh
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.
# 配置WeKnora和钉钉后自动启动应用
sudo dobotai-dingtalk-configure
## Run
# 仅首次且没有有效管理员时执行
sudo dobotai-dingtalk-bind-admin
```
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:
完整的安装、现有数据库迁移、备份、回滚和验收说明见 [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`。
+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)
}
}
+4 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"os"
"regexp"
"strings"
"testing"
)
@@ -15,10 +16,12 @@ func TestMainWiresDirectoryResolverIntoDingTalkHandler(t *testing.T) {
for _, required := range []string{
"directory.NewDingTalkLookup",
"directory.NewResolver",
"Directory: directoryResolver",
} {
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")
}
}
+6 -5
View File
@@ -13,10 +13,11 @@ import (
)
type faqConfig struct {
BaseID string
SheetID string
ViewID string
OperatorID string
BaseID string
SheetID string
ViewID string
OperatorID string
FieldMapping map[string]string
}
func readFAQConfig() (faqConfig, error) {
@@ -46,7 +47,7 @@ func buildFAQDispatcher(repo faqtable.ExportRepository, clientID, clientSecret s
}
client, err := questiontable.NewClient(questiontable.Config{
ClientID: clientID, Secret: clientSecret, BaseID: cfg.BaseID,
SheetID: cfg.SheetID, OperatorID: cfg.OperatorID,
SheetID: cfg.SheetID, OperatorID: cfg.OperatorID, FieldMapping: cfg.FieldMapping,
}, nil)
if err != nil {
return nil, err
+144 -11
View File
@@ -8,6 +8,7 @@ import (
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
@@ -20,14 +21,36 @@ import (
"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"))
@@ -35,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)
@@ -43,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
@@ -62,35 +106,43 @@ func main() {
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)
}
address := strings.TrimSpace(os.Getenv("DOBOTAI_LISTEN_ADDR"))
if address == "" {
address = "127.0.0.1:8090"
}
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 != "" {
@@ -109,43 +161,86 @@ func main() {
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,
os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID"),
supportConversationID,
faqRecordLinks,
)
if err != nil {
log.Fatalf("DingTalk support notification init failed: %v", err)
}
if supportDispatcher != nil {
go runSupportDispatcher(ctx, supportDispatcher, 5*time.Second)
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)
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")
@@ -204,7 +299,16 @@ func main() {
dingtalk.TenantConfig{TenantKey: dingCfg.TenantKey, Channel: "dingtalk"},
qaService,
sender,
dingtalk.HandlerOptions{ImageDownloader: downloader, CardSender: cardSender, Directory: directoryResolver},
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)
@@ -217,9 +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)
}
server := &http.Server{Addr: address, Handler: api.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
close(runtimeReady)
rootHandler := http.NewServeMux()
rootHandler.Handle("/", api.Handler())
if outboundImages.handler != nil {
rootHandler.Handle(outboundImageRoute, outboundImages.handler)
}
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) {
@@ -247,6 +361,25 @@ func main() {
log.Printf("question table poller shutdown timed out")
}
}
if streamClient != nil {
streamClient.Close()
}
if restartRequested.Load() {
os.Exit(75)
}
}
func outboundOSSConfigured() bool {
for _, key := range []string{
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT", "DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID", "DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET", "DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL",
} {
if strings.TrimSpace(os.Getenv(key)) == "" {
return false
}
}
return true
}
func buildAuditOptions(repo audit.TurnRepository, tenantKey, adminToken, address string) (httpapi.Options, error) {
+98
View File
@@ -21,6 +21,20 @@ func (r *auditRepoStub) GetTurn(context.Context, string, string) (audit.TurnDeta
return audit.TurnDetail{}, audit.ErrNotFound
}
func TestRuntimeBuildInfoUsesLinkerVariables(t *testing.T) {
oldVersion, oldCommitSHA, oldBuildTime := version, commitSHA, buildTime
t.Cleanup(func() {
version, commitSHA, buildTime = oldVersion, oldCommitSHA, oldBuildTime
})
version = "2.1.0"
commitSHA = "abc123def456"
buildTime = "2026-07-27T12:00:00Z"
got := runtimeBuildInfo()
if got.Version != version || got.CommitSHA != commitSHA || got.BuildTime != buildTime {
t.Fatalf("build info=%+v", got)
}
}
func TestBuildAuditOptionsDisabledIsBackwardCompatible(t *testing.T) {
opts, err := buildAuditOptions(nil, "", "", "0.0.0.0:8090")
if err != nil {
@@ -101,3 +115,87 @@ func TestMainWiresOptionalDingTalkCardDebugUser(t *testing.T) {
}
}
}
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
}
+34 -12
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
@@ -19,9 +20,10 @@ type questionTablePollerRunner interface {
// questionTableConfig holds the optional Notable question-table env values.
type questionTableConfig struct {
BaseID string
SheetID string
OperatorID string
BaseID string
SheetID string
OperatorID string
FieldMapping map[string]string
}
// readQuestionTableConfig reads the three dedicated env vars and returns
@@ -48,9 +50,11 @@ func readQuestionTableConfig() (questionTableConfig, error) {
// buildQuestionTableDispatcher creates a validated question-table dispatcher
// when the repo is non-nil and all required credentials are present.
// No HTTP request occurs at build time. It returns (nil, nil) when the
// question-table feature is disabled (empty config).
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig) (*questiontable.Dispatcher, error) {
// 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
@@ -61,17 +65,35 @@ func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID,
}
client, err := questiontable.NewClient(questiontable.Config{
ClientID: clientID,
Secret: clientSecret,
BaseID: cfg.BaseID,
SheetID: cfg.SheetID,
OperatorID: cfg.OperatorID,
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, nil)
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.
+5 -5
View File
@@ -32,7 +32,7 @@ func TestReadQuestionTableConfigDisabledAllBlank(t *testing.T) {
}
func TestBuildQuestionTableDispatcherDisabled(t *testing.T) {
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{})
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -87,7 +87,7 @@ func TestReadQuestionTableConfigPartialErrors(t *testing.T) {
func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg)
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg, nil)
if err == nil {
t.Fatal("expected error when repo is nil")
}
@@ -99,7 +99,7 @@ func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg)
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg, nil)
if err == nil {
t.Fatal("expected error when client id is empty")
}
@@ -108,7 +108,7 @@ func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg)
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg, nil)
if err == nil {
t.Fatal("expected error when secret is empty")
}
@@ -117,7 +117,7 @@ func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
func TestBuildQuestionTableDispatcherEnabledCompleteConfig(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg)
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+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
}
+1 -1
View File
@@ -25,7 +25,7 @@ func buildSupportDispatcher(repo coverage.NotificationRepository, clientID, clie
return nil, errors.New("support notification repository is required")
}
if recordLinks == nil {
return nil, errors.New("support notification FAQ record links are required")
return nil, nil
}
sender, err := dingtalk.NewSupportGroupSender(clientID, clientSecret, openConversationID, nil, dingtalk.WithFAQRecordLinkBuilder(recordLinks))
+9 -2
View File
@@ -62,6 +62,13 @@ func TestBuildSupportDispatcherDisabledWithoutGroup(t *testing.T) {
}
}
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)
@@ -74,7 +81,6 @@ func TestBuildSupportDispatcherEnabledFailsClosed(t *testing.T) {
{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},
{name: "missing FAQ links", repo: repo, clientID: "client", secret: "secret", gid: "group"},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := buildSupportDispatcher(tc.repo, tc.clientID, tc.secret, tc.gid, tc.links); err == nil {
@@ -163,7 +169,8 @@ func TestMainWiresCoverageSupportNotifications(t *testing.T) {
`os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID")`,
`coverage.NewService(store)`,
`qa.WithSucceededTurnObserver`,
`go runSupportDispatcher(ctx, supportDispatcher, 5*time.Second)`,
`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 @@
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 -50
View File
@@ -1,74 +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/coverage`: v1 covered/uncovered evaluation, durable support-case dispatch, finite retries, and stable failure codes. Notification failures never change a successful QA result.
- `internal/channels/dingtalk`: DingTalk Stream ingress (official SDK), text-only robot callbacks, fail-closed session webhook replies (`https`, `*.dingtalk.com`, no redirects). Wired in `cmd/dobotai`; enabled only when `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, and `DOBOTAI_TENANT_KEY` are all set. Group messages require an @-mention; direct chats map to one long-lived session per user, groups to one shared session per group. Images, OpenAPI fallback, and card replies are not implemented yet.
- future `channels/*`: platform callbacks and outbound replies.
- future `content-sync/*`: DingTalk KB, NAS, and CMS synchronization.
- 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"
+26 -5
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
@@ -206,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 {
@@ -305,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
}
@@ -326,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
}
}
}
+16 -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)
}
@@ -969,6 +971,19 @@ func TestSendFinalCardCreateStreamOnce(t *testing.T) {
// Test: UpdateCard with non-final / error flags
// ---------------------------------------------------------------------------
func TestUpdateCardRejectsHTTP200BusinessFailure(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
ts.streamResponse = map[string]any{"success": false, "code": "InvalidParameter", "message": "sensitive detail"}
sender, err := newCardSenderForTest("client-id", "client-secret", "card-template-123", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
if err := sender.UpdateCard(context.Background(), "track-1", "answer", true, false); err == nil {
t.Fatal("HTTP 200 business failure must be rejected")
}
}
func TestUpdateCardNonFinalAndError(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
@@ -0,0 +1,31 @@
package dingtalk
import (
"regexp"
"strings"
)
const displayReferenceMarkerPattern = `\[\[ref[\t ]+id[\t ]*=[\t ]*["']c[0-9]+["'][\t ]*\]\]`
var (
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
leadingReferenceGroupColonRE = regexp.MustCompile(`(?m)^[ ]*(?:` + displayReferenceMarkerPattern + `[ ]*)+[::][ ]*`)
spacedReferenceGroupRE = regexp.MustCompile(`[ ]+(?:` + displayReferenceMarkerPattern + `[ ]*)+`)
spaceBeforeDisplayPunctuation = regexp.MustCompile(`[ ]+([,。;:!?、,.;:!?])`)
spaceAfterDisplayColon = regexp.MustCompile(`([::])[\t ]+`)
)
func sanitizeDisplayAnswer(content string) string {
if index := strings.LastIndex(content, "[[ref"); index >= 0 && !strings.Contains(content[index:], "]]") {
content = content[:index]
}
if !displayReferenceMarkerRE.MatchString(content) {
return content
}
content = leadingReferenceGroupColonRE.ReplaceAllString(content, "")
content = spacedReferenceGroupRE.ReplaceAllString(content, " ")
content = displayReferenceMarkerRE.ReplaceAllString(content, "")
content = spaceBeforeDisplayPunctuation.ReplaceAllString(content, "$1")
content = spaceAfterDisplayColon.ReplaceAllString(content, "$1")
return content
}
@@ -0,0 +1,66 @@
package dingtalk
import "testing"
func TestSanitizeDisplayAnswerRemovesInternalReferenceMarkers(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "adjacent inline markers",
input: "结论支持该功能[[ref id=\"c2\"]][[ref id=\"c4\"]]。",
want: "结论支持该功能。",
},
{
name: "leading marker group and chinese colon",
input: "[[ref id=\"c2\"]][[ref id=\"c4\"]]:建议先检查配置。",
want: "建议先检查配置。",
},
{
name: "leading marker group and ascii colon on second line",
input: "第一段。\n[[ref id='c12']] : Second paragraph.",
want: "第一段。\nSecond paragraph.",
},
{
name: "normal punctuation remains natural",
input: "标题:[[ref id=\"c1\"]] 详细说明",
want: "标题:详细说明",
},
{
name: "spaces around marker collapse to one",
input: "Feature A [[ref id=\"c3\"]] remains supported.",
want: "Feature A remains supported.",
},
{
name: "unrelated double bracket syntax is preserved",
input: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
want: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
},
{
name: "content without references remains byte for byte unchanged",
input: "Protocol: value ; keep spacing\n indented",
want: "Protocol: value ; keep spacing\n indented",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sanitizeDisplayAnswer(tt.input); got != tt.want {
t.Fatalf("sanitizeDisplayAnswer()=%q, want %q", got, tt.want)
}
})
}
}
func TestSanitizeDisplayAnswerHoldsBackDanglingStreamingReference(t *testing.T) {
for _, input := range []string{
"答案正文[[ref",
"答案正文[[ref id=\"c",
"答案正文[[ref id=\"c2\"",
} {
if got := sanitizeDisplayAnswer(input); got != "答案正文" {
t.Fatalf("sanitizeDisplayAnswer(%q)=%q", input, got)
}
}
}
@@ -0,0 +1,33 @@
package dingtalk
import (
"testing"
)
func TestEffectiveDownloadCodesDeduplicatesAndPreservesOrder(t *testing.T) {
cb := BotCallback{
DownloadCode: "code-first",
DownloadCodes: []string{"code-first", "code-second", " code-third "},
}
codes := effectiveDownloadCodes(cb)
if len(codes) != 3 {
t.Fatalf("codes=%v, want 3", codes)
}
if codes[0] != "code-first" || codes[1] != "code-second" || codes[2] != "code-third" {
t.Fatalf("codes=%v, want ordered de-dup", codes)
}
}
func TestEffectiveDownloadCodesSingleOnly(t *testing.T) {
cb := BotCallback{DownloadCode: " solo "}
codes := effectiveDownloadCodes(cb)
if len(codes) != 1 || codes[0] != "solo" {
t.Fatalf("codes=%v, want [solo]", codes)
}
}
func TestEffectiveDownloadCodesEmpty(t *testing.T) {
if codes := effectiveDownloadCodes(BotCallback{}); len(codes) != 0 {
t.Fatalf("codes=%v, want empty", codes)
}
}
+54 -2
View File
@@ -187,9 +187,61 @@ func (s *SupportGroupSender) Send(ctx context.Context, notif coverage.SupportNot
return nil
}
// SendTest sends a fixed, non-sensitive message used to verify that the
// configured robot can reach the candidate support group. It deliberately
// does not require an FAQ-table binding.
func (s *SupportGroupSender) SendTest(ctx context.Context) error {
msgParam, err := json.Marshal(map[string]string{
"title": "DobotAI 技术支持群绑定测试",
"text": "DobotAI 技术支持群绑定测试成功。",
})
if err != nil {
return errors.New("dingtalk group sender: encode test message failed")
}
body := map[string]any{
"msgKey": "sampleMarkdown",
"msgParam": string(msgParam),
"openConversationId": s.openConversation,
"robotCode": s.clientID,
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return errors.New("dingtalk group sender: encode test request failed")
}
if len(bodyJSON) > maxGroupPayloadBytes {
return fmt.Errorf("dingtalk group sender: payload exceeds %d bytes", maxGroupPayloadBytes)
}
token, err := s.accessToken(ctx)
if err != nil {
return err
}
respPayload, err := s.doJSON(ctx, http.MethodPost, s.groupURL, body, token)
if err != nil {
return err
}
var response struct {
ProcessQueryKey string `json:"processQueryKey"`
ErrCode int `json:"errcode"`
}
if json.Unmarshal(respPayload, &response) != nil {
return errors.New("dingtalk group sender: invalid API response")
}
if response.ErrCode != 0 {
return errors.New("dingtalk group sender: API returned business error")
}
if strings.TrimSpace(response.ProcessQueryKey) == "" {
return errors.New("dingtalk group sender: incomplete API response")
}
return nil
}
// renderMarkdown produces the compact customer-question context shown in the action card.
func (s *SupportGroupSender) renderMarkdown(notif coverage.SupportNotification) (title, text string) {
title = "知识库未覆盖提醒"
label := "知识库未覆盖提醒"
if notif.TriggerType == coverage.SupportTriggerFeedbackDown {
label = "回答被点踩提醒"
}
title = label
question := escapeMarkdown(truncateRunes(notif.Question, questionRuneLimit))
sender := escapeMarkdown(notif.SenderName)
askedAt := "未知"
@@ -198,7 +250,7 @@ func (s *SupportGroupSender) renderMarkdown(notif coverage.SupportNotification)
}
var b strings.Builder
b.WriteString("## 【知识库未覆盖提醒】\n\n")
b.WriteString("## 【" + label + "】\n\n")
b.WriteString(fmt.Sprintf("**提问人:** %s\n\n", sender))
b.WriteString(fmt.Sprintf("**时间:** %s\n\n", askedAt))
b.WriteString(fmt.Sprintf("**当前问题:**\n> %s\n\n", question))
@@ -539,6 +539,38 @@ func TestSupportGroupSenderNoPreviousQuestions(t *testing.T) {
}
}
func TestSupportGroupSenderSendTestDoesNotRequireFAQBinding(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
sender, err := newSupportGroupSenderForTest(
"appKey", "appSecret", "cid-abc",
gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send",
)
if err != nil {
t.Fatal(err)
}
if err = sender.SendTest(context.Background()); err != nil {
t.Fatalf("SendTest: %v", err)
}
if gs.sendCalls != 1 {
t.Fatalf("sendCalls=%d want=1", gs.sendCalls)
}
if gs.lastSendBody["openConversationId"] != "cid-abc" || gs.lastSendBody["robotCode"] != "appKey" {
t.Fatalf("body=%v", gs.lastSendBody)
}
if gs.lastSendBody["msgKey"] != "sampleMarkdown" {
t.Fatalf("msgKey=%v want=sampleMarkdown", gs.lastSendBody["msgKey"])
}
var params map[string]string
if err = json.Unmarshal([]byte(gs.lastSendBody["msgParam"].(string)), &params); err != nil {
t.Fatal(err)
}
if params["title"] != "DobotAI 技术支持群绑定测试" || !strings.Contains(params["text"], "测试成功") {
t.Fatalf("params=%v", params)
}
}
func TestSupportGroupSenderRejectsMalformedOrIncompleteSuccessResponse(t *testing.T) {
tests := []struct {
name string
@@ -0,0 +1,24 @@
package dingtalk
import (
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
)
func TestSupportGroupSenderRendersFeedbackDownTrigger(t *testing.T) {
notification := newTestNotification()
notification.TriggerType = coverage.SupportTriggerFeedbackDown
title, text := (&SupportGroupSender{}).renderMarkdown(notification)
if title != "回答被点踩提醒" || !strings.Contains(text, "【回答被点踩提醒】") {
t.Fatalf("title=%q text=%q", title, text)
}
}
func TestSupportGroupSenderDefaultsHistoricalTriggerToCoverage(t *testing.T) {
title, text := (&SupportGroupSender{}).renderMarkdown(newTestNotification())
if title != "知识库未覆盖提醒" || !strings.Contains(text, "【知识库未覆盖提醒】") {
t.Fatalf("title=%q text=%q", title, text)
}
}
+301 -15
View File
@@ -2,6 +2,7 @@ package dingtalk
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log"
@@ -10,6 +11,7 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
@@ -45,6 +47,7 @@ type BotCallback struct {
SessionWebhook string
Text string
DownloadCode string
DownloadCodes []string
}
type QAAsker interface {
@@ -75,10 +78,66 @@ type ImageDownloader interface {
Download(ctx context.Context, downloadCode string) (core.ImageInput, error)
}
// ImagePublisher publishes a downloaded image to object storage and returns a
// stable HTTPS URL. Implementation satisfies outboundimage.Publisher.
type ImagePublisher interface {
Publish(ctx context.Context, data []byte, contentType string) (string, error)
}
type UserDirectory interface {
ResolveUser(ctx context.Context, tenantKey, staffID, fallbackName string) (directory.Profile, error)
}
type OutboundContentRewriter interface {
Rewrite(ctx context.Context, content string) string
}
type OutboundPartialSanitizer interface {
SanitizePartial(content string) string
}
type SettingsMessage struct {
TenantKey string
Channel string
StaffID string
UnionID string
DisplayName string
Department string
Text string
ConversationType string
ConversationID string
ConversationTitle string
}
type SettingsRouter interface {
Handle(context.Context, SettingsMessage) (handled bool, reply string, err error)
}
type ReasoningMessage struct {
TenantKey string
Channel string
StaffID string
Text string
ConversationID string
}
type ReasoningDecision struct {
Handled bool
Reply string
Query string
AgentID string
Execution reasoningmode.ExecutionMode
Expired bool
}
type ReasoningRouter interface {
Prepare(context.Context, ReasoningMessage) (ReasoningDecision, error)
}
type LanguagePreferenceResolver interface {
GetPreference(context.Context, string, string, string) (string, error)
}
// FinalCardSender sends an AI card as the final reply. Implementations must
// not log err, outTrackID, or content at any level.
type FinalCardSender interface {
@@ -93,9 +152,14 @@ type StreamingCardSender interface {
}
type HandlerOptions struct {
ImageDownloader ImageDownloader
CardSender FinalCardSender
Directory UserDirectory
ImageDownloader ImageDownloader
ImagePublisher ImagePublisher
CardSender FinalCardSender
Directory UserDirectory
OutboundRewriter OutboundContentRewriter
SettingsRouter SettingsRouter
ReasoningRouter ReasoningRouter
LanguageResolver LanguagePreferenceResolver
}
type Handler struct {
@@ -103,8 +167,13 @@ type Handler struct {
qa QAAsker
reply MarkdownSender
downloader ImageDownloader
imagePublisher ImagePublisher
card FinalCardSender
directory UserDirectory
outboundRewriter OutboundContentRewriter
settingsRouter SettingsRouter
reasoningRouter ReasoningRouter
languageResolver LanguagePreferenceResolver
now func() time.Time
cardUpdateInterval time.Duration
finalDeliveryTimeout time.Duration
@@ -144,8 +213,13 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
qa: qa,
reply: reply,
downloader: opts.ImageDownloader,
imagePublisher: opts.ImagePublisher,
card: opts.CardSender,
directory: opts.Directory,
outboundRewriter: opts.OutboundRewriter,
settingsRouter: opts.SettingsRouter,
reasoningRouter: opts.ReasoningRouter,
languageResolver: opts.LanguageResolver,
now: time.Now,
cardUpdateInterval: 500 * time.Millisecond,
finalDeliveryTimeout: defaultHTTPTimeout,
@@ -198,6 +272,24 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
triggerPerson = senderName
}
if h.settingsRouter != nil && strings.EqualFold(strings.TrimSpace(cb.Msgtype), msgTypeText) {
handled, settingsReply, err := h.settingsRouter.Handle(ctx, SettingsMessage{
TenantKey: h.cfg.TenantKey, Channel: h.cfg.Channel, StaffID: externalUserID,
UnionID: actorUnionID, DisplayName: senderName, Department: department, Text: strings.TrimSpace(cb.Text),
ConversationType: mapDingTalkConversationType(cb.ConversationType),
ConversationID: strings.TrimSpace(cb.ConversationID), ConversationTitle: strings.TrimSpace(cb.ConversationTitle),
})
if err != nil {
return fmt.Errorf("dingtalk settings: %w", err)
}
if handled {
if strings.TrimSpace(settingsReply) == "" {
return nil
}
return h.sendReply(ctx, webhookURL, settingsReply)
}
}
req := core.QARequest{
TenantKey: h.cfg.TenantKey,
Channel: h.cfg.Channel,
@@ -211,6 +303,11 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
MessageType: strings.ToLower(strings.TrimSpace(cb.Msgtype)),
ConversationTitle: strings.TrimSpace(cb.ConversationTitle),
}
if h.languageResolver != nil {
if locale, err := h.languageResolver.GetPreference(ctx, h.cfg.TenantKey, h.cfg.Channel, externalUserID); err == nil {
req.ResponseLocale = strings.TrimSpace(locale)
}
}
switch cb.ConversationType {
case conversationTypeGroup:
if strings.TrimSpace(cb.ConversationID) == "" {
@@ -230,7 +327,8 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
return errors.New("dingtalk handler: text message content is empty")
}
case msgTypePicture:
if strings.TrimSpace(cb.DownloadCode) == "" {
codes := effectiveDownloadCodes(cb)
if len(codes) == 0 {
log.Printf("DingTalk image preparation failed: stage=missing_download_code")
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
@@ -238,20 +336,49 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
log.Printf("DingTalk image preparation failed: stage=downloader_unavailable")
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
image, err := h.downloader.Download(ctx, strings.TrimSpace(cb.DownloadCode))
if err != nil {
log.Printf("DingTalk image preparation failed: stage=%s", imageFailureStage(err))
return h.sendReply(ctx, webhookURL, imageErrorReply)
images := make([]core.ImageInput, 0, len(codes))
for _, code := range codes {
image, err := h.downloader.Download(ctx, code)
if err != nil {
log.Printf("DingTalk image preparation failed: stage=%s", imageFailureStage(err))
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
if h.imagePublisher != nil {
if url := h.publishQuestionImage(ctx, image); url != "" {
image.PublishedURL = url
}
}
images = append(images, image)
}
req.Query = strings.TrimSpace(cb.Text)
if req.Query == "" {
req.Query = imageQuery
}
req.Images = []core.ImageInput{image}
req.Images = images
default:
return h.sendReply(ctx, webhookURL, unsupportedMessageReply)
}
if h.reasoningRouter != nil {
decision, err := h.reasoningRouter.Prepare(ctx, ReasoningMessage{
TenantKey: h.cfg.TenantKey, Channel: h.cfg.Channel, StaffID: externalUserID,
Text: req.Query, ConversationID: req.ExternalChatID,
})
if err != nil {
return fmt.Errorf("dingtalk reasoning mode: %w", err)
}
if decision.Handled {
if strings.TrimSpace(decision.Reply) == "" {
return nil
}
return h.sendReply(ctx, webhookURL, decision.Reply)
}
req.Query = strings.TrimSpace(decision.Query)
req.AgentIDOverride = strings.TrimSpace(decision.AgentID)
req.ReasoningExecution = string(decision.Execution)
req.ReasoningExpired = decision.Expired
}
if eventCard, eventQA := h.eventStreamingCapable(); eventCard != nil && eventQA != nil {
return h.handleEventStreaming(ctx, webhookURL, req, eventCard, eventQA)
}
@@ -263,6 +390,13 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
return h.handleNonStreaming(ctx, webhookURL, req)
}
func mapDingTalkConversationType(value string) string {
if value == conversationTypeGroup {
return "group"
}
return "direct"
}
func (h *Handler) eventStreamingCapable() (StreamingCardSender, EventQAAsker) {
sc, ok := h.card.(StreamingCardSender)
if !ok {
@@ -290,6 +424,7 @@ func (h *Handler) streamingCapable() (StreamingCardSender, StreamingQAAsker) {
func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req core.QARequest) error {
result, err := h.qa.Ask(ctx, req)
if err != nil {
logQAFailure("non_streaming", err)
if replyErr := h.sendReply(ctx, webhookURL, qaErrorReply); replyErr != nil {
return replyErr
}
@@ -298,13 +433,14 @@ func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req
if result.Replayed && result.DeliveryStatus == core.DeliverySent {
return nil
}
content := h.rewriteOutboundContent(ctx, result.Answer)
if h.card != nil {
cardReq := CardRequest{
ConversationType: req.ConversationType,
ExternalUserID: req.ExternalUserID,
ExternalChatID: req.ExternalChatID,
Content: result.Answer,
Content: content,
}
if outTrackID, cardErr := h.card.SendFinalCard(ctx, cardReq); cardErr == nil {
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodCard, outTrackID, "")
@@ -312,7 +448,7 @@ func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req
}
log.Printf("DingTalk card delivery failed, falling back to markdown")
}
if err := h.sendReply(ctx, webhookURL, result.Answer); err != nil {
if err := h.sendReply(ctx, webhookURL, content); err != nil {
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
return err
}
@@ -333,6 +469,7 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
// Fallback: Ask → Markdown only (no SendFinalCard, no AskStream)
result, askErr := h.qa.Ask(ctx, req)
if askErr != nil {
logQAFailure("streaming_fallback", askErr)
if replyErr := h.sendReply(ctx, webhookURL, qaErrorReply); replyErr != nil {
return replyErr
}
@@ -341,7 +478,8 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
if result.Replayed && result.DeliveryStatus == core.DeliverySent {
return nil
}
if replyErr := h.sendReply(ctx, webhookURL, result.Answer); replyErr != nil {
content := h.rewriteOutboundContent(ctx, result.Answer)
if replyErr := h.sendReply(ctx, webhookURL, content); replyErr != nil {
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
return replyErr
}
@@ -351,24 +489,33 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
// Streaming observer with the configured update throttle (500ms by default).
var lastUpdate time.Time
var lastPartialContent string
observer := func(fullContent string) {
if strings.TrimSpace(fullContent) == "" {
return
}
fullContent = decorateReasoningPartial(fullContent, req.ReasoningExecution)
now := h.now()
if lastUpdate.IsZero() || now.Sub(lastUpdate) >= h.cardUpdateInterval {
partialContent := h.sanitizeOutboundPartial(fullContent)
if strings.TrimSpace(partialContent) == "" || partialContent == lastPartialContent {
return
}
// Advance the throttle window before I/O so a failing DingTalk API
// cannot be hammered once per model chunk. The final update below is
// always attempted regardless of this timestamp.
lastUpdate = now
if updateErr := streamingCard.UpdateCard(ctx, outTrackID, fullContent, false, false); updateErr != nil {
if updateErr := streamingCard.UpdateCard(ctx, outTrackID, partialContent, false, false); updateErr != nil {
log.Printf("DingTalk streaming card update failed, continuing")
return
}
lastPartialContent = partialContent
}
}
result, err := streamingQA.AskStream(ctx, req, observer)
if err != nil || strings.TrimSpace(result.Answer) == "" {
logQAFailure("streaming", errOrEmptyAnswer(err, result.Answer))
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, core.QAResult{}, qaErrorReply, true)
}
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result, result.Answer, false)
@@ -386,12 +533,14 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
log.Printf("DingTalk event card start failed, falling back to markdown")
result, askErr := h.qa.Ask(ctx, req)
if askErr != nil {
logQAFailure("event_fallback", askErr)
return h.sendReply(ctx, webhookURL, qaErrorReply)
}
if result.Replayed && result.DeliveryStatus == core.DeliverySent {
return nil
}
if replyErr := h.sendReply(ctx, webhookURL, result.Answer); replyErr != nil {
content := h.rewriteOutboundContent(ctx, result.Answer)
if replyErr := h.sendReply(ctx, webhookURL, content); replyErr != nil {
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
return replyErr
}
@@ -406,7 +555,10 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
result, err := eventQA.AskEvents(ctx, req, func(event weknora.StreamEvent) {
if event.ResponseType == "answer" && strings.TrimSpace(event.FullAnswer) != "" {
answerStarted = true
pump.Offer(event.FullAnswer)
visibleAnswer := decorateReasoningPartial(event.FullAnswer, req.ReasoningExecution)
if partialContent := h.sanitizeOutboundPartial(visibleAnswer); strings.TrimSpace(partialContent) != "" {
pump.Offer(partialContent)
}
return
}
if answerStarted {
@@ -421,17 +573,32 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
pump.Stop()
if err != nil || strings.TrimSpace(result.Answer) == "" {
logQAFailure("event_streaming", errOrEmptyAnswer(err, result.Answer))
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, core.QAResult{}, qaErrorReply, true)
}
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result, result.Answer, false)
}
func decorateReasoningPartial(content, execution string) string {
switch reasoningmode.ExecutionMode(strings.TrimSpace(execution)) {
case reasoningmode.ExecutionReasoning, reasoningmode.ExecutionReasoningSingle:
if !strings.HasPrefix(content, "【智能推理模式】") {
return "【智能推理模式】\n\n" + content
}
}
return content
}
func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string, streamingCard StreamingCardSender, outTrackID string, result core.QAResult, content string, isError bool) error {
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
timeout = defaultHTTPTimeout
}
if !isError {
content = h.rewriteOutboundContent(ctx, content)
}
// Final card convergence must still be attempted when the QA context was
// canceled or timed out; otherwise the card can remain "processing".
finalCtx, cancelFinal := context.WithTimeout(context.WithoutCancel(ctx), timeout)
@@ -460,6 +627,27 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
return nil
}
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
if h.outboundRewriter == nil {
return sanitizeDisplayAnswer(content)
}
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
timeout = defaultHTTPTimeout
}
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
return sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content))
}
func (h *Handler) sanitizeOutboundPartial(content string) string {
sanitizer, ok := h.outboundRewriter.(OutboundPartialSanitizer)
if ok {
content = sanitizer.SanitizePartial(content)
}
return stableMarkdownPrefix(sanitizeDisplayAnswer(content))
}
func (h *Handler) recordDelivery(ctx context.Context, result core.QAResult, status, method, providerMessageID, failureCode string) {
if strings.TrimSpace(result.TurnID) == "" || strings.TrimSpace(result.AnswerMessageID) == "" {
return
@@ -511,6 +699,50 @@ func renderProgressEvent(event weknora.StreamEvent) string {
return ""
}
func errOrEmptyAnswer(err error, answer string) error {
if err != nil {
return err
}
if strings.TrimSpace(answer) == "" {
return weknora.ErrEmptyAnswer
}
return nil
}
func logQAFailure(mode string, err error) {
log.Printf("DingTalk QA failed: mode=%s stage=%s", mode, qaFailureStage(err))
}
func qaFailureStage(err error) string {
switch {
case err == nil:
return "unknown"
case errors.Is(err, context.Canceled):
return "context_canceled"
case errors.Is(err, context.DeadlineExceeded):
return "context_deadline"
case errors.Is(err, core.ErrTurnInProgress):
return "duplicate_or_in_progress"
case errors.Is(err, weknora.ErrEmptyAnswer):
return "empty_answer"
}
message := strings.ToLower(err.Error())
switch {
case strings.Contains(message, "begin turn"):
return "begin_turn"
case strings.Contains(message, "agent") && strings.Contains(message, "binding"):
return "agent_binding"
case strings.Contains(message, "session"):
return "session"
case strings.Contains(message, "weknora agent chat"):
return "agent_chat"
case strings.Contains(message, "required") || strings.Contains(message, "unsupported"):
return "validation"
default:
return "unknown"
}
}
func imageFailureStage(err error) string {
message := strings.ToLower(err.Error())
switch {
@@ -538,3 +770,57 @@ func (h *Handler) sendReply(ctx context.Context, webhookURL, text string) error
}
return nil
}
// publishQuestionImage best-effort publishes a downloaded image's bytes to
// object storage for traceability. It returns the stable HTTPS URL, or an
// empty string when publishing is unavailable or fails, so that an image
// question is never blocked by storage.
func (h *Handler) publishQuestionImage(ctx context.Context, image core.ImageInput) string {
if h.imagePublisher == nil {
return ""
}
data, err := decodeImageDataURI(image.DataURI)
if err != nil || len(data) == 0 {
log.Printf("DingTalk question image publish failed: stage=decode")
return ""
}
url, err := h.imagePublisher.Publish(ctx, data, image.MIMEType)
if err != nil || strings.TrimSpace(url) == "" {
log.Printf("DingTalk question image publish failed: stage=publish")
return ""
}
return strings.TrimSpace(url)
}
// decodeImageDataURI strips the "data:<mime>;base64," prefix and decodes the
// payload into raw bytes. It is used only to publish inbound images and never
// writes the data URI to storage.
func decodeImageDataURI(dataURI string) ([]byte, error) {
const prefix = "base64,"
index := strings.Index(dataURI, prefix)
if index < 0 {
return nil, errors.New("dingtalk image: data URI missing base64 payload")
}
return base64.StdEncoding.DecodeString(dataURI[index+len(prefix):])
}
// effectiveDownloadCodes returns the ordered, de-duplicated list of image
// download codes for a picture callback. DownloadCode equals the first code
// and DownloadCodes holds every code (richText with several pictures), so the
// single value is used to canonicalize the order without duplication.
func effectiveDownloadCodes(cb BotCallback) []string {
var codes []string
seen := make(map[string]struct{})
for _, code := range append([]string{cb.DownloadCode}, cb.DownloadCodes...) {
code = strings.TrimSpace(code)
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
codes = append(codes, code)
}
return codes
}
+224 -7
View File
@@ -3,6 +3,7 @@ package dingtalk
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
@@ -726,6 +727,46 @@ func TestHandleCardSuccessDirectFieldsCorrectNoMarkdown(t *testing.T) {
}
}
func TestHandleNonStreamingRewritesFinalCardAndMarkdownFallback(t *testing.T) {
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
signed := "https://img.example/signed.jpg"
answer := "说明\n![图](" + resource + ")"
for _, tc := range []struct {
name string
cardErr error
wantReply bool
}{
{name: "card"},
{name: "markdown fallback", cardErr: errors.New("card failed"), wantReply: true},
} {
t.Run(tc.name, func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: answer}}
reply := &fakeReply{}
card := &fakeCard{returnID: "track", err: tc.cardErr}
h := newTestHandlerWithCard(t, qa, reply, card)
rewriter := &fakeOutboundRewriter{from: resource, to: signed}
h.outboundRewriter = rewriter
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if !card.called || reply.called != tc.wantReply {
t.Fatalf("card=%t reply=%t", card.called, reply.called)
}
content := card.lastReq.Content
if tc.wantReply {
content = reply.lastText
}
if strings.Contains(content, resource) || !strings.Contains(content, signed) {
t.Fatalf("outbound content=%q", content)
}
if rewriter.calls != 1 {
t.Fatalf("rewrite calls=%d, want 1", rewriter.calls)
}
})
}
}
func TestHandleCardSuccessGroupChatIDCorrect(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "group card answer"}}
reply := &fakeReply{}
@@ -940,6 +981,23 @@ type cardUpdate struct {
isError bool
}
type fakeOutboundRewriter struct {
calls int
partialCalls int
from string
to string
}
func (f *fakeOutboundRewriter) Rewrite(_ context.Context, content string) string {
f.calls++
return strings.ReplaceAll(content, f.from, f.to)
}
func (f *fakeOutboundRewriter) SanitizePartial(content string) string {
f.partialCalls++
return strings.ReplaceAll(content, f.from, "")
}
func (f *fakeStreamingCard) SendFinalCard(_ context.Context, req CardRequest) (string, error) {
f.finalCalled = true
f.finalReq = req
@@ -1123,11 +1181,14 @@ func TestHandleStreamingThrottleClockAdvances(t *testing.T) {
func TestHandleStreamingStartFailsFallbackAskMarkdown(t *testing.T) {
card := &fakeStreamingCard{startErr: errors.New("card API unavailable")}
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
qa := &fakeStreamingQA{
result: core.QAResult{Answer: "**fallback markdown**"},
result: core.QAResult{Answer: "**fallback markdown**\n![图](" + resource + ")"},
}
reply := &fakeReply{}
h := newTestHandlerWithStreaming(t, qa, reply, card)
rewriter := &fakeOutboundRewriter{from: resource, to: "https://img.example/signed.jpg"}
h.outboundRewriter = rewriter
err := h.Handle(context.Background(), directTextCallback())
if err != nil {
@@ -1156,8 +1217,11 @@ func TestHandleStreamingStartFailsFallbackAskMarkdown(t *testing.T) {
if !reply.called {
t.Fatal("SendMarkdown must be called on Start failure")
}
if reply.lastText != "**fallback markdown**" {
t.Fatalf("markdown text = %q, want fallback markdown", reply.lastText)
if strings.Contains(reply.lastText, resource) || !strings.Contains(reply.lastText, "https://img.example/signed.jpg") {
t.Fatalf("markdown text = %q, want rewritten fallback markdown", reply.lastText)
}
if rewriter.calls != 1 {
t.Fatalf("rewrite calls=%d, want 1", rewriter.calls)
}
// No card updates
@@ -1611,15 +1675,16 @@ func TestRenderProgressEventUsesOnlySafeFixedText(t *testing.T) {
}
func TestHandleEventStreamingFinalizesLatestAnswerWithoutMarkdown(t *testing.T) {
rawAnswer := "完整答案[[ref id=\"c8\"]]"
card := &fakeStreamingCard{startID: "track-events"}
qa := &fakeEventQA{
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: "完整答案"}},
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: rawAnswer}},
events: []weknora.StreamEvent{
{ResponseType: "tool_call", ToolName: "query_understand"},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "references", ReferenceCount: 3},
{ResponseType: "answer", FullAnswer: "部分"},
{ResponseType: "answer", FullAnswer: "完整答案", Done: true},
{ResponseType: "answer", FullAnswer: "部分[[ref"},
{ResponseType: "answer", FullAnswer: rawAnswer, Done: true},
},
}
reply := &fakeReply{}
@@ -1641,6 +1706,9 @@ func TestHandleEventStreamingFinalizesLatestAnswerWithoutMarkdown(t *testing.T)
if last.content != "完整答案" || !last.isFinalize || last.isError {
t.Fatalf("final update=%+v", last)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
if reply.called {
t.Fatal("SendMarkdown must not be called")
}
@@ -2005,5 +2073,154 @@ func TestTriggerPersonSnapshotRegression(t *testing.T) {
t.Fatalf("SenderName = %q, want 张三", qa.lastReq.SenderName)
}
})
}
func TestHandleStreamingRewritesOnlyFinalOutboundImageContent(t *testing.T) {
card := &fakeStreamingCard{startID: "track-image-final"}
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
answer := "操作说明\n![步骤图](" + resource + ")"
qa := &fakeStreamingQA{chunks: []string{answer}, result: core.QAResult{Answer: answer}}
reply := &fakeReply{}
rewriter := &fakeOutboundRewriter{from: resource, to: "https://img.example/signed.jpg"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
h.outboundRewriter = rewriter
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if len(card.updates) < 2 {
t.Fatalf("updates=%d", len(card.updates))
}
if strings.Contains(card.updates[0].content, resource) {
t.Fatalf("streaming intermediate content leaked internal resource: %q", card.updates[0].content)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || strings.Contains(final.content, resource) || !strings.Contains(final.content, "https://img.example/signed.jpg") {
t.Fatalf("final content=%q finalize=%t", final.content, final.isFinalize)
}
if rewriter.calls != 1 {
t.Fatalf("rewrite calls=%d, want 1", rewriter.calls)
}
if rewriter.partialCalls == 0 {
t.Fatal("streaming intermediate content was not sanitized")
}
}
func TestHandleFiltersReferenceMarkersAtDingTalkDisplayBoundary(t *testing.T) {
rawAnswer := "结论[[ref id=\"c2\"]][[ref id=\"c4\"]]。"
wantDisplay := "结论。"
t.Run("final card", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: rawAnswer}}
reply := &fakeReply{}
card := &fakeCard{returnID: "track-reference-filter"}
h := newTestHandlerWithCard(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if card.lastReq.Content != wantDisplay {
t.Fatalf("card content=%q, want %q", card.lastReq.Content, wantDisplay)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
})
t.Run("markdown fallback", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: rawAnswer}}
reply := &fakeReply{}
card := &fakeCard{err: errors.New("card unavailable")}
h := newTestHandlerWithCard(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if reply.lastText != wantDisplay {
t.Fatalf("markdown content=%q, want %q", reply.lastText, wantDisplay)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
})
}
func TestHandleStreamingHoldsBackPartialReferenceMarkers(t *testing.T) {
rawAnswer := "答案正文[[ref id=\"c2\"]]继续。"
qa := &fakeStreamingQA{
chunks: []string{"答案正文[[ref", " id=\"c2\"]]继续。"},
result: core.QAResult{Answer: rawAnswer},
}
reply := &fakeReply{}
card := &fakeStreamingCard{startID: "track-stream-reference-filter"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if len(card.updates) < 2 {
t.Fatalf("updates=%d, want at least one partial plus final", len(card.updates))
}
for i, update := range card.updates {
if strings.Contains(update.content, "[[ref") {
t.Fatalf("update[%d] leaked reference marker: %q", i, update.content)
}
}
if card.updates[0].content != "答案正文" {
t.Fatalf("first partial=%q, want dangling marker held back", card.updates[0].content)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || final.content != "答案正文继续。" {
t.Fatalf("final content=%q finalize=%t", final.content, final.isFinalize)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
}
func TestHandleStreamingPublishesTableOnlyOnFinalUpdate(t *testing.T) {
answer := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n结论"
qa := &fakeStreamingQA{
chunks: []string{
"说明\n\n",
"| 检查项",
" | 操作要求 |\n",
"| --- | --",
"- |\n",
"| 接地检查 | 上电前确",
"认 |\n\n结论",
},
result: core.QAResult{Answer: answer},
}
reply := &fakeReply{}
card := &fakeStreamingCard{startID: "track-stream-table"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
callCount := int64(0)
h.now = func() time.Time {
callCount++
return time.Unix(0, callCount*int64(time.Second))
}
h.cardUpdateInterval = time.Millisecond
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
var partials []string
for _, update := range card.updates {
if !update.isFinalize {
partials = append(partials, update.content)
}
}
wantPartials := []string{"说明\n\n"}
if !reflect.DeepEqual(partials, wantPartials) {
t.Fatalf("partial updates=%q, want only pre-table stream %q", partials, wantPartials)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || final.content != answer {
t.Fatalf("final=%+v, want unchanged complete answer", final)
}
if qa.result.Answer != answer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
}
@@ -0,0 +1,80 @@
package dingtalk
import (
"context"
"encoding/base64"
"errors"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
type fakeImagePublisher struct {
published []byte
mime string
url string
err error
calls int
}
func (f *fakeImagePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
f.calls++
f.published = data
f.mime = contentType
return f.url, f.err
}
func TestDecodeImageDataURI(t *testing.T) {
raw := []byte("hello-image-bytes")
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
decoded, err := decodeImageDataURI(uri)
if err != nil {
t.Fatalf("decodeImageDataURI: %v", err)
}
if string(decoded) != string(raw) {
t.Fatalf("decoded=%q, want %q", decoded, raw)
}
if _, err := decodeImageDataURI("data:image/png;base64"); err == nil {
t.Fatal("expected error for missing base64 payload")
}
if _, err := decodeImageDataURI("no comma at all"); err == nil {
t.Fatal("expected error for non-data URI")
}
}
func TestPublishQuestionImagePublishesDecodedBytes(t *testing.T) {
pub := &fakeImagePublisher{url: "https://weknora-api.example/i/001"}
raw := []byte{0x89, 0x50, 0x4e, 0x47}
image := core.ImageInput{
DataURI: "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw),
MIMEType: "image/png",
}
h := &Handler{imagePublisher: pub}
got := h.publishQuestionImage(context.Background(), image)
if got != "https://weknora-api.example/i/001" {
t.Fatalf("url=%q, want published", got)
}
if pub.calls != 1 || pub.mime != "image/png" || string(pub.published) != string(raw) {
t.Fatalf("publisher calls=%d mime=%q bytes=%q", pub.calls, pub.mime, pub.published)
}
}
func TestPublishQuestionImageFailsClosedWithoutURL(t *testing.T) {
h := &Handler{imagePublisher: nil}
if got := h.publishQuestionImage(context.Background(), core.ImageInput{}); got != "" {
t.Fatalf("expected empty URL without publisher, got %q", got)
}
pub := &fakeImagePublisher{err: errors.New("oss down")}
h2 := &Handler{imagePublisher: pub}
image := core.ImageInput{
DataURI: "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("x")),
MIMEType: "image/png",
}
if got := h2.publishQuestionImage(context.Background(), image); got != "" {
t.Fatalf("expected empty URL on publish error, got %q", got)
}
}
@@ -0,0 +1,114 @@
package dingtalk
import "strings"
type markdownStreamLine struct {
start int
text string
inFence bool
}
// stableMarkdownPrefix returns the cumulative content that is safe to render
// while Markdown is still streaming. Once a table is confirmed, the table and
// every following token stay hidden until the terminal card update.
func stableMarkdownPrefix(content string) string {
lines := splitMarkdownStreamLines(content)
for i := 0; i < len(lines); i++ {
if lines[i].inFence || !isTableHeaderCandidate(lines[i].text) {
continue
}
if i == len(lines)-1 {
return content[:lines[i].start]
}
if lines[i+1].inFence || !isTableDelimiterCandidate(lines[i+1].text) {
continue
}
if !isCompleteTableDelimiter(lines[i+1].text) {
return content[:lines[i].start]
}
// DingTalk only lays out Markdown tables reliably on the terminal
// isFinalize=true update. Keep the table and every following partial
// token hidden; the authoritative final update bypasses this helper.
return content[:lines[i].start]
}
return content
}
func splitMarkdownStreamLines(content string) []markdownStreamLine {
lines := make([]markdownStreamLine, 0, strings.Count(content, "\n")+1)
fence := ""
for start := 0; start < len(content); {
end := strings.IndexByte(content[start:], '\n')
if end < 0 {
end = len(content)
} else {
end += start + 1
}
text := strings.TrimSuffix(content[start:end], "\n")
text = strings.TrimSuffix(text, "\r")
trimmed := strings.TrimSpace(text)
line := markdownStreamLine{start: start, text: text, inFence: fence != ""}
if marker := markdownFenceMarker(trimmed); marker != "" {
line.inFence = true
if fence == "" {
fence = marker
} else if marker == fence {
fence = ""
}
}
lines = append(lines, line)
start = end
}
return lines
}
func markdownFenceMarker(trimmed string) string {
switch {
case strings.HasPrefix(trimmed, "```"):
return "```"
case strings.HasPrefix(trimmed, "~~~"):
return "~~~"
default:
return ""
}
}
func isTableHeaderCandidate(line string) bool {
return strings.HasPrefix(strings.TrimSpace(line), "|")
}
func isTableDelimiterCandidate(line string) bool {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "|") {
return false
}
for _, r := range trimmed {
switch r {
case '|', ':', '-', ' ', '\t':
default:
return false
}
}
return true
}
func isCompleteTableDelimiter(line string) bool {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
cells := strings.Split(trimmed, "|")
if len(cells) < 2 {
return false
}
for _, cell := range cells {
cell = strings.TrimSpace(cell)
cell = strings.TrimPrefix(cell, ":")
cell = strings.TrimSuffix(cell, ":")
if len(cell) < 3 || strings.Trim(cell, "-") != "" {
return false
}
}
return true
}
@@ -0,0 +1,65 @@
package dingtalk
import (
"context"
"reflect"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
type pacedTableEventQA struct {
fakeStreamingQA
events []weknora.StreamEvent
pause time.Duration
}
func (f *pacedTableEventQA) AskEvents(_ context.Context, req core.QARequest, observer weknora.StreamObserver) (core.QAResult, error) {
f.lastReq = req
for _, event := range f.events {
if observer != nil {
observer(event)
}
time.Sleep(f.pause)
}
return f.result, f.err
}
func TestHandleEventStreamingPublishesTableOnlyOnFinalUpdate(t *testing.T) {
answer := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n结论"
qa := &pacedTableEventQA{
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: answer}},
events: []weknora.StreamEvent{
{ResponseType: "answer", FullAnswer: "说明\n\n"},
{ResponseType: "answer", FullAnswer: "说明\n\n| 检查项"},
{ResponseType: "answer", FullAnswer: "说明\n\n| 检查项 | 操作要求 |\n| --- | --"},
{ResponseType: "answer", FullAnswer: answer, Done: true},
},
pause: 20 * time.Millisecond,
}
reply := &fakeReply{}
card := &fakeStreamingCard{startID: "track-event-table"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
h.cardUpdateInterval = time.Millisecond
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
var partials []string
for _, update := range card.updates {
if !update.isFinalize {
partials = append(partials, update.content)
}
}
wantPartials := []string{"说明\n\n"}
if !reflect.DeepEqual(partials, wantPartials) {
t.Fatalf("event partial updates=%q, want only pre-table stream %q", partials, wantPartials)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || final.content != answer {
t.Fatalf("final=%+v, want unchanged complete answer", final)
}
}
@@ -0,0 +1,101 @@
package dingtalk
import "testing"
func TestStableMarkdownPrefixHoldsCompletedTableUntilFinalization(t *testing.T) {
content := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n表后正文仍在生成"
want := "说明\n\n"
if got := stableMarkdownPrefix(content); got != want {
t.Fatalf("stableMarkdownPrefix()=%q, want table and trailing stream held as %q", got, want)
}
}
func TestStableMarkdownPrefixHoldsBackTableUntilFinalization(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{
name: "split header",
content: "说明\n\n| 检查项",
want: "说明\n\n",
},
{
name: "header waiting for delimiter",
content: "说明\n\n| 检查项 | 操作要求 |\n",
want: "说明\n\n",
},
{
name: "partial delimiter",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --",
want: "说明\n\n",
},
{
name: "partial data row",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确",
want: "说明\n\n",
},
{
name: "complete table without terminator",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n",
want: "说明\n\n",
},
{
name: "complete table followed by blank line",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n后文",
want: "说明\n\n",
},
{
name: "complete table followed by heading",
content: "| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n## 后续",
want: "",
},
{
name: "second consecutive table remains hidden",
content: "| A | B |\n| --- | --- |\n| 1 | 2 |\n\n| C | D",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := stableMarkdownPrefix(tt.content); got != tt.want {
t.Fatalf("stableMarkdownPrefix()=%q, want %q", got, tt.want)
}
})
}
}
func TestStableMarkdownPrefixLeavesNonTablesUnchanged(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "ordinary text",
content: "普通段落继续流式显示。",
},
{
name: "ordinary inline pipe",
content: "状态使用 ready | running | done 表示。",
},
{
name: "pipe inside fenced code",
content: "```text\n| 这不是 | 表格 |\n| --- | --- |\n```",
},
{
name: "completed prose line starting with pipe",
content: "| 这是引用样式文本\n下一段普通文字",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := stableMarkdownPrefix(tt.content); got != tt.content {
t.Fatalf("stableMarkdownPrefix()=%q, want unchanged %q", got, tt.content)
}
})
}
}
@@ -0,0 +1,21 @@
package dingtalk
import (
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
func TestDecorateReasoningPartialMarksReasoningFromFirstChunk(t *testing.T) {
got := decorateReasoningPartial("部分答案", string(reasoningmode.ExecutionReasoning))
want := "【智能推理模式】\n\n部分答案"
if got != want {
t.Fatalf("got=%q want=%q", got, want)
}
}
func TestDecorateReasoningPartialLeavesQuickAnswerUnchanged(t *testing.T) {
if got := decorateReasoningPartial("快速答案", string(reasoningmode.ExecutionQuick)); got != "快速答案" {
t.Fatalf("got=%q", got)
}
}
@@ -0,0 +1,81 @@
package dingtalk
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
type fakeReasoningRouter struct {
called bool
message ReasoningMessage
decision ReasoningDecision
err error
}
func (f *fakeReasoningRouter) Prepare(_ context.Context, message ReasoningMessage) (ReasoningDecision, error) {
f.called = true
f.message = message
return f.decision, f.err
}
func TestReasoningControlCommandBypassesQA(t *testing.T) {
qa := &fakeQA{}
reply := &fakeReply{}
router := &fakeReasoningRouter{decision: ReasoningDecision{Handled: true, Reply: "mode enabled"}}
handler, err := NewHandlerWithOptions(
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
qa,
reply,
HandlerOptions{ReasoningRouter: router},
)
if err != nil {
t.Fatal(err)
}
callback := directTextCallback()
callback.Text = "/think on"
if err = handler.Handle(context.Background(), callback); err != nil {
t.Fatal(err)
}
if !router.called || router.message.StaffID != "staff-123" || router.message.Text != "/think on" {
t.Fatalf("router=%+v", router)
}
if qa.called {
t.Fatal("reasoning control command must bypass QA")
}
if !reply.called || reply.lastText != "mode enabled" {
t.Fatalf("reply=%+v", reply)
}
}
func TestReasoningRouteAppliesQueryAgentAndExecutionToQARequest(t *testing.T) {
qa := &fakeQA{result: core.QAResult{TurnID: "turn-1", AnswerMessageID: "answer-1", Answer: "结果"}}
reply := &fakeReply{}
router := &fakeReasoningRouter{decision: ReasoningDecision{
Query: "分析根因", AgentID: "builtin-smart-reasoning",
Execution: reasoningmode.ExecutionReasoningSingle, Expired: true,
}}
handler, err := NewHandlerWithOptions(
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
qa,
reply,
HandlerOptions{ReasoningRouter: router},
)
if err != nil {
t.Fatal(err)
}
callback := directTextCallback()
callback.Text = "/think 分析根因"
if err = handler.Handle(context.Background(), callback); err != nil {
t.Fatal(err)
}
if !qa.called {
t.Fatal("single-turn reasoning must call QA")
}
if qa.lastReq.Query != "分析根因" || qa.lastReq.AgentIDOverride != "builtin-smart-reasoning" ||
qa.lastReq.ReasoningExecution != string(reasoningmode.ExecutionReasoningSingle) || !qa.lastReq.ReasoningExpired {
t.Fatalf("QA request=%+v", qa.lastReq)
}
}
@@ -0,0 +1,55 @@
package dingtalk
import (
"context"
"testing"
)
type fakeSettingsRouter struct {
called bool
handled bool
reply string
}
func (f *fakeSettingsRouter) Handle(_ context.Context, in SettingsMessage) (bool, string, error) {
f.called = true
if in.StaffID != "staff-123" {
panic("wrong identity")
}
return f.handled, f.reply, nil
}
func TestSettingsCommandBypassesQA(t *testing.T) {
qa := &fakeQA{}
reply := &fakeReply{}
router := &fakeSettingsRouter{handled: true, reply: "settings menu"}
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{SettingsRouter: router})
if err != nil {
t.Fatal(err)
}
cb := directTextCallback()
cb.Text = "/setting"
if err = h.Handle(context.Background(), cb); err != nil {
t.Fatal(err)
}
if !router.called {
t.Fatal("router not called")
}
if qa.called {
t.Fatal("settings must bypass QA")
}
if !reply.called || reply.lastText != "settings menu" {
t.Fatalf("reply=%+v", reply)
}
}
func TestOrdinaryQuestionStillCallsQA(t *testing.T) {
qa := &fakeQA{}
reply := &fakeReply{}
router := &fakeSettingsRouter{}
h, _ := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{SettingsRouter: router})
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if !qa.called {
t.Fatal("ordinary question must call QA")
}
}
+160 -32
View File
@@ -5,7 +5,11 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net/url"
"strings"
"sync"
"time"
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
@@ -82,10 +86,24 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
Text: data.Text.Content,
}
if msgtype == "richtext" {
rtMsgtype, rtText, rtCode := parseRichText(data.Content)
rtMsgtype, rtText, rtCodes := parseRichText(data.Content)
cb.Msgtype = rtMsgtype
cb.Text = rtText
cb.DownloadCode = rtCode
cb.DownloadCodes = rtCodes
if len(rtCodes) > 0 {
cb.DownloadCode = rtCodes[0]
}
return cb, nil
}
if msgtype == "link" {
linkURL := extractLinkMessageURL(data.Content)
if linkURL == "" {
linkURL = safeHTTPURL(data.Text.Content)
}
if linkURL != "" {
cb.Msgtype = msgTypeText
cb.Text = linkURL
}
return cb, nil
}
if msgtype == "picture" {
@@ -94,6 +112,68 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
return cb, nil
}
// extractLinkMessageURL extracts an HTTP(S) target from DingTalk link-card
// content. Only known URL and envelope keys are traversed so a card image URL
// (for example picUrl) cannot be mistaken for the message target.
func extractLinkMessageURL(raw interface{}) string {
if raw == nil {
return ""
}
b, err := json.Marshal(raw)
if err != nil {
return ""
}
var value interface{}
if json.Unmarshal(b, &value) != nil {
return ""
}
return findLinkMessageURL(value)
}
func findLinkMessageURL(value interface{}) string {
object, ok := value.(map[string]interface{})
if !ok {
return ""
}
for key, candidate := range object {
switch normalizeLinkField(key) {
case "messageurl", "linkurl", "singletitleurl", "href", "url":
if text, ok := candidate.(string); ok {
if target := safeHTTPURL(text); target != "" {
return target
}
}
}
}
for key, nested := range object {
switch normalizeLinkField(key) {
case "content", "link", "data", "card":
if target := findLinkMessageURL(nested); target != "" {
return target
}
}
}
return ""
}
func normalizeLinkField(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, "_", "")
return strings.ReplaceAll(value, "-", "")
}
func safeHTTPURL(raw string) string {
raw = strings.TrimSpace(raw)
u, err := url.ParseRequestURI(raw)
if err != nil || u.Host == "" {
return ""
}
if !strings.EqualFold(u.Scheme, "https") && !strings.EqualFold(u.Scheme, "http") {
return ""
}
return raw
}
// parseRichText parses the SDK Content field for richText messages.
// It handles three content shapes:
// 1. Direct object: {"richText": [...]}
@@ -101,19 +181,19 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
// 3. Nested envelope: {"content":{"richText":[...]}}
//
// Picture items are identified by a "type" field (case-insensitive trimmed)
// equal to "picture". The first non-empty downloadCode is used; subsequent
// picture items are ignored. Text items are joined with newlines.
// equal to "picture". All non-empty download codes are collected in order;
// text items are joined with newlines.
//
// Returns: (msgtype, text, downloadCode). Msgtype is "picture" if any picture
// Returns: (msgtype, text, downloadCodes). Msgtype is "picture" if any picture
// item exists, "text" if only text items, "richtext" if malformed/empty.
func parseRichText(raw interface{}) (string, string, string) {
func parseRichText(raw interface{}) (string, string, []string) {
items := extractRichTextItems(raw)
if len(items) == 0 {
return "richtext", "", ""
return "richtext", "", nil
}
var textParts []string
var downloadCode string
var downloadCodes []string
hasPicture := false
for _, item := range items {
@@ -121,11 +201,9 @@ func parseRichText(raw interface{}) (string, string, string) {
if itemType == "picture" {
hasPicture = true
if downloadCode == "" {
code := strings.TrimSpace(item.DownloadCode)
if code != "" {
downloadCode = code
}
code := strings.TrimSpace(item.DownloadCode)
if code != "" {
downloadCodes = append(downloadCodes, code)
}
continue
}
@@ -139,12 +217,12 @@ func parseRichText(raw interface{}) (string, string, string) {
text := strings.Join(textParts, "\n")
if hasPicture {
return "picture", text, downloadCode
return "picture", text, downloadCodes
}
if text != "" {
return "text", text, ""
return "text", text, nil
}
return "richtext", "", ""
return "richtext", "", nil
}
// richTextItem is a single item in a richText array.
@@ -222,18 +300,77 @@ func extractDownloadCode(raw interface{}) string {
return strings.TrimSpace(nested.Content.DownloadCode)
}
// StreamClient wraps the official DingTalk Stream SDK client. It wires
// robot callbacks into the existing Handler synchronously (no goroutines,
// no queue). The SDK default auto-reconnect stays enabled while running;
// Close disables it so the connection stays closed.
const callbackDedupTTL = 5 * time.Minute
type chatCallbackHandler interface {
Handle(context.Context, BotCallback) error
}
// newAsyncChatCallbackRouter acknowledges valid DingTalk frames immediately,
// then processes them outside the SDK callback lifetime. DingTalk retries slow
// callbacks, so retaining MsgIDs for the platform's five-minute retry window
// prevents a retry from creating a second streaming card.
func newAsyncChatCallbackRouter(handler chatCallbackHandler) func(context.Context, *chatbot.BotCallbackDataModel) ([]byte, error) {
var mu sync.Mutex
seen := make(map[string]time.Time)
return func(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
cb, err := BotCallbackFromSDK(data)
if err != nil {
return nil, err
}
sourceType := safeCallbackMessageType(data.Msgtype)
if sourceType == "link" || sourceType == "richtext" {
log.Printf("DingTalk callback normalized: source_msgtype=%s normalized_msgtype=%s text_present=%t", sourceType, safeCallbackMessageType(cb.Msgtype), strings.TrimSpace(cb.Text) != "")
}
messageID := strings.TrimSpace(cb.MsgID)
if messageID == "" {
return nil, errors.New("dingtalk stream: msgId is required")
}
now := time.Now()
mu.Lock()
for id, expiresAt := range seen {
if !expiresAt.After(now) {
delete(seen, id)
}
}
if expiresAt, exists := seen[messageID]; exists && expiresAt.After(now) {
mu.Unlock()
return nil, nil
}
seen[messageID] = now.Add(callbackDedupTTL)
mu.Unlock()
workCtx := context.WithoutCancel(ctx)
go func() {
if err := handler.Handle(workCtx, cb); err != nil {
log.Printf("DingTalk async callback failed: stage=handler")
}
}()
return nil, nil
}
}
func safeCallbackMessageType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "text", "picture", "richtext", "link":
return strings.ToLower(strings.TrimSpace(raw))
default:
return "other"
}
}
// StreamClient wraps the official DingTalk Stream SDK client. Robot callbacks
// are acknowledged before the potentially slow QA/card work begins. The SDK
// default auto-reconnect stays enabled while running; Close disables it so the
// connection stays closed.
type StreamClient struct {
inner *dtsdk.StreamClient
}
// NewStreamClient validates the config, builds the SDK client with the app
// credential and registers the chatbot callback router. The handler is
// invoked synchronously from the SDK callback; its return value only
// acknowledges the frame, replies always go through the session webhook.
// acknowledged immediately; replies always go through the session webhook.
func NewStreamClient(config StreamConfig, handler *Handler, feedbackHandlers ...*CardFeedbackHandler) (*StreamClient, error) {
if err := config.Validate(); err != nil {
return nil, err
@@ -256,16 +393,7 @@ func NewStreamClient(config StreamConfig, handler *Handler, feedbackHandlers ...
inner := dtsdk.NewStreamClient(
dtsdk.WithAppCredential(cred),
)
inner.RegisterChatBotCallbackRouter(func(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
cb, err := BotCallbackFromSDK(data)
if err != nil {
return nil, err
}
if err := handler.Handle(ctx, cb); err != nil {
return nil, err
}
return nil, nil
})
inner.RegisterChatBotCallbackRouter(newAsyncChatCallbackRouter(handler))
if len(feedbackHandlers) == 1 {
inner.RegisterCardCallbackRouter(func(ctx context.Context, request *sdkcard.CardRequest) (*sdkcard.CardResponse, error) {
return feedbackHandlers[0].HandleSDK(ctx, request)
+113 -2
View File
@@ -1,12 +1,80 @@
package dingtalk
import (
"context"
"reflect"
"strings"
"sync"
"testing"
"time"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
)
type blockingCallbackHandler struct {
started chan struct{}
release chan struct{}
mu sync.Mutex
calls int
}
func (h *blockingCallbackHandler) Handle(_ context.Context, _ BotCallback) error {
h.mu.Lock()
h.calls++
if h.calls == 1 {
close(h.started)
}
h.mu.Unlock()
<-h.release
return nil
}
func (h *blockingCallbackHandler) callCount() int {
h.mu.Lock()
defer h.mu.Unlock()
return h.calls
}
func TestAsyncChatCallbackAcknowledgesBeforeWorkAndDeduplicatesInflightMessage(t *testing.T) {
handler := &blockingCallbackHandler{started: make(chan struct{}), release: make(chan struct{})}
router := newAsyncChatCallbackRouter(handler)
data := &chatbot.BotCallbackDataModel{
ConversationType: "1",
Msgtype: "text",
MsgId: "same-message",
SenderStaffId: "staff-1",
SessionWebhook: "https://oapi.dingtalk.com/robot/sendBySession?session=test",
Text: chatbot.BotCallbackDataTextModel{Content: "hello"},
}
done := make(chan error, 1)
go func() {
_, err := router(context.Background(), data)
done <- err
}()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("callback ACK waited for slow QA work")
}
select {
case <-handler.started:
case <-time.After(time.Second):
t.Fatal("async handler did not start")
}
if _, err := router(context.Background(), data); err != nil {
t.Fatal(err)
}
time.Sleep(20 * time.Millisecond)
if got := handler.callCount(); got != 1 {
t.Fatalf("same in-flight MsgID handled %d times, want 1", got)
}
close(handler.release)
}
func TestStreamConfigValidate(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -119,7 +187,7 @@ func TestBotCallbackFromSDK(t *testing.T) {
SessionWebhook: "https://oapi.dingtalk.com/robot/sendBySession?session=abc",
Text: " 你好 ",
}
if cb != want {
if !reflect.DeepEqual(cb, want) {
t.Fatalf("BotCallbackFromSDK() = %+v, want %+v", cb, want)
}
})
@@ -142,6 +210,46 @@ func TestBotCallbackFromSDK(t *testing.T) {
})
}
func TestBotCallbackFromSDKLinkCardPromotesTrustedURLToText(t *testing.T) {
t.Parallel()
const notableURL = "https://alidocs.dingtalk.com/i/nodes/base?sheetId=sheet&viewId=view"
tests := []struct {
name string
content interface{}
}{
{name: "direct content", content: map[string]interface{}{"title": "FAQ表", "messageUrl": notableURL}},
{name: "nested callback envelope", content: map[string]interface{}{"content": map[string]interface{}{"messageUrl": notableURL}}},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cb, err := BotCallbackFromSDK(&chatbot.BotCallbackDataModel{Msgtype: "link", MsgId: "link-1", Content: tt.content})
if err != nil {
t.Fatal(err)
}
if cb.Msgtype != "text" || cb.Text != notableURL {
t.Fatalf("callback=%+v, want promoted text URL", cb)
}
})
}
}
func TestBotCallbackFromSDKLinkCardRejectsUnsafeURL(t *testing.T) {
t.Parallel()
cb, err := BotCallbackFromSDK(&chatbot.BotCallbackDataModel{
Msgtype: "link",
MsgId: "link-unsafe",
Content: map[string]interface{}{"messageUrl": "javascript:alert(1)"},
})
if err != nil {
t.Fatal(err)
}
if cb.Msgtype != "link" || cb.Text != "" {
t.Fatalf("unsafe link must not be promoted: %+v", cb)
}
}
func TestBotCallbackFromSDKPicture(t *testing.T) {
t.Parallel()
@@ -343,7 +451,7 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
}
})
t.Run("first non-empty downloadCode used, subsequent ignored", func(t *testing.T) {
t.Run("all picture downloadCodes collected in order", func(t *testing.T) {
t.Parallel()
data := &chatbot.BotCallbackDataModel{
ConversationType: "1",
@@ -361,6 +469,9 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
if cb.DownloadCode != "code-first" {
t.Fatalf("DownloadCode = %q, want first code-first", cb.DownloadCode)
}
if len(cb.DownloadCodes) != 2 || cb.DownloadCodes[0] != "code-first" || cb.DownloadCodes[1] != "code-second" {
t.Fatalf("DownloadCodes = %+v, want [code-first code-second]", cb.DownloadCodes)
}
})
t.Run("picture item missing downloadCode still picture", func(t *testing.T) {
+14 -6
View File
@@ -17,13 +17,15 @@ var ErrTurnInProgress = errors.New("turn is already processing")
// ImageInput contains the image data for a QA request. The DataURI is a
// full data: URI (e.g. "data:image/png;base64,...") and must never be
// persisted to the audit log.
// persisted to the audit log. PublishedURL is an optional stable HTTPS URL
// produced after the image is published to object storage for traceability.
type ImageInput struct {
DataURI string
MIMEType string
SizeBytes int64
Width int
Height int
DataURI string
MIMEType string
SizeBytes int64
Width int
Height int
PublishedURL string
}
// MediaAudit records metadata about media in a turn for audit purposes.
@@ -49,7 +51,11 @@ type QARequest struct {
ActorUnionIDSnapshot string
Department string
Region string
ResponseLocale string
Query string
AgentIDOverride string
ReasoningExecution string
ReasoningExpired bool
Images []ImageInput
MessageType string
}
@@ -116,6 +122,8 @@ type Turn struct {
Answer string
References []Reference
Media []MediaAudit
QuestionImageURLs []string
KnowledgeSearched bool
Status string
ErrorMessage string
}
+75 -3
View File
@@ -16,6 +16,7 @@ var noAnswerPhrases = []string{
"暂无相关",
"暂无已发布的权威知识",
"知识库中没有",
"产品不存在于资料中",
}
// retrievalGapPhrases are only considered a no-answer signal when the answer
@@ -53,8 +54,17 @@ func (e *Evaluator) Version() string {
// rules (in priority order):
// 1. Failed/processing turns → ErrTurnNotSucceeded
// 2. Answer contains an explicit "no answer" phrase → uncovered_explicit_no_answer
// 3. Zero references (nil or empty) → uncovered_no_references
// 4. Has references → covered_has_references
// 3. Image turns (image media present) are classified by whether the question
// actually triggered a knowledge-base search:
// - has references → covered_has_references
// - no references + knowledge searched → uncovered_no_references
// - no references + no knowledge search → covered_visual_recognition
// (pure visual recognition is not a coverage miss)
// 4. Text turns with zero references are classified by whether the question
// actually triggered a knowledge-base search:
// - knowledge searched → uncovered_no_references
// - no knowledge search → covered_non_knowledge_response
// 5. Has references → covered_has_references
//
// v1 never uses a score threshold. TopScore captures the highest reference score
// (0 if no references).
@@ -81,8 +91,60 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
}, nil
}
// No references → uncovered.
// Image turns are classified by whether knowledge search occurred.
if hasImageMedia(turn.Media) {
if len(turn.References) > 0 {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredHasReferences,
ReferenceCount: len(turn.References),
TopScore: maxScore(turn.References),
}, nil
}
if turn.KnowledgeSearched {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictUncovered,
ReasonCode: ReasonUncoveredNoReferences,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredVisualRecognition,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
// Text turns without references are only uncovered if they actually
// triggered knowledge search. This prevents greetings and other normal
// non-knowledge responses from generating artificial support cases.
if len(turn.References) == 0 {
if !turn.KnowledgeSearched {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredNonKnowledgeResponse,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
@@ -108,6 +170,16 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
}, nil
}
// hasImageMedia reports whether the turn carried at least one image input.
func hasImageMedia(media []core.MediaAudit) bool {
for _, m := range media {
if strings.EqualFold(strings.TrimSpace(m.Type), "image") {
return true
}
}
return false
}
func isNoAnswer(answer string) bool {
lower := strings.ToLower(answer)
for _, phrase := range noAnswerPhrases {
+105
View File
@@ -0,0 +1,105 @@
package coverage
import (
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
func imageTurn(answer string, refs []core.Reference, searched bool) core.Turn {
return core.Turn{
ID: "img-turn",
Status: "succeeded",
MessageType: "image",
Answer: answer,
References: refs,
Media: []core.MediaAudit{{Type: "image", MIMEType: "image/jpeg"}},
KnowledgeSearched: searched,
}
}
func TestEvaluator_ImageVisualRecognitionNoSearch(t *testing.T) {
e := NewEvaluator()
turn := imageTurn("这张图片展示了一个手写的知识库数据处理与发布流程图。", nil, false)
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered {
t.Fatalf("verdict=%s, want covered (visual recognition is not a coverage miss)", d.Verdict)
}
if d.ReasonCode != ReasonCoveredVisualRecognition {
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonCoveredVisualRecognition)
}
}
func TestEvaluator_ImageSearchButNoReferencesIsUncovered(t *testing.T) {
e := NewEvaluator()
turn := imageTurn("根据图片中的错误信息,未能在知识库中找到对应的解决方案。", nil, true)
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered {
t.Fatalf("verdict=%s, want uncovered (image turn searched KB but got no refs)", d.Verdict)
}
if d.ReasonCode != ReasonUncoveredNoReferences {
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonUncoveredNoReferences)
}
}
func TestEvaluator_ImageWithReferencesIsCovered(t *testing.T) {
e := NewEvaluator()
turn := imageTurn("根据知识库资料,这个是故障代码E5,请检查...", []core.Reference{{ID: "r1", Score: 0.9}}, true)
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered {
t.Fatalf("verdict=%s, want covered", d.Verdict)
}
if d.ReasonCode != ReasonCoveredHasReferences {
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
}
}
func TestEvaluator_TextTurnSearchedButNoReferencesIsUncovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "text-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
MessageType: "text",
References: []core.Reference{},
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredNoReferences {
t.Fatalf("verdict=%s reason=%s, want uncovered_no_references after knowledge search", d.Verdict, d.ReasonCode)
}
}
func TestEvaluator_TextTurnWithoutKnowledgeSearchIsCovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "smalltalk-turn",
Status: "succeeded",
Answer: "你好,我在。",
MessageType: "text",
KnowledgeSearched: false,
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered || d.ReasonCode != ReasonCoveredNonKnowledgeResponse {
t.Fatalf("verdict=%s reason=%s, want covered_non_knowledge_response", d.Verdict, d.ReasonCode)
}
}
+31 -9
View File
@@ -8,8 +8,8 @@ import (
func TestEvaluatorVersion(t *testing.T) {
e := NewEvaluator()
if e.Version() != "v3" {
t.Errorf("Evaluator version = %q, want v3", e.Version())
if e.Version() != "v4" {
t.Errorf("Evaluator version = %q, want v4", e.Version())
}
}
@@ -62,13 +62,34 @@ func TestEvaluator_UncoveredExplicitNoAnswer(t *testing.T) {
}
}
func TestEvaluator_ProductionMissingProductPhraseTrumpsReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "missing-product-turn",
Status: "succeeded",
Answer: "根据提供的检索信息,无法提供对应手册。原因:产品不存在于资料中;检索资料未提及该产品。建议联系技术支持部门。",
References: []core.Reference{
{ID: "unrelated-1", Score: 0.395940},
{ID: "unrelated-2", Score: 0.311595},
},
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatal(err)
}
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Fatalf("verdict=%s reason=%s", d.Verdict, d.ReasonCode)
}
}
func TestEvaluator_UncoveredNoReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
References: []core.Reference{},
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
References: []core.Reference{},
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
@@ -85,9 +106,10 @@ func TestEvaluator_UncoveredNoReferences(t *testing.T) {
func TestEvaluator_UncoveredNilReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但引用为nil",
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但引用为nil",
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
+6
View File
@@ -10,11 +10,17 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
const (
SupportTriggerCoverageUncovered = "coverage_uncovered"
SupportTriggerFeedbackDown = "feedback_down"
)
// SupportNotification is a safe, minimal view of the data needed to send a
// notification. It must never contain tokens, webhooks, raw API responses,
// raw error text, reference bodies, or group IDs.
type SupportNotification struct {
CaseID string
TriggerType string
Question string
SenderName string
Channel string
+6 -5
View File
@@ -76,11 +76,12 @@ func TestService_EvaluateAndPersist_UncoveredNoReferences(t *testing.T) {
s, _ := NewService(repo)
turn := core.Turn{
ID: "turn-2",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: "正常回答但没有引用",
ID: "turn-2",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: "正常回答但没有引用",
KnowledgeSearched: true,
}
d, err := s.EvaluateAndPersist(context.Background(), turn)
+10
View File
@@ -0,0 +1,10 @@
package coverage
import "testing"
func TestSupportNotificationAcceptsAuditableTriggerType(t *testing.T) {
notification := SupportNotification{TriggerType: SupportTriggerFeedbackDown}
if notification.TriggerType != "feedback_down" {
t.Fatalf("trigger=%q", notification.TriggerType)
}
}
+6 -4
View File
@@ -25,13 +25,15 @@ const (
// ReasonCode values.
const (
ReasonCoveredHasReferences = "covered_has_references"
ReasonUncoveredNoReferences = "uncovered_no_references"
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
ReasonCoveredHasReferences = "covered_has_references"
ReasonUncoveredNoReferences = "uncovered_no_references"
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
ReasonCoveredVisualRecognition = "covered_visual_recognition"
ReasonCoveredNonKnowledgeResponse = "covered_non_knowledge_response"
)
// EvaluatorVersion is the current version of the evaluator.
const EvaluatorVersion = "v3"
const EvaluatorVersion = "v4"
var (
ErrTurnNotSucceeded = errors.New("coverage evaluator: turn status must be succeeded")
+69
View File
@@ -0,0 +1,69 @@
package dingtalkconfig
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Result struct {
OK bool
Code string
CheckedAt time.Time
}
type Verifier struct {
endpoint string
client *http.Client
}
func NewVerifier(endpoint string, client *http.Client) *Verifier {
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
return &Verifier{endpoint: strings.TrimSpace(endpoint), client: client}
}
func (v *Verifier) Verify(ctx context.Context, id, secret string) Result {
r := Result{Code: "verification_failed", CheckedAt: time.Now().UTC()}
if strings.TrimSpace(id) == "" || strings.TrimSpace(secret) == "" {
r.Code = "credential_incomplete"
return r
}
u, err := url.Parse(v.endpoint)
if err != nil {
r.Code = "endpoint_invalid"
return r
}
q := u.Query()
q.Set("appkey", id)
q.Set("appsecret", secret)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return r
}
resp, err := v.client.Do(req)
if err != nil {
r.Code = "network_error"
return r
}
defer resp.Body.Close()
var body struct {
ErrCode int `json:"errcode"`
AccessToken string `json:"access_token"`
}
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil {
r.Code = "invalid_response"
return r
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || body.ErrCode != 0 || strings.TrimSpace(body.AccessToken) == "" {
r.Code = "credential_rejected"
return r
}
r.OK = true
r.Code = "ok"
return r
}
+35
View File
@@ -0,0 +1,35 @@
package dingtalkconfig
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestVerifierNeverReturnsToken(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"errcode":0,"access_token":"secret-token"}`))
}))
defer ts.Close()
v := NewVerifier(ts.URL, ts.Client())
result := v.Verify(context.Background(), "id", "secret")
if !result.OK {
t.Fatalf("result=%+v", result)
}
if strings.Contains(result.Code, "secret-token") {
t.Fatal("token leaked")
}
}
func TestVerifierRejectsBadCredential(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"errcode":40014,"errmsg":"bad secret"}`))
}))
defer ts.Close()
result := NewVerifier(ts.URL, ts.Client()).Verify(context.Background(), "id", "wrong")
if result.OK || result.Code != "credential_rejected" {
t.Fatalf("result=%+v", result)
}
}
+26 -1
View File
@@ -25,9 +25,16 @@ type AuditService interface {
GetTurn(context.Context, string) (audit.TurnDetail, error)
}
type BuildInfo struct {
Version string `json:"version"`
CommitSHA string `json:"commit_sha"`
BuildTime string `json:"build_time"`
}
type Options struct {
AuditService AuditService
AdminToken string
BuildInfo BuildInfo
}
type Server struct {
@@ -35,6 +42,7 @@ type Server struct {
ingressToken string
auditService AuditService
adminToken string
buildInfo BuildInfo
mux *http.ServeMux
}
@@ -58,13 +66,25 @@ func NewWithOptions(service QAService, ingressToken string, opts Options) (*Serv
if hasAdminToken && constantTimeTokenEqual(adminToken, ingressToken) {
return nil, errors.New("DOBOTAI_ADMIN_TOKEN must differ from DOBOTAI_INGRESS_TOKEN")
}
buildInfo := opts.BuildInfo
if buildInfo.Version == "" {
buildInfo.Version = "dev"
}
if buildInfo.CommitSHA == "" {
buildInfo.CommitSHA = "unknown"
}
if buildInfo.BuildTime == "" {
buildInfo.BuildTime = "unknown"
}
s := &Server{
service: service, ingressToken: ingressToken,
auditService: opts.AuditService, adminToken: adminToken,
mux: http.NewServeMux(),
buildInfo: buildInfo,
mux: http.NewServeMux(),
}
s.mux.HandleFunc("GET /healthz", s.health)
s.mux.HandleFunc("GET /version", s.version)
s.mux.HandleFunc("POST /v1/qa", s.authorizeToken(s.ingressToken, s.ask))
if hasAuditService {
s.mux.HandleFunc("GET /v1/admin/audit/turns", s.authorizeToken(s.adminToken, s.listAuditTurns))
@@ -102,6 +122,11 @@ func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) version(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, s.buildInfo)
}
func (s *Server) ask(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
defer r.Body.Close()
+40
View File
@@ -25,6 +25,46 @@ func (f *captureQA) Ask(_ context.Context, request core.QARequest) (core.QAResul
return core.QAResult{TurnID: "turn-capture", Answer: "answer"}, nil
}
func TestVersionEndpointReturnsBuildMetadataWithoutAuthentication(t *testing.T) {
server, err := NewWithOptions(fakeQA{}, "ingress-secret", Options{
BuildInfo: BuildInfo{
Version: "2.1.0",
CommitSHA: "abc123def456",
BuildTime: "2026-07-27T12:00:00Z",
},
})
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/version", nil)
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", result.Code, result.Body.String())
}
if got := result.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control=%q, want no-store", got)
}
want := `{"version":"2.1.0","commit_sha":"abc123def456","build_time":"2026-07-27T12:00:00Z"}`
if got := strings.TrimSpace(result.Body.String()); got != want {
t.Fatalf("body=%s, want %s", got, want)
}
}
func TestVersionEndpointUsesSafeDefaults(t *testing.T) {
server, err := New(fakeQA{}, "ingress-secret")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/version", nil)
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
want := `{"version":"dev","commit_sha":"unknown","build_time":"unknown"}`
if got := strings.TrimSpace(result.Body.String()); result.Code != http.StatusOK || got != want {
t.Fatalf("status=%d body=%s, want %s", result.Code, got, want)
}
}
func TestQAEndpointRequiresTokenAndReturnsResult(t *testing.T) {
server, err := New(fakeQA{}, "ingress-secret")
if err != nil {
+79
View File
@@ -0,0 +1,79 @@
package outboundimage
import (
"bytes"
"context"
"errors"
"io"
"strings"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
)
type AliyunOSSStore struct {
client *oss.Client
bucket string
}
func NewAliyunOSSStore(endpoint, region, accessKeyID, accessKeySecret, bucket string) (*AliyunOSSStore, error) {
endpoint = strings.TrimSpace(endpoint)
region = strings.TrimSpace(region)
accessKeyID = strings.TrimSpace(accessKeyID)
accessKeySecret = strings.TrimSpace(accessKeySecret)
bucket = strings.TrimSpace(bucket)
if endpoint == "" || region == "" || accessKeyID == "" || accessKeySecret == "" || bucket == "" {
return nil, errors.New("outbound image: OSS endpoint, region, credentials, and bucket are required")
}
provider := credentials.NewStaticCredentialsProvider(accessKeyID, accessKeySecret, "")
config := oss.LoadDefaultConfig().
WithCredentialsProvider(provider).
WithRegion(region).
WithEndpoint(endpoint)
return &AliyunOSSStore{client: oss.NewClient(config), bucket: bucket}, nil
}
func (s *AliyunOSSStore) Put(ctx context.Context, key string, data []byte, contentType string) error {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || len(data) == 0 {
return errors.New("outbound image: invalid OSS put request")
}
_, err := s.client.PutObject(ctx, &oss.PutObjectRequest{
Bucket: oss.Ptr(s.bucket),
Key: oss.Ptr(key),
Body: bytes.NewReader(data),
ContentType: oss.Ptr(contentType),
})
if err != nil {
return errors.New("outbound image: OSS put failed")
}
return nil
}
func (s *AliyunOSSStore) Get(ctx context.Context, key string, maxBytes int64) ([]byte, string, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || maxBytes <= 0 {
return nil, "", errors.New("outbound image: invalid OSS get request")
}
result, err := s.client.GetObject(ctx, &oss.GetObjectRequest{
Bucket: oss.Ptr(s.bucket),
Key: oss.Ptr(key),
})
if err != nil {
return nil, "", errors.New("outbound image: OSS get failed")
}
defer result.Body.Close()
if result.ContentLength > maxBytes {
return nil, "", errors.New("outbound image: OSS object exceeds size limit")
}
data, err := io.ReadAll(io.LimitReader(result.Body, maxBytes+1))
if err != nil {
return nil, "", errors.New("outbound image: OSS read failed")
}
if int64(len(data)) > maxBytes {
return nil, "", errors.New("outbound image: OSS object exceeds size limit")
}
contentType := ""
if result.ContentType != nil {
contentType = strings.TrimSpace(*result.ContentType)
}
return data, contentType, nil
}
+58
View File
@@ -0,0 +1,58 @@
package outboundimage
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
)
func TestAliyunOSSStoreGetReturnsBoundedImageBytesAndMIME(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/private-bucket/dobotai/im-images/id" {
t.Fatalf("method=%s path=%s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", "4")
_, _ = w.Write([]byte("PNG!"))
}))
defer server.Close()
config := oss.LoadDefaultConfig().
WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test-id", "test-secret", "")).
WithRegion("cn-test").
WithEndpoint(server.URL).
WithUsePathStyle(true)
store := &AliyunOSSStore{client: oss.NewClient(config), bucket: "private-bucket"}
data, contentType, err := store.Get(context.Background(), "dobotai/im-images/id", 4)
if err != nil {
t.Fatal(err)
}
if string(data) != "PNG!" || contentType != "image/png" {
t.Fatalf("data=%q contentType=%q", data, contentType)
}
}
func TestAliyunOSSStoreGetRejectsOversizedObjects(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", "5")
_, _ = w.Write([]byte("PNG!!"))
}))
defer server.Close()
config := oss.LoadDefaultConfig().
WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test-id", "test-secret", "")).
WithRegion("cn-test").
WithEndpoint(server.URL).
WithUsePathStyle(true)
store := &AliyunOSSStore{client: oss.NewClient(config), bucket: "private-bucket"}
if _, _, err := store.Get(context.Background(), "dobotai/im-images/id", 4); err == nil {
t.Fatal("expected oversized object error")
}
}

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