@mininglamp-oss/cc-channel-octo 1.0.1-dev.60b73f3
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/CHANGELOG.md +349 -0
- package/LICENSE +191 -0
- package/README.md +577 -0
- package/config.bot.example.json +15 -0
- package/config.example.json +33 -0
- package/dist/agent-bridge.d.ts +79 -0
- package/dist/agent-bridge.js +392 -0
- package/dist/agent-bridge.js.map +1 -0
- package/dist/commands.d.ts +57 -0
- package/dist/commands.js +121 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +287 -0
- package/dist/config.js +332 -0
- package/dist/config.js.map +1 -0
- package/dist/cron-evaluator.d.ts +53 -0
- package/dist/cron-evaluator.js +191 -0
- package/dist/cron-evaluator.js.map +1 -0
- package/dist/cron-fire-marker.d.ts +24 -0
- package/dist/cron-fire-marker.js +25 -0
- package/dist/cron-fire-marker.js.map +1 -0
- package/dist/cron-scheduler.d.ts +46 -0
- package/dist/cron-scheduler.js +114 -0
- package/dist/cron-scheduler.js.map +1 -0
- package/dist/cron-store.d.ts +62 -0
- package/dist/cron-store.js +63 -0
- package/dist/cron-store.js.map +1 -0
- package/dist/cron-tool.d.ts +44 -0
- package/dist/cron-tool.js +151 -0
- package/dist/cron-tool.js.map +1 -0
- package/dist/cwd-resolver.d.ts +72 -0
- package/dist/cwd-resolver.js +166 -0
- package/dist/cwd-resolver.js.map +1 -0
- package/dist/db-adapter.d.ts +21 -0
- package/dist/db-adapter.js +64 -0
- package/dist/db-adapter.js.map +1 -0
- package/dist/file-inline-wrap.d.ts +94 -0
- package/dist/file-inline-wrap.js +243 -0
- package/dist/file-inline-wrap.js.map +1 -0
- package/dist/gateway.d.ts +100 -0
- package/dist/gateway.js +420 -0
- package/dist/gateway.js.map +1 -0
- package/dist/group-config.d.ts +41 -0
- package/dist/group-config.js +104 -0
- package/dist/group-config.js.map +1 -0
- package/dist/group-context.d.ts +81 -0
- package/dist/group-context.js +466 -0
- package/dist/group-context.js.map +1 -0
- package/dist/inbound.d.ts +136 -0
- package/dist/inbound.js +667 -0
- package/dist/inbound.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +932 -0
- package/dist/index.js.map +1 -0
- package/dist/media-inbound.d.ts +38 -0
- package/dist/media-inbound.js +131 -0
- package/dist/media-inbound.js.map +1 -0
- package/dist/mention-utils.d.ts +108 -0
- package/dist/mention-utils.js +199 -0
- package/dist/mention-utils.js.map +1 -0
- package/dist/octo/api.d.ts +148 -0
- package/dist/octo/api.js +320 -0
- package/dist/octo/api.js.map +1 -0
- package/dist/octo/socket.d.ts +102 -0
- package/dist/octo/socket.js +793 -0
- package/dist/octo/socket.js.map +1 -0
- package/dist/octo/types.d.ts +126 -0
- package/dist/octo/types.js +35 -0
- package/dist/octo/types.js.map +1 -0
- package/dist/prompt-safety.d.ts +78 -0
- package/dist/prompt-safety.js +148 -0
- package/dist/prompt-safety.js.map +1 -0
- package/dist/session-router.d.ts +144 -0
- package/dist/session-router.js +490 -0
- package/dist/session-router.js.map +1 -0
- package/dist/session-store.d.ts +89 -0
- package/dist/session-store.js +297 -0
- package/dist/session-store.js.map +1 -0
- package/dist/skill-linker.d.ts +31 -0
- package/dist/skill-linker.js +160 -0
- package/dist/skill-linker.js.map +1 -0
- package/dist/stream-relay.d.ts +42 -0
- package/dist/stream-relay.js +243 -0
- package/dist/stream-relay.js.map +1 -0
- package/dist/url-policy.d.ts +103 -0
- package/dist/url-policy.js +290 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +79 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v1.0: GROUP.md / THREAD.md per-conversation instruction files.
|
|
3
|
+
*
|
|
4
|
+
* Operators can drop a markdown file with custom instructions for a specific
|
|
5
|
+
* group (or, later, thread) into a configured directory. Its contents are
|
|
6
|
+
* injected into the agent's system prompt as a trusted instruction block, so a
|
|
7
|
+
* group can have its own persona / rules without code changes.
|
|
8
|
+
*
|
|
9
|
+
* SECURITY — read carefully. The `[Group instructions]` block is injected into
|
|
10
|
+
* the system prompt UNSANITIZED, so its contents are trusted. That trust holds
|
|
11
|
+
* ONLY if the file is writable solely by the operator (the gateway process user).
|
|
12
|
+
*
|
|
13
|
+
* Placing `groupConfigDir` outside `cwdBase` is necessary but NOT sufficient:
|
|
14
|
+
* under the shipped defaults (`allowedTools: '*'`, `bypassPermissions`) the agent
|
|
15
|
+
* has `Bash`/`Write` and can write ABSOLUTE paths anywhere the gateway user can
|
|
16
|
+
* write — `cwdBase` is a starting dir, not a chroot. So a malicious user in one
|
|
17
|
+
* group could drive the agent to write `<groupConfigDir>/<otherGroup>.md` and
|
|
18
|
+
* inject persistent, trusted instructions into a different group.
|
|
19
|
+
*
|
|
20
|
+
* The real protection is OS-level: `groupConfigDir` and its files MUST be made
|
|
21
|
+
* non-writable by the gateway process user (e.g. root-owned, mode 0755/0644),
|
|
22
|
+
* and/or the deployment hardened (drop `Bash`, sandboxed FS, unprivileged user).
|
|
23
|
+
* As cheap defense-in-depth, loadGroupConfig() refuses to inject a file that is
|
|
24
|
+
* group- or world-writable. The group id is filename-pinned to a safe slug so a
|
|
25
|
+
* crafted id can't traverse out of the config dir.
|
|
26
|
+
*/
|
|
27
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
/** Max bytes of an instruction file we will inject (keeps the prompt bounded). */
|
|
30
|
+
export const MAX_GROUP_CONFIG_BYTES = 16_384; // 16 KiB
|
|
31
|
+
/**
|
|
32
|
+
* Only allow ids that are safe as a single path segment — letters, digits, and
|
|
33
|
+
* a few separators. Anything else (slashes, dots-only, `..`) is rejected so a
|
|
34
|
+
* channel/thread id cannot escape `groupConfigDir`.
|
|
35
|
+
*/
|
|
36
|
+
function isSafeId(id) {
|
|
37
|
+
return /^[a-zA-Z0-9._-]+$/.test(id) && id !== '.' && id !== '..';
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Load the instruction file for a group, or undefined when none applies.
|
|
41
|
+
*
|
|
42
|
+
* Looks for `<groupConfigDir>/<groupId>.md`. Returns the trimmed contents,
|
|
43
|
+
* truncated to MAX_GROUP_CONFIG_BYTES. Returns undefined when:
|
|
44
|
+
* - groupConfigDir is not configured,
|
|
45
|
+
* - groupId is empty or unsafe as a path segment,
|
|
46
|
+
* - the file does not exist or is unreadable.
|
|
47
|
+
*
|
|
48
|
+
* Never throws — a misconfigured dir or unreadable file degrades to "no custom
|
|
49
|
+
* instructions" rather than failing the turn.
|
|
50
|
+
*/
|
|
51
|
+
export function loadGroupConfig(groupConfigDir, groupId) {
|
|
52
|
+
if (!groupConfigDir)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (!groupId || !isSafeId(groupId))
|
|
55
|
+
return undefined;
|
|
56
|
+
const path = join(groupConfigDir, `${groupId}.md`);
|
|
57
|
+
try {
|
|
58
|
+
if (!existsSync(path))
|
|
59
|
+
return undefined;
|
|
60
|
+
const st = statSync(path);
|
|
61
|
+
if (!st.isFile())
|
|
62
|
+
return undefined;
|
|
63
|
+
// Defense-in-depth: refuse a group/world-writable file. Its contents are
|
|
64
|
+
// injected UNSANITIZED into the system prompt, so a file anyone-but-the-
|
|
65
|
+
// operator can write is an untrusted injection sink. This catches the most
|
|
66
|
+
// common misconfiguration; it is NOT a substitute for proper OS perms +
|
|
67
|
+
// a hardened deployment (the agent can still write operator-owned paths
|
|
68
|
+
// under default Bash/bypassPermissions — see the module header).
|
|
69
|
+
if ((st.mode & 0o022) !== 0) {
|
|
70
|
+
console.error(`[cc-channel-octo] refusing group config ${path}: file is group/world-writable ` +
|
|
71
|
+
`(mode ${(st.mode & 0o777).toString(8)}). Make it writable only by the gateway user.`);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
// Read at most MAX+1 bytes so a mistakenly huge file can't block the event
|
|
75
|
+
// loop or allocate unbounded memory on every group turn. Reading one extra
|
|
76
|
+
// byte lets us detect (and mark) truncation.
|
|
77
|
+
const fd = openSync(path, 'r');
|
|
78
|
+
let content;
|
|
79
|
+
let wasTruncated = false;
|
|
80
|
+
try {
|
|
81
|
+
const buf = Buffer.allocUnsafe(MAX_GROUP_CONFIG_BYTES + 1);
|
|
82
|
+
const bytesRead = readSync(fd, buf, 0, MAX_GROUP_CONFIG_BYTES + 1, 0);
|
|
83
|
+
wasTruncated = bytesRead > MAX_GROUP_CONFIG_BYTES;
|
|
84
|
+
const slice = buf.subarray(0, Math.min(bytesRead, MAX_GROUP_CONFIG_BYTES));
|
|
85
|
+
content = slice.toString('utf-8');
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
if (wasTruncated) {
|
|
91
|
+
// The slice may end mid-codepoint; trim back to a valid UTF-8 boundary by
|
|
92
|
+
// dropping any trailing replacement char produced by a split sequence.
|
|
93
|
+
content = content.replace(/�+$/, '');
|
|
94
|
+
content += '\n[… group config truncated]';
|
|
95
|
+
}
|
|
96
|
+
const trimmed = content.trim();
|
|
97
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
console.error(`[cc-channel-octo] group config read failed for ${path}: ${String(err)}`);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=group-config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"group-config.js","sourceRoot":"","sources":["../src/group-config.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group Context — group chat message cache + maxContextChars budget + mention mapping.
|
|
3
|
+
*/
|
|
4
|
+
import type { DbAdapter } from './db-adapter.js';
|
|
5
|
+
export declare class GroupContext {
|
|
6
|
+
private readonly messageCache;
|
|
7
|
+
private readonly memberMapByChannel;
|
|
8
|
+
private readonly nameToUidByChannel;
|
|
9
|
+
private readonly robotFlags;
|
|
10
|
+
private readonly lastRefresh;
|
|
11
|
+
private readonly adapter;
|
|
12
|
+
private readonly maxContextChars;
|
|
13
|
+
private readonly maxWindowSize;
|
|
14
|
+
private upsertMember;
|
|
15
|
+
private deleteMember;
|
|
16
|
+
private selectMembers;
|
|
17
|
+
private insertMessage;
|
|
18
|
+
private selectRecentMessages;
|
|
19
|
+
private deleteOldMessages;
|
|
20
|
+
private selectMessagesSince;
|
|
21
|
+
private selectMaxId;
|
|
22
|
+
private upsertCursor;
|
|
23
|
+
private selectCursor;
|
|
24
|
+
constructor(adapter: DbAdapter, maxContextChars: number);
|
|
25
|
+
private initStatements;
|
|
26
|
+
private getMemberMap;
|
|
27
|
+
private getNameToUid;
|
|
28
|
+
pushMessage(channelId: string, fromUid: string, fromName: string, content: string, timestamp: number): void;
|
|
29
|
+
learnMember(channelId: string, uid: string, name: string): void;
|
|
30
|
+
refreshMembers(channelId: string, apiUrl: string, botToken: string): Promise<void>;
|
|
31
|
+
fetchAndLearnUser(uid: string, channelId: string, apiUrl: string, botToken: string): Promise<string | undefined>;
|
|
32
|
+
buildContext(channelId: string): string;
|
|
33
|
+
/** Read the per-channel consumption cursor (highest already-injected id), or 0. */
|
|
34
|
+
getContextCursor(channelId: string): number;
|
|
35
|
+
/** Advance the per-channel cursor to `lastId` (monotonic — never moves backward). */
|
|
36
|
+
setContextCursor(channelId: string, lastId: number): void;
|
|
37
|
+
/** The highest group_messages.id for a channel (for cursor priming), or 0. */
|
|
38
|
+
getMaxMessageId(channelId: string): number;
|
|
39
|
+
/**
|
|
40
|
+
* Build a group-context block from messages NEWER than `sinceId` (the delta the
|
|
41
|
+
* model hasn't seen yet), within `maxContextChars`. Returns the block text (same
|
|
42
|
+
* `[Recent group messages]` format as buildContext) and the highest id that
|
|
43
|
+
* EXISTS in the channel above `sinceId` (so the caller advances the cursor past
|
|
44
|
+
* the whole delta, including any oldest lines the char budget dropped — those
|
|
45
|
+
* are the least-relevant and are intentionally not re-shown). Empty text + the
|
|
46
|
+
* unchanged cursor when there's nothing new.
|
|
47
|
+
*
|
|
48
|
+
* Rows are fetched newest-first (so a backlog larger than the budget keeps the
|
|
49
|
+
* most-recent messages); the selected slice is reversed back to chronological
|
|
50
|
+
* order for display. Unlike buildContext (in-memory rolling window), this reads
|
|
51
|
+
* from the DB so the cursor delta is exact even across restarts / window eviction.
|
|
52
|
+
*/
|
|
53
|
+
buildContextSince(channelId: string, sinceId: number): {
|
|
54
|
+
text: string;
|
|
55
|
+
lastId: number;
|
|
56
|
+
};
|
|
57
|
+
resolveMentions(text: string, channelId: string): string[];
|
|
58
|
+
getName(uid: string, channelId: string): string | undefined;
|
|
59
|
+
/** G23: Check the server-authoritative robot flag for a group member. */
|
|
60
|
+
isRobot(channelId: string, uid: string): boolean | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* A8 (#143): true iff `uid` is a current member of `channelId` per the cached
|
|
63
|
+
* member list. The list is kept authoritative by refreshMembers (it prunes
|
|
64
|
+
* departed members, not just upserts) — but it is only best-effort fresh:
|
|
65
|
+
* refresh is throttled to REFRESH_INTERVAL_MS and seeded from DB on restart,
|
|
66
|
+
* so a membership change inside that window may not be reflected yet. Used by
|
|
67
|
+
* the outbound mention guard as defense-in-depth (the server still enforces
|
|
68
|
+
* real permissions), NOT as a real-time authority.
|
|
69
|
+
*/
|
|
70
|
+
isMember(channelId: string, uid: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* A8 (#143): the channel's displayName→uid map, for v1 `@name` outbound
|
|
73
|
+
* resolution in StreamRelay.deliver. Returns the live map (empty if the
|
|
74
|
+
* channel has no cached members yet). Read-only use by callers.
|
|
75
|
+
*/
|
|
76
|
+
getNameToUidMap(channelId: string): Map<string, string>;
|
|
77
|
+
loadMembersFromDb(channelId: string): void;
|
|
78
|
+
loadMessagesFromDb(channelId: string): void;
|
|
79
|
+
/** Load all persisted members and messages from DB (call once at startup). */
|
|
80
|
+
loadAllFromDb(): void;
|
|
81
|
+
}
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group Context — group chat message cache + maxContextChars budget + mention mapping.
|
|
3
|
+
*/
|
|
4
|
+
import { getGroupMembers, fetchUserInfo } from './octo/api.js';
|
|
5
|
+
import { sanitizeDisplayName } from './prompt-safety.js';
|
|
6
|
+
const REFRESH_INTERVAL_MS = 60 * 60 * 1000;
|
|
7
|
+
export class GroupContext {
|
|
8
|
+
messageCache = new Map();
|
|
9
|
+
// Per-channel member maps to avoid cross-group name collisions
|
|
10
|
+
memberMapByChannel = new Map(); // channelId → uid → name
|
|
11
|
+
nameToUidByChannel = new Map(); // channelId → name → uid
|
|
12
|
+
// G23: per-channel robot flag map (channelId → uid → isRobot).
|
|
13
|
+
// Populated from refreshMembers; stored for future routing integrations
|
|
14
|
+
// (e.g. 免@ gate that should treat bot members differently).
|
|
15
|
+
robotFlags = new Map();
|
|
16
|
+
lastRefresh = new Map();
|
|
17
|
+
adapter;
|
|
18
|
+
maxContextChars;
|
|
19
|
+
maxWindowSize;
|
|
20
|
+
upsertMember;
|
|
21
|
+
deleteMember;
|
|
22
|
+
selectMembers;
|
|
23
|
+
insertMessage;
|
|
24
|
+
selectRecentMessages;
|
|
25
|
+
deleteOldMessages;
|
|
26
|
+
selectMessagesSince;
|
|
27
|
+
selectMaxId;
|
|
28
|
+
upsertCursor;
|
|
29
|
+
selectCursor;
|
|
30
|
+
constructor(adapter, maxContextChars) {
|
|
31
|
+
this.adapter = adapter;
|
|
32
|
+
this.maxContextChars = maxContextChars;
|
|
33
|
+
this.maxWindowSize = 100;
|
|
34
|
+
this.initStatements();
|
|
35
|
+
}
|
|
36
|
+
initStatements() {
|
|
37
|
+
this.adapter.exec(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS group_messages (
|
|
39
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
40
|
+
channel_id TEXT NOT NULL,
|
|
41
|
+
from_uid TEXT NOT NULL,
|
|
42
|
+
from_name TEXT NOT NULL,
|
|
43
|
+
content TEXT NOT NULL,
|
|
44
|
+
timestamp INTEGER NOT NULL
|
|
45
|
+
)
|
|
46
|
+
`);
|
|
47
|
+
this.adapter.exec(`
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_group_messages_channel
|
|
49
|
+
ON group_messages (channel_id, id DESC)
|
|
50
|
+
`);
|
|
51
|
+
// Per-channel consumption cursor: the highest group_messages.id that has
|
|
52
|
+
// already been injected into a turn for this channel. Only group messages
|
|
53
|
+
// NEWER than this are injected next turn (the bot's standing context lives in
|
|
54
|
+
// the SDK session, so re-injecting the whole window every turn would be both
|
|
55
|
+
// redundant and a frozen-prompt violation). Mirrors the reset_barriers /
|
|
56
|
+
// sdk_sessions single-row-per-key pattern.
|
|
57
|
+
this.adapter.exec(`
|
|
58
|
+
CREATE TABLE IF NOT EXISTS group_context_cursors (
|
|
59
|
+
channel_id TEXT PRIMARY KEY,
|
|
60
|
+
last_id INTEGER NOT NULL,
|
|
61
|
+
updated_at INTEGER NOT NULL
|
|
62
|
+
)
|
|
63
|
+
`);
|
|
64
|
+
this.upsertMember = this.adapter.prepare('INSERT INTO group_members (group_id, uid, name, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(group_id, uid) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at');
|
|
65
|
+
this.deleteMember = this.adapter.prepare('DELETE FROM group_members WHERE group_id = ? AND uid = ?');
|
|
66
|
+
this.selectMembers = this.adapter.prepare('SELECT uid, name FROM group_members WHERE group_id = ?');
|
|
67
|
+
this.insertMessage = this.adapter.prepare('INSERT INTO group_messages (channel_id, from_uid, from_name, content, timestamp) VALUES (?, ?, ?, ?, ?)');
|
|
68
|
+
this.selectRecentMessages = this.adapter.prepare('SELECT from_uid, from_name, content, timestamp FROM group_messages WHERE channel_id = ? ORDER BY id DESC LIMIT ?');
|
|
69
|
+
this.deleteOldMessages = this.adapter.prepare('DELETE FROM group_messages WHERE channel_id = ? AND id NOT IN (SELECT id FROM group_messages WHERE channel_id = ? ORDER BY id DESC LIMIT ?)');
|
|
70
|
+
// Cursor delta: messages strictly newer than the cursor, NEWEST-first so a
|
|
71
|
+
// backlog larger than the fetch limit keeps the most-recent messages (the
|
|
72
|
+
// relevant ones) rather than ancient ones. LIMIT (maxWindowSize=100) is <=
|
|
73
|
+
// deleteOldMessages' retained 200, so every fetchable row survives trimming.
|
|
74
|
+
// buildContextSince re-sorts the budget-selected slice into chronological
|
|
75
|
+
// order for display. Mirrors the in-memory buildContext rolling-window.
|
|
76
|
+
this.selectMessagesSince = this.adapter.prepare('SELECT id, from_name, content FROM group_messages WHERE channel_id = ? AND id > ? ORDER BY id DESC LIMIT ?');
|
|
77
|
+
this.selectMaxId = this.adapter.prepare('SELECT MAX(id) AS maxId FROM group_messages WHERE channel_id = ?');
|
|
78
|
+
this.upsertCursor = this.adapter.prepare('INSERT INTO group_context_cursors (channel_id, last_id, updated_at) VALUES (?, ?, ?) ' +
|
|
79
|
+
'ON CONFLICT(channel_id) DO UPDATE SET last_id = excluded.last_id, updated_at = excluded.updated_at ' +
|
|
80
|
+
'WHERE excluded.last_id > group_context_cursors.last_id');
|
|
81
|
+
this.selectCursor = this.adapter.prepare('SELECT last_id FROM group_context_cursors WHERE channel_id = ?');
|
|
82
|
+
}
|
|
83
|
+
getMemberMap(channelId) {
|
|
84
|
+
let m = this.memberMapByChannel.get(channelId);
|
|
85
|
+
if (!m) {
|
|
86
|
+
m = new Map();
|
|
87
|
+
this.memberMapByChannel.set(channelId, m);
|
|
88
|
+
}
|
|
89
|
+
return m;
|
|
90
|
+
}
|
|
91
|
+
getNameToUid(channelId) {
|
|
92
|
+
let m = this.nameToUidByChannel.get(channelId);
|
|
93
|
+
if (!m) {
|
|
94
|
+
m = new Map();
|
|
95
|
+
this.nameToUidByChannel.set(channelId, m);
|
|
96
|
+
}
|
|
97
|
+
return m;
|
|
98
|
+
}
|
|
99
|
+
pushMessage(channelId, fromUid, fromName, content, timestamp) {
|
|
100
|
+
// SECURITY: fromName is the user-controlled IM display name and is rendered
|
|
101
|
+
// into the [Group context] block as `<name>:<content>`. Bound + strip it at
|
|
102
|
+
// the boundary (shared choke point) so it can't forge a label/line. fromUid
|
|
103
|
+
// is ALSO user-controlled, and sanitizeDisplayName returns its fallback
|
|
104
|
+
// verbatim — so sanitize the uid fallback too rather than passing it raw
|
|
105
|
+
// (PR #128 review: raw-uid-as-fallback re-introduces the injection).
|
|
106
|
+
const safeName = sanitizeDisplayName(fromName) || sanitizeDisplayName(fromUid) || 'unknown';
|
|
107
|
+
let window = this.messageCache.get(channelId);
|
|
108
|
+
if (!window) {
|
|
109
|
+
window = [];
|
|
110
|
+
this.messageCache.set(channelId, window);
|
|
111
|
+
}
|
|
112
|
+
window.push({ fromUid, fromName: safeName, content, timestamp });
|
|
113
|
+
while (window.length > this.maxWindowSize) {
|
|
114
|
+
window.shift();
|
|
115
|
+
}
|
|
116
|
+
this.learnMember(channelId, fromUid, safeName);
|
|
117
|
+
try {
|
|
118
|
+
this.insertMessage.run(channelId, fromUid, safeName, content, timestamp);
|
|
119
|
+
// Trim old messages to keep DB bounded (keep 2x window for safety)
|
|
120
|
+
this.deleteOldMessages.run(channelId, channelId, this.maxWindowSize * 2);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.error(`group-context: insert message failed: ${String(err)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
learnMember(channelId, uid, name) {
|
|
127
|
+
if (!uid || !name)
|
|
128
|
+
return;
|
|
129
|
+
const memberMap = this.getMemberMap(channelId);
|
|
130
|
+
const nameMap = this.getNameToUid(channelId);
|
|
131
|
+
const existing = memberMap.get(uid);
|
|
132
|
+
if (existing !== name) {
|
|
133
|
+
// Remove old reverse mapping only if it still points to THIS uid.
|
|
134
|
+
// If another user already claimed the same display name, don't clobber.
|
|
135
|
+
if (existing && nameMap.get(existing) === uid) {
|
|
136
|
+
nameMap.delete(existing);
|
|
137
|
+
}
|
|
138
|
+
memberMap.set(uid, name);
|
|
139
|
+
nameMap.set(name, uid);
|
|
140
|
+
try {
|
|
141
|
+
this.upsertMember.run(channelId, uid, name, Date.now());
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
console.error(`group-context: upsert member failed: ${String(err)}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async refreshMembers(channelId, apiUrl, botToken) {
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
const last = this.lastRefresh.get(channelId) ?? 0;
|
|
151
|
+
if (now - last < REFRESH_INTERVAL_MS)
|
|
152
|
+
return;
|
|
153
|
+
// Don't set lastRefresh here — only on success
|
|
154
|
+
try {
|
|
155
|
+
const members = await getGroupMembers({ apiUrl, botToken, groupNo: channelId });
|
|
156
|
+
this.lastRefresh.set(channelId, now); // Record only on success
|
|
157
|
+
const memberMap = this.getMemberMap(channelId);
|
|
158
|
+
const nameMap = this.getNameToUid(channelId);
|
|
159
|
+
// A8 (#143, take 2): the server response is AUTHORITATIVE — it is the full
|
|
160
|
+
// current roster, not a delta. Upserting returned members WITHOUT pruning
|
|
161
|
+
// departed ones (the original #144 bug, Jerry-Xin's 🔴) left a user who
|
|
162
|
+
// left the group cached forever, so isMember() kept accepting them and the
|
|
163
|
+
// outbound mention guard let a stale @uid through. Track who the server
|
|
164
|
+
// returned, then drop anyone cached/persisted who is no longer present.
|
|
165
|
+
//
|
|
166
|
+
// Best-effort caveat: this is only as fresh as the last successful refresh,
|
|
167
|
+
// which is throttled to REFRESH_INTERVAL_MS (1h) and seeded from DB on
|
|
168
|
+
// restart. A membership change inside that window is not reflected until
|
|
169
|
+
// the next refresh — the outbound guard is defense-in-depth, not a
|
|
170
|
+
// real-time authority. The server still enforces real permissions.
|
|
171
|
+
const present = new Set();
|
|
172
|
+
for (const m of members) {
|
|
173
|
+
if (!m.uid || !m.name)
|
|
174
|
+
continue;
|
|
175
|
+
present.add(m.uid);
|
|
176
|
+
const oldName = memberMap.get(m.uid);
|
|
177
|
+
if (oldName && oldName !== m.name && nameMap.get(oldName) === m.uid) {
|
|
178
|
+
nameMap.delete(oldName);
|
|
179
|
+
}
|
|
180
|
+
memberMap.set(m.uid, m.name);
|
|
181
|
+
nameMap.set(m.name, m.uid);
|
|
182
|
+
// G23: Track server-authoritative robot flag for future 免@ gate.
|
|
183
|
+
if (m.robot !== undefined) {
|
|
184
|
+
let rfMap = this.robotFlags.get(channelId);
|
|
185
|
+
if (!rfMap) {
|
|
186
|
+
rfMap = new Map();
|
|
187
|
+
this.robotFlags.set(channelId, rfMap);
|
|
188
|
+
}
|
|
189
|
+
rfMap.set(m.uid, m.robot === 1);
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
this.upsertMember.run(channelId, m.uid, m.name, now);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
console.error(`group-context: upsert member failed: ${String(err)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Prune members no longer in the authoritative roster (memory + DB + robot
|
|
199
|
+
// flags). Iterate a snapshot of uids since we mutate the map in the loop.
|
|
200
|
+
//
|
|
201
|
+
// Guard: skip pruning when the roster came back EMPTY. getGroupMembers
|
|
202
|
+
// returns [] not only for a genuinely empty group but also for a
|
|
203
|
+
// malformed/unexpected-shape 200 response (data.members not an array →
|
|
204
|
+
// silently []). A group the bot is in always has ≥1 member, so an empty
|
|
205
|
+
// roster is far more likely a transient quirk than "everyone left".
|
|
206
|
+
// Mass-pruning on it would wipe the whole roster — and since lastRefresh
|
|
207
|
+
// was already set on this "success", it wouldn't re-fetch for an hour,
|
|
208
|
+
// downgrading every mention to plain text in that window. Keep the prior
|
|
209
|
+
// snapshot instead; a real emptying still prunes member-by-member as the
|
|
210
|
+
// roster shrinks across non-empty responses.
|
|
211
|
+
if (present.size > 0) {
|
|
212
|
+
const rfMap = this.robotFlags.get(channelId);
|
|
213
|
+
for (const uid of [...memberMap.keys()]) {
|
|
214
|
+
if (present.has(uid))
|
|
215
|
+
continue;
|
|
216
|
+
const staleName = memberMap.get(uid);
|
|
217
|
+
memberMap.delete(uid);
|
|
218
|
+
// Only remove the reverse entry if it still points at THIS uid (a rename
|
|
219
|
+
// may have already re-pointed the name to someone else).
|
|
220
|
+
if (staleName !== undefined && nameMap.get(staleName) === uid) {
|
|
221
|
+
nameMap.delete(staleName);
|
|
222
|
+
}
|
|
223
|
+
rfMap?.delete(uid);
|
|
224
|
+
try {
|
|
225
|
+
this.deleteMember.run(channelId, uid);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
console.error(`group-context: delete stale member failed: ${String(err)}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
console.error(`group-context: refreshMembers(${channelId}) failed: ${String(err)}`);
|
|
235
|
+
// Don't update lastRefresh on failure — allow retry
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async fetchAndLearnUser(uid, channelId, apiUrl, botToken) {
|
|
239
|
+
const memberMap = this.getMemberMap(channelId);
|
|
240
|
+
const cached = memberMap.get(uid);
|
|
241
|
+
if (cached)
|
|
242
|
+
return cached;
|
|
243
|
+
try {
|
|
244
|
+
const info = await fetchUserInfo({ apiUrl, botToken, uid });
|
|
245
|
+
if (info?.name) {
|
|
246
|
+
this.learnMember(channelId, uid, info.name);
|
|
247
|
+
return info.name;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
console.error(`group-context: fetchUserInfo(${uid}) failed: ${String(err)}`);
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
buildContext(channelId) {
|
|
256
|
+
const window = this.messageCache.get(channelId);
|
|
257
|
+
if (!window || window.length === 0)
|
|
258
|
+
return '';
|
|
259
|
+
const header = '[Recent group messages]\n';
|
|
260
|
+
const trailer = '\n';
|
|
261
|
+
const budget = this.maxContextChars - header.length - trailer.length;
|
|
262
|
+
if (budget <= 0)
|
|
263
|
+
return '';
|
|
264
|
+
const selected = [];
|
|
265
|
+
let used = 0;
|
|
266
|
+
for (let i = window.length - 1; i >= 0; i--) {
|
|
267
|
+
const m = window[i];
|
|
268
|
+
const line = `${m.fromName}:${m.content}`;
|
|
269
|
+
const cost = line.length + (selected.length > 0 ? 1 : 0);
|
|
270
|
+
if (used + cost > budget)
|
|
271
|
+
break;
|
|
272
|
+
selected.push(line);
|
|
273
|
+
used += cost;
|
|
274
|
+
}
|
|
275
|
+
if (selected.length === 0)
|
|
276
|
+
return '';
|
|
277
|
+
selected.reverse();
|
|
278
|
+
return `${header}${selected.join('\n')}${trailer}`;
|
|
279
|
+
}
|
|
280
|
+
/** Read the per-channel consumption cursor (highest already-injected id), or 0. */
|
|
281
|
+
getContextCursor(channelId) {
|
|
282
|
+
try {
|
|
283
|
+
const row = this.selectCursor.get(channelId);
|
|
284
|
+
return row?.last_id ?? 0;
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
console.error(`group-context: getContextCursor(${channelId}) failed: ${String(err)}`);
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** Advance the per-channel cursor to `lastId` (monotonic — never moves backward). */
|
|
292
|
+
setContextCursor(channelId, lastId) {
|
|
293
|
+
try {
|
|
294
|
+
this.upsertCursor.run(channelId, lastId, Date.now());
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
console.error(`group-context: setContextCursor(${channelId}) failed: ${String(err)}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** The highest group_messages.id for a channel (for cursor priming), or 0. */
|
|
301
|
+
getMaxMessageId(channelId) {
|
|
302
|
+
try {
|
|
303
|
+
const row = this.selectMaxId.get(channelId);
|
|
304
|
+
return row?.maxId ?? 0;
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
console.error(`group-context: getMaxMessageId(${channelId}) failed: ${String(err)}`);
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Build a group-context block from messages NEWER than `sinceId` (the delta the
|
|
313
|
+
* model hasn't seen yet), within `maxContextChars`. Returns the block text (same
|
|
314
|
+
* `[Recent group messages]` format as buildContext) and the highest id that
|
|
315
|
+
* EXISTS in the channel above `sinceId` (so the caller advances the cursor past
|
|
316
|
+
* the whole delta, including any oldest lines the char budget dropped — those
|
|
317
|
+
* are the least-relevant and are intentionally not re-shown). Empty text + the
|
|
318
|
+
* unchanged cursor when there's nothing new.
|
|
319
|
+
*
|
|
320
|
+
* Rows are fetched newest-first (so a backlog larger than the budget keeps the
|
|
321
|
+
* most-recent messages); the selected slice is reversed back to chronological
|
|
322
|
+
* order for display. Unlike buildContext (in-memory rolling window), this reads
|
|
323
|
+
* from the DB so the cursor delta is exact even across restarts / window eviction.
|
|
324
|
+
*/
|
|
325
|
+
buildContextSince(channelId, sinceId) {
|
|
326
|
+
let rows;
|
|
327
|
+
try {
|
|
328
|
+
rows = this.selectMessagesSince.all(channelId, sinceId, this.maxWindowSize);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
console.error(`group-context: buildContextSince(${channelId}) failed: ${String(err)}`);
|
|
332
|
+
return { text: '', lastId: sinceId };
|
|
333
|
+
}
|
|
334
|
+
if (rows.length === 0)
|
|
335
|
+
return { text: '', lastId: sinceId };
|
|
336
|
+
// rows are newest-first; the highest id is the first row. Advance the cursor to
|
|
337
|
+
// it regardless of what the budget keeps, so we never re-show a line.
|
|
338
|
+
const lastId = rows[0].id;
|
|
339
|
+
const header = '[Recent group messages]\n';
|
|
340
|
+
const trailer = '\n';
|
|
341
|
+
const budget = this.maxContextChars - header.length - trailer.length;
|
|
342
|
+
if (budget <= 0)
|
|
343
|
+
return { text: '', lastId };
|
|
344
|
+
// Walk newest→oldest (rows[0] is newest) keeping lines within budget.
|
|
345
|
+
const selected = [];
|
|
346
|
+
let used = 0;
|
|
347
|
+
for (let i = 0; i < rows.length; i++) {
|
|
348
|
+
const line = `${rows[i].from_name}:${rows[i].content}`;
|
|
349
|
+
const cost = line.length + (selected.length > 0 ? 1 : 0);
|
|
350
|
+
if (used + cost > budget)
|
|
351
|
+
break;
|
|
352
|
+
selected.push(line);
|
|
353
|
+
used += cost;
|
|
354
|
+
}
|
|
355
|
+
if (selected.length === 0)
|
|
356
|
+
return { text: '', lastId };
|
|
357
|
+
selected.reverse(); // chronological order for display
|
|
358
|
+
return { text: `${header}${selected.join('\n')}${trailer}`, lastId };
|
|
359
|
+
}
|
|
360
|
+
resolveMentions(text, channelId) {
|
|
361
|
+
const uids = [];
|
|
362
|
+
const seen = new Set();
|
|
363
|
+
const nameMap = this.nameToUidByChannel.get(channelId);
|
|
364
|
+
if (!nameMap)
|
|
365
|
+
return uids;
|
|
366
|
+
// Match @name where name is a run of word-like characters (letters, digits, underscores,
|
|
367
|
+
// CJK ideographs, Hangul, Kana, etc.) — stops at punctuation and whitespace.
|
|
368
|
+
const regex = /@([\w\u4e00-\u9fff\u3400-\u4dbf\uac00-\ud7af\u3040-\u309f\u30a0-\u30ff]+)/g;
|
|
369
|
+
let match;
|
|
370
|
+
while ((match = regex.exec(text)) !== null) {
|
|
371
|
+
// Strip known trailing punctuation that might stick to names
|
|
372
|
+
let name = match[1];
|
|
373
|
+
name = name.replace(/[,.!?;:,。!?;:、)\]]+$/, '');
|
|
374
|
+
if (!name)
|
|
375
|
+
continue;
|
|
376
|
+
const uid = nameMap.get(name);
|
|
377
|
+
if (uid && !seen.has(uid)) {
|
|
378
|
+
seen.add(uid);
|
|
379
|
+
uids.push(uid);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return uids;
|
|
383
|
+
}
|
|
384
|
+
getName(uid, channelId) {
|
|
385
|
+
return this.getMemberMap(channelId).get(uid);
|
|
386
|
+
}
|
|
387
|
+
/** G23: Check the server-authoritative robot flag for a group member. */
|
|
388
|
+
isRobot(channelId, uid) {
|
|
389
|
+
return this.robotFlags.get(channelId)?.get(uid);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* A8 (#143): true iff `uid` is a current member of `channelId` per the cached
|
|
393
|
+
* member list. The list is kept authoritative by refreshMembers (it prunes
|
|
394
|
+
* departed members, not just upserts) — but it is only best-effort fresh:
|
|
395
|
+
* refresh is throttled to REFRESH_INTERVAL_MS and seeded from DB on restart,
|
|
396
|
+
* so a membership change inside that window may not be reflected yet. Used by
|
|
397
|
+
* the outbound mention guard as defense-in-depth (the server still enforces
|
|
398
|
+
* real permissions), NOT as a real-time authority.
|
|
399
|
+
*/
|
|
400
|
+
isMember(channelId, uid) {
|
|
401
|
+
return this.memberMapByChannel.get(channelId)?.has(uid) ?? false;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* A8 (#143): the channel's displayName→uid map, for v1 `@name` outbound
|
|
405
|
+
* resolution in StreamRelay.deliver. Returns the live map (empty if the
|
|
406
|
+
* channel has no cached members yet). Read-only use by callers.
|
|
407
|
+
*/
|
|
408
|
+
getNameToUidMap(channelId) {
|
|
409
|
+
return this.getNameToUid(channelId);
|
|
410
|
+
}
|
|
411
|
+
loadMembersFromDb(channelId) {
|
|
412
|
+
try {
|
|
413
|
+
const rows = this.selectMembers.all(channelId);
|
|
414
|
+
const memberMap = this.getMemberMap(channelId);
|
|
415
|
+
const nameMap = this.getNameToUid(channelId);
|
|
416
|
+
for (const r of rows) {
|
|
417
|
+
if (!r.uid || !r.name)
|
|
418
|
+
continue;
|
|
419
|
+
memberMap.set(r.uid, r.name);
|
|
420
|
+
nameMap.set(r.name, r.uid);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
console.error(`group-context: loadMembersFromDb(${channelId}) failed: ${String(err)}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
loadMessagesFromDb(channelId) {
|
|
428
|
+
try {
|
|
429
|
+
const rows = this.selectRecentMessages.all(channelId, this.maxWindowSize);
|
|
430
|
+
if (rows.length === 0)
|
|
431
|
+
return;
|
|
432
|
+
// Rows come in DESC order, reverse to chronological
|
|
433
|
+
rows.reverse();
|
|
434
|
+
const existing = this.messageCache.get(channelId);
|
|
435
|
+
if (existing && existing.length > 0)
|
|
436
|
+
return; // Don't overwrite live data
|
|
437
|
+
// Map snake_case DB columns to camelCase GroupMessage
|
|
438
|
+
this.messageCache.set(channelId, rows.map(r => ({
|
|
439
|
+
fromUid: r.from_uid,
|
|
440
|
+
fromName: r.from_name,
|
|
441
|
+
content: r.content,
|
|
442
|
+
timestamp: r.timestamp,
|
|
443
|
+
})));
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
console.error(`group-context: loadMessagesFromDb(${channelId}) failed: ${String(err)}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/** Load all persisted members and messages from DB (call once at startup). */
|
|
450
|
+
loadAllFromDb() {
|
|
451
|
+
try {
|
|
452
|
+
const rows = this.adapter.prepare('SELECT DISTINCT group_id FROM group_members').all();
|
|
453
|
+
for (const row of rows) {
|
|
454
|
+
this.loadMembersFromDb(row.group_id);
|
|
455
|
+
this.loadMessagesFromDb(row.group_id);
|
|
456
|
+
}
|
|
457
|
+
if (rows.length > 0) {
|
|
458
|
+
console.log(`[group-context] Loaded members + messages for ${rows.length} group(s) from DB`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
console.error(`group-context: loadAllFromDb failed: ${String(err)}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
//# sourceMappingURL=group-context.js.map
|