@kodelyth/tlon 2026.5.42 → 2026.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/klaw.plugin.json +203 -3
- package/package.json +17 -4
- package/api.ts +0 -16
- package/channel-plugin-api.ts +0 -1
- package/doctor-contract-api.ts +0 -1
- package/index.ts +0 -16
- package/runtime-api.ts +0 -17
- package/setup-api.ts +0 -2
- package/setup-entry.ts +0 -9
- package/src/account-fields.ts +0 -31
- package/src/channel.message-adapter.test.ts +0 -145
- package/src/channel.runtime.ts +0 -259
- package/src/channel.ts +0 -192
- package/src/config-schema.ts +0 -54
- package/src/core.test.ts +0 -298
- package/src/doctor-contract.ts +0 -9
- package/src/doctor.test.ts +0 -46
- package/src/doctor.ts +0 -10
- package/src/logger-runtime.ts +0 -1
- package/src/monitor/approval-runtime.ts +0 -363
- package/src/monitor/approval.test.ts +0 -33
- package/src/monitor/approval.ts +0 -283
- package/src/monitor/authorization.ts +0 -30
- package/src/monitor/cites.ts +0 -54
- package/src/monitor/discovery.ts +0 -68
- package/src/monitor/history.ts +0 -226
- package/src/monitor/index.ts +0 -1523
- package/src/monitor/media.test.ts +0 -80
- package/src/monitor/media.ts +0 -156
- package/src/monitor/processed-messages.test.ts +0 -58
- package/src/monitor/processed-messages.ts +0 -89
- package/src/monitor/settings-helpers.test.ts +0 -113
- package/src/monitor/settings-helpers.ts +0 -158
- package/src/monitor/utils.ts +0 -402
- package/src/runtime.ts +0 -9
- package/src/security.test.ts +0 -658
- package/src/session-route.ts +0 -40
- package/src/settings.ts +0 -391
- package/src/setup-core.ts +0 -231
- package/src/setup-surface.ts +0 -99
- package/src/targets.ts +0 -102
- package/src/tlon-api.test.ts +0 -572
- package/src/tlon-api.ts +0 -389
- package/src/types.ts +0 -160
- package/src/urbit/auth.ssrf.test.ts +0 -45
- package/src/urbit/auth.ts +0 -48
- package/src/urbit/base-url.test.ts +0 -48
- package/src/urbit/base-url.ts +0 -61
- package/src/urbit/channel-ops.test.ts +0 -36
- package/src/urbit/channel-ops.ts +0 -149
- package/src/urbit/context.ts +0 -50
- package/src/urbit/errors.ts +0 -51
- package/src/urbit/fetch.ts +0 -38
- package/src/urbit/foreigns.ts +0 -49
- package/src/urbit/send.test.ts +0 -83
- package/src/urbit/send.ts +0 -228
- package/src/urbit/sse-client.test.ts +0 -234
- package/src/urbit/sse-client.ts +0 -492
- package/src/urbit/story.ts +0 -332
- package/src/urbit/upload.test.ts +0 -155
- package/src/urbit/upload.ts +0 -60
- package/test-api.ts +0 -1
- package/tsconfig.json +0 -16
package/src/monitor/index.ts
DELETED
|
@@ -1,1523 +0,0 @@
|
|
|
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
|
-
}
|