@mininglamp-oss/cc-channel-octo 1.0.1
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 +278 -0
- package/dist/config.js +330 -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 +64 -0
- package/dist/group-context.js +396 -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 +922 -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 +99 -0
- package/dist/mention-utils.js +185 -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 +127 -0
- package/dist/session-router.js +432 -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,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Router — routing + concurrency control + mention gate + rate limiting.
|
|
3
|
+
*/
|
|
4
|
+
import { ChannelType, MessageType } from './octo/types.js';
|
|
5
|
+
import { sendMessage } from './octo/api.js';
|
|
6
|
+
import { isAuthenticCronFire } from './cron-fire-marker.js';
|
|
7
|
+
const BUCKET_STALE_MS = 5 * 60 * 1000; // 5 minutes
|
|
8
|
+
/** Global rate limit: 10x per-session limit, shared across all sessions. */
|
|
9
|
+
const GLOBAL_RATE_MULTIPLIER = 10;
|
|
10
|
+
/** Maximum allowed content length in bytes (Q10). Messages exceeding this are rejected. */
|
|
11
|
+
const MAX_CONTENT_BYTES = 32_768; // 32 KB
|
|
12
|
+
export class SessionRouter {
|
|
13
|
+
config;
|
|
14
|
+
robotId;
|
|
15
|
+
/** G18: owner_uid from registerBot. Stored for future permission model. */
|
|
16
|
+
ownerUid;
|
|
17
|
+
inboundQueues = new Map();
|
|
18
|
+
tokenBuckets = new Map();
|
|
19
|
+
/** G20: per-user buckets keyed by from_uid alone (cross-channel rate limit). */
|
|
20
|
+
userBuckets = new Map();
|
|
21
|
+
globalBucket = null;
|
|
22
|
+
/**
|
|
23
|
+
* G14: UIDs known to be bots. Initialized with this bot's robotId; can be
|
|
24
|
+
* extended via registerKnownBot() for future multi-bot deployments.
|
|
25
|
+
*/
|
|
26
|
+
knownBotUids = new Set();
|
|
27
|
+
constructor(config, robotId, ownerUid = '') {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.robotId = robotId;
|
|
30
|
+
this.ownerUid = ownerUid;
|
|
31
|
+
this.knownBotUids.add(robotId);
|
|
32
|
+
}
|
|
33
|
+
/** G14: register another known bot uid (future multi-bot support). */
|
|
34
|
+
registerKnownBot(uid) {
|
|
35
|
+
if (uid)
|
|
36
|
+
this.knownBotUids.add(uid);
|
|
37
|
+
}
|
|
38
|
+
/** G18: owner_uid stored from registerBot. Used by future permission model. */
|
|
39
|
+
getOwnerUid() {
|
|
40
|
+
return this.ownerUid;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Acquire a per-session lock for the full message handling pipeline.
|
|
44
|
+
* Callers (e.g. index.ts) use this to ensure the entire handleMessage
|
|
45
|
+
* chain — not just routing — runs serially per session key.
|
|
46
|
+
*/
|
|
47
|
+
async withSessionLock(key, fn) {
|
|
48
|
+
const prev = this.inboundQueues.get(key) ?? Promise.resolve();
|
|
49
|
+
let resolveGate;
|
|
50
|
+
const gate = new Promise((r) => {
|
|
51
|
+
resolveGate = r;
|
|
52
|
+
});
|
|
53
|
+
this.inboundQueues.set(key, gate);
|
|
54
|
+
try {
|
|
55
|
+
await prev;
|
|
56
|
+
return await fn();
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
resolveGate();
|
|
60
|
+
if (this.inboundQueues.get(key) === gate) {
|
|
61
|
+
this.inboundQueues.delete(key);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Route a message and, if it should be processed, run the handler callback
|
|
67
|
+
* under the same per-session lock. This ensures no gap between route decision
|
|
68
|
+
* and pipeline execution — concurrent same-session messages cannot interleave.
|
|
69
|
+
*/
|
|
70
|
+
/**
|
|
71
|
+
* Route a message and, if it should be processed, run the handler callback
|
|
72
|
+
* under the same per-session lock. This ensures no gap between route decision
|
|
73
|
+
* and pipeline execution — concurrent same-session messages cannot interleave.
|
|
74
|
+
*
|
|
75
|
+
* Returns the RouteResult (or `null` for silent-drop cases) so the caller
|
|
76
|
+
* can inspect `rejectionReason` to decide whether the message should still
|
|
77
|
+
* influence downstream side-effects like group context caching
|
|
78
|
+
* (C1 / P2.5 — stage 6).
|
|
79
|
+
*/
|
|
80
|
+
async routeAndHandle(msg, handler) {
|
|
81
|
+
const key = this.sessionKey(msg);
|
|
82
|
+
return this.withSessionLock(key, async () => {
|
|
83
|
+
const result = await this.processMessage(msg, key);
|
|
84
|
+
if (result && result.shouldProcess) {
|
|
85
|
+
await handler(result);
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async route(msg) {
|
|
91
|
+
const key = this.sessionKey(msg);
|
|
92
|
+
return this.withSessionLock(key, () => this.processMessage(msg, key));
|
|
93
|
+
}
|
|
94
|
+
sessionKey(msg) {
|
|
95
|
+
const spaceId = this.extractSpaceId(msg);
|
|
96
|
+
if (msg.channel_type === ChannelType.DM) {
|
|
97
|
+
// DM is per-user (private): same peer always resumes the same session.
|
|
98
|
+
// from_uid IS the peer's identity here; a missing one is unroutable —
|
|
99
|
+
// never fall back to '' (that would collapse every uid-less DM into ONE
|
|
100
|
+
// shared session across unrelated peers, leaking history/memory). Mirrors
|
|
101
|
+
// the group channel_id guard below. Caught upstream → message dropped.
|
|
102
|
+
if (!msg.from_uid) {
|
|
103
|
+
throw new Error('DM message has no from_uid — cannot derive a session key');
|
|
104
|
+
}
|
|
105
|
+
return spaceId ? `${spaceId}:${msg.from_uid}` : msg.from_uid;
|
|
106
|
+
}
|
|
107
|
+
// Group is per-CHANNEL (shared): every member of a group shares one session,
|
|
108
|
+
// history, working dir, and memory — a group is a collective workspace, not
|
|
109
|
+
// N private chats. (Reverses the per-(channel×user) split from PR #64; see
|
|
110
|
+
// src/cwd-resolver.ts header. Space isolation is implicit: one bot = one
|
|
111
|
+
// space = one process with its own dataDir/cwdBase/memoryBase.)
|
|
112
|
+
//
|
|
113
|
+
// channel_id IS the group's identity here, so a missing one is unroutable —
|
|
114
|
+
// never fall back to '' (that would collapse every channel-less group message
|
|
115
|
+
// into ONE shared session across unrelated channels, leaking history/memory
|
|
116
|
+
// between them). Fail loud instead; route() treats the throw as a drop.
|
|
117
|
+
if (!msg.channel_id) {
|
|
118
|
+
throw new Error('Group message has no channel_id — cannot derive a session key');
|
|
119
|
+
}
|
|
120
|
+
return msg.channel_id;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Extract spaceId from channel_id.
|
|
124
|
+
* DM format: s{spaceId}_{uid1}@s{spaceId}_{uid2}
|
|
125
|
+
* Group format: s{spaceId}_{groupNo} (but groups already use channel_id in key)
|
|
126
|
+
*/
|
|
127
|
+
extractSpaceId(msg) {
|
|
128
|
+
// For groups, channel_id already provides isolation
|
|
129
|
+
if (this.isGroupLike(msg.channel_type))
|
|
130
|
+
return "";
|
|
131
|
+
// DM: try from_uid first (format: s{spaceId}_{peerId})
|
|
132
|
+
const uid = msg.from_uid;
|
|
133
|
+
if (uid.startsWith("s")) {
|
|
134
|
+
const lastUnderscore = uid.lastIndexOf("_");
|
|
135
|
+
if (lastUnderscore > 0) {
|
|
136
|
+
return uid.substring(1, lastUnderscore);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// DM compound: s{spaceId}_{uid1}@s{spaceId}_{uid2}
|
|
140
|
+
const channelId = msg.channel_id;
|
|
141
|
+
if (channelId && channelId.startsWith("s")) {
|
|
142
|
+
const atIdx = channelId.indexOf("@");
|
|
143
|
+
const firstPart = atIdx > 0 ? channelId.substring(0, atIdx) : channelId;
|
|
144
|
+
if (firstPart.startsWith("s")) {
|
|
145
|
+
const lastUnderscore = firstPart.lastIndexOf("_");
|
|
146
|
+
if (lastUnderscore > 0) {
|
|
147
|
+
return firstPart.substring(1, lastUnderscore);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
153
|
+
isBlockedBot(uid) {
|
|
154
|
+
return this.config.botBlocklist?.includes(uid) ?? false;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* G14: Heuristic bot detection. Octo bot uids conventionally end in `_bot`.
|
|
158
|
+
* This is NOT a perfect check — a human could pick that suffix — but it
|
|
159
|
+
* catches the common case where a bot DMs another bot and triggers an
|
|
160
|
+
* uncontrolled response loop. Bots whitelisted in `allowedBotUids` bypass
|
|
161
|
+
* this gate.
|
|
162
|
+
*/
|
|
163
|
+
looksLikeBot(uid) {
|
|
164
|
+
if (this.knownBotUids.has(uid))
|
|
165
|
+
return true;
|
|
166
|
+
if (uid.endsWith('_bot'))
|
|
167
|
+
return true;
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
isAllowedBot(uid) {
|
|
171
|
+
return this.config.allowedBotUids?.includes(uid) ?? false;
|
|
172
|
+
}
|
|
173
|
+
isGroupLike(channelType) {
|
|
174
|
+
return channelType === ChannelType.Group || channelType === ChannelType.CommunityTopic;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Allowlist of channel types we actually converse on: DM, Group, and
|
|
178
|
+
* CommunityTopic. Octo also emits system/command channels (e.g. channel_type
|
|
179
|
+
* 8 "systemcmdonline" on connect) which are NOT user conversations — those
|
|
180
|
+
* must be dropped, otherwise they fall through the DM/group gates and get
|
|
181
|
+
* answered with an unsolicited LLM reply. Found in live deployment (#68).
|
|
182
|
+
*/
|
|
183
|
+
isSupportedChannel(channelType) {
|
|
184
|
+
return channelType === ChannelType.DM || this.isGroupLike(channelType);
|
|
185
|
+
}
|
|
186
|
+
isMentioned(msg) {
|
|
187
|
+
const mention = msg.payload.mention;
|
|
188
|
+
if (!mention)
|
|
189
|
+
return false;
|
|
190
|
+
if (mention.uids?.includes(this.robotId))
|
|
191
|
+
return true;
|
|
192
|
+
// Note: mention.all is a humans-only signal (@所有人), bots do NOT respond.
|
|
193
|
+
if (mention.ais)
|
|
194
|
+
return true;
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* #115: True for a GENUINE in-process cron fire — `payload._cronFire` AND a
|
|
199
|
+
* matching per-process nonce. Such messages bypass the group @mention gate
|
|
200
|
+
* (owner-gated at creation, bound to this session). A forged inbound payload
|
|
201
|
+
* can set `_cronFire` but not the secret nonce, so it does not pass. Real
|
|
202
|
+
* inbound messages never carry the marker.
|
|
203
|
+
*/
|
|
204
|
+
isCronFire(msg) {
|
|
205
|
+
return isAuthenticCronFire(msg.payload);
|
|
206
|
+
}
|
|
207
|
+
async processMessage(msg, key) {
|
|
208
|
+
// Skip messages from self.
|
|
209
|
+
if (msg.from_uid === this.robotId)
|
|
210
|
+
return null;
|
|
211
|
+
// Drop anything that isn't a real conversation channel (DM / group /
|
|
212
|
+
// community topic). Octo emits system/command channels (e.g. channel_type 8
|
|
213
|
+
// "systemcmdonline" on connect) that otherwise slip past the DM/group gates
|
|
214
|
+
// and get an unsolicited reply (#68).
|
|
215
|
+
if (!this.isSupportedChannel(msg.channel_type))
|
|
216
|
+
return null;
|
|
217
|
+
// Skip stream update messages (G21) — only process final (non-stream) messages.
|
|
218
|
+
// When streamOn is true, this is a partial update of an ongoing stream; the final
|
|
219
|
+
// message arrives with streamOn=false and contains the complete content.
|
|
220
|
+
if (msg.streamOn)
|
|
221
|
+
return null;
|
|
222
|
+
// DM blocklist filter.
|
|
223
|
+
if (msg.channel_type === ChannelType.DM && this.isBlockedBot(msg.from_uid)) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
// G14: DM from anything that looks like a bot — silently drop unless
|
|
227
|
+
// explicitly whitelisted. Prevents bot↔bot reply loops.
|
|
228
|
+
if (msg.channel_type === ChannelType.DM &&
|
|
229
|
+
this.looksLikeBot(msg.from_uid) &&
|
|
230
|
+
!this.isAllowedBot(msg.from_uid)) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
// Group: drop messages from other bots (blocklisted or self) entirely.
|
|
234
|
+
if (this.isGroupLike(msg.channel_type) && this.isBlockedBot(msg.from_uid)) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
// G14: Group messages from bot-looking uids — only respond if explicitly
|
|
238
|
+
// @-mentioned. The mention gate below already enforces this, but bots in
|
|
239
|
+
// the blocklist (above) get hard-dropped without even checking mentions.
|
|
240
|
+
// Group mention gate — skip unless mentioned OR in mention-free group (G12).
|
|
241
|
+
// #115: cron-fired synthetic messages bypass the @mention requirement — they
|
|
242
|
+
// were created (owner-gated) and bound to this session; there's no human to
|
|
243
|
+
// @-mention the bot at fire time. Rate limiting below still applies.
|
|
244
|
+
if (this.isGroupLike(msg.channel_type) && !this.isMentioned(msg) && !this.isCronFire(msg)) {
|
|
245
|
+
// G12: Check if this group is in the mention-free list
|
|
246
|
+
const isMentionFree = this.config.mentionFreeGroups?.includes(msg.channel_id ?? '') ?? false;
|
|
247
|
+
if (!isMentionFree) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
// Multi-bot loop guard: in a mention-free group there is no @-mention gate
|
|
251
|
+
// to stop one bot from replying to another bot's plain-text message. Drop
|
|
252
|
+
// messages from known/bot-looking uids (unless explicitly whitelisted) so
|
|
253
|
+
// two bots in the same mention-free room cannot enter an unbounded reply
|
|
254
|
+
// loop. An @-mention still goes through (handled by the branch above).
|
|
255
|
+
if (this.looksLikeBot(msg.from_uid) && !this.isAllowedBot(msg.from_uid)) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Skip system events (group join/leave, etc.) — no user-facing reply needed.
|
|
260
|
+
if (msg.payload.event)
|
|
261
|
+
return null;
|
|
262
|
+
// Rate limit check BEFORE non-text check — prevents DM spam of non-text
|
|
263
|
+
// messages from bypassing rate limiting entirely.
|
|
264
|
+
// G20 fix: peek all three buckets without consuming; only consume on full
|
|
265
|
+
// pass. On block, attach notified state to the actual blocking bucket so
|
|
266
|
+
// the debounce reply doesn't spam when a different bucket has tokens.
|
|
267
|
+
// #115: cron fires skip the rate limit — a scheduler-fired task is an
|
|
268
|
+
// operator-scheduled action (already bounded by the cron interval), not
|
|
269
|
+
// user spam. Without this a fire that lands while the owner's bucket is
|
|
270
|
+
// exhausted would be silently dropped while its nextRun has already advanced.
|
|
271
|
+
if (!this.isCronFire(msg)) {
|
|
272
|
+
const blocker = this.checkAllRateLimits(key, msg.from_uid);
|
|
273
|
+
if (blocker) {
|
|
274
|
+
if (!blocker.notified) {
|
|
275
|
+
blocker.notified = true;
|
|
276
|
+
await this.replySafe(msg, '请稍后再试');
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
sessionKey: key,
|
|
280
|
+
shouldProcess: false,
|
|
281
|
+
message: msg,
|
|
282
|
+
rejectionReason: 'rate_limited',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// G1: All payload types are now resolved by inbound.resolveContent in
|
|
287
|
+
// handleMessage. The router only filters rate limits, size, and bot
|
|
288
|
+
// loops — type-specific handling lives in the pipeline.
|
|
289
|
+
// Q10: Reject messages exceeding content length limit (text only — media
|
|
290
|
+
// URLs are bounded by their own size and rendered via resolveContent).
|
|
291
|
+
const content = msg.payload.content ?? '';
|
|
292
|
+
if (msg.payload.type === MessageType.Text &&
|
|
293
|
+
Buffer.byteLength(content, 'utf-8') > MAX_CONTENT_BYTES) {
|
|
294
|
+
await this.replySafe(msg, '消息过长,请缩短后重试');
|
|
295
|
+
return {
|
|
296
|
+
sessionKey: key,
|
|
297
|
+
shouldProcess: false,
|
|
298
|
+
message: msg,
|
|
299
|
+
rejectionReason: 'oversized',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
// G13: Strip leading @botname from group TEXT messages for cleaner LLM input.
|
|
303
|
+
// For non-text payloads (images, files, etc.) cleanContent stays undefined
|
|
304
|
+
// so the pipeline falls back to the resolveContent rendering instead of an
|
|
305
|
+
// empty string.
|
|
306
|
+
let cleanContent;
|
|
307
|
+
if (msg.payload.type === MessageType.Text) {
|
|
308
|
+
cleanContent = content;
|
|
309
|
+
if (this.isGroupLike(msg.channel_type)) {
|
|
310
|
+
const mention = msg.payload.mention;
|
|
311
|
+
// Path 1: entities-based removal (precise offset/length).
|
|
312
|
+
if (mention?.entities && Array.isArray(mention.entities)) {
|
|
313
|
+
const botEntity = mention.entities.find((e) => e.uid === this.robotId && e.offset === 0);
|
|
314
|
+
if (botEntity && typeof botEntity.length === 'number') {
|
|
315
|
+
cleanContent = content.substring(botEntity.length).trimStart();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Path 2: regex fallback — only when the bot was explicitly @mentioned.
|
|
319
|
+
// In mention-free groups (G12) where the bot wasn't @'d, do NOT touch
|
|
320
|
+
// the message — a leading @ is addressed to someone else.
|
|
321
|
+
if (cleanContent === content && this.isMentioned(msg)) {
|
|
322
|
+
cleanContent = content.replace(/^@\S+\s*/, '').trimStart();
|
|
323
|
+
}
|
|
324
|
+
// If stripping emptied the content, keep original.
|
|
325
|
+
if (!cleanContent)
|
|
326
|
+
cleanContent = content;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return { sessionKey: key, shouldProcess: true, message: msg, cleanContent };
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* G20 fix: Check all three rate limits (global, per-session, per-user) in
|
|
333
|
+
* one pass. Refills all three buckets, then either consumes 1 token from
|
|
334
|
+
* each (when all pass) or returns the blocking bucket (when any fails).
|
|
335
|
+
*
|
|
336
|
+
* Returns null on success (tokens consumed), or the blocking bucket on
|
|
337
|
+
* failure (no tokens consumed). The caller uses the blocking bucket's
|
|
338
|
+
* `notified` flag to debounce the "请稍后再试" reply per-bucket, so a user
|
|
339
|
+
* blocked by per-user limit doesn't get spammed when their per-session
|
|
340
|
+
* bucket still has tokens.
|
|
341
|
+
*/
|
|
342
|
+
checkAllRateLimits(key, uid) {
|
|
343
|
+
this.cleanStaleBuckets();
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
const maxPerMinute = this.config.rateLimit.maxPerMinute;
|
|
346
|
+
const globalMax = maxPerMinute * GLOBAL_RATE_MULTIPLIER;
|
|
347
|
+
const globalBucket = this.getOrCreateGlobalBucket(now, globalMax);
|
|
348
|
+
// Per-participant session bucket: key by session AND uid. For a group the
|
|
349
|
+
// sessionKey is the channel_id (shared), so keying the rate bucket by
|
|
350
|
+
// sessionKey alone would collapse the WHOLE room into one maxPerMinute quota
|
|
351
|
+
// (the 6th message/min from ANY member blocked). Including uid restores a
|
|
352
|
+
// per-member per-channel quota — matching the pre-shared-session behavior —
|
|
353
|
+
// while the global + per-user buckets still bound abuse. For a DM the
|
|
354
|
+
// sessionKey already embeds the peer, so this is just per-peer. Joined with a
|
|
355
|
+
// newline (never present in a uid/key) so distinct pairs can't alias.
|
|
356
|
+
const sessionBucketKey = `${key}\n${uid}`;
|
|
357
|
+
const sessionBucket = this.getOrCreateBucket(this.tokenBuckets, sessionBucketKey, now, maxPerMinute);
|
|
358
|
+
const userBucket = this.getOrCreateBucket(this.userBuckets, uid, now, maxPerMinute);
|
|
359
|
+
this.refillBucket(globalBucket, now, globalMax);
|
|
360
|
+
this.refillBucket(sessionBucket, now, maxPerMinute);
|
|
361
|
+
this.refillBucket(userBucket, now, maxPerMinute);
|
|
362
|
+
// Check in priority order: global → per-user → per-session.
|
|
363
|
+
// Per-user before per-session so a user blocked across groups gets a
|
|
364
|
+
// consistent debounce target instead of one per session bucket.
|
|
365
|
+
if (globalBucket.tokens < 1)
|
|
366
|
+
return globalBucket;
|
|
367
|
+
if (userBucket.tokens < 1)
|
|
368
|
+
return userBucket;
|
|
369
|
+
if (sessionBucket.tokens < 1)
|
|
370
|
+
return sessionBucket;
|
|
371
|
+
// All pass — consume one token from each, and clear notified flags so
|
|
372
|
+
// future blocks get a fresh debounce window.
|
|
373
|
+
globalBucket.tokens -= 1;
|
|
374
|
+
sessionBucket.tokens -= 1;
|
|
375
|
+
userBucket.tokens -= 1;
|
|
376
|
+
globalBucket.notified = false;
|
|
377
|
+
sessionBucket.notified = false;
|
|
378
|
+
userBucket.notified = false;
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
getOrCreateBucket(map, key, now, capacity) {
|
|
382
|
+
let bucket = map.get(key);
|
|
383
|
+
if (!bucket) {
|
|
384
|
+
bucket = { tokens: capacity, lastRefill: now, notified: false };
|
|
385
|
+
map.set(key, bucket);
|
|
386
|
+
}
|
|
387
|
+
return bucket;
|
|
388
|
+
}
|
|
389
|
+
getOrCreateGlobalBucket(now, capacity) {
|
|
390
|
+
if (!this.globalBucket) {
|
|
391
|
+
this.globalBucket = { tokens: capacity, lastRefill: now, notified: false };
|
|
392
|
+
}
|
|
393
|
+
return this.globalBucket;
|
|
394
|
+
}
|
|
395
|
+
refillBucket(bucket, now, capacity) {
|
|
396
|
+
const elapsed = now - bucket.lastRefill;
|
|
397
|
+
const refill = (elapsed / 60_000) * capacity;
|
|
398
|
+
bucket.tokens = Math.min(capacity, bucket.tokens + refill);
|
|
399
|
+
bucket.lastRefill = now;
|
|
400
|
+
}
|
|
401
|
+
/** Remove token buckets that haven't been used in 5 minutes. */
|
|
402
|
+
cleanStaleBuckets() {
|
|
403
|
+
const now = Date.now();
|
|
404
|
+
for (const [key, bucket] of this.tokenBuckets) {
|
|
405
|
+
if (now - bucket.lastRefill > BUCKET_STALE_MS) {
|
|
406
|
+
this.tokenBuckets.delete(key);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
for (const [uid, bucket] of this.userBuckets) {
|
|
410
|
+
if (now - bucket.lastRefill > BUCKET_STALE_MS) {
|
|
411
|
+
this.userBuckets.delete(uid);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async replySafe(msg, content) {
|
|
416
|
+
if (!msg.channel_id || msg.channel_type === undefined)
|
|
417
|
+
return;
|
|
418
|
+
try {
|
|
419
|
+
await sendMessage({
|
|
420
|
+
apiUrl: this.config.apiUrl,
|
|
421
|
+
botToken: this.config.botToken,
|
|
422
|
+
channelId: msg.channel_id,
|
|
423
|
+
channelType: msg.channel_type,
|
|
424
|
+
content,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
console.error(`session-router: reply failed: ${String(err)}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
//# sourceMappingURL=session-router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-router.js","sourceRoot":"","sources":["../src/session-router.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Store — SQLite persistence via better-sqlite3 + thin adapter.
|
|
3
|
+
*/
|
|
4
|
+
import type { DbAdapter } from './db-adapter.js';
|
|
5
|
+
export interface Session {
|
|
6
|
+
id: string;
|
|
7
|
+
channelId: string;
|
|
8
|
+
channelType: number;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
updatedAt: number;
|
|
11
|
+
}
|
|
12
|
+
export declare class SessionStore {
|
|
13
|
+
private readonly adapter;
|
|
14
|
+
private selectSession;
|
|
15
|
+
private insertSession;
|
|
16
|
+
private touchSession;
|
|
17
|
+
private insertMessage;
|
|
18
|
+
private selectRecentMessages;
|
|
19
|
+
private deleteExpired;
|
|
20
|
+
private deleteSessionStmt;
|
|
21
|
+
private upsertResetBarrier;
|
|
22
|
+
private selectResetBarrier;
|
|
23
|
+
private upsertSdkSession;
|
|
24
|
+
private selectSdkSession;
|
|
25
|
+
private deleteSdkSession;
|
|
26
|
+
private deleteExpiredSdkSessions;
|
|
27
|
+
/** Tracks the last message_seq at which the bot replied, per group session key. */
|
|
28
|
+
private lastBotReplySeq;
|
|
29
|
+
constructor(adapter: DbAdapter);
|
|
30
|
+
init(): void;
|
|
31
|
+
getOrCreate(id: string, channelId: string, channelType: number): Session;
|
|
32
|
+
appendUser(sessionId: string, content: string, messageSeq?: number, fromName?: string): void;
|
|
33
|
+
appendAssistant(sessionId: string, content: string, messageSeq?: number, botName?: string): void;
|
|
34
|
+
private append;
|
|
35
|
+
/**
|
|
36
|
+
* Render one history turn with speaker attribution. Group sessions are shared
|
|
37
|
+
* across members, so every turn names its sender — `[user <name>]:` and
|
|
38
|
+
* `[assistant <botName>]:`. The name is sanitized at write time (see append())
|
|
39
|
+
* so it cannot forge turn labels; the `?? role` coalesce only guards rows from
|
|
40
|
+
* before this column existed.
|
|
41
|
+
*
|
|
42
|
+
* SECURITY: the message CONTENT is also user-controlled and travels into the
|
|
43
|
+
* shared `[Conversation history]` block. A body whose line starts with
|
|
44
|
+
* `[assistant ...]:` / `[user ...]:` would forge an extra turn that, in shared
|
|
45
|
+
* group mode, every member then reads as real conversation (cross-user context
|
|
46
|
+
* poisoning — the same threat the from_name strip closes, but via content and
|
|
47
|
+
* easier to exploit since no display name is needed). So we neutralize any
|
|
48
|
+
* line-leading role label in the content here, at render time. This is the one
|
|
49
|
+
* coherent policy: turn labels can ONLY originate from this renderer, never
|
|
50
|
+
* from a user-controlled name or body.
|
|
51
|
+
*/
|
|
52
|
+
private renderTurn;
|
|
53
|
+
buildHistoryPrefix(sessionId: string, limit: number): string;
|
|
54
|
+
cleanExpired(): number;
|
|
55
|
+
deleteSession(sessionId: string): void;
|
|
56
|
+
/**
|
|
57
|
+
* v0.3 /reset: record a barrier so cold-start backfill never resurrects
|
|
58
|
+
* history at or before `resetSeq`. Persisted independently of the session row
|
|
59
|
+
* (survives deleteSession + restart). Monotonic — a later reset raises the
|
|
60
|
+
* barrier, an out-of-order/older seq is ignored.
|
|
61
|
+
*
|
|
62
|
+
* `resetSeq` is the message_seq of the /reset command itself; everything up to
|
|
63
|
+
* and including it is considered intentionally discarded.
|
|
64
|
+
*/
|
|
65
|
+
setResetBarrier(sessionId: string, resetSeq: number): void;
|
|
66
|
+
/** Return the reset barrier seq for a session, or undefined if never reset. */
|
|
67
|
+
getResetBarrier(sessionId: string): number | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* v0.3 persistent sessions: record the SDK session UUID for a sessionKey so a
|
|
70
|
+
* later turn can resume it. Upserts (latest wins).
|
|
71
|
+
*/
|
|
72
|
+
setSdkSessionId(sessionId: string, sdkSessionId: string): void;
|
|
73
|
+
/** Return the stored SDK session UUID for a sessionKey, or undefined. */
|
|
74
|
+
getSdkSessionId(sessionId: string): string | undefined;
|
|
75
|
+
/** Forget the SDK session mapping (e.g. on /reset or a resume failure). */
|
|
76
|
+
clearSdkSessionId(sessionId: string): void;
|
|
77
|
+
close(): void;
|
|
78
|
+
/** Record the message_seq at which the bot last replied for a session. */
|
|
79
|
+
setLastBotReplySeq(sessionId: string, seq: number): void;
|
|
80
|
+
/** Get the message_seq at which the bot last replied for a session. */
|
|
81
|
+
getLastBotReplySeq(sessionId: string): number | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Build history prefix with answered/new segmentation (G10).
|
|
84
|
+
* Messages with message_seq <= lastBotReplySeq are labeled [answered history],
|
|
85
|
+
* messages after are labeled [new messages]. Falls back to flat history if
|
|
86
|
+
* no lastBotReplySeq tracked or no seq data available.
|
|
87
|
+
*/
|
|
88
|
+
buildSegmentedHistoryPrefix(sessionId: string, limit: number): string;
|
|
89
|
+
}
|