@prismshadow/penguin-server 0.2.8 → 0.2.9
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/api/types.d.ts +232 -114
- package/dist/auth-token.js +1 -1
- package/dist/{chunk-UGAFPYVX.js → chunk-OTYHSDV7.js} +2 -2
- package/dist/{chunk-622KL7BQ.js → chunk-WL5FUO3A.js} +5 -5
- package/dist/{chunk-622KL7BQ.js.map → chunk-WL5FUO3A.js.map} +1 -1
- package/dist/index.js +2696 -1475
- package/dist/index.js.map +1 -1
- package/dist/reset-admin-password.js +2 -2
- package/package.json +3 -3
- package/web-dist/assets/{addon-fit-DHOzI3Ur.js → addon-fit-BjXzaLkS.js} +1 -1
- package/web-dist/assets/{addon-web-links-DC7759H1.js → addon-web-links-D4HvCIde.js} +1 -1
- package/web-dist/assets/{highlighter-CGPO6Q4b.js → highlighter-Di8euoN5.js} +1 -1
- package/web-dist/assets/index-C6i84Esu.js +451 -0
- package/web-dist/assets/index-DW00p4Ny.css +1 -0
- package/web-dist/assets/{xterm-DY5ko-BU.js → xterm-XhYBaOEK.js} +1 -1
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-CaFDHl-w.js +0 -451
- package/web-dist/assets/index-D8Bk9LtJ.css +0 -1
- /package/dist/{chunk-UGAFPYVX.js.map → chunk-OTYHSDV7.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
modelCredentialMissing,
|
|
16
16
|
storeApiToken,
|
|
17
17
|
toUserInfo
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-OTYHSDV7.js";
|
|
19
19
|
import {
|
|
20
20
|
acquireServerLock,
|
|
21
21
|
liveServerLock,
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
AuthSessionsRepo,
|
|
30
30
|
UsersRepo,
|
|
31
31
|
openDatabase
|
|
32
|
-
} from "./chunk-
|
|
32
|
+
} from "./chunk-WL5FUO3A.js";
|
|
33
33
|
import "./chunk-4SXGU7RO.js";
|
|
34
34
|
|
|
35
35
|
// src/index.ts
|
|
@@ -39,7 +39,7 @@ import { config as loadDotenv } from "dotenv";
|
|
|
39
39
|
import { serve } from "@hono/node-server";
|
|
40
40
|
|
|
41
41
|
// src/app.ts
|
|
42
|
-
import { createHash as
|
|
42
|
+
import { createHash as createHash6 } from "crypto";
|
|
43
43
|
import fs30 from "fs";
|
|
44
44
|
import fsp3 from "fs/promises";
|
|
45
45
|
import path28 from "path";
|
|
@@ -369,6 +369,29 @@ var ErrorsRepo = class {
|
|
|
369
369
|
message: r.message
|
|
370
370
|
}));
|
|
371
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* Deletes the rows a matching read would have returned, and answers how many went.
|
|
374
|
+
*
|
|
375
|
+
* The WHERE comes from the same `conds` the SELECTs use, so the delete can never reach a row
|
|
376
|
+
* the caller's own reads could not: narrowing a filter narrows both halves together, and
|
|
377
|
+
* there is no second expression to drift.
|
|
378
|
+
*
|
|
379
|
+
* `includeGlobal` is deliberately not accepted. Unattributed rows belong to no Project and
|
|
380
|
+
* appear in EVERY Project's admin view, so a Project-scoped clear that took them would empty
|
|
381
|
+
* them out of every other Project's panel as well — the same line deleteByProject and
|
|
382
|
+
* deleteByAgent already draw. Clearing them is an admin-wide act and needs an admin-wide
|
|
383
|
+
* surface, not a side effect of tidying one Project.
|
|
384
|
+
*
|
|
385
|
+
* The `Omit` on the parameter says that, but it does not enforce it: excess-property checking
|
|
386
|
+
* only fires on a fresh object literal, so a caller already holding an `ErrorFilter` could
|
|
387
|
+
* forward one carrying `includeGlobal: true` and compile clean. Hence the explicit `false`
|
|
388
|
+
* below — the guard is in the query, where a future caller cannot get around it.
|
|
389
|
+
*/
|
|
390
|
+
deleteFiltered(projectId, f = {}) {
|
|
391
|
+
const { where, params } = this.conds(projectId, { ...f, includeGlobal: false });
|
|
392
|
+
const res = this.db.prepare(`DELETE FROM error_records WHERE ${where}`).run(params);
|
|
393
|
+
return Number(res.changes);
|
|
394
|
+
}
|
|
372
395
|
/** Cascading cleanup on Agent deletion (unattributed errors carry no agent_id and are unaffected). */
|
|
373
396
|
deleteByAgent(projectId, agentId) {
|
|
374
397
|
this.db.prepare("DELETE FROM error_records WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
|
|
@@ -4508,182 +4531,652 @@ async function collectUnderCap(chunks, maxBytes, what) {
|
|
|
4508
4531
|
return Buffer.concat(parts);
|
|
4509
4532
|
}
|
|
4510
4533
|
|
|
4511
|
-
// src/runtime/messaging/
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4534
|
+
// src/runtime/messaging/qq-api.ts
|
|
4535
|
+
import { WebSocket } from "undici";
|
|
4536
|
+
var QQ_API_BASE = "https://api.bot.qq.com";
|
|
4537
|
+
var QQ_INTENT_GROUP_AND_C2C = 1 << 25;
|
|
4538
|
+
var CALL_TIMEOUT_MS = 15e3;
|
|
4539
|
+
var TOKEN_REFRESH_MARGIN_SEC = 60;
|
|
4540
|
+
var OP = {
|
|
4541
|
+
DISPATCH: 0,
|
|
4542
|
+
HEARTBEAT: 1,
|
|
4543
|
+
IDENTIFY: 2,
|
|
4544
|
+
RESUME: 6,
|
|
4545
|
+
RECONNECT: 7,
|
|
4546
|
+
INVALID_SESSION: 9,
|
|
4547
|
+
HELLO: 10,
|
|
4548
|
+
HEARTBEAT_ACK: 11
|
|
4549
|
+
};
|
|
4550
|
+
function closeCodeInvalidatesSession(code) {
|
|
4551
|
+
return code === 4006 || code === 4007 || code >= 4900 && code <= 4913;
|
|
4523
4552
|
}
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
import remarkGfm from "remark-gfm";
|
|
4527
|
-
import remarkParse from "remark-parse";
|
|
4528
|
-
import { unified } from "unified";
|
|
4529
|
-
var processor = unified().use(remarkParse).use(remarkGfm);
|
|
4530
|
-
function parseMarkdown(text) {
|
|
4531
|
-
return processor.parse(text);
|
|
4553
|
+
function closeCodeIsFatal(code) {
|
|
4554
|
+
return code === 4914 || code === 4915;
|
|
4532
4555
|
}
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
try {
|
|
4543
|
-
return SAFE_LINK_SCHEMES.has(new URL(url, "https://example.invalid/").protocol);
|
|
4544
|
-
} catch {
|
|
4545
|
-
return false;
|
|
4556
|
+
function closeCodeIsRoutine(code) {
|
|
4557
|
+
return code === 4009 || code >= 4900 && code <= 4913;
|
|
4558
|
+
}
|
|
4559
|
+
var MessagingConnectionClosedError = class extends Error {
|
|
4560
|
+
constructor(message, closeCode, recovers) {
|
|
4561
|
+
super(message);
|
|
4562
|
+
this.closeCode = closeCode;
|
|
4563
|
+
this.recovers = recovers;
|
|
4564
|
+
this.name = "MessagingConnectionClosedError";
|
|
4546
4565
|
}
|
|
4566
|
+
closeCode;
|
|
4567
|
+
recovers;
|
|
4568
|
+
};
|
|
4569
|
+
var QQ_HANDSHAKE_TIMEOUT_MS = 3e4;
|
|
4570
|
+
function defaultGatewayRetryMs(failures) {
|
|
4571
|
+
return Math.min(1e3 * 2 ** (failures - 1), 6e4);
|
|
4547
4572
|
}
|
|
4548
|
-
function
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4573
|
+
function fetchErrorText(err) {
|
|
4574
|
+
if (err instanceof Error) {
|
|
4575
|
+
if (err.name === "TimeoutError" || err.name === "AbortError") return "request timed out";
|
|
4576
|
+
const cause = err.cause;
|
|
4577
|
+
if (cause instanceof Error && cause.message !== "") return cause.message;
|
|
4578
|
+
return err.message;
|
|
4579
|
+
}
|
|
4580
|
+
return String(err);
|
|
4552
4581
|
}
|
|
4553
|
-
var
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4582
|
+
var QQ_BODY_INDEPENDENT_SEND_CODES = /* @__PURE__ */ new Set([
|
|
4583
|
+
40034128,
|
|
4584
|
+
40034005,
|
|
4585
|
+
304103,
|
|
4586
|
+
40034024,
|
|
4587
|
+
40034105,
|
|
4588
|
+
40054013
|
|
4560
4589
|
]);
|
|
4561
|
-
function
|
|
4562
|
-
|
|
4563
|
-
}
|
|
4564
|
-
function longestBacktickRun(value) {
|
|
4565
|
-
let best = 0;
|
|
4566
|
-
let run2 = 0;
|
|
4567
|
-
for (const ch of value) {
|
|
4568
|
-
run2 = ch === "`" ? run2 + 1 : 0;
|
|
4569
|
-
if (run2 > best) best = run2;
|
|
4590
|
+
function qqSendErrorText(errCode, message) {
|
|
4591
|
+
if (errCode === 40034128) {
|
|
4592
|
+
return `${message} \u2014 QQ allows only a few replies to one message, within minutes of it`;
|
|
4570
4593
|
}
|
|
4571
|
-
|
|
4572
|
-
}
|
|
4573
|
-
function splitHard(text, max) {
|
|
4574
|
-
const out = [];
|
|
4575
|
-
let rest = text;
|
|
4576
|
-
while (rest.length > max) {
|
|
4577
|
-
const window = rest.slice(0, max);
|
|
4578
|
-
const space = window.search(/\s\S*$/);
|
|
4579
|
-
const cut = space > max / 2 ? space : max;
|
|
4580
|
-
out.push(rest.slice(0, cut));
|
|
4581
|
-
rest = rest.slice(cut).replace(/^\s+/, "");
|
|
4594
|
+
if (errCode === 40034005 || errCode === 304103 || errCode === 40034024) {
|
|
4595
|
+
return `${message} \u2014 the QQ message being replied to has expired`;
|
|
4582
4596
|
}
|
|
4583
|
-
if (
|
|
4584
|
-
|
|
4585
|
-
}
|
|
4586
|
-
function splitLines(src, max) {
|
|
4587
|
-
const out = [];
|
|
4588
|
-
let buf = "";
|
|
4589
|
-
for (const line of src.split("\n")) {
|
|
4590
|
-
if (buf !== "" && buf.length + 1 + line.length > max) {
|
|
4591
|
-
out.push(buf);
|
|
4592
|
-
buf = "";
|
|
4593
|
-
}
|
|
4594
|
-
if (line.length > max) {
|
|
4595
|
-
if (buf !== "") {
|
|
4596
|
-
out.push(buf);
|
|
4597
|
-
buf = "";
|
|
4598
|
-
}
|
|
4599
|
-
for (const piece of splitHard(line, max)) out.push(piece);
|
|
4600
|
-
continue;
|
|
4601
|
-
}
|
|
4602
|
-
buf = buf === "" ? line : `${buf}
|
|
4603
|
-
${line}`;
|
|
4597
|
+
if (errCode === 40034105 || errCode === 40054013) {
|
|
4598
|
+
return `${message} \u2014 this QQ user has turned off messages from bots`;
|
|
4604
4599
|
}
|
|
4605
|
-
|
|
4606
|
-
return out;
|
|
4607
|
-
}
|
|
4608
|
-
function splitFencedCode(value, lang, max) {
|
|
4609
|
-
const language = typeof lang === "string" ? lang : "";
|
|
4610
|
-
const fence = "`".repeat(Math.max(3, longestBacktickRun(value) + 1));
|
|
4611
|
-
const open2 = `${fence}${language}`;
|
|
4612
|
-
const room = max - open2.length - fence.length - 2;
|
|
4613
|
-
if (room <= 0) return splitHard(value, max);
|
|
4614
|
-
return splitLines(value, room).map((piece) => `${open2}
|
|
4615
|
-
${piece}
|
|
4616
|
-
${fence}`);
|
|
4600
|
+
return message;
|
|
4617
4601
|
}
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
if (node.type === "code") return splitFencedCode(node.value, node.lang, max);
|
|
4624
|
-
const children = "children" in node ? node.children : [];
|
|
4625
|
-
const snap = BLOCK_PARENTS.has(node.type);
|
|
4626
|
-
const cuts = [];
|
|
4627
|
-
for (const child of children.slice(1)) {
|
|
4628
|
-
const childSpan = spanOf(child);
|
|
4629
|
-
if (childSpan === null) continue;
|
|
4630
|
-
const at = snap ? lineStart(text, childSpan.from) : childSpan.from;
|
|
4631
|
-
if (at > span.from && at < span.to && at !== cuts.at(-1)) cuts.push(at);
|
|
4602
|
+
var QQApiError = class extends Error {
|
|
4603
|
+
constructor(message, code) {
|
|
4604
|
+
super(message);
|
|
4605
|
+
this.code = code;
|
|
4606
|
+
this.name = "QQApiError";
|
|
4632
4607
|
}
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
let
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4608
|
+
code;
|
|
4609
|
+
};
|
|
4610
|
+
function tokenCacheOf(creds) {
|
|
4611
|
+
let token = "";
|
|
4612
|
+
let expiresAtMs = 0;
|
|
4613
|
+
let inFlight = null;
|
|
4614
|
+
const exchange = async () => {
|
|
4615
|
+
let res;
|
|
4616
|
+
try {
|
|
4617
|
+
res = await fetch(`${QQ_API_BASE}/app/getAppAccessToken`, {
|
|
4618
|
+
method: "POST",
|
|
4619
|
+
headers: { "content-type": "application/json" },
|
|
4620
|
+
body: JSON.stringify({ appId: creds.appId, clientSecret: creds.appSecret }),
|
|
4621
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS)
|
|
4622
|
+
});
|
|
4623
|
+
} catch (err) {
|
|
4624
|
+
throw new Error(`Token exchange failed: ${fetchErrorText(err)}`);
|
|
4642
4625
|
}
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4626
|
+
const body = await res.json().catch(() => null);
|
|
4627
|
+
if (body === null || typeof body.access_token !== "string" || body.access_token === "") {
|
|
4628
|
+
const detail = body?.message ?? `HTTP ${res.status}`;
|
|
4629
|
+
const code = body?.code ?? body?.err_code;
|
|
4630
|
+
throw new Error(
|
|
4631
|
+
`Token exchange failed: ${detail}${code !== void 0 ? ` (code ${code})` : ""}`
|
|
4632
|
+
);
|
|
4650
4633
|
}
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
function chunkMarkdown(text, max) {
|
|
4657
|
-
const trimmed = text.trim();
|
|
4658
|
-
if (trimmed === "") return [];
|
|
4659
|
-
if (trimmed.length <= max) return [trimmed];
|
|
4660
|
-
const root = parseMarkdown(text);
|
|
4661
|
-
const out = [];
|
|
4662
|
-
let buf = "";
|
|
4663
|
-
const flush = () => {
|
|
4664
|
-
const piece = buf.trim();
|
|
4665
|
-
if (piece !== "") out.push(piece);
|
|
4666
|
-
buf = "";
|
|
4634
|
+
const ttl = Number(body.expires_in);
|
|
4635
|
+
const seconds = Number.isFinite(ttl) && ttl > 0 ? Math.min(ttl, 7200) : 7200;
|
|
4636
|
+
token = body.access_token;
|
|
4637
|
+
expiresAtMs = Date.now() + Math.max(seconds - TOKEN_REFRESH_MARGIN_SEC, 1) * 1e3;
|
|
4638
|
+
return token;
|
|
4667
4639
|
};
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
flush();
|
|
4679
|
-
}
|
|
4680
|
-
continue;
|
|
4640
|
+
return {
|
|
4641
|
+
async get() {
|
|
4642
|
+
if (token !== "" && Date.now() < expiresAtMs) return token;
|
|
4643
|
+
inFlight ??= exchange().finally(() => {
|
|
4644
|
+
inFlight = null;
|
|
4645
|
+
});
|
|
4646
|
+
return inFlight;
|
|
4647
|
+
},
|
|
4648
|
+
invalidate() {
|
|
4649
|
+
expiresAtMs = 0;
|
|
4681
4650
|
}
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4651
|
+
};
|
|
4652
|
+
}
|
|
4653
|
+
function createProductionClient(creds) {
|
|
4654
|
+
const tokens = tokenCacheOf(creds);
|
|
4655
|
+
const post = async (path32, payload, retry = true) => {
|
|
4656
|
+
const token = await tokens.get();
|
|
4657
|
+
let res;
|
|
4658
|
+
try {
|
|
4659
|
+
res = await fetch(`${QQ_API_BASE}${path32}`, {
|
|
4660
|
+
method: "POST",
|
|
4661
|
+
headers: { "content-type": "application/json", authorization: `QQBot ${token}` },
|
|
4662
|
+
body: JSON.stringify(payload),
|
|
4663
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS)
|
|
4664
|
+
});
|
|
4665
|
+
} catch (err) {
|
|
4666
|
+
throw new Error(`Message send failed: ${fetchErrorText(err)}`);
|
|
4667
|
+
}
|
|
4668
|
+
if (res.status >= 200 && res.status < 300) return;
|
|
4669
|
+
const body = await res.json().catch(() => null);
|
|
4670
|
+
if (res.status === 401) {
|
|
4671
|
+
tokens.invalidate();
|
|
4672
|
+
if (retry) return post(path32, payload, false);
|
|
4673
|
+
}
|
|
4674
|
+
const errCode = body?.err_code ?? body?.code;
|
|
4675
|
+
const detail = qqSendErrorText(errCode, body?.message ?? `HTTP ${res.status}`);
|
|
4676
|
+
throw new QQApiError(
|
|
4677
|
+
`Message send failed: ${detail}${errCode !== void 0 ? ` (code ${errCode})` : ""}`,
|
|
4678
|
+
errCode
|
|
4679
|
+
);
|
|
4680
|
+
};
|
|
4681
|
+
return {
|
|
4682
|
+
tokens,
|
|
4683
|
+
async checkCredentials() {
|
|
4684
|
+
await tokens.get();
|
|
4685
|
+
},
|
|
4686
|
+
sendMessage(args) {
|
|
4687
|
+
const path32 = args.kind === "group" ? `/v2/groups/${encodeURIComponent(args.openid)}/messages` : `/v2/users/${encodeURIComponent(args.openid)}/messages`;
|
|
4688
|
+
const markdown = args.markdown;
|
|
4689
|
+
return post(path32, {
|
|
4690
|
+
// "传了 markdown 后此字段必须为空" — the platform rejects a payload carrying both.
|
|
4691
|
+
content: markdown === void 0 ? args.content : "",
|
|
4692
|
+
// 0 = plain text, 2 = markdown. Rich media (7) needs a publicly reachable URL for the
|
|
4693
|
+
// bytes, which is why this channel refuses outbound files (see qq-connector).
|
|
4694
|
+
msg_type: markdown === void 0 ? 0 : 2,
|
|
4695
|
+
...markdown !== void 0 ? { markdown: { content: markdown } } : {},
|
|
4696
|
+
msg_id: args.msgId,
|
|
4697
|
+
msg_seq: args.msgSeq
|
|
4698
|
+
});
|
|
4699
|
+
}
|
|
4700
|
+
};
|
|
4701
|
+
}
|
|
4702
|
+
function createQQTransport(opts = {}) {
|
|
4703
|
+
const retryMs = opts.gatewayRetryMs ?? defaultGatewayRetryMs;
|
|
4704
|
+
const createSocket = opts.createSocket ?? ((url) => new WebSocket(url));
|
|
4705
|
+
const handshakeMs = opts.handshakeTimeoutMs ?? QQ_HANDSHAKE_TIMEOUT_MS;
|
|
4706
|
+
return {
|
|
4707
|
+
createClient: (creds) => createProductionClient(creds),
|
|
4708
|
+
async openGateway(creds, handlers) {
|
|
4709
|
+
const session = new GatewaySession(createProductionClient(creds), handlers, {
|
|
4710
|
+
retryMs,
|
|
4711
|
+
createSocket,
|
|
4712
|
+
handshakeMs
|
|
4713
|
+
});
|
|
4714
|
+
session.start();
|
|
4715
|
+
return { close: () => session.close() };
|
|
4716
|
+
}
|
|
4717
|
+
};
|
|
4718
|
+
}
|
|
4719
|
+
var GatewaySession = class {
|
|
4720
|
+
constructor(client, handlers, opts) {
|
|
4721
|
+
this.client = client;
|
|
4722
|
+
this.handlers = handlers;
|
|
4723
|
+
this.opts = opts;
|
|
4724
|
+
}
|
|
4725
|
+
client;
|
|
4726
|
+
handlers;
|
|
4727
|
+
opts;
|
|
4728
|
+
ws = null;
|
|
4729
|
+
closed = false;
|
|
4730
|
+
failures = 0;
|
|
4731
|
+
/** The resume handle from READY; null until the first successful identify. */
|
|
4732
|
+
sessionId = null;
|
|
4733
|
+
/** Latest dispatch sequence (the heartbeat's `d`, and the resume's). */
|
|
4734
|
+
seq = null;
|
|
4735
|
+
/** Cached `GET /gateway` URL (see the class doc: 2 QPM). */
|
|
4736
|
+
gatewayUrl = null;
|
|
4737
|
+
heartbeat = null;
|
|
4738
|
+
retryTimer = null;
|
|
4739
|
+
/** Armed with the socket, disarmed by the handshake: see QQ_HANDSHAKE_TIMEOUT_MS. */
|
|
4740
|
+
handshakeTimer = null;
|
|
4741
|
+
/** When the last HEARTBEAT_ACK arrived, against which the heartbeat tick measures silence. */
|
|
4742
|
+
lastAckAt = 0;
|
|
4743
|
+
/**
|
|
4744
|
+
* What this outage has already reported; the retries stay quiet until a handshake succeeds.
|
|
4745
|
+
*
|
|
4746
|
+
* One report per outage is what keeps a backoff loop from filling the error table. A routine
|
|
4747
|
+
* close is now filed `expected`, though, so letting it claim the slot outright would silence
|
|
4748
|
+
* the outage behind it: a socket the platform expires at 4009 followed by an auth refusal on
|
|
4749
|
+
* every retry would leave the dashboard reading zero defects for a binding that is down until
|
|
4750
|
+
* a person acts. So a routine report is spent once more — by the first failure that is not
|
|
4751
|
+
* routine — and only then does the outage go quiet.
|
|
4752
|
+
*/
|
|
4753
|
+
reported = "none";
|
|
4754
|
+
start() {
|
|
4755
|
+
void this.connect();
|
|
4756
|
+
}
|
|
4757
|
+
close() {
|
|
4758
|
+
this.closed = true;
|
|
4759
|
+
this.stopHeartbeat();
|
|
4760
|
+
this.disarmHandshake();
|
|
4761
|
+
if (this.retryTimer !== null) clearTimeout(this.retryTimer);
|
|
4762
|
+
this.retryTimer = null;
|
|
4763
|
+
const ws = this.ws;
|
|
4764
|
+
this.ws = null;
|
|
4765
|
+
try {
|
|
4766
|
+
ws?.close();
|
|
4767
|
+
} catch {
|
|
4768
|
+
}
|
|
4769
|
+
}
|
|
4770
|
+
async connect() {
|
|
4771
|
+
if (this.closed) return;
|
|
4772
|
+
try {
|
|
4773
|
+
const token = await this.client.tokens.get();
|
|
4774
|
+
if (this.closed) return;
|
|
4775
|
+
this.gatewayUrl ??= await this.fetchGatewayUrl(token);
|
|
4776
|
+
if (this.closed) return;
|
|
4777
|
+
this.openSocket(this.gatewayUrl, token);
|
|
4778
|
+
} catch (err) {
|
|
4779
|
+
this.fail(err);
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
4782
|
+
async fetchGatewayUrl(token) {
|
|
4783
|
+
let res;
|
|
4784
|
+
try {
|
|
4785
|
+
res = await fetch(`${QQ_API_BASE}/gateway`, {
|
|
4786
|
+
headers: { authorization: `QQBot ${token}` },
|
|
4787
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS)
|
|
4788
|
+
});
|
|
4789
|
+
} catch (err) {
|
|
4790
|
+
throw new Error(`Gateway lookup failed: ${fetchErrorText(err)}`);
|
|
4791
|
+
}
|
|
4792
|
+
const body = await res.json().catch(() => null);
|
|
4793
|
+
if (body === null || typeof body.url !== "string" || body.url === "") {
|
|
4794
|
+
throw new Error(`Gateway lookup failed: ${body?.message ?? `HTTP ${res.status}`}`);
|
|
4795
|
+
}
|
|
4796
|
+
return body.url;
|
|
4797
|
+
}
|
|
4798
|
+
openSocket(url, token) {
|
|
4799
|
+
const ws = this.opts.createSocket(url);
|
|
4800
|
+
this.ws = ws;
|
|
4801
|
+
this.handshakeTimer = setTimeout(() => {
|
|
4802
|
+
this.handshakeTimer = null;
|
|
4803
|
+
this.dropSocket(ws, new Error("gateway handshake did not complete"));
|
|
4804
|
+
}, this.opts.handshakeMs);
|
|
4805
|
+
this.handshakeTimer.unref?.();
|
|
4806
|
+
ws.addEventListener("message", (evt) => {
|
|
4807
|
+
if (this.ws !== ws) return;
|
|
4808
|
+
this.onFrame(String(evt.data), token);
|
|
4809
|
+
});
|
|
4810
|
+
ws.addEventListener("error", () => {
|
|
4811
|
+
});
|
|
4812
|
+
ws.addEventListener("close", (evt) => {
|
|
4813
|
+
if (this.ws !== ws) return;
|
|
4814
|
+
this.stopHeartbeat();
|
|
4815
|
+
this.disarmHandshake();
|
|
4816
|
+
this.ws = null;
|
|
4817
|
+
if (closeCodeInvalidatesSession(evt.code)) {
|
|
4818
|
+
this.sessionId = null;
|
|
4819
|
+
this.seq = null;
|
|
4820
|
+
}
|
|
4821
|
+
if (closeCodeIsFatal(evt.code)) {
|
|
4822
|
+
this.closed = true;
|
|
4823
|
+
this.handlers.onError?.(
|
|
4824
|
+
new MessagingConnectionClosedError(
|
|
4825
|
+
`gateway refused this bot (close code ${evt.code}) \u2014 it is delisted or banned on the QQ open platform`,
|
|
4826
|
+
evt.code,
|
|
4827
|
+
false
|
|
4828
|
+
)
|
|
4829
|
+
);
|
|
4830
|
+
return;
|
|
4831
|
+
}
|
|
4832
|
+
this.fail(
|
|
4833
|
+
new MessagingConnectionClosedError(
|
|
4834
|
+
`gateway connection closed (code ${evt.code})`,
|
|
4835
|
+
evt.code,
|
|
4836
|
+
closeCodeIsRoutine(evt.code)
|
|
4837
|
+
)
|
|
4838
|
+
);
|
|
4839
|
+
});
|
|
4840
|
+
}
|
|
4841
|
+
onFrame(raw, token) {
|
|
4842
|
+
let frame;
|
|
4843
|
+
try {
|
|
4844
|
+
frame = JSON.parse(raw);
|
|
4845
|
+
} catch {
|
|
4846
|
+
return;
|
|
4847
|
+
}
|
|
4848
|
+
if (typeof frame.s === "number") this.seq = frame.s;
|
|
4849
|
+
switch (frame.op) {
|
|
4850
|
+
case OP.HELLO: {
|
|
4851
|
+
const interval = frame.d?.heartbeat_interval;
|
|
4852
|
+
this.startHeartbeat(typeof interval === "number" && interval > 0 ? interval : 45e3);
|
|
4853
|
+
this.send(
|
|
4854
|
+
this.sessionId !== null ? {
|
|
4855
|
+
op: OP.RESUME,
|
|
4856
|
+
d: { token: `QQBot ${token}`, session_id: this.sessionId, seq: this.seq ?? 0 }
|
|
4857
|
+
} : {
|
|
4858
|
+
op: OP.IDENTIFY,
|
|
4859
|
+
d: {
|
|
4860
|
+
token: `QQBot ${token}`,
|
|
4861
|
+
intents: QQ_INTENT_GROUP_AND_C2C,
|
|
4862
|
+
// Unsharded: this product runs one connection per bot.
|
|
4863
|
+
shard: [0, 1],
|
|
4864
|
+
// Documented as having no effect today; sent empty rather than inventing values.
|
|
4865
|
+
properties: {}
|
|
4866
|
+
}
|
|
4867
|
+
}
|
|
4868
|
+
);
|
|
4869
|
+
return;
|
|
4870
|
+
}
|
|
4871
|
+
case OP.DISPATCH:
|
|
4872
|
+
this.onDispatch(frame);
|
|
4873
|
+
return;
|
|
4874
|
+
case OP.INVALID_SESSION:
|
|
4875
|
+
this.sessionId = null;
|
|
4876
|
+
this.seq = null;
|
|
4877
|
+
this.ws?.close();
|
|
4878
|
+
return;
|
|
4879
|
+
case OP.RECONNECT:
|
|
4880
|
+
this.ws?.close();
|
|
4881
|
+
return;
|
|
4882
|
+
case OP.HEARTBEAT_ACK:
|
|
4883
|
+
this.lastAckAt = Date.now();
|
|
4884
|
+
return;
|
|
4885
|
+
default:
|
|
4886
|
+
return;
|
|
4887
|
+
}
|
|
4888
|
+
}
|
|
4889
|
+
onDispatch(frame) {
|
|
4890
|
+
if (frame.t === "READY") {
|
|
4891
|
+
const d = frame.d;
|
|
4892
|
+
if (typeof d?.session_id === "string") this.sessionId = d.session_id;
|
|
4893
|
+
this.onHandshake();
|
|
4894
|
+
return;
|
|
4895
|
+
}
|
|
4896
|
+
if (frame.t === "RESUMED") {
|
|
4897
|
+
this.onHandshake();
|
|
4898
|
+
return;
|
|
4899
|
+
}
|
|
4900
|
+
const evt = normalizeDispatch(frame);
|
|
4901
|
+
if (evt !== null) void this.handlers.onMessage(evt);
|
|
4902
|
+
}
|
|
4903
|
+
/** A handshake completed: the outage (if any) is over and the backoff resets. */
|
|
4904
|
+
onHandshake() {
|
|
4905
|
+
this.disarmHandshake();
|
|
4906
|
+
this.failures = 0;
|
|
4907
|
+
this.reported = "none";
|
|
4908
|
+
this.handlers.onReady?.();
|
|
4909
|
+
}
|
|
4910
|
+
disarmHandshake() {
|
|
4911
|
+
if (this.handshakeTimer !== null) clearTimeout(this.handshakeTimer);
|
|
4912
|
+
this.handshakeTimer = null;
|
|
4913
|
+
}
|
|
4914
|
+
/**
|
|
4915
|
+
* Abandons a socket the session has given up on and starts the backoff itself.
|
|
4916
|
+
*
|
|
4917
|
+
* The `close` handler is not enough on its own here: the failure both watchdogs cover is
|
|
4918
|
+
* a socket that never fires `close`, and asking a half-open one to close is a request the
|
|
4919
|
+
* far end may never answer. Detaching first makes the `close` that may eventually arrive
|
|
4920
|
+
* a no-op (every listener is gated on `this.ws === ws`).
|
|
4921
|
+
*/
|
|
4922
|
+
dropSocket(ws, err) {
|
|
4923
|
+
if (this.ws !== ws) return;
|
|
4924
|
+
this.stopHeartbeat();
|
|
4925
|
+
this.disarmHandshake();
|
|
4926
|
+
this.ws = null;
|
|
4927
|
+
try {
|
|
4928
|
+
ws.close();
|
|
4929
|
+
} catch {
|
|
4930
|
+
}
|
|
4931
|
+
this.fail(err);
|
|
4932
|
+
}
|
|
4933
|
+
startHeartbeat(intervalMs) {
|
|
4934
|
+
this.stopHeartbeat();
|
|
4935
|
+
const ws = this.ws;
|
|
4936
|
+
if (ws === null) return;
|
|
4937
|
+
this.lastAckAt = Date.now();
|
|
4938
|
+
this.heartbeat = setInterval(() => {
|
|
4939
|
+
if (Date.now() - this.lastAckAt > intervalMs * 2) {
|
|
4940
|
+
this.dropSocket(ws, new Error("gateway stopped acknowledging heartbeats"));
|
|
4941
|
+
return;
|
|
4942
|
+
}
|
|
4943
|
+
this.send({ op: OP.HEARTBEAT, d: this.seq });
|
|
4944
|
+
}, intervalMs);
|
|
4945
|
+
this.heartbeat.unref?.();
|
|
4946
|
+
}
|
|
4947
|
+
stopHeartbeat() {
|
|
4948
|
+
if (this.heartbeat !== null) clearInterval(this.heartbeat);
|
|
4949
|
+
this.heartbeat = null;
|
|
4950
|
+
}
|
|
4951
|
+
send(frame) {
|
|
4952
|
+
try {
|
|
4953
|
+
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));
|
|
4954
|
+
} catch {
|
|
4955
|
+
}
|
|
4956
|
+
}
|
|
4957
|
+
/**
|
|
4958
|
+
* One report per outage, then retry with backoff — the Telegram poll loop's contract, with
|
|
4959
|
+
* the one exception `reported` documents: a routine close does not get to be the whole story
|
|
4960
|
+
* of an outage that turns out to need a person.
|
|
4961
|
+
*/
|
|
4962
|
+
fail(err) {
|
|
4963
|
+
if (this.closed) return;
|
|
4964
|
+
this.failures += 1;
|
|
4965
|
+
const routine = err instanceof MessagingConnectionClosedError && err.recovers;
|
|
4966
|
+
if (this.reported === "none" || this.reported === "routine" && !routine) {
|
|
4967
|
+
this.reported = routine ? "routine" : "defect";
|
|
4968
|
+
this.handlers.onError?.(err);
|
|
4969
|
+
}
|
|
4970
|
+
this.retryTimer = setTimeout(() => {
|
|
4971
|
+
this.retryTimer = null;
|
|
4972
|
+
void this.connect();
|
|
4973
|
+
}, this.opts.retryMs(this.failures));
|
|
4974
|
+
this.retryTimer.unref?.();
|
|
4975
|
+
}
|
|
4976
|
+
};
|
|
4977
|
+
function normalizeDispatch(frame) {
|
|
4978
|
+
const d = frame.d;
|
|
4979
|
+
if (d === void 0 || typeof d.id !== "string" || d.id === "") return null;
|
|
4980
|
+
const content = typeof d.content === "string" ? d.content.trim() : "";
|
|
4981
|
+
if (frame.t === "C2C_MESSAGE_CREATE") {
|
|
4982
|
+
const openid = d.author?.user_openid;
|
|
4983
|
+
if (typeof openid !== "string" || openid === "") return null;
|
|
4984
|
+
return { kind: "c2c", openid, messageId: d.id, content, senderOpenid: openid };
|
|
4985
|
+
}
|
|
4986
|
+
if (frame.t === "GROUP_AT_MESSAGE_CREATE") {
|
|
4987
|
+
const openid = d.group_openid;
|
|
4988
|
+
if (typeof openid !== "string" || openid === "") return null;
|
|
4989
|
+
const sender = d.author?.member_openid;
|
|
4990
|
+
return {
|
|
4991
|
+
kind: "group",
|
|
4992
|
+
openid,
|
|
4993
|
+
messageId: d.id,
|
|
4994
|
+
content,
|
|
4995
|
+
...typeof sender === "string" && sender !== "" ? { senderOpenid: sender } : {}
|
|
4996
|
+
};
|
|
4997
|
+
}
|
|
4998
|
+
return null;
|
|
4999
|
+
}
|
|
5000
|
+
|
|
5001
|
+
// src/runtime/messaging/error-kind.ts
|
|
5002
|
+
var CODES_EXPLAINED_IN_CHAT = /* @__PURE__ */ new Set([
|
|
5003
|
+
"messaging_image_fetch_failed",
|
|
5004
|
+
"messaging_file_fetch_failed",
|
|
5005
|
+
"messaging_file_send_failed"
|
|
5006
|
+
]);
|
|
5007
|
+
function messagingErrorKind(err, code) {
|
|
5008
|
+
if (err instanceof MessagingConnectionClosedError) {
|
|
5009
|
+
return err.recovers ? "expected" : "unexpected";
|
|
5010
|
+
}
|
|
5011
|
+
if (!CODES_EXPLAINED_IN_CHAT.has(code)) return "unexpected";
|
|
5012
|
+
if (err instanceof MessagingPermissionError || err instanceof MessagingMediaTooLargeError || err instanceof MessagingUnsupportedError) {
|
|
5013
|
+
return "expected";
|
|
5014
|
+
}
|
|
5015
|
+
return "unexpected";
|
|
5016
|
+
}
|
|
5017
|
+
|
|
5018
|
+
// src/runtime/messaging/markdown.ts
|
|
5019
|
+
import remarkGfm from "remark-gfm";
|
|
5020
|
+
import remarkParse from "remark-parse";
|
|
5021
|
+
import { unified } from "unified";
|
|
5022
|
+
var processor = unified().use(remarkParse).use(remarkGfm);
|
|
5023
|
+
function parseMarkdown(text) {
|
|
5024
|
+
return processor.parse(text);
|
|
5025
|
+
}
|
|
5026
|
+
var SAFE_LINK_SCHEMES = /* @__PURE__ */ new Set([
|
|
5027
|
+
"http:",
|
|
5028
|
+
"https:",
|
|
5029
|
+
"mailto:",
|
|
5030
|
+
"tel:",
|
|
5031
|
+
"tg:"
|
|
5032
|
+
]);
|
|
5033
|
+
function isSafeUrl(url) {
|
|
5034
|
+
if (url === "" || url.startsWith("#")) return false;
|
|
5035
|
+
try {
|
|
5036
|
+
return SAFE_LINK_SCHEMES.has(new URL(url, "https://example.invalid/").protocol);
|
|
5037
|
+
} catch {
|
|
5038
|
+
return false;
|
|
5039
|
+
}
|
|
5040
|
+
}
|
|
5041
|
+
function spanOf(node) {
|
|
5042
|
+
const { position } = node;
|
|
5043
|
+
if (position?.start.offset === void 0 || position.end.offset === void 0) return null;
|
|
5044
|
+
return { from: position.start.offset, to: position.end.offset };
|
|
5045
|
+
}
|
|
5046
|
+
var BLOCK_PARENTS = /* @__PURE__ */ new Set([
|
|
5047
|
+
"root",
|
|
5048
|
+
"blockquote",
|
|
5049
|
+
"list",
|
|
5050
|
+
"listItem",
|
|
5051
|
+
"table",
|
|
5052
|
+
"footnoteDefinition"
|
|
5053
|
+
]);
|
|
5054
|
+
function lineStart(text, offset) {
|
|
5055
|
+
return text.lastIndexOf("\n", offset - 1) + 1;
|
|
5056
|
+
}
|
|
5057
|
+
function longestBacktickRun(value) {
|
|
5058
|
+
let best = 0;
|
|
5059
|
+
let run2 = 0;
|
|
5060
|
+
for (const ch of value) {
|
|
5061
|
+
run2 = ch === "`" ? run2 + 1 : 0;
|
|
5062
|
+
if (run2 > best) best = run2;
|
|
5063
|
+
}
|
|
5064
|
+
return best;
|
|
5065
|
+
}
|
|
5066
|
+
function splitHard(text, max) {
|
|
5067
|
+
const out = [];
|
|
5068
|
+
let rest = text;
|
|
5069
|
+
while (rest.length > max) {
|
|
5070
|
+
const window = rest.slice(0, max);
|
|
5071
|
+
const space = window.search(/\s\S*$/);
|
|
5072
|
+
const cut = space > max / 2 ? space : max;
|
|
5073
|
+
out.push(rest.slice(0, cut));
|
|
5074
|
+
rest = rest.slice(cut).replace(/^\s+/, "");
|
|
5075
|
+
}
|
|
5076
|
+
if (rest !== "") out.push(rest);
|
|
5077
|
+
return out;
|
|
5078
|
+
}
|
|
5079
|
+
function splitLines(src, max) {
|
|
5080
|
+
const out = [];
|
|
5081
|
+
let buf = "";
|
|
5082
|
+
for (const line of src.split("\n")) {
|
|
5083
|
+
if (buf !== "" && buf.length + 1 + line.length > max) {
|
|
5084
|
+
out.push(buf);
|
|
5085
|
+
buf = "";
|
|
5086
|
+
}
|
|
5087
|
+
if (line.length > max) {
|
|
5088
|
+
if (buf !== "") {
|
|
5089
|
+
out.push(buf);
|
|
5090
|
+
buf = "";
|
|
5091
|
+
}
|
|
5092
|
+
for (const piece of splitHard(line, max)) out.push(piece);
|
|
5093
|
+
continue;
|
|
5094
|
+
}
|
|
5095
|
+
buf = buf === "" ? line : `${buf}
|
|
5096
|
+
${line}`;
|
|
5097
|
+
}
|
|
5098
|
+
if (buf !== "") out.push(buf);
|
|
5099
|
+
return out;
|
|
5100
|
+
}
|
|
5101
|
+
function splitFencedCode(value, lang, max) {
|
|
5102
|
+
const language = typeof lang === "string" ? lang : "";
|
|
5103
|
+
const fence = "`".repeat(Math.max(3, longestBacktickRun(value) + 1));
|
|
5104
|
+
const open2 = `${fence}${language}`;
|
|
5105
|
+
const room = max - open2.length - fence.length - 2;
|
|
5106
|
+
if (room <= 0) return splitHard(value, max);
|
|
5107
|
+
return splitLines(value, room).map((piece) => `${open2}
|
|
5108
|
+
${piece}
|
|
5109
|
+
${fence}`);
|
|
5110
|
+
}
|
|
5111
|
+
function splitBlock(text, node, max) {
|
|
5112
|
+
const span = spanOf(node);
|
|
5113
|
+
if (span === null) return [];
|
|
5114
|
+
const src = text.slice(span.from, span.to);
|
|
5115
|
+
if (src.length <= max) return [src];
|
|
5116
|
+
if (node.type === "code") return splitFencedCode(node.value, node.lang, max);
|
|
5117
|
+
const children = "children" in node ? node.children : [];
|
|
5118
|
+
const snap = BLOCK_PARENTS.has(node.type);
|
|
5119
|
+
const cuts = [];
|
|
5120
|
+
for (const child of children.slice(1)) {
|
|
5121
|
+
const childSpan = spanOf(child);
|
|
5122
|
+
if (childSpan === null) continue;
|
|
5123
|
+
const at = snap ? lineStart(text, childSpan.from) : childSpan.from;
|
|
5124
|
+
if (at > span.from && at < span.to && at !== cuts.at(-1)) cuts.push(at);
|
|
5125
|
+
}
|
|
5126
|
+
if (cuts.length === 0) return splitLines(src, max);
|
|
5127
|
+
const bounds = [span.from, ...cuts, span.to];
|
|
5128
|
+
const out = [];
|
|
5129
|
+
let buf = "";
|
|
5130
|
+
for (let i = 0; i + 1 < bounds.length; i += 1) {
|
|
5131
|
+
const piece = text.slice(bounds[i], bounds[i + 1]);
|
|
5132
|
+
if (buf !== "" && buf.length + piece.length > max) {
|
|
5133
|
+
out.push(buf);
|
|
5134
|
+
buf = "";
|
|
5135
|
+
}
|
|
5136
|
+
if (piece.length > max) {
|
|
5137
|
+
if (buf !== "") {
|
|
5138
|
+
out.push(buf);
|
|
5139
|
+
buf = "";
|
|
5140
|
+
}
|
|
5141
|
+
for (const part of splitLines(piece, max)) out.push(part);
|
|
5142
|
+
continue;
|
|
5143
|
+
}
|
|
5144
|
+
buf += piece;
|
|
5145
|
+
}
|
|
5146
|
+
if (buf !== "") out.push(buf);
|
|
5147
|
+
return out;
|
|
5148
|
+
}
|
|
5149
|
+
function chunkMarkdown(text, max) {
|
|
5150
|
+
const trimmed = text.trim();
|
|
5151
|
+
if (trimmed === "") return [];
|
|
5152
|
+
if (trimmed.length <= max) return [trimmed];
|
|
5153
|
+
const root = parseMarkdown(text);
|
|
5154
|
+
const out = [];
|
|
5155
|
+
let buf = "";
|
|
5156
|
+
const flush = () => {
|
|
5157
|
+
const piece = buf.trim();
|
|
5158
|
+
if (piece !== "") out.push(piece);
|
|
5159
|
+
buf = "";
|
|
5160
|
+
};
|
|
5161
|
+
const blocks = root.children;
|
|
5162
|
+
for (const [i, block5] of blocks.entries()) {
|
|
5163
|
+
const span = spanOf(block5);
|
|
5164
|
+
if (span === null) continue;
|
|
5165
|
+
const to = spanOf(blocks[i + 1] ?? block5)?.from ?? span.to;
|
|
5166
|
+
const src = text.slice(span.from, Math.max(to, span.to));
|
|
5167
|
+
if (src.trimEnd().length > max) {
|
|
5168
|
+
flush();
|
|
5169
|
+
for (const piece of splitBlock(text, block5, max)) {
|
|
5170
|
+
buf = piece;
|
|
5171
|
+
flush();
|
|
5172
|
+
}
|
|
5173
|
+
continue;
|
|
5174
|
+
}
|
|
5175
|
+
if (buf !== "" && (buf + src).trimEnd().length > max) flush();
|
|
5176
|
+
buf += src;
|
|
5177
|
+
}
|
|
5178
|
+
flush();
|
|
5179
|
+
return out;
|
|
4687
5180
|
}
|
|
4688
5181
|
|
|
4689
5182
|
// src/runtime/messaging/reply-files.ts
|
|
@@ -6595,7 +7088,7 @@ function telegramHtmlOf(markdown) {
|
|
|
6595
7088
|
import { FormData, fetch as undiciFetch2 } from "undici";
|
|
6596
7089
|
var TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
6597
7090
|
var TELEGRAM_MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
|
|
6598
|
-
var
|
|
7091
|
+
var CALL_TIMEOUT_MS2 = 15e3;
|
|
6599
7092
|
var TRANSFER_TIMEOUT_MS = 6e4;
|
|
6600
7093
|
var TelegramApiError = class extends Error {
|
|
6601
7094
|
constructor(message, errorCode) {
|
|
@@ -6614,7 +7107,7 @@ function conflictText(description) {
|
|
|
6614
7107
|
}
|
|
6615
7108
|
return null;
|
|
6616
7109
|
}
|
|
6617
|
-
function
|
|
7110
|
+
function fetchErrorText2(err) {
|
|
6618
7111
|
if (err instanceof Error) {
|
|
6619
7112
|
if (err.name === "TimeoutError" || err.name === "AbortError") return "request timed out";
|
|
6620
7113
|
const cause = err.cause;
|
|
@@ -6641,7 +7134,7 @@ function createTelegramTransport(opts = {}) {
|
|
|
6641
7134
|
return {
|
|
6642
7135
|
createClient(creds) {
|
|
6643
7136
|
const send = async (method, body, headers, opts2) => {
|
|
6644
|
-
const deadline = AbortSignal.timeout(opts2?.timeoutMs ??
|
|
7137
|
+
const deadline = AbortSignal.timeout(opts2?.timeoutMs ?? CALL_TIMEOUT_MS2);
|
|
6645
7138
|
const signal = opts2?.signal ? AbortSignal.any([opts2.signal, deadline]) : deadline;
|
|
6646
7139
|
let res;
|
|
6647
7140
|
try {
|
|
@@ -6652,7 +7145,7 @@ function createTelegramTransport(opts = {}) {
|
|
|
6652
7145
|
signal
|
|
6653
7146
|
});
|
|
6654
7147
|
} catch (err) {
|
|
6655
|
-
throw new Error(`${method} failed: ${
|
|
7148
|
+
throw new Error(`${method} failed: ${fetchErrorText2(err)}`);
|
|
6656
7149
|
}
|
|
6657
7150
|
const parsed = await res.json().catch(() => null);
|
|
6658
7151
|
if (parsed === null || parsed.ok !== true) {
|
|
@@ -6720,7 +7213,7 @@ function createTelegramTransport(opts = {}) {
|
|
|
6720
7213
|
}
|
|
6721
7214
|
);
|
|
6722
7215
|
} catch (err) {
|
|
6723
|
-
throw new Error(`file download failed: ${
|
|
7216
|
+
throw new Error(`file download failed: ${fetchErrorText2(err)}`);
|
|
6724
7217
|
}
|
|
6725
7218
|
if (!res.ok || res.body === null) {
|
|
6726
7219
|
throw new Error(`file download failed: HTTP ${res.status}`);
|
|
@@ -7035,885 +7528,878 @@ var TelegramConnector = class {
|
|
|
7035
7528
|
}
|
|
7036
7529
|
};
|
|
7037
7530
|
|
|
7038
|
-
// src/runtime/messaging/qq-
|
|
7039
|
-
|
|
7040
|
-
var
|
|
7041
|
-
var
|
|
7042
|
-
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
}
|
|
7054
|
-
function
|
|
7055
|
-
return
|
|
7531
|
+
// src/runtime/messaging/qq-markdown.ts
|
|
7532
|
+
var LIST_INDENT2 = " ";
|
|
7533
|
+
var RULE2 = "***";
|
|
7534
|
+
var INLINE_SPECIALS = /[\\`*_~[\]<]/g;
|
|
7535
|
+
function esc3(text) {
|
|
7536
|
+
return text.replace(INLINE_SPECIALS, (ch) => `\\${ch}`);
|
|
7537
|
+
}
|
|
7538
|
+
function guardLineStarts2(text) {
|
|
7539
|
+
return text.split("\n").map((line) => line.replace(/^(\s*)([#>\-+=])/, "$1\\$2").replace(/^(\s*\d+)([.)])/, "$1\\$2")).join("\n");
|
|
7540
|
+
}
|
|
7541
|
+
function escLines(value) {
|
|
7542
|
+
return guardLineStarts2(esc3(value));
|
|
7543
|
+
}
|
|
7544
|
+
function href2(url) {
|
|
7545
|
+
return url.replaceAll("(", "%28").replaceAll(")", "%29");
|
|
7546
|
+
}
|
|
7547
|
+
function inline3(nodes) {
|
|
7548
|
+
return nodes.map(phrasing3).join("");
|
|
7549
|
+
}
|
|
7550
|
+
function phrasing3(node) {
|
|
7551
|
+
switch (node.type) {
|
|
7552
|
+
case "text":
|
|
7553
|
+
return esc3(node.value);
|
|
7554
|
+
case "strong":
|
|
7555
|
+
return `**${inline3(node.children)}**`;
|
|
7556
|
+
case "emphasis":
|
|
7557
|
+
return `*${inline3(node.children)}*`;
|
|
7558
|
+
case "delete":
|
|
7559
|
+
return `~~${inline3(node.children)}~~`;
|
|
7560
|
+
case "inlineCode":
|
|
7561
|
+
return esc3(node.value);
|
|
7562
|
+
case "link": {
|
|
7563
|
+
const label = inline3(node.children);
|
|
7564
|
+
return isSafeUrl(node.url) ? `[${label}](${href2(node.url)})` : label;
|
|
7565
|
+
}
|
|
7566
|
+
case "image": {
|
|
7567
|
+
const alt = node.alt ?? "";
|
|
7568
|
+
const label = esc3(alt !== "" ? alt : node.url);
|
|
7569
|
+
return isSafeUrl(node.url) ? `[${label}](${href2(node.url)})` : label;
|
|
7570
|
+
}
|
|
7571
|
+
case "break":
|
|
7572
|
+
return "\n";
|
|
7573
|
+
case "html":
|
|
7574
|
+
return esc3(node.value);
|
|
7575
|
+
case "footnoteReference":
|
|
7576
|
+
return esc3(`[^${node.identifier}]`);
|
|
7577
|
+
case "linkReference":
|
|
7578
|
+
case "imageReference":
|
|
7579
|
+
return "children" in node ? inline3(node.children) : esc3(node.label ?? node.identifier);
|
|
7580
|
+
default:
|
|
7581
|
+
return "";
|
|
7582
|
+
}
|
|
7583
|
+
}
|
|
7584
|
+
function plainText3(nodes) {
|
|
7585
|
+
return nodes.map((node) => {
|
|
7586
|
+
if (node.type === "text" || node.type === "inlineCode" || node.type === "html") {
|
|
7587
|
+
return node.value;
|
|
7588
|
+
}
|
|
7589
|
+
if (node.type === "break") return " ";
|
|
7590
|
+
if (node.type === "image") return node.alt ?? "";
|
|
7591
|
+
return "children" in node ? plainText3(node.children) : "";
|
|
7592
|
+
}).join("");
|
|
7593
|
+
}
|
|
7594
|
+
function renderTable2(node) {
|
|
7595
|
+
return escLines(
|
|
7596
|
+
node.children.map((row) => `| ${row.children.map((cell) => plainText3(cell.children)).join(" | ")} |`).join("\n")
|
|
7597
|
+
);
|
|
7598
|
+
}
|
|
7599
|
+
function itemMarker3(list, index) {
|
|
7600
|
+
return list.ordered === true ? `${(list.start ?? 1) + index}. ` : "- ";
|
|
7601
|
+
}
|
|
7602
|
+
function itemCheckbox3(item) {
|
|
7603
|
+
if (item.checked === true) return "\u2611 ";
|
|
7604
|
+
if (item.checked === false) return "\u2610 ";
|
|
7605
|
+
return "";
|
|
7606
|
+
}
|
|
7607
|
+
function renderList3(list, indent) {
|
|
7608
|
+
return list.children.map((item, i) => {
|
|
7609
|
+
const head = `${indent}${itemMarker3(list, i)}${itemCheckbox3(item)}`;
|
|
7610
|
+
const parts = [];
|
|
7611
|
+
for (const [j, child] of item.children.entries()) {
|
|
7612
|
+
if (j === 0 && child.type === "paragraph") {
|
|
7613
|
+
parts.push(head + inline3(child.children));
|
|
7614
|
+
continue;
|
|
7615
|
+
}
|
|
7616
|
+
if (child.type === "list") {
|
|
7617
|
+
parts.push(renderList3(child, indent + LIST_INDENT2));
|
|
7618
|
+
continue;
|
|
7619
|
+
}
|
|
7620
|
+
const rendered = block3(child, indent + LIST_INDENT2);
|
|
7621
|
+
if (rendered !== "") {
|
|
7622
|
+
parts.push(
|
|
7623
|
+
rendered.split("\n").map((line) => indent + LIST_INDENT2 + line).join("\n")
|
|
7624
|
+
);
|
|
7625
|
+
}
|
|
7626
|
+
}
|
|
7627
|
+
return parts.filter((part) => part !== "").join("\n");
|
|
7628
|
+
}).filter((line) => line !== "").join("\n");
|
|
7629
|
+
}
|
|
7630
|
+
function block3(node, indent) {
|
|
7631
|
+
switch (node.type) {
|
|
7632
|
+
case "paragraph":
|
|
7633
|
+
return guardLineStarts2(inline3(node.children));
|
|
7634
|
+
case "heading":
|
|
7635
|
+
return `${"#".repeat(Math.min(node.depth, 6))} ${inline3(node.children)}`;
|
|
7636
|
+
case "code":
|
|
7637
|
+
return escLines(node.value);
|
|
7638
|
+
case "blockquote":
|
|
7639
|
+
return node.children.map((child) => block3(child, indent)).filter((rendered) => rendered !== "").join("\n\n").split("\n").map((line) => `> ${line}`).join("\n");
|
|
7640
|
+
case "list":
|
|
7641
|
+
return renderList3(node, indent);
|
|
7642
|
+
case "table":
|
|
7643
|
+
return renderTable2(node);
|
|
7644
|
+
case "thematicBreak":
|
|
7645
|
+
return RULE2;
|
|
7646
|
+
case "html":
|
|
7647
|
+
return escLines(node.value);
|
|
7648
|
+
case "footnoteDefinition":
|
|
7649
|
+
return `**${esc3(`[^${node.identifier}]`)}** ${node.children.map((child) => block3(child, indent)).join("\n\n")}`;
|
|
7650
|
+
case "definition":
|
|
7651
|
+
return "";
|
|
7652
|
+
default:
|
|
7653
|
+
return "";
|
|
7654
|
+
}
|
|
7056
7655
|
}
|
|
7057
|
-
function
|
|
7058
|
-
|
|
7656
|
+
function qqMarkdownOf(markdown) {
|
|
7657
|
+
const root = parseMarkdown(markdown);
|
|
7658
|
+
return root.children.map((node) => block3(node, "")).filter((rendered) => rendered !== "").join("\n\n").trim();
|
|
7059
7659
|
}
|
|
7060
|
-
|
|
7061
|
-
|
|
7062
|
-
|
|
7660
|
+
|
|
7661
|
+
// src/runtime/messaging/qq-connector.ts
|
|
7662
|
+
var QQ_REPLY_BUDGET = { c2c: 4, group: 5 };
|
|
7663
|
+
var QQ_MIN_REPLY_BUDGET = Math.min(...Object.values(QQ_REPLY_BUDGET));
|
|
7664
|
+
var QQ_PASSIVE_WINDOW_MS = 5 * 6e4;
|
|
7665
|
+
var QQ_TAIL_FLUSH_MS = 1500;
|
|
7666
|
+
var QQ_TAIL_MAX_CHARS = 2e4;
|
|
7667
|
+
var QQ_TAIL_ELIDED = "\u2026(earlier part omitted \u524D\u7565)\u2026";
|
|
7668
|
+
function refuseMedia(file) {
|
|
7669
|
+
return Promise.reject(
|
|
7670
|
+
new MessagingUnsupportedError(
|
|
7671
|
+
`QQ cannot receive "${file.fileName}": sending a file to QQ requires a publicly reachable URL for it, which this server has no way to provide`
|
|
7672
|
+
)
|
|
7673
|
+
);
|
|
7063
7674
|
}
|
|
7064
|
-
function
|
|
7065
|
-
|
|
7066
|
-
|
|
7067
|
-
|
|
7068
|
-
if (cause instanceof Error && cause.message !== "") return cause.message;
|
|
7069
|
-
return err.message;
|
|
7675
|
+
function qqConfigOf(config) {
|
|
7676
|
+
const { appId, appSecret } = config;
|
|
7677
|
+
if (typeof appId !== "string" || appId === "" || typeof appSecret !== "string" || appSecret === "") {
|
|
7678
|
+
throw new Error("malformed qq binding config (appId/appSecret)");
|
|
7070
7679
|
}
|
|
7071
|
-
return
|
|
7680
|
+
return { appId, appSecret };
|
|
7072
7681
|
}
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
40034005,
|
|
7076
|
-
304103,
|
|
7077
|
-
40034024,
|
|
7078
|
-
40034105,
|
|
7079
|
-
40054013
|
|
7080
|
-
]);
|
|
7081
|
-
function qqSendErrorText(errCode, message) {
|
|
7082
|
-
if (errCode === 40034128) {
|
|
7083
|
-
return `${message} \u2014 QQ allows only a few replies to one message, within minutes of it`;
|
|
7084
|
-
}
|
|
7085
|
-
if (errCode === 40034005 || errCode === 304103 || errCode === 40034024) {
|
|
7086
|
-
return `${message} \u2014 the QQ message being replied to has expired`;
|
|
7087
|
-
}
|
|
7088
|
-
if (errCode === 40034105 || errCode === 40054013) {
|
|
7089
|
-
return `${message} \u2014 this QQ user has turned off messages from bots`;
|
|
7090
|
-
}
|
|
7091
|
-
return message;
|
|
7682
|
+
function qqChatIdOf(kind, openid) {
|
|
7683
|
+
return `${kind}:${openid}`;
|
|
7092
7684
|
}
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7685
|
+
function parseQQChatId(chatId) {
|
|
7686
|
+
const i = chatId.indexOf(":");
|
|
7687
|
+
const kind = i > 0 ? chatId.slice(0, i) : "";
|
|
7688
|
+
const openid = i > 0 ? chatId.slice(i + 1) : "";
|
|
7689
|
+
if (kind !== "c2c" && kind !== "group" || openid === "") {
|
|
7690
|
+
throw new Error(`malformed qq chat id "${chatId}"`);
|
|
7098
7691
|
}
|
|
7099
|
-
|
|
7100
|
-
};
|
|
7101
|
-
function tokenCacheOf(creds) {
|
|
7102
|
-
let token = "";
|
|
7103
|
-
let expiresAtMs = 0;
|
|
7104
|
-
let inFlight = null;
|
|
7105
|
-
const exchange = async () => {
|
|
7106
|
-
let res;
|
|
7107
|
-
try {
|
|
7108
|
-
res = await fetch(`${QQ_API_BASE}/app/getAppAccessToken`, {
|
|
7109
|
-
method: "POST",
|
|
7110
|
-
headers: { "content-type": "application/json" },
|
|
7111
|
-
body: JSON.stringify({ appId: creds.appId, clientSecret: creds.appSecret }),
|
|
7112
|
-
signal: AbortSignal.timeout(CALL_TIMEOUT_MS2)
|
|
7113
|
-
});
|
|
7114
|
-
} catch (err) {
|
|
7115
|
-
throw new Error(`Token exchange failed: ${fetchErrorText2(err)}`);
|
|
7116
|
-
}
|
|
7117
|
-
const body = await res.json().catch(() => null);
|
|
7118
|
-
if (body === null || typeof body.access_token !== "string" || body.access_token === "") {
|
|
7119
|
-
const detail = body?.message ?? `HTTP ${res.status}`;
|
|
7120
|
-
const code = body?.code ?? body?.err_code;
|
|
7121
|
-
throw new Error(
|
|
7122
|
-
`Token exchange failed: ${detail}${code !== void 0 ? ` (code ${code})` : ""}`
|
|
7123
|
-
);
|
|
7124
|
-
}
|
|
7125
|
-
const ttl = Number(body.expires_in);
|
|
7126
|
-
const seconds = Number.isFinite(ttl) && ttl > 0 ? Math.min(ttl, 7200) : 7200;
|
|
7127
|
-
token = body.access_token;
|
|
7128
|
-
expiresAtMs = Date.now() + Math.max(seconds - TOKEN_REFRESH_MARGIN_SEC, 1) * 1e3;
|
|
7129
|
-
return token;
|
|
7130
|
-
};
|
|
7131
|
-
return {
|
|
7132
|
-
async get() {
|
|
7133
|
-
if (token !== "" && Date.now() < expiresAtMs) return token;
|
|
7134
|
-
inFlight ??= exchange().finally(() => {
|
|
7135
|
-
inFlight = null;
|
|
7136
|
-
});
|
|
7137
|
-
return inFlight;
|
|
7138
|
-
},
|
|
7139
|
-
invalidate() {
|
|
7140
|
-
expiresAtMs = 0;
|
|
7141
|
-
}
|
|
7142
|
-
};
|
|
7692
|
+
return { kind, openid };
|
|
7143
7693
|
}
|
|
7144
|
-
function
|
|
7145
|
-
|
|
7146
|
-
const post = async (path32, payload, retry = true) => {
|
|
7147
|
-
const token = await tokens.get();
|
|
7148
|
-
let res;
|
|
7149
|
-
try {
|
|
7150
|
-
res = await fetch(`${QQ_API_BASE}${path32}`, {
|
|
7151
|
-
method: "POST",
|
|
7152
|
-
headers: { "content-type": "application/json", authorization: `QQBot ${token}` },
|
|
7153
|
-
body: JSON.stringify(payload),
|
|
7154
|
-
signal: AbortSignal.timeout(CALL_TIMEOUT_MS2)
|
|
7155
|
-
});
|
|
7156
|
-
} catch (err) {
|
|
7157
|
-
throw new Error(`Message send failed: ${fetchErrorText2(err)}`);
|
|
7158
|
-
}
|
|
7159
|
-
if (res.status >= 200 && res.status < 300) return;
|
|
7160
|
-
const body = await res.json().catch(() => null);
|
|
7161
|
-
if (res.status === 401) {
|
|
7162
|
-
tokens.invalidate();
|
|
7163
|
-
if (retry) return post(path32, payload, false);
|
|
7164
|
-
}
|
|
7165
|
-
const errCode = body?.err_code ?? body?.code;
|
|
7166
|
-
const detail = qqSendErrorText(errCode, body?.message ?? `HTTP ${res.status}`);
|
|
7167
|
-
throw new QQApiError(
|
|
7168
|
-
`Message send failed: ${detail}${errCode !== void 0 ? ` (code ${errCode})` : ""}`,
|
|
7169
|
-
errCode
|
|
7170
|
-
);
|
|
7171
|
-
};
|
|
7172
|
-
return {
|
|
7173
|
-
tokens,
|
|
7174
|
-
async checkCredentials() {
|
|
7175
|
-
await tokens.get();
|
|
7176
|
-
},
|
|
7177
|
-
sendMessage(args) {
|
|
7178
|
-
const path32 = args.kind === "group" ? `/v2/groups/${encodeURIComponent(args.openid)}/messages` : `/v2/users/${encodeURIComponent(args.openid)}/messages`;
|
|
7179
|
-
const markdown = args.markdown;
|
|
7180
|
-
return post(path32, {
|
|
7181
|
-
// "传了 markdown 后此字段必须为空" — the platform rejects a payload carrying both.
|
|
7182
|
-
content: markdown === void 0 ? args.content : "",
|
|
7183
|
-
// 0 = plain text, 2 = markdown. Rich media (7) needs a publicly reachable URL for the
|
|
7184
|
-
// bytes, which is why this channel refuses outbound files (see qq-connector).
|
|
7185
|
-
msg_type: markdown === void 0 ? 0 : 2,
|
|
7186
|
-
...markdown !== void 0 ? { markdown: { content: markdown } } : {},
|
|
7187
|
-
msg_id: args.msgId,
|
|
7188
|
-
msg_seq: args.msgSeq
|
|
7189
|
-
});
|
|
7190
|
-
}
|
|
7191
|
-
};
|
|
7694
|
+
function qqReplyRefOf(kind, openid, messageId) {
|
|
7695
|
+
return `${qqChatIdOf(kind, openid)}:${messageId}`;
|
|
7192
7696
|
}
|
|
7193
|
-
function
|
|
7194
|
-
const
|
|
7195
|
-
|
|
7196
|
-
|
|
7197
|
-
return {
|
|
7198
|
-
createClient: (creds) => createProductionClient(creds),
|
|
7199
|
-
async openGateway(creds, handlers) {
|
|
7200
|
-
const session = new GatewaySession(createProductionClient(creds), handlers, {
|
|
7201
|
-
retryMs,
|
|
7202
|
-
createSocket,
|
|
7203
|
-
handshakeMs
|
|
7204
|
-
});
|
|
7205
|
-
session.start();
|
|
7206
|
-
return { close: () => session.close() };
|
|
7207
|
-
}
|
|
7208
|
-
};
|
|
7697
|
+
function chatOfReplyRef(ref) {
|
|
7698
|
+
const cut = ref.lastIndexOf(":");
|
|
7699
|
+
if (cut <= 0) throw new Error(`malformed qq reply ref "${ref}"`);
|
|
7700
|
+
return parseQQChatId(ref.slice(0, cut));
|
|
7209
7701
|
}
|
|
7210
|
-
var
|
|
7211
|
-
constructor(
|
|
7212
|
-
this.
|
|
7213
|
-
this.
|
|
7214
|
-
this.
|
|
7702
|
+
var QQConnector = class {
|
|
7703
|
+
constructor(transport, opts = {}) {
|
|
7704
|
+
this.transport = transport;
|
|
7705
|
+
this.tailFlushMs = opts.tailFlushMs ?? QQ_TAIL_FLUSH_MS;
|
|
7706
|
+
this.now = opts.now ?? (() => Date.now());
|
|
7707
|
+
}
|
|
7708
|
+
transport;
|
|
7709
|
+
channel = "qq";
|
|
7710
|
+
/**
|
|
7711
|
+
* The budget channel-neutral callers clamp to — the bridge uses it to cap the
|
|
7712
|
+
* one-message-per-line split, whose own ceiling of 20 is meaningless against 4.
|
|
7713
|
+
*/
|
|
7714
|
+
replyBudget = QQ_MIN_REPLY_BUDGET;
|
|
7715
|
+
/**
|
|
7716
|
+
* Passive-reply state per `(appId, chat)`. It lives on the connector rather than on a
|
|
7717
|
+
* client, because the two halves that need it arrive separately: inbound events come
|
|
7718
|
+
* through `connect`, and sends through the client `createClient` hands the bridge. The
|
|
7719
|
+
* account is part of the key so two bindings on different bots can never spend each
|
|
7720
|
+
* other's budget.
|
|
7721
|
+
*/
|
|
7722
|
+
ledgers = /* @__PURE__ */ new Map();
|
|
7723
|
+
tailFlushMs;
|
|
7724
|
+
now;
|
|
7725
|
+
async createClient(config) {
|
|
7726
|
+
const creds = qqConfigOf(config);
|
|
7727
|
+
const bot = this.transport.createClient(creds);
|
|
7728
|
+
const deliver = (chatId, text, opts) => {
|
|
7729
|
+
const { kind, openid } = parseQQChatId(chatId);
|
|
7730
|
+
return this.enqueue(creds, bot, kind, openid, text, opts?.markdown === true);
|
|
7731
|
+
};
|
|
7732
|
+
const client = {
|
|
7733
|
+
async checkCredentials() {
|
|
7734
|
+
await bot.checkCredentials();
|
|
7735
|
+
return null;
|
|
7736
|
+
},
|
|
7737
|
+
sendText: (chatId, text, opts) => deliver(chatId, text, opts),
|
|
7738
|
+
// Same operation as sendText: `msg_id` is an authorization anchor on QQ, not a
|
|
7739
|
+
// visible quote, so there is no threading to honour (see the module doc).
|
|
7740
|
+
replyText: (ref, text, opts) => {
|
|
7741
|
+
const { kind, openid } = chatOfReplyRef(ref);
|
|
7742
|
+
return this.enqueue(creds, bot, kind, openid, text, opts?.markdown === true);
|
|
7743
|
+
},
|
|
7744
|
+
sendImage: (_chatId, file) => refuseMedia(file),
|
|
7745
|
+
sendFile: (_chatId, file) => refuseMedia(file)
|
|
7746
|
+
};
|
|
7747
|
+
return client;
|
|
7748
|
+
}
|
|
7749
|
+
async connect(config, handlers) {
|
|
7750
|
+
const creds = qqConfigOf(config);
|
|
7751
|
+
const connection = await this.transport.openGateway(creds, {
|
|
7752
|
+
onMessage: async (evt) => {
|
|
7753
|
+
this.noteInbound(creds, evt);
|
|
7754
|
+
await handlers.onMessage({
|
|
7755
|
+
chatId: qqChatIdOf(evt.kind, evt.openid),
|
|
7756
|
+
chatKind: evt.kind === "c2c" ? "direct" : "group",
|
|
7757
|
+
messageId: qqReplyRefOf(evt.kind, evt.openid, evt.messageId),
|
|
7758
|
+
// Only text is read today; an empty content is every non-text message type
|
|
7759
|
+
// (image, voice, card, forwarded record), which the bridge answers with the
|
|
7760
|
+
// text-only notice. Inbound media is a follow-up, not a gap.
|
|
7761
|
+
text: evt.content !== "" ? evt.content : null,
|
|
7762
|
+
...evt.senderOpenid !== void 0 ? { senderName: evt.senderOpenid } : {}
|
|
7763
|
+
});
|
|
7764
|
+
},
|
|
7765
|
+
...handlers.onReady ? { onReady: handlers.onReady } : {},
|
|
7766
|
+
...handlers.onError ? { onError: handlers.onError } : {}
|
|
7767
|
+
});
|
|
7768
|
+
return {
|
|
7769
|
+
close: () => {
|
|
7770
|
+
connection.close();
|
|
7771
|
+
this.dropLedgers(creds.appId);
|
|
7772
|
+
}
|
|
7773
|
+
};
|
|
7215
7774
|
}
|
|
7216
|
-
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
ws = null;
|
|
7220
|
-
closed = false;
|
|
7221
|
-
failures = 0;
|
|
7222
|
-
/** The resume handle from READY; null until the first successful identify. */
|
|
7223
|
-
sessionId = null;
|
|
7224
|
-
/** Latest dispatch sequence (the heartbeat's `d`, and the resume's). */
|
|
7225
|
-
seq = null;
|
|
7226
|
-
/** Cached `GET /gateway` URL (see the class doc: 2 QPM). */
|
|
7227
|
-
gatewayUrl = null;
|
|
7228
|
-
heartbeat = null;
|
|
7229
|
-
retryTimer = null;
|
|
7230
|
-
/** Armed with the socket, disarmed by the handshake: see QQ_HANDSHAKE_TIMEOUT_MS. */
|
|
7231
|
-
handshakeTimer = null;
|
|
7232
|
-
/** When the last HEARTBEAT_ACK arrived, against which the heartbeat tick measures silence. */
|
|
7233
|
-
lastAckAt = 0;
|
|
7234
|
-
/** This outage already reported; the retries stay quiet until a handshake succeeds. */
|
|
7235
|
-
reported = false;
|
|
7236
|
-
start() {
|
|
7237
|
-
void this.connect();
|
|
7775
|
+
// —— The passive-reply budget ————————————————————————————————————————————
|
|
7776
|
+
ledgerKey(appId, kind, openid) {
|
|
7777
|
+
return `${appId}:${kind}:${openid}`;
|
|
7238
7778
|
}
|
|
7239
|
-
|
|
7240
|
-
this.
|
|
7241
|
-
this.
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7246
|
-
|
|
7247
|
-
|
|
7248
|
-
|
|
7249
|
-
|
|
7779
|
+
ledgerFor(appId, kind, openid) {
|
|
7780
|
+
const key = this.ledgerKey(appId, kind, openid);
|
|
7781
|
+
let ledger = this.ledgers.get(key);
|
|
7782
|
+
if (ledger === void 0) {
|
|
7783
|
+
this.evictExpired();
|
|
7784
|
+
ledger = {
|
|
7785
|
+
appId,
|
|
7786
|
+
kind,
|
|
7787
|
+
openid,
|
|
7788
|
+
msgId: null,
|
|
7789
|
+
receivedAt: 0,
|
|
7790
|
+
spent: 0,
|
|
7791
|
+
tail: [],
|
|
7792
|
+
markdown: false,
|
|
7793
|
+
bot: null,
|
|
7794
|
+
timer: null,
|
|
7795
|
+
chain: Promise.resolve()
|
|
7796
|
+
};
|
|
7797
|
+
this.ledgers.set(key, ledger);
|
|
7250
7798
|
}
|
|
7799
|
+
return ledger;
|
|
7251
7800
|
}
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
if (this.closed) return;
|
|
7257
|
-
this.gatewayUrl ??= await this.fetchGatewayUrl(token);
|
|
7258
|
-
if (this.closed) return;
|
|
7259
|
-
this.openSocket(this.gatewayUrl, token);
|
|
7260
|
-
} catch (err) {
|
|
7261
|
-
this.fail(err);
|
|
7262
|
-
}
|
|
7801
|
+
/** Clears a ledger's pending flush and forgets it. */
|
|
7802
|
+
dropLedger(key, ledger) {
|
|
7803
|
+
if (ledger.timer !== null) clearTimeout(ledger.timer);
|
|
7804
|
+
this.ledgers.delete(key);
|
|
7263
7805
|
}
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
headers: { authorization: `QQBot ${token}` },
|
|
7269
|
-
signal: AbortSignal.timeout(CALL_TIMEOUT_MS2)
|
|
7270
|
-
});
|
|
7271
|
-
} catch (err) {
|
|
7272
|
-
throw new Error(`Gateway lookup failed: ${fetchErrorText2(err)}`);
|
|
7273
|
-
}
|
|
7274
|
-
const body = await res.json().catch(() => null);
|
|
7275
|
-
if (body === null || typeof body.url !== "string" || body.url === "") {
|
|
7276
|
-
throw new Error(`Gateway lookup failed: ${body?.message ?? `HTTP ${res.status}`}`);
|
|
7806
|
+
/** Forgets every ledger of one credential set — the connection's close (see connect). */
|
|
7807
|
+
dropLedgers(appId) {
|
|
7808
|
+
for (const [key, ledger] of this.ledgers) {
|
|
7809
|
+
if (ledger.appId === appId) this.dropLedger(key, ledger);
|
|
7277
7810
|
}
|
|
7278
|
-
return body.url;
|
|
7279
|
-
}
|
|
7280
|
-
openSocket(url, token) {
|
|
7281
|
-
const ws = this.opts.createSocket(url);
|
|
7282
|
-
this.ws = ws;
|
|
7283
|
-
this.handshakeTimer = setTimeout(() => {
|
|
7284
|
-
this.handshakeTimer = null;
|
|
7285
|
-
this.dropSocket(ws, new Error("gateway handshake did not complete"));
|
|
7286
|
-
}, this.opts.handshakeMs);
|
|
7287
|
-
this.handshakeTimer.unref?.();
|
|
7288
|
-
ws.addEventListener("message", (evt) => {
|
|
7289
|
-
if (this.ws !== ws) return;
|
|
7290
|
-
this.onFrame(String(evt.data), token);
|
|
7291
|
-
});
|
|
7292
|
-
ws.addEventListener("error", () => {
|
|
7293
|
-
});
|
|
7294
|
-
ws.addEventListener("close", (evt) => {
|
|
7295
|
-
if (this.ws !== ws) return;
|
|
7296
|
-
this.stopHeartbeat();
|
|
7297
|
-
this.disarmHandshake();
|
|
7298
|
-
this.ws = null;
|
|
7299
|
-
if (closeCodeInvalidatesSession(evt.code)) {
|
|
7300
|
-
this.sessionId = null;
|
|
7301
|
-
this.seq = null;
|
|
7302
|
-
}
|
|
7303
|
-
if (closeCodeIsFatal(evt.code)) {
|
|
7304
|
-
this.closed = true;
|
|
7305
|
-
this.handlers.onError?.(
|
|
7306
|
-
new Error(
|
|
7307
|
-
`gateway refused this bot (close code ${evt.code}) \u2014 it is delisted or banned on the QQ open platform`
|
|
7308
|
-
)
|
|
7309
|
-
);
|
|
7310
|
-
return;
|
|
7311
|
-
}
|
|
7312
|
-
this.fail(new Error(`gateway connection closed (code ${evt.code})`));
|
|
7313
|
-
});
|
|
7314
7811
|
}
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
7812
|
+
/**
|
|
7813
|
+
* Forgets every ledger whose anchor message is past the reply window. An unanchored one
|
|
7814
|
+
* is left alone: it holds no budget to lose, and the client that just created it is
|
|
7815
|
+
* holding the object.
|
|
7816
|
+
*/
|
|
7817
|
+
evictExpired() {
|
|
7818
|
+
for (const [key, ledger] of this.ledgers) {
|
|
7819
|
+
if (ledger.msgId === null) continue;
|
|
7820
|
+
if (this.now() - ledger.receivedAt <= QQ_PASSIVE_WINDOW_MS) continue;
|
|
7821
|
+
this.dropLedger(key, ledger);
|
|
7321
7822
|
}
|
|
7322
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
|
|
7328
|
-
|
|
7329
|
-
|
|
7330
|
-
|
|
7331
|
-
|
|
7332
|
-
|
|
7333
|
-
|
|
7334
|
-
|
|
7335
|
-
|
|
7336
|
-
|
|
7337
|
-
|
|
7338
|
-
|
|
7339
|
-
properties: {}
|
|
7340
|
-
}
|
|
7341
|
-
}
|
|
7342
|
-
);
|
|
7343
|
-
return;
|
|
7344
|
-
}
|
|
7345
|
-
case OP.DISPATCH:
|
|
7346
|
-
this.onDispatch(frame);
|
|
7347
|
-
return;
|
|
7348
|
-
case OP.INVALID_SESSION:
|
|
7349
|
-
this.sessionId = null;
|
|
7350
|
-
this.seq = null;
|
|
7351
|
-
this.ws?.close();
|
|
7352
|
-
return;
|
|
7353
|
-
case OP.RECONNECT:
|
|
7354
|
-
this.ws?.close();
|
|
7355
|
-
return;
|
|
7356
|
-
case OP.HEARTBEAT_ACK:
|
|
7357
|
-
this.lastAckAt = Date.now();
|
|
7358
|
-
return;
|
|
7359
|
-
default:
|
|
7360
|
-
return;
|
|
7823
|
+
}
|
|
7824
|
+
/**
|
|
7825
|
+
* A new inbound message: fresh budget, and the moment a withheld tail can finally go out.
|
|
7826
|
+
*
|
|
7827
|
+
* A tail is carried onto the new budget only while the message it was written for is
|
|
7828
|
+
* still inside its own window. Past that it is dropped — the one place anything is — and
|
|
7829
|
+
* deliberately: text withheld longer than the platform's whole reply window is no longer
|
|
7830
|
+
* an answer to anything the user still has in view, and delivering it as the first thing
|
|
7831
|
+
* they see after saying "hi" would read as the bot malfunctioning. The loss is bounded by
|
|
7832
|
+
* the same window everything else on this channel is.
|
|
7833
|
+
*/
|
|
7834
|
+
noteInbound(creds, evt) {
|
|
7835
|
+
const ledger = this.ledgerFor(creds.appId, evt.kind, evt.openid);
|
|
7836
|
+
if (ledger.msgId === evt.messageId) return;
|
|
7837
|
+
if (ledger.timer !== null) {
|
|
7838
|
+
clearTimeout(ledger.timer);
|
|
7839
|
+
ledger.timer = null;
|
|
7361
7840
|
}
|
|
7841
|
+
const carry = ledger.tail.length > 0 && this.repliable(ledger);
|
|
7842
|
+
if (!carry) ledger.tail = [];
|
|
7843
|
+
ledger.msgId = evt.messageId;
|
|
7844
|
+
ledger.receivedAt = this.now();
|
|
7845
|
+
ledger.spent = 0;
|
|
7846
|
+
if (carry) this.scheduleFlush(ledger, 0);
|
|
7362
7847
|
}
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7848
|
+
/** True while `ledger.msgId` can still be replied to at all. */
|
|
7849
|
+
repliable(ledger) {
|
|
7850
|
+
return ledger.msgId !== null && this.now() - ledger.receivedAt <= QQ_PASSIVE_WINDOW_MS;
|
|
7851
|
+
}
|
|
7852
|
+
/**
|
|
7853
|
+
* One outbound body. Sends immediately while an ordinary slot is free, otherwise
|
|
7854
|
+
* withholds it for the reserved final slot; throws when the chat has no repliable
|
|
7855
|
+
* message at all, which is the one thing QQ gives no way around (see the module doc).
|
|
7856
|
+
*/
|
|
7857
|
+
async enqueue(creds, bot, kind, openid, text, markdown) {
|
|
7858
|
+
const ledger = this.ledgerFor(creds.appId, kind, openid);
|
|
7859
|
+
ledger.bot = bot;
|
|
7860
|
+
ledger.markdown = markdown;
|
|
7861
|
+
if (!this.repliable(ledger)) {
|
|
7862
|
+
throw new Error(
|
|
7863
|
+
"QQ only accepts replies to a message sent from QQ, within a few minutes of it \u2014 send the bot a message in QQ and it will answer"
|
|
7864
|
+
);
|
|
7369
7865
|
}
|
|
7370
|
-
|
|
7371
|
-
|
|
7866
|
+
const budget = QQ_REPLY_BUDGET[kind];
|
|
7867
|
+
if (ledger.spent < budget - 1) {
|
|
7868
|
+
await this.chain(ledger, () => this.sendNow(ledger, text, markdown));
|
|
7372
7869
|
return;
|
|
7373
7870
|
}
|
|
7374
|
-
|
|
7375
|
-
if (
|
|
7871
|
+
this.withhold(ledger, text);
|
|
7872
|
+
if (ledger.spent < budget) this.scheduleFlush(ledger, this.tailFlushMs);
|
|
7376
7873
|
}
|
|
7377
|
-
/**
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
|
|
7382
|
-
|
|
7874
|
+
/** Appends to the withheld tail, dropping from the FRONT if the ceiling is reached. */
|
|
7875
|
+
withhold(ledger, text) {
|
|
7876
|
+
ledger.tail.push(text);
|
|
7877
|
+
let total = ledger.tail.reduce((n, part) => n + part.length + 2, 0);
|
|
7878
|
+
while (total > QQ_TAIL_MAX_CHARS && ledger.tail.length > 1) {
|
|
7879
|
+
const dropped = ledger.tail.shift();
|
|
7880
|
+
total -= (dropped?.length ?? 0) + 2;
|
|
7881
|
+
if (ledger.tail[0] !== QQ_TAIL_ELIDED) ledger.tail.unshift(QQ_TAIL_ELIDED);
|
|
7882
|
+
}
|
|
7383
7883
|
}
|
|
7384
|
-
|
|
7385
|
-
if (
|
|
7386
|
-
|
|
7884
|
+
scheduleFlush(ledger, delayMs) {
|
|
7885
|
+
if (ledger.timer !== null) clearTimeout(ledger.timer);
|
|
7886
|
+
ledger.timer = setTimeout(() => {
|
|
7887
|
+
ledger.timer = null;
|
|
7888
|
+
void this.chain(ledger, () => this.flush(ledger)).catch(() => {
|
|
7889
|
+
});
|
|
7890
|
+
}, delayMs);
|
|
7891
|
+
ledger.timer.unref?.();
|
|
7387
7892
|
}
|
|
7388
7893
|
/**
|
|
7389
|
-
*
|
|
7390
|
-
*
|
|
7391
|
-
*
|
|
7392
|
-
*
|
|
7393
|
-
* far end may never answer. Detaching first makes the `close` that may eventually arrive
|
|
7394
|
-
* a no-op (every listener is gated on `this.ws === ws`).
|
|
7894
|
+
* Spends the reserved final slot on everything withheld, as one message. A combined tail
|
|
7895
|
+
* over the channel's size cap is chunked as usual and only its FIRST chunk is sent — the
|
|
7896
|
+
* rest returns to the tail, to ride the next inbound message's budget rather than a
|
|
7897
|
+
* rejection.
|
|
7395
7898
|
*/
|
|
7396
|
-
|
|
7397
|
-
if (
|
|
7398
|
-
this.
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7899
|
+
async flush(ledger) {
|
|
7900
|
+
if (ledger.tail.length === 0) return;
|
|
7901
|
+
if (!this.repliable(ledger) || ledger.spent >= QQ_REPLY_BUDGET[ledger.kind]) return;
|
|
7902
|
+
const combined = ledger.tail.join("\n\n");
|
|
7903
|
+
const chunks = ledger.markdown ? chunkMarkdown(combined, MESSAGING_TEXT_CHUNK_CHARS) : chunkMessagingText(combined);
|
|
7904
|
+
const head = chunks[0];
|
|
7905
|
+
if (head === void 0) {
|
|
7906
|
+
ledger.tail = [];
|
|
7907
|
+
return;
|
|
7404
7908
|
}
|
|
7405
|
-
|
|
7406
|
-
}
|
|
7407
|
-
startHeartbeat(intervalMs) {
|
|
7408
|
-
this.stopHeartbeat();
|
|
7409
|
-
const ws = this.ws;
|
|
7410
|
-
if (ws === null) return;
|
|
7411
|
-
this.lastAckAt = Date.now();
|
|
7412
|
-
this.heartbeat = setInterval(() => {
|
|
7413
|
-
if (Date.now() - this.lastAckAt > intervalMs * 2) {
|
|
7414
|
-
this.dropSocket(ws, new Error("gateway stopped acknowledging heartbeats"));
|
|
7415
|
-
return;
|
|
7416
|
-
}
|
|
7417
|
-
this.send({ op: OP.HEARTBEAT, d: this.seq });
|
|
7418
|
-
}, intervalMs);
|
|
7419
|
-
this.heartbeat.unref?.();
|
|
7420
|
-
}
|
|
7421
|
-
stopHeartbeat() {
|
|
7422
|
-
if (this.heartbeat !== null) clearInterval(this.heartbeat);
|
|
7423
|
-
this.heartbeat = null;
|
|
7424
|
-
}
|
|
7425
|
-
send(frame) {
|
|
7909
|
+
ledger.tail = chunks.slice(1);
|
|
7426
7910
|
try {
|
|
7427
|
-
|
|
7428
|
-
} catch {
|
|
7429
|
-
|
|
7430
|
-
|
|
7431
|
-
/** One report per outage, then retry with backoff — the Telegram poll loop's contract. */
|
|
7432
|
-
fail(err) {
|
|
7433
|
-
if (this.closed) return;
|
|
7434
|
-
this.failures += 1;
|
|
7435
|
-
if (!this.reported) {
|
|
7436
|
-
this.reported = true;
|
|
7437
|
-
this.handlers.onError?.(err);
|
|
7911
|
+
await this.sendNow(ledger, head, ledger.markdown);
|
|
7912
|
+
} catch (err) {
|
|
7913
|
+
ledger.tail.unshift(head);
|
|
7914
|
+
throw err;
|
|
7438
7915
|
}
|
|
7439
|
-
this.retryTimer = setTimeout(() => {
|
|
7440
|
-
this.retryTimer = null;
|
|
7441
|
-
void this.connect();
|
|
7442
|
-
}, this.opts.retryMs(this.failures));
|
|
7443
|
-
this.retryTimer.unref?.();
|
|
7444
|
-
}
|
|
7445
|
-
};
|
|
7446
|
-
function normalizeDispatch(frame) {
|
|
7447
|
-
const d = frame.d;
|
|
7448
|
-
if (d === void 0 || typeof d.id !== "string" || d.id === "") return null;
|
|
7449
|
-
const content = typeof d.content === "string" ? d.content.trim() : "";
|
|
7450
|
-
if (frame.t === "C2C_MESSAGE_CREATE") {
|
|
7451
|
-
const openid = d.author?.user_openid;
|
|
7452
|
-
if (typeof openid !== "string" || openid === "") return null;
|
|
7453
|
-
return { kind: "c2c", openid, messageId: d.id, content, senderOpenid: openid };
|
|
7454
7916
|
}
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7461
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
7917
|
+
/**
|
|
7918
|
+
* One real send: the next `msg_seq` against the ledger's anchor message, as Markdown when
|
|
7919
|
+
* the binding asked for it and as plain text if the platform refuses that.
|
|
7920
|
+
*
|
|
7921
|
+
* The fallback costs a SLOT here, which it does on no other channel: a repeated
|
|
7922
|
+
* `(msg_id, msg_seq)` pair is refused rather than deduplicated, so the retry must carry a
|
|
7923
|
+
* fresh sequence number and that number is one of the four an inbound message funds. It is
|
|
7924
|
+
* still the right trade — a reply the reader never sees is worse than a reply that spent
|
|
7925
|
+
* an extra slot — but it is only taken while a slot remains, and never for a refusal that
|
|
7926
|
+
* was not about the body in the first place (see QQ_BODY_INDEPENDENT_SEND_CODES): those
|
|
7927
|
+
* would fail identically as text and burn the slot reserved for the withheld tail.
|
|
7928
|
+
*/
|
|
7929
|
+
async sendNow(ledger, text, markdown) {
|
|
7930
|
+
const msgId = ledger.msgId;
|
|
7931
|
+
const bot = ledger.bot;
|
|
7932
|
+
if (msgId === null || bot === null) return;
|
|
7933
|
+
const send = (body) => {
|
|
7934
|
+
ledger.spent += 1;
|
|
7935
|
+
return bot.sendMessage({
|
|
7936
|
+
kind: ledger.kind,
|
|
7937
|
+
openid: ledger.openid,
|
|
7938
|
+
content: text,
|
|
7939
|
+
...body,
|
|
7940
|
+
msgId,
|
|
7941
|
+
msgSeq: ledger.spent
|
|
7942
|
+
});
|
|
7465
7943
|
};
|
|
7466
|
-
|
|
7467
|
-
|
|
7468
|
-
}
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
function esc3(text) {
|
|
7475
|
-
return text.replace(INLINE_SPECIALS, (ch) => `\\${ch}`);
|
|
7476
|
-
}
|
|
7477
|
-
function guardLineStarts2(text) {
|
|
7478
|
-
return text.split("\n").map((line) => line.replace(/^(\s*)([#>\-+=])/, "$1\\$2").replace(/^(\s*\d+)([.)])/, "$1\\$2")).join("\n");
|
|
7479
|
-
}
|
|
7480
|
-
function escLines(value) {
|
|
7481
|
-
return guardLineStarts2(esc3(value));
|
|
7482
|
-
}
|
|
7483
|
-
function href2(url) {
|
|
7484
|
-
return url.replaceAll("(", "%28").replaceAll(")", "%29");
|
|
7485
|
-
}
|
|
7486
|
-
function inline3(nodes) {
|
|
7487
|
-
return nodes.map(phrasing3).join("");
|
|
7488
|
-
}
|
|
7489
|
-
function phrasing3(node) {
|
|
7490
|
-
switch (node.type) {
|
|
7491
|
-
case "text":
|
|
7492
|
-
return esc3(node.value);
|
|
7493
|
-
case "strong":
|
|
7494
|
-
return `**${inline3(node.children)}**`;
|
|
7495
|
-
case "emphasis":
|
|
7496
|
-
return `*${inline3(node.children)}*`;
|
|
7497
|
-
case "delete":
|
|
7498
|
-
return `~~${inline3(node.children)}~~`;
|
|
7499
|
-
case "inlineCode":
|
|
7500
|
-
return esc3(node.value);
|
|
7501
|
-
case "link": {
|
|
7502
|
-
const label = inline3(node.children);
|
|
7503
|
-
return isSafeUrl(node.url) ? `[${label}](${href2(node.url)})` : label;
|
|
7504
|
-
}
|
|
7505
|
-
case "image": {
|
|
7506
|
-
const alt = node.alt ?? "";
|
|
7507
|
-
const label = esc3(alt !== "" ? alt : node.url);
|
|
7508
|
-
return isSafeUrl(node.url) ? `[${label}](${href2(node.url)})` : label;
|
|
7944
|
+
if (!markdown) return send({});
|
|
7945
|
+
try {
|
|
7946
|
+
await send({ markdown: qqMarkdownOf(text) });
|
|
7947
|
+
return;
|
|
7948
|
+
} catch (err) {
|
|
7949
|
+
if (!(err instanceof QQApiError)) throw err;
|
|
7950
|
+
if (err.code !== void 0 && QQ_BODY_INDEPENDENT_SEND_CODES.has(err.code)) throw err;
|
|
7951
|
+
if (ledger.spent >= QQ_REPLY_BUDGET[ledger.kind]) throw err;
|
|
7509
7952
|
}
|
|
7510
|
-
|
|
7511
|
-
return "\n";
|
|
7512
|
-
case "html":
|
|
7513
|
-
return esc3(node.value);
|
|
7514
|
-
case "footnoteReference":
|
|
7515
|
-
return esc3(`[^${node.identifier}]`);
|
|
7516
|
-
case "linkReference":
|
|
7517
|
-
case "imageReference":
|
|
7518
|
-
return "children" in node ? inline3(node.children) : esc3(node.label ?? node.identifier);
|
|
7519
|
-
default:
|
|
7520
|
-
return "";
|
|
7953
|
+
await send({});
|
|
7521
7954
|
}
|
|
7522
|
-
|
|
7523
|
-
|
|
7524
|
-
|
|
7525
|
-
|
|
7526
|
-
|
|
7527
|
-
|
|
7528
|
-
|
|
7529
|
-
|
|
7530
|
-
|
|
7531
|
-
|
|
7532
|
-
}
|
|
7533
|
-
function renderTable2(node) {
|
|
7534
|
-
return escLines(
|
|
7535
|
-
node.children.map((row) => `| ${row.children.map((cell) => plainText3(cell.children)).join(" | ")} |`).join("\n")
|
|
7536
|
-
);
|
|
7537
|
-
}
|
|
7538
|
-
function itemMarker3(list, index) {
|
|
7539
|
-
return list.ordered === true ? `${(list.start ?? 1) + index}. ` : "- ";
|
|
7540
|
-
}
|
|
7541
|
-
function itemCheckbox3(item) {
|
|
7542
|
-
if (item.checked === true) return "\u2611 ";
|
|
7543
|
-
if (item.checked === false) return "\u2610 ";
|
|
7544
|
-
return "";
|
|
7545
|
-
}
|
|
7546
|
-
function renderList3(list, indent) {
|
|
7547
|
-
return list.children.map((item, i) => {
|
|
7548
|
-
const head = `${indent}${itemMarker3(list, i)}${itemCheckbox3(item)}`;
|
|
7549
|
-
const parts = [];
|
|
7550
|
-
for (const [j, child] of item.children.entries()) {
|
|
7551
|
-
if (j === 0 && child.type === "paragraph") {
|
|
7552
|
-
parts.push(head + inline3(child.children));
|
|
7553
|
-
continue;
|
|
7554
|
-
}
|
|
7555
|
-
if (child.type === "list") {
|
|
7556
|
-
parts.push(renderList3(child, indent + LIST_INDENT2));
|
|
7557
|
-
continue;
|
|
7558
|
-
}
|
|
7559
|
-
const rendered = block3(child, indent + LIST_INDENT2);
|
|
7560
|
-
if (rendered !== "") {
|
|
7561
|
-
parts.push(
|
|
7562
|
-
rendered.split("\n").map((line) => indent + LIST_INDENT2 + line).join("\n")
|
|
7563
|
-
);
|
|
7564
|
-
}
|
|
7565
|
-
}
|
|
7566
|
-
return parts.filter((part) => part !== "").join("\n");
|
|
7567
|
-
}).filter((line) => line !== "").join("\n");
|
|
7568
|
-
}
|
|
7569
|
-
function block3(node, indent) {
|
|
7570
|
-
switch (node.type) {
|
|
7571
|
-
case "paragraph":
|
|
7572
|
-
return guardLineStarts2(inline3(node.children));
|
|
7573
|
-
case "heading":
|
|
7574
|
-
return `${"#".repeat(Math.min(node.depth, 6))} ${inline3(node.children)}`;
|
|
7575
|
-
case "code":
|
|
7576
|
-
return escLines(node.value);
|
|
7577
|
-
case "blockquote":
|
|
7578
|
-
return node.children.map((child) => block3(child, indent)).filter((rendered) => rendered !== "").join("\n\n").split("\n").map((line) => `> ${line}`).join("\n");
|
|
7579
|
-
case "list":
|
|
7580
|
-
return renderList3(node, indent);
|
|
7581
|
-
case "table":
|
|
7582
|
-
return renderTable2(node);
|
|
7583
|
-
case "thematicBreak":
|
|
7584
|
-
return RULE2;
|
|
7585
|
-
case "html":
|
|
7586
|
-
return escLines(node.value);
|
|
7587
|
-
case "footnoteDefinition":
|
|
7588
|
-
return `**${esc3(`[^${node.identifier}]`)}** ${node.children.map((child) => block3(child, indent)).join("\n\n")}`;
|
|
7589
|
-
case "definition":
|
|
7590
|
-
return "";
|
|
7591
|
-
default:
|
|
7592
|
-
return "";
|
|
7955
|
+
/**
|
|
7956
|
+
* Appends to the chat's send chain. Two sends against one `msg_id` must reach the
|
|
7957
|
+
* platform in `msg_seq` order, and the chain never rejects — the caller's promise carries
|
|
7958
|
+
* the failure, and one failed send must not poison the chat's later ones.
|
|
7959
|
+
*/
|
|
7960
|
+
chain(ledger, run2) {
|
|
7961
|
+
const result = ledger.chain.then(run2);
|
|
7962
|
+
ledger.chain = result.catch(() => {
|
|
7963
|
+
});
|
|
7964
|
+
return result;
|
|
7593
7965
|
}
|
|
7594
|
-
}
|
|
7595
|
-
function qqMarkdownOf(markdown) {
|
|
7596
|
-
const root = parseMarkdown(markdown);
|
|
7597
|
-
return root.children.map((node) => block3(node, "")).filter((rendered) => rendered !== "").join("\n\n").trim();
|
|
7598
|
-
}
|
|
7966
|
+
};
|
|
7599
7967
|
|
|
7600
|
-
// src/runtime/messaging/qq-
|
|
7601
|
-
|
|
7602
|
-
var
|
|
7603
|
-
var
|
|
7604
|
-
var
|
|
7605
|
-
var
|
|
7606
|
-
var
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
|
|
7610
|
-
|
|
7611
|
-
)
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
|
|
7615
|
-
const { appId, appSecret } = config;
|
|
7616
|
-
if (typeof appId !== "string" || appId === "" || typeof appSecret !== "string" || appSecret === "") {
|
|
7617
|
-
throw new Error("malformed qq binding config (appId/appSecret)");
|
|
7968
|
+
// src/runtime/messaging/qq-scan.ts
|
|
7969
|
+
import { createDecipheriv, randomBytes as randomBytes2 } from "crypto";
|
|
7970
|
+
var QQ_SCAN_BASE = "https://q.qq.com";
|
|
7971
|
+
var QQ_SCAN_QR_PAGE = "https://q.qq.com/qqbot/openclaw/connect.html";
|
|
7972
|
+
var CALL_TIMEOUT_MS3 = 15e3;
|
|
7973
|
+
var QQ_SCAN_POLL_MS = 2e3;
|
|
7974
|
+
var QQ_SCAN_KEY_BYTES = 32;
|
|
7975
|
+
var GCM_IV_BYTES = 12;
|
|
7976
|
+
var GCM_TAG_BYTES = 16;
|
|
7977
|
+
function fetchErrorText3(err) {
|
|
7978
|
+
if (err instanceof Error) {
|
|
7979
|
+
if (err.name === "TimeoutError" || err.name === "AbortError") return "request timed out";
|
|
7980
|
+
const cause = err.cause;
|
|
7981
|
+
if (cause instanceof Error && cause.message !== "") return cause.message;
|
|
7982
|
+
return err.message;
|
|
7618
7983
|
}
|
|
7619
|
-
return
|
|
7984
|
+
return String(err);
|
|
7620
7985
|
}
|
|
7621
|
-
function
|
|
7622
|
-
|
|
7986
|
+
function statusOf(raw) {
|
|
7987
|
+
switch (Number(raw)) {
|
|
7988
|
+
case 0:
|
|
7989
|
+
return "none";
|
|
7990
|
+
case 2:
|
|
7991
|
+
return "completed";
|
|
7992
|
+
case 3:
|
|
7993
|
+
return "expired";
|
|
7994
|
+
default:
|
|
7995
|
+
return "pending";
|
|
7996
|
+
}
|
|
7623
7997
|
}
|
|
7624
|
-
function
|
|
7625
|
-
const
|
|
7626
|
-
const
|
|
7627
|
-
const
|
|
7628
|
-
|
|
7629
|
-
|
|
7998
|
+
function botsOf(data) {
|
|
7999
|
+
const entries = data === void 0 ? [] : Array.isArray(data) ? data : [data];
|
|
8000
|
+
const bots = [];
|
|
8001
|
+
for (const entry of entries) {
|
|
8002
|
+
const appId = entry.bot_appid;
|
|
8003
|
+
const secret = entry.bot_encrypt_secret;
|
|
8004
|
+
if (appId === void 0 || appId === "" || typeof secret !== "string" || secret === "") {
|
|
8005
|
+
continue;
|
|
8006
|
+
}
|
|
8007
|
+
bots.push({
|
|
8008
|
+
appId: String(appId),
|
|
8009
|
+
encryptedSecret: secret,
|
|
8010
|
+
...typeof entry.user_openid === "string" ? { userOpenid: entry.user_openid } : {}
|
|
8011
|
+
});
|
|
7630
8012
|
}
|
|
7631
|
-
return
|
|
8013
|
+
return bots;
|
|
7632
8014
|
}
|
|
7633
|
-
function
|
|
7634
|
-
|
|
8015
|
+
function createQQScanTransport(base = QQ_SCAN_BASE) {
|
|
8016
|
+
const post = async (path32, payload) => {
|
|
8017
|
+
let res;
|
|
8018
|
+
try {
|
|
8019
|
+
res = await fetch(`${base}${path32}`, {
|
|
8020
|
+
method: "POST",
|
|
8021
|
+
headers: { "content-type": "application/json" },
|
|
8022
|
+
body: JSON.stringify(payload),
|
|
8023
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS3)
|
|
8024
|
+
});
|
|
8025
|
+
} catch (err) {
|
|
8026
|
+
throw new Error(`QQ scan request failed: ${fetchErrorText3(err)}`);
|
|
8027
|
+
}
|
|
8028
|
+
const body = await res.json().catch(() => null);
|
|
8029
|
+
if (body === null || body.retcode !== void 0 && body.retcode !== 0) {
|
|
8030
|
+
const detail = body?.msg ?? `HTTP ${res.status}`;
|
|
8031
|
+
const code = body?.retcode !== void 0 ? ` (retcode ${body.retcode})` : "";
|
|
8032
|
+
throw new Error(`QQ scan request failed: ${detail}${code}`);
|
|
8033
|
+
}
|
|
8034
|
+
return body.data;
|
|
8035
|
+
};
|
|
8036
|
+
return {
|
|
8037
|
+
async createBindTask(key) {
|
|
8038
|
+
const data = await post("/lite/create_bind_task", { key });
|
|
8039
|
+
const taskId = data?.task_id;
|
|
8040
|
+
if (typeof taskId !== "string" || taskId === "") {
|
|
8041
|
+
throw new Error("QQ scan request failed: no task id was returned");
|
|
8042
|
+
}
|
|
8043
|
+
return taskId;
|
|
8044
|
+
},
|
|
8045
|
+
async pollBindResult(taskId) {
|
|
8046
|
+
const data = await post("/lite/poll_bind_result", {
|
|
8047
|
+
task_id: taskId
|
|
8048
|
+
});
|
|
8049
|
+
const first = Array.isArray(data) ? data[0] : data;
|
|
8050
|
+
return { status: statusOf(first?.status), bots: botsOf(data) };
|
|
8051
|
+
}
|
|
8052
|
+
};
|
|
7635
8053
|
}
|
|
7636
|
-
function
|
|
7637
|
-
|
|
7638
|
-
if (cut <= 0) throw new Error(`malformed qq reply ref "${ref}"`);
|
|
7639
|
-
return parseQQChatId(ref.slice(0, cut));
|
|
8054
|
+
function newQQScanKey() {
|
|
8055
|
+
return randomBytes2(QQ_SCAN_KEY_BYTES).toString("base64");
|
|
7640
8056
|
}
|
|
7641
|
-
|
|
8057
|
+
function qqScanQrUrl(taskId, source = "") {
|
|
8058
|
+
return `${QQ_SCAN_QR_PAGE}?task_id=${encodeURIComponent(taskId)}&source=${encodeURIComponent(source)}&_wv=2`;
|
|
8059
|
+
}
|
|
8060
|
+
function decryptQQBotSecret(keyBase64, encryptedBase64) {
|
|
8061
|
+
const key = Buffer.from(keyBase64, "base64");
|
|
8062
|
+
const payload = Buffer.from(encryptedBase64, "base64");
|
|
8063
|
+
if (key.length !== QQ_SCAN_KEY_BYTES) {
|
|
8064
|
+
throw new Error("QQ scan key is not a 32-byte AES key");
|
|
8065
|
+
}
|
|
8066
|
+
if (payload.length <= GCM_IV_BYTES + GCM_TAG_BYTES) {
|
|
8067
|
+
throw new Error("QQ scan payload is too short to contain an encrypted secret");
|
|
8068
|
+
}
|
|
8069
|
+
const iv = payload.subarray(0, GCM_IV_BYTES);
|
|
8070
|
+
const tag = payload.subarray(payload.length - GCM_TAG_BYTES);
|
|
8071
|
+
const ciphertext = payload.subarray(GCM_IV_BYTES, payload.length - GCM_TAG_BYTES);
|
|
8072
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
8073
|
+
decipher.setAuthTag(tag);
|
|
8074
|
+
let plaintext;
|
|
8075
|
+
try {
|
|
8076
|
+
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
8077
|
+
} catch {
|
|
8078
|
+
throw new Error("QQ scan result could not be decrypted");
|
|
8079
|
+
}
|
|
8080
|
+
const secret = plaintext.toString("utf8");
|
|
8081
|
+
if (secret === "") throw new Error("QQ scan result decrypted to an empty secret");
|
|
8082
|
+
return secret;
|
|
8083
|
+
}
|
|
8084
|
+
var QQ_SCAN_TASK_TTL_MS = 10 * 6e4;
|
|
8085
|
+
var QQ_SCAN_MAX_TASKS_PER_SESSION = 4;
|
|
8086
|
+
var QQ_SCAN_MAX_TASKS = 4096;
|
|
8087
|
+
var QQScanService = class {
|
|
7642
8088
|
constructor(transport, opts = {}) {
|
|
7643
8089
|
this.transport = transport;
|
|
7644
|
-
this.tailFlushMs = opts.tailFlushMs ?? QQ_TAIL_FLUSH_MS;
|
|
7645
8090
|
this.now = opts.now ?? (() => Date.now());
|
|
7646
8091
|
}
|
|
7647
8092
|
transport;
|
|
7648
|
-
|
|
7649
|
-
/**
|
|
7650
|
-
* The budget channel-neutral callers clamp to — the bridge uses it to cap the
|
|
7651
|
-
* one-message-per-line split, whose own ceiling of 20 is meaningless against 4.
|
|
7652
|
-
*/
|
|
7653
|
-
replyBudget = QQ_MIN_REPLY_BUDGET;
|
|
7654
|
-
/**
|
|
7655
|
-
* Passive-reply state per `(appId, chat)`. It lives on the connector rather than on a
|
|
7656
|
-
* client, because the two halves that need it arrive separately: inbound events come
|
|
7657
|
-
* through `connect`, and sends through the client `createClient` hands the bridge. The
|
|
7658
|
-
* account is part of the key so two bindings on different bots can never spend each
|
|
7659
|
-
* other's budget.
|
|
7660
|
-
*/
|
|
7661
|
-
ledgers = /* @__PURE__ */ new Map();
|
|
7662
|
-
tailFlushMs;
|
|
8093
|
+
tasks = /* @__PURE__ */ new Map();
|
|
7663
8094
|
now;
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
},
|
|
7676
|
-
sendText: (chatId, text, opts) => deliver(chatId, text, opts),
|
|
7677
|
-
// Same operation as sendText: `msg_id` is an authorization anchor on QQ, not a
|
|
7678
|
-
// visible quote, so there is no threading to honour (see the module doc).
|
|
7679
|
-
replyText: (ref, text, opts) => {
|
|
7680
|
-
const { kind, openid } = chatOfReplyRef(ref);
|
|
7681
|
-
return this.enqueue(creds, bot, kind, openid, text, opts?.markdown === true);
|
|
7682
|
-
},
|
|
7683
|
-
sendImage: (_chatId, file) => refuseMedia(file),
|
|
7684
|
-
sendFile: (_chatId, file) => refuseMedia(file)
|
|
7685
|
-
};
|
|
7686
|
-
return client;
|
|
7687
|
-
}
|
|
7688
|
-
async connect(config, handlers) {
|
|
7689
|
-
const creds = qqConfigOf(config);
|
|
7690
|
-
const connection = await this.transport.openGateway(creds, {
|
|
7691
|
-
onMessage: async (evt) => {
|
|
7692
|
-
this.noteInbound(creds, evt);
|
|
7693
|
-
await handlers.onMessage({
|
|
7694
|
-
chatId: qqChatIdOf(evt.kind, evt.openid),
|
|
7695
|
-
chatKind: evt.kind === "c2c" ? "direct" : "group",
|
|
7696
|
-
messageId: qqReplyRefOf(evt.kind, evt.openid, evt.messageId),
|
|
7697
|
-
// Only text is read today; an empty content is every non-text message type
|
|
7698
|
-
// (image, voice, card, forwarded record), which the bridge answers with the
|
|
7699
|
-
// text-only notice. Inbound media is a follow-up, not a gap.
|
|
7700
|
-
text: evt.content !== "" ? evt.content : null,
|
|
7701
|
-
...evt.senderOpenid !== void 0 ? { senderName: evt.senderOpenid } : {}
|
|
7702
|
-
});
|
|
7703
|
-
},
|
|
7704
|
-
...handlers.onReady ? { onReady: handlers.onReady } : {},
|
|
7705
|
-
...handlers.onError ? { onError: handlers.onError } : {}
|
|
7706
|
-
});
|
|
7707
|
-
return {
|
|
7708
|
-
close: () => {
|
|
7709
|
-
connection.close();
|
|
7710
|
-
this.dropLedgers(creds.appId);
|
|
7711
|
-
}
|
|
7712
|
-
};
|
|
7713
|
-
}
|
|
7714
|
-
// —— The passive-reply budget ————————————————————————————————————————————
|
|
7715
|
-
ledgerKey(appId, kind, openid) {
|
|
7716
|
-
return `${appId}:${kind}:${openid}`;
|
|
8095
|
+
/** The self-re-arming sweep, alive only while a task is remembered (see `armSweep`). */
|
|
8096
|
+
sweepTimer = null;
|
|
8097
|
+
/** Starts a scan: a fresh key, a task registered under it, and the URL for the QR. */
|
|
8098
|
+
async start(sessionId) {
|
|
8099
|
+
this.sweep();
|
|
8100
|
+
const key = newQQScanKey();
|
|
8101
|
+
const taskId = await this.transport.createBindTask(key);
|
|
8102
|
+
this.evictOwn(sessionId);
|
|
8103
|
+
this.tasks.set(taskId, { key, sessionId, createdAt: this.now(), claimed: false });
|
|
8104
|
+
this.armSweep();
|
|
8105
|
+
return { taskId, qrUrl: qqScanQrUrl(taskId), pollMs: QQ_SCAN_POLL_MS };
|
|
7717
8106
|
}
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
tail: [],
|
|
7731
|
-
markdown: false,
|
|
7732
|
-
bot: null,
|
|
7733
|
-
timer: null,
|
|
7734
|
-
chain: Promise.resolve()
|
|
7735
|
-
};
|
|
7736
|
-
this.ledgers.set(key, ledger);
|
|
8107
|
+
/**
|
|
8108
|
+
* Polls one task. Returns null when the task id is not one this Session started (an
|
|
8109
|
+
* unknown id, another Session's, one already consumed, or one another poll is resolving
|
|
8110
|
+
* right now) — the caller answers 404, which is also what a replay gets.
|
|
8111
|
+
*/
|
|
8112
|
+
async poll(sessionId, taskId) {
|
|
8113
|
+
const task = this.tasks.get(taskId);
|
|
8114
|
+
if (task === void 0 || task.sessionId !== sessionId || task.claimed) return null;
|
|
8115
|
+
task.claimed = true;
|
|
8116
|
+
if (this.now() - task.createdAt > QQ_SCAN_TASK_TTL_MS) {
|
|
8117
|
+
this.tasks.delete(taskId);
|
|
8118
|
+
return { status: "expired" };
|
|
7737
8119
|
}
|
|
7738
|
-
|
|
8120
|
+
let result;
|
|
8121
|
+
try {
|
|
8122
|
+
result = await this.transport.pollBindResult(taskId);
|
|
8123
|
+
} catch (err) {
|
|
8124
|
+
task.claimed = false;
|
|
8125
|
+
throw err;
|
|
8126
|
+
}
|
|
8127
|
+
if (result.status === "expired") {
|
|
8128
|
+
this.tasks.delete(taskId);
|
|
8129
|
+
return { status: "expired" };
|
|
8130
|
+
}
|
|
8131
|
+
if (result.status !== "completed") {
|
|
8132
|
+
task.claimed = false;
|
|
8133
|
+
return { status: result.status };
|
|
8134
|
+
}
|
|
8135
|
+
this.tasks.delete(taskId);
|
|
8136
|
+
const bot = result.bots[0];
|
|
8137
|
+
if (bot === void 0) {
|
|
8138
|
+
throw new Error("QQ reported the scan complete but returned no bot");
|
|
8139
|
+
}
|
|
8140
|
+
const appSecret = decryptQQBotSecret(task.key, bot.encryptedSecret);
|
|
8141
|
+
return { status: "completed", bot: { appId: bot.appId, appSecret } };
|
|
7739
8142
|
}
|
|
7740
|
-
/**
|
|
7741
|
-
|
|
7742
|
-
if (
|
|
7743
|
-
this.ledgers.delete(key);
|
|
8143
|
+
/** Drops a task the user walked away from (the editor's cancel, and its unmount). */
|
|
8144
|
+
cancel(sessionId, taskId) {
|
|
8145
|
+
if (this.tasks.get(taskId)?.sessionId === sessionId) this.tasks.delete(taskId);
|
|
7744
8146
|
}
|
|
7745
|
-
/**
|
|
7746
|
-
|
|
7747
|
-
for (const [
|
|
7748
|
-
if (
|
|
8147
|
+
/** Session-delete cascade and bind removal: forget anything this Session had in flight. */
|
|
8148
|
+
cancelSession(sessionId) {
|
|
8149
|
+
for (const [taskId, task] of this.tasks) {
|
|
8150
|
+
if (task.sessionId === sessionId) this.tasks.delete(taskId);
|
|
7749
8151
|
}
|
|
7750
8152
|
}
|
|
7751
8153
|
/**
|
|
7752
|
-
*
|
|
7753
|
-
*
|
|
7754
|
-
*
|
|
8154
|
+
* Drops this Session's oldest tasks until one more fits under its own ceiling. Scoped to
|
|
8155
|
+
* the caller: the whole point of the per-Session ceiling is that filling it takes a code
|
|
8156
|
+
* away from nobody else.
|
|
7755
8157
|
*/
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
7759
|
-
|
|
7760
|
-
|
|
8158
|
+
evictOwn(sessionId) {
|
|
8159
|
+
const own = [...this.tasks].filter(([, task]) => task.sessionId === sessionId);
|
|
8160
|
+
if (own.length < QQ_SCAN_MAX_TASKS_PER_SESSION) return;
|
|
8161
|
+
own.sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
8162
|
+
for (const [taskId] of own.slice(0, own.length - QQ_SCAN_MAX_TASKS_PER_SESSION + 1)) {
|
|
8163
|
+
this.tasks.delete(taskId);
|
|
7761
8164
|
}
|
|
7762
8165
|
}
|
|
7763
|
-
/**
|
|
7764
|
-
|
|
7765
|
-
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
noteInbound(creds, evt) {
|
|
7774
|
-
const ledger = this.ledgerFor(creds.appId, evt.kind, evt.openid);
|
|
7775
|
-
if (ledger.msgId === evt.messageId) return;
|
|
7776
|
-
if (ledger.timer !== null) {
|
|
7777
|
-
clearTimeout(ledger.timer);
|
|
7778
|
-
ledger.timer = null;
|
|
8166
|
+
/** Drops expired tasks, then the oldest ones if the table is still over its backstop. */
|
|
8167
|
+
sweep() {
|
|
8168
|
+
const cutoff = this.now() - QQ_SCAN_TASK_TTL_MS;
|
|
8169
|
+
for (const [taskId, task] of this.tasks) {
|
|
8170
|
+
if (task.createdAt <= cutoff) this.tasks.delete(taskId);
|
|
8171
|
+
}
|
|
8172
|
+
if (this.tasks.size < QQ_SCAN_MAX_TASKS) return;
|
|
8173
|
+
const byAge = [...this.tasks].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
8174
|
+
for (const [taskId] of byAge.slice(0, this.tasks.size - QQ_SCAN_MAX_TASKS + 1)) {
|
|
8175
|
+
this.tasks.delete(taskId);
|
|
7779
8176
|
}
|
|
7780
|
-
const carry = ledger.tail.length > 0 && this.repliable(ledger);
|
|
7781
|
-
if (!carry) ledger.tail = [];
|
|
7782
|
-
ledger.msgId = evt.messageId;
|
|
7783
|
-
ledger.receivedAt = this.now();
|
|
7784
|
-
ledger.spent = 0;
|
|
7785
|
-
if (carry) this.scheduleFlush(ledger, 0);
|
|
7786
|
-
}
|
|
7787
|
-
/** True while `ledger.msgId` can still be replied to at all. */
|
|
7788
|
-
repliable(ledger) {
|
|
7789
|
-
return ledger.msgId !== null && this.now() - ledger.receivedAt <= QQ_PASSIVE_WINDOW_MS;
|
|
7790
8177
|
}
|
|
7791
8178
|
/**
|
|
7792
|
-
*
|
|
7793
|
-
*
|
|
7794
|
-
*
|
|
8179
|
+
* Keeps one unref'd timer alive while any task is remembered, so a scan the user walked
|
|
8180
|
+
* away from stops holding its key roughly at the TTL instead of at whenever somebody
|
|
8181
|
+
* happens to start the next scan — which may be never, and a key at rest is the one
|
|
8182
|
+
* outcome this module is shaped to avoid. It re-arms only while the table is non-empty,
|
|
8183
|
+
* and unref'd it is never the reason a process stays alive.
|
|
7795
8184
|
*/
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
|
|
7803
|
-
|
|
8185
|
+
armSweep() {
|
|
8186
|
+
if (this.sweepTimer !== null || this.tasks.size === 0) return;
|
|
8187
|
+
const timer = setTimeout(() => {
|
|
8188
|
+
this.sweepTimer = null;
|
|
8189
|
+
this.sweep();
|
|
8190
|
+
this.armSweep();
|
|
8191
|
+
}, QQ_SCAN_TASK_TTL_MS);
|
|
8192
|
+
timer.unref();
|
|
8193
|
+
this.sweepTimer = timer;
|
|
8194
|
+
}
|
|
8195
|
+
};
|
|
8196
|
+
|
|
8197
|
+
// src/runtime/messaging/wechat-markdown.ts
|
|
8198
|
+
var LIST_INDENT3 = " ";
|
|
8199
|
+
var MAX_HEADING_DEPTH = 4;
|
|
8200
|
+
var INLINE_SPECIALS2 = /[\\`*_~[\]]/g;
|
|
8201
|
+
var CJK = /[⺀-〿-ヿ㐀-䶿一-鿿가-豈--]/;
|
|
8202
|
+
function esc4(text) {
|
|
8203
|
+
return text.replace(INLINE_SPECIALS2, (ch) => `\\${ch}`);
|
|
8204
|
+
}
|
|
8205
|
+
function guardLineStarts3(text) {
|
|
8206
|
+
return text.split("\n").map((line) => line.replace(/^(\s*)([#>\-+])/, "$1\\$2").replace(/^(\s*\d+)([.)])/, "$1\\$2")).join("\n");
|
|
8207
|
+
}
|
|
8208
|
+
function href3(url) {
|
|
8209
|
+
return url.replaceAll("(", "%28").replaceAll(")", "%29");
|
|
8210
|
+
}
|
|
8211
|
+
function inline4(nodes) {
|
|
8212
|
+
return nodes.map(phrasing4).join("");
|
|
8213
|
+
}
|
|
8214
|
+
function emphasis(node) {
|
|
8215
|
+
const body = inline4(node.children);
|
|
8216
|
+
return CJK.test(body) ? body : `*${body}*`;
|
|
8217
|
+
}
|
|
8218
|
+
function longestBacktickRun2(value) {
|
|
8219
|
+
let longest = 0;
|
|
8220
|
+
for (const run2 of value.matchAll(/`+/g)) longest = Math.max(longest, run2[0].length);
|
|
8221
|
+
return longest;
|
|
8222
|
+
}
|
|
8223
|
+
function phrasing4(node) {
|
|
8224
|
+
switch (node.type) {
|
|
8225
|
+
case "text":
|
|
8226
|
+
return esc4(node.value);
|
|
8227
|
+
case "strong":
|
|
8228
|
+
return `**${inline4(node.children)}**`;
|
|
8229
|
+
case "emphasis":
|
|
8230
|
+
return emphasis(node);
|
|
8231
|
+
case "delete":
|
|
8232
|
+
return `~~${inline4(node.children)}~~`;
|
|
8233
|
+
case "inlineCode": {
|
|
8234
|
+
const fence = "`".repeat(longestBacktickRun2(node.value) + 1);
|
|
8235
|
+
const pad = node.value.startsWith("`") || node.value.endsWith("`") ? " " : "";
|
|
8236
|
+
return `${fence}${pad}${node.value}${pad}${fence}`;
|
|
7804
8237
|
}
|
|
7805
|
-
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
return;
|
|
8238
|
+
case "link": {
|
|
8239
|
+
const label = inline4(node.children);
|
|
8240
|
+
return isSafeUrl(node.url) ? `[${label}](${href3(node.url)})` : label;
|
|
7809
8241
|
}
|
|
7810
|
-
|
|
7811
|
-
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
withhold(ledger, text) {
|
|
7815
|
-
ledger.tail.push(text);
|
|
7816
|
-
let total = ledger.tail.reduce((n, part) => n + part.length + 2, 0);
|
|
7817
|
-
while (total > QQ_TAIL_MAX_CHARS && ledger.tail.length > 1) {
|
|
7818
|
-
const dropped = ledger.tail.shift();
|
|
7819
|
-
total -= (dropped?.length ?? 0) + 2;
|
|
7820
|
-
if (ledger.tail[0] !== QQ_TAIL_ELIDED) ledger.tail.unshift(QQ_TAIL_ELIDED);
|
|
8242
|
+
case "image": {
|
|
8243
|
+
const alt = node.alt ?? "";
|
|
8244
|
+
const label = esc4(alt !== "" ? alt : node.url);
|
|
8245
|
+
return isSafeUrl(node.url) ? `[${label}](${href3(node.url)})` : label;
|
|
7821
8246
|
}
|
|
8247
|
+
case "break":
|
|
8248
|
+
return "\n";
|
|
8249
|
+
case "html":
|
|
8250
|
+
return esc4(node.value);
|
|
8251
|
+
case "footnoteReference":
|
|
8252
|
+
return esc4(`[^${node.identifier}]`);
|
|
8253
|
+
case "linkReference":
|
|
8254
|
+
case "imageReference":
|
|
8255
|
+
return "children" in node ? inline4(node.children) : esc4(node.label ?? node.identifier);
|
|
8256
|
+
default:
|
|
8257
|
+
return "";
|
|
7822
8258
|
}
|
|
7823
|
-
|
|
7824
|
-
|
|
7825
|
-
|
|
7826
|
-
|
|
7827
|
-
|
|
7828
|
-
});
|
|
7829
|
-
}, delayMs);
|
|
7830
|
-
ledger.timer.unref?.();
|
|
7831
|
-
}
|
|
7832
|
-
/**
|
|
7833
|
-
* Spends the reserved final slot on everything withheld, as one message. A combined tail
|
|
7834
|
-
* over the channel's size cap is chunked as usual and only its FIRST chunk is sent — the
|
|
7835
|
-
* rest returns to the tail, to ride the next inbound message's budget rather than a
|
|
7836
|
-
* rejection.
|
|
7837
|
-
*/
|
|
7838
|
-
async flush(ledger) {
|
|
7839
|
-
if (ledger.tail.length === 0) return;
|
|
7840
|
-
if (!this.repliable(ledger) || ledger.spent >= QQ_REPLY_BUDGET[ledger.kind]) return;
|
|
7841
|
-
const combined = ledger.tail.join("\n\n");
|
|
7842
|
-
const chunks = ledger.markdown ? chunkMarkdown(combined, MESSAGING_TEXT_CHUNK_CHARS) : chunkMessagingText(combined);
|
|
7843
|
-
const head = chunks[0];
|
|
7844
|
-
if (head === void 0) {
|
|
7845
|
-
ledger.tail = [];
|
|
7846
|
-
return;
|
|
8259
|
+
}
|
|
8260
|
+
function plainText4(nodes) {
|
|
8261
|
+
return nodes.map((node) => {
|
|
8262
|
+
if (node.type === "text" || node.type === "inlineCode" || node.type === "html") {
|
|
8263
|
+
return esc4(node.value);
|
|
7847
8264
|
}
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
8265
|
+
if (node.type === "break") return " ";
|
|
8266
|
+
if (node.type === "image") return node.alt ?? "";
|
|
8267
|
+
return "children" in node ? plainText4(node.children) : "";
|
|
8268
|
+
}).join("");
|
|
8269
|
+
}
|
|
8270
|
+
function renderTable3(node) {
|
|
8271
|
+
const rows = node.children.map(
|
|
8272
|
+
(row) => `| ${row.children.map((cell) => plainText4(cell.children).replaceAll("|", "\\|")).join(" | ")} |`
|
|
8273
|
+
);
|
|
8274
|
+
const header = rows[0];
|
|
8275
|
+
if (header === void 0) return "";
|
|
8276
|
+
const columns = node.children[0]?.children.length ?? 0;
|
|
8277
|
+
return [header, `|${" --- |".repeat(columns)}`, ...rows.slice(1)].join("\n");
|
|
8278
|
+
}
|
|
8279
|
+
function itemMarker4(list, index) {
|
|
8280
|
+
return list.ordered === true ? `${(list.start ?? 1) + index}. ` : "- ";
|
|
8281
|
+
}
|
|
8282
|
+
function itemCheckbox4(item) {
|
|
8283
|
+
if (item.checked === true) return "\u2611 ";
|
|
8284
|
+
if (item.checked === false) return "\u2610 ";
|
|
8285
|
+
return "";
|
|
8286
|
+
}
|
|
8287
|
+
function renderList4(list, indent) {
|
|
8288
|
+
return list.children.map((item, i) => {
|
|
8289
|
+
const head = `${indent}${itemMarker4(list, i)}${itemCheckbox4(item)}`;
|
|
8290
|
+
const parts = [];
|
|
8291
|
+
for (const [j, child] of item.children.entries()) {
|
|
8292
|
+
if (j === 0 && child.type === "paragraph") {
|
|
8293
|
+
parts.push(head + inline4(child.children));
|
|
8294
|
+
continue;
|
|
8295
|
+
}
|
|
8296
|
+
if (child.type === "list") {
|
|
8297
|
+
parts.push(renderList4(child, indent + LIST_INDENT3));
|
|
8298
|
+
continue;
|
|
8299
|
+
}
|
|
8300
|
+
const rendered = block4(child, indent + LIST_INDENT3);
|
|
8301
|
+
if (rendered !== "") {
|
|
8302
|
+
parts.push(
|
|
8303
|
+
rendered.split("\n").map((line) => indent + LIST_INDENT3 + line).join("\n")
|
|
8304
|
+
);
|
|
8305
|
+
}
|
|
8306
|
+
}
|
|
8307
|
+
return parts.filter((part) => part !== "").join("\n");
|
|
8308
|
+
}).filter((line) => line !== "").join("\n");
|
|
8309
|
+
}
|
|
8310
|
+
function codeFence(value) {
|
|
8311
|
+
return "`".repeat(Math.max(3, longestBacktickRun2(value) + 1));
|
|
8312
|
+
}
|
|
8313
|
+
function block4(node, indent) {
|
|
8314
|
+
switch (node.type) {
|
|
8315
|
+
case "paragraph":
|
|
8316
|
+
return guardLineStarts3(inline4(node.children));
|
|
8317
|
+
case "heading": {
|
|
8318
|
+
const text = inline4(node.children);
|
|
8319
|
+
return node.depth > MAX_HEADING_DEPTH ? guardLineStarts3(text) : `${"#".repeat(node.depth)} ${text}`;
|
|
8320
|
+
}
|
|
8321
|
+
case "code": {
|
|
8322
|
+
const fence = codeFence(node.value);
|
|
8323
|
+
return `${fence}${node.lang ?? ""}
|
|
8324
|
+
${node.value}
|
|
8325
|
+
${fence}`;
|
|
7854
8326
|
}
|
|
8327
|
+
case "blockquote":
|
|
8328
|
+
return node.children.map((child) => block4(child, indent)).filter((rendered) => rendered !== "").join("\n\n").split("\n").map((line) => `> ${line}`).join("\n");
|
|
8329
|
+
case "list":
|
|
8330
|
+
return renderList4(node, indent);
|
|
8331
|
+
case "table":
|
|
8332
|
+
return renderTable3(node);
|
|
8333
|
+
case "thematicBreak":
|
|
8334
|
+
return "---";
|
|
8335
|
+
case "html":
|
|
8336
|
+
return guardLineStarts3(esc4(node.value));
|
|
8337
|
+
case "footnoteDefinition":
|
|
8338
|
+
return `**${esc4(`[^${node.identifier}]`)}** ${node.children.map((child) => block4(child, indent)).join("\n\n")}`;
|
|
8339
|
+
case "definition":
|
|
8340
|
+
return "";
|
|
8341
|
+
default:
|
|
8342
|
+
return "";
|
|
8343
|
+
}
|
|
8344
|
+
}
|
|
8345
|
+
function wechatMarkdownOf(markdown) {
|
|
8346
|
+
const root = parseMarkdown(markdown);
|
|
8347
|
+
return root.children.map((node) => block4(node, "")).filter((rendered) => rendered !== "").join("\n\n").trim();
|
|
8348
|
+
}
|
|
8349
|
+
|
|
8350
|
+
// src/runtime/messaging/wechat-api.ts
|
|
8351
|
+
import { createCipheriv, createDecipheriv as createDecipheriv2, createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
|
|
8352
|
+
var WECHAT_API_BASE = "https://ilinkai.weixin.qq.com";
|
|
8353
|
+
var ILINK_APP_ID = "bot";
|
|
8354
|
+
var ILINK_CLIENT_VERSION = 1 << 16 | 0 << 8 | 0;
|
|
8355
|
+
var BOT_AGENT = "PenguinHarness/1.0";
|
|
8356
|
+
var CALL_TIMEOUT_MS4 = 15e3;
|
|
8357
|
+
var LONG_POLL_TIMEOUT_MS = 3e4;
|
|
8358
|
+
var TRANSFER_TIMEOUT_MS2 = 6e4;
|
|
8359
|
+
var DRAIN_TIMEOUT_MS = 5e3;
|
|
8360
|
+
var WECHAT_BOT_TYPE = "3";
|
|
8361
|
+
var ITEM_TEXT = 1;
|
|
8362
|
+
var ITEM_IMAGE = 2;
|
|
8363
|
+
var ITEM_VOICE = 3;
|
|
8364
|
+
var ITEM_FILE = 4;
|
|
8365
|
+
var ITEM_VIDEO = 5;
|
|
8366
|
+
var MESSAGE_TYPE_BOT = 2;
|
|
8367
|
+
var MESSAGE_STATE_FINISH = 2;
|
|
8368
|
+
var UPLOAD_MEDIA_IMAGE = 1;
|
|
8369
|
+
var UPLOAD_MEDIA_FILE = 3;
|
|
8370
|
+
var AES_KEY_BYTES = 16;
|
|
8371
|
+
var WECHAT_VIDEO_FILE_NAME = "video.mp4";
|
|
8372
|
+
var WECHAT_STALE_TOKEN_CODE = -14;
|
|
8373
|
+
var WeChatApiError = class extends Error {
|
|
8374
|
+
constructor(ret, message, flags = {}) {
|
|
8375
|
+
super(message);
|
|
8376
|
+
this.ret = ret;
|
|
8377
|
+
this.name = "WeChatApiError";
|
|
8378
|
+
this.authenticated = flags.authenticated ?? false;
|
|
8379
|
+
this.timedOut = flags.timedOut ?? false;
|
|
7855
8380
|
}
|
|
8381
|
+
ret;
|
|
7856
8382
|
/**
|
|
7857
|
-
*
|
|
7858
|
-
* the
|
|
8383
|
+
* The request was ACCEPTED — it authenticated, and the failure came from the call behind
|
|
8384
|
+
* the credential rather than from the credential.
|
|
7859
8385
|
*
|
|
7860
|
-
*
|
|
7861
|
-
*
|
|
7862
|
-
*
|
|
7863
|
-
*
|
|
7864
|
-
* an extra slot — but it is only taken while a slot remains, and never for a refusal that
|
|
7865
|
-
* was not about the body in the first place (see QQ_BODY_INDEPENDENT_SEND_CODES): those
|
|
7866
|
-
* would fail identically as text and burn the slot reserved for the withheld tail.
|
|
8386
|
+
* This distinction is the whole of the credential probe: every authenticated endpoint on
|
|
8387
|
+
* this platform shares one auth layer and then does something that needs state a
|
|
8388
|
+
* freshly-bound bot does not have, so the only thing a probe can honestly read is which of
|
|
8389
|
+
* the two layers refused it (see checkCredentials).
|
|
7867
8390
|
*/
|
|
7868
|
-
|
|
7869
|
-
const msgId = ledger.msgId;
|
|
7870
|
-
const bot = ledger.bot;
|
|
7871
|
-
if (msgId === null || bot === null) return;
|
|
7872
|
-
const send = (body) => {
|
|
7873
|
-
ledger.spent += 1;
|
|
7874
|
-
return bot.sendMessage({
|
|
7875
|
-
kind: ledger.kind,
|
|
7876
|
-
openid: ledger.openid,
|
|
7877
|
-
content: text,
|
|
7878
|
-
...body,
|
|
7879
|
-
msgId,
|
|
7880
|
-
msgSeq: ledger.spent
|
|
7881
|
-
});
|
|
7882
|
-
};
|
|
7883
|
-
if (!markdown) return send({});
|
|
7884
|
-
try {
|
|
7885
|
-
await send({ markdown: qqMarkdownOf(text) });
|
|
7886
|
-
return;
|
|
7887
|
-
} catch (err) {
|
|
7888
|
-
if (!(err instanceof QQApiError)) throw err;
|
|
7889
|
-
if (err.code !== void 0 && QQ_BODY_INDEPENDENT_SEND_CODES.has(err.code)) throw err;
|
|
7890
|
-
if (ledger.spent >= QQ_REPLY_BUDGET[ledger.kind]) throw err;
|
|
7891
|
-
}
|
|
7892
|
-
await send({});
|
|
7893
|
-
}
|
|
8391
|
+
authenticated;
|
|
7894
8392
|
/**
|
|
7895
|
-
*
|
|
7896
|
-
*
|
|
7897
|
-
*
|
|
8393
|
+
* The request was given up on at a deadline rather than answered or refused. On a long poll
|
|
8394
|
+
* that is the ordinary quiet case and not a failure at all, which is what `getUpdates` reads
|
|
8395
|
+
* it for; nothing else here distinguishes it from a network fault.
|
|
7898
8396
|
*/
|
|
7899
|
-
|
|
7900
|
-
const result = ledger.chain.then(run2);
|
|
7901
|
-
ledger.chain = result.catch(() => {
|
|
7902
|
-
});
|
|
7903
|
-
return result;
|
|
7904
|
-
}
|
|
8397
|
+
timedOut;
|
|
7905
8398
|
};
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
var QQ_SCAN_QR_PAGE = "https://q.qq.com/qqbot/openclaw/connect.html";
|
|
7911
|
-
var CALL_TIMEOUT_MS3 = 15e3;
|
|
7912
|
-
var QQ_SCAN_POLL_MS = 2e3;
|
|
7913
|
-
var QQ_SCAN_KEY_BYTES = 32;
|
|
7914
|
-
var GCM_IV_BYTES = 12;
|
|
7915
|
-
var GCM_TAG_BYTES = 16;
|
|
7916
|
-
function fetchErrorText3(err) {
|
|
8399
|
+
function isAbortLike(err) {
|
|
8400
|
+
return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
8401
|
+
}
|
|
8402
|
+
function wechatFetchErrorText(err) {
|
|
7917
8403
|
if (err instanceof Error) {
|
|
7918
8404
|
if (err.name === "TimeoutError" || err.name === "AbortError") return "request timed out";
|
|
7919
8405
|
const cause = err.cause;
|
|
@@ -7922,108 +8408,623 @@ function fetchErrorText3(err) {
|
|
|
7922
8408
|
}
|
|
7923
8409
|
return String(err);
|
|
7924
8410
|
}
|
|
7925
|
-
function
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
8411
|
+
function randomUin() {
|
|
8412
|
+
return Buffer.from(String(randomBytes3(4).readUInt32BE(0)), "utf8").toString("base64");
|
|
8413
|
+
}
|
|
8414
|
+
function headersFor(token) {
|
|
8415
|
+
return {
|
|
8416
|
+
"content-type": "application/json",
|
|
8417
|
+
AuthorizationType: "ilink_bot_token",
|
|
8418
|
+
Authorization: `Bearer ${token}`,
|
|
8419
|
+
"X-WECHAT-UIN": randomUin(),
|
|
8420
|
+
"iLink-App-Id": ILINK_APP_ID,
|
|
8421
|
+
"iLink-App-ClientVersion": String(ILINK_CLIENT_VERSION)
|
|
8422
|
+
};
|
|
8423
|
+
}
|
|
8424
|
+
function baseInfo() {
|
|
8425
|
+
return { channel_version: "1.0.0", bot_agent: BOT_AGENT };
|
|
8426
|
+
}
|
|
8427
|
+
function endpointUrl(baseUrl, path32) {
|
|
8428
|
+
return `${baseUrl.replace(/\/+$/, "")}/${path32}`;
|
|
8429
|
+
}
|
|
8430
|
+
function checkEnvelope(label, status, raw) {
|
|
8431
|
+
let body;
|
|
8432
|
+
try {
|
|
8433
|
+
body = JSON.parse(raw);
|
|
8434
|
+
} catch {
|
|
8435
|
+
throw new WeChatApiError(void 0, `${label} failed: HTTP ${status} (unparseable response)`);
|
|
8436
|
+
}
|
|
8437
|
+
const env = body;
|
|
8438
|
+
const code = env.ret !== void 0 && env.ret !== 0 ? env.ret : env.errcode;
|
|
8439
|
+
if (code !== void 0 && code !== 0) {
|
|
8440
|
+
throw new WeChatApiError(code, `${label} failed: ${env.errmsg ?? `code ${code}`}`, {
|
|
8441
|
+
authenticated: code !== WECHAT_STALE_TOKEN_CODE
|
|
8442
|
+
});
|
|
7935
8443
|
}
|
|
8444
|
+
return body;
|
|
7936
8445
|
}
|
|
7937
|
-
function
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
8446
|
+
function paddedSize(plaintextSize) {
|
|
8447
|
+
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
8448
|
+
}
|
|
8449
|
+
function encryptEcb(plaintext, key) {
|
|
8450
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
8451
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
8452
|
+
}
|
|
8453
|
+
function decryptEcb(ciphertext, key) {
|
|
8454
|
+
const decipher = createDecipheriv2("aes-128-ecb", key, null);
|
|
8455
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
8456
|
+
}
|
|
8457
|
+
function parseWeChatAesKey(aesKey) {
|
|
8458
|
+
const decoded = Buffer.from(aesKey, "base64");
|
|
8459
|
+
if (decoded.length === AES_KEY_BYTES) return decoded;
|
|
8460
|
+
const ascii = decoded.toString("ascii");
|
|
8461
|
+
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(ascii)) return Buffer.from(ascii, "hex");
|
|
8462
|
+
throw new Error("wechat media key is neither 16 raw bytes nor 32 hex characters");
|
|
8463
|
+
}
|
|
8464
|
+
function mediaRefOf(media, aesKeyOverride) {
|
|
8465
|
+
const url = media?.full_url;
|
|
8466
|
+
if (typeof url !== "string" || url === "") return null;
|
|
8467
|
+
const aesKey = typeof aesKeyOverride === "string" && aesKeyOverride !== "" ? Buffer.from(aesKeyOverride, "hex").toString("base64") : media?.aes_key;
|
|
8468
|
+
return { url, ...typeof aesKey === "string" && aesKey !== "" ? { aesKey } : {} };
|
|
8469
|
+
}
|
|
8470
|
+
function textOf(items) {
|
|
8471
|
+
const parts = [];
|
|
8472
|
+
for (const item of items) {
|
|
8473
|
+
if (item.type === ITEM_TEXT && typeof item.text_item?.text === "string") {
|
|
8474
|
+
const quoted = item.ref_msg;
|
|
8475
|
+
const quotedText = quoted === void 0 ? "" : [quoted.title, quoted.message_item ? textOf([quoted.message_item]) : ""].filter((part) => typeof part === "string" && part !== "").join(" | ");
|
|
8476
|
+
parts.push(
|
|
8477
|
+
quotedText === "" ? item.text_item.text : `[quote: ${quotedText}]
|
|
8478
|
+
${item.text_item.text}`
|
|
8479
|
+
);
|
|
7944
8480
|
continue;
|
|
7945
8481
|
}
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
8482
|
+
if (item.type === ITEM_VOICE && typeof item.voice_item?.text === "string") {
|
|
8483
|
+
parts.push(item.voice_item.text);
|
|
8484
|
+
}
|
|
8485
|
+
}
|
|
8486
|
+
return parts.join("\n").trim();
|
|
8487
|
+
}
|
|
8488
|
+
function normalizeWeChatMessage(msg) {
|
|
8489
|
+
const userId = msg.from_user_id;
|
|
8490
|
+
if (typeof userId !== "string" || userId === "") return null;
|
|
8491
|
+
if (msg.message_type === MESSAGE_TYPE_BOT) return null;
|
|
8492
|
+
const items = Array.isArray(msg.item_list) ? msg.item_list : [];
|
|
8493
|
+
const images = [];
|
|
8494
|
+
const files = [];
|
|
8495
|
+
for (const item of items) {
|
|
8496
|
+
if (item.type === ITEM_IMAGE) {
|
|
8497
|
+
const ref = mediaRefOf(item.image_item?.media, item.image_item?.aeskey);
|
|
8498
|
+
if (ref !== null) images.push(ref);
|
|
8499
|
+
} else if (item.type === ITEM_FILE) {
|
|
8500
|
+
const ref = mediaRefOf(item.file_item?.media);
|
|
8501
|
+
const name = item.file_item?.file_name;
|
|
8502
|
+
if (ref !== null)
|
|
8503
|
+
files.push({ fileName: name !== void 0 && name !== "" ? name : "file", media: ref });
|
|
8504
|
+
} else if (item.type === ITEM_VIDEO) {
|
|
8505
|
+
const ref = mediaRefOf(item.video_item?.media);
|
|
8506
|
+
if (ref !== null) files.push({ fileName: WECHAT_VIDEO_FILE_NAME, media: ref });
|
|
8507
|
+
}
|
|
8508
|
+
}
|
|
8509
|
+
const messageId = msg.message_id;
|
|
8510
|
+
return {
|
|
8511
|
+
userId,
|
|
8512
|
+
messageId: messageId === void 0 ? "" : String(messageId),
|
|
8513
|
+
text: textOf(items),
|
|
8514
|
+
...typeof msg.context_token === "string" && msg.context_token !== "" ? { contextToken: msg.context_token } : {},
|
|
8515
|
+
images,
|
|
8516
|
+
files
|
|
8517
|
+
};
|
|
8518
|
+
}
|
|
8519
|
+
function createWeChatTransport() {
|
|
8520
|
+
return {
|
|
8521
|
+
createClient(creds) {
|
|
8522
|
+
const post = async (path32, payload, opts) => {
|
|
8523
|
+
const url = endpointUrl(creds.baseUrl, path32);
|
|
8524
|
+
let res;
|
|
8525
|
+
try {
|
|
8526
|
+
res = await fetch(url, {
|
|
8527
|
+
method: "POST",
|
|
8528
|
+
headers: headersFor(creds.botToken),
|
|
8529
|
+
body: JSON.stringify({ ...payload, base_info: baseInfo() }),
|
|
8530
|
+
signal: opts.signal ?? AbortSignal.timeout(opts.timeoutMs)
|
|
8531
|
+
});
|
|
8532
|
+
} catch (err) {
|
|
8533
|
+
throw new WeChatApiError(
|
|
8534
|
+
void 0,
|
|
8535
|
+
`${opts.label} failed: ${wechatFetchErrorText(err)}`,
|
|
8536
|
+
{
|
|
8537
|
+
timedOut: isAbortLike(err)
|
|
8538
|
+
}
|
|
8539
|
+
);
|
|
8540
|
+
}
|
|
8541
|
+
const raw = await res.text().catch(() => "");
|
|
8542
|
+
if (!res.ok) {
|
|
8543
|
+
throw new WeChatApiError(
|
|
8544
|
+
void 0,
|
|
8545
|
+
`${opts.label} failed: HTTP ${res.status}${raw === "" ? "" : ` ${raw.slice(0, 300)}`}`
|
|
8546
|
+
);
|
|
8547
|
+
}
|
|
8548
|
+
return checkEnvelope(opts.label, res.status, raw);
|
|
8549
|
+
};
|
|
8550
|
+
const sendItem = async (args, item, label) => {
|
|
8551
|
+
await post(
|
|
8552
|
+
"ilink/bot/sendmessage",
|
|
8553
|
+
{
|
|
8554
|
+
msg: {
|
|
8555
|
+
from_user_id: "",
|
|
8556
|
+
to_user_id: args.userId,
|
|
8557
|
+
client_id: randomBytes3(16).toString("hex"),
|
|
8558
|
+
message_type: MESSAGE_TYPE_BOT,
|
|
8559
|
+
message_state: MESSAGE_STATE_FINISH,
|
|
8560
|
+
item_list: [item],
|
|
8561
|
+
...args.contextToken !== void 0 ? { context_token: args.contextToken } : {}
|
|
8562
|
+
}
|
|
8563
|
+
},
|
|
8564
|
+
{ timeoutMs: CALL_TIMEOUT_MS4, label }
|
|
8565
|
+
);
|
|
8566
|
+
};
|
|
8567
|
+
const upload = async (userId, file, mediaType, label) => {
|
|
8568
|
+
const aesKey = randomBytes3(AES_KEY_BYTES);
|
|
8569
|
+
const rawSize = file.data.length;
|
|
8570
|
+
const cipherSize = paddedSize(rawSize);
|
|
8571
|
+
const filekey = randomBytes3(AES_KEY_BYTES).toString("hex");
|
|
8572
|
+
const handle = await post(
|
|
8573
|
+
"ilink/bot/getuploadurl",
|
|
8574
|
+
{
|
|
8575
|
+
filekey,
|
|
8576
|
+
media_type: mediaType,
|
|
8577
|
+
to_user_id: userId,
|
|
8578
|
+
rawsize: rawSize,
|
|
8579
|
+
rawfilemd5: createHash2("md5").update(file.data).digest("hex"),
|
|
8580
|
+
filesize: cipherSize,
|
|
8581
|
+
no_need_thumb: true,
|
|
8582
|
+
aeskey: aesKey.toString("hex")
|
|
8583
|
+
},
|
|
8584
|
+
{ timeoutMs: CALL_TIMEOUT_MS4, label: `${label} upload handle` }
|
|
8585
|
+
);
|
|
8586
|
+
const uploadUrl = handle.upload_full_url;
|
|
8587
|
+
if (typeof uploadUrl !== "string" || uploadUrl.trim() === "") {
|
|
8588
|
+
throw new WeChatApiError(void 0, `${label} failed: the upload URL was not returned`);
|
|
8589
|
+
}
|
|
8590
|
+
let res;
|
|
8591
|
+
try {
|
|
8592
|
+
res = await fetch(uploadUrl.trim(), {
|
|
8593
|
+
method: "POST",
|
|
8594
|
+
headers: { "content-type": "application/octet-stream" },
|
|
8595
|
+
body: new Uint8Array(encryptEcb(file.data, aesKey)),
|
|
8596
|
+
signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS2)
|
|
8597
|
+
});
|
|
8598
|
+
} catch (err) {
|
|
8599
|
+
throw new WeChatApiError(void 0, `${label} failed: ${wechatFetchErrorText(err)}`);
|
|
8600
|
+
}
|
|
8601
|
+
if (!res.ok) {
|
|
8602
|
+
const detail = res.headers.get("x-error-message") ?? `HTTP ${res.status}`;
|
|
8603
|
+
throw new WeChatApiError(void 0, `${label} failed: ${detail}`);
|
|
8604
|
+
}
|
|
8605
|
+
const param = res.headers.get("x-encrypted-param");
|
|
8606
|
+
if (param === null || param === "") {
|
|
8607
|
+
throw new WeChatApiError(
|
|
8608
|
+
void 0,
|
|
8609
|
+
`${label} failed: the CDN returned no download handle`
|
|
8610
|
+
);
|
|
8611
|
+
}
|
|
8612
|
+
return { param, aesKey, rawSize, cipherSize };
|
|
8613
|
+
};
|
|
8614
|
+
return {
|
|
8615
|
+
async checkCredentials() {
|
|
8616
|
+
try {
|
|
8617
|
+
await post(
|
|
8618
|
+
"ilink/bot/getconfig",
|
|
8619
|
+
{ ilink_user_id: creds.userId },
|
|
8620
|
+
{ timeoutMs: CALL_TIMEOUT_MS4, label: "wechat credential check" }
|
|
8621
|
+
);
|
|
8622
|
+
} catch (err) {
|
|
8623
|
+
if (err instanceof WeChatApiError && err.authenticated) return;
|
|
8624
|
+
throw err;
|
|
8625
|
+
}
|
|
8626
|
+
},
|
|
8627
|
+
async getUpdates({ cursor, signal, drain = false }) {
|
|
8628
|
+
const windowMs = drain ? DRAIN_TIMEOUT_MS : LONG_POLL_TIMEOUT_MS;
|
|
8629
|
+
let body;
|
|
8630
|
+
try {
|
|
8631
|
+
body = await post(
|
|
8632
|
+
"ilink/bot/getupdates",
|
|
8633
|
+
{ get_updates_buf: cursor },
|
|
8634
|
+
{
|
|
8635
|
+
timeoutMs: windowMs,
|
|
8636
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(windowMs)]),
|
|
8637
|
+
label: "wechat getUpdates"
|
|
8638
|
+
}
|
|
8639
|
+
);
|
|
8640
|
+
} catch (err) {
|
|
8641
|
+
if (signal.aborted) throw err;
|
|
8642
|
+
if (err instanceof WeChatApiError && err.timedOut) {
|
|
8643
|
+
return { messages: [], cursor, timedOut: true };
|
|
8644
|
+
}
|
|
8645
|
+
throw err;
|
|
8646
|
+
}
|
|
8647
|
+
const raw = Array.isArray(body.msgs) ? body.msgs : [];
|
|
8648
|
+
const messages = [];
|
|
8649
|
+
for (const msg of raw) {
|
|
8650
|
+
const evt = normalizeWeChatMessage(msg);
|
|
8651
|
+
if (evt !== null) messages.push(evt);
|
|
8652
|
+
}
|
|
8653
|
+
const next = body.get_updates_buf;
|
|
8654
|
+
return {
|
|
8655
|
+
messages,
|
|
8656
|
+
cursor: typeof next === "string" && next !== "" ? next : cursor,
|
|
8657
|
+
timedOut: false
|
|
8658
|
+
};
|
|
8659
|
+
},
|
|
8660
|
+
sendText(args) {
|
|
8661
|
+
return sendItem(args, { type: ITEM_TEXT, text_item: { text: args.text } }, "wechat send");
|
|
8662
|
+
},
|
|
8663
|
+
async sendImage(args) {
|
|
8664
|
+
const { param, aesKey, cipherSize } = await upload(
|
|
8665
|
+
args.userId,
|
|
8666
|
+
args.file,
|
|
8667
|
+
UPLOAD_MEDIA_IMAGE,
|
|
8668
|
+
"wechat image send"
|
|
8669
|
+
);
|
|
8670
|
+
if (args.text !== "") await this.sendText(args);
|
|
8671
|
+
await sendItem(
|
|
8672
|
+
args,
|
|
8673
|
+
{
|
|
8674
|
+
type: ITEM_IMAGE,
|
|
8675
|
+
image_item: {
|
|
8676
|
+
media: {
|
|
8677
|
+
encrypt_query_param: param,
|
|
8678
|
+
aes_key: aesKey.toString("base64"),
|
|
8679
|
+
encrypt_type: 1
|
|
8680
|
+
},
|
|
8681
|
+
mid_size: cipherSize
|
|
8682
|
+
}
|
|
8683
|
+
},
|
|
8684
|
+
"wechat image send"
|
|
8685
|
+
);
|
|
8686
|
+
},
|
|
8687
|
+
async sendFile(args) {
|
|
8688
|
+
const { param, aesKey, rawSize } = await upload(
|
|
8689
|
+
args.userId,
|
|
8690
|
+
args.file,
|
|
8691
|
+
UPLOAD_MEDIA_FILE,
|
|
8692
|
+
"wechat file send"
|
|
8693
|
+
);
|
|
8694
|
+
if (args.text !== "") await this.sendText(args);
|
|
8695
|
+
await sendItem(
|
|
8696
|
+
args,
|
|
8697
|
+
{
|
|
8698
|
+
type: ITEM_FILE,
|
|
8699
|
+
file_item: {
|
|
8700
|
+
media: {
|
|
8701
|
+
encrypt_query_param: param,
|
|
8702
|
+
aes_key: aesKey.toString("base64"),
|
|
8703
|
+
encrypt_type: 1
|
|
8704
|
+
},
|
|
8705
|
+
file_name: args.file.fileName,
|
|
8706
|
+
// Plaintext length: the receiving client shows it, and the ciphertext is
|
|
8707
|
+
// one padding block longer than what the user actually gets.
|
|
8708
|
+
len: String(rawSize)
|
|
8709
|
+
}
|
|
8710
|
+
},
|
|
8711
|
+
"wechat file send"
|
|
8712
|
+
);
|
|
8713
|
+
},
|
|
8714
|
+
async fetchMedia(ref, maxBytes, what) {
|
|
8715
|
+
let res;
|
|
8716
|
+
try {
|
|
8717
|
+
res = await fetch(ref.url, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS2) });
|
|
8718
|
+
} catch (err) {
|
|
8719
|
+
throw new Error(`${what} could not be downloaded: ${wechatFetchErrorText(err)}`);
|
|
8720
|
+
}
|
|
8721
|
+
if (!res.ok) throw new Error(`${what} could not be downloaded: HTTP ${res.status}`);
|
|
8722
|
+
if (res.body === null) throw new Error(`${what} could not be downloaded: empty response`);
|
|
8723
|
+
const bytes = await collectUnderCap(
|
|
8724
|
+
res.body,
|
|
8725
|
+
maxBytes,
|
|
8726
|
+
what
|
|
8727
|
+
);
|
|
8728
|
+
if (ref.aesKey === void 0) return bytes;
|
|
8729
|
+
try {
|
|
8730
|
+
return decryptEcb(bytes, parseWeChatAesKey(ref.aesKey));
|
|
8731
|
+
} catch (err) {
|
|
8732
|
+
if (err instanceof MessagingMediaTooLargeError) throw err;
|
|
8733
|
+
throw new Error(`${what} arrived but could not be decrypted`);
|
|
8734
|
+
}
|
|
8735
|
+
}
|
|
8736
|
+
};
|
|
8737
|
+
}
|
|
8738
|
+
};
|
|
8739
|
+
}
|
|
8740
|
+
|
|
8741
|
+
// src/runtime/messaging/wechat-connector.ts
|
|
8742
|
+
var DRAIN_ATTEMPTS = 2;
|
|
8743
|
+
function wechatRetryDelayMs(failures) {
|
|
8744
|
+
return Math.min(2e3 * 2 ** Math.max(0, failures - 1), 6e4);
|
|
8745
|
+
}
|
|
8746
|
+
function wechatConfigOf(config) {
|
|
8747
|
+
const { botId, botToken, baseUrl, userId } = config;
|
|
8748
|
+
if (typeof botId !== "string" || botId === "" || typeof botToken !== "string" || botToken === "") {
|
|
8749
|
+
throw new Error("malformed wechat binding config (botId/botToken)");
|
|
8750
|
+
}
|
|
8751
|
+
return {
|
|
8752
|
+
botId,
|
|
8753
|
+
botToken,
|
|
8754
|
+
// A document written before the platform named a host, or by a scan whose answer carried
|
|
8755
|
+
// none, polls the default entry host — which is where a bot without an IDC assignment
|
|
8756
|
+
// lives anyway.
|
|
8757
|
+
baseUrl: typeof baseUrl === "string" && baseUrl !== "" ? baseUrl : WECHAT_API_BASE,
|
|
8758
|
+
userId: typeof userId === "string" ? userId : ""
|
|
8759
|
+
};
|
|
8760
|
+
}
|
|
8761
|
+
function credsOf(config) {
|
|
8762
|
+
const { botId, botToken, baseUrl, userId } = wechatConfigOf(config);
|
|
8763
|
+
return { botId, botToken, baseUrl, userId };
|
|
8764
|
+
}
|
|
8765
|
+
function inboundOf(bot, evt) {
|
|
8766
|
+
const images = evt.images.map((ref) => ({
|
|
8767
|
+
fetch: async (maxBytes) => {
|
|
8768
|
+
const data = await bot.fetchMedia(ref, maxBytes, "The image");
|
|
8769
|
+
return { data, mimeType: sniffImageMime(data) ?? "image/png" };
|
|
8770
|
+
}
|
|
8771
|
+
}));
|
|
8772
|
+
const files = evt.files.map((file) => ({
|
|
8773
|
+
fileName: file.fileName,
|
|
8774
|
+
fetch: (maxBytes) => bot.fetchMedia(file.media, maxBytes, "The file")
|
|
8775
|
+
}));
|
|
8776
|
+
return {
|
|
8777
|
+
// The sender IS the chat on this channel: a bot conversation is one-to-one, and the id
|
|
8778
|
+
// that arrives is the one a send is addressed to.
|
|
8779
|
+
chatId: evt.userId,
|
|
8780
|
+
chatKind: "direct",
|
|
8781
|
+
// The reply anchor carries the chat, as it does on every channel whose message ids are
|
|
8782
|
+
// not globally unique — and the id must identify the MESSAGE rather than the delivery,
|
|
8783
|
+
// because it is also the bridge's dedupe key.
|
|
8784
|
+
messageId: evt.messageId === "" ? "" : `${evt.userId}:${evt.messageId}`,
|
|
8785
|
+
text: evt.text !== "" ? evt.text : null,
|
|
8786
|
+
...images.length > 0 ? { images } : {},
|
|
8787
|
+
...files.length > 0 ? { files } : {}
|
|
8788
|
+
};
|
|
8789
|
+
}
|
|
8790
|
+
function chatOfWeChatReplyRef(ref) {
|
|
8791
|
+
const cut = ref.lastIndexOf(":");
|
|
8792
|
+
if (cut <= 0) throw new Error(`malformed wechat reply ref "${ref}"`);
|
|
8793
|
+
return ref.slice(0, cut);
|
|
8794
|
+
}
|
|
8795
|
+
var WeChatConnector = class {
|
|
8796
|
+
constructor(transport, opts = {}) {
|
|
8797
|
+
this.transport = transport;
|
|
8798
|
+
this.retryDelayMs = opts.retryDelayMs ?? wechatRetryDelayMs;
|
|
8799
|
+
}
|
|
8800
|
+
transport;
|
|
8801
|
+
channel = "wechat";
|
|
8802
|
+
/**
|
|
8803
|
+
* The most recent context token per `(botId, userId)`.
|
|
8804
|
+
*
|
|
8805
|
+
* On the connector rather than on a client because the two halves that need it arrive
|
|
8806
|
+
* separately: tokens come in through `connect`, and sends go out through the client
|
|
8807
|
+
* `createClient` hands the bridge. The bot is part of the key so two bindings on different
|
|
8808
|
+
* bots can never spend each other's conversation handles.
|
|
8809
|
+
*/
|
|
8810
|
+
contextTokens = /* @__PURE__ */ new Map();
|
|
8811
|
+
retryDelayMs;
|
|
8812
|
+
async createClient(config) {
|
|
8813
|
+
const creds = credsOf(config);
|
|
8814
|
+
const bot = this.transport.createClient(creds);
|
|
8815
|
+
const args = (userId, text, opts) => {
|
|
8816
|
+
const contextToken = this.contextTokens.get(this.tokenKey(creds.botId, userId));
|
|
8817
|
+
return {
|
|
8818
|
+
userId,
|
|
8819
|
+
text: opts?.markdown === true ? wechatMarkdownOf(text) : text,
|
|
8820
|
+
...contextToken !== void 0 ? { contextToken } : {}
|
|
8821
|
+
};
|
|
8822
|
+
};
|
|
8823
|
+
return {
|
|
8824
|
+
async checkCredentials() {
|
|
8825
|
+
await bot.checkCredentials();
|
|
8826
|
+
return null;
|
|
8827
|
+
},
|
|
8828
|
+
sendText: (chatId, text, opts) => bot.sendText(args(chatId, text, opts)),
|
|
8829
|
+
// The same operation as sendText: this channel has no quote relation to thread onto
|
|
8830
|
+
// (see the module doc), so the anchor is read only for the chat it names.
|
|
8831
|
+
replyText: (ref, text, opts) => bot.sendText(args(chatOfWeChatReplyRef(ref), text, opts)),
|
|
8832
|
+
sendImage: (chatId, file) => (
|
|
8833
|
+
// The caption is empty because the bridge sends a reply's text as its own message:
|
|
8834
|
+
// a picture here is the picture, and pairing it would duplicate what already went.
|
|
8835
|
+
bot.sendImage({ ...args(chatId, ""), file })
|
|
8836
|
+
),
|
|
8837
|
+
sendFile: (chatId, file) => bot.sendFile({ ...args(chatId, ""), file })
|
|
8838
|
+
};
|
|
8839
|
+
}
|
|
8840
|
+
async connect(config, handlers) {
|
|
8841
|
+
const creds = credsOf(config);
|
|
8842
|
+
const bot = this.transport.createClient(creds);
|
|
8843
|
+
const abort = new AbortController();
|
|
8844
|
+
let closed = false;
|
|
8845
|
+
void this.poll(creds, bot, handlers, abort.signal, () => closed);
|
|
8846
|
+
return {
|
|
8847
|
+
close: () => {
|
|
8848
|
+
closed = true;
|
|
8849
|
+
abort.abort();
|
|
8850
|
+
this.dropTokens(creds.botId);
|
|
8851
|
+
}
|
|
8852
|
+
};
|
|
8853
|
+
}
|
|
8854
|
+
tokenKey(botId, userId) {
|
|
8855
|
+
return `${botId}:${userId}`;
|
|
8856
|
+
}
|
|
8857
|
+
/** Forgets every conversation handle of one bot — the connection's close (see connect). */
|
|
8858
|
+
dropTokens(botId) {
|
|
8859
|
+
const prefix = `${botId}:`;
|
|
8860
|
+
for (const key of this.contextTokens.keys()) {
|
|
8861
|
+
if (key.startsWith(prefix)) this.contextTokens.delete(key);
|
|
8862
|
+
}
|
|
8863
|
+
}
|
|
8864
|
+
async poll(creds, bot, handlers, signal, isClosed) {
|
|
8865
|
+
let ready = false;
|
|
8866
|
+
let drained = false;
|
|
8867
|
+
let drainAttempts = 0;
|
|
8868
|
+
let failures = 0;
|
|
8869
|
+
let cursor = "";
|
|
8870
|
+
while (!isClosed()) {
|
|
8871
|
+
try {
|
|
8872
|
+
if (!ready) {
|
|
8873
|
+
await bot.checkCredentials();
|
|
8874
|
+
if (isClosed()) return;
|
|
8875
|
+
ready = true;
|
|
8876
|
+
handlers.onReady?.();
|
|
8877
|
+
}
|
|
8878
|
+
const {
|
|
8879
|
+
messages,
|
|
8880
|
+
cursor: next,
|
|
8881
|
+
timedOut
|
|
8882
|
+
} = await bot.getUpdates({
|
|
8883
|
+
cursor,
|
|
8884
|
+
signal,
|
|
8885
|
+
...drained ? {} : { drain: true }
|
|
8886
|
+
});
|
|
8887
|
+
if (isClosed()) return;
|
|
8888
|
+
cursor = next;
|
|
8889
|
+
if (!drained) {
|
|
8890
|
+
if (!timedOut || ++drainAttempts >= DRAIN_ATTEMPTS) drained = true;
|
|
8891
|
+
continue;
|
|
8892
|
+
}
|
|
8893
|
+
failures = 0;
|
|
8894
|
+
for (const evt of messages) {
|
|
8895
|
+
if (isClosed()) return;
|
|
8896
|
+
if (evt.contextToken !== void 0) {
|
|
8897
|
+
this.contextTokens.set(this.tokenKey(creds.botId, evt.userId), evt.contextToken);
|
|
8898
|
+
}
|
|
8899
|
+
await handlers.onMessage(inboundOf(bot, evt));
|
|
8900
|
+
}
|
|
8901
|
+
} catch (err) {
|
|
8902
|
+
if (isClosed()) return;
|
|
8903
|
+
ready = false;
|
|
8904
|
+
failures += 1;
|
|
8905
|
+
if (failures === 1) handlers.onError?.(err);
|
|
8906
|
+
await this.sleep(this.retryDelayMs(failures), signal);
|
|
8907
|
+
}
|
|
8908
|
+
}
|
|
8909
|
+
}
|
|
8910
|
+
/** A backoff that a close ends immediately rather than after its full delay. */
|
|
8911
|
+
sleep(ms, signal) {
|
|
8912
|
+
if (ms <= 0 || signal.aborted) return Promise.resolve();
|
|
8913
|
+
return new Promise((resolve) => {
|
|
8914
|
+
const timer = setTimeout(() => {
|
|
8915
|
+
signal.removeEventListener("abort", onAbort);
|
|
8916
|
+
resolve();
|
|
8917
|
+
}, ms);
|
|
8918
|
+
const onAbort = () => {
|
|
8919
|
+
clearTimeout(timer);
|
|
8920
|
+
resolve();
|
|
8921
|
+
};
|
|
8922
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
7950
8923
|
});
|
|
7951
8924
|
}
|
|
7952
|
-
|
|
8925
|
+
};
|
|
8926
|
+
|
|
8927
|
+
// src/runtime/messaging/wechat-scan.ts
|
|
8928
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
8929
|
+
var CREATE_TIMEOUT_MS = 15e3;
|
|
8930
|
+
var POLL_TIMEOUT_MS = 8e3;
|
|
8931
|
+
var WECHAT_SCAN_POLL_MS = 2e3;
|
|
8932
|
+
function statusOf2(raw) {
|
|
8933
|
+
switch (raw) {
|
|
8934
|
+
case "confirmed":
|
|
8935
|
+
return "completed";
|
|
8936
|
+
case "scaned":
|
|
8937
|
+
// A redirect is a scan that must continue against another host: the person has scanned,
|
|
8938
|
+
// which is what they need told, and the host switch is the caller's business.
|
|
8939
|
+
case "scaned_but_redirect":
|
|
8940
|
+
return "scanned";
|
|
8941
|
+
case "need_verifycode":
|
|
8942
|
+
return "need_verify_code";
|
|
8943
|
+
case "verify_code_blocked":
|
|
8944
|
+
return "blocked";
|
|
8945
|
+
case "expired":
|
|
8946
|
+
return "expired";
|
|
8947
|
+
case "binded_redirect":
|
|
8948
|
+
return "already_bound";
|
|
8949
|
+
default:
|
|
8950
|
+
return "pending";
|
|
8951
|
+
}
|
|
7953
8952
|
}
|
|
7954
|
-
function
|
|
7955
|
-
const
|
|
7956
|
-
|
|
7957
|
-
|
|
7958
|
-
|
|
7959
|
-
method: "POST",
|
|
7960
|
-
headers: { "content-type": "application/json" },
|
|
7961
|
-
body: JSON.stringify(payload),
|
|
7962
|
-
signal: AbortSignal.timeout(CALL_TIMEOUT_MS3)
|
|
7963
|
-
});
|
|
7964
|
-
} catch (err) {
|
|
7965
|
-
throw new Error(`QQ scan request failed: ${fetchErrorText3(err)}`);
|
|
7966
|
-
}
|
|
7967
|
-
const body = await res.json().catch(() => null);
|
|
7968
|
-
if (body === null || body.retcode !== void 0 && body.retcode !== 0) {
|
|
7969
|
-
const detail = body?.msg ?? `HTTP ${res.status}`;
|
|
7970
|
-
const code = body?.retcode !== void 0 ? ` (retcode ${body.retcode})` : "";
|
|
7971
|
-
throw new Error(`QQ scan request failed: ${detail}${code}`);
|
|
7972
|
-
}
|
|
7973
|
-
return body.data;
|
|
7974
|
-
};
|
|
8953
|
+
function createWeChatScanTransport(base = WECHAT_API_BASE) {
|
|
8954
|
+
const headers = () => ({
|
|
8955
|
+
"iLink-App-Id": "bot",
|
|
8956
|
+
"iLink-App-ClientVersion": String(1 << 16 | 0)
|
|
8957
|
+
});
|
|
7975
8958
|
return {
|
|
7976
|
-
async
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
|
|
7980
|
-
|
|
8959
|
+
async createQrCode() {
|
|
8960
|
+
let res;
|
|
8961
|
+
try {
|
|
8962
|
+
res = await fetch(
|
|
8963
|
+
`${base}/ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(WECHAT_BOT_TYPE)}`,
|
|
8964
|
+
{
|
|
8965
|
+
method: "POST",
|
|
8966
|
+
headers: { ...headers(), "content-type": "application/json" },
|
|
8967
|
+
// The platform takes the tokens this client already holds so it can recognize a
|
|
8968
|
+
// rebind of a bot it has already issued one for. Nothing here offers them: a
|
|
8969
|
+
// stored token is another Session's credential, and the `already_bound` answer
|
|
8970
|
+
// this would enable is not worth handing one binding's secret to another's flow.
|
|
8971
|
+
body: JSON.stringify({ local_token_list: [] }),
|
|
8972
|
+
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS)
|
|
8973
|
+
}
|
|
8974
|
+
);
|
|
8975
|
+
} catch (err) {
|
|
8976
|
+
throw new Error(`WeChat scan request failed: ${wechatFetchErrorText(err)}`);
|
|
7981
8977
|
}
|
|
7982
|
-
|
|
8978
|
+
if (!res.ok) throw new Error(`WeChat scan request failed: HTTP ${res.status}`);
|
|
8979
|
+
const body = await res.json().catch(() => null);
|
|
8980
|
+
const qrcode = body?.qrcode;
|
|
8981
|
+
const qrUrl = body?.qrcode_img_content;
|
|
8982
|
+
if (typeof qrcode !== "string" || qrcode === "" || typeof qrUrl !== "string" || qrUrl === "") {
|
|
8983
|
+
throw new Error("WeChat scan request failed: no QR code was returned");
|
|
8984
|
+
}
|
|
8985
|
+
return { qrcode, qrUrl };
|
|
7983
8986
|
},
|
|
7984
|
-
async
|
|
7985
|
-
const
|
|
7986
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
7989
|
-
|
|
8987
|
+
async pollQrStatus({ baseUrl, qrcode, verifyCode }) {
|
|
8988
|
+
const query = new URLSearchParams({ qrcode });
|
|
8989
|
+
if (verifyCode !== void 0 && verifyCode !== "") query.set("verify_code", verifyCode);
|
|
8990
|
+
let res;
|
|
8991
|
+
try {
|
|
8992
|
+
res = await fetch(`${baseUrl}/ilink/bot/get_qrcode_status?${query.toString()}`, {
|
|
8993
|
+
headers: headers(),
|
|
8994
|
+
signal: AbortSignal.timeout(POLL_TIMEOUT_MS)
|
|
8995
|
+
});
|
|
8996
|
+
} catch (err) {
|
|
8997
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
8998
|
+
return { status: "pending" };
|
|
8999
|
+
}
|
|
9000
|
+
throw new Error(`WeChat scan request failed: ${wechatFetchErrorText(err)}`);
|
|
9001
|
+
}
|
|
9002
|
+
if (!res.ok) throw new Error(`WeChat scan request failed: HTTP ${res.status}`);
|
|
9003
|
+
const body = await res.json().catch(() => null);
|
|
9004
|
+
const status = statusOf2(body?.status);
|
|
9005
|
+
const redirect = typeof body?.redirect_host === "string" && body.redirect_host !== "" ? { redirectHost: body.redirect_host } : {};
|
|
9006
|
+
if (status !== "completed") return { status, ...redirect };
|
|
9007
|
+
const botId = body?.ilink_bot_id;
|
|
9008
|
+
const botToken = body?.bot_token;
|
|
9009
|
+
if (typeof botId !== "string" || botId === "" || typeof botToken !== "string" || botToken === "") {
|
|
9010
|
+
throw new Error("WeChat reported the scan confirmed but returned no credentials");
|
|
9011
|
+
}
|
|
9012
|
+
return {
|
|
9013
|
+
status: "completed",
|
|
9014
|
+
bot: {
|
|
9015
|
+
botId,
|
|
9016
|
+
botToken,
|
|
9017
|
+
baseUrl: typeof body?.baseurl === "string" && body.baseurl !== "" ? body.baseurl : WECHAT_API_BASE,
|
|
9018
|
+
userId: typeof body?.ilink_user_id === "string" ? body.ilink_user_id : ""
|
|
9019
|
+
}
|
|
9020
|
+
};
|
|
7990
9021
|
}
|
|
7991
9022
|
};
|
|
7992
9023
|
}
|
|
7993
|
-
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
return `${QQ_SCAN_QR_PAGE}?task_id=${encodeURIComponent(taskId)}&source=${encodeURIComponent(source)}&_wv=2`;
|
|
7998
|
-
}
|
|
7999
|
-
function decryptQQBotSecret(keyBase64, encryptedBase64) {
|
|
8000
|
-
const key = Buffer.from(keyBase64, "base64");
|
|
8001
|
-
const payload = Buffer.from(encryptedBase64, "base64");
|
|
8002
|
-
if (key.length !== QQ_SCAN_KEY_BYTES) {
|
|
8003
|
-
throw new Error("QQ scan key is not a 32-byte AES key");
|
|
8004
|
-
}
|
|
8005
|
-
if (payload.length <= GCM_IV_BYTES + GCM_TAG_BYTES) {
|
|
8006
|
-
throw new Error("QQ scan payload is too short to contain an encrypted secret");
|
|
8007
|
-
}
|
|
8008
|
-
const iv = payload.subarray(0, GCM_IV_BYTES);
|
|
8009
|
-
const tag = payload.subarray(payload.length - GCM_TAG_BYTES);
|
|
8010
|
-
const ciphertext = payload.subarray(GCM_IV_BYTES, payload.length - GCM_TAG_BYTES);
|
|
8011
|
-
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
8012
|
-
decipher.setAuthTag(tag);
|
|
8013
|
-
let plaintext;
|
|
8014
|
-
try {
|
|
8015
|
-
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
8016
|
-
} catch {
|
|
8017
|
-
throw new Error("QQ scan result could not be decrypted");
|
|
8018
|
-
}
|
|
8019
|
-
const secret = plaintext.toString("utf8");
|
|
8020
|
-
if (secret === "") throw new Error("QQ scan result decrypted to an empty secret");
|
|
8021
|
-
return secret;
|
|
8022
|
-
}
|
|
8023
|
-
var QQ_SCAN_TASK_TTL_MS = 10 * 6e4;
|
|
8024
|
-
var QQ_SCAN_MAX_TASKS_PER_SESSION = 4;
|
|
8025
|
-
var QQ_SCAN_MAX_TASKS = 4096;
|
|
8026
|
-
var QQScanService = class {
|
|
9024
|
+
var WECHAT_SCAN_TASK_TTL_MS = 10 * 6e4;
|
|
9025
|
+
var WECHAT_SCAN_MAX_TASKS_PER_SESSION = 4;
|
|
9026
|
+
var WECHAT_SCAN_MAX_TASKS = 4096;
|
|
9027
|
+
var WeChatScanService = class {
|
|
8027
9028
|
constructor(transport, opts = {}) {
|
|
8028
9029
|
this.transport = transport;
|
|
8029
9030
|
this.now = opts.now ?? (() => Date.now());
|
|
@@ -8033,51 +9034,77 @@ var QQScanService = class {
|
|
|
8033
9034
|
now;
|
|
8034
9035
|
/** The self-re-arming sweep, alive only while a task is remembered (see `armSweep`). */
|
|
8035
9036
|
sweepTimer = null;
|
|
8036
|
-
/** Starts a scan: a fresh
|
|
9037
|
+
/** Starts a scan: a fresh code, a task registered under a handle of this module's own. */
|
|
8037
9038
|
async start(sessionId) {
|
|
8038
9039
|
this.sweep();
|
|
8039
|
-
const
|
|
8040
|
-
const taskId = await this.transport.createBindTask(key);
|
|
9040
|
+
const { qrcode, qrUrl } = await this.transport.createQrCode();
|
|
8041
9041
|
this.evictOwn(sessionId);
|
|
8042
|
-
|
|
9042
|
+
const taskId = randomBytes4(16).toString("hex");
|
|
9043
|
+
this.tasks.set(taskId, {
|
|
9044
|
+
qrcode,
|
|
9045
|
+
sessionId,
|
|
9046
|
+
createdAt: this.now(),
|
|
9047
|
+
polling: false,
|
|
9048
|
+
baseUrl: WECHAT_API_BASE
|
|
9049
|
+
});
|
|
8043
9050
|
this.armSweep();
|
|
8044
|
-
return { taskId, qrUrl
|
|
9051
|
+
return { taskId, qrUrl, pollMs: WECHAT_SCAN_POLL_MS };
|
|
8045
9052
|
}
|
|
8046
9053
|
/**
|
|
8047
|
-
*
|
|
8048
|
-
*
|
|
8049
|
-
*
|
|
9054
|
+
* Records the pairing code the person read off their phone. It rides the NEXT poll rather
|
|
9055
|
+
* than a request of its own: the platform takes it as a parameter of the status call, and
|
|
9056
|
+
* there is no separate submit.
|
|
9057
|
+
*
|
|
9058
|
+
* Returns false for a task this Session did not start, which the caller answers with the
|
|
9059
|
+
* same 404 an unknown poll gets.
|
|
9060
|
+
*/
|
|
9061
|
+
submitVerifyCode(sessionId, taskId, verifyCode) {
|
|
9062
|
+
const task = this.tasks.get(taskId);
|
|
9063
|
+
if (task === void 0 || task.sessionId !== sessionId) return false;
|
|
9064
|
+
task.verifyCode = verifyCode;
|
|
9065
|
+
return true;
|
|
9066
|
+
}
|
|
9067
|
+
/**
|
|
9068
|
+
* Polls one task. Returns null when the task id is not one this Session started — an
|
|
9069
|
+
* unknown id, another Session's, or one already resolved — which the caller answers 404,
|
|
9070
|
+
* and which is also what a replay gets.
|
|
8050
9071
|
*/
|
|
8051
9072
|
async poll(sessionId, taskId) {
|
|
8052
9073
|
const task = this.tasks.get(taskId);
|
|
8053
|
-
if (task === void 0 || task.sessionId !== sessionId
|
|
8054
|
-
task.
|
|
8055
|
-
if (this.now() - task.createdAt > QQ_SCAN_TASK_TTL_MS) {
|
|
9074
|
+
if (task === void 0 || task.sessionId !== sessionId) return null;
|
|
9075
|
+
if (this.now() - task.createdAt > WECHAT_SCAN_TASK_TTL_MS) {
|
|
8056
9076
|
this.tasks.delete(taskId);
|
|
8057
9077
|
return { status: "expired" };
|
|
8058
9078
|
}
|
|
9079
|
+
if (task.polling) return { status: "pending" };
|
|
9080
|
+
task.polling = true;
|
|
8059
9081
|
let result;
|
|
8060
9082
|
try {
|
|
8061
|
-
result = await this.transport.
|
|
9083
|
+
result = await this.transport.pollQrStatus({
|
|
9084
|
+
baseUrl: task.baseUrl,
|
|
9085
|
+
qrcode: task.qrcode,
|
|
9086
|
+
...task.verifyCode !== void 0 ? { verifyCode: task.verifyCode } : {}
|
|
9087
|
+
});
|
|
8062
9088
|
} catch (err) {
|
|
8063
|
-
task.
|
|
9089
|
+
task.polling = false;
|
|
8064
9090
|
throw err;
|
|
9091
|
+
} finally {
|
|
9092
|
+
task.polling = false;
|
|
8065
9093
|
}
|
|
8066
|
-
if (result.
|
|
8067
|
-
|
|
8068
|
-
|
|
9094
|
+
if (result.redirectHost !== void 0) task.baseUrl = `https://${result.redirectHost}`;
|
|
9095
|
+
if (result.status === "scanned" && task.verifyCode !== void 0) {
|
|
9096
|
+
task.verifyCode = void 0;
|
|
8069
9097
|
}
|
|
8070
|
-
if (result.status
|
|
8071
|
-
|
|
9098
|
+
if (result.status === "expired" || result.status === "blocked" || result.status === "already_bound") {
|
|
9099
|
+
this.tasks.delete(taskId);
|
|
8072
9100
|
return { status: result.status };
|
|
8073
9101
|
}
|
|
9102
|
+
if (result.status !== "completed") return { status: result.status };
|
|
8074
9103
|
this.tasks.delete(taskId);
|
|
8075
|
-
|
|
8076
|
-
|
|
8077
|
-
throw new Error("QQ reported the scan complete but returned no bot");
|
|
9104
|
+
if (result.bot === void 0) {
|
|
9105
|
+
throw new Error("WeChat reported the scan confirmed but returned no credentials");
|
|
8078
9106
|
}
|
|
8079
|
-
|
|
8080
|
-
return { status: "completed", bot: { appId: bot.appId, appSecret } };
|
|
9107
|
+
return { status: "completed", bot: result.bot };
|
|
8081
9108
|
}
|
|
8082
9109
|
/** Drops a task the user walked away from (the editor's cancel, and its unmount). */
|
|
8083
9110
|
cancel(sessionId, taskId) {
|
|
@@ -8089,37 +9116,31 @@ var QQScanService = class {
|
|
|
8089
9116
|
if (task.sessionId === sessionId) this.tasks.delete(taskId);
|
|
8090
9117
|
}
|
|
8091
9118
|
}
|
|
8092
|
-
/**
|
|
8093
|
-
* Drops this Session's oldest tasks until one more fits under its own ceiling. Scoped to
|
|
8094
|
-
* the caller: the whole point of the per-Session ceiling is that filling it takes a code
|
|
8095
|
-
* away from nobody else.
|
|
8096
|
-
*/
|
|
9119
|
+
/** Drops this Session's oldest tasks until one more fits under its own ceiling. */
|
|
8097
9120
|
evictOwn(sessionId) {
|
|
8098
9121
|
const own = [...this.tasks].filter(([, task]) => task.sessionId === sessionId);
|
|
8099
|
-
if (own.length <
|
|
9122
|
+
if (own.length < WECHAT_SCAN_MAX_TASKS_PER_SESSION) return;
|
|
8100
9123
|
own.sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
8101
|
-
for (const [taskId] of own.slice(0, own.length -
|
|
9124
|
+
for (const [taskId] of own.slice(0, own.length - WECHAT_SCAN_MAX_TASKS_PER_SESSION + 1)) {
|
|
8102
9125
|
this.tasks.delete(taskId);
|
|
8103
9126
|
}
|
|
8104
9127
|
}
|
|
8105
9128
|
/** Drops expired tasks, then the oldest ones if the table is still over its backstop. */
|
|
8106
9129
|
sweep() {
|
|
8107
|
-
const cutoff = this.now() -
|
|
9130
|
+
const cutoff = this.now() - WECHAT_SCAN_TASK_TTL_MS;
|
|
8108
9131
|
for (const [taskId, task] of this.tasks) {
|
|
8109
9132
|
if (task.createdAt <= cutoff) this.tasks.delete(taskId);
|
|
8110
9133
|
}
|
|
8111
|
-
if (this.tasks.size <
|
|
9134
|
+
if (this.tasks.size < WECHAT_SCAN_MAX_TASKS) return;
|
|
8112
9135
|
const byAge = [...this.tasks].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
8113
|
-
for (const [taskId] of byAge.slice(0, this.tasks.size -
|
|
9136
|
+
for (const [taskId] of byAge.slice(0, this.tasks.size - WECHAT_SCAN_MAX_TASKS + 1)) {
|
|
8114
9137
|
this.tasks.delete(taskId);
|
|
8115
9138
|
}
|
|
8116
9139
|
}
|
|
8117
9140
|
/**
|
|
8118
9141
|
* Keeps one unref'd timer alive while any task is remembered, so a scan the user walked
|
|
8119
|
-
* away from stops holding its
|
|
8120
|
-
* happens to start the next scan — which may be never
|
|
8121
|
-
* outcome this module is shaped to avoid. It re-arms only while the table is non-empty,
|
|
8122
|
-
* and unref'd it is never the reason a process stays alive.
|
|
9142
|
+
* away from stops holding its handle roughly at the TTL instead of at whenever somebody
|
|
9143
|
+
* happens to start the next scan — which may be never.
|
|
8123
9144
|
*/
|
|
8124
9145
|
armSweep() {
|
|
8125
9146
|
if (this.sweepTimer !== null || this.tasks.size === 0) return;
|
|
@@ -8127,7 +9148,7 @@ var QQScanService = class {
|
|
|
8127
9148
|
this.sweepTimer = null;
|
|
8128
9149
|
this.sweep();
|
|
8129
9150
|
this.armSweep();
|
|
8130
|
-
},
|
|
9151
|
+
}, WECHAT_SCAN_TASK_TTL_MS);
|
|
8131
9152
|
timer.unref();
|
|
8132
9153
|
this.sweepTimer = timer;
|
|
8133
9154
|
}
|
|
@@ -8267,7 +9288,7 @@ function isAttachmentLine(line) {
|
|
|
8267
9288
|
}
|
|
8268
9289
|
|
|
8269
9290
|
// src/services/ids.ts
|
|
8270
|
-
import { randomBytes as
|
|
9291
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
8271
9292
|
var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
|
|
8272
9293
|
var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
|
|
8273
9294
|
var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
|
|
@@ -8356,9 +9377,9 @@ var AdminService = class {
|
|
|
8356
9377
|
};
|
|
8357
9378
|
|
|
8358
9379
|
// src/services/desktop-service.ts
|
|
8359
|
-
import { createHash as
|
|
9380
|
+
import { createHash as createHash3, timingSafeEqual } from "crypto";
|
|
8360
9381
|
function digest(value) {
|
|
8361
|
-
return
|
|
9382
|
+
return createHash3("sha256").update(value).digest();
|
|
8362
9383
|
}
|
|
8363
9384
|
var DesktopService = class {
|
|
8364
9385
|
tokenDigest;
|
|
@@ -8744,6 +9765,24 @@ function asArray(v) {
|
|
|
8744
9765
|
function optNum(v) {
|
|
8745
9766
|
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
8746
9767
|
}
|
|
9768
|
+
function tieredRates(provider, modelId, rates) {
|
|
9769
|
+
const entry = catalogEntryFor(provider, modelId);
|
|
9770
|
+
const schedule = entry?.offPeakDiscount;
|
|
9771
|
+
if (schedule === void 0 || entry?.pricing === void 0)
|
|
9772
|
+
return { peak: rates, offPeak: rates };
|
|
9773
|
+
const peak = entry.pricing;
|
|
9774
|
+
const untouched = rates.cacheRead === peak.cache_read && rates.cacheWrite === peak.cache_write && rates.output === peak.output;
|
|
9775
|
+
if (!untouched) return { peak: rates, offPeak: rates };
|
|
9776
|
+
const off = (v) => Math.round(v * (1 - schedule.rate) * 1e6) / 1e6;
|
|
9777
|
+
return {
|
|
9778
|
+
peak: rates,
|
|
9779
|
+
offPeak: {
|
|
9780
|
+
cacheRead: off(rates.cacheRead),
|
|
9781
|
+
cacheWrite: off(rates.cacheWrite),
|
|
9782
|
+
output: off(rates.output)
|
|
9783
|
+
}
|
|
9784
|
+
};
|
|
9785
|
+
}
|
|
8747
9786
|
function optStr(v) {
|
|
8748
9787
|
return typeof v === "string" && v !== "" ? v : void 0;
|
|
8749
9788
|
}
|
|
@@ -8971,14 +10010,14 @@ var ProjectConfigService = class {
|
|
|
8971
10010
|
*/
|
|
8972
10011
|
async setChatDefaults(projectId, req) {
|
|
8973
10012
|
const raw = await this.readRaw(projectId);
|
|
8974
|
-
const
|
|
10013
|
+
const block5 = {
|
|
8975
10014
|
...req.agentId !== void 0 ? { agent_id: req.agentId } : {},
|
|
8976
10015
|
...req.workspace !== void 0 ? { workspace: req.workspace } : {},
|
|
8977
10016
|
...req.approvalMode !== void 0 ? { approval_mode: req.approvalMode } : {},
|
|
8978
10017
|
...req.thinkingLevel !== void 0 ? { thinking_level: req.thinkingLevel } : {}
|
|
8979
10018
|
};
|
|
8980
10019
|
const next = { ...raw };
|
|
8981
|
-
if (Object.keys(
|
|
10020
|
+
if (Object.keys(block5).length > 0) next.default_chat = block5;
|
|
8982
10021
|
else delete next.default_chat;
|
|
8983
10022
|
await this.writeRaw(projectId, next);
|
|
8984
10023
|
return this.getChatDefaults(projectId);
|
|
@@ -9010,15 +10049,15 @@ var ProjectConfigService = class {
|
|
|
9010
10049
|
*/
|
|
9011
10050
|
async setCommandPolicy(projectId, req) {
|
|
9012
10051
|
const raw = await this.readRaw(projectId);
|
|
9013
|
-
const
|
|
9014
|
-
if (req.enabled === false)
|
|
9015
|
-
|
|
10052
|
+
const block5 = {};
|
|
10053
|
+
if (req.enabled === false) block5.enabled = false;
|
|
10054
|
+
block5.rules = req.rules.map((r) => ({
|
|
9016
10055
|
name: r.name,
|
|
9017
10056
|
pattern: r.pattern,
|
|
9018
10057
|
...r.description !== void 0 && r.description !== "" ? { description: r.description } : {},
|
|
9019
10058
|
...r.enabled === false ? { enabled: false } : {}
|
|
9020
10059
|
}));
|
|
9021
|
-
await this.writeRaw(projectId, { ...raw, command_policy:
|
|
10060
|
+
await this.writeRaw(projectId, { ...raw, command_policy: block5 });
|
|
9022
10061
|
return this.getCommandPolicy(projectId);
|
|
9023
10062
|
}
|
|
9024
10063
|
/** Pricing lookup for usage-recorder: the current pricing for this paired reference (undefined if none -> cost is NULL). */
|
|
@@ -9032,7 +10071,8 @@ var ProjectConfigService = class {
|
|
|
9032
10071
|
if (cacheRead === void 0 && cacheWrite === void 0 && output === void 0) {
|
|
9033
10072
|
return void 0;
|
|
9034
10073
|
}
|
|
9035
|
-
|
|
10074
|
+
const rates = { cacheRead: cacheRead ?? 0, cacheWrite: cacheWrite ?? 0, output: output ?? 0 };
|
|
10075
|
+
return tieredRates(provider, modelId, rates);
|
|
9036
10076
|
}
|
|
9037
10077
|
/**
|
|
9038
10078
|
* Model connectivity test: the model reference `(provider, modelId)` is submitted
|
|
@@ -9263,7 +10303,7 @@ var ProjectConfigService = class {
|
|
|
9263
10303
|
const vision = typeof m.vision === "boolean" ? m.vision : cat?.supportsVision;
|
|
9264
10304
|
const maxTokens = optNum(m.max_tokens);
|
|
9265
10305
|
const fastMode = m.fast_mode === true ? true : void 0;
|
|
9266
|
-
const displayName = optStr(m.display_name) ?? cat?.displayName;
|
|
10306
|
+
const displayName = m.display_name === "" ? void 0 : optStr(m.display_name) ?? cat?.displayName;
|
|
9267
10307
|
const apiKey = optStr(m.api_key);
|
|
9268
10308
|
const credBaseUrl = optStr(m.base_url);
|
|
9269
10309
|
const createdAt = optStr(m.created_at);
|
|
@@ -9348,6 +10388,8 @@ var ProjectConfigService = class {
|
|
|
9348
10388
|
const catNew = catalogEntryFor(entry.provider, entry.modelId);
|
|
9349
10389
|
if (entry.displayName && entry.displayName !== catNew?.displayName) {
|
|
9350
10390
|
next2.display_name = entry.displayName;
|
|
10391
|
+
} else if (!entry.displayName && catNew?.displayName !== void 0) {
|
|
10392
|
+
next2.display_name = "";
|
|
9351
10393
|
}
|
|
9352
10394
|
if (entry.contextWindow !== void 0) next2.context_window = entry.contextWindow;
|
|
9353
10395
|
if (entry.clientType) next2.client_type = canonicalClientType(entry.clientType);
|
|
@@ -11042,7 +12084,7 @@ var BenchmarkService = class {
|
|
|
11042
12084
|
};
|
|
11043
12085
|
|
|
11044
12086
|
// src/services/snapshot-service.ts
|
|
11045
|
-
import { randomBytes as
|
|
12087
|
+
import { randomBytes as randomBytes6 } from "crypto";
|
|
11046
12088
|
import fs14 from "fs/promises";
|
|
11047
12089
|
import path12 from "path";
|
|
11048
12090
|
import * as tar from "tar";
|
|
@@ -11088,7 +12130,7 @@ var SnapshotService = class {
|
|
|
11088
12130
|
} catch {
|
|
11089
12131
|
}
|
|
11090
12132
|
await fs14.mkdir(dir, { recursive: true });
|
|
11091
|
-
const tmp = `${file}.tmp-${
|
|
12133
|
+
const tmp = `${file}.tmp-${randomBytes6(4).toString("hex")}`;
|
|
11092
12134
|
await tar.create(
|
|
11093
12135
|
{
|
|
11094
12136
|
gzip: true,
|
|
@@ -11121,7 +12163,7 @@ var SnapshotService = class {
|
|
|
11121
12163
|
async importArchive(projectId, agentId, archive, opts) {
|
|
11122
12164
|
const current2 = await this.currentVersion(projectId, agentId);
|
|
11123
12165
|
const base = agentDir2(this.root, projectId, agentId);
|
|
11124
|
-
const staging = path12.join(base, `.import-${
|
|
12166
|
+
const staging = path12.join(base, `.import-${randomBytes6(6).toString("hex")}`);
|
|
11125
12167
|
await fs14.mkdir(staging, { recursive: true });
|
|
11126
12168
|
try {
|
|
11127
12169
|
const archiveFile = path12.join(staging, "archive.tar.gz");
|
|
@@ -11188,7 +12230,7 @@ var SnapshotService = class {
|
|
|
11188
12230
|
};
|
|
11189
12231
|
|
|
11190
12232
|
// src/services/model-oauth-service.ts
|
|
11191
|
-
import { createHash as
|
|
12233
|
+
import { createHash as createHash4, randomBytes as randomBytes7, randomUUID as randomUUID4 } from "crypto";
|
|
11192
12234
|
import { APP_URL, providerInfo as providerInfo2 } from "@prismshadow/penguin-core/model-catalog";
|
|
11193
12235
|
var FLOW_TTL_MS = 10 * 60 * 1e3;
|
|
11194
12236
|
var MAX_FLOWS_PER_OWNER = 8;
|
|
@@ -11196,7 +12238,7 @@ function createVerifier() {
|
|
|
11196
12238
|
return `${randomUUID4()}${randomUUID4()}`;
|
|
11197
12239
|
}
|
|
11198
12240
|
function codeChallenge(verifier) {
|
|
11199
|
-
return
|
|
12241
|
+
return createHash4("sha256").update(verifier, "utf8").digest("base64url");
|
|
11200
12242
|
}
|
|
11201
12243
|
function buildAuthorizeUrl(input) {
|
|
11202
12244
|
const params = new URLSearchParams();
|
|
@@ -11262,7 +12304,7 @@ var ModelOAuthService = class {
|
|
|
11262
12304
|
}
|
|
11263
12305
|
this.sweep();
|
|
11264
12306
|
this.evictOldest(input.userId, input.projectId);
|
|
11265
|
-
const flowId =
|
|
12307
|
+
const flowId = randomBytes7(32).toString("base64url");
|
|
11266
12308
|
const verifier = createVerifier();
|
|
11267
12309
|
const createdAt = this.now();
|
|
11268
12310
|
const callbackUrl = input.mode === "callback" ? `${input.callbackOrigin}/api/projects/${encodeURIComponent(input.projectId)}/model-oauth/callback?flow=${encodeURIComponent(flowId)}` : void 0;
|
|
@@ -13864,8 +14906,10 @@ var UpdateCheckService = class {
|
|
|
13864
14906
|
};
|
|
13865
14907
|
|
|
13866
14908
|
// src/services/usage-service.ts
|
|
14909
|
+
import { offPeakScheduledRefs } from "@prismshadow/penguin-core/model-catalog";
|
|
13867
14910
|
var ERROR_RECENT_N = 10;
|
|
13868
|
-
function costOf(sums,
|
|
14911
|
+
function costOf(sums, tiered) {
|
|
14912
|
+
const rates = sums.peak ? tiered.peak : tiered.offPeak;
|
|
13869
14913
|
return (sums.cacheRead * rates.cacheRead + sums.cacheWrite * rates.cacheWrite + sums.output * rates.output) / 1e6;
|
|
13870
14914
|
}
|
|
13871
14915
|
function refKey2(provider, modelId) {
|
|
@@ -13882,6 +14926,22 @@ var UsageService = class {
|
|
|
13882
14926
|
errors;
|
|
13883
14927
|
lookupPricing;
|
|
13884
14928
|
now;
|
|
14929
|
+
/**
|
|
14930
|
+
* The catalog's time-based schedules, as the aggregations want them.
|
|
14931
|
+
*
|
|
14932
|
+
* Split by the SCHEDULE, not by whether this Project is on the catalog's price: a Project
|
|
14933
|
+
* that edited the price gets one rate for both halves from {@link lookupPricing}, so the extra
|
|
14934
|
+
* dimension costs a row and changes no number. Doing it the other way round would need the
|
|
14935
|
+
* config read before the query that discovers which references occur.
|
|
14936
|
+
*/
|
|
14937
|
+
tiers() {
|
|
14938
|
+
return offPeakScheduledRefs().map(({ schedule, refs }) => ({
|
|
14939
|
+
refs,
|
|
14940
|
+
utcOffsetMinutes: schedule.utcOffsetMinutes,
|
|
14941
|
+
peakDays: schedule.peakDays,
|
|
14942
|
+
peakHours: schedule.peakHours
|
|
14943
|
+
}));
|
|
14944
|
+
}
|
|
13885
14945
|
async query(projectId, q) {
|
|
13886
14946
|
const today = formatLocalDate(this.now());
|
|
13887
14947
|
const base = {};
|
|
@@ -13897,13 +14957,20 @@ var UsageService = class {
|
|
|
13897
14957
|
...q.fromTs !== void 0 ? { fromTs: q.fromTs } : {},
|
|
13898
14958
|
...q.toTs !== void 0 ? { toTs: q.toTs } : {}
|
|
13899
14959
|
};
|
|
13900
|
-
const
|
|
13901
|
-
const
|
|
13902
|
-
const
|
|
13903
|
-
|
|
13904
|
-
|
|
13905
|
-
|
|
13906
|
-
|
|
14960
|
+
const tiers = this.tiers();
|
|
14961
|
+
const todayRows = this.usage.bucketByModel(projectId, win(today, today), tiers);
|
|
14962
|
+
const last7dRows = this.usage.bucketByModel(
|
|
14963
|
+
projectId,
|
|
14964
|
+
win(localDateMinusDays(this.now(), 6)),
|
|
14965
|
+
tiers
|
|
14966
|
+
);
|
|
14967
|
+
const totalRows = this.usage.bucketByModel(projectId, { ...win(q.from, q.to), ...ts }, tiers);
|
|
14968
|
+
const groupRows = this.usage.groupsByModel(
|
|
14969
|
+
projectId,
|
|
14970
|
+
q.groupBy,
|
|
14971
|
+
{ ...win(q.from, q.to), ...ts },
|
|
14972
|
+
tiers
|
|
14973
|
+
);
|
|
13907
14974
|
const granularity = q.granularity ?? "day";
|
|
13908
14975
|
const seriesFrom = q.from ?? localDateMinusDays(this.now(), 29);
|
|
13909
14976
|
const seriesTo = q.to ?? today;
|
|
@@ -13923,10 +14990,12 @@ var UsageService = class {
|
|
|
13923
14990
|
"Date range too wide for this granularity; narrow the range or coarsen the granularity."
|
|
13924
14991
|
);
|
|
13925
14992
|
}
|
|
13926
|
-
const seriesRows = this.usage.seriesByModel(
|
|
13927
|
-
|
|
13928
|
-
|
|
13929
|
-
|
|
14993
|
+
const seriesRows = this.usage.seriesByModel(
|
|
14994
|
+
projectId,
|
|
14995
|
+
granularity,
|
|
14996
|
+
{ ...win(seriesFrom, seriesTo), ...ts },
|
|
14997
|
+
tiers
|
|
14998
|
+
);
|
|
13930
14999
|
const agentFree = {
|
|
13931
15000
|
...q.provider !== void 0 ? { provider: q.provider } : {},
|
|
13932
15001
|
...q.modelId !== void 0 ? { modelId: q.modelId } : {},
|
|
@@ -14006,16 +15075,55 @@ var UsageService = class {
|
|
|
14006
15075
|
total: this.errors.summary(projectId, f).total
|
|
14007
15076
|
};
|
|
14008
15077
|
}
|
|
15078
|
+
/**
|
|
15079
|
+
* Empties the error table for the filter the panel is showing, and answers how many rows went.
|
|
15080
|
+
*
|
|
15081
|
+
* The filter is the same date range and Agent the dashboard and the paged route take, so a
|
|
15082
|
+
* clear removes exactly the set the caller was looking at and never a row outside it: a
|
|
15083
|
+
* reader who has narrowed to one Agent and one week does not lose the rest of the year to a
|
|
15084
|
+
* button that said "clear".
|
|
15085
|
+
*
|
|
15086
|
+
* Unattributed rows are outside every clear (see ErrorsRepo.deleteFiltered): they carry no
|
|
15087
|
+
* Project, so they are not this Project's to remove, and the delete's scope is therefore
|
|
15088
|
+
* strictly narrower than what any caller — admin included — is allowed to read.
|
|
15089
|
+
*/
|
|
15090
|
+
clearErrors(projectId, q) {
|
|
15091
|
+
return this.errors.deleteFiltered(projectId, {
|
|
15092
|
+
...q.agentId !== void 0 ? { agentId: q.agentId } : {},
|
|
15093
|
+
...q.from !== void 0 ? { from: q.from } : {},
|
|
15094
|
+
...q.to !== void 0 ? { to: q.to } : {}
|
|
15095
|
+
});
|
|
15096
|
+
}
|
|
14009
15097
|
/** Error statistics: summary info (total / unexpected / most common error code) + the last N entries, all filtered by the selected range. */
|
|
14010
15098
|
foldErrors(projectId, f) {
|
|
14011
15099
|
const { total, unexpected } = this.errors.summary(projectId, f);
|
|
14012
15100
|
return {
|
|
14013
15101
|
total,
|
|
15102
|
+
// Counted the way deleteFiltered selects — without the unattributed rows an admin's read
|
|
15103
|
+
// includes — so the clear confirmation states what will really go rather than what is on
|
|
15104
|
+
// screen. Identical to `total` for everyone whose read did not include them.
|
|
15105
|
+
clearable: f.includeGlobal === true ? this.errors.summary(projectId, { ...f, includeGlobal: false }).total : total,
|
|
14014
15106
|
unexpected,
|
|
14015
15107
|
topCode: this.errors.topCode(projectId, f),
|
|
14016
15108
|
recent: this.errors.recent(projectId, f, ERROR_RECENT_N)
|
|
14017
15109
|
};
|
|
14018
15110
|
}
|
|
15111
|
+
/**
|
|
15112
|
+
* Lifetime Token totals per Model — one grouped scan of `usage_records`, no filters. The
|
|
15113
|
+
* models page shows each configured model what it has actually spent, and that figure is not
|
|
15114
|
+
* scoped to a range the way the cost center's is; a model with no records is simply absent,
|
|
15115
|
+
* so the caller renders nothing rather than a zero.
|
|
15116
|
+
*/
|
|
15117
|
+
modelTotals(projectId) {
|
|
15118
|
+
return {
|
|
15119
|
+
totals: this.usage.bucketByModel(projectId).map((r) => ({
|
|
15120
|
+
provider: r.provider,
|
|
15121
|
+
modelId: r.modelId,
|
|
15122
|
+
tokens: r.total,
|
|
15123
|
+
requests: r.requests
|
|
15124
|
+
}))
|
|
15125
|
+
};
|
|
15126
|
+
}
|
|
14019
15127
|
foldBucket(rows, rates) {
|
|
14020
15128
|
let total = 0;
|
|
14021
15129
|
let requests = 0;
|
|
@@ -16009,20 +17117,6 @@ var HmrHost = class {
|
|
|
16009
17117
|
return this.webMem !== null ? { kind: "mem", files: this.webMem } : null;
|
|
16010
17118
|
}
|
|
16011
17119
|
// -- Persistence ----------------------------------------------------------
|
|
16012
|
-
/**
|
|
16013
|
-
* Content-addresses the cli bundle (never imported — just stored, for packages/cli's
|
|
16014
|
-
* own loader to pick up) and the web gzip artifact next to the platform bundle the
|
|
16015
|
-
* boot already stored (its CODE only — see the module doc: state is never
|
|
16016
|
-
* persisted), then flips harness.json ONCE — `platform`, `cli`, and `web` all land in the SAME atomic
|
|
16017
|
-
* rename, never three separate commits that could leave one pointer ahead of the
|
|
16018
|
-
* others. `platform.bundle` and `cli.bundle` are genuinely independent files
|
|
16019
|
-
* (distinct content, distinct sha) rather than the same physical bundle under two
|
|
16020
|
-
* manifest keys.
|
|
16021
|
-
*
|
|
16022
|
-
* Returns whether the commit succeeded — the caller surfaces this to clients
|
|
16023
|
-
* (`persisted` in UpgradeOutcome) so a live swap that could not be written to disk
|
|
16024
|
-
* is visibly at risk of reverting on the next restart, rather than silently ok.
|
|
16025
|
-
*/
|
|
16026
17120
|
/**
|
|
16027
17121
|
* Unpacks a push's assets under store/assets/<sha>/, content-addressed like every other
|
|
16028
17122
|
* artifact so an unchanged set reuses its directory. Modes are restored from the push's
|
|
@@ -16066,6 +17160,20 @@ var HmrHost = class {
|
|
|
16066
17160
|
assetsDir() {
|
|
16067
17161
|
return this.assets;
|
|
16068
17162
|
}
|
|
17163
|
+
/**
|
|
17164
|
+
* Content-addresses the cli bundle (never imported — just stored, for packages/cli's
|
|
17165
|
+
* own loader to pick up) and the web gzip artifact next to the platform bundle the
|
|
17166
|
+
* boot already stored (its CODE only — see the module doc: state is never
|
|
17167
|
+
* persisted), then flips harness.json ONCE — `platform`, `cli`, and `web` all land in the SAME atomic
|
|
17168
|
+
* rename, never three separate commits that could leave one pointer ahead of the
|
|
17169
|
+
* others. `platform.bundle` and `cli.bundle` are genuinely independent files
|
|
17170
|
+
* (distinct content, distinct sha) rather than the same physical bundle under two
|
|
17171
|
+
* manifest keys.
|
|
17172
|
+
*
|
|
17173
|
+
* Returns whether the commit succeeded — the caller surfaces this to clients
|
|
17174
|
+
* (`persisted` in UpgradeOutcome) so a live swap that could not be written to disk
|
|
17175
|
+
* is visibly at risk of reverting on the next restart, rather than silently ok.
|
|
17176
|
+
*/
|
|
16069
17177
|
async persistVersion(platformSha, cliContent, webGz, webSha, assetsDir, source) {
|
|
16070
17178
|
try {
|
|
16071
17179
|
const cliSha = sha1(cliContent).slice(0, 16);
|
|
@@ -16102,11 +17210,11 @@ var HmrHost = class {
|
|
|
16102
17210
|
await this.pruneStore(manifest);
|
|
16103
17211
|
}
|
|
16104
17212
|
/**
|
|
16105
|
-
* Store GC: keep
|
|
16106
|
-
*
|
|
16107
|
-
* nothing referenced can be pruned. `platform`, `cli`,
|
|
16108
|
-
* independent subtrees
|
|
16109
|
-
* gets its own pass.
|
|
17213
|
+
* Store GC: keep the STORE_KEEP most recently written of each artifact, plus the one
|
|
17214
|
+
* harness.json references, which is kept whether or not recency would have. Best-effort;
|
|
17215
|
+
* ordered after the manifest flip so nothing referenced can be pruned. `platform`, `cli`,
|
|
17216
|
+
* `web` and the assets directories are independent subtrees (no shared file to piggyback a
|
|
17217
|
+
* sweep on), so each gets its own pass.
|
|
16110
17218
|
*/
|
|
16111
17219
|
async pruneStore(manifest) {
|
|
16112
17220
|
const keepNewest = async (dir, keys, referenced, remove) => {
|
|
@@ -16364,12 +17472,12 @@ function platformHttpSeam(hmr) {
|
|
|
16364
17472
|
}
|
|
16365
17473
|
|
|
16366
17474
|
// src/services/preview-token.ts
|
|
16367
|
-
import { createHmac, randomBytes as
|
|
17475
|
+
import { createHmac, randomBytes as randomBytes8, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
16368
17476
|
var PREVIEW_TOKEN_TTL_MS = 10 * 60 * 1e3;
|
|
16369
17477
|
function b64url(buf) {
|
|
16370
17478
|
return buf.toString("base64url");
|
|
16371
17479
|
}
|
|
16372
|
-
function createPreviewTokenSigner(secret =
|
|
17480
|
+
function createPreviewTokenSigner(secret = randomBytes8(32)) {
|
|
16373
17481
|
const mac = (body) => createHmac("sha256", secret).update(body).digest();
|
|
16374
17482
|
return {
|
|
16375
17483
|
sign(payload) {
|
|
@@ -16717,6 +17825,30 @@ var GROUP_COLUMNS = {
|
|
|
16717
17825
|
model: "model_id",
|
|
16718
17826
|
session: "session_id"
|
|
16719
17827
|
};
|
|
17828
|
+
function peakExpr(tiers) {
|
|
17829
|
+
if (tiers.length === 0) return { sql: "1", params: {} };
|
|
17830
|
+
const params = {};
|
|
17831
|
+
const branches = [];
|
|
17832
|
+
tiers.forEach((tier, ti) => {
|
|
17833
|
+
if (tier.refs.length === 0 || tier.peakDays.length === 0) return;
|
|
17834
|
+
const shift = `'${tier.utcOffsetMinutes >= 0 ? "+" : "-"}${Math.abs(tier.utcOffsetMinutes)} minutes'`;
|
|
17835
|
+
const refs = tier.refs.map((ref, ri) => {
|
|
17836
|
+
const p = `t${ti}p${ri}`;
|
|
17837
|
+
const m = `t${ti}m${ri}`;
|
|
17838
|
+
params[p] = ref.provider;
|
|
17839
|
+
params[m] = ref.modelId;
|
|
17840
|
+
return `(provider = :${p} AND model_id = :${m})`;
|
|
17841
|
+
});
|
|
17842
|
+
const day = `CASE CAST(strftime('%w', ts, ${shift}) AS INTEGER) WHEN 0 THEN 7 ELSE CAST(strftime('%w', ts, ${shift}) AS INTEGER) END`;
|
|
17843
|
+
const hour = `CAST(strftime('%H', ts, ${shift}) AS INTEGER)`;
|
|
17844
|
+
const windows = tier.peakHours.map(([from, to]) => `(${hour} >= ${from} AND ${hour} < ${to})`);
|
|
17845
|
+
branches.push(
|
|
17846
|
+
`WHEN (${refs.join(" OR ")}) THEN (CASE WHEN ${day} IN (${tier.peakDays.join(", ")}) AND (${windows.join(" OR ")}) THEN 1 ELSE 0 END)`
|
|
17847
|
+
);
|
|
17848
|
+
});
|
|
17849
|
+
if (branches.length === 0) return { sql: "1", params: {} };
|
|
17850
|
+
return { sql: `CASE ${branches.join(" ")} ELSE 1 END`, params };
|
|
17851
|
+
}
|
|
16720
17852
|
var SUM_COLUMNS = `COALESCE(SUM(cache_read), 0) AS cache_read,
|
|
16721
17853
|
COALESCE(SUM(cache_write), 0) AS cache_write,
|
|
16722
17854
|
COALESCE(SUM(output), 0) AS output,
|
|
@@ -16726,6 +17858,7 @@ function toSums(r) {
|
|
|
16726
17858
|
return {
|
|
16727
17859
|
provider: r.provider,
|
|
16728
17860
|
modelId: r.model_id,
|
|
17861
|
+
peak: r.peak !== 0,
|
|
16729
17862
|
cacheRead: r.cache_read,
|
|
16730
17863
|
cacheWrite: r.cache_write,
|
|
16731
17864
|
output: r.output,
|
|
@@ -16794,25 +17927,30 @@ var UsageRepo = class {
|
|
|
16794
17927
|
}
|
|
16795
17928
|
return { where: conds.join(" AND "), params };
|
|
16796
17929
|
}
|
|
16797
|
-
/**
|
|
16798
|
-
|
|
17930
|
+
/**
|
|
17931
|
+
* Sums (broken down by paired reference, and by price tier where one applies): date range +
|
|
17932
|
+
* optional agent/model filter.
|
|
17933
|
+
*/
|
|
17934
|
+
bucketByModel(projectId, f = {}, tiers = []) {
|
|
16799
17935
|
const { where, params } = this.conds(projectId, f);
|
|
17936
|
+
const peak = peakExpr(tiers);
|
|
16800
17937
|
const rows = this.db.prepare(
|
|
16801
|
-
`SELECT provider, model_id, ${SUM_COLUMNS}
|
|
17938
|
+
`SELECT provider, model_id, ${peak.sql} AS peak, ${SUM_COLUMNS}
|
|
16802
17939
|
FROM usage_records WHERE ${where}
|
|
16803
|
-
GROUP BY provider, model_id`
|
|
16804
|
-
).all(params);
|
|
17940
|
+
GROUP BY provider, model_id, peak`
|
|
17941
|
+
).all({ ...params, ...peak.params });
|
|
16805
17942
|
return rows.map(toSums);
|
|
16806
17943
|
}
|
|
16807
17944
|
/** Grouped aggregation (group key x paired reference breakdown): date range + optional agent/model filter. */
|
|
16808
|
-
groupsByModel(projectId, groupBy, f = {}) {
|
|
17945
|
+
groupsByModel(projectId, groupBy, f = {}, tiers = []) {
|
|
16809
17946
|
const col = GROUP_COLUMNS[groupBy];
|
|
16810
17947
|
const { where, params } = this.conds(projectId, f);
|
|
17948
|
+
const peak = peakExpr(tiers);
|
|
16811
17949
|
const rows = this.db.prepare(
|
|
16812
|
-
`SELECT ${col} AS key, provider, model_id, ${SUM_COLUMNS}
|
|
17950
|
+
`SELECT ${col} AS key, provider, model_id, ${peak.sql} AS peak, ${SUM_COLUMNS}
|
|
16813
17951
|
FROM usage_records WHERE ${where}
|
|
16814
|
-
GROUP BY ${col}, provider, model_id`
|
|
16815
|
-
).all(params);
|
|
17952
|
+
GROUP BY ${col}, provider, model_id, peak`
|
|
17953
|
+
).all({ ...params, ...peak.params });
|
|
16816
17954
|
return rows.map((r) => ({ key: r.key, ...toSums(r) }));
|
|
16817
17955
|
}
|
|
16818
17956
|
/**
|
|
@@ -16821,16 +17959,17 @@ var UsageRepo = class {
|
|
|
16821
17959
|
* (requests / success rate / Token / cost) at the requested precision. The
|
|
16822
17960
|
* denominator excludes aborted.
|
|
16823
17961
|
*/
|
|
16824
|
-
seriesByModel(projectId, granularity, f = {}) {
|
|
17962
|
+
seriesByModel(projectId, granularity, f = {}, tiers = []) {
|
|
16825
17963
|
const expr = BUCKET_EXPRS[granularity];
|
|
16826
17964
|
const { where, params } = this.conds(projectId, f);
|
|
17965
|
+
const peak = peakExpr(tiers);
|
|
16827
17966
|
const rows = this.db.prepare(
|
|
16828
|
-
`SELECT ${expr} AS key, provider, model_id, ${SUM_COLUMNS},
|
|
17967
|
+
`SELECT ${expr} AS key, provider, model_id, ${peak.sql} AS peak, ${SUM_COLUMNS},
|
|
16829
17968
|
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed,
|
|
16830
17969
|
COALESCE(SUM(CASE WHEN status <> 'aborted' THEN 1 ELSE 0 END), 0) AS denominator
|
|
16831
17970
|
FROM usage_records WHERE ${where}
|
|
16832
|
-
GROUP BY key, provider, model_id`
|
|
16833
|
-
).all(params);
|
|
17971
|
+
GROUP BY key, provider, model_id, peak`
|
|
17972
|
+
).all({ ...params, ...peak.params });
|
|
16834
17973
|
return rows.map((r) => ({
|
|
16835
17974
|
key: r.key,
|
|
16836
17975
|
...toSums(r),
|
|
@@ -17524,13 +18663,13 @@ function parseMode(value) {
|
|
|
17524
18663
|
return value;
|
|
17525
18664
|
}
|
|
17526
18665
|
function resultPage(title, body) {
|
|
17527
|
-
const
|
|
18666
|
+
const esc5 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
17528
18667
|
return `<!doctype html>
|
|
17529
18668
|
<html lang="en">
|
|
17530
18669
|
<head>
|
|
17531
18670
|
<meta charset="utf-8">
|
|
17532
18671
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
17533
|
-
<title>${
|
|
18672
|
+
<title>${esc5(title)}</title>
|
|
17534
18673
|
<style>
|
|
17535
18674
|
:root { color-scheme: light dark; }
|
|
17536
18675
|
body {
|
|
@@ -17547,7 +18686,7 @@ p { margin: 0; color: #4b5563; }
|
|
|
17547
18686
|
}
|
|
17548
18687
|
</style>
|
|
17549
18688
|
</head>
|
|
17550
|
-
<body><main><h1>${
|
|
18689
|
+
<body><main><h1>${esc5(title)}</h1><p>${esc5(body)}</p></main></body>
|
|
17551
18690
|
</html>
|
|
17552
18691
|
`;
|
|
17553
18692
|
}
|
|
@@ -17652,6 +18791,7 @@ function modelOAuthRoutes(deps) {
|
|
|
17652
18791
|
return c.json({
|
|
17653
18792
|
status: state.status,
|
|
17654
18793
|
provider: state.provider,
|
|
18794
|
+
...state.applied !== void 0 ? { applied: state.applied } : {},
|
|
17655
18795
|
...state.error !== void 0 ? { error: state.error } : {}
|
|
17656
18796
|
});
|
|
17657
18797
|
});
|
|
@@ -18823,7 +19963,7 @@ function memoryRoutes(deps) {
|
|
|
18823
19963
|
}
|
|
18824
19964
|
|
|
18825
19965
|
// src/http/routes/schedules.ts
|
|
18826
|
-
import { createHash as
|
|
19966
|
+
import { createHash as createHash5 } from "crypto";
|
|
18827
19967
|
import { Hono as Hono19 } from "hono";
|
|
18828
19968
|
import { isValidId as isValidId4 } from "@prismshadow/penguin-core";
|
|
18829
19969
|
function parseUpsertBody(body) {
|
|
@@ -18858,7 +19998,7 @@ function nextFireAt(def, state, nowMs) {
|
|
|
18858
19998
|
const next = nextSlotAfter(def, nowMs);
|
|
18859
19999
|
return next !== null ? new Date(next).toISOString() : void 0;
|
|
18860
20000
|
}
|
|
18861
|
-
function
|
|
20001
|
+
function statusOf3(def, state, nowMs) {
|
|
18862
20002
|
if (state.invalidReason !== null) return "invalid";
|
|
18863
20003
|
if (def.periodMs === void 0 && state.firedOnce) return "done";
|
|
18864
20004
|
if (def.periodMs === void 0 && state.missed) return "missed";
|
|
@@ -18878,7 +20018,7 @@ function toItem(def, state, queued, nowMs) {
|
|
|
18878
20018
|
...def.workspace !== void 0 ? { workspace: def.workspace } : {},
|
|
18879
20019
|
...def.modelId !== void 0 ? { modelId: def.modelId } : {},
|
|
18880
20020
|
...def.provider !== void 0 ? { provider: def.provider } : {},
|
|
18881
|
-
status:
|
|
20021
|
+
status: statusOf3(def, state, nowMs),
|
|
18882
20022
|
...state.invalidReason !== null ? { invalidReason: state.invalidReason } : {},
|
|
18883
20023
|
...next !== void 0 ? { nextFireAt: next } : {},
|
|
18884
20024
|
...state.lastFiredAt !== null ? { lastFiredAt: state.lastFiredAt } : {},
|
|
@@ -18975,7 +20115,7 @@ async function upsert(deps, userId, projectId, agentId, name, body) {
|
|
|
18975
20115
|
agentId,
|
|
18976
20116
|
name,
|
|
18977
20117
|
startAtMs: parsed.def.startAtMs,
|
|
18978
|
-
defHash:
|
|
20118
|
+
defHash: createHash5("sha1").update(raw).digest("hex"),
|
|
18979
20119
|
creatorUserId: userId
|
|
18980
20120
|
});
|
|
18981
20121
|
await deps.scheduler.reconcileAgent(projectId, agentId);
|
|
@@ -19179,9 +20319,9 @@ async function collectSkillArchive(dir, name) {
|
|
|
19179
20319
|
return out;
|
|
19180
20320
|
}
|
|
19181
20321
|
function explicitSkillVersion(skillMd) {
|
|
19182
|
-
const
|
|
19183
|
-
if (
|
|
19184
|
-
for (const line of
|
|
20322
|
+
const block5 = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd.replace(/^\uFEFF/, ""))?.[1];
|
|
20323
|
+
if (block5 === void 0) return null;
|
|
20324
|
+
for (const line of block5.split(/\r?\n/)) {
|
|
19185
20325
|
const idx = line.indexOf(":");
|
|
19186
20326
|
if (idx <= 0 || line.slice(0, idx).trim() !== "version") continue;
|
|
19187
20327
|
const version = Number.parseInt(line.slice(idx + 1).trim(), 10);
|
|
@@ -19758,6 +20898,11 @@ function usageRoutes(deps) {
|
|
|
19758
20898
|
})
|
|
19759
20899
|
);
|
|
19760
20900
|
});
|
|
20901
|
+
app.get("/model-totals", (c) => {
|
|
20902
|
+
const projectId = requireValidId(c, "projectId");
|
|
20903
|
+
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
20904
|
+
return c.json(deps.usageService.modelTotals(projectId));
|
|
20905
|
+
});
|
|
19761
20906
|
app.get("/errors", (c) => {
|
|
19762
20907
|
const projectId = requireValidId(c, "projectId");
|
|
19763
20908
|
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
@@ -19783,11 +20928,69 @@ function usageRoutes(deps) {
|
|
|
19783
20928
|
})
|
|
19784
20929
|
);
|
|
19785
20930
|
});
|
|
20931
|
+
app.delete("/errors", (c) => {
|
|
20932
|
+
const projectId = requireValidId(c, "projectId");
|
|
20933
|
+
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
20934
|
+
const from = optionalDateParam(c.req.query("from"), "from");
|
|
20935
|
+
const to = optionalDateParam(c.req.query("to"), "to");
|
|
20936
|
+
const agentId = c.req.query("agentId");
|
|
20937
|
+
const deleted = deps.usageService.clearErrors(projectId, {
|
|
20938
|
+
...from !== void 0 ? { from } : {},
|
|
20939
|
+
...to !== void 0 ? { to } : {},
|
|
20940
|
+
...agentId !== void 0 && agentId !== "" ? { agentId } : {}
|
|
20941
|
+
});
|
|
20942
|
+
return c.json({ deleted });
|
|
20943
|
+
});
|
|
19786
20944
|
return app;
|
|
19787
20945
|
}
|
|
19788
20946
|
|
|
19789
20947
|
// src/http/routes/messaging.ts
|
|
19790
|
-
import { Hono as Hono29 } from "hono";
|
|
20948
|
+
import { Hono as Hono29 } from "hono";
|
|
20949
|
+
|
|
20950
|
+
// src/http/routes/messaging-channels.ts
|
|
20951
|
+
function commonBindingFields(row) {
|
|
20952
|
+
return {
|
|
20953
|
+
sessionId: row.sessionId,
|
|
20954
|
+
enabled: row.enabled,
|
|
20955
|
+
linePerMessage: row.linePerMessage,
|
|
20956
|
+
finalReplyOnly: row.finalReplyOnly,
|
|
20957
|
+
renderMarkdown: row.renderMarkdown,
|
|
20958
|
+
lastChatKnown: row.lastChatId !== null,
|
|
20959
|
+
createdAt: row.createdAt,
|
|
20960
|
+
updatedAt: row.updatedAt
|
|
20961
|
+
};
|
|
20962
|
+
}
|
|
20963
|
+
function maskedSecretField(key, secret) {
|
|
20964
|
+
return secret === "" ? {} : { [key]: maskApiKey(secret) };
|
|
20965
|
+
}
|
|
20966
|
+
function deliveryPatchOf(body) {
|
|
20967
|
+
const linePerMessage = optionalBoolean(body, "linePerMessage");
|
|
20968
|
+
const finalReplyOnly = optionalBoolean(body, "finalReplyOnly");
|
|
20969
|
+
const renderMarkdown = optionalBoolean(body, "renderMarkdown");
|
|
20970
|
+
return {
|
|
20971
|
+
...linePerMessage !== void 0 ? { linePerMessage } : {},
|
|
20972
|
+
...finalReplyOnly !== void 0 ? { finalReplyOnly } : {},
|
|
20973
|
+
...renderMarkdown !== void 0 ? { renderMarkdown } : {}
|
|
20974
|
+
};
|
|
20975
|
+
}
|
|
20976
|
+
function resolveSecret(args) {
|
|
20977
|
+
const typed = args.typed !== void 0 && args.typed !== "" ? args.typed : void 0;
|
|
20978
|
+
if (typed !== void 0) return { secret: typed, fromRequest: true };
|
|
20979
|
+
if (args.clear && args.existing !== null) {
|
|
20980
|
+
if (args.existing.enabled) {
|
|
20981
|
+
throw new HttpError(
|
|
20982
|
+
409,
|
|
20983
|
+
"messaging_disable_before_clear",
|
|
20984
|
+
"Disable the connection before clearing its credential."
|
|
20985
|
+
);
|
|
20986
|
+
}
|
|
20987
|
+
return { secret: "", fromRequest: false };
|
|
20988
|
+
}
|
|
20989
|
+
if (args.stored === "") throw new HttpError(400, args.requiredCode, args.requiredMessage);
|
|
20990
|
+
return { secret: args.stored, fromRequest: false };
|
|
20991
|
+
}
|
|
20992
|
+
|
|
20993
|
+
// src/http/routes/messaging.ts
|
|
19791
20994
|
function feishuFieldsOf(row) {
|
|
19792
20995
|
const { appId, appSecret, baseDomain } = row.config;
|
|
19793
20996
|
return {
|
|
@@ -19807,62 +21010,82 @@ function qqFieldsOf(row) {
|
|
|
19807
21010
|
appSecret: typeof appSecret === "string" ? appSecret : ""
|
|
19808
21011
|
};
|
|
19809
21012
|
}
|
|
19810
|
-
function
|
|
19811
|
-
const
|
|
21013
|
+
function wechatFieldsOf(row) {
|
|
21014
|
+
const { botId, botToken } = row.config;
|
|
19812
21015
|
return {
|
|
19813
|
-
|
|
19814
|
-
|
|
19815
|
-
appId: fields.appId,
|
|
19816
|
-
// A cleared/never-entered secret has no mask: the field's absence is what tells the
|
|
19817
|
-
// editor to render "not configured" instead of a masked placeholder.
|
|
19818
|
-
...fields.appSecret !== "" ? { appSecretMasked: maskApiKey(fields.appSecret) } : {},
|
|
19819
|
-
baseDomain: fields.baseDomain,
|
|
19820
|
-
enabled: row.enabled,
|
|
19821
|
-
linePerMessage: row.linePerMessage,
|
|
19822
|
-
finalReplyOnly: row.finalReplyOnly,
|
|
19823
|
-
renderMarkdown: row.renderMarkdown,
|
|
19824
|
-
lastChatKnown: row.lastChatId !== null,
|
|
19825
|
-
createdAt: row.createdAt,
|
|
19826
|
-
updatedAt: row.updatedAt
|
|
21016
|
+
botId: typeof botId === "string" ? botId : row.accountId,
|
|
21017
|
+
botToken: typeof botToken === "string" ? botToken : ""
|
|
19827
21018
|
};
|
|
19828
21019
|
}
|
|
19829
|
-
|
|
19830
|
-
|
|
19831
|
-
|
|
21020
|
+
var CHANNEL_SPECS = {
|
|
21021
|
+
feishu: {
|
|
21022
|
+
channel: "feishu",
|
|
21023
|
+
label: "Feishu",
|
|
21024
|
+
storedSecret: (row) => feishuFieldsOf(row).appSecret,
|
|
21025
|
+
secretRequiredCode: "feishu_secret_required",
|
|
21026
|
+
toInfo: (row) => {
|
|
21027
|
+
const fields = feishuFieldsOf(row);
|
|
21028
|
+
return {
|
|
21029
|
+
channel: "feishu",
|
|
21030
|
+
...commonBindingFields(row),
|
|
21031
|
+
appId: fields.appId,
|
|
21032
|
+
...maskedSecretField("appSecretMasked", fields.appSecret),
|
|
21033
|
+
baseDomain: fields.baseDomain
|
|
21034
|
+
};
|
|
21035
|
+
}
|
|
21036
|
+
},
|
|
21037
|
+
telegram: {
|
|
19832
21038
|
channel: "telegram",
|
|
19833
|
-
|
|
19834
|
-
|
|
19835
|
-
|
|
19836
|
-
|
|
19837
|
-
|
|
19838
|
-
|
|
19839
|
-
|
|
19840
|
-
|
|
19841
|
-
|
|
19842
|
-
|
|
19843
|
-
|
|
19844
|
-
}
|
|
19845
|
-
|
|
19846
|
-
const fields = qqFieldsOf(row);
|
|
19847
|
-
return {
|
|
21039
|
+
label: "Telegram",
|
|
21040
|
+
storedSecret: (row) => telegramFieldsOf(row).botToken,
|
|
21041
|
+
secretRequiredCode: "telegram_token_required",
|
|
21042
|
+
toInfo: (row) => ({
|
|
21043
|
+
channel: "telegram",
|
|
21044
|
+
...commonBindingFields(row),
|
|
21045
|
+
// The bot id is the row's account identity rather than a config field: it survives a
|
|
21046
|
+
// cleared token, because the half in front of a token's colon never changes for a bot.
|
|
21047
|
+
botId: row.accountId,
|
|
21048
|
+
...maskedSecretField("botTokenMasked", telegramFieldsOf(row).botToken)
|
|
21049
|
+
})
|
|
21050
|
+
},
|
|
21051
|
+
qq: {
|
|
19848
21052
|
channel: "qq",
|
|
19849
|
-
|
|
19850
|
-
|
|
19851
|
-
|
|
19852
|
-
|
|
19853
|
-
|
|
19854
|
-
|
|
19855
|
-
|
|
19856
|
-
|
|
19857
|
-
|
|
19858
|
-
|
|
19859
|
-
|
|
21053
|
+
label: "QQ",
|
|
21054
|
+
storedSecret: (row) => qqFieldsOf(row).appSecret,
|
|
21055
|
+
secretRequiredCode: "qq_secret_required",
|
|
21056
|
+
toInfo: (row) => {
|
|
21057
|
+
const fields = qqFieldsOf(row);
|
|
21058
|
+
return {
|
|
21059
|
+
channel: "qq",
|
|
21060
|
+
...commonBindingFields(row),
|
|
21061
|
+
appId: fields.appId,
|
|
21062
|
+
...maskedSecretField("appSecretMasked", fields.appSecret)
|
|
21063
|
+
};
|
|
21064
|
+
}
|
|
21065
|
+
},
|
|
21066
|
+
wechat: {
|
|
21067
|
+
channel: "wechat",
|
|
21068
|
+
label: "WeChat",
|
|
21069
|
+
storedSecret: (row) => wechatFieldsOf(row).botToken,
|
|
21070
|
+
secretRequiredCode: "wechat_token_required",
|
|
21071
|
+
toInfo: (row) => {
|
|
21072
|
+
const fields = wechatFieldsOf(row);
|
|
21073
|
+
return {
|
|
21074
|
+
channel: "wechat",
|
|
21075
|
+
...commonBindingFields(row),
|
|
21076
|
+
// The bot id is the row's account identity as well as a config field: a cleared
|
|
21077
|
+
// token leaves the row still knowing which bot it was bound to.
|
|
21078
|
+
botId: fields.botId,
|
|
21079
|
+
...maskedSecretField("botTokenMasked", fields.botToken)
|
|
21080
|
+
};
|
|
21081
|
+
}
|
|
21082
|
+
}
|
|
21083
|
+
};
|
|
21084
|
+
function specOf(channel) {
|
|
21085
|
+
return CHANNEL_SPECS[channel] ?? null;
|
|
19860
21086
|
}
|
|
19861
21087
|
function toBindingInfo(row) {
|
|
19862
|
-
|
|
19863
|
-
if (row.channel === "telegram") return toTelegramInfo(row);
|
|
19864
|
-
if (row.channel === "qq") return toQQInfo(row);
|
|
19865
|
-
return null;
|
|
21088
|
+
return specOf(row.channel)?.toInfo(row) ?? null;
|
|
19866
21089
|
}
|
|
19867
21090
|
function parseBaseDomain(raw) {
|
|
19868
21091
|
const trimmed = raw?.trim() ?? "";
|
|
@@ -19901,27 +21124,17 @@ function sessionMessagingRoutes(deps) {
|
|
|
19901
21124
|
}
|
|
19902
21125
|
return row;
|
|
19903
21126
|
};
|
|
19904
|
-
const
|
|
19905
|
-
const row = deps.messagingRepo.find(sessionId,
|
|
19906
|
-
return {
|
|
19907
|
-
binding: row ? toFeishuInfo(row) : null,
|
|
19908
|
-
status: deps.messaging.statusOf(sessionId, "feishu")
|
|
19909
|
-
};
|
|
19910
|
-
};
|
|
19911
|
-
const telegramResponse = (sessionId) => {
|
|
19912
|
-
const row = deps.messagingRepo.find(sessionId, "telegram");
|
|
21127
|
+
const bindingResponse = (sessionId, spec) => {
|
|
21128
|
+
const row = deps.messagingRepo.find(sessionId, spec.channel);
|
|
19913
21129
|
return {
|
|
19914
|
-
binding: row ?
|
|
19915
|
-
status: deps.messaging.statusOf(sessionId,
|
|
19916
|
-
};
|
|
19917
|
-
};
|
|
19918
|
-
const qqResponse = (sessionId) => {
|
|
19919
|
-
const row = deps.messagingRepo.find(sessionId, "qq");
|
|
19920
|
-
return {
|
|
19921
|
-
binding: row ? toQQInfo(row) : null,
|
|
19922
|
-
status: deps.messaging.statusOf(sessionId, "qq")
|
|
21130
|
+
binding: row ? spec.toInfo(row) : null,
|
|
21131
|
+
status: deps.messaging.statusOf(sessionId, spec.channel)
|
|
19923
21132
|
};
|
|
19924
21133
|
};
|
|
21134
|
+
const feishuResponse = (sessionId) => bindingResponse(sessionId, CHANNEL_SPECS.feishu);
|
|
21135
|
+
const telegramResponse = (sessionId) => bindingResponse(sessionId, CHANNEL_SPECS.telegram);
|
|
21136
|
+
const qqResponse = (sessionId) => bindingResponse(sessionId, CHANNEL_SPECS.qq);
|
|
21137
|
+
const wechatResponse = (sessionId) => bindingResponse(sessionId, CHANNEL_SPECS.wechat);
|
|
19925
21138
|
const guardAccountFree = (sessionId, channel, accountId) => {
|
|
19926
21139
|
const holder = deps.messagingRepo.findEnabledByAccount(channel, accountId);
|
|
19927
21140
|
if (holder === null || holder.sessionId === sessionId) return;
|
|
@@ -19945,12 +21158,31 @@ function sessionMessagingRoutes(deps) {
|
|
|
19945
21158
|
);
|
|
19946
21159
|
}
|
|
19947
21160
|
guardAccountFree(sessionId, channel, row.accountId);
|
|
19948
|
-
const
|
|
19949
|
-
if (
|
|
19950
|
-
|
|
19951
|
-
|
|
21161
|
+
const spec = specOf(channel);
|
|
21162
|
+
if (spec === null || spec.storedSecret(row) === "") {
|
|
21163
|
+
throw new HttpError(
|
|
21164
|
+
400,
|
|
21165
|
+
spec?.secretRequiredCode ?? "messaging_channel_unknown",
|
|
21166
|
+
"The stored config has no credential: save one first."
|
|
21167
|
+
);
|
|
21168
|
+
}
|
|
21169
|
+
};
|
|
21170
|
+
const guardScanStart = (sessionId, channel) => {
|
|
21171
|
+
if (deps.messagingRepo.find(sessionId, channel)?.enabled === true) {
|
|
21172
|
+
throw new HttpError(
|
|
21173
|
+
409,
|
|
21174
|
+
"messaging_disable_before_scan",
|
|
21175
|
+
"Disable the connection before rebinding it by scan."
|
|
21176
|
+
);
|
|
19952
21177
|
}
|
|
19953
21178
|
};
|
|
21179
|
+
const saveScanResult = async (sessionId, channel, accountId, config) => {
|
|
21180
|
+
const bound = deps.messagingRepo.find(sessionId, channel);
|
|
21181
|
+
if (bound !== null && bound.enabled) guardAccountFree(sessionId, channel, accountId);
|
|
21182
|
+
const saved = deps.messagingRepo.upsert({ sessionId, channel, accountId, config });
|
|
21183
|
+
if (saved.enabled) await deps.messaging.sync(sessionId);
|
|
21184
|
+
return saved;
|
|
21185
|
+
};
|
|
19954
21186
|
app.get("/:sessionId/messaging", (c) => {
|
|
19955
21187
|
const row = resolveSession(c);
|
|
19956
21188
|
const bindings = [];
|
|
@@ -19961,74 +21193,83 @@ function sessionMessagingRoutes(deps) {
|
|
|
19961
21193
|
}
|
|
19962
21194
|
return c.json({ bindings });
|
|
19963
21195
|
});
|
|
19964
|
-
|
|
19965
|
-
const
|
|
19966
|
-
|
|
19967
|
-
|
|
21196
|
+
const registerChannelRoutes = (spec) => {
|
|
21197
|
+
const base = `/:sessionId/messaging/${spec.channel}`;
|
|
21198
|
+
const notBound = () => {
|
|
21199
|
+
throw new HttpError(
|
|
21200
|
+
404,
|
|
21201
|
+
`${spec.channel}_not_bound`,
|
|
21202
|
+
`This Session has no ${spec.label} binding.`
|
|
21203
|
+
);
|
|
21204
|
+
};
|
|
21205
|
+
const boundOr404 = (sessionId) => deps.messagingRepo.find(sessionId, spec.channel) ?? notBound();
|
|
21206
|
+
app.get(base, (c) => c.json(bindingResponse(resolveSession(c).sessionId, spec)));
|
|
21207
|
+
app.post(`${base}/state`, async (c) => {
|
|
21208
|
+
const row = resolveSession(c);
|
|
21209
|
+
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
21210
|
+
const enabled = await readStateBody(c);
|
|
21211
|
+
const binding = boundOr404(row.sessionId);
|
|
21212
|
+
if (enabled) guardEnable(row.sessionId, spec.channel, binding);
|
|
21213
|
+
deps.messagingRepo.setEnabled(row.sessionId, spec.channel, enabled);
|
|
21214
|
+
await deps.messaging.sync(row.sessionId);
|
|
21215
|
+
return c.json(bindingResponse(row.sessionId, spec));
|
|
21216
|
+
});
|
|
21217
|
+
app.delete(base, (c) => {
|
|
21218
|
+
const row = resolveSession(c);
|
|
21219
|
+
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
21220
|
+
deps.messaging.unbind(row.sessionId, spec.channel);
|
|
21221
|
+
return c.body(null, 204);
|
|
21222
|
+
});
|
|
21223
|
+
app.post(`${base}/test-message`, async (c) => {
|
|
21224
|
+
const row = resolveSession(c);
|
|
21225
|
+
const binding = boundOr404(row.sessionId);
|
|
21226
|
+
if (binding.lastChatId === null) {
|
|
21227
|
+
throw new HttpError(
|
|
21228
|
+
409,
|
|
21229
|
+
`${spec.channel}_no_chat`,
|
|
21230
|
+
`No ${spec.label} chat is known yet: message the bot once in ${spec.label} first.`
|
|
21231
|
+
);
|
|
21232
|
+
}
|
|
21233
|
+
try {
|
|
21234
|
+
await deps.messaging.sendTestMessage(binding);
|
|
21235
|
+
} catch (err) {
|
|
21236
|
+
throw new HttpError(
|
|
21237
|
+
502,
|
|
21238
|
+
`${spec.channel}_send_failed`,
|
|
21239
|
+
err instanceof Error ? err.message : String(err)
|
|
21240
|
+
);
|
|
21241
|
+
}
|
|
21242
|
+
return c.json({ ok: true });
|
|
21243
|
+
});
|
|
21244
|
+
};
|
|
21245
|
+
for (const spec of Object.values(CHANNEL_SPECS)) registerChannelRoutes(spec);
|
|
19968
21246
|
app.put("/:sessionId/messaging/feishu", async (c) => {
|
|
19969
21247
|
const row = resolveSession(c);
|
|
19970
21248
|
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
19971
21249
|
const body = await readJson(c);
|
|
19972
21250
|
const appId = requireString(body, "appId", { minLen: 1, maxLen: 200 }).trim();
|
|
19973
21251
|
if (appId === "") throw badRequest("appId must not be blank.");
|
|
19974
|
-
const secretInput = optionalString(body, "appSecret", { maxLen: 500 })?.trim();
|
|
19975
|
-
const clearSecret = body.clearAppSecret === true;
|
|
19976
21252
|
const baseDomain = parseBaseDomain(optionalString(body, "baseDomain", { maxLen: 500 }));
|
|
19977
|
-
const linePerMessage = optionalBoolean(body, "linePerMessage");
|
|
19978
|
-
const finalReplyOnly = optionalBoolean(body, "finalReplyOnly");
|
|
19979
|
-
const renderMarkdown = optionalBoolean(body, "renderMarkdown");
|
|
19980
21253
|
const existing = deps.messagingRepo.find(row.sessionId, "feishu");
|
|
19981
|
-
const
|
|
19982
|
-
|
|
19983
|
-
|
|
19984
|
-
|
|
19985
|
-
|
|
19986
|
-
|
|
19987
|
-
|
|
19988
|
-
|
|
19989
|
-
"Disable the connection before clearing its credential."
|
|
19990
|
-
);
|
|
19991
|
-
}
|
|
19992
|
-
appSecret = "";
|
|
19993
|
-
} else {
|
|
19994
|
-
const stored = existing !== null ? feishuFieldsOf(existing).appSecret : "";
|
|
19995
|
-
if (stored === "") {
|
|
19996
|
-
throw new HttpError(400, "feishu_secret_required", "appSecret is required to bind.");
|
|
19997
|
-
}
|
|
19998
|
-
appSecret = stored;
|
|
19999
|
-
}
|
|
21254
|
+
const { secret: appSecret } = resolveSecret({
|
|
21255
|
+
typed: optionalString(body, "appSecret", { maxLen: 500 })?.trim(),
|
|
21256
|
+
clear: body.clearAppSecret === true,
|
|
21257
|
+
existing,
|
|
21258
|
+
stored: existing !== null ? feishuFieldsOf(existing).appSecret : "",
|
|
21259
|
+
requiredCode: "feishu_secret_required",
|
|
21260
|
+
requiredMessage: "appSecret is required to bind."
|
|
21261
|
+
});
|
|
20000
21262
|
if (existing !== null && existing.enabled) guardAccountFree(row.sessionId, "feishu", appId);
|
|
20001
21263
|
const saved = deps.messagingRepo.upsert({
|
|
20002
21264
|
sessionId: row.sessionId,
|
|
20003
21265
|
channel: "feishu",
|
|
20004
21266
|
accountId: appId,
|
|
20005
21267
|
config: { appId, appSecret, baseDomain },
|
|
20006
|
-
...
|
|
20007
|
-
...finalReplyOnly !== void 0 ? { finalReplyOnly } : {},
|
|
20008
|
-
...renderMarkdown !== void 0 ? { renderMarkdown } : {}
|
|
21268
|
+
...deliveryPatchOf(body)
|
|
20009
21269
|
});
|
|
20010
21270
|
if (saved.enabled) await deps.messaging.sync(row.sessionId);
|
|
20011
21271
|
return c.json(feishuResponse(row.sessionId));
|
|
20012
21272
|
});
|
|
20013
|
-
app.post("/:sessionId/messaging/feishu/state", async (c) => {
|
|
20014
|
-
const row = resolveSession(c);
|
|
20015
|
-
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
20016
|
-
const enabled = await readStateBody(c);
|
|
20017
|
-
const binding = deps.messagingRepo.find(row.sessionId, "feishu");
|
|
20018
|
-
if (binding === null) {
|
|
20019
|
-
throw new HttpError(404, "feishu_not_bound", "This Session has no Feishu binding.");
|
|
20020
|
-
}
|
|
20021
|
-
if (enabled) guardEnable(row.sessionId, "feishu", binding);
|
|
20022
|
-
deps.messagingRepo.setEnabled(row.sessionId, "feishu", enabled);
|
|
20023
|
-
await deps.messaging.sync(row.sessionId);
|
|
20024
|
-
return c.json(feishuResponse(row.sessionId));
|
|
20025
|
-
});
|
|
20026
|
-
app.delete("/:sessionId/messaging/feishu", (c) => {
|
|
20027
|
-
const row = resolveSession(c);
|
|
20028
|
-
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
20029
|
-
deps.messaging.unbind(row.sessionId, "feishu");
|
|
20030
|
-
return c.body(null, 204);
|
|
20031
|
-
});
|
|
20032
21273
|
app.post("/:sessionId/messaging/feishu/test", async (c) => {
|
|
20033
21274
|
const row = resolveSession(c);
|
|
20034
21275
|
const body = await readJson(c);
|
|
@@ -20053,49 +21294,22 @@ function sessionMessagingRoutes(deps) {
|
|
|
20053
21294
|
...result.error !== void 0 ? { error: result.error } : {}
|
|
20054
21295
|
});
|
|
20055
21296
|
});
|
|
20056
|
-
app.post("/:sessionId/messaging/feishu/test-message", async (c) => {
|
|
20057
|
-
const row = resolveSession(c);
|
|
20058
|
-
const binding = deps.messagingRepo.find(row.sessionId, "feishu");
|
|
20059
|
-
if (!binding) {
|
|
20060
|
-
throw new HttpError(404, "feishu_not_bound", "This Session has no Feishu binding.");
|
|
20061
|
-
}
|
|
20062
|
-
if (binding.lastChatId === null) {
|
|
20063
|
-
throw new HttpError(
|
|
20064
|
-
409,
|
|
20065
|
-
"feishu_no_chat",
|
|
20066
|
-
"No Feishu chat is known yet: message the bot once in Feishu first."
|
|
20067
|
-
);
|
|
20068
|
-
}
|
|
20069
|
-
try {
|
|
20070
|
-
await deps.messaging.sendTestMessage(binding);
|
|
20071
|
-
} catch (err) {
|
|
20072
|
-
throw new HttpError(
|
|
20073
|
-
502,
|
|
20074
|
-
"feishu_send_failed",
|
|
20075
|
-
err instanceof Error ? err.message : String(err)
|
|
20076
|
-
);
|
|
20077
|
-
}
|
|
20078
|
-
return c.json({ ok: true });
|
|
20079
|
-
});
|
|
20080
|
-
app.get("/:sessionId/messaging/telegram", (c) => {
|
|
20081
|
-
const row = resolveSession(c);
|
|
20082
|
-
return c.json(telegramResponse(row.sessionId));
|
|
20083
|
-
});
|
|
20084
21297
|
app.put("/:sessionId/messaging/telegram", async (c) => {
|
|
20085
21298
|
const row = resolveSession(c);
|
|
20086
21299
|
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
20087
21300
|
const body = await readJson(c);
|
|
20088
|
-
const tokenInput = optionalString(body, "botToken", { maxLen: 200 })?.trim();
|
|
20089
|
-
const clearToken = body.clearBotToken === true;
|
|
20090
|
-
const linePerMessage = optionalBoolean(body, "linePerMessage");
|
|
20091
|
-
const finalReplyOnly = optionalBoolean(body, "finalReplyOnly");
|
|
20092
|
-
const renderMarkdown = optionalBoolean(body, "renderMarkdown");
|
|
20093
21301
|
const existing = deps.messagingRepo.find(row.sessionId, "telegram");
|
|
20094
|
-
const
|
|
20095
|
-
|
|
21302
|
+
const { secret: botToken, fromRequest } = resolveSecret({
|
|
21303
|
+
typed: optionalString(body, "botToken", { maxLen: 200 })?.trim(),
|
|
21304
|
+
clear: body.clearBotToken === true,
|
|
21305
|
+
existing,
|
|
21306
|
+
stored: existing !== null ? telegramFieldsOf(existing).botToken : "",
|
|
21307
|
+
requiredCode: "telegram_token_required",
|
|
21308
|
+
requiredMessage: "botToken is required to bind."
|
|
21309
|
+
});
|
|
20096
21310
|
let botId;
|
|
20097
|
-
if (
|
|
20098
|
-
const id = telegramBotIdOf(
|
|
21311
|
+
if (fromRequest) {
|
|
21312
|
+
const id = telegramBotIdOf(botToken);
|
|
20099
21313
|
if (id === null) {
|
|
20100
21314
|
throw new HttpError(
|
|
20101
21315
|
400,
|
|
@@ -20103,24 +21317,8 @@ function sessionMessagingRoutes(deps) {
|
|
|
20103
21317
|
"botToken must look like <numeric bot id>:<secret> (as issued by @BotFather)."
|
|
20104
21318
|
);
|
|
20105
21319
|
}
|
|
20106
|
-
botToken = typed;
|
|
20107
21320
|
botId = id;
|
|
20108
|
-
} else if (clearToken && existing !== null) {
|
|
20109
|
-
if (existing.enabled) {
|
|
20110
|
-
throw new HttpError(
|
|
20111
|
-
409,
|
|
20112
|
-
"messaging_disable_before_clear",
|
|
20113
|
-
"Disable the connection before clearing its credential."
|
|
20114
|
-
);
|
|
20115
|
-
}
|
|
20116
|
-
botToken = "";
|
|
20117
|
-
botId = existing.accountId;
|
|
20118
21321
|
} else {
|
|
20119
|
-
const stored = existing !== null ? telegramFieldsOf(existing).botToken : "";
|
|
20120
|
-
if (stored === "" || existing === null) {
|
|
20121
|
-
throw new HttpError(400, "telegram_token_required", "botToken is required to bind.");
|
|
20122
|
-
}
|
|
20123
|
-
botToken = stored;
|
|
20124
21322
|
botId = existing.accountId;
|
|
20125
21323
|
}
|
|
20126
21324
|
if (existing !== null && existing.enabled) guardAccountFree(row.sessionId, "telegram", botId);
|
|
@@ -20129,32 +21327,11 @@ function sessionMessagingRoutes(deps) {
|
|
|
20129
21327
|
channel: "telegram",
|
|
20130
21328
|
accountId: botId,
|
|
20131
21329
|
config: { botToken },
|
|
20132
|
-
...
|
|
20133
|
-
...finalReplyOnly !== void 0 ? { finalReplyOnly } : {},
|
|
20134
|
-
...renderMarkdown !== void 0 ? { renderMarkdown } : {}
|
|
21330
|
+
...deliveryPatchOf(body)
|
|
20135
21331
|
});
|
|
20136
21332
|
if (saved.enabled) await deps.messaging.sync(row.sessionId);
|
|
20137
21333
|
return c.json(telegramResponse(row.sessionId));
|
|
20138
21334
|
});
|
|
20139
|
-
app.post("/:sessionId/messaging/telegram/state", async (c) => {
|
|
20140
|
-
const row = resolveSession(c);
|
|
20141
|
-
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
20142
|
-
const enabled = await readStateBody(c);
|
|
20143
|
-
const binding = deps.messagingRepo.find(row.sessionId, "telegram");
|
|
20144
|
-
if (binding === null) {
|
|
20145
|
-
throw new HttpError(404, "telegram_not_bound", "This Session has no Telegram binding.");
|
|
20146
|
-
}
|
|
20147
|
-
if (enabled) guardEnable(row.sessionId, "telegram", binding);
|
|
20148
|
-
deps.messagingRepo.setEnabled(row.sessionId, "telegram", enabled);
|
|
20149
|
-
await deps.messaging.sync(row.sessionId);
|
|
20150
|
-
return c.json(telegramResponse(row.sessionId));
|
|
20151
|
-
});
|
|
20152
|
-
app.delete("/:sessionId/messaging/telegram", (c) => {
|
|
20153
|
-
const row = resolveSession(c);
|
|
20154
|
-
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
20155
|
-
deps.messaging.unbind(row.sessionId, "telegram");
|
|
20156
|
-
return c.body(null, 204);
|
|
20157
|
-
});
|
|
20158
21335
|
app.post("/:sessionId/messaging/telegram/test", async (c) => {
|
|
20159
21336
|
const row = resolveSession(c);
|
|
20160
21337
|
const body = await readJson(c);
|
|
@@ -20174,108 +21351,36 @@ function sessionMessagingRoutes(deps) {
|
|
|
20174
21351
|
...result.error !== void 0 ? { error: result.error } : {}
|
|
20175
21352
|
});
|
|
20176
21353
|
});
|
|
20177
|
-
app.post("/:sessionId/messaging/telegram/test-message", async (c) => {
|
|
20178
|
-
const row = resolveSession(c);
|
|
20179
|
-
const binding = deps.messagingRepo.find(row.sessionId, "telegram");
|
|
20180
|
-
if (!binding) {
|
|
20181
|
-
throw new HttpError(404, "telegram_not_bound", "This Session has no Telegram binding.");
|
|
20182
|
-
}
|
|
20183
|
-
if (binding.lastChatId === null) {
|
|
20184
|
-
throw new HttpError(
|
|
20185
|
-
409,
|
|
20186
|
-
"telegram_no_chat",
|
|
20187
|
-
"No Telegram chat is known yet: message the bot once in Telegram first."
|
|
20188
|
-
);
|
|
20189
|
-
}
|
|
20190
|
-
try {
|
|
20191
|
-
await deps.messaging.sendTestMessage(binding);
|
|
20192
|
-
} catch (err) {
|
|
20193
|
-
throw new HttpError(
|
|
20194
|
-
502,
|
|
20195
|
-
"telegram_send_failed",
|
|
20196
|
-
err instanceof Error ? err.message : String(err)
|
|
20197
|
-
);
|
|
20198
|
-
}
|
|
20199
|
-
return c.json({ ok: true });
|
|
20200
|
-
});
|
|
20201
|
-
app.get("/:sessionId/messaging/qq", (c) => {
|
|
20202
|
-
const row = resolveSession(c);
|
|
20203
|
-
return c.json(qqResponse(row.sessionId));
|
|
20204
|
-
});
|
|
20205
21354
|
app.put("/:sessionId/messaging/qq", async (c) => {
|
|
20206
21355
|
const row = resolveSession(c);
|
|
20207
21356
|
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
20208
21357
|
const body = await readJson(c);
|
|
20209
21358
|
const appId = requireString(body, "appId", { minLen: 1, maxLen: 200 }).trim();
|
|
20210
21359
|
if (appId === "") throw badRequest("appId must not be blank.");
|
|
20211
|
-
const secretInput = optionalString(body, "appSecret", { maxLen: 500 })?.trim();
|
|
20212
|
-
const clearSecret = body.clearAppSecret === true;
|
|
20213
|
-
const linePerMessage = optionalBoolean(body, "linePerMessage");
|
|
20214
|
-
const finalReplyOnly = optionalBoolean(body, "finalReplyOnly");
|
|
20215
|
-
const renderMarkdown = optionalBoolean(body, "renderMarkdown");
|
|
20216
21360
|
const existing = deps.messagingRepo.find(row.sessionId, "qq");
|
|
20217
|
-
const
|
|
20218
|
-
|
|
20219
|
-
|
|
20220
|
-
|
|
20221
|
-
|
|
20222
|
-
|
|
20223
|
-
|
|
20224
|
-
|
|
20225
|
-
"Disable the connection before clearing its credential."
|
|
20226
|
-
);
|
|
20227
|
-
}
|
|
20228
|
-
appSecret = "";
|
|
20229
|
-
} else {
|
|
20230
|
-
const stored = existing !== null ? qqFieldsOf(existing).appSecret : "";
|
|
20231
|
-
if (stored === "") {
|
|
20232
|
-
throw new HttpError(400, "qq_secret_required", "appSecret is required to bind.");
|
|
20233
|
-
}
|
|
20234
|
-
appSecret = stored;
|
|
20235
|
-
}
|
|
21361
|
+
const { secret: appSecret } = resolveSecret({
|
|
21362
|
+
typed: optionalString(body, "appSecret", { maxLen: 500 })?.trim(),
|
|
21363
|
+
clear: body.clearAppSecret === true,
|
|
21364
|
+
existing,
|
|
21365
|
+
stored: existing !== null ? qqFieldsOf(existing).appSecret : "",
|
|
21366
|
+
requiredCode: "qq_secret_required",
|
|
21367
|
+
requiredMessage: "appSecret is required to bind."
|
|
21368
|
+
});
|
|
20236
21369
|
if (existing !== null && existing.enabled) guardAccountFree(row.sessionId, "qq", appId);
|
|
20237
21370
|
const saved = deps.messagingRepo.upsert({
|
|
20238
21371
|
sessionId: row.sessionId,
|
|
20239
21372
|
channel: "qq",
|
|
20240
21373
|
accountId: appId,
|
|
20241
21374
|
config: { appId, appSecret },
|
|
20242
|
-
...
|
|
20243
|
-
...finalReplyOnly !== void 0 ? { finalReplyOnly } : {},
|
|
20244
|
-
...renderMarkdown !== void 0 ? { renderMarkdown } : {}
|
|
21375
|
+
...deliveryPatchOf(body)
|
|
20245
21376
|
});
|
|
20246
21377
|
if (saved.enabled) await deps.messaging.sync(row.sessionId);
|
|
20247
21378
|
return c.json(qqResponse(row.sessionId));
|
|
20248
21379
|
});
|
|
20249
|
-
app.post("/:sessionId/messaging/qq/state", async (c) => {
|
|
20250
|
-
const row = resolveSession(c);
|
|
20251
|
-
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
20252
|
-
const enabled = await readStateBody(c);
|
|
20253
|
-
const binding = deps.messagingRepo.find(row.sessionId, "qq");
|
|
20254
|
-
if (binding === null) {
|
|
20255
|
-
throw new HttpError(404, "qq_not_bound", "This Session has no QQ binding.");
|
|
20256
|
-
}
|
|
20257
|
-
if (enabled) guardEnable(row.sessionId, "qq", binding);
|
|
20258
|
-
deps.messagingRepo.setEnabled(row.sessionId, "qq", enabled);
|
|
20259
|
-
await deps.messaging.sync(row.sessionId);
|
|
20260
|
-
return c.json(qqResponse(row.sessionId));
|
|
20261
|
-
});
|
|
20262
|
-
app.delete("/:sessionId/messaging/qq", (c) => {
|
|
20263
|
-
const row = resolveSession(c);
|
|
20264
|
-
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
20265
|
-
deps.messaging.unbind(row.sessionId, "qq");
|
|
20266
|
-
deps.qqScan.cancelSession(row.sessionId);
|
|
20267
|
-
return c.body(null, 204);
|
|
20268
|
-
});
|
|
20269
21380
|
app.post("/:sessionId/messaging/qq/scan", async (c) => {
|
|
20270
21381
|
const row = resolveSession(c);
|
|
20271
21382
|
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
20272
|
-
|
|
20273
|
-
throw new HttpError(
|
|
20274
|
-
409,
|
|
20275
|
-
"messaging_disable_before_scan",
|
|
20276
|
-
"Disable the connection before rebinding it by scan."
|
|
20277
|
-
);
|
|
20278
|
-
}
|
|
21383
|
+
guardScanStart(row.sessionId, "qq");
|
|
20279
21384
|
let started;
|
|
20280
21385
|
try {
|
|
20281
21386
|
started = await deps.qqScan.start(row.sessionId);
|
|
@@ -20305,19 +21410,14 @@ function sessionMessagingRoutes(deps) {
|
|
|
20305
21410
|
if (result.status !== "completed" || result.bot === void 0) {
|
|
20306
21411
|
return c.json({ status: result.status });
|
|
20307
21412
|
}
|
|
20308
|
-
const
|
|
20309
|
-
|
|
20310
|
-
|
|
20311
|
-
sessionId: row.sessionId,
|
|
20312
|
-
channel: "qq",
|
|
20313
|
-
accountId: result.bot.appId,
|
|
20314
|
-
config: { appId: result.bot.appId, appSecret: result.bot.appSecret }
|
|
21413
|
+
const saved = await saveScanResult(row.sessionId, "qq", result.bot.appId, {
|
|
21414
|
+
appId: result.bot.appId,
|
|
21415
|
+
appSecret: result.bot.appSecret
|
|
20315
21416
|
});
|
|
20316
|
-
if (saved.enabled) await deps.messaging.sync(row.sessionId);
|
|
20317
21417
|
return c.json({
|
|
20318
21418
|
status: "completed",
|
|
20319
21419
|
appId: result.bot.appId,
|
|
20320
|
-
binding:
|
|
21420
|
+
binding: CHANNEL_SPECS.qq.toInfo(saved)
|
|
20321
21421
|
});
|
|
20322
21422
|
});
|
|
20323
21423
|
app.post("/:sessionId/messaging/qq/scan/cancel", async (c) => {
|
|
@@ -20347,25 +21447,135 @@ function sessionMessagingRoutes(deps) {
|
|
|
20347
21447
|
...result.error !== void 0 ? { error: result.error } : {}
|
|
20348
21448
|
});
|
|
20349
21449
|
});
|
|
20350
|
-
app.
|
|
21450
|
+
app.put("/:sessionId/messaging/wechat", async (c) => {
|
|
20351
21451
|
const row = resolveSession(c);
|
|
20352
|
-
|
|
20353
|
-
|
|
20354
|
-
|
|
21452
|
+
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
21453
|
+
const body = await readJson(c);
|
|
21454
|
+
const existing = deps.messagingRepo.find(row.sessionId, "wechat");
|
|
21455
|
+
if (existing === null) {
|
|
21456
|
+
throw new HttpError(
|
|
21457
|
+
400,
|
|
21458
|
+
"wechat_token_required",
|
|
21459
|
+
"Bind by scanning a QR code first: there is no saved WeChat config to update."
|
|
21460
|
+
);
|
|
20355
21461
|
}
|
|
20356
|
-
|
|
21462
|
+
const { secret: botToken } = resolveSecret({
|
|
21463
|
+
typed: void 0,
|
|
21464
|
+
clear: body.clearBotToken === true,
|
|
21465
|
+
existing,
|
|
21466
|
+
stored: wechatFieldsOf(existing).botToken,
|
|
21467
|
+
requiredCode: "wechat_token_required",
|
|
21468
|
+
requiredMessage: "Bind by scanning a QR code first."
|
|
21469
|
+
});
|
|
21470
|
+
const saved = deps.messagingRepo.upsert({
|
|
21471
|
+
sessionId: row.sessionId,
|
|
21472
|
+
channel: "wechat",
|
|
21473
|
+
accountId: existing.accountId,
|
|
21474
|
+
// The stored document carried forward with only the token replaced: it also holds the
|
|
21475
|
+
// API host the scan assigned and the id of the person who scanned, neither of which
|
|
21476
|
+
// this request knows and both of which the connector needs.
|
|
21477
|
+
config: { ...existing.config, botToken },
|
|
21478
|
+
...deliveryPatchOf(body)
|
|
21479
|
+
});
|
|
21480
|
+
if (saved.enabled) await deps.messaging.sync(row.sessionId);
|
|
21481
|
+
return c.json(wechatResponse(row.sessionId));
|
|
21482
|
+
});
|
|
21483
|
+
app.post("/:sessionId/messaging/wechat/scan", async (c) => {
|
|
21484
|
+
const row = resolveSession(c);
|
|
21485
|
+
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
21486
|
+
guardScanStart(row.sessionId, "wechat");
|
|
21487
|
+
let started;
|
|
21488
|
+
try {
|
|
21489
|
+
started = await deps.wechatScan.start(row.sessionId);
|
|
21490
|
+
} catch (err) {
|
|
20357
21491
|
throw new HttpError(
|
|
20358
|
-
|
|
20359
|
-
"
|
|
20360
|
-
|
|
21492
|
+
502,
|
|
21493
|
+
"wechat_scan_failed",
|
|
21494
|
+
err instanceof Error ? err.message : String(err)
|
|
20361
21495
|
);
|
|
20362
21496
|
}
|
|
21497
|
+
return c.json(started);
|
|
21498
|
+
});
|
|
21499
|
+
app.post("/:sessionId/messaging/wechat/scan/poll", async (c) => {
|
|
21500
|
+
const row = resolveSession(c);
|
|
21501
|
+
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
21502
|
+
const body = await readJson(c);
|
|
21503
|
+
const taskId = requireString(body, "taskId", { minLen: 1, maxLen: 500 });
|
|
21504
|
+
let result;
|
|
20363
21505
|
try {
|
|
20364
|
-
await deps.
|
|
21506
|
+
result = await deps.wechatScan.poll(row.sessionId, taskId);
|
|
20365
21507
|
} catch (err) {
|
|
20366
|
-
throw new HttpError(
|
|
21508
|
+
throw new HttpError(
|
|
21509
|
+
502,
|
|
21510
|
+
"wechat_scan_failed",
|
|
21511
|
+
err instanceof Error ? err.message : String(err)
|
|
21512
|
+
);
|
|
21513
|
+
}
|
|
21514
|
+
if (result === null) {
|
|
21515
|
+
throw new HttpError(
|
|
21516
|
+
404,
|
|
21517
|
+
"wechat_scan_task_unknown",
|
|
21518
|
+
"This scan is no longer in progress: start a new one."
|
|
21519
|
+
);
|
|
21520
|
+
}
|
|
21521
|
+
if (result.status !== "completed" || result.bot === void 0) {
|
|
21522
|
+
return c.json({ status: result.status });
|
|
21523
|
+
}
|
|
21524
|
+
const { botId, botToken, baseUrl, userId } = result.bot;
|
|
21525
|
+
const saved = await saveScanResult(row.sessionId, "wechat", botId, {
|
|
21526
|
+
botId,
|
|
21527
|
+
botToken,
|
|
21528
|
+
baseUrl,
|
|
21529
|
+
userId
|
|
21530
|
+
});
|
|
21531
|
+
return c.json({
|
|
21532
|
+
status: "completed",
|
|
21533
|
+
botId,
|
|
21534
|
+
binding: CHANNEL_SPECS.wechat.toInfo(saved)
|
|
21535
|
+
});
|
|
21536
|
+
});
|
|
21537
|
+
app.post("/:sessionId/messaging/wechat/scan/verify", async (c) => {
|
|
21538
|
+
const row = resolveSession(c);
|
|
21539
|
+
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
21540
|
+
const body = await readJson(c);
|
|
21541
|
+
const taskId = requireString(body, "taskId", { minLen: 1, maxLen: 500 });
|
|
21542
|
+
const verifyCode = requireString(body, "verifyCode", { minLen: 1, maxLen: 32 }).trim();
|
|
21543
|
+
if (verifyCode === "") throw badRequest("verifyCode must not be blank.");
|
|
21544
|
+
if (!deps.wechatScan.submitVerifyCode(row.sessionId, taskId, verifyCode)) {
|
|
21545
|
+
throw new HttpError(
|
|
21546
|
+
404,
|
|
21547
|
+
"wechat_scan_task_unknown",
|
|
21548
|
+
"This scan is no longer in progress: start a new one."
|
|
21549
|
+
);
|
|
21550
|
+
}
|
|
21551
|
+
return c.body(null, 204);
|
|
21552
|
+
});
|
|
21553
|
+
app.post("/:sessionId/messaging/wechat/scan/cancel", async (c) => {
|
|
21554
|
+
const row = resolveSession(c);
|
|
21555
|
+
deps.projectService.requireProjectOwner(c.var.user.userId, row.projectId);
|
|
21556
|
+
const body = await readJson(c);
|
|
21557
|
+
deps.wechatScan.cancel(
|
|
21558
|
+
row.sessionId,
|
|
21559
|
+
requireString(body, "taskId", { minLen: 1, maxLen: 500 })
|
|
21560
|
+
);
|
|
21561
|
+
return c.body(null, 204);
|
|
21562
|
+
});
|
|
21563
|
+
app.post("/:sessionId/messaging/wechat/test", async (c) => {
|
|
21564
|
+
const row = resolveSession(c);
|
|
21565
|
+
const stored = deps.messagingRepo.find(row.sessionId, "wechat");
|
|
21566
|
+
if (stored === null || wechatFieldsOf(stored).botToken === "") {
|
|
21567
|
+
throw new HttpError(
|
|
21568
|
+
400,
|
|
21569
|
+
"wechat_token_required",
|
|
21570
|
+
"Bind by scanning a QR code first: there is no credential to test."
|
|
21571
|
+
);
|
|
20367
21572
|
}
|
|
20368
|
-
|
|
21573
|
+
const result = await deps.messaging.testCredentials("wechat", stored.config);
|
|
21574
|
+
return c.json({
|
|
21575
|
+
ok: result.ok,
|
|
21576
|
+
...result.latencyMs !== void 0 ? { latencyMs: result.latencyMs } : {},
|
|
21577
|
+
...result.error !== void 0 ? { error: result.error } : {}
|
|
21578
|
+
});
|
|
20369
21579
|
});
|
|
20370
21580
|
return app;
|
|
20371
21581
|
}
|
|
@@ -20833,7 +22043,7 @@ async function resolveTarget(alias) {
|
|
|
20833
22043
|
}
|
|
20834
22044
|
|
|
20835
22045
|
// src/machines/install-server.ts
|
|
20836
|
-
import { randomBytes as
|
|
22046
|
+
import { randomBytes as randomBytes9 } from "crypto";
|
|
20837
22047
|
import fs28 from "fs";
|
|
20838
22048
|
import os4 from "os";
|
|
20839
22049
|
import path26 from "path";
|
|
@@ -21062,7 +22272,7 @@ async function installOnRemote(opts) {
|
|
|
21062
22272
|
say(`Installing release ${plan.baseVersion} (downloaded on the remote)\u2026`);
|
|
21063
22273
|
let where;
|
|
21064
22274
|
if (identity.platform === "win32") {
|
|
21065
|
-
const name = `penguin-${
|
|
22275
|
+
const name = `penguin-${randomBytes9(6).toString("hex")}.ps1`;
|
|
21066
22276
|
const local = path26.join(os4.tmpdir(), name);
|
|
21067
22277
|
fs28.writeFileSync(local, installer);
|
|
21068
22278
|
windowsTmp = { local, remote: `%USERPROFILE%\\${name}` };
|
|
@@ -21398,7 +22608,7 @@ var memEtags = /* @__PURE__ */ new WeakMap();
|
|
|
21398
22608
|
function etagOfBuffer(content) {
|
|
21399
22609
|
let etag = memEtags.get(content);
|
|
21400
22610
|
if (etag === void 0) {
|
|
21401
|
-
etag = `"${
|
|
22611
|
+
etag = `"${createHash6("sha256").update(content).digest("base64url").slice(0, 16)}"`;
|
|
21402
22612
|
memEtags.set(content, etag);
|
|
21403
22613
|
}
|
|
21404
22614
|
return etag;
|
|
@@ -21535,6 +22745,8 @@ function buildAppDeps(caps, overrides = {}) {
|
|
|
21535
22745
|
const usageService = new UsageService(
|
|
21536
22746
|
usageRepo,
|
|
21537
22747
|
errorsRepo,
|
|
22748
|
+
// No clock here: a scheduled row's two rates come back together and each aggregation half
|
|
22749
|
+
// is priced at the tier its own records ran in, which the query decides from their `ts`.
|
|
21538
22750
|
(projectId, provider, modelId) => projectConfigService.getPricing(projectId, provider, modelId),
|
|
21539
22751
|
overrides.now ?? (() => /* @__PURE__ */ new Date())
|
|
21540
22752
|
);
|
|
@@ -21617,7 +22829,11 @@ function buildAppDeps(caps, overrides = {}) {
|
|
|
21617
22829
|
new QQConnector(overrides.qqTransport ?? createQQTransport(), {
|
|
21618
22830
|
...overrides.qqTailFlushMs !== void 0 ? { tailFlushMs: overrides.qqTailFlushMs } : {},
|
|
21619
22831
|
...overrides.now ? { now: () => overrides.now().getTime() } : {}
|
|
21620
|
-
})
|
|
22832
|
+
}),
|
|
22833
|
+
new WeChatConnector(
|
|
22834
|
+
overrides.wechatTransport ?? createWeChatTransport(),
|
|
22835
|
+
overrides.wechatRetryDelayMs ? { retryDelayMs: overrides.wechatRetryDelayMs } : {}
|
|
22836
|
+
)
|
|
21621
22837
|
],
|
|
21622
22838
|
errors,
|
|
21623
22839
|
log,
|
|
@@ -21628,6 +22844,10 @@ function buildAppDeps(caps, overrides = {}) {
|
|
|
21628
22844
|
const qqScan = new QQScanService(overrides.qqScanTransport ?? createQQScanTransport(), {
|
|
21629
22845
|
...overrides.now ? { now: () => overrides.now().getTime() } : {}
|
|
21630
22846
|
});
|
|
22847
|
+
const wechatScan = new WeChatScanService(
|
|
22848
|
+
overrides.wechatScanTransport ?? createWeChatScanTransport(),
|
|
22849
|
+
{ ...overrides.now ? { now: () => overrides.now().getTime() } : {} }
|
|
22850
|
+
);
|
|
21631
22851
|
const sessionService = new SessionService({
|
|
21632
22852
|
root: config.root,
|
|
21633
22853
|
sessions: sessionsRepo,
|
|
@@ -21642,7 +22862,7 @@ function buildAppDeps(caps, overrides = {}) {
|
|
|
21642
22862
|
// stored channel reads as none (same defensive skip as the bridge and the routes).
|
|
21643
22863
|
messagingChannel: (sessionId) => {
|
|
21644
22864
|
const enabled = messagingRepo.findEnabled(sessionId);
|
|
21645
|
-
return enabled !== null && (enabled.channel === "feishu" || enabled.channel === "telegram" || enabled.channel === "qq") ? enabled.channel : null;
|
|
22865
|
+
return enabled !== null && (enabled.channel === "feishu" || enabled.channel === "telegram" || enabled.channel === "qq" || enabled.channel === "wechat") ? enabled.channel : null;
|
|
21646
22866
|
}
|
|
21647
22867
|
});
|
|
21648
22868
|
const scheduler = new Scheduler({
|
|
@@ -21687,6 +22907,7 @@ function buildAppDeps(caps, overrides = {}) {
|
|
|
21687
22907
|
errorsRepo,
|
|
21688
22908
|
messagingRepo,
|
|
21689
22909
|
qqScan,
|
|
22910
|
+
wechatScan,
|
|
21690
22911
|
messaging,
|
|
21691
22912
|
scheduler,
|
|
21692
22913
|
channels,
|