Compare commits

..

107 Commits

Author SHA1 Message Date
姚章浩 16ee1ddfbe fix(ci): 存量历史提交只告警不拦截,新增提交仍严格校验
build / build (push) Successful in 18s
把早已推送到其它远端分支的 lineage 合进 main 时,被带进来的提交不是本次新增,
它们标题不合规属既有事实。闸门先抓全部远端分支头,用 git branch -r --contains
取证:能证明已存在于其它远端分支的只告警放行;证不出来的照旧拦截(fail-closed)。
2026-11-11 16:42:15 +08:00
姚章浩 d24457d9d5 Merge remote-tracking branch 'origin/fix/weknora-timeout-nolimit' into HEAD
build / build (push) Failing after 4s
2026-11-11 15:18:47 +08:00
zheromiku 1b42ac7765 ci: 接入提交规范闸门与自动编译流水线(本机Runner+微信通知)
build / build (push) Has been cancelled
2026-09-28 18:38:36 +08:00
zheromiku 65490b1aaa ci: 接入提交规范闸门与自动编译流水线(本机Runner+微信通知)
build / build (push) Has been cancelled
2026-09-28 18:38:34 +08:00
zheromiku f803365a20 ci: 接入提交规范闸门与自动编译流水线(本机Runner+微信通知)
build / build (push) Has been cancelled
2026-09-28 18:38:33 +08:00
姚章浩 70b144f1e5 fix(weknora): drop 120s client total timeout so long agent streams are not cut
The weknora HTTP client used a hardcoded 120s total-duration timeout, which
kills any smart-reasoning agent stream that legitimately runs longer (22 tool
rounds measured in prod) and the failure branch then overwrote the already
streamed card with the error text. Pass 0 and keep Go's default (no timeout).
2026-09-22 16:10:07 +08:00
姚章浩 1bbaf2f07a fix(outboundtext): keep resource handles intact when normalizing formulas 2026-09-18 17:14:45 +08:00
姚章浩 48b741984f fix(reasoning): never cut a lead-in that carries an image 2026-09-18 16:49:16 +08:00
姚章浩 5ccaeb3b96 fix(outbound): keep single-run answers whole and show formulas
The DingTalk binding can point at the quick-answer agent, which publishes the
whole answer as one answer run plus tool_call/tool_result steps. The narration
boundary was rejected for that shape (start=0), so the text-only heuristic ran
and dropped the answer lead-in together with its image.

- answer_observation: a single answer run means there is no narration to cut.
- outboundtext: rewrite LaTeX formulas into plain Unicode text, because
  DingTalk cards render no math typesetting (r = sqrt(X^2 + Y^2) -> r = √(X² + Y²)).
- dingtalk handler: normalize formulas on both the partial and final card paths.
2026-09-18 16:21:42 +08:00
姚章浩 dfce8cb9f6 test(qa): pin the answer-run boundary used to trim narration
Adds the production CRA5 shape (narration run + tool steps + final answer run
that opens with a lead-in and an image) and a control case where the last
answer run is interrupted by a tool call and the heuristic still applies.
2026-09-18 15:58:14 +08:00
姚章浩 149e94b34b fix(reasoning): keep the answer body's lead-in and images when trimming narration
The DingTalk card dropped the answer's opening (and any image reference in
it) whenever the agent narrated its steps before answering: the text-only
boundary heuristic (reasoningmode.BodyStartOffset) treats everything before
the first structural line as narration, so a lead-in like "我已经找到了…"
plus the image that follows it disappeared, leaving the card to start at
the first heading.

The event stream already knows the boundary: every agent step publishes its
answer chunks under its own answer run id (data.event_id), and the final
body is the last run that no tool/thinking event interrupts. WeKnora
StreamEvent now carries the run id and run start offset; qa.Service tracks
the observation and prefers it over the heuristic, falling back to the
heuristic when the stream carries no run information.

Verified: production turn efae317e (给我CRA5的负载图) lost 131 chars —
lead-in + the load-curve image + separator — and now keeps them.
2026-09-18 15:53:43 +08:00
hermes dd229ec0ad feat(dingtalk): per-frame wide-table grouping (stream keeps tables flowing while streaming) 2026-09-18 11:55:45 +08:00
hermes 92324b4242 feat(dingtalk): make wide-table grouping switchable for wide-screen PC cards
DINGTALK_CARD_WIDE_TABLE_GROUP=off keeps over-wide answer tables as tables on the final frame (still replaces <br> inside cells) so the PC wide-screen card can lay them out itself.
2026-09-18 11:45:55 +08:00
agent 882ac62433 perf(dingtalk): compress outbound screenshots, reuse uploaded objects, raise rewrite budget
- outboundimage: re-encode large PNG/JPEG screenshots as downscaled JPEG (1600px long edge, q82, transparency flattened onto white) before upload; undecodable, small, GIF/WebP payloads are published untouched
- outboundimage: StableHashPublisher skips the upload when the content-addressed object already exists (ObjectStater); OSS store implements it via IsObjectExist
- cmd/dobotai: DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT default 60s -> 180s; DOBOTAI_OUTBOUND_IMAGE_COMPRESS (on by default, off restores raw bytes)
2026-09-18 11:14:13 +08:00
agent 8a9c1eaf82 perf(dingtalk): rewrite answer images concurrently and make the rewrite budget configurable
- outboundimage: fetch and publish up to 4 images in parallel, dedupe in document order, log per-image outcome plus a rewrite summary
- dingtalk handler: OutboundRewriteTimeout applies to the image rewrite stage only, falling back to finalDeliveryTimeout
- cmd/dobotai: DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT (default 60s, invalid values fall back)
2026-09-18 10:57:32 +08:00
agent 9de1ed66aa feat(dingtalk): make outbound image count configurable (DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT, default 12) 2026-09-18 10:16:14 +08:00
姚章浩 cfe3271fa2 feat(dingtalk): fall back to the public image URL and report image ref shape
The loopback fetch now degrades to the public URL ...
2026-09-17 18:20:02 +08:00
姚章浩 dfc7c8dba9 feat(dingtalk): deliver card images through DingTalk media ids
AI card bodies render only DingTalk-hosted media, so published image URLs are
rewritten into media ids inside card content (streaming frames never block,
final frame waits briefly), while the markdown fallback keeps external URLs.
2026-09-17 18:14:03 +08:00
姚章浩 06d11135e1 deploy(prod): 生产切换脚本(门控字面量+回滚标签+env 0600 备份) 2026-09-17 14:25:30 +08:00
姚章浩 5f1206b545 fix(dingtalk): 推理正文边界支持「首行即正文」并补齐卡片结构诊断 + 流式分帧重放守卫 2026-09-17 14:16:40 +08:00
姚章浩 a1a2aabab4 fix(dingtalk): 推理正文边界支持「首行即正文」并补齐卡片结构诊断
- BodyStartOffset:内容首个非空行即结构行时正文从头开始,避免收尾裁剪
  把标题+首段一起删掉(118 条生产样本中 31 条、最多 526 字)
- 语料回归守卫:隐藏前缀不得含结构行、上限 600 字;单测补 suffix 断言
- 卡片诊断新增 body_ok/body_line/body_prefix_runes/banner_pos/first_heading_line
  (定位流式残留推理末句,只看结构不看文本)
2026-09-17 14:01:10 +08:00
姚章浩 fcca33050a test: 通用的测试服镜像切换脚本(先打回滚标签+env 0600 备份,读回校验) 2026-09-17 11:08:23 +08:00
姚章浩 cd3f92aaaa verify: 合并宽屏自适应与推理输出改造(测试服验证用) 2026-09-17 11:03:05 +08:00
姚章浩 15f2a11162 fix(dingtalk): 推理模式卡片隐藏推理并只留正文
- reasoningmode: 新增正文边界识别(结构行 + 前置空行)与流式 PartialFeed:
  分步叙述按行拼接显示,正文开始时只输出正文,卡片更新即刷新清掉推理区
- dingtalk: 事件流与流式回调统一改走 PartialFeed
- qa: 推理执行的最终答案在入库/投递前去掉步骤叙述(快速模式行为不变)

语料回归:生产 118 条内建智能推理答案中 106 条识别出正文边界(被隐藏的
均为 59-164 字过渡句),12 条无推理的问候类答案保持原样;go vet 与
go test ./... 全绿。
2026-09-17 10:58:59 +08:00
hermes-agent a096ba5f51 fix(dingtalk): 投放 AI 卡片时开启宽屏自适应布局
PC 端卡片宽度过窄:创建卡片(createAndDeliver)请求体从未请求宽屏布局。

修复落在创建卡片的 JSON 载荷层 —— cardData.cardParamMap 的钉钉保留系统
字段 sys_full_json_obj 内传 {"config":{"autoLayout":true}}。config 为
对象类型,钉钉规定复杂类型变量必须走该保留字段;放在 cardParamMap 兄弟
节点会因变量未赋值而静默失效(宽度不变、无报错)。

- 仅影响 PC 宽屏渲染;移动端保持自适应布局不变。
- 未改动富文本组件宽度/padding,也未改动模板内静态内容。
- 流式更新(streaming)请求体不带该字段,避免中途变更布局。

新增回归守卫测试:投放体(direct/group)必须含该字段且 autoLayout=true,
流式更新体必须不含;防止退化为静默失效写法。

