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 +2 -2
- package/README_EN.md +2 -2
- package/dist/cli.js +403 -23
- package/dist/cli.js.map +1 -1
- package/dist/plugin.d.ts +6 -1
- package/dist/plugin.js +401 -21
- package/dist/plugin.js.map +1 -1
- package/package.json +3 -2
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 !== "
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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|
|
|
11285
|
-
"Usage: `/channels list|show <id>|add <telegram|
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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
|
|
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
|
|
22194
|
+
await sleep4(backoff);
|
|
21815
22195
|
}
|
|
21816
22196
|
} finally {
|
|
21817
22197
|
if (forceStopTimer) clearTimeout(forceStopTimer);
|