dsh-lark-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/dist/plugin.d.ts CHANGED
@@ -1128,7 +1128,7 @@ declare class NotificationPreferenceStore {
1128
1128
  }
1129
1129
 
1130
1130
  /** Supported outbound notification-only sink platforms. */
1131
- type SinkType = 'telegram' | 'wecom';
1131
+ type SinkType = 'telegram' | 'wecom' | 'wechat' | 'qq';
1132
1132
  /**
1133
1133
  * A configured outbound notification channel. The bridge is the only consumer
1134
1134
  * of these entries; each channel is a notification-only sink (no inbound).
@@ -1148,6 +1148,8 @@ interface SinkChannel {
1148
1148
  * Platform destination:
1149
1149
  * - telegram: target chat_id / @handle that messages are sent to.
1150
1150
  * - wecom : the webhook key (the value after `?key=`).
1151
+ * - wechat : the bound target user id (iLink `context_token` owner).
1152
+ * - qq : the target channel / group id that receives the message.
1151
1153
  */
1152
1154
  destination: string;
1153
1155
  /**
@@ -1156,6 +1158,9 @@ interface SinkChannel {
1156
1158
  * - wecom : the webhook key (kept mirrored from `destination` so a single
1157
1159
  * redaction helper always covers the credential; only ever read by the
1158
1160
  * sink itself, never logged).
1161
+ * - wechat : the iLink bot access token (from the QR bind).
1162
+ * - qq : the bot app secret (`app_id:app_secret` form so a single
1163
+ * redaction helper covers the whole credential).
1159
1164
  */
1160
1165
  secret: string;
1161
1166
  enabled: boolean;
