@inetafrica/open-claudia 3.5.14 → 3.5.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/Dockerfile +3 -1
- package/README.md +1 -0
- package/bin/cli.js +1 -1
- package/bin/task.js +51 -0
- package/bot.js +19 -0
- package/channels/kazee/adapter.js +304 -53
- package/channels/kazee/bot-handoffs.js +230 -0
- package/channels/telegram/adapter.js +4 -2
- package/core/actions.js +8 -0
- package/core/browser-view.js +200 -0
- package/core/config.js +19 -1
- package/core/cost-report.js +3 -0
- package/core/cron-schedule.js +59 -0
- package/core/dream.js +141 -34
- package/core/guardrail.js +5 -0
- package/core/handlers.js +24 -12
- package/core/lessons.js +11 -5
- package/core/loopback.js +29 -0
- package/core/passive-replies.js +4 -2
- package/core/providers/claude-events.js +9 -0
- package/core/providers/codex-hook.js +5 -1
- package/core/providers/codex.js +48 -0
- package/core/relationship.js +1 -1
- package/core/router.js +6 -1
- package/core/runner.js +264 -8
- package/core/safe-filename.js +47 -0
- package/core/scheduler.js +137 -36
- package/core/system-prompt.js +89 -16
- package/core/tasks.js +208 -6
- package/core/turn-journal.js +506 -0
- package/core/utility-agent.js +17 -1
- package/package.json +4 -4
- package/test-fixtures/fake-agent-cli.js +17 -1
- package/test-guardrail.js +19 -0
- package/test-kazee-context-media.js +1 -1
- package/test-provider-dream.js +3 -1
- package/test-provider-events.js +21 -0
- package/test-provider-language.js +20 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +3 -1
- package/test-provider-tool-hooks.js +17 -0
- package/test-thread-lanes.js +18 -4
- package/web.js +148 -50
|
@@ -8,10 +8,13 @@ const path = require("path");
|
|
|
8
8
|
const https = require("https");
|
|
9
9
|
const http = require("http");
|
|
10
10
|
const crypto = require("crypto");
|
|
11
|
+
const dns = require("dns");
|
|
12
|
+
const net = require("net");
|
|
11
13
|
const { pipeline } = require("stream/promises");
|
|
12
14
|
const { Transform } = require("stream");
|
|
13
15
|
const { URL } = require("url");
|
|
14
16
|
const { TEMP_DIR, FILES_DIR } = require("../../core/config");
|
|
17
|
+
const { safeJoin } = require("../../core/safe-filename");
|
|
15
18
|
const { canonicalForChannel } = require("../../core/identity");
|
|
16
19
|
const { inlineKeyboardToPortable } = require("../types");
|
|
17
20
|
const { createKazeeSocket } = require("./socket");
|
|
@@ -52,14 +55,10 @@ const THREAD_FETCH_TIMEOUT_MS = 4000;
|
|
|
52
55
|
// A thread whose root someone else posted is re-checked for the bot among its
|
|
53
56
|
// participants at most this often (the answer is "no" until the bot replies).
|
|
54
57
|
const THREAD_NOT_IN_TTL_MS = 60 * 1000;
|
|
55
|
-
// Bots
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
function botChainLimit() {
|
|
60
|
-
const value = parseInt(process.env.OC_BOT_CHAIN_LIMIT, 10);
|
|
61
|
-
return Number.isSafeInteger(value) && value >= 1 ? value : BOT_CHAIN_LIMIT;
|
|
62
|
-
}
|
|
58
|
+
// Bots handing work to each other (./bot-handoffs.js): what wakes a bot, when
|
|
59
|
+
// the bots pause and check in with a person, and the tag check.
|
|
60
|
+
const handoffs = require("./bot-handoffs");
|
|
61
|
+
const { botChainLimit } = handoffs;
|
|
63
62
|
// How many replies a thread's first turn carries as context.
|
|
64
63
|
const THREAD_CONTEXT_LIMIT = 30;
|
|
65
64
|
const THREAD_ROOT_BODY_LIMIT = 1000;
|
|
@@ -133,14 +132,72 @@ const MIME_EXT = {
|
|
|
133
132
|
"application/zip": ".zip",
|
|
134
133
|
};
|
|
135
134
|
|
|
135
|
+
// Inbound media never goes past 200 MB. With KAZEE_MEDIA_HOSTS set (comma-
|
|
136
|
+
// separated hosts or URLs), files are fetched only from those hosts and Kazee
|
|
137
|
+
// itself (KAZEE_URL). Without it, Kazee's presigned links on its storage host
|
|
138
|
+
// keep working: any public host is allowed, but never a private, loopback or
|
|
139
|
+
// link-local address (checked when connecting), so a crafted link cannot
|
|
140
|
+
// point the bot at internal services.
|
|
141
|
+
const MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024;
|
|
142
|
+
const DOWNLOAD_IDLE_MS = 60 * 1000;
|
|
143
|
+
|
|
144
|
+
function mediaHostList(mediaHosts) {
|
|
145
|
+
return (Array.isArray(mediaHosts) ? mediaHosts : String(mediaHosts || "").split(","))
|
|
146
|
+
.map((raw) => String(raw || "").trim())
|
|
147
|
+
.filter(Boolean);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function mediaHostSet(url, mediaHosts) {
|
|
151
|
+
const hosts = new Set();
|
|
152
|
+
for (const entry of [url, ...mediaHostList(mediaHosts)]) {
|
|
153
|
+
try { hosts.add((entry.includes("://") ? new URL(entry).host : entry).toLowerCase()); } catch (_) {}
|
|
154
|
+
}
|
|
155
|
+
return hosts;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const NON_PUBLIC = new net.BlockList();
|
|
159
|
+
for (const [address, prefix] of [
|
|
160
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16],
|
|
161
|
+
["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
162
|
+
]) NON_PUBLIC.addSubnet(address, prefix, "ipv4");
|
|
163
|
+
for (const [address, prefix] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]]) {
|
|
164
|
+
NON_PUBLIC.addSubnet(address, prefix, "ipv6");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isPublicAddress(address) {
|
|
168
|
+
const family = net.isIP(String(address || ""));
|
|
169
|
+
if (!family) return false;
|
|
170
|
+
const mapped = family === 6 && /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
|
|
171
|
+
if (mapped) return !NON_PUBLIC.check(mapped[1], "ipv4");
|
|
172
|
+
return !NON_PUBLIC.check(address, family === 6 ? "ipv6" : "ipv4");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// dns.lookup for http(s).get that refuses names resolving to a non-public
|
|
176
|
+
// address. It runs when connecting, so the answer cannot change in between.
|
|
177
|
+
function publicOnlyLookup(hostname, options, callback) {
|
|
178
|
+
dns.lookup(hostname, options, (err, address, family) => {
|
|
179
|
+
if (err) return callback(err);
|
|
180
|
+
const answers = Array.isArray(address) ? address : [{ address, family }];
|
|
181
|
+
if (answers.some((a) => !isPublicAddress(a.address))) {
|
|
182
|
+
console.error(`Kazee download refused: ${hostname} resolves to a non-public address`);
|
|
183
|
+
return callback(Object.assign(new Error(`${hostname} resolves to a non-public address`), { code: "EMEDIAHOST" }));
|
|
184
|
+
}
|
|
185
|
+
callback(null, address, family);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
136
189
|
class KazeeAdapter {
|
|
137
|
-
constructor({ id = "kazee", url, token, ownerUserId, botUserId, passiveGate = null }) {
|
|
190
|
+
constructor({ id = "kazee", url, token, ownerUserId, botUserId, mediaHosts = "", passiveGate = null, spawnUtilityAgent = null }) {
|
|
138
191
|
this.id = id;
|
|
139
192
|
this.type = "kazee";
|
|
140
193
|
// KAZEE_URL is the root host (socket.io lives at /). REST routes are
|
|
141
194
|
// mounted under /api on chat-central; we prepend that internally.
|
|
142
195
|
this.url = url.replace(/\/$/, "");
|
|
143
196
|
this.apiBase = this.url + "/api";
|
|
197
|
+
this._mediaHosts = mediaHostSet(this.url, mediaHosts);
|
|
198
|
+
this._mediaHostsListed = mediaHostList(mediaHosts).length > 0;
|
|
199
|
+
try { this._kazeeHost = new URL(this.url).host.toLowerCase(); } catch (_) { this._kazeeHost = ""; }
|
|
200
|
+
this._maxDownloadBytes = MAX_DOWNLOAD_BYTES;
|
|
144
201
|
this.token = token;
|
|
145
202
|
this.ownerUserId = ownerUserId || "";
|
|
146
203
|
this.botUserId = botUserId || null;
|
|
@@ -166,6 +223,12 @@ class KazeeAdapter {
|
|
|
166
223
|
// Groups set to "When they can help" (core/passive-replies.js); made on
|
|
167
224
|
// first use, so a bot in no passive group never builds one.
|
|
168
225
|
this._passive = passiveGate;
|
|
226
|
+
// Bot hand-offs (./bot-handoffs.js): messageId -> the bot ids it tagged,
|
|
227
|
+
// for messages this bot posted (so another bot's answer to one wakes it),
|
|
228
|
+
// and conversation key -> the check-in the gate asked this bot to post.
|
|
229
|
+
this._sentTags = new Map();
|
|
230
|
+
this._pauseNext = new Map();
|
|
231
|
+
this._spawnUtility = spawnUtilityAgent;
|
|
169
232
|
}
|
|
170
233
|
|
|
171
234
|
_passiveGate() {
|
|
@@ -197,11 +260,15 @@ class KazeeAdapter {
|
|
|
197
260
|
const participants = (Array.isArray(chat.participants) ? chat.participants : [])
|
|
198
261
|
.filter((p) => p && typeof p === "object");
|
|
199
262
|
const self = participants.find((p) => String(p._id || p.id) === String(this.botUserId));
|
|
263
|
+
const bots = participants.filter((p) => p.type === "AI" || p.type === "Bot")
|
|
264
|
+
.map((p) => ({ id: String(p._id || p.id), name: String(p.name || "") }));
|
|
200
265
|
const meta = {
|
|
201
266
|
isGroupChat: !!chat.isGroupChat,
|
|
202
267
|
assistantAction: String(chat.assistantAction || ""),
|
|
203
268
|
name: String(chat.chatName || chat.name || ""),
|
|
204
|
-
botIds:
|
|
269
|
+
botIds: bots.map((b) => b.id),
|
|
270
|
+
// The other bots, for the hand-off lines in a group turn's context.
|
|
271
|
+
bots: bots.filter((b) => b.id !== String(this.botUserId)),
|
|
205
272
|
botName: self && self.name ? String(self.name) : "",
|
|
206
273
|
};
|
|
207
274
|
this._chatMetaCache.set(chatId, { meta, at: Date.now() });
|
|
@@ -394,6 +461,70 @@ class KazeeAdapter {
|
|
|
394
461
|
return (id && this._sentMessages.get(id)) || null;
|
|
395
462
|
}
|
|
396
463
|
|
|
464
|
+
// The bots a message this bot posted tagged (an answer to it wakes this bot).
|
|
465
|
+
_rememberTags(messageId, botIds) {
|
|
466
|
+
const id = String(messageId || "");
|
|
467
|
+
if (!id) return;
|
|
468
|
+
this._sentTags.delete(id);
|
|
469
|
+
this._sentTags.set(id, botIds.map(String));
|
|
470
|
+
if (this._sentTags.size > SENT_MESSAGE_MEMORY) this._sentTags.delete(this._sentTags.keys().next().value);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
_rememberTagsSafely(messageId, botIds) {
|
|
474
|
+
try { this._rememberTags(messageId, botIds); }
|
|
475
|
+
catch (e) { console.error("Kazee: could not record a message's tags:", e.message); }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Settle the bot tags of a post in place (body.content) and return the ids
|
|
479
|
+
// of the bots it still tags. A final answer the gate asked to be the
|
|
480
|
+
// exchange's check-in tags no bot, only the person, behind PAUSE_MARK. A
|
|
481
|
+
// final answer keeps a bot's tag when the tag check says yes; any other
|
|
482
|
+
// post never wakes a bot. 1:1 chats and posts without a tag are untouched.
|
|
483
|
+
async _settleBotTags(channelId, threadId, body, finalAnswer) {
|
|
484
|
+
const key = threadId ? `${channelId}#thread:${threadId}` : String(channelId);
|
|
485
|
+
let pause = finalAnswer ? this._pauseNext.get(key) || null : null;
|
|
486
|
+
if (pause) {
|
|
487
|
+
this._pauseNext.delete(key);
|
|
488
|
+
if (!(pause.until > Date.now())) pause = null;
|
|
489
|
+
}
|
|
490
|
+
if (!pause && !String(body.content || "").includes("@[")) return [];
|
|
491
|
+
const meta = await this._chatMeta(String(channelId));
|
|
492
|
+
if (!meta || !meta.isGroupChat) return [];
|
|
493
|
+
const others = (meta.botIds || []).filter((id) => id !== String(this.botUserId));
|
|
494
|
+
if (pause) {
|
|
495
|
+
let content = handoffs.stripTags(body.content, others).trim();
|
|
496
|
+
const person = pause.person;
|
|
497
|
+
if (person && person.id && !others.includes(person.id) && !content.includes(`](${person.id})`)) {
|
|
498
|
+
content = `@[${person.name || "there"}](${person.id}) ${content}`;
|
|
499
|
+
}
|
|
500
|
+
body.content = content.startsWith(handoffs.PAUSE_MARK) ? content : `${handoffs.PAUSE_MARK} ${content}`;
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
const tags = handoffs.findBotTags(body.content, meta.botIds, this.botUserId);
|
|
504
|
+
if (!tags.length) return [];
|
|
505
|
+
const ids = [...new Set(tags.map((t) => t.id))];
|
|
506
|
+
if (!finalAnswer) {
|
|
507
|
+
body.content = handoffs.stripTags(body.content, ids);
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
510
|
+
const verdict = await this._checkTags(channelId, threadId, body.content, tags, meta);
|
|
511
|
+
console.log(`[handoff] chat ${channelId}: tag ${tags.map((t) => t.name).join(", ")} ${verdict.tag ? "sent" : "written as a name"} (${verdict.reason || "no reason"})`);
|
|
512
|
+
if (verdict.tag) return ids;
|
|
513
|
+
body.content = handoffs.stripTags(body.content, ids);
|
|
514
|
+
return [];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// The tag check (./bot-handoffs.js checkTag) on the room's recent messages.
|
|
518
|
+
async _checkTags(channelId, threadId, draft, tags, meta) {
|
|
519
|
+
let messages = [];
|
|
520
|
+
try { messages = await this._passiveContext(String(channelId), threadId || null); }
|
|
521
|
+
catch (e) { messages = []; }
|
|
522
|
+
return handoffs.checkTag(
|
|
523
|
+
{ botName: meta.botName, roomName: meta.name, messages, draft, targets: tags },
|
|
524
|
+
this._spawnUtility ? { spawnUtilityAgent: this._spawnUtility } : {},
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
397
528
|
// GET a thread's root and replies (oldest first). Throws on failure or
|
|
398
529
|
// after THREAD_FETCH_TIMEOUT_MS.
|
|
399
530
|
async _fetchThread(chatId, rootId, limit) {
|
|
@@ -607,6 +738,9 @@ class KazeeAdapter {
|
|
|
607
738
|
},
|
|
608
739
|
messageId: m._id || m.id,
|
|
609
740
|
media: this._mediaRefs(m, channelId, "Quoted message"),
|
|
741
|
+
// Who the quoted message tagged: another bot's answer to one of
|
|
742
|
+
// this bot's tags wakes it (./bot-handoffs.js).
|
|
743
|
+
mentions: (Array.isArray(m.mentions) ? m.mentions : []).map((x) => String(x?._id || x?.id || x)),
|
|
610
744
|
};
|
|
611
745
|
}
|
|
612
746
|
|
|
@@ -633,19 +767,37 @@ class KazeeAdapter {
|
|
|
633
767
|
.map((m) => String(m?._id || m?.id || m));
|
|
634
768
|
const mentioned = mentions.includes(botId);
|
|
635
769
|
// Another bot (Sumeet, 3 Oct 2026: two bots in one chat "go on an
|
|
636
|
-
// infinite conversation"
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
770
|
+
// infinite conversation"; 5 Oct: let them hand work to each other and
|
|
771
|
+
// decide themselves when to stop). It engages this bot only on
|
|
772
|
+
// purpose: by tagging it, or by answering (replying to) a message in
|
|
773
|
+
// which this bot tagged it. Never a reply to any other message, a
|
|
774
|
+
// thread it is in, a passive check or an every-message group. The
|
|
775
|
+
// exchange decides the rest (_botExchange): it goes on, this bot posts
|
|
776
|
+
// the one check-in, or it stays silent until a person writes.
|
|
777
|
+
const fromBot = (meta.botIds || []).includes(String(senderId));
|
|
778
|
+
if (fromBot) {
|
|
779
|
+
const answered = !mentioned && this._answersMyTag(envelope, senderId);
|
|
780
|
+
if (!mentioned && !answered) {
|
|
642
781
|
console.log(`Kazee group gate: silent, another bot (chat=${channelId} from=${userId})`);
|
|
643
782
|
return;
|
|
644
783
|
}
|
|
645
|
-
|
|
646
|
-
|
|
784
|
+
const exchange = await this._botExchange(channelId, envelope.threadId || null, meta, msg);
|
|
785
|
+
if (exchange.silent) {
|
|
786
|
+
console.log(`Kazee group gate: silent, ${exchange.silent} (chat=${channelId} from=${userId})`);
|
|
647
787
|
return;
|
|
648
788
|
}
|
|
789
|
+
envelope.botHandoff = {
|
|
790
|
+
from: { id: String(senderId), name: baseFrom.name },
|
|
791
|
+
kind: mentioned ? "asked" : "answered",
|
|
792
|
+
person: exchange.person,
|
|
793
|
+
count: exchange.streak,
|
|
794
|
+
pause: exchange.pause || null,
|
|
795
|
+
};
|
|
796
|
+
if (exchange.pause) {
|
|
797
|
+
const key = envelope.threadId ? `${channelId}#thread:${envelope.threadId}` : channelId;
|
|
798
|
+
this._pauseNext.set(key, { until: Date.now() + handoffs.PAUSE_TTL_MS, person: exchange.person });
|
|
799
|
+
console.log(`[handoff] chat ${channelId}: check-in (${exchange.pause === "repeat" ? "a repeated ask" : `${exchange.streak} bot messages in a row`})`);
|
|
800
|
+
}
|
|
649
801
|
}
|
|
650
802
|
const replyToBot = String(envelope.reply?.from?.id || "") === botId;
|
|
651
803
|
const freeReply = meta.assistantAction === "assistant_free_reply";
|
|
@@ -657,7 +809,7 @@ class KazeeAdapter {
|
|
|
657
809
|
: false;
|
|
658
810
|
// "@bot be quiet" / "you can talk again" in a passive group only switch
|
|
659
811
|
// its unprompted replies off or on: no turn, just a one-line answer.
|
|
660
|
-
if (passive && (mentioned || replyToBot) && envelope.type === "text") {
|
|
812
|
+
if (passive && !fromBot && (mentioned || replyToBot) && envelope.type === "text") {
|
|
661
813
|
const command = quietCommand(text, meta.botName);
|
|
662
814
|
if (command) return this._passiveQuietCommand(channelId, command, envelope);
|
|
663
815
|
}
|
|
@@ -698,31 +850,51 @@ class KazeeAdapter {
|
|
|
698
850
|
this._emit("message", envelope);
|
|
699
851
|
}
|
|
700
852
|
|
|
701
|
-
// Whether
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
|
|
853
|
+
// Whether another bot's message answers one of this bot's tags: it replies
|
|
854
|
+
// to a message this bot posted in which it tagged that bot (remembered when
|
|
855
|
+
// it was posted, else read off the quoted message after a restart).
|
|
856
|
+
_answersMyTag(envelope, senderId) {
|
|
857
|
+
const reply = envelope.reply;
|
|
858
|
+
if (!reply || String(reply.from?.id || "") !== String(this.botUserId)) return false;
|
|
859
|
+
const sender = String(senderId);
|
|
860
|
+
const remembered = this._sentTags.get(String(reply.messageId || ""));
|
|
861
|
+
if (remembered) return remembered.includes(sender);
|
|
862
|
+
return (reply.mentions || []).includes(sender)
|
|
863
|
+
|| handoffs.findBotTags(reply.text, [sender], this.botUserId).length > 0;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Where a bot-to-bot exchange stands in this conversation (a thread, or the
|
|
867
|
+
// main timeline), read from its newest messages, the triggering one
|
|
868
|
+
// included (./bot-handoffs.js). { silent: why } when this bot must not
|
|
869
|
+
// answer: a person asked the bots to stop, the bots already checked in, or
|
|
870
|
+
// they went past the limit. Otherwise { streak, person, pause }: pause is
|
|
871
|
+
// "limit" when this message makes botChainLimit() bot messages in a row
|
|
872
|
+
// since a person wrote, "repeat" when it repeats an ask; this bot then posts
|
|
873
|
+
// the one check-in. A failed read means silent: quiet is the safe side.
|
|
874
|
+
async _botExchange(chatId, threadId, meta, msg) {
|
|
706
875
|
const limit = botChainLimit();
|
|
707
|
-
const bots = new Set([...(meta.botIds || []), String(this.botUserId)]);
|
|
876
|
+
const bots = [...new Set([...(meta.botIds || []), String(this.botUserId)])];
|
|
708
877
|
let recent;
|
|
709
878
|
try {
|
|
710
879
|
if (threadId) {
|
|
711
|
-
const data = await this._fetchThread(chatId, threadId, limit +
|
|
880
|
+
const data = await this._fetchThread(chatId, threadId, limit + 4);
|
|
712
881
|
recent = [...(data.root && typeof data.root === "object" ? [data.root] : []), ...(Array.isArray(data.replies) ? data.replies : [])];
|
|
713
882
|
} else {
|
|
714
|
-
recent = await withTimeout(this._messagesSince(chatId, null, limit +
|
|
883
|
+
recent = await withTimeout(this._messagesSince(chatId, null, limit + 4), THREAD_FETCH_TIMEOUT_MS, "Kazee recent messages");
|
|
715
884
|
}
|
|
716
885
|
} catch (e) {
|
|
717
|
-
console.error(`Kazee bot
|
|
718
|
-
return
|
|
886
|
+
console.error(`Kazee bot exchange check failed (chat=${chatId}):`, e.message);
|
|
887
|
+
return { silent: "could not read the conversation" };
|
|
719
888
|
}
|
|
720
889
|
const list = (Array.isArray(recent) ? recent : []).filter((m) => m && !m.isDeleted && m.type !== "system");
|
|
721
890
|
const currentId = String(msg._id || msg.id || "");
|
|
722
891
|
if (currentId && !list.some((m) => String(m._id || m.id || "") === currentId)) list.push(msg);
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
return
|
|
892
|
+
const state = handoffs.exchangeState(list, bots);
|
|
893
|
+
if (state.stopped) return { silent: "a person asked the bots to stop" };
|
|
894
|
+
if (state.paused) return { silent: "the bots are waiting for a person" };
|
|
895
|
+
if (state.streak > limit) return { silent: "bots answering bots" };
|
|
896
|
+
const pause = state.streak === limit ? "limit" : (state.repeat ? "repeat" : null);
|
|
897
|
+
return { streak: state.streak, person: state.person, pause };
|
|
726
898
|
}
|
|
727
899
|
|
|
728
900
|
// Whether an unaddressed message in a passive group is the bot's to judge:
|
|
@@ -819,8 +991,31 @@ class KazeeAdapter {
|
|
|
819
991
|
// reply.
|
|
820
992
|
//
|
|
821
993
|
// A thread turn (DM or group) gets the THREAD's context instead: see
|
|
822
|
-
// _threadContext.
|
|
994
|
+
// _threadContext. A group turn also learns about the other bots in the room
|
|
995
|
+
// and, when one of them woke this bot, about that exchange (_handoffContext).
|
|
823
996
|
async buildThreadContext(envelope) {
|
|
997
|
+
const base = await this._conversationContext(envelope);
|
|
998
|
+
const extra = await this._handoffContext(envelope);
|
|
999
|
+
if (!extra) return base;
|
|
1000
|
+
return { contextBlock: [base?.contextBlock, extra].filter(Boolean).join("\n\n"), media: base?.media || [] };
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// The bots this bot may hand work to (./bot-handoffs.js), for a group turn;
|
|
1004
|
+
// "" for a 1:1 chat, a group with no other bot, or a failed read.
|
|
1005
|
+
async _handoffContext(envelope) {
|
|
1006
|
+
if (!envelope || !this.botUserId || envelope.surface !== "group") return "";
|
|
1007
|
+
try {
|
|
1008
|
+
const meta = await this._chatMeta(String(envelope.channelId || ""));
|
|
1009
|
+
const bots = (meta && meta.isGroupChat && meta.bots) || [];
|
|
1010
|
+
if (!bots.length && !envelope.botHandoff) return "";
|
|
1011
|
+
return handoffs.contextLines({ bots, handoff: envelope.botHandoff || null }).join("\n");
|
|
1012
|
+
} catch (e) {
|
|
1013
|
+
console.error("kazee hand-off context:", e.message);
|
|
1014
|
+
return "";
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async _conversationContext(envelope) {
|
|
824
1019
|
if (!envelope || !this.botUserId) return null;
|
|
825
1020
|
if (envelope.threadId) return this._threadContext(envelope);
|
|
826
1021
|
if (envelope.surface !== "group") return null;
|
|
@@ -1028,6 +1223,13 @@ class KazeeAdapter {
|
|
|
1028
1223
|
if (buttons) body.interactive = { buttons };
|
|
1029
1224
|
const threadId = opts.threadId ? String(opts.threadId) : null;
|
|
1030
1225
|
if (threadId) body.threadRootId = threadId;
|
|
1226
|
+
// Tags of other bots (./bot-handoffs.js). chat-central reads mentions out
|
|
1227
|
+
// of the text itself (@[Name](id)), so only a final answer (opts.botTags)
|
|
1228
|
+
// may keep one, and only with the tag check's yes; anything else (a stream
|
|
1229
|
+
// preview, a notice) writes a bot tag as a plain name.
|
|
1230
|
+
let botTags = [];
|
|
1231
|
+
try { botTags = await this._settleBotTags(channelId, threadId, body, opts.botTags === true); }
|
|
1232
|
+
catch (e) { console.error("Kazee bot tags:", e.message); }
|
|
1031
1233
|
// Set once the thread refused the reply and it went to the main timeline
|
|
1032
1234
|
// (quoting the root) instead: "quote" while that quote is still on.
|
|
1033
1235
|
let fallback = null;
|
|
@@ -1073,6 +1275,7 @@ class KazeeAdapter {
|
|
|
1073
1275
|
// body.threadRootId is gone when the reply fell back to the main
|
|
1074
1276
|
// timeline, so only a post that went INTO the thread marks it.
|
|
1075
1277
|
this._rememberSentSafely(id, body.threadRootId || null, channelId);
|
|
1278
|
+
if (id && botTags.length) this._rememberTagsSafely(id, botTags);
|
|
1076
1279
|
if (id) return String(id);
|
|
1077
1280
|
// POST returned 2xx but carried no message id (server fast-ack / payload
|
|
1078
1281
|
// fallback path). Returning null makes the caller treat a delivered
|
|
@@ -1451,16 +1654,48 @@ class KazeeAdapter {
|
|
|
1451
1654
|
const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
|
|
1452
1655
|
const keepName = media.type === "document" && media.fileName;
|
|
1453
1656
|
const nameSuffix = keepName && !path.extname(media.fileName) && ext !== ".bin" ? ext : "";
|
|
1657
|
+
const generated = `kz-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
|
|
1454
1658
|
const localPath = keepName
|
|
1455
|
-
?
|
|
1456
|
-
: path.join(baseDir,
|
|
1659
|
+
? safeJoin(baseDir, media.fileName + nameSuffix, generated)
|
|
1660
|
+
: path.join(baseDir, generated);
|
|
1457
1661
|
await this._downloadUrl(url, localPath);
|
|
1458
1662
|
return localPath;
|
|
1459
1663
|
}
|
|
1460
1664
|
|
|
1665
|
+
// The parsed URL when it may be fetched; otherwise logs why and throws.
|
|
1666
|
+
_checkedMediaUrl(raw) {
|
|
1667
|
+
let url = null;
|
|
1668
|
+
try { url = new URL(String(raw)); } catch (_) {}
|
|
1669
|
+
if (!url || !["https:", "http:"].includes(url.protocol) || url.username || url.password) {
|
|
1670
|
+
console.error("Kazee download refused: not an http(s) media URL");
|
|
1671
|
+
throw new Error("Invalid media URL");
|
|
1672
|
+
}
|
|
1673
|
+
const host = url.host.toLowerCase();
|
|
1674
|
+
if (this._mediaHostsListed) {
|
|
1675
|
+
if (!this._mediaHosts.has(host)) {
|
|
1676
|
+
console.error(`Kazee download refused: ${host} is not a Kazee media host (allowed: KAZEE_URL's host and KAZEE_MEDIA_HOSTS)`);
|
|
1677
|
+
throw new Error(`${host} is not a Kazee media host`);
|
|
1678
|
+
}
|
|
1679
|
+
} else if (host !== this._kazeeHost) {
|
|
1680
|
+
// Names are checked when connecting (publicOnlyLookup); IP literals never look up.
|
|
1681
|
+
const literal = url.hostname.replace(/^\[|\]$/g, "");
|
|
1682
|
+
if (net.isIP(literal) && !isPublicAddress(literal)) {
|
|
1683
|
+
console.error(`Kazee download refused: ${host} is a non-public address`);
|
|
1684
|
+
throw new Error(`${host} is a non-public address`);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return url;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Request options for a checked media URL: without a KAZEE_MEDIA_HOSTS list,
|
|
1691
|
+
// anything but Kazee's own host must resolve to public addresses only.
|
|
1692
|
+
_mediaRequestOptions(url) {
|
|
1693
|
+
if (this._mediaHostsListed || url.host.toLowerCase() === this._kazeeHost) return {};
|
|
1694
|
+
return { lookup: publicOnlyLookup };
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1461
1697
|
async _downloadContextMedia(media) {
|
|
1462
|
-
const url =
|
|
1463
|
-
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) throw new Error("Invalid media URL");
|
|
1698
|
+
const url = this._checkedMediaUrl(media.fileId);
|
|
1464
1699
|
const candidateExt = path.extname(media.fileName || url.pathname).toLowerCase();
|
|
1465
1700
|
const ext = /^\.[a-z0-9]{1,8}$/.test(candidateExt) ? candidateExt : MIME_EXT[media.mimeType] || ".bin";
|
|
1466
1701
|
const key = crypto.createHash("sha256").update(media.contextKey).digest("hex");
|
|
@@ -1477,7 +1712,7 @@ class KazeeAdapter {
|
|
|
1477
1712
|
const timer = setTimeout(() => req?.destroy(new Error("Media download timed out")), Math.min(media.timeoutMs || 8000, 8000));
|
|
1478
1713
|
try {
|
|
1479
1714
|
const res = await new Promise((resolve, reject) => {
|
|
1480
|
-
req = (url.protocol === "https:" ? https : http).get(url, resolve);
|
|
1715
|
+
req = (url.protocol === "https:" ? https : http).get(url, this._mediaRequestOptions(url), resolve);
|
|
1481
1716
|
req.on("error", reject);
|
|
1482
1717
|
});
|
|
1483
1718
|
if (res.statusCode !== 200 || Number(res.headers["content-length"]) > maxBytes) {
|
|
@@ -1599,21 +1834,37 @@ class KazeeAdapter {
|
|
|
1599
1834
|
});
|
|
1600
1835
|
}
|
|
1601
1836
|
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1837
|
+
// Streams to a .part file next to localPath, counting bytes; over the cap
|
|
1838
|
+
// (declared or actual) or idle too long, it aborts and deletes the part.
|
|
1839
|
+
async _downloadUrl(rawUrl, localPath) {
|
|
1840
|
+
const url = this._checkedMediaUrl(rawUrl);
|
|
1841
|
+
const maxBytes = this._maxDownloadBytes;
|
|
1842
|
+
const tooBig = () => new Error(`Kazee media exceeds the ${Math.round(maxBytes / 1024 / 1024)} MB size limit`);
|
|
1843
|
+
const partial = `${localPath}.${crypto.randomUUID()}.part`;
|
|
1844
|
+
let req;
|
|
1845
|
+
try {
|
|
1846
|
+
const res = await new Promise((resolve, reject) => {
|
|
1847
|
+
req = (url.protocol === "https:" ? https : http).get(url, this._mediaRequestOptions(url), resolve);
|
|
1848
|
+
req.on("error", reject);
|
|
1849
|
+
req.setTimeout(DOWNLOAD_IDLE_MS, () => req.destroy(new Error("Kazee download stalled")));
|
|
1850
|
+
});
|
|
1851
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
1852
|
+
res.resume();
|
|
1853
|
+
throw new Error(`Download from ${url.host} → ${res.statusCode}`);
|
|
1854
|
+
}
|
|
1855
|
+
if (Number(res.headers["content-length"]) > maxBytes) { res.destroy(); throw tooBig(); }
|
|
1856
|
+
let bytes = 0;
|
|
1857
|
+
await pipeline(res, new Transform({ transform(chunk, enc, done) {
|
|
1858
|
+
bytes += chunk.length;
|
|
1859
|
+
done(bytes > maxBytes ? tooBig() : null, chunk);
|
|
1860
|
+
} }), fs.createWriteStream(partial, { flags: "wx" }));
|
|
1861
|
+
fs.renameSync(partial, localPath);
|
|
1862
|
+
} catch (e) {
|
|
1863
|
+
req?.destroy();
|
|
1864
|
+
try { fs.unlinkSync(partial); } catch (_) {}
|
|
1865
|
+
throw e;
|
|
1866
|
+
}
|
|
1616
1867
|
}
|
|
1617
1868
|
}
|
|
1618
1869
|
|
|
1619
|
-
module.exports = { KazeeAdapter };
|
|
1870
|
+
module.exports = { KazeeAdapter, isPublicAddress, publicOnlyLookup };
|