diff --git a/CHANGELOG.md b/CHANGELOG.md index b89f28ee7d..0160154180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ WeChat original voice can be prepared for playback, native quotes and retained l ### Changed +- **New conversations** is now a platform default too: Lark, Slack and Discord each have a **New conversations** choice in their global IM defaults (**Admit automatically** unless changed), and every bound app follows it until you pick a value for that app. Apps already set to **Ask me first** keep it ([#1134](https://github.com/BotHarness/BotHarness/issues/1134), [External identities guide](docs/channel-sidebar/external-identities.md)). - The External connectors entry no longer offers **Add connector** or **Authorize a conversation** while syncing is redesigned around connecting any app's conversation; it lists existing syncs and keeps **Save a send target (advanced)** for apps that can't post to conversations directly. A conversation row shows where it is synced, and its actions (**Mute**, **Rules**, **Block**) sit on their own row with a tooltip each. The Lark setup guide shrinks to three steps (connect the app, bind it, send a test message), and the Lark, Slack and WeChat guides are rewritten around **Bind app** ([#1113](https://github.com/BotHarness/BotHarness/issues/1113), [Lark guide](docs/lark-connection.md), [External connectors guide](docs/channel-sidebar/external-connectors.md)). - A Bot's model and wake policy moved from its Profile to two new entries in the DM's Channel sidebar, **Model** and **Wake policy**. Each shows its current value in the collapsed header, lists one row per setting, and opens a dialog to edit; both can be reordered or hidden like other entries. The Model dialog now lists a **Main model** and a **Task model**, each picked from a searchable list with its reasoning effort; models can be set without any preset, a saved preset can still fill the dialog, and **Save as preset** keeps the current settings for reuse. **Attention policy** is now called **Wake policy** ([#1084](https://github.com/BotHarness/BotHarness/issues/1084), [Model guide](docs/channel-sidebar/model.md), [Wake policy guide](docs/channel-sidebar/wake-policy.md)). diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 04955bdd48..9a07cb5305 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -46,6 +46,7 @@ ### Changed +- **新会话** 也成了平台默认设置:飞书、Slack、Discord 的全局 IM 默认设置里各有一个 **新会话** 选项(不改就是 **自动接收**),每个已绑定应用都跟随它,除非你为这个应用单独选择。已经设为 **先问我** 的应用保持不变([#1134](https://github.com/BotHarness/BotHarness/issues/1134),[外部身份教程](docs/channel-sidebar/external-identities.md))。 - 「外部连接器」不再提供 **添加外部连接器** 和 **授权外部会话**(同步正在围绕“接入任意应用的会话”重新设计),只列出已有的同步,并保留 **保存发送目标(高级)** 给不能直接发往会话的应用。会话行会显示已同步到哪里,操作按钮(**静音**、**规则**、**屏蔽**)单独一行并各带说明提示。Lark 配置引导缩减为三步(连接应用、绑定、发消息验证),Lark、Slack、微信连接教程围绕 **绑定应用** 重写([#1113](https://github.com/BotHarness/BotHarness/issues/1113)、[Lark 教程](docs/lark-connection.md)、[外部连接器说明](docs/channel-sidebar/external-connectors.md))。 - Bot 的模型和唤醒策略从 Profile 移到私聊 Channel sidebar 的两个新分区 **模型** 与 **唤醒策略**:收起时标题右侧显示当前值,每项设置一行,点击打开弹窗修改,并可像其他分区一样排序或隐藏。模型弹窗改为 **主模型** 与 **执行模型** 两项,可输入过滤选择模型并选思考强度;不建预设也能直接保存,已有预设仍可用来填入,**另存为预设** 可把当前设置留作复用。**提醒策略** 改名为 **唤醒策略**([#1084](https://github.com/BotHarness/BotHarness/issues/1084),[模型教程](docs/channel-sidebar/model.md),[唤醒策略教程](docs/channel-sidebar/wake-policy.md))。 diff --git a/docs/assets/pr/1134-new-conversation-default/README.md b/docs/assets/pr/1134-new-conversation-default/README.md new file mode 100644 index 0000000000..4e01994e01 --- /dev/null +++ b/docs/assets/pr/1134-new-conversation-default/README.md @@ -0,0 +1,5 @@ +# #1134 New conversations as a platform default + +Settings → Bot settings → External platform defaults, Lark tab: the new **New conversations** column (Admit automatically by default). Saving "Ask me first" here was read back through the bridge as `newConversations: "ask"`, revision 1. + +![Defaults page](defaults.png) diff --git a/docs/assets/pr/1134-new-conversation-default/bound-app-edit.png b/docs/assets/pr/1134-new-conversation-default/bound-app-edit.png new file mode 100644 index 0000000000..bd0c029dc6 Binary files /dev/null and b/docs/assets/pr/1134-new-conversation-default/bound-app-edit.png differ diff --git a/docs/assets/pr/1134-new-conversation-default/defaults.png b/docs/assets/pr/1134-new-conversation-default/defaults.png new file mode 100644 index 0000000000..4804f612b2 Binary files /dev/null and b/docs/assets/pr/1134-new-conversation-default/defaults.png differ diff --git a/docs/channel-sidebar/external-identities.md b/docs/channel-sidebar/external-identities.md index 65d01a3ea0..dd9ce6e04b 100644 --- a/docs/channel-sidebar/external-identities.md +++ b/docs/channel-sidebar/external-identities.md @@ -56,6 +56,8 @@ The first DM or @mention that reaches the Inbox records its conversation. Click - **Admit automatically** (default): the message goes straight to the Inbox. - **Ask me first**: the conversation waits under **Waiting**. Only its name, first and last seen time and message count are kept, never the message text. After **Allow**, the next message is admitted. +Each app starts on **Follow platform default**, which uses the **New conversations** column in the platform's global IM defaults (Lark, Slack and Discord each have one; **Admit automatically** unless you change it). Pick **Admit automatically** or **Ask me first** in the app dialog to override it for that app only. + To keep a busy app from flooding the Inbox, at most 20 new conversations per app join automatically each hour, and an app holds at most 500 active conversations. Past either limit, new conversations wait under **Waiting** and the row says which limit was hit. **Waiting** keeps the 200 most recent conversations per app. ![The app’s conversation list](/guides/channel-sidebar/19c-app-conversations-en.webp) diff --git a/docs/channel-sidebar/external-identities.zh.md b/docs/channel-sidebar/external-identities.zh.md index b88fa85ace..c7ccc1c5d3 100644 --- a/docs/channel-sidebar/external-identities.zh.md +++ b/docs/channel-sidebar/external-identities.zh.md @@ -56,6 +56,8 @@ Slack 和 Discord 同样适用:私聊应用、或在它所在的频道里 @ - **自动接收**(默认):消息直接进入收件箱。 - **先问我**:会话先放在 **等待处理**,只记录名称、首次和最近出现时间以及消息次数,不保存消息内容;点 **允许** 后,从下一条消息开始接收。 +每个应用默认 **跟随平台默认**,即该平台全局 IM 默认设置里的 **新会话** 一栏(飞书、Slack、Discord 各一份,不改就是 **自动接收**)。在应用弹窗里选 **自动接收** 或 **先问我**,只对这个应用生效。 + 为防止繁忙的应用刷屏,每个应用每小时最多自动加入 20 个新会话,活跃会话最多 500 个。超过任一上限,新会话进入 **等待处理**,并在行内说明是哪一个上限。**等待处理** 每个应用最多保留最近的 200 个会话。 ![应用的会话列表](/guides/channel-sidebar/19c-app-conversations-zh.webp) diff --git a/packages/client/src/client/external-identity-list.tsx b/packages/client/src/client/external-identity-list.tsx index e86299285f..d278bf618e 100644 --- a/packages/client/src/client/external-identity-list.tsx +++ b/packages/client/src/client/external-identity-list.tsx @@ -45,7 +45,7 @@ export function ExternalIdentityList({ const [accountKey, setAccountKey] = useState(''); const [name, setName] = useState(''); const [inheritEnabled, setInheritEnabled] = useState(false); - const [newConversations, setNewConversations] = useState<'auto' | 'ask'>('auto'); + const [newConversations, setNewConversations] = useState<'auto' | 'ask' | 'inherit'>('inherit'); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const identities = snapshot?.identities ?? []; @@ -65,7 +65,9 @@ export function ExternalIdentityList({ setSelected(row); setName(row?.name ?? ''); setInheritEnabled(row?.enabledInheritance === 'inherit'); - setNewConversations(row?.newConversations ?? 'auto'); + setNewConversations( + row?.newConversationsInheritance === 'custom' ? row.newConversations : 'inherit', + ); setAccountKey(''); }; const close = () => { @@ -400,8 +402,25 @@ export function ExternalIdentityList({ toggleLabel={t('identity.newConversations')} value={newConversations} disabled={busy} - onSelect={(value) => setNewConversations(value === 'ask' ? 'ask' : 'auto')} + onSelect={(value) => + setNewConversations( + value === 'ask' || value === 'auto' ? value : 'inherit', + ) + } options={[ + { + value: 'inherit', + label: + selected.newConversationsInheritance === 'custom' + ? t('identity.newConversations.inheritPlain') + : t('identity.newConversations.inherit', { + value: t( + selected.newConversations === 'ask' + ? 'identity.newConversations.ask' + : 'identity.newConversations.auto', + ), + }), + }, { value: 'auto', label: t('identity.newConversations.auto') }, { value: 'ask', label: t('identity.newConversations.ask') }, ]} diff --git a/packages/client/src/client/locale.ts b/packages/client/src/client/locale.ts index ade70ddac8..9288b84c67 100644 --- a/packages/client/src/client/locale.ts +++ b/packages/client/src/client/locale.ts @@ -864,6 +864,8 @@ export const zh = { 'identity.newConversations': '新会话', 'identity.newConversations.auto': '自动接收', 'identity.newConversations.ask': '先问我', + 'identity.newConversations.inherit': '跟随平台默认({value})', + 'identity.newConversations.inheritPlain': '跟随平台默认', 'identity.newConversationsHint': '自动接收:新的私聊或 @ 直接进入收件箱。先问我:新会话先出现在「等待处理」,只记录会话名称和次数,不保存消息内容;允许后从下一条消息开始接收。', 'conversation.syncedTo': '已同步到 {names}', @@ -3064,6 +3066,8 @@ My relationship with the Human, established lore and topics to avoid: [fill in]. 'identity.newConversations': 'New conversations', 'identity.newConversations.auto': 'Admit automatically', 'identity.newConversations.ask': 'Ask me first', + 'identity.newConversations.inherit': 'Follow platform default ({value})', + 'identity.newConversations.inheritPlain': 'Follow platform default', 'identity.newConversationsHint': 'Admit automatically: new DMs and @mentions go straight to the Inbox. Ask me first: a new conversation waits under Waiting with only its name and message count, never the message text; once allowed, the next message is admitted.', 'conversation.syncedTo': 'Synced to {names}', diff --git a/packages/client/src/client/messaging-defaults-settings.tsx b/packages/client/src/client/messaging-defaults-settings.tsx index f1064b75b7..4566878553 100644 --- a/packages/client/src/client/messaging-defaults-settings.tsx +++ b/packages/client/src/client/messaging-defaults-settings.tsx @@ -115,6 +115,7 @@ function PlatformDefaultsSettings({ count: draft.count, intervalSeconds: draft.intervalSeconds, identityEnabled: draft.identityEnabled, + newConversations: draft.newConversations ?? 'auto', }; const value = await saveMessagingDefaults(call, input); if (mounted.current) { @@ -233,6 +234,7 @@ function PlatformDefaultsSettings({ {t('identity.platform')} {t('defaults.identity')} + {t('identity.newConversations')} @@ -246,6 +248,19 @@ function PlatformDefaultsSettings({ onChange={(identityEnabled) => change({ identityEnabled })} /> + + + diff --git a/packages/client/test/external-identity-list.test.ts b/packages/client/test/external-identity-list.test.ts index ff0b9f5c0f..2a76fc20c1 100644 --- a/packages/client/test/external-identity-list.test.ts +++ b/packages/client/test/external-identity-list.test.ts @@ -122,7 +122,7 @@ it('failed Switch writes retain the committed preference; stale Modal edits reta expectedRevision: 7, name: 'Retained edit', inheritEnabled: false, - newConversations: 'auto', + newConversations: 'inherit', enabled: true, }); } finally { diff --git a/packages/client/test/messaging-defaults-settings.test.ts b/packages/client/test/messaging-defaults-settings.test.ts index 31d26cf2d9..68dc188f25 100644 --- a/packages/client/test/messaging-defaults-settings.test.ts +++ b/packages/client/test/messaging-defaults-settings.test.ts @@ -80,6 +80,7 @@ it('keeps a dirty draft at its captured revision, refuses a stale save, and refr input: { platform: 'feishu', expectedRevision: 1, + newConversations: 'auto', collection: 'all', wake: 'digest', count: 5, @@ -188,6 +189,7 @@ it.each(['slack', 'discord'] as const)( input: { platform: targetPlatform, expectedRevision: 1, + newConversations: 'auto', collection: 'mentions', wake: 'digest', count: 3, diff --git a/packages/core/src/bridge/methods.ts b/packages/core/src/bridge/methods.ts index 203c2a9dab..0199255fe2 100644 --- a/packages/core/src/bridge/methods.ts +++ b/packages/core/src/bridge/methods.ts @@ -1132,7 +1132,7 @@ export function createBridgeMethods(deps: BridgeMethodsDeps): BridgeMethods { enabled: z.boolean(), inheritEnabled: z.boolean().optional(), expectedDefaultRevision: z.number().int().min(0).optional(), - newConversations: z.enum(['auto', 'ask']).optional(), + newConversations: z.enum(['auto', 'ask', 'inherit']).optional(), }) .strict(), z diff --git a/packages/core/src/database/schema-plan.ts b/packages/core/src/database/schema-plan.ts index 910b1a37ab..82253547c5 100644 --- a/packages/core/src/database/schema-plan.ts +++ b/packages/core/src/database/schema-plan.ts @@ -1594,6 +1594,19 @@ const APPROVAL_MESSAGING_MIGRATION: SchemaMigration = { }, }; +const NEW_CONVERSATION_DEFAULT_MIGRATION: SchemaMigration = { + generation: 65, + module: 'messaging', + description: 'Let a bound app inherit the platform default new-conversation mode', + migrate(database) { + database.exec(` + ALTER TABLE messaging_bindings ADD COLUMN new_conversations_inherited INTEGER NOT NULL DEFAULT 1 + CHECK (new_conversations_inherited IN (0, 1)); + UPDATE messaging_bindings SET new_conversations_inherited = 0 WHERE new_conversations = 'ask'; + `); + }, +}; + const BOUND_APP_ADMISSION_MIGRATION: SchemaMigration = { generation: 61, module: 'messaging', @@ -1767,4 +1780,5 @@ export const BOT_HARNESS_SCHEMA_PLAN = defineSchemaPlan([ CONVERSATION_LIST_MIGRATION, SEVERAL_APPS_MIGRATION, CONVERSATION_INGEST_MIGRATION, + NEW_CONVERSATION_DEFAULT_MIGRATION, ]); diff --git a/packages/core/src/messaging/defaults.ts b/packages/core/src/messaging/defaults.ts index dfa24a2a74..ffc8918139 100644 --- a/packages/core/src/messaging/defaults.ts +++ b/packages/core/src/messaging/defaults.ts @@ -16,6 +16,7 @@ export const messagingDefaultsInput = z count: z.number().int().min(1).max(100), intervalSeconds: z.number().int().min(1).max(86400), identityEnabled: z.boolean(), + newConversations: z.enum(['auto', 'ask']).optional(), }) .strict(); export type MessagingDefaultsInput = z.infer; @@ -46,6 +47,7 @@ export function messagingDefaults( count: 5, intervalSeconds: 30, identityEnabled: true, + newConversations: 'auto', revision: 0, changedAt: '', }; @@ -60,6 +62,7 @@ export function commitMessagingDefaults( const { expectedRevision: _expected, ...preferences } = parsed; const value = { ...preferences, + newConversations: preferences.newConversations ?? prior.newConversations ?? 'auto', revision: prior.revision + 1, changedAt: new Date().toISOString(), }; diff --git a/packages/core/src/messaging/identity.ts b/packages/core/src/messaging/identity.ts index 95bc0795a1..c58b086cb3 100644 --- a/packages/core/src/messaging/identity.ts +++ b/packages/core/src/messaging/identity.ts @@ -13,6 +13,7 @@ export interface MessagingIdentity { enabled: boolean; enabledInheritance?: 'inherit' | 'custom'; newConversations: MessagingNewConversations; + newConversationsInheritance?: 'inherit' | 'custom'; defaultRevision?: number; revision: number; createdAt: string; @@ -29,7 +30,7 @@ export type MessagingIdentityInput = enabled: boolean; inheritEnabled?: boolean | undefined; expectedDefaultRevision?: number | undefined; - newConversations?: MessagingNewConversations | undefined; + newConversations?: MessagingNewConversations | 'inherit' | undefined; } | { kind: 'reconnect'; id: string; expectedRevision: number } | { kind: 'unbind'; id: string; expectedRevision: number }; @@ -50,6 +51,7 @@ interface BindingRow { enabled: number; enabled_inherited: number; new_conversations: MessagingNewConversations; + new_conversations_inherited: number; revision: number; created_at: string; revoked_at: string | null; @@ -72,7 +74,11 @@ export function readMessagingIdentity(db: DatabaseSync, id: string): MessagingId ? messagingDefaults(db, r.platform).identityEnabled : r.enabled === 1, enabledInheritance: r.enabled_inherited === 1 ? 'inherit' : 'custom', - newConversations: r.new_conversations, + newConversations: + r.new_conversations_inherited === 1 + ? (messagingDefaults(db, r.platform).newConversations ?? 'auto') + : r.new_conversations, + newConversationsInheritance: r.new_conversations_inherited === 1 ? 'inherit' : 'custom', defaultRevision: messagingDefaults(db, r.platform).revision, revision: r.revision, createdAt: r.created_at, diff --git a/packages/core/src/messaging/outbound.ts b/packages/core/src/messaging/outbound.ts index 01b687672c..32569edec4 100644 --- a/packages/core/src/messaging/outbound.ts +++ b/packages/core/src/messaging/outbound.ts @@ -773,7 +773,7 @@ export function createOutboundMessaging(options: { throw new MessagingError('identity-stale'); const at = now(); db.prepare( - 'UPDATE messaging_bindings SET revision = revision + 1, enabled = ?, display_name = ?, revoked_at = ?, enabled_inherited = ?, new_conversations = ? WHERE id = ?', + 'UPDATE messaging_bindings SET revision = revision + 1, enabled = ?, display_name = ?, revoked_at = ?, enabled_inherited = ?, new_conversations = ?, new_conversations_inherited = ? WHERE id = ?', ).run( enabled ? 1 : 0, input.kind === 'update' ? input.name.trim() : latest.name, @@ -785,9 +785,22 @@ export function createOutboundMessaging(options: { : latest.enabledInheritance === 'inherit' ? 1 : 0, - input.kind === 'update' - ? (input.newConversations ?? latest.newConversations) - : latest.newConversations, + input.kind === 'update' && + input.newConversations !== undefined && + input.newConversations !== 'inherit' + ? input.newConversations + : ( + db + .prepare('SELECT new_conversations FROM messaging_bindings WHERE id = ?') + .get(value.id) as { new_conversations: string } + ).new_conversations, + input.kind === 'update' && input.newConversations !== undefined + ? input.newConversations === 'inherit' + ? 1 + : 0 + : latest.newConversationsInheritance === 'custom' + ? 0 + : 1, value.id, ); if (input.kind === 'unbind') { diff --git a/packages/core/test/messaging-lark-dm.test.ts b/packages/core/test/messaging-lark-dm.test.ts index ed59b82624..a63f47e394 100644 --- a/packages/core/test/messaging-lark-dm.test.ts +++ b/packages/core/test/messaging-lark-dm.test.ts @@ -122,6 +122,30 @@ it('Lark exact private chat uses canonical Inbox without mentions and replies th }); } expect((await core.externalMessaging.snapshot('ada')).identities).toHaveLength(2); + const asking = (await core.externalMessaging.snapshot('ada')).identities!.find( + (value) => value.platform === 'discord', + )!; + expect(asking).toMatchObject({ newConversations: 'ask', newConversationsInheritance: 'custom' }); + const following = await core.externalMessaging.identity('ada', { + kind: 'update', + id: asking.id, + expectedRevision: asking.revision, + name: asking.name, + enabled: true, + newConversations: 'inherit', + }); + expect(following).toMatchObject({ + newConversations: 'auto', + newConversationsInheritance: 'inherit', + }); + await core.externalMessaging.identity('ada', { + kind: 'update', + id: following.id, + expectedRevision: following.revision, + name: following.name, + enabled: true, + newConversations: 'ask', + }); expect(await createDshImProvider(transport, 'slack')!.accounts()).toEqual([]); expect(provider.post).toBeUndefined(); expect(provider.readFile).toBeUndefined(); diff --git a/packages/core/test/messaging-new-conversation-default.test.ts b/packages/core/test/messaging-new-conversation-default.test.ts new file mode 100644 index 0000000000..29c74cb2ec --- /dev/null +++ b/packages/core/test/messaging-new-conversation-default.test.ts @@ -0,0 +1,80 @@ +import { expect, it } from 'vitest'; +import { attachOperationalModule, mountOperationalDatabase } from '../src/database/owner.js'; +import { BOT_HARNESS_SCHEMA_PLAN } from '../src/database/schema-plan.js'; +import { defineSchemaPlan } from '../src/database/schema.js'; +import { commitMessagingDefaults, messagingDefaults } from '../src/messaging/defaults.js'; +import { readMessagingIdentity } from '../src/messaging/identity.js'; +import { createTempRoot } from './helpers.js'; + +it('keeps apps set to ask as custom and lets every other app follow the platform default', () => { + const home = createTempRoot('bh1134-upgrade-'); + const prior = mountOperationalDatabase({ + dshHome: home, + schemaPlan: defineSchemaPlan( + BOT_HARNESS_SCHEMA_PLAN.migrations.filter((m) => m.generation < 65), + ), + }); + const insert = + 'INSERT INTO messaging_bindings(id,bot_slug,provider_id,platform,account_ref,fingerprint,created_at,enabled,new_conversations) VALUES (?,?,?,?,?,?,?,1,?)'; + attachOperationalModule(prior, 'test').transaction((db) => { + db.prepare(insert).run( + 'auto-app', + 'ada', + 'dsh-im/feishu', + 'feishu', + 'a1', + 'f1', + '2026-10-01', + 'auto', + ); + db.prepare(insert).run( + 'ask-app', + 'bea', + 'dsh-im/feishu', + 'feishu', + 'a2', + 'f2', + '2026-10-01', + 'ask', + ); + }); + prior.close(); + const current = mountOperationalDatabase({ dshHome: home, schemaPlan: BOT_HARNESS_SCHEMA_PLAN }); + expect(current.mode).toBe('ready'); + const port = attachOperationalModule(current, 'test'); + port.transaction((db) => { + expect(messagingDefaults(db, 'feishu').newConversations).toBe('auto'); + expect(readMessagingIdentity(db, 'auto-app')).toMatchObject({ + newConversations: 'auto', + newConversationsInheritance: 'inherit', + }); + expect(readMessagingIdentity(db, 'ask-app')).toMatchObject({ + newConversations: 'ask', + newConversationsInheritance: 'custom', + }); + const before = messagingDefaults(db, 'feishu'); + commitMessagingDefaults(db, { + platform: 'feishu', + expectedRevision: before.revision, + collection: before.collection, + wake: before.wake, + count: before.count, + intervalSeconds: before.intervalSeconds, + identityEnabled: before.identityEnabled, + newConversations: 'ask', + }); + expect(readMessagingIdentity(db, 'auto-app').newConversations).toBe('ask'); + const after = messagingDefaults(db, 'feishu'); + commitMessagingDefaults(db, { + platform: 'feishu', + expectedRevision: after.revision, + collection: after.collection, + wake: after.wake, + count: after.count, + intervalSeconds: after.intervalSeconds, + identityEnabled: after.identityEnabled, + }); + expect(messagingDefaults(db, 'feishu').newConversations).toBe('ask'); + }); + current.close(); +});