Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bbaf2f07a | |||
| 48b741984f | |||
| 5ccaeb3b96 | |||
| dfce8cb9f6 | |||
| 149e94b34b | |||
| dd229ec0ad | |||
| 92324b4242 | |||
| 882ac62433 | |||
| 8a9c1eaf82 | |||
| 9de1ed66aa | |||
| cfe3271fa2 | |||
| dfc7c8dba9 | |||
| 06d11135e1 | |||
| 5f1206b545 | |||
| a1a2aabab4 | |||
| fcca33050a | |||
| cd3f92aaaa | |||
| 15f2a11162 | |||
| a096ba5f51 | |||
| ba090d1705 | |||
| 4e65028d1d | |||
| c7d2629a77 | |||
| b49cb5eaf9 | |||
| 16aa981028 | |||
| f800127461 | |||
| d6a781ba5b | |||
| 168e401bc0 | |||
| 58b37f1c24 | |||
| 6a3f505965 | |||
| a0f1fbf391 | |||
| 49e0789fa5 | |||
| d77fbd675b | |||
| a615693bd0 | |||
| febf99b549 | |||
| b4b61c0aa5 | |||
| 6153533b46 | |||
| 27d4d45af4 | |||
| a31156dc4e | |||
| f408096611 | |||
| 218ab657d6 | |||
| 5c5e4494cb | |||
| e584b17929 | |||
| 8027635330 | |||
| 563329320b | |||
| 9abbb2cb2b | |||
| 4c5b1e3b5a | |||
| fa938ca1eb | |||
| 7661e1b8f7 | |||
| bfa1b1b25f | |||
| 0798a2dac7 |
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
bin/
|
||||
.acceptance-canary/
|
||||
.hermes/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
+22
-28
@@ -1,58 +1,52 @@
|
||||
# DobotAI configuration
|
||||
# DobotAI 基础配置
|
||||
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
|
||||
DOBOTAI_INGRESS_TOKEN=replace-me
|
||||
# Optional local-only audit API. Use a distinct secret from DOBOTAI_INGRESS_TOKEN.
|
||||
# When set, DOBOTAI_TENANT_KEY must be set and the listen address must be loopback-only.
|
||||
# 可选的仅本机审计 API。必须使用不同于 DOBOTAI_INGRESS_TOKEN 的独立密钥。
|
||||
# 设置后必须同时设置 DOBOTAI_TENANT_KEY,且监听地址必须仅为回环地址。
|
||||
DOBOTAI_ADMIN_TOKEN=
|
||||
DOBOTAI_GROUP_SESSION_POLICY=shared
|
||||
DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"replace-with-agent-id"}
|
||||
# `/think` 路由必填。上面的基础绑定保持稳定的会话身份;以下配置仅选择每个 Turn 的执行 Agent。
|
||||
DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer
|
||||
DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning
|
||||
|
||||
# Official WeKnora API only
|
||||
# 仅使用 WeKnora 官方 API
|
||||
WEKNORA_BASE_URL=http://127.0.0.1:8080
|
||||
WEKNORA_API_KEY=replace-me
|
||||
|
||||
# DingTalk Stream mode (official SDK). Leave all three empty to disable the
|
||||
# DingTalk channel; set all three to enable it. Never commit real values.
|
||||
# 钉钉 Stream 模式(官方 SDK)。三项均留空时禁用钉钉渠道;三项同时设置时启用。不得提交真实值。
|
||||
DINGTALK_CLIENT_ID=
|
||||
DINGTALK_CLIENT_SECRET=
|
||||
# Tenant key this DingTalk ingress serves (matches tenant_key used by QA).
|
||||
# 该钉钉接入服务的租户键(必须与 QA 使用的 tenant_key 一致)。
|
||||
DOBOTAI_TENANT_KEY=
|
||||
|
||||
# Optional DingTalk AI card template ID. Leave empty to use markdown-only replies.
|
||||
# When set, a successful QA answer will be delivered as an AI card; markdown
|
||||
# is only sent as a fallback when card delivery fails. Never commit real values.
|
||||
# 可选的钉钉 AI 卡片模板 ID。留空时仅使用 Markdown 回复。
|
||||
# 设置后,成功的 QA 回答优先通过 AI 卡片发送;仅当卡片发送失败时才回退 Markdown。不得提交真实值。
|
||||
DINGTALK_CARD_TEMPLATE_ID=
|
||||
# Optional, temporary DingTalk card-debug allowlist. When set, only this exact
|
||||
# DingTalk user receives _CARD_DEBUG_TOOL_ENTRY=show in per-user privateData.
|
||||
# Keep empty outside a controlled debug session; never commit a real user ID.
|
||||
# 可选、临时的钉钉卡片调试白名单。设置后,只有该指定钉钉用户会在 per-user privateData 中收到 _CARD_DEBUG_TOOL_ENTRY=show。
|
||||
# 非受控调试期间必须留空;不得提交真实用户 ID。
|
||||
DINGTALK_CARD_DEBUG_USER_ID=
|
||||
|
||||
# Optional DingTalk FAQ table workflow for uncovered questions. Set all four
|
||||
# values together; leave all four empty to disable. Required when the support
|
||||
# group notification below is enabled. Never commit real IDs.
|
||||
# 可选的钉钉 FAQ 表工作流,用于未覆盖问题。四项必须同时设置;四项均留空时禁用。
|
||||
# 启用下方人工支持群通知时,此工作流为必需项。不得提交真实 ID。
|
||||
DINGTALK_FAQ_TABLE_BASE_ID=
|
||||
DINGTALK_FAQ_TABLE_SHEET_ID=
|
||||
DINGTALK_FAQ_TABLE_VIEW_ID=
|
||||
DINGTALK_FAQ_TABLE_OPERATOR_ID=
|
||||
|
||||
# Optional PDF-answer image egress for DingTalk cards. When any value is set,
|
||||
# all five values are required. DobotAI downloads only authenticated resource://
|
||||
# images from WEKNORA_BASE_URL /files, writes content-hashed copies under
|
||||
# dobotai/im-images/, and emits private OSS GET URLs valid for 2 hours.
|
||||
# Keep the bucket private and configure an OSS lifecycle rule to delete
|
||||
# dobotai/im-images/ objects after 1 day. Never commit real credentials.
|
||||
# 可选的钉钉卡片回答图片外发。任一项设置后,以下五项都必须设置。
|
||||
# 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=
|
||||
|
||||
# Optional support group for uncovered-answer notifications. This is the
|
||||
# group's openConversationId, not a webhook. Leave empty to record coverage
|
||||
# decisions and pending support cases without sending group notifications.
|
||||
# When enabled, the FAQ table workflow above must also be configured so the
|
||||
# ActionCard can link to the exact FAQ record. Never commit a real group ID.
|
||||
# 可选的未覆盖回答人工支持群通知。该值是群的 openConversationId,不是 webhook。
|
||||
# 留空时仍记录覆盖判定和待处理 Support Case,但不发送群通知。
|
||||
# 启用后必须同时配置上方 FAQ 表工作流,使 ActionCard 能链接到准确的 FAQ 记录。不得提交真实群 ID。
|
||||
DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID=
|
||||
|
||||
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
|
||||
# 独立的 DobotAI PostgreSQL;不得指向 WeKnora 数据库
|
||||
DATABASE_URL=postgres://dobotai:replace-me@127.0.0.1:5432/dobotai?sslmode=disable
|
||||
|
||||
@@ -10,6 +10,12 @@ coverage.out
|
||||
/backups/
|
||||
/check-live-dobotai.py
|
||||
|
||||
# Transient local validation and legacy test-deployment artifacts
|
||||
/dobotai
|
||||
/.acceptance-canary/
|
||||
/.hermes/
|
||||
/deploy_stable_images_test.py
|
||||
|
||||
# Editors and OS
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+23
@@ -0,0 +1,23 @@
|
||||
FROM golang:1.26.5-bookworm AS build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
ARG GOPROXY=https://proxy.golang.org,direct
|
||||
ENV GOPROXY=${GOPROXY}
|
||||
RUN go mod download
|
||||
COPY . ./
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT_SHA=unknown
|
||||
ARG BUILD_TIME=unknown
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION} -X main.commitSHA=${COMMIT_SHA} -X main.buildTime=${BUILD_TIME}" \
|
||||
-o /out/dobotai ./cmd/dobotai
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /out/dobotai /usr/local/bin/dobotai
|
||||
USER 10001:10001
|
||||
ENTRYPOINT ["/usr/local/bin/dobotai"]
|
||||
@@ -1,115 +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.
|
||||
- unauthenticated read-only `GET /version` runtime build identity endpoint.
|
||||
- DingTalk Stream text ingress (official SDK) wired into `cmd/dobotai`.
|
||||
- v1 knowledge coverage decisions, durable support cases, and optional DingTalk support-group notifications.
|
||||
## 架构边界
|
||||
|
||||
## DingTalk channel (Stream, text only)
|
||||
```text
|
||||
钉钉 / 内部调用方
|
||||
↓
|
||||
DobotAI
|
||||
├─ 钉钉 Stream、richText、图片、AI 卡片、反馈与通知
|
||||
├─ 独立 PostgreSQL:渠道身份、消息、会话映射、Turn、覆盖与支持流程
|
||||
└─ WeKnora 官方 REST / SSE API
|
||||
↓
|
||||
WeKnora:检索、Agent、知识与引用
|
||||
```
|
||||
|
||||
- Uses the official DingTalk Stream SDK (`dingtalk-stream-sdk-go`); no public webhook endpoint is exposed.
|
||||
- Text messages only for now. Images, OpenAPI fallback, and card replies are not implemented yet.
|
||||
- Group chats are handled only when the bot is @-mentioned; other group messages are ignored.
|
||||
- Sessions: direct chats use one long-lived session per user; group chats share one session per group (default `shared` policy).
|
||||
- Replies are sent via the callback's `sessionWebhook`, which is fail-closed: only `https` URLs on `*.dingtalk.com` (port 443, no redirects) are accepted.
|
||||
- Required env vars to enable (all three or none): `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, `DOBOTAI_TENANT_KEY`. Partial configuration is a fatal startup error.
|
||||
- Optional `DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID` enables durable uncovered-answer ActionCard notifications through the official group-message API. It requires the FAQ table workflow below so every “去回答” button targets an exact record. When empty, coverage decisions and support cases are still recorded but no group message is sent.
|
||||
责任边界:
|
||||
|
||||
## DingTalk question table sync (Notable v1)
|
||||
- **DobotAI**:钉钉协议、媒体收发、会话映射、命令路由、卡片、覆盖判定、人工支持、渠道审计。
|
||||
- **WeKnora**:通用 Agent Chat、检索、知识存储、引用与模型调用。
|
||||
- **DobotAI PostgreSQL**:只保存渠道与业务编排数据,不保存 WeKnora 知识正文。
|
||||
- **Redis**:仅用于队列、重试、幂等租约和短期状态。
|
||||
|
||||
Successful DingTalk QA turns can be appended asynchronously to a DingTalk Notable AI table through a durable PostgreSQL outbox. Notable failures never change the successful QA result; the worker retries with bounded backoff and stores only stable failure codes.
|
||||
## 目录结构
|
||||
|
||||
Apply `db/migrations/000010_question_table_exports.up.sql` before enabling the worker. The three table settings are optional as a group: all blank disables dispatch, while a partial configuration fails startup closed.
|
||||
```text
|
||||
.
|
||||
├── cmd/
|
||||
│ ├── dobotai/ 主程序入口、运行时装配与后台 Worker
|
||||
│ └── directory-import/ 钉钉通讯录导入命令
|
||||
├── internal/
|
||||
│ ├── channels/dingtalk/ 钉钉 Stream、卡片、媒体、反馈与群通知
|
||||
│ ├── qa/ 问答主流程、WeKnora 调用和检索信号记录
|
||||
│ ├── weknora/ SSE、工具结果和引用解析
|
||||
│ ├── coverage/ 覆盖判定、Support Case 和人工提醒
|
||||
│ ├── reasoningmode/ /think 推理模式
|
||||
│ ├── settings/ /setting 与首位管理员绑定
|
||||
│ ├── outboundimage/ resource:// 图片私有 OSS 外发
|
||||
│ ├── questiontable/ 问题表工作流
|
||||
│ ├── faqtable/ FAQ 表工作流
|
||||
│ ├── directory/ 钉钉通讯录查询与导入
|
||||
│ ├── audit/ 审计查询
|
||||
│ ├── httpapi/ /healthz、/version、/v1/qa 与审计 API
|
||||
│ ├── core/ 核心领域类型
|
||||
│ ├── session/ 会话键和会话标题
|
||||
│ ├── agentbinding/ 渠道到 WeKnora Agent 的绑定
|
||||
│ ├── feedback/ 点赞/点踩领域逻辑
|
||||
│ ├── secretstore/ 本地密钥引用存储
|
||||
│ ├── dingtalkconfig/ 钉钉凭据校验
|
||||
│ ├── runtime/ 运行时管理
|
||||
│ └── store/postgres/ PostgreSQL 持久化实现
|
||||
├── db/migrations/ SQL 迁移及其契约测试
|
||||
├── deploy/dobotai-dingtalk/ 容器 Compose、安装/配置/启动/绑定脚本和部署手册
|
||||
├── docs/ 架构说明与 API 边界文档
|
||||
├── scripts/ 本地部署与部署资产契约测试
|
||||
├── Dockerfile DobotAI 容器镜像构建
|
||||
├── .env.example 无真实凭据的配置示例
|
||||
└── README.md 项目功能、边界和开发说明
|
||||
```
|
||||
|
||||
- `DINGTALK_QUESTION_TABLE_BASE_ID`
|
||||
- `DINGTALK_QUESTION_TABLE_SHEET_ID`
|
||||
- `DINGTALK_QUESTION_TABLE_OPERATOR_ID` (DingTalk unionId)
|
||||
目录职责规则:`cmd/`只装配进程,`internal/`承载业务模块,`internal/store/postgres/`只实现数据库持久化,`db/migrations/`只放Schema变更,`deploy/`只放容器运行资产。历史计划、备份、镜像归档和真实环境文件均不进入Git。
|
||||
|
||||
The worker reuses `DINGTALK_CLIENT_ID` and `DINGTALK_CLIENT_SECRET`. The DingTalk application must have `Notable.Base.Read.All` and `Notable.Base.Write.All`. Only successful `channel=dingtalk` turns are enqueued; other channels are excluded. The first release writes the nine basic text/date/user fields and omits the five select-classification fields.
|
||||
## 部署方式
|
||||
|
||||
## Uncovered-question FAQ workflow
|
||||
| 方式 | 适用场景 | 数据库 | 入口 |
|
||||
| --- | --- | --- | --- |
|
||||
| 源码/原生二进制 | 本地开发、快速调试 | 手工配置阿里云RDS连接 | `go run ./cmd/dobotai` |
|
||||
| Docker | 测试、预发布、生产候选 | 既有阿里云RDS;Docker不运行数据库 | `sudo bash ./install-docker.sh` |
|
||||
|
||||
Apply `db/migrations/000011_faq_exports.up.sql` before enabling this workflow. Existing support cases are marked `not_applicable` and are not backfilled; only support cases created after the migration default to `pending` FAQ export.
|
||||
### 源码部署
|
||||
|
||||
Set all four values together, or leave all four blank to disable FAQ export. Partial configuration fails startup closed:
|
||||
```bash
|
||||
go run ./cmd/dobotai
|
||||
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
|
||||
```
|
||||
|
||||
- `DINGTALK_FAQ_TABLE_BASE_ID`
|
||||
- `DINGTALK_FAQ_TABLE_SHEET_ID`
|
||||
- `DINGTALK_FAQ_TABLE_VIEW_ID`
|
||||
- `DINGTALK_FAQ_TABLE_OPERATOR_ID` (DingTalk unionId)
|
||||
源码方式不自动安装Docker、不创建目录、不检查RDS表,也不适合作为长期渠道消费者。
|
||||
|
||||
For every newly detected uncovered answer, the asynchronous worker creates one FAQ record with standard question, original question, detected language, and asker. After DingTalk returns the `recordId`, DobotAI stores it and sends a built-in `sampleActionCard` containing only the current question and up to five earlier questions from the same stable actor. The “去回答” button opens that exact FAQ record. FAQ insert failures are retried with bounded backoff and never block the customer reply.
|
||||
### Docker + 阿里云RDS部署
|
||||
|
||||
## WeKnora baseline
|
||||
克隆仓库后在根目录执行:
|
||||
|
||||
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.
|
||||
```bash
|
||||
sudo bash ./install-docker.sh
|
||||
sudo dobotai-dingtalk-configure
|
||||
```
|
||||
|
||||
## Run
|
||||
第一条命令自动安装Docker/Compose(如缺失)、构建应用镜像、准备目录和配置模板;第二条命令安全录入RDS、WeKnora与钉钉配置,然后检查RDS表:空库初始化`000001`至`000021`,完整表不写入,异常/半成品表拒绝启动。Docker只运行`dobotai-dingtalk`,不创建`dobotai-postgres`、本地数据卷或本地数据库。
|
||||
|
||||
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:
|
||||
完整安装、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
|
||||
```
|
||||
|
||||
Runtime version check:
|
||||
运行版本:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8090/version
|
||||
```
|
||||
|
||||
`scripts/deploy-local.sh` builds from its own worktree (or the explicit
|
||||
`DOBOTAI_SOURCE_ROOT`), injects `version`, `commit_sha`, and `build_time`, and
|
||||
accepts the deployment only when `/version` reports the exact source commit.
|
||||
It refuses dirty worktrees so the reported SHA cannot hide uncommitted source.
|
||||
部署脚本应将 `version`、`commit_sha` 和 `build_time` 写入二进制,并仅在 `/version` 返回预期提交时接受部署。候选源码必须干净,避免运行版本掩盖未提交改动。
|
||||
|
||||
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"
|
||||
}'
|
||||
```
|
||||
- 不修改 WeKnora 源码、Schema 或私有包;
|
||||
- 不直接连接 WeKnora 数据库;
|
||||
- 不将凭据、Token、预签名 URL 或知识正文写入日志;
|
||||
- 不接受任意外部图片 URL 作为可信媒体;
|
||||
- 对钉钉图片、WeKnora `resource://` 图片和 OSS 返回体均执行来源、大小、类型和签名校验;
|
||||
- 外发 OSS Bucket 保持私有;
|
||||
- 生产、测试的钉钉应用、数据库、OSS Bucket 和凭据必须相互隔离;
|
||||
- 生产上线必须使用已验收的同一构建产物,禁止在生产重新编译。
|
||||
|
||||
## Hard boundaries
|
||||
|
||||
- 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.
|
||||
更多设计与边界见 `docs/architecture.md`。
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
||||
)
|
||||
|
||||
const bootstrapAdminTTL = 10 * time.Minute
|
||||
|
||||
type bootstrapAdminRepository interface {
|
||||
AdministratorCount(context.Context, string) (int, error)
|
||||
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
|
||||
ConfirmBootstrap(context.Context, string) error
|
||||
}
|
||||
|
||||
var createBootstrapChallenge = func(ctx context.Context, repo bootstrapAdminRepository, tenant, code string, expiry time.Time) error {
|
||||
return repo.CreateBootstrapChallenge(ctx, tenant, code, expiry)
|
||||
}
|
||||
|
||||
func runBootstrapAdminCommand(ctx context.Context, output io.Writer) error {
|
||||
tenant := strings.TrimSpace(os.Getenv("DOBOTAI_TENANT_KEY"))
|
||||
store, err := postgres.Open(ctx, os.Getenv("DATABASE_URL"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
repository, err := postgres.NewSettingsRepository(store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runBootstrapAdmin(ctx, output, repository, tenant, cryptorand.Reader, time.Now, time.Second)
|
||||
}
|
||||
|
||||
func runBootstrapAdmin(ctx context.Context, output io.Writer, repository bootstrapAdminRepository, tenant string, random io.Reader, now func() time.Time, pollInterval time.Duration) error {
|
||||
if repository == nil || strings.TrimSpace(tenant) == "" || random == nil || now == nil || pollInterval <= 0 {
|
||||
return errors.New("bootstrap administrator command requires repository, tenant, random source, clock, and poll interval")
|
||||
}
|
||||
count, err := repository.AdministratorCount(ctx, tenant)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check active administrators: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New("an active administrator already exists; use administrator management instead")
|
||||
}
|
||||
codeNumber, err := cryptorand.Int(random, big.NewInt(1_000_000))
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate bootstrap code: %w", err)
|
||||
}
|
||||
code := fmt.Sprintf("%06d", codeNumber.Int64())
|
||||
expiresAt := now().Add(bootstrapAdminTTL)
|
||||
if err := createBootstrapChallenge(ctx, repository, tenant, code, expiresAt); err != nil {
|
||||
return fmt.Errorf("create bootstrap challenge: %w", err)
|
||||
}
|
||||
fmt.Fprintf(output, "首位管理员绑定已开始。\n绑定码:%s\n请在%s前私聊钉钉机器人发送:/setting %s\n终端正在等待绑定完成;按 Ctrl+C 取消。\n", code, expiresAt.Local().Format("2006-01-02 15:04:05"), code)
|
||||
|
||||
waitFor := expiresAt.Sub(now())
|
||||
if waitFor <= 0 {
|
||||
return errors.New("bootstrap binding expiry must be in the future")
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(ctx, waitFor)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
err := repository.ConfirmBootstrap(waitCtx, tenant)
|
||||
if err == nil {
|
||||
fmt.Fprintln(output, "首位管理员绑定成功:候选用户已设为 super_admin。")
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, settings.ErrBootstrapCandidatePending) {
|
||||
return fmt.Errorf("confirm bootstrap candidate: %w", err)
|
||||
}
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
return errors.New("bootstrap binding expired or cancelled before a candidate was verified")
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
)
|
||||
|
||||
type bootstrapAdminRepoStub struct {
|
||||
administratorCount int
|
||||
createdCode string
|
||||
createdExpiry time.Time
|
||||
confirmCalls int
|
||||
}
|
||||
|
||||
func (r *bootstrapAdminRepoStub) AdministratorCount(context.Context, string) (int, error) {
|
||||
return r.administratorCount, nil
|
||||
}
|
||||
|
||||
func (r *bootstrapAdminRepoStub) CreateBootstrapChallenge(_ context.Context, _ string, code string, expiry time.Time) error {
|
||||
r.createdCode, r.createdExpiry = code, expiry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *bootstrapAdminRepoStub) ConfirmBootstrap(context.Context, string) error {
|
||||
r.confirmCalls++
|
||||
if r.confirmCalls == 1 {
|
||||
return settings.ErrBootstrapCandidatePending
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRunBootstrapAdminGeneratesCodeWaitsAndConfirmsCandidate(t *testing.T) {
|
||||
now := time.Date(2026, time.September, 10, 8, 0, 0, 0, time.UTC)
|
||||
repo := &bootstrapAdminRepoStub{}
|
||||
var output bytes.Buffer
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := runBootstrapAdmin(ctx, &output, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), func() time.Time { return now }, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.createdCode != "000000" {
|
||||
t.Fatalf("code=%q", repo.createdCode)
|
||||
}
|
||||
if !repo.createdExpiry.Equal(now.Add(10 * time.Minute)) {
|
||||
t.Fatalf("expiry=%s", repo.createdExpiry)
|
||||
}
|
||||
if repo.confirmCalls < 2 {
|
||||
t.Fatalf("confirm calls=%d", repo.confirmCalls)
|
||||
}
|
||||
for _, required := range []string{"/setting 000000", "绑定成功", "super_admin"} {
|
||||
if !strings.Contains(output.String(), required) {
|
||||
t.Fatalf("output=%q missing %q", output.String(), required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBootstrapAdminRefusesWhenAdministratorAlreadyExists(t *testing.T) {
|
||||
repo := &bootstrapAdminRepoStub{administratorCount: 1}
|
||||
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
|
||||
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if repo.createdCode != "" || repo.confirmCalls != 0 {
|
||||
t.Fatalf("repo mutated: %+v", repo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBootstrapAdminPropagatesChallengeCreationFailure(t *testing.T) {
|
||||
repo := &bootstrapAdminRepoStub{}
|
||||
original := createBootstrapChallenge
|
||||
createBootstrapChallenge = func(context.Context, bootstrapAdminRepository, string, string, time.Time) error {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
t.Cleanup(func() { createBootstrapChallenge = original })
|
||||
|
||||
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
|
||||
if err == nil || !strings.Contains(err.Error(), "database unavailable") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
)
|
||||
|
||||
type candidateConfigRepository interface {
|
||||
CandidateDingTalkConfig(context.Context, string) (string, string, error)
|
||||
ActivateDingTalkCandidate(context.Context, string, string) error
|
||||
}
|
||||
|
||||
type candidateCredentialVerifier interface {
|
||||
Verify(context.Context, string, string) dingtalkconfig.Result
|
||||
}
|
||||
|
||||
var (
|
||||
candidateTokenURL = ""
|
||||
candidateTableBaseURL = ""
|
||||
)
|
||||
|
||||
type dingTalkCandidateActivator struct {
|
||||
tenant string
|
||||
repo candidateConfigRepository
|
||||
secrets *secretstore.Store
|
||||
verifier candidateCredentialVerifier
|
||||
bindings map[string]settings.ResourceBinding
|
||||
restart func()
|
||||
}
|
||||
|
||||
func (a *dingTalkCandidateActivator) ActivateCandidate(ctx context.Context) error {
|
||||
if a == nil || a.repo == nil || a.secrets == nil || a.verifier == nil || a.restart == nil {
|
||||
return errors.New("candidate_activation_unavailable")
|
||||
}
|
||||
clientID, secretRef, err := a.repo.CandidateDingTalkConfig(ctx, a.tenant)
|
||||
if err != nil {
|
||||
return errors.New("candidate_not_found")
|
||||
}
|
||||
secret, err := a.secrets.Get(secretRef)
|
||||
if err != nil {
|
||||
return errors.New("candidate_secret_unavailable")
|
||||
}
|
||||
if result := a.verifier.Verify(ctx, clientID, secret); !result.OK {
|
||||
return errors.New("candidate_oauth_failed")
|
||||
}
|
||||
for _, kind := range []string{"question_table", "faq_table"} {
|
||||
binding, ok := a.bindings[kind]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
client, err := questiontable.NewClient(questiontable.Config{ClientID: clientID, Secret: secret, BaseID: binding.BaseID, SheetID: binding.SheetID, OperatorID: binding.OperatorID, TokenURL: candidateTokenURL, BaseURL: candidateTableBaseURL}, nil)
|
||||
if err != nil {
|
||||
return errors.New("candidate_table_config_failed")
|
||||
}
|
||||
fields, err := client.ReadFields(ctx)
|
||||
if err != nil {
|
||||
return errors.New("candidate_table_probe_failed")
|
||||
}
|
||||
required := questiontable.QuestionSchema()
|
||||
if kind == "faq_table" {
|
||||
required = questiontable.FAQSchema()
|
||||
}
|
||||
if _, err = questiontable.MapRequiredFields(fields, required); err != nil {
|
||||
return errors.New("candidate_table_schema_failed")
|
||||
}
|
||||
}
|
||||
if err := a.repo.ActivateDingTalkCandidate(ctx, a.tenant, secretRef); err != nil {
|
||||
return errors.New("candidate_database_activation_failed")
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
a.restart()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
)
|
||||
|
||||
type candidateRepoStub struct {
|
||||
activated bool
|
||||
ref string
|
||||
}
|
||||
|
||||
func (r *candidateRepoStub) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
|
||||
return "app", r.ref, nil
|
||||
}
|
||||
func (r *candidateRepoStub) ActivateDingTalkCandidate(context.Context, string, string) error {
|
||||
r.activated = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type candidateVerifierStub struct{ ok bool }
|
||||
|
||||
func (v candidateVerifierStub) Verify(context.Context, string, string) dingtalkconfig.Result {
|
||||
return dingtalkconfig.Result{OK: v.ok}
|
||||
}
|
||||
|
||||
func TestCandidateActivatorRejectsOAuthWithoutActivation(t *testing.T) {
|
||||
secrets, _ := secretstore.New(t.TempDir())
|
||||
ref, _ := secrets.Put("dingtalk", "secret")
|
||||
repo := &candidateRepoStub{ref: ref}
|
||||
a := &dingTalkCandidateActivator{tenant: "t", repo: repo, secrets: secrets, verifier: candidateVerifierStub{}, bindings: map[string]settings.ResourceBinding{}, restart: func() {}}
|
||||
if err := a.ActivateCandidate(context.Background()); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if repo.activated {
|
||||
t.Fatal("must not activate")
|
||||
}
|
||||
}
|
||||
func TestCandidateActivatorValidatesSchemasThenActivates(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"accessToken":"token","expireIn":7200}`))
|
||||
})
|
||||
mux.HandleFunc("/v1.0/notable/bases/base/sheets/sheet/fields", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"value":[{"id":"q","name":"中文问题","type":"text"},{"id":"tm","name":"提问时间","type":"date"},{"id":"u","name":"提问人","type":"user"},{"id":"o","name":"原始问题","type":"text"},{"id":"a","name":"AI答案","type":"text"},{"id":"p","name":"流程触发人员信息","type":"text"},{"id":"l","name":"语言","type":"text"},{"id":"d","name":"部门","type":"text"},{"id":"r","name":"区域","type":"text"}]}`))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
secrets, _ := secretstore.New(t.TempDir())
|
||||
ref, _ := secrets.Put("dingtalk", "secret")
|
||||
repo := &candidateRepoStub{ref: ref}
|
||||
restarted := make(chan struct{}, 1)
|
||||
a := &dingTalkCandidateActivator{tenant: "t", repo: repo, secrets: secrets, verifier: candidateVerifierStub{ok: true}, bindings: map[string]settings.ResourceBinding{"question_table": {BaseID: "base", SheetID: "sheet", OperatorID: "u"}}, restart: func() { restarted <- struct{}{} }}
|
||||
oldTokenURL, oldBaseURL := candidateTokenURL, candidateTableBaseURL
|
||||
candidateTokenURL, candidateTableBaseURL = server.URL+"/token", server.URL
|
||||
defer func() { candidateTokenURL, candidateTableBaseURL = oldTokenURL, oldBaseURL }()
|
||||
if err := a.ActivateCandidate(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !repo.activated {
|
||||
t.Fatal("expected activation")
|
||||
}
|
||||
select {
|
||||
case <-restarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("restart not requested")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"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 ""
|
||||
}
|
||||
}
|
||||
+61
-48
@@ -12,7 +12,6 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/agentbinding"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/httpapi"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/qa"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
||||
@@ -44,6 +44,12 @@ func runtimeBuildInfo() httpapi.BuildInfo {
|
||||
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()
|
||||
@@ -63,8 +69,7 @@ func main() {
|
||||
address = "127.0.0.1:8090"
|
||||
}
|
||||
if strings.TrimSpace(settingsRuntime.clientID) == "" || strings.TrimSpace(settingsRuntime.secret) == "" {
|
||||
runSetupOnly(ctx, cancel, settingsRuntime, address, os.Getenv("DOBOTAI_TENANT_KEY"), &restartRequested)
|
||||
return
|
||||
log.Fatal("DingTalk credentials are required; configure DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET in the protected runtime environment")
|
||||
}
|
||||
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second)
|
||||
if err != nil {
|
||||
@@ -74,6 +79,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
|
||||
@@ -93,6 +118,9 @@ func main() {
|
||||
qa.WithSucceededTurnObserver(coverageService, func(err error) {
|
||||
log.Printf("coverage persistence failed: %v", err)
|
||||
}),
|
||||
qa.WithReasoningModeFinalizer(reasoningService, func(err error) {
|
||||
log.Printf("reasoning mode completion failed: %T", err)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -119,11 +147,6 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
settingsRuntime.admin.SetCandidateActivator(&dingTalkCandidateActivator{
|
||||
tenant: dingCfg.TenantKey, repo: settingsRuntime.repository, secrets: settingsRuntime.secrets,
|
||||
verifier: settingsRuntime.verifier, bindings: activeBindings,
|
||||
restart: func() { restartRequested.Store(true); cancel() },
|
||||
})
|
||||
runtimeReady := make(chan struct{})
|
||||
faqCfg, err := readFAQConfig()
|
||||
if err != nil {
|
||||
@@ -185,6 +208,24 @@ func main() {
|
||||
}()
|
||||
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 {
|
||||
@@ -200,7 +241,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
qtDispatcher, err := buildQuestionTableDispatcher(qtRepo, dingCfg.ClientID, dingCfg.ClientSecret, qtCfg)
|
||||
qtDispatcher, err := buildQuestionTableDispatcher(qtRepo, dingCfg.ClientID, dingCfg.ClientSecret, qtCfg, answerRewriterAdapter{rewriter: outboundImages.rewriter})
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk question table dispatcher init failed: %v", err)
|
||||
}
|
||||
@@ -232,18 +273,6 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk directory resolver init failed: %v", err)
|
||||
}
|
||||
outboundRewriter, err := buildOutboundImageRewriter(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"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk outbound image init failed: %v", err)
|
||||
}
|
||||
feedbackService, err := feedback.NewService(dingCfg.TenantKey, "dingtalk", store)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -278,17 +307,24 @@ func main() {
|
||||
}
|
||||
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,
|
||||
ImagePublisher: outboundImages.publisher,
|
||||
CardSender: cardSender,
|
||||
Directory: directoryResolver,
|
||||
OutboundRewriter: outboundRewriter,
|
||||
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 {
|
||||
@@ -309,33 +345,10 @@ func main() {
|
||||
log.Printf("DingTalk Stream ingress started for tenant %s", dingCfg.TenantKey)
|
||||
}
|
||||
close(runtimeReady)
|
||||
_, questionBound := activeBindings["question_table"]
|
||||
_, faqBound := activeBindings["faq_table"]
|
||||
_, supportBound := activeBindings["support_group"]
|
||||
weknoraHealthURL := strings.TrimRight(strings.TrimSpace(os.Getenv("WEKNORA_BASE_URL")), "/")
|
||||
if weknoraHealthURL != "" {
|
||||
weknoraHealthURL += "/health"
|
||||
}
|
||||
settingsRuntime.admin.SetOperationalStatusProvider(&operationalStatusProvider{
|
||||
metrics: store,
|
||||
tenant: dingCfg.TenantKey,
|
||||
weknoraHealthURL: weknoraHealthURL,
|
||||
httpClient: &http.Client{Timeout: 3 * time.Second},
|
||||
now: time.Now,
|
||||
version: runtimeBuildInfo().Version,
|
||||
streamRunning: streamClient != nil,
|
||||
questionRunning: questionTableDone != nil,
|
||||
faqRunning: faqWorkflowDone != nil,
|
||||
supportRunning: supportDispatcher != nil,
|
||||
questionBound: questionBound,
|
||||
faqBound: faqBound,
|
||||
supportBound: supportBound,
|
||||
ossConfigured: outboundOSSConfigured(),
|
||||
})
|
||||
rootHandler := http.NewServeMux()
|
||||
rootHandler.Handle("/", api.Handler())
|
||||
if strings.TrimSpace(os.Getenv("DOBOTAI_ADMIN_TOKEN")) != "" {
|
||||
rootHandler.Handle("/admin/", adminweb.New(settingsRuntime.admin, os.Getenv("DOBOTAI_ADMIN_TOKEN")))
|
||||
if outboundImages.handler != nil {
|
||||
rootHandler.Handle(outboundImageRoute, outboundImages.handler)
|
||||
}
|
||||
server := &http.Server{Addr: address, Handler: rootHandler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
|
||||
go func() {
|
||||
@@ -377,7 +390,7 @@ 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_OSS_BUCKET", "DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL",
|
||||
} {
|
||||
if strings.TrimSpace(os.Getenv(key)) == "" {
|
||||
return false
|
||||
|
||||
+113
-8
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
|
||||
)
|
||||
@@ -116,21 +117,21 @@ func TestMainWiresOptionalDingTalkCardDebugUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutboundImageRewriterConfig(t *testing.T) {
|
||||
func TestBuildOutboundImageRuntimeConfig(t *testing.T) {
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
rewriter, err := buildOutboundImageRewriter(outboundImageConfig{})
|
||||
if err != nil || rewriter != nil {
|
||||
t.Fatalf("rewriter=%v err=%v", rewriter, err)
|
||||
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 := buildOutboundImageRewriter(outboundImageConfig{Bucket: "private-bucket"})
|
||||
_, err := buildOutboundImageRuntime(outboundImageConfig{Bucket: "private-bucket"})
|
||||
if err == nil {
|
||||
t.Fatal("expected configuration error")
|
||||
}
|
||||
})
|
||||
t.Run("complete configuration", func(t *testing.T) {
|
||||
rewriter, err := buildOutboundImageRewriter(outboundImageConfig{
|
||||
runtime, err := buildOutboundImageRuntime(outboundImageConfig{
|
||||
WeKnoraBaseURL: "http://127.0.0.1:8080",
|
||||
WeKnoraAPIKey: "api-key",
|
||||
Endpoint: "https://oss-cn-shenzhen.aliyuncs.com",
|
||||
@@ -138,9 +139,113 @@ func TestBuildOutboundImageRewriterConfig(t *testing.T) {
|
||||
AccessKeyID: "access-key-id",
|
||||
AccessKeySecret: "access-key-secret",
|
||||
Bucket: "private-bucket",
|
||||
PublicBaseURL: "https://weknora-api-test.dobot.cn/dingtalk/i",
|
||||
})
|
||||
if err != nil || rewriter == nil {
|
||||
t.Fatalf("rewriter=%v err=%v", rewriter, err)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
||||
)
|
||||
|
||||
type adminComponent = adminweb.Component
|
||||
type operationalMetrics = postgres.OperationalMetrics
|
||||
|
||||
type operationalMetricsSource interface {
|
||||
Ping(context.Context) error
|
||||
ReadOperationalMetrics(context.Context, string) (postgres.OperationalMetrics, error)
|
||||
}
|
||||
|
||||
type operationalStatusProvider struct {
|
||||
metrics operationalMetricsSource
|
||||
tenant string
|
||||
weknoraHealthURL string
|
||||
httpClient *http.Client
|
||||
now func() time.Time
|
||||
version string
|
||||
streamRunning bool
|
||||
questionRunning bool
|
||||
faqRunning bool
|
||||
supportRunning bool
|
||||
questionBound bool
|
||||
faqBound bool
|
||||
supportBound bool
|
||||
ossConfigured bool
|
||||
}
|
||||
|
||||
func (p *operationalStatusProvider) Components(ctx context.Context) ([]adminweb.Component, error) {
|
||||
now := time.Now()
|
||||
if p.now != nil {
|
||||
now = p.now()
|
||||
}
|
||||
components := []adminweb.Component{{Name: "运行版本", State: "ok", Code: "version_ok", Detail: "版本:" + safeVersion(p.version), LastSuccess: now, CheckedAt: now}}
|
||||
|
||||
metrics := operationalMetrics{}
|
||||
if p.metrics == nil || p.metrics.Ping(ctx) != nil {
|
||||
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "error", Code: "database_unavailable", Detail: "数据库健康检查失败", CheckedAt: now})
|
||||
} else {
|
||||
var err error
|
||||
metrics, err = p.metrics.ReadOperationalMetrics(ctx, p.tenant)
|
||||
if err != nil {
|
||||
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "error", Code: "metrics_query_failed", Detail: "数据库可连接,但状态聚合失败", LastSuccess: now, CheckedAt: now})
|
||||
} else {
|
||||
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "ok", Code: "database_ok", Detail: "连接与状态聚合正常", LastSuccess: now, CheckedAt: now})
|
||||
}
|
||||
}
|
||||
|
||||
components = append(components,
|
||||
booleanComponent("钉钉Stream", p.streamRunning, "stream_running", "stream_stopped", "正式Stream消费者运行中", "正式Stream消费者未运行", now),
|
||||
p.weknoraComponent(ctx, now),
|
||||
outboxComponent("问题表Outbox", metrics.QuestionPending, metrics.QuestionFailed, metrics.LastQuestionSuccess, now),
|
||||
outboxComponent("FAQ Outbox", metrics.FAQPending, metrics.FAQFailed, metrics.LastFAQSuccess, now),
|
||||
supportComponent(p, metrics, now),
|
||||
dispatcherComponent("问题表Dispatcher", p.questionBound, p.questionRunning, now),
|
||||
dispatcherComponent("FAQ Dispatcher", p.faqBound, p.faqRunning, now),
|
||||
booleanComponent("Private OSS图片", p.ossConfigured, "oss_configured", "oss_unconfigured", "图片出站配置完整", "图片出站未配置", now),
|
||||
)
|
||||
return components, nil
|
||||
}
|
||||
|
||||
func (p *operationalStatusProvider) weknoraComponent(ctx context.Context, now time.Time) adminweb.Component {
|
||||
component := adminweb.Component{Name: "WeKnora", State: "error", Code: "health_probe_failed", Detail: "健康探针失败", CheckedAt: now}
|
||||
if strings.TrimSpace(p.weknoraHealthURL) == "" {
|
||||
component.Code = "health_url_missing"
|
||||
component.Detail = "健康探针地址未配置"
|
||||
return component
|
||||
}
|
||||
client := p.httpClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 3 * time.Second}
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, p.weknoraHealthURL, nil)
|
||||
if err != nil {
|
||||
return component
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return component
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
component.Code = "health_http_error"
|
||||
component.Detail = fmt.Sprintf("健康探针HTTP %d", resp.StatusCode)
|
||||
return component
|
||||
}
|
||||
component.State, component.Code, component.Detail = "ok", "health_ok", "HTTP健康探针正常"
|
||||
component.LastSuccess = now
|
||||
return component
|
||||
}
|
||||
|
||||
func outboxComponent(name string, pending, failed int, lastSuccess, now time.Time) adminweb.Component {
|
||||
component := adminweb.Component{Name: name, State: "ok", Code: "outbox_empty", Detail: "pending=0,failed=0", LastSuccess: lastSuccess, CheckedAt: now}
|
||||
if failed > 0 {
|
||||
component.State, component.Code = "error", "outbox_failed"
|
||||
} else if pending > 0 {
|
||||
component.State, component.Code = "warning", "outbox_pending"
|
||||
}
|
||||
component.Detail = fmt.Sprintf("pending=%d,failed=%d", pending, failed)
|
||||
return component
|
||||
}
|
||||
|
||||
func supportComponent(p *operationalStatusProvider, metrics operationalMetrics, now time.Time) adminweb.Component {
|
||||
component := adminweb.Component{Name: "技术支持通知", CheckedAt: now, LastSuccess: metrics.LastSupportSuccess}
|
||||
switch {
|
||||
case !p.supportBound:
|
||||
component.State, component.Code, component.Detail = "disabled", "binding_missing", "技术支持群未绑定"
|
||||
case !p.faqBound:
|
||||
component.State, component.Code, component.Detail = "blocked", "faq_binding_required", "已绑定支持群;需先激活FAQ表"
|
||||
case metrics.SupportFailed > 0:
|
||||
component.State, component.Code = "error", "notification_failed"
|
||||
component.Detail = fmt.Sprintf("pending=%d,failed=%d", metrics.SupportPending, metrics.SupportFailed)
|
||||
case metrics.SupportPending > 0:
|
||||
component.State, component.Code = "warning", "notification_pending"
|
||||
component.Detail = fmt.Sprintf("pending=%d,failed=0", metrics.SupportPending)
|
||||
case p.supportRunning:
|
||||
component.State, component.Code, component.Detail = "ok", "dispatcher_running", "通知Dispatcher运行中;pending=0,failed=0"
|
||||
default:
|
||||
component.State, component.Code, component.Detail = "error", "dispatcher_stopped", "绑定与依赖完整,但Dispatcher未运行"
|
||||
}
|
||||
return component
|
||||
}
|
||||
|
||||
func dispatcherComponent(name string, bound, running bool, now time.Time) adminweb.Component {
|
||||
if !bound {
|
||||
return adminweb.Component{Name: name, State: "disabled", Code: "binding_missing", Detail: "无active绑定", CheckedAt: now}
|
||||
}
|
||||
if !running {
|
||||
return adminweb.Component{Name: name, State: "error", Code: "dispatcher_stopped", Detail: "已有active绑定,但Dispatcher未运行", CheckedAt: now}
|
||||
}
|
||||
return adminweb.Component{Name: name, State: "ok", Code: "dispatcher_running", Detail: "Dispatcher运行中", LastSuccess: now, CheckedAt: now}
|
||||
}
|
||||
|
||||
func booleanComponent(name string, ok bool, successCode, failureCode, successDetail, failureDetail string, now time.Time) adminweb.Component {
|
||||
if ok {
|
||||
return adminweb.Component{Name: name, State: "ok", Code: successCode, Detail: successDetail, LastSuccess: now, CheckedAt: now}
|
||||
}
|
||||
return adminweb.Component{Name: name, State: "disabled", Code: failureCode, Detail: failureDetail, CheckedAt: now}
|
||||
}
|
||||
|
||||
func safeVersion(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "unknown"
|
||||
}
|
||||
if len(value) > 80 {
|
||||
return value[:80]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOperationalStatusProviderBuildsLiveComponents(t *testing.T) {
|
||||
health := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer health.Close()
|
||||
|
||||
checked := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
|
||||
provider := &operationalStatusProvider{
|
||||
metrics: operationalMetricsStub{value: operationalMetrics{
|
||||
QuestionPending: 2, FAQFailed: 1, SupportPending: 3,
|
||||
LastQuestionSuccess: checked.Add(-time.Minute),
|
||||
}},
|
||||
weknoraHealthURL: health.URL,
|
||||
httpClient: health.Client(),
|
||||
now: func() time.Time { return checked },
|
||||
version: "settings-test",
|
||||
streamRunning: true,
|
||||
questionRunning: true,
|
||||
faqRunning: false,
|
||||
supportRunning: false,
|
||||
questionBound: true,
|
||||
faqBound: false,
|
||||
supportBound: true,
|
||||
ossConfigured: true,
|
||||
}
|
||||
components, err := provider.Components(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byName := componentMap(components)
|
||||
assertComponent(t, byName, "运行版本", "ok", "version_ok")
|
||||
assertComponent(t, byName, "PostgreSQL", "ok", "database_ok")
|
||||
assertComponent(t, byName, "钉钉Stream", "ok", "stream_running")
|
||||
assertComponent(t, byName, "WeKnora", "ok", "health_ok")
|
||||
assertComponent(t, byName, "问题表Outbox", "warning", "outbox_pending")
|
||||
assertComponent(t, byName, "FAQ Outbox", "error", "outbox_failed")
|
||||
assertComponent(t, byName, "技术支持通知", "blocked", "faq_binding_required")
|
||||
assertComponent(t, byName, "问题表Dispatcher", "ok", "dispatcher_running")
|
||||
assertComponent(t, byName, "FAQ Dispatcher", "disabled", "binding_missing")
|
||||
assertComponent(t, byName, "Private OSS图片", "ok", "oss_configured")
|
||||
for _, component := range components {
|
||||
for _, forbidden := range []string{health.URL, "cid-secret-group", "base-secret", "access-token"} {
|
||||
if strings.Contains(component.Detail, forbidden) {
|
||||
t.Fatalf("component %q leaks sensitive identifier %q", component.Name, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationalStatusProviderIsolatesWeKnoraFailure(t *testing.T) {
|
||||
provider := &operationalStatusProvider{
|
||||
metrics: operationalMetricsStub{value: operationalMetrics{}},
|
||||
weknoraHealthURL: "http://127.0.0.1:1/health",
|
||||
httpClient: &http.Client{Timeout: 50 * time.Millisecond},
|
||||
now: time.Now,
|
||||
}
|
||||
components, err := provider.Components(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byName := componentMap(components)
|
||||
assertComponent(t, byName, "PostgreSQL", "ok", "database_ok")
|
||||
assertComponent(t, byName, "WeKnora", "error", "health_probe_failed")
|
||||
}
|
||||
|
||||
type operationalMetricsStub struct {
|
||||
value operationalMetrics
|
||||
err error
|
||||
}
|
||||
|
||||
func (s operationalMetricsStub) ReadOperationalMetrics(context.Context, string) (operationalMetrics, error) {
|
||||
return s.value, s.err
|
||||
}
|
||||
|
||||
func (s operationalMetricsStub) Ping(context.Context) error { return s.err }
|
||||
|
||||
func componentMap(values []adminComponent) map[string]adminComponent {
|
||||
out := make(map[string]adminComponent, len(values))
|
||||
for _, value := range values {
|
||||
out[value.Name] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertComponent(t *testing.T, values map[string]adminComponent, name, state, code string) {
|
||||
t.Helper()
|
||||
value, ok := values[name]
|
||||
if !ok {
|
||||
t.Fatalf("missing component %q: %+v", name, values)
|
||||
}
|
||||
if value.State != state || value.Code != code {
|
||||
t.Fatalf("component %q=%+v want state=%q code=%q", name, value, state, code)
|
||||
}
|
||||
}
|
||||
+143
-12
@@ -2,7 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -11,12 +15,63 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
outboundImageMaxCount = 5
|
||||
outboundImageMaxBytes = 10 << 20
|
||||
outboundImageURLTTL = 2 * time.Hour
|
||||
outboundImagePrefix = "dobotai/im-images"
|
||||
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
|
||||
@@ -25,10 +80,22 @@ type outboundImageConfig struct {
|
||||
AccessKeyID string
|
||||
AccessKeySecret string
|
||||
Bucket string
|
||||
PublicBaseURL string
|
||||
}
|
||||
|
||||
func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundContentRewriter, error) {
|
||||
ossValues := []string{cfg.Endpoint, cfg.Region, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.Bucket}
|
||||
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) != "" {
|
||||
@@ -37,11 +104,11 @@ func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundConte
|
||||
}
|
||||
}
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
return outboundImageRuntime{}, nil
|
||||
}
|
||||
for _, value := range append([]string{cfg.WeKnoraBaseURL, cfg.WeKnoraAPIKey}, ossValues...) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, errors.New("outbound image: complete WeKnora and OSS configuration is required")
|
||||
return outboundImageRuntime{}, errors.New("outbound image: complete WeKnora, OSS, and public URL configuration is required")
|
||||
}
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
@@ -53,7 +120,7 @@ func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundConte
|
||||
outboundImageMaxBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return outboundImageRuntime{}, err
|
||||
}
|
||||
store, err := outboundimage.NewAliyunOSSStore(
|
||||
cfg.Endpoint,
|
||||
@@ -63,8 +130,72 @@ func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundConte
|
||||
cfg.Bucket,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return outboundImageRuntime{}, err
|
||||
}
|
||||
publisher := outboundimage.NewHashPublisher(store, outboundImagePrefix, outboundImageURLTTL)
|
||||
return outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount), nil
|
||||
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
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
|
||||
)
|
||||
|
||||
@@ -49,9 +50,11 @@ func readQuestionTableConfig() (questionTableConfig, error) {
|
||||
|
||||
// buildQuestionTableDispatcher creates a validated question-table dispatcher
|
||||
// when the repo is non-nil and all required credentials are present.
|
||||
// No HTTP request occurs at build time. It returns (nil, nil) when the
|
||||
// question-table feature is disabled (empty config).
|
||||
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig) (*questiontable.Dispatcher, error) {
|
||||
// The optional answer rewriter converts the stored answer into the same
|
||||
// outbound form used for DingTalk delivery (resource images to stable HTTPS,
|
||||
// document links, anchor cleanup). No HTTP request occurs at build time.
|
||||
// It returns (nil, nil) when the question-table feature is disabled.
|
||||
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig, rewriter questiontable.AnswerRewriter) (*questiontable.Dispatcher, error) {
|
||||
// Disabled: all blank config.
|
||||
if cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == "" {
|
||||
return nil, nil
|
||||
@@ -73,7 +76,24 @@ func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return questiontable.NewDispatcher(repo, client, nil)
|
||||
return questiontable.NewDispatcher(repo, client, &questiontable.DispatcherOptions{
|
||||
Rewriter: rewriter,
|
||||
SendTimeout: 60 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
// answerRewriterAdapter adapts a blocking outbound content rewriter to the
|
||||
// AnswerRewriter contract (caller never gets an error, so the original answer
|
||||
// is always written when conversion cannot run).
|
||||
type answerRewriterAdapter struct {
|
||||
rewriter dingtalk.OutboundContentRewriter
|
||||
}
|
||||
|
||||
func (a answerRewriterAdapter) Rewrite(ctx context.Context, content string) (string, error) {
|
||||
if a.rewriter == nil {
|
||||
return content, nil
|
||||
}
|
||||
return a.rewriter.Rewrite(ctx, content), nil
|
||||
}
|
||||
|
||||
// runQuestionTablePoller runs the dispatcher immediately, then every interval.
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestReadQuestionTableConfigDisabledAllBlank(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildQuestionTableDispatcherDisabled(t *testing.T) {
|
||||
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{})
|
||||
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestReadQuestionTableConfigPartialErrors(t *testing.T) {
|
||||
|
||||
func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
|
||||
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
|
||||
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg)
|
||||
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when repo is nil")
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
|
||||
func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
|
||||
repo := &questionTableExportRepoStub{}
|
||||
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
|
||||
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg)
|
||||
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when client id is empty")
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
|
||||
func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
|
||||
repo := &questionTableExportRepoStub{}
|
||||
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
|
||||
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg)
|
||||
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when secret is empty")
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
|
||||
func TestBuildQuestionTableDispatcherEnabledCompleteConfig(t *testing.T) {
|
||||
repo := &questionTableExportRepoStub{}
|
||||
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
|
||||
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg)
|
||||
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
)
|
||||
|
||||
type dingTalkReasoningRouter struct {
|
||||
service *reasoningmode.Service
|
||||
}
|
||||
|
||||
func (r *dingTalkReasoningRouter) Prepare(ctx context.Context, message dingtalk.ReasoningMessage) (dingtalk.ReasoningDecision, error) {
|
||||
decision, err := r.service.Prepare(ctx, reasoningmode.Scope{
|
||||
TenantKey: message.TenantKey, Channel: message.Channel, ExternalActorID: message.StaffID,
|
||||
}, message.Text)
|
||||
if err != nil {
|
||||
return dingtalk.ReasoningDecision{}, err
|
||||
}
|
||||
return dingtalk.ReasoningDecision{
|
||||
Handled: decision.Handled, Reply: decision.Reply, Query: decision.Query,
|
||||
AgentID: decision.AgentID, Execution: decision.Execution, Expired: decision.Expired,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMainWiresReasoningModeRoutingAndCompletion(t *testing.T) {
|
||||
mainSource, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wiringSource, err := os.ReadFile("reasoning_wiring.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainText := string(mainSource)
|
||||
for _, required := range []string{
|
||||
`os.Getenv("DOBOTAI_QUICK_AGENT_ID")`,
|
||||
`os.Getenv("DOBOTAI_REASONING_AGENT_ID")`,
|
||||
`reasoningmode.NewService`,
|
||||
`qa.WithReasoningModeFinalizer`,
|
||||
`ReasoningRouter:`,
|
||||
} {
|
||||
if !strings.Contains(mainText, required) {
|
||||
t.Errorf("main reasoning wiring missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{
|
||||
"dingTalkReasoningRouter",
|
||||
"ReasoningMessage",
|
||||
"service.Prepare",
|
||||
} {
|
||||
if !strings.Contains(string(wiringSource), required) {
|
||||
t.Errorf("reasoning adapter missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
||||
@@ -29,16 +27,12 @@ func (r *dingTalkSettingsRouter) Handle(ctx context.Context, m dingtalk.Settings
|
||||
}
|
||||
|
||||
type settingsRuntime struct {
|
||||
repository *postgres.SettingsRepository
|
||||
bindings *validatedSettingsRepository
|
||||
controller *settings.Controller
|
||||
admin *adminweb.ControlService
|
||||
clientID string
|
||||
secret string
|
||||
secretRef string
|
||||
secrets *secretstore.Store
|
||||
verifier *dingtalkconfig.Verifier
|
||||
pendingActivation bool
|
||||
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) {
|
||||
@@ -62,8 +56,7 @@ func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pendingActivation, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey)
|
||||
if err != nil {
|
||||
if _, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientID, secretRef, _, err := repo.ActiveDingTalkConfig(ctx, tenantKey)
|
||||
@@ -88,11 +81,6 @@ func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
verifier := dingtalkconfig.NewVerifier("https://oapi.dingtalk.com/gettoken", nil)
|
||||
control, err := adminweb.NewControlService(tenantKey, repo, secrets, verifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validatedRepo := &validatedSettingsRepository{SettingsRepository: repo, clientID: clientID, secret: secret}
|
||||
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), admin: control, clientID: clientID, secret: secret, secretRef: secretRef, secrets: secrets, verifier: verifier, pendingActivation: pendingActivation}, nil
|
||||
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), clientID: clientID, secret: secret, secretRef: secretRef}, nil
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
)
|
||||
|
||||
func newSetupOnlyHandler(runtime *settingsRuntime, token string) (http.Handler, error) {
|
||||
if runtime == nil || runtime.admin == nil {
|
||||
return nil, errors.New("setup runtime unavailable")
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return nil, errors.New("DOBOTAI_ADMIN_TOKEN is required in setup-only mode")
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "mode": "setup_required"})
|
||||
})
|
||||
mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(runtimeBuildInfo())
|
||||
})
|
||||
mux.Handle("/admin/", adminweb.New(runtime.admin, token))
|
||||
return mux, nil
|
||||
}
|
||||
|
||||
func runSetupOnly(ctx context.Context, cancel context.CancelFunc, runtime *settingsRuntime, address, tenant string, restartRequested *atomic.Bool) {
|
||||
runtime.admin.SetCandidateActivator(&dingTalkCandidateActivator{
|
||||
tenant: tenant, repo: runtime.repository, secrets: runtime.secrets, verifier: runtime.verifier,
|
||||
bindings: map[string]settings.ResourceBinding{},
|
||||
restart: func() { restartRequested.Store(true); cancel() },
|
||||
})
|
||||
handler, err := newSetupOnlyHandler(runtime, os.Getenv("DOBOTAI_ADMIN_TOKEN"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
server := &http.Server{Addr: address, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 20 * time.Second, IdleTimeout: 60 * time.Second}
|
||||
go func() {
|
||||
log.Printf("DobotAI setup-only listener on %s", address)
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("setup HTTP server failed: %v", err)
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
<-ctx.Done()
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("setup HTTP shutdown: %v", err)
|
||||
}
|
||||
if restartRequested.Load() {
|
||||
os.Exit(75)
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
)
|
||||
|
||||
type setupRepoStub struct{}
|
||||
|
||||
func (setupRepoStub) ActiveDingTalkConfig(context.Context, string) (string, string, bool, error) {
|
||||
return "", "", false, nil
|
||||
}
|
||||
func (setupRepoStub) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
|
||||
return "", "", nil
|
||||
}
|
||||
func (setupRepoStub) SaveDingTalkConfig(context.Context, string, string, string, bool) error {
|
||||
return nil
|
||||
}
|
||||
func (setupRepoStub) MarkDingTalkCandidateFailed(context.Context, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
func (setupRepoStub) AdministratorCount(context.Context, string) (int, error) { return 0, nil }
|
||||
func (setupRepoStub) ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error) {
|
||||
return map[string]settings.ResourceBinding{}, nil
|
||||
}
|
||||
func (setupRepoStub) CreateBootstrapChallenge(context.Context, string, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (setupRepoStub) ConfirmBootstrap(context.Context, string) error { return nil }
|
||||
|
||||
type setupVerifierStub struct{}
|
||||
|
||||
func (setupVerifierStub) Verify(context.Context, string, string) dingtalkconfig.Result {
|
||||
return dingtalkconfig.Result{}
|
||||
}
|
||||
|
||||
func setupControl(t *testing.T) *adminweb.ControlService {
|
||||
t.Helper()
|
||||
secrets, err := secretstore.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
control, err := adminweb.NewControlService("tenant", setupRepoStub{}, secrets, setupVerifierStub{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return control
|
||||
}
|
||||
|
||||
func TestSetupOnlyHandlerExposesHealthVersionAndProtectedAdmin(t *testing.T) {
|
||||
runtime := &settingsRuntime{admin: setupControl(t)}
|
||||
h, err := newSetupOnlyHandler(runtime, "admin-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("health status=%d", resp.Code)
|
||||
}
|
||||
var health map[string]string
|
||||
if err = json.Unmarshal(resp.Body.Bytes(), &health); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if health["status"] != "ok" || health["mode"] != "setup_required" {
|
||||
t.Fatalf("health=%v", health)
|
||||
}
|
||||
resp = httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/version", nil))
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("version status=%d", resp.Code)
|
||||
}
|
||||
resp = httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/admin/settings", nil))
|
||||
if resp.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth status=%d", resp.Code)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
resp = httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("auth status=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupOnlyHandlerRequiresAdminToken(t *testing.T) {
|
||||
_, err := newSetupOnlyHandler(&settingsRuntime{admin: setupControl(t)}, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected token error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 临时探针(不入库,用完即删):用真实 WeKnora + OSS 端点跑
|
||||
// internal/outboundimage 的改写链路,量化"一条含 N 张真实图的答案"在
|
||||
// 并发取图 + 预算可配下的出图结果与耗时。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
refsFile := flag.String("refs-file", "", "文件:每行一个 resource:// 句柄")
|
||||
budget := flag.Duration("budget", 180*time.Second, "改写预算")
|
||||
maxImages := flag.Int("max", 50, "图片上限")
|
||||
compress := flag.Bool("compress", true, "上传前压缩截图")
|
||||
passes := flag.Int("passes", 1, "重复改写轮数(第二轮应命中已存在对象)")
|
||||
flag.Parse()
|
||||
|
||||
refs := readRefs(*refsFile)
|
||||
if len(refs) == 0 {
|
||||
log.Fatal("probe: no references")
|
||||
}
|
||||
|
||||
fetcher, err := outboundimage.NewWeKnoraFetcher(
|
||||
os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"),
|
||||
&http.Client{Timeout: 30 * time.Second}, 10<<20)
|
||||
if err != nil {
|
||||
log.Fatalf("probe: fetcher: %v", err)
|
||||
}
|
||||
store, err := outboundimage.NewAliyunOSSStore(
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"))
|
||||
if err != nil {
|
||||
log.Fatalf("probe: store: %v", err)
|
||||
}
|
||||
publicBase := os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL")
|
||||
publisher, err := outboundimage.NewStableHashPublisher(store, "dobotai/im-images", publicBase)
|
||||
if err != nil {
|
||||
log.Fatalf("probe: publisher: %v", err)
|
||||
}
|
||||
rewriter := outboundimage.NewRewriter(fetcher, publisher, *maxImages, outboundimage.WithImageCompression(*compress))
|
||||
|
||||
var answer strings.Builder
|
||||
answer.WriteString("## 探针答案\n")
|
||||
for i, ref := range refs {
|
||||
fmt.Fprintf(&answer, "\n\n", i+1, ref)
|
||||
}
|
||||
|
||||
log.Printf("probe: refs=%d budget=%s max=%d compress=%v passes=%d bucket=%s", len(refs), *budget, *maxImages, *compress, *passes, os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"))
|
||||
body := answer.String()
|
||||
for pass := 1; pass <= *passes; pass++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *budget)
|
||||
started := time.Now()
|
||||
out := rewriter.Rewrite(ctx, body)
|
||||
elapsed := time.Since(started)
|
||||
cancel()
|
||||
|
||||
published := regexp.MustCompile(`!\[图\d+\]\(https://`).FindAllString(out, -1)
|
||||
leaked := strings.Count(out, "resource://")
|
||||
fmt.Printf("RESULT pass=%d elapsed=%s refs=%d published=%d dropped=%d residual_resource_refs=%d out_bytes=%d\n",
|
||||
pass, elapsed.Round(time.Millisecond), len(refs), len(published), len(refs)-len(published), leaked, len(out))
|
||||
if len(published) > 0 {
|
||||
fmt.Printf("FIRST_URL_%d=%s\nLAST_URL_%d=%s\n", pass, firstImageURL(out), pass, lastImageURL(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readRefs(path string) []string {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Fatalf("probe: refs file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
seen := map[string]bool{}
|
||||
refs := make([]string, 0, 64)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
ref := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(ref, "resource://") || seen[ref] {
|
||||
continue
|
||||
}
|
||||
seen[ref] = true
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
var imageURLRE = regexp.MustCompile(`!\[图\d+\]\((https://[^)]+)\)`)
|
||||
|
||||
func firstImageURL(content string) string {
|
||||
if match := imageURLRE.FindStringSubmatch(content); len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func lastImageURL(content string) string {
|
||||
if matches := imageURLRE.FindAllStringSubmatch(content, -1); len(matches) > 0 {
|
||||
return matches[len(matches)-1][1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 000016_question_image_urls down migration.
|
||||
ALTER TABLE turns
|
||||
DROP COLUMN IF EXISTS question_image_urls_json;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 000016_question_image_urls: stable published URLs for inbound question images.
|
||||
-- Stores the object-storage HTTPS URLs of images a user sent in a question,
|
||||
-- so the question-table export can render a readable, traceable record without
|
||||
-- persisting download codes or data URIs.
|
||||
ALTER TABLE turns
|
||||
ADD COLUMN IF NOT EXISTS question_image_urls_json JSONB NOT NULL DEFAULT '[]'::JSONB;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 000017_knowledge_searched down migration.
|
||||
ALTER TABLE turns
|
||||
DROP COLUMN IF EXISTS knowledge_searched;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 000017_knowledge_searched: records whether a turn actually triggered a
|
||||
-- knowledge-base search (knowledge_search tool call). Coverage classification
|
||||
-- uses this to distinguish pure visual recognition from a searched-but-missed
|
||||
-- knowledge question.
|
||||
ALTER TABLE turns
|
||||
ADD COLUMN IF NOT EXISTS knowledge_searched BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 000018_coverage_visual_recognition_reason down migration.
|
||||
ALTER TABLE coverage_decisions
|
||||
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
|
||||
|
||||
ALTER TABLE coverage_decisions
|
||||
ADD CONSTRAINT coverage_decisions_reason_code_check
|
||||
CHECK (reason_code IN (
|
||||
'covered_has_references',
|
||||
'uncovered_no_references',
|
||||
'uncovered_explicit_no_answer'
|
||||
));
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 000018_coverage_visual_recognition_reason: extend the coverage_decisions
|
||||
-- reason_code allowlist with the new image-visual-recognition reason emitted by
|
||||
-- coverage Evaluator v3 (visual recognition is not a knowledge-coverage miss).
|
||||
ALTER TABLE coverage_decisions
|
||||
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
|
||||
|
||||
ALTER TABLE coverage_decisions
|
||||
ADD CONSTRAINT coverage_decisions_reason_code_check
|
||||
CHECK (reason_code IN (
|
||||
'covered_has_references',
|
||||
'uncovered_no_references',
|
||||
'uncovered_explicit_no_answer',
|
||||
'covered_visual_recognition'
|
||||
));
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 000019_reasoning_mode_state down migration.
|
||||
DROP TABLE IF EXISTS reasoning_mode_turns;
|
||||
DROP TABLE IF EXISTS reasoning_mode_states;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 000019_reasoning_mode_state: actor-scoped /think state and
|
||||
-- per-turn idempotency for successful reasoning-mode completions.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reasoning_mode_states (
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
external_actor_id TEXT NOT NULL,
|
||||
mode TEXT NOT NULL DEFAULT 'quick'
|
||||
CHECK (mode IN ('quick', 'reasoning')),
|
||||
max_rounds INTEGER NOT NULL DEFAULT 10
|
||||
CHECK (max_rounds BETWEEN 1 AND 50),
|
||||
rounds_used INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (rounds_used >= 0 AND rounds_used <= max_rounds),
|
||||
hint_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_activity_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
onboarding_shown_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_key, channel, external_actor_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reasoning_mode_states_expiry
|
||||
ON reasoning_mode_states (expires_at)
|
||||
WHERE mode = 'reasoning' AND expires_at IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reasoning_mode_turns (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
external_actor_id TEXT NOT NULL,
|
||||
execution_mode TEXT NOT NULL
|
||||
CHECK (execution_mode IN ('quick', 'reasoning', 'reasoning_single')),
|
||||
round_number INTEGER NOT NULL DEFAULT 0 CHECK (round_number >= 0),
|
||||
showed_hint BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
auto_closed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
showed_onboarding BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reasoning_mode_turns_actor_created
|
||||
ON reasoning_mode_turns (tenant_key, channel, external_actor_id, created_at DESC);
|
||||
@@ -0,0 +1,79 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp019CreatesActorScopedReasoningModeState(t *testing.T) {
|
||||
data, err := os.ReadFile("000019_reasoning_mode_state.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"REASONING_MODE_STATES",
|
||||
"TENANT_KEY",
|
||||
"CHANNEL",
|
||||
"EXTERNAL_ACTOR_ID",
|
||||
"MODE",
|
||||
"MAX_ROUNDS",
|
||||
"ROUNDS_USED",
|
||||
"HINT_ENABLED",
|
||||
"EXPIRES_AT",
|
||||
"ONBOARDING_SHOWN_AT",
|
||||
"PRIMARY KEY (TENANT_KEY, CHANNEL, EXTERNAL_ACTOR_ID)",
|
||||
"MODE IN ('QUICK', 'REASONING')",
|
||||
"MAX_ROUNDS BETWEEN 1 AND 50",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("reasoning state migration missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp019CreatesTurnIdempotencyLedger(t *testing.T) {
|
||||
data, err := os.ReadFile("000019_reasoning_mode_state.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"REASONING_MODE_TURNS",
|
||||
"TURN_ID",
|
||||
"EXTERNAL_ACTOR_ID",
|
||||
"EXECUTION_MODE",
|
||||
"ROUND_NUMBER",
|
||||
"SHOWED_HINT",
|
||||
"AUTO_CLOSED",
|
||||
"SHOWED_ONBOARDING",
|
||||
"UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)",
|
||||
"REFERENCES TURNS(ID)",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("reasoning turn ledger migration missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown019RemovesOnlyReasoningModeObjects(t *testing.T) {
|
||||
data, err := os.ReadFile("000019_reasoning_mode_state.down.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"DROP TABLE IF EXISTS REASONING_MODE_TURNS",
|
||||
"DROP TABLE IF EXISTS REASONING_MODE_STATES",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("reasoning down migration missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"DROP TABLE TURNS", "DROP TABLE USER_PREFERENCES"} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Errorf("reasoning down migration must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 000020_feedback_support_trigger down migration.
|
||||
DROP INDEX IF EXISTS idx_support_cases_trigger_type_created;
|
||||
ALTER TABLE support_cases
|
||||
DROP COLUMN IF EXISTS trigger_feedback_id,
|
||||
DROP COLUMN IF EXISTS trigger_type;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 000020_feedback_support_trigger: let an existing support-case workflow be
|
||||
-- triggered by a downvote while preserving one case per successful Turn.
|
||||
|
||||
ALTER TABLE support_cases
|
||||
ALTER COLUMN coverage_decision_id DROP NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS trigger_type TEXT NOT NULL DEFAULT 'coverage_uncovered'
|
||||
CHECK (trigger_type IN ('coverage_uncovered', 'feedback_down')),
|
||||
ADD COLUMN IF NOT EXISTS trigger_feedback_id TEXT REFERENCES answer_feedback(id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_trigger_type_created
|
||||
ON support_cases (trigger_type, created_at DESC);
|
||||
@@ -0,0 +1,50 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp020AddsFeedbackSupportTriggerAndMakesCoverageDecisionOptional(t *testing.T) {
|
||||
data, err := os.ReadFile("000020_feedback_support_trigger.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"ALTER TABLE SUPPORT_CASES",
|
||||
"TRIGGER_TYPE",
|
||||
"COVERAGE_UNCOVERED",
|
||||
"FEEDBACK_DOWN",
|
||||
"TRIGGER_FEEDBACK_ID",
|
||||
"REFERENCES ANSWER_FEEDBACK(ID)",
|
||||
"ALTER COLUMN COVERAGE_DECISION_ID DROP NOT NULL",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("feedback support migration missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(source, "DROP CONSTRAINT SUPPORT_CASES_COVERAGE_DECISION_ID_FKEY") {
|
||||
t.Fatal("feedback support must retain the coverage decision foreign-key contract")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown020RemovesOnlyFeedbackSupportColumns(t *testing.T) {
|
||||
data, err := os.ReadFile("000020_feedback_support_trigger.down.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"DROP COLUMN IF EXISTS TRIGGER_FEEDBACK_ID",
|
||||
"DROP COLUMN IF EXISTS TRIGGER_TYPE",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("feedback support down migration missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(source, "DROP TABLE") {
|
||||
t.Fatal("feedback support down migration must not drop shared tables")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 000021_coverage_non_knowledge_response_reason down migration.
|
||||
-- Map the v4 covered reason to the pre-v4 covered code before restoring the
|
||||
-- legacy allowlist, so rollback remains valid with persisted v4 decisions.
|
||||
UPDATE coverage_decisions
|
||||
SET reason_code = 'covered_has_references'
|
||||
WHERE reason_code = 'covered_non_knowledge_response';
|
||||
|
||||
ALTER TABLE coverage_decisions
|
||||
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
|
||||
|
||||
ALTER TABLE coverage_decisions
|
||||
ADD CONSTRAINT coverage_decisions_reason_code_check
|
||||
CHECK (reason_code IN (
|
||||
'covered_has_references',
|
||||
'uncovered_no_references',
|
||||
'uncovered_explicit_no_answer',
|
||||
'covered_visual_recognition'
|
||||
));
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 000021_coverage_non_knowledge_response_reason: distinguish text turns that
|
||||
-- did not invoke knowledge search from actual retrieval misses.
|
||||
ALTER TABLE coverage_decisions
|
||||
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
|
||||
|
||||
ALTER TABLE coverage_decisions
|
||||
ADD CONSTRAINT coverage_decisions_reason_code_check
|
||||
CHECK (reason_code IN (
|
||||
'covered_has_references',
|
||||
'uncovered_no_references',
|
||||
'uncovered_explicit_no_answer',
|
||||
'covered_visual_recognition',
|
||||
'covered_non_knowledge_response'
|
||||
));
|
||||
@@ -0,0 +1,44 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp021AddsCoveredNonKnowledgeResponseReason(t *testing.T) {
|
||||
data, err := os.ReadFile("000021_coverage_non_knowledge_response_reason.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"ALTER TABLE COVERAGE_DECISIONS",
|
||||
"DROP CONSTRAINT IF EXISTS COVERAGE_DECISIONS_REASON_CODE_CHECK",
|
||||
"COVERED_NON_KNOWLEDGE_RESPONSE",
|
||||
"COVERED_VISUAL_RECOGNITION",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("up migration missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown021MapsNewCoveredReasonBeforeRestoringLegacyConstraint(t *testing.T) {
|
||||
data, err := os.ReadFile("000021_coverage_non_knowledge_response_reason.down.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"UPDATE COVERAGE_DECISIONS",
|
||||
"COVERED_NON_KNOWLEDGE_RESPONSE",
|
||||
"COVERED_HAS_REFERENCES",
|
||||
"ALTER TABLE COVERAGE_DECISIONS",
|
||||
"DROP CONSTRAINT IF EXISTS COVERAGE_DECISIONS_REASON_CODE_CHECK",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("down migration missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE reasoning_mode_states
|
||||
DROP COLUMN user_selected;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 000022_reasoning_default_mode: support an operator-configurable default
|
||||
-- mode for the /think reasoning router. The new column records whether a
|
||||
-- user has explicitly chosen a mode (/think on|off); users who never have
|
||||
-- will follow DOBOTAI_DEFAULT_MODE instead of the hardcoded quick answer.
|
||||
ALTER TABLE reasoning_mode_states
|
||||
ADD COLUMN user_selected BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,34 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp022AddsReasoningUserSelectedColumn(t *testing.T) {
|
||||
data, err := os.ReadFile("000022_reasoning_default_mode.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"ALTER TABLE REASONING_MODE_STATES",
|
||||
"ADD COLUMN USER_SELECTED BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("up migration missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown022DropsUserSelectedColumn(t *testing.T) {
|
||||
data, err := os.ReadFile("000022_reasoning_default_mode.down.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
if !strings.Contains(source, "DROP COLUMN USER_SELECTED") {
|
||||
t.Errorf("down migration missing %q", "DROP COLUMN USER_SELECTED")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot DingTalk AI card probe (test bot, private IM_ROBOT space).
|
||||
|
||||
Sends two cards to the owner's private robot chat so PC width can be compared:
|
||||
A: body carries sys_full_json_obj = {"config":{"autoLayout":true}}
|
||||
B: control, same body without the layout field
|
||||
Card body mirrors internal/channels/dingtalk/card.go buildCardBody() exactly.
|
||||
|
||||
Credentials are read from the running container's environment and are never
|
||||
printed. Only masked ids, HTTP statuses and DingTalk response bodies are shown.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
CONTAINER = "dobotai-dingtalk"
|
||||
TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
|
||||
CREATE_URL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
|
||||
STREAM_URL = "https://api.dingtalk.com/v1.0/card/streaming"
|
||||
OWNER_ID = "243143382023108067" # 姚章浩 (channel_actors.dingtalk, test tenant)
|
||||
WIDE = json.dumps({"config": {"autoLayout": True}}, separators=(",", ":"))
|
||||
|
||||
CONTENT = (
|
||||
"**{tag}**\n\n"
|
||||
"这是一条 PC 宽度对照测试卡片,由测试机器人在私聊里直接推送。\n\n"
|
||||
"- 投放体里的布局字段:{field}\n"
|
||||
"- 期望:{expect}\n"
|
||||
"- 请在 PC 端对比两张卡片的宽度差异\n\n"
|
||||
"移动端请忽略,宽屏只影响桌面端渲染。"
|
||||
)
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
out = subprocess.run(
|
||||
["docker", "exec", CONTAINER, "printenv", key],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
if not out:
|
||||
sys.exit(f"missing env {key}")
|
||||
return out
|
||||
|
||||
|
||||
def call(method: str, url: str, payload: dict, token: str = "") -> tuple[int, str]:
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if token:
|
||||
req.add_header("x-acs-dingtalk-access-token", token)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.status, resp.read(4096).decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read(4096).decode("utf-8", "replace")
|
||||
|
||||
|
||||
def mask(v: str) -> str:
|
||||
return v[:6] + "..." + v[-4:] if len(v) > 12 else "***"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app_key, app_secret = env("DINGTALK_CLIENT_ID"), env("DINGTALK_CLIENT_SECRET")
|
||||
tpl = env("DINGTALK_CARD_TEMPLATE_ID")
|
||||
print(f"app_key={mask(app_key)} template={mask(tpl)} target={mask(OWNER_ID)}")
|
||||
|
||||
status, body = call("POST", TOKEN_URL, {"appKey": app_key, "appSecret": app_secret})
|
||||
token = json.loads(body).get("accessToken", "")
|
||||
print(f"token: http={status} got={'yes' if token else 'NO'}")
|
||||
if not token:
|
||||
sys.exit("no token")
|
||||
|
||||
for tag, with_wide in (("A · 已带 autoLayout=true", True), ("B · 对照组(无布局字段)", False)):
|
||||
param_map = {
|
||||
"content": "",
|
||||
"feedbackSelected": "",
|
||||
"feedbackReasonSelected": "",
|
||||
"showFeedbackReasons": "false",
|
||||
"feedbackMessage": "",
|
||||
}
|
||||
if with_wide:
|
||||
param_map["sys_full_json_obj"] = WIDE
|
||||
out_track_id = str(uuid.uuid4())
|
||||
card_body = {
|
||||
"cardTemplateId": tpl,
|
||||
"outTrackId": out_track_id,
|
||||
"callbackType": "STREAM",
|
||||
"cardData": {"cardParamMap": param_map},
|
||||
"userIdType": 1,
|
||||
"openSpaceId": "dtv1.card//IM_ROBOT." + OWNER_ID,
|
||||
"imRobotOpenSpaceModel": {"supportForward": True},
|
||||
"imRobotOpenDeliverModel": {
|
||||
"robotCode": app_key,
|
||||
"spaceType": "IM_ROBOT",
|
||||
"extension": {},
|
||||
},
|
||||
}
|
||||
st, rb = call("POST", CREATE_URL, card_body, token)
|
||||
print(f"[{tag}] create http={st} resp={rb[:300]}")
|
||||
|
||||
text = CONTENT.format(
|
||||
tag=tag,
|
||||
field=("sys_full_json_obj = " + WIDE) if with_wide else "(无)",
|
||||
expect="PC 端铺满窗口(宽屏)" if with_wide else "PC 端窄卡片(默认宽度)",
|
||||
)
|
||||
stream_body = {
|
||||
"outTrackId": out_track_id,
|
||||
"guid": str(uuid.uuid4()),
|
||||
"key": "content",
|
||||
"content": text,
|
||||
"isFull": True,
|
||||
"isFinalize": True,
|
||||
"isError": False,
|
||||
}
|
||||
st2, rb2 = call("PUT", STREAM_URL, stream_body, token)
|
||||
print(f"[{tag}] finalize http={st2} resp={rb2[:200]} outTrackId={mask(out_track_id)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,157 @@
|
||||
# DobotAI Docker部署(阿里云RDS)
|
||||
|
||||
## 定位
|
||||
|
||||
DobotAI是钉钉渠道编排与交付服务。Docker只运行应用容器`dobotai-dingtalk`;数据库唯一使用已有的阿里云RDS PostgreSQL。
|
||||
|
||||
```text
|
||||
钉钉 Stream
|
||||
↓
|
||||
dobotai-dingtalk 容器
|
||||
├─ 阿里云RDS PostgreSQL:Turn、会话、反馈、管理员、覆盖判定
|
||||
└─ 既有WeKnora:Agent、检索、知识、模型
|
||||
```
|
||||
|
||||
禁止创建或使用:
|
||||
|
||||
```text
|
||||
dobotai-postgres 容器
|
||||
dobotai-postgres-data 数据卷
|
||||
本地PostgreSQL作为DobotAI目标数据库
|
||||
```
|
||||
|
||||
## 架构与目录
|
||||
|
||||
```text
|
||||
宿主机
|
||||
├── dobotai-dingtalk 唯一应用容器,host网络
|
||||
├── /opt/dobotai/container/ Compose、RDS检查器、SQL迁移、启动脚本
|
||||
├── /opt/dobotai/config/dobotai.env 0600,RDS/WeKnora/钉钉配置
|
||||
├── /opt/dobotai/secrets/ 0700,DobotAI SecretStore
|
||||
└── /opt/dobotai/backups/ 发布前RDS备份记录,不保存Docker数据库卷
|
||||
|
||||
阿里云RDS
|
||||
└── DobotAI专用PostgreSQL数据库
|
||||
```
|
||||
|
||||
应用使用`network_mode: host`,因此通过RDS私网地址访问云数据库、通过`127.0.0.1:8080`访问宿主机现有WeKnora。不会开放新的数据库端口;应用HTTP仍由`DOBOTAI_LISTEN_ADDR=127.0.0.1:8090`约束。
|
||||
|
||||
## 安装
|
||||
|
||||
克隆完整仓库后,只执行:
|
||||
|
||||
```bash
|
||||
sudo bash ./install-docker.sh
|
||||
```
|
||||
|
||||
根脚本自动:
|
||||
|
||||
```text
|
||||
检查或安装Docker Engine和Docker Compose v2(APT系统)
|
||||
以当前Git提交构建 dobotai-dingtalk:local-<Git短SHA>
|
||||
创建 /opt/dobotai/{container,config,secrets,backups}
|
||||
复制应用Compose、RDS检查器和21个SQL迁移
|
||||
生成权限0600的应用环境模板
|
||||
安装三条短命令
|
||||
```
|
||||
|
||||
它不会连接、创建或修改RDS,也不会启动未配置的钉钉应用。
|
||||
|
||||
## 配置、RDS检查与启动
|
||||
|
||||
安装后执行:
|
||||
|
||||
```bash
|
||||
sudo dobotai-dingtalk-configure
|
||||
```
|
||||
|
||||
配置器分字段录入阿里云RDS(主机、端口、数据库名、账号、密码),自动组装连接串;WeKnora和钉钉凭证密钥不回显,环境文件原子写入且权限0600。
|
||||
|
||||
`DOBOTAI_TENANT_KEY`不是WeKnora Agent租户键。它是DobotAI内部数据隔离键,首次安装时自动生成并固定保存;配置器不会要求填写或改动它。WeKnora侧只需要填写实际的两个Agent ID。
|
||||
|
||||
配置器会要求两个不同的WeKnora Agent ID,并自动生成`DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"快速问答Agent ID"}`。`DOBOTAI_INGRESS_TOKEN`是保护本机`POST /v1/qa`内部接口的Bearer Token,不用于钉钉或WeKnora;同样由安装器自动生成。
|
||||
|
||||
| 配置 | 示例 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `DATABASE_URL` | `postgres://USER:***@rds-endpoint:5432/dobotai?sslmode=require` | DobotAI专用阿里云RDS PostgreSQL |
|
||||
| `WEKNORA_BASE_URL` | `http://127.0.0.1:8080` | 宿主机既有WeKnora |
|
||||
| `WEKNORA_API_KEY` | `<你的WeKnora API Key>` | DobotAI专用Key |
|
||||
| `DOBOTAI_QUICK_AGENT_ID` | `<快速问答Agent ID>` | 默认快速问答实际调用的Agent |
|
||||
| `DOBOTAI_REASONING_AGENT_ID` | `<智能推理Agent ID>` | `/think`和持续智能推理实际调用的Agent |
|
||||
| `DINGTALK_CLIENT_ID` | `dingxxxxxxxx` | 钉钉AppKey |
|
||||
| `DINGTALK_CLIENT_SECRET` | `<你的钉钉 AppSecret>` | 钉钉AppSecret |
|
||||
|
||||
回答图片外发OSS同为分字段录入的可选整组(紧随RDS之后、WeKnora/钉钉凭证之前):
|
||||
|
||||
| 配置 | 示例 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT` | `https://oss-cn-shenzhen.aliyuncs.com` | 私有Bucket接入地址 |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_REGION` | `cn-shenzhen` | Bucket所在地域 |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET` | `dobot-xxx` | 外发图片专用私有Bucket |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID` | `<AK ID>` | 仅授权该Bucket的AK |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET` | `<AK Secret>` | 仅授权该Bucket的SK(不回显) |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL` | `https://host/dingtalk/i` | 钉钉可见的稳定HTTPS入口,不含凭据 |
|
||||
|
||||
OSS规则:六项全部留空即整组禁用图片外发(答案含`resource://`图片时按无图降级);填写任一项则六项必填,缺项拒绝保存。运行时仅改写WeKnora `resource://`引用为短签HTTPS,私有Bucket由稳定基础地址代理,`<img>`外链与`data:`剥除。禁止把OSS凭据写入Git或命令行。
|
||||
|
||||
保存后脚本自动执行以下RDS门禁:
|
||||
|
||||
```text
|
||||
1. 使用RDS连接串执行 pg_isready
|
||||
2. 检查DobotAI核心表
|
||||
3. 数据库没有任何public表:按000001~000021初始化
|
||||
4. 所有核心表存在:判定正常,不执行任何DDL
|
||||
5. 部分表、缺表或非空异常库:拒绝初始化和启动
|
||||
6. RDS通过后才启动 dobotai-dingtalk 并检查 /healthz
|
||||
```
|
||||
|
||||
这保证不会把已有RDS表误当成空库重建,也不会把半成品或错误库静默升级。
|
||||
|
||||
## 日常命令
|
||||
|
||||
```bash
|
||||
# 重新检查RDS并启动/升级应用
|
||||
sudo dobotai-dingtalk-start
|
||||
|
||||
# 交互更新RDS、WeKnora、钉钉配置后启动
|
||||
sudo dobotai-dingtalk-configure
|
||||
|
||||
# 仅首位管理员且无有效管理员时使用
|
||||
sudo dobotai-dingtalk-bind-admin
|
||||
|
||||
# 健康与版本
|
||||
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
|
||||
curl --noproxy '*' -fsS http://127.0.0.1:8090/version
|
||||
```
|
||||
|
||||
## 升级与回滚
|
||||
|
||||
应用升级仅替换`dobotai-dingtalk`镜像。启动前再次检查RDS;健康失败时恢复上一应用镜像(如存在)。RDS不是容器资产,不能被`docker compose down`、镜像替换或本机卷操作影响。
|
||||
|
||||
RDS有实际数据时,数据库备份、SQL变更与恢复应在阿里云RDS发布流程中完成并验证;不要把应用镜像回滚误当成数据库回滚。
|
||||
|
||||
## 首位管理员
|
||||
|
||||
应用和钉钉Stream运行后:
|
||||
|
||||
```bash
|
||||
sudo dobotai-dingtalk-bind-admin
|
||||
```
|
||||
|
||||
终端会显示10分钟有效的六码;候选管理员私聊机器人:
|
||||
|
||||
```text
|
||||
/setting <六码绑定码>
|
||||
```
|
||||
|
||||
已有有效管理员时绑定命令会拒绝,避免提权。
|
||||
|
||||
## 不执行的操作
|
||||
|
||||
```text
|
||||
不创建本地PostgreSQL容器或数据卷
|
||||
不迁移、删除或覆盖现有本地 dobotai-test-postgres
|
||||
不创建、删除或重置阿里云RDS数据库
|
||||
不把RDS连接串、Token、钉钉凭据写入Git、命令行或日志
|
||||
不重建WeKnora、Neo4j、Redis、Elasticsearch
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
dobotai-dingtalk:
|
||||
image: ${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE in runtime.env}
|
||||
container_name: dobotai-dingtalk
|
||||
network_mode: host
|
||||
user: "${DOBOTAI_RUNTIME_UID:?set DOBOTAI_RUNTIME_UID}:${DOBOTAI_RUNTIME_GID:?set DOBOTAI_RUNTIME_GID}"
|
||||
env_file:
|
||||
- ${DOBOTAI_ENV_FILE:?set DOBOTAI_ENV_FILE in runtime.env}
|
||||
environment:
|
||||
DOBOTAI_SECRET_DIR: /var/lib/dobotai/secrets
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${DOBOTAI_SECRET_DIR_HOST:?set DOBOTAI_SECRET_DIR_HOST in runtime.env}
|
||||
target: /var/lib/dobotai/secrets
|
||||
read_only: false
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=32m
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
FIELDS = (
|
||||
("WEKNORA_BASE_URL", "WeKnora 地址", "应用容器通过宿主机网络访问既有WeKnora。", "http://127.0.0.1:8080", False),
|
||||
("WEKNORA_API_KEY", "WeKnora API Key", "DobotAI专用WeKnora API Key。", "<你的WeKnora API Key>", True),
|
||||
("DOBOTAI_QUICK_AGENT_ID", "快速问答Agent ID", "默认问答实际调用的WeKnora Agent。", "<快速问答Agent ID>", False),
|
||||
("DOBOTAI_REASONING_AGENT_ID", "智能推理Agent ID", "/think和持续智能推理实际调用的WeKnora Agent。", "<智能推理Agent ID>", False),
|
||||
("DINGTALK_CLIENT_ID", "钉钉 Client ID", "钉钉应用Client ID/AppKey。", "dingxxxxxxxx", False),
|
||||
("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", "钉钉应用Client Secret/AppSecret。", "<你的钉钉 AppSecret>", True),
|
||||
)
|
||||
|
||||
def prompt_rds(values):
|
||||
"""分字段录入阿里云RDS并组装DATABASE_URL;已有值作为默认。"""
|
||||
existing = values.get("DATABASE_URL", "").strip()
|
||||
host = port = database = user = password = ""
|
||||
if existing:
|
||||
u = urlsplit(existing)
|
||||
host = u.hostname or ""
|
||||
port = str(u.port or "") if u.port else ""
|
||||
database = u.path.lstrip("/")
|
||||
user = unquote(u.username or "")
|
||||
password = unquote(u.password or "")
|
||||
|
||||
def ask(key, label, default, secret=False):
|
||||
suffix = "(已配置,直接回车保留)" if default else "(必填)"
|
||||
reader = getpass.getpass if secret else input
|
||||
entered = reader(f"{label}{suffix}: ").strip()
|
||||
return entered or default
|
||||
|
||||
print("\n阿里云RDS PostgreSQL(分字段填写)\n用途:DobotAI专用数据库;空库自动初始化,已有表只校验不改结构。")
|
||||
host = ask("host", "RDS主机地址", host)
|
||||
port = ask("port", "RDS端口", port or "5432")
|
||||
database = ask("database", "数据库名", database)
|
||||
user = ask("user", "数据库账号", user)
|
||||
password = ask("password", "数据库密码", password, secret=True)
|
||||
if not all((host, port, database, user, password)):
|
||||
raise SystemExit("RDS字段不完整,未保存。")
|
||||
if not database.startswith(("dobotai", "dobotai_")):
|
||||
print("警告:数据库名不是DobotAI专用库,继续前请确认。")
|
||||
values["DATABASE_URL"] = f"postgres://{quote(user, safe='')}:{quote(password, safe='')}@{host}:{port}/{database}"
|
||||
|
||||
OSS_FIELDS = (
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT", "OSS Endpoint", "私有Bucket的接入地址。", "https://oss-cn-shenzhen.aliyuncs.com", False),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION", "OSS Region", "Bucket所在地域。", "cn-shenzhen", False),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET", "OSS Bucket", "外发图片专用私有Bucket。", "dobot-xxx", False),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID", "OSS AccessKey ID", "仅授权该Bucket的AK。", "<AK ID>", False),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET", "OSS AccessKey Secret", "仅授权该Bucket的SK。", "<AK Secret>", True),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "图片稳定外发基础地址", "钉钉可见的稳定HTTPS入口,不含凭据。", "https://host/dingtalk/i", False),
|
||||
)
|
||||
|
||||
def prompt_oss(values):
|
||||
"""OSS整组录入:全部留空即禁用;任一项填写则六项必填。"""
|
||||
print("\n回答图片外发OSS(可选,OSS整组录入;直接回车表示整组禁用)")
|
||||
entered = {}
|
||||
for key, label, description, example, secret in OSS_FIELDS:
|
||||
default = values.get(key, "").strip()
|
||||
suffix = "(已配置,直接回车保留)" if default else ""
|
||||
print(f"用途:{description} 格式示例:{example}")
|
||||
reader = getpass.getpass if secret else input
|
||||
value = reader(f"{label}{suffix}: ").strip()
|
||||
entered[key] = value or default
|
||||
if not any(entered.values()):
|
||||
for key, *_ in OSS_FIELDS:
|
||||
values.pop(key, None)
|
||||
return
|
||||
missing = [label for key, label, *_ in OSS_FIELDS if not entered[key]]
|
||||
if missing:
|
||||
raise SystemExit("OSS为整组可选能力,以下项未填写:" + "、".join(missing))
|
||||
values.update(entered)
|
||||
|
||||
|
||||
|
||||
def parse_env(path):
|
||||
lines = path.read_text(encoding="utf-8").splitlines(); values = {}
|
||||
for line in lines:
|
||||
if line and not line.lstrip().startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1); values[key.strip()] = value
|
||||
return lines, values
|
||||
|
||||
def write_env(path, lines, values):
|
||||
rendered=[]; written=set()
|
||||
for line in lines:
|
||||
if line and not line.lstrip().startswith("#") and "=" in line:
|
||||
key=line.split("=",1)[0].strip()
|
||||
if key in values: rendered.append(f"{key}={values[key]}"); written.add(key); continue
|
||||
rendered.append(line)
|
||||
rendered.extend(f"{key}={value}" for key,value in values.items() if key not in written)
|
||||
temp=path.with_name(path.name+".new"); temp.write_text("\n".join(rendered)+"\n",encoding="utf-8"); os.chmod(temp,0o600); os.replace(temp,path)
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description="配置DobotAI阿里云RDS和渠道凭证")
|
||||
parser.add_argument("--env-file",required=True); parser.add_argument("--schema-script",required=True); parser.add_argument("--start-script",required=True)
|
||||
args=parser.parse_args(); path=Path(args.env_file)
|
||||
if not path.is_file(): raise SystemExit("DobotAI环境文件不存在,请先执行根目录install-docker.sh。")
|
||||
lines,values=parse_env(path)
|
||||
prompt_rds(values)
|
||||
prompt_oss(values)
|
||||
for key,label,description,example,secret in FIELDS:
|
||||
suffix="(已配置,直接回车保留)" if values.get(key,"").strip() else "(必填)"
|
||||
print(f"\n{label}\n用途:{description}\n格式示例:{example}")
|
||||
entered=(getpass.getpass if secret else input)(f"{label}{suffix}: ").strip()
|
||||
if entered: values[key]=entered
|
||||
missing=[label for key,label,*_ in FIELDS if not values.get(key,"").strip()]
|
||||
if missing: raise SystemExit("以下必填项未配置:"+"、".join(missing))
|
||||
if values["DOBOTAI_QUICK_AGENT_ID"] == values["DOBOTAI_REASONING_AGENT_ID"]:
|
||||
raise SystemExit("快速问答Agent ID与智能推理Agent ID必须不同。")
|
||||
values["DOBOTAI_AGENT_BINDINGS"] = json.dumps({"dingtalk:*": values["DOBOTAI_QUICK_AGENT_ID"]}, separators=(",", ":"))
|
||||
write_env(path,lines,values)
|
||||
subprocess.run([args.schema_script], check=True)
|
||||
os.execv(args.start_script,[args.start_script])
|
||||
if __name__=="__main__":
|
||||
try: main()
|
||||
except KeyboardInterrupt: sys.exit(130)
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
home="${DOBOTAI_HOME:-/opt/dobotai}"
|
||||
container_dir="$home/container"
|
||||
if [[ "$(docker inspect --format '{{.State.Running}}' dobotai-dingtalk 2>/dev/null || true)" != "true" ]]; then echo "DobotAI容器未运行。请先执行:sudo dobotai-dingtalk-configure" >&2; exit 1; fi
|
||||
exec docker exec -it dobotai-dingtalk /usr/local/bin/dobotai bootstrap-admin
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
exec python3 /opt/dobotai-test/container/configure_env.py \
|
||||
--env-file /etc/dobotai-test/dobotai.env \
|
||||
--start-script /usr/local/sbin/dobotai-dingtalk-start
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
home="${DOBOTAI_HOME:-/opt/dobotai}"
|
||||
container_dir="$home/container"
|
||||
compose_file="$container_dir/compose.yaml"
|
||||
runtime_env="$container_dir/runtime.env"
|
||||
health_url="http://127.0.0.1:8090/healthz"
|
||||
[[ -r "$compose_file" && -r "$runtime_env" ]] || { echo "容器资产未安装。" >&2; exit 1; }
|
||||
"$container_dir/rds-schema-init.sh"
|
||||
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
|
||||
previous_image="$(docker inspect -f '{{.Config.Image}}' dobotai-dingtalk 2>/dev/null || true)"
|
||||
rollback() {
|
||||
"${compose[@]}" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true
|
||||
if [[ -n "$previous_image" ]]; then "${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk >/dev/null 2>&1 || true; fi
|
||||
}
|
||||
trap rollback ERR
|
||||
"${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk
|
||||
for _ in $(seq 1 30); do
|
||||
if curl --noproxy '*' --fail --silent --max-time 2 "$health_url" >/dev/null; then trap - ERR; echo "DobotAI容器已启动:dobotai-dingtalk"; exit 0; fi
|
||||
sleep 1
|
||||
done
|
||||
echo "DobotAI健康检查失败,已恢复上一应用容器(如存在)。" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source_root="${DOBOTAI_SOURCE_ROOT:-$(cd "$script_dir/../.." && pwd)}"
|
||||
home="${DOBOTAI_HOME:-/opt/dobotai}"
|
||||
container_dir="$home/container"
|
||||
config_dir="$home/config"
|
||||
secret_dir="$home/secrets"
|
||||
backup_dir="$home/backups"
|
||||
app_env="$config_dir/dobotai.env"
|
||||
runtime_env="$container_dir/runtime.env"
|
||||
image="${DOBOTAI_IMAGE:?root install-docker.sh must provide the local image}"
|
||||
bin_dir="${DOBOTAI_BIN_DIR:-/usr/local/sbin}"
|
||||
|
||||
[[ ${EUID} -eq 0 ]] || { echo "请使用sudo执行安装脚本。" >&2; exit 1; }
|
||||
docker image inspect "$image" >/dev/null 2>&1 || { echo "应用镜像不存在。" >&2; exit 1; }
|
||||
install -d -m 0750 "$home" "$container_dir" "$config_dir" "$backup_dir" "$container_dir/migrations"
|
||||
install -d -m 0700 -o 10001 -g 10001 "$secret_dir"
|
||||
install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml"
|
||||
install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py"
|
||||
install -m 0750 "$script_dir/rds-schema-init.sh" "$container_dir/rds-schema-init.sh"
|
||||
install -m 0750 "$script_dir/dobotai-dingtalk-start" "$container_dir/dobotai-dingtalk-start"
|
||||
install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" "$container_dir/dobotai-dingtalk-bind-admin"
|
||||
install -m 0644 "$source_root"/db/migrations/*.up.sql "$container_dir/migrations/"
|
||||
|
||||
if [[ ! -f "$app_env" ]]; then
|
||||
umask 077
|
||||
read -r tenant_suffix ingress_token < <(python3 -c 'import secrets; print(secrets.token_hex(12), secrets.token_urlsafe(32))')
|
||||
cat > "$app_env" <<'EOF'
|
||||
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
|
||||
DATABASE_URL=
|
||||
WEKNORA_BASE_URL=http://127.0.0.1:8080
|
||||
WEKNORA_API_KEY=
|
||||
DOBOTAI_ADMIN_TOKEN=
|
||||
DOBOTAI_AGENT_BINDINGS=
|
||||
DOBOTAI_QUICK_AGENT_ID=
|
||||
DOBOTAI_REASONING_AGENT_ID=
|
||||
DINGTALK_CLIENT_ID=
|
||||
DINGTALK_CLIENT_SECRET=
|
||||
EOF
|
||||
printf 'DOBOTAI_TENANT_KEY=dobotai-%s\n' "$tenant_suffix" >> "$app_env"
|
||||
printf 'DOBOTAI_INGRESS_TOKEN=%s\n' "$ingress_token" >> "$app_env"
|
||||
chmod 0600 "$app_env"
|
||||
fi
|
||||
umask 077
|
||||
cat > "$runtime_env" <<EOF
|
||||
DOBOTAI_IMAGE=$image
|
||||
DOBOTAI_RUNTIME_UID=10001
|
||||
DOBOTAI_RUNTIME_GID=10001
|
||||
DOBOTAI_ENV_FILE=$app_env
|
||||
DOBOTAI_SECRET_DIR_HOST=$secret_dir
|
||||
EOF
|
||||
chmod 0600 "$runtime_env"
|
||||
install -d -m 0755 "$bin_dir"
|
||||
for command in configure start bind-admin; do
|
||||
target="$bin_dir/dobotai-dingtalk-$command"
|
||||
cat > "$target" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export DOBOTAI_HOME='$home'
|
||||
exec '$container_dir/dobotai-dingtalk-$command'
|
||||
EOF
|
||||
chmod 0750 "$target"
|
||||
done
|
||||
# configure is a Python entrypoint rather than a shell asset.
|
||||
cat > "$bin_dir/dobotai-dingtalk-configure" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export DOBOTAI_HOME='$home'
|
||||
exec python3 '$container_dir/configure_env.py' --env-file '$app_env' --schema-script '$container_dir/rds-schema-init.sh' --start-script '$bin_dir/dobotai-dingtalk-start'
|
||||
EOF
|
||||
chmod 0750 "$bin_dir/dobotai-dingtalk-configure"
|
||||
|
||||
echo "应用容器资产已安装;尚未连接或修改阿里云RDS。"
|
||||
echo "下一步执行:sudo dobotai-dingtalk-configure"
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
container_dir="${DOBOTAI_CONTAINER_DIR:-/opt/dobotai-test/container}"
|
||||
service="${DOBOTAI_LEGACY_SERVICE:-dobotai-test.service}"
|
||||
runtime_user="${DOBOTAI_RUNTIME_USER:-dobotai-test}"
|
||||
env_file="${DOBOTAI_ENV_FILE:-/etc/dobotai-test/dobotai.env}"
|
||||
secret_dir="${DOBOTAI_SECRET_DIR_HOST:-/var/lib/dobotai-test/secrets}"
|
||||
image="${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE to the reviewed image tag before installation}"
|
||||
backup_root="${DOBOTAI_BACKUP_ROOT:-/opt/dobotai-test/backups}"
|
||||
|
||||
if [[ ${EUID} -ne 0 ]]; then
|
||||
echo "请使用 sudo 执行安装脚本。" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! docker image inspect "$image" >/dev/null 2>&1; then
|
||||
echo "候选镜像不存在:$image" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -r "$env_file" || ! -d "$secret_dir" ]]; then
|
||||
echo "现有 DobotAI 环境文件或密钥目录不存在,拒绝安装。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
uid="$(id -u "$runtime_user")"
|
||||
gid="$(id -g "$runtime_user")"
|
||||
stamp="$(date -u '+%Y%m%dT%H%M%SZ')"
|
||||
backup_dir="$backup_root/pre-container-$stamp"
|
||||
install -d -m 0700 "$backup_dir" "$container_dir"
|
||||
cp -p /opt/dobotai-test/bin/dobotai "$backup_dir/dobotai.native"
|
||||
cp -p /etc/systemd/system/dobotai-test.service "$backup_dir/dobotai-test.service"
|
||||
sha256sum "$backup_dir/dobotai.native" > "$backup_dir/SHA256SUMS"
|
||||
|
||||
install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml"
|
||||
install -m 0750 "$script_dir/dobotai-dingtalk-start" /usr/local/sbin/dobotai-dingtalk-start
|
||||
install -m 0750 "$script_dir/dobotai-dingtalk-configure" /usr/local/sbin/dobotai-dingtalk-configure
|
||||
install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" /usr/local/sbin/dobotai-dingtalk-bind-admin
|
||||
install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py"
|
||||
|
||||
runtime_tmp="$container_dir/runtime.env.new"
|
||||
umask 077
|
||||
cat > "$runtime_tmp" <<EOF
|
||||
DOBOTAI_IMAGE=$image
|
||||
DOBOTAI_RUNTIME_UID=$uid
|
||||
DOBOTAI_RUNTIME_GID=$gid
|
||||
DOBOTAI_ENV_FILE=$env_file
|
||||
DOBOTAI_SECRET_DIR_HOST=$secret_dir
|
||||
EOF
|
||||
chmod 0600 "$runtime_tmp"
|
||||
mv -f "$runtime_tmp" "$container_dir/runtime.env"
|
||||
|
||||
echo "容器资产已安装,旧服务仍在运行。"
|
||||
echo "下一步执行:sudo dobotai-dingtalk-configure"
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
home="${DOBOTAI_HOME:-/opt/dobotai}"
|
||||
app_env="$home/config/dobotai.env"
|
||||
migrations_dir="$home/container/migrations"
|
||||
[[ -r "$app_env" && -d "$migrations_dir" ]] || { echo "RDS检查资产未安装。" >&2; exit 1; }
|
||||
temp_env="$(mktemp)"; trap 'rm -f "$temp_env"' EXIT
|
||||
python3 - "$app_env" "$temp_env" <<'PY'
|
||||
from pathlib import Path
|
||||
import os,sys
|
||||
values={}
|
||||
for line in Path(sys.argv[1]).read_text(encoding='utf-8').splitlines():
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key,value=line.split('=',1); values[key]=value
|
||||
url=values.get('DATABASE_URL','').strip()
|
||||
if not (url.startswith('postgres://') or url.startswith('postgresql://')): raise SystemExit('DATABASE_URL必须是阿里云RDS PostgreSQL连接串。')
|
||||
Path(sys.argv[2]).write_text('DATABASE_URL='+url+'\n',encoding='utf-8'); os.chmod(sys.argv[2],0o600)
|
||||
PY
|
||||
rds_shell() { docker run --rm -i --network host --env-file "$temp_env" postgres:16-alpine sh -ec "$1"; }
|
||||
rds_shell 'pg_isready -d "$DATABASE_URL" >/dev/null' || true
|
||||
if ! probe_error="$(rds_shell 'exec psql -At -v ON_ERROR_STOP=1 "$DATABASE_URL" -c "SELECT 1"' 2>&1)"; then
|
||||
case "$probe_error" in
|
||||
*"does not exist"*|*"不存在"*)
|
||||
echo "阿里云RDS目标数据库不存在;请RDS管理员创建DobotAI专用数据库并授予应用账号连接和建表权限。" >&2
|
||||
;;
|
||||
*)
|
||||
echo "无法连接阿里云RDS目标数据库;请管理员检查网络、白名单、TLS、账号权限和连接串。" >&2
|
||||
;;
|
||||
esac
|
||||
exit 1
|
||||
fi
|
||||
sql() { rds_shell 'exec psql -v ON_ERROR_STOP=1 "$DATABASE_URL"'; }
|
||||
sql_at() { rds_shell 'exec psql -At -v ON_ERROR_STOP=1 "$DATABASE_URL"'; }
|
||||
state=$(printf "%s\n" "WITH expected(name) AS (VALUES ('turns'),('session_mappings'),('messages'),('coverage_decisions'),('support_cases'),('system_config_revisions'),('administrators'),('setting_sessions'),('user_preferences'),('reasoning_mode_states'),('reasoning_mode_turns')) SELECT CASE WHEN to_regclass('public.turns') IS NULL AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public')=0 THEN 'empty' WHEN (SELECT COUNT(*) FROM expected e JOIN information_schema.tables t ON t.table_schema='public' AND t.table_name=e.name)=11 THEN 'normal' ELSE 'inconsistent' END;" | sql_at)
|
||||
case "$state" in
|
||||
normal) echo "阿里云RDS表结构检查通过;不执行初始化。"; exit 0;;
|
||||
inconsistent) echo "阿里云RDS表结构不完整或不是空库,拒绝初始化和启动。" >&2; exit 1;;
|
||||
empty) echo "阿里云RDS为空,开始初始化DobotAI表。";;
|
||||
*) echo "无法判定阿里云RDS表状态。" >&2; exit 1;;
|
||||
esac
|
||||
for migration in "$migrations_dir"/*.up.sql; do printf 'BEGIN;\n'; cat "$migration"; printf '\nCOMMIT;\n'; done | sql
|
||||
echo "阿里云RDS初始化完成。"
|
||||
@@ -0,0 +1,6 @@
|
||||
# 非敏感容器运行参数;install-test.sh 会根据测试服务器实际用户和目录生成此文件。
|
||||
DOBOTAI_IMAGE=dobotai-dingtalk:test
|
||||
DOBOTAI_RUNTIME_UID=996
|
||||
DOBOTAI_RUNTIME_GID=988
|
||||
DOBOTAI_ENV_FILE=/etc/dobotai-test/dobotai.env
|
||||
DOBOTAI_SECRET_DIR_HOST=/var/lib/dobotai-test/secrets
|
||||
@@ -0,0 +1,5 @@
|
||||
\pset pager off
|
||||
SELECT table_schema || '.' || table_name AS t
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
ORDER BY 1;
|
||||
@@ -0,0 +1,8 @@
|
||||
\pset pager off
|
||||
\echo == columns ==
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'channel_actors'
|
||||
ORDER BY ordinal_position;
|
||||
\echo == rows ==
|
||||
SELECT * FROM channel_actors ORDER BY 1 LIMIT 6;
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prod-server switch to the combined release:
|
||||
# reasoning-body boundary refresh + offset-0 body boundary + card structural diagnostics
|
||||
# + wide-screen (autoLayout) card payload
|
||||
# RUN ON THE PRODUCTION HOST (120.76.218.137) as root. Do not wrap in ssh.
|
||||
#
|
||||
# Usage: bash prod_switch_reasoning_widescreen.sh <new-image-ref>
|
||||
#
|
||||
# Safety: requires the new image locally; tags the running image as a rollback tag;
|
||||
# backs up runtime.env with its original mode; refuses unless DOBOTAI_IMAGE appears
|
||||
# exactly once before and once after the edit and the line count is unchanged;
|
||||
# refuses unless the new binary carries every release literal.
|
||||
# Prints only image refs / variable names / literal names - never env values.
|
||||
set -euo pipefail
|
||||
|
||||
NEW_IMAGE="${1:-}"
|
||||
[ -n "$NEW_IMAGE" ] || { echo "ABORT: pass the new image ref as \$1" >&2; exit 1; }
|
||||
|
||||
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
|
||||
BACKUP_DIR="/opt/dobotai/container/backups"
|
||||
COMPOSE_SERVICE="dobotai-dingtalk"
|
||||
RELEASE_SLUG="reasoning-widescreen"
|
||||
|
||||
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
|
||||
OLD_SHA="$(echo "$OLD_IMAGE" | sed 's/.*://')"
|
||||
ROLLBACK_TAG="dobotai-dingtalk:rollback-${OLD_SHA}-before-${RELEASE_SLUG}"
|
||||
STAMP="$(date +%Y%m%dT%H%M%S)"
|
||||
BACKUP="$BACKUP_DIR/runtime.env.before-${RELEASE_SLUG}.${STAMP}"
|
||||
|
||||
echo "== preflight =="
|
||||
docker image inspect "$NEW_IMAGE" >/dev/null || { echo "ABORT: $NEW_IMAGE not present locally (load it first)" >&2; exit 1; }
|
||||
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
|
||||
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
|
||||
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
|
||||
for lit in sys_full_json_obj autoLayout body_prefix_runes banner_pos PartialFeed; do
|
||||
if docker run --rm --entrypoint sh "$NEW_IMAGE" -c "grep -aqF '$lit' /usr/local/bin/dobotai"; then
|
||||
echo "literal $lit: PRESENT"
|
||||
else
|
||||
echo "ABORT: $NEW_IMAGE lacks literal '$lit' (wrong artifact)" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "old_image=$OLD_IMAGE"
|
||||
echo "new_image=$NEW_IMAGE"
|
||||
echo "rollback_tag=$ROLLBACK_TAG"
|
||||
|
||||
echo "== tag rollback target =="
|
||||
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
|
||||
echo "rollback tag already exists, keeping it (no overwrite)"
|
||||
else
|
||||
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
|
||||
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
|
||||
fi
|
||||
|
||||
echo "== backup runtime.env =="
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
ORIG_MODE="$(stat -c '%a' "$RUNTIME_ENV")"
|
||||
install -m "$ORIG_MODE" "$RUNTIME_ENV" "$BACKUP"
|
||||
[ "$ORIG_MODE" = "600" ] || chmod 0600 "$RUNTIME_ENV"
|
||||
echo "backup=$BACKUP mode=$ORIG_MODE"
|
||||
|
||||
echo "== rewrite DOBOTAI_IMAGE =="
|
||||
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
|
||||
chmod "$ORIG_MODE" "$tmp"
|
||||
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
|
||||
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
|
||||
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
|
||||
rm -f "$tmp"
|
||||
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv "$tmp" "$RUNTIME_ENV"
|
||||
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
|
||||
|
||||
echo "== recreate container =="
|
||||
cd /opt/dobotai/container
|
||||
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate "$COMPOSE_SERVICE"
|
||||
|
||||
echo "== readback =="
|
||||
sleep 4
|
||||
curl -fsS -m 5 http://127.0.0.1:8090/healthz; echo
|
||||
docker inspect "$COMPOSE_SERVICE" --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
|
||||
for lit in sys_full_json_obj autoLayout body_prefix_runes banner_pos PartialFeed; do
|
||||
if docker exec "$COMPOSE_SERVICE" grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
|
||||
echo "running literal $lit: PRESENT"
|
||||
else
|
||||
echo "running literal $lit: ABSENT"
|
||||
fi
|
||||
done
|
||||
echo "== log scan (since 90s) =="
|
||||
docker logs "$COMPOSE_SERVICE" --since 90s 2>&1 | grep -icE 'panic|fatal|level=error| ERROR ' || echo "0 error/panic lines"
|
||||
docker logs "$COMPOSE_SERVICE" --since 90s 2>&1 | tail -6
|
||||
|
||||
echo "== rollback (one command, run on this host) =="
|
||||
echo "sed -i 's|^DOBOTAI_IMAGE=.*|DOBOTAI_IMAGE=$OLD_IMAGE|' $RUNTIME_ENV && cd /opt/dobotai/container && docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate $COMPOSE_SERVICE"
|
||||
echo "or restore: install -m $ORIG_MODE $BACKUP $RUNTIME_ENV (then the compose line above)"
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test-server switch to the build that delivers card images as DingTalk media ids.
|
||||
# Rollback tag + 0600 env backup first; refuses unless the DOBOTAI_IMAGE
|
||||
# substitution is exactly 1 before and exactly 1 after. Read-only readback at
|
||||
# the end. No secrets are printed.
|
||||
set -euo pipefail
|
||||
|
||||
NEW_IMAGE="${NEW_IMAGE:-dobotai-dingtalk:local-dfc7c8dba956}"
|
||||
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
|
||||
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
|
||||
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-card-image-media"
|
||||
STAMP="$(date +%Y%m%dT%H%M%S)"
|
||||
BACKUP_DIR="/opt/dobotai/container/backups"
|
||||
BACKUP="$BACKUP_DIR/runtime.env.before-card-image-media.$STAMP"
|
||||
|
||||
echo "== preflight =="
|
||||
docker image inspect "$NEW_IMAGE" >/dev/null
|
||||
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
|
||||
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
|
||||
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
|
||||
echo "old_image=$OLD_IMAGE"
|
||||
echo "new_image=$NEW_IMAGE"
|
||||
echo "rollback_tag=$ROLLBACK_TAG"
|
||||
|
||||
echo "== tag rollback target =="
|
||||
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
|
||||
echo "rollback tag already exists, keeping it (no overwrite)"
|
||||
else
|
||||
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
|
||||
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
|
||||
fi
|
||||
|
||||
echo "== backup runtime.env =="
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
|
||||
echo "backup=$BACKUP"
|
||||
|
||||
echo "== rewrite DOBOTAI_IMAGE =="
|
||||
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
|
||||
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
|
||||
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
|
||||
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
|
||||
rm -f "$tmp"
|
||||
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 0644 "$tmp"
|
||||
mv "$tmp" "$RUNTIME_ENV"
|
||||
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
|
||||
|
||||
echo "== recreate container =="
|
||||
cd /opt/dobotai/container
|
||||
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
|
||||
|
||||
echo "== readback =="
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:8090/healthz; echo
|
||||
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
|
||||
for lit in oapi.dingtalk.com/media/upload DINGTALK_CARD_IMAGE_MEDIA; do
|
||||
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
|
||||
echo "literal $lit: PRESENT"
|
||||
else
|
||||
echo "literal $lit: ABSENT"
|
||||
fi
|
||||
done
|
||||
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -6
|
||||
echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)"
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test-server switch to the wide-screen (autoLayout) card image.
|
||||
# Rollback tag + 0600 env backup first; refuses unless the DOBOTAI_IMAGE
|
||||
# substitution is exactly 1 before and exactly 1 after. Read-only readback
|
||||
# at the end. No secrets are printed.
|
||||
set -euo pipefail
|
||||
|
||||
NEW_IMAGE="dobotai-dingtalk:local-cardfix-ba090d17"
|
||||
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
|
||||
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
|
||||
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-card-widescreen"
|
||||
STAMP="$(date +%Y%m%dT%H%M%S)"
|
||||
BACKUP_DIR="/opt/dobotai/container/backups"
|
||||
BACKUP="$BACKUP_DIR/runtime.env.before-card-widescreen.$STAMP"
|
||||
|
||||
echo "== preflight =="
|
||||
docker image inspect "$NEW_IMAGE" >/dev/null
|
||||
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
|
||||
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
|
||||
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
|
||||
echo "old_image=$OLD_IMAGE"
|
||||
echo "new_image=$NEW_IMAGE"
|
||||
echo "rollback_tag=$ROLLBACK_TAG"
|
||||
|
||||
echo "== tag rollback target =="
|
||||
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
|
||||
echo "rollback tag already exists, keeping it (no overwrite)"
|
||||
else
|
||||
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
|
||||
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
|
||||
fi
|
||||
|
||||
echo "== backup runtime.env =="
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
|
||||
echo "backup=$BACKUP"
|
||||
|
||||
echo "== rewrite DOBOTAI_IMAGE =="
|
||||
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
|
||||
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
|
||||
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
|
||||
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
|
||||
rm -f "$tmp"
|
||||
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 0644 "$tmp"
|
||||
mv "$tmp" "$RUNTIME_ENV"
|
||||
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
|
||||
|
||||
echo "== recreate container =="
|
||||
cd /opt/dobotai/container
|
||||
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
|
||||
|
||||
echo "== readback =="
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:8090/healthz; echo
|
||||
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
|
||||
for lit in sys_full_json_obj autoLayout _CARD_DEBUG_TOOL_ENTRY; do
|
||||
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
|
||||
echo "literal $lit: PRESENT"
|
||||
else
|
||||
echo "literal $lit: ABSENT"
|
||||
fi
|
||||
done
|
||||
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -5
|
||||
echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)"
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generic test-server image switch: rollback tag first, 0600 runtime.env backup,
|
||||
# post-edit verification (exactly 1 DOBOTAI_IMAGE line, unchanged line count),
|
||||
# container recreate, then read-only readback incl. binary literal presence.
|
||||
# Usage: bash test_switch_image.sh <image:tag> <label>
|
||||
set -euo pipefail
|
||||
NEW_IMAGE="${1:?usage: test_switch_image.sh <image:tag> <label>}"
|
||||
LABEL="${2:?usage: test_switch_image.sh <image:tag> <label>}"
|
||||
RUNTIME_ENV=/opt/dobotai/container/runtime.env
|
||||
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
|
||||
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-$LABEL"
|
||||
STAMP="$(date +%Y%m%dT%H%M%S)"
|
||||
BACKUP_DIR=/opt/dobotai/container/backups
|
||||
BACKUP="$BACKUP_DIR/runtime.env.before-$LABEL.$STAMP"
|
||||
|
||||
echo "== preflight =="
|
||||
docker image inspect "$NEW_IMAGE" >/dev/null
|
||||
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
|
||||
[ "$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")" = "1" ] || { echo "ABORT: DOBOTAI_IMAGE line count != 1" >&2; exit 1; }
|
||||
echo "old_image=$OLD_IMAGE"
|
||||
echo "new_image=$NEW_IMAGE"
|
||||
echo "rollback_tag=$ROLLBACK_TAG"
|
||||
|
||||
echo "== tag rollback target =="
|
||||
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
|
||||
echo "rollback tag already exists, keeping it (no overwrite)"
|
||||
else
|
||||
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
|
||||
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
|
||||
fi
|
||||
|
||||
echo "== backup runtime.env =="
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
|
||||
echo "backup=$BACKUP"
|
||||
|
||||
echo "== rewrite DOBOTAI_IMAGE =="
|
||||
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
|
||||
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
|
||||
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
|
||||
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
|
||||
rm -f "$tmp"
|
||||
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 0644 "$tmp"
|
||||
mv "$tmp" "$RUNTIME_ENV"
|
||||
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
|
||||
|
||||
echo "== recreate container =="
|
||||
cd /opt/dobotai/container
|
||||
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
|
||||
|
||||
echo "== readback =="
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:8090/healthz; echo
|
||||
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
|
||||
for lit in PartialFeed autoLayout 智能推理模式; do
|
||||
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
|
||||
echo "literal $lit: PRESENT"
|
||||
else
|
||||
echo "literal $lit: ABSENT"
|
||||
fi
|
||||
done
|
||||
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -8
|
||||
echo "rollback: set DOBOTAI_IMAGE=$OLD_IMAGE in $RUNTIME_ENV (backup $BACKUP) then: docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk"
|
||||
+16
-16
@@ -1,21 +1,21 @@
|
||||
# WeKnora API Capability Gaps
|
||||
# WeKnora API 能力缺口
|
||||
|
||||
These gaps must not be solved with private WeKnora source or database changes.
|
||||
以下能力缺口不得通过修改 WeKnora 私有源码或数据库解决。
|
||||
|
||||
| Capability | Current external handling |
|
||||
| 能力 | 当前外部处理方式 |
|
||||
|---|---|
|
||||
| Like/dislike and unresolved reason | DobotAI PostgreSQL and API |
|
||||
| Formal covered/not-covered decision | DobotAI evaluation rules using references, scores, answer status, and offline calibration |
|
||||
| Support case, owner, SLA, and status | DobotAI workflow |
|
||||
| Human reply to original channel | DobotAI channel adapter; not written to WeKnora history by default |
|
||||
| Stable external turn correlation | DobotAI mapping of external message, turn, WeKnora session/request, and gateway trace |
|
||||
| Complete multi-call token usage | Model gateway/Langfuse; unknown remains null/unknown |
|
||||
| Strict image failure semantics | Adapter contract test; reject image traffic on failure |
|
||||
| Arbitrary human assistant-message append | Keep in DobotAI and submit an upstream API request only if context injection becomes necessary |
|
||||
| 点赞、点踩和未解决原因 | DobotAI PostgreSQL 与 API |
|
||||
| 正式的已覆盖/未覆盖判定 | DobotAI 基于引用、分数、回答状态和离线校准的评估规则 |
|
||||
| Support Case、负责人、SLA 和状态 | DobotAI 工作流 |
|
||||
| 向原始渠道发送人工回复 | DobotAI 渠道适配器;默认不写回 WeKnora 历史 |
|
||||
| 稳定的外部 Turn 关联 | DobotAI 映射外部消息、Turn、WeKnora 会话/请求和网关追踪 |
|
||||
| 完整的多调用 Token 用量 | 模型网关/Langfuse;未知值保持 `null/unknown` |
|
||||
| 严格的图片失败语义 | Adapter 契约测试;图片流量失败时拒绝处理 |
|
||||
| 任意人工助手消息追加 | 保留在 DobotAI;仅当确有上下文注入需求时向上游提交 API 请求 |
|
||||
|
||||
Allowed responses to a gap:
|
||||
面对能力缺口时,只允许:
|
||||
|
||||
1. adjust the product design;
|
||||
2. implement it outside WeKnora;
|
||||
3. open an upstream issue;
|
||||
4. submit a general upstream pull request.
|
||||
1. 调整产品设计;
|
||||
2. 在 WeKnora 外部实现;
|
||||
3. 提交上游 Issue;
|
||||
4. 提交通用的上游 Pull Request。
|
||||
+74
-50
@@ -1,74 +1,98 @@
|
||||
# DobotAI External Architecture
|
||||
# DobotAI 外部集成架构
|
||||
|
||||
## Boundary
|
||||
## 系统边界
|
||||
|
||||
```text
|
||||
DingTalk / WeCom / Web / Software
|
||||
-> DobotAI ingress and workflows
|
||||
钉钉 / 企业微信 / Web / 业务软件
|
||||
-> DobotAI 接入层与工作流
|
||||
-> WeKnora Adapter
|
||||
-> official WeKnora REST API
|
||||
-> official WeKnora RAG and Agent
|
||||
-> WeKnora 官方 REST API
|
||||
-> WeKnora 官方 RAG 与 Agent
|
||||
```
|
||||
|
||||
DobotAI PostgreSQL owns channel identities, external messages, session mappings, turns, feedback, coverage decisions, support cases, human replies, FAQ workflow state, and content-source mappings. WeKnora owns only its official data model.
|
||||
DobotAI PostgreSQL 负责渠道身份、外部消息、会话映射、Turn、反馈、覆盖判定、Support Case、人工回复、FAQ 工作流状态和内容源映射。WeKnora 仅负责其官方数据模型。
|
||||
|
||||
## Session policy
|
||||
## 会话策略
|
||||
|
||||
Default policy:
|
||||
默认策略:
|
||||
|
||||
- direct: one long-lived session per tenant + channel + Agent + external user;
|
||||
- group: one shared session per tenant + channel + Agent + external group;
|
||||
- group messages are admitted only when the bot is mentioned or an explicit command triggers it;
|
||||
- sender display name is prefixed to the query so a shared Agent can distinguish speakers;
|
||||
- future controls must include idle rotation and an explicit new-conversation command;
|
||||
- confidential channels may switch to `per_user`, yielding group + user sessions.
|
||||
- 私聊:每个 `tenant + channel + Agent + 外部用户` 使用一个长期会话;
|
||||
- 群聊:每个 `tenant + channel + Agent + 外部群` 使用一个共享会话;
|
||||
- 群消息仅在机器人被提及或命中显式命令时进入处理;
|
||||
- 将发送人显示名添加到问题前缀,使共享 Agent 能区分发言者;
|
||||
- 后续控制项必须包含空闲轮换和显式新建会话命令;
|
||||
- 保密渠道可切换为 `per_user`,形成群组加用户维度的会话。
|
||||
|
||||
Shared group sessions are the common collaboration-bot pattern because all members see the same conversation. Group+user sessions provide stronger context isolation but make replies less coherent for collaborative group discussions.
|
||||
共享群会话符合协作机器人场景:所有成员能看到相同上下文。群组加用户会话能增强上下文隔离,但会削弱协作讨论中的回复连贯性。
|
||||
|
||||
## Agent binding
|
||||
快速问答与智能推理必须复用同一个 WeKnora Session;`/think` 仅改变当前 Turn 的执行 Agent,不得重建会话映射。
|
||||
|
||||
Agents are configuration-owned. Channel callers cannot choose an arbitrary `agent_id`.
|
||||
## Agent 绑定
|
||||
|
||||
Resolution order:
|
||||
Agent 由配置控制,渠道调用方不得任意指定 `agent_id`。
|
||||
|
||||
1. exact `<channel>:<external_chat_id>` binding;
|
||||
2. channel wildcard `<channel>:*`;
|
||||
3. fail closed when no binding exists.
|
||||
解析顺序:
|
||||
|
||||
## Image failure policy
|
||||
1. 精确 `<channel>:<external_chat_id>` 绑定;
|
||||
2. 渠道通配 `<channel>:*`;
|
||||
3. 没有任何绑定时失败关闭。
|
||||
|
||||
Before enabling image traffic, the WeKnora Adapter must run a contract test against the selected official release and configured vision model.
|
||||
## 首位管理员终端绑定
|
||||
|
||||
If any image path fails:
|
||||
首位管理员绑定由部署服务器终端发起,而非网页发码。钉钉 Stream 机器人已正常运行且当前不存在有效管理员时,操作人员执行:
|
||||
|
||||
1. do not resend the question without the image;
|
||||
2. do not return partial model output as success;
|
||||
3. return a typed `vision_unavailable` failure to the channel adapter;
|
||||
4. DingTalk tells the user that image recognition is temporarily unavailable and asks them to retry or send text;
|
||||
5. emit an operational alert to verify Agent image upload settings, selected vision model, provider connectivity, and model capability;
|
||||
6. keep image traffic disabled until the contract test passes.
|
||||
```bash
|
||||
dobotai bootstrap-admin
|
||||
```
|
||||
|
||||
No WeKnora source patch is allowed as a workaround.
|
||||
命令只连接 DobotAI PostgreSQL,使用加密随机数创建一个有效期10分钟、最多5次错误尝试的六码挑战,并在终端持续等待。候选用户必须私聊机器人发送:
|
||||
|
||||
## Service ownership
|
||||
```text
|
||||
/setting <六码绑定码>
|
||||
```
|
||||
|
||||
- `internal/weknora`: official REST/SSE compatibility only.
|
||||
- `internal/qa`: business orchestration and terminal-state ownership.
|
||||
- `internal/session`: external-to-WeKnora session policy.
|
||||
- `internal/store/postgres`: DobotAI business persistence only.
|
||||
- `internal/coverage`: v1 covered/uncovered evaluation, durable support-case dispatch, finite retries, and stable failure codes. Notification failures never change a successful QA result.
|
||||
- `internal/channels/dingtalk`: DingTalk Stream ingress (official SDK), text-only robot callbacks, fail-closed session webhook replies (`https`, `*.dingtalk.com`, no redirects). Wired in `cmd/dobotai`; enabled only when `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, and `DOBOTAI_TENANT_KEY` are all set. Group messages require an @-mention; direct chats map to one long-lived session per user, groups to one shared session per group. Images, OpenAPI fallback, and card replies are not implemented yet.
|
||||
- future `channels/*`: platform callbacks and outbound replies.
|
||||
- future `content-sync/*`: DingTalk KB, NAS, and CMS synchronization.
|
||||
- model gateway: token accounting, routing, quotas, circuit breaking, and provider observability.
|
||||
- Redis: queue, retry, short leases, and temporary stream state only.
|
||||
验证码匹配后,渠道侧只记录候选身份;终端轮询并事务性将该候选用户写为 `super_admin/active`,然后显示成功并退出。已有有效管理员时该命令拒绝执行,避免任意用户通过终端绑定流程被提权。DobotAI 不提供网页管理面,钉钉凭据和运行配置由部署环境文件管理。
|
||||
|
||||
## Upgrade contract
|
||||
## 图片失败策略
|
||||
|
||||
For every official WeKnora upgrade:
|
||||
启用图片流量前,WeKnora Adapter 必须针对选定的官方版本和视觉模型执行契约测试。
|
||||
|
||||
1. update the pinned official release/tag;
|
||||
2. run official database migrations against WeKnora only;
|
||||
3. run DobotAI Adapter contract tests;
|
||||
4. verify text Agent Chat, references, terminal errors, Stop, images, ingestion, and FAQ;
|
||||
5. promote the official image only after the contract suite passes.
|
||||
任一图片路径失败时:
|
||||
|
||||
1. 不得去掉图片后重发同一问题;
|
||||
2. 不得将部分模型输出当作成功结果;
|
||||
3. 向渠道适配器返回类型化 `vision_unavailable` 错误;
|
||||
4. 钉钉提示用户图片识别暂不可用,并请其重试或发送文本;
|
||||
5. 发送运维告警,核查 Agent 图片上传设置、视觉模型、供应商连通性和模型能力;
|
||||
6. 契约测试通过前保持图片流量禁用。
|
||||
|
||||
不得通过修改 WeKnora 源码规避上述失败。
|
||||
|
||||
对钉钉 richText 中的图片,DobotAI 仅通过官方接口下载,验证来源、大小、媒体类型和文件签名后,才转换为 WeKnora 通用多模态请求。对回答中的 `resource://` 图片,仅可在鉴权获取、内容哈希转存私有 OSS 后外发;不得把任意外部 URL 当作可信图片。
|
||||
|
||||
## 服务职责
|
||||
|
||||
- `internal/weknora`:仅处理 WeKnora 官方 REST/SSE 兼容;解析普通引用和智能推理工具结果引用;
|
||||
- `internal/qa`:业务编排与 Turn 终态归属;
|
||||
- `internal/session`:外部渠道到 WeKnora 的会话策略;
|
||||
- `internal/store/postgres`:仅保存 DobotAI 业务数据;
|
||||
- `internal/coverage`:已覆盖/未覆盖/纯视觉识别判定、持久化 Support Case、有限重试和稳定错误码;通知失败不得改变成功问答的结果;
|
||||
- `internal/channels/dingtalk`:钉钉 Stream 接入、richText 与多图片归一化、AI 流式卡片、命令路由、反馈与通知。仅在 `DINGTALK_CLIENT_ID`、`DINGTALK_CLIENT_SECRET` 和 `DOBOTAI_TENANT_KEY` 均已设置时启用。群聊必须 `@` 机器人;私聊按用户映射长期会话,群聊默认映射共享会话;`sessionWebhook` 必须是 `https`、`*.dingtalk.com`、443端口且不允许重定向;
|
||||
- `internal/reasoningmode`:`/think` 单次与持续推理、轮数、空闲到期、提示和首次使用引导;
|
||||
- `internal/outboundimage`:WeKnora `resource://` 图片的受控鉴权下载和私有 OSS 外发;
|
||||
- `future channels/*`:其他平台的回调与外发回复;
|
||||
- `future content-sync/*`:钉钉知识库、NAS 与 CMS 同步;
|
||||
- 模型网关:Token 统计、路由、配额、熔断和供应商可观测性;
|
||||
- Redis:仅用于队列、重试、短期租约和临时流状态。
|
||||
|
||||
## 升级契约
|
||||
|
||||
每次升级官方 WeKnora 时:
|
||||
|
||||
1. 更新固定的官方 Release/Tag;
|
||||
2. 仅对 WeKnora 执行官方数据库迁移;
|
||||
3. 执行 DobotAI Adapter 契约测试;
|
||||
4. 验证文本 Agent Chat、引用、终止错误、停止、图片、导入和 FAQ;
|
||||
5. 仅在契约测试通过后推广官方镜像。
|
||||
|
||||
WeKnora 升级、DobotAI 迁移、钉钉单消费者切换和生产部署是独立变更边界,必须各自备份、演练和验收。
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ${EUID} -ne 0 ]]; then
|
||||
echo "请在仓库根目录执行:sudo bash ./install-docker.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$repo_root"
|
||||
[[ -f Dockerfile && -f deploy/dobotai-dingtalk/install-docker.sh ]] || { echo "必须从完整DobotAI源码仓库根目录运行。" >&2; exit 1; }
|
||||
command -v git >/dev/null || { echo "未找到git。" >&2; exit 1; }
|
||||
git rev-parse --is-inside-work-tree >/dev/null || { echo "请从git clone得到的仓库中执行。" >&2; exit 1; }
|
||||
|
||||
install_docker_if_missing() {
|
||||
if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then return; fi
|
||||
command -v apt-get >/dev/null || { echo "请先安装Docker Engine和Docker Compose v2。" >&2; exit 1; }
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
|
||||
apt-get install -y docker.io docker-compose-v2
|
||||
elif apt-cache show docker-compose-plugin >/dev/null 2>&1; then
|
||||
apt-get install -y docker.io docker-compose-plugin
|
||||
else
|
||||
echo "APT源缺少Docker Compose v2插件。" >&2; exit 1
|
||||
fi
|
||||
systemctl enable --now docker
|
||||
}
|
||||
install_docker_if_missing
|
||||
docker compose version >/dev/null
|
||||
|
||||
commit_sha="$(git rev-parse HEAD)"
|
||||
commit_short="$(git rev-parse --short=12 HEAD)"
|
||||
image="dobotai-dingtalk:local-${commit_short}"
|
||||
echo "[1/2] 构建DobotAI应用镜像:$image"
|
||||
docker build --build-arg "GOPROXY=https://goproxy.cn,direct" --build-arg "VERSION=local-${commit_short}" --build-arg "COMMIT_SHA=${commit_sha}" --build-arg "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" --label "org.opencontainers.image.revision=${commit_sha}" --tag "$image" "$repo_root"
|
||||
|
||||
echo "[2/2] 安装应用容器资产;阿里云RDS将在配置后检查或初始化"
|
||||
export DOBOTAI_IMAGE="$image"
|
||||
export DOBOTAI_SOURCE_ROOT="$repo_root"
|
||||
exec bash "$repo_root/deploy/dobotai-dingtalk/install-docker.sh"
|
||||
@@ -1,205 +0,0 @@
|
||||
package adminweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
)
|
||||
|
||||
type ConfigRepository interface {
|
||||
ActiveDingTalkConfig(context.Context, string) (string, string, bool, error)
|
||||
CandidateDingTalkConfig(context.Context, string) (string, string, error)
|
||||
SaveDingTalkConfig(context.Context, string, string, string, bool) error
|
||||
MarkDingTalkCandidateFailed(context.Context, string, string, string) error
|
||||
AdministratorCount(context.Context, string) (int, error)
|
||||
ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error)
|
||||
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
|
||||
ConfirmBootstrap(context.Context, string) error
|
||||
}
|
||||
type AdministratorRepository interface {
|
||||
ListAdministrators(context.Context, string) ([]settings.Administrator, error)
|
||||
DisableAdministrator(context.Context, string, string) error
|
||||
}
|
||||
type CandidateActivator interface{ ActivateCandidate(context.Context) error }
|
||||
type OperationalStatusProvider interface {
|
||||
Components(context.Context) ([]Component, error)
|
||||
}
|
||||
type CredentialVerifier interface {
|
||||
Verify(context.Context, string, string) dingtalkconfig.Result
|
||||
}
|
||||
type ControlService struct {
|
||||
tenant string
|
||||
repo ConfigRepository
|
||||
secrets *secretstore.Store
|
||||
verifier CredentialVerifier
|
||||
activator CandidateActivator
|
||||
status OperationalStatusProvider
|
||||
}
|
||||
|
||||
func NewControlService(tenant string, repo ConfigRepository, secrets *secretstore.Store, verifier CredentialVerifier) (*ControlService, error) {
|
||||
if strings.TrimSpace(tenant) == "" || repo == nil || secrets == nil || verifier == nil {
|
||||
return nil, errors.New("admin control dependencies are required")
|
||||
}
|
||||
return &ControlService{tenant: strings.TrimSpace(tenant), repo: repo, secrets: secrets, verifier: verifier}, nil
|
||||
}
|
||||
func (s *ControlService) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
id, ref, verified, err := s.repo.ActiveDingTalkConfig(ctx, s.tenant)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
admins, err := s.repo.AdministratorCount(ctx, s.tenant)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
bindings, err := s.repo.ActiveBindings(ctx, s.tenant)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
administratorViews := []AdministratorView{}
|
||||
if administratorRepo, ok := s.repo.(AdministratorRepository); ok {
|
||||
administrators, listErr := administratorRepo.ListAdministrators(ctx, s.tenant)
|
||||
if listErr != nil {
|
||||
return Snapshot{}, listErr
|
||||
}
|
||||
activeCount := 0
|
||||
for _, administrator := range administrators {
|
||||
if administrator.Status == "active" {
|
||||
activeCount++
|
||||
}
|
||||
}
|
||||
for _, administrator := range administrators {
|
||||
administratorViews = append(administratorViews, AdministratorView{
|
||||
StaffID: administrator.StaffID, StaffIDMasked: maskID(administrator.StaffID),
|
||||
DisplayName: administrator.DisplayName, Department: administrator.Department,
|
||||
Role: administrator.Role, Status: administrator.Status,
|
||||
CanDisable: administrator.Status == "active" && activeCount > 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
state := settings.DetermineState(settings.Snapshot{ClientID: id, SecretConfigured: s.secrets.Configured(ref), CredentialsVerified: verified, AdministratorCount: admins})
|
||||
now := time.Now()
|
||||
components := []Component{
|
||||
{Name: "配置仓库", State: "ok", Code: "repository_ok", Detail: "PostgreSQL配置查询成功", LastSuccess: now, CheckedAt: now},
|
||||
{Name: "钉钉OAuth", State: status(verified), Code: ternaryCode(verified, "oauth_verified", "oauth_unconfigured"), Detail: "当前应用凭据验证状态", CheckedAt: now},
|
||||
{Name: "管理员", State: countStatus(admins), Code: ternaryCode(admins > 0, "admin_configured", "admin_missing"), Detail: itoa(admins) + " 位有效管理员", CheckedAt: now},
|
||||
{Name: "技术支持群绑定", State: bindingStatus(bindings, "support_group"), Code: bindingCode(bindings, "support_group"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
|
||||
{Name: "问题AI表格绑定", State: bindingStatus(bindings, "question_table"), Code: bindingCode(bindings, "question_table"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
|
||||
{Name: "FAQ表格绑定", State: bindingStatus(bindings, "faq_table"), Code: bindingCode(bindings, "faq_table"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
|
||||
}
|
||||
if s.status != nil {
|
||||
operational, probeErr := s.status.Components(ctx)
|
||||
if probeErr != nil {
|
||||
components = append(components, Component{Name: "运行探针", State: "error", Code: "component_probe_failed", Detail: "运行状态探针失败;未显示原始错误", CheckedAt: time.Now()})
|
||||
} else {
|
||||
components = append(components, operational...)
|
||||
}
|
||||
}
|
||||
return Snapshot{SystemState: string(state), ClientIDMasked: maskID(id), SecretConfigured: s.secrets.Configured(ref), Components: components, Administrators: administratorViews}, nil
|
||||
}
|
||||
func (s *ControlService) VerifyCredentials(ctx context.Context, id, secret string) Verification {
|
||||
r := s.verifier.Verify(ctx, id, secret)
|
||||
return Verification{OK: r.OK, Code: r.Code, CheckedAt: r.CheckedAt}
|
||||
}
|
||||
func (s *ControlService) SaveCredentials(ctx context.Context, id, secret string) error {
|
||||
_, oldRef, _ := s.repo.CandidateDingTalkConfig(ctx, s.tenant)
|
||||
ref, err := s.secrets.Put("dingtalk", secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.repo.SaveDingTalkConfig(ctx, s.tenant, strings.TrimSpace(id), ref, false); err != nil {
|
||||
_ = s.secrets.Delete(ref)
|
||||
return err
|
||||
}
|
||||
if oldRef != "" && oldRef != ref {
|
||||
_ = s.secrets.Delete(oldRef)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *ControlService) SetCandidateActivator(activator CandidateActivator) { s.activator = activator }
|
||||
func (s *ControlService) SetOperationalStatusProvider(provider OperationalStatusProvider) {
|
||||
s.status = provider
|
||||
}
|
||||
func (s *ControlService) ActivateCandidate(ctx context.Context) error {
|
||||
if s.activator == nil {
|
||||
return errors.New("candidate activator unavailable")
|
||||
}
|
||||
_, ref, err := s.repo.CandidateDingTalkConfig(ctx, s.tenant)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.activator.ActivateCandidate(ctx); err != nil {
|
||||
_ = s.repo.MarkDingTalkCandidateFailed(ctx, s.tenant, ref, "activation_failed")
|
||||
return errors.New("candidate activation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *ControlService) CreateBootstrap(ctx context.Context, code string) error {
|
||||
return s.repo.CreateBootstrapChallenge(ctx, s.tenant, code, time.Now().Add(10*time.Minute))
|
||||
}
|
||||
func (s *ControlService) ConfirmBootstrap(ctx context.Context) error {
|
||||
return s.repo.ConfirmBootstrap(ctx, s.tenant)
|
||||
}
|
||||
func (s *ControlService) DisableAdministrator(ctx context.Context, staffID string) error {
|
||||
staffID = strings.TrimSpace(staffID)
|
||||
if staffID == "" {
|
||||
return settings.ErrAdministratorNotFound
|
||||
}
|
||||
repo, ok := s.repo.(AdministratorRepository)
|
||||
if !ok {
|
||||
return errors.New("administrator management unavailable")
|
||||
}
|
||||
return repo.DisableAdministrator(ctx, s.tenant, staffID)
|
||||
}
|
||||
func maskID(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if len(v) <= 6 {
|
||||
return v
|
||||
}
|
||||
return v[:3] + "***" + v[len(v)-3:]
|
||||
}
|
||||
func status(ok bool) string {
|
||||
if ok {
|
||||
return "ok"
|
||||
}
|
||||
return "unconfigured"
|
||||
}
|
||||
func countStatus(n int) string {
|
||||
if n > 0 {
|
||||
return "ok"
|
||||
}
|
||||
return "unconfigured"
|
||||
}
|
||||
func bindingStatus(m map[string]settings.ResourceBinding, k string) string {
|
||||
if _, ok := m[k]; ok {
|
||||
return "configured"
|
||||
}
|
||||
return "unconfigured"
|
||||
}
|
||||
func bindingCode(m map[string]settings.ResourceBinding, k string) string {
|
||||
if _, ok := m[k]; ok {
|
||||
return "binding_active"
|
||||
}
|
||||
return "binding_missing"
|
||||
}
|
||||
func ternaryCode(ok bool, success, failure string) string {
|
||||
if ok {
|
||||
return success
|
||||
}
|
||||
return failure
|
||||
}
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
b := make([]byte, 0, 8)
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package adminweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
)
|
||||
|
||||
type controlRepo struct {
|
||||
savedVerified bool
|
||||
savedRef string
|
||||
candidateRef string
|
||||
failedCode string
|
||||
admins []settings.Administrator
|
||||
disabledStaff string
|
||||
disableErr error
|
||||
}
|
||||
|
||||
func (*controlRepo) ActiveDingTalkConfig(context.Context, string) (string, string, bool, error) {
|
||||
return "old", "old-ref", true, nil
|
||||
}
|
||||
func (r *controlRepo) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
|
||||
return "new", r.candidateRef, nil
|
||||
}
|
||||
func (r *controlRepo) SaveDingTalkConfig(_ context.Context, _, _, ref string, verified bool) error {
|
||||
r.savedRef = ref
|
||||
r.savedVerified = verified
|
||||
r.candidateRef = ref
|
||||
return nil
|
||||
}
|
||||
func (r *controlRepo) MarkDingTalkCandidateFailed(_ context.Context, _, _, code string) error {
|
||||
r.failedCode = code
|
||||
return nil
|
||||
}
|
||||
func (r *controlRepo) AdministratorCount(context.Context, string) (int, error) {
|
||||
if r.admins == nil {
|
||||
return 1, nil
|
||||
}
|
||||
n := 0
|
||||
for _, admin := range r.admins {
|
||||
if admin.Status == "active" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
func (r *controlRepo) ListAdministrators(context.Context, string) ([]settings.Administrator, error) {
|
||||
return r.admins, nil
|
||||
}
|
||||
func (r *controlRepo) DisableAdministrator(_ context.Context, _, staffID string) error {
|
||||
r.disabledStaff = staffID
|
||||
return r.disableErr
|
||||
}
|
||||
func (*controlRepo) ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error) {
|
||||
return map[string]settings.ResourceBinding{}, nil
|
||||
}
|
||||
func (*controlRepo) CreateBootstrapChallenge(context.Context, string, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (*controlRepo) ConfirmBootstrap(context.Context, string) error { return nil }
|
||||
|
||||
type verifyOK struct{}
|
||||
|
||||
func (verifyOK) Verify(context.Context, string, string) dingtalkconfig.Result {
|
||||
return dingtalkconfig.Result{OK: true, Code: "oauth_ok"}
|
||||
}
|
||||
|
||||
type activatorStub struct {
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (a *activatorStub) ActivateCandidate(context.Context) error { a.calls++; return a.err }
|
||||
|
||||
type operationalStatusStub struct {
|
||||
components []Component
|
||||
err error
|
||||
}
|
||||
|
||||
func (s operationalStatusStub) Components(context.Context) ([]Component, error) {
|
||||
return s.components, s.err
|
||||
}
|
||||
|
||||
func TestSnapshotIncludesOperationalComponents(t *testing.T) {
|
||||
secrets, err := secretstore.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := NewControlService("tenant", &controlRepo{}, secrets, verifyOK{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checked := time.Unix(100, 0)
|
||||
service.SetOperationalStatusProvider(operationalStatusStub{components: []Component{{
|
||||
Name: "WeKnora", State: "ok", Code: "health_ok", CheckedAt: checked,
|
||||
}}})
|
||||
snapshot, err := service.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, component := range snapshot.Components {
|
||||
if component.Name == "WeKnora" && component.Code == "health_ok" && component.CheckedAt.Equal(checked) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("components=%+v", snapshot.Components)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotKeepsPageAvailableWhenOperationalProbeFails(t *testing.T) {
|
||||
secrets, _ := secretstore.New(t.TempDir())
|
||||
service, err := NewControlService("tenant", &controlRepo{}, secrets, verifyOK{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.SetOperationalStatusProvider(operationalStatusStub{err: errors.New("probe failed")})
|
||||
snapshot, err := service.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
last := snapshot.Components[len(snapshot.Components)-1]
|
||||
if last.Name != "运行探针" || last.State != "error" || last.Code != "component_probe_failed" {
|
||||
t.Fatalf("component=%+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCredentialsCreatesCandidateNotActive(t *testing.T) {
|
||||
secrets, err := secretstore.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &controlRepo{}
|
||||
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = service.SaveCredentials(context.Background(), "new", "secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.savedVerified {
|
||||
t.Fatal("candidate must not be active")
|
||||
}
|
||||
if repo.savedRef == "" || !secrets.Configured(repo.savedRef) {
|
||||
t.Fatalf("ref=%q", repo.savedRef)
|
||||
}
|
||||
}
|
||||
func TestActivateCandidateDelegatesAndMarksFailure(t *testing.T) {
|
||||
secrets, err := secretstore.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &controlRepo{candidateRef: "candidate-ref"}
|
||||
activator := &activatorStub{err: errors.New("candidate_start_failed_rolled_back")}
|
||||
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.SetCandidateActivator(activator)
|
||||
if err = service.ActivateCandidate(context.Background()); err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
if activator.calls != 1 || repo.failedCode != "activation_failed" {
|
||||
t.Fatalf("calls=%d code=%q", activator.calls, repo.failedCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotIncludesAdministratorsAndOnlyAllowsDisableWhenAnotherIsActive(t *testing.T) {
|
||||
secrets, _ := secretstore.New(t.TempDir())
|
||||
repo := &controlRepo{admins: []settings.Administrator{
|
||||
{StaffID: "staff-old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active"},
|
||||
{StaffID: "staff-new", DisplayName: "新管理员", Department: "产品部", Role: "super_admin", Status: "active"},
|
||||
{StaffID: "staff-off", DisplayName: "停用管理员", Role: "admin", Status: "disabled"},
|
||||
}}
|
||||
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := service.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Administrators) != 3 || !snapshot.Administrators[0].CanDisable || !snapshot.Administrators[1].CanDisable || snapshot.Administrators[2].CanDisable {
|
||||
t.Fatalf("administrators=%+v", snapshot.Administrators)
|
||||
}
|
||||
if snapshot.Administrators[0].StaffIDMasked == "staff-old" || snapshot.Administrators[0].StaffIDMasked == "" {
|
||||
t.Fatalf("staff ID must be masked: %+v", snapshot.Administrators[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlServiceDelegatesAdministratorDisable(t *testing.T) {
|
||||
secrets, _ := secretstore.New(t.TempDir())
|
||||
repo := &controlRepo{}
|
||||
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = service.DisableAdministrator(context.Background(), "staff-old"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.disabledStaff != "staff-old" {
|
||||
t.Fatalf("disabled=%q", repo.disabledStaff)
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
package adminweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
SystemState, ClientIDMasked string
|
||||
SecretConfigured bool
|
||||
Components []Component
|
||||
Administrators []AdministratorView
|
||||
}
|
||||
type AdministratorView struct {
|
||||
StaffID, StaffIDMasked, DisplayName, Department, Role, Status string
|
||||
CanDisable bool
|
||||
}
|
||||
type Component struct {
|
||||
Name, State, Code, Detail string
|
||||
LastSuccess, CheckedAt time.Time
|
||||
}
|
||||
type Verification struct {
|
||||
OK bool
|
||||
Code string
|
||||
CheckedAt time.Time
|
||||
}
|
||||
type Service interface {
|
||||
Snapshot(context.Context) (Snapshot, error)
|
||||
SaveCredentials(context.Context, string, string) error
|
||||
VerifyCredentials(context.Context, string, string) Verification
|
||||
}
|
||||
type BootstrapService interface {
|
||||
CreateBootstrap(context.Context, string) error
|
||||
ConfirmBootstrap(context.Context) error
|
||||
}
|
||||
type CandidateService interface {
|
||||
ActivateCandidate(context.Context) error
|
||||
}
|
||||
type AdministratorService interface {
|
||||
DisableAdministrator(context.Context, string) error
|
||||
}
|
||||
type server struct {
|
||||
service Service
|
||||
token string
|
||||
csrfToken string
|
||||
mux *http.ServeMux
|
||||
page *template.Template
|
||||
}
|
||||
type pageData struct {
|
||||
Snapshot
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
const settingsPage = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
<title>DobotAI 设置</title>
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;background:#f5f8ff;color:#17324d;max-width:1080px;margin:36px auto;padding:0 20px;line-height:1.55}
|
||||
.card{background:#fff;border:1px solid #dbe7f4;border-radius:14px;padding:20px;margin:14px 0;box-shadow:0 4px 16px rgba(32,77,120,.05)}
|
||||
input{box-sizing:border-box;padding:10px;width:100%;margin:6px 0 12px;border:1px solid #bfd0e2;border-radius:8px}
|
||||
button{padding:10px 18px;background:#1677ff;color:#fff;border:0;border-radius:8px;margin:5px 0;cursor:pointer}.danger{background:#c73535}.muted{color:#55708a}.warn{background:#fff7e6;border-left:4px solid #f0a020;padding:10px 12px;border-radius:6px}.meta{color:#55708a;font-size:13px;line-height:1.6}
|
||||
table{width:100%;border-collapse:collapse;margin-top:12px}th,td{text-align:left;padding:10px;border-bottom:1px solid #e4edf6;vertical-align:middle}.inline{margin:0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}@media(max-width:760px){.grid{grid-template-columns:1fr}table{font-size:13px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>DobotAI 配置中心</h1>
|
||||
<div class="card"><b>系统状态:</b>{{.SystemState}}<br><b>Client ID:</b>{{.ClientIDMasked}}<br><b>Client Secret:</b>{{if .SecretConfigured}}已配置(不可查看){{else}}未配置{{end}}</div>
|
||||
<div class="card"><h2>配置/切换钉钉应用</h2><p>新凭据先在线验证并保存为候选。激活后受控重启整组钉钉组件,失败自动回滚。</p><form method="post" action="/admin/setup/credentials"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Client ID / AppKey</label><input name="client_id" required><label>Client Secret / AppSecret</label><input type="password" name="client_secret" required autocomplete="new-password"><button>验证并保存候选</button></form><form method="post" action="/admin/setup/candidate/activate"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button>激活候选并重启</button></form></div>
|
||||
<div class="card"><h2>新增/更换管理员</h2><p class="warn"><b>必须先加后停:</b>先确认新管理员已生效,再停用旧管理员。系统禁止停用最后一位有效管理员。</p><ol><li>输入任意6位一次性绑定码并生成,有效期10分钟。</li><li>新管理员私聊机器人发送 <code>/setting 6位码</code>。</li><li>回到本页确认检测到的新管理员。</li><li>在下方确认新管理员状态为active,再停用旧管理员。</li></ol><div class="grid"><form method="post" action="/admin/setup/bootstrap"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>一次性绑定码</label><input name="code" pattern="[0-9]{6}" placeholder="输入任意6位数字" required><button>生成6位绑定码</button></form><form method="post" action="/admin/setup/bootstrap/confirm"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><p class="muted">新管理员私聊验证后再点击:</p><button>确认检测到的新管理员</button></form></div><h3>管理员列表</h3><table><thead><tr><th>姓名</th><th>部门</th><th>Staff ID</th><th>角色</th><th>状态</th><th>操作</th></tr></thead><tbody>{{range .Administrators}}<tr><td>{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td><td>{{if .Department}}{{.Department}}{{else}}—{{end}}</td><td>{{.StaffIDMasked}}</td><td>{{.Role}}</td><td>{{.Status}}</td><td>{{if .CanDisable}}<form class="inline" method="post" action="/admin/settings/administrators/disable"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="staff_id" value="{{.StaffID}}"><button class="danger" type="submit">停用此管理员</button></form>{{else}}—{{end}}</td></tr>{{else}}<tr><td colspan="6" class="muted">暂无管理员记录</td></tr>{{end}}</tbody></table></div>
|
||||
<h2>组件检查</h2>{{range .Components}}<div class="card"><b>{{.Name}}</b>:{{.State}}<div class="meta">错误码:{{if .Code}}{{.Code}}{{else}}—{{end}}<br>最后成功:{{timeText .LastSuccess}}<br>最后检查:{{timeText .CheckedAt}}</div><p>{{.Detail}}</p></div>{{end}}
|
||||
</body></html>`
|
||||
|
||||
func New(service Service, token string) http.Handler {
|
||||
functions := template.FuncMap{"timeText": func(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return value.UTC().Format("2006-01-02 15:04:05")
|
||||
}}
|
||||
csrfBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(csrfBytes); err != nil {
|
||||
panic("admin CSRF token unavailable")
|
||||
}
|
||||
s := &server{service: service, token: strings.TrimSpace(token), csrfToken: hex.EncodeToString(csrfBytes), mux: http.NewServeMux(), page: template.Must(template.New("settings").Funcs(functions).Parse(settingsPage))}
|
||||
s.mux.HandleFunc("GET /admin/settings", s.settings)
|
||||
s.mux.HandleFunc("GET /admin/setup", s.settings)
|
||||
s.mux.HandleFunc("POST /admin/setup/credentials", s.saveCredentials)
|
||||
s.mux.HandleFunc("POST /admin/setup/candidate/activate", s.activateCandidate)
|
||||
s.mux.HandleFunc("POST /admin/setup/bootstrap", s.createBootstrap)
|
||||
s.mux.HandleFunc("POST /admin/setup/bootstrap/confirm", s.confirmBootstrap)
|
||||
s.mux.HandleFunc("POST /admin/settings/administrators/disable", s.disableAdministrator)
|
||||
return s
|
||||
}
|
||||
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if !s.authorized(r) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="DobotAI Admin"`)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
func (s *server) authorized(r *http.Request) bool {
|
||||
if s.token == "" {
|
||||
return false
|
||||
}
|
||||
if _, password, ok := r.BasicAuth(); ok && len(password) == len(s.token) && subtle.ConstantTimeCompare([]byte(password), []byte(s.token)) == 1 {
|
||||
return true
|
||||
}
|
||||
v := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
return len(v) == len(s.token) && subtle.ConstantTimeCompare([]byte(v), []byte(s.token)) == 1
|
||||
}
|
||||
func (s *server) requireCSRFForm(w http.ResponseWriter, r *http.Request, maxBytes int64) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
provided := strings.TrimSpace(r.FormValue("csrf_token"))
|
||||
if len(provided) != len(s.csrfToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.csrfToken)) != 1 {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *server) settings(w http.ResponseWriter, r *http.Request) {
|
||||
snap, err := s.service.Snapshot(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "settings unavailable", 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = s.page.Execute(w, pageData{Snapshot: snap, CSRFToken: s.csrfToken})
|
||||
}
|
||||
func (s *server) saveCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRFForm(w, r, 16<<10) {
|
||||
return
|
||||
}
|
||||
id, secret := strings.TrimSpace(r.FormValue("client_id")), strings.TrimSpace(r.FormValue("client_secret"))
|
||||
result := s.service.VerifyCredentials(r.Context(), id, secret)
|
||||
if !result.OK {
|
||||
http.Error(w, fmt.Sprintf("credential verification failed: %s", result.Code), 422)
|
||||
return
|
||||
}
|
||||
if err := s.service.SaveCredentials(r.Context(), id, secret); err != nil {
|
||||
http.Error(w, "credential save failed", 500)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) activateCandidate(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRFForm(w, r, 4<<10) {
|
||||
return
|
||||
}
|
||||
service, ok := s.service.(CandidateService)
|
||||
if !ok {
|
||||
http.Error(w, "candidate unavailable", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
if err := service.ActivateCandidate(r.Context()); err != nil {
|
||||
http.Error(w, "candidate activation failed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("候选已激活,DobotAI正在受控重启。\n"))
|
||||
}
|
||||
func (s *server) createBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRFForm(w, r, 4<<10) {
|
||||
return
|
||||
}
|
||||
service, ok := s.service.(BootstrapService)
|
||||
if !ok {
|
||||
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(r.FormValue("code"))
|
||||
if !sixDigits(code) {
|
||||
http.Error(w, "binding code must be 6 digits", 400)
|
||||
return
|
||||
}
|
||||
if err := service.CreateBootstrap(r.Context(), code); err != nil {
|
||||
http.Error(w, "create binding code failed", 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
fmt.Fprintf(w, "请在10分钟内私聊机器人发送:/setting %s\n", code)
|
||||
}
|
||||
func (s *server) confirmBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRFForm(w, r, 4<<10) {
|
||||
return
|
||||
}
|
||||
service, ok := s.service.(BootstrapService)
|
||||
if !ok {
|
||||
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
if err := service.ConfirmBootstrap(r.Context()); err != nil {
|
||||
http.Error(w, "尚未检测到可确认的管理员", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) disableAdministrator(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRFForm(w, r, 4<<10) {
|
||||
return
|
||||
}
|
||||
service, ok := s.service.(AdministratorService)
|
||||
if !ok {
|
||||
http.Error(w, "administrator management unavailable", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
staffID := strings.TrimSpace(r.FormValue("staff_id"))
|
||||
if staffID == "" {
|
||||
http.Error(w, "administrator not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
err := service.DisableAdministrator(r.Context(), staffID)
|
||||
switch {
|
||||
case err == nil:
|
||||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||||
case errors.Is(err, settings.ErrLastAdministrator):
|
||||
http.Error(w, "至少保留一位有效管理员;请先确认新管理员已生效。", http.StatusConflict)
|
||||
case errors.Is(err, settings.ErrAdministratorAlreadyDisabled):
|
||||
http.Error(w, "管理员已经停用。", http.StatusConflict)
|
||||
case errors.Is(err, settings.ErrAdministratorNotFound):
|
||||
http.Error(w, "administrator not found", http.StatusNotFound)
|
||||
default:
|
||||
http.Error(w, "administrator update failed", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
func sixDigits(value string) bool {
|
||||
if len(value) != 6 {
|
||||
return false
|
||||
}
|
||||
for _, ch := range value {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package adminweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
)
|
||||
|
||||
type fakeService struct {
|
||||
snapshot Snapshot
|
||||
verified bool
|
||||
saved bool
|
||||
disabled string
|
||||
disableErr error
|
||||
}
|
||||
|
||||
func (f *fakeService) Snapshot(context.Context) (Snapshot, error) { return f.snapshot, nil }
|
||||
func (f *fakeService) SaveCredentials(context.Context, string, string) error {
|
||||
f.saved = true
|
||||
return nil
|
||||
}
|
||||
func (f *fakeService) VerifyCredentials(context.Context, string, string) Verification {
|
||||
f.verified = true
|
||||
return Verification{OK: true, Code: "ok", CheckedAt: time.Unix(1, 0)}
|
||||
}
|
||||
func (f *fakeService) DisableAdministrator(_ context.Context, staffID string) error {
|
||||
f.disabled = staffID
|
||||
return f.disableErr
|
||||
}
|
||||
func csrfTokenFromPage(t *testing.T, h http.Handler) string {
|
||||
t.Helper()
|
||||
get := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
get.Header.Set("Authorization", "Bearer admin-token")
|
||||
page := httptest.NewRecorder()
|
||||
h.ServeHTTP(page, get)
|
||||
marker := `name="csrf_token" value="`
|
||||
start := strings.Index(page.Body.String(), marker)
|
||||
if start < 0 {
|
||||
t.Fatalf("csrf token missing: %s", page.Body.String())
|
||||
}
|
||||
start += len(marker)
|
||||
end := strings.Index(page.Body.String()[start:], `"`)
|
||||
if end < 0 {
|
||||
t.Fatal("csrf token terminator missing")
|
||||
}
|
||||
return page.Body.String()[start : start+end]
|
||||
}
|
||||
func TestDashboardNeverRendersSecrets(t *testing.T) {
|
||||
svc := &fakeService{snapshot: Snapshot{SystemState: "active", ClientIDMasked: "ding***", SecretConfigured: true}}
|
||||
h := New(svc, "admin-token")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "admin-token") || strings.Contains(strings.ToLower(body), "client secret value") {
|
||||
t.Fatal("secret leaked")
|
||||
}
|
||||
if !strings.Contains(body, "ding***") {
|
||||
t.Fatal("masked client id missing")
|
||||
}
|
||||
}
|
||||
func TestDashboardRendersOperationalComponentFields(t *testing.T) {
|
||||
checked := time.Date(2026, 8, 13, 18, 0, 0, 0, time.UTC)
|
||||
svc := &fakeService{snapshot: Snapshot{Components: []Component{{
|
||||
Name: "WeKnora", State: "ok", Code: "health_ok", Detail: "HTTP健康探针正常",
|
||||
LastSuccess: checked.Add(-time.Minute), CheckedAt: checked,
|
||||
}}}}
|
||||
h := New(svc, "admin-token")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"WeKnora", "health_ok", "HTTP健康探针正常", "2026-08-13 17:59:00", "2026-08-13 18:00:00"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("dashboard missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCredentialsRequiresVerification(t *testing.T) {
|
||||
svc := &fakeService{}
|
||||
h := New(svc, "admin-token")
|
||||
csrf := csrfTokenFromPage(t, h)
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/setup/credentials", strings.NewReader("client_id=id&client_secret=secret&csrf_token="+csrf))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusSeeOther || !svc.verified || !svc.saved {
|
||||
t.Fatalf("status=%d verified=%v saved=%v", w.Code, svc.verified, svc.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllAdminPOSTRoutesRejectMissingCSRF(t *testing.T) {
|
||||
svc := &fakeService{}
|
||||
h := New(svc, "admin-token")
|
||||
for _, route := range []string{"/admin/setup/credentials", "/admin/setup/candidate/activate", "/admin/setup/bootstrap", "/admin/setup/bootstrap/confirm", "/admin/settings/administrators/disable"} {
|
||||
req := httptest.NewRequest(http.MethodPost, route, strings.NewReader("client_id=id&client_secret=secret&code=123456&staff_id=staff-old"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("route=%s status=%d body=%q", route, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if svc.saved || svc.disabled != "" {
|
||||
t.Fatalf("business service called saved=%v disabled=%q", svc.saved, svc.disabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardRendersAdministratorReplacementWorkflow(t *testing.T) {
|
||||
svc := &fakeService{snapshot: Snapshot{Administrators: []AdministratorView{{StaffID: "staff-old", StaffIDMasked: "sta***old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active", CanDisable: true}}}}
|
||||
h := New(svc, "admin-token")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
for _, want := range []string{"新增/更换管理员", "先加后停", "旧管理员", "技术部", "sta***old", "生成6位绑定码", "停用此管理员"} {
|
||||
if !strings.Contains(w.Body.String(), want) {
|
||||
t.Fatalf("dashboard missing %q: %s", want, w.Body.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(w.Body.String(), ">staff-old<") {
|
||||
t.Fatalf("raw staff ID must not be rendered as visible text: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableAdministratorRequiresCSRFAndMapsLastAdminConflict(t *testing.T) {
|
||||
svc := &fakeService{}
|
||||
h := New(svc, "admin-token")
|
||||
post := func(body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/settings/administrators/disable", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer admin-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
if w := post("staff_id=staff-old"); w.Code != http.StatusForbidden || svc.disabled != "" {
|
||||
t.Fatalf("missing csrf status=%d disabled=%q", w.Code, svc.disabled)
|
||||
}
|
||||
|
||||
csrf := csrfTokenFromPage(t, h)
|
||||
|
||||
svc.disableErr = settings.ErrLastAdministrator
|
||||
w := post("staff_id=staff-old&csrf_token=" + csrf)
|
||||
if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "至少保留一位有效管理员") {
|
||||
t.Fatalf("last-admin status=%d body=%q", w.Code, w.Body.String())
|
||||
}
|
||||
svc.disableErr = nil
|
||||
w = post("staff_id=staff-old&csrf_token=" + csrf)
|
||||
if w.Code != http.StatusSeeOther || svc.disabled != "staff-old" {
|
||||
t.Fatalf("success status=%d disabled=%q body=%q", w.Code, svc.disabled, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -20,6 +21,24 @@ const (
|
||||
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
|
||||
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
|
||||
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
|
||||
|
||||
// cardWideScreenJSONKey is DingTalk's reserved system field inside
|
||||
// cardData.cardParamMap. Template variables whose value is a complex type
|
||||
// (object / array of objects) must be passed through this field as a JSON
|
||||
// string; plain string/number/boolean variables go in cardParamMap
|
||||
// directly. Putting a complex value anywhere else (for example as a
|
||||
// sibling of cardParamMap under cardData) leaves the template variable
|
||||
// unassigned and silently falls back to the default layout.
|
||||
cardWideScreenJSONKey = "sys_full_json_obj"
|
||||
|
||||
// cardWideScreenJSON configures the AI card's root "config": PC clients
|
||||
// render the card wide (auto layout) when autoLayout is true. The card
|
||||
// template must declare an object variable "config" with a boolean child
|
||||
// "autoLayout" (card builder > 数据源 > 编辑变量) and the value must be
|
||||
// passed when the card is created - setting it statically inside the
|
||||
// template does not take effect. Mobile clients keep their own adaptive
|
||||
// layout, so this only widens the card on desktop.
|
||||
cardWideScreenJSON = `{"config":{"autoLayout":true}}`
|
||||
)
|
||||
|
||||
// CardRequest is the input for sending a final AI card.
|
||||
@@ -206,6 +225,7 @@ func (s *CardSender) UpdateCard(ctx context.Context, outTrackID, content string,
|
||||
if len(content) > 64*1024 {
|
||||
return errors.New("dingtalk card: content exceeds 64 KiB")
|
||||
}
|
||||
log.Print(formatCardUpdateDiagnostic(content, isFinalize, isError))
|
||||
|
||||
token, err := s.accessToken(ctx)
|
||||
if err != nil {
|
||||
@@ -227,6 +247,7 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
|
||||
"feedbackReasonSelected": "",
|
||||
"showFeedbackReasons": "false",
|
||||
"feedbackMessage": "",
|
||||
cardWideScreenJSONKey: cardWideScreenJSON,
|
||||
},
|
||||
},
|
||||
"userIdType": 1,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
)
|
||||
|
||||
const reasoningBannerMarker = "【智能推理模式】"
|
||||
|
||||
type cardContentShape struct {
|
||||
bytes int
|
||||
lines int
|
||||
pipeLines int
|
||||
leadingPipeLines int
|
||||
delimiterLines int
|
||||
confirmedTable bool
|
||||
confirmedTablePos int
|
||||
// Structural split state: where the answer body starts inside this exact
|
||||
// payload. bodyPrefixRunes > 0 means the update still carries narration in
|
||||
// front of the body (the reasoning text the card is supposed to hide).
|
||||
bodyOK bool
|
||||
bodyLine int
|
||||
bodyPrefixRunes int
|
||||
bannerPos int
|
||||
firstHeadingLine int
|
||||
imageRefs int
|
||||
externalImageRefs int
|
||||
mediaImageRefs int
|
||||
}
|
||||
|
||||
func inspectCardContent(content string) cardContentShape {
|
||||
shape := cardContentShape{
|
||||
bytes: len(content),
|
||||
confirmedTablePos: -1,
|
||||
bodyLine: -1,
|
||||
firstHeadingLine: -1,
|
||||
bannerPos: -1,
|
||||
}
|
||||
if content == "" {
|
||||
return shape
|
||||
}
|
||||
shape.lines = strings.Count(content, "\n") + 1
|
||||
shape.imageRefs, shape.externalImageRefs, shape.mediaImageRefs = cardImageRefCounts(content)
|
||||
|
||||
shape.bannerPos = strings.Index(content, reasoningBannerMarker)
|
||||
// The streaming path decorates every frame with the reasoning banner, so the
|
||||
// prefix in front of the body has to be measured after that banner: only
|
||||
// what remains is narration the card is meant to hide.
|
||||
bannerSkip := 0
|
||||
if shape.bannerPos == 0 {
|
||||
if idx := strings.Index(content, "\n"); idx >= 0 {
|
||||
bannerSkip = idx + 1
|
||||
for bannerSkip < len(content) && content[bannerSkip] == '\n' {
|
||||
bannerSkip++
|
||||
}
|
||||
}
|
||||
}
|
||||
if offset, ok := reasoningmode.BodyStartOffset(content); ok {
|
||||
shape.bodyOK = true
|
||||
shape.bodyLine = strings.Count(content[:offset], "\n")
|
||||
prefix := content[:offset]
|
||||
if bannerSkip > 0 && bannerSkip <= len(prefix) {
|
||||
prefix = prefix[bannerSkip:]
|
||||
}
|
||||
shape.bodyPrefixRunes = len([]rune(prefix))
|
||||
}
|
||||
|
||||
lines := splitMarkdownStreamLines(content)
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line.text)
|
||||
if shape.firstHeadingLine < 0 && !line.inFence &&
|
||||
strings.HasPrefix(trimmed, "#") && strings.TrimLeft(trimmed, "#") != "" {
|
||||
shape.firstHeadingLine = i
|
||||
}
|
||||
if strings.Contains(line.text, "|") {
|
||||
shape.pipeLines++
|
||||
}
|
||||
if isTableHeaderCandidate(line.text) {
|
||||
shape.leadingPipeLines++
|
||||
}
|
||||
if !line.inFence && isCompleteTableDelimiter(line.text) {
|
||||
shape.delimiterLines++
|
||||
}
|
||||
if shape.confirmedTable || i == 0 || line.inFence || lines[i-1].inFence {
|
||||
continue
|
||||
}
|
||||
if isTableHeaderCandidate(lines[i-1].text) && isCompleteTableDelimiter(line.text) {
|
||||
shape.confirmedTable = true
|
||||
shape.confirmedTablePos = lines[i-1].start
|
||||
}
|
||||
}
|
||||
return shape
|
||||
}
|
||||
|
||||
func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string {
|
||||
shape := inspectCardContent(content)
|
||||
return fmt.Sprintf(
|
||||
"DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d body_ok=%t body_line=%d body_prefix_runes=%d banner_pos=%d first_heading_line=%d image_refs=%d external_image_refs=%d media_image_refs=%d",
|
||||
isFinalize,
|
||||
isError,
|
||||
shape.bytes,
|
||||
shape.lines,
|
||||
shape.pipeLines,
|
||||
shape.leadingPipeLines,
|
||||
shape.delimiterLines,
|
||||
shape.confirmedTable,
|
||||
shape.confirmedTablePos,
|
||||
shape.bodyOK,
|
||||
shape.bodyLine,
|
||||
shape.bodyPrefixRunes,
|
||||
shape.bannerPos,
|
||||
shape.firstHeadingLine,
|
||||
shape.imageRefs,
|
||||
shape.externalImageRefs,
|
||||
shape.mediaImageRefs,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCardSenderUpdateCardLogsOnlySafeContentShape(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
sender, err := newCardSenderForTest("client-id", "client-secret", "template-id", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
previousWriter := log.Writer()
|
||||
previousFlags := log.Flags()
|
||||
previousPrefix := log.Prefix()
|
||||
log.SetOutput(&output)
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("")
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(previousWriter)
|
||||
log.SetFlags(previousFlags)
|
||||
log.SetPrefix(previousPrefix)
|
||||
})
|
||||
|
||||
content := "DO_NOT_LOG_BOUNDARY_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
|
||||
if err := sender.UpdateCard(context.Background(), "track-diagnostic", content, false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := output.String()
|
||||
if !strings.Contains(got, "DingTalk card update shape:") || !strings.Contains(got, "confirmed_table=true") || !strings.Contains(got, "finalize=false") {
|
||||
t.Fatalf("missing safe boundary diagnostic: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "DO_NOT_LOG_BOUNDARY_TEXT") || strings.Contains(got, "CR5") || strings.Contains(got, "track-diagnostic") {
|
||||
t.Fatalf("boundary diagnostic leaked content or card identifier: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCardUpdateDiagnosticReportsStructureWithoutContent(t *testing.T) {
|
||||
content := "DO_NOT_LOG_ANSWER_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
|
||||
|
||||
got := formatCardUpdateDiagnostic(content, false, false)
|
||||
wantFields := []string{
|
||||
"DingTalk card update shape:",
|
||||
"finalize=false",
|
||||
"error=false",
|
||||
fmt.Sprintf("bytes=%d", len(content)),
|
||||
"lines=5",
|
||||
"pipe_lines=3",
|
||||
"leading_pipe_lines=3",
|
||||
"delimiter_lines=1",
|
||||
"confirmed_table=true",
|
||||
fmt.Sprintf("table_start=%d", strings.Index(content, "| 型号")),
|
||||
}
|
||||
for _, field := range wantFields {
|
||||
if !strings.Contains(got, field) {
|
||||
t.Fatalf("diagnostic %q missing field %q", got, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "DO_NOT_LOG_ANSWER_TEXT") || strings.Contains(got, "CR5") {
|
||||
t.Fatalf("diagnostic leaked answer content: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCardUpdateDiagnosticDistinguishesFinalPlainText(t *testing.T) {
|
||||
got := formatCardUpdateDiagnostic("普通回答", true, false)
|
||||
wantFields := []string{
|
||||
"finalize=true",
|
||||
"error=false",
|
||||
"lines=1",
|
||||
"pipe_lines=0",
|
||||
"leading_pipe_lines=0",
|
||||
"delimiter_lines=0",
|
||||
"confirmed_table=false",
|
||||
"table_start=-1",
|
||||
}
|
||||
for _, field := range wantFields {
|
||||
if !strings.Contains(got, field) {
|
||||
t.Fatalf("diagnostic %q missing field %q", got, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "普通回答") {
|
||||
t.Fatalf("diagnostic leaked answer content: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DingTalk AI cards only render images that live in DingTalk's own media store:
|
||||
// an external HTTPS image URL shows up in a normal markdown message but is
|
||||
// dropped inside a streaming/short-card body (verified on both desktop and
|
||||
// mobile, 2026-09-17). The card content therefore rewrites our own published
|
||||
// image URLs into DingTalk media_id values obtained through the legacy
|
||||
// oapi media upload endpoint, which both clients render.
|
||||
const (
|
||||
defaultCardImageTokenURL = "https://oapi.dingtalk.com/gettoken"
|
||||
defaultCardImageUploadURL = "https://oapi.dingtalk.com/media/upload"
|
||||
defaultCardImageMediaTTL = 12 * time.Hour
|
||||
defaultCardImageWaitTimeout = 5 * time.Second
|
||||
defaultCardImageFetchTimeout = 15 * time.Second
|
||||
defaultCardImageUploadTimeout = 20 * time.Second
|
||||
defaultCardImageMaxBytes = 8 << 20
|
||||
cardImageTokenRefreshMargin = 5 * time.Minute
|
||||
maxConcurrentCardImageUploads = 3
|
||||
)
|
||||
|
||||
// cardImageStableIDRE matches the deterministic image id produced by
|
||||
// outboundimage.StableHashPublisher (UUIDv5 shaped, lowercase hex).
|
||||
var cardImageStableIDRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
|
||||
|
||||
// cardMarkdownImageRE captures the URL of a complete markdown image, including
|
||||
// the optional title slot. Incomplete image syntax (still streaming) is not
|
||||
// matched, so it is never rewritten.
|
||||
var cardMarkdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\(([^\s)\r\n]+)(?:\s+(?:"[^"\r\n]*"|'[^'\r\n]*'|\([^)\r\n]*\)))?\)`)
|
||||
|
||||
// CardImageMediaRewriter converts card-body image references into DingTalk
|
||||
// media ids. Implementations must be safe for concurrent use and must never
|
||||
// fail a card update: on any error the original reference is kept.
|
||||
type CardImageMediaRewriter interface {
|
||||
RewriteForCard(ctx context.Context, content string, wait bool) string
|
||||
}
|
||||
|
||||
// CardImageMediaOptions configures the resolver. ClientID/ClientSecret are the
|
||||
// same Robot app credentials used for access tokens, and ImageURLPrefix is the
|
||||
// public prefix of our own published images.
|
||||
type CardImageMediaOptions struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
ImageURLPrefix string
|
||||
// FetchURLPrefix optionally replaces ImageURLPrefix when downloading the
|
||||
// bytes back (for example a loopback address), so the upload never depends
|
||||
// on the container being able to reach its own public host name.
|
||||
FetchURLPrefix string
|
||||
TokenURL string
|
||||
UploadURL string
|
||||
HTTPClient *http.Client
|
||||
TTL time.Duration
|
||||
WaitTimeout time.Duration
|
||||
MaxBytes int64
|
||||
}
|
||||
|
||||
type cardImageMediaEntry struct {
|
||||
mediaID string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type cardImageMediaJob struct {
|
||||
done chan struct{}
|
||||
mediaID string
|
||||
err error
|
||||
}
|
||||
|
||||
type cardImageMediaResolver struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
imageURLPrefix string
|
||||
fetchURLPrefix string
|
||||
tokenURL string
|
||||
uploadURL string
|
||||
client *http.Client
|
||||
ttl time.Duration
|
||||
waitTimeout time.Duration
|
||||
maxBytes int64
|
||||
sem chan struct{}
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
entries map[string]cardImageMediaEntry
|
||||
inflight map[string]*cardImageMediaJob
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
}
|
||||
|
||||
// NewCardImageMediaRewriter builds the resolver. It returns a nil rewriter and
|
||||
// no error when the feature is not configured, which keeps the caller simple.
|
||||
func NewCardImageMediaRewriter(opts CardImageMediaOptions) (CardImageMediaRewriter, error) {
|
||||
clientID := strings.TrimSpace(opts.ClientID)
|
||||
clientSecret := strings.TrimSpace(opts.ClientSecret)
|
||||
prefix := strings.TrimSpace(opts.ImageURLPrefix)
|
||||
if clientID == "" || clientSecret == "" || prefix == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
tokenURL := strings.TrimSpace(opts.TokenURL)
|
||||
if tokenURL == "" {
|
||||
tokenURL = defaultCardImageTokenURL
|
||||
}
|
||||
uploadURL := strings.TrimSpace(opts.UploadURL)
|
||||
if uploadURL == "" {
|
||||
uploadURL = defaultCardImageUploadURL
|
||||
}
|
||||
ttl := opts.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = defaultCardImageMediaTTL
|
||||
}
|
||||
waitTimeout := opts.WaitTimeout
|
||||
if waitTimeout <= 0 {
|
||||
waitTimeout = defaultCardImageWaitTimeout
|
||||
}
|
||||
maxBytes := opts.MaxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defaultCardImageMaxBytes
|
||||
}
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
client = &http.Client{Timeout: defaultCardImageFetchTimeout, Transport: transport}
|
||||
}
|
||||
return &cardImageMediaResolver{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
imageURLPrefix: prefix,
|
||||
fetchURLPrefix: strings.TrimSpace(opts.FetchURLPrefix),
|
||||
tokenURL: tokenURL,
|
||||
uploadURL: uploadURL,
|
||||
client: client,
|
||||
ttl: ttl,
|
||||
waitTimeout: waitTimeout,
|
||||
maxBytes: maxBytes,
|
||||
sem: make(chan struct{}, maxConcurrentCardImageUploads),
|
||||
now: time.Now,
|
||||
entries: make(map[string]cardImageMediaEntry),
|
||||
inflight: make(map[string]*cardImageMediaJob),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RewriteForCard replaces complete markdown image references that point at our
|
||||
// own published images with a DingTalk media id. With wait=false the rewrite
|
||||
// only uses media ids that are already known and kicks off the upload in the
|
||||
// background, so streaming frames never block on DingTalk; with wait=true it
|
||||
// waits (bounded) for in-flight uploads, which is what the final card update
|
||||
// uses.
|
||||
func (r *cardImageMediaResolver) RewriteForCard(ctx context.Context, content string, wait bool) string {
|
||||
if r == nil || strings.TrimSpace(content) == "" {
|
||||
return content
|
||||
}
|
||||
var waitCtx context.Context
|
||||
if wait {
|
||||
bound := r.waitTimeout
|
||||
if bound <= 0 {
|
||||
bound = defaultCardImageWaitTimeout
|
||||
}
|
||||
var cancel context.CancelFunc
|
||||
waitCtx, cancel = context.WithTimeout(context.WithoutCancel(ctx), bound)
|
||||
defer cancel()
|
||||
}
|
||||
rewritten := cardMarkdownImageRE.ReplaceAllStringFunc(content, func(image string) string {
|
||||
match := cardMarkdownImageRE.FindStringSubmatch(image)
|
||||
if len(match) < 2 {
|
||||
return image
|
||||
}
|
||||
rawURL := strings.TrimSpace(match[1])
|
||||
if rawURL == "" || !r.isOwnImageURL(rawURL) {
|
||||
return image
|
||||
}
|
||||
if mediaID, ok := r.lookup(rawURL); ok {
|
||||
return replaceCardImageURL(image, match[1], mediaID)
|
||||
}
|
||||
job := r.startUpload(rawURL)
|
||||
if !wait {
|
||||
return image
|
||||
}
|
||||
select {
|
||||
case <-job.done:
|
||||
case <-waitCtx.Done():
|
||||
}
|
||||
if job.mediaID != "" {
|
||||
return replaceCardImageURL(image, match[1], job.mediaID)
|
||||
}
|
||||
if job.err != nil {
|
||||
log.Printf("DingTalk card image media upload failed, keeping external reference")
|
||||
}
|
||||
return image
|
||||
})
|
||||
return rewritten
|
||||
}
|
||||
|
||||
func replaceCardImageURL(image, rawURL, mediaID string) string {
|
||||
if mediaID == "" || rawURL == "" {
|
||||
return image
|
||||
}
|
||||
return strings.Replace(image, rawURL, mediaID, 1)
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) isOwnImageURL(rawURL string) bool {
|
||||
if r.imageURLPrefix == "" || !strings.HasPrefix(rawURL, r.imageURLPrefix) {
|
||||
return false
|
||||
}
|
||||
id := strings.TrimPrefix(rawURL, r.imageURLPrefix)
|
||||
if id == "" || strings.ContainsAny(id, "/?#") {
|
||||
return false
|
||||
}
|
||||
return cardImageStableIDRE.MatchString(id)
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) lookup(rawURL string) (string, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
entry, ok := r.entries[rawURL]
|
||||
if !ok || r.now().After(entry.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
return entry.mediaID, true
|
||||
}
|
||||
|
||||
// startUpload returns a job for the image, reusing an in-flight upload or a
|
||||
// cached media id when available. The upload itself runs on a background
|
||||
// context so a streaming frame's cancellation cannot abort it half way.
|
||||
func (r *cardImageMediaResolver) startUpload(rawURL string) *cardImageMediaJob {
|
||||
r.mu.Lock()
|
||||
if entry, ok := r.entries[rawURL]; ok && r.now().Before(entry.expiresAt) {
|
||||
job := &cardImageMediaJob{done: make(chan struct{}), mediaID: entry.mediaID}
|
||||
close(job.done)
|
||||
r.mu.Unlock()
|
||||
return job
|
||||
}
|
||||
if job, ok := r.inflight[rawURL]; ok {
|
||||
r.mu.Unlock()
|
||||
return job
|
||||
}
|
||||
job := &cardImageMediaJob{done: make(chan struct{})}
|
||||
r.inflight[rawURL] = job
|
||||
r.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
uploadCtx, cancel := context.WithTimeout(context.Background(), defaultCardImageUploadTimeout)
|
||||
defer cancel()
|
||||
mediaID, err := r.upload(uploadCtx, rawURL)
|
||||
job.mediaID = mediaID
|
||||
job.err = err
|
||||
r.mu.Lock()
|
||||
delete(r.inflight, rawURL)
|
||||
if err == nil && mediaID != "" {
|
||||
r.entries[rawURL] = cardImageMediaEntry{mediaID: mediaID, expiresAt: r.now().Add(r.ttl)}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
close(job.done)
|
||||
}()
|
||||
return job
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) upload(ctx context.Context, rawURL string) (string, error) {
|
||||
select {
|
||||
case r.sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
defer func() { <-r.sem }()
|
||||
|
||||
data, contentType, err := r.fetch(ctx, rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token, err := r.accessToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.uploadMedia(ctx, token, data, contentType)
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) fetchURL(rawURL string) string {
|
||||
targets := r.fetchURLs(rawURL)
|
||||
if len(targets) == 0 {
|
||||
return rawURL
|
||||
}
|
||||
return targets[0]
|
||||
}
|
||||
|
||||
// fetchURLs lists the download candidates for one of our published images: the
|
||||
// loopback prefix first (cheap, stays inside the host) and the public URL as a
|
||||
// fallback so a missing local route never turns into a missing card image.
|
||||
func (r *cardImageMediaResolver) fetchURLs(rawURL string) []string {
|
||||
relative := ""
|
||||
if strings.HasPrefix(rawURL, r.imageURLPrefix) {
|
||||
relative = strings.TrimPrefix(rawURL, r.imageURLPrefix)
|
||||
}
|
||||
targets := make([]string, 0, 2)
|
||||
if r.fetchURLPrefix != "" && relative != "" {
|
||||
targets = append(targets, strings.TrimRight(r.fetchURLPrefix, "/")+"/"+relative)
|
||||
}
|
||||
if relative == "" || r.fetchURLPrefix == "" {
|
||||
targets = append(targets, rawURL)
|
||||
} else {
|
||||
targets = append(targets, strings.TrimRight(r.imageURLPrefix, "/")+"/"+relative)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) fetch(ctx context.Context, rawURL string) ([]byte, string, error) {
|
||||
var firstErr error
|
||||
for _, target := range r.fetchURLs(rawURL) {
|
||||
data, contentType, err := r.fetchOnce(ctx, target)
|
||||
if err == nil {
|
||||
return data, contentType, nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = errors.New("card image fetch: no candidate URL")
|
||||
}
|
||||
return nil, "", firstErr
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) fetchOnce(ctx context.Context, target string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("card image fetch: unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
contentType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
|
||||
if !strings.HasPrefix(strings.ToLower(contentType), "image/") {
|
||||
return nil, "", fmt.Errorf("card image fetch: unexpected content type")
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, r.maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if int64(len(data)) > r.maxBytes {
|
||||
return nil, "", errors.New("card image fetch: image exceeds upload size limit")
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, "", errors.New("card image fetch: empty image")
|
||||
}
|
||||
return data, contentType, nil
|
||||
}
|
||||
|
||||
// cardImageRefCounts splits the markdown image references of a card body into
|
||||
// externally hosted URLs and DingTalk media ids, so operators can tell from the
|
||||
// logs whether a card body can actually render its images.
|
||||
func cardImageRefCounts(content string) (total, external, media int) {
|
||||
for _, match := range cardMarkdownImageRE.FindAllStringSubmatch(content, -1) {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if strings.Contains(strings.TrimSpace(match[1]), "://") {
|
||||
external++
|
||||
} else {
|
||||
media++
|
||||
}
|
||||
}
|
||||
return total, external, media
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) accessToken(ctx context.Context) (string, error) {
|
||||
r.mu.Lock()
|
||||
if r.token != "" && r.now().Before(r.tokenExpiry) {
|
||||
token := r.token
|
||||
r.mu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
values := map[string]string{"appkey": r.clientID, "appsecret": r.clientSecret}
|
||||
payload, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.tokenURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parsed struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", fmt.Errorf("card image token: invalid response")
|
||||
}
|
||||
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.AccessToken) == "" {
|
||||
return "", fmt.Errorf("card image token: rejected with errcode %d", parsed.ErrCode)
|
||||
}
|
||||
expiresIn := parsed.ExpiresIn
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = 7200
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.token = parsed.AccessToken
|
||||
r.tokenExpiry = r.now().Add(time.Duration(expiresIn)*time.Second - cardImageTokenRefreshMargin)
|
||||
r.mu.Unlock()
|
||||
return parsed.AccessToken, nil
|
||||
}
|
||||
|
||||
func (r *cardImageMediaResolver) uploadMedia(ctx context.Context, token string, data []byte, contentType string) (string, error) {
|
||||
var buffer bytes.Buffer
|
||||
writer := multipart.NewWriter(&buffer)
|
||||
header := make(textproto.MIMEHeader)
|
||||
header.Set("Content-Disposition", `form-data; name="media"; filename="card-image"`)
|
||||
if contentType == "" {
|
||||
contentType = "image/png"
|
||||
}
|
||||
header.Set("Content-Type", contentType)
|
||||
part, err := writer.CreatePart(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
uploadURL := r.uploadURL + "?access_token=" + token + "&type=image"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, &buffer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parsed struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
MediaID string `json:"media_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", errors.New("card image upload: invalid response")
|
||||
}
|
||||
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.MediaID) == "" {
|
||||
return "", fmt.Errorf("card image upload: rejected with errcode %d", parsed.ErrCode)
|
||||
}
|
||||
return parsed.MediaID, nil
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
const cardImageTestID = "5f0a1c2e-3b4d-5e6f-8a9b-0c1d2e3f4a5b"
|
||||
const cardImageTestMediaID = "@lADP-test-media-id"
|
||||
|
||||
type cardImageTestServer struct {
|
||||
server *httptest.Server
|
||||
imageBytes []byte
|
||||
oversizeBytes []byte
|
||||
uploadErrcode int
|
||||
|
||||
tokenCalls int
|
||||
uploadCalls int
|
||||
uploadedBytes int
|
||||
fetched []string
|
||||
|
||||
mu sync.Mutex
|
||||
uploadedC chan struct{}
|
||||
}
|
||||
|
||||
func newCardImageTestServer(t *testing.T) *cardImageTestServer {
|
||||
t.Helper()
|
||||
fixture := &cardImageTestServer{
|
||||
imageBytes: []byte("\x89PNG\r\n\x1a\nfixture-bytes"),
|
||||
oversizeBytes: make([]byte, 512),
|
||||
uploadedC: make(chan struct{}, 4),
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
serveImage := func(w http.ResponseWriter, r *http.Request) {
|
||||
fixture.mu.Lock()
|
||||
fixture.fetched = append(fixture.fetched, r.URL.Path)
|
||||
fixture.mu.Unlock()
|
||||
body := fixture.imageBytes
|
||||
if strings.HasSuffix(r.URL.Path, "big") {
|
||||
body = fixture.oversizeBytes
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
mux.HandleFunc("/dingtalk/i/", serveImage)
|
||||
mux.HandleFunc("/images/", serveImage)
|
||||
mux.HandleFunc("/gettoken", func(w http.ResponseWriter, r *http.Request) {
|
||||
fixture.mu.Lock()
|
||||
fixture.tokenCalls++
|
||||
fixture.mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"errcode": 0, "errmsg": "ok", "access_token": "test-token", "expires_in": 7200,
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/media/upload", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("access_token") != "test-token" || r.URL.Query().Get("type") != "image" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"errcode": 40001, "errmsg": "bad token"})
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("media")
|
||||
if err == nil {
|
||||
data, _ := io.ReadAll(file)
|
||||
_ = file.Close()
|
||||
fixture.mu.Lock()
|
||||
fixture.uploadedBytes += len(data)
|
||||
fixture.mu.Unlock()
|
||||
}
|
||||
fixture.mu.Lock()
|
||||
fixture.uploadCalls++
|
||||
code := fixture.uploadErrcode
|
||||
fixture.mu.Unlock()
|
||||
select {
|
||||
case fixture.uploadedC <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"errcode": code, "errmsg": "ok", "media_id": cardImageTestMediaID,
|
||||
})
|
||||
})
|
||||
fixture.server = httptest.NewServer(mux)
|
||||
t.Cleanup(fixture.server.Close)
|
||||
return fixture
|
||||
}
|
||||
|
||||
func (f *cardImageTestServer) resolver(t *testing.T, mutate func(*CardImageMediaOptions)) CardImageMediaRewriter {
|
||||
t.Helper()
|
||||
opts := CardImageMediaOptions{
|
||||
ClientID: "robot-client-id",
|
||||
ClientSecret: "robot-client-secret",
|
||||
ImageURLPrefix: f.server.URL + "/dingtalk/i/",
|
||||
TokenURL: f.server.URL + "/gettoken",
|
||||
UploadURL: f.server.URL + "/media/upload",
|
||||
HTTPClient: f.server.Client(),
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(&opts)
|
||||
}
|
||||
rewriter, err := NewCardImageMediaRewriter(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCardImageMediaRewriter: %v", err)
|
||||
}
|
||||
if rewriter == nil {
|
||||
t.Fatal("expected a configured rewriter")
|
||||
}
|
||||
return rewriter
|
||||
}
|
||||
|
||||
func (f *cardImageTestServer) counts() (token, upload int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.tokenCalls, f.uploadCalls
|
||||
}
|
||||
|
||||
func (f *cardImageTestServer) uploadedByteCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.uploadedBytes
|
||||
}
|
||||
|
||||
func (f *cardImageTestServer) imageURL(id string) string {
|
||||
return f.server.URL + "/dingtalk/i/" + id
|
||||
}
|
||||
|
||||
func (f *cardImageTestServer) waitForUpload(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-f.uploadedC:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for the media upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardImageMediaRewritesOwnImageAndCachesMediaID(t *testing.T) {
|
||||
fixture := newCardImageTestServer(t)
|
||||
rewriter := fixture.resolver(t, nil)
|
||||
content := fmt.Sprintf("看图:", fixture.imageURL(cardImageTestID))
|
||||
|
||||
got := rewriter.RewriteForCard(context.Background(), content, true)
|
||||
want := fmt.Sprintf("看图:", cardImageTestMediaID)
|
||||
if got != want {
|
||||
t.Fatalf("got=%q want=%q", got, want)
|
||||
}
|
||||
if _, uploads := fixture.counts(); uploads != 1 {
|
||||
t.Fatalf("uploads=%d want 1", uploads)
|
||||
}
|
||||
if got := fixture.uploadedByteCount(); got != len(fixture.imageBytes) {
|
||||
t.Fatalf("uploaded bytes=%d want %d", got, len(fixture.imageBytes))
|
||||
}
|
||||
|
||||
// A second rewrite must reuse the cached media id without another upload,
|
||||
// and the token must only be fetched once.
|
||||
again := rewriter.RewriteForCard(context.Background(), content, true)
|
||||
if again != want {
|
||||
t.Fatalf("cached rewrite got=%q want=%q", again, want)
|
||||
}
|
||||
tokens, uploads := fixture.counts()
|
||||
if uploads != 1 || tokens != 1 {
|
||||
t.Fatalf("tokens=%d uploads=%d want 1/1", tokens, uploads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardImageMediaPartialFramesNeverBlockAndPickUpLater(t *testing.T) {
|
||||
fixture := newCardImageTestServer(t)
|
||||
rewriter := fixture.resolver(t, nil)
|
||||
content := fmt.Sprintf("", fixture.imageURL(cardImageTestID))
|
||||
|
||||
// A streaming frame keeps the external URL but starts the upload.
|
||||
if got := rewriter.RewriteForCard(context.Background(), content, false); got != content {
|
||||
t.Fatalf("streaming frame must stay unchanged, got=%q", got)
|
||||
}
|
||||
fixture.waitForUpload(t)
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
got := rewriter.RewriteForCard(context.Background(), content, false)
|
||||
if strings.Contains(got, cardImageTestMediaID) {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("media id never became available, got=%q", got)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if _, uploads := fixture.counts(); uploads != 1 {
|
||||
t.Fatalf("uploads=%d want 1", uploads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardImageMediaIgnoresForeignAndIncompleteReferences(t *testing.T) {
|
||||
fixture := newCardImageTestServer(t)
|
||||
rewriter := fixture.resolver(t, nil)
|
||||
content := strings.Join([]string{
|
||||
fmt.Sprintf("", cardImageTestID),
|
||||
fmt.Sprintf("", fixture.imageURL("not-a-stable-id")),
|
||||
fmt.Sprintf("),
|
||||
fmt.Sprintf("纯链接 %s", fixture.imageURL(cardImageTestID)),
|
||||
}, "\n")
|
||||
|
||||
if got := rewriter.RewriteForCard(context.Background(), content, true); got != content {
|
||||
t.Fatalf("content must be untouched, got=%q", got)
|
||||
}
|
||||
tokens, uploads := fixture.counts()
|
||||
if tokens != 0 || uploads != 0 {
|
||||
t.Fatalf("tokens=%d uploads=%d want 0/0", tokens, uploads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardImageMediaFallsBackWhenUploadFails(t *testing.T) {
|
||||
fixture := newCardImageTestServer(t)
|
||||
fixture.uploadErrcode = 40035
|
||||
rewriter := fixture.resolver(t, nil)
|
||||
content := fmt.Sprintf("", fixture.imageURL(cardImageTestID))
|
||||
|
||||
if got := rewriter.RewriteForCard(context.Background(), content, true); got != content {
|
||||
t.Fatalf("failed upload must keep the external reference, got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardImageMediaFallsBackForOversizeImages(t *testing.T) {
|
||||
fixture := newCardImageTestServer(t)
|
||||
rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) {
|
||||
opts.MaxBytes = 128
|
||||
})
|
||||
content := fmt.Sprintf("", fixture.imageURL(cardImageTestID))
|
||||
|
||||
if got := rewriter.RewriteForCard(context.Background(), content, true); got != content {
|
||||
t.Fatalf("oversize image must keep the external reference, got=%q", got)
|
||||
}
|
||||
if _, uploads := fixture.counts(); uploads != 0 {
|
||||
t.Fatalf("uploads=%d want 0", uploads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardImageMediaFetchUsesLoopbackPrefix(t *testing.T) {
|
||||
fixture := newCardImageTestServer(t)
|
||||
rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) {
|
||||
opts.ImageURLPrefix = "https://weknora-api.invalid/dingtalk/i/"
|
||||
opts.FetchURLPrefix = fixture.server.URL + "/images/"
|
||||
})
|
||||
content := fmt.Sprintf("", cardImageTestID)
|
||||
|
||||
got := rewriter.RewriteForCard(context.Background(), content, true)
|
||||
if !strings.Contains(got, cardImageTestMediaID) {
|
||||
t.Fatalf("loopback fetch must still produce a media id, got=%q", got)
|
||||
}
|
||||
fixture.mu.Lock()
|
||||
defer fixture.mu.Unlock()
|
||||
if len(fixture.fetched) != 1 || !strings.HasPrefix(fixture.fetched[0], "/images/") {
|
||||
t.Fatalf("fetched=%v want one loopback path", fixture.fetched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardImageMediaDeduplicatesConcurrentUploads(t *testing.T) {
|
||||
fixture := newCardImageTestServer(t)
|
||||
rewriter := fixture.resolver(t, nil)
|
||||
content := fmt.Sprintf("", fixture.imageURL(cardImageTestID))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
rewriter.RewriteForCard(context.Background(), content, true)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if _, uploads := fixture.counts(); uploads != 1 {
|
||||
t.Fatalf("uploads=%d want 1 for concurrent callers", uploads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardImageMediaRewriterRequiresFullConfiguration(t *testing.T) {
|
||||
for name, opts := range map[string]CardImageMediaOptions{
|
||||
"missing client id": {ClientSecret: "s", ImageURLPrefix: "https://img.example/dingtalk/i/"},
|
||||
"missing secret": {ClientID: "id", ImageURLPrefix: "https://img.example/dingtalk/i/"},
|
||||
"missing prefix": {ClientID: "id", ClientSecret: "s"},
|
||||
} {
|
||||
rewriter, err := NewCardImageMediaRewriter(opts)
|
||||
if err != nil || rewriter != nil {
|
||||
t.Fatalf("%s: rewriter=%v err=%v want nil/nil", name, rewriter, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingCardDeliverySendsMediaIDAndKeepsMarkdownExternal(t *testing.T) {
|
||||
fixture := newCardImageTestServer(t)
|
||||
rewriter := fixture.resolver(t, nil)
|
||||
answer := fmt.Sprintf("CR20V 接线如下:\n\n", fixture.imageURL(cardImageTestID))
|
||||
|
||||
card := &fakeStreamingCard{startID: "track-image-card"}
|
||||
qa := &fakeStreamingQA{chunks: []string{answer}, result: core.QAResult{Answer: answer}}
|
||||
reply := &fakeReply{}
|
||||
h := newTestHandlerWithStreaming(t, qa, reply, card)
|
||||
h.cardImageMedia = rewriter
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if len(card.updates) == 0 {
|
||||
t.Fatal("no card update was recorded")
|
||||
}
|
||||
final := card.updates[len(card.updates)-1]
|
||||
if !final.isFinalize {
|
||||
t.Fatalf("last update must finalize the card, got %+v", final)
|
||||
}
|
||||
if !strings.Contains(final.content, cardImageTestMediaID) {
|
||||
t.Fatalf("final card body must carry the media id, got=%q", final.content)
|
||||
}
|
||||
if strings.Contains(final.content, fixture.server.URL) {
|
||||
t.Fatalf("final card body must not keep the external URL, got=%q", final.content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFinalizeFallbackKeepsExternalImageURL(t *testing.T) {
|
||||
fixture := newCardImageTestServer(t)
|
||||
rewriter := fixture.resolver(t, nil)
|
||||
answer := fmt.Sprintf("", fixture.imageURL(cardImageTestID))
|
||||
|
||||
card := &fakeStreamingCard{startID: "track-image-fallback", updateErr: errors.New("card unavailable")}
|
||||
qa := &fakeStreamingQA{chunks: []string{answer}, result: core.QAResult{Answer: answer}}
|
||||
reply := &fakeReply{}
|
||||
h := newTestHandlerWithStreaming(t, qa, reply, card)
|
||||
h.cardImageMedia = rewriter
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if !reply.called {
|
||||
t.Fatal("markdown fallback was not sent")
|
||||
}
|
||||
if !strings.Contains(reply.lastText, fixture.server.URL) {
|
||||
t.Fatalf("markdown fallback must keep the external URL, got=%q", reply.lastText)
|
||||
}
|
||||
if strings.Contains(reply.lastText, cardImageTestMediaID) {
|
||||
t.Fatalf("markdown fallback must not carry a media id, got=%q", reply.lastText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardImageMediaFallsBackToPublicURLWhenLoopbackMisses(t *testing.T) {
|
||||
fixture := newCardImageTestServer(t)
|
||||
rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) {
|
||||
// The loopback route exists but does not serve this object.
|
||||
opts.FetchURLPrefix = fixture.server.URL + "/missing/"
|
||||
})
|
||||
content := fmt.Sprintf("", fixture.imageURL(cardImageTestID))
|
||||
|
||||
got := rewriter.RewriteForCard(context.Background(), content, true)
|
||||
if !strings.Contains(got, cardImageTestMediaID) {
|
||||
t.Fatalf("public fallback must still produce a media id, got=%q", got)
|
||||
}
|
||||
fixture.mu.Lock()
|
||||
defer fixture.mu.Unlock()
|
||||
if len(fixture.fetched) != 1 || !strings.HasPrefix(fixture.fetched[0], "/dingtalk/i/") {
|
||||
t.Fatalf("fetched=%v want a single public fetch after the loopback miss", fixture.fetched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardImageRefCountsSplitMediaAndExternalReferences(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
fmt.Sprintf("", cardImageTestMediaID),
|
||||
fmt.Sprintf("", cardImageTestID),
|
||||
fmt.Sprintf("", cardImageTestMediaID),
|
||||
}, "\n")
|
||||
total, external, media := cardImageRefCounts(content)
|
||||
if total != 3 || external != 1 || media != 2 {
|
||||
t.Fatalf("total=%d external=%d media=%d want 3/1/2", total, external, media)
|
||||
}
|
||||
if total, external, media := cardImageRefCounts("无图回答"); total != 0 || external != 0 || media != 0 {
|
||||
t.Fatalf("plain text must report no image refs, got %d/%d/%d", total, external, media)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardUpdateDiagnosticReportsImageReferenceShape(t *testing.T) {
|
||||
content := fmt.Sprintf("正文\n\n", cardImageTestMediaID)
|
||||
got := formatCardUpdateDiagnostic(content, true, false)
|
||||
if !strings.Contains(got, "image_refs=1") || !strings.Contains(got, "media_image_refs=1") || !strings.Contains(got, "external_image_refs=0") {
|
||||
t.Fatalf("diagnostic must report image reference shape: %q", got)
|
||||
}
|
||||
if strings.Contains(got, cardImageTestMediaID) {
|
||||
t.Fatalf("diagnostic must not leak the media id: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func (p *cardUpdatePump) run(ctx context.Context, ticks <-chan time.Time, stopTi
|
||||
if stopTicks != nil {
|
||||
defer stopTicks()
|
||||
}
|
||||
lastSuccessful := ""
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -52,9 +53,14 @@ func (p *cardUpdatePump) run(ctx context.Context, ticks <-chan time.Time, stopTi
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if latest == lastSuccessful {
|
||||
continue
|
||||
}
|
||||
if err := update(ctx, latest); err != nil {
|
||||
log.Printf("DingTalk async card update failed, continuing")
|
||||
continue
|
||||
}
|
||||
lastSuccessful = latest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1289,6 +1289,117 @@ func TestOAuthAcrossStartAndUpdates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: PC wide-screen layout config is delivered through the reserved
|
||||
// cardParamMap system field, never as a sibling of cardParamMap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCardWideScreenAutoLayoutParam(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
req CardRequest
|
||||
}{
|
||||
{
|
||||
name: "direct",
|
||||
req: CardRequest{
|
||||
ConversationType: core.ConversationDirect,
|
||||
ExternalUserID: "user-456",
|
||||
Content: "Hello from AI",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "group",
|
||||
req: CardRequest{
|
||||
ConversationType: core.ConversationGroup,
|
||||
ExternalChatID: "chat-789",
|
||||
ExternalUserID: "user-456",
|
||||
Content: "Hello from AI",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
sender, err := newCardSenderForTest("id", "secret", "tpl", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := sender.StartCard(context.Background(), tc.req); err != nil {
|
||||
t.Fatalf("StartCard: %v", err)
|
||||
}
|
||||
|
||||
cardData, ok := ts.lastCreateBody["cardData"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("cardData missing or wrong type: %#v", ts.lastCreateBody["cardData"])
|
||||
}
|
||||
// Regression guard: a complex-typed value placed next to
|
||||
// cardParamMap leaves the template variable unassigned and the
|
||||
// card silently renders in the default narrow layout.
|
||||
for _, badKey := range []string{"config", cardWideScreenJSONKey} {
|
||||
if _, present := cardData[badKey]; present {
|
||||
t.Fatalf("%q must not be a sibling of cardParamMap: %#v", badKey, cardData)
|
||||
}
|
||||
}
|
||||
paramMap, ok := cardData["cardParamMap"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("cardParamMap missing or wrong type: %#v", cardData["cardParamMap"])
|
||||
}
|
||||
raw, ok := paramMap[cardWideScreenJSONKey].(string)
|
||||
if !ok {
|
||||
t.Fatalf("%s must be a JSON string, got %#v", cardWideScreenJSONKey, paramMap[cardWideScreenJSONKey])
|
||||
}
|
||||
var decoded struct {
|
||||
Config struct {
|
||||
AutoLayout bool `json:"autoLayout"`
|
||||
} `json:"config"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
||||
t.Fatalf("%s is not valid JSON (%q): %v", cardWideScreenJSONKey, raw, err)
|
||||
}
|
||||
if !decoded.Config.AutoLayout {
|
||||
t.Fatalf("config.autoLayout=false in %q, want true", raw)
|
||||
}
|
||||
// Plain string variables must keep going through cardParamMap directly.
|
||||
if paramMap["content"] != tc.req.Content {
|
||||
t.Errorf("content=%v, want %v", paramMap["content"], tc.req.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: streaming updates only carry content (no layout config)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCardWideScreenNotSentOnStreamingUpdate(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
sender, err := newCardSenderForTest("id", "secret", "tpl", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outTrackID, err := sender.StartCard(context.Background(), CardRequest{
|
||||
ConversationType: core.ConversationDirect,
|
||||
ExternalUserID: "user-456",
|
||||
Content: "placeholder",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartCard: %v", err)
|
||||
}
|
||||
if err := sender.UpdateCard(context.Background(), outTrackID, "streamed chunk", false, false); err != nil {
|
||||
t.Fatalf("UpdateCard: %v", err)
|
||||
}
|
||||
if _, present := ts.lastStreamBody[cardWideScreenJSONKey]; present {
|
||||
t.Fatalf("streaming body must not carry %s: %#v", cardWideScreenJSONKey, ts.lastStreamBody)
|
||||
}
|
||||
if ts.lastStreamBody["key"] != "content" {
|
||||
t.Errorf("stream key=%v, want content", ts.lastStreamBody["key"])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const displayReferenceMarkerPattern = `\[\[ref[\t ]+id[\t ]*=[\t ]*["']c[0-9]+["'][\t ]*\]\]`
|
||||
|
||||
var (
|
||||
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
|
||||
// bareInternalSourceHandleRE matches WeKnora request-local source aliases
|
||||
// leaked by a model (for example [d21], [c12], or [w3]). It intentionally
|
||||
// does not match ordinary Markdown links or arbitrary bracketed content.
|
||||
bareInternalSourceHandleRE = regexp.MustCompile(`(?i)\[(?:[dcw][0-9]+)(?:[ ]*[,,][ ]*(?:[dcw][0-9]+))*\]`)
|
||||
leadingReferenceGroupColonRE = regexp.MustCompile(`(?m)^[ ]*(?:` + displayReferenceMarkerPattern + `[ ]*)+[::][ ]*`)
|
||||
spacedReferenceGroupRE = regexp.MustCompile(`[ ]+(?:` + displayReferenceMarkerPattern + `[ ]*)+`)
|
||||
spaceBeforeDisplayPunctuation = regexp.MustCompile(`[ ]+([,。;:!?、,.;:!?])`)
|
||||
spaceAfterDisplayColon = regexp.MustCompile(`([::])[\t ]+`)
|
||||
)
|
||||
|
||||
func sanitizeDisplayAnswer(content string) string {
|
||||
if index := strings.LastIndex(content, "[[ref"); index >= 0 && !strings.Contains(content[index:], "]]") {
|
||||
content = content[:index]
|
||||
}
|
||||
if !displayReferenceMarkerRE.MatchString(content) && !bareInternalSourceHandleRE.MatchString(content) {
|
||||
return content
|
||||
}
|
||||
content = leadingReferenceGroupColonRE.ReplaceAllString(content, "")
|
||||
content = spacedReferenceGroupRE.ReplaceAllString(content, " ")
|
||||
content = displayReferenceMarkerRE.ReplaceAllString(content, "")
|
||||
content = bareInternalSourceHandleRE.ReplaceAllString(content, "")
|
||||
content = spaceBeforeDisplayPunctuation.ReplaceAllString(content, "$1")
|
||||
content = spaceAfterDisplayColon.ReplaceAllString(content, "$1")
|
||||
return content
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package dingtalk
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeDisplayAnswerRemovesInternalReferenceMarkers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "adjacent inline markers",
|
||||
input: "结论支持该功能[[ref id=\"c2\"]][[ref id=\"c4\"]]。",
|
||||
want: "结论支持该功能。",
|
||||
},
|
||||
{
|
||||
name: "leading marker group and chinese colon",
|
||||
input: "[[ref id=\"c2\"]][[ref id=\"c4\"]]:建议先检查配置。",
|
||||
want: "建议先检查配置。",
|
||||
},
|
||||
{
|
||||
name: "leading marker group and ascii colon on second line",
|
||||
input: "第一段。\n[[ref id='c12']] : Second paragraph.",
|
||||
want: "第一段。\nSecond paragraph.",
|
||||
},
|
||||
{
|
||||
name: "normal punctuation remains natural",
|
||||
input: "标题:[[ref id=\"c1\"]] 详细说明",
|
||||
want: "标题:详细说明",
|
||||
},
|
||||
{
|
||||
name: "spaces around marker collapse to one",
|
||||
input: "Feature A [[ref id=\"c3\"]] remains supported.",
|
||||
want: "Feature A remains supported.",
|
||||
},
|
||||
{
|
||||
name: "unrelated double bracket syntax and ordinary link are preserved",
|
||||
input: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
|
||||
want: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
|
||||
},
|
||||
{
|
||||
name: "bare internal source handles",
|
||||
input: "Nova支持虚拟控制器离线仿真[d21],并可搭配VX500[w3]。",
|
||||
want: "Nova支持虚拟控制器离线仿真,并可搭配VX500。",
|
||||
},
|
||||
{
|
||||
name: "grouped bare internal source handles",
|
||||
input: "该结论由多个资料支持 [d21, c12,w3]。",
|
||||
want: "该结论由多个资料支持。",
|
||||
},
|
||||
{
|
||||
name: "content without references remains byte for byte unchanged",
|
||||
input: "Protocol: value ; keep spacing\n indented",
|
||||
want: "Protocol: value ; keep spacing\n indented",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sanitizeDisplayAnswer(tt.input); got != tt.want {
|
||||
t.Fatalf("sanitizeDisplayAnswer()=%q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeDisplayAnswerHoldsBackDanglingStreamingReference(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"答案正文[[ref",
|
||||
"答案正文[[ref id=\"c",
|
||||
"答案正文[[ref id=\"c2\"",
|
||||
} {
|
||||
if got := sanitizeDisplayAnswer(input); got != "答案正文" {
|
||||
t.Fatalf("sanitizeDisplayAnswer(%q)=%q", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEffectiveDownloadCodesDeduplicatesAndPreservesOrder(t *testing.T) {
|
||||
cb := BotCallback{
|
||||
DownloadCode: "code-first",
|
||||
DownloadCodes: []string{"code-first", "code-second", " code-third "},
|
||||
}
|
||||
codes := effectiveDownloadCodes(cb)
|
||||
if len(codes) != 3 {
|
||||
t.Fatalf("codes=%v, want 3", codes)
|
||||
}
|
||||
if codes[0] != "code-first" || codes[1] != "code-second" || codes[2] != "code-third" {
|
||||
t.Fatalf("codes=%v, want ordered de-dup", codes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveDownloadCodesSingleOnly(t *testing.T) {
|
||||
cb := BotCallback{DownloadCode: " solo "}
|
||||
codes := effectiveDownloadCodes(cb)
|
||||
if len(codes) != 1 || codes[0] != "solo" {
|
||||
t.Fatalf("codes=%v, want [solo]", codes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveDownloadCodesEmpty(t *testing.T) {
|
||||
if codes := effectiveDownloadCodes(BotCallback{}); len(codes) != 0 {
|
||||
t.Fatalf("codes=%v, want empty", codes)
|
||||
}
|
||||
}
|
||||
@@ -237,7 +237,11 @@ func (s *SupportGroupSender) SendTest(ctx context.Context) error {
|
||||
|
||||
// renderMarkdown produces the compact customer-question context shown in the action card.
|
||||
func (s *SupportGroupSender) renderMarkdown(notif coverage.SupportNotification) (title, text string) {
|
||||
title = "知识库未覆盖提醒"
|
||||
label := "知识库未覆盖提醒"
|
||||
if notif.TriggerType == coverage.SupportTriggerFeedbackDown {
|
||||
label = "回答被点踩提醒"
|
||||
}
|
||||
title = label
|
||||
question := escapeMarkdown(truncateRunes(notif.Question, questionRuneLimit))
|
||||
sender := escapeMarkdown(notif.SenderName)
|
||||
askedAt := "未知"
|
||||
@@ -246,7 +250,7 @@ func (s *SupportGroupSender) renderMarkdown(notif coverage.SupportNotification)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("## 【知识库未覆盖提醒】\n\n")
|
||||
b.WriteString("## 【" + label + "】\n\n")
|
||||
b.WriteString(fmt.Sprintf("**提问人:** %s\n\n", sender))
|
||||
b.WriteString(fmt.Sprintf("**时间:** %s\n\n", askedAt))
|
||||
b.WriteString(fmt.Sprintf("**当前问题:**\n> %s\n\n", question))
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
|
||||
)
|
||||
|
||||
func TestSupportGroupSenderRendersFeedbackDownTrigger(t *testing.T) {
|
||||
notification := newTestNotification()
|
||||
notification.TriggerType = coverage.SupportTriggerFeedbackDown
|
||||
title, text := (&SupportGroupSender{}).renderMarkdown(notification)
|
||||
if title != "回答被点踩提醒" || !strings.Contains(text, "【回答被点踩提醒】") {
|
||||
t.Fatalf("title=%q text=%q", title, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportGroupSenderDefaultsHistoricalTriggerToCoverage(t *testing.T) {
|
||||
title, text := (&SupportGroupSender{}).renderMarkdown(newTestNotification())
|
||||
if title != "知识库未覆盖提醒" || !strings.Contains(text, "【知识库未覆盖提醒】") {
|
||||
t.Fatalf("title=%q text=%q", title, text)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundtext"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
||||
)
|
||||
|
||||
@@ -45,6 +48,7 @@ type BotCallback struct {
|
||||
SessionWebhook string
|
||||
Text string
|
||||
DownloadCode string
|
||||
DownloadCodes []string
|
||||
}
|
||||
|
||||
type QAAsker interface {
|
||||
@@ -75,6 +79,12 @@ type ImageDownloader interface {
|
||||
Download(ctx context.Context, downloadCode string) (core.ImageInput, error)
|
||||
}
|
||||
|
||||
// ImagePublisher publishes a downloaded image to object storage and returns a
|
||||
// stable HTTPS URL. Implementation satisfies outboundimage.Publisher.
|
||||
type ImagePublisher interface {
|
||||
Publish(ctx context.Context, data []byte, contentType string) (string, error)
|
||||
}
|
||||
|
||||
type UserDirectory interface {
|
||||
ResolveUser(ctx context.Context, tenantKey, staffID, fallbackName string) (directory.Profile, error)
|
||||
}
|
||||
@@ -83,6 +93,10 @@ type OutboundContentRewriter interface {
|
||||
Rewrite(ctx context.Context, content string) string
|
||||
}
|
||||
|
||||
type OutboundPartialSanitizer interface {
|
||||
SanitizePartial(content string) string
|
||||
}
|
||||
|
||||
type SettingsMessage struct {
|
||||
TenantKey string
|
||||
Channel string
|
||||
@@ -100,6 +114,27 @@ type SettingsRouter interface {
|
||||
Handle(context.Context, SettingsMessage) (handled bool, reply string, err error)
|
||||
}
|
||||
|
||||
type ReasoningMessage struct {
|
||||
TenantKey string
|
||||
Channel string
|
||||
StaffID string
|
||||
Text string
|
||||
ConversationID string
|
||||
}
|
||||
|
||||
type ReasoningDecision struct {
|
||||
Handled bool
|
||||
Reply string
|
||||
Query string
|
||||
AgentID string
|
||||
Execution reasoningmode.ExecutionMode
|
||||
Expired bool
|
||||
}
|
||||
|
||||
type ReasoningRouter interface {
|
||||
Prepare(context.Context, ReasoningMessage) (ReasoningDecision, error)
|
||||
}
|
||||
|
||||
type LanguagePreferenceResolver interface {
|
||||
GetPreference(context.Context, string, string, string) (string, error)
|
||||
}
|
||||
@@ -119,11 +154,26 @@ type StreamingCardSender interface {
|
||||
|
||||
type HandlerOptions struct {
|
||||
ImageDownloader ImageDownloader
|
||||
ImagePublisher ImagePublisher
|
||||
CardSender FinalCardSender
|
||||
Directory UserDirectory
|
||||
OutboundRewriter OutboundContentRewriter
|
||||
// CardImageMedia rewrites our own published image URLs in card bodies into
|
||||
// DingTalk media ids; nil keeps external URLs as-is.
|
||||
CardImageMedia CardImageMediaRewriter
|
||||
SettingsRouter SettingsRouter
|
||||
ReasoningRouter ReasoningRouter
|
||||
LanguageResolver LanguagePreferenceResolver
|
||||
// OutboundRewriteTimeout bounds the outbound image rewrite stage (fetch +
|
||||
// publish). Answers carrying many large screenshots need more than the
|
||||
// delivery timeout, otherwise the remaining images are dropped. Zero keeps
|
||||
// finalDeliveryTimeout.
|
||||
OutboundRewriteTimeout time.Duration
|
||||
// WideTableGrouping selects how over-wide answer tables are adapted, per
|
||||
// frame: "" or "on" groups on every frame (historical), "off" never groups,
|
||||
// "stream" groups only while the answer is still streaming and keeps the
|
||||
// real table on the final frame. See tableGroupingForMode.
|
||||
WideTableGrouping string
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
@@ -131,14 +181,26 @@ type Handler struct {
|
||||
qa QAAsker
|
||||
reply MarkdownSender
|
||||
downloader ImageDownloader
|
||||
imagePublisher ImagePublisher
|
||||
card FinalCardSender
|
||||
directory UserDirectory
|
||||
outboundRewriter OutboundContentRewriter
|
||||
cardImageMedia CardImageMediaRewriter
|
||||
settingsRouter SettingsRouter
|
||||
reasoningRouter ReasoningRouter
|
||||
languageResolver LanguagePreferenceResolver
|
||||
now func() time.Time
|
||||
cardUpdateInterval time.Duration
|
||||
finalDeliveryTimeout time.Duration
|
||||
// outboundRewriteTimeout applies to the image rewrite stage only; zero
|
||||
// falls back to finalDeliveryTimeout.
|
||||
outboundRewriteTimeout time.Duration
|
||||
// groupTablesPartial / groupTablesFinal decide per frame whether an
|
||||
// over-wide table becomes a grouped list. Grouped lists survive the
|
||||
// streaming frames where a table is still incomplete; the final frame can
|
||||
// keep the real table, which the PC wide-screen card lays out itself.
|
||||
groupTablesPartial bool
|
||||
groupTablesFinal bool
|
||||
}
|
||||
|
||||
// NewHandler accepts at most one image downloader. A nil/omitted downloader
|
||||
@@ -170,19 +232,28 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
|
||||
if reply == nil {
|
||||
return nil, errors.New("dingtalk handler: markdown sender is required")
|
||||
}
|
||||
groupPartial, groupFinal := tableGroupingForMode(opts.WideTableGrouping)
|
||||
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
qa: qa,
|
||||
reply: reply,
|
||||
downloader: opts.ImageDownloader,
|
||||
imagePublisher: opts.ImagePublisher,
|
||||
card: opts.CardSender,
|
||||
directory: opts.Directory,
|
||||
outboundRewriter: opts.OutboundRewriter,
|
||||
cardImageMedia: opts.CardImageMedia,
|
||||
settingsRouter: opts.SettingsRouter,
|
||||
reasoningRouter: opts.ReasoningRouter,
|
||||
languageResolver: opts.LanguageResolver,
|
||||
now: time.Now,
|
||||
cardUpdateInterval: 500 * time.Millisecond,
|
||||
finalDeliveryTimeout: defaultHTTPTimeout,
|
||||
|
||||
outboundRewriteTimeout: opts.OutboundRewriteTimeout,
|
||||
groupTablesPartial: groupPartial,
|
||||
groupTablesFinal: groupFinal,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -287,7 +358,8 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
|
||||
return errors.New("dingtalk handler: text message content is empty")
|
||||
}
|
||||
case msgTypePicture:
|
||||
if strings.TrimSpace(cb.DownloadCode) == "" {
|
||||
codes := effectiveDownloadCodes(cb)
|
||||
if len(codes) == 0 {
|
||||
log.Printf("DingTalk image preparation failed: stage=missing_download_code")
|
||||
return h.sendReply(ctx, webhookURL, imageErrorReply)
|
||||
}
|
||||
@@ -295,20 +367,49 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
|
||||
log.Printf("DingTalk image preparation failed: stage=downloader_unavailable")
|
||||
return h.sendReply(ctx, webhookURL, imageErrorReply)
|
||||
}
|
||||
image, err := h.downloader.Download(ctx, strings.TrimSpace(cb.DownloadCode))
|
||||
if err != nil {
|
||||
log.Printf("DingTalk image preparation failed: stage=%s", imageFailureStage(err))
|
||||
return h.sendReply(ctx, webhookURL, imageErrorReply)
|
||||
images := make([]core.ImageInput, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
image, err := h.downloader.Download(ctx, code)
|
||||
if err != nil {
|
||||
log.Printf("DingTalk image preparation failed: stage=%s", imageFailureStage(err))
|
||||
return h.sendReply(ctx, webhookURL, imageErrorReply)
|
||||
}
|
||||
if h.imagePublisher != nil {
|
||||
if url := h.publishQuestionImage(ctx, image); url != "" {
|
||||
image.PublishedURL = url
|
||||
}
|
||||
}
|
||||
images = append(images, image)
|
||||
}
|
||||
req.Query = strings.TrimSpace(cb.Text)
|
||||
if req.Query == "" {
|
||||
req.Query = imageQuery
|
||||
}
|
||||
req.Images = []core.ImageInput{image}
|
||||
req.Images = images
|
||||
default:
|
||||
return h.sendReply(ctx, webhookURL, unsupportedMessageReply)
|
||||
}
|
||||
|
||||
if h.reasoningRouter != nil {
|
||||
decision, err := h.reasoningRouter.Prepare(ctx, ReasoningMessage{
|
||||
TenantKey: h.cfg.TenantKey, Channel: h.cfg.Channel, StaffID: externalUserID,
|
||||
Text: req.Query, ConversationID: req.ExternalChatID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dingtalk reasoning mode: %w", err)
|
||||
}
|
||||
if decision.Handled {
|
||||
if strings.TrimSpace(decision.Reply) == "" {
|
||||
return nil
|
||||
}
|
||||
return h.sendReply(ctx, webhookURL, decision.Reply)
|
||||
}
|
||||
req.Query = strings.TrimSpace(decision.Query)
|
||||
req.AgentIDOverride = strings.TrimSpace(decision.AgentID)
|
||||
req.ReasoningExecution = string(decision.Execution)
|
||||
req.ReasoningExpired = decision.Expired
|
||||
}
|
||||
|
||||
if eventCard, eventQA := h.eventStreamingCapable(); eventCard != nil && eventQA != nil {
|
||||
return h.handleEventStreaming(ctx, webhookURL, req, eventCard, eventQA)
|
||||
}
|
||||
@@ -370,7 +471,7 @@ func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req
|
||||
ConversationType: req.ConversationType,
|
||||
ExternalUserID: req.ExternalUserID,
|
||||
ExternalChatID: req.ExternalChatID,
|
||||
Content: content,
|
||||
Content: h.rewriteCardImages(ctx, content, true),
|
||||
}
|
||||
if outTrackID, cardErr := h.card.SendFinalCard(ctx, cardReq); cardErr == nil {
|
||||
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodCard, outTrackID, "")
|
||||
@@ -419,19 +520,29 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
|
||||
|
||||
// Streaming observer with the configured update throttle (500ms by default).
|
||||
var lastUpdate time.Time
|
||||
var lastPartialContent string
|
||||
feed := reasoningmode.NewPartialFeed(req.ReasoningExecution)
|
||||
observer := func(fullContent string) {
|
||||
if strings.TrimSpace(fullContent) == "" {
|
||||
visible := feed.OnAnswer(fullContent)
|
||||
if strings.TrimSpace(visible) == "" {
|
||||
return
|
||||
}
|
||||
fullContent = decorateReasoningPartial(visible, req.ReasoningExecution)
|
||||
now := h.now()
|
||||
if lastUpdate.IsZero() || now.Sub(lastUpdate) >= h.cardUpdateInterval {
|
||||
partialContent := h.sanitizeOutboundPartial(ctx, fullContent)
|
||||
if strings.TrimSpace(partialContent) == "" || partialContent == lastPartialContent {
|
||||
return
|
||||
}
|
||||
// Advance the throttle window before I/O so a failing DingTalk API
|
||||
// cannot be hammered once per model chunk. The final update below is
|
||||
// always attempted regardless of this timestamp.
|
||||
lastUpdate = now
|
||||
if updateErr := streamingCard.UpdateCard(ctx, outTrackID, fullContent, false, false); updateErr != nil {
|
||||
if updateErr := streamingCard.UpdateCard(ctx, outTrackID, partialContent, false, false); updateErr != nil {
|
||||
log.Printf("DingTalk streaming card update failed, continuing")
|
||||
return
|
||||
}
|
||||
lastPartialContent = partialContent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,14 +584,26 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
|
||||
pump := newCardUpdatePump(ctx, h.cardUpdateInterval, func(updateCtx context.Context, content string) error {
|
||||
return streamingCard.UpdateCard(updateCtx, outTrackID, content, false, false)
|
||||
})
|
||||
answerStarted := false
|
||||
feed := reasoningmode.NewPartialFeed(req.ReasoningExecution)
|
||||
result, err := eventQA.AskEvents(ctx, req, func(event weknora.StreamEvent) {
|
||||
if event.ResponseType == "answer" && strings.TrimSpace(event.FullAnswer) != "" {
|
||||
answerStarted = true
|
||||
pump.Offer(event.FullAnswer)
|
||||
if event.ResponseType == "answer" {
|
||||
visible := feed.OnAnswer(event.FullAnswer)
|
||||
if strings.TrimSpace(visible) == "" {
|
||||
return
|
||||
}
|
||||
visibleAnswer := decorateReasoningPartial(visible, req.ReasoningExecution)
|
||||
if partialContent := h.sanitizeOutboundPartial(ctx, visibleAnswer); strings.TrimSpace(partialContent) != "" {
|
||||
pump.Offer(partialContent)
|
||||
}
|
||||
return
|
||||
}
|
||||
if answerStarted {
|
||||
switch event.ResponseType {
|
||||
case "tool_call", "tool_result", "thinking", "reflection":
|
||||
// A reasoning step ended: the next narrative segment starts a new
|
||||
// line so the card shows the reasoning process line by line.
|
||||
feed.StepBoundary()
|
||||
}
|
||||
if feed.Started() {
|
||||
return
|
||||
}
|
||||
if content := renderProgressEvent(event); content != "" {
|
||||
@@ -498,6 +621,16 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
|
||||
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result, result.Answer, false)
|
||||
}
|
||||
|
||||
func decorateReasoningPartial(content, execution string) string {
|
||||
switch reasoningmode.ExecutionMode(strings.TrimSpace(execution)) {
|
||||
case reasoningmode.ExecutionReasoning, reasoningmode.ExecutionReasoningSingle:
|
||||
if !strings.HasPrefix(content, "【智能推理模式】") {
|
||||
return "【智能推理模式】\n\n" + content
|
||||
}
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string, streamingCard StreamingCardSender, outTrackID string, result core.QAResult, content string, isError bool) error {
|
||||
timeout := h.finalDeliveryTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -511,7 +644,13 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
|
||||
// Final card convergence must still be attempted when the QA context was
|
||||
// canceled or timed out; otherwise the card can remain "processing".
|
||||
finalCtx, cancelFinal := context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||
updateErr := streamingCard.UpdateCard(finalCtx, outTrackID, content, true, isError)
|
||||
cardContent := content
|
||||
if !isError {
|
||||
// Card bodies render DingTalk media ids only, so the images are
|
||||
// converted here while the markdown fallback keeps the external URLs.
|
||||
cardContent = h.rewriteCardImages(finalCtx, content, true)
|
||||
}
|
||||
updateErr := streamingCard.UpdateCard(finalCtx, outTrackID, cardContent, true, isError)
|
||||
cancelFinal()
|
||||
if updateErr == nil {
|
||||
if !isError {
|
||||
@@ -537,16 +676,46 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
|
||||
}
|
||||
|
||||
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
|
||||
content = outboundtext.NormalizeMath(content)
|
||||
options := tableNormalizeOptions{groupWideTables: h.groupTablesFinal}
|
||||
if h.outboundRewriter == nil {
|
||||
return content
|
||||
return normalizeMarkdownTables(sanitizeDisplayAnswer(content), options)
|
||||
}
|
||||
timeout := h.outboundRewriteTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = h.finalDeliveryTimeout
|
||||
}
|
||||
timeout := h.finalDeliveryTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultHTTPTimeout
|
||||
}
|
||||
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||
defer cancel()
|
||||
return h.outboundRewriter.Rewrite(rewriteCtx, content)
|
||||
return normalizeMarkdownTables(sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content)), options)
|
||||
}
|
||||
|
||||
func (h *Handler) sanitizeOutboundPartial(ctx context.Context, content string) string {
|
||||
content = outboundtext.NormalizeMath(content)
|
||||
sanitizer, ok := h.outboundRewriter.(OutboundPartialSanitizer)
|
||||
if ok {
|
||||
content = sanitizer.SanitizePartial(content)
|
||||
}
|
||||
options := tableNormalizeOptions{groupWideTables: h.groupTablesPartial}
|
||||
content = normalizeMarkdownTables(sanitizeDisplayAnswer(content), options)
|
||||
return h.rewriteCardImages(ctx, stableMarkdownPrefix(content), false)
|
||||
}
|
||||
|
||||
// rewriteCardImages converts image references in card content into DingTalk
|
||||
// media ids. It is a no-op when the feature is not configured and never fails
|
||||
// the caller: unuploadable images keep their original external reference.
|
||||
func (h *Handler) rewriteCardImages(ctx context.Context, content string, wait bool) string {
|
||||
if h.cardImageMedia == nil || strings.TrimSpace(content) == "" {
|
||||
return content
|
||||
}
|
||||
rewritten := h.cardImageMedia.RewriteForCard(ctx, content, wait)
|
||||
if strings.TrimSpace(rewritten) == "" {
|
||||
return content
|
||||
}
|
||||
return rewritten
|
||||
}
|
||||
|
||||
func (h *Handler) recordDelivery(ctx context.Context, result core.QAResult, status, method, providerMessageID, failureCode string) {
|
||||
@@ -671,3 +840,57 @@ func (h *Handler) sendReply(ctx context.Context, webhookURL, text string) error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishQuestionImage best-effort publishes a downloaded image's bytes to
|
||||
// object storage for traceability. It returns the stable HTTPS URL, or an
|
||||
// empty string when publishing is unavailable or fails, so that an image
|
||||
// question is never blocked by storage.
|
||||
func (h *Handler) publishQuestionImage(ctx context.Context, image core.ImageInput) string {
|
||||
if h.imagePublisher == nil {
|
||||
return ""
|
||||
}
|
||||
data, err := decodeImageDataURI(image.DataURI)
|
||||
if err != nil || len(data) == 0 {
|
||||
log.Printf("DingTalk question image publish failed: stage=decode")
|
||||
return ""
|
||||
}
|
||||
url, err := h.imagePublisher.Publish(ctx, data, image.MIMEType)
|
||||
if err != nil || strings.TrimSpace(url) == "" {
|
||||
log.Printf("DingTalk question image publish failed: stage=publish")
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(url)
|
||||
}
|
||||
|
||||
// decodeImageDataURI strips the "data:<mime>;base64," prefix and decodes the
|
||||
// payload into raw bytes. It is used only to publish inbound images and never
|
||||
// writes the data URI to storage.
|
||||
func decodeImageDataURI(dataURI string) ([]byte, error) {
|
||||
const prefix = "base64,"
|
||||
index := strings.Index(dataURI, prefix)
|
||||
if index < 0 {
|
||||
return nil, errors.New("dingtalk image: data URI missing base64 payload")
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(dataURI[index+len(prefix):])
|
||||
}
|
||||
|
||||
// effectiveDownloadCodes returns the ordered, de-duplicated list of image
|
||||
// download codes for a picture callback. DownloadCode equals the first code
|
||||
// and DownloadCodes holds every code (richText with several pictures), so the
|
||||
// single value is used to canonicalize the order without duplication.
|
||||
func effectiveDownloadCodes(cb BotCallback) []string {
|
||||
var codes []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, code := range append([]string{cb.DownloadCode}, cb.DownloadCodes...) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[code]; ok {
|
||||
continue
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package dingtalk
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -981,9 +982,10 @@ type cardUpdate struct {
|
||||
}
|
||||
|
||||
type fakeOutboundRewriter struct {
|
||||
calls int
|
||||
from string
|
||||
to string
|
||||
calls int
|
||||
partialCalls int
|
||||
from string
|
||||
to string
|
||||
}
|
||||
|
||||
func (f *fakeOutboundRewriter) Rewrite(_ context.Context, content string) string {
|
||||
@@ -991,6 +993,11 @@ func (f *fakeOutboundRewriter) Rewrite(_ context.Context, content string) string
|
||||
return strings.ReplaceAll(content, f.from, f.to)
|
||||
}
|
||||
|
||||
func (f *fakeOutboundRewriter) SanitizePartial(content string) string {
|
||||
f.partialCalls++
|
||||
return strings.ReplaceAll(content, f.from, "")
|
||||
}
|
||||
|
||||
func (f *fakeStreamingCard) SendFinalCard(_ context.Context, req CardRequest) (string, error) {
|
||||
f.finalCalled = true
|
||||
f.finalReq = req
|
||||
@@ -1668,15 +1675,16 @@ func TestRenderProgressEventUsesOnlySafeFixedText(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleEventStreamingFinalizesLatestAnswerWithoutMarkdown(t *testing.T) {
|
||||
rawAnswer := "完整答案[[ref id=\"c8\"]]"
|
||||
card := &fakeStreamingCard{startID: "track-events"}
|
||||
qa := &fakeEventQA{
|
||||
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: "完整答案"}},
|
||||
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: rawAnswer}},
|
||||
events: []weknora.StreamEvent{
|
||||
{ResponseType: "tool_call", ToolName: "query_understand"},
|
||||
{ResponseType: "tool_call", ToolName: "knowledge_search"},
|
||||
{ResponseType: "references", ReferenceCount: 3},
|
||||
{ResponseType: "answer", FullAnswer: "部分"},
|
||||
{ResponseType: "answer", FullAnswer: "完整答案", Done: true},
|
||||
{ResponseType: "answer", FullAnswer: "部分[[ref"},
|
||||
{ResponseType: "answer", FullAnswer: rawAnswer, Done: true},
|
||||
},
|
||||
}
|
||||
reply := &fakeReply{}
|
||||
@@ -1698,6 +1706,9 @@ func TestHandleEventStreamingFinalizesLatestAnswerWithoutMarkdown(t *testing.T)
|
||||
if last.content != "完整答案" || !last.isFinalize || last.isError {
|
||||
t.Fatalf("final update=%+v", last)
|
||||
}
|
||||
if qa.result.Answer != rawAnswer {
|
||||
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
|
||||
}
|
||||
if reply.called {
|
||||
t.Fatal("SendMarkdown must not be called")
|
||||
}
|
||||
@@ -2080,8 +2091,8 @@ func TestHandleStreamingRewritesOnlyFinalOutboundImageContent(t *testing.T) {
|
||||
if len(card.updates) < 2 {
|
||||
t.Fatalf("updates=%d", len(card.updates))
|
||||
}
|
||||
if !strings.Contains(card.updates[0].content, resource) {
|
||||
t.Fatalf("streaming intermediate content should remain untouched: %q", card.updates[0].content)
|
||||
if strings.Contains(card.updates[0].content, resource) {
|
||||
t.Fatalf("streaming intermediate content leaked internal resource: %q", card.updates[0].content)
|
||||
}
|
||||
final := card.updates[len(card.updates)-1]
|
||||
if !final.isFinalize || strings.Contains(final.content, resource) || !strings.Contains(final.content, "https://img.example/signed.jpg") {
|
||||
@@ -2090,4 +2101,126 @@ func TestHandleStreamingRewritesOnlyFinalOutboundImageContent(t *testing.T) {
|
||||
if rewriter.calls != 1 {
|
||||
t.Fatalf("rewrite calls=%d, want 1", rewriter.calls)
|
||||
}
|
||||
if rewriter.partialCalls == 0 {
|
||||
t.Fatal("streaming intermediate content was not sanitized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFiltersReferenceMarkersAtDingTalkDisplayBoundary(t *testing.T) {
|
||||
rawAnswer := "结论[[ref id=\"c2\"]][[ref id=\"c4\"]]。"
|
||||
wantDisplay := "结论。"
|
||||
|
||||
t.Run("final card", func(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: rawAnswer}}
|
||||
reply := &fakeReply{}
|
||||
card := &fakeCard{returnID: "track-reference-filter"}
|
||||
h := newTestHandlerWithCard(t, qa, reply, card)
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if card.lastReq.Content != wantDisplay {
|
||||
t.Fatalf("card content=%q, want %q", card.lastReq.Content, wantDisplay)
|
||||
}
|
||||
if qa.result.Answer != rawAnswer {
|
||||
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("markdown fallback", func(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: rawAnswer}}
|
||||
reply := &fakeReply{}
|
||||
card := &fakeCard{err: errors.New("card unavailable")}
|
||||
h := newTestHandlerWithCard(t, qa, reply, card)
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply.lastText != wantDisplay {
|
||||
t.Fatalf("markdown content=%q, want %q", reply.lastText, wantDisplay)
|
||||
}
|
||||
if qa.result.Answer != rawAnswer {
|
||||
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleStreamingHoldsBackPartialReferenceMarkers(t *testing.T) {
|
||||
rawAnswer := "答案正文[[ref id=\"c2\"]]继续。"
|
||||
qa := &fakeStreamingQA{
|
||||
chunks: []string{"答案正文[[ref", " id=\"c2\"]]继续。"},
|
||||
result: core.QAResult{Answer: rawAnswer},
|
||||
}
|
||||
reply := &fakeReply{}
|
||||
card := &fakeStreamingCard{startID: "track-stream-reference-filter"}
|
||||
h := newTestHandlerWithStreaming(t, qa, reply, card)
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(card.updates) < 2 {
|
||||
t.Fatalf("updates=%d, want at least one partial plus final", len(card.updates))
|
||||
}
|
||||
for i, update := range card.updates {
|
||||
if strings.Contains(update.content, "[[ref") {
|
||||
t.Fatalf("update[%d] leaked reference marker: %q", i, update.content)
|
||||
}
|
||||
}
|
||||
if card.updates[0].content != "答案正文" {
|
||||
t.Fatalf("first partial=%q, want dangling marker held back", card.updates[0].content)
|
||||
}
|
||||
final := card.updates[len(card.updates)-1]
|
||||
if !final.isFinalize || final.content != "答案正文继续。" {
|
||||
t.Fatalf("final content=%q finalize=%t", final.content, final.isFinalize)
|
||||
}
|
||||
if qa.result.Answer != rawAnswer {
|
||||
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStreamingPublishesTableOnlyOnFinalUpdate(t *testing.T) {
|
||||
answer := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n结论"
|
||||
qa := &fakeStreamingQA{
|
||||
chunks: []string{
|
||||
"说明\n\n",
|
||||
"| 检查项",
|
||||
" | 操作要求 |\n",
|
||||
"| --- | --",
|
||||
"- |\n",
|
||||
"| 接地检查 | 上电前确",
|
||||
"认 |\n\n结论",
|
||||
},
|
||||
result: core.QAResult{Answer: answer},
|
||||
}
|
||||
reply := &fakeReply{}
|
||||
card := &fakeStreamingCard{startID: "track-stream-table"}
|
||||
h := newTestHandlerWithStreaming(t, qa, reply, card)
|
||||
callCount := int64(0)
|
||||
h.now = func() time.Time {
|
||||
callCount++
|
||||
return time.Unix(0, callCount*int64(time.Second))
|
||||
}
|
||||
h.cardUpdateInterval = time.Millisecond
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var partials []string
|
||||
for _, update := range card.updates {
|
||||
if !update.isFinalize {
|
||||
partials = append(partials, update.content)
|
||||
}
|
||||
}
|
||||
wantPartials := []string{"说明\n\n"}
|
||||
if !reflect.DeepEqual(partials, wantPartials) {
|
||||
t.Fatalf("partial updates=%q, want only pre-table stream %q", partials, wantPartials)
|
||||
}
|
||||
final := card.updates[len(card.updates)-1]
|
||||
if !final.isFinalize || final.content != answer {
|
||||
t.Fatalf("final=%+v, want unchanged complete answer", final)
|
||||
}
|
||||
if qa.result.Answer != answer {
|
||||
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
type fakeImagePublisher struct {
|
||||
published []byte
|
||||
mime string
|
||||
url string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeImagePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
|
||||
f.calls++
|
||||
f.published = data
|
||||
f.mime = contentType
|
||||
return f.url, f.err
|
||||
}
|
||||
|
||||
func TestDecodeImageDataURI(t *testing.T) {
|
||||
raw := []byte("hello-image-bytes")
|
||||
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
|
||||
decoded, err := decodeImageDataURI(uri)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeImageDataURI: %v", err)
|
||||
}
|
||||
if string(decoded) != string(raw) {
|
||||
t.Fatalf("decoded=%q, want %q", decoded, raw)
|
||||
}
|
||||
|
||||
if _, err := decodeImageDataURI("data:image/png;base64"); err == nil {
|
||||
t.Fatal("expected error for missing base64 payload")
|
||||
}
|
||||
if _, err := decodeImageDataURI("no comma at all"); err == nil {
|
||||
t.Fatal("expected error for non-data URI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishQuestionImagePublishesDecodedBytes(t *testing.T) {
|
||||
pub := &fakeImagePublisher{url: "https://weknora-api.example/i/001"}
|
||||
raw := []byte{0x89, 0x50, 0x4e, 0x47}
|
||||
image := core.ImageInput{
|
||||
DataURI: "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw),
|
||||
MIMEType: "image/png",
|
||||
}
|
||||
h := &Handler{imagePublisher: pub}
|
||||
|
||||
got := h.publishQuestionImage(context.Background(), image)
|
||||
if got != "https://weknora-api.example/i/001" {
|
||||
t.Fatalf("url=%q, want published", got)
|
||||
}
|
||||
if pub.calls != 1 || pub.mime != "image/png" || string(pub.published) != string(raw) {
|
||||
t.Fatalf("publisher calls=%d mime=%q bytes=%q", pub.calls, pub.mime, pub.published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishQuestionImageFailsClosedWithoutURL(t *testing.T) {
|
||||
h := &Handler{imagePublisher: nil}
|
||||
if got := h.publishQuestionImage(context.Background(), core.ImageInput{}); got != "" {
|
||||
t.Fatalf("expected empty URL without publisher, got %q", got)
|
||||
}
|
||||
|
||||
pub := &fakeImagePublisher{err: errors.New("oss down")}
|
||||
h2 := &Handler{imagePublisher: pub}
|
||||
image := core.ImageInput{
|
||||
DataURI: "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("x")),
|
||||
MIMEType: "image/png",
|
||||
}
|
||||
if got := h2.publishQuestionImage(context.Background(), image); got != "" {
|
||||
t.Fatalf("expected empty URL on publish error, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package dingtalk
|
||||
|
||||
import "strings"
|
||||
|
||||
type markdownStreamLine struct {
|
||||
start int
|
||||
text string
|
||||
inFence bool
|
||||
}
|
||||
|
||||
// stableMarkdownPrefix returns the cumulative content that is safe to render
|
||||
// while Markdown is still streaming. Once a table is confirmed, the table and
|
||||
// every following token stay hidden until the terminal card update.
|
||||
func stableMarkdownPrefix(content string) string {
|
||||
lines := splitMarkdownStreamLines(content)
|
||||
for i := 0; i < len(lines); i++ {
|
||||
if lines[i].inFence || !isTableHeaderCandidate(lines[i].text) {
|
||||
continue
|
||||
}
|
||||
|
||||
if i == len(lines)-1 {
|
||||
return content[:lines[i].start]
|
||||
}
|
||||
if lines[i+1].inFence || !isTableDelimiterCandidate(lines[i+1].text) {
|
||||
continue
|
||||
}
|
||||
if !isCompleteTableDelimiter(lines[i+1].text) {
|
||||
return content[:lines[i].start]
|
||||
}
|
||||
// DingTalk only lays out Markdown tables reliably on the terminal
|
||||
// isFinalize=true update. Keep the table and every following partial
|
||||
// token hidden; the authoritative final update bypasses this helper.
|
||||
return content[:lines[i].start]
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func splitMarkdownStreamLines(content string) []markdownStreamLine {
|
||||
lines := make([]markdownStreamLine, 0, strings.Count(content, "\n")+1)
|
||||
fence := ""
|
||||
for start := 0; start < len(content); {
|
||||
end := strings.IndexByte(content[start:], '\n')
|
||||
if end < 0 {
|
||||
end = len(content)
|
||||
} else {
|
||||
end += start + 1
|
||||
}
|
||||
text := strings.TrimSuffix(content[start:end], "\n")
|
||||
text = strings.TrimSuffix(text, "\r")
|
||||
trimmed := strings.TrimSpace(text)
|
||||
line := markdownStreamLine{start: start, text: text, inFence: fence != ""}
|
||||
|
||||
if marker := markdownFenceMarker(trimmed); marker != "" {
|
||||
line.inFence = true
|
||||
if fence == "" {
|
||||
fence = marker
|
||||
} else if marker == fence {
|
||||
fence = ""
|
||||
}
|
||||
}
|
||||
lines = append(lines, line)
|
||||
start = end
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func markdownFenceMarker(trimmed string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "```"):
|
||||
return "```"
|
||||
case strings.HasPrefix(trimmed, "~~~"):
|
||||
return "~~~"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isTableHeaderCandidate(line string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(line), "|")
|
||||
}
|
||||
|
||||
func isTableDelimiterCandidate(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "|") {
|
||||
return false
|
||||
}
|
||||
for _, r := range trimmed {
|
||||
switch r {
|
||||
case '|', ':', '-', ' ', '\t':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isCompleteTableDelimiter(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
trimmed = strings.TrimPrefix(trimmed, "|")
|
||||
trimmed = strings.TrimSuffix(trimmed, "|")
|
||||
cells := strings.Split(trimmed, "|")
|
||||
if len(cells) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, cell := range cells {
|
||||
cell = strings.TrimSpace(cell)
|
||||
cell = strings.TrimPrefix(cell, ":")
|
||||
cell = strings.TrimSuffix(cell, ":")
|
||||
if len(cell) < 3 || strings.Trim(cell, "-") != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
||||
)
|
||||
|
||||
type pacedTableEventQA struct {
|
||||
fakeStreamingQA
|
||||
events []weknora.StreamEvent
|
||||
pause time.Duration
|
||||
}
|
||||
|
||||
func (f *pacedTableEventQA) AskEvents(_ context.Context, req core.QARequest, observer weknora.StreamObserver) (core.QAResult, error) {
|
||||
f.lastReq = req
|
||||
for _, event := range f.events {
|
||||
if observer != nil {
|
||||
observer(event)
|
||||
}
|
||||
time.Sleep(f.pause)
|
||||
}
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func TestHandleEventStreamingPublishesTableOnlyOnFinalUpdate(t *testing.T) {
|
||||
answer := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n结论"
|
||||
qa := &pacedTableEventQA{
|
||||
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: answer}},
|
||||
events: []weknora.StreamEvent{
|
||||
{ResponseType: "answer", FullAnswer: "说明\n\n"},
|
||||
{ResponseType: "answer", FullAnswer: "说明\n\n| 检查项"},
|
||||
{ResponseType: "answer", FullAnswer: "说明\n\n| 检查项 | 操作要求 |\n| --- | --"},
|
||||
{ResponseType: "answer", FullAnswer: answer, Done: true},
|
||||
},
|
||||
pause: 20 * time.Millisecond,
|
||||
}
|
||||
reply := &fakeReply{}
|
||||
card := &fakeStreamingCard{startID: "track-event-table"}
|
||||
h := newTestHandlerWithStreaming(t, qa, reply, card)
|
||||
h.cardUpdateInterval = time.Millisecond
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var partials []string
|
||||
for _, update := range card.updates {
|
||||
if !update.isFinalize {
|
||||
partials = append(partials, update.content)
|
||||
}
|
||||
}
|
||||
wantPartials := []string{"说明\n\n"}
|
||||
if !reflect.DeepEqual(partials, wantPartials) {
|
||||
t.Fatalf("event partial updates=%q, want only pre-table stream %q", partials, wantPartials)
|
||||
}
|
||||
final := card.updates[len(card.updates)-1]
|
||||
if !final.isFinalize || final.content != answer {
|
||||
t.Fatalf("final=%+v, want unchanged complete answer", final)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package dingtalk
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStableMarkdownPrefixHoldsCompletedTableUntilFinalization(t *testing.T) {
|
||||
content := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n表后正文仍在生成"
|
||||
want := "说明\n\n"
|
||||
|
||||
if got := stableMarkdownPrefix(content); got != want {
|
||||
t.Fatalf("stableMarkdownPrefix()=%q, want table and trailing stream held as %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableMarkdownPrefixHoldsBackTableUntilFinalization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "split header",
|
||||
content: "说明\n\n| 检查项",
|
||||
want: "说明\n\n",
|
||||
},
|
||||
{
|
||||
name: "header waiting for delimiter",
|
||||
content: "说明\n\n| 检查项 | 操作要求 |\n",
|
||||
want: "说明\n\n",
|
||||
},
|
||||
{
|
||||
name: "partial delimiter",
|
||||
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --",
|
||||
want: "说明\n\n",
|
||||
},
|
||||
{
|
||||
name: "partial data row",
|
||||
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确",
|
||||
want: "说明\n\n",
|
||||
},
|
||||
{
|
||||
name: "complete table without terminator",
|
||||
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n",
|
||||
want: "说明\n\n",
|
||||
},
|
||||
{
|
||||
name: "complete table followed by blank line",
|
||||
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n后文",
|
||||
want: "说明\n\n",
|
||||
},
|
||||
{
|
||||
name: "complete table followed by heading",
|
||||
content: "| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n## 后续",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "second consecutive table remains hidden",
|
||||
content: "| A | B |\n| --- | --- |\n| 1 | 2 |\n\n| C | D",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := stableMarkdownPrefix(tt.content); got != tt.want {
|
||||
t.Fatalf("stableMarkdownPrefix()=%q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableMarkdownPrefixLeavesNonTablesUnchanged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "ordinary text",
|
||||
content: "普通段落继续流式显示。",
|
||||
},
|
||||
{
|
||||
name: "ordinary inline pipe",
|
||||
content: "状态使用 ready | running | done 表示。",
|
||||
},
|
||||
{
|
||||
name: "pipe inside fenced code",
|
||||
content: "```text\n| 这不是 | 表格 |\n| --- | --- |\n```",
|
||||
},
|
||||
{
|
||||
name: "completed prose line starting with pipe",
|
||||
content: "| 这是引用样式文本\n下一段普通文字",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := stableMarkdownPrefix(tt.content); got != tt.content {
|
||||
t.Fatalf("stableMarkdownPrefix()=%q, want unchanged %q", got, tt.content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage"
|
||||
)
|
||||
|
||||
type diagFetcher struct{}
|
||||
|
||||
func (diagFetcher) Fetch(ctx context.Context, ref string) ([]byte, string, error) {
|
||||
return []byte("PNGDATA"), "image/png", nil
|
||||
}
|
||||
|
||||
type diagPublisher struct{}
|
||||
|
||||
func (diagPublisher) Publish(ctx context.Context, data []byte, contentType string) (string, error) {
|
||||
return "https://weknora-api-test.dobot.cn/dingtalk/i/a87f73b8-15cb-546e-ba53-bf93dade916f", nil
|
||||
}
|
||||
|
||||
// The outbound rewrite must publish a resource image and keep it in the card
|
||||
// body. A reference whose handle contains "_x" (…ZM_pltQ) used to be mangled by
|
||||
// the math normalizer, which made the image unpublishable and the sanitizer
|
||||
// drop the whole line.
|
||||
func TestRewriteOutboundContentKeepsPublishedImage(t *testing.T) {
|
||||
content := "\n\n公式:$r = \\sqrt{X^2 + Y^2}$\n"
|
||||
h := &Handler{
|
||||
outboundRewriter: outboundimage.NewRewriter(diagFetcher{}, diagPublisher{}, 50),
|
||||
groupTablesFinal: true,
|
||||
}
|
||||
out := h.rewriteOutboundContent(context.Background(), content)
|
||||
if !strings.Contains(out, " {
|
||||
t.Fatalf("published image missing from card content: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "r = √(X² + Y²)") {
|
||||
t.Fatalf("formula not converted: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
)
|
||||
|
||||
func TestDecorateReasoningPartialMarksReasoningFromFirstChunk(t *testing.T) {
|
||||
got := decorateReasoningPartial("部分答案", string(reasoningmode.ExecutionReasoning))
|
||||
want := "【智能推理模式】\n\n部分答案"
|
||||
if got != want {
|
||||
t.Fatalf("got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecorateReasoningPartialLeavesQuickAnswerUnchanged(t *testing.T) {
|
||||
if got := decorateReasoningPartial("快速答案", string(reasoningmode.ExecutionQuick)); got != "快速答案" {
|
||||
t.Fatalf("got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningStreamingShowsStepsLineByLineThenDropsThemForBody(t *testing.T) {
|
||||
execution := string(reasoningmode.ExecutionReasoning)
|
||||
first := "我先确认一下设备的型号和手册。"
|
||||
second := "手册里有关节扭矩表,让我核对一下。"
|
||||
body := "## CR20V 关节电机最大旋转扭矩\n\n| 关节 | 扭矩 |"
|
||||
feed := reasoningmode.NewPartialFeed(execution)
|
||||
|
||||
if got, want := decorateReasoningPartial(feed.OnAnswer(first), execution), "【智能推理模式】\n\n"+first; got != want {
|
||||
t.Fatalf("step1 got=%q want=%q", got, want)
|
||||
}
|
||||
feed.StepBoundary()
|
||||
if got, want := decorateReasoningPartial(feed.OnAnswer(first+second), execution), "【智能推理模式】\n\n"+first+"\n\n"+second; got != want {
|
||||
t.Fatalf("step2 got=%q want=%q", got, want)
|
||||
}
|
||||
answer := first + second + "\n\n" + body
|
||||
if got, want := decorateReasoningPartial(feed.OnAnswer(answer), execution), "【智能推理模式】\n\n"+body; got != want {
|
||||
t.Fatalf("refresh got=%q want=%q", got, want)
|
||||
}
|
||||
got := feed.OnAnswer(answer + "\n| J1 | 98 N·m |")
|
||||
if strings.Contains(got, first) || !strings.Contains(got, "J1") {
|
||||
t.Fatalf("reasoning must stay hidden while the body streams, got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickStreamingKeepsAccumulatedAnswer(t *testing.T) {
|
||||
feed := reasoningmode.NewPartialFeed(string(reasoningmode.ExecutionQuick))
|
||||
if got := feed.OnAnswer("快速答案"); got != "快速答案" {
|
||||
t.Fatalf("got=%q", got)
|
||||
}
|
||||
feed.StepBoundary()
|
||||
if got := feed.OnAnswer("快速答案追加"); got != "快速答案追加" {
|
||||
t.Fatalf("got=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
)
|
||||
|
||||
type fakeReasoningRouter struct {
|
||||
called bool
|
||||
message ReasoningMessage
|
||||
decision ReasoningDecision
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeReasoningRouter) Prepare(_ context.Context, message ReasoningMessage) (ReasoningDecision, error) {
|
||||
f.called = true
|
||||
f.message = message
|
||||
return f.decision, f.err
|
||||
}
|
||||
|
||||
func TestReasoningControlCommandBypassesQA(t *testing.T) {
|
||||
qa := &fakeQA{}
|
||||
reply := &fakeReply{}
|
||||
router := &fakeReasoningRouter{decision: ReasoningDecision{Handled: true, Reply: "mode enabled"}}
|
||||
handler, err := NewHandlerWithOptions(
|
||||
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
|
||||
qa,
|
||||
reply,
|
||||
HandlerOptions{ReasoningRouter: router},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
callback := directTextCallback()
|
||||
callback.Text = "/think on"
|
||||
if err = handler.Handle(context.Background(), callback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !router.called || router.message.StaffID != "staff-123" || router.message.Text != "/think on" {
|
||||
t.Fatalf("router=%+v", router)
|
||||
}
|
||||
if qa.called {
|
||||
t.Fatal("reasoning control command must bypass QA")
|
||||
}
|
||||
if !reply.called || reply.lastText != "mode enabled" {
|
||||
t.Fatalf("reply=%+v", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningRouteAppliesQueryAgentAndExecutionToQARequest(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{TurnID: "turn-1", AnswerMessageID: "answer-1", Answer: "结果"}}
|
||||
reply := &fakeReply{}
|
||||
router := &fakeReasoningRouter{decision: ReasoningDecision{
|
||||
Query: "分析根因", AgentID: "builtin-smart-reasoning",
|
||||
Execution: reasoningmode.ExecutionReasoningSingle, Expired: true,
|
||||
}}
|
||||
handler, err := NewHandlerWithOptions(
|
||||
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
|
||||
qa,
|
||||
reply,
|
||||
HandlerOptions{ReasoningRouter: router},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
callback := directTextCallback()
|
||||
callback.Text = "/think 分析根因"
|
||||
if err = handler.Handle(context.Background(), callback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qa.called {
|
||||
t.Fatal("single-turn reasoning must call QA")
|
||||
}
|
||||
if qa.lastReq.Query != "分析根因" || qa.lastReq.AgentIDOverride != "builtin-smart-reasoning" ||
|
||||
qa.lastReq.ReasoningExecution != string(reasoningmode.ExecutionReasoningSingle) || !qa.lastReq.ReasoningExpired {
|
||||
t.Fatalf("QA request=%+v", qa.lastReq)
|
||||
}
|
||||
}
|
||||
@@ -86,10 +86,13 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
|
||||
Text: data.Text.Content,
|
||||
}
|
||||
if msgtype == "richtext" {
|
||||
rtMsgtype, rtText, rtCode := parseRichText(data.Content)
|
||||
rtMsgtype, rtText, rtCodes := parseRichText(data.Content)
|
||||
cb.Msgtype = rtMsgtype
|
||||
cb.Text = rtText
|
||||
cb.DownloadCode = rtCode
|
||||
cb.DownloadCodes = rtCodes
|
||||
if len(rtCodes) > 0 {
|
||||
cb.DownloadCode = rtCodes[0]
|
||||
}
|
||||
return cb, nil
|
||||
}
|
||||
if msgtype == "link" {
|
||||
@@ -178,19 +181,19 @@ func safeHTTPURL(raw string) string {
|
||||
// 3. Nested envelope: {"content":{"richText":[...]}}
|
||||
//
|
||||
// Picture items are identified by a "type" field (case-insensitive trimmed)
|
||||
// equal to "picture". The first non-empty downloadCode is used; subsequent
|
||||
// picture items are ignored. Text items are joined with newlines.
|
||||
// equal to "picture". All non-empty download codes are collected in order;
|
||||
// text items are joined with newlines.
|
||||
//
|
||||
// Returns: (msgtype, text, downloadCode). Msgtype is "picture" if any picture
|
||||
// Returns: (msgtype, text, downloadCodes). Msgtype is "picture" if any picture
|
||||
// item exists, "text" if only text items, "richtext" if malformed/empty.
|
||||
func parseRichText(raw interface{}) (string, string, string) {
|
||||
func parseRichText(raw interface{}) (string, string, []string) {
|
||||
items := extractRichTextItems(raw)
|
||||
if len(items) == 0 {
|
||||
return "richtext", "", ""
|
||||
return "richtext", "", nil
|
||||
}
|
||||
|
||||
var textParts []string
|
||||
var downloadCode string
|
||||
var downloadCodes []string
|
||||
hasPicture := false
|
||||
|
||||
for _, item := range items {
|
||||
@@ -198,11 +201,9 @@ func parseRichText(raw interface{}) (string, string, string) {
|
||||
|
||||
if itemType == "picture" {
|
||||
hasPicture = true
|
||||
if downloadCode == "" {
|
||||
code := strings.TrimSpace(item.DownloadCode)
|
||||
if code != "" {
|
||||
downloadCode = code
|
||||
}
|
||||
code := strings.TrimSpace(item.DownloadCode)
|
||||
if code != "" {
|
||||
downloadCodes = append(downloadCodes, code)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -216,12 +217,12 @@ func parseRichText(raw interface{}) (string, string, string) {
|
||||
text := strings.Join(textParts, "\n")
|
||||
|
||||
if hasPicture {
|
||||
return "picture", text, downloadCode
|
||||
return "picture", text, downloadCodes
|
||||
}
|
||||
if text != "" {
|
||||
return "text", text, ""
|
||||
return "text", text, nil
|
||||
}
|
||||
return "richtext", "", ""
|
||||
return "richtext", "", nil
|
||||
}
|
||||
|
||||
// richTextItem is a single item in a richText array.
|
||||
|
||||
@@ -2,6 +2,7 @@ package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -186,7 +187,7 @@ func TestBotCallbackFromSDK(t *testing.T) {
|
||||
SessionWebhook: "https://oapi.dingtalk.com/robot/sendBySession?session=abc",
|
||||
Text: " 你好 ",
|
||||
}
|
||||
if cb != want {
|
||||
if !reflect.DeepEqual(cb, want) {
|
||||
t.Fatalf("BotCallbackFromSDK() = %+v, want %+v", cb, want)
|
||||
}
|
||||
})
|
||||
@@ -450,7 +451,7 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("first non-empty downloadCode used, subsequent ignored", func(t *testing.T) {
|
||||
t.Run("all picture downloadCodes collected in order", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := &chatbot.BotCallbackDataModel{
|
||||
ConversationType: "1",
|
||||
@@ -468,6 +469,9 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
|
||||
if cb.DownloadCode != "code-first" {
|
||||
t.Fatalf("DownloadCode = %q, want first code-first", cb.DownloadCode)
|
||||
}
|
||||
if len(cb.DownloadCodes) != 2 || cb.DownloadCodes[0] != "code-first" || cb.DownloadCodes[1] != "code-second" {
|
||||
t.Fatalf("DownloadCodes = %+v, want [code-first code-second]", cb.DownloadCodes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("picture item missing downloadCode still picture", func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// maxTableCellVisualWidth is the DingTalk-safe cell budget. CJK counts as 2
|
||||
// display columns; a cell wider than this tends to collapse the column layout
|
||||
// (one character per line) in the streaming card renderer.
|
||||
const maxTableCellVisualWidth = 32
|
||||
|
||||
// tableNormalizeOptions controls how a final answer is adapted to the card
|
||||
// renderer. The zero value keeps the historical behaviour.
|
||||
type tableNormalizeOptions struct {
|
||||
// groupWideTables rewrites tables containing over-wide cells into grouped
|
||||
// lists (bold row heading + "- header:value" lines). Disable it when the
|
||||
// client lays out wide tables itself, so the answer keeps its real table.
|
||||
groupWideTables bool
|
||||
}
|
||||
|
||||
func defaultTableNormalizeOptions() tableNormalizeOptions {
|
||||
return tableNormalizeOptions{groupWideTables: true}
|
||||
}
|
||||
|
||||
// tableGroupingForMode maps the configured mode to the two frames the card
|
||||
// renders. An unknown or empty mode keeps the historical behaviour (group on
|
||||
// every frame), so an unset environment never changes rendering.
|
||||
//
|
||||
// ""/"on" group on every frame
|
||||
// "off" never group: real tables everywhere
|
||||
// "stream" group while streaming, real table on the final frame
|
||||
func tableGroupingForMode(mode string) (partial, final bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "off", "0", "false", "no":
|
||||
return false, false
|
||||
case "stream", "streaming", "partial":
|
||||
return true, false
|
||||
default:
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeWideMarkdownTables makes final answers render reliably on DingTalk
|
||||
// cards without changing the knowledge itself. It (1) replaces <br> inside
|
||||
// table cells with a full-width separator, and (2) rewrites any table that
|
||||
// still contains an over-wide cell into a grouped list — a bold row heading
|
||||
// followed by one "- header:value" line per column. Compact tables pass
|
||||
// through unchanged. Tables inside code fences are never touched.
|
||||
func normalizeWideMarkdownTables(content string) string {
|
||||
return normalizeMarkdownTables(content, defaultTableNormalizeOptions())
|
||||
}
|
||||
|
||||
func normalizeMarkdownTables(content string, options tableNormalizeOptions) string {
|
||||
if !strings.Contains(content, "|") {
|
||||
return content
|
||||
}
|
||||
lines := splitMarkdownStreamLines(content)
|
||||
out := make([]string, 0, len(lines))
|
||||
for i := 0; i < len(lines); {
|
||||
line := lines[i]
|
||||
if line.inFence {
|
||||
out = append(out, line.text)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if isTableHeaderCandidate(line.text) && i+1 < len(lines) &&
|
||||
isCompleteTableDelimiter(lines[i+1].text) {
|
||||
end := i + 1
|
||||
for end+1 < len(lines) && !lines[end+1].inFence &&
|
||||
strings.HasPrefix(strings.TrimSpace(lines[end+1].text), "|") {
|
||||
end++
|
||||
}
|
||||
out = append(out, normalizeTableBlock(lines[i:end+1], options)...)
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
out = append(out, line.text)
|
||||
i++
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func normalizeTableBlock(rows []markdownStreamLine, options tableNormalizeOptions) []string {
|
||||
cells := make([][]string, 0, len(rows))
|
||||
for idx, row := range rows {
|
||||
if idx == 1 { // delimiter row
|
||||
continue
|
||||
}
|
||||
cells = append(cells, splitTableRow(row.text))
|
||||
}
|
||||
maxWidth := 0
|
||||
for _, row := range cells {
|
||||
for _, cell := range row {
|
||||
if w := visualWidth(cell); w > maxWidth {
|
||||
maxWidth = w
|
||||
}
|
||||
}
|
||||
}
|
||||
header := cells[0]
|
||||
if maxWidth <= maxTableCellVisualWidth || !options.groupWideTables {
|
||||
// Compact tables keep their exact original bytes unless a cell carries
|
||||
// <br>, which the card renderer mishandles inside cells. The same path
|
||||
// serves over-wide tables when grouping is disabled: the table stays a
|
||||
// table and only <br> is replaced.
|
||||
if !blockHasBreak(rows) {
|
||||
rebuilt := make([]string, len(rows))
|
||||
for i, row := range rows {
|
||||
rebuilt[i] = row.text
|
||||
}
|
||||
return rebuilt
|
||||
}
|
||||
var rebuilt []string
|
||||
for idx, row := range cells {
|
||||
joined := "| " + strings.Join(row, " | ") + " |"
|
||||
if idx == 0 {
|
||||
rebuilt = append(rebuilt, joined)
|
||||
delims := make([]string, len(row))
|
||||
for c := range row {
|
||||
delims[c] = "---"
|
||||
}
|
||||
rebuilt = append(rebuilt, "| "+strings.Join(delims, " | ")+" |")
|
||||
} else {
|
||||
rebuilt = append(rebuilt, joined)
|
||||
}
|
||||
}
|
||||
return rebuilt
|
||||
}
|
||||
|
||||
var out []string
|
||||
for gi, row := range cells[1:] {
|
||||
if gi > 0 {
|
||||
out = append(out, "")
|
||||
}
|
||||
var title string
|
||||
var rest []string
|
||||
for c, cell := range row {
|
||||
if c == 0 {
|
||||
title = cell
|
||||
continue
|
||||
}
|
||||
label := strings.TrimSpace(header[min(c, len(header)-1)])
|
||||
if label == "" {
|
||||
label = "第" + itoa(c+1) + "项"
|
||||
}
|
||||
if cell == "" {
|
||||
continue
|
||||
}
|
||||
rest = append(rest, "- "+label+":"+cell)
|
||||
}
|
||||
if title == "" {
|
||||
title = label2(row[0])
|
||||
}
|
||||
out = append(out, "**"+strings.Trim(title, "* ")+"**")
|
||||
out = append(out, rest...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func blockHasBreak(rows []markdownStreamLine) bool {
|
||||
for _, row := range rows {
|
||||
lower := strings.ToLower(row.text)
|
||||
if strings.Contains(lower, "<br") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitTableRow(line string) []string {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
trimmed = strings.TrimPrefix(trimmed, "|")
|
||||
trimmed = strings.TrimSuffix(trimmed, "|")
|
||||
parts := splitEscaped(trimmed)
|
||||
for i := range parts {
|
||||
cell := strings.TrimSpace(parts[i])
|
||||
cell = strings.ReplaceAll(cell, "<br>", ";")
|
||||
cell = strings.ReplaceAll(cell, "<br/>", ";")
|
||||
cell = strings.ReplaceAll(cell, "<br />", ";")
|
||||
cell = strings.ReplaceAll(cell, "\\|", "|")
|
||||
parts[i] = strings.TrimSpace(cell)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// splitEscaped splits on unescaped pipe separators.
|
||||
func splitEscaped(s string) []string {
|
||||
var parts []string
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case '\\':
|
||||
if i+1 < len(s) && s[i+1] == '|' {
|
||||
b.WriteString("\\|")
|
||||
i++
|
||||
continue
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
case '|':
|
||||
parts = append(parts, b.String())
|
||||
b.Reset()
|
||||
default:
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
parts = append(parts, b.String())
|
||||
return parts
|
||||
}
|
||||
|
||||
func label2(cell string) string { return strings.Trim(cell, "* ") }
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var d []byte
|
||||
for n > 0 {
|
||||
d = append([]byte{byte('0' + n%10)}, d...)
|
||||
n /= 10
|
||||
}
|
||||
return string(d)
|
||||
}
|
||||
|
||||
func visualWidth(s string) int {
|
||||
w := 0
|
||||
for _, r := range s {
|
||||
if r == '\n' {
|
||||
if w > 0 {
|
||||
w++ // separator counted conservatively
|
||||
}
|
||||
continue
|
||||
}
|
||||
if unicode.Is(unicode.Han, r) || unicode.Is(unicode.Katakana, r) ||
|
||||
unicode.Is(unicode.Hiragana, r) || fullwidthPunct(r) {
|
||||
w += 2
|
||||
continue
|
||||
}
|
||||
w++
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func fullwidthPunct(r rune) bool {
|
||||
switch r {
|
||||
case ',', '。', ':', ';', '(', ')', '、', '【', '】', '《', '》', '“', '”', '‘', '’', ' ':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeKeepsCompactTableUnchanged(t *testing.T) {
|
||||
content := "重量如下:\n\n| 产品型号 | 净重 (本体重量) | 毛重 (含包装) |\n| :--- | :--- | :--- |\n| CR3A | 16.5 kg / 17.5 kg | 28.5 kg |\n| CR5A | 25 kg | 37 kg |\n\n结束"
|
||||
got := normalizeWideMarkdownTables(content)
|
||||
if got != content {
|
||||
t.Fatalf("compact table must pass through untouched, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConvertsWideTableToGroupedList(t *testing.T) {
|
||||
longCell := "J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 180°/s (CR3A/5A/7A)"
|
||||
content := "差异如下:\n\n| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " + longCell + " | J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 更多参数说明 |\n| 控制柜 | CC162 | CC262 |\n\n结论"
|
||||
got := normalizeWideMarkdownTables(content)
|
||||
|
||||
if strings.Contains(got, "| 对比项") || strings.Contains(got, "<br>") {
|
||||
t.Fatalf("wide table must be converted and <br> removed, got %q", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "差异如下:\n\n") || !strings.HasSuffix(got, "\n\n结论") {
|
||||
t.Fatalf("surrounding text must be preserved, got %q", got)
|
||||
}
|
||||
// compact row (控制柜) from the same table is now a list entry too
|
||||
if !strings.Contains(got, "**关节最大速度**\n- CR 系列:") || !strings.Contains(got, "- CRA 系列:") {
|
||||
t.Fatalf("row key must become bold heading with header-labelled values, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "**控制柜**\n- CR 系列:CC162\n- CRA 系列:CC262") {
|
||||
t.Fatalf("every row must become a group, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "J1: 180°/s") == false {
|
||||
t.Fatalf("cell content must be preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeReplacesBrKeepsTableWhenCellsShort(t *testing.T) {
|
||||
content := "| A | B |\n| --- | --- |\n| 短 | 行一<br>行二 |"
|
||||
got := normalizeWideMarkdownTables(content)
|
||||
if strings.Contains(got, "<br>") {
|
||||
t.Fatalf("<br> must be replaced, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "| A | B |") || !strings.Contains(got, "行一;行二") {
|
||||
t.Fatalf("short table must stay a table with ; separator, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSkipsFencedTables(t *testing.T) {
|
||||
long := strings.Repeat("长", 30)
|
||||
content := "```\n| 对比 | " + long + " |\n| --- | --- |\n| a | b |\n```\n正文"
|
||||
got := normalizeWideMarkdownTables(content)
|
||||
if got != content {
|
||||
t.Fatalf("fenced content must never be rewritten, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKeepsWideTableWhenGroupingDisabled(t *testing.T) {
|
||||
longCell := "J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 180°/s (CR3A/5A/7A)"
|
||||
content := "差异如下:\n\n| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " + longCell + " | J1: 180°/s (CR3A/5A/7A)<br>J2: 更多参数说明 |\n| 控制柜 | CC162 | CC262 |\n\n结论"
|
||||
options := defaultTableNormalizeOptions()
|
||||
options.groupWideTables = false
|
||||
got := normalizeMarkdownTables(content, options)
|
||||
|
||||
if !strings.Contains(got, "| 对比项 | CR 系列 | CRA 系列 |") || !strings.Contains(got, "| **关节最大速度** |") {
|
||||
t.Fatalf("wide table must stay a table when grouping is disabled, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "**关节最大速度**\n- CR 系列:") {
|
||||
t.Fatalf("grouped list must not be produced when grouping is disabled, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "<br>") {
|
||||
t.Fatalf("<br> must still be replaced inside cells, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, ";") {
|
||||
t.Fatalf("cell line breaks must become the full-width separator, got %q", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "差异如下:\n\n") || !strings.HasSuffix(got, "\n\n结论") {
|
||||
t.Fatalf("surrounding text must be preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableGroupingForMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode string
|
||||
wantPartial bool
|
||||
wantFinal bool
|
||||
}{
|
||||
{mode: "", wantPartial: true, wantFinal: true},
|
||||
{mode: "on", wantPartial: true, wantFinal: true},
|
||||
{mode: "weird", wantPartial: true, wantFinal: true},
|
||||
{mode: "off", wantPartial: false, wantFinal: false},
|
||||
{mode: "NO", wantPartial: false, wantFinal: false},
|
||||
{mode: "stream", wantPartial: true, wantFinal: false},
|
||||
{mode: " Stream ", wantPartial: true, wantFinal: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
partial, final := tableGroupingForMode(tc.mode)
|
||||
if partial != tc.wantPartial || final != tc.wantFinal {
|
||||
t.Fatalf("tableGroupingForMode(%q) = (%v,%v), want (%v,%v)", tc.mode, partial, final, tc.wantPartial, tc.wantFinal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWideTableGroupingPerFrame proves the switch reaches both frames the card
|
||||
// renders: the streaming frames and the final one. The streaming path drops
|
||||
// unstable table blocks, so with grouping off a partial frame simply carries no
|
||||
// table at all.
|
||||
func TestWideTableGroupingPerFrame(t *testing.T) {
|
||||
wide := "| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " +
|
||||
strings.Repeat("J1: 180°/s (CR3A/5A/7A) ", 6) + " | CC262 |"
|
||||
const tableMarker, groupMarker = "| 对比项 |", "- CR 系列:"
|
||||
cases := []struct {
|
||||
name string
|
||||
mode string
|
||||
partialWant [2]bool // [grouped, table]
|
||||
finalWant [2]bool // [grouped, table]
|
||||
}{
|
||||
{name: "default groups every frame", mode: "", partialWant: [2]bool{true, false}, finalWant: [2]bool{true, false}},
|
||||
{name: "off keeps the table on the final frame", mode: "off", partialWant: [2]bool{false, false}, finalWant: [2]bool{false, true}},
|
||||
{name: "stream groups partial and keeps the final table", mode: "stream", partialWant: [2]bool{true, false}, finalWant: [2]bool{false, true}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, err := NewHandlerWithOptions(
|
||||
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
|
||||
&fakeQA{},
|
||||
&fakeReply{},
|
||||
HandlerOptions{WideTableGrouping: tc.mode},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandlerWithOptions: %v", err)
|
||||
}
|
||||
frames := map[string]struct {
|
||||
got string
|
||||
want [2]bool
|
||||
}{
|
||||
"partial": {got: h.sanitizeOutboundPartial(context.Background(), wide), want: tc.partialWant},
|
||||
"final": {got: h.rewriteOutboundContent(context.Background(), wide), want: tc.finalWant},
|
||||
}
|
||||
for name, frame := range frames {
|
||||
grouped := strings.Contains(frame.got, groupMarker)
|
||||
table := strings.Contains(frame.got, tableMarker)
|
||||
if grouped != frame.want[0] || table != frame.want[1] {
|
||||
t.Fatalf("%s frame: grouped=%v table=%v, want grouped=%v table=%v, got %q",
|
||||
name, grouped, table, frame.want[0], frame.want[1], frame.got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHandlesEscapedPipesAndEmptyHeaders(t *testing.T) {
|
||||
long := strings.Repeat("x", 40)
|
||||
content := "| 项 | | \n| --- | --- |\n| a\\|b | " + long + " |"
|
||||
got := normalizeWideMarkdownTables(content)
|
||||
if strings.Contains(got, "| a\\|b") {
|
||||
t.Fatalf("escaped pipe cell should be converted out of table form, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "a|b") {
|
||||
t.Fatalf("escaped pipe must be restored as literal, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "第2项:") {
|
||||
t.Fatalf("missing header should fall back to ordinal label, got %q", got)
|
||||
}
|
||||
}
|
||||
+13
-6
@@ -17,13 +17,15 @@ var ErrTurnInProgress = errors.New("turn is already processing")
|
||||
|
||||
// ImageInput contains the image data for a QA request. The DataURI is a
|
||||
// full data: URI (e.g. "data:image/png;base64,...") and must never be
|
||||
// persisted to the audit log.
|
||||
// persisted to the audit log. PublishedURL is an optional stable HTTPS URL
|
||||
// produced after the image is published to object storage for traceability.
|
||||
type ImageInput struct {
|
||||
DataURI string
|
||||
MIMEType string
|
||||
SizeBytes int64
|
||||
Width int
|
||||
Height int
|
||||
DataURI string
|
||||
MIMEType string
|
||||
SizeBytes int64
|
||||
Width int
|
||||
Height int
|
||||
PublishedURL string
|
||||
}
|
||||
|
||||
// MediaAudit records metadata about media in a turn for audit purposes.
|
||||
@@ -51,6 +53,9 @@ type QARequest struct {
|
||||
Region string
|
||||
ResponseLocale string
|
||||
Query string
|
||||
AgentIDOverride string
|
||||
ReasoningExecution string
|
||||
ReasoningExpired bool
|
||||
Images []ImageInput
|
||||
MessageType string
|
||||
}
|
||||
@@ -117,6 +122,8 @@ type Turn struct {
|
||||
Answer string
|
||||
References []Reference
|
||||
Media []MediaAudit
|
||||
QuestionImageURLs []string
|
||||
KnowledgeSearched bool
|
||||
Status string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
+100
-53
@@ -6,36 +6,11 @@ import (
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
// noAnswerPhrases lists explicit "no answer" phrases that the knowledge base
|
||||
// returns when it cannot find relevant information. These are matched as
|
||||
// substrings after trimming whitespace.
|
||||
var noAnswerPhrases = []string{
|
||||
"未找到",
|
||||
"没有找到",
|
||||
"无法确定",
|
||||
"暂无相关",
|
||||
"暂无已发布的权威知识",
|
||||
"知识库中没有",
|
||||
"产品不存在于资料中",
|
||||
}
|
||||
|
||||
// retrievalGapPhrases are only considered a no-answer signal when the answer
|
||||
// also directs the user to a human channel. Requiring both signals avoids
|
||||
// misclassifying valid negative results such as "未查询到异常记录".
|
||||
var retrievalGapPhrases = []string{
|
||||
"未查询到",
|
||||
"未检索到",
|
||||
"未能查询到",
|
||||
"未能检索到",
|
||||
}
|
||||
|
||||
var humanEscalationPhrases = []string{
|
||||
"建议您联系",
|
||||
"建议联系",
|
||||
"请联系",
|
||||
"联系技术支持",
|
||||
"联系官方销售",
|
||||
}
|
||||
// canonicalNoAnswer is the only response that carries the explicit-no-answer
|
||||
// machine contract. It must be the complete answer, never a substring match:
|
||||
// an otherwise supported answer may legitimately say that one specific detail
|
||||
// was not found.
|
||||
const canonicalNoAnswer = "当前暂无已发布的权威知识,暂不建议操作。"
|
||||
|
||||
// Evaluator classifies a successful turn into a coverage verdict.
|
||||
type Evaluator struct{}
|
||||
@@ -53,11 +28,20 @@ func (e *Evaluator) Version() string {
|
||||
// Evaluate classifies a turn. It rejects non-succeeded turns. The evaluation
|
||||
// rules (in priority order):
|
||||
// 1. Failed/processing turns → ErrTurnNotSucceeded
|
||||
// 2. Answer contains an explicit "no answer" phrase → uncovered_explicit_no_answer
|
||||
// 3. Zero references (nil or empty) → uncovered_no_references
|
||||
// 4. Has references → covered_has_references
|
||||
// 2. Has references → covered_has_references (references are the coverage
|
||||
// evidence; wording in a supported answer never overrides them)
|
||||
// 3. Complete canonical no-answer response with no references
|
||||
// → uncovered_explicit_no_answer
|
||||
// 4. Image turns (image media present) are classified by whether the question
|
||||
// actually triggered a knowledge-base search:
|
||||
// - no references + knowledge searched → uncovered_no_references
|
||||
// - no references + no knowledge search → covered_visual_recognition
|
||||
// 5. Text turns with zero references are classified by whether the question
|
||||
// actually triggered a knowledge-base search:
|
||||
// - knowledge searched → uncovered_no_references
|
||||
// - no knowledge search → covered_non_knowledge_response
|
||||
//
|
||||
// v1 never uses a score threshold. TopScore captures the highest reference score
|
||||
// v5 never uses a score threshold. TopScore captures the highest reference score
|
||||
// (0 if no references).
|
||||
func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
|
||||
if turn.Status != "succeeded" {
|
||||
@@ -67,9 +51,25 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
|
||||
// Normalize answer for no-answer phrase detection.
|
||||
trimmed := strings.TrimSpace(turn.Answer)
|
||||
|
||||
// Check for explicit no-answer phrases first — this takes priority
|
||||
// over reference presence.
|
||||
if isNoAnswer(trimmed) {
|
||||
// References are the durable coverage evidence. A supported answer may
|
||||
// correctly note that one sub-detail is absent, so its wording cannot turn
|
||||
// the entire turn into a knowledge gap.
|
||||
if len(turn.References) > 0 {
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictCovered,
|
||||
ReasonCode: ReasonCoveredHasReferences,
|
||||
ReferenceCount: len(turn.References),
|
||||
TopScore: maxScore(turn.References),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// An explicit abstention is a stable machine contract only when it is the
|
||||
// complete answer. Do not use broad natural-language substring matching.
|
||||
if isCanonicalNoAnswer(trimmed) {
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
@@ -77,13 +77,65 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictUncovered,
|
||||
ReasonCode: ReasonUncoveredExplicitNoAnswer,
|
||||
ReferenceCount: len(turn.References),
|
||||
TopScore: maxScore(turn.References),
|
||||
ReferenceCount: 0,
|
||||
TopScore: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// No references → uncovered.
|
||||
// Image turns are classified by whether knowledge search occurred.
|
||||
if hasImageMedia(turn.Media) {
|
||||
if len(turn.References) > 0 {
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictCovered,
|
||||
ReasonCode: ReasonCoveredHasReferences,
|
||||
ReferenceCount: len(turn.References),
|
||||
TopScore: maxScore(turn.References),
|
||||
}, nil
|
||||
}
|
||||
if turn.KnowledgeSearched {
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictUncovered,
|
||||
ReasonCode: ReasonUncoveredNoReferences,
|
||||
ReferenceCount: 0,
|
||||
TopScore: 0,
|
||||
}, nil
|
||||
}
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictCovered,
|
||||
ReasonCode: ReasonCoveredVisualRecognition,
|
||||
ReferenceCount: 0,
|
||||
TopScore: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Text turns without references are only uncovered if they actually
|
||||
// triggered knowledge search. This prevents greetings and other normal
|
||||
// non-knowledge responses from generating artificial support cases.
|
||||
if len(turn.References) == 0 {
|
||||
if !turn.KnowledgeSearched {
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictCovered,
|
||||
ReasonCode: ReasonCoveredNonKnowledgeResponse,
|
||||
ReferenceCount: 0,
|
||||
TopScore: 0,
|
||||
}, nil
|
||||
}
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
@@ -109,25 +161,20 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isNoAnswer(answer string) bool {
|
||||
lower := strings.ToLower(answer)
|
||||
for _, phrase := range noAnswerPhrases {
|
||||
if strings.Contains(lower, strings.ToLower(phrase)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return containsAny(lower, retrievalGapPhrases) && containsAny(lower, humanEscalationPhrases)
|
||||
}
|
||||
|
||||
func containsAny(value string, phrases []string) bool {
|
||||
for _, phrase := range phrases {
|
||||
if strings.Contains(value, strings.ToLower(phrase)) {
|
||||
// hasImageMedia reports whether the turn carried at least one image input.
|
||||
func hasImageMedia(media []core.MediaAudit) bool {
|
||||
for _, m := range media {
|
||||
if strings.EqualFold(strings.TrimSpace(m.Type), "image") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isCanonicalNoAnswer(answer string) bool {
|
||||
return strings.TrimSpace(answer) == canonicalNoAnswer
|
||||
}
|
||||
|
||||
func maxScore(refs []core.Reference) float64 {
|
||||
var max float64
|
||||
for _, r := range refs {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
func imageTurn(answer string, refs []core.Reference, searched bool) core.Turn {
|
||||
return core.Turn{
|
||||
ID: "img-turn",
|
||||
Status: "succeeded",
|
||||
MessageType: "image",
|
||||
Answer: answer,
|
||||
References: refs,
|
||||
Media: []core.MediaAudit{{Type: "image", MIMEType: "image/jpeg"}},
|
||||
KnowledgeSearched: searched,
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ImageVisualRecognitionNoSearch(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := imageTurn("这张图片展示了一个手写的知识库数据处理与发布流程图。", nil, false)
|
||||
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Fatalf("verdict=%s, want covered (visual recognition is not a coverage miss)", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonCoveredVisualRecognition {
|
||||
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonCoveredVisualRecognition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ImageSearchButNoReferencesIsUncovered(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := imageTurn("根据图片中的错误信息,未能在知识库中找到对应的解决方案。", nil, true)
|
||||
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Fatalf("verdict=%s, want uncovered (image turn searched KB but got no refs)", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredNoReferences {
|
||||
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonUncoveredNoReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ImageWithReferencesIsCovered(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := imageTurn("根据知识库资料,这个是故障代码E5,请检查...", []core.Reference{{ID: "r1", Score: 0.9}}, true)
|
||||
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Fatalf("verdict=%s, want covered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonCoveredHasReferences {
|
||||
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_TextTurnSearchedButNoReferencesIsUncovered(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "text-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "这是一个正常的回答,但没有任何引用",
|
||||
MessageType: "text",
|
||||
References: []core.Reference{},
|
||||
KnowledgeSearched: true,
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredNoReferences {
|
||||
t.Fatalf("verdict=%s reason=%s, want uncovered_no_references after knowledge search", d.Verdict, d.ReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_TextTurnWithoutKnowledgeSearchIsCovered(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "smalltalk-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "你好,我在。",
|
||||
MessageType: "text",
|
||||
KnowledgeSearched: false,
|
||||
}
|
||||
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered || d.ReasonCode != ReasonCoveredNonKnowledgeResponse {
|
||||
t.Fatalf("verdict=%s reason=%s, want covered_non_knowledge_response", d.Verdict, d.ReasonCode)
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
func TestEvaluatorVersion(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
if e.Version() != "v3" {
|
||||
t.Errorf("Evaluator version = %q, want v3", e.Version())
|
||||
if e.Version() != "v5" {
|
||||
t.Errorf("Evaluator version = %q, want v5", e.Version())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,65 +30,51 @@ func TestEvaluator_RejectsProcessingTurn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_UncoveredExplicitNoAnswer(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
noAnswerPhrases := []string{
|
||||
"未找到相关结果",
|
||||
"没有找到你需要的信息",
|
||||
"无法确定该问题的答案",
|
||||
"暂无相关信息",
|
||||
"知识库中没有找到相关内容",
|
||||
"这个问题知识库中没有可用的信息",
|
||||
}
|
||||
for _, answer := range noAnswerPhrases {
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: answer,
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.9},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for answer %q: %v", answer, err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("answer %q: verdict = %s, want uncovered", answer, d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Errorf("answer %q: reason_code = %s, want %s", answer, d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ProductionMissingProductPhraseTrumpsReferences(t *testing.T) {
|
||||
func TestEvaluator_UncoveredCanonicalNoAnswerWithoutReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "missing-product-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "根据提供的检索信息,无法提供对应手册。原因:产品不存在于资料中;检索资料未提及该产品。建议联系技术支持部门。",
|
||||
References: []core.Reference{
|
||||
{ID: "unrelated-1", Score: 0.395940},
|
||||
{ID: "unrelated-2", Score: 0.311595},
|
||||
},
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: canonicalNoAnswer,
|
||||
KnowledgeSearched: true,
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Fatalf("verdict=%s reason=%s", d.Verdict, d.ReasonCode)
|
||||
t.Fatalf("verdict/reason = %s/%s, want %s/%s", d.Verdict, d.ReasonCode, VerdictUncovered, ReasonUncoveredExplicitNoAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_NovaPartialNotFoundWithSevenReferencesIsCovered(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "nova-partial-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "已找到Nova系列的相关资料;未找到VX500 2.5D视觉一体方案的明确说明。",
|
||||
References: []core.Reference{
|
||||
{ID: "r1"}, {ID: "r2"}, {ID: "r3"}, {ID: "r4"},
|
||||
{ID: "r5"}, {ID: "r6"}, {ID: "r7"},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered || d.ReasonCode != ReasonCoveredHasReferences || d.ReferenceCount != 7 {
|
||||
t.Fatalf("verdict/reason/references = %s/%s/%d, want %s/%s/7", d.Verdict, d.ReasonCode, d.ReferenceCount, VerdictCovered, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_UncoveredNoReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "这是一个正常的回答,但没有任何引用",
|
||||
References: []core.Reference{},
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "这是一个正常的回答,但没有任何引用",
|
||||
References: []core.Reference{},
|
||||
KnowledgeSearched: true,
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
@@ -105,9 +91,10 @@ func TestEvaluator_UncoveredNoReferences(t *testing.T) {
|
||||
func TestEvaluator_UncoveredNilReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "这是一个正常的回答,但引用为nil",
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "这是一个正常的回答,但引用为nil",
|
||||
KnowledgeSearched: true,
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
@@ -203,9 +190,9 @@ func TestEvaluator_TopScoreZeroWhenNoReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_NoAnswerTrumpsReferences(t *testing.T) {
|
||||
// When answer contains explicit "no answer" phrase, it should be uncovered
|
||||
// even if references exist. No-answer detection takes priority.
|
||||
func TestEvaluator_ReferencesTrumpNaturalLanguageNoAnswer(t *testing.T) {
|
||||
// References are durable coverage evidence; a natural-language phrase must
|
||||
// not override them.
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
@@ -220,11 +207,11 @@ func TestEvaluator_NoAnswerTrumpsReferences(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("no-answer with references: verdict = %s, want uncovered", d.Verdict)
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Errorf("answer with references: verdict = %s, want covered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Errorf("no-answer with references: reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
if d.ReasonCode != ReasonCoveredHasReferences {
|
||||
t.Errorf("answer with references: reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,12 +230,12 @@ func TestEvaluator_NoAnswerPhraseMatchesInsideAnswer(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
if d.ReasonCode != ReasonCoveredHasReferences {
|
||||
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ProductionUnavailableAuthorityPhraseTrumpsReferences(t *testing.T) {
|
||||
func TestEvaluator_ProductionUnavailableAuthorityPhraseWithReferencesIsCovered(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "cra-price-turn",
|
||||
@@ -263,11 +250,11 @@ func TestEvaluator_ProductionUnavailableAuthorityPhraseTrumpsReferences(t *testi
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Fatalf("verdict = %s, want %s", d.Verdict, VerdictUncovered)
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Fatalf("verdict = %s, want %s", d.Verdict, VerdictCovered)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
if d.ReasonCode != ReasonCoveredHasReferences {
|
||||
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,10 +267,10 @@ func TestEvaluator_RetrievalGapRequiresHumanEscalation(t *testing.T) {
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "production NOVA price answer",
|
||||
name: "retrieval wording with evidence remains covered",
|
||||
answer: "当前暂未查询到Nova系列机器人的公开销售价格信息。\n\n如需获取报价,建议您联系越疆科技官方销售渠道或对应区域的销售工程师咨询具体价格方案。",
|
||||
verdict: VerdictUncovered,
|
||||
reason: ReasonUncoveredExplicitNoAnswer,
|
||||
verdict: VerdictCovered,
|
||||
reason: ReasonCoveredHasReferences,
|
||||
},
|
||||
{
|
||||
name: "retrieval gap without escalation is a valid negative result",
|
||||
|
||||
@@ -10,11 +10,17 @@ import (
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
const (
|
||||
SupportTriggerCoverageUncovered = "coverage_uncovered"
|
||||
SupportTriggerFeedbackDown = "feedback_down"
|
||||
)
|
||||
|
||||
// SupportNotification is a safe, minimal view of the data needed to send a
|
||||
// notification. It must never contain tokens, webhooks, raw API responses,
|
||||
// raw error text, reference bodies, or group IDs.
|
||||
type SupportNotification struct {
|
||||
CaseID string
|
||||
TriggerType string
|
||||
Question string
|
||||
SenderName string
|
||||
Channel string
|
||||
|
||||
@@ -76,11 +76,12 @@ func TestService_EvaluateAndPersist_UncoveredNoReferences(t *testing.T) {
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-2",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "正常回答但没有引用",
|
||||
ID: "turn-2",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "正常回答但没有引用",
|
||||
KnowledgeSearched: true,
|
||||
}
|
||||
|
||||
d, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
@@ -103,14 +104,12 @@ func TestService_EvaluateAndPersist_UncoveredExplicitNoAnswer(t *testing.T) {
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-3",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "未找到相关结果",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.5},
|
||||
},
|
||||
ID: "turn-3",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: canonicalNoAnswer,
|
||||
KnowledgeSearched: true,
|
||||
}
|
||||
|
||||
d, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package coverage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSupportNotificationAcceptsAuditableTriggerType(t *testing.T) {
|
||||
notification := SupportNotification{TriggerType: SupportTriggerFeedbackDown}
|
||||
if notification.TriggerType != "feedback_down" {
|
||||
t.Fatalf("trigger=%q", notification.TriggerType)
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,15 @@ const (
|
||||
|
||||
// ReasonCode values.
|
||||
const (
|
||||
ReasonCoveredHasReferences = "covered_has_references"
|
||||
ReasonUncoveredNoReferences = "uncovered_no_references"
|
||||
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
|
||||
ReasonCoveredHasReferences = "covered_has_references"
|
||||
ReasonUncoveredNoReferences = "uncovered_no_references"
|
||||
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
|
||||
ReasonCoveredVisualRecognition = "covered_visual_recognition"
|
||||
ReasonCoveredNonKnowledgeResponse = "covered_non_knowledge_response"
|
||||
)
|
||||
|
||||
// EvaluatorVersion is the current version of the evaluator.
|
||||
const EvaluatorVersion = "v3"
|
||||
const EvaluatorVersion = "v5"
|
||||
|
||||
var (
|
||||
ErrTurnNotSucceeded = errors.New("coverage evaluator: turn status must be succeeded")
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
|
||||
@@ -49,16 +49,44 @@ func (s *AliyunOSSStore) Put(ctx context.Context, key string, data []byte, conte
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AliyunOSSStore) PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error) {
|
||||
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || ttl <= 0 {
|
||||
return "", errors.New("outbound image: invalid OSS presign request")
|
||||
// Exists reports whether an object is already stored under key, so identical
|
||||
// content is never uploaded twice.
|
||||
func (s *AliyunOSSStore) Exists(ctx context.Context, key string) (bool, error) {
|
||||
if s == nil || s.client == nil || strings.TrimSpace(key) == "" {
|
||||
return false, errors.New("outbound image: invalid OSS exists request")
|
||||
}
|
||||
result, err := s.client.Presign(ctx, &oss.GetObjectRequest{
|
||||
exists, err := s.client.IsObjectExist(ctx, s.bucket, key)
|
||||
if err != nil {
|
||||
return false, errors.New("outbound image: OSS exists check failed")
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *AliyunOSSStore) Get(ctx context.Context, key string, maxBytes int64) ([]byte, string, error) {
|
||||
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || maxBytes <= 0 {
|
||||
return nil, "", errors.New("outbound image: invalid OSS get request")
|
||||
}
|
||||
result, err := s.client.GetObject(ctx, &oss.GetObjectRequest{
|
||||
Bucket: oss.Ptr(s.bucket),
|
||||
Key: oss.Ptr(key),
|
||||
}, oss.PresignExpires(ttl))
|
||||
})
|
||||
if err != nil {
|
||||
return "", errors.New("outbound image: OSS presign failed")
|
||||
return nil, "", errors.New("outbound image: OSS get failed")
|
||||
}
|
||||
return result.URL, nil
|
||||
defer result.Body.Close()
|
||||
if result.ContentLength > maxBytes {
|
||||
return nil, "", errors.New("outbound image: OSS object exceeds size limit")
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(result.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", errors.New("outbound image: OSS read failed")
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, "", errors.New("outbound image: OSS object exceeds size limit")
|
||||
}
|
||||
contentType := ""
|
||||
if result.ContentType != nil {
|
||||
contentType = strings.TrimSpace(*result.ContentType)
|
||||
}
|
||||
return data, contentType, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user