@kodelyth/tlon 2026.5.39 → 2026.5.42

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 (91) hide show
  1. package/README.md +5 -0
  2. package/api.ts +16 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/dist/api.js +4 -0
  5. package/dist/channel-Bvzym9ez.js +236 -0
  6. package/dist/channel-plugin-api.js +2 -0
  7. package/dist/channel.runtime-CDY2BdfM.js +3626 -0
  8. package/dist/doctor-contract-Ip6FcHDH.js +7 -0
  9. package/dist/doctor-contract-api.js +2 -0
  10. package/dist/index.js +18 -0
  11. package/dist/runtime-BmSb9A-q.js +8 -0
  12. package/dist/runtime-api-Dq8wkBC_.js +4 -0
  13. package/dist/runtime-api.js +2 -0
  14. package/dist/setup-api.js +3 -0
  15. package/dist/setup-core-CF3ryHqs.js +387 -0
  16. package/dist/setup-entry.js +11 -0
  17. package/dist/setup-surface-BM5_V_XL.js +74 -0
  18. package/dist/test-api.js +2 -0
  19. package/doctor-contract-api.ts +1 -0
  20. package/index.ts +16 -0
  21. package/klaw.plugin.json +3 -203
  22. package/package.json +4 -4
  23. package/runtime-api.ts +17 -0
  24. package/setup-api.ts +2 -0
  25. package/setup-entry.ts +9 -0
  26. package/src/account-fields.ts +31 -0
  27. package/src/channel.message-adapter.test.ts +145 -0
  28. package/src/channel.runtime.ts +259 -0
  29. package/src/channel.ts +192 -0
  30. package/src/config-schema.ts +54 -0
  31. package/src/core.test.ts +298 -0
  32. package/src/doctor-contract.ts +9 -0
  33. package/src/doctor.test.ts +46 -0
  34. package/src/doctor.ts +10 -0
  35. package/src/logger-runtime.ts +1 -0
  36. package/src/monitor/approval-runtime.ts +363 -0
  37. package/src/monitor/approval.test.ts +33 -0
  38. package/src/monitor/approval.ts +283 -0
  39. package/src/monitor/authorization.ts +30 -0
  40. package/src/monitor/cites.ts +54 -0
  41. package/src/monitor/discovery.ts +68 -0
  42. package/src/monitor/history.ts +226 -0
  43. package/src/monitor/index.ts +1523 -0
  44. package/src/monitor/media.test.ts +80 -0
  45. package/src/monitor/media.ts +156 -0
  46. package/src/monitor/processed-messages.test.ts +58 -0
  47. package/src/monitor/processed-messages.ts +89 -0
  48. package/src/monitor/settings-helpers.test.ts +113 -0
  49. package/src/monitor/settings-helpers.ts +158 -0
  50. package/src/monitor/utils.ts +402 -0
  51. package/src/runtime.ts +9 -0
  52. package/src/security.test.ts +658 -0
  53. package/src/session-route.ts +40 -0
  54. package/src/settings.ts +391 -0
  55. package/src/setup-core.ts +231 -0
  56. package/src/setup-surface.ts +99 -0
  57. package/src/targets.ts +102 -0
  58. package/src/tlon-api.test.ts +572 -0
  59. package/src/tlon-api.ts +389 -0
  60. package/src/types.ts +160 -0
  61. package/src/urbit/auth.ssrf.test.ts +45 -0
  62. package/src/urbit/auth.ts +48 -0
  63. package/src/urbit/base-url.test.ts +48 -0
  64. package/src/urbit/base-url.ts +61 -0
  65. package/src/urbit/channel-ops.test.ts +36 -0
  66. package/src/urbit/channel-ops.ts +149 -0
  67. package/src/urbit/context.ts +50 -0
  68. package/src/urbit/errors.ts +51 -0
  69. package/src/urbit/fetch.ts +38 -0
  70. package/src/urbit/foreigns.ts +49 -0
  71. package/src/urbit/send.test.ts +83 -0
  72. package/src/urbit/send.ts +228 -0
  73. package/src/urbit/sse-client.test.ts +234 -0
  74. package/src/urbit/sse-client.ts +492 -0
  75. package/src/urbit/story.ts +332 -0
  76. package/src/urbit/upload.test.ts +155 -0
  77. package/src/urbit/upload.ts +60 -0
  78. package/test-api.ts +1 -0
  79. package/tsconfig.json +16 -0
  80. package/api.js +0 -7
  81. package/bundled-skills/@tloncorp/tlon-skill/SKILL.md +0 -501
  82. package/bundled-skills/@tloncorp/tlon-skill/bin/tlon.js +0 -7
  83. package/bundled-skills/@tloncorp/tlon-skill/package.json +0 -40
  84. package/bundled-skills/@tloncorp/tlon-skill/scripts/postinstall.js +0 -7
  85. package/channel-plugin-api.js +0 -7
  86. package/doctor-contract-api.js +0 -7
  87. package/index.js +0 -7
  88. package/runtime-api.js +0 -7
  89. package/setup-api.js +0 -7
  90. package/setup-entry.js +0 -7
  91. package/test-api.js +0 -7
