@inetafrica/open-claudia 3.5.1 → 3.5.7

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.
Files changed (44) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bin/cli.js +13 -5
  4. package/bin/loopback-client.js +4 -0
  5. package/bin/schedule.js +7 -2
  6. package/bot.js +6 -2
  7. package/channels/kazee/adapter.js +519 -64
  8. package/channels/kazee/state.js +88 -2
  9. package/channels/types.js +11 -5
  10. package/core/actions.js +6 -0
  11. package/core/approvals.js +9 -0
  12. package/core/context.js +11 -2
  13. package/core/handlers.js +161 -59
  14. package/core/identity.js +33 -3
  15. package/core/io.js +21 -8
  16. package/core/jobs.js +3 -0
  17. package/core/loopback.js +55 -7
  18. package/core/providers/claude.js +6 -2
  19. package/core/providers/utility-policy.js +3 -1
  20. package/core/router.js +25 -13
  21. package/core/run-context.js +49 -8
  22. package/core/runner.js +318 -122
  23. package/core/sandbox-probe.js +78 -0
  24. package/core/scheduler.js +45 -3
  25. package/core/side-chat.js +40 -11
  26. package/core/speaker-attest.js +2 -0
  27. package/core/state.js +317 -50
  28. package/core/system-prompt.js +61 -14
  29. package/core/thread-title.js +237 -0
  30. package/core/transcripts.js +23 -6
  31. package/package.json +3 -3
  32. package/test-approval-cards.js +24 -0
  33. package/test-call-speech.js +5 -3
  34. package/test-fixtures/fake-agent-cli.js +52 -1
  35. package/test-provider-compaction.js +50 -11
  36. package/test-provider-gateway.js +31 -10
  37. package/test-provider-language.js +7 -1
  38. package/test-provider-scheduler.js +77 -20
  39. package/test-provider-session-commands.js +11 -2
  40. package/test-provider-side-chat.js +30 -2
  41. package/test-run-lock.js +122 -39
  42. package/test-thread-lanes.js +530 -0
  43. package/core/queue-drain.js +0 -25
  44. package/test-queue-routing.js +0 -52
@@ -40,6 +40,68 @@ 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
+ // Thread titles: how much of a thread the bot reads to name it, and how long
62
+ // the naming call may take (it runs after the reply, never in front of it).
63
+ const THREAD_TITLE_SOURCE_LIMIT = 10;
64
+ const THREAD_TITLE_BODY_LIMIT = 600;
65
+ const THREAD_TITLE_TIMEOUT_MS = 10000;
66
+ // chat-central's refusals of a threadRootId (400 / socket code INVALID_THREAD).
67
+ // On any of them the reply goes to the main timeline instead of being lost.
68
+ 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;
69
+ // chat-central drops a typing threadRootId that is not a Mongo ObjectId.
70
+ const OBJECT_ID_RE = /^[0-9a-f]{24}$/i;
71
+
72
+ // The thread a message belongs to: its root's id, or "" for the main timeline.
73
+ // The field is an id today; accept a populated root doc too.
74
+ function threadRootOf(msg) {
75
+ const t = msg && msg.threadRoot;
76
+ if (!t) return "";
77
+ if (typeof t === "object") return String(t._id || t.id || "");
78
+ return String(t);
79
+ }
80
+
81
+ function senderIdOf(m) {
82
+ return String(m?.sender?._id || m?.sender?.id || m?.senderId || (typeof m?.sender === "string" ? m.sender : "") || "");
83
+ }
84
+
85
+ // How a new thread starts. "hybrid" (the default): the root, the replies so
86
+ // far and the main-timeline messages just before the root. "fresh": the root
87
+ // and the replies only. "fork" (Claude): the runner resumes the chat's main
88
+ // session into the thread, and the short main slice stays as well -- cheap,
89
+ // and it is what a Codex thread falls back to.
90
+ function threadSeedMode() {
91
+ let value = process.env.OC_THREAD_SEED;
92
+ try { value = require("../../core/config").config?.OC_THREAD_SEED ?? value; } catch (_) {}
93
+ const mode = String(value || "").trim().toLowerCase();
94
+ return mode === "fresh" || mode === "fork" ? mode : "hybrid";
95
+ }
96
+
97
+ function withTimeout(promise, ms, label) {
98
+ let timer;
99
+ return Promise.race([
100
+ promise,
101
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); }),
102
+ ]).finally(() => clearTimeout(timer));
103
+ }
104
+
43
105
  // Kazee Media docs from mobile uploads often carry no fileName, so the saved
44
106
  // extension must come from the mimeType (or the blob URL) — otherwise every
45
107
  // video/document lands on disk as ".bin".
@@ -85,6 +147,12 @@ class KazeeAdapter {
85
147
  this._joinPending = new Map();
86
148
  // Insertion-ordered, so the oldest id is always the first key.
87
149
  this._seenMessageIds = new Set();
150
+ // messageId -> threadId (null = main timeline) for messages the bot posted.
151
+ // Insertion-ordered LRU, like _seenMessageIds.
152
+ this._sentMessages = new Map();
153
+ // rootId -> time the bot was last found NOT in that thread (short cache).
154
+ this._threadNotIn = new Map();
155
+ this._typingThreadWarned = new Set();
88
156
  this._callManager = new KazeeCallManager({ adapter: this });
89
157
  }
90
158
 
