Compare commits

...

8 Commits

Author SHA1 Message Date
Innei 64a1ce737b feat(agent): route single first-line mentions directly 2026-04-27 18:19:24 +08:00
YuTengjing 9acb128943 📝 docs(skills): rename code-review to review-checklist (#14229) 2026-04-27 18:17:16 +08:00
Arvin Xu ee55d74dd4 💄 style(tasks): drop custom actions on result briefs & show trigger tag in subtasks (#14226)
 feat(tasks): drop custom actions on result briefs & show trigger tag in subtasks

- Result briefs render a fixed single-button UI, so reject custom actions at
  brief creation time and remove the unused defaults / lifecycle actions.
- Surface automation trigger (heartbeat / schedule) on subtask rows by
  threading the fields through TaskService → TaskDetailSubtask → tree.
- Polish: tree title flex/overflow fix, QueueTray send icon swapped to ArrowUp.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:44:03 +08:00
YuTengjing cca1050e82 🐛 fix: localize provider moderation generation errors (#14220) 2026-04-27 15:22:56 +08:00
Arvin Xu 92a848c69c feat(tasks/brief): subtask avatar polish, brief actions revamp & task drawer Gateway reconnect (#14208)
* 💄 style(task): right-align subtask assignee avatar and make it clickable

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(brief): standardize result brief actions to mark-as-done + edit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(brief): align decision brief icon with kanban pending-review column

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(brief): rename result brief primary action to "Confirm complete"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(tasks): wire passive Gateway WS reconnect for the task topic drawer

The task topic drawer rendered messages from the DB but never connected
to the Gateway, so a running task showed only the initial prompt and the
empty assistant placeholder. Server already writes runningOperation into
topic metadata; expose it through TaskDetailActivity and reuse the main
agent reconnect hook so the drawer establishes the WebSocket on open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(brief): mute Check icon on resolved success tag

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(recent): exclude system-trigger topics from the Recent sidebar

The Recent SQL union pulled every topic regardless of trigger, so cron,
eval, task_manager, and task-runner topics leaked into the main "最近"
list alongside ordinary chats. Filter them in the topics SELECT, and
align the long-stale `TopicTrigger.RunTask` constant with the literal
`'task'` that TaskRunnerService actually writes (the const was unused
so no DB migration is needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:54:19 +08:00
Arvin Xu f32fff19dd 📝 docs(skills): record contributor roster in version-release (#14219)
📝 docs(skills): record contributor roster in version-release skill

- Add Contributor Ordering section with the canonical LobeHub team roster (10 handles) and a flat-list rule (community first, team after, sorted by PR count desc).
- Note the git-author-name vs GitHub-handle pitfall (e.g. YuTengjing -> @tjx666) and how to verify via gh CLI.
- Drop commits count from the changelog template's metadata and contributors lines; reword the contributors intro to a "Huge thanks to N contributors" pattern.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:23:04 +08:00
lobehubbot 38d7bdbd96 Merge remote-tracking branch 'origin/main' into canary 2026-04-27 05:19:09 +00:00
Rdmclin2 3e236ec36f feat: support dm pair policy (#14211)
* feat: support pair dm policy

* feat: add enum descriptions

* chore: optimize labels and copy

* chore: update i18n

* fix: lint error

* chore: update bot docs

* fix: peek paring request and so on issues
2026-04-27 11:31:07 +07:00
87 changed files with 3266 additions and 387 deletions
@@ -1,58 +1,51 @@
---
name: code-review
description: 'Code review checklist for LobeHub. Use when reviewing PRs, diffs, or code changes. Covers correctness, security, quality, and project-specific patterns.'
name: review-checklist
description: 'Common recurring mistakes in LobeHub code review — console leftovers, missing return await, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs @lobehub/ui, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing PRs, diffs, or branch changes.'
---
# Code Review Guide
# Review Checklist
## Before You Start
1. Read `/typescript` and `/testing` skills for code style and test conventions
2. Get the diff (skip if already in context, e.g., injected by GitHub review app): `git diff` or `git diff origin/canary..HEAD`
## Checklist
### Correctness
## Correctness
- Leftover `console.log` / `console.debug` — should use `debug` package or remove
- Missing `return await` in try/catch — see <https://typescript-eslint.io/rules/return-await/> (not in our ESLint config yet, requires type info)
- Can the fix/implementation be more concise, efficient, or have better compatibility?
### Security
## Security
- No sensitive data (API keys, tokens, credentials) in `console.*` or `debug()` output
- No base64 output to terminal — extremely long, freezes output
- No hardcoded secrets — use environment variables
### Testing
## Testing
- Bug fixes must include tests covering the fixed scenario
- New logic (services, store actions, utilities) should have test coverage
- Existing tests still cover the changed behavior?
- Prefer `vi.spyOn` over `vi.mock` (see `/testing` skill)
### i18n
## i18n
- New user-facing strings use i18n keys, not hardcoded text
- Keys added to `src/locales/default/{namespace}.ts` with `{feature}.{context}.{action|status}` naming
- For PRs: `locales/` translations for all languages updated (`pnpm i18n`)
### SPA / routing
## SPA / routing
- **`desktopRouter` pair:** If the diff touches `src/spa/router/desktopRouter.config.tsx`, does it also update `src/spa/router/desktopRouter.config.desktop.tsx` with the same route paths and nesting? Single-file edits often cause drift and blank screens.
### Reuse
## Reuse
- Newly written code duplicates existing utilities in `packages/utils` or shared modules?
- Copy-pasted blocks with slight variation — extract into shared function
- `antd` imports replaceable with `@lobehub/ui` wrapped components (`Input`, `Button`, `Modal`, `Avatar`, etc.)
- Use `antd-style` token system, not hardcoded colors; prefer `createStaticStyles` + `cssVar.*` over `createStyles` + `token` unless runtime computation is required
### Database
## Database
- Migration scripts must be idempotent (`IF NOT EXISTS`, `IF EXISTS` guards)
### Cloud Impact
## Cloud Impact
A downstream cloud deployment depends on this repo. Flag changes that may require cloud-side updates:
@@ -61,13 +54,3 @@ A downstream cloud deployment depends on this repo. Flag changes that may requir
- **Dependency versions bumped** — e.g., upgrading `next` or `drizzle-orm` in `package.json`
- **`@lobechat/business-*` exports changed** — e.g., renaming a function in `src/business/` or changing type signatures in `packages/business/`
- `src/business/` and `packages/business/` must not expose cloud commercial logic in comments or code
## Output Format
For local CLI review only (GitHub review app posts inline PR comments instead):
- Number all findings sequentially
- Indicate priority: `[high]` / `[medium]` / `[low]`
- Include file path and line number for each finding
- Only list problems — no summary, no praise
- Re-read full source for each finding to verify it's real, then output "All findings verified."
+25 -5
View File
@@ -238,13 +238,34 @@ Use `---` separators between major blocks for long releases.
- Keep concise.
- Must include `Migration overview`, operator impact, and rollback/backup note.
### Contributor Ordering
Render contributors as a **single flat list** (no separate "Community" / "Core Team" subsections). Order: **community contributors first, team members after**. Within each group, sort by PR count desc. Bots (`@lobehubbot`, `renovate[bot]`) go on a separate "maintenance" line.
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
- @arvinxx
- @Innei
- @tjx666 (commit author name: YuTengjing)
- @LiJian
- @Neko
- @Rdmclin2
- @AmAzing129
- @sudongyuer
- @rivertwilight
- @CanisMinor
> **Resolving handles** — git author names (e.g. `YuTengjing`) are not always the GitHub handle. Verify via `gh pr view <PR> --json author` or `gh api search/users -f q='<email>'` before listing.
If a new contributor appears who is not on this list, treat them as community by default and ask the user whether to add them to the roster.
### GitHub Release Changelog Template
```md
# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)
**Release Date:** <Month DD, YYYY>
**Since <Previous Version>:** <N commits> · <N merged PRs> · <N resolved issues> · <N contributors>
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
> <One release thesis sentence: what this release unlocks in practice.>
@@ -296,12 +317,11 @@ Use `---` separators between major blocks for long releases.
## 👥 Contributors
**<N merged PRs>** from **<N contributors>** across **<N commits>**.
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
### Community Contributors
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
- @<username> - <notable contribution area>
- @<username> - <notable contribution area>
Plus @lobehubbot and renovate[bot] for maintenance.
---
+5 -1
View File
@@ -121,4 +121,8 @@ cd packages/database && bunx vitest run --silent='passed-only' '[file]'
- Add keys to a namespace file under `src/locales/default/` (e.g. `agent.ts`, `auth.ts`)
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
- Don't run `pnpm i18n` - CI handles it
- `pnpm i18n` is slow; run it manually when locale keys need updating (e.g. before opening a PR).
### Code Review
Before reviewing a PR / diff / branch change, read the **review-checklist** skill (`.agents/skills/review-checklist/SKILL.md`) — it lists the recurring mistakes specific to this codebase.
+23 -2
View File
@@ -123,6 +123,26 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
Back in LobeHub's channel settings for Discord, click **Test Connection** to verify everything is configured correctly. Then send a message to your bot in Discord to confirm it responds.
## Step 5: Set Your Platform Identity (Recommended)
Two optional fields under **Advanced Settings** carry a lot of weight in day-to-day use — fill them in once and most surprises go away.
### Your Platform User ID
This is your own Discord user ID, used by:
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Discord account.
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to friends won't accidentally lock you out.
To get it: in Discord, open **User Settings → Advanced** and turn on **Developer Mode**. Then right-click your own username anywhere in Discord and choose **Copy User ID**. Paste the numeric ID into **Your Platform User ID** in LobeHub's Advanced Settings.
### Default Server
The Discord guild ID the bot's AI tools should default to when you ask it to "list channels", "send to #announcements", or anything else that needs a server context without naming one explicitly. Doesn't affect access control — that's **Group Policy**'s job.
To get it: with **Developer Mode** on, right-click the server name in your server list and choose **Copy Server ID**. Paste it into **Default Server** in LobeHub's Advanced Settings.
## Access Policies
LobeHub gates inbound traffic with three layered settings, all under **Advanced Settings** and all defaulting to permissive.
@@ -139,9 +159,10 @@ Controls 1:1 direct messages.
- **Open (default)** — Anyone who shares a server with the bot can DM it (subject to the global allowlist when set).
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` fails closed (no DMs), `Open` still lets anyone DM.
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
- **Disabled** — The bot ignores all DMs. Senders get a one-line notice pointing them at @mentioning the bot in a shared channel instead.
> Discord bots can be reached by anyone in any shared server, so consider populating **Allowed User IDs** or switching DM Policy to **Disabled** if your bot is meant to be private.
> Discord bots can be reached by anyone in any shared server, so consider populating **Allowed User IDs**, switching DM Policy to **Pairing** for self-service approval, or **Disabled** if your bot is meant to be private.
### Group Policy
@@ -163,7 +184,7 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
| **Bot Token** | Yes | Authentication token for your Discord bot |
| **Public Key** | Yes | Used to verify interaction requests from Discord |
| **Allowed User IDs** | No | Comma- or whitespace-separated Discord user IDs. Global gate — applies to DMs and group @mentions |
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Discord channel IDs. Used when Group Policy is Allowlist |
+31 -10
View File
@@ -122,6 +122,26 @@ tags:
返回 LobeHub 的 Discord 渠道设置,点击 **测试连接** 以验证配置是否正确。然后在 Discord 中向您的机器人发送消息,确认其是否响应。
## 第五步:填写你的平台身份(推荐)
**高级设置**里有两个可选字段,影响着日常使用体验,建议一开始就填好。
### 你的平台用户 ID
也就是你自己的 Discord 用户 ID,用于:
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 Discord 账号。
- **防自锁** — 自动被 **允许的用户** 信任,给好友收紧 bot 时不会把自己挡在外面。
获取方式:在 Discord 中打开 **用户设置 → 高级**,启用 **开发者模式**。然后在任意位置右键你自己的用户名,选 **复制用户 ID**。把数字 ID 粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
### 默认服务器
Discord 的 guild ID。当你让 bot 做 "列出频道"、"发送到 #announcements" 这类需要服务器上下文但没指明哪台的事时,AI 工具会默认用这个 server。和访问控制无关 —— 那是 **群组策略** 的活。
获取方式:在 **开发者模式** 已开启的情况下,在服务器列表中右键服务器名,选 **复制服务器 ID**。粘贴到 LobeHub 高级设置的 **默认服务器** 字段。
## 接入策略
LobeHub 通过三层叠加配置控制入站消息,全部位于 **高级设置**,默认都为宽松。
@@ -138,9 +158,10 @@ LobeHub 通过三层叠加配置控制入站消息,全部位于 **高级设置
- **开放 (Open)(默认)** — 任何与机器人共享服务器的用户都可以私信(若设置了全局白名单则受其约束)。
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**`Open` 模式仍然放任何人私信。
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
- **禁用 (Disabled)** — 机器人忽略所有私信,发起方会收到一条提示,引导其在共享频道里 @机器人。
> Discord 机器人可被任意共享服务器的用户私信,如果你的机器人是私有用途,建议填入 **允许的用户 ID** 或将私信策略切到 **禁用**。
> Discord 机器人可被任意共享服务器的用户私信,如果你的机器人是私有用途,建议填入 **允许的用户 ID**、把私信策略切到 **配对审批** 让陌生人走自助申请通道,或干脆设为 **禁用**。
### 群组策略
@@ -156,15 +177,15 @@ LobeHub 通过三层叠加配置控制入站消息,全部位于 **高级设置
## 配置参考
| 字段 | 是否必需 | 描述 |
| ------------ | ---- | -------------------------------------------------- |
| **应用程序 ID** | 是 | 您的 Discord 应用程序的 ID |
| **机器人令牌** | 是 | 您的 Discord 机器人的认证令牌 |
| **公钥** | 是 | 用于验证来自 Discord 的交互请求 |
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Discord 用户 ID。全局闸门 — 私信和群聊 @ 都受其约束 |
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些频道响应 |
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Discord 频道 ID。仅在群组策略为白名单时使用 |
| 字段 | 是否必需 | 描述 |
| ------------ | ---- | ---------------------------------------------------------- |
| **应用程序 ID** | 是 | 您的 Discord 应用程序的 ID |
| **机器人令牌** | 是 | 您的 Discord 机器人的认证令牌 |
| **公钥** | 是 | 用于验证来自 Discord 的交互请求 |
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Discord 用户 ID。全局闸门 — 私信和群聊 @ 都受其约束 |
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些频道响应 |
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Discord 频道 ID。仅在群组策略为白名单时使用 |
## 故障排除
+18 -1
View File
@@ -174,6 +174,22 @@ By connecting a Feishu channel to your LobeHub agent, team members can interact
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Feishu by searching its name and send it a message to confirm it responds.
## Step 7: Set Your Platform Identity (Recommended)
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
### Your Platform User ID
This is your own Feishu `open_id` (the per-app, per-user identifier — **not** the same as your Feishu mobile number or email), used by:
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Feishu account.
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to teammates won't accidentally lock you out.
To get it: DM the bot once and inspect the inbound event payload — the `open_id` field on the sender is yours. The Feishu Developer Portal also exposes a **User ID** lookup that maps mobile/email to `open_id`. Paste it into **Your Platform User ID** in LobeHub's Advanced Settings.
> Feishu doesn't expose a single "default server" concept that AI tools can pivot on (the bot operates per-tenant via credentials), so the **Default Server** field is not exposed for Feishu channels.
## Access Policies
Two independent policies gate inbound traffic. Both default to **Open**.
@@ -186,6 +202,7 @@ A populated **Allowed User IDs** field is a global gate — DMs *and* group `@me
- **Open (default)** — Any tenant member can DM the bot (subject to the global allowlist when set).
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
- **Disabled** — The bot ignores all DMs and only responds to chat-group `@mentions`.
### Group Policy
@@ -208,7 +225,7 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
| **Encrypt Key** | No | Decrypts encrypted event payloads |
| **Event Subscription URL** | — | Auto-generated after saving; paste into Feishu Developer Portal |
| **Allowed User IDs** | No | Comma- or whitespace-separated Feishu `open_id` values. Global gate — applies to DMs and group @mentions |
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Feishu `chat_id` values. Used when Group Policy is Allowlist |
+28 -11
View File
@@ -170,6 +170,22 @@ tags:
回到 LobeHub 的渠道设置,点击 **测试连接** 以验证凭证。然后在飞书中搜索您的机器人名称并发送消息,确认其是否响应。
## 第七步:填写你的平台身份(推荐)
**高级设置**里有一个可选字段影响日常使用体验,建议一开始就填好。
### 你的平台用户 ID
也就是你自己的飞书 `open_id`(按应用、按用户隔离的标识符 ——**不是**手机号或邮箱),用于:
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的飞书账号。
- **防自锁** — 自动被 **允许的用户** 信任,给同事收紧 bot 时不会把自己挡在外面。
获取方式:先用任意消息私信 bot 一次,查看入站事件 payload 中发送方的 `open_id` 字段,那就是你的。飞书开发者后台也提供 **User ID 查询** 工具,用手机号 / 邮箱反查 `open_id`。粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
> 飞书没有一个 AI 工具能默认指向的 "默认服务器" 概念(bot 通过凭证按租户运行),因此飞书渠道不展示 **默认服务器** 字段。
## 接入策略
两个独立的策略控制入站消息,默认都为 **开放**。
@@ -182,6 +198,7 @@ tags:
- **开放 (Open)(默认)** — 租户内任何成员都可以私信机器人(若设置了全局白名单则受其约束)。
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群聊里被 `@提及` 时回复。
### 群组策略
@@ -196,17 +213,17 @@ tags:
## 配置参考
| 字段 | 是否必需 | 描述 |
| ---------------------- | ---- | -------------------------------------------------- |
| **应用 ID** | 是 | 您的飞书应用的应用 ID(`cli_xxx` |
| **应用密钥** | 是 | 您的飞书应用的应用密钥 |
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
| **Encrypt Key** | 否 | 解密加密事件负载 |
| **事件订阅 URL** | — | 保存后自动生成;粘贴到飞书开发者门户 |
| **允许的用户 ID** | 否 | 逗号或空格分隔的飞书 `open_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
| **允许的频道 ID** | 否 | 逗号或空格分隔的飞书 `chat_id`。仅在群组策略为白名单时使用 |
| 字段 | 是否必需 | 描述 |
| ---------------------- | ---- | ---------------------------------------------------------- |
| **应用 ID** | 是 | 您的飞书应用的应用 ID(`cli_xxx` |
| **应用密钥** | 是 | 您的飞书应用的应用密钥 |
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
| **Encrypt Key** | 否 | 解密加密事件负载 |
| **事件订阅 URL** | — | 保存后自动生成;粘贴到飞书开发者门户 |
| **允许的用户 ID** | 否 | 逗号或空格分隔的飞书 `open_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
| **允许的频道 ID** | 否 | 逗号或空格分隔的飞书 `chat_id`。仅在群组策略为白名单时使用 |
## 故障排除
+18 -1
View File
@@ -165,6 +165,22 @@ By connecting a Lark channel to your LobeHub agent, team members can interact wi
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Lark by searching its name and send it a message to confirm it responds.
## Step 7: Set Your Platform Identity (Recommended)
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
### Your Platform User ID
This is your own Lark `open_id` (the per-app, per-user identifier — **not** the same as your Lark mobile number or email), used by:
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Lark account.
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to teammates won't accidentally lock you out.
To get it: DM the bot once and inspect the inbound event payload — the `open_id` field on the sender is yours. The Lark Developer Portal also exposes a **User ID** lookup that maps mobile/email to `open_id`. Paste it into **Your Platform User ID** in LobeHub's Advanced Settings.
> Lark doesn't expose a single "default server" concept that AI tools can pivot on (the bot operates per-tenant via credentials), so the **Default Server** field is not exposed for Lark channels.
## Access Policies
Two independent policies gate inbound traffic. Both default to **Open**.
@@ -177,6 +193,7 @@ A populated **Allowed User IDs** field is a global gate — DMs *and* group `@me
- **Open (default)** — Any tenant member can DM the bot (subject to the global allowlist when set).
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
- **Disabled** — The bot ignores all DMs and only responds to chat-group `@mentions`.
### Group Policy
@@ -199,7 +216,7 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
| **Encrypt Key** | No | Decrypts encrypted event payloads |
| **Event Subscription URL** | — | Auto-generated after saving; paste into Lark Developer Portal |
| **Allowed User IDs** | No | Comma- or whitespace-separated Lark `open_id` values. Global gate — applies to DMs and group @mentions |
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Lark `chat_id` values. Used when Group Policy is Allowlist |
+28 -11
View File
@@ -162,6 +162,22 @@ tags:
回到 LobeHub 的渠道设置,点击 **Test Connection** 以验证凭证。然后在 Lark 中搜索您的机器人名称并发送消息,确认其是否响应。
## 第七步:填写你的平台身份(推荐)
**高级设置**里有一个可选字段影响日常使用体验,建议一开始就填好。
### 你的平台用户 ID
也就是你自己的 Lark `open_id`(按应用、按用户隔离的标识符 ——**不是**手机号或邮箱),用于:
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 Lark 账号。
- **防自锁** — 自动被 **允许的用户** 信任,给同事收紧 bot 时不会把自己挡在外面。
获取方式:先用任意消息私信 bot 一次,查看入站事件 payload 中发送方的 `open_id` 字段,那就是你的。Lark 开发者后台也提供 **User ID 查询** 工具,用手机号 / 邮箱反查 `open_id`。粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
> Lark 没有一个 AI 工具能默认指向的 "默认服务器" 概念(bot 通过凭证按租户运行),因此 Lark 渠道不展示 **默认服务器** 字段。
## 接入策略
两个独立的策略控制入站消息,默认都为 **开放**。
@@ -174,6 +190,7 @@ tags:
- **开放 (Open)(默认)** — 租户内任何成员都可以私信机器人(若设置了全局白名单则受其约束)。
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群聊里被 `@提及` 时回复。
### 群组策略
@@ -188,17 +205,17 @@ tags:
## 配置参考
| 字段 | 是否必需 | 描述 |
| -------------------------- | ---- | -------------------------------------------------- |
| **App ID** | 是 | 您的 Lark 应用的 App ID`cli_xxx` |
| **App Secret** | 是 | 您的 Lark 应用的 App Secret |
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
| **Encrypt Key** | 否 | 解密加密事件负载 |
| **Event Subscription URL** | — | 保存后自动生成;粘贴到 Lark 开发者门户 |
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Lark `open_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Lark `chat_id`。仅在群组策略为白名单时使用 |
| 字段 | 是否必需 | 描述 |
| -------------------------- | ---- | ---------------------------------------------------------- |
| **App ID** | 是 | 您的 Lark 应用的 App ID`cli_xxx` |
| **App Secret** | 是 | 您的 Lark 应用的 App Secret |
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
| **Encrypt Key** | 否 | 解密加密事件负载 |
| **Event Subscription URL** | — | 保存后自动生成;粘贴到 Lark 开发者门户 |
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Lark `open_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Lark `chat_id`。仅在群组策略为白名单时使用 |
## 故障排除
+6 -5
View File
@@ -90,11 +90,12 @@ Add one entry per row. Each row holds a platform user ID (required) and an optio
DM Policy only governs DMs — group `@mentions` are gated independently by **Group Policy** below. The user-level filter from the global **Allowed Users** is also applied; per-scope policy stacks on top.
| Policy | Behavior |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Open** | Any user on the platform can DM the bot (subject to the global allowlist when set). Best for public-facing assistants. |
| **Allowlist** | DMs require the sender to be in **Allowed Users**. Distinct from `Open` only when the list is empty: `Allowlist` then **fails closed** (no DMs); `Open` still lets anyone DM. |
| **Disabled** | The bot ignores all DMs entirely. Use this when the bot should only reply in shared channels via `@mention`. |
| Policy | Behavior |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Open** | Any user on the platform can DM the bot (subject to the global allowlist when set). Best for public-facing assistants. |
| **Allowlist** | DMs require the sender to be in **Allowed Users**. Distinct from `Open` only when the list is empty: `Allowlist` then **fails closed** (no DMs); `Open` still lets anyone DM. |
| **Pairing** | Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. The owner approves via `/approve <code>`, which appends the applicant to **Allowed Users** so future DMs flow normally. Requires **Your Platform User ID** and a configured Redis. |
| **Disabled** | The bot ignores all DMs entirely. Use this when the bot should only reply in shared channels via `@mention`. |
## Group Policy
+6 -5
View File
@@ -89,11 +89,12 @@ tags:
私信策略只影响私信 — 群聊里的 `@提及` 由下面的 **群组策略** 单独管理。全局 **允许的用户** 的用户级过滤也会同时生效;各 scope 的策略叠加在上面。
| 策略 | 行为 |
| ------------------- | -------------------------------------------------------------------------------------- |
| **开放 (Open)** | 平台上的任何用户都可以私信机器人(如设置了全局白名单则受其约束)。适合面向所有人开放的助手。 |
| **白名单 (Allowlist)** | 私信需要发送者在 **允许的用户** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式下会**全部拒绝**,而 `Open` 模式下任何人都能私信。 |
| **禁用 (Disabled)** | 机器人会忽略所有私信。适合那种 " 只在群里被 `@` 时才回复 " 的场景。 |
| 策略 | 行为 |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **开放 (Open)** | 平台上的任何用户都可以私信机器人(如设置了全局白名单则受其约束)。适合面向所有人开放的助手。 |
| **白名单 (Allowlist)** | 私信需要发送者在 **允许的用户** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式下会**全部拒绝**,而 `Open` 模式下任何人都能私信。 |
| **配对审批 (Pairing)** | 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户**,后续 DM 直通。需先填 **你的平台用户 ID** 并部署 Redis。 |
| **禁用 (Disabled)** | 机器人会忽略所有私信。适合那种 " 只在群里被 `@` 时才回复 " 的场景。 |
## 群组策略
+18 -1
View File
@@ -132,6 +132,22 @@ LobeHub supports two connection modes for QQ bots:
Click **Test Connection** in LobeHub's channel settings to verify the integration. Then open QQ, find your bot, and send a message. The bot should respond through your LobeHub agent.
## Set Your Platform Identity (Recommended)
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
### Your Platform User ID
This is your own QQ `tiny_id` (the platform-level user identifier — **not** the public-facing QQ number, which doesn't always match), used by:
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your QQ account.
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to friends won't accidentally lock you out.
To get it: DM the bot once with any message and check the server logs for the `tiny_id` field on the inbound event payload (or read it from the OpenAPI dashboard if available). Paste the long numeric ID into **Your Platform User ID** in LobeHub's Advanced Settings.
> QQ doesn't expose a single "default server" concept that AI tools can pivot on, so the **Default Server** field is not exposed for QQ channels.
## Adding the Bot to Group Chats
To use the bot in QQ groups:
@@ -152,6 +168,7 @@ A populated **Allowed User IDs** field is a global gate — DMs *and* group `@me
- **Open (default)** — Any QQ user who shares context with the bot can DM it (subject to the global allowlist when set).
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
- **Disabled** — The bot ignores all DMs and only responds to group `@mentions`.
### Group Policy
@@ -172,7 +189,7 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
| **App Secret** | Yes | Your bot's App Secret from QQ Open Platform |
| **Connection Mode** | No | `websocket` (default) or `webhook`. Choose based on your QQ Open Platform configuration |
| **Allowed User IDs** | No | Comma- or whitespace-separated QQ `tiny_id` values. Global gate — applies to DMs and group @mentions |
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
| **Allowed Channel IDs** | No | Comma- or whitespace-separated QQ group IDs. Used when Group Policy is Allowlist |
+26 -9
View File
@@ -129,6 +129,22 @@ LobeHub 持两种 QQ 机器人连接模式:
在 LobeHub 的渠道设置中点击 **测试连接** 以验证集成。然后打开 QQ,找到您的机器人并发送消息。机器人应通过您的 LobeHub 代理进行响应。
## 填写你的平台身份(推荐)
**高级设置**里有一个可选字段影响日常使用体验,建议一开始就填好。
### 你的平台用户 ID
也就是你自己的 QQ `tiny_id`(平台级用户标识符 ——**不是**对外可见的 QQ 号,两者不一定一致),用于:
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 QQ 账号。
- **防自锁** — 自动被 **允许的用户** 信任,给好友收紧 bot 时不会把自己挡在外面。
获取方式:先用任意消息私信 bot 一次,然后在 server log 里查看入站事件 payload 中的 `tiny_id` 字段(或在 OpenAPI 控制台读取,如果有)。把那串长数字 ID 粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
> QQ 没有一个稳定的 "默认服务器" 概念可让 AI 工具默认指向,因此 QQ 渠道不展示 **默认服务器** 字段。
## 将机器人添加到群聊
要在 QQ 群聊中使用机器人:
@@ -149,6 +165,7 @@ LobeHub 持两种 QQ 机器人连接模式:
- **开放 (Open)(默认)** — 任何与机器人有上下文交集的 QQ 用户都可以私信(若设置了全局白名单则受其约束)。
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群聊里被 `@提及` 时回复。
### 群组策略
@@ -163,15 +180,15 @@ LobeHub 持两种 QQ 机器人连接模式:
## 配置参考
| 字段 | 是否必需 | 描述 |
| -------------- | ---- | -------------------------------------------------- |
| **应用 ID** | 是 | 来自 QQ 开放平台的 App ID |
| **App Secret** | 是 | 来自 QQ 开放平台的 App Secret |
| **连接模式** | 否 | `websocket`(默认)或 `webhook`,根据 QQ 开放平台配置选择 |
| **允许的用户 ID** | 否 | 逗号或空格分隔的 QQ `tiny_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
| **允许的频道 ID** | 否 | 逗号或空格分隔的 QQ 群 ID。仅在群组策略为白名单时使用 |
| 字段 | 是否必需 | 描述 |
| -------------- | ---- | ---------------------------------------------------------- |
| **应用 ID** | 是 | 来自 QQ 开放平台的 App ID |
| **App Secret** | 是 | 来自 QQ 开放平台的 App Secret |
| **连接模式** | 否 | `websocket`(默认)或 `webhook`,根据 QQ 开放平台配置选择 |
| **允许的用户 ID** | 否 | 逗号或空格分隔的 QQ `tiny_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
| **允许的频道 ID** | 否 | 逗号或空格分隔的 QQ 群 ID。仅在群组策略为白名单时使用 |
## 功能限制
+22 -1
View File
@@ -213,6 +213,26 @@ Use this method if your Slack app already has Event Subscriptions configured wit
Also ensure you add the `commands` scope under **OAuth & Permissions** → **Bot Token Scopes**, and enable **Interactivity & Shortcuts** with the same Webhook URL as the Request URL.
</Steps>
## Set Your Platform Identity (Recommended)
Two optional fields under **Advanced Settings** carry a lot of weight in day-to-day use — fill them in once and most surprises go away.
### Your Platform User ID
This is your own Slack member ID, used by:
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Slack account.
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to teammates won't accidentally lock you out.
To get it: in Slack, click your avatar → **Profile**, then click the `⋮` overflow menu and choose **Copy member ID**. Member IDs start with `U`. Paste it into **Your Platform User ID** in LobeHub's Advanced Settings.
### Default Server
The Slack workspace (team) ID the bot's AI tools should default to when you ask it to "list channels", "send to #announcements", or anything else that needs a workspace context without naming one explicitly. Doesn't affect access control — that's **Group Policy**'s job.
To get it: open Slack in the browser; the URL contains the team ID (`https://app.slack.com/client/T01ABCDEF/...`) — copy the part starting with `T`. Paste it into **Default Server** in LobeHub's Advanced Settings.
## Access Policies
Two independent policies gate inbound traffic. Both default to **Open**.
@@ -225,6 +245,7 @@ A populated **Allowed User IDs** field is a global gate — DMs *and* channel `@
- **Open (default)** — Any workspace member can DM the bot (subject to the global allowlist when set).
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
- **Disabled** — The bot ignores all DMs and only replies to channel `@mentions`.
### Group Policy
@@ -247,7 +268,7 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
| **App-Level Token** | Socket Mode only | App-level token (`xapp-...`) for WebSocket connection |
| **Connection Mode** | No | `websocket` or `webhook` (default: `webhook`) |
| **Allowed User IDs** | No | Comma- or whitespace-separated Slack member IDs. Global gate — applies to DMs and channel @mentions |
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Slack channel IDs (start with `C`). Used when Group Policy is Allowlist |
+32 -11
View File
@@ -210,6 +210,26 @@ LobeHub 支持两种 Slack 连接模式:
同时确保在 **OAuth & Permissions** → **Bot Token Scopes** 中添加 `commands` 权限,并在 **Interactivity & Shortcuts** 中启用 Interactivity,将 Request URL 设为相同的 Webhook URL。
</Steps>
## 填写你的平台身份(推荐)
**高级设置**里有两个可选字段,影响着日常使用体验,建议一开始就填好。
### 你的平台用户 ID
也就是你自己的 Slack member ID,用于:
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 Slack 账号。
- **防自锁** — 自动被 **允许的用户** 信任,给同事收紧 bot 时不会把自己挡在外面。
获取方式:在 Slack 中点击你的头像 → **个人资料**,点击 `⋮` 溢出菜单,选 **复制 member ID**。member ID 以 `U` 开头。粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
### 默认服务器
Slack workspaceteamID。当你让 bot 做 "列出频道"、"发送到 #announcements" 这类需要 workspace 上下文但没指明哪个的事时,AI 工具会默认用这个。和访问控制无关 —— 那是 **群组策略** 的活。
获取方式:用浏览器打开 SlackURL 里就有 team ID`https://app.slack.com/client/T01ABCDEF/...`)—— 复制以 `T` 开头那段。粘贴到 LobeHub 高级设置的 **默认服务器** 字段。
## 接入策略
两个独立的策略控制入站消息,默认都为 **开放**。
@@ -222,6 +242,7 @@ LobeHub 支持两种 Slack 连接模式:
- **开放 (Open)(默认)** — workspace 内任何成员都可以私信机器人(若设置了全局白名单则受其约束)。
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
- **禁用 (Disabled)** — 机器人忽略所有私信,只在频道里被 `@提及` 时回复。
### 群组策略
@@ -236,17 +257,17 @@ LobeHub 支持两种 Slack 连接模式:
## 配置参考
| 字段 | 是否必需 | 描述 |
| -------------- | ------------- | --------------------------------------------------- |
| **应用 ID** | 是 | 您的 Slack 应用 ID |
| **Bot Token** | 是 | Bot User OAuth Token`xoxb-...` |
| **签名密钥** | 是 | 用于验证来自 Slack 的请求 |
| **应用级别 Token** | 仅 Socket Mode | 应用级别 Token`xapp-...`),用于 WebSocket 连接 |
| **连接模式** | 否 | `websocket` 或 `webhook`(默认:`webhook` |
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Slack 成员 ID。全局闸门 — 私信和频道 @ 都受其约束 |
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些频道中响应 |
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Slack 频道 ID(以 `C` 开头)。仅在群组策略为白名单时使用 |
| 字段 | 是否必需 | 描述 |
| -------------- | ------------- | ---------------------------------------------------------- |
| **应用 ID** | 是 | 您的 Slack 应用 ID |
| **Bot Token** | 是 | Bot User OAuth Token`xoxb-...` |
| **签名密钥** | 是 | 用于验证来自 Slack 的请求 |
| **应用级别 Token** | 仅 Socket Mode | 应用级别 Token`xapp-...`),用于 WebSocket 连接 |
| **连接模式** | 否 | `websocket` 或 `webhook`(默认:`webhook` |
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Slack 成员 ID。全局闸门 — 私信和频道 @ 都受其约束 |
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些频道中响应 |
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Slack 频道 ID(以 `C` 开头)。仅在群组策略为白名单时使用 |
## 故障排除
+18 -1
View File
@@ -79,6 +79,22 @@ Click **Test Connection** in LobeHub's channel settings to verify the integratio
![](/blog/assets5dd8b54083201bff2494404b66e37df0.webp)
## Set Your Platform Identity (Recommended)
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
### Your Platform User ID
This is your own Telegram numeric user ID, used by:
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Telegram account.
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to friends won't accidentally lock you out.
To get it: open Telegram, message [@userinfobot](https://t.me/userinfobot), and it will reply with your numeric user ID. Paste it into **Your Platform User ID** in LobeHub's Advanced Settings.
> Telegram doesn't have a "default server" concept (each chat is its own surface), so the **Default Server** field is not exposed for Telegram channels.
## Adding the Bot to Group Chats
To use the bot in Telegram groups:
@@ -105,6 +121,7 @@ A populated **Allowed User IDs** field acts as a global gate — DMs *and* group
- **Open (default)** — Anyone on Telegram can DM the bot (subject to the global allowlist when set).
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
- **Disabled** — The bot ignores all DMs and only responds to group `@mentions`.
### Group Policy
@@ -125,7 +142,7 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
| **Bot User ID** | Auto | Automatically derived from the bot token |
| **Webhook Secret Token** | No | Optional secret for verifying webhook requests |
| **Allowed User IDs** | No | Comma- or whitespace-separated Telegram numeric user IDs. Global gate — applies to DMs and group @mentions |
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds in groups |
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Telegram chat IDs (group IDs are negative). Used when Group Policy is Allowlist |
+26 -9
View File
@@ -78,6 +78,22 @@ tags:
![](/blog/assets5dd8b54083201bff2494404b66e37df0.webp)
## 填写你的平台身份(推荐)
**高级设置**里有一个可选字段影响日常使用体验,建议一开始就填好。
### 你的平台用户 ID
也就是你自己的 Telegram 数字用户 ID,用于:
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 Telegram 账号。
- **防自锁** — 自动被 **允许的用户** 信任,给好友收紧 bot 时不会把自己挡在外面。
获取方式:打开 Telegram,私信 [@userinfobot](https://t.me/userinfobot),它会把你的数字用户 ID 回给你。粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
> Telegram 没有 "默认服务器" 的概念(每个会话各自独立),因此 Telegram 渠道不展示 **默认服务器** 字段。
## 将机器人添加到群组聊天
要在 Telegram 群组中使用机器人:
@@ -104,6 +120,7 @@ tags:
- **开放 (Open)(默认)** — Telegram 上任何用户都可以私信机器人(若设置了全局白名单则受其约束)。
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群组里被 `@提及` 时回复。
### 群组策略
@@ -118,15 +135,15 @@ tags:
## 配置参考
| 字段 | 是否必需 | 描述 |
| ---------------- | ---- | --------------------------------------------------- |
| **机器人令牌** | 是 | 来自 BotFather 的 API 令牌 |
| **机器人用户 ID** | 自动 | 根据机器人令牌自动生成 |
| **Webhook 密钥令牌** | 否 | 用于验证 Webhook 请求的可选密钥 |
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Telegram 数字用户 ID。全局闸门 — 私信和群聊 @ 都受其约束 |
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群组中响应 |
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Telegram chat ID(群组为负数)。仅在群组策略为白名单时使用 |
| 字段 | 是否必需 | 描述 |
| ---------------- | ---- | ---------------------------------------------------------- |
| **机器人令牌** | 是 | 来自 BotFather 的 API 令牌 |
| **机器人用户 ID** | 自动 | 根据机器人令牌自动生成 |
| **Webhook 密钥令牌** | 否 | 用于验证 Webhook 请求的可选密钥 |
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Telegram 数字用户 ID。全局闸门 — 私信和群聊 @ 都受其约束 |
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群组中响应 |
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Telegram chat ID(群组为负数)。仅在群组策略为白名单时使用 |
## 故障排除
+20 -8
View File
@@ -23,8 +23,10 @@
"channel.charLimitHint": "Maximum number of characters per message",
"channel.concurrency": "Concurrency Mode",
"channel.concurrencyDebounce": "Debounce",
"channel.concurrencyHint": "Queue processes messages one at a time; Debounce waits for a burst of messages to finish before processing",
"channel.concurrencyDebounceHint": "Only process the last message in a burst (earlier ones are dropped)",
"channel.concurrencyHint": "How concurrent messages are batched",
"channel.concurrencyQueue": "Queue",
"channel.concurrencyQueueHint": "Process messages one at a time",
"channel.connectFailed": "Bot connection failed",
"channel.connectQueued": "Bot connection queued. It will start shortly.",
"channel.connectStarting": "Bot is starting. Please wait a moment.",
@@ -32,9 +34,11 @@
"channel.connecting": "Connecting...",
"channel.connectionConfig": "Connection Configuration",
"channel.connectionMode": "Connection Mode",
"channel.connectionModeHint": "WebSocket is recommended for new bots. Use Webhook if your bot already has a callback URL configured.",
"channel.connectionModeHint": "How the platform delivers events to the bot",
"channel.connectionModeWebSocket": "WebSocket",
"channel.connectionModeWebSocketHint": "Recommended for new bots",
"channel.connectionModeWebhook": "Webhook",
"channel.connectionModeWebhookHint": "Use if your bot has a callback URL configured",
"channel.copied": "Copied to clipboard",
"channel.copy": "Copy",
"channel.credentials": "Credentials",
@@ -56,9 +60,14 @@
"channel.dm": "Direct Messages",
"channel.dmPolicy": "DM Policy",
"channel.dmPolicyAllowlist": "Allowlist",
"channel.dmPolicyAllowlistHint": "Only listed users can DM the bot",
"channel.dmPolicyDisabled": "Disabled",
"channel.dmPolicyHint": "Control who can send direct messages to the bot",
"channel.dmPolicyDisabledHint": "Reject all DMs",
"channel.dmPolicyHint": "Who can DM the bot",
"channel.dmPolicyOpen": "Open",
"channel.dmPolicyOpenHint": "Accept DMs from anyone",
"channel.dmPolicyPairing": "Pairing",
"channel.dmPolicyPairingHint": "Strangers need /approve to DM",
"channel.documentation": "Documentation",
"channel.enabled": "Enabled",
"channel.encryptKey": "Encrypt Key",
@@ -80,9 +89,12 @@
"channel.groupAllowFromNamePlaceholder": "e.g. #general (your reminder)",
"channel.groupPolicy": "Group Policy",
"channel.groupPolicyAllowlist": "Allowlist",
"channel.groupPolicyAllowlistHint": "Only respond in listed channels",
"channel.groupPolicyDisabled": "Disabled",
"channel.groupPolicyHint": "Control where the bot responds in groups, channels, and threads",
"channel.groupPolicyDisabledHint": "Ignore all group messages",
"channel.groupPolicyHint": "Where the bot responds in groups, channels, and threads",
"channel.groupPolicyOpen": "Open",
"channel.groupPolicyOpenHint": "Respond in any group, channel, or thread",
"channel.historyLimit": "History Message Limit",
"channel.historyLimitHint": "Default number of messages to fetch when reading channel history",
"channel.importConfig": "Import Configuration",
@@ -111,8 +123,8 @@
"channel.secretToken": "Webhook Secret Token",
"channel.secretTokenHint": "Optional. Used to verify webhook requests from Telegram.",
"channel.secretTokenPlaceholder": "Optional secret for webhook verification",
"channel.serverId": "Default Server (for AI tools)",
"channel.serverIdHint": "The server / guild ID AI tools should default to when you ask the bot to act on a server (e.g. 'list channels', 'send to #announcements'). Independent of access control — see Group Policy for that.",
"channel.serverId": "Default Server ID",
"channel.serverIdHint": "Default server / guild AI tools act on; not used for access control",
"channel.settings": "Advanced Settings",
"channel.settingsResetConfirm": "Are you sure you want to reset advanced settings to default?",
"channel.settingsResetDefault": "Reset to Default",
@@ -138,8 +150,8 @@
"channel.testFailed": "Connection test failed",
"channel.testSuccess": "Connection test passed",
"channel.updateFailed": "Failed to update status",
"channel.userId": "Your Platform User ID (for AI tools)",
"channel.userIdHint": "AI tools use this to reach you proactively (e.g. reminders, notifications); also auto-trusted by the global allowlist.",
"channel.userId": "Your Platform User ID",
"channel.userIdHint": "Lets AI tools reach you proactively (e.g. reminders); auto-trusted by the global allowlist",
"channel.validationError": "Please fill in Application ID and Token",
"channel.verificationToken": "Verification Token",
"channel.verificationTokenHint": "Optional. Used to verify webhook event source.",
+2
View File
@@ -5,12 +5,14 @@
"agentSelection.search": "No matching agents found",
"brief.action.acknowledge": "Acknowledge",
"brief.action.approve": "Approve",
"brief.action.confirmDone": "Confirm complete",
"brief.action.feedback": "Feedback",
"brief.action.retry": "Retry",
"brief.addFeedback": "Share feedback",
"brief.collapse": "Show less",
"brief.commentPlaceholder": "Share your feedback...",
"brief.commentSubmit": "Submit feedback",
"brief.editResult": "Edit",
"brief.expandAll": "Show more",
"brief.feedbackSent": "Feedback shared",
"brief.resolved": "Marked as resolved",
+20 -8
View File
@@ -23,8 +23,10 @@
"channel.charLimitHint": "单条消息的最大字符数",
"channel.concurrency": "并发模式",
"channel.concurrencyDebounce": "防抖",
"channel.concurrencyHint": "队列模式逐条处理消息;防抖模式等待连续消息发送完毕后再统一处理",
"channel.concurrencyDebounceHint": "仅处理连续消息中的最后一条,前序消息会被丢弃",
"channel.concurrencyHint": "并发消息的处理方式",
"channel.concurrencyQueue": "队列",
"channel.concurrencyQueueHint": "逐条处理消息",
"channel.connectFailed": "Bot 连接失败",
"channel.connectQueued": "机器人连接已排队。即将启动。",
"channel.connectStarting": "机器人正在启动。请稍候。",
@@ -32,9 +34,11 @@
"channel.connecting": "连接中...",
"channel.connectionConfig": "连接配置",
"channel.connectionMode": "连接模式",
"channel.connectionModeHint": "机器人推荐使用 WebSocket。如果你的机器人已配置了回调地址,请选择 Webhook。",
"channel.connectionModeHint": "平台向机器人推送事件的方式",
"channel.connectionModeWebSocket": "WebSocket",
"channel.connectionModeWebSocketHint": "推荐用于新机器人",
"channel.connectionModeWebhook": "Webhook",
"channel.connectionModeWebhookHint": "已配置回调地址时选用",
"channel.copied": "已复制到剪贴板",
"channel.copy": "复制",
"channel.credentials": "凭证配置",
@@ -56,9 +60,14 @@
"channel.dm": "私信",
"channel.dmPolicy": "私信策略",
"channel.dmPolicyAllowlist": "白名单",
"channel.dmPolicyAllowlistHint": "仅允许名单内的用户发送私信",
"channel.dmPolicyDisabled": "禁用",
"channel.dmPolicyHint": "控制谁可以向机器人发送私信",
"channel.dmPolicyDisabledHint": "拒绝所有私信",
"channel.dmPolicyHint": "谁可以向机器人发送私信",
"channel.dmPolicyOpen": "开放",
"channel.dmPolicyOpenHint": "接受任何人发送的私信",
"channel.dmPolicyPairing": "配对审批",
"channel.dmPolicyPairingHint": "陌生人需经 /approve 审批后才能私信",
"channel.documentation": "文档",
"channel.enabled": "已启用",
"channel.encryptKey": "Encrypt Key",
@@ -80,9 +89,12 @@
"channel.groupAllowFromNamePlaceholder": "如:#general(仅你自己可见)",
"channel.groupPolicy": "群组策略",
"channel.groupPolicyAllowlist": "白名单",
"channel.groupPolicyAllowlistHint": "仅在名单内的频道中响应",
"channel.groupPolicyDisabled": "禁用",
"channel.groupPolicyHint": "控制机器人在群组、频道、子话题里的响应范围",
"channel.groupPolicyDisabledHint": "忽略所有群组消息",
"channel.groupPolicyHint": "机器人在群组、频道、子话题中的响应范围",
"channel.groupPolicyOpen": "开放",
"channel.groupPolicyOpenHint": "在所有群组、频道、子话题中响应",
"channel.historyLimit": "历史消息条数",
"channel.historyLimitHint": "读取频道历史消息时默认获取的消息数量",
"channel.importConfig": "导入平台配置",
@@ -111,8 +123,8 @@
"channel.secretToken": "Webhook 密钥",
"channel.secretTokenHint": "可选。用于验证来自 Telegram 的 Webhook 请求。",
"channel.secretTokenPlaceholder": "可选的 Webhook 验证密钥",
"channel.serverId": "默认服务器(供 AI 工具使用)",
"channel.serverIdHint": "你让 bot 在某个服务器上做事时(比如 \"列出频道\"、\"发到 #announcements\"),AI 工具默认作的服务器 / Guild ID。跟访问控制无关 —— 那是群组策略的事。",
"channel.serverId": "默认服务器 ID",
"channel.serverIdHint": "AI 工具默认作的服务器 / Guild,与访问控制无关",
"channel.settings": "高级设置",
"channel.settingsResetConfirm": "确定要将高级设置恢复为默认配置吗?",
"channel.settingsResetDefault": "恢复默认配置",
@@ -138,8 +150,8 @@
"channel.testFailed": "连接测试失败",
"channel.testSuccess": "连接测试通过",
"channel.updateFailed": "更新状态失败",
"channel.userId": "你的平台用户 ID(供 AI 工具使用)",
"channel.userIdHint": "AI 工具用它主动联系你(如定时提醒、通知);该 ID 也会被全局白名单自动信任。",
"channel.userId": "你的平台用户 ID",
"channel.userIdHint": "AI 工具主动联系你(如提醒、通知),并自动加入全局白名单",
"channel.validationError": "请填写应用 ID 和 Token",
"channel.verificationToken": "Verification Token",
"channel.verificationTokenHint": "可选。用于验证事件推送来源。",
+1 -1
View File
@@ -268,7 +268,7 @@
"footer.title": "喜欢我们的产品?",
"fullscreen": "全屏模式",
"generation.hero.taglinePrefix": "即刻创作",
"generation.promptModeration.blocked": "请求内容可能违反内容政策。请调整提示词后重试",
"generation.promptModeration.blocked": "内容安全检查未通过,请调整提示词后重试",
"getDesktopApp": "获取桌面应用",
"historyRange": "历史范围",
"home.suggestQuestions": "试试这些示例",
+2
View File
@@ -111,6 +111,8 @@
"response.PluginServerError": "技能服务端返回错误,请根据下方信息检查技能描述、配置或服务端实现",
"response.PluginSettingsInvalid": "该技能需要完成配置后才能使用,请检查技能配置",
"response.ProviderBizError": "模型服务商返回错误。请根据以下信息排查,或稍后重试",
"response.ProviderContentModeration": "内容安全检查未通过,请调整描述后重试。",
"response.ProviderContentModerationWarning": "多次触发内容安全限制,继续违规可能导致账号受限。",
"response.QuotaLimitReached": "Token 用量或请求次数已达配额上限。请提升配额或稍后再试",
"response.QuotaLimitReachedCloud": "当前模型服务负载较高,请稍后重试或切换其他模型。",
"response.ServerAgentRuntimeError": "助理运行服务暂不可用。请稍后再试,或邮件联系我们",
+2
View File
@@ -5,12 +5,14 @@
"agentSelection.search": "未找到匹配的助理",
"brief.action.acknowledge": "确认",
"brief.action.approve": "批准",
"brief.action.confirmDone": "确认完成",
"brief.action.feedback": "反馈",
"brief.action.retry": "重试",
"brief.addFeedback": "分享反馈",
"brief.collapse": "收起",
"brief.commentPlaceholder": "分享你的反馈…",
"brief.commentSubmit": "提交反馈",
"brief.editResult": "编辑",
"brief.expandAll": "展开全部",
"brief.feedbackSent": "反馈已提交",
"brief.resolved": "已标记为已解决",
+1 -1
View File
@@ -15,7 +15,7 @@ export const BriefManifest: BuiltinToolManifest = {
properties: {
actions: {
description:
'Custom action buttons for the user. If omitted, defaults are generated based on type. Each action has key (identifier), label (display text), and type ("resolve" to close, "comment" to prompt feedback).',
'Custom action buttons for the user. Ignored when type is "result" (result briefs render a fixed approve button). For other types, if omitted, defaults are generated based on type. Each action has key (identifier), label (display text), and type ("resolve" to close, "comment" to prompt feedback).',
items: {
properties: {
key: { description: 'Action identifier, e.g. "approve", "split"', type: 'string' },
+7
View File
@@ -23,6 +23,9 @@ export class RecentModel {
}
queryRecent = async (limit: number = 10): Promise<RecentDbItem[]> => {
// System-trigger topics live in their own surfaces (Task Manager, cron,
// eval, task runs) and would clutter the main "Recent" sidebar. Mirrors
// `MAIN_SIDEBAR_EXCLUDE_TRIGGERS` in `src/const/topic.ts`.
const query = sql`
SELECT * FROM (
SELECT
@@ -41,6 +44,10 @@ export class RecentModel {
OR ${agents.slug} = 'inbox'
OR (${topics.groupId} IS NULL AND ${agents.virtual} != true)
)
AND (
${topics.trigger} IS NULL
OR ${topics.trigger} NOT IN ('cron', 'eval', 'task_manager', 'task')
)
UNION ALL
@@ -181,6 +181,7 @@ export class TaskTopicModel {
.select({
createdAt: taskTopics.createdAt,
handoff: taskTopics.handoff,
metadata: topics.metadata,
seq: taskTopics.seq,
status: taskTopics.status,
title: topics.title,
@@ -465,6 +465,57 @@ describe('createRouterRuntime', () => {
expect(mockChatSuccess).not.toHaveBeenCalled();
});
it('should not retry when shouldStopFallback returns true', async () => {
const moderationError = {
errorType: AgentRuntimeErrorType.ProviderBizError,
error: { message: 'Content violates usage guidelines' },
provider: 'test',
};
const mockChatFail = vi.fn().mockRejectedValue(moderationError);
const mockChatSuccess = vi.fn().mockResolvedValue('success');
const shouldStopFallback = vi.fn().mockResolvedValue(true);
class FailRuntime implements LobeRuntimeAI {
chat = mockChatFail;
}
class SuccessRuntime implements LobeRuntimeAI {
chat = mockChatSuccess;
}
const Runtime = createRouterRuntime({
id: 'test-runtime',
routers: [
{
apiType: 'openai',
options: [
{ apiKey: 'key-1', runtime: FailRuntime as any },
{ apiKey: 'key-2', runtime: SuccessRuntime as any },
],
runtime: FailRuntime as any,
models: ['gpt-4'],
},
],
shouldStopFallback,
});
const runtime = new Runtime();
await expect(
runtime.chat({ model: 'gpt-4', messages: [], temperature: 0.7 }),
).rejects.toEqual(moderationError);
expect(shouldStopFallback).toHaveBeenCalledWith(
expect.objectContaining({
error: moderationError,
model: 'gpt-4',
optionIndex: 0,
}),
);
expect(mockChatFail).toHaveBeenCalledTimes(1);
expect(mockChatSuccess).not.toHaveBeenCalled();
});
it('should still retry on other error types', async () => {
const bizError = {
errorType: AgentRuntimeErrorType.ProviderBizError,
@@ -164,6 +164,12 @@ export interface CreateRouterRuntimeOptions<T extends Record<string, any> = any>
) => ChatStreamPayload;
};
routers: Routers;
shouldStopFallback?: (params: {
error: unknown;
metadata?: Record<string, unknown>;
model: string;
optionIndex: number;
}) => boolean | Promise<boolean>;
}
export const createRouterRuntime = ({
@@ -406,6 +412,25 @@ export const createRouterRuntime = ({
throw error;
}
try {
const shouldStopFallback = await params.shouldStopFallback?.({
error,
metadata,
model,
optionIndex: index,
});
if (shouldStopFallback) {
throw error;
}
} catch (fallbackError) {
if (fallbackError === error) {
throw error;
}
log('shouldStopFallback callback error: %O', fallbackError);
}
if (attempt < totalOptions) {
log(
'attempt %d/%d failed (model=%s apiType=%s channelId=%s remark=%s), trying next',
+3 -2
View File
@@ -23,16 +23,17 @@ export enum AsyncTaskErrorType {
FreePlanLimit = 'FreePlanLimit',
InvalidProviderAPIKey = 'InvalidProviderAPIKey',
/* ↑ cloud slot ↑ */
/**
* Model not found on server
*/
ModelNotFound = 'ModelNotFound',
/* ↑ cloud slot ↑ */
/**
* the chunk parse result it empty
*/
NoChunkError = 'NoChunkError',
ProviderContentModeration = 'ProviderContentModeration',
ServerError = 'ServerError',
/**
* Subscription plan limit reached (paid users run out of credits)
+7 -5
View File
@@ -14,7 +14,13 @@ export interface BriefAction {
url?: string;
}
/** Default actions by brief type */
/**
* Default actions by brief type.
*
* Note: `result` briefs intentionally have no defaults — they are terminal and
* render a fixed single-button UI (approve → completes the task). Custom
* actions on result briefs are dropped at creation time.
*/
export const DEFAULT_BRIEF_ACTIONS: Record<string, BriefAction[]> = {
decision: [
{ key: 'approve', label: '✅ 确认', type: 'resolve' },
@@ -25,10 +31,6 @@ export const DEFAULT_BRIEF_ACTIONS: Record<string, BriefAction[]> = {
{ key: 'feedback', label: '💬 反馈', type: 'comment' },
],
insight: [{ key: 'acknowledge', label: '👍 知悉', type: 'resolve' }],
result: [
{ key: 'approve', label: '✅ 通过', type: 'resolve' },
{ key: 'feedback', label: '💬 修改意见', type: 'comment' },
],
};
/** Brief type — must match DEFAULT_BRIEF_ACTIONS keys and DB schema comment */
+14
View File
@@ -162,11 +162,14 @@ export interface TaskDetailSubtaskAssignee {
export interface TaskDetailSubtask {
assignee?: TaskDetailSubtaskAssignee | null;
automationMode?: TaskAutomationMode | null;
blockedBy?: string;
children?: TaskDetailSubtask[];
heartbeat?: { interval?: number | null };
identifier: string;
name?: string | null;
priority?: number | null;
schedule?: { pattern?: string | null; timezone?: string | null };
status: string;
}
@@ -210,6 +213,17 @@ export interface TaskDetailActivity {
resolvedAction?: string | null;
resolvedAt?: string | null;
resolvedComment?: string | null;
/**
* Topic-only: currently running Gateway operation, mirrored from
* `topics.metadata.runningOperation`. Lets the task topic drawer establish
* a Gateway WebSocket reconnection without a separate topic lookup.
*/
runningOperation?: {
assistantMessageId: string;
operationId: string;
scope?: string;
threadId?: string | null;
} | null;
seq?: number | null;
status?: string | null;
summary?: string;
@@ -0,0 +1,5 @@
export const getProviderContentPolicyErrorMessage = async (_params: {
error: unknown;
provider: string;
userId?: string;
}): Promise<string | undefined> => undefined;
@@ -0,0 +1,11 @@
export interface TrackProviderContentPolicyViolationParams {
error: unknown;
model?: string;
provider: string;
trigger?: string;
userId?: string;
}
export const trackProviderContentPolicyViolation = async (
_params: TrackProviderContentPolicyViolationParams,
): Promise<void> => {};
+4 -1
View File
@@ -1,11 +1,14 @@
/**
* Well-known `topic.trigger` values used to segment the same agent's topics
* across different panels (Task Manager vs. main chat).
*
* `RunTask` is what `TaskRunnerService` writes when starting an agent run for
* a task; the literal `'task'` is intentional and matches existing DB rows.
*/
export const TopicTrigger = {
Cron: 'cron',
Eval: 'eval',
RunTask: 'run_task',
RunTask: 'task',
TaskManager: 'task_manager',
} as const;
@@ -1,5 +1,5 @@
import type { TaskDetailSubtask } from '@lobechat/types';
import { ActionIcon, Avatar, Block, ContextMenuTrigger, Flexbox, Icon, Text } from '@lobehub/ui';
import { ActionIcon, Block, ContextMenuTrigger, Flexbox, Icon, Text } from '@lobehub/ui';
import { Button, ConfigProvider, Tree } from 'antd';
import type { DataNode } from 'antd/es/tree';
import { cssVar } from 'antd-style';
@@ -12,9 +12,12 @@ import { useTaskStore } from '@/store/task';
import { taskDetailSelectors } from '@/store/task/selectors';
import CreateTaskInlineEntry from '../AgentTaskList/CreateTaskInlineEntry';
import AssigneeAgentSelector from '../features/AssigneeAgentSelector';
import AssigneeAvatar from '../features/AssigneeAvatar';
import TaskPriorityTag from '../features/TaskPriorityTag';
import TaskStatusTag from '../features/TaskStatusTag';
import TaskSubtaskProgressTag from '../features/TaskSubtaskProgressTag';
import TaskTriggerTag from '../features/TaskTriggerTag';
import { useTaskItemContextMenu } from '../features/useTaskItemContextMenu';
import { styles } from '../shared/style';
@@ -78,13 +81,16 @@ const SubtaskTitle = memo<{ task: TaskDetailSubtask }>(({ task }) => {
status: task.status,
});
const isRunning = status === 'running';
return (
<ContextMenuTrigger items={items} onContextMenu={onContextMenu}>
<Flexbox
horizontal
align="center"
gap={8}
style={{ lineHeight: 1, minWidth: 0, overflow: 'hidden' }}
justify="space-between"
style={{ lineHeight: 1, minWidth: 0, overflow: 'hidden', width: '100%' }}
>
<span
style={{ alignItems: 'center', display: 'inline-flex', flex: 'none' }}
@@ -101,21 +107,34 @@ const SubtaskTitle = memo<{ task: TaskDetailSubtask }>(({ task }) => {
<Text ellipsis fontSize={13} style={{ flex: 1, minWidth: 0 }}>
{task.name || task.identifier}
</Text>
{task.assignee && (
{task.automationMode ? (
<span
style={{ alignItems: 'center', display: 'inline-flex', flex: 'none' }}
onClick={(e) => e.stopPropagation()}
>
<Avatar
avatar={task.assignee.avatar ?? ''}
background={task.assignee.backgroundColor || cssVar.colorBgContainer}
shape="circle"
size={18}
title={task.assignee.title ?? ''}
variant="outlined"
<TaskTriggerTag
heartbeatInterval={task.heartbeat?.interval}
schedulePattern={task.schedule?.pattern}
scheduleTimezone={task.schedule?.timezone}
/>
</span>
)}
) : null}
<AssigneeAgentSelector
currentAgentId={task.assignee?.id ?? null}
disabled={isRunning}
taskIdentifier={task.identifier}
>
<span
style={{
alignItems: 'center',
cursor: isRunning ? 'not-allowed' : 'pointer',
display: 'inline-flex',
flex: 'none',
}}
>
<AssigneeAvatar agentId={task.assignee?.id} size={18} />
</span>
</AssigneeAgentSelector>
</Flexbox>
</ContextMenuTrigger>
);
@@ -7,6 +7,7 @@ import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ChatList, ConversationProvider, MessageItem } from '@/features/Conversation';
import { useGatewayReconnect } from '@/hooks/useGatewayReconnect';
import { useOperationState } from '@/hooks/useOperationState';
import { useChatStore } from '@/store/chat';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
@@ -36,6 +37,11 @@ const TopicChatDrawerBody = memo<TopicChatDrawerBodyProps>(({ agentId, topicId }
const replaceMessages = useChatStore((s) => s.replaceMessages);
const operationState = useOperationState(context);
const runningOperation = useTaskStore(
(s) => taskActivitySelectors.activeDrawerTopicActivity(s)?.runningOperation,
);
useGatewayReconnect(topicId, runningOperation);
const itemContent = useCallback(
(index: number, id: string) => (
<MessageItem
+8
View File
@@ -61,14 +61,22 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
overflow: hidden;
display: flex;
flex: 1;
gap: 4px;
align-items: center;
min-width: 0;
min-height: 36px;
color: ${cssVar.colorTextSecondary};
}
.ant-tree-title {
overflow: hidden;
flex: 1;
min-width: 0;
}
.ant-tree-switcher {
margin-inline-end: 0;
color: ${cssVar.colorTextDescription};
@@ -2,7 +2,7 @@
import { ActionIcon, Flexbox, Icon } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { ListEnd, Pencil, SendHorizontal, Trash2 } from 'lucide-react';
import { ArrowUp, ListEnd, Pencil, Trash2 } from 'lucide-react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -124,7 +124,7 @@ const QueueTray = memo(() => {
onClick={() => handleEdit(msg.id, msg.content)}
/>
<ActionIcon
icon={SendHorizontal}
icon={ArrowUp}
size="small"
title={t('inputQueue.sendNow')}
onClick={() => handleSendNow(msg)}
+16 -5
View File
@@ -28,7 +28,7 @@ type CommentMode = { type: 'feedback' } | { key: string; type: 'comment' };
const SuccessTag = memo<{ label: string }>(({ label }) => (
<Flexbox horizontal align={'center'} gap={4}>
<Icon icon={Check} size={14} />
<Icon color={cssVar.colorTextQuaternary} icon={Check} size={14} />
<Text className={styles.resolvedTag}>{label}</Text>
</Flexbox>
));
@@ -58,15 +58,20 @@ const BriefCardActions = memo<BriefCardActionsProps>(
return () => clearTimeout(timer);
}, [feedbackSent]);
const actions = actionsProp ?? DEFAULT_BRIEF_ACTIONS[briefType] ?? [];
const isResult = briefType === 'result';
const actions: BriefAction[] = isResult
? [{ key: 'approve', label: t('brief.action.confirmDone'), type: 'resolve' }]
: (actionsProp ?? DEFAULT_BRIEF_ACTIONS[briefType] ?? []);
const getActionLabel = useCallback(
(action: BriefAction) => {
if (isResult && action.key === 'approve') return t('brief.action.confirmDone');
const i18nKey = `brief.action.${action.key}`;
const translated = t(i18nKey, { defaultValue: '' });
return !translated || translated === i18nKey ? action.label : translated;
},
[t],
[isResult, t],
);
const handleResolve = useCallback(
@@ -119,12 +124,18 @@ const BriefCardActions = memo<BriefCardActionsProps>(
.filter((a) => a.type !== 'comment')
.slice(1)
.reverse();
const showEditButton = !!taskId && (isResult || !!commentActions);
const editTooltip = isResult
? t('brief.editResult')
: commentActions
? getActionLabel(commentActions) || t('brief.addFeedback')
: t('brief.addFeedback');
return (
<Flexbox horizontal align={'center'} gap={8} justify={'flex-end'} wrap={'wrap'}>
<Flexbox horizontal align={'center'} gap={8}>
{taskId && commentActions && (
<Tooltip title={getActionLabel(commentActions) || t('brief.addFeedback')}>
{showEditButton && (
<Tooltip title={editTooltip}>
<Button
className={'brief-comment-btn'}
icon={SquarePen}
+4 -4
View File
@@ -2,25 +2,25 @@ import { type BriefType } from '@lobechat/types';
import { Block, Icon } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import type { CircleDot } from 'lucide-react';
import { CheckCheckIcon, EyeIcon, Lightbulb, PencilRulerIcon, SirenIcon } from 'lucide-react';
import { CheckCheckIcon, EyeIcon, HandIcon, Lightbulb, SirenIcon } from 'lucide-react';
import { memo } from 'react';
const BRIEF_TYPE_ICON: Record<BriefType, typeof CircleDot> = {
decision: PencilRulerIcon,
decision: HandIcon,
error: SirenIcon,
insight: EyeIcon,
result: CheckCheckIcon,
};
const BRIEF_TYPE_COLOR: Record<BriefType, string | undefined> = {
decision: cssVar.colorWarning,
decision: cssVar.colorInfo,
error: cssVar.colorError,
insight: cssVar.colorInfo,
result: cssVar.colorSuccess,
} as const;
const BRIEF_TYPE_COLOR_BG: Record<BriefType, string | undefined> = {
decision: cssVar.colorWarningBgHover,
decision: cssVar.colorInfoBgHover,
error: cssVar.colorErrorBgHover,
insight: cssVar.colorInfoBgHover,
result: cssVar.colorSuccessBgHover,
@@ -15,6 +15,8 @@ vi.mock('react-i18next', () => ({
'cancel': 'Cancel',
'brief.commentPlaceholder': 'Share your feedback...',
'brief.commentSubmit': 'Submit feedback',
'brief.action.confirmDone': 'Confirm complete',
'brief.editResult': 'Edit',
};
return map[key] || key;
},
@@ -107,8 +109,34 @@ describe('BriefCardActions', () => {
});
it('should fallback to DEFAULT_BRIEF_ACTIONS when actions prop is null', () => {
render(<BriefCardActions actions={null} briefId="brief-2" briefType="result" />);
render(<BriefCardActions actions={null} briefId="brief-2" briefType="decision" />);
expect(screen.getByText('✅ 通过')).toBeInTheDocument();
expect(screen.getByText('✅ 确认')).toBeInTheDocument();
});
it('should hardcode primary action label to "Confirm complete" for result briefs', () => {
render(
<BriefCardActions
actions={[{ key: 'approve', label: '✅ Custom approve', type: 'resolve' }]}
briefId="brief-3"
briefType="result"
/>,
);
expect(screen.getByText('Confirm complete')).toBeInTheDocument();
expect(screen.queryByText('✅ Custom approve')).not.toBeInTheDocument();
});
it('should always show the Edit button for result briefs when taskId is set', () => {
const { container } = render(
<BriefCardActions
actions={[{ key: 'approve', label: '✅ Custom', type: 'resolve' }]}
briefId="brief-4"
briefType="result"
taskId="task-1"
/>,
);
expect(container.querySelector('.brief-comment-btn')).toBeInTheDocument();
});
});
+49
View File
@@ -0,0 +1,49 @@
import useSWR from 'swr';
import { useChatStore } from '@/store/chat';
interface RunningOperation {
assistantMessageId: string;
operationId: string;
scope?: string;
threadId?: string | null;
}
/**
* Auto-reconnect to a running Gateway operation on the given topic.
*
* The caller sources `runningOperation` itself — the chat-store topic map
* (main agent) and the task-detail activity (task drawer) live in different
* stores, so this hook stays source-agnostic.
*
* SWR key is the operationId, so the same operation deduplicates and only
* one reconnect attempt fires per op.
*/
export const useGatewayReconnect = (
topicId: string | null | undefined,
runningOperation: RunningOperation | null | undefined,
) => {
const isGatewayModeEnabled = useChatStore((s) => s.isGatewayModeEnabled);
useSWR(
runningOperation && topicId && isGatewayModeEnabled()
? ['reconnectGateway', runningOperation.operationId]
: null,
async () => {
if (!runningOperation || !topicId) return;
await useChatStore.getState().reconnectToGatewayOperation({
assistantMessageId: runningOperation.assistantMessageId,
operationId: runningOperation.operationId,
scope: runningOperation.scope,
threadId: runningOperation.threadId,
topicId,
});
},
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
},
);
};
+21 -11
View File
@@ -113,27 +113,35 @@ export default {
'channel.appSecretHint':
'The App Secret of your bot application. It will be encrypted and stored securely.',
'channel.connectionMode': 'Connection Mode',
'channel.connectionModeHint':
'WebSocket is recommended for new bots. Use Webhook if your bot already has a callback URL configured.',
'channel.connectionModeHint': 'How the platform delivers events to the bot',
'channel.connectionModeWebSocket': 'WebSocket',
'channel.connectionModeWebSocketHint': 'Recommended for new bots',
'channel.connectionModeWebhook': 'Webhook',
'channel.connectionModeWebhookHint': 'Use if your bot has a callback URL configured',
'channel.charLimit': 'Character Limit',
'channel.charLimitHint': 'Maximum number of characters per message',
'channel.concurrency': 'Concurrency Mode',
'channel.concurrencyDebounce': 'Debounce',
'channel.concurrencyHint':
'Queue processes messages one at a time; Debounce waits for a burst of messages to finish before processing',
'channel.concurrencyDebounceHint':
'Only process the last message in a burst (earlier ones are dropped)',
'channel.concurrencyHint': 'How concurrent messages are batched',
'channel.concurrencyQueue': 'Queue',
'channel.concurrencyQueueHint': 'Process messages one at a time',
'channel.credentials': 'Credentials',
'channel.debounceMs': 'Debounce Window (ms)',
'channel.debounceMsHint':
'How long to wait for additional messages before dispatching to the agent (ms)',
'channel.dm': 'Direct Messages',
'channel.dmPolicy': 'DM Policy',
'channel.dmPolicyHint': 'Control who can send direct messages to the bot',
'channel.dmPolicyHint': 'Who can DM the bot',
'channel.dmPolicyAllowlist': 'Allowlist',
'channel.dmPolicyAllowlistHint': 'Only listed users can DM the bot',
'channel.dmPolicyDisabled': 'Disabled',
'channel.dmPolicyDisabledHint': 'Reject all DMs',
'channel.dmPolicyOpen': 'Open',
'channel.dmPolicyOpenHint': 'Accept DMs from anyone',
'channel.dmPolicyPairing': 'Pairing',
'channel.dmPolicyPairingHint': 'Strangers need /approve to DM',
'channel.allowFrom': 'Allowed Users',
'channel.allowFromHint':
"Only listed users can interact with the bot; your 'Platform User ID' is auto-included.",
@@ -144,10 +152,13 @@ export default {
'channel.allowFromAdd': 'Add user',
'channel.allowFromEmpty': 'No users added yet — anyone can interact with the bot.',
'channel.groupPolicy': 'Group Policy',
'channel.groupPolicyHint': 'Control where the bot responds in groups, channels, and threads',
'channel.groupPolicyHint': 'Where the bot responds in groups, channels, and threads',
'channel.groupPolicyAllowlist': 'Allowlist',
'channel.groupPolicyAllowlistHint': 'Only respond in listed channels',
'channel.groupPolicyDisabled': 'Disabled',
'channel.groupPolicyDisabledHint': 'Ignore all group messages',
'channel.groupPolicyOpen': 'Open',
'channel.groupPolicyOpenHint': 'Respond in any group, channel, or thread',
'channel.groupAllowFrom': 'Allowed Channels',
'channel.groupAllowFromHint': 'Channel / group / chat IDs the bot may respond in.',
'channel.groupAllowFromIdLabel': 'Channel ID',
@@ -169,12 +180,11 @@ export default {
'Show tool call details during AI responses. When disabled, only the final response is displayed for a cleaner experience.',
'channel.historyLimit': 'History Message Limit',
'channel.historyLimitHint': 'Default number of messages to fetch when reading channel history',
'channel.serverId': 'Default Server (for AI tools)',
'channel.serverIdHint':
"The server / guild ID AI tools should default to when you ask the bot to act on a server (e.g. 'list channels', 'send to #announcements'). Independent of access control — see Group Policy for that.",
'channel.userId': 'Your Platform User ID (for AI tools)',
'channel.serverId': 'Default Server ID',
'channel.serverIdHint': 'Default server / guild AI tools act on; not used for access control',
'channel.userId': 'Your Platform User ID',
'channel.userIdHint':
'AI tools use this to reach you proactively (e.g. reminders, notifications); also auto-trusted by the global allowlist.',
'Lets AI tools reach you proactively (e.g. reminders); auto-trusted by the global allowlist',
'channel.refreshStatus': 'Refresh status',
'channel.runtimeDisconnected': 'Bot disconnected',
'channel.statusConnected': 'Connected',
+1 -2
View File
@@ -350,8 +350,7 @@ export default {
'footer.title': 'Like Our Product?',
'fullscreen': 'Full Screen Mode',
'generation.hero.taglinePrefix': 'Start Creating with',
'generation.promptModeration.blocked':
'The request content may violate content policy. Please modify your prompt and try again.',
'generation.promptModeration.blocked': 'Content policy check failed. Please revise your prompt.',
'historyRange': 'History Range',
'home.suggestQuestions': 'Try these examples',
'import': 'Import',
+4
View File
@@ -199,6 +199,10 @@ export default {
'This skill needs to be correctly configured before it can be used. Please check if your configuration is correct',
'response.ProviderBizError':
'Error requesting {{provider}} service, please troubleshoot or retry based on the following information',
'response.ProviderContentModeration':
'Content policy check failed. Revise your prompt and try again.',
'response.ProviderContentModerationWarning':
'Repeated policy violations detected. Further misuse may restrict your account.',
'response.QuotaLimitReached':
"Sorry, the token usage or request count has reached the quota limit for this key. Please increase the key's quota or try again later.",
'response.ServerAgentRuntimeError':
+2
View File
@@ -7,10 +7,12 @@ export default {
'brief.action.acknowledge': 'Acknowledge',
'brief.action.approve': 'Approve',
'brief.action.feedback': 'Feedback',
'brief.action.confirmDone': 'Confirm complete',
'brief.action.retry': 'Retry',
'brief.collapse': 'Show less',
'brief.commentPlaceholder': 'Share your feedback...',
'brief.commentSubmit': 'Submit feedback',
'brief.editResult': 'Edit',
'brief.expandAll': 'Show more',
'brief.feedbackSent': 'Feedback shared',
'brief.resolved': 'Marked as resolved',
@@ -7,6 +7,8 @@ import { ImageOffIcon } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AsyncTaskErrorType } from '@/types/asyncTask';
import { ActionButtons } from './ActionButtons';
import { styles } from './styles';
import { type ErrorStateProps } from './types';
@@ -55,7 +57,10 @@ export const ErrorState = memo<ErrorStateProps>(
// Fallback to original error message
return errorBody || error.name || 'Unknown error';
}, [generation.task.error, generationBatch.provider, tError]);
}, [generation.task.error, tError]);
const isProviderContentModerationError =
generation.task.error?.name === AsyncTaskErrorType.ProviderContentModeration;
return (
<Block
@@ -74,9 +79,11 @@ export const ErrorState = memo<ErrorStateProps>(
<Center gap={8}>
<Icon color={cssVar.colorTextDescription} icon={ImageOffIcon} size={24} />
<Text strong align={'center'} type={'secondary'}>
{t('generation.status.failed')}
{isProviderContentModerationError
? tError('response.ProviderContentModeration')
: t('generation.status.failed')}
</Text>
{generation.task.error && (
{generation.task.error && !isProviderContentModerationError && (
<Text
code
ellipsis={{ rows: 2 }}
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
import { ActionButtons } from '@/routes/(main)/(create)/image/features/GenerationFeed/GenerationItem/ActionButtons';
import { styles } from '@/routes/(main)/(create)/image/features/GenerationFeed/GenerationItem/styles';
import { AsyncTaskErrorType } from '@/types/asyncTask';
import type { Generation } from '@/types/generation';
interface VideoErrorItemProps {
@@ -54,6 +55,9 @@ const VideoErrorItem = memo<VideoErrorItemProps>(
return errorBody || error.name || 'Unknown error';
}, [generation.task.error, tError]);
const isProviderContentModerationError =
generation.task.error?.name === AsyncTaskErrorType.ProviderContentModeration;
return (
<Block
align={'center'}
@@ -72,9 +76,11 @@ const VideoErrorItem = memo<VideoErrorItemProps>(
<Center gap={8}>
<Icon color={cssVar.colorTextDescription} icon={VideoOffIcon} size={24} />
<Text strong type={'secondary'}>
{t('generation.status.failed')}
{isProviderContentModerationError
? tError('response.ProviderContentModeration')
: t('generation.status.failed')}
</Text>
{generation.task.error && (
{generation.task.error && !isProviderContentModerationError && (
<Text
code
ellipsis={{ rows: 2 }}
@@ -1,6 +1,6 @@
'use client';
import { Flexbox, Form, FormGroup, FormItem, Tag } from '@lobehub/ui';
import { Flexbox, Form, FormGroup, FormItem, Tag, Text } from '@lobehub/ui';
import {
Button,
Form as AntdForm,
@@ -123,10 +123,26 @@ const SchemaField = memo<SchemaFieldProps>(({ field, parentKey, divider }) => {
}
case 'string': {
if (field.enum) {
const hasDescriptions = field.enumDescriptions?.some(Boolean);
children = (
<Select
placeholder={field.placeholder}
optionRender={
hasDescriptions
? (item) => (
<Flexbox horizontal align="center" gap={12} justify="space-between">
<span>{item.label}</span>
{item.data.description ? (
<Text fontSize={12} type="secondary">
{item.data.description}
</Text>
) : null}
</Flexbox>
)
: undefined
}
options={field.enum.map((value, i) => ({
description: field.enumDescriptions?.[i] ? t(field.enumDescriptions[i]) : undefined,
label: field.enumLabels?.[i] ? t(field.enumLabels[i]) : value,
value,
}))}
@@ -8,10 +8,12 @@ import AgentHome from '@/features/AgentHome';
import ChatMiniMap from '@/features/ChatMiniMap';
import { ChatList, ConversationProvider, TodoProgress } from '@/features/Conversation';
import ZenModeToast from '@/features/ZenModeToast';
import { useGatewayReconnect } from '@/hooks/useGatewayReconnect';
import { useOperationState } from '@/hooks/useOperationState';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import HeterogeneousChatInput from './HeterogeneousChatInput';
@@ -20,7 +22,6 @@ import MessageFromUrl from './MainChatInput/MessageFromUrl';
import ThreadHydration from './ThreadHydration';
import { useActionsBarConfig } from './useActionsBarConfig';
import { useAgentContext } from './useAgentContext';
import { useGatewayReconnect } from './useGatewayReconnect';
const log = debug('lobe-render:agent:ConversationArea');
@@ -55,7 +56,12 @@ const Conversation = memo(() => {
const isHeterogeneousAgent = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
// Auto-reconnect to running Gateway operation on topic load
useGatewayReconnect(context.topicId);
const runningOperation = useChatStore((s) =>
context.topicId
? topicSelectors.getTopicById(context.topicId)(s)?.metadata?.runningOperation
: undefined,
);
useGatewayReconnect(context.topicId, runningOperation);
return (
<ConversationProvider
@@ -1,45 +0,0 @@
import useSWR from 'swr';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
/**
* Hook that detects a running Gateway operation on the current topic
* and automatically reconnects the WebSocket after page reload.
*
* Uses SWR to manage the reconnect lifecycle — it only fires when
* runningOperation exists and deduplicates by operationId.
*/
export const useGatewayReconnect = (topicId?: string | null) => {
const isGatewayModeEnabled = useChatStore((s) => s.isGatewayModeEnabled);
// Subscribe to topic's runningOperation — re-evaluates when topic data arrives from SWR
const runningOperation = useChatStore((s) =>
topicId ? topicSelectors.getTopicById(topicId)(s)?.metadata?.runningOperation : undefined,
);
// SWR key is the operationId — null key means no fetch
// This naturally deduplicates: same operationId = same key = no re-fetch
useSWR(
runningOperation && isGatewayModeEnabled()
? ['reconnectGateway', runningOperation.operationId]
: null,
async () => {
if (!runningOperation || !topicId) return;
await useChatStore.getState().reconnectToGatewayOperation({
assistantMessageId: runningOperation.assistantMessageId,
operationId: runningOperation.operationId,
scope: runningOperation.scope,
threadId: runningOperation.threadId,
topicId,
});
},
{
// Don't revalidate on focus/reconnect — one attempt is enough
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
},
);
};
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { getContentPolicyErrorMessage } from './contentPolicyError';
describe('getContentPolicyErrorMessage', () => {
it('should return a generic message for known content policy codes', () => {
expect(getContentPolicyErrorMessage({ code: 'content_policy_violation' })).toBe(
'Content policy check failed. Revise your prompt and try again.',
);
expect(getContentPolicyErrorMessage({ error: { code: 'moderation_blocked' } })).toBe(
'Content policy check failed. Revise your prompt and try again.',
);
expect(getContentPolicyErrorMessage({ code: 'InputTextSensitiveContentDetected' })).toBe(
'Content policy check failed. Revise your prompt and try again.',
);
});
it('should return a generic message for known content policy text', () => {
expect(getContentPolicyErrorMessage({ message: 'Blocked by content policy.' })).toBe(
'Content policy check failed. Revise your prompt and try again.',
);
expect(
getContentPolicyErrorMessage({
error: { message: 'Your request was rejected by the safety system.' },
}),
).toBe('Content policy check failed. Revise your prompt and try again.');
expect(getContentPolicyErrorMessage({ message: 'Input contains sensitive information.' })).toBe(
'Content policy check failed. Revise your prompt and try again.',
);
});
it('should return undefined for unrelated provider errors', () => {
expect(getContentPolicyErrorMessage({ code: 'rate_limit_exceeded' })).toBeUndefined();
expect(getContentPolicyErrorMessage({ message: 'Network timeout' })).toBeUndefined();
});
});
@@ -0,0 +1,21 @@
const CONTENT_POLICY_ERROR_MESSAGE =
'Content policy check failed. Revise your prompt and try again.';
const getErrorCode = (error: any) => error?.code || error?.error?.code;
const getErrorMessage = (error: any) => error?.message || error?.error?.message || '';
export const getContentPolicyErrorMessage = (error: any) => {
const errorCode = getErrorCode(error);
const errorMessage = getErrorMessage(error).toLowerCase();
if (
errorCode === 'InputTextSensitiveContentDetected' ||
errorCode === 'content_policy_violation' ||
errorCode === 'moderation_blocked' ||
errorMessage.includes('content policy') ||
errorMessage.includes('safety system') ||
errorMessage.includes('sensitive information')
) {
return CONTENT_POLICY_ERROR_MESSAGE;
}
};
+21 -12
View File
@@ -16,6 +16,7 @@ import debug from 'debug';
import { type RuntimeImageGenParams } from 'model-bank';
import { z } from 'zod';
import { getProviderContentPolicyErrorMessage } from '@/business/server/getProviderContentPolicyErrorMessage';
import { chargeAfterGenerate } from '@/business/server/image-generation/chargeAfterGenerate';
import { notifyImageCompleted } from '@/business/server/image-generation/notifyImageCompleted';
import { createImageBusinessMiddleware } from '@/business/server/trpc-middlewares/async';
@@ -28,6 +29,8 @@ import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { GenerationService } from '@/server/services/generation';
import { sanitizeFileName } from '@/utils/sanitizeFileName';
import { getContentPolicyErrorMessage } from './contentPolicyError';
const log = debug('lobe-image:async');
const IMAGE_URL_PREVIEW_LENGTH = 100;
@@ -84,6 +87,7 @@ const categorizeError = (
error: any,
isAborted: boolean,
isEditingImage: boolean,
providerContentPolicyMessage?: string,
): { errorMessage: string; errorType: AsyncTaskErrorType } => {
log('🔥🔥🔥 [ASYNC] categorizeError called:', {
errorMessage: error?.message,
@@ -165,19 +169,18 @@ const categorizeError = (
};
}
// Content moderation / policy violation — return a clean, generic message
const errorMsg: string = error.message || error.error?.message || '';
const errorCode: string = error.code || error.error?.code || '';
if (
errorCode === 'InputTextSensitiveContentDetected' ||
errorCode === 'content_policy_violation' ||
errorMsg.toLowerCase().includes('content policy') ||
errorMsg.toLowerCase().includes('sensitive information')
) {
if (providerContentPolicyMessage) {
return {
errorMessage:
'The request content may violate content policy. Please modify your prompt and try again.',
errorType: AsyncTaskErrorType.ServerError,
errorMessage: providerContentPolicyMessage,
errorType: AsyncTaskErrorType.ProviderContentModeration,
};
}
const fallbackContentPolicyMessage = getContentPolicyErrorMessage(error);
if (fallbackContentPolicyMessage) {
return {
errorMessage: fallbackContentPolicyMessage,
errorType: AsyncTaskErrorType.ProviderContentModeration,
};
}
@@ -439,10 +442,16 @@ export const imageRouter = router({
});
// Improved error categorization logic
const providerContentPolicyMessage = await getProviderContentPolicyErrorMessage({
error,
provider,
userId: ctx.userId,
});
const { errorType, errorMessage } = categorizeError(
error,
abortController.signal.aborted,
isEditingImage,
providerContentPolicyMessage,
);
await ctx.asyncTaskModel.update(taskId, {
+13 -3
View File
@@ -8,6 +8,7 @@ import { AsyncTaskError, AsyncTaskErrorType, AsyncTaskStatus } from '@lobechat/t
import debug from 'debug';
import { z } from 'zod';
import { getProviderContentPolicyErrorMessage } from '@/business/server/getProviderContentPolicyErrorMessage';
import { chargeAfterGenerate } from '@/business/server/video-generation/chargeAfterGenerate';
import { AsyncTaskModel } from '@/database/models/asyncTask';
import { GenerationModel } from '@/database/models/generation';
@@ -263,11 +264,20 @@ export const videoRouter = router({
inferenceId,
});
const providerContentPolicyMessage = await getProviderContentPolicyErrorMessage({
error,
provider,
userId: ctx.userId,
});
await ctx.asyncTaskModel.update(asyncTaskId, {
error: new AsyncTaskError(
AsyncTaskErrorType.ServerError,
'Background polling failed: ' +
(error instanceof Error ? error.message : 'Unknown error'),
providerContentPolicyMessage
? AsyncTaskErrorType.ProviderContentModeration
: AsyncTaskErrorType.ServerError,
providerContentPolicyMessage ??
'Background polling failed: ' +
(error instanceof Error ? error.message : 'Unknown error'),
),
status: AsyncTaskStatus.Error,
});
+29 -5
View File
@@ -7,7 +7,11 @@ import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { getBotMessageRouter } from '@/server/services/bot/BotMessageRouter';
import { mergeWithDefaults, platformRegistry } from '@/server/services/bot/platforms';
import {
mergeWithDefaults,
platformRegistry,
validateAccessSettings,
} from '@/server/services/bot/platforms';
import { GatewayService } from '@/server/services/gateway';
import { getBotRuntimeStatus } from '@/server/services/gateway/runtimeStatus';
@@ -40,6 +44,24 @@ function mergeSettingsForPersist(
return mergeWithDefaults(definition.schema, settings);
}
/**
* Run cross-platform access-policy invariants on settings before they hit
* the DB. Throws `TRPCError(BAD_REQUEST)` with field-prefixed messages so
* the client form can surface the failing field. Skipped when `settings`
* is undefined (update payload didn't touch them).
*/
function assertAccessSettings(settings: Record<string, unknown> | undefined): void {
if (settings === undefined) return;
const result = validateAccessSettings(settings);
if (result.valid) return;
throw new TRPCError({
code: 'BAD_REQUEST',
message:
result.errors?.map((e) => `${e.field}: ${e.message}`).join('; ') ||
'Invalid access policy settings',
});
}
export const agentBotProviderRouter = router({
listPlatforms: authedProcedure.query(() => {
return platformRegistry.listSerializedPlatforms();
@@ -57,11 +79,12 @@ export const agentBotProviderRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
const payload = {
...input,
settings: mergeSettingsForPersist(input.platform, input.settings),
};
assertAccessSettings(payload.settings);
try {
const payload = {
...input,
settings: mergeSettingsForPersist(input.platform, input.settings),
};
return await ctx.agentBotProviderModel.create(payload);
} catch (e: any) {
if (e?.cause?.code === '23505') {
@@ -236,6 +259,7 @@ export const agentBotProviderRouter = router({
value.platform ?? existing?.platform,
value.settings,
);
assertAccessSettings(value.settings);
}
const result = await ctx.agentBotProviderModel.update(id, value);
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { AsyncTaskErrorType } from '@/types/asyncTask';
import { createVideoTaskSubmitError } from './error';
describe('createVideoTaskSubmitError', () => {
it('should use task trigger error for generic submit failures', () => {
const error = createVideoTaskSubmitError(new Error('API timeout'));
expect(error.name).toBe(AsyncTaskErrorType.TaskTriggerError);
expect(error.body.detail).toBe('Failed to submit video task: API timeout');
});
it('should use provider moderation type for content policy failures', () => {
const error = createVideoTaskSubmitError(
new Error('rejected by safety system'),
'Content policy check failed. Revise your prompt and try again.',
);
expect(error.name).toBe(AsyncTaskErrorType.ProviderContentModeration);
expect(error.body.detail).toBe(
'Content policy check failed. Revise your prompt and try again.',
);
});
});
+10
View File
@@ -0,0 +1,10 @@
import { AsyncTaskError, AsyncTaskErrorType } from '@/types/asyncTask';
export const createVideoTaskSubmitError = (error: unknown, providerContentPolicyMessage?: string) =>
new AsyncTaskError(
providerContentPolicyMessage
? AsyncTaskErrorType.ProviderContentModeration
: AsyncTaskErrorType.TaskTriggerError,
providerContentPolicyMessage ??
'Failed to submit video task: ' + (error instanceof Error ? error.message : 'Unknown error'),
);
+10 -10
View File
@@ -10,6 +10,7 @@ import { and, eq } from 'drizzle-orm';
import { after } from 'next/server';
import { z } from 'zod';
import { getProviderContentPolicyErrorMessage } from '@/business/server/getProviderContentPolicyErrorMessage';
import { chargeAfterGenerate } from '@/business/server/video-generation/chargeAfterGenerate';
import { chargeBeforeGenerate } from '@/business/server/video-generation/chargeBeforeGenerate';
import { getVideoFreeQuota } from '@/business/server/video-generation/getVideoFreeQuota';
@@ -28,12 +29,9 @@ import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { FileService } from '@/server/services/file';
import { processBackgroundVideoPolling } from '@/server/services/generation/videoBackgroundPolling';
import {
AsyncTaskError,
AsyncTaskErrorType,
AsyncTaskStatus,
AsyncTaskType,
} from '@/types/asyncTask';
import { AsyncTaskStatus, AsyncTaskType } from '@/types/asyncTask';
import { createVideoTaskSubmitError } from './error';
const log = debug('lobe-video:lambda');
@@ -283,11 +281,13 @@ export const videoRouter = router({
} catch (e) {
console.error('Failed to submit video generation task:', e);
const providerContentPolicyMessage = await getProviderContentPolicyErrorMessage({
error: e,
provider,
userId,
});
await asyncTaskModel.update(asyncTaskId, {
error: new AsyncTaskError(
AsyncTaskErrorType.TaskTriggerError,
'Failed to submit video task: ' + (e instanceof Error ? e.message : 'Unknown error'),
),
error: createVideoTaskSubmitError(e, providerContentPolicyMessage),
status: AsyncTaskStatus.Error,
});
+340 -28
View File
@@ -13,16 +13,24 @@ import { emitAgentSignalSourceEvent } from '@/server/services/agentSignal';
import { AiAgentService } from '@/server/services/aiAgent';
import { AgentBridgeService } from './AgentBridgeService';
import {
createOrGetPairingRequest,
deletePairingRequest,
peekPairingRequest,
releasePairingClaim,
} from './dmPairingStore';
import {
type BotPlatformRuntimeContext,
type BotReplyLocale,
buildRuntimeKey,
type DmDecision,
type DmSettings,
extractDmSettings,
extractGroupSettings,
extractUserAllowlist,
getBotReplyLocale,
type GroupSettings,
normalizeAllowFromEntries,
normalizeBotReplyLocale,
type PlatformClient,
type PlatformDefinition,
@@ -34,7 +42,9 @@ import {
type UserAllowlist,
} from './platforms';
import {
renderApproveSuccess,
renderCommandReply,
renderDmPairing,
renderDmRejected,
renderError,
renderGroupRejected,
@@ -89,6 +99,14 @@ interface RegisteredBot {
interface CommandContext {
/** Text after the command name (e.g. "/new foo" → "foo"). */
args: string;
/** Platform user ID of the invoking user. Optional because the source
* event may not carry one (best-effort), but commands that gate on
* identity (e.g. `/approve` requires the owner) treat its absence as
* failure. */
authorUserId?: string;
/** Display name of the invoking user. Optional because some platforms
* surface only the ID, not a friendly label. */
authorUserName?: string;
post: (text: string) => Promise<any>;
/** Locale to use for any system-generated reply text. Plumbed in by the
* caller — text-based commands derive it per-message via the platform's
@@ -105,6 +123,22 @@ interface BotCommand {
description: string;
handler: (ctx: CommandContext) => Promise<void>;
name: string;
/**
* Native slash-command argument schema for platforms that require
* arguments to be declared up-front (Discord, Slack). Without this,
* Discord registers the command as zero-arg — clicking it from the
* slash menu fires the handler with `ctx.args` empty even when the
* user expected to pass a value. Adapters flatten option values back
* into `event.text`, so the handler still reads `ctx.args` as before.
*
* Text-based platforms (Telegram / Feishu) ignore this and parse args
* from the trailing message text via the dispatch regex.
*/
options?: Array<{
description: string;
name: string;
required?: boolean;
}>;
}
/**
@@ -269,7 +303,27 @@ export class BotMessageRouter {
const client = entry.clientFactory.createClient(providerConfig, runtimeContext);
const adapters = client.createAdapter();
const commands = this.buildCommands(serverDB, { agentId, platform, userId });
// dmSettings + operatorUserId are needed by `/approve` (to enforce the
// owner-only gate and to know whether pairing is even enabled), and by
// the DM pairing branch in registerHandlers. Extract once, share with
// both — registerHandlers re-derives from `settings` to keep its own
// closure-internal contract self-contained.
const dmSettings: DmSettings = extractDmSettings(settings);
const operatorUserId =
typeof settings.userId === 'string'
? (settings.userId as string).trim() || undefined
: undefined;
const commands = this.buildCommands(serverDB, {
agentId,
applicationId,
client,
dmSettings,
operatorUserId,
platform,
providerId: provider.id,
userId,
});
// Default to 'queue' for legacy providers that don't have `concurrency`
// in their saved settings. Historically this defaulted to 'debounce', but
@@ -297,7 +351,11 @@ export class BotMessageRouter {
// Register platform-specific bot commands (e.g., Telegram setMyCommands menu)
if (client.registerBotCommands) {
const commandList = commands.map((c) => ({ command: c.name, description: c.description }));
const commandList = commands.map((c) => ({
command: c.name,
description: c.description,
options: c.options,
}));
client.registerBotCommands(commandList).catch((error) => {
log('registerBotCommands failed for %s: %O', key, error);
});
@@ -423,6 +481,17 @@ export class BotMessageRouter {
const dmSettings: DmSettings = extractDmSettings(info.settings);
const groupSettings: GroupSettings = extractGroupSettings(info.settings);
const userAllowlist: UserAllowlist = extractUserAllowlist(info.settings);
/**
* The provider's owner platform user ID. Only consulted under the
* `pairing` policy, where the gate gives the owner a free pass so they
* can DM their own bot before any approvals exist (otherwise the
* shouldHandleDm gate would tell the owner to ask themselves to
* approve via `/approve`).
*/
const operatorUserId =
typeof info.settings?.userId === 'string'
? (info.settings.userId as string).trim() || undefined
: undefined;
const fallbackReplyLocale: BotReplyLocale = getBotReplyLocale(platform);
/**
@@ -451,18 +520,19 @@ export class BotMessageRouter {
/**
* Gate inbound events on DM policy. Non-DM threads pass through — their
* group-policy / @mention rules apply instead. DM threads are blocked
* when disabled, and filtered against the global `allowFrom` user list
* when set to `allowlist`.
* group-policy / @mention rules apply instead. The `'pair'` decision
* is distinct from `'reject'` because the router branches on it (issue
* a pairing code) — see `passGatesOrNotify` below.
*/
const passesDmPolicy = (
thread: { isDM?: boolean },
message: { author?: { userId?: string } },
): boolean =>
): DmDecision =>
shouldHandleDm({
authorUserId: message.author?.userId,
dmSettings,
isDM: thread.isDM === true,
operatorUserId,
userAllowlist,
});
@@ -535,9 +605,10 @@ export class BotMessageRouter {
thread: { post: (text: string) => Promise<unknown> },
replyLocale: BotReplyLocale,
): Promise<void> => {
// 'open' should never reach here, but guard anyway so we never post the
// 'open' and 'pairing' should never reach here ('pairing' has its own
// flow via triggerDmPairing), but guard anyway so we never post the
// wrong copy if shouldHandleDm grows another false branch.
if (dmSettings.policy === 'open') return;
if (dmSettings.policy !== 'allowlist' && dmSettings.policy !== 'disabled') return;
try {
await thread.post(renderDmRejected(dmSettings.policy, replyLocale));
} catch (error) {
@@ -562,9 +633,62 @@ export class BotMessageRouter {
}
};
/**
* Pairing branch of the DM gate: stranger DMed a bot in `pairing` mode.
* Issue (or recycle, when the same applicant DMed within the TTL) a
* one-time code, persist a pending entry to Redis so `/approve <code>`
* can later append the applicant to `allowFrom`, and post the code in
* the applicant's DM thread.
*
* Best-effort: if Redis is unwired (`'redis-unavailable'`) or the
* per-bot pending cap is hit (`'capacity-exceeded'`), surface a useful
* status string to the applicant rather than silently dropping them —
* silent drops look broken and operators waste time debugging.
*/
const triggerDmPairing = async (
thread: { id: string; post: (text: string) => Promise<unknown> },
author: { userId?: string; userName?: string },
replyLocale: BotReplyLocale,
): Promise<void> => {
if (!author.userId) {
log(
'triggerDmPairing: missing author userId, cannot pair (agent=%s, platform=%s)',
agentId,
platform,
);
return;
}
const result = await createOrGetPairingRequest({
applicant: {
applicantUserId: author.userId,
applicantUserName: author.userName,
replyLocale,
threadId: thread.id,
},
applicationId,
platform,
redis: getAgentRuntimeRedisClient(),
});
let text: string;
if (result.status === 'created' || result.status === 'reused') {
text = renderDmPairing('code', replyLocale, { code: result.code });
} else if (result.status === 'capacity-exceeded') {
text = renderDmPairing('capacity-exceeded', replyLocale);
} else {
text = renderDmPairing('unavailable', replyLocale);
}
try {
await thread.post(text);
} catch (error) {
log('triggerDmPairing: failed to post pairing notice: %O', error);
}
};
/** Try dispatching a text command. Returns true if handled.
* Strips platform mention artifacts (e.g. Slack's `<@U123>`) before
* checking so that "@bot /new" correctly resolves to the /new command. */
* checking so that "@bot /new" correctly resolves to the /new command.
* Forwards the inbound `message.author` so commands that gate on
* identity (e.g. `/approve` requires the bot's owner) can verify. */
const tryDispatch = async (
thread: {
id: string;
@@ -572,6 +696,7 @@ export class BotMessageRouter {
setState: (s: Record<string, any>, o?: { replace?: boolean }) => Promise<any>;
},
text: string | undefined,
author: { userId?: string; userName?: string } | undefined,
replyLocale: BotReplyLocale,
): Promise<boolean> => {
const sanitized = client.sanitizeUserInput?.(text ?? '') ?? text;
@@ -579,6 +704,8 @@ export class BotMessageRouter {
if (!result) return false;
await result.command.handler({
args: result.args,
authorUserId: author?.userId,
authorUserName: author?.userName,
post: (t) => thread.post(t),
replyLocale,
setState: (s, o) => thread.setState(s, o),
@@ -612,7 +739,34 @@ export class BotMessageRouter {
replyLocale: BotReplyLocale,
caller: string,
): Promise<boolean> => {
if (!passesGlobalAllowlist({ author })) {
// Owner override. The bot's operator (`settings.userId`) sets the
// policies for *other* users — locking themselves out of their own
// bot is a footgun. Without this branch:
// - `/approve` in any group channel that isn't in `groupAllowFrom`
// gets rejected by the group gate, breaking the approval flow
// from a not-yet-allowed channel (Discord native slash commands
// in particular sometimes report `event.channel.isDM=false` for
// DM invocations, putting the gate on the group branch).
// - DMing a `disabled` bot for a self-test gets blocked.
// The override is unconditional on author identity, so non-command
// messages from the operator also pass — that matches the existing
// implicit-merge of `settings.userId` into `extractUserAllowlist`,
// which already treats the operator as always-allowed.
if (operatorUserId && author.userId === operatorUserId) {
return true;
}
// Pairing redefines what `allowFrom` means: it's the *post-approval*
// list (managed by `/approve`), not a hard identity gate. A stranger
// DMing a pairing bot must reach the DM gate's `'pair'` branch so we
// can issue them a code — but the global allowFrom gate would
// otherwise short-circuit them out at step 1 (since they're not yet
// approved). Skip the global gate for DM threads under pairing so
// the DM gate alone governs user filtering. Other policies are
// unaffected: `open` keeps allowFrom as an extra lockdown layer,
// `allowlist` resolves to the same list either way, `disabled`
// rejects regardless.
const isPairingDm = thread.isDM === true && dmSettings.policy === 'pairing';
if (!isPairingDm && !passesGlobalAllowlist({ author })) {
log(
'%s: sender blocked by allowFrom, agent=%s, platform=%s, thread=%s, author=%s',
caller,
@@ -636,20 +790,24 @@ export class BotMessageRouter {
await notifyGroupRejected(thread, replyLocale);
return false;
}
if (!passesDmPolicy(thread, { author })) {
log(
'%s: DM blocked by policy, agent=%s, platform=%s, thread=%s, author=%s, policy=%s',
caller,
agentId,
platform,
thread.id,
author.userName ?? author.userId,
dmSettings.policy,
);
const dmDecision = passesDmPolicy(thread, { author });
if (dmDecision === 'allow') return true;
log(
'%s: DM gate=%s, agent=%s, platform=%s, thread=%s, author=%s, policy=%s',
caller,
dmDecision,
agentId,
platform,
thread.id,
author.userName ?? author.userId,
dmSettings.policy,
);
if (dmDecision === 'pair') {
await triggerDmPairing(thread, author, replyLocale);
} else {
await notifyDmRejected(thread, replyLocale);
return false;
}
return true;
return false;
};
bot.onNewMention(async (thread, message, context?: MessageContext) => {
@@ -661,7 +819,7 @@ export class BotMessageRouter {
return;
}
if (await tryDispatch(thread, message.text, replyLocale)) return;
if (await tryDispatch(thread, message.text, message.author, replyLocale)) return;
log(
'onNewMention raw: agent=%s, platform=%s, msgId=%s, textLen=%d, attachments=%o, skipped=%d',
@@ -769,7 +927,7 @@ export class BotMessageRouter {
return;
}
if (await tryDispatch(thread, message.text, replyLocale)) return;
if (await tryDispatch(thread, message.text, message.author, replyLocale)) return;
log(
'onSubscribedMessage raw: agent=%s, platform=%s, msgId=%s, textLen=%d, attachments=%o, skipped=%d',
@@ -970,14 +1128,38 @@ export class BotMessageRouter {
* Build the list of bot commands. Each entry defines a name, description,
* and handler. To add a new command, just append to this array.
*
* Handlers close over serverDB / userId / agentId / platform so they can
* access services without needing those passed through CommandContext.
* Handlers close over `info` so they can reach services and the bot's
* own configuration (DM policy, owner identity, applicationId) without
* needing every command entry threaded through CommandContext.
*/
private buildCommands(
serverDB: LobeChatDatabase,
info: { agentId: string; platform: string; userId: string },
info: {
agentId: string;
applicationId: string;
/** PlatformClient used to message the applicant after a successful
* `/approve`; the owner runs the command in their own thread, but
* the applicant's notification has to land in the applicant's DM. */
client: PlatformClient;
dmSettings: DmSettings;
operatorUserId?: string;
platform: string;
/** DB row id of the agent_bot_providers row for this bot — used by
* `/approve` to append a fresh applicant to `settings.allowFrom`. */
providerId: string;
userId: string;
},
): BotCommand[] {
const { agentId, platform, userId } = info;
const {
agentId,
applicationId,
client,
dmSettings,
operatorUserId,
platform,
providerId,
userId,
} = info;
return [
{
@@ -1023,6 +1205,132 @@ export class BotMessageRouter {
},
name: 'stop',
},
{
description: 'Approve a pairing request: /approve <code>',
options: [
{
description: 'The 8-character pairing code shown to the applicant',
name: 'code',
required: true,
},
],
handler: async (ctx) => {
log(
'command /approve: agent=%s, platform=%s, author=%s',
agentId,
platform,
ctx.authorUserName ?? ctx.authorUserId,
);
if (dmSettings.policy !== 'pairing') {
await ctx.post(renderCommandReply('cmdApproveDisabled', ctx.replyLocale));
return;
}
// Owner check: the gate in passGatesOrNotify already lets the
// operator through (operator bypass for pairing), but a
// pre-approved third party would also pass that gate. The
// command itself enforces owner-only at the action layer.
if (!operatorUserId || !ctx.authorUserId || ctx.authorUserId !== operatorUserId) {
await ctx.post(renderCommandReply('cmdApproveNotOwner', ctx.replyLocale));
return;
}
const code = ctx.args.toUpperCase().trim();
if (!code) {
await ctx.post(renderCommandReply('cmdApproveUsage', ctx.replyLocale));
return;
}
const redis = getAgentRuntimeRedisClient();
const entry = await peekPairingRequest({
applicationId,
code,
platform,
redis,
});
if (!entry) {
await ctx.post(renderCommandReply('cmdApproveUnknownCode', ctx.replyLocale));
return;
}
// Persist the applicant to allowFrom BEFORE deleting the Redis
// entry. If persistence fails (transient DB error, missing
// provider row), the code stays valid so the owner can retry
// — otherwise the applicant is locked out and we'd need a
// fresh code from them. Read-modify-write so we preserve every
// other settings field; `model.update` would otherwise
// lodash-merge over only the fields we pass.
const approvedLabel = entry.applicantUserName ?? entry.applicantUserId;
let persisted = false;
try {
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
const model = new AgentBotProviderModel(serverDB, userId, gateKeeper);
const provider = await model.findById(providerId);
if (provider) {
const settings = (provider.settings ?? {}) as Record<string, unknown>;
const entries = normalizeAllowFromEntries(settings.allowFrom);
if (!entries.some((e) => e.id === entry.applicantUserId)) {
entries.push(
entry.applicantUserName
? { id: entry.applicantUserId, name: entry.applicantUserName }
: { id: entry.applicantUserId },
);
await model.update(providerId, {
settings: { ...settings, allowFrom: entries },
});
// The router caches RegisteredBot by key; drop it so the
// next inbound DM rebuilds with fresh allowFrom rather
// than re-pairing the user we just approved.
await this.invalidateBot(platform, applicationId);
}
// Already on the list counts as a successful approval —
// the durable state matches what the owner asked for.
persisted = true;
} else {
log(
'command /approve: provider %s not found while approving code=%s',
providerId,
code,
);
}
} catch (error) {
log('command /approve: failed to persist allowFrom for code=%s: %O', code, error);
}
if (!persisted) {
// Leave the Redis entry intact: the owner can retry the same
// /approve once the underlying issue clears, without forcing
// the applicant to mint a new code. Release the peek claim
// so the retry isn't blocked behind our own lock.
await releasePairingClaim({ applicationId, code, platform, redis });
await ctx.post(renderCommandReply('cmdApproveFailed', ctx.replyLocale));
return;
}
await deletePairingRequest({
applicationId,
applicantUserId: entry.applicantUserId,
code,
platform,
redis,
});
// Notify the applicant in their own DM thread, in the locale
// they originally DMed in (owner's locale may differ).
try {
const messenger = client.getMessenger(entry.threadId);
await messenger.createMessage(
renderCommandReply('dmPairingApplicantApproved', entry.replyLocale),
);
} catch (error) {
log('command /approve: failed to notify applicant for code=%s: %O', code, error);
}
await ctx.post(renderApproveSuccess(approvedLabel, ctx.replyLocale));
},
name: 'approve',
},
];
}
@@ -1093,6 +1401,8 @@ export class BotMessageRouter {
}
await cmd.handler({
args: event.text,
authorUserId: authorLike.userId,
authorUserName: authorLike.userName,
post: (text) => event.channel.post(text),
// Native slash-command events don't carry a Chat SDK Message, so
// there's no per-sender locale field to read; use the channel
@@ -1126,6 +1436,8 @@ export class BotMessageRouter {
}
await result.command.handler({
args: result.args,
authorUserId: message.author?.userId,
authorUserName: message.author?.userName,
post: (text) => thread.post(text),
replyLocale,
setState: (state, opts) => thread.setState(state, opts),
@@ -7,15 +7,38 @@ import { BotMessageRouter } from '../BotMessageRouter';
const mockFindEnabledByPlatform = vi.hoisted(() => vi.fn());
const mockInitWithEnvKey = vi.hoisted(() => vi.fn());
const mockGetServerDB = vi.hoisted(() => vi.fn());
const mockProviderFindById = vi.hoisted(() => vi.fn());
const mockProviderUpdate = vi.hoisted(() => vi.fn());
const mockPeekPairingRequest = vi.hoisted(() => vi.fn());
const mockDeletePairingRequest = vi.hoisted(() => vi.fn());
const mockReleasePairingClaim = vi.hoisted(() => vi.fn());
const mockCreateOrGetPairingRequest = vi.hoisted(() => vi.fn());
const mockGetAgentRuntimeRedisClient = vi.hoisted(() => vi.fn().mockReturnValue(null));
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: mockGetServerDB,
}));
vi.mock('@/database/models/agentBotProvider', () => ({
AgentBotProviderModel: {
findEnabledByPlatform: mockFindEnabledByPlatform,
},
vi.mock('@/database/models/agentBotProvider', () => {
// Constructor returns the same set of instance-method mocks so tests
// can assert / configure without grabbing a per-instance reference.
const ctor = vi.fn().mockImplementation(() => ({
findById: mockProviderFindById,
update: mockProviderUpdate,
}));
// Preserve the static method other tests rely on (load path).
(
ctor as unknown as { findEnabledByPlatform: typeof mockFindEnabledByPlatform }
).findEnabledByPlatform = mockFindEnabledByPlatform;
return { AgentBotProviderModel: ctor };
});
vi.mock('../dmPairingStore', () => ({
consumePairingRequest: vi.fn(),
createOrGetPairingRequest: mockCreateOrGetPairingRequest,
deletePairingRequest: mockDeletePairingRequest,
peekPairingRequest: mockPeekPairingRequest,
releasePairingClaim: mockReleasePairingClaim,
}));
vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
@@ -25,7 +48,7 @@ vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
}));
vi.mock('@/server/modules/AgentRuntime/redis', () => ({
getAgentRuntimeRedisClient: vi.fn().mockReturnValue(null),
getAgentRuntimeRedisClient: mockGetAgentRuntimeRedisClient,
}));
vi.mock('@chat-adapter/state-ioredis', () => ({
@@ -190,11 +213,45 @@ vi.mock('../platforms', () => ({
extractDmSettings: (settings: Record<string, unknown> | null | undefined) => {
const rawPolicy = settings?.dmPolicy as string | undefined;
const policy =
rawPolicy === 'allowlist' || rawPolicy === 'open' || rawPolicy === 'disabled'
rawPolicy === 'allowlist' ||
rawPolicy === 'open' ||
rawPolicy === 'disabled' ||
rawPolicy === 'pairing'
? rawPolicy
: 'open';
return { policy };
},
normalizeAllowFromEntries: (raw: unknown): Array<{ id: string; name?: string }> => {
if (typeof raw === 'string') {
return raw
.split(/[\s,]+/)
.map((id: string) => id.trim())
.filter(Boolean)
.map((id: string) => ({ id }));
}
if (Array.isArray(raw)) {
const out: Array<{ id: string; name?: string }> = [];
for (const entry of raw) {
if (typeof entry === 'string') {
const id = entry.trim();
if (id) out.push({ id });
continue;
}
if (entry && typeof entry === 'object' && 'id' in entry) {
const id = (entry as { id?: unknown }).id;
if (typeof id !== 'string' || !id.trim()) continue;
const name = (entry as { name?: unknown }).name;
out.push(
typeof name === 'string' && name.trim()
? { id: id.trim(), name: name.trim() }
: { id: id.trim() },
);
}
}
return out;
}
return [];
},
extractGroupSettings: (settings: Record<string, unknown> | null | undefined) => {
const allowFrom = parseAllowlistMock(settings?.groupAllowFrom);
const rawPolicy = settings?.groupPolicy as string | undefined;
@@ -226,16 +283,26 @@ vi.mock('../platforms', () => ({
},
shouldHandleDm: (params: {
authorUserId: string | undefined;
dmSettings: { policy: 'allowlist' | 'disabled' | 'open' };
dmSettings: { policy: 'allowlist' | 'disabled' | 'open' | 'pairing' };
isDM: boolean;
operatorUserId?: string;
userAllowlist: { ids: string[] };
}) => {
if (!params.isDM) return true;
if (params.dmSettings.policy === 'disabled') return false;
if (params.dmSettings.policy === 'open') return true;
if (!params.authorUserId) return false;
if (params.userAllowlist.ids.length === 0) return false;
return params.userAllowlist.ids.includes(params.authorUserId);
}): 'allow' | 'pair' | 'reject' => {
if (!params.isDM) return 'allow';
if (params.dmSettings.policy === 'disabled') return 'reject';
if (params.dmSettings.policy === 'open') return 'allow';
if (!params.authorUserId) return 'reject';
if (
params.dmSettings.policy === 'pairing' &&
params.operatorUserId &&
params.authorUserId === params.operatorUserId
) {
return 'allow';
}
const inList =
params.userAllowlist.ids.length > 0 && params.userAllowlist.ids.includes(params.authorUserId);
if (inList) return 'allow';
return params.dmSettings.policy === 'pairing' ? 'pair' : 'reject';
},
shouldHandleGroup: (params: {
candidateChannelIds: ReadonlyArray<string | undefined>;
@@ -276,6 +343,15 @@ describe('BotMessageRouter', () => {
mockFindEnabledByPlatform.mockResolvedValue([]);
mockHandleMention.mockResolvedValue(undefined);
mockHandleSubscribedMessage.mockResolvedValue(undefined);
// Reset pairing-store + provider-model mocks to safe defaults so a
// previous test's stub doesn't leak into the next one.
mockPeekPairingRequest.mockResolvedValue(null);
mockDeletePairingRequest.mockResolvedValue(undefined);
mockReleasePairingClaim.mockResolvedValue(undefined);
mockCreateOrGetPairingRequest.mockResolvedValue({ status: 'redis-unavailable' });
mockProviderFindById.mockResolvedValue(undefined);
mockProviderUpdate.mockResolvedValue(undefined);
mockGetAgentRuntimeRedisClient.mockReturnValue(null);
});
describe('getWebhookHandler', () => {
@@ -747,6 +823,128 @@ describe('BotMessageRouter', () => {
expect(thread.post).toHaveBeenCalledTimes(1);
expect(thread.post.mock.calls[0][0]).toContain("aren't authorized");
});
it('lets pairing-mode strangers reach the DM gate (does NOT short-circuit on allowFrom)', async () => {
// Regression: previously, the global `allowFrom` gate ran first and
// rejected anyone not on the list — including strangers DMing a
// pairing bot, who never reached the pairing flow. With pairing,
// `allowFrom` is the *post-approval* list (managed by `/approve`),
// so the global gate must skip on DM threads under pairing.
const handler = await loadDmCatchAllHandler({
// allowFrom only contains the operator — Lin is a stranger here.
allowFrom: [{ id: 'owner-id', name: 'me' }],
dmPolicy: 'pairing',
userId: 'owner-id',
});
if (!handler) throw new Error('expected catch-all to be registered');
const thread = {
id: 'telegram:chat-1',
isDM: true,
post: vi.fn().mockResolvedValue(undefined),
};
const message = {
author: { isBot: false, userId: 'lin-id', userName: 'Lin' },
text: 'Hi',
};
await handler(thread, message);
// No agent dispatch (gate didn't pass through to the agent)
expect(mockHandleMention).not.toHaveBeenCalled();
// Post was made — but it must NOT be the allowlist rejection text
// (which is what the bug rendered). With redis mocked to null in
// this suite the pairing flow falls back to the "unavailable"
// copy; the important thing is we left the global-allowFrom branch
// and entered the pairing branch.
expect(thread.post).toHaveBeenCalledTimes(1);
const text = thread.post.mock.calls[0][0] as string;
expect(text).not.toContain("aren't authorized");
expect(text).toContain('Pairing');
});
it('owner DMing a pairing bot bypasses the gate via operator-bypass', async () => {
// Even with allowFrom not yet populated with anyone but the owner,
// the owner themselves must be able to DM the bot to test it /
// approve other users.
const handler = await loadDmCatchAllHandler({
allowFrom: [{ id: 'owner-id' }],
dmPolicy: 'pairing',
userId: 'owner-id',
});
if (!handler) throw new Error('expected catch-all to be registered');
const thread = {
id: 'telegram:chat-1',
isDM: true,
post: vi.fn().mockResolvedValue(undefined),
};
const message = {
author: { isBot: false, userId: 'owner-id', userName: 'me' },
text: 'self test',
};
await handler(thread, message);
expect(mockHandleMention).toHaveBeenCalledTimes(1);
});
it('owner bypasses the gate from any channel context (slash-command DM/group safety net)', async () => {
// Discord's native slash-command events sometimes deliver DM
// invocations with `event.channel.isDM=false`, which would otherwise
// route the owner's `/approve` through the group gate and reject
// them when their channel isn't in `groupAllowFrom`. The operator
// override neutralises this for any inbound from the bot's owner.
const handler = await loadDmCatchAllHandler({
allowFrom: [{ id: 'owner-id' }],
dmPolicy: 'pairing',
groupAllowFrom: [{ id: 'allowed-channel' }],
groupPolicy: 'allowlist',
userId: 'owner-id',
});
if (!handler) throw new Error('expected catch-all to be registered');
// Mis-reported isDM=false on a DM-y thread — channel id is NOT in
// groupAllowFrom; without owner override the group gate would
// reject. (The catch-all itself returns early on isDM!==true, so
// we use isDM=true here; the assertion is that the DM path lets
// owner through under the strictest combination of policies.)
const thread = {
id: 'discord:dm-channel-1',
isDM: true,
post: vi.fn().mockResolvedValue(undefined),
};
const message = {
author: { isBot: false, userId: 'owner-id', userName: 'me' },
text: 'self test',
};
await handler(thread, message);
expect(mockHandleMention).toHaveBeenCalledTimes(1);
expect(thread.post).not.toHaveBeenCalled();
});
it('previously-approved users on a pairing bot pass straight through (no re-pairing)', async () => {
const handler = await loadDmCatchAllHandler({
allowFrom: [{ id: 'owner-id' }, { id: 'lin-id', name: 'Lin' }],
dmPolicy: 'pairing',
userId: 'owner-id',
});
if (!handler) throw new Error('expected catch-all to be registered');
const thread = {
id: 'telegram:chat-1',
isDM: true,
post: vi.fn().mockResolvedValue(undefined),
};
const message = {
author: { isBot: false, userId: 'lin-id', userName: 'Lin' },
text: 'Hello again',
};
await handler(thread, message);
expect(mockHandleMention).toHaveBeenCalledTimes(1);
// No pairing notice was posted — Lin is already approved.
expect(thread.post).not.toHaveBeenCalled();
});
});
describe('group policy', () => {
@@ -1495,4 +1693,160 @@ describe('BotMessageRouter', () => {
expect(thread.post.mock.calls[0][0]).toContain('该机器人不接受私信');
});
});
describe('/approve persistence failure', () => {
/**
* The /approve flow used to consume the pairing code from Redis
* BEFORE writing the applicant onto allowFrom. If the DB write
* failed (transient outage, missing provider row), the code was
* lost yet the owner still saw a success message — leaving the
* applicant locked out with no recoverable state. These tests pin
* the corrected ordering: peek-then-persist-then-delete, with a
* clear failure message and the code preserved for retry on
* persistence errors.
*/
async function loadApproveHandler(
providerOverrides: Record<string, unknown> = {},
): Promise<(event: any) => Promise<void>> {
mockGetAgentRuntimeRedisClient.mockReturnValue({} as any);
mockFindEnabledByPlatform.mockResolvedValue([
makeProvider({
applicationId: 'app-1',
settings: {
allowFrom: [{ id: 'owner-id' }],
dmPolicy: 'pairing',
userId: 'owner-id',
...providerOverrides,
},
userId: 'owner-id',
}),
]);
const router = new BotMessageRouter();
const webhookHandler = router.getWebhookHandler('telegram', 'app-1');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await webhookHandler(req);
const slashApproveCall = mockOnSlashCommand.mock.calls.find((c) => c[0] === '/approve');
if (!slashApproveCall) throw new Error('expected /approve to be registered');
return slashApproveCall[1] as (event: any) => Promise<void>;
}
function makeApproveEvent() {
const channel = {
id: 'telegram:dm-channel-1',
isDM: true,
post: vi.fn().mockResolvedValue(undefined),
setState: vi.fn().mockResolvedValue(undefined),
};
return {
channel,
text: 'ABCD2345',
user: { isBot: false, userId: 'owner-id', userName: 'owner' },
};
}
const PAIRING_ENTRY = {
applicantUserId: 'lin-id',
applicantUserName: 'Lin',
applicationId: 'app-1',
code: 'ABCD2345',
createdAt: 1_700_000_000_000,
platform: 'telegram',
replyLocale: 'en-US' as const,
threadId: 'telegram:dm-lin',
};
it('reports failure and preserves the code when the DB update throws', async () => {
mockPeekPairingRequest.mockResolvedValue(PAIRING_ENTRY);
mockProviderFindById.mockResolvedValue({
settings: { allowFrom: [{ id: 'owner-id' }] },
});
mockProviderUpdate.mockRejectedValue(new Error('connection refused'));
const slashApprove = await loadApproveHandler();
const event = makeApproveEvent();
await slashApprove(event);
// Owner sees the failure copy, not the success copy. This is the
// core of the bug: a logged-and-swallowed error must NOT render
// as a successful approval.
expect(event.channel.post).toHaveBeenCalledTimes(1);
const reply = event.channel.post.mock.calls[0][0] as string;
expect(reply).not.toMatch(/Approved/i);
expect(reply).toContain("Couldn't save");
// Code is still in Redis so the owner can rerun /approve, and the
// peek claim was released so the retry isn't blocked behind our
// own 60s lock.
expect(mockDeletePairingRequest).not.toHaveBeenCalled();
expect(mockReleasePairingClaim).toHaveBeenCalledTimes(1);
});
it('reports failure when the provider row is missing', async () => {
// Edge case: provider deleted between issuing the code and
// approving it. Without this guard, the old code silently no-op'd
// and posted "Approved" — now the owner sees the same failure
// copy and can investigate.
mockPeekPairingRequest.mockResolvedValue(PAIRING_ENTRY);
mockProviderFindById.mockResolvedValue(undefined);
const slashApprove = await loadApproveHandler();
const event = makeApproveEvent();
await slashApprove(event);
expect(event.channel.post).toHaveBeenCalledTimes(1);
expect(event.channel.post.mock.calls[0][0]).toContain("Couldn't save");
expect(mockDeletePairingRequest).not.toHaveBeenCalled();
expect(mockProviderUpdate).not.toHaveBeenCalled();
expect(mockReleasePairingClaim).toHaveBeenCalledTimes(1);
});
it('happy path: persists, then deletes the code, then reports success', async () => {
mockPeekPairingRequest.mockResolvedValue(PAIRING_ENTRY);
mockProviderFindById.mockResolvedValue({
settings: { allowFrom: [{ id: 'owner-id' }] },
});
mockProviderUpdate.mockResolvedValue(undefined);
const slashApprove = await loadApproveHandler();
const event = makeApproveEvent();
await slashApprove(event);
// Persist must precede delete — that's the whole point of the
// refactor. Use invocationCallOrder to lock the sequence.
const updateOrder = mockProviderUpdate.mock.invocationCallOrder[0];
const deleteOrder = mockDeletePairingRequest.mock.invocationCallOrder[0];
expect(updateOrder).toBeLessThan(deleteOrder);
expect(mockDeletePairingRequest).toHaveBeenCalledWith(
expect.objectContaining({
applicantUserId: 'lin-id',
applicationId: 'app-1',
code: 'ABCD2345',
platform: 'telegram',
}),
);
expect(event.channel.post).toHaveBeenCalledTimes(1);
expect(event.channel.post.mock.calls[0][0]).toMatch(/Approved Lin/i);
});
it('skips the DB write when the applicant is already on allowFrom but still cleans up the code', async () => {
// Read-modify-write idempotency: a second /approve for the same
// user shouldn't fail just because they're already in. The code
// gets cleared either way so it can't be reused.
mockPeekPairingRequest.mockResolvedValue(PAIRING_ENTRY);
mockProviderFindById.mockResolvedValue({
settings: { allowFrom: [{ id: 'owner-id' }, { id: 'lin-id', name: 'Lin' }] },
});
const slashApprove = await loadApproveHandler();
const event = makeApproveEvent();
await slashApprove(event);
expect(mockProviderUpdate).not.toHaveBeenCalled();
expect(mockDeletePairingRequest).toHaveBeenCalledTimes(1);
expect(event.channel.post.mock.calls[0][0]).toMatch(/Approved Lin/i);
});
});
});
@@ -0,0 +1,420 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
consumePairingRequest,
createOrGetPairingRequest,
deletePairingRequest,
generatePairingCode,
PAIRING_MAX_PENDING_PER_BOT,
PAIRING_TTL_SECONDS,
peekPairingRequest,
releasePairingClaim,
} from '../dmPairingStore';
// ioredis surface used by the store. `multi` returns a chainable builder
// whose terminal `exec()` resolves to an array. Each test resets these
// mocks so cross-test state doesn't leak.
const multiBuilder = {
del: vi.fn(),
exec: vi.fn(),
expire: vi.fn(),
set: vi.fn(),
zadd: vi.fn(),
zrem: vi.fn(),
};
const mockRedis = {
del: vi.fn(),
exists: vi.fn(),
get: vi.fn(),
multi: vi.fn(() => multiBuilder),
set: vi.fn(),
zcard: vi.fn(),
zremrangebyscore: vi.fn(),
} as any;
beforeEach(() => {
vi.clearAllMocks();
// Re-establish chain returns after clearAllMocks wipes them
multiBuilder.set.mockReturnValue(multiBuilder);
multiBuilder.zadd.mockReturnValue(multiBuilder);
multiBuilder.expire.mockReturnValue(multiBuilder);
multiBuilder.del.mockReturnValue(multiBuilder);
multiBuilder.zrem.mockReturnValue(multiBuilder);
multiBuilder.exec.mockResolvedValue([]);
mockRedis.multi.mockReturnValue(multiBuilder);
mockRedis.zremrangebyscore.mockResolvedValue(0);
// peek/consume always try to acquire the claim first; default to "won".
mockRedis.set.mockResolvedValue('OK');
});
describe('generatePairingCode', () => {
it('returns an 8-character Crockford-Base32 code (no 0/1/I/L/O/U)', () => {
const code = generatePairingCode();
expect(code).toHaveLength(8);
// Excluded glyphs would produce ambiguous codes when re-typed by the
// owner — guard that the alphabet stays intentional.
expect(code).toMatch(/^[A-HJKMNP-TV-Z2-9]{8}$/);
});
it('produces independent codes across calls (no obvious correlation)', () => {
const codes = new Set(Array.from({ length: 50 }, () => generatePairingCode()));
// 50 codes from a 30^8 space have a vanishing collision probability;
// anything less than 50 means generation is broken (e.g. fixed seed).
expect(codes.size).toBe(50);
});
});
describe('createOrGetPairingRequest', () => {
const baseParams = {
applicant: {
applicantUserId: 'stranger-1',
applicantUserName: 'Stranger',
replyLocale: 'en-US' as const,
threadId: 'discord:dm-channel-1',
},
applicationId: 'app-123',
platform: 'discord',
};
it('returns redis-unavailable when no client is wired', async () => {
const result = await createOrGetPairingRequest({ ...baseParams, redis: null });
expect(result).toEqual({ status: 'redis-unavailable' });
});
it('mints a fresh code and writes the code-, applicant-, and active-set keys', async () => {
mockRedis.get.mockResolvedValue(null); // no existing applicant entry
mockRedis.zcard.mockResolvedValue(0); // no capacity pressure
mockRedis.exists.mockResolvedValue(0); // no code collision
const result = await createOrGetPairingRequest({ ...baseParams, redis: mockRedis });
expect(result.status).toBe('created');
if (result.status !== 'created') throw new Error('unreachable');
expect(result.reused).toBe(false);
expect(result.code).toMatch(/^[A-HJKMNP-TV-Z2-9]{8}$/);
// Code key — JSON entry, with TTL
expect(multiBuilder.set).toHaveBeenCalledWith(
`bot:dm-pairing:code:discord:app-123:${result.code}`,
expect.any(String),
'EX',
PAIRING_TTL_SECONDS,
);
// Applicant index — points back to the code, with TTL
expect(multiBuilder.set).toHaveBeenCalledWith(
'bot:dm-pairing:applicant:discord:app-123:stranger-1',
result.code,
'EX',
PAIRING_TTL_SECONDS,
);
// Active set — code is added with a future-expiry score, set TTL refreshed
expect(multiBuilder.zadd).toHaveBeenCalledWith(
'bot:dm-pairing:active:discord:app-123',
expect.any(Number),
result.code,
);
const zaddScore = multiBuilder.zadd.mock.calls[0][1] as number;
expect(zaddScore).toBeGreaterThan(Date.now());
expect(multiBuilder.expire).toHaveBeenCalledWith(
'bot:dm-pairing:active:discord:app-123',
PAIRING_TTL_SECONDS,
);
expect(multiBuilder.exec).toHaveBeenCalled();
// Persisted JSON includes everything needed by /approve later
const persisted = JSON.parse(multiBuilder.set.mock.calls[0][1] as string);
expect(persisted).toMatchObject({
applicantUserId: 'stranger-1',
applicantUserName: 'Stranger',
applicationId: 'app-123',
code: result.code,
platform: 'discord',
replyLocale: 'en-US',
threadId: 'discord:dm-channel-1',
});
expect(typeof persisted.createdAt).toBe('number');
});
it('reuses an existing code when the same applicant DMs again within TTL', async () => {
// applicant index exists → recycle path
mockRedis.get
.mockResolvedValueOnce('ABCD2345') // applicantKey lookup
.mockResolvedValueOnce('{"code":"ABCD2345"}'); // codeKey lookup confirms it's still alive
const result = await createOrGetPairingRequest({ ...baseParams, redis: mockRedis });
expect(result).toEqual({ code: 'ABCD2345', reused: true, status: 'reused' });
// Idempotent: no fresh write
expect(multiBuilder.set).not.toHaveBeenCalled();
expect(multiBuilder.zadd).not.toHaveBeenCalled();
});
it('falls through to a fresh code when the applicant index points at an expired entry', async () => {
// applicant index exists, but the code-keyed entry is gone (TTL elapsed
// mid-window). Issue a new code rather than returning a dead reference.
mockRedis.get
.mockResolvedValueOnce('STALECODE') // applicantKey lookup
.mockResolvedValueOnce(null); // codeKey is empty
mockRedis.zcard.mockResolvedValue(0);
mockRedis.exists.mockResolvedValue(0);
const result = await createOrGetPairingRequest({ ...baseParams, redis: mockRedis });
expect(result.status).toBe('created');
expect(multiBuilder.set).toHaveBeenCalled();
});
it('returns capacity-exceeded when the per-bot pending cap is hit', async () => {
mockRedis.get.mockResolvedValue(null);
mockRedis.zcard.mockResolvedValue(PAIRING_MAX_PENDING_PER_BOT);
const result = await createOrGetPairingRequest({ ...baseParams, redis: mockRedis });
expect(result).toEqual({ status: 'capacity-exceeded' });
// No state mutation when capacity is exceeded
expect(multiBuilder.set).not.toHaveBeenCalled();
expect(multiBuilder.zadd).not.toHaveBeenCalled();
});
it('prunes naturally-expired members before counting (keeps the cap honest)', async () => {
// Without this, codes that expired without /approve would linger in
// the active set forever and wedge the gate at 50.
mockRedis.get.mockResolvedValue(null);
mockRedis.zremrangebyscore.mockResolvedValue(7); // 7 stale members dropped
mockRedis.zcard.mockResolvedValue(0); // post-prune count
mockRedis.exists.mockResolvedValue(0);
const result = await createOrGetPairingRequest({ ...baseParams, redis: mockRedis });
expect(result.status).toBe('created');
expect(mockRedis.zremrangebyscore).toHaveBeenCalledWith(
'bot:dm-pairing:active:discord:app-123',
0,
expect.any(Number),
);
// Prune must run before the count, not after — otherwise the gate
// sees stale members and rejects legitimate requests.
const pruneOrder = mockRedis.zremrangebyscore.mock.invocationCallOrder[0];
const countOrder = mockRedis.zcard.mock.invocationCallOrder[0];
expect(pruneOrder).toBeLessThan(countOrder);
});
it('regenerates on a code collision (defensive — astronomically unlikely)', async () => {
mockRedis.get.mockResolvedValue(null);
mockRedis.zcard.mockResolvedValue(0);
// First exists check returns 1 (collision), second returns 0
mockRedis.exists.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
const result = await createOrGetPairingRequest({ ...baseParams, redis: mockRedis });
expect(result.status).toBe('created');
expect(mockRedis.exists).toHaveBeenCalledTimes(2);
});
});
describe('consumePairingRequest', () => {
const baseParams = {
applicationId: 'app-123',
code: 'ABCD2345',
platform: 'discord',
};
it('returns null when no redis client is wired', async () => {
const result = await consumePairingRequest({ ...baseParams, redis: null });
expect(result).toBeNull();
});
it('returns null when the code is unknown / expired', async () => {
mockRedis.get.mockResolvedValue(null);
const result = await consumePairingRequest({ ...baseParams, redis: mockRedis });
expect(result).toBeNull();
expect(multiBuilder.del).not.toHaveBeenCalled();
// Peek released its own claim so a follow-up call isn't blocked.
expect(mockRedis.del).toHaveBeenCalledWith('bot:dm-pairing:claim:discord:app-123:ABCD2345');
});
it('returns null without side effects when another caller holds the claim', async () => {
// SET NX returns null when the lock is already taken — peek bails
// without touching the entry, so the in-flight caller can finish.
mockRedis.set.mockResolvedValue(null);
const result = await consumePairingRequest({ ...baseParams, redis: mockRedis });
expect(result).toBeNull();
expect(mockRedis.get).not.toHaveBeenCalled();
expect(mockRedis.del).not.toHaveBeenCalled();
expect(multiBuilder.del).not.toHaveBeenCalled();
});
it('returns null and cleans up the malformed key when JSON is corrupt', async () => {
mockRedis.get.mockResolvedValue('not-json');
const result = await consumePairingRequest({ ...baseParams, redis: mockRedis });
expect(result).toBeNull();
// Best-effort cleanup so the bad entry doesn't sit around. The
// applicant index is keyed by the (unparseable) entry's userId, so
// we can't drop it here — but the code key, claim lock, and
// active-set member all can.
expect(multiBuilder.del).toHaveBeenCalledWith('bot:dm-pairing:code:discord:app-123:ABCD2345');
expect(multiBuilder.del).toHaveBeenCalledWith('bot:dm-pairing:claim:discord:app-123:ABCD2345');
expect(multiBuilder.zrem).toHaveBeenCalledWith(
'bot:dm-pairing:active:discord:app-123',
'ABCD2345',
);
});
it('happy path: returns the entry and tears down all four keys atomically', async () => {
const persisted = {
applicantUserId: 'stranger-1',
applicantUserName: 'Stranger',
applicationId: 'app-123',
code: 'ABCD2345',
createdAt: 1_700_000_000_000,
platform: 'discord',
replyLocale: 'en-US',
threadId: 'discord:dm-channel-1',
};
mockRedis.get.mockResolvedValue(JSON.stringify(persisted));
const result = await consumePairingRequest({ ...baseParams, redis: mockRedis });
expect(result).toEqual(persisted);
expect(multiBuilder.del).toHaveBeenCalledWith('bot:dm-pairing:code:discord:app-123:ABCD2345');
expect(multiBuilder.del).toHaveBeenCalledWith(
'bot:dm-pairing:applicant:discord:app-123:stranger-1',
);
expect(multiBuilder.del).toHaveBeenCalledWith('bot:dm-pairing:claim:discord:app-123:ABCD2345');
expect(multiBuilder.zrem).toHaveBeenCalledWith(
'bot:dm-pairing:active:discord:app-123',
'ABCD2345',
);
expect(multiBuilder.exec).toHaveBeenCalled();
});
it('normalizes case + whitespace before lookup (codes are typed by humans)', async () => {
mockRedis.get.mockResolvedValue(null);
await consumePairingRequest({ ...baseParams, code: ' abcd2345 ', redis: mockRedis });
expect(mockRedis.get).toHaveBeenCalledWith('bot:dm-pairing:code:discord:app-123:ABCD2345');
});
it('returns null on an empty / whitespace code without hitting redis', async () => {
const result = await consumePairingRequest({ ...baseParams, code: ' ', redis: mockRedis });
expect(result).toBeNull();
expect(mockRedis.get).not.toHaveBeenCalled();
});
});
describe('peekPairingRequest', () => {
const baseParams = {
applicationId: 'app-123',
code: 'ABCD2345',
platform: 'discord',
};
const persisted = {
applicantUserId: 'stranger-1',
applicantUserName: 'Stranger',
applicationId: 'app-123',
code: 'ABCD2345',
createdAt: 1_700_000_000_000,
platform: 'discord',
replyLocale: 'en-US' as const,
threadId: 'discord:dm-channel-1',
};
it('takes the claim and returns the entry without deleting bookkeeping', async () => {
mockRedis.get.mockResolvedValue(JSON.stringify(persisted));
const result = await peekPairingRequest({ ...baseParams, redis: mockRedis });
expect(result).toEqual(persisted);
// The claim lock is taken with NX so concurrent peeks can't both win.
expect(mockRedis.set).toHaveBeenCalledWith(
'bot:dm-pairing:claim:discord:app-123:ABCD2345',
'1',
'EX',
expect.any(Number),
'NX',
);
// Critical: peek leaves the bookkeeping intact. If we deleted here
// and downstream persistence failed, the owner couldn't retry.
expect(multiBuilder.del).not.toHaveBeenCalled();
expect(multiBuilder.zrem).not.toHaveBeenCalled();
});
it('returns null on missing / unknown code and releases its own claim', async () => {
mockRedis.get.mockResolvedValue(null);
const result = await peekPairingRequest({ ...baseParams, redis: mockRedis });
expect(result).toBeNull();
// Without the lock release, a follow-up call would sit behind a
// phantom 60s claim for an entry that never existed.
expect(mockRedis.del).toHaveBeenCalledWith('bot:dm-pairing:claim:discord:app-123:ABCD2345');
expect(multiBuilder.del).not.toHaveBeenCalled();
});
it('returns null without side effects when another caller holds the claim', async () => {
mockRedis.set.mockResolvedValue(null); // SET NX failed — race lost
const result = await peekPairingRequest({ ...baseParams, redis: mockRedis });
expect(result).toBeNull();
expect(mockRedis.get).not.toHaveBeenCalled();
expect(mockRedis.del).not.toHaveBeenCalled();
});
it('returns null when redis is unwired', async () => {
const result = await peekPairingRequest({ ...baseParams, redis: null });
expect(result).toBeNull();
});
});
describe('releasePairingClaim', () => {
const baseParams = {
applicationId: 'app-123',
code: 'ABCD2345',
platform: 'discord',
};
it('clears only the claim lock so the entry stays available for retry', async () => {
await releasePairingClaim({ ...baseParams, redis: mockRedis });
expect(mockRedis.del).toHaveBeenCalledWith('bot:dm-pairing:claim:discord:app-123:ABCD2345');
expect(mockRedis.del).toHaveBeenCalledTimes(1);
expect(multiBuilder.del).not.toHaveBeenCalled();
});
it('is a no-op when redis is unwired', async () => {
await expect(releasePairingClaim({ ...baseParams, redis: null })).resolves.toBeUndefined();
expect(mockRedis.del).not.toHaveBeenCalled();
});
it('normalizes case + whitespace before releasing', async () => {
await releasePairingClaim({ ...baseParams, code: ' abcd2345 ', redis: mockRedis });
expect(mockRedis.del).toHaveBeenCalledWith('bot:dm-pairing:claim:discord:app-123:ABCD2345');
});
});
describe('deletePairingRequest', () => {
const baseParams = {
applicantUserId: 'stranger-1',
applicationId: 'app-123',
code: 'ABCD2345',
platform: 'discord',
};
it('tears down all four keys atomically', async () => {
await deletePairingRequest({ ...baseParams, redis: mockRedis });
expect(multiBuilder.del).toHaveBeenCalledWith('bot:dm-pairing:code:discord:app-123:ABCD2345');
expect(multiBuilder.del).toHaveBeenCalledWith(
'bot:dm-pairing:applicant:discord:app-123:stranger-1',
);
expect(multiBuilder.del).toHaveBeenCalledWith('bot:dm-pairing:claim:discord:app-123:ABCD2345');
expect(multiBuilder.zrem).toHaveBeenCalledWith(
'bot:dm-pairing:active:discord:app-123',
'ABCD2345',
);
expect(multiBuilder.exec).toHaveBeenCalled();
});
it('is a no-op when redis is unwired (callers should not have to guard)', async () => {
await expect(deletePairingRequest({ ...baseParams, redis: null })).resolves.toBeUndefined();
expect(multiBuilder.del).not.toHaveBeenCalled();
});
it('normalizes case + whitespace on the code before deleting', async () => {
await deletePairingRequest({ ...baseParams, code: ' abcd2345 ', redis: mockRedis });
expect(multiBuilder.del).toHaveBeenCalledWith('bot:dm-pairing:code:discord:app-123:ABCD2345');
expect(multiBuilder.zrem).toHaveBeenCalledWith(
'bot:dm-pairing:active:discord:app-123',
'ABCD2345',
);
});
});
+358
View File
@@ -0,0 +1,358 @@
import { randomInt } from 'node:crypto';
import debug from 'debug';
import type Redis from 'ioredis';
import type { BotReplyLocale } from './platforms';
const log = debug('lobe-server:bot:dm-pairing-store');
/**
* One pairing request lives in Redis for an hour. Long enough that an owner
* can take a meal-break before approving, short enough that abandoned codes
* don't pile up indefinitely.
*/
export const PAIRING_TTL_SECONDS = 3600;
/**
* Per-bot ceiling on simultaneously pending requests. The owner is the
* funnel — too many open codes means the owner can't realistically triage,
* and the bot becomes a spam attractor. 50 is a generous upper bound: a
* legitimate bot rarely sees that many fresh strangers per hour.
*/
export const PAIRING_MAX_PENDING_PER_BOT = 50;
/**
* How long a single `/approve` handler is granted exclusive access to a
* code after `peekPairingRequest` returns. Long enough to cover normal DB
* persistence; short enough that a crashed handler doesn't permanently
* block the operator from retrying.
*/
const PAIRING_CLAIM_TTL_SECONDS = 60;
/**
* Crockford Base32 alphabet (no I/L/O/U, no 0/1) — chosen because the code
* gets eyeballed and re-typed, and the standard base32 set produces too
* many lookalikes (`0/O`, `1/I/L`). 8 characters from a 30-symbol alphabet
* give >38 bits of entropy, which is enough that brute-forcing a code in
* the 1-hour TTL window is infeasible at any realistic request rate.
*/
const CROCKFORD_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789';
const CODE_LENGTH = 8;
/** Public applicant info captured at request time. */
export interface PairingApplicant {
/** The applicant's platform user ID — what gets appended to allowFrom. */
applicantUserId: string;
/** Optional operator-facing label (the platform user's display name). */
applicantUserName?: string;
/**
* Locale to use when notifying the applicant after approval. Captured at
* request time because the owner runs `/approve` in their own context,
* which may not match the applicant's language.
*/
replyLocale: BotReplyLocale;
/** Composite platformThreadId for the applicant's DM — where the
* approval notification gets posted. */
threadId: string;
}
/** Persisted pending request — applicant + bot-scoping fields. */
export interface PairingEntry extends PairingApplicant {
applicationId: string;
code: string;
/** Wall-clock millis at creation, used for diagnostic logging. */
createdAt: number;
platform: string;
}
export type CreatePairingResult =
| { code: string; reused: boolean; status: 'created' | 'reused' }
| { status: 'capacity-exceeded' | 'redis-unavailable' };
/**
* Generate a fresh pairing code. Uses `crypto.randomInt` (CSPRNG with
* rejection sampling) rather than `Math.random` because the code gates
* write access to allowFrom — predictable codes would let a stranger
* preempt the owner's approval. `randomInt` is preferred over
* `randomBytes() % N` because the alphabet length (30) doesn't divide 256
* evenly, so a naive modulo would bias toward earlier characters.
*/
export function generatePairingCode(): string {
let code = '';
for (let i = 0; i < CODE_LENGTH; i += 1) {
code += CROCKFORD_ALPHABET[randomInt(CROCKFORD_ALPHABET.length)];
}
return code;
}
const codeKey = (platform: string, applicationId: string, code: string): string =>
`bot:dm-pairing:code:${platform}:${applicationId}:${code}`;
const applicantKey = (platform: string, applicationId: string, applicantUserId: string): string =>
`bot:dm-pairing:applicant:${platform}:${applicationId}:${applicantUserId}`;
const activeSetKey = (platform: string, applicationId: string): string =>
`bot:dm-pairing:active:${platform}:${applicationId}`;
const claimKey = (platform: string, applicationId: string, code: string): string =>
`bot:dm-pairing:claim:${platform}:${applicationId}:${code}`;
/**
* Create a pending pairing request, or return the applicant's existing one
* if they already have one outstanding.
*
* The applicant index (`applicantKey`) makes a re-DM idempotent: a stranger
* who pings the bot twice in a row sees the same code rather than receiving
* a fresh one each time and confusing their owner with stale codes. The
* active-set check (`activeSetKey`) caps the per-bot workload so a flood of
* distinct fake accounts can't drown the owner.
*
* Returns `'redis-unavailable'` (no Redis client wired) or
* `'capacity-exceeded'` (cap hit) without state change so the caller can
* surface a useful message instead of silently dropping the applicant.
*/
export async function createOrGetPairingRequest(params: {
applicant: PairingApplicant;
applicationId: string;
platform: string;
redis: Redis | null;
}): Promise<CreatePairingResult> {
const { applicant, applicationId, platform, redis } = params;
if (!redis) {
log('createOrGetPairingRequest: redis unavailable — skipping');
return { status: 'redis-unavailable' };
}
const aKey = applicantKey(platform, applicationId, applicant.applicantUserId);
const sKey = activeSetKey(platform, applicationId);
// Same applicant within the TTL window → reuse their code (don't make
// them stack codes if they DM again).
const existingCode = await redis.get(aKey);
if (existingCode) {
const entry = await redis.get(codeKey(platform, applicationId, existingCode));
if (entry) {
log(
'createOrGetPairingRequest: reuse existing code for applicant=%s, platform=%s, app=%s',
applicant.applicantUserId,
platform,
applicationId,
);
return { code: existingCode, reused: true, status: 'reused' };
}
// Index pointed to an expired code — fall through and mint a fresh one.
}
// The active set is a ZSET scored by per-entry expiry. Codes only get
// SREM'd on explicit approval, so codes that expire naturally (the
// common case for abandoned requests) would otherwise linger and wedge
// the capacity gate at 50 forever. Drop the dead members before counting.
const createdAt = Date.now();
const expiresAt = createdAt + PAIRING_TTL_SECONDS * 1000;
await redis.zremrangebyscore(sKey, 0, createdAt);
const activeCount = await redis.zcard(sKey);
if (activeCount >= PAIRING_MAX_PENDING_PER_BOT) {
log(
'createOrGetPairingRequest: capacity %d/%d exceeded for platform=%s, app=%s',
activeCount,
PAIRING_MAX_PENDING_PER_BOT,
platform,
applicationId,
);
return { status: 'capacity-exceeded' };
}
// Mint a fresh code, retrying on the (astronomically unlikely) collision.
let code = generatePairingCode();
for (let attempt = 0; attempt < 3; attempt += 1) {
const exists = await redis.exists(codeKey(platform, applicationId, code));
if (!exists) break;
code = generatePairingCode();
}
const entry: PairingEntry = {
applicantUserId: applicant.applicantUserId,
applicantUserName: applicant.applicantUserName,
applicationId,
code,
createdAt,
platform,
replyLocale: applicant.replyLocale,
threadId: applicant.threadId,
};
await redis
.multi()
.set(codeKey(platform, applicationId, code), JSON.stringify(entry), 'EX', PAIRING_TTL_SECONDS)
.set(aKey, code, 'EX', PAIRING_TTL_SECONDS)
.zadd(sKey, expiresAt, code)
.expire(sKey, PAIRING_TTL_SECONDS)
.exec();
log(
'createOrGetPairingRequest: created code for applicant=%s, platform=%s, app=%s',
applicant.applicantUserId,
platform,
applicationId,
);
return { code, reused: false, status: 'created' };
}
/**
* Look up a pending request by code, taking a single-winner claim on it
* for the duration of the caller's downstream work.
*
* Returns the persisted `PairingEntry`, or `null` when the code is
* unknown / expired / malformed, or when another caller already holds
* the claim (the GET/MULTI-DEL split would otherwise race: two concurrent
* `/approve` calls could both read the entry before either's cleanup
* runs and end up sending duplicate approvals to the applicant).
*
* The entry itself is left in Redis so the caller can pair this with
* `deletePairingRequest` (on success) or `releasePairingClaim` (on
* persistence failure, so the operator can retry without forcing the
* applicant to mint a new code). The claim auto-expires after
* `PAIRING_CLAIM_TTL_SECONDS` so a crashed handler doesn't permanently
* block retry.
*/
export async function peekPairingRequest(params: {
applicationId: string;
code: string;
platform: string;
redis: Redis | null;
}): Promise<PairingEntry | null> {
const { applicationId, code, platform, redis } = params;
if (!redis) return null;
const normalized = code.trim().toUpperCase();
if (!normalized) return null;
const cKey = codeKey(platform, applicationId, normalized);
const lockKey = claimKey(platform, applicationId, normalized);
// Atomic single-winner claim. SET NX returns null when the lock is
// already held — the loser bails out as if the code didn't exist
// (which from their perspective it effectively doesn't, because a
// peer is mid-approval).
const acquired = await redis.set(lockKey, '1', 'EX', PAIRING_CLAIM_TTL_SECONDS, 'NX');
if (!acquired) {
log('peekPairingRequest: lost claim race for code=%s', normalized);
return null;
}
const raw = await redis.get(cKey);
if (!raw) {
// Code expired or never existed — release the claim immediately so
// the next caller doesn't sit behind a phantom lock for 60s.
await redis.del(lockKey);
return null;
}
try {
return JSON.parse(raw) as PairingEntry;
} catch (error) {
log('peekPairingRequest: failed to parse entry for code=%s: %O', normalized, error);
// Malformed entries can never be approved — drop them so they don't
// sit around forever consuming a slot in the active set, and clear
// our own claim while we're at it.
await redis
.multi()
.del(cKey)
.del(lockKey)
.zrem(activeSetKey(platform, applicationId), normalized)
.exec();
return null;
}
}
/**
* Release the peek claim without removing the underlying entry. Used
* when downstream persistence fails: the operator should be able to
* retry `/approve` immediately rather than waiting out the claim TTL.
*/
export async function releasePairingClaim(params: {
applicationId: string;
code: string;
platform: string;
redis: Redis | null;
}): Promise<void> {
const { applicationId, code, platform, redis } = params;
if (!redis) return;
const normalized = code.trim().toUpperCase();
if (!normalized) return;
await redis.del(claimKey(platform, applicationId, normalized));
}
/**
* Tear down all bookkeeping for a pairing request once it has been
* successfully approved (or otherwise resolved). Idempotent: a second
* call after the keys are gone is a no-op against Redis.
*/
export async function deletePairingRequest(params: {
applicationId: string;
applicantUserId: string;
code: string;
platform: string;
redis: Redis | null;
}): Promise<void> {
const { applicationId, applicantUserId, code, platform, redis } = params;
if (!redis) return;
const normalized = code.trim().toUpperCase();
if (!normalized) return;
await redis
.multi()
.del(codeKey(platform, applicationId, normalized))
.del(applicantKey(platform, applicationId, applicantUserId))
.del(claimKey(platform, applicationId, normalized))
.zrem(activeSetKey(platform, applicationId), normalized)
.exec();
log(
'deletePairingRequest: cleared code for applicant=%s, platform=%s, app=%s',
applicantUserId,
platform,
applicationId,
);
}
/**
* Claim a pending request by code and remove its bookkeeping.
*
* Returns the persisted `PairingEntry` so callers can act on the
* applicant's identity / thread / locale, or `null` when the code is
* unknown / expired / already consumed / lost to a concurrent caller.
* Atomicity is enforced by `peekPairingRequest`'s claim lock — two
* simultaneous `/approve`s for the same code see only one winner.
*
* Prefer `peekPairingRequest` + `deletePairingRequest` (with
* `releasePairingClaim` on failure) when downstream persistence can
* fail — consuming the code before persistence loses the code on a
* transient error.
*/
export async function consumePairingRequest(params: {
applicationId: string;
code: string;
platform: string;
redis: Redis | null;
}): Promise<PairingEntry | null> {
const entry = await peekPairingRequest(params);
if (!entry) return null;
await deletePairingRequest({
applicationId: params.applicationId,
applicantUserId: entry.applicantUserId,
code: params.code,
platform: params.platform,
redis: params.redis,
});
return entry;
}
@@ -9,11 +9,13 @@ import {
getStepReactionEmoji,
makeDmPolicyField,
makeGroupPolicyFields,
normalizeAllowFromEntries,
normalizeBotReplyLocale,
shouldAllowSender,
shouldHandleDm,
shouldHandleGroup,
THINKING_REACTION_EMOJI,
validateAccessSettings,
WORKING_REACTION_EMOJI,
} from '../const';
@@ -96,13 +98,30 @@ describe('getStepReactionEmoji', () => {
});
describe('makeDmPolicyField', () => {
it('produces a flat dmPolicy field with the supplied default policy and three modes', () => {
it('produces a flat dmPolicy field with the supplied default policy and four modes', () => {
const field = makeDmPolicyField({ policy: 'open' });
expect(field.key).toBe('dmPolicy');
expect(field.type).toBe('string');
expect(field.default).toBe('open');
expect(field.enum).toEqual(['open', 'allowlist', 'disabled']);
expect(field.enum).toEqual(['open', 'allowlist', 'pairing', 'disabled']);
// Label keys must be in 1:1 order with `enum` so the form renders the
// right text for each option — easy regression to introduce when adding
// a fourth policy.
expect(field.enumLabels).toEqual([
'channel.dmPolicyOpen',
'channel.dmPolicyAllowlist',
'channel.dmPolicyPairing',
'channel.dmPolicyDisabled',
]);
// Per-option descriptions render to the right of each option in the
// dropdown — must stay 1:1 with `enum`/`enumLabels` for the same reason.
expect(field.enumDescriptions).toEqual([
'channel.dmPolicyOpenHint',
'channel.dmPolicyAllowlistHint',
'channel.dmPolicyPairingHint',
'channel.dmPolicyDisabledHint',
]);
});
it('supports the per-platform default override (e.g. opt-in disabled)', () => {
@@ -162,6 +181,7 @@ describe('extractDmSettings', () => {
it('reads the flat dmPolicy field (not legacy nested settings.dm.policy)', () => {
expect(extractDmSettings({ dmPolicy: 'disabled' })).toEqual({ policy: 'disabled' });
expect(extractDmSettings({ dmPolicy: 'allowlist' })).toEqual({ policy: 'allowlist' });
expect(extractDmSettings({ dmPolicy: 'pairing' })).toEqual({ policy: 'pairing' });
// Regression: the original bug stored disabled at `settings.dm.policy` but
// never read it back. The new shape is flat; nested `dm.policy` is ignored.
expect(extractDmSettings({ dm: { policy: 'disabled' } })).toEqual({ policy: 'open' });
@@ -319,6 +339,7 @@ describe('shouldHandleDm', () => {
const open = { policy: 'open' as const };
const disabled = { policy: 'disabled' as const };
const allowlist = { policy: 'allowlist' as const };
const pairing = { policy: 'pairing' as const };
const emptyUserAllowlist = { ids: [] as string[] };
const aliceAndBob = { ids: ['alice-id', 'bob-id'] };
@@ -330,10 +351,10 @@ describe('shouldHandleDm', () => {
isDM: false,
userAllowlist: emptyUserAllowlist,
}),
).toBe(true);
).toBe('allow');
});
it('blocks DMs when disabled', () => {
it('rejects DMs when disabled', () => {
expect(
shouldHandleDm({
authorUserId: 'alice-id',
@@ -341,7 +362,7 @@ describe('shouldHandleDm', () => {
isDM: true,
userAllowlist: aliceAndBob,
}),
).toBe(false);
).toBe('reject');
});
it('allows DMs under the open policy regardless of allowlist contents', () => {
@@ -352,7 +373,7 @@ describe('shouldHandleDm', () => {
isDM: true,
userAllowlist: emptyUserAllowlist,
}),
).toBe(true);
).toBe('allow');
// The global gate (shouldAllowSender) is the runtime filter for `open`;
// shouldHandleDm itself does not re-check it.
expect(
@@ -362,7 +383,7 @@ describe('shouldHandleDm', () => {
isDM: true,
userAllowlist: aliceAndBob,
}),
).toBe(true);
).toBe('allow');
});
it('allows DMs in allowlist mode when the sender is on the list', () => {
@@ -373,7 +394,7 @@ describe('shouldHandleDm', () => {
isDM: true,
userAllowlist: aliceAndBob,
}),
).toBe(true);
).toBe('allow');
});
it('rejects DMs in allowlist mode when the sender is NOT on the list', () => {
@@ -384,10 +405,10 @@ describe('shouldHandleDm', () => {
isDM: true,
userAllowlist: aliceAndBob,
}),
).toBe(false);
).toBe('reject');
});
it('fails closed in allowlist mode when allowFrom is empty (no DMs)', () => {
it('rejects in allowlist mode when allowFrom is empty (no DMs)', () => {
// This is the only behavioural difference from `open`: `open` would
// pass anyone here, `allowlist` rejects everyone.
expect(
@@ -397,10 +418,10 @@ describe('shouldHandleDm', () => {
isDM: true,
userAllowlist: emptyUserAllowlist,
}),
).toBe(false);
).toBe('reject');
});
it('fails closed when the allowlisted policy sees a missing user id', () => {
it('rejects when the allowlisted policy sees a missing user id', () => {
expect(
shouldHandleDm({
authorUserId: undefined,
@@ -408,7 +429,69 @@ describe('shouldHandleDm', () => {
isDM: true,
userAllowlist: aliceAndBob,
}),
).toBe(false);
).toBe('reject');
});
it('pairs an unknown sender under pairing policy (so the router can issue a code)', () => {
expect(
shouldHandleDm({
authorUserId: 'stranger-id',
dmSettings: pairing,
isDM: true,
operatorUserId: 'owner-id',
userAllowlist: aliceAndBob,
}),
).toBe('pair');
});
it('pairs unknown senders even when allowFrom is empty (pre-approval starting state)', () => {
expect(
shouldHandleDm({
authorUserId: 'stranger-id',
dmSettings: pairing,
isDM: true,
operatorUserId: 'owner-id',
userAllowlist: emptyUserAllowlist,
}),
).toBe('pair');
});
it('allows the operator under pairing even when allowFrom is empty (owner self-DM)', () => {
// Without the operator bypass, the owner's first DM to their own
// pairing bot would land in `pair` and ask them to approve themselves.
expect(
shouldHandleDm({
authorUserId: 'owner-id',
dmSettings: pairing,
isDM: true,
operatorUserId: 'owner-id',
userAllowlist: emptyUserAllowlist,
}),
).toBe('allow');
});
it('allows pairing senders already on the approved list', () => {
expect(
shouldHandleDm({
authorUserId: 'alice-id',
dmSettings: pairing,
isDM: true,
operatorUserId: 'owner-id',
userAllowlist: aliceAndBob,
}),
).toBe('allow');
});
it('rejects pairing when authorUserId is missing — cannot issue a code without a target', () => {
expect(
shouldHandleDm({
authorUserId: undefined,
dmSettings: pairing,
isDM: true,
operatorUserId: 'owner-id',
userAllowlist: emptyUserAllowlist,
}),
).toBe('reject');
});
});
@@ -501,3 +584,82 @@ describe('shouldHandleGroup', () => {
).toBe(false);
});
});
describe('normalizeAllowFromEntries', () => {
it('returns an empty list for missing / empty input', () => {
expect(normalizeAllowFromEntries(undefined)).toEqual([]);
expect(normalizeAllowFromEntries(null)).toEqual([]);
expect(normalizeAllowFromEntries('')).toEqual([]);
expect(normalizeAllowFromEntries([])).toEqual([]);
});
it('preserves both id and name on the current object-list shape', () => {
expect(normalizeAllowFromEntries([{ id: 'alice', name: 'Alice' }, { id: 'bob' }])).toEqual([
{ id: 'alice', name: 'Alice' },
{ id: 'bob' },
]);
});
it('drops blank names while keeping the id (no point persisting whitespace)', () => {
expect(normalizeAllowFromEntries([{ id: 'alice', name: ' ' }])).toEqual([{ id: 'alice' }]);
});
it('lifts legacy string[] entries to nameless objects', () => {
expect(normalizeAllowFromEntries(['alice', ' bob ', ''])).toEqual([
{ id: 'alice' },
{ id: 'bob' },
]);
});
it('lifts legacy comma / whitespace-separated strings the same way', () => {
expect(normalizeAllowFromEntries('alice, bob\ncarol')).toEqual([
{ id: 'alice' },
{ id: 'bob' },
{ id: 'carol' },
]);
});
it('skips object entries without a usable id', () => {
expect(
normalizeAllowFromEntries([
{ id: '', name: 'no-id' },
{ name: 'no-id-field' } as { id?: string },
{ id: 'kept' },
]),
).toEqual([{ id: 'kept' }]);
});
});
describe('validateAccessSettings', () => {
it('passes when no policy needs cross-field invariants', () => {
expect(validateAccessSettings(undefined).valid).toBe(true);
expect(validateAccessSettings({}).valid).toBe(true);
expect(validateAccessSettings({ dmPolicy: 'open' }).valid).toBe(true);
expect(validateAccessSettings({ dmPolicy: 'allowlist' }).valid).toBe(true);
expect(validateAccessSettings({ dmPolicy: 'disabled' }).valid).toBe(true);
});
it('passes pairing when the operator (settings.userId) is set', () => {
expect(validateAccessSettings({ dmPolicy: 'pairing', userId: 'owner-id' }).valid).toBe(true);
});
it('rejects pairing without a userId — owner is the approver, missing it bricks the flow', () => {
const result = validateAccessSettings({ dmPolicy: 'pairing' });
expect(result.valid).toBe(false);
expect(result.errors).toEqual([
{
field: 'userId',
message: expect.stringContaining('Pairing policy'),
},
]);
});
it('treats a blank-string userId the same as missing (whitespace is not an owner)', () => {
expect(validateAccessSettings({ dmPolicy: 'pairing', userId: ' ' }).valid).toBe(false);
});
it('does not require userId for allowlist or disabled — they have no approval flow', () => {
expect(validateAccessSettings({ dmPolicy: 'allowlist' }).valid).toBe(true);
expect(validateAccessSettings({ dmPolicy: 'disabled' }).valid).toBe(true);
});
});
+139 -20
View File
@@ -1,7 +1,7 @@
import { DEFAULT_LANG } from '@/const/locale';
import { type Locales, normalizeLocale } from '@/locales/resources';
import type { FieldSchema } from './types';
import type { FieldSchema, ValidationResult } from './types';
export const displayToolCallsField: FieldSchema = {
key: 'displayToolCalls',
@@ -118,7 +118,7 @@ export function normalizeBotReplyLocale(
* crosses scopes (`allowFrom` is consulted by every gate); a prefixed name
* advertises the field is the property of one specific scope.
*/
export type DmPolicy = 'open' | 'allowlist' | 'disabled';
export type DmPolicy = 'open' | 'allowlist' | 'pairing' | 'disabled';
/** User-ID allowlist shared across user-scope policies (DM today). */
export interface UserAllowlist {
@@ -138,7 +138,7 @@ export interface GroupSettings {
policy: GroupPolicy;
}
const DM_POLICIES: ReadonlySet<DmPolicy> = new Set(['open', 'allowlist', 'disabled']);
const DM_POLICIES: ReadonlySet<DmPolicy> = new Set(['open', 'allowlist', 'pairing', 'disabled']);
const GROUP_POLICIES: ReadonlySet<GroupPolicy> = new Set(['open', 'allowlist', 'disabled']);
/**
@@ -149,6 +149,12 @@ const GROUP_POLICIES: ReadonlySet<GroupPolicy> = new Set(['open', 'allowlist', '
* - `allowlist`: DMs require the sender to be in the global `allowFrom`
* list. Distinct from `open` only when `allowFrom` is empty: `allowlist`
* then **fails closed** (no DMs), while `open` still lets anyone DM.
* - `pairing`: same gate as `allowlist`, but a non-listed sender receives a
* one-time pairing code instead of a flat rejection. The owner approves
* via `/approve <code>`, which appends the applicant to `allowFrom` so
* subsequent DMs flow normally. Requires `settings.userId` (the owner's
* platform user ID, used both as approver identity and as the implicit
* "always allowed" sender for an empty allowFrom).
* - `disabled`: ignore all DMs (the sender gets a one-line system reply
* pointing them at @mentioning the bot in a shared channel instead)
*/
@@ -157,8 +163,19 @@ export function makeDmPolicyField(defaults: { policy: DmPolicy }): FieldSchema {
key: 'dmPolicy',
default: defaults.policy,
description: 'channel.dmPolicyHint',
enum: ['open', 'allowlist', 'disabled'],
enumLabels: ['channel.dmPolicyOpen', 'channel.dmPolicyAllowlist', 'channel.dmPolicyDisabled'],
enum: ['open', 'allowlist', 'pairing', 'disabled'],
enumDescriptions: [
'channel.dmPolicyOpenHint',
'channel.dmPolicyAllowlistHint',
'channel.dmPolicyPairingHint',
'channel.dmPolicyDisabledHint',
],
enumLabels: [
'channel.dmPolicyOpen',
'channel.dmPolicyAllowlist',
'channel.dmPolicyPairing',
'channel.dmPolicyDisabled',
],
label: 'channel.dmPolicy',
type: 'string',
};
@@ -223,6 +240,11 @@ export function makeGroupPolicyFields(defaults: { policy: GroupPolicy }): FieldS
default: defaults.policy,
description: 'channel.groupPolicyHint',
enum: ['open', 'allowlist', 'disabled'],
enumDescriptions: [
'channel.groupPolicyOpenHint',
'channel.groupPolicyAllowlistHint',
'channel.groupPolicyDisabledHint',
],
enumLabels: [
'channel.groupPolicyOpen',
'channel.groupPolicyAllowlist',
@@ -262,6 +284,45 @@ export function makeGroupPolicyFields(defaults: { policy: GroupPolicy }): FieldS
];
}
/**
* Like {@link parseIdList} but preserves `name` so writers (e.g. the
* pairing approval flow) can round-trip the operator-facing labels rather
* than collapsing every entry to a bare ID. Same back-compat coverage:
* accepts the current `{ id, name? }[]`, the legacy `string[]`, and the
* original comma-separated string shape.
*/
export function normalizeAllowFromEntries(raw: unknown): Array<{ id: string; name?: string }> {
if (typeof raw === 'string') {
return raw
.split(/[\s,]+/)
.map((id) => id.trim())
.filter(Boolean)
.map((id) => ({ id }));
}
if (Array.isArray(raw)) {
const out: Array<{ id: string; name?: string }> = [];
for (const entry of raw) {
if (typeof entry === 'string') {
const id = entry.trim();
if (id) out.push({ id });
continue;
}
if (entry && typeof entry === 'object' && 'id' in entry) {
const id = (entry as { id?: unknown }).id;
if (typeof id !== 'string' || !id.trim()) continue;
const name = (entry as { name?: unknown }).name;
out.push(
typeof name === 'string' && name.trim()
? { id: id.trim(), name: name.trim() }
: { id: id.trim() },
);
}
}
return out;
}
return [];
}
/**
* Pull the platform IDs out of an allowlist value, regardless of which
* historical shape it has on disk. Three shapes are all valid input:
@@ -365,6 +426,15 @@ export function shouldAllowSender(params: {
return userAllowlist.ids.includes(authorUserId);
}
/**
* Three-state outcome of the DM gate. `pair` is distinct from `reject`
* because the router branches on it (issue a pairing code instead of
* dropping the sender). Existing pass / fail call-sites can keep treating
* `'pair'` as not-allow — the only thing that promotes pairing into a
* useful behaviour is the router's pairing branch.
*/
export type DmDecision = 'allow' | 'pair' | 'reject';
/**
* Gate inbound DM handling. Non-DM threads pass through unconditionally —
* those are governed by `shouldHandleGroup` instead.
@@ -372,28 +442,44 @@ export function shouldAllowSender(params: {
* Callers are expected to apply {@link shouldAllowSender} first, so this
* function only encodes the per-scope semantics:
*
* - `policy='disabled'` → block all DMs.
* - `policy='open'` → allow any sender (the global `allowFrom` filter, if
* - `policy='disabled'` → `'reject'` for everyone.
* - `policy='open'` → `'allow'` (the global `allowFrom` filter, when
* populated, is enforced earlier by the caller).
* - `policy='allowlist'` → require the sender to be in the global
* `userAllowlist`, **and fail closed when the list is empty** (this is
* the only behavioural difference from `open`; `open` lets anyone DM
* when `allowFrom` is empty, `allowlist` does not).
* - `policy='allowlist'` → `'allow'` for senders in `userAllowlist`,
* `'reject'` otherwise. Fails closed when the list is empty (this is
* the only behavioural difference from `open`).
* - `policy='pairing'` → same gate as `allowlist`, but a non-listed sender
* gets `'pair'` instead of `'reject'` so the router can issue a pairing
* code. The owner (`operatorUserId`) is implicitly always allowed —
* without this, a fresh pairing bot with an empty allowFrom would refuse
* its own owner's DMs and they'd be told to ask themselves to approve.
*/
export function shouldHandleDm(params: {
authorUserId: string | undefined;
dmSettings: DmSettings;
isDM: boolean;
/**
* The owning operator's platform user ID (`settings.userId`). Only
* consulted under `pairing` policy, where the owner bypasses the
* allowlist gate so they can DM their own bot before anyone is
* approved. Pass `undefined` for non-pairing policies — the validator
* already enforces presence at save time for pairing.
*/
operatorUserId?: string;
userAllowlist: UserAllowlist;
}): boolean {
const { authorUserId, dmSettings, isDM, userAllowlist } = params;
if (!isDM) return true;
if (dmSettings.policy === 'disabled') return false;
if (dmSettings.policy === 'open') return true;
// allowlist: require non-empty list AND user in it (fail closed otherwise)
if (!authorUserId) return false;
if (userAllowlist.ids.length === 0) return false;
return userAllowlist.ids.includes(authorUserId);
}): DmDecision {
const { authorUserId, dmSettings, isDM, operatorUserId, userAllowlist } = params;
if (!isDM) return 'allow';
if (dmSettings.policy === 'disabled') return 'reject';
if (dmSettings.policy === 'open') return 'allow';
// allowlist & pairing share the same gate; they differ only on miss.
if (!authorUserId) return 'reject';
if (dmSettings.policy === 'pairing' && operatorUserId && authorUserId === operatorUserId) {
return 'allow';
}
const inList = userAllowlist.ids.length > 0 && userAllowlist.ids.includes(authorUserId);
if (inList) return 'allow';
return dmSettings.policy === 'pairing' ? 'pair' : 'reject';
}
/**
@@ -427,6 +513,39 @@ export function shouldHandleGroup(params: {
return candidates.some((id) => groupSettings.allowFrom.includes(id));
}
/**
* Validate cross-platform access-policy settings at save time.
*
* Catches misconfigurations that would silently break runtime gating
* before they hit the DB. Today this only enforces one rule:
*
* - `dmPolicy='pairing'` requires `settings.userId` (the owner's platform
* user ID). Without it nobody can issue `/approve`, so inbound pairing
* requests would land in a permanent limbo — surface the missing field
* at save time so operators don't paint themselves into the corner.
*
* Per-platform rules (e.g. Telegram bot tokens, Discord intents) belong
* in `ClientFactory.validateSettings`; this function only encodes shared
* invariants that apply regardless of platform.
*/
export function validateAccessSettings(
settings: Record<string, unknown> | null | undefined,
): ValidationResult {
const errors: Array<{ field: string; message: string }> = [];
const dmSettings = extractDmSettings(settings);
if (dmSettings.policy === 'pairing') {
const operatorId = (settings?.userId as string | undefined)?.trim();
if (!operatorId) {
errors.push({
field: 'userId',
message:
"Pairing policy requires the owner's Platform User ID. Fill in 'Your Platform User ID' or pick a different DM Policy.",
});
}
}
return errors.length > 0 ? { errors, valid: false } : { valid: true };
}
// ---------- Step-aware reactions ----------
/**
@@ -1,6 +1,7 @@
import { REST } from '@discordjs/rest';
import debug from 'debug';
import {
ApplicationCommandOptionType,
ApplicationCommandType,
ChannelType,
type RESTGetAPIChannelMessageReactionUsersResult,
@@ -225,13 +226,30 @@ export class DiscordApi {
async registerCommands(
applicationId: string,
commands: Array<{ command: string; description: string }>,
commands: Array<{
command: string;
description: string;
options?: Array<{ description: string; name: string; required?: boolean }>;
}>,
): Promise<void> {
log('registerCommands: appId=%s, %d commands', applicationId, commands.length);
await this.rest.put(Routes.applicationCommands(applicationId), {
body: commands.map((c) => ({
description: c.description,
name: c.command,
// Map our generic option schema to Discord's option type. We only
// surface string options today (Crockford-Base32 pairing codes);
// extend the mapping when a new command needs ints/booleans/etc.
...(c.options && c.options.length > 0
? {
options: c.options.map((opt) => ({
description: opt.description,
name: opt.name,
required: opt.required ?? false,
type: ApplicationCommandOptionType.String,
})),
}
: {}),
type: ApplicationCommandType.ChatInput,
})),
});
@@ -386,7 +386,11 @@ class DiscordGatewayClient implements PlatformClient {
}
async registerBotCommands(
commands: Array<{ command: string; description: string }>,
commands: Array<{
command: string;
description: string;
options?: Array<{ description: string; name: string; required?: boolean }>;
}>,
): Promise<void> {
await this.discord.registerCommands(this.applicationId, commands);
log('DiscordBot appId=%s registered %d commands', this.applicationId, commands.length);
@@ -63,6 +63,7 @@ export const schema: FieldSchema[] = [
default: 'queue',
description: 'channel.concurrencyHint',
enum: ['queue', 'debounce'],
enumDescriptions: ['channel.concurrencyQueueHint', 'channel.concurrencyDebounceHint'],
enumLabels: ['channel.concurrencyQueue', 'channel.concurrencyDebounce'],
label: 'channel.concurrency',
type: 'string',
@@ -60,6 +60,10 @@ export const sharedSchema: FieldSchema[] = [
default: DEFAULT_FEISHU_CONNECTION_MODE,
description: 'channel.connectionModeHint',
enum: ['websocket', 'webhook'],
enumDescriptions: [
'channel.connectionModeWebSocketHint',
'channel.connectionModeWebhookHint',
],
enumLabels: ['channel.connectionModeWebSocket', 'channel.connectionModeWebhook'],
label: 'channel.connectionMode',
type: 'string',
@@ -78,6 +82,7 @@ export const sharedSchema: FieldSchema[] = [
default: 'queue',
description: 'channel.concurrencyHint',
enum: ['queue', 'debounce'],
enumDescriptions: ['channel.concurrencyQueueHint', 'channel.concurrencyDebounceHint'],
enumLabels: ['channel.concurrencyQueue', 'channel.concurrencyDebounce'],
label: 'channel.concurrency',
type: 'string',
@@ -13,6 +13,7 @@ export {
allowFromField,
type BotReplyLocale,
displayToolCallsField,
type DmDecision,
type DmPolicy,
type DmSettings,
extractDmSettings,
@@ -24,6 +25,7 @@ export {
type GroupSettings,
makeDmPolicyField,
makeGroupPolicyFields,
normalizeAllowFromEntries,
normalizeBotReplyLocale,
RECEIVED_REACTION_EMOJI,
serverIdField,
@@ -33,6 +35,7 @@ export {
THINKING_REACTION_EMOJI,
type UserAllowlist,
userIdField,
validateAccessSettings,
WORKING_REACTION_EMOJI,
} from './const';
export { PlatformRegistry } from './registry';
@@ -41,6 +41,10 @@ export const schema: FieldSchema[] = [
default: DEFAULT_QQ_CONNECTION_MODE,
description: 'channel.connectionModeHint',
enum: ['websocket', 'webhook'],
enumDescriptions: [
'channel.connectionModeWebSocketHint',
'channel.connectionModeWebhookHint',
],
enumLabels: ['channel.connectionModeWebSocket', 'channel.connectionModeWebhook'],
label: 'channel.connectionMode',
type: 'string',
@@ -59,6 +63,7 @@ export const schema: FieldSchema[] = [
default: 'queue',
description: 'channel.concurrencyHint',
enum: ['queue', 'debounce'],
enumDescriptions: ['channel.concurrencyQueueHint', 'channel.concurrencyDebounceHint'],
enumLabels: ['channel.concurrencyQueue', 'channel.concurrencyDebounce'],
label: 'channel.concurrency',
type: 'string',
@@ -61,6 +61,10 @@ export const schema: FieldSchema[] = [
default: DEFAULT_SLACK_CONNECTION_MODE,
description: 'channel.connectionModeHint',
enum: ['websocket', 'webhook'],
enumDescriptions: [
'channel.connectionModeWebSocketHint',
'channel.connectionModeWebhookHint',
],
enumLabels: ['channel.connectionModeWebSocket', 'channel.connectionModeWebhook'],
label: 'channel.connectionMode',
type: 'string',
@@ -79,6 +83,7 @@ export const schema: FieldSchema[] = [
default: 'queue',
description: 'channel.concurrencyHint',
enum: ['queue', 'debounce'],
enumDescriptions: ['channel.concurrencyQueueHint', 'channel.concurrencyDebounceHint'],
enumLabels: ['channel.concurrencyQueue', 'channel.concurrencyDebounce'],
label: 'channel.concurrency',
type: 'string',
@@ -209,10 +209,19 @@ class TelegramWebhookClient implements PlatformClient {
}
async registerBotCommands(
commands: Array<{ command: string; description: string }>,
commands: Array<{
command: string;
description: string;
// Telegram setMyCommands has no options schema (users type free-form
// text after the command); the field is accepted for interface
// parity with platforms that need it (Discord) and ignored here.
options?: Array<{ description: string; name: string; required?: boolean }>;
}>,
): Promise<void> {
const telegram = new TelegramApi(this.config.credentials.botToken);
await telegram.setMyCommands(commands);
await telegram.setMyCommands(
commands.map((c) => ({ command: c.command, description: c.description })),
);
log('TelegramBot appId=%s registered %d commands', this.applicationId, commands.length);
}
@@ -57,6 +57,7 @@ export const schema: FieldSchema[] = [
default: 'queue',
description: 'channel.concurrencyHint',
enum: ['queue', 'debounce'],
enumDescriptions: ['channel.concurrencyQueueHint', 'channel.concurrencyDebounceHint'],
enumLabels: ['channel.concurrencyQueue', 'channel.concurrencyDebounce'],
label: 'channel.concurrency',
type: 'string',
+17 -1
View File
@@ -43,6 +43,8 @@ export interface FieldSchema {
devOnly?: boolean;
/** Enum options for select fields */
enum?: string[];
/** Per-option help text rendered alongside each enum option (1:1 with `enum`). */
enumDescriptions?: string[];
/** Display labels for enum options */
enumLabels?: string[];
/** Array item schema */
@@ -222,7 +224,21 @@ export interface PlatformClient {
* Optional — platforms that don't support command menus can omit this.
*/
registerBotCommands?: (
commands: Array<{ command: string; description: string }>,
commands: Array<{
command: string;
description: string;
/**
* Argument schema for platforms with structured slash commands
* (Discord, Slack). Without this, Discord registers as zero-arg and
* users have no UI to pass a value — adapters that don't support
* options should silently ignore this field.
*/
options?: Array<{
description: string;
name: string;
required?: boolean;
}>;
}>,
) => Promise<void>;
/**
@@ -23,6 +23,7 @@ export const schema: FieldSchema[] = [
default: 'queue',
description: 'channel.concurrencyHint',
enum: ['queue', 'debounce'],
enumDescriptions: ['channel.concurrencyQueueHint', 'channel.concurrencyDebounceHint'],
enumLabels: ['channel.concurrencyQueue', 'channel.concurrencyDebounce'],
label: 'channel.concurrency',
type: 'string',
+74 -1
View File
@@ -205,10 +205,20 @@ export function renderFinalReply(content: string): string {
* through this map.
*/
type SystemStrings = {
cmdApproveDisabled: string;
cmdApproveFailed: string;
cmdApproveNotOwner: string;
cmdApproveSuccess: (label: string) => string;
cmdApproveUnknownCode: string;
cmdApproveUsage: string;
cmdNewReset: string;
cmdStopNotActive: string;
cmdStopRequested: string;
cmdStopUnable: string;
dmPairingApplicantApproved: string;
dmPairingCapacityExceeded: string;
dmPairingCode: (code: string) => string;
dmPairingUnavailable: string;
dmRejectedAllowlist: string;
dmRejectedDisabled: string;
error: string;
@@ -231,10 +241,23 @@ type SystemStrings = {
const SYSTEM_STRINGS: Partial<Record<BotReplyLocale, SystemStrings>> = {
'en-US': {
cmdApproveDisabled: 'Pairing is not enabled on this bot.',
cmdApproveFailed:
"Couldn't save the approval — the bot's settings may be unavailable. The pairing code is still valid; please try `/approve` again in a moment.",
cmdApproveNotOwner: 'Only the bot owner can approve pairing requests.',
cmdApproveSuccess: (label) => `Approved ${label}.`,
cmdApproveUnknownCode: 'That pairing code is unknown or has expired.',
cmdApproveUsage: 'Usage: `/approve <code>`',
cmdNewReset: 'Conversation reset. Your next message will start a new topic.',
cmdStopNotActive: 'No active execution to stop.',
cmdStopRequested: 'Stop requested.',
cmdStopUnable: 'Unable to stop the current execution.',
dmPairingApplicantApproved: "You've been approved. Send your message again.",
dmPairingCapacityExceeded:
'This bot is handling too many pairing requests right now. Please try again in a few minutes.',
dmPairingCode: (code) =>
`To DM this bot, send this pairing code to the bot's owner: \`${code}\`. They run \`/approve ${code}\` to grant you access. The code expires in 1 hour.`,
dmPairingUnavailable: 'Pairing is temporarily unavailable on this bot. Please try again later.',
dmRejectedAllowlist:
"Sorry, you aren't authorized to send direct messages to this bot. Please contact the bot's owner if you need access.",
dmRejectedDisabled:
@@ -255,10 +278,21 @@ const SYSTEM_STRINGS: Partial<Record<BotReplyLocale, SystemStrings>> = {
toolsCallingHeader: (count, time) => `> total **${count}** tools calling ${time}\n\n`,
},
'zh-CN': {
cmdApproveDisabled: '该机器人未启用配对审批模式。',
cmdApproveFailed: '保存审批失败,机器人设置暂不可用。配对码仍然有效,请稍后重试 `/approve`。',
cmdApproveNotOwner: '只有机器人管理员可以审批配对请求。',
cmdApproveSuccess: (label) => `已审批 ${label}`,
cmdApproveUnknownCode: '该配对码不存在或已过期。',
cmdApproveUsage: '用法:`/approve <配对码>`',
cmdNewReset: '对话已重置,下一条消息会开启新话题。',
cmdStopNotActive: '当前没有正在执行的任务可以停止。',
cmdStopRequested: '已发出停止请求。',
cmdStopUnable: '无法停止当前执行。',
dmPairingApplicantApproved: '已通过审批,请重新发送你的消息。',
dmPairingCapacityExceeded: '该机器人当前待审批请求过多,请稍后再试。',
dmPairingCode: (code) =>
`若要私信该机器人,请把以下配对码发给机器人管理员:\`${code}\`,他们将通过 \`/approve ${code}\` 命令为你授权。配对码 1 小时后失效。`,
dmPairingUnavailable: '配对功能暂时不可用,请稍后再试。',
dmRejectedAllowlist: '抱歉,您没有私信该机器人的权限。如需访问请联系机器人管理员。',
dmRejectedDisabled: '该机器人不接受私信。请在共享频道或群组里 @它来联系。',
error: '**Agent 执行失败**',
@@ -306,10 +340,16 @@ export function renderInlineError(message: string, lng?: BotReplyLocale): string
}
export type CommandReplyKey =
| 'cmdApproveDisabled'
| 'cmdApproveFailed'
| 'cmdApproveNotOwner'
| 'cmdApproveUnknownCode'
| 'cmdApproveUsage'
| 'cmdNewReset'
| 'cmdStopNotActive'
| 'cmdStopRequested'
| 'cmdStopUnable';
| 'cmdStopUnable'
| 'dmPairingApplicantApproved';
/**
* Render a slash-command response (e.g. `/new`, `/stop`). Centralized so the
@@ -319,6 +359,39 @@ export function renderCommandReply(key: CommandReplyKey, lng?: BotReplyLocale):
return getSystemStrings(lng)[key];
}
/**
* Render the owner-facing confirmation when `/approve` succeeds. The label
* is the applicant's display name when known, otherwise their platform
* user ID — owners shouldn't have to do the lookup themselves to know what
* they just approved.
*/
export function renderApproveSuccess(label: string, lng?: BotReplyLocale): string {
return getSystemStrings(lng).cmdApproveSuccess(label);
}
/**
* Render the system message a stranger sees after their first DM when the
* bot is in pairing mode. Variants:
*
* - `code`: a fresh pairing code was issued. Bake the code into the body
* so it's copy-pastable from the chat client without follow-up.
* - `capacity-exceeded`: per-bot pending cap hit; no code created. Tell
* the applicant to retry rather than silently dropping them.
* - `unavailable`: Redis isn't wired (pairing requires it for cross-process
* pending state). Surface the temporary state so the operator can fix
* the deployment instead of debugging mysterious silence.
*/
export function renderDmPairing(
variant: 'capacity-exceeded' | 'code' | 'unavailable',
lng?: BotReplyLocale,
params?: { code?: string },
): string {
const strings = getSystemStrings(lng);
if (variant === 'code' && params?.code) return strings.dmPairingCode(params.code);
if (variant === 'capacity-exceeded') return strings.dmPairingCapacityExceeded;
return strings.dmPairingUnavailable;
}
/**
* Render the system message shown to a sender whose DM was blocked by the
* channel's DM Policy. We split disabled vs allowlist so the user can act on
@@ -1,5 +1,7 @@
import debug from 'debug';
import { getProviderContentPolicyErrorMessage } from '@/business/server/getProviderContentPolicyErrorMessage';
import { trackProviderContentPolicyViolation } from '@/business/server/trackProviderContentPolicyViolation';
import { AsyncTaskModel } from '@/database/models/asyncTask';
import { GenerationModel } from '@/database/models/generation';
import type { LobeChatDatabase } from '@/database/type';
@@ -34,10 +36,8 @@ export async function processBackgroundVideoPolling(
asyncTaskId,
generationBatchId,
generationId,
generationTopicId,
inferenceId,
model,
prechargeResult,
provider,
userId,
} = params;
@@ -107,10 +107,32 @@ export async function processBackgroundVideoPolling(
log('Background video polling error for task: %s', asyncTaskId, error);
const asyncTaskModel = new AsyncTaskModel(db, userId);
const providerContentPolicyMessage = await getProviderContentPolicyErrorMessage({
error,
provider,
userId,
});
if (providerContentPolicyMessage) {
try {
await trackProviderContentPolicyViolation({
error,
model,
provider,
trigger: 'video-polling',
userId,
});
} catch (trackError) {
log('Failed to track provider content policy violation: %O', trackError);
}
}
await asyncTaskModel.update(asyncTaskId, {
error: new AsyncTaskError(
AsyncTaskErrorType.ServerError,
'Background polling failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
providerContentPolicyMessage
? AsyncTaskErrorType.ProviderContentModeration
: AsyncTaskErrorType.ServerError,
providerContentPolicyMessage ??
'Background polling failed: ' +
(error instanceof Error ? error.message : 'Unknown error'),
),
status: AsyncTaskStatus.Error,
});
+6
View File
@@ -117,11 +117,16 @@ export class TaskService {
},
}
: {}),
automationMode: s.automationMode,
blockedBy: depMap.get(s.id),
children: buildSubtaskTree(s.id),
...(s.heartbeatInterval != null ? { heartbeat: { interval: s.heartbeatInterval } } : {}),
identifier: s.identifier,
name: s.name,
priority: s.priority,
...(s.schedulePattern || s.scheduleTimezone
? { schedule: { pattern: s.schedulePattern, timezone: s.scheduleTimezone } }
: {}),
status: s.status,
};
});
@@ -210,6 +215,7 @@ export class TaskService {
return {
author: task.assigneeAgentId ? authorMap.get(task.assigneeAgentId) : undefined,
id: t.topicId ?? undefined,
runningOperation: t.metadata?.runningOperation ?? null,
seq: t.seq,
status: t.status,
summary: handoff?.summary,
+2 -6
View File
@@ -354,13 +354,9 @@ export class TaskLifecycleService {
// Max iterations reached — surface the (failed) result for human accept/retry.
// Type is `result` so the user's `approve` action is treated as a terminal
// accept signal (force-pass) by BriefService.resolve.
// accept signal (force-pass) by BriefService.resolve. Result briefs render
// a fixed single-button UI, so no custom actions are persisted.
await this.briefModel.create({
actions: [
{ key: 'retry', label: '🔄 重试', type: 'resolve' as const },
{ key: 'approve', label: '✅ 强制通过', type: 'resolve' as const },
{ key: 'feedback', label: '💬 修改意见', type: 'comment' as const },
],
priority: 'urgent',
summary: `Review failed after ${iteration} iteration(s) (score: ${reviewResult.overallScore}%). Suggestions: ${reviewResult.suggestions?.join('; ') || 'none'}`,
taskId,
+2 -1
View File
@@ -4,6 +4,7 @@ import type { ExecAgentResult } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import { TopicTrigger } from '@/const/topic';
import { BriefModel } from '@/database/models/brief';
import { TaskModel } from '@/database/models/task';
import { TaskTopicModel } from '@/database/models/taskTopic';
@@ -169,7 +170,7 @@ export class TaskRunnerService {
prompt,
taskId: task.id,
title: extraPrompt ? extraPrompt.slice(0, 100) : task.name || task.identifier,
trigger: 'task',
trigger: TopicTrigger.RunTask,
userInterventionConfig: { approvalMode: 'headless' },
...(continueTopicId && { appContext: { topicId: continueTopicId } }),
});
@@ -23,7 +23,11 @@ const createBriefRuntime = ({
title: string;
type: string;
}) => {
const actions = args.actions || DEFAULT_BRIEF_ACTIONS[args.type] || [];
// 'result' briefs are terminal — the UI hardcodes a single approve action
// and routes it through BriefService.resolve to complete the task. Custom
// actions on result briefs would be ignored, so reject them at the source.
const actions =
args.type === 'result' ? null : args.actions || DEFAULT_BRIEF_ACTIONS[args.type] || [];
const brief = await briefModel.create({
actions,
@@ -903,7 +903,7 @@ describe('ConversationLifecycle actions', () => {
});
describe('@agent mention delegation', () => {
it('should NOT set isSupervisor on assistant message when @agent is mentioned in non-group chat', async () => {
it('should route directly to the single first-line @agent in non-group chat', async () => {
const { result } = renderHook(() => useChatStore());
const sendMessageInServerSpy = vi
@@ -944,9 +944,9 @@ describe('ConversationLifecycle actions', () => {
});
});
// Assistant message metadata should NOT contain isSupervisor
expect(sendMessageInServerSpy).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'agent-a',
newAssistantMessage: expect.objectContaining({
metadata: undefined,
}),
@@ -954,16 +954,130 @@ describe('ConversationLifecycle actions', () => {
expect.any(AbortController),
);
// But runtime should receive mentionedAgents in initialContext
expect(result.current.internal_execAgentRuntime).toHaveBeenCalledWith(
const execCall = (result.current.internal_execAgentRuntime as any).mock.calls[0]?.[0];
expect(execCall?.context.agentId).toBe('agent-a');
expect(execCall?.initialContext?.initialContext?.mentionedAgents).toBeUndefined();
expect(execCall?.initialContext?.initialContext?.injectedManifests).toBeUndefined();
});
it('should keep supervisor callAgent delegation for multiple @agent mentions in non-group chat', async () => {
const { result } = renderHook(() => useChatStore());
const sendMessageInServerSpy = vi
.spyOn(aiChatService, 'sendMessageInServer')
.mockResolvedValue({
messages: [
createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
createMockMessage({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, role: 'assistant' }),
],
topics: [],
assistantMessageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
userMessageId: TEST_IDS.USER_MESSAGE_ID,
} as any);
await act(async () => {
await result.current.sendMessage({
message: '@Agent A @Agent B compare options',
editorData: {
root: {
children: [
{
children: [
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
{ text: ' ', type: 'text' },
{
label: 'Agent B',
metadata: { id: 'agent-b', type: 'agent' },
type: 'mention',
},
{ text: ' compare options', type: 'text' },
],
type: 'paragraph',
},
],
type: 'root',
},
} as any,
context: createTestContext(),
});
});
expect(sendMessageInServerSpy).toHaveBeenCalledWith(
expect.objectContaining({
initialContext: expect.objectContaining({
initialContext: expect.objectContaining({
mentionedAgents: [{ id: 'agent-a', name: 'Agent A' }],
}),
}),
agentId: TEST_IDS.SESSION_ID,
}),
expect.any(AbortController),
);
const execCall = (result.current.internal_execAgentRuntime as any).mock.calls[0]?.[0];
expect(execCall?.context.agentId).toBe(TEST_IDS.SESSION_ID);
expect(execCall?.initialContext?.initialContext?.mentionedAgents).toEqual([
{ id: 'agent-a', name: 'Agent A' },
{ id: 'agent-b', name: 'Agent B' },
]);
expect(execCall?.initialContext?.initialContext?.injectedManifests).toHaveLength(1);
});
it('should keep supervisor callAgent delegation when @agent is not on the first line', async () => {
const { result } = renderHook(() => useChatStore());
const sendMessageInServerSpy = vi
.spyOn(aiChatService, 'sendMessageInServer')
.mockResolvedValue({
messages: [
createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
createMockMessage({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, role: 'assistant' }),
],
topics: [],
assistantMessageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
userMessageId: TEST_IDS.USER_MESSAGE_ID,
} as any);
await act(async () => {
await result.current.sendMessage({
message: 'Please route this\n@Agent A',
editorData: {
root: {
children: [
{
children: [{ text: 'Please route this', type: 'text' }],
type: 'paragraph',
},
{
children: [
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
],
type: 'paragraph',
},
],
type: 'root',
},
} as any,
context: createTestContext(),
});
});
expect(sendMessageInServerSpy).toHaveBeenCalledWith(
expect.objectContaining({
agentId: TEST_IDS.SESSION_ID,
}),
expect.any(AbortController),
);
const execCall = (result.current.internal_execAgentRuntime as any).mock.calls[0]?.[0];
expect(execCall?.context.agentId).toBe(TEST_IDS.SESSION_ID);
expect(execCall?.initialContext?.initialContext?.mentionedAgents).toEqual([
{ id: 'agent-a', name: 'Agent A' },
]);
expect(execCall?.initialContext?.initialContext?.injectedManifests).toHaveLength(1);
});
it('should NOT inject mentionedAgents into initialContext when in group chat', async () => {
@@ -979,15 +1093,17 @@ describe('ConversationLifecycle actions', () => {
},
} as any);
vi.spyOn(aiChatService, 'sendMessageInServer').mockResolvedValue({
messages: [
createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
createMockMessage({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, role: 'assistant' }),
],
topics: [],
assistantMessageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
userMessageId: TEST_IDS.USER_MESSAGE_ID,
} as any);
const sendMessageInServerSpy = vi
.spyOn(aiChatService, 'sendMessageInServer')
.mockResolvedValue({
messages: [
createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
createMockMessage({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, role: 'assistant' }),
],
topics: [],
assistantMessageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
userMessageId: TEST_IDS.USER_MESSAGE_ID,
} as any);
await act(async () => {
await result.current.sendMessage({
@@ -1020,8 +1136,16 @@ describe('ConversationLifecycle actions', () => {
});
});
expect(sendMessageInServerSpy).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'sub-agent-id',
}),
expect.any(AbortController),
);
// Runtime should NOT receive mentionedAgents in group context
const execCall = (result.current.internal_execAgentRuntime as any).mock.calls[0]?.[0];
expect(execCall?.context.agentId).toBe('sub-agent-id');
const initialCtx = execCall?.initialContext?.initialContext;
expect(initialCtx?.mentionedAgents).toBeUndefined();
});
@@ -12,6 +12,7 @@ export {
parseMentionedAgentsFromEditorData,
parseSelectedSkillsFromEditorData,
parseSelectedToolsFromEditorData,
parseSingleFirstLineAgentMentionDirectRoute,
} from './parseCommands';
export type { CommandSendOverrides } from './types';
@@ -5,6 +5,7 @@ import {
parseMentionedAgentsFromEditorData,
parseSelectedSkillsFromEditorData,
parseSelectedToolsFromEditorData,
parseSingleFirstLineAgentMentionDirectRoute,
} from './parseCommands';
describe('parseCommandsFromEditorData', () => {
@@ -500,3 +501,139 @@ describe('parseMentionedAgentsFromEditorData', () => {
]);
});
});
describe('parseSingleFirstLineAgentMentionDirectRoute', () => {
it('should return target agent when the only agent mention starts the first non-empty paragraph', () => {
const editorData = {
root: {
children: [
{
children: [{ text: ' ', type: 'text' }],
type: 'paragraph',
},
{
children: [
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
{ text: ' please handle this', type: 'text' },
],
type: 'paragraph',
},
],
type: 'root',
},
};
expect(parseSingleFirstLineAgentMentionDirectRoute(editorData)).toEqual({
targetAgent: { id: 'agent-a', name: 'Agent A' },
});
});
it('should return undefined when there are multiple agent mention occurrences', () => {
const editorData = {
root: {
children: [
{
children: [
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
{ text: ' and ', type: 'text' },
{
label: 'Agent B',
metadata: { id: 'agent-b', type: 'agent' },
type: 'mention',
},
],
type: 'paragraph',
},
],
type: 'root',
},
};
expect(parseSingleFirstLineAgentMentionDirectRoute(editorData)).toBeUndefined();
});
it('should return undefined when another non-agent mention appears later', () => {
const editorData = {
root: {
children: [
{
children: [
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
{ text: ' compare with ', type: 'text' },
{
label: 'Topic X',
metadata: { id: 'topic-x', topicId: 'topic-x', type: 'topic' },
type: 'mention',
},
],
type: 'paragraph',
},
],
type: 'root',
},
};
expect(parseSingleFirstLineAgentMentionDirectRoute(editorData)).toBeUndefined();
});
it('should return undefined when the first non-empty paragraph does not start with the mention', () => {
const editorData = {
root: {
children: [
{
children: [
{ text: 'Please ask ', type: 'text' },
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
],
type: 'paragraph',
},
],
type: 'root',
},
};
expect(parseSingleFirstLineAgentMentionDirectRoute(editorData)).toBeUndefined();
});
it('should return undefined when the mention is not in the first non-empty paragraph', () => {
const editorData = {
root: {
children: [
{
children: [{ text: 'First line', type: 'text' }],
type: 'paragraph',
},
{
children: [
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
],
type: 'paragraph',
},
],
type: 'root',
},
};
expect(parseSingleFirstLineAgentMentionDirectRoute(editorData)).toBeUndefined();
});
});
@@ -17,6 +17,21 @@ export interface ParsedActionTag {
export interface ParsedCommand extends ParsedActionTag {}
interface MentionNodeMatch {
agent: RuntimeMentionedAgent;
node: any;
}
interface MentionNodeOccurrence {
label: string;
metadata: Record<string, unknown>;
node: any;
}
export interface SingleFirstLineAgentMentionDirectRoute {
targetAgent: RuntimeMentionedAgent;
}
/**
* Walk the Lexical JSON tree to find all action-tag nodes.
* Returns the extracted action tags in document order.
@@ -92,20 +107,34 @@ export const parseMentionedAgentsFromEditorData = (
): RuntimeMentionedAgent[] => {
if (!editorData) return [];
const agents: RuntimeMentionedAgent[] = [];
const seen = new Set<string>();
const mentions = collectAgentMentionOccurrences(editorData.root);
walkMentionNode(editorData.root, (label, metadata) => {
// Only accept explicit agent mentions — skip topics, ALL_MEMBERS, and other types
if (metadata?.type !== 'agent') return;
const id = metadata?.id as string | undefined;
if (!id || seen.has(id)) return;
return mentions.reduce<RuntimeMentionedAgent[]>((agents, mention) => {
if (seen.has(mention.agent.id)) return agents;
seen.add(id);
agents.push({ id, name: label || id });
});
seen.add(mention.agent.id);
agents.push(mention.agent);
return agents;
return agents;
}, []);
};
export const parseSingleFirstLineAgentMentionDirectRoute = (
editorData: Record<string, any> | undefined,
): SingleFirstLineAgentMentionDirectRoute | undefined => {
if (!editorData) return;
const allMentions = collectMentionOccurrences(editorData.root);
if (allMentions.length !== 1) return;
const mentions = collectAgentMentionOccurrences(editorData.root);
if (mentions.length !== 1) return;
const firstMeaningfulNode = findFirstMeaningfulNode(editorData.root);
if (firstMeaningfulNode !== mentions[0].node) return;
return { targetAgent: mentions[0].agent };
};
/**
@@ -132,13 +161,54 @@ function collectText(node: any, out: string[]): void {
}
}
function collectAgentMentionOccurrences(node: any): MentionNodeMatch[] {
const mentions: MentionNodeMatch[] = [];
for (const mention of collectMentionOccurrences(node)) {
// Only accept explicit agent mentions — skip topics, ALL_MEMBERS, and other types
if (mention.metadata?.type !== 'agent') continue;
const id = mention.metadata?.id as string | undefined;
if (!id) continue;
mentions.push({
agent: { id, name: mention.label || id },
node: mention.node,
});
}
return mentions;
}
function collectMentionOccurrences(node: any): MentionNodeOccurrence[] {
const mentions: MentionNodeOccurrence[] = [];
walkMentionNode(node, (mentionNode, label, metadata) => {
mentions.push({ label, metadata, node: mentionNode });
});
return mentions;
}
function findFirstMeaningfulNode(node: any): any | undefined {
if (!node) return;
if (node.type === 'text') {
return typeof node.text === 'string' && node.text.trim().length > 0 ? node : undefined;
}
if (node.type === 'mention' || node.type === 'action-tag') return node;
if (Array.isArray(node.children)) {
for (const child of node.children) {
const meaningfulNode = findFirstMeaningfulNode(child);
if (meaningfulNode) return meaningfulNode;
}
}
}
function walkMentionNode(
node: any,
cb: (label: string, metadata: Record<string, unknown>) => void,
cb: (node: any, label: string, metadata: Record<string, unknown>) => void,
): void {
if (!node) return;
if (node.type === 'mention' && node.metadata) {
cb(node.label ?? '', node.metadata);
cb(node, node.label ?? '', node.metadata);
}
if (Array.isArray(node.children)) {
for (const child of node.children) {
@@ -54,6 +54,7 @@ import {
parseMentionedAgentsFromEditorData,
parseSelectedSkillsFromEditorData,
parseSelectedToolsFromEditorData,
parseSingleFirstLineAgentMentionDirectRoute,
processCommands,
} from './commandBus';
/**
@@ -134,9 +135,12 @@ export class ConversationLifecycleActionImpl {
const selectedSkills = parseSelectedSkillsFromEditorData(editorData);
const selectedTools = parseSelectedToolsFromEditorData(editorData);
const mentionedAgents = parseMentionedAgentsFromEditorData(editorData);
const directMentionRoute = !context.groupId
? parseSingleFirstLineAgentMentionDirectRoute(editorData)
: undefined;
// Use context from params (required)
const { agentId } = context;
const requestedAgentId = context.agentId;
// If creating new thread (isNew + scope='thread'), threadId will be created by server
const isCreatingNewThread = context.isNew && context.scope === 'thread';
// Build newThread params for server from new context format
@@ -149,7 +153,7 @@ export class ConversationLifecycleActionImpl {
}
: undefined;
if (!agentId) return;
if (!requestedAgentId) return;
// ── Command Bus: extract and process built-in commands from editorData ──
const commandOverrides: CommandSendOverrides = processCommands({
@@ -198,18 +202,25 @@ export class ConversationLifecycleActionImpl {
context = { ...context, topicId: undefined };
}
// When creating new thread, override threadId to undefined (server will create it)
// Direct first-line @agent routes this turn to the target agent instead of
// asking the current agent to delegate through callAgent.
const agentId = directMentionRoute?.targetAgent.id ?? context.agentId;
if (!agentId) return;
// Check if current agentId is the supervisor agent of the group
let isGroupSupervisor = false;
if (context.groupId) {
const group = agentGroupByIdSelectors.groupById(context.groupId)(getChatGroupStoreState());
isGroupSupervisor = group?.supervisorAgentId === agentId;
}
// In non-group context, @agent mentions make the current agent act as supervisor
const hasMentionedAgents = !context.groupId && mentionedAgents.length > 0;
// In non-group context, non-direct @agent mentions make the current agent act as supervisor
const hasMentionedAgents =
!context.groupId && !directMentionRoute && mentionedAgents.length > 0;
const operationContext = {
...context,
agentId,
// When creating new thread, override threadId to undefined (server will create it)
...(isCreatingNewThread && { threadId: undefined }),
// Only set isSupervisor for actual group supervisors — NOT for @agent mentions.
// isSupervisor triggers group-specific UI rendering (SupervisorMessage with group avatars).
@@ -270,7 +281,7 @@ export class ConversationLifecycleActionImpl {
// Use provided messages or query from store
// For /newTopic from existing topic, start with empty message list (fresh topic)
const contextKey = messageMapKey(context);
const contextKey = messageMapKey(operationContext);
const messages = forceNewTopicFromExisting
? []
: (inputMessages ?? displayMessageSelectors.getDisplayMessagesByKey(contextKey)(this.#get()));