dsh-caduceus 0.1.1 → 0.5.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,48 +1,151 @@
1
- const MENTION_PATTERN = /<at[^>]*>.*?<\/at>/g;
2
- /** Message types the v0.1 surface handles (attachments are v0.2). */
3
- const SUPPORTED_MESSAGE_TYPES = new Set(['text']);
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;
23
+ /** Message types the surface can process (attachments included). */
24
+ const KNOWN_MESSAGE_TYPES = new Set(['text', 'image', 'file', 'post']);
25
+ function restoreMentions(text, rawMentions, botOpenId) {
26
+ text = text.replace(AT_TAG_PATTERN, (_match, inner) => ` @${inner.trim().replace(/^@/, '')} `);
27
+ text = text.replace(/\r\n?/g, '\n');
28
+ const entries = rawMentions
29
+ .map((mention) => ({ key: mention.key, name: mention.name, openId: mention.id?.open_id }))
30
+ .filter((entry) => entry.key !== undefined || entry.name !== undefined || entry.openId !== undefined);
31
+ if (entries.length === 0)
32
+ return stripLeadingMentions(text.trim()).trim();
33
+ for (const entry of entries) {
34
+ const own = botOpenId !== undefined && botOpenId.length > 0 && entry.openId === botOpenId;
35
+ if (!own)
36
+ continue;
37
+ if (entry.key !== undefined && entry.key.length > 0)
38
+ text = text.split(entry.key).join(' ');
39
+ if (entry.name !== undefined && entry.name.length > 0)
40
+ text = text.split(`@${entry.name}`).join(' ');
41
+ }
42
+ const replaceable = entries
43
+ .filter((entry) => entry.name !== undefined && entry.name.length > 0 && entry.key !== undefined)
44
+ .sort((left, right) => (right.key?.length ?? 0) - (left.key?.length ?? 0));
45
+ for (const entry of replaceable) {
46
+ text = text.split(entry.key).join(`@${entry.name}`);
47
+ }
48
+ return text.replace(/[ \t]{2,}/g, ' ').trim();
49
+ }
50
+ function postParts(parsed) {
51
+ const attachments = [];
52
+ const texts = [];
53
+ if (typeof parsed.title === 'string' && parsed.title.trim().length > 0) {
54
+ texts.push(parsed.title.trim());
55
+ }
56
+ const flatten = (value) => {
57
+ if (!Array.isArray(value))
58
+ return;
59
+ for (const item of value) {
60
+ if (Array.isArray(item)) {
61
+ flatten(item);
62
+ continue;
63
+ }
64
+ if (typeof item !== 'object' || item === null)
65
+ continue;
66
+ const block = item;
67
+ if (block.tag === 'text' && typeof block.text === 'string')
68
+ texts.push(block.text);
69
+ if ((block.tag === 'img' || block.tag === 'image') &&
70
+ typeof block.image_key === 'string' &&
71
+ block.image_key.length > 0) {
72
+ attachments.push({ kind: 'image', fileKey: block.image_key });
73
+ }
74
+ }
75
+ };
76
+ flatten(parsed.content);
77
+ return { text: texts.join(' '), attachments };
78
+ }
4
79
  /**
5
80
  * Normalize one raw event.
6
81
  *
7
82
  * @returns the normalized message, or `undefined` for unsupported/malformed
8
- * payloads or empty text.
83
+ * payloads or empty text. Unsupported message types return a message with
84
+ * `unsupportedType` so the bridge can reply and react explicitly.
9
85
  */
