diff --git a/CHANGELOG.md b/CHANGELOG.md index 0160154180..dee3505977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ WeChat original voice can be prepared for playback, native quotes and retained l ### Added +- Lark, Slack and Discord groups now show their real names instead of IDs: in External identities, in connected external conversations, on messages in the Channel, and in what the Bot reads, where the ID is still given next to the name. Discord groups read as `Server #channel`. Lark sender names are looked up with a longer timeout and cached per app, and a failed lookup falls back to the ID. Existing entries pick up the name with their next message ([#1151](https://github.com/BotHarness/BotHarness/issues/1151), [External connectors guide](docs/channel-sidebar/external-connectors.md)). - A group Channel can connect an external conversation: **Connect an external conversation** under External connectors in the Channel Profile picks a bound app and one of its known groups, and every message from that group then appears in the Channel. Member Bots get the messages as context only by default and wake only when someone @s them; the connection's wake setting can switch to a batch or every message, and a member's own wake policy in the Channel still wins. Each row shows its state and last message, with a switch to pause and **Delete** to stop new messages while keeping the ones already here ([#1145](https://github.com/BotHarness/BotHarness/issues/1145), [ADR-0143](docs/adr/0143-an-external-conversation-streams-into-a-channel-as-one-way-context.md), [External connectors guide](docs/channel-sidebar/external-connectors.md)). - Slack DMs to a bound app now reach the Bot's Inbox and get answered in the DM without opening a thread, and Discord apps appear in **Bind app** again. The development IM provider moves to a dsh-im build with Slack DM intake, Discord checked delivery and checked Slack/Discord reply mentions ([#1125](https://github.com/BotHarness/BotHarness/issues/1125), [External identities guide](docs/channel-sidebar/external-identities.md)). diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 9a07cb5305..1a8f8c8acd 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -9,6 +9,7 @@ ### Added +- 飞书、Slack 和 Discord 的群现在显示真实群名,不再显示 ID:外部身份、接入的外部会话、频道里的消息和 Bot 读到的内容都改为群名,Bot 仍会同时看到 ID。Discord 群显示为「服务器 #频道」。飞书发送者名字查询放宽了超时,并按应用缓存;查询失败时退回显示 ID。已有条目会在下一条消息到达时更新名字([#1151](https://github.com/BotHarness/BotHarness/issues/1151),[外部连接器教程](docs/channel-sidebar/external-connectors.md))。 - 群频道可以接入外部会话:在频道 Profile 的「外部连接器」中点 **接入外部会话**,选一个已绑定的应用和它已知的一个群,之后这个群里的每条消息都会出现在频道里。成员 Bot 默认只把这些消息当作上下文,只有被 @ 时才会被唤醒;接入的唤醒设置可以改成攒够条数后唤醒或每条立即唤醒,成员在频道里单独设置的唤醒策略仍然优先。每一行显示状态和最后一条消息的时间,可以用开关暂停,**删除** 后不再接收新消息,已接入的消息保留([#1145](https://github.com/BotHarness/BotHarness/issues/1145),[ADR-0143](docs/adr/0143-an-external-conversation-streams-into-a-channel-as-one-way-context.md),[外部连接器教程](docs/channel-sidebar/external-connectors.md))。 - 私聊已绑定的 Slack 应用,消息现在会进入 Bot 收件箱,Bot 直接在私聊里回复,不开 thread;Discord 应用也重新出现在 **绑定应用** 里。开发用的 IM Provider 换到新的 dsh-im 版本,包含 Slack 私聊接收、Discord checked 发送,以及 Slack/Discord 回复里的 checked @([#1125](https://github.com/BotHarness/BotHarness/issues/1125)、[外部身份指南](docs/channel-sidebar/external-identities.md))。 diff --git a/docs/assets/pr/1151-real-names/README.md b/docs/assets/pr/1151-real-names/README.md new file mode 100644 index 0000000000..18416cd3a3 --- /dev/null +++ b/docs/assets/pr/1151-real-names/README.md @@ -0,0 +1,8 @@ +# PR #1160 / issue #1151 screenshots + +Real cloud QA Host, 2026-10-07, with Lark, Slack and Discord apps receiving real messages. + +- `channel-lark.png`: a Lark group message in a Channel, origin shown as `Lark/Feishu BH Cloud QA`. +- `ingest-row.png`: the connected external conversation row named after the Lark group. +- `lark-conversations.png`, `slack-conversations.png`, `discord-conversations.png`: each app's conversation list with real group names. +- `lark-mention.png`: Lark @mentions after 19e01229, origin shown as `Lark/Feishu BH Cloud QA` (2026-10-08). diff --git a/docs/assets/pr/1151-real-names/channel-lark.png b/docs/assets/pr/1151-real-names/channel-lark.png new file mode 100644 index 0000000000..aaf78bcef0 Binary files /dev/null and b/docs/assets/pr/1151-real-names/channel-lark.png differ diff --git a/docs/assets/pr/1151-real-names/discord-conversations.png b/docs/assets/pr/1151-real-names/discord-conversations.png new file mode 100644 index 0000000000..be46ff4c6b Binary files /dev/null and b/docs/assets/pr/1151-real-names/discord-conversations.png differ diff --git a/docs/assets/pr/1151-real-names/ingest-row.png b/docs/assets/pr/1151-real-names/ingest-row.png new file mode 100644 index 0000000000..2ca36dc785 Binary files /dev/null and b/docs/assets/pr/1151-real-names/ingest-row.png differ diff --git a/docs/assets/pr/1151-real-names/lark-conversations.png b/docs/assets/pr/1151-real-names/lark-conversations.png new file mode 100644 index 0000000000..abcc7421e8 Binary files /dev/null and b/docs/assets/pr/1151-real-names/lark-conversations.png differ diff --git a/docs/assets/pr/1151-real-names/lark-mention.png b/docs/assets/pr/1151-real-names/lark-mention.png new file mode 100644 index 0000000000..897c2e12a7 Binary files /dev/null and b/docs/assets/pr/1151-real-names/lark-mention.png differ diff --git a/docs/assets/pr/1151-real-names/slack-conversations.png b/docs/assets/pr/1151-real-names/slack-conversations.png new file mode 100644 index 0000000000..907261a973 Binary files /dev/null and b/docs/assets/pr/1151-real-names/slack-conversations.png differ diff --git a/packages/core/src/messaging/conversations.ts b/packages/core/src/messaging/conversations.ts index 37a0995a9c..2aeb8964ef 100644 --- a/packages/core/src/messaging/conversations.ts +++ b/packages/core/src/messaging/conversations.ts @@ -55,7 +55,7 @@ export type MessagingConversationInput = export function conversationName(event: MessagingInboundEvent): string { return event.conversation.kind === 'dm' ? event.actor.name || event.actor.id - : event.conversation.id; + : event.conversation.name || event.conversation.id; } export function readBlock( diff --git a/packages/core/src/messaging/dsh-im.ts b/packages/core/src/messaging/dsh-im.ts index 4a37bfdfca..d902710828 100644 --- a/packages/core/src/messaging/dsh-im.ts +++ b/packages/core/src/messaging/dsh-im.ts @@ -146,7 +146,13 @@ const inboundSchema = z .object({ kind: z.literal('user'), id: identifier, name: identifier.optional() }) .strict() .transform(({ name, ...actor }) => ({ ...actor, ...(name === undefined ? {} : { name }) })), - conversation: z.object({ kind: z.enum(['group', 'dm']), id: identifier }).strict(), + conversation: z + .object({ kind: z.enum(['group', 'dm']), id: identifier, name: identifier.optional() }) + .strict() + .transform(({ name, ...conversation }) => ({ + ...conversation, + ...(name === undefined ? {} : { name }), + })), mentions: z .array( z diff --git a/packages/core/src/messaging/inbound.ts b/packages/core/src/messaging/inbound.ts index 7fb09445df..745e5ea1e8 100644 --- a/packages/core/src/messaging/inbound.ts +++ b/packages/core/src/messaging/inbound.ts @@ -297,6 +297,8 @@ export function createInboundMessaging(options: { ]), ) .digest('hex'); + const ingestName = (ingest: ConversationIngest, event: MessagingInboundEvent) => + event.conversation.name || ingest.conversation.name; const persistSource = ( db: DatabaseSync, value: MessagingGrant | { ingest: ConversationIngest; botSlug: string }, @@ -351,7 +353,7 @@ export function createInboundMessaging(options: { at: event.at, platform: event.channel, accountName: value.ingest.accountName, - conversationName: value.ingest.conversation.name, + conversationName: ingestName(value.ingest, event), event: evidence, grantId: '', grantRevision: 0, @@ -364,7 +366,9 @@ export function createInboundMessaging(options: { at: event.at, platform: event.channel, accountName: value.accountName, - conversationName: value.targetName, + conversationName: + (event.conversation.kind === 'group' && event.conversation.name) || + value.targetName, event: evidence, grantId: value.id, grantRevision: value.revision, @@ -1188,6 +1192,27 @@ export function createInboundMessaging(options: { }), ); }; + const renameEntries = ( + db: DatabaseSync, + entries: MessagingGrant[], + event: MessagingInboundEvent, + ): MessagingGrant[] => { + const name = event.conversation.kind === 'group' ? event.conversation.name : event.actor.name; + if (!name) return entries; + return entries.map((item) => { + if ( + item.targetName === name || + (event.conversation.kind === 'dm' && item.origin !== 'implicit') + ) + return item; + const renamed = { ...item, targetName: name }; + db.prepare('UPDATE messaging_grants SET body = ? WHERE id = ?').run( + JSON.stringify(renamed), + item.id, + ); + return renamed; + }); + }; const placeIngests = (identity: MessagingIdentity, event: MessagingInboundEvent) => { if (event.conversation.kind !== 'group') return; const current = (db: DatabaseSync) => @@ -1232,7 +1257,11 @@ export function createInboundMessaging(options: { options.isBotActive, )) members.add(slug); - writeIngest(db, { ...ingest, lastMessageAt: event.at }); + writeIngest(db, { + ...ingest, + conversation: { ...ingest.conversation, name: ingestName(ingest, event) }, + lastMessageAt: event.at, + }); } }, ['source-event', 'channel', 'bot-inbox'], @@ -1308,7 +1337,7 @@ export function createInboundMessaging(options: { (db) => { signal.throwIfAborted(); lease.controller.signal.throwIfAborted(); - const entries = conversationEntries(db, id, event.conversation); + const entries = renameEntries(db, conversationEntries(db, id, event.conversation), event); if (entries.some((item) => item.origin !== 'implicit')) return undefined; let value: MessagingGrant | undefined = entries[0]; let created = false; diff --git a/packages/core/src/messaging/provider.ts b/packages/core/src/messaging/provider.ts index 0b0e6c84a8..98cd0cdf10 100644 --- a/packages/core/src/messaging/provider.ts +++ b/packages/core/src/messaging/provider.ts @@ -60,7 +60,7 @@ export interface MessagingInboundEvent { eventId: string; messageId: string; actor: { kind: 'user'; id: string; name?: string }; - conversation: { kind: 'group' | 'dm'; id: string }; + conversation: { kind: 'group' | 'dm'; id: string; name?: string }; mentions: { id: string; key: string; name?: string }[]; mentionedAccount: boolean; at: string; diff --git a/packages/core/test/messaging-conversation-ingest.test.ts b/packages/core/test/messaging-conversation-ingest.test.ts index 8ebad627f2..847930f300 100644 --- a/packages/core/test/messaging-conversation-ingest.test.ts +++ b/packages/core/test/messaging-conversation-ingest.test.ts @@ -324,3 +324,29 @@ it('refuses unknown conversations and a second connection of the same group', as }), ).rejects.toThrow('ingest-exists'); }); + +it('shows the real group name on the connection, the placed source and the bound entry', async () => { + const { fx, group, ingest } = await connected(); + const named = (id: string, mentioned = false): MessagingInboundEvent => { + const event = chatter(id, mentioned); + return { ...event, conversation: { ...event.conversation, name: 'Team chat' } }; + }; + await fx.receive(named('a')); + expect(ingest().conversation).toEqual({ kind: 'group', id: 'oc_team', name: 'Team chat' }); + await fx.receive(chatter('b')); + expect(ingest().conversation.name).toBe('Team chat'); + await fx.receive(named('ask', true)); + expect( + fx.query( + `SELECT json_extract(e.payload_json, '$.external.conversationName') AS name, + json_extract(e.payload_json, '$.external.event.conversation.id') AS id + FROM channel_placements p JOIN source_events e USING(source_event_id) + WHERE p.channel_id = '${group.id}' ORDER BY p.revision`, + ), + ).toEqual(['Team chat', 'Team chat', 'Team chat'].map((name) => ({ name, id: 'oc_team' }))); + expect( + fx.query( + "SELECT json_extract(body, '$.targetName') AS name FROM messaging_grants WHERE json_extract(body, '$.receiveScope.kind') = 'group'", + ), + ).toEqual([{ name: 'Team chat' }]); +}); diff --git a/scripts/dev-im-provider.mjs b/scripts/dev-im-provider.mjs index 26549a3317..76e17815ab 100644 --- a/scripts/dev-im-provider.mjs +++ b/scripts/dev-im-provider.mjs @@ -5,11 +5,11 @@ import { join } from 'node:path'; export const qualifiedImProvider = Object.freeze({ package: '@xmanrui/dsh-im', packageVersion: '4.32.0', - source: '55b4528480ccce3e8acc067e10b880556bf5034c', - spec: 'github:DoodleBears/dsh-im#55b4528480ccce3e8acc067e10b880556bf5034c', + source: 'a0ba2839dadbdcdda9a9bf7511da3094a79eb1fb', + spec: 'github:DoodleBears/dsh-im#a0ba2839dadbdcdda9a9bf7511da3094a79eb1fb', dsh: '0.2.0-rc.1', runtimeFiles: 403, - runtimeSha256: '43949fc5b06e195f2a66f56a801a142f02e5e76fd47a53be191b7391abc70278', + runtimeSha256: '0407606bfe4b1fbeed4a58ad6a368eb4f999aa6f8bb38ed706fd3e6d53994924', }); export function withQualifiedImProvider(manifest) {