@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +478 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +450 -31
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +46 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +805 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +110 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/push.ts +284 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +143 -0
- package/src/requirement-notify.ts +134 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-list-slim-http.test.ts +315 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1439 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4522 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +307 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3534 -343
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { maybeGzipResponse, wantsGzip, isCompressibleResponse, trimLightTask, GZIP_MIN_BYTES, LIGHT_TASK_MAX_CHARS } from "./http-gzip";
|
|
3
|
+
|
|
4
|
+
const big = JSON.stringify({ sessions: Array.from({ length: 200 }, (_, i) => ({ alias: `节点${i}`, task: "x".repeat(50) })) });
|
|
5
|
+
const json = (body: string, extra: Record<string, string> = {}) =>
|
|
6
|
+
new Response(body, { status: 200, headers: { "Content-Type": "application/json; charset=utf-8", ...extra } });
|
|
7
|
+
const reqWith = (ae?: string, method = "GET") =>
|
|
8
|
+
new Request("http://hub/api/status?light=1", { method, headers: ae === undefined ? {} : { "Accept-Encoding": ae } });
|
|
9
|
+
|
|
10
|
+
test("wantsGzip reads Accept-Encoding, including q-values", () => {
|
|
11
|
+
expect(wantsGzip(reqWith("gzip, deflate, br"))).toBe(true);
|
|
12
|
+
expect(wantsGzip(reqWith("br;q=1.0, gzip;q=0.8"))).toBe(true);
|
|
13
|
+
expect(wantsGzip(reqWith("identity"))).toBe(false);
|
|
14
|
+
expect(wantsGzip(reqWith("gzip;q=0"))).toBe(false);
|
|
15
|
+
expect(wantsGzip(reqWith())).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("a large JSON body is gzipped only when the client asked, and round-trips", async () => {
|
|
19
|
+
const out = await maybeGzipResponse(reqWith("gzip"), json(big));
|
|
20
|
+
expect(out!.headers.get("Content-Encoding")).toBe("gzip");
|
|
21
|
+
expect(out!.headers.get("Vary")).toContain("Accept-Encoding");
|
|
22
|
+
const bytes = new Uint8Array(await out!.arrayBuffer());
|
|
23
|
+
expect(bytes.byteLength).toBeLessThan(big.length / 3);
|
|
24
|
+
expect(Number(out!.headers.get("Content-Length"))).toBe(bytes.byteLength);
|
|
25
|
+
expect(new TextDecoder().decode(Bun.gunzipSync(bytes))).toBe(big);
|
|
26
|
+
expect(out!.headers.get("Content-Type")).toContain("application/json");
|
|
27
|
+
|
|
28
|
+
const plain = json(big);
|
|
29
|
+
expect(await maybeGzipResponse(reqWith(), plain)).toBe(plain); // untouched object
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("small, streamed, already-encoded, SSE, 204 and HEAD responses are left alone", async () => {
|
|
33
|
+
const small = json("{\"ok\":true}");
|
|
34
|
+
const outSmall = await maybeGzipResponse(reqWith("gzip"), small);
|
|
35
|
+
expect(outSmall!.headers.get("Content-Encoding")).toBeNull();
|
|
36
|
+
expect(await outSmall!.text()).toBe("{\"ok\":true}");
|
|
37
|
+
expect(GZIP_MIN_BYTES).toBe(1024);
|
|
38
|
+
|
|
39
|
+
const sse = new Response(new ReadableStream(), { headers: { "Content-Type": "text/event-stream" } });
|
|
40
|
+
expect(isCompressibleResponse(sse)).toBe(false);
|
|
41
|
+
expect(await maybeGzipResponse(reqWith("gzip"), sse)).toBe(sse);
|
|
42
|
+
|
|
43
|
+
const encoded = json(big, { "Content-Encoding": "br" });
|
|
44
|
+
expect(await maybeGzipResponse(reqWith("gzip"), encoded)).toBe(encoded);
|
|
45
|
+
|
|
46
|
+
const noContent = new Response(null, { status: 204 });
|
|
47
|
+
expect(await maybeGzipResponse(reqWith("gzip"), noContent)).toBe(noContent);
|
|
48
|
+
|
|
49
|
+
const head = json(big);
|
|
50
|
+
expect(await maybeGzipResponse(reqWith("gzip", "HEAD"), head)).toBe(head);
|
|
51
|
+
|
|
52
|
+
// /api/files downloads: Range replies and attachments keep their exact byte lengths (CI #509/#514 caught this)
|
|
53
|
+
const partial = new Response("x".repeat(4096), { status: 206, headers: { "Content-Type": "text/plain", "Content-Range": "bytes 0-4095/8192" } });
|
|
54
|
+
expect(await maybeGzipResponse(reqWith("gzip"), partial)).toBe(partial);
|
|
55
|
+
const attachment = new Response("y".repeat(4096), { status: 200, headers: { "Content-Type": "text/plain", "Content-Disposition": "attachment; filename=\"a.txt\"" } });
|
|
56
|
+
expect(await maybeGzipResponse(reqWith("gzip"), attachment)).toBe(attachment);
|
|
57
|
+
|
|
58
|
+
const binary = new Response(new Uint8Array(4096), { headers: { "Content-Type": "image/png" } });
|
|
59
|
+
expect(await maybeGzipResponse(reqWith("gzip"), binary)).toBe(binary);
|
|
60
|
+
|
|
61
|
+
expect(await maybeGzipResponse(reqWith("gzip"), undefined)).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("trimLightTask keeps a one-line preview under the cap", () => {
|
|
65
|
+
expect(trimLightTask(null)).toBeNull();
|
|
66
|
+
expect(trimLightTask(" ")).toBeNull();
|
|
67
|
+
expect(trimLightTask("a\n b")).toBe("a b");
|
|
68
|
+
const long = "字".repeat(LIGHT_TASK_MAX_CHARS + 40);
|
|
69
|
+
const t = trimLightTask(long)!;
|
|
70
|
+
expect(t.endsWith("…")).toBe(true);
|
|
71
|
+
expect([...t].length).toBe(LIGHT_TASK_MAX_CHARS + 1);
|
|
72
|
+
expect(trimLightTask("short")).toBe("short");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a response marked reusable is gzipped once per key; unmarked ones every time; different keys never share bytes", async () => {
|
|
76
|
+
const { markGzipReusable, gzipCacheStats, __resetGzipCacheForTest } = await import("./http-gzip");
|
|
77
|
+
__resetGzipCacheForTest();
|
|
78
|
+
const first = await maybeGzipResponse(reqWith("gzip"), markGzipReusable(json(big), "W/\"k1\""));
|
|
79
|
+
const second = await maybeGzipResponse(reqWith("gzip"), markGzipReusable(json(big), "W/\"k1\""));
|
|
80
|
+
expect(gzipCacheStats).toEqual({ hits: 1, misses: 1 });
|
|
81
|
+
const a = new Uint8Array(await first!.arrayBuffer());
|
|
82
|
+
const b = new Uint8Array(await second!.arrayBuffer());
|
|
83
|
+
expect(new TextDecoder().decode(Bun.gunzipSync(b))).toBe(big);
|
|
84
|
+
expect(Buffer.from(b).equals(Buffer.from(a))).toBe(true);
|
|
85
|
+
expect(Number(second!.headers.get("Content-Length"))).toBe(b.byteLength);
|
|
86
|
+
const other = big.replace("节点0", "节点X");
|
|
87
|
+
const third = await maybeGzipResponse(reqWith("gzip"), markGzipReusable(json(other), "W/\"k2\""));
|
|
88
|
+
expect(new TextDecoder().decode(Bun.gunzipSync(new Uint8Array(await third!.arrayBuffer())))).toBe(other);
|
|
89
|
+
await maybeGzipResponse(reqWith("gzip"), json(big));
|
|
90
|
+
expect(gzipCacheStats).toEqual({ hits: 1, misses: 2 });
|
|
91
|
+
// 没要 gzip 的客户端照旧拿原样正文,不碰缓存
|
|
92
|
+
const plain = markGzipReusable(json(big), "W/\"k1\"");
|
|
93
|
+
expect(await maybeGzipResponse(reqWith(), plain)).toBe(plain);
|
|
94
|
+
});
|
package/src/http-gzip.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// 2026-09-16(Vincent:「发一个消息都要被吞一段时间」「打开窗口历史很久才出来」):
|
|
2
|
+
// 桌面端经 RELAY 连 hub,链路实测只有 ~13 KB/s;/api/status?light=1 128 KB 要 17 s,
|
|
3
|
+
// 一页 20 条任务 41 KB 要 5.6 s,而 hub 从不压缩。gzip 后 128 KB → 31 KB、137 KB → 49 KB。
|
|
4
|
+
// 只在客户端明确 `Accept-Encoding: gzip` 时压;text/event-stream(SSE)与流式 body 一律不碰。
|
|
5
|
+
export const GZIP_MIN_BYTES = 1024;
|
|
6
|
+
|
|
7
|
+
const COMPRESSIBLE = /^(application\/json|text\/(plain|html|css|javascript)|application\/javascript)\b/i;
|
|
8
|
+
|
|
9
|
+
export function wantsGzip(req: Request): boolean {
|
|
10
|
+
const ae = req.headers.get("accept-encoding") || "";
|
|
11
|
+
for (const part of ae.split(",")) {
|
|
12
|
+
const [coding, ...params] = part.trim().split(";").map((x) => x.trim());
|
|
13
|
+
if (coding.toLowerCase() !== "gzip") continue;
|
|
14
|
+
const q = params.find((x) => /^q=/i.test(x));
|
|
15
|
+
if (!q) return true;
|
|
16
|
+
const n = Number(q.slice(2));
|
|
17
|
+
return Number.isFinite(n) && n > 0;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isCompressibleResponse(res: Response): boolean {
|
|
23
|
+
// 只压完整的 200:206 Partial Content / Content-Range / 附件下载(Content-Disposition)都有自己的
|
|
24
|
+
// 字节语义(Range 客户端按 Content-Length 数字节),压了就错 —— CI 的 /api/files Range 测试抓到的。
|
|
25
|
+
if (res.status !== 200 || !res.body) return false;
|
|
26
|
+
if (res.headers.get("content-encoding") || res.headers.get("content-range") || res.headers.get("content-disposition")) return false;
|
|
27
|
+
const ct = res.headers.get("content-type") || "";
|
|
28
|
+
if (/text\/event-stream/i.test(ct)) return false;
|
|
29
|
+
return COMPRESSIBLE.test(ct);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 2026-09-30(连接较慢横幅):任务页每 15 s 轮询 ~1 MB 的需求表,每次都在事件循环上同步 gzip 一遍,
|
|
33
|
+
// 而两次轮询之间表多半没变。带内容哈希 ETag 的响应(conditionalJson)标一个键,同一份正文只压一次。
|
|
34
|
+
// 键就是正文的哈希,所以不同调用者拿到同一份压缩字节是安全的:字节相同 ⇔ 正文相同。
|
|
35
|
+
export const GZIP_CACHE_MAX_BYTES = 16 * 1024 * 1024;
|
|
36
|
+
const gzipKeys = new WeakMap<Response, string>();
|
|
37
|
+
const gzipCache = new Map<string, Uint8Array>();
|
|
38
|
+
let gzipCacheBytes = 0;
|
|
39
|
+
export const gzipCacheStats = { hits: 0, misses: 0 };
|
|
40
|
+
|
|
41
|
+
/** 标记这个响应的正文由 `key`(正文的内容哈希)唯一决定:压缩结果可以按键复用。 */
|
|
42
|
+
export function markGzipReusable(res: Response, key: string): Response {
|
|
43
|
+
gzipKeys.set(res, key);
|
|
44
|
+
return res;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function gzipCached(key: string | undefined, raw: () => Promise<Uint8Array>): Promise<{ raw: Uint8Array | null; gz: Uint8Array | null }> | { raw: null; gz: Uint8Array } {
|
|
48
|
+
if (key) {
|
|
49
|
+
const hit = gzipCache.get(key);
|
|
50
|
+
if (hit) {
|
|
51
|
+
gzipCacheStats.hits++;
|
|
52
|
+
gzipCache.delete(key);
|
|
53
|
+
gzipCache.set(key, hit); // 最近用过的挪到队尾(LRU)
|
|
54
|
+
return { raw: null, gz: hit };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return raw().then(bytes => {
|
|
58
|
+
if (bytes.byteLength < GZIP_MIN_BYTES) return { raw: bytes, gz: null };
|
|
59
|
+
const gz = Bun.gzipSync(bytes);
|
|
60
|
+
if (key) gzipCacheStats.misses++;
|
|
61
|
+
if (key && gz.byteLength <= GZIP_CACHE_MAX_BYTES / 4) {
|
|
62
|
+
gzipCache.set(key, gz);
|
|
63
|
+
gzipCacheBytes += gz.byteLength;
|
|
64
|
+
for (const [k, v] of gzipCache) {
|
|
65
|
+
if (gzipCacheBytes <= GZIP_CACHE_MAX_BYTES) break;
|
|
66
|
+
gzipCache.delete(k);
|
|
67
|
+
gzipCacheBytes -= v.byteLength;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { raw: bytes, gz };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Test-only. */
|
|
75
|
+
export function __resetGzipCacheForTest(): void {
|
|
76
|
+
gzipCache.clear();
|
|
77
|
+
gzipCacheBytes = 0;
|
|
78
|
+
gzipCacheStats.hits = 0;
|
|
79
|
+
gzipCacheStats.misses = 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Gzip a finished response when the client asked for it and the body is worth it.
|
|
84
|
+
* Reads the body once; callers must not have consumed it. Anything streamed or
|
|
85
|
+
* not compressible is returned untouched (same object).
|
|
86
|
+
*/
|
|
87
|
+
export async function maybeGzipResponse(req: Request, res: Response | undefined | void): Promise<Response | undefined> {
|
|
88
|
+
if (!res) return res as undefined;
|
|
89
|
+
if (req.method === "HEAD" || !wantsGzip(req) || !isCompressibleResponse(res)) return res;
|
|
90
|
+
const got = await gzipCached(gzipKeys.get(res), async () => new Uint8Array(await res.arrayBuffer()));
|
|
91
|
+
if (!got.gz) {
|
|
92
|
+
return new Response(got.raw, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
93
|
+
}
|
|
94
|
+
const gz = got.gz;
|
|
95
|
+
const headers = new Headers(res.headers);
|
|
96
|
+
headers.set("Content-Encoding", "gzip");
|
|
97
|
+
headers.set("Content-Length", String(gz.byteLength));
|
|
98
|
+
const vary = headers.get("Vary");
|
|
99
|
+
headers.set("Vary", vary && !/accept-encoding/i.test(vary) ? `${vary}, Accept-Encoding` : (vary || "Accept-Encoding"));
|
|
100
|
+
return new Response(gz, { status: res.status, statusText: res.statusText, headers });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 轻量列表里的 task 只给列表预览用;全文占了 light 载荷的一半以上。 */
|
|
104
|
+
export const LIGHT_TASK_MAX_CHARS = 160;
|
|
105
|
+
export function trimLightTask(task: unknown): string | null {
|
|
106
|
+
if (typeof task !== "string") return null;
|
|
107
|
+
const t = task.replace(/\s+/g, " ").trim();
|
|
108
|
+
if (!t) return null;
|
|
109
|
+
return t.length > LIGHT_TASK_MAX_CHARS ? `${t.slice(0, LIGHT_TASK_MAX_CHARS)}…` : t;
|
|
110
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
3
|
+
|
|
4
|
+
const RAW = "2026-08-30 21:11:24"; // sessions.last_seen_at 的真实取值
|
|
5
|
+
const TRUE_UTC = Date.UTC(2026, 7, 30, 21, 11, 24);
|
|
6
|
+
|
|
7
|
+
describe("parseHubTimestamp (#1650)", () => {
|
|
8
|
+
test("🔴 非 UTC 时区下也必须解出同一个 UTC 时刻", () => {
|
|
9
|
+
// `bun test` 把测试进程时区固定成 UTC(getTimezoneOffset() === 0)。
|
|
10
|
+
// 在 UTC 下,「补 Z」和「不补 Z」解析出**同一个数** —— 这个函数存在的那个
|
|
11
|
+
// 缺陷在测试进程里**结构上不可见**。所以这一条必须开 TZ≠UTC 的子进程。
|
|
12
|
+
// 实测:去掉补 Z,本文件其余各条**全绿**。
|
|
13
|
+
const child = `import { parseHubTimestamp } from ${JSON.stringify(import.meta.dir + "/hub-timestamp.ts")};
|
|
14
|
+
process.stdout.write(new Date().getTimezoneOffset() + "," + parseHubTimestamp(${JSON.stringify(RAW)}));`;
|
|
15
|
+
const r = Bun.spawnSync(["bun", "-e", child], { env: { ...process.env, TZ: "Asia/Shanghai" } });
|
|
16
|
+
const [offset, parsed] = r.stdout.toString().trim().split(",");
|
|
17
|
+
// 🔴 先断言控制生效:子进程真的不在 UTC。否则 TZ 被忽略时这条又变成空测试。
|
|
18
|
+
expect(Number(offset)).not.toBe(0);
|
|
19
|
+
expect(parsed).toBe(String(TRUE_UTC));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("已带 Z / ±HH:MM 的串不再追加时区", () => {
|
|
23
|
+
expect(parseHubTimestamp("2026-08-30T21:11:24Z")).toBe(TRUE_UTC);
|
|
24
|
+
expect(parseHubTimestamp("2026-08-30T22:11:24+01:00")).toBe(TRUE_UTC);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("拿不到就返回 null,不返回一个看起来合理的数", () => {
|
|
28
|
+
for (const bad of [undefined, null, "", " ", 0, 1788124284000, {}, [], "2026-08-30", "垃圾"]) {
|
|
29
|
+
expect(parseHubTimestamp(bad as unknown)).toBeNull();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("🔴 INTEGER 列(epoch 毫秒)不归它管 —— 传数字必须返回 null,而不是悄悄接受", () => {
|
|
34
|
+
// external_schedule_edits.* 是 INTEGER。如果有人把本函数用在那儿,
|
|
35
|
+
// 应当立刻拿到 null 而不是一个错的时刻。
|
|
36
|
+
expect(parseHubTimestamp(TRUE_UTC)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1650 — hub 的 TEXT 时间戳列是 SQLite `datetime('now')` 写的,
|
|
3
|
+
* 形如 `2026-08-30 21:11:24`:**UTC,但不带任何时区标记**。
|
|
4
|
+
*
|
|
5
|
+
* 🔴 `new Date(raw)` / `Date.parse(raw)` 对这种串按**本地时间**解析(ES 规范:
|
|
6
|
+
* 带时间的形式无偏移时视为本地时间),误差恰好等于主机时区偏移,**而且不报错**:
|
|
7
|
+
*
|
|
8
|
+
* TZ=UTC 误差 0h
|
|
9
|
+
* TZ=Asia/Shanghai 误差 -8h (看起来更陈旧)
|
|
10
|
+
* TZ=America/Los_Angeles 误差 +7h (看起来更新鲜 ← 朝好的一侧错)
|
|
11
|
+
*
|
|
12
|
+
* 🔴 **只对 TEXT 列用它。** 同一个字段名在不同表里可能是不同类型:
|
|
13
|
+
* sessions.last_seen_at / licenses.expires_at → TEXT(要用本函数)
|
|
14
|
+
* external_schedule_edits.expires_at 等 4 列 → INTEGER(epoch 毫秒,直接 new Date 就对)
|
|
15
|
+
* 按字段名判会判错 —— 见 #1650 里的更正。
|
|
16
|
+
*
|
|
17
|
+
* 与 agent-network 侧的同名函数是**有意的重复**:server 包不依赖 agent-network,
|
|
18
|
+
* 仓里也没有共享 lib。两边各自带测试。
|
|
19
|
+
*/
|
|
20
|
+
export function parseHubTimestamp(raw: unknown): number | null {
|
|
21
|
+
if (typeof raw !== "string") return null;
|
|
22
|
+
const t = raw.trim();
|
|
23
|
+
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(t)) return null;
|
|
24
|
+
const hasZone = /([Zz]|[+-]\d{2}:?\d{2})$/.test(t);
|
|
25
|
+
const ms = Date.parse(t.replace(" ", "T") + (hasZone ? "" : "Z"));
|
|
26
|
+
return Number.isFinite(ms) ? ms : null;
|
|
27
|
+
}
|
package/src/human-dm.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// 多用户账号:人与人私信(同一网络里的两个用户之间)。
|
|
2
|
+
//
|
|
3
|
+
// 投递复用 user_inbox(与 send_desktop_message 同一张表、同一条 /events/users/me 推送、同一套
|
|
4
|
+
// /api/messages?scope=user 未读与 ack),另外记下 sender_user_id —— 由 Hub 按发信人的用户令牌写入,
|
|
5
|
+
// 不是客户端声称的 from_session。会话记录按 (收件人, sender_user_id) 双向取,所以发信人看得到自己发的。
|
|
6
|
+
//
|
|
7
|
+
// 与 Agent 权限无关:受限成员(看不到任何 Agent 的成员)照样能和网络里的其他人私信。
|
|
8
|
+
|
|
9
|
+
import { db, uuidv4 } from "./db.js";
|
|
10
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
11
|
+
import { isAgentRestricted } from "./agent-access.js";
|
|
12
|
+
import { pushUserEvent, hasUserSubscribers } from "./push.js";
|
|
13
|
+
import { existsSync, readFileSync } from "fs";
|
|
14
|
+
import { DM_FILE_SCOPE, FILE_ID_REGEX, indexEntryPath, validateAttachments, validateIndexEntry, type UploadIndexEntry } from "./uploads.js";
|
|
15
|
+
import { restrictedMemberCanUseFile } from "./restricted-files.js";
|
|
16
|
+
import { redactMessageRow } from "./redact-tokens.js";
|
|
17
|
+
|
|
18
|
+
export const HUMAN_DM_KIND = "human_dm";
|
|
19
|
+
const MAX_DM_CHARS = 10_000;
|
|
20
|
+
|
|
21
|
+
export type HumanDmSendInput = {
|
|
22
|
+
networkId: string;
|
|
23
|
+
sender: { userId: string; username: string };
|
|
24
|
+
toUserId?: unknown;
|
|
25
|
+
toUsername?: unknown;
|
|
26
|
+
message?: unknown;
|
|
27
|
+
attachments?: unknown;
|
|
28
|
+
clientRequestId?: unknown;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type HumanDmSendResult =
|
|
32
|
+
| { ok: true; message: Record<string, unknown>; delivered: boolean }
|
|
33
|
+
| { ok: false; error: string; status: number };
|
|
34
|
+
|
|
35
|
+
export function sendHumanDm(input: HumanDmSendInput): HumanDmSendResult {
|
|
36
|
+
const { networkId, sender } = input;
|
|
37
|
+
if (!getUserNetworkRole(sender.userId, networkId)) return { ok: false, error: "not a member of this network", status: 403 };
|
|
38
|
+
const text = typeof input.message === "string" ? input.message : "";
|
|
39
|
+
if (text.length > MAX_DM_CHARS) return { ok: false, error: "message_too_long", status: 400 };
|
|
40
|
+
|
|
41
|
+
const byId = typeof input.toUserId === "string" && input.toUserId
|
|
42
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", input.toUserId)
|
|
43
|
+
: null;
|
|
44
|
+
const byName = typeof input.toUsername === "string" && input.toUsername
|
|
45
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", input.toUsername)
|
|
46
|
+
: null;
|
|
47
|
+
if (byId && byName && byId.user_id !== byName.user_id) return { ok: false, error: "dm_target_mismatch", status: 400 };
|
|
48
|
+
const target = byId ?? byName;
|
|
49
|
+
// 不存在与不在本网络同一个错误,不给用户名探测留差异。
|
|
50
|
+
if (!target || !getUserNetworkRole(target.user_id, networkId)) return { ok: false, error: "dm_target_not_in_network", status: 404 };
|
|
51
|
+
if (target.user_id === sender.userId) return { ok: false, error: "dm_to_self", status: 400 };
|
|
52
|
+
|
|
53
|
+
const attachments = validateAttachments(input.attachments);
|
|
54
|
+
if (!attachments.ok) return { ok: false, error: "bad_attachments", status: 400 };
|
|
55
|
+
if (!text.trim() && attachments.attachments.length === 0) return { ok: false, error: "message_required", status: 400 };
|
|
56
|
+
const restricted = isAgentRestricted(sender.userId, networkId);
|
|
57
|
+
for (const a of attachments.attachments) {
|
|
58
|
+
const fileId = (a as { file_id?: string }).file_id;
|
|
59
|
+
if (!fileId) continue;
|
|
60
|
+
const entry = networkFileEntry(networkId, fileId);
|
|
61
|
+
const usable = restricted
|
|
62
|
+
? restrictedMemberCanUseFile(sender.userId, sender.username, networkId, fileId)
|
|
63
|
+
: !!entry;
|
|
64
|
+
if (!usable) return { ok: false, error: "attachment_not_accessible", status: 403 };
|
|
65
|
+
// 私信文件只能由看得见它的人转发:否则任何成员把别人私信里的 file_id 塞进一条自己发出的私信,
|
|
66
|
+
// 就成了「私信参与者」而解锁它。上传者本人、以及已经在某段私信里收到/发出过它的人可以转。
|
|
67
|
+
if (entry?.scope === DM_FILE_SCOPE && entry.owner_id !== sender.userId && !dmParticipantSeesFile(sender.userId, networkId, fileId)) {
|
|
68
|
+
return { ok: false, error: "attachment_not_accessible", status: 403 };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const clientRequestId = typeof input.clientRequestId === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(input.clientRequestId)
|
|
73
|
+
? input.clientRequestId : null;
|
|
74
|
+
// 同一个气泡重试:按 (发信人, client_request_id) 定出同一个 message_id,重投不产生第二条。
|
|
75
|
+
const messageId = clientRequestId
|
|
76
|
+
? `dm_${sender.userId}_${clientRequestId}`.slice(0, 120)
|
|
77
|
+
: `dm_${uuidv4()}`;
|
|
78
|
+
const metaJson = attachments.attachments.length ? JSON.stringify({ attachments: attachments.attachments }) : null;
|
|
79
|
+
|
|
80
|
+
db.transaction(() => {
|
|
81
|
+
insertDmRow({ messageId, networkId, targetUserId: target.user_id, sender, title: null, text, metaJson });
|
|
82
|
+
db.run(
|
|
83
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
84
|
+
VALUES (?1, ?2, 'human_dm_sent', 'user', ?3, ?4, ?5)`,
|
|
85
|
+
[sender.userId, sender.username, target.user_id, JSON.stringify({ message_id: messageId, attachments: attachments.attachments.length }), networkId],
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
const row = db.get<Record<string, unknown>>(
|
|
89
|
+
`SELECT message_id, network_id, user_id, sender_user_id, from_session, kind, content, meta_json, acked, created_at
|
|
90
|
+
FROM user_inbox WHERE message_id = ?1`,
|
|
91
|
+
messageId,
|
|
92
|
+
)!;
|
|
93
|
+
// 推送在提交之后(与 send_desktop_message 同一条规矩:回滚的消息不能先被看见)。
|
|
94
|
+
const delivered = pushDmEvent({ messageId, networkId, targetUserId: target.user_id, sender, title: null, text, metaJson });
|
|
95
|
+
return { ok: true, message: redactMessageRow(row), delivered };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type DmRow = { messageId: string; networkId: string; targetUserId: string; sender: { userId: string; username: string }; title: string | null; text: string; metaJson: string | null };
|
|
99
|
+
|
|
100
|
+
function insertDmRow(m: DmRow): void {
|
|
101
|
+
db.run(
|
|
102
|
+
// created_at 带毫秒:同一秒里一来一回的两条私信要能排出先后(秒级的 datetime('now') 排不出)。
|
|
103
|
+
`INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, sender_user_id, created_at)
|
|
104
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'info', ?8, ?9, strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
105
|
+
ON CONFLICT(message_id) DO NOTHING`,
|
|
106
|
+
[m.messageId, m.networkId, m.targetUserId, m.sender.username, HUMAN_DM_KIND, m.title, m.text, m.metaJson, m.sender.userId],
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 推 desktop_message 到收件人的 /events/users/me。返回推送时他有没有在线的订阅。 */
|
|
111
|
+
function pushDmEvent(m: DmRow): boolean {
|
|
112
|
+
const delivered = hasUserSubscribers(m.networkId, m.targetUserId);
|
|
113
|
+
pushUserEvent(m.networkId, m.targetUserId, {
|
|
114
|
+
type: "desktop_message",
|
|
115
|
+
message_id: m.messageId,
|
|
116
|
+
kind: HUMAN_DM_KIND,
|
|
117
|
+
from: m.sender.username,
|
|
118
|
+
from_user_id: m.sender.userId,
|
|
119
|
+
title: m.title,
|
|
120
|
+
message: m.text,
|
|
121
|
+
severity: "info",
|
|
122
|
+
created_at: new Date().toISOString(),
|
|
123
|
+
...(m.metaJson ? { meta: JSON.parse(m.metaJson) } : {}),
|
|
124
|
+
});
|
|
125
|
+
return delivered;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Hub 代某人发的一条私信(参与人改了任务的通知,见 requirement-notify.ts):与人发的私信同一张表、同一条推送,
|
|
130
|
+
* 所以 App 按私信展示(顶部提示 + 人员列表未读 + 与他的会话里能看到),不用新渠道。
|
|
131
|
+
* 不校验正文 / 附件(调用方生成),也不记 human_dm_sent 审计(这不是他亲手发的)。
|
|
132
|
+
* 收件人或发信人已不在网络里 → 不发。返回 false = 没发。
|
|
133
|
+
*/
|
|
134
|
+
export function sendNoticeDm(m: DmRow): boolean {
|
|
135
|
+
if (m.targetUserId === m.sender.userId) return false;
|
|
136
|
+
if (!getUserNetworkRole(m.sender.userId, m.networkId) || !getUserNetworkRole(m.targetUserId, m.networkId)) return false;
|
|
137
|
+
insertDmRow(m);
|
|
138
|
+
pushDmEvent(m);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 把一条还没读的通知私信改写成合并后的正文(同一段时间里的连续改动并成一条)。
|
|
144
|
+
* 收件人已经读过(acked)或那行没了 → false,调用方另发一条新的。
|
|
145
|
+
* 只改库、再推一次同一个 message_id:App 按 message_id 去重,不会第二次弹;会话列表下次拉到的是新正文。
|
|
146
|
+
*/
|
|
147
|
+
export function rewriteUnreadNoticeDm(m: DmRow): boolean {
|
|
148
|
+
const row = db.get<{ acked: number | boolean | null }>("SELECT acked FROM user_inbox WHERE message_id = ?1 AND user_id = ?2", m.messageId, m.targetUserId);
|
|
149
|
+
if (!row || row.acked) return false;
|
|
150
|
+
db.run("UPDATE user_inbox SET content = ?1, meta_json = ?2 WHERE message_id = ?3 AND user_id = ?4", [m.text, m.metaJson, m.messageId, m.targetUserId]);
|
|
151
|
+
pushDmEvent(m);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 带附件:文件必须属于这个网络(防把别的网络的 file_id 塞进来)。返回它的索引条目,不属于 → null。 */
|
|
156
|
+
function networkFileEntry(networkId: string, fileId: string): UploadIndexEntry | null {
|
|
157
|
+
if (!FILE_ID_REGEX.test(fileId)) return null;
|
|
158
|
+
const path = indexEntryPath(fileId);
|
|
159
|
+
if (!path || !existsSync(path)) return null;
|
|
160
|
+
try {
|
|
161
|
+
const entry = JSON.parse(readFileSync(path, "utf8"));
|
|
162
|
+
return validateIndexEntry(entry) && entry.file_id === fileId && entry.network_id === networkId ? entry : null;
|
|
163
|
+
} catch { return null; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 这个用户是不是某段带着这个文件的私信里的一方(收到的或发出的)。私信文件(scope=dm)的下载只认它
|
|
168
|
+
* 和上传者本人(server.ts authorizeFileDownload)。LIKE 只做预筛,真正的判定按 attachments 键解析。
|
|
169
|
+
*/
|
|
170
|
+
export function dmParticipantSeesFile(userId: string, networkId: string, fileId: string): boolean {
|
|
171
|
+
if (!FILE_ID_REGEX.test(fileId)) return false;
|
|
172
|
+
const needle = `%"${fileId.replace(/_/g, "\\_")}"%`;
|
|
173
|
+
const rows = db.all<{ meta_json: string | null }>(
|
|
174
|
+
`SELECT meta_json FROM user_inbox
|
|
175
|
+
WHERE network_id = ?1 AND kind = '${HUMAN_DM_KIND}' AND (user_id = ?2 OR sender_user_id = ?2)
|
|
176
|
+
AND meta_json LIKE ?3 ESCAPE '\\' LIMIT 50`,
|
|
177
|
+
networkId, userId, needle,
|
|
178
|
+
);
|
|
179
|
+
return rows.some((row) => {
|
|
180
|
+
try {
|
|
181
|
+
const list = JSON.parse(row.meta_json ?? "null")?.attachments;
|
|
182
|
+
return Array.isArray(list) && list.some((a: any) => a && typeof a === "object" && a.file_id === fileId);
|
|
183
|
+
} catch { return false; }
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 我和某人的私信记录(双向),新的在前。before = 上一页最后一条的 created_at。 */
|
|
188
|
+
export function listDmThread(networkId: string, meUserId: string, otherUserId: string, limit = 50, before?: string | null) {
|
|
189
|
+
const params: unknown[] = [networkId, meUserId, otherUserId];
|
|
190
|
+
let sql = `SELECT message_id, network_id, user_id, sender_user_id, from_session, kind, content, meta_json, acked, created_at
|
|
191
|
+
FROM user_inbox
|
|
192
|
+
WHERE network_id = ?1 AND kind = '${HUMAN_DM_KIND}'
|
|
193
|
+
AND ((user_id = ?2 AND sender_user_id = ?3) OR (user_id = ?3 AND sender_user_id = ?2))`;
|
|
194
|
+
if (before) { params.push(before); sql += ` AND created_at < ?${params.length}`; }
|
|
195
|
+
params.push(Math.max(1, Math.min(limit, 200)));
|
|
196
|
+
sql += ` ORDER BY created_at DESC, rowid DESC LIMIT ?${params.length}`;
|
|
197
|
+
return db.all<Record<string, unknown>>(sql, ...params).map((row) => ({
|
|
198
|
+
...redactMessageRow(row),
|
|
199
|
+
direction: row.sender_user_id === meUserId ? "out" : "in",
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 我在这个网络里的私信会话:每个对方一行,最后一条时间 + 我这边的未读数。 */
|
|
204
|
+
export function listDmThreads(networkId: string, meUserId: string) {
|
|
205
|
+
return db.all<{ other_user_id: string; last_at: string; unread: number }>(
|
|
206
|
+
`SELECT other_user_id, MAX(created_at) AS last_at, SUM(unread) AS unread FROM (
|
|
207
|
+
SELECT sender_user_id AS other_user_id, created_at, CASE WHEN acked = 0 THEN 1 ELSE 0 END AS unread
|
|
208
|
+
FROM user_inbox WHERE network_id = ?1 AND user_id = ?2 AND kind = '${HUMAN_DM_KIND}' AND sender_user_id IS NOT NULL
|
|
209
|
+
UNION ALL
|
|
210
|
+
SELECT user_id AS other_user_id, created_at, 0 AS unread
|
|
211
|
+
FROM user_inbox WHERE network_id = ?1 AND sender_user_id = ?2 AND kind = '${HUMAN_DM_KIND}'
|
|
212
|
+
) GROUP BY other_user_id ORDER BY last_at DESC`,
|
|
213
|
+
networkId, meUserId,
|
|
214
|
+
).map((r) => ({ other_user_id: r.other_user_id, last_at: r.last_at, unread: Number(r.unread ?? 0) }));
|
|
215
|
+
}
|