@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/Dockerfile +3 -1
  3. package/README.md +1 -0
  4. package/bin/cli.js +1 -1
  5. package/bin/task.js +51 -0
  6. package/bot.js +19 -0
  7. package/channels/kazee/adapter.js +304 -53
  8. package/channels/kazee/bot-handoffs.js +230 -0
  9. package/channels/telegram/adapter.js +4 -2
  10. package/core/actions.js +8 -0
  11. package/core/browser-view.js +200 -0
  12. package/core/config.js +19 -1
  13. package/core/cost-report.js +3 -0
  14. package/core/cron-schedule.js +59 -0
  15. package/core/dream.js +141 -34
  16. package/core/guardrail.js +5 -0
  17. package/core/handlers.js +24 -12
  18. package/core/lessons.js +11 -5
  19. package/core/loopback.js +29 -0
  20. package/core/passive-replies.js +4 -2
  21. package/core/providers/claude-events.js +9 -0
  22. package/core/providers/codex-hook.js +5 -1
  23. package/core/providers/codex.js +48 -0
  24. package/core/relationship.js +1 -1
  25. package/core/router.js +6 -1
  26. package/core/runner.js +264 -8
  27. package/core/safe-filename.js +47 -0
  28. package/core/scheduler.js +137 -36
  29. package/core/system-prompt.js +89 -16
  30. package/core/tasks.js +208 -6
  31. package/core/turn-journal.js +506 -0
  32. package/core/utility-agent.js +17 -1
  33. package/package.json +4 -4
  34. package/test-fixtures/fake-agent-cli.js +17 -1
  35. package/test-guardrail.js +19 -0
  36. package/test-kazee-context-media.js +1 -1
  37. package/test-provider-dream.js +3 -1
  38. package/test-provider-events.js +21 -0
  39. package/test-provider-language.js +20 -1
  40. package/test-provider-pack-review.js +3 -1
  41. package/test-provider-scheduler.js +3 -1
  42. package/test-provider-tool-hooks.js +17 -0
  43. package/test-thread-lanes.js +18 -4
  44. 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 answering bots: once this many messages in a row (the newest one
56
- // included) came from bots, a bot's @-mention no longer engages the bot until
57
- // a person speaks again. OC_BOT_CHAIN_LIMIT overrides it (whole number >= 1).
58
- const BOT_CHAIN_LIMIT = 4;
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: participants.filter((p) => p.type === "AI" || p.type === "Bot").map((p) => String(p._id || p.id)),
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"): only an explicit @-mention engages the bot,
637
- // never a reply to its message, a thread it is in, a passive check or
638
- // an every-message group; and never once the conversation is a chain
639
- // of bots answering bots (_botChainTooLong) until a person speaks.
640
- if ((meta.botIds || []).includes(String(senderId))) {
641
- if (!mentioned) {
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
- if (await this._botChainTooLong(channelId, envelope.threadId || null, meta, msg)) {
646
- console.log(`Kazee group gate: silent, bots answering bots (chat=${channelId} from=${userId})`);
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 the conversation (a thread, or the main timeline) is already a
702
- // chain of bots answering bots: its newest messages, this one included, all
703
- // came from bots, botChainLimit() of them or more. A failed read counts as
704
- // a chain: quiet is the safe side of a possible loop.
705
- async _botChainTooLong(chatId, threadId, meta, msg) {
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 + 2);
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 + 2), THREAD_FETCH_TIMEOUT_MS, "Kazee recent messages");
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 chain check failed (chat=${chatId}):`, e.message);
718
- return true;
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
- let streak = 0;
724
- for (let i = list.length - 1; i >= 0 && bots.has(senderIdOf(list[i])); i -= 1) streak += 1;
725
- return streak >= limit;
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
- ? path.join(baseDir, media.fileName + nameSuffix)
1456
- : path.join(baseDir, `kz-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`);
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 = new URL(media.fileId);
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
- _downloadUrl(url, localPath) {
1603
- return new Promise((resolve, reject) => {
1604
- const lib = url.startsWith("https:") ? https : http;
1605
- const out = fs.createWriteStream(localPath);
1606
- lib.get(url, (res) => {
1607
- if (res.statusCode && res.statusCode >= 400) {
1608
- out.destroy();
1609
- reject(new Error(`Download ${url} → ${res.statusCode}`));
1610
- return;
1611
- }
1612
- res.pipe(out);
1613
- out.on("finish", () => { out.close(); resolve(); });
1614
- }).on("error", reject);
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 };