@tloncorp/openclaw 0.1.0

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 (63) hide show
  1. package/README.md +174 -0
  2. package/dist/index.js +190 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/src/account-fields.js +17 -0
  5. package/dist/src/account-fields.js.map +1 -0
  6. package/dist/src/actions.js +164 -0
  7. package/dist/src/actions.js.map +1 -0
  8. package/dist/src/channel.js +400 -0
  9. package/dist/src/channel.js.map +1 -0
  10. package/dist/src/config-schema.js +55 -0
  11. package/dist/src/config-schema.js.map +1 -0
  12. package/dist/src/monitor/approval.js +194 -0
  13. package/dist/src/monitor/approval.js.map +1 -0
  14. package/dist/src/monitor/discovery.js +64 -0
  15. package/dist/src/monitor/discovery.js.map +1 -0
  16. package/dist/src/monitor/history.js +158 -0
  17. package/dist/src/monitor/history.js.map +1 -0
  18. package/dist/src/monitor/index.js +1940 -0
  19. package/dist/src/monitor/index.js.map +1 -0
  20. package/dist/src/monitor/media.js +128 -0
  21. package/dist/src/monitor/media.js.map +1 -0
  22. package/dist/src/monitor/processed-messages.js +38 -0
  23. package/dist/src/monitor/processed-messages.js.map +1 -0
  24. package/dist/src/monitor/utils.js +283 -0
  25. package/dist/src/monitor/utils.js.map +1 -0
  26. package/dist/src/onboarding.js +178 -0
  27. package/dist/src/onboarding.js.map +1 -0
  28. package/dist/src/runtime.js +11 -0
  29. package/dist/src/runtime.js.map +1 -0
  30. package/dist/src/settings.js +305 -0
  31. package/dist/src/settings.js.map +1 -0
  32. package/dist/src/targets.js +85 -0
  33. package/dist/src/targets.js.map +1 -0
  34. package/dist/src/types.js +79 -0
  35. package/dist/src/types.js.map +1 -0
  36. package/dist/src/urbit/api-client.js +104 -0
  37. package/dist/src/urbit/api-client.js.map +1 -0
  38. package/dist/src/urbit/auth.js +35 -0
  39. package/dist/src/urbit/auth.js.map +1 -0
  40. package/dist/src/urbit/base-url.js +45 -0
  41. package/dist/src/urbit/base-url.js.map +1 -0
  42. package/dist/src/urbit/channel-ops.js +136 -0
  43. package/dist/src/urbit/channel-ops.js.map +1 -0
  44. package/dist/src/urbit/context.js +42 -0
  45. package/dist/src/urbit/context.js.map +1 -0
  46. package/dist/src/urbit/errors.js +36 -0
  47. package/dist/src/urbit/errors.js.map +1 -0
  48. package/dist/src/urbit/fetch.js +23 -0
  49. package/dist/src/urbit/fetch.js.map +1 -0
  50. package/dist/src/urbit/foreigns.js +6 -0
  51. package/dist/src/urbit/foreigns.js.map +1 -0
  52. package/dist/src/urbit/http-poke.js +56 -0
  53. package/dist/src/urbit/http-poke.js.map +1 -0
  54. package/dist/src/urbit/send.js +208 -0
  55. package/dist/src/urbit/send.js.map +1 -0
  56. package/dist/src/urbit/sse-client.js +453 -0
  57. package/dist/src/urbit/sse-client.js.map +1 -0
  58. package/dist/src/urbit/story.js +286 -0
  59. package/dist/src/urbit/story.js.map +1 -0
  60. package/dist/src/urbit/upload.js +51 -0
  61. package/dist/src/urbit/upload.js.map +1 -0
  62. package/openclaw.plugin.json +10 -0
  63. package/package.json +84 -0
