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.
- package/README.md +12 -0
- package/README.zh.md +12 -0
- package/dist/bridge/bridge.d.ts +9 -0
- package/dist/bridge/bridge.js +319 -16
- package/dist/bridge/types.d.ts +38 -0
- package/dist/commands/parse.d.ts +0 -11
- package/dist/commands/parse.js +5 -1
- package/dist/config.d.ts +68 -1
- package/dist/config.js +39 -0
- package/dist/gate.d.ts +3 -1
- package/dist/gate.js +6 -5
- package/dist/index.js +21 -1
- package/dist/interactive/approval.d.ts +9 -0
- package/dist/interactive/approval.js +85 -0
- package/dist/interactive/cards.d.ts +36 -0
- package/dist/interactive/cards.js +125 -0
- package/dist/interactive/pending.d.ts +29 -0
- package/dist/interactive/pending.js +39 -0
- package/dist/interactive/question.d.ts +25 -0
- package/dist/interactive/question.js +86 -0
- package/dist/interactive/runtime.d.ts +44 -0
- package/dist/interactive/runtime.js +147 -0
- package/dist/interactive/types.d.ts +50 -0
- package/dist/interactive/types.js +25 -0
- package/dist/mentions.d.ts +15 -0
- package/dist/mentions.js +21 -0
- package/dist/transport/feishu-api.d.ts +14 -1
- package/dist/transport/lark.d.ts +11 -1
- package/dist/transport/lark.js +61 -2
- package/dist/transport/normalize.d.ts +24 -23
- package/dist/transport/normalize.js +148 -23
- package/dist/transport/reliable.d.ts +9 -1
- package/dist/transport/reliable.js +15 -0
- package/dist/transport/types.d.ts +31 -0
- package/package.json +1 -1
|
@@ -1,48 +1,151 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|