验证:gofmt 干净、go vet ok、go test ./... 全绿;
A/B 探针(A 带字段 / B 对照)经 PC 端人工确认 A 卡片变宽。
2026-09-17 09:59:39 +08:00
zheromiku ba090d1705 Merge pull request 'feat: default smart reasoning and robust channel delivery' (#16) from feat/default-reasoning-mode into main 2026-09-15 17:13:32 +08:00
姚章浩 4e65028d1d fix(dingtalk): strip leaked internal source handles 2026-09-15 17:08:33 +08:00
姚章浩 c7d2629a77 fix(coverage): let references override partial no-answer wording 2026-09-15 16:53:12 +08:00
姚章浩 b49cb5eaf9 feat(dingtalk): normalize wide tables into grouped lists on final card 2026-09-15 16:09:43 +08:00
姚章浩 16aa981028 test(reasoning): cover default-mode routing, user pinning and 000022 contract 2026-09-15 14:01:00 +08:00
姚章浩 f800127461 feat(reasoning): support operator-configurable default mode via DOBOTAI_DEFAULT_MODE 2026-09-15 12:25:09 +08:00
姚章浩 d6a781ba5b fix(dingtalk): send answer language via Accept-Language header
WeKnora's system prompt hard-enforces 'ALWAYS respond in {{language}}',
fed by its Language middleware (WEKNORA_LANGUAGE env -> Accept-Language
header -> zh-CN fallback). The previous localizedQuery() prompt prefix
lost against that instruction, so a user-set 'en' preference still got
Chinese answers on English questions.

- AgentChatRequest.Locale is transmitted as the Accept-Language header
  (never in the JSON body); WeKnora maps it to the prompt language
- 'auto' detects the query script client-side (latin->en, Han->zh-CN,
  kana->ja, Hangul->ko); es is never auto-guessed
- unset/unknown locale sends no header, preserving prior server behavior
- drop localizedQuery prompt prefixes entirely
2026-09-14 16:58:52 +08:00
姚章浩 168e401bc0 docs(deploy): table the optional outbound-image OSS fields 2026-09-11 20:38:06 +08:00
zheromiku 58b37f1c24 Merge pull request 'fix(deploy): configure outbound image OSS as an optional group' (#15) from feat/docker-external-rds into main 2026-09-11 18:48:56 +08:00
姚章浩 6a3f505965 fix(deploy): configure outbound image OSS as an optional group 2026-09-11 18:48:15 +08:00
zheromiku a0f1fbf391 Merge pull request 'feat(deploy): external-RDS Docker deployment with fielded RDS config' (#14) from feat/docker-external-rds into main 2026-09-11 18:39:20 +08:00
姚章浩 49e0789fa5 feat(deploy): prompt RDS config as separate fields 2026-09-11 18:04:38 +08:00
姚章浩 d77fbd675b fix(deploy): honor configured Go module proxy 2026-09-11 17:27:20 +08:00
姚章浩 a615693bd0 fix(deploy): configure distinct quick and reasoning agents 2026-09-11 13:26:17 +08:00
姚章浩 febf99b549 fix(deploy): generate internal tenant key automatically 2026-09-11 13:20:44 +08:00
姚章浩 b4b61c0aa5 fix(deploy): explain missing RDS database to administrators 2026-09-11 12:25:31 +08:00
姚章浩 6153533b46 fix(deploy): isolate installer command directory 2026-09-11 12:10:08 +08:00
姚章浩 27d4d45af4 feat(deploy): run Docker application against external RDS 2026-09-11 12:09:29 +08:00
zheromiku a31156dc4e Merge pull request 'docs: 清理历史计划并说明仓库目录结构' (#13) from feat/cli-admin-bootstrap into main 2026-09-11 10:18:28 +08:00
姚章浩 f408096611 docs: document repository structure and remove stale plan 2026-09-11 10:17:03 +08:00
hermes-bot 218ab657d6 Merge pull request 'Feat/cli admin bootstrap' (#12) from feat/cli-admin-bootstrap into main
Reviewed-on: #12
2026-09-10 22:38:04 +08:00
姚章浩 5c5e4494cb test(migrations): cover non-knowledge response reason 2026-09-10 22:25:48 +08:00
姚章浩 e584b17929 docs(dobotai): add container deployment guide 2026-09-10 22:25:19 +08:00
姚章浩 8027635330 fix(coverage): skip alerts for non-knowledge text turns 2026-09-10 22:02:59 +08:00
姚章浩 563329320b docs(dobotai): guide container credential input 2026-09-10 18:31:55 +08:00
姚章浩 9abbb2cb2b feat(dobotai): containerize test runtime and admin bootstrap 2026-09-10 18:22:21 +08:00
hermes-bot 4c5b1e3b5a Merge pull request 'docs: 中文化文档与环境示例注释' (#11) from docs/chinese-documentation-r1 into main
Reviewed-on: #11
2026-09-10 17:04:31 +08:00
姚章浩 fa938ca1eb docs: translate documentation and environment comments to Chinese 2026-09-10 16:59:22 +08:00
zheromiku 7661e1b8f7 Merge pull request 'feat(dingtalk): merge tested multimodal, reasoning, and support workflows' (#10) from feat/think-mode-support-alerts into main 2026-09-10 16:40:42 +08:00
姚章浩 bfa1b1b25f feat(dingtalk): add reasoning mode and support evidence 2026-09-10 16:24:56 +08:00
姚章浩 0798a2dac7 feat(dingtalk): stabilize multimodal answer workflows 2026-09-09 13:57:43 +08:00
姚章浩 18c81232ce fix: keep only setting command entries 2026-08-14 12:35:33 +08:00
姚章浩 19ed4b50df feat: add guided administrator management 2026-08-14 12:12:33 +08:00
姚章浩 9f65c3eef0 docs: update AI table binding example 2026-08-14 11:46:37 +08:00
姚章浩 50379b3b87 fix: parse AI table iframe query links 2026-08-14 11:40:28 +08:00
姚章浩 3f9ff359b9 feat: accept trusted AI table link tails 2026-08-14 11:20:15 +08:00
姚章浩 a3defdd1c0 fix: report AI table binding validation failures 2026-08-14 11:11:07 +08:00
姚章浩 b4b68d8b21 fix: accept AI table link cards in settings 2026-08-14 10:46:51 +08:00
姚章浩 365f1b8c7d fix: detect missing-product coverage responses 2026-08-14 10:02:58 +08:00
姚章浩 725db721a8 fix: refresh table dispatchers after binding activation 2026-08-13 18:39:15 +08:00
姚章浩 5d68b22636 feat: add operational component status dashboard 2026-08-13 18:33:52 +08:00
姚章浩 6f24743be2 feat: verify and activate support group binding 2026-08-13 18:22:43 +08:00
姚章浩 4dbc20ee74 fix: validate card business responses and trim help 2026-08-13 15:10:09 +08:00
姚章浩 e5be9ee780 fix: stop enqueueing disabled external table exports 2026-08-13 14:49:44 +08:00
姚章浩 0f30f8cec8 fix: route help and repeated-slash settings locally 2026-08-13 14:39:56 +08:00
姚章浩 44d9398c78 fix: keep setup available without active DingTalk app 2026-08-13 13:55:25 +08:00
姚章浩 b207ee4089 feat: switch DingTalk applications with rollback 2026-08-13 13:36:26 +08:00
姚章浩 0fc64ba77d feat: validate and activate AI table bindings 2026-08-13 13:14:06 +08:00
姚章浩 a7432aed49 feat: wire settings admin into DobotAI runtime 2026-08-13 12:59:20 +08:00
姚章浩 5036071ac2 feat: keep table binding messages out of QA 2026-08-13 12:55:00 +08:00
姚章浩 2d9490f424 feat: apply per-user answer language 2026-08-13 12:50:07 +08:00
姚章浩 462ee1f3ff feat: add admin settings control flow 2026-08-13 12:45:08 +08:00
姚章浩 8d2d4b469b feat: add secure settings foundation 2026-08-13 12:38:01 +08:00
姚章浩 9363d10e44 feat: publish outbound answer images to private OSS 2026-08-13 12:34:39 +08:00
姚章浩 74c9af9944 fix: acknowledge DingTalk callbacks before async processing 2026-08-13 12:34:16 +08:00
姚章浩 5c35a49486 fix: recreate stale WeKnora sessions once 2026-08-13 12:34:05 +08:00
姚章浩 357e9bad5c docs: plan DobotAI settings administration 2026-08-13 12:33:55 +08:00
姚章浩 2c0ed7cc67 feat: expose runtime build version 2026-08-13 10:44:16 +08:00
姚章浩 aa0513eb59 feat: add FAQ uncovered workflow 2026-07-27 11:54:34 +08:00
姚章浩 e582d851a4 fix: wrap Notable record fields payload 2026-07-23 18:55:03 +08:00
姚章浩 0a3f62618e feat: sync DingTalk QA to Notable question table 2026-07-23 14:38:50 +08:00
姚章浩 5e0694835a feat: add DingTalk directory-backed session titles 2026-07-23 10:55:25 +08:00
姚章浩 39c5fbbaef fix: detect retrieval gaps with human escalation 2026-07-22 14:17:39 +08:00
姚章浩 d29b28be13 fix: detect unavailable authoritative knowledge answers 2026-07-22 13:21:59 +08:00
姚章浩 774ccd9454 feat: wire coverage support notifications 2026-07-22 12:48:30 +08:00
姚章浩 86593df57c feat: add durable support notification dispatcher 2026-07-22 12:36:33 +08:00
姚章浩 913a65b16d fix: persist coverage reference count 2026-07-22 12:19:53 +08:00
姚章浩 a6569ee4bf feat: add DingTalk support group sender 2026-07-22 12:17:04 +08:00
姚章浩 5b5865dc07 feat: add coverage decision persistence 2026-07-22 12:00:32 +08:00
zheromiku 4366c018f9 Merge pull request 'feat: add exact custom card feedback reasons' (#9) from feature/custom-card-feedback-actions into main 2026-07-21 21:23:15 +08:00
姚章浩 a952232cc9 feat: add exact custom card feedback reasons 2026-07-21 21:21:41 +08:00
zheromiku 4dc3f22784 Merge pull request 'feat: handle native DingTalk card feedback' (#8) from feature/native-card-feedback into main 2026-07-21 21:00:04 +08:00
姚章浩 b8f572cc84 feat: handle native DingTalk card feedback 2026-07-21 20:41:12 +08:00
zheromiku fcf4559c9a Merge pull request 'feat: enable per-user DingTalk card debugging' (#7) from feature/card-feedback-debug into main 2026-07-21 18:06:42 +08:00
姚章浩 9fa2eac6a4 feat: enable per-user DingTalk card debugging 2026-07-21 18:02:14 +08:00
zheromiku da69226481 Merge pull request 'feat: add DingTalk answer feedback workflow' (#6) from feature/answer-feedback into main 2026-07-21 16:00:17 +08:00
姚章浩 38560c5f41 feat: add DingTalk answer feedback workflow 2026-07-21 15:23:57 +08:00
zheromiku f0d3d5b50d Merge pull request 'feat: add tenant-scoped audit query API' (#5) from feature/audit-query-api into main 2026-07-21 14:11:37 +08:00
姚章浩 56e3bf4686 feat: add tenant-scoped audit query API 2026-07-21 13:57:31 +08:00
zheromiku 257a55a3f0 Merge pull request 'feat: close DingTalk delivery audit lifecycle' (#4) from feature/message-delivery-audit into main 2026-07-21 12:56:53 +08:00
姚章浩 98295ee79a feat: track DingTalk delivery outcomes 2026-07-21 12:53:40 +08:00
zheromiku c49cde1042 Merge pull request 'feat: add message audit foundation' (#3) from feature/message-audit-foundation into main 2026-07-21 11:12:37 +08:00
270 changed files with 30522 additions and 409 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.env
.env.*
!.env.example
bin/
.acceptance-canary/
.hermes/
__pycache__/
*.pyc
+37 -9
View File
@@ -1,24 +1,52 @@
# DobotAI configuration
# DobotAI 基础配置
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
DOBOTAI_INGRESS_TOKEN=replace-me
# 可选的仅本机审计 API。必须使用不同于 DOBOTAI_INGRESS_TOKEN 的独立密钥。
# 设置后必须同时设置 DOBOTAI_TENANT_KEY,且监听地址必须仅为回环地址。
DOBOTAI_ADMIN_TOKEN=
DOBOTAI_GROUP_SESSION_POLICY=shared
DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"replace-with-agent-id"}
# `/think` 路由必填。上面的基础绑定保持稳定的会话身份;以下配置仅选择每个 Turn 的执行 Agent。
DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer
DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning
# Official WeKnora API only
# 仅使用 WeKnora 官方 API
WEKNORA_BASE_URL=http://127.0.0.1:8080
WEKNORA_API_KEY=replace-me
# DingTalk Stream mode (official SDK). Leave all three empty to disable the
# DingTalk channel; set all three to enable it. Never commit real values.
# 钉钉 Stream 模式(官方 SDK)。三项均留空时禁用钉钉渠道;三项同时设置时启用。不得提交真实值。
DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET=
# Tenant key this DingTalk ingress serves (matches tenant_key used by QA).
# 该钉钉接入服务的租户键(必须与 QA 使用的 tenant_key 一致)。
DOBOTAI_TENANT_KEY=
# Optional DingTalk AI card template ID. Leave empty to use markdown-only replies.
# When set, a successful QA answer will be delivered as an AI card; markdown
# is only sent as a fallback when card delivery fails. Never commit real values.
# 可选的钉钉 AI 卡片模板 ID。留空时仅使用 Markdown 回复。
# 设置后,成功的 QA 回答优先通过 AI 卡片发送;仅当卡片发送失败时才回退 Markdown。不得提交真实值。
DINGTALK_CARD_TEMPLATE_ID=
# 可选、临时的钉钉卡片调试白名单。设置后,只有该指定钉钉用户会在 per-user privateData 中收到 _CARD_DEBUG_TOOL_ENTRY=show。
# 非受控调试期间必须留空;不得提交真实用户 ID。
DINGTALK_CARD_DEBUG_USER_ID=
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
# 可选的钉钉 FAQ 表工作流,用于未覆盖问题。四项必须同时设置;四项均留空时禁用。
# 启用下方人工支持群通知时,此工作流为必需项。不得提交真实 ID。
DINGTALK_FAQ_TABLE_BASE_ID=
DINGTALK_FAQ_TABLE_SHEET_ID=
DINGTALK_FAQ_TABLE_VIEW_ID=
DINGTALK_FAQ_TABLE_OPERATOR_ID=
# 可选的钉钉卡片回答图片外发。任一项设置后,以下五项都必须设置。
# DobotAI 仅从 WEKNORA_BASE_URL 的 /files 鉴权下载 resource:// 图片,在 dobotai/im-images/ 下写入内容哈希副本,并生成有效期2小时的私有 OSS GET URL。
# Bucket 必须保持私有,并配置 OSS 生命周期规则,在1天后删除 dobotai/im-images/ 对象。不得提交真实凭据。
DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT=
DOBOTAI_OUTBOUND_IMAGE_OSS_REGION=
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID=
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET=
DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET=
# 可选的未覆盖回答人工支持群通知。该值是群的 openConversationId,不是 webhook。
# 留空时仍记录覆盖判定和待处理 Support Case,但不发送群通知。
# 启用后必须同时配置上方 FAQ 表工作流,使 ActionCard 能链接到准确的 FAQ 记录。不得提交真实群 ID。
DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID=
# 独立的 DobotAI PostgreSQL;不得指向 WeKnora 数据库
DATABASE_URL=postgres://dobotai:replace-me@127.0.0.1:5432/dobotai?sslmode=disable
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""提交信息规范校验(CI 侧权威闸门)。
规则与本机 ~/.githooks/commit-msg 完全一致,防止 --no-verify 绕过。
放行:Merge / Revert " / fixup! / squash! / amend! 开头的自动生成提交。
存量历史口径:合并一条早已推送到别的远端分支的lineage(例如把生产分支合进 main)时,
被带进来的提交并非本次新增,它们的标题不合规属于既有事实。判定为「已在其它远端分支存在」
的提交只告警、不判红;本次真正新造的提交一律照旧严格校验。
豁免必须是可证明的:先抓全部远端分支头,用 `git branch -r --contains <sha>` 取证;
抓不到 / 证不出 ⇒ 不豁免(宁可红,也不假绿)。
"""
import json
import os
import re
import subprocess
import sys
TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert"
RE = re.compile(r"^(?:" + TYPES + r")(?:\([a-zA-Z0-9._/-]+\))?!?: .{3,}$")
ALLOW = re.compile(r'^(?:Merge |Revert "|fixup! |squash! |amend! )')
# 本次推送的分支名,用来把「当前分支自己」排除在豁免证据之外
PUSH_BRANCH = (os.environ.get("GITHUB_REF_NAME") or "").strip()
HELP = """
\u274c 提交信息不合规,已拦截。
规范 : <类型>(<范围>): <简短描述>
类型 : feat 新功能 | fix 修缺陷 | docs 文档 | style 格式(不改逻辑)
refactor 重构 | perf 性能 | test 测试 | build 构建/依赖
ci CI 配置 | chore 杂务 | revert 回滚
示例 : feat(asset): 新增按歌曲生成应援序列
fix(parser): 修复表格解析越界导致的 500
docs: 补充 OSS 短签名接口说明
要点 : 类型小写、冒号后一个空格、描述 \u22653 字符
标题 \u226472 字符、结尾不加句号
破坏性变更类型后加 !,正文写 BREAKING CHANGE:
详见 : docs/提交规范.md
"""
def sh(args):
return subprocess.run(args, capture_output=True, text=True)
def collect():
"""返回 [(sha, message)]:优先 Gitea 事件负载,取不到则退回 git log。"""
items = []
ev = os.environ.get("GITHUB_EVENT_PATH") or ""
if ev and os.path.exists(ev):
try:
with open(ev, encoding="utf-8") as fh:
d = json.load(fh)
for c in (d.get("commits") or []):
m = (c.get("message") or "").strip()
if m:
items.append(((c.get("id") or "").strip(), m))
except Exception as e:
print(" 事件负载解析失败(%s),回退到 git log" % e)
if not items:
out = sh(["git", "log", "-n", "5", "--format=%H%x1f%B%x1e"]).stdout
for rec in out.split("\x1e"):
rec = rec.strip()
if not rec:
continue
sha, _, msg = rec.partition("\x1f")
if msg.strip():
items.append((sha.strip(), msg.strip()))
return items
_fetched = None
def ensure_remote_heads():
"""抓全部远端分支头(浅历史足够判断 lineage 末端)。"""
global _fetched
if _fetched is None:
r = sh(["git", "fetch", "-q", "--depth", "200", "origin", "+refs/heads/*:refs/remotes/origin/*"])
_fetched = (r.returncode == 0, (r.stderr or "").strip())
if not _fetched[0]:
print(" \u26a0 无法抓取远端分支,存量豁免不可用(将严格校验): %s" % _fetched[1][:200])
return _fetched[0]
def refs_containing(sha):
"""除本次推送分支外,还有哪些远端分支包含该提交。"""
if not ensure_remote_heads():
return []
r = sh(["git", "branch", "-r", "--contains", sha])
if r.returncode != 0:
return []
refs = []
for line in r.stdout.splitlines():
ref = line.strip().lstrip("*").strip()
if not ref or "->" in ref:
continue
if PUSH_BRANCH and ref == "origin/" + PUSH_BRANCH:
continue
refs.append(ref)
return refs
def subject_of(m):
for line in m.splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
return s
return ""
def judge(subj):
if not subj:
return "空提交信息"
if ALLOW.match(subj):
return ""
if len(subj) > 72:
return "标题 %d 字符 > 72" % len(subj)
if not RE.match(subj):
return "不符合 <类型>(<范围>): <描述>"
if subj.endswith(".") or subj.endswith("\u3002"):
return "标题结尾不要加句号"
return ""
def main():
items = collect()
bad = []
excused = []
print("待校验提交 %d 条:" % len(items))
for sha, msg in items:
subj = subject_of(msg)
why = judge(subj)
if not why:
print(" \u2705 %s" % subj)
continue
refs = refs_containing(sha) if sha else []
if refs:
excused.append((sha, subj, why, refs))
print(" \u21aa [%s] %s ← 已存在于 %s,按存量历史放行" % (why, subj, ",".join(refs[:3])))
continue
bad.append((subj, why))
print(" \u274c [%s] %s" % (why, subj))
if excused:
print("")
print("存量豁免 %d 条(提交早于本次合并,已在其它远端分支存在):" % len(excused))
for sha, subj, why, refs in excused:
print(" %s %s [%s]" % (sha[:10], subj, ",".join(refs[:3])))
try:
with open("/tmp/ci-gate-status", "w") as fh:
fh.write("failure" if bad else "success")
except Exception:
pass
if bad:
print(HELP)
return 1
print("\u2705 提交信息全部合规")
return 0
if __name__ == "__main__":
sys.exit(main())
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""把 CI 结果上报给主机侧 ci-bridge(bridge 负责落报告页 + 推微信)。"""
import json
import os
import subprocess
import urllib.request
def rd(path, default=""):
try:
with open(path, encoding="utf-8") as fh:
return fh.read().strip()
except Exception:
return default
def git(*args):
try:
return subprocess.run(["git"] + list(args), capture_output=True,
text=True).stdout.strip()
except Exception:
return ""
def main():
repo = os.environ.get("GITHUB_REPOSITORY", "unknown/unknown")
sha = rd("/tmp/ci-sha") or git("rev-parse", "HEAD")
branch = os.environ.get("GITHUB_REF_NAME") or \
os.environ.get("GITHUB_REF", "").replace("refs/heads/", "")
gate = rd("/tmp/ci-gate-status", "skipped")
build = rd("/tmp/ci-build-status", "failure")
status = "success" if (gate == "success" and build == "success") else "failure"
log = rd("/tmp/ci-build-tail")
if gate != "success":
log = "提交信息规范校验未通过\n" + log
try:
count = int(rd("/tmp/ci-commits-count", "1") or 1)
except Exception:
count = 1
rep = {
"repo": repo,
"sha": sha,
"branch": branch,
"actor": os.environ.get("GITHUB_ACTOR", ""),
"status": status,
"commit_subject": git("log", "-1", "--format=%s"),
"commit_count": count,
"duration_s": rd("/tmp/ci-duration", "?"),
"preview_url": rd("/tmp/ci-preview-url"),
"log": log[-3500:],
"steps": [
{"name": "提交信息规范校验", "status": gate},
{"name": "编译与自检", "status": build},
],
}
if rep["preview_url"]:
print("\U0001F310 网页预览: %s" % rep["preview_url"])
print("结果: %s | 构建: %s | 提交闸门: %s | 耗时: %ss"
% (status, build, gate, rep["duration_s"]))
base = os.environ.get("CI_BRIDGE_RESOLVED", "").rstrip("/")
if not base:
print("跳过上报:未找到可达的 ci-bridge")
return 0
data = json.dumps(rep, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
base + "/api/ci/report", data=data, method="POST",
headers={"Content-Type": "application/json",
"X-CI-Token": os.environ.get("BRIDGE_TOKEN", "")})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
print("\u2705 已上报 ci-bridge:", resp.read().decode()[:300])
except Exception as e:
print("\u274c 上报 ci-bridge 失败:", e)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+156
View File
@@ -0,0 +1,156 @@
name: build
# 提交 -> 本机 Runner 编译 -> 微信通知 + 网页预览
# 实测结论(勿随意改动):
# 1. 不用任何第三方 action(作业容器不联网拉 action,全用镜像自带工具)
# 2. 必须自己 clone —— Runner 不会自动检出代码,工作区初始为空
# 3. act 以 `bash -e -o pipefail` 执行每步 -> 禁止 `cmd | head` 这种会 SIGPIPE 提前退出的管道
# 4. 工具链来自主机只读挂载 /opt/citools(go/uv),缓存写 /opt/citools-cache
# 5. bridge 从容器内走 docker 网关 172.17.0.1:8899(实测可达)
# 6. 校验/上报逻辑放在 .gitea/scripts/ 下的脚本里,YAML 只负责调用(避免 YAML 里嵌深缩进代码)
on:
push:
branches:
- "**"
workflow_dispatch:
env:
BRIDGE_URL: ${{ vars.CI_BRIDGE_URL }}
BRIDGE_TOKEN: ${{ secrets.CI_BRIDGE_TOKEN }}
GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }}
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: 检出代码
run: |
set -uo pipefail
SRV="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}"
HP="${SRV#*://}"
BR="${GITHUB_REF_NAME:-${GITHUB_REF#refs/heads/}}"
cd "${GITHUB_WORKSPACE:-$(pwd)}"
rm -rf ./* .git 2>/dev/null || true
git init -q .
git remote add origin "http://x-access-token:${GIT_TOKEN}@${HP}/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 50 origin "$BR"
git checkout -q -B "$BR" FETCH_HEAD
git rev-parse HEAD > /tmp/ci-sha
date +%s > /tmp/ci-start
: > /tmp/ci-preview-url
echo skipped > /tmp/ci-build-status
git rev-list --count FETCH_HEAD > /tmp/ci-commits-count 2>/dev/null || echo 1 > /tmp/ci-commits-count
echo "已检出 ${GITHUB_REPOSITORY} 分支 ${BR}"
git log -1 --format=' %h %an %s'
- name: 提交信息规范校验
run: python3 .gitea/scripts/ci_gate.py
- name: 编译与自检
run: |
set +e; set +o pipefail
LOG=/tmp/ci-build.log; : > "$LOG"
run() { echo "-- $* --"; "$@" 2>&1 | tee -a "$LOG"; return "${PIPESTATUS[0]}"; }
runin(){ local d="$1"; shift; echo "-- ($d) $* --"; ( cd "$d" && "$@" ) 2>&1 | tee -a "$LOG"; return "${PIPESTATUS[0]}"; }
export PATH=/opt/citools/go/bin:/opt/citools/bin:$PATH
export GOCACHE=/opt/citools-cache/gocache GOMODCACHE=/opt/citools-cache/gomod UV_CACHE_DIR=/opt/citools-cache/uv
export GOPROXY=https://goproxy.cn,direct GOTOOLCHAIN=local GOFLAGS=-mod=mod GOPATH=/opt/citools-cache/gopath
mkdir -p "$GOCACHE" "$GOMODCACHE" "$UV_CACHE_DIR" "$GOPATH" 2>/dev/null
echo "工具链: $(go version 2>&1) | $(uv --version 2>&1) | $(node -v 2>&1) | $(python3 -V 2>&1)"
fail=0; ran=""
if [ -f go.mod ]; then
ran="$ran go"
run go build ./... || fail=1
run go vet ./... || fail=1
run go test ./... || fail=1
fi
if [ -f pyproject.toml ] || [ -f requirements.txt ] || [ -f setup.py ]; then
ran="$ran python"
if grep -qE '^check:' Makefile 2>/dev/null; then
run make UV=uv check || fail=1
else
run uv sync || fail=1
run uv run pytest -q || fail=1
fi
fi
WEBPKG=""
for p in package.json web/package.json frontend/package.json ui/package.json; do
if [ -f "$p" ]; then WEBPKG="$p"; break; fi
done
if [ -n "$WEBPKG" ]; then
ran="$ran node"
D="$(dirname "$WEBPKG")"
if [ -f "$D/package-lock.json" ]; then
runin "$D" npm ci || fail=1
else
runin "$D" npm install || fail=1
fi
if grep -q '"build"[[:space:]]*:' "$WEBPKG" 2>/dev/null; then
runin "$D" npm run build || fail=1
fi
fi
if [ -z "$ran" ] && [ -f Makefile ]; then
ran="$ran make"
run make || fail=1
fi
if [ -z "$ran" ]; then
echo "未识别到构建入口(go.mod / pyproject.toml / package.json / Makefile 都没有),跳过编译"
fi
if [ "$fail" -eq 0 ]; then echo success > /tmp/ci-build-status; else echo failure > /tmp/ci-build-status; fi
tail -c 4000 "$LOG" > /tmp/ci-build-tail
echo "构建结果: $(cat /tmp/ci-build-status)(识别:$ran)"
[ "$fail" -eq 0 ] || exit 1
- name: 发布网页预览
if: always()
run: |
set +e; set +o pipefail
: > /tmp/ci-preview-url
if [ "$(cat /tmp/ci-build-status 2>/dev/null)" != "success" ]; then
echo "构建未成功,跳过预览发布"; exit 0
fi
WEB=""
for d in web/dist dist build out public web/build frontend/dist; do
if [ -f "$d/index.html" ]; then WEB="$d"; break; fi
done
if [ -z "$WEB" ]; then echo "未发现静态网页产物目录,跳过预览发布"; exit 0; fi
tar -czf /tmp/ci-web.tgz -C "$WEB" . || { echo "打包失败"; exit 0; }
B="${BRIDGE_URL:-}"
for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do
if [ -n "$cand" ]; then
curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; }
fi
done
SHA="$(cat /tmp/ci-sha)"
echo "发布预览到 $B(产物目录 $WEB)"
curl -sS -m 90 -X POST "$B/api/ci/publish?repo=${GITHUB_REPOSITORY}&sha=${SHA}" -H "X-CI-Token: ${BRIDGE_TOKEN}" -H 'Content-Type: application/gzip' --data-binary @/tmp/ci-web.tgz -o /tmp/ci-pub.json
cat /tmp/ci-pub.json; echo
URL="$(python3 -c "import json;print(json.load(open('/tmp/ci-pub.json')).get('url',''))" 2>/dev/null)"
if [ -n "$URL" ]; then echo "$URL" > /tmp/ci-preview-url; echo "网页预览: $URL"; fi
- name: 通知微信
if: always()
run: |
set +e; set +o pipefail
B="${BRIDGE_URL:-}"
for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do
if [ -n "$cand" ]; then
curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; }
fi
done
echo "使用 ci-bridge: $B"
S=$(date +%s); T=$(cat /tmp/ci-start 2>/dev/null); [ -n "$T" ] || T=$S
echo $((S - T)) > /tmp/ci-duration
CI_BRIDGE_RESOLVED="$B" python3 .gitea/scripts/ci_report.py
+11
View File
@@ -5,6 +5,17 @@
*.local
coverage.out
# Local deployment, backup, and production diagnostic artifacts
/.deploy/
/backups/
/check-live-dobotai.py
# Transient local validation and legacy test-deployment artifacts
/dobotai
/.acceptance-canary/
/.hermes/
/deploy_stable_images_test.py
# Editors and OS
.idea/
.vscode/
+23
View File
@@ -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"]
+210 -50
View File
@@ -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`。
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(args []string, out io.Writer) error {
flags := flag.NewFlagSet("directory-import", flag.ContinueOnError)
flags.SetOutput(io.Discard)
csvPath := flags.String("csv", "", "path to tested DingTalk directory CSV output")
tenant := flags.String("tenant", "", "tenant key")
databaseURLFile := flags.String("database-url-file", "", "file containing DATABASE_URL")
validateOnly := flags.Bool("validate-only", false, "validate CSV without connecting to PostgreSQL")
if err := flags.Parse(args); err != nil {
return errors.New("invalid directory import arguments")
}
if strings.TrimSpace(*csvPath) == "" || strings.TrimSpace(*tenant) == "" {
return errors.New("-csv and -tenant are required")
}
input, err := os.Open(*csvPath)
if err != nil {
return errors.New("open directory CSV failed")
}
defer input.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if *validateOnly {
count, importErr := directory.ImportCSV(ctx, *tenant, input, validationWriter{})
if importErr != nil {
return errors.New("directory CSV validation failed")
}
_, _ = fmt.Fprintf(out, "validated_profiles=%d\n", count)
return nil
}
if strings.TrimSpace(*databaseURLFile) == "" {
return errors.New("-database-url-file is required for import")
}
databaseURL, err := readSecretFile(*databaseURLFile)
if err != nil {
return err
}
store, err := postgres.Open(ctx, databaseURL)
if err != nil {
return errors.New("open directory database failed")
}
defer store.Close()
count, err := directory.ImportCSV(ctx, *tenant, input, store)
if err != nil {
return errors.New("directory CSV import failed")
}
_, _ = fmt.Fprintf(out, "imported_profiles=%d\n", count)
return nil
}
type validationWriter struct{}
func (validationWriter) UpsertDirectoryProfiles(context.Context, string, []directory.Profile) error {
return nil
}
func readSecretFile(path string) (string, error) {
raw, err := os.ReadFile(strings.TrimSpace(path))
if err != nil { return "", errors.New("read database URL file failed") }
if len(raw) > 16<<10 { return "", errors.New("database URL file is too large") }
value := strings.TrimSpace(string(raw))
if value == "" { return "", errors.New("database URL file is empty") }
return value, nil
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestReadSecretFileTrimsValue(t *testing.T) {
path := filepath.Join(t.TempDir(), "database_url")
if err := os.WriteFile(path, []byte(" postgres://secret-value \n"), 0o600); err != nil { t.Fatal(err) }
value, err := readSecretFile(path)
if err != nil { t.Fatalf("readSecretFile: %v", err) }
if value != "postgres://secret-value" { t.Fatalf("value=%q", value) }
}
func TestReadSecretFileErrorDoesNotLeakContents(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty")
if err := os.WriteFile(path, []byte(""), 0o600); err != nil { t.Fatal(err) }
_, err := readSecretFile(path)
if err == nil { t.Fatal("expected error") }
if strings.Contains(err.Error(), "secret-value") { t.Fatalf("error leaked secret: %v", err) }
}
func TestRunValidateOnlyDoesNotRequireDatabase(t *testing.T) {
csvPath := filepath.Join(t.TempDir(), "directory.csv")
if err := os.WriteFile(csvPath, []byte("user_id,union_id,name,nickname,title,department_ids,dept_name\nstaff-1,,张三,,工程师,100,技术支持部\n"), 0o600); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
err := run([]string{"-csv", csvPath, "-tenant", "tenant-a", "-validate-only"}, &out)
if err != nil {
t.Fatalf("run validate-only: %v", err)
}
if !strings.Contains(out.String(), "validated_profiles=1") {
t.Fatalf("output=%q", out.String())
}
}
func TestRunRequiresCSVAndDatabaseURLFileBeforeConnecting(t *testing.T) {
var out bytes.Buffer
for _, args := range [][]string{{}, {"-csv", "snapshot.csv"}} {
err := run(args, &out)
if err == nil { t.Fatalf("run(%v) expected error", args) }
}
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"context"
cryptorand "crypto/rand"
"errors"
"fmt"
"io"
"math/big"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
const bootstrapAdminTTL = 10 * time.Minute
type bootstrapAdminRepository interface {
AdministratorCount(context.Context, string) (int, error)
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
ConfirmBootstrap(context.Context, string) error
}
var createBootstrapChallenge = func(ctx context.Context, repo bootstrapAdminRepository, tenant, code string, expiry time.Time) error {
return repo.CreateBootstrapChallenge(ctx, tenant, code, expiry)
}
func runBootstrapAdminCommand(ctx context.Context, output io.Writer) error {
tenant := strings.TrimSpace(os.Getenv("DOBOTAI_TENANT_KEY"))
store, err := postgres.Open(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
return err
}
defer store.Close()
repository, err := postgres.NewSettingsRepository(store)
if err != nil {
return err
}
return runBootstrapAdmin(ctx, output, repository, tenant, cryptorand.Reader, time.Now, time.Second)
}
func runBootstrapAdmin(ctx context.Context, output io.Writer, repository bootstrapAdminRepository, tenant string, random io.Reader, now func() time.Time, pollInterval time.Duration) error {
if repository == nil || strings.TrimSpace(tenant) == "" || random == nil || now == nil || pollInterval <= 0 {
return errors.New("bootstrap administrator command requires repository, tenant, random source, clock, and poll interval")
}
count, err := repository.AdministratorCount(ctx, tenant)
if err != nil {
return fmt.Errorf("check active administrators: %w", err)
}
if count > 0 {
return errors.New("an active administrator already exists; use administrator management instead")
}
codeNumber, err := cryptorand.Int(random, big.NewInt(1_000_000))
if err != nil {
return fmt.Errorf("generate bootstrap code: %w", err)
}
code := fmt.Sprintf("%06d", codeNumber.Int64())
expiresAt := now().Add(bootstrapAdminTTL)
if err := createBootstrapChallenge(ctx, repository, tenant, code, expiresAt); err != nil {
return fmt.Errorf("create bootstrap challenge: %w", err)
}
fmt.Fprintf(output, "首位管理员绑定已开始。\n绑定码:%s\n请在%s前私聊钉钉机器人发送:/setting %s\n终端正在等待绑定完成;按 Ctrl+C 取消。\n", code, expiresAt.Local().Format("2006-01-02 15:04:05"), code)
waitFor := expiresAt.Sub(now())
if waitFor <= 0 {
return errors.New("bootstrap binding expiry must be in the future")
}
waitCtx, cancel := context.WithTimeout(ctx, waitFor)
defer cancel()
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
err := repository.ConfirmBootstrap(waitCtx, tenant)
if err == nil {
fmt.Fprintln(output, "首位管理员绑定成功:候选用户已设为 super_admin。")
return nil
}
if !errors.Is(err, settings.ErrBootstrapCandidatePending) {
return fmt.Errorf("confirm bootstrap candidate: %w", err)
}
select {
case <-waitCtx.Done():
return errors.New("bootstrap binding expired or cancelled before a candidate was verified")
case <-ticker.C:
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type bootstrapAdminRepoStub struct {
administratorCount int
createdCode string
createdExpiry time.Time
confirmCalls int
}
func (r *bootstrapAdminRepoStub) AdministratorCount(context.Context, string) (int, error) {
return r.administratorCount, nil
}
func (r *bootstrapAdminRepoStub) CreateBootstrapChallenge(_ context.Context, _ string, code string, expiry time.Time) error {
r.createdCode, r.createdExpiry = code, expiry
return nil
}
func (r *bootstrapAdminRepoStub) ConfirmBootstrap(context.Context, string) error {
r.confirmCalls++
if r.confirmCalls == 1 {
return settings.ErrBootstrapCandidatePending
}
return nil
}
func TestRunBootstrapAdminGeneratesCodeWaitsAndConfirmsCandidate(t *testing.T) {
now := time.Date(2026, time.September, 10, 8, 0, 0, 0, time.UTC)
repo := &bootstrapAdminRepoStub{}
var output bytes.Buffer
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := runBootstrapAdmin(ctx, &output, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), func() time.Time { return now }, time.Millisecond)
if err != nil {
t.Fatal(err)
}
if repo.createdCode != "000000" {
t.Fatalf("code=%q", repo.createdCode)
}
if !repo.createdExpiry.Equal(now.Add(10 * time.Minute)) {
t.Fatalf("expiry=%s", repo.createdExpiry)
}
if repo.confirmCalls < 2 {
t.Fatalf("confirm calls=%d", repo.confirmCalls)
}
for _, required := range []string{"/setting 000000", "绑定成功", "super_admin"} {
if !strings.Contains(output.String(), required) {
t.Fatalf("output=%q missing %q", output.String(), required)
}
}
}
func TestRunBootstrapAdminRefusesWhenAdministratorAlreadyExists(t *testing.T) {
repo := &bootstrapAdminRepoStub{administratorCount: 1}
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("err=%v", err)
}
if repo.createdCode != "" || repo.confirmCalls != 0 {
t.Fatalf("repo mutated: %+v", repo)
}
}
func TestRunBootstrapAdminPropagatesChallengeCreationFailure(t *testing.T) {
repo := &bootstrapAdminRepoStub{}
original := createBootstrapChallenge
createBootstrapChallenge = func(context.Context, bootstrapAdminRepository, string, string, time.Time) error {
return errors.New("database unavailable")
}
t.Cleanup(func() { createBootstrapChallenge = original })
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "database unavailable") {
t.Fatalf("err=%v", err)
}
}
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"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 ""
}
}
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
)
func TestMainWiresDirectoryResolverIntoDingTalkHandler(t *testing.T) {
raw, err := os.ReadFile("main.go")
if err != nil {
t.Fatalf("read main.go: %v", err)
}
src := string(raw)
for _, required := range []string{
"directory.NewDingTalkLookup",
"directory.NewResolver",
} {
if !strings.Contains(src, required) {
t.Errorf("main.go missing %q", required)
}
}
if !regexp.MustCompile(`Directory:\s*directoryResolver`).MatchString(src) {
t.Error("main.go does not wire directoryResolver into HandlerOptions.Directory")
}
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"context"
"errors"
"log"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
type faqConfig struct {
BaseID string
SheetID string
ViewID string
OperatorID string
FieldMapping map[string]string
}
func readFAQConfig() (faqConfig, error) {
cfg := faqConfig{
BaseID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_BASE_ID")),
SheetID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_SHEET_ID")),
ViewID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_VIEW_ID")),
OperatorID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_OPERATOR_ID")),
}
blank := cfg.BaseID == "" && cfg.SheetID == "" && cfg.ViewID == "" && cfg.OperatorID == ""
complete := cfg.BaseID != "" && cfg.SheetID != "" && cfg.ViewID != "" && cfg.OperatorID != ""
if blank {
return faqConfig{}, nil
}
if !complete {
return faqConfig{}, errors.New("DingTalk FAQ table config must be complete or disabled")
}
return cfg, nil
}
func buildFAQDispatcher(repo faqtable.ExportRepository, clientID, clientSecret string, cfg faqConfig) (*faqtable.Dispatcher, error) {
if cfg.BaseID == "" {
return nil, nil
}
if repo == nil {
return nil, errors.New("FAQ dispatcher repository is required")
}
client, err := questiontable.NewClient(questiontable.Config{
ClientID: clientID, Secret: clientSecret, BaseID: cfg.BaseID,
SheetID: cfg.SheetID, OperatorID: cfg.OperatorID, FieldMapping: cfg.FieldMapping,
}, nil)
if err != nil {
return nil, err
}
return faqtable.NewDispatcher(repo, client, nil)
}
type faqPollerRunner interface {
RunOnce(context.Context) (faqtable.DispatcherStats, error)
}
func runFAQPoller(ctx context.Context, runner faqPollerRunner, interval time.Duration) {
if runner == nil || interval <= 0 {
return
}
run := func() {
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
stats, err := runner.RunOnce(runCtx)
cancel()
if err != nil {
log.Printf("FAQ table dispatch failed")
return
}
if stats.Claimed > 0 {
log.Printf("FAQ table dispatch: claimed=%d exported=%d failed=%d", stats.Claimed, stats.Exported, stats.Failed)
}
}
if ctx.Err() != nil {
return
}
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
type faqRepoStub struct{}
func (*faqRepoStub) Claim(context.Context, int, int, time.Time) ([]faqtable.PendingExport, error) {
return nil, nil
}
func (*faqRepoStub) MarkExported(context.Context, string, string) error { return nil }
func (*faqRepoStub) MarkFailed(context.Context, string, string) error { return nil }
func TestReadFAQConfigDisabledWhenAllBlank(t *testing.T) {
for _, key := range []string{"DINGTALK_FAQ_TABLE_BASE_ID", "DINGTALK_FAQ_TABLE_SHEET_ID", "DINGTALK_FAQ_TABLE_VIEW_ID", "DINGTALK_FAQ_TABLE_OPERATOR_ID"} {
t.Setenv(key, "")
}
cfg, err := readFAQConfig()
if err != nil || cfg.BaseID != "" {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
}
func TestReadFAQConfigRejectsPartialValues(t *testing.T) {
t.Setenv("DINGTALK_FAQ_TABLE_BASE_ID", "base")
t.Setenv("DINGTALK_FAQ_TABLE_SHEET_ID", "")
t.Setenv("DINGTALK_FAQ_TABLE_VIEW_ID", "view")
t.Setenv("DINGTALK_FAQ_TABLE_OPERATOR_ID", "operator")
if _, err := readFAQConfig(); err == nil {
t.Fatal("expected partial-config error")
}
}
func TestBuildFAQDispatcherRequiresRepositoryWhenEnabled(t *testing.T) {
cfg := faqConfig{BaseID: "base", SheetID: "sheet", ViewID: "view", OperatorID: "operator"}
if _, err := buildFAQDispatcher(nil, "client", "secret", cfg); err == nil {
t.Fatal("expected repository error")
}
if d, err := buildFAQDispatcher(&faqRepoStub{}, "client", "secret", cfg); err != nil || d == nil {
t.Fatalf("dispatcher=%v err=%v", d, err)
}
}
func TestMainWiresFAQExportBeforeSupportNotification(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
"readFAQConfig()", "postgres.NewFAQExportRepository(store)",
"buildFAQDispatcher(", "runFAQPoller(ctx, faqDispatcher, 5*time.Second)",
"faqtable.NewRecordLinkBuilder(", "buildSupportDispatcher(",
} {
if !strings.Contains(source, fragment) {
t.Errorf("main FAQ wiring missing %q", fragment)
}
}
if strings.Index(source, "runFAQPoller(ctx") > strings.Index(source, "runSupportDispatcher(ctx") {
t.Fatal("FAQ exporter must start before support notifications")
}
}
func TestEnvExampleDocumentsFAQWorkflowConfig(t *testing.T) {
data, err := os.ReadFile("../../.env.example")
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"DINGTALK_FAQ_TABLE_BASE_ID", "DINGTALK_FAQ_TABLE_SHEET_ID", "DINGTALK_FAQ_TABLE_VIEW_ID", "DINGTALK_FAQ_TABLE_OPERATOR_ID"} {
if !strings.Contains(string(data), key+"=") {
t.Errorf(".env.example missing %s", key)
}
}
}
+317 -12
View File
@@ -8,21 +8,49 @@ import (
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/agentbinding"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/httpapi"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/qa"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
var (
version = "dev"
commitSHA = "unknown"
buildTime = "unknown"
)
func runtimeBuildInfo() httpapi.BuildInfo {
return httpapi.BuildInfo{
Version: version,
CommitSHA: commitSHA,
BuildTime: buildTime,
}
}
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if len(os.Args) == 2 && os.Args[1] == "bootstrap-admin" {
if err := runBootstrapAdminCommand(ctx, os.Stdout); err != nil {
log.Fatal(err)
}
return
}
var restartRequested atomic.Bool
storeCtx, storeCancel := context.WithTimeout(ctx, 10*time.Second)
defer storeCancel()
store, err := postgres.Open(storeCtx, os.Getenv("DATABASE_URL"))
@@ -30,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)
}
@@ -38,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
@@ -45,25 +109,181 @@ func main() {
if groupPolicy != session.GroupShared && groupPolicy != session.GroupPerUser {
log.Fatal("DOBOTAI_GROUP_SESSION_POLICY must be shared or per_user")
}
qaService, err := qa.New(store, bindings, client, groupPolicy)
coverageService, err := coverage.NewService(store)
if err != nil {
log.Fatal(err)
}
api, err := httpapi.New(qaService, os.Getenv("DOBOTAI_INGRESS_TOKEN"))
qaService, err := qa.New(
store,
bindings,
client,
groupPolicy,
qa.WithSucceededTurnObserver(coverageService, func(err error) {
log.Printf("coverage persistence failed: %v", err)
}),
qa.WithReasoningModeFinalizer(reasoningService, func(err error) {
log.Printf("reasoning mode completion failed: %T", err)
}),
)
if err != nil {
log.Fatal(err)
}
auditOptions, err := buildAuditOptions(store, os.Getenv("DOBOTAI_TENANT_KEY"), os.Getenv("DOBOTAI_ADMIN_TOKEN"), address)
if err != nil {
log.Fatal(err)
}
auditOptions.BuildInfo = runtimeBuildInfo()
api, err := httpapi.NewWithOptions(qaService, os.Getenv("DOBOTAI_INGRESS_TOKEN"), auditOptions)
if err != nil {
log.Fatal(err)
}
dingCfg := dingtalk.StreamConfig{
ClientID: os.Getenv("DINGTALK_CLIENT_ID"),
ClientSecret: os.Getenv("DINGTALK_CLIENT_SECRET"),
ClientID: settingsRuntime.clientID,
ClientSecret: settingsRuntime.secret,
TenantKey: os.Getenv("DOBOTAI_TENANT_KEY"),
}
if err := dingCfg.Validate(); err != nil {
log.Fatal(err)
}
activeBindings, err := settingsRuntime.repository.ActiveBindings(storeCtx, dingCfg.TenantKey)
if err != nil {
log.Fatal(err)
}
runtimeReady := make(chan struct{})
faqCfg, err := readFAQConfig()
if err != nil {
log.Fatal(err)
}
if binding, ok := activeBindings["faq_table"]; ok {
faqCfg = faqConfig{BaseID: binding.BaseID, SheetID: binding.SheetID, ViewID: binding.ViewID, OperatorID: binding.OperatorID, FieldMapping: binding.FieldMapping}
}
var faqWorkflowDone chan struct{}
var faqRecordLinks *faqtable.RecordLinkBuilder
if faqCfg.BaseID != "" {
faqRecordLinks, err = faqtable.NewRecordLinkBuilder(faqCfg.BaseID, faqCfg.SheetID, faqCfg.ViewID)
if err != nil {
log.Fatal(err)
}
faqRepo, err := postgres.NewFAQExportRepository(store)
if err != nil {
log.Fatal(err)
}
faqDispatcher, err := buildFAQDispatcher(faqRepo, dingCfg.ClientID, dingCfg.ClientSecret, faqCfg)
if err != nil {
log.Fatalf("DingTalk FAQ table dispatcher init failed: %v", err)
}
faqWorkflowDone = make(chan struct{})
go func() {
defer close(faqWorkflowDone)
select {
case <-runtimeReady:
case <-ctx.Done():
return
}
runFAQPoller(ctx, faqDispatcher, 5*time.Second)
}()
log.Printf("DingTalk FAQ table dispatcher started")
}
supportConversationID := os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID")
if binding, ok := activeBindings["support_group"]; ok {
supportConversationID = binding.ConversationID
}
supportDispatcher, err := buildSupportDispatcher(
store,
dingCfg.ClientID,
dingCfg.ClientSecret,
supportConversationID,
faqRecordLinks,
)
if err != nil {
log.Fatalf("DingTalk support notification init failed: %v", err)
}
if supportDispatcher != nil {
go func() {
select {
case <-runtimeReady:
case <-ctx.Done():
return
}
runSupportDispatcher(ctx, supportDispatcher, 5*time.Second)
}()
log.Printf("DingTalk support notification dispatcher started")
}
// Outbound image runtime (stable-image publishing) is built from environment
// variables independently of the Stream ingress, so both the question-table
// answer rewriter and the inbound-image publisher can share it.
outboundImages, err := buildOutboundImageRuntime(outboundImageConfig{
WeKnoraBaseURL: os.Getenv("WEKNORA_BASE_URL"),
WeKnoraAPIKey: os.Getenv("WEKNORA_API_KEY"),
Endpoint: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
Region: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
AccessKeyID: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
AccessKeySecret: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
Bucket: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"),
PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL"),
})
if err != nil {
log.Fatalf("DingTalk outbound image init failed: %v", err)
}
// Question table dispatcher (optional, fail-closed).
qtCfg, err := readQuestionTableConfig()
if err != nil {
log.Fatal(err)
}
if binding, ok := activeBindings["question_table"]; ok {
qtCfg = questionTableConfig{BaseID: binding.BaseID, SheetID: binding.SheetID, OperatorID: binding.OperatorID, FieldMapping: binding.FieldMapping}
}
store.SetExternalTableExports(qtCfg.BaseID != "", faqCfg.BaseID != "")
var questionTableDone chan struct{}
if qtCfg.BaseID != "" {
qtRepo, err := postgres.NewQuestionTableExportRepository(store)
if err != nil {
log.Fatal(err)
}
qtDispatcher, err := buildQuestionTableDispatcher(qtRepo, dingCfg.ClientID, dingCfg.ClientSecret, qtCfg, answerRewriterAdapter{rewriter: outboundImages.rewriter})
if err != nil {
log.Fatalf("DingTalk question table dispatcher init failed: %v", err)
}
questionTableDone = make(chan struct{})
go func() {
defer close(questionTableDone)
select {
case <-runtimeReady:
case <-ctx.Done():
return
}
runQuestionTablePoller(ctx, qtDispatcher, 5*time.Second)
}()
log.Printf("DingTalk question table dispatcher started")
}
var streamClient *dingtalk.StreamClient
if dingCfg.Enabled() {
directoryLookup, err := directory.NewDingTalkLookup(directory.DingTalkLookupConfig{
TokenURL: "https://oapi.dingtalk.com/gettoken",
APIBaseURL: "https://oapi.dingtalk.com",
ClientID: dingCfg.ClientID,
ClientSecret: dingCfg.ClientSecret,
Timeout: 10 * time.Second,
})
if err != nil {
log.Fatalf("DingTalk directory lookup init failed: %v", err)
}
directoryResolver, err := directory.NewResolver(store, directoryLookup)
if err != nil {
log.Fatalf("DingTalk directory resolver init failed: %v", err)
}
feedbackService, err := feedback.NewService(dingCfg.TenantKey, "dingtalk", store)
if err != nil {
log.Fatal(err)
}
feedbackHandler, err := dingtalk.NewCardFeedbackHandler(feedbackService)
if err != nil {
log.Fatal(err)
}
sender, err := dingtalk.NewWebhookSender(nil)
if err != nil {
log.Fatal(err)
@@ -74,22 +294,46 @@ 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)
}
streamClient, err = dingtalk.NewStreamClient(dingCfg, handler)
streamClient, err = dingtalk.NewStreamClient(dingCfg, handler, feedbackHandler)
if err != nil {
log.Fatal(err)
}
@@ -97,13 +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)
}
address := strings.TrimSpace(os.Getenv("DOBOTAI_LISTEN_ADDR"))
if address == "" {
address = "127.0.0.1:8090"
close(runtimeReady)
rootHandler := http.NewServeMux()
rootHandler.Handle("/", api.Handler())
if outboundImages.handler != nil {
rootHandler.Handle(outboundImageRoute, outboundImages.handler)
}
server := &http.Server{Addr: address, Handler: api.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
server := &http.Server{Addr: address, Handler: rootHandler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("DobotAI ingress listening on %s", address)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -117,4 +367,59 @@ func main() {
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP shutdown: %v", err)
}
if faqWorkflowDone != nil {
select {
case <-faqWorkflowDone:
case <-shutdownCtx.Done():
log.Printf("FAQ table poller shutdown timed out")
}
}
if questionTableDone != nil {
select {
case <-questionTableDone:
case <-shutdownCtx.Done():
log.Printf("question table poller shutdown timed out")
}
}
if streamClient != nil {
streamClient.Close()
}
if restartRequested.Load() {
os.Exit(75)
}
}
func outboundOSSConfigured() bool {
for _, key := range []string{
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT", "DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID", "DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET", "DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL",
} {
if strings.TrimSpace(os.Getenv(key)) == "" {
return false
}
}
return true
}
func buildAuditOptions(repo audit.TurnRepository, tenantKey, adminToken, address string) (httpapi.Options, error) {
adminToken = strings.TrimSpace(adminToken)
if adminToken == "" {
return httpapi.Options{}, nil
}
if repo == nil {
return httpapi.Options{}, errors.New("audit repository is required when DOBOTAI_ADMIN_TOKEN is set")
}
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" {
return httpapi.Options{}, errors.New("DOBOTAI_TENANT_KEY is required when DOBOTAI_ADMIN_TOKEN is set")
}
if err := httpapi.ValidateLoopbackListenAddress(address); err != nil {
return httpapi.Options{}, err
}
service, err := audit.NewService(tenantKey, repo)
if err != nil {
return httpapi.Options{}, err
}
return httpapi.Options{AuditService: service, AdminToken: adminToken}, nil
}
+251
View File
@@ -0,0 +1,251 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
)
type auditRepoStub struct {
tenant string
}
func (r *auditRepoStub) ListTurns(_ context.Context, tenant string, _ audit.ListQuery) ([]audit.TurnSummary, error) {
r.tenant = tenant
return nil, nil
}
func (r *auditRepoStub) GetTurn(context.Context, string, string) (audit.TurnDetail, error) {
return audit.TurnDetail{}, audit.ErrNotFound
}
func TestRuntimeBuildInfoUsesLinkerVariables(t *testing.T) {
oldVersion, oldCommitSHA, oldBuildTime := version, commitSHA, buildTime
t.Cleanup(func() {
version, commitSHA, buildTime = oldVersion, oldCommitSHA, oldBuildTime
})
version = "2.1.0"
commitSHA = "abc123def456"
buildTime = "2026-07-27T12:00:00Z"
got := runtimeBuildInfo()
if got.Version != version || got.CommitSHA != commitSHA || got.BuildTime != buildTime {
t.Fatalf("build info=%+v", got)
}
}
func TestBuildAuditOptionsDisabledIsBackwardCompatible(t *testing.T) {
opts, err := buildAuditOptions(nil, "", "", "0.0.0.0:8090")
if err != nil {
t.Fatal(err)
}
if opts.AuditService != nil || opts.AdminToken != "" {
t.Fatalf("options=%+v", opts)
}
}
func TestBuildAuditOptionsRequiresTenantRepositoryAndLoopback(t *testing.T) {
repo := &auditRepoStub{}
for _, tc := range []struct {
name string
repo audit.TurnRepository
tenant string
address string
}{
{name: "missing repository", tenant: "tenant", address: "127.0.0.1:8090"},
{name: "missing tenant", repo: repo, address: "127.0.0.1:8090"},
{name: "public listener", repo: repo, tenant: "tenant", address: "0.0.0.0:8090"},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := buildAuditOptions(tc.repo, tc.tenant, "admin-secret", tc.address); err == nil {
t.Fatal("expected configuration error")
}
})
}
}
func TestBuildAuditOptionsFixesServerSideTenant(t *testing.T) {
repo := &auditRepoStub{}
opts, err := buildAuditOptions(repo, " tenant-a ", " admin-secret ", "127.0.0.1:8090")
if err != nil {
t.Fatal(err)
}
if opts.AdminToken != "admin-secret" || opts.AuditService == nil {
t.Fatalf("options=%+v", opts)
}
if _, err := opts.AuditService.ListTurns(context.Background(), audit.ListTurnsRequest{}); err != nil {
t.Fatal(err)
}
if repo.tenant != "tenant-a" {
t.Fatalf("tenant=%q", repo.tenant)
}
}
func TestMainWiresFeedbackIntoDingTalkStream(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"`,
`feedback.NewService(dingCfg.TenantKey, "dingtalk", store)`,
`dingtalk.NewCardFeedbackHandler(feedbackService)`,
`dingtalk.NewStreamClient(dingCfg, handler, feedbackHandler)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main feedback wiring missing %q", fragment)
}
}
}
func TestMainWiresOptionalDingTalkCardDebugUser(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`os.Getenv("DINGTALK_CARD_DEBUG_USER_ID")`,
`dingtalk.WithCardDebugUserID(cardDebugUserID)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main card debug wiring missing %q", fragment)
}
}
}
func TestBuildOutboundImageRuntimeConfig(t *testing.T) {
t.Run("disabled", func(t *testing.T) {
runtime, err := buildOutboundImageRuntime(outboundImageConfig{})
if err != nil || runtime.rewriter != nil || runtime.handler != nil {
t.Fatalf("runtime=%+v err=%v", runtime, err)
}
})
t.Run("partial configuration rejected", func(t *testing.T) {
_, err := buildOutboundImageRuntime(outboundImageConfig{Bucket: "private-bucket"})
if err == nil {
t.Fatal("expected configuration error")
}
})
t.Run("complete configuration", func(t *testing.T) {
runtime, err := buildOutboundImageRuntime(outboundImageConfig{
WeKnoraBaseURL: "http://127.0.0.1:8080",
WeKnoraAPIKey: "api-key",
Endpoint: "https://oss-cn-shenzhen.aliyuncs.com",
Region: "cn-shenzhen",
AccessKeyID: "access-key-id",
AccessKeySecret: "access-key-secret",
Bucket: "private-bucket",
PublicBaseURL: "https://weknora-api-test.dobot.cn/dingtalk/i",
})
if err != nil || runtime.rewriter == nil || runtime.handler == nil {
t.Fatalf("runtime=%+v err=%v", runtime, err)
}
})
}
func TestMainWiresStableOutboundImageRuntime(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`buildOutboundImageRuntime(outboundImageConfig{`,
`PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL")`,
`OutboundRewriter: outboundImages.rewriter`,
`rootHandler.Handle(outboundImageRoute, outboundImages.handler)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main stable image wiring missing %q", fragment)
}
}
}
func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
for _, key := range []string{
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT",
"DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET",
} {
t.Setenv(key, "configured")
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "")
if outboundOSSConfigured() {
t.Fatal("stable public base URL must be required")
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "https://img.example/dingtalk/i")
if !outboundOSSConfigured() {
t.Fatal("complete stable image configuration should be active")
}
}
func 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")
}
}
+201
View File
@@ -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
}
+164
View File
@@ -0,0 +1,164 @@
package main
import (
"context"
"errors"
"log"
"os"
"reflect"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
// questionTablePollerRunner is the interface expected by the poller loop.
type questionTablePollerRunner interface {
RunOnce(context.Context) (questiontable.DispatcherStats, error)
}
// questionTableConfig holds the optional Notable question-table env values.
type questionTableConfig struct {
BaseID string
SheetID string
OperatorID string
FieldMapping map[string]string
}
// readQuestionTableConfig reads the three dedicated env vars and returns
// a validated config. All three blank => disabled (no error). Any partial
// configuration => stable configuration error (no values echoed).
func readQuestionTableConfig() (questionTableConfig, error) {
cfg := questionTableConfig{
BaseID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_BASE_ID")),
SheetID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_SHEET_ID")),
OperatorID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID")),
}
allBlank := cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == ""
allSet := cfg.BaseID != "" && cfg.SheetID != "" && cfg.OperatorID != ""
if allBlank {
return questionTableConfig{}, nil
}
if allSet {
return cfg, nil
}
return questionTableConfig{}, errors.New("DingTalk question table config: all three env vars must be set or all blank (DINGTALK_QUESTION_TABLE_BASE_ID, DINGTALK_QUESTION_TABLE_SHEET_ID, DINGTALK_QUESTION_TABLE_OPERATOR_ID)")
}
// buildQuestionTableDispatcher creates a validated question-table dispatcher
// when the repo is non-nil and all required credentials are present.
// The optional answer rewriter converts the stored answer into the same
// outbound form used for DingTalk delivery (resource images to stable HTTPS,
// document links, anchor cleanup). No HTTP request occurs at build time.
// It returns (nil, nil) when the question-table feature is disabled.
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig, rewriter questiontable.AnswerRewriter) (*questiontable.Dispatcher, error) {
// Disabled: all blank config.
if cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == "" {
return nil, nil
}
if repo == nil {
return nil, errors.New("question table dispatcher: repository is required")
}
client, err := questiontable.NewClient(questiontable.Config{
ClientID: clientID,
Secret: clientSecret,
BaseID: cfg.BaseID,
SheetID: cfg.SheetID,
OperatorID: cfg.OperatorID,
FieldMapping: cfg.FieldMapping,
}, nil)
if err != nil {
return nil, err
}
return questiontable.NewDispatcher(repo, client, &questiontable.DispatcherOptions{
Rewriter: rewriter,
SendTimeout: 60 * time.Second,
})
}
// answerRewriterAdapter adapts a blocking outbound content rewriter to the
// AnswerRewriter contract (caller never gets an error, so the original answer
// is always written when conversion cannot run).
type answerRewriterAdapter struct {
rewriter dingtalk.OutboundContentRewriter
}
func (a answerRewriterAdapter) Rewrite(ctx context.Context, content string) (string, error) {
if a.rewriter == nil {
return content, nil
}
return a.rewriter.Rewrite(ctx, content), nil
}
// runQuestionTablePoller runs the dispatcher immediately, then every interval.
// Non-positive interval or nil/typed-nil runner returns without calling.
// Continues after a RunOnce error. Exits promptly on context cancellation.
// Logging is safe: success may log only Claimed/Exported/Failed integers;
// errors log stable stage text only, never raw errors.
func runQuestionTablePoller(ctx context.Context, runner questionTablePollerRunner, interval time.Duration) {
if runner == nil || interval <= 0 {
return
}
// Guard against typed nil.
if isNilPoller(runner) {
return
}
runOnce := func() {
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
stats, err := runner.RunOnce(runCtx)
cancel()
if err != nil {
log.Printf("question table dispatch failed")
return
}
if stats.Claimed > 0 || stats.Exported > 0 || stats.Failed > 0 {
log.Printf("question table dispatch: claimed=%d exported=%d failed=%d", stats.Claimed, stats.Exported, stats.Failed)
}
}
if ctx.Err() != nil {
return
}
runOnce()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runOnce()
}
}
}
// isNilPoller returns true when runner is a typed nil.
func isNilPoller(runner questionTablePollerRunner) bool {
if runner == nil {
return true
}
// reflect-based typed-nil check for the interface.
return isInterfaceNil(runner)
}
// isInterfaceNil returns true when v is a typed nil wrapped in an interface.
func isInterfaceNil(v interface{}) bool {
if v == nil {
return true
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return rv.IsNil()
default:
return false
}
}
+377
View File
@@ -0,0 +1,377 @@
package main
import (
"context"
"errors"
"log"
"os"
"strings"
"sync"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
// ---------------------------------------------------------------------------
// Test 1: disabled all blank returns nil/no error
// ---------------------------------------------------------------------------
func TestReadQuestionTableConfigDisabledAllBlank(t *testing.T) {
t.Setenv("DINGTALK_QUESTION_TABLE_BASE_ID", "")
t.Setenv("DINGTALK_QUESTION_TABLE_SHEET_ID", "")
t.Setenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID", "")
cfg, err := readQuestionTableConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.BaseID != "" || cfg.SheetID != "" || cfg.OperatorID != "" {
t.Errorf("expected empty config, got %+v", cfg)
}
}
func TestBuildQuestionTableDispatcherDisabled(t *testing.T) {
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d != nil {
t.Errorf("expected nil dispatcher for disabled config, got %v", d)
}
}
// ---------------------------------------------------------------------------
// Test 2: every partial combination errors and does not echo sentinel config values
// ---------------------------------------------------------------------------
func TestReadQuestionTableConfigPartialErrors(t *testing.T) {
tests := []struct {
name string
baseID string
sheetID string
operator string
}{
{"only base", "base-1", "", ""},
{"only sheet", "", "sheet-1", ""},
{"only operator", "", "", "op-1"},
{"base+sheet", "base-1", "sheet-1", ""},
{"base+operator", "base-1", "", "op-1"},
{"sheet+operator", "", "sheet-1", "op-1"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DINGTALK_QUESTION_TABLE_BASE_ID", tc.baseID)
t.Setenv("DINGTALK_QUESTION_TABLE_SHEET_ID", tc.sheetID)
t.Setenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID", tc.operator)
_, err := readQuestionTableConfig()
if err == nil {
t.Fatal("expected error for partial config")
}
errStr := err.Error()
// Must not echo any sentinel config value.
for _, sentinel := range []string{"base-1", "sheet-1", "op-1"} {
if strings.Contains(errStr, sentinel) {
t.Errorf("error must not echo sentinel value %q: %q", sentinel, errStr)
}
}
})
}
}
// ---------------------------------------------------------------------------
// Test 3: enabled missing repo/client/secret fails closed; complete config builds
// ---------------------------------------------------------------------------
func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg, nil)
if err == nil {
t.Fatal("expected error when repo is nil")
}
if !strings.Contains(err.Error(), "repository is required") {
t.Errorf("error = %v, want 'repository is required'", err)
}
}
func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg, nil)
if err == nil {
t.Fatal("expected error when client id is empty")
}
}
func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg, nil)
if err == nil {
t.Fatal("expected error when secret is empty")
}
}
func TestBuildQuestionTableDispatcherEnabledCompleteConfig(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d == nil {
t.Fatal("expected non-nil dispatcher for complete config")
}
}
// ---------------------------------------------------------------------------
// Test 4: immediate run + periodic repeat + cancel stop
// ---------------------------------------------------------------------------
func TestRunQuestionTablePollerImmediateAndPeriodic(t *testing.T) {
runner := &countingPollerRunner{}
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
}()
// Wait for at least 2 calls (immediate + one tick).
time.Sleep(120 * time.Millisecond)
cancel()
wg.Wait()
runner.mu.Lock()
calls := runner.calls
runner.mu.Unlock()
if calls < 2 {
t.Errorf("expected at least 2 calls, got %d", calls)
}
}
func TestRunQuestionTablePollerCancelledContext(t *testing.T) {
runner := &countingPollerRunner{}
ctx, cancel := context.WithCancel(context.Background())
cancel() // already cancelled
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
runner.mu.Lock()
calls := runner.calls
runner.mu.Unlock()
if calls != 0 {
t.Errorf("expected 0 calls (cancelled context), got %d", calls)
}
}
// ---------------------------------------------------------------------------
// Test 5: continue after error and log/output security contract
// ---------------------------------------------------------------------------
func TestRunQuestionTablePollerContinuesAfterError(t *testing.T) {
runner := &errorThenSuccessPoller{}
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runQuestionTablePoller(ctx, runner, 30*time.Millisecond)
}()
time.Sleep(120 * time.Millisecond)
cancel()
wg.Wait()
runner.mu.Lock()
calls := runner.calls
runner.mu.Unlock()
if calls < 2 {
t.Errorf("expected at least 2 calls after error, got %d", calls)
}
}
// Test logging contract: the runOnce function in runQuestionTablePoller logs
// only stable stage text for errors (never raw errors via %v) and only
// Claimed/Exported/Failed integers for success.
func TestPollerLoggingContract(t *testing.T) {
// Behavioral log-capture test: runner returns an error containing unique
// sentinel values; the captured log must contain the stable stage text
// and none of those sentinels.
sentinel := "s3nt1n3l-op3r4t0r-t0k3n-q"
runner := &sentinelErrorPoller{errMsg: "questiontable dispatcher: " + sentinel}
ctx, cancel := context.WithCancel(context.Background())
// Save and restore global log output.
originalOutput := log.Writer()
originalFlags := log.Flags()
var buf strings.Builder
log.SetOutput(&buf)
log.SetFlags(0)
defer func() {
log.SetOutput(originalOutput)
log.SetFlags(originalFlags)
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
}()
time.Sleep(120 * time.Millisecond)
cancel()
wg.Wait()
output := buf.String()
if !strings.Contains(output, "question table dispatch failed") {
t.Errorf("log must contain stable stage text, got: %s", output)
}
if strings.Contains(output, sentinel) {
t.Errorf("log must not contain sentinel %q, got: %s", sentinel, output)
}
}
// sentinelErrorPoller always returns an error with the given message.
type sentinelErrorPoller struct {
errMsg string
}
func (r *sentinelErrorPoller) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
return questiontable.DispatcherStats{}, errors.New(r.errMsg)
}
// ---------------------------------------------------------------------------
// Test 6: nil/typed-nil runner and nonpositive interval safe
// ---------------------------------------------------------------------------
func TestRunQuestionTablePollerNilRunner(t *testing.T) {
ctx := context.Background()
// Should not panic, just return immediately.
runQuestionTablePoller(ctx, nil, 50*time.Millisecond)
// If we reach here without panic, test passes.
}
func TestRunQuestionTablePollerNonPositiveInterval(t *testing.T) {
runner := &countingPollerRunner{}
ctx := context.Background()
runQuestionTablePoller(ctx, runner, 0)
runQuestionTablePoller(ctx, runner, -1*time.Second)
runner.mu.Lock()
calls := runner.calls
runner.mu.Unlock()
if calls != 0 {
t.Errorf("expected 0 calls for non-positive interval, got %d", calls)
}
}
func TestRunQuestionTablePollerTypedNilInterface(t *testing.T) {
// The runner is nil through the interface but not at the concrete type level.
var typedNil *questiontable.Dispatcher = nil
var runner questionTablePollerRunner = typedNil
ctx := context.Background()
// Should not panic, just return immediately.
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
}
// ---------------------------------------------------------------------------
// Test 7: main source wiring contract
// ---------------------------------------------------------------------------
func TestMainWiresQuestionTableEnvNames(t *testing.T) {
mainData, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
syncData, err := os.ReadFile("question_table_sync.go")
if err != nil {
t.Fatal(err)
}
mainSource := string(mainData)
syncSource := string(syncData)
for _, fragment := range []string{
`"DINGTALK_QUESTION_TABLE_BASE_ID"`,
`"DINGTALK_QUESTION_TABLE_SHEET_ID"`,
`"DINGTALK_QUESTION_TABLE_OPERATOR_ID"`,
} {
if !strings.Contains(syncSource, fragment) {
t.Errorf("question table config reader missing %q", fragment)
}
}
for _, fragment := range []string{
`NewQuestionTableExportRepository`,
`5*time.Second`,
`"DingTalk question table dispatcher started"`,
} {
if !strings.Contains(mainSource, fragment) {
t.Errorf("main wiring missing %q", fragment)
}
}
shutdownAt := strings.Index(mainSource, "server.Shutdown(shutdownCtx)")
waitAt := strings.Index(mainSource, "case <-questionTableDone:")
if shutdownAt < 0 || waitAt < 0 || waitAt <= shutdownAt {
t.Fatal("main must wait for question table poller after HTTP shutdown and before returning")
}
}
// ---------------------------------------------------------------------------
// Test doubles
// ---------------------------------------------------------------------------
// questionTableExportRepoStub implements questiontable.ExportRepository.
type questionTableExportRepoStub struct{}
func (r *questionTableExportRepoStub) Claim(ctx context.Context, limit int, maxAttempts int, staleBefore time.Time) ([]questiontable.PendingExport, error) {
return nil, nil
}
func (r *questionTableExportRepoStub) MarkExported(ctx context.Context, exportID, location string) error {
return nil
}
func (r *questionTableExportRepoStub) MarkFailed(ctx context.Context, exportID, failureCode string) error {
return nil
}
// countingPollerRunner counts calls to RunOnce.
type countingPollerRunner struct {
mu sync.Mutex
calls int
}
func (r *countingPollerRunner) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
r.mu.Lock()
r.calls++
r.mu.Unlock()
return questiontable.DispatcherStats{}, nil
}
// errorThenSuccessPoller returns an error on the first call, then succeeds.
type errorThenSuccessPoller struct {
mu sync.Mutex
calls int
}
func (r *errorThenSuccessPoller) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
r.mu.Lock()
r.calls++
call := r.calls
r.mu.Unlock()
if call == 1 {
return questiontable.DispatcherStats{}, errors.New("questiontable dispatcher: claim failed")
}
return questiontable.DispatcherStats{Claimed: 1, Exported: 1}, nil
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"context"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
type dingTalkReasoningRouter struct {
service *reasoningmode.Service
}
func (r *dingTalkReasoningRouter) Prepare(ctx context.Context, message dingtalk.ReasoningMessage) (dingtalk.ReasoningDecision, error) {
decision, err := r.service.Prepare(ctx, reasoningmode.Scope{
TenantKey: message.TenantKey, Channel: message.Channel, ExternalActorID: message.StaffID,
}, message.Text)
if err != nil {
return dingtalk.ReasoningDecision{}, err
}
return dingtalk.ReasoningDecision{
Handled: decision.Handled, Reply: decision.Reply, Query: decision.Query,
AgentID: decision.AgentID, Execution: decision.Execution, Expired: decision.Expired,
}, nil
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"os"
"strings"
"testing"
)
func TestMainWiresReasoningModeRoutingAndCompletion(t *testing.T) {
mainSource, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
wiringSource, err := os.ReadFile("reasoning_wiring.go")
if err != nil {
t.Fatal(err)
}
mainText := string(mainSource)
for _, required := range []string{
`os.Getenv("DOBOTAI_QUICK_AGENT_ID")`,
`os.Getenv("DOBOTAI_REASONING_AGENT_ID")`,
`reasoningmode.NewService`,
`qa.WithReasoningModeFinalizer`,
`ReasoningRouter:`,
} {
if !strings.Contains(mainText, required) {
t.Errorf("main reasoning wiring missing %q", required)
}
}
for _, required := range []string{
"dingTalkReasoningRouter",
"ReasoningMessage",
"service.Prepare",
} {
if !strings.Contains(string(wiringSource), required) {
t.Errorf("reasoning adapter missing %q", required)
}
}
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"errors"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
type validatedSettingsRepository struct {
*postgres.SettingsRepository
clientID string
secret string
testSupport func(context.Context, settings.ResourceBinding) error
validateTable func(context.Context, settings.ResourceBinding) (map[string]string, error)
saveBinding func(context.Context, settings.ResourceBinding) error
restart func()
restartDelay time.Duration
}
func (r *validatedSettingsRepository) TestAndActivateSupportGroup(ctx context.Context, binding settings.ResourceBinding) error {
if r.testSupport != nil {
if err := r.testSupport(ctx, binding); err != nil {
return errors.New("support_group_test_failed")
}
} else {
sender, err := dingtalk.NewSupportGroupSender(r.clientID, r.secret, binding.ConversationID, nil)
if err != nil {
return errors.New("support_group_test_invalid_config")
}
if err = sender.SendTest(ctx); err != nil {
return errors.New("support_group_test_failed")
}
}
binding.ValidationCode = "support_test_ok"
binding.Activate = true
var err error
if r.saveBinding != nil {
err = r.saveBinding(ctx, binding)
} else if r.SettingsRepository != nil {
err = r.SettingsRepository.SaveBinding(ctx, binding)
} else {
err = errors.New("support_group_binding_repository_unavailable")
}
if err != nil {
return err
}
if r.restart != nil {
go func() {
if r.restartDelay > 0 {
time.Sleep(r.restartDelay)
}
r.restart()
}()
}
return nil
}
func (r *validatedSettingsRepository) SaveBinding(ctx context.Context, binding settings.ResourceBinding) error {
activate := false
switch binding.Kind {
case "question_table", "faq_table":
var mapping map[string]string
var err error
if r.validateTable != nil {
mapping, err = r.validateTable(ctx, binding)
} else {
mapping, err = r.readAndMapTable(ctx, binding)
}
if err != nil {
return err
}
binding.FieldMapping = mapping
binding.ValidationCode = "schema_ok"
binding.Activate = true
activate = true
case "support_group":
binding.ValidationCode = "pending_test_message"
binding.Activate = false
default:
return errors.New("unsupported_binding_kind")
}
var err error
if r.saveBinding != nil {
err = r.saveBinding(ctx, binding)
} else if r.SettingsRepository != nil {
err = r.SettingsRepository.SaveBinding(ctx, binding)
} else {
err = errors.New("binding_repository_unavailable")
}
if err != nil {
return err
}
if activate {
r.requestRestart()
}
return nil
}
func (r *validatedSettingsRepository) readAndMapTable(ctx context.Context, binding settings.ResourceBinding) (map[string]string, error) {
client, err := questiontable.NewClient(questiontable.Config{
ClientID: r.clientID, Secret: r.secret, BaseID: binding.BaseID,
SheetID: binding.SheetID, OperatorID: binding.OperatorID,
}, nil)
if err != nil {
return nil, errors.New("table_binding_invalid_config")
}
fields, err := client.ReadFields(ctx)
if err != nil {
return nil, err
}
required := questiontable.QuestionSchema()
if binding.Kind == "faq_table" {
required = questiontable.FAQSchema()
}
return questiontable.MapRequiredFields(fields, required)
}
func (r *validatedSettingsRepository) requestRestart() {
if r.restart == nil {
return
}
go func() {
if r.restartDelay > 0 {
time.Sleep(r.restartDelay)
}
r.restart()
}()
}
@@ -0,0 +1,117 @@
package main
import (
"context"
"errors"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
func TestTableBindingRestartsOnlyAfterActiveSave(t *testing.T) {
restarted := make(chan struct{}, 1)
var saved settings.ResourceBinding
repo := &validatedSettingsRepository{
validateTable: func(_ context.Context, binding settings.ResourceBinding) (map[string]string, error) {
return map[string]string{"answer": "field-answer"}, nil
},
saveBinding: func(_ context.Context, binding settings.ResourceBinding) error {
saved = binding
return nil
},
restart: func() { restarted <- struct{}{} },
restartDelay: 0,
}
if err := repo.SaveBinding(context.Background(), settings.ResourceBinding{Kind: "question_table", TenantKey: "tenant", BaseID: "base", SheetID: "sheet", OperatorID: "operator"}); err != nil {
t.Fatal(err)
}
if !saved.Activate || saved.ValidationCode != "schema_ok" || saved.FieldMapping["answer"] != "field-answer" {
t.Fatalf("binding=%+v", saved)
}
select {
case <-restarted:
case <-time.After(50 * time.Millisecond):
t.Fatal("restart not requested")
}
}
func TestTableBindingDoesNotRestartOnValidationOrSaveFailure(t *testing.T) {
for _, tc := range []struct {
name string
validateErr error
saveErr error
}{
{name: "validation failure", validateErr: errors.New("schema failed")},
{name: "save failure", saveErr: errors.New("save failed")},
} {
t.Run(tc.name, func(t *testing.T) {
restarted := make(chan struct{}, 1)
repo := &validatedSettingsRepository{
validateTable: func(context.Context, settings.ResourceBinding) (map[string]string, error) {
return map[string]string{"answer": "field"}, tc.validateErr
},
saveBinding: func(context.Context, settings.ResourceBinding) error { return tc.saveErr },
restart: func() { restarted <- struct{}{} },
}
if err := repo.SaveBinding(context.Background(), settings.ResourceBinding{Kind: "question_table"}); err == nil {
t.Fatal("expected failure")
}
select {
case <-restarted:
t.Fatal("must not restart after failed activation")
case <-time.After(50 * time.Millisecond):
}
})
}
}
func TestSupportGroupActivationRestartsOnlyAfterTestAndSaveSucceed(t *testing.T) {
for _, tc := range []struct {
name string
testErr error
saveErr error
wantSaved bool
wantRestart bool
}{
{name: "test fails", testErr: errors.New("send failed")},
{name: "save fails", saveErr: errors.New("save failed"), wantSaved: true},
{name: "success", wantSaved: true, wantRestart: true},
} {
t.Run(tc.name, func(t *testing.T) {
var saved settings.ResourceBinding
restarted := make(chan struct{}, 1)
repo := &validatedSettingsRepository{
testSupport: func(context.Context, settings.ResourceBinding) error { return tc.testErr },
saveBinding: func(_ context.Context, binding settings.ResourceBinding) error {
saved = binding
return tc.saveErr
},
restart: func() { restarted <- struct{}{} },
restartDelay: 0,
}
err := repo.TestAndActivateSupportGroup(context.Background(), settings.ResourceBinding{
Kind: "support_group", TenantKey: "tenant", ConversationID: "group",
})
if (err != nil) != (tc.testErr != nil || tc.saveErr != nil) {
t.Fatalf("err=%v", err)
}
if got := saved.Kind != ""; got != tc.wantSaved {
t.Fatalf("saved=%v want=%v", got, tc.wantSaved)
}
if tc.wantSaved && (saved.ValidationCode != "support_test_ok" || !saved.Activate) {
t.Fatalf("binding=%+v", saved)
}
select {
case <-restarted:
if !tc.wantRestart {
t.Fatal("unexpected restart")
}
case <-time.After(50 * time.Millisecond):
if tc.wantRestart {
t.Fatal("restart not requested")
}
}
})
}
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
type dingTalkSettingsRouter struct{ controller *settings.Controller }
func (r *dingTalkSettingsRouter) Handle(ctx context.Context, m dingtalk.SettingsMessage) (bool, string, error) {
if r == nil || r.controller == nil {
return false, "", nil
}
if !r.controller.ShouldHandle(ctx, m.TenantKey, m.StaffID, m.Text) {
return false, "", nil
}
out, err := r.controller.Handle(ctx, settings.SettingInput{TenantKey: m.TenantKey, Channel: m.Channel, StaffID: m.StaffID, UnionID: m.UnionID, DisplayName: m.DisplayName, Department: m.Department, Text: m.Text, ConversationType: m.ConversationType, ConversationID: m.ConversationID, ConversationTitle: m.ConversationTitle})
return true, strings.TrimSpace(out), err
}
type settingsRuntime struct {
repository *postgres.SettingsRepository
bindings *validatedSettingsRepository
controller *settings.Controller
clientID string
secret string
secretRef string
}
func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey string) (*settingsRuntime, error) {
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" {
return nil, errors.New("DOBOTAI_TENANT_KEY is required")
}
repo, err := postgres.NewSettingsRepository(store)
if err != nil {
return nil, err
}
root := strings.TrimSpace(os.Getenv("DOBOTAI_SECRET_DIR"))
if root == "" {
home, homeErr := os.UserHomeDir()
if homeErr != nil {
return nil, homeErr
}
root = filepath.Join(home, ".config", "dobotai", "secrets")
}
secrets, err := secretstore.New(root)
if err != nil {
return nil, err
}
if _, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey); err != nil {
return nil, err
}
clientID, secretRef, _, err := repo.ActiveDingTalkConfig(ctx, tenantKey)
if err != nil {
return nil, err
}
var secret string
if secretRef != "" {
secret, err = secrets.Get(secretRef)
if err != nil {
return nil, errors.New("active DingTalk secret is unavailable")
}
}
if clientID == "" && strings.TrimSpace(os.Getenv("DINGTALK_CLIENT_ID")) != "" && strings.TrimSpace(os.Getenv("DINGTALK_CLIENT_SECRET")) != "" {
clientID, secret = strings.TrimSpace(os.Getenv("DINGTALK_CLIENT_ID")), strings.TrimSpace(os.Getenv("DINGTALK_CLIENT_SECRET"))
secretRef, err = secrets.Put("dingtalk", secret)
if err != nil {
return nil, err
}
if err = repo.SaveDingTalkConfig(ctx, tenantKey, clientID, secretRef, true); err != nil {
_ = secrets.Delete(secretRef)
return nil, err
}
}
validatedRepo := &validatedSettingsRepository{SettingsRepository: repo, clientID: clientID, secret: secret}
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), clientID: clientID, secret: secret, secretRef: secretRef}, nil
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"errors"
"log"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
type supportDispatcherRunner interface {
RunOnce(context.Context) (coverage.DispatcherStats, error)
}
func buildSupportDispatcher(repo coverage.NotificationRepository, clientID, clientSecret, openConversationID string, recordLinks *faqtable.RecordLinkBuilder) (*coverage.Dispatcher, error) {
openConversationID = strings.TrimSpace(openConversationID)
if openConversationID == "" {
return nil, nil
}
if repo == nil {
return nil, errors.New("support notification repository is required")
}
if recordLinks == nil {
return nil, nil
}
sender, err := dingtalk.NewSupportGroupSender(clientID, clientSecret, openConversationID, nil, dingtalk.WithFAQRecordLinkBuilder(recordLinks))
if err != nil {
return nil, err
}
return coverage.NewDispatcher(repo, sender)
}
func runSupportDispatcher(ctx context.Context, dispatcher supportDispatcherRunner, interval time.Duration) {
if dispatcher == nil || interval <= 0 {
return
}
runOnce := func() {
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
stats, err := dispatcher.RunOnce(runCtx)
cancel()
if err != nil {
log.Printf("support notification dispatch failed: %v", err)
return
}
if stats.Claimed > 0 {
log.Printf("support notification dispatch: claimed=%d notified=%d failed=%d", stats.Claimed, stats.Notified, stats.Failed)
}
}
runOnce()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runOnce()
}
}
}
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
type supportNotificationRepoStub struct{}
func (*supportNotificationRepoStub) Claim(context.Context, int, int, time.Time) ([]coverage.PendingSupportCase, error) {
return nil, nil
}
func (*supportNotificationRepoStub) MarkNotified(context.Context, string) error { return nil }
func (*supportNotificationRepoStub) MarkFailed(context.Context, string, string) error { return nil }
func testFAQRecordLinks(t *testing.T) *faqtable.RecordLinkBuilder {
t.Helper()
builder, err := faqtable.NewRecordLinkBuilder("base", "sheet", "view")
if err != nil {
t.Fatal(err)
}
return builder
}
type supportDispatcherStub struct {
mu sync.Mutex
calls int
errs []error
callCh chan int
}
func (s *supportDispatcherStub) RunOnce(context.Context) (coverage.DispatcherStats, error) {
s.mu.Lock()
s.calls++
call := s.calls
var err error
if call <= len(s.errs) {
err = s.errs[call-1]
}
s.mu.Unlock()
if s.callCh != nil {
select {
case s.callCh <- call:
default:
}
}
return coverage.DispatcherStats{Claimed: 1, Notified: 1}, err
}
func TestBuildSupportDispatcherDisabledWithoutGroup(t *testing.T) {
dispatcher, err := buildSupportDispatcher(nil, "", "", " ", nil)
if err != nil || dispatcher != nil {
t.Fatalf("dispatcher=%v err=%v, want disabled", dispatcher, err)
}
}
func TestBuildSupportDispatcherBlocksWithoutFAQLinks(t *testing.T) {
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, "client", "secret", "group", nil)
if err != nil || dispatcher != nil {
t.Fatalf("dispatcher=%v err=%v, want blocked without process failure", dispatcher, err)
}
}
func TestBuildSupportDispatcherEnabledFailsClosed(t *testing.T) {
repo := &supportNotificationRepoStub{}
links := testFAQRecordLinks(t)
for _, tc := range []struct {
name string
repo coverage.NotificationRepository
clientID, secret, gid string
links *faqtable.RecordLinkBuilder
}{
{name: "missing repo", clientID: "client", secret: "secret", gid: "group", links: links},
{name: "missing client", repo: repo, secret: "secret", gid: "group", links: links},
{name: "missing secret", repo: repo, clientID: "client", gid: "group", links: links},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := buildSupportDispatcher(tc.repo, tc.clientID, tc.secret, tc.gid, tc.links); err == nil {
t.Fatal("expected configuration error")
}
})
}
}
func TestBuildSupportDispatcherValidDoesNotSend(t *testing.T) {
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, " client ", " secret ", " group ", testFAQRecordLinks(t))
if err != nil {
t.Fatalf("buildSupportDispatcher: %v", err)
}
if dispatcher == nil {
t.Fatal("expected dispatcher")
}
}
func TestRunSupportDispatcherRunsImmediatelyAndStops(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
stub := &supportDispatcherStub{callCh: make(chan int, 4)}
done := make(chan struct{})
go func() {
runSupportDispatcher(ctx, stub, time.Hour)
close(done)
}()
select {
case <-stub.callCh:
case <-time.After(500 * time.Millisecond):
t.Fatal("dispatcher did not run immediately")
}
cancel()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("dispatcher did not stop after context cancellation")
}
}
func TestRunSupportDispatcherContinuesAfterError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stub := &supportDispatcherStub{
errs: []error{errors.New("temporary db error")},
callCh: make(chan int, 4),
}
done := make(chan struct{})
go func() {
runSupportDispatcher(ctx, stub, 10*time.Millisecond)
close(done)
}()
for want := 1; want <= 2; want++ {
select {
case got := <-stub.callCh:
if got != want {
t.Fatalf("call=%d, want %d", got, want)
}
case <-time.After(500 * time.Millisecond):
t.Fatalf("missing dispatcher call %d", want)
}
}
cancel()
<-done
}
func TestRunSupportDispatcherRejectsNonPositiveInterval(t *testing.T) {
stub := &supportDispatcherStub{}
runSupportDispatcher(context.Background(), stub, 0)
stub.mu.Lock()
defer stub.mu.Unlock()
if stub.calls != 0 {
t.Fatalf("calls=%d, want 0", stub.calls)
}
}
func TestMainWiresCoverageSupportNotifications(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID")`,
`coverage.NewService(store)`,
`qa.WithSucceededTurnObserver`,
`case <-runtimeReady:`,
`runSupportDispatcher(ctx, supportDispatcher, 5*time.Second)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main support notification wiring missing %q", fragment)
}
}
}
+118
View File
@@ -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![图%d](%s)\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,16 @@
-- 000004_delivery_audit down migration
-- Drop in reverse order: index first, then columns.
DROP INDEX IF EXISTS idx_messages_tenant_status_updated;
ALTER TABLE messages
DROP COLUMN IF EXISTS failure_code;
ALTER TABLE messages
DROP COLUMN IF EXISTS provider_message_id;
ALTER TABLE messages
DROP COLUMN IF EXISTS delivery_method;
ALTER TABLE messages
DROP COLUMN IF EXISTS delivered_at;
@@ -0,0 +1,20 @@
-- 000004_delivery_audit: delivery audit columns on messages
-- Adds delivered_at, delivery_method, provider_message_id, failure_code
-- with a tenant+status+updated_at query index.
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ;
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS delivery_method TEXT NOT NULL DEFAULT ''
CHECK (delivery_method IN ('', 'card', 'markdown'));
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS provider_message_id TEXT NOT NULL DEFAULT '';
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS failure_code TEXT NOT NULL DEFAULT ''
CHECK (failure_code IN ('', 'channel_delivery_failed'));
CREATE INDEX IF NOT EXISTS idx_messages_tenant_status_updated
ON messages (tenant_key, status, updated_at DESC);
@@ -0,0 +1,200 @@
// Package migrations_test contains source-level contract tests for the
// 000004_delivery_audit migration. No real database connection is used.
package migrations_test
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func read004UpMigration(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000004_delivery_audit.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
return string(data)
}
func read004DownMigration(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000004_delivery_audit.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
return string(data)
}
// TestUp004UsesIfNotExists ensures ALTER TABLE uses IF NOT EXISTS for new columns.
func TestUp004UsesIfNotExists(t *testing.T) {
up := read004UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "ADD COLUMN IF NOT EXISTS") {
t.Error("up migration must use ADD COLUMN IF NOT EXISTS for idempotency")
}
if !strings.Contains(upper, "CREATE INDEX IF NOT EXISTS") {
t.Error("up migration must use CREATE INDEX IF NOT EXISTS for idempotency")
}
// At least 4 new columns: delivered_at, delivery_method, provider_message_id, failure_code
if got := countMatches(up, `(?i)ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS`); got < 4 {
t.Errorf("expected at least 4 ADD COLUMN IF NOT EXISTS, got %d", got)
}
// At least 1 new index
if got := countMatches(up, `(?i)CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS`); got < 1 {
t.Errorf("expected at least 1 CREATE INDEX IF NOT EXISTS, got %d", got)
}
}
// TestUp004AddsDeliveryColumns ensures all 4 delivery audit columns are added to messages.
func TestUp004AddsDeliveryColumns(t *testing.T) {
up := read004UpMigration(t)
upper := strings.ToUpper(up)
columns := []string{
"DELIVERED_AT TIMESTAMPTZ",
"DELIVERY_METHOD TEXT NOT NULL DEFAULT ''",
"PROVIDER_MESSAGE_ID TEXT NOT NULL DEFAULT ''",
"FAILURE_CODE TEXT NOT NULL DEFAULT ''",
}
for _, col := range columns {
if !strings.Contains(upper, col) {
t.Errorf("up migration must add column %q", col)
}
}
}
// TestUp004DeliveryMethodCheck ensures delivery_method CHECK constraint.
func TestUp004DeliveryMethodCheck(t *testing.T) {
up := read004UpMigration(t)
upper := strings.ToUpper(up)
// delivery_method must have CHECK constraint
if !strings.Contains(upper, "DELIVERY_METHOD") {
t.Fatal("up migration must add delivery_method column")
}
// CHECK (delivery_method IN ('', 'card', 'markdown'))
if !strings.Contains(upper, "CARD") || !strings.Contains(upper, "MARKDOWN") {
t.Error("delivery_method CHECK must include 'card' and 'markdown'")
}
}
func TestUp004FailureCodeCheck(t *testing.T) {
up := strings.ToUpper(read004UpMigration(t))
if !strings.Contains(up, "CHECK (FAILURE_CODE IN ('', 'CHANNEL_DELIVERY_FAILED'))") {
t.Error("failure_code must be constrained to safe enumerated values")
}
}
// TestUp004AddsTenantStatusUpdatedAtIndex ensures the query index is created.
func TestUp004AddsTenantStatusUpdatedAtIndex(t *testing.T) {
up := read004UpMigration(t)
upper := strings.ToUpper(up)
// Must have an index on (tenant_key, status, updated_at DESC)
hasDesired := strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "STATUS") &&
strings.Contains(upper, "UPDATED_AT") &&
strings.Contains(upper, "DESC")
if !hasDesired {
t.Error("up migration must create index on (tenant_key, status, updated_at DESC)")
}
}
// TestUp004NoForbiddenColumns ensures no secrets/protocol columns are introduced.
func TestUp004NoForbiddenColumns(t *testing.T) {
up := read004UpMigration(t)
upper := strings.ToUpper(up)
forbidden := []string{
"RAW_PAYLOAD", "SESSIONWEBHOOK", "DOWNLOAD_CODE",
"DATAURI", "DATA_URI", "TOKEN", "RAW_RESPONSE", "RAW_ERROR",
"PROVIDER_RESPONSE", "API_RESPONSE",
}
for _, col := range forbidden {
if strings.Contains(upper, col) {
t.Errorf("migration must not contain forbidden column %q", col)
}
}
}
// TestUp004DoesNotModifyOtherTables ensures migration only touches messages.
func TestUp004DoesNotModifyOtherTables(t *testing.T) {
up := read004UpMigration(t)
upper := strings.ToUpper(up)
// Must not create/drop turns, session_mappings, channel_actors, channel_conversations
if strings.Contains(upper, "CREATE TABLE") {
t.Error("migration 004 must not CREATE TABLE")
}
if strings.Contains(upper, "DROP TABLE") {
t.Error("migration 004 must not DROP TABLE")
}
if strings.Contains(upper, "ALTER TABLE TURNS") {
t.Error("migration 004 must not ALTER TABLE turns")
}
if strings.Contains(upper, "ALTER TABLE SESSION_MAPPINGS") {
t.Error("migration 004 must not ALTER TABLE session_mappings")
}
if strings.Contains(upper, "ALTER TABLE CHANNEL_ACTORS") {
t.Error("migration 004 must not ALTER TABLE channel_actors")
}
if strings.Contains(upper, "ALTER TABLE CHANNEL_CONVERSATIONS") {
t.Error("migration 004 must not ALTER TABLE channel_conversations")
}
}
// TestDown004DropsDeliveryColumns ensures down drops columns in reverse order.
func TestDown004DropsDeliveryColumns(t *testing.T) {
down := read004DownMigration(t)
upper := strings.ToUpper(down)
columns := []string{
"FAILURE_CODE",
"PROVIDER_MESSAGE_ID",
"DELIVERY_METHOD",
"DELIVERED_AT",
}
for _, col := range columns {
if !strings.Contains(upper, "DROP COLUMN IF EXISTS "+col) {
t.Errorf("down migration must drop %s with IF EXISTS", col)
}
}
// Must drop the index too
if !strings.Contains(upper, "DROP INDEX IF EXISTS") {
t.Error("down migration must drop the delivery audit index")
}
}
// TestDown004DoesNotDropBaselineTable ensures down does not drop messages table.
func TestDown004DoesNotDropBaselineTable(t *testing.T) {
down := read004DownMigration(t)
upper := strings.ToUpper(down)
if strings.Contains(upper, "DROP TABLE IF EXISTS MESSAGES") {
t.Error("down migration must not drop the baseline messages table")
}
}
// TestUp004MessagesNoReservedWords ensures no bare reserved words in new columns.
func TestUp004MessagesNoReservedWords(t *testing.T) {
up := read004UpMigration(t)
// Check for bare "references" as column name
bareRefs := regexp.MustCompile(`(?im)^\s*REFERENCES\s+`)
if bareRefs.MatchString(up) {
lines := strings.Split(up, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
upperLine := strings.ToUpper(trimmed)
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "FOREIGN KEY") {
t.Errorf("reserved word 'references' used as column name: %s", trimmed)
}
}
}
}
@@ -0,0 +1,7 @@
-- 000005_answer_feedback down migration
-- Drop in reverse order: indexes first, then table.
DROP INDEX IF EXISTS idx_messages_card_provider_unique;
DROP INDEX IF EXISTS idx_answer_feedback_tenant_reason_updated;
DROP INDEX IF EXISTS idx_answer_feedback_tenant_value_updated;
DROP TABLE IF EXISTS answer_feedback;
@@ -0,0 +1,32 @@
-- 000005_answer_feedback: user feedback on answers via DingTalk card
-- Each (tenant_key, channel, answer_message_id, actor_id) holds the current
-- feedback state: up or down with optional reason_code for down.
-- Depends on: 000004_delivery_audit (and its 000003 message-audit baseline).
CREATE TABLE IF NOT EXISTS answer_feedback (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
answer_message_id TEXT NOT NULL REFERENCES messages(id),
turn_id TEXT NOT NULL REFERENCES turns(id),
actor_id TEXT NOT NULL REFERENCES channel_actors(id),
value TEXT NOT NULL CHECK (value IN ('up', 'down')),
reason_code TEXT NOT NULL DEFAULT '' CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other')),
source TEXT NOT NULL CHECK (source IN ('dingtalk_card')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, answer_message_id, actor_id),
CHECK (value != 'up' OR reason_code = '')
);
-- Queries that filter by value or reason_code within a tenant use these indexes.
CREATE INDEX IF NOT EXISTS idx_answer_feedback_tenant_value_updated
ON answer_feedback (tenant_key, value, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_answer_feedback_tenant_reason_updated
ON answer_feedback (tenant_key, reason_code, updated_at DESC);
-- A DingTalk outTrackId must identify exactly one delivered card answer.
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_card_provider_unique
ON messages (tenant_key, channel, provider_message_id)
WHERE provider_message_id != '' AND delivery_method = 'card';
@@ -0,0 +1,296 @@
// Package migrations_test contains source-level contract tests for the
// 000005_answer_feedback migration. No real database connection is used.
package migrations_test
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func read005UpMigration(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000005_answer_feedback.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
return string(data)
}
func read005DownMigration(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000005_answer_feedback.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
return string(data)
}
// --- RED: These tests will fail until the migration files exist ---
// TestUp005CreatesAnswerFeedbackTable ensures the table is created with IF NOT EXISTS.
func TestUp005CreatesAnswerFeedbackTable(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "CREATE TABLE IF NOT EXISTS ANSWER_FEEDBACK") {
t.Error("up migration must create answer_feedback table with IF NOT EXISTS")
}
}
// TestUp005AnswerFeedbackColumns ensures all required columns are present.
func TestUp005AnswerFeedbackColumns(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
required := []string{
"ID TEXT PRIMARY KEY",
"TENANT_KEY TEXT NOT NULL",
"CHANNEL TEXT NOT NULL",
"ANSWER_MESSAGE_ID TEXT NOT NULL",
"TURN_ID TEXT NOT NULL",
"ACTOR_ID TEXT NOT NULL",
"VALUE TEXT NOT NULL",
"REASON_CODE TEXT NOT NULL DEFAULT ''",
"SOURCE TEXT NOT NULL",
"CREATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW()",
"UPDATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW()",
}
for _, col := range required {
if !strings.Contains(upper, col) {
t.Errorf("up migration must contain column %q", col)
}
}
}
// TestUp005ValueCheck ensures value is constrained to 'up' or 'down'.
func TestUp005ValueCheck(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "VALUE IN ('UP'") || !strings.Contains(upper, "'DOWN'") {
t.Error("value must be CHECK constrained to 'up' and 'down'")
}
}
// TestUp005ReasonCodeCheck ensures reason_code is constrained to allowed values.
func TestUp005ReasonCodeCheck(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
allowed := []string{"''", "INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"}
for _, r := range allowed {
if !strings.Contains(upper, r) {
t.Errorf("reason_code CHECK must include %q", r)
}
}
}
// TestUp005SourceCheck ensures source is constrained to 'dingtalk_card'.
func TestUp005SourceCheck(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "DINGTALK_CARD") {
t.Error("source must be CHECK constrained to 'dingtalk_card'")
}
}
// TestUp005UpValueMustHaveNoReason ensures CHECK that value='up' implies reason_code=”.
func TestUp005UpValueMustHaveNoReason(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Must have a CHECK that when value='up', reason_code must be empty
hasCheck := strings.Contains(upper, "VALUE = 'UP'") && strings.Contains(upper, "REASON_CODE = ''")
// Also check for the inverse: "VALUE != 'UP' OR REASON_CODE = ''"
hasOr := strings.Contains(upper, "OR") && strings.Contains(upper, "REASON_CODE = ''")
if !hasCheck && !hasOr {
t.Error("must have CHECK ensuring value='up' implies reason_code=''")
}
}
// TestUp005ForeignKeyConstraints ensures FK constraints on answer_message_id, turn_id, actor_id.
func TestUp005ForeignKeyConstraints(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Must reference messages(id), turns(id), channel_actors(id)
if !strings.Contains(upper, "REFERENCES MESSAGES(ID)") {
t.Error("answer_message_id must have FK REFERENCES messages(id)")
}
if !strings.Contains(upper, "REFERENCES TURNS(ID)") {
t.Error("turn_id must have FK REFERENCES turns(id)")
}
if !strings.Contains(upper, "REFERENCES CHANNEL_ACTORS(ID)") {
t.Error("actor_id must have FK REFERENCES channel_actors(id)")
}
}
// TestUp005UniqueConstraint ensures UNIQUE on (tenant_key, channel, answer_message_id, actor_id).
func TestUp005UniqueConstraint(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
hasUnique := strings.Contains(upper, "UNIQUE") &&
strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "CHANNEL") &&
strings.Contains(upper, "ANSWER_MESSAGE_ID") &&
strings.Contains(upper, "ACTOR_ID")
if !hasUnique {
t.Error("must have UNIQUE constraint on (tenant_key, channel, answer_message_id, actor_id)")
}
}
// TestUp005Indexes ensures required indexes are created.
func TestUp005Indexes(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Index on (tenant_key, value, updated_at DESC)
hasValueIdx := strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "VALUE") &&
strings.Contains(upper, "UPDATED_AT") &&
strings.Contains(upper, "DESC")
if !hasValueIdx {
t.Error("must have index on (tenant_key, value, updated_at DESC)")
}
// Index on (tenant_key, reason_code, updated_at DESC)
hasReasonIdx := strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "REASON_CODE") &&
strings.Contains(upper, "UPDATED_AT") &&
strings.Contains(upper, "DESC")
if !hasReasonIdx {
t.Error("must have index on (tenant_key, reason_code, updated_at DESC)")
}
// Must use CREATE INDEX IF NOT EXISTS
if got := countMatchesRe(up, `(?i)CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS`); got < 2 {
t.Errorf("expected at least 2 CREATE INDEX IF NOT EXISTS, got %d", got)
}
}
// TestUp005UpMigrationUsesIfNotExists ensures idempotency.
func TestUp005UpMigrationUsesIfNotExists(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if strings.Contains(upper, "CREATE TABLE") && !strings.Contains(upper, "CREATE TABLE IF NOT EXISTS") {
t.Error("CREATE TABLE must use IF NOT EXISTS for idempotency")
}
// Must have exactly 1 CREATE TABLE IF NOT EXISTS
if got := countMatchesRe(up, `(?i)CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS`); got != 1 {
t.Errorf("expected 1 CREATE TABLE IF NOT EXISTS, got %d", got)
}
}
func TestUp005CreatesUniqueCardProviderIndex(t *testing.T) {
up := strings.ToUpper(read005UpMigration(t))
for _, fragment := range []string{
"CREATE UNIQUE INDEX IF NOT EXISTS IDX_MESSAGES_CARD_PROVIDER_UNIQUE",
"ON MESSAGES (TENANT_KEY, CHANNEL, PROVIDER_MESSAGE_ID)",
"PROVIDER_MESSAGE_ID != ''",
"DELIVERY_METHOD = 'CARD'",
} {
if !strings.Contains(up, fragment) {
t.Errorf("provider binding index missing %q", fragment)
}
}
down := strings.ToUpper(read005DownMigration(t))
if !strings.Contains(down, "DROP INDEX IF EXISTS IDX_MESSAGES_CARD_PROVIDER_UNIQUE") {
t.Error("down migration must remove the B2 provider binding index")
}
}
// TestUp005NoForbiddenColumns ensures no secrets/protocol columns.
func TestUp005NoForbiddenColumns(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
forbidden := []string{
"WEBHOOK", "TOKEN", "RAW_CALLBACK", "RAW_PAYLOAD",
"CONTENT", "CALLBACK", "EXTENSION", "CORP_ID",
"SESSIONWEBHOOK", "DOWNLOAD_CODE", "DATAURI", "DATA_URI",
"RAW_RESPONSE", "RAW_ERROR", "PROVIDER_RESPONSE", "API_RESPONSE",
}
for _, col := range forbidden {
if strings.Contains(upper, col) {
t.Errorf("migration must not contain forbidden column %q", col)
}
}
}
// TestUp005DoesNotModifyBaselineTables ensures migration only touches answer_feedback.
func TestUp005DoesNotModifyBaselineTables(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Must not ALTER other tables
baseline := []string{"MESSAGES", "TURNS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS", "SESSION_MAPPINGS"}
for _, tbl := range baseline {
if strings.Contains(upper, "ALTER TABLE "+tbl) {
t.Errorf("migration must not ALTER TABLE %s", tbl)
}
}
}
// TestDown005DropsAnswerFeedbackTable ensures down drops only the new table and indexes.
func TestDown005DropsAnswerFeedbackTable(t *testing.T) {
down := read005DownMigration(t)
upper := strings.ToUpper(down)
if !strings.Contains(upper, "DROP TABLE IF EXISTS ANSWER_FEEDBACK") {
t.Error("down migration must drop answer_feedback with IF EXISTS")
}
}
// TestDown005DoesNotDropBaselineTables ensures down does not touch B1 tables.
func TestDown005DoesNotDropBaselineTables(t *testing.T) {
down := read005DownMigration(t)
upper := strings.ToUpper(down)
baseline := []string{"MESSAGES", "TURNS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS", "SESSION_MAPPINGS"}
for _, tbl := range baseline {
if strings.Contains(upper, "DROP TABLE IF EXISTS "+tbl) {
t.Errorf("down migration must not drop baseline table %s", tbl)
}
}
}
// TestDown005DoesNotDropBaselineColumns ensures down does not drop baseline columns.
func TestDown005DoesNotDropBaselineColumns(t *testing.T) {
down := read005DownMigration(t)
upper := strings.ToUpper(down)
if strings.Contains(upper, "DROP COLUMN") || strings.Contains(upper, "ALTER TABLE") {
t.Error("down migration must not drop/alter columns — only drop the answer_feedback table and indexes")
}
}
// TestUp005NoReservedWords ensures no bare reserved words as column names.
func TestUp005NoReservedWords(t *testing.T) {
up := read005UpMigration(t)
// Check for bare "references" as column name (not in FK context)
bareRefs := regexp.MustCompile(`(?im)^\s*REFERENCES\s+`)
if bareRefs.MatchString(up) {
lines := strings.Split(up, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
upperLine := strings.ToUpper(trimmed)
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "FOREIGN KEY") {
t.Errorf("reserved word 'references' used as column name: %s", trimmed)
}
}
}
}
// --- Helpers ---
func countMatchesRe(haystack, pattern string) int {
return len(regexp.MustCompile(pattern).FindAllString(haystack, -1))
}
@@ -0,0 +1,29 @@
-- 000006_native_card_feedback_reasons down migration
-- A schema rollback cannot retain values rejected by the legacy constraint,
-- so map each native reason to its closest B2 legacy category first.
UPDATE answer_feedback
SET reason_code = CASE reason_code
WHEN 'content_mismatch' THEN 'irrelevant'
WHEN 'content_incomplete' THEN 'not_resolved'
WHEN 'illogical_expression' THEN 'unclear'
WHEN 'factual_error' THEN 'incorrect'
WHEN 'sensitive_content' THEN 'other'
WHEN 'answer_not_wanted' THEN 'not_resolved'
ELSE reason_code
END
WHERE reason_code IN (
'content_mismatch',
'content_incomplete',
'illogical_expression',
'factual_error',
'sensitive_content',
'answer_not_wanted'
);
ALTER TABLE answer_feedback
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
ALTER TABLE answer_feedback
ADD CONSTRAINT answer_feedback_reason_code_check
CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other'));
@@ -0,0 +1,24 @@
-- 000006_native_card_feedback_reasons up migration
-- Preserve the original B2 reasons and add the six exact reason codes emitted
-- by DingTalk's built-in AI feedback component.
ALTER TABLE answer_feedback
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
ALTER TABLE answer_feedback
ADD CONSTRAINT answer_feedback_reason_code_check
CHECK (reason_code IN (
'',
'incorrect',
'not_resolved',
'irrelevant',
'unclear',
'outdated',
'other',
'content_mismatch',
'content_incomplete',
'illogical_expression',
'factual_error',
'sensitive_content',
'answer_not_wanted'
));
@@ -0,0 +1,78 @@
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read006Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000006_native_card_feedback_reasons."+direction+".sql"))
if err != nil {
t.Fatalf("read 000006 %s migration: %v", direction, err)
}
return strings.Join(strings.Fields(strings.ToUpper(string(data))), " ")
}
func TestUp006ExpandsNamedReasonConstraint(t *testing.T) {
up := read006Migration(t, "up")
for _, fragment := range []string{
"ALTER TABLE ANSWER_FEEDBACK DROP CONSTRAINT IF EXISTS ANSWER_FEEDBACK_REASON_CODE_CHECK",
"ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK",
"CHECK (REASON_CODE IN",
"CONTENT_MISMATCH",
"CONTENT_INCOMPLETE",
"ILLOGICAL_EXPRESSION",
"FACTUAL_ERROR",
"SENSITIVE_CONTENT",
"ANSWER_NOT_WANTED",
} {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
for _, legacy := range []string{"INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"} {
if !strings.Contains(up, legacy) {
t.Errorf("up migration must preserve legacy reason %q", legacy)
}
}
}
func TestUp006OnlyChangesReasonConstraint(t *testing.T) {
up := read006Migration(t, "up")
for _, forbidden := range []string{"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM", "UPDATE ANSWER_FEEDBACK"} {
if strings.Contains(up, forbidden) {
t.Errorf("up migration contains forbidden operation %q", forbidden)
}
}
}
func TestDown006MapsNativeReasonsBeforeRestoringConstraint(t *testing.T) {
down := read006Migration(t, "down")
mappings := map[string]string{
"CONTENT_MISMATCH": "IRRELEVANT",
"CONTENT_INCOMPLETE": "NOT_RESOLVED",
"ILLOGICAL_EXPRESSION": "UNCLEAR",
"FACTUAL_ERROR": "INCORRECT",
"SENSITIVE_CONTENT": "OTHER",
"ANSWER_NOT_WANTED": "NOT_RESOLVED",
}
updateAt := strings.Index(down, "UPDATE ANSWER_FEEDBACK")
constraintAt := strings.Index(down, "ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK")
if updateAt < 0 || constraintAt < 0 || updateAt > constraintAt {
t.Fatal("down migration must map native reasons before restoring the legacy constraint")
}
for from, to := range mappings {
if !strings.Contains(down, "WHEN '"+from+"' THEN '"+to+"'") {
t.Errorf("down migration missing mapping %s -> %s", from, to)
}
}
for _, native := range []string{"CONTENT_MISMATCH", "CONTENT_INCOMPLETE", "ILLOGICAL_EXPRESSION", "FACTUAL_ERROR", "SENSITIVE_CONTENT", "ANSWER_NOT_WANTED"} {
constraint := down[constraintAt:]
if strings.Contains(constraint, native) {
t.Errorf("legacy down constraint must exclude native reason %q", native)
}
}
}
@@ -0,0 +1,5 @@
-- 000007_coverage_decisions down migration
-- Drop support_cases first (child table), then coverage_decisions (parent table).
DROP TABLE IF EXISTS support_cases;
DROP TABLE IF EXISTS coverage_decisions;
@@ -0,0 +1,49 @@
-- 000007_coverage_decisions up migration
-- Creates coverage_decisions and support_cases tables for B3.1 persistence.
CREATE TABLE IF NOT EXISTS coverage_decisions (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
evaluator_version TEXT NOT NULL DEFAULT 'v1',
verdict TEXT NOT NULL CHECK (verdict IN ('covered', 'uncovered')),
reason_code TEXT NOT NULL CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer'
)),
reference_count INTEGER NOT NULL DEFAULT 0 CHECK (reference_count >= 0),
top_score DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_tenant_channel
ON coverage_decisions (tenant_key, channel, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_verdict
ON coverage_decisions (verdict) WHERE verdict = 'uncovered';
CREATE TABLE IF NOT EXISTS support_cases (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
coverage_decision_id TEXT NOT NULL REFERENCES coverage_decisions(id),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'notifying', 'notification_failed', 'notified', 'resolved')),
notification_attempts INTEGER NOT NULL DEFAULT 0 CHECK (notification_attempts >= 0),
last_notification_failure_code TEXT NOT NULL DEFAULT '',
last_notification_at TIMESTAMPTZ,
notified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
CREATE INDEX IF NOT EXISTS idx_support_cases_notification_pending
ON support_cases (status, created_at, id) WHERE status IN ('pending', 'notification_failed');
CREATE INDEX IF NOT EXISTS idx_support_cases_notifying_stale
ON support_cases (status, last_notification_at) WHERE status = 'notifying';
@@ -0,0 +1,179 @@
// Package migrations_test contains source-level contract tests for the
// 000007_coverage_decisions migration. No real database connection is used.
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read007Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000007_coverage_decisions."+direction+".sql"))
if err != nil {
t.Fatalf("read 000007 %s migration: %v", direction, err)
}
return string(data)
}
func TestUp007CreatesCoverageDecisionsTable(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
required := []string{
"CREATE TABLE IF NOT EXISTS COVERAGE_DECISIONS",
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
"EVALUATOR_VERSION", "VERDICT", "REASON_CODE", "REFERENCE_COUNT", "TOP_SCORE",
"CREATED_AT", "UPDATED_AT",
}
for _, fragment := range required {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
}
func TestUp007CoverageDecisionsConstraints(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
// REFERENCES turns(id)
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
t.Error("coverage_decisions.turn_id must reference turns(id)")
}
// UNIQUE (tenant_key, channel, turn_id)
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
t.Error("coverage_decisions must have UNIQUE (tenant_key, channel, turn_id)")
}
// verdict CHECK
if !strings.Contains(up, "COVERED") || !strings.Contains(up, "UNCOVERED") {
t.Error("verdict CHECK must include 'covered' and 'uncovered'")
}
// reason_code CHECK
for _, code := range []string{"COVERED_HAS_REFERENCES", "UNCOVERED_NO_REFERENCES", "UNCOVERED_EXPLICIT_NO_ANSWER"} {
if !strings.Contains(up, code) {
t.Errorf("reason_code CHECK missing %q", code)
}
}
}
func TestUp007CreatesSupportCasesTable(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
required := []string{
"CREATE TABLE IF NOT EXISTS SUPPORT_CASES",
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
"COVERAGE_DECISION_ID", "STATUS", "CREATED_AT", "UPDATED_AT",
}
for _, fragment := range required {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
// Notification fields must be present
notificationFields := []string{
"NOTIFICATION_ATTEMPTS", "LAST_NOTIFICATION_FAILURE_CODE",
"LAST_NOTIFICATION_AT", "NOTIFIED_AT",
}
for _, fragment := range notificationFields {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing notification field %q", fragment)
}
}
}
func TestUp007SupportCasesConstraints(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
// FK to coverage_decisions
if !strings.Contains(up, "REFERENCES COVERAGE_DECISIONS(ID)") {
t.Error("support_cases.coverage_decision_id must reference coverage_decisions(id)")
}
// FK to turns
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
t.Error("support_cases.turn_id must reference turns(id)")
}
// UNIQUE (tenant_key, channel, turn_id)
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
t.Error("support_cases must have UNIQUE (tenant_key, channel, turn_id)")
}
// status CHECK
for _, status := range []string{"PENDING", "NOTIFYING", "NOTIFICATION_FAILED", "NOTIFIED", "RESOLVED"} {
if !strings.Contains(up, status) {
t.Errorf("status CHECK must include %q", status)
}
}
}
func TestUp007CreatesQueryIndexes(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
// Pending/retry query index and stale notifying index
if !strings.Contains(up, "IDX_SUPPORT_CASES_NOTIFICATION_PENDING") ||
!strings.Contains(up, "WHERE STATUS IN ('PENDING', 'NOTIFICATION_FAILED')") {
t.Error("up migration must index pending and notification_failed support_cases")
}
if !strings.Contains(up, "IDX_SUPPORT_CASES_NOTIFYING_STALE") ||
!strings.Contains(up, "WHERE STATUS = 'NOTIFYING'") {
t.Error("up migration must index stale notifying support_cases")
}
// Coverage decisions listing index
if !strings.Contains(up, "IDX_COVERAGE_DECISIONS_TENANT_CHANNEL") {
t.Error("up migration must create index on coverage_decisions (tenant_key, channel, created_at DESC)")
}
// Uncovered verdict index
if !strings.Contains(up, "WHERE VERDICT = 'UNCOVERED'") {
t.Error("up migration must create partial index on uncovered coverage_decisions")
}
}
func TestUp007CoverageNoReservedWords(t *testing.T) {
up := read007Migration(t, "up")
// Check for bare "references" as column name in CREATE TABLE statements
lines := strings.Split(up, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
upperLine := strings.ToUpper(trimmed)
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "REFERENCES TURNS") && !strings.Contains(upperLine, "REFERENCES COVERAGE") {
t.Errorf("reserved word 'references' used as column name in: %s", trimmed)
}
}
}
func TestUp007NoForbiddenOperations(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
for _, forbidden := range []string{
"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM",
"ALTER TABLE", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
"RAW_RESPONSE", "RAW_ERROR",
} {
if strings.Contains(up, forbidden) {
t.Errorf("up migration contains forbidden operation %q", forbidden)
}
}
}
func TestUp007NoQuestionAnswerBodies(t *testing.T) {
up := strings.ToUpper(read007Migration(t, "up"))
for _, forbidden := range []string{"QUESTION", "ANSWER_TEXT", "REFERENCE_CONTENT", "ANSWER_CONTENT"} {
if strings.Contains(up, forbidden) {
t.Errorf("coverage/support tables must not duplicate question/answer/reference bodies: found %q", forbidden)
}
}
}
func TestDown007DropsInSafeOrder(t *testing.T) {
down := strings.ToUpper(read007Migration(t, "down"))
supportAt := strings.Index(down, "DROP TABLE IF EXISTS SUPPORT_CASES")
coverageAt := strings.Index(down, "DROP TABLE IF EXISTS COVERAGE_DECISIONS")
if supportAt < 0 || coverageAt < 0 {
t.Fatal("down migration must drop both tables")
}
if supportAt > coverageAt {
t.Fatal("down migration must drop support_cases (child) before coverage_decisions (parent)")
}
}
func TestDown007DoesNotDropBaselineTables(t *testing.T) {
down := strings.ToUpper(read007Migration(t, "down"))
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
t.Errorf("down migration must not drop baseline table %q", table)
}
}
}
@@ -0,0 +1,2 @@
ALTER TABLE session_mappings
DROP COLUMN IF EXISTS display_title;
@@ -0,0 +1,2 @@
ALTER TABLE session_mappings
ADD COLUMN IF NOT EXISTS display_title TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,28 @@
package migrations
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSessionDisplayTitleMigration(t *testing.T) {
up, err := os.ReadFile(filepath.Join("000008_session_display_title.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
down, err := os.ReadFile(filepath.Join("000008_session_display_title.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
upper := strings.ToUpper(string(up))
for _, required := range []string{"ALTER TABLE SESSION_MAPPINGS", "ADD COLUMN IF NOT EXISTS DISPLAY_TITLE", "TEXT NOT NULL", "DEFAULT ''"} {
if !strings.Contains(upper, required) {
t.Errorf("up migration missing %q", required)
}
}
if !strings.Contains(strings.ToUpper(string(down)), "DROP COLUMN IF EXISTS DISPLAY_TITLE") {
t.Error("down migration must drop display_title")
}
}
@@ -0,0 +1,7 @@
ALTER TABLE messages
DROP COLUMN IF EXISTS region_snapshot;
ALTER TABLE messages
DROP COLUMN IF EXISTS department_snapshot;
DROP TABLE IF EXISTS directory_profiles;
@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS directory_profiles (
tenant_key TEXT NOT NULL,
staff_id TEXT NOT NULL,
union_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
nickname TEXT NOT NULL DEFAULT '',
job_title TEXT NOT NULL DEFAULT '',
department_ids JSONB NOT NULL DEFAULT '[]'::JSONB,
department_name TEXT NOT NULL DEFAULT '',
main_department_name TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
employment_status TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL CHECK (source IN ('full_sync','on_demand')),
synced_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_key, staff_id)
);
CREATE INDEX IF NOT EXISTS idx_directory_profiles_tenant_name
ON directory_profiles (tenant_key, name);
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS department_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS region_snapshot TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,42 @@
package migrations
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDirectoryProfilesMigration(t *testing.T) {
up, err := os.ReadFile(filepath.Join("000009_directory_profiles.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
down, err := os.ReadFile(filepath.Join("000009_directory_profiles.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
upper := strings.ToUpper(string(up))
for _, required := range []string{
"CREATE TABLE IF NOT EXISTS DIRECTORY_PROFILES",
"PRIMARY KEY (TENANT_KEY, STAFF_ID)",
"DEPARTMENT_IDS JSONB",
"REGION TEXT",
"SOURCE TEXT",
"ADD COLUMN IF NOT EXISTS DEPARTMENT_SNAPSHOT",
"ADD COLUMN IF NOT EXISTS REGION_SNAPSHOT",
} {
if !strings.Contains(upper, required) {
t.Errorf("up migration missing %q", required)
}
}
for _, forbidden := range []string{"MOBILE", "EMAIL", "CLIENT_SECRET", "ACCESS_TOKEN", "RAW_PAYLOAD"} {
if strings.Contains(upper, forbidden) {
t.Errorf("up migration contains forbidden sensitive field %q", forbidden)
}
}
downUpper := strings.ToUpper(string(down))
if !strings.Contains(downUpper, "DROP TABLE IF EXISTS DIRECTORY_PROFILES") || !strings.Contains(downUpper, "DROP COLUMN IF EXISTS REGION_SNAPSHOT") {
t.Error("down migration must remove directory table and snapshot columns")
}
}
@@ -0,0 +1,9 @@
-- 000010_question_table_exports down migration
-- Drop in reverse dependency order: first drop export table, then remove column
DROP TABLE IF EXISTS question_table_exports;
ALTER TABLE messages
DROP COLUMN IF EXISTS trigger_person_snapshot;
ALTER TABLE messages
DROP COLUMN IF EXISTS actor_union_id_snapshot;
@@ -0,0 +1,40 @@
-- 000010_question_table_exports: durable outbox for Notable question table async sync
-- Add trigger_person_snapshot and actor_union_id_snapshot to messages and create question_table_exports table
-- 1. Add trigger_person_snapshot and actor_union_id_snapshot columns to messages
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS trigger_person_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS actor_union_id_snapshot TEXT NOT NULL DEFAULT '';
-- 2. Create question_table_exports outbox table
CREATE TABLE IF NOT EXISTS question_table_exports (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'exporting', 'exported', 'export_failed')),
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TIMESTAMPTZ,
next_attempt_at TIMESTAMPTZ,
exported_at TIMESTAMPTZ,
failure_code TEXT NOT NULL DEFAULT '',
external_record_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
-- 3. Index for claim query: pending/export_failed/exporting ordered by created_at
-- Covers pending, export_failed (when next_attempt_at is NULL or <= NOW()),
-- and stale exporting (when last_attempt_at < staleBefore).
CREATE INDEX IF NOT EXISTS idx_question_table_exports_claim
ON question_table_exports (status, next_attempt_at, created_at, id)
WHERE status IN ('pending', 'export_failed')
OR (status = 'exporting' AND last_attempt_at IS NOT NULL);
-- 4. Index for stale exporting recovery
CREATE INDEX IF NOT EXISTS idx_question_table_exports_stale
ON question_table_exports (status, last_attempt_at)
WHERE status = 'exporting';
@@ -0,0 +1,161 @@
// Package migrations_test contains source-level contract tests for the
// 000010_question_table_exports migration. No real database connection is used.
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read010Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000010_question_table_exports."+direction+".sql"))
if err != nil {
t.Fatalf("read 000010 %s migration: %v", direction, err)
}
return string(data)
}
func TestUp010AddsTriggerPersonSnapshot(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "ALTER TABLE MESSAGES") {
t.Fatal("up migration must alter messages")
}
if !strings.Contains(up, "ADD COLUMN IF NOT EXISTS TRIGGER_PERSON_SNAPSHOT") {
t.Error("up migration must add trigger_person_snapshot column")
}
if !strings.Contains(up, "ADD COLUMN IF NOT EXISTS ACTOR_UNION_ID_SNAPSHOT") {
t.Error("up migration must add actor_union_id_snapshot column")
}
if !strings.Contains(up, "TEXT NOT NULL DEFAULT ''") {
t.Error("snapshot columns must be TEXT NOT NULL DEFAULT ''")
}
}
func TestUp010CreatesQuestionTableExports(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
required := []string{
"CREATE TABLE IF NOT EXISTS QUESTION_TABLE_EXPORTS",
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
"STATUS", "ATTEMPTS", "LAST_ATTEMPT_AT", "NEXT_ATTEMPT_AT", "EXPORTED_AT",
"FAILURE_CODE", "EXTERNAL_RECORD_ID",
"CREATED_AT", "UPDATED_AT",
}
for _, fragment := range required {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
}
func TestUp010QuestionTableExportsConstraints(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
// FK to turns
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
t.Error("question_table_exports.turn_id must reference turns(id)")
}
// UNIQUE (tenant_key, channel, turn_id)
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
t.Error("question_table_exports must have UNIQUE (tenant_key, channel, turn_id)")
}
// status CHECK
for _, status := range []string{"PENDING", "EXPORTING", "EXPORTED", "EXPORT_FAILED"} {
if !strings.Contains(up, status) {
t.Errorf("status CHECK must include %q", status)
}
}
}
func TestUp010ClaimIndexes(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "IDX_QUESTION_TABLE_EXPORTS_CLAIM") {
t.Error("up migration must create claim index")
}
if !strings.Contains(up, "WHERE STATUS IN ('PENDING', 'EXPORT_FAILED')") {
t.Error("claim index must include pending and export_failed statuses")
}
if !strings.Contains(up, "IDX_QUESTION_TABLE_EXPORTS_STALE") {
t.Error("up migration must create stale exporting index")
}
if !strings.Contains(up, "WHERE STATUS = 'EXPORTING'") {
t.Error("stale index must filter exporting status")
}
}
func TestUp010HasNextAttemptAtColumn(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "NEXT_ATTEMPT_AT TIMESTAMPTZ") {
t.Error("question_table_exports must have next_attempt_at column for retry backoff")
}
}
func TestUp010HasExternalRecordID(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "EXTERNAL_RECORD_ID TEXT NOT NULL DEFAULT ''") {
t.Error("question_table_exports must have external_record_id column (not a1_notation)")
}
if strings.Contains(up, "A1_NOTATION") {
t.Error("question_table_exports must not have stale a1_notation column")
}
}
func TestUp010ClaimIndexCoversNextAttemptAt(t *testing.T) {
up := strings.ToUpper(read010Migration(t, "up"))
if !strings.Contains(up, "NEXT_ATTEMPT_AT") {
t.Error("claim index must cover next_attempt_at for retry backoff filtering")
}
}
func TestDown010DropsInSafeOrder(t *testing.T) {
down := strings.ToUpper(read010Migration(t, "down"))
dropExportAt := strings.Index(down, "DROP TABLE IF EXISTS QUESTION_TABLE_EXPORTS")
dropTriggerAt := strings.Index(down, "DROP COLUMN IF EXISTS TRIGGER_PERSON_SNAPSHOT")
dropUnionAt := strings.Index(down, "DROP COLUMN IF EXISTS ACTOR_UNION_ID_SNAPSHOT")
if dropExportAt < 0 || dropTriggerAt < 0 || dropUnionAt < 0 {
t.Fatal("down migration must drop export table and both snapshot columns")
}
if dropExportAt > dropTriggerAt || dropTriggerAt > dropUnionAt {
t.Fatal("down migration must drop export table before snapshot columns")
}
}
func TestDown010DoesNotDropBaselineTables(t *testing.T) {
down := strings.ToUpper(read010Migration(t, "down"))
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
t.Errorf("down migration must not drop baseline table %q", table)
}
}
}
func TestUp010ExportsNoFormulaColumns(t *testing.T) {
up := read010Migration(t, "up")
// Check that the CREATE TABLE body does not contain user/AI content columns
lines := strings.Split(up, "\n")
inTable := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
upper := strings.ToUpper(trimmed)
if strings.HasPrefix(upper, "CREATE TABLE IF NOT EXISTS QUESTION_TABLE_EXPORTS") {
inTable = true
continue
}
if inTable && trimmed == ");" {
inTable = false
continue
}
if !inTable {
continue
}
for _, forbidden := range []string{
"QUESTION", "ANSWER_TEXT", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
"RAW_RESPONSE", "RAW_ERROR", "ACCESS_TOKEN", "APP_SECRET",
} {
if strings.Contains(upper, forbidden) {
t.Errorf("question_table_exports column must not contain %q", forbidden)
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
DROP INDEX IF EXISTS idx_support_cases_faq_export_stale;
DROP INDEX IF EXISTS idx_support_cases_faq_export_claim;
ALTER TABLE support_cases
DROP COLUMN IF EXISTS faq_record_id,
DROP COLUMN IF EXISTS faq_failure_code,
DROP COLUMN IF EXISTS faq_exported_at,
DROP COLUMN IF EXISTS faq_next_attempt_at,
DROP COLUMN IF EXISTS faq_last_attempt_at,
DROP COLUMN IF EXISTS faq_export_attempts,
DROP COLUMN IF EXISTS faq_export_status;
+24
View File
@@ -0,0 +1,24 @@
-- Durable FAQ export state for uncovered support cases.
ALTER TABLE support_cases
ADD COLUMN IF NOT EXISTS faq_export_status TEXT NOT NULL DEFAULT 'not_applicable'
CHECK (faq_export_status IN ('not_applicable', 'pending', 'exporting', 'exported', 'export_failed')),
ADD COLUMN IF NOT EXISTS faq_export_attempts INTEGER NOT NULL DEFAULT 0
CHECK (faq_export_attempts >= 0),
ADD COLUMN IF NOT EXISTS faq_last_attempt_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS faq_next_attempt_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS faq_exported_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS faq_failure_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS faq_record_id TEXT NOT NULL DEFAULT '';
-- Existing cases are intentionally not backfilled. Only cases created after
-- this migration enter the FAQ workflow automatically.
ALTER TABLE support_cases
ALTER COLUMN faq_export_status SET DEFAULT 'pending';
CREATE INDEX IF NOT EXISTS idx_support_cases_faq_export_claim
ON support_cases (faq_export_status, faq_next_attempt_at, created_at, id)
WHERE faq_export_status IN ('pending', 'export_failed');
CREATE INDEX IF NOT EXISTS idx_support_cases_faq_export_stale
ON support_cases (faq_export_status, faq_last_attempt_at)
WHERE faq_export_status = 'exporting';
@@ -0,0 +1,74 @@
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read011Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000011_faq_exports."+direction+".sql"))
if err != nil {
t.Fatalf("read 000011 %s migration: %v", direction, err)
}
return strings.ToUpper(string(data))
}
func TestUp011AddsDurableFAQExportStateToSupportCases(t *testing.T) {
up := read011Migration(t, "up")
for _, fragment := range []string{
"ALTER TABLE SUPPORT_CASES",
"FAQ_EXPORT_STATUS", "FAQ_EXPORT_ATTEMPTS", "FAQ_LAST_ATTEMPT_AT",
"FAQ_NEXT_ATTEMPT_AT", "FAQ_EXPORTED_AT", "FAQ_FAILURE_CODE", "FAQ_RECORD_ID",
"PENDING", "EXPORTING", "EXPORTED", "EXPORT_FAILED",
} {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
for _, forbidden := range []string{"FAQ_QUESTION", "FAQ_ANSWER", "RAW_RESPONSE", "ACCESS_TOKEN"} {
if strings.Contains(up, forbidden) {
t.Errorf("migration must not duplicate business or secret field %q", forbidden)
}
}
}
func TestUp011DoesNotBackfillHistoricalSupportCases(t *testing.T) {
up := read011Migration(t, "up")
if !strings.Contains(up, "DEFAULT 'NOT_APPLICABLE'") {
t.Fatal("existing support cases must start as not_applicable")
}
if !strings.Contains(up, "ALTER COLUMN FAQ_EXPORT_STATUS SET DEFAULT 'PENDING'") {
t.Fatal("future support cases must default to pending")
}
}
func TestUp011AddsBoundedClaimIndexes(t *testing.T) {
up := read011Migration(t, "up")
for _, fragment := range []string{
"IDX_SUPPORT_CASES_FAQ_EXPORT_CLAIM",
"FAQ_NEXT_ATTEMPT_AT", "FAQ_LAST_ATTEMPT_AT",
"WHERE FAQ_EXPORT_STATUS IN ('PENDING', 'EXPORT_FAILED')",
} {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing claim index fragment %q", fragment)
}
}
}
func TestDown011OnlyRemovesFAQExportState(t *testing.T) {
down := read011Migration(t, "down")
for _, column := range []string{
"FAQ_EXPORT_STATUS", "FAQ_EXPORT_ATTEMPTS", "FAQ_LAST_ATTEMPT_AT",
"FAQ_NEXT_ATTEMPT_AT", "FAQ_EXPORTED_AT", "FAQ_FAILURE_CODE", "FAQ_RECORD_ID",
} {
if !strings.Contains(down, "DROP COLUMN IF EXISTS "+column) {
t.Errorf("down migration missing %s", column)
}
}
if strings.Contains(down, "DROP TABLE") {
t.Fatal("down migration must not drop baseline tables")
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS config_audit_events; DROP TABLE IF EXISTS system_resource_bindings; DROP TABLE IF EXISTS administrators; DROP TABLE IF EXISTS system_config_revisions;
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS system_config_revisions (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0),
state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')), public_config JSONB NOT NULL DEFAULT '{}'::JSONB,
secret_ref TEXT NOT NULL DEFAULT '', validation_status TEXT NOT NULL DEFAULT 'pending', validation_code TEXT NOT NULL DEFAULT '',
validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ, created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_key,kind,revision));
CREATE UNIQUE INDEX IF NOT EXISTS uq_system_config_active ON system_config_revisions(tenant_key,kind) WHERE state='active';
CREATE TABLE IF NOT EXISTS administrators (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, union_id TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '',
department TEXT NOT NULL DEFAULT '', role TEXT NOT NULL CHECK(role IN ('super_admin','admin')), status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','disabled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,staff_id));
CREATE TABLE IF NOT EXISTS system_resource_bindings (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0), state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')),
public_config JSONB NOT NULL DEFAULT '{}'::JSONB, validation_code TEXT NOT NULL DEFAULT '', validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ,
created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,kind,revision));
CREATE UNIQUE INDEX IF NOT EXISTS uq_resource_binding_active ON system_resource_bindings(tenant_key,kind) WHERE state='active';
CREATE TABLE IF NOT EXISTS config_audit_events (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, actor_staff_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, target_kind TEXT NOT NULL,
target_revision INTEGER NOT NULL DEFAULT 0, result_code TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
@@ -0,0 +1,25 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp012CreatesSettingsWithoutSecrets(t *testing.T) {
b, e := os.ReadFile("000012_system_settings.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
for _, x := range []string{"SYSTEM_CONFIG_REVISIONS", "ADMINISTRATORS", "SYSTEM_RESOURCE_BINDINGS", "CONFIG_AUDIT_EVENTS", "PUBLIC_CONFIG"} {
if !strings.Contains(s, x) {
t.Errorf("missing %s", x)
}
}
for _, x := range []string{"CLIENT_SECRET JSON", "ACCESS_TOKEN JSON", "API_KEY JSON"} {
if strings.Contains(s, x) {
t.Errorf("forbidden %s", x)
}
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS admin_bootstrap_challenges;
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS admin_bootstrap_challenges (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, code_hash TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts>=0), max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts BETWEEN 1 AND 10),
candidate_staff_id TEXT NOT NULL DEFAULT '', candidate_union_id TEXT NOT NULL DEFAULT '', candidate_display_name TEXT NOT NULL DEFAULT '', candidate_department TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK(status IN ('pending','candidate_detected','confirmed','expired','locked')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
CREATE UNIQUE INDEX IF NOT EXISTS uq_admin_bootstrap_pending ON admin_bootstrap_challenges(tenant_key) WHERE status IN ('pending','candidate_detected');
@@ -0,0 +1,18 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp013StoresOnlyBootstrapHash(t *testing.T) {
b, e := os.ReadFile("000013_admin_bootstrap.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
if !strings.Contains(s, "CODE_HASH") || strings.Contains(s, " CODE TEXT") {
t.Fatal("bootstrap must store hash only")
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS setting_sessions;
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS setting_sessions (
tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, conversation_id TEXT NOT NULL, conversation_type TEXT NOT NULL CHECK(conversation_type IN ('direct','group')),
current_step TEXT NOT NULL, pending_action TEXT NOT NULL DEFAULT '', draft_config JSONB NOT NULL DEFAULT '{}'::JSONB, expires_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY(tenant_key,staff_id));
CREATE INDEX IF NOT EXISTS idx_setting_sessions_expiry ON setting_sessions(expires_at);
@@ -0,0 +1,20 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp014CreatesExpiringSettingSessions(t *testing.T) {
b, e := os.ReadFile("000014_setting_sessions.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
for _, x := range []string{"SETTING_SESSIONS", "EXPIRES_AT", "DRAFT_CONFIG"} {
if !strings.Contains(s, x) {
t.Errorf("missing %s", x)
}
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS user_preferences;
@@ -0,0 +1,3 @@
CREATE TABLE IF NOT EXISTS user_preferences (
tenant_key TEXT NOT NULL, channel TEXT NOT NULL, external_actor_id TEXT NOT NULL, language_mode TEXT NOT NULL DEFAULT 'auto' CHECK(language_mode IN ('auto','fixed')),
locale TEXT NOT NULL DEFAULT 'auto' CHECK(locale IN ('auto','zh-CN','en','ja','ko','es')), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY(tenant_key,channel,external_actor_id));
@@ -0,0 +1,20 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp015ScopesPreferencesByTenantAndActor(t *testing.T) {
b, e := os.ReadFile("000015_user_preferences.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
for _, x := range []string{"USER_PREFERENCES", "TENANT_KEY", "EXTERNAL_ACTOR_ID", "LANGUAGE_MODE", "LOCALE"} {
if !strings.Contains(s, x) {
t.Errorf("missing %s", x)
}
}
}
@@ -0,0 +1,3 @@
-- 000016_question_image_urls down migration.
ALTER TABLE turns
DROP COLUMN IF EXISTS question_image_urls_json;
@@ -0,0 +1,6 @@
-- 000016_question_image_urls: stable published URLs for inbound question images.
-- Stores the object-storage HTTPS URLs of images a user sent in a question,
-- so the question-table export can render a readable, traceable record without
-- persisting download codes or data URIs.
ALTER TABLE turns
ADD COLUMN IF NOT EXISTS question_image_urls_json JSONB NOT NULL DEFAULT '[]'::JSONB;
@@ -0,0 +1,3 @@
-- 000017_knowledge_searched down migration.
ALTER TABLE turns
DROP COLUMN IF EXISTS knowledge_searched;
@@ -0,0 +1,6 @@
-- 000017_knowledge_searched: records whether a turn actually triggered a
-- knowledge-base search (knowledge_search tool call). Coverage classification
-- uses this to distinguish pure visual recognition from a searched-but-missed
-- knowledge question.
ALTER TABLE turns
ADD COLUMN IF NOT EXISTS knowledge_searched BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,11 @@
-- 000018_coverage_visual_recognition_reason down migration.
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer'
));
@@ -0,0 +1,14 @@
-- 000018_coverage_visual_recognition_reason: extend the coverage_decisions
-- reason_code allowlist with the new image-visual-recognition reason emitted by
-- coverage Evaluator v3 (visual recognition is not a knowledge-coverage miss).
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition'
));
@@ -0,0 +1,3 @@
-- 000019_reasoning_mode_state down migration.
DROP TABLE IF EXISTS reasoning_mode_turns;
DROP TABLE IF EXISTS reasoning_mode_states;
@@ -0,0 +1,43 @@
-- 000019_reasoning_mode_state: actor-scoped /think state and
-- per-turn idempotency for successful reasoning-mode completions.
CREATE TABLE IF NOT EXISTS reasoning_mode_states (
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
external_actor_id TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'quick'
CHECK (mode IN ('quick', 'reasoning')),
max_rounds INTEGER NOT NULL DEFAULT 10
CHECK (max_rounds BETWEEN 1 AND 50),
rounds_used INTEGER NOT NULL DEFAULT 0
CHECK (rounds_used >= 0 AND rounds_used <= max_rounds),
hint_enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_activity_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
onboarding_shown_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_key, channel, external_actor_id)
);
CREATE INDEX IF NOT EXISTS idx_reasoning_mode_states_expiry
ON reasoning_mode_states (expires_at)
WHERE mode = 'reasoning' AND expires_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS reasoning_mode_turns (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
external_actor_id TEXT NOT NULL,
execution_mode TEXT NOT NULL
CHECK (execution_mode IN ('quick', 'reasoning', 'reasoning_single')),
round_number INTEGER NOT NULL DEFAULT 0 CHECK (round_number >= 0),
showed_hint BOOLEAN NOT NULL DEFAULT FALSE,
auto_closed BOOLEAN NOT NULL DEFAULT FALSE,
showed_onboarding BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
CREATE INDEX IF NOT EXISTS idx_reasoning_mode_turns_actor_created
ON reasoning_mode_turns (tenant_key, channel, external_actor_id, created_at DESC);
@@ -0,0 +1,79 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp019CreatesActorScopedReasoningModeState(t *testing.T) {
data, err := os.ReadFile("000019_reasoning_mode_state.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"REASONING_MODE_STATES",
"TENANT_KEY",
"CHANNEL",
"EXTERNAL_ACTOR_ID",
"MODE",
"MAX_ROUNDS",
"ROUNDS_USED",
"HINT_ENABLED",
"EXPIRES_AT",
"ONBOARDING_SHOWN_AT",
"PRIMARY KEY (TENANT_KEY, CHANNEL, EXTERNAL_ACTOR_ID)",
"MODE IN ('QUICK', 'REASONING')",
"MAX_ROUNDS BETWEEN 1 AND 50",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning state migration missing %q", required)
}
}
}
func TestUp019CreatesTurnIdempotencyLedger(t *testing.T) {
data, err := os.ReadFile("000019_reasoning_mode_state.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"REASONING_MODE_TURNS",
"TURN_ID",
"EXTERNAL_ACTOR_ID",
"EXECUTION_MODE",
"ROUND_NUMBER",
"SHOWED_HINT",
"AUTO_CLOSED",
"SHOWED_ONBOARDING",
"UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)",
"REFERENCES TURNS(ID)",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning turn ledger migration missing %q", required)
}
}
}
func TestDown019RemovesOnlyReasoningModeObjects(t *testing.T) {
data, err := os.ReadFile("000019_reasoning_mode_state.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"DROP TABLE IF EXISTS REASONING_MODE_TURNS",
"DROP TABLE IF EXISTS REASONING_MODE_STATES",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning down migration missing %q", required)
}
}
for _, forbidden := range []string{"DROP TABLE TURNS", "DROP TABLE USER_PREFERENCES"} {
if strings.Contains(source, forbidden) {
t.Errorf("reasoning down migration must not contain %q", forbidden)
}
}
}
@@ -0,0 +1,5 @@
-- 000020_feedback_support_trigger down migration.
DROP INDEX IF EXISTS idx_support_cases_trigger_type_created;
ALTER TABLE support_cases
DROP COLUMN IF EXISTS trigger_feedback_id,
DROP COLUMN IF EXISTS trigger_type;
@@ -0,0 +1,11 @@
-- 000020_feedback_support_trigger: let an existing support-case workflow be
-- triggered by a downvote while preserving one case per successful Turn.
ALTER TABLE support_cases
ALTER COLUMN coverage_decision_id DROP NOT NULL,
ADD COLUMN IF NOT EXISTS trigger_type TEXT NOT NULL DEFAULT 'coverage_uncovered'
CHECK (trigger_type IN ('coverage_uncovered', 'feedback_down')),
ADD COLUMN IF NOT EXISTS trigger_feedback_id TEXT REFERENCES answer_feedback(id);
CREATE INDEX IF NOT EXISTS idx_support_cases_trigger_type_created
ON support_cases (trigger_type, created_at DESC);
@@ -0,0 +1,50 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp020AddsFeedbackSupportTriggerAndMakesCoverageDecisionOptional(t *testing.T) {
data, err := os.ReadFile("000020_feedback_support_trigger.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ALTER TABLE SUPPORT_CASES",
"TRIGGER_TYPE",
"COVERAGE_UNCOVERED",
"FEEDBACK_DOWN",
"TRIGGER_FEEDBACK_ID",
"REFERENCES ANSWER_FEEDBACK(ID)",
"ALTER COLUMN COVERAGE_DECISION_ID DROP NOT NULL",
} {
if !strings.Contains(source, required) {
t.Errorf("feedback support migration missing %q", required)
}
}
if strings.Contains(source, "DROP CONSTRAINT SUPPORT_CASES_COVERAGE_DECISION_ID_FKEY") {
t.Fatal("feedback support must retain the coverage decision foreign-key contract")
}
}
func TestDown020RemovesOnlyFeedbackSupportColumns(t *testing.T) {
data, err := os.ReadFile("000020_feedback_support_trigger.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"DROP COLUMN IF EXISTS TRIGGER_FEEDBACK_ID",
"DROP COLUMN IF EXISTS TRIGGER_TYPE",
} {
if !strings.Contains(source, required) {
t.Errorf("feedback support down migration missing %q", required)
}
}
if strings.Contains(source, "DROP TABLE") {
t.Fatal("feedback support down migration must not drop shared tables")
}
}
@@ -0,0 +1,18 @@
-- 000021_coverage_non_knowledge_response_reason down migration.
-- Map the v4 covered reason to the pre-v4 covered code before restoring the
-- legacy allowlist, so rollback remains valid with persisted v4 decisions.
UPDATE coverage_decisions
SET reason_code = 'covered_has_references'
WHERE reason_code = 'covered_non_knowledge_response';
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition'
));
@@ -0,0 +1,14 @@
-- 000021_coverage_non_knowledge_response_reason: distinguish text turns that
-- did not invoke knowledge search from actual retrieval misses.
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition',
'covered_non_knowledge_response'
));
@@ -0,0 +1,44 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp021AddsCoveredNonKnowledgeResponseReason(t *testing.T) {
data, err := os.ReadFile("000021_coverage_non_knowledge_response_reason.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ALTER TABLE COVERAGE_DECISIONS",
"DROP CONSTRAINT IF EXISTS COVERAGE_DECISIONS_REASON_CODE_CHECK",
"COVERED_NON_KNOWLEDGE_RESPONSE",
"COVERED_VISUAL_RECOGNITION",
} {
if !strings.Contains(source, required) {
t.Errorf("up migration missing %q", required)
}
}
}
func TestDown021MapsNewCoveredReasonBeforeRestoringLegacyConstraint(t *testing.T) {
data, err := os.ReadFile("000021_coverage_non_knowledge_response_reason.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"UPDATE COVERAGE_DECISIONS",
"COVERED_NON_KNOWLEDGE_RESPONSE",
"COVERED_HAS_REFERENCES",
"ALTER TABLE COVERAGE_DECISIONS",
"DROP CONSTRAINT IF EXISTS COVERAGE_DECISIONS_REASON_CODE_CHECK",
} {
if !strings.Contains(source, required) {
t.Errorf("down migration missing %q", required)
}
}
}
@@ -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")
}
}
+121
View File
@@ -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()
+157
View File
@@ -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
```
+22
View File
@@ -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
+121
View File
@@ -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
+76
View File
@@ -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"
+54
View File
@@ -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
+5
View File
@@ -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;
+8
View File
@@ -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)"
+67
View File
@@ -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)"
+67
View File
@@ -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)"
+66
View File
@@ -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"

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