mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
chore: remove the IM bot gateway
Studio has no bot surface. On this line the QQ / Feishu / WeChat gateway is reachable only as `reasonix bot`, a CLI command no Studio user starts, and it carries about 12.9k lines: internal/bot, internal/botruntime, the subcommand, its config section and its docs. It is also where the recent Windows heap-corruption and gateway-wedge fixes had to land. What goes with it: - `reasonix bot`, its completion spec and its usage line in three locales. - The typed [bot] config, its defaults, render block, model-ref and migration hooks, and the 0.x QQ import. - GatewayAPI, the control port only the gateway named, and the two passive inbox variants only it called; neterr.IsTransient and textutil.FitGraphemeBytes, which only it used. - The bot entries in repolint's layer and frontend-parity declarations, the bot race-test target in CI, BOT_GUIDE.md and its images, and the [bot] example. What stays, and why: - The user config is shared with 1.x installs, which still run the gateway. [bot] now decodes into an opaque table and is written back unchanged on every save, so a settings change here does not switch that gateway off or erase its allowlists. Credential variables the table names are still kept out of subprocess environments. - surface.Bot stays: usage records on disk carry it, and dropping it would re-attribute them to another frontend. - SECURITY.md and the crash-report worker still describe the bot, since they serve every release line. Budgets only fall: frontend-parity drops by 20 edges and the reclaimed headroom is recorded with -update.
This commit is contained in:
1 parent
8b2a05c514
commit
f352dfbecc
114 files changed
+325
-20588
No files matched your search
@@ -242,7 +242,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test -race ./internal/agent/... ./internal/plugin/... ./internal/jobs/... ./internal/proc/... ./internal/sandbox/... ./internal/filelock/... ./internal/eventwire/... ./internal/remote/... ./internal/extension/... ./internal/boot/... ./internal/control/... ./internal/tool/... ./internal/bot/...
|
||||
run: go test -race ./internal/agent/... ./internal/plugin/... ./internal/jobs/... ./internal/proc/... ./internal/sandbox/... ./internal/filelock/... ./internal/eventwire/... ./internal/remote/... ./internal/extension/... ./internal/boot/... ./internal/control/... ./internal/tool/...
|
||||
|
||||
- name: test -race (full)
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
@@ -177,7 +177,7 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
|
||||
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
|
||||
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
|
||||
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
|
||||
[Recovery and updates](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
|
||||
[Recovery and updates](./docs/RECOVERY.md) ·
|
||||
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
|
||||
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
|
||||
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
|
||||
|
||||
@@ -146,7 +146,6 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
|
||||
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.md) ·
|
||||
[恢复与安全模式](./docs/RECOVERY.md) ·
|
||||
[机器人使用指南](./docs/BOT_GUIDE.md) ·
|
||||
[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)
|
||||
- **工程与迁移:** [规格](./docs/SPEC.md) ·
|
||||
[任务合约与暂停策略](./docs/TASK_CONTRACT.md) ·
|
||||
|
||||
+3
-3
@@ -14,9 +14,9 @@ agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
|
||||
- Layering (enforced): utility packages import nothing under `reasonix/` except
|
||||
one another — that layer is closed, so a leaf reaching a leaf drags no graph
|
||||
along, while refusing it leaves one judgement copied into several of them; only
|
||||
the frontends `cli`, `serve`, `acp`, `bot`, `botruntime`, `boot` and the hosts
|
||||
`cmd/`, `desktop/` may import `control`; nothing below a frontend may import
|
||||
one. The declared sets live in `tools/repolint/layers.go`.
|
||||
the frontends `cli`, `serve`, `acp`, `boot` and the hosts `cmd/`, `desktop/`
|
||||
may import `control`; nothing below a frontend may import one. The declared
|
||||
sets live in `tools/repolint/layers.go`.
|
||||
- Subagent delegation keeps five concepts apart: a profile says how a worker
|
||||
thinks, `TaskSpec` what this call wants, `CapabilityGrant` what it may touch,
|
||||
`ContextRequest` what it starts from, `SchedulerPolicy` when it runs. Put a
|
||||
|
||||
+1
-1
@@ -276,7 +276,7 @@ any other slash command.
|
||||
| Existing ACP v1 methods | Their names and response shapes are unchanged. | Compatible |
|
||||
| Capability `_meta` | Unknown metadata may be ignored. | Compatible |
|
||||
| Persisted transcripts | Transcript schema is unchanged; the inbox is a versioned sidecar. | Compatible |
|
||||
| CLI, Desktop, and Bot steering | Rejected steers remain durable follow-ups. | Compatible |
|
||||
| CLI and Desktop steering | Rejected steers remain durable follow-ups. | Compatible |
|
||||
|
||||
Steering appends a user-requested message to normal conversation history. It
|
||||
does not change the system prompt, tool schemas, tool order, or other stable
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
# Reasonix Bot Guide
|
||||
|
||||
<a href="../README.md">README</a>
|
||||
·
|
||||
<a href="./GUIDE.md">General guide</a>
|
||||
|
||||
> For desktop and CLI users. This guide explains how to connect Feishu, Lark,
|
||||
> WeChat, and QQ bots, how to use Reasonix from IM, and how approvals, Ask
|
||||
> questions, YOLO, and bot commands work.
|
||||
|
||||
## Contents
|
||||
|
||||
- [What the bot does](#what-the-bot-does)
|
||||
- [Where it runs](#where-it-runs)
|
||||
- [Connect the four channels](#connect-the-four-channels)
|
||||
- [Run the bot headlessly](#run-the-bot-headlessly)
|
||||
- [Usage flow](#usage-flow)
|
||||
- [Channel interaction differences](#channel-interaction-differences)
|
||||
- [Command quick reference](#command-quick-reference)
|
||||
- [Approvals and YOLO](#approvals-and-yolo)
|
||||
- [Do upgrades require rebinding?](#do-upgrades-require-rebinding)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## What the bot does
|
||||
|
||||
After a bot is connected, you can send Reasonix messages from Feishu, Lark,
|
||||
WeChat, or QQ. The desktop app or `reasonix bot start` process handles the
|
||||
model, tools, permissions, sandboxing, and local context, then sends progress
|
||||
and results back to the IM channel.
|
||||
|
||||
Common uses:
|
||||
|
||||
- Ask Reasonix to inspect code, read docs, explain errors, or summarize findings.
|
||||
- Trigger tool calls from IM and receive progress or final results in the chat.
|
||||
- Approve or deny sensitive actions such as file writes or shell commands.
|
||||
- Enable YOLO for trusted temporary work so ordinary tool approvals are skipped.
|
||||
- Open the matching desktop IM session to inspect context, cost, tokens, and tool
|
||||
traces.
|
||||
|
||||
## Where it runs
|
||||
|
||||
The bot gateway is a shared Go runtime. The same core behavior works on
|
||||
Windows, macOS, and Linux; platform differences mostly come from each IM
|
||||
provider's credentials, network reachability, callback/WebSocket setup, and
|
||||
saved local account state.
|
||||
|
||||
There are two supported entry points:
|
||||
|
||||
- **Desktop runtime**: configure bots in **Settings -> Bots**. The desktop app
|
||||
starts the gateway, keeps status in the app, persists per-connection tool
|
||||
approval mode changes, and lets you open matching local IM sessions.
|
||||
- **CLI runtime**: run `reasonix bot start` for a headless long-lived process.
|
||||
It uses the same config, allowlist, routes, queue settings, pairing store,
|
||||
adapters, and project/session index as the desktop runtime.
|
||||
|
||||
The normal `reasonix run` command does not automatically start the IM gateway.
|
||||
Remote bot behavior is active only while the desktop bot runtime is running or
|
||||
while a `reasonix bot start` process is alive.
|
||||
|
||||
## Connect the four channels
|
||||
|
||||
Open the Reasonix desktop app and go to **Settings -> Bots**. In **Add IM Bot**,
|
||||
choose a channel and scan the QR code.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Open desktop settings"] --> B["Bots"]
|
||||
B --> C["Add IM Bot"]
|
||||
C --> D{"Choose channel"}
|
||||
D --> E["Scan with Feishu to create a PersonalAgent"]
|
||||
D --> F["Scan with Lark to create a PersonalAgent"]
|
||||
D --> G["Scan with WeChat to sign in Bot Assistant"]
|
||||
D --> H["Manual setup for QQ Bot"]
|
||||
E --> I["Connection is saved locally"]
|
||||
F --> I
|
||||
G --> I
|
||||
H --> I
|
||||
I --> J["Send the first IM message"]
|
||||
J --> K["Desktop creates the matching session"]
|
||||
```
|
||||
|
||||
### Feishu
|
||||
|
||||
1. In **Settings -> Bots -> Add IM Bot**, choose **Feishu**.
|
||||
2. Generate a QR code.
|
||||
3. Scan it with Feishu and finish authorization.
|
||||
4. Wait until the page shows the connection as connected.
|
||||
5. Send the bot a message such as `hello` or `please inspect this error`.
|
||||
|
||||
### Lark
|
||||
|
||||
1. In **Settings -> Bots -> Add IM Bot**, choose **Lark**.
|
||||
2. Generate a QR code.
|
||||
3. Scan it with Lark and finish authorization.
|
||||
4. Wait until the page shows the connection as connected.
|
||||
5. Send the Lark bot a message.
|
||||
|
||||
Feishu and Lark share the same capability set, but they are saved as separate
|
||||
connections. You can give them different models, working directories, or tool
|
||||
approval modes. Bot text replies are sent as standalone Interactive Card JSON
|
||||
2.0 markdown, which avoids Feishu/Lark platform quote prefixes while preserving
|
||||
CommonMark formatting. If a card is too large for the platform limit, Reasonix
|
||||
falls back to plain text automatically.
|
||||
|
||||
For webhook mode, configure a verification token. Incoming webhook events are
|
||||
verified fail-closed: an empty or missing configured token rejects callers
|
||||
instead of silently opening the webhook.
|
||||
|
||||
### WeChat
|
||||
|
||||
1. In **Settings -> Bots -> Add IM Bot**, choose **WeChat**.
|
||||
2. Generate a QR code.
|
||||
3. Scan it with WeChat to sign in to Bot Assistant.
|
||||
4. Wait until the page shows the connection as connected.
|
||||
5. Send the WeChat bot a message.
|
||||
|
||||
WeChat does not provide interactive card buttons here, so approvals use numeric
|
||||
or text commands. Ask questions can be answered by replying with normal text,
|
||||
option numbers, or `/answer <id> <answer>`.
|
||||
|
||||
### QQ
|
||||
|
||||
1. In **Settings -> Bots -> Add IM Bot**, choose **QQ**.
|
||||
2. Fill in the **App ID** and **App Secret** (or set the env var `QQ_BOT_APP_SECRET`).
|
||||
3. Click **Save** to store the credentials.
|
||||
4. Wait until the page shows the connection as connected.
|
||||
5. Send the QQ bot a message.
|
||||
|
||||
QQ Bot uses the official QQ Bot platform API. It supports inline keyboard
|
||||
buttons for approvals. Ask questions are sent as text; reply with normal text,
|
||||
option numbers, or `/answer <id> <answer>`. When a button expires or the
|
||||
platform reports an action failure, copy the ID shown in the card and send the
|
||||
equivalent text command.
|
||||
|
||||
QQ does not support QR-code scanning for connection setup. You must configure
|
||||
the App ID and App Secret manually. The adapter reads only the configured
|
||||
`app_secret_env` value; it does not fall back to an unrelated `QQ_SECRET`
|
||||
environment variable. QQ and WeChat HTTP calls use bounded clients so a stalled
|
||||
provider request cannot block the gateway indefinitely.
|
||||
|
||||
## Run the bot headlessly
|
||||
|
||||
The desktop app is the easiest way to create and test bot connections, but the
|
||||
runtime itself can also run as a long-lived headless gateway:
|
||||
|
||||
```sh
|
||||
reasonix bot doctor
|
||||
reasonix bot doctor --deep
|
||||
reasonix bot start --channels qq,feishu,lark,weixin --dir /path/to/project
|
||||
```
|
||||
|
||||
Use `--channels` to choose which configured IM inputs to accept. `feishu` and
|
||||
`lark` select the matching Feishu-family connection; `weixin` selects the saved
|
||||
WeChat iLink account; `qq` selects the configured QQ bot. Use `--dir` to attach
|
||||
incoming messages to a project workspace and `--model` to override the default
|
||||
model for this process.
|
||||
|
||||
The headless gateway uses the same config records as the desktop app:
|
||||
|
||||
- `[[bot.connections]]` identifies each IM input. `provider` is the adapter
|
||||
family (`feishu`, `weixin`, or `qq`), while `domain` distinguishes variants
|
||||
such as Feishu vs Lark.
|
||||
- `credential.app_id`, `credential.app_secret_env`, `credential.account_id`,
|
||||
and `credential.token_env` point to app IDs, app secrets, saved accounts, and
|
||||
tokens. Secrets stay in environment variables or the Reasonix user credentials
|
||||
store.
|
||||
- `workspace_root`, `model`, and `tool_approval_mode` can be set per
|
||||
connection. This lets different IM channels route to different local projects
|
||||
or approval postures.
|
||||
- `access` can also be set per connection with `enabled`, `allow_all`,
|
||||
`pairing_enabled`, `users`, `groups`, `admins`, and `approvers`. When a
|
||||
connection has active access settings, they are checked before the legacy
|
||||
global `[bot.allowlist]`.
|
||||
- `[[bot.routes]]` adds finer routing by connection, platform, chat type, chat
|
||||
ID, user ID, or thread ID. Empty match fields are wildcards; the first matching
|
||||
route wins and can override `workspace_root`, `model`, and
|
||||
`tool_approval_mode`.
|
||||
- `session_mappings` are filled from inbound messages with the remote chat ID
|
||||
and scope. The desktop UI can open the matching conversation once the mapping
|
||||
also has a local `session_id` target, such as a saved `path:` session target
|
||||
from a desktop-managed bot runtime or a manually configured mapping.
|
||||
- The bot's project/session index is intentionally bounded to configured
|
||||
`workspace_root` values, route workspaces, active bot sessions, and saved
|
||||
`session_mappings`. Commands such as `/use project` and `/attach session`
|
||||
can only jump to those indexed targets; arbitrary local directories are not
|
||||
accepted from IM text.
|
||||
|
||||
Access control is still mandatory. New desktop-created bots should normally set
|
||||
access inside that bot's own detail panel, which saves to `[[bot.connections]]`
|
||||
or `[bot.qq].access`. The legacy global `[bot.allowlist]` remains a fallback for
|
||||
older configs and for connections without active per-bot access. You can
|
||||
deliberately set `allow_all = true`, or enable `pairing_enabled` for a single
|
||||
bot / `[bot.pairing]` globally so an unknown DM sender receives a one-time
|
||||
pairing code. That code must be approved locally with
|
||||
`reasonix bot pairing approve <code>` before the sender can drive the bot; when
|
||||
the request is tied to a connection, approval adds the sender to that
|
||||
connection's access list. Users listed in `admins` / `approvers` or the legacy
|
||||
`*_admins` / `*_approvers` also receive base bot admission, so they do not need
|
||||
to be duplicated in `users` / `*_users`. Group chats are not opened by DM
|
||||
pairing or role admission; group IDs remain an additional narrowing layer.
|
||||
Use these commands to manage pending requests:
|
||||
|
||||
```sh
|
||||
reasonix bot pairing list
|
||||
reasonix bot pairing approve CODE
|
||||
reasonix bot pairing reject CODE
|
||||
```
|
||||
|
||||
If `qq_admins`, `feishu_admins`, `weixin_admins`, or the matching
|
||||
`*_approvers` lists are configured, `/yolo` and `/mode` are admin-only while
|
||||
`/projects`, `/use project`, `/sessions`, `/attach session`, and `/search all`
|
||||
are also admin-only. `/approve` and `/deny` require an approver or admin. When
|
||||
no role lists are set, existing allowlisted users keep the previous command
|
||||
behavior for compatibility. Remote users go through the same controller,
|
||||
permission policy, tool approval mode, and sandbox rules as local desktop or CLI
|
||||
turns.
|
||||
|
||||
```toml
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou_member"]
|
||||
feishu_admins = ["ou_admin"]
|
||||
feishu_approvers = ["ou_approver"]
|
||||
```
|
||||
|
||||
`ignore_self_messages = true` is enabled by default. The gateway remembers the
|
||||
platform `message_id` values it just sent and ignores matching echo events. If a
|
||||
platform does not echo the same message ID reliably, configure the bot's own user
|
||||
IDs under `[bot.self_user_ids]` as a second layer of loop protection. `/status`
|
||||
also includes the current queue mode and adapter health, such as
|
||||
`feishu-lark=running` or `weixin-weixin=degraded`.
|
||||
|
||||
The optional `[bot.control]` section exposes a local loopback HTTP API and is
|
||||
disabled by default. When enabled, `token_env` must point to an environment
|
||||
variable and every request must include `Authorization: Bearer <token>`. The
|
||||
server only binds to `localhost`, `127.0.0.1`, or `::1`. Current endpoints are
|
||||
`GET /status` for session and adapter health snapshots, `GET /metrics` for
|
||||
Prometheus text metrics, and `POST /send` for sending text or media through a
|
||||
configured connection.
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
export REASONIX_BOT_CONTROL_TOKEN="change-me"
|
||||
|
||||
curl -H "Authorization: Bearer $REASONIX_BOT_CONTROL_TOKEN" \
|
||||
http://127.0.0.1:37913/status
|
||||
|
||||
curl -X POST http://127.0.0.1:37913/send \
|
||||
-H "Authorization: Bearer $REASONIX_BOT_CONTROL_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"connection_id": "feishu-lark",
|
||||
"domain": "lark",
|
||||
"chat_id": "oc_xxx",
|
||||
"chat_type": "dm",
|
||||
"text": "hello from local control API"
|
||||
}'
|
||||
```
|
||||
|
||||
## Usage flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as "User"
|
||||
participant IM as "Feishu / Lark / WeChat / QQ"
|
||||
participant R as "Reasonix desktop or bot start"
|
||||
participant T as "Local tools and model"
|
||||
|
||||
U->>IM: "Send a request"
|
||||
IM->>R: "Message enters the local bot gateway"
|
||||
R->>T: "Model reasons and calls tools"
|
||||
alt "Normal reply"
|
||||
R-->>IM: "Send answer"
|
||||
else "Approval required"
|
||||
R-->>IM: "Send approval card or approval text"
|
||||
U->>IM: "Allow or deny"
|
||||
IM->>R: "Approval command"
|
||||
R->>T: "Continue or stop the tool call"
|
||||
R-->>IM: "Send result"
|
||||
else "User choice required"
|
||||
R-->>IM: "Send Ask question"
|
||||
U->>IM: "Choose an option or reply with /answer"
|
||||
R-->>IM: "Continue and send result"
|
||||
end
|
||||
```
|
||||
|
||||
The **Bots** entry in the desktop sidebar lists connected bots. After the first
|
||||
IM message arrives, you can open the matching local session from there to inspect
|
||||
context, tool traces, cost, and runtime metrics.
|
||||
|
||||
## Channel interaction differences
|
||||
|
||||
The following images are synthetic examples. They show the interaction shape
|
||||
without exposing real account IDs, local paths, or private chat content.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
| Channel | Connection | Approval | Ask questions | Best for |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Feishu | Scan to create a PersonalAgent | Interactive card buttons, or commands | Interactive card buttons, or commands | Feishu workspaces, DMs, and groups |
|
||||
| Lark | Scan to create a PersonalAgent | Interactive card buttons, or commands | Interactive card buttons, or commands | International Lark workspaces |
|
||||
| WeChat | Scan with WeChat | Reply `1` / `2`, or commands | Reply with normal text, option numbers, or commands | Lightweight personal/mobile testing |
|
||||
| QQ | Manual setup (App ID + App Secret) | Inline keyboard buttons, numeric replies, or commands | Reply with normal text, option numbers, or commands | QQ groups, DMs, and official QQ Bot platform |
|
||||
|
||||
Feishu and Lark card buttons are converted into commands such as
|
||||
`/approve <id>`, `/deny <id>`, or `/answer <id> <option>`. QQ approval buttons
|
||||
work the same way. If a button expires or the platform reports an action
|
||||
failure, copy the ID shown in the card and send the equivalent text command.
|
||||
|
||||
## Command quick reference
|
||||
|
||||
These commands work in Feishu, Lark, WeChat, and QQ.
|
||||
|
||||
| Command | Purpose | Example |
|
||||
| --- | --- | --- |
|
||||
| `/help` | Show available commands | `/help` |
|
||||
| `/status` | Show active tasks, queue state, tool approval mode, and adapter health | `/status` |
|
||||
| `/stop` | Stop the current task | `/stop` |
|
||||
| `/new` | Start a fresh session | `/new` |
|
||||
| `/reset` | Reset the current session | `/reset` |
|
||||
| `/approve <id>` | Approve a pending operation | `/approve 1` |
|
||||
| `/deny <id>` | Deny a pending operation | `/deny 1` |
|
||||
| `/answer <id> <option>` | Answer an Ask question | `/answer ask-1 2` |
|
||||
| `/yolo` | Enable YOLO | `/yolo` |
|
||||
| `/yolo on` | Enable YOLO | `/yolo on` |
|
||||
| `/yolo off` | Return to Ask mode | `/yolo off` |
|
||||
| `/yolo auto` | Switch to Auto approval mode | `/yolo auto` |
|
||||
| `/yolo status` | Show the current tool approval mode | `/yolo status` |
|
||||
| `/mode yolo` | Switch to YOLO | `/mode yolo` |
|
||||
| `/mode ask` | Switch to Ask mode | `/mode ask` |
|
||||
| `/mode auto` | Switch to Auto mode | `/mode auto` |
|
||||
| `/queue status` | Show the current queue mode | `/queue status` |
|
||||
| `/queue steer` | Treat mid-run messages as guidance for the current task | `/queue steer` |
|
||||
| `/queue followup` | Queue mid-run messages as later turns | `/queue followup` |
|
||||
| `/queue collect` | Merge queued messages into one later turn | `/queue collect` |
|
||||
| `/queue interrupt` | Cancel the current task and keep the newest message | `/queue interrupt` |
|
||||
| `/queue list` / `/queue show <n\|id>` | Inspect durable queued work | `/queue list` |
|
||||
| `/queue delete <n\|id>` / `/queue move <n\|id> <to>` | Delete or reorder pending work | `/queue move 2 1` |
|
||||
| `/queue pause` / `/queue resume` | Stop or resume automatic dispatch | `/queue resume` |
|
||||
| `/queue retry <n\|id>` / `/queue refresh <n\|id>` | Retry uncertain work or refresh frozen references | `/queue retry 1` |
|
||||
| `/projects [query]` | List indexed project workspaces | `/projects reasonix` |
|
||||
| `/use project <id\|name>` | Route this remote session to an indexed project | `/use project p1` |
|
||||
| `/use project default` | Clear the project override and return to configured routing | `/use project default` |
|
||||
| `/sessions search <query>` | Search indexed desktop/bot sessions | `/sessions search release bug` |
|
||||
| `/attach session <id\|query>` | Continue this remote session from an indexed `path:` transcript | `/attach session s1` |
|
||||
| `/search all <query>` | Search file contents across indexed project roots | `/search all TODO` |
|
||||
|
||||
Shortcut replies:
|
||||
|
||||
- When an approval is pending, reply `1` to approve and `2` to deny.
|
||||
- When an Ask question is pending, reply with any normal non-slash text. Option
|
||||
numbers still work for choice questions.
|
||||
- Slash commands such as `/stop`, `/mode`, or `/answer ...` are not captured as
|
||||
Ask shortcut replies.
|
||||
- If there is no pending operation, `1` / `2` are treated as normal text or
|
||||
produce guidance.
|
||||
|
||||
The default queue mode is `steer`: when the same session is already running, a
|
||||
new message is persisted before it is injected as mid-turn guidance. If the
|
||||
active turn cannot accept it, the same durable item remains a follow-up.
|
||||
Capacity is fail-closed at 64 items, 4 MiB per item, and 64 MiB per session;
|
||||
`queue_drop` no longer removes older work. After recovery the inbox stays
|
||||
paused until `/queue resume`. `reasonix bot doctor --deep` reports queue,
|
||||
pairing, and role diagnostics.
|
||||
|
||||
Queue modes:
|
||||
|
||||
- `steer`: mid-run messages become guidance for the current turn when possible.
|
||||
- `followup`: mid-run messages are queued as later turns.
|
||||
- `collect`: queued messages are merged into one later turn.
|
||||
- `interrupt`: the active turn is canceled and the newest message is kept as the
|
||||
next turn.
|
||||
|
||||
Project and session navigation:
|
||||
|
||||
- `/projects [query]` lists workspaces from configured bot routes, connection
|
||||
workspaces, active bot sessions, and saved session mappings.
|
||||
- `/use project <id|name>` pins the current remote session to one indexed
|
||||
project. `/use project default` clears the override.
|
||||
- `/sessions search <query>` searches indexed desktop and bot session metadata.
|
||||
- `/attach session <id|query>` continues the remote session from an indexed
|
||||
`path:` transcript.
|
||||
- `/search all <query>` searches file contents across indexed project roots.
|
||||
Reasonix uses `rg` when available and falls back to a bounded Go scanner.
|
||||
|
||||
These navigation commands never accept arbitrary paths typed from IM. They only
|
||||
jump to indexed targets and, when role lists are configured, require an admin.
|
||||
|
||||
When an adapter supplies media URLs, the gateway downloads those files into the
|
||||
current workspace's `.reasonix/attachments` directory and passes them to
|
||||
Reasonix as `@.reasonix/attachments/...` references. If an attachment cannot be
|
||||
saved, the bot sends a short warning and continues with the available text. The
|
||||
built-in Feishu, Weixin, and QQ adapters currently focus on text events; ordinary
|
||||
IM attachment extraction can be added at the adapter layer.
|
||||
|
||||
## Approvals and YOLO
|
||||
|
||||
Reasonix bots use the same permission system as the desktop app. Ask mode is the
|
||||
default: sensitive tool calls such as file writes and shell commands request
|
||||
confirmation first.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Model prepares a tool call"] --> B{"Matches a deny rule?"}
|
||||
B -- "Yes" --> C["Block immediately"]
|
||||
B -- "No" --> D{"Tool approval mode"}
|
||||
D -- "Ask" --> E["Send approval to IM"]
|
||||
D -- "Auto" --> F["Auto-allow when policy permits"]
|
||||
D -- "YOLO" --> G["Skip ordinary tool approvals"]
|
||||
E --> H{"User choice"}
|
||||
H -- "Allow" --> I["Run tool"]
|
||||
H -- "Deny" --> J["Stop that operation"]
|
||||
F --> I
|
||||
G --> I
|
||||
```
|
||||
|
||||
YOLO boundaries:
|
||||
|
||||
- YOLO skips ordinary tool approval prompts.
|
||||
- YOLO does not bypass hard `deny` rules.
|
||||
- YOLO does not answer model Ask questions for you.
|
||||
- YOLO does not approve plan-mode plan approvals for you.
|
||||
|
||||
Recommendations:
|
||||
|
||||
- Use `/yolo` for temporary trusted debugging or fast local iteration.
|
||||
- Use `/mode ask` for risky work, production code, or anything uncertain.
|
||||
- Use `/mode auto` when you want fewer routine prompts while keeping policy
|
||||
decisions.
|
||||
|
||||
## Do upgrades require rebinding?
|
||||
|
||||
No. A normal Reasonix app upgrade or overwrite install does not require
|
||||
rebinding.
|
||||
|
||||
Bindings are stored in the user's Reasonix data, not inside the app bundle:
|
||||
|
||||
- Bot connections, remote IDs, allowlists, model choices, and approval modes are
|
||||
stored in the user config.
|
||||
- Feishu and Lark secrets are stored in Reasonix's global
|
||||
`<Reasonix home>/.env`, shared by CLI and desktop.
|
||||
- The WeChat scanned account token is stored in the Reasonix user data
|
||||
directory.
|
||||
- The QQ App ID is stored in user config; the App Secret is stored under the
|
||||
configured env var, `QQ_BOT_APP_SECRET` by default, in the global credentials
|
||||
file.
|
||||
|
||||
You may need to bind again if:
|
||||
|
||||
- The Reasonix user config directory was deleted.
|
||||
- You changed machines or OS users.
|
||||
- Authorization was revoked on the platform side.
|
||||
- The WeChat token expired.
|
||||
- Feishu or Lark app secrets were cleared.
|
||||
- The QQ App ID changed, or the configured QQ App Secret env var was cleared.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | What to check |
|
||||
| --- | --- |
|
||||
| QR code says the link expired | Generate a new QR code in Settings; QR codes expire (Feishu, Lark, WeChat only — QQ uses manual setup and has no QR code). |
|
||||
| Connected but no reply | Make sure the desktop bot runtime or `reasonix bot start` process is running, the bot connection is enabled, and the sender ID is allowlisted, paired, or access is open. |
|
||||
| Feishu or Lark button action fails | Send the text command from the card, such as `/approve <id>` or `/deny <id>`. |
|
||||
| QQ button action fails | Same as Feishu/Lark — send the text command from the card, such as `/approve <id>` or `/deny <id>`. |
|
||||
| WeChat reply `1` does nothing | Numeric shortcuts only work when an approval or Ask is pending; use the full command if needed. |
|
||||
| QQ reply `1` does nothing | Same as WeChat — numeric shortcuts only work when an approval or Ask is pending; use the full command if needed. |
|
||||
| Need to confirm the current mode | Send `/status` or `/yolo status`. |
|
||||
| Need a fresh context | Send `/new` or `/reset`. |
|
||||
| Need to stop the current task | Send `/stop`. |
|
||||
|
||||
If connectivity still fails, open the connection's advanced settings in
|
||||
**Settings -> Bots** and use the configuration check, test send, and runtime
|
||||
settings to locate the issue.
|
||||
@@ -64,10 +64,13 @@ mean `<Reasonix home>/config.toml`.
|
||||
|
||||
`<Reasonix home>/config.toml` stores non-secret configuration shared by the CLI
|
||||
and desktop app. It may contain the same provider, plugin, UI, desktop, tool,
|
||||
skill, sandbox, bot, and agent settings that Reasonix renders into user config.
|
||||
skill, sandbox, and agent settings that Reasonix renders into user config.
|
||||
Provider entries store the name of the credential variable in `api_key_env`, not
|
||||
the secret value.
|
||||
|
||||
A `[bot]` table written by a 1.x install is kept as written when the file is
|
||||
saved; this line does not run the IM bot gateway.
|
||||
|
||||
Saved provider and bot credential variables are removed from every
|
||||
model-controlled child-process environment. The global credential `.env` is
|
||||
also hidden from Reasonix's file readers, sandboxed shell commands, and MCP
|
||||
|
||||
+1
-2
@@ -1349,8 +1349,7 @@ Existing `[agent].max_steps` and `planner_max_steps` keys remain syntactically
|
||||
accepted during upgrades, but their values are ignored and removed with a
|
||||
one-time notice. This prevents a stale hidden limit from truncating automatic
|
||||
progress or inherited subagent work. Use the one-off CLI `--max-steps` flag when
|
||||
an explicit run budget is needed; unattended bots retain `[bot].max_steps`,
|
||||
where `0` means continuous execution and a positive value is explicit.
|
||||
an explicit run budget is needed.
|
||||
|
||||
**An ordinary chat task has no limit of any kind by default** — not rounds, not
|
||||
tokens, not time, not money. It runs until the model finishes, an adaptive
|
||||
|
||||
+1
-2
@@ -1042,8 +1042,7 @@ Reasonix 会自动管理正常执行:活跃 Todo 连续 8 个工具调用轮
|
||||
|
||||
升级时仍可解析已有的 `[agent].max_steps` 和 `planner_max_steps`,但其值会被忽略,并在一次性
|
||||
迁移提示后从配置中移除,避免隐藏的旧上限截断自动进度管理或子 Agent 的继承任务。确实需要
|
||||
为单次运行设置预算时使用 CLI `--max-steps`;无人值守 Bot 仍保留 `[bot].max_steps`,其中 `0`
|
||||
表示自动持续执行,正数表示用户显式上限。
|
||||
为单次运行设置预算时使用 CLI `--max-steps`。
|
||||
|
||||
**普通对话任务默认没有任何上限**——轮数、token、时长、花费都不限。它一直跑到模型自己
|
||||
结束、自适应守卫判定它不再产生进展,或者你手动停止为止。
|
||||
|
||||
+2
-2
@@ -1191,8 +1191,8 @@ complete in order, and the phase becomes `in_progress` — and signs off — onl
|
||||
after all of its sub-steps have completed. A level-1 item with no phase above
|
||||
it is rejected. Retired `[agent].max_steps` and `planner_max_steps` keys remain
|
||||
parseable for upgrade compatibility, but are ignored and removed by a one-time
|
||||
migration. The CLI `--max-steps` flag and `[bot].max_steps` remain separate,
|
||||
explicit controls for one-off and unattended execution; bot `0` means continuous.
|
||||
migration. The CLI `--max-steps` flag remains the explicit control for one-off
|
||||
execution.
|
||||
|
||||
`reasonix setup` writes this default config so the CLI is usable out of the box.
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ What is **not** done:
|
||||
prompt changes, no configuration.
|
||||
- Previews are never persisted: after a restart the complete sub-agent
|
||||
transcript (and `read_subagent_result`) remains the source of truth.
|
||||
- ACP and bot consumers keep ignoring `ToolProgress` bodies entirely.
|
||||
- ACP consumers keep ignoring `ToolProgress` bodies entirely.
|
||||
|
||||
## Desktop
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Feishu bot approval interaction</title>
|
||||
<desc id="desc">A synthetic Feishu chat showing a Reasonix bot approval card with allow and deny buttons.</desc>
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#111827" flood-opacity="0.14"/>
|
||||
</filter>
|
||||
<linearGradient id="blue" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#4f7cff"/>
|
||||
<stop offset="1" stop-color="#2f6bff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1280" height="720" rx="0" fill="#f6f4ef"/>
|
||||
<text x="72" y="82" fill="#15171a" font-family="Inter, Arial, sans-serif" font-size="34" font-weight="750">Feishu approval card</text>
|
||||
<text x="72" y="118" fill="#69707d" font-family="Inter, Arial, sans-serif" font-size="18">When a sensitive tool needs permission, Reasonix sends a card with clear actions.</text>
|
||||
|
||||
<rect x="72" y="160" width="736" height="480" rx="24" fill="#ffffff" filter="url(#shadow)"/>
|
||||
<rect x="72" y="160" width="736" height="58" rx="24" fill="#f4f7ff"/>
|
||||
<circle cx="112" cy="189" r="15" fill="#3370ff"/>
|
||||
<text x="140" y="197" fill="#20242b" font-family="Inter, Arial, sans-serif" font-size="20" font-weight="700">Feishu chat</text>
|
||||
|
||||
<rect x="548" y="252" width="202" height="62" rx="18" fill="#e8f1ff"/>
|
||||
<text x="579" y="290" fill="#1f2a44" font-family="Inter, Arial, sans-serif" font-size="19">Run the tests</text>
|
||||
|
||||
<circle cx="126" cy="388" r="22" fill="url(#blue)"/>
|
||||
<text x="117" y="397" fill="#ffffff" font-family="Inter, Arial, sans-serif" font-size="22" font-weight="760">R</text>
|
||||
<rect x="164" y="334" width="500" height="220" rx="18" fill="#f4f5f7" stroke="#e1e4ea"/>
|
||||
<text x="196" y="376" fill="#101418" font-family="Inter, Arial, sans-serif" font-size="24" font-weight="760">Approval required</text>
|
||||
<text x="196" y="416" fill="#4f5663" font-family="Inter, Arial, sans-serif" font-size="18">Tool: bash</text>
|
||||
<text x="196" y="448" fill="#4f5663" font-family="Inter, Arial, sans-serif" font-size="18">Action: go test ./...</text>
|
||||
<rect x="196" y="470" width="96" height="30" rx="9" fill="#ffffff" stroke="#d7dbe3"/>
|
||||
<text x="214" y="491" fill="#313844" font-family="Menlo, Consolas, monospace" font-size="15">ID: 42</text>
|
||||
<rect x="196" y="516" width="142" height="42" rx="12" fill="#2f6bff"/>
|
||||
<text x="222" y="543" fill="#ffffff" font-family="Inter, Arial, sans-serif" font-size="17" font-weight="700">Allow once</text>
|
||||
<rect x="354" y="516" width="108" height="42" rx="12" fill="#ffffff" stroke="#ff4d4f" stroke-width="2"/>
|
||||
<text x="389" y="543" fill="#d92d2b" font-family="Inter, Arial, sans-serif" font-size="17" font-weight="700">Deny</text>
|
||||
|
||||
<rect x="852" y="202" width="356" height="398" rx="24" fill="#15171a"/>
|
||||
<text x="890" y="258" fill="#ffffff" font-family="Inter, Arial, sans-serif" font-size="26" font-weight="750">What the user does</text>
|
||||
<circle cx="902" cy="314" r="6" fill="#52c41a"/>
|
||||
<text x="922" y="322" fill="#e9eef8" font-family="Inter, Arial, sans-serif" font-size="18">Tap Allow once to continue this tool call.</text>
|
||||
<circle cx="902" cy="374" r="6" fill="#ff4d4f"/>
|
||||
<text x="922" y="382" fill="#e9eef8" font-family="Inter, Arial, sans-serif" font-size="18">Tap Deny to stop this operation.</text>
|
||||
<circle cx="902" cy="434" r="6" fill="#ffaa33"/>
|
||||
<text x="922" y="442" fill="#e9eef8" font-family="Inter, Arial, sans-serif" font-size="18">If the card expires, send the text command.</text>
|
||||
<rect x="890" y="492" width="278" height="52" rx="14" fill="#242933"/>
|
||||
<text x="914" y="525" fill="#ffffff" font-family="Menlo, Consolas, monospace" font-size="18">/approve 42</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,47 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Lark bot YOLO mode interaction</title>
|
||||
<desc id="desc">A synthetic Lark chat showing the user enabling YOLO and Reasonix confirming that ordinary tool approvals are skipped.</desc>
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#111827" flood-opacity="0.15"/>
|
||||
</filter>
|
||||
<linearGradient id="orange" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#ff7a45"/>
|
||||
<stop offset="1" stop-color="#ff4d2e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1280" height="720" fill="#f7f6f3"/>
|
||||
<text x="72" y="82" fill="#15171a" font-family="Inter, Arial, sans-serif" font-size="34" font-weight="750">Lark YOLO mode</text>
|
||||
<text x="72" y="118" fill="#69707d" font-family="Inter, Arial, sans-serif" font-size="18">Turn on YOLO from chat when you trust the task and want fewer approval prompts.</text>
|
||||
|
||||
<rect x="72" y="158" width="760" height="500" rx="24" fill="#ffffff" filter="url(#shadow)"/>
|
||||
<rect x="72" y="158" width="760" height="58" rx="24" fill="#fdf1ec"/>
|
||||
<circle cx="113" cy="188" r="15" fill="#ff5b3a"/>
|
||||
<text x="140" y="197" fill="#20242b" font-family="Inter, Arial, sans-serif" font-size="20" font-weight="700">Lark chat</text>
|
||||
|
||||
<rect x="560" y="248" width="194" height="58" rx="18" fill="#eaf2ff"/>
|
||||
<text x="612" y="284" fill="#1f2a44" font-family="Menlo, Consolas, monospace" font-size="20">/yolo</text>
|
||||
|
||||
<circle cx="126" cy="386" r="22" fill="url(#orange)"/>
|
||||
<text x="117" y="395" fill="#ffffff" font-family="Inter, Arial, sans-serif" font-size="22" font-weight="760">R</text>
|
||||
<rect x="164" y="330" width="548" height="120" rx="18" fill="#f4f5f7" stroke="#e1e4ea"/>
|
||||
<text x="196" y="372" fill="#101418" font-family="Inter, Arial, sans-serif" font-size="23" font-weight="760">YOLO enabled</text>
|
||||
<text x="196" y="410" fill="#4f5663" font-family="Inter, Arial, sans-serif" font-size="18">Ordinary tool approvals will be skipped.</text>
|
||||
<text x="196" y="438" fill="#4f5663" font-family="Inter, Arial, sans-serif" font-size="18">Ask questions and plan approvals still wait.</text>
|
||||
|
||||
<rect x="474" y="492" width="280" height="58" rx="18" fill="#eaf2ff"/>
|
||||
<text x="502" y="528" fill="#1f2a44" font-family="Inter, Arial, sans-serif" font-size="19">Fix the lint errors</text>
|
||||
|
||||
<rect x="164" y="582" width="500" height="42" rx="13" fill="#f4f5f7" stroke="#e1e4ea"/>
|
||||
<text x="196" y="609" fill="#4f5663" font-family="Inter, Arial, sans-serif" font-size="17">Running tools: explore -> edit -> test</text>
|
||||
|
||||
<rect x="878" y="210" width="330" height="360" rx="24" fill="#15171a"/>
|
||||
<text x="914" y="266" fill="#ffffff" font-family="Inter, Arial, sans-serif" font-size="26" font-weight="750">Useful commands</text>
|
||||
<rect x="914" y="306" width="244" height="42" rx="12" fill="#242933"/>
|
||||
<text x="936" y="333" fill="#ffffff" font-family="Menlo, Consolas, monospace" font-size="18">/yolo on</text>
|
||||
<rect x="914" y="366" width="244" height="42" rx="12" fill="#242933"/>
|
||||
<text x="936" y="393" fill="#ffffff" font-family="Menlo, Consolas, monospace" font-size="18">/mode ask</text>
|
||||
<rect x="914" y="426" width="244" height="42" rx="12" fill="#242933"/>
|
||||
<text x="936" y="453" fill="#ffffff" font-family="Menlo, Consolas, monospace" font-size="18">/status</text>
|
||||
<text x="914" y="514" fill="#b8c0cc" font-family="Inter, Arial, sans-serif" font-size="17">Use Ask mode again for risky work.</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB |
@@ -1,48 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-labelledby="title desc">
|
||||
<title id="title">QQ bot approval interaction</title>
|
||||
<desc id="desc">A synthetic QQ conversation showing a Reasonix approval prompt with inline keyboard buttons and text command fallback.</desc>
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#111827" flood-opacity="0.14"/>
|
||||
</filter>
|
||||
<linearGradient id="qqblue" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#12b7f5"/>
|
||||
<stop offset="1" stop-color="#2378ff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1280" height="720" fill="#f6f4ef"/>
|
||||
<text x="72" y="82" fill="#15171a" font-family="Inter, Arial, sans-serif" font-size="34" font-weight="750">QQ approval buttons</text>
|
||||
<text x="72" y="118" fill="#69707d" font-family="Inter, Arial, sans-serif" font-size="18">QQ uses an inline keyboard for approvals, with numeric replies and slash commands as fallback.</text>
|
||||
|
||||
<rect x="88" y="152" width="520" height="512" rx="34" fill="#ffffff" filter="url(#shadow)"/>
|
||||
<rect x="88" y="152" width="520" height="70" rx="34" fill="#f7f9fc"/>
|
||||
<circle cx="132" cy="187" r="18" fill="url(#qqblue)"/>
|
||||
<text x="123" y="196" fill="#ffffff" font-family="Inter, Arial, sans-serif" font-size="22" font-weight="760">Q</text>
|
||||
<text x="164" y="196" fill="#20242b" font-family="Inter, Arial, sans-serif" font-size="24" font-weight="750">QQ Bot</text>
|
||||
|
||||
<rect x="134" y="262" width="386" height="154" rx="18" fill="#f0f3f8" stroke="#e2e7ef"/>
|
||||
<text x="164" y="304" fill="#101418" font-family="Inter, Arial, sans-serif" font-size="22" font-weight="750">Approval required</text>
|
||||
<text x="164" y="338" fill="#4f5663" font-family="Inter, Arial, sans-serif" font-size="17">Tool: bash</text>
|
||||
<text x="164" y="368" fill="#4f5663" font-family="Inter, Arial, sans-serif" font-size="17">Action: go test ./...</text>
|
||||
<rect x="164" y="386" width="86" height="28" rx="9" fill="#ffffff" stroke="#d7dbe3"/>
|
||||
<text x="181" y="406" fill="#313844" font-family="Menlo, Consolas, monospace" font-size="14">ID: 42</text>
|
||||
|
||||
<rect x="164" y="442" width="146" height="46" rx="13" fill="#2378ff"/>
|
||||
<text x="197" y="472" fill="#ffffff" font-family="Inter, Arial, sans-serif" font-size="17" font-weight="700">Allow once</text>
|
||||
<rect x="326" y="442" width="102" height="46" rx="13" fill="#ffffff" stroke="#f05b57" stroke-width="2"/>
|
||||
<text x="360" y="472" fill="#d93025" font-family="Inter, Arial, sans-serif" font-size="17" font-weight="700">Deny</text>
|
||||
|
||||
<rect x="414" y="536" width="90" height="54" rx="18" fill="#dff1ff"/>
|
||||
<text x="450" y="570" fill="#104d88" font-family="Inter, Arial, sans-serif" font-size="24" font-weight="760">1</text>
|
||||
|
||||
<rect x="672" y="202" width="536" height="398" rx="24" fill="#15171a"/>
|
||||
<text x="714" y="258" fill="#ffffff" font-family="Inter, Arial, sans-serif" font-size="26" font-weight="750">Fallback commands</text>
|
||||
<circle cx="728" cy="314" r="6" fill="#52c41a"/>
|
||||
<text x="748" y="322" fill="#e9eef8" font-family="Inter, Arial, sans-serif" font-size="18">Tap Allow once, reply 1, or send /approve 42.</text>
|
||||
<circle cx="728" cy="374" r="6" fill="#ff4d4f"/>
|
||||
<text x="748" y="382" fill="#e9eef8" font-family="Inter, Arial, sans-serif" font-size="18">Tap Deny, reply 2, or send /deny 42.</text>
|
||||
<circle cx="728" cy="434" r="6" fill="#ffaa33"/>
|
||||
<text x="748" y="442" fill="#e9eef8" font-family="Inter, Arial, sans-serif" font-size="18">For Ask prompts, reply with an option number.</text>
|
||||
<rect x="714" y="492" width="330" height="52" rx="14" fill="#242933"/>
|
||||
<text x="738" y="525" fill="#ffffff" font-family="Menlo, Consolas, monospace" font-size="18">/answer ask-1 2</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,36 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-labelledby="title desc">
|
||||
<title id="title">WeChat bot text command interaction</title>
|
||||
<desc id="desc">A synthetic WeChat conversation showing Reasonix approval through numeric replies and slash commands.</desc>
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#111827" flood-opacity="0.14"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="1280" height="720" fill="#f6f4ef"/>
|
||||
<text x="72" y="82" fill="#15171a" font-family="Inter, Arial, sans-serif" font-size="34" font-weight="750">WeChat text commands</text>
|
||||
<text x="72" y="118" fill="#69707d" font-family="Inter, Arial, sans-serif" font-size="18">WeChat uses text replies: reply 1 or 2 for pending approvals, or send slash commands.</text>
|
||||
|
||||
<rect x="88" y="152" width="488" height="512" rx="34" fill="#ffffff" filter="url(#shadow)"/>
|
||||
<rect x="88" y="152" width="488" height="70" rx="34" fill="#f7f8fa"/>
|
||||
<text x="132" y="196" fill="#20242b" font-family="Inter, Arial, sans-serif" font-size="24" font-weight="750">Bot Assistant</text>
|
||||
<rect x="130" y="268" width="360" height="132" rx="18" fill="#f0f1f4"/>
|
||||
<text x="158" y="306" fill="#101418" font-family="Inter, Arial, sans-serif" font-size="21" font-weight="750">Approval required</text>
|
||||
<text x="158" y="340" fill="#4f5663" font-family="Inter, Arial, sans-serif" font-size="17">Tool: explore</text>
|
||||
<text x="158" y="370" fill="#4f5663" font-family="Inter, Arial, sans-serif" font-size="17">ID: 7 Reply 1 approve, 2 deny</text>
|
||||
|
||||
<rect x="420" y="438" width="72" height="54" rx="18" fill="#95ec69"/>
|
||||
<text x="452" y="472" fill="#0f2912" font-family="Inter, Arial, sans-serif" font-size="24" font-weight="760">1</text>
|
||||
<rect x="130" y="532" width="250" height="62" rx="18" fill="#f0f1f4"/>
|
||||
<text x="158" y="570" fill="#101418" font-family="Inter, Arial, sans-serif" font-size="20">Approved. Continuing.</text>
|
||||
|
||||
<rect x="642" y="202" width="566" height="398" rx="24" fill="#15171a"/>
|
||||
<text x="684" y="258" fill="#ffffff" font-family="Inter, Arial, sans-serif" font-size="26" font-weight="750">Text fallback works everywhere</text>
|
||||
<circle cx="698" cy="314" r="6" fill="#52c41a"/>
|
||||
<text x="718" y="322" fill="#e9eef8" font-family="Inter, Arial, sans-serif" font-size="18">Approval: reply 1 or send /approve 7.</text>
|
||||
<circle cx="698" cy="374" r="6" fill="#ff4d4f"/>
|
||||
<text x="718" y="382" fill="#e9eef8" font-family="Inter, Arial, sans-serif" font-size="18">Deny: reply 2 or send /deny 7.</text>
|
||||
<circle cx="698" cy="434" r="6" fill="#ffaa33"/>
|
||||
<text x="718" y="442" fill="#e9eef8" font-family="Inter, Arial, sans-serif" font-size="18">Ask: send /answer ask-1 2.</text>
|
||||
<rect x="684" y="494" width="418" height="52" rx="14" fill="#242933"/>
|
||||
<text x="710" y="527" fill="#ffffff" font-family="Menlo, Consolas, monospace" font-size="18">/yolo status</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -23,7 +23,7 @@ type recoveryIdentity struct {
|
||||
|
||||
// RecoveryGate is the host-side Auto Guard consulted by the agent around tool
|
||||
// execution. It is independent of the permission Gate and of
|
||||
// how the Controller surfaces confirmations (desktop card, bot prompt, headless
|
||||
// how the Controller surfaces confirmations (desktop card, terminal prompt, headless
|
||||
// blocker). A nil gate means the feature is off for this agent.
|
||||
//
|
||||
// ObserveResult runs after a tool result is produced. BeforeMutation also checks
|
||||
|
||||
@@ -323,7 +323,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
level := event.LevelInfo
|
||||
text := "Deprecated agent step limits were removed."
|
||||
detail := "[agent].max_steps and planner_max_steps are no longer used; Reasonix now manages interactive progress automatically. " +
|
||||
"Use the CLI --max-steps flag for a one-off run or [bot].max_steps for unattended bot sessions."
|
||||
"Use the CLI --max-steps flag for a one-off run."
|
||||
if stepLimitMigErr != nil {
|
||||
level = event.LevelWarn
|
||||
text = "Deprecated agent step limits were ignored."
|
||||
@@ -1081,7 +1081,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
}
|
||||
// HeadlessApprovalMode is an explicit declaration that this frontend has
|
||||
// no decision channel (`reasonix run`). ApprovalTimeout is not a proxy for
|
||||
// that capability: bots have a bounded timeout and can still answer cards.
|
||||
// that capability: a frontend can bound the wait and still answer prompts.
|
||||
ctrlOpts.RecoveryHeadless = recoveryHeadlessMode(opts)
|
||||
}
|
||||
// Goal evaluator: the same zero-config model fallback as the recovery
|
||||
|
||||
@@ -3522,7 +3522,7 @@ api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
for _, notice := range notices {
|
||||
if notice.Text == "Deprecated agent step limits were removed." {
|
||||
migrationNotices++
|
||||
if notice.Level != event.LevelInfo || !strings.Contains(notice.Detail, "--max-steps") || !strings.Contains(notice.Detail, "[bot].max_steps") {
|
||||
if notice.Level != event.LevelInfo || !strings.Contains(notice.Detail, "--max-steps") {
|
||||
t.Fatalf("migration notice = %+v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ type Options struct {
|
||||
// reconciler; frontends with different deletion semantics can override it.
|
||||
CleanupPendingReconciler func(sessionDir string) error
|
||||
// Bounds how long an approval or ask prompt blocks. Zero waits forever,
|
||||
// correct for a terminal; headless/bot frontends pass a positive value so
|
||||
// correct for a terminal; headless frontends pass a positive value so
|
||||
// an unanswered prompt cannot wedge the session (#4626, #4402).
|
||||
ApprovalTimeout time.Duration
|
||||
// The non-interactive approval contract for every headless gate this build
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
package bot
|
||||
|
||||
// What each adapter's connection is doing, recorded as it happens so a status
|
||||
// command can answer without asking the platform.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (gw *BotGateway) AdapterCount() int {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
return len(gw.adapters)
|
||||
}
|
||||
|
||||
func (gw *BotGateway) StartErrors() []error {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
out := make([]error, len(gw.startErr))
|
||||
copy(out, gw.startErr)
|
||||
return out
|
||||
}
|
||||
|
||||
// AdapterHealth returns a stable snapshot of all configured adapter instances.
|
||||
func (gw *BotGateway) AdapterHealth() []AdapterHealthSnapshot {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
out := make([]AdapterHealthSnapshot, 0, len(gw.adapterHealth))
|
||||
for _, health := range gw.adapterHealth {
|
||||
if health == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, *health)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
func (gw *BotGateway) setAdapterConfigured(binding AdapterBinding) {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
gw.ensureAdapterHealthLocked(binding).Status = "configured"
|
||||
}
|
||||
|
||||
func (gw *BotGateway) markAdapterDisabled(binding AdapterBinding) {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
health := gw.ensureAdapterHealthLocked(binding)
|
||||
health.Status = "disabled"
|
||||
health.Closed = true
|
||||
}
|
||||
|
||||
func (gw *BotGateway) markAdapterStarted(binding AdapterBinding) {
|
||||
now := time.Now()
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
health := gw.ensureAdapterHealthLocked(binding)
|
||||
health.Status = "running"
|
||||
health.StartedAt = now
|
||||
health.LastError = ""
|
||||
health.Closed = false
|
||||
}
|
||||
|
||||
func (gw *BotGateway) markAdapterStartFailed(binding AdapterBinding, err error) {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
health := gw.ensureAdapterHealthLocked(binding)
|
||||
health.Status = "error"
|
||||
health.Closed = true
|
||||
health.LastErrorAt = time.Now()
|
||||
if err != nil {
|
||||
health.LastError = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *BotGateway) markAdapterMessage(binding AdapterBinding) {
|
||||
now := time.Now()
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
health := gw.ensureAdapterHealthLocked(binding)
|
||||
health.Status = "running"
|
||||
health.LastMessageAt = now
|
||||
health.Messages++
|
||||
health.Closed = false
|
||||
}
|
||||
|
||||
func (gw *BotGateway) markAdapterClosed(binding AdapterBinding) {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
health := gw.ensureAdapterHealthLocked(binding)
|
||||
if health.Status == "running" {
|
||||
health.Status = "closed"
|
||||
}
|
||||
health.Closed = true
|
||||
}
|
||||
|
||||
func (gw *BotGateway) markAdapterSend(binding AdapterBinding, err error) {
|
||||
now := time.Now()
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
health := gw.ensureAdapterHealthLocked(binding)
|
||||
if err != nil {
|
||||
health.SendErrors++
|
||||
health.LastErrorAt = now
|
||||
health.LastError = err.Error()
|
||||
if health.Status == "running" {
|
||||
health.Status = "degraded"
|
||||
}
|
||||
return
|
||||
}
|
||||
health.Sends++
|
||||
health.LastSendAt = now
|
||||
if health.Status == "degraded" {
|
||||
health.Status = "running"
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *BotGateway) ensureAdapterHealthLocked(binding AdapterBinding) *AdapterHealthSnapshot {
|
||||
id := strings.TrimSpace(binding.ID)
|
||||
if id == "" && binding.Adapter != nil {
|
||||
id = binding.Adapter.Name()
|
||||
}
|
||||
if id == "" {
|
||||
id = string(binding.Platform)
|
||||
}
|
||||
health := gw.adapterHealth[id]
|
||||
if health == nil {
|
||||
health = &AdapterHealthSnapshot{ID: id}
|
||||
gw.adapterHealth[id] = health
|
||||
}
|
||||
health.Platform = binding.Platform
|
||||
health.Domain = strings.TrimSpace(binding.Domain)
|
||||
if binding.Adapter != nil {
|
||||
health.Name = binding.Adapter.Name()
|
||||
}
|
||||
if strings.TrimSpace(health.Status) == "" {
|
||||
health.Status = "configured"
|
||||
}
|
||||
return health
|
||||
}
|
||||
|
||||
func (gw *BotGateway) adapterHealthSummaryText() string {
|
||||
snapshots := gw.AdapterHealth()
|
||||
if len(snapshots) == 0 {
|
||||
return "未启动"
|
||||
}
|
||||
parts := make([]string, 0, len(snapshots))
|
||||
for _, h := range snapshots {
|
||||
label := strings.TrimSpace(h.ID)
|
||||
if label == "" {
|
||||
label = string(h.Platform)
|
||||
}
|
||||
status := strings.TrimSpace(h.Status)
|
||||
if status == "" {
|
||||
status = "unknown"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", label, status))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package bot
|
||||
|
||||
// Answering a pending approval or ask by replying to its message: which reply
|
||||
// counts as which answer, and which pending question a reply is answering.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reasonix/internal/event"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (gw *BotGateway) normalizeApprovalShortcut(key, text string) (string, bool) {
|
||||
approvalID := gw.currentPendingApprovalID(key)
|
||||
if approvalID == "" {
|
||||
return "", false
|
||||
}
|
||||
if gw.pendingApprovalIsRecovery(key, approvalID) {
|
||||
if command, ok := recoveryShortcutCommand(text, gw.pendingRecoveryCanGrantTask(key, approvalID)); ok {
|
||||
return command + " " + approvalID, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
command, ok := approvalShortcutCommand(text)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return command + " " + approvalID, true
|
||||
}
|
||||
|
||||
func approvalShortcutCommand(text string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(text)) {
|
||||
case "1", "y", "yes", "ok", "同意", "批准", "允许", "允许一次":
|
||||
return "/approve", true
|
||||
case "2", "0", "n", "no", "deny", "拒绝":
|
||||
return "/deny", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func recoveryShortcutCommand(text string, canGrantTask bool) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(text)) {
|
||||
case "1", "y", "yes", "ok", "继续", "继续此变更", "continue":
|
||||
return "/recovery-continue", true
|
||||
case "2", "a", "同类", "本任务允许", "allow similar":
|
||||
if canGrantTask {
|
||||
return "/recovery-continue-task", true
|
||||
}
|
||||
return "/recovery-revise", true
|
||||
case "3":
|
||||
if canGrantTask {
|
||||
return "/recovery-revise", true
|
||||
}
|
||||
return "", false
|
||||
case "修改", "修改方案", "换个办法", "revise":
|
||||
return "/recovery-revise", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *BotGateway) pendingRecoveryCanGrantTask(key, id string) bool {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
state, ok := gw.controllers[key]
|
||||
if !ok || state.pendingApprovals == nil {
|
||||
return false
|
||||
}
|
||||
a, ok := state.pendingApprovals[id]
|
||||
return ok && a.Recovery != nil && a.Recovery.CanGrantTask
|
||||
}
|
||||
|
||||
func (gw *BotGateway) pendingApprovalIsRecovery(key, id string) bool {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
state, ok := gw.controllers[key]
|
||||
if !ok || state.pendingApprovals == nil {
|
||||
return false
|
||||
}
|
||||
a, ok := state.pendingApprovals[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(a.Kind), "recovery") || a.Recovery != nil
|
||||
}
|
||||
|
||||
func decisionShortcutCommand(text string) (string, bool) {
|
||||
if command, ok := approvalShortcutCommand(text); ok {
|
||||
return command, true
|
||||
}
|
||||
if _, ok := askShortcutAnswer(text); ok {
|
||||
return "/answer", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (gw *BotGateway) currentPendingApprovalID(key string) string {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
state, ok := gw.controllers[key]
|
||||
if !ok || len(state.pendingApprovals) == 0 {
|
||||
return ""
|
||||
}
|
||||
if state.lastApprovalID != "" {
|
||||
if _, ok := state.pendingApprovals[state.lastApprovalID]; ok {
|
||||
return state.lastApprovalID
|
||||
}
|
||||
}
|
||||
for id := range state.pendingApprovals {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (gw *BotGateway) forgetPendingApproval(key, id string) {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
state, ok := gw.controllers[key]
|
||||
if !ok || state.pendingApprovals == nil {
|
||||
return
|
||||
}
|
||||
delete(state.pendingApprovals, id)
|
||||
if state.lastApprovalID == id {
|
||||
state.lastApprovalID = ""
|
||||
for nextID := range state.pendingApprovals {
|
||||
state.lastApprovalID = nextID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *BotGateway) normalizeAskShortcut(key, text string) (string, bool) {
|
||||
raw := strings.TrimSpace(text)
|
||||
if raw == "" || strings.HasPrefix(raw, "/") {
|
||||
return "", false
|
||||
}
|
||||
askID := gw.currentPendingAskIDForReply(key)
|
||||
if askID == "" {
|
||||
return "", false
|
||||
}
|
||||
return "/answer " + askID + " " + raw, true
|
||||
}
|
||||
|
||||
func askShortcutAnswer(text string) (string, bool) {
|
||||
raw := strings.TrimSpace(text)
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(raw, " \t\n;=") {
|
||||
return "", false
|
||||
}
|
||||
if _, err := strconv.Atoi(raw); err == nil {
|
||||
return raw, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (gw *BotGateway) currentPendingAskIDForReply(key string) string {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
state, ok := gw.controllers[key]
|
||||
if !ok || len(state.pendingAsks) == 0 {
|
||||
return ""
|
||||
}
|
||||
if state.lastAskID != "" {
|
||||
if _, ok := state.pendingAsks[state.lastAskID]; ok {
|
||||
return state.lastAskID
|
||||
}
|
||||
}
|
||||
if len(state.pendingAsks) != 1 {
|
||||
return ""
|
||||
}
|
||||
for id := range state.pendingAsks {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseAskAnswers(questions []event.AskQuestion, raw string) []event.AskAnswer {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if len(questions) == 0 {
|
||||
return []event.AskAnswer{{Selected: []string{raw}}}
|
||||
}
|
||||
byID := make(map[string]*event.AskQuestion, len(questions))
|
||||
for i := range questions {
|
||||
q := &questions[i]
|
||||
byID[q.ID] = q
|
||||
byID[fmt.Sprintf("%d", i+1)] = q
|
||||
}
|
||||
answerMap := make(map[string][]string, len(questions))
|
||||
if strings.Contains(raw, "=") {
|
||||
for part := range strings.SplitSeq(raw, ";") {
|
||||
k, v, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
q := byID[strings.TrimSpace(k)]
|
||||
if q == nil {
|
||||
continue
|
||||
}
|
||||
answerMap[q.ID] = normalizeAskSelection(*q, strings.TrimSpace(v))
|
||||
}
|
||||
} else if len(questions) == 1 {
|
||||
answerMap[questions[0].ID] = normalizeAskSelection(questions[0], raw)
|
||||
}
|
||||
out := make([]event.AskAnswer, 0, len(questions))
|
||||
for _, q := range questions {
|
||||
out = append(out, event.AskAnswer{QuestionID: q.ID, Selected: answerMap[q.ID]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeAskSelection(q event.AskQuestion, raw string) []string {
|
||||
parts := []string{raw}
|
||||
if q.Multi && strings.Contains(raw, ",") {
|
||||
parts = strings.Split(raw, ",")
|
||||
}
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if idx, err := strconv.Atoi(part); err == nil && idx >= 1 && idx <= len(q.Options) {
|
||||
out = append(out, q.Options[idx-1].Label)
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file holds the shared connection-lifecycle primitives for the platform
|
||||
// adapters (qq / feishu / weixin). Each adapter previously hand-rolled the same
|
||||
// "loop forever, attempt a connection, sleep a fixed 5s on failure" shape; two of
|
||||
// them used time.Sleep, which ignores ctx and so left a Stop() blocked for the
|
||||
// remaining delay. SleepCtx fixes that; RunWithRetry folds the persistent-
|
||||
// connection reconnect loop (qq / feishu) into one cancellation-aware,
|
||||
// exponentially-backing-off helper.
|
||||
|
||||
const (
|
||||
defaultInitialDelay = 1 * time.Second
|
||||
defaultMaxDelay = 30 * time.Second
|
||||
defaultResetAfter = 60 * time.Second
|
||||
)
|
||||
|
||||
// RetryConfig controls the reconnect/error backoff for RunWithRetry. The zero
|
||||
// value uses sane defaults (1s → 30s exponential, reset after 60s up).
|
||||
type RetryConfig struct {
|
||||
// InitialDelay is the wait after the first failed/closed attempt.
|
||||
InitialDelay time.Duration
|
||||
// MaxDelay caps the exponential backoff.
|
||||
MaxDelay time.Duration
|
||||
// ResetAfter is the minimum time an attempt must stay connected for the
|
||||
// backoff to reset to InitialDelay on its next failure — so a flaky reconnect
|
||||
// escalates while a long-healthy connection that finally drops retries fast.
|
||||
ResetAfter time.Duration
|
||||
}
|
||||
|
||||
func (c RetryConfig) withDefaults() RetryConfig {
|
||||
if c.InitialDelay <= 0 {
|
||||
c.InitialDelay = defaultInitialDelay
|
||||
}
|
||||
if c.MaxDelay <= 0 {
|
||||
c.MaxDelay = defaultMaxDelay
|
||||
}
|
||||
if c.MaxDelay < c.InitialDelay {
|
||||
c.MaxDelay = c.InitialDelay
|
||||
}
|
||||
if c.ResetAfter <= 0 {
|
||||
c.ResetAfter = defaultResetAfter
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// SleepCtx waits for d or until ctx is cancelled, whichever comes first. It
|
||||
// returns true if the full duration elapsed and false if ctx was cancelled (or
|
||||
// was already cancelled on entry). Adapter loops use it instead of time.Sleep so
|
||||
// a Stop() takes effect promptly rather than blocking out the remaining delay.
|
||||
func SleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
if d <= 0 {
|
||||
return true
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// nextDelay doubles cur, capped at max. The <= 0 guard catches overflow on very
|
||||
// large durations.
|
||||
func nextDelay(cur, max time.Duration) time.Duration {
|
||||
next := cur * 2
|
||||
if next <= 0 || next > max {
|
||||
next = max
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// RunWithRetry drives a persistent-connection adapter. It calls attempt(ctx) —
|
||||
// one full connection lifetime that blocks until the connection drops or errors —
|
||||
// then waits a cancellation-aware exponential backoff and reconnects, repeating
|
||||
// until ctx is cancelled. attempt MUST honor ctx and clean up its own connection
|
||||
// before returning; it returns nil for a clean close and an error otherwise (both
|
||||
// trigger a reconnect — only ctx cancellation stops the loop).
|
||||
func RunWithRetry(ctx context.Context, log *slog.Logger, name string, cfg RetryConfig, attempt func(context.Context) error) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
cfg = cfg.withDefaults()
|
||||
delay := cfg.InitialDelay
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
err := attempt(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(name+" connection failed", "err", err)
|
||||
} else {
|
||||
log.Warn(name + " connection closed")
|
||||
}
|
||||
if time.Since(start) >= cfg.ResetAfter {
|
||||
delay = cfg.InitialDelay
|
||||
}
|
||||
if !SleepCtx(ctx, delay) {
|
||||
return
|
||||
}
|
||||
delay = nextDelay(delay, cfg.MaxDelay)
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestSleepCtxCompletes(t *testing.T) {
|
||||
if !SleepCtx(context.Background(), time.Millisecond) {
|
||||
t.Fatal("SleepCtx should return true when the full delay elapses")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSleepCtxCancelledReturnsPromptly(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
start := time.Now()
|
||||
if SleepCtx(ctx, 10*time.Second) {
|
||||
t.Fatal("SleepCtx should return false when ctx is cancelled mid-wait")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("SleepCtx ignored cancellation: waited %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSleepCtxAlreadyCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if SleepCtx(ctx, time.Second) {
|
||||
t.Fatal("SleepCtx should return false immediately when ctx is already cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextDelay(t *testing.T) {
|
||||
maxD := 30 * time.Second
|
||||
cases := []struct{ cur, want time.Duration }{
|
||||
{1 * time.Second, 2 * time.Second},
|
||||
{8 * time.Second, 16 * time.Second},
|
||||
{16 * time.Second, 30 * time.Second}, // doubling past max → capped
|
||||
{30 * time.Second, 30 * time.Second}, // stays at max
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := nextDelay(c.cur, maxD); got != c.want {
|
||||
t.Errorf("nextDelay(%v, %v) = %v, want %v", c.cur, maxD, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryConfigDefaults(t *testing.T) {
|
||||
got := RetryConfig{}.withDefaults()
|
||||
if got.InitialDelay != defaultInitialDelay || got.MaxDelay != defaultMaxDelay || got.ResetAfter != defaultResetAfter {
|
||||
t.Fatalf("zero RetryConfig defaults = %+v", got)
|
||||
}
|
||||
// MaxDelay below InitialDelay is clamped up to InitialDelay.
|
||||
got = RetryConfig{InitialDelay: 5 * time.Second, MaxDelay: time.Second}.withDefaults()
|
||||
if got.MaxDelay != 5*time.Second {
|
||||
t.Fatalf("MaxDelay should clamp up to InitialDelay, got %v", got.MaxDelay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithRetryNotCalledWhenContextAlreadyCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
var calls atomic.Int32
|
||||
RunWithRetry(ctx, discardLogger(), "test", RetryConfig{InitialDelay: time.Millisecond}, func(context.Context) error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
if n := calls.Load(); n != 0 {
|
||||
t.Fatalf("attempt ran %d times for an already-cancelled ctx, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithRetryRetriesUntilCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var calls atomic.Int32
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
RunWithRetry(ctx, discardLogger(), "test", RetryConfig{InitialDelay: time.Millisecond, MaxDelay: time.Millisecond}, func(context.Context) error {
|
||||
// Stop the loop from inside the attempt once we've reconnected 3 times.
|
||||
if calls.Add(1) >= 3 {
|
||||
cancel()
|
||||
}
|
||||
return errors.New("dropped")
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("RunWithRetry did not return after ctx cancellation")
|
||||
}
|
||||
if n := calls.Load(); n != 3 {
|
||||
t.Fatalf("attempt ran %d times, want exactly 3", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithRetryCancelDuringBackoffReturnsPromptly(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
start := time.Now()
|
||||
go func() {
|
||||
// Large backoff: the only way this returns quickly is if the backoff wait
|
||||
// honors ctx cancellation.
|
||||
RunWithRetry(ctx, discardLogger(), "test", RetryConfig{InitialDelay: 10 * time.Second, MaxDelay: 10 * time.Second}, func(context.Context) error {
|
||||
return errors.New("dropped")
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("RunWithRetry ignored cancellation during backoff")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("RunWithRetry took %v to honor cancellation during backoff", elapsed)
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultControlAddr = "127.0.0.1:37913"
|
||||
|
||||
type controlHTTPServer struct {
|
||||
server *http.Server
|
||||
addr string
|
||||
}
|
||||
|
||||
type controlStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
ControlAddr string `json:"control_addr,omitempty"`
|
||||
ActiveSessions int `json:"active_sessions"`
|
||||
RetainedSessions int `json:"retained_sessions"`
|
||||
StartErrors []string `json:"start_errors,omitempty"`
|
||||
Adapters []AdapterHealthSnapshot `json:"adapters"`
|
||||
}
|
||||
|
||||
type controlSendRequest struct {
|
||||
ConnectionID string `json:"connection_id"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType ChatType `json:"chat_type,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
MediaURLs []string `json:"media_urls,omitempty"`
|
||||
ReplyToMsgID string `json:"reply_to_msg_id,omitempty"`
|
||||
}
|
||||
|
||||
type controlSendResponse struct {
|
||||
MessageID string `json:"message_id,omitempty"`
|
||||
MessageIDs []string `json:"message_ids,omitempty"`
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (gw *BotGateway) startControlServer(parent context.Context) error {
|
||||
if !gw.cfg.ControlEnabled {
|
||||
return nil
|
||||
}
|
||||
token := strings.TrimSpace(gw.cfg.ControlToken)
|
||||
if token == "" {
|
||||
return errors.New("bot control is enabled but control token is empty")
|
||||
}
|
||||
addr := strings.TrimSpace(gw.cfg.ControlAddr)
|
||||
if addr == "" {
|
||||
addr = defaultControlAddr
|
||||
}
|
||||
if err := validateLoopbackAddr(addr); err != nil {
|
||||
return err
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/status", gw.controlAuth(gw.handleControlStatus))
|
||||
mux.HandleFunc("/health", gw.controlAuth(gw.handleControlStatus))
|
||||
mux.HandleFunc("/metrics", gw.controlAuth(gw.handleControlMetrics))
|
||||
mux.HandleFunc("/send", gw.controlAuth(gw.handleControlSend))
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start bot control server: %w", err)
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
control := &controlHTTPServer{server: srv, addr: ln.Addr().String()}
|
||||
gw.mu.Lock()
|
||||
gw.controlServer = control
|
||||
gw.mu.Unlock()
|
||||
|
||||
gw.gatewayWG.Add(2)
|
||||
go func() {
|
||||
defer gw.gatewayWG.Done()
|
||||
<-parent.Done()
|
||||
gw.stopControlServer()
|
||||
}()
|
||||
go func() {
|
||||
defer gw.gatewayWG.Done()
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
gw.logger.Warn("bot control server stopped with error", "addr", control.addr, "err", err)
|
||||
}
|
||||
}()
|
||||
gw.logger.Info("bot control server started", "addr", control.addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gw *BotGateway) stopControlServer() {
|
||||
gw.mu.Lock()
|
||||
control := gw.controlServer
|
||||
gw.controlServer = nil
|
||||
gw.mu.Unlock()
|
||||
if control == nil || control.server == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := control.server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
gw.logger.Warn("bot control server shutdown failed", "addr", control.addr, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ControlAddr returns the bound loopback control address when enabled.
|
||||
func (gw *BotGateway) ControlAddr() string {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
if gw.controlServer == nil {
|
||||
return ""
|
||||
}
|
||||
return gw.controlServer.addr
|
||||
}
|
||||
|
||||
func validateLoopbackAddr(addr string) error {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bot control addr must be host:port on loopback: %w", err)
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return nil
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return fmt.Errorf("bot control addr must bind to loopback, got %q", addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gw *BotGateway) controlAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
got := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(got, prefix) || subtle.ConstantTimeCompare([]byte(strings.TrimSpace(strings.TrimPrefix(got, prefix))), []byte(strings.TrimSpace(gw.cfg.ControlToken))) != 1 {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *BotGateway) handleControlStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
gw.mu.Lock()
|
||||
retained := len(gw.controllers)
|
||||
gw.mu.Unlock()
|
||||
startErrs := gw.StartErrors()
|
||||
errTexts := make([]string, 0, len(startErrs))
|
||||
for _, err := range startErrs {
|
||||
if err != nil {
|
||||
errTexts = append(errTexts, err.Error())
|
||||
}
|
||||
}
|
||||
status := "running"
|
||||
for _, health := range gw.AdapterHealth() {
|
||||
switch health.Status {
|
||||
case "error", "degraded", "closed":
|
||||
status = "degraded"
|
||||
}
|
||||
}
|
||||
writeControlJSON(w, controlStatusResponse{
|
||||
Status: status,
|
||||
ControlAddr: gw.ControlAddr(),
|
||||
ActiveSessions: gw.sessions.ActiveCount(),
|
||||
RetainedSessions: retained,
|
||||
StartErrors: errTexts,
|
||||
Adapters: gw.AdapterHealth(),
|
||||
})
|
||||
}
|
||||
|
||||
func (gw *BotGateway) handleControlSend(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req controlSendRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ConnectionID) == "" || strings.TrimSpace(req.ChatID) == "" {
|
||||
http.Error(w, "connection_id and chat_id are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := gw.SendToAdapter(r.Context(), req.ConnectionID, req.Domain, OutboundMessage{
|
||||
ConnectionID: req.ConnectionID,
|
||||
Domain: req.Domain,
|
||||
ChatID: req.ChatID,
|
||||
ChatType: req.ChatType,
|
||||
Text: req.Text,
|
||||
MediaURLs: req.MediaURLs,
|
||||
ReplyToMsgID: req.ReplyToMsgID,
|
||||
})
|
||||
if err != nil {
|
||||
if len(result.DeliveredMessageIDs()) > 0 {
|
||||
writeControlJSONStatus(w, http.StatusMultiStatus, controlSendResponse{
|
||||
MessageID: result.MessageID,
|
||||
MessageIDs: result.MessageIDs,
|
||||
Partial: true,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeControlJSON(w, controlSendResponse{MessageID: result.MessageID, MessageIDs: result.MessageIDs})
|
||||
}
|
||||
|
||||
func (gw *BotGateway) handleControlMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
gw.mu.Lock()
|
||||
retained := len(gw.controllers)
|
||||
gw.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
fmt.Fprintf(w, "# TYPE reasonix_bot_active_sessions gauge\nreasonix_bot_active_sessions %d\n", gw.sessions.ActiveCount())
|
||||
fmt.Fprintf(w, "# TYPE reasonix_bot_retained_sessions gauge\nreasonix_bot_retained_sessions %d\n", retained)
|
||||
fmt.Fprintln(w, "# TYPE reasonix_bot_adapter_messages_total counter")
|
||||
for _, health := range gw.AdapterHealth() {
|
||||
labels := adapterMetricLabels(health)
|
||||
fmt.Fprintf(w, "reasonix_bot_adapter_messages_total{%s} %d\n", labels, health.Messages)
|
||||
}
|
||||
fmt.Fprintln(w, "# TYPE reasonix_bot_adapter_sends_total counter")
|
||||
for _, health := range gw.AdapterHealth() {
|
||||
labels := adapterMetricLabels(health)
|
||||
fmt.Fprintf(w, "reasonix_bot_adapter_sends_total{%s} %d\n", labels, health.Sends)
|
||||
}
|
||||
fmt.Fprintln(w, "# TYPE reasonix_bot_adapter_send_errors_total counter")
|
||||
for _, health := range gw.AdapterHealth() {
|
||||
labels := adapterMetricLabels(health)
|
||||
fmt.Fprintf(w, "reasonix_bot_adapter_send_errors_total{%s} %d\n", labels, health.SendErrors)
|
||||
}
|
||||
fmt.Fprintln(w, "# TYPE reasonix_bot_adapter_status gauge")
|
||||
for _, health := range gw.AdapterHealth() {
|
||||
labels := adapterMetricLabels(health)
|
||||
fmt.Fprintf(w, "reasonix_bot_adapter_status{%s,status=\"%s\"} 1\n", labels, prometheusLabelValue(health.Status))
|
||||
}
|
||||
}
|
||||
|
||||
func writeControlJSON(w http.ResponseWriter, v any) {
|
||||
writeControlJSONStatus(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
func writeControlJSONStatus(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func adapterMetricLabels(health AdapterHealthSnapshot) string {
|
||||
return fmt.Sprintf("id=\"%s\",platform=\"%s\",domain=\"%s\"",
|
||||
prometheusLabelValue(health.ID),
|
||||
prometheusLabelValue(string(health.Platform)),
|
||||
prometheusLabelValue(health.Domain),
|
||||
)
|
||||
}
|
||||
|
||||
func prometheusLabelValue(value string) string {
|
||||
value = strings.ReplaceAll(value, "\\", "\\\\")
|
||||
value = strings.ReplaceAll(value, "\n", "\\n")
|
||||
value = strings.ReplaceAll(value, "\"", "\\\"")
|
||||
return value
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package bot
|
||||
|
||||
import "reasonix/internal/control"
|
||||
|
||||
// stubBotController answers the calls the gateway makes on every session, so a
|
||||
// fake states only what its test is about. Everything else reaches the nil
|
||||
// botController and panics: a fake missing a method the gateway starts calling
|
||||
// fails at once on every platform, instead of being recovered somewhere.
|
||||
type stubBotController struct {
|
||||
botController
|
||||
}
|
||||
|
||||
func (stubBotController) SetToolApprovalMode(string) {}
|
||||
func (stubBotController) WorkspaceRoot() string { return "" }
|
||||
func (stubBotController) RuntimeStatus() control.RuntimeStatus { return control.RuntimeStatus{} }
|
||||
@@ -1,268 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// DesktopSessionInfo 是一个桌面 live 会话(tab)的快照,用于 /desktop status。
|
||||
type DesktopSessionInfo struct {
|
||||
TabID string
|
||||
Label string
|
||||
Workspace string
|
||||
Topic string
|
||||
Ready bool
|
||||
Running bool
|
||||
PendingPrompt bool
|
||||
// Detached 标记后台运行的会话(已从可见 tab 分离,controller 仍存活)。
|
||||
Detached bool
|
||||
// Pending 列出该会话当前待处理的审批/问答,便于用户在推送丢失时仍能
|
||||
// 用 /desktop approve|answer <id> 处理。
|
||||
Pending []DesktopPendingInfo
|
||||
}
|
||||
|
||||
// DesktopPendingInfo 是一条待处理的审批或问答的摘要。
|
||||
type DesktopPendingInfo struct {
|
||||
ID string
|
||||
Kind string // "approval" | "ask"
|
||||
Tool string
|
||||
}
|
||||
|
||||
// DesktopWatchRoute 标识一个订阅了桌面事件的 bot 聊天。
|
||||
type DesktopWatchRoute struct {
|
||||
ConnectionID string
|
||||
Domain string
|
||||
Platform Platform
|
||||
ChatType ChatType
|
||||
ChatID string
|
||||
}
|
||||
|
||||
// Key 返回订阅表的稳定键。
|
||||
func (r DesktopWatchRoute) Key() string {
|
||||
return fmt.Sprintf("%s|%s|%s|%s", r.Platform, r.ConnectionID, r.Domain, r.ChatID)
|
||||
}
|
||||
|
||||
// DesktopBridge 由桌面端进程实现,让 bot 聊天获得对整个桌面端的上帝视角:
|
||||
// 全局会话清单、事件订阅、以及对任意桌面 live 会话的远程审批/问答。
|
||||
//
|
||||
// 语义约定:
|
||||
// - 审批应答与桌面 UI 是"先到者赢"(controller 侧幂等,重复应答被静默忽略),
|
||||
// Approve/Answer 的返回文案应体现"以先到者为准"。
|
||||
// - 接管是"显式"的:/desktop takeover 绑定聊天与会话,桌面端会在会话
|
||||
// transcript 里看到提示;桌面用户在该会话本地发送任意消息即自动收回
|
||||
// 控制并通知远端。接管期间桌面输入不被锁定(controller 侧先到者赢)。
|
||||
type DesktopBridge interface {
|
||||
// Sessions 枚举当前所有桌面 live 会话(含后台 detached)。
|
||||
Sessions() []DesktopSessionInfo
|
||||
// SetWatch 订阅/退订当前聊天的桌面事件推送。
|
||||
SetWatch(route DesktopWatchRoute, enable bool) error
|
||||
// Watching 返回该聊天当前是否在订阅。
|
||||
Watching(route DesktopWatchRoute) bool
|
||||
// Approve 应答任意桌面会话的待审批项,返回用户可读的结果文案。
|
||||
Approve(approvalID string, allow bool) (string, error)
|
||||
// AskQuestions 返回某个待回答 ask 的问题列表(用于把 IM 文本解析成选项)。
|
||||
AskQuestions(askID string) ([]event.AskQuestion, bool)
|
||||
// Answer 应答任意桌面会话的待回答 ask,返回用户可读的结果文案。
|
||||
Answer(askID string, answers []event.AskAnswer) (string, error)
|
||||
// Takeover 把该聊天绑定为某个桌面会话的远程驾驶者,返回用户可读文案。
|
||||
Takeover(route DesktopWatchRoute, tabID string) (string, error)
|
||||
// Release 解除该聊天的接管绑定,返回用户可读文案。
|
||||
Release(route DesktopWatchRoute) (string, error)
|
||||
// TakeoverTab 返回该聊天当前接管的 tabID,未接管时为空串。
|
||||
TakeoverTab(route DesktopWatchRoute) string
|
||||
// DriveInput 把一条文本作为新 turn 提交到该聊天接管的桌面会话。
|
||||
// 成功时输出会经事件转发流回聊天;返回的文案(可为空)用于即时回执。
|
||||
DriveInput(route DesktopWatchRoute, text string) (string, error)
|
||||
}
|
||||
|
||||
func desktopRouteFromMessage(msg InboundMessage) DesktopWatchRoute {
|
||||
return DesktopWatchRoute{
|
||||
ConnectionID: msg.ConnectionID,
|
||||
Domain: msg.Domain,
|
||||
Platform: msg.Platform,
|
||||
ChatType: msg.ChatType,
|
||||
ChatID: msg.ChatID,
|
||||
}
|
||||
}
|
||||
|
||||
const desktopCommandUsage = "用法:\n" +
|
||||
"/desktop status - 查看桌面端所有 live 会话\n" +
|
||||
"/desktop watch on|off|status - 订阅/退订桌面事件推送(审批请求、任务完成/出错)\n" +
|
||||
"/desktop approve <id> - 批准桌面会话的待审批操作\n" +
|
||||
"/desktop deny <id> - 拒绝桌面会话的待审批操作\n" +
|
||||
"/desktop answer <id> <选项编号或文本> - 回答桌面会话的提问\n" +
|
||||
"/desktop takeover <tab> - 接管桌面会话,后续消息直接驱动它\n" +
|
||||
"/desktop release - 解除接管,回到普通 bot 会话"
|
||||
|
||||
// handleDesktopCommand 处理 /desktop 系列命令(上帝视角:观察 + 遥控审批)。
|
||||
// 调用方已完成 admin 角色门控。
|
||||
func (gw *BotGateway) handleDesktopCommand(msg InboundMessage) string {
|
||||
bridge := gw.cfg.Desktop
|
||||
if bridge == nil {
|
||||
return "此 bot 未运行在桌面端进程内,/desktop 命令不可用。请在桌面端设置里启用 bot。"
|
||||
}
|
||||
fields := strings.Fields(msg.Text)
|
||||
sub := ""
|
||||
if len(fields) > 1 {
|
||||
sub = strings.ToLower(fields[1])
|
||||
}
|
||||
switch sub {
|
||||
case "", "status", "sessions":
|
||||
return formatDesktopSessions(bridge.Sessions())
|
||||
case "watch":
|
||||
arg := ""
|
||||
if len(fields) > 2 {
|
||||
arg = strings.ToLower(fields[2])
|
||||
}
|
||||
route := desktopRouteFromMessage(msg)
|
||||
switch arg {
|
||||
case "on":
|
||||
if err := bridge.SetWatch(route, true); err != nil {
|
||||
return "已在本次运行中订阅桌面事件,但保存订阅失败;桌面端重启后可能需要重新订阅。"
|
||||
}
|
||||
return "已订阅桌面事件:审批请求、任务完成/出错会推送到本聊天。订阅已保存,桌面端重启后仍会生效。用 /desktop watch off 退订。"
|
||||
case "off":
|
||||
if err := bridge.SetWatch(route, false); err != nil {
|
||||
return "已在本次运行中退订桌面事件,但保存失败;桌面端重启后订阅可能恢复。"
|
||||
}
|
||||
return "已退订桌面事件推送,持久化订阅也已移除。"
|
||||
case "", "state":
|
||||
if bridge.Watching(route) {
|
||||
return "本聊天正在订阅桌面事件推送。用 /desktop watch off 退订。"
|
||||
}
|
||||
return "本聊天未订阅桌面事件推送。用 /desktop watch on 订阅。"
|
||||
default:
|
||||
return desktopCommandUsage
|
||||
}
|
||||
case "approve", "deny":
|
||||
if len(fields) < 3 {
|
||||
return desktopCommandUsage
|
||||
}
|
||||
feedback, err := bridge.Approve(fields[2], sub == "approve")
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return feedback
|
||||
case "answer":
|
||||
if len(fields) < 4 {
|
||||
return desktopCommandUsage
|
||||
}
|
||||
askID := fields[2]
|
||||
questions, ok := bridge.AskQuestions(askID)
|
||||
if !ok {
|
||||
return fmt.Sprintf("未找到待回答的提问 %s(可能已在桌面端回答或已超时)。", askID)
|
||||
}
|
||||
raw := strings.Join(fields[3:], " ")
|
||||
answers := parseAskAnswers(questions, raw)
|
||||
feedback, err := bridge.Answer(askID, answers)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return feedback
|
||||
case "takeover":
|
||||
if len(fields) < 3 {
|
||||
return desktopCommandUsage
|
||||
}
|
||||
feedback, err := bridge.Takeover(desktopRouteFromMessage(msg), fields[2])
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return feedback
|
||||
case "release":
|
||||
feedback, err := bridge.Release(desktopRouteFromMessage(msg))
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return feedback
|
||||
default:
|
||||
return desktopCommandUsage
|
||||
}
|
||||
}
|
||||
|
||||
// divertToDesktopTakeover 把已接管聊天的普通消息改道到桌面会话。斜杠命令
|
||||
// 不经过这里(仍走 handleSlashCommand),所以 /desktop release 永远可用。
|
||||
// 返回 true 表示消息已被桌面接管通道消费。
|
||||
func (gw *BotGateway) divertToDesktopTakeover(ctx context.Context, adapter Adapter, msg InboundMessage) bool {
|
||||
if strings.HasPrefix(strings.TrimSpace(msg.Text), "/") {
|
||||
return false
|
||||
}
|
||||
bridge := gw.cfg.Desktop
|
||||
if bridge == nil {
|
||||
return false
|
||||
}
|
||||
route := desktopRouteFromMessage(msg)
|
||||
if bridge.TakeoverTab(route) == "" {
|
||||
return false
|
||||
}
|
||||
// Re-check the continuing capability, not only the command that created it.
|
||||
// A runtime refresh can revoke admin while leaving this process-local binding
|
||||
// alive; base allowlist admission alone must not preserve takeover power.
|
||||
if !gw.checkCommandRole(msg.Platform, msg, "admin") {
|
||||
_, _ = bridge.Release(route)
|
||||
_ = gw.sendText(ctx, adapter, msg, "桌面接管已解除:当前账号不再具有 bot 管理员权限。")
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(msg.Text) == "" {
|
||||
_ = gw.sendText(ctx, adapter, msg, "接管模式暂不支持转发附件,请发送文本。")
|
||||
return true
|
||||
}
|
||||
feedback, err := bridge.DriveInput(route, msg.Text)
|
||||
if err != nil {
|
||||
_ = gw.sendText(ctx, adapter, msg, err.Error())
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(feedback) != "" {
|
||||
_ = gw.sendText(ctx, adapter, msg, feedback)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func formatDesktopSessions(sessions []DesktopSessionInfo) string {
|
||||
if len(sessions) == 0 {
|
||||
return "桌面端当前没有 live 会话。"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "桌面端 live 会话(%d 个):\n", len(sessions))
|
||||
for _, s := range sessions {
|
||||
state := "空闲"
|
||||
switch {
|
||||
case s.PendingPrompt:
|
||||
state = "⚠️ 等待审批/回答"
|
||||
case s.Running:
|
||||
state = "▶️ 执行中"
|
||||
case !s.Ready:
|
||||
state = "启动中"
|
||||
}
|
||||
label := strings.TrimSpace(s.Label)
|
||||
if label == "" {
|
||||
label = strings.TrimSpace(s.Topic)
|
||||
}
|
||||
if label == "" {
|
||||
label = "(未命名)"
|
||||
}
|
||||
if s.Detached {
|
||||
state += "·后台"
|
||||
}
|
||||
fmt.Fprintf(&b, "\n- %s [%s]", label, state)
|
||||
if ws := strings.TrimSpace(s.Workspace); ws != "" {
|
||||
fmt.Fprintf(&b, "\n 项目: %s", ws)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n tab: %s", s.TabID)
|
||||
for _, p := range s.Pending {
|
||||
kind := "审批"
|
||||
if p.Kind == "ask" {
|
||||
kind = "提问"
|
||||
}
|
||||
line := fmt.Sprintf("\n 待%s: %s", kind, p.ID)
|
||||
if tool := strings.TrimSpace(p.Tool); tool != "" {
|
||||
line += " (" + tool + ")"
|
||||
}
|
||||
b.WriteString(line)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n\n用 /desktop watch on 订阅审批与完成事件;/desktop takeover <tab> 接管某个会话。")
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
type fakeDesktopBridge struct {
|
||||
sessions []DesktopSessionInfo
|
||||
watching map[string]bool
|
||||
approved []string
|
||||
denied []string
|
||||
answered map[string][]event.AskAnswer
|
||||
questions map[string][]event.AskQuestion
|
||||
takeovers map[string]string // routeKey -> tabID
|
||||
driven []string
|
||||
driveErr error
|
||||
watchErr error
|
||||
}
|
||||
|
||||
func newFakeDesktopBridge() *fakeDesktopBridge {
|
||||
return &fakeDesktopBridge{
|
||||
watching: make(map[string]bool),
|
||||
answered: make(map[string][]event.AskAnswer),
|
||||
questions: make(map[string][]event.AskQuestion),
|
||||
takeovers: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeDesktopBridge) Sessions() []DesktopSessionInfo { return f.sessions }
|
||||
func (f *fakeDesktopBridge) SetWatch(route DesktopWatchRoute, enable bool) error {
|
||||
f.watching[route.Key()] = enable
|
||||
return f.watchErr
|
||||
}
|
||||
func (f *fakeDesktopBridge) Watching(route DesktopWatchRoute) bool {
|
||||
return f.watching[route.Key()]
|
||||
}
|
||||
func (f *fakeDesktopBridge) Approve(id string, allow bool) (string, error) {
|
||||
if id == "gone" {
|
||||
return "", fmt.Errorf("未找到待处理的审批 %s", id)
|
||||
}
|
||||
if allow {
|
||||
f.approved = append(f.approved, id)
|
||||
} else {
|
||||
f.denied = append(f.denied, id)
|
||||
}
|
||||
return "已提交", nil
|
||||
}
|
||||
func (f *fakeDesktopBridge) AskQuestions(id string) ([]event.AskQuestion, bool) {
|
||||
qs, ok := f.questions[id]
|
||||
return qs, ok
|
||||
}
|
||||
func (f *fakeDesktopBridge) Answer(id string, answers []event.AskAnswer) (string, error) {
|
||||
f.answered[id] = answers
|
||||
return "已提交回答", nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktopBridge) Takeover(route DesktopWatchRoute, tabID string) (string, error) {
|
||||
for _, s := range f.sessions {
|
||||
if s.TabID == tabID {
|
||||
f.takeovers[route.Key()] = tabID
|
||||
return "已接管", nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("未找到会话 %s", tabID)
|
||||
}
|
||||
|
||||
func (f *fakeDesktopBridge) Release(route DesktopWatchRoute) (string, error) {
|
||||
if _, ok := f.takeovers[route.Key()]; !ok {
|
||||
return "", fmt.Errorf("本聊天当前没有接管任何桌面会话。")
|
||||
}
|
||||
delete(f.takeovers, route.Key())
|
||||
return "已解除接管", nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktopBridge) TakeoverTab(route DesktopWatchRoute) string {
|
||||
return f.takeovers[route.Key()]
|
||||
}
|
||||
|
||||
func (f *fakeDesktopBridge) DriveInput(route DesktopWatchRoute, text string) (string, error) {
|
||||
if f.driveErr != nil {
|
||||
return "", f.driveErr
|
||||
}
|
||||
f.driven = append(f.driven, text)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func desktopTestMessage(text string) InboundMessage {
|
||||
return InboundMessage{
|
||||
Platform: PlatformFeishu,
|
||||
ConnectionID: "feishu-main",
|
||||
Domain: "feishu",
|
||||
ChatType: ChatDM,
|
||||
ChatID: "chat-god",
|
||||
UserID: "admin-user",
|
||||
Text: text,
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDesktopCommandWithoutBridge(t *testing.T) {
|
||||
gw := &BotGateway{cfg: GatewayConfig{}}
|
||||
got := gw.handleDesktopCommand(desktopTestMessage("/desktop status"))
|
||||
if !strings.Contains(got, "未运行在桌面端进程内") {
|
||||
t.Fatalf("reply = %q, want standalone-mode notice", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDesktopCommandStatusListsSessions(t *testing.T) {
|
||||
bridge := newFakeDesktopBridge()
|
||||
bridge.sessions = []DesktopSessionInfo{
|
||||
{TabID: "tab-1", Label: "修复登录", Workspace: "blade", Running: true, Ready: true},
|
||||
{TabID: "tab-2", Label: "", Topic: "周报", Ready: true, PendingPrompt: true},
|
||||
}
|
||||
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
|
||||
|
||||
got := gw.handleDesktopCommand(desktopTestMessage("/desktop status"))
|
||||
for _, want := range []string{"2 个", "修复登录", "▶️ 执行中", "周报", "⚠️ 等待审批/回答", "tab-1", "blade"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("status reply = %q, want it to contain %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDesktopCommandStatusListsPendingIDs(t *testing.T) {
|
||||
bridge := newFakeDesktopBridge()
|
||||
bridge.sessions = []DesktopSessionInfo{{
|
||||
TabID: "tab-1", Label: "修复登录", Ready: true, PendingPrompt: true,
|
||||
Pending: []DesktopPendingInfo{{ID: "appr-9", Kind: "approval", Tool: "bash"}},
|
||||
}}
|
||||
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
|
||||
got := gw.handleDesktopCommand(desktopTestMessage("/desktop status"))
|
||||
// The pending id must be visible so a user whose push was dropped can still
|
||||
// run /desktop approve <id>.
|
||||
if !strings.Contains(got, "appr-9") {
|
||||
t.Fatalf("status = %q, want it to list the pending approval id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDesktopCommandStatusEmpty(t *testing.T) {
|
||||
gw := &BotGateway{cfg: GatewayConfig{Desktop: newFakeDesktopBridge()}}
|
||||
got := gw.handleDesktopCommand(desktopTestMessage("/desktop"))
|
||||
if !strings.Contains(got, "没有 live 会话") {
|
||||
t.Fatalf("reply = %q, want empty-sessions notice", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDesktopCommandWatchLifecycle(t *testing.T) {
|
||||
bridge := newFakeDesktopBridge()
|
||||
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
|
||||
msg := desktopTestMessage("/desktop watch on")
|
||||
|
||||
got := gw.handleDesktopCommand(msg)
|
||||
if !strings.Contains(got, "已订阅") {
|
||||
t.Fatalf("watch on reply = %q", got)
|
||||
}
|
||||
route := desktopRouteFromMessage(msg)
|
||||
if !bridge.watching[route.Key()] {
|
||||
t.Fatal("watch on did not subscribe the message route")
|
||||
}
|
||||
|
||||
msg.Text = "/desktop watch off"
|
||||
got = gw.handleDesktopCommand(msg)
|
||||
if !strings.Contains(got, "已退订") {
|
||||
t.Fatalf("watch off reply = %q", got)
|
||||
}
|
||||
if bridge.watching[route.Key()] {
|
||||
t.Fatal("watch off did not unsubscribe the message route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDesktopCommandWatchReportsPersistenceFailure(t *testing.T) {
|
||||
bridge := newFakeDesktopBridge()
|
||||
bridge.watchErr = fmt.Errorf("disk unavailable")
|
||||
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
|
||||
msg := desktopTestMessage("/desktop watch on")
|
||||
|
||||
got := gw.handleDesktopCommand(msg)
|
||||
if !strings.Contains(got, "本次运行中订阅") || !strings.Contains(got, "保存订阅失败") {
|
||||
t.Fatalf("watch persistence failure reply = %q", got)
|
||||
}
|
||||
if !bridge.Watching(desktopRouteFromMessage(msg)) {
|
||||
t.Fatal("runtime subscription should remain active after persistence failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDesktopCommandApproveAndDeny(t *testing.T) {
|
||||
bridge := newFakeDesktopBridge()
|
||||
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
|
||||
|
||||
if got := gw.handleDesktopCommand(desktopTestMessage("/desktop approve appr-1")); !strings.Contains(got, "已提交") {
|
||||
t.Fatalf("approve reply = %q", got)
|
||||
}
|
||||
if got := gw.handleDesktopCommand(desktopTestMessage("/desktop deny appr-2")); !strings.Contains(got, "已提交") {
|
||||
t.Fatalf("deny reply = %q", got)
|
||||
}
|
||||
if len(bridge.approved) != 1 || bridge.approved[0] != "appr-1" {
|
||||
t.Fatalf("approved = %v, want [appr-1]", bridge.approved)
|
||||
}
|
||||
if len(bridge.denied) != 1 || bridge.denied[0] != "appr-2" {
|
||||
t.Fatalf("denied = %v, want [appr-2]", bridge.denied)
|
||||
}
|
||||
|
||||
if got := gw.handleDesktopCommand(desktopTestMessage("/desktop approve gone")); !strings.Contains(got, "未找到") {
|
||||
t.Fatalf("missing-approval reply = %q", got)
|
||||
}
|
||||
if got := gw.handleDesktopCommand(desktopTestMessage("/desktop approve")); got != desktopCommandUsage {
|
||||
t.Fatalf("missing-arg reply = %q, want usage", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDesktopCommandAnswerParsesSelection(t *testing.T) {
|
||||
bridge := newFakeDesktopBridge()
|
||||
bridge.questions["ask-1"] = []event.AskQuestion{{
|
||||
ID: "q1",
|
||||
Prompt: "选一个",
|
||||
Options: []event.AskOption{{Label: "方案 A"}, {Label: "方案 B"}},
|
||||
}}
|
||||
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
|
||||
|
||||
got := gw.handleDesktopCommand(desktopTestMessage("/desktop answer ask-1 2"))
|
||||
if !strings.Contains(got, "已提交回答") {
|
||||
t.Fatalf("answer reply = %q", got)
|
||||
}
|
||||
answers := bridge.answered["ask-1"]
|
||||
if len(answers) != 1 || answers[0].QuestionID != "q1" {
|
||||
t.Fatalf("answers = %+v, want one answer for q1", answers)
|
||||
}
|
||||
if len(answers[0].Selected) != 1 || answers[0].Selected[0] != "方案 B" {
|
||||
t.Fatalf("selected = %v, want numeric index resolved to 方案 B", answers[0].Selected)
|
||||
}
|
||||
|
||||
if got := gw.handleDesktopCommand(desktopTestMessage("/desktop answer ask-gone 1")); !strings.Contains(got, "未找到") {
|
||||
t.Fatalf("missing-ask reply = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDesktopCommandTakeoverAndRelease(t *testing.T) {
|
||||
bridge := newFakeDesktopBridge()
|
||||
bridge.sessions = []DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}}
|
||||
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
|
||||
msg := desktopTestMessage("/desktop takeover tab-1")
|
||||
|
||||
if got := gw.handleDesktopCommand(msg); !strings.Contains(got, "已接管") {
|
||||
t.Fatalf("takeover reply = %q", got)
|
||||
}
|
||||
route := desktopRouteFromMessage(msg)
|
||||
if bridge.takeovers[route.Key()] != "tab-1" {
|
||||
t.Fatalf("takeovers = %v, want route bound to tab-1", bridge.takeovers)
|
||||
}
|
||||
|
||||
msg.Text = "/desktop release"
|
||||
if got := gw.handleDesktopCommand(msg); !strings.Contains(got, "已解除") {
|
||||
t.Fatalf("release reply = %q", got)
|
||||
}
|
||||
if got := gw.handleDesktopCommand(desktopTestMessage("/desktop takeover missing")); !strings.Contains(got, "未找到") {
|
||||
t.Fatalf("missing-tab reply = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDivertToDesktopTakeover(t *testing.T) {
|
||||
bridge := newFakeDesktopBridge()
|
||||
bridge.sessions = []DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}}
|
||||
gw := &BotGateway{
|
||||
cfg: GatewayConfig{Desktop: bridge},
|
||||
logger: discardLogger(),
|
||||
adapterHealth: map[string]*AdapterHealthSnapshot{},
|
||||
}
|
||||
adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
|
||||
msg := desktopTestMessage("帮我跑一下测试")
|
||||
|
||||
// 未接管:不分流。
|
||||
if gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
|
||||
t.Fatal("message should not divert without a takeover binding")
|
||||
}
|
||||
|
||||
route := desktopRouteFromMessage(msg)
|
||||
bridge.takeovers[route.Key()] = "tab-1"
|
||||
msg.Text = "/desktop status"
|
||||
if gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
|
||||
t.Fatal("slash commands must remain in the bot command path during takeover")
|
||||
}
|
||||
msg.Text = "帮我跑一下测试"
|
||||
if !gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
|
||||
t.Fatal("message should divert to the taken-over session")
|
||||
}
|
||||
if len(bridge.driven) != 1 || bridge.driven[0] != "帮我跑一下测试" {
|
||||
t.Fatalf("driven = %v, want the plain message text", bridge.driven)
|
||||
}
|
||||
|
||||
// 驱动失败:错误文案回给用户。
|
||||
bridge.driveErr = fmt.Errorf("会话正在执行中")
|
||||
if !gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
|
||||
t.Fatal("drive failure should still consume the message")
|
||||
}
|
||||
sent := adapter.sentMessages()
|
||||
if len(sent) == 0 || !strings.Contains(sent[len(sent)-1].Text, "正在执行中") {
|
||||
t.Fatalf("sent = %+v, want drive error relayed to the chat", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDivertToDesktopTakeoverRevokesFormerAdmin(t *testing.T) {
|
||||
bridge := newFakeDesktopBridge()
|
||||
bridge.sessions = []DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}}
|
||||
gw := &BotGateway{
|
||||
cfg: GatewayConfig{
|
||||
Desktop: bridge,
|
||||
Allowlist: AllowlistConfig{Admins: map[Platform][]string{
|
||||
PlatformFeishu: {"current-admin"},
|
||||
}},
|
||||
},
|
||||
logger: discardLogger(),
|
||||
adapterHealth: map[string]*AdapterHealthSnapshot{},
|
||||
}
|
||||
adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
|
||||
msg := desktopTestMessage("run tests")
|
||||
route := desktopRouteFromMessage(msg)
|
||||
bridge.takeovers[route.Key()] = "tab-1"
|
||||
|
||||
if !gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
|
||||
t.Fatal("revoked takeover message should be consumed with an explanation")
|
||||
}
|
||||
if bridge.TakeoverTab(route) != "" || len(bridge.driven) != 0 {
|
||||
t.Fatalf("revoked takeover remained active or drove input: tab=%q driven=%v", bridge.TakeoverTab(route), bridge.driven)
|
||||
}
|
||||
sent := adapter.sentMessages()
|
||||
if len(sent) == 0 || !strings.Contains(sent[len(sent)-1].Text, "不再具有") {
|
||||
t.Fatalf("sent = %+v, want admin-revocation explanation", sent)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package feishu
|
||||
|
||||
import "errors"
|
||||
|
||||
// The codes are on the error the API returned; the digits also appear in its
|
||||
// text, and reading them there matched any message that happened to quote one
|
||||
// while missing a send that carried the code in a field.
|
||||
const (
|
||||
feishuCardTooLargeCode = 11310
|
||||
feishuCardInvalidCode = 11325
|
||||
)
|
||||
|
||||
func isCardLimitError(err error) bool {
|
||||
var apiErr *feishuAPIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return false
|
||||
}
|
||||
return apiErr.code == feishuCardTooLargeCode || apiErr.code == feishuCardInvalidCode
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The codes were read out of the error's text, so any message quoting 11310
|
||||
// matched and a send that carried the code in a field did not. The send path
|
||||
// now returns the typed error the reply path already did.
|
||||
func TestCardLimitIsReadFromTheCodeNotTheText(t *testing.T) {
|
||||
for _, code := range []int{feishuCardTooLargeCode, feishuCardInvalidCode} {
|
||||
err := error(&feishuAPIError{op: "send", code: code, msg: "card too large"})
|
||||
if !isCardLimitError(err) {
|
||||
t.Fatalf("code %d did not read as a card limit", code)
|
||||
}
|
||||
if !isCardLimitError(fmt.Errorf("sending: %w", err)) {
|
||||
t.Fatalf("code %d did not survive wrapping", code)
|
||||
}
|
||||
}
|
||||
if isCardLimitError(&feishuAPIError{op: "send", code: 99999, msg: "x"}) {
|
||||
t.Fatal("an unrelated code read as a card limit")
|
||||
}
|
||||
// A message that merely quotes the digits is not the API saying so.
|
||||
if isCardLimitError(errors.New("user said 11310 in chat")) {
|
||||
t.Fatal("text quoting the code read as a card limit")
|
||||
}
|
||||
}
|
||||
|
||||
// isReplyFallbackError checks the op as well, so the send path taking the same
|
||||
// type must not start looking like a recalled reply.
|
||||
func TestSendErrorsAreNotMistakenForRecalledReplies(t *testing.T) {
|
||||
if isReplyFallbackError(&feishuAPIError{op: "send", code: feishuReplyRecalledCode, msg: "x"}) {
|
||||
t.Fatal("a send error read as a recalled reply")
|
||||
}
|
||||
if !isReplyFallbackError(&feishuAPIError{op: "reply", code: feishuReplyRecalledCode, msg: "x"}) {
|
||||
t.Fatal("a recalled reply stopped reading as one")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,561 +0,0 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/config"
|
||||
|
||||
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher/callback"
|
||||
)
|
||||
|
||||
func TestStartReturnsMissingWebSocketSecret(t *testing.T) {
|
||||
t.Setenv("FEISHU_TEST_SECRET", "")
|
||||
a := New(config.FeishuBotConfig{
|
||||
AppID: "cli-test",
|
||||
AppSecretEnv: "FEISHU_TEST_SECRET",
|
||||
Mode: "websocket",
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
err := a.Start(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "FEISHU_TEST_SECRET") {
|
||||
t.Fatalf("Start error = %v, want missing secret env", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationTokenValidRequiresConfiguredToken(t *testing.T) {
|
||||
a := &adapter{cfg: config.FeishuBotConfig{VerificationToken: "expected"}}
|
||||
|
||||
if a.verificationTokenValid("") {
|
||||
t.Fatal("missing token should be rejected when verification token is configured")
|
||||
}
|
||||
if a.verificationTokenValid("wrong") {
|
||||
t.Fatal("wrong token should be rejected")
|
||||
}
|
||||
if !a.verificationTokenValid("expected") {
|
||||
t.Fatal("matching token should be accepted")
|
||||
}
|
||||
|
||||
a.cfg.VerificationToken = ""
|
||||
if a.verificationTokenValid("") {
|
||||
t.Fatal("unconfigured verification token should deny all callers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkSeenConcurrent(t *testing.T) {
|
||||
a := &adapter{seen: make(map[string]bool)}
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range 100 {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_ = a.markSeen(fmt.Sprintf("evt-%d", i%5))
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := len(a.seen); got != 5 {
|
||||
t.Fatalf("seen size = %d, want 5", got)
|
||||
}
|
||||
if a.markSeen("evt-1") != true {
|
||||
t.Fatal("second markSeen call should report duplicate")
|
||||
}
|
||||
if a.markSeen("") {
|
||||
t.Fatal("empty event id should not be treated as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCardActionUsesChatType(t *testing.T) {
|
||||
a := &adapter{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
raw := []byte(`{
|
||||
"event": {
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "open-user"}
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "msg-1",
|
||||
"open_chat_id": "chat-1"
|
||||
},
|
||||
"action": {
|
||||
"value": {
|
||||
"command": "/approve approval-1",
|
||||
"chat_type": "dm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if !a.handleCardAction(raw) {
|
||||
t.Fatal("handleCardAction returned false")
|
||||
}
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.ChatType != bot.ChatDM {
|
||||
t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatDM)
|
||||
}
|
||||
if msg.Text != "/approve approval-1" {
|
||||
t.Fatalf("text = %q, want /approve approval-1", msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCardActionEnqueuesAskAnswerCommand(t *testing.T) {
|
||||
a := &adapter{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
raw := []byte(`{
|
||||
"event": {
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "open-user"}
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "msg-ask",
|
||||
"open_chat_id": "chat-ask"
|
||||
},
|
||||
"action": {
|
||||
"value": {
|
||||
"command": "/answer ask-1 2",
|
||||
"chat_type": "dm",
|
||||
"user_id": "allowed-user"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if !a.handleCardAction(raw) {
|
||||
t.Fatal("handleCardAction returned false")
|
||||
}
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.Text != "/answer ask-1 2" {
|
||||
t.Fatalf("text = %q, want /answer ask-1 2", msg.Text)
|
||||
}
|
||||
if msg.UserID != "allowed-user" {
|
||||
t.Fatalf("user id = %q, want allowed-user", msg.UserID)
|
||||
}
|
||||
if msg.OperatorID != "open-user" {
|
||||
t.Fatalf("operator id = %q, want open-user (the actual clicker, not the card requester)", msg.OperatorID)
|
||||
}
|
||||
if msg.ChatID != "chat-ask" || msg.MessageID != "msg-ask" {
|
||||
t.Fatalf("message routing = chat %q msg %q, want chat-ask/msg-ask", msg.ChatID, msg.MessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCardActionAcceptsDirectOperatorID(t *testing.T) {
|
||||
a := &adapter{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
raw := []byte(`{
|
||||
"event": {
|
||||
"operator": {
|
||||
"open_id": "open-user-direct"
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "msg-1",
|
||||
"open_chat_id": "chat-1"
|
||||
},
|
||||
"action": {
|
||||
"value": {
|
||||
"command": "/approve approval-1",
|
||||
"chat_type": "dm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if !a.handleCardAction(raw) {
|
||||
t.Fatal("handleCardAction returned false")
|
||||
}
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.UserID != "open-user-direct" {
|
||||
t.Fatalf("user id = %q, want open-user-direct", msg.UserID)
|
||||
}
|
||||
if msg.OperatorID != "open-user-direct" {
|
||||
t.Fatalf("operator id = %q, want open-user-direct", msg.OperatorID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCardActionDoesNotTrustCardRequesterAsOperator(t *testing.T) {
|
||||
a := &adapter{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
raw := []byte(`{
|
||||
"event": {
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "clicker"}
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "msg-1",
|
||||
"open_chat_id": "chat-1"
|
||||
},
|
||||
"action": {
|
||||
"value": {
|
||||
"command": "/approve approval-1",
|
||||
"chat_type": "group",
|
||||
"user_id": "requester"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if !a.handleCardAction(raw) {
|
||||
t.Fatal("handleCardAction returned false")
|
||||
}
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.UserID != "requester" {
|
||||
t.Fatalf("user id = %q, want requester (routing follows the card value)", msg.UserID)
|
||||
}
|
||||
if msg.OperatorID != "clicker" {
|
||||
t.Fatalf("operator id = %q, want clicker (gate follows the real button presser)", msg.OperatorID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageTreatsTopicGroupAsGroup(t *testing.T) {
|
||||
a := &adapter{
|
||||
cfg: config.FeishuBotConfig{RequireMention: true},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-topic",
|
||||
ChatID: "chat-topic",
|
||||
ChatType: "topic_group",
|
||||
MsgType: "text",
|
||||
Content: `{"text":"hello"}`,
|
||||
Sender: feishuSender{SenderID: struct {
|
||||
UserID string `json:"user_id"`
|
||||
OpenID string `json:"open_id"`
|
||||
UnionID string `json:"union_id"`
|
||||
}{OpenID: "open-user"}},
|
||||
Mentions: []feishuMention{{Key: "@_user_1"}},
|
||||
})
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.ChatType != bot.ChatGroup {
|
||||
t.Fatalf("chat type = %q, want group", msg.ChatType)
|
||||
}
|
||||
if msg.ChatID != "chat-topic" || msg.UserID != "open-user" {
|
||||
t.Fatalf("message = %+v, want topic group routing", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageRequiresMentionInTopicGroup(t *testing.T) {
|
||||
a := &adapter{
|
||||
cfg: config.FeishuBotConfig{RequireMention: true},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-topic",
|
||||
ChatID: "chat-topic",
|
||||
ChatType: "topic_group",
|
||||
MsgType: "text",
|
||||
Content: `{"text":"hello"}`,
|
||||
Sender: feishuSender{SenderID: struct {
|
||||
UserID string `json:"user_id"`
|
||||
OpenID string `json:"open_id"`
|
||||
UnionID string `json:"union_id"`
|
||||
}{OpenID: "open-user"}},
|
||||
})
|
||||
|
||||
select {
|
||||
case msg := <-a.msgCh:
|
||||
t.Fatalf("message without mention was queued: %+v", msg)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketDispatcherHandlesCardActionTrigger(t *testing.T) {
|
||||
a := &adapter{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
raw := []byte(`{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt-card-1",
|
||||
"event_type": "card.action.trigger",
|
||||
"token": ""
|
||||
},
|
||||
"event": {
|
||||
"operator": {
|
||||
"operator_id": {
|
||||
"open_id": "open-user",
|
||||
"union_id": "union-user"
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "msg-card-1",
|
||||
"open_chat_id": "chat-card-1"
|
||||
},
|
||||
"action": {
|
||||
"value": {
|
||||
"command": "/approve approval-2",
|
||||
"chat_type": "dm",
|
||||
"user_id": "allowed-user"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
resp, err := a.newEventDispatcher().Do(context.Background(), raw)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatcher.Do returned error: %v", err)
|
||||
}
|
||||
toast, ok := resp.(*callback.CardActionTriggerResponse)
|
||||
if !ok {
|
||||
t.Fatalf("response = %T, want *callback.CardActionTriggerResponse", resp)
|
||||
}
|
||||
if toast.Toast == nil || toast.Toast.Type != "success" {
|
||||
t.Fatalf("toast = %#v, want success toast", toast.Toast)
|
||||
}
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.Text != "/approve approval-2" {
|
||||
t.Fatalf("text = %q, want /approve approval-2", msg.Text)
|
||||
}
|
||||
if msg.ChatID != "chat-card-1" {
|
||||
t.Fatalf("chat id = %q, want chat-card-1", msg.ChatID)
|
||||
}
|
||||
if msg.UserID != "allowed-user" {
|
||||
t.Fatalf("user id = %q, want allowed-user", msg.UserID)
|
||||
}
|
||||
|
||||
_, err = a.newEventDispatcher().Do(context.Background(), raw)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate dispatcher.Do returned error: %v", err)
|
||||
}
|
||||
select {
|
||||
case duplicate := <-a.msgCh:
|
||||
t.Fatalf("duplicate card action enqueued message: %#v", duplicate)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// pngHeader 是合法 PNG 签名,足够 http.DetectContentType 识别为 image/png。
|
||||
var pngHeader = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}
|
||||
|
||||
func newTestAdapter(fetch func(ctx context.Context, messageID, key, typ string) ([]byte, string, error)) *adapter {
|
||||
return &adapter{
|
||||
cfg: config.FeishuBotConfig{},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
fetchResource: fetch,
|
||||
}
|
||||
}
|
||||
|
||||
func testSender(openID string) feishuSender {
|
||||
return feishuSender{SenderID: struct {
|
||||
UserID string `json:"user_id"`
|
||||
OpenID string `json:"open_id"`
|
||||
UnionID string `json:"union_id"`
|
||||
}{OpenID: openID}}
|
||||
}
|
||||
|
||||
func TestHandleMessageDefersImageDownload(t *testing.T) {
|
||||
fetchCalls := 0
|
||||
a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
|
||||
fetchCalls++
|
||||
if messageID != "msg-img" || key != "img-key-1" || typ != "image" {
|
||||
t.Fatalf("fetch args = %s/%s/%s, want msg-img/img-key-1/image", messageID, key, typ)
|
||||
}
|
||||
return pngHeader, "", nil
|
||||
})
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-img",
|
||||
ChatID: "chat-1",
|
||||
ChatType: "p2p",
|
||||
MsgType: "image",
|
||||
Content: `{"image_key":"img-key-1"}`,
|
||||
Sender: testSender("open-user"),
|
||||
})
|
||||
|
||||
msg := <-a.msgCh
|
||||
if len(msg.Media) != 1 {
|
||||
t.Fatalf("media items = %d, want 1", len(msg.Media))
|
||||
}
|
||||
if fetchCalls != 0 {
|
||||
t.Fatalf("resource fetched %d times before gateway admission, want 0", fetchCalls)
|
||||
}
|
||||
data, _, err := msg.Media[0].Load(context.Background())
|
||||
if err != nil || !strings.HasPrefix(string(data), string(pngHeader)) {
|
||||
t.Fatalf("deferred load = %x, %v; want png bytes", data, err)
|
||||
}
|
||||
if fetchCalls != 1 {
|
||||
t.Fatalf("resource fetched %d times after load, want 1", fetchCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageFileDownloadFailureKeepsDeferredPlaceholder(t *testing.T) {
|
||||
fetchCalls := 0
|
||||
a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
|
||||
fetchCalls++
|
||||
return nil, "", fmt.Errorf("boom")
|
||||
})
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-file",
|
||||
ChatID: "chat-1",
|
||||
ChatType: "p2p",
|
||||
MsgType: "file",
|
||||
Content: `{"file_key":"file-key-1","file_name":"report.pdf"}`,
|
||||
Sender: testSender("open-user"),
|
||||
})
|
||||
|
||||
msg := <-a.msgCh
|
||||
if len(msg.Media) != 1 || fetchCalls != 0 {
|
||||
t.Fatalf("media items/fetches = %d/%d, want one deferred item and no pre-admission fetch", len(msg.Media), fetchCalls)
|
||||
}
|
||||
if _, _, err := msg.Media[0].Load(context.Background()); err == nil {
|
||||
t.Fatal("deferred load should report the injected failure")
|
||||
}
|
||||
if !strings.Contains(msg.Media[0].FailureText, "report.pdf") {
|
||||
t.Fatalf("fallback = %q, want download-failure placeholder naming the file", msg.Media[0].FailureText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageParsesPostContent(t *testing.T) {
|
||||
fetchCalls := 0
|
||||
a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
|
||||
fetchCalls++
|
||||
if key != "post-img-1" || typ != "image" {
|
||||
t.Fatalf("fetch args = %s/%s, want post-img-1/image", key, typ)
|
||||
}
|
||||
return pngHeader, "", nil
|
||||
})
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-post",
|
||||
ChatID: "chat-1",
|
||||
ChatType: "p2p",
|
||||
MsgType: "post",
|
||||
Content: `{"title":"周报","content":[[{"tag":"text","text":"进展见 "},{"tag":"a","text":"文档","href":"https://example.com/doc"},{"tag":"at","user_name":"张三"}],[{"tag":"img","image_key":"post-img-1"}]]}`,
|
||||
Sender: testSender("open-user"),
|
||||
})
|
||||
|
||||
msg := <-a.msgCh
|
||||
for _, want := range []string{"周报", "进展见", "文档 (https://example.com/doc)", "@张三"} {
|
||||
if !strings.Contains(msg.Text, want) {
|
||||
t.Fatalf("text = %q, want it to contain %q", msg.Text, want)
|
||||
}
|
||||
}
|
||||
if len(msg.Media) != 1 {
|
||||
t.Fatalf("media items = %d, want one deferred embedded image", len(msg.Media))
|
||||
}
|
||||
if fetchCalls != 0 {
|
||||
t.Fatalf("post image fetched %d times before gateway admission, want 0", fetchCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageUnsupportedTypeIgnored(t *testing.T) {
|
||||
a := newTestAdapter(nil)
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-audio",
|
||||
ChatID: "chat-1",
|
||||
ChatType: "p2p",
|
||||
MsgType: "audio",
|
||||
Content: `{"file_key":"audio-key"}`,
|
||||
Sender: testSender("open-user"),
|
||||
})
|
||||
|
||||
select {
|
||||
case msg := <-a.msgCh:
|
||||
t.Fatalf("unsupported message type was queued: %+v", msg)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceMentionPlaceholdersStripsBotAndNamesOthers(t *testing.T) {
|
||||
a := newTestAdapter(nil)
|
||||
a.botID = "ou-bot"
|
||||
got := a.replaceMentionPlaceholders("@_user_1 帮 @_user_2 看看这个", []mentionRef{
|
||||
{Key: "@_user_1", OpenID: "ou-bot", Name: "Reasonix"},
|
||||
{Key: "@_user_2", OpenID: "ou-zhang", Name: "张三"},
|
||||
})
|
||||
if got != "帮 @张三 看看这个" {
|
||||
t.Fatalf("text = %q, want bot mention stripped and peer mention named", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMentionGatingRequiresBotWhenIdentityKnown(t *testing.T) {
|
||||
a := newTestAdapter(nil)
|
||||
a.cfg.RequireMention = true
|
||||
a.botID = "ou-bot"
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-other",
|
||||
ChatID: "chat-group",
|
||||
ChatType: "group",
|
||||
MsgType: "text",
|
||||
Content: `{"text":"@_user_1 在吗"}`,
|
||||
Sender: testSender("open-user"),
|
||||
Mentions: []feishuMention{{Key: "@_user_1", Name: "张三", ID: struct {
|
||||
OpenID string `json:"open_id"`
|
||||
}{OpenID: "ou-zhang"}}},
|
||||
})
|
||||
|
||||
select {
|
||||
case msg := <-a.msgCh:
|
||||
t.Fatalf("message mentioning someone else was queued: %+v", msg)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMarkdownCard(t *testing.T) {
|
||||
content, err := buildMarkdownCard("hello [docs](https://example.com)")
|
||||
if err != nil {
|
||||
t.Fatalf("buildMarkdownCard: %v", err)
|
||||
}
|
||||
var payload struct {
|
||||
Schema string `json:"schema"`
|
||||
Config struct {
|
||||
UpdateMulti bool `json:"update_multi"`
|
||||
} `json:"config"`
|
||||
Body struct {
|
||||
Elements []struct {
|
||||
Tag string `json:"tag"`
|
||||
Content string `json:"content"`
|
||||
} `json:"elements"`
|
||||
} `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &payload); err != nil {
|
||||
t.Fatalf("card content should be valid json: %v", err)
|
||||
}
|
||||
if payload.Schema != "2.0" {
|
||||
t.Fatalf("schema = %q, want 2.0", payload.Schema)
|
||||
}
|
||||
// update_multi must be set or Im.Message.Patch (streaming) is rejected.
|
||||
if !payload.Config.UpdateMulti {
|
||||
t.Fatal("card config.update_multi = false, want true so the card is patchable")
|
||||
}
|
||||
if len(payload.Body.Elements) != 1 || payload.Body.Elements[0].Tag != "markdown" {
|
||||
t.Fatalf("elements = %#v, want one markdown element", payload.Body.Elements)
|
||||
}
|
||||
if payload.Body.Elements[0].Content != "hello [docs](https://example.com)" {
|
||||
t.Fatalf("content = %q, want original markdown", payload.Body.Elements[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyFallbackOnlyForRecalledMessage(t *testing.T) {
|
||||
if isReplyFallbackError(fmt.Errorf("i/o timeout")) {
|
||||
t.Fatal("ambiguous transport errors must not fall back to Create")
|
||||
}
|
||||
if isReplyFallbackError(&feishuAPIError{op: "reply", code: 230013, msg: "no availability"}) {
|
||||
t.Fatal("permission errors must not fall back to Create")
|
||||
}
|
||||
if !isReplyFallbackError(&feishuAPIError{op: "reply", code: feishuReplyRecalledCode, msg: "recalled"}) {
|
||||
t.Fatal("a recalled target should fall back to Create")
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
// maxFeishuMediaBytes matches the bot gateway's inbound media cap.
|
||||
const maxFeishuMediaBytes = 25 * 1024 * 1024
|
||||
|
||||
const resourceDownloadTimeout = 30 * time.Second
|
||||
|
||||
type imageContent struct {
|
||||
ImageKey string `json:"image_key"`
|
||||
}
|
||||
|
||||
type fileContent struct {
|
||||
FileKey string `json:"file_key"`
|
||||
FileName string `json:"file_name"`
|
||||
}
|
||||
|
||||
// mentionRef 是 SDK 事件与 webhook 事件两种 mention 表示的归一化形态。
|
||||
type mentionRef struct {
|
||||
Key string
|
||||
OpenID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func sdkMentionRefs(mentions []*larkim.MentionEvent) []mentionRef {
|
||||
refs := make([]mentionRef, 0, len(mentions))
|
||||
for _, m := range mentions {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
ref := mentionRef{Key: stringPtrValue(m.Key), Name: stringPtrValue(m.Name)}
|
||||
if m.Id != nil {
|
||||
ref.OpenID = stringPtrValue(m.Id.OpenId)
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// mentionsBot 判断消息是否 @ 了本 bot。bot open_id 未知时退回“任意 @ 均放行”
|
||||
// 的旧行为,避免因为 bot/v3/info 拉取失败而完全失聪。
|
||||
func (a *adapter) mentionsBot(mentions []mentionRef) bool {
|
||||
botID := a.botOpenID()
|
||||
if botID == "" {
|
||||
return len(mentions) > 0
|
||||
}
|
||||
for _, m := range mentions {
|
||||
if m.OpenID != "" && m.OpenID == botID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// replaceMentionPlaceholders 把 "@_user_N" 占位符还原为可读的 "@显示名";
|
||||
// bot 自己的占位符直接移除,模型看到的输入不再包含对 bot 的 @。
|
||||
func (a *adapter) replaceMentionPlaceholders(text string, mentions []mentionRef) string {
|
||||
botID := a.botOpenID()
|
||||
for _, m := range mentions {
|
||||
if m.Key == "" {
|
||||
continue
|
||||
}
|
||||
replacement := ""
|
||||
if (botID == "" || m.OpenID != botID) && m.Name != "" {
|
||||
replacement = "@" + m.Name
|
||||
}
|
||||
text = strings.ReplaceAll(text, m.Key, replacement)
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// parseInboundContent parses Feishu content without fetching remote resources.
|
||||
// Media loaders run later, after the gateway allowlist admits the sender.
|
||||
func (a *adapter) parseInboundContent(msgType, content, messageID string) (text string, media []bot.InboundMedia, ok bool) {
|
||||
switch msgType {
|
||||
case "text":
|
||||
var tc textContent
|
||||
if err := json.Unmarshal([]byte(content), &tc); err != nil {
|
||||
a.logger.Warn("feishu message ignored", "reason", "bad_content", "message", logHash(messageID), "err", err)
|
||||
return "", nil, false
|
||||
}
|
||||
return tc.Text, nil, true
|
||||
case "image":
|
||||
var ic imageContent
|
||||
if err := json.Unmarshal([]byte(content), &ic); err != nil || strings.TrimSpace(ic.ImageKey) == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
return "", []bot.InboundMedia{a.deferredMedia(messageID, ic.ImageKey, "image", "", "[图片下载失败]")}, true
|
||||
case "sticker":
|
||||
var fc fileContent
|
||||
if err := json.Unmarshal([]byte(content), &fc); err != nil || strings.TrimSpace(fc.FileKey) == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
return "", []bot.InboundMedia{a.deferredMedia(messageID, fc.FileKey, "image", "", "[sticker]")}, true
|
||||
case "file":
|
||||
var fc fileContent
|
||||
if err := json.Unmarshal([]byte(content), &fc); err != nil || strings.TrimSpace(fc.FileKey) == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
return "", []bot.InboundMedia{a.deferredMedia(messageID, fc.FileKey, "file", fc.FileName, fmt.Sprintf("[文件下载失败: %s]", fc.FileName))}, true
|
||||
case "post":
|
||||
return a.parsePostContent(content, messageID)
|
||||
default:
|
||||
return "", nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// parsePostContent 解析富文本(post)消息:抽取文本、链接、@,并下载内嵌图片。
|
||||
func (a *adapter) parsePostContent(content, messageID string) (string, []bot.InboundMedia, bool) {
|
||||
var post struct {
|
||||
Title string `json:"title"`
|
||||
Content [][]struct {
|
||||
Tag string `json:"tag"`
|
||||
Text string `json:"text"`
|
||||
Href string `json:"href"`
|
||||
UserName string `json:"user_name"`
|
||||
ImageKey string `json:"image_key"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &post); err != nil {
|
||||
a.logger.Warn("feishu message ignored", "reason", "bad_post_content", "message", logHash(messageID), "err", err)
|
||||
return "", nil, false
|
||||
}
|
||||
var b strings.Builder
|
||||
var media []bot.InboundMedia
|
||||
if title := strings.TrimSpace(post.Title); title != "" {
|
||||
b.WriteString(title)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, paragraph := range post.Content {
|
||||
for _, run := range paragraph {
|
||||
switch run.Tag {
|
||||
case "text", "code_block", "md":
|
||||
b.WriteString(run.Text)
|
||||
case "a":
|
||||
switch {
|
||||
case run.Text != "" && run.Href != "" && run.Text != run.Href:
|
||||
fmt.Fprintf(&b, "%s (%s)", run.Text, run.Href)
|
||||
case run.Href != "":
|
||||
b.WriteString(run.Href)
|
||||
default:
|
||||
b.WriteString(run.Text)
|
||||
}
|
||||
case "at":
|
||||
if run.UserName != "" {
|
||||
b.WriteString("@" + run.UserName)
|
||||
}
|
||||
case "img":
|
||||
if strings.TrimSpace(run.ImageKey) == "" {
|
||||
continue
|
||||
}
|
||||
media = append(media, a.deferredMedia(messageID, run.ImageKey, "image", "", "[图片下载失败]"))
|
||||
case "media":
|
||||
b.WriteString("[视频]")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n"), media, true
|
||||
}
|
||||
|
||||
func (a *adapter) deferredMedia(messageID, key, typ, name, failureText string) bot.InboundMedia {
|
||||
return bot.InboundMedia{
|
||||
Name: name,
|
||||
FailureText: failureText,
|
||||
Load: func(ctx context.Context) ([]byte, string, error) {
|
||||
fetch := a.fetchResource
|
||||
if fetch == nil {
|
||||
fetch = a.sdkFetchResource
|
||||
}
|
||||
data, fetchedName, err := fetch(ctx, messageID, key, typ)
|
||||
if err != nil {
|
||||
a.logger.Warn("feishu media download failed", "message", logHash(messageID), "type", typ, "err", err)
|
||||
return nil, "", err
|
||||
}
|
||||
if strings.TrimSpace(name) != "" {
|
||||
fetchedName = name
|
||||
}
|
||||
return data, fetchedName, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// sdkFetchResource 经 SDK 鉴权接口下载消息资源(图片/文件)。
|
||||
func (a *adapter) sdkFetchResource(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
|
||||
client, err := a.sdkClient()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, resourceDownloadTimeout)
|
||||
defer cancel()
|
||||
var data []byte
|
||||
var fileName string
|
||||
err = withTransientRetry(ctx, a.logger, "get message resource", func(ctx context.Context) error {
|
||||
req := larkim.NewGetMessageResourceReqBuilder().
|
||||
MessageId(messageID).
|
||||
FileKey(key).
|
||||
Type(typ).
|
||||
Build()
|
||||
resp, err := client.Im.MessageResource.Get(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("feishu resource error: empty response")
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu resource error: %s", feishuCodeError(resp.Code, resp.Msg))
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.File, maxFeishuMediaBytes+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) == 0 || len(raw) > maxFeishuMediaBytes {
|
||||
return fmt.Errorf("feishu resource must be between 1 byte and 25 MB")
|
||||
}
|
||||
data, fileName = raw, resp.FileName
|
||||
return nil
|
||||
})
|
||||
return data, fileName, err
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
// EditMessage 原地编辑一条已发送的消息(Im.Message.Patch,仅对 interactive
|
||||
// card 消息有效)。bot 渲染器用它实现回合中的流式输出。
|
||||
func (a *adapter) EditMessage(ctx context.Context, messageID string, msg bot.OutboundMessage) error {
|
||||
messageID = strings.TrimSpace(messageID)
|
||||
if messageID == "" {
|
||||
return fmt.Errorf("feishu edit: empty message id")
|
||||
}
|
||||
content, err := buildMarkdownCard(msg.Text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := a.sdkClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return withTransientRetry(ctx, a.logger, "patch message", func(ctx context.Context) error {
|
||||
req := larkim.NewPatchMessageReqBuilder().
|
||||
MessageId(messageID).
|
||||
Body(larkim.NewPatchMessageReqBodyBuilder().Content(content).Build()).
|
||||
Build()
|
||||
resp, err := client.Im.Message.Patch(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("feishu patch error: empty response")
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu patch error: %s", feishuCodeError(resp.Code, resp.Msg))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
maxOutboundMediaBytes = 25 * 1024 * 1024
|
||||
maxOutboundMediaTotalBytes = maxOutboundMediaBytes
|
||||
)
|
||||
|
||||
type outboundMedia struct {
|
||||
name string
|
||||
data []byte
|
||||
}
|
||||
|
||||
// loadOutboundMedia validates and reads every requested item before any remote
|
||||
// message is sent. This keeps local policy failures from producing a successful
|
||||
// text message followed by a retryable /send error.
|
||||
func (a *adapter) loadOutboundMedia(refs []string) ([]outboundMedia, error) {
|
||||
media := make([]outboundMedia, 0, len(refs))
|
||||
totalBytes := 0
|
||||
for _, ref := range refs {
|
||||
data, name, err := a.readOutboundFile(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxOutboundMediaTotalBytes-totalBytes {
|
||||
return nil, fmt.Errorf("feishu outbound media: total payload must not exceed 25 MB")
|
||||
}
|
||||
totalBytes += len(data)
|
||||
media = append(media, outboundMedia{name: name, data: data})
|
||||
}
|
||||
return media, nil
|
||||
}
|
||||
|
||||
func (a *adapter) sendMedia(ctx context.Context, msg bot.OutboundMessage, media []outboundMedia) (bot.SendResult, error) {
|
||||
var result bot.SendResult
|
||||
for _, item := range media {
|
||||
res, err := a.sendOneMedia(ctx, msg, item)
|
||||
if err != nil {
|
||||
a.logger.Warn("feishu media send failed", "err", err)
|
||||
return result, err
|
||||
}
|
||||
result.Merge(res)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *adapter) sendOneMedia(ctx context.Context, msg bot.OutboundMessage, media outboundMedia) (bot.SendResult, error) {
|
||||
mimeType := http.DetectContentType(media.data[:min(len(media.data), 512)])
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
imageKey, err := a.uploadImage(ctx, media.data)
|
||||
if err == nil {
|
||||
content, _ := json.Marshal(map[string]string{"image_key": imageKey})
|
||||
return a.sendSDKContent(ctx, msg, larkim.MsgTypeImage, string(content))
|
||||
}
|
||||
a.logger.Warn("feishu image upload failed; falling back to file", "err", err)
|
||||
}
|
||||
fileKey, err := a.uploadFile(ctx, media.name, media.data)
|
||||
if err != nil {
|
||||
return bot.SendResult{}, err
|
||||
}
|
||||
content, _ := json.Marshal(map[string]string{"file_key": fileKey})
|
||||
return a.sendSDKContent(ctx, msg, larkim.MsgTypeFile, string(content))
|
||||
}
|
||||
|
||||
// readOutboundFile reads a bare filename from exactly one configured root.
|
||||
// os.Root pins each root directory and prevents symlink traversal outside it;
|
||||
// the selected file is then sized and read through the same open handle.
|
||||
func (a *adapter) readOutboundFile(ref string) ([]byte, string, error) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: empty ref")
|
||||
}
|
||||
if len(a.cfg.OutboundMediaRoots) == 0 {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: local file sending is disabled (set outbound_media_roots)")
|
||||
}
|
||||
name := filepath.Base(ref)
|
||||
if name != ref || name == "." || name == ".." || name == string(filepath.Separator) {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: ref must be a bare file name")
|
||||
}
|
||||
|
||||
var selected *os.File
|
||||
var selectedSize int64
|
||||
for i, root := range a.cfg.OutboundMediaRoots {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
if !filepath.IsAbs(root) {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: configured root %d must be absolute", i+1)
|
||||
}
|
||||
rootHandle, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, "", fmt.Errorf("feishu outbound media: configured root %d is unavailable: %w", i+1, err)
|
||||
}
|
||||
file, openErr := rootHandle.Open(name)
|
||||
closeErr := rootHandle.Close()
|
||||
if openErr != nil {
|
||||
if os.IsNotExist(openErr) {
|
||||
continue
|
||||
}
|
||||
return nil, "", fmt.Errorf("feishu outbound media: cannot open %q in configured root %d: %w", name, i+1, openErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, "", fmt.Errorf("feishu outbound media: close configured root %d: %w", i+1, closeErr)
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, "", fmt.Errorf("feishu outbound media: stat %q: %w", name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
_ = file.Close()
|
||||
continue
|
||||
}
|
||||
if selected != nil {
|
||||
_ = file.Close()
|
||||
return nil, "", fmt.Errorf("feishu outbound media: %q exists in more than one configured root", name)
|
||||
}
|
||||
selected = file
|
||||
selectedSize = info.Size()
|
||||
defer selected.Close()
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: %q not found in any configured root", name)
|
||||
}
|
||||
if selectedSize == 0 || selectedSize > maxOutboundMediaBytes {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: %q must be between 1 byte and 25 MB", name)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(selected, maxOutboundMediaBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: read %q: %w", name, err)
|
||||
}
|
||||
if len(raw) == 0 || len(raw) > maxOutboundMediaBytes {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: %q must be between 1 byte and 25 MB", name)
|
||||
}
|
||||
return raw, name, nil
|
||||
}
|
||||
|
||||
func (a *adapter) uploadImage(ctx context.Context, data []byte) (string, error) {
|
||||
client, err := a.sdkClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var key string
|
||||
err = withTransientRetry(ctx, a.logger, "upload image", func(ctx context.Context) error {
|
||||
req := larkim.NewCreateImageReqBuilder().
|
||||
Body(larkim.NewCreateImageReqBodyBuilder().
|
||||
ImageType(larkim.CreateImageImageTypeMessage).
|
||||
Image(bytes.NewReader(data)).
|
||||
Build()).
|
||||
Build()
|
||||
resp, err := client.Im.Image.Create(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("feishu image upload error: empty response")
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu image upload error: %s", feishuCodeError(resp.Code, resp.Msg))
|
||||
}
|
||||
if resp.Data == nil || resp.Data.ImageKey == nil {
|
||||
return fmt.Errorf("feishu image upload error: missing image key")
|
||||
}
|
||||
key = *resp.Data.ImageKey
|
||||
return nil
|
||||
})
|
||||
return key, err
|
||||
}
|
||||
|
||||
func (a *adapter) uploadFile(ctx context.Context, name string, data []byte) (string, error) {
|
||||
client, err := a.sdkClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = "media.bin"
|
||||
}
|
||||
var key string
|
||||
err = withTransientRetry(ctx, a.logger, "upload file", func(ctx context.Context) error {
|
||||
req := larkim.NewCreateFileReqBuilder().
|
||||
Body(larkim.NewCreateFileReqBodyBuilder().
|
||||
FileType(feishuFileType(name)).
|
||||
FileName(name).
|
||||
File(bytes.NewReader(data)).
|
||||
Build()).
|
||||
Build()
|
||||
resp, err := client.Im.File.Create(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("feishu file upload error: empty response")
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu file upload error: %s", feishuCodeError(resp.Code, resp.Msg))
|
||||
}
|
||||
if resp.Data == nil || resp.Data.FileKey == nil {
|
||||
return fmt.Errorf("feishu file upload error: missing file key")
|
||||
}
|
||||
key = *resp.Data.FileKey
|
||||
return nil
|
||||
})
|
||||
return key, err
|
||||
}
|
||||
|
||||
func feishuFileType(name string) string {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".pdf":
|
||||
return "pdf"
|
||||
case ".doc", ".docx":
|
||||
return "doc"
|
||||
case ".xls", ".xlsx":
|
||||
return "xls"
|
||||
case ".ppt", ".pptx":
|
||||
return "ppt"
|
||||
case ".mp4":
|
||||
return "mp4"
|
||||
case ".opus":
|
||||
return "opus"
|
||||
default:
|
||||
return "stream"
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/testenv"
|
||||
)
|
||||
|
||||
func TestReadOutboundFileConfinement(t *testing.T) {
|
||||
root := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "ok.txt"), []byte("hello"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(testenv.TempDir(t), "secret.txt")
|
||||
if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{root}}}
|
||||
|
||||
data, name, err := a.readOutboundFile("ok.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("file inside root should be readable: %v", err)
|
||||
}
|
||||
if string(data) != "hello" || name != "ok.txt" {
|
||||
t.Fatalf("got %q/%q, want hello/ok.txt", data, name)
|
||||
}
|
||||
|
||||
// Paths are never aliases for a staged filename: callers must send a bare
|
||||
// filename, even when an absolute path happens to point inside a root.
|
||||
if _, _, err := a.readOutboundFile(filepath.Join(root, "ok.txt")); err == nil {
|
||||
t.Fatal("absolute paths must be rejected")
|
||||
}
|
||||
|
||||
// Outside every root: rejected before any lookup.
|
||||
if _, _, err := a.readOutboundFile(outside); err == nil {
|
||||
t.Fatal("file outside the roots must be rejected")
|
||||
}
|
||||
|
||||
// Traversal that resolves outside the root: rejected.
|
||||
if _, _, err := a.readOutboundFile(filepath.Join(root, "..", filepath.Base(outside))); err == nil {
|
||||
t.Fatal("traversal out of the root must be rejected")
|
||||
}
|
||||
|
||||
// Relative path: rejected.
|
||||
if _, _, err := a.readOutboundFile("relative/path"); err == nil {
|
||||
t.Fatal("relative path must be rejected")
|
||||
}
|
||||
|
||||
// No roots configured: local sending disabled.
|
||||
off := &adapter{cfg: config.FeishuBotConfig{}}
|
||||
if _, _, err := off.readOutboundFile("ok.txt"); err == nil {
|
||||
t.Fatal("local file sending must be disabled when no roots are set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOutboundFileRejectsAmbiguousName(t *testing.T) {
|
||||
first := testenv.TempDir(t)
|
||||
second := testenv.TempDir(t)
|
||||
for _, root := range []string{first, second} {
|
||||
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte(root), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{first, second}}}
|
||||
if _, _, err := a.readOutboundFile("report.pdf"); err == nil {
|
||||
t.Fatal("the same filename in multiple roots must be rejected as ambiguous")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOutboundFileRequiresAbsoluteRoots(t *testing.T) {
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{"relative-root"}}}
|
||||
if _, _, err := a.readOutboundFile("report.pdf"); err == nil {
|
||||
t.Fatal("relative outbound media roots must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOutboundFileEnforcesActualReadLimit(t *testing.T) {
|
||||
root := testenv.TempDir(t)
|
||||
path := filepath.Join(root, "large.bin")
|
||||
if err := os.WriteFile(path, []byte{1}, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Truncate(path, maxOutboundMediaBytes+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{root}}}
|
||||
if _, _, err := a.readOutboundFile("large.bin"); err == nil {
|
||||
t.Fatal("files larger than the actual read limit must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOutboundMediaEnforcesAggregateLimit(t *testing.T) {
|
||||
root := testenv.TempDir(t)
|
||||
for _, name := range []string{"first.bin", "second.bin"} {
|
||||
path := filepath.Join(root, name)
|
||||
if err := os.WriteFile(path, []byte{1}, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Truncate(path, maxOutboundMediaBytes/2+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{root}}}
|
||||
if _, err := a.loadOutboundMedia([]string{"first.bin", "second.bin"}); err == nil {
|
||||
t.Fatal("aggregate outbound media larger than 25 MB must be rejected before sending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOutboundFileRejectsSymlinkEscape(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation is unreliable on Windows CI")
|
||||
}
|
||||
root := testenv.TempDir(t)
|
||||
secret := filepath.Join(testenv.TempDir(t), "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("top secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A symlink living inside the allowed root but pointing outside it.
|
||||
link := filepath.Join(root, "escape.txt")
|
||||
if err := os.Symlink(secret, link); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{root}}}
|
||||
if _, _, err := a.readOutboundFile("escape.txt"); err == nil {
|
||||
t.Fatal("a symlink escaping the root must be rejected (symlink resolution)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOutboundFileAcceptsSymlinkWithinRoot(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation is unreliable on Windows CI")
|
||||
}
|
||||
root := testenv.TempDir(t)
|
||||
real := filepath.Join(root, "real.txt")
|
||||
if err := os.WriteFile(real, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "link.txt")
|
||||
if err := os.Symlink(filepath.Base(real), link); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{root}}}
|
||||
if _, _, err := a.readOutboundFile("link.txt"); err != nil {
|
||||
t.Fatalf("a symlink staying within the root should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
mrand "math/rand/v2"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/neterr"
|
||||
)
|
||||
|
||||
// newIdempotencyKey returns a random key for the Feishu create/reply `uuid`
|
||||
// dedup field. It is generated once per logical send and reused across
|
||||
// transient retries, so a retry after the request already reached the server
|
||||
// (response read failed) does not post a duplicate visible message. An empty
|
||||
// return (rand failure) simply omits the key — retries then behave as before.
|
||||
func newIdempotencyKey() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
const (
|
||||
transientRetryAttempts = 3
|
||||
transientRetryBaseDelay = 500 * time.Millisecond
|
||||
transientRetryMaxDelay = 5 * time.Second
|
||||
)
|
||||
|
||||
// withTransientRetry retries fn on transport-level failures (connection reset,
|
||||
// timeout, broken pipe) with exponential backoff and jitter. Feishu API-level
|
||||
// errors (rate limit, size limit, permission) are returned as-is — retrying
|
||||
// those blindly would only make things worse.
|
||||
func withTransientRetry(ctx context.Context, logger *slog.Logger, op string, fn func(context.Context) error) error {
|
||||
delay := transientRetryBaseDelay
|
||||
for attempt := 1; ; attempt++ {
|
||||
err := fn(ctx)
|
||||
if err == nil || attempt >= transientRetryAttempts || ctx.Err() != nil || !neterr.IsTransient(err) {
|
||||
return err
|
||||
}
|
||||
wait := delay + time.Duration(mrand.Int64N(int64(delay/4)+1))
|
||||
logger.Warn("feishu transient error; retrying", "op", op, "attempt", attempt, "wait", wait, "err", err)
|
||||
if !bot.SleepCtx(ctx, wait) {
|
||||
return err
|
||||
}
|
||||
delay *= 2
|
||||
if delay > transientRetryMaxDelay {
|
||||
delay = transientRetryMaxDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,157 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/sessioninbox"
|
||||
)
|
||||
|
||||
func (gw *BotGateway) dispatchQueueResult(ctx context.Context, adapter Adapter, key string, msg InboundMessage, cleanup func(), result QueueResult) {
|
||||
if result.Queued {
|
||||
// Unexpected with Cap=max and Drop=new while idle; persist as fallback.
|
||||
if rec, err := gw.followupActiveSessionDurable(ctx, adapter, key, msg); err == nil {
|
||||
gw.storeReactionCleanup(key, cleanup)
|
||||
_ = gw.sendText(ctx, adapter, msg, formatQueuedReceipt(rec))
|
||||
} else {
|
||||
gw.storeReactionCleanup(key, cleanup)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !result.Acquired {
|
||||
gw.logger.Debug("session busy without queue action", "session", key[:8])
|
||||
gw.storeReactionCleanup(key, cleanup)
|
||||
return
|
||||
}
|
||||
// Keep the dispatch loop free to deliver approval/answer replies while the
|
||||
// active turn blocks. The per-session lock still serializes all turns.
|
||||
gw.turnWG.Go(func() { gw.runTurn(ctx, adapter, key, msg, cleanup) })
|
||||
}
|
||||
|
||||
func (gw *BotGateway) finishTurnItem(ctx context.Context, adapter Adapter, key string, fallback InboundMessage, cleanup func()) {
|
||||
// Legacy in-memory pending first (compat), then durable session inbox.
|
||||
next := gw.sessions.Release(key)
|
||||
nextInboxID := ""
|
||||
if next == nil {
|
||||
if queued := gw.nextInboxTurn(key, fallback); queued != nil {
|
||||
next = &queued.msg
|
||||
nextInboxID = queued.itemID
|
||||
// Release made the session idle. If another inbound message wins the
|
||||
// lock, leave this disk-backed item queued for that turn's drain.
|
||||
if !gw.sessions.TryAcquireIdle(key) {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
gw.flushReactionCleanups(key, cleanup)
|
||||
return
|
||||
}
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
nextCleanup := makeReactionCleanup(gw.takeReactionCleanups(key))
|
||||
gw.logger.Info("bot pending message released", "platform", next.Platform, "chat_type", next.ChatType, "chat", hashID(next.ChatID), "session", key[:8])
|
||||
gw.runTurnItem(ctx, adapter, key, *next, nextInboxID, nextCleanup)
|
||||
}
|
||||
|
||||
func (gw *BotGateway) followupActiveSessionDurable(ctx context.Context, adapter Adapter, key string, msg InboundMessage) (sessioninbox.InboxReceipt, error) {
|
||||
api := gw.sessionAPI(key)
|
||||
if api == nil {
|
||||
return sessioninbox.InboxReceipt{}, fmt.Errorf("no session controller")
|
||||
}
|
||||
gw.mu.Lock()
|
||||
state := gw.controllers[key]
|
||||
gw.mu.Unlock()
|
||||
msg = gw.prepareDurableInboxMessage(ctx, adapter, msg, state)
|
||||
return enqueueViaInbox(api, msg, sessioninbox.IntentFollowup)
|
||||
}
|
||||
|
||||
func (gw *BotGateway) collectActiveSessionDurable(ctx context.Context, adapter Adapter, key string, msg InboundMessage) (sessioninbox.InboxReceipt, error) {
|
||||
api := gw.sessionAPI(key)
|
||||
if api == nil {
|
||||
return sessioninbox.InboxReceipt{}, fmt.Errorf("no session controller")
|
||||
}
|
||||
gw.mu.Lock()
|
||||
state := gw.controllers[key]
|
||||
gw.mu.Unlock()
|
||||
msg = gw.prepareDurableInboxMessage(ctx, adapter, msg, state)
|
||||
return collectAppend(api, msg, gw.sessions.Debounce())
|
||||
}
|
||||
|
||||
func (gw *BotGateway) interruptActiveSessionDurable(ctx context.Context, adapter Adapter, key string, msg InboundMessage) (sessioninbox.InboxReceipt, error) {
|
||||
api := gw.sessionAPI(key)
|
||||
if api == nil {
|
||||
return sessioninbox.InboxReceipt{}, fmt.Errorf("no session controller")
|
||||
}
|
||||
gw.mu.Lock()
|
||||
state := gw.controllers[key]
|
||||
gw.mu.Unlock()
|
||||
msg = gw.prepareDurableInboxMessage(ctx, adapter, msg, state)
|
||||
return interruptEnqueue(api, msg)
|
||||
}
|
||||
|
||||
func (gw *BotGateway) prepareDurableInboxMessage(ctx context.Context, adapter Adapter, msg InboundMessage, state *sessionState) InboundMessage {
|
||||
msg.Text = gw.inputTextWithMedia(ctx, adapter, msg, state)
|
||||
if msg.ChatType == ChatGroup {
|
||||
userName := strings.TrimSpace(msg.UserName)
|
||||
if msg.ResolveUserName != nil {
|
||||
if resolved := strings.TrimSpace(msg.ResolveUserName(ctx)); resolved != "" {
|
||||
userName = resolved
|
||||
}
|
||||
}
|
||||
msg.Text = fmt.Sprintf("[%s] %s", userName, msg.Text)
|
||||
msg.UserName = userName
|
||||
}
|
||||
msg.Media = nil
|
||||
msg.MediaURLs = nil
|
||||
msg.ResolveUserName = nil
|
||||
msg.Raw = nil
|
||||
return msg
|
||||
}
|
||||
|
||||
type botInboxTurn struct {
|
||||
itemID string
|
||||
msg InboundMessage
|
||||
}
|
||||
|
||||
// nextInboxTurn loads the next durable FIFO follow-up with its original routing
|
||||
// metadata. RunInboxTurn performs the atomic queued -> running claim.
|
||||
func (gw *BotGateway) nextInboxTurn(key string, fallback InboundMessage) *botInboxTurn {
|
||||
api := gw.sessionAPI(key)
|
||||
if api == nil {
|
||||
return nil
|
||||
}
|
||||
snap := api.InboxSnapshot()
|
||||
if snap.Paused {
|
||||
return nil
|
||||
}
|
||||
for _, it := range snap.Items {
|
||||
if it.State != sessioninbox.StateQueued {
|
||||
continue
|
||||
}
|
||||
_, env, err := api.ReadInboxItem(it.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
msg := botMessageFromEnvelope(env, fallback)
|
||||
if _, hasStoredRoute := env.Extra[botInboxMessageExtraKey]; !hasStoredRoute && it.Idempotency != "" {
|
||||
msg.MessageID = it.Idempotency
|
||||
}
|
||||
return &botInboxTurn{itemID: it.ID, msg: msg}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextInboxMessage is retained for focused queue inspection tests.
|
||||
func (gw *BotGateway) nextInboxMessage(key string) *InboundMessage {
|
||||
next := gw.nextInboxTurn(key, InboundMessage{ChatID: key})
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
return &next.msg
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/testenv"
|
||||
)
|
||||
|
||||
type closeProbeBotController struct {
|
||||
*control.Controller
|
||||
onClose func()
|
||||
}
|
||||
|
||||
type stopWaitBotController struct {
|
||||
stubBotController
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (c *stopWaitBotController) RunTurn(context.Context, string) error {
|
||||
close(c.started)
|
||||
<-c.release
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *stopWaitBotController) SessionPath() string { return "" }
|
||||
func (c *stopWaitBotController) WorkspaceRoot() string { return "" }
|
||||
func (c *stopWaitBotController) Close() {}
|
||||
|
||||
type countingStopAdapter struct {
|
||||
*fakeAdapter
|
||||
mu sync.Mutex
|
||||
stopCalls int
|
||||
}
|
||||
|
||||
type cancelBlockingStartAdapter struct {
|
||||
*fakeAdapter
|
||||
entered chan struct{}
|
||||
}
|
||||
|
||||
func (a *cancelBlockingStartAdapter) Start(ctx context.Context) error {
|
||||
close(a.entered)
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (a *countingStopAdapter) Stop() error {
|
||||
a.mu.Lock()
|
||||
a.stopCalls++
|
||||
a.mu.Unlock()
|
||||
return a.fakeAdapter.Stop()
|
||||
}
|
||||
|
||||
func (a *countingStopAdapter) calls() int {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.stopCalls
|
||||
}
|
||||
|
||||
func (c *closeProbeBotController) Close() {
|
||||
if c.onClose != nil {
|
||||
c.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotGatewayStopClosesSessionsWithoutGatewayLock(t *testing.T) {
|
||||
gw := &BotGateway{
|
||||
controllers: map[string]*sessionState{},
|
||||
}
|
||||
closed := make(chan struct{}, 1)
|
||||
gw.controllers["session"] = &sessionState{
|
||||
ctrl: &closeProbeBotController{
|
||||
Controller: control.New(control.Options{}),
|
||||
onClose: func() {
|
||||
gw.mu.Lock()
|
||||
gw.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
|
||||
closed <- struct{}{}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
gw.Stop()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop blocked while closing a controller")
|
||||
}
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("controller Close was not called")
|
||||
}
|
||||
if len(gw.controllers) != 0 {
|
||||
t.Fatalf("controllers retained after Stop: %d", len(gw.controllers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotGatewayStopWaitsForDispatchHandler(t *testing.T) {
|
||||
adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
gw := NewGatewayWithAdapterBindings(GatewayConfig{
|
||||
Enabled: map[Platform]bool{PlatformFeishu: true},
|
||||
Allowlist: AllowlistConfig{AllowAll: true},
|
||||
OnInbound: func(InboundMessage) {
|
||||
close(entered)
|
||||
<-release
|
||||
},
|
||||
}, []AdapterBinding{{ID: "feishu-lark", Platform: PlatformFeishu, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err := gw.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
adapter.msgCh <- InboundMessage{ChatType: ChatDM, ChatID: "chat", UserID: "user", Text: "/status"}
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("dispatch handler did not start")
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
gw.Stop()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("Stop returned while a dispatch handler was still running")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop did not return after the dispatch handler exited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotGatewayStopWaitsForTurn(t *testing.T) {
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
gw := NewGatewayWithAdapterBindings(GatewayConfig{
|
||||
Enabled: map[Platform]bool{PlatformWeixin: true},
|
||||
Allowlist: AllowlistConfig{AllowAll: true},
|
||||
}, []AdapterBinding{{ID: "weixin", Platform: PlatformWeixin, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
ctrl := &stopWaitBotController{started: make(chan struct{}), release: make(chan struct{})}
|
||||
msg := InboundMessage{Platform: PlatformWeixin, ConnectionID: "weixin", ChatType: ChatDM, ChatID: "chat", UserID: "user", Text: "hello"}
|
||||
key := BuildSessionKey(msg.Session())
|
||||
gw.controllers[key] = &sessionState{ctrl: ctrl, sink: &sessionEventSink{}}
|
||||
if err := gw.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
adapter.msgCh <- msg
|
||||
select {
|
||||
case <-ctrl.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("turn did not start")
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
gw.Stop()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("Stop returned while a turn was still running")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
close(ctrl.release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop did not return after the turn exited")
|
||||
}
|
||||
}
|
||||
|
||||
type typingHoldAdapter struct {
|
||||
*fakeAdapter
|
||||
entered chan struct{} // one send per turn parked in SendTyping
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (a *typingHoldAdapter) SendTyping(context.Context, string) error {
|
||||
a.entered <- struct{}{}
|
||||
<-a.release
|
||||
return nil
|
||||
}
|
||||
|
||||
type cancelPublishBotController struct {
|
||||
stubBotController
|
||||
closeEntered chan struct{} // one send when Close begins
|
||||
closeHold chan struct{} // Close parks here, pinning Stop inside the closeSessions loop
|
||||
turnCtx chan error // ctx.Err() observed on RunTurn entry
|
||||
}
|
||||
|
||||
func (c *cancelPublishBotController) RunTurn(ctx context.Context, _ string) error {
|
||||
c.turnCtx <- ctx.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cancelPublishBotController) SessionPath() string { return "" }
|
||||
func (c *cancelPublishBotController) WorkspaceRoot() string { return "" }
|
||||
func (c *cancelPublishBotController) Close() {
|
||||
c.closeEntered <- struct{}{}
|
||||
<-c.closeHold
|
||||
}
|
||||
|
||||
// Guards the cancel-publication window: runTurn publishes state.cancel under
|
||||
// gw.mu only after the session is already visible in gw.controllers, so Stop
|
||||
// can consume the field from another goroutine while the turn is still on its
|
||||
// way to publication. TestBotGatewayStopWaitsForTurn stops only after RunTurn
|
||||
// has begun (cancel already published) and never covers this window.
|
||||
//
|
||||
// Two sessions are needed because whichever state closeSessions visits first
|
||||
// has its cancel read before Close signals the test — any write released off
|
||||
// that signal is ordered after the read and invisible to the race detector.
|
||||
// The first state's blocking Close pins Stop mid-loop instead, so the second
|
||||
// state's cancel read happens after the turns were let through to publish.
|
||||
// Run with -race: an unlocked read of state.cancel here is a data race.
|
||||
func TestBotGatewayStopBeforeTurnCancelPublication(t *testing.T) {
|
||||
adapter := &typingHoldAdapter{
|
||||
fakeAdapter: newFakeAdapter(PlatformWeixin, "fake-weixin"),
|
||||
entered: make(chan struct{}, 2),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
gw := NewGatewayWithAdapterBindings(GatewayConfig{
|
||||
Enabled: map[Platform]bool{PlatformWeixin: true},
|
||||
Allowlist: AllowlistConfig{AllowAll: true},
|
||||
}, []AdapterBinding{{ID: "weixin", Platform: PlatformWeixin, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
closeEntered := make(chan struct{}, 2)
|
||||
closeHold := make(chan struct{})
|
||||
turnCtx := make(chan error, 2)
|
||||
chats := []string{"chat-a", "chat-b"}
|
||||
for _, chat := range chats {
|
||||
msg := InboundMessage{Platform: PlatformWeixin, ConnectionID: "weixin", ChatType: ChatDM, ChatID: chat, UserID: "user"}
|
||||
gw.controllers[BuildSessionKey(msg.Session())] = &sessionState{
|
||||
ctrl: &cancelPublishBotController{closeEntered: closeEntered, closeHold: closeHold, turnCtx: turnCtx},
|
||||
sink: &sessionEventSink{},
|
||||
}
|
||||
}
|
||||
if err := gw.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
for _, chat := range chats {
|
||||
adapter.msgCh <- InboundMessage{Platform: PlatformWeixin, ConnectionID: "weixin", ChatType: ChatDM, ChatID: chat, UserID: "user", Text: "hello"}
|
||||
}
|
||||
for range chats {
|
||||
select {
|
||||
case <-adapter.entered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("turn did not reach the pre-publication window")
|
||||
}
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
gw.Stop()
|
||||
close(done)
|
||||
}()
|
||||
// Stop is parked inside the closeSessions loop: the first state's cancel is
|
||||
// consumed and its Close is held; the second state's cancel is still unread.
|
||||
select {
|
||||
case <-closeEntered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop did not reach the session close loop")
|
||||
}
|
||||
// Let both turns publish their cancel funcs while Stop stays parked. The
|
||||
// sleep is deliberate and cannot become a channel handshake: observing the
|
||||
// publication would order the write before Stop's read and hide the race
|
||||
// from the detector.
|
||||
close(adapter.release)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
close(closeHold)
|
||||
|
||||
for range chats {
|
||||
select {
|
||||
case err := <-turnCtx:
|
||||
if err == nil {
|
||||
t.Fatal("turn ran with a live context after Stop closed its session")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("turn did not run to completion")
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop did not return after the late turns exited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotGatewayStopIsIdempotent(t *testing.T) {
|
||||
adapter := &countingStopAdapter{fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu")}
|
||||
gw := NewGatewayWithAdapterBindings(GatewayConfig{
|
||||
Enabled: map[Platform]bool{PlatformFeishu: true},
|
||||
}, []AdapterBinding{{ID: "feishu-lark", Platform: PlatformFeishu, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err := gw.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
gw.Stop()
|
||||
gw.Stop()
|
||||
if got := adapter.calls(); got != 1 {
|
||||
t.Fatalf("adapter Stop calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotGatewayStopCancelsConcurrentStart(t *testing.T) {
|
||||
adapter := &cancelBlockingStartAdapter{
|
||||
fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu"),
|
||||
entered: make(chan struct{}),
|
||||
}
|
||||
gw := NewGatewayWithAdapterBindings(GatewayConfig{
|
||||
Enabled: map[Platform]bool{PlatformFeishu: true},
|
||||
}, []AdapterBinding{{ID: "feishu-lark", Platform: PlatformFeishu, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
startDone := make(chan error, 1)
|
||||
go func() { startDone <- gw.Start(context.Background()) }()
|
||||
select {
|
||||
case <-adapter.entered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("adapter Start did not begin")
|
||||
}
|
||||
|
||||
stopDone := make(chan struct{})
|
||||
go func() {
|
||||
gw.Stop()
|
||||
close(stopDone)
|
||||
}()
|
||||
select {
|
||||
case err := <-startDone:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Start error = %v, want context canceled", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop did not cancel the in-progress Start")
|
||||
}
|
||||
select {
|
||||
case <-stopDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop did not finish after Start returned")
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the gw.cfg.Channels / gw.cfg.ConnectionChannels / gw.cfg.ToolApprovalMode
|
||||
// snapshot locking: approval-mode writers mutate those under gw.mu while
|
||||
// sessionOptionsForMessage and the project/session index builders read them.
|
||||
// Run with -race; a lock-free read is a concurrent map read/write crash.
|
||||
func TestBotGatewayToolApprovalModeConcurrentWithConfigReaders(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
gw := &BotGateway{
|
||||
cfg: GatewayConfig{
|
||||
WorkspaceRoot: testenv.TempDir(t),
|
||||
Channels: map[Platform]ChannelConfig{
|
||||
PlatformFeishu: {ToolApprovalMode: control.ToolApprovalAsk},
|
||||
},
|
||||
ConnectionChannels: map[string]ChannelConfig{
|
||||
"feishu-lark": {ToolApprovalMode: control.ToolApprovalAsk},
|
||||
},
|
||||
},
|
||||
controllers: map[string]*sessionState{},
|
||||
sessionOverrides: map[string]sessionRuntimeOverride{},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
|
||||
// Keep both sides finite. The former stop-driven writer kept allocating until
|
||||
// the reader returned and repeatedly tripped Go's Windows GC in normal CI.
|
||||
// The shared start edge orders neither loop after the other, so -race still
|
||||
// observes any missing map lock even if one goroutine happens to run first.
|
||||
const iterations = 200
|
||||
start := make(chan struct{})
|
||||
writerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(writerDone)
|
||||
<-start
|
||||
modes := []string{control.ToolApprovalYolo, control.ToolApprovalAsk, control.ToolApprovalAuto}
|
||||
for i := range iterations {
|
||||
mode := modes[i%len(modes)]
|
||||
gw.UpdateConnectionToolApprovalMode("feishu-lark", mode)
|
||||
gw.mu.Lock()
|
||||
gw.updateToolApprovalModeDefaultLocked(InboundMessage{Platform: PlatformFeishu}, mode)
|
||||
gw.updateToolApprovalModeDefaultLocked(InboundMessage{}, mode)
|
||||
gw.mu.Unlock()
|
||||
runtime.Gosched()
|
||||
}
|
||||
}()
|
||||
|
||||
close(start)
|
||||
connMsg := InboundMessage{Platform: PlatformFeishu, ConnectionID: "feishu-lark", ChatType: ChatDM, ChatID: "chat", UserID: "user"}
|
||||
for range iterations {
|
||||
gw.sessionOptionsForMessage(connMsg)
|
||||
gw.sessionOptionsForMessage(InboundMessage{Platform: PlatformFeishu})
|
||||
projects := gw.buildProjectIndex()
|
||||
gw.buildSessionIndex(projects)
|
||||
runtime.Gosched()
|
||||
}
|
||||
<-writerDone
|
||||
}
|
||||
|
||||
type panickingRootBotController struct {
|
||||
stubBotController
|
||||
}
|
||||
|
||||
func (panickingRootBotController) WorkspaceRoot() string { panic("workspace root unavailable") }
|
||||
|
||||
// Deciding whether to reuse a session calls into its controller under the
|
||||
// gateway lock. A panic there has to release the lock on the way out: held, it
|
||||
// wedges every later message and the turn cleanup that runs while unwinding.
|
||||
func TestAPanicWhileClaimingASessionReleasesTheGatewayLock(t *testing.T) {
|
||||
gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{AllowAll: true}}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
msg := InboundMessage{Platform: PlatformWeixin, ConnectionID: "weixin", ChatType: ChatDM, ChatID: "chat", UserID: "user"}
|
||||
key := BuildSessionKey(msg.Session())
|
||||
gw.controllers[key] = &sessionState{ctrl: panickingRootBotController{}, sink: &sessionEventSink{}}
|
||||
|
||||
panicked := make(chan any, 1)
|
||||
go func() {
|
||||
defer func() { panicked <- recover() }()
|
||||
gw.getOrCreateSession(context.Background(), key, msg)
|
||||
}()
|
||||
if r := <-panicked; r == nil {
|
||||
t.Fatal("the controller's panic did not reach the caller")
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for !gw.mu.TryLock() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the gateway lock is still held after the panic unwound")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,15 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// hashID 对用户/群聊 ID 做脱敏哈希,用于日志和状态展示。
|
||||
func hashID(id string) string {
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
h := sha256.Sum256([]byte(id))
|
||||
return hex.EncodeToString(h[:])[:12]
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/sessioninbox"
|
||||
)
|
||||
|
||||
const botInboxMessageExtraKey = "reasonix.bot.inbound.v1"
|
||||
|
||||
type durableBotMessage struct {
|
||||
Platform Platform `json:"platform"`
|
||||
ConnectionID string `json:"connectionId,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
ChatType ChatType `json:"chatType"`
|
||||
ChatID string `json:"chatId"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
OperatorID string `json:"operatorId,omitempty"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
ThreadID string `json:"threadId,omitempty"`
|
||||
}
|
||||
|
||||
func botInboxExtra(msg InboundMessage) map[string]string {
|
||||
data, err := json.Marshal(durableBotMessage{
|
||||
Platform: msg.Platform, ConnectionID: msg.ConnectionID, Domain: msg.Domain,
|
||||
ChatType: msg.ChatType, ChatID: msg.ChatID, UserID: msg.UserID,
|
||||
UserName: msg.UserName, OperatorID: msg.OperatorID, MessageID: msg.MessageID,
|
||||
ThreadID: msg.ThreadID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{botInboxMessageExtraKey: string(data)}
|
||||
}
|
||||
|
||||
func botMessageFromEnvelope(env sessioninbox.PromptEnvelope, fallback InboundMessage) InboundMessage {
|
||||
msg := fallback
|
||||
if raw := env.Extra[botInboxMessageExtraKey]; raw != "" {
|
||||
var stored durableBotMessage
|
||||
if json.Unmarshal([]byte(raw), &stored) == nil {
|
||||
msg.Platform = stored.Platform
|
||||
msg.ConnectionID = stored.ConnectionID
|
||||
msg.Domain = stored.Domain
|
||||
msg.ChatType = stored.ChatType
|
||||
msg.ChatID = stored.ChatID
|
||||
msg.UserID = stored.UserID
|
||||
msg.UserName = stored.UserName
|
||||
msg.OperatorID = stored.OperatorID
|
||||
msg.MessageID = stored.MessageID
|
||||
msg.ThreadID = stored.ThreadID
|
||||
}
|
||||
}
|
||||
msg.Text = firstNonEmptyBotText(env.DisplayText, env.SubmitText, env.RawText)
|
||||
msg.Media = nil
|
||||
msg.MediaURLs = nil
|
||||
msg.ResolveUserName = nil
|
||||
msg.Raw = nil
|
||||
return msg
|
||||
}
|
||||
|
||||
func firstNonEmptyBotText(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// enqueueViaInbox durably queues an inbound message on the controller's
|
||||
// session inbox. Platform message IDs are used as idempotency keys.
|
||||
func enqueueViaInbox(ctrl control.GatewayAPI, msg InboundMessage, intent sessioninbox.InboxIntent) (sessioninbox.InboxReceipt, error) {
|
||||
if ctrl == nil {
|
||||
return sessioninbox.InboxReceipt{}, fmt.Errorf("no controller")
|
||||
}
|
||||
if ensurer, ok := ctrl.(interface{ EnsureSessionPath() }); ok {
|
||||
ensurer.EnsureSessionPath()
|
||||
}
|
||||
text := strings.TrimSpace(msg.Text)
|
||||
if text == "" {
|
||||
return sessioninbox.InboxReceipt{}, sessioninbox.ErrEmpty
|
||||
}
|
||||
idem := strings.TrimSpace(msg.MessageID)
|
||||
req := control.InboxRequest{
|
||||
Intent: intent,
|
||||
Display: text,
|
||||
Raw: text,
|
||||
Submit: text,
|
||||
Source: "bot",
|
||||
Idempotency: idem,
|
||||
Extra: botInboxExtra(msg),
|
||||
}
|
||||
if intent == sessioninbox.IntentSteer {
|
||||
return ctrl.TryEnqueueAndSteer(req)
|
||||
}
|
||||
// Bot owns synchronous response rendering and drains the durable FIFO itself;
|
||||
// detached Controller dispatch would lose the platform sink.
|
||||
return ctrl.EnqueueInbox(req)
|
||||
}
|
||||
|
||||
// collectAppend tries to append text into the last queued follow-up blob within
|
||||
// the debounce window. Falls back to a new enqueue.
|
||||
func collectAppend(ctrl control.GatewayAPI, msg InboundMessage, debounce time.Duration) (sessioninbox.InboxReceipt, error) {
|
||||
if ctrl == nil {
|
||||
return sessioninbox.InboxReceipt{}, fmt.Errorf("no controller")
|
||||
}
|
||||
snap := ctrl.InboxSnapshot()
|
||||
// Find last queued follow-up.
|
||||
var last *sessioninbox.InboxItemMeta
|
||||
for i, it := range slices.Backward(snap.Items) {
|
||||
if it.State == sessioninbox.StateQueued && it.Intent == sessioninbox.IntentFollowup {
|
||||
last = &snap.Items[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
text := strings.TrimSpace(msg.Text)
|
||||
if last != nil && debounce > 0 && time.Since(last.UpdatedAt) < debounce {
|
||||
if _, err := ctrl.AppendInboxItem(last.ID, text, strings.TrimSpace(msg.MessageID), botInboxExtra(msg)); err == nil {
|
||||
return sessioninbox.InboxReceipt{
|
||||
ItemID: last.ID,
|
||||
Disposition: sessioninbox.DispositionQueuedFollowup,
|
||||
Position: snap.Capacity.Items,
|
||||
Paused: snap.Paused,
|
||||
Capacity: snap.Capacity,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return enqueueViaInbox(ctrl, msg, sessioninbox.IntentFollowup)
|
||||
}
|
||||
|
||||
// interruptEnqueue cancels the current turn and moves a new item to the front.
|
||||
func interruptEnqueue(ctrl control.GatewayAPI, msg InboundMessage) (sessioninbox.InboxReceipt, error) {
|
||||
if ctrl == nil {
|
||||
return sessioninbox.InboxReceipt{}, fmt.Errorf("no controller")
|
||||
}
|
||||
ctrl.Cancel()
|
||||
rec, err := enqueueViaInbox(ctrl, msg, sessioninbox.IntentFollowup)
|
||||
if err != nil {
|
||||
return rec, err
|
||||
}
|
||||
// Move to front (index 0) so it runs next; do not delete existing queue.
|
||||
if err := ctrl.MoveInboxItem(rec.ItemID, 0); err != nil {
|
||||
slog.Warn("bot: move interrupt item to front", "err", err)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// formatQueuedReceipt is the user-visible durable queue confirmation.
|
||||
func formatQueuedReceipt(rec sessioninbox.InboxReceipt) string {
|
||||
return fmt.Sprintf("已持久排队 #%s", shortItemID(rec.ItemID))
|
||||
}
|
||||
|
||||
func shortItemID(id string) string {
|
||||
if len(id) <= 8 {
|
||||
return id
|
||||
}
|
||||
return id[:8]
|
||||
}
|
||||
|
||||
// warnDeprecatedQueueDrop logs once when an old drop policy is still configured.
|
||||
func warnDeprecatedQueueDrop(drop string) {
|
||||
switch NormalizeQueueDrop(drop) {
|
||||
case QueueDropOld, QueueDropSummarize:
|
||||
slog.Warn("bot: queue_drop is deprecated; capacity rejections no longer drop old messages", "drop", drop)
|
||||
}
|
||||
}
|
||||
|
||||
// handleQueueInboxCommand extends /queue with durable inbox management.
|
||||
// Returns handled=false for mode-switch forms of /queue.
|
||||
func (gw *BotGateway) handleQueueInboxCommand(ctx context.Context, key string, msg InboundMessage) (string, bool, bool) {
|
||||
_ = ctx
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 2 {
|
||||
return "", false, false
|
||||
}
|
||||
sub := strings.ToLower(parts[1])
|
||||
if !isBotInboxCommand(sub) {
|
||||
return "", false, false
|
||||
}
|
||||
api := gw.sessionAPI(key)
|
||||
if api == nil {
|
||||
return "当前没有可管理的会话队列。", true, false
|
||||
}
|
||||
// Group chats: only the same session initiator or admins may read bodies.
|
||||
// Mode-level admin gate is enforced by requireCommandRole on sensitive ops.
|
||||
switch sub {
|
||||
case "list", "ls":
|
||||
return formatBotInboxList(api), true, false
|
||||
case "show":
|
||||
return showBotInboxItem(api, parts), true, false
|
||||
case "delete", "rm":
|
||||
return deleteBotInboxItem(api, parts), true, false
|
||||
case "move":
|
||||
return moveBotInboxItem(api, parts), true, false
|
||||
case "pause":
|
||||
return setBotInboxPaused(api, true), true, false
|
||||
case "resume":
|
||||
reply := setBotInboxPaused(api, false)
|
||||
return reply, true, reply == "inbox resumed"
|
||||
case "retry":
|
||||
reply := retryBotInboxItem(api, parts)
|
||||
return reply, true, strings.HasPrefix(reply, "retry #")
|
||||
case "refresh":
|
||||
return refreshBotInboxItem(api, parts), true, false
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
func formatBotInboxList(api control.GatewayAPI) string {
|
||||
snap := api.InboxSnapshot()
|
||||
if len(snap.Items) == 0 {
|
||||
return "inbox empty" + pausedSuffix(snap.Paused)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "inbox items=%d", len(snap.Items))
|
||||
if snap.Paused {
|
||||
b.WriteString(" paused")
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
limit := min(len(snap.Items), 15)
|
||||
for i := range limit {
|
||||
it := snap.Items[i]
|
||||
fmt.Fprintf(&b, "%d. [%s/%s] %s #%s\n", i+1, it.Intent, it.State, it.Preview, shortItemID(it.ID))
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func showBotInboxItem(api control.GatewayAPI, parts []string) string {
|
||||
if len(parts) < 3 {
|
||||
return "用法: /queue show <n|id>"
|
||||
}
|
||||
id, err := resolveBotInboxRef(api, parts[2])
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
_, env, err := api.ReadInboxItem(id)
|
||||
if err != nil {
|
||||
return "show: " + err.Error()
|
||||
}
|
||||
return env.SubmitText
|
||||
}
|
||||
|
||||
func deleteBotInboxItem(api control.GatewayAPI, parts []string) string {
|
||||
if len(parts) < 3 {
|
||||
return "用法: /queue delete <n|id>"
|
||||
}
|
||||
id, err := resolveBotInboxRef(api, parts[2])
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if err := api.DeleteInboxItem(id); err != nil {
|
||||
return "delete: " + err.Error()
|
||||
}
|
||||
return "deleted #" + shortItemID(id)
|
||||
}
|
||||
|
||||
func moveBotInboxItem(api control.GatewayAPI, parts []string) string {
|
||||
if len(parts) < 4 {
|
||||
return "用法: /queue move <n|id> <to>"
|
||||
}
|
||||
id, err := resolveBotInboxRef(api, parts[2])
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
var to int
|
||||
if _, err := fmt.Sscanf(parts[3], "%d", &to); err != nil {
|
||||
return "move: bad index"
|
||||
}
|
||||
if err := api.MoveInboxItem(id, to-1); err != nil {
|
||||
return "move: " + err.Error()
|
||||
}
|
||||
return "moved #" + shortItemID(id)
|
||||
}
|
||||
|
||||
func setBotInboxPaused(api control.GatewayAPI, paused bool) string {
|
||||
setter, ok := any(api).(interface{ SetInboxPausedPassive(bool) error })
|
||||
var err error
|
||||
if ok {
|
||||
err = setter.SetInboxPausedPassive(paused)
|
||||
} else {
|
||||
err = api.SetInboxPaused(paused)
|
||||
}
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if paused {
|
||||
return "inbox paused"
|
||||
}
|
||||
return "inbox resumed"
|
||||
}
|
||||
|
||||
func retryBotInboxItem(api control.GatewayAPI, parts []string) string {
|
||||
if len(parts) < 3 {
|
||||
return "用法: /queue retry <n|id>"
|
||||
}
|
||||
id, err := resolveBotInboxRef(api, parts[2])
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
retrier, ok := any(api).(interface{ RetryInboxItemPassive(string) error })
|
||||
var retryErr error
|
||||
if ok {
|
||||
retryErr = retrier.RetryInboxItemPassive(id)
|
||||
} else {
|
||||
retryErr = api.RetryInboxItem(id)
|
||||
}
|
||||
if retryErr != nil {
|
||||
return retryErr.Error()
|
||||
}
|
||||
return "retry #" + shortItemID(id)
|
||||
}
|
||||
|
||||
func refreshBotInboxItem(api control.GatewayAPI, parts []string) string {
|
||||
if len(parts) < 3 {
|
||||
return "用法: /queue refresh <n|id>"
|
||||
}
|
||||
id, err := resolveBotInboxRef(api, parts[2])
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if err := api.RefreshInboxReferences(id); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return "refs refreshed #" + shortItemID(id)
|
||||
}
|
||||
|
||||
func isBotInboxCommand(sub string) bool {
|
||||
switch sub {
|
||||
case "list", "ls", "show", "delete", "rm", "move", "pause", "resume", "retry", "refresh":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pausedSuffix(paused bool) string {
|
||||
if paused {
|
||||
return " (paused)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveBotInboxRef(api control.GatewayAPI, ref string) (string, error) {
|
||||
snap := api.InboxSnapshot()
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(ref, "%d", &n); err == nil && n >= 1 && n <= len(snap.Items) {
|
||||
return snap.Items[n-1].ID, nil
|
||||
}
|
||||
for _, it := range snap.Items {
|
||||
if it.ID == ref || strings.HasPrefix(it.ID, ref) {
|
||||
return it.ID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unknown inbox item %q", ref)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/sessioninbox"
|
||||
"reasonix/internal/testenv"
|
||||
)
|
||||
|
||||
func TestCollectAppendDeduplicatesPlatformRedelivery(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
ctrl := control.New(control.Options{
|
||||
SessionPath: filepath.Join(dir, "s.jsonl"),
|
||||
SessionDir: dir,
|
||||
Sink: event.Discard,
|
||||
})
|
||||
first := InboundMessage{
|
||||
Platform: PlatformFeishu, ChatType: ChatDM, ChatID: "chat-1",
|
||||
UserID: "user-1", MessageID: "msg-1", Text: "first request",
|
||||
}
|
||||
rec, err := enqueueViaInbox(ctrl, first, sessioninbox.IntentFollowup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := first
|
||||
second.MessageID = "msg-2"
|
||||
second.Text = "second request"
|
||||
for range 2 {
|
||||
got, err := collectAppend(ctrl, second, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ItemID != rec.ItemID {
|
||||
t.Fatalf("collect item = %q, want %q", got.ItemID, rec.ItemID)
|
||||
}
|
||||
}
|
||||
_, env, err := ctrl.ReadInboxItem(rec.ItemID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Count(env.SubmitText, "second request") != 1 {
|
||||
t.Fatalf("redelivered collect body = %q", env.SubmitText)
|
||||
}
|
||||
recovered := botMessageFromEnvelope(env, InboundMessage{})
|
||||
if recovered.Platform != second.Platform || recovered.ChatID != second.ChatID || recovered.MessageID != second.MessageID {
|
||||
t.Fatalf("durable route metadata was not updated: %+v", recovered)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/testenv"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testenv.RunWithIsolatedUserState(m)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
const maxBotMediaBytes = 25 * 1024 * 1024
|
||||
|
||||
var botMediaHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
func saveInboundMedia(ctx context.Context, workspaceRoot string, mediaURLs []string) (refs []string, errs []error) {
|
||||
for _, rawURL := range mediaURLs {
|
||||
ref, err := saveOneInboundMedia(ctx, workspaceRoot, rawURL)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs, errs
|
||||
}
|
||||
|
||||
func saveOneInboundMedia(ctx context.Context, workspaceRoot, rawURL string) (string, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return "", fmt.Errorf("empty media URL")
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", fmt.Errorf("unsupported media URL scheme %q", u.Scheme)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := botMediaHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("download media: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBotMediaBytes+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) == 0 || len(raw) > maxBotMediaBytes {
|
||||
return "", fmt.Errorf("media must be between 1 byte and 25 MB")
|
||||
}
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if semi := strings.Index(contentType, ";"); semi >= 0 {
|
||||
contentType = strings.TrimSpace(contentType[:semi])
|
||||
}
|
||||
if strings.TrimSpace(contentType) == "" || strings.EqualFold(contentType, "application/octet-stream") {
|
||||
contentType = http.DetectContentType(raw[:min(len(raw), 512)])
|
||||
}
|
||||
name := mediaFilename(u, contentType)
|
||||
if strings.HasPrefix(strings.ToLower(contentType), "image/") {
|
||||
return control.SaveImageBytesInRoot(workspaceRoot, contentType, raw)
|
||||
}
|
||||
return control.SaveAttachmentBytesInRoot(workspaceRoot, name, raw)
|
||||
}
|
||||
|
||||
func saveInboundMediaItems(ctx context.Context, workspaceRoot string, items []InboundMedia) (refs, fallbacks []string, errs []error) {
|
||||
for _, item := range items {
|
||||
ref, err := saveOneInboundMediaItem(ctx, workspaceRoot, item)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
if fallback := strings.TrimSpace(item.FailureText); fallback != "" {
|
||||
fallbacks = append(fallbacks, fallback)
|
||||
}
|
||||
continue
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs, fallbacks, errs
|
||||
}
|
||||
|
||||
func saveOneInboundMediaItem(ctx context.Context, workspaceRoot string, item InboundMedia) (string, error) {
|
||||
if item.Load != nil {
|
||||
data, name, err := item.Load(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
item.Data = data
|
||||
if strings.TrimSpace(item.Name) == "" {
|
||||
item.Name = name
|
||||
}
|
||||
}
|
||||
if len(item.Data) == 0 || len(item.Data) > maxBotMediaBytes {
|
||||
return "", fmt.Errorf("media must be between 1 byte and 25 MB")
|
||||
}
|
||||
contentType := strings.TrimSpace(item.MIME)
|
||||
if contentType == "" || strings.EqualFold(contentType, "application/octet-stream") {
|
||||
contentType = http.DetectContentType(item.Data[:min(len(item.Data), 512)])
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(contentType), "image/") {
|
||||
if ref, err := control.SaveImageBytesInRoot(workspaceRoot, contentType, item.Data); err == nil {
|
||||
return ref, nil
|
||||
}
|
||||
// 图片超过 10MB 上限或签名不被识别时,降级存为普通附件而不是丢弃。
|
||||
}
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 {
|
||||
name = "media" + exts[0]
|
||||
} else {
|
||||
name = "media.bin"
|
||||
}
|
||||
}
|
||||
return control.SaveAttachmentBytesInRoot(workspaceRoot, name, item.Data)
|
||||
}
|
||||
|
||||
func appendMediaFallbacks(text string, fallbacks []string) string {
|
||||
for _, fallback := range fallbacks {
|
||||
fallback = strings.TrimSpace(fallback)
|
||||
if fallback == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(text) != "" {
|
||||
text += "\n"
|
||||
}
|
||||
text += fallback
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func mediaFilename(u *url.URL, contentType string) string {
|
||||
base := path.Base(u.Path)
|
||||
if base == "." || base == "/" || strings.TrimSpace(base) == "" {
|
||||
if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 {
|
||||
return "media" + exts[0]
|
||||
}
|
||||
return "media.bin"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func appendMediaRefs(text string, refs []string) string {
|
||||
if len(refs) == 0 {
|
||||
return text
|
||||
}
|
||||
var b strings.Builder
|
||||
if strings.TrimSpace(text) != "" {
|
||||
b.WriteString(text)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString("Attachments:")
|
||||
for _, ref := range refs {
|
||||
b.WriteString("\n@")
|
||||
b.WriteString(ref)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/testenv"
|
||||
)
|
||||
|
||||
func TestSaveInboundMediaStoresWorkspaceImageAttachment(t *testing.T) {
|
||||
raw, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=")
|
||||
if err != nil {
|
||||
t.Fatalf("decode png: %v", err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(raw)
|
||||
}))
|
||||
defer srv.Close()
|
||||
workspace := testenv.TempDir(t)
|
||||
|
||||
ref, err := saveOneInboundMedia(context.Background(), workspace, srv.URL+"/shot.png")
|
||||
if err != nil {
|
||||
t.Fatalf("saveOneInboundMedia: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(ref, ".reasonix/attachments/") || !strings.HasSuffix(ref, ".png") {
|
||||
t.Fatalf("ref = %q, want png attachment ref", ref)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workspace, filepath.FromSlash(ref))); err != nil {
|
||||
t.Fatalf("stored attachment missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveInboundMediaItemsStoresBytesAndReportsErrors(t *testing.T) {
|
||||
png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=")
|
||||
if err != nil {
|
||||
t.Fatalf("decode png: %v", err)
|
||||
}
|
||||
workspace := testenv.TempDir(t)
|
||||
|
||||
refs, fallbacks, errs := saveInboundMediaItems(context.Background(), workspace, []InboundMedia{
|
||||
{MIME: "image/png", Data: png},
|
||||
{Name: "notes.txt", MIME: "text/plain", Data: []byte("hello from feishu")},
|
||||
{Name: "empty.bin", FailureText: "[file unavailable]"}, // no data -> error
|
||||
})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("errs = %v, want exactly one for the empty item", errs)
|
||||
}
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("refs = %v, want image and text attachment", refs)
|
||||
}
|
||||
if len(fallbacks) != 1 || fallbacks[0] != "[file unavailable]" {
|
||||
t.Fatalf("fallbacks = %v, want the failed item's placeholder", fallbacks)
|
||||
}
|
||||
if !strings.HasSuffix(refs[0], ".png") {
|
||||
t.Fatalf("image ref = %q, want .png attachment", refs[0])
|
||||
}
|
||||
if !strings.HasSuffix(refs[1], ".txt") {
|
||||
t.Fatalf("text ref = %q, want .txt attachment", refs[1])
|
||||
}
|
||||
for _, ref := range refs {
|
||||
if _, err := os.Stat(filepath.Join(workspace, filepath.FromSlash(ref))); err != nil {
|
||||
t.Fatalf("stored attachment missing: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveInboundMediaItemsLoadsDeferredBytesAfterAdmission(t *testing.T) {
|
||||
workspace := testenv.TempDir(t)
|
||||
called := false
|
||||
refs, fallbacks, errs := saveInboundMediaItems(context.Background(), workspace, []InboundMedia{{
|
||||
FailureText: "[download failed]",
|
||||
Load: func(context.Context) ([]byte, string, error) {
|
||||
called = true
|
||||
return []byte("deferred data"), "notes.txt", nil
|
||||
},
|
||||
}})
|
||||
if !called {
|
||||
t.Fatal("deferred loader was not called")
|
||||
}
|
||||
if len(errs) != 0 || len(fallbacks) != 0 || len(refs) != 1 || !strings.HasSuffix(refs[0], ".txt") {
|
||||
t.Fatalf("refs/fallbacks/errs = %v/%v/%v, want one saved text attachment", refs, fallbacks, errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputTextWithMediaKeepsDeferredFailurePlaceholder(t *testing.T) {
|
||||
workspace := testenv.TempDir(t)
|
||||
adapter := newFakeAdapter(PlatformFeishu, "feishu")
|
||||
gw := NewGateway(GatewayConfig{WorkspaceRoot: workspace}, map[Platform]Adapter{PlatformFeishu: adapter}, discardLogger())
|
||||
input := gw.inputTextWithMedia(context.Background(), adapter, InboundMessage{
|
||||
Platform: PlatformFeishu,
|
||||
ChatType: ChatDM,
|
||||
ChatID: "chat",
|
||||
Media: []InboundMedia{{
|
||||
FailureText: "[文件下载失败: report.pdf]",
|
||||
Load: func(context.Context) ([]byte, string, error) {
|
||||
return nil, "", os.ErrNotExist
|
||||
},
|
||||
}},
|
||||
}, nil)
|
||||
if input != "[文件下载失败: report.pdf]" {
|
||||
t.Fatalf("input = %q, want deferred failure placeholder", input)
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package bot
|
||||
|
||||
// Sending back out: what was sent (so the gateway does not answer itself), and
|
||||
// the reactions that have to be cleaned up afterwards.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (gw *BotGateway) storeReactionCleanup(key string, cleanup func()) {
|
||||
if cleanup == nil {
|
||||
return
|
||||
}
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
gw.pendingReactionCleanups[key] = append(gw.pendingReactionCleanups[key], cleanup)
|
||||
}
|
||||
|
||||
func (gw *BotGateway) flushReactionCleanups(key string, cleanup func()) {
|
||||
stored := gw.takeReactionCleanups(key)
|
||||
runReactionCleanups(stored)
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *BotGateway) takeReactionCleanups(key string) []func() {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
stored := gw.pendingReactionCleanups[key]
|
||||
delete(gw.pendingReactionCleanups, key)
|
||||
return stored
|
||||
}
|
||||
|
||||
func runReactionCleanups(cleanups []func()) {
|
||||
for _, cleanup := range cleanups {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeReactionCleanup(cleanups []func()) func() {
|
||||
if len(cleanups) == 0 {
|
||||
return nil
|
||||
}
|
||||
return func() {
|
||||
runReactionCleanups(cleanups)
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *BotGateway) addPendingReaction(ctx context.Context, plat Platform, adapter Adapter, msg InboundMessage) func() {
|
||||
if strings.TrimSpace(msg.MessageID) == "" {
|
||||
return nil
|
||||
}
|
||||
reactor, ok := adapter.(pendingReactionAdapter)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
cleanup, err := reactor.AddPendingReaction(ctx, msg.MessageID)
|
||||
if err != nil {
|
||||
gw.logger.Warn("pending reaction failed", "platform", plat, "err", err)
|
||||
return nil
|
||||
}
|
||||
return cleanup
|
||||
}
|
||||
|
||||
func (gw *BotGateway) isSelfMessage(msg InboundMessage) bool {
|
||||
if !gw.cfg.IgnoreSelfMessages {
|
||||
return false
|
||||
}
|
||||
actor := strings.TrimSpace(msg.UserID)
|
||||
if strings.TrimSpace(msg.OperatorID) != "" {
|
||||
actor = strings.TrimSpace(msg.OperatorID)
|
||||
}
|
||||
if actor != "" && gw.selfUserIDs[msg.Platform][actor] {
|
||||
return true
|
||||
}
|
||||
messageID := strings.TrimSpace(msg.MessageID)
|
||||
if messageID == "" {
|
||||
return false
|
||||
}
|
||||
key := outboundMessageKey(msg.Platform, msg.ConnectionID, msg.Domain, msg.ChatID, messageID)
|
||||
now := time.Now()
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
gw.pruneOutboundMessagesLocked(now)
|
||||
_, ok := gw.outboundMessageIDs[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (gw *BotGateway) rememberOutboundMessage(platform Platform, connID, domain, chatID, messageID string) {
|
||||
messageID = strings.TrimSpace(messageID)
|
||||
if !gw.cfg.IgnoreSelfMessages || messageID == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
key := outboundMessageKey(platform, connID, domain, chatID, messageID)
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
gw.pruneOutboundMessagesLocked(now)
|
||||
gw.outboundMessageIDs[key] = now.Add(outboundEchoTTL)
|
||||
}
|
||||
|
||||
func (gw *BotGateway) pruneOutboundMessagesLocked(now time.Time) {
|
||||
for key, expiresAt := range gw.outboundMessageIDs {
|
||||
if !expiresAt.After(now) {
|
||||
delete(gw.outboundMessageIDs, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func outboundMessageKey(platform Platform, connID, domain, chatID, messageID string) string {
|
||||
return strings.Join([]string{
|
||||
string(platform),
|
||||
strings.TrimSpace(connID),
|
||||
strings.TrimSpace(domain),
|
||||
strings.TrimSpace(chatID),
|
||||
strings.TrimSpace(messageID),
|
||||
}, "\x00")
|
||||
}
|
||||
|
||||
func (gw *BotGateway) sendText(ctx context.Context, adapter Adapter, msg InboundMessage, text string) error {
|
||||
out := OutboundMessage{
|
||||
ConnectionID: msg.ConnectionID,
|
||||
Domain: msg.Domain,
|
||||
ChatID: msg.ChatID,
|
||||
ChatType: msg.ChatType,
|
||||
Text: text,
|
||||
ReplyToMsgID: msg.MessageID,
|
||||
}
|
||||
binding := AdapterBinding{
|
||||
ID: strings.TrimSpace(msg.ConnectionID),
|
||||
Domain: strings.TrimSpace(msg.Domain),
|
||||
Platform: msg.Platform,
|
||||
Adapter: adapter,
|
||||
}
|
||||
if binding.Platform == "" && adapter != nil {
|
||||
binding.Platform = adapter.Platform()
|
||||
}
|
||||
if binding.ID == "" && adapter != nil {
|
||||
binding.ID = adapter.Name()
|
||||
}
|
||||
result, err := gw.sendViaAdapter(ctx, binding, out)
|
||||
if err != nil {
|
||||
gw.logger.Warn("bot send failed", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "reply_to", hashID(msg.MessageID), "err", err)
|
||||
return err
|
||||
}
|
||||
gw.logger.Info("bot send completed", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "reply_to", hashID(msg.MessageID), "message", hashID(result.MessageID))
|
||||
return err
|
||||
}
|
||||
|
||||
func (gw *BotGateway) sendViaAdapter(ctx context.Context, binding AdapterBinding, msg OutboundMessage) (SendResult, error) {
|
||||
if binding.Adapter == nil {
|
||||
return SendResult{}, errors.New("bot send: adapter is nil")
|
||||
}
|
||||
if strings.TrimSpace(msg.ConnectionID) == "" {
|
||||
msg.ConnectionID = binding.ID
|
||||
}
|
||||
if strings.TrimSpace(msg.Domain) == "" {
|
||||
msg.Domain = binding.Domain
|
||||
}
|
||||
result, err := binding.Adapter.Send(ctx, msg)
|
||||
gw.markAdapterSend(binding, err)
|
||||
for _, messageID := range result.DeliveredMessageIDs() {
|
||||
gw.rememberOutboundMessage(binding.Platform, binding.ID, binding.Domain, msg.ChatID, messageID)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SendToAdapter sends a message through the adapter identified by connID.
|
||||
// Returns an error if no matching adapter is found.
|
||||
func (gw *BotGateway) SendToAdapter(ctx context.Context, connID, domain string, msg OutboundMessage) (SendResult, error) {
|
||||
connID = strings.TrimSpace(connID)
|
||||
domain = strings.TrimSpace(domain)
|
||||
var target AdapterBinding
|
||||
gw.mu.Lock()
|
||||
for _, binding := range gw.adapters {
|
||||
if strings.TrimSpace(binding.ID) == connID &&
|
||||
(domain == "" || strings.EqualFold(strings.TrimSpace(binding.Domain), domain)) {
|
||||
target = binding
|
||||
break
|
||||
}
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
if target.Adapter != nil {
|
||||
return gw.sendViaAdapter(ctx, target, msg)
|
||||
}
|
||||
return SendResult{}, fmt.Errorf("SendToAdapter: no adapter found for connection %q (domain %q)", connID, domain)
|
||||
}
|
||||
|
||||
// SendTextToAdapter sends a plain text message through the adapter identified by connID.
|
||||
func (gw *BotGateway) SendTextToAdapter(ctx context.Context, connID, domain, chatID string, chatType ChatType, text string) (SendResult, error) {
|
||||
return gw.SendToAdapter(ctx, connID, domain, OutboundMessage{
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
Text: text,
|
||||
})
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/fileutil"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPairingTTL = time.Hour
|
||||
defaultPairingMaxPending = 3
|
||||
pairingAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
)
|
||||
|
||||
type PairingConfig struct {
|
||||
Enabled bool
|
||||
RequestTTL time.Duration
|
||||
MaxPendingPerPlatform int
|
||||
}
|
||||
|
||||
type PairingRequest struct {
|
||||
Code string `json:"code"`
|
||||
Platform Platform `json:"platform"`
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
ChatType ChatType `json:"chat_type"`
|
||||
ChatID string `json:"chat_id"`
|
||||
UserID string `json:"user_id"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type pairingFile struct {
|
||||
Requests []PairingRequest `json:"requests"`
|
||||
}
|
||||
|
||||
// pairingMu serializes every load-modify-save of the pairing store, so
|
||||
// concurrent adapter dispatch goroutines (offerPairing) and CLI approve/reject
|
||||
// can't interleave RMWs and drop each other's requests. It must not be held
|
||||
// across user-config edits (see ApprovePairingCode).
|
||||
var pairingMu sync.Mutex
|
||||
|
||||
func NormalizePairingConfig(cfg PairingConfig) PairingConfig {
|
||||
if cfg.RequestTTL <= 0 {
|
||||
cfg.RequestTTL = defaultPairingTTL
|
||||
}
|
||||
if cfg.MaxPendingPerPlatform <= 0 {
|
||||
cfg.MaxPendingPerPlatform = defaultPairingMaxPending
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func PairingStorePath() string {
|
||||
dir := config.MemoryUserDir()
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, "bot", "pairing.json")
|
||||
}
|
||||
|
||||
func CreateOrRefreshPairingRequest(msg InboundMessage, cfg PairingConfig) (PairingRequest, bool, error) {
|
||||
cfg = NormalizePairingConfig(cfg)
|
||||
if !cfg.Enabled {
|
||||
return PairingRequest{}, false, errors.New("bot pairing is disabled")
|
||||
}
|
||||
if msg.ChatType != ChatDM && msg.ChatType != ChatDirect {
|
||||
return PairingRequest{}, false, errors.New("bot pairing only supports direct messages")
|
||||
}
|
||||
if strings.TrimSpace(msg.UserID) == "" || strings.TrimSpace(msg.ChatID) == "" {
|
||||
return PairingRequest{}, false, errors.New("bot pairing needs user_id and chat_id")
|
||||
}
|
||||
path := PairingStorePath()
|
||||
if path == "" {
|
||||
return PairingRequest{}, false, errors.New("reasonix user state directory is unavailable")
|
||||
}
|
||||
pairingMu.Lock()
|
||||
defer pairingMu.Unlock()
|
||||
store, err := loadPairingFile(path)
|
||||
if err != nil {
|
||||
return PairingRequest{}, false, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
store.Requests = pruneExpiredPairingRequests(store.Requests, now)
|
||||
for _, req := range store.Requests {
|
||||
if pairingRequestMatches(req, msg) {
|
||||
return req, false, savePairingFile(path, store)
|
||||
}
|
||||
}
|
||||
pendingForPlatform := 0
|
||||
for _, req := range store.Requests {
|
||||
if req.Platform == msg.Platform && strings.TrimSpace(req.ConnectionID) == strings.TrimSpace(msg.ConnectionID) {
|
||||
pendingForPlatform++
|
||||
}
|
||||
}
|
||||
if pendingForPlatform >= cfg.MaxPendingPerPlatform {
|
||||
return PairingRequest{}, false, fmt.Errorf("too many pending pairing requests for %s", msg.Platform)
|
||||
}
|
||||
code, err := newPairingCode()
|
||||
if err != nil {
|
||||
return PairingRequest{}, false, err
|
||||
}
|
||||
req := PairingRequest{
|
||||
Code: code,
|
||||
Platform: msg.Platform,
|
||||
ConnectionID: strings.TrimSpace(msg.ConnectionID),
|
||||
Domain: strings.TrimSpace(msg.Domain),
|
||||
ChatType: msg.ChatType,
|
||||
ChatID: strings.TrimSpace(msg.ChatID),
|
||||
UserID: strings.TrimSpace(msg.UserID),
|
||||
UserName: strings.TrimSpace(msg.UserName),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(cfg.RequestTTL),
|
||||
}
|
||||
store.Requests = append(store.Requests, req)
|
||||
return req, true, savePairingFile(path, store)
|
||||
}
|
||||
|
||||
func ListPairingRequests() ([]PairingRequest, error) {
|
||||
path := PairingStorePath()
|
||||
if path == "" {
|
||||
return nil, errors.New("reasonix user state directory is unavailable")
|
||||
}
|
||||
pairingMu.Lock()
|
||||
defer pairingMu.Unlock()
|
||||
store, err := loadPairingFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
next := pruneExpiredPairingRequests(store.Requests, now)
|
||||
if len(next) != len(store.Requests) {
|
||||
store.Requests = next
|
||||
if err := savePairingFile(path, store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func ApprovePairingCode(code string) (PairingRequest, error) {
|
||||
req, err := removePairingCode(code)
|
||||
if err != nil {
|
||||
return PairingRequest{}, err
|
||||
}
|
||||
userPath := config.UserConfigPath()
|
||||
if userPath == "" {
|
||||
return PairingRequest{}, errors.New("reasonix user config path is unavailable")
|
||||
}
|
||||
unlock := config.LockUserConfigEdits()
|
||||
defer unlock()
|
||||
cfg := config.LoadForEdit(userPath)
|
||||
if approvePairingForConnectionAccess(&cfg.Bot, req) {
|
||||
if err := cfg.SaveTo(userPath); err != nil {
|
||||
return PairingRequest{}, err
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
switch req.Platform {
|
||||
case PlatformQQ:
|
||||
cfg.Bot.Allowlist.QQUsers, _ = appendUnique(cfg.Bot.Allowlist.QQUsers, req.UserID)
|
||||
case PlatformFeishu:
|
||||
cfg.Bot.Allowlist.FeishuUsers, _ = appendUnique(cfg.Bot.Allowlist.FeishuUsers, req.UserID)
|
||||
case PlatformWeixin:
|
||||
cfg.Bot.Allowlist.WeixinUsers, _ = appendUnique(cfg.Bot.Allowlist.WeixinUsers, req.UserID)
|
||||
}
|
||||
if allowlistAdminCount(cfg.Bot.Allowlist) == 0 {
|
||||
switch req.Platform {
|
||||
case PlatformQQ:
|
||||
cfg.Bot.Allowlist.QQAdmins, _ = appendUnique(cfg.Bot.Allowlist.QQAdmins, req.UserID)
|
||||
cfg.Bot.Allowlist.QQApprovers, _ = appendUnique(cfg.Bot.Allowlist.QQApprovers, req.UserID)
|
||||
case PlatformFeishu:
|
||||
cfg.Bot.Allowlist.FeishuAdmins, _ = appendUnique(cfg.Bot.Allowlist.FeishuAdmins, req.UserID)
|
||||
cfg.Bot.Allowlist.FeishuApprovers, _ = appendUnique(cfg.Bot.Allowlist.FeishuApprovers, req.UserID)
|
||||
case PlatformWeixin:
|
||||
cfg.Bot.Allowlist.WeixinAdmins, _ = appendUnique(cfg.Bot.Allowlist.WeixinAdmins, req.UserID)
|
||||
cfg.Bot.Allowlist.WeixinApprovers, _ = appendUnique(cfg.Bot.Allowlist.WeixinApprovers, req.UserID)
|
||||
}
|
||||
}
|
||||
if err := cfg.SaveTo(userPath); err != nil {
|
||||
return PairingRequest{}, err
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func approvePairingForConnectionAccess(botCfg *config.BotConfig, req PairingRequest) bool {
|
||||
if botCfg == nil {
|
||||
return false
|
||||
}
|
||||
connectionID := strings.TrimSpace(req.ConnectionID)
|
||||
if connectionID != "" {
|
||||
for i := range botCfg.Connections {
|
||||
if pairingConnectionMatches(botCfg.Connections[i], connectionID) {
|
||||
approvePairingAccess(&botCfg.Connections[i].Access, req.UserID)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Platform == PlatformQQ && (connectionID == "" || connectionID == string(PlatformQQ)) {
|
||||
approvePairingAccess(&botCfg.QQ.Access, req.UserID)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func approvePairingAccess(access *config.BotAccessConfig, userID string) {
|
||||
if access == nil {
|
||||
return
|
||||
}
|
||||
wasEmpty := !access.AllowAll &&
|
||||
len(access.Users) == 0 &&
|
||||
len(access.Groups) == 0 &&
|
||||
len(access.Approvers) == 0 &&
|
||||
len(access.Admins) == 0
|
||||
access.Enabled = true
|
||||
access.Users, _ = appendUnique(access.Users, userID)
|
||||
if wasEmpty {
|
||||
access.Admins, _ = appendUnique(access.Admins, userID)
|
||||
access.Approvers, _ = appendUnique(access.Approvers, userID)
|
||||
}
|
||||
}
|
||||
|
||||
func pairingConnectionMatches(conn config.BotConnectionConfig, connectionID string) bool {
|
||||
connectionID = strings.TrimSpace(connectionID)
|
||||
if connectionID == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(conn.ID) == connectionID {
|
||||
return true
|
||||
}
|
||||
return pairingConnectionRuntimeID(conn) == connectionID
|
||||
}
|
||||
|
||||
func pairingConnectionRuntimeID(conn config.BotConnectionConfig) string {
|
||||
if id := strings.TrimSpace(conn.ID); id != "" {
|
||||
return id
|
||||
}
|
||||
provider := strings.TrimSpace(conn.Provider)
|
||||
domain := strings.TrimSpace(conn.Domain)
|
||||
if provider == "" {
|
||||
return ""
|
||||
}
|
||||
if domain == "" {
|
||||
return provider
|
||||
}
|
||||
return provider + "-" + domain
|
||||
}
|
||||
|
||||
func RejectPairingCode(code string) (PairingRequest, error) {
|
||||
return removePairingCode(code)
|
||||
}
|
||||
|
||||
func removePairingCode(code string) (PairingRequest, error) {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
if code == "" {
|
||||
return PairingRequest{}, errors.New("pairing code is required")
|
||||
}
|
||||
path := PairingStorePath()
|
||||
if path == "" {
|
||||
return PairingRequest{}, errors.New("reasonix user state directory is unavailable")
|
||||
}
|
||||
pairingMu.Lock()
|
||||
defer pairingMu.Unlock()
|
||||
store, err := loadPairingFile(path)
|
||||
if err != nil {
|
||||
return PairingRequest{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
next := pruneExpiredPairingRequests(store.Requests, now)
|
||||
var found PairingRequest
|
||||
kept := next[:0]
|
||||
for _, req := range next {
|
||||
if strings.EqualFold(req.Code, code) {
|
||||
found = req
|
||||
continue
|
||||
}
|
||||
kept = append(kept, req)
|
||||
}
|
||||
if found.Code == "" {
|
||||
return PairingRequest{}, fmt.Errorf("pairing code %s not found or expired", code)
|
||||
}
|
||||
store.Requests = kept
|
||||
return found, savePairingFile(path, store)
|
||||
}
|
||||
|
||||
func loadPairingFile(path string) (pairingFile, error) {
|
||||
var store pairingFile
|
||||
data, err := fileencoding.ReadFileUTF8(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return store, nil
|
||||
}
|
||||
if err != nil {
|
||||
return store, err
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return store, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
return pairingFile{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// savePairingFile persists the store via tmpfile+rename so a crash or a
|
||||
// concurrent reader never observes a truncated pairing.json.
|
||||
func savePairingFile(path string, store pairingFile) error {
|
||||
data, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.AtomicWriteFile(path, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
func pruneExpiredPairingRequests(reqs []PairingRequest, now time.Time) []PairingRequest {
|
||||
out := reqs[:0]
|
||||
for _, req := range reqs {
|
||||
if req.ExpiresAt.IsZero() || now.Before(req.ExpiresAt) {
|
||||
out = append(out, req)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pairingRequestMatches(req PairingRequest, msg InboundMessage) bool {
|
||||
return req.Platform == msg.Platform &&
|
||||
strings.TrimSpace(req.ConnectionID) == strings.TrimSpace(msg.ConnectionID) &&
|
||||
strings.TrimSpace(req.ChatID) == strings.TrimSpace(msg.ChatID) &&
|
||||
strings.TrimSpace(req.UserID) == strings.TrimSpace(msg.UserID)
|
||||
}
|
||||
|
||||
func newPairingCode() (string, error) {
|
||||
var b strings.Builder
|
||||
max := big.NewInt(int64(len(pairingAlphabet)))
|
||||
for range 8 {
|
||||
n, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteByte(pairingAlphabet[n.Int64()])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func allowlistAdminCount(a config.BotAllowlist) int {
|
||||
return len(a.QQAdmins) + len(a.FeishuAdmins) + len(a.WeixinAdmins)
|
||||
}
|
||||
|
||||
func appendUnique(values []string, next string) ([]string, bool) {
|
||||
next = strings.TrimSpace(next)
|
||||
if next == "" {
|
||||
return values, false
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == next {
|
||||
return values, false
|
||||
}
|
||||
}
|
||||
return append(values, next), true
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/testenv"
|
||||
)
|
||||
|
||||
// Guards pairingMu + the atomic savePairingFile: concurrent offerPairing
|
||||
// dispatch goroutines used to load-modify-save pairing.json without a lock and
|
||||
// overwrite each other's requests. Run with -race.
|
||||
func TestCreateOrRefreshPairingRequestConcurrent(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
cfg := PairingConfig{Enabled: true, RequestTTL: time.Hour, MaxPendingPerPlatform: 64}
|
||||
|
||||
const workers = 8
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, workers)
|
||||
for i := range workers {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
msg := InboundMessage{
|
||||
Platform: PlatformFeishu,
|
||||
ChatType: ChatDM,
|
||||
ChatID: fmt.Sprintf("chat-%d", i),
|
||||
UserID: fmt.Sprintf("user-%d", i),
|
||||
}
|
||||
for range 5 {
|
||||
if _, _, err := CreateOrRefreshPairingRequest(msg, cfg); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent pairing request failed: %v", err)
|
||||
}
|
||||
|
||||
reqs, err := ListPairingRequests()
|
||||
if err != nil {
|
||||
t.Fatalf("list pairing requests: %v", err)
|
||||
}
|
||||
if len(reqs) != workers {
|
||||
t.Fatalf("pairing store lost concurrent writes: got %d requests, want %d", len(reqs), workers)
|
||||
}
|
||||
}
|
||||
@@ -1,831 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/proc"
|
||||
"reasonix/internal/secrets"
|
||||
)
|
||||
|
||||
const (
|
||||
botProjectListLimit = 20
|
||||
botSessionListLimit = 20
|
||||
botSearchListLimit = 20
|
||||
)
|
||||
|
||||
type botProjectEntry struct {
|
||||
ID string
|
||||
Name string
|
||||
Root string
|
||||
Sources []string
|
||||
}
|
||||
|
||||
type botSessionEntry struct {
|
||||
ID string
|
||||
ProjectID string
|
||||
ProjectName string
|
||||
WorkspaceRoot string
|
||||
SessionID string
|
||||
SessionPath string
|
||||
RemoteID string
|
||||
ConnectionID string
|
||||
ChatType string
|
||||
UserID string
|
||||
ThreadID string
|
||||
Scope string
|
||||
Preview string
|
||||
TopicTitle string
|
||||
LastActivityAt time.Time
|
||||
Source string
|
||||
}
|
||||
|
||||
type botProjectSearchResult struct {
|
||||
ProjectID string
|
||||
ProjectName string
|
||||
Path string
|
||||
Line int
|
||||
Text string
|
||||
}
|
||||
|
||||
func (gw *BotGateway) buildProjectIndex() []botProjectEntry {
|
||||
collector := newBotProjectCollector()
|
||||
collector.add(gw.cfg.WorkspaceRoot, "default")
|
||||
|
||||
// cfg.Channels / cfg.ConnectionChannels are rewritten under gw.mu at runtime,
|
||||
// so the whole scan shares the controllers critical section below.
|
||||
gw.mu.Lock()
|
||||
platforms := make([]string, 0, len(gw.cfg.Channels))
|
||||
for platform := range gw.cfg.Channels {
|
||||
platforms = append(platforms, string(platform))
|
||||
}
|
||||
slices.Sort(platforms)
|
||||
for _, platform := range platforms {
|
||||
channel := gw.cfg.Channels[Platform(platform)]
|
||||
source := "channel:" + platform
|
||||
collector.add(channel.WorkspaceRoot, source)
|
||||
addMappingProjects(collector, channel, source)
|
||||
}
|
||||
|
||||
connections := make([]string, 0, len(gw.cfg.ConnectionChannels))
|
||||
for id := range gw.cfg.ConnectionChannels {
|
||||
connections = append(connections, id)
|
||||
}
|
||||
slices.Sort(connections)
|
||||
for _, id := range connections {
|
||||
channel := gw.cfg.ConnectionChannels[id]
|
||||
source := "connection:" + id
|
||||
collector.add(channel.WorkspaceRoot, source)
|
||||
addMappingProjects(collector, channel, source)
|
||||
}
|
||||
|
||||
for i, route := range gw.cfg.Routes {
|
||||
collector.add(route.Channel.WorkspaceRoot, fmt.Sprintf("route:%d", i+1))
|
||||
}
|
||||
|
||||
for key, state := range gw.controllers {
|
||||
root := ""
|
||||
if state != nil {
|
||||
root = state.workspaceRoot
|
||||
if root == "" && state.ctrl != nil {
|
||||
root = state.ctrl.WorkspaceRoot()
|
||||
}
|
||||
}
|
||||
collector.add(root, "active:"+shortBotID(key))
|
||||
}
|
||||
for key, override := range gw.sessionOverrides {
|
||||
collector.add(override.channel.WorkspaceRoot, "override:"+shortBotID(key))
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
|
||||
return collector.entries()
|
||||
}
|
||||
|
||||
func addMappingProjects(collector *botProjectCollector, channel ChannelConfig, source string) {
|
||||
for _, mapping := range channel.SessionMappings {
|
||||
root := workspaceRootForSessionMapping(mapping, channel.WorkspaceRoot)
|
||||
collector.add(root, source+":mapping:"+strings.TrimSpace(mapping.RemoteID))
|
||||
}
|
||||
}
|
||||
|
||||
type botProjectCollector struct {
|
||||
byRoot map[string]*botProjectEntry
|
||||
}
|
||||
|
||||
func newBotProjectCollector() *botProjectCollector {
|
||||
return &botProjectCollector{byRoot: make(map[string]*botProjectEntry)}
|
||||
}
|
||||
|
||||
func (c *botProjectCollector) add(root, source string) {
|
||||
root = canonicalBotPath(root)
|
||||
if root == "" {
|
||||
return
|
||||
}
|
||||
entry := c.byRoot[root]
|
||||
if entry == nil {
|
||||
entry = &botProjectEntry{Name: botProjectName(root), Root: root}
|
||||
c.byRoot[root] = entry
|
||||
}
|
||||
source = strings.TrimSpace(source)
|
||||
if source == "" {
|
||||
return
|
||||
}
|
||||
if slices.Contains(entry.Sources, source) {
|
||||
return
|
||||
}
|
||||
entry.Sources = append(entry.Sources, source)
|
||||
}
|
||||
|
||||
func (c *botProjectCollector) entries() []botProjectEntry {
|
||||
out := make([]botProjectEntry, 0, len(c.byRoot))
|
||||
for _, entry := range c.byRoot {
|
||||
copied := *entry
|
||||
slices.Sort(copied.Sources)
|
||||
out = append(out, copied)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
li := strings.ToLower(out[i].Name)
|
||||
lj := strings.ToLower(out[j].Name)
|
||||
if li != lj {
|
||||
return li < lj
|
||||
}
|
||||
return out[i].Root < out[j].Root
|
||||
})
|
||||
for i := range out {
|
||||
out[i].ID = fmt.Sprintf("p%d", i+1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (gw *BotGateway) buildSessionIndex(projects []botProjectEntry) []botSessionEntry {
|
||||
projectByRoot := make(map[string]botProjectEntry, len(projects))
|
||||
for _, project := range projects {
|
||||
projectByRoot[canonicalBotPath(project.Root)] = project
|
||||
}
|
||||
collector := newBotSessionCollector(projectByRoot)
|
||||
|
||||
// cfg.Channels / cfg.ConnectionChannels are rewritten under gw.mu at runtime;
|
||||
// collect the mapping-derived entries under a short lock, keeping the
|
||||
// filesystem scan below outside it.
|
||||
gw.mu.Lock()
|
||||
platforms := make([]string, 0, len(gw.cfg.Channels))
|
||||
for platform := range gw.cfg.Channels {
|
||||
platforms = append(platforms, string(platform))
|
||||
}
|
||||
slices.Sort(platforms)
|
||||
for _, platform := range platforms {
|
||||
channel := gw.cfg.Channels[Platform(platform)]
|
||||
addMappingSessions(collector, channel, "", "channel:"+platform)
|
||||
}
|
||||
|
||||
connections := make([]string, 0, len(gw.cfg.ConnectionChannels))
|
||||
for id := range gw.cfg.ConnectionChannels {
|
||||
connections = append(connections, id)
|
||||
}
|
||||
slices.Sort(connections)
|
||||
for _, id := range connections {
|
||||
channel := gw.cfg.ConnectionChannels[id]
|
||||
addMappingSessions(collector, channel, id, "connection:"+id)
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
|
||||
for _, project := range projects {
|
||||
dir := botSessionDir(project.Root)
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
infos, err := agent.ListSessions(dir)
|
||||
if err != nil {
|
||||
gw.logger.Warn("bot project session index failed", "project", project.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
for _, info := range infos {
|
||||
collector.add(botSessionEntry{
|
||||
WorkspaceRoot: project.Root,
|
||||
SessionPath: canonicalBotPath(info.Path),
|
||||
SessionID: botSessionTarget(info.Path),
|
||||
Scope: firstNonEmptyString(info.Scope, "project"),
|
||||
Preview: info.Preview,
|
||||
TopicTitle: info.TopicTitle,
|
||||
LastActivityAt: info.LastActivityAt,
|
||||
Source: "project-sessions",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return collector.entries()
|
||||
}
|
||||
|
||||
func addMappingSessions(collector *botSessionCollector, channel ChannelConfig, connectionID, source string) {
|
||||
for _, mapping := range channel.SessionMappings {
|
||||
sessionID := strings.TrimSpace(mapping.SessionID)
|
||||
sessionPath := botSessionPathFromTarget(sessionID)
|
||||
root := workspaceRootForSessionMapping(mapping, channel.WorkspaceRoot)
|
||||
collector.add(botSessionEntry{
|
||||
WorkspaceRoot: root,
|
||||
SessionID: sessionID,
|
||||
SessionPath: sessionPath,
|
||||
RemoteID: strings.TrimSpace(mapping.RemoteID),
|
||||
ConnectionID: strings.TrimSpace(connectionID),
|
||||
ChatType: strings.TrimSpace(mapping.ChatType),
|
||||
UserID: strings.TrimSpace(mapping.UserID),
|
||||
ThreadID: strings.TrimSpace(mapping.ThreadID),
|
||||
Scope: strings.TrimSpace(mapping.Scope),
|
||||
LastActivityAt: parseBotMappingUpdatedAt(mapping.UpdatedAt),
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type botSessionCollector struct {
|
||||
projectByRoot map[string]botProjectEntry
|
||||
byKey map[string]*botSessionEntry
|
||||
}
|
||||
|
||||
func newBotSessionCollector(projectByRoot map[string]botProjectEntry) *botSessionCollector {
|
||||
return &botSessionCollector{
|
||||
projectByRoot: projectByRoot,
|
||||
byKey: make(map[string]*botSessionEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *botSessionCollector) add(entry botSessionEntry) {
|
||||
entry.WorkspaceRoot = canonicalBotPath(entry.WorkspaceRoot)
|
||||
entry.SessionPath = canonicalBotPath(entry.SessionPath)
|
||||
if entry.SessionID == "" && entry.SessionPath != "" {
|
||||
entry.SessionID = botSessionTarget(entry.SessionPath)
|
||||
}
|
||||
if entry.SessionPath == "" && entry.SessionID == "" && entry.RemoteID == "" {
|
||||
return
|
||||
}
|
||||
if project, ok := c.projectByRoot[entry.WorkspaceRoot]; ok {
|
||||
entry.ProjectID = project.ID
|
||||
entry.ProjectName = project.Name
|
||||
}
|
||||
key := entry.SessionPath
|
||||
if key == "" {
|
||||
key = strings.Join([]string{"target", entry.ConnectionID, entry.RemoteID, entry.ChatType, entry.UserID, entry.ThreadID, entry.SessionID}, "\x00")
|
||||
}
|
||||
existing := c.byKey[key]
|
||||
if existing == nil {
|
||||
c.byKey[key] = &entry
|
||||
return
|
||||
}
|
||||
mergeBotSessionEntry(existing, entry)
|
||||
}
|
||||
|
||||
func mergeBotSessionEntry(dst *botSessionEntry, src botSessionEntry) {
|
||||
if dst.ProjectID == "" {
|
||||
dst.ProjectID = src.ProjectID
|
||||
}
|
||||
if dst.ProjectName == "" {
|
||||
dst.ProjectName = src.ProjectName
|
||||
}
|
||||
if dst.WorkspaceRoot == "" {
|
||||
dst.WorkspaceRoot = src.WorkspaceRoot
|
||||
}
|
||||
if dst.SessionID == "" {
|
||||
dst.SessionID = src.SessionID
|
||||
}
|
||||
if dst.SessionPath == "" {
|
||||
dst.SessionPath = src.SessionPath
|
||||
}
|
||||
if dst.RemoteID == "" {
|
||||
dst.RemoteID = src.RemoteID
|
||||
}
|
||||
if dst.ConnectionID == "" {
|
||||
dst.ConnectionID = src.ConnectionID
|
||||
}
|
||||
if dst.ChatType == "" {
|
||||
dst.ChatType = src.ChatType
|
||||
}
|
||||
if dst.UserID == "" {
|
||||
dst.UserID = src.UserID
|
||||
}
|
||||
if dst.ThreadID == "" {
|
||||
dst.ThreadID = src.ThreadID
|
||||
}
|
||||
if dst.Scope == "" {
|
||||
dst.Scope = src.Scope
|
||||
}
|
||||
if dst.Preview == "" {
|
||||
dst.Preview = src.Preview
|
||||
}
|
||||
if dst.TopicTitle == "" {
|
||||
dst.TopicTitle = src.TopicTitle
|
||||
}
|
||||
if src.LastActivityAt.After(dst.LastActivityAt) {
|
||||
dst.LastActivityAt = src.LastActivityAt
|
||||
}
|
||||
if dst.Source == "" {
|
||||
dst.Source = src.Source
|
||||
} else if src.Source != "" && !strings.Contains(dst.Source, src.Source) {
|
||||
dst.Source += "," + src.Source
|
||||
}
|
||||
}
|
||||
|
||||
func (c *botSessionCollector) entries() []botSessionEntry {
|
||||
out := make([]botSessionEntry, 0, len(c.byKey))
|
||||
for _, entry := range c.byKey {
|
||||
out = append(out, *entry)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if !out[i].LastActivityAt.Equal(out[j].LastActivityAt) {
|
||||
return out[i].LastActivityAt.After(out[j].LastActivityAt)
|
||||
}
|
||||
if out[i].ProjectName != out[j].ProjectName {
|
||||
return out[i].ProjectName < out[j].ProjectName
|
||||
}
|
||||
return out[i].SessionPath < out[j].SessionPath
|
||||
})
|
||||
for i := range out {
|
||||
out[i].ID = fmt.Sprintf("s%d", i+1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func botSessionPathFromTarget(target string) string {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return ""
|
||||
}
|
||||
if after, ok := strings.CutPrefix(target, "path:"); ok {
|
||||
return canonicalBotPath(after)
|
||||
}
|
||||
if filepath.IsAbs(target) && strings.HasSuffix(target, ".jsonl") {
|
||||
return canonicalBotPath(target)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseBotMappingUpdatedAt(value string) time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func resolveBotProject(projects []botProjectEntry, selector string) (botProjectEntry, []botProjectEntry) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return botProjectEntry{}, nil
|
||||
}
|
||||
selectorLower := strings.ToLower(selector)
|
||||
canonicalSelector := canonicalBotPath(selector)
|
||||
var matches []botProjectEntry
|
||||
for _, project := range projects {
|
||||
if strings.EqualFold(project.ID, selector) || canonicalBotPath(project.Root) == canonicalSelector || strings.EqualFold(project.Name, selector) {
|
||||
return project, nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(project.Name), selectorLower) || strings.Contains(strings.ToLower(project.Root), selectorLower) {
|
||||
matches = append(matches, project)
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0], nil
|
||||
}
|
||||
return botProjectEntry{}, matches
|
||||
}
|
||||
|
||||
func resolveBotSession(sessions []botSessionEntry, selector string) (botSessionEntry, []botSessionEntry) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return botSessionEntry{}, nil
|
||||
}
|
||||
selectorLower := strings.ToLower(selector)
|
||||
canonicalSelector := canonicalBotPath(selector)
|
||||
var matches []botSessionEntry
|
||||
for _, session := range sessions {
|
||||
if strings.EqualFold(session.ID, selector) ||
|
||||
(session.SessionPath != "" && canonicalBotPath(session.SessionPath) == canonicalSelector) ||
|
||||
(session.SessionPath != "" && strings.EqualFold(filepath.Base(session.SessionPath), selector)) ||
|
||||
(session.SessionID != "" && strings.EqualFold(session.SessionID, selector)) {
|
||||
return session, nil
|
||||
}
|
||||
if botSessionMatchesQuery(session, selectorLower) {
|
||||
matches = append(matches, session)
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0], nil
|
||||
}
|
||||
return botSessionEntry{}, matches
|
||||
}
|
||||
|
||||
func filterBotProjects(projects []botProjectEntry, query string) []botProjectEntry {
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
if query == "" {
|
||||
return projects
|
||||
}
|
||||
var out []botProjectEntry
|
||||
for _, project := range projects {
|
||||
if strings.Contains(strings.ToLower(project.Name+" "+project.Root+" "+strings.Join(project.Sources, " ")), query) {
|
||||
out = append(out, project)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterBotSessions(sessions []botSessionEntry, query string) []botSessionEntry {
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
if query == "" {
|
||||
return sessions
|
||||
}
|
||||
var out []botSessionEntry
|
||||
for _, session := range sessions {
|
||||
if botSessionMatchesQuery(session, query) {
|
||||
out = append(out, session)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func botSessionMatchesQuery(session botSessionEntry, query string) bool {
|
||||
haystack := strings.ToLower(strings.Join([]string{
|
||||
session.ID,
|
||||
session.ProjectID,
|
||||
session.ProjectName,
|
||||
session.WorkspaceRoot,
|
||||
session.SessionID,
|
||||
session.SessionPath,
|
||||
session.RemoteID,
|
||||
session.ConnectionID,
|
||||
session.ChatType,
|
||||
session.UserID,
|
||||
session.ThreadID,
|
||||
session.Scope,
|
||||
session.Preview,
|
||||
session.TopicTitle,
|
||||
session.Source,
|
||||
}, " "))
|
||||
return strings.Contains(haystack, query)
|
||||
}
|
||||
|
||||
func formatBotProjects(projects []botProjectEntry, query string, limit int) string {
|
||||
matches := filterBotProjects(projects, query)
|
||||
if len(matches) == 0 {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "还没有可用项目索引。请先在 bot 连接、route 或当前会话里配置 workspace_root。"
|
||||
}
|
||||
return "没有匹配的项目。"
|
||||
}
|
||||
if limit <= 0 || limit > len(matches) {
|
||||
limit = len(matches)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "项目索引(%d/%d):", limit, len(matches))
|
||||
for i := range limit {
|
||||
project := matches[i]
|
||||
fmt.Fprintf(&b, "\n%s %s — %s", project.ID, project.Name, displayBotPath(project.Root))
|
||||
if len(project.Sources) > 0 {
|
||||
fmt.Fprintf(&b, "\n 来源: %s", strings.Join(project.Sources, ", "))
|
||||
}
|
||||
}
|
||||
if len(matches) > limit {
|
||||
fmt.Fprintf(&b, "\n还有 %d 个结果,请加关键词缩小范围。", len(matches)-limit)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatBotSessions(sessions []botSessionEntry, query string, limit int) string {
|
||||
matches := filterBotSessions(sessions, query)
|
||||
if len(matches) == 0 {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "还没有可用会话索引。已有项目会话或 bot session_mappings 后会出现在这里。"
|
||||
}
|
||||
return "没有匹配的会话。"
|
||||
}
|
||||
if limit <= 0 || limit > len(matches) {
|
||||
limit = len(matches)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "会话索引(%d/%d):", limit, len(matches))
|
||||
for i := range limit {
|
||||
session := matches[i]
|
||||
project := firstNonEmptyString(session.ProjectName, "global")
|
||||
fmt.Fprintf(&b, "\n%s %s", session.ID, project)
|
||||
if session.TopicTitle != "" {
|
||||
fmt.Fprintf(&b, " · %s", singleLineBotText(session.TopicTitle, 40))
|
||||
}
|
||||
if session.Preview != "" {
|
||||
fmt.Fprintf(&b, "\n 预览: %s", singleLineBotText(session.Preview, 90))
|
||||
}
|
||||
if session.SessionPath != "" {
|
||||
fmt.Fprintf(&b, "\n 文件: %s", displayBotPath(session.SessionPath))
|
||||
} else if session.SessionID != "" {
|
||||
fmt.Fprintf(&b, "\n 目标: %s", session.SessionID)
|
||||
}
|
||||
if session.RemoteID != "" || session.ConnectionID != "" {
|
||||
fmt.Fprintf(&b, "\n 远端: %s %s", session.ConnectionID, session.RemoteID)
|
||||
}
|
||||
}
|
||||
if len(matches) > limit {
|
||||
fmt.Fprintf(&b, "\n还有 %d 个结果,请加关键词缩小范围。", len(matches)-limit)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatBotProjectSearchResults(results []botProjectSearchResult, limit int) string {
|
||||
if len(results) == 0 {
|
||||
return "没有跨项目命中。"
|
||||
}
|
||||
if limit <= 0 || limit > len(results) {
|
||||
limit = len(results)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "跨项目检索结果(%d/%d):", limit, len(results))
|
||||
for i := range limit {
|
||||
result := results[i]
|
||||
project := firstNonEmptyString(result.ProjectName, result.ProjectID)
|
||||
fmt.Fprintf(&b, "\n- %s %s:%d: %s", project, displayBotPath(result.Path), result.Line, singleLineBotText(result.Text, 120))
|
||||
}
|
||||
if len(results) > limit {
|
||||
fmt.Fprintf(&b, "\n还有 %d 条命中,请加关键词缩小范围。", len(results)-limit)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func searchBotProjects(ctx context.Context, projects []botProjectEntry, query string, limit int) ([]botProjectSearchResult, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if len([]rune(query)) < 2 {
|
||||
return nil, errors.New("检索词至少需要 2 个字符")
|
||||
}
|
||||
var roots []string
|
||||
seen := map[string]bool{}
|
||||
for _, project := range projects {
|
||||
root := canonicalBotPath(project.Root)
|
||||
if root == "" || seen[root] {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(root)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
seen[root] = true
|
||||
roots = append(roots, root)
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
return nil, errors.New("没有可检索的项目目录")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = botSearchListLimit
|
||||
}
|
||||
if rg, err := exec.LookPath("rg"); err == nil {
|
||||
return searchBotProjectsWithRG(ctx, rg, projects, roots, query, limit)
|
||||
}
|
||||
return searchBotProjectsFallback(ctx, projects, roots, query, limit)
|
||||
}
|
||||
|
||||
func searchBotProjectsWithRG(ctx context.Context, rg string, projects []botProjectEntry, roots []string, query string, limit int) ([]botProjectSearchResult, error) {
|
||||
args := []string{
|
||||
"--json",
|
||||
"--color", "never",
|
||||
"--fixed-strings",
|
||||
"--max-count", "3",
|
||||
"--max-filesize", "1M",
|
||||
"--glob", "!.git",
|
||||
"--glob", "!node_modules",
|
||||
"--glob", "!dist",
|
||||
"--glob", "!build",
|
||||
"--glob", "!vendor",
|
||||
"--",
|
||||
query,
|
||||
}
|
||||
args = append(args, roots...)
|
||||
cmd := exec.CommandContext(ctx, rg, args...)
|
||||
cmd.Env = secrets.ProcessEnv()
|
||||
// The desktop app hosts bot bridges in the GUI process; without this an
|
||||
// rg search flashes a console window on Windows.
|
||||
proc.HideWindow(cmd)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("rg failed: %w", err)
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(out))
|
||||
var results []botProjectSearchResult
|
||||
for {
|
||||
var item struct {
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
Path struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"path"`
|
||||
Lines struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"lines"`
|
||||
LineNumber int `json:"line_number"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := dec.Decode(&item); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
if item.Type != "match" {
|
||||
continue
|
||||
}
|
||||
path := canonicalBotPath(item.Data.Path.Text)
|
||||
project := botProjectForPath(projects, path)
|
||||
results = append(results, botProjectSearchResult{
|
||||
ProjectID: project.ID,
|
||||
ProjectName: project.Name,
|
||||
Path: path,
|
||||
Line: item.Data.LineNumber,
|
||||
Text: strings.TrimSpace(item.Data.Lines.Text),
|
||||
})
|
||||
if len(results) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
var errStopBotSearch = errors.New("stop bot project search")
|
||||
|
||||
func searchBotProjectsFallback(ctx context.Context, projects []botProjectEntry, roots []string, query string, limit int) ([]botProjectSearchResult, error) {
|
||||
queryLower := strings.ToLower(query)
|
||||
var results []botProjectSearchResult
|
||||
for _, root := range roots {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return results, err
|
||||
}
|
||||
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if shouldSkipBotSearchDir(d.Name()) && path != root {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil || info.Size() > 1024*1024 {
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
line := 0
|
||||
for scanner.Scan() {
|
||||
if err := ctx.Err(); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
line++
|
||||
text := scanner.Text()
|
||||
if strings.Contains(strings.ToLower(text), queryLower) {
|
||||
project := botProjectForPath(projects, path)
|
||||
results = append(results, botProjectSearchResult{
|
||||
ProjectID: project.ID,
|
||||
ProjectName: project.Name,
|
||||
Path: canonicalBotPath(path),
|
||||
Line: line,
|
||||
Text: strings.TrimSpace(text),
|
||||
})
|
||||
if len(results) >= limit {
|
||||
_ = file.Close()
|
||||
return errStopBotSearch
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = file.Close()
|
||||
return nil
|
||||
})
|
||||
if errors.Is(walkErr, errStopBotSearch) {
|
||||
break
|
||||
}
|
||||
if walkErr != nil {
|
||||
return results, walkErr
|
||||
}
|
||||
if len(results) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func shouldSkipBotSearchDir(name string) bool {
|
||||
switch name {
|
||||
case ".git", "node_modules", "dist", "build", "vendor", ".next", ".cache":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func botProjectForPath(projects []botProjectEntry, path string) botProjectEntry {
|
||||
path = canonicalBotPath(path)
|
||||
var best botProjectEntry
|
||||
for _, project := range projects {
|
||||
root := canonicalBotPath(project.Root)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
if path == root || strings.HasPrefix(path, root+string(os.PathSeparator)) {
|
||||
if len(root) > len(best.Root) {
|
||||
best = project
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func canonicalBotPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if abs, err := filepath.Abs(path); err == nil {
|
||||
path = abs
|
||||
}
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
|
||||
func botProjectName(root string) string {
|
||||
root = strings.TrimRight(canonicalBotPath(root), string(os.PathSeparator))
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
name := filepath.Base(root)
|
||||
if name == "." || name == string(os.PathSeparator) {
|
||||
return root
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func displayBotPath(path string) string {
|
||||
path = canonicalBotPath(path)
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
home = canonicalBotPath(home)
|
||||
if home != "" && (path == home || strings.HasPrefix(path, home+string(os.PathSeparator))) {
|
||||
return "~" + strings.TrimPrefix(path, home)
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func singleLineBotText(text string, limit int) string {
|
||||
text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
|
||||
if limit <= 0 {
|
||||
return text
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) <= limit {
|
||||
return text
|
||||
}
|
||||
return string(runes[:limit-1]) + "…"
|
||||
}
|
||||
|
||||
func shortBotID(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) <= 8 {
|
||||
return value
|
||||
}
|
||||
return value[:8]
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Package qq 实现 QQ 官方 Bot API v2 适配器。
|
||||
// 参考 Hermes Agent 的 qqbot adapter 实现:
|
||||
// - app token 获取与刷新
|
||||
// - WebSocket gateway 连接、heartbeat、resume
|
||||
// - REST API 回复消息
|
||||
// - C2C / group / guild / direct message 支持
|
||||
// - inline keyboard 审批
|
||||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/config"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
// New 创建 QQ Bot 适配器。
|
||||
func New(cfg config.QQBotConfig, logger *slog.Logger) bot.Adapter {
|
||||
return &adapter{
|
||||
cfg: cfg,
|
||||
logger: logger.With("platform", "qq"),
|
||||
}
|
||||
}
|
||||
|
||||
type adapter struct {
|
||||
cfg config.QQBotConfig
|
||||
logger *slog.Logger
|
||||
msgCh chan bot.InboundMessage
|
||||
cancel context.CancelFunc
|
||||
loopWG sync.WaitGroup
|
||||
|
||||
// gateway 状态
|
||||
connMu sync.Mutex
|
||||
conn *websocket.Conn // live gateway connection, closed by Stop to unblock reads
|
||||
sessionID string
|
||||
seq int64
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
tokenMu sync.Mutex
|
||||
|
||||
sendMu sync.Mutex
|
||||
nextOutboundMsgSeq int
|
||||
markdownDisabled bool
|
||||
}
|
||||
|
||||
func (a *adapter) Platform() bot.Platform { return bot.PlatformQQ }
|
||||
func (a *adapter) Name() string { return "qq" }
|
||||
|
||||
func (a *adapter) Start(ctx context.Context) error {
|
||||
a.msgCh = make(chan bot.InboundMessage, 64)
|
||||
startupCtx, startupCancel := context.WithTimeout(ctx, qqStartupValidationTimeout)
|
||||
defer startupCancel()
|
||||
token, err := a.getAccessToken(startupCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.getGatewayURL(startupCtx, token); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, a.cancel = context.WithCancel(ctx)
|
||||
|
||||
a.loopWG.Go(func() {
|
||||
a.gatewayLoop(ctx)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 取消 gateway context、关闭当前 WebSocket 连接并等待 gatewayLoop 退出。
|
||||
// websocket 的阻塞读不响应 context,只有关闭连接才能解除阻塞;不等待就返回
|
||||
// 会在宿主重建 bot runtime 后留下仍占用 QQ gateway session 的僵尸连接。
|
||||
func (a *adapter) Stop() error {
|
||||
if a.cancel != nil {
|
||||
a.cancel()
|
||||
}
|
||||
a.closeConn()
|
||||
a.loopWG.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *adapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
return a.sendMessage(ctx, msg)
|
||||
}
|
||||
|
||||
func (a *adapter) SendTyping(ctx context.Context, chatID string) error {
|
||||
return nil // QQ Bot 暂不支持 typing 指示器
|
||||
}
|
||||
|
||||
func (a *adapter) Messages() <-chan bot.InboundMessage {
|
||||
return a.msgCh
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
// Guards the Stop drain contract: the gateway loop blocks in websocket reads
|
||||
// that do not honor ctx, so Stop must close the tracked connection to unblock
|
||||
// them and must wait for the loop goroutine to exit before returning.
|
||||
func TestStopClosesTrackedConnAndWaitsForLoop(t *testing.T) {
|
||||
srv := httptest.NewServer(websocket.Handler(func(ws *websocket.Conn) {
|
||||
_, _ = io.Copy(io.Discard, ws) // hold the connection open, send nothing
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
conn, err := websocket.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), "", srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("dial test server: %v", err)
|
||||
}
|
||||
|
||||
a := &adapter{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.cancel = cancel
|
||||
tracked := make(chan struct{})
|
||||
decodeReturned := make(chan struct{})
|
||||
a.loopWG.Go(func() {
|
||||
if !a.trackConn(ctx, conn) {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
defer a.dropConn(conn)
|
||||
close(tracked)
|
||||
var payload gatewayPayload
|
||||
_ = json.NewDecoder(conn).Decode(&payload) // blocks like connectGateway's reads
|
||||
close(decodeReturned)
|
||||
})
|
||||
select {
|
||||
case <-tracked:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("gateway loop did not track its connection")
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = a.Stop()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Stop did not close the gateway connection and wait for the loop")
|
||||
}
|
||||
select {
|
||||
case <-decodeReturned:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop returned before the blocking gateway read exited")
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the dial-phase Stop contract: until the dial returns, the conn is
|
||||
// not tracked and closeConn has nothing to close, so cancelling the adapter
|
||||
// context must abort a stalled TCP dial or WebSocket handshake. This locks in
|
||||
// cfg.DialContext(ctx) over websocket.DialConfig, which dials with
|
||||
// context.Background() and would leave Stop blocked on loopWG.Wait.
|
||||
func TestStopUnblocksStalledHandshakeDial(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
accepted := make(chan net.Conn, 1)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
accepted <- conn // hold the conn open, never answer the handshake
|
||||
}()
|
||||
defer func() {
|
||||
select {
|
||||
case conn := <-accepted:
|
||||
conn.Close()
|
||||
default:
|
||||
}
|
||||
}()
|
||||
|
||||
a := &adapter{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.cancel = cancel
|
||||
dialErr := make(chan error, 1)
|
||||
a.loopWG.Go(func() {
|
||||
conn, err := a.dialGateway(ctx, "ws://"+ln.Addr().String(), "test-token")
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
}
|
||||
dialErr <- err
|
||||
})
|
||||
|
||||
var srvConn net.Conn
|
||||
select {
|
||||
case srvConn = <-accepted:
|
||||
defer srvConn.Close()
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("dial never reached the stalled server")
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = a.Stop()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Stop blocked on a stalled gateway handshake")
|
||||
}
|
||||
select {
|
||||
case err := <-dialErr:
|
||||
if err == nil {
|
||||
t.Fatal("stalled handshake dial unexpectedly succeeded")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("dial did not return after Stop cancelled the context")
|
||||
}
|
||||
}
|
||||
|
||||
// A connection that finishes dialing after Stop must not be published: Stop
|
||||
// has already emptied the tracked slot, so a late publication would leave a
|
||||
// conn (and its blocked reader) that nothing can ever close.
|
||||
func TestTrackConnRefusesPublicationAfterCancel(t *testing.T) {
|
||||
a := &adapter{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if a.trackConn(ctx, &websocket.Conn{}) {
|
||||
t.Fatal("trackConn published a connection after cancellation")
|
||||
}
|
||||
a.connMu.Lock()
|
||||
defer a.connMu.Unlock()
|
||||
if a.conn != nil {
|
||||
t.Fatal("cancelled publication still stored the connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopWithoutStartIsSafe(t *testing.T) {
|
||||
a := &adapter{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = a.Stop()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop blocked on a never-started adapter")
|
||||
}
|
||||
}
|
||||
@@ -1,793 +0,0 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/textutil"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
qqTokenURL = "https://bots.qq.com/app/getAppAccessToken"
|
||||
qqBaseURL = "https://api.sgroup.qq.com"
|
||||
qqSandboxURL = "https://sandbox.api.sgroup.qq.com"
|
||||
qqGatewayURL = "wss://api.sgroup.qq.com/websocket"
|
||||
qqMaxChunkBytes = 1500
|
||||
qqMaxPassiveReplyChunks = 5
|
||||
qqMinHeartbeat = 5 * time.Second
|
||||
qqMaxHeartbeat = time.Minute
|
||||
qqStartupValidationTimeout = 10 * time.Second
|
||||
qqPassiveReplyTruncationNotice = "\n\n[Truncated: QQ allows at most 5 passive replies for one incoming message.]"
|
||||
qqHTTPTimeout = 30 * time.Second
|
||||
|
||||
opDispatch = 0
|
||||
opHeartbeat = 1
|
||||
opIdentify = 2
|
||||
opResume = 6
|
||||
opReconnect = 7
|
||||
opInvalid = 9
|
||||
opHello = 10
|
||||
opHeartbeatAck = 11
|
||||
)
|
||||
|
||||
var qqMarkdownWrapperRe = regexp.MustCompile("(?is)^```(?:markdown|md)\\s*\\r?\\n([\\s\\S]*?)\\r?\\n```$")
|
||||
|
||||
var qqHTTPClient = &http.Client{Timeout: qqHTTPTimeout}
|
||||
|
||||
var allowedGatewayHosts = []string{
|
||||
"api.sgroup.qq.com",
|
||||
"sandbox.api.sgroup.qq.com",
|
||||
"qq.com",
|
||||
}
|
||||
|
||||
// gatewayPayload QQ WebSocket 消息载荷。
|
||||
type gatewayPayload struct {
|
||||
Op int `json:"op"`
|
||||
D json.RawMessage `json:"d,omitempty"`
|
||||
S int64 `json:"s,omitempty"`
|
||||
T string `json:"t,omitempty"`
|
||||
}
|
||||
|
||||
type helloData struct {
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
}
|
||||
|
||||
type identifyData struct {
|
||||
Token string `json:"token"`
|
||||
Intents int `json:"intents"`
|
||||
Shard [2]int `json:"shard"`
|
||||
Properties properties `json:"properties"`
|
||||
}
|
||||
|
||||
type properties struct {
|
||||
OS string `json:"$os"`
|
||||
Browser string `json:"$browser"`
|
||||
Device string `json:"$device"`
|
||||
}
|
||||
|
||||
type dispatchEvent struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Author struct {
|
||||
ID string `json:"id"`
|
||||
UserOpenID string `json:"user_openid"`
|
||||
MemberOpenID string `json:"member_openid"`
|
||||
UnionOpenID string `json:"union_openid"`
|
||||
Username string `json:"username"`
|
||||
} `json:"author"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
GuildID string `json:"guild_id"`
|
||||
GroupOpenID string `json:"group_openid"`
|
||||
}
|
||||
|
||||
// wsClient 管理 QQ WebSocket 连接。
|
||||
type wsClient struct {
|
||||
mu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
heartbeatMs int
|
||||
sessionID string
|
||||
lastSeq int64
|
||||
token string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (a *adapter) gatewayLoop(ctx context.Context) {
|
||||
bot.RunWithRetry(ctx, a.logger, "qq gateway", bot.RetryConfig{}, func(ctx context.Context) error {
|
||||
token, err := a.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// connectGateway blocks for the connection's lifetime, returning on
|
||||
// disconnect or error; RunWithRetry handles the cancellation-aware
|
||||
// backoff and reconnect.
|
||||
return a.connectGateway(ctx, token)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *adapter) getAccessToken(ctx context.Context) (string, error) {
|
||||
a.tokenMu.Lock()
|
||||
if a.token != "" && time.Now().Before(a.tokenExpiry) {
|
||||
token := a.token
|
||||
a.tokenMu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
a.tokenMu.Unlock()
|
||||
|
||||
appID := a.appID()
|
||||
appSecret := a.appSecret()
|
||||
if appID == "" {
|
||||
return "", fmt.Errorf("qq app_id is empty")
|
||||
}
|
||||
if appSecret == "" {
|
||||
return "", fmt.Errorf("qq app secret is empty: set the %s environment variable", a.appSecretEnvName())
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"appId": appID,
|
||||
"clientSecret": appSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qqTokenURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := qqHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("qq token api error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"-"`
|
||||
ExpiresRaw any `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result.ExpiresIn, err = qqExpiresInSeconds(result.ExpiresRaw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
return "", fmt.Errorf("empty access token")
|
||||
}
|
||||
a.tokenMu.Lock()
|
||||
a.token = result.AccessToken
|
||||
expiresIn := int(result.ExpiresIn)
|
||||
if expiresIn > 60 {
|
||||
a.tokenExpiry = time.Now().Add(time.Duration(expiresIn-60) * time.Second)
|
||||
} else {
|
||||
a.tokenExpiry = time.Now().Add(5 * time.Minute)
|
||||
}
|
||||
a.tokenMu.Unlock()
|
||||
a.logger.Info("qq access token acquired", "expires_in_seconds", result.ExpiresIn)
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
|
||||
func qqExpiresInSeconds(value any) (int, error) {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return 0, nil
|
||||
case float64:
|
||||
return int(v), nil
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid qq token expires_in %q: %w", v, err)
|
||||
}
|
||||
return n, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid qq token expires_in type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *adapter) connectGateway(ctx context.Context, token string) error {
|
||||
gatewayURL, err := a.getGatewayURL(ctx, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parsed, parseErr := url.Parse(gatewayURL); parseErr == nil {
|
||||
a.logger.Info("qq gateway endpoint resolved", "host", parsed.Hostname(), "sandbox", a.cfg.Sandbox)
|
||||
}
|
||||
conn, err := a.dialGateway(ctx, gatewayURL, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial gateway: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
defer a.dropConn(conn)
|
||||
if !a.trackConn(ctx, conn) {
|
||||
// Stop already closed the tracked conn slot; entering a blocking read
|
||||
// now would leave a connection Stop can no longer unblock.
|
||||
return ctx.Err()
|
||||
}
|
||||
a.logger.Info("qq gateway connected", "sandbox", a.cfg.Sandbox)
|
||||
|
||||
ws := &wsClient{conn: conn, token: token, logger: a.logger}
|
||||
|
||||
var msg gatewayPayload
|
||||
decoder := json.NewDecoder(conn)
|
||||
|
||||
// 第一次读取必须是 Hello
|
||||
if err := decoder.Decode(&msg); err != nil {
|
||||
return fmt.Errorf("read hello: %w", err)
|
||||
}
|
||||
if msg.Op != opHello {
|
||||
return fmt.Errorf("expected op=%d hello, got op=%d", opHello, msg.Op)
|
||||
}
|
||||
var hello helloData
|
||||
if err := json.Unmarshal(msg.D, &hello); err != nil {
|
||||
return err
|
||||
}
|
||||
ws.heartbeatMs = int(sanitizeHeartbeatInterval(time.Duration(hello.HeartbeatInterval) * time.Millisecond).Milliseconds())
|
||||
|
||||
// Identify
|
||||
identify := identifyData{
|
||||
Token: fmt.Sprintf("QQBot %s", token),
|
||||
Intents: 1<<0 | 1<<1 | 1<<9 | 1<<10 | 1<<12 | 1<<25 | 1<<26,
|
||||
Shard: [2]int{0, 1},
|
||||
Properties: properties{
|
||||
OS: "linux",
|
||||
Browser: "reasonix",
|
||||
Device: "reasonix-bot",
|
||||
},
|
||||
}
|
||||
identifyJSON, _ := json.Marshal(identify)
|
||||
if err := ws.send(opIdentify, identifyJSON); err != nil {
|
||||
return fmt.Errorf("send identify: %w", err)
|
||||
}
|
||||
|
||||
// 读取 Ready
|
||||
if err := decoder.Decode(&msg); err != nil {
|
||||
return fmt.Errorf("read ready: %w", err)
|
||||
}
|
||||
if msg.Op == opDispatch && msg.T == "READY" {
|
||||
var ready struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.D, &ready); err != nil {
|
||||
return fmt.Errorf("decode ready: %w", err)
|
||||
}
|
||||
ws.sessionID = ready.SessionID
|
||||
a.sessionID = ready.SessionID
|
||||
a.seq = msg.S
|
||||
a.logger.Info("qq gateway ready", "sandbox", a.cfg.Sandbox, "heartbeat_ms", ws.heartbeatMs)
|
||||
} else {
|
||||
a.logger.Warn("qq gateway expected ready event", "op", msg.Op, "event", msg.T)
|
||||
}
|
||||
|
||||
// 启动 heartbeat
|
||||
heartbeatCtx, heartbeatCancel := context.WithCancel(ctx)
|
||||
defer heartbeatCancel()
|
||||
heartbeatDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(heartbeatDone)
|
||||
ticker := time.NewTicker(time.Duration(ws.heartbeatMs) * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-heartbeatCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
ws.mu.Lock()
|
||||
payload := json.RawMessage("null")
|
||||
if ws.lastSeq != 0 {
|
||||
payload = json.RawMessage(fmt.Sprintf(`%d`, ws.lastSeq))
|
||||
}
|
||||
if err := ws.send(opHeartbeat, payload); err != nil {
|
||||
ws.logger.Error("heartbeat failed", "err", err)
|
||||
ws.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ws.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 主循环:读取 dispatch 事件
|
||||
for {
|
||||
if err := decoder.Decode(&msg); err != nil {
|
||||
a.logger.Error("decode gateway message", "err", err)
|
||||
heartbeatCancel()
|
||||
<-heartbeatDone
|
||||
return err
|
||||
}
|
||||
ws.lastSeq = msg.S
|
||||
a.seq = msg.S
|
||||
|
||||
switch msg.Op {
|
||||
case opDispatch:
|
||||
a.handleDispatch(msg)
|
||||
case opHeartbeatAck:
|
||||
case opReconnect:
|
||||
a.logger.Info("gateway requested reconnect")
|
||||
heartbeatCancel()
|
||||
<-heartbeatDone
|
||||
return nil
|
||||
case opInvalid:
|
||||
a.sessionID = ""
|
||||
a.seq = 0
|
||||
a.logger.Info("gateway session invalidated")
|
||||
heartbeatCancel()
|
||||
<-heartbeatDone
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dialGateway dials the QQ gateway honoring ctx. The conn only becomes
|
||||
// trackable after the dial returns, so Stop can interrupt a stalled TCP dial
|
||||
// or WebSocket/TLS handshake only through ctx cancellation —
|
||||
// websocket.DialConfig would dial with context.Background() and leave Stop's
|
||||
// loopWG.Wait blocked with nothing to close.
|
||||
func (a *adapter) dialGateway(ctx context.Context, gatewayURL, token string) (*websocket.Conn, error) {
|
||||
cfg, err := websocket.NewConfig(gatewayURL, gatewayURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Header = http.Header{}
|
||||
cfg.Header.Set("Authorization", "QQBot "+token)
|
||||
cfg.Header.Set("X-Union-Appid", a.appID())
|
||||
return cfg.DialContext(ctx)
|
||||
}
|
||||
|
||||
// trackConn publishes the live gateway connection so Stop can close it and
|
||||
// unblock the blocking websocket reads, which do not honor ctx. Publication is
|
||||
// refused once ctx is cancelled, so a conn that finishes dialing concurrently
|
||||
// with Stop can never be left open but unreachable.
|
||||
func (a *adapter) trackConn(ctx context.Context, conn *websocket.Conn) bool {
|
||||
a.connMu.Lock()
|
||||
defer a.connMu.Unlock()
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
a.conn = conn
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *adapter) dropConn(conn *websocket.Conn) {
|
||||
a.connMu.Lock()
|
||||
if a.conn == conn {
|
||||
a.conn = nil
|
||||
}
|
||||
a.connMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *adapter) closeConn() {
|
||||
a.connMu.Lock()
|
||||
conn := a.conn
|
||||
a.conn = nil
|
||||
a.connMu.Unlock()
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *adapter) appID() string {
|
||||
if value := strings.TrimSpace(a.cfg.AppID); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("QQ_APPID"))
|
||||
}
|
||||
|
||||
func (a *adapter) appSecretEnvName() string {
|
||||
if value := strings.TrimSpace(a.cfg.AppSecretEnv); value != "" {
|
||||
return value
|
||||
}
|
||||
return "QQ_BOT_APP_SECRET"
|
||||
}
|
||||
|
||||
func (a *adapter) appSecret() string {
|
||||
return strings.TrimSpace(os.Getenv(a.appSecretEnvName()))
|
||||
}
|
||||
|
||||
func (a *adapter) apiBaseURL() string {
|
||||
if a.cfg.Sandbox {
|
||||
return qqSandboxURL
|
||||
}
|
||||
return qqBaseURL
|
||||
}
|
||||
|
||||
func (a *adapter) getGatewayURL(ctx context.Context, token string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.apiBaseURL()+"/gateway", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "QQBot "+token)
|
||||
resp, err := qqHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("qq gateway api error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return validateGatewayURL(result.URL)
|
||||
}
|
||||
|
||||
func validateGatewayURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("empty qq gateway url")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.Scheme != "wss" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || !allowedGatewayHost(u.Hostname()) {
|
||||
return "", fmt.Errorf("unexpected qq gateway url: %s", raw)
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func allowedGatewayHost(hostname string) bool {
|
||||
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||
for _, allowed := range allowedGatewayHosts {
|
||||
if hostname == allowed || strings.HasSuffix(hostname, "."+allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sanitizeHeartbeatInterval(interval time.Duration) time.Duration {
|
||||
if interval <= 0 {
|
||||
return qqMinHeartbeat
|
||||
}
|
||||
if interval < qqMinHeartbeat {
|
||||
return qqMinHeartbeat
|
||||
}
|
||||
if interval > qqMaxHeartbeat {
|
||||
return qqMaxHeartbeat
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
func (ws *wsClient) send(op int, d json.RawMessage) error {
|
||||
payload := gatewayPayload{Op: op, D: d}
|
||||
data, _ := json.Marshal(payload)
|
||||
_, err := ws.conn.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) handleDispatch(msg gatewayPayload) {
|
||||
var evt dispatchEvent
|
||||
if err := json.Unmarshal(msg.D, &evt); err != nil {
|
||||
a.logger.Error("parse dispatch", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
ib := bot.InboundMessage{
|
||||
Platform: bot.PlatformQQ,
|
||||
UserID: qqAuthorID(evt),
|
||||
UserName: evt.Author.Username,
|
||||
Text: evt.Content,
|
||||
MessageID: evt.ID,
|
||||
}
|
||||
|
||||
switch msg.T {
|
||||
case "C2C_MESSAGE_CREATE":
|
||||
ib.ChatType = bot.ChatDM
|
||||
ib.ChatID = ib.UserID
|
||||
case "GROUP_AT_MESSAGE_CREATE":
|
||||
ib.ChatType = bot.ChatGroup
|
||||
ib.ChatID = evt.GroupOpenID
|
||||
case "AT_MESSAGE_CREATE":
|
||||
ib.ChatType = bot.ChatGuild
|
||||
ib.ChatID = evt.ChannelID
|
||||
case "DIRECT_MESSAGE_CREATE":
|
||||
ib.ChatType = bot.ChatDirect
|
||||
ib.ChatID = evt.GuildID
|
||||
case "MESSAGE_CREATE":
|
||||
ib.ChatType = bot.ChatDM
|
||||
ib.ChatID = evt.ChannelID
|
||||
default:
|
||||
if strings.TrimSpace(msg.T) != "" {
|
||||
a.logger.Info("qq dispatch ignored", "event", msg.T)
|
||||
}
|
||||
return // 忽略其他事件
|
||||
}
|
||||
a.logger.Info("qq dispatch received", "event", msg.T, "chat_type", ib.ChatType)
|
||||
|
||||
select {
|
||||
case a.msgCh <- ib:
|
||||
default:
|
||||
a.logger.Warn("message channel full, dropping message")
|
||||
}
|
||||
}
|
||||
|
||||
func qqAuthorID(evt dispatchEvent) string {
|
||||
for _, value := range []string{evt.Author.UserOpenID, evt.Author.MemberOpenID, evt.Author.UnionOpenID, evt.Author.ID} {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sendMessage 使用 QQ REST API 发送消息。
|
||||
func (a *adapter) sendMessage(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
text := normalizeQQMarkdownReply(msg.Text)
|
||||
chunks := splitQQMessage(text, qqMaxChunkBytes)
|
||||
if len(chunks) == 0 {
|
||||
chunks = []string{""}
|
||||
}
|
||||
originalChunkCount := len(chunks)
|
||||
var truncated bool
|
||||
chunks, truncated = capQQPassiveReplyChunks(msg, chunks)
|
||||
if truncated {
|
||||
a.logger.Warn("qq passive reply truncated", "chat_type", msg.ChatType, "chunks", originalChunkCount, "limit", len(chunks))
|
||||
}
|
||||
var delivered bot.SendResult
|
||||
for _, chunk := range chunks {
|
||||
seq := a.nextMessageSeq(msg.ReplyToMsgID)
|
||||
var result bot.SendResult
|
||||
var err error
|
||||
if msg.Keyboard == nil && a.markdownDeliveryDisabled() {
|
||||
result, err = a.sendPlainMessageChunk(ctx, msg, chunk, seq)
|
||||
} else {
|
||||
result, err = a.sendMessageChunk(ctx, msg, chunk, seq)
|
||||
}
|
||||
if err != nil && msg.Keyboard == nil {
|
||||
a.disableMarkdownDelivery()
|
||||
a.logger.Warn("qq markdown delivery failed, retrying plain text", "chat_type", msg.ChatType, "err", err)
|
||||
result, err = a.sendPlainMessageChunk(ctx, msg, chunk, a.nextMessageSeq(msg.ReplyToMsgID))
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Error("qq message send failed", "chat_type", msg.ChatType, "err", err)
|
||||
return delivered, err
|
||||
}
|
||||
a.logger.Info("qq message sent", "chat_type", msg.ChatType, "message_id_set", strings.TrimSpace(result.MessageID) != "")
|
||||
delivered.Merge(result)
|
||||
}
|
||||
return delivered, nil
|
||||
}
|
||||
|
||||
func (a *adapter) sendPlainMessageChunk(ctx context.Context, msg bot.OutboundMessage, text string, seq int) (bot.SendResult, error) {
|
||||
return a.sendMessagePayload(ctx, msg, map[string]any{
|
||||
"content": text,
|
||||
"msg_type": 0,
|
||||
}, seq)
|
||||
}
|
||||
|
||||
func (a *adapter) sendMessageChunk(ctx context.Context, msg bot.OutboundMessage, text string, seq int) (bot.SendResult, error) {
|
||||
if msg.Keyboard != nil {
|
||||
payload := map[string]any{
|
||||
"content": text,
|
||||
"msg_type": 2,
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(msg.Keyboard.Rows))
|
||||
for _, row := range msg.Keyboard.Rows {
|
||||
buttons := make([]map[string]any, 0, len(row.Buttons))
|
||||
for _, btn := range row.Buttons {
|
||||
buttons = append(buttons, map[string]any{
|
||||
"id": strings.TrimSpace(btn.ID),
|
||||
"render_data": map[string]any{
|
||||
"label": btn.Label,
|
||||
"style": btn.Style,
|
||||
},
|
||||
"action": map[string]any{
|
||||
"type": 2,
|
||||
"data": btn.CallbackID,
|
||||
},
|
||||
})
|
||||
}
|
||||
rows = append(rows, map[string]any{"buttons": buttons})
|
||||
}
|
||||
payload["keyboard"] = map[string]any{
|
||||
"content": rows,
|
||||
}
|
||||
return a.sendMessagePayload(ctx, msg, payload, seq)
|
||||
}
|
||||
return a.sendMessagePayload(ctx, msg, map[string]any{
|
||||
"markdown": map[string]string{"content": text},
|
||||
"msg_type": 2,
|
||||
}, seq)
|
||||
}
|
||||
|
||||
func (a *adapter) sendMessagePayload(ctx context.Context, msg bot.OutboundMessage, payload map[string]any, seq int) (bot.SendResult, error) {
|
||||
token, err := a.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return bot.SendResult{}, err
|
||||
}
|
||||
|
||||
if msg.ReplyToMsgID != "" {
|
||||
payload["msg_id"] = msg.ReplyToMsgID
|
||||
}
|
||||
if seq > 0 {
|
||||
payload["msg_seq"] = seq
|
||||
}
|
||||
|
||||
url := a.qqSendURL(msg)
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return bot.SendResult{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "QQBot "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Union-Appid", a.appID())
|
||||
|
||||
resp, err := qqHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return bot.SendResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return bot.SendResult{}, fmt.Errorf("qq api error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return bot.SendResult{}, fmt.Errorf("decode send response: %w", err)
|
||||
}
|
||||
|
||||
return bot.SendResult{MessageID: result.ID}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) qqSendURL(msg bot.OutboundMessage) string {
|
||||
base := a.apiBaseURL()
|
||||
switch msg.ChatType {
|
||||
case bot.ChatGroup:
|
||||
return fmt.Sprintf("%s/v2/groups/%s/messages", base, url.PathEscape(msg.ChatID))
|
||||
case bot.ChatGuild, bot.ChatThread:
|
||||
return fmt.Sprintf("%s/v2/channels/%s/messages", base, url.PathEscape(msg.ChatID))
|
||||
case bot.ChatDirect:
|
||||
return fmt.Sprintf("%s/v2/dms/%s/messages", base, url.PathEscape(msg.ChatID))
|
||||
default:
|
||||
return fmt.Sprintf("%s/v2/users/%s/messages", base, url.PathEscape(msg.ChatID))
|
||||
}
|
||||
}
|
||||
|
||||
func qqSendURL(msg bot.OutboundMessage) string {
|
||||
return (&adapter{}).qqSendURL(msg)
|
||||
}
|
||||
|
||||
func (a *adapter) nextMessageSeq(replyTo string) int {
|
||||
if strings.TrimSpace(replyTo) == "" {
|
||||
return 0
|
||||
}
|
||||
a.sendMu.Lock()
|
||||
defer a.sendMu.Unlock()
|
||||
if a.nextOutboundMsgSeq <= 0 {
|
||||
a.nextOutboundMsgSeq = 1
|
||||
}
|
||||
seq := a.nextOutboundMsgSeq
|
||||
a.nextOutboundMsgSeq++
|
||||
return seq
|
||||
}
|
||||
|
||||
func (a *adapter) markdownDeliveryDisabled() bool {
|
||||
a.sendMu.Lock()
|
||||
defer a.sendMu.Unlock()
|
||||
return a.markdownDisabled
|
||||
}
|
||||
|
||||
func (a *adapter) disableMarkdownDelivery() {
|
||||
a.sendMu.Lock()
|
||||
defer a.sendMu.Unlock()
|
||||
a.markdownDisabled = true
|
||||
}
|
||||
|
||||
func normalizeQQMarkdownReply(text string) string {
|
||||
match := qqMarkdownWrapperRe.FindStringSubmatch(strings.TrimSpace(text))
|
||||
if len(match) != 2 {
|
||||
return text
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
func splitQQMessage(text string, maxBytes int) []string {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = qqMaxChunkBytes
|
||||
}
|
||||
var chunks []string
|
||||
remaining := text
|
||||
for remaining != "" {
|
||||
if len([]byte(remaining)) <= maxBytes {
|
||||
chunks = append(chunks, remaining)
|
||||
break
|
||||
}
|
||||
candidate := fitUTF8Slice(remaining, maxBytes)
|
||||
splitAt := pickNaturalSplit(candidate)
|
||||
chunks = append(chunks, candidate[:splitAt])
|
||||
remaining = strings.TrimLeft(remaining[splitAt:], " \t\r\n")
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func capQQPassiveReplyChunks(msg bot.OutboundMessage, chunks []string) ([]string, bool) {
|
||||
if !qqUsesPassiveReplyLimit(msg) || len(chunks) <= qqMaxPassiveReplyChunks {
|
||||
return chunks, false
|
||||
}
|
||||
capped := make([]string, 0, qqMaxPassiveReplyChunks)
|
||||
capped = append(capped, chunks[:qqMaxPassiveReplyChunks-1]...)
|
||||
capped = append(capped, fitQQChunkWithSuffix(chunks[qqMaxPassiveReplyChunks-1], qqPassiveReplyTruncationNotice, qqMaxChunkBytes))
|
||||
return capped, true
|
||||
}
|
||||
|
||||
func qqUsesPassiveReplyLimit(msg bot.OutboundMessage) bool {
|
||||
if strings.TrimSpace(msg.ReplyToMsgID) == "" {
|
||||
return false
|
||||
}
|
||||
return msg.ChatType == bot.ChatDM || msg.ChatType == bot.ChatGroup
|
||||
}
|
||||
|
||||
func fitQQChunkWithSuffix(text, suffix string, maxBytes int) string {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = qqMaxChunkBytes
|
||||
}
|
||||
suffixBytes := len([]byte(suffix))
|
||||
if suffixBytes >= maxBytes {
|
||||
return fitUTF8Slice(suffix, maxBytes)
|
||||
}
|
||||
prefix := strings.TrimRight(fitUTF8Slice(text, maxBytes-suffixBytes), " \t\r\n")
|
||||
if prefix == "" {
|
||||
return strings.TrimLeft(fitUTF8Slice(suffix, maxBytes), " \t\r\n")
|
||||
}
|
||||
return prefix + suffix
|
||||
}
|
||||
|
||||
func fitUTF8Slice(text string, maxBytes int) string {
|
||||
return textutil.FitGraphemeBytes(text, maxBytes)
|
||||
}
|
||||
|
||||
func pickNaturalSplit(candidate string) int {
|
||||
if candidate == "" {
|
||||
return 0
|
||||
}
|
||||
minSplit := len(candidate) * 6 / 10
|
||||
for _, sep := range []string{"\n\n", "\n", " "} {
|
||||
if at := strings.LastIndex(candidate, sep); at >= minSplit {
|
||||
return at + len(sep)
|
||||
}
|
||||
}
|
||||
return len(candidate)
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func TestHandleDispatchDirectMessageUsesDirectChatType(t *testing.T) {
|
||||
a := &adapter{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"id": "msg-1",
|
||||
"content": "hello",
|
||||
"guild_id": "guild-1",
|
||||
"author": map[string]string{
|
||||
"id": "user-1",
|
||||
"username": "user",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.handleDispatch(gatewayPayload{T: "DIRECT_MESSAGE_CREATE", D: raw})
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.ChatType != bot.ChatDirect {
|
||||
t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatDirect)
|
||||
}
|
||||
if msg.ChatID != "guild-1" {
|
||||
t.Fatalf("chat id = %q, want guild-1", msg.ChatID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDispatchC2CUsesUserOpenID(t *testing.T) {
|
||||
a := &adapter{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"id": "msg-1",
|
||||
"content": "hello",
|
||||
"author": map[string]string{
|
||||
"user_openid": "openid-user",
|
||||
"username": "user",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.handleDispatch(gatewayPayload{T: "C2C_MESSAGE_CREATE", D: raw})
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.UserID != "openid-user" {
|
||||
t.Fatalf("user id = %q, want openid-user", msg.UserID)
|
||||
}
|
||||
if msg.ChatID != "openid-user" {
|
||||
t.Fatalf("chat id = %q, want openid-user", msg.ChatID)
|
||||
}
|
||||
if msg.ChatType != bot.ChatDM {
|
||||
t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatDM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQQSendURLDirectMessage(t *testing.T) {
|
||||
got := qqSendURL(bot.OutboundMessage{ChatType: bot.ChatDirect, ChatID: "guild-1"})
|
||||
want := fmt.Sprintf("%s/v2/dms/%s/messages", qqBaseURL, "guild-1")
|
||||
if got != want {
|
||||
t.Fatalf("url = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQQSendURLUsesSandboxBase(t *testing.T) {
|
||||
a := &adapter{cfg: config.QQBotConfig{Sandbox: true}}
|
||||
got := a.qqSendURL(bot.OutboundMessage{ChatType: bot.ChatDM, ChatID: "user/open id"})
|
||||
want := qqSandboxURL + "/v2/users/user%2Fopen%20id/messages"
|
||||
if got != want {
|
||||
t.Fatalf("url = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGatewayURL(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"wss://api.sgroup.qq.com/websocket",
|
||||
"wss://sandbox.api.sgroup.qq.com/websocket",
|
||||
"wss://gateway.qq.com/websocket",
|
||||
} {
|
||||
if _, err := validateGatewayURL(raw); err != nil {
|
||||
t.Fatalf("valid gateway %q rejected: %v", raw, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{
|
||||
"http://api.sgroup.qq.com/websocket",
|
||||
"wss://evil.example/websocket",
|
||||
"wss://api.sgroup.qq.com/websocket?token=1",
|
||||
"wss://user:[email protected]/websocket",
|
||||
} {
|
||||
if _, err := validateGatewayURL(raw); err == nil {
|
||||
t.Fatalf("invalid gateway %q accepted", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeQQMarkdownReply(t *testing.T) {
|
||||
got := normalizeQQMarkdownReply("```markdown\n# Title\n\n**bold**\n```")
|
||||
if got != "# Title\n\n**bold**" {
|
||||
t.Fatalf("normalized markdown = %q", got)
|
||||
}
|
||||
normal := "Here is code:\n```go\nfmt.Println()\n```"
|
||||
if got := normalizeQQMarkdownReply(normal); got != normal {
|
||||
t.Fatalf("normal code block changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitQQMessageKeepsUTF8Budget(t *testing.T) {
|
||||
chunks := splitQQMessage(strings.Repeat("中", 600), 1500)
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("chunks = %d, want more than one", len(chunks))
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
if len([]byte(chunk)) > 1500 {
|
||||
t.Fatalf("chunk byte length = %d, want <= 1500", len([]byte(chunk)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitUTF8SliceKeepsGraphemeCluster(t *testing.T) {
|
||||
cluster := "👨👩👧👦"
|
||||
got := fitUTF8Slice(cluster+"!", len([]byte(cluster)))
|
||||
if got != cluster {
|
||||
t.Fatalf("fitUTF8Slice split grapheme cluster: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapQQPassiveReplyChunks(t *testing.T) {
|
||||
chunks := splitQQMessage(strings.Repeat("chunk-", 1800), qqMaxChunkBytes)
|
||||
if len(chunks) <= qqMaxPassiveReplyChunks {
|
||||
t.Fatalf("chunks = %d, want more than passive reply limit", len(chunks))
|
||||
}
|
||||
|
||||
got, truncated := capQQPassiveReplyChunks(bot.OutboundMessage{
|
||||
ChatType: bot.ChatDM,
|
||||
ReplyToMsgID: "msg-id",
|
||||
}, chunks)
|
||||
if !truncated {
|
||||
t.Fatal("capQQPassiveReplyChunks truncated = false, want true")
|
||||
}
|
||||
if len(got) != qqMaxPassiveReplyChunks {
|
||||
t.Fatalf("capped chunks = %d, want %d", len(got), qqMaxPassiveReplyChunks)
|
||||
}
|
||||
for _, chunk := range got {
|
||||
if len([]byte(chunk)) > qqMaxChunkBytes {
|
||||
t.Fatalf("chunk byte length = %d, want <= %d", len([]byte(chunk)), qqMaxChunkBytes)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got[len(got)-1], "Truncated") {
|
||||
t.Fatalf("last chunk = %q, want truncation notice", got[len(got)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapQQPassiveReplyChunksDoesNotCapNonPassiveReplies(t *testing.T) {
|
||||
chunks := splitQQMessage(strings.Repeat("chunk-", 1800), qqMaxChunkBytes)
|
||||
got, truncated := capQQPassiveReplyChunks(bot.OutboundMessage{
|
||||
ChatType: bot.ChatDM,
|
||||
}, chunks)
|
||||
if truncated {
|
||||
t.Fatal("capQQPassiveReplyChunks truncated non-passive reply")
|
||||
}
|
||||
if len(got) != len(chunks) {
|
||||
t.Fatalf("chunks = %d, want %d", len(got), len(chunks))
|
||||
}
|
||||
|
||||
got, truncated = capQQPassiveReplyChunks(bot.OutboundMessage{
|
||||
ChatType: bot.ChatDirect,
|
||||
ReplyToMsgID: "msg-id",
|
||||
}, chunks)
|
||||
if truncated {
|
||||
t.Fatal("capQQPassiveReplyChunks truncated direct/guild reply")
|
||||
}
|
||||
if len(got) != len(chunks) {
|
||||
t.Fatalf("chunks = %d, want %d", len(got), len(chunks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartValidatesQQCredentialsBeforeRunning(t *testing.T) {
|
||||
a := &adapter{}
|
||||
if err := a.Start(context.Background()); err == nil {
|
||||
t.Fatal("Start() error = nil, want missing app_id error")
|
||||
}
|
||||
if a.cancel != nil {
|
||||
t.Fatal("Start() installed runtime cancel after validation failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQQExpiresInSecondsAcceptsNumberAndString(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
value any
|
||||
want int
|
||||
}{
|
||||
{name: "number", value: float64(3600), want: 3600},
|
||||
{name: "string", value: "7200", want: 7200},
|
||||
{name: "blank", value: "", want: 0},
|
||||
{name: "missing", value: nil, want: 0},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := qqExpiresInSeconds(tt.value)
|
||||
if err != nil {
|
||||
t.Fatalf("qqExpiresInSeconds() error = %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("qqExpiresInSeconds() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageMarkdownFallbackDisablesMarkdown(t *testing.T) {
|
||||
t.Setenv("QQ_BOT_APP_SECRET", "secret")
|
||||
origTransport := http.DefaultTransport
|
||||
defer func() { http.DefaultTransport = origTransport }()
|
||||
|
||||
var bodies []map[string]any
|
||||
sendCount := 0
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Host {
|
||||
case "bots.qq.com":
|
||||
return jsonResponse(200, map[string]any{"access_token": "token", "expires_in": 3600}), nil
|
||||
case "api.sgroup.qq.com":
|
||||
if req.Header.Get("Authorization") != "QQBot token" {
|
||||
t.Fatalf("authorization = %q, want QQBot token", req.Header.Get("Authorization"))
|
||||
}
|
||||
if req.Header.Get("X-Union-Appid") != "app-id" {
|
||||
t.Fatalf("x-union-appid = %q, want app-id", req.Header.Get("X-Union-Appid"))
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bodies = append(bodies, body)
|
||||
sendCount++
|
||||
if sendCount == 1 {
|
||||
return jsonResponse(400, map[string]any{"message": "markdown rejected"}), nil
|
||||
}
|
||||
return jsonResponse(200, map[string]any{"id": fmt.Sprintf("sent-%d", sendCount)}), nil
|
||||
default:
|
||||
t.Fatalf("unexpected request host: %s", req.URL.Host)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
a := &adapter{
|
||||
cfg: config.QQBotConfig{AppID: "app-id", AppSecretEnv: "QQ_BOT_APP_SECRET"},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
_, err := a.sendMessage(context.Background(), bot.OutboundMessage{
|
||||
ChatType: bot.ChatDM,
|
||||
ChatID: "openid-user",
|
||||
Text: "**bold**",
|
||||
ReplyToMsgID: "msg-id",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send message: %v", err)
|
||||
}
|
||||
_, err = a.sendMessage(context.Background(), bot.OutboundMessage{
|
||||
ChatType: bot.ChatDM,
|
||||
ChatID: "openid-user",
|
||||
Text: "**next**",
|
||||
ReplyToMsgID: "msg-id-2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second send message: %v", err)
|
||||
}
|
||||
if len(bodies) != 3 {
|
||||
t.Fatalf("sent bodies = %d, want 3", len(bodies))
|
||||
}
|
||||
if bodies[0]["msg_type"] != float64(2) || bodies[0]["markdown"] == nil || bodies[0]["msg_seq"] != float64(1) {
|
||||
t.Fatalf("first body = %#v, want markdown msg_seq=1", bodies[0])
|
||||
}
|
||||
if bodies[1]["msg_type"] != float64(0) || bodies[1]["content"] != "**bold**" || bodies[1]["msg_seq"] != float64(2) {
|
||||
t.Fatalf("fallback body = %#v, want plain msg_seq=2", bodies[1])
|
||||
}
|
||||
if bodies[2]["msg_type"] != float64(0) || bodies[2]["content"] != "**next**" || bodies[2]["msg_seq"] != float64(3) {
|
||||
t.Fatalf("second body = %#v, want plain msg_seq=3", bodies[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageReturnsAllChunkIDs(t *testing.T) {
|
||||
t.Setenv("QQ_BOT_APP_SECRET", "secret")
|
||||
origTransport := http.DefaultTransport
|
||||
defer func() { http.DefaultTransport = origTransport }()
|
||||
|
||||
sendCount := 0
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Host {
|
||||
case "bots.qq.com":
|
||||
return jsonResponse(200, map[string]any{"access_token": "token", "expires_in": 3600}), nil
|
||||
case "api.sgroup.qq.com":
|
||||
sendCount++
|
||||
return jsonResponse(200, map[string]any{"id": fmt.Sprintf("sent-%d", sendCount)}), nil
|
||||
default:
|
||||
t.Fatalf("unexpected request host: %s", req.URL.Host)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
text := strings.Repeat("chunk-", 1800)
|
||||
wantChunks := len(splitQQMessage(text, qqMaxChunkBytes))
|
||||
a := &adapter{
|
||||
cfg: config.QQBotConfig{AppID: "app-id", AppSecretEnv: "QQ_BOT_APP_SECRET"},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
result, err := a.sendMessage(context.Background(), bot.OutboundMessage{
|
||||
ChatType: bot.ChatDM,
|
||||
ChatID: "openid-user",
|
||||
Text: text,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send message: %v", err)
|
||||
}
|
||||
if len(result.MessageIDs) != wantChunks {
|
||||
t.Fatalf("message IDs = %v, want %d chunk IDs", result.MessageIDs, wantChunks)
|
||||
}
|
||||
if result.MessageID != fmt.Sprintf("sent-%d", wantChunks) {
|
||||
t.Fatalf("compatibility message ID = %q, want last chunk", result.MessageID)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func jsonResponse(status int, v any) *http.Response {
|
||||
data, _ := json.Marshal(v)
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": {"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewReader(data)),
|
||||
}
|
||||
}
|
||||
@@ -1,737 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// messageEditor 是适配器的可选能力:原地编辑已发送的消息。实现它的适配器
|
||||
// (目前是飞书,经 Im.Message.Patch)获得回合中的流式输出——渲染器不断更新
|
||||
// 同一条“live 消息”,而不是攒到回合结束一次性分段发送。
|
||||
type messageEditor interface {
|
||||
EditMessage(ctx context.Context, messageID string, msg OutboundMessage) error
|
||||
}
|
||||
|
||||
// renderSink 将 Reasonix 事件流渲染为平台消息。
|
||||
type renderSink struct {
|
||||
ctx context.Context
|
||||
adapter Adapter
|
||||
editor messageEditor // 非 nil 时启用原地编辑流式输出
|
||||
connID string
|
||||
domain string
|
||||
chatID string
|
||||
chatType ChatType
|
||||
userID string
|
||||
replyTo string
|
||||
logger *slog.Logger
|
||||
ctrl botController
|
||||
onApproval func(event.Approval)
|
||||
onAsk func(event.Ask)
|
||||
|
||||
// 渲染缓冲
|
||||
buf strings.Builder
|
||||
thinking strings.Builder
|
||||
inThinking bool
|
||||
toolNames map[string]string // tool ID -> name
|
||||
lastFlush time.Time
|
||||
lastProgress time.Time
|
||||
progressCount int
|
||||
|
||||
// 流式 live 消息状态(editor != nil 时使用)
|
||||
liveMsgID string // 正在原地编辑的消息 ID;空表示当前块还没创建消息
|
||||
liveSentBytes int // buf 前缀中已成功送达 live 消息的字节数
|
||||
lastEdit time.Time // 上次成功 create/edit 的时间,用于限频
|
||||
}
|
||||
|
||||
const (
|
||||
renderSoftFlushAfter = 1200 * time.Millisecond
|
||||
renderMaxChunkRunes = 1800
|
||||
renderHardChunkRunes = 3500
|
||||
renderProgressMinInterval = 2 * time.Second
|
||||
renderMaxProgressMessages = 3
|
||||
)
|
||||
|
||||
func newRenderSink(ctx context.Context, adapter Adapter, connID, domain, chatID string, chatType ChatType, userID string, replyTo string, logger *slog.Logger, onApproval func(event.Approval), onAsk func(event.Ask)) *renderSink {
|
||||
editor, _ := adapter.(messageEditor)
|
||||
return &renderSink{
|
||||
ctx: ctx,
|
||||
adapter: adapter,
|
||||
editor: editor,
|
||||
connID: connID,
|
||||
domain: domain,
|
||||
chatID: chatID,
|
||||
chatType: chatType,
|
||||
userID: userID,
|
||||
replyTo: replyTo,
|
||||
logger: logger,
|
||||
onApproval: onApproval,
|
||||
onAsk: onAsk,
|
||||
toolNames: make(map[string]string),
|
||||
lastFlush: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *renderSink) Emit(e event.Event) {
|
||||
switch e.Kind {
|
||||
case event.TurnStarted:
|
||||
s.buf.Reset()
|
||||
s.thinking.Reset()
|
||||
s.inThinking = false
|
||||
s.toolNames = make(map[string]string)
|
||||
s.progressCount = 0
|
||||
s.lastProgress = time.Time{}
|
||||
s.liveMsgID = ""
|
||||
s.liveSentBytes = 0
|
||||
s.lastEdit = time.Time{}
|
||||
|
||||
case event.Reasoning:
|
||||
if !s.inThinking {
|
||||
s.inThinking = true
|
||||
}
|
||||
s.thinking.WriteString(e.Text)
|
||||
|
||||
case event.Text:
|
||||
if s.inThinking {
|
||||
s.inThinking = false
|
||||
}
|
||||
s.buf.WriteString(e.Text)
|
||||
s.maybeStream()
|
||||
|
||||
case event.Message:
|
||||
// full message received, do nothing extra
|
||||
|
||||
case event.ToolDispatch:
|
||||
if e.Tool.Refreshed {
|
||||
break
|
||||
}
|
||||
name := renderToolName(e.Tool)
|
||||
s.toolNames[e.Tool.ID] = name
|
||||
s.sendProgress(fmt.Sprintf("正在执行: %s", name), false)
|
||||
|
||||
case event.ToolResult:
|
||||
name := s.toolNames[e.Tool.ID]
|
||||
if name == "" {
|
||||
name = renderToolName(e.Tool)
|
||||
}
|
||||
if e.Tool.Err != "" {
|
||||
s.sendProgress(fmt.Sprintf("%s 执行失败,稍后会在结果中说明。", name), true)
|
||||
}
|
||||
|
||||
case event.ToolProgress:
|
||||
// Keep streaming tool output out of IM channels; the session transcript
|
||||
// still records the complete controller turn for desktop review.
|
||||
|
||||
case event.ApprovalRequest:
|
||||
// 发送审批请求
|
||||
if s.onApproval != nil {
|
||||
s.onApproval(e.Approval)
|
||||
}
|
||||
approvalText := renderApprovalText(e.Approval)
|
||||
msg := OutboundMessage{
|
||||
ConnectionID: s.connID,
|
||||
Domain: s.domain,
|
||||
ChatID: s.chatID,
|
||||
ChatType: s.chatType,
|
||||
Text: approvalText,
|
||||
ReplyToMsgID: s.replyTo,
|
||||
}
|
||||
switch s.adapter.Platform() {
|
||||
case PlatformQQ:
|
||||
if isRecoveryApproval(e.Approval) {
|
||||
msg.Keyboard = recoveryKeyboard(e.Approval)
|
||||
} else {
|
||||
msg.Keyboard = approvalKeyboard(e.Approval.ID)
|
||||
}
|
||||
case PlatformFeishu:
|
||||
if isRecoveryApproval(e.Approval) {
|
||||
msg.Card = recoveryCard(e.Approval, s.chatType, s.userID)
|
||||
} else {
|
||||
msg.Card = approvalCard(e.Approval, s.chatType, s.userID)
|
||||
}
|
||||
}
|
||||
_ = s.send(msg)
|
||||
|
||||
case event.AskRequest:
|
||||
if s.onAsk != nil {
|
||||
s.onAsk(e.Ask)
|
||||
}
|
||||
// 发送问答请求
|
||||
askText := renderAskText(e.Ask)
|
||||
msg := OutboundMessage{
|
||||
ConnectionID: s.connID,
|
||||
Domain: s.domain,
|
||||
ChatID: s.chatID,
|
||||
ChatType: s.chatType,
|
||||
Text: askText,
|
||||
ReplyToMsgID: s.replyTo,
|
||||
}
|
||||
if s.adapter.Platform() == PlatformFeishu {
|
||||
msg.Card = askCard(e.Ask, askText, s.chatType, s.userID)
|
||||
}
|
||||
_ = s.send(msg)
|
||||
|
||||
case event.TurnDone:
|
||||
// 刷新缓冲
|
||||
s.flush()
|
||||
if e.Err != nil {
|
||||
if !errors.Is(e.Err, context.Canceled) {
|
||||
_ = s.send(OutboundMessage{
|
||||
ConnectionID: s.connID,
|
||||
Domain: s.domain,
|
||||
ChatID: s.chatID,
|
||||
ChatType: s.chatType,
|
||||
Text: fmt.Sprintf("❌ 执行出错: %v", e.Err),
|
||||
ReplyToMsgID: s.replyTo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
case event.Notice:
|
||||
if e.Audience == event.NoticeAudienceOperator {
|
||||
// Persistence recovery remains available through controller logs and
|
||||
// local operator surfaces. It is not actionable for the remote chat
|
||||
// participant and must not interrupt their conversation (#7215).
|
||||
s.logger.Debug("bot suppressed operator notice", "code", e.Code)
|
||||
break
|
||||
}
|
||||
if e.Level == event.LevelWarn {
|
||||
_ = s.send(OutboundMessage{
|
||||
ConnectionID: s.connID,
|
||||
Domain: s.domain,
|
||||
ChatID: s.chatID,
|
||||
ChatType: s.chatType,
|
||||
Text: fmt.Sprintf("⚠️ %s", e.Text),
|
||||
ReplyToMsgID: s.replyTo,
|
||||
})
|
||||
}
|
||||
|
||||
case event.CompactionStarted:
|
||||
_ = s.send(OutboundMessage{
|
||||
ConnectionID: s.connID,
|
||||
Domain: s.domain,
|
||||
ChatID: s.chatID,
|
||||
ChatType: s.chatType,
|
||||
Text: "🔄 正在压缩上下文...",
|
||||
ReplyToMsgID: s.replyTo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *renderSink) flush() {
|
||||
for strings.TrimSpace(s.buf.String()) != "" {
|
||||
raw := s.buf.String()
|
||||
// When streaming into a live message, finalize the whole remaining text
|
||||
// with one edit instead of splitting at a semantic boundary — otherwise
|
||||
// a final answer that does not end on a boundary (code block, list, URL)
|
||||
// gets shrunk in place and its tail re-sent as a separate message,
|
||||
// defeating the point of in-place streaming. Only fall back to boundary
|
||||
// chunking when the remainder genuinely exceeds the hard cap.
|
||||
if s.editor != nil && s.liveMsgID != "" && len([]rune(raw)) < renderHardChunkRunes {
|
||||
s.flushPrefix(len(raw))
|
||||
continue
|
||||
}
|
||||
idx := renderFlushIndex(raw, renderSoftFlushAfter)
|
||||
if idx <= 0 {
|
||||
idx = byteIndexForRuneLimit(raw, renderMaxChunkRunes)
|
||||
}
|
||||
if idx <= 0 || idx > len(raw) {
|
||||
idx = len(raw)
|
||||
}
|
||||
s.flushPrefix(idx)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *renderSink) flushPrefix(idx int) {
|
||||
raw := s.buf.String()
|
||||
if idx <= 0 || idx > len(raw) {
|
||||
idx = len(raw)
|
||||
}
|
||||
text := strings.TrimSpace(raw[:idx])
|
||||
if text == "" {
|
||||
remaining := raw[idx:]
|
||||
s.buf.Reset()
|
||||
s.buf.WriteString(remaining)
|
||||
s.lastFlush = time.Now()
|
||||
return
|
||||
}
|
||||
// resumeFrom marks where the not-yet-delivered remainder starts. On success
|
||||
// it is idx (the block boundary). On edit failure the live message is frozen
|
||||
// at raw[:liveSentBytes], so anything already shown past idx must NOT be
|
||||
// re-queued — the resume point becomes max(idx, liveSentBytes), otherwise the
|
||||
// [idx, liveSentBytes] span is both displayed and re-sent (duplication).
|
||||
resumeFrom := idx
|
||||
if s.liveMsgID != "" {
|
||||
// 当前块已有 live 消息:把最终内容原地编辑进去,而不是再发一条。
|
||||
if err := s.editLive(text); err != nil {
|
||||
s.logger.Warn("bot live message final edit failed; sending tail as new message", "err", err)
|
||||
if tail := strings.TrimSpace(raw[min(s.liveSentBytes, idx):idx]); tail != "" {
|
||||
_ = s.send(s.textMessage(tail))
|
||||
}
|
||||
if s.liveSentBytes > resumeFrom {
|
||||
resumeFrom = s.liveSentBytes
|
||||
}
|
||||
}
|
||||
s.liveMsgID = ""
|
||||
s.liveSentBytes = 0
|
||||
} else {
|
||||
_ = s.send(s.textMessage(text))
|
||||
}
|
||||
if resumeFrom > len(raw) {
|
||||
resumeFrom = len(raw)
|
||||
}
|
||||
remaining := raw[resumeFrom:]
|
||||
s.buf.Reset()
|
||||
s.buf.WriteString(remaining)
|
||||
s.lastFlush = time.Now()
|
||||
}
|
||||
|
||||
// maybeStream 在每个文本增量后驱动流式输出:把已缓冲文本 create/edit 到
|
||||
// live 消息。仅当适配器支持原地编辑时启用;限频间隔复用 renderSoftFlushAfter
|
||||
// (1.2s,低于飞书单消息 Patch 的 QPS 上限)。
|
||||
func (s *renderSink) maybeStream() {
|
||||
if s.editor == nil {
|
||||
return
|
||||
}
|
||||
raw := s.buf.String()
|
||||
if len([]rune(raw)) >= renderHardChunkRunes {
|
||||
// 当前块过长:按语义边界收尾 live 消息,剩余文本进入下一块。
|
||||
idx := lastSemanticBoundary(raw, renderMaxChunkRunes)
|
||||
if idx <= 0 {
|
||||
idx = byteIndexForRuneLimit(raw, renderMaxChunkRunes)
|
||||
}
|
||||
s.flushPrefix(idx)
|
||||
return
|
||||
}
|
||||
last := s.lastEdit
|
||||
if s.liveMsgID == "" {
|
||||
last = s.lastFlush
|
||||
}
|
||||
if time.Since(last) < renderSoftFlushAfter {
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
if s.liveMsgID == "" {
|
||||
res, err := s.adapter.Send(s.ctx, s.textMessage(text))
|
||||
if err != nil {
|
||||
// 创建失败(可能是瞬时网络错误):文本留在 buf 里,限频后重试;
|
||||
// 就算一直失败,回合末的 flush 也会兜底发送。
|
||||
s.logger.Warn("bot live message create failed", "err", err)
|
||||
s.lastFlush = time.Now()
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(res.MessageID) == "" {
|
||||
// 平台没回消息 ID,无法编辑:本回合退回“攒到回合末分段发送”,
|
||||
// 已发出的前缀从 buf 里去掉避免重复。
|
||||
s.editor = nil
|
||||
s.cutBufPrefix(len(raw))
|
||||
return
|
||||
}
|
||||
s.liveMsgID = res.MessageID
|
||||
s.liveSentBytes = len(raw)
|
||||
s.lastEdit = time.Now()
|
||||
return
|
||||
}
|
||||
if err := s.editLive(text); err != nil {
|
||||
// 编辑失败(限频/超长/消息被撤回):结束这个块,已送达前缀不再重发,
|
||||
// 未送达的尾部留在 buf 里由下一条消息续上。
|
||||
s.logger.Warn("bot live message edit failed; rotating to new message", "err", err)
|
||||
s.cutBufPrefix(s.liveSentBytes)
|
||||
s.liveMsgID = ""
|
||||
s.liveSentBytes = 0
|
||||
return
|
||||
}
|
||||
s.liveSentBytes = len(raw)
|
||||
s.lastEdit = time.Now()
|
||||
}
|
||||
|
||||
func (s *renderSink) editLive(text string) error {
|
||||
err := s.editor.EditMessage(s.ctx, s.liveMsgID, s.textMessage(text))
|
||||
if err == nil {
|
||||
s.lastEdit = time.Now()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// cutBufPrefix 从 buf 头部移除 n 个字节(已送达 live 消息的内容)。
|
||||
func (s *renderSink) cutBufPrefix(n int) {
|
||||
raw := s.buf.String()
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
if n > len(raw) {
|
||||
n = len(raw)
|
||||
}
|
||||
remaining := raw[n:]
|
||||
s.buf.Reset()
|
||||
s.buf.WriteString(remaining)
|
||||
s.lastFlush = time.Now()
|
||||
}
|
||||
|
||||
func (s *renderSink) textMessage(text string) OutboundMessage {
|
||||
return OutboundMessage{
|
||||
ConnectionID: s.connID,
|
||||
Domain: s.domain,
|
||||
ChatID: s.chatID,
|
||||
ChatType: s.chatType,
|
||||
Text: text,
|
||||
ReplyToMsgID: s.replyTo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *renderSink) sendProgress(text string, force bool) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if s.progressCount >= renderMaxProgressMessages {
|
||||
return
|
||||
}
|
||||
if !force && !s.lastProgress.IsZero() && now.Sub(s.lastProgress) < renderProgressMinInterval {
|
||||
return
|
||||
}
|
||||
_ = s.send(OutboundMessage{
|
||||
ConnectionID: s.connID,
|
||||
Domain: s.domain,
|
||||
ChatID: s.chatID,
|
||||
ChatType: s.chatType,
|
||||
Text: text,
|
||||
ReplyToMsgID: s.replyTo,
|
||||
})
|
||||
s.progressCount++
|
||||
s.lastProgress = now
|
||||
}
|
||||
|
||||
func renderToolName(t event.Tool) string {
|
||||
if name := strings.TrimSpace(t.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
if id := strings.TrimSpace(t.ID); id != "" {
|
||||
return id
|
||||
}
|
||||
return "tool"
|
||||
}
|
||||
|
||||
func renderFlushIndex(text string, elapsed time.Duration) int {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return 0
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) >= renderHardChunkRunes {
|
||||
if idx := lastSemanticBoundary(text, renderHardChunkRunes); idx > 0 {
|
||||
return idx
|
||||
}
|
||||
return byteIndexForRuneLimit(text, renderMaxChunkRunes)
|
||||
}
|
||||
if len(runes) >= renderMaxChunkRunes {
|
||||
if idx := lastSemanticBoundary(text, renderMaxChunkRunes); idx > 0 {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
if elapsed < renderSoftFlushAfter {
|
||||
return 0
|
||||
}
|
||||
return lastSemanticBoundary(text, len(runes))
|
||||
}
|
||||
|
||||
func lastSemanticBoundary(text string, maxRunes int) int {
|
||||
if maxRunes <= 0 {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
lastBoundary := 0
|
||||
lastNonSpaceBoundary := 0
|
||||
inFence := false
|
||||
for idx, r := range text {
|
||||
if strings.HasPrefix(text[idx:], "```") {
|
||||
inFence = !inFence
|
||||
}
|
||||
count++
|
||||
if count > maxRunes {
|
||||
break
|
||||
}
|
||||
next := idx + len(string(r))
|
||||
if r == '\n' && !inFence {
|
||||
lastNonSpaceBoundary = next
|
||||
lastBoundary = next
|
||||
continue
|
||||
}
|
||||
if unicode.IsSpace(r) {
|
||||
if lastNonSpaceBoundary > 0 {
|
||||
lastBoundary = next
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inFence {
|
||||
continue
|
||||
}
|
||||
if isSemanticBoundaryRune(r) {
|
||||
lastNonSpaceBoundary = next
|
||||
lastBoundary = next
|
||||
}
|
||||
}
|
||||
return lastBoundary
|
||||
}
|
||||
|
||||
func isSemanticBoundaryRune(r rune) bool {
|
||||
switch r {
|
||||
case '.', '!', '?', ';', '。', '!', '?', ';', '…':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func byteIndexForRuneLimit(text string, maxRunes int) int {
|
||||
if maxRunes <= 0 {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for idx, r := range text {
|
||||
count++
|
||||
if count >= maxRunes {
|
||||
return idx + len(string(r))
|
||||
}
|
||||
}
|
||||
return len(text)
|
||||
}
|
||||
|
||||
func (s *renderSink) send(msg OutboundMessage) error {
|
||||
_, err := s.adapter.Send(s.ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func approvalKeyboard(id string) *InlineKeyboard {
|
||||
return &InlineKeyboard{Rows: []InlineKeyboardRow{{
|
||||
Buttons: []InlineKeyboardButton{
|
||||
{ID: "allow_once", Label: "允许一次", Style: 1, CallbackID: "/approve " + id},
|
||||
{ID: "deny", Label: "拒绝", Style: 2, CallbackID: "/deny " + id},
|
||||
},
|
||||
}}}
|
||||
}
|
||||
|
||||
func recoveryKeyboard(a event.Approval) *InlineKeyboard {
|
||||
if isRecoveryPlanChange(a) {
|
||||
return &InlineKeyboard{Rows: []InlineKeyboardRow{{Buttons: []InlineKeyboardButton{
|
||||
{ID: "recovery_continue", Label: "1 采用并继续", Style: 0, CallbackID: "/recovery-continue " + a.ID},
|
||||
{ID: "recovery_revise", Label: "2 不采用并调整", Style: 0, CallbackID: "/recovery-revise " + a.ID},
|
||||
}}}}
|
||||
}
|
||||
buttons := []InlineKeyboardButton{{ID: "recovery_continue", Label: "1 继续一次", Style: 1, CallbackID: "/recovery-continue " + a.ID}}
|
||||
if a.Recovery != nil && a.Recovery.CanGrantTask {
|
||||
buttons = append(buttons, InlineKeyboardButton{ID: "recovery_continue_task", Label: "2 本任务允许同类", Style: 0, CallbackID: "/recovery-continue-task " + a.ID})
|
||||
return &InlineKeyboard{Rows: []InlineKeyboardRow{{Buttons: buttons}, {Buttons: []InlineKeyboardButton{{ID: "recovery_revise", Label: "3 换个办法", Style: 0, CallbackID: "/recovery-revise " + a.ID}}}}}
|
||||
}
|
||||
buttons = append(buttons, InlineKeyboardButton{ID: "recovery_revise", Label: "2 换个办法", Style: 0, CallbackID: "/recovery-revise " + a.ID})
|
||||
return &InlineKeyboard{Rows: []InlineKeyboardRow{{Buttons: buttons}}}
|
||||
}
|
||||
|
||||
func isRecoveryApproval(a event.Approval) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(a.Kind), "recovery") || a.Recovery != nil
|
||||
}
|
||||
|
||||
func isRecoveryPlanChange(a event.Approval) bool {
|
||||
if !isRecoveryApproval(a) || a.Recovery == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(a.Recovery.ChangeKind)) {
|
||||
case "strategy", "scope":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func renderApprovalText(a event.Approval) string {
|
||||
if isRecoveryApproval(a) {
|
||||
return renderRecoveryText(a)
|
||||
}
|
||||
return fmt.Sprintf("⚠️ 需要批准操作:\n工具: %s\n操作: %s\n\nID: `%s`\n回复 1 批准,回复 2 拒绝;也可用 /approve %s 或 /deny %s。",
|
||||
a.Tool, a.Subject, a.ID, a.ID, a.ID)
|
||||
}
|
||||
|
||||
func renderRecoveryText(a event.Approval) string {
|
||||
var b strings.Builder
|
||||
if isRecoveryPlanChange(a) {
|
||||
b.WriteString("⚠️ 执行计划需要你的决定\n")
|
||||
} else {
|
||||
b.WriteString("⚠️ 执行前确认\n")
|
||||
}
|
||||
rec := a.Recovery
|
||||
if rec != nil {
|
||||
if isRecoveryPlanChange(a) && (strings.TrimSpace(rec.PlanBefore) != "" || strings.TrimSpace(rec.PlanAfter) != "") {
|
||||
if before := clipBotPlan(rec.PlanBefore); before != "" {
|
||||
fmt.Fprintf(&b, "原计划:\n%s\n", before)
|
||||
}
|
||||
if after := clipBotPlan(rec.PlanAfter); after != "" {
|
||||
fmt.Fprintf(&b, "新计划:\n%s\n", after)
|
||||
}
|
||||
} else if next := firstNonEmptyBot(rec.NextAction, a.Subject, a.Tool); next != "" {
|
||||
fmt.Fprintf(&b, "即将执行: %s\n", next)
|
||||
}
|
||||
why := firstNonEmptyBot(rec.ChangeRationale, rec.ReviewRationale, a.Reason)
|
||||
if why != "" {
|
||||
fmt.Fprintf(&b, "原因: %s\n", why)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(&b, "即将执行: %s\n", firstNonEmptyBot(a.Subject, a.Tool))
|
||||
}
|
||||
if isRecoveryPlanChange(a) {
|
||||
fmt.Fprintf(&b, "\nID: `%s`\n回复 1 采用新计划并继续,2 不采用并让 Auto 调整。需要给出具体意见时,可使用 `/recovery-revise %s <调整意见>`。", a.ID, a.ID)
|
||||
} else if rec != nil && rec.CanGrantTask {
|
||||
if scope := strings.TrimSpace(rec.TaskGrantScope); scope != "" {
|
||||
fmt.Fprintf(&b, "授权范围: %s\n", scope)
|
||||
}
|
||||
fmt.Fprintf(&b, "\nID: `%s`\n回复 1 继续一次,2 在本任务内允许同类操作,3 换个办法。范围扩大或风险升级仍会再次确认。", a.ID)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "\nID: `%s`\n回复 1 继续,2 换个办法。", a.ID)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func approvalCard(a event.Approval, chatType ChatType, userID string) *InteractiveCard {
|
||||
return &InteractiveCard{
|
||||
Header: "需要批准操作",
|
||||
Elements: []InteractiveCardElement{
|
||||
{Tag: "markdown", Content: fmt.Sprintf("**工具**: %s\n\n**操作**: %s\n\nID: `%s`", a.Tool, a.Subject, a.ID)},
|
||||
{Tag: "action", Extra: map[string]any{
|
||||
"actions": []map[string]any{
|
||||
{"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "允许一次"}, "type": "primary", "value": cardActionValue("/approve "+a.ID, chatType, userID)},
|
||||
{"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "拒绝"}, "type": "danger", "value": cardActionValue("/deny "+a.ID, chatType, userID)},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func recoveryCard(a event.Approval, chatType ChatType, userID string) *InteractiveCard {
|
||||
if isRecoveryPlanChange(a) {
|
||||
return &InteractiveCard{
|
||||
Header: "执行计划需要你的决定",
|
||||
Elements: []InteractiveCardElement{
|
||||
{Tag: "markdown", Content: renderRecoveryText(a)},
|
||||
{Tag: "action", Extra: map[string]any{
|
||||
"actions": []map[string]any{
|
||||
{"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "采用并继续"}, "type": "default", "value": cardActionValue("/recovery-continue "+a.ID, chatType, userID)},
|
||||
{"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "不采用并调整"}, "type": "default", "value": cardActionValue("/recovery-revise "+a.ID, chatType, userID)},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
actions := []map[string]any{
|
||||
{"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "继续一次"}, "type": "primary", "value": cardActionValue("/recovery-continue "+a.ID, chatType, userID)},
|
||||
}
|
||||
if a.Recovery != nil && a.Recovery.CanGrantTask {
|
||||
actions = append(actions, map[string]any{"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "本任务允许同类"}, "type": "default", "value": cardActionValue("/recovery-continue-task "+a.ID, chatType, userID)})
|
||||
}
|
||||
actions = append(actions, map[string]any{"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "换个办法"}, "type": "default", "value": cardActionValue("/recovery-revise "+a.ID, chatType, userID)})
|
||||
return &InteractiveCard{
|
||||
Header: "执行前确认",
|
||||
Elements: []InteractiveCardElement{
|
||||
{Tag: "markdown", Content: renderRecoveryText(a)},
|
||||
{Tag: "action", Extra: map[string]any{
|
||||
"actions": actions,
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func clipBotPlan(plan string) string {
|
||||
plan = strings.TrimSpace(plan)
|
||||
const maxRunes = 800
|
||||
runes := []rune(plan)
|
||||
if len(runes) <= maxRunes {
|
||||
return plan
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:maxRunes])) + "…"
|
||||
}
|
||||
|
||||
func firstNonEmptyBot(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cardActionValue(command string, chatType ChatType, userID string) map[string]string {
|
||||
value := map[string]string{
|
||||
"command": command,
|
||||
"chat_type": string(chatType),
|
||||
}
|
||||
if strings.TrimSpace(userID) != "" {
|
||||
value["user_id"] = strings.TrimSpace(userID)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func renderAskText(ask event.Ask) string {
|
||||
var qb strings.Builder
|
||||
qb.WriteString("❓ 请回答以下问题:\n")
|
||||
for i, q := range ask.Questions {
|
||||
fmt.Fprintf(&qb, "\n**%d. %s**\n", i+1, q.Prompt)
|
||||
for j, opt := range q.Options {
|
||||
fmt.Fprintf(&qb, " %d. %s", j+1, opt.Label)
|
||||
if opt.Description != "" {
|
||||
fmt.Fprintf(&qb, " — %s", opt.Description)
|
||||
}
|
||||
qb.WriteString("\n")
|
||||
}
|
||||
if q.Multi {
|
||||
qb.WriteString(" (可多选)\n")
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&qb, "\nID: `%s`", ask.ID)
|
||||
if askSupportsNumericShortcut(ask) {
|
||||
fmt.Fprintf(&qb, "\n直接回复选项编号即可回答;也可用 /answer %s <选项编号或文本>。", ask.ID)
|
||||
} else {
|
||||
fmt.Fprintf(&qb, "\n用 /answer %s <选项编号或文本> 回答;多题可用 q1=1;q2=2。", ask.ID)
|
||||
}
|
||||
return qb.String()
|
||||
}
|
||||
|
||||
func askCard(ask event.Ask, fallback string, chatType ChatType, userID string) *InteractiveCard {
|
||||
card := &InteractiveCard{
|
||||
Header: "需要回答问题",
|
||||
Elements: []InteractiveCardElement{
|
||||
{Tag: "markdown", Content: fallback},
|
||||
},
|
||||
}
|
||||
if !askSupportsNumericShortcut(ask) {
|
||||
return card
|
||||
}
|
||||
question := ask.Questions[0]
|
||||
actions := make([]map[string]any, 0, len(question.Options))
|
||||
for i, opt := range question.Options {
|
||||
label := strings.TrimSpace(opt.Label)
|
||||
if label == "" {
|
||||
label = fmt.Sprintf("选项 %d", i+1)
|
||||
}
|
||||
actions = append(actions, map[string]any{
|
||||
"tag": "button",
|
||||
"text": map[string]string{"tag": "plain_text", "content": label},
|
||||
"type": "primary",
|
||||
"value": cardActionValue(fmt.Sprintf("/answer %s %d", ask.ID, i+1), chatType, userID),
|
||||
})
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
card.Elements = append(card.Elements, InteractiveCardElement{Tag: "action", Extra: map[string]any{"actions": actions}})
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func askSupportsNumericShortcut(ask event.Ask) bool {
|
||||
return len(ask.Questions) == 1 && len(ask.Questions[0].Options) > 0
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
func TestApprovalCardCarriesChatType(t *testing.T) {
|
||||
if got := renderApprovalText(event.Approval{
|
||||
ID: "r1", Tool: "write_file", Subject: "a.go", Kind: "recovery",
|
||||
Recovery: &event.RecoveryApproval{
|
||||
FailedTool: "bash", FailedSummary: "exit 1", Diagnosis: "nil pointer",
|
||||
NextTool: "write_file", NextAction: "edit a.go", ChangeRationale: "strategy change",
|
||||
SourceAgent: "subagent",
|
||||
},
|
||||
}); !strings.Contains(got, "执行前确认") || !strings.Contains(got, "回复 1 继续,2 换个办法") || strings.Contains(got, "Auto Guard") {
|
||||
t.Fatalf("recovery text = %q", got)
|
||||
}
|
||||
|
||||
grantApproval := event.Approval{ID: "r2", Kind: "recovery", Recovery: &event.RecoveryApproval{
|
||||
CanGrantTask: true, TaskGrantScope: "git push origin → feature",
|
||||
}}
|
||||
if got := renderRecoveryText(grantApproval); !strings.Contains(got, "2 在本任务内允许同类操作") ||
|
||||
!strings.Contains(got, "风险升级仍会再次确认") || !strings.Contains(got, "授权范围: git push origin → feature") {
|
||||
t.Fatalf("task-grant recovery text = %q", got)
|
||||
}
|
||||
keyboard := recoveryKeyboard(grantApproval)
|
||||
if len(keyboard.Rows) != 2 || keyboard.Rows[0].Buttons[1].CallbackID != "/recovery-continue-task r2" {
|
||||
t.Fatalf("task-grant keyboard = %#v", keyboard)
|
||||
}
|
||||
grantCard := recoveryCard(grantApproval, ChatDM, "allowed-user")
|
||||
grantActions, ok := grantCard.Elements[1].Extra["actions"].([]map[string]any)
|
||||
if !ok || len(grantActions) != 3 {
|
||||
t.Fatalf("task-grant card actions = %#v", grantCard.Elements[1].Extra["actions"])
|
||||
}
|
||||
|
||||
planApproval := event.Approval{ID: "r3", Kind: "recovery", Recovery: &event.RecoveryApproval{
|
||||
ChangeKind: "strategy", NextAction: "replace the storage backend", ChangeRationale: "the original approach cannot satisfy the requirement",
|
||||
PlanBefore: "1. Keep the current storage backend", PlanAfter: "1. Replace the storage backend",
|
||||
}}
|
||||
if got := renderRecoveryText(planApproval); !strings.Contains(got, "执行计划需要你的决定") ||
|
||||
!strings.Contains(got, "原计划:\n1. Keep the current storage backend") ||
|
||||
!strings.Contains(got, "新计划:\n1. Replace the storage backend") ||
|
||||
!strings.Contains(got, "回复 1 采用新计划并继续,2 不采用并让 Auto 调整") {
|
||||
t.Fatalf("plan-change recovery text = %q", got)
|
||||
}
|
||||
planKeyboard := recoveryKeyboard(planApproval)
|
||||
if len(planKeyboard.Rows) != 1 || planKeyboard.Rows[0].Buttons[0].Label != "1 采用并继续" || planKeyboard.Rows[0].Buttons[0].Style != 0 {
|
||||
t.Fatalf("plan-change keyboard = %#v", planKeyboard)
|
||||
}
|
||||
planCard := recoveryCard(planApproval, ChatDM, "allowed-user")
|
||||
if planCard.Header != "执行计划需要你的决定" {
|
||||
t.Fatalf("plan-change card header = %q", planCard.Header)
|
||||
}
|
||||
|
||||
card := approvalCard(event.Approval{ID: "approval-1", Tool: "bash", Subject: "ls"}, ChatDM, "allowed-user")
|
||||
if len(card.Elements) < 2 {
|
||||
t.Fatalf("approval card elements = %d, want at least 2", len(card.Elements))
|
||||
}
|
||||
actions, ok := card.Elements[1].Extra["actions"].([]map[string]any)
|
||||
if !ok || len(actions) == 0 {
|
||||
t.Fatalf("approval card actions missing or wrong type: %#v", card.Elements[1].Extra["actions"])
|
||||
}
|
||||
value, ok := actions[0]["value"].(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("approval action value has wrong type: %#v", actions[0]["value"])
|
||||
}
|
||||
if value["command"] != "/approve approval-1" {
|
||||
t.Fatalf("command = %q, want /approve approval-1", value["command"])
|
||||
}
|
||||
if value["chat_type"] != string(ChatDM) {
|
||||
t.Fatalf("chat_type = %q, want %q", value["chat_type"], ChatDM)
|
||||
}
|
||||
if value["user_id"] != "allowed-user" {
|
||||
t.Fatalf("user_id = %q, want allowed-user", value["user_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalCardActionsAreToolAgnostic(t *testing.T) {
|
||||
for _, approval := range []event.Approval{
|
||||
{ID: "plan-1", Tool: "exit_plan_mode", Subject: "plan"},
|
||||
{ID: "task-1", Tool: "task", Subject: "run subtask"},
|
||||
} {
|
||||
card := approvalCard(approval, ChatGroup, "allowed-user")
|
||||
if len(card.Elements) < 2 {
|
||||
t.Fatalf("%s card elements = %d, want actions", approval.Tool, len(card.Elements))
|
||||
}
|
||||
actions, ok := card.Elements[1].Extra["actions"].([]map[string]any)
|
||||
if !ok || len(actions) != 2 {
|
||||
t.Fatalf("%s actions missing or wrong type: %#v", approval.Tool, card.Elements[1].Extra["actions"])
|
||||
}
|
||||
allow, ok := actions[0]["value"].(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("%s allow value has wrong type: %#v", approval.Tool, actions[0]["value"])
|
||||
}
|
||||
deny, ok := actions[1]["value"].(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("%s deny value has wrong type: %#v", approval.Tool, actions[1]["value"])
|
||||
}
|
||||
if allow["command"] != "/approve "+approval.ID || deny["command"] != "/deny "+approval.ID {
|
||||
t.Fatalf("%s commands = %q/%q, want approve/deny by id", approval.Tool, allow["command"], deny["command"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskCardAddsAnswerButtonsForSingleChoice(t *testing.T) {
|
||||
card := askCard(event.Ask{
|
||||
ID: "ask-1",
|
||||
Questions: []event.AskQuestion{{
|
||||
ID: "q1",
|
||||
Prompt: "Choose one",
|
||||
Options: []event.AskOption{
|
||||
{Label: "允许一次"},
|
||||
{Label: "拒绝"},
|
||||
},
|
||||
}},
|
||||
}, "fallback", ChatDM, "allowed-user")
|
||||
|
||||
if len(card.Elements) != 2 {
|
||||
t.Fatalf("ask card elements = %d, want markdown + actions", len(card.Elements))
|
||||
}
|
||||
actions, ok := card.Elements[1].Extra["actions"].([]map[string]any)
|
||||
if !ok || len(actions) != 2 {
|
||||
t.Fatalf("ask card actions missing or wrong type: %#v", card.Elements[1].Extra["actions"])
|
||||
}
|
||||
value, ok := actions[0]["value"].(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("ask action value has wrong type: %#v", actions[0]["value"])
|
||||
}
|
||||
if value["command"] != "/answer ask-1 1" {
|
||||
t.Fatalf("command = %q, want /answer ask-1 1", value["command"])
|
||||
}
|
||||
if value["chat_type"] != string(ChatDM) {
|
||||
t.Fatalf("chat_type = %q, want %q", value["chat_type"], ChatDM)
|
||||
}
|
||||
if value["user_id"] != "allowed-user" {
|
||||
t.Fatalf("user_id = %q, want allowed-user", value["user_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSinkDoesNotFlushMidSentenceOnTimer(t *testing.T) {
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
sink.lastFlush = time.Now().Add(-2 * time.Second)
|
||||
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "我是 **"})
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "Reasonix**,一个专注于执行代码任务的 AI 编程助手"})
|
||||
|
||||
if sent := adapter.sentMessages(); len(sent) != 0 {
|
||||
t.Fatalf("sent = %+v, want no mid-sentence flush", sent)
|
||||
}
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
sent := adapter.sentMessages()
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("sent count = %d, want final flush only", len(sent))
|
||||
}
|
||||
if sent[0].Text != "我是 **Reasonix**,一个专注于执行代码任务的 AI 编程助手" {
|
||||
t.Fatalf("sent text = %q, want combined sentence", sent[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSinkKeepsSemanticTextUntilFinalResult(t *testing.T) {
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
sink.lastFlush = time.Now().Add(-2 * time.Second)
|
||||
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "第一句。"})
|
||||
|
||||
if sent := adapter.sentMessages(); len(sent) != 0 {
|
||||
t.Fatalf("sent = %+v, want semantic text held until final result", sent)
|
||||
}
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
sent := adapter.sentMessages()
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("sent count = %d, want final result only", len(sent))
|
||||
}
|
||||
if sent[0].Text != "第一句。" {
|
||||
t.Fatalf("sent text = %q, want final result", sent[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSinkFinalFlushKeepsChunkLimit(t *testing.T) {
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
sink.buf.WriteString(strings.Repeat("长", renderMaxChunkRunes*2+10))
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
sent := adapter.sentMessages()
|
||||
if len(sent) < 2 {
|
||||
t.Fatalf("sent count = %d, want chunked final flush", len(sent))
|
||||
}
|
||||
for i, msg := range sent {
|
||||
if got := len([]rune(msg.Text)); got > renderMaxChunkRunes {
|
||||
t.Fatalf("sent[%d] runes = %d, want <= %d", i, got, renderMaxChunkRunes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSinkConsumesEmptyWhitespacePrefix(t *testing.T) {
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
sink.buf.WriteString("\n工具状态")
|
||||
|
||||
sink.flushPrefix(1)
|
||||
|
||||
if got := sink.buf.String(); got != "工具状态" {
|
||||
t.Fatalf("buffer = %q, want leading newline consumed", got)
|
||||
}
|
||||
if sent := adapter.sentMessages(); len(sent) != 0 {
|
||||
t.Fatalf("sent = %+v, want no empty outbound message", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSinkSendsProgressWithoutToolOutput(t *testing.T) {
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnStarted})
|
||||
sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "tool-1", Name: "read_file", ReadOnly: true}})
|
||||
sink.lastProgress = time.Now().Add(-renderProgressMinInterval)
|
||||
sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "tool-1", Name: "read_file", ReadOnly: true, Refreshed: true}})
|
||||
sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "tool-1", Name: "read_file", Output: "secret output that should stay out of IM"}})
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "完成。"})
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
sent := adapter.sentMessages()
|
||||
if len(sent) != 2 {
|
||||
t.Fatalf("sent count = %d, want one progress message plus final result: %+v", len(sent), sent)
|
||||
}
|
||||
if sent[0].Text != "正在执行: read_file" {
|
||||
t.Fatalf("progress text = %q, want concise tool status", sent[0].Text)
|
||||
}
|
||||
if strings.Contains(sent[0].Text, "secret output") || strings.Contains(sent[1].Text, "secret output") {
|
||||
t.Fatalf("tool output leaked into IM messages: %+v", sent)
|
||||
}
|
||||
if sent[1].Text != "完成。" {
|
||||
t.Fatalf("final text = %q, want final result only", sent[1].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSinkLimitsProgressMessages(t *testing.T) {
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
|
||||
for range renderMaxProgressMessages + 2 {
|
||||
sink.lastProgress = time.Now().Add(-renderProgressMinInterval)
|
||||
sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "tool", Name: "bash"}})
|
||||
}
|
||||
|
||||
sent := adapter.sentMessages()
|
||||
if len(sent) != renderMaxProgressMessages {
|
||||
t.Fatalf("sent count = %d, want capped progress count %d", len(sent), renderMaxProgressMessages)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEditorAdapter struct {
|
||||
*fakeAdapter
|
||||
mu sync.Mutex
|
||||
edits []editRecord
|
||||
editErr error
|
||||
}
|
||||
|
||||
type editRecord struct {
|
||||
messageID string
|
||||
text string
|
||||
}
|
||||
|
||||
func newFakeEditorAdapter() *fakeEditorAdapter {
|
||||
return &fakeEditorAdapter{fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu")}
|
||||
}
|
||||
|
||||
func (f *fakeEditorAdapter) EditMessage(ctx context.Context, messageID string, msg OutboundMessage) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.editErr != nil {
|
||||
return f.editErr
|
||||
}
|
||||
f.edits = append(f.edits, editRecord{messageID: messageID, text: msg.Text})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeEditorAdapter) editRecords() []editRecord {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]editRecord, len(f.edits))
|
||||
copy(out, f.edits)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRenderSinkStreamsIntoLiveMessage(t *testing.T) {
|
||||
adapter := newFakeEditorAdapter()
|
||||
sink := newRenderSink(context.Background(), adapter, "feishu-feishu", "feishu", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
|
||||
// 第一个增量:超过软窗口后创建 live 消息。
|
||||
sink.lastFlush = time.Now().Add(-2 * renderSoftFlushAfter)
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "第一段内容"})
|
||||
if sent := adapter.sentMessages(); len(sent) != 1 || sent[0].Text != "第一段内容" {
|
||||
t.Fatalf("sent = %+v, want live message created with first chunk", sent)
|
||||
}
|
||||
if sink.liveMsgID != "fake_msg_1" {
|
||||
t.Fatalf("liveMsgID = %q, want fake_msg_1", sink.liveMsgID)
|
||||
}
|
||||
|
||||
// 第二个增量:原地编辑同一条消息,而不是再发一条。
|
||||
sink.lastEdit = time.Now().Add(-2 * renderSoftFlushAfter)
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: ",第二段内容。"})
|
||||
if sent := adapter.sentMessages(); len(sent) != 1 {
|
||||
t.Fatalf("sent count = %d, want still one message after streaming edit", len(sent))
|
||||
}
|
||||
edits := adapter.editRecords()
|
||||
if len(edits) != 1 || edits[0].messageID != "fake_msg_1" {
|
||||
t.Fatalf("edits = %+v, want one edit to live message", edits)
|
||||
}
|
||||
if edits[0].text != "第一段内容,第二段内容。" {
|
||||
t.Fatalf("edit text = %q, want cumulative content", edits[0].text)
|
||||
}
|
||||
|
||||
// 回合结束:最终内容编辑进 live 消息,不再新发。
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "收尾。"})
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
if sent := adapter.sentMessages(); len(sent) != 1 {
|
||||
t.Fatalf("sent count = %d, want no extra message at turn end", len(sent))
|
||||
}
|
||||
edits = adapter.editRecords()
|
||||
final := edits[len(edits)-1]
|
||||
if final.text != "第一段内容,第二段内容。收尾。" {
|
||||
t.Fatalf("final edit = %q, want full content", final.text)
|
||||
}
|
||||
if sink.liveMsgID != "" {
|
||||
t.Fatalf("liveMsgID = %q, want cleared after turn done", sink.liveMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSinkStreamingThrottledBySoftWindow(t *testing.T) {
|
||||
adapter := newFakeEditorAdapter()
|
||||
sink := newRenderSink(context.Background(), adapter, "feishu-feishu", "feishu", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
|
||||
// 软窗口内的增量不触发任何网络调用。
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "刚开始的内容"})
|
||||
if sent := adapter.sentMessages(); len(sent) != 0 {
|
||||
t.Fatalf("sent = %+v, want throttled inside soft window", sent)
|
||||
}
|
||||
if edits := adapter.editRecords(); len(edits) != 0 {
|
||||
t.Fatalf("edits = %+v, want none inside soft window", edits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSinkStreamingEditFailureRotatesWithoutDuplication(t *testing.T) {
|
||||
adapter := newFakeEditorAdapter()
|
||||
sink := newRenderSink(context.Background(), adapter, "feishu-feishu", "feishu", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
|
||||
sink.lastFlush = time.Now().Add(-2 * renderSoftFlushAfter)
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "已送达的内容。"})
|
||||
if sink.liveMsgID == "" {
|
||||
t.Fatal("live message should be created")
|
||||
}
|
||||
|
||||
// 编辑失败:块轮转,已送达前缀不重发。
|
||||
adapter.mu.Lock()
|
||||
adapter.editErr = context.DeadlineExceeded
|
||||
adapter.mu.Unlock()
|
||||
sink.lastEdit = time.Now().Add(-2 * renderSoftFlushAfter)
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "后续内容。"})
|
||||
if sink.liveMsgID != "" {
|
||||
t.Fatalf("liveMsgID = %q, want rotation after edit failure", sink.liveMsgID)
|
||||
}
|
||||
|
||||
adapter.mu.Lock()
|
||||
adapter.editErr = nil
|
||||
adapter.mu.Unlock()
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
sent := adapter.sentMessages()
|
||||
if len(sent) != 2 {
|
||||
t.Fatalf("sent count = %d, want live message plus rotated tail: %+v", len(sent), sent)
|
||||
}
|
||||
if sent[1].Text != "后续内容。" {
|
||||
t.Fatalf("rotated tail = %q, want only undelivered content", sent[1].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSinkStreamingHardCapRotatesBlocks(t *testing.T) {
|
||||
adapter := newFakeEditorAdapter()
|
||||
sink := newRenderSink(context.Background(), adapter, "feishu-feishu", "feishu", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
|
||||
sink.lastFlush = time.Now().Add(-2 * renderSoftFlushAfter)
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "第一句。"})
|
||||
if sink.liveMsgID == "" {
|
||||
t.Fatal("live message should be created")
|
||||
}
|
||||
|
||||
// 超过硬上限:live 消息按语义边界收尾,剩余进入下一块。
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("长", renderHardChunkRunes) + "。尾部"})
|
||||
if sink.liveMsgID != "" {
|
||||
t.Fatalf("liveMsgID = %q, want block closed at hard cap", sink.liveMsgID)
|
||||
}
|
||||
edits := adapter.editRecords()
|
||||
if len(edits) == 0 {
|
||||
t.Fatal("hard cap should finalize the live message via edit")
|
||||
}
|
||||
if got := len([]rune(edits[len(edits)-1].text)); got > renderMaxChunkRunes {
|
||||
t.Fatalf("finalized block runes = %d, want <= %d", got, renderMaxChunkRunes)
|
||||
}
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
sent := adapter.sentMessages()
|
||||
if len(sent) < 2 {
|
||||
t.Fatalf("sent count = %d, want new message for the next block", len(sent))
|
||||
}
|
||||
}
|
||||
|
||||
// failingEditorAdapter accepts the initial Send (returns a message id so
|
||||
// streaming engages) but fails every edit, simulating a rate-limited / recalled
|
||||
// live message mid-turn.
|
||||
type failingEditorAdapter struct {
|
||||
*fakeAdapter
|
||||
}
|
||||
|
||||
func (f *failingEditorAdapter) EditMessage(ctx context.Context, id string, msg OutboundMessage) error {
|
||||
return fmt.Errorf("simulated edit failure")
|
||||
}
|
||||
|
||||
func TestRenderSinkStreamingEditFailureDoesNotDuplicate(t *testing.T) {
|
||||
adapter := &failingEditorAdapter{fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu")}
|
||||
sink := newRenderSink(context.Background(), adapter, "feishu-feishu", "feishu", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
|
||||
// Stream a first chunk so a live message is created (liveSentBytes == full).
|
||||
sink.lastFlush = time.Now().Add(-2 * renderSoftFlushAfter)
|
||||
head := strings.Repeat("a", 2000)
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: head})
|
||||
if sink.liveMsgID == "" {
|
||||
t.Fatalf("streaming did not engage; sent=%d", len(adapter.sentMessages()))
|
||||
}
|
||||
// Push past the hard cap so flushPrefix runs with idx < liveSentBytes, then
|
||||
// finish. Every edit fails, so rotation must not re-queue already-shown text.
|
||||
tail := strings.Repeat("b", renderHardChunkRunes)
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: tail})
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
var shown strings.Builder
|
||||
shown.WriteString(head) // live message frozen at last successful state
|
||||
for _, m := range adapter.sentMessages()[1:] {
|
||||
shown.WriteString(m.Text)
|
||||
}
|
||||
wantRunes := len([]rune(head + tail))
|
||||
if gotRunes := len([]rune(shown.String())); gotRunes > wantRunes {
|
||||
t.Fatalf("duplication: user would see %d runes, expected at most %d (%d duplicated)", gotRunes, wantRunes, gotRunes-wantRunes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSinkStreamingFinalizesInOneEditWithoutSplit(t *testing.T) {
|
||||
adapter := newFakeEditorAdapter()
|
||||
sink := newRenderSink(context.Background(), adapter, "feishu-feishu", "feishu", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
|
||||
// A final answer that does NOT end on a semantic boundary (ends inside a
|
||||
// code fence) must be finalized as a single in-place edit, not shrunk +
|
||||
// tail-as-new-message.
|
||||
sink.lastFlush = time.Now().Add(-2 * renderSoftFlushAfter)
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "结论如下。这里是代码:\n```go\nfmt.Println(\"x\")\n```"})
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
if sent := adapter.sentMessages(); len(sent) != 1 {
|
||||
t.Fatalf("sent %d messages, want exactly one live message (no split): %+v", len(sent), sent)
|
||||
}
|
||||
edits := adapter.editRecords()
|
||||
if len(edits) == 0 || !strings.Contains(edits[len(edits)-1].text, "```") {
|
||||
t.Fatalf("final edit should carry the full text including the code fence: %+v", edits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSinkSuppressesReasoning(t *testing.T) {
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
|
||||
sink.Emit(event.Event{Kind: event.Reasoning, Text: "internal reasoning"})
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "可见结果"})
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
sent := adapter.sentMessages()
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("sent count = %d, want one final result", len(sent))
|
||||
}
|
||||
if strings.Contains(sent[0].Text, "internal reasoning") {
|
||||
t.Fatalf("reasoning leaked into IM message: %q", sent[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSinkSuppressesOperatorNoticesWithoutHidingUserWarnings(t *testing.T) {
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "please resend your message"})
|
||||
for _, code := range []string{
|
||||
event.NoticeCodeSessionRecoveryForked,
|
||||
event.NoticeCodeSessionRecoveryAdopted,
|
||||
event.NoticeCodeSessionRecoveryAdoptedCovered,
|
||||
event.NoticeCodeSessionRecoveryDepthCap,
|
||||
event.NoticeCodeSessionShutdownRecoveryForked,
|
||||
} {
|
||||
sink.Emit(event.Event{
|
||||
Kind: event.Notice, Level: event.LevelWarn,
|
||||
Audience: event.NoticeAudienceOperator,
|
||||
Code: code,
|
||||
Text: "local session maintenance",
|
||||
})
|
||||
}
|
||||
|
||||
sent := adapter.sentMessages()
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("sent = %+v, want only the actionable user warning", sent)
|
||||
}
|
||||
if sent[0].Text != "⚠️ please resend your message" {
|
||||
t.Fatalf("sent text = %q, want the ordinary user warning", sent[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSinkIgnoresSubagentProgress locks the bot policy for the reserved
|
||||
// sub-agent progress ToolProgress channels: streaming previews must never leak
|
||||
// into IM channels, exactly like ordinary tool output.
|
||||
func TestRenderSinkIgnoresSubagentProgress(t *testing.T) {
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnStarted})
|
||||
sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "task-1", Name: "task"}})
|
||||
sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
|
||||
ID: "task-1", Name: event.SubagentProgressStatusName, Output: "reasoning",
|
||||
}})
|
||||
sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
|
||||
ID: "task-1", Name: event.SubagentProgressReasoningName, Output: "thinking out loud",
|
||||
}})
|
||||
sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
|
||||
ID: "task-1", Name: event.SubagentProgressTextName, Output: "answer preview",
|
||||
}})
|
||||
sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "task-1", Name: "task", Output: "final"}})
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
sent := adapter.sentMessages()
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("sent count = %d, want only the dispatch status: %+v", len(sent), sent)
|
||||
}
|
||||
for i, m := range sent {
|
||||
if strings.Contains(m.Text, "thinking out loud") || strings.Contains(m.Text, "answer preview") {
|
||||
t.Fatalf("sub-agent preview leaked into IM message %d: %+v", i, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A cancelled turn is the user stopping, not a failure to report. This used to
|
||||
// be decided by looking for "context canceled" in the message, which a wrapped
|
||||
// or reworded error would have slipped past — and the identity only survives
|
||||
// because the event carries the error itself rather than its text.
|
||||
func TestRenderSinkStaysQuietOnACancelledTurn(t *testing.T) {
|
||||
for name, err := range map[string]error{
|
||||
"the sentinel": context.Canceled,
|
||||
"wrapped": fmt.Errorf("run turn: %w", context.Canceled),
|
||||
"wrapped further": fmt.Errorf("gateway: %w", fmt.Errorf("run turn: %w", context.Canceled)),
|
||||
} {
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
sink.Emit(event.Event{Kind: event.TurnDone, Err: err})
|
||||
if sent := adapter.sentMessages(); len(sent) != 0 {
|
||||
t.Errorf("%s: sent %+v, want silence", name, sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anything else still reaches the reader; going quiet on every error would be
|
||||
// the opposite failure.
|
||||
func TestRenderSinkStillReportsARealFailure(t *testing.T) {
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||||
sink.Emit(event.Event{Kind: event.TurnDone, Err: errors.New("disk went away")})
|
||||
if sent := adapter.sentMessages(); len(sent) == 0 {
|
||||
t.Fatal("a real failure was swallowed")
|
||||
}
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
QueueModeSteer = "steer"
|
||||
QueueModeFollowup = "followup"
|
||||
QueueModeCollect = "collect"
|
||||
QueueModeInterrupt = "interrupt"
|
||||
|
||||
QueueDropSummarize = "summarize"
|
||||
QueueDropOld = "old"
|
||||
QueueDropNew = "new"
|
||||
|
||||
DefaultQueueCap = 20
|
||||
)
|
||||
|
||||
type QueueOptions struct {
|
||||
Mode string
|
||||
Cap int
|
||||
Drop string
|
||||
}
|
||||
|
||||
type QueueResult struct {
|
||||
Acquired bool
|
||||
Queued bool
|
||||
Rejected bool
|
||||
Dropped bool
|
||||
Pending int
|
||||
Mode string
|
||||
}
|
||||
|
||||
type QueueSnapshot struct {
|
||||
Active int
|
||||
Pending int
|
||||
Dropped int
|
||||
Sessions int
|
||||
}
|
||||
|
||||
func NormalizeQueueMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case QueueModeSteer:
|
||||
return QueueModeSteer
|
||||
case QueueModeFollowup:
|
||||
return QueueModeFollowup
|
||||
case QueueModeCollect:
|
||||
return QueueModeCollect
|
||||
case QueueModeInterrupt:
|
||||
return QueueModeInterrupt
|
||||
default:
|
||||
return QueueModeSteer
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeOptionalQueueMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case QueueModeSteer:
|
||||
return QueueModeSteer
|
||||
case QueueModeFollowup:
|
||||
return QueueModeFollowup
|
||||
case QueueModeCollect:
|
||||
return QueueModeCollect
|
||||
case QueueModeInterrupt:
|
||||
return QueueModeInterrupt
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeQueueDrop(drop string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(drop)) {
|
||||
case QueueDropOld:
|
||||
return QueueDropOld
|
||||
case QueueDropNew:
|
||||
return QueueDropNew
|
||||
default:
|
||||
return QueueDropSummarize
|
||||
}
|
||||
}
|
||||
|
||||
// BuildSessionKey 根据 Hermes 模式生成稳定的 session key:
|
||||
// - DM:按 chat 隔离(同一 DM 会话共享历史)
|
||||
// - 群聊:按 user 隔离(每人独立会话)
|
||||
// - thread:共享(thread 内所有人共享上下文)
|
||||
func BuildSessionKey(src SessionSource) string {
|
||||
var scope string
|
||||
source := sessionSourceID(src)
|
||||
switch src.ChatType {
|
||||
case ChatDM:
|
||||
scope = fmt.Sprintf("%s:dm:%s", source, src.ChatID)
|
||||
case ChatGroup:
|
||||
scope = fmt.Sprintf("%s:group:%s:%s", source, src.ChatID, src.UserID)
|
||||
case ChatGuild:
|
||||
scope = fmt.Sprintf("%s:guild:%s:%s", source, src.ChatID, src.UserID)
|
||||
case ChatDirect:
|
||||
scope = fmt.Sprintf("%s:direct:%s", source, src.ChatID)
|
||||
case ChatThread:
|
||||
threadID := src.ThreadID
|
||||
if threadID == "" {
|
||||
threadID = src.ChatID
|
||||
}
|
||||
scope = fmt.Sprintf("%s:thread:%s", source, threadID)
|
||||
default:
|
||||
scope = fmt.Sprintf("%s:%s:%s:%s", source, src.ChatType, src.ChatID, src.UserID)
|
||||
}
|
||||
h := sha256.Sum256([]byte(scope))
|
||||
return hex.EncodeToString(h[:])[:16]
|
||||
}
|
||||
|
||||
func sessionSourceID(src SessionSource) string {
|
||||
if src.ConnectionID != "" {
|
||||
return src.ConnectionID
|
||||
}
|
||||
if src.Domain != "" {
|
||||
return fmt.Sprintf("%s:%s", src.Platform, src.Domain)
|
||||
}
|
||||
return string(src.Platform)
|
||||
}
|
||||
|
||||
// slashCommands 是绕过忙碌队列的命令集合。
|
||||
var slashCommands = map[string]bool{
|
||||
"/stop": true,
|
||||
"/new": true,
|
||||
"/reset": true,
|
||||
"/approve": true,
|
||||
"/deny": true,
|
||||
"/answer": true,
|
||||
"/yolo": true,
|
||||
"/mode": true,
|
||||
"/queue": true,
|
||||
"/projects": true,
|
||||
"/use": true,
|
||||
"/sessions": true,
|
||||
"/attach": true,
|
||||
"/search": true,
|
||||
"/desktop": true,
|
||||
"/status": true,
|
||||
"/help": true,
|
||||
}
|
||||
|
||||
// IsSlashBypass 判断消息是否为绕过队列的斜杠命令。
|
||||
func IsSlashBypass(text string) bool {
|
||||
if len(text) == 0 {
|
||||
return false
|
||||
}
|
||||
cmd := text
|
||||
for i, r := range text {
|
||||
if r == ' ' {
|
||||
cmd = text[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
return slashCommands[cmd]
|
||||
}
|
||||
|
||||
// pendingTurn 是等待执行的一轮对话。
|
||||
type pendingTurn struct {
|
||||
msg InboundMessage
|
||||
timestamp time.Time
|
||||
mode string
|
||||
}
|
||||
|
||||
// SessionManager 管理 session 级别的并发控制:同一 session 同时只跑一个任务。
|
||||
type SessionManager struct {
|
||||
mu sync.Mutex
|
||||
active map[string]bool // session key -> 是否正在运行
|
||||
pending map[string][]pendingTurn // session key -> 等待队列
|
||||
debounce time.Duration
|
||||
modeOverrides map[string]string
|
||||
dropped map[string][]string
|
||||
}
|
||||
|
||||
// NewSessionManager 创建一个新的 session 管理器。debounce 是消息合并窗口。
|
||||
func NewSessionManager(debounce time.Duration) *SessionManager {
|
||||
if debounce <= 0 {
|
||||
debounce = 1500 * time.Millisecond
|
||||
}
|
||||
return &SessionManager{
|
||||
active: make(map[string]bool),
|
||||
pending: make(map[string][]pendingTurn),
|
||||
debounce: debounce,
|
||||
modeOverrides: make(map[string]string),
|
||||
dropped: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
// TryAcquire 尝试获取 session 锁。如果 session 正忙且消息非绕过命令,返回 false。
|
||||
// 返回 (acquired, merged) — merged 为 true 表示消息已合并到等待队列。
|
||||
func (sm *SessionManager) TryAcquire(key string, msg InboundMessage) (acquired bool, merged bool) {
|
||||
result := sm.TryAcquireWithQueue(key, msg, QueueOptions{Mode: QueueModeCollect, Cap: DefaultQueueCap, Drop: QueueDropSummarize})
|
||||
return result.Acquired, result.Queued
|
||||
}
|
||||
|
||||
func (sm *SessionManager) TryAcquireWithQueue(key string, msg InboundMessage, opts QueueOptions) QueueResult {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
mode := NormalizeQueueMode(opts.Mode)
|
||||
if mode == QueueModeSteer || mode == QueueModeInterrupt {
|
||||
mode = QueueModeFollowup
|
||||
}
|
||||
cap := opts.Cap
|
||||
if cap <= 0 {
|
||||
cap = DefaultQueueCap
|
||||
}
|
||||
drop := NormalizeQueueDrop(opts.Drop)
|
||||
|
||||
if sm.active[key] {
|
||||
// 绕过命令立即返回 true(让调用方直接处理)
|
||||
if IsSlashBypass(msg.Text) {
|
||||
return QueueResult{Acquired: true, Mode: mode}
|
||||
}
|
||||
queue := sm.pending[key]
|
||||
if len(queue) >= cap {
|
||||
switch drop {
|
||||
case QueueDropNew:
|
||||
return QueueResult{Rejected: true, Pending: len(queue), Mode: mode}
|
||||
case QueueDropOld, QueueDropSummarize:
|
||||
removed := queue[0]
|
||||
queue = queue[1:]
|
||||
if drop == QueueDropSummarize {
|
||||
sm.dropped[key] = append(sm.dropped[key], queueSummary(removed.msg.Text))
|
||||
}
|
||||
}
|
||||
}
|
||||
if mode == QueueModeCollect && len(queue) > 0 {
|
||||
last := &queue[len(queue)-1]
|
||||
if msg.Text != "" && time.Since(last.timestamp) < sm.debounce {
|
||||
if last.msg.Text != "" {
|
||||
last.msg.Text = last.msg.Text + "\n" + msg.Text
|
||||
} else {
|
||||
last.msg.Text = msg.Text
|
||||
}
|
||||
last.timestamp = time.Now()
|
||||
last.mode = mode
|
||||
sm.pending[key] = queue
|
||||
return QueueResult{Queued: true, Dropped: len(sm.dropped[key]) > 0, Pending: len(queue), Mode: mode}
|
||||
}
|
||||
}
|
||||
queue = append(queue, pendingTurn{msg: msg, timestamp: time.Now(), mode: mode})
|
||||
sm.pending[key] = queue
|
||||
return QueueResult{Queued: true, Dropped: len(sm.dropped[key]) > 0, Pending: len(queue), Mode: mode}
|
||||
}
|
||||
|
||||
sm.active[key] = true
|
||||
return QueueResult{Acquired: true, Mode: mode}
|
||||
}
|
||||
|
||||
// TryAcquireIdle acquires an idle session without enqueueing when another
|
||||
// goroutine wins the race. Durable inbox items already live on disk, so adding
|
||||
// a second in-memory copy on a failed claim would execute them twice.
|
||||
func (sm *SessionManager) TryAcquireIdle(key string) bool {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
if sm.active[key] {
|
||||
return false
|
||||
}
|
||||
sm.active[key] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (sm *SessionManager) ReplacePending(key string, msg InboundMessage) QueueResult {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
if !sm.active[key] {
|
||||
sm.active[key] = true
|
||||
return QueueResult{Acquired: true, Mode: QueueModeInterrupt}
|
||||
}
|
||||
sm.pending[key] = []pendingTurn{{msg: msg, timestamp: time.Now(), mode: QueueModeFollowup}}
|
||||
delete(sm.dropped, key)
|
||||
return QueueResult{Queued: true, Pending: 1, Mode: QueueModeInterrupt}
|
||||
}
|
||||
|
||||
// Release 释放 session 锁,返回等待队列中的下一条消息(合并后)。
|
||||
func (sm *SessionManager) Release(key string) *InboundMessage {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
queue := sm.pending[key]
|
||||
if len(queue) == 0 {
|
||||
delete(sm.active, key)
|
||||
delete(sm.pending, key)
|
||||
delete(sm.dropped, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
mode := NormalizeQueueMode(queue[0].mode)
|
||||
var merged *InboundMessage
|
||||
if mode == QueueModeFollowup {
|
||||
m := queue[0].msg
|
||||
merged = &m
|
||||
if len(queue) == 1 {
|
||||
delete(sm.pending, key)
|
||||
} else {
|
||||
sm.pending[key] = queue[1:]
|
||||
}
|
||||
merged.Text = sm.consumeDroppedPrefixLocked(key, merged.Text)
|
||||
return merged
|
||||
}
|
||||
|
||||
// collect 模式取出等待队列,并合并其中所有消息。
|
||||
for i := range queue {
|
||||
if merged == nil {
|
||||
m := queue[i].msg
|
||||
merged = &m
|
||||
} else {
|
||||
if queue[i].msg.Text != "" {
|
||||
merged.Text = merged.Text + "\n" + queue[i].msg.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(sm.pending, key)
|
||||
merged.Text = sm.consumeDroppedPrefixLocked(key, merged.Text)
|
||||
// active 保持 true,因为调用方会立即用 merged 消息开始新 turn
|
||||
return merged
|
||||
}
|
||||
|
||||
func (sm *SessionManager) consumeDroppedPrefixLocked(key, text string) string {
|
||||
dropped := sm.dropped[key]
|
||||
if len(dropped) == 0 {
|
||||
return text
|
||||
}
|
||||
delete(sm.dropped, key)
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[Queue note: %d older pending message(s) were dropped because this bot session reached its queue cap.", len(dropped))
|
||||
if len(dropped) > 0 {
|
||||
b.WriteString(" Dropped summaries:")
|
||||
limit := min(len(dropped), 3)
|
||||
for i := range limit {
|
||||
fmt.Fprintf(&b, "\n- %s", dropped[i])
|
||||
}
|
||||
if len(dropped) > limit {
|
||||
fmt.Fprintf(&b, "\n- ... and %d more", len(dropped)-limit)
|
||||
}
|
||||
}
|
||||
b.WriteString("]\n\n")
|
||||
b.WriteString(text)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func queueSummary(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return "(empty message)"
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) <= 180 {
|
||||
return text
|
||||
}
|
||||
return string(runes[:180]) + "..."
|
||||
}
|
||||
|
||||
// IsActive 返回 session 是否有正在运行的任务。
|
||||
func (sm *SessionManager) IsActive(key string) bool {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
return sm.active[key]
|
||||
}
|
||||
|
||||
// Debounce returns the collect-mode merge window.
|
||||
func (sm *SessionManager) Debounce() time.Duration {
|
||||
if sm == nil {
|
||||
return 1500 * time.Millisecond
|
||||
}
|
||||
return sm.debounce
|
||||
}
|
||||
|
||||
// runIfIdle holds the per-gateway admission lock while fn switches runtime
|
||||
// ownership for key. A normal message cannot become active between the idle
|
||||
// check and the controller unlink/close sequence.
|
||||
func (sm *SessionManager) runIfIdle(key string, fn func() bool) bool {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
if sm.active[key] || fn == nil {
|
||||
return false
|
||||
}
|
||||
if !fn() {
|
||||
return false
|
||||
}
|
||||
delete(sm.pending, key)
|
||||
delete(sm.dropped, key)
|
||||
return true
|
||||
}
|
||||
|
||||
// ActiveCount 返回当前活跃 session 数。
|
||||
func (sm *SessionManager) ActiveCount() int {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
return len(sm.active)
|
||||
}
|
||||
|
||||
func (sm *SessionManager) PendingCount(key string) int {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
return len(sm.pending[key])
|
||||
}
|
||||
|
||||
func (sm *SessionManager) Snapshot() QueueSnapshot {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
var pending int
|
||||
var dropped int
|
||||
for _, queue := range sm.pending {
|
||||
pending += len(queue)
|
||||
}
|
||||
for _, summaries := range sm.dropped {
|
||||
dropped += len(summaries)
|
||||
}
|
||||
return QueueSnapshot{
|
||||
Active: len(sm.active),
|
||||
Pending: pending,
|
||||
Dropped: dropped,
|
||||
Sessions: len(sm.active) + len(sm.pending),
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SessionManager) QueueMode(key, fallback string) string {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
if mode := sm.modeOverrides[key]; mode != "" {
|
||||
return mode
|
||||
}
|
||||
return NormalizeQueueMode(fallback)
|
||||
}
|
||||
|
||||
func (sm *SessionManager) SetQueueMode(key, mode string) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
if normalized := NormalizeOptionalQueueMode(mode); normalized != "" {
|
||||
sm.modeOverrides[key] = normalized
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SessionManager) ClearQueueMode(key string) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
delete(sm.modeOverrides, key)
|
||||
}
|
||||
|
||||
// ForceRelease 强制释放 session(用于 session 关闭或错误恢复)。
|
||||
func (sm *SessionManager) ForceRelease(key string) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
delete(sm.active, key)
|
||||
delete(sm.pending, key)
|
||||
delete(sm.dropped, key)
|
||||
}
|
||||
@@ -1,649 +0,0 @@
|
||||
package bot
|
||||
|
||||
// Which session a message belongs to: the mapping from a chat to a workspace
|
||||
// and runtime, the options a session is opened with, and what closing one does.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/secrets"
|
||||
"reasonix/internal/surface"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (gw *BotGateway) closeSessions() {
|
||||
var states []*sessionState
|
||||
gw.mu.Lock()
|
||||
for key, state := range gw.controllers {
|
||||
states = append(states, state)
|
||||
delete(gw.controllers, key)
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
for _, state := range states {
|
||||
gw.closeSessionState(state)
|
||||
}
|
||||
}
|
||||
|
||||
// closeSessionState tears down a session state that has been unlinked from
|
||||
// gw.controllers. runTurn publishes state.cancel under gw.mu on every turn —
|
||||
// possibly after the state was already unlinked — so snapshot and clear the
|
||||
// field inside the lock and invoke it outside (the same discipline as
|
||||
// cancelActiveSession).
|
||||
func (gw *BotGateway) closeSessionState(state *sessionState) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
// Serialize retirement with recovery ownership handoffs. Stop unlinks
|
||||
// sessions before turn goroutines drain, so a recovery callback captured by
|
||||
// the controller can still arrive here. Marking the state retired under the
|
||||
// same lock prevents that callback from reacquiring a lease after teardown;
|
||||
// an already-running handoff completes before the lease is released below.
|
||||
state.lifecycleMu.Lock()
|
||||
if state.retired {
|
||||
state.lifecycleMu.Unlock()
|
||||
return
|
||||
}
|
||||
state.retired = true
|
||||
state.lifecycleMu.Unlock()
|
||||
|
||||
gw.mu.Lock()
|
||||
cancel := state.cancel
|
||||
state.cancel = nil
|
||||
gw.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if state.ctrl != nil {
|
||||
state.ctrl.Close()
|
||||
}
|
||||
if state.leases != nil {
|
||||
state.leases.Release()
|
||||
}
|
||||
}
|
||||
|
||||
// unlinkAndCloseSessionState removes state from the live gateway before closing
|
||||
// it. It is used when a controller has already rotated its transcript but the
|
||||
// replacement lease could not be acquired: retaining that state would let the
|
||||
// next message reuse a controller that no longer owns its active session path.
|
||||
func (gw *BotGateway) unlinkAndCloseSessionState(key string, state *sessionState) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
gw.mu.Lock()
|
||||
if gw.controllers[key] == state {
|
||||
delete(gw.controllers, key)
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
gw.closeSessionState(state)
|
||||
}
|
||||
|
||||
func (gw *BotGateway) setSessionRuntimeOverride(key string, override sessionRuntimeOverride, enabled bool) bool {
|
||||
return gw.sessions.runIfIdle(key, func() bool {
|
||||
var old *sessionState
|
||||
gw.mu.Lock()
|
||||
if state, ok := gw.controllers[key]; ok {
|
||||
if botSessionHasActiveWork(state) {
|
||||
gw.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
old = state
|
||||
delete(gw.controllers, key)
|
||||
}
|
||||
if enabled {
|
||||
override.sessionPath = canonicalBotPath(override.sessionPath)
|
||||
override.channel.WorkspaceRoot = canonicalBotPath(override.channel.WorkspaceRoot)
|
||||
gw.sessionOverrides[key] = override
|
||||
} else {
|
||||
delete(gw.sessionOverrides, key)
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
gw.closeSessionState(old)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func botSessionHasActiveWork(state *sessionState) bool {
|
||||
if state == nil || state.ctrl == nil {
|
||||
return false
|
||||
}
|
||||
status, ok := safeBotControllerRuntimeStatus(state.ctrl)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return status.Running || status.PendingPrompt || status.BackgroundJobs > 0
|
||||
}
|
||||
|
||||
func safeBotControllerRuntimeStatus(ctrl botController) (control.RuntimeStatus, bool) {
|
||||
if ctrl == nil {
|
||||
return control.RuntimeStatus{}, false
|
||||
}
|
||||
return ctrl.RuntimeStatus(), true
|
||||
}
|
||||
|
||||
func (gw *BotGateway) sessionRuntimeOverrideForMessage(msg InboundMessage) (sessionRuntimeOverride, bool) {
|
||||
key := BuildSessionKey(msg.Session())
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
override, ok := gw.sessionOverrides[key]
|
||||
return override, ok
|
||||
}
|
||||
|
||||
// sessionClaim is what claimSession decided about the session a message maps to.
|
||||
type sessionClaim int
|
||||
|
||||
const (
|
||||
sessionAbsent sessionClaim = iota
|
||||
sessionReused
|
||||
sessionChangeDeferred
|
||||
sessionRetired
|
||||
)
|
||||
|
||||
// claimSession decides, under the gateway lock, whether a session is reused,
|
||||
// kept despite a runtime change because it has work, or retired. The decision
|
||||
// calls into the controller, so the lock is released by defer: a panic there
|
||||
// must not leave every later message waiting on it.
|
||||
func (gw *BotGateway) claimSession(key string, msg InboundMessage, profile sessionRuntimeProfile) (*sessionState, sessionClaim) {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
state, ok := gw.controllers[key]
|
||||
switch {
|
||||
case !ok:
|
||||
return nil, sessionAbsent
|
||||
case sessionStateMatchesRuntime(state, profile):
|
||||
updateSessionStateRuntime(state, msg, profile)
|
||||
return state, sessionReused
|
||||
case botSessionHasActiveWork(state):
|
||||
return state, sessionChangeDeferred
|
||||
}
|
||||
delete(gw.controllers, key)
|
||||
return state, sessionRetired
|
||||
}
|
||||
|
||||
func (gw *BotGateway) getOrCreateSession(ctx context.Context, key string, msg InboundMessage) *sessionState {
|
||||
profile := gw.sessionProfileForMessage(msg)
|
||||
switch state, reuse := gw.claimSession(key, msg, profile); reuse {
|
||||
case sessionReused:
|
||||
safeBotSetToolApprovalMode(state.ctrl, profile.toolApprovalMode)
|
||||
gw.logger.Info("bot session reused", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
|
||||
return state
|
||||
case sessionChangeDeferred:
|
||||
safeBotSetToolApprovalMode(state.ctrl, profile.toolApprovalMode)
|
||||
gw.logger.Warn("bot session runtime change deferred while work is active", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
|
||||
return state
|
||||
case sessionRetired:
|
||||
gw.closeSessionState(state)
|
||||
gw.logger.Warn("bot session runtime changed; rebuilding", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8], "old_workspace_set", strings.TrimSpace(state.workspaceRoot) != "", "new_workspace_set", profile.workspaceRoot != "", "old_model", state.model, "new_model", profile.model)
|
||||
}
|
||||
|
||||
// Create the lease owner before the controller so automatic conflict
|
||||
// recovery can move ownership to the recovery branch before the controller
|
||||
// commits to writing it. Without this callback the bot kept guarding the
|
||||
// original path while continuing on an unleased recovery path.
|
||||
sessionSink := &sessionEventSink{}
|
||||
leases := control.NewSessionLeaseKeeper()
|
||||
state := &sessionState{
|
||||
sink: sessionSink,
|
||||
leases: leases,
|
||||
platform: msg.Platform,
|
||||
connectionID: strings.TrimSpace(msg.ConnectionID),
|
||||
model: profile.model,
|
||||
workspaceRoot: profile.workspaceRoot,
|
||||
toolApprovalMode: profile.toolApprovalMode,
|
||||
sessionPath: profile.sessionPath,
|
||||
pendingAsks: make(map[string][]event.AskQuestion),
|
||||
createdAt: time.Now(),
|
||||
lastActive: time.Now(),
|
||||
}
|
||||
gw.logger.Info("bot session creating", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8], "model", profile.model, "workspace_set", profile.workspaceRoot != "", "tool_approval_mode", profile.toolApprovalMode)
|
||||
ctrl, err := boot.Build(ctx, boot.Options{
|
||||
Model: profile.model,
|
||||
MaxSteps: gw.cfg.MaxSteps,
|
||||
MaxStepsKey: "bot.max_steps",
|
||||
RequireKey: true,
|
||||
Sink: sessionSink,
|
||||
StatsSource: surface.Bot,
|
||||
WorkspaceRoot: profile.workspaceRoot,
|
||||
SessionDir: botSessionDir(profile.workspaceRoot),
|
||||
ApprovalTimeout: gw.approvalTimeout(),
|
||||
OnSessionRecovered: gw.botSessionRecoveredHandler(key, msg, state),
|
||||
})
|
||||
if err != nil {
|
||||
leases.Release()
|
||||
gw.logger.Error("build controller failed", "err", secrets.RedactError(err))
|
||||
return nil
|
||||
}
|
||||
state.ctrl = ctrl
|
||||
if profile.sessionPath != "" {
|
||||
// A mapped binding degrades to a fresh session on failure; only an
|
||||
// explicit /attach is allowed to hard-fail the message, because the
|
||||
// user named that exact session.
|
||||
degrade := func(reason string, err error) bool {
|
||||
if !profile.sessionPathOptional {
|
||||
return false
|
||||
}
|
||||
gw.logger.Warn("mapped bot session unavailable; starting fresh", "reason", reason, "session_path", profile.sessionPath, "err", err)
|
||||
profile.sessionPath = ""
|
||||
state.sessionPath = ""
|
||||
state.mappingDegraded = true
|
||||
return true
|
||||
}
|
||||
if err := leases.Rebind(profile.sessionPath); err != nil {
|
||||
if !degrade("lease held elsewhere", err) {
|
||||
ctrl.Close()
|
||||
leases.Release()
|
||||
gw.logger.Error("attached bot session is in use", "err", control.SessionInUseMessage(err))
|
||||
return nil
|
||||
}
|
||||
} else if loaded, err := agent.LoadSession(profile.sessionPath); err != nil {
|
||||
if !degrade("load failed", err) {
|
||||
ctrl.Close()
|
||||
leases.Release()
|
||||
if os.IsNotExist(err) {
|
||||
gw.logger.Error("attached bot session missing", "session_path", profile.sessionPath)
|
||||
} else {
|
||||
gw.logger.Error("attached bot session load failed", "session_path", profile.sessionPath, "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
} else if err := ctrl.Resume(loaded, profile.sessionPath); err != nil {
|
||||
gw.logger.Error("attached session resume refused", "session_path", profile.sessionPath, "err", err)
|
||||
}
|
||||
}
|
||||
ctrl.EnableInteractiveApproval()
|
||||
ctrl.SetToolApprovalMode(profile.toolApprovalMode)
|
||||
ctrl.EnsureSessionPath()
|
||||
if err := rebindBotControllerWriteAuthority(leases, ctrl); err != nil {
|
||||
ctrl.Close()
|
||||
leases.Release()
|
||||
gw.logger.Error("bot session lease failed", "err", control.SessionInUseMessage(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
var replace *sessionState
|
||||
gw.mu.Lock()
|
||||
// Re-check under the lock: while we were off-lock in boot.Build, a second
|
||||
// message for the same key may have built and registered its own session.
|
||||
// Reuse it only when it still targets this message's runtime profile.
|
||||
if existing, ok := gw.controllers[key]; ok {
|
||||
if sessionStateMatchesRuntime(existing, profile) {
|
||||
updateSessionStateRuntime(existing, msg, profile)
|
||||
gw.mu.Unlock()
|
||||
ctrl.Close()
|
||||
leases.Release()
|
||||
safeBotSetToolApprovalMode(existing.ctrl, profile.toolApprovalMode)
|
||||
gw.logger.Info("bot session built concurrently; discarding duplicate", "platform", msg.Platform, "chat", hashID(msg.ChatID), "session", key[:8])
|
||||
return existing
|
||||
}
|
||||
delete(gw.controllers, key)
|
||||
replace = existing
|
||||
}
|
||||
gw.controllers[key] = state
|
||||
gw.mu.Unlock()
|
||||
gw.closeSessionState(replace)
|
||||
|
||||
gw.logger.Info("bot session created", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
|
||||
return state
|
||||
}
|
||||
|
||||
func updateSessionStateRuntime(state *sessionState, msg InboundMessage, profile sessionRuntimeProfile) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
if state.connectionID == "" {
|
||||
state.connectionID = strings.TrimSpace(msg.ConnectionID)
|
||||
}
|
||||
if state.platform == "" {
|
||||
state.platform = msg.Platform
|
||||
}
|
||||
state.model = profile.model
|
||||
state.workspaceRoot = profile.workspaceRoot
|
||||
state.toolApprovalMode = profile.toolApprovalMode
|
||||
state.sessionPath = profile.sessionPath
|
||||
state.lastActive = time.Now()
|
||||
}
|
||||
|
||||
func (gw *BotGateway) sessionProfileForMessage(msg InboundMessage) sessionRuntimeProfile {
|
||||
model, workspaceRoot, toolApprovalMode := gw.sessionOptionsForMessage(msg)
|
||||
var sessionPath string
|
||||
sessionPathOptional := false
|
||||
if override, ok := gw.sessionRuntimeOverrideForMessage(msg); ok {
|
||||
sessionPath = override.sessionPath
|
||||
}
|
||||
// A persisted session_mappings binding is the durable chat→session link
|
||||
// the desktop writes into the connection config. Without consuming it
|
||||
// here, every gateway restart or runtime rebuild opened a brand-new
|
||||
// session file for the chat and the configured binding was display-only
|
||||
// (#6917, #6934).
|
||||
if sessionPath == "" {
|
||||
if mapped := gw.sessionMappingPathForMessage(msg); mapped != "" {
|
||||
sessionPath = mapped
|
||||
sessionPathOptional = true
|
||||
}
|
||||
}
|
||||
return sessionRuntimeProfile{
|
||||
model: strings.TrimSpace(model),
|
||||
workspaceRoot: strings.TrimSpace(workspaceRoot),
|
||||
toolApprovalMode: normalizeBotToolApprovalMode(toolApprovalMode),
|
||||
sessionPath: canonicalBotPath(sessionPath),
|
||||
sessionPathOptional: sessionPathOptional,
|
||||
}
|
||||
}
|
||||
|
||||
// sessionMappingPathForMessage resolves the persisted session_mappings entry
|
||||
// for a message to an existing session file. Only bindings that resolve to a
|
||||
// present, readable file participate — a moved or deleted target quietly
|
||||
// degrades to normal session creation rather than blocking the chat.
|
||||
func (gw *BotGateway) sessionMappingPathForMessage(msg InboundMessage) string {
|
||||
gw.mu.Lock()
|
||||
var mappings []SessionMapping
|
||||
if msg.ConnectionID != "" {
|
||||
if channel, ok := gw.cfg.ConnectionChannels[msg.ConnectionID]; ok {
|
||||
mappings = channel.SessionMappings
|
||||
}
|
||||
}
|
||||
if len(mappings) == 0 {
|
||||
if channel, ok := gw.cfg.Channels[msg.Platform]; ok {
|
||||
mappings = channel.SessionMappings
|
||||
}
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
mapping, ok := matchingSessionMapping(mappings, msg)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
path := botSessionPathFromTarget(mapping.SessionID)
|
||||
if path == "" {
|
||||
path = botSessionPathFromTarget(mapping.SessionSource)
|
||||
}
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if info, err := os.Stat(path); err != nil || info.IsDir() {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func sessionStateMatchesRuntime(state *sessionState, profile sessionRuntimeProfile) bool {
|
||||
if state == nil || state.ctrl == nil {
|
||||
return false
|
||||
}
|
||||
if stateModel := strings.TrimSpace(state.model); stateModel != "" && profile.model != "" && stateModel != profile.model {
|
||||
return false
|
||||
}
|
||||
stateRoot := strings.TrimSpace(state.workspaceRoot)
|
||||
wantRoot := strings.TrimSpace(profile.workspaceRoot)
|
||||
if stateRoot == "" {
|
||||
root, ok := safeBotControllerWorkspaceRoot(state.ctrl)
|
||||
if ok {
|
||||
stateRoot = strings.TrimSpace(root)
|
||||
} else if wantRoot != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if stateRoot != wantRoot {
|
||||
return false
|
||||
}
|
||||
// A state that already degraded off its mapped session keeps running on
|
||||
// its fresh path even though the profile re-resolves the mapping each
|
||||
// message; rebuilding here would spawn a new session per message while the
|
||||
// mapped file stays unavailable.
|
||||
if profile.sessionPathOptional && state.mappingDegraded {
|
||||
return true
|
||||
}
|
||||
if canonicalBotPath(state.sessionPath) != canonicalBotPath(profile.sessionPath) {
|
||||
return false
|
||||
}
|
||||
if profile.sessionPath != "" && canonicalBotPath(state.ctrl.SessionPath()) != canonicalBotPath(profile.sessionPath) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeBotControllerWorkspaceRoot(ctrl botController) (string, bool) {
|
||||
if ctrl == nil {
|
||||
return "", false
|
||||
}
|
||||
return ctrl.WorkspaceRoot(), true
|
||||
}
|
||||
|
||||
func safeBotSetToolApprovalMode(ctrl botController, mode string) {
|
||||
if ctrl == nil {
|
||||
return
|
||||
}
|
||||
ctrl.SetToolApprovalMode(mode)
|
||||
}
|
||||
|
||||
func botSessionDir(workspaceRoot string) string {
|
||||
if strings.TrimSpace(workspaceRoot) == "" {
|
||||
return config.SessionDir()
|
||||
}
|
||||
if dir := config.ProjectSessionDir(workspaceRoot); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return config.SessionDir()
|
||||
}
|
||||
|
||||
func (gw *BotGateway) rememberSessionReady(msg InboundMessage, ctrl botController) {
|
||||
if gw.cfg.OnSessionReady == nil || ctrl == nil {
|
||||
return
|
||||
}
|
||||
gw.rememberSessionPath(msg, ctrl.SessionPath())
|
||||
}
|
||||
|
||||
func (gw *BotGateway) rememberSessionPath(msg InboundMessage, sessionPath string) {
|
||||
if gw.cfg.OnSessionReady == nil {
|
||||
return
|
||||
}
|
||||
sessionID := botSessionTarget(sessionPath)
|
||||
if sessionID == "" {
|
||||
return
|
||||
}
|
||||
if err := gw.cfg.OnSessionReady(msg, sessionID); err != nil {
|
||||
gw.logger.Warn("remember bot session failed", "platform", msg.Platform, "connection", msg.ConnectionID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// botSessionRecoveredHandler keeps the controller path, its writer lease, and
|
||||
// the remote-to-session mapping on the same recovery generation. The lease
|
||||
// handoff runs first and is failure-atomic: if the recovery path is already
|
||||
// owned, the controller stays on the original path and the old lease remains
|
||||
// held. Mapping updates are limited to this exact sessionState so a late
|
||||
// callback from a retired controller cannot overwrite its replacement.
|
||||
func (gw *BotGateway) botSessionRecoveredHandler(key string, msg InboundMessage, state *sessionState) func(control.SessionRecoveryInfo) error {
|
||||
return func(info control.SessionRecoveryInfo) error {
|
||||
if state == nil || state.leases == nil {
|
||||
return nil
|
||||
}
|
||||
// Keep the lease handoff and mapping publication atomic with respect to
|
||||
// state retirement. In particular, never let a callback that outlives
|
||||
// Stop reacquire a lease after closeSessionState has released it.
|
||||
state.lifecycleMu.Lock()
|
||||
defer state.lifecycleMu.Unlock()
|
||||
if state.retired {
|
||||
return errBotSessionRetired
|
||||
}
|
||||
if err := state.leases.HandleSessionRecovered(info); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
originalPath := canonicalBotPath(info.OriginalPath)
|
||||
recoveryPath := canonicalBotPath(info.RecoveryPath)
|
||||
live := false
|
||||
gw.mu.Lock()
|
||||
if gw.controllers[key] == state {
|
||||
live = true
|
||||
if canonicalBotPath(state.sessionPath) == originalPath {
|
||||
state.sessionPath = recoveryPath
|
||||
}
|
||||
if override, ok := gw.sessionOverrides[key]; ok && canonicalBotPath(override.sessionPath) == originalPath {
|
||||
override.sessionPath = recoveryPath
|
||||
gw.sessionOverrides[key] = override
|
||||
}
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
|
||||
if live {
|
||||
gw.rememberSessionPath(msg, recoveryPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func botSessionTarget(sessionPath string) string {
|
||||
sessionPath = strings.TrimSpace(sessionPath)
|
||||
if sessionPath == "" {
|
||||
return ""
|
||||
}
|
||||
return "path:" + sessionPath
|
||||
}
|
||||
|
||||
func (gw *BotGateway) sessionOptionsForMessage(msg InboundMessage) (model string, workspaceRoot string, toolApprovalMode string) {
|
||||
// cfg.ToolApprovalMode / Channels / ConnectionChannels are rewritten under
|
||||
// gw.mu at runtime (/yolo, UpdateConnectionToolApprovalMode), so snapshot them
|
||||
// under a short lock and resolve outside it — applyRuntimeOverrideOptions
|
||||
// takes gw.mu itself. Copying the ChannelConfig value is enough: writers
|
||||
// replace whole map entries and never mutate SessionMappings in place.
|
||||
gw.mu.Lock()
|
||||
model = gw.cfg.Model
|
||||
workspaceRoot = gw.cfg.WorkspaceRoot
|
||||
toolApprovalMode = normalizeBotToolApprovalMode(gw.cfg.ToolApprovalMode)
|
||||
var connChannel ChannelConfig
|
||||
connOK := false
|
||||
if msg.ConnectionID != "" {
|
||||
connChannel, connOK = gw.cfg.ConnectionChannels[msg.ConnectionID]
|
||||
}
|
||||
platChannel, platOK := gw.cfg.Channels[msg.Platform]
|
||||
gw.mu.Unlock()
|
||||
|
||||
var mappings []SessionMapping
|
||||
if connOK {
|
||||
applyBotChannelOptions(connChannel, &model, &workspaceRoot, &toolApprovalMode)
|
||||
mappings = connChannel.SessionMappings
|
||||
if mapping, ok := matchingSessionMapping(mappings, msg); ok {
|
||||
workspaceRoot = workspaceRootForSessionMapping(mapping, workspaceRoot)
|
||||
}
|
||||
model, workspaceRoot, toolApprovalMode = gw.applyRouteOptions(msg, model, workspaceRoot, toolApprovalMode)
|
||||
model, workspaceRoot, toolApprovalMode = gw.applyRuntimeOverrideOptions(msg, model, workspaceRoot, toolApprovalMode)
|
||||
return model, workspaceRoot, toolApprovalMode
|
||||
}
|
||||
if platOK {
|
||||
applyBotChannelOptions(platChannel, &model, &workspaceRoot, &toolApprovalMode)
|
||||
mappings = platChannel.SessionMappings
|
||||
}
|
||||
if mapping, ok := matchingSessionMapping(mappings, msg); ok {
|
||||
workspaceRoot = workspaceRootForSessionMapping(mapping, workspaceRoot)
|
||||
}
|
||||
model, workspaceRoot, toolApprovalMode = gw.applyRouteOptions(msg, model, workspaceRoot, toolApprovalMode)
|
||||
model, workspaceRoot, toolApprovalMode = gw.applyRuntimeOverrideOptions(msg, model, workspaceRoot, toolApprovalMode)
|
||||
return model, workspaceRoot, toolApprovalMode
|
||||
}
|
||||
|
||||
func (gw *BotGateway) applyRuntimeOverrideOptions(msg InboundMessage, model, workspaceRoot, toolApprovalMode string) (string, string, string) {
|
||||
if override, ok := gw.sessionRuntimeOverrideForMessage(msg); ok {
|
||||
applyBotChannelOptions(override.channel, &model, &workspaceRoot, &toolApprovalMode)
|
||||
}
|
||||
return model, workspaceRoot, toolApprovalMode
|
||||
}
|
||||
|
||||
func (gw *BotGateway) applyRouteOptions(msg InboundMessage, model, workspaceRoot, toolApprovalMode string) (string, string, string) {
|
||||
for _, route := range gw.cfg.Routes {
|
||||
if routeMatchesMessage(route, msg) {
|
||||
applyBotChannelOptions(route.Channel, &model, &workspaceRoot, &toolApprovalMode)
|
||||
break
|
||||
}
|
||||
}
|
||||
return model, workspaceRoot, toolApprovalMode
|
||||
}
|
||||
|
||||
func applyBotChannelOptions(channel ChannelConfig, model *string, workspaceRoot *string, toolApprovalMode *string) {
|
||||
if value := strings.TrimSpace(channel.Model); value != "" {
|
||||
*model = value
|
||||
}
|
||||
if value := strings.TrimSpace(channel.WorkspaceRoot); value != "" {
|
||||
*workspaceRoot = value
|
||||
}
|
||||
if value := normalizeOptionalBotToolApprovalMode(channel.ToolApprovalMode); value != "" {
|
||||
*toolApprovalMode = value
|
||||
}
|
||||
}
|
||||
|
||||
func matchingSessionMapping(mappings []SessionMapping, msg InboundMessage) (SessionMapping, bool) {
|
||||
for i := range mappings {
|
||||
if sessionMappingMatches(mappings[i], msg) {
|
||||
return mappings[i], true
|
||||
}
|
||||
}
|
||||
return SessionMapping{}, false
|
||||
}
|
||||
|
||||
func sessionMappingMatches(mapping SessionMapping, msg InboundMessage) bool {
|
||||
if strings.TrimSpace(mapping.RemoteID) != strings.TrimSpace(msg.ChatID) {
|
||||
return false
|
||||
}
|
||||
chatType, userID, threadID := sessionMappingIdentity(msg)
|
||||
mappingChatType := strings.TrimSpace(mapping.ChatType)
|
||||
if mappingChatType == "" {
|
||||
return chatType == ""
|
||||
}
|
||||
if mappingChatType != chatType {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(mapping.UserID) != userID {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(mapping.ThreadID) == threadID
|
||||
}
|
||||
|
||||
func sessionMappingIdentity(msg InboundMessage) (chatType string, userID string, threadID string) {
|
||||
switch msg.ChatType {
|
||||
case ChatGroup, ChatGuild:
|
||||
chatType = string(msg.ChatType)
|
||||
userID = strings.TrimSpace(msg.UserID)
|
||||
case ChatThread:
|
||||
chatType = string(msg.ChatType)
|
||||
threadID = strings.TrimSpace(msg.ThreadID)
|
||||
if threadID == "" {
|
||||
threadID = strings.TrimSpace(msg.ChatID)
|
||||
}
|
||||
}
|
||||
return chatType, userID, threadID
|
||||
}
|
||||
|
||||
func workspaceRootForSessionMapping(mapping SessionMapping, fallback string) string {
|
||||
if root := strings.TrimSpace(mapping.WorkspaceRoot); root != "" {
|
||||
return root
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(mapping.Scope), "global") {
|
||||
return ""
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func routeMatchesMessage(route RouteConfig, msg InboundMessage) bool {
|
||||
if value := strings.TrimSpace(route.ConnectionID); value != "" && value != strings.TrimSpace(msg.ConnectionID) {
|
||||
return false
|
||||
}
|
||||
if route.Platform != "" && route.Platform != msg.Platform {
|
||||
return false
|
||||
}
|
||||
if route.ChatType != "" && route.ChatType != msg.ChatType {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(route.ChatID); value != "" && value != strings.TrimSpace(msg.ChatID) {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(route.UserID); value != "" && value != strings.TrimSpace(msg.UserID) {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(route.ThreadID); value != "" && value != strings.TrimSpace(msg.ThreadID) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildSessionKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
src SessionSource
|
||||
// DM 同 chat 不同 user 应返回相同 key
|
||||
wantSame bool
|
||||
src2 SessionSource
|
||||
}{
|
||||
{
|
||||
name: "dm same chat different user",
|
||||
src: SessionSource{Platform: PlatformQQ, ChatType: ChatDM, ChatID: "user123", UserID: "a"},
|
||||
src2: SessionSource{Platform: PlatformQQ, ChatType: ChatDM, ChatID: "user123", UserID: "b"},
|
||||
wantSame: true,
|
||||
},
|
||||
{
|
||||
name: "dm different chat",
|
||||
src: SessionSource{Platform: PlatformQQ, ChatType: ChatDM, ChatID: "user123", UserID: "a"},
|
||||
src2: SessionSource{Platform: PlatformQQ, ChatType: ChatDM, ChatID: "user456", UserID: "a"},
|
||||
wantSame: false,
|
||||
},
|
||||
{
|
||||
name: "direct same chat different user",
|
||||
src: SessionSource{Platform: PlatformQQ, ChatType: ChatDirect, ChatID: "guild123", UserID: "a"},
|
||||
src2: SessionSource{Platform: PlatformQQ, ChatType: ChatDirect, ChatID: "guild123", UserID: "b"},
|
||||
wantSame: true,
|
||||
},
|
||||
{
|
||||
name: "direct distinct from dm",
|
||||
src: SessionSource{Platform: PlatformQQ, ChatType: ChatDirect, ChatID: "shared", UserID: "a"},
|
||||
src2: SessionSource{Platform: PlatformQQ, ChatType: ChatDM, ChatID: "shared", UserID: "a"},
|
||||
wantSame: false,
|
||||
},
|
||||
{
|
||||
name: "group same chat different user",
|
||||
src: SessionSource{Platform: PlatformFeishu, ChatType: ChatGroup, ChatID: "group1", UserID: "a"},
|
||||
src2: SessionSource{Platform: PlatformFeishu, ChatType: ChatGroup, ChatID: "group1", UserID: "b"},
|
||||
wantSame: false,
|
||||
},
|
||||
{
|
||||
name: "group same user different chat",
|
||||
src: SessionSource{Platform: PlatformFeishu, ChatType: ChatGroup, ChatID: "group1", UserID: "a"},
|
||||
src2: SessionSource{Platform: PlatformFeishu, ChatType: ChatGroup, ChatID: "group2", UserID: "a"},
|
||||
wantSame: false,
|
||||
},
|
||||
{
|
||||
name: "thread shared",
|
||||
src: SessionSource{Platform: PlatformQQ, ChatType: ChatThread, ChatID: "ch1", ThreadID: "th1", UserID: "a"},
|
||||
src2: SessionSource{Platform: PlatformQQ, ChatType: ChatThread, ChatID: "ch1", ThreadID: "th1", UserID: "b"},
|
||||
wantSame: true,
|
||||
},
|
||||
{
|
||||
name: "different platform same ids",
|
||||
src: SessionSource{Platform: PlatformQQ, ChatType: ChatDM, ChatID: "123", UserID: "u1"},
|
||||
src2: SessionSource{Platform: PlatformFeishu, ChatType: ChatDM, ChatID: "123", UserID: "u1"},
|
||||
wantSame: false,
|
||||
},
|
||||
{
|
||||
name: "same platform different connection",
|
||||
src: SessionSource{Platform: PlatformFeishu, ConnectionID: "feishu-feishu", ChatType: ChatDM, ChatID: "123", UserID: "u1"},
|
||||
src2: SessionSource{Platform: PlatformFeishu, ConnectionID: "feishu-lark", ChatType: ChatDM, ChatID: "123", UserID: "u1"},
|
||||
wantSame: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
k1 := BuildSessionKey(tt.src)
|
||||
k2 := BuildSessionKey(tt.src2)
|
||||
if tt.wantSame && k1 != k2 {
|
||||
t.Errorf("want same key, got %s != %s", k1, k2)
|
||||
}
|
||||
if !tt.wantSame && k1 == k2 {
|
||||
t.Errorf("want different keys, got %s == %s", k1, k2)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSlashBypass(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
bypass bool
|
||||
}{
|
||||
{"/stop", true},
|
||||
{"/stop extra args", true},
|
||||
{"/new", true},
|
||||
{"/reset", true},
|
||||
{"/approve", true},
|
||||
{"/deny", true},
|
||||
{"/yolo", true},
|
||||
{"/yolo on", true},
|
||||
{"/mode yolo", true},
|
||||
{"/status", true},
|
||||
{"/help", true},
|
||||
{"hello", false},
|
||||
{"/unknown", false},
|
||||
{"", false},
|
||||
{" /stop", false}, // leading space means not a slash command
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := IsSlashBypass(tt.text)
|
||||
if got != tt.bypass {
|
||||
t.Errorf("IsSlashBypass(%q) = %v, want %v", tt.text, got, tt.bypass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManager_TryAcquire(t *testing.T) {
|
||||
sm := NewSessionManager(100 * time.Millisecond)
|
||||
|
||||
msg := InboundMessage{Text: "hello", Platform: PlatformQQ, ChatType: ChatDM, ChatID: "c1", UserID: "u1"}
|
||||
key := BuildSessionKey(msg.Session())
|
||||
|
||||
// 第一次获取成功
|
||||
acquired, merged := sm.TryAcquire(key, msg)
|
||||
if !acquired || merged {
|
||||
t.Error("first acquire should succeed")
|
||||
}
|
||||
|
||||
// 第二次获取应该排队
|
||||
acquired, merged = sm.TryAcquire(key, InboundMessage{Text: "world"})
|
||||
if acquired || !merged {
|
||||
t.Error("second acquire should merge into queue")
|
||||
}
|
||||
|
||||
// slash bypass 命令应绕过
|
||||
acquired, merged = sm.TryAcquire(key, InboundMessage{Text: "/stop"})
|
||||
if !acquired || merged {
|
||||
t.Error("slash bypass should acquire immediately")
|
||||
}
|
||||
|
||||
// 第一次 Release 返回排队消息
|
||||
next := sm.Release(key)
|
||||
if next == nil {
|
||||
t.Fatal("expected queued message after first release")
|
||||
}
|
||||
if next.Text != "world" {
|
||||
t.Errorf("merged text = %q, want %q", next.Text, "world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManager_Debounce(t *testing.T) {
|
||||
sm := NewSessionManager(200 * time.Millisecond)
|
||||
|
||||
msg := InboundMessage{Text: "first", Platform: PlatformQQ, ChatType: ChatDM, ChatID: "c1", UserID: "u1"}
|
||||
key := BuildSessionKey(msg.Session())
|
||||
|
||||
acquired, _ := sm.TryAcquire(key, msg)
|
||||
if !acquired {
|
||||
t.Fatal("first acquire should succeed")
|
||||
}
|
||||
|
||||
// 同 session 消息应合并
|
||||
sm.TryAcquire(key, InboundMessage{Text: "second"})
|
||||
// 在 debounce 窗口内发第三条
|
||||
sm.TryAcquire(key, InboundMessage{Text: "third"})
|
||||
|
||||
next := sm.Release(key)
|
||||
if next == nil {
|
||||
t.Fatal("expected queued message after release")
|
||||
}
|
||||
// "second" 和 "third" 合并在队列里("first" 已作为 active 被处理)
|
||||
if next.Text != "second\nthird" {
|
||||
t.Errorf("merged = %q, want %q", next.Text, "second\nthird")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManager_ForceRelease(t *testing.T) {
|
||||
sm := NewSessionManager(100 * time.Millisecond)
|
||||
|
||||
msg := InboundMessage{Text: "test", Platform: PlatformQQ, ChatType: ChatDM, ChatID: "c1", UserID: "u1"}
|
||||
key := BuildSessionKey(msg.Session())
|
||||
|
||||
sm.TryAcquire(key, msg)
|
||||
if !sm.IsActive(key) {
|
||||
t.Error("should be active")
|
||||
}
|
||||
|
||||
sm.ForceRelease(key)
|
||||
if sm.IsActive(key) {
|
||||
t.Error("should not be active after force release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerRunIfIdleSerializesNewAdmission(t *testing.T) {
|
||||
sm := NewSessionManager(100 * time.Millisecond)
|
||||
msg := InboundMessage{Text: "test", Platform: PlatformQQ, ChatType: ChatDM, ChatID: "c1", UserID: "u1"}
|
||||
key := BuildSessionKey(msg.Session())
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
switchDone := make(chan bool, 1)
|
||||
go func() {
|
||||
switchDone <- sm.runIfIdle(key, func() bool {
|
||||
close(entered)
|
||||
<-release
|
||||
return true
|
||||
})
|
||||
}()
|
||||
<-entered
|
||||
|
||||
admitted := make(chan bool, 1)
|
||||
go func() {
|
||||
acquired, _ := sm.TryAcquire(key, msg)
|
||||
admitted <- acquired
|
||||
}()
|
||||
select {
|
||||
case acquired := <-admitted:
|
||||
t.Fatalf("message admission completed during runtime switch: acquired=%v", acquired)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
if !<-switchDone {
|
||||
t.Fatal("idle runtime switch was rejected")
|
||||
}
|
||||
select {
|
||||
case acquired := <-admitted:
|
||||
if !acquired {
|
||||
t.Fatal("message was not admitted after runtime switch")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("message admission remained blocked after runtime switch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerTryAcquireIdleNeverQueuesDuplicate(t *testing.T) {
|
||||
sm := NewSessionManager(time.Second)
|
||||
key := "durable-session"
|
||||
if !sm.TryAcquireIdle(key) {
|
||||
t.Fatal("first idle acquisition failed")
|
||||
}
|
||||
if sm.TryAcquireIdle(key) {
|
||||
t.Fatal("second idle acquisition unexpectedly succeeded")
|
||||
}
|
||||
if next := sm.Release(key); next != nil {
|
||||
t.Fatalf("failed durable acquisition created an in-memory duplicate: %+v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashID(t *testing.T) {
|
||||
h1 := hashID("user_12345")
|
||||
h2 := hashID("user_12345")
|
||||
h3 := hashID("user_67890")
|
||||
|
||||
if h1 != h2 {
|
||||
t.Error("same input should produce same hash")
|
||||
}
|
||||
if h1 == h3 {
|
||||
t.Error("different inputs should produce different hashes")
|
||||
}
|
||||
if hashID("") != "" {
|
||||
t.Error("empty input should produce empty hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundMessage_Session(t *testing.T) {
|
||||
msg := InboundMessage{
|
||||
Platform: PlatformQQ,
|
||||
ConnectionID: "qq-main",
|
||||
Domain: "qq",
|
||||
ChatType: ChatDM,
|
||||
ChatID: "chat1",
|
||||
UserID: "user1",
|
||||
ThreadID: "thread1",
|
||||
}
|
||||
|
||||
src := msg.Session()
|
||||
if src.Platform != PlatformQQ || src.ConnectionID != "qq-main" || src.Domain != "qq" || src.ChatType != ChatDM || src.ChatID != "chat1" || src.UserID != "user1" || src.ThreadID != "thread1" {
|
||||
t.Error("Session() should copy all fields")
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
func bindBotSessionWriteAuthority(state *sessionState) error {
|
||||
if state == nil || state.leases == nil {
|
||||
return nil
|
||||
}
|
||||
ctrl, ok := state.ctrl.(*control.Controller)
|
||||
if !ok || ctrl == nil {
|
||||
return nil
|
||||
}
|
||||
return state.leases.BindControllerAuthority(ctrl)
|
||||
}
|
||||
|
||||
func rebindBotSessionWriteAuthority(state *sessionState, path string) error {
|
||||
if state == nil || state.leases == nil {
|
||||
return nil
|
||||
}
|
||||
if err := state.leases.Rebind(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return bindBotSessionWriteAuthority(state)
|
||||
}
|
||||
|
||||
func rebindBotControllerWriteAuthority(leases *control.SessionLeaseKeeper, ctrl *control.Controller) error {
|
||||
if leases == nil || ctrl == nil {
|
||||
return nil
|
||||
}
|
||||
if err := leases.Rebind(ctrl.SessionPath()); err != nil {
|
||||
return err
|
||||
}
|
||||
return leases.BindControllerAuthority(ctrl)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
func mustBindBotControllerAuthority(t *testing.T, leases *control.SessionLeaseKeeper, ctrl *control.Controller) {
|
||||
t.Helper()
|
||||
if err := leases.BindControllerAuthority(ctrl); err != nil {
|
||||
t.Fatalf("bind controller authority: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,642 +0,0 @@
|
||||
package bot
|
||||
|
||||
// The slash commands a chat platform can send, and the text each answers with.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/sessioninbox"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (gw *BotGateway) handleSlashCommand(ctx context.Context, adapter Adapter, key string, msg InboundMessage) {
|
||||
switch {
|
||||
case strings.HasPrefix(msg.Text, "/stop"):
|
||||
var cancel context.CancelFunc
|
||||
gw.mu.Lock()
|
||||
if state, ok := gw.controllers[key]; ok {
|
||||
cancel = state.cancel
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
gw.sessions.ForceRelease(key)
|
||||
_ = gw.sendText(ctx, adapter, msg, "已停止当前任务。")
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/new") || strings.HasPrefix(msg.Text, "/reset"):
|
||||
var cancel context.CancelFunc
|
||||
gw.mu.Lock()
|
||||
state, ok := gw.controllers[key]
|
||||
if ok {
|
||||
cancel = state.cancel
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
if ok {
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
// NewSession refuses to rotate while a turn is running; the cancel
|
||||
// above is asynchronous, so give the turn a bounded window to
|
||||
// unwind before rotating.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for state.ctrl.Running() && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if err := state.ctrl.NewSession(); err != nil {
|
||||
gw.logger.Warn("new session failed", "err", err)
|
||||
gw.sessions.ForceRelease(key)
|
||||
_ = gw.sendText(ctx, adapter, msg, "新会话创建失败,请稍后重试。")
|
||||
return
|
||||
}
|
||||
if state.leases != nil {
|
||||
if err := rebindBotSessionWriteAuthority(state, state.ctrl.SessionPath()); err != nil {
|
||||
gw.logger.Warn("new session lease failed", "err", control.SessionInUseMessage(err))
|
||||
gw.unlinkAndCloseSessionState(key, state)
|
||||
gw.sessions.ForceRelease(key)
|
||||
_ = gw.sendText(ctx, adapter, msg, "新会话创建失败:无法取得写入权限。请关闭其他 Reasonix 窗口或进程后重试。")
|
||||
return
|
||||
}
|
||||
}
|
||||
// /new leaves an attached transcript and continues in the freshly
|
||||
// rotated path. Clear only the path pin while preserving any project
|
||||
// override, otherwise the next message would rebuild the old attached
|
||||
// transcript and silently undo the rotation.
|
||||
gw.mu.Lock()
|
||||
if gw.controllers[key] == state {
|
||||
state.sessionPath = ""
|
||||
if override, exists := gw.sessionOverrides[key]; exists && override.sessionPath != "" {
|
||||
override.sessionPath = ""
|
||||
gw.sessionOverrides[key] = override
|
||||
}
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
gw.rememberSessionReady(msg, state.ctrl)
|
||||
}
|
||||
gw.sessions.ForceRelease(key)
|
||||
_ = gw.sendText(ctx, adapter, msg, "已开始新会话。")
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/approve"):
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
|
||||
return
|
||||
}
|
||||
// 从消息中解析 approval ID
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 2 {
|
||||
_ = gw.sendText(ctx, adapter, msg, "用法: /approve <id>")
|
||||
return
|
||||
}
|
||||
gw.mu.Lock()
|
||||
state, ok := gw.controllers[key]
|
||||
gw.mu.Unlock()
|
||||
if ok && state.ctrl != nil {
|
||||
// Recovery cards map allow → continue for older clients that only know Approve.
|
||||
if gw.pendingApprovalIsRecovery(key, parts[1]) {
|
||||
_ = state.ctrl.ResolveRecovery(parts[1], agent.RecoveryActionContinue, "")
|
||||
} else {
|
||||
state.ctrl.Approve(parts[1], true, false, false)
|
||||
}
|
||||
gw.forgetPendingApproval(key, parts[1])
|
||||
_ = gw.sendText(ctx, adapter, msg, "已批准。")
|
||||
} else {
|
||||
_ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的待审批操作,请重新触发一次操作。")
|
||||
}
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/deny"):
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
|
||||
return
|
||||
}
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 2 {
|
||||
_ = gw.sendText(ctx, adapter, msg, "用法: /deny <id>")
|
||||
return
|
||||
}
|
||||
gw.mu.Lock()
|
||||
state, ok := gw.controllers[key]
|
||||
gw.mu.Unlock()
|
||||
if ok && state.ctrl != nil {
|
||||
if gw.pendingApprovalIsRecovery(key, parts[1]) {
|
||||
_ = state.ctrl.ResolveRecovery(parts[1], agent.RecoveryActionRevise, "")
|
||||
} else {
|
||||
state.ctrl.Approve(parts[1], false, false, false)
|
||||
}
|
||||
gw.forgetPendingApproval(key, parts[1])
|
||||
_ = gw.sendText(ctx, adapter, msg, "已拒绝。")
|
||||
} else {
|
||||
_ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的待审批操作,请重新触发一次操作。")
|
||||
}
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/recovery-continue-task"):
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
|
||||
return
|
||||
}
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 2 {
|
||||
_ = gw.sendText(ctx, adapter, msg, "用法: /recovery-continue-task <id>")
|
||||
return
|
||||
}
|
||||
gw.mu.Lock()
|
||||
state, ok := gw.controllers[key]
|
||||
gw.mu.Unlock()
|
||||
if ok && state.ctrl != nil {
|
||||
if err := state.ctrl.ResolveRecovery(parts[1], agent.RecoveryActionContinueTask, ""); err != nil {
|
||||
_ = gw.sendText(ctx, adapter, msg, "确认失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
gw.forgetPendingApproval(key, parts[1])
|
||||
_ = gw.sendText(ctx, adapter, msg, "已继续;本任务内同类操作将自动执行,范围扩大或风险升级仍会确认。")
|
||||
} else {
|
||||
_ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的待确认操作。")
|
||||
}
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/recovery-continue"):
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
|
||||
return
|
||||
}
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 2 {
|
||||
_ = gw.sendText(ctx, adapter, msg, "用法: /recovery-continue <id>")
|
||||
return
|
||||
}
|
||||
gw.mu.Lock()
|
||||
state, ok := gw.controllers[key]
|
||||
gw.mu.Unlock()
|
||||
if ok && state.ctrl != nil {
|
||||
if err := state.ctrl.ResolveRecovery(parts[1], agent.RecoveryActionContinue, ""); err != nil {
|
||||
_ = gw.sendText(ctx, adapter, msg, "确认失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
gw.forgetPendingApproval(key, parts[1])
|
||||
_ = gw.sendText(ctx, adapter, msg, "已继续。")
|
||||
} else {
|
||||
_ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的待确认操作。")
|
||||
}
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/recovery-revise"):
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
|
||||
return
|
||||
}
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 2 {
|
||||
_ = gw.sendText(ctx, adapter, msg, "用法: /recovery-revise <id> [补充要求]")
|
||||
return
|
||||
}
|
||||
feedback := strings.TrimSpace(strings.Join(parts[2:], " "))
|
||||
gw.mu.Lock()
|
||||
state, ok := gw.controllers[key]
|
||||
gw.mu.Unlock()
|
||||
if ok && state.ctrl != nil {
|
||||
if err := state.ctrl.ResolveRecovery(parts[1], agent.RecoveryActionRevise, feedback); err != nil {
|
||||
_ = gw.sendText(ctx, adapter, msg, "修改方案失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
gw.forgetPendingApproval(key, parts[1])
|
||||
_ = gw.sendText(ctx, adapter, msg, "已拒绝当前变更并注入修改要求。")
|
||||
} else {
|
||||
_ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的恢复检查点。")
|
||||
}
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/recovery-stop"):
|
||||
// Backward compatibility for cards rendered by an older client: reject
|
||||
// the proposed mutation but leave task cancellation to ordinary /stop.
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
|
||||
return
|
||||
}
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 2 {
|
||||
_ = gw.sendText(ctx, adapter, msg, "用法: /recovery-stop <id>")
|
||||
return
|
||||
}
|
||||
gw.mu.Lock()
|
||||
state, ok := gw.controllers[key]
|
||||
gw.mu.Unlock()
|
||||
if ok && state.ctrl != nil {
|
||||
if err := state.ctrl.ResolveRecovery(parts[1], agent.RecoveryActionRevise, "cancel this proposed action"); err != nil {
|
||||
_ = gw.sendText(ctx, adapter, msg, "取消变更失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
gw.forgetPendingApproval(key, parts[1])
|
||||
_ = gw.sendText(ctx, adapter, msg, "已取消当前变更;如需停止整个任务,请使用 /stop。")
|
||||
} else {
|
||||
_ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的恢复检查点。")
|
||||
}
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/answer"):
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 3 {
|
||||
_ = gw.sendText(ctx, adapter, msg, "用法: /answer <id> <选项或 q1=选项;q2=选项>")
|
||||
return
|
||||
}
|
||||
askID := parts[1]
|
||||
rawAnswer := strings.TrimSpace(strings.Join(parts[2:], " "))
|
||||
gw.mu.Lock()
|
||||
state, ok := gw.controllers[key]
|
||||
var questions []event.AskQuestion
|
||||
if ok {
|
||||
questions = state.pendingAsks[askID]
|
||||
delete(state.pendingAsks, askID)
|
||||
if state.lastAskID == askID {
|
||||
state.lastAskID = ""
|
||||
for nextID := range state.pendingAsks {
|
||||
state.lastAskID = nextID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
gw.mu.Unlock()
|
||||
if !ok || state.ctrl == nil {
|
||||
_ = gw.sendText(ctx, adapter, msg, "没有找到当前会话。")
|
||||
return
|
||||
}
|
||||
answers := parseAskAnswers(questions, rawAnswer)
|
||||
state.ctrl.AnswerQuestion(askID, answers)
|
||||
_ = gw.sendText(ctx, adapter, msg, "已提交回答。")
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/yolo") || strings.HasPrefix(msg.Text, "/mode"):
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||||
return
|
||||
}
|
||||
mode, statusOnly, ok := parseToolApprovalModeCommand(msg.Text)
|
||||
if !ok {
|
||||
_ = gw.sendText(ctx, adapter, msg, "用法: /yolo on|off|auto|status,或 /mode yolo|ask|auto")
|
||||
return
|
||||
}
|
||||
if statusOnly {
|
||||
_ = gw.sendText(ctx, adapter, msg, gw.toolApprovalModeStatusText(key, msg))
|
||||
return
|
||||
}
|
||||
persistErr := gw.setToolApprovalModeForMessage(key, msg, mode)
|
||||
text := toolApprovalModeChangedText(mode)
|
||||
if persistErr != nil {
|
||||
text += "\n当前会话已生效,但保存到设置失败:" + persistErr.Error()
|
||||
}
|
||||
_ = gw.sendText(ctx, adapter, msg, text)
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/queue"):
|
||||
if reply, handled, kick := gw.handleQueueInboxCommand(ctx, key, msg); handled {
|
||||
_ = gw.sendText(ctx, adapter, msg, reply)
|
||||
if kick {
|
||||
gw.kickInbox(ctx, adapter, key, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
mode, clear, statusOnly, ok := parseQueueCommand(msg.Text)
|
||||
if !ok {
|
||||
_ = gw.sendText(ctx, adapter, msg, "用法: /queue steer|followup|collect|interrupt|status|list|show|delete|move|pause|resume|retry|default")
|
||||
return
|
||||
}
|
||||
if statusOnly {
|
||||
_ = gw.sendText(ctx, adapter, msg, gw.queueStatusText(key, msg))
|
||||
return
|
||||
}
|
||||
if clear {
|
||||
gw.sessions.ClearQueueMode(key)
|
||||
_ = gw.sendText(ctx, adapter, msg, "已恢复默认队列模式:"+queueModeLabel(gw.queueMode(key, msg))+"。")
|
||||
return
|
||||
}
|
||||
gw.sessions.SetQueueMode(key, mode)
|
||||
_ = gw.sendText(ctx, adapter, msg, "已切换队列模式:"+queueModeLabel(mode)+"。")
|
||||
|
||||
case slashCommandVerb(msg.Text) == "/projects":
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||||
return
|
||||
}
|
||||
query := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/projects"))
|
||||
_ = gw.sendText(ctx, adapter, msg, formatBotProjects(gw.buildProjectIndex(), query, botProjectListLimit))
|
||||
|
||||
case slashCommandVerb(msg.Text) == "/use":
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||||
return
|
||||
}
|
||||
_ = gw.sendText(ctx, adapter, msg, gw.handleUseProjectCommand(key, msg.Text))
|
||||
|
||||
case slashCommandVerb(msg.Text) == "/sessions":
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||||
return
|
||||
}
|
||||
_ = gw.sendText(ctx, adapter, msg, gw.handleSessionsCommand(msg.Text))
|
||||
|
||||
case slashCommandVerb(msg.Text) == "/attach":
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||||
return
|
||||
}
|
||||
_ = gw.sendText(ctx, adapter, msg, gw.handleAttachSessionCommand(key, msg.Text))
|
||||
|
||||
case slashCommandVerb(msg.Text) == "/search":
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||||
return
|
||||
}
|
||||
_ = gw.sendText(ctx, adapter, msg, gw.handleProjectSearchCommand(ctx, msg.Text))
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/desktop"):
|
||||
// God view over the embedding desktop app: listing every live desktop
|
||||
// session and answering its approvals is strictly more power than the
|
||||
// per-session approver role, so gate on admin.
|
||||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||||
return
|
||||
}
|
||||
_ = gw.sendText(ctx, adapter, msg, gw.handleDesktopCommand(msg))
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/status"):
|
||||
active := gw.sessions.ActiveCount()
|
||||
pending := gw.sessions.PendingCount(key)
|
||||
gw.mu.Lock()
|
||||
sessions := len(gw.controllers)
|
||||
gw.mu.Unlock()
|
||||
mode := gw.currentToolApprovalMode(key, msg)
|
||||
_ = gw.sendText(ctx, adapter, msg, fmt.Sprintf("活跃任务数: %d\n保留会话数: %d\n工具审批模式: %s\n队列模式: %s\n当前会话排队: %d\n连接健康: %s", active, sessions, toolApprovalModeLabel(mode), queueModeLabel(gw.queueMode(key, msg)), pending, gw.adapterHealthSummaryText()))
|
||||
|
||||
case strings.HasPrefix(msg.Text, "/help"):
|
||||
help := "可用命令:\n" +
|
||||
"/stop - 停止当前任务\n" +
|
||||
"/new - 开始新会话\n" +
|
||||
"/reset - 重置会话\n" +
|
||||
"/approve <id> - 批准操作\n" +
|
||||
"/deny <id> - 拒绝操作\n" +
|
||||
"/answer <id> <选项> - 回答 ask 问题\n" +
|
||||
"/yolo on|off|auto|status - 切换或查看工具审批模式\n" +
|
||||
"/mode yolo|ask|auto - 切换工具审批模式\n" +
|
||||
"/queue steer|followup|collect|interrupt|status - 切换或查看队列模式\n" +
|
||||
"/projects [关键词] - 查看可切换项目索引\n" +
|
||||
"/use project <id|名称> - 将当前远端会话切到某个项目\n" +
|
||||
"/sessions search <关键词> - 搜索可 attach 的历史会话\n" +
|
||||
"/attach session <id|关键词> - 绑定当前远端会话到已有历史会话\n" +
|
||||
"/search all <关键词> - 跨已索引项目检索文件内容\n" +
|
||||
"/desktop status|watch|approve|deny|answer - 桌面端上帝视角(需内嵌运行)\n" +
|
||||
"/status - 查看状态\n" +
|
||||
"/help - 显示帮助"
|
||||
_ = gw.sendText(ctx, adapter, msg, help)
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *BotGateway) kickInbox(ctx context.Context, adapter Adapter, key string, fallback InboundMessage) {
|
||||
if gw.sessions.IsActive(key) {
|
||||
return
|
||||
}
|
||||
next := gw.nextInboxTurn(key, fallback)
|
||||
if next == nil {
|
||||
return
|
||||
}
|
||||
if !gw.sessions.TryAcquireIdle(key) {
|
||||
return
|
||||
}
|
||||
gw.turnWG.Go(func() {
|
||||
gw.runTurnItem(ctx, adapter, key, next.msg, next.itemID, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func slashCommandVerb(text string) string {
|
||||
parts := strings.Fields(strings.TrimSpace(text))
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(parts[0])
|
||||
}
|
||||
|
||||
func (gw *BotGateway) handleUseProjectCommand(key, text string) string {
|
||||
selector := parseUseProjectSelector(text)
|
||||
if selector == "" {
|
||||
return "用法: /use project <项目 id|名称|路径>,或 /use project default 恢复默认路由。"
|
||||
}
|
||||
if isDefaultBotSelector(selector) {
|
||||
if !gw.setSessionRuntimeOverride(key, sessionRuntimeOverride{}, false) {
|
||||
return botRuntimeSwitchBusyText()
|
||||
}
|
||||
return "已恢复当前远端会话的默认项目路由。下一条消息会按 bot 配置重新选择 workspace。"
|
||||
}
|
||||
projects := gw.buildProjectIndex()
|
||||
project, matches := resolveBotProject(projects, selector)
|
||||
if project.Root == "" {
|
||||
if len(matches) > 0 {
|
||||
return "匹配到多个项目,请使用项目 id:\n" + formatBotProjects(matches, "", botProjectListLimit)
|
||||
}
|
||||
return "没有匹配的项目。可先用 /projects 查看当前索引。"
|
||||
}
|
||||
if !gw.setSessionRuntimeOverride(key, sessionRuntimeOverride{
|
||||
channel: ChannelConfig{WorkspaceRoot: project.Root},
|
||||
label: "project:" + project.ID,
|
||||
}, true) {
|
||||
return botRuntimeSwitchBusyText()
|
||||
}
|
||||
return fmt.Sprintf("已将当前远端会话切到项目 %s %s。\n下一条消息将在 %s 中运行。", project.ID, project.Name, displayBotPath(project.Root))
|
||||
}
|
||||
|
||||
func parseUseProjectSelector(text string) string {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) < 2 || strings.ToLower(parts[0]) != "/use" {
|
||||
return ""
|
||||
}
|
||||
if len(parts) >= 3 && strings.EqualFold(parts[1], "project") {
|
||||
return strings.TrimSpace(strings.Join(parts[2:], " "))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts[1:], " "))
|
||||
}
|
||||
|
||||
func (gw *BotGateway) handleSessionsCommand(text string) string {
|
||||
query := parseSessionsQuery(text)
|
||||
projects := gw.buildProjectIndex()
|
||||
sessions := gw.buildSessionIndex(projects)
|
||||
return formatBotSessions(sessions, query, botSessionListLimit)
|
||||
}
|
||||
|
||||
func parseSessionsQuery(text string) string {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) <= 1 {
|
||||
return ""
|
||||
}
|
||||
if strings.EqualFold(parts[1], "search") {
|
||||
return strings.TrimSpace(strings.Join(parts[2:], " "))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts[1:], " "))
|
||||
}
|
||||
|
||||
func (gw *BotGateway) handleAttachSessionCommand(key, text string) string {
|
||||
selector := parseAttachSessionSelector(text)
|
||||
if selector == "" {
|
||||
return "用法: /attach session <会话 id|关键词|path:...>"
|
||||
}
|
||||
projects := gw.buildProjectIndex()
|
||||
sessions := gw.buildSessionIndex(projects)
|
||||
session, matches := resolveBotSession(sessions, selector)
|
||||
if session.ID == "" {
|
||||
if len(matches) > 0 {
|
||||
return "匹配到多个会话,请使用会话 id:\n" + formatBotSessions(matches, "", botSessionListLimit)
|
||||
}
|
||||
return "没有匹配的会话。可先用 /sessions search <关键词> 查看当前索引。"
|
||||
}
|
||||
if session.SessionPath == "" {
|
||||
return "这个会话没有可恢复的 path: transcript,暂时不能 attach。"
|
||||
}
|
||||
if info, err := os.Stat(session.SessionPath); err != nil || info.IsDir() {
|
||||
return "会话文件不可用或已被移动:" + displayBotPath(session.SessionPath)
|
||||
}
|
||||
workspaceRoot := session.WorkspaceRoot
|
||||
if workspaceRoot == "" {
|
||||
project := botProjectForPath(projects, session.SessionPath)
|
||||
workspaceRoot = project.Root
|
||||
}
|
||||
if !gw.setSessionRuntimeOverride(key, sessionRuntimeOverride{
|
||||
channel: ChannelConfig{WorkspaceRoot: workspaceRoot},
|
||||
sessionPath: session.SessionPath,
|
||||
label: "session:" + session.ID,
|
||||
}, true) {
|
||||
return botRuntimeSwitchBusyText()
|
||||
}
|
||||
projectName := firstNonEmptyString(session.ProjectName, botProjectName(workspaceRoot), "global")
|
||||
return fmt.Sprintf("已 attach 到会话 %s(%s)。\n下一条消息会从 %s 继续。", session.ID, projectName, displayBotPath(session.SessionPath))
|
||||
}
|
||||
|
||||
func parseAttachSessionSelector(text string) string {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) < 3 || !strings.EqualFold(parts[0], "/attach") || !strings.EqualFold(parts[1], "session") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts[2:], " "))
|
||||
}
|
||||
|
||||
func (gw *BotGateway) handleProjectSearchCommand(ctx context.Context, text string) string {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) < 3 || !strings.EqualFold(parts[1], "all") {
|
||||
return "用法: /search all <关键词>"
|
||||
}
|
||||
query := strings.TrimSpace(strings.Join(parts[2:], " "))
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
results, err := searchBotProjects(searchCtx, gw.buildProjectIndex(), query, botSearchListLimit)
|
||||
if err != nil {
|
||||
return "检索失败:" + err.Error()
|
||||
}
|
||||
return formatBotProjectSearchResults(results, botSearchListLimit)
|
||||
}
|
||||
|
||||
func botRuntimeSwitchBusyText() string {
|
||||
return "当前会话仍有正在运行、等待确认或后台执行的任务。请先完成或停止这些任务,再切换项目或 attach 会话。"
|
||||
}
|
||||
|
||||
func isDefaultBotSelector(selector string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(selector)) {
|
||||
case "default", "reset", "inherit", "global", "none", "默认", "重置":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseQueueCommand(text string) (mode string, clear bool, statusOnly bool, ok bool) {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) == 0 || strings.ToLower(strings.TrimSpace(parts[0])) != "/queue" {
|
||||
return "", false, false, false
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return "", false, true, true
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(parts[1])) {
|
||||
case "status", "state", "show", "状态", "查看":
|
||||
return "", false, true, true
|
||||
case "default", "reset", "inherit", "默认", "重置":
|
||||
return "", true, false, true
|
||||
default:
|
||||
if normalized := NormalizeOptionalQueueMode(parts[1]); normalized != "" {
|
||||
return normalized, false, false, true
|
||||
}
|
||||
return "", false, false, false
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *BotGateway) queueStatusText(key string, msg InboundMessage) string {
|
||||
inboxN := 0
|
||||
paused := false
|
||||
if api := gw.sessionAPI(key); api != nil {
|
||||
snap := api.InboxSnapshot()
|
||||
inboxN = len(snap.Items)
|
||||
paused = snap.Paused
|
||||
}
|
||||
return fmt.Sprintf("当前队列模式:%s\n持久化 Inbox: %d%s\n全局上限: %d\n溢出策略: 拒绝新消息(queue_drop 已弃用)\n用法:/queue steer|followup|collect|interrupt|status|list|show|delete|move|pause|resume|retry|default",
|
||||
queueModeLabel(gw.queueMode(key, msg)),
|
||||
inboxN,
|
||||
map[bool]string{true: " (paused)", false: ""}[paused],
|
||||
sessioninbox.DefaultMaxItems,
|
||||
)
|
||||
}
|
||||
|
||||
func queueModeLabel(mode string) string {
|
||||
switch NormalizeQueueMode(mode) {
|
||||
case QueueModeFollowup:
|
||||
return "逐条跟进"
|
||||
case QueueModeCollect:
|
||||
return "合并收集"
|
||||
case QueueModeInterrupt:
|
||||
return "打断重跑"
|
||||
default:
|
||||
return "即时补充"
|
||||
}
|
||||
}
|
||||
|
||||
func parseToolApprovalModeCommand(text string) (mode string, statusOnly bool, ok bool) {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) == 0 {
|
||||
return "", false, false
|
||||
}
|
||||
cmd := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
switch cmd {
|
||||
case "/yolo":
|
||||
if len(parts) == 1 {
|
||||
return control.ToolApprovalYolo, false, true
|
||||
}
|
||||
return parseToolApprovalModeArg(parts[1])
|
||||
case "/mode":
|
||||
if len(parts) == 1 {
|
||||
return "", true, true
|
||||
}
|
||||
return parseToolApprovalModeArg(parts[1])
|
||||
default:
|
||||
return "", false, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseToolApprovalModeArg(arg string) (mode string, statusOnly bool, ok bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(arg)) {
|
||||
case "status", "state", "show", "状态", "查看":
|
||||
return "", true, true
|
||||
case "on", "enable", "enabled", "true", "1", "yolo", "full", "full-access", "bypass", "开启", "打开":
|
||||
return control.ToolApprovalYolo, false, true
|
||||
case "off", "disable", "disabled", "false", "0", "ask", "询问", "关闭":
|
||||
return control.ToolApprovalAsk, false, true
|
||||
case "auto", "自动":
|
||||
return control.ToolApprovalAuto, false, true
|
||||
default:
|
||||
return "", false, false
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *BotGateway) toolApprovalModeStatusText(key string, msg InboundMessage) string {
|
||||
mode := gw.currentToolApprovalMode(key, msg)
|
||||
return fmt.Sprintf("当前工具审批模式:%s\n用法:/yolo on|off|auto|status,或 /mode yolo|ask|auto", toolApprovalModeLabel(mode))
|
||||
}
|
||||
|
||||
func toolApprovalModeChangedText(mode string) string {
|
||||
switch normalizeBotToolApprovalMode(mode) {
|
||||
case control.ToolApprovalYolo:
|
||||
return "已开启 YOLO:普通工具审批将自动放行;Ask 问题和计划批准仍会等待确认。"
|
||||
case control.ToolApprovalAuto:
|
||||
return "已切换为自动模式:策略允许的工具会自动放行,仍保留需要询问或拒绝的规则。"
|
||||
default:
|
||||
return "已切回询问模式:工具执行前会请求确认。"
|
||||
}
|
||||
}
|
||||
|
||||
func toolApprovalModeLabel(mode string) string {
|
||||
switch normalizeBotToolApprovalMode(mode) {
|
||||
case control.ToolApprovalYolo:
|
||||
return "YOLO"
|
||||
case control.ToolApprovalAuto:
|
||||
return "自动"
|
||||
default:
|
||||
return "询问"
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
// approvalBlockingController is a botController whose RunTurn blocks the way a
|
||||
// real turn does when it hits interactive tool approval: it parks inside
|
||||
// RunTurn until Approve is called. Every other method is a harmless stub so the
|
||||
// gateway's turn/approve path can drive it without a real controller.
|
||||
type approvalBlockingController struct {
|
||||
stubBotController
|
||||
started chan struct{}
|
||||
released chan struct{}
|
||||
approved chan struct{}
|
||||
}
|
||||
|
||||
func newApprovalBlockingController() *approvalBlockingController {
|
||||
return &approvalBlockingController{
|
||||
started: make(chan struct{}, 1),
|
||||
released: make(chan struct{}),
|
||||
approved: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *approvalBlockingController) RunTurn(ctx context.Context, input string) error {
|
||||
// Signal the turn is in-flight, then block as if waiting for ctrl.Approve.
|
||||
select {
|
||||
case c.started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-c.released:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *approvalBlockingController) Approve(id string, allow, session, persist bool) {
|
||||
select {
|
||||
case c.approved <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
// Unblock the parked RunTurn, mirroring the real approval handoff.
|
||||
select {
|
||||
case <-c.released:
|
||||
default:
|
||||
close(c.released)
|
||||
}
|
||||
}
|
||||
|
||||
// Methods the turn/approve path touches but whose behavior is irrelevant here.
|
||||
func (c *approvalBlockingController) SessionPath() string { return "" }
|
||||
|
||||
var _ botController = (*approvalBlockingController)(nil)
|
||||
var _ control.Approvals = (*approvalBlockingController)(nil)
|
||||
|
||||
// TestGatewayApprovalReplyUnblocksTurnOffDispatchGoroutine guards the contract
|
||||
// fixed in this PR: a turn that blocks inside RunTurn waiting for approval must
|
||||
// not wedge the per-adapter dispatch loop. handleSlashCommand (the only caller
|
||||
// of ctrl.Approve) runs on that same dispatch goroutine, so if runTurn ran
|
||||
// inline the loop could never deliver the /approve reply that unblocks the turn
|
||||
// (#4402, #4701, #4863). Running the turn on its own goroutine keeps the loop
|
||||
// free to deliver it.
|
||||
func TestGatewayApprovalReplyUnblocksTurnOffDispatchGoroutine(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{AllowAll: true}}, nil, logger)
|
||||
|
||||
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
|
||||
binding := AdapterBinding{ID: "weixin", Platform: PlatformWeixin, Adapter: adapter}
|
||||
|
||||
ctrl := newApprovalBlockingController()
|
||||
msg := InboundMessage{
|
||||
Platform: PlatformWeixin,
|
||||
ConnectionID: "weixin",
|
||||
ChatType: ChatDM,
|
||||
ChatID: "chat",
|
||||
UserID: "user",
|
||||
}
|
||||
key := BuildSessionKey(msg.Session())
|
||||
// Pre-seed the session so runTurn reuses this fake controller instead of
|
||||
// building a real one via boot.Build.
|
||||
gw.controllers[key] = &sessionState{ctrl: ctrl, sink: &sessionEventSink{}}
|
||||
|
||||
ctx := t.Context()
|
||||
go gw.dispatchLoop(ctx, binding)
|
||||
|
||||
// First message: a normal turn that will block on approval inside RunTurn.
|
||||
turn := msg
|
||||
turn.Text = "do something that needs approval"
|
||||
adapter.msgCh <- turn
|
||||
|
||||
select {
|
||||
case <-ctrl.started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("turn never started; dispatch loop did not run the turn")
|
||||
}
|
||||
|
||||
// Second message: the /approve reply. If the turn ran inline on the dispatch
|
||||
// goroutine, the loop is parked in RunTurn and can never read this — the
|
||||
// session would wedge until restart. With the turn off the dispatch loop,
|
||||
// this reply is delivered and unblocks the turn.
|
||||
approve := msg
|
||||
approve.Text = "/approve some-id"
|
||||
adapter.msgCh <- approve
|
||||
|
||||
select {
|
||||
case <-ctrl.approved:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("approval reply was never delivered: dispatch loop is wedged on the blocked turn")
|
||||
}
|
||||
|
||||
// And the parked turn actually unblocks.
|
||||
select {
|
||||
case <-ctrl.released:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("turn did not unblock after approval")
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
// Package bot 实现 Reasonix 多渠道 IM bot 消息网关,支持 QQ、飞书、微信。
|
||||
// 架构参考 Hermes Agent 的 gateway/adapter/session 模式。
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Platform 标识 IM 平台。
|
||||
type Platform string
|
||||
|
||||
const (
|
||||
PlatformQQ Platform = "qq"
|
||||
PlatformFeishu Platform = "feishu"
|
||||
PlatformWeixin Platform = "weixin"
|
||||
)
|
||||
|
||||
// ChatType 标识会话类型。
|
||||
type ChatType string
|
||||
|
||||
const (
|
||||
ChatDM ChatType = "dm"
|
||||
ChatGroup ChatType = "group"
|
||||
ChatGuild ChatType = "guild"
|
||||
ChatDirect ChatType = "direct"
|
||||
ChatThread ChatType = "thread"
|
||||
)
|
||||
|
||||
// SessionSource 是会话的复合标识,用于生成稳定的 session key。
|
||||
type SessionSource struct {
|
||||
Platform Platform `json:"platform"`
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
ChatType ChatType `json:"chat_type"`
|
||||
ChatID string `json:"chat_id"`
|
||||
UserID string `json:"user_id"`
|
||||
ThreadID string `json:"thread_id,omitempty"`
|
||||
}
|
||||
|
||||
// InboundMedia is an authenticated inbound attachment. Adapters may provide
|
||||
// Data directly, or a lazy Load callback for platform resources that must not
|
||||
// be fetched until the gateway has admitted the sender through its allowlist.
|
||||
type InboundMedia struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
Data []byte `json:"-"`
|
||||
Load func(context.Context) ([]byte, string, error) `json:"-"`
|
||||
FailureText string `json:"-"`
|
||||
}
|
||||
|
||||
// InboundMessage 是从任一平台收到的入站消息。
|
||||
type InboundMessage struct {
|
||||
Platform Platform `json:"platform"`
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
ChatType ChatType `json:"chat_type"`
|
||||
ChatID string `json:"chat_id"`
|
||||
UserID string `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
// OperatorID, when set, is the authenticated actor gated by the allowlist; UserID stays routing-only.
|
||||
OperatorID string `json:"operator_id,omitempty"`
|
||||
Text string `json:"text"`
|
||||
MessageID string `json:"message_id"`
|
||||
ThreadID string `json:"thread_id,omitempty"`
|
||||
MediaURLs []string `json:"media_urls,omitempty"`
|
||||
Media []InboundMedia `json:"-"`
|
||||
// ResolveUserName performs optional platform enrichment after admission.
|
||||
// UserName remains the safe fallback when the callback is nil or fails.
|
||||
ResolveUserName func(context.Context) string `json:"-"`
|
||||
Raw any `json:"-"`
|
||||
}
|
||||
|
||||
// Session derives the SessionSource from this message.
|
||||
func (m InboundMessage) Session() SessionSource {
|
||||
return SessionSource{
|
||||
Platform: m.Platform,
|
||||
ConnectionID: m.ConnectionID,
|
||||
Domain: m.Domain,
|
||||
ChatType: m.ChatType,
|
||||
ChatID: m.ChatID,
|
||||
UserID: m.UserID,
|
||||
ThreadID: m.ThreadID,
|
||||
}
|
||||
}
|
||||
|
||||
// OutboundMessage 是发送到平台的消息。
|
||||
type OutboundMessage struct {
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType ChatType `json:"chat_type,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
MediaURLs []string `json:"media_urls,omitempty"`
|
||||
ReplyToMsgID string `json:"reply_to_msg_id,omitempty"`
|
||||
Keyboard *InlineKeyboard `json:"keyboard,omitempty"`
|
||||
Card *InteractiveCard `json:"card,omitempty"`
|
||||
}
|
||||
|
||||
// InlineKeyboard 是内联键盘(用于 QQ 审批)。
|
||||
type InlineKeyboard struct {
|
||||
Rows []InlineKeyboardRow `json:"rows"`
|
||||
}
|
||||
|
||||
// InlineKeyboardRow 是一行按钮。
|
||||
type InlineKeyboardRow struct {
|
||||
Buttons []InlineKeyboardButton `json:"buttons"`
|
||||
}
|
||||
|
||||
// InlineKeyboardButton 是一个按钮。
|
||||
type InlineKeyboardButton struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Style int `json:"style,omitempty"` // 0=default, 1=primary, 2=danger
|
||||
CallbackID string `json:"callback_id,omitempty"`
|
||||
}
|
||||
|
||||
// InteractiveCard 是交互式卡片(用于飞书审批/问答)。
|
||||
type InteractiveCard struct {
|
||||
Header string `json:"header"`
|
||||
Elements []InteractiveCardElement `json:"elements"`
|
||||
}
|
||||
|
||||
// InteractiveCardElement 是卡片内元素。
|
||||
type InteractiveCardElement struct {
|
||||
Tag string `json:"tag"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// SendResult 是发送消息的结果。
|
||||
type SendResult struct {
|
||||
MessageID string `json:"message_id,omitempty"`
|
||||
MessageIDs []string `json:"message_ids,omitempty"`
|
||||
Err error `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// DeliveredMessageIDs returns every known delivered message ID, including the
|
||||
// legacy singular MessageID field, in delivery order without duplicates.
|
||||
func (r SendResult) DeliveredMessageIDs() []string {
|
||||
ids := make([]string, 0, len(r.MessageIDs)+1)
|
||||
add := func(id string) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
if slices.Contains(ids, id) {
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
for _, id := range r.MessageIDs {
|
||||
add(id)
|
||||
}
|
||||
add(r.MessageID)
|
||||
return ids
|
||||
}
|
||||
|
||||
// Merge appends delivered IDs from another send while keeping MessageID as the
|
||||
// last delivered ID for callers using the legacy singular field.
|
||||
func (r *SendResult) Merge(delivered SendResult) {
|
||||
for _, id := range delivered.DeliveredMessageIDs() {
|
||||
duplicate := slices.Contains(r.MessageIDs, id)
|
||||
if !duplicate {
|
||||
r.MessageIDs = append(r.MessageIDs, id)
|
||||
}
|
||||
r.MessageID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter 是平台适配器接口,每个平台实现一个。
|
||||
type Adapter interface {
|
||||
// Platform 返回平台标识。
|
||||
Platform() Platform
|
||||
|
||||
// Start 启动适配器,连接平台 gateway。
|
||||
Start(ctx context.Context) error
|
||||
|
||||
// Stop 优雅关闭适配器。
|
||||
Stop() error
|
||||
|
||||
// Send 发送一条出站消息。
|
||||
Send(ctx context.Context, msg OutboundMessage) (SendResult, error)
|
||||
|
||||
// SendTyping 发送"正在输入"状态。
|
||||
SendTyping(ctx context.Context, chatID string) error
|
||||
|
||||
// Messages 返回入站消息通道。
|
||||
Messages() <-chan InboundMessage
|
||||
|
||||
// Name 返回适配器实例名(用于日志)。
|
||||
Name() string
|
||||
}
|
||||
|
||||
// MessageHandler 是 BotGateway 处理入站消息的回调。
|
||||
type MessageHandler func(ctx context.Context, msg InboundMessage)
|
||||
@@ -1,21 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendResultMergeKeepsEveryDeliveredMessageID(t *testing.T) {
|
||||
var got SendResult
|
||||
got.Merge(SendResult{MessageID: "text"})
|
||||
got.Merge(SendResult{MessageID: "media-2", MessageIDs: []string{"media-1", "media-2"}})
|
||||
got.Merge(SendResult{MessageID: "media-2"})
|
||||
|
||||
want := []string{"text", "media-1", "media-2"}
|
||||
if ids := got.DeliveredMessageIDs(); !slices.Equal(ids, want) {
|
||||
t.Fatalf("message IDs = %v, want %v", ids, want)
|
||||
}
|
||||
if got.MessageID != "media-2" {
|
||||
t.Fatalf("compatibility message ID = %q, want media-2", got.MessageID)
|
||||
}
|
||||
}
|
||||
@@ -1,666 +0,0 @@
|
||||
// Package weixin 实现微信 iLink Bot 适配器。
|
||||
// 参考 Hermes Agent 的 weixin adapter:
|
||||
// - getupdates 长轮询
|
||||
// - sendmessage / sendtyping
|
||||
// - context_token 持久化
|
||||
// - 二维码登录
|
||||
// - DM allowlist(默认只对 allowlist 内用户开放 DM;群聊默认关闭)
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/fileutil"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWeixinAPI = "https://ilinkai.weixin.qq.com"
|
||||
getUpdatesPath = "/ilink/bot/getupdates"
|
||||
sendMessagePath = "/ilink/bot/sendmessage"
|
||||
sendTypingPath = "/ilink/bot/sendtyping"
|
||||
uploadMediaPath = "/ilink/bot/getuploadurl"
|
||||
getBotQRPath = "/ilink/bot/get_bot_qrcode"
|
||||
getQRStatusPath = "/ilink/bot/get_qrcode_status"
|
||||
|
||||
ilinkAppID = "bot"
|
||||
ilinkClientVersion = (2 << 16) | (2 << 8)
|
||||
ilinkChannelVersion = "2.2.0"
|
||||
weixinItemText = 1
|
||||
weixinMsgTypeBot = 2
|
||||
weixinMsgStateDone = 2
|
||||
|
||||
weixinHTTPTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var weixinHTTPClient = &http.Client{Timeout: weixinHTTPTimeout}
|
||||
|
||||
// ilinkUpdate 微信 iLink getupdates 返回的更新消息。
|
||||
type ilinkUpdate struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
UpdateType string `json:"update_type"`
|
||||
Message struct {
|
||||
MessageID ilinkString `json:"message_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType string `json:"chat_type"`
|
||||
From struct {
|
||||
UserID string `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
} `json:"from"`
|
||||
Text string `json:"text"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
type ilinkMessage struct {
|
||||
MessageID ilinkString `json:"message_id"`
|
||||
FromUserID string `json:"from_user_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
RoomID string `json:"room_id"`
|
||||
ChatRoomID string `json:"chat_room_id"`
|
||||
ContextToken string `json:"context_token"`
|
||||
MsgType int `json:"msg_type"`
|
||||
ItemList []struct {
|
||||
Type int `json:"type"`
|
||||
TextItem struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"text_item"`
|
||||
} `json:"item_list"`
|
||||
}
|
||||
|
||||
type ilinkResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
Errcode int `json:"errcode"`
|
||||
Errmsg string `json:"errmsg"`
|
||||
Updates []ilinkUpdate `json:"updates"`
|
||||
Msgs []ilinkMessage `json:"msgs"`
|
||||
HasMore bool `json:"has_more"`
|
||||
ContextToken string `json:"context_token"`
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
LongpollingTimeoutMs int `json:"longpolling_timeout_ms"`
|
||||
}
|
||||
|
||||
type ilinkString string
|
||||
|
||||
func (s *ilinkString) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
*s = ""
|
||||
return nil
|
||||
}
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
*s = ilinkString(str)
|
||||
return nil
|
||||
}
|
||||
var num json.Number
|
||||
if err := json.Unmarshal(data, &num); err == nil {
|
||||
*s = ilinkString(num.String())
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("ilink string: expected string or number, got %s", string(data))
|
||||
}
|
||||
|
||||
// adapter 微信适配器实现。
|
||||
type adapter struct {
|
||||
cfg config.WeixinBotConfig
|
||||
logger *slog.Logger
|
||||
msgCh chan bot.InboundMessage
|
||||
cancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
contextTokens map[string]string
|
||||
syncBuf string
|
||||
lastUpdateID int64
|
||||
pollReadyOnce sync.Once
|
||||
lastPollLog time.Time
|
||||
}
|
||||
|
||||
// New 创建微信 Bot 适配器。
|
||||
func New(cfg config.WeixinBotConfig, logger *slog.Logger) bot.Adapter {
|
||||
return &adapter{
|
||||
cfg: cfg,
|
||||
logger: logger.With("platform", "weixin"),
|
||||
contextTokens: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *adapter) Platform() bot.Platform { return bot.PlatformWeixin }
|
||||
func (a *adapter) Name() string { return "weixin" }
|
||||
|
||||
func (a *adapter) Start(ctx context.Context) error {
|
||||
a.msgCh = make(chan bot.InboundMessage, 64)
|
||||
ctx, a.cancel = context.WithCancel(ctx)
|
||||
a.loadContextTokens()
|
||||
if a.token() == "" {
|
||||
return a.tokenMissingError()
|
||||
}
|
||||
|
||||
a.logger.Info("weixin polling started", "account", logHash(a.accountID()), "api_base", a.apiBase())
|
||||
go a.pollLoop(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *adapter) Stop() error {
|
||||
if a.cancel != nil {
|
||||
a.cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *adapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
return a.sendMessage(ctx, msg)
|
||||
}
|
||||
|
||||
func (a *adapter) SendTyping(ctx context.Context, chatID string) error {
|
||||
return a.sendTyping(ctx, chatID)
|
||||
}
|
||||
|
||||
func (a *adapter) Messages() <-chan bot.InboundMessage {
|
||||
return a.msgCh
|
||||
}
|
||||
|
||||
// SendText sends one plain text message to a saved Weixin iLink conversation.
|
||||
// It is used by desktop settings as an actual connection test.
|
||||
func SendText(ctx context.Context, cfg config.WeixinBotConfig, chatID, text string) (bot.SendResult, error) {
|
||||
a := &adapter{cfg: cfg, logger: slog.Default().With("platform", "weixin"), contextTokens: make(map[string]string)}
|
||||
return a.sendMessage(ctx, bot.OutboundMessage{ChatID: chatID, Text: text})
|
||||
}
|
||||
|
||||
// token 从环境变量获取微信 token。
|
||||
func (a *adapter) token() string {
|
||||
if token := os.Getenv(a.cfg.TokenEnv); token != "" {
|
||||
return token
|
||||
}
|
||||
account, _ := loadSavedAccount(a.accountID())
|
||||
if account.Token != "" {
|
||||
return account.Token
|
||||
}
|
||||
if a.cfg.AccountID == "" {
|
||||
account, _ = loadAnySavedAccount()
|
||||
return account.Token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *adapter) tokenMissingError() error {
|
||||
if strings.TrimSpace(a.cfg.TokenEnv) == "" {
|
||||
return fmt.Errorf("weixin token is not configured and no saved weixin account is available")
|
||||
}
|
||||
return fmt.Errorf("%s not set and no saved weixin account is available", a.cfg.TokenEnv)
|
||||
}
|
||||
|
||||
// apiBase 返回 API base URL。
|
||||
func (a *adapter) apiBase() string {
|
||||
if a.cfg.APIBase != "" {
|
||||
return a.cfg.APIBase
|
||||
}
|
||||
account, _ := loadSavedAccount(a.accountID())
|
||||
if account.BaseURL != "" {
|
||||
return strings.TrimRight(account.BaseURL, "/")
|
||||
}
|
||||
return defaultWeixinAPI
|
||||
}
|
||||
|
||||
func (a *adapter) accountID() string {
|
||||
if a.cfg.AccountID != "" {
|
||||
return a.cfg.AccountID
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
func (a *adapter) contextToken(chatID string) string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.contextTokens[chatID]
|
||||
}
|
||||
|
||||
func (a *adapter) setContextToken(chatID, token string) {
|
||||
a.mu.Lock()
|
||||
if token == "" {
|
||||
delete(a.contextTokens, chatID)
|
||||
} else {
|
||||
a.contextTokens[chatID] = token
|
||||
}
|
||||
a.mu.Unlock()
|
||||
a.saveContextTokens()
|
||||
}
|
||||
|
||||
func (a *adapter) tokenStorePath() string {
|
||||
root := config.MemoryUserDir()
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(weixinAccountDir(root), a.accountID()+".context-tokens.json")
|
||||
}
|
||||
|
||||
func (a *adapter) loadContextTokens() {
|
||||
path := a.tokenStorePath()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
data, err := fileencoding.ReadFileUTF8(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var tokens map[string]string
|
||||
if err := json.Unmarshal(data, &tokens); err != nil {
|
||||
a.logger.Warn("failed to load weixin context tokens", "err", err)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.contextTokens = tokens
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *adapter) saveContextTokens() {
|
||||
path := a.tokenStorePath()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
a.logger.Warn("failed to create weixin token dir", "err", err)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
data, err := json.MarshalIndent(a.contextTokens, "", " ")
|
||||
a.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := fileutil.AtomicWriteFile(path, data, 0o600); err != nil {
|
||||
a.logger.Warn("failed to save weixin context tokens", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ilinkGET(ctx context.Context, baseURL, endpoint string) (map[string]any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/"+strings.TrimLeft(endpoint, "/"), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("iLink-App-Id", ilinkAppID)
|
||||
req.Header.Set("iLink-App-ClientVersion", fmt.Sprintf("%d", ilinkClientVersion))
|
||||
resp, err := weixinHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
if len(data) > 200 {
|
||||
data = data[:200]
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pollLoop 长轮询获取更新。
|
||||
func (a *adapter) pollLoop(ctx context.Context) {
|
||||
// 启动时短暂等待让登录完成
|
||||
if !bot.SleepCtx(ctx, 2*time.Second) {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
updates, err := a.getUpdates(ctx)
|
||||
if err != nil {
|
||||
a.logger.Error("getupdates failed", "err", err)
|
||||
if !bot.SleepCtx(ctx, 5*time.Second) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for _, upd := range updates {
|
||||
a.handleUpdate(upd)
|
||||
}
|
||||
|
||||
// 没有更新时短暂等待
|
||||
if len(updates) == 0 {
|
||||
if !bot.SleepCtx(ctx, 500*time.Millisecond) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getUpdates 调用微信 iLink getupdates API。
|
||||
func (a *adapter) getUpdates(ctx context.Context) ([]ilinkUpdate, error) {
|
||||
tok := a.token()
|
||||
if tok == "" {
|
||||
return nil, a.tokenMissingError()
|
||||
}
|
||||
|
||||
url := a.apiBase() + getUpdatesPath
|
||||
|
||||
a.mu.Lock()
|
||||
payload := map[string]any{
|
||||
"get_updates_buf": a.syncBuf,
|
||||
"base_info": map[string]string{
|
||||
"channel_version": ilinkChannelVersion,
|
||||
},
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setIlinkHeaders(req, tok, body)
|
||||
|
||||
resp, err := weixinHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result ilinkResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Ret != 0 || result.Errcode != 0 {
|
||||
return nil, fmt.Errorf("getupdates error ret=%d errcode=%d: %s", result.Ret, result.Errcode, result.Errmsg)
|
||||
}
|
||||
a.pollReadyOnce.Do(func() {
|
||||
a.logger.Info("weixin getupdates ready", "account", logHash(a.accountID()), "api_base", a.apiBase())
|
||||
})
|
||||
a.logPollHealth(result)
|
||||
|
||||
a.mu.Lock()
|
||||
if result.GetUpdatesBuf != "" {
|
||||
a.syncBuf = result.GetUpdatesBuf
|
||||
}
|
||||
if len(result.Updates) > 0 {
|
||||
last := result.Updates[len(result.Updates)-1]
|
||||
a.lastUpdateID = last.UpdateID
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if len(result.Msgs) > 0 {
|
||||
for _, msg := range result.Msgs {
|
||||
a.handleIlinkMessage(msg)
|
||||
}
|
||||
}
|
||||
return result.Updates, nil
|
||||
}
|
||||
|
||||
func (a *adapter) logPollHealth(result ilinkResponse) {
|
||||
shouldLog := len(result.Updates) > 0 || len(result.Msgs) > 0
|
||||
a.mu.Lock()
|
||||
if !shouldLog && time.Since(a.lastPollLog) >= 5*time.Minute {
|
||||
shouldLog = true
|
||||
}
|
||||
if shouldLog {
|
||||
a.lastPollLog = time.Now()
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if !shouldLog {
|
||||
return
|
||||
}
|
||||
a.logger.Info("weixin getupdates heartbeat",
|
||||
"updates", len(result.Updates),
|
||||
"msgs", len(result.Msgs),
|
||||
"has_more", result.HasMore,
|
||||
"timeout_ms", result.LongpollingTimeoutMs)
|
||||
}
|
||||
|
||||
// handleUpdate 处理单条微信更新消息。
|
||||
func (a *adapter) handleUpdate(upd ilinkUpdate) {
|
||||
if upd.UpdateType != "message" {
|
||||
a.logger.Info("weixin update ignored", "reason", "non_message", "update_type", upd.UpdateType)
|
||||
return
|
||||
}
|
||||
|
||||
m := upd.Message
|
||||
chatType := bot.ChatDM
|
||||
if m.ChatType == "group" {
|
||||
chatType = bot.ChatGroup
|
||||
}
|
||||
|
||||
ib := bot.InboundMessage{
|
||||
Platform: bot.PlatformWeixin,
|
||||
ChatType: chatType,
|
||||
ChatID: m.ChatID,
|
||||
UserID: m.From.UserID,
|
||||
UserName: m.From.UserName,
|
||||
Text: m.Text,
|
||||
MessageID: string(m.MessageID),
|
||||
}
|
||||
|
||||
select {
|
||||
case a.msgCh <- ib:
|
||||
a.logger.Info("weixin inbound queued", "source", "update", "chat_type", chatType, "chat", logHash(ib.ChatID), "user", logHash(ib.UserID), "message", logHash(ib.MessageID), "text_chars", len([]rune(ib.Text)))
|
||||
default:
|
||||
a.logger.Warn("weixin message channel full")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *adapter) handleIlinkMessage(m ilinkMessage) {
|
||||
if m.FromUserID == "" || m.FromUserID == a.accountID() {
|
||||
a.logger.Info("weixin message ignored", "reason", "self_or_missing_sender", "from", logHash(m.FromUserID), "message", logHash(string(m.MessageID)))
|
||||
return
|
||||
}
|
||||
text := extractIlinkText(m.ItemList)
|
||||
if text == "" {
|
||||
a.logger.Info("weixin message ignored", "reason", "empty_text", "from", logHash(m.FromUserID), "message", logHash(string(m.MessageID)))
|
||||
return
|
||||
}
|
||||
chatType, chatID := guessIlinkChat(m, a.accountID())
|
||||
if chatID == "" {
|
||||
a.logger.Info("weixin message ignored", "reason", "missing_chat", "from", logHash(m.FromUserID), "message", logHash(string(m.MessageID)))
|
||||
return
|
||||
}
|
||||
if m.ContextToken != "" {
|
||||
a.setContextToken(chatID, m.ContextToken)
|
||||
}
|
||||
ib := bot.InboundMessage{
|
||||
Platform: bot.PlatformWeixin,
|
||||
ChatType: chatType,
|
||||
ChatID: chatID,
|
||||
UserID: m.FromUserID,
|
||||
UserName: m.FromUserID,
|
||||
Text: text,
|
||||
MessageID: string(m.MessageID),
|
||||
}
|
||||
select {
|
||||
case a.msgCh <- ib:
|
||||
a.logger.Info("weixin inbound queued", "source", "message", "chat_type", chatType, "chat", logHash(ib.ChatID), "user", logHash(ib.UserID), "message", logHash(ib.MessageID), "text_chars", len([]rune(ib.Text)))
|
||||
default:
|
||||
a.logger.Warn("weixin message channel full")
|
||||
}
|
||||
}
|
||||
|
||||
func logHash(id string) string {
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(id))
|
||||
return hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
func extractIlinkText(items []struct {
|
||||
Type int `json:"type"`
|
||||
TextItem struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"text_item"`
|
||||
}) string {
|
||||
var out []string
|
||||
for _, item := range items {
|
||||
if item.Type == weixinItemText && item.TextItem.Text != "" {
|
||||
out = append(out, item.TextItem.Text)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
|
||||
func guessIlinkChat(m ilinkMessage, accountID string) (bot.ChatType, string) {
|
||||
roomID := firstNonEmptyString(m.RoomID, m.ChatRoomID)
|
||||
if roomID != "" {
|
||||
return bot.ChatGroup, roomID
|
||||
}
|
||||
if m.ToUserID != "" && accountID != "" && m.ToUserID != accountID && m.MsgType == 1 {
|
||||
return bot.ChatGroup, m.ToUserID
|
||||
}
|
||||
return bot.ChatDM, m.FromUserID
|
||||
}
|
||||
|
||||
func setIlinkHeaders(req *http.Request, token string, body []byte) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
req.Header.Set("X-WECHAT-UIN", randomWechatUIN())
|
||||
req.Header.Set("iLink-App-Id", ilinkAppID)
|
||||
req.Header.Set("iLink-App-ClientVersion", fmt.Sprintf("%d", ilinkClientVersion))
|
||||
}
|
||||
|
||||
func randomWechatUIN() string {
|
||||
var b [4]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", time.Now().UnixNano()))
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", uint32(b[0])<<24|uint32(b[1])<<16|uint32(b[2])<<8|uint32(b[3])))
|
||||
}
|
||||
|
||||
func firstNonEmptyString(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sendMessage 使用微信 iLink sendmessage API 发送消息。
|
||||
func (a *adapter) sendMessage(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
tok := a.token()
|
||||
if tok == "" {
|
||||
return bot.SendResult{}, a.tokenMissingError()
|
||||
}
|
||||
|
||||
url := a.apiBase() + sendMessagePath
|
||||
|
||||
payload := map[string]any{
|
||||
"base_info": map[string]string{"channel_version": ilinkChannelVersion},
|
||||
"msg": map[string]any{
|
||||
"from_user_id": "",
|
||||
"to_user_id": msg.ChatID,
|
||||
"client_id": fmt.Sprintf("reasonix-%d", time.Now().UnixNano()),
|
||||
"message_type": weixinMsgTypeBot,
|
||||
"message_state": weixinMsgStateDone,
|
||||
"item_list": []map[string]any{
|
||||
{"type": weixinItemText, "text_item": map[string]string{"text": msg.Text}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if contextToken := a.contextToken(msg.ChatID); contextToken != "" {
|
||||
if m, ok := payload["msg"].(map[string]any); ok {
|
||||
m["context_token"] = contextToken
|
||||
}
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return bot.SendResult{}, err
|
||||
}
|
||||
setIlinkHeaders(req, tok, body)
|
||||
|
||||
resp, err := weixinHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return bot.SendResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Ret int `json:"ret"`
|
||||
Errcode int `json:"errcode"`
|
||||
Errmsg string `json:"errmsg"`
|
||||
MessageID ilinkString `json:"message_id"`
|
||||
}
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return bot.SendResult{}, err
|
||||
}
|
||||
if result.Ret != 0 || result.Errcode != 0 {
|
||||
if a.contextToken(msg.ChatID) != "" {
|
||||
a.setContextToken(msg.ChatID, "")
|
||||
return a.sendMessage(ctx, msg)
|
||||
}
|
||||
return bot.SendResult{}, fmt.Errorf("sendmessage error ret=%d errcode=%d: %s", result.Ret, result.Errcode, result.Errmsg)
|
||||
}
|
||||
|
||||
return bot.SendResult{MessageID: string(result.MessageID)}, nil
|
||||
}
|
||||
|
||||
// sendTyping 发送"正在输入"状态。
|
||||
func (a *adapter) sendTyping(ctx context.Context, chatID string) error {
|
||||
tok := a.token()
|
||||
if tok == "" {
|
||||
return a.tokenMissingError()
|
||||
}
|
||||
|
||||
url := a.apiBase() + sendTypingPath
|
||||
|
||||
payload := map[string]any{
|
||||
"base_info": map[string]string{"channel_version": ilinkChannelVersion},
|
||||
"ilink_user_id": chatID,
|
||||
"status": 1,
|
||||
}
|
||||
if contextToken := a.contextToken(chatID); contextToken != "" {
|
||||
payload["context_token"] = contextToken
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setIlinkHeaders(req, tok, body)
|
||||
|
||||
resp, err := weixinHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Ret int `json:"ret"`
|
||||
Errcode int `json:"errcode"`
|
||||
Errmsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Ret != 0 || result.Errcode != 0 {
|
||||
return fmt.Errorf("sendtyping error ret=%d errcode=%d: %s", result.Ret, result.Errcode, result.Errmsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/fileutil"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
)
|
||||
|
||||
type savedAccount struct {
|
||||
Token string `json:"token"`
|
||||
BaseURL string `json:"base_url"`
|
||||
UserID string `json:"user_id"`
|
||||
SavedAt string `json:"saved_at"`
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
AccountID string
|
||||
Token string
|
||||
BaseURL string
|
||||
UserID string
|
||||
}
|
||||
|
||||
type LoginSession struct {
|
||||
SessionKey string
|
||||
QRCode string
|
||||
QRCodeURL string
|
||||
BaseURL string
|
||||
StartedAt time.Time
|
||||
}
|
||||
|
||||
func weixinAccountDir(root string) string {
|
||||
return filepath.Join(root, "weixin", "accounts")
|
||||
}
|
||||
|
||||
func savedAccountPath(accountID string) string {
|
||||
root := config.MemoryUserDir()
|
||||
if root == "" || accountID == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(weixinAccountDir(root), accountID+".json")
|
||||
}
|
||||
|
||||
func loadSavedAccount(accountID string) (savedAccount, error) {
|
||||
path := savedAccountPath(accountID)
|
||||
if path == "" {
|
||||
return savedAccount{}, fmt.Errorf("reasonix user config dir is unavailable")
|
||||
}
|
||||
data, err := fileencoding.ReadFileUTF8(path)
|
||||
if err != nil {
|
||||
return savedAccount{}, err
|
||||
}
|
||||
var account savedAccount
|
||||
if err := json.Unmarshal(data, &account); err != nil {
|
||||
return savedAccount{}, err
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func loadAnySavedAccount() (savedAccount, error) {
|
||||
root := config.MemoryUserDir()
|
||||
if root == "" {
|
||||
return savedAccount{}, fmt.Errorf("reasonix user config dir is unavailable")
|
||||
}
|
||||
entries, err := os.ReadDir(weixinAccountDir(root))
|
||||
if err != nil {
|
||||
return savedAccount{}, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") || strings.Contains(entry.Name(), "context-tokens") {
|
||||
continue
|
||||
}
|
||||
accountID := strings.TrimSuffix(entry.Name(), ".json")
|
||||
account, err := loadSavedAccount(accountID)
|
||||
if err == nil && account.Token != "" {
|
||||
return account, nil
|
||||
}
|
||||
}
|
||||
return savedAccount{}, fmt.Errorf("no saved weixin account")
|
||||
}
|
||||
|
||||
func HasSavedAccount(accountID string) bool {
|
||||
if accountID != "" {
|
||||
account, err := loadSavedAccount(accountID)
|
||||
return err == nil && account.Token != ""
|
||||
}
|
||||
account, err := loadSavedAccount("default")
|
||||
if err == nil && account.Token != "" {
|
||||
return true
|
||||
}
|
||||
account, err = loadAnySavedAccount()
|
||||
return err == nil && account.Token != ""
|
||||
}
|
||||
|
||||
func saveAccount(accountID string, account savedAccount) error {
|
||||
path := savedAccountPath(accountID)
|
||||
if path == "" {
|
||||
return fmt.Errorf("reasonix user config dir is unavailable")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(account, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Atomic write: a truncated credentials file silently breaks login.
|
||||
return fileutil.AtomicWriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
func Login(ctx context.Context, out io.Writer, timeout time.Duration) (*LoginResult, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = 8 * time.Minute
|
||||
}
|
||||
session, err := StartLogin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out != nil {
|
||||
fmt.Fprintln(out, "请使用微信扫描以下二维码链接:")
|
||||
if session.QRCodeURL != "" {
|
||||
fmt.Fprintln(out, session.QRCodeURL)
|
||||
} else {
|
||||
fmt.Fprintln(out, session.QRCode)
|
||||
}
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
result, status, err := PollLogin(ctx, session)
|
||||
if err != nil {
|
||||
if out != nil {
|
||||
fmt.Fprintf(out, "二维码状态查询失败: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if result != nil {
|
||||
return result, nil
|
||||
}
|
||||
if out != nil {
|
||||
switch status {
|
||||
case "wait", "", "<nil>":
|
||||
fmt.Fprint(out, ".")
|
||||
case "scaned":
|
||||
fmt.Fprintln(out, "\n已扫码,请在微信里确认...")
|
||||
default:
|
||||
fmt.Fprintf(out, "\n二维码状态: %s\n", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("weixin login timed out")
|
||||
}
|
||||
|
||||
func StartLogin(ctx context.Context) (*LoginSession, error) {
|
||||
qrResp, err := ilinkGET(ctx, defaultWeixinAPI, getBotQRPath+"?bot_type=3")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch qr code: %w", err)
|
||||
}
|
||||
qrcode := fmt.Sprint(qrResp["qrcode"])
|
||||
qrcodeURL := fmt.Sprint(qrResp["qrcode_img_content"])
|
||||
if qrcode == "" || qrcode == "<nil>" {
|
||||
return nil, fmt.Errorf("weixin qr response missing qrcode")
|
||||
}
|
||||
if qrcodeURL == "<nil>" {
|
||||
qrcodeURL = ""
|
||||
}
|
||||
return &LoginSession{
|
||||
SessionKey: qrcode,
|
||||
QRCode: qrcode,
|
||||
QRCodeURL: qrcodeURL,
|
||||
BaseURL: defaultWeixinAPI,
|
||||
StartedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func PollLogin(ctx context.Context, session *LoginSession) (*LoginResult, string, error) {
|
||||
if session == nil || session.QRCode == "" {
|
||||
return nil, "", fmt.Errorf("weixin login session is missing")
|
||||
}
|
||||
baseURL := session.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = defaultWeixinAPI
|
||||
}
|
||||
statusResp, err := ilinkGET(ctx, baseURL, getQRStatusPath+"?qrcode="+session.QRCode)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
status := fmt.Sprint(statusResp["status"])
|
||||
switch status {
|
||||
case "wait", "", "<nil>":
|
||||
return nil, status, nil
|
||||
case "scaned":
|
||||
return nil, status, nil
|
||||
case "scaned_but_redirect":
|
||||
if host := fmt.Sprint(statusResp["redirect_host"]); host != "" && host != "<nil>" {
|
||||
session.BaseURL = "https://" + host
|
||||
}
|
||||
return nil, status, nil
|
||||
case "confirmed":
|
||||
accountID := fmt.Sprint(statusResp["ilink_bot_id"])
|
||||
token := fmt.Sprint(statusResp["bot_token"])
|
||||
userID := fmt.Sprint(statusResp["ilink_user_id"])
|
||||
respBaseURL := fmt.Sprint(statusResp["baseurl"])
|
||||
if respBaseURL == "" || respBaseURL == "<nil>" {
|
||||
respBaseURL = defaultWeixinAPI
|
||||
}
|
||||
if accountID == "" || accountID == "<nil>" || token == "" || token == "<nil>" {
|
||||
return nil, status, fmt.Errorf("weixin qr confirmed but credential payload is incomplete")
|
||||
}
|
||||
account := savedAccount{
|
||||
Token: token,
|
||||
BaseURL: respBaseURL,
|
||||
UserID: userID,
|
||||
SavedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := saveAccount(accountID, account); err != nil {
|
||||
return nil, status, err
|
||||
}
|
||||
if err := saveAccount("default", account); err != nil {
|
||||
return nil, status, err
|
||||
}
|
||||
return &LoginResult{AccountID: accountID, Token: token, BaseURL: respBaseURL, UserID: userID}, status, nil
|
||||
case "expired":
|
||||
return nil, status, fmt.Errorf("weixin qr code expired; rerun login")
|
||||
default:
|
||||
return nil, status, nil
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/testenv"
|
||||
)
|
||||
|
||||
func TestStartReturnsMissingToken(t *testing.T) {
|
||||
isolateWeixinUserConfig(t)
|
||||
t.Setenv("WEIXIN_TEST_TOKEN", "")
|
||||
a := New(config.WeixinBotConfig{
|
||||
TokenEnv: "WEIXIN_TEST_TOKEN",
|
||||
AccountID: "missing-account",
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
err := a.Start(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "WEIXIN_TEST_TOKEN") {
|
||||
t.Fatalf("Start error = %v, want missing token env", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTextPostsIlinkMessage(t *testing.T) {
|
||||
t.Setenv("WEIXIN_TEST_TOKEN", "token-1")
|
||||
var gotAuth string
|
||||
var gotPayload map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != sendMessagePath {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ret": 0,
|
||||
"errcode": 0,
|
||||
"message_id": "wx-msg-1",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := SendText(context.Background(), config.WeixinBotConfig{
|
||||
TokenEnv: "WEIXIN_TEST_TOKEN",
|
||||
APIBase: server.URL,
|
||||
}, "chat-1", "hello weixin")
|
||||
if err != nil {
|
||||
t.Fatalf("SendText: %v", err)
|
||||
}
|
||||
if result.MessageID != "wx-msg-1" {
|
||||
t.Fatalf("message id = %q, want wx-msg-1", result.MessageID)
|
||||
}
|
||||
if gotAuth != "Bearer token-1" {
|
||||
t.Fatalf("Authorization = %q, want Bearer token-1", gotAuth)
|
||||
}
|
||||
msg, ok := gotPayload["msg"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("payload msg = %#v, want object", gotPayload["msg"])
|
||||
}
|
||||
if msg["to_user_id"] != "chat-1" || msg["message_type"] != float64(weixinMsgTypeBot) || msg["message_state"] != float64(weixinMsgStateDone) {
|
||||
t.Fatalf("msg metadata = %#v", msg)
|
||||
}
|
||||
items, ok := msg["item_list"].([]any)
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("item_list = %#v, want one text item", msg["item_list"])
|
||||
}
|
||||
item, ok := items[0].(map[string]any)
|
||||
if !ok || item["type"] != float64(weixinItemText) {
|
||||
t.Fatalf("item = %#v, want text item", items[0])
|
||||
}
|
||||
textItem, ok := item["text_item"].(map[string]any)
|
||||
if !ok || textItem["text"] != "hello weixin" {
|
||||
t.Fatalf("text item = %#v, want hello weixin", item["text_item"])
|
||||
}
|
||||
}
|
||||
|
||||
func isolateWeixinUserConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
||||
}
|
||||
|
||||
func TestLogPollHealthThrottlesEmptyPolls(t *testing.T) {
|
||||
a := &adapter{logger: slog.Default().With("platform", "weixin")}
|
||||
a.logPollHealth(ilinkResponse{})
|
||||
first := a.lastPollLog
|
||||
if first.IsZero() {
|
||||
t.Fatal("first empty poll should update heartbeat timestamp")
|
||||
}
|
||||
a.logPollHealth(ilinkResponse{})
|
||||
if !a.lastPollLog.Equal(first) {
|
||||
t.Fatalf("second empty poll updated heartbeat timestamp: got %v want %v", a.lastPollLog, first)
|
||||
}
|
||||
stale := time.Now().Add(-6 * time.Minute)
|
||||
a.lastPollLog = stale
|
||||
a.logPollHealth(ilinkResponse{})
|
||||
if !a.lastPollLog.After(stale) {
|
||||
t.Fatalf("stale empty poll did not refresh heartbeat timestamp: got %v after %v", a.lastPollLog, stale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogPollHealthLogsNonEmptyPolls(t *testing.T) {
|
||||
a := &adapter{logger: slog.Default().With("platform", "weixin")}
|
||||
a.logPollHealth(ilinkResponse{Msgs: []ilinkMessage{{MessageID: "msg-1"}}})
|
||||
if a.lastPollLog.IsZero() {
|
||||
t.Fatal("non-empty poll should update heartbeat timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUpdatesAcceptsNumericIlinkMessageID(t *testing.T) {
|
||||
t.Setenv("WEIXIN_TEST_TOKEN", "token-1")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != getUpdatesPath {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ret": 0,
|
||||
"errcode": 0,
|
||||
"msgs": []map[string]any{
|
||||
{
|
||||
"message_id": 123456789,
|
||||
"from_user_id": "wx-user-1",
|
||||
"to_user_id": "bot-account",
|
||||
"msg_type": 1,
|
||||
"item_list": []map[string]any{
|
||||
{"type": weixinItemText, "text_item": map[string]string{"text": "hello"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
a := &adapter{
|
||||
cfg: config.WeixinBotConfig{
|
||||
TokenEnv: "WEIXIN_TEST_TOKEN",
|
||||
APIBase: server.URL,
|
||||
AccountID: "bot-account",
|
||||
},
|
||||
logger: slog.Default().With("platform", "weixin"),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
contextTokens: make(map[string]string),
|
||||
}
|
||||
updates, err := a.getUpdates(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("getUpdates: %v", err)
|
||||
}
|
||||
if len(updates) != 0 {
|
||||
t.Fatalf("updates = %d, want 0", len(updates))
|
||||
}
|
||||
select {
|
||||
case msg := <-a.msgCh:
|
||||
if msg.MessageID != "123456789" || msg.UserID != "wx-user-1" || msg.Text != "hello" {
|
||||
t.Fatalf("message = %+v, want numeric id converted and text preserved", msg)
|
||||
}
|
||||
case <-context.Background().Done():
|
||||
t.Fatal("unreachable")
|
||||
default:
|
||||
t.Fatal("expected queued inbound message")
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package botruntime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/testenv"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testenv.RunWithIsolatedUserState(m)
|
||||
}
|
||||
@@ -1,714 +0,0 @@
|
||||
package botruntime
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/bot/feishu"
|
||||
"reasonix/internal/bot/qq"
|
||||
"reasonix/internal/bot/weixin"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// EnabledPlatforms resolves the requested channel list against the saved config.
|
||||
// "lark" is a domain alias for the Feishu adapter platform.
|
||||
func EnabledPlatforms(cfg *config.Config, channels []string) (map[bot.Platform]bool, []string) {
|
||||
enabled := make(map[bot.Platform]bool)
|
||||
var warnings []string
|
||||
if len(channels) > 0 {
|
||||
for _, ch := range channels {
|
||||
ch = strings.TrimSpace(ch)
|
||||
switch bot.Platform(ch) {
|
||||
case bot.PlatformQQ:
|
||||
enabled[bot.PlatformQQ] = PlatformConfigured(cfg, bot.PlatformQQ)
|
||||
case bot.PlatformFeishu:
|
||||
enabled[bot.PlatformFeishu] = PlatformConfigured(cfg, bot.PlatformFeishu)
|
||||
case bot.PlatformWeixin:
|
||||
enabled[bot.PlatformWeixin] = PlatformConfigured(cfg, bot.PlatformWeixin)
|
||||
default:
|
||||
if strings.EqualFold(ch, "lark") {
|
||||
enabled[bot.PlatformFeishu] = PlatformConfigured(cfg, bot.PlatformFeishu)
|
||||
} else if ch != "" {
|
||||
warnings = append(warnings, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
return enabled, warnings
|
||||
}
|
||||
enabled[bot.PlatformQQ] = PlatformConfigured(cfg, bot.PlatformQQ)
|
||||
enabled[bot.PlatformFeishu] = PlatformConfigured(cfg, bot.PlatformFeishu)
|
||||
enabled[bot.PlatformWeixin] = PlatformConfigured(cfg, bot.PlatformWeixin)
|
||||
return enabled, warnings
|
||||
}
|
||||
|
||||
// RequestedFeishuDomains returns the Feishu-family domains the caller explicitly
|
||||
// named ("feishu"/"lark"), or nil when neither was requested (no restriction).
|
||||
func RequestedFeishuDomains(channels []string) map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, ch := range channels {
|
||||
switch {
|
||||
case strings.EqualFold(strings.TrimSpace(ch), string(bot.PlatformFeishu)):
|
||||
domains["feishu"] = true
|
||||
case strings.EqualFold(strings.TrimSpace(ch), "lark"):
|
||||
domains["lark"] = true
|
||||
}
|
||||
}
|
||||
if len(domains) == 0 {
|
||||
return nil
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
func feishuDomainKey(domain string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(domain), "lark") {
|
||||
return "lark"
|
||||
}
|
||||
return "feishu"
|
||||
}
|
||||
|
||||
func HasEnabledPlatform(enabled map[bot.Platform]bool) bool {
|
||||
for _, value := range enabled {
|
||||
if value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func PlatformConfigured(cfg *config.Config, platform bot.Platform) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
switch platform {
|
||||
case bot.PlatformQQ:
|
||||
if cfg.Bot.QQ.Enabled {
|
||||
return true
|
||||
}
|
||||
case bot.PlatformFeishu:
|
||||
if cfg.Bot.Feishu.Enabled {
|
||||
return true
|
||||
}
|
||||
case bot.PlatformWeixin:
|
||||
if cfg.Bot.Weixin.Enabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, conn := range cfg.Bot.Connections {
|
||||
if conn.Enabled && bot.Platform(strings.TrimSpace(conn.Provider)) == platform {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ChannelConfigs(connections []config.BotConnectionConfig, includeModel bool, includeWorkspaceRoot bool) map[bot.Platform]bot.ChannelConfig {
|
||||
if len(connections) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[bot.Platform]bot.ChannelConfig)
|
||||
for _, conn := range connections {
|
||||
if !conn.Enabled {
|
||||
continue
|
||||
}
|
||||
plat := bot.Platform(strings.TrimSpace(conn.Provider))
|
||||
switch plat {
|
||||
case bot.PlatformQQ, bot.PlatformFeishu, bot.PlatformWeixin:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
channel := out[plat]
|
||||
if includeModel {
|
||||
channel.Model = strings.TrimSpace(conn.Model)
|
||||
}
|
||||
if includeWorkspaceRoot {
|
||||
channel.WorkspaceRoot = strings.TrimSpace(conn.WorkspaceRoot)
|
||||
}
|
||||
if value := normalizeToolApprovalMode(conn.ToolApprovalMode); value != "" {
|
||||
channel.ToolApprovalMode = value
|
||||
}
|
||||
if channel.Model != "" || channel.WorkspaceRoot != "" || channel.ToolApprovalMode != "" {
|
||||
out[plat] = channel
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ConnectionChannelConfigs(connections []config.BotConnectionConfig, includeModel bool, includeWorkspaceRoot bool) map[string]bot.ChannelConfig {
|
||||
if len(connections) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bot.ChannelConfig)
|
||||
for _, conn := range connections {
|
||||
if !conn.Enabled {
|
||||
continue
|
||||
}
|
||||
id := ConnectionRuntimeID(conn)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
var channel bot.ChannelConfig
|
||||
if includeModel {
|
||||
channel.Model = strings.TrimSpace(conn.Model)
|
||||
}
|
||||
if includeWorkspaceRoot {
|
||||
channel.WorkspaceRoot = strings.TrimSpace(conn.WorkspaceRoot)
|
||||
channel.SessionMappings = botSessionMappings(conn.SessionMappings)
|
||||
}
|
||||
if value := normalizeToolApprovalMode(conn.ToolApprovalMode); value != "" {
|
||||
channel.ToolApprovalMode = value
|
||||
}
|
||||
if channel.Model != "" || channel.WorkspaceRoot != "" || channel.ToolApprovalMode != "" || len(channel.SessionMappings) > 0 {
|
||||
out[id] = channel
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ConnectionAccessConfigs(cfg *config.Config) map[string]bot.AccessConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bot.AccessConfig)
|
||||
if BotAccessActive(cfg.Bot.QQ.Access) {
|
||||
out[string(bot.PlatformQQ)] = botAccessConfig(cfg.Bot.QQ.Access)
|
||||
}
|
||||
for _, conn := range cfg.Bot.Connections {
|
||||
if !conn.Enabled {
|
||||
continue
|
||||
}
|
||||
id := ConnectionRuntimeID(conn)
|
||||
if id == "" || !BotAccessActive(conn.Access) {
|
||||
continue
|
||||
}
|
||||
out[id] = botAccessConfig(conn.Access)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func BotAccessActive(access config.BotAccessConfig) bool {
|
||||
return access.Enabled ||
|
||||
access.AllowAll ||
|
||||
access.PairingEnabled ||
|
||||
len(access.Users) > 0 ||
|
||||
len(access.Groups) > 0 ||
|
||||
len(access.Approvers) > 0 ||
|
||||
len(access.Admins) > 0
|
||||
}
|
||||
|
||||
func botAccessConfig(access config.BotAccessConfig) bot.AccessConfig {
|
||||
return bot.AccessConfig{
|
||||
Enabled: access.Enabled,
|
||||
AllowAll: access.AllowAll,
|
||||
PairingEnabled: access.PairingEnabled,
|
||||
Users: trimStringSlice(access.Users),
|
||||
Groups: trimStringSlice(access.Groups),
|
||||
Approvers: trimStringSlice(access.Approvers),
|
||||
Admins: trimStringSlice(access.Admins),
|
||||
}
|
||||
}
|
||||
|
||||
func trimStringSlice(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func botSessionMappings(mappings []config.BotConnectionSessionMapping) []bot.SessionMapping {
|
||||
if len(mappings) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]bot.SessionMapping, 0, len(mappings))
|
||||
for _, mapping := range mappings {
|
||||
out = append(out, bot.SessionMapping{
|
||||
RemoteID: strings.TrimSpace(mapping.RemoteID),
|
||||
SessionID: strings.TrimSpace(mapping.SessionID),
|
||||
SessionSource: strings.TrimSpace(mapping.SessionSource),
|
||||
ChatType: strings.TrimSpace(mapping.ChatType),
|
||||
UserID: strings.TrimSpace(mapping.UserID),
|
||||
ThreadID: strings.TrimSpace(mapping.ThreadID),
|
||||
Scope: strings.TrimSpace(mapping.Scope),
|
||||
WorkspaceRoot: strings.TrimSpace(mapping.WorkspaceRoot),
|
||||
UpdatedAt: strings.TrimSpace(mapping.UpdatedAt),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func RouteConfigs(routes []config.BotRouteConfig, includeModel bool, includeWorkspaceRoot bool) []bot.RouteConfig {
|
||||
if len(routes) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]bot.RouteConfig, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
var channel bot.ChannelConfig
|
||||
if includeModel {
|
||||
channel.Model = strings.TrimSpace(route.Model)
|
||||
}
|
||||
if includeWorkspaceRoot {
|
||||
channel.WorkspaceRoot = strings.TrimSpace(route.WorkspaceRoot)
|
||||
}
|
||||
if value := normalizeToolApprovalMode(route.ToolApprovalMode); value != "" {
|
||||
channel.ToolApprovalMode = value
|
||||
}
|
||||
if channel.Model == "" && channel.WorkspaceRoot == "" && channel.ToolApprovalMode == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, bot.RouteConfig{
|
||||
ConnectionID: strings.TrimSpace(route.ConnectionID),
|
||||
Platform: bot.Platform(strings.TrimSpace(route.Platform)),
|
||||
ChatType: bot.ChatType(strings.TrimSpace(route.ChatType)),
|
||||
ChatID: strings.TrimSpace(route.ChatID),
|
||||
UserID: strings.TrimSpace(route.UserID),
|
||||
ThreadID: strings.TrimSpace(route.ThreadID),
|
||||
Channel: channel,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeToolApprovalMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "ask":
|
||||
return "ask"
|
||||
case "auto":
|
||||
return "auto"
|
||||
case "yolo", "full", "full-access", "bypass":
|
||||
return "yolo"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func AdapterBindings(cfg *config.Config, enabled map[bot.Platform]bool, feishuDomains map[string]bool, logger *slog.Logger) []bot.AdapterBinding {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
var bindings []bot.AdapterBinding
|
||||
hasConnection := make(map[bot.Platform]bool)
|
||||
for _, conn := range cfg.Bot.Connections {
|
||||
if !conn.Enabled {
|
||||
continue
|
||||
}
|
||||
platform := bot.Platform(strings.TrimSpace(conn.Provider))
|
||||
if !enabled[platform] {
|
||||
continue
|
||||
}
|
||||
id := ConnectionRuntimeID(conn)
|
||||
switch platform {
|
||||
case bot.PlatformQQ:
|
||||
qqCfg := cfg.Bot.QQ
|
||||
qqCfg.Enabled = true
|
||||
qqCfg.AppID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppID), qqCfg.AppID)
|
||||
qqCfg.AppSecretEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppSecretEnv), qqCfg.AppSecretEnv)
|
||||
bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: strings.TrimSpace(conn.Domain), Platform: platform, Adapter: qq.New(qqCfg, logger)})
|
||||
hasConnection[platform] = true
|
||||
case bot.PlatformFeishu:
|
||||
feishuCfg := cfg.Bot.Feishu
|
||||
feishuCfg.Enabled = true
|
||||
feishuCfg.Domain = firstNonEmptyString(strings.TrimSpace(conn.Domain), feishuCfg.Domain)
|
||||
if feishuDomains != nil && !feishuDomains[feishuDomainKey(feishuCfg.Domain)] {
|
||||
continue
|
||||
}
|
||||
feishuCfg.AppID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppID), feishuCfg.AppID)
|
||||
feishuCfg.AppSecretEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppSecretEnv), feishuCfg.AppSecretEnv)
|
||||
bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: feishuCfg.Domain, Platform: platform, Adapter: feishu.New(feishuCfg, logger)})
|
||||
hasConnection[platform] = true
|
||||
case bot.PlatformWeixin:
|
||||
weixinCfg := cfg.Bot.Weixin
|
||||
weixinCfg.Enabled = true
|
||||
weixinCfg.AccountID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AccountID), weixinCfg.AccountID)
|
||||
weixinCfg.TokenEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.TokenEnv), weixinCfg.TokenEnv)
|
||||
bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: strings.TrimSpace(conn.Domain), Platform: platform, Adapter: weixin.New(weixinCfg, logger)})
|
||||
hasConnection[platform] = true
|
||||
}
|
||||
}
|
||||
if enabled[bot.PlatformQQ] && !hasConnection[bot.PlatformQQ] {
|
||||
bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformQQ), Platform: bot.PlatformQQ, Adapter: qq.New(cfg.Bot.QQ, logger)})
|
||||
}
|
||||
if enabled[bot.PlatformFeishu] && !hasConnection[bot.PlatformFeishu] {
|
||||
if feishuDomains == nil || feishuDomains[feishuDomainKey(cfg.Bot.Feishu.Domain)] {
|
||||
bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformFeishu), Domain: cfg.Bot.Feishu.Domain, Platform: bot.PlatformFeishu, Adapter: feishu.New(cfg.Bot.Feishu, logger)})
|
||||
}
|
||||
}
|
||||
if enabled[bot.PlatformWeixin] && !hasConnection[bot.PlatformWeixin] {
|
||||
bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformWeixin), Domain: "weixin", Platform: bot.PlatformWeixin, Adapter: weixin.New(cfg.Bot.Weixin, logger)})
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func ConnectionRuntimeID(conn config.BotConnectionConfig) string {
|
||||
if id := strings.TrimSpace(conn.ID); id != "" {
|
||||
return id
|
||||
}
|
||||
provider := strings.TrimSpace(conn.Provider)
|
||||
domain := strings.TrimSpace(conn.Domain)
|
||||
if provider == "" {
|
||||
return ""
|
||||
}
|
||||
if domain == "" {
|
||||
return provider
|
||||
}
|
||||
return provider + "-" + domain
|
||||
}
|
||||
|
||||
func ModelName(cfg *config.Config, override string) string {
|
||||
if strings.TrimSpace(override) != "" {
|
||||
return strings.TrimSpace(override)
|
||||
}
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(cfg.Bot.Model) != "" {
|
||||
return strings.TrimSpace(cfg.Bot.Model)
|
||||
}
|
||||
return strings.TrimSpace(cfg.DefaultModel)
|
||||
}
|
||||
|
||||
func AllowlistUserCount(a config.BotAllowlist) int {
|
||||
return len(a.QQUsers) + len(a.FeishuUsers) + len(a.WeixinUsers) +
|
||||
len(a.QQApprovers) + len(a.FeishuApprovers) + len(a.WeixinApprovers) +
|
||||
len(a.QQAdmins) + len(a.FeishuAdmins) + len(a.WeixinAdmins)
|
||||
}
|
||||
|
||||
func BotConfigHasAccessControl(bc config.BotConfig) bool {
|
||||
if bc.Allowlist.AllowAll || bc.Pairing.Enabled || (bc.Allowlist.Enabled && AllowlistUserCount(bc.Allowlist) > 0) {
|
||||
return true
|
||||
}
|
||||
if BotAccessActive(bc.QQ.Access) {
|
||||
return true
|
||||
}
|
||||
for _, conn := range bc.Connections {
|
||||
if conn.Enabled && BotAccessActive(conn.Access) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func NewRemoteRememberer(logger *slog.Logger) func(bot.InboundMessage) {
|
||||
var mu sync.Mutex
|
||||
seen := make(map[string]bool)
|
||||
return func(msg bot.InboundMessage) {
|
||||
remoteID := strings.TrimSpace(msg.ChatID)
|
||||
if remoteID == "" {
|
||||
return
|
||||
}
|
||||
key := strings.Join([]string{
|
||||
string(msg.Platform),
|
||||
strings.TrimSpace(msg.ConnectionID),
|
||||
strings.TrimSpace(msg.Domain),
|
||||
string(msg.ChatType),
|
||||
remoteID,
|
||||
strings.TrimSpace(msg.UserID),
|
||||
}, "\x00")
|
||||
mu.Lock()
|
||||
if seen[key] {
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
mu.Unlock()
|
||||
|
||||
if err := RememberInbound(msg); err != nil && logger != nil {
|
||||
logger.Warn("remember bot remote failed", "platform", msg.Platform, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewSessionRemembererWithWorkspace(logger *slog.Logger, workspaceRoot string) func(bot.InboundMessage, string) error {
|
||||
return func(msg bot.InboundMessage, sessionID string) error {
|
||||
if err := RememberInboundSessionWorkspace(msg, sessionID, workspaceRoot); err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("remember bot session failed", "platform", msg.Platform, "err", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func RememberInbound(msg bot.InboundMessage) error {
|
||||
return rememberInbound(msg, "", "")
|
||||
}
|
||||
|
||||
func RememberInboundSessionWorkspace(msg bot.InboundMessage, sessionID string, workspaceRoot string) error {
|
||||
return rememberInbound(msg, strings.TrimSpace(sessionID), strings.TrimSpace(workspaceRoot))
|
||||
}
|
||||
|
||||
func ForgetAutoSessionMappingsForPath(sessionPath string) error {
|
||||
target := normalizedBotSessionPath(sessionPath)
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
userPath := config.UserConfigPath()
|
||||
if strings.TrimSpace(userPath) == "" {
|
||||
return nil
|
||||
}
|
||||
unlock := config.LockUserConfigEdits()
|
||||
defer unlock()
|
||||
|
||||
cfg := config.LoadForEdit(userPath)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
changed := false
|
||||
for i := range cfg.Bot.Connections {
|
||||
conn := &cfg.Bot.Connections[i]
|
||||
next := conn.SessionMappings[:0]
|
||||
removed := false
|
||||
for _, mapping := range conn.SessionMappings {
|
||||
if strings.TrimSpace(mapping.SessionSource) == "auto" && normalizedBotSessionPath(mapping.SessionID) == target {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
next = append(next, mapping)
|
||||
}
|
||||
if !removed {
|
||||
continue
|
||||
}
|
||||
conn.SessionMappings = next
|
||||
conn.UpdatedAt = now
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
return cfg.SaveTo(userPath)
|
||||
}
|
||||
|
||||
func rememberInbound(msg bot.InboundMessage, sessionID string, actualWorkspaceRoot string) error {
|
||||
userPath := config.UserConfigPath()
|
||||
platform := msg.Platform
|
||||
remoteID := strings.TrimSpace(msg.ChatID)
|
||||
if userPath == "" || remoteID == "" {
|
||||
return nil
|
||||
}
|
||||
unlock := config.LockUserConfigEdits()
|
||||
defer unlock()
|
||||
|
||||
cfg := config.LoadForEdit(userPath)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
changed := false
|
||||
for i := range cfg.Bot.Connections {
|
||||
conn := &cfg.Bot.Connections[i]
|
||||
if strings.TrimSpace(conn.Provider) != string(platform) || !conn.Enabled || !connectionMatchesInbound(*conn, msg) {
|
||||
continue
|
||||
}
|
||||
mappingIndex := -1
|
||||
for j := range conn.SessionMappings {
|
||||
if botSessionMappingMatches(conn.SessionMappings[j], msg) {
|
||||
mappingIndex = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if mappingIndex >= 0 {
|
||||
if sessionID == "" {
|
||||
continue
|
||||
}
|
||||
mapping := &conn.SessionMappings[mappingIndex]
|
||||
current := strings.TrimSpace(mapping.SessionID)
|
||||
if current == sessionID || botSessionMappingHasExplicitTarget(*mapping) {
|
||||
continue
|
||||
}
|
||||
mapping.SessionID = sessionID
|
||||
mapping.SessionSource = "auto"
|
||||
mapping.UpdatedAt = now
|
||||
conn.UpdatedAt = now
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
scope := "global"
|
||||
workspaceRoot := ""
|
||||
if strings.TrimSpace(conn.WorkspaceRoot) != "" {
|
||||
scope = "project"
|
||||
workspaceRoot = strings.TrimSpace(conn.WorkspaceRoot)
|
||||
} else if actualWorkspaceRoot != "" {
|
||||
scope = "project"
|
||||
workspaceRoot = actualWorkspaceRoot
|
||||
}
|
||||
chatType, userID, threadID := botSessionMappingIdentity(msg)
|
||||
conn.SessionMappings = append(conn.SessionMappings, config.BotConnectionSessionMapping{
|
||||
RemoteID: remoteID,
|
||||
SessionID: sessionID,
|
||||
SessionSource: botSessionSource(sessionID),
|
||||
ChatType: chatType,
|
||||
UserID: userID,
|
||||
ThreadID: threadID,
|
||||
Scope: scope,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
conn.UpdatedAt = now
|
||||
changed = true
|
||||
}
|
||||
if rememberAllowlist(&cfg.Bot.Allowlist, platform, msg.UserID, remoteID, msg.ChatType) {
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
return cfg.SaveTo(userPath)
|
||||
}
|
||||
|
||||
func botSessionMappingMatches(mapping config.BotConnectionSessionMapping, msg bot.InboundMessage) bool {
|
||||
if strings.TrimSpace(mapping.RemoteID) != strings.TrimSpace(msg.ChatID) {
|
||||
return false
|
||||
}
|
||||
chatType, userID, threadID := botSessionMappingIdentity(msg)
|
||||
mappingChatType := strings.TrimSpace(mapping.ChatType)
|
||||
if mappingChatType == "" {
|
||||
return chatType == ""
|
||||
}
|
||||
if mappingChatType != chatType {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(mapping.UserID) != userID {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(mapping.ThreadID) == threadID
|
||||
}
|
||||
|
||||
func botSessionMappingIdentity(msg bot.InboundMessage) (chatType string, userID string, threadID string) {
|
||||
switch msg.ChatType {
|
||||
case bot.ChatGroup, bot.ChatGuild:
|
||||
chatType = string(msg.ChatType)
|
||||
userID = strings.TrimSpace(msg.UserID)
|
||||
case bot.ChatThread:
|
||||
chatType = string(msg.ChatType)
|
||||
threadID = strings.TrimSpace(msg.ThreadID)
|
||||
if threadID == "" {
|
||||
threadID = strings.TrimSpace(msg.ChatID)
|
||||
}
|
||||
}
|
||||
return chatType, userID, threadID
|
||||
}
|
||||
|
||||
func botSessionMappingHasExplicitTarget(mapping config.BotConnectionSessionMapping) bool {
|
||||
sessionID := strings.TrimSpace(mapping.SessionID)
|
||||
if sessionID == "" || strings.TrimSpace(mapping.SessionSource) == "auto" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func botSessionSource(sessionID string) string {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return ""
|
||||
}
|
||||
return "auto"
|
||||
}
|
||||
|
||||
func normalizedBotSessionPath(sessionID string) string {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(sessionID), "path:") {
|
||||
sessionID = strings.TrimSpace(sessionID[5:])
|
||||
}
|
||||
if sessionID == "" {
|
||||
return ""
|
||||
}
|
||||
if !(strings.HasSuffix(sessionID, ".jsonl") || strings.Contains(sessionID, "/") || strings.Contains(sessionID, `\`) || strings.HasPrefix(sessionID, "~")) {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(sessionID)
|
||||
}
|
||||
|
||||
func connectionMatchesInbound(conn config.BotConnectionConfig, msg bot.InboundMessage) bool {
|
||||
if msg.ConnectionID != "" {
|
||||
return ConnectionRuntimeID(conn) == strings.TrimSpace(msg.ConnectionID)
|
||||
}
|
||||
if msg.Domain != "" && strings.TrimSpace(conn.Domain) != "" {
|
||||
return strings.EqualFold(strings.TrimSpace(conn.Domain), strings.TrimSpace(msg.Domain))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func rememberAllowlist(allowlist *config.BotAllowlist, platform bot.Platform, userID string, chatID string, chatType bot.ChatType) bool {
|
||||
if allowlist == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID != "" {
|
||||
switch platform {
|
||||
case bot.PlatformQQ:
|
||||
allowlist.QQUsers, changed = appendUniqueString(allowlist.QQUsers, userID)
|
||||
case bot.PlatformFeishu:
|
||||
allowlist.FeishuUsers, changed = appendUniqueString(allowlist.FeishuUsers, userID)
|
||||
case bot.PlatformWeixin:
|
||||
allowlist.WeixinUsers, changed = appendUniqueString(allowlist.WeixinUsers, userID)
|
||||
}
|
||||
}
|
||||
if !chatUsesGroupAllowlist(chatType) {
|
||||
return changed
|
||||
}
|
||||
groupID := strings.TrimSpace(chatID)
|
||||
if groupID == "" {
|
||||
return changed
|
||||
}
|
||||
groupChanged := false
|
||||
switch platform {
|
||||
case bot.PlatformQQ:
|
||||
allowlist.QQGroups, groupChanged = appendUniqueString(allowlist.QQGroups, groupID)
|
||||
case bot.PlatformFeishu:
|
||||
allowlist.FeishuGroups, groupChanged = appendUniqueString(allowlist.FeishuGroups, groupID)
|
||||
case bot.PlatformWeixin:
|
||||
allowlist.WeixinGroups, groupChanged = appendUniqueString(allowlist.WeixinGroups, groupID)
|
||||
}
|
||||
return changed || groupChanged
|
||||
}
|
||||
|
||||
func appendUniqueString(values []string, next string) ([]string, bool) {
|
||||
next = strings.TrimSpace(next)
|
||||
if next == "" {
|
||||
return values, false
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == next {
|
||||
return values, false
|
||||
}
|
||||
}
|
||||
return append(values, next), true
|
||||
}
|
||||
|
||||
func chatUsesGroupAllowlist(chatType bot.ChatType) bool {
|
||||
switch chatType {
|
||||
case bot.ChatGroup, bot.ChatGuild, bot.ChatThread:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyString(vals ...string) string {
|
||||
for _, val := range vals {
|
||||
if strings.TrimSpace(val) != "" {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
package botruntime
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/testenv"
|
||||
)
|
||||
|
||||
func rememberInboundSession(msg bot.InboundMessage, sessionID string) error {
|
||||
return RememberInboundSessionWorkspace(msg, sessionID, "")
|
||||
}
|
||||
|
||||
func TestAllowlistUserCountIncludesRoles(t *testing.T) {
|
||||
allowlist := config.BotAllowlist{
|
||||
FeishuApprovers: []string{"ou-approver"},
|
||||
FeishuAdmins: []string{"ou-admin"},
|
||||
}
|
||||
|
||||
if got := AllowlistUserCount(allowlist); got != 2 {
|
||||
t.Fatalf("AllowlistUserCount() = %d, want role users included", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRemembererKeepsDistinctGroupUsers(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
remember := NewRemoteRememberer(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
remember(bot.InboundMessage{
|
||||
Platform: bot.PlatformFeishu,
|
||||
ConnectionID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatType: bot.ChatGroup,
|
||||
ChatID: "oc-group-1",
|
||||
UserID: "ou-user-1",
|
||||
})
|
||||
remember(bot.InboundMessage{
|
||||
Platform: bot.PlatformFeishu,
|
||||
ConnectionID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatType: bot.ChatGroup,
|
||||
ChatID: "oc-group-1",
|
||||
UserID: "ou-user-2",
|
||||
})
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
if users := got.Bot.Allowlist.FeishuUsers; len(users) != 2 || users[0] != "ou-user-1" || users[1] != "ou-user-2" {
|
||||
t.Fatalf("feishu users = %+v, want both group users", users)
|
||||
}
|
||||
if groups := got.Bot.Allowlist.FeishuGroups; len(groups) != 1 || groups[0] != "oc-group-1" {
|
||||
t.Fatalf("feishu groups = %+v, want group once", groups)
|
||||
}
|
||||
if mappings := got.Bot.Connections[0].SessionMappings; len(mappings) != 2 || mappings[0].RemoteID != "oc-group-1" || mappings[0].UserID != "ou-user-1" || mappings[1].RemoteID != "oc-group-1" || mappings[1].UserID != "ou-user-2" {
|
||||
t.Fatalf("session mappings = %+v, want distinct group-user mappings", mappings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberInboundSessionFillsExistingMappingSessionID(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
msg := bot.InboundMessage{
|
||||
Platform: bot.PlatformWeixin,
|
||||
ConnectionID: "weixin-weixin",
|
||||
Domain: "weixin",
|
||||
ChatType: bot.ChatDM,
|
||||
ChatID: "wx-chat-1",
|
||||
UserID: "wx-user-1",
|
||||
}
|
||||
if err := RememberInbound(msg); err != nil {
|
||||
t.Fatalf("remember inbound: %v", err)
|
||||
}
|
||||
if err := rememberInboundSession(msg, "path:/sessions/20260614-120000.000000000-deepseek.jsonl"); err != nil {
|
||||
t.Fatalf("remember inbound session: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
mappings := got.Bot.Connections[0].SessionMappings
|
||||
if len(mappings) != 1 {
|
||||
t.Fatalf("mappings = %+v, want one mapping", mappings)
|
||||
}
|
||||
if mappings[0].RemoteID != "wx-chat-1" || mappings[0].SessionID != "path:/sessions/20260614-120000.000000000-deepseek.jsonl" || mappings[0].SessionSource != "auto" {
|
||||
t.Fatalf("mapping = %+v, want remote chat with session id", mappings[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberInboundSessionCreatesMappingWithSessionID(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
if err := rememberInboundSession(bot.InboundMessage{
|
||||
Platform: bot.PlatformFeishu,
|
||||
ConnectionID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatType: bot.ChatDM,
|
||||
ChatID: "oc-chat-1",
|
||||
UserID: "ou-user-1",
|
||||
}, "path:/sessions/topic-bot.jsonl"); err != nil {
|
||||
t.Fatalf("remember inbound session: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
mappings := got.Bot.Connections[0].SessionMappings
|
||||
if len(mappings) != 1 || mappings[0].RemoteID != "oc-chat-1" || mappings[0].SessionID != "path:/sessions/topic-bot.jsonl" || mappings[0].SessionSource != "auto" {
|
||||
t.Fatalf("mappings = %+v, want mapping with session id", mappings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberInboundSessionKeepsDistinctGroupUsers(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
msg1 := bot.InboundMessage{Platform: bot.PlatformFeishu, ConnectionID: "feishu-lark", Domain: "lark", ChatType: bot.ChatGroup, ChatID: "oc-group-1", UserID: "ou-user-1"}
|
||||
msg2 := bot.InboundMessage{Platform: bot.PlatformFeishu, ConnectionID: "feishu-lark", Domain: "lark", ChatType: bot.ChatGroup, ChatID: "oc-group-1", UserID: "ou-user-2"}
|
||||
if err := rememberInboundSession(msg1, "path:/sessions/user-1.jsonl"); err != nil {
|
||||
t.Fatalf("remember user 1: %v", err)
|
||||
}
|
||||
if err := rememberInboundSession(msg2, "path:/sessions/user-2.jsonl"); err != nil {
|
||||
t.Fatalf("remember user 2: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
mappings := got.Bot.Connections[0].SessionMappings
|
||||
if len(mappings) != 2 {
|
||||
t.Fatalf("mappings = %+v, want two group-user mappings", mappings)
|
||||
}
|
||||
if mappings[0].UserID != "ou-user-1" || mappings[0].SessionID != "path:/sessions/user-1.jsonl" || mappings[1].UserID != "ou-user-2" || mappings[1].SessionID != "path:/sessions/user-2.jsonl" {
|
||||
t.Fatalf("mappings = %+v, want user-specific session ids", mappings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberInboundSessionSharesThreadMappingAcrossUsers(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
msg1 := bot.InboundMessage{Platform: bot.PlatformFeishu, ConnectionID: "feishu-lark", Domain: "lark", ChatType: bot.ChatThread, ChatID: "oc-group-1", ThreadID: "thread-1", UserID: "ou-user-1"}
|
||||
msg2 := bot.InboundMessage{Platform: bot.PlatformFeishu, ConnectionID: "feishu-lark", Domain: "lark", ChatType: bot.ChatThread, ChatID: "oc-group-1", ThreadID: "thread-1", UserID: "ou-user-2"}
|
||||
if err := rememberInboundSession(msg1, "path:/sessions/thread-old.jsonl"); err != nil {
|
||||
t.Fatalf("remember user 1: %v", err)
|
||||
}
|
||||
if err := rememberInboundSession(msg2, "path:/sessions/thread-new.jsonl"); err != nil {
|
||||
t.Fatalf("remember user 2: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
mappings := got.Bot.Connections[0].SessionMappings
|
||||
if len(mappings) != 1 {
|
||||
t.Fatalf("mappings = %+v, want one shared thread mapping", mappings)
|
||||
}
|
||||
if mappings[0].ChatType != string(bot.ChatThread) || mappings[0].ThreadID != "thread-1" || mappings[0].UserID != "" || mappings[0].SessionID != "path:/sessions/thread-new.jsonl" {
|
||||
t.Fatalf("mapping = %+v, want shared thread identity with latest auto session", mappings[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberInboundSessionPreservesExplicitMappingTarget(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
||||
ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
|
||||
SessionMappings: []config.BotConnectionSessionMapping{{RemoteID: "wx-chat-1", SessionID: "topic:manual-topic"}},
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
|
||||
if err := rememberInboundSession(msg, "path:/sessions/auto.jsonl"); err != nil {
|
||||
t.Fatalf("remember inbound session: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
mapping := got.Bot.Connections[0].SessionMappings[0]
|
||||
if mapping.SessionID != "topic:manual-topic" || mapping.SessionSource != "" {
|
||||
t.Fatalf("mapping = %+v, want explicit topic preserved", mapping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberInboundSessionPreservesBareExplicitMappingTarget(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
||||
ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
|
||||
SessionMappings: []config.BotConnectionSessionMapping{{RemoteID: "wx-chat-1", SessionID: "manual-topic"}},
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
|
||||
if err := rememberInboundSession(msg, "path:/sessions/auto.jsonl"); err != nil {
|
||||
t.Fatalf("remember inbound session: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
mapping := got.Bot.Connections[0].SessionMappings[0]
|
||||
if mapping.SessionID != "manual-topic" || mapping.SessionSource != "" {
|
||||
t.Fatalf("mapping = %+v, want bare explicit target preserved", mapping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberInboundSessionUsesActualWorkspaceWhenConnectionIsGlobal(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
workspace := filepath.Join(testenv.TempDir(t), "workspace")
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
|
||||
if err := RememberInboundSessionWorkspace(msg, "path:/sessions/auto.jsonl", workspace); err != nil {
|
||||
t.Fatalf("remember inbound session: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
mapping := got.Bot.Connections[0].SessionMappings[0]
|
||||
if mapping.Scope != "project" || mapping.WorkspaceRoot != workspace {
|
||||
t.Fatalf("mapping = %+v, want actual workspace scope", mapping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberInboundSessionKeepsConfiguredWorkspaceOverActualWorkspace(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
configured := filepath.Join(testenv.TempDir(t), "configured")
|
||||
actual := filepath.Join(testenv.TempDir(t), "actual")
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
||||
ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected", WorkspaceRoot: configured,
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
|
||||
if err := RememberInboundSessionWorkspace(msg, "path:/sessions/auto.jsonl", actual); err != nil {
|
||||
t.Fatalf("remember inbound session: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
mapping := got.Bot.Connections[0].SessionMappings[0]
|
||||
if mapping.Scope != "project" || mapping.WorkspaceRoot != configured {
|
||||
t.Fatalf("mapping = %+v, want configured workspace scope", mapping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberInboundSessionUpdatesAutoMappingTarget(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
||||
ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
|
||||
SessionMappings: []config.BotConnectionSessionMapping{{RemoteID: "wx-chat-1", SessionID: "path:/sessions/old.jsonl", SessionSource: "auto"}},
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
|
||||
if err := rememberInboundSession(msg, "path:/sessions/new.jsonl"); err != nil {
|
||||
t.Fatalf("remember inbound session: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
mapping := got.Bot.Connections[0].SessionMappings[0]
|
||||
if mapping.SessionID != "path:/sessions/new.jsonl" || mapping.SessionSource != "auto" {
|
||||
t.Fatalf("mapping = %+v, want auto target updated", mapping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgetAutoSessionMappingsForPathRemovesOnlyAutoPathTargets(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
target := filepath.Join(testenv.TempDir(t), "bot-channel.jsonl")
|
||||
other := filepath.Join(testenv.TempDir(t), "other-channel.jsonl")
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
||||
ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
|
||||
SessionMappings: []config.BotConnectionSessionMapping{
|
||||
{RemoteID: "remove-path-prefix", SessionID: "path:" + target, SessionSource: "auto"},
|
||||
{RemoteID: "remove-raw-path", SessionID: target, SessionSource: "auto"},
|
||||
{RemoteID: "keep-explicit-path", SessionID: "path:" + target},
|
||||
{RemoteID: "keep-other-auto", SessionID: "path:" + other, SessionSource: "auto"},
|
||||
{RemoteID: "keep-topic-auto", SessionID: "topic:bot-topic", SessionSource: "auto"},
|
||||
},
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
if err := ForgetAutoSessionMappingsForPath(target); err != nil {
|
||||
t.Fatalf("forget auto session mappings: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
mappings := got.Bot.Connections[0].SessionMappings
|
||||
if len(mappings) != 3 {
|
||||
t.Fatalf("mappings = %+v, want three preserved mappings", mappings)
|
||||
}
|
||||
remotes := map[string]bool{}
|
||||
for _, mapping := range mappings {
|
||||
remotes[mapping.RemoteID] = true
|
||||
}
|
||||
for _, remote := range []string{"keep-explicit-path", "keep-other-auto", "keep-topic-auto"} {
|
||||
if !remotes[remote] {
|
||||
t.Fatalf("mapping %q was not preserved: %+v", remote, mappings)
|
||||
}
|
||||
}
|
||||
if got.Bot.Connections[0].UpdatedAt == "" {
|
||||
t.Fatalf("connection UpdatedAt was not refreshed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionChannelConfigsPreserveToolApprovalMode(t *testing.T) {
|
||||
connections := []config.BotConnectionConfig{
|
||||
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, ToolApprovalMode: "auto"},
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, ToolApprovalMode: "yolo"},
|
||||
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, ToolApprovalMode: "ask"},
|
||||
}
|
||||
|
||||
byConnection := ConnectionChannelConfigs(connections, true, true)
|
||||
if got := byConnection["feishu-feishu"].ToolApprovalMode; got != "auto" {
|
||||
t.Fatalf("feishu tool approval mode = %q, want auto", got)
|
||||
}
|
||||
if got := byConnection["feishu-lark"].ToolApprovalMode; got != "yolo" {
|
||||
t.Fatalf("lark tool approval mode = %q, want yolo", got)
|
||||
}
|
||||
if got := byConnection["weixin-weixin"].ToolApprovalMode; got != "ask" {
|
||||
t.Fatalf("weixin tool approval mode = %q, want explicit ask override", got)
|
||||
}
|
||||
|
||||
byPlatform := ChannelConfigs(connections, true, true)
|
||||
if got := byPlatform[bot.PlatformFeishu].ToolApprovalMode; got != "yolo" {
|
||||
t.Fatalf("platform feishu tool approval mode = %q, want last enabled Feishu/Lark override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionChannelConfigsCarrySessionMappingsOnlyPerConnection(t *testing.T) {
|
||||
connections := []config.BotConnectionConfig{
|
||||
{
|
||||
ID: "weixin-weixin",
|
||||
Provider: "weixin",
|
||||
Domain: "weixin",
|
||||
Enabled: true,
|
||||
WorkspaceRoot: "/connection",
|
||||
SessionMappings: []config.BotConnectionSessionMapping{{
|
||||
RemoteID: "wx-group-1",
|
||||
SessionID: "path:/tmp/reasonix-session.jsonl",
|
||||
ChatType: string(bot.ChatGroup),
|
||||
UserID: "wx-user-1",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: "/mapped",
|
||||
UpdatedAt: "2026-07-04T12:00:00Z",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
byConnection := ConnectionChannelConfigs(connections, true, true)
|
||||
mappings := byConnection["weixin-weixin"].SessionMappings
|
||||
if len(mappings) != 1 {
|
||||
t.Fatalf("connection mappings = %+v, want one mapping", mappings)
|
||||
}
|
||||
if got := mappings[0]; got.RemoteID != "wx-group-1" || got.SessionID != "path:/tmp/reasonix-session.jsonl" || got.ChatType != string(bot.ChatGroup) || got.UserID != "wx-user-1" || got.WorkspaceRoot != "/mapped" || got.UpdatedAt == "" {
|
||||
t.Fatalf("connection mapping = %+v, want copied routing fields", got)
|
||||
}
|
||||
|
||||
byPlatform := ChannelConfigs(connections, true, true)
|
||||
if got := byPlatform[bot.PlatformWeixin].SessionMappings; len(got) != 0 {
|
||||
t.Fatalf("platform mappings = %+v, want none to avoid cross-connection routing", got)
|
||||
}
|
||||
|
||||
noWorkspace := ConnectionChannelConfigs(connections, true, false)
|
||||
if got := noWorkspace["weixin-weixin"].SessionMappings; len(got) != 0 {
|
||||
t.Fatalf("connection mappings with includeWorkspaceRoot=false = %+v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteConfigsPreserveRemoteOverrides(t *testing.T) {
|
||||
routes := RouteConfigs([]config.BotRouteConfig{
|
||||
{ConnectionID: "feishu-lark", Platform: "feishu", ChatType: "group", ChatID: "group-1", Model: "route-model", WorkspaceRoot: "/route", ToolApprovalMode: "full-access"},
|
||||
{ConnectionID: "empty-route"},
|
||||
}, true, true)
|
||||
if len(routes) != 1 {
|
||||
t.Fatalf("routes = %+v, want one non-empty route", routes)
|
||||
}
|
||||
got := routes[0]
|
||||
if got.ConnectionID != "feishu-lark" || got.Platform != bot.PlatformFeishu || got.ChatType != bot.ChatGroup || got.ChatID != "group-1" {
|
||||
t.Fatalf("route match fields = %+v, want trimmed remote match", got)
|
||||
}
|
||||
if got.Channel.Model != "route-model" || got.Channel.WorkspaceRoot != "/route" || got.Channel.ToolApprovalMode != "yolo" {
|
||||
t.Fatalf("route channel = %+v, want normalized overrides", got.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
func isolateUserConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
||||
t.Chdir(testenv.TempDir(t))
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/bot/weixin"
|
||||
"reasonix/internal/botruntime"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func botCommand(args []string, version string) int {
|
||||
if len(args) < 1 {
|
||||
botUsage()
|
||||
return 2
|
||||
}
|
||||
|
||||
sub := args[0]
|
||||
rest := args[1:]
|
||||
|
||||
switch sub {
|
||||
case "start":
|
||||
return botStart(rest, version)
|
||||
case "doctor":
|
||||
return botDoctor(rest)
|
||||
case "pairing":
|
||||
return botPairing(rest)
|
||||
case "weixin-login":
|
||||
return botWeixinLogin(rest)
|
||||
case "help", "--help", "-h":
|
||||
botUsage()
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown bot subcommand %q\n\n", sub)
|
||||
botUsage()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func botStart(args []string, version string) int {
|
||||
fs := flag.NewFlagSet("bot start", flag.ContinueOnError)
|
||||
channels := fs.String("channels", "", "启用的平台,逗号分隔:qq,feishu,lark,weixin")
|
||||
dir := fs.String("dir", "", "工作目录")
|
||||
model := fs.String("model", "", "模型名(空则用 default_model)")
|
||||
|
||||
if code, ok := parseCommandFlags(fs, args); !ok {
|
||||
return code
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
cfg, err := loadBotCommandConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: load config: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if !cfg.Bot.Enabled {
|
||||
fmt.Fprintln(os.Stderr, "error: bot is not enabled in config — set [bot] enabled = true")
|
||||
return 1
|
||||
}
|
||||
if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
|
||||
fmt.Fprintln(os.Stderr, "error: bot requires explicit access control; set per-connection access, enable pairing, configure [bot.allowlist], or set allow_all = true intentionally")
|
||||
return 1
|
||||
}
|
||||
|
||||
workspaceRoot := *dir
|
||||
if workspaceRoot == "" {
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
workspaceRoot = wd
|
||||
}
|
||||
}
|
||||
|
||||
requestedChannels := splitBotChannels(*channels)
|
||||
enabledPlatforms, unknownChannels := botruntime.EnabledPlatforms(cfg, requestedChannels)
|
||||
for _, ch := range unknownChannels {
|
||||
fmt.Fprintf(os.Stderr, "warning: unknown channel %q\n", ch)
|
||||
}
|
||||
if !botruntime.HasEnabledPlatform(enabledPlatforms) {
|
||||
fmt.Fprintln(os.Stderr, "error: no bot channels enabled — enable at least one in config")
|
||||
return 1
|
||||
}
|
||||
|
||||
modelName := botruntime.ModelName(cfg, *model)
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
rememberInboundRemote := botruntime.NewRemoteRememberer(logger)
|
||||
|
||||
// 构建网关配置
|
||||
gwCfg := bot.GatewayConfig{
|
||||
Model: modelName,
|
||||
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
|
||||
MaxSteps: cfg.Bot.MaxSteps,
|
||||
QueueMode: cfg.Bot.QueueMode,
|
||||
QueueCap: cfg.Bot.QueueCap,
|
||||
QueueDrop: cfg.Bot.QueueDrop,
|
||||
PairingEnabled: cfg.Bot.Pairing.Enabled,
|
||||
PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
|
||||
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
|
||||
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
|
||||
SelfUserIDs: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
|
||||
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
|
||||
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
|
||||
},
|
||||
ControlEnabled: cfg.Bot.Control.Enabled,
|
||||
ControlAddr: cfg.Bot.Control.Addr,
|
||||
ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Channels: botruntime.ChannelConfigs(cfg.Bot.Connections, *model == "", *dir == ""),
|
||||
ConnectionChannels: botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, *model == "", *dir == ""),
|
||||
Routes: botruntime.RouteConfigs(cfg.Bot.Routes, *model == "", *dir == ""),
|
||||
ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
|
||||
Enabled: enabledPlatforms,
|
||||
Allowlist: bot.AllowlistConfig{
|
||||
Enabled: cfg.Bot.Allowlist.Enabled,
|
||||
AllowAll: cfg.Bot.Allowlist.AllowAll,
|
||||
Users: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQUsers,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
|
||||
},
|
||||
Approvers: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
|
||||
},
|
||||
Admins: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
|
||||
},
|
||||
Groups: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups,
|
||||
},
|
||||
},
|
||||
Debounce: time.Duration(cfg.Bot.DebounceMs) * time.Millisecond,
|
||||
OnInbound: rememberInboundRemote,
|
||||
OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
|
||||
}
|
||||
|
||||
feishuDomains := botruntime.RequestedFeishuDomains(requestedChannels)
|
||||
gw := bot.NewGatewayWithAdapterBindings(gwCfg, botruntime.AdapterBindings(cfg, enabledPlatforms, feishuDomains, logger), logger)
|
||||
|
||||
// 信号处理
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigCh
|
||||
fmt.Fprintln(os.Stderr, "\nshutting down...")
|
||||
cancel()
|
||||
}()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "reasonix bot starting (model: %s, channels: %s)...\n", modelName, *channels)
|
||||
fmt.Fprintf(os.Stderr, "version: %s\n", version)
|
||||
|
||||
if err := gw.Start(ctx); err != nil {
|
||||
gw.Stop()
|
||||
fmt.Fprintf(os.Stderr, "error: start gateway: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer gw.Stop()
|
||||
|
||||
// 等待信号或 context 取消
|
||||
<-ctx.Done()
|
||||
return 0
|
||||
}
|
||||
|
||||
func splitBotChannels(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(raw, ",")
|
||||
}
|
||||
|
||||
func botDoctor(args []string) int {
|
||||
fs := flag.NewFlagSet("bot doctor", flag.ContinueOnError)
|
||||
jsonOut := fs.Bool("json", false, "JSON 格式输出")
|
||||
deep := fs.Bool("deep", false, "执行更详细的本机诊断")
|
||||
|
||||
if code, ok := parseCommandFlags(fs, args); !ok {
|
||||
return code
|
||||
}
|
||||
|
||||
cfg, err := loadBotCommandConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: load config: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
bc := cfg.Bot
|
||||
|
||||
type checkResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
var results []checkResult
|
||||
|
||||
addCheck := func(name, status, detail string) {
|
||||
results = append(results, checkResult{Name: name, Status: status, Detail: detail})
|
||||
}
|
||||
|
||||
// 基础检查
|
||||
if bc.Enabled {
|
||||
addCheck("bot.enabled", "ok", "")
|
||||
} else {
|
||||
addCheck("bot.enabled", "disabled", "bot is not enabled in config")
|
||||
}
|
||||
if *deep {
|
||||
if path := config.UserConfigPath(); path != "" {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
addCheck("bot.config.user", "ok", path)
|
||||
} else {
|
||||
addCheck("bot.config.user", "missing", path)
|
||||
}
|
||||
}
|
||||
if dir := config.SessionDir(); dir != "" {
|
||||
addCheck("bot.sessions.dir", "ok", dir)
|
||||
}
|
||||
}
|
||||
queueMode := bot.NormalizeQueueMode(bc.QueueMode)
|
||||
queueCap := bc.QueueCap
|
||||
if queueCap <= 0 {
|
||||
queueCap = bot.DefaultQueueCap
|
||||
}
|
||||
addCheck("bot.queue", "ok", fmt.Sprintf("mode=%s cap=%d drop=%s", queueMode, queueCap, bot.NormalizeQueueDrop(bc.QueueDrop)))
|
||||
if bc.Pairing.Enabled {
|
||||
addCheck("bot.pairing", "enabled", fmt.Sprintf("ttl=%dm max_pending=%d", bc.Pairing.RequestTTLMinutes, bc.Pairing.MaxPendingPerPlatform))
|
||||
} else {
|
||||
addCheck("bot.pairing", "disabled", "")
|
||||
}
|
||||
if *deep {
|
||||
reqs, err := bot.ListPairingRequests()
|
||||
if err != nil {
|
||||
addCheck("bot.pairing.pending", "error", err.Error())
|
||||
} else {
|
||||
addCheck("bot.pairing.pending", "ok", fmt.Sprintf("%d pending", len(reqs)))
|
||||
}
|
||||
if path := bot.PairingStorePath(); path != "" {
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
addCheck("bot.pairing.store", "ok", fmt.Sprintf("%s mode=%s", path, info.Mode().Perm()))
|
||||
} else {
|
||||
addCheck("bot.pairing.store", "missing", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
if *deep {
|
||||
selfStatus := "disabled"
|
||||
if bc.IgnoreSelfMessages {
|
||||
selfStatus = "enabled"
|
||||
}
|
||||
addCheck("bot.self_protection", selfStatus,
|
||||
fmt.Sprintf("self_ids=%d", len(bc.SelfUserIDs.QQ)+len(bc.SelfUserIDs.Feishu)+len(bc.SelfUserIDs.Weixin)))
|
||||
controlStatus := "disabled"
|
||||
controlDetail := ""
|
||||
if bc.Control.Enabled {
|
||||
controlStatus = "enabled"
|
||||
tokenStatus := "missing_token"
|
||||
if strings.TrimSpace(bc.Control.TokenEnv) != "" && os.Getenv(strings.TrimSpace(bc.Control.TokenEnv)) != "" {
|
||||
tokenStatus = "token_set"
|
||||
}
|
||||
addr := strings.TrimSpace(bc.Control.Addr)
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:37913"
|
||||
}
|
||||
controlDetail = fmt.Sprintf("addr=%s token_env=%s %s", addr, bc.Control.TokenEnv, tokenStatus)
|
||||
}
|
||||
addCheck("bot.control", controlStatus, controlDetail)
|
||||
addCheck("bot.routes", "ok", fmt.Sprintf("%d routes", len(bc.Routes)))
|
||||
}
|
||||
|
||||
// QQ 检查
|
||||
if bc.QQ.Enabled {
|
||||
addCheck("bot.qq.enabled", "ok", "")
|
||||
secret := os.Getenv(bc.QQ.AppSecretEnv)
|
||||
if secret == "" {
|
||||
addCheck("bot.qq.app_secret", "missing", bc.QQ.AppSecretEnv+" is not set")
|
||||
} else {
|
||||
addCheck("bot.qq.app_secret", "ok", bc.QQ.AppSecretEnv+" is set")
|
||||
}
|
||||
if bc.QQ.AppID == "" {
|
||||
addCheck("bot.qq.app_id", "missing", "app_id is empty")
|
||||
} else {
|
||||
addCheck("bot.qq.app_id", "ok", "app_id configured")
|
||||
}
|
||||
} else {
|
||||
addCheck("bot.qq", "disabled", "")
|
||||
}
|
||||
|
||||
// 飞书检查
|
||||
if bc.Feishu.Enabled {
|
||||
addCheck("bot.feishu.enabled", "ok", "")
|
||||
secret := os.Getenv(bc.Feishu.AppSecretEnv)
|
||||
if secret == "" {
|
||||
addCheck("bot.feishu.app_secret", "missing", bc.Feishu.AppSecretEnv+" is not set")
|
||||
} else {
|
||||
addCheck("bot.feishu.app_secret", "ok", bc.Feishu.AppSecretEnv+" is set")
|
||||
}
|
||||
if bc.Feishu.AppID == "" {
|
||||
addCheck("bot.feishu.app_id", "missing", "app_id is empty")
|
||||
} else {
|
||||
addCheck("bot.feishu.app_id", "ok", "app_id configured")
|
||||
}
|
||||
mode := bc.Feishu.Mode
|
||||
if mode == "" {
|
||||
mode = "webhook"
|
||||
}
|
||||
addCheck("bot.feishu.mode", "ok", mode)
|
||||
} else {
|
||||
addCheck("bot.feishu", "disabled", "")
|
||||
}
|
||||
|
||||
// 微信检查
|
||||
if bc.Weixin.Enabled {
|
||||
addCheck("bot.weixin.enabled", "ok", "")
|
||||
token := os.Getenv(bc.Weixin.TokenEnv)
|
||||
if token != "" {
|
||||
addCheck("bot.weixin.token", "ok", bc.Weixin.TokenEnv+" is set")
|
||||
} else if weixin.HasSavedAccount(bc.Weixin.AccountID) {
|
||||
addCheck("bot.weixin.token", "ok", "saved iLink account is available")
|
||||
} else {
|
||||
addCheck("bot.weixin.token", "missing", bc.Weixin.TokenEnv+" is not set; run `reasonix bot weixin-login` to save an iLink account")
|
||||
}
|
||||
} else {
|
||||
addCheck("bot.weixin", "disabled", "")
|
||||
}
|
||||
|
||||
enabledConnections := 0
|
||||
for _, conn := range bc.Connections {
|
||||
if conn.Enabled {
|
||||
enabledConnections++
|
||||
}
|
||||
}
|
||||
addCheck("bot.connections", "ok", fmt.Sprintf("enabled=%d total=%d", enabledConnections, len(bc.Connections)))
|
||||
for _, conn := range bc.Connections {
|
||||
id := strings.TrimSpace(conn.ID)
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(conn.Provider)
|
||||
}
|
||||
status := "ok"
|
||||
if !conn.Enabled {
|
||||
status = "disabled"
|
||||
} else if len(conn.SessionMappings) == 0 && (conn.Provider == string(bot.PlatformFeishu) || conn.Provider == string(bot.PlatformWeixin)) {
|
||||
status = "missing"
|
||||
}
|
||||
addCheck("bot.connection."+id+".session_mappings", status,
|
||||
fmt.Sprintf("provider=%s mappings=%d", conn.Provider, len(conn.SessionMappings)))
|
||||
}
|
||||
|
||||
// Allowlist 检查
|
||||
if bc.Allowlist.AllowAll {
|
||||
addCheck("bot.allowlist", "open", "allow_all=true — every reachable user can trigger local tools")
|
||||
} else if bc.Allowlist.Enabled {
|
||||
addCheck("bot.allowlist", "enabled",
|
||||
fmt.Sprintf("qq=%d feishu=%d weixin=%d users approvers=%d admins=%d",
|
||||
len(bc.Allowlist.QQUsers),
|
||||
len(bc.Allowlist.FeishuUsers),
|
||||
len(bc.Allowlist.WeixinUsers),
|
||||
len(bc.Allowlist.QQApprovers)+len(bc.Allowlist.FeishuApprovers)+len(bc.Allowlist.WeixinApprovers),
|
||||
len(bc.Allowlist.QQAdmins)+len(bc.Allowlist.FeishuAdmins)+len(bc.Allowlist.WeixinAdmins)))
|
||||
} else {
|
||||
addCheck("bot.allowlist", "missing", "bot start will refuse without allowlist or allow_all=true")
|
||||
}
|
||||
if *deep {
|
||||
addCheck("bot.roles", "ok",
|
||||
fmt.Sprintf("approvers=%d admins=%d",
|
||||
len(bc.Allowlist.QQApprovers)+len(bc.Allowlist.FeishuApprovers)+len(bc.Allowlist.WeixinApprovers),
|
||||
len(bc.Allowlist.QQAdmins)+len(bc.Allowlist.FeishuAdmins)+len(bc.Allowlist.WeixinAdmins)))
|
||||
}
|
||||
|
||||
if *jsonOut {
|
||||
fmt.Println("[")
|
||||
for i, r := range results {
|
||||
comma := ","
|
||||
if i == len(results)-1 {
|
||||
comma = ""
|
||||
}
|
||||
fmt.Printf(" {\"name\":%q,\"status\":%q,\"detail\":%q}%s\n", r.Name, r.Status, r.Detail, comma)
|
||||
}
|
||||
fmt.Println("]")
|
||||
} else {
|
||||
for _, r := range results {
|
||||
marker := "✓"
|
||||
if r.Status == "missing" || r.Status == "disabled" {
|
||||
marker = "✗"
|
||||
}
|
||||
fmt.Printf(" %s %s: %s", marker, r.Name, r.Status)
|
||||
if r.Detail != "" {
|
||||
fmt.Printf(" — %s", r.Detail)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func botPairing(args []string) int {
|
||||
if len(args) < 1 {
|
||||
botPairingUsage()
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
reqs, err := bot.ListPairingRequests()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: list pairing requests: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if len(reqs) == 0 {
|
||||
fmt.Println("No pending bot pairing requests.")
|
||||
return 0
|
||||
}
|
||||
for _, req := range reqs {
|
||||
fmt.Printf("%s\t%s\t%s\tuser=%s\tchat=%s\texpires=%s\n",
|
||||
req.Code,
|
||||
req.Platform,
|
||||
req.ChatType,
|
||||
req.UserID,
|
||||
req.ChatID,
|
||||
req.ExpiresAt.Local().Format("2006-01-02 15:04"),
|
||||
)
|
||||
}
|
||||
return 0
|
||||
case "approve":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "error: pairing approve requires a code")
|
||||
return 2
|
||||
}
|
||||
req, err := bot.ApprovePairingCode(args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: approve pairing: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("Approved %s user %s for %s.\n", req.Platform, req.UserID, req.ChatID)
|
||||
return 0
|
||||
case "reject", "deny":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "error: pairing reject requires a code")
|
||||
return 2
|
||||
}
|
||||
req, err := bot.RejectPairingCode(args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: reject pairing: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("Rejected %s user %s for %s.\n", req.Platform, req.UserID, req.ChatID)
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown bot pairing subcommand %q\n\n", args[0])
|
||||
botPairingUsage()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func botPairingUsage() {
|
||||
fmt.Print(`reasonix bot pairing — approve pending bot DM pairings
|
||||
|
||||
Usage:
|
||||
reasonix bot pairing list
|
||||
reasonix bot pairing approve CODE
|
||||
reasonix bot pairing reject CODE
|
||||
`)
|
||||
}
|
||||
|
||||
func botWeixinLogin(args []string) int {
|
||||
fs := flag.NewFlagSet("bot weixin-login", flag.ContinueOnError)
|
||||
timeoutSeconds := fs.Int("timeout", 480, "登录超时时间(秒)")
|
||||
if code, ok := parseCommandFlags(fs, args); !ok {
|
||||
return code
|
||||
}
|
||||
|
||||
cfg, err := loadBotCommandConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: load config: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if !cfg.Bot.Weixin.Enabled {
|
||||
fmt.Fprintln(os.Stderr, "error: weixin bot is not enabled in config")
|
||||
return 1
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
result, err := weixin.Login(ctx, os.Stdout, time.Duration(*timeoutSeconds)*time.Second)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: weixin login failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("\n微信登录成功: account_id=%s user_id=%s base_url=%s\n", result.AccountID, result.UserID, result.BaseURL)
|
||||
fmt.Println("凭据已保存到 Reasonix 用户配置目录;也可以把 [bot.weixin] account_id 设置为该 account_id。")
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func loadBotCommandConfig() (*config.Config, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userPath := config.UserConfigPath()
|
||||
if strings.TrimSpace(userPath) == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
if _, err := os.Stat(userPath); err != nil {
|
||||
return cfg, nil
|
||||
}
|
||||
userCfg := config.LoadForEdit(userPath)
|
||||
if botConfigIsUserOwned(userCfg.Bot) {
|
||||
cfg.Bot = userCfg.Bot
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func botConfigIsUserOwned(bc config.BotConfig) bool {
|
||||
if bc.Enabled || len(bc.Connections) > 0 || bc.QQ.Enabled || bc.Feishu.Enabled || bc.Weixin.Enabled {
|
||||
return true
|
||||
}
|
||||
if bc.Allowlist.AllowAll || botruntime.AllowlistUserCount(bc.Allowlist) > 0 {
|
||||
return true
|
||||
}
|
||||
if botruntime.BotAccessActive(bc.QQ.Access) {
|
||||
return true
|
||||
}
|
||||
for _, conn := range bc.Connections {
|
||||
if botruntime.BotAccessActive(conn.Access) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return len(bc.Allowlist.QQGroups)+len(bc.Allowlist.FeishuGroups)+len(bc.Allowlist.WeixinGroups)+
|
||||
len(bc.Allowlist.QQApprovers)+len(bc.Allowlist.FeishuApprovers)+len(bc.Allowlist.WeixinApprovers)+
|
||||
len(bc.Allowlist.QQAdmins)+len(bc.Allowlist.FeishuAdmins)+len(bc.Allowlist.WeixinAdmins) > 0
|
||||
}
|
||||
|
||||
func botUsage() {
|
||||
fmt.Print(`reasonix bot — multi-channel IM bot gateway (QQ / Feishu / WeChat)
|
||||
|
||||
Usage:
|
||||
reasonix bot start [--channels qq,feishu,lark,weixin] [--dir PATH] [--model NAME]
|
||||
reasonix bot doctor [--json] [--deep]
|
||||
reasonix bot pairing list|approve|reject
|
||||
reasonix bot weixin-login [--timeout SECONDS]
|
||||
|
||||
Subcommands:
|
||||
start 启动 bot 网关
|
||||
doctor 诊断 bot 配置和连通性
|
||||
pairing 查看或批准 IM 私聊配对
|
||||
weixin-login 微信 iLink 二维码登录
|
||||
|
||||
Examples:
|
||||
reasonix bot start --channels qq,feishu
|
||||
reasonix bot start --dir /path/to/project --model deepseek-pro
|
||||
reasonix bot doctor --json
|
||||
|
||||
Configuration:
|
||||
Edit reasonix.toml:
|
||||
[bot] enabled / model / max_steps
|
||||
[bot] queue_mode / queue_cap / queue_drop
|
||||
[bot.pairing] enabled / request_ttl_minutes / max_pending_per_platform
|
||||
[bot.allowlist] enabled / users / approvers / admins / groups
|
||||
[bot.qq] enabled / app_id / app_secret_env
|
||||
[bot.feishu] enabled / app_id / app_secret_env / verification_token / mode
|
||||
[bot.weixin] enabled / account_id / token_env / api_base
|
||||
|
||||
All secrets are read from environment variables; never put keys in config files.
|
||||
`)
|
||||
}
|
||||
@@ -1,498 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/botruntime"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/testenv"
|
||||
)
|
||||
|
||||
func TestRememberBotRemoteStoresIncomingChatID(t *testing.T) {
|
||||
isolateBotUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
|
||||
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
msg := bot.InboundMessage{
|
||||
Platform: bot.PlatformWeixin,
|
||||
ChatType: bot.ChatDM,
|
||||
ChatID: "wx-chat-1",
|
||||
UserID: "wx-user-1",
|
||||
}
|
||||
if err := botruntime.RememberInbound(msg); err != nil {
|
||||
t.Fatalf("rememberBotInbound: %v", err)
|
||||
}
|
||||
if err := botruntime.RememberInbound(msg); err != nil {
|
||||
t.Fatalf("rememberBotRemote duplicate: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
if len(got.Bot.Connections) != 2 {
|
||||
t.Fatalf("connections = %d, want 2", len(got.Bot.Connections))
|
||||
}
|
||||
var wx config.BotConnectionConfig
|
||||
var fs config.BotConnectionConfig
|
||||
for _, conn := range got.Bot.Connections {
|
||||
switch conn.ID {
|
||||
case "weixin-weixin":
|
||||
wx = conn
|
||||
case "feishu-feishu":
|
||||
fs = conn
|
||||
}
|
||||
}
|
||||
if len(fs.SessionMappings) != 0 {
|
||||
t.Fatalf("feishu mappings = %+v, want none", fs.SessionMappings)
|
||||
}
|
||||
if len(wx.SessionMappings) != 1 {
|
||||
t.Fatalf("weixin mappings = %+v, want one", wx.SessionMappings)
|
||||
}
|
||||
if m := wx.SessionMappings[0]; m.RemoteID != "wx-chat-1" || m.Scope != "global" || m.WorkspaceRoot != "" || m.UpdatedAt == "" {
|
||||
t.Fatalf("weixin mapping = %+v, want global wx-chat-1 with timestamp", m)
|
||||
}
|
||||
if got := got.Bot.Allowlist.WeixinUsers; len(got) != 1 || got[0] != "wx-user-1" {
|
||||
t.Fatalf("weixin users = %+v, want wx-user-1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberBotRemoteKeepsProjectScopedConnection(t *testing.T) {
|
||||
isolateBotUserConfig(t)
|
||||
workspace := filepath.Join(testenv.TempDir(t), "project")
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
||||
ID: "feishu-project",
|
||||
Provider: "feishu",
|
||||
Domain: "feishu",
|
||||
Label: "飞书",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
WorkspaceRoot: workspace,
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
if err := botruntime.RememberInbound(bot.InboundMessage{
|
||||
Platform: bot.PlatformFeishu,
|
||||
ChatType: bot.ChatDM,
|
||||
ChatID: "oc-chat-1",
|
||||
UserID: "ou-user-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("rememberBotInbound: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
if len(got.Bot.Connections) != 1 || len(got.Bot.Connections[0].SessionMappings) != 1 {
|
||||
t.Fatalf("connections = %+v, want one project mapping", got.Bot.Connections)
|
||||
}
|
||||
if m := got.Bot.Connections[0].SessionMappings[0]; m.RemoteID != "oc-chat-1" || m.Scope != "project" || m.WorkspaceRoot != workspace {
|
||||
t.Fatalf("mapping = %+v, want project scoped remote", m)
|
||||
}
|
||||
if got := got.Bot.Allowlist.FeishuUsers; len(got) != 1 || got[0] != "ou-user-1" {
|
||||
t.Fatalf("feishu users = %+v, want ou-user-1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberBotInboundStoresGroupAllowlist(t *testing.T) {
|
||||
isolateBotUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
msg := bot.InboundMessage{
|
||||
Platform: bot.PlatformFeishu,
|
||||
ChatType: bot.ChatGroup,
|
||||
ChatID: "oc-group-1",
|
||||
UserID: "ou-user-1",
|
||||
}
|
||||
if err := botruntime.RememberInbound(msg); err != nil {
|
||||
t.Fatalf("rememberBotInbound: %v", err)
|
||||
}
|
||||
if err := botruntime.RememberInbound(msg); err != nil {
|
||||
t.Fatalf("rememberBotInbound duplicate: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
if users := got.Bot.Allowlist.FeishuUsers; len(users) != 1 || users[0] != "ou-user-1" {
|
||||
t.Fatalf("feishu users = %+v, want one ou-user-1", users)
|
||||
}
|
||||
if groups := got.Bot.Allowlist.FeishuGroups; len(groups) != 1 || groups[0] != "oc-group-1" {
|
||||
t.Fatalf("feishu groups = %+v, want one oc-group-1", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotDoctorReportsSessionMappingCounts(t *testing.T) {
|
||||
isolateBotUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
|
||||
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
|
||||
}
|
||||
cfg.Bot.Connections[0].SessionMappings = []config.BotConnectionSessionMapping{{RemoteID: "oc-chat-1", Scope: "global"}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if rc := botDoctor([]string{"--json"}); rc != 0 {
|
||||
t.Fatalf("botDoctor rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
for _, want := range []string{
|
||||
`"name":"bot.connections","status":"ok","detail":"enabled=2 total=2"`,
|
||||
`"name":"bot.connection.feishu-feishu.session_mappings","status":"ok","detail":"provider=feishu mappings=1"`,
|
||||
`"name":"bot.connection.weixin-weixin.session_mappings","status":"missing","detail":"provider=weixin mappings=0"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("bot doctor output missing %s:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotDoctorDeepReportsPairingAndRoles(t *testing.T) {
|
||||
isolateBotUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = true
|
||||
cfg.Bot.Pairing.Enabled = true
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Allowlist.FeishuUsers = []string{"ou-user"}
|
||||
cfg.Bot.Allowlist.FeishuApprovers = []string{"ou-approver"}
|
||||
cfg.Bot.Allowlist.FeishuAdmins = []string{"ou-admin"}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := bot.CreateOrRefreshPairingRequest(bot.InboundMessage{
|
||||
Platform: bot.PlatformFeishu,
|
||||
ConnectionID: "feishu-feishu",
|
||||
ChatType: bot.ChatDM,
|
||||
ChatID: "chat",
|
||||
UserID: "pending-user",
|
||||
}, bot.PairingConfig{Enabled: true}); err != nil {
|
||||
t.Fatalf("create pairing: %v", err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if rc := botDoctor([]string{"--json", "--deep"}); rc != 0 {
|
||||
t.Fatalf("botDoctor rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
for _, want := range []string{
|
||||
`"name":"bot.pairing.pending","status":"ok","detail":"1 pending"`,
|
||||
`"name":"bot.roles","status":"ok","detail":"approvers=1 admins=1"`,
|
||||
`"name":"bot.config.user","status":"ok"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("bot doctor deep output missing %s:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotPairingApproveAddsAllowlistAndFirstAdmin(t *testing.T) {
|
||||
isolateBotUserConfig(t)
|
||||
cfg := config.Default()
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
req, _, err := bot.CreateOrRefreshPairingRequest(bot.InboundMessage{
|
||||
Platform: bot.PlatformWeixin,
|
||||
ChatType: bot.ChatDM,
|
||||
ChatID: "wx-chat",
|
||||
UserID: "wx-user",
|
||||
}, bot.PairingConfig{Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create pairing: %v", err)
|
||||
}
|
||||
|
||||
if rc := botPairing([]string{"approve", req.Code}); rc != 0 {
|
||||
t.Fatalf("botPairing approve rc = %d, want 0", rc)
|
||||
}
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
if users := got.Bot.Allowlist.WeixinUsers; len(users) != 1 || users[0] != "wx-user" {
|
||||
t.Fatalf("weixin users = %+v, want wx-user", users)
|
||||
}
|
||||
if admins := got.Bot.Allowlist.WeixinAdmins; len(admins) != 1 || admins[0] != "wx-user" {
|
||||
t.Fatalf("weixin admins = %+v, want first paired admin", admins)
|
||||
}
|
||||
if approvers := got.Bot.Allowlist.WeixinApprovers; len(approvers) != 1 || approvers[0] != "wx-user" {
|
||||
t.Fatalf("weixin approvers = %+v, want first paired approver", approvers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotPairingApproveAddsUserToConnectionAccess(t *testing.T) {
|
||||
isolateBotUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
||||
ID: "feishu-lark",
|
||||
Provider: "feishu",
|
||||
Domain: "lark",
|
||||
Label: "Lark",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Access: config.BotAccessConfig{
|
||||
Enabled: true,
|
||||
PairingEnabled: true,
|
||||
Users: []string{"ou-existing"},
|
||||
},
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
req, _, err := bot.CreateOrRefreshPairingRequest(bot.InboundMessage{
|
||||
Platform: bot.PlatformFeishu,
|
||||
ConnectionID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatType: bot.ChatDM,
|
||||
ChatID: "oc-chat",
|
||||
UserID: "ou-new",
|
||||
}, bot.PairingConfig{Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create pairing: %v", err)
|
||||
}
|
||||
|
||||
if rc := botPairing([]string{"approve", req.Code}); rc != 0 {
|
||||
t.Fatalf("botPairing approve rc = %d, want 0", rc)
|
||||
}
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
if users := got.Bot.Allowlist.FeishuUsers; len(users) != 0 {
|
||||
t.Fatalf("global feishu users = %+v, want unchanged global allowlist", users)
|
||||
}
|
||||
if len(got.Bot.Connections) != 1 {
|
||||
t.Fatalf("connections = %+v, want one connection", got.Bot.Connections)
|
||||
}
|
||||
access := got.Bot.Connections[0].Access
|
||||
if !access.Enabled {
|
||||
t.Fatal("connection access disabled after approval, want enabled")
|
||||
}
|
||||
for _, want := range []string{"ou-existing", "ou-new"} {
|
||||
if !hasTestString(access.Users, want) {
|
||||
t.Fatalf("connection users = %+v, want %s", access.Users, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotDoctorPrefersUserBotSettingsOverProjectBotConfig(t *testing.T) {
|
||||
isolateBotUserConfig(t)
|
||||
userCfg := config.Default()
|
||||
userCfg.Bot.Enabled = true
|
||||
userCfg.Bot.Allowlist.Enabled = true
|
||||
userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-user"}
|
||||
userCfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
|
||||
project := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[bot]
|
||||
enabled = false
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
t.Chdir(project)
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if rc := botDoctor([]string{"--json"}); rc != 0 {
|
||||
t.Fatalf("botDoctor rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
for _, want := range []string{
|
||||
`"name":"bot.enabled","status":"ok"`,
|
||||
`"name":"bot.connections","status":"ok","detail":"enabled=1 total=1"`,
|
||||
`"name":"bot.connection.feishu-lark.session_mappings","status":"missing","detail":"provider=feishu mappings=0"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("bot doctor output missing %s:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotDoctorUsesProjectBotConfigWhenUserBotIsUnconfigured(t *testing.T) {
|
||||
isolateBotUserConfig(t)
|
||||
projectCfg := config.Default()
|
||||
projectCfg.Bot.Enabled = true
|
||||
projectCfg.Bot.Allowlist.AllowAll = true
|
||||
projectCfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := projectCfg.SaveTo("reasonix.toml"); err != nil {
|
||||
t.Fatalf("save project config: %v", err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if rc := botDoctor([]string{"--json"}); rc != 0 {
|
||||
t.Fatalf("botDoctor rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
for _, want := range []string{
|
||||
`"name":"bot.enabled","status":"ok"`,
|
||||
`"name":"bot.connections","status":"ok","detail":"enabled=1 total=1"`,
|
||||
`"name":"bot.allowlist","status":"open"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("bot doctor output missing %s:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotDoctorUsesProjectBotConfigWhenUserConfigOnlyHasBotDefaults(t *testing.T) {
|
||||
isolateBotUserConfig(t)
|
||||
userCfg := config.Default()
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
projectCfg := config.Default()
|
||||
projectCfg.Bot.Enabled = true
|
||||
projectCfg.Bot.Allowlist.AllowAll = true
|
||||
projectCfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := projectCfg.SaveTo("reasonix.toml"); err != nil {
|
||||
t.Fatalf("save project config: %v", err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if rc := botDoctor([]string{"--json"}); rc != 0 {
|
||||
t.Fatalf("botDoctor rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
for _, want := range []string{
|
||||
`"name":"bot.enabled","status":"ok"`,
|
||||
`"name":"bot.connections","status":"ok","detail":"enabled=1 total=1"`,
|
||||
`"name":"bot.allowlist","status":"open"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("bot doctor output missing %s:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotConnectionChannelConfigsKeepFeishuAndLarkSeparate(t *testing.T) {
|
||||
connections := []config.BotConnectionConfig{
|
||||
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, Model: "feishu-model", WorkspaceRoot: "/feishu"},
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Model: "lark-model", WorkspaceRoot: "/lark"},
|
||||
}
|
||||
channels := botruntime.ConnectionChannelConfigs(connections, true, true)
|
||||
if channels["feishu-feishu"].Model != "feishu-model" || channels["feishu-feishu"].WorkspaceRoot != "/feishu" {
|
||||
t.Fatalf("feishu channel = %+v, want feishu override", channels["feishu-feishu"])
|
||||
}
|
||||
if channels["feishu-lark"].Model != "lark-model" || channels["feishu-lark"].WorkspaceRoot != "/lark" {
|
||||
t.Fatalf("lark channel = %+v, want lark override", channels["feishu-lark"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAdapterBindingsCreateSeparateFeishuAndLarkInstances(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-feishu", AppSecretEnv: "FEISHU_BOT_APP_SECRET"}},
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-lark", AppSecretEnv: "LARK_BOT_APP_SECRET"}},
|
||||
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, Credential: config.BotConnectionCredential{AccountID: "wx-account", TokenEnv: "WEIXIN_BOT_TOKEN"}},
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
bindings := botruntime.AdapterBindings(cfg, map[bot.Platform]bool{bot.PlatformFeishu: true, bot.PlatformWeixin: true}, nil, logger)
|
||||
|
||||
got := map[string]bot.AdapterBinding{}
|
||||
for _, binding := range bindings {
|
||||
got[binding.ID] = binding
|
||||
}
|
||||
for _, id := range []string{"feishu-feishu", "feishu-lark", "weixin-weixin"} {
|
||||
if got[id].Adapter == nil {
|
||||
t.Fatalf("binding %s missing from %+v", id, bindings)
|
||||
}
|
||||
}
|
||||
if got["feishu-feishu"].Domain != "feishu" || got["feishu-lark"].Domain != "lark" {
|
||||
t.Fatalf("domains = feishu:%q lark:%q, want separate domains", got["feishu-feishu"].Domain, got["feishu-lark"].Domain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAdapterBindingsIsolateRequestedFeishuDomain(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-feishu", AppSecretEnv: "FEISHU_BOT_APP_SECRET"}},
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-lark", AppSecretEnv: "LARK_BOT_APP_SECRET"}},
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
enabled := map[bot.Platform]bool{bot.PlatformFeishu: true}
|
||||
|
||||
larkOnly := botruntime.AdapterBindings(cfg, enabled, botruntime.RequestedFeishuDomains([]string{"lark"}), logger)
|
||||
if len(larkOnly) != 1 || larkOnly[0].ID != "feishu-lark" {
|
||||
t.Fatalf("--channels lark bindings = %+v, want only feishu-lark", larkOnly)
|
||||
}
|
||||
|
||||
feishuOnly := botruntime.AdapterBindings(cfg, enabled, botruntime.RequestedFeishuDomains([]string{"feishu"}), logger)
|
||||
if len(feishuOnly) != 1 || feishuOnly[0].ID != "feishu-feishu" {
|
||||
t.Fatalf("--channels feishu bindings = %+v, want only feishu-feishu", feishuOnly)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberBotInboundUsesConnectionID(t *testing.T) {
|
||||
isolateBotUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
if err := botruntime.RememberInbound(bot.InboundMessage{
|
||||
Platform: bot.PlatformFeishu,
|
||||
ConnectionID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatType: bot.ChatDM,
|
||||
ChatID: "oc-lark-chat",
|
||||
UserID: "ou-lark-user",
|
||||
}); err != nil {
|
||||
t.Fatalf("rememberBotInbound: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
var feishuConn, larkConn config.BotConnectionConfig
|
||||
for _, conn := range got.Bot.Connections {
|
||||
switch conn.ID {
|
||||
case "feishu-feishu":
|
||||
feishuConn = conn
|
||||
case "feishu-lark":
|
||||
larkConn = conn
|
||||
}
|
||||
}
|
||||
if len(feishuConn.SessionMappings) != 0 {
|
||||
t.Fatalf("feishu mappings = %+v, want none", feishuConn.SessionMappings)
|
||||
}
|
||||
if len(larkConn.SessionMappings) != 1 || larkConn.SessionMappings[0].RemoteID != "oc-lark-chat" {
|
||||
t.Fatalf("lark mappings = %+v, want lark chat only", larkConn.SessionMappings)
|
||||
}
|
||||
}
|
||||
|
||||
func isolateBotUserConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
||||
t.Chdir(testenv.TempDir(t))
|
||||
}
|
||||
|
||||
func hasTestString(values []string, want string) bool {
|
||||
return slices.Contains(values, want)
|
||||
}
|
||||
+1
-4
@@ -167,9 +167,6 @@ func RunWithBuildInfo(args []string, info BuildInfo) int {
|
||||
case "review":
|
||||
configureCLIThemeFromConfig()
|
||||
return reviewCommand(rest)
|
||||
case "bot":
|
||||
configureCLIThemeFromConfig()
|
||||
return botCommand(rest, version)
|
||||
case "upgrade", "update":
|
||||
configureCLIThemeFromConfig()
|
||||
return upgradeCommand(rest, version)
|
||||
@@ -210,7 +207,7 @@ func isDefaultInteractiveFlag(arg string) bool {
|
||||
|
||||
func shouldMigrateLegacyConfigForCLI(cmd string) bool {
|
||||
switch cmd {
|
||||
case "", "run", "chat", "code", "serve", "web", "setup", "config", "init", "acp", "mcp", "remote", "plugin", "subagent", "doctor", "bot", "upgrade", "update", "login", "whoami", "logout":
|
||||
case "", "run", "chat", "code", "serve", "web", "setup", "config", "init", "acp", "mcp", "remote", "plugin", "subagent", "doctor", "upgrade", "update", "login", "whoami", "logout":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -270,16 +270,6 @@ func cliCompletionRootSpec() cliCompletionSpec {
|
||||
completionSpec("review", []cliCompletionFlag{
|
||||
completionFlag("--base --commit --instructions", cliCompletionStaticValue), model, help,
|
||||
}),
|
||||
completionSpec("bot", []cliCompletionFlag{help},
|
||||
completionSpec("start", []cliCompletionFlag{completionFlag("--channels --dir", cliCompletionStaticValue), model, help}),
|
||||
completionSpec("doctor", []cliCompletionFlag{completionFlag("--json --deep", cliCompletionNoValue), help}),
|
||||
completionSpec("pairing", []cliCompletionFlag{help},
|
||||
completionSpec("list", []cliCompletionFlag{help}),
|
||||
completionSpec("approve", []cliCompletionFlag{help}),
|
||||
completionSpecWithAliases("reject", []string{"deny"}, []cliCompletionFlag{help}),
|
||||
),
|
||||
completionSpec("weixin-login", []cliCompletionFlag{completionFlag("--timeout", cliCompletionStaticValue), help}),
|
||||
),
|
||||
completionSpecWithAliases("upgrade", []string{"update"}, []cliCompletionFlag{
|
||||
completionFlag("--check --force", cliCompletionNoValue), completionFlag("--channel", cliCompletionStaticValue), help,
|
||||
}),
|
||||
|
||||
@@ -1102,25 +1102,10 @@ func TestNormalizeLegacyMimoCustomProvidersRecognizesBareModelRefs(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLegacyMimoCustomProvidersScansBotRefs(t *testing.T) {
|
||||
c := Default()
|
||||
c.Bot.Model = "mimo-pro"
|
||||
c.Bot.Connections = []BotConnectionConfig{{Model: "mimo-flash"}}
|
||||
if !normalizeLegacyMimoCustomProviders(c) {
|
||||
t.Fatal("legacy bot MiMo migration did not report a change")
|
||||
}
|
||||
if _, ok := c.Provider("mimo-pro"); !ok {
|
||||
t.Fatal("mimo-pro provider missing")
|
||||
}
|
||||
if _, ok := c.Provider("mimo-flash"); !ok {
|
||||
t.Fatal("mimo-flash provider missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLegacyDesktopProviderAccessIncludesUnconfiguredMimoRefs(t *testing.T) {
|
||||
c := Default()
|
||||
c.DefaultModel = "mimo-pro"
|
||||
c.Bot.Connections = []BotConnectionConfig{{Model: "mimo-flash"}}
|
||||
c.Agent.SubagentModels = map[string]string{"explore": "mimo-flash"}
|
||||
normalizeLegacyMimoCustomProviders(c)
|
||||
NormalizeLegacyDesktopProviderAccess(c)
|
||||
access := desktopProviderAccessMap(c.Desktop.ProviderAccess)
|
||||
|
||||
+4
-192
@@ -45,7 +45,7 @@ type Config struct {
|
||||
Memory MemoryConfig `toml:"memory"`
|
||||
Statusline StatuslineConfig `toml:"statusline"`
|
||||
LSP LSPConfig `toml:"lsp"`
|
||||
Bot BotConfig `toml:"bot"`
|
||||
Bot map[string]any `toml:"bot"` // opaque; see passthrough.go
|
||||
Serve ServeConfig `toml:"serve"`
|
||||
Secrets SecretsConfig `toml:"secrets"`
|
||||
Remote RemoteConfig `toml:"remote"`
|
||||
@@ -692,179 +692,6 @@ type StatuslineConfig struct {
|
||||
Command string `toml:"command"`
|
||||
}
|
||||
|
||||
// BotConfig 控制多渠道 IM bot 消息网关。
|
||||
type BotConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Model string `toml:"model"` // 用于 bot 的模型名,空则用 default_model
|
||||
ToolApprovalMode string `toml:"tool_approval_mode"`
|
||||
MaxSteps int `toml:"max_steps"`
|
||||
DebounceMs int `toml:"debounce_ms"` // 消息合并窗口,毫秒
|
||||
QueueMode string `toml:"queue_mode"` // steer|followup|collect|interrupt
|
||||
QueueCap int `toml:"queue_cap"`
|
||||
QueueDrop string `toml:"queue_drop"` // summarize|old|new
|
||||
IgnoreSelfMessages bool `toml:"ignore_self_messages"`
|
||||
SelfUserIDs BotSelfUserIDs `toml:"self_user_ids"`
|
||||
Control BotControlConfig `toml:"control"`
|
||||
Pairing BotPairingConfig `toml:"pairing"`
|
||||
Allowlist BotAllowlist `toml:"allowlist"`
|
||||
QQ QQBotConfig `toml:"qq"`
|
||||
Feishu FeishuBotConfig `toml:"feishu"`
|
||||
Weixin WeixinBotConfig `toml:"weixin"`
|
||||
Routes []BotRouteConfig `toml:"routes"`
|
||||
Connections []BotConnectionConfig `toml:"connections"`
|
||||
// DesktopWatchers persists /desktop watch subscriptions so god-view
|
||||
// notifications survive a desktop restart. Managed by the desktop bot
|
||||
// bridge, not the settings UI.
|
||||
DesktopWatchers []BotDesktopWatcherConfig `toml:"desktop_watchers"`
|
||||
}
|
||||
|
||||
// BotDesktopWatcherConfig is one bot chat subscribed to desktop events
|
||||
// (/desktop watch on).
|
||||
type BotDesktopWatcherConfig struct {
|
||||
Platform string `toml:"platform"`
|
||||
ConnectionID string `toml:"connection_id"`
|
||||
Domain string `toml:"domain"`
|
||||
ChatType string `toml:"chat_type"`
|
||||
ChatID string `toml:"chat_id"`
|
||||
}
|
||||
|
||||
type BotSelfUserIDs struct {
|
||||
QQ []string `toml:"qq"`
|
||||
Feishu []string `toml:"feishu"`
|
||||
Weixin []string `toml:"weixin"`
|
||||
}
|
||||
|
||||
type BotControlConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Addr string `toml:"addr"`
|
||||
TokenEnv string `toml:"token_env"`
|
||||
}
|
||||
|
||||
type BotRouteConfig struct {
|
||||
ConnectionID string `toml:"connection_id"`
|
||||
Platform string `toml:"platform"`
|
||||
ChatType string `toml:"chat_type"`
|
||||
ChatID string `toml:"chat_id"`
|
||||
UserID string `toml:"user_id"`
|
||||
ThreadID string `toml:"thread_id"`
|
||||
Model string `toml:"model"`
|
||||
ToolApprovalMode string `toml:"tool_approval_mode"`
|
||||
WorkspaceRoot string `toml:"workspace_root"`
|
||||
}
|
||||
|
||||
// BotAllowlist 控制哪些用户可以使用 bot。
|
||||
type BotAllowlist struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
AllowAll bool `toml:"allow_all"`
|
||||
QQUsers []string `toml:"qq_users"`
|
||||
FeishuUsers []string `toml:"feishu_users"`
|
||||
WeixinUsers []string `toml:"weixin_users"`
|
||||
QQApprovers []string `toml:"qq_approvers"`
|
||||
FeishuApprovers []string `toml:"feishu_approvers"`
|
||||
WeixinApprovers []string `toml:"weixin_approvers"`
|
||||
QQAdmins []string `toml:"qq_admins"`
|
||||
FeishuAdmins []string `toml:"feishu_admins"`
|
||||
WeixinAdmins []string `toml:"weixin_admins"`
|
||||
QQGroups []string `toml:"qq_groups"`
|
||||
FeishuGroups []string `toml:"feishu_groups"`
|
||||
WeixinGroups []string `toml:"weixin_groups"`
|
||||
}
|
||||
|
||||
type BotPairingConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
RequestTTLMinutes int `toml:"request_ttl_minutes"`
|
||||
MaxPendingPerPlatform int `toml:"max_pending_per_platform"`
|
||||
}
|
||||
|
||||
// BotAccessConfig controls who may use one concrete bot connection.
|
||||
type BotAccessConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
AllowAll bool `toml:"allow_all"`
|
||||
PairingEnabled bool `toml:"pairing_enabled"`
|
||||
Users []string `toml:"users"`
|
||||
Groups []string `toml:"groups"`
|
||||
Approvers []string `toml:"approvers"`
|
||||
Admins []string `toml:"admins"`
|
||||
}
|
||||
|
||||
// QQBotConfig QQ 官方 Bot API v2 配置。
|
||||
type QQBotConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
AppID string `toml:"app_id"`
|
||||
AppSecretEnv string `toml:"app_secret_env"` // 环境变量名,如 QQ_BOT_APP_SECRET
|
||||
Sandbox bool `toml:"sandbox"` // true 使用 QQ 沙箱 API / gateway
|
||||
Model string `toml:"model"`
|
||||
ToolApprovalMode string `toml:"tool_approval_mode"`
|
||||
WorkspaceRoot string `toml:"workspace_root"`
|
||||
Access BotAccessConfig `toml:"access"`
|
||||
}
|
||||
|
||||
// FeishuBotConfig 飞书自建应用 Bot 配置。
|
||||
type FeishuBotConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Domain string `toml:"domain"` // feishu(默认)| lark
|
||||
AppID string `toml:"app_id"`
|
||||
AppSecretEnv string `toml:"app_secret_env"` // 如 FEISHU_BOT_APP_SECRET
|
||||
VerificationToken string `toml:"verification_token"` // 事件订阅验证 token
|
||||
Mode string `toml:"mode"` // webhook(默认)| websocket
|
||||
WebhookPort int `toml:"webhook_port"` // webhook 模式端口
|
||||
RequireMention bool `toml:"require_mention"`
|
||||
// OutboundMediaRoots contains absolute local directories the loopback /send
|
||||
// control API may attach files from. Media refs must be bare filenames and
|
||||
// must exist in exactly one configured root. Empty (the default) disables
|
||||
// outbound file sending.
|
||||
OutboundMediaRoots []string `toml:"outbound_media_roots"`
|
||||
}
|
||||
|
||||
// WeixinBotConfig 微信 iLink Bot 配置。
|
||||
type WeixinBotConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
AccountID string `toml:"account_id"`
|
||||
TokenEnv string `toml:"token_env"` // 环境变量名,如 WEIXIN_BOT_TOKEN
|
||||
APIBase string `toml:"api_base"` // iLink API base URL
|
||||
}
|
||||
|
||||
// BotConnectionConfig is the desktop-friendly connection record for IM bot
|
||||
// channels. It keeps install/runtime state separate from legacy per-provider
|
||||
// knobs so the UI can expose a simple "connect first" flow while old configs
|
||||
// keep working.
|
||||
type BotConnectionConfig struct {
|
||||
ID string `toml:"id"`
|
||||
Provider string `toml:"provider"` // qq|feishu|weixin
|
||||
Domain string `toml:"domain"` // feishu|lark|weixin|qq
|
||||
Label string `toml:"label"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
Status string `toml:"status"` // disconnected|pending|connected|error
|
||||
Model string `toml:"model"`
|
||||
ToolApprovalMode string `toml:"tool_approval_mode"`
|
||||
WorkspaceRoot string `toml:"workspace_root"`
|
||||
Access BotAccessConfig `toml:"access"`
|
||||
Credential BotConnectionCredential `toml:"credential"`
|
||||
SessionMappings []BotConnectionSessionMapping `toml:"session_mappings"`
|
||||
LastError string `toml:"last_error"`
|
||||
CreatedAt string `toml:"created_at"`
|
||||
UpdatedAt string `toml:"updated_at"`
|
||||
}
|
||||
|
||||
type BotConnectionCredential struct {
|
||||
AppID string `toml:"app_id"`
|
||||
AppSecretEnv string `toml:"app_secret_env"`
|
||||
AccountID string `toml:"account_id"`
|
||||
TokenEnv string `toml:"token_env"`
|
||||
}
|
||||
|
||||
type BotConnectionSessionMapping struct {
|
||||
RemoteID string `toml:"remote_id"`
|
||||
SessionID string `toml:"session_id"`
|
||||
SessionSource string `toml:"session_source"`
|
||||
ChatType string `toml:"chat_type"`
|
||||
UserID string `toml:"user_id"`
|
||||
ThreadID string `toml:"thread_id"`
|
||||
Scope string `toml:"scope"`
|
||||
WorkspaceRoot string `toml:"workspace_root"`
|
||||
UpdatedAt string `toml:"updated_at"`
|
||||
}
|
||||
|
||||
// ServeConfig controls the HTTP serve frontend security settings.
|
||||
type ServeConfig struct {
|
||||
// AuthMode selects the authentication mode for the HTTP serve frontend.
|
||||
@@ -1219,7 +1046,7 @@ type AgentConfig struct {
|
||||
SystemPromptFile string `toml:"system_prompt_file"`
|
||||
// Deprecated compatibility fields. Old TOML and desktop clients may still
|
||||
// send them, but config loading normalizes both to zero and rendering omits
|
||||
// them. One-off CLI and unattended bot limits remain separate controls.
|
||||
// them. The one-off CLI limit remains a separate control.
|
||||
MaxSteps int `toml:"max_steps"`
|
||||
PlannerMaxSteps int `toml:"planner_max_steps"`
|
||||
Temperature float64 `toml:"temperature"`
|
||||
@@ -1784,23 +1611,8 @@ func Default() *Config {
|
||||
Sandbox: SandboxConfig{Network: true, HostAuthorities: []string{"ssh_agent"}},
|
||||
// LSP tools on by default, but dormant until a language server is on PATH;
|
||||
// a missing server yields an install hint rather than an error.
|
||||
LSP: LSPConfig{Enabled: true},
|
||||
Network: NetworkConfig{ProxyMode: netclient.ModeAuto},
|
||||
Bot: BotConfig{
|
||||
ToolApprovalMode: "ask",
|
||||
MaxSteps: 0,
|
||||
DebounceMs: 1500,
|
||||
QueueMode: "steer",
|
||||
QueueCap: 20,
|
||||
QueueDrop: "summarize",
|
||||
IgnoreSelfMessages: true,
|
||||
Control: BotControlConfig{Addr: "127.0.0.1:37913", TokenEnv: "REASONIX_BOT_CONTROL_TOKEN"},
|
||||
Pairing: BotPairingConfig{Enabled: true, RequestTTLMinutes: 60, MaxPendingPerPlatform: 3},
|
||||
Allowlist: BotAllowlist{Enabled: true},
|
||||
QQ: QQBotConfig{AppSecretEnv: "QQ_BOT_APP_SECRET"},
|
||||
Feishu: FeishuBotConfig{Domain: "feishu", AppSecretEnv: "FEISHU_BOT_APP_SECRET", Mode: "webhook", WebhookPort: 8080, RequireMention: true},
|
||||
Weixin: WeixinBotConfig{AccountID: "default", TokenEnv: "WEIXIN_BOT_TOKEN", APIBase: "https://ilinkai.weixin.qq.com"},
|
||||
},
|
||||
LSP: LSPConfig{Enabled: true},
|
||||
Network: NetworkConfig{ProxyMode: netclient.ModeAuto},
|
||||
Providers: deepSeekDefaultProviders(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,12 +183,8 @@ func credentialEnvNamesFromConfig(cfg *Config) []string {
|
||||
for _, p := range cfg.Providers {
|
||||
add(p.APIKeyEnv)
|
||||
}
|
||||
add(cfg.Bot.QQ.AppSecretEnv)
|
||||
add(cfg.Bot.Feishu.AppSecretEnv)
|
||||
add(cfg.Bot.Weixin.TokenEnv)
|
||||
for _, conn := range cfg.Bot.Connections {
|
||||
add(conn.Credential.AppSecretEnv)
|
||||
add(conn.Credential.TokenEnv)
|
||||
for _, name := range passthroughCredentialEnvNames(cfg.Bot) {
|
||||
add(name)
|
||||
}
|
||||
for _, h := range cfg.Remote.Hosts {
|
||||
add(h.PassphraseEnv)
|
||||
|
||||
@@ -112,7 +112,7 @@ func migrateRetiredDeepSeekModelRefs(c *Config) bool {
|
||||
// retargetModelRefs applies rewrite to every field holding a provider/model ref.
|
||||
// Missing one leaves a ref pointing at what the others just stopped naming.
|
||||
func retargetModelRefs(c *Config, rewrite func(string) string) {
|
||||
for _, field := range append([]*string{&c.DefaultModel, &c.Bot.Model}, c.roleModelRefTargets()...) {
|
||||
for _, field := range append([]*string{&c.DefaultModel}, c.roleModelRefTargets()...) {
|
||||
if ref := strings.TrimSpace(*field); ref != "" {
|
||||
*field = rewrite(ref)
|
||||
}
|
||||
|
||||
@@ -14,12 +14,6 @@ func TestDefaultReasoningLanguageAuto(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultBotRunsWithoutStepLimit(t *testing.T) {
|
||||
if got := Default().Bot.MaxSteps; got != 0 {
|
||||
t.Fatalf("default bot max_steps = %d, want continuous 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultDeepSeekUsesAnthropicWebSearch(t *testing.T) {
|
||||
cfg := Default()
|
||||
for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
|
||||
|
||||
@@ -1541,8 +1541,8 @@ temperature = 0.8
|
||||
if cfg.DefaultModel != "deepseek-pro" {
|
||||
t.Fatalf("default_model = %q, want project config to keep overriding unrelated fields", cfg.DefaultModel)
|
||||
}
|
||||
if cfg.Bot.MaxSteps != 21 {
|
||||
t.Fatalf("bot.max_steps = %d, want independent bot limit preserved", cfg.Bot.MaxSteps)
|
||||
if got := cfg.Bot["max_steps"]; got != int64(21) {
|
||||
t.Fatalf("bot.max_steps = %v, want the [bot] table preserved", got)
|
||||
}
|
||||
for _, path := range []string{userPath, filepath.Join(root, "reasonix.toml")} {
|
||||
raw, err := os.ReadFile(path)
|
||||
|
||||
+1
-11
@@ -251,9 +251,6 @@ func LoadBuiltinDefaultsForRoot(root string) *Config {
|
||||
cfg := Default()
|
||||
cfg.Plugins = nil
|
||||
cfg.Skills = SkillsConfig{}
|
||||
cfg.Bot.Enabled = false
|
||||
cfg.Bot.Connections = nil
|
||||
cfg.Bot.Routes = nil
|
||||
cfg.Statusline.Command = ""
|
||||
cfg.LSP.Enabled = false
|
||||
cfg.setExpansionEnv(nil)
|
||||
@@ -1271,13 +1268,10 @@ func legacyMimoConfigRefs(c *Config) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
refs := append([]string{c.DefaultModel, c.Bot.Model}, c.roleModelRefs()...)
|
||||
refs := append([]string{c.DefaultModel}, c.roleModelRefs()...)
|
||||
for _, ref := range c.Agent.SubagentModels {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
for _, conn := range c.Bot.Connections {
|
||||
refs = append(refs, conn.Model)
|
||||
}
|
||||
refs = append(refs, c.Desktop.ProviderAccess...)
|
||||
return refs
|
||||
}
|
||||
@@ -1434,10 +1428,6 @@ func NormalizeLegacyDesktopProviderAccess(c *Config) {
|
||||
for _, ref := range c.Agent.SubagentModels {
|
||||
addRef(ref)
|
||||
}
|
||||
addRef(c.Bot.Model)
|
||||
for _, conn := range c.Bot.Connections {
|
||||
addRef(conn.Model)
|
||||
}
|
||||
for i := range c.Providers {
|
||||
p := &c.Providers[i]
|
||||
if legacyMimoProviderName(p.Name) != "" && len(p.ModelList()) > 0 {
|
||||
|
||||
@@ -29,7 +29,6 @@ type legacyConfig struct {
|
||||
MCPServers map[string]legacyMCPServer `json:"mcpServers"`
|
||||
MCPEnv map[string]map[string]string `json:"mcpEnv"`
|
||||
MCPDisabled []string `json:"mcpDisabled"`
|
||||
QQ legacyQQConfig `json:"qq"`
|
||||
}
|
||||
|
||||
type legacyMCPServer struct {
|
||||
@@ -43,15 +42,6 @@ type legacyMCPServer struct {
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type legacyQQConfig struct {
|
||||
AppID string `json:"appId"`
|
||||
AppSecret string `json:"appSecret"`
|
||||
Sandbox bool `json:"sandbox"`
|
||||
Enabled bool `json:"enabled"`
|
||||
OwnerOpenID string `json:"ownerOpenId"`
|
||||
Allowlist []string `json:"allowlist"`
|
||||
}
|
||||
|
||||
// MigrationResult summarizes a one-time legacy import for the boot-time notice.
|
||||
type MigrationResult struct {
|
||||
From string
|
||||
@@ -162,11 +152,6 @@ func (r Roots) MigrateLegacyIfNeededForRoot(root string) (*MigrationResult, erro
|
||||
" — it was applied to the built-in DeepSeek providers; verify models if this endpoint is not DeepSeek-compatible")
|
||||
}
|
||||
}
|
||||
if qqSecret := strings.TrimSpace(legacy.QQ.AppSecret); qqSecret != "" {
|
||||
envLines = append(envLines, "QQ_BOT_APP_SECRET="+qqSecret)
|
||||
res.Warnings = append(res.Warnings, "your previous QQ Bot App Secret was saved to reasonix's credentials store")
|
||||
}
|
||||
migrateLegacyQQConfig(cfg, legacy.QQ)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create config dir: %w", err)
|
||||
@@ -475,43 +460,6 @@ func credentialLines(assignments map[string]string) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
func migrateLegacyQQConfig(cfg *Config, legacy legacyQQConfig) {
|
||||
if cfg == nil || !legacyQQConfigured(legacy) {
|
||||
return
|
||||
}
|
||||
cfg.Bot.Enabled = cfg.Bot.Enabled || legacy.Enabled
|
||||
cfg.Bot.QQ.Enabled = legacy.Enabled
|
||||
cfg.Bot.QQ.AppID = strings.TrimSpace(legacy.AppID)
|
||||
cfg.Bot.QQ.AppSecretEnv = "QQ_BOT_APP_SECRET"
|
||||
cfg.Bot.QQ.Sandbox = legacy.Sandbox
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Allowlist.QQUsers = mergeUniqueTrimmed(cfg.Bot.Allowlist.QQUsers, legacy.OwnerOpenID)
|
||||
cfg.Bot.Allowlist.QQUsers = mergeUniqueTrimmed(cfg.Bot.Allowlist.QQUsers, legacy.Allowlist...)
|
||||
}
|
||||
|
||||
func legacyQQConfigured(legacy legacyQQConfig) bool {
|
||||
return legacy.Enabled ||
|
||||
strings.TrimSpace(legacy.AppID) != "" ||
|
||||
strings.TrimSpace(legacy.AppSecret) != "" ||
|
||||
strings.TrimSpace(legacy.OwnerOpenID) != "" ||
|
||||
len(legacy.Allowlist) > 0 ||
|
||||
legacy.Sandbox
|
||||
}
|
||||
|
||||
func mergeUniqueTrimmed(base []string, values ...string) []string {
|
||||
seen := make(map[string]bool, len(base)+len(values))
|
||||
out := make([]string, 0, len(base)+len(values))
|
||||
for _, value := range append(base, values...) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func migrateLegacyTOMLIfNeeded(dest, home string) (*MigrationResult, error) {
|
||||
for _, src := range legacyTOMLPaths(dest, home) {
|
||||
if src == "" || filepath.Clean(src) == filepath.Clean(dest) {
|
||||
|
||||
@@ -94,55 +94,6 @@ func TestMigrateImportsKeyPluginsAndLang(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateImportsLegacyQQConfig(t *testing.T) {
|
||||
src, dest, _ := legacyHome(t)
|
||||
writeLegacy(t, src, `{
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"appId": "qq-app-id",
|
||||
"appSecret": "qq-secret",
|
||||
"sandbox": true,
|
||||
"ownerOpenId": " owner-openid ",
|
||||
"allowlist": ["owner-openid", " member-openid ", "member-openid", ""]
|
||||
}
|
||||
}`)
|
||||
|
||||
res, err := MigrateLegacyIfNeeded()
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatal("expected migration result")
|
||||
}
|
||||
envData, err := os.ReadFile(UserCredentialsPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read credentials: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(envData), "QQ_BOT_APP_SECRET=qq-secret") {
|
||||
t.Fatalf("credentials missing QQ secret: %q", envData)
|
||||
}
|
||||
tomlData, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("read dest: %v", err)
|
||||
}
|
||||
toml := string(tomlData)
|
||||
for _, want := range []string{
|
||||
`[bot.qq]`,
|
||||
`enabled = true`,
|
||||
`app_id = "qq-app-id"`,
|
||||
`app_secret_env = "QQ_BOT_APP_SECRET"`,
|
||||
`sandbox = true`,
|
||||
`qq_users = ["owner-openid", "member-openid"]`,
|
||||
} {
|
||||
if !strings.Contains(toml, want) {
|
||||
t.Fatalf("migrated config missing %q:\n%s", want, toml)
|
||||
}
|
||||
}
|
||||
if strings.Contains(toml, "qq-secret") {
|
||||
t.Fatalf("migrated TOML must not contain QQ secret:\n%s", toml)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateImportsLegacyMCPStringList covers the pre-mcpServers `mcp` format
|
||||
// (#3949): `--mcp`-style strings, with mcpEnv/mcpDisabled keyed by name and
|
||||
// mcpServers winning a name collision.
|
||||
|
||||
@@ -18,9 +18,9 @@ import (
|
||||
)
|
||||
|
||||
// userEditMu serializes in-process read-modify-write cycles. The public lock
|
||||
// helpers also take a path-derived advisory file lock, so CLI, Desktop, bot, and
|
||||
// helpers also take a path-derived advisory file lock, so CLI, Desktop, and
|
||||
// other Reasonix processes cannot save stale snapshots over one another.
|
||||
// Desktop's read-only config loads (tray/view/bot-runtime paths) never write:
|
||||
// Desktop's read-only config loads (tray/view paths) never write:
|
||||
// they apply legacy migrations in memory only, and the migrated form reaches
|
||||
// disk through the first locked write path (loadDesktopUserConfigForEdit,
|
||||
// called with this lock held).
|
||||
|
||||
@@ -19,10 +19,9 @@ import (
|
||||
// TestLockUserConfigEditsSerializesRMW drives concurrent load-modify-save
|
||||
// cycles through the edit lock and checks no writer's change is dropped.
|
||||
// Without the lock, two editors load the same base config, each append their
|
||||
// own connection, and the second save silently erases the first one's entry —
|
||||
// the bot auto-session persistence vs. settings-save race this lock exists for.
|
||||
// own host, and the second save silently erases the first one's entry.
|
||||
func TestLockUserConfigEditsSerializesRMW(t *testing.T) {
|
||||
// Point the user config at a temp home: SaveTo renders bot connections only
|
||||
// Point the user config at a temp home: SaveTo renders remote hosts only
|
||||
// for user-scope paths (project configs save incrementally without them).
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
@@ -40,10 +39,9 @@ func TestLockUserConfigEditsSerializesRMW(t *testing.T) {
|
||||
unlock := LockUserConfigEdits()
|
||||
defer unlock()
|
||||
cfg := LoadForEdit(path)
|
||||
cfg.Bot.Connections = append(cfg.Bot.Connections, BotConnectionConfig{
|
||||
ID: fmt.Sprintf("conn-%d", n),
|
||||
Provider: "qq",
|
||||
Enabled: true,
|
||||
cfg.Remote.Hosts = append(cfg.Remote.Hosts, RemoteHostEntry{
|
||||
Name: fmt.Sprintf("host-%d", n),
|
||||
Host: fmt.Sprintf("host-%d.example", n),
|
||||
})
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
t.Errorf("save: %v", err)
|
||||
@@ -53,21 +51,20 @@ func TestLockUserConfigEditsSerializesRMW(t *testing.T) {
|
||||
wg.Wait()
|
||||
|
||||
cfg := LoadForEdit(path)
|
||||
if got := len(cfg.Bot.Connections); got != writers {
|
||||
t.Fatalf("connections = %d, want %d (concurrent read-modify-write dropped updates)", got, writers)
|
||||
if got := len(cfg.Remote.Hosts); got != writers {
|
||||
t.Fatalf("hosts = %d, want %d (concurrent read-modify-write dropped updates)", got, writers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentBotAndSettingsWritersKeepBothFields reproduces the reviewed
|
||||
// P1 scenario: a bot auto-session mapping writer and a settings writer race
|
||||
// on the user config. Both hold LockUserConfigEdits around their
|
||||
// TestConcurrentHostAndSettingsWritersKeepBothFields races a remote-host writer
|
||||
// and a settings writer on the user config. Both hold LockUserConfigEdits around their
|
||||
// load-modify-save cycle, so neither may ever overwrite the other's field
|
||||
// with a stale copy. Each writer also checks, under the lock, that its own
|
||||
// previous round survived — any single lost update fails the test, not just
|
||||
// one on the final round. Fault check: removing either writer's lock/unlock
|
||||
// pair makes this test fail (at least intermittently) with "previous ...
|
||||
// update lost".
|
||||
func TestConcurrentBotAndSettingsWritersKeepBothFields(t *testing.T) {
|
||||
func TestConcurrentHostAndSettingsWritersKeepBothFields(t *testing.T) {
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
path := UserConfigPath()
|
||||
@@ -80,8 +77,7 @@ func TestConcurrentBotAndSettingsWritersKeepBothFields(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
// Bot mapping writer: rewrites Bot.Connections like the botruntime
|
||||
// auto-session persistence path.
|
||||
// Host writer: rewrites Remote.Hosts like a remote-settings save.
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
@@ -89,22 +85,21 @@ func TestConcurrentBotAndSettingsWritersKeepBothFields(t *testing.T) {
|
||||
unlock := LockUserConfigEdits()
|
||||
cfg := LoadForEdit(path)
|
||||
if i > 1 {
|
||||
wantID := fmt.Sprintf("conn-%d", i-1)
|
||||
if len(cfg.Bot.Connections) != 1 || cfg.Bot.Connections[0].ID != wantID {
|
||||
wantName := fmt.Sprintf("host-%d", i-1)
|
||||
if len(cfg.Remote.Hosts) != 1 || cfg.Remote.Hosts[0].Name != wantName {
|
||||
unlock()
|
||||
t.Errorf("round %d: previous bot update lost: got %+v, want single connection %q", i, cfg.Bot.Connections, wantID)
|
||||
t.Errorf("round %d: previous host update lost: got %+v, want single host %q", i, cfg.Remote.Hosts, wantName)
|
||||
return
|
||||
}
|
||||
}
|
||||
cfg.Bot.Connections = []BotConnectionConfig{{
|
||||
ID: fmt.Sprintf("conn-%d", i),
|
||||
Provider: "feishu",
|
||||
Enabled: true,
|
||||
cfg.Remote.Hosts = []RemoteHostEntry{{
|
||||
Name: fmt.Sprintf("host-%d", i),
|
||||
Host: "remote.example",
|
||||
}}
|
||||
err := cfg.SaveTo(path)
|
||||
unlock()
|
||||
if err != nil {
|
||||
t.Errorf("bot writer save: %v", err)
|
||||
t.Errorf("host writer save: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -139,9 +134,9 @@ func TestConcurrentBotAndSettingsWritersKeepBothFields(t *testing.T) {
|
||||
}
|
||||
|
||||
final := LoadForEdit(path)
|
||||
wantID := fmt.Sprintf("conn-%d", rounds)
|
||||
if len(final.Bot.Connections) != 1 || final.Bot.Connections[0].ID != wantID {
|
||||
t.Fatalf("bot writer's last update lost: got %+v, want single connection %q", final.Bot.Connections, wantID)
|
||||
wantName := fmt.Sprintf("host-%d", rounds)
|
||||
if len(final.Remote.Hosts) != 1 || final.Remote.Hosts[0].Name != wantName {
|
||||
t.Fatalf("host writer's last update lost: got %+v, want single host %q", final.Remote.Hosts, wantName)
|
||||
}
|
||||
if final.Agent.Temperature != rounds {
|
||||
t.Fatalf("settings writer's last update lost: Temperature = %v, want %d", final.Agent.Temperature, rounds)
|
||||
@@ -231,7 +226,7 @@ func assertUserConfigLockSerializesAcrossProcesses(t *testing.T, firstHome, seco
|
||||
t.Fatalf("timed out waiting for %s", path)
|
||||
}
|
||||
|
||||
first, firstOutput := startHelper("bot", firstHome, firstTmp, aStarted, aAcquired, aRelease)
|
||||
first, firstOutput := startHelper("host", firstHome, firstTmp, aStarted, aAcquired, aRelease)
|
||||
waitForFile(aAcquired)
|
||||
second, secondOutput := startHelper("cli", secondHome, secondTmp, bStarted, bAcquired, "")
|
||||
waitForFile(bStarted)
|
||||
@@ -259,8 +254,8 @@ func assertUserConfigLockSerializesAcrossProcesses(t *testing.T, firstHome, seco
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(final.Bot.Connections) != 1 || final.Bot.Connections[0].ID != "cross-process" {
|
||||
t.Fatalf("bot update was lost: %+v", final.Bot.Connections)
|
||||
if len(final.Remote.Hosts) != 1 || final.Remote.Hosts[0].Name != "cross-process" {
|
||||
t.Fatalf("host update was lost: %+v", final.Remote.Hosts)
|
||||
}
|
||||
if got := final.CLIUpdateChannel(); got != "stable" {
|
||||
t.Fatalf("CLI channel migration was lost: %q", got)
|
||||
@@ -359,11 +354,10 @@ func TestLockUserConfigEditsHelperProcess(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch os.Getenv("REASONIX_CONFIG_LOCK_MODE") {
|
||||
case "bot":
|
||||
cfg.Bot.Connections = []BotConnectionConfig{{
|
||||
ID: "cross-process",
|
||||
Provider: "qq",
|
||||
Enabled: true,
|
||||
case "host":
|
||||
cfg.Remote.Hosts = []RemoteHostEntry{{
|
||||
Name: "cross-process",
|
||||
Host: "remote.example",
|
||||
}}
|
||||
case "cli":
|
||||
if err := cfg.SetCLIUpdateChannel("preview"); err != nil {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// renderPassthroughTable writes back a table this build decodes but does not
|
||||
// act on. The user config is shared with installs of the 1.x line, which still
|
||||
// run the IM bot gateway from [bot]; a full-file rewrite that dropped the table
|
||||
// would silently switch that gateway off and erase its allowlists.
|
||||
func renderPassthroughTable(b *strings.Builder, name string, table map[string]any) {
|
||||
if len(table) == 0 {
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := toml.NewEncoder(&buf)
|
||||
enc.Indent = ""
|
||||
if err := enc.Encode(map[string]any{name: table}); err != nil {
|
||||
return
|
||||
}
|
||||
b.Write(buf.Bytes())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// passthroughCredentialEnvNames collects the credential variables a [bot]
|
||||
// table names, at any depth, by the gateway schema's two credential keys. The
|
||||
// gateway does not run here, but its secrets still stay out of the
|
||||
// environments model-controlled subprocesses inherit.
|
||||
func passthroughCredentialEnvNames(v any) []string {
|
||||
var out []string
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
for k, e := range x {
|
||||
if s, ok := e.(string); ok && (k == "app_secret_env" || k == "token_env") {
|
||||
out = append(out, s)
|
||||
continue
|
||||
}
|
||||
out = append(out, passthroughCredentialEnvNames(e)...)
|
||||
}
|
||||
case []map[string]any:
|
||||
for _, e := range x {
|
||||
out = append(out, passthroughCredentialEnvNames(e)...)
|
||||
}
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
out = append(out, passthroughCredentialEnvNames(e)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"reasonix/internal/testenv"
|
||||
)
|
||||
|
||||
const botTableFixture = `default_model = "deepseek-flash"
|
||||
|
||||
[bot]
|
||||
enabled = true
|
||||
model = "deepseek-pro"
|
||||
max_steps = 21
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
qq_users = ["owner-openid", "member-openid"]
|
||||
|
||||
[bot.qq]
|
||||
enabled = true
|
||||
app_id = "qq-app-id"
|
||||
app_secret_env = "QQ_BOT_APP_SECRET"
|
||||
access = { enabled = true, users = ["u1"] }
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
credential = { app_id = "cli_lark", app_secret_env = "LARK_BOT_APP_SECRET" }
|
||||
session_mappings = [{ remote_id = "ou_123", session_id = "topic:1" }]
|
||||
|
||||
[[bot.routes]]
|
||||
chat_id = "oc_group"
|
||||
workspace_root = "/tmp/reasonix-route"
|
||||
`
|
||||
|
||||
func TestBotTableSurvivesAFullRewrite(t *testing.T) {
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
path := UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(botTableFixture), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := LoadForEditReadOnlyStrict(path)
|
||||
if err != nil {
|
||||
t.Fatalf("a config carrying [bot] must still load: %v", err)
|
||||
}
|
||||
if _, err := LoadForRoot(testenv.TempDir(t)); err != nil {
|
||||
t.Fatalf("runtime load with [bot]: %v", err)
|
||||
}
|
||||
cfg.Agent.Temperature = 0.3
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var want, got struct {
|
||||
Bot map[string]any `toml:"bot"`
|
||||
}
|
||||
if _, err := toml.Decode(botTableFixture, &want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := toml.Decode(string(raw), &got); err != nil {
|
||||
t.Fatalf("rewritten config does not parse: %v\n%s", err, raw)
|
||||
}
|
||||
if !reflect.DeepEqual(tomlValue(got.Bot), tomlValue(want.Bot)) {
|
||||
t.Fatalf("[bot] changed across a rewrite:\n got %#v\nwant %#v\n%s", got.Bot, want.Bot, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// tomlValue folds the two spellings of an array of tables — [[t]] and
|
||||
// t = [{...}] decode to different Go slice types — into one.
|
||||
func tomlValue(v any) any {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(x))
|
||||
for k, e := range x {
|
||||
out[k] = tomlValue(e)
|
||||
}
|
||||
return out
|
||||
case []map[string]any:
|
||||
out := make([]any, len(x))
|
||||
for i, e := range x {
|
||||
out[i] = tomlValue(e)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(x))
|
||||
for i, e := range x {
|
||||
out[i] = tomlValue(e)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotCredentialVariablesStayCredentials(t *testing.T) {
|
||||
var cfg Config
|
||||
if _, err := toml.Decode(botTableFixture, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := credentialEnvNamesFromConfig(&cfg)
|
||||
for _, want := range []string{"QQ_BOT_APP_SECRET", "LARK_BOT_APP_SECRET"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("credential names %v lost %s named under [bot]", names, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoBotTableIsNotInvented(t *testing.T) {
|
||||
out := RenderTOMLForScope(Default(), RenderScopeUser)
|
||||
var doc map[string]toml.Primitive
|
||||
if _, err := toml.Decode(out, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := doc["bot"]; ok {
|
||||
t.Fatalf("a config without [bot] rendered one:\n%s", out)
|
||||
}
|
||||
}
|
||||
+1
-296
@@ -447,165 +447,7 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if shouldRenderBot(c, defaults, scope) {
|
||||
b.WriteString("# Bot gateway: multi-channel IM bot for QQ, Feishu/Lark, and WeChat.\n")
|
||||
b.WriteString("[bot]\n")
|
||||
fmt.Fprintf(&b, "enabled = %v\n", c.Bot.Enabled)
|
||||
if c.Bot.Model != "" {
|
||||
fmt.Fprintf(&b, "model = %q\n", c.Bot.Model)
|
||||
} else {
|
||||
b.WriteString("# model = \"\" # empty = default_model\n")
|
||||
}
|
||||
if c.Bot.ToolApprovalMode != "" {
|
||||
fmt.Fprintf(&b, "tool_approval_mode = %q # ask|auto|yolo; yolo skips tool approvals only\n", c.Bot.ToolApprovalMode)
|
||||
} else {
|
||||
b.WriteString("# tool_approval_mode = \"ask\" # ask|auto|yolo; ask and plan decisions still wait\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "max_steps = %d\n", c.Bot.MaxSteps)
|
||||
fmt.Fprintf(&b, "debounce_ms = %d\n", c.Bot.DebounceMs)
|
||||
if c.Bot.QueueMode != "" {
|
||||
fmt.Fprintf(&b, "queue_mode = %q # steer|followup|collect|interrupt\n", c.Bot.QueueMode)
|
||||
} else {
|
||||
b.WriteString("# queue_mode = \"steer\" # steer|followup|collect|interrupt\n")
|
||||
}
|
||||
if c.Bot.QueueCap > 0 {
|
||||
fmt.Fprintf(&b, "queue_cap = %d\n", c.Bot.QueueCap)
|
||||
} else {
|
||||
b.WriteString("# queue_cap = 20\n")
|
||||
}
|
||||
if c.Bot.QueueDrop != "" {
|
||||
fmt.Fprintf(&b, "queue_drop = %q # summarize|old|new\n", c.Bot.QueueDrop)
|
||||
} else {
|
||||
b.WriteString("# queue_drop = \"summarize\" # summarize|old|new\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "ignore_self_messages = %v # ignore bot echo by returned message_id and configured self user ids\n", c.Bot.IgnoreSelfMessages)
|
||||
b.WriteString("\n[bot.self_user_ids]\n")
|
||||
fmt.Fprintf(&b, "qq = %s\n", renderStringArray(c.Bot.SelfUserIDs.QQ))
|
||||
fmt.Fprintf(&b, "feishu = %s\n", renderStringArray(c.Bot.SelfUserIDs.Feishu))
|
||||
fmt.Fprintf(&b, "weixin = %s\n", renderStringArray(c.Bot.SelfUserIDs.Weixin))
|
||||
b.WriteString("\n[bot.control]\n")
|
||||
fmt.Fprintf(&b, "enabled = %v # local loopback HTTP API for status/send; requires Bearer token\n", c.Bot.Control.Enabled)
|
||||
if strings.TrimSpace(c.Bot.Control.Addr) != "" {
|
||||
fmt.Fprintf(&b, "addr = %q\n", c.Bot.Control.Addr)
|
||||
} else {
|
||||
b.WriteString("# addr = \"127.0.0.1:37913\"\n")
|
||||
}
|
||||
if strings.TrimSpace(c.Bot.Control.TokenEnv) != "" {
|
||||
fmt.Fprintf(&b, "token_env = %q\n", c.Bot.Control.TokenEnv)
|
||||
} else {
|
||||
b.WriteString("# token_env = \"REASONIX_BOT_CONTROL_TOKEN\"\n")
|
||||
}
|
||||
if len(c.Bot.Routes) > 0 {
|
||||
for _, route := range c.Bot.Routes {
|
||||
b.WriteString("\n[[bot.routes]]\n")
|
||||
renderBotRoute(&b, route)
|
||||
}
|
||||
}
|
||||
if len(c.Bot.DesktopWatchers) > 0 {
|
||||
for _, watcher := range c.Bot.DesktopWatchers {
|
||||
b.WriteString("\n[[bot.desktop_watchers]]\n")
|
||||
renderBotDesktopWatcher(&b, watcher)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n[bot.pairing]\n")
|
||||
fmt.Fprintf(&b, "enabled = %v\n", c.Bot.Pairing.Enabled)
|
||||
if c.Bot.Pairing.RequestTTLMinutes > 0 {
|
||||
fmt.Fprintf(&b, "request_ttl_minutes = %d\n", c.Bot.Pairing.RequestTTLMinutes)
|
||||
} else {
|
||||
b.WriteString("# request_ttl_minutes = 60\n")
|
||||
}
|
||||
if c.Bot.Pairing.MaxPendingPerPlatform > 0 {
|
||||
fmt.Fprintf(&b, "max_pending_per_platform = %d\n", c.Bot.Pairing.MaxPendingPerPlatform)
|
||||
} else {
|
||||
b.WriteString("# max_pending_per_platform = 3\n")
|
||||
}
|
||||
b.WriteString("\n[bot.allowlist]\n")
|
||||
fmt.Fprintf(&b, "enabled = %v\n", c.Bot.Allowlist.Enabled)
|
||||
fmt.Fprintf(&b, "allow_all = %v\n", c.Bot.Allowlist.AllowAll)
|
||||
fmt.Fprintf(&b, "qq_users = %s\n", renderStringArray(c.Bot.Allowlist.QQUsers))
|
||||
fmt.Fprintf(&b, "feishu_users = %s\n", renderStringArray(c.Bot.Allowlist.FeishuUsers))
|
||||
fmt.Fprintf(&b, "weixin_users = %s\n", renderStringArray(c.Bot.Allowlist.WeixinUsers))
|
||||
fmt.Fprintf(&b, "qq_approvers = %s\n", renderStringArray(c.Bot.Allowlist.QQApprovers))
|
||||
fmt.Fprintf(&b, "feishu_approvers = %s\n", renderStringArray(c.Bot.Allowlist.FeishuApprovers))
|
||||
fmt.Fprintf(&b, "weixin_approvers = %s\n", renderStringArray(c.Bot.Allowlist.WeixinApprovers))
|
||||
fmt.Fprintf(&b, "qq_admins = %s\n", renderStringArray(c.Bot.Allowlist.QQAdmins))
|
||||
fmt.Fprintf(&b, "feishu_admins = %s\n", renderStringArray(c.Bot.Allowlist.FeishuAdmins))
|
||||
fmt.Fprintf(&b, "weixin_admins = %s\n", renderStringArray(c.Bot.Allowlist.WeixinAdmins))
|
||||
fmt.Fprintf(&b, "qq_groups = %s\n", renderStringArray(c.Bot.Allowlist.QQGroups))
|
||||
fmt.Fprintf(&b, "feishu_groups = %s\n", renderStringArray(c.Bot.Allowlist.FeishuGroups))
|
||||
fmt.Fprintf(&b, "weixin_groups = %s\n", renderStringArray(c.Bot.Allowlist.WeixinGroups))
|
||||
b.WriteString("\n[bot.qq]\n")
|
||||
fmt.Fprintf(&b, "enabled = %v\n", c.Bot.QQ.Enabled)
|
||||
fmt.Fprintf(&b, "app_id = %q\n", c.Bot.QQ.AppID)
|
||||
fmt.Fprintf(&b, "app_secret_env = %q\n", c.Bot.QQ.AppSecretEnv)
|
||||
fmt.Fprintf(&b, "sandbox = %v\n", c.Bot.QQ.Sandbox)
|
||||
if strings.TrimSpace(c.Bot.QQ.Model) != "" {
|
||||
fmt.Fprintf(&b, "model = %q\n", strings.TrimSpace(c.Bot.QQ.Model))
|
||||
}
|
||||
if strings.TrimSpace(c.Bot.QQ.ToolApprovalMode) != "" {
|
||||
fmt.Fprintf(&b, "tool_approval_mode = %q\n", strings.TrimSpace(c.Bot.QQ.ToolApprovalMode))
|
||||
}
|
||||
if strings.TrimSpace(c.Bot.QQ.WorkspaceRoot) != "" {
|
||||
fmt.Fprintf(&b, "workspace_root = %q\n", strings.TrimSpace(c.Bot.QQ.WorkspaceRoot))
|
||||
}
|
||||
if parts := renderBotAccess(c.Bot.QQ.Access); parts != "" {
|
||||
fmt.Fprintf(&b, "access = %s\n", parts)
|
||||
}
|
||||
b.WriteString("\n[bot.feishu]\n")
|
||||
fmt.Fprintf(&b, "enabled = %v\n", c.Bot.Feishu.Enabled)
|
||||
fmt.Fprintf(&b, "app_id = %q\n", c.Bot.Feishu.AppID)
|
||||
fmt.Fprintf(&b, "domain = %q\n", c.Bot.Feishu.Domain)
|
||||
fmt.Fprintf(&b, "app_secret_env = %q\n", c.Bot.Feishu.AppSecretEnv)
|
||||
fmt.Fprintf(&b, "verification_token = %q\n", c.Bot.Feishu.VerificationToken)
|
||||
fmt.Fprintf(&b, "mode = %q\n", c.Bot.Feishu.Mode)
|
||||
fmt.Fprintf(&b, "webhook_port = %d\n", c.Bot.Feishu.WebhookPort)
|
||||
fmt.Fprintf(&b, "require_mention = %v\n", c.Bot.Feishu.RequireMention)
|
||||
if len(c.Bot.Feishu.OutboundMediaRoots) > 0 {
|
||||
fmt.Fprintf(&b, "outbound_media_roots = %s\n", renderStringArray(c.Bot.Feishu.OutboundMediaRoots))
|
||||
}
|
||||
b.WriteString("\n[bot.weixin]\n")
|
||||
fmt.Fprintf(&b, "enabled = %v\n", c.Bot.Weixin.Enabled)
|
||||
fmt.Fprintf(&b, "account_id = %q\n", c.Bot.Weixin.AccountID)
|
||||
fmt.Fprintf(&b, "token_env = %q\n", c.Bot.Weixin.TokenEnv)
|
||||
fmt.Fprintf(&b, "api_base = %q\n", c.Bot.Weixin.APIBase)
|
||||
for _, conn := range c.Bot.Connections {
|
||||
b.WriteString("\n[[bot.connections]]\n")
|
||||
fmt.Fprintf(&b, "id = %q\n", conn.ID)
|
||||
fmt.Fprintf(&b, "provider = %q\n", conn.Provider)
|
||||
fmt.Fprintf(&b, "domain = %q\n", conn.Domain)
|
||||
fmt.Fprintf(&b, "label = %q\n", conn.Label)
|
||||
fmt.Fprintf(&b, "enabled = %v\n", conn.Enabled)
|
||||
fmt.Fprintf(&b, "status = %q\n", conn.Status)
|
||||
if conn.Model != "" {
|
||||
fmt.Fprintf(&b, "model = %q\n", conn.Model)
|
||||
}
|
||||
if conn.ToolApprovalMode != "" {
|
||||
fmt.Fprintf(&b, "tool_approval_mode = %q\n", conn.ToolApprovalMode)
|
||||
}
|
||||
if conn.WorkspaceRoot != "" {
|
||||
fmt.Fprintf(&b, "workspace_root = %q\n", conn.WorkspaceRoot)
|
||||
}
|
||||
if parts := renderBotAccess(conn.Access); parts != "" {
|
||||
fmt.Fprintf(&b, "access = %s\n", parts)
|
||||
}
|
||||
if conn.LastError != "" {
|
||||
fmt.Fprintf(&b, "last_error = %q\n", conn.LastError)
|
||||
}
|
||||
if conn.CreatedAt != "" {
|
||||
fmt.Fprintf(&b, "created_at = %q\n", conn.CreatedAt)
|
||||
}
|
||||
if conn.UpdatedAt != "" {
|
||||
fmt.Fprintf(&b, "updated_at = %q\n", conn.UpdatedAt)
|
||||
}
|
||||
if parts := renderBotCredential(conn.Credential); parts != "" {
|
||||
fmt.Fprintf(&b, "credential = %s\n", parts)
|
||||
}
|
||||
if len(conn.SessionMappings) > 0 {
|
||||
fmt.Fprintf(&b, "session_mappings = %s\n", renderBotSessionMappings(conn.SessionMappings))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
renderPassthroughTable(&b, "bot", c.Bot)
|
||||
|
||||
renderStorage(&b, c, scope)
|
||||
|
||||
@@ -1264,13 +1106,6 @@ func projectScopedConfigForRender(c *Config) *Config {
|
||||
return &cp
|
||||
}
|
||||
|
||||
func shouldRenderBot(c, defaults *Config, scope RenderScope) bool {
|
||||
if scope != RenderScopeProject {
|
||||
return true
|
||||
}
|
||||
return !reflect.DeepEqual(c.Bot, defaults.Bot)
|
||||
}
|
||||
|
||||
func shouldRenderSystemPrompt(c, defaults *Config, scope RenderScope) bool {
|
||||
if scope == RenderScopeFull {
|
||||
return true
|
||||
@@ -1554,136 +1389,6 @@ func renderIntMap(m map[string]int) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderBotCredential(cred BotConnectionCredential) string {
|
||||
parts := make(map[string]string)
|
||||
if cred.AppID != "" {
|
||||
parts["app_id"] = cred.AppID
|
||||
}
|
||||
if cred.AppSecretEnv != "" {
|
||||
parts["app_secret_env"] = cred.AppSecretEnv
|
||||
}
|
||||
if cred.AccountID != "" {
|
||||
parts["account_id"] = cred.AccountID
|
||||
}
|
||||
if cred.TokenEnv != "" {
|
||||
parts["token_env"] = cred.TokenEnv
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return renderStringMap(parts)
|
||||
}
|
||||
|
||||
func renderBotAccess(access BotAccessConfig) string {
|
||||
hasList := len(access.Users) > 0 || len(access.Groups) > 0 || len(access.Approvers) > 0 || len(access.Admins) > 0
|
||||
if !access.Enabled && !access.AllowAll && !access.PairingEnabled && !hasList {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
parts = append(parts, fmt.Sprintf("enabled = %v", access.Enabled))
|
||||
parts = append(parts, fmt.Sprintf("allow_all = %v", access.AllowAll))
|
||||
parts = append(parts, fmt.Sprintf("pairing_enabled = %v", access.PairingEnabled))
|
||||
if len(access.Users) > 0 {
|
||||
parts = append(parts, "users = "+renderStringArray(access.Users))
|
||||
}
|
||||
if len(access.Groups) > 0 {
|
||||
parts = append(parts, "groups = "+renderStringArray(access.Groups))
|
||||
}
|
||||
if len(access.Approvers) > 0 {
|
||||
parts = append(parts, "approvers = "+renderStringArray(access.Approvers))
|
||||
}
|
||||
if len(access.Admins) > 0 {
|
||||
parts = append(parts, "admins = "+renderStringArray(access.Admins))
|
||||
}
|
||||
return "{ " + strings.Join(parts, ", ") + " }"
|
||||
}
|
||||
|
||||
func renderBotSessionMappings(mappings []BotConnectionSessionMapping) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('[')
|
||||
for i, mapping := range mappings {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
parts := map[string]string{
|
||||
"remote_id": mapping.RemoteID,
|
||||
"session_id": mapping.SessionID,
|
||||
}
|
||||
if mapping.SessionSource != "" {
|
||||
parts["session_source"] = mapping.SessionSource
|
||||
}
|
||||
if mapping.ChatType != "" {
|
||||
parts["chat_type"] = mapping.ChatType
|
||||
}
|
||||
if mapping.UserID != "" {
|
||||
parts["user_id"] = mapping.UserID
|
||||
}
|
||||
if mapping.ThreadID != "" {
|
||||
parts["thread_id"] = mapping.ThreadID
|
||||
}
|
||||
if mapping.Scope != "" {
|
||||
parts["scope"] = mapping.Scope
|
||||
}
|
||||
if mapping.WorkspaceRoot != "" {
|
||||
parts["workspace_root"] = mapping.WorkspaceRoot
|
||||
}
|
||||
if mapping.UpdatedAt != "" {
|
||||
parts["updated_at"] = mapping.UpdatedAt
|
||||
}
|
||||
b.WriteString(renderStringMap(parts))
|
||||
}
|
||||
b.WriteByte(']')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderBotRoute(b *strings.Builder, route BotRouteConfig) {
|
||||
if strings.TrimSpace(route.ConnectionID) != "" {
|
||||
fmt.Fprintf(b, "connection_id = %q\n", strings.TrimSpace(route.ConnectionID))
|
||||
}
|
||||
if strings.TrimSpace(route.Platform) != "" {
|
||||
fmt.Fprintf(b, "platform = %q\n", strings.TrimSpace(route.Platform))
|
||||
}
|
||||
if strings.TrimSpace(route.ChatType) != "" {
|
||||
fmt.Fprintf(b, "chat_type = %q\n", strings.TrimSpace(route.ChatType))
|
||||
}
|
||||
if strings.TrimSpace(route.ChatID) != "" {
|
||||
fmt.Fprintf(b, "chat_id = %q\n", strings.TrimSpace(route.ChatID))
|
||||
}
|
||||
if strings.TrimSpace(route.UserID) != "" {
|
||||
fmt.Fprintf(b, "user_id = %q\n", strings.TrimSpace(route.UserID))
|
||||
}
|
||||
if strings.TrimSpace(route.ThreadID) != "" {
|
||||
fmt.Fprintf(b, "thread_id = %q\n", strings.TrimSpace(route.ThreadID))
|
||||
}
|
||||
if strings.TrimSpace(route.Model) != "" {
|
||||
fmt.Fprintf(b, "model = %q\n", strings.TrimSpace(route.Model))
|
||||
}
|
||||
if strings.TrimSpace(route.ToolApprovalMode) != "" {
|
||||
fmt.Fprintf(b, "tool_approval_mode = %q\n", strings.TrimSpace(route.ToolApprovalMode))
|
||||
}
|
||||
if strings.TrimSpace(route.WorkspaceRoot) != "" {
|
||||
fmt.Fprintf(b, "workspace_root = %q\n", strings.TrimSpace(route.WorkspaceRoot))
|
||||
}
|
||||
}
|
||||
|
||||
func renderBotDesktopWatcher(b *strings.Builder, watcher BotDesktopWatcherConfig) {
|
||||
if strings.TrimSpace(watcher.Platform) != "" {
|
||||
fmt.Fprintf(b, "platform = %q\n", strings.TrimSpace(watcher.Platform))
|
||||
}
|
||||
if strings.TrimSpace(watcher.ConnectionID) != "" {
|
||||
fmt.Fprintf(b, "connection_id = %q\n", strings.TrimSpace(watcher.ConnectionID))
|
||||
}
|
||||
if strings.TrimSpace(watcher.Domain) != "" {
|
||||
fmt.Fprintf(b, "domain = %q\n", strings.TrimSpace(watcher.Domain))
|
||||
}
|
||||
if strings.TrimSpace(watcher.ChatType) != "" {
|
||||
fmt.Fprintf(b, "chat_type = %q\n", strings.TrimSpace(watcher.ChatType))
|
||||
}
|
||||
if strings.TrimSpace(watcher.ChatID) != "" {
|
||||
fmt.Fprintf(b, "chat_id = %q\n", strings.TrimSpace(watcher.ChatID))
|
||||
}
|
||||
}
|
||||
|
||||
// renderRuleList emits a permission rule list. A populated list renders as an
|
||||
// active TOML array; an empty one renders as a commented example so `reasonix setup`
|
||||
// scaffolds discoverable guidance without imposing surprising rules.
|
||||
|
||||
@@ -282,43 +282,6 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
|
||||
orig.Skills.DisabledSkills = []string{"review", "explore"}
|
||||
orig.Skills.DisableImplicitInvocation = true
|
||||
orig.Skills.MaxDepth = 2
|
||||
orig.Bot.ToolApprovalMode = "auto"
|
||||
orig.Bot.Control = BotControlConfig{Enabled: true, Addr: "127.0.0.1:39001", TokenEnv: "BOT_CONTROL_TOKEN"}
|
||||
orig.Bot.Feishu.OutboundMediaRoots = []string{"/tmp/reasonix-media", "/srv/shots"}
|
||||
orig.Bot.Routes = []BotRouteConfig{{
|
||||
ConnectionID: "feishu-lark",
|
||||
ChatType: "group",
|
||||
ChatID: "oc_group",
|
||||
Model: "deepseek-pro",
|
||||
ToolApprovalMode: "ask",
|
||||
WorkspaceRoot: "/tmp/reasonix-route",
|
||||
}}
|
||||
orig.Bot.DesktopWatchers = []BotDesktopWatcherConfig{{
|
||||
Platform: "feishu",
|
||||
ConnectionID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatType: "dm",
|
||||
ChatID: "oc_watcher",
|
||||
}}
|
||||
orig.Bot.Connections = []BotConnectionConfig{{
|
||||
ID: "feishu-lark",
|
||||
Provider: "feishu",
|
||||
Domain: "lark",
|
||||
Label: "Lark",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Model: "deepseek-pro",
|
||||
ToolApprovalMode: "yolo",
|
||||
WorkspaceRoot: "/tmp/reasonix-bot",
|
||||
Credential: BotConnectionCredential{AppID: "cli_lark", AppSecretEnv: "LARK_BOT_APP_SECRET"},
|
||||
SessionMappings: []BotConnectionSessionMapping{{
|
||||
RemoteID: "ou_123",
|
||||
SessionID: "topic:topic_bot",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: "/tmp/reasonix-bot",
|
||||
UpdatedAt: "2026-06-11T00:00:00Z",
|
||||
}},
|
||||
}}
|
||||
orig.LSP = LSPConfig{
|
||||
Enabled: true,
|
||||
Servers: map[string]LSPServer{
|
||||
@@ -423,27 +386,6 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
|
||||
if got.Agent.PlannerMaxSteps != orig.Agent.PlannerMaxSteps {
|
||||
t.Errorf("planner_max_steps = %d, want %d", got.Agent.PlannerMaxSteps, orig.Agent.PlannerMaxSteps)
|
||||
}
|
||||
if len(got.Bot.Connections) != 1 || got.Bot.Connections[0].Model != "deepseek-pro" || got.Bot.Connections[0].WorkspaceRoot != "/tmp/reasonix-bot" {
|
||||
t.Errorf("bot connection not preserved: %+v", got.Bot.Connections)
|
||||
}
|
||||
if got.Bot.ToolApprovalMode != "auto" || got.Bot.Connections[0].ToolApprovalMode != "yolo" {
|
||||
t.Errorf("bot tool approval mode not preserved: bot=%q connection=%q", got.Bot.ToolApprovalMode, got.Bot.Connections[0].ToolApprovalMode)
|
||||
}
|
||||
if !got.Bot.Control.Enabled || got.Bot.Control.Addr != "127.0.0.1:39001" || got.Bot.Control.TokenEnv != "BOT_CONTROL_TOKEN" {
|
||||
t.Errorf("bot control not preserved: %+v", got.Bot.Control)
|
||||
}
|
||||
if len(got.Bot.Feishu.OutboundMediaRoots) != 2 || got.Bot.Feishu.OutboundMediaRoots[0] != "/tmp/reasonix-media" {
|
||||
t.Errorf("feishu outbound_media_roots not preserved: %+v", got.Bot.Feishu.OutboundMediaRoots)
|
||||
}
|
||||
if len(got.Bot.Routes) != 1 || got.Bot.Routes[0].WorkspaceRoot != "/tmp/reasonix-route" || got.Bot.Routes[0].ChatID != "oc_group" {
|
||||
t.Errorf("bot routes not preserved: %+v", got.Bot.Routes)
|
||||
}
|
||||
if len(got.Bot.DesktopWatchers) != 1 || got.Bot.DesktopWatchers[0].ChatID != "oc_watcher" || got.Bot.DesktopWatchers[0].Platform != "feishu" || got.Bot.DesktopWatchers[0].Domain != "lark" {
|
||||
t.Errorf("bot desktop watchers not preserved: %+v", got.Bot.DesktopWatchers)
|
||||
}
|
||||
if len(got.Bot.Connections[0].SessionMappings) != 1 || got.Bot.Connections[0].SessionMappings[0].Scope != "project" || got.Bot.Connections[0].SessionMappings[0].WorkspaceRoot != "/tmp/reasonix-bot" {
|
||||
t.Errorf("bot session mapping scope not preserved: %+v", got.Bot.Connections[0].SessionMappings)
|
||||
}
|
||||
if got.Agent.Temperature != orig.Agent.Temperature {
|
||||
t.Errorf("temperature = %v, want %v", got.Agent.Temperature, orig.Agent.Temperature)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ var (
|
||||
|
||||
// errNoSessionPath is returned by snapshot when a session has content to persist
|
||||
// but no resolved session path — a misconfiguration (e.g. an unresolvable data
|
||||
// dir in a bot deployment) that previously dropped conversations silently
|
||||
// dir in a headless deployment) that previously dropped conversations silently
|
||||
// (#4414). Callers log it and continue; it must never be swallowed quietly.
|
||||
var errNoSessionPath = errors.New("session has content but no session path; conversation cannot be persisted")
|
||||
|
||||
@@ -392,7 +392,7 @@ type Options struct {
|
||||
OnSessionRecovered func(SessionRecoveryInfo) error
|
||||
// ApprovalTimeout bounds how long a tool-approval or ask prompt blocks waiting
|
||||
// for a user decision. Zero (default) waits forever — right for an interactive
|
||||
// terminal. Bot/headless frontends set a positive value so an unanswered
|
||||
// terminal. Headless frontends set a positive value so an unanswered
|
||||
// prompt can't wedge the session indefinitely (#4626, #4402).
|
||||
ApprovalTimeout time.Duration
|
||||
// RuntimeProfile selects capability routing/filtering behavior. Empty keeps
|
||||
|
||||
@@ -524,16 +524,6 @@ func (c *Controller) MoveInboxItem(id string, toIndex int) error {
|
||||
}
|
||||
|
||||
func (c *Controller) SetInboxPaused(paused bool) error {
|
||||
return c.setInboxPaused(paused, true)
|
||||
}
|
||||
|
||||
// SetInboxPausedPassive changes pause state without starting a background turn.
|
||||
// Blocking transports such as Bot own their render sink and drain explicitly.
|
||||
func (c *Controller) SetInboxPausedPassive(paused bool) error {
|
||||
return c.setInboxPaused(paused, false)
|
||||
}
|
||||
|
||||
func (c *Controller) setInboxPaused(paused, dispatch bool) error {
|
||||
st, err := c.ensureInbox()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -543,7 +533,7 @@ func (c *Controller) setInboxPaused(paused, dispatch bool) error {
|
||||
}
|
||||
if paused {
|
||||
sessioninbox.NotePaused()
|
||||
} else if dispatch {
|
||||
} else {
|
||||
// On resume, try to dispatch if idle.
|
||||
c.maybeDispatchInbox()
|
||||
}
|
||||
@@ -551,15 +541,6 @@ func (c *Controller) setInboxPaused(paused, dispatch bool) error {
|
||||
}
|
||||
|
||||
func (c *Controller) RetryInboxItem(id string) error {
|
||||
return c.retryInboxItem(id, true)
|
||||
}
|
||||
|
||||
// RetryInboxItemPassive requeues an item without detached background dispatch.
|
||||
func (c *Controller) RetryInboxItemPassive(id string) error {
|
||||
return c.retryInboxItem(id, false)
|
||||
}
|
||||
|
||||
func (c *Controller) retryInboxItem(id string, dispatch bool) error {
|
||||
st, err := c.ensureInbox()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -567,9 +548,7 @@ func (c *Controller) retryInboxItem(id string, dispatch bool) error {
|
||||
if err := st.RetryItem(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if dispatch {
|
||||
c.maybeDispatchInbox()
|
||||
}
|
||||
c.maybeDispatchInbox()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"reasonix/internal/sessioninbox"
|
||||
)
|
||||
|
||||
// RunInboxTurn synchronously claims and executes one durable item. Bot and ACP
|
||||
// use this path so their blocking response sink remains attached through every
|
||||
// RunInboxTurn synchronously claims and executes one durable item. ACP uses
|
||||
// this path so its blocking response sink remains attached through every
|
||||
// queued follow-up while Controller still owns durable state and ack semantics.
|
||||
func (c *Controller) RunInboxTurn(ctx context.Context, id string) error {
|
||||
st, err := c.ensureInbox()
|
||||
|
||||
@@ -19,10 +19,10 @@ import (
|
||||
)
|
||||
|
||||
// This file defines the driving port: the typed, segregated interface surface
|
||||
// that frontends (cli, bot, acp, serve) consume instead of coupling to the
|
||||
// that frontends (cli, acp, serve) consume instead of coupling to the
|
||||
// concrete *Controller and its whole method surface. Each depends on the
|
||||
// sub-ports it actually drives, so the bot never sees checkpoint or memory
|
||||
// methods; port_segregation_test keeps that true.
|
||||
// sub-ports it actually drives, so an editor integration never sees checkpoint
|
||||
// or memory methods; port_segregation_test keeps that true.
|
||||
//
|
||||
// The sub-ports are also the intended decomposition boundary for Controller
|
||||
// itself: the port comes first and gives the later collaborator splits a spec to
|
||||
@@ -333,7 +333,7 @@ type Settings interface {
|
||||
|
||||
// SessionAPI is the full driving port — the composition of every sub-port, for
|
||||
// a frontend that drives all of it: the TUI does, and the HTTP server all but
|
||||
// one. Leaner frontends name GatewayAPI or EditorAPI instead.
|
||||
// one. A leaner frontend names EditorAPI instead.
|
||||
type SessionAPI interface {
|
||||
Lifecycle
|
||||
TurnControl
|
||||
@@ -350,18 +350,8 @@ type SessionAPI interface {
|
||||
Inbox
|
||||
}
|
||||
|
||||
// GatewayAPI is what a chat gateway drives: messages in, queued, run as turns,
|
||||
// approvals answered. What it does not name is the point — no transcript, no
|
||||
// memory pane, no checkpoint UI, so it cannot reach for them by accident.
|
||||
type GatewayAPI interface {
|
||||
Lifecycle
|
||||
TurnControl
|
||||
Approvals
|
||||
Inbox
|
||||
}
|
||||
|
||||
// EditorAPI is what an editor integration drives over ACP: a gateway's four,
|
||||
// plus the slash dispatch, MCP surface and sidecar extensions it puts on
|
||||
// EditorAPI is what an editor integration drives over ACP: turns, approvals and
|
||||
// the inbox, plus the slash dispatch, MCP surface and sidecar extensions it puts on
|
||||
// screen, and the goals and persistence it shows. What it does not name is the
|
||||
// point — an editor authors no skill, edits no hook, and saves no sandbox rule.
|
||||
type EditorAPI interface {
|
||||
@@ -380,7 +370,6 @@ type EditorAPI interface {
|
||||
// the full port, so frontend migrations to the interfaces are mechanical and can
|
||||
// never silently drift from the implementation.
|
||||
var (
|
||||
_ GatewayAPI = (*Controller)(nil)
|
||||
_ EditorAPI = (*Controller)(nil)
|
||||
_ Lifecycle = (*Controller)(nil)
|
||||
_ TurnControl = (*Controller)(nil)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package control
|
||||
|
||||
// The driving port promises segregation and nothing enforced it: bot had
|
||||
// drifted to the full SessionAPI — 8 sub-ports, 122 methods it never calls,
|
||||
// including the memory and checkpoint surfaces port.go says it must not see.
|
||||
// The driving port promises segregation: a frontend handed sub-ports it never
|
||||
// calls reaches surfaces port.go says it must not see, so over-declaring fails.
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
@@ -130,7 +129,7 @@ func TestFrontendsDeclareOnlyThePortsTheyDrive(t *testing.T) {
|
||||
if len(model.methodOwner) == 0 || len(model.composed) == 0 {
|
||||
t.Fatal("no sub-ports read from this package; this guard is watching nothing")
|
||||
}
|
||||
for _, frontend := range []string{"bot", "acp", "serve", "cli"} {
|
||||
for _, frontend := range []string{"acp", "serve", "cli"} {
|
||||
t.Run(frontend, func(t *testing.T) {
|
||||
dir := filepath.Join("..", frontend)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
@@ -149,7 +148,7 @@ func TestFrontendsDeclareOnlyThePortsTheyDrive(t *testing.T) {
|
||||
slices.Sort(spare)
|
||||
if len(spare) > slackSubPorts {
|
||||
t.Errorf("%s is handed %d sub-ports it never calls: %s\n"+
|
||||
"Name the composite it actually drives (see GatewayAPI/EditorAPI), or add the calls.",
|
||||
"Name the composite it actually drives (see EditorAPI), or add the calls.",
|
||||
frontend, len(spare), strings.Join(spare, ", "))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -61,8 +61,8 @@ func (c *Controller) NewSession() error {
|
||||
// Claim the rotation gate for the whole snapshot-then-swap sequence. A bare
|
||||
// `if c.gate.running` check released before Snapshot() left a window where a turn
|
||||
// could start during the snapshot and then have its live session replaced by
|
||||
// the SetSession below. Submit ("/new") and the bot gateway call this
|
||||
// asynchronously, so the gate is load-bearing, not defensive.
|
||||
// the SetSession below. Submit ("/new") calls this asynchronously, so the
|
||||
// gate is load-bearing, not defensive.
|
||||
if err := c.beginRotation(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ import (
|
||||
// surface runs right after building a controller. It is a no-op once a resume or
|
||||
// continue has already pinned a path (SessionPath() != ""), so callers can run a
|
||||
// conditional Resume and then invoke this unconditionally. Centralises the
|
||||
// per-surface copies of this logic (the CLI chat/serve fresh branches and the
|
||||
// bot's former ensureControllerSessionPath).
|
||||
// per-surface copies of this logic (the CLI chat/serve fresh branches).
|
||||
func (c *Controller) EnsureSessionPath() {
|
||||
if c.SessionPath() != "" || c.SessionDir() == "" {
|
||||
return
|
||||
|
||||
@@ -6,7 +6,7 @@ import "strings"
|
||||
//
|
||||
// These names are an internal wire contract between the agent progress
|
||||
// tracker and local frontends (desktop and CLI). They must never be presented
|
||||
// as provider-visible tool names: ACP and bot consumers ignore ToolProgress
|
||||
// as provider-visible tool names: ACP consumers ignore ToolProgress
|
||||
// bodies entirely, and the names are filtered out of any transcript or
|
||||
// provider context by construction (they only ever ride ToolProgress events).
|
||||
//
|
||||
|
||||
@@ -602,7 +602,6 @@ Usage:
|
||||
reasonix hook list|status --json [--dir PATH] inspect redacted hook state
|
||||
reasonix task list|show|status|events|stop|cancel|monitor|tmux --json [--dir PATH]
|
||||
inspect or control redacted tasks
|
||||
reasonix bot start|doctor|weixin-login multi-channel IM bot gateway
|
||||
reasonix upgrade [--check] [--force] update to the latest official release (also: reasonix update)
|
||||
reasonix completion bash|zsh|fish print a shell completion script to stdout
|
||||
reasonix version [--verbose|--json] print version (single line) or build metadata
|
||||
|
||||
@@ -603,7 +603,6 @@ var Chinese = Messages{
|
||||
reasonix hook list|status --json [--dir PATH] 查看脱敏 Hook 状态
|
||||
reasonix task list|show|status|events|stop|cancel|monitor|tmux --json [--dir PATH]
|
||||
查看或控制脱敏 Task
|
||||
reasonix bot start|doctor|weixin-login 多渠道 IM bot 网关
|
||||
reasonix upgrade [--check] [--force] 更新到最新正式版(别名:reasonix update)
|
||||
reasonix completion bash|zsh|fish 打印 shell 补全脚本到 stdout
|
||||
reasonix version [--verbose|--json] 打印版本(单行)或构建元信息
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user