From 7d83f74406c0852211816da409d4eab58ed4e60d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 18:19:06 +0000 Subject: [PATCH 1/2] fix(messaging): post Feishu DMs through the saved DM target (#3842) An implicit Feishu DM grant (admitted when the owner chats with the Bot) was listed as postable, but implicit grants post through the checked Group conversation API, which never proves speaking permission in a private chat, so every proactive DM send failed with send-permission-denied. - Implicit grants are postable only for Group scope. - A post through an implicit DM grant uses the saved DM target for the same private chat, or refuses with capability-unavailable. - binding_id + a DM conversation_id resolves to that saved DM target. Closes #3842 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lpx6qoDVkpmp8Trm3GtaAJ --- changes/3842-feishu-dm-proactive-post.md | 11 + packages/core/src/messaging/outbound.ts | 43 +++- .../test/messaging-feishu-dm-post.test.ts | 205 ++++++++++++++++++ 3 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 changes/3842-feishu-dm-proactive-post.md create mode 100644 packages/core/test/messaging-feishu-dm-post.test.ts 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..c28556ec98 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,14 @@ export function createOutboundMessaging(options: { }, async post(botSlug, grantId, requestId, text) { const value = grant(botSlug, grantId); + if (value.origin === 'implicit' && value.receiveScope?.kind === 'dm') { + // The checked conversation API only proves Group speaking permission, + // so an implicit DM grant posts through the saved target for the same + // private chat, or refuses as unsupported (#3842). + 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 +2470,10 @@ export function createOutboundMessaging(options: { canPost = checked.inspected.account.postSupported !== false && (value.origin === 'implicit' - ? checked.provider.postConversation !== undefined + ? // Implicit grants post through the checked group conversation + // API only; a DM posts through its saved target (#3842). + 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..a679e38d0a --- /dev/null +++ b/packages/core/test/messaging-feishu-dm-post.test.ts @@ -0,0 +1,205 @@ +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'; + +// #3842: a Bot whose owner has already chatted with it in a Feishu DM holds an +// implicit DM grant. That grant was listed as postable, but implicit grants post +// through the checked group-only conversation API, so every proactive DM send +// was refused with send-permission-denied. The saved (explicit) DM target posts +// through the saved-target route with a checked receipt instead. + +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 }), + // Mirrors the Feishu host: the checked conversation post proves group + // speaking permission, which a private chat never has. + 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', + }); + // The owner chats with the Bot first, which admits an implicit DM grant. + 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' }); + // Settings then saves the same DM as an explicit send target. + 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([]); +}); From bec547ec29f924abdb15e206e7df9109964d021c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 18:32:43 +0000 Subject: [PATCH 2/2] chore(messaging): drop code comments per the source policy Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lpx6qoDVkpmp8Trm3GtaAJ --- packages/core/src/messaging/outbound.ts | 7 +------ packages/core/test/messaging-feishu-dm-post.test.ts | 10 ---------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/packages/core/src/messaging/outbound.ts b/packages/core/src/messaging/outbound.ts index c28556ec98..b9e331e034 100644 --- a/packages/core/src/messaging/outbound.ts +++ b/packages/core/src/messaging/outbound.ts @@ -2234,9 +2234,6 @@ export function createOutboundMessaging(options: { async post(botSlug, grantId, requestId, text) { const value = grant(botSlug, grantId); if (value.origin === 'implicit' && value.receiveScope?.kind === 'dm') { - // The checked conversation API only proves Group speaking permission, - // so an implicit DM grant posts through the saved target for the same - // private chat, or refuses as unsupported (#3842). const saved = await savedDmGrant(value); if (saved === undefined) throw new MessagingError('capability-unavailable'); return service.post(botSlug, saved, requestId, text); @@ -2470,9 +2467,7 @@ export function createOutboundMessaging(options: { canPost = checked.inspected.account.postSupported !== false && (value.origin === 'implicit' - ? // Implicit grants post through the checked group conversation - // API only; a DM posts through its saved target (#3842). - checked.provider.postConversation !== undefined && + ? checked.provider.postConversation !== undefined && checked.inspected.target.receiveScope?.kind === 'group' : checked.provider.post !== undefined || checked.provider.postConversation !== undefined) && diff --git a/packages/core/test/messaging-feishu-dm-post.test.ts b/packages/core/test/messaging-feishu-dm-post.test.ts index a679e38d0a..e8185f2d7a 100644 --- a/packages/core/test/messaging-feishu-dm-post.test.ts +++ b/packages/core/test/messaging-feishu-dm-post.test.ts @@ -5,12 +5,6 @@ import { createDshImProvider, type DshImOutboundService } from '../src/messaging import type { MessagingInboundEvent } from '../src/messaging/provider.js'; import { createTempRoot } from './helpers.js'; -// #3842: a Bot whose owner has already chatted with it in a Feishu DM holds an -// implicit DM grant. That grant was listed as postable, but implicit grants post -// through the checked group-only conversation API, so every proactive DM send -// was refused with send-permission-denied. The saved (explicit) DM target posts -// through the saved-target route with a checked receipt instead. - const cores: BotHarnessCore[] = []; afterEach(async () => { for (const core of cores.splice(0)) { @@ -78,8 +72,6 @@ async function fixture() { receipt: { version: 1, messageId: 'om_reply', conversationId: route.conversationId }, }), listReachableConversations: async () => ({ version: 1, conversations: [], hasMore: false }), - // Mirrors the Feishu host: the checked conversation post proves group - // speaking permission, which a private chat never has. postConversationChecked: async (_account, conversationId) => { groupChecks.push(conversationId); throw Object.assign(new Error('send-permission-denied'), { @@ -117,7 +109,6 @@ async function fixture() { enabled: true, newConversations: 'auto', }); - // The owner chats with the Bot first, which admits an implicit DM grant. const source: MessagingInboundEvent = { version: 1, channel: 'feishu', @@ -142,7 +133,6 @@ async function fixture() { (grant) => grant.origin === 'implicit', )!; expect(implicit.receiveScope).toEqual({ kind: 'dm', conversationId: 'oc_ownerdm' }); - // Settings then saves the same DM as an explicit send target. const target = (await provider.targets('feishu-qa'))[0]!; const explicit = await core.externalMessaging.authorize({ botSlug: 'tob',