@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.
@@ -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 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
- });
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
- if (!mentioned && !replyToBot && !freeReply) {
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 || envelope.surface !== "group" || !this.botUserId) return null;
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
- return Array.isArray(data.messages) ? data.messages : [];
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
- 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;
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
- 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
- });
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
- socket.emit(event, payload, (ack) => {
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
- async typing(channelId, state) {
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
- try { this._socket?.emit?.("typing:stop", { chatId }); } catch (e) {}
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
  }