@kodelyth/tlon 2026.5.39 → 2026.5.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/api.ts +16 -0
- package/channel-plugin-api.ts +1 -0
- package/dist/api.js +4 -0
- package/dist/channel-Bvzym9ez.js +236 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/channel.runtime-CDY2BdfM.js +3626 -0
- package/dist/doctor-contract-Ip6FcHDH.js +7 -0
- package/dist/doctor-contract-api.js +2 -0
- package/dist/index.js +18 -0
- package/dist/runtime-BmSb9A-q.js +8 -0
- package/dist/runtime-api-Dq8wkBC_.js +4 -0
- package/dist/runtime-api.js +2 -0
- package/dist/setup-api.js +3 -0
- package/dist/setup-core-CF3ryHqs.js +387 -0
- package/dist/setup-entry.js +11 -0
- package/dist/setup-surface-BM5_V_XL.js +74 -0
- package/dist/test-api.js +2 -0
- package/doctor-contract-api.ts +1 -0
- package/index.ts +16 -0
- package/klaw.plugin.json +3 -203
- package/package.json +4 -4
- package/runtime-api.ts +17 -0
- package/setup-api.ts +2 -0
- package/setup-entry.ts +9 -0
- package/src/account-fields.ts +31 -0
- package/src/channel.message-adapter.test.ts +145 -0
- package/src/channel.runtime.ts +259 -0
- package/src/channel.ts +192 -0
- package/src/config-schema.ts +54 -0
- package/src/core.test.ts +298 -0
- package/src/doctor-contract.ts +9 -0
- package/src/doctor.test.ts +46 -0
- package/src/doctor.ts +10 -0
- package/src/logger-runtime.ts +1 -0
- package/src/monitor/approval-runtime.ts +363 -0
- package/src/monitor/approval.test.ts +33 -0
- package/src/monitor/approval.ts +283 -0
- package/src/monitor/authorization.ts +30 -0
- package/src/monitor/cites.ts +54 -0
- package/src/monitor/discovery.ts +68 -0
- package/src/monitor/history.ts +226 -0
- package/src/monitor/index.ts +1523 -0
- package/src/monitor/media.test.ts +80 -0
- package/src/monitor/media.ts +156 -0
- package/src/monitor/processed-messages.test.ts +58 -0
- package/src/monitor/processed-messages.ts +89 -0
- package/src/monitor/settings-helpers.test.ts +113 -0
- package/src/monitor/settings-helpers.ts +158 -0
- package/src/monitor/utils.ts +402 -0
- package/src/runtime.ts +9 -0
- package/src/security.test.ts +658 -0
- package/src/session-route.ts +40 -0
- package/src/settings.ts +391 -0
- package/src/setup-core.ts +231 -0
- package/src/setup-surface.ts +99 -0
- package/src/targets.ts +102 -0
- package/src/tlon-api.test.ts +572 -0
- package/src/tlon-api.ts +389 -0
- package/src/types.ts +160 -0
- package/src/urbit/auth.ssrf.test.ts +45 -0
- package/src/urbit/auth.ts +48 -0
- package/src/urbit/base-url.test.ts +48 -0
- package/src/urbit/base-url.ts +61 -0
- package/src/urbit/channel-ops.test.ts +36 -0
- package/src/urbit/channel-ops.ts +149 -0
- package/src/urbit/context.ts +50 -0
- package/src/urbit/errors.ts +51 -0
- package/src/urbit/fetch.ts +38 -0
- package/src/urbit/foreigns.ts +49 -0
- package/src/urbit/send.test.ts +83 -0
- package/src/urbit/send.ts +228 -0
- package/src/urbit/sse-client.test.ts +234 -0
- package/src/urbit/sse-client.ts +492 -0
- package/src/urbit/story.ts +332 -0
- package/src/urbit/upload.test.ts +155 -0
- package/src/urbit/upload.ts +60 -0
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/api.js +0 -7
- package/bundled-skills/@tloncorp/tlon-skill/SKILL.md +0 -501
- package/bundled-skills/@tloncorp/tlon-skill/bin/tlon.js +0 -7
- package/bundled-skills/@tloncorp/tlon-skill/package.json +0 -40
- package/bundled-skills/@tloncorp/tlon-skill/scripts/postinstall.js +0 -7
- package/channel-plugin-api.js +0 -7
- package/doctor-contract-api.js +0 -7
- package/index.js +0 -7
- package/runtime-api.js +0 -7
- package/setup-api.js +0 -7
- package/setup-entry.js +0 -7
- package/test-api.js +0 -7
|
@@ -0,0 +1,3626 @@
|
|
|
1
|
+
import { n as createDedupeCache, s as createLoggerBackedRuntime } from "./runtime-api-Dq8wkBC_.js";
|
|
2
|
+
import { c as validateUrbitBaseUrl, d as formatTargetHint, f as normalizeShip, h as resolveTlonOutboundTarget, m as parseTlonTarget, p as parseChannelNest, s as normalizeUrbitHostname, u as resolveTlonAccount } from "./setup-core-CF3ryHqs.js";
|
|
3
|
+
import { t as getTlonRuntime } from "./runtime-BmSb9A-q.js";
|
|
4
|
+
import { t as tlonSetupWizard } from "./setup-surface-BM5_V_XL.js";
|
|
5
|
+
import { fetchWithSsrFGuard, ssrfPolicyFromDangerouslyAllowPrivateNetwork } from "klaw/plugin-sdk/ssrf-runtime";
|
|
6
|
+
import { createMessageReceiptFromOutboundResults } from "klaw/plugin-sdk/channel-message";
|
|
7
|
+
import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
|
|
8
|
+
import crypto, { randomBytes, randomUUID } from "node:crypto";
|
|
9
|
+
import { da, scot } from "@urbit/aura";
|
|
10
|
+
import { Readable } from "node:stream";
|
|
11
|
+
import { resolveStableChannelMessageIngress } from "klaw/plugin-sdk/channel-ingress-runtime";
|
|
12
|
+
import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
|
|
13
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { extensionForMime } from "klaw/plugin-sdk/media-mime";
|
|
16
|
+
import { MAX_IMAGE_BYTES, readRemoteMediaBuffer, saveRemoteMedia } from "klaw/plugin-sdk/media-runtime";
|
|
17
|
+
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
18
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
19
|
+
//#region extensions/tlon/src/settings.ts
|
|
20
|
+
const SETTINGS_DESK = "moltbot";
|
|
21
|
+
const SETTINGS_BUCKET = "tlon";
|
|
22
|
+
/**
|
|
23
|
+
* Parse channelRules - handles both JSON string and object formats.
|
|
24
|
+
* Settings-store doesn't support nested objects, so we store as JSON string.
|
|
25
|
+
*/
|
|
26
|
+
function parseChannelRules(value) {
|
|
27
|
+
if (!value) return;
|
|
28
|
+
if (typeof value === "string") try {
|
|
29
|
+
const parsed = JSON.parse(value);
|
|
30
|
+
if (isChannelRulesObject(parsed)) return parsed;
|
|
31
|
+
} catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (isChannelRulesObject(value)) return value;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parse settings from the raw Urbit settings-store response.
|
|
38
|
+
* The response shape is: { [bucket]: { [key]: value } }
|
|
39
|
+
*/
|
|
40
|
+
function parseSettingsResponse(raw) {
|
|
41
|
+
if (!raw || typeof raw !== "object") return {};
|
|
42
|
+
const bucket = raw[SETTINGS_BUCKET];
|
|
43
|
+
if (!bucket || typeof bucket !== "object") return {};
|
|
44
|
+
const settings = bucket;
|
|
45
|
+
return {
|
|
46
|
+
groupChannels: Array.isArray(settings.groupChannels) ? settings.groupChannels.filter((x) => typeof x === "string") : void 0,
|
|
47
|
+
dmAllowlist: Array.isArray(settings.dmAllowlist) ? settings.dmAllowlist.filter((x) => typeof x === "string") : void 0,
|
|
48
|
+
autoDiscover: typeof settings.autoDiscover === "boolean" ? settings.autoDiscover : void 0,
|
|
49
|
+
showModelSig: typeof settings.showModelSig === "boolean" ? settings.showModelSig : void 0,
|
|
50
|
+
autoAcceptDmInvites: typeof settings.autoAcceptDmInvites === "boolean" ? settings.autoAcceptDmInvites : void 0,
|
|
51
|
+
autoAcceptGroupInvites: typeof settings.autoAcceptGroupInvites === "boolean" ? settings.autoAcceptGroupInvites : void 0,
|
|
52
|
+
groupInviteAllowlist: Array.isArray(settings.groupInviteAllowlist) ? settings.groupInviteAllowlist.filter((x) => typeof x === "string") : void 0,
|
|
53
|
+
channelRules: parseChannelRules(settings.channelRules),
|
|
54
|
+
defaultAuthorizedShips: Array.isArray(settings.defaultAuthorizedShips) ? settings.defaultAuthorizedShips.filter((x) => typeof x === "string") : void 0,
|
|
55
|
+
ownerShip: typeof settings.ownerShip === "string" ? settings.ownerShip : void 0,
|
|
56
|
+
pendingApprovals: parsePendingApprovals(settings.pendingApprovals)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function isChannelRulesObject(val) {
|
|
60
|
+
if (!val || typeof val !== "object" || Array.isArray(val)) return false;
|
|
61
|
+
for (const [, rule] of Object.entries(val)) if (!rule || typeof rule !== "object") return false;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Parse pendingApprovals - handles both JSON string and array formats.
|
|
66
|
+
* Settings-store stores complex objects as JSON strings.
|
|
67
|
+
*/
|
|
68
|
+
function parsePendingApprovals(value) {
|
|
69
|
+
if (!value) return;
|
|
70
|
+
let parsed = value;
|
|
71
|
+
if (typeof value === "string") try {
|
|
72
|
+
parsed = JSON.parse(value);
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!Array.isArray(parsed)) return;
|
|
77
|
+
return parsed.filter((item) => {
|
|
78
|
+
if (!item || typeof item !== "object") return false;
|
|
79
|
+
const obj = item;
|
|
80
|
+
return typeof obj.id === "string" && (obj.type === "dm" || obj.type === "channel" || obj.type === "group") && typeof obj.requestingShip === "string" && typeof obj.timestamp === "number";
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Parse a single settings entry update event.
|
|
85
|
+
*/
|
|
86
|
+
function parseSettingsEvent(event) {
|
|
87
|
+
if (!event || typeof event !== "object") return null;
|
|
88
|
+
const evt = event;
|
|
89
|
+
if (evt["put-entry"]) {
|
|
90
|
+
const put = evt["put-entry"];
|
|
91
|
+
if (put.desk !== SETTINGS_DESK || put["bucket-key"] !== SETTINGS_BUCKET) return null;
|
|
92
|
+
return {
|
|
93
|
+
key: typeof put["entry-key"] === "string" ? put["entry-key"] : "",
|
|
94
|
+
value: put.value
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (evt["del-entry"]) {
|
|
98
|
+
const del = evt["del-entry"];
|
|
99
|
+
if (del.desk !== SETTINGS_DESK || del["bucket-key"] !== SETTINGS_BUCKET) return null;
|
|
100
|
+
return {
|
|
101
|
+
key: typeof del["entry-key"] === "string" ? del["entry-key"] : "",
|
|
102
|
+
value: void 0
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Apply a single settings update to the current state.
|
|
109
|
+
*/
|
|
110
|
+
function applySettingsUpdate(current, key, value) {
|
|
111
|
+
const next = { ...current };
|
|
112
|
+
switch (key) {
|
|
113
|
+
case "groupChannels":
|
|
114
|
+
next.groupChannels = Array.isArray(value) ? value.filter((x) => typeof x === "string") : void 0;
|
|
115
|
+
break;
|
|
116
|
+
case "dmAllowlist":
|
|
117
|
+
next.dmAllowlist = Array.isArray(value) ? value.filter((x) => typeof x === "string") : void 0;
|
|
118
|
+
break;
|
|
119
|
+
case "autoDiscover":
|
|
120
|
+
next.autoDiscover = typeof value === "boolean" ? value : void 0;
|
|
121
|
+
break;
|
|
122
|
+
case "showModelSig":
|
|
123
|
+
next.showModelSig = typeof value === "boolean" ? value : void 0;
|
|
124
|
+
break;
|
|
125
|
+
case "autoAcceptDmInvites":
|
|
126
|
+
next.autoAcceptDmInvites = typeof value === "boolean" ? value : void 0;
|
|
127
|
+
break;
|
|
128
|
+
case "autoAcceptGroupInvites":
|
|
129
|
+
next.autoAcceptGroupInvites = typeof value === "boolean" ? value : void 0;
|
|
130
|
+
break;
|
|
131
|
+
case "groupInviteAllowlist":
|
|
132
|
+
next.groupInviteAllowlist = Array.isArray(value) ? value.filter((x) => typeof x === "string") : void 0;
|
|
133
|
+
break;
|
|
134
|
+
case "channelRules":
|
|
135
|
+
next.channelRules = parseChannelRules(value);
|
|
136
|
+
break;
|
|
137
|
+
case "defaultAuthorizedShips":
|
|
138
|
+
next.defaultAuthorizedShips = Array.isArray(value) ? value.filter((x) => typeof x === "string") : void 0;
|
|
139
|
+
break;
|
|
140
|
+
case "ownerShip":
|
|
141
|
+
next.ownerShip = typeof value === "string" ? value : void 0;
|
|
142
|
+
break;
|
|
143
|
+
case "pendingApprovals":
|
|
144
|
+
next.pendingApprovals = parsePendingApprovals(value);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Create a settings store subscription manager.
|
|
151
|
+
*
|
|
152
|
+
* Usage:
|
|
153
|
+
* const settings = createSettingsManager(api, logger);
|
|
154
|
+
* await settings.load();
|
|
155
|
+
* settings.subscribe((newSettings) => { ... });
|
|
156
|
+
*/
|
|
157
|
+
function createSettingsManager(api, logger) {
|
|
158
|
+
let state = {
|
|
159
|
+
current: {},
|
|
160
|
+
loaded: false
|
|
161
|
+
};
|
|
162
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
163
|
+
const notify = () => {
|
|
164
|
+
for (const listener of listeners) try {
|
|
165
|
+
listener(state.current);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
logger?.error?.(`[settings] Listener error: ${String(err)}`);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
/**
|
|
172
|
+
* Get current settings (may be empty if not loaded yet).
|
|
173
|
+
*/
|
|
174
|
+
get current() {
|
|
175
|
+
return state.current;
|
|
176
|
+
},
|
|
177
|
+
/**
|
|
178
|
+
* Whether initial settings have been loaded.
|
|
179
|
+
*/
|
|
180
|
+
get loaded() {
|
|
181
|
+
return state.loaded;
|
|
182
|
+
},
|
|
183
|
+
/**
|
|
184
|
+
* Load initial settings via scry.
|
|
185
|
+
*/
|
|
186
|
+
async load() {
|
|
187
|
+
try {
|
|
188
|
+
const deskData = (await api.scry("/settings/all.json"))?.all?.[SETTINGS_DESK];
|
|
189
|
+
state.current = parseSettingsResponse(deskData ?? {});
|
|
190
|
+
state.loaded = true;
|
|
191
|
+
logger?.log?.(`[settings] Loaded: ${JSON.stringify(state.current)}`);
|
|
192
|
+
return state.current;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
logger?.log?.(`[settings] No settings found (using defaults): ${String(err)}`);
|
|
195
|
+
state.current = {};
|
|
196
|
+
state.loaded = true;
|
|
197
|
+
return state.current;
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
/**
|
|
201
|
+
* Subscribe to settings changes.
|
|
202
|
+
*/
|
|
203
|
+
async startSubscription() {
|
|
204
|
+
await api.subscribe({
|
|
205
|
+
app: "settings",
|
|
206
|
+
path: "/desk/" + SETTINGS_DESK,
|
|
207
|
+
event: (event) => {
|
|
208
|
+
const update = parseSettingsEvent(event);
|
|
209
|
+
if (!update) return;
|
|
210
|
+
logger?.log?.(`[settings] Update: ${update.key} = ${JSON.stringify(update.value)}`);
|
|
211
|
+
state.current = applySettingsUpdate(state.current, update.key, update.value);
|
|
212
|
+
notify();
|
|
213
|
+
},
|
|
214
|
+
err: (error) => {
|
|
215
|
+
logger?.error?.(`[settings] Subscription error: ${String(error)}`);
|
|
216
|
+
},
|
|
217
|
+
quit: () => {
|
|
218
|
+
logger?.log?.("[settings] Subscription ended");
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
logger?.log?.("[settings] Subscribed to settings updates");
|
|
222
|
+
},
|
|
223
|
+
/**
|
|
224
|
+
* Register a listener for settings changes.
|
|
225
|
+
*/
|
|
226
|
+
onChange(listener) {
|
|
227
|
+
listeners.add(listener);
|
|
228
|
+
return () => listeners.delete(listener);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region extensions/tlon/src/urbit/errors.ts
|
|
234
|
+
var UrbitError = class extends Error {
|
|
235
|
+
constructor(code, message, options) {
|
|
236
|
+
super(message, options);
|
|
237
|
+
this.name = "UrbitError";
|
|
238
|
+
this.code = code;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
var UrbitUrlError = class extends UrbitError {
|
|
242
|
+
constructor(message, options) {
|
|
243
|
+
super("invalid_url", message, options);
|
|
244
|
+
this.name = "UrbitUrlError";
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
var UrbitHttpError = class extends UrbitError {
|
|
248
|
+
constructor(params) {
|
|
249
|
+
const suffix = params.bodyText ? ` - ${params.bodyText}` : "";
|
|
250
|
+
super("http_error", `${params.operation} failed: ${params.status}${suffix}`, { cause: params.cause });
|
|
251
|
+
this.name = "UrbitHttpError";
|
|
252
|
+
this.status = params.status;
|
|
253
|
+
this.operation = params.operation;
|
|
254
|
+
this.bodyText = params.bodyText;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
var UrbitAuthError = class extends UrbitError {
|
|
258
|
+
constructor(code, message, options) {
|
|
259
|
+
super(code, message, options);
|
|
260
|
+
this.name = "UrbitAuthError";
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region extensions/tlon/src/urbit/fetch.ts
|
|
265
|
+
async function urbitFetch(params) {
|
|
266
|
+
const validated = validateUrbitBaseUrl(params.baseUrl);
|
|
267
|
+
if (!validated.ok) throw new UrbitUrlError(validated.error);
|
|
268
|
+
return await fetchWithSsrFGuard({
|
|
269
|
+
url: new URL(params.path, validated.baseUrl).toString(),
|
|
270
|
+
fetchImpl: params.fetchImpl,
|
|
271
|
+
init: params.init,
|
|
272
|
+
timeoutMs: params.timeoutMs,
|
|
273
|
+
maxRedirects: params.maxRedirects,
|
|
274
|
+
signal: params.signal,
|
|
275
|
+
policy: params.ssrfPolicy,
|
|
276
|
+
lookupFn: params.lookupFn,
|
|
277
|
+
auditContext: params.auditContext,
|
|
278
|
+
pinDns: params.pinDns
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region extensions/tlon/src/urbit/auth.ts
|
|
283
|
+
async function authenticate(url, code, options = {}) {
|
|
284
|
+
const { response, release } = await urbitFetch({
|
|
285
|
+
baseUrl: url,
|
|
286
|
+
path: "/~/login",
|
|
287
|
+
init: {
|
|
288
|
+
method: "POST",
|
|
289
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
290
|
+
body: new URLSearchParams({ password: code }).toString()
|
|
291
|
+
},
|
|
292
|
+
ssrfPolicy: options.ssrfPolicy,
|
|
293
|
+
lookupFn: options.lookupFn,
|
|
294
|
+
fetchImpl: options.fetchImpl,
|
|
295
|
+
timeoutMs: options.timeoutMs ?? 15e3,
|
|
296
|
+
maxRedirects: 3,
|
|
297
|
+
auditContext: "tlon-urbit-login"
|
|
298
|
+
});
|
|
299
|
+
try {
|
|
300
|
+
if (!response.ok) throw new UrbitAuthError("auth_failed", `Login failed with status ${response.status}`);
|
|
301
|
+
await response.text().catch(() => {});
|
|
302
|
+
const cookie = response.headers.get("set-cookie");
|
|
303
|
+
if (!cookie) throw new UrbitAuthError("missing_cookie", "No authentication cookie received");
|
|
304
|
+
return cookie;
|
|
305
|
+
} finally {
|
|
306
|
+
await release();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region extensions/tlon/src/urbit/context.ts
|
|
311
|
+
function resolveShipFromHostname(hostname) {
|
|
312
|
+
const trimmed = normalizeUrbitHostname(hostname);
|
|
313
|
+
if (!trimmed) return "";
|
|
314
|
+
if (trimmed.includes(".")) return trimmed.split(".")[0] ?? trimmed;
|
|
315
|
+
return trimmed;
|
|
316
|
+
}
|
|
317
|
+
function normalizeUrbitShip(ship, hostname) {
|
|
318
|
+
return (ship?.replace(/^~/, "") ?? resolveShipFromHostname(hostname)).trim();
|
|
319
|
+
}
|
|
320
|
+
function normalizeUrbitCookie(cookie) {
|
|
321
|
+
return cookie.split(";")[0] ?? cookie;
|
|
322
|
+
}
|
|
323
|
+
function getUrbitContext(url, ship) {
|
|
324
|
+
const validated = validateUrbitBaseUrl(url);
|
|
325
|
+
if (!validated.ok) throw new UrbitUrlError(validated.error);
|
|
326
|
+
return {
|
|
327
|
+
baseUrl: validated.baseUrl,
|
|
328
|
+
hostname: validated.hostname,
|
|
329
|
+
ship: normalizeUrbitShip(ship, validated.hostname)
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region extensions/tlon/src/urbit/story.ts
|
|
334
|
+
/**
|
|
335
|
+
* Parse inline markdown formatting (bold, italic, code, links, mentions)
|
|
336
|
+
*/
|
|
337
|
+
function parseInlineMarkdown(text) {
|
|
338
|
+
const result = [];
|
|
339
|
+
let remaining = text;
|
|
340
|
+
while (remaining.length > 0) {
|
|
341
|
+
const shipMatch = remaining.match(/^(~[a-z][-a-z0-9]*)/);
|
|
342
|
+
if (shipMatch) {
|
|
343
|
+
result.push({ ship: shipMatch[1] });
|
|
344
|
+
remaining = remaining.slice(shipMatch[0].length);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const boldMatch = remaining.match(/^\*\*(.+?)\*\*|^__(.+?)__/);
|
|
348
|
+
if (boldMatch) {
|
|
349
|
+
const content = boldMatch[1] || boldMatch[2];
|
|
350
|
+
result.push({ bold: parseInlineMarkdown(content) });
|
|
351
|
+
remaining = remaining.slice(boldMatch[0].length);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const italicsMatch = remaining.match(/^\*([^*]+?)\*|^_([^_]+?)_(?![a-zA-Z0-9])/);
|
|
355
|
+
if (italicsMatch) {
|
|
356
|
+
const content = italicsMatch[1] || italicsMatch[2];
|
|
357
|
+
result.push({ italics: parseInlineMarkdown(content) });
|
|
358
|
+
remaining = remaining.slice(italicsMatch[0].length);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const strikeMatch = remaining.match(/^~~(.+?)~~/);
|
|
362
|
+
if (strikeMatch) {
|
|
363
|
+
result.push({ strike: parseInlineMarkdown(strikeMatch[1]) });
|
|
364
|
+
remaining = remaining.slice(strikeMatch[0].length);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const codeMatch = remaining.match(/^`([^`]+)`/);
|
|
368
|
+
if (codeMatch) {
|
|
369
|
+
result.push({ "inline-code": codeMatch[1] });
|
|
370
|
+
remaining = remaining.slice(codeMatch[0].length);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
|
|
374
|
+
if (linkMatch) {
|
|
375
|
+
result.push({ link: {
|
|
376
|
+
href: linkMatch[2],
|
|
377
|
+
content: linkMatch[1]
|
|
378
|
+
} });
|
|
379
|
+
remaining = remaining.slice(linkMatch[0].length);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const imageMatch = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/);
|
|
383
|
+
if (imageMatch) {
|
|
384
|
+
result.push({ __image: {
|
|
385
|
+
src: imageMatch[2],
|
|
386
|
+
alt: imageMatch[1]
|
|
387
|
+
} });
|
|
388
|
+
remaining = remaining.slice(imageMatch[0].length);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const urlMatch = remaining.match(/^(https?:\/\/[^\s<>"\]]+)/);
|
|
392
|
+
if (urlMatch) {
|
|
393
|
+
result.push({ link: {
|
|
394
|
+
href: urlMatch[1],
|
|
395
|
+
content: urlMatch[1]
|
|
396
|
+
} });
|
|
397
|
+
remaining = remaining.slice(urlMatch[0].length);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const plainMatch = remaining.match(/^[^*_`~[#~\n:/]+/);
|
|
401
|
+
if (plainMatch) {
|
|
402
|
+
result.push(plainMatch[0]);
|
|
403
|
+
remaining = remaining.slice(plainMatch[0].length);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
result.push(remaining[0]);
|
|
407
|
+
remaining = remaining.slice(1);
|
|
408
|
+
}
|
|
409
|
+
return mergeAdjacentStrings(result);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Merge adjacent string elements in an inline array
|
|
413
|
+
*/
|
|
414
|
+
function mergeAdjacentStrings(inlines) {
|
|
415
|
+
const result = [];
|
|
416
|
+
for (const item of inlines) if (typeof item === "string" && typeof result[result.length - 1] === "string") result[result.length - 1] = result[result.length - 1] + item;
|
|
417
|
+
else result.push(item);
|
|
418
|
+
return result;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Create an image block
|
|
422
|
+
*/
|
|
423
|
+
function createImageBlock(src, alt = "", height = 0, width = 0) {
|
|
424
|
+
return { block: { image: {
|
|
425
|
+
src,
|
|
426
|
+
height,
|
|
427
|
+
width,
|
|
428
|
+
alt
|
|
429
|
+
} } };
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Check if URL looks like an image
|
|
433
|
+
*/
|
|
434
|
+
function isImageUrl(url) {
|
|
435
|
+
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i.test(url);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Process inlines and extract any image markers into blocks
|
|
439
|
+
*/
|
|
440
|
+
function processInlinesForImages(inlines) {
|
|
441
|
+
const cleanInlines = [];
|
|
442
|
+
const imageBlocks = [];
|
|
443
|
+
for (const inline of inlines) if (typeof inline === "object" && "__image" in inline) {
|
|
444
|
+
const img = inline["__image"];
|
|
445
|
+
imageBlocks.push(createImageBlock(img.src, img.alt));
|
|
446
|
+
} else cleanInlines.push(inline);
|
|
447
|
+
return {
|
|
448
|
+
inlines: cleanInlines,
|
|
449
|
+
imageBlocks
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Convert markdown text to Tlon story format
|
|
454
|
+
*/
|
|
455
|
+
function markdownToStory(markdown) {
|
|
456
|
+
const story = [];
|
|
457
|
+
const lines = markdown.split("\n");
|
|
458
|
+
let i = 0;
|
|
459
|
+
while (i < lines.length) {
|
|
460
|
+
const line = lines[i];
|
|
461
|
+
if (line.startsWith("```")) {
|
|
462
|
+
const lang = line.slice(3).trim() || "plaintext";
|
|
463
|
+
const codeLines = [];
|
|
464
|
+
i++;
|
|
465
|
+
while (i < lines.length && !lines[i].startsWith("```")) {
|
|
466
|
+
codeLines.push(lines[i]);
|
|
467
|
+
i++;
|
|
468
|
+
}
|
|
469
|
+
story.push({ block: { code: {
|
|
470
|
+
code: codeLines.join("\n"),
|
|
471
|
+
lang
|
|
472
|
+
} } });
|
|
473
|
+
i++;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
477
|
+
if (headerMatch) {
|
|
478
|
+
const tag = `h${headerMatch[1].length}`;
|
|
479
|
+
story.push({ block: { header: {
|
|
480
|
+
tag,
|
|
481
|
+
content: parseInlineMarkdown(headerMatch[2])
|
|
482
|
+
} } });
|
|
483
|
+
i++;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (/^(-{3,}|\*{3,})$/.test(line.trim())) {
|
|
487
|
+
story.push({ block: { rule: null } });
|
|
488
|
+
i++;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (line.startsWith("> ")) {
|
|
492
|
+
const quoteLines = [];
|
|
493
|
+
while (i < lines.length && lines[i].startsWith("> ")) {
|
|
494
|
+
quoteLines.push(lines[i].slice(2));
|
|
495
|
+
i++;
|
|
496
|
+
}
|
|
497
|
+
const quoteText = quoteLines.join("\n");
|
|
498
|
+
story.push({ inline: [{ blockquote: parseInlineMarkdown(quoteText) }] });
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (line.trim() === "") {
|
|
502
|
+
i++;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const paragraphLines = [];
|
|
506
|
+
while (i < lines.length && lines[i].trim() !== "" && !lines[i].startsWith("#") && !lines[i].startsWith("```") && !lines[i].startsWith("> ") && !/^(-{3,}|\*{3,})$/.test(lines[i].trim())) {
|
|
507
|
+
paragraphLines.push(lines[i]);
|
|
508
|
+
i++;
|
|
509
|
+
}
|
|
510
|
+
if (paragraphLines.length > 0) {
|
|
511
|
+
const inlines = parseInlineMarkdown(paragraphLines.join("\n"));
|
|
512
|
+
const withBreaks = [];
|
|
513
|
+
for (const inline of inlines) if (typeof inline === "string" && inline.includes("\n")) {
|
|
514
|
+
const parts = inline.split("\n");
|
|
515
|
+
for (let j = 0; j < parts.length; j++) {
|
|
516
|
+
if (parts[j]) withBreaks.push(parts[j]);
|
|
517
|
+
if (j < parts.length - 1) withBreaks.push({ break: null });
|
|
518
|
+
}
|
|
519
|
+
} else withBreaks.push(inline);
|
|
520
|
+
const { inlines: cleanInlines, imageBlocks } = processInlinesForImages(withBreaks);
|
|
521
|
+
if (cleanInlines.length > 0) story.push({ inline: cleanInlines });
|
|
522
|
+
story.push(...imageBlocks);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return story;
|
|
526
|
+
}
|
|
527
|
+
//#endregion
|
|
528
|
+
//#region extensions/tlon/src/urbit/send.ts
|
|
529
|
+
function createTlonSendReceipt(params) {
|
|
530
|
+
return createMessageReceiptFromOutboundResults({
|
|
531
|
+
results: [{
|
|
532
|
+
channel: "tlon",
|
|
533
|
+
messageId: params.messageId,
|
|
534
|
+
conversationId: params.conversationId
|
|
535
|
+
}],
|
|
536
|
+
threadId: params.conversationId,
|
|
537
|
+
kind: params.kind
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
async function sendDm({ api, fromShip, toShip, text }) {
|
|
541
|
+
return sendDmWithStory({
|
|
542
|
+
api,
|
|
543
|
+
fromShip,
|
|
544
|
+
toShip,
|
|
545
|
+
story: markdownToStory(text),
|
|
546
|
+
kind: "text"
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
async function sendDmWithStory({ api, fromShip, toShip, story, kind = "unknown" }) {
|
|
550
|
+
const sentAt = Date.now();
|
|
551
|
+
const id = `${fromShip}/${scot("ud", da.fromUnix(sentAt))}`;
|
|
552
|
+
const action = {
|
|
553
|
+
ship: toShip,
|
|
554
|
+
diff: {
|
|
555
|
+
id,
|
|
556
|
+
delta: { add: {
|
|
557
|
+
memo: {
|
|
558
|
+
content: story,
|
|
559
|
+
author: fromShip,
|
|
560
|
+
sent: sentAt
|
|
561
|
+
},
|
|
562
|
+
kind: null,
|
|
563
|
+
time: null
|
|
564
|
+
} }
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
await api.poke({
|
|
568
|
+
app: "chat",
|
|
569
|
+
mark: "chat-dm-action",
|
|
570
|
+
json: action
|
|
571
|
+
});
|
|
572
|
+
return {
|
|
573
|
+
channel: "tlon",
|
|
574
|
+
messageId: id,
|
|
575
|
+
receipt: createTlonSendReceipt({
|
|
576
|
+
messageId: id,
|
|
577
|
+
conversationId: toShip,
|
|
578
|
+
kind
|
|
579
|
+
})
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
async function sendGroupMessage({ api, fromShip, hostShip, channelName, text, replyToId }) {
|
|
583
|
+
return sendGroupMessageWithStory({
|
|
584
|
+
api,
|
|
585
|
+
fromShip,
|
|
586
|
+
hostShip,
|
|
587
|
+
channelName,
|
|
588
|
+
story: markdownToStory(text),
|
|
589
|
+
replyToId,
|
|
590
|
+
kind: "text"
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
async function sendGroupMessageWithStory({ api, fromShip, hostShip, channelName, story, replyToId, kind = "unknown" }) {
|
|
594
|
+
const sentAt = Date.now();
|
|
595
|
+
let formattedReplyId = replyToId;
|
|
596
|
+
if (replyToId && /^\d+$/.test(replyToId)) try {
|
|
597
|
+
formattedReplyId = scot("ud", BigInt(replyToId));
|
|
598
|
+
} catch {}
|
|
599
|
+
const action = { channel: {
|
|
600
|
+
nest: `chat/${hostShip}/${channelName}`,
|
|
601
|
+
action: formattedReplyId ? { post: { reply: {
|
|
602
|
+
id: formattedReplyId,
|
|
603
|
+
action: { add: {
|
|
604
|
+
content: story,
|
|
605
|
+
author: fromShip,
|
|
606
|
+
sent: sentAt
|
|
607
|
+
} }
|
|
608
|
+
} } } : { post: { add: {
|
|
609
|
+
content: story,
|
|
610
|
+
author: fromShip,
|
|
611
|
+
sent: sentAt,
|
|
612
|
+
kind: "/chat",
|
|
613
|
+
blob: null,
|
|
614
|
+
meta: null
|
|
615
|
+
} } }
|
|
616
|
+
} };
|
|
617
|
+
await api.poke({
|
|
618
|
+
app: "channels",
|
|
619
|
+
mark: "channel-action-1",
|
|
620
|
+
json: action
|
|
621
|
+
});
|
|
622
|
+
const messageId = `${fromShip}/${sentAt}`;
|
|
623
|
+
return {
|
|
624
|
+
channel: "tlon",
|
|
625
|
+
messageId,
|
|
626
|
+
receipt: createTlonSendReceipt({
|
|
627
|
+
messageId,
|
|
628
|
+
conversationId: `${hostShip}/${channelName}`,
|
|
629
|
+
kind
|
|
630
|
+
})
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Build a story with text and optional media (image)
|
|
635
|
+
*/
|
|
636
|
+
function buildMediaStory(text, mediaUrl) {
|
|
637
|
+
const story = [];
|
|
638
|
+
const cleanText = text?.trim() ?? "";
|
|
639
|
+
const cleanUrl = mediaUrl?.trim() ?? "";
|
|
640
|
+
if (cleanText) story.push(...markdownToStory(cleanText));
|
|
641
|
+
if (cleanUrl && isImageUrl(cleanUrl)) story.push(createImageBlock(cleanUrl, ""));
|
|
642
|
+
else if (cleanUrl) story.push({ inline: [{ link: {
|
|
643
|
+
href: cleanUrl,
|
|
644
|
+
content: cleanUrl
|
|
645
|
+
} }] });
|
|
646
|
+
return story.length > 0 ? story : [{ inline: [""] }];
|
|
647
|
+
}
|
|
648
|
+
//#endregion
|
|
649
|
+
//#region extensions/tlon/src/urbit/channel-ops.ts
|
|
650
|
+
async function putUrbitChannel(deps, params) {
|
|
651
|
+
return await urbitFetch({
|
|
652
|
+
baseUrl: deps.baseUrl,
|
|
653
|
+
path: `/~/channel/${deps.channelId}`,
|
|
654
|
+
init: {
|
|
655
|
+
method: "PUT",
|
|
656
|
+
headers: {
|
|
657
|
+
"Content-Type": "application/json",
|
|
658
|
+
Cookie: deps.cookie
|
|
659
|
+
},
|
|
660
|
+
body: JSON.stringify(params.body)
|
|
661
|
+
},
|
|
662
|
+
ssrfPolicy: deps.ssrfPolicy,
|
|
663
|
+
lookupFn: deps.lookupFn,
|
|
664
|
+
fetchImpl: deps.fetchImpl,
|
|
665
|
+
timeoutMs: 3e4,
|
|
666
|
+
auditContext: params.auditContext
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
async function pokeUrbitChannel(deps, params) {
|
|
670
|
+
const pokeId = Date.now();
|
|
671
|
+
const { response, release } = await putUrbitChannel(deps, {
|
|
672
|
+
body: [{
|
|
673
|
+
id: pokeId,
|
|
674
|
+
action: "poke",
|
|
675
|
+
ship: deps.ship,
|
|
676
|
+
app: params.app,
|
|
677
|
+
mark: params.mark,
|
|
678
|
+
json: params.json
|
|
679
|
+
}],
|
|
680
|
+
auditContext: params.auditContext
|
|
681
|
+
});
|
|
682
|
+
try {
|
|
683
|
+
if (!response.ok && response.status !== 204) {
|
|
684
|
+
const errorText = await response.text().catch(() => "");
|
|
685
|
+
throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
|
|
686
|
+
}
|
|
687
|
+
return pokeId;
|
|
688
|
+
} finally {
|
|
689
|
+
await release();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
async function scryUrbitPath(deps, params) {
|
|
693
|
+
const scryPath = `/~/scry${params.path}`;
|
|
694
|
+
const { response, release } = await urbitFetch({
|
|
695
|
+
baseUrl: deps.baseUrl,
|
|
696
|
+
path: scryPath,
|
|
697
|
+
init: {
|
|
698
|
+
method: "GET",
|
|
699
|
+
headers: { Cookie: deps.cookie }
|
|
700
|
+
},
|
|
701
|
+
ssrfPolicy: deps.ssrfPolicy,
|
|
702
|
+
lookupFn: deps.lookupFn,
|
|
703
|
+
fetchImpl: deps.fetchImpl,
|
|
704
|
+
timeoutMs: 3e4,
|
|
705
|
+
auditContext: params.auditContext
|
|
706
|
+
});
|
|
707
|
+
try {
|
|
708
|
+
if (!response.ok) throw new Error(`Scry failed: ${response.status} for path ${params.path}`);
|
|
709
|
+
try {
|
|
710
|
+
return await response.json();
|
|
711
|
+
} catch (cause) {
|
|
712
|
+
throw new Error(`Urbit scry response was malformed JSON for path ${params.path}`, { cause });
|
|
713
|
+
}
|
|
714
|
+
} finally {
|
|
715
|
+
await release();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
async function createUrbitChannel(deps, params) {
|
|
719
|
+
const { response, release } = await putUrbitChannel(deps, params);
|
|
720
|
+
try {
|
|
721
|
+
if (!response.ok && response.status !== 204) throw new UrbitHttpError({
|
|
722
|
+
operation: "Channel creation",
|
|
723
|
+
status: response.status
|
|
724
|
+
});
|
|
725
|
+
} finally {
|
|
726
|
+
await release();
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
async function wakeUrbitChannel(deps) {
|
|
730
|
+
const { response, release } = await putUrbitChannel(deps, {
|
|
731
|
+
body: [{
|
|
732
|
+
id: Date.now(),
|
|
733
|
+
action: "poke",
|
|
734
|
+
ship: deps.ship,
|
|
735
|
+
app: "hood",
|
|
736
|
+
mark: "helm-hi",
|
|
737
|
+
json: "Opening API channel"
|
|
738
|
+
}],
|
|
739
|
+
auditContext: "tlon-urbit-channel-wake"
|
|
740
|
+
});
|
|
741
|
+
try {
|
|
742
|
+
if (!response.ok && response.status !== 204) throw new UrbitHttpError({
|
|
743
|
+
operation: "Channel activation",
|
|
744
|
+
status: response.status
|
|
745
|
+
});
|
|
746
|
+
} finally {
|
|
747
|
+
await release();
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
async function ensureUrbitChannelOpen(deps, params) {
|
|
751
|
+
await createUrbitChannel(deps, {
|
|
752
|
+
body: params.createBody,
|
|
753
|
+
auditContext: params.createAuditContext
|
|
754
|
+
});
|
|
755
|
+
await wakeUrbitChannel(deps);
|
|
756
|
+
}
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region extensions/tlon/src/urbit/sse-client.ts
|
|
759
|
+
function parseUrbitSsePayload(data) {
|
|
760
|
+
try {
|
|
761
|
+
return JSON.parse(data);
|
|
762
|
+
} catch (cause) {
|
|
763
|
+
throw new Error("Tlon Urbit SSE event was malformed JSON", { cause });
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
var UrbitSSEClient = class {
|
|
767
|
+
constructor(url, cookie, options = {}) {
|
|
768
|
+
this.subscriptions = [];
|
|
769
|
+
this.eventHandlers = /* @__PURE__ */ new Map();
|
|
770
|
+
this.aborted = false;
|
|
771
|
+
this.streamController = null;
|
|
772
|
+
this.reconnectAttempts = 0;
|
|
773
|
+
this.isConnected = false;
|
|
774
|
+
this.streamRelease = null;
|
|
775
|
+
this.lastHeardEventId = -1;
|
|
776
|
+
this.lastAcknowledgedEventId = -1;
|
|
777
|
+
this.ackThreshold = 20;
|
|
778
|
+
const ctx = getUrbitContext(url, options.ship);
|
|
779
|
+
this.url = ctx.baseUrl;
|
|
780
|
+
this.cookie = normalizeUrbitCookie(cookie);
|
|
781
|
+
this.ship = ctx.ship;
|
|
782
|
+
this.channelId = `${Math.floor(Date.now() / 1e3)}-${randomUUID()}`;
|
|
783
|
+
this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString();
|
|
784
|
+
this.onReconnect = options.onReconnect ?? null;
|
|
785
|
+
this.autoReconnect = options.autoReconnect !== false;
|
|
786
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
|
|
787
|
+
this.reconnectDelay = options.reconnectDelay ?? 1e3;
|
|
788
|
+
this.maxReconnectDelay = options.maxReconnectDelay ?? 3e4;
|
|
789
|
+
this.logger = options.logger ?? {};
|
|
790
|
+
this.ssrfPolicy = options.ssrfPolicy;
|
|
791
|
+
this.lookupFn = options.lookupFn;
|
|
792
|
+
this.fetchImpl = options.fetchImpl;
|
|
793
|
+
}
|
|
794
|
+
channelRequestContext() {
|
|
795
|
+
return {
|
|
796
|
+
baseUrl: this.url,
|
|
797
|
+
cookie: this.cookie,
|
|
798
|
+
ship: this.ship,
|
|
799
|
+
channelId: this.channelId,
|
|
800
|
+
ssrfPolicy: this.ssrfPolicy,
|
|
801
|
+
lookupFn: this.lookupFn,
|
|
802
|
+
fetchImpl: this.fetchImpl
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
async subscribe(params) {
|
|
806
|
+
const subId = this.subscriptions.length + 1;
|
|
807
|
+
const subscription = {
|
|
808
|
+
id: subId,
|
|
809
|
+
action: "subscribe",
|
|
810
|
+
ship: this.ship,
|
|
811
|
+
app: params.app,
|
|
812
|
+
path: params.path
|
|
813
|
+
};
|
|
814
|
+
this.subscriptions.push(subscription);
|
|
815
|
+
this.eventHandlers.set(subId, {
|
|
816
|
+
event: params.event,
|
|
817
|
+
err: params.err,
|
|
818
|
+
quit: params.quit
|
|
819
|
+
});
|
|
820
|
+
if (this.isConnected) try {
|
|
821
|
+
await this.sendSubscription(subscription);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
this.eventHandlers.get(subId)?.err?.(error);
|
|
824
|
+
}
|
|
825
|
+
return subId;
|
|
826
|
+
}
|
|
827
|
+
async sendSubscription(subscription) {
|
|
828
|
+
const { response, release } = await this.putChannelPayload([subscription], {
|
|
829
|
+
timeoutMs: 3e4,
|
|
830
|
+
auditContext: "tlon-urbit-subscribe"
|
|
831
|
+
});
|
|
832
|
+
try {
|
|
833
|
+
if (!response.ok && response.status !== 204) {
|
|
834
|
+
const errorText = await response.text().catch(() => "");
|
|
835
|
+
throw new Error(`Subscribe failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
|
|
836
|
+
}
|
|
837
|
+
} finally {
|
|
838
|
+
await release();
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
async connect() {
|
|
842
|
+
await ensureUrbitChannelOpen(this.channelRequestContext(), {
|
|
843
|
+
createBody: this.subscriptions,
|
|
844
|
+
createAuditContext: "tlon-urbit-channel-create"
|
|
845
|
+
});
|
|
846
|
+
await this.openStream();
|
|
847
|
+
this.isConnected = true;
|
|
848
|
+
this.reconnectAttempts = 0;
|
|
849
|
+
}
|
|
850
|
+
async openStream() {
|
|
851
|
+
const controller = new AbortController();
|
|
852
|
+
const timeoutId = setTimeout(() => controller.abort(), 6e4);
|
|
853
|
+
this.streamController = controller;
|
|
854
|
+
const { response, release } = await urbitFetch({
|
|
855
|
+
baseUrl: this.url,
|
|
856
|
+
path: `/~/channel/${this.channelId}`,
|
|
857
|
+
init: {
|
|
858
|
+
method: "GET",
|
|
859
|
+
headers: {
|
|
860
|
+
Accept: "text/event-stream",
|
|
861
|
+
Cookie: this.cookie
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
ssrfPolicy: this.ssrfPolicy,
|
|
865
|
+
lookupFn: this.lookupFn,
|
|
866
|
+
fetchImpl: this.fetchImpl,
|
|
867
|
+
signal: controller.signal,
|
|
868
|
+
auditContext: "tlon-urbit-sse-stream"
|
|
869
|
+
});
|
|
870
|
+
this.streamRelease = release;
|
|
871
|
+
clearTimeout(timeoutId);
|
|
872
|
+
if (!response.ok) {
|
|
873
|
+
await release();
|
|
874
|
+
this.streamRelease = null;
|
|
875
|
+
throw new Error(`Stream connection failed: ${response.status}`);
|
|
876
|
+
}
|
|
877
|
+
this.processStream(response.body).catch((error) => {
|
|
878
|
+
if (!this.aborted) {
|
|
879
|
+
this.logger.error?.(`Stream error: ${String(error)}`);
|
|
880
|
+
for (const { err } of this.eventHandlers.values()) if (err) err(error);
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
async processStream(body) {
|
|
885
|
+
if (!body) return;
|
|
886
|
+
const stream = body instanceof ReadableStream ? Readable.fromWeb(body) : body;
|
|
887
|
+
let buffer = "";
|
|
888
|
+
try {
|
|
889
|
+
for await (const chunk of stream) {
|
|
890
|
+
if (this.aborted) break;
|
|
891
|
+
buffer += chunk.toString();
|
|
892
|
+
let eventEnd;
|
|
893
|
+
while ((eventEnd = buffer.indexOf("\n\n")) !== -1) {
|
|
894
|
+
const eventData = buffer.slice(0, eventEnd);
|
|
895
|
+
buffer = buffer.slice(eventEnd + 2);
|
|
896
|
+
this.processEvent(eventData);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
} finally {
|
|
900
|
+
if (this.streamRelease) {
|
|
901
|
+
const release = this.streamRelease;
|
|
902
|
+
this.streamRelease = null;
|
|
903
|
+
await release();
|
|
904
|
+
}
|
|
905
|
+
this.streamController = null;
|
|
906
|
+
if (!this.aborted && this.autoReconnect) {
|
|
907
|
+
this.isConnected = false;
|
|
908
|
+
this.logger.log?.("[SSE] Stream ended, attempting reconnection...");
|
|
909
|
+
await this.attemptReconnect();
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
processEvent(eventData) {
|
|
914
|
+
const lines = eventData.split("\n");
|
|
915
|
+
let data = null;
|
|
916
|
+
let eventId = null;
|
|
917
|
+
for (const line of lines) {
|
|
918
|
+
if (line.startsWith("id: ")) eventId = Number.parseInt(line.slice(4), 10);
|
|
919
|
+
if (line.startsWith("data: ")) data = line.slice(6);
|
|
920
|
+
}
|
|
921
|
+
if (!data) return;
|
|
922
|
+
if (eventId !== null && !Number.isNaN(eventId)) {
|
|
923
|
+
if (eventId > this.lastHeardEventId) {
|
|
924
|
+
this.lastHeardEventId = eventId;
|
|
925
|
+
if (eventId - this.lastAcknowledgedEventId > this.ackThreshold) {
|
|
926
|
+
this.logger.log?.(`[SSE] Acking event ${eventId} (last acked: ${this.lastAcknowledgedEventId})`);
|
|
927
|
+
this.ack(eventId).catch((err) => {
|
|
928
|
+
this.logger.error?.(`Failed to ack event ${eventId}: ${String(err)}`);
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
try {
|
|
934
|
+
const parsed = parseUrbitSsePayload(data);
|
|
935
|
+
if (parsed.response === "quit") {
|
|
936
|
+
if (parsed.id) {
|
|
937
|
+
const handlers = this.eventHandlers.get(parsed.id);
|
|
938
|
+
if (handlers?.quit) handlers.quit();
|
|
939
|
+
}
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (parsed.id && this.eventHandlers.has(parsed.id)) {
|
|
943
|
+
const { event } = this.eventHandlers.get(parsed.id) ?? {};
|
|
944
|
+
if (event && parsed.json) event(parsed.json);
|
|
945
|
+
} else if (parsed.json) {
|
|
946
|
+
for (const { event } of this.eventHandlers.values()) if (event) event(parsed.json);
|
|
947
|
+
}
|
|
948
|
+
} catch (error) {
|
|
949
|
+
this.logger.error?.(`Error parsing SSE event: ${String(error)}`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
async poke(params) {
|
|
953
|
+
return await pokeUrbitChannel(this.channelRequestContext(), {
|
|
954
|
+
...params,
|
|
955
|
+
auditContext: "tlon-urbit-poke"
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
async scry(path) {
|
|
959
|
+
return await scryUrbitPath({
|
|
960
|
+
baseUrl: this.url,
|
|
961
|
+
cookie: this.cookie,
|
|
962
|
+
ssrfPolicy: this.ssrfPolicy,
|
|
963
|
+
lookupFn: this.lookupFn,
|
|
964
|
+
fetchImpl: this.fetchImpl
|
|
965
|
+
}, {
|
|
966
|
+
path,
|
|
967
|
+
auditContext: "tlon-urbit-scry"
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Update the cookie used for authentication.
|
|
972
|
+
* Call this when re-authenticating after session expiry.
|
|
973
|
+
*/
|
|
974
|
+
updateCookie(newCookie) {
|
|
975
|
+
this.cookie = normalizeUrbitCookie(newCookie);
|
|
976
|
+
}
|
|
977
|
+
async ack(eventId) {
|
|
978
|
+
this.lastAcknowledgedEventId = eventId;
|
|
979
|
+
const ackData = {
|
|
980
|
+
id: Date.now(),
|
|
981
|
+
action: "ack",
|
|
982
|
+
"event-id": eventId
|
|
983
|
+
};
|
|
984
|
+
const { response, release } = await this.putChannelPayload([ackData], {
|
|
985
|
+
timeoutMs: 1e4,
|
|
986
|
+
auditContext: "tlon-urbit-ack"
|
|
987
|
+
});
|
|
988
|
+
try {
|
|
989
|
+
if (!response.ok) throw new Error(`Ack failed with status ${response.status}`);
|
|
990
|
+
} finally {
|
|
991
|
+
await release();
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
async attemptReconnect() {
|
|
995
|
+
if (this.aborted || !this.autoReconnect) {
|
|
996
|
+
this.logger.log?.("[SSE] Reconnection aborted or disabled");
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
1000
|
+
this.logger.log?.(`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Waiting 10s before resetting...`);
|
|
1001
|
+
const extendedBackoff = 1e4;
|
|
1002
|
+
await new Promise((resolve) => setTimeout(resolve, extendedBackoff));
|
|
1003
|
+
this.reconnectAttempts = 0;
|
|
1004
|
+
this.logger.log?.("[SSE] Reconnection attempts reset, resuming reconnection...");
|
|
1005
|
+
}
|
|
1006
|
+
this.reconnectAttempts += 1;
|
|
1007
|
+
const delay = Math.min(this.reconnectDelay * 2 ** (this.reconnectAttempts - 1), this.maxReconnectDelay);
|
|
1008
|
+
this.logger.log?.(`[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`);
|
|
1009
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1010
|
+
try {
|
|
1011
|
+
this.channelId = `${Math.floor(Date.now() / 1e3)}-${randomUUID()}`;
|
|
1012
|
+
this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString();
|
|
1013
|
+
if (this.onReconnect) await this.onReconnect(this);
|
|
1014
|
+
await this.connect();
|
|
1015
|
+
this.logger.log?.("[SSE] Reconnection successful!");
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
this.logger.error?.(`[SSE] Reconnection failed: ${String(error)}`);
|
|
1018
|
+
await this.attemptReconnect();
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
async close() {
|
|
1022
|
+
this.aborted = true;
|
|
1023
|
+
this.isConnected = false;
|
|
1024
|
+
this.streamController?.abort();
|
|
1025
|
+
try {
|
|
1026
|
+
const unsubscribes = this.subscriptions.map((sub) => ({
|
|
1027
|
+
id: sub.id,
|
|
1028
|
+
action: "unsubscribe",
|
|
1029
|
+
subscription: sub.id
|
|
1030
|
+
}));
|
|
1031
|
+
{
|
|
1032
|
+
const { response, release } = await this.putChannelPayload(unsubscribes, {
|
|
1033
|
+
timeoutMs: 3e4,
|
|
1034
|
+
auditContext: "tlon-urbit-unsubscribe"
|
|
1035
|
+
});
|
|
1036
|
+
try {
|
|
1037
|
+
response.body?.cancel();
|
|
1038
|
+
} finally {
|
|
1039
|
+
await release();
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
{
|
|
1043
|
+
const { response, release } = await urbitFetch({
|
|
1044
|
+
baseUrl: this.url,
|
|
1045
|
+
path: `/~/channel/${this.channelId}`,
|
|
1046
|
+
init: {
|
|
1047
|
+
method: "DELETE",
|
|
1048
|
+
headers: { Cookie: this.cookie }
|
|
1049
|
+
},
|
|
1050
|
+
ssrfPolicy: this.ssrfPolicy,
|
|
1051
|
+
lookupFn: this.lookupFn,
|
|
1052
|
+
fetchImpl: this.fetchImpl,
|
|
1053
|
+
timeoutMs: 3e4,
|
|
1054
|
+
auditContext: "tlon-urbit-channel-close"
|
|
1055
|
+
});
|
|
1056
|
+
try {
|
|
1057
|
+
response.body?.cancel();
|
|
1058
|
+
} finally {
|
|
1059
|
+
await release();
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
this.logger.error?.(`Error closing channel: ${String(error)}`);
|
|
1064
|
+
}
|
|
1065
|
+
if (this.streamRelease) {
|
|
1066
|
+
const release = this.streamRelease;
|
|
1067
|
+
this.streamRelease = null;
|
|
1068
|
+
await release();
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
async putChannelPayload(payload, params) {
|
|
1072
|
+
return await urbitFetch({
|
|
1073
|
+
baseUrl: this.url,
|
|
1074
|
+
path: `/~/channel/${this.channelId}`,
|
|
1075
|
+
init: {
|
|
1076
|
+
method: "PUT",
|
|
1077
|
+
headers: {
|
|
1078
|
+
"Content-Type": "application/json",
|
|
1079
|
+
Cookie: this.cookie
|
|
1080
|
+
},
|
|
1081
|
+
body: JSON.stringify(payload)
|
|
1082
|
+
},
|
|
1083
|
+
ssrfPolicy: this.ssrfPolicy,
|
|
1084
|
+
lookupFn: this.lookupFn,
|
|
1085
|
+
fetchImpl: this.fetchImpl,
|
|
1086
|
+
timeoutMs: params.timeoutMs,
|
|
1087
|
+
auditContext: params.auditContext
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
//#endregion
|
|
1092
|
+
//#region extensions/tlon/src/monitor/approval.ts
|
|
1093
|
+
/**
|
|
1094
|
+
* Approval system for managing DM, channel mention, and group invite approvals.
|
|
1095
|
+
*
|
|
1096
|
+
* When an unknown ship tries to interact with the bot, the owner receives
|
|
1097
|
+
* a notification and can approve or deny the request.
|
|
1098
|
+
*/
|
|
1099
|
+
/**
|
|
1100
|
+
* Generate a unique approval ID in the format: {type}-{timestamp}-{shortHash}
|
|
1101
|
+
*/
|
|
1102
|
+
function generateApprovalId(type) {
|
|
1103
|
+
return `${type}-${Date.now()}-${randomBytes(3).toString("hex")}`;
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Create a pending approval object.
|
|
1107
|
+
*/
|
|
1108
|
+
function createPendingApproval(params) {
|
|
1109
|
+
return {
|
|
1110
|
+
id: generateApprovalId(params.type),
|
|
1111
|
+
type: params.type,
|
|
1112
|
+
requestingShip: params.requestingShip,
|
|
1113
|
+
channelNest: params.channelNest,
|
|
1114
|
+
groupFlag: params.groupFlag,
|
|
1115
|
+
messagePreview: params.messagePreview,
|
|
1116
|
+
originalMessage: params.originalMessage,
|
|
1117
|
+
timestamp: Date.now()
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Truncate text to a maximum length with ellipsis.
|
|
1122
|
+
*/
|
|
1123
|
+
function truncate(text, maxLength) {
|
|
1124
|
+
if (text.length <= maxLength) return text;
|
|
1125
|
+
return text.slice(0, maxLength - 3) + "...";
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Format a notification message for the owner about a pending approval.
|
|
1129
|
+
*/
|
|
1130
|
+
function formatApprovalRequest(approval) {
|
|
1131
|
+
const preview = approval.messagePreview ? `\n"${truncate(approval.messagePreview, 100)}"` : "";
|
|
1132
|
+
switch (approval.type) {
|
|
1133
|
+
case "dm": return `New DM request from ${approval.requestingShip}:${preview}\n\nReply "approve", "deny", or "block" (ID: ${approval.id})`;
|
|
1134
|
+
case "channel": return `${approval.requestingShip} mentioned you in ${approval.channelNest}:${preview}\n\nReply "approve", "deny", or "block"\n(ID: ${approval.id})`;
|
|
1135
|
+
case "group": return `Group invite from ${approval.requestingShip} to join ${approval.groupFlag}\n\nReply "approve", "deny", or "block"\n(ID: ${approval.id})`;
|
|
1136
|
+
}
|
|
1137
|
+
throw new Error("Unsupported approval type");
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Parse an owner's response to an approval request.
|
|
1141
|
+
* Supports formats:
|
|
1142
|
+
* - "approve" / "deny" / "block" (applies to most recent pending)
|
|
1143
|
+
* - "approve dm-1234567890-abc" / "deny dm-1234567890-abc" (specific ID)
|
|
1144
|
+
* - "block" permanently blocks the ship via Tlon's native blocking
|
|
1145
|
+
*/
|
|
1146
|
+
function parseApprovalResponse(text) {
|
|
1147
|
+
const match = normalizeLowercaseStringOrEmpty(text).match(/^(approve|deny|block)(?:\s+(.+))?$/);
|
|
1148
|
+
if (!match) return null;
|
|
1149
|
+
return {
|
|
1150
|
+
action: match[1],
|
|
1151
|
+
id: match[2]?.trim()
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Check if a message text looks like an approval response.
|
|
1156
|
+
* Used to determine if we should intercept the message before normal processing.
|
|
1157
|
+
*/
|
|
1158
|
+
function isApprovalResponse(text) {
|
|
1159
|
+
const trimmed = normalizeLowercaseStringOrEmpty(text);
|
|
1160
|
+
return trimmed.startsWith("approve") || trimmed.startsWith("deny") || trimmed.startsWith("block");
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Find a pending approval by ID, or return the most recent if no ID specified.
|
|
1164
|
+
*/
|
|
1165
|
+
function findPendingApproval(pendingApprovals, id) {
|
|
1166
|
+
if (id) return pendingApprovals.find((a) => a.id === id);
|
|
1167
|
+
return pendingApprovals[pendingApprovals.length - 1];
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Remove a pending approval from the list by ID.
|
|
1171
|
+
*/
|
|
1172
|
+
function removePendingApproval(pendingApprovals, id) {
|
|
1173
|
+
return pendingApprovals.filter((a) => a.id !== id);
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Format a confirmation message after an approval action.
|
|
1177
|
+
*/
|
|
1178
|
+
function formatApprovalConfirmation(approval, action) {
|
|
1179
|
+
if (action === "block") return `Blocked ${approval.requestingShip}. They will no longer be able to contact the bot.`;
|
|
1180
|
+
const actionText = action === "approve" ? "Approved" : "Denied";
|
|
1181
|
+
switch (approval.type) {
|
|
1182
|
+
case "dm":
|
|
1183
|
+
if (action === "approve") return `${actionText} DM access for ${approval.requestingShip}. They can now message the bot.`;
|
|
1184
|
+
return `${actionText} DM request from ${approval.requestingShip}.`;
|
|
1185
|
+
case "channel":
|
|
1186
|
+
if (action === "approve") return `${actionText} ${approval.requestingShip} for ${approval.channelNest}. They can now interact in this channel.`;
|
|
1187
|
+
return `${actionText} ${approval.requestingShip} for ${approval.channelNest}.`;
|
|
1188
|
+
case "group":
|
|
1189
|
+
if (action === "approve") return `${actionText} group invite from ${approval.requestingShip} to ${approval.groupFlag}. Joining group...`;
|
|
1190
|
+
return `${actionText} group invite from ${approval.requestingShip} to ${approval.groupFlag}.`;
|
|
1191
|
+
}
|
|
1192
|
+
throw new Error("Unsupported approval type");
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Parse an admin command from owner message.
|
|
1196
|
+
* Supports:
|
|
1197
|
+
* - "unblock ~ship" - unblock a specific ship
|
|
1198
|
+
* - "blocked" - list all blocked ships
|
|
1199
|
+
* - "pending" - list all pending approvals
|
|
1200
|
+
*/
|
|
1201
|
+
function parseAdminCommand(text) {
|
|
1202
|
+
const trimmed = normalizeLowercaseStringOrEmpty(text);
|
|
1203
|
+
if (trimmed === "blocked") return { type: "blocked" };
|
|
1204
|
+
if (trimmed === "pending") return { type: "pending" };
|
|
1205
|
+
const unblockMatch = trimmed.match(/^unblock\s+(~[\w-]+)$/);
|
|
1206
|
+
if (unblockMatch) return {
|
|
1207
|
+
type: "unblock",
|
|
1208
|
+
ship: unblockMatch[1]
|
|
1209
|
+
};
|
|
1210
|
+
return null;
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Check if a message text looks like an admin command.
|
|
1214
|
+
*/
|
|
1215
|
+
function isAdminCommand(text) {
|
|
1216
|
+
return parseAdminCommand(text) !== null;
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Format the list of blocked ships for display to owner.
|
|
1220
|
+
*/
|
|
1221
|
+
function formatBlockedList(ships) {
|
|
1222
|
+
if (ships.length === 0) return "No ships are currently blocked.";
|
|
1223
|
+
return `Blocked ships (${ships.length}):\n${ships.map((s) => `• ${s}`).join("\n")}`;
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Format the list of pending approvals for display to owner.
|
|
1227
|
+
*/
|
|
1228
|
+
function formatPendingList(approvals) {
|
|
1229
|
+
if (approvals.length === 0) return "No pending approval requests.";
|
|
1230
|
+
return `Pending approvals (${approvals.length}):\n${approvals.map((a) => `• ${a.id}: ${a.type} from ${a.requestingShip}`).join("\n")}`;
|
|
1231
|
+
}
|
|
1232
|
+
//#endregion
|
|
1233
|
+
//#region extensions/tlon/src/monitor/approval-runtime.ts
|
|
1234
|
+
function createTlonApprovalRuntime(params) {
|
|
1235
|
+
const { api, runtime, botShipName, getPendingApprovals, setPendingApprovals, getCurrentSettings, setCurrentSettings, getEffectiveDmAllowlist, setEffectiveDmAllowlist, getEffectiveOwnerShip, processApprovedMessage, refreshWatchedChannels } = params;
|
|
1236
|
+
const savePendingApprovals = async () => {
|
|
1237
|
+
try {
|
|
1238
|
+
await api.poke({
|
|
1239
|
+
app: "settings",
|
|
1240
|
+
mark: "settings-event",
|
|
1241
|
+
json: { "put-entry": {
|
|
1242
|
+
desk: "moltbot",
|
|
1243
|
+
"bucket-key": "tlon",
|
|
1244
|
+
"entry-key": "pendingApprovals",
|
|
1245
|
+
value: JSON.stringify(getPendingApprovals())
|
|
1246
|
+
} }
|
|
1247
|
+
});
|
|
1248
|
+
} catch (err) {
|
|
1249
|
+
runtime.error?.(`[tlon] Failed to save pending approvals: ${String(err)}`);
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
const addToDmAllowlist = async (ship) => {
|
|
1253
|
+
const normalizedShip = normalizeShip(ship);
|
|
1254
|
+
const nextAllowlist = getEffectiveDmAllowlist().includes(normalizedShip) ? getEffectiveDmAllowlist() : [...getEffectiveDmAllowlist(), normalizedShip];
|
|
1255
|
+
setEffectiveDmAllowlist(nextAllowlist);
|
|
1256
|
+
try {
|
|
1257
|
+
await api.poke({
|
|
1258
|
+
app: "settings",
|
|
1259
|
+
mark: "settings-event",
|
|
1260
|
+
json: { "put-entry": {
|
|
1261
|
+
desk: "moltbot",
|
|
1262
|
+
"bucket-key": "tlon",
|
|
1263
|
+
"entry-key": "dmAllowlist",
|
|
1264
|
+
value: nextAllowlist
|
|
1265
|
+
} }
|
|
1266
|
+
});
|
|
1267
|
+
runtime.log?.(`[tlon] Added ${normalizedShip} to dmAllowlist`);
|
|
1268
|
+
} catch (err) {
|
|
1269
|
+
runtime.error?.(`[tlon] Failed to update dmAllowlist: ${String(err)}`);
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
const addToChannelAllowlist = async (ship, channelNest) => {
|
|
1273
|
+
const normalizedShip = normalizeShip(ship);
|
|
1274
|
+
const currentSettings = getCurrentSettings();
|
|
1275
|
+
const channelRules = currentSettings.channelRules ?? {};
|
|
1276
|
+
const rule = channelRules[channelNest] ?? {
|
|
1277
|
+
mode: "restricted",
|
|
1278
|
+
allowedShips: []
|
|
1279
|
+
};
|
|
1280
|
+
const allowedShips = [...rule.allowedShips ?? []];
|
|
1281
|
+
if (!allowedShips.includes(normalizedShip)) allowedShips.push(normalizedShip);
|
|
1282
|
+
const updatedRules = {
|
|
1283
|
+
...channelRules,
|
|
1284
|
+
[channelNest]: {
|
|
1285
|
+
...rule,
|
|
1286
|
+
allowedShips
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
setCurrentSettings({
|
|
1290
|
+
...currentSettings,
|
|
1291
|
+
channelRules: updatedRules
|
|
1292
|
+
});
|
|
1293
|
+
try {
|
|
1294
|
+
await api.poke({
|
|
1295
|
+
app: "settings",
|
|
1296
|
+
mark: "settings-event",
|
|
1297
|
+
json: { "put-entry": {
|
|
1298
|
+
desk: "moltbot",
|
|
1299
|
+
"bucket-key": "tlon",
|
|
1300
|
+
"entry-key": "channelRules",
|
|
1301
|
+
value: JSON.stringify(updatedRules)
|
|
1302
|
+
} }
|
|
1303
|
+
});
|
|
1304
|
+
runtime.log?.(`[tlon] Added ${normalizedShip} to ${channelNest} allowlist`);
|
|
1305
|
+
} catch (err) {
|
|
1306
|
+
runtime.error?.(`[tlon] Failed to update channelRules: ${String(err)}`);
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
const blockShip = async (ship) => {
|
|
1310
|
+
const normalizedShip = normalizeShip(ship);
|
|
1311
|
+
try {
|
|
1312
|
+
await api.poke({
|
|
1313
|
+
app: "chat",
|
|
1314
|
+
mark: "chat-block-ship",
|
|
1315
|
+
json: { ship: normalizedShip }
|
|
1316
|
+
});
|
|
1317
|
+
runtime.log?.(`[tlon] Blocked ship ${normalizedShip}`);
|
|
1318
|
+
} catch (err) {
|
|
1319
|
+
runtime.error?.(`[tlon] Failed to block ship ${normalizedShip}: ${String(err)}`);
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
const isShipBlocked = async (ship) => {
|
|
1323
|
+
const normalizedShip = normalizeShip(ship);
|
|
1324
|
+
try {
|
|
1325
|
+
const blocked = await api.scry("/chat/blocked.json");
|
|
1326
|
+
return Array.isArray(blocked) && blocked.some((item) => normalizeShip(item) === normalizedShip);
|
|
1327
|
+
} catch (err) {
|
|
1328
|
+
runtime.log?.(`[tlon] Failed to check blocked list: ${String(err)}`);
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
};
|
|
1332
|
+
const getBlockedShips = async () => {
|
|
1333
|
+
try {
|
|
1334
|
+
const blocked = await api.scry("/chat/blocked.json");
|
|
1335
|
+
return Array.isArray(blocked) ? blocked : [];
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
runtime.log?.(`[tlon] Failed to get blocked list: ${String(err)}`);
|
|
1338
|
+
return [];
|
|
1339
|
+
}
|
|
1340
|
+
};
|
|
1341
|
+
const unblockShip = async (ship) => {
|
|
1342
|
+
const normalizedShip = normalizeShip(ship);
|
|
1343
|
+
try {
|
|
1344
|
+
await api.poke({
|
|
1345
|
+
app: "chat",
|
|
1346
|
+
mark: "chat-unblock-ship",
|
|
1347
|
+
json: { ship: normalizedShip }
|
|
1348
|
+
});
|
|
1349
|
+
runtime.log?.(`[tlon] Unblocked ship ${normalizedShip}`);
|
|
1350
|
+
return true;
|
|
1351
|
+
} catch (err) {
|
|
1352
|
+
runtime.error?.(`[tlon] Failed to unblock ship ${normalizedShip}: ${String(err)}`);
|
|
1353
|
+
return false;
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
const sendOwnerNotification = async (message) => {
|
|
1357
|
+
const ownerShip = getEffectiveOwnerShip();
|
|
1358
|
+
if (!ownerShip) {
|
|
1359
|
+
runtime.log?.("[tlon] No ownerShip configured, cannot send notification");
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
try {
|
|
1363
|
+
await sendDm({
|
|
1364
|
+
api,
|
|
1365
|
+
fromShip: botShipName,
|
|
1366
|
+
toShip: ownerShip,
|
|
1367
|
+
text: message
|
|
1368
|
+
});
|
|
1369
|
+
runtime.log?.(`[tlon] Sent notification to owner ${ownerShip}`);
|
|
1370
|
+
} catch (err) {
|
|
1371
|
+
runtime.error?.(`[tlon] Failed to send notification to owner: ${String(err)}`);
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
const queueApprovalRequest = async (approval) => {
|
|
1375
|
+
if (await isShipBlocked(approval.requestingShip)) {
|
|
1376
|
+
runtime.log?.(`[tlon] Ignoring request from blocked ship ${approval.requestingShip}`);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
const approvals = getPendingApprovals();
|
|
1380
|
+
const existingIndex = approvals.findIndex((item) => item.type === approval.type && item.requestingShip === approval.requestingShip && (approval.type !== "channel" || item.channelNest === approval.channelNest) && (approval.type !== "group" || item.groupFlag === approval.groupFlag));
|
|
1381
|
+
if (existingIndex !== -1) {
|
|
1382
|
+
const existing = approvals[existingIndex];
|
|
1383
|
+
if (approval.originalMessage) {
|
|
1384
|
+
existing.originalMessage = approval.originalMessage;
|
|
1385
|
+
existing.messagePreview = approval.messagePreview;
|
|
1386
|
+
}
|
|
1387
|
+
runtime.log?.(`[tlon] Updated existing approval for ${approval.requestingShip} (${approval.type}) - re-sending notification`);
|
|
1388
|
+
await savePendingApprovals();
|
|
1389
|
+
await sendOwnerNotification(formatApprovalRequest(existing));
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
setPendingApprovals([...approvals, approval]);
|
|
1393
|
+
await savePendingApprovals();
|
|
1394
|
+
await sendOwnerNotification(formatApprovalRequest(approval));
|
|
1395
|
+
runtime.log?.(`[tlon] Queued approval request: ${approval.id} (${approval.type} from ${approval.requestingShip})`);
|
|
1396
|
+
};
|
|
1397
|
+
const handleApprovalResponse = async (text) => {
|
|
1398
|
+
const parsed = parseApprovalResponse(text);
|
|
1399
|
+
if (!parsed) return false;
|
|
1400
|
+
const approval = findPendingApproval(getPendingApprovals(), parsed.id);
|
|
1401
|
+
if (!approval) {
|
|
1402
|
+
await sendOwnerNotification(`No pending approval found${parsed.id ? ` for ID: ${parsed.id}` : ""}`);
|
|
1403
|
+
return true;
|
|
1404
|
+
}
|
|
1405
|
+
if (parsed.action === "approve") {
|
|
1406
|
+
switch (approval.type) {
|
|
1407
|
+
case "dm":
|
|
1408
|
+
await addToDmAllowlist(approval.requestingShip);
|
|
1409
|
+
if (approval.originalMessage) {
|
|
1410
|
+
runtime.log?.(`[tlon] Processing original message from ${approval.requestingShip} after approval`);
|
|
1411
|
+
await processApprovedMessage(approval);
|
|
1412
|
+
}
|
|
1413
|
+
break;
|
|
1414
|
+
case "channel":
|
|
1415
|
+
if (approval.channelNest) {
|
|
1416
|
+
await addToChannelAllowlist(approval.requestingShip, approval.channelNest);
|
|
1417
|
+
if (approval.originalMessage) {
|
|
1418
|
+
runtime.log?.(`[tlon] Processing original message from ${approval.requestingShip} in ${approval.channelNest} after approval`);
|
|
1419
|
+
await processApprovedMessage(approval);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
break;
|
|
1423
|
+
case "group":
|
|
1424
|
+
if (approval.groupFlag) try {
|
|
1425
|
+
await api.poke({
|
|
1426
|
+
app: "groups",
|
|
1427
|
+
mark: "group-join",
|
|
1428
|
+
json: {
|
|
1429
|
+
flag: approval.groupFlag,
|
|
1430
|
+
"join-all": true
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
runtime.log?.(`[tlon] Joined group ${approval.groupFlag} after approval`);
|
|
1434
|
+
setTimeout(() => {
|
|
1435
|
+
(async () => {
|
|
1436
|
+
try {
|
|
1437
|
+
const newCount = await refreshWatchedChannels();
|
|
1438
|
+
if (newCount > 0) runtime.log?.(`[tlon] Discovered ${newCount} new channel(s) after joining group`);
|
|
1439
|
+
} catch (err) {
|
|
1440
|
+
runtime.log?.(`[tlon] Channel discovery after group join failed: ${String(err)}`);
|
|
1441
|
+
}
|
|
1442
|
+
})();
|
|
1443
|
+
}, 2e3);
|
|
1444
|
+
} catch (err) {
|
|
1445
|
+
runtime.error?.(`[tlon] Failed to join group ${approval.groupFlag}: ${String(err)}`);
|
|
1446
|
+
}
|
|
1447
|
+
break;
|
|
1448
|
+
}
|
|
1449
|
+
await sendOwnerNotification(formatApprovalConfirmation(approval, "approve"));
|
|
1450
|
+
} else if (parsed.action === "block") {
|
|
1451
|
+
await blockShip(approval.requestingShip);
|
|
1452
|
+
await sendOwnerNotification(formatApprovalConfirmation(approval, "block"));
|
|
1453
|
+
} else await sendOwnerNotification(formatApprovalConfirmation(approval, "deny"));
|
|
1454
|
+
setPendingApprovals(removePendingApproval(getPendingApprovals(), approval.id));
|
|
1455
|
+
await savePendingApprovals();
|
|
1456
|
+
return true;
|
|
1457
|
+
};
|
|
1458
|
+
const handleAdminCommand = async (text) => {
|
|
1459
|
+
const command = parseAdminCommand(text);
|
|
1460
|
+
if (!command) return false;
|
|
1461
|
+
switch (command.type) {
|
|
1462
|
+
case "blocked": {
|
|
1463
|
+
const blockedShips = await getBlockedShips();
|
|
1464
|
+
await sendOwnerNotification(formatBlockedList(blockedShips));
|
|
1465
|
+
runtime.log?.(`[tlon] Owner requested blocked ships list (${blockedShips.length} ships)`);
|
|
1466
|
+
return true;
|
|
1467
|
+
}
|
|
1468
|
+
case "pending":
|
|
1469
|
+
await sendOwnerNotification(formatPendingList(getPendingApprovals()));
|
|
1470
|
+
runtime.log?.(`[tlon] Owner requested pending approvals list (${getPendingApprovals().length} pending)`);
|
|
1471
|
+
return true;
|
|
1472
|
+
case "unblock": {
|
|
1473
|
+
const shipToUnblock = command.ship;
|
|
1474
|
+
if (!await isShipBlocked(shipToUnblock)) {
|
|
1475
|
+
await sendOwnerNotification(`${shipToUnblock} is not blocked.`);
|
|
1476
|
+
return true;
|
|
1477
|
+
}
|
|
1478
|
+
await sendOwnerNotification(await unblockShip(shipToUnblock) ? `Unblocked ${shipToUnblock}.` : `Failed to unblock ${shipToUnblock}.`);
|
|
1479
|
+
return true;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
throw new Error("Unsupported Tlon admin command");
|
|
1483
|
+
};
|
|
1484
|
+
return {
|
|
1485
|
+
queueApprovalRequest,
|
|
1486
|
+
handleApprovalResponse,
|
|
1487
|
+
handleAdminCommand
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
//#endregion
|
|
1491
|
+
//#region extensions/tlon/src/monitor/authorization.ts
|
|
1492
|
+
function resolveChannelAuthorization(cfg, channelNest, settings) {
|
|
1493
|
+
const tlonConfig = cfg.channels?.tlon;
|
|
1494
|
+
const fileRules = tlonConfig?.authorization?.channelRules ?? {};
|
|
1495
|
+
const rule = (settings?.channelRules ?? {})[channelNest] ?? fileRules[channelNest];
|
|
1496
|
+
const defaultShips = settings?.defaultAuthorizedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
|
|
1497
|
+
return {
|
|
1498
|
+
mode: rule?.mode ?? "restricted",
|
|
1499
|
+
allowedShips: rule?.allowedShips ?? defaultShips
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
//#endregion
|
|
1503
|
+
//#region extensions/tlon/src/monitor/utils.ts
|
|
1504
|
+
function extractCites(content) {
|
|
1505
|
+
if (!content || !Array.isArray(content)) return [];
|
|
1506
|
+
const cites = [];
|
|
1507
|
+
for (const verse of content) if (verse?.block?.cite && typeof verse.block.cite === "object") {
|
|
1508
|
+
const cite = verse.block.cite;
|
|
1509
|
+
if (cite.chan && typeof cite.chan === "object") {
|
|
1510
|
+
const { nest, where } = cite.chan;
|
|
1511
|
+
const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/);
|
|
1512
|
+
cites.push({
|
|
1513
|
+
type: "chan",
|
|
1514
|
+
nest,
|
|
1515
|
+
where,
|
|
1516
|
+
author: whereMatch?.[1],
|
|
1517
|
+
postId: whereMatch?.[2]
|
|
1518
|
+
});
|
|
1519
|
+
} else if (cite.group && typeof cite.group === "string") cites.push({
|
|
1520
|
+
type: "group",
|
|
1521
|
+
group: cite.group
|
|
1522
|
+
});
|
|
1523
|
+
else if (cite.desk && typeof cite.desk === "object") cites.push({
|
|
1524
|
+
type: "desk",
|
|
1525
|
+
flag: cite.desk.flag,
|
|
1526
|
+
where: cite.desk.where
|
|
1527
|
+
});
|
|
1528
|
+
else if (cite.bait && typeof cite.bait === "object") cites.push({
|
|
1529
|
+
type: "bait",
|
|
1530
|
+
group: cite.bait.group,
|
|
1531
|
+
nest: cite.bait.graph,
|
|
1532
|
+
where: cite.bait.where
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
return cites;
|
|
1536
|
+
}
|
|
1537
|
+
function formatModelName(modelString) {
|
|
1538
|
+
if (!modelString) return "AI";
|
|
1539
|
+
const modelName = modelString.includes("/") ? modelString.split("/")[1] : modelString;
|
|
1540
|
+
const modelMappings = {
|
|
1541
|
+
"claude-opus-4-5": "Claude Opus 4.5",
|
|
1542
|
+
"claude-sonnet-4-5": "Claude Sonnet 4.5",
|
|
1543
|
+
"claude-sonnet-3-5": "Claude Sonnet 3.5",
|
|
1544
|
+
"gpt-4o": "GPT-4o",
|
|
1545
|
+
"gpt-4-turbo": "GPT-4 Turbo",
|
|
1546
|
+
"gpt-4": "GPT-4",
|
|
1547
|
+
"gemini-2.0-flash": "Gemini 2.0 Flash",
|
|
1548
|
+
"gemini-pro": "Gemini Pro"
|
|
1549
|
+
};
|
|
1550
|
+
if (modelMappings[modelName]) return modelMappings[modelName];
|
|
1551
|
+
return modelName.replace(/-/g, " ").split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
1552
|
+
}
|
|
1553
|
+
function isBotMentioned(messageText, botShipName, nickname) {
|
|
1554
|
+
if (!messageText || !botShipName) return false;
|
|
1555
|
+
if (/@all\b/i.test(messageText)) return true;
|
|
1556
|
+
const escapedShip = normalizeShip(botShipName).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1557
|
+
if (new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i").test(messageText)) return true;
|
|
1558
|
+
if (nickname) {
|
|
1559
|
+
const escapedNickname = nickname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1560
|
+
if (new RegExp(`(^|\\s)${escapedNickname}(?=\\s|$|[,!?.])`, "i").test(messageText)) return true;
|
|
1561
|
+
}
|
|
1562
|
+
return false;
|
|
1563
|
+
}
|
|
1564
|
+
function stripBotMention(messageText, botShipName) {
|
|
1565
|
+
if (!messageText || !botShipName) return messageText;
|
|
1566
|
+
return messageText.replace(normalizeShip(botShipName), "").trim();
|
|
1567
|
+
}
|
|
1568
|
+
const tlonIngressIdentity = {
|
|
1569
|
+
key: "sender-ship",
|
|
1570
|
+
normalize: normalizeShip,
|
|
1571
|
+
sensitivity: "pii",
|
|
1572
|
+
isWildcardEntry: () => false,
|
|
1573
|
+
entryIdPrefix: "tlon-entry"
|
|
1574
|
+
};
|
|
1575
|
+
async function isDmAllowedWithIngress(senderShip, allowlist) {
|
|
1576
|
+
return (await resolveStableChannelMessageIngress({
|
|
1577
|
+
channelId: "tlon",
|
|
1578
|
+
accountId: "default",
|
|
1579
|
+
identity: tlonIngressIdentity,
|
|
1580
|
+
subject: { stableId: senderShip },
|
|
1581
|
+
conversation: {
|
|
1582
|
+
kind: "direct",
|
|
1583
|
+
id: "direct"
|
|
1584
|
+
},
|
|
1585
|
+
dmPolicy: "allowlist",
|
|
1586
|
+
allowFrom: allowlist ?? []
|
|
1587
|
+
})).senderAccess.allowed;
|
|
1588
|
+
}
|
|
1589
|
+
async function resolveTlonCommandAuthorizationWithIngress(params) {
|
|
1590
|
+
const normalizedOwner = params.ownerShip ? normalizeShip(params.ownerShip) : null;
|
|
1591
|
+
return await resolveStableChannelMessageIngress({
|
|
1592
|
+
channelId: "tlon",
|
|
1593
|
+
accountId: "default",
|
|
1594
|
+
identity: tlonIngressIdentity,
|
|
1595
|
+
useAccessGroups: params.useAccessGroups,
|
|
1596
|
+
subject: { stableId: params.senderShip },
|
|
1597
|
+
conversation: {
|
|
1598
|
+
kind: "direct",
|
|
1599
|
+
id: "command"
|
|
1600
|
+
},
|
|
1601
|
+
event: {
|
|
1602
|
+
authMode: "none",
|
|
1603
|
+
mayPair: false
|
|
1604
|
+
},
|
|
1605
|
+
dmPolicy: "allowlist",
|
|
1606
|
+
groupPolicy: "open",
|
|
1607
|
+
allowFrom: normalizedOwner ? [normalizedOwner] : [],
|
|
1608
|
+
command: {}
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
function isGroupInviteAllowed(inviterShip, allowlist) {
|
|
1612
|
+
if (!allowlist || allowlist.length === 0) return false;
|
|
1613
|
+
const normalizedInviter = normalizeShip(inviterShip);
|
|
1614
|
+
return allowlist.map((ship) => normalizeShip(ship)).some((ship) => ship === normalizedInviter);
|
|
1615
|
+
}
|
|
1616
|
+
async function resolveAuthorizedMessageText(params) {
|
|
1617
|
+
const { rawText, content, authorizedForCites, resolveAllCites } = params;
|
|
1618
|
+
if (!authorizedForCites) return rawText;
|
|
1619
|
+
return await resolveAllCites(content) + rawText;
|
|
1620
|
+
}
|
|
1621
|
+
const asRecord = asNullableObjectRecord;
|
|
1622
|
+
const formatErrorMessage$1 = formatErrorMessage;
|
|
1623
|
+
const readString = readStringField;
|
|
1624
|
+
function asNullableObjectRecord(value) {
|
|
1625
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1626
|
+
}
|
|
1627
|
+
function readStringField(record, field) {
|
|
1628
|
+
const value = record?.[field];
|
|
1629
|
+
return typeof value === "string" ? value : void 0;
|
|
1630
|
+
}
|
|
1631
|
+
function renderInlineItem(item, options) {
|
|
1632
|
+
if (typeof item === "string") return item;
|
|
1633
|
+
const record = asRecord(item);
|
|
1634
|
+
if (!record) return "";
|
|
1635
|
+
const ship = readString(record, "ship");
|
|
1636
|
+
if (ship) return ship;
|
|
1637
|
+
if ("sect" in record) {
|
|
1638
|
+
const sect = record.sect;
|
|
1639
|
+
if (typeof sect === "string") return `@${sect || "all"}`;
|
|
1640
|
+
if (sect === null) return "@all";
|
|
1641
|
+
}
|
|
1642
|
+
if (options?.allowBreak && "break" in record) return "\n";
|
|
1643
|
+
const inlineCode = readString(record, "inline-code");
|
|
1644
|
+
if (inlineCode) return `\`${inlineCode}\``;
|
|
1645
|
+
const code = readString(record, "code");
|
|
1646
|
+
if (code) return `\`${code}\``;
|
|
1647
|
+
const link = asRecord(record.link);
|
|
1648
|
+
const linkHref = link ? readString(link, "href") : void 0;
|
|
1649
|
+
if (link && linkHref) {
|
|
1650
|
+
const linkContent = readString(link, "content");
|
|
1651
|
+
return options?.linkMode === "href" ? linkHref : linkContent || linkHref;
|
|
1652
|
+
}
|
|
1653
|
+
if (Array.isArray(record.bold)) return `**${extractInlineText(record.bold)}**`;
|
|
1654
|
+
if (Array.isArray(record.italics)) return `*${extractInlineText(record.italics)}*`;
|
|
1655
|
+
if (Array.isArray(record.strike)) return `~~${extractInlineText(record.strike)}~~`;
|
|
1656
|
+
if (options?.allowBlockquote && Array.isArray(record.blockquote)) return `> ${extractInlineText(record.blockquote)}`;
|
|
1657
|
+
return "";
|
|
1658
|
+
}
|
|
1659
|
+
function extractInlineText(items) {
|
|
1660
|
+
return items.map((item) => renderInlineItem(item)).join("");
|
|
1661
|
+
}
|
|
1662
|
+
function extractMessageText(content) {
|
|
1663
|
+
if (!content || !Array.isArray(content)) return "";
|
|
1664
|
+
return content.map((verse) => {
|
|
1665
|
+
const verseRecord = asRecord(verse);
|
|
1666
|
+
if (!verseRecord) return "";
|
|
1667
|
+
if (Array.isArray(verseRecord.inline)) return verseRecord.inline.map((item) => renderInlineItem(item, {
|
|
1668
|
+
linkMode: "href",
|
|
1669
|
+
allowBreak: true,
|
|
1670
|
+
allowBlockquote: true
|
|
1671
|
+
})).join("");
|
|
1672
|
+
const block = asRecord(verseRecord.block);
|
|
1673
|
+
if (block) {
|
|
1674
|
+
const image = asRecord(block.image);
|
|
1675
|
+
if (image) {
|
|
1676
|
+
const imageSrc = readString(image, "src");
|
|
1677
|
+
if (imageSrc) {
|
|
1678
|
+
const altText = readString(image, "alt");
|
|
1679
|
+
return `\n${imageSrc}${altText ? ` (${altText})` : ""}\n`;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
const codeBlock = asRecord(block.code);
|
|
1683
|
+
if (codeBlock) return `\n\`\`\`${readString(codeBlock, "lang") ?? ""}\n${readString(codeBlock, "code") ?? ""}\n\`\`\`\n`;
|
|
1684
|
+
const header = asRecord(block.header);
|
|
1685
|
+
if (header) return `\n## ${(Array.isArray(header.content) ? header.content : []).map((item) => typeof item === "string" ? item : "").join("") || ""}\n`;
|
|
1686
|
+
const cite = asRecord(block.cite);
|
|
1687
|
+
if (cite) {
|
|
1688
|
+
const chanCite = asRecord(cite.chan);
|
|
1689
|
+
if (chanCite) {
|
|
1690
|
+
const nest = readString(chanCite, "nest");
|
|
1691
|
+
const whereMatch = readString(chanCite, "where")?.match(/\/msg\/(~[a-z-]+)\/(.+)/);
|
|
1692
|
+
if (whereMatch) {
|
|
1693
|
+
const [, author, _postId] = whereMatch;
|
|
1694
|
+
return `\n> [quoted: ${author} in ${nest}]\n`;
|
|
1695
|
+
}
|
|
1696
|
+
return `\n> [quoted from ${nest}]\n`;
|
|
1697
|
+
}
|
|
1698
|
+
const group = readString(cite, "group");
|
|
1699
|
+
if (group) return `\n> [ref: group ${group}]\n`;
|
|
1700
|
+
const desk = asRecord(cite.desk);
|
|
1701
|
+
if (desk) {
|
|
1702
|
+
const flag = readString(desk, "flag");
|
|
1703
|
+
if (flag) return `\n> [ref: ${flag}]\n`;
|
|
1704
|
+
}
|
|
1705
|
+
const bait = asRecord(cite.bait);
|
|
1706
|
+
if (bait) {
|
|
1707
|
+
const graph = readString(bait, "graph");
|
|
1708
|
+
const groupName = readString(bait, "group");
|
|
1709
|
+
if (graph && groupName) return `\n> [ref: ${graph} in ${groupName}]\n`;
|
|
1710
|
+
}
|
|
1711
|
+
return `\n> [quoted message]\n`;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
return "";
|
|
1715
|
+
}).join("\n").trim();
|
|
1716
|
+
}
|
|
1717
|
+
function isSummarizationRequest(messageText) {
|
|
1718
|
+
return [
|
|
1719
|
+
/summarize\s+(this\s+)?(channel|chat|conversation)/i,
|
|
1720
|
+
/what\s+did\s+i\s+miss/i,
|
|
1721
|
+
/catch\s+me\s+up/i,
|
|
1722
|
+
/channel\s+summary/i,
|
|
1723
|
+
/tldr/i
|
|
1724
|
+
].some((pattern) => pattern.test(messageText));
|
|
1725
|
+
}
|
|
1726
|
+
//#endregion
|
|
1727
|
+
//#region extensions/tlon/src/monitor/cites.ts
|
|
1728
|
+
function createTlonCitationResolver(params) {
|
|
1729
|
+
const { api, runtime } = params;
|
|
1730
|
+
const resolveCiteContent = async (cite) => {
|
|
1731
|
+
if (cite.type !== "chan" || !cite.nest || !cite.postId) return null;
|
|
1732
|
+
try {
|
|
1733
|
+
const scryPath = `/channels/v4/${cite.nest}/posts/post/${cite.postId}.json`;
|
|
1734
|
+
runtime.log?.(`[tlon] Fetching cited post: ${scryPath}`);
|
|
1735
|
+
const essay = asRecord(asRecord(await api.scry(scryPath))?.essay);
|
|
1736
|
+
if (essay?.content) return extractMessageText(essay.content) || null;
|
|
1737
|
+
return null;
|
|
1738
|
+
} catch (err) {
|
|
1739
|
+
runtime.log?.(`[tlon] Failed to fetch cited post: ${String(err)}`);
|
|
1740
|
+
return null;
|
|
1741
|
+
}
|
|
1742
|
+
};
|
|
1743
|
+
const resolveAllCites = async (content) => {
|
|
1744
|
+
const cites = extractCites(content);
|
|
1745
|
+
if (cites.length === 0) return "";
|
|
1746
|
+
const resolved = [];
|
|
1747
|
+
for (const cite of cites) {
|
|
1748
|
+
const text = await resolveCiteContent(cite);
|
|
1749
|
+
if (text) resolved.push(`> ${cite.author || "unknown"} wrote: ${text}`);
|
|
1750
|
+
}
|
|
1751
|
+
return resolved.length > 0 ? `${resolved.join("\n")}\n\n` : "";
|
|
1752
|
+
};
|
|
1753
|
+
return {
|
|
1754
|
+
resolveCiteContent,
|
|
1755
|
+
resolveAllCites
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
//#endregion
|
|
1759
|
+
//#region extensions/tlon/src/monitor/discovery.ts
|
|
1760
|
+
/**
|
|
1761
|
+
* Fetch groups-ui init data, returning channels and foreigns.
|
|
1762
|
+
* This is a single scry that provides both channel discovery and pending invites.
|
|
1763
|
+
*/
|
|
1764
|
+
async function fetchInitData(api, runtime) {
|
|
1765
|
+
try {
|
|
1766
|
+
runtime.log?.("[tlon] Fetching groups-ui init data...");
|
|
1767
|
+
const initData = asRecord(await api.scry("/groups-ui/v6/init.json"));
|
|
1768
|
+
const channels = [];
|
|
1769
|
+
const groups = asRecord(initData?.groups);
|
|
1770
|
+
if (groups) for (const groupData of Object.values(groups)) {
|
|
1771
|
+
const groupChannels = asRecord(asRecord(groupData)?.channels);
|
|
1772
|
+
if (groupChannels) {
|
|
1773
|
+
for (const channelNest of Object.keys(groupChannels)) if (channelNest.startsWith("chat/")) channels.push(channelNest);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
if (channels.length > 0) runtime.log?.(`[tlon] Auto-discovered ${channels.length} chat channel(s)`);
|
|
1777
|
+
else runtime.log?.("[tlon] No chat channels found via auto-discovery");
|
|
1778
|
+
const foreignsValue = asRecord(initData?.foreigns);
|
|
1779
|
+
const foreigns = foreignsValue ? foreignsValue : null;
|
|
1780
|
+
if (foreigns) {
|
|
1781
|
+
const pendingCount = Object.values(foreigns).filter((f) => f.invites?.some((i) => i.valid)).length;
|
|
1782
|
+
if (pendingCount > 0) runtime.log?.(`[tlon] Found ${pendingCount} pending group invite(s)`);
|
|
1783
|
+
}
|
|
1784
|
+
return {
|
|
1785
|
+
channels,
|
|
1786
|
+
foreigns
|
|
1787
|
+
};
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
runtime.log?.(`[tlon] Init data fetch failed: ${formatErrorMessage$1(error)}`);
|
|
1790
|
+
return {
|
|
1791
|
+
channels: [],
|
|
1792
|
+
foreigns: null
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
async function fetchAllChannels(api, runtime) {
|
|
1797
|
+
const { channels } = await fetchInitData(api, runtime);
|
|
1798
|
+
return channels;
|
|
1799
|
+
}
|
|
1800
|
+
//#endregion
|
|
1801
|
+
//#region extensions/tlon/src/monitor/history.ts
|
|
1802
|
+
/**
|
|
1803
|
+
* Format a number as @ud (with dots every 3 digits from the right)
|
|
1804
|
+
* e.g., 170141184507799509469114119040828178432 -> 170.141.184.507.799.509.469.114.119.040.828.178.432
|
|
1805
|
+
*/
|
|
1806
|
+
function formatUd(id) {
|
|
1807
|
+
const reversed = String(id).replace(/\./g, "").split("").toReversed();
|
|
1808
|
+
const chunks = [];
|
|
1809
|
+
for (let i = 0; i < reversed.length; i += 3) chunks.push(reversed.slice(i, i + 3).toReversed().join(""));
|
|
1810
|
+
return chunks.toReversed().join(".");
|
|
1811
|
+
}
|
|
1812
|
+
function createHistoryEntryFromMemo(params) {
|
|
1813
|
+
const { memo, seal, fallbackId } = params;
|
|
1814
|
+
return {
|
|
1815
|
+
author: typeof memo?.author === "string" ? memo.author : "unknown",
|
|
1816
|
+
content: extractMessageText(memo?.content || []),
|
|
1817
|
+
timestamp: typeof memo?.sent === "number" ? memo.sent : Date.now(),
|
|
1818
|
+
id: typeof seal?.id === "string" ? seal.id : typeof fallbackId === "string" ? fallbackId : void 0
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
const messageCache = /* @__PURE__ */ new Map();
|
|
1822
|
+
const MAX_CACHED_MESSAGES = 100;
|
|
1823
|
+
function cacheMessage(channelNest, message) {
|
|
1824
|
+
if (!messageCache.has(channelNest)) messageCache.set(channelNest, []);
|
|
1825
|
+
const cache = messageCache.get(channelNest);
|
|
1826
|
+
if (!cache) return;
|
|
1827
|
+
cache.unshift(message);
|
|
1828
|
+
if (cache.length > MAX_CACHED_MESSAGES) cache.pop();
|
|
1829
|
+
}
|
|
1830
|
+
async function fetchChannelHistory(api, channelNest, count = 50, runtime) {
|
|
1831
|
+
try {
|
|
1832
|
+
const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`;
|
|
1833
|
+
runtime?.log?.(`[tlon] Fetching history: ${scryPath}`);
|
|
1834
|
+
const data = await api.scry(scryPath);
|
|
1835
|
+
if (!data) return [];
|
|
1836
|
+
let posts = [];
|
|
1837
|
+
if (Array.isArray(data)) posts = data;
|
|
1838
|
+
else {
|
|
1839
|
+
const dataRecord = asRecord(data);
|
|
1840
|
+
const postMap = asRecord(dataRecord?.posts);
|
|
1841
|
+
if (postMap) posts = Object.values(postMap);
|
|
1842
|
+
else if (dataRecord) posts = Object.values(dataRecord);
|
|
1843
|
+
}
|
|
1844
|
+
const messages = posts.map((item) => {
|
|
1845
|
+
const itemRecord = asRecord(item);
|
|
1846
|
+
const replyPostSet = asRecord(asRecord(itemRecord?.["r-post"])?.set);
|
|
1847
|
+
const essay = asRecord(itemRecord?.essay) ?? asRecord(replyPostSet?.essay);
|
|
1848
|
+
const seal = asRecord(itemRecord?.seal) ?? asRecord(replyPostSet?.seal);
|
|
1849
|
+
return {
|
|
1850
|
+
author: typeof essay?.author === "string" ? essay.author : "unknown",
|
|
1851
|
+
content: extractMessageText(essay?.content || []),
|
|
1852
|
+
timestamp: typeof essay?.sent === "number" ? essay.sent : Date.now(),
|
|
1853
|
+
id: typeof seal?.id === "string" ? seal.id : void 0
|
|
1854
|
+
};
|
|
1855
|
+
}).filter((msg) => msg.content);
|
|
1856
|
+
runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`);
|
|
1857
|
+
return messages;
|
|
1858
|
+
} catch (error) {
|
|
1859
|
+
runtime?.log?.(`[tlon] Error fetching channel history: ${formatErrorMessage$1(error)}`);
|
|
1860
|
+
return [];
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
async function getChannelHistory(api, channelNest, count = 50, runtime) {
|
|
1864
|
+
const cache = messageCache.get(channelNest) ?? [];
|
|
1865
|
+
if (cache.length >= count) {
|
|
1866
|
+
runtime?.log?.(`[tlon] Using cached messages (${cache.length} available)`);
|
|
1867
|
+
return cache.slice(0, count);
|
|
1868
|
+
}
|
|
1869
|
+
runtime?.log?.(`[tlon] Cache has ${cache.length} messages, need ${count}, fetching from scry...`);
|
|
1870
|
+
return await fetchChannelHistory(api, channelNest, count, runtime);
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Fetch thread/reply history for a specific parent post.
|
|
1874
|
+
* Used to get context when entering a thread conversation.
|
|
1875
|
+
*/
|
|
1876
|
+
async function fetchThreadHistory(api, channelNest, parentId, count = 50, runtime) {
|
|
1877
|
+
try {
|
|
1878
|
+
const formattedParentId = formatUd(parentId);
|
|
1879
|
+
runtime?.log?.(`[tlon] Thread history - parentId: ${parentId} -> formatted: ${formattedParentId}`);
|
|
1880
|
+
const scryPath = `/channels/v4/${channelNest}/posts/post/id/${formattedParentId}/replies/newest/${count}.json`;
|
|
1881
|
+
runtime?.log?.(`[tlon] Fetching thread history: ${scryPath}`);
|
|
1882
|
+
const data = await api.scry(scryPath);
|
|
1883
|
+
if (!data) {
|
|
1884
|
+
runtime?.log?.(`[tlon] No thread history data returned`);
|
|
1885
|
+
return [];
|
|
1886
|
+
}
|
|
1887
|
+
let replies = [];
|
|
1888
|
+
if (Array.isArray(data)) replies = data;
|
|
1889
|
+
else {
|
|
1890
|
+
const dataRecord = asRecord(data);
|
|
1891
|
+
const replyValue = dataRecord?.replies;
|
|
1892
|
+
if (Array.isArray(replyValue)) replies = replyValue;
|
|
1893
|
+
else if (typeof replyValue === "object" && replyValue) replies = Object.values(replyValue);
|
|
1894
|
+
else if (dataRecord) replies = Object.values(dataRecord);
|
|
1895
|
+
}
|
|
1896
|
+
const messages = replies.map((item) => {
|
|
1897
|
+
const itemRecord = asRecord(item);
|
|
1898
|
+
const replySet = asRecord(asRecord(itemRecord?.["r-reply"])?.set);
|
|
1899
|
+
return createHistoryEntryFromMemo({
|
|
1900
|
+
memo: asRecord(itemRecord?.memo) ?? asRecord(replySet?.memo) ?? itemRecord,
|
|
1901
|
+
seal: asRecord(itemRecord?.seal) ?? asRecord(replySet?.seal),
|
|
1902
|
+
fallbackId: itemRecord?.id
|
|
1903
|
+
});
|
|
1904
|
+
}).filter((msg) => msg.content);
|
|
1905
|
+
runtime?.log?.(`[tlon] Extracted ${messages.length} thread replies from history`);
|
|
1906
|
+
return messages;
|
|
1907
|
+
} catch (error) {
|
|
1908
|
+
runtime?.log?.(`[tlon] Error fetching thread history: ${formatErrorMessage$1(error)}`);
|
|
1909
|
+
try {
|
|
1910
|
+
const altPath = `/channels/v4/${channelNest}/posts/post/id/${formatUd(parentId)}.json`;
|
|
1911
|
+
runtime?.log?.(`[tlon] Trying alternate path: ${altPath}`);
|
|
1912
|
+
const data = asRecord(await api.scry(altPath));
|
|
1913
|
+
const dataMeta = asRecord(asRecord(data?.seal)?.meta);
|
|
1914
|
+
const repliesValue = data?.replies;
|
|
1915
|
+
if (typeof dataMeta?.replyCount === "number" && dataMeta.replyCount > 0 && repliesValue) {
|
|
1916
|
+
const messages = (Array.isArray(repliesValue) ? repliesValue : Object.values(repliesValue)).map((reply) => {
|
|
1917
|
+
const replyRecord = asRecord(reply);
|
|
1918
|
+
return createHistoryEntryFromMemo({
|
|
1919
|
+
memo: asRecord(replyRecord?.memo),
|
|
1920
|
+
seal: asRecord(replyRecord?.seal)
|
|
1921
|
+
});
|
|
1922
|
+
}).filter((msg) => msg.content);
|
|
1923
|
+
runtime?.log?.(`[tlon] Extracted ${messages.length} replies from post data`);
|
|
1924
|
+
return messages;
|
|
1925
|
+
}
|
|
1926
|
+
} catch (altError) {
|
|
1927
|
+
runtime?.log?.(`[tlon] Alternate path also failed: ${formatErrorMessage$1(altError)}`);
|
|
1928
|
+
}
|
|
1929
|
+
return [];
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
//#endregion
|
|
1933
|
+
//#region extensions/tlon/src/monitor/media.ts
|
|
1934
|
+
const MAX_IMAGES_PER_MESSAGE = 8;
|
|
1935
|
+
const TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS = 3e4;
|
|
1936
|
+
/**
|
|
1937
|
+
* Extract image blocks from Tlon message content.
|
|
1938
|
+
* Returns array of image URLs found in the message.
|
|
1939
|
+
*/
|
|
1940
|
+
function extractImageBlocks(content) {
|
|
1941
|
+
if (!content || !Array.isArray(content)) return [];
|
|
1942
|
+
const images = [];
|
|
1943
|
+
for (const verse of content) if (verse?.block?.image?.src) {
|
|
1944
|
+
images.push({
|
|
1945
|
+
url: verse.block.image.src,
|
|
1946
|
+
alt: verse.block.image.alt
|
|
1947
|
+
});
|
|
1948
|
+
if (images.length >= MAX_IMAGES_PER_MESSAGE) break;
|
|
1949
|
+
}
|
|
1950
|
+
return images;
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Download a media file from URL to local storage.
|
|
1954
|
+
* Returns the local path where the file was saved.
|
|
1955
|
+
*/
|
|
1956
|
+
async function downloadMedia(url, mediaDir) {
|
|
1957
|
+
try {
|
|
1958
|
+
const parsedUrl = new URL(url);
|
|
1959
|
+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
1960
|
+
console.warn(`[tlon-media] Rejected non-http(s) URL: ${url}`);
|
|
1961
|
+
return null;
|
|
1962
|
+
}
|
|
1963
|
+
const fetchOptions = {
|
|
1964
|
+
url,
|
|
1965
|
+
maxBytes: MAX_IMAGE_BYTES,
|
|
1966
|
+
readIdleTimeoutMs: TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS,
|
|
1967
|
+
ssrfPolicy: void 0,
|
|
1968
|
+
requestInit: { method: "GET" }
|
|
1969
|
+
};
|
|
1970
|
+
if (!mediaDir) {
|
|
1971
|
+
const saved = await saveRemoteMedia(fetchOptions);
|
|
1972
|
+
return {
|
|
1973
|
+
localPath: saved.path,
|
|
1974
|
+
contentType: saved.contentType ?? "application/octet-stream",
|
|
1975
|
+
originalUrl: url
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
const fetched = await readRemoteMediaBuffer(fetchOptions);
|
|
1979
|
+
await mkdir(mediaDir, { recursive: true });
|
|
1980
|
+
const ext = getExtensionFromFileName(fetched.fileName) || getExtensionFromContentType(fetched.contentType ?? "") || getExtensionFromUrl(url) || "bin";
|
|
1981
|
+
const localPath = path.join(mediaDir, `${randomUUID()}.${ext}`);
|
|
1982
|
+
await writeFile(localPath, fetched.buffer);
|
|
1983
|
+
return {
|
|
1984
|
+
localPath,
|
|
1985
|
+
contentType: fetched.contentType ?? "application/octet-stream",
|
|
1986
|
+
originalUrl: url
|
|
1987
|
+
};
|
|
1988
|
+
} catch (error) {
|
|
1989
|
+
console.error(`[tlon-media] Error downloading ${url}: ${formatErrorMessage(error)}`);
|
|
1990
|
+
return null;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
function getExtensionFromFileName(fileName) {
|
|
1994
|
+
if (!fileName) return null;
|
|
1995
|
+
return path.extname(fileName).replace(/^\./, "") || null;
|
|
1996
|
+
}
|
|
1997
|
+
function getExtensionFromContentType(contentType) {
|
|
1998
|
+
return extensionForMime(contentType)?.replace(/^\./u, "") ?? null;
|
|
1999
|
+
}
|
|
2000
|
+
function getExtensionFromUrl(url) {
|
|
2001
|
+
try {
|
|
2002
|
+
const match = new URL(url).pathname.match(/\.([a-z0-9]+)$/i);
|
|
2003
|
+
return match ? normalizeLowercaseStringOrEmpty(match[1]) : null;
|
|
2004
|
+
} catch {
|
|
2005
|
+
return null;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Download all images from a message and return attachment metadata.
|
|
2010
|
+
* Format matches Klaw's expected attachment structure.
|
|
2011
|
+
*/
|
|
2012
|
+
async function downloadMessageImages(content, mediaDir) {
|
|
2013
|
+
const images = extractImageBlocks(content);
|
|
2014
|
+
if (images.length === 0) return [];
|
|
2015
|
+
const attachments = [];
|
|
2016
|
+
for (const image of images) {
|
|
2017
|
+
const downloaded = await downloadMedia(image.url, mediaDir);
|
|
2018
|
+
if (downloaded) attachments.push({
|
|
2019
|
+
path: downloaded.localPath,
|
|
2020
|
+
contentType: downloaded.contentType
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
return attachments;
|
|
2024
|
+
}
|
|
2025
|
+
//#endregion
|
|
2026
|
+
//#region extensions/tlon/src/monitor/processed-messages.ts
|
|
2027
|
+
function createProcessedMessageTracker(limit = 2e3) {
|
|
2028
|
+
const dedupe = createDedupeCache({
|
|
2029
|
+
ttlMs: 0,
|
|
2030
|
+
maxSize: limit
|
|
2031
|
+
});
|
|
2032
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
2033
|
+
const claim = (id) => {
|
|
2034
|
+
const trimmed = id?.trim();
|
|
2035
|
+
if (!trimmed) return { kind: "claimed" };
|
|
2036
|
+
if (inFlight.has(trimmed) || dedupe.peek(trimmed)) return { kind: "duplicate" };
|
|
2037
|
+
inFlight.add(trimmed);
|
|
2038
|
+
return { kind: "claimed" };
|
|
2039
|
+
};
|
|
2040
|
+
const commit = (id) => {
|
|
2041
|
+
const trimmed = id?.trim();
|
|
2042
|
+
if (!trimmed) return;
|
|
2043
|
+
inFlight.delete(trimmed);
|
|
2044
|
+
dedupe.check(trimmed);
|
|
2045
|
+
};
|
|
2046
|
+
const release = (id) => {
|
|
2047
|
+
const trimmed = id?.trim();
|
|
2048
|
+
if (!trimmed) return;
|
|
2049
|
+
inFlight.delete(trimmed);
|
|
2050
|
+
};
|
|
2051
|
+
const mark = (id) => {
|
|
2052
|
+
if (claim(id).kind === "duplicate") return false;
|
|
2053
|
+
commit(id);
|
|
2054
|
+
return true;
|
|
2055
|
+
};
|
|
2056
|
+
const has = (id) => {
|
|
2057
|
+
const trimmed = id?.trim();
|
|
2058
|
+
if (!trimmed) return false;
|
|
2059
|
+
return dedupe.peek(trimmed);
|
|
2060
|
+
};
|
|
2061
|
+
return {
|
|
2062
|
+
claim,
|
|
2063
|
+
commit,
|
|
2064
|
+
release,
|
|
2065
|
+
mark,
|
|
2066
|
+
has,
|
|
2067
|
+
size: () => dedupe.size()
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
async function runWithProcessedMessageClaim(params) {
|
|
2071
|
+
const claim = params.tracker.claim(params.id);
|
|
2072
|
+
if (claim.kind === "duplicate") return claim;
|
|
2073
|
+
try {
|
|
2074
|
+
const value = await params.task();
|
|
2075
|
+
params.tracker.commit(params.id);
|
|
2076
|
+
return {
|
|
2077
|
+
kind: "processed",
|
|
2078
|
+
value
|
|
2079
|
+
};
|
|
2080
|
+
} catch (error) {
|
|
2081
|
+
params.tracker.release(params.id);
|
|
2082
|
+
throw error;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
//#endregion
|
|
2086
|
+
//#region extensions/tlon/src/monitor/settings-helpers.ts
|
|
2087
|
+
function buildTlonSettingsMigrations(account, currentSettings) {
|
|
2088
|
+
return [
|
|
2089
|
+
{
|
|
2090
|
+
key: "dmAllowlist",
|
|
2091
|
+
fileValue: account.dmAllowlist,
|
|
2092
|
+
settingsValue: currentSettings.dmAllowlist
|
|
2093
|
+
},
|
|
2094
|
+
{
|
|
2095
|
+
key: "groupInviteAllowlist",
|
|
2096
|
+
fileValue: account.groupInviteAllowlist,
|
|
2097
|
+
settingsValue: currentSettings.groupInviteAllowlist
|
|
2098
|
+
},
|
|
2099
|
+
{
|
|
2100
|
+
key: "groupChannels",
|
|
2101
|
+
fileValue: account.groupChannels,
|
|
2102
|
+
settingsValue: currentSettings.groupChannels
|
|
2103
|
+
},
|
|
2104
|
+
{
|
|
2105
|
+
key: "defaultAuthorizedShips",
|
|
2106
|
+
fileValue: account.defaultAuthorizedShips,
|
|
2107
|
+
settingsValue: currentSettings.defaultAuthorizedShips
|
|
2108
|
+
},
|
|
2109
|
+
{
|
|
2110
|
+
key: "autoDiscoverChannels",
|
|
2111
|
+
fileValue: account.autoDiscoverChannels,
|
|
2112
|
+
settingsValue: currentSettings.autoDiscoverChannels
|
|
2113
|
+
},
|
|
2114
|
+
{
|
|
2115
|
+
key: "autoAcceptDmInvites",
|
|
2116
|
+
fileValue: account.autoAcceptDmInvites,
|
|
2117
|
+
settingsValue: currentSettings.autoAcceptDmInvites
|
|
2118
|
+
},
|
|
2119
|
+
{
|
|
2120
|
+
key: "autoAcceptGroupInvites",
|
|
2121
|
+
fileValue: account.autoAcceptGroupInvites,
|
|
2122
|
+
settingsValue: currentSettings.autoAcceptGroupInvites
|
|
2123
|
+
},
|
|
2124
|
+
{
|
|
2125
|
+
key: "showModelSig",
|
|
2126
|
+
fileValue: account.showModelSignature,
|
|
2127
|
+
settingsValue: currentSettings.showModelSig
|
|
2128
|
+
}
|
|
2129
|
+
];
|
|
2130
|
+
}
|
|
2131
|
+
function shouldMigrateTlonSetting(fileValue, settingsValue) {
|
|
2132
|
+
return (Array.isArray(fileValue) ? fileValue.length > 0 : fileValue != null) && !(settingsValue != null);
|
|
2133
|
+
}
|
|
2134
|
+
function applyTlonSettingsOverrides(params) {
|
|
2135
|
+
let effectiveDmAllowlist = params.account.dmAllowlist;
|
|
2136
|
+
let effectiveShowModelSig = params.account.showModelSignature ?? false;
|
|
2137
|
+
let effectiveAutoAcceptDmInvites = params.account.autoAcceptDmInvites ?? false;
|
|
2138
|
+
let effectiveAutoAcceptGroupInvites = params.account.autoAcceptGroupInvites ?? false;
|
|
2139
|
+
let effectiveGroupInviteAllowlist = params.account.groupInviteAllowlist;
|
|
2140
|
+
let effectiveAutoDiscoverChannels = params.account.autoDiscoverChannels ?? false;
|
|
2141
|
+
let effectiveOwnerShip = params.account.ownerShip ? normalizeShip(params.account.ownerShip) : null;
|
|
2142
|
+
let pendingApprovals = [];
|
|
2143
|
+
if (params.currentSettings.defaultAuthorizedShips?.length) params.log?.(`[tlon] Using defaultAuthorizedShips from settings store: ${params.currentSettings.defaultAuthorizedShips.join(", ")}`);
|
|
2144
|
+
if (params.currentSettings.autoDiscoverChannels !== void 0) {
|
|
2145
|
+
effectiveAutoDiscoverChannels = params.currentSettings.autoDiscoverChannels;
|
|
2146
|
+
params.log?.(`[tlon] Using autoDiscoverChannels from settings store: ${effectiveAutoDiscoverChannels}`);
|
|
2147
|
+
}
|
|
2148
|
+
if (params.currentSettings.dmAllowlist !== void 0) {
|
|
2149
|
+
effectiveDmAllowlist = params.currentSettings.dmAllowlist;
|
|
2150
|
+
params.log?.(`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.join(", ")}`);
|
|
2151
|
+
}
|
|
2152
|
+
if (params.currentSettings.showModelSig !== void 0) effectiveShowModelSig = params.currentSettings.showModelSig;
|
|
2153
|
+
if (params.currentSettings.autoAcceptDmInvites !== void 0) {
|
|
2154
|
+
effectiveAutoAcceptDmInvites = params.currentSettings.autoAcceptDmInvites;
|
|
2155
|
+
params.log?.(`[tlon] Using autoAcceptDmInvites from settings store: ${effectiveAutoAcceptDmInvites}`);
|
|
2156
|
+
}
|
|
2157
|
+
if (params.currentSettings.autoAcceptGroupInvites !== void 0) {
|
|
2158
|
+
effectiveAutoAcceptGroupInvites = params.currentSettings.autoAcceptGroupInvites;
|
|
2159
|
+
params.log?.(`[tlon] Using autoAcceptGroupInvites from settings store: ${effectiveAutoAcceptGroupInvites}`);
|
|
2160
|
+
}
|
|
2161
|
+
if (params.currentSettings.groupInviteAllowlist !== void 0) {
|
|
2162
|
+
effectiveGroupInviteAllowlist = params.currentSettings.groupInviteAllowlist;
|
|
2163
|
+
params.log?.(`[tlon] Using groupInviteAllowlist from settings store: ${effectiveGroupInviteAllowlist.join(", ")}`);
|
|
2164
|
+
}
|
|
2165
|
+
if (params.currentSettings.ownerShip) {
|
|
2166
|
+
effectiveOwnerShip = normalizeShip(params.currentSettings.ownerShip);
|
|
2167
|
+
params.log?.(`[tlon] Using ownerShip from settings store: ${effectiveOwnerShip}`);
|
|
2168
|
+
}
|
|
2169
|
+
if (params.currentSettings.pendingApprovals?.length) {
|
|
2170
|
+
pendingApprovals = params.currentSettings.pendingApprovals;
|
|
2171
|
+
params.log?.(`[tlon] Loaded ${pendingApprovals.length} pending approval(s) from settings`);
|
|
2172
|
+
}
|
|
2173
|
+
return {
|
|
2174
|
+
effectiveDmAllowlist,
|
|
2175
|
+
effectiveShowModelSig,
|
|
2176
|
+
effectiveAutoAcceptDmInvites,
|
|
2177
|
+
effectiveAutoAcceptGroupInvites,
|
|
2178
|
+
effectiveGroupInviteAllowlist,
|
|
2179
|
+
effectiveAutoDiscoverChannels,
|
|
2180
|
+
effectiveOwnerShip,
|
|
2181
|
+
pendingApprovals,
|
|
2182
|
+
currentSettings: params.currentSettings
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
function mergeUniqueStrings(base, next) {
|
|
2186
|
+
if (!next?.length) return [...base];
|
|
2187
|
+
const merged = [...base];
|
|
2188
|
+
for (const value of next) if (!merged.includes(value)) merged.push(value);
|
|
2189
|
+
return merged;
|
|
2190
|
+
}
|
|
2191
|
+
//#endregion
|
|
2192
|
+
//#region extensions/tlon/src/monitor/index.ts
|
|
2193
|
+
function readNumber(record, key) {
|
|
2194
|
+
const value = record?.[key];
|
|
2195
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
2196
|
+
}
|
|
2197
|
+
async function monitorTlonProvider(opts = {}) {
|
|
2198
|
+
const core = getTlonRuntime();
|
|
2199
|
+
const cfg = core.config.current();
|
|
2200
|
+
if (cfg.channels?.tlon?.enabled === false) return;
|
|
2201
|
+
const logger = core.logging.getChildLogger({ module: "tlon-auto-reply" });
|
|
2202
|
+
const runtime = opts.runtime ?? createLoggerBackedRuntime({ logger });
|
|
2203
|
+
const account = resolveTlonAccount(cfg, opts.accountId ?? void 0);
|
|
2204
|
+
if (!account.enabled) return;
|
|
2205
|
+
if (!account.configured || !account.ship || !account.url || !account.code) throw new Error("Tlon account not configured (ship/url/code required)");
|
|
2206
|
+
const botShipName = normalizeShip(account.ship);
|
|
2207
|
+
runtime.log?.(`[tlon] Starting monitor for ${botShipName}`);
|
|
2208
|
+
const ssrfPolicy = ssrfPolicyFromDangerouslyAllowPrivateNetwork(account.dangerouslyAllowPrivateNetwork);
|
|
2209
|
+
const accountUrl = account.url;
|
|
2210
|
+
const accountCode = account.code;
|
|
2211
|
+
async function authenticateWithRetry(maxAttempts = 10) {
|
|
2212
|
+
for (let attempt = 1;; attempt++) {
|
|
2213
|
+
if (opts.abortSignal?.aborted) throw new Error("Aborted while waiting to authenticate");
|
|
2214
|
+
try {
|
|
2215
|
+
runtime.log?.(`[tlon] Attempting authentication to ${accountUrl}...`);
|
|
2216
|
+
return await authenticate(accountUrl, accountCode, { ssrfPolicy });
|
|
2217
|
+
} catch (error) {
|
|
2218
|
+
runtime.error?.(`[tlon] Failed to authenticate (attempt ${attempt}): ${formatErrorMessage$1(error)}`);
|
|
2219
|
+
if (attempt >= maxAttempts) throw error;
|
|
2220
|
+
const delay = Math.min(3e4, 1e3 * 2 ** (attempt - 1));
|
|
2221
|
+
runtime.log?.(`[tlon] Retrying authentication in ${delay}ms...`);
|
|
2222
|
+
await new Promise((resolve, reject) => {
|
|
2223
|
+
const timer = setTimeout(resolve, delay);
|
|
2224
|
+
if (opts.abortSignal) {
|
|
2225
|
+
const onAbort = () => {
|
|
2226
|
+
clearTimeout(timer);
|
|
2227
|
+
reject(/* @__PURE__ */ new Error("Aborted"));
|
|
2228
|
+
};
|
|
2229
|
+
opts.abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
2230
|
+
}
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
let api = null;
|
|
2236
|
+
const cookie = await authenticateWithRetry();
|
|
2237
|
+
api = new UrbitSSEClient(account.url, cookie, {
|
|
2238
|
+
ship: botShipName,
|
|
2239
|
+
ssrfPolicy,
|
|
2240
|
+
logger: {
|
|
2241
|
+
log: (message) => runtime.log?.(message),
|
|
2242
|
+
error: (message) => runtime.error?.(message)
|
|
2243
|
+
},
|
|
2244
|
+
onReconnect: async (client) => {
|
|
2245
|
+
runtime.log?.("[tlon] Re-authenticating on SSE reconnect...");
|
|
2246
|
+
const newCookie = await authenticateWithRetry(5);
|
|
2247
|
+
client.updateCookie(newCookie);
|
|
2248
|
+
runtime.log?.("[tlon] Re-authentication successful");
|
|
2249
|
+
}
|
|
2250
|
+
});
|
|
2251
|
+
const processedTracker = createProcessedMessageTracker(2e3);
|
|
2252
|
+
let groupChannels = [];
|
|
2253
|
+
let botNickname = null;
|
|
2254
|
+
const settingsManager = createSettingsManager(api, {
|
|
2255
|
+
log: (msg) => runtime.log?.(msg),
|
|
2256
|
+
error: (msg) => runtime.error?.(msg)
|
|
2257
|
+
});
|
|
2258
|
+
let effectiveDmAllowlist = account.dmAllowlist;
|
|
2259
|
+
let effectiveShowModelSig = account.showModelSignature ?? false;
|
|
2260
|
+
let effectiveAutoAcceptDmInvites = account.autoAcceptDmInvites ?? false;
|
|
2261
|
+
let effectiveAutoAcceptGroupInvites = account.autoAcceptGroupInvites ?? false;
|
|
2262
|
+
let effectiveGroupInviteAllowlist = account.groupInviteAllowlist;
|
|
2263
|
+
let effectiveAutoDiscoverChannels = account.autoDiscoverChannels ?? false;
|
|
2264
|
+
let effectiveOwnerShip = account.ownerShip ? normalizeShip(account.ownerShip) : null;
|
|
2265
|
+
let pendingApprovals = [];
|
|
2266
|
+
let currentSettings = {};
|
|
2267
|
+
const participatedThreads = /* @__PURE__ */ new Set();
|
|
2268
|
+
const dmSendersBySession = /* @__PURE__ */ new Map();
|
|
2269
|
+
let sharedSessionWarningSent = false;
|
|
2270
|
+
try {
|
|
2271
|
+
const selfProfile = await api.scry("/contacts/v1/self.json");
|
|
2272
|
+
if (selfProfile && typeof selfProfile === "object") {
|
|
2273
|
+
botNickname = selfProfile.nickname?.value || null;
|
|
2274
|
+
if (botNickname) runtime.log?.(`[tlon] Bot nickname: ${botNickname}`);
|
|
2275
|
+
}
|
|
2276
|
+
} catch (error) {
|
|
2277
|
+
runtime.log?.(`[tlon] Could not fetch nickname: ${formatErrorMessage$1(error)}`);
|
|
2278
|
+
}
|
|
2279
|
+
let initForeigns = null;
|
|
2280
|
+
async function migrateConfigToSettings() {
|
|
2281
|
+
const migrations = buildTlonSettingsMigrations(account, currentSettings);
|
|
2282
|
+
for (const { key, fileValue, settingsValue } of migrations) if (shouldMigrateTlonSetting(fileValue, settingsValue)) try {
|
|
2283
|
+
await api.poke({
|
|
2284
|
+
app: "settings",
|
|
2285
|
+
mark: "settings-event",
|
|
2286
|
+
json: { "put-entry": {
|
|
2287
|
+
"bucket-key": "tlon",
|
|
2288
|
+
"entry-key": key,
|
|
2289
|
+
value: fileValue,
|
|
2290
|
+
desk: "moltbot"
|
|
2291
|
+
} }
|
|
2292
|
+
});
|
|
2293
|
+
runtime.log?.(`[tlon] Migrated ${key} from config to settings store`);
|
|
2294
|
+
} catch (err) {
|
|
2295
|
+
runtime.log?.(`[tlon] Failed to migrate ${key}: ${String(err)}`);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
try {
|
|
2299
|
+
currentSettings = await settingsManager.load();
|
|
2300
|
+
await migrateConfigToSettings();
|
|
2301
|
+
({effectiveDmAllowlist, effectiveShowModelSig, effectiveAutoAcceptDmInvites, effectiveAutoAcceptGroupInvites, effectiveGroupInviteAllowlist, effectiveAutoDiscoverChannels, effectiveOwnerShip, pendingApprovals, currentSettings} = applyTlonSettingsOverrides({
|
|
2302
|
+
account,
|
|
2303
|
+
currentSettings,
|
|
2304
|
+
log: (message) => runtime.log?.(message)
|
|
2305
|
+
}));
|
|
2306
|
+
} catch (err) {
|
|
2307
|
+
runtime.log?.(`[tlon] Settings store not available, using file config: ${String(err)}`);
|
|
2308
|
+
}
|
|
2309
|
+
if (effectiveAutoDiscoverChannels) try {
|
|
2310
|
+
const initData = await fetchInitData(api, runtime);
|
|
2311
|
+
if (initData.channels.length > 0) groupChannels = initData.channels;
|
|
2312
|
+
initForeigns = initData.foreigns;
|
|
2313
|
+
} catch (error) {
|
|
2314
|
+
runtime.error?.(`[tlon] Auto-discovery failed: ${formatErrorMessage$1(error)}`);
|
|
2315
|
+
}
|
|
2316
|
+
if (account.groupChannels.length > 0) {
|
|
2317
|
+
groupChannels = mergeUniqueStrings(groupChannels, account.groupChannels);
|
|
2318
|
+
runtime.log?.(`[tlon] Added ${account.groupChannels.length} manual groupChannels to monitoring`);
|
|
2319
|
+
}
|
|
2320
|
+
groupChannels = mergeUniqueStrings(groupChannels, currentSettings.groupChannels);
|
|
2321
|
+
if (groupChannels.length > 0) runtime.log?.(`[tlon] Monitoring ${groupChannels.length} group channel(s): ${groupChannels.join(", ")}`);
|
|
2322
|
+
else runtime.log?.("[tlon] No group channels to monitor (DMs only)");
|
|
2323
|
+
function isOwner(ship) {
|
|
2324
|
+
if (!effectiveOwnerShip) return false;
|
|
2325
|
+
return normalizeShip(ship) === effectiveOwnerShip;
|
|
2326
|
+
}
|
|
2327
|
+
/**
|
|
2328
|
+
* Extract the DM partner ship from the 'whom' field.
|
|
2329
|
+
* This is the canonical source for DM routing (more reliable than essay.author).
|
|
2330
|
+
* Returns empty string if whom doesn't contain a valid patp-like value.
|
|
2331
|
+
*/
|
|
2332
|
+
function extractDmPartnerShip(whom) {
|
|
2333
|
+
const normalized = normalizeShip(typeof whom === "string" ? whom : whom && typeof whom === "object" && "ship" in whom && typeof whom.ship === "string" ? whom.ship : "");
|
|
2334
|
+
return /^~?[a-z-]+$/i.test(normalized) ? normalized : "";
|
|
2335
|
+
}
|
|
2336
|
+
const processMessage = async (params) => {
|
|
2337
|
+
const { messageId, senderShip, isGroup, channelNest, hostShip: _hostShip, channelName: _channelName, timestamp, parentId, isThreadReply, messageContent } = params;
|
|
2338
|
+
const groupChannel = channelNest;
|
|
2339
|
+
let messageText = params.messageText;
|
|
2340
|
+
let attachments = [];
|
|
2341
|
+
if (messageContent) try {
|
|
2342
|
+
attachments = await downloadMessageImages(messageContent);
|
|
2343
|
+
if (attachments.length > 0) runtime.log?.(`[tlon] Downloaded ${attachments.length} image(s) from message`);
|
|
2344
|
+
} catch (error) {
|
|
2345
|
+
runtime.log?.(`[tlon] Failed to download images: ${formatErrorMessage$1(error)}`);
|
|
2346
|
+
}
|
|
2347
|
+
if (isThreadReply && parentId && groupChannel) try {
|
|
2348
|
+
const threadHistory = await fetchThreadHistory(api, groupChannel, parentId, 20, runtime);
|
|
2349
|
+
if (threadHistory.length > 0) {
|
|
2350
|
+
const threadContext = threadHistory.slice(-10).map((msg) => `${msg.author}: ${msg.content}`).join("\n");
|
|
2351
|
+
messageText = `${`[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.]`}\n\n[Previous messages]\n${threadContext}\n\n[Current message]\n${messageText}`;
|
|
2352
|
+
runtime?.log?.(`[tlon] Added thread context (${threadHistory.length} replies) to message`);
|
|
2353
|
+
}
|
|
2354
|
+
} catch (error) {
|
|
2355
|
+
runtime?.log?.(`[tlon] Could not fetch thread context: ${formatErrorMessage$1(error)}`);
|
|
2356
|
+
}
|
|
2357
|
+
if (isGroup && groupChannel && isSummarizationRequest(messageText)) try {
|
|
2358
|
+
const history = await getChannelHistory(api, groupChannel, 50, runtime);
|
|
2359
|
+
if (history.length === 0) {
|
|
2360
|
+
const noHistoryMsg = "I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue.";
|
|
2361
|
+
if (isGroup) {
|
|
2362
|
+
const parsed = parseChannelNest(groupChannel);
|
|
2363
|
+
if (parsed) await sendGroupMessage({
|
|
2364
|
+
api,
|
|
2365
|
+
fromShip: botShipName,
|
|
2366
|
+
hostShip: parsed.hostShip,
|
|
2367
|
+
channelName: parsed.channelName,
|
|
2368
|
+
text: noHistoryMsg
|
|
2369
|
+
});
|
|
2370
|
+
} else await sendDm({
|
|
2371
|
+
api,
|
|
2372
|
+
fromShip: botShipName,
|
|
2373
|
+
toShip: senderShip,
|
|
2374
|
+
text: noHistoryMsg
|
|
2375
|
+
});
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
const historyText = history.map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`).join("\n");
|
|
2379
|
+
messageText = `Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\nProvide a concise summary highlighting:
|
|
2380
|
+
1. Main topics discussed
|
|
2381
|
+
2. Key decisions or conclusions
|
|
2382
|
+
3. Action items if any
|
|
2383
|
+
4. Notable participants`;
|
|
2384
|
+
} catch (error) {
|
|
2385
|
+
const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${formatErrorMessage$1(error)}`;
|
|
2386
|
+
if (isGroup && groupChannel) {
|
|
2387
|
+
const parsed = parseChannelNest(groupChannel);
|
|
2388
|
+
if (parsed) await sendGroupMessage({
|
|
2389
|
+
api,
|
|
2390
|
+
fromShip: botShipName,
|
|
2391
|
+
hostShip: parsed.hostShip,
|
|
2392
|
+
channelName: parsed.channelName,
|
|
2393
|
+
text: errorMsg
|
|
2394
|
+
});
|
|
2395
|
+
} else await sendDm({
|
|
2396
|
+
api,
|
|
2397
|
+
fromShip: botShipName,
|
|
2398
|
+
toShip: senderShip,
|
|
2399
|
+
text: errorMsg
|
|
2400
|
+
});
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
2404
|
+
cfg,
|
|
2405
|
+
channel: "tlon",
|
|
2406
|
+
accountId: opts.accountId ?? void 0,
|
|
2407
|
+
peer: {
|
|
2408
|
+
kind: isGroup ? "group" : "direct",
|
|
2409
|
+
id: isGroup ? groupChannel ?? senderShip : senderShip
|
|
2410
|
+
}
|
|
2411
|
+
});
|
|
2412
|
+
if (!isGroup) {
|
|
2413
|
+
const sessionKey = route.sessionKey;
|
|
2414
|
+
if (!dmSendersBySession.has(sessionKey)) dmSendersBySession.set(sessionKey, /* @__PURE__ */ new Set());
|
|
2415
|
+
const senders = dmSendersBySession.get(sessionKey);
|
|
2416
|
+
if (senders.size > 0 && !senders.has(senderShip)) {
|
|
2417
|
+
runtime.log?.("[tlon] ⚠️ SECURITY: Multiple users sharing DM session. Configure \"session.dmScope: per-channel-peer\" in Klaw config.");
|
|
2418
|
+
if (!sharedSessionWarningSent && effectiveOwnerShip) {
|
|
2419
|
+
sharedSessionWarningSent = true;
|
|
2420
|
+
sendDm({
|
|
2421
|
+
api,
|
|
2422
|
+
fromShip: botShipName,
|
|
2423
|
+
toShip: effectiveOwnerShip,
|
|
2424
|
+
text: "⚠️ Security Warning: Multiple users are sharing a DM session with this bot. This can leak conversation context between users.\n\nFix: Add to your Klaw config:\nsession:\n dmScope: \"per-channel-peer\"\n\nDocs: https://klaw.kodelyth.com/concepts/session#secure-dm-mode"
|
|
2425
|
+
}).catch((err) => runtime.error?.(`[tlon] Failed to send security warning to owner: ${err}`));
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
senders.add(senderShip);
|
|
2429
|
+
}
|
|
2430
|
+
const senderRole = isOwner(senderShip) ? "owner" : "user";
|
|
2431
|
+
const fromLabel = isGroup ? `${senderShip} [${senderRole}] in ${channelNest}` : `${senderShip} [${senderRole}]`;
|
|
2432
|
+
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(messageText, cfg);
|
|
2433
|
+
let commandAuthorized = false;
|
|
2434
|
+
if (shouldComputeAuth) {
|
|
2435
|
+
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
2436
|
+
commandAuthorized = (await resolveTlonCommandAuthorizationWithIngress({
|
|
2437
|
+
senderShip,
|
|
2438
|
+
ownerShip: effectiveOwnerShip,
|
|
2439
|
+
useAccessGroups
|
|
2440
|
+
})).commandAccess.authorized;
|
|
2441
|
+
if (!commandAuthorized) console.log(`[tlon] Command attempt denied: ${senderShip} is not owner (owner=${effectiveOwnerShip ?? "not configured"})`);
|
|
2442
|
+
}
|
|
2443
|
+
let bodyWithAttachments = messageText;
|
|
2444
|
+
if (attachments.length > 0) bodyWithAttachments = attachments.map((a) => `[media attached: ${a.path} (${a.contentType}) | ${a.path}]`).join("\n") + "\n" + messageText;
|
|
2445
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
2446
|
+
channel: "Tlon",
|
|
2447
|
+
from: fromLabel,
|
|
2448
|
+
timestamp,
|
|
2449
|
+
body: bodyWithAttachments
|
|
2450
|
+
});
|
|
2451
|
+
const commandBody = isGroup ? stripBotMention(messageText, botShipName) : messageText;
|
|
2452
|
+
const tlonConversationId = isGroup ? groupChannel ?? channelNest ?? senderShip : senderShip;
|
|
2453
|
+
const ctxPayload = core.channel.turn.buildContext({
|
|
2454
|
+
channel: "tlon",
|
|
2455
|
+
accountId: route.accountId,
|
|
2456
|
+
messageId,
|
|
2457
|
+
timestamp,
|
|
2458
|
+
from: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
|
|
2459
|
+
sender: {
|
|
2460
|
+
id: senderShip,
|
|
2461
|
+
name: senderShip,
|
|
2462
|
+
roles: [senderRole]
|
|
2463
|
+
},
|
|
2464
|
+
conversation: {
|
|
2465
|
+
kind: isGroup ? "group" : "direct",
|
|
2466
|
+
id: tlonConversationId,
|
|
2467
|
+
label: fromLabel,
|
|
2468
|
+
routePeer: {
|
|
2469
|
+
kind: isGroup ? "group" : "direct",
|
|
2470
|
+
id: tlonConversationId
|
|
2471
|
+
}
|
|
2472
|
+
},
|
|
2473
|
+
route: {
|
|
2474
|
+
agentId: route.agentId,
|
|
2475
|
+
accountId: route.accountId,
|
|
2476
|
+
routeSessionKey: route.sessionKey
|
|
2477
|
+
},
|
|
2478
|
+
reply: {
|
|
2479
|
+
to: `tlon:${botShipName}`,
|
|
2480
|
+
originatingTo: `tlon:${isGroup ? groupChannel : botShipName}`,
|
|
2481
|
+
replyToId: parentId ?? void 0
|
|
2482
|
+
},
|
|
2483
|
+
message: {
|
|
2484
|
+
body,
|
|
2485
|
+
bodyForAgent: commandBody,
|
|
2486
|
+
rawBody: messageText,
|
|
2487
|
+
commandBody,
|
|
2488
|
+
envelopeFrom: fromLabel
|
|
2489
|
+
},
|
|
2490
|
+
extra: {
|
|
2491
|
+
GroupSubject: void 0,
|
|
2492
|
+
SenderRole: senderRole,
|
|
2493
|
+
CommandAuthorized: commandAuthorized,
|
|
2494
|
+
CommandSource: "text",
|
|
2495
|
+
...attachments.length > 0 && { Attachments: attachments },
|
|
2496
|
+
...parentId && { ThreadId: parentId }
|
|
2497
|
+
}
|
|
2498
|
+
});
|
|
2499
|
+
const dispatchStartTime = Date.now();
|
|
2500
|
+
const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix;
|
|
2501
|
+
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
|
2502
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId });
|
|
2503
|
+
const deliveryTarget = isGroup ? groupChannel : senderShip;
|
|
2504
|
+
const prepareReplyPayload = (payload) => {
|
|
2505
|
+
const replyText = payload.text;
|
|
2506
|
+
if (!replyText) return payload;
|
|
2507
|
+
if (!effectiveShowModelSig) return payload;
|
|
2508
|
+
const extPayload = payload;
|
|
2509
|
+
const defaultModel = cfg.agents?.defaults?.model;
|
|
2510
|
+
const modelInfo = extPayload.metadata?.model || extPayload.model || (typeof defaultModel === "string" ? defaultModel : defaultModel?.primary);
|
|
2511
|
+
return {
|
|
2512
|
+
...payload,
|
|
2513
|
+
text: `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`
|
|
2514
|
+
};
|
|
2515
|
+
};
|
|
2516
|
+
const rememberThreadParticipation = (result) => {
|
|
2517
|
+
if (!isGroup || !groupChannel || !parentId || result?.visibleReplySent === false) return;
|
|
2518
|
+
participatedThreads.add(parentId);
|
|
2519
|
+
runtime.log?.(`[tlon] Now tracking thread for future replies: ${parentId}`);
|
|
2520
|
+
};
|
|
2521
|
+
await core.channel.turn.runAssembled({
|
|
2522
|
+
channel: "tlon",
|
|
2523
|
+
accountId: route.accountId,
|
|
2524
|
+
cfg,
|
|
2525
|
+
agentId: route.agentId,
|
|
2526
|
+
routeSessionKey: route.sessionKey,
|
|
2527
|
+
storePath,
|
|
2528
|
+
ctxPayload,
|
|
2529
|
+
recordInboundSession: core.channel.session.recordInboundSession,
|
|
2530
|
+
dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
2531
|
+
delivery: {
|
|
2532
|
+
preparePayload: prepareReplyPayload,
|
|
2533
|
+
durable: deliveryTarget ? () => ({
|
|
2534
|
+
to: deliveryTarget,
|
|
2535
|
+
replyToId: parentId ?? void 0,
|
|
2536
|
+
threadId: parentId ?? void 0
|
|
2537
|
+
}) : false,
|
|
2538
|
+
deliver: async (payload) => {
|
|
2539
|
+
const replyText = payload.text;
|
|
2540
|
+
if (!replyText) return { visibleReplySent: false };
|
|
2541
|
+
if (isGroup && groupChannel) {
|
|
2542
|
+
const parsed = parseChannelNest(groupChannel);
|
|
2543
|
+
if (!parsed) return { visibleReplySent: false };
|
|
2544
|
+
await sendGroupMessage({
|
|
2545
|
+
api,
|
|
2546
|
+
fromShip: botShipName,
|
|
2547
|
+
hostShip: parsed.hostShip,
|
|
2548
|
+
channelName: parsed.channelName,
|
|
2549
|
+
text: replyText,
|
|
2550
|
+
replyToId: parentId ?? void 0
|
|
2551
|
+
});
|
|
2552
|
+
return {
|
|
2553
|
+
visibleReplySent: true,
|
|
2554
|
+
replyToId: parentId ?? void 0
|
|
2555
|
+
};
|
|
2556
|
+
}
|
|
2557
|
+
await sendDm({
|
|
2558
|
+
api,
|
|
2559
|
+
fromShip: botShipName,
|
|
2560
|
+
toShip: senderShip,
|
|
2561
|
+
text: replyText
|
|
2562
|
+
});
|
|
2563
|
+
return { visibleReplySent: true };
|
|
2564
|
+
},
|
|
2565
|
+
onDelivered: (_payload, _info, result) => {
|
|
2566
|
+
rememberThreadParticipation(result);
|
|
2567
|
+
},
|
|
2568
|
+
onError: (err, info) => {
|
|
2569
|
+
const dispatchDuration = Date.now() - dispatchStartTime;
|
|
2570
|
+
runtime.error?.(`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`);
|
|
2571
|
+
}
|
|
2572
|
+
},
|
|
2573
|
+
dispatcherOptions: {
|
|
2574
|
+
responsePrefix,
|
|
2575
|
+
humanDelay
|
|
2576
|
+
},
|
|
2577
|
+
record: { onRecordError: (err) => {
|
|
2578
|
+
runtime.error?.(`[tlon] failed updating session meta: ${String(err)}`);
|
|
2579
|
+
} }
|
|
2580
|
+
});
|
|
2581
|
+
};
|
|
2582
|
+
const watchedChannels = new Set(groupChannels);
|
|
2583
|
+
const refreshWatchedChannels = async () => {
|
|
2584
|
+
const discoveredChannels = await fetchAllChannels(api, runtime);
|
|
2585
|
+
let newCount = 0;
|
|
2586
|
+
for (const channelNest of discoveredChannels) if (!watchedChannels.has(channelNest)) {
|
|
2587
|
+
watchedChannels.add(channelNest);
|
|
2588
|
+
newCount++;
|
|
2589
|
+
}
|
|
2590
|
+
return newCount;
|
|
2591
|
+
};
|
|
2592
|
+
const { resolveAllCites } = createTlonCitationResolver({
|
|
2593
|
+
api: { scry: (path) => api.scry(path) },
|
|
2594
|
+
runtime
|
|
2595
|
+
});
|
|
2596
|
+
const { queueApprovalRequest, handleApprovalResponse, handleAdminCommand } = createTlonApprovalRuntime({
|
|
2597
|
+
api: {
|
|
2598
|
+
poke: (payload) => api.poke(payload),
|
|
2599
|
+
scry: (path) => api.scry(path)
|
|
2600
|
+
},
|
|
2601
|
+
runtime,
|
|
2602
|
+
botShipName,
|
|
2603
|
+
getPendingApprovals: () => pendingApprovals,
|
|
2604
|
+
setPendingApprovals: (approvals) => {
|
|
2605
|
+
pendingApprovals = approvals;
|
|
2606
|
+
},
|
|
2607
|
+
getCurrentSettings: () => currentSettings,
|
|
2608
|
+
setCurrentSettings: (settings) => {
|
|
2609
|
+
currentSettings = settings;
|
|
2610
|
+
},
|
|
2611
|
+
getEffectiveDmAllowlist: () => effectiveDmAllowlist,
|
|
2612
|
+
setEffectiveDmAllowlist: (ships) => {
|
|
2613
|
+
effectiveDmAllowlist = ships;
|
|
2614
|
+
},
|
|
2615
|
+
getEffectiveOwnerShip: () => effectiveOwnerShip,
|
|
2616
|
+
processApprovedMessage: async (approval) => {
|
|
2617
|
+
if (!approval.originalMessage) return;
|
|
2618
|
+
if (approval.type === "dm") {
|
|
2619
|
+
await processMessage({
|
|
2620
|
+
messageId: approval.originalMessage.messageId,
|
|
2621
|
+
senderShip: approval.requestingShip,
|
|
2622
|
+
messageText: approval.originalMessage.messageText,
|
|
2623
|
+
messageContent: approval.originalMessage.messageContent,
|
|
2624
|
+
isGroup: false,
|
|
2625
|
+
timestamp: approval.originalMessage.timestamp
|
|
2626
|
+
});
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
if (approval.type === "channel" && approval.channelNest) {
|
|
2630
|
+
const parsedChannel = parseChannelNest(approval.channelNest);
|
|
2631
|
+
await processMessage({
|
|
2632
|
+
messageId: approval.originalMessage.messageId,
|
|
2633
|
+
senderShip: approval.requestingShip,
|
|
2634
|
+
messageText: approval.originalMessage.messageText,
|
|
2635
|
+
messageContent: approval.originalMessage.messageContent,
|
|
2636
|
+
isGroup: true,
|
|
2637
|
+
channelNest: approval.channelNest,
|
|
2638
|
+
hostShip: parsedChannel?.hostShip,
|
|
2639
|
+
channelName: parsedChannel?.channelName,
|
|
2640
|
+
timestamp: approval.originalMessage.timestamp,
|
|
2641
|
+
parentId: approval.originalMessage.parentId,
|
|
2642
|
+
isThreadReply: approval.originalMessage.isThreadReply
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2645
|
+
},
|
|
2646
|
+
refreshWatchedChannels
|
|
2647
|
+
});
|
|
2648
|
+
const handleChannelsFirehose = async (event) => {
|
|
2649
|
+
try {
|
|
2650
|
+
const eventRecord = asRecord(event);
|
|
2651
|
+
const nest = readString(eventRecord, "nest");
|
|
2652
|
+
if (!nest) return;
|
|
2653
|
+
if (!watchedChannels.has(nest)) return;
|
|
2654
|
+
const response = asRecord(eventRecord?.response);
|
|
2655
|
+
if (!response) return;
|
|
2656
|
+
const post = asRecord(response.post);
|
|
2657
|
+
const rPost = asRecord(post?.["r-post"]);
|
|
2658
|
+
const set = asRecord(rPost?.set);
|
|
2659
|
+
const reply = asRecord(rPost?.reply);
|
|
2660
|
+
const replySet = asRecord(asRecord(reply?.["r-reply"])?.set);
|
|
2661
|
+
const essay = asRecord(set?.essay);
|
|
2662
|
+
const memo = asRecord(replySet?.memo);
|
|
2663
|
+
if (!essay && !memo) return;
|
|
2664
|
+
const content = memo ?? essay;
|
|
2665
|
+
if (!content) return;
|
|
2666
|
+
const isThreadReply = Boolean(memo);
|
|
2667
|
+
const messageId = isThreadReply ? readString(reply, "id") : readString(post, "id");
|
|
2668
|
+
if (!messageId) return;
|
|
2669
|
+
if ((await runWithProcessedMessageClaim({
|
|
2670
|
+
tracker: processedTracker,
|
|
2671
|
+
id: messageId,
|
|
2672
|
+
task: async () => {
|
|
2673
|
+
const senderShip = normalizeShip(readString(content, "author") ?? "");
|
|
2674
|
+
if (!senderShip || senderShip === botShipName) return;
|
|
2675
|
+
const rawText = extractMessageText(content.content);
|
|
2676
|
+
if (!rawText.trim()) return;
|
|
2677
|
+
const contentBody = content.content;
|
|
2678
|
+
const sentAt = readNumber(content, "sent") ?? Date.now();
|
|
2679
|
+
cacheMessage(nest, {
|
|
2680
|
+
author: senderShip,
|
|
2681
|
+
content: rawText,
|
|
2682
|
+
timestamp: sentAt,
|
|
2683
|
+
id: messageId
|
|
2684
|
+
});
|
|
2685
|
+
const seal = isThreadReply ? asRecord(replySet?.seal) : asRecord(set?.seal);
|
|
2686
|
+
const parentId = readString(seal, "parent-id") ?? readString(seal, "parent") ?? null;
|
|
2687
|
+
const mentioned = isBotMentioned(rawText, botShipName, botNickname ?? void 0);
|
|
2688
|
+
const inParticipatedThread = isThreadReply && parentId && participatedThreads.has(parentId);
|
|
2689
|
+
if (!mentioned && !inParticipatedThread) return;
|
|
2690
|
+
if (inParticipatedThread && !mentioned) runtime.log?.(`[tlon] Responding to thread we participated in (no mention): ${parentId}`);
|
|
2691
|
+
if (isOwner(senderShip)) runtime.log?.(`[tlon] Owner ${senderShip} is always allowed in channels`);
|
|
2692
|
+
else {
|
|
2693
|
+
const { mode, allowedShips } = resolveChannelAuthorization(cfg, nest, currentSettings);
|
|
2694
|
+
if (mode === "restricted") {
|
|
2695
|
+
if (!allowedShips.map(normalizeShip).includes(senderShip)) {
|
|
2696
|
+
if (effectiveOwnerShip) await queueApprovalRequest(createPendingApproval({
|
|
2697
|
+
type: "channel",
|
|
2698
|
+
requestingShip: senderShip,
|
|
2699
|
+
channelNest: nest,
|
|
2700
|
+
messagePreview: rawText.slice(0, 100),
|
|
2701
|
+
originalMessage: {
|
|
2702
|
+
messageId: messageId ?? "",
|
|
2703
|
+
messageText: rawText,
|
|
2704
|
+
messageContent: contentBody,
|
|
2705
|
+
timestamp: sentAt,
|
|
2706
|
+
parentId: parentId ?? void 0,
|
|
2707
|
+
isThreadReply
|
|
2708
|
+
}
|
|
2709
|
+
}));
|
|
2710
|
+
else runtime.log?.(`[tlon] Access denied: ${senderShip} in ${nest} (allowed: ${allowedShips.join(", ")})`);
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
const messageText = await resolveAuthorizedMessageText({
|
|
2716
|
+
rawText,
|
|
2717
|
+
content: contentBody,
|
|
2718
|
+
authorizedForCites: true,
|
|
2719
|
+
resolveAllCites
|
|
2720
|
+
});
|
|
2721
|
+
const parsed = parseChannelNest(nest);
|
|
2722
|
+
await processMessage({
|
|
2723
|
+
messageId: messageId ?? "",
|
|
2724
|
+
senderShip,
|
|
2725
|
+
messageText,
|
|
2726
|
+
messageContent: contentBody,
|
|
2727
|
+
isGroup: true,
|
|
2728
|
+
channelNest: nest,
|
|
2729
|
+
hostShip: parsed?.hostShip,
|
|
2730
|
+
channelName: parsed?.channelName,
|
|
2731
|
+
timestamp: sentAt,
|
|
2732
|
+
parentId,
|
|
2733
|
+
isThreadReply
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
})).kind === "duplicate") return;
|
|
2737
|
+
} catch (error) {
|
|
2738
|
+
runtime.error?.(`[tlon] Error handling channel firehose event: ${formatErrorMessage$1(error)}`);
|
|
2739
|
+
}
|
|
2740
|
+
};
|
|
2741
|
+
const processedDmInvites = /* @__PURE__ */ new Set();
|
|
2742
|
+
const handleChatFirehose = async (event) => {
|
|
2743
|
+
try {
|
|
2744
|
+
if (Array.isArray(event)) {
|
|
2745
|
+
for (const invite of event) {
|
|
2746
|
+
const ship = normalizeShip(invite.ship || "");
|
|
2747
|
+
if (!ship || processedDmInvites.has(ship)) continue;
|
|
2748
|
+
if (isOwner(ship)) {
|
|
2749
|
+
try {
|
|
2750
|
+
await api.poke({
|
|
2751
|
+
app: "chat",
|
|
2752
|
+
mark: "chat-dm-rsvp",
|
|
2753
|
+
json: {
|
|
2754
|
+
ship,
|
|
2755
|
+
ok: true
|
|
2756
|
+
}
|
|
2757
|
+
});
|
|
2758
|
+
processedDmInvites.add(ship);
|
|
2759
|
+
runtime.log?.(`[tlon] Auto-accepted DM invite from owner ${ship}`);
|
|
2760
|
+
} catch (err) {
|
|
2761
|
+
runtime.error?.(`[tlon] Failed to auto-accept DM from owner: ${String(err)}`);
|
|
2762
|
+
}
|
|
2763
|
+
continue;
|
|
2764
|
+
}
|
|
2765
|
+
if (effectiveAutoAcceptDmInvites && await isDmAllowedWithIngress(ship, effectiveDmAllowlist)) {
|
|
2766
|
+
try {
|
|
2767
|
+
await api.poke({
|
|
2768
|
+
app: "chat",
|
|
2769
|
+
mark: "chat-dm-rsvp",
|
|
2770
|
+
json: {
|
|
2771
|
+
ship,
|
|
2772
|
+
ok: true
|
|
2773
|
+
}
|
|
2774
|
+
});
|
|
2775
|
+
processedDmInvites.add(ship);
|
|
2776
|
+
runtime.log?.(`[tlon] Auto-accepted DM invite from ${ship}`);
|
|
2777
|
+
} catch (err) {
|
|
2778
|
+
runtime.error?.(`[tlon] Failed to auto-accept DM from ${ship}: ${String(err)}`);
|
|
2779
|
+
}
|
|
2780
|
+
continue;
|
|
2781
|
+
}
|
|
2782
|
+
if (effectiveOwnerShip && !await isDmAllowedWithIngress(ship, effectiveDmAllowlist)) {
|
|
2783
|
+
await queueApprovalRequest(createPendingApproval({
|
|
2784
|
+
type: "dm",
|
|
2785
|
+
requestingShip: ship,
|
|
2786
|
+
messagePreview: "(DM invite - no message yet)"
|
|
2787
|
+
}));
|
|
2788
|
+
processedDmInvites.add(ship);
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
return;
|
|
2792
|
+
}
|
|
2793
|
+
const eventRecord = asRecord(event);
|
|
2794
|
+
if (!eventRecord) return;
|
|
2795
|
+
const whom = eventRecord.whom;
|
|
2796
|
+
const messageId = readString(eventRecord, "id");
|
|
2797
|
+
const response = asRecord(eventRecord.response);
|
|
2798
|
+
if (!messageId || !response) return;
|
|
2799
|
+
const essay = asRecord(asRecord(response.add)?.essay);
|
|
2800
|
+
if (!essay) return;
|
|
2801
|
+
if ((await runWithProcessedMessageClaim({
|
|
2802
|
+
tracker: processedTracker,
|
|
2803
|
+
id: messageId,
|
|
2804
|
+
task: async () => {
|
|
2805
|
+
const authorShip = normalizeShip(readString(essay, "author") ?? "");
|
|
2806
|
+
const partnerShip = extractDmPartnerShip(whom);
|
|
2807
|
+
const senderShip = partnerShip || authorShip;
|
|
2808
|
+
if (authorShip === botShipName) return;
|
|
2809
|
+
if (!senderShip || senderShip === botShipName) return;
|
|
2810
|
+
if (authorShip && partnerShip && authorShip !== partnerShip) runtime.log?.(`[tlon] DM ship mismatch (author=${authorShip}, partner=${partnerShip}) - routing to partner`);
|
|
2811
|
+
const rawText = extractMessageText(essay.content);
|
|
2812
|
+
if (!rawText.trim()) return;
|
|
2813
|
+
const messageText = rawText;
|
|
2814
|
+
if (isOwner(senderShip) && isApprovalResponse(messageText)) {
|
|
2815
|
+
if (await handleApprovalResponse(messageText)) {
|
|
2816
|
+
runtime.log?.(`[tlon] Processed approval response from owner: ${messageText}`);
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
if (isOwner(senderShip) && isAdminCommand(messageText)) {
|
|
2821
|
+
if (await handleAdminCommand(messageText)) {
|
|
2822
|
+
runtime.log?.(`[tlon] Processed admin command from owner: ${messageText}`);
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
if (isOwner(senderShip)) {
|
|
2827
|
+
const resolvedMessageText = await resolveAuthorizedMessageText({
|
|
2828
|
+
rawText,
|
|
2829
|
+
content: essay.content,
|
|
2830
|
+
authorizedForCites: true,
|
|
2831
|
+
resolveAllCites
|
|
2832
|
+
});
|
|
2833
|
+
runtime.log?.(`[tlon] Processing DM from owner ${senderShip}`);
|
|
2834
|
+
await processMessage({
|
|
2835
|
+
messageId: messageId ?? "",
|
|
2836
|
+
senderShip,
|
|
2837
|
+
messageText: resolvedMessageText,
|
|
2838
|
+
messageContent: essay.content,
|
|
2839
|
+
isGroup: false,
|
|
2840
|
+
timestamp: readNumber(essay, "sent") ?? Date.now()
|
|
2841
|
+
});
|
|
2842
|
+
return;
|
|
2843
|
+
}
|
|
2844
|
+
if (!await isDmAllowedWithIngress(senderShip, effectiveDmAllowlist)) {
|
|
2845
|
+
if (effectiveOwnerShip) await queueApprovalRequest(createPendingApproval({
|
|
2846
|
+
type: "dm",
|
|
2847
|
+
requestingShip: senderShip,
|
|
2848
|
+
messagePreview: messageText.slice(0, 100),
|
|
2849
|
+
originalMessage: {
|
|
2850
|
+
messageId: messageId ?? "",
|
|
2851
|
+
messageText,
|
|
2852
|
+
messageContent: essay.content,
|
|
2853
|
+
timestamp: readNumber(essay, "sent") ?? Date.now()
|
|
2854
|
+
}
|
|
2855
|
+
}));
|
|
2856
|
+
else runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
await processMessage({
|
|
2860
|
+
messageText: await resolveAuthorizedMessageText({
|
|
2861
|
+
rawText,
|
|
2862
|
+
content: essay.content,
|
|
2863
|
+
authorizedForCites: true,
|
|
2864
|
+
resolveAllCites
|
|
2865
|
+
}),
|
|
2866
|
+
messageId: messageId ?? "",
|
|
2867
|
+
senderShip,
|
|
2868
|
+
messageContent: essay.content,
|
|
2869
|
+
isGroup: false,
|
|
2870
|
+
timestamp: readNumber(essay, "sent") ?? Date.now()
|
|
2871
|
+
});
|
|
2872
|
+
}
|
|
2873
|
+
})).kind === "duplicate") return;
|
|
2874
|
+
} catch (error) {
|
|
2875
|
+
runtime.error?.(`[tlon] Error handling chat firehose event: ${formatErrorMessage$1(error)}`);
|
|
2876
|
+
}
|
|
2877
|
+
};
|
|
2878
|
+
try {
|
|
2879
|
+
runtime.log?.("[tlon] Subscribing to firehose updates...");
|
|
2880
|
+
await api.subscribe({
|
|
2881
|
+
app: "channels",
|
|
2882
|
+
path: "/v2",
|
|
2883
|
+
event: handleChannelsFirehose,
|
|
2884
|
+
err: (error) => {
|
|
2885
|
+
runtime.error?.(`[tlon] Channels firehose error: ${String(error)}`);
|
|
2886
|
+
},
|
|
2887
|
+
quit: () => {
|
|
2888
|
+
runtime.log?.("[tlon] Channels firehose subscription ended");
|
|
2889
|
+
}
|
|
2890
|
+
});
|
|
2891
|
+
runtime.log?.("[tlon] Subscribed to channels firehose (/v2)");
|
|
2892
|
+
await api.subscribe({
|
|
2893
|
+
app: "chat",
|
|
2894
|
+
path: "/v3",
|
|
2895
|
+
event: handleChatFirehose,
|
|
2896
|
+
err: (error) => {
|
|
2897
|
+
runtime.error?.(`[tlon] Chat firehose error: ${String(error)}`);
|
|
2898
|
+
},
|
|
2899
|
+
quit: () => {
|
|
2900
|
+
runtime.log?.("[tlon] Chat firehose subscription ended");
|
|
2901
|
+
}
|
|
2902
|
+
});
|
|
2903
|
+
runtime.log?.("[tlon] Subscribed to chat firehose (/v3)");
|
|
2904
|
+
await api.subscribe({
|
|
2905
|
+
app: "contacts",
|
|
2906
|
+
path: "/v1/news",
|
|
2907
|
+
event: (event) => {
|
|
2908
|
+
try {
|
|
2909
|
+
const eventRecord = asRecord(event);
|
|
2910
|
+
if (eventRecord?.self) {
|
|
2911
|
+
const nickname = asRecord(asRecord(asRecord(eventRecord.self)?.contact)?.nickname);
|
|
2912
|
+
if (nickname && "value" in nickname) {
|
|
2913
|
+
const newNickname = readString(nickname, "value") ?? null;
|
|
2914
|
+
if (newNickname !== botNickname) {
|
|
2915
|
+
botNickname = newNickname;
|
|
2916
|
+
runtime.log?.(`[tlon] Nickname updated: ${botNickname}`);
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
} catch (error) {
|
|
2921
|
+
runtime.error?.(`[tlon] Error handling contacts event: ${formatErrorMessage$1(error)}`);
|
|
2922
|
+
}
|
|
2923
|
+
},
|
|
2924
|
+
err: (error) => {
|
|
2925
|
+
runtime.error?.(`[tlon] Contacts subscription error: ${String(error)}`);
|
|
2926
|
+
},
|
|
2927
|
+
quit: () => {
|
|
2928
|
+
runtime.log?.("[tlon] Contacts subscription ended");
|
|
2929
|
+
}
|
|
2930
|
+
});
|
|
2931
|
+
runtime.log?.("[tlon] Subscribed to contacts updates (/v1/news)");
|
|
2932
|
+
settingsManager.onChange((newSettings) => {
|
|
2933
|
+
currentSettings = newSettings;
|
|
2934
|
+
if (newSettings.groupChannels?.length) {
|
|
2935
|
+
const newChannels = newSettings.groupChannels;
|
|
2936
|
+
for (const ch of newChannels) if (!watchedChannels.has(ch)) {
|
|
2937
|
+
watchedChannels.add(ch);
|
|
2938
|
+
runtime.log?.(`[tlon] Settings: now watching channel ${ch}`);
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
({effectiveDmAllowlist, effectiveShowModelSig, effectiveAutoAcceptDmInvites, effectiveAutoAcceptGroupInvites, effectiveGroupInviteAllowlist, effectiveAutoDiscoverChannels, effectiveOwnerShip, pendingApprovals} = applyTlonSettingsOverrides({
|
|
2942
|
+
account,
|
|
2943
|
+
currentSettings: newSettings,
|
|
2944
|
+
log: (message) => runtime.log?.(message)
|
|
2945
|
+
}));
|
|
2946
|
+
});
|
|
2947
|
+
try {
|
|
2948
|
+
await settingsManager.startSubscription();
|
|
2949
|
+
} catch (err) {
|
|
2950
|
+
runtime.log?.(`[tlon] Settings subscription not available: ${String(err)}`);
|
|
2951
|
+
}
|
|
2952
|
+
try {
|
|
2953
|
+
await api.subscribe({
|
|
2954
|
+
app: "groups",
|
|
2955
|
+
path: "/groups/ui",
|
|
2956
|
+
event: async (event) => {
|
|
2957
|
+
try {
|
|
2958
|
+
const eventRecord = asRecord(event);
|
|
2959
|
+
if (eventRecord) {
|
|
2960
|
+
const channels = asRecord(eventRecord.channels);
|
|
2961
|
+
if (channels) for (const [channelNest, _channelData] of Object.entries(channels)) {
|
|
2962
|
+
if (!channelNest.startsWith("chat/")) continue;
|
|
2963
|
+
if (!watchedChannels.has(channelNest)) {
|
|
2964
|
+
watchedChannels.add(channelNest);
|
|
2965
|
+
runtime.log?.(`[tlon] Auto-detected new channel (invite accepted): ${channelNest}`);
|
|
2966
|
+
if (effectiveAutoAcceptGroupInvites) try {
|
|
2967
|
+
const currentChannels = currentSettings.groupChannels || [];
|
|
2968
|
+
if (!currentChannels.includes(channelNest)) {
|
|
2969
|
+
const updatedChannels = [...currentChannels, channelNest];
|
|
2970
|
+
await api.poke({
|
|
2971
|
+
app: "settings",
|
|
2972
|
+
mark: "settings-event",
|
|
2973
|
+
json: { "put-entry": {
|
|
2974
|
+
"bucket-key": "tlon",
|
|
2975
|
+
"entry-key": "groupChannels",
|
|
2976
|
+
value: updatedChannels,
|
|
2977
|
+
desk: "moltbot"
|
|
2978
|
+
} }
|
|
2979
|
+
});
|
|
2980
|
+
runtime.log?.(`[tlon] Persisted ${channelNest} to settings store`);
|
|
2981
|
+
}
|
|
2982
|
+
} catch (err) {
|
|
2983
|
+
runtime.error?.(`[tlon] Failed to persist channel to settings: ${String(err)}`);
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
const join = asRecord(eventRecord.join);
|
|
2988
|
+
if (join) {
|
|
2989
|
+
const joinChannels = Array.isArray(join.channels) ? join.channels : [];
|
|
2990
|
+
if (joinChannels.length > 0) for (const channelNest of joinChannels) {
|
|
2991
|
+
if (typeof channelNest !== "string") continue;
|
|
2992
|
+
if (!channelNest.startsWith("chat/")) continue;
|
|
2993
|
+
if (!watchedChannels.has(channelNest)) {
|
|
2994
|
+
watchedChannels.add(channelNest);
|
|
2995
|
+
runtime.log?.(`[tlon] Auto-detected joined channel: ${channelNest}`);
|
|
2996
|
+
if (effectiveAutoAcceptGroupInvites) try {
|
|
2997
|
+
const currentChannels = currentSettings.groupChannels || [];
|
|
2998
|
+
if (!currentChannels.includes(channelNest)) {
|
|
2999
|
+
const updatedChannels = [...currentChannels, channelNest];
|
|
3000
|
+
await api.poke({
|
|
3001
|
+
app: "settings",
|
|
3002
|
+
mark: "settings-event",
|
|
3003
|
+
json: { "put-entry": {
|
|
3004
|
+
"bucket-key": "tlon",
|
|
3005
|
+
"entry-key": "groupChannels",
|
|
3006
|
+
value: updatedChannels,
|
|
3007
|
+
desk: "moltbot"
|
|
3008
|
+
} }
|
|
3009
|
+
});
|
|
3010
|
+
runtime.log?.(`[tlon] Persisted ${channelNest} to settings store`);
|
|
3011
|
+
}
|
|
3012
|
+
} catch (err) {
|
|
3013
|
+
runtime.error?.(`[tlon] Failed to persist channel to settings: ${String(err)}`);
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
} catch (error) {
|
|
3020
|
+
runtime.error?.(`[tlon] Error handling groups-ui event: ${formatErrorMessage$1(error)}`);
|
|
3021
|
+
}
|
|
3022
|
+
},
|
|
3023
|
+
err: (error) => {
|
|
3024
|
+
runtime.error?.(`[tlon] Groups-ui subscription error: ${String(error)}`);
|
|
3025
|
+
},
|
|
3026
|
+
quit: () => {
|
|
3027
|
+
runtime.log?.("[tlon] Groups-ui subscription ended");
|
|
3028
|
+
}
|
|
3029
|
+
});
|
|
3030
|
+
runtime.log?.("[tlon] Subscribed to groups-ui for real-time channel detection");
|
|
3031
|
+
} catch (err) {
|
|
3032
|
+
runtime.log?.(`[tlon] Groups-ui subscription failed (will rely on polling): ${String(err)}`);
|
|
3033
|
+
}
|
|
3034
|
+
{
|
|
3035
|
+
const processedGroupInvites = /* @__PURE__ */ new Set();
|
|
3036
|
+
const processPendingInvites = async (foreigns) => {
|
|
3037
|
+
if (!foreigns || typeof foreigns !== "object") return;
|
|
3038
|
+
for (const [groupFlag, foreign] of Object.entries(foreigns)) {
|
|
3039
|
+
if (processedGroupInvites.has(groupFlag)) continue;
|
|
3040
|
+
if (!foreign.invites || foreign.invites.length === 0) continue;
|
|
3041
|
+
const validInvite = foreign.invites.find((inv) => inv.valid);
|
|
3042
|
+
if (!validInvite) continue;
|
|
3043
|
+
const inviterShip = validInvite.from;
|
|
3044
|
+
if (isOwner(inviterShip)) {
|
|
3045
|
+
try {
|
|
3046
|
+
await api.poke({
|
|
3047
|
+
app: "groups",
|
|
3048
|
+
mark: "group-join",
|
|
3049
|
+
json: {
|
|
3050
|
+
flag: groupFlag,
|
|
3051
|
+
"join-all": true
|
|
3052
|
+
}
|
|
3053
|
+
});
|
|
3054
|
+
processedGroupInvites.add(groupFlag);
|
|
3055
|
+
runtime.log?.(`[tlon] Auto-accepted group invite from owner: ${groupFlag}`);
|
|
3056
|
+
} catch (err) {
|
|
3057
|
+
runtime.error?.(`[tlon] Failed to accept group invite from owner: ${String(err)}`);
|
|
3058
|
+
}
|
|
3059
|
+
continue;
|
|
3060
|
+
}
|
|
3061
|
+
if (!effectiveAutoAcceptGroupInvites) {
|
|
3062
|
+
if (effectiveOwnerShip) {
|
|
3063
|
+
await queueApprovalRequest(createPendingApproval({
|
|
3064
|
+
type: "group",
|
|
3065
|
+
requestingShip: inviterShip,
|
|
3066
|
+
groupFlag
|
|
3067
|
+
}));
|
|
3068
|
+
processedGroupInvites.add(groupFlag);
|
|
3069
|
+
}
|
|
3070
|
+
continue;
|
|
3071
|
+
}
|
|
3072
|
+
if (!isGroupInviteAllowed(inviterShip, effectiveGroupInviteAllowlist)) {
|
|
3073
|
+
if (effectiveOwnerShip) {
|
|
3074
|
+
await queueApprovalRequest(createPendingApproval({
|
|
3075
|
+
type: "group",
|
|
3076
|
+
requestingShip: inviterShip,
|
|
3077
|
+
groupFlag
|
|
3078
|
+
}));
|
|
3079
|
+
processedGroupInvites.add(groupFlag);
|
|
3080
|
+
} else {
|
|
3081
|
+
runtime.log?.(`[tlon] Rejected group invite from ${inviterShip} (not in groupInviteAllowlist): ${groupFlag}`);
|
|
3082
|
+
processedGroupInvites.add(groupFlag);
|
|
3083
|
+
}
|
|
3084
|
+
continue;
|
|
3085
|
+
}
|
|
3086
|
+
try {
|
|
3087
|
+
await api.poke({
|
|
3088
|
+
app: "groups",
|
|
3089
|
+
mark: "group-join",
|
|
3090
|
+
json: {
|
|
3091
|
+
flag: groupFlag,
|
|
3092
|
+
"join-all": true
|
|
3093
|
+
}
|
|
3094
|
+
});
|
|
3095
|
+
processedGroupInvites.add(groupFlag);
|
|
3096
|
+
runtime.log?.(`[tlon] Auto-accepted group invite: ${groupFlag} (from ${validInvite.from})`);
|
|
3097
|
+
} catch (err) {
|
|
3098
|
+
runtime.error?.(`[tlon] Failed to auto-accept group ${groupFlag}: ${String(err)}`);
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
};
|
|
3102
|
+
if (initForeigns) await processPendingInvites(initForeigns);
|
|
3103
|
+
try {
|
|
3104
|
+
await api.subscribe({
|
|
3105
|
+
app: "groups",
|
|
3106
|
+
path: "/v1/foreigns",
|
|
3107
|
+
event: (data) => {
|
|
3108
|
+
(async () => {
|
|
3109
|
+
try {
|
|
3110
|
+
await processPendingInvites(data);
|
|
3111
|
+
} catch (error) {
|
|
3112
|
+
runtime.error?.(`[tlon] Error handling foreigns event: ${formatErrorMessage$1(error)}`);
|
|
3113
|
+
}
|
|
3114
|
+
})();
|
|
3115
|
+
},
|
|
3116
|
+
err: (error) => {
|
|
3117
|
+
runtime.error?.(`[tlon] Foreigns subscription error: ${String(error)}`);
|
|
3118
|
+
},
|
|
3119
|
+
quit: () => {
|
|
3120
|
+
runtime.log?.("[tlon] Foreigns subscription ended");
|
|
3121
|
+
}
|
|
3122
|
+
});
|
|
3123
|
+
runtime.log?.("[tlon] Subscribed to foreigns (/v1/foreigns) for auto-accepting group invites");
|
|
3124
|
+
} catch (err) {
|
|
3125
|
+
runtime.log?.(`[tlon] Foreigns subscription failed: ${String(err)}`);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
if (effectiveAutoDiscoverChannels) {
|
|
3129
|
+
const discoveredChannels = await fetchAllChannels(api, runtime);
|
|
3130
|
+
for (const channelNest of discoveredChannels) watchedChannels.add(channelNest);
|
|
3131
|
+
runtime.log?.(`[tlon] Watching ${watchedChannels.size} channel(s)`);
|
|
3132
|
+
}
|
|
3133
|
+
for (const channelNest of watchedChannels) runtime.log?.(`[tlon] Watching channel: ${channelNest}`);
|
|
3134
|
+
runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream...");
|
|
3135
|
+
await api.connect();
|
|
3136
|
+
runtime.log?.("[tlon] Connected! Firehose subscriptions active");
|
|
3137
|
+
const pollInterval = setInterval(async () => {
|
|
3138
|
+
if (!opts.abortSignal?.aborted) try {
|
|
3139
|
+
if (effectiveAutoDiscoverChannels) {
|
|
3140
|
+
const discoveredChannels = await fetchAllChannels(api, runtime);
|
|
3141
|
+
for (const channelNest of discoveredChannels) if (!watchedChannels.has(channelNest)) {
|
|
3142
|
+
watchedChannels.add(channelNest);
|
|
3143
|
+
runtime.log?.(`[tlon] Now watching new channel: ${channelNest}`);
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
} catch (error) {
|
|
3147
|
+
runtime.error?.(`[tlon] Channel refresh error: ${formatErrorMessage$1(error)}`);
|
|
3148
|
+
}
|
|
3149
|
+
}, 120 * 1e3);
|
|
3150
|
+
if (opts.abortSignal) {
|
|
3151
|
+
const signal = opts.abortSignal;
|
|
3152
|
+
await new Promise((resolve) => {
|
|
3153
|
+
signal.addEventListener("abort", () => {
|
|
3154
|
+
clearInterval(pollInterval);
|
|
3155
|
+
resolve(null);
|
|
3156
|
+
}, { once: true });
|
|
3157
|
+
});
|
|
3158
|
+
} else await new Promise(() => {});
|
|
3159
|
+
} finally {
|
|
3160
|
+
try {
|
|
3161
|
+
await api?.close();
|
|
3162
|
+
} catch (error) {
|
|
3163
|
+
runtime.error?.(`[tlon] Cleanup error: ${formatErrorMessage$1(error)}`);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
//#endregion
|
|
3168
|
+
//#region extensions/tlon/src/tlon-api.ts
|
|
3169
|
+
const MEMEX_BASE_URL = "https://memex.tlon.network";
|
|
3170
|
+
let currentClientConfig = null;
|
|
3171
|
+
function configureClient(params) {
|
|
3172
|
+
currentClientConfig = {
|
|
3173
|
+
...params,
|
|
3174
|
+
shipName: params.shipName.replace(/^~/, "")
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
function requireClientConfig() {
|
|
3178
|
+
if (!currentClientConfig) throw new Error("Tlon client not configured");
|
|
3179
|
+
return currentClientConfig;
|
|
3180
|
+
}
|
|
3181
|
+
function getExtensionFromMimeType(mimeType) {
|
|
3182
|
+
return extensionForMime(mimeType) || ".jpg";
|
|
3183
|
+
}
|
|
3184
|
+
function hasCustomS3Creds(credentials) {
|
|
3185
|
+
return Boolean(credentials?.accessKeyId && credentials?.endpoint && credentials?.secretAccessKey);
|
|
3186
|
+
}
|
|
3187
|
+
function isStorageCredentials(value) {
|
|
3188
|
+
if (!value || typeof value !== "object") return false;
|
|
3189
|
+
const record = value;
|
|
3190
|
+
return typeof record.endpoint === "string" && typeof record.accessKeyId === "string" && typeof record.secretAccessKey === "string";
|
|
3191
|
+
}
|
|
3192
|
+
function hostnameMatchesDomainBoundary(hostname, domain) {
|
|
3193
|
+
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
3194
|
+
}
|
|
3195
|
+
function isHostedShipUrl(shipUrl) {
|
|
3196
|
+
const hostname = extractShipHostname(shipUrl);
|
|
3197
|
+
return hostname !== null && isHostedTlonHostname(hostname);
|
|
3198
|
+
}
|
|
3199
|
+
function extractShipHostname(shipUrl) {
|
|
3200
|
+
const trimmed = shipUrl.trim();
|
|
3201
|
+
if (!trimmed) return null;
|
|
3202
|
+
const normalized = /^[a-zA-Z][\w+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
3203
|
+
try {
|
|
3204
|
+
return new URL(normalized).hostname;
|
|
3205
|
+
} catch {
|
|
3206
|
+
return null;
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
function isHostedTlonHostname(hostname) {
|
|
3210
|
+
return hostnameMatchesDomainBoundary(hostname, "tlon.network") || hostnameMatchesDomainBoundary(hostname, "test.tlon.systems");
|
|
3211
|
+
}
|
|
3212
|
+
function assertTrustedMemexUploadUrl(rawUrl, label) {
|
|
3213
|
+
let parsed;
|
|
3214
|
+
try {
|
|
3215
|
+
parsed = new URL(rawUrl);
|
|
3216
|
+
} catch {
|
|
3217
|
+
throw new Error(`${label} must be a valid https URL`);
|
|
3218
|
+
}
|
|
3219
|
+
if (parsed.protocol !== "https:") throw new Error(`${label} must use https`);
|
|
3220
|
+
if (!isHostedTlonHostname(parsed.hostname)) throw new Error(`${label} must target a trusted hosted Tlon domain`);
|
|
3221
|
+
if (parsed.port && parsed.port !== "443") throw new Error(`${label} must not specify a non-standard port`);
|
|
3222
|
+
return parsed.toString();
|
|
3223
|
+
}
|
|
3224
|
+
function assertSafeUploadResultUrl(rawUrl, label) {
|
|
3225
|
+
let parsed;
|
|
3226
|
+
try {
|
|
3227
|
+
parsed = new URL(rawUrl);
|
|
3228
|
+
} catch {
|
|
3229
|
+
throw new Error(`${label} must be a valid http(s) URL`);
|
|
3230
|
+
}
|
|
3231
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${label} must use http or https`);
|
|
3232
|
+
return parsed.toString();
|
|
3233
|
+
}
|
|
3234
|
+
function prefixEndpoint(endpoint) {
|
|
3235
|
+
return /https?:\/\//.test(endpoint) ? endpoint : `https://${endpoint}`;
|
|
3236
|
+
}
|
|
3237
|
+
function sanitizeFileName(fileName) {
|
|
3238
|
+
return fileName.split(/[/\\]/).pop() || fileName;
|
|
3239
|
+
}
|
|
3240
|
+
async function getAuthCookie(config) {
|
|
3241
|
+
return await authenticate(config.shipUrl, await config.getCode(), { ssrfPolicy: ssrfPolicyFromDangerouslyAllowPrivateNetwork(config.dangerouslyAllowPrivateNetwork) });
|
|
3242
|
+
}
|
|
3243
|
+
async function scryJson(config, cookie, path) {
|
|
3244
|
+
return await scryUrbitPath({
|
|
3245
|
+
baseUrl: config.shipUrl,
|
|
3246
|
+
cookie,
|
|
3247
|
+
ssrfPolicy: ssrfPolicyFromDangerouslyAllowPrivateNetwork(config.dangerouslyAllowPrivateNetwork)
|
|
3248
|
+
}, {
|
|
3249
|
+
path,
|
|
3250
|
+
auditContext: "tlon-storage-scry"
|
|
3251
|
+
});
|
|
3252
|
+
}
|
|
3253
|
+
async function getStorageConfiguration(config, cookie) {
|
|
3254
|
+
const result = await scryJson(config, cookie, "/storage/configuration.json");
|
|
3255
|
+
if ("storage-update" in result && result["storage-update"]?.configuration) return result["storage-update"].configuration;
|
|
3256
|
+
if ("currentBucket" in result) return result;
|
|
3257
|
+
throw new Error("Invalid storage configuration response");
|
|
3258
|
+
}
|
|
3259
|
+
async function getStorageCredentials(config, cookie) {
|
|
3260
|
+
const result = await scryJson(config, cookie, "/storage/credentials.json");
|
|
3261
|
+
if ("storage-update" in result) return result["storage-update"]?.credentials ?? null;
|
|
3262
|
+
if (isStorageCredentials(result)) return result;
|
|
3263
|
+
return null;
|
|
3264
|
+
}
|
|
3265
|
+
async function getMemexUploadUrl(params) {
|
|
3266
|
+
const token = await scryJson(params.config, params.cookie, "/genuine/secret.json");
|
|
3267
|
+
const resolvedToken = typeof token === "string" ? token : token.secret;
|
|
3268
|
+
if (!resolvedToken) throw new Error("Missing genuine secret");
|
|
3269
|
+
const endpoint = `${MEMEX_BASE_URL}/v1/${params.config.shipName}/upload`;
|
|
3270
|
+
let release;
|
|
3271
|
+
try {
|
|
3272
|
+
const guarded = await fetchWithSsrFGuard({
|
|
3273
|
+
url: endpoint,
|
|
3274
|
+
init: {
|
|
3275
|
+
method: "PUT",
|
|
3276
|
+
headers: { "Content-Type": "application/json" },
|
|
3277
|
+
body: JSON.stringify({
|
|
3278
|
+
token: resolvedToken,
|
|
3279
|
+
contentLength: params.contentLength,
|
|
3280
|
+
contentType: params.contentType,
|
|
3281
|
+
fileName: params.fileName
|
|
3282
|
+
})
|
|
3283
|
+
},
|
|
3284
|
+
auditContext: "tlon-memex-upload-url",
|
|
3285
|
+
capture: false,
|
|
3286
|
+
maxRedirects: 0
|
|
3287
|
+
});
|
|
3288
|
+
release = guarded.release;
|
|
3289
|
+
if (!guarded.response.ok) throw new Error(`Memex upload request failed: ${guarded.response.status}`);
|
|
3290
|
+
const data = await guarded.response.json();
|
|
3291
|
+
if (!data?.url || !data.filePath) throw new Error("Invalid response from Memex");
|
|
3292
|
+
return {
|
|
3293
|
+
hostedUrl: data.filePath,
|
|
3294
|
+
uploadUrl: data.url
|
|
3295
|
+
};
|
|
3296
|
+
} finally {
|
|
3297
|
+
await release?.();
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
async function uploadFile(params) {
|
|
3301
|
+
const config = requireClientConfig();
|
|
3302
|
+
const cookie = await getAuthCookie(config);
|
|
3303
|
+
const privateNetworkPolicy = ssrfPolicyFromDangerouslyAllowPrivateNetwork(config.dangerouslyAllowPrivateNetwork);
|
|
3304
|
+
const [storageConfig, credentials] = await Promise.all([getStorageConfiguration(config, cookie), getStorageCredentials(config, cookie)]);
|
|
3305
|
+
const contentType = params.contentType || params.blob.type || "application/octet-stream";
|
|
3306
|
+
const extension = getExtensionFromMimeType(contentType);
|
|
3307
|
+
const fileName = sanitizeFileName(params.fileName || `upload${extension}`);
|
|
3308
|
+
const fileKey = `${config.shipName}/${Date.now()}-${crypto.randomUUID()}-${fileName}`;
|
|
3309
|
+
if (isHostedShipUrl(config.shipUrl) && (storageConfig.service === "presigned-url" || !hasCustomS3Creds(credentials))) {
|
|
3310
|
+
const { hostedUrl, uploadUrl } = await getMemexUploadUrl({
|
|
3311
|
+
config,
|
|
3312
|
+
cookie,
|
|
3313
|
+
contentLength: params.blob.size,
|
|
3314
|
+
contentType,
|
|
3315
|
+
fileName: fileKey
|
|
3316
|
+
});
|
|
3317
|
+
const trustedUploadUrl = assertTrustedMemexUploadUrl(uploadUrl, "Memex upload URL");
|
|
3318
|
+
let release;
|
|
3319
|
+
try {
|
|
3320
|
+
const guarded = await fetchWithSsrFGuard({
|
|
3321
|
+
url: trustedUploadUrl,
|
|
3322
|
+
init: {
|
|
3323
|
+
method: "PUT",
|
|
3324
|
+
body: params.blob,
|
|
3325
|
+
headers: {
|
|
3326
|
+
"Cache-Control": "public, max-age=3600",
|
|
3327
|
+
"Content-Type": contentType
|
|
3328
|
+
}
|
|
3329
|
+
},
|
|
3330
|
+
auditContext: "tlon-memex-upload",
|
|
3331
|
+
capture: false,
|
|
3332
|
+
maxRedirects: 0
|
|
3333
|
+
});
|
|
3334
|
+
release = guarded.release;
|
|
3335
|
+
assertTrustedMemexUploadUrl(guarded.finalUrl, "Memex final upload URL");
|
|
3336
|
+
if (!guarded.response.ok) throw new Error(`Upload failed: ${guarded.response.status}`);
|
|
3337
|
+
} finally {
|
|
3338
|
+
await release?.();
|
|
3339
|
+
}
|
|
3340
|
+
return { url: assertTrustedMemexUploadUrl(hostedUrl, "Memex hosted URL") };
|
|
3341
|
+
}
|
|
3342
|
+
if (!hasCustomS3Creds(credentials)) throw new Error("No storage credentials configured");
|
|
3343
|
+
const endpoint = new URL(prefixEndpoint(credentials.endpoint));
|
|
3344
|
+
const client = new S3Client({
|
|
3345
|
+
endpoint: {
|
|
3346
|
+
protocol: endpoint.protocol.slice(0, -1),
|
|
3347
|
+
hostname: endpoint.host,
|
|
3348
|
+
path: endpoint.pathname || "/"
|
|
3349
|
+
},
|
|
3350
|
+
region: storageConfig.region || "us-east-1",
|
|
3351
|
+
credentials: {
|
|
3352
|
+
accessKeyId: credentials.accessKeyId,
|
|
3353
|
+
secretAccessKey: credentials.secretAccessKey
|
|
3354
|
+
},
|
|
3355
|
+
forcePathStyle: true
|
|
3356
|
+
});
|
|
3357
|
+
const headers = {
|
|
3358
|
+
"Cache-Control": "public, max-age=3600",
|
|
3359
|
+
"Content-Type": contentType,
|
|
3360
|
+
"x-amz-acl": "public-read"
|
|
3361
|
+
};
|
|
3362
|
+
const signedUrl = await getSignedUrl(client, new PutObjectCommand({
|
|
3363
|
+
Bucket: storageConfig.currentBucket,
|
|
3364
|
+
Key: fileKey,
|
|
3365
|
+
ContentType: headers["Content-Type"],
|
|
3366
|
+
CacheControl: headers["Cache-Control"],
|
|
3367
|
+
ACL: "public-read"
|
|
3368
|
+
}), {
|
|
3369
|
+
expiresIn: 3600,
|
|
3370
|
+
signableHeaders: new Set(Object.keys(headers))
|
|
3371
|
+
});
|
|
3372
|
+
let release;
|
|
3373
|
+
try {
|
|
3374
|
+
const guarded = await fetchWithSsrFGuard({
|
|
3375
|
+
url: signedUrl,
|
|
3376
|
+
init: {
|
|
3377
|
+
method: "PUT",
|
|
3378
|
+
body: params.blob,
|
|
3379
|
+
headers: signedUrl.includes("digitaloceanspaces.com") ? headers : void 0
|
|
3380
|
+
},
|
|
3381
|
+
auditContext: "tlon-custom-s3-upload",
|
|
3382
|
+
capture: false,
|
|
3383
|
+
maxRedirects: 0,
|
|
3384
|
+
policy: privateNetworkPolicy
|
|
3385
|
+
});
|
|
3386
|
+
release = guarded.release;
|
|
3387
|
+
if (!guarded.response.ok) throw new Error(`Upload failed: ${guarded.response.status}`);
|
|
3388
|
+
} finally {
|
|
3389
|
+
await release?.();
|
|
3390
|
+
}
|
|
3391
|
+
return { url: assertSafeUploadResultUrl(storageConfig.publicUrlBase ? new URL(fileKey, storageConfig.publicUrlBase).toString() : signedUrl.split("?")[0], "Upload result URL") };
|
|
3392
|
+
}
|
|
3393
|
+
//#endregion
|
|
3394
|
+
//#region extensions/tlon/src/urbit/upload.ts
|
|
3395
|
+
/**
|
|
3396
|
+
* Upload an image from a URL to Tlon storage.
|
|
3397
|
+
*/
|
|
3398
|
+
/**
|
|
3399
|
+
* Fetch an image from a URL and upload it to Tlon storage.
|
|
3400
|
+
* Returns the uploaded URL, or falls back to the original URL on error.
|
|
3401
|
+
*
|
|
3402
|
+
* Note: configureClient must be called before using this function.
|
|
3403
|
+
*/
|
|
3404
|
+
async function uploadImageFromUrl(imageUrl) {
|
|
3405
|
+
try {
|
|
3406
|
+
const url = new URL(imageUrl);
|
|
3407
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
3408
|
+
console.warn(`[tlon] Rejected non-http(s) URL: ${imageUrl}`);
|
|
3409
|
+
return imageUrl;
|
|
3410
|
+
}
|
|
3411
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
3412
|
+
url: imageUrl,
|
|
3413
|
+
init: { method: "GET" },
|
|
3414
|
+
policy: void 0,
|
|
3415
|
+
auditContext: "tlon-upload-image"
|
|
3416
|
+
});
|
|
3417
|
+
try {
|
|
3418
|
+
if (!response.ok) {
|
|
3419
|
+
console.warn(`[tlon] Failed to fetch image from ${imageUrl}: ${response.status}`);
|
|
3420
|
+
return imageUrl;
|
|
3421
|
+
}
|
|
3422
|
+
const contentType = response.headers.get("content-type") || "image/png";
|
|
3423
|
+
return (await uploadFile({
|
|
3424
|
+
blob: await response.blob(),
|
|
3425
|
+
fileName: new URL(imageUrl).pathname.split("/").pop() || `upload-${Date.now()}.png`,
|
|
3426
|
+
contentType
|
|
3427
|
+
})).url;
|
|
3428
|
+
} finally {
|
|
3429
|
+
await release();
|
|
3430
|
+
}
|
|
3431
|
+
} catch (err) {
|
|
3432
|
+
console.warn(`[tlon] Failed to upload image, using original URL: ${String(err)}`);
|
|
3433
|
+
return imageUrl;
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
//#endregion
|
|
3437
|
+
//#region extensions/tlon/src/channel.runtime.ts
|
|
3438
|
+
async function createHttpPokeApi(params) {
|
|
3439
|
+
const ssrfPolicy = ssrfPolicyFromDangerouslyAllowPrivateNetwork(params.dangerouslyAllowPrivateNetwork);
|
|
3440
|
+
const cookie = await authenticate(params.url, params.code, { ssrfPolicy });
|
|
3441
|
+
const channelPath = `/~/channel/${`${Math.floor(Date.now() / 1e3)}-${crypto.randomUUID()}`}`;
|
|
3442
|
+
const shipName = params.ship.replace(/^~/, "");
|
|
3443
|
+
return {
|
|
3444
|
+
poke: async (pokeParams) => {
|
|
3445
|
+
const pokeId = Date.now();
|
|
3446
|
+
const pokeData = {
|
|
3447
|
+
id: pokeId,
|
|
3448
|
+
action: "poke",
|
|
3449
|
+
ship: shipName,
|
|
3450
|
+
app: pokeParams.app,
|
|
3451
|
+
mark: pokeParams.mark,
|
|
3452
|
+
json: pokeParams.json
|
|
3453
|
+
};
|
|
3454
|
+
const { response, release } = await urbitFetch({
|
|
3455
|
+
baseUrl: params.url,
|
|
3456
|
+
path: channelPath,
|
|
3457
|
+
init: {
|
|
3458
|
+
method: "PUT",
|
|
3459
|
+
headers: {
|
|
3460
|
+
"Content-Type": "application/json",
|
|
3461
|
+
Cookie: cookie.split(";")[0]
|
|
3462
|
+
},
|
|
3463
|
+
body: JSON.stringify([pokeData])
|
|
3464
|
+
},
|
|
3465
|
+
ssrfPolicy,
|
|
3466
|
+
auditContext: "tlon-poke"
|
|
3467
|
+
});
|
|
3468
|
+
try {
|
|
3469
|
+
if (!response.ok && response.status !== 204) {
|
|
3470
|
+
const errorText = await response.text();
|
|
3471
|
+
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
|
|
3472
|
+
}
|
|
3473
|
+
return pokeId;
|
|
3474
|
+
} finally {
|
|
3475
|
+
await release();
|
|
3476
|
+
}
|
|
3477
|
+
},
|
|
3478
|
+
delete: async () => {}
|
|
3479
|
+
};
|
|
3480
|
+
}
|
|
3481
|
+
function resolveOutboundContext(params) {
|
|
3482
|
+
const account = resolveTlonAccount(params.cfg, params.accountId ?? void 0);
|
|
3483
|
+
if (!account.configured || !account.ship || !account.url || !account.code) throw new Error("Tlon account not configured");
|
|
3484
|
+
const parsed = parseTlonTarget(params.to);
|
|
3485
|
+
if (!parsed) throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
|
|
3486
|
+
return {
|
|
3487
|
+
account,
|
|
3488
|
+
parsed
|
|
3489
|
+
};
|
|
3490
|
+
}
|
|
3491
|
+
function resolveReplyId(replyToId, threadId) {
|
|
3492
|
+
return replyToId ?? threadId ? String(replyToId ?? threadId) : void 0;
|
|
3493
|
+
}
|
|
3494
|
+
async function withHttpPokeAccountApi(account, run) {
|
|
3495
|
+
const api = await createHttpPokeApi({
|
|
3496
|
+
url: account.url,
|
|
3497
|
+
ship: account.ship,
|
|
3498
|
+
code: account.code,
|
|
3499
|
+
dangerouslyAllowPrivateNetwork: account.dangerouslyAllowPrivateNetwork ?? void 0
|
|
3500
|
+
});
|
|
3501
|
+
try {
|
|
3502
|
+
return await run(api);
|
|
3503
|
+
} finally {
|
|
3504
|
+
try {
|
|
3505
|
+
await api.delete();
|
|
3506
|
+
} catch {}
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
const tlonRuntimeOutbound = {
|
|
3510
|
+
deliveryMode: "direct",
|
|
3511
|
+
textChunkLimit: 1e4,
|
|
3512
|
+
resolveTarget: ({ to }) => resolveTlonOutboundTarget(to),
|
|
3513
|
+
deliveryCapabilities: { durableFinal: {
|
|
3514
|
+
text: true,
|
|
3515
|
+
media: true,
|
|
3516
|
+
replyTo: true,
|
|
3517
|
+
thread: true,
|
|
3518
|
+
messageSendingHooks: true
|
|
3519
|
+
} },
|
|
3520
|
+
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
|
3521
|
+
const { account, parsed } = resolveOutboundContext({
|
|
3522
|
+
cfg,
|
|
3523
|
+
accountId,
|
|
3524
|
+
to
|
|
3525
|
+
});
|
|
3526
|
+
return withHttpPokeAccountApi(account, async (api) => {
|
|
3527
|
+
const fromShip = normalizeShip(account.ship);
|
|
3528
|
+
if (parsed.kind === "dm") return await sendDm({
|
|
3529
|
+
api,
|
|
3530
|
+
fromShip,
|
|
3531
|
+
toShip: parsed.ship,
|
|
3532
|
+
text
|
|
3533
|
+
});
|
|
3534
|
+
return await sendGroupMessage({
|
|
3535
|
+
api,
|
|
3536
|
+
fromShip,
|
|
3537
|
+
hostShip: parsed.hostShip,
|
|
3538
|
+
channelName: parsed.channelName,
|
|
3539
|
+
text,
|
|
3540
|
+
replyToId: resolveReplyId(replyToId, threadId)
|
|
3541
|
+
});
|
|
3542
|
+
});
|
|
3543
|
+
},
|
|
3544
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
|
|
3545
|
+
const { account, parsed } = resolveOutboundContext({
|
|
3546
|
+
cfg,
|
|
3547
|
+
accountId,
|
|
3548
|
+
to
|
|
3549
|
+
});
|
|
3550
|
+
configureClient({
|
|
3551
|
+
shipUrl: account.url,
|
|
3552
|
+
shipName: account.ship.replace(/^~/, ""),
|
|
3553
|
+
verbose: false,
|
|
3554
|
+
getCode: async () => account.code,
|
|
3555
|
+
dangerouslyAllowPrivateNetwork: account.dangerouslyAllowPrivateNetwork ?? void 0
|
|
3556
|
+
});
|
|
3557
|
+
const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : void 0;
|
|
3558
|
+
return withHttpPokeAccountApi(account, async (api) => {
|
|
3559
|
+
const fromShip = normalizeShip(account.ship);
|
|
3560
|
+
const story = buildMediaStory(text, uploadedUrl);
|
|
3561
|
+
if (parsed.kind === "dm") return await sendDmWithStory({
|
|
3562
|
+
api,
|
|
3563
|
+
fromShip,
|
|
3564
|
+
toShip: parsed.ship,
|
|
3565
|
+
story,
|
|
3566
|
+
kind: "media"
|
|
3567
|
+
});
|
|
3568
|
+
return await sendGroupMessageWithStory({
|
|
3569
|
+
api,
|
|
3570
|
+
fromShip,
|
|
3571
|
+
hostShip: parsed.hostShip,
|
|
3572
|
+
channelName: parsed.channelName,
|
|
3573
|
+
story,
|
|
3574
|
+
replyToId: resolveReplyId(replyToId, threadId),
|
|
3575
|
+
kind: "media"
|
|
3576
|
+
});
|
|
3577
|
+
});
|
|
3578
|
+
}
|
|
3579
|
+
};
|
|
3580
|
+
async function probeTlonAccount(account) {
|
|
3581
|
+
try {
|
|
3582
|
+
const ssrfPolicy = ssrfPolicyFromDangerouslyAllowPrivateNetwork(account.dangerouslyAllowPrivateNetwork);
|
|
3583
|
+
const cookie = await authenticate(account.url, account.code, { ssrfPolicy });
|
|
3584
|
+
const { response, release } = await urbitFetch({
|
|
3585
|
+
baseUrl: account.url,
|
|
3586
|
+
path: "/~/name",
|
|
3587
|
+
init: {
|
|
3588
|
+
method: "GET",
|
|
3589
|
+
headers: { Cookie: cookie }
|
|
3590
|
+
},
|
|
3591
|
+
ssrfPolicy,
|
|
3592
|
+
timeoutMs: 3e4,
|
|
3593
|
+
auditContext: "tlon-probe-account"
|
|
3594
|
+
});
|
|
3595
|
+
try {
|
|
3596
|
+
if (!response.ok) return {
|
|
3597
|
+
ok: false,
|
|
3598
|
+
error: `Name request failed: ${response.status}`
|
|
3599
|
+
};
|
|
3600
|
+
return { ok: true };
|
|
3601
|
+
} finally {
|
|
3602
|
+
await release();
|
|
3603
|
+
}
|
|
3604
|
+
} catch (error) {
|
|
3605
|
+
return {
|
|
3606
|
+
ok: false,
|
|
3607
|
+
error: error?.message ?? String(error)
|
|
3608
|
+
};
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
async function startTlonGatewayAccount(ctx) {
|
|
3612
|
+
const account = ctx.account;
|
|
3613
|
+
ctx.setStatus({
|
|
3614
|
+
accountId: account.accountId,
|
|
3615
|
+
ship: account.ship,
|
|
3616
|
+
url: account.url
|
|
3617
|
+
});
|
|
3618
|
+
ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`);
|
|
3619
|
+
return monitorTlonProvider({
|
|
3620
|
+
runtime: ctx.runtime,
|
|
3621
|
+
abortSignal: ctx.abortSignal,
|
|
3622
|
+
accountId: account.accountId
|
|
3623
|
+
});
|
|
3624
|
+
}
|
|
3625
|
+
//#endregion
|
|
3626
|
+
export { probeTlonAccount, startTlonGatewayAccount, tlonRuntimeOutbound, tlonSetupWizard };
|