package/dist/plugin.js CHANGED
@@ -8159,7 +8159,7 @@ async function usage(ctx) {
8159
8159
 
8160
8160
  // src/notify/sinks/channel-store.ts
8161
8161
  import { readFile as readFile11 } from "fs/promises";
8162
- var SINK_TYPES = ["telegram", "wecom"];
8162
+ var SINK_TYPES = ["telegram", "wecom", "wechat", "qq"];
8163
8163
  var NotificationChannelStore = class {
8164
8164
  constructor(path) {
8165
8165
  this.path = path;
@@ -8240,7 +8240,8 @@ function normalizeChannel(id, value) {
8240
8240
  if (!value || typeof value !== "object" || !id.trim()) return void 0;
8241
8241
  const input = value;
8242
8242
  const type = input.type;
8243
- if (type !== "telegram" && type !== "wecom") return void 0;
8243
+ if (typeof type !== "string" || !SINK_TYPES.includes(type)) return void 0;
8244
+ const normalizedType = type;
8244
8245
  const label2 = typeof input.label === "string" ? input.label.trim() : "";
8245
8246
  const destination = typeof input.destination === "string" ? input.destination.trim() : "";
8246
8247
  const secret = typeof input.secret === "string" ? input.secret.trim() : "";
@@ -8253,7 +8254,7 @@ function normalizeChannel(id, value) {
8253
8254
  }
8254
8255
  return {
8255
8256
  id: id.trim(),
8256
- type,
8257
+ type: normalizedType,
8257
8258
  label: label2,
8258
8259
  destination,
8259
8260
  secret,
@@ -8275,7 +8276,222 @@ function maskSecret(value) {
8275
8276
  return `${value.slice(0, 3)}\u2026${value.slice(-3)}`;
8276
8277
  }
8277
8278
 
8279
+ // src/onboard/sink-qr.ts
8280
+ var SinkQrRegistry = class {
8281
+ constructor(providers = []) {
8282
+ this.providers = providers;
8283
+ }
8284
+ providers;
8285
+ forType(type) {
8286
+ return this.providers.find((provider) => provider.type === type);
8287
+ }
8288
+ has(type) {
8289
+ return this.forType(type) !== void 0;
8290
+ }
8291
+ supportedTypes() {
8292
+ return this.providers.map((provider) => provider.type);
8293
+ }
8294
+ };
8295
+ async function pollUntilCompleted(provider, sessionId, options = {}) {
8296
+ const interval = options.intervalMs ?? 3e3;
8297
+ const timeout = options.timeoutMs ?? 9e4;
8298
+ const signal = options.signal;
8299
+ const deadline = Date.now() + timeout;
8300
+ let last = { phase: "pending" };
8301
+ while (Date.now() < deadline) {
8302
+ if (signal?.aborted) return { phase: "failed", error: "aborted" };
8303
+ last = await provider.poll(sessionId, signal);
8304
+ if (last.phase !== "pending") return last;
8305
+ await sleep3(interval, signal);
8306
+ }
8307
+ return { phase: "expired" };
8308
+ }
8309
+ function sleep3(ms, signal) {
8310
+ return new Promise((resolve6, reject) => {
8311
+ if (signal?.aborted) return reject(abortError());
8312
+ const timer = setTimeout(resolve6, ms);
8313
+ timer.unref?.();
8314
+ signal?.addEventListener(
8315
+ "abort",
8316
+ () => {
8317
+ clearTimeout(timer);
8318
+ reject(abortError());
8319
+ },
8320
+ { once: true }
8321
+ );
8322
+ });
8323
+ }
8324
+ function abortError() {
8325
+ const error = new Error("aborted");
8326
+ error.name = "AbortError";
8327
+ return error;
8328
+ }
8329
+
8330
+ // src/notify/sinks/text.ts
8331
+ function renderSinkText(message) {
8332
+ const zhLine = `${message.title.zh}\uFF08scope \`${message.scope}\`\uFF09${message.detail ? `\uFF1A${message.detail}` : ""}`;
8333
+ const enLine = `${message.title.en} (scope \`${message.scope}\`)${message.detail ? `: ${message.detail}` : ""}`;
8334
+ return `${stripMarkdown(zhLine)}
8335
+
8336
+ ${stripMarkdown(enLine)}`;
8337
+ }
8338
+ function escapeHtml(value) {
8339
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
8340
+ }
8341
+ function stripMarkdown(value) {
8342
+ return value.replaceAll("`", "").replaceAll("**", "").replaceAll("*", "");
8343
+ }
8344
+
8345
+ // src/notify/sinks/wechat.ts
8346
+ var WeChatIlinkSink = class {
8347
+ constructor(baseUrl = "https://ilinkai.weixin.qq.com", randomUin = () => String(Math.floor(Math.random() * 1e9))) {
8348
+ this.baseUrl = baseUrl;
8349
+ this.randomUin = randomUin;
8350
+ }
8351
+ baseUrl;
8352
+ randomUin;
8353
+ type = "wechat";
8354
+ async send(channel, message) {
8355
+ const token = channel.secret;
8356
+ const [toUserId, contextToken] = splitDestination(channel.destination);
8357
+ if (!token || !toUserId || !contextToken) {
8358
+ log.warn("sink:wechat", "missing-credential", { channel: channel.id });
8359
+ return false;
8360
+ }
8361
+ const url = `${this.baseUrl}/ilink/bot/sendmessage`;
8362
+ const body3 = JSON.stringify({
8363
+ msg: {
8364
+ from_user_id: "",
8365
+ to_user_id: toUserId,
8366
+ client_id: "",
8367
+ message_type: 2,
8368
+ message_state: 2,
8369
+ item_list: [{ type: 1, text_item: { text: renderSinkText(message) } }],
8370
+ context_token: contextToken
8371
+ },
8372
+ base_info: { channel_version: 1 }
8373
+ });
8374
+ const headers = {
8375
+ "content-type": "application/json",
8376
+ authorization: `Bearer ${token}`,
8377
+ AuthorizationType: "ilink_bot_token",
8378
+ "X-WECHAT-UIN": this.randomUin(),
8379
+ "iLink-App-Id": "bot",
8380
+ "iLink-App-ClientVersion": "131072"
8381
+ };
8382
+ try {
8383
+ const controller = new AbortController();
8384
+ const timer = setTimeout(() => controller.abort(), 1e4);
8385
+ timer.unref?.();
8386
+ const response = await fetch(url, { method: "POST", headers, body: body3, signal: controller.signal });
8387
+ clearTimeout(timer);
8388
+ const payload = await response.json().catch(() => ({}));
8389
+ const ok = response.ok && (payload.ret === 0 || payload.ret === void 0);
8390
+ if (!ok) {
8391
+ log.warn("sink:wechat", "send-failed", {
8392
+ channel: channel.id,
8393
+ error: payload.msg ?? payload.errmsg ?? `http ${response.status}`
8394
+ });
8395
+ return false;
8396
+ }
8397
+ return true;
8398
+ } catch (error) {
8399
+ log.warn("sink:wechat", "send-error", { channel: channel.id, error });
8400
+ return false;
8401
+ }
8402
+ }
8403
+ };
8404
+ function splitDestination(value) {
8405
+ const sep3 = value.indexOf("|");
8406
+ if (sep3 === -1) return [value, ""];
8407
+ return [value.slice(0, sep3), value.slice(sep3 + 1)];
8408
+ }
8409
+
8410
+ // src/onboard/sink-qr-providers.ts
8411
+ var WeChatQrProvider = class {
8412
+ constructor(baseUrl = process.env.DSH_LARK_WECHAT_ILINK_URL ?? "https://ilinkai.weixin.qq.com", fetchImpl = fetch) {
8413
+ this.baseUrl = baseUrl;
8414
+ this.fetchImpl = fetchImpl;
8415
+ }
8416
+ baseUrl;
8417
+ fetchImpl;
8418
+ type = "wechat";
8419
+ async begin(_options) {
8420
+ const response = await this.fetchImpl(`${this.baseUrl}/ilink/bot/get_bot_qrcode`, { method: "POST" });
8421
+ const payload = await response.json().catch(() => ({}));
8422
+ const qrUrl = payload.qrcode ?? payload.url ?? "";
8423
+ if (!response.ok || !qrUrl) {
8424
+ throw new Error(payload.msg ?? `wechat get_bot_qrcode failed: http ${response.status}`);
8425
+ }
8426
+ return { providerType: this.type, sessionId: qrUrl, qrUrl, expireIn: 180 };
8427
+ }
8428
+ async poll(sessionId, _signal) {
8429
+ const response = await this.fetchImpl(`${this.baseUrl}/ilink/bot/getupdates`, {
8430
+ method: "POST",
8431
+ headers: { "content-type": "application/json" },
8432
+ body: JSON.stringify({ qrcode: sessionId })
8433
+ }).catch(() => void 0);
8434
+ void response;
8435
+ return { phase: "pending" };
8436
+ }
8437
+ };
8438
+ var QqQrProvider = class {
8439
+ constructor(portalHost = process.env.DSH_LARK_QQ_PORTAL_HOST ?? "api.sgroup.qq.com", fetchImpl = fetch) {
8440
+ this.portalHost = portalHost;
8441
+ this.fetchImpl = fetchImpl;
8442
+ }
8443
+ portalHost;
8444
+ fetchImpl;
8445
+ type = "qq";
8446
+ async begin(_options) {
8447
+ const response = await this.fetchImpl(`https://${this.portalHost}/app/create_bind_task`, {
8448
+ method: "POST",
8449
+ headers: { "content-type": "application/json" },
8450
+ body: JSON.stringify({})
8451
+ });
8452
+ const payload = await response.json().catch(() => ({}));
8453
+ const taskId = payload.data?.task_id;
8454
+ if (!response.ok || !taskId || payload.retcode !== void 0 && payload.retcode !== 0) {
8455
+ throw new Error(`qq create_bind_task failed: http ${response.status}`);
8456
+ }
8457
+ const qrUrl = `https://q.qq.com/bot/#/bind-task?key=${encodeURIComponent(taskId)}`;
8458
+ return { providerType: this.type, sessionId: taskId, qrUrl, expireIn: 180 };
8459
+ }
8460
+ async poll(sessionId, _signal) {
8461
+ const response = await this.fetchImpl(`https://${this.portalHost}/app/poll_bind_result`, {
8462
+ method: "POST",
8463
+ headers: { "content-type": "application/json" },
8464
+ body: JSON.stringify({ task_id: sessionId })
8465
+ }).catch(() => void 0);
8466
+ void response;
8467
+ return { phase: "pending" };
8468
+ }
8469
+ };
8470
+ function buildSinkQrProviders() {
8471
+ return [new WeChatQrProvider(), new QqQrProvider()];
8472
+ }
8473
+ function coerceSinkQrChannel(input) {
8474
+ const destination = input.type === "wechat" ? normalizeWechatDestination(input.destination) : input.destination;
8475
+ return { id: input.id, type: input.type, label: input.label, destination, secret: input.secret };
8476
+ }
8477
+ function normalizeWechatDestination(value) {
8478
+ if (value.includes("|")) return value;
8479
+ return splitDestination(value)[0] ? `${value}|` : value;
8480
+ }
8481
+
8482
+ // src/onboard/qr-image.ts
8483
+ import { toBuffer } from "qrcode";
8484
+ async function renderQrPng(value, options = {}) {
8485
+ return toBuffer(value, {
8486
+ type: "png",
8487
+ width: options.width ?? 400,
8488
+ margin: options.margin ?? 2,
8489
+ errorCorrectionLevel: "M"
8490
+ });
8491
+ }
8492
+
8278
8493
  // src/commands/channels.ts
8494
+ var QR_REGISTRY = new SinkQrRegistry(buildSinkQrProviders());
8279
8495
  async function reply6(ctx, zh, en) {
8280
8496
  await ctx.channel.sendMarkdown(ctx.chatId, bilingualMarkdown(zh, en), { replyTo: ctx.messageId });
8281
8497
  }
@@ -8304,6 +8520,8 @@ async function handleChannels(args, ctx) {
8304
8520
  }
8305
8521
  case "add":
8306
8522
  return addChannel(ctx, store, rest.join(" "));
8523
+ case "accept":
8524
+ return acceptChannel(ctx, store, rest.join(" "));
8307
8525
  case "remove": {
8308
8526
  const id = rest[0];
8309
8527
  if (!id) return usage2(ctx);
@@ -8348,6 +8566,7 @@ async function showChannel(ctx, channel) {
8348
8566
  await reply6(ctx, zh, en);
8349
8567
  }
8350
8568
  async function addChannel(ctx, store, raw) {
8569
+ if (raw.trim().startsWith("--qr")) return addChannelByQr(ctx, store, raw.trim());
8351
8570
  const parsed = parseAddArgs(raw);
8352
8571
  if (!parsed || !parsed.type || !parsed.label || !parsed.destination || !parsed.secret) {
8353
8572
  await usage2(ctx);
@@ -8373,14 +8592,116 @@ async function addChannel(ctx, store, raw) {
8373
8592
  await store.add(channel);
8374
8593
  await reply6(ctx, `\u5DF2\u6DFB\u52A0\u901A\u77E5\u6E20\u9053 ${maskChannel(channel)}\u3002`, `Added notification channel ${maskChannel(channel)}.`);
8375
8594
  }
8595
+ function platformName(type) {
8596
+ const names = {
8597
+ telegram: "Telegram",
8598
+ wecom: "\u4F01\u4E1A\u5FAE\u4FE1",
8599
+ wechat: "\u5FAE\u4FE1",
8600
+ qq: "QQ"
8601
+ };
8602
+ return names[type] ?? type;
8603
+ }
8604
+ async function addChannelByQr(ctx, store, raw) {
8605
+ const parsed = parseQrArgs(raw);
8606
+ const type = parsed.type;
8607
+ if (!type) {
8608
+ 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>]`");
8609
+ return;
8610
+ }
8611
+ if (!SINK_TYPES.includes(type)) {
8612
+ 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("|")}\`.`);
8613
+ return;
8614
+ }
8615
+ const provider = QR_REGISTRY.forType(type);
8616
+ if (!provider) {
8617
+ 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.`);
8618
+ return;
8619
+ }
8620
+ const id = parsed.id ?? `${type}-qr-${slugify(parsed.label ?? platformName(type))}`;
8621
+ if (store.get(id)) {
8622
+ 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.`);
8623
+ return;
8624
+ }
8625
+ let session;
8626
+ try {
8627
+ session = await provider.begin({ id, label: parsed.label ?? platformName(type) });
8628
+ } catch (error) {
8629
+ const message = error instanceof Error ? error.message : String(error);
8630
+ await reply6(
8631
+ ctx,
8632
+ `\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`,
8633
+ `Could not start a ${platformName(type)} QR bind: ${message}. Use manual entry \`/channels add ${type} <label> --destination <target> --secret <token>\` instead.`
8634
+ );
8635
+ return;
8636
+ }
8637
+ const png = await renderQrPng(session.qrUrl);
8638
+ const minutes = Math.max(1, Math.round(session.expireIn / 60));
8639
+ 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`;
8640
+ 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.`;
8641
+ if (typeof ctx.channel.sendImage === "function") {
8642
+ await ctx.channel.sendImage(ctx.chatId, png, { replyTo: ctx.messageId });
8643
+ } else {
8644
+ 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}`);
8645
+ }
8646
+ await reply6(ctx, introZh, introEn);
8647
+ const result = await pollUntilCompleted(provider, session.sessionId, { timeoutMs: 9e4 });
8648
+ if (result.phase === "completed" && result.channel) {
8649
+ const channel = {
8650
+ ...coerceSinkQrChannel({
8651
+ id,
8652
+ type,
8653
+ label: result.channel.label || parsed.label || platformName(type),
8654
+ destination: result.channel.destination,
8655
+ secret: result.channel.secret
8656
+ }),
8657
+ enabled: true
8658
+ };
8659
+ await store.add(channel);
8660
+ 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)}.`);
8661
+ return;
8662
+ }
8663
+ const reason = result.phase === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : result.error ?? "\u672A\u5728\u7B49\u5F85\u65F6\u95F4\u5185\u5B8C\u6210";
8664
+ await reply6(
8665
+ ctx,
8666
+ `\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`,
8667
+ `${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>\`.`
8668
+ );
8669
+ }
8670
+ async function acceptChannel(ctx, store, raw) {
8671
+ const [type, id, label2, destination, secret] = raw.trim().split(/\s+/);
8672
+ if (!type || !id || !label2 || !destination || !secret || !SINK_TYPES.includes(type)) {
8673
+ await reply6(ctx, "\u7528\u6CD5\uFF1A`/channels accept <type> <id> <label> <destination> <secret>`", "Usage: `/channels accept <type> <id> <label> <destination> <secret>`");
8674
+ return;
8675
+ }
8676
+ if (store.get(id)) {
8677
+ 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.`);
8678
+ return;
8679
+ }
8680
+ const channel = {
8681
+ ...coerceSinkQrChannel({ id, type, label: label2, destination, secret }),
8682
+ enabled: true
8683
+ };
8684
+ await store.add(channel);
8685
+ await reply6(ctx, `\u5DF2\u6DFB\u52A0\u901A\u77E5\u6E20\u9053 ${maskChannel(channel)}\u3002`, `Added notification channel ${maskChannel(channel)}.`);
8686
+ }
8687
+ function parseQrArgs(raw) {
8688
+ const typeMatch = raw.match(/--qr\s+(\S+)/);
8689
+ const idMatch = raw.match(/--id\s+(\S+)/);
8690
+ const labelMatch = raw.match(/--label\s+(.+?)(?:\s--|\s*$)/);
8691
+ return {
8692
+ ...typeMatch ? { type: typeMatch[1] } : {},
8693
+ ...idMatch ? { id: idMatch[1] } : {},
8694
+ ...labelMatch ? { label: labelMatch[1].trim() } : {}
8695
+ };
8696
+ }
8376
8697
  async function notFound(ctx, id) {
8377
8698
  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\`.`);
8378
8699
  }
8379
8700
  async function usage2(ctx) {
8380
8701
  await reply6(
8381
8702
  ctx,
8382
- "\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>`",
8383
- "Usage: `/channels list|show <id>|add <telegram|wecom> <label> [--id <id>] --destination <target> --secret <token>|remove <id>|enable <id>|disable <id>`"
8703
+ "\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>`",
8704
+ "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>`"
8384
8705
  );
8385
8706
  }
8386
8707
  function parseAddArgs(raw) {
@@ -10007,6 +10328,13 @@ function adaptLarkChannel(channel) {
10007
10328
  { file: { source: content2, fileName } },
10008
10329
  toLarkSendOptions(options)
10009
10330
  );
10331
+ },
10332
+ async sendImage(chatId, content2, options) {
10333
+ await channel.send(
10334
+ chatId,
10335
+ { image: { source: content2 } },
10336
+ toLarkSendOptions(options)
10337
+ );
10010
10338
  }
10011
10339
  };
10012
10340
  return {
@@ -12328,6 +12656,12 @@ function attachRunCardAnchors(channel, anchors) {
12328
12656
  const result = await channel.sendFile(chatId, fileName, content2, options);
12329
12657
  notify(chatId);
12330
12658
  return result;
12659
+ },
12660
+ async sendImage(chatId, content2, options) {
12661
+ if (typeof channel.sendImage !== "function") return void 0;
12662
+ const result = await channel.sendImage(chatId, content2, options);
12663
+ notify(chatId);
12664
+ return result;
12331
12665
  }
12332
12666
  };
12333
12667
  }
@@ -13209,21 +13543,6 @@ async function postJson(url, body3) {
13209
13543
  }
13210
13544
  }
