dsh-caduceus 0.5.1 → 0.7.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
@@ -15,7 +15,7 @@
15
15
  - **One scope, one session** — p2p, topic, and group-per-user sessions are kept separate.
16
16
  - **Visible titles** — generated from the first prompt and shown on the turn card.
17
17
  - **One card per turn** — status and answer live in the same card, edited in place; the final answer is not a second message.
18
- - **11 commands** — `/new`, `/sessions`, `/rename`, `/resume`, `/adopt`, `/status`, `/stop`, `/doctor`, `/selfcheck`, `/persona`, `/help`.
18
+ - **12 commands** — `/new`, `/sessions`, `/rename`, `/resume`, `/adopt`, `/status`, `/stop`, `/doctor`, `/selfcheck`, `/persona`, `/whoami`, `/help`.
19
19
  - **Persona** — `SOUL.md` is synced into the `AGENTS.md` block, automatically at startup.
20
20
  - **Observability** — two-stage self-check, `/doctor`, and in-chat degradation warnings.
21
21
 
@@ -90,6 +90,11 @@ onlyBuiltDependencies:
90
90
  | `images` | `true` | Accept image messages and attach them to the agent prompt |
91
91
  | `files` | `true` | Accept file messages (durable attachment or saved to cwd) |
92
92
  | `maxAttachmentBytes` | `20971520` | Per-attachment download limit (20 MiB) |
93
+ | `sendMedia` | `true` | Send `MEDIA:/path` outputs from the agent back to Feishu |
94
+ | `maxSendFileBytes` | `31457280` | Outbound file limit (30 MiB) |
95
+ | `maxSendImageBytes` | `10485760` | Outbound image limit (10 MiB) |
96
+ | `mediaRoots` | `[]` | Allowed roots for outbound paths (empty = session cwd) |
97
+ | `admins` | `[]` | Admin open-id allowlist; empty disables access control |
93
98
 
94
99
  ## Commands
95
100
 
@@ -105,6 +110,7 @@ onlyBuiltDependencies:
105
110
  | `/doctor [--json]` | Health report: runtime / Feishu / persona / session / storage / versions |
106
111
  | `/selfcheck` | Re-run both self-check stages and print every item |
107
112
  | `/persona [show\|sync]` | Show or sync the SOUL persona block |
113
+ | `/whoami` | Show your open_id, current chat/topic, and admin/guest role |
108
114
  | `/help` | Command table |
109
115
 
110
116
  ## Troubleshooting
package/README.zh.md CHANGED
@@ -15,7 +15,7 @@
15
15
  - **一个 scope 一个会话** — 私聊、话题、普通群按发送者隔离。
16
16
  - **标题可见** — 由第一条 prompt 生成,显示在回合卡上。
17
17
  - **每回合一张卡** — 状态与答案在同一张卡里,原地编辑;终稿不再另发一条消息。
18
- - **11 条命令** — `/new`、`/sessions`、`/rename`、`/resume`、`/adopt`、`/status`、`/stop`、`/doctor`、`/selfcheck`、`/persona`、`/help`。
18
+ - **12 条命令** — `/new`、`/sessions`、`/rename`、`/resume`、`/adopt`、`/status`、`/stop`、`/doctor`、`/selfcheck`、`/persona`、`/whoami`、`/help`。
19
19
  - **人格** — `SOUL.md` 同步到 `AGENTS.md` 的标记块,启动时自动执行。
20
20
  - **可观测性** — 两阶段自检、`/doctor`、对话内的降级提示。
21
21
 
@@ -89,6 +89,11 @@ onlyBuiltDependencies:
89
89
  | `images` | `true` | 接收图片消息并作为附件交给 agent |
90
90
  | `files` | `true` | 接收文件消息(附件服务或落到 cwd) |
91
91
  | `maxAttachmentBytes` | `20971520` | 单个附件下载上限(20 MiB) |
92
+ | `sendMedia` | `true` | 把 agent 的 `MEDIA:/path` 产物发回飞书 |
93
+ | `maxSendFileBytes` | `31457280` | 发送文件上限(30 MiB) |
94
+ | `maxSendImageBytes` | `10485760` | 发送图片上限(10 MiB) |
95
+ | `mediaRoots` | `[]` | 允许发送的目录(空=会话 cwd) |
96
+ | `admins` | `[]` | 管理员 open_id 白名单;空=关闭访问控制 |
92
97
 
93
98
  ## 命令
94
99
 
@@ -104,6 +109,7 @@ onlyBuiltDependencies:
104
109
  | `/doctor [--json]` | 体检报告:运行时 / 飞书 / 人格 / 会话 / 存储 / 版本 |
105
110
  | `/selfcheck` | 立即重跑两阶段自检并输出逐项明细 |
106
111
  | `/persona [show\|sync]` | 查看或同步 SOUL 人格块 |
112
+ | `/whoami` | 显示你的 open_id、当前会话与身份 |
107
113
  | `/help` | 命令表 |
108
114
 
109
115
  ## Troubleshooting(故障排查)
