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