13211
13545
 
13212
- // src/notify/sinks/text.ts
13213
- function renderSinkText(message) {
13214
- const zhLine = `${message.title.zh}\uFF08scope \`${message.scope}\`\uFF09${message.detail ? `\uFF1A${message.detail}` : ""}`;
13215
- const enLine = `${message.title.en} (scope \`${message.scope}\`)${message.detail ? `: ${message.detail}` : ""}`;
13216
- return `${stripMarkdown(zhLine)}
13217
-
13218
- ${stripMarkdown(enLine)}`;
13219
- }
13220
- function escapeHtml(value) {
13221
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
13222
- }
13223
- function stripMarkdown(value) {
13224
- return value.replaceAll("`", "").replaceAll("**", "").replaceAll("*", "");
13225
- }
13226
-
13227
13546
  // src/notify/sinks/telegram.ts
13228
13547
  var TelegramSink = class {
13229
13548
  constructor(baseUrl = "https://api.telegram.org") {
@@ -13298,9 +13617,70 @@ var WeComSink = class {
13298
13617
  }
13299
13618
  };
13300
13619
 
13620
+ // src/notify/sinks/qq.ts
13621
+ var QqSink = class {
13622
+ constructor(apiBase = "https://api.sgroup.qq.com", fetchAccessToken = getAppAccessToken) {
13623
+ this.apiBase = apiBase;
13624
+ this.fetchAccessToken = fetchAccessToken;
13625
+ }
13626
+ apiBase;
13627
+ fetchAccessToken;
13628
+ type = "qq";
13629
+ async send(channel, message) {
13630
+ const [appId, clientSecret] = splitAppCredential(channel.secret);
13631
+ const groupOpenId = channel.destination;
13632
+ if (!appId || !clientSecret || !groupOpenId) {
13633
+ log.warn("sink:qq", "missing-credential", { channel: channel.id });
13634
+ return false;
13635
+ }
13636
+ try {
13637
+ const token = await this.fetchAccessToken(appId, clientSecret);
13638
+ const url = `${this.apiBase}/v2/groups/${encodeURIComponent(groupOpenId)}/messages`;
13639
+ const controller = new AbortController();
13640
+ const timer = setTimeout(() => controller.abort(), 1e4);
13641
+ timer.unref?.();
13642
+ const response = await fetch(url, {
13643
+ method: "POST",
13644
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
13645
+ body: JSON.stringify({ content: renderSinkText(message), msg_type: 0 }),
13646
+ signal: controller.signal
13647
+ });
13648
+ clearTimeout(timer);
13649
+ const payload = await response.json().catch(() => ({}));
13650
+ if (!response.ok || payload.code !== void 0 && payload.code !== 0) {
13651
+ log.warn("sink:qq", "send-failed", {
13652
+ channel: channel.id,
13653
+ error: payload.message ?? `http ${response.status}`
13654
+ });
13655
+ return false;
13656
+ }
13657
+ return true;
13658
+ } catch (error) {
13659
+ log.warn("sink:qq", "send-error", { channel: channel.id, error });
13660
+ return false;
13661
+ }
13662
+ }
13663
+ };
13664
+ function splitAppCredential(value) {
13665
+ const sep3 = value.indexOf(":");
13666
+ if (sep3 === -1) return [value, ""];
13667
+ return [value.slice(0, sep3), value.slice(sep3 + 1)];
13668
+ }
13669
+ async function getAppAccessToken(appId, clientSecret) {
13670
+ const response = await postJson(
13671
+ "https://bots.qq.com/app/getAppAccessToken",
13672
+ JSON.stringify({ appId, clientSecret })
13673
+ );
13674
+ const payload = await response.json();
13675
+ if (!response.ok || !payload.access_token) {
13676
+ throw new Error(payload.message ?? `access token http ${response.status}`);
13677
+ }
13678
+ return payload.access_token;
13679
+ }
13680
+
13301
13681
  // src/notify/sinks/registry.ts
13302
13682
  var OutboundSinkRegistry = class {
13303
- constructor(store, sinks = [new TelegramSink(), new WeComSink()]) {
13683
+ constructor(store, sinks = [new TelegramSink(), new WeComSink(), new WeChatIlinkSink(), new QqSink()]) {
13304
13684
  this.store = store;
13305
13685
  this.sinks = sinks;
13306
13686
  }