@@ -0,0 +1,1940 @@
1
+ import { format } from "node:util";
2
+ import { getTlonRuntime } from "../runtime.js";
3
+ import { createSettingsManager } from "../settings.js";
4
+ import { normalizeShip, parseChannelNest } from "../targets.js";
5
+ import { resolveTlonAccount } from "../types.js";
6
+ import { authenticate } from "../urbit/auth.js";
7
+ import { ssrfPolicyFromAllowPrivateNetwork } from "../urbit/context.js";
8
+ import { configureTlonApiWithPoke } from "../urbit/api-client.js";
9
+ import { sendDm, sendChannelPost } from "../urbit/send.js";
10
+ import { markdownToStory } from "../urbit/story.js";
11
+ import { UrbitSSEClient } from "../urbit/sse-client.js";
12
+ import { createPendingApproval, formatApprovalRequest, formatApprovalConfirmation, parseApprovalResponse, isApprovalResponse, findPendingApproval, removePendingApproval, parseAdminCommand, isAdminCommand, formatBlockedList, formatPendingList, } from "./approval.js";
13
+ import { fetchAllChannels, fetchInitData } from "./discovery.js";
14
+ import { cacheMessage, lookupCachedMessage, getChannelHistory, fetchThreadHistory } from "./history.js";
15
+ import { downloadMessageImages } from "./media.js";
16
+ import { createProcessedMessageTracker } from "./processed-messages.js";
17
+ import { extractMessageText, extractCites, formatModelName, isBotMentioned, stripBotMention, isDmAllowed, isSummarizationRequest, } from "./utils.js";
18
+ /**
19
+ * Resolve channel authorization by merging file config with settings store.
20
+ * Settings store takes precedence for fields it defines.
21
+ */
22
+ function resolveChannelAuthorization(cfg, channelNest, settings) {
23
+ const tlonConfig = cfg.channels?.tlon;
24
+ // Merge channel rules: settings override file config
25
+ const fileRules = tlonConfig?.authorization?.channelRules ?? {};
26
+ const settingsRules = settings?.channelRules ?? {};
27
+ const rule = settingsRules[channelNest] ?? fileRules[channelNest];
28
+ // Merge default authorized ships: settings override file config
29
+ const defaultShips = settings?.defaultAuthorizedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
30
+ const allowedShips = rule?.allowedShips ?? defaultShips;
31
+ const mode = rule?.mode ?? "restricted";
32
+ return { mode, allowedShips };
33
+ }
34
+ export async function monitorTlonProvider(opts = {}) {
35
+ const core = getTlonRuntime();
36
+ const cfg = core.config.loadConfig();
37
+ if (cfg.channels?.tlon?.enabled === false) {
38
+ return;
39
+ }
40
+ const logger = core.logging.getChildLogger({ module: "tlon-auto-reply" });
41
+ const formatRuntimeMessage = (...args) => format(...args);
42
+ const runtime = opts.runtime ?? {
43
+ log: (...args) => {
44
+ logger.info(formatRuntimeMessage(...args));
45
+ },
46
+ error: (...args) => {
47
+ logger.error(formatRuntimeMessage(...args));
48
+ },
49
+ exit: (code) => {
50
+ throw new Error(`exit ${code}`);
51
+ },
52
+ };
53
+ const account = resolveTlonAccount(cfg, opts.accountId ?? undefined);
54
+ if (!account.enabled) {
55
+ return;
56
+ }
57
+ if (!account.configured || !account.ship || !account.url || !account.code) {
58
+ throw new Error("Tlon account not configured (ship/url/code required)");
59
+ }
60
+ // Capture validated values for use in nested functions
61
+ const accountUrl = account.url;
62
+ const accountCode = account.code;
63
+ const botShipName = normalizeShip(account.ship);
64
+ runtime.log?.(`[tlon] Starting monitor for ${botShipName}`);
65
+ const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork);
66
+ // Helper to authenticate with retry logic
67
+ async function authenticateWithRetry(maxAttempts = 10) {
68
+ for (let attempt = 1;; attempt++) {
69
+ if (opts.abortSignal?.aborted) {
70
+ throw new Error("Aborted while waiting to authenticate");
71
+ }
72
+ try {
73
+ runtime.log?.(`[tlon] Attempting authentication to ${accountUrl}...`);
74
+ return await authenticate(accountUrl, accountCode, { ssrfPolicy });
75
+ }
76
+ catch (error) {
77
+ runtime.error?.(`[tlon] Failed to authenticate (attempt ${attempt}): ${error?.message ?? String(error)}`);
78
+ if (attempt >= maxAttempts) {
79
+ throw error;
80
+ }
81
+ const delay = Math.min(30000, 1000 * Math.pow(2, attempt - 1));
82
+ runtime.log?.(`[tlon] Retrying authentication in ${delay}ms...`);
83
+ await new Promise((resolve, reject) => {
84
+ const timer = setTimeout(resolve, delay);
85
+ if (opts.abortSignal) {
86
+ const onAbort = () => {
87
+ clearTimeout(timer);
88
+ reject(new Error("Aborted"));
89
+ };
90
+ opts.abortSignal.addEventListener("abort", onAbort, { once: true });
91
+ }
92
+ });
93
+ }
94
+ }
95
+ }
96
+ let api = null;
97
+ const cookie = await authenticateWithRetry();
98
+ api = new UrbitSSEClient(account.url, cookie, {
99
+ ship: botShipName,
100
+ ssrfPolicy,
101
+ logger: {
102
+ log: (message) => runtime.log?.(message),
103
+ error: (message) => runtime.error?.(message),
104
+ },
105
+ // Re-authenticate on reconnect in case the session expired
106
+ onReconnect: async (client) => {
107
+ runtime.log?.("[tlon] Re-authenticating on SSE reconnect...");
108
+ const newCookie = await authenticateWithRetry(5);
109
+ client.updateCookie(newCookie);
110
+ runtime.log?.("[tlon] Re-authentication successful");
111
+ },
112
+ });
113
+ // Configure @tloncorp/api's global client to use the SSE client's poke for all send operations
114
+ configureTlonApiWithPoke(api.poke.bind(api), botShipName, account.url);
115
+ const processedTracker = createProcessedMessageTracker(2000);
116
+ let groupChannels = [];
117
+ const channelToGroup = new Map();
118
+ let botNickname = null;
119
+ // Settings store manager for hot-reloading config
120
+ const settingsManager = createSettingsManager(api, {
121
+ log: (msg) => runtime.log?.(msg),
122
+ error: (msg) => runtime.error?.(msg),
123
+ });
124
+ // Reactive state that can be updated via settings store
125
+ let effectiveDmAllowlist = account.dmAllowlist;
126
+ let effectiveShowModelSig = account.showModelSignature ?? false;
127
+ let effectiveAutoAcceptDmInvites = account.autoAcceptDmInvites ?? false;
128
+ let effectiveAutoAcceptGroupInvites = account.autoAcceptGroupInvites ?? false;
129
+ let effectiveGroupInviteAllowlist = account.groupInviteAllowlist;
130
+ let effectiveAutoDiscoverChannels = account.autoDiscoverChannels ?? false;
131
+ let effectiveOwnerShip = account.ownerShip
132
+ ? normalizeShip(account.ownerShip)
133
+ : null;
134
+ let pendingApprovals = [];
135
+ let currentSettings = {};
136
+ // Track threads we've participated in (by parentId) - respond without mention requirement
137
+ const participatedThreads = new Set();
138
+ // Track DM senders per session to detect shared sessions (security warning)
139
+ const dmSendersBySession = new Map();
140
+ let sharedSessionWarningSent = false;
141
+ // Nickname cache for all known contacts (ship -> nickname)
142
+ const nicknameCache = new Map();
143
+ // Sanitize nickname to prevent format injection
144
+ function sanitizeNickname(nickname) {
145
+ return nickname
146
+ .replace(/[\[\]()]/g, "") // Remove format-breaking chars
147
+ .slice(0, 50); // Reasonable length limit
148
+ }
149
+ // Format a ship with nickname if available
150
+ function formatShipWithNickname(ship) {
151
+ const nickname = nicknameCache.get(ship);
152
+ if (!nickname)
153
+ return ship;
154
+ const sanitized = sanitizeNickname(nickname);
155
+ return sanitized ? `${ship} (${sanitized})` : ship;
156
+ }
157
+ // Fetch bot's nickname and all contacts
158
+ try {
159
+ const selfProfile = await api.scry("/contacts/v1/self.json");
160
+ if (selfProfile && typeof selfProfile === "object") {
161
+ const profile = selfProfile;
162
+ botNickname = profile.nickname?.value || null;
163
+ if (botNickname) {
164
+ runtime.log?.(`[tlon] Bot nickname: ${botNickname}`);
165
+ nicknameCache.set(botShipName, botNickname);
166
+ }
167
+ }
168
+ }
169
+ catch (error) {
170
+ runtime.log?.(`[tlon] Could not fetch self profile: ${error?.message ?? String(error)}`);
171
+ }
172
+ // Fetch all contacts to populate nickname cache
173
+ try {
174
+ const allContacts = await api.scry("/contacts/v1/all.json");
175
+ if (allContacts && typeof allContacts === "object") {
176
+ for (const [ship, contact] of Object.entries(allContacts)) {
177
+ const nickname = contact?.nickname?.value ?? contact?.nickname;
178
+ if (nickname && typeof nickname === "string") {
179
+ nicknameCache.set(normalizeShip(ship), nickname);
180
+ }
181
+ }
182
+ runtime.log?.(`[tlon] Loaded ${nicknameCache.size} contact nickname(s)`);
183
+ }
184
+ }
185
+ catch (error) {
186
+ runtime.log?.(`[tlon] Could not fetch contacts: ${error?.message ?? String(error)}`);
187
+ }
188
+ // Store init foreigns for processing after settings are loaded
189
+ let initForeigns = null;
190
+ // Migrate file config to settings store (seed on first run)
191
+ async function migrateConfigToSettings() {
192
+ const migrations = [
193
+ {
194
+ key: "dmAllowlist",
195
+ fileValue: account.dmAllowlist,
196
+ settingsValue: currentSettings.dmAllowlist,
197
+ },
198
+ {
199
+ key: "groupInviteAllowlist",
200
+ fileValue: account.groupInviteAllowlist,
201
+ settingsValue: currentSettings.groupInviteAllowlist,
202
+ },
203
+ {
204
+ key: "groupChannels",
205
+ fileValue: account.groupChannels,
206
+ settingsValue: currentSettings.groupChannels,
207
+ },
208
+ {
209
+ key: "defaultAuthorizedShips",
210
+ fileValue: account.defaultAuthorizedShips,
211
+ settingsValue: currentSettings.defaultAuthorizedShips,
212
+ },
213
+ {
214
+ key: "autoDiscoverChannels",
215
+ fileValue: account.autoDiscoverChannels,
216
+ settingsValue: currentSettings.autoDiscoverChannels,
217
+ },
218
+ {
219
+ key: "autoAcceptDmInvites",
220
+ fileValue: account.autoAcceptDmInvites,
221
+ settingsValue: currentSettings.autoAcceptDmInvites,
222
+ },
223
+ {
224
+ key: "autoAcceptGroupInvites",
225
+ fileValue: account.autoAcceptGroupInvites,
226
+ settingsValue: currentSettings.autoAcceptGroupInvites,
227
+ },
228
+ {
229
+ key: "showModelSig",
230
+ fileValue: account.showModelSignature,
231
+ settingsValue: currentSettings.showModelSig,
232
+ },
233
+ ];
234
+ for (const { key, fileValue, settingsValue } of migrations) {
235
+ // Only migrate if file has a value and settings store doesn't
236
+ const hasFileValue = Array.isArray(fileValue) ? fileValue.length > 0 : fileValue != null;
237
+ const hasSettingsValue = Array.isArray(settingsValue)
238
+ ? settingsValue.length > 0
239
+ : settingsValue != null;
240
+ if (hasFileValue && !hasSettingsValue) {
241
+ try {
242
+ await api.poke({
243
+ app: "settings",
244
+ mark: "settings-event",
245
+ json: {
246
+ "put-entry": {
247
+ "bucket-key": "tlon",
248
+ "entry-key": key,
249
+ value: fileValue,
250
+ desk: "moltbot",
251
+ },
252
+ },
253
+ });
254
+ runtime.log?.(`[tlon] Migrated ${key} from config to settings store`);
255
+ }
256
+ catch (err) {
257
+ runtime.log?.(`[tlon] Failed to migrate ${key}: ${String(err)}`);
258
+ }
259
+ }
260
+ }
261
+ }
262
+ // Load settings from settings store (hot-reloadable config)
263
+ try {
264
+ currentSettings = await settingsManager.load();
265
+ // Migrate file config to settings store if not already present
266
+ await migrateConfigToSettings();
267
+ // Apply settings overrides
268
+ // Note: groupChannels from settings store are merged AFTER discovery runs (below)
269
+ if (currentSettings.defaultAuthorizedShips?.length) {
270
+ runtime.log?.(`[tlon] Using defaultAuthorizedShips from settings store: ${currentSettings.defaultAuthorizedShips.join(", ")}`);
271
+ }
272
+ if (currentSettings.autoDiscoverChannels !== undefined) {
273
+ effectiveAutoDiscoverChannels = currentSettings.autoDiscoverChannels;
274
+ runtime.log?.(`[tlon] Using autoDiscoverChannels from settings store: ${effectiveAutoDiscoverChannels}`);
275
+ }
276
+ if (currentSettings.dmAllowlist?.length) {
277
+ effectiveDmAllowlist = currentSettings.dmAllowlist;
278
+ runtime.log?.(`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.join(", ")}`);
279
+ }
280
+ if (currentSettings.showModelSig !== undefined) {
281
+ effectiveShowModelSig = currentSettings.showModelSig;
282
+ }
283
+ if (currentSettings.autoAcceptDmInvites !== undefined) {
284
+ effectiveAutoAcceptDmInvites = currentSettings.autoAcceptDmInvites;
285
+ runtime.log?.(`[tlon] Using autoAcceptDmInvites from settings store: ${effectiveAutoAcceptDmInvites}`);
286
+ }
287
+ if (currentSettings.autoAcceptGroupInvites !== undefined) {
288
+ effectiveAutoAcceptGroupInvites = currentSettings.autoAcceptGroupInvites;
289
+ runtime.log?.(`[tlon] Using autoAcceptGroupInvites from settings store: ${effectiveAutoAcceptGroupInvites}`);
290
+ }
291
+ if (currentSettings.groupInviteAllowlist?.length) {
292
+ effectiveGroupInviteAllowlist = currentSettings.groupInviteAllowlist;
293
+ runtime.log?.(`[tlon] Using groupInviteAllowlist from settings store: ${effectiveGroupInviteAllowlist.join(", ")}`);
294
+ }
295
+ if (currentSettings.ownerShip) {
296
+ effectiveOwnerShip = normalizeShip(currentSettings.ownerShip);
297
+ runtime.log?.(`[tlon] Using ownerShip from settings store: ${effectiveOwnerShip}`);
298
+ }
299
+ if (currentSettings.pendingApprovals?.length) {
300
+ pendingApprovals = currentSettings.pendingApprovals;
301
+ runtime.log?.(`[tlon] Loaded ${pendingApprovals.length} pending approval(s) from settings`);
302
+ }
303
+ }
304
+ catch (err) {
305
+ runtime.log?.(`[tlon] Settings store not available, using file config: ${String(err)}`);
306
+ }
307
+ // Run channel discovery AFTER settings are loaded (so settings store value is used)
308
+ if (effectiveAutoDiscoverChannels) {
309
+ try {
310
+ const initData = await fetchInitData(api, runtime);
311
+ if (initData.channels.length > 0) {
312
+ groupChannels = initData.channels;
313
+ }
314
+ // Populate channel-to-group mapping for member hint injection
315
+ for (const [nest, groupFlag] of initData.channelToGroup) {
316
+ channelToGroup.set(nest, groupFlag);
317
+ }
318
+ initForeigns = initData.foreigns;
319
+ }
320
+ catch (error) {
321
+ runtime.error?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`);
322
+ }
323
+ }
324
+ // Merge manual config with auto-discovered channels
325
+ if (account.groupChannels.length > 0) {
326
+ for (const ch of account.groupChannels) {
327
+ if (!groupChannels.includes(ch)) {
328
+ groupChannels.push(ch);
329
+ }
330
+ }
331
+ runtime.log?.(`[tlon] Added ${account.groupChannels.length} manual groupChannels to monitoring`);
332
+ }
333
+ // Also merge settings store groupChannels (may have been set via tlon settings command)
334
+ if (currentSettings.groupChannels?.length) {
335
+ for (const ch of currentSettings.groupChannels) {
336
+ if (!groupChannels.includes(ch)) {
337
+ groupChannels.push(ch);
338
+ }
339
+ }
340
+ }
341
+ if (groupChannels.length > 0) {
342
+ runtime.log?.(`[tlon] Monitoring ${groupChannels.length} group channel(s): ${groupChannels.join(", ")}`);
343
+ }
344
+ else {
345
+ runtime.log?.("[tlon] No group channels to monitor (DMs only)");
346
+ }
347
+ // Helper to resolve cited message content
348
+ async function resolveCiteContent(cite) {
349
+ if (cite.type !== "chan" || !cite.nest || !cite.postId) {
350
+ return null;
351
+ }
352
+ try {
353
+ // Scry for the specific post: /v4/{nest}/posts/post/{postId}
354
+ const scryPath = `/channels/v4/${cite.nest}/posts/post/${cite.postId}.json`;
355
+ runtime.log?.(`[tlon] Fetching cited post: ${scryPath}`);
356
+ const data = await api.scry(scryPath);
357
+ // Extract text from the post's essay content
358
+ if (data?.essay?.content) {
359
+ const text = extractMessageText(data.essay.content);
360
+ return text || null;
361
+ }
362
+ return null;
363
+ }
364
+ catch (err) {
365
+ runtime.log?.(`[tlon] Failed to fetch cited post: ${String(err)}`);
366
+ return null;
367
+ }
368
+ }
369
+ // Resolve all cites in message content and return quoted text
370
+ async function resolveAllCites(content) {
371
+ const cites = extractCites(content);
372
+ if (cites.length === 0) {
373
+ return "";
374
+ }
375
+ const resolved = [];
376
+ for (const cite of cites) {
377
+ const text = await resolveCiteContent(cite);
378
+ if (text) {
379
+ const author = cite.author || "unknown";
380
+ resolved.push(`> ${author} wrote: ${text}`);
381
+ }
382
+ }
383
+ return resolved.length > 0 ? resolved.join("\n") + "\n\n" : "";
384
+ }
385
+ // Helper to save pending approvals to settings store
386
+ async function savePendingApprovals() {
387
+ try {
388
+ await api.poke({
389
+ app: "settings",
390
+ mark: "settings-event",
391
+ json: {
392
+ "put-entry": {
393
+ desk: "moltbot",
394
+ "bucket-key": "tlon",
395
+ "entry-key": "pendingApprovals",
396
+ value: JSON.stringify(pendingApprovals),
397
+ },
398
+ },
399
+ });
400
+ }
401
+ catch (err) {
402
+ runtime.error?.(`[tlon] Failed to save pending approvals: ${String(err)}`);
403
+ }
404
+ }
405
+ // Helper to update dmAllowlist in settings store
406
+ async function addToDmAllowlist(ship) {
407
+ const normalizedShip = normalizeShip(ship);
408
+ if (!effectiveDmAllowlist.includes(normalizedShip)) {
409
+ effectiveDmAllowlist = [...effectiveDmAllowlist, normalizedShip];
410
+ }
411
+ try {
412
+ await api.poke({
413
+ app: "settings",
414
+ mark: "settings-event",
415
+ json: {
416
+ "put-entry": {
417
+ desk: "moltbot",
418
+ "bucket-key": "tlon",
419
+ "entry-key": "dmAllowlist",
420
+ value: effectiveDmAllowlist,
421
+ },
422
+ },
423
+ });
424
+ runtime.log?.(`[tlon] Added ${normalizedShip} to dmAllowlist`);
425
+ }
426
+ catch (err) {
427
+ runtime.error?.(`[tlon] Failed to update dmAllowlist: ${String(err)}`);
428
+ }
429
+ }
430
+ // Helper to update channelRules in settings store
431
+ async function addToChannelAllowlist(ship, channelNest) {
432
+ const normalizedShip = normalizeShip(ship);
433
+ const channelRules = currentSettings.channelRules ?? {};
434
+ const rule = channelRules[channelNest] ?? { mode: "restricted", allowedShips: [] };
435
+ const allowedShips = [...(rule.allowedShips ?? [])]; // Clone to avoid mutation
436
+ if (!allowedShips.includes(normalizedShip)) {
437
+ allowedShips.push(normalizedShip);
438
+ }
439
+ const updatedRules = {
440
+ ...channelRules,
441
+ [channelNest]: { ...rule, allowedShips },
442
+ };
443
+ // Update local state immediately (don't wait for settings subscription)
444
+ currentSettings = { ...currentSettings, channelRules: updatedRules };
445
+ try {
446
+ await api.poke({
447
+ app: "settings",
448
+ mark: "settings-event",
449
+ json: {
450
+ "put-entry": {
451
+ desk: "moltbot",
452
+ "bucket-key": "tlon",
453
+ "entry-key": "channelRules",
454
+ value: JSON.stringify(updatedRules),
455
+ },
456
+ },
457
+ });
458
+ runtime.log?.(`[tlon] Added ${normalizedShip} to ${channelNest} allowlist`);
459
+ }
460
+ catch (err) {
461
+ runtime.error?.(`[tlon] Failed to update channelRules: ${String(err)}`);
462
+ }
463
+ }
464
+ // Helper to block a ship using Tlon's native blocking
465
+ async function blockShip(ship) {
466
+ const normalizedShip = normalizeShip(ship);
467
+ try {
468
+ await api.poke({
469
+ app: "chat",
470
+ mark: "chat-block-ship",
471
+ json: { ship: normalizedShip },
472
+ });
473
+ runtime.log?.(`[tlon] Blocked ship ${normalizedShip}`);
474
+ }
475
+ catch (err) {
476
+ runtime.error?.(`[tlon] Failed to block ship ${normalizedShip}: ${String(err)}`);
477
+ }
478
+ }
479
+ // Check if a ship is blocked using Tlon's native block list
480
+ async function isShipBlocked(ship) {
481
+ const normalizedShip = normalizeShip(ship);
482
+ try {
483
+ const blocked = (await api.scry("/chat/blocked.json"));
484
+ return Array.isArray(blocked) && blocked.some((s) => normalizeShip(s) === normalizedShip);
485
+ }
486
+ catch (err) {
487
+ runtime.log?.(`[tlon] Failed to check blocked list: ${String(err)}`);
488
+ return false;
489
+ }
490
+ }
491
+ // Get all blocked ships
492
+ async function getBlockedShips() {
493
+ try {
494
+ const blocked = (await api.scry("/chat/blocked.json"));
495
+ return Array.isArray(blocked) ? blocked : [];
496
+ }
497
+ catch (err) {
498
+ runtime.log?.(`[tlon] Failed to get blocked list: ${String(err)}`);
499
+ return [];
500
+ }
501
+ }
502
+ // Helper to unblock a ship using Tlon's native blocking
503
+ async function unblockShip(ship) {
504
+ const normalizedShip = normalizeShip(ship);
505
+ try {
506
+ await api.poke({
507
+ app: "chat",
508
+ mark: "chat-unblock-ship",
509
+ json: { ship: normalizedShip },
510
+ });
511
+ runtime.log?.(`[tlon] Unblocked ship ${normalizedShip}`);
512
+ return true;
513
+ }
514
+ catch (err) {
515
+ runtime.error?.(`[tlon] Failed to unblock ship ${normalizedShip}: ${String(err)}`);
516
+ return false;
517
+ }
518
+ }
519
+ // Helper to send DM notification to owner
520
+ async function sendOwnerNotification(message) {
521
+ if (!effectiveOwnerShip) {
522
+ runtime.log?.("[tlon] No ownerShip configured, cannot send notification");
523
+ return;
524
+ }
525
+ try {
526
+ await sendDm({
527
+ fromShip: botShipName,
528
+ toShip: effectiveOwnerShip,
529
+ text: message,
530
+ });
531
+ runtime.log?.(`[tlon] Sent notification to owner ${effectiveOwnerShip}`);
532
+ }
533
+ catch (err) {
534
+ runtime.error?.(`[tlon] Failed to send notification to owner: ${String(err)}`);
535
+ }
536
+ }
537
+ // Queue a new approval request and notify the owner
538
+ async function queueApprovalRequest(approval) {
539
+ // Check if ship is blocked - silently ignore
540
+ if (await isShipBlocked(approval.requestingShip)) {
541
+ runtime.log?.(`[tlon] Ignoring request from blocked ship ${approval.requestingShip}`);
542
+ return;
543
+ }
544
+ // Check for duplicate - if found, update it with new content and re-notify
545
+ const existingIndex = pendingApprovals.findIndex((a) => a.type === approval.type &&
546
+ a.requestingShip === approval.requestingShip &&
547
+ (approval.type !== "channel" || a.channelNest === approval.channelNest) &&
548
+ (approval.type !== "group" || a.groupFlag === approval.groupFlag));
549
+ if (existingIndex !== -1) {
550
+ // Update existing approval with new content (preserves the original ID)
551
+ const existing = pendingApprovals[existingIndex];
552
+ if (approval.originalMessage) {
553
+ existing.originalMessage = approval.originalMessage;
554
+ existing.messagePreview = approval.messagePreview;
555
+ }
556
+ runtime.log?.(`[tlon] Updated existing approval for ${approval.requestingShip} (${approval.type}) - re-sending notification`);
557
+ await savePendingApprovals();
558
+ const message = formatApprovalRequest(existing);
559
+ await sendOwnerNotification(message);
560
+ return;
561
+ }
562
+ pendingApprovals.push(approval);
563
+ await savePendingApprovals();
564
+ const message = formatApprovalRequest(approval);
565
+ await sendOwnerNotification(message);
566
+ runtime.log?.(`[tlon] Queued approval request: ${approval.id} (${approval.type} from ${approval.requestingShip})`);
567
+ }
568
+ // Process the owner's approval response
569
+ async function handleApprovalResponse(text) {
570
+ const parsed = parseApprovalResponse(text);
571
+ if (!parsed) {
572
+ return false;
573
+ }
574
+ const approval = findPendingApproval(pendingApprovals, parsed.id);
575
+ if (!approval) {
576
+ await sendOwnerNotification("No pending approval found" + (parsed.id ? ` for ID: ${parsed.id}` : ""));
577
+ return true; // Still consumed the message
578
+ }
579
+ if (parsed.action === "approve") {
580
+ switch (approval.type) {
581
+ case "dm":
582
+ await addToDmAllowlist(approval.requestingShip);
583
+ // Process the original message if available
584
+ if (approval.originalMessage) {
585
+ runtime.log?.(`[tlon] Processing original message from ${approval.requestingShip} after approval`);
586
+ await processMessage({
587
+ messageId: approval.originalMessage.messageId,
588
+ senderShip: approval.requestingShip,
589
+ messageText: approval.originalMessage.messageText,
590
+ messageContent: approval.originalMessage.messageContent,
591
+ isGroup: false,
592
+ timestamp: approval.originalMessage.timestamp,
593
+ });
594
+ }
595
+ break;
596
+ case "channel":
597
+ if (approval.channelNest) {
598
+ await addToChannelAllowlist(approval.requestingShip, approval.channelNest);
599
+ // Process the original message if available
600
+ if (approval.originalMessage) {
601
+ const parsed = parseChannelNest(approval.channelNest);
602
+ runtime.log?.(`[tlon] Processing original message from ${approval.requestingShip} in ${approval.channelNest} after approval`);
603
+ await processMessage({
604
+ messageId: approval.originalMessage.messageId,
605
+ senderShip: approval.requestingShip,
606
+ messageText: approval.originalMessage.messageText,
607
+ messageContent: approval.originalMessage.messageContent,
608
+ isGroup: true,
609
+ channelNest: approval.channelNest,
610
+ hostShip: parsed?.hostShip,
611
+ channelName: parsed?.channelName,
612
+ timestamp: approval.originalMessage.timestamp,
613
+ parentId: approval.originalMessage.parentId,
614
+ isThreadReply: approval.originalMessage.isThreadReply,
615
+ });
616
+ }
617
+ }
618
+ break;
619
+ case "group":
620
+ // Accept the group invite (don't add to allowlist - each invite requires approval)
621
+ if (approval.groupFlag) {
622
+ try {
623
+ await api.poke({
624
+ app: "groups",
625
+ mark: "group-join",
626
+ json: {
627
+ flag: approval.groupFlag,
628
+ "join-all": true,
629
+ },
630
+ });
631
+ runtime.log?.(`[tlon] Joined group ${approval.groupFlag} after approval`);
632
+ // Immediately discover channels from the newly joined group
633
+ // Small delay to allow the join to propagate
634
+ setTimeout(async () => {
635
+ try {
636
+ const discoveredChannels = await fetchAllChannels(api, runtime);
637
+ let newCount = 0;
638
+ for (const channelNest of discoveredChannels) {
639
+ if (!watchedChannels.has(channelNest)) {
640
+ watchedChannels.add(channelNest);
641
+ newCount++;
642
+ }
643
+ }
644
+ if (newCount > 0) {
645
+ runtime.log?.(`[tlon] Discovered ${newCount} new channel(s) after joining group`);
646
+ }
647
+ }
648
+ catch (err) {
649
+ runtime.log?.(`[tlon] Channel discovery after group join failed: ${String(err)}`);
650
+ }
651
+ }, 2000);
652
+ }
653
+ catch (err) {
654
+ runtime.error?.(`[tlon] Failed to join group ${approval.groupFlag}: ${String(err)}`);
655
+ }
656
+ }
657
+ break;
658
+ }
659
+ await sendOwnerNotification(formatApprovalConfirmation(approval, "approve"));
660
+ }
661
+ else if (parsed.action === "block") {
662
+ // Block the ship using Tlon's native blocking
663
+ await blockShip(approval.requestingShip);
664
+ await sendOwnerNotification(formatApprovalConfirmation(approval, "block"));
665
+ }
666
+ else {
667
+ // Denied - just remove from pending, no notification to requester
668
+ await sendOwnerNotification(formatApprovalConfirmation(approval, "deny"));
669
+ }
670
+ // Remove from pending
671
+ pendingApprovals = removePendingApproval(pendingApprovals, approval.id);
672
+ await savePendingApprovals();
673
+ return true;
674
+ }
675
+ // Handle admin commands from owner (unblock, blocked, pending)
676
+ async function handleAdminCommand(text) {
677
+ const command = parseAdminCommand(text);
678
+ if (!command) {
679
+ return false;
680
+ }
681
+ switch (command.type) {
682
+ case "blocked": {
683
+ const blockedShips = await getBlockedShips();
684
+ await sendOwnerNotification(formatBlockedList(blockedShips));
685
+ runtime.log?.(`[tlon] Owner requested blocked ships list (${blockedShips.length} ships)`);
686
+ return true;
687
+ }
688
+ case "pending": {
689
+ await sendOwnerNotification(formatPendingList(pendingApprovals));
690
+ runtime.log?.(`[tlon] Owner requested pending approvals list (${pendingApprovals.length} pending)`);
691
+ return true;
692
+ }
693
+ case "unblock": {
694
+ const shipToUnblock = command.ship;
695
+ const isBlocked = await isShipBlocked(shipToUnblock);
696
+ if (!isBlocked) {
697
+ await sendOwnerNotification(`${shipToUnblock} is not blocked.`);
698
+ return true;
699
+ }
700
+ const success = await unblockShip(shipToUnblock);
701
+ if (success) {
702
+ await sendOwnerNotification(`Unblocked ${shipToUnblock}.`);
703
+ }
704
+ else {
705
+ await sendOwnerNotification(`Failed to unblock ${shipToUnblock}.`);
706
+ }
707
+ return true;
708
+ }
709
+ }
710
+ }
711
+ // Check if a ship is the owner (always allowed to DM)
712
+ function isOwner(ship) {
713
+ if (!effectiveOwnerShip) {
714
+ return false;
715
+ }
716
+ return normalizeShip(ship) === effectiveOwnerShip;
717
+ }
718
+ /**
719
+ * Extract the DM partner ship from the 'whom' field.
720
+ * This is the canonical source for DM routing (more reliable than essay.author).
721
+ * Returns empty string if whom doesn't contain a valid patp-like value.
722
+ */
723
+ function extractDmPartnerShip(whom) {
724
+ const raw = typeof whom === "string"
725
+ ? whom
726
+ : whom && typeof whom === "object" && "ship" in whom && typeof whom.ship === "string"
727
+ ? whom.ship
728
+ : "";
729
+ const normalized = normalizeShip(raw);
730
+ // Keep DM routing strict: accept only patp-like values.
731
+ return /^~?[a-z-]+$/i.test(normalized) ? normalized : "";
732
+ }
733
+ const processMessage = async (params) => {
734
+ const { messageId, senderShip, isGroup, channelNest, hostShip, channelName, timestamp, parentId, isThreadReply, messageContent, } = params;
735
+ // replyParentId overrides parentId for the deliver callback (thread reply routing)
736
+ // but doesn't affect the ctx payload (MessageThreadId/ReplyToId).
737
+ // Used for reactions: agent sees no thread context (so it responds), but
738
+ // the reply is still delivered as a thread reply.
739
+ const deliverParentId = params.replyParentId ?? parentId;
740
+ const groupChannel = channelNest; // For compatibility
741
+ let messageText = params.messageText;
742
+ const rawMessageText = messageText; // Preserve original before any modifications
743
+ // Strip bot mention EARLY, before thread context is prepended.
744
+ // This ensures [Current message] in thread context won't contain the bot ship name,
745
+ // which was causing the agent to mistake it for its own message and return NO_REPLY.
746
+ if (isGroup) {
747
+ messageText = stripBotMention(messageText, botShipName);
748
+ }
749
+ // Track owner interaction timestamp for heartbeat engagement recovery.
750
+ // Store both epoch ms (for code) and ISO date (for LLM — models can't reliably convert epoch).
751
+ if (isOwner(senderShip)) {
752
+ const isoDate = new Date(timestamp).toISOString().split("T")[0]; // YYYY-MM-DD
753
+ Promise.all([
754
+ api.poke({
755
+ app: "settings",
756
+ mark: "settings-event",
757
+ json: {
758
+ "put-entry": {
759
+ desk: "moltbot",
760
+ "bucket-key": "tlon",
761
+ "entry-key": "lastOwnerMessageAt",
762
+ value: timestamp,
763
+ },
764
+ },
765
+ }),
766
+ api.poke({
767
+ app: "settings",
768
+ mark: "settings-event",
769
+ json: {
770
+ "put-entry": {
771
+ desk: "moltbot",
772
+ "bucket-key": "tlon",
773
+ "entry-key": "lastOwnerMessageDate",
774
+ value: isoDate,
775
+ },
776
+ },
777
+ }),
778
+ ])
779
+ .then(() => {
780
+ runtime.log?.(`[tlon] Updated lastOwnerMessageAt: ${timestamp} (${isoDate})`);
781
+ })
782
+ .catch((err) => {
783
+ runtime.error?.(`[tlon] Failed to update lastOwnerMessageAt: ${String(err)}`);
784
+ });
785
+ }
786
+ // Download any images from the message content
787
+ let attachments = [];
788
+ if (messageContent) {
789
+ try {
790
+ attachments = await downloadMessageImages(messageContent);
791
+ if (attachments.length > 0) {
792
+ runtime.log?.(`[tlon] Downloaded ${attachments.length} image(s) from message`);
793
+ }
794
+ }
795
+ catch (error) {
796
+ runtime.log?.(`[tlon] Failed to download images: ${error?.message ?? String(error)}`);
797
+ }
798
+ }
799
+ // Fetch thread context when entering a thread for the first time
800
+ if (isThreadReply && parentId && groupChannel) {
801
+ try {
802
+ const threadHistory = await fetchThreadHistory(api, groupChannel, parentId, 20, runtime);
803
+ if (threadHistory.length > 0) {
804
+ const threadContext = threadHistory
805
+ .slice(-10) // Last 10 messages for context
806
+ .map((msg) => `${msg.author}: ${msg.content}`)
807
+ .join("\n");
808
+ // Prepend thread context to the message
809
+ // Include note about ongoing conversation for agent judgment
810
+ 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.]`;
811
+ messageText = `${contextNote}\n\n[Previous messages]\n${threadContext}\n\n[Current message]\n${messageText}`;
812
+ runtime?.log?.(`[tlon] Added thread context (${threadHistory.length} replies) to message`);
813
+ }
814
+ }
815
+ catch (error) {
816
+ runtime?.log?.(`[tlon] Could not fetch thread context: ${error?.message ?? String(error)}`);
817
+ // Continue without thread context - not critical
818
+ }
819
+ }
820
+ if (isGroup && groupChannel && isSummarizationRequest(messageText)) {
821
+ try {
822
+ const history = await getChannelHistory(api, groupChannel, 50, runtime);
823
+ if (history.length === 0) {
824
+ const noHistoryMsg = "I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue.";
825
+ if (isGroup && groupChannel) {
826
+ await sendChannelPost({
827
+ fromShip: botShipName,
828
+ nest: groupChannel,
829
+ story: markdownToStory(noHistoryMsg),
830
+ });
831
+ }
832
+ else {
833
+ await sendDm({
834
+ fromShip: botShipName,
835
+ toShip: senderShip,
836
+ text: noHistoryMsg,
837
+ });
838
+ }
839
+ return;
840
+ }
841
+ const historyText = history
842
+ .map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`)
843
+ .join("\n");
844
+ messageText =
845
+ `Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\n` +
846
+ "Provide a concise summary highlighting:\n" +
847
+ "1. Main topics discussed\n" +
848
+ "2. Key decisions or conclusions\n" +
849
+ "3. Action items if any\n" +
850
+ "4. Notable participants";
851
+ }
852
+ catch (error) {
853
+ const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error?.message ?? String(error)}`;
854
+ if (isGroup && groupChannel) {
855
+ await sendChannelPost({
856
+ fromShip: botShipName,
857
+ nest: groupChannel,
858
+ story: markdownToStory(errorMsg),
859
+ });
860
+ }
861
+ else {
862
+ await sendDm({ fromShip: botShipName, toShip: senderShip, text: errorMsg });
863
+ }
864
+ return;
865
+ }
866
+ }
867
+ const route = core.channel.routing.resolveAgentRoute({
868
+ cfg,
869
+ channel: "tlon",
870
+ accountId: opts.accountId ?? undefined,
871
+ peer: {
872
+ kind: isGroup ? "group" : "direct",
873
+ id: isGroup ? (groupChannel ?? senderShip) : senderShip,
874
+ },
875
+ });
876
+ // Warn if multiple users share a DM session (insecure dmScope configuration)
877
+ if (!isGroup) {
878
+ const sessionKey = route.sessionKey;
879
+ if (!dmSendersBySession.has(sessionKey)) {
880
+ dmSendersBySession.set(sessionKey, new Set());
881
+ }
882
+ const senders = dmSendersBySession.get(sessionKey);
883
+ if (senders.size > 0 && !senders.has(senderShip)) {
884
+ // Log warning
885
+ runtime.log?.(`[tlon] ⚠️ SECURITY: Multiple users sharing DM session. ` +
886
+ `Configure "session.dmScope: per-channel-peer" in OpenClaw config.`);
887
+ // Notify owner via DM (once per monitor session)
888
+ if (!sharedSessionWarningSent && effectiveOwnerShip) {
889
+ sharedSessionWarningSent = true;
890
+ const warningMsg = `⚠️ Security Warning: Multiple users are sharing a DM session with this bot. ` +
891
+ `This can leak conversation context between users.\n\n` +
892
+ `Fix: Add to your OpenClaw config:\n` +
893
+ `session:\n dmScope: "per-channel-peer"\n\n` +
894
+ `Docs: https://docs.openclaw.ai/concepts/session#secure-dm-mode`;
895
+ // Send async, don't block message processing
896
+ sendDm({
897
+ fromShip: botShipName,
898
+ toShip: effectiveOwnerShip,
899
+ text: warningMsg,
900
+ }).catch((err) => runtime.error?.(`[tlon] Failed to send security warning to owner: ${err}`));
901
+ }
902
+ }
903
+ senders.add(senderShip);
904
+ }
905
+ const senderRole = isOwner(senderShip) ? "owner" : "user";
906
+ const senderDisplay = formatShipWithNickname(senderShip);
907
+ const fromLabel = isGroup
908
+ ? `${senderDisplay} [${senderRole}] in ${channelNest}`
909
+ : `${senderDisplay} [${senderRole}]`;
910
+ // Compute command authorization for slash commands (owner-only)
911
+ const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(messageText, cfg);
912
+ let commandAuthorized = false;
913
+ if (shouldComputeAuth) {
914
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
915
+ const senderIsOwner = isOwner(senderShip);
916
+ commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
917
+ useAccessGroups,
918
+ authorizers: [{ configured: Boolean(effectiveOwnerShip), allowed: senderIsOwner }],
919
+ });
920
+ // Log when non-owner attempts a slash command (will be silently ignored by Gateway)
921
+ if (!commandAuthorized) {
922
+ console.log(`[tlon] Command attempt denied: ${senderShip} is not owner (owner=${effectiveOwnerShip ?? "not configured"})`);
923
+ }
924
+ }
925
+ // Bot mention was already stripped early (before thread context), so use messageText directly
926
+ // Prepend attachment annotations to message body (similar to Signal format)
927
+ let bodyWithAttachments = messageText;
928
+ if (attachments.length > 0) {
929
+ const mediaLines = attachments
930
+ .map((a) => `[media attached: ${a.path} (${a.contentType}) | ${a.path}]`)
931
+ .join("\n");
932
+ bodyWithAttachments = mediaLines + "\n" + messageText;
933
+ }
934
+ // For group messages, add a hint about how to query members (avoids injecting full list)
935
+ if (isGroup && channelNest) {
936
+ const groupFlag = channelToGroup.get(channelNest);
937
+ if (groupFlag) {
938
+ bodyWithAttachments += `\n[Group members available via: tlon groups info ${groupFlag}]`;
939
+ }
940
+ }
941
+ const body = core.channel.reply.formatAgentEnvelope({
942
+ channel: "Tlon",
943
+ from: fromLabel,
944
+ timestamp,
945
+ body: bodyWithAttachments,
946
+ });
947
+ // Use raw text (no thread context) for command detection so "/status" is recognized
948
+ const commandBody = isGroup
949
+ ? stripBotMention(rawMessageText, botShipName)
950
+ : rawMessageText;
951
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
952
+ Body: body,
953
+ RawBody: messageText,
954
+ CommandBody: commandBody,
955
+ From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
956
+ To: `tlon:${botShipName}`,
957
+ SessionKey: route.sessionKey,
958
+ AccountId: route.accountId,
959
+ ChatType: isGroup ? "group" : "direct",
960
+ ConversationLabel: fromLabel,
961
+ SenderName: senderShip,
962
+ SenderId: senderShip,
963
+ SenderRole: senderRole,
964
+ CommandAuthorized: commandAuthorized,
965
+ CommandSource: "text",
966
+ Provider: "tlon",
967
+ Surface: "tlon",
968
+ MessageSid: messageId,
969
+ // Include downloaded media attachments
970
+ ...(attachments.length > 0 && { Attachments: attachments }),
971
+ OriginatingChannel: "tlon",
972
+ OriginatingTo: `tlon:${isGroup ? groupChannel : botShipName}`,
973
+ // Include thread context for automatic reply routing
974
+ ...(parentId && { MessageThreadId: String(parentId), ReplyToId: String(parentId) }),
975
+ });
976
+ const dispatchStartTime = Date.now();
977
+ const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix;
978
+ const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
979
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
980
+ ctx: ctxPayload,
981
+ cfg,
982
+ dispatcherOptions: {
983
+ responsePrefix,
984
+ humanDelay,
985
+ deliver: async (payload) => {
986
+ let replyText = payload.text;
987
+ if (!replyText) {
988
+ return;
989
+ }
990
+ // Use settings store value if set, otherwise fall back to file config
991
+ const showSignature = effectiveShowModelSig;
992
+ if (showSignature) {
993
+ const modelInfo = payload.metadata?.model ||
994
+ payload.model ||
995
+ route.model ||
996
+ cfg.agents?.defaults?.model?.primary;
997
+ replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
998
+ }
999
+ if (isGroup && groupChannel) {
1000
+ // Send to any channel type (chat, heap, diary) using the nest directly
1001
+ await sendChannelPost({
1002
+ fromShip: botShipName,
1003
+ nest: groupChannel,
1004
+ story: markdownToStory(replyText),
1005
+ replyToId: deliverParentId ?? undefined,
1006
+ });
1007
+ // Track thread participation for future replies without mention
1008
+ if (deliverParentId) {
1009
+ participatedThreads.add(String(deliverParentId));
1010
+ runtime.log?.(`[tlon] Now tracking thread for future replies: ${deliverParentId}`);
1011
+ }
1012
+ }
1013
+ else {
1014
+ await sendDm({ fromShip: botShipName, toShip: senderShip, text: replyText, replyToId: deliverParentId ? String(deliverParentId) : undefined });
1015
+ }
1016
+ },
1017
+ onError: (err, info) => {
1018
+ const dispatchDuration = Date.now() - dispatchStartTime;
1019
+ runtime.error?.(`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`);
1020
+ },
1021
+ },
1022
+ });
1023
+ };
1024
+ // Track which channels we're interested in for filtering firehose events
1025
+ const watchedChannels = new Set(groupChannels);
1026
+ const _watchedDMs = new Set();
1027
+ // Firehose handler for all channel messages (/v2)
1028
+ const handleChannelsFirehose = async (event) => {
1029
+ try {
1030
+ const nest = event?.nest;
1031
+ if (!nest) {
1032
+ return;
1033
+ }
1034
+ // Auto-watch channels from firehose: if we receive events for a channel,
1035
+ // the bot is a member of the group — add it to watchedChannels automatically.
1036
+ if (!watchedChannels.has(nest) && (nest.startsWith("chat/") || nest.startsWith("heap/"))) {
1037
+ watchedChannels.add(nest);
1038
+ runtime.log?.(`[tlon] Auto-watching channel from firehose: ${nest}`);
1039
+ }
1040
+ // Only process channels we're watching
1041
+ if (!watchedChannels.has(nest)) {
1042
+ return;
1043
+ }
1044
+ const response = event?.response;
1045
+ if (!response) {
1046
+ return;
1047
+ }
1048
+ // Handle reaction events (top-level posts)
1049
+ const reacts = response?.post?.["r-post"]?.reacts;
1050
+ // Handle reaction events (replies/comments)
1051
+ const replyReacts = response?.post?.["r-post"]?.reply?.["r-reply"]?.reacts;
1052
+ const effectiveReacts = reacts || replyReacts;
1053
+ if (effectiveReacts && typeof effectiveReacts === "object") {
1054
+ const postId = replyReacts
1055
+ ? (response?.post?.["r-post"]?.reply?.id ?? response?.post?.id ?? "unknown")
1056
+ : (response?.post?.id ?? "unknown");
1057
+ for (const [reactShip, reactEmoji] of Object.entries(effectiveReacts)) {
1058
+ const ship = normalizeShip(reactShip);
1059
+ if (!ship || ship === botShipName)
1060
+ continue;
1061
+ try {
1062
+ const route = core.channel.routing.resolveAgentRoute({
1063
+ cfg,
1064
+ channel: "tlon",
1065
+ accountId: opts.accountId ?? undefined,
1066
+ peer: { kind: "group", id: nest },
1067
+ });
1068
+ // Look up the reacted-to message content for context
1069
+ const cached = lookupCachedMessage(nest, postId);
1070
+ const contentSnippet = cached?.content
1071
+ ? ` (message: "${cached.content.substring(0, 200)}${cached.content.length > 200 ? "..." : ""}")`
1072
+ : "";
1073
+ const authorInfo = cached?.author ? ` (by ${formatShipWithNickname(cached.author)})` : "";
1074
+ const reactorDisplay = formatShipWithNickname(ship);
1075
+ const eventText = `Tlon reaction in ${nest}: ${reactEmoji} by ${reactorDisplay} on post ${postId}${authorInfo}${contentSnippet}`;
1076
+ runtime.log?.(`[tlon] REACTION: ${eventText}`);
1077
+ // If reacting to the bot's own message, dispatch as a real message
1078
+ // so the agent runs immediately (e.g. thumbs-up as "yes")
1079
+ if (cached?.author === botShipName) {
1080
+ // Include context so agent knows what was reacted to, since we're
1081
+ // deliberately omitting thread context (parentId) to avoid the agent
1082
+ // suppressing responses when it sees its own message in thread history.
1083
+ const reactionParentId = replyReacts
1084
+ ? (response?.post?.id ?? postId)
1085
+ : postId;
1086
+ const reactText = cached?.content
1087
+ ? `${reactEmoji} (reacting to: "${cached.content}")`
1088
+ : reactEmoji;
1089
+ runtime.log?.(`[tlon] Dispatching channel reaction as message: ${reactEmoji} from ${ship}`);
1090
+ const parsed = parseChannelNest(nest);
1091
+ await processMessage({
1092
+ messageId: `react-${postId}-${ship}-${Date.now()}`,
1093
+ senderShip: ship,
1094
+ messageText: reactText,
1095
+ isGroup: true,
1096
+ channelNest: nest,
1097
+ hostShip: parsed?.hostShip,
1098
+ channelName: parsed?.channelName,
1099
+ timestamp: Date.now(),
1100
+ replyParentId: reactionParentId, // Thread reply for delivery only
1101
+ });
1102
+ }
1103
+ else {
1104
+ // For reactions on other people's messages, just enqueue as system event
1105
+ core.system.enqueueSystemEvent(eventText, {
1106
+ sessionKey: route.sessionKey,
1107
+ contextKey: `tlon:reaction:${nest}:${postId}:${reactEmoji}:${ship}`,
1108
+ });
1109
+ }
1110
+ }
1111
+ catch (err) {
1112
+ runtime.error?.(`[tlon] Error handling reaction: ${err?.message ?? String(err)}`);
1113
+ }
1114
+ }
1115
+ return;
1116
+ }
1117
+ // Handle post responses (new posts and replies)
1118
+ const essay = response?.post?.["r-post"]?.set?.essay;
1119
+ const memo = response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
1120
+ if (!essay && !memo) {
1121
+ return;
1122
+ }
1123
+ const content = memo || essay;
1124
+ const isThreadReply = Boolean(memo);
1125
+ const messageId = isThreadReply ? response?.post?.["r-post"]?.reply?.id : response?.post?.id;
1126
+ if (!processedTracker.mark(messageId)) {
1127
+ return;
1128
+ }
1129
+ const senderShip = normalizeShip(content?.author ?? "");
1130
+ if (!senderShip) {
1131
+ return;
1132
+ }
1133
+ // Resolve any cited/quoted messages first
1134
+ const citedContent = await resolveAllCites(content.content);
1135
+ const rawText = extractMessageText(content.content);
1136
+ const messageText = citedContent + rawText;
1137
+ if (!messageText.trim()) {
1138
+ return;
1139
+ }
1140
+ // Cache ALL messages (including bot's own) so reaction lookups have context
1141
+ cacheMessage(nest, {
1142
+ author: senderShip,
1143
+ content: messageText,
1144
+ timestamp: content.sent || Date.now(),
1145
+ id: messageId,
1146
+ });
1147
+ // Skip processing bot's own messages (but they're already cached above)
1148
+ if (senderShip === botShipName) {
1149
+ return;
1150
+ }
1151
+ // Get thread info early for participation check
1152
+ const seal = isThreadReply
1153
+ ? response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
1154
+ : response?.post?.["r-post"]?.set?.seal;
1155
+ const parentId = seal?.["parent-id"] || seal?.parent || null;
1156
+ // Check if we should respond:
1157
+ // 1. Direct mention always triggers response
1158
+ // 2. Thread replies where we've participated - respond if relevant (let agent decide)
1159
+ const mentioned = isBotMentioned(messageText, botShipName, botNickname ?? undefined);
1160
+ const inParticipatedThread = isThreadReply && parentId && participatedThreads.has(String(parentId));
1161
+ if (!mentioned && !inParticipatedThread) {
1162
+ return;
1163
+ }
1164
+ // Log why we're responding
1165
+ if (inParticipatedThread && !mentioned) {
1166
+ runtime.log?.(`[tlon] Responding to thread we participated in (no mention): ${parentId}`);
1167
+ }
1168
+ // Owner is always allowed
1169
+ if (isOwner(senderShip)) {
1170
+ runtime.log?.(`[tlon] Owner ${senderShip} is always allowed in channels`);
1171
+ }
1172
+ else {
1173
+ const { mode, allowedShips } = resolveChannelAuthorization(cfg, nest, currentSettings);
1174
+ if (mode === "restricted") {
1175
+ const normalizedAllowed = allowedShips.map(normalizeShip);
1176
+ if (!normalizedAllowed.includes(senderShip)) {
1177
+ // If owner is configured, queue approval request
1178
+ if (effectiveOwnerShip) {
1179
+ const approval = createPendingApproval({
1180
+ type: "channel",
1181
+ requestingShip: senderShip,
1182
+ channelNest: nest,
1183
+ messagePreview: messageText.substring(0, 100),
1184
+ originalMessage: {
1185
+ messageId: messageId ?? "",
1186
+ messageText,
1187
+ messageContent: content.content,
1188
+ timestamp: content.sent || Date.now(),
1189
+ parentId: parentId ?? undefined,
1190
+ isThreadReply,
1191
+ },
1192
+ });
1193
+ await queueApprovalRequest(approval);
1194
+ }
1195
+ else {
1196
+ runtime.log?.(`[tlon] Access denied: ${senderShip} in ${nest} (allowed: ${allowedShips.join(", ")})`);
1197
+ }
1198
+ return;
1199
+ }
1200
+ }
1201
+ }
1202
+ const parsed = parseChannelNest(nest);
1203
+ await processMessage({
1204
+ messageId: messageId ?? "",
1205
+ senderShip,
1206
+ messageText,
1207
+ messageContent: content.content, // Pass raw content for media extraction
1208
+ isGroup: true,
1209
+ channelNest: nest,
1210
+ hostShip: parsed?.hostShip,
1211
+ channelName: parsed?.channelName,
1212
+ timestamp: content.sent || Date.now(),
1213
+ parentId,
1214
+ isThreadReply,
1215
+ });
1216
+ }
1217
+ catch (error) {
1218
+ runtime.error?.(`[tlon] Error handling channel firehose event: ${error?.message ?? String(error)}`);
1219
+ }
1220
+ };
1221
+ // Firehose handler for all DM messages (/v3)
1222
+ // Track which DM invites we've already processed to avoid duplicate accepts
1223
+ const processedDmInvites = new Set();
1224
+ const handleChatFirehose = async (event) => {
1225
+ try {
1226
+ // Handle DM invite lists (arrays)
1227
+ if (Array.isArray(event)) {
1228
+ for (const invite of event) {
1229
+ const ship = normalizeShip(invite.ship || "");
1230
+ if (!ship || processedDmInvites.has(ship)) {
1231
+ continue;
1232
+ }
1233
+ // Owner is always allowed
1234
+ if (isOwner(ship)) {
1235
+ try {
1236
+ await api.poke({
1237
+ app: "chat",
1238
+ mark: "chat-dm-rsvp",
1239
+ json: { ship, ok: true },
1240
+ });
1241
+ processedDmInvites.add(ship);
1242
+ runtime.log?.(`[tlon] Auto-accepted DM invite from owner ${ship}`);
1243
+ }
1244
+ catch (err) {
1245
+ runtime.error?.(`[tlon] Failed to auto-accept DM from owner: ${String(err)}`);
1246
+ }
1247
+ continue;
1248
+ }
1249
+ // Auto-accept if on allowlist and auto-accept is enabled
1250
+ if (effectiveAutoAcceptDmInvites && isDmAllowed(ship, effectiveDmAllowlist)) {
1251
+ try {
1252
+ await api.poke({
1253
+ app: "chat",
1254
+ mark: "chat-dm-rsvp",
1255
+ json: { ship, ok: true },
1256
+ });
1257
+ processedDmInvites.add(ship);
1258
+ runtime.log?.(`[tlon] Auto-accepted DM invite from ${ship}`);
1259
+ }
1260
+ catch (err) {
1261
+ runtime.error?.(`[tlon] Failed to auto-accept DM from ${ship}: ${String(err)}`);
1262
+ }
1263
+ continue;
1264
+ }
1265
+ // If owner is configured and ship is not on allowlist, queue approval
1266
+ if (effectiveOwnerShip && !isDmAllowed(ship, effectiveDmAllowlist)) {
1267
+ const approval = createPendingApproval({
1268
+ type: "dm",
1269
+ requestingShip: ship,
1270
+ messagePreview: "(DM invite - no message yet)",
1271
+ });
1272
+ await queueApprovalRequest(approval);
1273
+ processedDmInvites.add(ship); // Mark as processed to avoid duplicate notifications
1274
+ }
1275
+ }
1276
+ return;
1277
+ }
1278
+ if (!("whom" in event) || !("response" in event)) {
1279
+ return;
1280
+ }
1281
+ const whom = event.whom; // DM partner ship or club ID
1282
+ const messageId = event.id;
1283
+ const response = event.response;
1284
+ // Handle add events (new messages)
1285
+ const essay = response?.add?.essay;
1286
+ const dmReply = response?.reply;
1287
+ // Handle DM reaction events
1288
+ const dmAddReact = response?.["add-react"];
1289
+ const dmDelReact = response?.["del-react"];
1290
+ if (dmAddReact || dmDelReact) {
1291
+ const isAdd = Boolean(dmAddReact);
1292
+ const reactData = dmAddReact || dmDelReact;
1293
+ const reactAuthor = normalizeShip(reactData?.author ?? reactData?.ship ?? "");
1294
+ const reactEmoji = reactData?.react ?? "";
1295
+ if (reactAuthor && reactAuthor !== botShipName) {
1296
+ try {
1297
+ const partnerShip = extractDmPartnerShip(whom);
1298
+ const route = core.channel.routing.resolveAgentRoute({
1299
+ cfg,
1300
+ channel: "tlon",
1301
+ accountId: opts.accountId ?? undefined,
1302
+ peer: { kind: "direct", id: partnerShip || reactAuthor },
1303
+ });
1304
+ // Look up cached DM message for context
1305
+ const dmCacheKey = `dm/${whom}`;
1306
+ const cached = lookupCachedMessage(dmCacheKey, messageId);
1307
+ const action = isAdd ? "added" : "removed";
1308
+ // If reacting to the bot's own message, dispatch as a real message
1309
+ // so the agent runs immediately (e.g. thumbs-up as "yes")
1310
+ if (isAdd && cached?.author === botShipName) {
1311
+ // Include context so agent knows what was reacted to
1312
+ const reactText = cached?.content
1313
+ ? `${reactEmoji} (reacting to: "${cached.content}")`
1314
+ : reactEmoji;
1315
+ runtime.log?.(`[tlon] Dispatching DM reaction as message: ${reactEmoji} from ${reactAuthor}`);
1316
+ await processMessage({
1317
+ messageId: `react-${messageId}-${reactAuthor}-${Date.now()}`,
1318
+ senderShip: reactAuthor,
1319
+ messageText: reactText,
1320
+ isGroup: false,
1321
+ timestamp: Date.now(),
1322
+ replyParentId: messageId, // Thread reply for delivery only
1323
+ });
1324
+ }
1325
+ else {
1326
+ const contentSnippet = cached?.content
1327
+ ? ` (message: "${cached.content.substring(0, 200)}${cached.content.length > 200 ? "..." : ""}")`
1328
+ : "";
1329
+ const authorInfo = cached?.author ? ` (by ${formatShipWithNickname(cached.author)})` : "";
1330
+ const reactorDisplay = formatShipWithNickname(reactAuthor);
1331
+ const eventText = `Tlon DM reaction ${action}: ${reactEmoji} by ${reactorDisplay} on message ${messageId}${authorInfo}${contentSnippet}`;
1332
+ core.system.enqueueSystemEvent(eventText, {
1333
+ sessionKey: route.sessionKey,
1334
+ contextKey: `tlon:dm-reaction:${messageId}:${reactEmoji}:${reactAuthor}:${action}`,
1335
+ });
1336
+ runtime.log?.(`[tlon] DM_REACTION: ${eventText}`);
1337
+ }
1338
+ }
1339
+ catch (err) {
1340
+ runtime.error?.(`[tlon] Error handling DM reaction: ${err?.message ?? String(err)}`);
1341
+ }
1342
+ }
1343
+ return;
1344
+ }
1345
+ // Extract memo from DM thread reply
1346
+ const dmReplyMemo = dmReply?.delta?.add?.memo;
1347
+ const dmReplyParentId = dmReply ? event.id : undefined;
1348
+ const isDmThreadReply = Boolean(dmReplyMemo);
1349
+ const dmContent = essay || dmReplyMemo;
1350
+ // For DM thread replies, extract the reply's own ID (distinct from the parent post ID)
1351
+ // The reply ID may be in dmReply.id, or we construct it from author/sent
1352
+ let dmReplyOwnId;
1353
+ if (isDmThreadReply && dmReply) {
1354
+ dmReplyOwnId = dmReply.id ?? dmReply.delta?.add?.id;
1355
+ // If no explicit reply ID, construct from author/sent (same format as our outbound)
1356
+ if (!dmReplyOwnId && dmReplyMemo?.author && dmReplyMemo?.sent) {
1357
+ dmReplyOwnId = `${normalizeShip(dmReplyMemo.author)}/${dmReplyMemo.sent}`;
1358
+ }
1359
+ }
1360
+ if (!dmContent) {
1361
+ return;
1362
+ }
1363
+ // Use the reply's own ID for thread replies so the agent has the correct message ID
1364
+ const effectiveMessageId = dmReplyOwnId ?? messageId;
1365
+ if (!processedTracker.mark(effectiveMessageId)) {
1366
+ return;
1367
+ }
1368
+ const authorShip = normalizeShip(dmContent?.author ?? "");
1369
+ const partnerShip = extractDmPartnerShip(whom);
1370
+ const senderShip = partnerShip || authorShip;
1371
+ // Cache DM messages (including bot's own) so reaction lookups have context
1372
+ const dmCacheKey = `dm/${whom}`;
1373
+ const rawCacheText = extractMessageText(dmContent.content);
1374
+ if (rawCacheText.trim()) {
1375
+ cacheMessage(dmCacheKey, {
1376
+ author: authorShip,
1377
+ content: rawCacheText,
1378
+ timestamp: dmContent.sent || Date.now(),
1379
+ id: effectiveMessageId,
1380
+ });
1381
+ }
1382
+ // Skip processing bot's own messages (but they're already cached above)
1383
+ if (authorShip === botShipName) {
1384
+ return;
1385
+ }
1386
+ if (!senderShip || senderShip === botShipName) {
1387
+ return;
1388
+ }
1389
+ // Log mismatch between author and partner for debugging
1390
+ if (authorShip && partnerShip && authorShip !== partnerShip) {
1391
+ runtime.log?.(`[tlon] DM ship mismatch (author=${authorShip}, partner=${partnerShip}) - routing to partner`);
1392
+ }
1393
+ // Resolve any cited/quoted messages first
1394
+ const citedContent = await resolveAllCites(dmContent.content);
1395
+ const rawText = extractMessageText(dmContent.content);
1396
+ const messageText = citedContent + rawText;
1397
+ if (!messageText.trim()) {
1398
+ return;
1399
+ }
1400
+ // Check if this is the owner sending an approval response
1401
+ if (isOwner(senderShip) && isApprovalResponse(messageText)) {
1402
+ const handled = await handleApprovalResponse(messageText);
1403
+ if (handled) {
1404
+ runtime.log?.(`[tlon] Processed approval response from owner: ${messageText}`);
1405
+ return;
1406
+ }
1407
+ }
1408
+ // Check if this is the owner sending an admin command
1409
+ if (isOwner(senderShip) && isAdminCommand(messageText)) {
1410
+ const handled = await handleAdminCommand(messageText);
1411
+ if (handled) {
1412
+ runtime.log?.(`[tlon] Processed admin command from owner: ${messageText}`);
1413
+ return;
1414
+ }
1415
+ }
1416
+ // Owner is always allowed to DM (bypass allowlist)
1417
+ if (isOwner(senderShip)) {
1418
+ runtime.log?.(`[tlon] Processing DM from owner ${senderShip}${isDmThreadReply ? ` (thread reply, parent=${dmReplyParentId}, replyId=${effectiveMessageId})` : ""}`);
1419
+ await processMessage({
1420
+ messageId: effectiveMessageId ?? "",
1421
+ senderShip,
1422
+ messageText,
1423
+ messageContent: dmContent.content,
1424
+ isGroup: false,
1425
+ timestamp: dmContent.sent || Date.now(),
1426
+ parentId: dmReplyParentId,
1427
+ isThreadReply: isDmThreadReply,
1428
+ });
1429
+ return;
1430
+ }
1431
+ // For DMs from others, check allowlist
1432
+ if (!isDmAllowed(senderShip, effectiveDmAllowlist)) {
1433
+ // If owner is configured, queue approval request
1434
+ if (effectiveOwnerShip) {
1435
+ const approval = createPendingApproval({
1436
+ type: "dm",
1437
+ requestingShip: senderShip,
1438
+ messagePreview: messageText.substring(0, 100),
1439
+ originalMessage: {
1440
+ messageId: effectiveMessageId ?? "",
1441
+ messageText,
1442
+ messageContent: dmContent.content,
1443
+ timestamp: dmContent.sent || Date.now(),
1444
+ },
1445
+ });
1446
+ await queueApprovalRequest(approval);
1447
+ }
1448
+ else {
1449
+ runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
1450
+ }
1451
+ return;
1452
+ }
1453
+ await processMessage({
1454
+ messageId: effectiveMessageId ?? "",
1455
+ senderShip,
1456
+ messageText,
1457
+ messageContent: dmContent.content, // Pass raw content for media extraction
1458
+ isGroup: false,
1459
+ timestamp: dmContent.sent || Date.now(),
1460
+ parentId: dmReplyParentId,
1461
+ isThreadReply: isDmThreadReply,
1462
+ });
1463
+ }
1464
+ catch (error) {
1465
+ runtime.error?.(`[tlon] Error handling chat firehose event: ${error?.message ?? String(error)}`);
1466
+ }
1467
+ };
1468
+ try {
1469
+ runtime.log?.("[tlon] Subscribing to firehose updates...");
1470
+ // Subscribe to channels firehose (/v2)
1471
+ await api.subscribe({
1472
+ app: "channels",
1473
+ path: "/v2",
1474
+ event: handleChannelsFirehose,
1475
+ err: (error) => {
1476
+ runtime.error?.(`[tlon] Channels firehose error: ${String(error)}`);
1477
+ },
1478
+ quit: () => {
1479
+ runtime.log?.("[tlon] Channels firehose quit received, SSE client will resubscribe");
1480
+ },
1481
+ });
1482
+ runtime.log?.("[tlon] Subscribed to channels firehose (/v2)");
1483
+ // Subscribe to chat/DM firehose (/v3)
1484
+ await api.subscribe({
1485
+ app: "chat",
1486
+ path: "/v3",
1487
+ event: handleChatFirehose,
1488
+ err: (error) => {
1489
+ runtime.error?.(`[tlon] Chat firehose error: ${String(error)}`);
1490
+ },
1491
+ quit: () => {
1492
+ runtime.log?.("[tlon] Chat firehose quit received, SSE client will resubscribe");
1493
+ },
1494
+ });
1495
+ runtime.log?.("[tlon] Subscribed to chat firehose (/v3)");
1496
+ // Subscribe to contacts updates to track nickname changes
1497
+ await api.subscribe({
1498
+ app: "contacts",
1499
+ path: "/v1/news",
1500
+ event: (event) => {
1501
+ try {
1502
+ // Look for self profile updates
1503
+ if (event?.self) {
1504
+ const selfUpdate = event.self;
1505
+ if (selfUpdate?.contact?.nickname?.value !== undefined) {
1506
+ const newNickname = selfUpdate.contact.nickname.value || null;
1507
+ if (newNickname !== botNickname) {
1508
+ botNickname = newNickname;
1509
+ runtime.log?.(`[tlon] Bot nickname updated: ${botNickname}`);
1510
+ if (botNickname) {
1511
+ nicknameCache.set(botShipName, botNickname);
1512
+ }
1513
+ else {
1514
+ nicknameCache.delete(botShipName);
1515
+ }
1516
+ }
1517
+ }
1518
+ }
1519
+ // Look for peer profile updates (other users)
1520
+ if (event?.peer) {
1521
+ const ship = event.peer.ship ? normalizeShip(event.peer.ship) : null;
1522
+ const nickname = event.peer.contact?.nickname?.value ?? event.peer.contact?.nickname;
1523
+ if (ship) {
1524
+ if (nickname && typeof nickname === "string") {
1525
+ nicknameCache.set(ship, nickname);
1526
+ }
1527
+ else {
1528
+ nicknameCache.delete(ship);
1529
+ }
1530
+ }
1531
+ }
1532
+ }
1533
+ catch (error) {
1534
+ runtime.error?.(`[tlon] Error handling contacts event: ${error?.message ?? String(error)}`);
1535
+ }
1536
+ },
1537
+ err: (error) => {
1538
+ runtime.error?.(`[tlon] Contacts subscription error: ${String(error)}`);
1539
+ },
1540
+ quit: () => {
1541
+ runtime.log?.("[tlon] Contacts quit received, SSE client will resubscribe");
1542
+ },
1543
+ });
1544
+ runtime.log?.("[tlon] Subscribed to contacts updates (/v1/news)");
1545
+ // Subscribe to settings store for hot-reloading config
1546
+ settingsManager.onChange((newSettings) => {
1547
+ currentSettings = newSettings;
1548
+ // Update watched channels if settings changed
1549
+ if (newSettings.groupChannels?.length) {
1550
+ const newChannels = newSettings.groupChannels;
1551
+ for (const ch of newChannels) {
1552
+ if (!watchedChannels.has(ch)) {
1553
+ watchedChannels.add(ch);
1554
+ runtime.log?.(`[tlon] Settings: now watching channel ${ch}`);
1555
+ }
1556
+ }
1557
+ // Note: we don't remove channels from watchedChannels to avoid missing messages
1558
+ // during transitions. The authorization check handles access control.
1559
+ }
1560
+ // Update DM allowlist
1561
+ if (newSettings.dmAllowlist !== undefined) {
1562
+ effectiveDmAllowlist =
1563
+ newSettings.dmAllowlist.length > 0 ? newSettings.dmAllowlist : account.dmAllowlist;
1564
+ runtime.log?.(`[tlon] Settings: dmAllowlist updated to ${effectiveDmAllowlist.join(", ")}`);
1565
+ }
1566
+ // Update model signature setting
1567
+ if (newSettings.showModelSig !== undefined) {
1568
+ effectiveShowModelSig = newSettings.showModelSig;
1569
+ runtime.log?.(`[tlon] Settings: showModelSig = ${effectiveShowModelSig}`);
1570
+ }
1571
+ // Update auto-accept DM invites setting
1572
+ if (newSettings.autoAcceptDmInvites !== undefined) {
1573
+ effectiveAutoAcceptDmInvites = newSettings.autoAcceptDmInvites;
1574
+ runtime.log?.(`[tlon] Settings: autoAcceptDmInvites = ${effectiveAutoAcceptDmInvites}`);
1575
+ }
1576
+ // Update auto-accept group invites setting
1577
+ if (newSettings.autoAcceptGroupInvites !== undefined) {
1578
+ effectiveAutoAcceptGroupInvites = newSettings.autoAcceptGroupInvites;
1579
+ runtime.log?.(`[tlon] Settings: autoAcceptGroupInvites = ${effectiveAutoAcceptGroupInvites}`);
1580
+ }
1581
+ // Update group invite allowlist
1582
+ if (newSettings.groupInviteAllowlist !== undefined) {
1583
+ effectiveGroupInviteAllowlist =
1584
+ newSettings.groupInviteAllowlist.length > 0
1585
+ ? newSettings.groupInviteAllowlist
1586
+ : account.groupInviteAllowlist;
1587
+ runtime.log?.(`[tlon] Settings: groupInviteAllowlist updated to ${effectiveGroupInviteAllowlist.join(", ")}`);
1588
+ }
1589
+ if (newSettings.defaultAuthorizedShips !== undefined) {
1590
+ runtime.log?.(`[tlon] Settings: defaultAuthorizedShips updated to ${(newSettings.defaultAuthorizedShips || []).join(", ")}`);
1591
+ }
1592
+ // Update auto-discover channels
1593
+ if (newSettings.autoDiscoverChannels !== undefined) {
1594
+ effectiveAutoDiscoverChannels = newSettings.autoDiscoverChannels;
1595
+ runtime.log?.(`[tlon] Settings: autoDiscoverChannels = ${effectiveAutoDiscoverChannels}`);
1596
+ }
1597
+ // Update owner ship
1598
+ if (newSettings.ownerShip !== undefined) {
1599
+ effectiveOwnerShip = newSettings.ownerShip
1600
+ ? normalizeShip(newSettings.ownerShip)
1601
+ : account.ownerShip
1602
+ ? normalizeShip(account.ownerShip)
1603
+ : null;
1604
+ runtime.log?.(`[tlon] Settings: ownerShip = ${effectiveOwnerShip}`);
1605
+ }
1606
+ // Update pending approvals
1607
+ if (newSettings.pendingApprovals !== undefined) {
1608
+ pendingApprovals = newSettings.pendingApprovals;
1609
+ runtime.log?.(`[tlon] Settings: pendingApprovals updated (${pendingApprovals.length} items)`);
1610
+ }
1611
+ });
1612
+ try {
1613
+ await settingsManager.startSubscription();
1614
+ }
1615
+ catch (err) {
1616
+ // Settings subscription is optional - don't fail if it doesn't work
1617
+ runtime.log?.(`[tlon] Settings subscription not available: ${String(err)}`);
1618
+ }
1619
+ // Subscribe to groups-ui for real-time channel additions (when invites are accepted)
1620
+ try {
1621
+ await api.subscribe({
1622
+ app: "groups",
1623
+ path: "/groups/ui",
1624
+ event: async (event) => {
1625
+ try {
1626
+ // Handle fleet (member) changes - inject system message for joins
1627
+ if (event?.flag && event?.update?.fleet) {
1628
+ const groupFlag = event.flag;
1629
+ const fleet = event.update.fleet;
1630
+ // Fleet structure: { "~ship": { add: null } } or similar
1631
+ if (fleet && typeof fleet === "object") {
1632
+ for (const [ship, diff] of Object.entries(fleet)) {
1633
+ if (diff && typeof diff === "object" && "add" in diff) {
1634
+ // New member joined - find sessions with channels in this group
1635
+ for (const [nest, flag] of channelToGroup.entries()) {
1636
+ if (flag === groupFlag && watchedChannels.has(nest)) {
1637
+ const route = core.channel.routing.resolveAgentRoute({
1638
+ cfg,
1639
+ channel: "tlon",
1640
+ peer: { kind: "group", id: nest },
1641
+ });
1642
+ if (route?.sessionKey) {
1643
+ const memberDisplay = formatShipWithNickname(ship);
1644
+ core.system.enqueueSystemEvent(`[${memberDisplay} joined group ${groupFlag}]`, { sessionKey: route.sessionKey });
1645
+ runtime.log?.(`[tlon] Member joined: ${ship} → ${groupFlag}`);
1646
+ break; // Only inject once per group
1647
+ }
1648
+ }
1649
+ }
1650
+ }
1651
+ }
1652
+ }
1653
+ }
1654
+ // Handle group/channel join events
1655
+ // Event structure: { group: { flag: "~host/group-name", ... }, channels: { ... } }
1656
+ if (event && typeof event === "object") {
1657
+ // Check for new channels being added to groups
1658
+ if (event.channels && typeof event.channels === "object") {
1659
+ const channels = event.channels;
1660
+ for (const [channelNest, _channelData] of Object.entries(channels)) {
1661
+ // Only monitor chat and heap channels
1662
+ if (!channelNest.startsWith("chat/") && !channelNest.startsWith("heap/")) {
1663
+ continue;
1664
+ }
1665
+ // If this is a new channel we're not watching yet, add it
1666
+ if (!watchedChannels.has(channelNest)) {
1667
+ watchedChannels.add(channelNest);
1668
+ runtime.log?.(`[tlon] Auto-detected new channel (invite accepted): ${channelNest}`);
1669
+ // Persist to settings store so it survives restarts
1670
+ if (effectiveAutoAcceptGroupInvites) {
1671
+ try {
1672
+ const currentChannels = currentSettings.groupChannels || [];
1673
+ if (!currentChannels.includes(channelNest)) {
1674
+ const updatedChannels = [...currentChannels, channelNest];
1675
+ // Poke settings store to persist
1676
+ await api.poke({
1677
+ app: "settings",
1678
+ mark: "settings-event",
1679
+ json: {
1680
+ "put-entry": {
1681
+ "bucket-key": "tlon",
1682
+ "entry-key": "groupChannels",
1683
+ value: updatedChannels,
1684
+ desk: "moltbot",
1685
+ },
1686
+ },
1687
+ });
1688
+ runtime.log?.(`[tlon] Persisted ${channelNest} to settings store`);
1689
+ }
1690
+ }
1691
+ catch (err) {
1692
+ runtime.error?.(`[tlon] Failed to persist channel to settings: ${String(err)}`);
1693
+ }
1694
+ }
1695
+ }
1696
+ }
1697
+ }
1698
+ // Also check for the "join" event structure
1699
+ if (event.join && typeof event.join === "object") {
1700
+ const join = event.join;
1701
+ if (join.channels) {
1702
+ for (const channelNest of join.channels) {
1703
+ if (!channelNest.startsWith("chat/") && !channelNest.startsWith("heap/")) {
1704
+ continue;
1705
+ }
1706
+ if (!watchedChannels.has(channelNest)) {
1707
+ watchedChannels.add(channelNest);
1708
+ runtime.log?.(`[tlon] Auto-detected joined channel: ${channelNest}`);
1709
+ // Persist to settings store
1710
+ if (effectiveAutoAcceptGroupInvites) {
1711
+ try {
1712
+ const currentChannels = currentSettings.groupChannels || [];
1713
+ if (!currentChannels.includes(channelNest)) {
1714
+ const updatedChannels = [...currentChannels, channelNest];
1715
+ await api.poke({
1716
+ app: "settings",
1717
+ mark: "settings-event",
1718
+ json: {
1719
+ "put-entry": {
1720
+ "bucket-key": "tlon",
1721
+ "entry-key": "groupChannels",
1722
+ value: updatedChannels,
1723
+ desk: "moltbot",
1724
+ },
1725
+ },
1726
+ });
1727
+ runtime.log?.(`[tlon] Persisted ${channelNest} to settings store`);
1728
+ }
1729
+ }
1730
+ catch (err) {
1731
+ runtime.error?.(`[tlon] Failed to persist channel to settings: ${String(err)}`);
1732
+ }
1733
+ }
1734
+ }
1735
+ }
1736
+ }
1737
+ }
1738
+ }
1739
+ }
1740
+ catch (error) {
1741
+ runtime.error?.(`[tlon] Error handling groups-ui event: ${error?.message ?? String(error)}`);
1742
+ }
1743
+ },
1744
+ err: (error) => {
1745
+ runtime.error?.(`[tlon] Groups-ui subscription error: ${String(error)}`);
1746
+ },
1747
+ quit: () => {
1748
+ runtime.log?.("[tlon] Groups-ui quit received, SSE client will resubscribe");
1749
+ },
1750
+ });
1751
+ runtime.log?.("[tlon] Subscribed to groups-ui for real-time channel detection");
1752
+ }
1753
+ catch (err) {
1754
+ // Groups-ui subscription is optional - channel discovery will still work via polling
1755
+ runtime.log?.(`[tlon] Groups-ui subscription failed (will rely on polling): ${String(err)}`);
1756
+ }
1757
+ // Subscribe to foreigns for auto-accepting group invites
1758
+ // Always subscribe so we can hot-reload the setting via settings store
1759
+ {
1760
+ const processedGroupInvites = new Set();
1761
+ // Helper to process pending invites
1762
+ const processPendingInvites = async (foreigns) => {
1763
+ if (!foreigns || typeof foreigns !== "object") {
1764
+ return;
1765
+ }
1766
+ for (const [groupFlag, foreign] of Object.entries(foreigns)) {
1767
+ if (processedGroupInvites.has(groupFlag)) {
1768
+ continue;
1769
+ }
1770
+ if (!foreign.invites || foreign.invites.length === 0) {
1771
+ continue;
1772
+ }
1773
+ const validInvite = foreign.invites.find((inv) => inv.valid);
1774
+ if (!validInvite) {
1775
+ continue;
1776
+ }
1777
+ const inviterShip = validInvite.from;
1778
+ const normalizedInviter = normalizeShip(inviterShip);
1779
+ // Owner invites are always accepted
1780
+ if (isOwner(inviterShip)) {
1781
+ try {
1782
+ await api.poke({
1783
+ app: "groups",
1784
+ mark: "group-join",
1785
+ json: {
1786
+ flag: groupFlag,
1787
+ "join-all": true,
1788
+ },
1789
+ });
1790
+ processedGroupInvites.add(groupFlag);
1791
+ runtime.log?.(`[tlon] Auto-accepted group invite from owner: ${groupFlag}`);
1792
+ }
1793
+ catch (err) {
1794
+ runtime.error?.(`[tlon] Failed to accept group invite from owner: ${String(err)}`);
1795
+ }
1796
+ continue;
1797
+ }
1798
+ // Skip if auto-accept is disabled
1799
+ if (!effectiveAutoAcceptGroupInvites) {
1800
+ // If owner is configured, queue approval
1801
+ if (effectiveOwnerShip) {
1802
+ const approval = createPendingApproval({
1803
+ type: "group",
1804
+ requestingShip: inviterShip,
1805
+ groupFlag,
1806
+ });
1807
+ await queueApprovalRequest(approval);
1808
+ processedGroupInvites.add(groupFlag);
1809
+ }
1810
+ continue;
1811
+ }
1812
+ // Check if inviter is on allowlist
1813
+ const isAllowed = effectiveGroupInviteAllowlist.length > 0
1814
+ ? effectiveGroupInviteAllowlist
1815
+ .map((s) => normalizeShip(s))
1816
+ .some((s) => s === normalizedInviter)
1817
+ : false; // Fail-safe: empty allowlist means deny
1818
+ if (!isAllowed) {
1819
+ // If owner is configured, queue approval
1820
+ if (effectiveOwnerShip) {
1821
+ const approval = createPendingApproval({
1822
+ type: "group",
1823
+ requestingShip: inviterShip,
1824
+ groupFlag,
1825
+ });
1826
+ await queueApprovalRequest(approval);
1827
+ processedGroupInvites.add(groupFlag);
1828
+ }
1829
+ else {
1830
+ runtime.log?.(`[tlon] Rejected group invite from ${inviterShip} (not in groupInviteAllowlist): ${groupFlag}`);
1831
+ processedGroupInvites.add(groupFlag);
1832
+ }
1833
+ continue;
1834
+ }
1835
+ // Inviter is on allowlist - accept the invite
1836
+ try {
1837
+ await api.poke({
1838
+ app: "groups",
1839
+ mark: "group-join",
1840
+ json: {
1841
+ flag: groupFlag,
1842
+ "join-all": true,
1843
+ },
1844
+ });
1845
+ processedGroupInvites.add(groupFlag);
1846
+ runtime.log?.(`[tlon] Auto-accepted group invite: ${groupFlag} (from ${validInvite.from})`);
1847
+ }
1848
+ catch (err) {
1849
+ runtime.error?.(`[tlon] Failed to auto-accept group ${groupFlag}: ${String(err)}`);
1850
+ }
1851
+ }
1852
+ };
1853
+ // Process existing pending invites from init data
1854
+ if (initForeigns) {
1855
+ await processPendingInvites(initForeigns);
1856
+ }
1857
+ try {
1858
+ await api.subscribe({
1859
+ app: "groups",
1860
+ path: "/v1/foreigns",
1861
+ event: (data) => {
1862
+ void (async () => {
1863
+ try {
1864
+ await processPendingInvites(data);
1865
+ }
1866
+ catch (error) {
1867
+ runtime.error?.(`[tlon] Error handling foreigns event: ${error?.message ?? String(error)}`);
1868
+ }
1869
+ })();
1870
+ },
1871
+ err: (error) => {
1872
+ runtime.error?.(`[tlon] Foreigns subscription error: ${String(error)}`);
1873
+ },
1874
+ quit: () => {
1875
+ runtime.log?.("[tlon] Foreigns quit received, SSE client will resubscribe");
1876
+ },
1877
+ });
1878
+ runtime.log?.("[tlon] Subscribed to foreigns (/v1/foreigns) for auto-accepting group invites");
1879
+ }
1880
+ catch (err) {
1881
+ runtime.log?.(`[tlon] Foreigns subscription failed: ${String(err)}`);
1882
+ }
1883
+ }
1884
+ // Discover channels to watch
1885
+ if (effectiveAutoDiscoverChannels) {
1886
+ const discoveredChannels = await fetchAllChannels(api, runtime);
1887
+ for (const channelNest of discoveredChannels) {
1888
+ watchedChannels.add(channelNest);
1889
+ }
1890
+ runtime.log?.(`[tlon] Watching ${watchedChannels.size} channel(s)`);
1891
+ }
1892
+ // Log watched channels
1893
+ for (const channelNest of watchedChannels) {
1894
+ runtime.log?.(`[tlon] Watching channel: ${channelNest}`);
1895
+ }
1896
+ runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream...");
1897
+ await api.connect();
1898
+ runtime.log?.("[tlon] Connected! Firehose subscriptions active");
1899
+ // Periodically refresh channel discovery
1900
+ const pollInterval = setInterval(async () => {
1901
+ if (!opts.abortSignal?.aborted) {
1902
+ try {
1903
+ if (effectiveAutoDiscoverChannels) {
1904
+ const discoveredChannels = await fetchAllChannels(api, runtime);
1905
+ for (const channelNest of discoveredChannels) {
1906
+ if (!watchedChannels.has(channelNest)) {
1907
+ watchedChannels.add(channelNest);
1908
+ runtime.log?.(`[tlon] Now watching new channel: ${channelNest}`);
1909
+ }
1910
+ }
1911
+ }
1912
+ }
1913
+ catch (error) {
1914
+ runtime.error?.(`[tlon] Channel refresh error: ${error?.message ?? String(error)}`);
1915
+ }
1916
+ }
1917
+ }, 2 * 60 * 1000);
1918
+ if (opts.abortSignal) {
1919
+ const signal = opts.abortSignal;
1920
+ await new Promise((resolve) => {
1921
+ signal.addEventListener("abort", () => {
1922
+ clearInterval(pollInterval);
1923
+ resolve(null);
1924
+ }, { once: true });
1925
+ });
1926
+ }
1927
+ else {
1928
+ await new Promise(() => { });
1929
+ }
1930
+ }
1931
+ finally {
1932
+ try {
1933
+ await api?.close();
1934
+ }
1935
+ catch (error) {
1936
+ runtime.error?.(`[tlon] Cleanup error: ${error?.message ?? String(error)}`);
1937
+ }
1938
+ }
1939
+ }
1940
+ //# sourceMappingURL=index.js.map