Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70b144f1e5 | |||
| 1bbaf2f07a | |||
| 48b741984f | |||
| 5ccaeb3b96 | |||
| dfce8cb9f6 | |||
| 149e94b34b | |||
| dd229ec0ad | |||
| 92324b4242 | |||
| 882ac62433 | |||
| 8a9c1eaf82 | |||
| 9de1ed66aa | |||
| cfe3271fa2 | |||
| dfc7c8dba9 | |||
| 06d11135e1 | |||
| 5f1206b545 | |||
| a1a2aabab4 | |||
| fcca33050a | |||
| cd3f92aaaa | |||
| 15f2a11162 | |||
| a096ba5f51 | |||
| ba090d1705 | |||
| 4e65028d1d | |||
| c7d2629a77 | |||
| b49cb5eaf9 | |||
| 16aa981028 | |||
| f800127461 | |||
| d6a781ba5b | |||
| 168e401bc0 | |||
| 58b37f1c24 | |||
| 6a3f505965 | |||
| a0f1fbf391 | |||
| 49e0789fa5 | |||
| d77fbd675b | |||
| a615693bd0 | |||
| febf99b549 | |||
| b4b61c0aa5 | |||
| 6153533b46 | |||
| 27d4d45af4 | |||
| a31156dc4e | |||
| f408096611 | |||
| 218ab657d6 | |||
| 5c5e4494cb | |||
| e584b17929 | |||
| 8027635330 | |||
| 563329320b | |||
| 9abbb2cb2b | |||
| 4c5b1e3b5a | |||
| fa938ca1eb | |||
| 7661e1b8f7 | |||
| bfa1b1b25f | |||
| 0798a2dac7 | |||
| 18c81232ce | |||
| 19ed4b50df | |||
| 9f65c3eef0 | |||
| 50379b3b87 | |||
| 3f9ff359b9 | |||
| a3defdd1c0 | |||
| b4b68d8b21 | |||
| 365f1b8c7d | |||
| 725db721a8 | |||
| 5d68b22636 | |||
| 6f24743be2 | |||
| 4dbc20ee74 | |||
| e5be9ee780 | |||
| 0f30f8cec8 | |||
| 44d9398c78 | |||
| b207ee4089 | |||
| 0fc64ba77d | |||
| a7432aed49 | |||
| 5036071ac2 | |||
| 2d9490f424 | |||
| 462ee1f3ff | |||
| 8d2d4b469b | |||
| 9363d10e44 | |||
| 74c9af9944 | |||
| 5c35a49486 | |||
| 357e9bad5c | |||
| 2c0ed7cc67 | |||
| aa0513eb59 | |||
| e582d851a4 | |||
| 0a3f62618e | |||
| 5e0694835a | |||
| 39c5fbbaef | |||
| d29b28be13 | |||
| 774ccd9454 | |||
| 86593df57c | |||
| 913a65b16d | |||
| a6569ee4bf | |||
| 5b5865dc07 | |||
| 4366c018f9 | |||
| a952232cc9 | |||
| 4dc3f22784 | |||
| b8f572cc84 | |||
| fcf4559c9a | |||
| 9fa2eac6a4 | |||
| da69226481 |
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
bin/
|
||||
.acceptance-canary/
|
||||
.hermes/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
+36
-11
@@ -1,27 +1,52 @@
|
||||
# DobotAI configuration
|
||||
# DobotAI 基础配置
|
||||
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
|
||||
DOBOTAI_INGRESS_TOKEN=replace-me
|
||||
# Optional local-only audit API. Use a distinct secret from DOBOTAI_INGRESS_TOKEN.
|
||||
# When set, DOBOTAI_TENANT_KEY must be set and the listen address must be loopback-only.
|
||||
# 可选的仅本机审计 API。必须使用不同于 DOBOTAI_INGRESS_TOKEN 的独立密钥。
|
||||
# 设置后必须同时设置 DOBOTAI_TENANT_KEY,且监听地址必须仅为回环地址。
|
||||
DOBOTAI_ADMIN_TOKEN=
|
||||
DOBOTAI_GROUP_SESSION_POLICY=shared
|
||||
DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"replace-with-agent-id"}
|
||||
# `/think` 路由必填。上面的基础绑定保持稳定的会话身份;以下配置仅选择每个 Turn 的执行 Agent。
|
||||
DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer
|
||||
DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning
|
||||
|
||||
# Official WeKnora API only
|
||||
# 仅使用 WeKnora 官方 API
|
||||
WEKNORA_BASE_URL=http://127.0.0.1:8080
|
||||
WEKNORA_API_KEY=replace-me
|
||||
|
||||
# DingTalk Stream mode (official SDK). Leave all three empty to disable the
|
||||
# DingTalk channel; set all three to enable it. Never commit real values.
|
||||
# 钉钉 Stream 模式(官方 SDK)。三项均留空时禁用钉钉渠道;三项同时设置时启用。不得提交真实值。
|
||||
DINGTALK_CLIENT_ID=
|
||||
DINGTALK_CLIENT_SECRET=
|
||||
# Tenant key this DingTalk ingress serves (matches tenant_key used by QA).
|
||||
# 该钉钉接入服务的租户键(必须与 QA 使用的 tenant_key 一致)。
|
||||
DOBOTAI_TENANT_KEY=
|
||||
|
||||
# Optional DingTalk AI card template ID. Leave empty to use markdown-only replies.
|
||||
# When set, a successful QA answer will be delivered as an AI card; markdown
|
||||
# is only sent as a fallback when card delivery fails. Never commit real values.
|
||||
# 可选的钉钉 AI 卡片模板 ID。留空时仅使用 Markdown 回复。
|
||||
# 设置后,成功的 QA 回答优先通过 AI 卡片发送;仅当卡片发送失败时才回退 Markdown。不得提交真实值。
|
||||
DINGTALK_CARD_TEMPLATE_ID=
|
||||
# 可选、临时的钉钉卡片调试白名单。设置后,只有该指定钉钉用户会在 per-user privateData 中收到 _CARD_DEBUG_TOOL_ENTRY=show。
|
||||
# 非受控调试期间必须留空;不得提交真实用户 ID。
|
||||
DINGTALK_CARD_DEBUG_USER_ID=
|
||||
|
||||
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
|
||||
# 可选的钉钉 FAQ 表工作流,用于未覆盖问题。四项必须同时设置;四项均留空时禁用。
|
||||
# 启用下方人工支持群通知时,此工作流为必需项。不得提交真实 ID。
|
||||
DINGTALK_FAQ_TABLE_BASE_ID=
|
||||
DINGTALK_FAQ_TABLE_SHEET_ID=
|
||||
DINGTALK_FAQ_TABLE_VIEW_ID=
|
||||
DINGTALK_FAQ_TABLE_OPERATOR_ID=
|
||||
|
||||
# 可选的钉钉卡片回答图片外发。任一项设置后,以下五项都必须设置。
|
||||
# DobotAI 仅从 WEKNORA_BASE_URL 的 /files 鉴权下载 resource:// 图片,在 dobotai/im-images/ 下写入内容哈希副本,并生成有效期2小时的私有 OSS GET URL。
|
||||
# Bucket 必须保持私有,并配置 OSS 生命周期规则,在1天后删除 dobotai/im-images/ 对象。不得提交真实凭据。
|
||||
DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT=
|
||||
DOBOTAI_OUTBOUND_IMAGE_OSS_REGION=
|
||||
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID=
|
||||
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET=
|
||||
DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET=
|
||||
|
||||
# 可选的未覆盖回答人工支持群通知。该值是群的 openConversationId,不是 webhook。
|
||||
# 留空时仍记录覆盖判定和待处理 Support Case,但不发送群通知。
|
||||
# 启用后必须同时配置上方 FAQ 表工作流,使 ActionCard 能链接到准确的 FAQ 记录。不得提交真实群 ID。
|
||||
DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID=
|
||||
|
||||
# 独立的 DobotAI PostgreSQL;不得指向 WeKnora 数据库
|
||||
DATABASE_URL=postgres://dobotai:replace-me@127.0.0.1:5432/dobotai?sslmode=disable
|
||||
|
||||
+11
@@ -5,6 +5,17 @@
|
||||
*.local
|
||||
coverage.out
|
||||
|
||||
# Local deployment, backup, and production diagnostic artifacts
|
||||
/.deploy/
|
||||
/backups/
|
||||
/check-live-dobotai.py
|
||||
|
||||
# Transient local validation and legacy test-deployment artifacts
|
||||
/dobotai
|
||||
/.acceptance-canary/
|
||||
/.hermes/
|
||||
/deploy_stable_images_test.py
|
||||
|
||||
# Editors and OS
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
FROM golang:1.26.5-bookworm AS build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
ARG GOPROXY=https://proxy.golang.org,direct
|
||||
ENV GOPROXY=${GOPROXY}
|
||||
RUN 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"]
|
||||
@@ -1,76 +1,236 @@
|
||||
# DobotAI Platform
|
||||
# DobotAI 平台
|
||||
|
||||
DobotAI is the external enterprise integration layer for official WeKnora. It does not import WeKnora packages, modify WeKnora source, or access the WeKnora database.
|
||||
DobotAI 是 WeKnora 的企业外部渠道集成层,负责钉钉协议、身份与会话映射、消息编排、流式卡片、媒体收发、覆盖判定和人工支持通知。它只通过 WeKnora 官方 REST/SSE API 调用知识问答与 Agent Chat:不导入 WeKnora 私有包、不修改 WeKnora 源码,也不访问 WeKnora 数据库。
|
||||
|
||||
Current milestone: channel-bound Agent QA core.
|
||||
## 当前能力
|
||||
|
||||
Implemented:
|
||||
- 通过官方 `POST /api/v1/sessions` 与 `POST /api/v1/agent-chat/:session_id` 调用 WeKnora;
|
||||
- 严格解析 SSE 的 `answer`、`references`、`tool_call`、`tool_result`、`error` 与终止事件;`error + done=false` 被视为可恢复的工具错误,不会提前结束钉钉卡片;
|
||||
- 解析普通引用及智能推理工具结果中的检索证据,供覆盖判定与后端审计使用;
|
||||
- 按渠道、会话和用户维护长期 WeKnora Session,并保证回调幂等;
|
||||
- 钉钉 Stream 机器人支持文本、richText 以及多图片输入;图片下载经过钉钉官方接口、大小与文件签名校验后,才以 WeKnora 通用多模态请求发送;
|
||||
- 钉钉 AI 流式卡片与最终消息回退;通道侧保留原始回答,不修改 WeKnora 持久化消息;
|
||||
- 默认快速问答,支持 `/think <问题>` 单次推理、`/think on` 持续推理、`/think off` 或 `/rag` 关闭推理;快速问答与智能推理共用同一 WeKnora Session;
|
||||
- 用户级推理轮次、空闲到期、提示开关和首次使用指引;
|
||||
- 成功回答的覆盖判定、持久化 Support Case、FAQ Outbox 与钉钉人工支持群通知;
|
||||
- 对已覆盖答案点踩时创建 `feedback_down` 人工处理提醒;对已先因未覆盖创建 Case 的同一问题保持“一题一个 Case”幂等;
|
||||
- 可选的钉钉问题表异步写入;写入失败不会改变问答成功状态;
|
||||
- 可选的回答图片外发:仅将 WeKnora `resource://` 图片通过鉴权下载、内容哈希转存到私有 OSS 后,以受控 HTTPS 地址提供给钉钉。
|
||||
|
||||
- official `POST /api/v1/sessions` adapter;
|
||||
- official `POST /api/v1/agent-chat/:session_id` adapter;
|
||||
- fail-closed SSE parsing for `references`, `answer`, `error`, and terminal `done`;
|
||||
- per-channel/per-conversation Agent binding;
|
||||
- long-lived direct-user sessions;
|
||||
- shared-group sessions, with optional group+user policy;
|
||||
- external-message idempotency;
|
||||
- independent PostgreSQL session/turn storage;
|
||||
- authenticated internal `POST /v1/qa` ingress endpoint.
|
||||
- DingTalk Stream text ingress (official SDK) wired into `cmd/dobotai`.
|
||||
## 架构边界
|
||||
|
||||
## DingTalk channel (Stream, text only)
|
||||
```text
|
||||
钉钉 / 内部调用方
|
||||
↓
|
||||
DobotAI
|
||||
├─ 钉钉 Stream、richText、图片、AI 卡片、反馈与通知
|
||||
├─ 独立 PostgreSQL:渠道身份、消息、会话映射、Turn、覆盖与支持流程
|
||||
└─ WeKnora 官方 REST / SSE API
|
||||
↓
|
||||
WeKnora:检索、Agent、知识与引用
|
||||
```
|
||||
|
||||
- Uses the official DingTalk Stream SDK (`dingtalk-stream-sdk-go`); no public webhook endpoint is exposed.
|
||||
- Text messages only for now. Images, OpenAPI fallback, and card replies are not implemented yet.
|
||||
- Group chats are handled only when the bot is @-mentioned; other group messages are ignored.
|
||||
- Sessions: direct chats use one long-lived session per user; group chats share one session per group (default `shared` policy).
|
||||
- Replies are sent via the callback's `sessionWebhook`, which is fail-closed: only `https` URLs on `*.dingtalk.com` (port 443, no redirects) are accepted.
|
||||
- Required env vars to enable (all three or none): `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, `DOBOTAI_TENANT_KEY`. Partial configuration is a fatal startup error.
|
||||
责任边界:
|
||||
|
||||
## WeKnora baseline
|
||||
- **DobotAI**:钉钉协议、媒体收发、会话映射、命令路由、卡片、覆盖判定、人工支持、渠道审计。
|
||||
- **WeKnora**:通用 Agent Chat、检索、知识存储、引用与模型调用。
|
||||
- **DobotAI PostgreSQL**:只保存渠道与业务编排数据,不保存 WeKnora 知识正文。
|
||||
- **Redis**:仅用于队列、重试、幂等租约和短期状态。
|
||||
|
||||
Target policy: latest stable official Release/Tag. The newest locally fetched stable tag is `v0.6.3`. Online GitHub verification is pending because DNS/proxy access was unavailable during initialization.
|
||||
## 目录结构
|
||||
|
||||
## Run
|
||||
```text
|
||||
.
|
||||
├── cmd/
|
||||
│ ├── dobotai/ 主程序入口、运行时装配与后台 Worker
|
||||
│ └── directory-import/ 钉钉通讯录导入命令
|
||||
├── internal/
|
||||
│ ├── channels/dingtalk/ 钉钉 Stream、卡片、媒体、反馈与群通知
|
||||
│ ├── qa/ 问答主流程、WeKnora 调用和检索信号记录
|
||||
│ ├── weknora/ SSE、工具结果和引用解析
|
||||
│ ├── coverage/ 覆盖判定、Support Case 和人工提醒
|
||||
│ ├── reasoningmode/ /think 推理模式
|
||||
│ ├── settings/ /setting 与首位管理员绑定
|
||||
│ ├── outboundimage/ resource:// 图片私有 OSS 外发
|
||||
│ ├── questiontable/ 问题表工作流
|
||||
│ ├── faqtable/ FAQ 表工作流
|
||||
│ ├── directory/ 钉钉通讯录查询与导入
|
||||
│ ├── audit/ 审计查询
|
||||
│ ├── httpapi/ /healthz、/version、/v1/qa 与审计 API
|
||||
│ ├── core/ 核心领域类型
|
||||
│ ├── session/ 会话键和会话标题
|
||||
│ ├── agentbinding/ 渠道到 WeKnora Agent 的绑定
|
||||
│ ├── feedback/ 点赞/点踩领域逻辑
|
||||
│ ├── secretstore/ 本地密钥引用存储
|
||||
│ ├── dingtalkconfig/ 钉钉凭据校验
|
||||
│ ├── runtime/ 运行时管理
|
||||
│ └── store/postgres/ PostgreSQL 持久化实现
|
||||
├── db/migrations/ SQL 迁移及其契约测试
|
||||
├── deploy/dobotai-dingtalk/ 容器 Compose、安装/配置/启动/绑定脚本和部署手册
|
||||
├── docs/ 架构说明与 API 边界文档
|
||||
├── scripts/ 本地部署与部署资产契约测试
|
||||
├── Dockerfile DobotAI 容器镜像构建
|
||||
├── .env.example 无真实凭据的配置示例
|
||||
└── README.md 项目功能、边界和开发说明
|
||||
```
|
||||
|
||||
1. Create an independent PostgreSQL database named `dobotai`.
|
||||
2. Apply `db/migrations/000001_core.up.sql` to that database only.
|
||||
3. Copy `.env.example` to a local `.env` and supply secrets outside Git.
|
||||
4. Export the variables and run:
|
||||
目录职责规则:`cmd/`只装配进程,`internal/`承载业务模块,`internal/store/postgres/`只实现数据库持久化,`db/migrations/`只放Schema变更,`deploy/`只放容器运行资产。历史计划、备份、镜像归档和真实环境文件均不进入Git。
|
||||
|
||||
## 部署方式
|
||||
|
||||
| 方式 | 适用场景 | 数据库 | 入口 |
|
||||
| --- | --- | --- | --- |
|
||||
| 源码/原生二进制 | 本地开发、快速调试 | 手工配置阿里云RDS连接 | `go run ./cmd/dobotai` |
|
||||
| Docker | 测试、预发布、生产候选 | 既有阿里云RDS;Docker不运行数据库 | `sudo bash ./install-docker.sh` |
|
||||
|
||||
### 源码部署
|
||||
|
||||
```bash
|
||||
go run ./cmd/dobotai
|
||||
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
|
||||
```
|
||||
|
||||
源码方式不自动安装Docker、不创建目录、不检查RDS表,也不适合作为长期渠道消费者。
|
||||
|
||||
### Docker + 阿里云RDS部署
|
||||
|
||||
克隆仓库后在根目录执行:
|
||||
|
||||
```bash
|
||||
sudo bash ./install-docker.sh
|
||||
sudo dobotai-dingtalk-configure
|
||||
```
|
||||
|
||||
第一条命令自动安装Docker/Compose(如缺失)、构建应用镜像、准备目录和配置模板;第二条命令安全录入RDS、WeKnora与钉钉配置,然后检查RDS表:空库初始化`000001`至`000021`,完整表不写入,异常/半成品表拒绝启动。Docker只运行`dobotai-dingtalk`,不创建`dobotai-postgres`、本地数据卷或本地数据库。
|
||||
|
||||
完整安装、RDS检查、升级和回滚见 [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`。
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string, out io.Writer) error {
|
||||
flags := flag.NewFlagSet("directory-import", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
csvPath := flags.String("csv", "", "path to tested DingTalk directory CSV output")
|
||||
tenant := flags.String("tenant", "", "tenant key")
|
||||
databaseURLFile := flags.String("database-url-file", "", "file containing DATABASE_URL")
|
||||
validateOnly := flags.Bool("validate-only", false, "validate CSV without connecting to PostgreSQL")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return errors.New("invalid directory import arguments")
|
||||
}
|
||||
if strings.TrimSpace(*csvPath) == "" || strings.TrimSpace(*tenant) == "" {
|
||||
return errors.New("-csv and -tenant are required")
|
||||
}
|
||||
input, err := os.Open(*csvPath)
|
||||
if err != nil {
|
||||
return errors.New("open directory CSV failed")
|
||||
}
|
||||
defer input.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if *validateOnly {
|
||||
count, importErr := directory.ImportCSV(ctx, *tenant, input, validationWriter{})
|
||||
if importErr != nil {
|
||||
return errors.New("directory CSV validation failed")
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, "validated_profiles=%d\n", count)
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(*databaseURLFile) == "" {
|
||||
return errors.New("-database-url-file is required for import")
|
||||
}
|
||||
databaseURL, err := readSecretFile(*databaseURLFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := postgres.Open(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return errors.New("open directory database failed")
|
||||
}
|
||||
defer store.Close()
|
||||
count, err := directory.ImportCSV(ctx, *tenant, input, store)
|
||||
if err != nil {
|
||||
return errors.New("directory CSV import failed")
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, "imported_profiles=%d\n", count)
|
||||
return nil
|
||||
}
|
||||
|
||||
type validationWriter struct{}
|
||||
|
||||
func (validationWriter) UpsertDirectoryProfiles(context.Context, string, []directory.Profile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSecretFile(path string) (string, error) {
|
||||
raw, err := os.ReadFile(strings.TrimSpace(path))
|
||||
if err != nil { return "", errors.New("read database URL file failed") }
|
||||
if len(raw) > 16<<10 { return "", errors.New("database URL file is too large") }
|
||||
value := strings.TrimSpace(string(raw))
|
||||
if value == "" { return "", errors.New("database URL file is empty") }
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadSecretFileTrimsValue(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "database_url")
|
||||
if err := os.WriteFile(path, []byte(" postgres://secret-value \n"), 0o600); err != nil { t.Fatal(err) }
|
||||
value, err := readSecretFile(path)
|
||||
if err != nil { t.Fatalf("readSecretFile: %v", err) }
|
||||
if value != "postgres://secret-value" { t.Fatalf("value=%q", value) }
|
||||
}
|
||||
|
||||
func TestReadSecretFileErrorDoesNotLeakContents(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "empty")
|
||||
if err := os.WriteFile(path, []byte(""), 0o600); err != nil { t.Fatal(err) }
|
||||
_, err := readSecretFile(path)
|
||||
if err == nil { t.Fatal("expected error") }
|
||||
if strings.Contains(err.Error(), "secret-value") { t.Fatalf("error leaked secret: %v", err) }
|
||||
}
|
||||
|
||||
func TestRunValidateOnlyDoesNotRequireDatabase(t *testing.T) {
|
||||
csvPath := filepath.Join(t.TempDir(), "directory.csv")
|
||||
if err := os.WriteFile(csvPath, []byte("user_id,union_id,name,nickname,title,department_ids,dept_name\nstaff-1,,张三,,工程师,100,技术支持部\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
err := run([]string{"-csv", csvPath, "-tenant", "tenant-a", "-validate-only"}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("run validate-only: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "validated_profiles=1") {
|
||||
t.Fatalf("output=%q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresCSVAndDatabaseURLFileBeforeConnecting(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
for _, args := range [][]string{{}, {"-csv", "snapshot.csv"}} {
|
||||
err := run(args, &out)
|
||||
if err == nil { t.Fatalf("run(%v) expected error", args) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cardWideTableGroupingMode returns the configured handling of over-wide answer
|
||||
// tables (see dingtalk.tableGroupingForMode):
|
||||
//
|
||||
// unset / on → grouped list on every frame (historical behaviour)
|
||||
// off → real tables on every frame
|
||||
// stream → grouped list while streaming, real table on the final frame
|
||||
//
|
||||
// The default stays "on" so an environment without the variable renders exactly
|
||||
// as before; DINGTALK_CARD_WIDE_TABLE_GROUP selects the other modes.
|
||||
func cardWideTableGroupingMode() string {
|
||||
mode := strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_WIDE_TABLE_GROUP")))
|
||||
switch mode {
|
||||
case "off", "0", "false", "no", "stream", "streaming", "partial":
|
||||
log.Printf("DingTalk wide-table grouping mode=%s", mode)
|
||||
return mode
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMainWiresDirectoryResolverIntoDingTalkHandler(t *testing.T) {
|
||||
raw, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
src := string(raw)
|
||||
for _, required := range []string{
|
||||
"directory.NewDingTalkLookup",
|
||||
"directory.NewResolver",
|
||||
} {
|
||||
if !strings.Contains(src, required) {
|
||||
t.Errorf("main.go missing %q", required)
|
||||
}
|
||||
}
|
||||
if !regexp.MustCompile(`Directory:\s*directoryResolver`).MatchString(src) {
|
||||
t.Error("main.go does not wire directoryResolver into HandlerOptions.Directory")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
|
||||
)
|
||||
|
||||
type faqConfig struct {
|
||||
BaseID string
|
||||
SheetID string
|
||||
ViewID string
|
||||
OperatorID string
|
||||
FieldMapping map[string]string
|
||||
}
|
||||
|
||||
func readFAQConfig() (faqConfig, error) {
|
||||
cfg := faqConfig{
|
||||
BaseID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_BASE_ID")),
|
||||
SheetID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_SHEET_ID")),
|
||||
ViewID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_VIEW_ID")),
|
||||
OperatorID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_OPERATOR_ID")),
|
||||
}
|
||||
blank := cfg.BaseID == "" && cfg.SheetID == "" && cfg.ViewID == "" && cfg.OperatorID == ""
|
||||
complete := cfg.BaseID != "" && cfg.SheetID != "" && cfg.ViewID != "" && cfg.OperatorID != ""
|
||||
if blank {
|
||||
return faqConfig{}, nil
|
||||
}
|
||||
if !complete {
|
||||
return faqConfig{}, errors.New("DingTalk FAQ table config must be complete or disabled")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func buildFAQDispatcher(repo faqtable.ExportRepository, clientID, clientSecret string, cfg faqConfig) (*faqtable.Dispatcher, error) {
|
||||
if cfg.BaseID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if repo == nil {
|
||||
return nil, errors.New("FAQ dispatcher repository is required")
|
||||
}
|
||||
client, err := questiontable.NewClient(questiontable.Config{
|
||||
ClientID: clientID, Secret: clientSecret, BaseID: cfg.BaseID,
|
||||
SheetID: cfg.SheetID, OperatorID: cfg.OperatorID, FieldMapping: cfg.FieldMapping,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return faqtable.NewDispatcher(repo, client, nil)
|
||||
}
|
||||
|
||||
type faqPollerRunner interface {
|
||||
RunOnce(context.Context) (faqtable.DispatcherStats, error)
|
||||
}
|
||||
|
||||
func runFAQPoller(ctx context.Context, runner faqPollerRunner, interval time.Duration) {
|
||||
if runner == nil || interval <= 0 {
|
||||
return
|
||||
}
|
||||
run := func() {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
stats, err := runner.RunOnce(runCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("FAQ table dispatch failed")
|
||||
return
|
||||
}
|
||||
if stats.Claimed > 0 {
|
||||
log.Printf("FAQ table dispatch: claimed=%d exported=%d failed=%d", stats.Claimed, stats.Exported, stats.Failed)
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
run()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
run()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
)
|
||||
|
||||
type faqRepoStub struct{}
|
||||
|
||||
func (*faqRepoStub) Claim(context.Context, int, int, time.Time) ([]faqtable.PendingExport, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*faqRepoStub) MarkExported(context.Context, string, string) error { return nil }
|
||||
func (*faqRepoStub) MarkFailed(context.Context, string, string) error { return nil }
|
||||
|
||||
func TestReadFAQConfigDisabledWhenAllBlank(t *testing.T) {
|
||||
for _, key := range []string{"DINGTALK_FAQ_TABLE_BASE_ID", "DINGTALK_FAQ_TABLE_SHEET_ID", "DINGTALK_FAQ_TABLE_VIEW_ID", "DINGTALK_FAQ_TABLE_OPERATOR_ID"} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
cfg, err := readFAQConfig()
|
||||
if err != nil || cfg.BaseID != "" {
|
||||
t.Fatalf("cfg=%+v err=%v", cfg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFAQConfigRejectsPartialValues(t *testing.T) {
|
||||
t.Setenv("DINGTALK_FAQ_TABLE_BASE_ID", "base")
|
||||
t.Setenv("DINGTALK_FAQ_TABLE_SHEET_ID", "")
|
||||
t.Setenv("DINGTALK_FAQ_TABLE_VIEW_ID", "view")
|
||||
t.Setenv("DINGTALK_FAQ_TABLE_OPERATOR_ID", "operator")
|
||||
if _, err := readFAQConfig(); err == nil {
|
||||
t.Fatal("expected partial-config error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFAQDispatcherRequiresRepositoryWhenEnabled(t *testing.T) {
|
||||
cfg := faqConfig{BaseID: "base", SheetID: "sheet", ViewID: "view", OperatorID: "operator"}
|
||||
if _, err := buildFAQDispatcher(nil, "client", "secret", cfg); err == nil {
|
||||
t.Fatal("expected repository error")
|
||||
}
|
||||
if d, err := buildFAQDispatcher(&faqRepoStub{}, "client", "secret", cfg); err != nil || d == nil {
|
||||
t.Fatalf("dispatcher=%v err=%v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainWiresFAQExportBeforeSupportNotification(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{
|
||||
"readFAQConfig()", "postgres.NewFAQExportRepository(store)",
|
||||
"buildFAQDispatcher(", "runFAQPoller(ctx, faqDispatcher, 5*time.Second)",
|
||||
"faqtable.NewRecordLinkBuilder(", "buildSupportDispatcher(",
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("main FAQ wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
if strings.Index(source, "runFAQPoller(ctx") > strings.Index(source, "runSupportDispatcher(ctx") {
|
||||
t.Fatal("FAQ exporter must start before support notifications")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvExampleDocumentsFAQWorkflowConfig(t *testing.T) {
|
||||
data, err := os.ReadFile("../../.env.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"DINGTALK_FAQ_TABLE_BASE_ID", "DINGTALK_FAQ_TABLE_SHEET_ID", "DINGTALK_FAQ_TABLE_VIEW_ID", "DINGTALK_FAQ_TABLE_OPERATOR_ID"} {
|
||||
if !strings.Contains(string(data), key+"=") {
|
||||
t.Errorf(".env.example missing %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
+279
-10
@@ -8,23 +8,49 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/agentbinding"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/httpapi"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/qa"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commitSHA = "unknown"
|
||||
buildTime = "unknown"
|
||||
)
|
||||
|
||||
func runtimeBuildInfo() httpapi.BuildInfo {
|
||||
return httpapi.BuildInfo{
|
||||
Version: version,
|
||||
CommitSHA: commitSHA,
|
||||
BuildTime: buildTime,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
if len(os.Args) == 2 && os.Args[1] == "bootstrap-admin" {
|
||||
if err := runBootstrapAdminCommand(ctx, os.Stdout); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var restartRequested atomic.Bool
|
||||
storeCtx, storeCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer storeCancel()
|
||||
store, err := postgres.Open(storeCtx, os.Getenv("DATABASE_URL"))
|
||||
@@ -32,7 +58,23 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second)
|
||||
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")
|
||||
}
|
||||
// 0 = no client-side total timeout. WeKnora's smart-reasoning agent can run
|
||||
// 20+ tool rounds and stream far longer than 120s; the total-time cap cut the
|
||||
// SSE mid-answer and the failure branch overwrote the streamed card.
|
||||
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 0)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -40,6 +82,26 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defaultMode := reasoningmode.ModeQuick
|
||||
if raw := strings.TrimSpace(os.Getenv("DOBOTAI_DEFAULT_MODE")); raw != "" {
|
||||
switch raw {
|
||||
case "reasoning":
|
||||
defaultMode = reasoningmode.ModeReasoning
|
||||
case "quick":
|
||||
defaultMode = reasoningmode.ModeQuick
|
||||
default:
|
||||
log.Fatal("DOBOTAI_DEFAULT_MODE must be quick or reasoning")
|
||||
}
|
||||
}
|
||||
reasoningService, err := reasoningmode.NewService(
|
||||
store,
|
||||
os.Getenv("DOBOTAI_QUICK_AGENT_ID"),
|
||||
os.Getenv("DOBOTAI_REASONING_AGENT_ID"),
|
||||
reasoningmode.WithDefaultMode(defaultMode),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
groupPolicy := session.GroupPolicy(strings.TrimSpace(os.Getenv("DOBOTAI_GROUP_SESSION_POLICY")))
|
||||
if groupPolicy == "" {
|
||||
groupPolicy = session.GroupShared
|
||||
@@ -47,33 +109,173 @@ func main() {
|
||||
if groupPolicy != session.GroupShared && groupPolicy != session.GroupPerUser {
|
||||
log.Fatal("DOBOTAI_GROUP_SESSION_POLICY must be shared or per_user")
|
||||
}
|
||||
qaService, err := qa.New(store, bindings, client, groupPolicy)
|
||||
coverageService, err := coverage.NewService(store)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
address := strings.TrimSpace(os.Getenv("DOBOTAI_LISTEN_ADDR"))
|
||||
if address == "" {
|
||||
address = "127.0.0.1:8090"
|
||||
qaService, err := qa.New(
|
||||
store,
|
||||
bindings,
|
||||
client,
|
||||
groupPolicy,
|
||||
qa.WithSucceededTurnObserver(coverageService, func(err error) {
|
||||
log.Printf("coverage persistence failed: %v", err)
|
||||
}),
|
||||
qa.WithReasoningModeFinalizer(reasoningService, func(err error) {
|
||||
log.Printf("reasoning mode completion failed: %T", err)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
auditOptions, err := buildAuditOptions(store, os.Getenv("DOBOTAI_TENANT_KEY"), os.Getenv("DOBOTAI_ADMIN_TOKEN"), address)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
auditOptions.BuildInfo = runtimeBuildInfo()
|
||||
api, err := httpapi.NewWithOptions(qaService, os.Getenv("DOBOTAI_INGRESS_TOKEN"), auditOptions)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
dingCfg := dingtalk.StreamConfig{
|
||||
ClientID: os.Getenv("DINGTALK_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("DINGTALK_CLIENT_SECRET"),
|
||||
ClientID: settingsRuntime.clientID,
|
||||
ClientSecret: settingsRuntime.secret,
|
||||
TenantKey: os.Getenv("DOBOTAI_TENANT_KEY"),
|
||||
}
|
||||
if err := dingCfg.Validate(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
activeBindings, err := settingsRuntime.repository.ActiveBindings(storeCtx, dingCfg.TenantKey)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
runtimeReady := make(chan struct{})
|
||||
faqCfg, err := readFAQConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if binding, ok := activeBindings["faq_table"]; ok {
|
||||
faqCfg = faqConfig{BaseID: binding.BaseID, SheetID: binding.SheetID, ViewID: binding.ViewID, OperatorID: binding.OperatorID, FieldMapping: binding.FieldMapping}
|
||||
}
|
||||
var faqWorkflowDone chan struct{}
|
||||
var faqRecordLinks *faqtable.RecordLinkBuilder
|
||||
if faqCfg.BaseID != "" {
|
||||
faqRecordLinks, err = faqtable.NewRecordLinkBuilder(faqCfg.BaseID, faqCfg.SheetID, faqCfg.ViewID)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
faqRepo, err := postgres.NewFAQExportRepository(store)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
faqDispatcher, err := buildFAQDispatcher(faqRepo, dingCfg.ClientID, dingCfg.ClientSecret, faqCfg)
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk FAQ table dispatcher init failed: %v", err)
|
||||
}
|
||||
faqWorkflowDone = make(chan struct{})
|
||||
go func() {
|
||||
defer close(faqWorkflowDone)
|
||||
select {
|
||||
case <-runtimeReady:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
runFAQPoller(ctx, faqDispatcher, 5*time.Second)
|
||||
}()
|
||||
log.Printf("DingTalk FAQ table dispatcher started")
|
||||
}
|
||||
|
||||
supportConversationID := os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID")
|
||||
if binding, ok := activeBindings["support_group"]; ok {
|
||||
supportConversationID = binding.ConversationID
|
||||
}
|
||||
supportDispatcher, err := buildSupportDispatcher(
|
||||
store,
|
||||
dingCfg.ClientID,
|
||||
dingCfg.ClientSecret,
|
||||
supportConversationID,
|
||||
faqRecordLinks,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk support notification init failed: %v", err)
|
||||
}
|
||||
if supportDispatcher != nil {
|
||||
go func() {
|
||||
select {
|
||||
case <-runtimeReady:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
runSupportDispatcher(ctx, supportDispatcher, 5*time.Second)
|
||||
}()
|
||||
log.Printf("DingTalk support notification dispatcher started")
|
||||
}
|
||||
|
||||
// Outbound image runtime (stable-image publishing) is built from environment
|
||||
// variables independently of the Stream ingress, so both the question-table
|
||||
// answer rewriter and the inbound-image publisher can share it.
|
||||
outboundImages, err := buildOutboundImageRuntime(outboundImageConfig{
|
||||
WeKnoraBaseURL: os.Getenv("WEKNORA_BASE_URL"),
|
||||
WeKnoraAPIKey: os.Getenv("WEKNORA_API_KEY"),
|
||||
Endpoint: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
|
||||
Region: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
|
||||
AccessKeyID: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
|
||||
AccessKeySecret: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
|
||||
Bucket: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"),
|
||||
PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk outbound image init failed: %v", err)
|
||||
}
|
||||
|
||||
// Question table dispatcher (optional, fail-closed).
|
||||
qtCfg, err := readQuestionTableConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if binding, ok := activeBindings["question_table"]; ok {
|
||||
qtCfg = questionTableConfig{BaseID: binding.BaseID, SheetID: binding.SheetID, OperatorID: binding.OperatorID, FieldMapping: binding.FieldMapping}
|
||||
}
|
||||
store.SetExternalTableExports(qtCfg.BaseID != "", faqCfg.BaseID != "")
|
||||
var questionTableDone chan struct{}
|
||||
if qtCfg.BaseID != "" {
|
||||
qtRepo, err := postgres.NewQuestionTableExportRepository(store)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
qtDispatcher, err := buildQuestionTableDispatcher(qtRepo, dingCfg.ClientID, dingCfg.ClientSecret, qtCfg, answerRewriterAdapter{rewriter: outboundImages.rewriter})
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk question table dispatcher init failed: %v", err)
|
||||
}
|
||||
questionTableDone = make(chan struct{})
|
||||
go func() {
|
||||
defer close(questionTableDone)
|
||||
select {
|
||||
case <-runtimeReady:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
runQuestionTablePoller(ctx, qtDispatcher, 5*time.Second)
|
||||
}()
|
||||
log.Printf("DingTalk question table dispatcher started")
|
||||
}
|
||||
var streamClient *dingtalk.StreamClient
|
||||
if dingCfg.Enabled() {
|
||||
directoryLookup, err := directory.NewDingTalkLookup(directory.DingTalkLookupConfig{
|
||||
TokenURL: "https://oapi.dingtalk.com/gettoken",
|
||||
APIBaseURL: "https://oapi.dingtalk.com",
|
||||
ClientID: dingCfg.ClientID,
|
||||
ClientSecret: dingCfg.ClientSecret,
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk directory lookup init failed: %v", err)
|
||||
}
|
||||
directoryResolver, err := directory.NewResolver(store, directoryLookup)
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk directory resolver init failed: %v", err)
|
||||
}
|
||||
feedbackService, err := feedback.NewService(dingCfg.TenantKey, "dingtalk", store)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -92,17 +294,41 @@ func main() {
|
||||
}
|
||||
var cardSender dingtalk.FinalCardSender
|
||||
if cardTemplateID := strings.TrimSpace(os.Getenv("DINGTALK_CARD_TEMPLATE_ID")); cardTemplateID != "" {
|
||||
cs, err := dingtalk.NewCardSender(dingCfg.ClientID, dingCfg.ClientSecret, cardTemplateID, nil)
|
||||
cardDebugUserID := os.Getenv("DINGTALK_CARD_DEBUG_USER_ID")
|
||||
cs, err := dingtalk.NewCardSender(
|
||||
dingCfg.ClientID,
|
||||
dingCfg.ClientSecret,
|
||||
cardTemplateID,
|
||||
nil,
|
||||
dingtalk.WithCardDebugUserID(cardDebugUserID),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk card sender init failed: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(cardDebugUserID) != "" {
|
||||
log.Printf("DingTalk card debug enabled for one allowlisted user")
|
||||
}
|
||||
cardSender = cs
|
||||
}
|
||||
cardImageMedia := buildCardImageMedia(outboundImages.publicURLPrefix, dingCfg.ClientID, dingCfg.ClientSecret, address)
|
||||
handler, err := dingtalk.NewHandlerWithOptions(
|
||||
dingtalk.TenantConfig{TenantKey: dingCfg.TenantKey, Channel: "dingtalk"},
|
||||
qaService,
|
||||
sender,
|
||||
dingtalk.HandlerOptions{ImageDownloader: downloader, CardSender: cardSender},
|
||||
dingtalk.HandlerOptions{
|
||||
ImageDownloader: downloader,
|
||||
ImagePublisher: outboundImages.publisher,
|
||||
CardSender: cardSender,
|
||||
Directory: directoryResolver,
|
||||
OutboundRewriter: outboundImages.rewriter,
|
||||
CardImageMedia: cardImageMedia,
|
||||
SettingsRouter: &dingTalkSettingsRouter{controller: settingsRuntime.controller},
|
||||
ReasoningRouter: &dingTalkReasoningRouter{service: reasoningService},
|
||||
LanguageResolver: settingsRuntime.repository,
|
||||
|
||||
OutboundRewriteTimeout: outboundImages.rewriteTimeout,
|
||||
WideTableGrouping: cardWideTableGroupingMode(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -115,9 +341,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) {
|
||||
@@ -131,6 +367,39 @@ func main() {
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("HTTP shutdown: %v", err)
|
||||
}
|
||||
if faqWorkflowDone != nil {
|
||||
select {
|
||||
case <-faqWorkflowDone:
|
||||
case <-shutdownCtx.Done():
|
||||
log.Printf("FAQ table poller shutdown timed out")
|
||||
}
|
||||
}
|
||||
if questionTableDone != nil {
|
||||
select {
|
||||
case <-questionTableDone:
|
||||
case <-shutdownCtx.Done():
|
||||
log.Printf("question table poller shutdown timed out")
|
||||
}
|
||||
}
|
||||
if streamClient != nil {
|
||||
streamClient.Close()
|
||||
}
|
||||
if restartRequested.Load() {
|
||||
os.Exit(75)
|
||||
}
|
||||
}
|
||||
|
||||
func outboundOSSConfigured() bool {
|
||||
for _, key := range []string{
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT", "DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID", "DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET", "DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL",
|
||||
} {
|
||||
if strings.TrimSpace(os.Getenv(key)) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildAuditOptions(repo audit.TurnRepository, tenantKey, adminToken, address string) (httpapi.Options, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
|
||||
)
|
||||
@@ -21,6 +22,20 @@ func (r *auditRepoStub) GetTurn(context.Context, string, string) (audit.TurnDeta
|
||||
return audit.TurnDetail{}, audit.ErrNotFound
|
||||
}
|
||||
|
||||
func TestRuntimeBuildInfoUsesLinkerVariables(t *testing.T) {
|
||||
oldVersion, oldCommitSHA, oldBuildTime := version, commitSHA, buildTime
|
||||
t.Cleanup(func() {
|
||||
version, commitSHA, buildTime = oldVersion, oldCommitSHA, oldBuildTime
|
||||
})
|
||||
version = "2.1.0"
|
||||
commitSHA = "abc123def456"
|
||||
buildTime = "2026-07-27T12:00:00Z"
|
||||
got := runtimeBuildInfo()
|
||||
if got.Version != version || got.CommitSHA != commitSHA || got.BuildTime != buildTime {
|
||||
t.Fatalf("build info=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuditOptionsDisabledIsBackwardCompatible(t *testing.T) {
|
||||
opts, err := buildAuditOptions(nil, "", "", "0.0.0.0:8090")
|
||||
if err != nil {
|
||||
@@ -85,3 +100,152 @@ func TestMainWiresFeedbackIntoDingTalkStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainWiresOptionalDingTalkCardDebugUser(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{
|
||||
`os.Getenv("DINGTALK_CARD_DEBUG_USER_ID")`,
|
||||
`dingtalk.WithCardDebugUserID(cardDebugUserID)`,
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("main card debug wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutboundImageRuntimeConfig(t *testing.T) {
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
runtime, err := buildOutboundImageRuntime(outboundImageConfig{})
|
||||
if err != nil || runtime.rewriter != nil || runtime.handler != nil {
|
||||
t.Fatalf("runtime=%+v err=%v", runtime, err)
|
||||
}
|
||||
})
|
||||
t.Run("partial configuration rejected", func(t *testing.T) {
|
||||
_, err := buildOutboundImageRuntime(outboundImageConfig{Bucket: "private-bucket"})
|
||||
if err == nil {
|
||||
t.Fatal("expected configuration error")
|
||||
}
|
||||
})
|
||||
t.Run("complete configuration", func(t *testing.T) {
|
||||
runtime, err := buildOutboundImageRuntime(outboundImageConfig{
|
||||
WeKnoraBaseURL: "http://127.0.0.1:8080",
|
||||
WeKnoraAPIKey: "api-key",
|
||||
Endpoint: "https://oss-cn-shenzhen.aliyuncs.com",
|
||||
Region: "cn-shenzhen",
|
||||
AccessKeyID: "access-key-id",
|
||||
AccessKeySecret: "access-key-secret",
|
||||
Bucket: "private-bucket",
|
||||
PublicBaseURL: "https://weknora-api-test.dobot.cn/dingtalk/i",
|
||||
})
|
||||
if err != nil || runtime.rewriter == nil || runtime.handler == nil {
|
||||
t.Fatalf("runtime=%+v err=%v", runtime, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMainWiresStableOutboundImageRuntime(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{
|
||||
`buildOutboundImageRuntime(outboundImageConfig{`,
|
||||
`PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL")`,
|
||||
`OutboundRewriter: outboundImages.rewriter`,
|
||||
`rootHandler.Handle(outboundImageRoute, outboundImages.handler)`,
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("main stable image wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET",
|
||||
} {
|
||||
t.Setenv(key, "configured")
|
||||
}
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "")
|
||||
if outboundOSSConfigured() {
|
||||
t.Fatal("stable public base URL must be required")
|
||||
}
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "https://img.example/dingtalk/i")
|
||||
if !outboundOSSConfigured() {
|
||||
t.Fatal("complete stable image configuration should be active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundImageBudgetAndCompressionDefaults(t *testing.T) {
|
||||
if outboundImageRewriteTimeoutDefault != 180*time.Second {
|
||||
t.Fatalf("default rewrite budget=%s", outboundImageRewriteTimeoutDefault)
|
||||
}
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", "")
|
||||
if got := outboundImageRewriteTimeout(); got != outboundImageRewriteTimeoutDefault {
|
||||
t.Fatalf("unset budget=%s", got)
|
||||
}
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", "2m30s")
|
||||
if got := outboundImageRewriteTimeout(); got != 150*time.Second {
|
||||
t.Fatalf("parsed budget=%s", got)
|
||||
}
|
||||
for _, invalid := range []string{"-1s", "180", "soon"} {
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", invalid)
|
||||
if got := outboundImageRewriteTimeout(); got != outboundImageRewriteTimeoutDefault {
|
||||
t.Fatalf("invalid budget %q resolved to %s", invalid, got)
|
||||
}
|
||||
}
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS", "")
|
||||
if !outboundImageCompressionEnabled() {
|
||||
t.Fatal("compression must default to on")
|
||||
}
|
||||
for _, disabled := range []string{"off", "0", "false", "no"} {
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS", disabled)
|
||||
if outboundImageCompressionEnabled() {
|
||||
t.Fatalf("compression must be off for %q", disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardWideTableGroupingMode(t *testing.T) {
|
||||
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "")
|
||||
if got := cardWideTableGroupingMode(); got != "" {
|
||||
t.Fatalf("unset must keep the historical mode, got %q", got)
|
||||
}
|
||||
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "off")
|
||||
if got := cardWideTableGroupingMode(); got != "off" {
|
||||
t.Fatalf("off must be passed through, got %q", got)
|
||||
}
|
||||
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", " STREAM ")
|
||||
if got := cardWideTableGroupingMode(); got != "stream" {
|
||||
t.Fatalf("stream must be normalised, got %q", got)
|
||||
}
|
||||
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "on")
|
||||
if got := cardWideTableGroupingMode(); got != "" {
|
||||
t.Fatalf("on is the default and must not be forwarded as a mode, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage"
|
||||
)
|
||||
|
||||
const (
|
||||
outboundImageMaxCountDefault = 12
|
||||
outboundImageMaxBytes = 10 << 20
|
||||
outboundImagePrefix = "dobotai/im-images"
|
||||
outboundImageRoute = "/dingtalk/i/"
|
||||
// outboundImageRewriteTimeoutDefault is the budget for one answer's image
|
||||
// rewrite. Manual screenshots are multi-megabyte uploads and the container's
|
||||
// uplink is the real ceiling (measured ~0.6MB/s), so long answers need a
|
||||
// budget with headroom: the historical 15s delivery timeout truncated them.
|
||||
outboundImageRewriteTimeoutDefault = 180 * time.Second
|
||||
)
|
||||
|
||||
// outboundImageMaxCount resolves how many images one answer may rewrite.
|
||||
// DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT overrides the default; invalid values fall back.
|
||||
func outboundImageMaxCount() int {
|
||||
raw := strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT"))
|
||||
if raw == "" {
|
||||
return outboundImageMaxCountDefault
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 {
|
||||
log.Printf("outbound image: invalid DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT=%q; using default %d", raw, outboundImageMaxCountDefault)
|
||||
return outboundImageMaxCountDefault
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// outboundImageRewriteTimeout resolves the budget for the image rewrite stage.
|
||||
// DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT accepts a Go duration ("90s", "2m");
|
||||
// invalid or non-positive values fall back to the default.
|
||||
func outboundImageRewriteTimeout() time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT"))
|
||||
if raw == "" {
|
||||
return outboundImageRewriteTimeoutDefault
|
||||
}
|
||||
duration, err := time.ParseDuration(raw)
|
||||
if err != nil || duration <= 0 {
|
||||
log.Printf("outbound image: invalid DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT=%q; using default %s", raw, outboundImageRewriteTimeoutDefault)
|
||||
return outboundImageRewriteTimeoutDefault
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
// outboundImageCompressionEnabled reports whether published screenshots are
|
||||
// re-encoded as smaller JPEGs before upload. It is on by default because the
|
||||
// container uplink, not the rewrite code, caps how many images fit in the
|
||||
// delivery budget; DOBOTAI_OUTBOUND_IMAGE_COMPRESS=off restores the original
|
||||
// bytes exactly as fetched.
|
||||
func outboundImageCompressionEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS"))) {
|
||||
case "off", "0", "false", "no":
|
||||
log.Printf("outbound image: compression disabled by configuration")
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// rewriteTimeout is the budget the channel gives the image rewrite stage.
|
||||
rewriteTimeout time.Duration
|
||||
// publicURLPrefix is the prefix of every published image URL, used by the
|
||||
// DingTalk card image uploader to recognise (and refetch) our own images.
|
||||
publicURLPrefix string
|
||||
}
|
||||
|
||||
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(), outboundimage.WithImageCompression(outboundImageCompressionEnabled())),
|
||||
publisher: publisher,
|
||||
handler: handler,
|
||||
rewriteTimeout: outboundImageRewriteTimeout(),
|
||||
publicURLPrefix: strings.TrimRight(cfg.PublicBaseURL, "/") + "/",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundContentRewriter, error) {
|
||||
runtime, err := buildOutboundImageRuntime(cfg)
|
||||
return runtime.rewriter, err
|
||||
}
|
||||
|
||||
// buildCardImageMedia wires the DingTalk media-id rewriter used for AI card
|
||||
// bodies. It returns nil when the feature is switched off or not configured, so
|
||||
// a disabled uploader simply keeps external image URLs in place.
|
||||
func buildCardImageMedia(publicImagePrefix, clientID, clientSecret, listenAddr string) dingtalk.CardImageMediaRewriter {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_IMAGE_MEDIA"))) {
|
||||
case "off", "0", "false", "no":
|
||||
log.Printf("DingTalk card image media ids disabled by configuration")
|
||||
return nil
|
||||
}
|
||||
resolver, err := dingtalk.NewCardImageMediaRewriter(dingtalk.CardImageMediaOptions{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
ImageURLPrefix: publicImagePrefix,
|
||||
FetchURLPrefix: cardImageFetchPrefix(listenAddr),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("DingTalk card image media ids disabled: %v", err)
|
||||
return nil
|
||||
}
|
||||
if resolver == nil {
|
||||
return nil
|
||||
}
|
||||
log.Printf("DingTalk card images will be delivered through DingTalk media ids")
|
||||
return resolver
|
||||
}
|
||||
|
||||
// cardImageFetchPrefix derives the prefix used to download our own published
|
||||
// images before uploading them to DingTalk. An explicit
|
||||
// DINGTALK_CARD_IMAGE_FETCH_BASE_URL (host only, without the image route) wins;
|
||||
// otherwise the local listen port is reused so the fetch stays on loopback.
|
||||
func cardImageFetchPrefix(listenAddr string) string {
|
||||
base := strings.TrimSpace(os.Getenv("DINGTALK_CARD_IMAGE_FETCH_BASE_URL"))
|
||||
if base == "" {
|
||||
addr := strings.TrimSpace(listenAddr)
|
||||
if addr == "" {
|
||||
return ""
|
||||
}
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
if err != nil || port == "" {
|
||||
return ""
|
||||
}
|
||||
base = "http://127.0.0.1:" + port
|
||||
}
|
||||
return strings.TrimRight(base, "/") + outboundImageRoute
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
|
||||
)
|
||||
|
||||
// questionTablePollerRunner is the interface expected by the poller loop.
|
||||
type questionTablePollerRunner interface {
|
||||
RunOnce(context.Context) (questiontable.DispatcherStats, error)
|
||||
}
|
||||
|
||||
// questionTableConfig holds the optional Notable question-table env values.
|
||||
type questionTableConfig struct {
|
||||
BaseID string
|
||||
SheetID string
|
||||
OperatorID string
|
||||
FieldMapping map[string]string
|
||||
}
|
||||
|
||||
// readQuestionTableConfig reads the three dedicated env vars and returns
|
||||
// a validated config. All three blank => disabled (no error). Any partial
|
||||
// configuration => stable configuration error (no values echoed).
|
||||
func readQuestionTableConfig() (questionTableConfig, error) {
|
||||
cfg := questionTableConfig{
|
||||
BaseID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_BASE_ID")),
|
||||
SheetID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_SHEET_ID")),
|
||||
OperatorID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID")),
|
||||
}
|
||||
|
||||
allBlank := cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == ""
|
||||
allSet := cfg.BaseID != "" && cfg.SheetID != "" && cfg.OperatorID != ""
|
||||
|
||||
if allBlank {
|
||||
return questionTableConfig{}, nil
|
||||
}
|
||||
if allSet {
|
||||
return cfg, nil
|
||||
}
|
||||
return questionTableConfig{}, errors.New("DingTalk question table config: all three env vars must be set or all blank (DINGTALK_QUESTION_TABLE_BASE_ID, DINGTALK_QUESTION_TABLE_SHEET_ID, DINGTALK_QUESTION_TABLE_OPERATOR_ID)")
|
||||
}
|
||||
|
||||
// buildQuestionTableDispatcher creates a validated question-table dispatcher
|
||||
// when the repo is non-nil and all required credentials are present.
|
||||
// The optional answer rewriter converts the stored answer into the same
|
||||
// outbound form used for DingTalk delivery (resource images to stable HTTPS,
|
||||
// document links, anchor cleanup). No HTTP request occurs at build time.
|
||||
// It returns (nil, nil) when the question-table feature is disabled.
|
||||
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig, rewriter questiontable.AnswerRewriter) (*questiontable.Dispatcher, error) {
|
||||
// Disabled: all blank config.
|
||||
if cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if repo == nil {
|
||||
return nil, errors.New("question table dispatcher: repository is required")
|
||||
}
|
||||
|
||||
client, err := questiontable.NewClient(questiontable.Config{
|
||||
ClientID: clientID,
|
||||
Secret: clientSecret,
|
||||
BaseID: cfg.BaseID,
|
||||
SheetID: cfg.SheetID,
|
||||
OperatorID: cfg.OperatorID,
|
||||
FieldMapping: cfg.FieldMapping,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return questiontable.NewDispatcher(repo, client, &questiontable.DispatcherOptions{
|
||||
Rewriter: rewriter,
|
||||
SendTimeout: 60 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
// answerRewriterAdapter adapts a blocking outbound content rewriter to the
|
||||
// AnswerRewriter contract (caller never gets an error, so the original answer
|
||||
// is always written when conversion cannot run).
|
||||
type answerRewriterAdapter struct {
|
||||
rewriter dingtalk.OutboundContentRewriter
|
||||
}
|
||||
|
||||
func (a answerRewriterAdapter) Rewrite(ctx context.Context, content string) (string, error) {
|
||||
if a.rewriter == nil {
|
||||
return content, nil
|
||||
}
|
||||
return a.rewriter.Rewrite(ctx, content), nil
|
||||
}
|
||||
|
||||
// runQuestionTablePoller runs the dispatcher immediately, then every interval.
|
||||
// Non-positive interval or nil/typed-nil runner returns without calling.
|
||||
// Continues after a RunOnce error. Exits promptly on context cancellation.
|
||||
// Logging is safe: success may log only Claimed/Exported/Failed integers;
|
||||
// errors log stable stage text only, never raw errors.
|
||||
func runQuestionTablePoller(ctx context.Context, runner questionTablePollerRunner, interval time.Duration) {
|
||||
if runner == nil || interval <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against typed nil.
|
||||
if isNilPoller(runner) {
|
||||
return
|
||||
}
|
||||
|
||||
runOnce := func() {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
stats, err := runner.RunOnce(runCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("question table dispatch failed")
|
||||
return
|
||||
}
|
||||
if stats.Claimed > 0 || stats.Exported > 0 || stats.Failed > 0 {
|
||||
log.Printf("question table dispatch: claimed=%d exported=%d failed=%d", stats.Claimed, stats.Exported, stats.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
runOnce()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isNilPoller returns true when runner is a typed nil.
|
||||
func isNilPoller(runner questionTablePollerRunner) bool {
|
||||
if runner == nil {
|
||||
return true
|
||||
}
|
||||
// reflect-based typed-nil check for the interface.
|
||||
return isInterfaceNil(runner)
|
||||
}
|
||||
|
||||
// isInterfaceNil returns true when v is a typed nil wrapped in an interface.
|
||||
func isInterfaceNil(v interface{}) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
|
||||
return rv.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: disabled all blank returns nil/no error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestReadQuestionTableConfigDisabledAllBlank(t *testing.T) {
|
||||
t.Setenv("DINGTALK_QUESTION_TABLE_BASE_ID", "")
|
||||
t.Setenv("DINGTALK_QUESTION_TABLE_SHEET_ID", "")
|
||||
t.Setenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID", "")
|
||||
|
||||
cfg, err := readQuestionTableConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.BaseID != "" || cfg.SheetID != "" || cfg.OperatorID != "" {
|
||||
t.Errorf("expected empty config, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuestionTableDispatcherDisabled(t *testing.T) {
|
||||
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d != nil {
|
||||
t.Errorf("expected nil dispatcher for disabled config, got %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2: every partial combination errors and does not echo sentinel config values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestReadQuestionTableConfigPartialErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseID string
|
||||
sheetID string
|
||||
operator string
|
||||
}{
|
||||
{"only base", "base-1", "", ""},
|
||||
{"only sheet", "", "sheet-1", ""},
|
||||
{"only operator", "", "", "op-1"},
|
||||
{"base+sheet", "base-1", "sheet-1", ""},
|
||||
{"base+operator", "base-1", "", "op-1"},
|
||||
{"sheet+operator", "", "sheet-1", "op-1"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("DINGTALK_QUESTION_TABLE_BASE_ID", tc.baseID)
|
||||
t.Setenv("DINGTALK_QUESTION_TABLE_SHEET_ID", tc.sheetID)
|
||||
t.Setenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID", tc.operator)
|
||||
|
||||
_, err := readQuestionTableConfig()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for partial config")
|
||||
}
|
||||
errStr := err.Error()
|
||||
// Must not echo any sentinel config value.
|
||||
for _, sentinel := range []string{"base-1", "sheet-1", "op-1"} {
|
||||
if strings.Contains(errStr, sentinel) {
|
||||
t.Errorf("error must not echo sentinel value %q: %q", sentinel, errStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3: enabled missing repo/client/secret fails closed; complete config builds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
|
||||
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
|
||||
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when repo is nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "repository is required") {
|
||||
t.Errorf("error = %v, want 'repository is required'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
|
||||
repo := &questionTableExportRepoStub{}
|
||||
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
|
||||
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when client id is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
|
||||
repo := &questionTableExportRepoStub{}
|
||||
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
|
||||
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when secret is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuestionTableDispatcherEnabledCompleteConfig(t *testing.T) {
|
||||
repo := &questionTableExportRepoStub{}
|
||||
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
|
||||
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d == nil {
|
||||
t.Fatal("expected non-nil dispatcher for complete config")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 4: immediate run + periodic repeat + cancel stop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRunQuestionTablePollerImmediateAndPeriodic(t *testing.T) {
|
||||
runner := &countingPollerRunner{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
|
||||
}()
|
||||
|
||||
// Wait for at least 2 calls (immediate + one tick).
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
cancel()
|
||||
wg.Wait()
|
||||
|
||||
runner.mu.Lock()
|
||||
calls := runner.calls
|
||||
runner.mu.Unlock()
|
||||
|
||||
if calls < 2 {
|
||||
t.Errorf("expected at least 2 calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQuestionTablePollerCancelledContext(t *testing.T) {
|
||||
runner := &countingPollerRunner{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // already cancelled
|
||||
|
||||
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
|
||||
|
||||
runner.mu.Lock()
|
||||
calls := runner.calls
|
||||
runner.mu.Unlock()
|
||||
|
||||
if calls != 0 {
|
||||
t.Errorf("expected 0 calls (cancelled context), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 5: continue after error and log/output security contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRunQuestionTablePollerContinuesAfterError(t *testing.T) {
|
||||
runner := &errorThenSuccessPoller{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
runQuestionTablePoller(ctx, runner, 30*time.Millisecond)
|
||||
}()
|
||||
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
cancel()
|
||||
wg.Wait()
|
||||
|
||||
runner.mu.Lock()
|
||||
calls := runner.calls
|
||||
runner.mu.Unlock()
|
||||
|
||||
if calls < 2 {
|
||||
t.Errorf("expected at least 2 calls after error, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Test logging contract: the runOnce function in runQuestionTablePoller logs
|
||||
// only stable stage text for errors (never raw errors via %v) and only
|
||||
// Claimed/Exported/Failed integers for success.
|
||||
func TestPollerLoggingContract(t *testing.T) {
|
||||
// Behavioral log-capture test: runner returns an error containing unique
|
||||
// sentinel values; the captured log must contain the stable stage text
|
||||
// and none of those sentinels.
|
||||
sentinel := "s3nt1n3l-op3r4t0r-t0k3n-q"
|
||||
runner := &sentinelErrorPoller{errMsg: "questiontable dispatcher: " + sentinel}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Save and restore global log output.
|
||||
originalOutput := log.Writer()
|
||||
originalFlags := log.Flags()
|
||||
var buf strings.Builder
|
||||
log.SetOutput(&buf)
|
||||
log.SetFlags(0)
|
||||
defer func() {
|
||||
log.SetOutput(originalOutput)
|
||||
log.SetFlags(originalFlags)
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
|
||||
}()
|
||||
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
cancel()
|
||||
wg.Wait()
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "question table dispatch failed") {
|
||||
t.Errorf("log must contain stable stage text, got: %s", output)
|
||||
}
|
||||
if strings.Contains(output, sentinel) {
|
||||
t.Errorf("log must not contain sentinel %q, got: %s", sentinel, output)
|
||||
}
|
||||
}
|
||||
|
||||
// sentinelErrorPoller always returns an error with the given message.
|
||||
type sentinelErrorPoller struct {
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func (r *sentinelErrorPoller) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
|
||||
return questiontable.DispatcherStats{}, errors.New(r.errMsg)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 6: nil/typed-nil runner and nonpositive interval safe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRunQuestionTablePollerNilRunner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// Should not panic, just return immediately.
|
||||
runQuestionTablePoller(ctx, nil, 50*time.Millisecond)
|
||||
// If we reach here without panic, test passes.
|
||||
}
|
||||
|
||||
func TestRunQuestionTablePollerNonPositiveInterval(t *testing.T) {
|
||||
runner := &countingPollerRunner{}
|
||||
ctx := context.Background()
|
||||
runQuestionTablePoller(ctx, runner, 0)
|
||||
runQuestionTablePoller(ctx, runner, -1*time.Second)
|
||||
|
||||
runner.mu.Lock()
|
||||
calls := runner.calls
|
||||
runner.mu.Unlock()
|
||||
|
||||
if calls != 0 {
|
||||
t.Errorf("expected 0 calls for non-positive interval, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQuestionTablePollerTypedNilInterface(t *testing.T) {
|
||||
// The runner is nil through the interface but not at the concrete type level.
|
||||
var typedNil *questiontable.Dispatcher = nil
|
||||
var runner questionTablePollerRunner = typedNil
|
||||
ctx := context.Background()
|
||||
// Should not panic, just return immediately.
|
||||
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 7: main source wiring contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMainWiresQuestionTableEnvNames(t *testing.T) {
|
||||
mainData, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
syncData, err := os.ReadFile("question_table_sync.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainSource := string(mainData)
|
||||
syncSource := string(syncData)
|
||||
|
||||
for _, fragment := range []string{
|
||||
`"DINGTALK_QUESTION_TABLE_BASE_ID"`,
|
||||
`"DINGTALK_QUESTION_TABLE_SHEET_ID"`,
|
||||
`"DINGTALK_QUESTION_TABLE_OPERATOR_ID"`,
|
||||
} {
|
||||
if !strings.Contains(syncSource, fragment) {
|
||||
t.Errorf("question table config reader missing %q", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
for _, fragment := range []string{
|
||||
`NewQuestionTableExportRepository`,
|
||||
`5*time.Second`,
|
||||
`"DingTalk question table dispatcher started"`,
|
||||
} {
|
||||
if !strings.Contains(mainSource, fragment) {
|
||||
t.Errorf("main wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
shutdownAt := strings.Index(mainSource, "server.Shutdown(shutdownCtx)")
|
||||
waitAt := strings.Index(mainSource, "case <-questionTableDone:")
|
||||
if shutdownAt < 0 || waitAt < 0 || waitAt <= shutdownAt {
|
||||
t.Fatal("main must wait for question table poller after HTTP shutdown and before returning")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test doubles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// questionTableExportRepoStub implements questiontable.ExportRepository.
|
||||
type questionTableExportRepoStub struct{}
|
||||
|
||||
func (r *questionTableExportRepoStub) Claim(ctx context.Context, limit int, maxAttempts int, staleBefore time.Time) ([]questiontable.PendingExport, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *questionTableExportRepoStub) MarkExported(ctx context.Context, exportID, location string) error {
|
||||
return nil
|
||||
}
|
||||
func (r *questionTableExportRepoStub) MarkFailed(ctx context.Context, exportID, failureCode string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// countingPollerRunner counts calls to RunOnce.
|
||||
type countingPollerRunner struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *countingPollerRunner) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
|
||||
r.mu.Lock()
|
||||
r.calls++
|
||||
r.mu.Unlock()
|
||||
return questiontable.DispatcherStats{}, nil
|
||||
}
|
||||
|
||||
// errorThenSuccessPoller returns an error on the first call, then succeeds.
|
||||
type errorThenSuccessPoller struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *errorThenSuccessPoller) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
|
||||
r.mu.Lock()
|
||||
r.calls++
|
||||
call := r.calls
|
||||
r.mu.Unlock()
|
||||
|
||||
if call == 1 {
|
||||
return questiontable.DispatcherStats{}, errors.New("questiontable dispatcher: claim failed")
|
||||
}
|
||||
return questiontable.DispatcherStats{Claimed: 1, Exported: 1}, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
)
|
||||
|
||||
type dingTalkReasoningRouter struct {
|
||||
service *reasoningmode.Service
|
||||
}
|
||||
|
||||
func (r *dingTalkReasoningRouter) Prepare(ctx context.Context, message dingtalk.ReasoningMessage) (dingtalk.ReasoningDecision, error) {
|
||||
decision, err := r.service.Prepare(ctx, reasoningmode.Scope{
|
||||
TenantKey: message.TenantKey, Channel: message.Channel, ExternalActorID: message.StaffID,
|
||||
}, message.Text)
|
||||
if err != nil {
|
||||
return dingtalk.ReasoningDecision{}, err
|
||||
}
|
||||
return dingtalk.ReasoningDecision{
|
||||
Handled: decision.Handled, Reply: decision.Reply, Query: decision.Query,
|
||||
AgentID: decision.AgentID, Execution: decision.Execution, Expired: decision.Expired,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMainWiresReasoningModeRoutingAndCompletion(t *testing.T) {
|
||||
mainSource, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wiringSource, err := os.ReadFile("reasoning_wiring.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainText := string(mainSource)
|
||||
for _, required := range []string{
|
||||
`os.Getenv("DOBOTAI_QUICK_AGENT_ID")`,
|
||||
`os.Getenv("DOBOTAI_REASONING_AGENT_ID")`,
|
||||
`reasoningmode.NewService`,
|
||||
`qa.WithReasoningModeFinalizer`,
|
||||
`ReasoningRouter:`,
|
||||
} {
|
||||
if !strings.Contains(mainText, required) {
|
||||
t.Errorf("main reasoning wiring missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{
|
||||
"dingTalkReasoningRouter",
|
||||
"ReasoningMessage",
|
||||
"service.Prepare",
|
||||
} {
|
||||
if !strings.Contains(string(wiringSource), required) {
|
||||
t.Errorf("reasoning adapter missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
||||
)
|
||||
|
||||
type validatedSettingsRepository struct {
|
||||
*postgres.SettingsRepository
|
||||
clientID string
|
||||
secret string
|
||||
testSupport func(context.Context, settings.ResourceBinding) error
|
||||
validateTable func(context.Context, settings.ResourceBinding) (map[string]string, error)
|
||||
saveBinding func(context.Context, settings.ResourceBinding) error
|
||||
restart func()
|
||||
restartDelay time.Duration
|
||||
}
|
||||
|
||||
func (r *validatedSettingsRepository) TestAndActivateSupportGroup(ctx context.Context, binding settings.ResourceBinding) error {
|
||||
if r.testSupport != nil {
|
||||
if err := r.testSupport(ctx, binding); err != nil {
|
||||
return errors.New("support_group_test_failed")
|
||||
}
|
||||
} else {
|
||||
sender, err := dingtalk.NewSupportGroupSender(r.clientID, r.secret, binding.ConversationID, nil)
|
||||
if err != nil {
|
||||
return errors.New("support_group_test_invalid_config")
|
||||
}
|
||||
if err = sender.SendTest(ctx); err != nil {
|
||||
return errors.New("support_group_test_failed")
|
||||
}
|
||||
}
|
||||
binding.ValidationCode = "support_test_ok"
|
||||
binding.Activate = true
|
||||
var err error
|
||||
if r.saveBinding != nil {
|
||||
err = r.saveBinding(ctx, binding)
|
||||
} else if r.SettingsRepository != nil {
|
||||
err = r.SettingsRepository.SaveBinding(ctx, binding)
|
||||
} else {
|
||||
err = errors.New("support_group_binding_repository_unavailable")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.restart != nil {
|
||||
go func() {
|
||||
if r.restartDelay > 0 {
|
||||
time.Sleep(r.restartDelay)
|
||||
}
|
||||
r.restart()
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *validatedSettingsRepository) SaveBinding(ctx context.Context, binding settings.ResourceBinding) error {
|
||||
activate := false
|
||||
switch binding.Kind {
|
||||
case "question_table", "faq_table":
|
||||
var mapping map[string]string
|
||||
var err error
|
||||
if r.validateTable != nil {
|
||||
mapping, err = r.validateTable(ctx, binding)
|
||||
} else {
|
||||
mapping, err = r.readAndMapTable(ctx, binding)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binding.FieldMapping = mapping
|
||||
binding.ValidationCode = "schema_ok"
|
||||
binding.Activate = true
|
||||
activate = true
|
||||
case "support_group":
|
||||
binding.ValidationCode = "pending_test_message"
|
||||
binding.Activate = false
|
||||
default:
|
||||
return errors.New("unsupported_binding_kind")
|
||||
}
|
||||
var err error
|
||||
if r.saveBinding != nil {
|
||||
err = r.saveBinding(ctx, binding)
|
||||
} else if r.SettingsRepository != nil {
|
||||
err = r.SettingsRepository.SaveBinding(ctx, binding)
|
||||
} else {
|
||||
err = errors.New("binding_repository_unavailable")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if activate {
|
||||
r.requestRestart()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *validatedSettingsRepository) readAndMapTable(ctx context.Context, binding settings.ResourceBinding) (map[string]string, error) {
|
||||
client, err := questiontable.NewClient(questiontable.Config{
|
||||
ClientID: r.clientID, Secret: r.secret, BaseID: binding.BaseID,
|
||||
SheetID: binding.SheetID, OperatorID: binding.OperatorID,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("table_binding_invalid_config")
|
||||
}
|
||||
fields, err := client.ReadFields(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
required := questiontable.QuestionSchema()
|
||||
if binding.Kind == "faq_table" {
|
||||
required = questiontable.FAQSchema()
|
||||
}
|
||||
return questiontable.MapRequiredFields(fields, required)
|
||||
}
|
||||
|
||||
func (r *validatedSettingsRepository) requestRestart() {
|
||||
if r.restart == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if r.restartDelay > 0 {
|
||||
time.Sleep(r.restartDelay)
|
||||
}
|
||||
r.restart()
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
)
|
||||
|
||||
func TestTableBindingRestartsOnlyAfterActiveSave(t *testing.T) {
|
||||
restarted := make(chan struct{}, 1)
|
||||
var saved settings.ResourceBinding
|
||||
repo := &validatedSettingsRepository{
|
||||
validateTable: func(_ context.Context, binding settings.ResourceBinding) (map[string]string, error) {
|
||||
return map[string]string{"answer": "field-answer"}, nil
|
||||
},
|
||||
saveBinding: func(_ context.Context, binding settings.ResourceBinding) error {
|
||||
saved = binding
|
||||
return nil
|
||||
},
|
||||
restart: func() { restarted <- struct{}{} },
|
||||
restartDelay: 0,
|
||||
}
|
||||
if err := repo.SaveBinding(context.Background(), settings.ResourceBinding{Kind: "question_table", TenantKey: "tenant", BaseID: "base", SheetID: "sheet", OperatorID: "operator"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !saved.Activate || saved.ValidationCode != "schema_ok" || saved.FieldMapping["answer"] != "field-answer" {
|
||||
t.Fatalf("binding=%+v", saved)
|
||||
}
|
||||
select {
|
||||
case <-restarted:
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
t.Fatal("restart not requested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableBindingDoesNotRestartOnValidationOrSaveFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
validateErr error
|
||||
saveErr error
|
||||
}{
|
||||
{name: "validation failure", validateErr: errors.New("schema failed")},
|
||||
{name: "save failure", saveErr: errors.New("save failed")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
restarted := make(chan struct{}, 1)
|
||||
repo := &validatedSettingsRepository{
|
||||
validateTable: func(context.Context, settings.ResourceBinding) (map[string]string, error) {
|
||||
return map[string]string{"answer": "field"}, tc.validateErr
|
||||
},
|
||||
saveBinding: func(context.Context, settings.ResourceBinding) error { return tc.saveErr },
|
||||
restart: func() { restarted <- struct{}{} },
|
||||
}
|
||||
if err := repo.SaveBinding(context.Background(), settings.ResourceBinding{Kind: "question_table"}); err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
select {
|
||||
case <-restarted:
|
||||
t.Fatal("must not restart after failed activation")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportGroupActivationRestartsOnlyAfterTestAndSaveSucceed(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
testErr error
|
||||
saveErr error
|
||||
wantSaved bool
|
||||
wantRestart bool
|
||||
}{
|
||||
{name: "test fails", testErr: errors.New("send failed")},
|
||||
{name: "save fails", saveErr: errors.New("save failed"), wantSaved: true},
|
||||
{name: "success", wantSaved: true, wantRestart: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var saved settings.ResourceBinding
|
||||
restarted := make(chan struct{}, 1)
|
||||
repo := &validatedSettingsRepository{
|
||||
testSupport: func(context.Context, settings.ResourceBinding) error { return tc.testErr },
|
||||
saveBinding: func(_ context.Context, binding settings.ResourceBinding) error {
|
||||
saved = binding
|
||||
return tc.saveErr
|
||||
},
|
||||
restart: func() { restarted <- struct{}{} },
|
||||
restartDelay: 0,
|
||||
}
|
||||
err := repo.TestAndActivateSupportGroup(context.Background(), settings.ResourceBinding{
|
||||
Kind: "support_group", TenantKey: "tenant", ConversationID: "group",
|
||||
})
|
||||
if (err != nil) != (tc.testErr != nil || tc.saveErr != nil) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := saved.Kind != ""; got != tc.wantSaved {
|
||||
t.Fatalf("saved=%v want=%v", got, tc.wantSaved)
|
||||
}
|
||||
if tc.wantSaved && (saved.ValidationCode != "support_test_ok" || !saved.Activate) {
|
||||
t.Fatalf("binding=%+v", saved)
|
||||
}
|
||||
select {
|
||||
case <-restarted:
|
||||
if !tc.wantRestart {
|
||||
t.Fatal("unexpected restart")
|
||||
}
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
if tc.wantRestart {
|
||||
t.Fatal("restart not requested")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
)
|
||||
|
||||
type supportDispatcherRunner interface {
|
||||
RunOnce(context.Context) (coverage.DispatcherStats, error)
|
||||
}
|
||||
|
||||
func buildSupportDispatcher(repo coverage.NotificationRepository, clientID, clientSecret, openConversationID string, recordLinks *faqtable.RecordLinkBuilder) (*coverage.Dispatcher, error) {
|
||||
openConversationID = strings.TrimSpace(openConversationID)
|
||||
if openConversationID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if repo == nil {
|
||||
return nil, errors.New("support notification repository is required")
|
||||
}
|
||||
if recordLinks == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sender, err := dingtalk.NewSupportGroupSender(clientID, clientSecret, openConversationID, nil, dingtalk.WithFAQRecordLinkBuilder(recordLinks))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return coverage.NewDispatcher(repo, sender)
|
||||
}
|
||||
|
||||
func runSupportDispatcher(ctx context.Context, dispatcher supportDispatcherRunner, interval time.Duration) {
|
||||
if dispatcher == nil || interval <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
runOnce := func() {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
stats, err := dispatcher.RunOnce(runCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("support notification dispatch failed: %v", err)
|
||||
return
|
||||
}
|
||||
if stats.Claimed > 0 {
|
||||
log.Printf("support notification dispatch: claimed=%d notified=%d failed=%d", stats.Claimed, stats.Notified, stats.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
runOnce()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
)
|
||||
|
||||
type supportNotificationRepoStub struct{}
|
||||
|
||||
func (*supportNotificationRepoStub) Claim(context.Context, int, int, time.Time) ([]coverage.PendingSupportCase, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*supportNotificationRepoStub) MarkNotified(context.Context, string) error { return nil }
|
||||
func (*supportNotificationRepoStub) MarkFailed(context.Context, string, string) error { return nil }
|
||||
|
||||
func testFAQRecordLinks(t *testing.T) *faqtable.RecordLinkBuilder {
|
||||
t.Helper()
|
||||
builder, err := faqtable.NewRecordLinkBuilder("base", "sheet", "view")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
type supportDispatcherStub struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
errs []error
|
||||
callCh chan int
|
||||
}
|
||||
|
||||
func (s *supportDispatcherStub) RunOnce(context.Context) (coverage.DispatcherStats, error) {
|
||||
s.mu.Lock()
|
||||
s.calls++
|
||||
call := s.calls
|
||||
var err error
|
||||
if call <= len(s.errs) {
|
||||
err = s.errs[call-1]
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if s.callCh != nil {
|
||||
select {
|
||||
case s.callCh <- call:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return coverage.DispatcherStats{Claimed: 1, Notified: 1}, err
|
||||
}
|
||||
|
||||
func TestBuildSupportDispatcherDisabledWithoutGroup(t *testing.T) {
|
||||
dispatcher, err := buildSupportDispatcher(nil, "", "", " ", nil)
|
||||
if err != nil || dispatcher != nil {
|
||||
t.Fatalf("dispatcher=%v err=%v, want disabled", dispatcher, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportDispatcherBlocksWithoutFAQLinks(t *testing.T) {
|
||||
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, "client", "secret", "group", nil)
|
||||
if err != nil || dispatcher != nil {
|
||||
t.Fatalf("dispatcher=%v err=%v, want blocked without process failure", dispatcher, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportDispatcherEnabledFailsClosed(t *testing.T) {
|
||||
repo := &supportNotificationRepoStub{}
|
||||
links := testFAQRecordLinks(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
repo coverage.NotificationRepository
|
||||
clientID, secret, gid string
|
||||
links *faqtable.RecordLinkBuilder
|
||||
}{
|
||||
{name: "missing repo", clientID: "client", secret: "secret", gid: "group", links: links},
|
||||
{name: "missing client", repo: repo, secret: "secret", gid: "group", links: links},
|
||||
{name: "missing secret", repo: repo, clientID: "client", gid: "group", links: links},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := buildSupportDispatcher(tc.repo, tc.clientID, tc.secret, tc.gid, tc.links); err == nil {
|
||||
t.Fatal("expected configuration error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportDispatcherValidDoesNotSend(t *testing.T) {
|
||||
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, " client ", " secret ", " group ", testFAQRecordLinks(t))
|
||||
if err != nil {
|
||||
t.Fatalf("buildSupportDispatcher: %v", err)
|
||||
}
|
||||
if dispatcher == nil {
|
||||
t.Fatal("expected dispatcher")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSupportDispatcherRunsImmediatelyAndStops(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
stub := &supportDispatcherStub{callCh: make(chan int, 4)}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runSupportDispatcher(ctx, stub, time.Hour)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-stub.callCh:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("dispatcher did not run immediately")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("dispatcher did not stop after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSupportDispatcherContinuesAfterError(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
stub := &supportDispatcherStub{
|
||||
errs: []error{errors.New("temporary db error")},
|
||||
callCh: make(chan int, 4),
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runSupportDispatcher(ctx, stub, 10*time.Millisecond)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
for want := 1; want <= 2; want++ {
|
||||
select {
|
||||
case got := <-stub.callCh:
|
||||
if got != want {
|
||||
t.Fatalf("call=%d, want %d", got, want)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatalf("missing dispatcher call %d", want)
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestRunSupportDispatcherRejectsNonPositiveInterval(t *testing.T) {
|
||||
stub := &supportDispatcherStub{}
|
||||
runSupportDispatcher(context.Background(), stub, 0)
|
||||
stub.mu.Lock()
|
||||
defer stub.mu.Unlock()
|
||||
if stub.calls != 0 {
|
||||
t.Fatalf("calls=%d, want 0", stub.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainWiresCoverageSupportNotifications(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{
|
||||
`os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID")`,
|
||||
`coverage.NewService(store)`,
|
||||
`qa.WithSucceededTurnObserver`,
|
||||
`case <-runtimeReady:`,
|
||||
`runSupportDispatcher(ctx, supportDispatcher, 5*time.Second)`,
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("main support notification wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 临时探针(不入库,用完即删):用真实 WeKnora + OSS 端点跑
|
||||
// internal/outboundimage 的改写链路,量化"一条含 N 张真实图的答案"在
|
||||
// 并发取图 + 预算可配下的出图结果与耗时。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
refsFile := flag.String("refs-file", "", "文件:每行一个 resource:// 句柄")
|
||||
budget := flag.Duration("budget", 180*time.Second, "改写预算")
|
||||
maxImages := flag.Int("max", 50, "图片上限")
|
||||
compress := flag.Bool("compress", true, "上传前压缩截图")
|
||||
passes := flag.Int("passes", 1, "重复改写轮数(第二轮应命中已存在对象)")
|
||||
flag.Parse()
|
||||
|
||||
refs := readRefs(*refsFile)
|
||||
if len(refs) == 0 {
|
||||
log.Fatal("probe: no references")
|
||||
}
|
||||
|
||||
fetcher, err := outboundimage.NewWeKnoraFetcher(
|
||||
os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"),
|
||||
&http.Client{Timeout: 30 * time.Second}, 10<<20)
|
||||
if err != nil {
|
||||
log.Fatalf("probe: fetcher: %v", err)
|
||||
}
|
||||
store, err := outboundimage.NewAliyunOSSStore(
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"))
|
||||
if err != nil {
|
||||
log.Fatalf("probe: store: %v", err)
|
||||
}
|
||||
publicBase := os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL")
|
||||
publisher, err := outboundimage.NewStableHashPublisher(store, "dobotai/im-images", publicBase)
|
||||
if err != nil {
|
||||
log.Fatalf("probe: publisher: %v", err)
|
||||
}
|
||||
rewriter := outboundimage.NewRewriter(fetcher, publisher, *maxImages, outboundimage.WithImageCompression(*compress))
|
||||
|
||||
var answer strings.Builder
|
||||
answer.WriteString("## 探针答案\n")
|
||||
for i, ref := range refs {
|
||||
fmt.Fprintf(&answer, "\n\n", i+1, ref)
|
||||
}
|
||||
|
||||
log.Printf("probe: refs=%d budget=%s max=%d compress=%v passes=%d bucket=%s", len(refs), *budget, *maxImages, *compress, *passes, os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"))
|
||||
body := answer.String()
|
||||
for pass := 1; pass <= *passes; pass++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *budget)
|
||||
started := time.Now()
|
||||
out := rewriter.Rewrite(ctx, body)
|
||||
elapsed := time.Since(started)
|
||||
cancel()
|
||||
|
||||
published := regexp.MustCompile(`!\[图\d+\]\(https://`).FindAllString(out, -1)
|
||||
leaked := strings.Count(out, "resource://")
|
||||
fmt.Printf("RESULT pass=%d elapsed=%s refs=%d published=%d dropped=%d residual_resource_refs=%d out_bytes=%d\n",
|
||||
pass, elapsed.Round(time.Millisecond), len(refs), len(published), len(refs)-len(published), leaked, len(out))
|
||||
if len(published) > 0 {
|
||||
fmt.Printf("FIRST_URL_%d=%s\nLAST_URL_%d=%s\n", pass, firstImageURL(out), pass, lastImageURL(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readRefs(path string) []string {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Fatalf("probe: refs file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
seen := map[string]bool{}
|
||||
refs := make([]string, 0, 64)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
ref := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(ref, "resource://") || seen[ref] {
|
||||
continue
|
||||
}
|
||||
seen[ref] = true
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
var imageURLRE = regexp.MustCompile(`!\[图\d+\]\((https://[^)]+)\)`)
|
||||
|
||||
func firstImageURL(content string) string {
|
||||
if match := imageURLRE.FindStringSubmatch(content); len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func lastImageURL(content string) string {
|
||||
if matches := imageURLRE.FindAllStringSubmatch(content, -1); len(matches) > 0 {
|
||||
return matches[len(matches)-1][1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 000006_native_card_feedback_reasons down migration
|
||||
-- A schema rollback cannot retain values rejected by the legacy constraint,
|
||||
-- so map each native reason to its closest B2 legacy category first.
|
||||
|
||||
UPDATE answer_feedback
|
||||
SET reason_code = CASE reason_code
|
||||
WHEN 'content_mismatch' THEN 'irrelevant'
|
||||
WHEN 'content_incomplete' THEN 'not_resolved'
|
||||
WHEN 'illogical_expression' THEN 'unclear'
|
||||
WHEN 'factual_error' THEN 'incorrect'
|
||||
WHEN 'sensitive_content' THEN 'other'
|
||||
WHEN 'answer_not_wanted' THEN 'not_resolved'
|
||||
ELSE reason_code
|
||||
END
|
||||
WHERE reason_code IN (
|
||||
'content_mismatch',
|
||||
'content_incomplete',
|
||||
'illogical_expression',
|
||||
'factual_error',
|
||||
'sensitive_content',
|
||||
'answer_not_wanted'
|
||||
);
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
ADD CONSTRAINT answer_feedback_reason_code_check
|
||||
CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other'));
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 000006_native_card_feedback_reasons up migration
|
||||
-- Preserve the original B2 reasons and add the six exact reason codes emitted
|
||||
-- by DingTalk's built-in AI feedback component.
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
ADD CONSTRAINT answer_feedback_reason_code_check
|
||||
CHECK (reason_code IN (
|
||||
'',
|
||||
'incorrect',
|
||||
'not_resolved',
|
||||
'irrelevant',
|
||||
'unclear',
|
||||
'outdated',
|
||||
'other',
|
||||
'content_mismatch',
|
||||
'content_incomplete',
|
||||
'illogical_expression',
|
||||
'factual_error',
|
||||
'sensitive_content',
|
||||
'answer_not_wanted'
|
||||
));
|
||||
@@ -0,0 +1,78 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read006Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000006_native_card_feedback_reasons."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000006 %s migration: %v", direction, err)
|
||||
}
|
||||
return strings.Join(strings.Fields(strings.ToUpper(string(data))), " ")
|
||||
}
|
||||
|
||||
func TestUp006ExpandsNamedReasonConstraint(t *testing.T) {
|
||||
up := read006Migration(t, "up")
|
||||
for _, fragment := range []string{
|
||||
"ALTER TABLE ANSWER_FEEDBACK DROP CONSTRAINT IF EXISTS ANSWER_FEEDBACK_REASON_CODE_CHECK",
|
||||
"ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK",
|
||||
"CHECK (REASON_CODE IN",
|
||||
"CONTENT_MISMATCH",
|
||||
"CONTENT_INCOMPLETE",
|
||||
"ILLOGICAL_EXPRESSION",
|
||||
"FACTUAL_ERROR",
|
||||
"SENSITIVE_CONTENT",
|
||||
"ANSWER_NOT_WANTED",
|
||||
} {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
for _, legacy := range []string{"INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"} {
|
||||
if !strings.Contains(up, legacy) {
|
||||
t.Errorf("up migration must preserve legacy reason %q", legacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp006OnlyChangesReasonConstraint(t *testing.T) {
|
||||
up := read006Migration(t, "up")
|
||||
for _, forbidden := range []string{"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM", "UPDATE ANSWER_FEEDBACK"} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("up migration contains forbidden operation %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown006MapsNativeReasonsBeforeRestoringConstraint(t *testing.T) {
|
||||
down := read006Migration(t, "down")
|
||||
mappings := map[string]string{
|
||||
"CONTENT_MISMATCH": "IRRELEVANT",
|
||||
"CONTENT_INCOMPLETE": "NOT_RESOLVED",
|
||||
"ILLOGICAL_EXPRESSION": "UNCLEAR",
|
||||
"FACTUAL_ERROR": "INCORRECT",
|
||||
"SENSITIVE_CONTENT": "OTHER",
|
||||
"ANSWER_NOT_WANTED": "NOT_RESOLVED",
|
||||
}
|
||||
updateAt := strings.Index(down, "UPDATE ANSWER_FEEDBACK")
|
||||
constraintAt := strings.Index(down, "ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK")
|
||||
if updateAt < 0 || constraintAt < 0 || updateAt > constraintAt {
|
||||
t.Fatal("down migration must map native reasons before restoring the legacy constraint")
|
||||
}
|
||||
for from, to := range mappings {
|
||||
if !strings.Contains(down, "WHEN '"+from+"' THEN '"+to+"'") {
|
||||
t.Errorf("down migration missing mapping %s -> %s", from, to)
|
||||
}
|
||||
}
|
||||
for _, native := range []string{"CONTENT_MISMATCH", "CONTENT_INCOMPLETE", "ILLOGICAL_EXPRESSION", "FACTUAL_ERROR", "SENSITIVE_CONTENT", "ANSWER_NOT_WANTED"} {
|
||||
constraint := down[constraintAt:]
|
||||
if strings.Contains(constraint, native) {
|
||||
t.Errorf("legacy down constraint must exclude native reason %q", native)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 000007_coverage_decisions down migration
|
||||
-- Drop support_cases first (child table), then coverage_decisions (parent table).
|
||||
|
||||
DROP TABLE IF EXISTS support_cases;
|
||||
DROP TABLE IF EXISTS coverage_decisions;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- 000007_coverage_decisions up migration
|
||||
-- Creates coverage_decisions and support_cases tables for B3.1 persistence.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coverage_decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
evaluator_version TEXT NOT NULL DEFAULT 'v1',
|
||||
verdict TEXT NOT NULL CHECK (verdict IN ('covered', 'uncovered')),
|
||||
reason_code TEXT NOT NULL CHECK (reason_code IN (
|
||||
'covered_has_references',
|
||||
'uncovered_no_references',
|
||||
'uncovered_explicit_no_answer'
|
||||
)),
|
||||
reference_count INTEGER NOT NULL DEFAULT 0 CHECK (reference_count >= 0),
|
||||
top_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_tenant_channel
|
||||
ON coverage_decisions (tenant_key, channel, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_verdict
|
||||
ON coverage_decisions (verdict) WHERE verdict = 'uncovered';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS support_cases (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
coverage_decision_id TEXT NOT NULL REFERENCES coverage_decisions(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'notifying', 'notification_failed', 'notified', 'resolved')),
|
||||
notification_attempts INTEGER NOT NULL DEFAULT 0 CHECK (notification_attempts >= 0),
|
||||
last_notification_failure_code TEXT NOT NULL DEFAULT '',
|
||||
last_notification_at TIMESTAMPTZ,
|
||||
notified_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_notification_pending
|
||||
ON support_cases (status, created_at, id) WHERE status IN ('pending', 'notification_failed');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_notifying_stale
|
||||
ON support_cases (status, last_notification_at) WHERE status = 'notifying';
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package migrations_test contains source-level contract tests for the
|
||||
// 000007_coverage_decisions migration. No real database connection is used.
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read007Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000007_coverage_decisions."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000007 %s migration: %v", direction, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestUp007CreatesCoverageDecisionsTable(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
required := []string{
|
||||
"CREATE TABLE IF NOT EXISTS COVERAGE_DECISIONS",
|
||||
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
|
||||
"EVALUATOR_VERSION", "VERDICT", "REASON_CODE", "REFERENCE_COUNT", "TOP_SCORE",
|
||||
"CREATED_AT", "UPDATED_AT",
|
||||
}
|
||||
for _, fragment := range required {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CoverageDecisionsConstraints(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
// REFERENCES turns(id)
|
||||
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
|
||||
t.Error("coverage_decisions.turn_id must reference turns(id)")
|
||||
}
|
||||
// UNIQUE (tenant_key, channel, turn_id)
|
||||
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
|
||||
t.Error("coverage_decisions must have UNIQUE (tenant_key, channel, turn_id)")
|
||||
}
|
||||
// verdict CHECK
|
||||
if !strings.Contains(up, "COVERED") || !strings.Contains(up, "UNCOVERED") {
|
||||
t.Error("verdict CHECK must include 'covered' and 'uncovered'")
|
||||
}
|
||||
// reason_code CHECK
|
||||
for _, code := range []string{"COVERED_HAS_REFERENCES", "UNCOVERED_NO_REFERENCES", "UNCOVERED_EXPLICIT_NO_ANSWER"} {
|
||||
if !strings.Contains(up, code) {
|
||||
t.Errorf("reason_code CHECK missing %q", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CreatesSupportCasesTable(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
required := []string{
|
||||
"CREATE TABLE IF NOT EXISTS SUPPORT_CASES",
|
||||
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
|
||||
"COVERAGE_DECISION_ID", "STATUS", "CREATED_AT", "UPDATED_AT",
|
||||
}
|
||||
for _, fragment := range required {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
// Notification fields must be present
|
||||
notificationFields := []string{
|
||||
"NOTIFICATION_ATTEMPTS", "LAST_NOTIFICATION_FAILURE_CODE",
|
||||
"LAST_NOTIFICATION_AT", "NOTIFIED_AT",
|
||||
}
|
||||
for _, fragment := range notificationFields {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing notification field %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007SupportCasesConstraints(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
// FK to coverage_decisions
|
||||
if !strings.Contains(up, "REFERENCES COVERAGE_DECISIONS(ID)") {
|
||||
t.Error("support_cases.coverage_decision_id must reference coverage_decisions(id)")
|
||||
}
|
||||
// FK to turns
|
||||
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
|
||||
t.Error("support_cases.turn_id must reference turns(id)")
|
||||
}
|
||||
// UNIQUE (tenant_key, channel, turn_id)
|
||||
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
|
||||
t.Error("support_cases must have UNIQUE (tenant_key, channel, turn_id)")
|
||||
}
|
||||
// status CHECK
|
||||
for _, status := range []string{"PENDING", "NOTIFYING", "NOTIFICATION_FAILED", "NOTIFIED", "RESOLVED"} {
|
||||
if !strings.Contains(up, status) {
|
||||
t.Errorf("status CHECK must include %q", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CreatesQueryIndexes(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
// Pending/retry query index and stale notifying index
|
||||
if !strings.Contains(up, "IDX_SUPPORT_CASES_NOTIFICATION_PENDING") ||
|
||||
!strings.Contains(up, "WHERE STATUS IN ('PENDING', 'NOTIFICATION_FAILED')") {
|
||||
t.Error("up migration must index pending and notification_failed support_cases")
|
||||
}
|
||||
if !strings.Contains(up, "IDX_SUPPORT_CASES_NOTIFYING_STALE") ||
|
||||
!strings.Contains(up, "WHERE STATUS = 'NOTIFYING'") {
|
||||
t.Error("up migration must index stale notifying support_cases")
|
||||
}
|
||||
// Coverage decisions listing index
|
||||
if !strings.Contains(up, "IDX_COVERAGE_DECISIONS_TENANT_CHANNEL") {
|
||||
t.Error("up migration must create index on coverage_decisions (tenant_key, channel, created_at DESC)")
|
||||
}
|
||||
// Uncovered verdict index
|
||||
if !strings.Contains(up, "WHERE VERDICT = 'UNCOVERED'") {
|
||||
t.Error("up migration must create partial index on uncovered coverage_decisions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CoverageNoReservedWords(t *testing.T) {
|
||||
up := read007Migration(t, "up")
|
||||
// Check for bare "references" as column name in CREATE TABLE statements
|
||||
lines := strings.Split(up, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
upperLine := strings.ToUpper(trimmed)
|
||||
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "REFERENCES TURNS") && !strings.Contains(upperLine, "REFERENCES COVERAGE") {
|
||||
t.Errorf("reserved word 'references' used as column name in: %s", trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007NoForbiddenOperations(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
for _, forbidden := range []string{
|
||||
"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM",
|
||||
"ALTER TABLE", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
|
||||
"RAW_RESPONSE", "RAW_ERROR",
|
||||
} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("up migration contains forbidden operation %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007NoQuestionAnswerBodies(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
for _, forbidden := range []string{"QUESTION", "ANSWER_TEXT", "REFERENCE_CONTENT", "ANSWER_CONTENT"} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("coverage/support tables must not duplicate question/answer/reference bodies: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown007DropsInSafeOrder(t *testing.T) {
|
||||
down := strings.ToUpper(read007Migration(t, "down"))
|
||||
supportAt := strings.Index(down, "DROP TABLE IF EXISTS SUPPORT_CASES")
|
||||
coverageAt := strings.Index(down, "DROP TABLE IF EXISTS COVERAGE_DECISIONS")
|
||||
if supportAt < 0 || coverageAt < 0 {
|
||||
t.Fatal("down migration must drop both tables")
|
||||
}
|
||||
if supportAt > coverageAt {
|
||||
t.Fatal("down migration must drop support_cases (child) before coverage_decisions (parent)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown007DoesNotDropBaselineTables(t *testing.T) {
|
||||
down := strings.ToUpper(read007Migration(t, "down"))
|
||||
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
|
||||
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
|
||||
t.Errorf("down migration must not drop baseline table %q", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE session_mappings
|
||||
DROP COLUMN IF EXISTS display_title;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE session_mappings
|
||||
ADD COLUMN IF NOT EXISTS display_title TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,28 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionDisplayTitleMigration(t *testing.T) {
|
||||
up, err := os.ReadFile(filepath.Join("000008_session_display_title.up.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read up migration: %v", err)
|
||||
}
|
||||
down, err := os.ReadFile(filepath.Join("000008_session_display_title.down.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read down migration: %v", err)
|
||||
}
|
||||
upper := strings.ToUpper(string(up))
|
||||
for _, required := range []string{"ALTER TABLE SESSION_MAPPINGS", "ADD COLUMN IF NOT EXISTS DISPLAY_TITLE", "TEXT NOT NULL", "DEFAULT ''"} {
|
||||
if !strings.Contains(upper, required) {
|
||||
t.Errorf("up migration missing %q", required)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.ToUpper(string(down)), "DROP COLUMN IF EXISTS DISPLAY_TITLE") {
|
||||
t.Error("down migration must drop display_title")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS region_snapshot;
|
||||
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS department_snapshot;
|
||||
|
||||
DROP TABLE IF EXISTS directory_profiles;
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE IF NOT EXISTS directory_profiles (
|
||||
tenant_key TEXT NOT NULL,
|
||||
staff_id TEXT NOT NULL,
|
||||
union_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
nickname TEXT NOT NULL DEFAULT '',
|
||||
job_title TEXT NOT NULL DEFAULT '',
|
||||
department_ids JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
department_name TEXT NOT NULL DEFAULT '',
|
||||
main_department_name TEXT NOT NULL DEFAULT '',
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
employment_status TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL CHECK (source IN ('full_sync','on_demand')),
|
||||
synced_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_key, staff_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_directory_profiles_tenant_name
|
||||
ON directory_profiles (tenant_key, name);
|
||||
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS department_snapshot TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS region_snapshot TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,42 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDirectoryProfilesMigration(t *testing.T) {
|
||||
up, err := os.ReadFile(filepath.Join("000009_directory_profiles.up.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read up migration: %v", err)
|
||||
}
|
||||
down, err := os.ReadFile(filepath.Join("000009_directory_profiles.down.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read down migration: %v", err)
|
||||
}
|
||||
upper := strings.ToUpper(string(up))
|
||||
for _, required := range []string{
|
||||
"CREATE TABLE IF NOT EXISTS DIRECTORY_PROFILES",
|
||||
"PRIMARY KEY (TENANT_KEY, STAFF_ID)",
|
||||
"DEPARTMENT_IDS JSONB",
|
||||
"REGION TEXT",
|
||||
"SOURCE TEXT",
|
||||
"ADD COLUMN IF NOT EXISTS DEPARTMENT_SNAPSHOT",
|
||||
"ADD COLUMN IF NOT EXISTS REGION_SNAPSHOT",
|
||||
} {
|
||||
if !strings.Contains(upper, required) {
|
||||
t.Errorf("up migration missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"MOBILE", "EMAIL", "CLIENT_SECRET", "ACCESS_TOKEN", "RAW_PAYLOAD"} {
|
||||
if strings.Contains(upper, forbidden) {
|
||||
t.Errorf("up migration contains forbidden sensitive field %q", forbidden)
|
||||
}
|
||||
}
|
||||
downUpper := strings.ToUpper(string(down))
|
||||
if !strings.Contains(downUpper, "DROP TABLE IF EXISTS DIRECTORY_PROFILES") || !strings.Contains(downUpper, "DROP COLUMN IF EXISTS REGION_SNAPSHOT") {
|
||||
t.Error("down migration must remove directory table and snapshot columns")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 000010_question_table_exports down migration
|
||||
-- Drop in reverse dependency order: first drop export table, then remove column
|
||||
|
||||
DROP TABLE IF EXISTS question_table_exports;
|
||||
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS trigger_person_snapshot;
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS actor_union_id_snapshot;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- 000010_question_table_exports: durable outbox for Notable question table async sync
|
||||
-- Add trigger_person_snapshot and actor_union_id_snapshot to messages and create question_table_exports table
|
||||
|
||||
-- 1. Add trigger_person_snapshot and actor_union_id_snapshot columns to messages
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS trigger_person_snapshot TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS actor_union_id_snapshot TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 2. Create question_table_exports outbox table
|
||||
CREATE TABLE IF NOT EXISTS question_table_exports (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'exporting', 'exported', 'export_failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at TIMESTAMPTZ,
|
||||
next_attempt_at TIMESTAMPTZ,
|
||||
exported_at TIMESTAMPTZ,
|
||||
failure_code TEXT NOT NULL DEFAULT '',
|
||||
external_record_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
-- 3. Index for claim query: pending/export_failed/exporting ordered by created_at
|
||||
-- Covers pending, export_failed (when next_attempt_at is NULL or <= NOW()),
|
||||
-- and stale exporting (when last_attempt_at < staleBefore).
|
||||
CREATE INDEX IF NOT EXISTS idx_question_table_exports_claim
|
||||
ON question_table_exports (status, next_attempt_at, created_at, id)
|
||||
WHERE status IN ('pending', 'export_failed')
|
||||
OR (status = 'exporting' AND last_attempt_at IS NOT NULL);
|
||||
|
||||
-- 4. Index for stale exporting recovery
|
||||
CREATE INDEX IF NOT EXISTS idx_question_table_exports_stale
|
||||
ON question_table_exports (status, last_attempt_at)
|
||||
WHERE status = 'exporting';
|
||||
@@ -0,0 +1,161 @@
|
||||
// Package migrations_test contains source-level contract tests for the
|
||||
// 000010_question_table_exports migration. No real database connection is used.
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read010Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000010_question_table_exports."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000010 %s migration: %v", direction, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestUp010AddsTriggerPersonSnapshot(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "ALTER TABLE MESSAGES") {
|
||||
t.Fatal("up migration must alter messages")
|
||||
}
|
||||
if !strings.Contains(up, "ADD COLUMN IF NOT EXISTS TRIGGER_PERSON_SNAPSHOT") {
|
||||
t.Error("up migration must add trigger_person_snapshot column")
|
||||
}
|
||||
if !strings.Contains(up, "ADD COLUMN IF NOT EXISTS ACTOR_UNION_ID_SNAPSHOT") {
|
||||
t.Error("up migration must add actor_union_id_snapshot column")
|
||||
}
|
||||
if !strings.Contains(up, "TEXT NOT NULL DEFAULT ''") {
|
||||
t.Error("snapshot columns must be TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010CreatesQuestionTableExports(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
required := []string{
|
||||
"CREATE TABLE IF NOT EXISTS QUESTION_TABLE_EXPORTS",
|
||||
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
|
||||
"STATUS", "ATTEMPTS", "LAST_ATTEMPT_AT", "NEXT_ATTEMPT_AT", "EXPORTED_AT",
|
||||
"FAILURE_CODE", "EXTERNAL_RECORD_ID",
|
||||
"CREATED_AT", "UPDATED_AT",
|
||||
}
|
||||
for _, fragment := range required {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010QuestionTableExportsConstraints(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
// FK to turns
|
||||
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
|
||||
t.Error("question_table_exports.turn_id must reference turns(id)")
|
||||
}
|
||||
// UNIQUE (tenant_key, channel, turn_id)
|
||||
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
|
||||
t.Error("question_table_exports must have UNIQUE (tenant_key, channel, turn_id)")
|
||||
}
|
||||
// status CHECK
|
||||
for _, status := range []string{"PENDING", "EXPORTING", "EXPORTED", "EXPORT_FAILED"} {
|
||||
if !strings.Contains(up, status) {
|
||||
t.Errorf("status CHECK must include %q", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010ClaimIndexes(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "IDX_QUESTION_TABLE_EXPORTS_CLAIM") {
|
||||
t.Error("up migration must create claim index")
|
||||
}
|
||||
if !strings.Contains(up, "WHERE STATUS IN ('PENDING', 'EXPORT_FAILED')") {
|
||||
t.Error("claim index must include pending and export_failed statuses")
|
||||
}
|
||||
if !strings.Contains(up, "IDX_QUESTION_TABLE_EXPORTS_STALE") {
|
||||
t.Error("up migration must create stale exporting index")
|
||||
}
|
||||
if !strings.Contains(up, "WHERE STATUS = 'EXPORTING'") {
|
||||
t.Error("stale index must filter exporting status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010HasNextAttemptAtColumn(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "NEXT_ATTEMPT_AT TIMESTAMPTZ") {
|
||||
t.Error("question_table_exports must have next_attempt_at column for retry backoff")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010HasExternalRecordID(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "EXTERNAL_RECORD_ID TEXT NOT NULL DEFAULT ''") {
|
||||
t.Error("question_table_exports must have external_record_id column (not a1_notation)")
|
||||
}
|
||||
if strings.Contains(up, "A1_NOTATION") {
|
||||
t.Error("question_table_exports must not have stale a1_notation column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010ClaimIndexCoversNextAttemptAt(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "NEXT_ATTEMPT_AT") {
|
||||
t.Error("claim index must cover next_attempt_at for retry backoff filtering")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown010DropsInSafeOrder(t *testing.T) {
|
||||
down := strings.ToUpper(read010Migration(t, "down"))
|
||||
dropExportAt := strings.Index(down, "DROP TABLE IF EXISTS QUESTION_TABLE_EXPORTS")
|
||||
dropTriggerAt := strings.Index(down, "DROP COLUMN IF EXISTS TRIGGER_PERSON_SNAPSHOT")
|
||||
dropUnionAt := strings.Index(down, "DROP COLUMN IF EXISTS ACTOR_UNION_ID_SNAPSHOT")
|
||||
if dropExportAt < 0 || dropTriggerAt < 0 || dropUnionAt < 0 {
|
||||
t.Fatal("down migration must drop export table and both snapshot columns")
|
||||
}
|
||||
if dropExportAt > dropTriggerAt || dropTriggerAt > dropUnionAt {
|
||||
t.Fatal("down migration must drop export table before snapshot columns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown010DoesNotDropBaselineTables(t *testing.T) {
|
||||
down := strings.ToUpper(read010Migration(t, "down"))
|
||||
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
|
||||
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
|
||||
t.Errorf("down migration must not drop baseline table %q", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010ExportsNoFormulaColumns(t *testing.T) {
|
||||
up := read010Migration(t, "up")
|
||||
// Check that the CREATE TABLE body does not contain user/AI content columns
|
||||
lines := strings.Split(up, "\n")
|
||||
inTable := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
upper := strings.ToUpper(trimmed)
|
||||
if strings.HasPrefix(upper, "CREATE TABLE IF NOT EXISTS QUESTION_TABLE_EXPORTS") {
|
||||
inTable = true
|
||||
continue
|
||||
}
|
||||
if inTable && trimmed == ");" {
|
||||
inTable = false
|
||||
continue
|
||||
}
|
||||
if !inTable {
|
||||
continue
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"QUESTION", "ANSWER_TEXT", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
|
||||
"RAW_RESPONSE", "RAW_ERROR", "ACCESS_TOKEN", "APP_SECRET",
|
||||
} {
|
||||
if strings.Contains(upper, forbidden) {
|
||||
t.Errorf("question_table_exports column must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
DROP INDEX IF EXISTS idx_support_cases_faq_export_stale;
|
||||
DROP INDEX IF EXISTS idx_support_cases_faq_export_claim;
|
||||
|
||||
ALTER TABLE support_cases
|
||||
DROP COLUMN IF EXISTS faq_record_id,
|
||||
DROP COLUMN IF EXISTS faq_failure_code,
|
||||
DROP COLUMN IF EXISTS faq_exported_at,
|
||||
DROP COLUMN IF EXISTS faq_next_attempt_at,
|
||||
DROP COLUMN IF EXISTS faq_last_attempt_at,
|
||||
DROP COLUMN IF EXISTS faq_export_attempts,
|
||||
DROP COLUMN IF EXISTS faq_export_status;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Durable FAQ export state for uncovered support cases.
|
||||
ALTER TABLE support_cases
|
||||
ADD COLUMN IF NOT EXISTS faq_export_status TEXT NOT NULL DEFAULT 'not_applicable'
|
||||
CHECK (faq_export_status IN ('not_applicable', 'pending', 'exporting', 'exported', 'export_failed')),
|
||||
ADD COLUMN IF NOT EXISTS faq_export_attempts INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (faq_export_attempts >= 0),
|
||||
ADD COLUMN IF NOT EXISTS faq_last_attempt_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS faq_next_attempt_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS faq_exported_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS faq_failure_code TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS faq_record_id TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Existing cases are intentionally not backfilled. Only cases created after
|
||||
-- this migration enter the FAQ workflow automatically.
|
||||
ALTER TABLE support_cases
|
||||
ALTER COLUMN faq_export_status SET DEFAULT 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_faq_export_claim
|
||||
ON support_cases (faq_export_status, faq_next_attempt_at, created_at, id)
|
||||
WHERE faq_export_status IN ('pending', 'export_failed');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_faq_export_stale
|
||||
ON support_cases (faq_export_status, faq_last_attempt_at)
|
||||
WHERE faq_export_status = 'exporting';
|
||||
@@ -0,0 +1,74 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read011Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000011_faq_exports."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000011 %s migration: %v", direction, err)
|
||||
}
|
||||
return strings.ToUpper(string(data))
|
||||
}
|
||||
|
||||
func TestUp011AddsDurableFAQExportStateToSupportCases(t *testing.T) {
|
||||
up := read011Migration(t, "up")
|
||||
for _, fragment := range []string{
|
||||
"ALTER TABLE SUPPORT_CASES",
|
||||
"FAQ_EXPORT_STATUS", "FAQ_EXPORT_ATTEMPTS", "FAQ_LAST_ATTEMPT_AT",
|
||||
"FAQ_NEXT_ATTEMPT_AT", "FAQ_EXPORTED_AT", "FAQ_FAILURE_CODE", "FAQ_RECORD_ID",
|
||||
"PENDING", "EXPORTING", "EXPORTED", "EXPORT_FAILED",
|
||||
} {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"FAQ_QUESTION", "FAQ_ANSWER", "RAW_RESPONSE", "ACCESS_TOKEN"} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("migration must not duplicate business or secret field %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp011DoesNotBackfillHistoricalSupportCases(t *testing.T) {
|
||||
up := read011Migration(t, "up")
|
||||
if !strings.Contains(up, "DEFAULT 'NOT_APPLICABLE'") {
|
||||
t.Fatal("existing support cases must start as not_applicable")
|
||||
}
|
||||
if !strings.Contains(up, "ALTER COLUMN FAQ_EXPORT_STATUS SET DEFAULT 'PENDING'") {
|
||||
t.Fatal("future support cases must default to pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp011AddsBoundedClaimIndexes(t *testing.T) {
|
||||
up := read011Migration(t, "up")
|
||||
for _, fragment := range []string{
|
||||
"IDX_SUPPORT_CASES_FAQ_EXPORT_CLAIM",
|
||||
"FAQ_NEXT_ATTEMPT_AT", "FAQ_LAST_ATTEMPT_AT",
|
||||
"WHERE FAQ_EXPORT_STATUS IN ('PENDING', 'EXPORT_FAILED')",
|
||||
} {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing claim index fragment %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown011OnlyRemovesFAQExportState(t *testing.T) {
|
||||
down := read011Migration(t, "down")
|
||||
for _, column := range []string{
|
||||
"FAQ_EXPORT_STATUS", "FAQ_EXPORT_ATTEMPTS", "FAQ_LAST_ATTEMPT_AT",
|
||||
"FAQ_NEXT_ATTEMPT_AT", "FAQ_EXPORTED_AT", "FAQ_FAILURE_CODE", "FAQ_RECORD_ID",
|
||||
} {
|
||||
if !strings.Contains(down, "DROP COLUMN IF EXISTS "+column) {
|
||||
t.Errorf("down migration missing %s", column)
|
||||
}
|
||||
}
|
||||
if strings.Contains(down, "DROP TABLE") {
|
||||
t.Fatal("down migration must not drop baseline tables")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS config_audit_events; DROP TABLE IF EXISTS system_resource_bindings; DROP TABLE IF EXISTS administrators; DROP TABLE IF EXISTS system_config_revisions;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS system_config_revisions (
|
||||
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0),
|
||||
state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')), public_config JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
secret_ref TEXT NOT NULL DEFAULT '', validation_status TEXT NOT NULL DEFAULT 'pending', validation_code TEXT NOT NULL DEFAULT '',
|
||||
validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ, created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_key,kind,revision));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_system_config_active ON system_config_revisions(tenant_key,kind) WHERE state='active';
|
||||
CREATE TABLE IF NOT EXISTS administrators (
|
||||
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, union_id TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '',
|
||||
department TEXT NOT NULL DEFAULT '', role TEXT NOT NULL CHECK(role IN ('super_admin','admin')), status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','disabled')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,staff_id));
|
||||
CREATE TABLE IF NOT EXISTS system_resource_bindings (
|
||||
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0), state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')),
|
||||
public_config JSONB NOT NULL DEFAULT '{}'::JSONB, validation_code TEXT NOT NULL DEFAULT '', validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ,
|
||||
created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,kind,revision));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_resource_binding_active ON system_resource_bindings(tenant_key,kind) WHERE state='active';
|
||||
CREATE TABLE IF NOT EXISTS config_audit_events (
|
||||
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, actor_staff_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, target_kind TEXT NOT NULL,
|
||||
target_revision INTEGER NOT NULL DEFAULT 0, result_code TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
|
||||
@@ -0,0 +1,25 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp012CreatesSettingsWithoutSecrets(t *testing.T) {
|
||||
b, e := os.ReadFile("000012_system_settings.up.sql")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s := strings.ToUpper(string(b))
|
||||
for _, x := range []string{"SYSTEM_CONFIG_REVISIONS", "ADMINISTRATORS", "SYSTEM_RESOURCE_BINDINGS", "CONFIG_AUDIT_EVENTS", "PUBLIC_CONFIG"} {
|
||||
if !strings.Contains(s, x) {
|
||||
t.Errorf("missing %s", x)
|
||||
}
|
||||
}
|
||||
for _, x := range []string{"CLIENT_SECRET JSON", "ACCESS_TOKEN JSON", "API_KEY JSON"} {
|
||||
if strings.Contains(s, x) {
|
||||
t.Errorf("forbidden %s", x)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS admin_bootstrap_challenges;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS admin_bootstrap_challenges (
|
||||
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, code_hash TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts>=0), max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts BETWEEN 1 AND 10),
|
||||
candidate_staff_id TEXT NOT NULL DEFAULT '', candidate_union_id TEXT NOT NULL DEFAULT '', candidate_display_name TEXT NOT NULL DEFAULT '', candidate_department TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL CHECK(status IN ('pending','candidate_detected','confirmed','expired','locked')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_admin_bootstrap_pending ON admin_bootstrap_challenges(tenant_key) WHERE status IN ('pending','candidate_detected');
|
||||
@@ -0,0 +1,18 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp013StoresOnlyBootstrapHash(t *testing.T) {
|
||||
b, e := os.ReadFile("000013_admin_bootstrap.up.sql")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s := strings.ToUpper(string(b))
|
||||
if !strings.Contains(s, "CODE_HASH") || strings.Contains(s, " CODE TEXT") {
|
||||
t.Fatal("bootstrap must store hash only")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS setting_sessions;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS setting_sessions (
|
||||
tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, conversation_id TEXT NOT NULL, conversation_type TEXT NOT NULL CHECK(conversation_type IN ('direct','group')),
|
||||
current_step TEXT NOT NULL, pending_action TEXT NOT NULL DEFAULT '', draft_config JSONB NOT NULL DEFAULT '{}'::JSONB, expires_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY(tenant_key,staff_id));
|
||||
CREATE INDEX IF NOT EXISTS idx_setting_sessions_expiry ON setting_sessions(expires_at);
|
||||
@@ -0,0 +1,20 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp014CreatesExpiringSettingSessions(t *testing.T) {
|
||||
b, e := os.ReadFile("000014_setting_sessions.up.sql")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s := strings.ToUpper(string(b))
|
||||
for _, x := range []string{"SETTING_SESSIONS", "EXPIRES_AT", "DRAFT_CONFIG"} {
|
||||
if !strings.Contains(s, x) {
|
||||
t.Errorf("missing %s", x)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS user_preferences;
|
||||
@@ -0,0 +1,3 @@
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
tenant_key TEXT NOT NULL, channel TEXT NOT NULL, external_actor_id TEXT NOT NULL, language_mode TEXT NOT NULL DEFAULT 'auto' CHECK(language_mode IN ('auto','fixed')),
|
||||
locale TEXT NOT NULL DEFAULT 'auto' CHECK(locale IN ('auto','zh-CN','en','ja','ko','es')), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY(tenant_key,channel,external_actor_id));
|
||||
@@ -0,0 +1,20 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp015ScopesPreferencesByTenantAndActor(t *testing.T) {
|
||||
b, e := os.ReadFile("000015_user_preferences.up.sql")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s := strings.ToUpper(string(b))
|
||||
for _, x := range []string{"USER_PREFERENCES", "TENANT_KEY", "EXTERNAL_ACTOR_ID", "LANGUAGE_MODE", "LOCALE"} {
|
||||
if !strings.Contains(s, x) {
|
||||
t.Errorf("missing %s", x)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 000016_question_image_urls down migration.
|
||||
ALTER TABLE turns
|
||||
DROP COLUMN IF EXISTS question_image_urls_json;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 000016_question_image_urls: stable published URLs for inbound question images.
|
||||
-- Stores the object-storage HTTPS URLs of images a user sent in a question,
|
||||
-- so the question-table export can render a readable, traceable record without
|
||||
-- persisting download codes or data URIs.
|
||||
ALTER TABLE turns
|
||||
ADD COLUMN IF NOT EXISTS question_image_urls_json JSONB NOT NULL DEFAULT '[]'::JSONB;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 000017_knowledge_searched down migration.
|
||||
ALTER TABLE turns
|
||||
DROP COLUMN IF EXISTS knowledge_searched;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 000017_knowledge_searched: records whether a turn actually triggered a
|
||||
-- knowledge-base search (knowledge_search tool call). Coverage classification
|
||||
-- uses this to distinguish pure visual recognition from a searched-but-missed
|
||||
-- knowledge question.
|
||||
ALTER TABLE turns
|
||||
ADD COLUMN IF NOT EXISTS knowledge_searched BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 000018_coverage_visual_recognition_reason down migration.
|
||||
ALTER TABLE coverage_decisions
|
||||
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
|
||||
|
||||
ALTER TABLE coverage_decisions
|
||||
ADD CONSTRAINT coverage_decisions_reason_code_check
|
||||
CHECK (reason_code IN (
|
||||
'covered_has_references',
|
||||
'uncovered_no_references',
|
||||
'uncovered_explicit_no_answer'
|
||||
));
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 000018_coverage_visual_recognition_reason: extend the coverage_decisions
|
||||
-- reason_code allowlist with the new image-visual-recognition reason emitted by
|
||||
-- coverage Evaluator v3 (visual recognition is not a knowledge-coverage miss).
|
||||
ALTER TABLE coverage_decisions
|
||||
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
|
||||
|
||||
ALTER TABLE coverage_decisions
|
||||
ADD CONSTRAINT coverage_decisions_reason_code_check
|
||||
CHECK (reason_code IN (
|
||||
'covered_has_references',
|
||||
'uncovered_no_references',
|
||||
'uncovered_explicit_no_answer',
|
||||
'covered_visual_recognition'
|
||||
));
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 000019_reasoning_mode_state down migration.
|
||||
DROP TABLE IF EXISTS reasoning_mode_turns;
|
||||
DROP TABLE IF EXISTS reasoning_mode_states;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 000019_reasoning_mode_state: actor-scoped /think state and
|
||||
-- per-turn idempotency for successful reasoning-mode completions.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reasoning_mode_states (
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
external_actor_id TEXT NOT NULL,
|
||||
mode TEXT NOT NULL DEFAULT 'quick'
|
||||
CHECK (mode IN ('quick', 'reasoning')),
|
||||
max_rounds INTEGER NOT NULL DEFAULT 10
|
||||
CHECK (max_rounds BETWEEN 1 AND 50),
|
||||
rounds_used INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (rounds_used >= 0 AND rounds_used <= max_rounds),
|
||||
hint_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_activity_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
onboarding_shown_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_key, channel, external_actor_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reasoning_mode_states_expiry
|
||||
ON reasoning_mode_states (expires_at)
|
||||
WHERE mode = 'reasoning' AND expires_at IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reasoning_mode_turns (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
external_actor_id TEXT NOT NULL,
|
||||
execution_mode TEXT NOT NULL
|
||||
CHECK (execution_mode IN ('quick', 'reasoning', 'reasoning_single')),
|
||||
round_number INTEGER NOT NULL DEFAULT 0 CHECK (round_number >= 0),
|
||||
showed_hint BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
auto_closed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
showed_onboarding BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reasoning_mode_turns_actor_created
|
||||
ON reasoning_mode_turns (tenant_key, channel, external_actor_id, created_at DESC);
|
||||
@@ -0,0 +1,79 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp019CreatesActorScopedReasoningModeState(t *testing.T) {
|
||||
data, err := os.ReadFile("000019_reasoning_mode_state.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"REASONING_MODE_STATES",
|
||||
"TENANT_KEY",
|
||||
"CHANNEL",
|
||||
"EXTERNAL_ACTOR_ID",
|
||||
"MODE",
|
||||
"MAX_ROUNDS",
|
||||
"ROUNDS_USED",
|
||||
"HINT_ENABLED",
|
||||
"EXPIRES_AT",
|
||||
"ONBOARDING_SHOWN_AT",
|
||||
"PRIMARY KEY (TENANT_KEY, CHANNEL, EXTERNAL_ACTOR_ID)",
|
||||
"MODE IN ('QUICK', 'REASONING')",
|
||||
"MAX_ROUNDS BETWEEN 1 AND 50",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("reasoning state migration missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp019CreatesTurnIdempotencyLedger(t *testing.T) {
|
||||
data, err := os.ReadFile("000019_reasoning_mode_state.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"REASONING_MODE_TURNS",
|
||||
"TURN_ID",
|
||||
"EXTERNAL_ACTOR_ID",
|
||||
"EXECUTION_MODE",
|
||||
"ROUND_NUMBER",
|
||||
"SHOWED_HINT",
|
||||
"AUTO_CLOSED",
|
||||
"SHOWED_ONBOARDING",
|
||||
"UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)",
|
||||
"REFERENCES TURNS(ID)",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("reasoning turn ledger migration missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown019RemovesOnlyReasoningModeObjects(t *testing.T) {
|
||||
data, err := os.ReadFile("000019_reasoning_mode_state.down.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"DROP TABLE IF EXISTS REASONING_MODE_TURNS",
|
||||
"DROP TABLE IF EXISTS REASONING_MODE_STATES",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("reasoning down migration missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"DROP TABLE TURNS", "DROP TABLE USER_PREFERENCES"} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Errorf("reasoning down migration must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 000020_feedback_support_trigger down migration.
|
||||
DROP INDEX IF EXISTS idx_support_cases_trigger_type_created;
|
||||
ALTER TABLE support_cases
|
||||
DROP COLUMN IF EXISTS trigger_feedback_id,
|
||||
DROP COLUMN IF EXISTS trigger_type;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 000020_feedback_support_trigger: let an existing support-case workflow be
|
||||
-- triggered by a downvote while preserving one case per successful Turn.
|
||||
|
||||
ALTER TABLE support_cases
|
||||
ALTER COLUMN coverage_decision_id DROP NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS trigger_type TEXT NOT NULL DEFAULT 'coverage_uncovered'
|
||||
CHECK (trigger_type IN ('coverage_uncovered', 'feedback_down')),
|
||||
ADD COLUMN IF NOT EXISTS trigger_feedback_id TEXT REFERENCES answer_feedback(id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_trigger_type_created
|
||||
ON support_cases (trigger_type, created_at DESC);
|
||||
@@ -0,0 +1,50 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp020AddsFeedbackSupportTriggerAndMakesCoverageDecisionOptional(t *testing.T) {
|
||||
data, err := os.ReadFile("000020_feedback_support_trigger.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"ALTER TABLE SUPPORT_CASES",
|
||||
"TRIGGER_TYPE",
|
||||
"COVERAGE_UNCOVERED",
|
||||
"FEEDBACK_DOWN",
|
||||
"TRIGGER_FEEDBACK_ID",
|
||||
"REFERENCES ANSWER_FEEDBACK(ID)",
|
||||
"ALTER COLUMN COVERAGE_DECISION_ID DROP NOT NULL",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("feedback support migration missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(source, "DROP CONSTRAINT SUPPORT_CASES_COVERAGE_DECISION_ID_FKEY") {
|
||||
t.Fatal("feedback support must retain the coverage decision foreign-key contract")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown020RemovesOnlyFeedbackSupportColumns(t *testing.T) {
|
||||
data, err := os.ReadFile("000020_feedback_support_trigger.down.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"DROP COLUMN IF EXISTS TRIGGER_FEEDBACK_ID",
|
||||
"DROP COLUMN IF EXISTS TRIGGER_TYPE",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("feedback support down migration missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(source, "DROP TABLE") {
|
||||
t.Fatal("feedback support down migration must not drop shared tables")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE reasoning_mode_states
|
||||
DROP COLUMN user_selected;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 000022_reasoning_default_mode: support an operator-configurable default
|
||||
-- mode for the /think reasoning router. The new column records whether a
|
||||
-- user has explicitly chosen a mode (/think on|off); users who never have
|
||||
-- will follow DOBOTAI_DEFAULT_MODE instead of the hardcoded quick answer.
|
||||
ALTER TABLE reasoning_mode_states
|
||||
ADD COLUMN user_selected BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,34 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp022AddsReasoningUserSelectedColumn(t *testing.T) {
|
||||
data, err := os.ReadFile("000022_reasoning_default_mode.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"ALTER TABLE REASONING_MODE_STATES",
|
||||
"ADD COLUMN USER_SELECTED BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("up migration missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown022DropsUserSelectedColumn(t *testing.T) {
|
||||
data, err := os.ReadFile("000022_reasoning_default_mode.down.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
if !strings.Contains(source, "DROP COLUMN USER_SELECTED") {
|
||||
t.Errorf("down migration missing %q", "DROP COLUMN USER_SELECTED")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot DingTalk AI card probe (test bot, private IM_ROBOT space).
|
||||
|
||||
Sends two cards to the owner's private robot chat so PC width can be compared:
|
||||
A: body carries sys_full_json_obj = {"config":{"autoLayout":true}}
|
||||
B: control, same body without the layout field
|
||||
Card body mirrors internal/channels/dingtalk/card.go buildCardBody() exactly.
|
||||
|
||||
Credentials are read from the running container's environment and are never
|
||||
printed. Only masked ids, HTTP statuses and DingTalk response bodies are shown.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
CONTAINER = "dobotai-dingtalk"
|
||||
TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
|
||||
CREATE_URL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
|
||||
STREAM_URL = "https://api.dingtalk.com/v1.0/card/streaming"
|
||||
OWNER_ID = "243143382023108067" # 姚章浩 (channel_actors.dingtalk, test tenant)
|
||||
WIDE = json.dumps({"config": {"autoLayout": True}}, separators=(",", ":"))
|
||||
|
||||
CONTENT = (
|
||||
"**{tag}**\n\n"
|
||||
"这是一条 PC 宽度对照测试卡片,由测试机器人在私聊里直接推送。\n\n"
|
||||
"- 投放体里的布局字段:{field}\n"
|
||||
"- 期望:{expect}\n"
|
||||
"- 请在 PC 端对比两张卡片的宽度差异\n\n"
|
||||
"移动端请忽略,宽屏只影响桌面端渲染。"
|
||||
)
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
out = subprocess.run(
|
||||
["docker", "exec", CONTAINER, "printenv", key],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
if not out:
|
||||
sys.exit(f"missing env {key}")
|
||||
return out
|
||||
|
||||
|
||||
def call(method: str, url: str, payload: dict, token: str = "") -> tuple[int, str]:
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if token:
|
||||
req.add_header("x-acs-dingtalk-access-token", token)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.status, resp.read(4096).decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read(4096).decode("utf-8", "replace")
|
||||
|
||||
|
||||
def mask(v: str) -> str:
|
||||
return v[:6] + "..." + v[-4:] if len(v) > 12 else "***"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app_key, app_secret = env("DINGTALK_CLIENT_ID"), env("DINGTALK_CLIENT_SECRET")
|
||||
tpl = env("DINGTALK_CARD_TEMPLATE_ID")
|
||||
print(f"app_key={mask(app_key)} template={mask(tpl)} target={mask(OWNER_ID)}")
|
||||
|
||||
status, body = call("POST", TOKEN_URL, {"appKey": app_key, "appSecret": app_secret})
|
||||
token = json.loads(body).get("accessToken", "")
|
||||
print(f"token: http={status} got={'yes' if token else 'NO'}")
|
||||
if not token:
|
||||
sys.exit("no token")
|
||||
|
||||
for tag, with_wide in (("A · 已带 autoLayout=true", True), ("B · 对照组(无布局字段)", False)):
|
||||
param_map = {
|
||||
"content": "",
|
||||
"feedbackSelected": "",
|
||||
"feedbackReasonSelected": "",
|
||||
"showFeedbackReasons": "false",
|
||||
"feedbackMessage": "",
|
||||
}
|
||||
if with_wide:
|
||||
param_map["sys_full_json_obj"] = WIDE
|
||||
out_track_id = str(uuid.uuid4())
|
||||
card_body = {
|
||||
"cardTemplateId": tpl,
|
||||
"outTrackId": out_track_id,
|
||||
"callbackType": "STREAM",
|
||||
"cardData": {"cardParamMap": param_map},
|
||||
"userIdType": 1,
|
||||
"openSpaceId": "dtv1.card//IM_ROBOT." + OWNER_ID,
|
||||
"imRobotOpenSpaceModel": {"supportForward": True},
|
||||
"imRobotOpenDeliverModel": {
|
||||
"robotCode": app_key,
|
||||
"spaceType": "IM_ROBOT",
|
||||
"extension": {},
|
||||
},
|
||||
}
|
||||
st, rb = call("POST", CREATE_URL, card_body, token)
|
||||
print(f"[{tag}] create http={st} resp={rb[:300]}")
|
||||
|
||||
text = CONTENT.format(
|
||||
tag=tag,
|
||||
field=("sys_full_json_obj = " + WIDE) if with_wide else "(无)",
|
||||
expect="PC 端铺满窗口(宽屏)" if with_wide else "PC 端窄卡片(默认宽度)",
|
||||
)
|
||||
stream_body = {
|
||||
"outTrackId": out_track_id,
|
||||
"guid": str(uuid.uuid4()),
|
||||
"key": "content",
|
||||
"content": text,
|
||||
"isFull": True,
|
||||
"isFinalize": True,
|
||||
"isError": False,
|
||||
}
|
||||
st2, rb2 = call("PUT", STREAM_URL, stream_body, token)
|
||||
print(f"[{tag}] finalize http={st2} resp={rb2[:200]} outTrackId={mask(out_track_id)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,157 @@
|
||||
# DobotAI Docker部署(阿里云RDS)
|
||||
|
||||
## 定位
|
||||
|
||||
DobotAI是钉钉渠道编排与交付服务。Docker只运行应用容器`dobotai-dingtalk`;数据库唯一使用已有的阿里云RDS PostgreSQL。
|
||||
|
||||
```text
|
||||
钉钉 Stream
|
||||
↓
|
||||
dobotai-dingtalk 容器
|
||||
├─ 阿里云RDS PostgreSQL:Turn、会话、反馈、管理员、覆盖判定
|
||||
└─ 既有WeKnora:Agent、检索、知识、模型
|
||||
```
|
||||
|
||||
禁止创建或使用:
|
||||
|
||||
```text
|
||||
dobotai-postgres 容器
|
||||
dobotai-postgres-data 数据卷
|
||||
本地PostgreSQL作为DobotAI目标数据库
|
||||
```
|
||||
|
||||
## 架构与目录
|
||||
|
||||
```text
|
||||
宿主机
|
||||
├── dobotai-dingtalk 唯一应用容器,host网络
|
||||
├── /opt/dobotai/container/ Compose、RDS检查器、SQL迁移、启动脚本
|
||||
├── /opt/dobotai/config/dobotai.env 0600,RDS/WeKnora/钉钉配置
|
||||
├── /opt/dobotai/secrets/ 0700,DobotAI SecretStore
|
||||
└── /opt/dobotai/backups/ 发布前RDS备份记录,不保存Docker数据库卷
|
||||
|
||||
阿里云RDS
|
||||
└── DobotAI专用PostgreSQL数据库
|
||||
```
|
||||
|
||||
应用使用`network_mode: host`,因此通过RDS私网地址访问云数据库、通过`127.0.0.1:8080`访问宿主机现有WeKnora。不会开放新的数据库端口;应用HTTP仍由`DOBOTAI_LISTEN_ADDR=127.0.0.1:8090`约束。
|
||||
|
||||
## 安装
|
||||
|
||||
克隆完整仓库后,只执行:
|
||||
|
||||
```bash
|
||||
sudo bash ./install-docker.sh
|
||||
```
|
||||
|
||||
根脚本自动:
|
||||
|
||||
```text
|
||||
检查或安装Docker Engine和Docker Compose v2(APT系统)
|
||||
以当前Git提交构建 dobotai-dingtalk:local-<Git短SHA>
|
||||
创建 /opt/dobotai/{container,config,secrets,backups}
|
||||
复制应用Compose、RDS检查器和21个SQL迁移
|
||||
生成权限0600的应用环境模板
|
||||
安装三条短命令
|
||||
```
|
||||
|
||||
它不会连接、创建或修改RDS,也不会启动未配置的钉钉应用。
|
||||
|
||||
## 配置、RDS检查与启动
|
||||
|
||||
安装后执行:
|
||||
|
||||
```bash
|
||||
sudo dobotai-dingtalk-configure
|
||||
```
|
||||
|
||||
配置器分字段录入阿里云RDS(主机、端口、数据库名、账号、密码),自动组装连接串;WeKnora和钉钉凭证密钥不回显,环境文件原子写入且权限0600。
|
||||
|
||||
`DOBOTAI_TENANT_KEY`不是WeKnora Agent租户键。它是DobotAI内部数据隔离键,首次安装时自动生成并固定保存;配置器不会要求填写或改动它。WeKnora侧只需要填写实际的两个Agent ID。
|
||||
|
||||
配置器会要求两个不同的WeKnora Agent ID,并自动生成`DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"快速问答Agent ID"}`。`DOBOTAI_INGRESS_TOKEN`是保护本机`POST /v1/qa`内部接口的Bearer Token,不用于钉钉或WeKnora;同样由安装器自动生成。
|
||||
|
||||
| 配置 | 示例 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `DATABASE_URL` | `postgres://USER:***@rds-endpoint:5432/dobotai?sslmode=require` | DobotAI专用阿里云RDS PostgreSQL |
|
||||
| `WEKNORA_BASE_URL` | `http://127.0.0.1:8080` | 宿主机既有WeKnora |
|
||||
| `WEKNORA_API_KEY` | `<你的WeKnora API Key>` | DobotAI专用Key |
|
||||
| `DOBOTAI_QUICK_AGENT_ID` | `<快速问答Agent ID>` | 默认快速问答实际调用的Agent |
|
||||
| `DOBOTAI_REASONING_AGENT_ID` | `<智能推理Agent ID>` | `/think`和持续智能推理实际调用的Agent |
|
||||
| `DINGTALK_CLIENT_ID` | `dingxxxxxxxx` | 钉钉AppKey |
|
||||
| `DINGTALK_CLIENT_SECRET` | `<你的钉钉 AppSecret>` | 钉钉AppSecret |
|
||||
|
||||
回答图片外发OSS同为分字段录入的可选整组(紧随RDS之后、WeKnora/钉钉凭证之前):
|
||||
|
||||
| 配置 | 示例 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT` | `https://oss-cn-shenzhen.aliyuncs.com` | 私有Bucket接入地址 |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_REGION` | `cn-shenzhen` | Bucket所在地域 |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET` | `dobot-xxx` | 外发图片专用私有Bucket |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID` | `<AK ID>` | 仅授权该Bucket的AK |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET` | `<AK Secret>` | 仅授权该Bucket的SK(不回显) |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL` | `https://host/dingtalk/i` | 钉钉可见的稳定HTTPS入口,不含凭据 |
|
||||
|
||||
OSS规则:六项全部留空即整组禁用图片外发(答案含`resource://`图片时按无图降级);填写任一项则六项必填,缺项拒绝保存。运行时仅改写WeKnora `resource://`引用为短签HTTPS,私有Bucket由稳定基础地址代理,`<img>`外链与`data:`剥除。禁止把OSS凭据写入Git或命令行。
|
||||
|
||||
保存后脚本自动执行以下RDS门禁:
|
||||
|
||||
```text
|
||||
1. 使用RDS连接串执行 pg_isready
|
||||
2. 检查DobotAI核心表
|
||||
3. 数据库没有任何public表:按000001~000021初始化
|
||||
4. 所有核心表存在:判定正常,不执行任何DDL
|
||||
5. 部分表、缺表或非空异常库:拒绝初始化和启动
|
||||
6. RDS通过后才启动 dobotai-dingtalk 并检查 /healthz
|
||||
```
|
||||
|
||||
这保证不会把已有RDS表误当成空库重建,也不会把半成品或错误库静默升级。
|
||||
|
||||
## 日常命令
|
||||
|
||||
```bash
|
||||
# 重新检查RDS并启动/升级应用
|
||||
sudo dobotai-dingtalk-start
|
||||
|
||||
# 交互更新RDS、WeKnora、钉钉配置后启动
|
||||
sudo dobotai-dingtalk-configure
|
||||
|
||||
# 仅首位管理员且无有效管理员时使用
|
||||
sudo dobotai-dingtalk-bind-admin
|
||||
|
||||
# 健康与版本
|
||||
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
|
||||
curl --noproxy '*' -fsS http://127.0.0.1:8090/version
|
||||
```
|
||||
|
||||
## 升级与回滚
|
||||
|
||||
应用升级仅替换`dobotai-dingtalk`镜像。启动前再次检查RDS;健康失败时恢复上一应用镜像(如存在)。RDS不是容器资产,不能被`docker compose down`、镜像替换或本机卷操作影响。
|
||||
|
||||
RDS有实际数据时,数据库备份、SQL变更与恢复应在阿里云RDS发布流程中完成并验证;不要把应用镜像回滚误当成数据库回滚。
|
||||
|
||||
## 首位管理员
|
||||
|
||||
应用和钉钉Stream运行后:
|
||||
|
||||
```bash
|
||||
sudo dobotai-dingtalk-bind-admin
|
||||
```
|
||||
|
||||
终端会显示10分钟有效的六码;候选管理员私聊机器人:
|
||||
|
||||
```text
|
||||
/setting <六码绑定码>
|
||||
```
|
||||
|
||||
已有有效管理员时绑定命令会拒绝,避免提权。
|
||||
|
||||
## 不执行的操作
|
||||
|
||||
```text
|
||||
不创建本地PostgreSQL容器或数据卷
|
||||
不迁移、删除或覆盖现有本地 dobotai-test-postgres
|
||||
不创建、删除或重置阿里云RDS数据库
|
||||
不把RDS连接串、Token、钉钉凭据写入Git、命令行或日志
|
||||
不重建WeKnora、Neo4j、Redis、Elasticsearch
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
dobotai-dingtalk:
|
||||
image: ${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE in runtime.env}
|
||||
container_name: dobotai-dingtalk
|
||||
network_mode: host
|
||||
user: "${DOBOTAI_RUNTIME_UID:?set DOBOTAI_RUNTIME_UID}:${DOBOTAI_RUNTIME_GID:?set DOBOTAI_RUNTIME_GID}"
|
||||
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
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=32m
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
FIELDS = (
|
||||
("WEKNORA_BASE_URL", "WeKnora 地址", "应用容器通过宿主机网络访问既有WeKnora。", "http://127.0.0.1:8080", False),
|
||||
("WEKNORA_API_KEY", "WeKnora API Key", "DobotAI专用WeKnora API Key。", "<你的WeKnora API Key>", True),
|
||||
("DOBOTAI_QUICK_AGENT_ID", "快速问答Agent ID", "默认问答实际调用的WeKnora Agent。", "<快速问答Agent ID>", False),
|
||||
("DOBOTAI_REASONING_AGENT_ID", "智能推理Agent ID", "/think和持续智能推理实际调用的WeKnora Agent。", "<智能推理Agent ID>", False),
|
||||
("DINGTALK_CLIENT_ID", "钉钉 Client ID", "钉钉应用Client ID/AppKey。", "dingxxxxxxxx", False),
|
||||
("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", "钉钉应用Client Secret/AppSecret。", "<你的钉钉 AppSecret>", True),
|
||||
)
|
||||
|
||||
def prompt_rds(values):
|
||||
"""分字段录入阿里云RDS并组装DATABASE_URL;已有值作为默认。"""
|
||||
existing = values.get("DATABASE_URL", "").strip()
|
||||
host = port = database = user = password = ""
|
||||
if existing:
|
||||
u = urlsplit(existing)
|
||||
host = u.hostname or ""
|
||||
port = str(u.port or "") if u.port else ""
|
||||
database = u.path.lstrip("/")
|
||||
user = unquote(u.username or "")
|
||||
password = unquote(u.password or "")
|
||||
|
||||
def ask(key, label, default, secret=False):
|
||||
suffix = "(已配置,直接回车保留)" if default else "(必填)"
|
||||
reader = getpass.getpass if secret else input
|
||||
entered = reader(f"{label}{suffix}: ").strip()
|
||||
return entered or default
|
||||
|
||||
print("\n阿里云RDS PostgreSQL(分字段填写)\n用途:DobotAI专用数据库;空库自动初始化,已有表只校验不改结构。")
|
||||
host = ask("host", "RDS主机地址", host)
|
||||
port = ask("port", "RDS端口", port or "5432")
|
||||
database = ask("database", "数据库名", database)
|
||||
user = ask("user", "数据库账号", user)
|
||||
password = ask("password", "数据库密码", password, secret=True)
|
||||
if not all((host, port, database, user, password)):
|
||||
raise SystemExit("RDS字段不完整,未保存。")
|
||||
if not database.startswith(("dobotai", "dobotai_")):
|
||||
print("警告:数据库名不是DobotAI专用库,继续前请确认。")
|
||||
values["DATABASE_URL"] = f"postgres://{quote(user, safe='')}:{quote(password, safe='')}@{host}:{port}/{database}"
|
||||
|
||||
OSS_FIELDS = (
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT", "OSS Endpoint", "私有Bucket的接入地址。", "https://oss-cn-shenzhen.aliyuncs.com", False),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION", "OSS Region", "Bucket所在地域。", "cn-shenzhen", False),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET", "OSS Bucket", "外发图片专用私有Bucket。", "dobot-xxx", False),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID", "OSS AccessKey ID", "仅授权该Bucket的AK。", "<AK ID>", False),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET", "OSS AccessKey Secret", "仅授权该Bucket的SK。", "<AK Secret>", True),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "图片稳定外发基础地址", "钉钉可见的稳定HTTPS入口,不含凭据。", "https://host/dingtalk/i", False),
|
||||
)
|
||||
|
||||
def prompt_oss(values):
|
||||
"""OSS整组录入:全部留空即禁用;任一项填写则六项必填。"""
|
||||
print("\n回答图片外发OSS(可选,OSS整组录入;直接回车表示整组禁用)")
|
||||
entered = {}
|
||||
for key, label, description, example, secret in OSS_FIELDS:
|
||||
default = values.get(key, "").strip()
|
||||
suffix = "(已配置,直接回车保留)" if default else ""
|
||||
print(f"用途:{description} 格式示例:{example}")
|
||||
reader = getpass.getpass if secret else input
|
||||
value = reader(f"{label}{suffix}: ").strip()
|
||||
entered[key] = value or default
|
||||
if not any(entered.values()):
|
||||
for key, *_ in OSS_FIELDS:
|
||||
values.pop(key, None)
|
||||
return
|
||||
missing = [label for key, label, *_ in OSS_FIELDS if not entered[key]]
|
||||
if missing:
|
||||
raise SystemExit("OSS为整组可选能力,以下项未填写:" + "、".join(missing))
|
||||
values.update(entered)
|
||||
|
||||
|
||||
|
||||
def parse_env(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 write_env(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)
|
||||
rendered.extend(f"{key}={value}" for key,value in values.items() if key not in written)
|
||||
temp=path.with_name(path.name+".new"); temp.write_text("\n".join(rendered)+"\n",encoding="utf-8"); os.chmod(temp,0o600); os.replace(temp,path)
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description="配置DobotAI阿里云RDS和渠道凭证")
|
||||
parser.add_argument("--env-file",required=True); parser.add_argument("--schema-script",required=True); parser.add_argument("--start-script",required=True)
|
||||
args=parser.parse_args(); path=Path(args.env_file)
|
||||
if not path.is_file(): raise SystemExit("DobotAI环境文件不存在,请先执行根目录install-docker.sh。")
|
||||
lines,values=parse_env(path)
|
||||
prompt_rds(values)
|
||||
prompt_oss(values)
|
||||
for key,label,description,example,secret in FIELDS:
|
||||
suffix="(已配置,直接回车保留)" if values.get(key,"").strip() else "(必填)"
|
||||
print(f"\n{label}\n用途:{description}\n格式示例:{example}")
|
||||
entered=(getpass.getpass if secret else input)(f"{label}{suffix}: ").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))
|
||||
if values["DOBOTAI_QUICK_AGENT_ID"] == values["DOBOTAI_REASONING_AGENT_ID"]:
|
||||
raise SystemExit("快速问答Agent ID与智能推理Agent ID必须不同。")
|
||||
values["DOBOTAI_AGENT_BINDINGS"] = json.dumps({"dingtalk:*": values["DOBOTAI_QUICK_AGENT_ID"]}, separators=(",", ":"))
|
||||
write_env(path,lines,values)
|
||||
subprocess.run([args.schema_script], check=True)
|
||||
os.execv(args.start_script,[args.start_script])
|
||||
if __name__=="__main__":
|
||||
try: main()
|
||||
except KeyboardInterrupt: sys.exit(130)
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/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,23 @@
|
||||
#!/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"
|
||||
[[ -r "$compose_file" && -r "$runtime_env" ]] || { echo "容器资产未安装。" >&2; exit 1; }
|
||||
"$container_dir/rds-schema-init.sh"
|
||||
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
|
||||
previous_image="$(docker inspect -f '{{.Config.Image}}' dobotai-dingtalk 2>/dev/null || true)"
|
||||
rollback() {
|
||||
"${compose[@]}" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true
|
||||
if [[ -n "$previous_image" ]]; then "${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk >/dev/null 2>&1 || true; fi
|
||||
}
|
||||
trap rollback ERR
|
||||
"${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
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/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"
|
||||
runtime_env="$container_dir/runtime.env"
|
||||
image="${DOBOTAI_IMAGE:?root install-docker.sh must provide the local image}"
|
||||
bin_dir="${DOBOTAI_BIN_DIR:-/usr/local/sbin}"
|
||||
|
||||
[[ ${EUID} -eq 0 ]] || { echo "请使用sudo执行安装脚本。" >&2; exit 1; }
|
||||
docker image inspect "$image" >/dev/null 2>&1 || { echo "应用镜像不存在。" >&2; exit 1; }
|
||||
install -d -m 0750 "$home" "$container_dir" "$config_dir" "$backup_dir" "$container_dir/migrations"
|
||||
install -d -m 0700 -o 10001 -g 10001 "$secret_dir"
|
||||
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/rds-schema-init.sh" "$container_dir/rds-schema-init.sh"
|
||||
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 0644 "$source_root"/db/migrations/*.up.sql "$container_dir/migrations/"
|
||||
|
||||
if [[ ! -f "$app_env" ]]; then
|
||||
umask 077
|
||||
read -r tenant_suffix ingress_token < <(python3 -c 'import secrets; print(secrets.token_hex(12), secrets.token_urlsafe(32))')
|
||||
cat > "$app_env" <<'EOF'
|
||||
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
|
||||
DATABASE_URL=
|
||||
WEKNORA_BASE_URL=http://127.0.0.1:8080
|
||||
WEKNORA_API_KEY=
|
||||
DOBOTAI_ADMIN_TOKEN=
|
||||
DOBOTAI_AGENT_BINDINGS=
|
||||
DOBOTAI_QUICK_AGENT_ID=
|
||||
DOBOTAI_REASONING_AGENT_ID=
|
||||
DINGTALK_CLIENT_ID=
|
||||
DINGTALK_CLIENT_SECRET=
|
||||
EOF
|
||||
printf 'DOBOTAI_TENANT_KEY=dobotai-%s\n' "$tenant_suffix" >> "$app_env"
|
||||
printf 'DOBOTAI_INGRESS_TOKEN=%s\n' "$ingress_token" >> "$app_env"
|
||||
chmod 0600 "$app_env"
|
||||
fi
|
||||
umask 077
|
||||
cat > "$runtime_env" <<EOF
|
||||
DOBOTAI_IMAGE=$image
|
||||
DOBOTAI_RUNTIME_UID=10001
|
||||
DOBOTAI_RUNTIME_GID=10001
|
||||
DOBOTAI_ENV_FILE=$app_env
|
||||
DOBOTAI_SECRET_DIR_HOST=$secret_dir
|
||||
EOF
|
||||
chmod 0600 "$runtime_env"
|
||||
install -d -m 0755 "$bin_dir"
|
||||
for command in configure start bind-admin; do
|
||||
target="$bin_dir/dobotai-dingtalk-$command"
|
||||
cat > "$target" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export DOBOTAI_HOME='$home'
|
||||
exec '$container_dir/dobotai-dingtalk-$command'
|
||||
EOF
|
||||
chmod 0750 "$target"
|
||||
done
|
||||
# configure is a Python entrypoint rather than a shell asset.
|
||||
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' --schema-script '$container_dir/rds-schema-init.sh' --start-script '$bin_dir/dobotai-dingtalk-start'
|
||||
EOF
|
||||
chmod 0750 "$bin_dir/dobotai-dingtalk-configure"
|
||||
|
||||
echo "应用容器资产已安装;尚未连接或修改阿里云RDS。"
|
||||
echo "下一步执行:sudo dobotai-dingtalk-configure"
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
container_dir="${DOBOTAI_CONTAINER_DIR:-/opt/dobotai-test/container}"
|
||||
service="${DOBOTAI_LEGACY_SERVICE:-dobotai-test.service}"
|
||||
runtime_user="${DOBOTAI_RUNTIME_USER:-dobotai-test}"
|
||||
env_file="${DOBOTAI_ENV_FILE:-/etc/dobotai-test/dobotai.env}"
|
||||
secret_dir="${DOBOTAI_SECRET_DIR_HOST:-/var/lib/dobotai-test/secrets}"
|
||||
image="${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE to the reviewed image tag before installation}"
|
||||
backup_root="${DOBOTAI_BACKUP_ROOT:-/opt/dobotai-test/backups}"
|
||||
|
||||
if [[ ${EUID} -ne 0 ]]; then
|
||||
echo "请使用 sudo 执行安装脚本。" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! docker image inspect "$image" >/dev/null 2>&1; then
|
||||
echo "候选镜像不存在:$image" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -r "$env_file" || ! -d "$secret_dir" ]]; then
|
||||
echo "现有 DobotAI 环境文件或密钥目录不存在,拒绝安装。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
uid="$(id -u "$runtime_user")"
|
||||
gid="$(id -g "$runtime_user")"
|
||||
stamp="$(date -u '+%Y%m%dT%H%M%SZ')"
|
||||
backup_dir="$backup_root/pre-container-$stamp"
|
||||
install -d -m 0700 "$backup_dir" "$container_dir"
|
||||
cp -p /opt/dobotai-test/bin/dobotai "$backup_dir/dobotai.native"
|
||||
cp -p /etc/systemd/system/dobotai-test.service "$backup_dir/dobotai-test.service"
|
||||
sha256sum "$backup_dir/dobotai.native" > "$backup_dir/SHA256SUMS"
|
||||
|
||||
install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml"
|
||||
install -m 0750 "$script_dir/dobotai-dingtalk-start" /usr/local/sbin/dobotai-dingtalk-start
|
||||
install -m 0750 "$script_dir/dobotai-dingtalk-configure" /usr/local/sbin/dobotai-dingtalk-configure
|
||||
install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" /usr/local/sbin/dobotai-dingtalk-bind-admin
|
||||
install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py"
|
||||
|
||||
runtime_tmp="$container_dir/runtime.env.new"
|
||||
umask 077
|
||||
cat > "$runtime_tmp" <<EOF
|
||||
DOBOTAI_IMAGE=$image
|
||||
DOBOTAI_RUNTIME_UID=$uid
|
||||
DOBOTAI_RUNTIME_GID=$gid
|
||||
DOBOTAI_ENV_FILE=$env_file
|
||||
DOBOTAI_SECRET_DIR_HOST=$secret_dir
|
||||
EOF
|
||||
chmod 0600 "$runtime_tmp"
|
||||
mv -f "$runtime_tmp" "$container_dir/runtime.env"
|
||||
|
||||
echo "容器资产已安装,旧服务仍在运行。"
|
||||
echo "下一步执行:sudo dobotai-dingtalk-configure"
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
home="${DOBOTAI_HOME:-/opt/dobotai}"
|
||||
app_env="$home/config/dobotai.env"
|
||||
migrations_dir="$home/container/migrations"
|
||||
[[ -r "$app_env" && -d "$migrations_dir" ]] || { echo "RDS检查资产未安装。" >&2; exit 1; }
|
||||
temp_env="$(mktemp)"; trap 'rm -f "$temp_env"' EXIT
|
||||
python3 - "$app_env" "$temp_env" <<'PY'
|
||||
from pathlib import Path
|
||||
import os,sys
|
||||
values={}
|
||||
for line in Path(sys.argv[1]).read_text(encoding='utf-8').splitlines():
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key,value=line.split('=',1); values[key]=value
|
||||
url=values.get('DATABASE_URL','').strip()
|
||||
if not (url.startswith('postgres://') or url.startswith('postgresql://')): raise SystemExit('DATABASE_URL必须是阿里云RDS PostgreSQL连接串。')
|
||||
Path(sys.argv[2]).write_text('DATABASE_URL='+url+'\n',encoding='utf-8'); os.chmod(sys.argv[2],0o600)
|
||||
PY
|
||||
rds_shell() { docker run --rm -i --network host --env-file "$temp_env" postgres:16-alpine sh -ec "$1"; }
|
||||
rds_shell 'pg_isready -d "$DATABASE_URL" >/dev/null' || true
|
||||
if ! probe_error="$(rds_shell 'exec psql -At -v ON_ERROR_STOP=1 "$DATABASE_URL" -c "SELECT 1"' 2>&1)"; then
|
||||
case "$probe_error" in
|
||||
*"does not exist"*|*"不存在"*)
|
||||
echo "阿里云RDS目标数据库不存在;请RDS管理员创建DobotAI专用数据库并授予应用账号连接和建表权限。" >&2
|
||||
;;
|
||||
*)
|
||||
echo "无法连接阿里云RDS目标数据库;请管理员检查网络、白名单、TLS、账号权限和连接串。" >&2
|
||||
;;
|
||||
esac
|
||||
exit 1
|
||||
fi
|
||||
sql() { rds_shell 'exec psql -v ON_ERROR_STOP=1 "$DATABASE_URL"'; }
|
||||
sql_at() { rds_shell 'exec psql -At -v ON_ERROR_STOP=1 "$DATABASE_URL"'; }
|
||||
state=$(printf "%s\n" "WITH expected(name) AS (VALUES ('turns'),('session_mappings'),('messages'),('coverage_decisions'),('support_cases'),('system_config_revisions'),('administrators'),('setting_sessions'),('user_preferences'),('reasoning_mode_states'),('reasoning_mode_turns')) SELECT CASE WHEN to_regclass('public.turns') IS NULL AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public')=0 THEN 'empty' WHEN (SELECT COUNT(*) FROM expected e JOIN information_schema.tables t ON t.table_schema='public' AND t.table_name=e.name)=11 THEN 'normal' ELSE 'inconsistent' END;" | sql_at)
|
||||
case "$state" in
|
||||
normal) echo "阿里云RDS表结构检查通过;不执行初始化。"; exit 0;;
|
||||
inconsistent) echo "阿里云RDS表结构不完整或不是空库,拒绝初始化和启动。" >&2; exit 1;;
|
||||
empty) echo "阿里云RDS为空,开始初始化DobotAI表。";;
|
||||
*) echo "无法判定阿里云RDS表状态。" >&2; exit 1;;
|
||||
esac
|
||||
for migration in "$migrations_dir"/*.up.sql; do printf 'BEGIN;\n'; cat "$migration"; printf '\nCOMMIT;\n'; done | sql
|
||||
echo "阿里云RDS初始化完成。"
|
||||
@@ -0,0 +1,6 @@
|
||||
# 非敏感容器运行参数;install-test.sh 会根据测试服务器实际用户和目录生成此文件。
|
||||
DOBOTAI_IMAGE=dobotai-dingtalk:test
|
||||
DOBOTAI_RUNTIME_UID=996
|
||||
DOBOTAI_RUNTIME_GID=988
|
||||
DOBOTAI_ENV_FILE=/etc/dobotai-test/dobotai.env
|
||||
DOBOTAI_SECRET_DIR_HOST=/var/lib/dobotai-test/secrets
|
||||
@@ -0,0 +1,5 @@
|
||||
\pset pager off
|
||||
SELECT table_schema || '.' || table_name AS t
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
ORDER BY 1;
|
||||
@@ -0,0 +1,8 @@
|
||||
\pset pager off
|
||||
\echo == columns ==
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'channel_actors'
|
||||
ORDER BY ordinal_position;
|
||||
\echo == rows ==
|
||||
SELECT * FROM channel_actors ORDER BY 1 LIMIT 6;
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prod-server switch to the combined release:
|
||||
# reasoning-body boundary refresh + offset-0 body boundary + card structural diagnostics
|
||||
# + wide-screen (autoLayout) card payload
|
||||
# RUN ON THE PRODUCTION HOST (120.76.218.137) as root. Do not wrap in ssh.
|
||||
#
|
||||
# Usage: bash prod_switch_reasoning_widescreen.sh <new-image-ref>
|
||||
#
|
||||
# Safety: requires the new image locally; tags the running image as a rollback tag;
|
||||
# backs up runtime.env with its original mode; refuses unless DOBOTAI_IMAGE appears
|
||||
# exactly once before and once after the edit and the line count is unchanged;
|
||||
# refuses unless the new binary carries every release literal.
|
||||
# Prints only image refs / variable names / literal names - never env values.
|
||||
set -euo pipefail
|
||||
|
||||
NEW_IMAGE="${1:-}"
|
||||
[ -n "$NEW_IMAGE" ] || { echo "ABORT: pass the new image ref as \$1" >&2; exit 1; }
|
||||
|
||||
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
|
||||
BACKUP_DIR="/opt/dobotai/container/backups"
|
||||
COMPOSE_SERVICE="dobotai-dingtalk"
|
||||
RELEASE_SLUG="reasoning-widescreen"
|
||||
|
||||
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
|
||||
OLD_SHA="$(echo "$OLD_IMAGE" | sed 's/.*://')"
|
||||
ROLLBACK_TAG="dobotai-dingtalk:rollback-${OLD_SHA}-before-${RELEASE_SLUG}"
|
||||
STAMP="$(date +%Y%m%dT%H%M%S)"
|
||||
BACKUP="$BACKUP_DIR/runtime.env.before-${RELEASE_SLUG}.${STAMP}"
|
||||
|
||||
echo "== preflight =="
|
||||
docker image inspect "$NEW_IMAGE" >/dev/null || { echo "ABORT: $NEW_IMAGE not present locally (load it first)" >&2; exit 1; }
|
||||
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
|
||||
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
|
||||
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
|
||||
for lit in sys_full_json_obj autoLayout body_prefix_runes banner_pos PartialFeed; do
|
||||
if docker run --rm --entrypoint sh "$NEW_IMAGE" -c "grep -aqF '$lit' /usr/local/bin/dobotai"; then
|
||||
echo "literal $lit: PRESENT"
|
||||
else
|
||||
echo "ABORT: $NEW_IMAGE lacks literal '$lit' (wrong artifact)" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "old_image=$OLD_IMAGE"
|
||||
echo "new_image=$NEW_IMAGE"
|
||||
echo "rollback_tag=$ROLLBACK_TAG"
|
||||
|
||||
echo "== tag rollback target =="
|
||||
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
|
||||
echo "rollback tag already exists, keeping it (no overwrite)"
|
||||
else
|
||||
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
|
||||
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
|
||||
fi
|
||||
|
||||
echo "== backup runtime.env =="
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
ORIG_MODE="$(stat -c '%a' "$RUNTIME_ENV")"
|
||||
install -m "$ORIG_MODE" "$RUNTIME_ENV" "$BACKUP"
|
||||
[ "$ORIG_MODE" = "600" ] || chmod 0600 "$RUNTIME_ENV"
|
||||
echo "backup=$BACKUP mode=$ORIG_MODE"
|
||||
|
||||
echo "== rewrite DOBOTAI_IMAGE =="
|
||||
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
|
||||
chmod "$ORIG_MODE" "$tmp"
|
||||
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
|
||||
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
|
||||
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
|
||||
rm -f "$tmp"
|
||||
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv "$tmp" "$RUNTIME_ENV"
|
||||
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
|
||||
|
||||
echo "== recreate container =="
|
||||
cd /opt/dobotai/container
|
||||
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate "$COMPOSE_SERVICE"
|
||||
|
||||
echo "== readback =="
|
||||
sleep 4
|
||||
curl -fsS -m 5 http://127.0.0.1:8090/healthz; echo
|
||||
docker inspect "$COMPOSE_SERVICE" --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
|
||||
for lit in sys_full_json_obj autoLayout body_prefix_runes banner_pos PartialFeed; do
|
||||
if docker exec "$COMPOSE_SERVICE" grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
|
||||
echo "running literal $lit: PRESENT"
|
||||
else
|
||||
echo "running literal $lit: ABSENT"
|
||||
fi
|
||||
done
|
||||
echo "== log scan (since 90s) =="
|
||||
docker logs "$COMPOSE_SERVICE" --since 90s 2>&1 | grep -icE 'panic|fatal|level=error| ERROR ' || echo "0 error/panic lines"
|
||||
docker logs "$COMPOSE_SERVICE" --since 90s 2>&1 | tail -6
|
||||
|
||||
echo "== rollback (one command, run on this host) =="
|
||||
echo "sed -i 's|^DOBOTAI_IMAGE=.*|DOBOTAI_IMAGE=$OLD_IMAGE|' $RUNTIME_ENV && cd /opt/dobotai/container && docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate $COMPOSE_SERVICE"
|
||||
echo "or restore: install -m $ORIG_MODE $BACKUP $RUNTIME_ENV (then the compose line above)"
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test-server switch to the build that delivers card images as DingTalk media ids.
|
||||
# Rollback tag + 0600 env backup first; refuses unless the DOBOTAI_IMAGE
|
||||
# substitution is exactly 1 before and exactly 1 after. Read-only readback at
|
||||
# the end. No secrets are printed.
|
||||
set -euo pipefail
|
||||
|
||||
NEW_IMAGE="${NEW_IMAGE:-dobotai-dingtalk:local-dfc7c8dba956}"
|
||||
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
|
||||
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
|
||||
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-card-image-media"
|
||||
STAMP="$(date +%Y%m%dT%H%M%S)"
|
||||
BACKUP_DIR="/opt/dobotai/container/backups"
|
||||
BACKUP="$BACKUP_DIR/runtime.env.before-card-image-media.$STAMP"
|
||||
|
||||
echo "== preflight =="
|
||||
docker image inspect "$NEW_IMAGE" >/dev/null
|
||||
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
|
||||
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
|
||||
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
|
||||
echo "old_image=$OLD_IMAGE"
|
||||
echo "new_image=$NEW_IMAGE"
|
||||
echo "rollback_tag=$ROLLBACK_TAG"
|
||||
|
||||
echo "== tag rollback target =="
|
||||
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
|
||||
echo "rollback tag already exists, keeping it (no overwrite)"
|
||||
else
|
||||
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
|
||||
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
|
||||
fi
|
||||
|
||||
echo "== backup runtime.env =="
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
|
||||
echo "backup=$BACKUP"
|
||||
|
||||
echo "== rewrite DOBOTAI_IMAGE =="
|
||||
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
|
||||
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
|
||||
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
|
||||
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
|
||||
rm -f "$tmp"
|
||||
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 0644 "$tmp"
|
||||
mv "$tmp" "$RUNTIME_ENV"
|
||||
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
|
||||
|
||||
echo "== recreate container =="
|
||||
cd /opt/dobotai/container
|
||||
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
|
||||
|
||||
echo "== readback =="
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:8090/healthz; echo
|
||||
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
|
||||
for lit in oapi.dingtalk.com/media/upload DINGTALK_CARD_IMAGE_MEDIA; do
|
||||
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
|
||||
echo "literal $lit: PRESENT"
|
||||
else
|
||||
echo "literal $lit: ABSENT"
|
||||
fi
|
||||
done
|
||||
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -6
|
||||
echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)"
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test-server switch to the wide-screen (autoLayout) card image.
|
||||
# Rollback tag + 0600 env backup first; refuses unless the DOBOTAI_IMAGE
|
||||
# substitution is exactly 1 before and exactly 1 after. Read-only readback
|
||||
# at the end. No secrets are printed.
|
||||
set -euo pipefail
|
||||
|
||||
NEW_IMAGE="dobotai-dingtalk:local-cardfix-ba090d17"
|
||||
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
|
||||
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
|
||||
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-card-widescreen"
|
||||
STAMP="$(date +%Y%m%dT%H%M%S)"
|
||||
BACKUP_DIR="/opt/dobotai/container/backups"
|
||||
BACKUP="$BACKUP_DIR/runtime.env.before-card-widescreen.$STAMP"
|
||||
|
||||
echo "== preflight =="
|
||||
docker image inspect "$NEW_IMAGE" >/dev/null
|
||||
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
|
||||
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
|
||||
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
|
||||
echo "old_image=$OLD_IMAGE"
|
||||
echo "new_image=$NEW_IMAGE"
|
||||
echo "rollback_tag=$ROLLBACK_TAG"
|
||||
|
||||
echo "== tag rollback target =="
|
||||
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
|
||||
echo "rollback tag already exists, keeping it (no overwrite)"
|
||||
else
|
||||
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
|
||||
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
|
||||
fi
|
||||
|
||||
echo "== backup runtime.env =="
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
|
||||
echo "backup=$BACKUP"
|
||||
|
||||
echo "== rewrite DOBOTAI_IMAGE =="
|
||||
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
|
||||
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
|
||||
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
|
||||
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
|
||||
rm -f "$tmp"
|
||||
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 0644 "$tmp"
|
||||
mv "$tmp" "$RUNTIME_ENV"
|
||||
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
|
||||
|
||||
echo "== recreate container =="
|
||||
cd /opt/dobotai/container
|
||||
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
|
||||
|
||||
echo "== readback =="
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:8090/healthz; echo
|
||||
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
|
||||
for lit in sys_full_json_obj autoLayout _CARD_DEBUG_TOOL_ENTRY; do
|
||||
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
|
||||
echo "literal $lit: PRESENT"
|
||||
else
|
||||
echo "literal $lit: ABSENT"
|
||||
fi
|
||||
done
|
||||
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -5
|
||||
echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)"
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generic test-server image switch: rollback tag first, 0600 runtime.env backup,
|
||||
# post-edit verification (exactly 1 DOBOTAI_IMAGE line, unchanged line count),
|
||||
# container recreate, then read-only readback incl. binary literal presence.
|
||||
# Usage: bash test_switch_image.sh <image:tag> <label>
|
||||
set -euo pipefail
|
||||
NEW_IMAGE="${1:?usage: test_switch_image.sh <image:tag> <label>}"
|
||||
LABEL="${2:?usage: test_switch_image.sh <image:tag> <label>}"
|
||||
RUNTIME_ENV=/opt/dobotai/container/runtime.env
|
||||
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
|
||||
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-$LABEL"
|
||||
STAMP="$(date +%Y%m%dT%H%M%S)"
|
||||
BACKUP_DIR=/opt/dobotai/container/backups
|
||||
BACKUP="$BACKUP_DIR/runtime.env.before-$LABEL.$STAMP"
|
||||
|
||||
echo "== preflight =="
|
||||
docker image inspect "$NEW_IMAGE" >/dev/null
|
||||
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
|
||||
[ "$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")" = "1" ] || { echo "ABORT: DOBOTAI_IMAGE line count != 1" >&2; exit 1; }
|
||||
echo "old_image=$OLD_IMAGE"
|
||||
echo "new_image=$NEW_IMAGE"
|
||||
echo "rollback_tag=$ROLLBACK_TAG"
|
||||
|
||||
echo "== tag rollback target =="
|
||||
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
|
||||
echo "rollback tag already exists, keeping it (no overwrite)"
|
||||
else
|
||||
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
|
||||
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
|
||||
fi
|
||||
|
||||
echo "== backup runtime.env =="
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
|
||||
echo "backup=$BACKUP"
|
||||
|
||||
echo "== rewrite DOBOTAI_IMAGE =="
|
||||
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
|
||||
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
|
||||
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
|
||||
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
|
||||
rm -f "$tmp"
|
||||
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 0644 "$tmp"
|
||||
mv "$tmp" "$RUNTIME_ENV"
|
||||
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
|
||||
|
||||
echo "== recreate container =="
|
||||
cd /opt/dobotai/container
|
||||
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
|
||||
|
||||
echo "== readback =="
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:8090/healthz; echo
|
||||
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
|
||||
for lit in PartialFeed autoLayout 智能推理模式; do
|
||||
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
|
||||
echo "literal $lit: PRESENT"
|
||||
else
|
||||
echo "literal $lit: ABSENT"
|
||||
fi
|
||||
done
|
||||
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -8
|
||||
echo "rollback: set DOBOTAI_IMAGE=$OLD_IMAGE in $RUNTIME_ENV (backup $BACKUP) then: docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk"
|
||||
+16
-16
@@ -1,21 +1,21 @@
|
||||
# WeKnora API Capability Gaps
|
||||
# WeKnora API 能力缺口
|
||||
|
||||
These gaps must not be solved with private WeKnora source or database changes.
|
||||
以下能力缺口不得通过修改 WeKnora 私有源码或数据库解决。
|
||||
|
||||
| Capability | Current external handling |
|
||||
| 能力 | 当前外部处理方式 |
|
||||
|---|---|
|
||||
| Like/dislike and unresolved reason | DobotAI PostgreSQL and API |
|
||||
| Formal covered/not-covered decision | DobotAI evaluation rules using references, scores, answer status, and offline calibration |
|
||||
| Support case, owner, SLA, and status | DobotAI workflow |
|
||||
| Human reply to original channel | DobotAI channel adapter; not written to WeKnora history by default |
|
||||
| Stable external turn correlation | DobotAI mapping of external message, turn, WeKnora session/request, and gateway trace |
|
||||
| Complete multi-call token usage | Model gateway/Langfuse; unknown remains null/unknown |
|
||||
| Strict image failure semantics | Adapter contract test; reject image traffic on failure |
|
||||
| Arbitrary human assistant-message append | Keep in DobotAI and submit an upstream API request only if context injection becomes necessary |
|
||||
| 点赞、点踩和未解决原因 | DobotAI PostgreSQL 与 API |
|
||||
| 正式的已覆盖/未覆盖判定 | DobotAI 基于引用、分数、回答状态和离线校准的评估规则 |
|
||||
| Support Case、负责人、SLA 和状态 | DobotAI 工作流 |
|
||||
| 向原始渠道发送人工回复 | DobotAI 渠道适配器;默认不写回 WeKnora 历史 |
|
||||
| 稳定的外部 Turn 关联 | DobotAI 映射外部消息、Turn、WeKnora 会话/请求和网关追踪 |
|
||||
| 完整的多调用 Token 用量 | 模型网关/Langfuse;未知值保持 `null/unknown` |
|
||||
| 严格的图片失败语义 | Adapter 契约测试;图片流量失败时拒绝处理 |
|
||||
| 任意人工助手消息追加 | 保留在 DobotAI;仅当确有上下文注入需求时向上游提交 API 请求 |
|
||||
|
||||
Allowed responses to a gap:
|
||||
面对能力缺口时,只允许:
|
||||
|
||||
1. adjust the product design;
|
||||
2. implement it outside WeKnora;
|
||||
3. open an upstream issue;
|
||||
4. submit a general upstream pull request.
|
||||
1. 调整产品设计;
|
||||
2. 在 WeKnora 外部实现;
|
||||
3. 提交上游 Issue;
|
||||
4. 提交通用的上游 Pull Request。
|
||||
+74
-49
@@ -1,73 +1,98 @@
|
||||
# DobotAI External Architecture
|
||||
# DobotAI 外部集成架构
|
||||
|
||||
## Boundary
|
||||
## 系统边界
|
||||
|
||||
```text
|
||||
DingTalk / WeCom / Web / Software
|
||||
-> DobotAI ingress and workflows
|
||||
钉钉 / 企业微信 / Web / 业务软件
|
||||
-> DobotAI 接入层与工作流
|
||||
-> WeKnora Adapter
|
||||
-> official WeKnora REST API
|
||||
-> official WeKnora RAG and Agent
|
||||
-> WeKnora 官方 REST API
|
||||
-> WeKnora 官方 RAG 与 Agent
|
||||
```
|
||||
|
||||
DobotAI PostgreSQL owns channel identities, external messages, session mappings, turns, feedback, coverage decisions, support cases, human replies, FAQ workflow state, and content-source mappings. WeKnora owns only its official data model.
|
||||
DobotAI PostgreSQL 负责渠道身份、外部消息、会话映射、Turn、反馈、覆盖判定、Support Case、人工回复、FAQ 工作流状态和内容源映射。WeKnora 仅负责其官方数据模型。
|
||||
|
||||
## Session policy
|
||||
## 会话策略
|
||||
|
||||
Default policy:
|
||||
默认策略:
|
||||
|
||||
- direct: one long-lived session per tenant + channel + Agent + external user;
|
||||
- group: one shared session per tenant + channel + Agent + external group;
|
||||
- group messages are admitted only when the bot is mentioned or an explicit command triggers it;
|
||||
- sender display name is prefixed to the query so a shared Agent can distinguish speakers;
|
||||
- future controls must include idle rotation and an explicit new-conversation command;
|
||||
- confidential channels may switch to `per_user`, yielding group + user sessions.
|
||||
- 私聊:每个 `tenant + channel + Agent + 外部用户` 使用一个长期会话;
|
||||
- 群聊:每个 `tenant + channel + Agent + 外部群` 使用一个共享会话;
|
||||
- 群消息仅在机器人被提及或命中显式命令时进入处理;
|
||||
- 将发送人显示名添加到问题前缀,使共享 Agent 能区分发言者;
|
||||
- 后续控制项必须包含空闲轮换和显式新建会话命令;
|
||||
- 保密渠道可切换为 `per_user`,形成群组加用户维度的会话。
|
||||
|
||||
Shared group sessions are the common collaboration-bot pattern because all members see the same conversation. Group+user sessions provide stronger context isolation but make replies less coherent for collaborative group discussions.
|
||||
共享群会话符合协作机器人场景:所有成员能看到相同上下文。群组加用户会话能增强上下文隔离,但会削弱协作讨论中的回复连贯性。
|
||||
|
||||
## Agent binding
|
||||
快速问答与智能推理必须复用同一个 WeKnora Session;`/think` 仅改变当前 Turn 的执行 Agent,不得重建会话映射。
|
||||
|
||||
Agents are configuration-owned. Channel callers cannot choose an arbitrary `agent_id`.
|
||||
## Agent 绑定
|
||||
|
||||
Resolution order:
|
||||
Agent 由配置控制,渠道调用方不得任意指定 `agent_id`。
|
||||
|
||||
1. exact `<channel>:<external_chat_id>` binding;
|
||||
2. channel wildcard `<channel>:*`;
|
||||
3. fail closed when no binding exists.
|
||||
解析顺序:
|
||||
|
||||
## Image failure policy
|
||||
1. 精确 `<channel>:<external_chat_id>` 绑定;
|
||||
2. 渠道通配 `<channel>:*`;
|
||||
3. 没有任何绑定时失败关闭。
|
||||
|
||||
Before enabling image traffic, the WeKnora Adapter must run a contract test against the selected official release and configured vision model.
|
||||
## 首位管理员终端绑定
|
||||
|
||||
If any image path fails:
|
||||
首位管理员绑定由部署服务器终端发起,而非网页发码。钉钉 Stream 机器人已正常运行且当前不存在有效管理员时,操作人员执行:
|
||||
|
||||
1. do not resend the question without the image;
|
||||
2. do not return partial model output as success;
|
||||
3. return a typed `vision_unavailable` failure to the channel adapter;
|
||||
4. DingTalk tells the user that image recognition is temporarily unavailable and asks them to retry or send text;
|
||||
5. emit an operational alert to verify Agent image upload settings, selected vision model, provider connectivity, and model capability;
|
||||
6. keep image traffic disabled until the contract test passes.
|
||||
```bash
|
||||
dobotai bootstrap-admin
|
||||
```
|
||||
|
||||
No WeKnora source patch is allowed as a workaround.
|
||||
命令只连接 DobotAI PostgreSQL,使用加密随机数创建一个有效期10分钟、最多5次错误尝试的六码挑战,并在终端持续等待。候选用户必须私聊机器人发送:
|
||||
|
||||
## Service ownership
|
||||
```text
|
||||
/setting <六码绑定码>
|
||||
```
|
||||
|
||||
- `internal/weknora`: official REST/SSE compatibility only.
|
||||
- `internal/qa`: business orchestration and terminal-state ownership.
|
||||
- `internal/session`: external-to-WeKnora session policy.
|
||||
- `internal/store/postgres`: DobotAI business persistence only.
|
||||
- `internal/channels/dingtalk`: DingTalk Stream ingress (official SDK), text-only robot callbacks, fail-closed session webhook replies (`https`, `*.dingtalk.com`, no redirects). Wired in `cmd/dobotai`; enabled only when `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, and `DOBOTAI_TENANT_KEY` are all set. Group messages require an @-mention; direct chats map to one long-lived session per user, groups to one shared session per group. Images, OpenAPI fallback, and card replies are not implemented yet.
|
||||
- future `channels/*`: platform callbacks and outbound replies.
|
||||
- future `content-sync/*`: DingTalk KB, NAS, and CMS synchronization.
|
||||
- model gateway: token accounting, routing, quotas, circuit breaking, and provider observability.
|
||||
- Redis: queue, retry, short leases, and temporary stream state only.
|
||||
验证码匹配后,渠道侧只记录候选身份;终端轮询并事务性将该候选用户写为 `super_admin/active`,然后显示成功并退出。已有有效管理员时该命令拒绝执行,避免任意用户通过终端绑定流程被提权。DobotAI 不提供网页管理面,钉钉凭据和运行配置由部署环境文件管理。
|
||||
|
||||
## Upgrade contract
|
||||
## 图片失败策略
|
||||
|
||||
For every official WeKnora upgrade:
|
||||
启用图片流量前,WeKnora Adapter 必须针对选定的官方版本和视觉模型执行契约测试。
|
||||
|
||||
1. update the pinned official release/tag;
|
||||
2. run official database migrations against WeKnora only;
|
||||
3. run DobotAI Adapter contract tests;
|
||||
4. verify text Agent Chat, references, terminal errors, Stop, images, ingestion, and FAQ;
|
||||
5. promote the official image only after the contract suite passes.
|
||||
任一图片路径失败时:
|
||||
|
||||
1. 不得去掉图片后重发同一问题;
|
||||
2. 不得将部分模型输出当作成功结果;
|
||||
3. 向渠道适配器返回类型化 `vision_unavailable` 错误;
|
||||
4. 钉钉提示用户图片识别暂不可用,并请其重试或发送文本;
|
||||
5. 发送运维告警,核查 Agent 图片上传设置、视觉模型、供应商连通性和模型能力;
|
||||
6. 契约测试通过前保持图片流量禁用。
|
||||
|
||||
不得通过修改 WeKnora 源码规避上述失败。
|
||||
|
||||
对钉钉 richText 中的图片,DobotAI 仅通过官方接口下载,验证来源、大小、媒体类型和文件签名后,才转换为 WeKnora 通用多模态请求。对回答中的 `resource://` 图片,仅可在鉴权获取、内容哈希转存私有 OSS 后外发;不得把任意外部 URL 当作可信图片。
|
||||
|
||||
## 服务职责
|
||||
|
||||
- `internal/weknora`:仅处理 WeKnora 官方 REST/SSE 兼容;解析普通引用和智能推理工具结果引用;
|
||||
- `internal/qa`:业务编排与 Turn 终态归属;
|
||||
- `internal/session`:外部渠道到 WeKnora 的会话策略;
|
||||
- `internal/store/postgres`:仅保存 DobotAI 业务数据;
|
||||
- `internal/coverage`:已覆盖/未覆盖/纯视觉识别判定、持久化 Support Case、有限重试和稳定错误码;通知失败不得改变成功问答的结果;
|
||||
- `internal/channels/dingtalk`:钉钉 Stream 接入、richText 与多图片归一化、AI 流式卡片、命令路由、反馈与通知。仅在 `DINGTALK_CLIENT_ID`、`DINGTALK_CLIENT_SECRET` 和 `DOBOTAI_TENANT_KEY` 均已设置时启用。群聊必须 `@` 机器人;私聊按用户映射长期会话,群聊默认映射共享会话;`sessionWebhook` 必须是 `https`、`*.dingtalk.com`、443端口且不允许重定向;
|
||||
- `internal/reasoningmode`:`/think` 单次与持续推理、轮数、空闲到期、提示和首次使用引导;
|
||||
- `internal/outboundimage`:WeKnora `resource://` 图片的受控鉴权下载和私有 OSS 外发;
|
||||
- `future channels/*`:其他平台的回调与外发回复;
|
||||
- `future content-sync/*`:钉钉知识库、NAS 与 CMS 同步;
|
||||
- 模型网关:Token 统计、路由、配额、熔断和供应商可观测性;
|
||||
- Redis:仅用于队列、重试、短期租约和临时流状态。
|
||||
|
||||
## 升级契约
|
||||
|
||||
每次升级官方 WeKnora 时:
|
||||
|
||||
1. 更新固定的官方 Release/Tag;
|
||||
2. 仅对 WeKnora 执行官方数据库迁移;
|
||||
3. 执行 DobotAI Adapter 契约测试;
|
||||
4. 验证文本 Agent Chat、引用、终止错误、停止、图片、导入和 FAQ;
|
||||
5. 仅在契约测试通过后推广官方镜像。
|
||||
|
||||
WeKnora 升级、DobotAI 迁移、钉钉单消费者切换和生产部署是独立变更边界,必须各自备份、演练和验收。
|
||||
@@ -3,6 +3,7 @@ module gitea.zheromiku.top/Weknora/dobotai-platform
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
)
|
||||
@@ -15,4 +16,5 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.4.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1 h1:vtiFd0hhPAbyYJjztl0wYUq/PqEGkIlDmVuTIy6zw8Y=
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -26,6 +28,8 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY=
|
||||
golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
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 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 "请先安装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
|
||||
}
|
||||
install_docker_if_missing
|
||||
docker compose version >/dev/null
|
||||
|
||||
commit_sha="$(git rev-parse HEAD)"
|
||||
commit_short="$(git rev-parse --short=12 HEAD)"
|
||||
image="dobotai-dingtalk:local-${commit_short}"
|
||||
echo "[1/2] 构建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=$(date -u +%Y-%m-%dT%H:%M:%SZ)" --label "org.opencontainers.image.revision=${commit_sha}" --tag "$image" "$repo_root"
|
||||
|
||||
echo "[2/2] 安装应用容器资产;阿里云RDS将在配置后检查或初始化"
|
||||
export DOBOTAI_IMAGE="$image"
|
||||
export DOBOTAI_SOURCE_ROOT="$repo_root"
|
||||
exec bash "$repo_root/deploy/dobotai-dingtalk/install-docker.sh"
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -17,8 +18,27 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
|
||||
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
|
||||
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
|
||||
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
|
||||
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
|
||||
|
||||
// cardWideScreenJSONKey is DingTalk's reserved system field inside
|
||||
// cardData.cardParamMap. Template variables whose value is a complex type
|
||||
// (object / array of objects) must be passed through this field as a JSON
|
||||
// string; plain string/number/boolean variables go in cardParamMap
|
||||
// directly. Putting a complex value anywhere else (for example as a
|
||||
// sibling of cardParamMap under cardData) leaves the template variable
|
||||
// unassigned and silently falls back to the default layout.
|
||||
cardWideScreenJSONKey = "sys_full_json_obj"
|
||||
|
||||
// cardWideScreenJSON configures the AI card's root "config": PC clients
|
||||
// render the card wide (auto layout) when autoLayout is true. The card
|
||||
// template must declare an object variable "config" with a boolean child
|
||||
// "autoLayout" (card builder > 数据源 > 编辑变量) and the value must be
|
||||
// passed when the card is created - setting it statically inside the
|
||||
// template does not take effect. Mobile clients keep their own adaptive
|
||||
// layout, so this only widens the card on desktop.
|
||||
cardWideScreenJSON = `{"config":{"autoLayout":true}}`
|
||||
)
|
||||
|
||||
// CardRequest is the input for sending a final AI card.
|
||||
@@ -39,26 +59,38 @@ type CardSender struct {
|
||||
streamURL string
|
||||
apiClient *http.Client
|
||||
allowTestURL bool
|
||||
debugUserID string
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenTill time.Time
|
||||
}
|
||||
|
||||
// CardSenderOption configures optional CardSender behavior.
|
||||
type CardSenderOption func(*CardSender)
|
||||
|
||||
// WithCardDebugUserID enables DingTalk's official card debug entry only for
|
||||
// the matching user through per-user privateData. A blank value keeps it off.
|
||||
func WithCardDebugUserID(userID string) CardSenderOption {
|
||||
return func(sender *CardSender) {
|
||||
sender.debugUserID = strings.TrimSpace(userID)
|
||||
}
|
||||
}
|
||||
|
||||
// NewCardSender creates a CardSender. All three config values are required
|
||||
// after trimming whitespace. A nil http.Client installs a hardened default
|
||||
// (no redirects, Proxy=nil, 15s timeout).
|
||||
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client) (*CardSender, error) {
|
||||
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false)
|
||||
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, options ...CardSenderOption) (*CardSender, error) {
|
||||
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false, options...)
|
||||
}
|
||||
|
||||
// newCardSenderForTest is used by tests to inject httptest URLs.
|
||||
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string) (*CardSender, error) {
|
||||
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string, options ...CardSenderOption) (*CardSender, error) {
|
||||
streamURL := strings.TrimSuffix(createURL, "/create") + "/stream"
|
||||
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true)
|
||||
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true, options...)
|
||||
}
|
||||
|
||||
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool) (*CardSender, error) {
|
||||
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool, options ...CardSenderOption) (*CardSender, error) {
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
clientSecret = strings.TrimSpace(clientSecret)
|
||||
cardTemplateID = strings.TrimSpace(cardTemplateID)
|
||||
@@ -91,7 +123,7 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
|
||||
apiClient.Transport = transport
|
||||
}
|
||||
|
||||
return &CardSender{
|
||||
sender := &CardSender{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
cardTemplateID: cardTemplateID,
|
||||
@@ -100,7 +132,13 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
|
||||
streamURL: streamURL,
|
||||
apiClient: &apiClient,
|
||||
allowTestURL: allowTest,
|
||||
}, nil
|
||||
}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
option(sender)
|
||||
}
|
||||
}
|
||||
return sender, nil
|
||||
}
|
||||
|
||||
// SendFinalCard sends the AI card to DingTalk and returns the generated outTrackID.
|
||||
@@ -134,9 +172,10 @@ func (s *CardSender) StartCard(ctx context.Context, req CardRequest) (string, er
|
||||
return "", errors.New("dingtalk card: content exceeds 64 KiB")
|
||||
}
|
||||
|
||||
// Validate identity fields
|
||||
// Validate identity fields. ExternalUserID is also available for group
|
||||
// askers and is used only for per-user card private data.
|
||||
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
|
||||
if req.ConversationType == core.ConversationDirect {
|
||||
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
|
||||
if req.ExternalUserID == "" {
|
||||
return "", errors.New("dingtalk card: ExternalUserID is required for direct conversations")
|
||||
}
|
||||
@@ -186,6 +225,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 {
|
||||
@@ -207,6 +247,7 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
|
||||
"feedbackReasonSelected": "",
|
||||
"showFeedbackReasons": "false",
|
||||
"feedbackMessage": "",
|
||||
cardWideScreenJSONKey: cardWideScreenJSON,
|
||||
},
|
||||
},
|
||||
"userIdType": 1,
|
||||
@@ -232,6 +273,15 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
|
||||
"extension": map[string]any{},
|
||||
}
|
||||
}
|
||||
if s.debugUserID != "" && req.ExternalUserID == s.debugUserID {
|
||||
body["privateData"] = map[string]any{
|
||||
req.ExternalUserID: map[string]any{
|
||||
"cardParamMap": map[string]any{
|
||||
cardDebugPrivateKey: "show",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -276,9 +326,25 @@ func (s *CardSender) postCard(ctx context.Context, body map[string]any, token st
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Discard successful response body
|
||||
if len(payload) > 0 {
|
||||
// Body is already bounded by postJSON→readBoundedAPIResponse; just discard
|
||||
return validateDingTalkBusinessResponse(payload)
|
||||
}
|
||||
|
||||
func validateDingTalkBusinessResponse(payload []byte) error {
|
||||
if len(bytes.TrimSpace(payload)) == 0 {
|
||||
return nil
|
||||
}
|
||||
var response struct {
|
||||
Success *bool `json:"success"`
|
||||
ErrCode any `json:"errcode"`
|
||||
}
|
||||
if json.Unmarshal(payload, &response) != nil {
|
||||
return nil
|
||||
}
|
||||
if response.Success != nil && !*response.Success {
|
||||
return errors.New("dingtalk card: API business failure")
|
||||
}
|
||||
if response.ErrCode != nil && fmt.Sprint(response.ErrCode) != "0" {
|
||||
return errors.New("dingtalk card: API business failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -297,8 +363,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,119 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
)
|
||||
|
||||
const reasoningBannerMarker = "【智能推理模式】"
|
||||
|
||||
type cardContentShape struct {
|
||||
bytes int
|
||||
lines int
|
||||
pipeLines int
|
||||
leadingPipeLines int
|
||||
delimiterLines int
|
||||
confirmedTable bool
|
||||
confirmedTablePos int
|
||||
// Structural split state: where the answer body starts inside this exact
|
||||
// payload. bodyPrefixRunes > 0 means the update still carries narration in
|
||||
// front of the body (the reasoning text the card is supposed to hide).
|
||||
bodyOK bool
|
||||
bodyLine int
|
||||
bodyPrefixRunes int
|
||||
bannerPos int
|
||||
firstHeadingLine int
|
||||
imageRefs int
|
||||
externalImageRefs int
|
||||
mediaImageRefs int
|
||||
}
|
||||
|
||||
func inspectCardContent(content string) cardContentShape {
|
||||
shape := cardContentShape{
|
||||
bytes: len(content),
|
||||
confirmedTablePos: -1,
|
||||
bodyLine: -1,
|
||||
firstHeadingLine: -1,
|
||||
bannerPos: -1,
|
||||
}
|
||||
if content == "" {
|
||||
return shape
|
||||
}
|
||||
shape.lines = strings.Count(content, "\n") + 1
|
||||
shape.imageRefs, shape.externalImageRefs, shape.mediaImageRefs = cardImageRefCounts(content)
|
||||
|
||||
shape.bannerPos = strings.Index(content, reasoningBannerMarker)
|
||||
// The streaming path decorates every frame with the reasoning banner, so the
|
||||
// prefix in front of the body has to be measured after that banner: only
|
||||
// what remains is narration the card is meant to hide.
|
||||
bannerSkip := 0
|
||||
if shape.bannerPos == 0 {
|
||||
if idx := strings.Index(content, "\n"); idx >= 0 {
|
||||
bannerSkip = idx + 1
|
||||
for bannerSkip < len(content) && content[bannerSkip] == '\n' {
|
||||
bannerSkip++
|
||||
}
|
||||
}
|
||||
}
|
||||
if offset, ok := reasoningmode.BodyStartOffset(content); ok {
|
||||
shape.bodyOK = true
|
||||
shape.bodyLine = strings.Count(content[:offset], "\n")
|
||||
prefix := content[:offset]
|
||||
if bannerSkip > 0 && bannerSkip <= len(prefix) {
|
||||
prefix = prefix[bannerSkip:]
|
||||
}
|
||||
shape.bodyPrefixRunes = len([]rune(prefix))
|
||||
}
|
||||
|
||||
lines := splitMarkdownStreamLines(content)
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line.text)
|
||||
if shape.firstHeadingLine < 0 && !line.inFence &&
|
||||
strings.HasPrefix(trimmed, "#") && strings.TrimLeft(trimmed, "#") != "" {
|
||||
shape.firstHeadingLine = i
|
||||
}
|
||||
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 body_ok=%t body_line=%d body_prefix_runes=%d banner_pos=%d first_heading_line=%d image_refs=%d external_image_refs=%d media_image_refs=%d",
|
||||
isFinalize,
|
||||
isError,
|
||||
shape.bytes,
|
||||
shape.lines,
|
||||
shape.pipeLines,
|
||||
shape.leadingPipeLines,
|
||||
shape.delimiterLines,
|
||||
shape.confirmedTable,
|
||||
shape.confirmedTablePos,
|
||||
shape.bodyOK,
|
||||
shape.bodyLine,
|
||||
shape.bodyPrefixRunes,
|
||||
shape.bannerPos,
|
||||
shape.firstHeadingLine,
|
||||
shape.imageRefs,
|
||||
shape.externalImageRefs,
|
||||
shape.mediaImageRefs,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DingTalk AI cards only render images that live in DingTalk's own media store:
|
||||
// an external HTTPS image URL shows up in a normal markdown message but is
|
||||
// dropped inside a streaming/short-card body (verified on both desktop and
|
||||
// mobile, 2026-09-17). The card content therefore rewrites our own published
|
||||
// image URLs into DingTalk media_id values obtained through the legacy
|
||||
// oapi media upload endpoint, which both clients render.
|
||||
const (
|
||||
defaultCardImageTokenURL = "https://oapi.dingtalk.com/gettoken"
|
||||
defaultCardImageUploadURL = "https://oapi.dingtalk.com/media/upload"
|
||||
defaultCardImageMediaTTL = 12 * time.Hour
|
||||
defaultCardImageWaitTimeout = 5 * time.Second
|
||||
defaultCardImageFetchTimeout = 15 * time.Second
|
||||
defaultCardImageUploadTimeout = 20 * time.Second
|
||||
defaultCardImageMaxBytes = 8 << 20
|
||||
cardImageTokenRefreshMargin = 5 * time.Minute
|
||||
maxConcurrentCardImageUploads = 3
|
||||
)
|
||||
|
||||
// cardImageStableIDRE matches the deterministic image id produced by
|
||||
// outboundimage.StableHashPublisher (UUIDv5 shaped, lowercase hex).
|
||||
var cardImageStableIDRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
|
||||
|
||||
// cardMarkdownImageRE captures the URL of a complete markdown image, including
|
||||
// the optional title slot. Incomplete image syntax (still streaming) is not
|
||||
// matched, so it is never rewritten.
|
||||
var cardMarkdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\(([^\s)\r\n]+)(?:\s+(?:"[^"\r\n]*"|'[^'\r\n]*'|\([^)\r\n]*\)))?\)`)
|
||||
|
||||
// CardImageMediaRewriter converts card-body image references into DingTalk
|
||||
// media ids. Implementations must be safe for concurrent use and must never
|
||||
// fail a card update: on any error the original reference is kept.
|
||||
type CardImageMediaRewriter interface {
|
||||
RewriteForCard(ctx context.Context, content string, wait bool) string
|
||||
}
|
||||
|
||||
// CardImageMediaOptions configures the resolver. ClientID/ClientSecret are the
|
||||
// same Robot app credentials used for access tokens, and ImageURLPrefix is the
|
||||
// public prefix of our own published images.
|
||||
type CardImageMediaOptions struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
ImageURLPrefix string
|
||||
// FetchURLPrefix optionally replaces ImageURLPrefix when downloading the
|
||||
// bytes back (for example a loopback address), so the upload never depends
|
||||
// on the container being able to reach its own public host name.
|
||||
FetchURLPrefix string
|
||||
TokenURL string
|
||||
UploadURL string
|
||||
HTTPClient *http.Client
|
||||
TTL time.Duration
|
||||
WaitTimeout time.Duration
|
||||
MaxBytes int64
|
||||
}
|
||||
|
||||
type cardImageMediaEntry struct {
|
||||
mediaID string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type cardImageMediaJob struct {
|
||||
done chan struct{}
|
||||
mediaID string
|
||||
err error
|
||||
}
|
||||
|
||||
type cardImageMediaResolver struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
imageURLPrefix string
|
||||
fetchURLPrefix string
|
||||
tokenURL string
|
||||
uploadURL string
|
||||
client *http.Client
|
||||
ttl time.Duration
|
||||
waitTimeout time.Duration
|
||||
maxBytes int64
|
||||
sem chan struct{}
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
entries map[string]cardImageMediaEntry
|
||||
inflight map[string]*cardImageMediaJob
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
}
|
||||
|
||||
// NewCardImageMediaRewriter builds the resolver. It returns a nil rewriter and
|
||||
// no error when the feature is not configured, which keeps the caller simple.
|
||||
func NewCardImageMediaRewriter(opts CardImageMediaOptions) (CardImageMediaRewriter, error) {
|
||||
clientID := strings.TrimSpace(opts.ClientID)
|
||||
clientSecret := strings.TrimSpace(opts.ClientSecret)
|
||||
prefix := strings.TrimSpace(opts.ImageURLPrefix)
|
||||
if clientID == "" || clientSecret == "" || prefix == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
tokenURL := strings.TrimSpace(opts.TokenURL)
|
||||
if tokenURL == "" {
|
||||
tokenURL = defaultCardImageTokenURL
|
||||
}
|
||||
uploadURL := strings.TrimSpace(opts.UploadURL)
|
||||
if uploadURL == "" {
|
||||
uploadURL = defaultCardImageUploadURL
|
||||
}
|
||||
ttl := opts.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = defaultCardImageMediaTTL
|
||||
}
|
||||
waitTimeout := opts.WaitTimeout
|
||||
if waitTimeout <= 0 {
|
||||
waitTimeout = defaultCardImageWaitTimeout
|
||||
}
|
||||
maxBytes := opts.MaxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defaultCardImageMaxBytes
|
||||
}
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
client = &http.Client{Timeout: defaultCardImageFetchTimeout, Transport: transport}
|
||||
}
|
||||
return &cardImageMediaResolver{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
imageURLPrefix: prefix,
|
||||
fetchURLPrefix: strings.TrimSpace(opts.FetchURLPrefix),
|
||||
tokenURL: tokenURL,
|
||||
uploadURL: uploadURL,
|
||||
client: client,
|
||||
ttl: ttl,
|
||||
waitTimeout: waitTimeout,
|
||||
maxBytes: maxBytes,
|
||||
sem: make(chan struct{}, maxConcurrentCardImageUploads),
|
||||
now: time.Now,
|
||||
entries: make(map[string]cardImageMediaEntry),
|
||||
inflight: make(map[string]*cardImageMediaJob),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RewriteForCard replaces complete markdown image references that point at our
|
||||
// own published images with a DingTalk media id. With wait=false the rewrite
|
||||
// only uses media ids that are already known and kicks off the upload in the
|
||||
// background, so streaming frames never block on DingTalk; with wait=true it
|
||||
// waits (bounded) for in-flight uploads, which is what the final card update
|
||||
// uses.
|
||||
func (r *cardImageMediaResolver) RewriteForCard(ctx context.Context, content string, wait bool) string {
|
||||
if r == nil || strings.TrimSpace(content) == "" {
|
||||
return content
|
||||
}
|
||||
var waitCtx context.Context
|
||||
if wait {
|
||||
bound := r.waitTimeout
|
||||
if bound <= 0 {
|
||||
bound = defaultCardImageWaitTimeout
|
||||
}
|
||||
var cancel context.CancelFunc
|
||||
waitCtx, cancel = context.WithTimeout(context.WithoutCancel(ctx), bound)
|
||||
defer cancel()
|
||||
}
|
||||
rewritten := cardMarkdownImageRE.ReplaceAllStringFunc(content, func(image string) string {
|
||||
match := cardMarkdownImageRE.FindStringSubmatch(image)
|
||||
if len(match) < 2 {
|
||||
return image
|
||||
}
|
||||
rawURL := strings.TrimSpace(match[1])
|
||||
if rawURL == "" || !r.isOwnImageURL(rawURL) {
|
||||
return image
|
||||
}
|
||||
if mediaID, ok := r.lookup(rawURL); ok {
|
||||
return replaceCardImageURL(image, match[1], mediaID)
|
||||
}
|
||||
job := r.startUpload(rawURL)
|
||||
if !wait {
|
||||
return image
|
||||
}
|
||||
select {
|
||||
case <-job.done:
|
||||
case <-waitCtx.Done():
|
||||
}
|
||||
if job.mediaID != "" {
|
||||
return replaceCardImageURL(image, match[1], job.mediaID)
|
||||
}
|
||||
if job.err != nil {
|
||||
log.Printf("DingTalk card image media upload failed, keeping external reference")
|
||||
}
|
||||
return image
|
||||
})
|
||||
return rewritten
|
||||
}
|
||||
|
||||
func replaceCardImageURL(image, rawURL, mediaID string) string {
|
||||
if mediaID == "" || rawURL == "" {
|
||||
return image
|
||||
}
|
||||
return strings.Replace(image, rawURL, mediaID, 1)
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) isOwnImageURL(rawURL string) bool {
|
||||
if r.imageURLPrefix == "" || !strings.HasPrefix(rawURL, r.imageURLPrefix) {
|
||||
return false
|
||||
}
|
||||
id := strings.TrimPrefix(rawURL, r.imageURLPrefix)
|
||||
if id == "" || strings.ContainsAny(id, "/?#") {
|
||||
return false
|
||||
}
|
||||
return cardImageStableIDRE.MatchString(id)
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) lookup(rawURL string) (string, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
entry, ok := r.entries[rawURL]
|
||||
if !ok || r.now().After(entry.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
return entry.mediaID, true
|
||||
}
|
||||
|
||||
// startUpload returns a job for the image, reusing an in-flight upload or a
|
||||
// cached media id when available. The upload itself runs on a background
|
||||
// context so a streaming frame's cancellation cannot abort it half way.
|
||||
func (r *cardImageMediaResolver) startUpload(rawURL string) *cardImageMediaJob {
|
||||
r.mu.Lock()
|
||||
if entry, ok := r.entries[rawURL]; ok && r.now().Before(entry.expiresAt) {
|
||||
job := &cardImageMediaJob{done: make(chan struct{}), mediaID: entry.mediaID}
|
||||
close(job.done)
|
||||
r.mu.Unlock()
|
||||
return job
|
||||
}
|
||||
if job, ok := r.inflight[rawURL]; ok {
|
||||
r.mu.Unlock()
|
||||
return job
|
||||
}
|
||||
job := &cardImageMediaJob{done: make(chan struct{})}
|
||||
r.inflight[rawURL] = job
|
||||
r.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
uploadCtx, cancel := context.WithTimeout(context.Background(), defaultCardImageUploadTimeout)
|
||||
defer cancel()
|
||||
mediaID, err := r.upload(uploadCtx, rawURL)
|
||||
job.mediaID = mediaID
|
||||
job.err = err
|
||||
r.mu.Lock()
|
||||
delete(r.inflight, rawURL)
|
||||
if err == nil && mediaID != "" {
|
||||
r.entries[rawURL] = cardImageMediaEntry{mediaID: mediaID, expiresAt: r.now().Add(r.ttl)}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
close(job.done)
|
||||
}()
|
||||
return job
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) upload(ctx context.Context, rawURL string) (string, error) {
|
||||
select {
|
||||
case r.sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
defer func() { <-r.sem }()
|
||||
|
||||
data, contentType, err := r.fetch(ctx, rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token, err := r.accessToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.uploadMedia(ctx, token, data, contentType)
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) fetchURL(rawURL string) string {
|
||||
targets := r.fetchURLs(rawURL)
|
||||
if len(targets) == 0 {
|
||||
return rawURL
|
||||
}
|
||||
return targets[0]
|
||||
}
|
||||
|
||||
// fetchURLs lists the download candidates for one of our published images: the
|
||||
// loopback prefix first (cheap, stays inside the host) and the public URL as a
|
||||
// fallback so a missing local route never turns into a missing card image.
|
||||
func (r *cardImageMediaResolver) fetchURLs(rawURL string) []string {
|
||||
relative := ""
|
||||
if strings.HasPrefix(rawURL, r.imageURLPrefix) {
|
||||
relative = strings.TrimPrefix(rawURL, r.imageURLPrefix)
|
||||
}
|
||||
targets := make([]string, 0, 2)
|
||||
if r.fetchURLPrefix != "" && relative != "" {
|
||||
targets = append(targets, strings.TrimRight(r.fetchURLPrefix, "/")+"/"+relative)
|
||||
}
|
||||
if relative == "" || r.fetchURLPrefix == "" {
|
||||
targets = append(targets, rawURL)
|
||||
} else {
|
||||
targets = append(targets, strings.TrimRight(r.imageURLPrefix, "/")+"/"+relative)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) fetch(ctx context.Context, rawURL string) ([]byte, string, error) {
|
||||
var firstErr error
|
||||
for _, target := range r.fetchURLs(rawURL) {
|
||||
data, contentType, err := r.fetchOnce(ctx, target)
|
||||
if err == nil {
|
||||
return data, contentType, nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = errors.New("card image fetch: no candidate URL")
|
||||
}
|
||||
return nil, "", firstErr
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) fetchOnce(ctx context.Context, target string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("card image fetch: unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
contentType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
|
||||
if !strings.HasPrefix(strings.ToLower(contentType), "image/") {
|
||||
return nil, "", fmt.Errorf("card image fetch: unexpected content type")
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, r.maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if int64(len(data)) > r.maxBytes {
|
||||
return nil, "", errors.New("card image fetch: image exceeds upload size limit")
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, "", errors.New("card image fetch: empty image")
|
||||
}
|
||||
return data, contentType, nil
|
||||
}
|
||||
|
||||
// cardImageRefCounts splits the markdown image references of a card body into
|
||||
// externally hosted URLs and DingTalk media ids, so operators can tell from the
|
||||
// logs whether a card body can actually render its images.
|
||||
func cardImageRefCounts(content string) (total, external, media int) {
|
||||
for _, match := range cardMarkdownImageRE.FindAllStringSubmatch(content, -1) {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if strings.Contains(strings.TrimSpace(match[1]), "://") {
|
||||
external++
|
||||
} else {
|
||||
media++
|
||||
}
|
||||
}
|
||||
return total, external, media
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) accessToken(ctx context.Context) (string, error) {
|
||||
r.mu.Lock()
|
||||
if r.token != "" && r.now().Before(r.tokenExpiry) {
|
||||
token := r.token
|
||||
r.mu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
values := map[string]string{"appkey": r.clientID, "appsecret": r.clientSecret}
|
||||
payload, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.tokenURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parsed struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", fmt.Errorf("card image token: invalid response")
|
||||
}
|
||||
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.AccessToken) == "" {
|
||||
return "", fmt.Errorf("card image token: rejected with errcode %d", parsed.ErrCode)
|
||||
}
|
||||
expiresIn := parsed.ExpiresIn
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = 7200
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.token = parsed.AccessToken
|
||||
r.tokenExpiry = r.now().Add(time.Duration(expiresIn)*time.Second - cardImageTokenRefreshMargin)
|
||||
r.mu.Unlock()
|
||||
return parsed.AccessToken, nil
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) uploadMedia(ctx context.Context, token string, data []byte, contentType string) (string, error) {
|
||||
var buffer bytes.Buffer
|
||||
writer := multipart.NewWriter(&buffer)
|
||||
header := make(textproto.MIMEHeader)
|
||||
header.Set("Content-Disposition", `form-data; name="media"; filename="card-image"`)
|
||||
if contentType == "" {
|
||||
contentType = "image/png"
|
||||
}
|
||||
header.Set("Content-Type", contentType)
|
||||
part, err := writer.CreatePart(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
uploadURL := r.uploadURL + "?access_token=" + token + "&type=image"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, &buffer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parsed struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
MediaID string `json:"media_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", errors.New("card image upload: invalid response")
|
||||
}
|
||||
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.MediaID) == "" {
|
||||
return "", fmt.Errorf("card image upload: rejected with errcode %d", parsed.ErrCode)
|
||||
}
|
||||
return parsed.MediaID, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user