@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/index.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  modelCredentialMissing,
16
16
  storeApiToken,
17
17
  toUserInfo
18
- } from "./chunk-UGAFPYVX.js";
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-622KL7BQ.js";
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 createHash5 } from "crypto";
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/error-kind.ts
4512
- var CODES_EXPLAINED_IN_CHAT = /* @__PURE__ */ new Set([
4513
- "messaging_image_fetch_failed",
4514
- "messaging_file_fetch_failed",
4515
- "messaging_file_send_failed"
4516
- ]);
4517
- function messagingErrorKind(err, code) {
4518
- if (!CODES_EXPLAINED_IN_CHAT.has(code)) return "unexpected";
4519
- if (err instanceof MessagingPermissionError || err instanceof MessagingMediaTooLargeError || err instanceof MessagingUnsupportedError) {
4520
- return "expected";
4521
- }
4522
- return "unexpected";
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
- // src/runtime/messaging/markdown.ts
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
- var SAFE_LINK_SCHEMES = /* @__PURE__ */ new Set([
4534
- "http:",
4535
- "https:",
4536
- "mailto:",
4537
- "tel:",
4538
- "tg:"
4539
- ]);
4540
- function isSafeUrl(url) {
4541
- if (url === "" || url.startsWith("#")) return false;
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 spanOf(node) {
4549
- const { position } = node;
4550
- if (position?.start.offset === void 0 || position.end.offset === void 0) return null;
4551
- return { from: position.start.offset, to: position.end.offset };
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 BLOCK_PARENTS = /* @__PURE__ */ new Set([
4554
- "root",
4555
- "blockquote",
4556
- "list",
4557
- "listItem",
4558
- "table",
4559
- "footnoteDefinition"
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 lineStart(text, offset) {
4562
- return text.lastIndexOf("\n", offset - 1) + 1;
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
- return best;
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 (rest !== "") out.push(rest);
4584
- return out;
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
- if (buf !== "") out.push(buf);
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
- function splitBlock(text, node, max) {
4619
- const span = spanOf(node);
4620
- if (span === null) return [];
4621
- const src = text.slice(span.from, span.to);
4622
- if (src.length <= max) return [src];
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
- if (cuts.length === 0) return splitLines(src, max);
4634
- const bounds = [span.from, ...cuts, span.to];
4635
- const out = [];
4636
- let buf = "";
4637
- for (let i = 0; i + 1 < bounds.length; i += 1) {
4638
- const piece = text.slice(bounds[i], bounds[i + 1]);
4639
- if (buf !== "" && buf.length + piece.length > max) {
4640
- out.push(buf);
4641
- buf = "";
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
- if (piece.length > max) {
4644
- if (buf !== "") {
4645
- out.push(buf);
4646
- buf = "";
4647
- }
4648
- for (const part of splitLines(piece, max)) out.push(part);
4649
- continue;
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
- buf += piece;
4652
- }
4653
- if (buf !== "") out.push(buf);
4654
- return out;
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
- const blocks = root.children;
4669
- for (const [i, block4] of blocks.entries()) {
4670
- const span = spanOf(block4);
4671
- if (span === null) continue;
4672
- const to = spanOf(blocks[i + 1] ?? block4)?.from ?? span.to;
4673
- const src = text.slice(span.from, Math.max(to, span.to));
4674
- if (src.trimEnd().length > max) {
4675
- flush();
4676
- for (const piece of splitBlock(text, block4, max)) {
4677
- buf = piece;
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
- if (buf !== "" && (buf + src).trimEnd().length > max) flush();
4683
- buf += src;
4684
- }
4685
- flush();
4686
- return out;
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 CALL_TIMEOUT_MS = 15e3;
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 fetchErrorText(err) {
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 ?? CALL_TIMEOUT_MS);
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: ${fetchErrorText(err)}`);
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: ${fetchErrorText(err)}`);
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-api.ts
7039
- import { WebSocket } from "undici";
7040
- var QQ_API_BASE = "https://api.bot.qq.com";
7041
- var QQ_INTENT_GROUP_AND_C2C = 1 << 25;
7042
- var CALL_TIMEOUT_MS2 = 15e3;
7043
- var TOKEN_REFRESH_MARGIN_SEC = 60;
7044
- var OP = {
7045
- DISPATCH: 0,
7046
- HEARTBEAT: 1,
7047
- IDENTIFY: 2,
7048
- RESUME: 6,
7049
- RECONNECT: 7,
7050
- INVALID_SESSION: 9,
7051
- HELLO: 10,
7052
- HEARTBEAT_ACK: 11
7053
- };
7054
- function closeCodeInvalidatesSession(code) {
7055
- return code === 4006 || code === 4007 || code >= 4900 && code <= 4913;
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 closeCodeIsFatal(code) {
7058
- return code === 4914 || code === 4915;
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
- var QQ_HANDSHAKE_TIMEOUT_MS = 3e4;
7061
- function defaultGatewayRetryMs(failures) {
7062
- return Math.min(1e3 * 2 ** (failures - 1), 6e4);
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 fetchErrorText2(err) {
7065
- if (err instanceof Error) {
7066
- if (err.name === "TimeoutError" || err.name === "AbortError") return "request timed out";
7067
- const cause = err.cause;
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 String(err);
7680
+ return { appId, appSecret };
7072
7681
  }
7073
- var QQ_BODY_INDEPENDENT_SEND_CODES = /* @__PURE__ */ new Set([
7074
- 40034128,
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
- var QQApiError = class extends Error {
7094
- constructor(message, code) {
7095
- super(message);
7096
- this.code = code;
7097
- this.name = "QQApiError";
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
- code;
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 createProductionClient(creds) {
7145
- const tokens = tokenCacheOf(creds);
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 createQQTransport(opts = {}) {
7194
- const retryMs = opts.gatewayRetryMs ?? defaultGatewayRetryMs;
7195
- const createSocket = opts.createSocket ?? ((url) => new WebSocket(url));
7196
- const handshakeMs = opts.handshakeTimeoutMs ?? QQ_HANDSHAKE_TIMEOUT_MS;
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 GatewaySession = class {
7211
- constructor(client, handlers, opts) {
7212
- this.client = client;
7213
- this.handlers = handlers;
7214
- this.opts = opts;
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
- client;
7217
- handlers;
7218
- opts;
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
- close() {
7240
- this.closed = true;
7241
- this.stopHeartbeat();
7242
- this.disarmHandshake();
7243
- if (this.retryTimer !== null) clearTimeout(this.retryTimer);
7244
- this.retryTimer = null;
7245
- const ws = this.ws;
7246
- this.ws = null;
7247
- try {
7248
- ws?.close();
7249
- } catch {
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
- async connect() {
7253
- if (this.closed) return;
7254
- try {
7255
- const token = await this.client.tokens.get();
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
- async fetchGatewayUrl(token) {
7265
- let res;
7266
- try {
7267
- res = await fetch(`${QQ_API_BASE}/gateway`, {
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
- onFrame(raw, token) {
7316
- let frame;
7317
- try {
7318
- frame = JSON.parse(raw);
7319
- } catch {
7320
- return;
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
- if (typeof frame.s === "number") this.seq = frame.s;
7323
- switch (frame.op) {
7324
- case OP.HELLO: {
7325
- const interval = frame.d?.heartbeat_interval;
7326
- this.startHeartbeat(typeof interval === "number" && interval > 0 ? interval : 45e3);
7327
- this.send(
7328
- this.sessionId !== null ? {
7329
- op: OP.RESUME,
7330
- d: { token: `QQBot ${token}`, session_id: this.sessionId, seq: this.seq ?? 0 }
7331
- } : {
7332
- op: OP.IDENTIFY,
7333
- d: {
7334
- token: `QQBot ${token}`,
7335
- intents: QQ_INTENT_GROUP_AND_C2C,
7336
- // Unsharded: this product runs one connection per bot.
7337
- shard: [0, 1],
7338
- // Documented as having no effect today; sent empty rather than inventing values.
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
- onDispatch(frame) {
7364
- if (frame.t === "READY") {
7365
- const d = frame.d;
7366
- if (typeof d?.session_id === "string") this.sessionId = d.session_id;
7367
- this.onHandshake();
7368
- return;
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
- if (frame.t === "RESUMED") {
7371
- this.onHandshake();
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
- const evt = normalizeDispatch(frame);
7375
- if (evt !== null) void this.handlers.onMessage(evt);
7871
+ this.withhold(ledger, text);
7872
+ if (ledger.spent < budget) this.scheduleFlush(ledger, this.tailFlushMs);
7376
7873
  }
7377
- /** A handshake completed: the outage (if any) is over and the backoff resets. */
7378
- onHandshake() {
7379
- this.disarmHandshake();
7380
- this.failures = 0;
7381
- this.reported = false;
7382
- this.handlers.onReady?.();
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
- disarmHandshake() {
7385
- if (this.handshakeTimer !== null) clearTimeout(this.handshakeTimer);
7386
- this.handshakeTimer = null;
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
- * Abandons a socket the session has given up on and starts the backoff itself.
7390
- *
7391
- * The `close` handler is not enough on its own here: the failure both watchdogs cover is
7392
- * a socket that never fires `close`, and asking a half-open one to close is a request the
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
- dropSocket(ws, err) {
7397
- if (this.ws !== ws) return;
7398
- this.stopHeartbeat();
7399
- this.disarmHandshake();
7400
- this.ws = null;
7401
- try {
7402
- ws.close();
7403
- } catch {
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
- this.fail(err);
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
- if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));
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
- if (frame.t === "GROUP_AT_MESSAGE_CREATE") {
7456
- const openid = d.group_openid;
7457
- if (typeof openid !== "string" || openid === "") return null;
7458
- const sender = d.author?.member_openid;
7459
- return {
7460
- kind: "group",
7461
- openid,
7462
- messageId: d.id,
7463
- content,
7464
- ...typeof sender === "string" && sender !== "" ? { senderOpenid: sender } : {}
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
- return null;
7468
- }
7469
-
7470
- // src/runtime/messaging/qq-markdown.ts
7471
- var LIST_INDENT2 = " ";
7472
- var RULE2 = "***";
7473
- var INLINE_SPECIALS = /[\\`*_~[\]<]/g;
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
- case "break":
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
- function plainText3(nodes) {
7524
- return nodes.map((node) => {
7525
- if (node.type === "text" || node.type === "inlineCode" || node.type === "html") {
7526
- return node.value;
7527
- }
7528
- if (node.type === "break") return " ";
7529
- if (node.type === "image") return node.alt ?? "";
7530
- return "children" in node ? plainText3(node.children) : "";
7531
- }).join("");
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-connector.ts
7601
- var QQ_REPLY_BUDGET = { c2c: 4, group: 5 };
7602
- var QQ_MIN_REPLY_BUDGET = Math.min(...Object.values(QQ_REPLY_BUDGET));
7603
- var QQ_PASSIVE_WINDOW_MS = 5 * 6e4;
7604
- var QQ_TAIL_FLUSH_MS = 1500;
7605
- var QQ_TAIL_MAX_CHARS = 2e4;
7606
- var QQ_TAIL_ELIDED = "\u2026(earlier part omitted \u524D\u7565)\u2026";
7607
- function refuseMedia(file) {
7608
- return Promise.reject(
7609
- new MessagingUnsupportedError(
7610
- `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`
7611
- )
7612
- );
7613
- }
7614
- function qqConfigOf(config) {
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 { appId, appSecret };
7984
+ return String(err);
7620
7985
  }
7621
- function qqChatIdOf(kind, openid) {
7622
- return `${kind}:${openid}`;
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 parseQQChatId(chatId) {
7625
- const i = chatId.indexOf(":");
7626
- const kind = i > 0 ? chatId.slice(0, i) : "";
7627
- const openid = i > 0 ? chatId.slice(i + 1) : "";
7628
- if (kind !== "c2c" && kind !== "group" || openid === "") {
7629
- throw new Error(`malformed qq chat id "${chatId}"`);
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 { kind, openid };
8013
+ return bots;
7632
8014
  }
7633
- function qqReplyRefOf(kind, openid, messageId) {
7634
- return `${qqChatIdOf(kind, openid)}:${messageId}`;
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 chatOfReplyRef(ref) {
7637
- const cut = ref.lastIndexOf(":");
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
- var QQConnector = class {
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
- channel = "qq";
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
- async createClient(config) {
7665
- const creds = qqConfigOf(config);
7666
- const bot = this.transport.createClient(creds);
7667
- const deliver = (chatId, text, opts) => {
7668
- const { kind, openid } = parseQQChatId(chatId);
7669
- return this.enqueue(creds, bot, kind, openid, text, opts?.markdown === true);
7670
- };
7671
- const client = {
7672
- async checkCredentials() {
7673
- await bot.checkCredentials();
7674
- return null;
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
- ledgerFor(appId, kind, openid) {
7719
- const key = this.ledgerKey(appId, kind, openid);
7720
- let ledger = this.ledgers.get(key);
7721
- if (ledger === void 0) {
7722
- this.evictExpired();
7723
- ledger = {
7724
- appId,
7725
- kind,
7726
- openid,
7727
- msgId: null,
7728
- receivedAt: 0,
7729
- spent: 0,
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
- return ledger;
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
- /** Clears a ledger's pending flush and forgets it. */
7741
- dropLedger(key, ledger) {
7742
- if (ledger.timer !== null) clearTimeout(ledger.timer);
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
- /** Forgets every ledger of one credential set — the connection's close (see connect). */
7746
- dropLedgers(appId) {
7747
- for (const [key, ledger] of this.ledgers) {
7748
- if (ledger.appId === appId) this.dropLedger(key, ledger);
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
- * Forgets every ledger whose anchor message is past the reply window. An unanchored one
7753
- * is left alone: it holds no budget to lose, and the client that just created it is
7754
- * holding the object.
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
- evictExpired() {
7757
- for (const [key, ledger] of this.ledgers) {
7758
- if (ledger.msgId === null) continue;
7759
- if (this.now() - ledger.receivedAt <= QQ_PASSIVE_WINDOW_MS) continue;
7760
- this.dropLedger(key, ledger);
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
- * A new inbound message: fresh budget, and the moment a withheld tail can finally go out.
7765
- *
7766
- * A tail is carried onto the new budget only while the message it was written for is
7767
- * still inside its own window. Past that it is dropped — the one place anything is — and
7768
- * deliberately: text withheld longer than the platform's whole reply window is no longer
7769
- * an answer to anything the user still has in view, and delivering it as the first thing
7770
- * they see after saying "hi" would read as the bot malfunctioning. The loss is bounded by
7771
- * the same window everything else on this channel is.
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
- * One outbound body. Sends immediately while an ordinary slot is free, otherwise
7793
- * withholds it for the reserved final slot; throws when the chat has no repliable
7794
- * message at all, which is the one thing QQ gives no way around (see the module doc).
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
- async enqueue(creds, bot, kind, openid, text, markdown) {
7797
- const ledger = this.ledgerFor(creds.appId, kind, openid);
7798
- ledger.bot = bot;
7799
- ledger.markdown = markdown;
7800
- if (!this.repliable(ledger)) {
7801
- throw new Error(
7802
- "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"
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
- const budget = QQ_REPLY_BUDGET[kind];
7806
- if (ledger.spent < budget - 1) {
7807
- await this.chain(ledger, () => this.sendNow(ledger, text, markdown));
7808
- return;
8238
+ case "link": {
8239
+ const label = inline4(node.children);
8240
+ return isSafeUrl(node.url) ? `[${label}](${href3(node.url)})` : label;
7809
8241
  }
7810
- this.withhold(ledger, text);
7811
- if (ledger.spent < budget) this.scheduleFlush(ledger, this.tailFlushMs);
7812
- }
7813
- /** Appends to the withheld tail, dropping from the FRONT if the ceiling is reached. */
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
- scheduleFlush(ledger, delayMs) {
7824
- if (ledger.timer !== null) clearTimeout(ledger.timer);
7825
- ledger.timer = setTimeout(() => {
7826
- ledger.timer = null;
7827
- void this.chain(ledger, () => this.flush(ledger)).catch(() => {
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
- ledger.tail = chunks.slice(1);
7849
- try {
7850
- await this.sendNow(ledger, head, ledger.markdown);
7851
- } catch (err) {
7852
- ledger.tail.unshift(head);
7853
- throw err;
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
- * One real send: the next `msg_seq` against the ledger's anchor message, as Markdown when
7858
- * the binding asked for it and as plain text if the platform refuses that.
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
- * The fallback costs a SLOT here, which it does on no other channel: a repeated
7861
- * `(msg_id, msg_seq)` pair is refused rather than deduplicated, so the retry must carry a
7862
- * fresh sequence number and that number is one of the four an inbound message funds. It is
7863
- * still the right trade — a reply the reader never sees is worse than a reply that spent
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
- async sendNow(ledger, text, markdown) {
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
- * Appends to the chat's send chain. Two sends against one `msg_id` must reach the
7896
- * platform in `msg_seq` order, and the chain never rejects — the caller's promise carries
7897
- * the failure, and one failed send must not poison the chat's later ones.
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
- chain(ledger, run2) {
7900
- const result = ledger.chain.then(run2);
7901
- ledger.chain = result.catch(() => {
7902
- });
7903
- return result;
7904
- }
8397
+ timedOut;
7905
8398
  };
7906
-
7907
- // src/runtime/messaging/qq-scan.ts
7908
- import { createDecipheriv, randomBytes as randomBytes2 } from "crypto";
7909
- var QQ_SCAN_BASE = "https://q.qq.com";
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 statusOf(raw) {
7926
- switch (Number(raw)) {
7927
- case 0:
7928
- return "none";
7929
- case 2:
7930
- return "completed";
7931
- case 3:
7932
- return "expired";
7933
- default:
7934
- return "pending";
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 botsOf(data) {
7938
- const entries = data === void 0 ? [] : Array.isArray(data) ? data : [data];
7939
- const bots = [];
7940
- for (const entry of entries) {
7941
- const appId = entry.bot_appid;
7942
- const secret = entry.bot_encrypt_secret;
7943
- if (appId === void 0 || appId === "" || typeof secret !== "string" || secret === "") {
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
- bots.push({
7947
- appId: String(appId),
7948
- encryptedSecret: secret,
7949
- ...typeof entry.user_openid === "string" ? { userOpenid: entry.user_openid } : {}
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
- return bots;
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 createQQScanTransport(base = QQ_SCAN_BASE) {
7955
- const post = async (path32, payload) => {
7956
- let res;
7957
- try {
7958
- res = await fetch(`${base}${path32}`, {
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 createBindTask(key) {
7977
- const data = await post("/lite/create_bind_task", { key });
7978
- const taskId = data?.task_id;
7979
- if (typeof taskId !== "string" || taskId === "") {
7980
- throw new Error("QQ scan request failed: no task id was returned");
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
- return taskId;
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 pollBindResult(taskId) {
7985
- const data = await post("/lite/poll_bind_result", {
7986
- task_id: taskId
7987
- });
7988
- const first = Array.isArray(data) ? data[0] : data;
7989
- return { status: statusOf(first?.status), bots: botsOf(data) };
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
- function newQQScanKey() {
7994
- return randomBytes2(QQ_SCAN_KEY_BYTES).toString("base64");
7995
- }
7996
- function qqScanQrUrl(taskId, source = "") {
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 key, a task registered under it, and the URL for the QR. */
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 key = newQQScanKey();
8040
- const taskId = await this.transport.createBindTask(key);
9040
+ const { qrcode, qrUrl } = await this.transport.createQrCode();
8041
9041
  this.evictOwn(sessionId);
8042
- this.tasks.set(taskId, { key, sessionId, createdAt: this.now(), claimed: false });
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: qqScanQrUrl(taskId), pollMs: QQ_SCAN_POLL_MS };
9051
+ return { taskId, qrUrl, pollMs: WECHAT_SCAN_POLL_MS };
8045
9052
  }
8046
9053
  /**
8047
- * Polls one task. Returns null when the task id is not one this Session started (an
8048
- * unknown id, another Session's, one already consumed, or one another poll is resolving
8049
- * right now) — the caller answers 404, which is also what a replay gets.
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 || task.claimed) return null;
8054
- task.claimed = true;
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.pollBindResult(taskId);
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.claimed = false;
9089
+ task.polling = false;
8064
9090
  throw err;
9091
+ } finally {
9092
+ task.polling = false;
8065
9093
  }
8066
- if (result.status === "expired") {
8067
- this.tasks.delete(taskId);
8068
- return { status: "expired" };
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 !== "completed") {
8071
- task.claimed = false;
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
- const bot = result.bots[0];
8076
- if (bot === void 0) {
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
- const appSecret = decryptQQBotSecret(task.key, bot.encryptedSecret);
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 < QQ_SCAN_MAX_TASKS_PER_SESSION) return;
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 - QQ_SCAN_MAX_TASKS_PER_SESSION + 1)) {
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() - QQ_SCAN_TASK_TTL_MS;
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 < QQ_SCAN_MAX_TASKS) return;
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 - QQ_SCAN_MAX_TASKS + 1)) {
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 key roughly at the TTL instead of at whenever somebody
8120
- * happens to start the next scan — which may be never, and a key at rest is the one
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
- }, QQ_SCAN_TASK_TTL_MS);
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 randomBytes3 } from "crypto";
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 createHash2, timingSafeEqual } from "crypto";
9380
+ import { createHash as createHash3, timingSafeEqual } from "crypto";
8360
9381
  function digest(value) {
8361
- return createHash2("sha256").update(value).digest();
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 block4 = {
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(block4).length > 0) next.default_chat = block4;
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 block4 = {};
9014
- if (req.enabled === false) block4.enabled = false;
9015
- block4.rules = req.rules.map((r) => ({
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: block4 });
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
- return { cacheRead: cacheRead ?? 0, cacheWrite: cacheWrite ?? 0, output: output ?? 0 };
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 randomBytes4 } from "crypto";
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-${randomBytes4(4).toString("hex")}`;
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-${randomBytes4(6).toString("hex")}`);
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 createHash3, randomBytes as randomBytes5, randomUUID as randomUUID4 } from "crypto";
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 createHash3("sha256").update(verifier, "utf8").digest("base64url");
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 = randomBytes5(32).toString("base64url");
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, rates) {
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 todayRows = this.usage.bucketByModel(projectId, win(today, today));
13901
- const last7dRows = this.usage.bucketByModel(projectId, win(localDateMinusDays(this.now(), 6)));
13902
- const totalRows = this.usage.bucketByModel(projectId, { ...win(q.from, q.to), ...ts });
13903
- const groupRows = this.usage.groupsByModel(projectId, q.groupBy, {
13904
- ...win(q.from, q.to),
13905
- ...ts
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(projectId, granularity, {
13927
- ...win(seriesFrom, seriesTo),
13928
- ...ts
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 at most STORE_KEEP versions — the committed one is always
16106
- * kept, the rest by recency. Best-effort; ordered after the manifest flip so
16107
- * nothing referenced can be pruned. `platform`, `cli`, and `web` are three
16108
- * independent subtrees now (no shared file to piggyback a sweep on), so each
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 randomBytes6, timingSafeEqual as timingSafeEqual2 } from "crypto";
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 = randomBytes6(32)) {
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
- /** Sums (broken down by paired reference): date range + optional agent/model filter. */
16798
- bucketByModel(projectId, f = {}) {
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 esc4 = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
18666
+ const esc5 = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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>${esc4(title)}</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>${esc4(title)}</h1><p>${esc4(body)}</p></main></body>
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 createHash4 } from "crypto";
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 statusOf2(def, state, nowMs) {
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: statusOf2(def, state, nowMs),
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: createHash4("sha1").update(raw).digest("hex"),
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 block4 = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd.replace(/^\uFEFF/, ""))?.[1];
19183
- if (block4 === void 0) return null;
19184
- for (const line of block4.split(/\r?\n/)) {
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 toFeishuInfo(row) {
19811
- const fields = feishuFieldsOf(row);
21013
+ function wechatFieldsOf(row) {
21014
+ const { botId, botToken } = row.config;
19812
21015
  return {
19813
- channel: "feishu",
19814
- sessionId: row.sessionId,
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
- function toTelegramInfo(row) {
19830
- const { botToken } = telegramFieldsOf(row);
19831
- return {
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
- sessionId: row.sessionId,
19834
- botId: row.accountId,
19835
- ...botToken !== "" ? { botTokenMasked: maskApiKey(botToken) } : {},
19836
- enabled: row.enabled,
19837
- linePerMessage: row.linePerMessage,
19838
- finalReplyOnly: row.finalReplyOnly,
19839
- renderMarkdown: row.renderMarkdown,
19840
- lastChatKnown: row.lastChatId !== null,
19841
- createdAt: row.createdAt,
19842
- updatedAt: row.updatedAt
19843
- };
19844
- }
19845
- function toQQInfo(row) {
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
- sessionId: row.sessionId,
19850
- appId: fields.appId,
19851
- ...fields.appSecret !== "" ? { appSecretMasked: maskApiKey(fields.appSecret) } : {},
19852
- enabled: row.enabled,
19853
- linePerMessage: row.linePerMessage,
19854
- finalReplyOnly: row.finalReplyOnly,
19855
- renderMarkdown: row.renderMarkdown,
19856
- lastChatKnown: row.lastChatId !== null,
19857
- createdAt: row.createdAt,
19858
- updatedAt: row.updatedAt
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
- if (row.channel === "feishu") return toFeishuInfo(row);
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 feishuResponse = (sessionId) => {
19905
- const row = deps.messagingRepo.find(sessionId, "feishu");
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 ? toTelegramInfo(row) : null,
19915
- status: deps.messaging.statusOf(sessionId, "telegram")
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 secret = channel === "feishu" ? feishuFieldsOf(row).appSecret : channel === "qq" ? qqFieldsOf(row).appSecret : telegramFieldsOf(row).botToken;
19949
- if (secret === "") {
19950
- const code = channel === "feishu" ? "feishu_secret_required" : channel === "qq" ? "qq_secret_required" : "telegram_token_required";
19951
- throw new HttpError(400, code, "The stored config has no credential: save one first.");
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
- app.get("/:sessionId/messaging/feishu", (c) => {
19965
- const row = resolveSession(c);
19966
- return c.json(feishuResponse(row.sessionId));
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 typed = secretInput !== void 0 && secretInput !== "" ? secretInput : void 0;
19982
- let appSecret;
19983
- if (typed !== void 0) appSecret = typed;
19984
- else if (clearSecret && existing !== null) {
19985
- if (existing.enabled) {
19986
- throw new HttpError(
19987
- 409,
19988
- "messaging_disable_before_clear",
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
- ...linePerMessage !== void 0 ? { linePerMessage } : {},
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 typed = tokenInput !== void 0 && tokenInput !== "" ? tokenInput : void 0;
20095
- let botToken;
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 (typed !== void 0) {
20098
- const id = telegramBotIdOf(typed);
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
- ...linePerMessage !== void 0 ? { linePerMessage } : {},
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 typed = secretInput !== void 0 && secretInput !== "" ? secretInput : void 0;
20218
- let appSecret;
20219
- if (typed !== void 0) appSecret = typed;
20220
- else if (clearSecret && existing !== null) {
20221
- if (existing.enabled) {
20222
- throw new HttpError(
20223
- 409,
20224
- "messaging_disable_before_clear",
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
- ...linePerMessage !== void 0 ? { linePerMessage } : {},
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
- if (deps.messagingRepo.find(row.sessionId, "qq")?.enabled === true) {
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 bound = deps.messagingRepo.find(row.sessionId, "qq");
20309
- if (bound !== null && bound.enabled) guardAccountFree(row.sessionId, "qq", result.bot.appId);
20310
- const saved = deps.messagingRepo.upsert({
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: toQQInfo(saved)
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.post("/:sessionId/messaging/qq/test-message", async (c) => {
21450
+ app.put("/:sessionId/messaging/wechat", async (c) => {
20351
21451
  const row = resolveSession(c);
20352
- const binding = deps.messagingRepo.find(row.sessionId, "qq");
20353
- if (!binding) {
20354
- throw new HttpError(404, "qq_not_bound", "This Session has no QQ binding.");
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
- if (binding.lastChatId === null) {
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
- 409,
20359
- "qq_no_chat",
20360
- "No QQ chat is known yet: message the bot once in QQ first."
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.messaging.sendTestMessage(binding);
21506
+ result = await deps.wechatScan.poll(row.sessionId, taskId);
20365
21507
  } catch (err) {
20366
- throw new HttpError(502, "qq_send_failed", err instanceof Error ? err.message : String(err));
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
- return c.json({ ok: true });
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 randomBytes7 } from "crypto";
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-${randomBytes7(6).toString("hex")}.ps1`;
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 = `"${createHash5("sha256").update(content).digest("base64url").slice(0, 16)}"`;
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,