@sleep2agi/commhub-server 0.9.0-preview.92 → 0.9.0-preview.94
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/package.json +1 -1
- package/src/auth.ts +3 -3
- 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/departments.ts +31 -10
- 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/human-dm.ts +2 -2
- package/src/mcp-context-cost-http.test.ts +4 -2
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-permissions-http.test.ts +18 -0
- package/src/requirement-due-reminders-http.test.ts +390 -0
- package/src/requirement-due-reminders.ts +310 -0
- package/src/requirements-errors.ts +2 -0
- package/src/requirements-status-participants-http.test.ts +5 -1
- package/src/requirements.ts +16 -1
- package/src/server.ts +171 -10
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +2 -1
- package/src/tools.ts +36 -6
|
@@ -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
|
+
}
|
package/src/human-dm.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { restrictedMemberCanUseFile } from "./restricted-files.js";
|
|
|
16
16
|
import { redactMessageRow } from "./redact-tokens.js";
|
|
17
17
|
|
|
18
18
|
export const HUMAN_DM_KIND = "human_dm";
|
|
19
|
-
const MAX_DM_CHARS = 10_000;
|
|
19
|
+
export const MAX_DM_CHARS = 10_000;
|
|
20
20
|
|
|
21
21
|
export type HumanDmSendInput = {
|
|
22
22
|
networkId: string;
|
|
@@ -153,7 +153,7 @@ export function rewriteUnreadNoticeDm(m: DmRow): boolean {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
/** 带附件:文件必须属于这个网络(防把别的网络的 file_id 塞进来)。返回它的索引条目,不属于 → null。 */
|
|
156
|
-
function networkFileEntry(networkId: string, fileId: string): UploadIndexEntry | null {
|
|
156
|
+
export function networkFileEntry(networkId: string, fileId: string): UploadIndexEntry | null {
|
|
157
157
|
if (!FILE_ID_REGEX.test(fileId)) return null;
|
|
158
158
|
const path = indexEntryPath(fileId);
|
|
159
159
|
if (!path || !existsSync(path)) return null;
|
|
@@ -21,9 +21,11 @@ if (!activeDbPath) throw new Error("mcp-context-cost requires COMMHUB_DB (or COM
|
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* tools/list 整份 JSON 的字节上限。#476 之前(main,含 #475)74 个工具 76,824 B;修剪后 71,399 B。
|
|
24
|
-
*
|
|
24
|
+
* #478 起 tools/list 按调用者给:这里的调用者是用户令牌,看到 56 个工具 54,780 B(不含节点 / 协议工具)。
|
|
25
|
+
* 上限跟着收到用户这一档(~1.7 KB 余量);节点 / 全部工具的上限在 tool-audience-http.test.ts。
|
|
26
|
+
* 加工具或加说明超过它,就在这里改数字并在 PR 里说为什么。
|
|
25
27
|
*/
|
|
26
|
-
const TOOLS_LIST_MAX_BYTES =
|
|
28
|
+
const TOOLS_LIST_MAX_BYTES = 56_500;
|
|
27
29
|
/** 单个工具(名字 + 描述 + 输入 schema)的上限;最大的是 report_status(节点心跳,~8.4 KB 的遥测结构)。 */
|
|
28
30
|
const TOOL_MAX_BYTES = 9_000;
|
|
29
31
|
/** 单条工具描述的上限(说明写进参数的 describe,或者拆短)。 */
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// report_status must not relabel a nodes row that belongs to another node.
|
|
2
|
+
//
|
|
3
|
+
// Shape seen in production: node A's config carries a node_id that another
|
|
4
|
+
// node B reported first (a copied config / a colliding hand-backfilled id).
|
|
5
|
+
// The row is B's alias + B's config_path, unowned (pre-RFC-036). When A starts
|
|
6
|
+
// reporting node_id, upsertNodeWithSec1Guard's COALESCE would overwrite the
|
|
7
|
+
// alias with A's — B's row silently becomes A's. identity_mismatch refuses it.
|
|
8
|
+
//
|
|
9
|
+
// Run with: COMMHUB_DB=/tmp/identity-mismatch.db bun test src/node-identity-mismatch.test.ts
|
|
10
|
+
|
|
11
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
import { upsertNodeWithSec1Guard } from "./tools.js";
|
|
14
|
+
|
|
15
|
+
const NET = "net_identity";
|
|
16
|
+
|
|
17
|
+
function row(node_id: string, alias: string, config_path: string | null, owner: string | null = null) {
|
|
18
|
+
db.run(
|
|
19
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, config_path, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex-app-server-sdk', ?3, ?4, ?5)`,
|
|
20
|
+
[node_id, alias, config_path, NET, owner],
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const read = (id: string) => db.get<{ alias: string; runtime: string | null; config_path: string | null }>(
|
|
24
|
+
"SELECT alias, runtime, config_path FROM nodes WHERE node_id = ?1", id,
|
|
25
|
+
)!;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => { db.run("DELETE FROM nodes"); });
|
|
28
|
+
|
|
29
|
+
describe("upsertNodeWithSec1Guard — identity_mismatch", () => {
|
|
30
|
+
test("different alias + different config_path ⇒ refused, row untouched", () => {
|
|
31
|
+
row("n_shared01", "node-b", "/work/b/.anet/nodes/node-b/config.json");
|
|
32
|
+
const out = upsertNodeWithSec1Guard({
|
|
33
|
+
node_id: "n_shared01", callerNetworkId: NET, alias: "node-a", runtime: "claude-code",
|
|
34
|
+
config_path: "/work/a/.anet/nodes/node-a/config.json",
|
|
35
|
+
});
|
|
36
|
+
expect(out).toMatchObject({ result: "refused", reason: "identity_mismatch" });
|
|
37
|
+
expect(read("n_shared01")).toEqual({ alias: "node-b", runtime: "codex-app-server-sdk", config_path: "/work/b/.anet/nodes/node-b/config.json" });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("same alias, different config_path (the node moved dirs) ⇒ updated", () => {
|
|
41
|
+
row("n_moved01", "node-a", "/old/.anet/nodes/node-a/config.json");
|
|
42
|
+
const out = upsertNodeWithSec1Guard({ node_id: "n_moved01", callerNetworkId: NET, alias: "node-a", config_path: "/new/.anet/nodes/node-a/config.json" });
|
|
43
|
+
expect(out.result).toBe("updated");
|
|
44
|
+
expect(read("n_moved01").config_path).toBe("/new/.anet/nodes/node-a/config.json");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("different alias, same config_path ⇒ updated (not the collision shape)", () => {
|
|
48
|
+
row("n_same01", "old-name", "/w/.anet/nodes/x/config.json");
|
|
49
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_same01", callerNetworkId: NET, alias: "new-name", config_path: "/w/.anet/nodes/x/config.json" }).result).toBe("updated");
|
|
50
|
+
expect(read("n_same01").alias).toBe("new-name");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("row without config_path (token-mint row) ⇒ first reporter fills it in", () => {
|
|
54
|
+
row("n_mint01", "node-a", null);
|
|
55
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_mint01", callerNetworkId: NET, alias: "node-a", config_path: "/w/a/config.json" }).result).toBe("updated");
|
|
56
|
+
expect(read("n_mint01").config_path).toBe("/w/a/config.json");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reporter without config_path ⇒ no evidence, legacy behaviour kept", () => {
|
|
60
|
+
row("n_nocfg01", "node-b", "/w/b/config.json");
|
|
61
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_nocfg01", callerNetworkId: NET, alias: "node-a" }).result).toBe("updated");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("new node_id ⇒ inserted", () => {
|
|
65
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_fresh01", callerNetworkId: NET, alias: "node-a", config_path: "/w/a/config.json" }).result).toBe("inserted");
|
|
66
|
+
expect(read("n_fresh01").alias).toBe("node-a");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("owner mismatch still wins first (existing gate unchanged)", () => {
|
|
70
|
+
row("n_owned01", "node-b", "/w/b/config.json", "usr_b");
|
|
71
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_owned01", callerNetworkId: NET, callerUserId: "usr_a", alias: "node-a", config_path: "/w/a/config.json" }))
|
|
72
|
+
.toMatchObject({ result: "refused", reason: "owner_mismatch" });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -221,6 +221,24 @@ describe("PUT /api/nodes/:id/permission-mode", () => {
|
|
|
221
221
|
});
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
+
describe("GET /api/nodes viewer_can.permission_mode (#489)", () => {
|
|
225
|
+
test("true exactly for the callers PUT …/permission-mode accepts; node tokens get false; /health advertises it", async () => {
|
|
226
|
+
const canFor = async (token: string, nodeKey: string) =>
|
|
227
|
+
(await call(token, "GET", `/api/nodes?node_id=${node[nodeKey].id}&network_id=${net}`)).body.nodes?.[0]?.viewer_can?.permission_mode;
|
|
228
|
+
expect(await canFor(user.boss.token, "nN")).toBe(true); // 网络 owner
|
|
229
|
+
expect(await canFor(user.sco.token, "nS")).toBe(true); // 节点主人(普通成员)
|
|
230
|
+
expect(await canFor(user.sco.token, "nN")).toBe(false); // 成员,不是主人
|
|
231
|
+
expect(await canFor(user.mem.token, "nN")).toBe(false);
|
|
232
|
+
expect(await canFor(node.nN.token, "nN")).toBe(false); // 节点令牌永远不能改
|
|
233
|
+
const list = await call(user.boss.token, "GET", `/api/nodes?network_id=${net}`);
|
|
234
|
+
expect(list.body.nodes.every((n: any) => !("owner_user_id" in n))).toBe(true);
|
|
235
|
+
const health = await fetch(`${base}/health`).then((r) => r.json()) as any;
|
|
236
|
+
expect(health.capabilities).toContain("node_permission_mode");
|
|
237
|
+
// 与写接口一致:viewer_can=false 的人 PUT 得到 403
|
|
238
|
+
expect((await call(user.mem.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "normal" })).status).toBe(403);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
224
242
|
describe("GET /api/networks/:id/node-permission-report", () => {
|
|
225
243
|
test("owner sees would-have-blocked counts per node; members and node tokens are refused", async () => {
|
|
226
244
|
const r = await call(user.boss.token, "GET", `/api/networks/${net}/node-permission-report`);
|