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
Binary file added .github/pr-screenshots/1145/after-delete.png
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 .github/pr-screenshots/1145/connect-dialog.png
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 .github/pr-screenshots/1145/edit-dialog.png
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 .github/pr-screenshots/1145/local-at-answer.png
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 .github/pr-screenshots/1145/row-receiving.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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

- 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)).

- A Bot can be shown as a transparent pixel Window Companion from its sidebar menu. It follows genuine Activity and new Human–Bot DM replies across Harness pages, supports eased drag poses, continuous inertial falling and a soft landing alongside a hover reading list, and restores its local selection after restart without replaying earlier messages. Reduced motion disables the drop and pose effects. This first slice supports one companion; pause stops walking, not Bot execution ([#1138](https://github.com/BotHarness/DeepSeekBot/issues/1138)).
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

- 群频道可以接入外部会话:在频道 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))。

- 从侧栏 Bot 菜单可显示透明像素窗口伙伴,在 Harness 页面之间保留,跟随真实 Activity 和新的 Human–Bot 私聊回复,支持拖拽姿态缓动、连续惯性下落与柔和落地,以及悬浮阅读列表;减少动效时关闭下落和姿态效果。重启恢复本地选择,不重播历史消息;首个切片支持一个伙伴,暂停只停止走动,不暂停 Bot 执行([#1138](https://github.com/BotHarness/DeepSeekBot/issues/1138))。
Expand Down
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,10 @@ _Avoid_: default folder, inbox, fixed bottom bucket
A configured connection from an external source, such as an IM conversation or later a webhook, to an explicit Channel or PersonaBot Inbox target; it carries inbound delivery and exposes outbound capabilities without becoming the Actor. For an IM conversation it is listed under External connectors (外部连接器); the PersonaBot's own external identity is a separate thing.
_Avoid_: integration, adapter, connector (bare)

**Conversation ingest**:
A Channel-owned, one-way connection that places every message of one external conversation into that Channel as Source Events, with context-only member Admissions by default; its wake setting can switch to a batch or every message, and a member PersonaBot's own wake policy in that Channel wins. It is listed under External connectors as an external conversation (外部会话); it grants no reply or other authority to any PersonaBot. Until slice 9 converges them, a Bridge is the Bot-owned route and a Conversation ingest is the Channel-owned one.
_Avoid_: sync, mirror, Bridge (for this record)

**App**:
The UI name (应用) for one authenticated Provider account, such as a Lark app, a Slack app, a Discord bot or a paired WeChat Bot. An App is bound to at most one PersonaBot; a PersonaBot may bind several Apps, including several of one platform. Settings → IM apps lists every App and the Bot that uses it.
_Avoid_: IM account (in UI copy), connector, integration
Expand Down
4 changes: 4 additions & 0 deletions CONTEXT.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,10 @@ _避免使用_:default folder、inbox、fixed bottom bucket
从 external source(如 IM 会话,未来还有 webhook)到某个显式 Channel 或 PersonaBot Inbox target 的已配置连接;它承载 inbound delivery 并暴露 outbound capability,但不会成为 Actor。对于 IM 会话,它列在「外部连接器 / External connector」中;PersonaBot 自己的外部身份是另一回事。
_避免使用_:integration、adapter、裸用 connector

**Conversation ingest**:
外部会话接入。由 Channel 持有的单向连接:把某个外部会话的每条消息作为 Source Event 放进该 Channel,成员 PersonaBot 默认只获得「仅作上下文」的 Admission;接入的唤醒设置可改为攒够条数后唤醒或逐条唤醒,成员在该 Channel 中的唤醒策略优先。它列在「外部连接器」中,显示为外部会话;它不给任何 PersonaBot 回复或其他权限。在 slice 9 合并之前,Bridge 指 Bot 持有的路由,Conversation ingest 指 Channel 持有的接入。
_避免使用_:sync、mirror、用 Bridge 指代这条记录

**App**:
UI 上叫「应用」:一个已认证 Provider account 的名称,例如 Lark 应用、Slack 应用、Discord bot 或已配对的微信 Bot。一个应用最多绑定到一个 PersonaBot;一个 PersonaBot 可以绑定多个应用,包括同一平台的多个应用。「设置 → IM 应用」列出每个应用及使用它的 Bot。
_避免使用_:UI 文案里的「IM账号」、connector、integration
Expand Down
12 changes: 12 additions & 0 deletions docs/channel-sidebar/external-connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,16 @@ Lark, Slack and Discord apps post to conversations directly, so they never need

![The send target dialog](/guides/channel-sidebar/22-conversation-authorization-zh.webp)

## Connect an external conversation to a group Channel

A group Channel can take in everything said in an external group, the way a webhook feeds it. Open the group's detailed Profile, scroll to **External connectors** and click **Connect an external conversation**:

1. Choose an **App**. Every app a member Bot has bound is listed, with the platform and the Bot that uses it.
2. Choose a **Conversation**. The list shows the groups that app already knows, for example a group where someone has @mentioned it. If yours is missing, @ the app once in that group.
3. Keep **Wake** on **Context only**, or pick **Wake after a batch** or **Wake on every message**, then click **Connect**.

From then on every message from that group appears in the Channel. With **Context only**, member Bots read those messages only when someone @s them, locally or by @mentioning their own app in the group. A member's own wake policy set in this Channel takes precedence. Nothing is sent back to the external group.

The row shows **Waiting for the first message** until one arrives, then **Receiving** and the time of the last message. The app must receive ordinary group messages: Lark needs the `im:message.group_msg` scope. Use the Switch to pause, and **Delete** in the edit dialog to stop new messages; messages already in the Channel stay.

A local Group still manages its connectors in the group’s detailed Profile. Platform setup: [Lark / Feishu](/docs/lark-connection), [Slack](/docs/slack-connection), [personal WeChat](/docs/wechat-connection). [All sidebar features](/docs/channel-sidebar).
12 changes: 12 additions & 0 deletions docs/channel-sidebar/external-connectors.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,16 @@ Lark、Slack 和 Discord 应用可以直接发往会话,永远不需要这一

![发送目标弹窗](/guides/channel-sidebar/22-conversation-authorization-zh.webp)

## 把外部会话接入群频道

群频道可以像接 webhook 一样,接收一个外部群里的所有消息。打开群的详细 Profile,滚动到 **外部连接器**,点 **接入外部会话**:

1. 选择 **应用**。这里列出成员 Bot 已绑定的所有应用,并注明平台和使用它的 Bot。
2. 选择 **会话**。列表里是这个应用已知的群,比如有人 @ 过它的群。如果找不到你的群,先在那个群里 @ 一次应用。
3. **唤醒** 保持 **仅作上下文**,或者选 **攒够条数后唤醒**、**每条立即唤醒**,然后点 **接入**。

之后这个群里的每条消息都会出现在频道里。选 **仅作上下文** 时,成员 Bot 只有在被 @ 时才会读到这些消息,无论是在频道里本地 @,还是在群里 @ 它自己的应用。成员在这个频道里单独设置的唤醒策略优先。不会有任何内容发回外部群。

在第一条消息到达之前,这一行显示 **等待第一条消息**,之后显示 **正在接收** 和最后一条消息的时间。应用需要能收到群里的普通消息:飞书需要开通 `im:message.group_msg` 权限。用开关可以暂停;在编辑窗口里点 **删除** 后不再接收新消息,已接入的消息保留。

本地群聊仍在群的详细 Profile 中管理外部连接器。平台配置:[Lark / 飞书](/zh/docs/lark-connection)、[Slack](/zh/docs/slack-connection)、[个人微信](/zh/docs/wechat-connection)。返回[侧栏总览](/zh/docs/channel-sidebar)。
10 changes: 10 additions & 0 deletions packages/client/src/client/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import type {
ChannelBridgeInput,
ChannelBridgeSnapshot,
} from '../../../core/src/messaging/channel-bridge.js';
import type {
ConversationIngestInput,
ConversationIngestSnapshot,
} from '../../../core/src/messaging/conversation-ingest.js';
import type {
MessagingIdentity,
MessagingIdentityInput,
Expand All @@ -50,6 +54,8 @@ import type { MessagingTarget } from '../../../core/src/messaging/provider.js';
import {
loadChannelBridges,
manageChannelBridge,
loadChannelIngests,
manageChannelIngest,
loadMessagingSnapshot,
manageMessagingIdentity,
manageMessagingConversation,
Expand Down Expand Up @@ -257,6 +263,8 @@ export interface HostDirectoryListing {
export interface BridgeActions {
channelBridges(channelId: string): Promise<ChannelBridgeSnapshot>;
channelBridge(channelId: string, input: ChannelBridgeInput): Promise<void>;
channelIngests(channelId: string): Promise<ConversationIngestSnapshot>;
channelIngest(channelId: string, input: ConversationIngestInput): Promise<void>;
messagingChannelTarget(slug: string, grantId: string, channelId: string | null): Promise<void>;
messagingThreadPolicy(
slug: string,
Expand Down Expand Up @@ -1804,6 +1812,8 @@ export function createActions(
messagingSource: (slug, sourceEventId) => readMessagingSource(call, slug, sourceEventId),
channelBridges: (channelId) => loadChannelBridges(call, channelId),
channelBridge: (channelId, input) => manageChannelBridge(call, channelId, input),
channelIngests: (channelId) => loadChannelIngests(call, channelId),
channelIngest: (channelId, input) => manageChannelIngest(call, channelId, input),
approvalRoute: (slug, pairingId, expectedRevision) =>
setApprovalRoute(call, slug, pairingId, expectedRevision),
approvalTest: (slug) => testApprovalRoute(call, slug),
Expand Down
54 changes: 54 additions & 0 deletions packages/client/src/client/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ import type { GroupReceptionInput } from '../../../core/src/messaging/group-poli
import type { MessagingConversationInput } from '../../../core/src/messaging/conversations.js';
import type { ActivityOverview } from '../../../core/src/bridge/methods.js';
import type { ExternalSource } from '../../../core/src/messaging/inbound.js';
import type {
ConversationIngestInput,
ConversationIngestSnapshot,
} from '../../../core/src/messaging/conversation-ingest.js';
import type { HumanAssignmentContext } from '../../../core/src/runtime/assignment-human-context.js';
export type { HumanAssignmentContext } from '../../../core/src/runtime/assignment-human-context.js';
import type {
Expand Down Expand Up @@ -3486,6 +3490,56 @@ export async function loadChannelBridges(
throw new BridgeCallError('invalid-response', 'Invalid Channel Bridge snapshot');
return value as unknown as ChannelBridgeSnapshot;
}
export async function loadChannelIngests(
call: BridgeCall,
channelId: string,
): Promise<ConversationIngestSnapshot> {
const value = asRecord(await unwrap(call, 'channelIngests', { channelId }));
if (
!value ||
value['channelId'] !== channelId ||
!Array.isArray(value['ingests']) ||
!value['ingests'].every((item) => {
const row = asRecord(item);
const conversation = asRecord(row?.['conversation']);
const wake = asRecord(row?.['wake']);
return (
row &&
conversation &&
wake &&
['id', 'channelId', 'platform', 'accountName', 'intakeAfter'].every(
(key) => typeof row[key] === 'string',
) &&
typeof conversation['id'] === 'string' &&
typeof conversation['name'] === 'string' &&
typeof row['enabled'] === 'boolean' &&
Number.isInteger(row['revision']) &&
['mentions', 'digest', 'all'].includes(String(wake['mode'])) &&
['waiting', 'receiving', 'paused', 'unavailable'].includes(String(row['state']))
);
}) ||
!Array.isArray(value['candidates']) ||
!value['candidates'].every((item) => {
const row = asRecord(item);
return (
row &&
['bindingId', 'botSlug', 'platform', 'accountName'].every(
(key) => typeof row[key] === 'string',
) &&
Array.isArray(row['conversations'])
);
})
)
throw new BridgeCallError('invalid-response', 'Invalid external conversation snapshot');
return value as unknown as ConversationIngestSnapshot;
}
export async function manageChannelIngest(
call: BridgeCall,
channelId: string,
input: ConversationIngestInput,
): Promise<void> {
await unwrap(call, 'channelIngest', { channelId, input });
}
export async function manageChannelBridge(
call: BridgeCall,
channelId: string,
Expand Down
4 changes: 3 additions & 1 deletion packages/client/src/client/channel-bridge-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ export function ChannelBridgeList({
botNames,
actions,
t,
showEmpty = true,
children,
}: {
channelId: string;
channelName: string;
botNames: ReadonlyMap<string, string>;
actions: Pick<BridgeActions, 'channelBridges' | 'channelBridge'>;
t: BotHarnessTranslate;
showEmpty?: boolean;
children?: ReactNode;
}): ReactElement {
const [snapshot, setSnapshot] = useState<ChannelBridgeSnapshot>();
Expand Down Expand Up @@ -244,7 +246,7 @@ export function ChannelBridgeList({
}
/>
))}
{snapshot && !snapshot.bridges.length ? (
{showEmpty && snapshot && !snapshot.bridges.length ? (
<li className="bh-card-row bh-muted bh-card-note">{t('bridge.empty')}</li>
) : null}
{children}
Expand Down
Loading
Loading