@@ -182,21 +250,47 @@ class KazeeAdapter {
182
250
  socket.on("message:new", (msg) => this._handleInboundMessage(msg));
183
251
  socket.on("message:action", (payload) => {
184
252
  const channelId = String(payload.chatId);
185
- const userId = String(payload.userId);
186
- this._emit("action", {
187
- adapter: this,
188
- channelId,
189
- canonicalUserId: canonicalForChannel("kazee", userId),
190
- userId,
191
- type: "action",
192
- messageId: payload.messageId,
193
- action: {
194
- buttonId: payload.buttonId,
195
- payload: payload.buttonPayload ?? payload.clientPayload ?? null,
196
- sourceMessageId: payload.messageId,
197
- },
198
- raw: payload,
199
- });
253
+ const known = this._sentMessages.has(String(payload.messageId || ""));
254
+ // The press payload names no thread: the button's message does, if the
255
+ // bot posted it inside one. Remembered sends answer at once; a message
256
+ // the memory no longer has (a restart, or 5000 sends ago) is looked up
257
+ // on the server. A failed lookup is the main timeline.
258
+ if (known) return this._emitAction(payload, this._threadOfSent(payload.messageId));
259
+ return this._threadOfMessage(channelId, String(payload.messageId || ""))
260
+ .then((threadId) => this._emitAction(payload, threadId));
261
+ });
262
+ }
263
+
264
+ // The thread a message sits in, read from the server; null when it is on
265
+ // the main timeline or the lookup fails (never throws, never hangs).
266
+ async _threadOfMessage(chatId, messageId) {
267
+ if (!messageId) return null;
268
+ try {
269
+ const m = await withTimeout(this._messageInChat(chatId, messageId), THREAD_FETCH_TIMEOUT_MS, "Kazee message lookup");
270
+ return (m && threadRootOf(m)) || null;
271
+ } catch (e) {
272
+ console.error(`Kazee button message lookup failed (chat=${chatId} message=${messageId}):`, e.message);
273
+ return null;
274
+ }
275
+ }
276
+
277
+ _emitAction(payload, threadId) {
278
+ const channelId = String(payload.chatId);
279
+ const userId = String(payload.userId);
280
+ this._emit("action", {
281
+ adapter: this,
282
+ channelId,
283
+ canonicalUserId: canonicalForChannel("kazee", userId),
284
+ userId,
285
+ type: "action",
286
+ messageId: payload.messageId,
287
+ threadId: threadId || null,
288
+ action: {
289
+ buttonId: payload.buttonId,
290
+ payload: payload.buttonPayload ?? payload.clientPayload ?? null,
291
+ sourceMessageId: payload.messageId,
292
+ },
293
+ raw: payload,
200
294
  });
201
295
  }
202
296
 
@@ -215,6 +309,155 @@ class KazeeAdapter {
215
309
  return false;
216
310
  }
217
311
 
