dsh-caduceus 0.1.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `user-questions/request` answerer: render one question card (buttons when a
3
+ * single question has 1..4 options) and resolve the first click. Free-text
4
+ * answers are not implemented in this version; text-only cards expire.
5
+ *
6
+ * @module dsh-caduceus/interactive/question
7
+ */
8
+ import { questionCard, questionTerminalCard } from './cards.js';
9
+ import { logPrefix } from './types.js';
10
+ export function createQuestionAnswerer(host, contextFor) {
11
+ return async (request) => {
12
+ if (!host.config.questionCard) {
13
+ host.logger.warn('interactive: question skipped (questionCard disabled)');
14
+ throw new Error('interactive question cards are disabled');
15
+ }
16
+ const context = contextFor();
17
+ if (context === undefined) {
18
+ host.logger.warn('interactive: question skipped (no session context)');
19
+ throw new Error('no caduceus session context for user question');
20
+ }
21
+ let settle;
22
+ let fail;
23
+ const answer = new Promise((resolve, reject) => {
24
+ settle = resolve;
25
+ fail = reject;
26
+ });
27
+ const entry = host.pending.add({
28
+ kind: 'question',
29
+ scopeKey: context.scopeKey,
30
+ chatId: context.chatId,
31
+ ownerOpenId: context.ownerOpenId,
32
+ sessionId: context.sessionId,
33
+ createdAt: host.clock.now(),
34
+ resolve: (value) => settle(value),
35
+ reject: (error) => fail(error),
36
+ meta: { questions: request.questions },
37
+ });
38
+ host.logger.info(`interactive: question request id=${entry.id} questions=${request.questions.length} scope=${context.scopeKey}`);
39
+ const rendered = questionCard({
40
+ requestId: entry.id,
41
+ sessionId: context.sessionId,
42
+ questions: request.questions,
43
+ });
44
+ let timer;
45
+ const onAbort = () => {
46
+ if (host.pending.delete(entry.id) === undefined)
47
+ return;
48
+ void host
49
+ .updateCard(entry.cardMessageId ?? '', questionTerminalCard(request.questions[0]?.question ?? '问题', '已取消'))
50
+ .catch(() => undefined);
51
+ fail(new Error('user question cancelled'));
52
+ };
53
+ const cleanup = () => {
54
+ if (timer !== undefined)
55
+ host.clock.clearTimeout(timer);
56
+ request.signal?.removeEventListener('abort', onAbort);
57
+ };
58
+ entry.cleanup = cleanup;
59
+ host.logger.info(`interactive: question card sending chat=${logPrefix(context.chatId)}`);
60
+ try {
61
+ const sent = await host.sendCard(context.sessionId, rendered.card);
62
+ entry.cardMessageId = sent.messageId;
63
+ host.logger.info(`interactive: question card sent messageId=${logPrefix(entry.cardMessageId)} thread=${logPrefix(sent.threadId ?? '-', 8)}`);
64
+ }
65
+ catch (error) {
66
+ host.pending.delete(entry.id);
67
+ host.logger.error(`interactive: question card send failed: ${error instanceof Error ? error.message : String(error)}`);
68
+ throw error instanceof Error ? error : new Error(String(error));
69
+ }
70
+ timer = host.clock.setTimeout(() => {
71
+ if (host.pending.delete(entry.id) === undefined)
72
+ return;
73
+ host.logger.info(`interactive: question timed out after ${host.config.timeoutMs}ms`);
74
+ void host
75
+ .updateCard(entry.cardMessageId ?? '', questionTerminalCard(request.questions[0]?.question ?? '问题', '已过期'))
76
+ .catch(() => undefined);
77
+ fail(new Error('user question timed out'));
78
+ }, host.config.timeoutMs);
79
+ if (request.signal?.aborted === true) {
80
+ onAbort();
81
+ return answer;
82
+ }
83
+ request.signal?.addEventListener('abort', onAbort, { once: true });
84
+ return answer;
85
+ };
86
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Runtime that owns the pending registry, sends/patch cards, routes
3
+ * `card.action.trigger` clicks, and installs the two agent-scoped answerers.
4
+ *
5
+ * @module dsh-caduceus/interactive/runtime
6
+ */
7
+ import type { LoggerLike } from '../logger.js';
8
+ import { type Clock } from '../render/stream.js';
9
+ import type { CardAction, InteractiveConfig, InteractiveContext } from './types.js';
10
+ import { PendingRegistry } from './pending.js';
11
+ import type { FeishuCard } from '../transport/types.js';
12
+ export interface InteractiveRuntimeOptions {
13
+ readonly config: InteractiveConfig;
14
+ readonly logger?: LoggerLike;
15
+ readonly clock?: Clock;
16
+ sendCard(sessionId: string, card: FeishuCard): Promise<{
17
+ readonly messageId: string;
18
+ readonly threadId?: string;
19
+ }>;
20
+ updateCard(messageId: string, card: FeishuCard): Promise<void>;
21
+ }
22
+ export declare class InteractiveRuntime {
23
+ #private;
24
+ readonly config: InteractiveConfig;
25
+ readonly logger: LoggerLike;
26
+ readonly clock: Clock;
27
+ readonly pending: PendingRegistry;
28
+ constructor(options: InteractiveRuntimeOptions);
29
+ /**
30
+ * Register both waterfall answerers ONCE on the plugin root context, as dsh's
31
+ * own ACP bridge does. Ownership is decided per request from
32
+ * `request.agent.session.id`; requests for other agents are delegated by
33
+ * calling `next()`, never claimed.
34
+ */
35
+ registerRoot(rootCtx: unknown, resolve: {
36
+ ownsSession(sessionId: string): boolean;
37
+ contextFor(sessionId: string): InteractiveContext | undefined;
38
+ }): boolean;
39
+ /**
40
+ * Route one card click. Unknown ids are ignored; non-owners are ignored with
41
+ * a warning and do not resolve or patch anything.
42
+ */
43
+ handleCardAction(action: CardAction): Promise<void>;
44
+ }
@@ -0,0 +1,147 @@
1
+ import { silentLogger } from '../logger.js';
2
+ import { systemClock } from '../render/stream.js';
3
+ import { describeScope, isEventScope, logPrefix } from './types.js';
4
+ import { createApprovalAnswerer } from './approval.js';
5
+ import { PendingRegistry } from './pending.js';
6
+ import { createQuestionAnswerer } from './question.js';
7
+ import { approvalTerminalCard, questionCard, questionTerminalCard } from './cards.js';
8
+ export class InteractiveRuntime {
9
+ config;
10
+ logger;
11
+ clock;
12
+ pending = new PendingRegistry();
13
+ #sendCard;
14
+ #updateCard;
15
+ constructor(options) {
16
+ this.config = options.config;
17
+ this.logger = options.logger ?? silentLogger();
18
+ this.clock = options.clock ?? systemClock;
19
+ this.#sendCard = options.sendCard;
20
+ this.#updateCard = options.updateCard;
21
+ }
22
+ /**
23
+ * Register both waterfall answerers ONCE on the plugin root context, as dsh's
24
+ * own ACP bridge does. Ownership is decided per request from
25
+ * `request.agent.session.id`; requests for other agents are delegated by
26
+ * calling `next()`, never claimed.
27
+ */
28
+ registerRoot(rootCtx, resolve) {
29
+ if (!isEventScope(rootCtx)) {
30
+ this.logger.warn(`interactive: register skipped (not an event scope: ${describeScope(rootCtx)})`);
31
+ return false;
32
+ }
33
+ rootCtx.on('approval/request', async (request, next) => {
34
+ const sessionId = sessionIdOf(request);
35
+ if (sessionId === undefined || !resolve.ownsSession(sessionId)) {
36
+ this.logger.info(`interactive: approval request agent=${sessionId === undefined ? '?' : logPrefix(sessionId)} not ours, delegated`);
37
+ return next();
38
+ }
39
+ this.logger.info(`interactive: approval request agent=${logPrefix(sessionId)} claimed`);
40
+ const context = resolve.contextFor(sessionId);
41
+ return createApprovalAnswerer(this.#host(), () => context)(request, next);
42
+ });
43
+ rootCtx.on('user-questions/request', async (request, next) => {
44
+ const sessionId = sessionIdOf(request);
45
+ if (sessionId === undefined || !resolve.ownsSession(sessionId)) {
46
+ this.logger.info(`interactive: question request agent=${sessionId === undefined ? '?' : logPrefix(sessionId)} not ours, delegated`);
47
+ return next();
48
+ }
49
+ this.logger.info(`interactive: question request agent=${logPrefix(sessionId)} claimed`);
50
+ const context = resolve.contextFor(sessionId);
51
+ return createQuestionAnswerer(this.#host(), () => context)(request, next);
52
+ });
53
+ this.logger.info('interactive: answerers registered (root scope)');
54
+ return true;
55
+ }
56
+ /**
57
+ * Route one card click. Unknown ids are ignored; non-owners are ignored with
58
+ * a warning and do not resolve or patch anything.
59
+ */
60
+ async handleCardAction(action) {
61
+ const id = typeof action.value['id'] === 'string' ? action.value['id'] : undefined;
62
+ if (id === undefined) {
63
+ this.logger.info('card action ignored (unknown id)');
64
+ return;
65
+ }
66
+ this.logger.info(`card action id=${id} kind=${String(action.value['kind'] ?? '?')} operator=${logPrefix(action.operatorOpenId)}`);
67
+ const entry = this.pending.get(id);
68
+ if (entry === undefined) {
69
+ this.logger.info('card action ignored (unknown id)');
70
+ return;
71
+ }
72
+ if (action.operatorOpenId !== entry.ownerOpenId) {
73
+ this.logger.warn(`ignored card action from non-owner ${action.operatorOpenId} for ${entry.kind} ${id}`);
74
+ return;
75
+ }
76
+ if (entry.kind === 'question' && (action.formValue !== undefined || action.value['form'] === true)) {
77
+ const questions = entry.meta?.['questions'];
78
+ const question = questions?.[0]?.question ?? '问题';
79
+ const questionId = typeof action.value['questionId'] === 'string'
80
+ ? action.value['questionId']
81
+ : (questions?.[0]?.id ?? '');
82
+ const custom = (action.formValue?.['other'] ?? '').trim();
83
+ if (custom.length === 0) {
84
+ // Keep the pending entry and its timer: prompt again on the same card.
85
+ const rebuilt = questionCard({
86
+ requestId: entry.id,
87
+ sessionId: entry.sessionId,
88
+ ...(questions === undefined ? { questions: [] } : { questions }),
89
+ hint: '请填写后再提交',
90
+ });
91
+ await this.#patchTerminal(entry, rebuilt.card);
92
+ return;
93
+ }
94
+ this.pending.delete(id);
95
+ await this.#patchTerminal(entry, questionTerminalCard(question, `已填写:${custom.slice(0, 40)}`));
96
+ entry.resolve({ answers: [{ id: questionId, selected: [], custom }] });
97
+ return;
98
+ }
99
+ this.pending.delete(id);
100
+ if (entry.kind === 'approval') {
101
+ const choice = action.value['choice'] === 'allow' ? 'allow' : 'reject';
102
+ const outcome = choice === 'allow' ? 'allowed-once' : 'rejected';
103
+ await this.#patchTerminal(entry, approvalTerminalCard(String(entry.meta?.['toolName'] ?? 'tool'), outcome === 'allowed-once' ? '已允许' : '已拒绝'));
104
+ entry.resolve(outcome);
105
+ return;
106
+ }
107
+ const label = typeof action.value['label'] === 'string' ? action.value['label'] : '';
108
+ const questionId = typeof action.value['questionId'] === 'string' ? action.value['questionId'] : '';
109
+ const question = entry.meta?.['questions']?.[0]
110
+ ?.question ?? '问题';
111
+ await this.#patchTerminal(entry, questionTerminalCard(question, `已选择:${label}`));
112
+ entry.resolve({ answers: [{ id: questionId, selected: [label] }] });
113
+ }
114
+ async #patchTerminal(entry, card) {
115
+ if (entry.cardMessageId === undefined)
116
+ return;
117
+ try {
118
+ await this.#updateCard(entry.cardMessageId, card);
119
+ }
120
+ catch (error) {
121
+ this.logger.warn(`interactive card patch failed: ${error instanceof Error ? error.message : String(error)}`);
122
+ }
123
+ }
124
+ #host() {
125
+ return {
126
+ config: this.config,
127
+ logger: this.logger,
128
+ clock: this.clock,
129
+ pending: this.pending,
130
+ sendCard: (sessionId, card) => this.#sendCard(sessionId, card),
131
+ updateCard: (messageId, card) => this.#updateCard(messageId, card),
132
+ };
133
+ }
134
+ }
135
+ /** Extract `request.agent.session.id` structurally (dsh waterfall payloads). */
136
+ function sessionIdOf(request) {
137
+ if (typeof request !== 'object' || request === null)
138
+ return undefined;
139
+ const agent = request.agent;
140
+ if (typeof agent !== 'object' || agent === null)
141
+ return undefined;
142
+ const session = agent.session;
143
+ if (typeof session !== 'object' || session === null)
144
+ return undefined;
145
+ const id = session.id;
146
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
147
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared seams for caduceus interactive cards (approval + user questions).
3
+ *
4
+ * The dsh services and the Cordis waterfall are adapted structurally so unit
5
+ * tests never import dsh internals; the gateway wires the real transport and
6
+ * agent scope at the edge.
7
+ *
8
+ * @module dsh-caduceus/interactive/types
9
+ */
10
+ import type { LoggerLike } from '../logger.js';
11
+ import type { Clock } from '../render/stream.js';
12
+ import type { CardAction, FeishuCard } from '../transport/types.js';
13
+ export type { CardAction };
14
+ import type { PendingRegistry } from './pending.js';
15
+ export interface InteractiveConfig {
16
+ /** Send approval cards (default true). */
17
+ readonly approvalCard: boolean;
18
+ /** Send question cards (default true). */
19
+ readonly questionCard: boolean;
20
+ /** How long a card waits before it expires (ms, default 300000). */
21
+ readonly timeoutMs: number;
22
+ }
23
+ /** Where the card belongs and who may click it. */
24
+ export interface InteractiveContext {
25
+ readonly scopeKey: string;
26
+ readonly chatId: string;
27
+ readonly ownerOpenId: string;
28
+ readonly sessionId: string;
29
+ }
30
+ /** A Cordis context (the plugin root or an agent scope); only `on` is needed. */
31
+ export interface EventScopeLike {
32
+ on(name: string, listener: (...args: any[]) => any): unknown;
33
+ }
34
+ export declare function logPrefix(value: string, max?: number): string;
35
+ /** Short, safe description of a value for the attach-skipped diagnostic. */
36
+ export declare function describeScope(value: unknown): string;
37
+ export declare function isEventScope(value: unknown): value is EventScopeLike;
38
+ /** What the approval/question answerers need from the runtime. */
39
+ export interface InteractiveHost {
40
+ readonly config: InteractiveConfig;
41
+ readonly logger: LoggerLike;
42
+ readonly clock: Clock;
43
+ readonly pending: PendingRegistry;
44
+ sendCard(sessionId: string, card: FeishuCard): Promise<{
45
+ readonly messageId: string;
46
+ readonly threadId?: string;
47
+ }>;
48
+ updateCard(messageId: string, card: FeishuCard): Promise<void>;
49
+ }
50
+ /** CardAction is defined by the transport seam. */
@@ -0,0 +1,25 @@
1
+ export function logPrefix(value, max = 12) {
2
+ return value.length <= max ? value : value.slice(0, max);
3
+ }
4
+ /** Short, safe description of a value for the attach-skipped diagnostic. */
5
+ export function describeScope(value) {
6
+ if (value === null)
7
+ return 'null';
8
+ const type = typeof value;
9
+ if (type !== 'object')
10
+ return type;
11
+ let keys = [];
12
+ try {
13
+ keys = Object.keys(value).slice(0, 6);
14
+ }
15
+ catch {
16
+ return 'object (unreadable keys)';
17
+ }
18
+ return keys.length === 0 ? 'object keys=none' : `object keys=${keys.join(',')}`;
19
+ }
20
+ export function isEventScope(value) {
21
+ return (typeof value === 'object' &&
22
+ value !== null &&
23
+ typeof value.on === 'function');
24
+ }
25
+ /** CardAction is defined by the transport seam. */
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Leading Feishu mention stripping.
3
+ *
4
+ * Feishu renders @-mentions in message text either as `<at ...>...</at>` tags
5
+ * (already removed by the transport normalizer) or as placeholders such as
6
+ * `@_user_1` / `@_user_2`. Only mentions at the START of a message are removed:
7
+ * a `/command` typed after them must still be recognized, while ordinary text
8
+ * keeps its original form (mentions inside the body stay untouched).
9
+ *
10
+ * @module dsh-caduceus/mentions
11
+ */
12
+ /** Strip only the leading mention prefix; body mentions are preserved. */
13
+ export declare function stripLeadingMentions(text: string): string;
14
+ /** True when the text begins with one or more mentions. */
15
+ export declare function hasLeadingMentions(text: string): boolean;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Leading Feishu mention stripping.
3
+ *
4
+ * Feishu renders @-mentions in message text either as `<at ...>...</at>` tags
5
+ * (already removed by the transport normalizer) or as placeholders such as
6
+ * `@_user_1` / `@_user_2`. Only mentions at the START of a message are removed:
7
+ * a `/command` typed after them must still be recognized, while ordinary text
8
+ * keeps its original form (mentions inside the body stay untouched).
9
+ *
10
+ * @module dsh-caduceus/mentions
11
+ */
12
+ /** One or more leading `@mention` tokens followed by optional whitespace. */
13
+ const LEADING_MENTIONS = /^\s*(?:@\S+\s*)+/;
14
+ /** Strip only the leading mention prefix; body mentions are preserved. */
15
+ export function stripLeadingMentions(text) {
16
+ return text.replace(LEADING_MENTIONS, '');
17
+ }
18
+ /** True when the text begins with one or more mentions. */
19
+ export function hasLeadingMentions(text) {
20
+ return LEADING_MENTIONS.test(text);
21
+ }
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @module dsh-caduceus/transport/feishu-api
9
9
  */
10
- import type { FeishuCard, InboundMessage } from './types.js';
10
+ import type { CardAction, FeishuCard, InboundMessage } from './types.js';
11
11
  export interface CreateMessageInput {
12
12
  readonly chatId: string;
13
13
  readonly msgType: 'text' | 'interactive';
@@ -28,6 +28,12 @@ export interface FeishuApi {
28
28
  start(): Promise<void>;
29
29
  stop(): Promise<void>;
30
30
  onMessage(handler: (message: InboundMessage) => void): void;
31
+ /** Register the card.action.trigger callback. */
32
+ onCardAction?(handler: (action: CardAction) => void): void;
33
+ /** Add one emoji reaction; resolves the reaction id. */
34
+ addReaction(messageId: string, emojiType: string): Promise<string>;
35
+ /** Remove one reaction previously added by this bot. */
36
+ removeReaction(messageId: string, reactionId: string): Promise<void>;
31
37
  createMessage(input: CreateMessageInput): Promise<{
32
38
  messageId: string;
33
39
  }>;
@@ -37,6 +43,8 @@ export interface FeishuApi {
37
43
  patchCard(messageId: string, card: FeishuCard): Promise<void>;
38
44
  /** The bot's own open id, when resolved (group @ gate). */
39
45
  getBotOpenId?(): string | undefined;
46
+ /** Retry bot identity resolution (group @ gate). */
47
+ refreshBotOpenId?(): Promise<string | undefined>;
40
48
  /** Cached bot display name, resolved once at startup. */
41
49
  getBotName?(): string | undefined;
42
50
  /** Long-connection status and start time, when known. */
@@ -1,6 +1,6 @@
1
1
  import type { LoggerLike } from '../logger.js';
2
2
  import type { CreateMessageInput, FeishuApi, ReplyMessageInput } from './feishu-api.js';
3
- import type { FeishuCard, InboundMessage } from './types.js';
3
+ import type { CardAction, FeishuCard, InboundMessage } from './types.js';
4
4
  export interface LarkFeishuApiOptions {
5
5
  readonly appId: string;
6
6
  readonly appSecret: string;
@@ -13,13 +13,17 @@ export declare class LarkFeishuApi implements FeishuApi {
13
13
  private readonly dispatcher;
14
14
  private readonly logger;
15
15
  private handler;
16
+ private cardActionHandler;
16
17
  private running;
17
18
  private botOpenId;
18
19
  private botName;
19
20
  private startedAt;
20
21
  constructor(options: LarkFeishuApiOptions);
21
22
  onMessage(handler: (message: InboundMessage) => void): void;
23
+ onCardAction(handler: (action: CardAction) => void): void;
22
24
  start(): Promise<void>;
25
+ /** Retry bot identity resolution (called again on the next group message). */
26
+ refreshBotOpenId(): Promise<string | undefined>;
23
27
  /** Resolve the bot's own open id once, best-effort (group @ gate). */
24
28
  private resolveBotOpenId;
25
29
  /** The bot's own open id, or `undefined` until resolved. */
@@ -36,6 +40,8 @@ export declare class LarkFeishuApi implements FeishuApi {
36
40
  replyMessage(input: ReplyMessageInput): Promise<{
37
41
  messageId: string;
38
42
  }>;
43
+ addReaction(messageId: string, emojiType: string): Promise<string>;
44
+ removeReaction(messageId: string, reactionId: string): Promise<void>;
39
45
  patchCard(messageId: string, card: FeishuCard): Promise<void>;
40
46
  private messageIdOf;
41
47
  private assertOk;
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { Client, EventDispatcher, WSClient } from '@larksuiteoapi/node-sdk';
12
12
  import { silentLogger } from '../logger.js';
13
- import { normalizeMessageEvent } from './normalize.js';
13
+ import { normalizeCardAction, normalizeMessageEvent, } from './normalize.js';
14
14
  import { formatGateLine } from '../gate.js';
15
15
  import { FeishuApiError } from './types.js';
16
16
  /** Long-connection Feishu implementation of {@link FeishuApi}. */
@@ -20,6 +20,7 @@ export class LarkFeishuApi {
20
20
  dispatcher;
21
21
  logger;
22
22
  handler;
23
+ cardActionHandler;
23
24
  running = false;
24
25
  botOpenId;
25
26
  botName;
@@ -29,6 +30,11 @@ export class LarkFeishuApi {
29
30
  this.client = new Client({ appId: options.appId, appSecret: options.appSecret });
30
31
  this.dispatcher = new EventDispatcher({});
31
32
  this.dispatcher.register({
33
+ 'card.action.trigger': (data) => {
34
+ const action = normalizeCardAction(data);
35
+ if (action !== undefined)
36
+ this.cardActionHandler?.(action);
37
+ },
32
38
  'im.message.receive_v1': (data) => {
33
39
  const raw = data;
34
40
  const senderType = raw.sender?.sender_type;
@@ -47,7 +53,7 @@ export class LarkFeishuApi {
47
53
  }));
48
54
  return;
49
55
  }
50
- const message = normalizeMessageEvent(raw);
56
+ const message = normalizeMessageEvent(raw, this.botOpenId);
51
57
  if (message === undefined) {
52
58
  this.logger.debug('ignoring unsupported Feishu message event');
53
59
  return;
@@ -60,6 +66,9 @@ export class LarkFeishuApi {
60
66
  onMessage(handler) {
61
67
  this.handler = handler;
62
68
  }
69
+ onCardAction(handler) {
70
+ this.cardActionHandler = handler;
71
+ }
63
72
  async start() {
64
73
  if (this.running)
65
74
  return;
@@ -69,6 +78,11 @@ export class LarkFeishuApi {
69
78
  this.startedAt = Date.now();
70
79
  this.logger.info('feishu long connection started');
71
80
  }
81
+ /** Retry bot identity resolution (called again on the next group message). */
82
+ async refreshBotOpenId() {
83
+ await this.resolveBotOpenId();
84
+ return this.botOpenId;
85
+ }
72
86
  /** Resolve the bot's own open id once, best-effort (group @ gate). */
73
87
  async resolveBotOpenId() {
74
88
  try {
@@ -134,6 +148,24 @@ export class LarkFeishuApi {
134
148
  }));
135
149
  return { messageId: this.messageIdOf(response, 'im.v1.message.reply') };
136
150
  }
151
+ async addReaction(messageId, emojiType) {
152
+ const response = (await this.client.im.v1.messageReaction.create({
153
+ data: { reaction_type: { emoji_type: emojiType } },
154
+ path: { message_id: messageId },
155
+ }));
156
+ this.assertOk(response, 'im.v1.message.reaction.create');
157
+ const reactionId = response.data?.reaction_id;
158
+ if (reactionId === undefined || reactionId.length === 0) {
159
+ throw new FeishuApiError('feishu im.v1.message.reaction.create returned no reaction_id', -1);
160
+ }
161
+ return reactionId;
162
+ }
163
+ async removeReaction(messageId, reactionId) {
164
+ const response = (await this.client.im.v1.messageReaction.delete({
165
+ path: { message_id: messageId, reaction_id: reactionId },
166
+ }));
167
+ this.assertOk(response, 'im.v1.message.reaction.delete');
168
+ }
137
169
  async patchCard(messageId, card) {
138
170
  const response = (await this.client.im.v1.message.patch({
139
171
  data: { content: JSON.stringify(card) },
@@ -1,24 +1,4 @@
1
- /**
2
- * Pure normalization of a Feishu `im.message.receive_v1` payload.
3
- *
4
- * The topic identity is the whole point of this module: the bridge keys
5
- * sessions by `(chatId, threadId)`, and the bot must answer in the same topic
6
- * (design doc A6). Feishu puts the topic id in `thread_id`; older/related
7
- * shapes only carry `root_id`, so the rule is:
8
- *
9
- * threadId = message.thread_id ?? message.root_id
10
- *
11
- * Non-human senders (`sender.sender_type !== 'user'`) are dropped FIRST, before
12
- * any other judgement: a bot/app event (e.g. an app self-improvement notice)
13
- * must never become a session prompt or a fallback title. Feishu events from
14
- * humans carry `sender_type: 'user'`; a missing sender_type is treated as
15
- * non-human on purpose (fail closed).
16
- *
17
- * Mention open ids are preserved so the bridge can enforce the group @ gate.
18
- *
19
- * @module dsh-caduceus/transport/normalize
20
- */
21
- import type { InboundMessage } from './types.js';
1
+ import type { CardAction, InboundMessage } from './types.js';
22
2
  /** Structural subset of the SDK's `RawMessageEvent` (no SDK import needed). */
23
3
  export interface RawMessageEventLike {
24
4
  readonly sender?: {
@@ -39,6 +19,7 @@ export interface RawMessageEventLike {
39
19
  readonly parent_id?: string;
40
20
  readonly mentions?: readonly {
41
21
  readonly key?: string;
22
+ readonly name?: string;
42
23
  readonly id?: {
43
24
  readonly open_id?: string;
44
25
  };
@@ -51,4 +32,23 @@ export interface RawMessageEventLike {
51
32
  * @returns the normalized message, or `undefined` for unsupported/malformed
52
33
  * payloads or empty text.
53
34
  */
54
- export declare function normalizeMessageEvent(raw: RawMessageEventLike): InboundMessage | undefined;
35
+ export declare function normalizeMessageEvent(raw: RawMessageEventLike, botOpenId?: string): InboundMessage | undefined;
36
+ /** Raw `card.action.trigger` payload fields used by the surface. */
37
+ export interface RawCardActionLike {
38
+ readonly open_id?: string;
39
+ readonly operator?: {
40
+ readonly open_id?: string;
41
+ };
42
+ readonly open_message_id?: string;
43
+ readonly open_chat_id?: string;
44
+ readonly context?: {
45
+ readonly open_message_id?: string;
46
+ readonly open_chat_id?: string;
47
+ };
48
+ readonly action?: {
49
+ readonly value?: Record<string, unknown>;
50
+ readonly form_value?: Record<string, unknown>;
51
+ };
52
+ }
53
+ /** Normalize Feishu's card click into the transport's CardAction. */
54
+ export declare function normalizeCardAction(raw: RawCardActionLike): CardAction | undefined;