@inetafrica/open-claudia 3.5.1 → 3.5.4
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 +15 -0
- package/bin/cli.js +13 -5
- package/bin/loopback-client.js +4 -0
- package/bin/schedule.js +7 -2
- package/bot.js +6 -2
- package/channels/kazee/adapter.js +462 -64
- package/channels/kazee/state.js +78 -2
- package/channels/types.js +11 -5
- package/core/actions.js +6 -0
- package/core/approvals.js +9 -0
- package/core/context.js +11 -2
- package/core/handlers.js +161 -59
- package/core/identity.js +33 -3
- package/core/io.js +21 -8
- package/core/jobs.js +3 -0
- package/core/loopback.js +55 -7
- package/core/providers/claude.js +6 -2
- package/core/router.js +25 -13
- package/core/run-context.js +49 -8
- package/core/runner.js +302 -120
- package/core/scheduler.js +45 -3
- package/core/side-chat.js +40 -11
- package/core/speaker-attest.js +2 -0
- package/core/state.js +317 -50
- package/core/system-prompt.js +61 -14
- package/core/transcripts.js +23 -6
- package/package.json +3 -3
- package/test-approval-cards.js +24 -0
- package/test-call-speech.js +5 -3
- package/test-fixtures/fake-agent-cli.js +52 -1
- package/test-provider-compaction.js +50 -11
- package/test-provider-gateway.js +31 -10
- package/test-provider-language.js +10 -1
- package/test-provider-scheduler.js +77 -20
- package/test-provider-session-commands.js +11 -2
- package/test-provider-side-chat.js +30 -2
- package/test-run-lock.js +122 -39
- package/test-thread-lanes.js +530 -0
- package/core/queue-drain.js +0 -25
- package/test-queue-routing.js +0 -52
|
@@ -40,6 +40,63 @@ const CONTEXT_MAX_MESSAGES = 40;
|
|
|
40
40
|
const CONTEXT_BODY_LIMIT = 280;
|
|
41
41
|
const COLD_START_WINDOW = 40;
|
|
42
42
|
|
|
43
|
+
// Chat threads ("reply in thread"). Button presses carry no thread, so the
|
|
44
|
+
// adapter remembers which thread each message it posted went into (and that
|
|
45
|
+
// it posted it at all: a thread hanging off one of the bot's own messages
|
|
46
|
+
// engages it in a group). How many to remember: a busy week of replies.
|
|
47
|
+
const SENT_MESSAGE_MEMORY = 5000;
|
|
48
|
+
// Thread history fetches (the group gate's root lookup and the thread context
|
|
49
|
+
// block) must never hold a turn up: past this the turn goes ahead without them.
|
|
50
|
+
const THREAD_FETCH_TIMEOUT_MS = 4000;
|
|
51
|
+
// A thread whose root someone else posted is re-checked for the bot among its
|
|
52
|
+
// participants at most this often (the answer is "no" until the bot replies).
|
|
53
|
+
const THREAD_NOT_IN_TTL_MS = 60 * 1000;
|
|
54
|
+
// How many replies a thread's first turn carries as context.
|
|
55
|
+
const THREAD_CONTEXT_LIMIT = 30;
|
|
56
|
+
const THREAD_ROOT_BODY_LIMIT = 1000;
|
|
57
|
+
// A new thread starts "hybrid" (Sumeet, 29 Sep 2026): besides its root and
|
|
58
|
+
// replies it sees the last few main-timeline messages before the root -- what
|
|
59
|
+
// the chat was talking about when the thread began -- not the whole history.
|
|
60
|
+
const THREAD_SEED_MAIN_MESSAGES = 10;
|
|
61
|
+
// chat-central's refusals of a threadRootId (400 / socket code INVALID_THREAD).
|
|
62
|
+
// On any of them the reply goes to the main timeline instead of being lost.
|
|
63
|
+
const INVALID_THREAD_RE = /INVALID_THREAD|Invalid thread|Thread not found in this chat|This message can['’]t have a thread|That message was deleted|This chat doesn['’]t have threads/i;
|
|
64
|
+
// chat-central drops a typing threadRootId that is not a Mongo ObjectId.
|
|
65
|
+
const OBJECT_ID_RE = /^[0-9a-f]{24}$/i;
|
|
66
|
+
|
|
67
|
+
// The thread a message belongs to: its root's id, or "" for the main timeline.
|
|
68
|
+
// The field is an id today; accept a populated root doc too.
|
|
69
|
+
function threadRootOf(msg) {
|
|
70
|
+
const t = msg && msg.threadRoot;
|
|
71
|
+
if (!t) return "";
|
|
72
|
+
if (typeof t === "object") return String(t._id || t.id || "");
|
|
73
|
+
return String(t);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function senderIdOf(m) {
|
|
77
|
+
return String(m?.sender?._id || m?.sender?.id || m?.senderId || (typeof m?.sender === "string" ? m.sender : "") || "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// How a new thread starts. "hybrid" (the default): the root, the replies so
|
|
81
|
+
// far and the main-timeline messages just before the root. "fresh": the root
|
|
82
|
+
// and the replies only. "fork" (Claude): the runner resumes the chat's main
|
|
83
|
+
// session into the thread, and the short main slice stays as well -- cheap,
|
|
84
|
+
// and it is what a Codex thread falls back to.
|
|
85
|
+
function threadSeedMode() {
|
|
86
|
+
let value = process.env.OC_THREAD_SEED;
|
|
87
|
+
try { value = require("../../core/config").config?.OC_THREAD_SEED ?? value; } catch (_) {}
|
|
88
|
+
const mode = String(value || "").trim().toLowerCase();
|
|
89
|
+
return mode === "fresh" || mode === "fork" ? mode : "hybrid";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function withTimeout(promise, ms, label) {
|
|
93
|
+
let timer;
|
|
94
|
+
return Promise.race([
|
|
95
|
+
promise,
|
|
96
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); }),
|
|
97
|
+
]).finally(() => clearTimeout(timer));
|
|
98
|
+
}
|
|
99
|
+
|
|
43
100
|
// Kazee Media docs from mobile uploads often carry no fileName, so the saved
|
|
44
101
|
// extension must come from the mimeType (or the blob URL) — otherwise every
|
|
45
102
|
// video/document lands on disk as ".bin".
|
|
@@ -85,6 +142,12 @@ class KazeeAdapter {
|
|
|
85
142
|
this._joinPending = new Map();
|
|
86
143
|
// Insertion-ordered, so the oldest id is always the first key.
|
|
87
144
|
this._seenMessageIds = new Set();
|
|
145
|
+
// messageId -> threadId (null = main timeline) for messages the bot posted.
|
|
146
|
+
// Insertion-ordered LRU, like _seenMessageIds.
|
|
147
|
+
this._sentMessages = new Map();
|
|
148
|
+
// rootId -> time the bot was last found NOT in that thread (short cache).
|
|
149
|
+
this._threadNotIn = new Map();
|
|
150
|
+
this._typingThreadWarned = new Set();
|
|
88
151
|
this._callManager = new KazeeCallManager({ adapter: this });
|
|
89
152
|
}
|
|
90
153
|
|
|
@@ -182,21 +245,47 @@ class KazeeAdapter {
|
|
|
182
245
|
socket.on("message:new", (msg) => this._handleInboundMessage(msg));
|
|
183
246
|
socket.on("message:action", (payload) => {
|
|
184
247
|
const channelId = String(payload.chatId);
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
248
|
+
const known = this._sentMessages.has(String(payload.messageId || ""));
|
|
249
|
+
// The press payload names no thread: the button's message does, if the
|
|
250
|
+
// bot posted it inside one. Remembered sends answer at once; a message
|
|
251
|
+
// the memory no longer has (a restart, or 5000 sends ago) is looked up
|
|
252
|
+
// on the server. A failed lookup is the main timeline.
|
|
253
|
+
if (known) return this._emitAction(payload, this._threadOfSent(payload.messageId));
|
|
254
|
+
return this._threadOfMessage(channelId, String(payload.messageId || ""))
|
|
255
|
+
.then((threadId) => this._emitAction(payload, threadId));
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// The thread a message sits in, read from the server; null when it is on
|
|
260
|
+
// the main timeline or the lookup fails (never throws, never hangs).
|
|
261
|
+
async _threadOfMessage(chatId, messageId) {
|
|
262
|
+
if (!messageId) return null;
|
|
263
|
+
try {
|
|
264
|
+
const m = await withTimeout(this._messageInChat(chatId, messageId), THREAD_FETCH_TIMEOUT_MS, "Kazee message lookup");
|
|
265
|
+
return (m && threadRootOf(m)) || null;
|
|
266
|
+
} catch (e) {
|
|
267
|
+
console.error(`Kazee button message lookup failed (chat=${chatId} message=${messageId}):`, e.message);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_emitAction(payload, threadId) {
|
|
273
|
+
const channelId = String(payload.chatId);
|
|
274
|
+
const userId = String(payload.userId);
|
|
275
|
+
this._emit("action", {
|
|
276
|
+
adapter: this,
|
|
277
|
+
channelId,
|
|
278
|
+
canonicalUserId: canonicalForChannel("kazee", userId),
|
|
279
|
+
userId,
|
|
280
|
+
type: "action",
|
|
281
|
+
messageId: payload.messageId,
|
|
282
|
+
threadId: threadId || null,
|
|
283
|
+
action: {
|
|
284
|
+
buttonId: payload.buttonId,
|
|
285
|
+
payload: payload.buttonPayload ?? payload.clientPayload ?? null,
|
|
286
|
+
sourceMessageId: payload.messageId,
|
|
287
|
+
},
|
|
288
|
+
raw: payload,
|
|
200
289
|
});
|
|
201
290
|
}
|
|
202
291
|
|
|
@@ -215,6 +304,103 @@ class KazeeAdapter {
|
|
|
215
304
|
return false;
|
|
216
305
|
}
|
|
217
306
|
|
|
307
|
+
// Remember a message the bot just posted (and the thread it went into). A
|
|
308
|
+
// post into a thread also marks the thread as one the bot takes part in, so
|
|
309
|
+
// later replies there engage it in a group without an @-mention.
|
|
310
|
+
_rememberSent(messageId, threadId, chatId) {
|
|
311
|
+
const id = String(messageId || "");
|
|
312
|
+
const thread = threadId ? String(threadId) : null;
|
|
313
|
+
if (id) {
|
|
314
|
+
this._sentMessages.delete(id);
|
|
315
|
+
this._sentMessages.set(id, thread);
|
|
316
|
+
if (this._sentMessages.size > SENT_MESSAGE_MEMORY) {
|
|
317
|
+
this._sentMessages.delete(this._sentMessages.keys().next().value);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (thread) kazeeState.markBotRepliedInThread(chatId, thread);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// _rememberSent after a successful post: bookkeeping must never fail the
|
|
324
|
+
// send (a caller or retry loop would post it again).
|
|
325
|
+
_rememberSentSafely(messageId, threadId, chatId) {
|
|
326
|
+
try { this._rememberSent(messageId, threadId, chatId); }
|
|
327
|
+
catch (e) { console.error("Kazee: could not record a sent message:", e.message); }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
_threadOfSent(messageId) {
|
|
331
|
+
const id = String(messageId || "");
|
|
332
|
+
return (id && this._sentMessages.get(id)) || null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// GET a thread's root and replies (oldest first). Throws on failure or
|
|
336
|
+
// after THREAD_FETCH_TIMEOUT_MS.
|
|
337
|
+
async _fetchThread(chatId, rootId, limit) {
|
|
338
|
+
const route = `/thread/${encodeURIComponent(chatId)}/${encodeURIComponent(rootId)}/${encodeURIComponent(String(this.botUserId))}?limit=${limit}`;
|
|
339
|
+
const res = await withTimeout(this._request("GET", route), THREAD_FETCH_TIMEOUT_MS, "Kazee thread fetch");
|
|
340
|
+
const data = res?.data || res || {};
|
|
341
|
+
if (res?.error || data.error || res?.success === false) throw new Error(String(res?.error || data.error || "thread fetch refused"));
|
|
342
|
+
return data;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// The main-timeline messages just before a thread's root, oldest first: the
|
|
346
|
+
// hybrid start of a new thread. chat-central's "around" read anchors on the
|
|
347
|
+
// root; threads=true keeps thread-only replies out (and they are dropped
|
|
348
|
+
// here too, in case a server ignores it). Its `after` reads 0 as "default",
|
|
349
|
+
// so 1 is asked for and anything at or after the root is dropped here.
|
|
350
|
+
async _mainBeforeRoot(chatId, rootId) {
|
|
351
|
+
const route = `/messages/${encodeURIComponent(chatId)}/${encodeURIComponent(String(this.botUserId))}/around/${encodeURIComponent(rootId)}?before=${THREAD_SEED_MAIN_MESSAGES}&after=1&threads=true`;
|
|
352
|
+
const res = await withTimeout(this._request("GET", route), THREAD_FETCH_TIMEOUT_MS, "Kazee main-timeline fetch");
|
|
353
|
+
const data = res?.data || res || {};
|
|
354
|
+
if (res?.error || data.error || res?.success === false) throw new Error(String(res?.error || data.error || "main-timeline fetch refused"));
|
|
355
|
+
const messages = Array.isArray(data.messages) ? data.messages : [];
|
|
356
|
+
const anchor = messages.find((m) => String(m._id || m.id || "") === rootId);
|
|
357
|
+
const rootMs = Date.parse(anchor?.createdAt || "") || Infinity;
|
|
358
|
+
return messages
|
|
359
|
+
.filter((m) => String(m._id || m.id || "") !== rootId && !m.isDeleted
|
|
360
|
+
&& (Date.parse(m.createdAt || "") || 0) < rootMs
|
|
361
|
+
&& (!threadRootOf(m) || m.alsoInChannel === true))
|
|
362
|
+
.sort((a, b) => (Date.parse(a.createdAt || "") || 0) - (Date.parse(b.createdAt || "") || 0))
|
|
363
|
+
.slice(-THREAD_SEED_MAIN_MESSAGES);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Group gate for a thread reply with no @-mention: the bot is part of the
|
|
367
|
+
// thread when it posted the root or has already replied in it. Answered
|
|
368
|
+
// from memory when possible; otherwise the root is fetched ONCE and its
|
|
369
|
+
// author kept (persisted), so the next reply in the same thread costs
|
|
370
|
+
// nothing. A failed lookup stays silent (the gate fails closed).
|
|
371
|
+
async _botInThread(chatId, rootId) {
|
|
372
|
+
const botId = String(this.botUserId);
|
|
373
|
+
if (this._sentMessages.has(rootId)) return true;
|
|
374
|
+
if (kazeeState.botRepliedInThread(rootId)) return true;
|
|
375
|
+
const known = kazeeState.getThreadRootAuthor(rootId);
|
|
376
|
+
if (known === botId) return true;
|
|
377
|
+
// Someone else posted the root: the bot may still have replied there (a
|
|
378
|
+
// reply whose POST came back without an id was never recorded). Re-check
|
|
379
|
+
// the participants, at most once a minute per thread.
|
|
380
|
+
if (known && Date.now() - (this._threadNotIn.get(rootId) || 0) < THREAD_NOT_IN_TTL_MS) return false;
|
|
381
|
+
try {
|
|
382
|
+
const data = await this._fetchThread(chatId, rootId, 1);
|
|
383
|
+
const root = data.root || null;
|
|
384
|
+
const author = senderIdOf(root);
|
|
385
|
+
if (author) kazeeState.setThreadRootAuthor(chatId, rootId, author);
|
|
386
|
+
const participants = (Array.isArray(root?.threadParticipants) ? root.threadParticipants : [])
|
|
387
|
+
.map((p) => String(p?._id || p?.id || p));
|
|
388
|
+
const replied = participants.includes(botId)
|
|
389
|
+
|| (Array.isArray(data.replies) ? data.replies : []).some((m) => senderIdOf(m) === botId);
|
|
390
|
+
if (replied) kazeeState.markBotRepliedInThread(chatId, rootId);
|
|
391
|
+
const inThread = author === botId || replied;
|
|
392
|
+
if (inThread) this._threadNotIn.delete(rootId);
|
|
393
|
+
else {
|
|
394
|
+
this._threadNotIn.set(rootId, Date.now());
|
|
395
|
+
if (this._threadNotIn.size > SENT_MESSAGE_MEMORY) this._threadNotIn.delete(this._threadNotIn.keys().next().value);
|
|
396
|
+
}
|
|
397
|
+
return inThread;
|
|
398
|
+
} catch (e) {
|
|
399
|
+
console.error(`Kazee thread root lookup failed (chat=${chatId} root=${rootId}):`, e.message);
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
218
404
|
async _handleInboundMessage(msg) {
|
|
219
405
|
if (!msg) return;
|
|
220
406
|
if (this._isDuplicateInbound(msg)) return;
|
|
@@ -252,6 +438,10 @@ class KazeeAdapter {
|
|
|
252
438
|
text,
|
|
253
439
|
messageId: msg._id || msg.id,
|
|
254
440
|
replyToId: msg.replyTo,
|
|
441
|
+
// A reply posted in a thread is a turn of that thread's own conversation.
|
|
442
|
+
// One with alsoInChannel=true shows in the timeline too, but it is the
|
|
443
|
+
// same message (deduped by id above): handled once, as a thread turn.
|
|
444
|
+
threadId: threadRootOf(msg) || null,
|
|
255
445
|
from: baseFrom,
|
|
256
446
|
raw: msg,
|
|
257
447
|
};
|
|
@@ -312,6 +502,8 @@ class KazeeAdapter {
|
|
|
312
502
|
// Group participation gate: in a group chat the bot engages only when
|
|
313
503
|
// addressed — an @-mention of the bot user or a reply to one of its own
|
|
314
504
|
// messages — unless the chat's assistantAction opts into free replies.
|
|
505
|
+
// A thread reply also engages it when the bot posted the thread's root or
|
|
506
|
+
// has already replied in that thread (it is part of that conversation).
|
|
315
507
|
// Everyone is gated, owner included: a bot answering every group message
|
|
316
508
|
// drowns the room. 1:1 chats are never gated. Engaged messages are marked
|
|
317
509
|
// so the router can authorize the turn for non-owner speakers, who then
|
|
@@ -325,7 +517,10 @@ class KazeeAdapter {
|
|
|
325
517
|
const mentioned = mentions.includes(botId);
|
|
326
518
|
const replyToBot = String(envelope.reply?.from?.id || "") === botId;
|
|
327
519
|
const freeReply = meta.assistantAction === "assistant_free_reply";
|
|
328
|
-
|
|
520
|
+
const inThread = !mentioned && !replyToBot && !freeReply && envelope.threadId
|
|
521
|
+
? await this._botInThread(channelId, envelope.threadId)
|
|
522
|
+
: false;
|
|
523
|
+
if (!mentioned && !replyToBot && !freeReply && !inThread) {
|
|
329
524
|
console.log(`Kazee group gate: silent (chat=${channelId} from=${userId})`);
|
|
330
525
|
return;
|
|
331
526
|
}
|
|
@@ -383,8 +578,13 @@ class KazeeAdapter {
|
|
|
383
578
|
// restart self-heals: the next engage still pulls the true set. Best-effort:
|
|
384
579
|
// any failure returns null, leaves the watermark alone, and never blocks the
|
|
385
580
|
// reply.
|
|
581
|
+
//
|
|
582
|
+
// A thread turn (DM or group) gets the THREAD's context instead: see
|
|
583
|
+
// _threadContext.
|
|
386
584
|
async buildThreadContext(envelope) {
|
|
387
|
-
if (!envelope ||
|
|
585
|
+
if (!envelope || !this.botUserId) return null;
|
|
586
|
+
if (envelope.threadId) return this._threadContext(envelope);
|
|
587
|
+
if (envelope.surface !== "group") return null;
|
|
388
588
|
const chatId = String(envelope.channelId || "");
|
|
389
589
|
if (!chatId) return null;
|
|
390
590
|
try {
|
|
@@ -439,19 +639,122 @@ class KazeeAdapter {
|
|
|
439
639
|
}
|
|
440
640
|
}
|
|
441
641
|
|
|
642
|
+
// The context a thread turn starts from: the thread's root (who posted it,
|
|
643
|
+
// when, what, any media) and the replies so far, oldest first, the bot's own
|
|
644
|
+
// marked as "You". A per-thread watermark means later turns in the same
|
|
645
|
+
// thread only add the replies posted since the last one (the thread's
|
|
646
|
+
// session already holds the rest). Replies newer than the triggering
|
|
647
|
+
// message are left for their own turns. Never blocks the turn: the fetch
|
|
648
|
+
// times out after THREAD_FETCH_TIMEOUT_MS and any failure returns null with
|
|
649
|
+
// the watermark untouched.
|
|
650
|
+
async _threadContext(envelope) {
|
|
651
|
+
const chatId = String(envelope.channelId || "");
|
|
652
|
+
const rootId = String(envelope.threadId || "");
|
|
653
|
+
if (!chatId || !rootId) return null;
|
|
654
|
+
try {
|
|
655
|
+
const since = kazeeState.getThreadWatermark(rootId);
|
|
656
|
+
// The hybrid start's main slice is read alongside the thread, on the
|
|
657
|
+
// thread's first turn only; if it fails the turn just goes without it.
|
|
658
|
+
const mainSlice = !since && threadSeedMode() !== "fresh"
|
|
659
|
+
? this._mainBeforeRoot(chatId, rootId).catch((e) => { console.error("kazee thread seed:", e.message); return []; })
|
|
660
|
+
: Promise.resolve([]);
|
|
661
|
+
const [data, mainBefore] = await Promise.all([this._fetchThread(chatId, rootId, THREAD_CONTEXT_LIMIT), mainSlice]);
|
|
662
|
+
const root = data.root && typeof data.root === "object" ? data.root : null;
|
|
663
|
+
const replies = Array.isArray(data.replies) ? data.replies : [];
|
|
664
|
+
const sinceMs = since ? Date.parse(since) || 0 : 0;
|
|
665
|
+
const triggerId = String(envelope.messageId || "");
|
|
666
|
+
const triggerMs = Date.parse(envelope.raw?.createdAt || "") || Infinity;
|
|
667
|
+
const botId = String(this.botUserId);
|
|
668
|
+
const inWindow = replies.filter((m) => {
|
|
669
|
+
const t = Date.parse(m.createdAt || "") || 0;
|
|
670
|
+
return t <= triggerMs && (!sinceMs || t > sinceMs);
|
|
671
|
+
});
|
|
672
|
+
const fresh = inWindow.filter((m) => !m.isDeleted && String(m._id || m.id || "") !== triggerId);
|
|
673
|
+
const stamps = [...inWindow.map((m) => m.createdAt), envelope.raw?.createdAt]
|
|
674
|
+
.filter((iso) => Date.parse(iso || ""));
|
|
675
|
+
const newest = stamps.sort((a, b) => Date.parse(a) - Date.parse(b)).pop();
|
|
676
|
+
if (root) {
|
|
677
|
+
const author = senderIdOf(root);
|
|
678
|
+
if (author) kazeeState.setThreadRootAuthor(chatId, rootId, author);
|
|
679
|
+
}
|
|
680
|
+
if (fresh.some((m) => senderIdOf(m) === botId)) kazeeState.markBotRepliedInThread(chatId, rootId);
|
|
681
|
+
|
|
682
|
+
const first = !since;
|
|
683
|
+
const rendered = fresh
|
|
684
|
+
.map((m) => ({ who: senderIdOf(m) === botId ? "You" : (m.sender?.name || "Someone"), body: this._contextBody(m, CONTEXT_BODY_LIMIT), ago: this._ago(m.createdAt) }))
|
|
685
|
+
.filter((m) => m.body);
|
|
686
|
+
const media = [
|
|
687
|
+
...(first && root && !root.isDeleted ? this._mediaRefs(root, chatId, "Thread root") : []),
|
|
688
|
+
...fresh.filter((m) => senderIdOf(m) !== botId)
|
|
689
|
+
.flatMap((m) => this._mediaRefs(m, chatId, `${m.sender?.name || "Someone"} in this thread (${m.createdAt || "unknown time"})`)),
|
|
690
|
+
];
|
|
691
|
+
if (newest) kazeeState.setThreadWatermark(chatId, rootId, newest);
|
|
692
|
+
if (!first && !rendered.length && !media.length) return null;
|
|
693
|
+
|
|
694
|
+
const lines = ["[Kazee thread context]"];
|
|
695
|
+
if (first) {
|
|
696
|
+
lines.push("This conversation is a thread in this chat (its own conversation: your replies go into the thread).");
|
|
697
|
+
const mainRendered = mainBefore
|
|
698
|
+
.map((m) => ({ who: senderIdOf(m) === botId ? "You" : (m.sender?.name || "Someone"), body: this._contextBody(m, CONTEXT_BODY_LIMIT), ago: this._ago(m.createdAt) }))
|
|
699
|
+
.filter((m) => m.body);
|
|
700
|
+
if (mainRendered.length) {
|
|
701
|
+
lines.push("The main chat just before this thread started (oldest→newest):");
|
|
702
|
+
for (const m of mainRendered) lines.push(` [${m.ago}] ${m.who}: ${m.body}`);
|
|
703
|
+
}
|
|
704
|
+
if (!root || root.isDeleted) {
|
|
705
|
+
lines.push("Thread root: (deleted or unavailable)");
|
|
706
|
+
} else {
|
|
707
|
+
const who = senderIdOf(root) === botId ? "You" : (root.sender?.name || "Someone");
|
|
708
|
+
const when = root.createdAt ? `${root.createdAt}, ${this._ago(root.createdAt)}` : "unknown time";
|
|
709
|
+
lines.push(`Thread root, posted by ${who} (${when}):`);
|
|
710
|
+
lines.push(` ${this._contextBody(root, THREAD_ROOT_BODY_LIMIT) || "(no text)"}`);
|
|
711
|
+
}
|
|
712
|
+
if (rendered.length) {
|
|
713
|
+
lines.push(data.hasMoreBefore
|
|
714
|
+
? "Replies so far (oldest→newest; earlier replies not shown):"
|
|
715
|
+
: "Replies so far (oldest→newest):");
|
|
716
|
+
}
|
|
717
|
+
} else {
|
|
718
|
+
lines.push("New replies in this thread since your last turn (oldest→newest):");
|
|
719
|
+
}
|
|
720
|
+
for (const m of rendered) lines.push(` [${m.ago}] ${m.who}: ${m.body}`);
|
|
721
|
+
return { contextBlock: lines.join("\n"), media };
|
|
722
|
+
} catch (e) {
|
|
723
|
+
console.error("kazee thread context:", e.message);
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// One message rendered for a context block: its text, a [type] note for
|
|
729
|
+
// media, clipped to `limit`.
|
|
730
|
+
_contextBody(m, limit) {
|
|
731
|
+
let body = (typeof m.content === "string" ? m.content : "").trim();
|
|
732
|
+
if (m.type && m.type !== "text" && m.type !== "interactive") {
|
|
733
|
+
body = `[${m.type}]${body ? " " + body : ""}`;
|
|
734
|
+
} else if (this._mediaRefs(m).length) {
|
|
735
|
+
body = `[attachment]${body ? " " + body : ""}`;
|
|
736
|
+
}
|
|
737
|
+
if (body.length > limit) body = body.slice(0, limit).trimEnd() + " …";
|
|
738
|
+
return body;
|
|
739
|
+
}
|
|
740
|
+
|
|
442
741
|
// Message delta for a chat. With a watermark: chat-central's since-sync
|
|
443
742
|
// (oldest-first, server-capped at 500). Without one — the chat's first-ever
|
|
444
743
|
// engage — one recent page, which the server already returns in
|
|
445
|
-
// chronological order.
|
|
744
|
+
// chronological order. threads=true keeps thread-only replies out: they
|
|
745
|
+
// belong to their thread's conversation, not the main timeline's.
|
|
446
746
|
async _messagesSince(chatId, sinceIso, count = COLD_START_WINDOW) {
|
|
447
747
|
const base = `/messages/${encodeURIComponent(chatId)}/${encodeURIComponent(String(this.botUserId))}`;
|
|
448
748
|
const query = sinceIso
|
|
449
|
-
? `?since=${encodeURIComponent(sinceIso)}`
|
|
450
|
-
: `?page=1&messageCount=${count}`;
|
|
749
|
+
? `?since=${encodeURIComponent(sinceIso)}&threads=true`
|
|
750
|
+
: `?page=1&messageCount=${count}&threads=true`;
|
|
451
751
|
const res = await this._request("GET", base + query);
|
|
452
752
|
const data = res?.data || res || {};
|
|
453
753
|
if (data.error) throw new Error(String(data.error));
|
|
454
|
-
|
|
754
|
+
// A server that ignores threads=true still must not leak thread-only
|
|
755
|
+
// replies into the main timeline's context.
|
|
756
|
+
return (Array.isArray(data.messages) ? data.messages : [])
|
|
757
|
+
.filter((m) => !threadRootOf(m) || m.alsoInChannel === true);
|
|
455
758
|
}
|
|
456
759
|
|
|
457
760
|
_ago(iso) {
|
|
@@ -484,32 +787,27 @@ class KazeeAdapter {
|
|
|
484
787
|
};
|
|
485
788
|
if (opts.replyTo) body.replyTo = opts.replyTo;
|
|
486
789
|
if (buttons) body.interactive = { buttons };
|
|
790
|
+
const threadId = opts.threadId ? String(opts.threadId) : null;
|
|
791
|
+
if (threadId) body.threadRootId = threadId;
|
|
792
|
+
// Set once the thread refused the reply and it went to the main timeline
|
|
793
|
+
// (quoting the root) instead: "quote" while that quote is still on.
|
|
794
|
+
let fallback = null;
|
|
487
795
|
|
|
488
796
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
797
|
+
let res;
|
|
489
798
|
try {
|
|
490
|
-
|
|
491
|
-
// Return the created chat message's id so callers can edit it in place.
|
|
492
|
-
// The id can arrive as `_id` (raw Mongo doc) or `id` (serialized) and
|
|
493
|
-
// may sit on `res.message` or at the top level — mirror the reply-to
|
|
494
|
-
// fetch which already handles `m._id || m.id`. Never fall back to the
|
|
495
|
-
// envelope's `messageId`: that's ActionHero's numeric request counter,
|
|
496
|
-
// not a chat message id, and using it makes later edits 500 (and, when
|
|
497
|
-
// the real id is missing, a null return double-sends the reply).
|
|
498
|
-
const m = res?.message || res || {};
|
|
499
|
-
const id = m._id || m.id;
|
|
500
|
-
if (id) return String(id);
|
|
501
|
-
// POST returned 2xx but carried no message id (server fast-ack / payload
|
|
502
|
-
// fallback path). Returning null makes the caller treat a delivered
|
|
503
|
-
// reply as a failed send and resend it — the Kazee double-send. Signal
|
|
504
|
-
// success with a truthy, non-editable sentinel so nothing sends twice.
|
|
505
|
-
console.error("Kazee send: POST ok but no message id in response keys:", Object.keys(res || {}).join(","));
|
|
506
|
-
return true;
|
|
799
|
+
res = await this._request("POST", `/chat/${encodeURIComponent(channelId)}/message`, body);
|
|
507
800
|
} catch (e) {
|
|
508
801
|
const errMsg = e.message || "";
|
|
509
802
|
// Don't retry on client errors (4xx) — they won't self-heal.
|
|
510
803
|
const statusMatch = errMsg.match(/→ (\d{3})/);
|
|
511
804
|
const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
|
|
512
805
|
if (status >= 400 && status < 500) {
|
|
806
|
+
// The thread refused the reply (deleted root, not a thread-able
|
|
807
|
+
// message, ...): never lose it, post it in the main timeline once,
|
|
808
|
+
// quoting the root when that can still work.
|
|
809
|
+
const next = this._threadFallback(body, threadId, fallback, errMsg, "send");
|
|
810
|
+
if (next) { fallback = next; attempt--; continue; }
|
|
513
811
|
console.error("Kazee send error (not retrying):", errMsg);
|
|
514
812
|
return null;
|
|
515
813
|
}
|
|
@@ -522,6 +820,50 @@ class KazeeAdapter {
|
|
|
522
820
|
console.error("Kazee send error (exhausted retries):", errMsg);
|
|
523
821
|
return null;
|
|
524
822
|
}
|
|
823
|
+
// Posted. Nothing below may throw back into the retry loop: that would
|
|
824
|
+
// post the reply a second time.
|
|
825
|
+
// Return the created chat message's id so callers can edit it in place.
|
|
826
|
+
// The id can arrive as `_id` (raw Mongo doc) or `id` (serialized) and
|
|
827
|
+
// may sit on `res.message` or at the top level — mirror the reply-to
|
|
828
|
+
// fetch which already handles `m._id || m.id`. Never fall back to the
|
|
829
|
+
// envelope's `messageId`: that's ActionHero's numeric request counter,
|
|
830
|
+
// not a chat message id, and using it makes later edits 500 (and, when
|
|
831
|
+
// the real id is missing, a null return double-sends the reply).
|
|
832
|
+
const m = res?.message || res || {};
|
|
833
|
+
const id = m._id || m.id;
|
|
834
|
+
// body.threadRootId is gone when the reply fell back to the main
|
|
835
|
+
// timeline, so only a post that went INTO the thread marks it.
|
|
836
|
+
this._rememberSentSafely(id, body.threadRootId || null, channelId);
|
|
837
|
+
if (id) return String(id);
|
|
838
|
+
// POST returned 2xx but carried no message id (server fast-ack / payload
|
|
839
|
+
// fallback path). Returning null makes the caller treat a delivered
|
|
840
|
+
// reply as a failed send and resend it — the Kazee double-send. Signal
|
|
841
|
+
// success with a truthy, non-editable sentinel so nothing sends twice.
|
|
842
|
+
console.error("Kazee send: POST ok but no message id in response keys:", Object.keys(res || {}).join(","));
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// One step of the INVALID_THREAD fallback for a REST post body (mutated in
|
|
849
|
+
// place). First refusal of the thread: drop threadRootId and quote the root
|
|
850
|
+
// instead (unless the refusal says the root is deleted). If the quote is
|
|
851
|
+
// refused as well: drop it too. Returns the new fallback stage, or null
|
|
852
|
+
// when there is nothing left to fall back from.
|
|
853
|
+
_threadFallback(body, threadId, stage, errMsg, what) {
|
|
854
|
+
if (!threadId) return null;
|
|
855
|
+
if (!stage) {
|
|
856
|
+
if (!body.threadRootId || !INVALID_THREAD_RE.test(errMsg)) return null;
|
|
857
|
+
delete body.threadRootId;
|
|
858
|
+
const quote = !body.replyTo && !/deleted/i.test(errMsg);
|
|
859
|
+
if (quote) body.replyTo = threadId;
|
|
860
|
+
console.error(`Kazee ${what}: thread ${threadId} refused (${errMsg.slice(0, 200)}); posting in the main timeline${quote ? ", quoting the thread root" : ""}`);
|
|
861
|
+
return quote ? "quote" : "plain";
|
|
862
|
+
}
|
|
863
|
+
if (stage === "quote" && body.replyTo === threadId) {
|
|
864
|
+
delete body.replyTo;
|
|
865
|
+
console.error(`Kazee ${what}: quoting thread root ${threadId} refused too; posting in the main timeline without the quote`);
|
|
866
|
+
return "plain";
|
|
525
867
|
}
|
|
526
868
|
return null;
|
|
527
869
|
}
|
|
@@ -574,18 +916,19 @@ class KazeeAdapter {
|
|
|
574
916
|
catch (e) { /* ignore */ }
|
|
575
917
|
}
|
|
576
918
|
|
|
577
|
-
async sendVoice(channelId, oggPath) {
|
|
578
|
-
const ok = await this.sendFile(channelId, oggPath);
|
|
919
|
+
async sendVoice(channelId, oggPath, opts = {}) {
|
|
920
|
+
const ok = await this.sendFile(channelId, oggPath, undefined, opts);
|
|
579
921
|
try { fs.unlinkSync(oggPath); } catch (e) {}
|
|
580
922
|
return ok;
|
|
581
923
|
}
|
|
582
924
|
|
|
583
|
-
async sendPhoto(channelId, filePath, caption) {
|
|
925
|
+
async sendPhoto(channelId, filePath, caption, opts = {}) {
|
|
584
926
|
// Kazee's sendFile picks the right message type by extension already.
|
|
585
|
-
return this.sendFile(channelId, filePath, caption);
|
|
927
|
+
return this.sendFile(channelId, filePath, caption, opts);
|
|
586
928
|
}
|
|
587
929
|
|
|
588
|
-
async sendFile(channelId, filePath, caption) {
|
|
930
|
+
async sendFile(channelId, filePath, caption, opts = {}) {
|
|
931
|
+
const threadId = opts && opts.threadId ? String(opts.threadId) : null;
|
|
589
932
|
try {
|
|
590
933
|
const buffer = fs.readFileSync(filePath);
|
|
591
934
|
const fileName = path.basename(filePath);
|
|
@@ -616,16 +959,29 @@ class KazeeAdapter {
|
|
|
616
959
|
findMeCode: `oc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
617
960
|
mediaIds: [mediaId],
|
|
618
961
|
};
|
|
962
|
+
if (threadId) payload.threadRootId = threadId;
|
|
619
963
|
try {
|
|
620
964
|
const res = await this._socketEmit("message:send", payload, 30000);
|
|
621
965
|
if (res && res.code) {
|
|
966
|
+
if (threadId && INVALID_THREAD_RE.test(`${res.code} ${res.message || ""}`)) {
|
|
967
|
+
// The thread refused it: post in the main timeline (REST, which
|
|
968
|
+
// can quote the root) rather than lose the file.
|
|
969
|
+
const refusal = `${res.code}: ${res.message || ""}`;
|
|
970
|
+
const quote = !/deleted/i.test(refusal);
|
|
971
|
+
console.error(`Kazee sendFile: thread ${threadId} refused (${refusal}); posting in the main timeline${quote ? ", quoting the thread root" : ""}`);
|
|
972
|
+
return await this._sendMediaViaRest(channelId, mediaId, type, caption, { replyTo: quote ? threadId : null, fallbackOf: threadId });
|
|
973
|
+
}
|
|
622
974
|
console.error(`Kazee message:send error (${res.code}), falling back to REST:`, res.message);
|
|
623
|
-
return await this._sendMediaViaRest(channelId, mediaId, type, caption);
|
|
975
|
+
return await this._sendMediaViaRest(channelId, mediaId, type, caption, { threadId });
|
|
624
976
|
}
|
|
977
|
+
// chat-central acks callback(null, {message}): the id is on .message.
|
|
978
|
+
const sent = res?.message || res?.data || res || {};
|
|
979
|
+
const sentId = typeof sent === "object" ? (sent._id || sent.id) : null;
|
|
980
|
+
this._rememberSentSafely(sentId, threadId, channelId);
|
|
625
981
|
return true;
|
|
626
982
|
} catch (sockErr) {
|
|
627
983
|
console.error("Kazee message:send unavailable, falling back to REST:", sockErr.message);
|
|
628
|
-
return await this._sendMediaViaRest(channelId, mediaId, type, caption);
|
|
984
|
+
return await this._sendMediaViaRest(channelId, mediaId, type, caption, { threadId });
|
|
629
985
|
}
|
|
630
986
|
} catch (e) {
|
|
631
987
|
console.error("Kazee sendFile error:", e.message);
|
|
@@ -636,20 +992,40 @@ class KazeeAdapter {
|
|
|
636
992
|
// REST fallback for posting an already-uploaded Media doc when the socket
|
|
637
993
|
// path is unavailable. Mirrors send()'s route (POST /chat/:id/message →
|
|
638
994
|
// sendMessage action) but references the saved media by id.
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
995
|
+
// opts.threadId posts into that thread (with the same once-only
|
|
996
|
+
// INVALID_THREAD fallback as send); opts.replyTo quotes a message;
|
|
997
|
+
// opts.fallbackOf names the thread a main-timeline fallback is standing in
|
|
998
|
+
// for (so a refused quote of its root is dropped too).
|
|
999
|
+
async _sendMediaViaRest(channelId, mediaId, type, caption, opts = {}) {
|
|
1000
|
+
const threadId = opts.threadId ? String(opts.threadId) : null;
|
|
1001
|
+
const body = {
|
|
1002
|
+
chat_id: channelId,
|
|
1003
|
+
sender: this.botUserId,
|
|
1004
|
+
content: caption || "",
|
|
1005
|
+
type,
|
|
1006
|
+
media_ids: [mediaId],
|
|
1007
|
+
findMeCode: `oc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1008
|
+
};
|
|
1009
|
+
if (threadId) body.threadRootId = threadId;
|
|
1010
|
+
if (opts.replyTo) body.replyTo = String(opts.replyTo);
|
|
1011
|
+
const origin = threadId || (opts.fallbackOf ? String(opts.fallbackOf) : null);
|
|
1012
|
+
let stage = !threadId && opts.fallbackOf ? (opts.replyTo ? "quote" : "plain") : null;
|
|
1013
|
+
for (;;) {
|
|
1014
|
+
let res;
|
|
1015
|
+
try {
|
|
1016
|
+
res = await this._request("POST", `/chat/${encodeURIComponent(channelId)}/message`, body);
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
const errMsg = e.message || "";
|
|
1019
|
+
const statusMatch = errMsg.match(/→ (\d{3})/);
|
|
1020
|
+
const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
|
|
1021
|
+
const next = status >= 400 && status < 500 ? this._threadFallback(body, origin, stage, errMsg, "media send") : null;
|
|
1022
|
+
if (next) { stage = next; continue; }
|
|
1023
|
+
console.error("Kazee REST media send failed:", errMsg);
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
const m = res?.message || res || {};
|
|
1027
|
+
this._rememberSentSafely(m._id || m.id, body.threadRootId || null, channelId);
|
|
649
1028
|
return true;
|
|
650
|
-
} catch (e) {
|
|
651
|
-
console.error("Kazee REST media send failed:", e.message);
|
|
652
|
-
return false;
|
|
653
1029
|
}
|
|
654
1030
|
}
|
|
655
1031
|
|
|
@@ -668,11 +1044,14 @@ class KazeeAdapter {
|
|
|
668
1044
|
settled = true;
|
|
669
1045
|
reject(new Error(`Kazee ${event} ack timeout`));
|
|
670
1046
|
}, timeoutMs);
|
|
671
|
-
|
|
1047
|
+
// Errors ack as the first argument ({code, message}); successes ack
|
|
1048
|
+
// Node-style, callback(null, body), so take the second when the first
|
|
1049
|
+
// is empty.
|
|
1050
|
+
socket.emit(event, payload, (ack, data) => {
|
|
672
1051
|
if (settled) return;
|
|
673
1052
|
settled = true;
|
|
674
1053
|
clearTimeout(timer);
|
|
675
|
-
resolve(ack);
|
|
1054
|
+
resolve(ack == null && data !== undefined ? data : ack);
|
|
676
1055
|
});
|
|
677
1056
|
});
|
|
678
1057
|
}
|
|
@@ -734,11 +1113,27 @@ class KazeeAdapter {
|
|
|
734
1113
|
return attempt;
|
|
735
1114
|
}
|
|
736
1115
|
|
|
737
|
-
|
|
1116
|
+
// opts.threadId shows the indicator inside that thread. chat-central drops
|
|
1117
|
+
// a threadRootId that is not an ObjectId (the indicator would then show in
|
|
1118
|
+
// the channel), so a malformed one is left out and logged once.
|
|
1119
|
+
_typingThreadId(opts) {
|
|
1120
|
+
const threadId = opts && opts.threadId ? String(opts.threadId) : "";
|
|
1121
|
+
if (!threadId) return null;
|
|
1122
|
+
if (OBJECT_ID_RE.test(threadId)) return threadId;
|
|
1123
|
+
if (!this._typingThreadWarned.has(threadId)) {
|
|
1124
|
+
this._typingThreadWarned.add(threadId);
|
|
1125
|
+
console.error(`Kazee typing: thread id "${threadId.slice(0, 40)}" is not a 24-hex id; the indicator shows in the channel instead`);
|
|
1126
|
+
}
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async typing(channelId, state, opts = {}) {
|
|
738
1131
|
try {
|
|
739
1132
|
const chatId = String(channelId);
|
|
740
1133
|
if (!(await this._ensureJoinedRoom(chatId))) return;
|
|
741
1134
|
const payload = { chatId };
|
|
1135
|
+
const threadRootId = this._typingThreadId(opts);
|
|
1136
|
+
if (threadRootId) payload.threadRootId = threadRootId;
|
|
742
1137
|
if (state) payload.state = state;
|
|
743
1138
|
this._socket?.emit?.("typing:start", payload);
|
|
744
1139
|
} catch (e) {}
|
|
@@ -747,12 +1142,15 @@ class KazeeAdapter {
|
|
|
747
1142
|
// Socket channels keep rendering "typing…" until an explicit stop. Telegram
|
|
748
1143
|
// has no equivalent (its chat action self-expires), so this is Kazee-only and
|
|
749
1144
|
// called via optional chaining from the core typing heartbeat cleanup.
|
|
750
|
-
async typingStop(channelId) {
|
|
1145
|
+
async typingStop(channelId, opts = {}) {
|
|
751
1146
|
const chatId = String(channelId);
|
|
752
1147
|
// Never join just to stop: a stop from a socket that never started is a
|
|
753
1148
|
// no-op server-side anyway, and joining here would race the teardown.
|
|
754
1149
|
if (!this._joinedRooms.has(chatId)) return;
|
|
755
|
-
|
|
1150
|
+
const payload = { chatId };
|
|
1151
|
+
const threadRootId = this._typingThreadId(opts);
|
|
1152
|
+
if (threadRootId) payload.threadRootId = threadRootId;
|
|
1153
|
+
try { this._socket?.emit?.("typing:stop", payload); } catch (e) {}
|
|
756
1154
|
}
|
|
757
1155
|
|
|
758
1156
|
async downloadMedia(media) {
|
|
@@ -775,7 +1173,7 @@ class KazeeAdapter {
|
|
|
775
1173
|
const nameSuffix = keepName && !path.extname(media.fileName) && ext !== ".bin" ? ext : "";
|
|
776
1174
|
const localPath = keepName
|
|
777
1175
|
? path.join(baseDir, media.fileName + nameSuffix)
|
|
778
|
-
: path.join(baseDir, `kz-${Date.now()}${ext}`);
|
|
1176
|
+
: path.join(baseDir, `kz-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`);
|
|
779
1177
|
await this._downloadUrl(url, localPath);
|
|
780
1178
|
return localPath;
|
|
781
1179
|
}
|