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 +3 -0
- package/README.zh.md +3 -0
- package/dist/bridge/bridge.js +164 -11
- package/dist/bridge/types.d.ts +16 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +10 -0
- package/dist/index.js +1 -0
- package/dist/transport/feishu-api.d.ts +5 -0
- package/dist/transport/lark.d.ts +4 -0
- package/dist/transport/lark.js +27 -0
- package/dist/transport/normalize.d.ts +2 -1
- package/dist/transport/normalize.js +103 -58
- package/dist/transport/reliable.d.ts +4 -0
- package/dist/transport/reliable.js +3 -0
- package/dist/transport/types.d.ts +16 -0
- package/package.json +1 -1
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
|
|
package/dist/bridge/bridge.js
CHANGED
|
@@ -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,
|
|
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 #
|
|
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:
|
|
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
|
-
: `本轮已结束(${
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
package/dist/bridge/types.d.ts
CHANGED
|
@@ -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
|
}>;
|
package/dist/transport/lark.d.ts
CHANGED
|
@@ -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;
|
package/dist/transport/lark.js
CHANGED
|
@@ -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
|
|
24
|
-
const
|
|
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
|
-
|
|
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.
|
|
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",
|