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.
@@ -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.0",
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",
@@ -37,7 +37,8 @@
37
37
  "dist/",
38
38
  "cordis.patch.yml",
39
39
  "README.md",
40
- "LICENSE"
40
+ "LICENSE",
41
+ "README.zh.md"
41
42
  ],
42
43
  "engines": {
43
44
  "node": ">=22.13",