@@ -0,0 +1,1523 @@
1
+ import type { ReplyPayload } from "klaw/plugin-sdk/reply-runtime";
2
+ import type { RuntimeEnv } from "klaw/plugin-sdk/runtime";
3
+ import type { KlawConfig } from "../../runtime-api.js";
4
+ import { createLoggerBackedRuntime } from "../../runtime-api.js";
5
+ import { getTlonRuntime } from "../runtime.js";
6
+ import { createSettingsManager, type TlonSettingsStore } from "../settings.js";
7
+ import { normalizeShip, parseChannelNest } from "../targets.js";
8
+ import { resolveTlonAccount } from "../types.js";
9
+ import { authenticate } from "../urbit/auth.js";
10
+ import { ssrfPolicyFromDangerouslyAllowPrivateNetwork } from "../urbit/context.js";
11
+ import type { DmInvite, Foreigns } from "../urbit/foreigns.js";
12
+ import { sendDm, sendGroupMessage } from "../urbit/send.js";
13
+ import { UrbitSSEClient } from "../urbit/sse-client.js";
14
+ import { createTlonApprovalRuntime } from "./approval-runtime.js";
15
+ import {
16
+ createPendingApproval,
17
+ isAdminCommand,
18
+ isApprovalResponse,
19
+ type PendingApproval,
20
+ } from "./approval.js";
21
+ import { resolveChannelAuthorization } from "./authorization.js";
22
+ import { createTlonCitationResolver } from "./cites.js";
23
+ import { fetchAllChannels, fetchInitData } from "./discovery.js";
24
+ import { cacheMessage, fetchThreadHistory, getChannelHistory } from "./history.js";
25
+ import { downloadMessageImages } from "./media.js";
26
+ import {
27
+ createProcessedMessageTracker,
28
+ runWithProcessedMessageClaim,
29
+ } from "./processed-messages.js";
30
+ import {
31
+ applyTlonSettingsOverrides,
32
+ buildTlonSettingsMigrations,
33
+ mergeUniqueStrings,
34
+ shouldMigrateTlonSetting,
35
+ } from "./settings-helpers.js";
36
+ import { asRecord, formatErrorMessage, readString } from "./utils.js";
37
+ import {
38
+ extractMessageText,
39
+ formatModelName,
40
+ isBotMentioned,
41
+ isDmAllowedWithIngress,
42
+ isGroupInviteAllowed,
43
+ isSummarizationRequest,
44
+ resolveAuthorizedMessageText,
45
+ resolveTlonCommandAuthorizationWithIngress,
46
+ stripBotMention,
47
+ } from "./utils.js";
48
+
49
+ type MonitorTlonOpts = {
50
+ runtime?: RuntimeEnv;
51
+ abortSignal?: AbortSignal;
52
+ accountId?: string | null;
53
+ };
54
+
55
+ function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
56
+ const value = record?.[key];
57
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
58
+ }
59
+
60
+ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<void> {
61
+ const core = getTlonRuntime();
62
+ const cfg = core.config.current() as KlawConfig;
63
+ if (cfg.channels?.tlon?.enabled === false) {
64
+ return;
65
+ }
66
+
67
+ const logger = core.logging.getChildLogger({ module: "tlon-auto-reply" });
68
+ const runtime: RuntimeEnv =
69
+ opts.runtime ??
70
+ createLoggerBackedRuntime({
71
+ logger,
72
+ });
73
+
74
+ const account = resolveTlonAccount(cfg, opts.accountId ?? undefined);
75
+ if (!account.enabled) {
76
+ return;
77
+ }
78
+ if (!account.configured || !account.ship || !account.url || !account.code) {
79
+ throw new Error("Tlon account not configured (ship/url/code required)");
80
+ }
81
+
82
+ const botShipName = normalizeShip(account.ship);
83
+ runtime.log?.(`[tlon] Starting monitor for ${botShipName}`);
84
+
85
+ const ssrfPolicy = ssrfPolicyFromDangerouslyAllowPrivateNetwork(
86
+ account.dangerouslyAllowPrivateNetwork,
87
+ );
88
+
89
+ // Store validated values for use in closures (TypeScript narrowing doesn't propagate)
90
+ const accountUrl = account.url;
91
+ const accountCode = account.code;
92
+
93
+ // Helper to authenticate with retry logic
94
+ async function authenticateWithRetry(maxAttempts = 10): Promise<string> {
95
+ for (let attempt = 1; ; attempt++) {
96
+ if (opts.abortSignal?.aborted) {
97
+ throw new Error("Aborted while waiting to authenticate");
98
+ }
99
+ try {
100
+ runtime.log?.(`[tlon] Attempting authentication to ${accountUrl}...`);
101
+ return await authenticate(accountUrl, accountCode, { ssrfPolicy });
102
+ } catch (error: unknown) {
103
+ runtime.error?.(
104
+ `[tlon] Failed to authenticate (attempt ${attempt}): ${formatErrorMessage(error)}`,
105
+ );
106
+ if (attempt >= maxAttempts) {
107
+ throw error;
108
+ }
109
+ const delay = Math.min(30000, 1000 * 2 ** (attempt - 1));
110
+ runtime.log?.(`[tlon] Retrying authentication in ${delay}ms...`);
111
+ await new Promise<void>((resolve, reject) => {
112
+ const timer = setTimeout(resolve, delay);
113
+ if (opts.abortSignal) {
114
+ const onAbort = () => {
115
+ clearTimeout(timer);
116
+ reject(new Error("Aborted"));
117
+ };
118
+ opts.abortSignal.addEventListener("abort", onAbort, { once: true });
119
+ }
120
+ });
121
+ }
122
+ }
123
+ }
124
+
125
+ let api: UrbitSSEClient | null = null;
126
+ const cookie = await authenticateWithRetry();
127
+ api = new UrbitSSEClient(account.url, cookie, {
128
+ ship: botShipName,
129
+ ssrfPolicy,
130
+ logger: {
131
+ log: (message) => runtime.log?.(message),
132
+ error: (message) => runtime.error?.(message),
133
+ },
134
+ // Re-authenticate on reconnect in case the session expired
135
+ onReconnect: async (client) => {
136
+ runtime.log?.("[tlon] Re-authenticating on SSE reconnect...");
137
+ const newCookie = await authenticateWithRetry(5);
138
+ client.updateCookie(newCookie);
139
+ runtime.log?.("[tlon] Re-authentication successful");
140
+ },
141
+ });
142
+
143
+ const processedTracker = createProcessedMessageTracker(2000);
144
+ let groupChannels: string[] = [];
145
+ let botNickname: string | null = null;
146
+
147
+ // Settings store manager for hot-reloading config
148
+ const settingsManager = createSettingsManager(api, {
149
+ log: (msg) => runtime.log?.(msg),
150
+ error: (msg) => runtime.error?.(msg),
151
+ });
152
+
153
+ // Reactive state that can be updated via settings store
154
+ let effectiveDmAllowlist: string[] = account.dmAllowlist;
155
+ let effectiveShowModelSig: boolean = account.showModelSignature ?? false;
156
+ let effectiveAutoAcceptDmInvites: boolean = account.autoAcceptDmInvites ?? false;
157
+ let effectiveAutoAcceptGroupInvites: boolean = account.autoAcceptGroupInvites ?? false;
158
+ let effectiveGroupInviteAllowlist: string[] = account.groupInviteAllowlist;
159
+ let effectiveAutoDiscoverChannels: boolean = account.autoDiscoverChannels ?? false;
160
+ let effectiveOwnerShip: string | null = account.ownerShip
161
+ ? normalizeShip(account.ownerShip)
162
+ : null;
163
+ let pendingApprovals: PendingApproval[] = [];
164
+ let currentSettings: TlonSettingsStore = {};
165
+
166
+ // Track threads we've participated in (by parentId) - respond without mention requirement
167
+ const participatedThreads = new Set<string>();
168
+
169
+ // Track DM senders per session to detect shared sessions (security warning)
170
+ const dmSendersBySession = new Map<string, Set<string>>();
171
+ let sharedSessionWarningSent = false;
172
+
173
+ // Fetch bot's nickname from contacts
174
+ try {
175
+ const selfProfile = await api.scry("/contacts/v1/self.json");
176
+ if (selfProfile && typeof selfProfile === "object") {
177
+ const profile = selfProfile as { nickname?: { value?: string } };
178
+ botNickname = profile.nickname?.value || null;
179
+ if (botNickname) {
180
+ runtime.log?.(`[tlon] Bot nickname: ${botNickname}`);
181
+ }
182
+ }
183
+ } catch (error: unknown) {
184
+ runtime.log?.(`[tlon] Could not fetch nickname: ${formatErrorMessage(error)}`);
185
+ }
186
+
187
+ // Store init foreigns for processing after settings are loaded
188
+ let initForeigns: Foreigns | null = null;
189
+
190
+ // Migrate file config to settings store (seed on first run)
191
+ async function migrateConfigToSettings() {
192
+ const migrations = buildTlonSettingsMigrations(account, currentSettings);
193
+
194
+ for (const { key, fileValue, settingsValue } of migrations) {
195
+ if (shouldMigrateTlonSetting(fileValue, settingsValue)) {
196
+ try {
197
+ await api!.poke({
198
+ app: "settings",
199
+ mark: "settings-event",
200
+ json: {
201
+ "put-entry": {
202
+ "bucket-key": "tlon",
203
+ "entry-key": key,
204
+ value: fileValue,
205
+ desk: "moltbot",
206
+ },
207
+ },
208
+ });
209
+ runtime.log?.(`[tlon] Migrated ${key} from config to settings store`);
210
+ } catch (err) {
211
+ runtime.log?.(`[tlon] Failed to migrate ${key}: ${String(err)}`);
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ // Load settings from settings store (hot-reloadable config)
218
+ try {
219
+ currentSettings = await settingsManager.load();
220
+
221
+ // Migrate file config to settings store if not already present
222
+ await migrateConfigToSettings();
223
+ ({
224
+ effectiveDmAllowlist,
225
+ effectiveShowModelSig,
226
+ effectiveAutoAcceptDmInvites,
227
+ effectiveAutoAcceptGroupInvites,
228
+ effectiveGroupInviteAllowlist,
229
+ effectiveAutoDiscoverChannels,
230
+ effectiveOwnerShip,
231
+ pendingApprovals,
232
+ currentSettings,
233
+ } = applyTlonSettingsOverrides({
234
+ account,
235
+ currentSettings,
236
+ log: (message) => runtime.log?.(message),
237
+ }));
238
+ } catch (err) {
239
+ runtime.log?.(`[tlon] Settings store not available, using file config: ${String(err)}`);
240
+ }
241
+
242
+ // Run channel discovery AFTER settings are loaded (so settings store value is used)
243
+ if (effectiveAutoDiscoverChannels) {
244
+ try {
245
+ const initData = await fetchInitData(api, runtime);
246
+ if (initData.channels.length > 0) {
247
+ groupChannels = initData.channels;
248
+ }
249
+ initForeigns = initData.foreigns;
250
+ } catch (error: unknown) {
251
+ runtime.error?.(`[tlon] Auto-discovery failed: ${formatErrorMessage(error)}`);
252
+ }
253
+ }
254
+
255
+ // Merge manual config with auto-discovered channels
256
+ if (account.groupChannels.length > 0) {
257
+ groupChannels = mergeUniqueStrings(groupChannels, account.groupChannels);
258
+ runtime.log?.(
259
+ `[tlon] Added ${account.groupChannels.length} manual groupChannels to monitoring`,
260
+ );
261
+ }
262
+
263
+ // Also merge settings store groupChannels (may have been set via tlon settings command)
264
+ groupChannels = mergeUniqueStrings(groupChannels, currentSettings.groupChannels);
265
+
266
+ if (groupChannels.length > 0) {
267
+ runtime.log?.(
268
+ `[tlon] Monitoring ${groupChannels.length} group channel(s): ${groupChannels.join(", ")}`,
269
+ );
270
+ } else {
271
+ runtime.log?.("[tlon] No group channels to monitor (DMs only)");
272
+ }
273
+
274
+ // Check if a ship is the owner (always allowed to DM)
275
+ function isOwner(ship: string): boolean {
276
+ if (!effectiveOwnerShip) {
277
+ return false;
278
+ }
279
+ return normalizeShip(ship) === effectiveOwnerShip;
280
+ }
281
+
282
+ /**
283
+ * Extract the DM partner ship from the 'whom' field.
284
+ * This is the canonical source for DM routing (more reliable than essay.author).
285
+ * Returns empty string if whom doesn't contain a valid patp-like value.
286
+ */
287
+ function extractDmPartnerShip(whom: unknown): string {
288
+ const raw =
289
+ typeof whom === "string"
290
+ ? whom
291
+ : whom && typeof whom === "object" && "ship" in whom && typeof whom.ship === "string"
292
+ ? whom.ship
293
+ : "";
294
+ const normalized = normalizeShip(raw);
295
+ // Keep DM routing strict: accept only patp-like values.
296
+ return /^~?[a-z-]+$/i.test(normalized) ? normalized : "";
297
+ }
298
+
299
+ const processMessage = async (params: {
300
+ messageId: string;
301
+ senderShip: string;
302
+ messageText: string;
303
+ messageContent?: unknown; // Raw Tlon content for media extraction
304
+ isGroup: boolean;
305
+ channelNest?: string;
306
+ hostShip?: string;
307
+ channelName?: string;
308
+ timestamp: number;
309
+ parentId?: string | null;
310
+ isThreadReply?: boolean;
311
+ }) => {
312
+ const {
313
+ messageId,
314
+ senderShip,
315
+ isGroup,
316
+ channelNest,
317
+ hostShip: _hostShip,
318
+ channelName: _channelName,
319
+ timestamp,
320
+ parentId,
321
+ isThreadReply,
322
+ messageContent,
323
+ } = params;
324
+ const groupChannel = channelNest; // For compatibility
325
+ let messageText = params.messageText;
326
+
327
+ // Download any images from the message content
328
+ let attachments: Array<{ path: string; contentType: string }> = [];
329
+ if (messageContent) {
330
+ try {
331
+ attachments = await downloadMessageImages(messageContent);
332
+ if (attachments.length > 0) {
333
+ runtime.log?.(`[tlon] Downloaded ${attachments.length} image(s) from message`);
334
+ }
335
+ } catch (error: unknown) {
336
+ runtime.log?.(`[tlon] Failed to download images: ${formatErrorMessage(error)}`);
337
+ }
338
+ }
339
+
340
+ // Fetch thread context when entering a thread for the first time
341
+ if (isThreadReply && parentId && groupChannel) {
342
+ try {
343
+ const threadHistory = await fetchThreadHistory(api, groupChannel, parentId, 20, runtime);
344
+ if (threadHistory.length > 0) {
345
+ const threadContext = threadHistory
346
+ .slice(-10) // Last 10 messages for context
347
+ .map((msg) => `${msg.author}: ${msg.content}`)
348
+ .join("\n");
349
+
350
+ // Prepend thread context to the message
351
+ // Include note about ongoing conversation for agent judgment
352
+ const contextNote = `[Thread conversation - ${threadHistory.length} previous replies. You are participating in this thread. Only respond if relevant or helpful - you don't need to reply to every message.]`;
353
+ messageText = `${contextNote}\n\n[Previous messages]\n${threadContext}\n\n[Current message]\n${messageText}`;
354
+ runtime?.log?.(
355
+ `[tlon] Added thread context (${threadHistory.length} replies) to message`,
356
+ );
357
+ }
358
+ } catch (error: unknown) {
359
+ runtime?.log?.(`[tlon] Could not fetch thread context: ${formatErrorMessage(error)}`);
360
+ // Continue without thread context - not critical
361
+ }
362
+ }
363
+
364
+ if (isGroup && groupChannel && isSummarizationRequest(messageText)) {
365
+ try {
366
+ const history = await getChannelHistory(api, groupChannel, 50, runtime);
367
+ if (history.length === 0) {
368
+ const noHistoryMsg =
369
+ "I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue.";
370
+ if (isGroup) {
371
+ const parsed = parseChannelNest(groupChannel);
372
+ if (parsed) {
373
+ await sendGroupMessage({
374
+ api: api,
375
+ fromShip: botShipName,
376
+ hostShip: parsed.hostShip,
377
+ channelName: parsed.channelName,
378
+ text: noHistoryMsg,
379
+ });
380
+ }
381
+ } else {
382
+ await sendDm({
383
+ api: api,
384
+ fromShip: botShipName,
385
+ toShip: senderShip,
386
+ text: noHistoryMsg,
387
+ });
388
+ }
389
+ return;
390
+ }
391
+
392
+ const historyText = history
393
+ .map(
394
+ (msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`,
395
+ )
396
+ .join("\n");
397
+
398
+ messageText =
399
+ `Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\n` +
400
+ "Provide a concise summary highlighting:\n" +
401
+ "1. Main topics discussed\n" +
402
+ "2. Key decisions or conclusions\n" +
403
+ "3. Action items if any\n" +
404
+ "4. Notable participants";
405
+ } catch (error: unknown) {
406
+ const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${formatErrorMessage(error)}`;
407
+ if (isGroup && groupChannel) {
408
+ const parsed = parseChannelNest(groupChannel);
409
+ if (parsed) {
410
+ await sendGroupMessage({
411
+ api: api,
412
+ fromShip: botShipName,
413
+ hostShip: parsed.hostShip,
414
+ channelName: parsed.channelName,
415
+ text: errorMsg,
416
+ });
417
+ }
418
+ } else {
419
+ await sendDm({ api: api, fromShip: botShipName, toShip: senderShip, text: errorMsg });
420
+ }
421
+ return;
422
+ }
423
+ }
424
+
425
+ const route = core.channel.routing.resolveAgentRoute({
426
+ cfg,
427
+ channel: "tlon",
428
+ accountId: opts.accountId ?? undefined,
429
+ peer: {
430
+ kind: isGroup ? "group" : "direct",
431
+ id: isGroup ? (groupChannel ?? senderShip) : senderShip,
432
+ },
433
+ });
434
+
435
+ if (!isGroup) {
436
+ const sessionKey = route.sessionKey;
437
+ if (!dmSendersBySession.has(sessionKey)) {
438
+ dmSendersBySession.set(sessionKey, new Set());
439
+ }
440
+ const senders = dmSendersBySession.get(sessionKey)!;
441
+ if (senders.size > 0 && !senders.has(senderShip)) {
442
+ runtime.log?.(
443
+ `[tlon] ⚠️ SECURITY: Multiple users sharing DM session. ` +
444
+ `Configure "session.dmScope: per-channel-peer" in Klaw config.`,
445
+ );
446
+
447
+ if (!sharedSessionWarningSent && effectiveOwnerShip) {
448
+ sharedSessionWarningSent = true;
449
+ const warningMsg =
450
+ `⚠️ Security Warning: Multiple users are sharing a DM session with this bot. ` +
451
+ `This can leak conversation context between users.\n\n` +
452
+ `Fix: Add to your Klaw config:\n` +
453
+ `session:\n dmScope: "per-channel-peer"\n\n` +
454
+ `Docs: https://klaw.kodelyth.com/concepts/session#secure-dm-mode`;
455
+
456
+ sendDm({
457
+ api,
458
+ fromShip: botShipName,
459
+ toShip: effectiveOwnerShip,
460
+ text: warningMsg,
461
+ }).catch((err) =>
462
+ runtime.error?.(`[tlon] Failed to send security warning to owner: ${err}`),
463
+ );
464
+ }
465
+ }
466
+ senders.add(senderShip);
467
+ }
468
+
469
+ const senderRole = isOwner(senderShip) ? "owner" : "user";
470
+ const fromLabel = isGroup
471
+ ? `${senderShip} [${senderRole}] in ${channelNest}`
472
+ : `${senderShip} [${senderRole}]`;
473
+
474
+ const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(
475
+ messageText,
476
+ cfg,
477
+ );
478
+ let commandAuthorized = false;
479
+
480
+ if (shouldComputeAuth) {
481
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
482
+ const commandAccess = await resolveTlonCommandAuthorizationWithIngress({
483
+ senderShip,
484
+ ownerShip: effectiveOwnerShip,
485
+ useAccessGroups,
486
+ });
487
+ commandAuthorized = commandAccess.commandAccess.authorized;
488
+
489
+ if (!commandAuthorized) {
490
+ console.log(
491
+ `[tlon] Command attempt denied: ${senderShip} is not owner (owner=${effectiveOwnerShip ?? "not configured"})`,
492
+ );
493
+ }
494
+ }
495
+
496
+ let bodyWithAttachments = messageText;
497
+ if (attachments.length > 0) {
498
+ const mediaLines = attachments
499
+ .map((a) => `[media attached: ${a.path} (${a.contentType}) | ${a.path}]`)
500
+ .join("\n");
501
+ bodyWithAttachments = mediaLines + "\n" + messageText;
502
+ }
503
+
504
+ const body = core.channel.reply.formatAgentEnvelope({
505
+ channel: "Tlon",
506
+ from: fromLabel,
507
+ timestamp,
508
+ body: bodyWithAttachments,
509
+ });
510
+
511
+ const commandBody = isGroup ? stripBotMention(messageText, botShipName) : messageText;
512
+ const tlonConversationId = isGroup ? (groupChannel ?? channelNest ?? senderShip) : senderShip;
513
+ const ctxPayload = core.channel.turn.buildContext({
514
+ channel: "tlon",
515
+ accountId: route.accountId,
516
+ messageId,
517
+ timestamp,
518
+ from: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
519
+ sender: {
520
+ id: senderShip,
521
+ name: senderShip,
522
+ roles: [senderRole],
523
+ },
524
+ conversation: {
525
+ kind: isGroup ? "group" : "direct",
526
+ id: tlonConversationId,
527
+ label: fromLabel,
528
+ routePeer: {
529
+ kind: isGroup ? "group" : "direct",
530
+ id: tlonConversationId,
531
+ },
532
+ },
533
+ route: {
534
+ agentId: route.agentId,
535
+ accountId: route.accountId,
536
+ routeSessionKey: route.sessionKey,
537
+ },
538
+ reply: {
539
+ to: `tlon:${botShipName}`,
540
+ originatingTo: `tlon:${isGroup ? groupChannel : botShipName}`,
541
+ replyToId: parentId ?? undefined,
542
+ },
543
+ message: {
544
+ body,
545
+ bodyForAgent: commandBody,
546
+ rawBody: messageText,
547
+ commandBody,
548
+ envelopeFrom: fromLabel,
549
+ },
550
+ extra: {
551
+ GroupSubject: undefined,
552
+ SenderRole: senderRole,
553
+ CommandAuthorized: commandAuthorized,
554
+ CommandSource: "text" as const,
555
+ ...(attachments.length > 0 && { Attachments: attachments }),
556
+ ...(parentId && { ThreadId: parentId }),
557
+ },
558
+ });
559
+
560
+ const dispatchStartTime = Date.now();
561
+
562
+ const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(
563
+ cfg,
564
+ route.agentId,
565
+ ).responsePrefix;
566
+ const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
567
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
568
+ agentId: route.agentId,
569
+ });
570
+ const deliveryTarget = isGroup ? groupChannel : senderShip;
571
+
572
+ const prepareReplyPayload = (payload: ReplyPayload): ReplyPayload => {
573
+ const replyText = payload.text;
574
+ if (!replyText) {
575
+ return payload;
576
+ }
577
+ if (!effectiveShowModelSig) {
578
+ return payload;
579
+ }
580
+ const extPayload = payload as {
581
+ metadata?: { model?: string };
582
+ model?: string;
583
+ };
584
+ const defaultModel = cfg.agents?.defaults?.model;
585
+ const modelInfo =
586
+ extPayload.metadata?.model ||
587
+ extPayload.model ||
588
+ (typeof defaultModel === "string" ? defaultModel : defaultModel?.primary);
589
+ return {
590
+ ...payload,
591
+ text: `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`,
592
+ };
593
+ };
594
+
595
+ const rememberThreadParticipation = (result: { visibleReplySent?: boolean } | void) => {
596
+ if (!isGroup || !groupChannel || !parentId || result?.visibleReplySent === false) {
597
+ return;
598
+ }
599
+ participatedThreads.add(parentId);
600
+ runtime.log?.(`[tlon] Now tracking thread for future replies: ${parentId}`);
601
+ };
602
+
603
+ await core.channel.turn.runAssembled({
604
+ channel: "tlon",
605
+ accountId: route.accountId,
606
+ cfg,
607
+ agentId: route.agentId,
608
+ routeSessionKey: route.sessionKey,
609
+ storePath,
610
+ ctxPayload,
611
+ recordInboundSession: core.channel.session.recordInboundSession,
612
+ dispatchReplyWithBufferedBlockDispatcher:
613
+ core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
614
+ delivery: {
615
+ preparePayload: prepareReplyPayload,
616
+ durable: deliveryTarget
617
+ ? () => ({
618
+ to: deliveryTarget,
619
+ replyToId: parentId ?? undefined,
620
+ threadId: parentId ?? undefined,
621
+ })
622
+ : false,
623
+ deliver: async (payload: ReplyPayload) => {
624
+ const replyText = payload.text;
625
+ if (!replyText) {
626
+ return { visibleReplySent: false };
627
+ }
628
+
629
+ if (isGroup && groupChannel) {
630
+ const parsed = parseChannelNest(groupChannel);
631
+ if (!parsed) {
632
+ return { visibleReplySent: false };
633
+ }
634
+ await sendGroupMessage({
635
+ api: api,
636
+ fromShip: botShipName,
637
+ hostShip: parsed.hostShip,
638
+ channelName: parsed.channelName,
639
+ text: replyText,
640
+ replyToId: parentId ?? undefined,
641
+ });
642
+ return { visibleReplySent: true, replyToId: parentId ?? undefined };
643
+ }
644
+
645
+ await sendDm({
646
+ api: api,
647
+ fromShip: botShipName,
648
+ toShip: senderShip,
649
+ text: replyText,
650
+ });
651
+ return { visibleReplySent: true };
652
+ },
653
+ onDelivered: (_payload, _info, result) => {
654
+ rememberThreadParticipation(result);
655
+ },
656
+ onError: (err, info) => {
657
+ const dispatchDuration = Date.now() - dispatchStartTime;
658
+ runtime.error?.(
659
+ `[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`,
660
+ );
661
+ },
662
+ },
663
+ dispatcherOptions: {
664
+ responsePrefix,
665
+ humanDelay,
666
+ },
667
+ record: {
668
+ onRecordError: (err) => {
669
+ runtime.error?.(`[tlon] failed updating session meta: ${String(err)}`);
670
+ },
671
+ },
672
+ });
673
+ };
674
+
675
+ // Track which channels we're interested in for filtering firehose events
676
+ const watchedChannels = new Set<string>(groupChannels);
677
+
678
+ const refreshWatchedChannels = async (): Promise<number> => {
679
+ const discoveredChannels = await fetchAllChannels(api, runtime);
680
+ let newCount = 0;
681
+ for (const channelNest of discoveredChannels) {
682
+ if (!watchedChannels.has(channelNest)) {
683
+ watchedChannels.add(channelNest);
684
+ newCount++;
685
+ }
686
+ }
687
+ return newCount;
688
+ };
689
+
690
+ const { resolveAllCites } = createTlonCitationResolver({
691
+ api: { scry: (path) => api.scry(path) },
692
+ runtime,
693
+ });
694
+
695
+ const { queueApprovalRequest, handleApprovalResponse, handleAdminCommand } =
696
+ createTlonApprovalRuntime({
697
+ api: {
698
+ poke: (payload) => api.poke(payload),
699
+ scry: (path) => api.scry(path),
700
+ },
701
+ runtime,
702
+ botShipName,
703
+ getPendingApprovals: () => pendingApprovals,
704
+ setPendingApprovals: (approvals) => {
705
+ pendingApprovals = approvals;
706
+ },
707
+ getCurrentSettings: () => currentSettings,
708
+ setCurrentSettings: (settings) => {
709
+ currentSettings = settings;
710
+ },
711
+ getEffectiveDmAllowlist: () => effectiveDmAllowlist,
712
+ setEffectiveDmAllowlist: (ships) => {
713
+ effectiveDmAllowlist = ships;
714
+ },
715
+ getEffectiveOwnerShip: () => effectiveOwnerShip,
716
+ processApprovedMessage: async (approval) => {
717
+ if (!approval.originalMessage) {
718
+ return;
719
+ }
720
+ if (approval.type === "dm") {
721
+ await processMessage({
722
+ messageId: approval.originalMessage.messageId,
723
+ senderShip: approval.requestingShip,
724
+ messageText: approval.originalMessage.messageText,
725
+ messageContent: approval.originalMessage.messageContent,
726
+ isGroup: false,
727
+ timestamp: approval.originalMessage.timestamp,
728
+ });
729
+ return;
730
+ }
731
+ if (approval.type === "channel" && approval.channelNest) {
732
+ const parsedChannel = parseChannelNest(approval.channelNest);
733
+ await processMessage({
734
+ messageId: approval.originalMessage.messageId,
735
+ senderShip: approval.requestingShip,
736
+ messageText: approval.originalMessage.messageText,
737
+ messageContent: approval.originalMessage.messageContent,
738
+ isGroup: true,
739
+ channelNest: approval.channelNest,
740
+ hostShip: parsedChannel?.hostShip,
741
+ channelName: parsedChannel?.channelName,
742
+ timestamp: approval.originalMessage.timestamp,
743
+ parentId: approval.originalMessage.parentId,
744
+ isThreadReply: approval.originalMessage.isThreadReply,
745
+ });
746
+ }
747
+ },
748
+ refreshWatchedChannels,
749
+ });
750
+
751
+ // Firehose handler for all channel messages (/v2)
752
+ const handleChannelsFirehose = async (event: unknown) => {
753
+ try {
754
+ const eventRecord = asRecord(event);
755
+ const nest = readString(eventRecord, "nest");
756
+ if (!nest) {
757
+ return;
758
+ }
759
+
760
+ // Only process channels we're watching
761
+ if (!watchedChannels.has(nest)) {
762
+ return;
763
+ }
764
+
765
+ const response = asRecord(eventRecord?.response);
766
+ if (!response) {
767
+ return;
768
+ }
769
+
770
+ // Handle post responses (new posts and replies)
771
+ const post = asRecord(response.post);
772
+ const rPost = asRecord(post?.["r-post"]);
773
+ const set = asRecord(rPost?.set);
774
+ const reply = asRecord(rPost?.reply);
775
+ const replyPayload = asRecord(reply?.["r-reply"]);
776
+ const replySet = asRecord(replyPayload?.set);
777
+ const essay = asRecord(set?.essay);
778
+ const memo = asRecord(replySet?.memo);
779
+ if (!essay && !memo) {
780
+ return;
781
+ }
782
+
783
+ const content = memo ?? essay;
784
+ if (!content) {
785
+ return;
786
+ }
787
+ const isThreadReply = Boolean(memo);
788
+ const messageId = isThreadReply ? readString(reply, "id") : readString(post, "id");
789
+ if (!messageId) {
790
+ return;
791
+ }
792
+
793
+ const processed = await runWithProcessedMessageClaim({
794
+ tracker: processedTracker,
795
+ id: messageId,
796
+ task: async () => {
797
+ const senderShip = normalizeShip(readString(content, "author") ?? "");
798
+ if (!senderShip || senderShip === botShipName) {
799
+ return;
800
+ }
801
+
802
+ const rawText = extractMessageText(content.content);
803
+ if (!rawText.trim()) {
804
+ return;
805
+ }
806
+
807
+ const contentBody = content.content;
808
+ const sentAt = readNumber(content, "sent") ?? Date.now();
809
+
810
+ cacheMessage(nest, {
811
+ author: senderShip,
812
+ content: rawText,
813
+ timestamp: sentAt,
814
+ id: messageId,
815
+ });
816
+
817
+ // Get thread info early for participation check
818
+ const seal = isThreadReply ? asRecord(replySet?.seal) : asRecord(set?.seal);
819
+ const parentId = readString(seal, "parent-id") ?? readString(seal, "parent") ?? null;
820
+
821
+ // Check if we should respond:
822
+ // 1. Direct mention always triggers response
823
+ // 2. Thread replies where we've participated - respond if relevant (let agent decide)
824
+ const mentioned = isBotMentioned(rawText, botShipName, botNickname ?? undefined);
825
+ const inParticipatedThread =
826
+ isThreadReply && parentId && participatedThreads.has(parentId);
827
+
828
+ if (!mentioned && !inParticipatedThread) {
829
+ return;
830
+ }
831
+
832
+ // Log why we're responding
833
+ if (inParticipatedThread && !mentioned) {
834
+ runtime.log?.(
835
+ `[tlon] Responding to thread we participated in (no mention): ${parentId}`,
836
+ );
837
+ }
838
+
839
+ // Owner is always allowed
840
+ if (isOwner(senderShip)) {
841
+ runtime.log?.(`[tlon] Owner ${senderShip} is always allowed in channels`);
842
+ } else {
843
+ const { mode, allowedShips } = resolveChannelAuthorization(cfg, nest, currentSettings);
844
+ if (mode === "restricted") {
845
+ const normalizedAllowed = allowedShips.map(normalizeShip);
846
+ if (!normalizedAllowed.includes(senderShip)) {
847
+ // If owner is configured, queue approval request
848
+ if (effectiveOwnerShip) {
849
+ const approval = createPendingApproval({
850
+ type: "channel",
851
+ requestingShip: senderShip,
852
+ channelNest: nest,
853
+ messagePreview: rawText.slice(0, 100),
854
+ originalMessage: {
855
+ messageId: messageId ?? "",
856
+ messageText: rawText,
857
+ messageContent: contentBody,
858
+ timestamp: sentAt,
859
+ parentId: parentId ?? undefined,
860
+ isThreadReply,
861
+ },
862
+ });
863
+ await queueApprovalRequest(approval);
864
+ } else {
865
+ runtime.log?.(
866
+ `[tlon] Access denied: ${senderShip} in ${nest} (allowed: ${allowedShips.join(", ")})`,
867
+ );
868
+ }
869
+ return;
870
+ }
871
+ }
872
+ }
873
+
874
+ const messageText = await resolveAuthorizedMessageText({
875
+ rawText,
876
+ content: contentBody,
877
+ authorizedForCites: true,
878
+ resolveAllCites,
879
+ });
880
+
881
+ const parsed = parseChannelNest(nest);
882
+ await processMessage({
883
+ messageId: messageId ?? "",
884
+ senderShip,
885
+ messageText,
886
+ messageContent: contentBody, // Pass raw content for media extraction
887
+ isGroup: true,
888
+ channelNest: nest,
889
+ hostShip: parsed?.hostShip,
890
+ channelName: parsed?.channelName,
891
+ timestamp: sentAt,
892
+ parentId,
893
+ isThreadReply,
894
+ });
895
+ },
896
+ });
897
+ if (processed.kind === "duplicate") {
898
+ return;
899
+ }
900
+ } catch (error: unknown) {
901
+ runtime.error?.(`[tlon] Error handling channel firehose event: ${formatErrorMessage(error)}`);
902
+ }
903
+ };
904
+
905
+ // Firehose handler for all DM messages (/v3)
906
+ // Track which DM invites we've already processed to avoid duplicate accepts
907
+ const processedDmInvites = new Set<string>();
908
+
909
+ const handleChatFirehose = async (event: unknown) => {
910
+ try {
911
+ // Handle DM invite lists (arrays)
912
+ if (Array.isArray(event)) {
913
+ for (const invite of event as DmInvite[]) {
914
+ const ship = normalizeShip(invite.ship || "");
915
+ if (!ship || processedDmInvites.has(ship)) {
916
+ continue;
917
+ }
918
+
919
+ // Owner is always allowed
920
+ if (isOwner(ship)) {
921
+ try {
922
+ await api.poke({
923
+ app: "chat",
924
+ mark: "chat-dm-rsvp",
925
+ json: { ship, ok: true },
926
+ });
927
+ processedDmInvites.add(ship);
928
+ runtime.log?.(`[tlon] Auto-accepted DM invite from owner ${ship}`);
929
+ } catch (err) {
930
+ runtime.error?.(`[tlon] Failed to auto-accept DM from owner: ${String(err)}`);
931
+ }
932
+ continue;
933
+ }
934
+
935
+ // Auto-accept if on allowlist and auto-accept is enabled
936
+ if (
937
+ effectiveAutoAcceptDmInvites &&
938
+ (await isDmAllowedWithIngress(ship, effectiveDmAllowlist))
939
+ ) {
940
+ try {
941
+ await api.poke({
942
+ app: "chat",
943
+ mark: "chat-dm-rsvp",
944
+ json: { ship, ok: true },
945
+ });
946
+ processedDmInvites.add(ship);
947
+ runtime.log?.(`[tlon] Auto-accepted DM invite from ${ship}`);
948
+ } catch (err) {
949
+ runtime.error?.(`[tlon] Failed to auto-accept DM from ${ship}: ${String(err)}`);
950
+ }
951
+ continue;
952
+ }
953
+
954
+ // If owner is configured and ship is not on allowlist, queue approval
955
+ if (effectiveOwnerShip && !(await isDmAllowedWithIngress(ship, effectiveDmAllowlist))) {
956
+ const approval = createPendingApproval({
957
+ type: "dm",
958
+ requestingShip: ship,
959
+ messagePreview: "(DM invite - no message yet)",
960
+ });
961
+ await queueApprovalRequest(approval);
962
+ processedDmInvites.add(ship); // Mark as processed to avoid duplicate notifications
963
+ }
964
+ }
965
+ return;
966
+ }
967
+ const eventRecord = asRecord(event);
968
+ if (!eventRecord) {
969
+ return;
970
+ }
971
+
972
+ const whom = eventRecord.whom; // DM partner ship or club ID
973
+ const messageId = readString(eventRecord, "id");
974
+ const response = asRecord(eventRecord.response);
975
+ if (!messageId || !response) {
976
+ return;
977
+ }
978
+
979
+ // Handle add events (new messages)
980
+ const essay = asRecord(asRecord(response.add)?.essay);
981
+ if (!essay) {
982
+ return;
983
+ }
984
+
985
+ const processed = await runWithProcessedMessageClaim({
986
+ tracker: processedTracker,
987
+ id: messageId,
988
+ task: async () => {
989
+ const authorShip = normalizeShip(readString(essay, "author") ?? "");
990
+ const partnerShip = extractDmPartnerShip(whom);
991
+ const senderShip = partnerShip || authorShip;
992
+
993
+ // Ignore the bot's own outbound DM events.
994
+ if (authorShip === botShipName) {
995
+ return;
996
+ }
997
+ if (!senderShip || senderShip === botShipName) {
998
+ return;
999
+ }
1000
+
1001
+ // Log mismatch between author and partner for debugging
1002
+ if (authorShip && partnerShip && authorShip !== partnerShip) {
1003
+ runtime.log?.(
1004
+ `[tlon] DM ship mismatch (author=${authorShip}, partner=${partnerShip}) - routing to partner`,
1005
+ );
1006
+ }
1007
+
1008
+ const rawText = extractMessageText(essay.content);
1009
+ if (!rawText.trim()) {
1010
+ return;
1011
+ }
1012
+
1013
+ // Check if this is the owner sending an approval response
1014
+ const messageText = rawText;
1015
+ if (isOwner(senderShip) && isApprovalResponse(messageText)) {
1016
+ const handled = await handleApprovalResponse(messageText);
1017
+ if (handled) {
1018
+ runtime.log?.(`[tlon] Processed approval response from owner: ${messageText}`);
1019
+ return;
1020
+ }
1021
+ }
1022
+
1023
+ // Check if this is the owner sending an admin command
1024
+ if (isOwner(senderShip) && isAdminCommand(messageText)) {
1025
+ const handled = await handleAdminCommand(messageText);
1026
+ if (handled) {
1027
+ runtime.log?.(`[tlon] Processed admin command from owner: ${messageText}`);
1028
+ return;
1029
+ }
1030
+ }
1031
+
1032
+ // Owner is always allowed to DM (bypass allowlist)
1033
+ if (isOwner(senderShip)) {
1034
+ const resolvedMessageText = await resolveAuthorizedMessageText({
1035
+ rawText,
1036
+ content: essay.content,
1037
+ authorizedForCites: true,
1038
+ resolveAllCites,
1039
+ });
1040
+ runtime.log?.(`[tlon] Processing DM from owner ${senderShip}`);
1041
+ await processMessage({
1042
+ messageId: messageId ?? "",
1043
+ senderShip,
1044
+ messageText: resolvedMessageText,
1045
+ messageContent: essay.content,
1046
+ isGroup: false,
1047
+ timestamp: readNumber(essay, "sent") ?? Date.now(),
1048
+ });
1049
+ return;
1050
+ }
1051
+
1052
+ // For DMs from others, check allowlist
1053
+ if (!(await isDmAllowedWithIngress(senderShip, effectiveDmAllowlist))) {
1054
+ // If owner is configured, queue approval request
1055
+ if (effectiveOwnerShip) {
1056
+ const approval = createPendingApproval({
1057
+ type: "dm",
1058
+ requestingShip: senderShip,
1059
+ messagePreview: messageText.slice(0, 100),
1060
+ originalMessage: {
1061
+ messageId: messageId ?? "",
1062
+ messageText,
1063
+ messageContent: essay.content,
1064
+ timestamp: readNumber(essay, "sent") ?? Date.now(),
1065
+ },
1066
+ });
1067
+ await queueApprovalRequest(approval);
1068
+ } else {
1069
+ runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
1070
+ }
1071
+ return;
1072
+ }
1073
+
1074
+ await processMessage({
1075
+ messageText: await resolveAuthorizedMessageText({
1076
+ rawText,
1077
+ content: essay.content,
1078
+ authorizedForCites: true,
1079
+ resolveAllCites,
1080
+ }),
1081
+ messageId: messageId ?? "",
1082
+ senderShip,
1083
+ messageContent: essay.content, // Pass raw content for media extraction
1084
+ isGroup: false,
1085
+ timestamp: readNumber(essay, "sent") ?? Date.now(),
1086
+ });
1087
+ },
1088
+ });
1089
+ if (processed.kind === "duplicate") {
1090
+ return;
1091
+ }
1092
+ } catch (error: unknown) {
1093
+ runtime.error?.(`[tlon] Error handling chat firehose event: ${formatErrorMessage(error)}`);
1094
+ }
1095
+ };
1096
+
1097
+ try {
1098
+ runtime.log?.("[tlon] Subscribing to firehose updates...");
1099
+
1100
+ // Subscribe to channels firehose (/v2)
1101
+ await api.subscribe({
1102
+ app: "channels",
1103
+ path: "/v2",
1104
+ event: handleChannelsFirehose,
1105
+ err: (error) => {
1106
+ runtime.error?.(`[tlon] Channels firehose error: ${String(error)}`);
1107
+ },
1108
+ quit: () => {
1109
+ runtime.log?.("[tlon] Channels firehose subscription ended");
1110
+ },
1111
+ });
1112
+ runtime.log?.("[tlon] Subscribed to channels firehose (/v2)");
1113
+
1114
+ // Subscribe to chat/DM firehose (/v3)
1115
+ await api.subscribe({
1116
+ app: "chat",
1117
+ path: "/v3",
1118
+ event: handleChatFirehose,
1119
+ err: (error) => {
1120
+ runtime.error?.(`[tlon] Chat firehose error: ${String(error)}`);
1121
+ },
1122
+ quit: () => {
1123
+ runtime.log?.("[tlon] Chat firehose subscription ended");
1124
+ },
1125
+ });
1126
+ runtime.log?.("[tlon] Subscribed to chat firehose (/v3)");
1127
+
1128
+ // Subscribe to contacts updates to track nickname changes
1129
+ await api.subscribe({
1130
+ app: "contacts",
1131
+ path: "/v1/news",
1132
+ event: (event: unknown) => {
1133
+ try {
1134
+ const eventRecord = asRecord(event);
1135
+ // Look for self profile updates
1136
+ if (eventRecord?.self) {
1137
+ const selfUpdate = asRecord(eventRecord.self);
1138
+ const contact = asRecord(selfUpdate?.contact);
1139
+ const nickname = asRecord(contact?.nickname);
1140
+ if (nickname && "value" in nickname) {
1141
+ const newNickname = readString(nickname, "value") ?? null;
1142
+ if (newNickname !== botNickname) {
1143
+ botNickname = newNickname;
1144
+ runtime.log?.(`[tlon] Nickname updated: ${botNickname}`);
1145
+ }
1146
+ }
1147
+ }
1148
+ } catch (error: unknown) {
1149
+ runtime.error?.(`[tlon] Error handling contacts event: ${formatErrorMessage(error)}`);
1150
+ }
1151
+ },
1152
+ err: (error) => {
1153
+ runtime.error?.(`[tlon] Contacts subscription error: ${String(error)}`);
1154
+ },
1155
+ quit: () => {
1156
+ runtime.log?.("[tlon] Contacts subscription ended");
1157
+ },
1158
+ });
1159
+ runtime.log?.("[tlon] Subscribed to contacts updates (/v1/news)");
1160
+
1161
+ // Subscribe to settings store for hot-reloading config
1162
+ settingsManager.onChange((newSettings) => {
1163
+ currentSettings = newSettings;
1164
+
1165
+ // Update watched channels if settings changed
1166
+ if (newSettings.groupChannels?.length) {
1167
+ const newChannels = newSettings.groupChannels;
1168
+ for (const ch of newChannels) {
1169
+ if (!watchedChannels.has(ch)) {
1170
+ watchedChannels.add(ch);
1171
+ runtime.log?.(`[tlon] Settings: now watching channel ${ch}`);
1172
+ }
1173
+ }
1174
+ // Note: we don't remove channels from watchedChannels to avoid missing messages
1175
+ // during transitions. The authorization check handles access control.
1176
+ }
1177
+
1178
+ // Recompute effective settings from the latest snapshot so deletions
1179
+ // cleanly fall back to file config and empty arrays remain authoritative.
1180
+ ({
1181
+ effectiveDmAllowlist,
1182
+ effectiveShowModelSig,
1183
+ effectiveAutoAcceptDmInvites,
1184
+ effectiveAutoAcceptGroupInvites,
1185
+ effectiveGroupInviteAllowlist,
1186
+ effectiveAutoDiscoverChannels,
1187
+ effectiveOwnerShip,
1188
+ pendingApprovals,
1189
+ } = applyTlonSettingsOverrides({
1190
+ account,
1191
+ currentSettings: newSettings,
1192
+ log: (message) => runtime.log?.(message),
1193
+ }));
1194
+ });
1195
+
1196
+ try {
1197
+ await settingsManager.startSubscription();
1198
+ } catch (err) {
1199
+ // Settings subscription is optional - don't fail if it doesn't work
1200
+ runtime.log?.(`[tlon] Settings subscription not available: ${String(err)}`);
1201
+ }
1202
+
1203
+ // Subscribe to groups-ui for real-time channel additions (when invites are accepted)
1204
+ try {
1205
+ await api.subscribe({
1206
+ app: "groups",
1207
+ path: "/groups/ui",
1208
+ event: async (event: unknown) => {
1209
+ try {
1210
+ const eventRecord = asRecord(event);
1211
+ // Handle group/channel join events
1212
+ // Event structure: { group: { flag: "~host/group-name", ... }, channels: { ... } }
1213
+ if (eventRecord) {
1214
+ // Check for new channels being added to groups
1215
+ const channels = asRecord(eventRecord.channels);
1216
+ if (channels) {
1217
+ for (const [channelNest, _channelData] of Object.entries(channels)) {
1218
+ // Only monitor chat channels
1219
+ if (!channelNest.startsWith("chat/")) {
1220
+ continue;
1221
+ }
1222
+
1223
+ // If this is a new channel we're not watching yet, add it
1224
+ if (!watchedChannels.has(channelNest)) {
1225
+ watchedChannels.add(channelNest);
1226
+ runtime.log?.(
1227
+ `[tlon] Auto-detected new channel (invite accepted): ${channelNest}`,
1228
+ );
1229
+
1230
+ // Persist to settings store so it survives restarts
1231
+ if (effectiveAutoAcceptGroupInvites) {
1232
+ try {
1233
+ const currentChannels = currentSettings.groupChannels || [];
1234
+ if (!currentChannels.includes(channelNest)) {
1235
+ const updatedChannels = [...currentChannels, channelNest];
1236
+ // Poke settings store to persist
1237
+ await api.poke({
1238
+ app: "settings",
1239
+ mark: "settings-event",
1240
+ json: {
1241
+ "put-entry": {
1242
+ "bucket-key": "tlon",
1243
+ "entry-key": "groupChannels",
1244
+ value: updatedChannels,
1245
+ desk: "moltbot",
1246
+ },
1247
+ },
1248
+ });
1249
+ runtime.log?.(`[tlon] Persisted ${channelNest} to settings store`);
1250
+ }
1251
+ } catch (err) {
1252
+ runtime.error?.(
1253
+ `[tlon] Failed to persist channel to settings: ${String(err)}`,
1254
+ );
1255
+ }
1256
+ }
1257
+ }
1258
+ }
1259
+ }
1260
+
1261
+ // Also check for the "join" event structure
1262
+ const join = asRecord(eventRecord.join);
1263
+ if (join) {
1264
+ const joinChannels = Array.isArray(join.channels) ? join.channels : [];
1265
+ if (joinChannels.length > 0) {
1266
+ for (const channelNest of joinChannels) {
1267
+ if (typeof channelNest !== "string") {
1268
+ continue;
1269
+ }
1270
+ if (!channelNest.startsWith("chat/")) {
1271
+ continue;
1272
+ }
1273
+ if (!watchedChannels.has(channelNest)) {
1274
+ watchedChannels.add(channelNest);
1275
+ runtime.log?.(`[tlon] Auto-detected joined channel: ${channelNest}`);
1276
+
1277
+ // Persist to settings store
1278
+ if (effectiveAutoAcceptGroupInvites) {
1279
+ try {
1280
+ const currentChannels = currentSettings.groupChannels || [];
1281
+ if (!currentChannels.includes(channelNest)) {
1282
+ const updatedChannels = [...currentChannels, channelNest];
1283
+ await api.poke({
1284
+ app: "settings",
1285
+ mark: "settings-event",
1286
+ json: {
1287
+ "put-entry": {
1288
+ "bucket-key": "tlon",
1289
+ "entry-key": "groupChannels",
1290
+ value: updatedChannels,
1291
+ desk: "moltbot",
1292
+ },
1293
+ },
1294
+ });
1295
+ runtime.log?.(`[tlon] Persisted ${channelNest} to settings store`);
1296
+ }
1297
+ } catch (err) {
1298
+ runtime.error?.(
1299
+ `[tlon] Failed to persist channel to settings: ${String(err)}`,
1300
+ );
1301
+ }
1302
+ }
1303
+ }
1304
+ }
1305
+ }
1306
+ }
1307
+ }
1308
+ } catch (error: unknown) {
1309
+ runtime.error?.(`[tlon] Error handling groups-ui event: ${formatErrorMessage(error)}`);
1310
+ }
1311
+ },
1312
+ err: (error) => {
1313
+ runtime.error?.(`[tlon] Groups-ui subscription error: ${String(error)}`);
1314
+ },
1315
+ quit: () => {
1316
+ runtime.log?.("[tlon] Groups-ui subscription ended");
1317
+ },
1318
+ });
1319
+ runtime.log?.("[tlon] Subscribed to groups-ui for real-time channel detection");
1320
+ } catch (err) {
1321
+ // Groups-ui subscription is optional - channel discovery will still work via polling
1322
+ runtime.log?.(`[tlon] Groups-ui subscription failed (will rely on polling): ${String(err)}`);
1323
+ }
1324
+
1325
+ // Subscribe to foreigns for auto-accepting group invites
1326
+ // Always subscribe so we can hot-reload the setting via settings store
1327
+ {
1328
+ const processedGroupInvites = new Set<string>();
1329
+
1330
+ // Helper to process pending invites
1331
+ const processPendingInvites = async (foreigns: Foreigns) => {
1332
+ if (!foreigns || typeof foreigns !== "object") {
1333
+ return;
1334
+ }
1335
+
1336
+ for (const [groupFlag, foreign] of Object.entries(foreigns)) {
1337
+ if (processedGroupInvites.has(groupFlag)) {
1338
+ continue;
1339
+ }
1340
+ if (!foreign.invites || foreign.invites.length === 0) {
1341
+ continue;
1342
+ }
1343
+
1344
+ const validInvite = foreign.invites.find((inv) => inv.valid);
1345
+ if (!validInvite) {
1346
+ continue;
1347
+ }
1348
+
1349
+ const inviterShip = validInvite.from;
1350
+ // Owner invites are always accepted
1351
+ if (isOwner(inviterShip)) {
1352
+ try {
1353
+ await api.poke({
1354
+ app: "groups",
1355
+ mark: "group-join",
1356
+ json: {
1357
+ flag: groupFlag,
1358
+ "join-all": true,
1359
+ },
1360
+ });
1361
+ processedGroupInvites.add(groupFlag);
1362
+ runtime.log?.(`[tlon] Auto-accepted group invite from owner: ${groupFlag}`);
1363
+ } catch (err) {
1364
+ runtime.error?.(`[tlon] Failed to accept group invite from owner: ${String(err)}`);
1365
+ }
1366
+ continue;
1367
+ }
1368
+
1369
+ // Skip if auto-accept is disabled
1370
+ if (!effectiveAutoAcceptGroupInvites) {
1371
+ // If owner is configured, queue approval
1372
+ if (effectiveOwnerShip) {
1373
+ const approval = createPendingApproval({
1374
+ type: "group",
1375
+ requestingShip: inviterShip,
1376
+ groupFlag,
1377
+ });
1378
+ await queueApprovalRequest(approval);
1379
+ processedGroupInvites.add(groupFlag);
1380
+ }
1381
+ continue;
1382
+ }
1383
+
1384
+ // Check if inviter is on allowlist
1385
+ const isAllowed = isGroupInviteAllowed(inviterShip, effectiveGroupInviteAllowlist);
1386
+
1387
+ if (!isAllowed) {
1388
+ // If owner is configured, queue approval
1389
+ if (effectiveOwnerShip) {
1390
+ const approval = createPendingApproval({
1391
+ type: "group",
1392
+ requestingShip: inviterShip,
1393
+ groupFlag,
1394
+ });
1395
+ await queueApprovalRequest(approval);
1396
+ processedGroupInvites.add(groupFlag);
1397
+ } else {
1398
+ runtime.log?.(
1399
+ `[tlon] Rejected group invite from ${inviterShip} (not in groupInviteAllowlist): ${groupFlag}`,
1400
+ );
1401
+ processedGroupInvites.add(groupFlag);
1402
+ }
1403
+ continue;
1404
+ }
1405
+
1406
+ // Inviter is on allowlist - accept the invite
1407
+ try {
1408
+ await api.poke({
1409
+ app: "groups",
1410
+ mark: "group-join",
1411
+ json: {
1412
+ flag: groupFlag,
1413
+ "join-all": true,
1414
+ },
1415
+ });
1416
+ processedGroupInvites.add(groupFlag);
1417
+ runtime.log?.(
1418
+ `[tlon] Auto-accepted group invite: ${groupFlag} (from ${validInvite.from})`,
1419
+ );
1420
+ } catch (err) {
1421
+ runtime.error?.(`[tlon] Failed to auto-accept group ${groupFlag}: ${String(err)}`);
1422
+ }
1423
+ }
1424
+ };
1425
+
1426
+ // Process existing pending invites from init data
1427
+ if (initForeigns) {
1428
+ await processPendingInvites(initForeigns);
1429
+ }
1430
+
1431
+ try {
1432
+ await api.subscribe({
1433
+ app: "groups",
1434
+ path: "/v1/foreigns",
1435
+ event: (data: unknown) => {
1436
+ void (async () => {
1437
+ try {
1438
+ await processPendingInvites(data as Foreigns);
1439
+ } catch (error: unknown) {
1440
+ runtime.error?.(
1441
+ `[tlon] Error handling foreigns event: ${formatErrorMessage(error)}`,
1442
+ );
1443
+ }
1444
+ })();
1445
+ },
1446
+ err: (error) => {
1447
+ runtime.error?.(`[tlon] Foreigns subscription error: ${String(error)}`);
1448
+ },
1449
+ quit: () => {
1450
+ runtime.log?.("[tlon] Foreigns subscription ended");
1451
+ },
1452
+ });
1453
+ runtime.log?.(
1454
+ "[tlon] Subscribed to foreigns (/v1/foreigns) for auto-accepting group invites",
1455
+ );
1456
+ } catch (err) {
1457
+ runtime.log?.(`[tlon] Foreigns subscription failed: ${String(err)}`);
1458
+ }
1459
+ }
1460
+
1461
+ // Discover channels to watch
1462
+ if (effectiveAutoDiscoverChannels) {
1463
+ const discoveredChannels = await fetchAllChannels(api, runtime);
1464
+ for (const channelNest of discoveredChannels) {
1465
+ watchedChannels.add(channelNest);
1466
+ }
1467
+ runtime.log?.(`[tlon] Watching ${watchedChannels.size} channel(s)`);
1468
+ }
1469
+
1470
+ // Log watched channels
1471
+ for (const channelNest of watchedChannels) {
1472
+ runtime.log?.(`[tlon] Watching channel: ${channelNest}`);
1473
+ }
1474
+
1475
+ runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream...");
1476
+ await api.connect();
1477
+ runtime.log?.("[tlon] Connected! Firehose subscriptions active");
1478
+
1479
+ // Periodically refresh channel discovery
1480
+ const pollInterval = setInterval(
1481
+ async () => {
1482
+ if (!opts.abortSignal?.aborted) {
1483
+ try {
1484
+ if (effectiveAutoDiscoverChannels) {
1485
+ const discoveredChannels = await fetchAllChannels(api, runtime);
1486
+ for (const channelNest of discoveredChannels) {
1487
+ if (!watchedChannels.has(channelNest)) {
1488
+ watchedChannels.add(channelNest);
1489
+ runtime.log?.(`[tlon] Now watching new channel: ${channelNest}`);
1490
+ }
1491
+ }
1492
+ }
1493
+ } catch (error: unknown) {
1494
+ runtime.error?.(`[tlon] Channel refresh error: ${formatErrorMessage(error)}`);
1495
+ }
1496
+ }
1497
+ },
1498
+ 2 * 60 * 1000,
1499
+ );
1500
+
1501
+ if (opts.abortSignal) {
1502
+ const signal = opts.abortSignal;
1503
+ await new Promise((resolve) => {
1504
+ signal.addEventListener(
1505
+ "abort",
1506
+ () => {
1507
+ clearInterval(pollInterval);
1508
+ resolve(null);
1509
+ },
1510
+ { once: true },
1511
+ );
1512
+ });
1513
+ } else {
1514
+ await new Promise(() => {});
1515
+ }
1516
+ } finally {
1517
+ try {
1518
+ await api?.close();
1519
+ } catch (error: unknown) {
1520
+ runtime.error?.(`[tlon] Cleanup error: ${formatErrorMessage(error)}`);
1521
+ }
1522
+ }
1523
+ }