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:
esengine committed 2026-09-17 02:02:48 -07:00
1 parent 8b2a05c514
commit f352dfbecc
114 files changed
+325 -20588

No files matched your search

+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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) ·
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
-480
View File
@@ -1,480 +0,0 @@
# Reasonix Bot Guide
<a href="../README.md">README</a>
&nbsp;·&nbsp;
<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.
![Feishu approval card example](./assets/bot-feishu-approval.svg)
![Lark YOLO mode example](./assets/bot-lark-yolo.svg)
![WeChat text command example](./assets/bot-weixin-text-commands.svg)
![QQ approval card example](./assets/bot-qq-approval.svg)
| 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.
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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
-48
View File
@@ -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

-47
View File
@@ -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

-48
View File
@@ -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

-36
View File
@@ -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

+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
-163
View File
@@ -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, ", ")
}
-232
View File
@@ -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
}
-117
View File
@@ -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)
}
}
-132
View File
@@ -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)
}
}
-278
View File
@@ -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
}
-15
View File
@@ -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{} }
-268
View File
@@ -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()
}
-333
View File
@@ -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)
}
}
-19
View File
@@ -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
}
-40
View File
@@ -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
-561
View File
@@ -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")
}
}
-233
View File
@@ -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
}
-45
View File
@@ -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
})
}
-244
View File
@@ -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"
}
}
-151
View File
@@ -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)
}
}
-55
View File
@@ -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
-157
View File
@@ -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
}
-443
View File
@@ -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
-15
View File
@@ -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]
}
-363
View File
@@ -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)
}
-53
View File
@@ -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)
}
}
-11
View File
@@ -1,11 +0,0 @@
package bot
import (
"testing"
"reasonix/internal/testenv"
)
func TestMain(m *testing.M) {
testenv.RunWithIsolatedUserState(m)
}
-168
View File
@@ -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()
}
-110
View File
@@ -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)
}
}
-203
View File
@@ -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,
})
}
-371
View File
@@ -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
}
-53
View File
@@ -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)
}
}
-831
View File
@@ -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 ""
}
-95
View File
@@ -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
}
-167
View File
@@ -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")
}
}
-793
View File
@@ -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)
}
-355
View File
@@ -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)),
}
}
-737
View File
@@ -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
}
-588
View File
@@ -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")
}
}
-454
View File
@@ -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)
}
-649
View File
@@ -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
}
-278
View File
@@ -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")
}
}
-36
View File
@@ -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)
}
}
-642
View File
@@ -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 "询问"
}
}
-126
View File
@@ -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")
}
}
-197
View File
@@ -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)
-21
View File
@@ -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)
}
}
-666
View File
@@ -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
}
-241
View File
@@ -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
}
}
-177
View File
@@ -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")
}
}
-11
View File
@@ -1,11 +0,0 @@
package botruntime
import (
"testing"
"reasonix/internal/testenv"
)
func TestMain(m *testing.M) {
testenv.RunWithIsolatedUserState(m)
}
-714
View File
@@ -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 ""
}
-437
View File
@@ -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))
}
-583
View File
@@ -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.
`)
}
-498
View File
@@ -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
View File
@@ -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
-10
View File
@@ -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,
}),
+1 -16
View File
@@ -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
View File
@@ -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(),
}
}
+2 -6
View File
@@ -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)
+1 -1
View File
@@ -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)
}
-6
View File
@@ -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"} {
+2 -2
View File
@@ -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
View File
@@ -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 {
-52
View File
@@ -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) {
-49
View File
@@ -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.
+2 -2
View File
@@ -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).
+28 -34
View File
@@ -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 {
+53
View File
@@ -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
}
+133
View File
@@ -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
View File
@@ -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.
-58
View File
@@ -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)
}
+2 -2
View File
@@ -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
+2 -23
View File
@@ -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
}
+2 -2
View File
@@ -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()
+6 -17
View File
@@ -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)
+4 -5
View File
@@ -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, ", "))
}
})
+2 -2
View File
@@ -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
}
+1 -2
View File
@@ -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
+1 -1
View File
@@ -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).
//
-1
View File
@@ -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
-1
View File
@@ -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