diff --git a/changes/3842-feishu-dm-proactive-post.md b/changes/3842-feishu-dm-proactive-post.md new file mode 100644 index 0000000000..649e2b0851 --- /dev/null +++ b/changes/3842-feishu-dm-proactive-post.md @@ -0,0 +1,11 @@ +--- +section: Fixed +--- + +## en + +- A Bot's proactive Feishu/Lark DM to its owner no longer fails with `send-permission-denied` once the owner has chatted with it: the private chat posts through its saved send target with a checked receipt instead of the Group-only speaking check, and an unsaved private chat is no longer listed as postable ([#3842](https://github.com/BotHarness/DeepSeekBot/issues/3842)). + +## zh + +- Owner 曾在飞书/Lark 单聊中与 Bot 对话后,Bot 主动发给 Owner 的单聊不再一律返回 `send-permission-denied`:单聊改经保存的发送目标并带回执送达,不再走仅适用于群聊的发言权限检查;未保存为发送目标的单聊也不再显示为可发送([#3842](https://github.com/BotHarness/DeepSeekBot/issues/3842))。 diff --git a/packages/core/src/messaging/outbound.ts b/packages/core/src/messaging/outbound.ts index 422d849ac8..b9e331e034 100644 --- a/packages/core/src/messaging/outbound.ts +++ b/packages/core/src/messaging/outbound.ts @@ -1030,6 +1030,32 @@ export function createOutboundMessaging(options: { ) throw new MessagingError('conversation-blocked'); }; + const savedDmGrant = async (value: MessagingGrant): Promise => { + const conversationId = value.receiveScope?.conversationId; + if (conversationId === undefined) return undefined; + const rows = database.read((db) => + db + .prepare( + 'SELECT body FROM messaging_grants WHERE binding_id = ? AND bot_slug = ? AND revoked_at IS NULL ORDER BY created_at, rowid', + ) + .all(value.bindingId, value.botSlug), + ) as { body: string }[]; + const candidates = rows + .map((row) => JSON.parse(row.body) as MessagingGrant) + .filter((candidate) => candidate.origin !== 'implicit' && !candidate.suspendedReason); + if (candidates.length === 0) return undefined; + const entry = provider(value.providerId); + const targets = await bounded(entry.provider.targets(value.accountRef)); + return candidates.find((candidate) => + targets.some( + (target) => + target.ref === candidate.targetRef && + target.digest === candidate.targetDigest && + target.receiveScope?.kind === 'dm' && + target.receiveScope.conversationId === conversationId, + ), + )?.id; + }; const check = async (value: MessagingGrant) => { active(value.botSlug); if (value.revokedAt !== undefined) throw new MessagingError('grant-revoked'); @@ -2122,9 +2148,7 @@ export function createOutboundMessaging(options: { throw new MessagingError('capability-unavailable'); const targets = await bounded(entry.provider.targets(identity.accountRef)); const matching = targets.filter( - (target) => - target.receiveScope?.kind === 'group' && - target.receiveScope.conversationId === conversationId, + (target) => target.receiveScope?.conversationId === conversationId, ); const findExisting = (db: DatabaseSync) => { const rows = db @@ -2209,6 +2233,11 @@ export function createOutboundMessaging(options: { }, async post(botSlug, grantId, requestId, text) { const value = grant(botSlug, grantId); + if (value.origin === 'implicit' && value.receiveScope?.kind === 'dm') { + const saved = await savedDmGrant(value); + if (saved === undefined) throw new MessagingError('capability-unavailable'); + return service.post(botSlug, saved, requestId, text); + } if ( value.origin === 'implicit' ? provider(value.providerId).provider.postConversation === undefined @@ -2438,7 +2467,8 @@ export function createOutboundMessaging(options: { canPost = checked.inspected.account.postSupported !== false && (value.origin === 'implicit' - ? checked.provider.postConversation !== undefined + ? checked.provider.postConversation !== undefined && + checked.inspected.target.receiveScope?.kind === 'group' : checked.provider.post !== undefined || checked.provider.postConversation !== undefined) && checked.inspected.target.receiveScope !== undefined; diff --git a/packages/core/test/messaging-feishu-dm-post.test.ts b/packages/core/test/messaging-feishu-dm-post.test.ts new file mode 100644 index 0000000000..e8185f2d7a --- /dev/null +++ b/packages/core/test/messaging-feishu-dm-post.test.ts @@ -0,0 +1,195 @@ +import { setImmediate as tick } from 'node:timers/promises'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createCore, type BotHarnessCore } from '../src/plugin.js'; +import { createDshImProvider, type DshImOutboundService } from '../src/messaging/dsh-im.js'; +import type { MessagingInboundEvent } from '../src/messaging/provider.js'; +import { createTempRoot } from './helpers.js'; + +const cores: BotHarnessCore[] = []; +afterEach(async () => { + for (const core of cores.splice(0)) { + core.externalMessaging.close(); + await core.runtime.close(); + core.operationalDatabase.close(); + } +}); + +async function fixture() { + const fingerprint = 'e'.repeat(64); + let consumer: Parameters>[1] | undefined; + const groupChecks: string[] = []; + const saved: { targetId: string; text: string; receipt: boolean }[] = []; + const transport: DshImOutboundService = { + contractVersion: 1, + receiptVersion: 1, + postFenceVersion: 1, + reachableConversationVersion: 1, + replyContextVersion: 1, + replyReceiptVersion: 1, + replyFenceVersion: 1, + listBots: async () => [{ botId: 'feishu-qa', channel: 'feishu' }], + listTargets: async () => [ + { + targetId: 'owner-dm', + name: 'Owner DM', + kind: 'user', + route: { openId: 'ou_owner', chatId: 'oc_ownerdm' }, + }, + ], + describeBot: async (botId) => ({ + version: 1, + botId, + channel: 'feishu', + connected: true, + account: { fingerprint, name: 'Lark QA Bot' }, + capabilities: [ + 'proactive-text-checked', + 'proactive-receipt-checked', + 'proactive-fence-checked', + 'reachable-conversations-checked', + 'exclusive-text-consumer', + 'reply-text-checked', + 'reply-context-checked', + 'reply-receipt-checked', + 'reply-fence-checked', + ], + }), + sendChecked: vi.fn(async (_account, targetId, text, options) => { + expect(options.beforeSend?.()).toBe(true); + saved.push({ targetId, text, receipt: options.receipt === true }); + return { + sent: true as const, + receipt: { version: 1 as const, messageId: 'om_dm', conversationId: 'oc_ownerdm' }, + }; + }), + consumeInbound: async (_account, options) => { + consumer = options; + return () => {}; + }, + qualifyReplyChecked: async (_account, route) => route, + replyChecked: async (_account, route) => ({ + sent: true, + receipt: { version: 1, messageId: 'om_reply', conversationId: route.conversationId }, + }), + listReachableConversations: async () => ({ version: 1, conversations: [], hasMore: false }), + postConversationChecked: async (_account, conversationId) => { + groupChecks.push(conversationId); + throw Object.assign(new Error('send-permission-denied'), { + code: 'send-permission-denied', + }); + }, + }; + const core = createCore({ + dshHome: createTempRoot('feishu-dm-post-'), + agents: { + async runOrchestrator() {}, + async runAssignment() {}, + requestAssignment() { + return { delivery: 'steer' }; + }, + async stopAssignment() {}, + async close() {}, + }, + }); + cores.push(core); + expect(core.registry.create({ slug: 'tob', displayName: 'Tob' }).ok).toBe(true); + const provider = createDshImProvider(transport, 'feishu')!; + core.externalMessaging.register(provider); + const bound = await core.externalMessaging.identity('tob', { + kind: 'bind', + providerId: provider.id, + accountRef: 'feishu-qa', + fingerprint, + }); + const binding = await core.externalMessaging.identity('tob', { + kind: 'update', + id: bound.id, + expectedRevision: bound.revision, + name: bound.name, + enabled: true, + newConversations: 'auto', + }); + const source: MessagingInboundEvent = { + version: 1, + channel: 'feishu', + botId: 'feishu-qa', + fingerprint, + eventId: 'gateway:qa:1', + messageId: 'om_owner_1', + actor: { kind: 'user', id: 'ou_owner' }, + conversation: { kind: 'dm', id: 'oc_ownerdm' }, + mentions: [], + mentionedAccount: false, + at: new Date().toISOString(), + text: 'hello', + reply: { messageId: 'om_owner_1', actorId: 'ou_owner', conversationId: 'oc_ownerdm' }, + replay: { kind: 'provider-redelivery', resumeCursor: false, gapPossible: true }, + }; + await tick(); + await consumer!.onEvent(source, { signal: consumer!.signal }); + await tick(); + await core.runtime.whenIdle(); + const implicit = (await core.externalMessaging.snapshot('tob')).grants.find( + (grant) => grant.origin === 'implicit', + )!; + expect(implicit.receiveScope).toEqual({ kind: 'dm', conversationId: 'oc_ownerdm' }); + const target = (await provider.targets('feishu-qa'))[0]!; + const explicit = await core.externalMessaging.authorize({ + botSlug: 'tob', + providerId: provider.id, + accountRef: 'feishu-qa', + targetRef: target.ref, + fingerprint, + targetDigest: target.digest, + }); + return { core, binding, implicit, explicit, groupChecks, saved }; +} + +it('lists only the saved DM target as postable, never the implicit DM grant', async () => { + const { core, implicit, explicit } = await fixture(); + const grants = (await core.externalMessaging.snapshot('tob')).grants; + expect(grants.find((grant) => grant.id === implicit.id)).toMatchObject({ canPost: false }); + expect(grants.find((grant) => grant.id === explicit.id)).toMatchObject({ + canPost: true, + postConversationId: 'oc_ownerdm', + }); +}); + +it('a proactive post through the implicit DM grant uses the saved DM target', async () => { + const { core, implicit, groupChecks, saved } = await fixture(); + const result = await core.externalMessaging.post('tob', implicit.id, 'dm-post-implicit', 'Hi'); + expect(result).toMatchObject({ state: 'provider-accepted', receipt: { messageId: 'om_dm' } }); + expect(groupChecks).toEqual([]); + expect(saved).toEqual([{ targetId: 'owner-dm', text: 'Hi', receipt: true }]); +}); + +it('a proactive post through the saved DM target is delivered with a receipt', async () => { + const { core, explicit, groupChecks, saved } = await fixture(); + const result = await core.externalMessaging.post('tob', explicit.id, 'dm-post-explicit', 'Hi'); + expect(result).toMatchObject({ state: 'provider-accepted', receipt: { messageId: 'om_dm' } }); + expect(groupChecks).toEqual([]); + expect(saved).toEqual([{ targetId: 'owner-dm', text: 'Hi', receipt: true }]); +}); + +it('binding_id and the DM conversation_id resolve to the saved DM target', async () => { + const { core, binding, groupChecks, saved } = await fixture(); + const result = await core.externalMessaging.postConversation( + 'tob', + binding.id, + 'oc_ownerdm', + 'dm-post-binding', + 'Hi', + ); + expect(result).toMatchObject({ state: 'provider-accepted' }); + expect(groupChecks).toEqual([]); + expect(saved).toHaveLength(1); +}); + +it('without a saved DM target the implicit DM grant refuses as unsupported, not denied', async () => { + const { core, implicit, explicit, groupChecks } = await fixture(); + await core.externalMessaging.revoke('tob', explicit.id); + await expect( + core.externalMessaging.post('tob', implicit.id, 'dm-post-no-target', 'Hi'), + ).rejects.toMatchObject({ code: 'capability-unavailable' }); + expect(groupChecks).toEqual([]); +});