diff --git a/src/channel/interactive-dispatch.ts b/src/channel/interactive-dispatch.ts index 4540f2b1..ac3a6a12 100644 --- a/src/channel/interactive-dispatch.ts +++ b/src/channel/interactive-dispatch.ts @@ -15,13 +15,14 @@ import type { ClawdbotConfig } from 'openclaw/plugin-sdk'; // NOTE: This is the SDK-standard interactive pipeline. import { dispatchPluginInteractiveHandler } from 'openclaw/plugin-sdk/plugin-runtime'; +import { resolveCardCallbackOperatorId } from '../core/card-action-operator'; import { larkLogger } from '../core/lark-logger'; import { sendCardFeishu, sendMessageFeishu, updateCardFeishu } from '../messaging/outbound/send'; const log = larkLogger('channel/interactive-dispatch'); interface FeishuCardActionTriggerEvent { - operator?: { open_id?: string }; + operator?: { open_id?: string; user_id?: string }; open_chat_id?: string; open_message_id?: string; context?: { open_chat_id?: string; open_message_id?: string }; @@ -42,7 +43,7 @@ function extractBasics(data: unknown): { const openMessageId = ev.open_message_id ?? ev.context?.open_message_id; return { action: action.trim(), - senderOpenId: ev.operator?.open_id, + senderOpenId: resolveCardCallbackOperatorId(ev.operator), openChatId, openMessageId, }; diff --git a/src/core/card-action-operator.ts b/src/core/card-action-operator.ts new file mode 100644 index 00000000..ac3d5fc3 --- /dev/null +++ b/src/core/card-action-operator.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + * + * Card callback operator identity extraction. + * + * Feishu Schema 2 card callbacks may carry the operator identity under + * either `operator.open_id` (Schema 1 / default) or `operator.user_id` + * (Schema 2 when the user has no open_id in the app's tenant). + * + * This helper provides a single, consistent extraction point so that + * every card callback handler resolves the operator identity the same + * way. See openclaw/openclaw#71670 for the upstream Schema 2 change. + */ + +/** + * Minimal shape of the `operator` object in a Feishu card callback event. + * Both fields are optional because Schema 2 may omit `open_id` entirely. + */ +export interface CardCallbackOperator { + open_id?: string; + user_id?: string; +} + +/** + * Extract the operator's identity from a Feishu card callback event. + * + * Prefers `open_id` (the stable per-app user identifier) and falls back + * to `user_id` when `open_id` is absent or empty — this is the Schema 2 path. + * + * @param operator - The `operator` field from the card callback payload. + * @returns The resolved operator identifier, or `undefined` when neither + * field is present. + */ +export function resolveCardCallbackOperatorId(operator: CardCallbackOperator | undefined): string | undefined { + return operator?.open_id || operator?.user_id; +} diff --git a/src/tools/ask-user-question.ts b/src/tools/ask-user-question.ts index 5be7f944..9be5d517 100644 --- a/src/tools/ask-user-question.ts +++ b/src/tools/ask-user-question.ts @@ -24,6 +24,7 @@ import { randomUUID } from 'node:crypto'; import type { ClawdbotConfig, OpenClawPluginApi } from 'openclaw/plugin-sdk'; import { Type } from '@sinclair/typebox'; import { getTicket, withTicket } from '../core/lark-ticket'; +import { resolveCardCallbackOperatorId } from '../core/card-action-operator'; import { larkLogger } from '../core/lark-logger'; import { createCardEntity, sendCardByCardId, updateCardKitCard } from '../card/cardkit'; import { buildQueueKey, enqueueFeishuChatTask } from '../channel/chat-queue'; @@ -208,7 +209,7 @@ export function handleAskUserAction(data: unknown, _cfg: ClawdbotConfig, account try { const event = data as { - operator?: { open_id?: string }; + operator?: { open_id?: string; user_id?: string }; open_chat_id?: string; context?: { open_chat_id?: string; open_message_id?: string }; action?: { @@ -218,7 +219,7 @@ export function handleAskUserAction(data: unknown, _cfg: ClawdbotConfig, account value?: Record; }; }; - senderOpenId = event.operator?.open_id; + senderOpenId = resolveCardCallbackOperatorId(event.operator); // open_chat_id may be at top level or inside context (form submit callbacks use context) openChatId = event.open_chat_id ?? event.context?.open_chat_id; const actionTag = event.action?.tag; diff --git a/src/tools/auto-auth.ts b/src/tools/auto-auth.ts index e7f0716c..14285d61 100644 --- a/src/tools/auto-auth.ts +++ b/src/tools/auto-auth.ts @@ -33,6 +33,7 @@ import type { ClawdbotConfig } from 'openclaw/plugin-sdk'; import type { ConfiguredLarkAccount, LarkBrand } from '../core/types'; import type { LarkTicket } from '../core/lark-ticket'; import { getTicket } from '../core/lark-ticket'; +import { resolveCardCallbackOperatorId } from '../core/card-action-operator'; import { larkLogger } from '../core/lark-logger'; const log = larkLogger('tools/auto-auth'); @@ -749,12 +750,12 @@ export async function handleCardAction(data: unknown, cfg: ClawdbotConfig, accou try { const event = data as { - operator?: { open_id?: string }; + operator?: { open_id?: string; user_id?: string }; action?: { value?: { action?: string; operation_id?: string } }; }; action = event.action?.value?.action; operationId = event.action?.value?.operation_id; - senderOpenId = event.operator?.open_id; + senderOpenId = resolveCardCallbackOperatorId(event.operator); } catch { return; } diff --git a/tests/card-action-operator.test.ts b/tests/card-action-operator.test.ts new file mode 100644 index 00000000..acb00b33 --- /dev/null +++ b/tests/card-action-operator.test.ts @@ -0,0 +1,59 @@ +/** + * Tests for resolveCardCallbackOperatorId — the shared helper that + * extracts operator identity from Feishu card callback events. + * + * Schema 2 card callbacks may carry the operator identity under + * `operator.user_id` instead of `operator.open_id`. + */ + +import { describe, expect, it } from 'vitest'; +import { resolveCardCallbackOperatorId } from '../src/core/card-action-operator'; + +describe('resolveCardCallbackOperatorId', () => { + it('returns open_id when both fields are present (Schema 1 default)', () => { + expect( + resolveCardCallbackOperatorId({ open_id: 'ou_abc', user_id: 'uid_123' }), + ).toBe('ou_abc'); + }); + + it('returns open_id when only open_id is present', () => { + expect( + resolveCardCallbackOperatorId({ open_id: 'ou_abc' }), + ).toBe('ou_abc'); + }); + + it('falls back to user_id when open_id is absent (Schema 2)', () => { + expect( + resolveCardCallbackOperatorId({ user_id: 'uid_123' }), + ).toBe('uid_123'); + }); + + it('returns undefined when operator is undefined', () => { + expect(resolveCardCallbackOperatorId(undefined)).toBeUndefined(); + }); + + it('returns undefined when both fields are absent', () => { + expect(resolveCardCallbackOperatorId({})).toBeUndefined(); + }); + + it('returns undefined when both fields are empty strings', () => { + // Both empty strings are falsy, so || falls through to the last operand. + expect( + resolveCardCallbackOperatorId({ open_id: '', user_id: '' }), + ).toBe(''); + }); + + it('prefers non-empty open_id over non-empty user_id', () => { + expect( + resolveCardCallbackOperatorId({ open_id: 'ou_real', user_id: 'uid_fallback' }), + ).toBe('ou_real'); + }); + + it('skips empty-string open_id and returns user_id (Schema 2 edge case)', () => { + // Some Schema 2 payloads may send open_id as "" rather than omitting it. + // With ||, empty-string open_id is treated as falsy and falls back to user_id. + expect( + resolveCardCallbackOperatorId({ open_id: '', user_id: 'uid_123' }), + ).toBe('uid_123'); + }); +});