Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ WeChat original voice can be prepared for playback, native quotes and retained l

### Fixed

- **Bind app** no longer hides apps the IM plugin can't serve yet. A Discord app whose plugin lacks checked sending now shows greyed out with **update the IM plugin to bind this app**, instead of silently missing from the list ([#1123](https://github.com/BotHarness/BotHarness/issues/1123)).

- Shared Channel messages now show the external sender’s retained name, including existing Discord history; bubbles render retained native @ names as chips using the Lark Inbox renderer, while original text and IDs stay intact and unavailable names keep the literal fallback ([#1061](https://github.com/BotHarness/BotHarness/issues/1061)).

### Documentation
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@

### Fixed

- **绑定应用** 不再隐藏 IM 插件暂时不支持的应用。插件缺少受检发送能力的 Discord 应用现在会置灰显示,并注明 **需要更新 IM 插件后才能绑定**,不会再悄悄从列表里消失([#1123](https://github.com/BotHarness/BotHarness/issues/1123))。

- 共享频道消息现在显示已保存的外部发送人名称,已有 Discord 历史也会更新显示;气泡复用 Lark 收件箱的渲染方式,将已保存名称的原生 @ 提及显示为标签;原文和 ID 保持不变,未知名称保留原始文本([#1061](https://github.com/BotHarness/BotHarness/issues/1061))。

### Documentation
Expand Down
8 changes: 8 additions & 0 deletions docs/assets/pr/1123-unsupported-apps/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# #1123 Unsupported apps stay visible in Bind app

Captured from a real DSH Host (0.2.0-rc.1) with a simulated provider. "旧版插件应用" is a simulated app whose IM plugin lacks `proactive-text-checked`, the same state as Discord apps on the pinned dsh-im. Bind app is opened from Bob.

| | Light | Dark |
| --- | ----------------------- | ---------------------- |
| zh | ![](bind-zh-light.webp) | ![](bind-zh-dark.webp) |
| en | ![](bind-en-light.webp) | ![](bind-en-dark.webp) |
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.
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.
14 changes: 9 additions & 5 deletions packages/client/src/client/external-identity-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,16 +280,17 @@ export function ExternalIdentityList({
options={[...accounts]
.sort(
(a, b) =>
Number(a.boundBotSlug !== undefined || !a.connected) -
Number(b.boundBotSlug !== undefined || !b.connected) ||
Number(a.boundBotSlug !== undefined || !a.connected || !!a.unsupported) -
Number(b.boundBotSlug !== undefined || !b.connected || !!b.unsupported) ||
platform(a.platform).localeCompare(platform(b.platform)) ||
a.name.localeCompare(b.name),
)
.map((account) => ({
value: account.providerId + ':' + account.ref,
label: account.name,
hint:
account.boundBotSlug === undefined
hint: account.unsupported
? t('identity.appUnsupported', { platform: platform(account.platform) })
: account.boundBotSlug === undefined
? account.connected
? platform(account.platform)
: t('identity.appOffline', { platform: platform(account.platform) })
Expand All @@ -303,7 +304,10 @@ export function ExternalIdentityList({
platform: platform(account.platform),
name: botName(account.boundBotSlug),
}),
disabled: !account.connected || account.boundBotSlug !== undefined,
disabled:
!account.connected ||
account.boundBotSlug !== undefined ||
!!account.unsupported,
}))}
/>
</label>
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/client/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,7 @@ export const zh = {
'identity.appUsedBy': '{platform} · 已被 {name} 使用',
'identity.appThisBot': '{platform} · 已绑定到这个 Bot',
'identity.appOffline': '{platform} · 未连接',
'identity.appUnsupported': '{platform} · 需要更新 IM 插件后才能绑定',
'identity.manageApps': '管理应用凭据',
'identity.newConversations': '新会话',
'identity.newConversations.auto': '自动接收',
Expand Down Expand Up @@ -2961,6 +2962,7 @@ My relationship with the Human, established lore and topics to avoid: [fill in].
'identity.appUsedBy': '{platform} · used by {name}',
'identity.appThisBot': '{platform} · already bound to this Bot',
'identity.appOffline': '{platform} · not connected',
'identity.appUnsupported': '{platform} · update the IM plugin to bind this app',
'identity.manageApps': 'Manage app credentials',
'identity.newConversations': 'New conversations',
'identity.newConversations.auto': 'Admit automatically',
Expand Down
10 changes: 8 additions & 2 deletions packages/client/test/external-identity-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ it('Bind app shows real readiness after the commit and the app row lists its con
}
});

it('Bind app lists every app, disables the ones a Bot uses with its owner, and offers a second app of a bound platform', async () => {
it('Bind app lists every app, disables the ones a Bot uses with its owner or the Provider cannot serve, and offers a second app of a bound platform', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const app = (ref: string, boundBotSlug?: string) => ({
providerId: 'dsh-im/feishu',
Expand All @@ -264,7 +264,12 @@ it('Bind app lists every app, disables the ones a Bot uses with its owner, and o
...(boundBotSlug ? { boundBotSlug } : {}),
});
const snapshot: MessagingSnapshot = {
accounts: [app('mine', 'ada'), app('theirs', 'bea'), app('free')],
accounts: [
app('mine', 'ada'),
app('theirs', 'bea'),
{ ...app('old'), unsupported: 'checked-send' as const },
app('free'),
],
identities: [
{
id: 'binding',
Expand Down Expand Up @@ -311,6 +316,7 @@ it('Bind app lists every app, disables the ones a Bot uses with its owner, and o
expect(options.map((o) => [o.dataset.value, o.disabled, o.textContent])).toEqual([
['dsh-im/feishu:free', false, expect.stringContaining('App free')],
['dsh-im/feishu:mine', true, expect.stringContaining('已绑定到这个 Bot')],
['dsh-im/feishu:old', true, expect.stringContaining('需要更新 IM 插件')],
['dsh-im/feishu:theirs', true, expect.stringContaining('已被 Bea 使用')],
]);
} finally {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/messaging/channel-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ export function createChannelMediaAccess(options: {
? entry.provider.inspectAccount(value.accountRef)
: entry.provider
.accounts()
.then((accounts) => accounts.find((item) => item.ref === value.accountRef));
.then((accounts) =>
accounts.find((item) => item.ref === value.accountRef && !item.unsupported),
);
const account = await abortable(inspect, signal);
validate();
if (
Expand Down
15 changes: 11 additions & 4 deletions packages/core/src/messaging/dsh-im.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ export function createDshImProvider(
)
return undefined;
const host = service as DshImOutboundService;
const account = async (ref: string) => {
const describe = async (ref: string) => {
let info;
try {
info = await host.describeBot(ref);
Expand All @@ -322,8 +322,7 @@ export function createDshImProvider(
info.version !== 1 ||
info.botId !== ref ||
info.channel !== platform ||
!/^[a-f0-9]{64}$/.test(info.account?.fingerprint ?? '') ||
!info.capabilities.includes('proactive-text-checked')
!/^[a-f0-9]{64}$/.test(info.account?.fingerprint ?? '')
)
throw new MessagingError('provider-incompatible');
return {
Expand All @@ -332,8 +331,16 @@ export function createDshImProvider(
name: info.account.name ?? ref,
fingerprint: info.account.fingerprint,
connected: info.connected,
...(info.capabilities.includes('proactive-text-checked')
? {}
: { unsupported: 'checked-send' as const }),
};
};
const account = async (ref: string) => {
const value = await describe(ref);
if (value.unsupported) throw new MessagingError('provider-incompatible');
return value;
};
const targets = async (ref: string) =>
(await host.listTargets(ref)).map((target) => ({
ref: target.targetId,
Expand Down Expand Up @@ -373,7 +380,7 @@ export function createDshImProvider(
id: `dsh-im/${platform}`,
async accounts() {
const bots = (await host.listBots()).filter((bot) => bot.channel === platform);
const result = await Promise.allSettled(bots.map((bot) => account(bot.botId)));
const result = await Promise.allSettled(bots.map((bot) => describe(bot.botId)));
return result.flatMap((item) => (item.status === 'fulfilled' ? [item.value] : []));
},
targets,
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/messaging/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,9 @@ export function createOutboundMessaging(options: {
const account = await bounded(
entry.provider.inspectAccount
? entry.provider.inspectAccount(value.accountRef)
: entry.provider.accounts().then((rows) => rows.find((r) => r.ref === value.accountRef)),
: entry.provider
.accounts()
.then((rows) => rows.find((r) => r.ref === value.accountRef && !r.unsupported)),
);
current(value.providerId, entry.token);
if (!account || account.ref !== value.accountRef || account.fingerprint !== value.fingerprint)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/messaging/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface MessagingAccount {
name: string;
fingerprint: string;
connected: boolean;
unsupported?: 'checked-send';
}

export interface MessagingTarget {
Expand Down
32 changes: 32 additions & 0 deletions packages/core/test/dsh-im-unsupported.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect, it } from 'vitest';
import { createDshImProvider, type DshImOutboundService } from '../src/messaging/dsh-im.js';

it('lists a Discord app without checked send as unsupported and refuses to inspect it for binding', async () => {
const capabilities = { old: [] as string[], ready: ['proactive-text-checked'] };
const service = {
contractVersion: 1,
listBots: async () => [
{ botId: 'old', channel: 'discord' },
{ botId: 'ready', channel: 'discord' },
],
describeBot: async (botId: 'old' | 'ready') => ({
version: 1,
botId,
channel: 'discord',
connected: true,
capabilities: capabilities[botId],
account: { name: botId, fingerprint: (botId === 'old' ? 'a' : 'b').repeat(64) },
}),
listTargets: async () => [],
sendChecked: async () => ({ ok: true }),
} as unknown as DshImOutboundService;
const provider = createDshImProvider(service, 'discord')!;
expect(await provider.accounts()).toEqual([
expect.objectContaining({ ref: 'old', unsupported: 'checked-send' }),
expect.not.objectContaining({ unsupported: expect.anything() }),
]);
await expect(provider.inspectAccount!('old')).rejects.toMatchObject({
code: 'provider-incompatible',
});
await expect(provider.inspectAccount!('ready')).resolves.toMatchObject({ ref: 'ready' });
});
Loading