Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/channel/interactive-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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,
};
Expand Down
37 changes: 37 additions & 0 deletions src/core/card-action-operator.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 3 additions & 2 deletions src/tools/ask-user-question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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?: {
Expand All @@ -218,7 +219,7 @@ export function handleAskUserAction(data: unknown, _cfg: ClawdbotConfig, account
value?: Record<string, unknown>;
};
};
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;
Expand Down
5 changes: 3 additions & 2 deletions src/tools/auto-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
}
Expand Down
59 changes: 59 additions & 0 deletions tests/card-action-operator.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading