feat: optimize bot cli & userId guide (#14258)

* chore: add userId and serverId tooltip guide

* feat: update built in message tool

*  feat(cli): add bot dm-policy / allowlist subcommands (LOBE-8254)

Extend `lh bot update` with --dm-policy / --group-policy / --user-id /
--server-id, and add new `lh bot allowlist` and `lh bot group-allowlist`
subcommand groups (list/add/remove/clear). All write paths read existing
settings first and merge so unrelated keys aren't wiped by the partial
update.

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

*  feat(channel): warn when a saved bot is missing the operator userId

Surface an inline alert and auto-expand the Advanced Settings group when an
existing bot has no settings.userId — without it AI tools can't push
notifications back to the operator and pairing approvals fail silently.
Skip on first-time configs and on platforms that don't expose userId.

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

* chore: optimize userId alert

* fix: test case

* fix: footer effective userId

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rdmclin2
2026-04-28 15:14:51 +07:00
committed by GitHub
parent 2835b99d1a
commit e896024b68
24 changed files with 730 additions and 105 deletions
+1 -1
View File
@@ -166,7 +166,7 @@ Each platform exposes a `PlatformDefinition` registered in `platforms/index.ts`:
}
```
`schema` drives both server validation (`mergeWithDefaults`, `extractDefaults`) **and** the auto-generated UI form. Top-level keys `applicationId` / `credentials` / `settings` map to DB columns. Common settings fields live in `platforms/const.ts` (`displayToolCallsField`, `serverIdField`, `userIdField`).
`schema` drives both server validation (`mergeWithDefaults`, `extractDefaults`) **and** the auto-generated UI form. Top-level keys `applicationId` / `credentials` / `settings` map to DB columns. Common settings fields live in `platforms/const.ts` (`displayToolCallsField`, `makeServerIdField(platform?)`, `makeUserIdField(platform?)`). The `serverId` / `userId` factories take a platform identifier so the field's hint can render platform-specific "how to find this ID" guidance (Discord Developer Mode, Telegram @userinfobot, etc.); pass no argument to fall back to generic copy.
Each platform implements `PlatformClient` (see `platforms/types.ts`):
+256 -1
View File
@@ -7,7 +7,54 @@ import { confirm, outputJson, printBoxTable, printTable, timeAgo } from '../util
import { log } from '../utils/logger';
import { registerBotMessageCommands } from './botMessage';
// ── Helpers ──────────────────────────────────────────────
// ── Access policy helpers ──────────────────────────────
const DM_POLICIES = ['open', 'allowlist', 'pairing', 'disabled'] as const;
const GROUP_POLICIES = ['open', 'allowlist', 'disabled'] as const;
type DmPolicy = (typeof DM_POLICIES)[number];
type GroupPolicy = (typeof GROUP_POLICIES)[number];
interface AllowEntry {
id: string;
name?: string;
}
/**
* Normalize an allow-list value into `{id, name?}[]`. Mirrors the server-side
* back-compat parser — `settings.allowFrom` may be on disk as a comma-separated
* string, a bare `string[]`, or the current `{id, name?}[]` shape. The CLI
* needs the canonical form before push/filter operations and before sending
* back to the server.
*/
function normalizeAllowList(raw: unknown): AllowEntry[] {
if (typeof raw === 'string') {
return raw
.split(/[\s,]+/)
.map((id) => id.trim())
.filter(Boolean)
.map((id) => ({ id }));
}
if (!Array.isArray(raw)) return [];
const out: AllowEntry[] = [];
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;
}
function maskValue(val: string): string {
if (val.length > 8) return val.slice(0, 4) + '****' + val.slice(-4);
@@ -78,6 +125,150 @@ async function resolvePlatform(client: TrpcClient, platformId: string) {
return def;
}
// ── Allowlist subcommand factory ────────────────────────
interface AllowlistGroupOptions {
/** Description shown by `lh bot <name> --help`. */
description: string;
/** Settings field to mutate — `allowFrom` (user IDs) or `groupAllowFrom` (channel IDs). */
fieldKey: 'allowFrom' | 'groupAllowFrom';
/** Human-friendly description of what the `<id>` arg represents. */
idLabel: string;
/** Subcommand group name (`allowlist` or `group-allowlist`). */
name: string;
}
/**
* Build a `list / add / remove / clear` subcommand group around an
* array-typed settings field (`allowFrom` or `groupAllowFrom`). All write
* paths read existing settings first and merge — passing only a partial
* `settings` object to the TRPC `update` would replace the whole JSONB
* column and silently drop unrelated fields.
*/
function registerAllowlistCommand(bot: Command, opts: AllowlistGroupOptions) {
const group = bot.command(opts.name).description(opts.description);
// Read the current entries off a freshly-fetched bot row.
const readEntries = (bot: any): AllowEntry[] =>
normalizeAllowList((bot.settings as Record<string, unknown> | null)?.[opts.fieldKey]);
// Build the next settings payload from existing settings + the new entries.
const buildPayload = (bot: any, nextEntries: AllowEntry[]) => ({
id: bot.id,
settings: {
...(bot.settings as Record<string, unknown>),
[opts.fieldKey]: nextEntries,
},
});
group
.command('list <botId>')
.description(`List ${opts.fieldKey} entries`)
.option('--json', 'Output JSON')
.action(async (botId: string, options: { json?: boolean }) => {
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
if (options.json) {
outputJson(entries);
return;
}
if (entries.length === 0) {
console.log(`${pc.dim(`No ${opts.fieldKey} entries.`)}`);
return;
}
printTable(
entries.map((e) => [e.id, e.name ?? pc.dim('-')]),
['ID', 'NAME'],
);
});
group
.command('add <botId> <id>')
.description(`Add a ${opts.idLabel} to ${opts.fieldKey}`)
.option('--name <name>', 'Optional human-friendly label so you can recognise the entry later')
.action(async (botId: string, id: string, options: { name?: string }) => {
const trimmedId = id.trim();
if (!trimmedId) {
log.error('ID cannot be empty.');
process.exit(1);
return;
}
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
if (entries.some((e) => e.id === trimmedId)) {
log.info(`${trimmedId} is already on the ${opts.fieldKey} list — nothing to do.`);
return;
}
const trimmedName = options.name?.trim();
const next = [
...entries,
trimmedName ? { id: trimmedId, name: trimmedName } : { id: trimmedId },
];
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
console.log(
`${pc.green('✓')} Added ${pc.bold(trimmedId)}${trimmedName ? ` (${trimmedName})` : ''} to ${opts.fieldKey} (now ${next.length} entr${next.length === 1 ? 'y' : 'ies'})`,
);
});
group
.command('remove <botId> <id>')
.description(`Remove a ${opts.idLabel} from ${opts.fieldKey}`)
.action(async (botId: string, id: string) => {
const trimmedId = id.trim();
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
const next = entries.filter((e) => e.id !== trimmedId);
if (next.length === entries.length) {
log.info(`${trimmedId} is not on the ${opts.fieldKey} list — nothing to do.`);
return;
}
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
console.log(
`${pc.green('✓')} Removed ${pc.bold(trimmedId)} from ${opts.fieldKey} (${next.length} entr${next.length === 1 ? 'y' : 'ies'} left)`,
);
});
group
.command('clear <botId>')
.description(`Clear all entries from ${opts.fieldKey}`)
.option('--yes', 'Skip confirmation prompt')
.action(async (botId: string, options: { yes?: boolean }) => {
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
if (entries.length === 0) {
log.info(`${opts.fieldKey} is already empty — nothing to do.`);
return;
}
if (!options.yes) {
const confirmed = await confirm(
`Clear all ${entries.length} ${opts.fieldKey} entr${entries.length === 1 ? 'y' : 'ies'} from this bot?`,
);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
await client.agentBotProvider.update.mutate(buildPayload(b, []) as any);
console.log(`${pc.green('✓')} Cleared ${opts.fieldKey} on bot ${pc.bold(botId)}`);
});
}
// ── Command Registration ─────────────────────────────────
export function registerBotCommand(program: Command) {
@@ -313,6 +504,16 @@ export function registerBotCommand(program: Command) {
.option('--verification-token <token>', 'New verification token')
.option('--app-id <appId>', 'New application ID')
.option('--platform <platform>', 'New platform')
.option(
'--dm-policy <policy>',
`DM access policy (${DM_POLICIES.join('|')}). 'pairing' requires --user-id.`,
)
.option('--group-policy <policy>', `Group/channel access policy (${GROUP_POLICIES.join('|')})`)
.option(
'--user-id <id>',
"Owner's platform user ID (required for --dm-policy=pairing; auto-trusts the operator in the global allowlist)",
)
.option('--server-id <id>', 'Default server / guild / workspace ID for AI tool calls')
.action(
async (
botId: string,
@@ -321,11 +522,15 @@ export function registerBotCommand(program: Command) {
appSecret?: string;
botId?: string;
botToken?: string;
dmPolicy?: string;
encryptKey?: string;
groupPolicy?: string;
platform?: string;
publicKey?: string;
secretToken?: string;
serverId?: string;
signingSecret?: string;
userId?: string;
verificationToken?: string;
webhookProxyUrl?: string;
},
@@ -342,6 +547,40 @@ export function registerBotCommand(program: Command) {
if (options.appId) input.applicationId = options.appId;
if (options.platform) input.platform = options.platform;
// ── Settings (DM / group policy + identity fields) ────────────
// Read-modify-write so we don't wipe `allowFrom`, `groupAllowFrom`,
// or any other settings field the operator already configured.
const settingsPatch: Record<string, unknown> = {};
if (options.dmPolicy !== undefined) {
if (!(DM_POLICIES as readonly string[]).includes(options.dmPolicy)) {
log.error(
`Invalid --dm-policy "${options.dmPolicy}". Must be one of: ${DM_POLICIES.join(', ')}`,
);
process.exit(1);
return;
}
settingsPatch.dmPolicy = options.dmPolicy as DmPolicy;
}
if (options.groupPolicy !== undefined) {
if (!(GROUP_POLICIES as readonly string[]).includes(options.groupPolicy)) {
log.error(
`Invalid --group-policy "${options.groupPolicy}". Must be one of: ${GROUP_POLICIES.join(', ')}`,
);
process.exit(1);
return;
}
settingsPatch.groupPolicy = options.groupPolicy as GroupPolicy;
}
if (options.userId !== undefined) settingsPatch.userId = options.userId;
if (options.serverId !== undefined) settingsPatch.serverId = options.serverId;
if (Object.keys(settingsPatch).length > 0) {
input.settings = {
...(existing.settings as Record<string, unknown>),
...settingsPatch,
};
}
if (Object.keys(input).length <= 1) {
log.error('No changes specified.');
process.exit(1);
@@ -353,6 +592,22 @@ export function registerBotCommand(program: Command) {
},
);
// ── allowlist (DM / group user gate) ──────────────────
registerAllowlistCommand(bot, {
description: 'Manage the global user allowlist (gates DMs and group @mentions)',
fieldKey: 'allowFrom',
idLabel: 'platform user ID',
name: 'allowlist',
});
registerAllowlistCommand(bot, {
description: 'Manage the group/channel allowlist (used when groupPolicy=allowlist)',
fieldKey: 'groupAllowFrom',
idLabel: 'channel / group / thread ID',
name: 'group-allowlist',
});
// ── remove ────────────────────────────────────────────
bot
+9
View File
@@ -125,6 +125,8 @@
"channel.secretTokenPlaceholder": "Optional secret for webhook verification",
"channel.serverId": "Default Server ID",
"channel.serverIdHint": "Default server / guild AI tools act on; not used for access control",
"channel.serverIdHint.discord": "Enable Developer Mode (Settings → Advanced), then right-click the server icon → Copy Server ID.",
"channel.serverIdHint.slack": "Workspace ID (starts with T). Find it under Settings & administration → Workspace settings, or in the workspace URL.",
"channel.settings": "Advanced Settings",
"channel.settingsResetConfirm": "Are you sure you want to reset advanced settings to default?",
"channel.settingsResetDefault": "Reset to Default",
@@ -152,6 +154,13 @@
"channel.updateFailed": "Failed to update status",
"channel.userId": "Your Platform User ID",
"channel.userIdHint": "Lets AI tools reach you proactively (e.g. reminders); auto-trusted by the global allowlist",
"channel.userIdHint.discord": "Enable Developer Mode (Settings → Advanced), then right-click your avatar → Copy User ID.",
"channel.userIdHint.feishu": "Open your app on the Feishu / Lark Open Platform → Permissions, then look up your Open ID.",
"channel.userIdHint.qq": "Your QQ number, shown on your QQ profile page.",
"channel.userIdHint.slack": "Open your Slack profile → ⋮ More → Copy member ID (starts with U).",
"channel.userIdHint.telegram": "Send any message to @userinfobot in Telegram — it replies with your numeric User ID.",
"channel.userIdMissingDesc": "Without it, AI tools can't reach you with reminders, and pairing approvals will fail. Fill it in under Advanced Settings.",
"channel.userIdMissingTitle": "Add your platform User ID",
"channel.validationError": "Please fill in Application ID and Token",
"channel.verificationToken": "Verification Token",
"channel.verificationTokenHint": "Optional. Used to verify webhook event source.",
+9
View File
@@ -125,6 +125,8 @@
"channel.secretTokenPlaceholder": "可选的 Webhook 验证密钥",
"channel.serverId": "默认服务器 ID",
"channel.serverIdHint": "AI 工具默认作用的服务器 / Guild,与访问控制无关",
"channel.serverIdHint.discord": "在 Discord 设置 → 高级中开启开发者模式,然后右键服务器图标 → 复制服务器 ID。",
"channel.serverIdHint.slack": "Workspace ID(以 T 开头),在 设置与管理 → 工作空间设置 中查看,或从工作空间 URL 中获取。",
"channel.settings": "高级设置",
"channel.settingsResetConfirm": "确定要将高级设置恢复为默认配置吗?",
"channel.settingsResetDefault": "恢复默认配置",
@@ -152,6 +154,13 @@
"channel.updateFailed": "更新状态失败",
"channel.userId": "你的平台用户 ID",
"channel.userIdHint": "供 AI 工具主动联系你(如提醒、通知),并自动加入全局白名单",
"channel.userIdHint.discord": "在 Discord 设置 → 高级中开启开发者模式,然后右键你的头像 → 复制用户 ID。",
"channel.userIdHint.feishu": "在飞书 / Lark 开放平台打开你的应用 → 权限管理,查看你的 Open ID。",
"channel.userIdHint.qq": "你的 QQ 号,在 QQ 资料页可见。",
"channel.userIdHint.slack": "打开 Slack 个人资料 → ⋮ 更多 → 复制 Member ID(以 U 开头)。",
"channel.userIdHint.telegram": "在 Telegram 中给 @userinfobot 发送任意消息,它会回复你的数字 User ID。",
"channel.userIdMissingDesc": "未填写时,AI 工具无法主动联系你,配对申请也将无法被批准。请在「高级设置」中补充。",
"channel.userIdMissingTitle": "建议补充你的平台用户 ID",
"channel.validationError": "请填写应用 ID 和 Token",
"channel.verificationToken": "Verification Token",
"channel.verificationTokenHint": "可选。用于验证事件推送来源。",
@@ -153,6 +153,7 @@ export interface BotProviderQuery {
applicationId: string;
credentials: Record<string, string>;
platform: string;
settings?: Record<string, unknown>;
}) => Promise<{ id: string; platform: string }>;
deleteBot: (botId: string) => Promise<void>;
getBotDetail: (botId: string) => Promise<GetBotDetailState | null>;
+84 -3
View File
@@ -5,6 +5,80 @@ import { MessageApiName, MessageToolIdentifier } from './types';
const platformEnum = ['discord', 'telegram', 'slack', 'feishu', 'lark', 'qq', 'wechat'];
/**
* Schema for the bot's `settings` JSON column. Both `createBot` and
* `updateBot` accept a partial object — only the keys you pass are written
* (everything else preserved). Use this as the single source of truth for
* what the AI is allowed to toggle on a bot.
*/
const botSettingsSchema = {
additionalProperties: true,
properties: {
allowFrom: {
description:
'Global user-ID allowlist. When non-empty, ONLY listed users may interact with the bot anywhere — DMs, group @mentions, threads — regardless of dmPolicy/groupPolicy. Empty array means "no user-level filter". Pass the FULL desired list (this field is overwrite-replace, not append): to add or remove a single user, first call getBotDetail to read settings.allowFrom, mutate locally, then write back the entire array.',
items: {
additionalProperties: false,
properties: {
id: {
description: 'Platform user ID (e.g. Discord snowflake, Telegram user_id)',
type: 'string',
},
name: {
description:
'Optional human-friendly label so the operator can recognise the entry later (e.g. "Ada from Product"). Runtime ignores this; only id is matched.',
type: 'string',
},
},
required: ['id'],
type: 'object',
},
type: 'array',
},
dmPolicy: {
description:
'Direct-message gate. open=accept DMs from anyone (default); allowlist=only users in allowFrom can DM, fails closed if list is empty; pairing=non-listed senders get a one-time code and the owner runs /approve <code> to add them; disabled=ignore all DMs. pairing requires settings.userId (owner platform ID).',
enum: ['open', 'allowlist', 'pairing', 'disabled'],
type: 'string',
},
groupAllowFrom: {
description:
'Channel/group/thread ID allowlist for group traffic. Only consulted when groupPolicy="allowlist". Same overwrite-replace semantics as allowFrom — read-modify-write to add/remove entries.',
items: {
additionalProperties: false,
properties: {
id: {
description:
'Channel / group / thread ID (e.g. Discord channel ID copied via "Copy Channel ID")',
type: 'string',
},
name: { description: 'Optional human-friendly label.', type: 'string' },
},
required: ['id'],
type: 'object',
},
type: 'array',
},
groupPolicy: {
description:
'Group/channel @mention gate. open=respond to @mentions in any channel (default); allowlist=respond only in channels listed in groupAllowFrom; disabled=ignore all non-DM traffic.',
enum: ['open', 'allowlist', 'disabled'],
type: 'string',
},
serverId: {
description:
'Default server / guild / workspace ID used when the AI calls listChannels/getMemberInfo without an explicit serverId. Optional; populated automatically once the bot has been used in a server.',
type: 'string',
},
userId: {
description:
"The bot owner's platform user ID. Required when dmPolicy='pairing' (used as approver identity and as an implicit member of allowFrom). Also used to push owner-only notifications.",
type: 'string',
},
},
type: 'object',
};
export const MessageManifest: BuiltinToolManifest = {
api: [
// ==================== Direct Messaging ====================
@@ -581,13 +655,19 @@ export const MessageManifest: BuiltinToolManifest = {
enum: platformEnum,
type: 'string',
},
settings: {
...botSettingsSchema,
description:
'Optional initial settings (DM policy, allowlists, owner userId, etc.). Omit to use schema defaults — open DMs, no allowlist. See field descriptions for each key.',
},
},
required: ['platform', 'agentId', 'applicationId', 'credentials'],
type: 'object',
},
},
{
description: 'Update credentials or settings of an existing bot integration.',
description:
'Update credentials or settings of an existing bot integration. Use this to adjust DM policy (e.g. switch to pairing mode), edit the allowlist, or rotate credentials. Settings is merged at the key level — only keys you pass are written. For array fields like allowFrom/groupAllowFrom, the array is REPLACED, not merged: read-modify-write via getBotDetail before adding/removing entries.',
name: MessageApiName.updateBot,
parameters: {
additionalProperties: false,
@@ -601,8 +681,9 @@ export const MessageManifest: BuiltinToolManifest = {
type: 'object',
},
settings: {
description: 'Updated settings (partial update)',
type: 'object',
...botSettingsSchema,
description:
'Updated settings (partial update at the key level). See nested field descriptions for the allowed keys (dmPolicy, allowFrom, userId, groupPolicy, groupAllowFrom, serverId).',
},
},
required: ['botId'],
@@ -13,14 +13,43 @@ export const systemPrompt = `You have access to a Message tool that provides uni
<bot_management>
1. **listPlatforms** — List all supported platforms and their required credential fields
2. **listBots** — List configured bots for the current agent (with runtime status)
3. **getBotDetail** — Get detailed info about a specific bot
4. **createBot** — Create a new bot integration (requires agentId, platform, applicationId, credentials)
5. **updateBot** — Update bot credentials or settings
3. **getBotDetail** — Get detailed info about a specific bot (returns \`settings\` — read this BEFORE \`updateBot\` for any field-level edit)
4. **createBot** — Create a new bot integration (requires agentId, platform, applicationId, credentials; optional initial settings)
5. **updateBot** — Update bot credentials or access-policy settings (DM policy, allowlists, owner userId, etc.)
6. **deleteBot** — Remove a bot integration
7. **toggleBot** — Enable or disable a bot
8. **connectBot** — Start a bot (establish connection to the platform)
</bot_management>
<access_policies>
The bot's \`settings\` JSON column controls **who can talk to the bot** on every platform. Use \`updateBot({ botId, settings: {...} })\` to change any of the keys below. Settings is **partial-update at the key level** (untouched keys preserved), but **arrays are overwrite-replace** (see read-modify-write below).
**dmPolicy** — gate inbound 1:1 DMs:
- \`open\` (default): anyone can DM the bot
- \`allowlist\`: only users in \`allowFrom\` can DM (fails closed when list is empty)
- \`pairing\`: same as allowlist, but a non-listed sender receives a one-time code; the owner runs \`/approve <code>\` in their own DM to add the applicant. **Requires \`settings.userId\`** (owner's platform user ID) — without it the validator rejects the save.
- \`disabled\`: ignore all DMs
Typical asks → action:
- "lock my bot down so only I can DM" → \`updateBot({ settings: { dmPolicy: 'pairing', userId: '<owner platform ID>' } })\`
- "let anyone DM again" → \`updateBot({ settings: { dmPolicy: 'open' } })\`
- "stop accepting DMs for now" → \`updateBot({ settings: { dmPolicy: 'disabled' } })\`
**allowFrom** — global user-ID allowlist, format \`[{ id, name? }]\`. When non-empty, applies to **every** inbound surface (DM, group, threads), regardless of dmPolicy/groupPolicy. The runtime only matches \`id\`; \`name\` is an operator-facing label so the human can recognise the entry months later — always include a name when you have one (display name, handle, etc.).
**groupPolicy** + **groupAllowFrom** — same shape but for group/channel/thread traffic. \`groupAllowFrom\` items are channel/group/thread IDs (e.g. Discord channel IDs from "Copy Channel ID"), not user IDs.
**Read-modify-write for allowFrom and groupAllowFrom (CRITICAL):**
Both arrays are written as a whole — passing \`{ allowFrom: [{ id: 'X' }] }\` REPLACES the entire list, not appends. To add or remove a single entry:
1. Call \`getBotDetail(botId)\` and read \`settings.allowFrom\` (it may be missing — treat as \`[]\`).
2. Mutate the array locally (\`push\` to add, \`filter\` to remove). Preserve every existing \`{ id, name }\` you didn't intend to touch.
3. Call \`updateBot({ botId, settings: { allowFrom: [...newArray] } })\`.
Skipping step 1 will silently wipe other entries. Same workflow applies to \`groupAllowFrom\`.
**Validation behaviour:** the server validates settings before persisting and returns \`updateBot error: <field>: <reason>\` when something fails (e.g. \`userId: Pairing policy requires the owner's Platform User ID.\`). Surface that message to the user and ask for the missing value rather than retrying blindly.
</access_policies>
<messaging_capabilities>
1. **sendDirectMessage** — Send a private/direct message to a user by their platform user ID (auto-creates DM channel)
2. **sendMessage** — Send a message to a channel or conversation
@@ -442,6 +442,11 @@ export interface CreateBotParams {
credentials: Record<string, string>;
/** Target platform */
platform: string;
/**
* Optional initial settings (DM policy, allowlist, server/user IDs, etc.).
* Same shape as `UpdateBotParams.settings`. Omit to use schema defaults.
*/
settings?: Record<string, unknown>;
}
export interface CreateBotState {
+15
View File
@@ -182,9 +182,24 @@ export default {
'channel.historyLimitHint': 'Default number of messages to fetch when reading channel history',
'channel.serverId': 'Default Server ID',
'channel.serverIdHint': 'Default server / guild AI tools act on; not used for access control',
'channel.serverIdHint.discord':
'Enable Developer Mode (Settings → Advanced), then right-click the server icon → Copy Server ID.',
'channel.serverIdHint.slack':
'Workspace ID (starts with T). Find it under Settings & administration → Workspace settings, or in the workspace URL.',
'channel.userId': 'Your Platform User ID',
'channel.userIdHint':
'Lets AI tools reach you proactively (e.g. reminders); auto-trusted by the global allowlist',
'channel.userIdMissingDesc':
"Without it, AI tools can't reach you with reminders, and pairing approvals will fail. Fill it in under Advanced Settings.",
'channel.userIdMissingTitle': 'Add your platform User ID',
'channel.userIdHint.discord':
'Enable Developer Mode (Settings → Advanced), then right-click your avatar → Copy User ID.',
'channel.userIdHint.feishu':
'Open your app on the Feishu / Lark Open Platform → Permissions, then look up your Open ID.',
'channel.userIdHint.qq': 'Your QQ number, shown on your QQ profile page.',
'channel.userIdHint.slack': 'Open your Slack profile → ⋮ More → Copy member ID (starts with U).',
'channel.userIdHint.telegram':
'Send any message to @userinfobot in Telegram — it replies with your numeric User ID.',
'channel.refreshStatus': 'Refresh status',
'channel.runtimeDisconnected': 'Bot disconnected',
'channel.statusConnected': 'Connected',
+33 -10
View File
@@ -16,6 +16,7 @@ import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import InfoTooltip from '@/components/InfoTooltip';
import type {
FieldSchema,
SerializedPlatformDefinition,
@@ -84,14 +85,23 @@ const SchemaField = memo<SchemaFieldProps>(({ field, parentKey, divider }) => {
);
if (field.visibleWhen && watchedValue !== field.visibleWhen.value) return null;
const label = field.devOnly ? (
<Flexbox horizontal align="center" gap={8}>
{t(field.label)}
<Tag color="gold">Dev Only</Tag>
</Flexbox>
) : (
t(field.label)
);
// Compose the label with optional adornments: a `?` tooltip carrying the
// long-form "how to find this value" guidance, and a Dev Only tag when
// the field is dev-gated. Plain string when neither is needed so antd
// can still treat the label as a simple text node.
const tooltipNode = field.tooltip ? (
<InfoTooltip size={'small'} title={t(field.tooltip)} />
) : null;
const label =
tooltipNode || field.devOnly ? (
<Flexbox horizontal align="center" gap={8}>
{t(field.label)}
{tooltipNode}
{field.devOnly && <Tag color="gold">Dev Only</Tag>}
</Flexbox>
) : (
t(field.label)
);
// Array of objects (e.g. user / channel allowlist) — needs Form.List, can't
// be expressed as a single control inside a name-bound FormItem.
@@ -357,7 +367,20 @@ const Body = memo<BodyProps>(({ platformDef, form, hasConfig, currentConfig, onA
[platformDef.schema],
);
const [settingsActive, setSettingsActive] = useState(false);
// Auto-expand the settings group on mount when an already-saved bot is
// missing its operator User ID, so operators land directly on the field
// the Footer alert is asking them to fill in. Driven off the saved value
// (not the form watch) because `defaultActive` is mount-only — the form
// hasn't hydrated yet at this point — and skipped on platforms without
// a `userId` field in their schema (e.g. WeChat).
const userIdInitiallyMissing = useMemo(() => {
if (!hasConfig) return false;
const hasUserIdField = settingsFields.some((f) => f.key === 'userId');
if (!hasUserIdField) return false;
const savedUserId = currentConfig?.settings?.userId;
return !(typeof savedUserId === 'string' && savedUserId.trim());
}, [hasConfig, settingsFields, currentConfig?.settings]);
const [settingsActive, setSettingsActive] = useState(userIdInitiallyMissing);
const handleResetSettings = useCallback(() => {
form.setFieldsValue({
@@ -397,7 +420,7 @@ const Body = memo<BodyProps>(({ platformDef, form, hasConfig, currentConfig, onA
{settingsFields.length > 0 && (
<FormGroup
collapsible
defaultActive={false}
defaultActive={userIdInitiallyMissing}
keyValue={`settings-${platformDef.id}`}
style={{ marginBlockStart: 16 }}
title={<SettingsTitle schema={platformDef.schema} />}
@@ -4,13 +4,13 @@ import { Alert, Flexbox, Tag } from '@lobehub/ui';
import { Button, Form as AntdForm, type FormInstance } from 'antd';
import { createStaticStyles } from 'antd-style';
import { RefreshCw, Save, Trash2 } from 'lucide-react';
import { memo } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useAppOrigin } from '@/hooks/useAppOrigin';
import type { SerializedPlatformDefinition } from '@/server/services/bot/platforms/types';
import type { ChannelFormValues, TestResult } from './index';
import type { ChannelFormValues, CurrentConfig, TestResult } from './index';
const styles = createStaticStyles(({ css, cssVar }) => ({
actionBar: css`
@@ -50,6 +50,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
interface FooterProps {
connecting: boolean;
connectResult?: TestResult;
currentConfig?: CurrentConfig;
form: FormInstance<ChannelFormValues>;
hasConfig: boolean;
onCopied: () => void;
@@ -66,6 +67,7 @@ interface FooterProps {
const Footer = memo<FooterProps>(
({
platformDef,
currentConfig,
form,
hasConfig,
connectResult,
@@ -88,6 +90,33 @@ const Footer = memo<FooterProps>(
const showWebhookUrl = platformDef.showWebhookUrl || settingsConnectionMode === 'webhook';
// Strong reminder when an already-saved bot is missing the operator's
// User ID. Without it, AI tools can't push notifications back to the
// operator and the pairing approver identity is undefined. Skipped on
// first-time config and on platforms whose schema doesn't expose
// `userId` (e.g. WeChat, which auto-manages identity via QR).
const hasUserIdField = useMemo(() => {
const settings = platformDef.schema.find((f) => f.key === 'settings');
return settings?.properties?.some((f) => f.key === 'userId') ?? false;
}, [platformDef.schema]);
const watchedUserId = AntdForm.useWatch(['settings', 'userId'], form);
// `useWatch` returns `undefined` until antd Form hydrates from the
// parent's `initialValues`. Fall back to the saved value only during
// that pre-hydration window so we don't flash the alert for every
// saved bot. Once the form has reported a value, trust the watched
// value — including `undefined`, so "Reset to Default" (which clears
// settings.userId) correctly re-surfaces the alert.
const savedUserId = currentConfig?.settings?.userId;
const [formHydrated, setFormHydrated] = useState(false);
useEffect(() => {
if (watchedUserId !== undefined) setFormHydrated(true);
}, [watchedUserId]);
const effectiveUserId = formHydrated ? watchedUserId : savedUserId;
const userIdMissing =
hasConfig &&
hasUserIdField &&
!(typeof effectiveUserId === 'string' && effectiveUserId.trim());
const webhookUrl = applicationId
? `${origin}/api/agent/webhooks/${platformId}/${applicationId}`
: `${origin}/api/agent/webhooks/${platformId}`;
@@ -167,13 +196,23 @@ const Footer = memo<FooterProps>(
/>
)}
{userIdMissing && (
<Alert
closable
showIcon
description={t('channel.userIdMissingDesc')}
message={t('channel.userIdMissingTitle')}
type="info"
/>
)}
{hasConfig && showWebhookUrl && platformId === 'qq' && (
<Alert
closable
showIcon
description={t('channel.qq.webhookMigrationDesc')}
message={t('channel.qq.webhookMigrationTitle')}
type="warning"
type="info"
/>
)}
@@ -183,7 +222,7 @@ const Footer = memo<FooterProps>(
showIcon
description={t('channel.slack.webhookMigrationDesc')}
message={t('channel.slack.webhookMigrationTitle')}
type="warning"
type="info"
/>
)}
@@ -193,7 +232,7 @@ const Footer = memo<FooterProps>(
showIcon
description={t('channel.feishu.webhookMigrationDesc')}
message={t('channel.feishu.webhookMigrationTitle')}
type="warning"
type="info"
/>
)}
@@ -38,7 +38,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
const omitUndefinedValues = <T extends Record<string, unknown>>(record: T) =>
Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T;
interface CurrentConfig {
export interface CurrentConfig {
applicationId: string;
credentials: Record<string, string>;
enabled: boolean;
@@ -484,6 +484,7 @@ const PlatformDetail = memo<PlatformDetailProps>(
<Footer
connectResult={connectResult}
connecting={connecting}
currentConfig={currentConfig}
form={form}
hasConfig={!!currentConfig}
platformDef={platformDef}
+30 -53
View File
@@ -6,12 +6,13 @@ import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
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,
validateAccessSettings,
} from '@/server/services/bot/platforms';
assertBotAccessSettings,
invalidateBotAfterUpdate,
mergeBotSettingsForPersist,
} from '@/server/services/bot/agentBotProviderSettings';
import { getBotMessageRouter } from '@/server/services/bot/BotMessageRouter';
import { mergeWithDefaults, platformRegistry } from '@/server/services/bot/platforms';
import { GatewayService } from '@/server/services/gateway';
import { getBotRuntimeStatus } from '@/server/services/gateway/runtimeStatus';
@@ -27,39 +28,19 @@ const agentBotProviderProcedure = authedProcedure.use(serverDatabase).use(async
});
/**
* Merge schema defaults into incoming settings before persisting, so the DB
* row always carries every declared field. Without this, fields the user
* never explicitly touched would stay `undefined` in the DB while the UI
* still renders the schema default — a mismatch that has caused silent
* connection-mode regressions in the past.
* Wrap the shared access-policy validator so violations surface as
* `TRPCError(BAD_REQUEST)` — keeps client forms able to highlight the
* failing field via the existing TRPC error path.
*/
function mergeSettingsForPersist(
platform: string | undefined,
settings: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (settings === undefined) return undefined;
if (!platform) return settings;
const definition = platformRegistry.getPlatform(platform);
if (!definition) return settings;
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',
});
function assertAccessSettingsForTRPC(settings: Record<string, unknown> | undefined): void {
try {
assertBotAccessSettings(settings);
} catch (e) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: (e as Error).message,
});
}
}
export const agentBotProviderRouter = router({
@@ -81,9 +62,9 @@ export const agentBotProviderRouter = router({
.mutation(async ({ input, ctx }) => {
const payload = {
...input,
settings: mergeSettingsForPersist(input.platform, input.settings),
settings: mergeBotSettingsForPersist(input.platform, input.settings),
};
assertAccessSettings(payload.settings);
assertAccessSettingsForTRPC(payload.settings);
try {
return await ctx.agentBotProviderModel.create(payload);
} catch (e: any) {
@@ -255,28 +236,24 @@ export const agentBotProviderRouter = router({
const existing = await ctx.agentBotProviderModel.findById(id);
if (value.settings !== undefined) {
value.settings = mergeSettingsForPersist(
value.settings = mergeBotSettingsForPersist(
value.platform ?? existing?.platform,
value.settings,
);
assertAccessSettings(value.settings);
assertAccessSettingsForTRPC(value.settings);
}
const result = await ctx.agentBotProviderModel.update(id, value);
// Invalidate cached bot so it reloads with fresh config on next webhook
if (existing) {
const shouldStopRuntime =
value.enabled === false ||
(value.applicationId !== undefined && value.applicationId !== existing.applicationId) ||
(value.platform !== undefined && value.platform !== existing.platform);
if (shouldStopRuntime) {
const service = new GatewayService();
await service.stopClient(existing.platform, existing.applicationId, ctx.userId);
}
await getBotMessageRouter().invalidateBot(existing.platform, existing.applicationId);
await invalidateBotAfterUpdate(
{
applicationId: existing.applicationId,
platform: existing.platform,
userId: ctx.userId,
},
value,
);
}
return result;
@@ -0,0 +1,75 @@
import { GatewayService } from '@/server/services/gateway';
import { getBotMessageRouter } from './BotMessageRouter';
import { mergeWithDefaults, platformRegistry, validateAccessSettings } from './platforms';
/**
* Merge schema defaults into incoming settings before persisting, so the DB
* row always carries every declared field. Without this, fields the user
* never explicitly touched would stay `undefined` in the DB while the UI
* still renders the schema default — a mismatch that has caused silent
* connection-mode regressions in the past.
*/
export function mergeBotSettingsForPersist(
platform: string | undefined,
settings: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (settings === undefined) return undefined;
if (!platform) return settings;
const definition = platformRegistry.getPlatform(platform);
if (!definition) return settings;
return mergeWithDefaults(definition.schema, settings);
}
/**
* Run cross-platform access-policy invariants on settings before they hit
* the DB. Throws a plain `Error` with field-prefixed messages so each caller
* can re-wrap it (TRPC -> `TRPCError`, AI runtime -> tool error result).
* Skipped when `settings` is undefined (update payload didn't touch them).
*/
export function assertBotAccessSettings(settings: Record<string, unknown> | undefined): void {
if (settings === undefined) return;
const result = validateAccessSettings(settings);
if (result.valid) return;
const message =
result.errors?.map((e) => `${e.field}: ${e.message}`).join('; ') ||
'Invalid access policy settings';
throw new Error(message);
}
interface BotInvalidationTarget {
applicationId: string;
platform: string;
/** Owner of the bot — passed to `GatewayService.stopClient` for runtime teardown. */
userId: string;
}
interface BotInvalidationDelta {
applicationId?: string;
enabled?: boolean;
platform?: string;
}
/**
* Drop the cached `RegisteredBot` so the next inbound webhook re-reads the
* latest credentials/settings, and stop the gateway runtime when the change
* makes the existing process invalid (disabled, app-id rebound, platform
* changed). Both TRPC and the AI message tool call this after persisting
* a successful update so the two write paths stay in sync.
*/
export async function invalidateBotAfterUpdate(
existing: BotInvalidationTarget,
value: BotInvalidationDelta,
): Promise<void> {
const shouldStopRuntime =
value.enabled === false ||
(value.applicationId !== undefined && value.applicationId !== existing.applicationId) ||
(value.platform !== undefined && value.platform !== existing.platform);
if (shouldStopRuntime) {
const service = new GatewayService();
await service.stopClient(existing.platform, existing.applicationId, existing.userId);
}
await getBotMessageRouter().invalidateBot(existing.platform, existing.applicationId);
}
+50 -10
View File
@@ -11,20 +11,60 @@ export const displayToolCallsField: FieldSchema = {
type: 'boolean',
};
export const serverIdField: FieldSchema = {
key: 'serverId',
description: 'channel.serverIdHint',
label: 'channel.serverId',
type: 'string',
/**
* Per-platform "how to find this ID" tooltip keys. Each platform paints a
* different path to the value the operator has to paste — Discord wants
* Developer Mode + right-click, Telegram wants @userinfobot, Slack uses
* profile menus, etc. The factory below picks the right key so the field's
* `?` tooltip renders concrete steps; the inline `description` stays
* platform-agnostic so it doesn't compete with the tooltip.
*
* Platforms not listed render no tooltip — only the generic description.
*/
const USER_ID_TOOLTIP_BY_PLATFORM: Record<string, string> = {
discord: 'channel.userIdHint.discord',
// Feishu and Lark share `sharedSchema`, which always passes 'feishu' — the
// tooltip copy mentions both products so it reads naturally for either.
feishu: 'channel.userIdHint.feishu',
qq: 'channel.userIdHint.qq',
slack: 'channel.userIdHint.slack',
telegram: 'channel.userIdHint.telegram',
};
export const userIdField: FieldSchema = {
key: 'userId',
description: 'channel.userIdHint',
label: 'channel.userId',
type: 'string',
const SERVER_ID_TOOLTIP_BY_PLATFORM: Record<string, string> = {
discord: 'channel.serverIdHint.discord',
slack: 'channel.serverIdHint.slack',
};
/**
* Build the operator's "Default Server ID" field for `platform`. The inline
* description stays generic; platform-specific "how to find" guidance lives
* in the `?` tooltip next to the label.
*/
export function makeServerIdField(platform?: string): FieldSchema {
return {
key: 'serverId',
description: 'channel.serverIdHint',
label: 'channel.serverId',
tooltip: platform ? SERVER_ID_TOOLTIP_BY_PLATFORM[platform] : undefined,
type: 'string',
};
}
/**
* Build the operator's "Your Platform User ID" field for `platform`. See
* {@link makeServerIdField} — same factory pattern, swapped vocabulary.
*/
export function makeUserIdField(platform?: string): FieldSchema {
return {
key: 'userId',
description: 'channel.userIdHint',
label: 'channel.userId',
tooltip: platform ? USER_ID_TOOLTIP_BY_PLATFORM[platform] : undefined,
type: 'string',
};
}
// ---------- Bot reply locale ----------
/**
@@ -10,8 +10,8 @@ import {
displayToolCallsField,
makeDmPolicyField,
makeGroupPolicyFields,
serverIdField,
userIdField,
makeServerIdField,
makeUserIdField,
} from '../const';
import type { FieldSchema } from '../types';
import { MAX_DISCORD_HISTORY_LIMIT } from './const';
@@ -49,6 +49,8 @@ export const schema: FieldSchema[] = [
key: 'settings',
label: 'channel.settings',
properties: [
makeUserIdField('discord'),
makeServerIdField('discord'),
{
key: 'charLimit',
default: 2000,
@@ -95,8 +97,6 @@ export const schema: FieldSchema[] = [
minimum: MIN_BOT_HISTORY_LIMIT,
type: 'number',
},
serverIdField,
userIdField,
makeDmPolicyField({ policy: 'open' }),
...makeGroupPolicyFields({ policy: 'open' }),
allowFromField,
@@ -10,7 +10,7 @@ import {
displayToolCallsField,
makeDmPolicyField,
makeGroupPolicyFields,
userIdField,
makeUserIdField,
} from '../../const';
import type { FieldSchema } from '../../types';
import { DEFAULT_FEISHU_CONNECTION_MODE, MAX_FEISHU_HISTORY_LIMIT } from '../const';
@@ -55,6 +55,7 @@ export const sharedSchema: FieldSchema[] = [
key: 'settings',
label: 'channel.settings',
properties: [
makeUserIdField('feishu'),
{
key: 'connectionMode',
default: DEFAULT_FEISHU_CONNECTION_MODE,
@@ -114,7 +115,6 @@ export const sharedSchema: FieldSchema[] = [
minimum: MIN_BOT_HISTORY_LIMIT,
type: 'number',
},
userIdField,
makeDmPolicyField({ policy: 'open' }),
...makeGroupPolicyFields({ policy: 'open' }),
allowFromField,
+2 -2
View File
@@ -25,16 +25,16 @@ export {
type GroupSettings,
makeDmPolicyField,
makeGroupPolicyFields,
makeServerIdField,
makeUserIdField,
normalizeAllowFromEntries,
normalizeBotReplyLocale,
RECEIVED_REACTION_EMOJI,
serverIdField,
shouldAllowSender,
shouldHandleDm,
shouldHandleGroup,
THINKING_REACTION_EMOJI,
type UserAllowlist,
userIdField,
validateAccessSettings,
WORKING_REACTION_EMOJI,
} from './const';
@@ -5,7 +5,7 @@ import {
displayToolCallsField,
makeDmPolicyField,
makeGroupPolicyFields,
userIdField,
makeUserIdField,
} from '../const';
import type { FieldSchema } from '../types';
import { DEFAULT_QQ_CONNECTION_MODE } from './const';
@@ -36,6 +36,7 @@ export const schema: FieldSchema[] = [
key: 'settings',
label: 'channel.settings',
properties: [
makeUserIdField('qq'),
{
key: 'connectionMode',
default: DEFAULT_QQ_CONNECTION_MODE,
@@ -86,7 +87,6 @@ export const schema: FieldSchema[] = [
type: 'boolean',
},
displayToolCallsField,
userIdField,
makeDmPolicyField({ policy: 'open' }),
...makeGroupPolicyFields({ policy: 'open' }),
allowFromField,
@@ -10,8 +10,8 @@ import {
displayToolCallsField,
makeDmPolicyField,
makeGroupPolicyFields,
serverIdField,
userIdField,
makeServerIdField,
makeUserIdField,
} from '../const';
import type { FieldSchema } from '../types';
import { DEFAULT_SLACK_CONNECTION_MODE, MAX_SLACK_HISTORY_LIMIT } from './const';
@@ -56,6 +56,8 @@ export const schema: FieldSchema[] = [
key: 'settings',
label: 'channel.settings',
properties: [
makeUserIdField('slack'),
makeServerIdField('slack'),
{
key: 'connectionMode',
default: DEFAULT_SLACK_CONNECTION_MODE,
@@ -115,8 +117,6 @@ export const schema: FieldSchema[] = [
minimum: MIN_BOT_HISTORY_LIMIT,
type: 'number',
},
serverIdField,
userIdField,
makeDmPolicyField({ policy: 'open' }),
...makeGroupPolicyFields({ policy: 'open' }),
allowFromField,
@@ -5,7 +5,7 @@ import {
displayToolCallsField,
makeDmPolicyField,
makeGroupPolicyFields,
userIdField,
makeUserIdField,
} from '../const';
import type { FieldSchema } from '../types';
@@ -43,6 +43,7 @@ export const schema: FieldSchema[] = [
key: 'settings',
label: 'channel.settings',
properties: [
makeUserIdField('telegram'),
{
key: 'charLimit',
default: 4000,
@@ -80,7 +81,6 @@ export const schema: FieldSchema[] = [
type: 'boolean',
},
displayToolCallsField,
userIdField,
makeDmPolicyField({ policy: 'open' }),
...makeGroupPolicyFields({ policy: 'open' }),
allowFromField,
@@ -59,6 +59,12 @@ export interface FieldSchema {
/** Nested fields (for type: 'object') */
properties?: FieldSchema[];
required?: boolean;
/**
* i18n key for an extra `?` tooltip rendered next to the field label. Use
* for "how to find this value" guidance that's too long for the inline
* `description` (e.g. platform-specific UI paths for fetching User IDs).
*/
tooltip?: string;
/**
* Field type, maps to UI component:
* - 'string' → Input
@@ -19,6 +19,18 @@ vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
},
}));
// Stub the bot-settings helper so the test never loads its transitive
// imports (BotMessageRouter -> AiAgentService -> ModelRuntime). ModelRuntime
// reads server-only env at module construction, which the vitest client
// runtime rejects ("Attempted to access a server-side environment variable
// on the client"). The runtime under test doesn't exercise these helpers in
// any covered path; pass-through / no-op behaviour is enough to load.
vi.mock('@/server/services/bot/agentBotProviderSettings', () => ({
assertBotAccessSettings: vi.fn(),
invalidateBotAfterUpdate: vi.fn().mockResolvedValue(undefined),
mergeBotSettingsForPersist: vi.fn((_platform, settings) => settings),
}));
// Mock platform API constructors
const mockDiscordCreateMessage = vi.fn();
const mockDiscordGetMessages = vi.fn();
@@ -7,6 +7,11 @@ import { WechatApiClient } from '@lobechat/chat-adapter-wechat';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import {
assertBotAccessSettings,
invalidateBotAfterUpdate,
mergeBotSettingsForPersist,
} from '@/server/services/bot/agentBotProviderSettings';
import { platformRegistry } from '@/server/services/bot/platforms';
import { DiscordApi } from '@/server/services/bot/platforms/discord/api';
import { DiscordMessageService } from '@/server/services/bot/platforms/discord/service';
@@ -102,11 +107,24 @@ export const messageRuntime: ServerRuntimeRegistration = {
return { status };
},
createBot: async (params) => {
const result = await providerModel.create(params);
const settings = mergeBotSettingsForPersist(params.platform, params.settings);
assertBotAccessSettings(settings);
const result = await providerModel.create({ ...params, settings });
return { id: result.id, platform: params.platform };
},
deleteBot: async (botId) => {
const existing = await providerModel.findById(botId);
await providerModel.delete(botId);
if (existing) {
await invalidateBotAfterUpdate(
{
applicationId: existing.applicationId,
platform: existing.platform,
userId: context.userId!,
},
{ enabled: false },
);
}
},
getBotDetail: async (botId) => {
const bot = await providerModel.findById(botId);
@@ -159,10 +177,40 @@ export const messageRuntime: ServerRuntimeRegistration = {
});
},
toggleBot: async (botId, enabled) => {
const existing = await providerModel.findById(botId);
if (!existing) throw new Error(`Bot not found: ${botId}`);
await providerModel.update(botId, { enabled });
await invalidateBotAfterUpdate(
{
applicationId: existing.applicationId,
platform: existing.platform,
userId: context.userId!,
},
{ enabled },
);
},
updateBot: async (botId, params) => {
await providerModel.update(botId, params);
const existing = await providerModel.findById(botId);
if (!existing) throw new Error(`Bot not found: ${botId}`);
const value: { credentials?: Record<string, string>; settings?: Record<string, unknown> } =
{};
if (params.credentials !== undefined) value.credentials = params.credentials;
if (params.settings !== undefined) {
const merged = mergeBotSettingsForPersist(existing.platform, params.settings);
assertBotAccessSettings(merged);
value.settings = merged;
}
await providerModel.update(botId, value);
await invalidateBotAfterUpdate(
{
applicationId: existing.applicationId,
platform: existing.platform,
userId: context.userId!,
},
{},
);
},
};