diff --git a/CHANGELOG.md b/CHANGELOG.md index 59815de6fc..ef6a8716b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 6fec2a1121..774892942d 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -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 diff --git a/docs/assets/pr/1123-unsupported-apps/README.md b/docs/assets/pr/1123-unsupported-apps/README.md new file mode 100644 index 0000000000..b3c512f366 --- /dev/null +++ b/docs/assets/pr/1123-unsupported-apps/README.md @@ -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) | diff --git a/docs/assets/pr/1123-unsupported-apps/bind-en-dark.webp b/docs/assets/pr/1123-unsupported-apps/bind-en-dark.webp new file mode 100644 index 0000000000..8e72bbe056 Binary files /dev/null and b/docs/assets/pr/1123-unsupported-apps/bind-en-dark.webp differ diff --git a/docs/assets/pr/1123-unsupported-apps/bind-en-light.webp b/docs/assets/pr/1123-unsupported-apps/bind-en-light.webp new file mode 100644 index 0000000000..691ac48f99 Binary files /dev/null and b/docs/assets/pr/1123-unsupported-apps/bind-en-light.webp differ diff --git a/docs/assets/pr/1123-unsupported-apps/bind-zh-dark.webp b/docs/assets/pr/1123-unsupported-apps/bind-zh-dark.webp new file mode 100644 index 0000000000..e4248f9cdd Binary files /dev/null and b/docs/assets/pr/1123-unsupported-apps/bind-zh-dark.webp differ diff --git a/docs/assets/pr/1123-unsupported-apps/bind-zh-light.webp b/docs/assets/pr/1123-unsupported-apps/bind-zh-light.webp new file mode 100644 index 0000000000..8f79d76b6d Binary files /dev/null and b/docs/assets/pr/1123-unsupported-apps/bind-zh-light.webp differ diff --git a/packages/client/src/client/external-identity-list.tsx b/packages/client/src/client/external-identity-list.tsx index 5936430b47..554fb788af 100644 --- a/packages/client/src/client/external-identity-list.tsx +++ b/packages/client/src/client/external-identity-list.tsx @@ -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) }) @@ -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, }))} /> diff --git a/packages/client/src/client/locale.ts b/packages/client/src/client/locale.ts index de0c68e343..728b869109 100644 --- a/packages/client/src/client/locale.ts +++ b/packages/client/src/client/locale.ts @@ -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': '自动接收', @@ -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', diff --git a/packages/client/test/external-identity-list.test.ts b/packages/client/test/external-identity-list.test.ts index 244e7df611..bd45630c2c 100644 --- a/packages/client/test/external-identity-list.test.ts +++ b/packages/client/test/external-identity-list.test.ts @@ -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', @@ -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', @@ -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 { diff --git a/packages/core/src/messaging/channel-media.ts b/packages/core/src/messaging/channel-media.ts index a446d5d816..d52300af60 100644 --- a/packages/core/src/messaging/channel-media.ts +++ b/packages/core/src/messaging/channel-media.ts @@ -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 ( diff --git a/packages/core/src/messaging/dsh-im.ts b/packages/core/src/messaging/dsh-im.ts index 5bee521cb9..90d2ef7266 100644 --- a/packages/core/src/messaging/dsh-im.ts +++ b/packages/core/src/messaging/dsh-im.ts @@ -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); @@ -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 { @@ -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, @@ -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, diff --git a/packages/core/src/messaging/outbound.ts b/packages/core/src/messaging/outbound.ts index e8c539f3bc..f8e044c28e 100644 --- a/packages/core/src/messaging/outbound.ts +++ b/packages/core/src/messaging/outbound.ts @@ -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) diff --git a/packages/core/src/messaging/provider.ts b/packages/core/src/messaging/provider.ts index 6e6207b2ff..0b0e6c84a8 100644 --- a/packages/core/src/messaging/provider.ts +++ b/packages/core/src/messaging/provider.ts @@ -4,6 +4,7 @@ export interface MessagingAccount { name: string; fingerprint: string; connected: boolean; + unsupported?: 'checked-send'; } export interface MessagingTarget { diff --git a/packages/core/test/dsh-im-unsupported.test.ts b/packages/core/test/dsh-im-unsupported.test.ts new file mode 100644 index 0000000000..698c53372a --- /dev/null +++ b/packages/core/test/dsh-im-unsupported.test.ts @@ -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' }); +});