dsh-caduceus 0.1.1 → 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,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;
@@ -1,4 +1,25 @@
1
- const MENTION_PATTERN = /<at[^>]*>.*?<\/at>/g;
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 { stripLeadingMentions } from '../mentions.js';
22
+ const AT_TAG_PATTERN = /<at[^>]*>(.*?)<\/at>/gs;
2
23
  /** Message types the v0.1 surface handles (attachments are v0.2). */
3
24
  const SUPPORTED_MESSAGE_TYPES = new Set(['text']);
4
25
  /**
@@ -7,7 +28,7 @@ const SUPPORTED_MESSAGE_TYPES = new Set(['text']);
7
28
  * @returns the normalized message, or `undefined` for unsupported/malformed
8
29
  * payloads or empty text.
9
30
  */
10
- export function normalizeMessageEvent(raw) {
31
+ export function normalizeMessageEvent(raw, botOpenId) {
11
32
  // Priority #1: only human messages may enter a session.
12
33
  const senderType = raw.sender?.sender_type;
13
34
  if (senderType !== 'user')
@@ -25,7 +46,44 @@ export function normalizeMessageEvent(raw) {
25
46
  catch {
26
47
  return undefined;
27
48
  }
28
- text = text.replace(MENTION_PATTERN, ' ').replace(/\r\n?/g, '\n').trim();
49
+ // Preserve the display text inside <at> tags first: key -> @name replacement
50
+ // below needs the placeholder text to still be present.
51
+ text = text.replace(AT_TAG_PATTERN, (_match, inner) => ` @${inner.trim().replace(/^@/, '')} `);
52
+ text = text.replace(/\r\n?/g, '\n');
53
+ const rawMentions = message.mentions ?? [];
54
+ const entries = rawMentions
55
+ .map((mention) => ({
56
+ key: mention.key,
57
+ name: mention.name,
58
+ openId: mention.id?.open_id,
59
+ }))
60
+ .filter((entry) => entry.key !== undefined || entry.name !== undefined || entry.openId !== undefined);
61
+ if (entries.length === 0) {
62
+ // No mention metadata: keep the previous behaviour (strip placeholders).
63
+ text = text.trim();
64
+ text = stripLeadingMentions(text).trim();
65
+ }
66
+ else {
67
+ // 1) Remove the bot's own mention(s) entirely (agent need not see "@self").
68
+ for (const entry of entries) {
69
+ const own = botOpenId !== undefined && botOpenId.length > 0 && entry.openId === botOpenId;
70
+ if (!own)
71
+ continue;
72
+ if (entry.key !== undefined && entry.key.length > 0)
73
+ text = text.split(entry.key).join(' ');
74
+ if (entry.name !== undefined && entry.name.length > 0)
75
+ text = text.split(`@${entry.name}`).join(' ');
76
+ }
77
+ // 2) Replace remaining placeholder keys with @display-name (longest first,
78
+ // so @_user_10 is never clobbered by @_user_1).
79
+ const replaceable = entries
80
+ .filter((entry) => entry.name !== undefined && entry.name.length > 0 && entry.key !== undefined)
81
+ .sort((left, right) => (right.key?.length ?? 0) - (left.key?.length ?? 0));
82
+ for (const entry of replaceable) {
83
+ text = text.split(entry.key).join(`@${entry.name}`);
84
+ }
85
+ text = text.replace(/[ \t]{2,}/g, ' ').trim();
86
+ }
29
87
  if (text.length === 0)
30
88
  return undefined;
31
89
  const threadId = firstNonEmpty(message.thread_id, message.root_id);
@@ -52,3 +110,25 @@ function firstNonEmpty(...values) {
52
110
  }
53
111
  return undefined;
54
112
  }
113
+ /** Normalize Feishu's card click into the transport's CardAction. */
114
+ export function normalizeCardAction(raw) {
115
+ const operatorOpenId = raw.operator?.open_id ?? raw.open_id ?? '';
116
+ const messageId = raw.context?.open_message_id ?? raw.open_message_id ?? '';
117
+ const chatId = raw.context?.open_chat_id ?? raw.open_chat_id ?? '';
118
+ const value = raw.action?.value;
119
+ if (operatorOpenId.length === 0 || messageId.length === 0)
120
+ return undefined;
121
+ if (typeof value !== 'object' || value === null)
122
+ return undefined;
123
+ const rawForm = raw.action?.form_value;
124
+ const formValue = typeof rawForm === 'object' && rawForm !== null
125
+ ? Object.fromEntries(Object.entries(rawForm).filter((entry) => typeof entry[1] === 'string'))
126
+ : undefined;
127
+ return {
128
+ operatorOpenId,
129
+ messageId,
130
+ chatId,
131
+ value,
132
+ ...(formValue === undefined || Object.keys(formValue).length === 0 ? {} : { formValue }),
133
+ };
134
+ }
@@ -1,6 +1,6 @@
1
1
  import { type LoggerLike } from '../logger.js';
2
2
  import type { FeishuApi } from './feishu-api.js';
3
- import type { FeishuCard, InboundMessage, ReplyOptions, ReplyPayload, SendOptions, SentMessage, Transport } from './types.js';
3
+ import type { CardAction, FeishuCard, InboundMessage, ReplyOptions, ReplyPayload, SendOptions, SentMessage, Transport } from './types.js';
4
4
  export interface ReliableTransportOptions {
5
5
  readonly logger?: LoggerLike;
6
6
  /** Idempotency-key factory (injectable for deterministic tests). */
@@ -16,8 +16,10 @@ export declare class ReliableTransport implements Transport {
16
16
  start(): Promise<void>;
17
17
  stop(): Promise<void>;
18
18
  onMessage(handler: (message: InboundMessage) => void): void;
19
+ onCardAction(handler: (action: CardAction) => void): void;
19
20
  /** Forward the bot open id when the underlying API resolved one. */
20
21
  getBotOpenId(): string | undefined;
22
+ refreshBotOpenId(): Promise<string | undefined>;
21
23
  getBotName(): string | undefined;
22
24
  getConnectionStatus(): {
23
25
  readonly ready: boolean;
@@ -30,6 +32,8 @@ export declare class ReliableTransport implements Transport {
30
32
  };
31
33
  sendText(chatId: string, text: string, options?: SendOptions): Promise<SentMessage>;
32
34
  sendCard(chatId: string, card: FeishuCard, options?: SendOptions): Promise<SentMessage>;
35
+ addReaction(messageId: string, emojiType: string): Promise<string>;
36
+ removeReaction(messageId: string, reactionId: string): Promise<void>;
33
37
  updateCard(messageId: string, card: FeishuCard): Promise<void>;
34
38
  reply(chatId: string, messageId: string, payload: ReplyPayload, options?: ReplyOptions): Promise<SentMessage>;
35
39
  }
@@ -37,10 +37,16 @@ export class ReliableTransport {
37
37
  onMessage(handler) {
38
38
  this.api.onMessage(handler);
39
39
  }
40
+ onCardAction(handler) {
41
+ this.api.onCardAction?.(handler);
42
+ }
40
43
  /** Forward the bot open id when the underlying API resolved one. */
41
44
  getBotOpenId() {
42
45
  return this.api.getBotOpenId?.();
43
46
  }
47
+ refreshBotOpenId() {
48
+ return this.api.refreshBotOpenId?.() ?? Promise.resolve(this.api.getBotOpenId?.());
49
+ }
44
50
  getBotName() {
45
51
  return this.api.getBotName?.();
46
52
  }
@@ -94,6 +100,12 @@ export class ReliableTransport {
94
100
  uuid: options.uuid ?? this.uuidFactory(),
95
101
  }));
96
102
  }
103
+ addReaction(messageId, emojiType) {
104
+ return this.api.addReaction(messageId, emojiType);
105
+ }
106
+ removeReaction(messageId, reactionId) {
107
+ return this.api.removeReaction(messageId, reactionId);
108
+ }
97
109
  async updateCard(messageId, card) {
98
110
  assertUpdateMulti(card);
99
111
  await this.#recordVoid('卡片', () => this.api.patchCard(messageId, card));
@@ -47,6 +47,15 @@ export interface InboundMessage {
47
47
  /** Unix epoch milliseconds from Feishu; `0` when the event omitted it. */
48
48
  readonly createdAt: number;
49
49
  }
50
+ /** One interactive-card button click, normalized for the bridge. */
51
+ export interface CardAction {
52
+ readonly operatorOpenId: string;
53
+ readonly messageId: string;
54
+ readonly chatId: string;
55
+ readonly value: Record<string, unknown>;
56
+ /** Feishu form fields submitted with the action (`action.form_value`). */
57
+ readonly formValue?: Readonly<Record<string, string>>;
58
+ }
50
59
  /** A created message. */
51
60
  export interface SentMessage {
52
61
  readonly messageId: string;
@@ -110,11 +119,17 @@ export interface Transport {
110
119
  sendText(chatId: string, text: string, options?: SendOptions): Promise<SentMessage>;
111
120
  sendCard(chatId: string, card: FeishuCard, options?: SendOptions): Promise<SentMessage>;
112
121
  updateCard(messageId: string, card: FeishuCard): Promise<void>;
122
+ /** Add one emoji reaction; resolves the reaction id. */
123
+ addReaction(messageId: string, emojiType: string): Promise<string>;
124
+ /** Remove one reaction previously added by this bot. */
125
+ removeReaction(messageId: string, reactionId: string): Promise<void>;
113
126
  /**
114
127
  * The bot's own open id when known (needed for the group @ gate). Optional:
115
128
  * fake transports may omit it, and the real one resolves it lazily.
116
129
  */
117
130
  getBotOpenId?(): string | undefined;
131
+ /** Retry bot identity resolution (group @ gate). */
132
+ refreshBotOpenId?(): Promise<string | undefined>;
118
133
  /** Cached bot display name (resolved at startup; no extra API call). */
119
134
  getBotName?(): string | undefined;
120
135
  /** Long-connection status/start time, when known. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-caduceus",
3
- "version": "0.1.1",
3
+ "version": "0.4.1",
4
4
  "description": "Feishu/Lark surface plugin for DeepSeek Harness (dsh): one topic per session, SOUL persona sync, streaming process cards, /status and /doctor.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",