Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))。

Expand Down
8 changes: 8 additions & 0 deletions docs/assets/pr/1151-real-names/README.md
Original file line number Diff line number Diff line change
@@ -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).
Binary file added docs/assets/pr/1151-real-names/channel-lark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/pr/1151-real-names/ingest-row.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/pr/1151-real-names/lark-mention.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion packages/core/src/messaging/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/messaging/dsh-im.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 33 additions & 4 deletions packages/core/src/messaging/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/messaging/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions packages/core/test/messaging-conversation-ingest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }]);
});
6 changes: 3 additions & 3 deletions scripts/dev-im-provider.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading