dsh-caduceus 0.4.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 CHANGED
@@ -87,6 +87,9 @@ onlyBuiltDependencies:
87
87
  | `reactionOnDone` | `DONE` | Reaction added when a turn completes |
88
88
  | `reactionOnFail` | `CROSS_MARK` | Reaction added when a turn errors/interrupts (falls back to `THINKING`) |
89
89
  | `gateRequireBotIdentity` | `true` | Group @ gate fails closed when the bot open id is unknown |
90
+ | `images` | `true` | Accept image messages and attach them to the agent prompt |
91
+ | `files` | `true` | Accept file messages (durable attachment or saved to cwd) |
92
+ | `maxAttachmentBytes` | `20971520` | Per-attachment download limit (20 MiB) |
90
93
 
91
94
  ## Commands
92
95
 
package/README.zh.md CHANGED
@@ -86,6 +86,9 @@ onlyBuiltDependencies:
86
86
  | `reactionOnDone` | `DONE` | 回合完成时添加的表情 |
87
87
  | `reactionOnFail` | `CROSS_MARK` | 回合出错/中断时添加的表情(失败退回 `THINKING`) |
88
88
  | `gateRequireBotIdentity` | `true` | bot open id 未知时群 @ 门禁 fail-closed(false 恢复旧放行) |
89
+ | `images` | `true` | 接收图片消息并作为附件交给 agent |
90
+ | `files` | `true` | 接收文件消息(附件服务或落到 cwd) |
91
+ | `maxAttachmentBytes` | `20971520` | 单个附件下载上限(20 MiB) |
89
92
 
90
93
  ## 命令
91
94
 
@@ -17,6 +17,8 @@
17
17
  * @module dsh-caduceus/bridge/bridge
18
18
  */
19
19
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
20
+ import { mkdirSync, writeFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
20
22
  import { planSlashLine } from '../commands/dispatch.js';
21
23
  import { helpText } from '../commands/parse.js';
22
24
  import { evaluateGate, logGate } from '../gate.js';
@@ -27,7 +29,7 @@ import { writeOwnership } from '../ownership.js';
27
29
  import { finalCard, finalCardPlainText, progressCard, } from '../render/cards.js';
28
30
  import { CardStream, systemClock } from '../render/stream.js';
29
31
  import { mintSessionId, scopeKey, SCOPE_KEY_SEPARATOR } from '../session-key.js';
30
- import { StatusTracker, shouldNotifyTerminal, recentEventText, statusLabel, statusTemplate, statusText, terminalNotice, } from '../status.js';
32
+ import { StatusTracker, shouldNotifyTerminal, stateForTurnEnd, recentEventText, statusLabel, statusTemplate, statusText, terminalNotice, } from '../status.js';
31
33
  import { ScopeQueue } from './scope-queue.js';
32
34
  function errorMessage(error) {
33
35
  return error instanceof Error ? error.message : String(error);
@@ -133,6 +135,13 @@ export class Bridge {
133
135
  }
134
136
  /** Process one inbound message (serialized per scope). */
135
137
  async handleInbound(message) {
138
+ if (message.unsupportedType !== undefined) {
139
+ const state = this.#stateFor(message);
140
+ this.#logger.info(`unsupported message type: ${message.unsupportedType}`);
141
+ await this.#replyText(state, '这类消息暂不支持。');
142
+ await this.#safeAddReaction(message.messageId, 'CROSS_MARK');
143
+ return;
144
+ }
136
145
  // Bot identity may have failed at startup; retry on the next group message
137
146
  // before deciding, so a late bot/v3/info success restores normal gating.
138
147
  if (message.chatType === 'group' && (this.#transport.getBotOpenId?.() ?? '').length === 0) {
@@ -149,7 +158,9 @@ export class Bridge {
149
158
  chatId: message.chatId,
150
159
  ...(message.threadId === undefined ? {} : { threadId: message.threadId }),
151
160
  senderOpenId: message.senderOpenId,
152
- text: message.text,
161
+ text: message.text.trim().length === 0 && (message.attachments?.length ?? 0) > 0
162
+ ? '[attachment]'
163
+ : message.text,
153
164
  ...(message.mentions === undefined ? {} : { mentions: message.mentions }),
154
165
  ...(botOpenId === undefined ? {} : { botOpenId }),
155
166
  requireBotIdentity: this.#config.gateRequireBotIdentity !== false,
@@ -254,7 +265,10 @@ export class Bridge {
254
265
  }
255
266
  state.reactionQueue.push(message.messageId);
256
267
  this.#startReaction(state, message.messageId);
257
- await this.#deliverTurn(state, message.text);
268
+ await this.#deliverTurn(state, {
269
+ text: message.text,
270
+ ...(message.attachments === undefined ? {} : { attachments: message.attachments }),
271
+ });
258
272
  }
259
273
  async #handleSlash(state, raw) {
260
274
  const plan = planSlashLine(raw);
@@ -410,14 +424,136 @@ export class Bridge {
410
424
  }
411
425
  }
412
426
  }
413
- async #deliverTurn(state, text) {
427
+ async #prepareAttachments(state, input) {
428
+ const attachments = input.attachments ?? [];
429
+ if (attachments.length === 0) {
430
+ return { content: [{ type: 'text', text: input.text }] };
431
+ }
432
+ const maxBytes = this.#config.maxAttachmentBytes ?? 20 * 1024 * 1024;
433
+ const imagesEnabled = this.#config.images !== false;
434
+ const filesEnabled = this.#config.files !== false;
435
+ const kept = [];
436
+ let imageCount = 0;
437
+ let truncatedImages = 0;
438
+ let ignored = 0;
439
+ for (const attachment of attachments) {
440
+ if (attachment.kind === 'image') {
441
+ if (!imagesEnabled) {
442
+ ignored += 1;
443
+ continue;
444
+ }
445
+ if (imageCount >= 9) {
446
+ truncatedImages += 1;
447
+ continue;
448
+ }
449
+ imageCount += 1;
450
+ kept.push(attachment);
451
+ }
452
+ else if (!filesEnabled) {
453
+ ignored += 1;
454
+ }
455
+ else {
456
+ kept.push(attachment);
457
+ }
458
+ }
459
+ if (ignored > 0) {
460
+ await this.#replyText(state, '当前未启用图片/文件消息,已忽略部分附件。');
461
+ }
462
+ let annotated = input.text;
463
+ if (truncatedImages > 0) {
464
+ annotated += `(图片超过 9 张,已截断 ${truncatedImages} 张)`;
465
+ }
466
+ const store = this.#options.attachmentProvider?.();
467
+ const parts = [];
468
+ for (const attachment of kept) {
469
+ let downloaded;
470
+ try {
471
+ downloaded = await this.#transport.downloadResource(state.lastInboundMessageId ?? '', attachment.fileKey, attachment.kind, maxBytes);
472
+ }
473
+ catch (error) {
474
+ const tooLarge = errorMessage(error).includes('too large');
475
+ await this.#replyText(state, tooLarge ? '文件太大,已跳过。' : '附件下载失败,已跳过。');
476
+ await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
477
+ return undefined;
478
+ }
479
+ if (downloaded.bytes.length > maxBytes) {
480
+ await this.#replyText(state, '文件太大,已跳过。');
481
+ await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
482
+ return undefined;
483
+ }
484
+ const base64 = Buffer.from(downloaded.bytes).toString('base64');
485
+ if (attachment.kind === 'image') {
486
+ if (store === undefined) {
487
+ await this.#replyText(state, '当前无法处理图片(attachments 服务不可用)。');
488
+ await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
489
+ return undefined;
490
+ }
491
+ parts.push({
492
+ type: 'image',
493
+ mediaType: imageMediaType(downloaded.contentType),
494
+ data: base64,
495
+ ...(attachment.fileName === undefined ? {} : { name: attachment.fileName }),
496
+ });
497
+ continue;
498
+ }
499
+ const fileName = attachment.fileName ?? attachment.fileKey;
500
+ let admitted = false;
501
+ if (store !== undefined) {
502
+ try {
503
+ const ref = await store.admitEncodedFile({ data: base64, name: fileName });
504
+ parts.push({ type: 'file', attachment: ref });
505
+ admitted = true;
506
+ }
507
+ catch {
508
+ admitted = false;
509
+ }
510
+ }
511
+ if (!admitted) {
512
+ try {
513
+ const dir = this.#config.defaultCwd;
514
+ mkdirSync(dir, { recursive: true });
515
+ const target = join(dir, `${Date.now()}-${fileName.replace(/[^\w.-]+/g, '_')}`);
516
+ writeFileSync(target, downloaded.bytes);
517
+ annotated += `(文件已保存:${target})`;
518
+ }
519
+ catch (error) {
520
+ this.#logger.warn(`attachment file save failed: ${errorMessage(error)}`);
521
+ await this.#replyText(state, '附件保存失败,已跳过。');
522
+ await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
523
+ return undefined;
524
+ }
525
+ }
526
+ }
527
+ if (parts.length === 0)
528
+ return { content: [{ type: 'text', text: annotated }] };
529
+ if (store !== undefined) {
530
+ try {
531
+ const admitted = await store.admitPromptContent([
532
+ { type: 'text', text: annotated },
533
+ ...parts,
534
+ ]);
535
+ return { content: admitted };
536
+ }
537
+ catch (error) {
538
+ this.#logger.warn(`attachment admission failed: ${errorMessage(error)}`);
539
+ await this.#replyText(state, '附件处理失败,已跳过。');
540
+ await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
541
+ return undefined;
542
+ }
543
+ }
544
+ return { content: [{ type: 'text', text: annotated }, ...parts] };
545
+ }
546
+ async #deliverTurn(state, input) {
547
+ const prepared = await this.#prepareAttachments(state, input);
548
+ if (prepared === undefined)
549
+ return;
414
550
  const resolved = await this.#tryResolve(state);
415
551
  if (resolved === undefined) {
416
552
  await this.#replyText(state, '无法创建会话(agents 服务不可用)。');
417
553
  return;
418
554
  }
419
555
  const message = createUserMessage({
420
- content: [{ type: 'text', text }],
556
+ content: prepared.content,
421
557
  source: { kind: 'user' },
422
558
  });
423
559
  const agent = resolved.agent;
@@ -440,11 +576,17 @@ export class Bridge {
440
576
  }
441
577
  async #settleTurn(state, event) {
442
578
  state.running = false;
579
+ const kind = turnEndReasonKind(event.data);
580
+ const finalState = stateForTurnEnd(kind);
581
+ const finalStateLabel = statusLabel(finalState);
582
+ // The tracker is usually already terminal (apply ran before this method),
583
+ // but re-apply to guarantee /status never disagrees with the final card.
584
+ state.status.apply(event);
443
585
  const snapshot = state.status.get();
444
586
  const body = state.lastOutput.trim().length > 0
445
587
  ? state.lastOutput
446
- : `本轮已结束(${statusLabel(snapshot.state)})。`;
447
- const finalInput = this.#finalCardInput(state, snapshot, body);
588
+ : `本轮已结束(${finalStateLabel})。`;
589
+ const finalInput = this.#finalCardInput(state, snapshot, body, finalState, finalStateLabel);
448
590
  const stream = state.progress;
449
591
  const messageId = stream?.messageId;
450
592
  if (stream !== undefined) {
@@ -469,7 +611,6 @@ export class Bridge {
469
611
  await this.#finalFallback(state, finalInput, error);
470
612
  }
471
613
  }
