@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130
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 +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -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-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -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 +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- 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 +930 -35
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -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/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -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-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -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/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -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-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -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/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -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-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -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 +145 -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 +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -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 +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +125 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -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/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -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-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -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 +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -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 +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -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-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -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-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -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/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3927 -323
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- 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,297 @@
|
|
|
1
|
+
// 部门群(RFC-042,看板 #457)—— 第三个 PR:群消息、未读、实时推送。
|
|
2
|
+
//
|
|
3
|
+
// 只增迁移:两张新表(chat_group_messages、chat_group_reads),零改列。旧代码不读它们,回滚安全。
|
|
4
|
+
//
|
|
5
|
+
// 尽量照私信(human-dm.ts)做,App 能复用私信界面:
|
|
6
|
+
// - 正文上限 MAX_DM_CHARS(1 万字)、附件走同一个 validateAttachments、同样的文件可见性校验;
|
|
7
|
+
// - 去重按 client_request_id(同一个人在同一个群里重投同一个 id → 同一条消息,不发第二条、不推第二次),
|
|
8
|
+
// 不按内容哈希(hub 旧私信去重按内容吞掉用户的重复消息,那是 bug,见 reference_hub_send_dedup);
|
|
9
|
+
// - 消息行形状对齐 user_inbox 私信行:message_id / network_id / sender_user_id / from_session / kind / content /
|
|
10
|
+
// meta_json / created_at(毫秒)+ direction,另加 group_id 和 seq(翻页游标)。
|
|
11
|
+
// - 推送复用 /events/users/me(pushUserEvent),新事件类型 group_message,发给发送时**当前**的每个群成员(含发信人自己:
|
|
12
|
+
// 多端同步;App 按 message_id 去重)。被同步移出的人自然收不到 —— 收件人在发送那一刻从 chat_group_members 现取。
|
|
13
|
+
//
|
|
14
|
+
// 一条消息一行(不按人复制);每人已读位置一行(chat_group_reads.last_read_seq,只前进不后退)。
|
|
15
|
+
// 未读 = 群里 seq > 已读位置、且在我入群之后发的、不是我自己发的消息条数。
|
|
16
|
+
// 读写只认「当前是群成员」:被移出的人(同步移出、手动移出、移出网络)立刻读不到这个群的任何历史,也打不开群里的附件;
|
|
17
|
+
// 重新入群后整段历史又可见(群历史属于群,不属于某个人)。
|
|
18
|
+
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { db, uuidv4 } from "./db.js";
|
|
21
|
+
import { isAgentRestricted } from "./agent-access.js";
|
|
22
|
+
import { pushUserEvent, hasUserSubscribers } from "./push.js";
|
|
23
|
+
import { DM_FILE_SCOPE, FILE_ID_REGEX, validateAttachments } from "./uploads.js";
|
|
24
|
+
import { restrictedMemberCanUseFile } from "./restricted-files.js";
|
|
25
|
+
import { redactMessageRow } from "./redact-tokens.js";
|
|
26
|
+
import { MAX_DM_CHARS, dmParticipantSeesFile, networkFileEntry } from "./human-dm.js";
|
|
27
|
+
import { isGroupMember, publicDisplayName } from "./department-groups.js";
|
|
28
|
+
|
|
29
|
+
// 两张表的 DDL 在 department-groups.ts 里(和 chat_groups 一起建,删网络时一起删,不形成模块环)。
|
|
30
|
+
|
|
31
|
+
export const GROUP_MESSAGE_KIND = "group_message";
|
|
32
|
+
export const GROUP_MESSAGE_EVENT = "group_message";
|
|
33
|
+
export const GROUP_READ_EVENT = "group_read";
|
|
34
|
+
const CLIENT_REQUEST_ID = /^[A-Za-z0-9._-]{1,80}$/;
|
|
35
|
+
const MSG_COLS = "seq, message_id, group_id, network_id, sender_user_id, from_session, content, meta_json, created_at";
|
|
36
|
+
|
|
37
|
+
type Fail = { ok: false; status: number; error: string };
|
|
38
|
+
const fail = (status: number, error: string): Fail => ({ ok: false, status, error });
|
|
39
|
+
type MsgRow = { seq: number | string; message_id: string; group_id: string; network_id: string; sender_user_id: string; from_session: string; content: string; meta_json: string | null; created_at: string };
|
|
40
|
+
|
|
41
|
+
function shape(row: MsgRow, meUserId: string) {
|
|
42
|
+
return {
|
|
43
|
+
...redactMessageRow(row),
|
|
44
|
+
seq: Number(row.seq),
|
|
45
|
+
kind: GROUP_MESSAGE_KIND,
|
|
46
|
+
direction: row.sender_user_id === meUserId ? "out" : "in",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 消息接口的门:群在这个网络里、我是当前成员。否则 null(调用方一律 404 group_not_found,不分「没有」和「不让看」)。 */
|
|
51
|
+
export function memberGroup(networkId: string, groupId: string, userId: string): { group_id: string; name: string } | null {
|
|
52
|
+
const g = db.get<{ group_id: string; name: string }>("SELECT group_id, name FROM chat_groups WHERE network_id = ?1 AND group_id = ?2", networkId, groupId);
|
|
53
|
+
if (!g || !isGroupMember(groupId, userId)) return null;
|
|
54
|
+
return g;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 这个用户是不是**当前**某个群的成员、且那个群里有一条消息带着这个文件。群里的人因此能下载群消息的附件
|
|
59
|
+
* (私信文件和受限成员两条分支都认它,见 server.ts authorizeFileDownload)。被移出群 = 立刻失去。
|
|
60
|
+
* LIKE 只做预筛,真正的判定按 attachments 键解析(和 dmParticipantSeesFile 同一个写法)。
|
|
61
|
+
*/
|
|
62
|
+
export function groupMemberSeesFile(userId: string, networkId: string, fileId: string): boolean {
|
|
63
|
+
if (!FILE_ID_REGEX.test(fileId)) return false;
|
|
64
|
+
const needle = `%"${fileId.replace(/_/g, "\\_")}"%`;
|
|
65
|
+
const rows = db.all<{ meta_json: string | null }>(
|
|
66
|
+
`SELECT g.meta_json FROM chat_group_messages g
|
|
67
|
+
JOIN chat_group_members m ON m.group_id = g.group_id AND m.user_id = ?2
|
|
68
|
+
WHERE g.network_id = ?1 AND g.meta_json LIKE ?3 ESCAPE '\\' LIMIT 50`,
|
|
69
|
+
networkId, userId, needle,
|
|
70
|
+
);
|
|
71
|
+
return rows.some((row) => {
|
|
72
|
+
try {
|
|
73
|
+
const list = JSON.parse(row.meta_json ?? "null")?.attachments;
|
|
74
|
+
return Array.isArray(list) && list.some((a: any) => a && typeof a === "object" && a.file_id === fileId);
|
|
75
|
+
} catch { return false; }
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type GroupSendInput = {
|
|
80
|
+
networkId: string;
|
|
81
|
+
groupId: string;
|
|
82
|
+
sender: { userId: string; username: string };
|
|
83
|
+
message?: unknown;
|
|
84
|
+
attachments?: unknown;
|
|
85
|
+
clientRequestId?: unknown;
|
|
86
|
+
};
|
|
87
|
+
export type GroupSendResult =
|
|
88
|
+
| { ok: true; message: ReturnType<typeof shape>; duplicate: boolean; delivered_to: number }
|
|
89
|
+
| Fail;
|
|
90
|
+
|
|
91
|
+
export function sendGroupMessage(input: GroupSendInput): GroupSendResult {
|
|
92
|
+
const { networkId, groupId, sender } = input;
|
|
93
|
+
const group = memberGroup(networkId, groupId, sender.userId);
|
|
94
|
+
if (!group) return fail(404, "group_not_found");
|
|
95
|
+
const text = typeof input.message === "string" ? input.message : "";
|
|
96
|
+
if (text.length > MAX_DM_CHARS) return fail(400, "message_too_long");
|
|
97
|
+
const attachments = validateAttachments(input.attachments);
|
|
98
|
+
if (!attachments.ok) return fail(400, "bad_attachments");
|
|
99
|
+
if (!text.trim() && attachments.attachments.length === 0) return fail(400, "message_required");
|
|
100
|
+
|
|
101
|
+
// 文件校验与私信同一套:必须是本网络的文件;受限成员只能发自己能用的;私信文件只能由看得见它的人转发
|
|
102
|
+
// (上传者、带着它的私信里的人、带着它的群里的当前成员)—— 否则把别人私信里的 file_id 塞进群,就给全群解锁了它。
|
|
103
|
+
const restricted = isAgentRestricted(sender.userId, networkId);
|
|
104
|
+
for (const a of attachments.attachments) {
|
|
105
|
+
const fileId = (a as { file_id?: string }).file_id;
|
|
106
|
+
if (!fileId) continue;
|
|
107
|
+
const entry = networkFileEntry(networkId, fileId);
|
|
108
|
+
const usable = restricted
|
|
109
|
+
? restrictedMemberCanUseFile(sender.userId, sender.username, networkId, fileId) || (!!entry && groupMemberSeesFile(sender.userId, networkId, fileId))
|
|
110
|
+
: !!entry;
|
|
111
|
+
if (!usable) return fail(403, "attachment_not_accessible");
|
|
112
|
+
if (entry?.scope === DM_FILE_SCOPE && entry.owner_id !== sender.userId
|
|
113
|
+
&& !dmParticipantSeesFile(sender.userId, networkId, fileId) && !groupMemberSeesFile(sender.userId, networkId, fileId)) {
|
|
114
|
+
return fail(403, "attachment_not_accessible");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const clientRequestId = typeof input.clientRequestId === "string" && CLIENT_REQUEST_ID.test(input.clientRequestId) ? input.clientRequestId : null;
|
|
119
|
+
// 同一个气泡重试:按 (群, 发信人, client_request_id) 定出同一个 message_id(哈希,不截断拼接,避免长 id 截出碰撞)。
|
|
120
|
+
const messageId = clientRequestId
|
|
121
|
+
? `gm_${createHash("sha256").update(`${groupId}\n${sender.userId}\n${clientRequestId}`).digest("hex").slice(0, 32)}`
|
|
122
|
+
: `gm_${uuidv4().replace(/-/g, "")}`;
|
|
123
|
+
const metaJson = attachments.attachments.length ? JSON.stringify({ attachments: attachments.attachments }) : null;
|
|
124
|
+
|
|
125
|
+
let inserted = false;
|
|
126
|
+
db.transaction(() => {
|
|
127
|
+
const r = db.run(
|
|
128
|
+
`INSERT INTO chat_group_messages (message_id, group_id, network_id, sender_user_id, from_session, content, meta_json, created_at)
|
|
129
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
130
|
+
ON CONFLICT(message_id) DO NOTHING`,
|
|
131
|
+
[messageId, groupId, networkId, sender.userId, sender.username, text, metaJson],
|
|
132
|
+
);
|
|
133
|
+
inserted = (r?.changes ?? 0) > 0;
|
|
134
|
+
if (!inserted) return;
|
|
135
|
+
db.run(
|
|
136
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
137
|
+
VALUES (?1, ?2, 'chat_group_message_sent', 'chat_group', ?3, ?4, ?5)`,
|
|
138
|
+
[sender.userId, sender.username, groupId, JSON.stringify({ message_id: messageId, attachments: attachments.attachments.length }), networkId],
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
const row = db.get<MsgRow>(`SELECT ${MSG_COLS} FROM chat_group_messages WHERE message_id = ?1`, messageId)!;
|
|
142
|
+
const message = shape(row, sender.userId);
|
|
143
|
+
// 重投:同一条消息原样返回,不再推送(第一次已经推过)。推送在提交之后:回滚的消息不能先被看见。
|
|
144
|
+
const deliveredTo = inserted ? pushGroupMessage(networkId, group, row) : 0;
|
|
145
|
+
return { ok: true, message, duplicate: !inserted, delivered_to: deliveredTo };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 推 group_message 给发送这一刻的每个群成员(含发信人自己,多端同步),每人带上自己的未读数。返回推送时在线的人数。 */
|
|
149
|
+
function pushGroupMessage(networkId: string, group: { group_id: string; name: string }, row: MsgRow): number {
|
|
150
|
+
const unread = unreadByMember(group.group_id);
|
|
151
|
+
let online = 0;
|
|
152
|
+
for (const [userId, count] of unread) {
|
|
153
|
+
if (hasUserSubscribers(networkId, userId)) online++;
|
|
154
|
+
pushUserEvent(networkId, userId, {
|
|
155
|
+
type: GROUP_MESSAGE_EVENT,
|
|
156
|
+
message_id: row.message_id,
|
|
157
|
+
seq: Number(row.seq),
|
|
158
|
+
group_id: group.group_id,
|
|
159
|
+
group_name: group.name,
|
|
160
|
+
kind: GROUP_MESSAGE_KIND,
|
|
161
|
+
from: row.from_session,
|
|
162
|
+
from_user_id: row.sender_user_id,
|
|
163
|
+
message: redactMessageRow({ content: row.content }).content,
|
|
164
|
+
severity: "info",
|
|
165
|
+
created_at: row.created_at,
|
|
166
|
+
unread: count,
|
|
167
|
+
...(row.meta_json ? { meta: JSON.parse(redactMessageRow({ meta_json: row.meta_json }).meta_json) } : {}),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return online;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 未读口径(一处定义,三个地方用):别人发的、在我入群之后、seq 超过我的已读位置。
|
|
174
|
+
// joined_at 是秒级、created_at 是毫秒级的同格式 UTC 文本(SQLite 与 PG 一致),逐字比较即时间先后;
|
|
175
|
+
// 入群那一秒里发的消息算未读(宁可多一条,不漏)。重新入群 joined_at 刷新,离开期间的消息不算未读。
|
|
176
|
+
const UNREAD_SUBQUERY = `(SELECT COUNT(*) FROM chat_group_messages g
|
|
177
|
+
WHERE g.group_id = m.group_id AND g.sender_user_id <> m.user_id
|
|
178
|
+
AND g.created_at > m.joined_at AND g.seq > COALESCE(r.last_read_seq, 0))`;
|
|
179
|
+
|
|
180
|
+
function unreadByMember(groupId: string): Map<string, number> {
|
|
181
|
+
const rows = db.all<{ user_id: string; unread: number | string }>(
|
|
182
|
+
`SELECT m.user_id, ${UNREAD_SUBQUERY} AS unread
|
|
183
|
+
FROM chat_group_members m LEFT JOIN chat_group_reads r ON r.group_id = m.group_id AND r.user_id = m.user_id
|
|
184
|
+
WHERE m.group_id = ?1`,
|
|
185
|
+
groupId,
|
|
186
|
+
);
|
|
187
|
+
return new Map(rows.map((r) => [r.user_id, Number(r.unread ?? 0)]));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function groupUnreadFor(groupId: string, userId: string): number {
|
|
191
|
+
const r = db.get<{ unread: number | string }>(
|
|
192
|
+
`SELECT ${UNREAD_SUBQUERY} AS unread
|
|
193
|
+
FROM chat_group_members m LEFT JOIN chat_group_reads r ON r.group_id = m.group_id AND r.user_id = m.user_id
|
|
194
|
+
WHERE m.group_id = ?1 AND m.user_id = ?2`,
|
|
195
|
+
groupId, userId,
|
|
196
|
+
);
|
|
197
|
+
return Number(r?.unread ?? 0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 会话列表里的最后一条预览(App 补口,只增)。text = 正文(去敏后)空白折成一个空格、取前 80 个字符(按码点,不切坏 emoji);
|
|
202
|
+
* 只有附件 → text 为 "",attachment_count 是附件数。sender_name = 发信人当前的 display_name(非空)否则 username
|
|
203
|
+
* (账号没了回落到发送时的 from_session)。at = 这条消息的 created_at(与 last_at 同值)。
|
|
204
|
+
*/
|
|
205
|
+
export type GroupLastMessage = { text: string; attachment_count: number; sender_user_id: string; sender_name: string; at: string };
|
|
206
|
+
export const LAST_MESSAGE_PREVIEW_CHARS = 80;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 我在这个网络里每个群的最后一条消息,一次查询(按群取 MAX(seq) 走 (group_id, seq) 索引,不逐群发查询)。
|
|
210
|
+
* 只覆盖我**当前**在里面的群 —— 不是成员的群(管理身份看得到群资料)不给预览,和 last_message_at 同一口径。
|
|
211
|
+
*/
|
|
212
|
+
export function lastGroupMessagesFor(networkId: string, userId: string): Map<string, GroupLastMessage> {
|
|
213
|
+
const rows = db.all<{ group_id: string; sender_user_id: string; from_session: string; content: string; meta_json: string | null; created_at: string; username: string | null; display_name: string | null }>(
|
|
214
|
+
`SELECT g.group_id, g.sender_user_id, g.from_session, g.content, g.meta_json, g.created_at, u.username, u.display_name
|
|
215
|
+
FROM chat_group_members m
|
|
216
|
+
JOIN chat_group_messages g ON g.group_id = m.group_id
|
|
217
|
+
AND g.seq = (SELECT MAX(g2.seq) FROM chat_group_messages g2 WHERE g2.group_id = m.group_id)
|
|
218
|
+
LEFT JOIN users u ON u.user_id = g.sender_user_id
|
|
219
|
+
WHERE m.network_id = ?1 AND m.user_id = ?2`,
|
|
220
|
+
networkId, userId,
|
|
221
|
+
);
|
|
222
|
+
const out = new Map<string, GroupLastMessage>();
|
|
223
|
+
for (const r of rows) {
|
|
224
|
+
const content = String(redactMessageRow({ content: r.content }).content ?? "");
|
|
225
|
+
let attachmentCount = 0;
|
|
226
|
+
try {
|
|
227
|
+
const list = JSON.parse(r.meta_json ?? "null")?.attachments;
|
|
228
|
+
if (Array.isArray(list)) attachmentCount = list.length;
|
|
229
|
+
} catch {}
|
|
230
|
+
const name = publicDisplayName(r.username, r.display_name).trim() || r.username || r.from_session;
|
|
231
|
+
out.set(r.group_id, {
|
|
232
|
+
text: [...content.replace(/\s+/g, " ").trim()].slice(0, LAST_MESSAGE_PREVIEW_CHARS).join(""),
|
|
233
|
+
attachment_count: attachmentCount,
|
|
234
|
+
sender_user_id: r.sender_user_id,
|
|
235
|
+
sender_name: name,
|
|
236
|
+
at: r.created_at,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export type GroupThread = { group_id: string; name: string; department_id: string | null; last_at: string | null; unread: number; last_read_seq: number; last_message: GroupLastMessage | null };
|
|
243
|
+
|
|
244
|
+
/** 我在这个网络里的群会话:每个群一行,最后一条时间 + 我的未读数。按最后一条时间倒序(没消息的排最后),和私信会话列表同序。 */
|
|
245
|
+
export function listGroupThreads(networkId: string, userId: string): GroupThread[] {
|
|
246
|
+
const rows = db.all<{ group_id: string; name: string; department_id: string | null; last_at: string | null; unread: number | string; last_read_seq: number | string | null }>(
|
|
247
|
+
`SELECT m.group_id, c.name, c.department_id, ${UNREAD_SUBQUERY} AS unread,
|
|
248
|
+
(SELECT MAX(g2.created_at) FROM chat_group_messages g2 WHERE g2.group_id = m.group_id) AS last_at,
|
|
249
|
+
r.last_read_seq
|
|
250
|
+
FROM chat_group_members m
|
|
251
|
+
JOIN chat_groups c ON c.group_id = m.group_id
|
|
252
|
+
LEFT JOIN chat_group_reads r ON r.group_id = m.group_id AND r.user_id = m.user_id
|
|
253
|
+
WHERE m.network_id = ?1 AND m.user_id = ?2`,
|
|
254
|
+
networkId, userId,
|
|
255
|
+
);
|
|
256
|
+
const last = lastGroupMessagesFor(networkId, userId);
|
|
257
|
+
// 排序放在 JS 里:SQLite 和 PG 对 DESC 里 NULL 的位置相反。
|
|
258
|
+
return rows
|
|
259
|
+
.map((r) => ({ group_id: r.group_id, name: r.name, department_id: r.department_id, last_at: r.last_at, unread: Number(r.unread ?? 0), last_read_seq: Number(r.last_read_seq ?? 0), last_message: last.get(r.group_id) ?? null }))
|
|
260
|
+
.sort((a, b) => (b.last_at ?? "").localeCompare(a.last_at ?? "") || a.group_id.localeCompare(b.group_id));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 群消息记录,新的在前。before = 上一页最后(最老)一条的 seq;返回 next_before(没有更多 → null)。 */
|
|
264
|
+
export function listGroupMessages(groupId: string, meUserId: string, limit = 50, before?: string | null): { messages: ReturnType<typeof shape>[]; next_before: number | null } | Fail {
|
|
265
|
+
const n = Math.max(1, Math.min(Number.isFinite(limit) ? Math.trunc(limit) : 50, 200));
|
|
266
|
+
let rows: MsgRow[];
|
|
267
|
+
if (before !== undefined && before !== null && before !== "") {
|
|
268
|
+
if (!/^\d{1,15}$/.test(before)) return fail(400, "invalid_before");
|
|
269
|
+
rows = db.all<MsgRow>(`SELECT ${MSG_COLS} FROM chat_group_messages WHERE group_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3`, groupId, Number(before), n);
|
|
270
|
+
} else {
|
|
271
|
+
rows = db.all<MsgRow>(`SELECT ${MSG_COLS} FROM chat_group_messages WHERE group_id = ?1 ORDER BY seq DESC LIMIT ?2`, groupId, n);
|
|
272
|
+
}
|
|
273
|
+
const messages = rows.map((r) => shape(r, meUserId));
|
|
274
|
+
return { messages, next_before: rows.length === n ? Number(rows[rows.length - 1].seq) : null };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 标已读。seq 缺省 = 群里最新一条;给了就读到那一条(超过最新按最新算)。已读位置只前进不后退
|
|
279
|
+
* (两台设备先后上报,旧的那次不会把未读又顶回来)。读完推 group_read 给我自己的其他设备,让角标一起清。
|
|
280
|
+
*/
|
|
281
|
+
export function markGroupRead(networkId: string, groupId: string, userId: string, seq: unknown): { ok: true; last_read_seq: number; unread: number } | Fail {
|
|
282
|
+
if (seq !== undefined && seq !== null && !(typeof seq === "number" && Number.isSafeInteger(seq) && seq >= 0)) return fail(400, "invalid_seq");
|
|
283
|
+
const latest = Number(db.get<{ s: number | string | null }>("SELECT MAX(seq) AS s FROM chat_group_messages WHERE group_id = ?1", groupId)?.s ?? 0);
|
|
284
|
+
const target = typeof seq === "number" ? Math.min(seq, latest) : latest;
|
|
285
|
+
db.run(
|
|
286
|
+
`INSERT INTO chat_group_reads (group_id, user_id, network_id, last_read_seq, updated_at)
|
|
287
|
+
VALUES (?1, ?2, ?3, ?4, datetime('now'))
|
|
288
|
+
ON CONFLICT(group_id, user_id) DO UPDATE SET
|
|
289
|
+
last_read_seq = CASE WHEN excluded.last_read_seq > chat_group_reads.last_read_seq THEN excluded.last_read_seq ELSE chat_group_reads.last_read_seq END,
|
|
290
|
+
updated_at = excluded.updated_at`,
|
|
291
|
+
[groupId, userId, networkId, target],
|
|
292
|
+
);
|
|
293
|
+
const lastRead = Number(db.get<{ s: number | string }>("SELECT last_read_seq AS s FROM chat_group_reads WHERE group_id = ?1 AND user_id = ?2", groupId, userId)?.s ?? 0);
|
|
294
|
+
const unread = groupUnreadFor(groupId, userId);
|
|
295
|
+
pushUserEvent(networkId, userId, { type: GROUP_READ_EVENT, group_id: groupId, last_read_seq: lastRead, unread });
|
|
296
|
+
return { ok: true, last_read_seq: lastRead, unread };
|
|
297
|
+
}
|