10
- export function normalizeMessageEvent(raw) {
11
- // Priority #1: only human messages may enter a session.
86
+ export function normalizeMessageEvent(raw, botOpenId) {
12
87
  const senderType = raw.sender?.sender_type;
13
88
  if (senderType !== 'user')
14
89
  return undefined;
15
90
  const message = raw.message;
16
- if (!SUPPORTED_MESSAGE_TYPES.has(message.message_type))
17
- return undefined;
18
- let text;
19
- try {
20
- const parsed = JSON.parse(message.content);
21
- if (typeof parsed.text !== 'string')
22
- return undefined;
23
- text = parsed.text;
24
- }
25
- catch {
26
- return undefined;
27
- }
28
- text = text.replace(MENTION_PATTERN, ' ').replace(/\r\n?/g, '\n').trim();
29
- if (text.length === 0)
30
- return undefined;
31
91
  const threadId = firstNonEmpty(message.thread_id, message.root_id);
32
92
  const rootId = firstNonEmpty(message.root_id);
33
93
  const mentions = (message.mentions ?? [])
34
94
  .map((mention) => mention.id?.open_id)
35
95
  .filter((openId) => typeof openId === 'string' && openId.length > 0);
36
- return {
96
+ const base = () => ({
37
97
  messageId: message.message_id,
38
98
  chatId: message.chat_id,
39
99
  chatType: message.chat_type === 'group' ? 'group' : 'p2p',
40
100
  senderOpenId: raw.sender?.sender_id?.open_id ?? '',
41
101
  ...(mentions.length === 0 ? {} : { mentions }),
42
- text,
43
102
  ...(threadId === undefined ? {} : { threadId }),
44
103
  ...(rootId === undefined ? {} : { rootId }),
45
104
  createdAt: Number(message.create_time) || 0,
105
+ });
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(message.content);
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ if (!KNOWN_MESSAGE_TYPES.has(message.message_type)) {
114
+ return { ...base(), text: '', unsupportedType: message.message_type };
115
+ }
116
+ let text = '';
117
+ let attachments = [];
118
+ if (message.message_type === 'image') {
119
+ if (typeof parsed.image_key !== 'string' || parsed.image_key.length === 0)
120
+ return undefined;
121
+ attachments = [{ kind: 'image', fileKey: parsed.image_key }];
122
+ }
123
+ else if (message.message_type === 'file') {
124
+ if (typeof parsed.file_key !== 'string' || parsed.file_key.length === 0)
125
+ return undefined;
126
+ attachments = [{
127
+ kind: 'file',
128
+ fileKey: parsed.file_key,
129
+ ...(typeof parsed.file_name === 'string' && parsed.file_name.length > 0
130
+ ? { fileName: parsed.file_name }
131
+ : {}),
132
+ }];
133
+ }
134
+ else if (message.message_type === 'post') {
135
+ const parts = postParts(parsed);
136
+ text = parts.text;
137
+ attachments = parts.attachments;
138
+ }
139
+ else {
140
+ text = typeof parsed.text === 'string' ? parsed.text : '';
141
+ }
142
+ text = restoreMentions(text, message.mentions ?? [], botOpenId);
143
+ if (text.length === 0 && attachments.length === 0)
144
+ return undefined;
145
+ return {
146
+ ...base(),
147
+ text,
148
+ ...(attachments.length === 0 ? {} : { attachments }),
46
149
  };
47
150
  }
48
151
  function firstNonEmpty(...values) {
@@ -52,3 +155,25 @@ function firstNonEmpty(...values) {
52
155
  }
53
156
  return undefined;
54
157
  }
158
+ /** Normalize Feishu's card click into the transport's CardAction. */
159
+ export function normalizeCardAction(raw) {
160
+ const operatorOpenId = raw.operator?.open_id ?? raw.open_id ?? '';
161
+ const messageId = raw.context?.open_message_id ?? raw.open_message_id ?? '';
162
+ const chatId = raw.context?.open_chat_id ?? raw.open_chat_id ?? '';
163
+ const value = raw.action?.value;
164
+ if (operatorOpenId.length === 0 || messageId.length === 0)
165
+ return undefined;
166
+ if (typeof value !== 'object' || value === null)
167
+ return undefined;
168
+ const rawForm = raw.action?.form_value;
169
+ const formValue = typeof rawForm === 'object' && rawForm !== null
170
+ ? Object.fromEntries(Object.entries(rawForm).filter((entry) => typeof entry[1] === 'string'))
171
+ : undefined;
172
+ return {
173
+ operatorOpenId,
174
+ messageId,
175
+ chatId,
176
+ value,
177
+ ...(formValue === undefined || Object.keys(formValue).length === 0 ? {} : { formValue }),
178
+ };
179
+ }
@@ -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,12 @@ 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>;
37
+ downloadResource(messageId: string, fileKey: string, type: 'image' | 'file', maxBytes: number): Promise<{
38
+ readonly bytes: Uint8Array;
39
+ readonly contentType?: string;
40
+ }>;
33
41
  updateCard(messageId: string, card: FeishuCard): Promise<void>;
34
42
  reply(chatId: string, messageId: string, payload: ReplyPayload, options?: ReplyOptions): Promise<SentMessage>;
35
43
  }
@@ -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,15 @@ 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
+ }
109
+ downloadResource(messageId, fileKey, type, maxBytes) {
110
+ return this.api.downloadResource(messageId, fileKey, type, maxBytes);
111
+ }
97
112
  async updateCard(messageId, card) {
98
113
  assertUpdateMulti(card);
99
114
  await this.#recordVoid('卡片', () => this.api.patchCard(messageId, card));
@@ -46,6 +46,26 @@ export interface InboundMessage {
46
46
  readonly rootId?: string;
47
47
  /** Unix epoch milliseconds from Feishu; `0` when the event omitted it. */
48
48
  readonly createdAt: number;
49
+ /** Image/file attachments carried by this message. */
50
+ readonly attachments?: readonly InboundAttachment[];
51
+ /** Set for message types caduceus cannot process (audio/video/sticker/...). */
52
+ readonly unsupportedType?: string;
53
+ }
54
+ /** One inbound attachment reference (image/file message or post image). */
55
+ export interface InboundAttachment {
56
+ readonly kind: 'image' | 'file';
57
+ readonly fileKey: string;
58
+ readonly fileName?: string;
59
+ readonly mediaType?: string;
60
+ }
61
+ /** One interactive-card button click, normalized for the bridge. */
62
+ export interface CardAction {
63
+ readonly operatorOpenId: string;
64
+ readonly messageId: string;
65
+ readonly chatId: string;
66
+ readonly value: Record<string, unknown>;
67
+ /** Feishu form fields submitted with the action (`action.form_value`). */
68
+ readonly formValue?: Readonly<Record<string, string>>;
49
69
  }
50
70
  /** A created message. */
51
71
  export interface SentMessage {
@@ -110,11 +130,22 @@ export interface Transport {
110
130
  sendText(chatId: string, text: string, options?: SendOptions): Promise<SentMessage>;
111
131
  sendCard(chatId: string, card: FeishuCard, options?: SendOptions): Promise<SentMessage>;
112
132
  updateCard(messageId: string, card: FeishuCard): Promise<void>;
133
+ /** Add one emoji reaction; resolves the reaction id. */
134
+ addReaction(messageId: string, emojiType: string): Promise<string>;
135
+ /** Remove one reaction previously added by this bot. */
136
+ removeReaction(messageId: string, reactionId: string): Promise<void>;
137
+ /** Download a message resource (image/file), enforcing a byte limit. */
138
+ downloadResource(messageId: string, fileKey: string, type: 'image' | 'file', maxBytes: number): Promise<{
139
+ readonly bytes: Uint8Array;
140
+ readonly contentType?: string;
141
+ }>;
113
142
  /**
114
143
  * The bot's own open id when known (needed for the group @ gate). Optional:
115
144
  * fake transports may omit it, and the real one resolves it lazily.
116
145
  */
117
146
  getBotOpenId?(): string | undefined;
147
+ /** Retry bot identity resolution (group @ gate). */
148
+ refreshBotOpenId?(): Promise<string | undefined>;
118
149
  /** Cached bot display name (resolved at startup; no extra API call). */
119
150
  getBotName?(): string | undefined;
120
151
  /** 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.5.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",