diff --git a/.agents/skills/bot/SKILL.md b/.agents/skills/bot/SKILL.md index 8a4ac1fa11..164ee12c7c 100644 --- a/.agents/skills/bot/SKILL.md +++ b/.agents/skills/bot/SKILL.md @@ -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`): diff --git a/apps/cli/src/commands/bot.ts b/apps/cli/src/commands/bot.ts index 802ccd2fe3..adbf49e19b 100644 --- a/apps/cli/src/commands/bot.ts +++ b/apps/cli/src/commands/bot.ts @@ -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 --help`. */ + description: string; + /** Settings field to mutate — `allowFrom` (user IDs) or `groupAllowFrom` (channel IDs). */ + fieldKey: 'allowFrom' | 'groupAllowFrom'; + /** Human-friendly description of what the `` 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 | 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), + [opts.fieldKey]: nextEntries, + }, + }); + + group + .command('list ') + .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 ') + .description(`Add a ${opts.idLabel} to ${opts.fieldKey}`) + .option('--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 ') + .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 ') + .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 ', 'New verification token') .option('--app-id ', 'New application ID') .option('--platform ', 'New platform') + .option( + '--dm-policy ', + `DM access policy (${DM_POLICIES.join('|')}). 'pairing' requires --user-id.`, + ) + .option('--group-policy ', `Group/channel access policy (${GROUP_POLICIES.join('|')})`) + .option( + '--user-id ', + "Owner's platform user ID (required for --dm-policy=pairing; auto-trusts the operator in the global allowlist)", + ) + .option('--server-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 = {}; + 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), + ...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 diff --git a/locales/en-US/agent.json b/locales/en-US/agent.json index 782e291df1..20c6adc136 100644 --- a/locales/en-US/agent.json +++ b/locales/en-US/agent.json @@ -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.", diff --git a/locales/zh-CN/agent.json b/locales/zh-CN/agent.json index dfe76e0b67..76dca3508a 100644 --- a/locales/zh-CN/agent.json +++ b/locales/zh-CN/agent.json @@ -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": "可选。用于验证事件推送来源。", diff --git a/packages/builtin-tool-message/src/ExecutionRuntime/index.ts b/packages/builtin-tool-message/src/ExecutionRuntime/index.ts index ff5fe7276c..806574850a 100644 --- a/packages/builtin-tool-message/src/ExecutionRuntime/index.ts +++ b/packages/builtin-tool-message/src/ExecutionRuntime/index.ts @@ -153,6 +153,7 @@ export interface BotProviderQuery { applicationId: string; credentials: Record; platform: string; + settings?: Record; }) => Promise<{ id: string; platform: string }>; deleteBot: (botId: string) => Promise; getBotDetail: (botId: string) => Promise; diff --git a/packages/builtin-tool-message/src/manifest.ts b/packages/builtin-tool-message/src/manifest.ts index fb8c129759..6adf95a7b4 100644 --- a/packages/builtin-tool-message/src/manifest.ts +++ b/packages/builtin-tool-message/src/manifest.ts @@ -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 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'], diff --git a/packages/builtin-tool-message/src/systemRole.ts b/packages/builtin-tool-message/src/systemRole.ts index aa360eb6f2..5b8c4dc229 100644 --- a/packages/builtin-tool-message/src/systemRole.ts +++ b/packages/builtin-tool-message/src/systemRole.ts @@ -13,14 +13,43 @@ export const systemPrompt = `You have access to a Message tool that provides uni 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) + +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 \` 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: '' } })\` +- "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: : \` 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. + + 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 diff --git a/packages/builtin-tool-message/src/types.ts b/packages/builtin-tool-message/src/types.ts index 7b93162e74..658828dd1d 100644 --- a/packages/builtin-tool-message/src/types.ts +++ b/packages/builtin-tool-message/src/types.ts @@ -442,6 +442,11 @@ export interface CreateBotParams { credentials: Record; /** 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; } export interface CreateBotState { diff --git a/src/locales/default/agent.ts b/src/locales/default/agent.ts index a3f55b964b..ae621f72f5 100644 --- a/src/locales/default/agent.ts +++ b/src/locales/default/agent.ts @@ -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', diff --git a/src/routes/(main)/agent/channel/detail/Body.tsx b/src/routes/(main)/agent/channel/detail/Body.tsx index dc2d668c22..e828caa43e 100644 --- a/src/routes/(main)/agent/channel/detail/Body.tsx +++ b/src/routes/(main)/agent/channel/detail/Body.tsx @@ -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(({ field, parentKey, divider }) => { ); if (field.visibleWhen && watchedValue !== field.visibleWhen.value) return null; - const label = field.devOnly ? ( - - {t(field.label)} - Dev Only - - ) : ( - 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 ? ( + + ) : null; + const label = + tooltipNode || field.devOnly ? ( + + {t(field.label)} + {tooltipNode} + {field.devOnly && Dev Only} + + ) : ( + 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(({ 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(({ platformDef, form, hasConfig, currentConfig, onA {settingsFields.length > 0 && ( } diff --git a/src/routes/(main)/agent/channel/detail/Footer.tsx b/src/routes/(main)/agent/channel/detail/Footer.tsx index 10ca973e7a..5ca79fb1a3 100644 --- a/src/routes/(main)/agent/channel/detail/Footer.tsx +++ b/src/routes/(main)/agent/channel/detail/Footer.tsx @@ -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; hasConfig: boolean; onCopied: () => void; @@ -66,6 +67,7 @@ interface FooterProps { const Footer = memo( ({ platformDef, + currentConfig, form, hasConfig, connectResult, @@ -88,6 +90,33 @@ const Footer = memo( 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( /> )} + {userIdMissing && ( + + )} + {hasConfig && showWebhookUrl && platformId === 'qq' && ( )} @@ -183,7 +222,7 @@ const Footer = memo( showIcon description={t('channel.slack.webhookMigrationDesc')} message={t('channel.slack.webhookMigrationTitle')} - type="warning" + type="info" /> )} @@ -193,7 +232,7 @@ const Footer = memo( showIcon description={t('channel.feishu.webhookMigrationDesc')} message={t('channel.feishu.webhookMigrationTitle')} - type="warning" + type="info" /> )} diff --git a/src/routes/(main)/agent/channel/detail/index.tsx b/src/routes/(main)/agent/channel/detail/index.tsx index 85a9c2d22f..6a6d4232d4 100644 --- a/src/routes/(main)/agent/channel/detail/index.tsx +++ b/src/routes/(main)/agent/channel/detail/index.tsx @@ -38,7 +38,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ const omitUndefinedValues = >(record: T) => Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; -interface CurrentConfig { +export interface CurrentConfig { applicationId: string; credentials: Record; enabled: boolean; @@ -484,6 +484,7 @@ const PlatformDetail = memo(