472
- const kind = turnEndReasonKind(event.data);
473
614
  if (shouldNotifyTerminal(kind)) {
474
615
  const notice = terminalNotice(kind, snapshot.errorMessage);
475
616
  if (notice !== undefined)
@@ -478,11 +619,12 @@ export class Bridge {
478
619
  await this.#finishReactions(state, kind);
479
620
  state.lastOutput = '';
480
621
  }
481
- #finalCardInput(state, snapshot, body) {
622
+ #finalCardInput(state, snapshot, body, finalState, finalStateLabel) {
482
623
  const sessionTitle = this.#titleOf(state)?.trim() ?? state.title;
483
624
  return {
484
625
  title: '飞书会话 · 过程',
485
- stateLabel: statusLabel(snapshot.state),
626
+ // Event-derived, never the (possibly stale) tracker snapshot.
627
+ stateLabel: finalStateLabel,
486
628
  steps: snapshot.steps,
487
629
  tools: snapshot.tools,
488
630
  ...(sessionTitle === undefined ? {} : { sessionTitle }),
@@ -490,7 +632,7 @@ export class Bridge {
490
632
  ...(snapshot.startedAt === undefined ? {} : { startedAt: snapshot.startedAt }),
491
633
  ...(snapshot.endedAt === undefined ? {} : { endedAt: snapshot.endedAt }),
492
634
  body,
493
- template: statusTemplate(snapshot.state),
635
+ template: statusTemplate(finalState),
494
636
  };
495
637
  }
496
638
  /** The ONLY fallback path: card edit failed, so send the same content as text once. */
@@ -1275,3 +1417,14 @@ export function progressCardForSnapshot(snapshot, sessionTitle, recentEvent, tai
1275
1417
  now,
1276
1418
  });
1277
1419
  }
1420
+ /** Normalize a resource content-type to one of dsh's accepted image types. */
1421
+ function imageMediaType(contentType) {
1422
+ const normalized = (contentType ?? '').split(';')[0]?.trim().toLowerCase();
1423
+ if (normalized === 'image/png' ||
1424
+ normalized === 'image/jpeg' ||
1425
+ normalized === 'image/webp' ||
1426
+ normalized === 'image/gif') {
1427
+ return normalized;
1428
+ }
1429
+ return 'image/jpeg';
1430
+ }
@@ -20,6 +20,14 @@ import type { Transport } from '../transport/types.js';
20
20
  export type BusyMode = 'queue' | 'steer' | 'interrupt';
21
21
  /** Session approval policy understood by dsh's approval service. */
22
22
  export type ApprovalPolicy = 'ask' | 'never';
23
+ /** The slice of dsh's attachment service the bridge needs. */
24
+ export interface AttachmentStoreLike {
25
+ admitPromptContent(content: readonly unknown[]): Promise<readonly unknown[]>;
26
+ admitEncodedFile(input: {
27
+ readonly data: string;
28
+ readonly name?: string;
29
+ }): Promise<unknown>;
30
+ }
23
31
  /** The slice of dsh's approval service the bridge needs. */
