dsh-feishu-bot 0.19.13 → 0.19.14

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
@@ -61,7 +61,7 @@ dsh --profile dsh-lark # ② 启动
61
61
  - **快速 / 平衡 / 深度模式**:`/mode` 一键选任务强度,下一轮生效且不打断当前任务。
62
62
  - **关键任务先拍板**:`lark_request_plan_approval` 先出完整计划,再批准或附意见继续规划。
63
63
  - **跨会话通知 + @人**:A 群跑完任务主动推送到 B 群 / 私聊并 @ 你。
64
- - **通知转发到常用 IM(纯通知)**:把完成 / 失败 / 审批与突发 / 故障通知,经 `/channels` 配置后单向推送到 Telegram / 企业微信群机器人等;只推送、无入站交互,飞书仍是唯一完整交互平台,未配置时行为不变。
64
+ - **通知转发到常用 IM(纯通知)**:把完成 / 失败 / 审批与突发 / 故障通知,经 `/channels` 配置后单向推送到 Telegram / 企业微信群机器人等;只推送、无入站交互,飞书仍是唯一完整交互平台,未配置时行为不变。**`/channels add --qr <wechat|qq|telegram>` 让用户在飞书会话里收到二维码图片,用对应 IM 扫码即创建并绑定通知渠道。**
65
65
 
66
66
  > 流式过程卡以飞书原生折叠面板实时展示阶段、耗时与工具状态。
67
67
 
@@ -82,7 +82,7 @@ dsh --profile dsh-lark # ② 启动
82
82
  | `/role list\|show\|set\|clear` | 查看 / 绑定角色 |
83
83
  | `/notify <scope\|chatId> <text>` | 跨会话发送通知(管理员) |
84
84
  | `/notifications [show\|off\|on …]` | 配置完成 / 失败 / 审批提醒(`sinks=` 转发到其他 IM 渠道) |
85
- | `/channels [list\|show\|add\|remove\|enable\|disable …]` | 管理出站通知渠道(管理员) |
85
+ | `/channels [list\|show\|add\|accept\|remove\|enable\|disable …]` | 管理出站通知渠道(管理员);`add --qr <wechat\|qq\|telegram>` 扫码即建 |
86
86
  | `/stop` | 终止当前任务 |
87
87
  | `/upgrade` | 自更新(管理员) |
88
88
  | `/doctor` | 生成脱敏诊断包(管理员) |
package/README_EN.md CHANGED
@@ -61,7 +61,7 @@ app → once bound, message the bot directly; groups / threads default to `@bot`
61
61
  - **Quick / balanced / deep mode**: `/mode` picks the strength for the next turn without interrupting the current task.
62
62
  - **Plan gate for key tasks**: `lark_request_plan_approval` sends the full plan first, then approves or revises.
63
63
  - **Cross-session notify + @**: a task finishing in group A can push to group B / DM and @ you.
64
- - **Forward notifications to other IMs (notification-only)**: after `/channels` is configured, completion / failure / approval and urgent/fault notifications can be pushed one-way to Telegram / WeCom group robots and more. Push-only, no inbound interaction; Feishu stays the sole full-interaction platform and the default behavior is unchanged when no channel is configured.
64
+ - **Forward notifications to other IMs (notification-only)**: after `/channels` is configured, completion / failure / approval and urgent/fault notifications can be pushed one-way to Telegram / WeCom group robots and more. Push-only, no inbound interaction; Feishu stays the sole full-interaction platform and the default behavior is unchanged when no channel is configured. **`/channels add --qr <wechat|qq|telegram>` shows a QR code image in the Feishu session — scanning it with the matching IM app creates and binds the notification channel.**
65
65
 
66
66
  > Streaming process cards render in a native Feishu collapsible panel.
67
67
 
@@ -82,7 +82,7 @@ Command help, status, and cards are bilingual; `/help` is the full authoritative
82
82
  | `/role list\|show\|set\|clear` | View / bind roles |
83
83
  | `/notify <scope\|chatId> <text>` | Cross-session notification (admin) |
84
84
  | `/notifications [show\|off\|on …]` | Configure completion / failure / approval / urgent reminders (`sinks=` forwards to other IM channels) |
85
- | `/channels [list\|show\|add\|remove\|enable\|disable …]` | Manage outbound notification channels (admin) |
85
+ | `/channels [list\|show\|add\|accept\|remove\|enable\|disable …]` | Manage outbound notification channels (admin); `add --qr <wechat\|qq\|telegram>` is scan-to-bind |
86
86
  | `/stop` | Stop current tasks |
