@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.
- package/.env.example +13 -0
- package/CHANGELOG.md +36 -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 +519 -64
- package/channels/kazee/state.js +88 -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/providers/utility-policy.js +3 -1
- package/core/router.js +25 -13
- package/core/run-context.js +49 -8
- package/core/runner.js +318 -122
- package/core/sandbox-probe.js +78 -0
- 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/thread-title.js +237 -0
- 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 +7 -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,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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|