24
32
  export interface ApprovalServiceLike {
25
33
  setPolicy(agent: AgentLike, policy: ApprovalPolicy): void;
@@ -100,6 +108,12 @@ export interface BridgeConfig {
100
108
  readonly reactionOnFail?: string;
101
109
  /** Default true: group @ gate fails closed when bot open id is unknown. */
102
110
  readonly gateRequireBotIdentity?: boolean;
111
+ /** Process image attachments (default true). */
112
+ readonly images?: boolean;
113
+ /** Process file attachments (default true). */
114
+ readonly files?: boolean;
115
+ /** Per-attachment byte cap (default 20 MiB). */
116
+ readonly maxAttachmentBytes?: number;
103
117
  }
104
118
  /** Everything the bridge needs; unit tests provide fakes for every seam. */
105
119
  export interface BridgeOptions {
@@ -129,6 +143,8 @@ export interface BridgeOptions {
129
143
  readonly interactive?: InteractiveRuntime;
130
144
  /** Plugin root Cordis context; answerers register here once, not per agent. */
131
145
  readonly rootCtx?: unknown;
146
+ /** Lazily resolve dsh's attachment service (image admission / file refs). */
147
+ readonly attachmentProvider?: () => AttachmentStoreLike | undefined;
132
148
  /** Lazily resolve dsh's approval service (agent-scoped setPolicy). */
133
149
  readonly approvalProvider?: () => ApprovalServiceLike | undefined;
134
150
  /** Desired session approval policy (default `ask`). */
package/dist/config.d.ts CHANGED
@@ -35,6 +35,12 @@ export interface GatewayConfig {
35
35
  readonly reactionOnFail?: string;
36
36
  /** Require the bot's own open id for the group @ gate (default true). */
37
37
  readonly gateRequireBotIdentity?: boolean;
38
+ /** Process image attachments (default true). */
39
+ readonly images?: boolean;
40
+ /** Process file attachments (default true). */
41
+ readonly files?: boolean;
42
+ /** Per-attachment byte cap (default 20 MiB). */
43
+ readonly maxAttachmentBytes?: number;
38
44
  }
39
45
  /** Schemastery schema for the `caduceus-gateway` entry. */
40
46
  export declare const Config: z<Schemastery.ObjectS<NoInfer<{
@@ -58,6 +64,9 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
58
64
  reactionOnDone: z<string, string, "plain">;
59
65
  reactionOnFail: z<string, string, "plain">;
60
66
  gateRequireBotIdentity: z<boolean, boolean, "plain">;
67
+ images: z<boolean, boolean, "plain">;
68
+ files: z<boolean, boolean, "plain">;
69
+ maxAttachmentBytes: z<number, number, "plain">;
61
70
  }>>, Schemastery.ObjectT<NoInfer<{
62
71
  appId: z<string, string, "plain">;
63
72
  appSecret: z<string, string, "plain">;
@@ -79,6 +88,9 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
79
88
  reactionOnDone: z<string, string, "plain">;
80
89
  reactionOnFail: z<string, string, "plain">;
81
90
  gateRequireBotIdentity: z<boolean, boolean, "plain">;
91
+ images: z<boolean, boolean, "plain">;
92
+ files: z<boolean, boolean, "plain">;
93
+ maxAttachmentBytes: z<number, number, "plain">;
82
94
  }>>, "plain">;
83
95
  /** Default card patch throttle: half of Feishu's 5 QPS budget. */
84
96
  export declare const DEFAULT_CARD_THROTTLE_MS = 400;
@@ -92,6 +104,10 @@ export declare const DEFAULT_LOG_LEVEL: 'info' | 'debug';
92
104
  export declare const DEFAULT_PERSONA_AUTO_SYNC = true;
93
105
  /** Default agent preset for caduceus sessions. */
94
106
  export declare const DEFAULT_AGENT_PRESET = "standard";
107
+ /** Default attachment switches/limit. */
108
+ export declare const DEFAULT_IMAGES = true;
109
+ export declare const DEFAULT_FILES = true;
110
+ export declare const DEFAULT_MAX_ATTACHMENT_BYTES: number;
95
111
  /** Default message reactions. */
96
112
  export declare const DEFAULT_REACTIONS = true;
97
113
  export declare const DEFAULT_REACTION_ON_START = "OnIt";
package/dist/config.js CHANGED
@@ -32,6 +32,9 @@ export const Config = z.object({
32
32
  reactionOnDone: z.string().required(false),
33
33
  reactionOnFail: z.string().required(false),
34
34
  gateRequireBotIdentity: z.boolean().required(false),
35
+ images: z.boolean().required(false),
36
+ files: z.boolean().required(false),
37
+ maxAttachmentBytes: z.natural().min(1).required(false),
35
38
  });
36
39
  /** Default card patch throttle: half of Feishu's 5 QPS budget. */
37
40
  export const DEFAULT_CARD_THROTTLE_MS = 400;
@@ -45,6 +48,10 @@ export const DEFAULT_LOG_LEVEL = 'info';
45
48
  export const DEFAULT_PERSONA_AUTO_SYNC = true;
46
49
  /** Default agent preset for caduceus sessions. */
47
50
  export const DEFAULT_AGENT_PRESET = 'standard';
51
+ /** Default attachment switches/limit. */
52
+ export const DEFAULT_IMAGES = true;
53
+ export const DEFAULT_FILES = true;
54
+ export const DEFAULT_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
48
55
  /** Default message reactions. */
49
56
  export const DEFAULT_REACTIONS = true;
50
57
  export const DEFAULT_REACTION_ON_START = 'OnIt';
@@ -86,6 +93,9 @@ export function resolveGatewayConfig(config = {}, env = process.env, cwd = proce
86
93
  reactionOnDone: config.reactionOnDone ?? DEFAULT_REACTION_ON_DONE,
87
94
  reactionOnFail: config.reactionOnFail ?? DEFAULT_REACTION_ON_FAIL,
88
95
  gateRequireBotIdentity: config.gateRequireBotIdentity ?? true,
96
+ images: config.images ?? DEFAULT_IMAGES,
97
+ files: config.files ?? DEFAULT_FILES,
98
+ maxAttachmentBytes: config.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES,
89
99
  };
90
100
  return {
91
101
  ...(appId === undefined || appSecret === undefined ? {} : { credentials: { appId, appSecret } }),
package/dist/index.js CHANGED
@@ -193,6 +193,7 @@ export function apply(ctx, config = {}) {
193
193
  interactive,
194
194
  rootCtx: ctx,
195
195
  approvalProvider: () => ctx.get('approval'),
196
+ attachmentProvider: () => ctx.get('attachments'),
196
197
  approvalPolicy: resolved.approvalPolicy,
197
198
  sessionPresetLookup: (sessionId) => ctx.get('sessions')?.get(sessionId)?.header
198
199
  ?.agentPreset,
@@ -34,6 +34,11 @@ export interface FeishuApi {
34
34
  addReaction(messageId: string, emojiType: string): Promise<string>;
35
35
  /** Remove one reaction previously added by this bot. */
36
36
  removeReaction(messageId: string, reactionId: string): Promise<void>;
37
+ /** Download a message resource (image/file), enforcing a byte limit. */
38
+ downloadResource(messageId: string, fileKey: string, type: 'image' | 'file', maxBytes: number): Promise<{
39
+ readonly bytes: Uint8Array;
40
+ readonly contentType?: string;
41
+ }>;
37
42
  createMessage(input: CreateMessageInput): Promise<{
38
43
  messageId: string;
39
44
  }>;
@@ -42,6 +42,10 @@ export declare class LarkFeishuApi implements FeishuApi {
42
42
  }>;
43
43
  addReaction(messageId: string, emojiType: string): Promise<string>;
44
44
  removeReaction(messageId: string, reactionId: string): Promise<void>;
45
+ downloadResource(messageId: string, fileKey: string, type: 'image' | 'file', maxBytes: number): Promise<{
46
+ readonly bytes: Uint8Array;
47
+ readonly contentType?: string;
48
+ }>;
45
49
  patchCard(messageId: string, card: FeishuCard): Promise<void>;
46
50
  private messageIdOf;
47
51
  private assertOk;
@@ -166,6 +166,33 @@ export class LarkFeishuApi {
166
166
  }));
167
167
  this.assertOk(response, 'im.v1.message.reaction.delete');
168
168
  }
169
+ async downloadResource(messageId, fileKey, type, maxBytes) {
170
+ const response = await this.client.im.v1.messageResource.get({
171
+ params: { type },
172
+ path: { message_id: messageId, file_key: fileKey },
173
+ });
174
+ const headers = (response.headers ?? {});
175
+ const declared = Number(headers['content-length']);
176
+ if (Number.isFinite(declared) && declared > maxBytes) {
177
+ throw new FeishuApiError(`attachment too large (${declared} > ${maxBytes})`, -2);
178
+ }
179
+ const contentType = headers['content-type'] ?? headers['Content-Type'];
180
+ const stream = response.getReadableStream();
181
+ const chunks = [];
182
+ let total = 0;
183
+ for await (const chunk of stream) {
184
+ total += chunk.length;
185
+ if (total > maxBytes) {
186
+ stream.destroy?.();
187
+ throw new FeishuApiError(`attachment too large (> ${maxBytes})`, -2);
188
+ }
189
+ chunks.push(chunk);
190
+ }
191
+ return {
192
+ bytes: new Uint8Array(Buffer.concat(chunks)),
193
+ ...(contentType === undefined ? {} : { contentType }),
194
+ };
195
+ }
169
196
  async patchCard(messageId, card) {
170
197
  const response = (await this.client.im.v1.message.patch({
171
198
  data: { content: JSON.stringify(card) },
@@ -30,7 +30,8 @@ export interface RawMessageEventLike {
30
30
  * Normalize one raw event.
31
31
  *
32
32
  * @returns the normalized message, or `undefined` for unsupported/malformed
33
- * payloads or empty text.
33
+ * payloads or empty text. Unsupported message types return a message with
34
+ * `unsupportedType` so the bridge can reply and react explicitly.
34
35
  */
35
36
  export declare function normalizeMessageEvent(raw: RawMessageEventLike, botOpenId?: string): InboundMessage | undefined;
36
37
  /** Raw `card.action.trigger` payload fields used by the surface. */
@@ -20,87 +20,132 @@
20
20
  */
21
21
  import { stripLeadingMentions } from '../mentions.js';
22
22
  const AT_TAG_PATTERN = /<at[^>]*>(.*?)<\/at>/gs;
23
- /** Message types the v0.1 surface handles (attachments are v0.2). */
24
- const SUPPORTED_MESSAGE_TYPES = new Set(['text']);
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
+ }
25
79
  /**
26
80
  * Normalize one raw event.
27
81
  *
28
82
  * @returns the normalized message, or `undefined` for unsupported/malformed
29
- * 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.
30
85
  */
31
86
  export function normalizeMessageEvent(raw, botOpenId) {
32
- // Priority #1: only human messages may enter a session.
33
87
  const senderType = raw.sender?.sender_type;
34
88
  if (senderType !== 'user')
35
89
  return undefined;
36
90
  const message = raw.message;
37
- if (!SUPPORTED_MESSAGE_TYPES.has(message.message_type))
38
- return undefined;
39
- let text;
40
- try {
41
- const parsed = JSON.parse(message.content);
42
- if (typeof parsed.text !== 'string')
43
- return undefined;
44
- text = parsed.text;
45
- }
46
- catch {
47
- return undefined;
48
- }
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
- }
87
- if (text.length === 0)
88
- return undefined;
89
91
  const threadId = firstNonEmpty(message.thread_id, message.root_id);
90
92
  const rootId = firstNonEmpty(message.root_id);
91
93
  const mentions = (message.mentions ?? [])
92
94
  .map((mention) => mention.id?.open_id)
93
95
  .filter((openId) => typeof openId === 'string' && openId.length > 0);
94
- return {
96
+ const base = () => ({
95
97
  messageId: message.message_id,
96
98
  chatId: message.chat_id,
97
99
  chatType: message.chat_type === 'group' ? 'group' : 'p2p',
98
100
  senderOpenId: raw.sender?.sender_id?.open_id ?? '',
99
101
  ...(mentions.length === 0 ? {} : { mentions }),
100
- text,
101
102
  ...(threadId === undefined ? {} : { threadId }),
102
103
  ...(rootId === undefined ? {} : { rootId }),
103
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 }),
104
149
  };
105
150
  }
106
151
  function firstNonEmpty(...values) {
@@ -34,6 +34,10 @@ export declare class ReliableTransport implements Transport {
34
34
  sendCard(chatId: string, card: FeishuCard, options?: SendOptions): Promise<SentMessage>;
35
35
  addReaction(messageId: string, emojiType: string): Promise<string>;
36
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
+ }>;
37
41
  updateCard(messageId: string, card: FeishuCard): Promise<void>;
38
42
  reply(chatId: string, messageId: string, payload: ReplyPayload, options?: ReplyOptions): Promise<SentMessage>;
39
43
  }
@@ -106,6 +106,9 @@ export class ReliableTransport {
106
106
  removeReaction(messageId, reactionId) {
107
107
  return this.api.removeReaction(messageId, reactionId);
108
108
  }
109
+ downloadResource(messageId, fileKey, type, maxBytes) {
110
+ return this.api.downloadResource(messageId, fileKey, type, maxBytes);
111
+ }
109
112
  async updateCard(messageId, card) {
110
113
  assertUpdateMulti(card);
111
114
  await this.#recordVoid('卡片', () => this.api.patchCard(messageId, card));
@@ -46,6 +46,17 @@ 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;
49
60
  }
50
61
  /** One interactive-card button click, normalized for the bridge. */
51
62
  export interface CardAction {
@@ -123,6 +134,11 @@ export interface Transport {
123
134
  addReaction(messageId: string, emojiType: string): Promise<string>;
124
135
  /** Remove one reaction previously added by this bot. */
125
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
+ }>;
126
142
  /**
127
143
  * The bot's own open id when known (needed for the group @ gate). Optional:
128
144
  * fake transports may omit it, and the real one resolves it lazily.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-caduceus",
3
- "version": "0.4.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",