312
+ // Remember a message the bot just posted (and the thread it went into). A
313
+ // post into a thread also marks the thread as one the bot takes part in, so
314
+ // later replies there engage it in a group without an @-mention.
315
+ _rememberSent(messageId, threadId, chatId) {
316
+ const id = String(messageId || "");
317
+ const thread = threadId ? String(threadId) : null;
318
+ if (id) {
319
+ this._sentMessages.delete(id);
320
+ this._sentMessages.set(id, thread);
321
+ if (this._sentMessages.size > SENT_MESSAGE_MEMORY) {
322
+ this._sentMessages.delete(this._sentMessages.keys().next().value);
323
+ }
324
+ }
325
+ if (thread) kazeeState.markBotRepliedInThread(chatId, thread);
326
+ }
327
+
328
+ // _rememberSent after a successful post: bookkeeping must never fail the
329
+ // send (a caller or retry loop would post it again).
330
+ _rememberSentSafely(messageId, threadId, chatId) {
331
+ try { this._rememberSent(messageId, threadId, chatId); }
332
+ catch (e) { console.error("Kazee: could not record a sent message:", e.message); }
333
+ }
334
+
335
+ _threadOfSent(messageId) {
336
+ const id = String(messageId || "");
337
+ return (id && this._sentMessages.get(id)) || null;
338
+ }
339
+
340
+ // GET a thread's root and replies (oldest first). Throws on failure or
341
+ // after THREAD_FETCH_TIMEOUT_MS.
342
+ async _fetchThread(chatId, rootId, limit) {
343
+ const route = `/thread/${encodeURIComponent(chatId)}/${encodeURIComponent(rootId)}/${encodeURIComponent(String(this.botUserId))}?limit=${limit}`;
344
+ const res = await withTimeout(this._request("GET", route), THREAD_FETCH_TIMEOUT_MS, "Kazee thread fetch");
345
+ const data = res?.data || res || {};
346
+ if (res?.error || data.error || res?.success === false) throw new Error(String(res?.error || data.error || "thread fetch refused"));
347
+ return data;
348
+ }
349
+
350
+ // The main-timeline messages just before a thread's root, oldest first: the
351
+ // hybrid start of a new thread. chat-central's "around" read anchors on the
352
+ // root; threads=true keeps thread-only replies out (and they are dropped
353
+ // here too, in case a server ignores it). Its `after` reads 0 as "default",
354
+ // so 1 is asked for and anything at or after the root is dropped here.
355
+ async _mainBeforeRoot(chatId, rootId) {
356
+ const route = `/messages/${encodeURIComponent(chatId)}/${encodeURIComponent(String(this.botUserId))}/around/${encodeURIComponent(rootId)}?before=${THREAD_SEED_MAIN_MESSAGES}&after=1&threads=true`;
357
+ const res = await withTimeout(this._request("GET", route), THREAD_FETCH_TIMEOUT_MS, "Kazee main-timeline fetch");
358
+ const data = res?.data || res || {};
359
+ if (res?.error || data.error || res?.success === false) throw new Error(String(res?.error || data.error || "main-timeline fetch refused"));
360
+ const messages = Array.isArray(data.messages) ? data.messages : [];
361
+ const anchor = messages.find((m) => String(m._id || m.id || "") === rootId);
362
+ const rootMs = Date.parse(anchor?.createdAt || "") || Infinity;
363
+ return messages
364
+ .filter((m) => String(m._id || m.id || "") !== rootId && !m.isDeleted
365
+ && (Date.parse(m.createdAt || "") || 0) < rootMs
366
+ && (!threadRootOf(m) || m.alsoInChannel === true))
367
+ .sort((a, b) => (Date.parse(a.createdAt || "") || 0) - (Date.parse(b.createdAt || "") || 0))
368
+ .slice(-THREAD_SEED_MAIN_MESSAGES);
369
+ }
370
+
371
+ // What the bot reads to name a thread: the root's text, the first replies
372
+ // (the bot's own marked) and the title the thread already has, when the
373
+ // server sends one. Throws like _fetchThread.
374
+ async threadTitleSource(chatId, rootId) {
375
+ const data = await this._fetchThread(String(chatId), String(rootId), THREAD_TITLE_SOURCE_LIMIT);
376
+ const root = data.root && typeof data.root === "object" ? data.root : null;
377
+ const botId = String(this.botUserId);
378
+ const replies = (Array.isArray(data.replies) ? data.replies : [])
379
+ .filter((m) => !m.isDeleted)
380
+ .map((m) => ({ fromBot: senderIdOf(m) === botId, text: this._contextBody(m, THREAD_TITLE_BODY_LIMIT) }))
381
+ .filter((m) => m.text);
382
+ const title = [root?.threadTitle, data.threadTitle, data.title].find((t) => typeof t === "string" && t.trim());
383
+ return {
384
+ rootText: root && !root.isDeleted ? this._contextBody(root, THREAD_TITLE_BODY_LIMIT) : "",
385
+ replies,
386
+ title: title ? title.trim() : null,
387
+ };
388
+ }
389
+
390
+ // Name a thread (chat-central's shared thread titles). onlyIfUnset: only
391
+ // when it has no title yet, so a title a person gave is never overwritten.
392
+ // Never throws: { ok, changed, title } on success, else { ok: false,
393
+ // supported, transient, status, error }. A 404 is a server without thread
394
+ // titles; a network failure, timeout, 408/429 or 5xx is worth one retry.
395
+ async setThreadTitle(chatId, rootId, title, { onlyIfUnset = true } = {}) {
396
+ if (!this.botUserId) return { ok: false, supported: true, transient: false, status: null, error: "no bot user id" };
397
+ const route = `/thread/${encodeURIComponent(String(chatId))}/${encodeURIComponent(String(rootId))}/title`;
398
+ const body = { userId: String(this.botUserId), title: String(title || ""), onlyIfUnset: onlyIfUnset !== false };
399
+ try {
400
+ const res = await withTimeout(this._request("POST", route, body), THREAD_TITLE_TIMEOUT_MS, "Kazee thread title");
401
+ if (!res || res.success === false || res.error) {
402
+ return { ok: false, supported: true, transient: false, status: 200, error: String(res?.error || "thread title refused") };
403
+ }
404
+ const out = res.data && typeof res.data === "object" ? { ...res, ...res.data } : res;
405
+ return { ok: true, changed: out.changed === true, title: typeof out.title === "string" ? out.title : null };
406
+ } catch (e) {
407
+ const match = /→ (\d{3}):/.exec(String(e.message || ""));
408
+ const status = match ? Number(match[1]) : null;
409
+ return {
410
+ ok: false,
411
+ supported: status !== 404,
412
+ transient: !status || status === 408 || status === 429 || status >= 500,
413
+ status,
414
+ error: String(e.message || e),
415
+ };
416
+ }
417
+ }
418
+
419
+ // The thread was named (or found named) already: remembered across restarts.
420
+ markThreadTitled(chatId, rootId) { kazeeState.markThreadTitled(chatId, rootId); }
421
+ threadTitled(rootId) { return kazeeState.threadTitled(rootId); }
422
+
423
+ // Group gate for a thread reply with no @-mention: the bot is part of the
424
+ // thread when it posted the root or has already replied in it. Answered
425
+ // from memory when possible; otherwise the root is fetched ONCE and its
426
+ // author kept (persisted), so the next reply in the same thread costs
427
+ // nothing. A failed lookup stays silent (the gate fails closed).
428
+ async _botInThread(chatId, rootId) {
429
+ const botId = String(this.botUserId);
430
+ if (this._sentMessages.has(rootId)) return true;
431
+ if (kazeeState.botRepliedInThread(rootId)) return true;
432
+ const known = kazeeState.getThreadRootAuthor(rootId);
433
+ if (known === botId) return true;
434
+ // Someone else posted the root: the bot may still have replied there (a
435
+ // reply whose POST came back without an id was never recorded). Re-check
436
+ // the participants, at most once a minute per thread.
437
+ if (known && Date.now() - (this._threadNotIn.get(rootId) || 0) < THREAD_NOT_IN_TTL_MS) return false;
438
+ try {
439
+ const data = await this._fetchThread(chatId, rootId, 1);
440
+ const root = data.root || null;
441
+ const author = senderIdOf(root);
442
+ if (author) kazeeState.setThreadRootAuthor(chatId, rootId, author);
443
+ const participants = (Array.isArray(root?.threadParticipants) ? root.threadParticipants : [])
444
+ .map((p) => String(p?._id || p?.id || p));
445
+ const replied = participants.includes(botId)
446
+ || (Array.isArray(data.replies) ? data.replies : []).some((m) => senderIdOf(m) === botId);
447
+ if (replied) kazeeState.markBotRepliedInThread(chatId, rootId);
448
+ const inThread = author === botId || replied;
449
+ if (inThread) this._threadNotIn.delete(rootId);
450
+ else {
451
+ this._threadNotIn.set(rootId, Date.now());
452
+ if (this._threadNotIn.size > SENT_MESSAGE_MEMORY) this._threadNotIn.delete(this._threadNotIn.keys().next().value);
453
+ }
454
+ return inThread;
455
+ } catch (e) {
456
+ console.error(`Kazee thread root lookup failed (chat=${chatId} root=${rootId}):`, e.message);
457
+ return false;
458
+ }
459
+ }
460
+
218
461
  async _handleInboundMessage(msg) {
219
462
  if (!msg) return;
220
463
  if (this._isDuplicateInbound(msg)) return;
@@ -252,6 +495,10 @@ class KazeeAdapter {
252
495
  text,
253
496
  messageId: msg._id || msg.id,
254
497
  replyToId: msg.replyTo,
498
+ // A reply posted in a thread is a turn of that thread's own conversation.
499
+ // One with alsoInChannel=true shows in the timeline too, but it is the
500
+ // same message (deduped by id above): handled once, as a thread turn.
501
+ threadId: threadRootOf(msg) || null,
255
502
  from: baseFrom,
256
503
  raw: msg,
257
504
  };
@@ -312,6 +559,8 @@ class KazeeAdapter {
312
559
  // Group participation gate: in a group chat the bot engages only when
313
560
  // addressed — an @-mention of the bot user or a reply to one of its own
314
561
  // messages — unless the chat's assistantAction opts into free replies.
562
+ // A thread reply also engages it when the bot posted the thread's root or
563
+ // has already replied in that thread (it is part of that conversation).
315
564
  // Everyone is gated, owner included: a bot answering every group message
316
565
  // drowns the room. 1:1 chats are never gated. Engaged messages are marked
317
566
  // so the router can authorize the turn for non-owner speakers, who then
@@ -325,7 +574,10 @@ class KazeeAdapter {
325
574
  const mentioned = mentions.includes(botId);
326
575
  const replyToBot = String(envelope.reply?.from?.id || "") === botId;
327
576
  const freeReply = meta.assistantAction === "assistant_free_reply";
328
- if (!mentioned && !replyToBot && !freeReply) {
577
+ const inThread = !mentioned && !replyToBot && !freeReply && envelope.threadId
578
+ ? await this._botInThread(channelId, envelope.threadId)
579
+ : false;
580
+ if (!mentioned && !replyToBot && !freeReply && !inThread) {
329
581
  console.log(`Kazee group gate: silent (chat=${channelId} from=${userId})`);
330
582
  return;
331
583
  }
@@ -383,8 +635,13 @@ class KazeeAdapter {
383
635
  // restart self-heals: the next engage still pulls the true set. Best-effort:
384
636
  // any failure returns null, leaves the watermark alone, and never blocks the
385
637
  // reply.
638
+ //
639
+ // A thread turn (DM or group) gets the THREAD's context instead: see
640
+ // _threadContext.
386
641
  async buildThreadContext(envelope) {
387
- if (!envelope || envelope.surface !== "group" || !this.botUserId) return null;
642
+ if (!envelope || !this.botUserId) return null;
643
+ if (envelope.threadId) return this._threadContext(envelope);
644
+ if (envelope.surface !== "group") return null;
388
645
  const chatId = String(envelope.channelId || "");
389
646
  if (!chatId) return null;
390
647
  try {
@@ -439,19 +696,122 @@ class KazeeAdapter {
439
696
  }
440
697
  }
441
698
 
699
+ // The context a thread turn starts from: the thread's root (who posted it,
700
+ // when, what, any media) and the replies so far, oldest first, the bot's own
701
+ // marked as "You". A per-thread watermark means later turns in the same
702
+ // thread only add the replies posted since the last one (the thread's
703
+ // session already holds the rest). Replies newer than the triggering
704
+ // message are left for their own turns. Never blocks the turn: the fetch
705
+ // times out after THREAD_FETCH_TIMEOUT_MS and any failure returns null with
706
+ // the watermark untouched.
707
+ async _threadContext(envelope) {
708
+ const chatId = String(envelope.channelId || "");
709
+ const rootId = String(envelope.threadId || "");
710
+ if (!chatId || !rootId) return null;
711
+ try {
712
+ const since = kazeeState.getThreadWatermark(rootId);
713
+ // The hybrid start's main slice is read alongside the thread, on the
714
+ // thread's first turn only; if it fails the turn just goes without it.
715
+ const mainSlice = !since && threadSeedMode() !== "fresh"
716
+ ? this._mainBeforeRoot(chatId, rootId).catch((e) => { console.error("kazee thread seed:", e.message); return []; })
717
+ : Promise.resolve([]);
718
+ const [data, mainBefore] = await Promise.all([this._fetchThread(chatId, rootId, THREAD_CONTEXT_LIMIT), mainSlice]);
719
+ const root = data.root && typeof data.root === "object" ? data.root : null;
720
+ const replies = Array.isArray(data.replies) ? data.replies : [];
721
+ const sinceMs = since ? Date.parse(since) || 0 : 0;
722
+ const triggerId = String(envelope.messageId || "");
723
+ const triggerMs = Date.parse(envelope.raw?.createdAt || "") || Infinity;
724
+ const botId = String(this.botUserId);
725
+ const inWindow = replies.filter((m) => {
726
+ const t = Date.parse(m.createdAt || "") || 0;
727
+ return t <= triggerMs && (!sinceMs || t > sinceMs);
728
+ });
729
+ const fresh = inWindow.filter((m) => !m.isDeleted && String(m._id || m.id || "") !== triggerId);
730
+ const stamps = [...inWindow.map((m) => m.createdAt), envelope.raw?.createdAt]
731
+ .filter((iso) => Date.parse(iso || ""));
732
+ const newest = stamps.sort((a, b) => Date.parse(a) - Date.parse(b)).pop();
733
+ if (root) {
734
+ const author = senderIdOf(root);
735
+ if (author) kazeeState.setThreadRootAuthor(chatId, rootId, author);
736
+ }
737
+ if (fresh.some((m) => senderIdOf(m) === botId)) kazeeState.markBotRepliedInThread(chatId, rootId);
738
+
739
+ const first = !since;
740
+ const rendered = fresh
741
+ .map((m) => ({ who: senderIdOf(m) === botId ? "You" : (m.sender?.name || "Someone"), body: this._contextBody(m, CONTEXT_BODY_LIMIT), ago: this._ago(m.createdAt) }))
742
+ .filter((m) => m.body);
743
+ const media = [
744
+ ...(first && root && !root.isDeleted ? this._mediaRefs(root, chatId, "Thread root") : []),
745
+ ...fresh.filter((m) => senderIdOf(m) !== botId)
746
+ .flatMap((m) => this._mediaRefs(m, chatId, `${m.sender?.name || "Someone"} in this thread (${m.createdAt || "unknown time"})`)),
747
+ ];
748
+ if (newest) kazeeState.setThreadWatermark(chatId, rootId, newest);
749
+ if (!first && !rendered.length && !media.length) return null;
750
+
751
+ const lines = ["[Kazee thread context]"];
752
+ if (first) {
753
+ lines.push("This conversation is a thread in this chat (its own conversation: your replies go into the thread).");
754
+ const mainRendered = mainBefore
755
+ .map((m) => ({ who: senderIdOf(m) === botId ? "You" : (m.sender?.name || "Someone"), body: this._contextBody(m, CONTEXT_BODY_LIMIT), ago: this._ago(m.createdAt) }))
756
+ .filter((m) => m.body);
757
+ if (mainRendered.length) {
758
+ lines.push("The main chat just before this thread started (oldest→newest):");
759
+ for (const m of mainRendered) lines.push(` [${m.ago}] ${m.who}: ${m.body}`);
760
+ }
761
+ if (!root || root.isDeleted) {
762
+ lines.push("Thread root: (deleted or unavailable)");
763
+ } else {
764
+ const who = senderIdOf(root) === botId ? "You" : (root.sender?.name || "Someone");
765
+ const when = root.createdAt ? `${root.createdAt}, ${this._ago(root.createdAt)}` : "unknown time";
766
+ lines.push(`Thread root, posted by ${who} (${when}):`);
767
+ lines.push(` ${this._contextBody(root, THREAD_ROOT_BODY_LIMIT) || "(no text)"}`);
768
+ }
769
+ if (rendered.length) {
770
+ lines.push(data.hasMoreBefore
771
+ ? "Replies so far (oldest→newest; earlier replies not shown):"
772
+ : "Replies so far (oldest→newest):");
773
+ }
774
+ } else {
775
+ lines.push("New replies in this thread since your last turn (oldest→newest):");
776
+ }
777
+ for (const m of rendered) lines.push(` [${m.ago}] ${m.who}: ${m.body}`);
778
+ return { contextBlock: lines.join("\n"), media };
779
+ } catch (e) {
780
+ console.error("kazee thread context:", e.message);
781
+ return null;
782
+ }
783
+ }
784
+
785
+ // One message rendered for a context block: its text, a [type] note for
786
+ // media, clipped to `limit`.
787
+ _contextBody(m, limit) {
788
+ let body = (typeof m.content === "string" ? m.content : "").trim();
789
+ if (m.type && m.type !== "text" && m.type !== "interactive") {
790
+ body = `[${m.type}]${body ? " " + body : ""}`;
791
+ } else if (this._mediaRefs(m).length) {
792
+ body = `[attachment]${body ? " " + body : ""}`;
793
+ }
794
+ if (body.length > limit) body = body.slice(0, limit).trimEnd() + " …";
795
+ return body;
796
+ }
797
+
442
798
  // Message delta for a chat. With a watermark: chat-central's since-sync
443
799
  // (oldest-first, server-capped at 500). Without one — the chat's first-ever
444
800
  // engage — one recent page, which the server already returns in
445
- // chronological order.
801
+ // chronological order. threads=true keeps thread-only replies out: they
802
+ // belong to their thread's conversation, not the main timeline's.
446
803
  async _messagesSince(chatId, sinceIso, count = COLD_START_WINDOW) {
447
804
  const base = `/messages/${encodeURIComponent(chatId)}/${encodeURIComponent(String(this.botUserId))}`;
448
805
  const query = sinceIso
449
- ? `?since=${encodeURIComponent(sinceIso)}`
450
- : `?page=1&messageCount=${count}`;
806
+ ? `?since=${encodeURIComponent(sinceIso)}&threads=true`
807
+ : `?page=1&messageCount=${count}&threads=true`;
451
808
  const res = await this._request("GET", base + query);
452
809
  const data = res?.data || res || {};
453
810
  if (data.error) throw new Error(String(data.error));
454
- return Array.isArray(data.messages) ? data.messages : [];
811
+ // A server that ignores threads=true still must not leak thread-only
812
+ // replies into the main timeline's context.
813
+ return (Array.isArray(data.messages) ? data.messages : [])
814
+ .filter((m) => !threadRootOf(m) || m.alsoInChannel === true);
455
815
  }
456
816
 
457
817
  _ago(iso) {
@@ -484,32 +844,27 @@ class KazeeAdapter {
484
844
  };
485
845
  if (opts.replyTo) body.replyTo = opts.replyTo;
486
846
  if (buttons) body.interactive = { buttons };
847
+ const threadId = opts.threadId ? String(opts.threadId) : null;
848
+ if (threadId) body.threadRootId = threadId;
849
+ // Set once the thread refused the reply and it went to the main timeline
850
+ // (quoting the root) instead: "quote" while that quote is still on.
851
+ let fallback = null;
487
852
 
488
853
  for (let attempt = 0; attempt < 3; attempt++) {
854
+ let res;
489
855
  try {
490
- const res = await this._request("POST", `/chat/${encodeURIComponent(channelId)}/message`, body);
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;
856
+ res = await this._request("POST", `/chat/${encodeURIComponent(channelId)}/message`, body);
507
857
  } catch (e) {
508
858
  const errMsg = e.message || "";
509
859
  // Don't retry on client errors (4xx) — they won't self-heal.
510
860
  const statusMatch = errMsg.match(/→ (\d{3})/);
511
861
  const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
512
862
  if (status >= 400 && status < 500) {
863
+ // The thread refused the reply (deleted root, not a thread-able
864
+ // message, ...): never lose it, post it in the main timeline once,
865
+ // quoting the root when that can still work.
866
+ const next = this._threadFallback(body, threadId, fallback, errMsg, "send");
867
+ if (next) { fallback = next; attempt--; continue; }
513
868
  console.error("Kazee send error (not retrying):", errMsg);
514
869
  return null;
515
870
  }
@@ -522,6 +877,50 @@ class KazeeAdapter {
522
877
  console.error("Kazee send error (exhausted retries):", errMsg);
523
878
  return null;
524
879
  }
880
+ // Posted. Nothing below may throw back into the retry loop: that would
881
+ // post the reply a second time.
882
+ // Return the created chat message's id so callers can edit it in place.
883
+ // The id can arrive as `_id` (raw Mongo doc) or `id` (serialized) and
884
+ // may sit on `res.message` or at the top level — mirror the reply-to
885
+ // fetch which already handles `m._id || m.id`. Never fall back to the
886
+ // envelope's `messageId`: that's ActionHero's numeric request counter,
887
+ // not a chat message id, and using it makes later edits 500 (and, when
888
+ // the real id is missing, a null return double-sends the reply).
889
+ const m = res?.message || res || {};
890
+ const id = m._id || m.id;
891
+ // body.threadRootId is gone when the reply fell back to the main
892
+ // timeline, so only a post that went INTO the thread marks it.
893
+ this._rememberSentSafely(id, body.threadRootId || null, channelId);
894
+ if (id) return String(id);
895
+ // POST returned 2xx but carried no message id (server fast-ack / payload
896
+ // fallback path). Returning null makes the caller treat a delivered
897
+ // reply as a failed send and resend it — the Kazee double-send. Signal
898
+ // success with a truthy, non-editable sentinel so nothing sends twice.
899
+ console.error("Kazee send: POST ok but no message id in response keys:", Object.keys(res || {}).join(","));
900
+ return true;
901
+ }
902
+ return null;
903
+ }
904
+
905
+ // One step of the INVALID_THREAD fallback for a REST post body (mutated in
906
+ // place). First refusal of the thread: drop threadRootId and quote the root
907
+ // instead (unless the refusal says the root is deleted). If the quote is
908
+ // refused as well: drop it too. Returns the new fallback stage, or null
909
+ // when there is nothing left to fall back from.
910
+ _threadFallback(body, threadId, stage, errMsg, what) {
911
+ if (!threadId) return null;
912
+ if (!stage) {
913
+ if (!body.threadRootId || !INVALID_THREAD_RE.test(errMsg)) return null;
914
+ delete body.threadRootId;
915
+ const quote = !body.replyTo && !/deleted/i.test(errMsg);
916
+ if (quote) body.replyTo = threadId;
917
+ console.error(`Kazee ${what}: thread ${threadId} refused (${errMsg.slice(0, 200)}); posting in the main timeline${quote ? ", quoting the thread root" : ""}`);
918
+ return quote ? "quote" : "plain";
919
+ }
920
+ if (stage === "quote" && body.replyTo === threadId) {
921
+ delete body.replyTo;
922
+ console.error(`Kazee ${what}: quoting thread root ${threadId} refused too; posting in the main timeline without the quote`);
923
+ return "plain";
525
924
  }
526
925
  return null;
527
926
  }
@@ -574,18 +973,19 @@ class KazeeAdapter {
574
973
  catch (e) { /* ignore */ }
575
974
  }
576
975
 
577
- async sendVoice(channelId, oggPath) {
578
- const ok = await this.sendFile(channelId, oggPath);
976
+ async sendVoice(channelId, oggPath, opts = {}) {
977
+ const ok = await this.sendFile(channelId, oggPath, undefined, opts);
579
978
  try { fs.unlinkSync(oggPath); } catch (e) {}
580
979
  return ok;
581
980
  }
582
981
 
583
- async sendPhoto(channelId, filePath, caption) {
982
+ async sendPhoto(channelId, filePath, caption, opts = {}) {
584
983
  // Kazee's sendFile picks the right message type by extension already.
585
- return this.sendFile(channelId, filePath, caption);
984
+ return this.sendFile(channelId, filePath, caption, opts);
586
985
  }
587
986
 
588
- async sendFile(channelId, filePath, caption) {
987
+ async sendFile(channelId, filePath, caption, opts = {}) {
988
+ const threadId = opts && opts.threadId ? String(opts.threadId) : null;
589
989
  try {
590
990
  const buffer = fs.readFileSync(filePath);
591
991
  const fileName = path.basename(filePath);
@@ -616,16 +1016,29 @@ class KazeeAdapter {
616
1016
  findMeCode: `oc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
617
1017
  mediaIds: [mediaId],
618
1018
  };
1019
+ if (threadId) payload.threadRootId = threadId;
619
1020
  try {
620
1021
  const res = await this._socketEmit("message:send", payload, 30000);
621
1022
  if (res && res.code) {
1023
+ if (threadId && INVALID_THREAD_RE.test(`${res.code} ${res.message || ""}`)) {
1024
+ // The thread refused it: post in the main timeline (REST, which
1025
+ // can quote the root) rather than lose the file.
1026
+ const refusal = `${res.code}: ${res.message || ""}`;
1027
+ const quote = !/deleted/i.test(refusal);
1028
+ console.error(`Kazee sendFile: thread ${threadId} refused (${refusal}); posting in the main timeline${quote ? ", quoting the thread root" : ""}`);
1029
+ return await this._sendMediaViaRest(channelId, mediaId, type, caption, { replyTo: quote ? threadId : null, fallbackOf: threadId });
1030
+ }
622
1031
  console.error(`Kazee message:send error (${res.code}), falling back to REST:`, res.message);
623
- return await this._sendMediaViaRest(channelId, mediaId, type, caption);
1032
+ return await this._sendMediaViaRest(channelId, mediaId, type, caption, { threadId });
624
1033
  }
1034
+ // chat-central acks callback(null, {message}): the id is on .message.
1035
+ const sent = res?.message || res?.data || res || {};
1036
+ const sentId = typeof sent === "object" ? (sent._id || sent.id) : null;
1037
+ this._rememberSentSafely(sentId, threadId, channelId);
625
1038
  return true;
626
1039
  } catch (sockErr) {
627
1040
  console.error("Kazee message:send unavailable, falling back to REST:", sockErr.message);
628
- return await this._sendMediaViaRest(channelId, mediaId, type, caption);
1041
+ return await this._sendMediaViaRest(channelId, mediaId, type, caption, { threadId });
629
1042
  }
630
1043
  } catch (e) {
631
1044
  console.error("Kazee sendFile error:", e.message);
@@ -636,20 +1049,40 @@ class KazeeAdapter {
636
1049
  // REST fallback for posting an already-uploaded Media doc when the socket
637
1050
  // path is unavailable. Mirrors send()'s route (POST /chat/:id/message →
638
1051
  // sendMessage action) but references the saved media by id.
639
- async _sendMediaViaRest(channelId, mediaId, type, caption) {
640
- try {
641
- await this._request("POST", `/chat/${encodeURIComponent(channelId)}/message`, {
642
- chat_id: channelId,
643
- sender: this.botUserId,
644
- content: caption || "",
645
- type,
646
- media_ids: [mediaId],
647
- findMeCode: `oc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
648
- });
1052
+ // opts.threadId posts into that thread (with the same once-only
1053
+ // INVALID_THREAD fallback as send); opts.replyTo quotes a message;
1054
+ // opts.fallbackOf names the thread a main-timeline fallback is standing in
1055
+ // for (so a refused quote of its root is dropped too).
1056
+ async _sendMediaViaRest(channelId, mediaId, type, caption, opts = {}) {
1057
+ const threadId = opts.threadId ? String(opts.threadId) : null;
1058
+ const body = {
1059
+ chat_id: channelId,
1060
+ sender: this.botUserId,
1061
+ content: caption || "",
1062
+ type,
1063
+ media_ids: [mediaId],
1064
+ findMeCode: `oc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1065
+ };
1066
+ if (threadId) body.threadRootId = threadId;
1067
+ if (opts.replyTo) body.replyTo = String(opts.replyTo);
1068
+ const origin = threadId || (opts.fallbackOf ? String(opts.fallbackOf) : null);
1069
+ let stage = !threadId && opts.fallbackOf ? (opts.replyTo ? "quote" : "plain") : null;
1070
+ for (;;) {
1071
+ let res;
1072
+ try {
1073
+ res = await this._request("POST", `/chat/${encodeURIComponent(channelId)}/message`, body);
1074
+ } catch (e) {
1075
+ const errMsg = e.message || "";
1076
+ const statusMatch = errMsg.match(/→ (\d{3})/);
1077
+ const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
1078
+ const next = status >= 400 && status < 500 ? this._threadFallback(body, origin, stage, errMsg, "media send") : null;
1079
+ if (next) { stage = next; continue; }
1080
+ console.error("Kazee REST media send failed:", errMsg);
1081
+ return false;
1082
+ }
1083
+ const m = res?.message || res || {};
1084
+ this._rememberSentSafely(m._id || m.id, body.threadRootId || null, channelId);
649
1085
  return true;
650
- } catch (e) {
651
- console.error("Kazee REST media send failed:", e.message);
652
- return false;
653
1086
  }
654
1087
  }
655
1088
 
@@ -668,11 +1101,14 @@ class KazeeAdapter {
668
1101
  settled = true;
669
1102
  reject(new Error(`Kazee ${event} ack timeout`));
670
1103
  }, timeoutMs);
671
- socket.emit(event, payload, (ack) => {
1104
+ // Errors ack as the first argument ({code, message}); successes ack
1105
+ // Node-style, callback(null, body), so take the second when the first
1106
+ // is empty.
1107
+ socket.emit(event, payload, (ack, data) => {
672
1108
  if (settled) return;
673
1109
  settled = true;
674
1110
  clearTimeout(timer);
675
- resolve(ack);
1111
+ resolve(ack == null && data !== undefined ? data : ack);
676
1112
  });
677
1113
  });
678
1114
  }
@@ -734,11 +1170,27 @@ class KazeeAdapter {
734
1170
  return attempt;
735
1171
  }
736
1172
 
737
- async typing(channelId, state) {
1173
+ // opts.threadId shows the indicator inside that thread. chat-central drops
1174
+ // a threadRootId that is not an ObjectId (the indicator would then show in
1175
+ // the channel), so a malformed one is left out and logged once.
1176
+ _typingThreadId(opts) {
1177
+ const threadId = opts && opts.threadId ? String(opts.threadId) : "";
1178
+ if (!threadId) return null;
1179
+ if (OBJECT_ID_RE.test(threadId)) return threadId;
1180
+ if (!this._typingThreadWarned.has(threadId)) {
1181
+ this._typingThreadWarned.add(threadId);
1182
+ console.error(`Kazee typing: thread id "${threadId.slice(0, 40)}" is not a 24-hex id; the indicator shows in the channel instead`);
1183
+ }
1184
+ return null;
1185
+ }
1186
+
1187
+ async typing(channelId, state, opts = {}) {
738
1188
  try {
739
1189
  const chatId = String(channelId);
740
1190
  if (!(await this._ensureJoinedRoom(chatId))) return;
741
1191
  const payload = { chatId };
1192
+ const threadRootId = this._typingThreadId(opts);
1193
+ if (threadRootId) payload.threadRootId = threadRootId;
742
1194
  if (state) payload.state = state;
743
1195
  this._socket?.emit?.("typing:start", payload);
744
1196
  } catch (e) {}
@@ -747,12 +1199,15 @@ class KazeeAdapter {
747
1199
  // Socket channels keep rendering "typing…" until an explicit stop. Telegram
748
1200
  // has no equivalent (its chat action self-expires), so this is Kazee-only and
749
1201
  // called via optional chaining from the core typing heartbeat cleanup.
750
- async typingStop(channelId) {
1202
+ async typingStop(channelId, opts = {}) {
751
1203
  const chatId = String(channelId);
752
1204
  // Never join just to stop: a stop from a socket that never started is a
753
1205
  // no-op server-side anyway, and joining here would race the teardown.
754
1206
  if (!this._joinedRooms.has(chatId)) return;
755
- try { this._socket?.emit?.("typing:stop", { chatId }); } catch (e) {}
1207
+ const payload = { chatId };
1208
+ const threadRootId = this._typingThreadId(opts);
1209
+ if (threadRootId) payload.threadRootId = threadRootId;
1210
+ try { this._socket?.emit?.("typing:stop", payload); } catch (e) {}
756
1211
  }
757
1212
 
758
1213
  async downloadMedia(media) {
@@ -775,7 +1230,7 @@ class KazeeAdapter {
775
1230
  const nameSuffix = keepName && !path.extname(media.fileName) && ext !== ".bin" ? ext : "";
776
1231
  const localPath = keepName
777
1232
  ? path.join(baseDir, media.fileName + nameSuffix)
778
- : path.join(baseDir, `kz-${Date.now()}${ext}`);
1233
+ : path.join(baseDir, `kz-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`);
779
1234
  await this._downloadUrl(url, localPath);
780
1235
  return localPath;
781
1236
  }