@@ -17,15 +17,16 @@
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';
20
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
22
  import { planSlashLine } from '../commands/dispatch.js';
23
- import { helpText } from '../commands/parse.js';
23
+ import { helpText, parseCommand } from '../commands/parse.js';
24
24
  import { evaluateGate, logGate } from '../gate.js';
25
25
  import { silentLogger } from '../logger.js';
26
26
  import { formatScopeLine, sessionIdTimestamp, sessionTimeLabel, shortSessionId, } from '../observability.js';
27
27
  import { buildDoctorReport, renderDoctorJson, renderDoctorText, } from '../doctor.js';
28
28
  import { writeOwnership } from '../ownership.js';
29
+ import { extractMediaMarkers, resolveMedia } from '../media.js';
29
30
  import { finalCard, finalCardPlainText, progressCard, } from '../render/cards.js';
30
31
  import { CardStream, systemClock } from '../render/stream.js';
31
32
  import { mintSessionId, scopeKey, SCOPE_KEY_SEPARATOR } from '../session-key.js';
@@ -180,8 +181,27 @@ export class Bridge {
180
181
  }
181
182
  if (!decision.accept)
182
183
  return;
184
+ if (!this.#isAdmin(message.senderOpenId) && !this.#isPublicCommand(message.text)) {
185
+ const state = this.#stateFor(message);
186
+ state.lastInboundMessageId = message.messageId;
187
+ state.ownerOpenId = message.senderOpenId;
188
+ this.#logger.info(`gate drop: not-admin sender=${message.senderOpenId.slice(0, 12) || '-'}`);
189
+ await this.#replyText(state, '访客无执行权限。');
190
+ return;
191
+ }
183
192
  await this.#queue.run(scopeKey(message.chatType, message.chatId, message.threadId, message.senderOpenId, this.#config.groupSessionsPerUser), () => this.#processInbound(message));
184
193
  }
194
+ #isAdmin(openId) {
195
+ const admins = this.#config.admins ?? [];
196
+ return admins.length === 0 || admins.includes(openId);
197
+ }
198
+ /** `/help` and `/whoami` are safe for any sender (no execution, no session). */
199
+ #isPublicCommand(text) {
200
+ const command = parseCommand(text);
201
+ return (command.kind === 'help' ||
202
+ command.kind === 'whoami' ||
203
+ (command.kind === 'usage' && (command.command === 'help' || command.command === 'whoami')));
204
+ }
185
205
  /** Feed one dsh session event into the render/status layer. */