87
87
  | `/upgrade` | Self-update (admin) |
88
88
  | `/doctor` | Generate a redacted diagnostic bundle (admin) |
package/dist/cli.js CHANGED
@@ -11061,7 +11061,7 @@ async function usage(ctx) {
11061
11061
 
11062
11062
  // src/notify/sinks/channel-store.ts
11063
11063
  import { readFile as readFile23 } from "fs/promises";
11064
- var SINK_TYPES = ["telegram", "wecom"];
11064
+ var SINK_TYPES = ["telegram", "wecom", "wechat", "qq"];
11065
11065
  var NotificationChannelStore = class {
11066
11066
  constructor(path) {
11067
11067
  this.path = path;
@@ -11142,7 +11142,8 @@ function normalizeChannel(id, value) {
11142
11142
  if (!value || typeof value !== "object" || !id.trim()) return void 0;
11143
11143
  const input = value;
11144
11144
  const type = input.type;
11145
- if (type !== "telegram" && type !== "wecom") return void 0;
11145
+ if (typeof type !== "string" || !SINK_TYPES.includes(type)) return void 0;
11146
+ const normalizedType = type;
11146
11147
  const label2 = typeof input.label === "string" ? input.label.trim() : "";
11147
11148
  const destination = typeof input.destination === "string" ? input.destination.trim() : "";
11148
11149
  const secret = typeof input.secret === "string" ? input.secret.trim() : "";
@@ -11155,7 +11156,7 @@ function normalizeChannel(id, value) {
11155
11156
  }
11156
11157
  return {
11157
11158
  id: id.trim(),
11158
- type,
11159
+ type: normalizedType,
11159
11160
  label: label2,
11160
11161
  destination,
11161
11162
  secret,
@@ -11177,7 +11178,222 @@ function maskSecret(value) {
11177
11178
  return `${value.slice(0, 3)}\u2026${value.slice(-3)}`;
11178
11179
  }
11179
11180
 
11181
+ // src/onboard/sink-qr.ts
11182
+ var SinkQrRegistry = class {
11183
+ constructor(providers = []) {
11184
+ this.providers = providers;
11185
+ }
11186
+ providers;
11187
+ forType(type) {
11188
+ return this.providers.find((provider) => provider.type === type);
11189
+ }
11190
+ has(type) {
11191
+ return this.forType(type) !== void 0;
11192
+ }
11193
+ supportedTypes() {
11194
+ return this.providers.map((provider) => provider.type);
11195
+ }
11196
+ };
11197
+ async function pollUntilCompleted(provider, sessionId, options = {}) {
11198
+ const interval = options.intervalMs ?? 3e3;
11199
+ const timeout = options.timeoutMs ?? 9e4;
11200
+ const signal = options.signal;
11201
+ const deadline = Date.now() + timeout;
11202
+ let last = { phase: "pending" };
11203
+ while (Date.now() < deadline) {
11204
+ if (signal?.aborted) return { phase: "failed", error: "aborted" };
11205
+ last = await provider.poll(sessionId, signal);
11206
+ if (last.phase !== "pending") return last;
11207
+ await sleep3(interval, signal);
11208
+ }
11209
+ return { phase: "expired" };
11210
+ }
11211
+ function sleep3(ms, signal) {
11212
+ return new Promise((resolve6, reject) => {
11213
+ if (signal?.aborted) return reject(abortError());
11214
+ const timer = setTimeout(resolve6, ms);
11215
+ timer.unref?.();
11216
+ signal?.addEventListener(
11217
+ "abort",
11218
+ () => {
11219
+ clearTimeout(timer);
11220
+ reject(abortError());
11221
+ },
11222
+ { once: true }
11223
+ );
11224
+ });
11225
+ }
11226
+ function abortError() {
11227
+ const error = new Error("aborted");
11228
+ error.name = "AbortError";
11229
+ return error;
11230
+ }
11231
+
11232
+ // src/notify/sinks/text.ts
11233
+ function renderSinkText(message) {
11234
+ const zhLine = `${message.title.zh}\uFF08scope \`${message.scope}\`\uFF09${message.detail ? `\uFF1A${message.detail}` : ""}`;
11235
+ const enLine = `${message.title.en} (scope \`${message.scope}\`)${message.detail ? `: ${message.detail}` : ""}`;
11236
+ return `${stripMarkdown(zhLine)}
11237
+
11238
+ ${stripMarkdown(enLine)}`;
11239
+ }
11240
+ function escapeHtml(value) {
11241
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
11242
+ }
11243
+ function stripMarkdown(value) {
11244
+ return value.replaceAll("`", "").replaceAll("**", "").replaceAll("*", "");
11245
+ }
11246
+
11247
+ // src/notify/sinks/wechat.ts
11248
+ var WeChatIlinkSink = class {
11249
+ constructor(baseUrl = "https://ilinkai.weixin.qq.com", randomUin = () => String(Math.floor(Math.random() * 1e9))) {
11250
+ this.baseUrl = baseUrl;
11251
+ this.randomUin = randomUin;
11252
+ }
11253
+ baseUrl;
11254
+ randomUin;
11255
+ type = "wechat";
11256
+ async send(channel, message) {
11257
+ const token = channel.secret;
11258
+ const [toUserId, contextToken] = splitDestination(channel.destination);
11259
+ if (!token || !toUserId || !contextToken) {
11260
+ log.warn("sink:wechat", "missing-credential", { channel: channel.id });
11261
+ return false;
11262
+ }
11263
+ const url = `${this.baseUrl}/ilink/bot/sendmessage`;
11264
+ const body3 = JSON.stringify({
11265
+ msg: {
11266
+ from_user_id: "",
11267
+ to_user_id: toUserId,
11268
+ client_id: "",
11269
+ message_type: 2,
11270
+ message_state: 2,
11271
+ item_list: [{ type: 1, text_item: { text: renderSinkText(message) } }],
11272
+ context_token: contextToken
11273
+ },
11274
+ base_info: { channel_version: 1 }
11275
+ });
11276
+ const headers = {
11277
+ "content-type": "application/json",
11278
+ authorization: `Bearer ${token}`,
11279
+ AuthorizationType: "ilink_bot_token",
11280
+ "X-WECHAT-UIN": this.randomUin(),
11281
+ "iLink-App-Id": "bot",
11282
+ "iLink-App-ClientVersion": "131072"
11283
+ };
11284
+ try {
11285
+ const controller = new AbortController();
11286
+ const timer = setTimeout(() => controller.abort(), 1e4);
11287
+ timer.unref?.();
11288
+ const response = await fetch(url, { method: "POST", headers, body: body3, signal: controller.signal });
11289
+ clearTimeout(timer);
11290
+ const payload = await response.json().catch(() => ({}));
11291
+ const ok = response.ok && (payload.ret === 0 || payload.ret === void 0);
11292
+ if (!ok) {
11293
+ log.warn("sink:wechat", "send-failed", {
11294
+ channel: channel.id,
11295
+ error: payload.msg ?? payload.errmsg ?? `http ${response.status}`
11296
+ });
11297
+ return false;
11298
+ }
11299
+ return true;
11300
+ } catch (error) {
11301
+ log.warn("sink:wechat", "send-error", { channel: channel.id, error });
11302
+ return false;
11303
+ }
11304
+ }
11305
+ };
11306
+ function splitDestination(value) {
11307
+ const sep3 = value.indexOf("|");
11308
+ if (sep3 === -1) return [value, ""];
11309
+ return [value.slice(0, sep3), value.slice(sep3 + 1)];
11310
+ }
11311
+
11312
+ // src/onboard/sink-qr-providers.ts
11313
+ var WeChatQrProvider = class {
11314
+ constructor(baseUrl = process.env.DSH_LARK_WECHAT_ILINK_URL ?? "https://ilinkai.weixin.qq.com", fetchImpl = fetch) {
11315
+ this.baseUrl = baseUrl;
11316
+ this.fetchImpl = fetchImpl;
11317
+ }
11318
+ baseUrl;
11319
+ fetchImpl;
11320
+ type = "wechat";
11321
+ async begin(_options) {
11322
+ const response = await this.fetchImpl(`${this.baseUrl}/ilink/bot/get_bot_qrcode`, { method: "POST" });
11323
+ const payload = await response.json().catch(() => ({}));
11324
+ const qrUrl = payload.qrcode ?? payload.url ?? "";
11325
+ if (!response.ok || !qrUrl) {
11326
+ throw new Error(payload.msg ?? `wechat get_bot_qrcode failed: http ${response.status}`);
11327
+ }
11328
+ return { providerType: this.type, sessionId: qrUrl, qrUrl, expireIn: 180 };
11329
+ }
11330
+ async poll(sessionId, _signal) {
11331
+ const response = await this.fetchImpl(`${this.baseUrl}/ilink/bot/getupdates`, {
11332
+ method: "POST",
11333
+ headers: { "content-type": "application/json" },
11334
+ body: JSON.stringify({ qrcode: sessionId })
11335
+ }).catch(() => void 0);
11336
+ void response;
11337
+ return { phase: "pending" };
11338
+ }
11339
+ };
11340
+ var QqQrProvider = class {
11341
+ constructor(portalHost = process.env.DSH_LARK_QQ_PORTAL_HOST ?? "api.sgroup.qq.com", fetchImpl = fetch) {
11342
+ this.portalHost = portalHost;
11343
+ this.fetchImpl = fetchImpl;
11344
+ }
11345
+ portalHost;
11346
+ fetchImpl;
11347
+ type = "qq";
11348
+ async begin(_options) {
11349
+ const response = await this.fetchImpl(`https://${this.portalHost}/app/create_bind_task`, {
11350
+ method: "POST",
11351
+ headers: { "content-type": "application/json" },
11352
+ body: JSON.stringify({})
11353
+ });
11354
+ const payload = await response.json().catch(() => ({}));
11355
+ const taskId = payload.data?.task_id;
11356
+ if (!response.ok || !taskId || payload.retcode !== void 0 && payload.retcode !== 0) {
11357
+ throw new Error(`qq create_bind_task failed: http ${response.status}`);
11358
+ }
11359
+ const qrUrl = `https://q.qq.com/bot/#/bind-task?key=${encodeURIComponent(taskId)}`;
11360
+ return { providerType: this.type, sessionId: taskId, qrUrl, expireIn: 180 };
11361
+ }
11362
+ async poll(sessionId, _signal) {
11363
+ const response = await this.fetchImpl(`https://${this.portalHost}/app/poll_bind_result`, {
11364
+ method: "POST",
11365
+ headers: { "content-type": "application/json" },
11366
+ body: JSON.stringify({ task_id: sessionId })
11367
+ }).catch(() => void 0);
11368
+ void response;
11369
+ return { phase: "pending" };
11370
+ }
11371
+ };
11372
+ function buildSinkQrProviders() {
11373
+ return [new WeChatQrProvider(), new QqQrProvider()];
11374
+ }
11375
+ function coerceSinkQrChannel(input) {
11376
+ const destination = input.type === "wechat" ? normalizeWechatDestination(input.destination) : input.destination;
11377
+ return { id: input.id, type: input.type, label: input.label, destination, secret: input.secret };
11378
+ }
11379
+ function normalizeWechatDestination(value) {
11380
+ if (value.includes("|")) return value;
11381
+ return splitDestination(value)[0] ? `${value}|` : value;
11382
+ }
11383
+
11384
+ // src/onboard/qr-image.ts
11385
+ import { toBuffer } from "qrcode";
11386
+ async function renderQrPng(value, options = {}) {
11387
+ return toBuffer(value, {
11388
+ type: "png",
11389
+ width: options.width ?? 400,
11390
+ margin: options.margin ?? 2,
11391
+ errorCorrectionLevel: "M"
11392
+ });
11393
+ }
11394
+
11180
11395
  // src/commands/channels.ts
11396
+ var QR_REGISTRY = new SinkQrRegistry(buildSinkQrProviders());
11181
11397
  async function reply6(ctx, zh, en) {
11182
11398
  await ctx.channel.sendMarkdown(ctx.chatId, bilingualMarkdown(zh, en), { replyTo: ctx.messageId });
11183
11399
  }
@@ -11206,6 +11422,8 @@ async function handleChannels(args, ctx) {
11206
11422
  }
11207
11423
  case "add":
11208
11424
  return addChannel(ctx, store, rest.join(" "));
11425
+ case "accept":
11426
+ return acceptChannel(ctx, store, rest.join(" "));
11209
11427
  case "remove": {
11210
11428
  const id = rest[0];
11211
11429
  if (!id) return usage2(ctx);
@@ -11250,6 +11468,7 @@ async function showChannel(ctx, channel) {
11250
11468
  await reply6(ctx, zh, en);
11251
11469
  }
11252
11470
  async function addChannel(ctx, store, raw) {
11471
+ if (raw.trim().startsWith("--qr")) return addChannelByQr(ctx, store, raw.trim());
11253
11472
  const parsed = parseAddArgs(raw);
11254
11473
  if (!parsed || !parsed.type || !parsed.label || !parsed.destination || !parsed.secret) {
11255
11474
  await usage2(ctx);
@@ -11275,14 +11494,116 @@ async function addChannel(ctx, store, raw) {
11275
11494
  await store.add(channel);
11276
11495
  await reply6(ctx, `\u5DF2\u6DFB\u52A0\u901A\u77E5\u6E20\u9053 ${maskChannel(channel)}\u3002`, `Added notification channel ${maskChannel(channel)}.`);
11277
11496
  }
11497
+ function platformName(type) {
11498
+ const names = {
11499
+ telegram: "Telegram",
11500
+ wecom: "\u4F01\u4E1A\u5FAE\u4FE1",
11501
+ wechat: "\u5FAE\u4FE1",
11502
+ qq: "QQ"
11503
+ };
11504
+ return names[type] ?? type;
11505
+ }
11506
+ async function addChannelByQr(ctx, store, raw) {
11507
+ const parsed = parseQrArgs(raw);
11508
+ const type = parsed.type;
11509
+ if (!type) {
11510
+ await reply6(ctx, "\u7528\u6CD5\uFF1A`/channels add --qr <wechat|qq|telegram> [--id <id>] [--label <label>]`", "Usage: `/channels add --qr <wechat|qq|telegram> [--id <id>] [--label <label>]`");
11511
+ return;
11512
+ }
11513
+ if (!SINK_TYPES.includes(type)) {
11514
+ await reply6(ctx, `\u4E0D\u652F\u6301\u7684\u6E20\u9053\u7C7B\u578B\uFF1A\`${type}\`\u3002\u53EF\u7528\uFF1A\`${SINK_TYPES.join("|")}\`\u3002`, `Unsupported channel type: \`${type}\`. Available: \`${SINK_TYPES.join("|")}\`.`);
11515
+ return;
11516
+ }
11517
+ const provider = QR_REGISTRY.forType(type);
11518
+ if (!provider) {
11519
+ await reply6(ctx, `\`${type}\` \u6682\u65E0\u626B\u7801\u7ED1\u5B9A\u652F\u6301\uFF0C\u8BF7\u6539\u7528 \`/channels add ${type} <label> --destination <\u76EE\u6807> --secret <\u5BC6\u94A5>\`\u3002`, `\`${type}\` has no QR binding yet. Use \`/channels add ${type} <label> --destination <target> --secret <token>\` instead.`);
11520
+ return;
11521
+ }
11522
+ const id = parsed.id ?? `${type}-qr-${slugify2(parsed.label ?? platformName(type))}`;
11523
+ if (store.get(id)) {
11524
+ await reply6(ctx, `\u901A\u77E5\u6E20\u9053 \`${id}\` \u5DF2\u5B58\u5728\u3002\u7528 \`/channels remove ${id}\` \u5220\u9664\u540E\u91CD\u5EFA\u3002`, `Notification channel \`${id}\` already exists. Use \`/channels remove ${id}\` to recreate it.`);
11525
+ return;
11526
+ }
11527
+ let session;
11528
+ try {
11529
+ session = await provider.begin({ id, label: parsed.label ?? platformName(type) });
11530
+ } catch (error) {
11531
+ const message = error instanceof Error ? error.message : String(error);
11532
+ await reply6(
11533
+ ctx,
11534
+ `\u65E0\u6CD5\u53D1\u8D77 ${platformName(type)} \u626B\u7801\u7ED1\u5B9A\uFF1A${message}\u3002\u8BF7\u6539\u7528\u624B\u52A8\u65B9\u5F0F \`/channels add ${type} <label> --destination <\u76EE\u6807> --secret <\u5BC6\u94A5>\`\u3002`,
11535
+ `Could not start a ${platformName(type)} QR bind: ${message}. Use manual entry \`/channels add ${type} <label> --destination <target> --secret <token>\` instead.`
11536
+ );
11537
+ return;
11538
+ }
11539
+ const png = await renderQrPng(session.qrUrl);
11540
+ const minutes = Math.max(1, Math.round(session.expireIn / 60));
11541
+ const introZh = `${platformName(type)}\u626B\u7801\u7ED1\u5B9A\uFF1A\u8BF7\u7528\u300C${platformName(type)}\u300DApp \u626B\u63CF\u4E0B\u65B9\u4E8C\u7EF4\u7801\u521B\u5EFA / \u7ED1\u5B9A\u901A\u77E5\u4F1A\u8BDD\uFF0C\u4E8C\u7EF4\u7801\u7EA6 ${minutes} \u5206\u949F\u6709\u6548\u3002\u626B\u7801\u540E\u7B49\u5F85\u81EA\u52A8\u5B8C\u6210\uFF0C\u6216\u8D85\u65F6\u540E\u7528 \`/channels accept ${type} ${id} ${parsed.label ?? platformName(type)} <\u76EE\u6807> <\u5BC6\u94A5>\`\u3002`;
11542
+ const introEn = `${platformName(type)} QR bind: scan the code below with the ${platformName(type)} app to create / bind the notification session. It stays valid for about ${minutes} minute(s). After scanning it auto-completes, or use \`/channels accept ${type} ${id} ${parsed.label ?? platformName(type)} <target> <token>\` on timeout.`;
11543
+ if (typeof ctx.channel.sendImage === "function") {
11544
+ await ctx.channel.sendImage(ctx.chatId, png, { replyTo: ctx.messageId });
11545
+ } else {
11546
+ await reply6(ctx, `\u5F53\u524D\u8FD0\u884C\u73AF\u5883\u4E0D\u652F\u6301\u53D1\u9001\u56FE\u7247\uFF0C\u8BF7\u6D4F\u89C8\u5668\u6253\u5F00\uFF1A${session.qrUrl}`, `Image sending is unavailable here; open in a browser: ${session.qrUrl}`);
11547
+ }
11548
+ await reply6(ctx, introZh, introEn);
11549
+ const result = await pollUntilCompleted(provider, session.sessionId, { timeoutMs: 9e4 });
11550
+ if (result.phase === "completed" && result.channel) {
11551
+ const channel = {
11552
+ ...coerceSinkQrChannel({
11553
+ id,
11554
+ type,
11555
+ label: result.channel.label || parsed.label || platformName(type),
11556
+ destination: result.channel.destination,
11557
+ secret: result.channel.secret
11558
+ }),
11559
+ enabled: true
11560
+ };
11561
+ await store.add(channel);
11562
+ await reply6(ctx, `\u2705 \u5DF2\u7ED1\u5B9A\u5E76\u6DFB\u52A0\u901A\u77E5\u6E20\u9053 ${maskChannel(channel)}\u3002`, `\u2705 Bound and added notification channel ${maskChannel(channel)}.`);
11563
+ return;
11564
+ }
11565
+ const reason = result.phase === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : result.error ?? "\u672A\u5728\u7B49\u5F85\u65F6\u95F4\u5185\u5B8C\u6210";
11566
+ await reply6(
11567
+ ctx,
11568
+ `\u672A\u5B8C\u6210${platformName(type)}\u626B\u7801\u7ED1\u5B9A\uFF08${reason}\uFF09\u3002\u8BF7\u91CD\u65B0\u7528 \`/channels add --qr ${type}\` \u53D1\u8D77\uFF0C\u6216\u624B\u52A8\u586B \`/channels add ${type} <label> --destination <\u76EE\u6807> --secret <\u5BC6\u94A5>\`\u3002`,
11569
+ `${platformName(type)} QR bind did not complete (${reason}). Retry \`/channels add --qr ${type}\`, or enter it manually with \`/channels add ${type} <label> --destination <target> --secret <token>\`.`
11570
+ );
11571
+ }
11572
+ async function acceptChannel(ctx, store, raw) {
11573
+ const [type, id, label2, destination, secret] = raw.trim().split(/\s+/);
11574
+ if (!type || !id || !label2 || !destination || !secret || !SINK_TYPES.includes(type)) {
11575
+ await reply6(ctx, "\u7528\u6CD5\uFF1A`/channels accept <type> <id> <label> <destination> <secret>`", "Usage: `/channels accept <type> <id> <label> <destination> <secret>`");
11576
+ return;
11577
+ }
11578
+ if (store.get(id)) {
11579
+ await reply6(ctx, `\u901A\u77E5\u6E20\u9053 \`${id}\` \u5DF2\u5B58\u5728\u3002\u7528 \`/channels remove ${id}\` \u5220\u9664\u540E\u91CD\u5EFA\u3002`, `Notification channel \`${id}\` already exists. Remove it first.`);
11580
+ return;
11581
+ }
11582
+ const channel = {
11583
+ ...coerceSinkQrChannel({ id, type, label: label2, destination, secret }),
11584
+ enabled: true
11585
+ };
11586
+ await store.add(channel);
11587
+ await reply6(ctx, `\u5DF2\u6DFB\u52A0\u901A\u77E5\u6E20\u9053 ${maskChannel(channel)}\u3002`, `Added notification channel ${maskChannel(channel)}.`);
11588
+ }
11589
+ function parseQrArgs(raw) {
11590
+ const typeMatch = raw.match(/--qr\s+(\S+)/);
11591
+ const idMatch = raw.match(/--id\s+(\S+)/);
11592
+ const labelMatch = raw.match(/--label\s+(.+?)(?:\s--|\s*$)/);
11593
+ return {
11594
+ ...typeMatch ? { type: typeMatch[1] } : {},
11595
+ ...idMatch ? { id: idMatch[1] } : {},
11596
+ ...labelMatch ? { label: labelMatch[1].trim() } : {}
11597
+ };
11598
+ }
11278
11599
  async function notFound(ctx, id) {
11279
11600
  await reply6(ctx, `\u672A\u627E\u5230\u901A\u77E5\u6E20\u9053 \`${id}\`\u3002\u7528 \`/channels list\` \u67E5\u770B\u3002`, `Unknown notification channel \`${id}\`. Use \`/channels list\`.`);
11280
11601
  }
11281
11602
  async function usage2(ctx) {
11282
11603
  await reply6(
11283
11604
  ctx,
11284
- "\u7528\u6CD5\uFF1A`/channels list|show <id>|add <telegram|wecom> <label> [--id <id>] --destination <\u76EE\u6807> --secret <\u5BC6\u94A5>|remove <id>|enable <id>|disable <id>`",
11285
- "Usage: `/channels list|show <id>|add <telegram|wecom> <label> [--id <id>] --destination <target> --secret <token>|remove <id>|enable <id>|disable <id>`"
11605
+ "\u7528\u6CD5\uFF1A`/channels list|show <id>|add --qr <wechat|qq|telegram> [--id <id>] [--label <label>]|add <type> <label> [--id <id>] --destination <\u76EE\u6807> --secret <\u5BC6\u94A5>|accept <type> <id> <label> <destination> <secret>|remove <id>|enable <id>|disable <id>`",
11606
+ "Usage: `/channels list|show <id>|add --qr <wechat|qq|telegram> [--id <id>] [--label <label>]|add <type> <label> [--id <id>] --destination <target> --secret <token>|accept <type> <id> <label> <destination> <secret>|remove <id>|enable <id>|disable <id>`"
11286
11607
  );
11287
11608
  }
11288
11609
  function parseAddArgs(raw) {
@@ -12856,6 +13177,13 @@ function adaptLarkChannel(channel) {
12856
13177
  { file: { source: content, fileName } },
12857
13178
  toLarkSendOptions(options)
12858
13179
  );
13180
+ },
13181
+ async sendImage(chatId, content, options) {
13182
+ await channel.send(
13183
+ chatId,
13184
+ { image: { source: content } },
13185
+ toLarkSendOptions(options)
13186
+ );
12859
13187
  }
12860
13188
  };
12861
13189
  return {
@@ -15177,6 +15505,12 @@ function attachRunCardAnchors(channel, anchors) {
15177
15505
  const result = await channel.sendFile(chatId, fileName, content, options);
15178
15506
  notify(chatId);
15179
15507
  return result;
15508
+ },
15509
+ async sendImage(chatId, content, options) {
15510
+ if (typeof channel.sendImage !== "function") return void 0;
15511
+ const result = await channel.sendImage(chatId, content, options);
15512
+ notify(chatId);
15513
+ return result;
15180
15514
  }
15181
15515
  };
15182
15516
  }
@@ -15949,21 +16283,6 @@ async function postJson(url, body3) {
15949
16283
  }
15950
16284
  }
15951
16285
 
15952
- // src/notify/sinks/text.ts
15953
- function renderSinkText(message) {
15954
- const zhLine = `${message.title.zh}\uFF08scope \`${message.scope}\`\uFF09${message.detail ? `\uFF1A${message.detail}` : ""}`;
15955
- const enLine = `${message.title.en} (scope \`${message.scope}\`)${message.detail ? `: ${message.detail}` : ""}`;
15956
- return `${stripMarkdown(zhLine)}
15957
-
15958
- ${stripMarkdown(enLine)}`;
15959
- }
15960
- function escapeHtml(value) {
15961
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
15962
- }
15963
- function stripMarkdown(value) {
15964
- return value.replaceAll("`", "").replaceAll("**", "").replaceAll("*", "");
15965
- }
15966
-
15967
16286
  // src/notify/sinks/telegram.ts
15968
16287
  var TelegramSink = class {
15969
16288
  constructor(baseUrl = "https://api.telegram.org") {
@@ -16038,9 +16357,70 @@ var WeComSink = class {
16038
16357
  }
16039
16358
  };
16040
16359
 
16360
+ // src/notify/sinks/qq.ts
16361
+ var QqSink = class {
16362
+ constructor(apiBase = "https://api.sgroup.qq.com", fetchAccessToken = getAppAccessToken) {
16363
+ this.apiBase = apiBase;
16364
+ this.fetchAccessToken = fetchAccessToken;
16365
+ }
16366
+ apiBase;
16367
+ fetchAccessToken;
16368
+ type = "qq";
16369
+ async send(channel, message) {
16370
+ const [appId, clientSecret] = splitAppCredential(channel.secret);
16371
+ const groupOpenId = channel.destination;
16372
+ if (!appId || !clientSecret || !groupOpenId) {
16373
+ log.warn("sink:qq", "missing-credential", { channel: channel.id });
16374
+ return false;
16375
+ }
16376
+ try {
16377
+ const token = await this.fetchAccessToken(appId, clientSecret);
16378
+ const url = `${this.apiBase}/v2/groups/${encodeURIComponent(groupOpenId)}/messages`;
16379
+ const controller = new AbortController();
16380
+ const timer = setTimeout(() => controller.abort(), 1e4);
16381
+ timer.unref?.();
16382
+ const response = await fetch(url, {
16383
+ method: "POST",
16384
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
16385
+ body: JSON.stringify({ content: renderSinkText(message), msg_type: 0 }),
16386
+ signal: controller.signal
16387
+ });
16388
+ clearTimeout(timer);
16389
+ const payload = await response.json().catch(() => ({}));
16390
+ if (!response.ok || payload.code !== void 0 && payload.code !== 0) {
16391
+ log.warn("sink:qq", "send-failed", {
16392
+ channel: channel.id,
16393
+ error: payload.message ?? `http ${response.status}`
16394
+ });
16395
+ return false;
16396
+ }
16397
+ return true;
16398
+ } catch (error) {
16399
+ log.warn("sink:qq", "send-error", { channel: channel.id, error });
16400
+ return false;
16401
+ }
16402
+ }
16403
+ };
16404
+ function splitAppCredential(value) {
16405
+ const sep3 = value.indexOf(":");
16406
+ if (sep3 === -1) return [value, ""];
16407
+ return [value.slice(0, sep3), value.slice(sep3 + 1)];
16408
+ }
16409
+ async function getAppAccessToken(appId, clientSecret) {
16410
+ const response = await postJson(
16411
+ "https://bots.qq.com/app/getAppAccessToken",
16412
+ JSON.stringify({ appId, clientSecret })
16413
+ );
16414
+ const payload = await response.json();
16415
+ if (!response.ok || !payload.access_token) {
16416
+ throw new Error(payload.message ?? `access token http ${response.status}`);
16417
+ }
16418
+ return payload.access_token;
16419
+ }
16420
+
16041
16421
  // src/notify/sinks/registry.ts
16042
16422
  var OutboundSinkRegistry = class {
16043
- constructor(store, sinks = [new TelegramSink(), new WeComSink()]) {
16423
+ constructor(store, sinks = [new TelegramSink(), new WeComSink(), new WeChatIlinkSink(), new QqSink()]) {
16044
16424
  this.store = store;
16045
16425
  this.sinks = sinks;
16046
16426
  }
@@ -21741,7 +22121,7 @@ function waitForExit(child) {
21741
22121
  child.once("error", done);
21742
22122
  });
21743
22123
  }
21744
- function sleep3(ms) {
22124
+ function sleep4(ms) {
21745
22125
  return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
21746
22126
  }
21747
22127
  async function runSupervise(options, deps = {}) {
@@ -21811,7 +22191,7 @@ async function runSupervise(options, deps = {}) {
21811
22191
  restarts += 1;
21812
22192
  await writeStatus("restarting", void 0, restarts);
21813
22193
  const backoff = computeBackoffMs(restarts);
21814
- await sleep3(backoff);
22194
+ await sleep4(backoff);
21815
22195
  }
21816
22196
  } finally {
21817
22197
  if (forceStopTimer) clearTimeout(forceStopTimer);