186
206
  async handleSessionEvent(session, event) {
187
207
  const key = this.#scopeBySession.get(String(session.id));
@@ -409,6 +429,13 @@ export class Bridge {
409
429
  await this.#replyText(state, result.text);
410
430
  return;
411
431
  }
432
+ case 'whoami': {
433
+ const openId = state.ownerOpenId ?? '';
434
+ const identity = this.#isAdmin(openId) ? '管理员' : '访客';
435
+ const topic = state.threadId === undefined ? '' : `[话题 ${state.threadId}]`;
436
+ await this.#replyText(state, `你的 open_id:${openId}\n当前会话:${state.chatId}${topic}\n身份:${identity}`);
437
+ return;
438
+ }
412
439
  case 'help': {
413
440
  await this.#replyText(state, helpText());
414
441
  return;
@@ -583,9 +610,11 @@ export class Bridge {
583
610
  // but re-apply to guarantee /status never disagrees with the final card.
584
611
  state.status.apply(event);
585
612
  const snapshot = state.status.get();
586
- const body = state.lastOutput.trim().length > 0
613
+ const rawBody = state.lastOutput.trim().length > 0
587
614
  ? state.lastOutput
588
615
  : `本轮已结束(${finalStateLabel})。`;
616
+ const outbound = this.#planOutboundMedia(state, rawBody);
617
+ const body = outbound.body;
589
618
  const finalInput = this.#finalCardInput(state, snapshot, body, finalState, finalStateLabel);
590
619
  const stream = state.progress;
591
620
  const messageId = stream?.messageId;
@@ -611,6 +640,7 @@ export class Bridge {
611
640
  await this.#finalFallback(state, finalInput, error);
612
641
  }
613
642
  }
643
+ await this.#sendOutboundMedia(state, outbound.entries);
614
644
  if (shouldNotifyTerminal(kind)) {
615
645
  const notice = terminalNotice(kind, snapshot.errorMessage);
616
646
  if (notice !== undefined)
@@ -619,6 +649,60 @@ export class Bridge {
619
649
  await this.#finishReactions(state, kind);
620
650
  state.lastOutput = '';
621
651
  }
652
+ /** Extract MEDIA markers, validate paths, and annotate the final card body. */
653
+ #planOutboundMedia(state, rawBody) {
654
+ if (this.#config.sendMedia === false)
655
+ return { body: rawBody, entries: [] };
656
+ const parsed = extractMediaMarkers(rawBody);
657
+ if (parsed.paths.length === 0)
658
+ return { body: parsed.text, entries: [] };
659
+ const cwd = this.#sessionMap.getCwd(state.key) ?? this.#config.defaultCwd;
660
+ const roots = this.#config.mediaRoots?.length ? this.#config.mediaRoots : [cwd];
661
+ const entries = [];
662
+ const notes = [];
663
+ for (const path of parsed.paths) {
664
+ const resolved = resolveMedia(path, {
665
+ roots,
666
+ maxFileBytes: this.#config.maxSendFileBytes ?? 30 * 1024 * 1024,
667
+ maxImageBytes: this.#config.maxSendImageBytes ?? 10 * 1024 * 1024,
668
+ });
669
+ if (!resolved.ok) {
670
+ notes.push(`📎 ${resolved.name}:未发送(${resolved.reason})`);
671
+ continue;
672
+ }
673
+ entries.push({ path: resolved.path, name: resolved.name, image: resolved.image });
674
+ notes.push(`📎 ${resolved.name} × 1`);
675
+ }
676
+ const body = parsed.text.length === 0 ? notes.join('\n') : `${parsed.text}\n\n${notes.join('\n')}`;
677
+ return { body, entries };
678
+ }
679
+ /** Upload and reply each validated file individually; one failure never stops the rest. */
680
+ async #sendOutboundMedia(state, entries) {
681
+ if (entries.length === 0)
682
+ return;
683
+ const replyTo = state.lastInboundMessageId;
684
+ if (replyTo === undefined) {
685
+ this.#logger.warn('media send skipped: no inbound message to reply to');
686
+ return;
687
+ }
688
+ const threadOptions = state.threadId === undefined ? {} : { threadId: state.threadId };
689
+ for (const entry of entries) {
690
+ try {
691
+ const bytes = readFileSync(entry.path);
692
+ if (entry.image) {
693
+ const imageKey = await this.#transport.uploadImage(bytes);
694
+ await this.#transport.reply(state.chatId, replyTo, { kind: 'image', imageKey }, threadOptions);
695
+ }
696
+ else {
697
+ const fileKey = await this.#transport.uploadFile(entry.name, bytes);
698
+ await this.#transport.reply(state.chatId, replyTo, { kind: 'file', fileKey, fileName: entry.name }, threadOptions);
699
+ }
700
+ }
701
+ catch (error) {
702
+ this.#logger.warn(`media send failed for ${entry.name}: ${errorMessage(error)}`);
703
+ }
704
+ }
705
+ }
622
706
  #finalCardInput(state, snapshot, body, finalState, finalStateLabel) {
623
707
  const sessionTitle = this.#titleOf(state)?.trim() ?? state.title;
624
708
  return {
@@ -1371,8 +1455,10 @@ export class Bridge {
1371
1455
  });
1372
1456
  }
1373
1457
  async #sendNewCard(state, card) {
1374
- if (state.lastInboundMessageId !== undefined) {
1375
- return this.#transport.reply(state.chatId, state.lastInboundMessageId, { kind: 'card', card }, state.threadId === undefined ? {} : { threadId: state.threadId });
1458
+ // A topic must receive a thread reply; a normal chat must get a plain card
1459
+ // message (quoting the user's message creates a fake "topic" UI).
1460
+ if (state.threadId !== undefined && state.lastInboundMessageId !== undefined) {
1461
+ return this.#transport.reply(state.chatId, state.lastInboundMessageId, { kind: 'card', card }, { threadId: state.threadId });
1376
1462
  }
1377
1463
  return this.#transport.sendCard(state.chatId, card);
1378
1464
  }
@@ -1390,10 +1476,11 @@ export class Bridge {
1390
1476
  if (text.length === 0)
1391
1477
  return;
1392
1478
  try {
1393
- if (state.lastInboundMessageId !== undefined) {
1479
+ if (state.threadId !== undefined && state.lastInboundMessageId !== undefined) {
1394
1480
  await this.#transport.reply(state.chatId, state.lastInboundMessageId, { kind: 'text', text }, { threadId: state.threadId });
1395
1481
  }
1396
1482
  else {
1483
+ // Normal chat: a fresh message, not a quoted "topic reply".
1397
1484
  await this.#transport.sendText(state.chatId, text);
1398
1485
  }
1399
1486
  }
@@ -114,6 +114,14 @@ export interface BridgeConfig {
114
114
  readonly files?: boolean;
115
115
  /** Per-attachment byte cap (default 20 MiB). */
116
116
  readonly maxAttachmentBytes?: number;
117
+ /** Send MEDIA:/path outputs back to Feishu (default true). */
118
+ readonly sendMedia?: boolean;
119
+ readonly maxSendFileBytes?: number;
120
+ readonly maxSendImageBytes?: number;
121
+ /** Allowed roots for outbound MEDIA paths; empty = session cwd. */
122
+ readonly mediaRoots?: readonly string[];
123
+ /** Admin allowlist; empty disables access control (default). */
124
+ readonly admins?: readonly string[];
117
125
  }
118
126
  /** Everything the bridge needs; unit tests provide fakes for every seam. */
119
127
  export interface BridgeOptions {
@@ -1,5 +1,5 @@
1
1
  /** Commands dsh-caduceus owns. Keep in sync with README's command table. */
2
- export declare const COMMAND_NAMES: readonly ["new", "sessions", "rename", "resume", "adopt", "selfcheck", "doctor", "stop", "status", "persona", "help"];
2
+ export declare const COMMAND_NAMES: readonly ["new", "sessions", "rename", "resume", "adopt", "selfcheck", "doctor", "stop", "status", "persona", "whoami", "help"];
3
3
  export type CommandName = (typeof COMMAND_NAMES)[number];
4
4
  export interface CommandSpec {
5
5
  readonly name: CommandName;
@@ -36,6 +36,8 @@ export type ParsedCommand = {
36
36
  } | {
37
37
  readonly kind: 'persona';
38
38
  readonly action: PersonaAction;
39
+ } | {
40
+ readonly kind: 'whoami';
39
41
  } | {
40
42
  readonly kind: 'help';
41
43
  } | {
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { stripLeadingMentions } from '../mentions.js';
13
13
  /** Commands dsh-caduceus owns. Keep in sync with README's command table. */
14
- export const COMMAND_NAMES = ['new', 'sessions', 'rename', 'resume', 'adopt', 'selfcheck', 'doctor', 'stop', 'status', 'persona', 'help'];
14
+ export const COMMAND_NAMES = ['new', 'sessions', 'rename', 'resume', 'adopt', 'selfcheck', 'doctor', 'stop', 'status', 'persona', 'whoami', 'help'];
15
15
  /** Command metadata; `helpText()` and the README command table both derive from it. */
16
16
  export const COMMAND_SPECS = [
17
17
  { name: 'new', usage: '/new', description: '为当前聊天/话题新建会话,旧会话保留' },
@@ -24,6 +24,7 @@ export const COMMAND_SPECS = [
24
24
  { name: 'stop', usage: '/stop', description: '中断当前正在运行的一轮' },
25
25
  { name: 'status', usage: '/status', description: '显示本会话的运行状态、步数与起止时间' },
26
26
  { name: 'persona', usage: '/persona [show|sync]', description: '查看或同步 SOUL 人格块' },
27
+ { name: 'whoami', usage: '/whoami', description: '显示你的 open_id、当前会话与身份' },
27
28
  { name: 'help', usage: '/help', description: '显示命令表' },
28
29
  ];
29
30
  function usageResult(command, reason) {
@@ -63,6 +64,8 @@ export function parseCommand(raw) {
63
64
  return rest === '' ? { kind: 'stop' } : usageResult('stop', '/stop 不接受参数');
64
65
  case 'status':
65
66
  return rest === '' ? { kind: 'status' } : usageResult('status', '/status 不接受参数');
67
+ case 'whoami':
68
+ return rest === '' ? { kind: 'whoami' } : usageResult('whoami', '/whoami 不接受参数');
66
69
  case 'help':
67
70
  return { kind: 'help' };
68
71
  case 'rename':
package/dist/config.d.ts CHANGED
@@ -41,6 +41,16 @@ export interface GatewayConfig {
41
41
  readonly files?: boolean;
42
42
  /** Per-attachment byte cap (default 20 MiB). */
43
43
  readonly maxAttachmentBytes?: number;
44
+ /** Send MEDIA:/path outputs back to Feishu (default true). */
45
+ readonly sendMedia?: boolean;
46
+ /** Outbound file size limit (default 30 MiB). */
47
+ readonly maxSendFileBytes?: number;
48
+ /** Outbound image size limit (default 10 MiB). */
49
+ readonly maxSendImageBytes?: number;
50
+ /** Allowed roots for MEDIA paths; empty = session cwd only. */
51
+ readonly mediaRoots?: readonly string[];
52
+ /** Admin open-id allowlist; empty disables access control (default). */
53
+ readonly admins?: readonly string[];
44
54
  }
45
55
  /** Schemastery schema for the `caduceus-gateway` entry. */
46
56
  export declare const Config: z<Schemastery.ObjectS<NoInfer<{
@@ -67,6 +77,11 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
67
77
  images: z<boolean, boolean, "plain">;
68
78
  files: z<boolean, boolean, "plain">;
69
79
  maxAttachmentBytes: z<number, number, "plain">;
80
+ sendMedia: z<boolean, boolean, "plain">;
81
+ maxSendFileBytes: z<number, number, "plain">;
82
+ maxSendImageBytes: z<number, number, "plain">;
83
+ mediaRoots: z<string[], string[], "plain">;
84
+ admins: z<string[], string[], "plain">;
70
85
  }>>, Schemastery.ObjectT<NoInfer<{
71
86
  appId: z<string, string, "plain">;
72
87
  appSecret: z<string, string, "plain">;
@@ -91,6 +106,11 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
91
106
  images: z<boolean, boolean, "plain">;
92
107
  files: z<boolean, boolean, "plain">;
93
108
  maxAttachmentBytes: z<number, number, "plain">;
109
+ sendMedia: z<boolean, boolean, "plain">;
110
+ maxSendFileBytes: z<number, number, "plain">;
111
+ maxSendImageBytes: z<number, number, "plain">;
112
+ mediaRoots: z<string[], string[], "plain">;
113
+ admins: z<string[], string[], "plain">;
94
114
  }>>, "plain">;
95
115
  /** Default card patch throttle: half of Feishu's 5 QPS budget. */
96
116
  export declare const DEFAULT_CARD_THROTTLE_MS = 400;
@@ -108,6 +128,10 @@ export declare const DEFAULT_AGENT_PRESET = "standard";
108
128
  export declare const DEFAULT_IMAGES = true;
109
129
  export declare const DEFAULT_FILES = true;
110
130
  export declare const DEFAULT_MAX_ATTACHMENT_BYTES: number;
131
+ /** Default outbound media switches/limits. */
132
+ export declare const DEFAULT_SEND_MEDIA = true;
133
+ export declare const DEFAULT_MAX_SEND_FILE_BYTES: number;
134
+ export declare const DEFAULT_MAX_SEND_IMAGE_BYTES: number;
111
135
  /** Default message reactions. */
112
136
  export declare const DEFAULT_REACTIONS = true;
113
137
  export declare const DEFAULT_REACTION_ON_START = "OnIt";
@@ -119,6 +143,11 @@ export declare const DEFAULT_APPROVAL_POLICY: ApprovalPolicy;
119
143
  export declare const DEFAULT_APPROVAL_CARD = true;
120
144
  export declare const DEFAULT_QUESTION_CARD = true;
121
145
  export declare const DEFAULT_INTERACTIVE_TIMEOUT_MS = 300000;
146
+ /**
147
+ * Startup warning for access control (empty admin list = disabled).
148
+ * Exported so the log line stays testable without mounting the plugin.
149
+ */
150
+ export declare function accessControlWarning(admins: readonly string[]): string | undefined;
122
151
  export interface ResolvedGatewayConfig {
123
152
  readonly credentials?: {
124
153
  readonly appId: string;
package/dist/config.js CHANGED
@@ -35,6 +35,11 @@ export const Config = z.object({
35
35
  images: z.boolean().required(false),
36
36
  files: z.boolean().required(false),
37
37
  maxAttachmentBytes: z.natural().min(1).required(false),
38
+ sendMedia: z.boolean().required(false),
39
+ maxSendFileBytes: z.natural().min(1).required(false),
40
+ maxSendImageBytes: z.natural().min(1).required(false),
41
+ mediaRoots: z.array(z.string()).required(false),
42
+ admins: z.array(z.string()).required(false),
38
43
  });
39
44
  /** Default card patch throttle: half of Feishu's 5 QPS budget. */
40
45
  export const DEFAULT_CARD_THROTTLE_MS = 400;
@@ -52,6 +57,10 @@ export const DEFAULT_AGENT_PRESET = 'standard';
52
57
  export const DEFAULT_IMAGES = true;
53
58
  export const DEFAULT_FILES = true;
54
59
  export const DEFAULT_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
60
+ /** Default outbound media switches/limits. */
61
+ export const DEFAULT_SEND_MEDIA = true;
62
+ export const DEFAULT_MAX_SEND_FILE_BYTES = 30 * 1024 * 1024;
63
+ export const DEFAULT_MAX_SEND_IMAGE_BYTES = 10 * 1024 * 1024;
55
64
  /** Default message reactions. */
56
65
  export const DEFAULT_REACTIONS = true;
57
66
  export const DEFAULT_REACTION_ON_START = 'OnIt';
@@ -63,6 +72,13 @@ export const DEFAULT_APPROVAL_POLICY = 'ask';
63
72
  export const DEFAULT_APPROVAL_CARD = true;
64
73
  export const DEFAULT_QUESTION_CARD = true;
65
74
  export const DEFAULT_INTERACTIVE_TIMEOUT_MS = 300_000;
75
+ /**
76
+ * Startup warning for access control (empty admin list = disabled).
77
+ * Exported so the log line stays testable without mounting the plugin.
78
+ */
79
+ export function accessControlWarning(admins) {
80
+ return admins.length === 0 ? 'access control disabled (no admins configured)' : undefined;
81
+ }
66
82
  function firstNonEmpty(...values) {
67
83
  for (const value of values) {
68
84
  if (value !== undefined && value.length > 0)
@@ -96,6 +112,11 @@ export function resolveGatewayConfig(config = {}, env = process.env, cwd = proce
96
112
  images: config.images ?? DEFAULT_IMAGES,
97
113
  files: config.files ?? DEFAULT_FILES,
98
114
  maxAttachmentBytes: config.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES,
115
+ sendMedia: config.sendMedia ?? DEFAULT_SEND_MEDIA,
116
+ maxSendFileBytes: config.maxSendFileBytes ?? DEFAULT_MAX_SEND_FILE_BYTES,
117
+ maxSendImageBytes: config.maxSendImageBytes ?? DEFAULT_MAX_SEND_IMAGE_BYTES,
118
+ mediaRoots: config.mediaRoots ?? [],
119
+ admins: config.admins ?? [],
99
120
  };
100
121
  return {
101
122
  ...(appId === undefined || appSecret === undefined ? {} : { credentials: { appId, appSecret } }),
package/dist/doctor.d.ts CHANGED
@@ -57,6 +57,11 @@ export interface DoctorInputs {
57
57
  readonly env?: HomeEnv;
58
58
  };
59
59
  readonly session: DoctorSessionInfo;
60
+ /** Access-control state (`admins` allowlist). */
61
+ readonly access?: {
62
+ readonly enabled: boolean;
63
+ readonly adminCount: number;
64
+ };
60
65
  readonly storage: {
61
66
  readonly mapVersion?: number;
62
67
  readonly mapWritable?: boolean;
package/dist/doctor.js CHANGED
@@ -116,6 +116,14 @@ export async function buildDoctorReport(inputs) {
116
116
  detail: `preset=${presetText},工具 ${toolsText},${relativeAge(lastTurnInput.at, now)}`,
117
117
  };
118
118
  });
119
+ const accessItem = inputs.access === undefined
120
+ ? undefined
121
+ : await safeItem('runtime', 'access', '访问控制', () => ({
122
+ mark: inputs.access?.enabled === true ? 'ok' : 'skip',
123
+ detail: inputs.access?.enabled === true
124
+ ? `开启(${inputs.access.adminCount} 人)`
125
+ : '关闭(未配置 admins)',
126
+ }));
119
127
  const titleService = await safeItem('runtime', 'session-title', '标题服务', () => {
120
128
  if (inputs.runtime.sessionTitleService === undefined)
121
129
  return { mark: 'skip', detail: '不可用' };
@@ -249,6 +257,7 @@ export async function buildDoctorReport(inputs) {
249
257
  tools,
250
258
  sessionTools,
251
259
  ...(lastTurnItem === undefined ? [] : [lastTurnItem]),
260
+ ...(accessItem === undefined ? [] : [accessItem]),
252
261
  titleService,
253
262
  agentsService,
254
263
  ],
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { dirname, join } from 'node:path';
16
16
  import { backfillSessionOwnership, scanSessionDirs } from './backfill.js';
17
17
  import { Bridge } from './bridge/bridge.js';
18
18
  import { runHarnessCommand } from './commands/harness.js';
19
- import { Config, resolveGatewayConfig } from './config.js';
19
+ import { Config, accessControlWarning, resolveGatewayConfig, } from './config.js';
20
20
  import { FileLogger } from './file-logger.js';
21
21
  import { defaultLogPath } from './home.js';
22
22
  import { adaptLogger, teeLoggers } from './logger.js';
@@ -140,6 +140,9 @@ export function apply(ctx, config = {}) {
140
140
  busyMode: resolved.bridge.busyMode,
141
141
  }));
142
142
  logger.info(`agent preset=${resolved.agentPreset}`);
143
+ const accessWarning = accessControlWarning(resolved.bridge.admins ?? []);
144
+ if (accessWarning !== undefined)
145
+ logger.warn(accessWarning);
143
146
  const selfCheck = new SelfChecker({
144
147
  logger,
145
148
  getAgentPresets: () => ctx.get('agentPresets'),
@@ -0,0 +1,27 @@
1
+ export interface MediaMarkerResult {
2
+ /** Answer text with every MEDIA marker line removed. */
3
+ readonly text: string;
4
+ /** Absolute paths in marker order. */
5
+ readonly paths: string[];
6
+ }
7
+ /** Strip one-line `MEDIA:/abs/path` markers; other lines stay untouched. */
8
+ export declare function extractMediaMarkers(text: string): MediaMarkerResult;
9
+ export declare function isImagePath(path: string): boolean;
10
+ export interface MediaLimits {
11
+ readonly roots: readonly string[];
12
+ readonly maxFileBytes: number;
13
+ readonly maxImageBytes: number;
14
+ }
15
+ export type MediaResolution = {
16
+ readonly ok: true;
17
+ readonly path: string;
18
+ readonly name: string;
19
+ readonly bytes: number;
20
+ readonly image: boolean;
21
+ } | {
22
+ readonly ok: false;
23
+ readonly name: string;
24
+ readonly reason: string;
25
+ };
26
+ /** Resolve and validate one marker path (absolute, inside roots, size limit). */
27
+ export declare function resolveMedia(path: string, limits: MediaLimits): MediaResolution;
package/dist/media.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `MEDIA:<absolute path>` markers in the agent's final answer, plus the safety
3
+ * boundary for sending local files back to Feishu.
4
+ *
5
+ * @module dsh-caduceus/media
6
+ */
7
+ import { realpathSync, statSync } from 'node:fs';
8
+ import { basename, extname, isAbsolute, sep } from 'node:path';
9
+ const MEDIA_LINE = /^\s*MEDIA:(\S.*?)\s*$/;
10
+ /** Strip one-line `MEDIA:/abs/path` markers; other lines stay untouched. */
11
+ export function extractMediaMarkers(text) {
12
+ const kept = [];
13
+ const paths = [];
14
+ for (const line of text.split('\n')) {
15
+ const match = MEDIA_LINE.exec(line);
16
+ if (match !== null) {
17
+ const path = match[1].trim();
18
+ if (path.length > 0)
19
+ paths.push(path);
20
+ continue;
21
+ }
22
+ kept.push(line);
23
+ }
24
+ return { text: kept.join('\n').trim(), paths };
25
+ }
26
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
27
+ export function isImagePath(path) {
28
+ return IMAGE_EXTENSIONS.has(extname(path).toLowerCase());
29
+ }
30
+ /** Resolve and validate one marker path (absolute, inside roots, size limit). */
31
+ export function resolveMedia(path, limits) {
32
+ const name = basename(path) || path;
33
+ if (!isAbsolute(path))
34
+ return { ok: false, name, reason: '仅支持绝对路径' };
35
+ let real;
36
+ try {
37
+ real = realpathSync(path);
38
+ }
39
+ catch {
40
+ return { ok: false, name, reason: '文件不存在' };
41
+ }
42
+ const realRoots = [];
43
+ for (const root of limits.roots) {
44
+ try {
45
+ realRoots.push(realpathSync(root));
46
+ }
47
+ catch {
48
+ // A missing root simply cannot authorize anything.
49
+ }
50
+ }
51
+ const inside = realRoots.some((root) => real === root || real.startsWith(root.endsWith(sep) ? root : `${root}${sep}`));
52
+ if (!inside)
53
+ return { ok: false, name, reason: '路径不在允许目录内' };
54
+ let stat;
55
+ try {
56
+ stat = statSync(real);
57
+ }
58
+ catch {
59
+ return { ok: false, name, reason: '文件不存在' };
60
+ }
61
+ if (!stat.isFile())
62
+ return { ok: false, name, reason: '不是普通文件' };
63
+ const image = isImagePath(real);
64
+ const max = image ? limits.maxImageBytes : limits.maxFileBytes;
65
+ if (stat.size > max)
66
+ return { ok: false, name, reason: '超过大小限制' };
67
+ return { ok: true, path: real, name, bytes: stat.size, image };
68
+ }
@@ -10,14 +10,14 @@
10
10
  import type { CardAction, FeishuCard, InboundMessage } from './types.js';
11
11
  export interface CreateMessageInput {
12
12
  readonly chatId: string;
13
- readonly msgType: 'text' | 'interactive';
13
+ readonly msgType: 'text' | 'interactive' | 'file' | 'image';
14
14
  /** Serialized `msg_type` content (JSON string). */
15
15
  readonly content: string;
16
16
  readonly uuid?: string;
17
17
  }
18
18
  export interface ReplyMessageInput {
19
19
  readonly messageId: string;
20
- readonly msgType: 'text' | 'interactive';
20
+ readonly msgType: 'text' | 'interactive' | 'file' | 'image';
21
21
  readonly content: string;
22
22
  /** Post the reply inside the message's topic (`reply_in_thread`). */
23
23
  readonly replyInThread: boolean;
@@ -34,6 +34,10 @@ 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
+ /** Upload one file and return its Feishu file_key. */
38
+ uploadFile(fileName: string, bytes: Uint8Array): Promise<string>;
39
+ /** Upload one image message and return its Feishu image_key. */
40
+ uploadImage(bytes: Uint8Array): Promise<string>;
37
41
  /** Download a message resource (image/file), enforcing a byte limit. */
38
42
  downloadResource(messageId: string, fileKey: string, type: 'image' | 'file', maxBytes: number): Promise<{
39
43
  readonly bytes: Uint8Array;
@@ -42,6 +42,8 @@ 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
+ uploadFile(fileName: string, bytes: Uint8Array): Promise<string>;
46
+ uploadImage(bytes: Uint8Array): Promise<string>;
45
47
  downloadResource(messageId: string, fileKey: string, type: 'image' | 'file', maxBytes: number): Promise<{
46
48
  readonly bytes: Uint8Array;
47
49
  readonly contentType?: string;
@@ -166,6 +166,35 @@ export class LarkFeishuApi {
166
166
  }));
167
167
  this.assertOk(response, 'im.v1.message.reaction.delete');
168
168
  }
169
+ async uploadFile(fileName, bytes) {
170
+ const response = (await this.client.im.v1.file.create({
171
+ data: {
172
+ file_type: 'stream',
173
+ file_name: fileName,
174
+ file: Buffer.from(bytes),
175
+ },
176
+ }));
177
+ this.assertOk(response, 'im.v1.file.create');
178
+ const fileKey = response.data?.file_key;
179
+ if (fileKey === undefined || fileKey.length === 0) {
180
+ throw new FeishuApiError('feishu im.v1.file.create returned no file_key', -1);
181
+ }
182
+ return fileKey;
183
+ }
184
+ async uploadImage(bytes) {
185
+ const response = (await this.client.im.v1.image.create({
186
+ data: {
187
+ image_type: 'message',
188
+ image: Buffer.from(bytes),
189
+ },
190
+ }));
191
+ this.assertOk(response, 'im.v1.image.create');
192
+ const imageKey = response.data?.image_key;
193
+ if (imageKey === undefined || imageKey.length === 0) {
194
+ throw new FeishuApiError('feishu im.v1.image.create returned no image_key', -1);
195
+ }
196
+ return imageKey;
197
+ }
169
198
  async downloadResource(messageId, fileKey, type, maxBytes) {
170
199
  const response = await this.client.im.v1.messageResource.get({
171
200
  params: { type },
@@ -88,7 +88,9 @@ export function normalizeMessageEvent(raw, botOpenId) {
88
88
  if (senderType !== 'user')
89
89
  return undefined;
90
90
  const message = raw.message;
91
- const threadId = firstNonEmpty(message.thread_id, message.root_id);
91
+ // Only a real thread_id makes a topic. In p2p, root_id can equal the
92
+ // message id; treating it as threadId produced bogus "topic replies".
93
+ const threadId = firstNonEmpty(message.thread_id);
92
94
  const rootId = firstNonEmpty(message.root_id);
93
95
  const mentions = (message.mentions ?? [])
94
96
  .map((mention) => mention.id?.open_id)
@@ -34,6 +34,8 @@ 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
+ uploadFile(fileName: string, bytes: Uint8Array): Promise<string>;
38
+ uploadImage(bytes: Uint8Array): Promise<string>;
37
39
  downloadResource(messageId: string, fileKey: string, type: 'image' | 'file', maxBytes: number): Promise<{
38
40
  readonly bytes: Uint8Array;
39
41
  readonly contentType?: string;
@@ -106,6 +106,12 @@ export class ReliableTransport {
106
106
  removeReaction(messageId, reactionId) {
107
107
  return this.api.removeReaction(messageId, reactionId);
108
108
  }
109
+ uploadFile(fileName, bytes) {
110
+ return this.api.uploadFile(fileName, bytes);
111
+ }
112
+ uploadImage(bytes) {
113
+ return this.api.uploadImage(bytes);
114
+ }
109
115
  downloadResource(messageId, fileKey, type, maxBytes) {
110
116
  return this.api.downloadResource(messageId, fileKey, type, maxBytes);
111
117
  }
@@ -115,8 +121,18 @@ export class ReliableTransport {
115
121
  }
116
122
  async reply(chatId, messageId, payload, options = {}) {
117
123
  const uuid = options.uuid ?? this.uuidFactory();
118
- const msgType = payload.kind === 'text' ? 'text' : 'interactive';
119
- const content = payload.kind === 'text' ? textPayload(payload.text) : cardPayload(payload.card);
124
+ const msgType = payload.kind === 'text'
125
+ ? 'text'
126
+ : payload.kind === 'card'
127
+ ? 'interactive'
128
+ : payload.kind;
129
+ const content = payload.kind === 'text'
130
+ ? textPayload(payload.text)
131
+ : payload.kind === 'card'
132
+ ? cardPayload(payload.card)
133
+ : payload.kind === 'file'
134
+ ? JSON.stringify({ file_key: payload.fileKey })
135
+ : JSON.stringify({ image_key: payload.imageKey });
120
136
  const replyInThread = options.threadId !== undefined && options.threadId.length > 0;
121
137
  const kind = payload.kind === 'card' ? '卡片' : '消息';
122
138
  try {
@@ -109,6 +109,13 @@ export type ReplyPayload = {
109
109
  } | {
110
110
  readonly kind: 'card';
111
111
  readonly card: FeishuCard;
112
+ } | {
113
+ readonly kind: 'file';
114
+ readonly fileKey: string;
115
+ readonly fileName: string;
116
+ } | {
117
+ readonly kind: 'image';
118
+ readonly imageKey: string;
112
119
  };
113
120
  export interface SendOptions {
114
121
  /** Idempotency key; the same key may succeed only once per hour on Feishu. */
@@ -134,6 +141,10 @@ export interface Transport {
134
141
  addReaction(messageId: string, emojiType: string): Promise<string>;
135
142
  /** Remove one reaction previously added by this bot. */
136
143
  removeReaction(messageId: string, reactionId: string): Promise<void>;
144
+ /** Upload one file and return its Feishu file_key. */
145
+ uploadFile(fileName: string, bytes: Uint8Array): Promise<string>;
146
+ /** Upload one image message and return its Feishu image_key. */
147
+ uploadImage(bytes: Uint8Array): Promise<string>;
137
148
  /** Download a message resource (image/file), enforcing a byte limit. */
138
149
  downloadResource(messageId: string, fileKey: string, type: 'image' | 'file', maxBytes: number): Promise<{
139
150
  readonly bytes: Uint8Array;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-caduceus",
3
- "version": "0.5.1",
3
+ "version": "0.7.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",