// Store validated values for use in closures (TypeScript narrowing doesn't propagate) const accountUrl = account.url; const accountCode = account.code;
// Helper to authenticate with retry logic
async function authenticateWithRetry(maxAttempts = 10): Promise<string> { for (let attempt = 1; ; attempt++) { if (opts.abortSignal?.aborted) { thrownew Error("Aborted while waiting to authenticate");
} try {
runtime.log?.(`[tlon] Attempting authentication to ${accountUrl}...`); return await authenticate(accountUrl, accountCode, { ssrfPolicy });
} catch (error: unknown) {
runtime.error?.(
`[tlon] Failed to authenticate (attempt ${attempt}): ${formatErrorMessage(error)}`,
); if (attempt >= maxAttempts) { throw error;
} const delay = Math.min(30000, 1000 * 2 ** (attempt - 1));
runtime.log?.(`[tlon] Retrying authentication in ${delay}ms...`);
await new Promise<void>((resolve, reject) => { const timer = setTimeout(resolve, delay); if (opts.abortSignal) { const onAbort = () => {
clearTimeout(timer);
reject(new Error("Aborted"));
};
opts.abortSignal.addEventListener("abort", onAbort, { once: true });
}
});
}
}
}
let api: UrbitSSEClient | null = null; const cookie = await authenticateWithRetry();
api = new UrbitSSEClient(account.url, cookie, {
ship: botShipName,
ssrfPolicy,
logger: {
log: (message) => runtime.log?.(message),
error: (message) => runtime.error?.(message),
}, // Re-authenticate on reconnect in case the session expired
onReconnect: async (client) => {
runtime.log?.("[tlon] Re-authenticating on SSE reconnect..."); const newCookie = await authenticateWithRetry(5);
client.updateCookie(newCookie);
runtime.log?.("[tlon] Re-authentication successful");
},
});
const processedTracker = createProcessedMessageTracker(2000);
let groupChannels: string[] = [];
let botNickname: string | null = null;
// Reactive state that can be updated via settings store
let effectiveDmAllowlist: string[] = account.dmAllowlist;
let effectiveShowModelSig: boolean = account.showModelSignature ?? false;
let effectiveAutoAcceptDmInvites: boolean = account.autoAcceptDmInvites ?? false;
let effectiveAutoAcceptGroupInvites: boolean = account.autoAcceptGroupInvites ?? false;
let effectiveGroupInviteAllowlist: string[] = account.groupInviteAllowlist;
let effectiveAutoDiscoverChannels: boolean = account.autoDiscoverChannels ?? false;
let effectiveOwnerShip: string | null = account.ownerShip
? normalizeShip(account.ownerShip)
: null;
let pendingApprovals: PendingApproval[] = [];
let currentSettings: TlonSettingsStore = {};
// Track threads we've participated in (by parentId) - respond without mention requirement const participatedThreads = new Set<string>();
// Track DM senders per session to detect shared sessions (security warning) const dmSendersBySession = new Map<string, Set<string>>();
let sharedSessionWarningSent = false;
// Fetch bot's nickname from contacts try { const selfProfile = await api.scry("/contacts/v1/self.json"); if (selfProfile && typeof selfProfile === "object") { const profile = selfProfile as { nickname?: { value?: string } };
botNickname = profile.nickname?.value || null; if (botNickname) {
runtime.log?.(`[tlon] Bot nickname: ${botNickname}`);
}
}
} catch (error: unknown) {
runtime.log?.(`[tlon] Could not fetch nickname: ${formatErrorMessage(error)}`);
}
// Store init foreigns for processing after settings are loaded
let initForeigns: Foreigns | null = null;
// Migrate file config to settings store (seed on first run)
async function migrateConfigToSettings() { const migrations = buildTlonSettingsMigrations(account, currentSettings);
for (const { key, fileValue, settingsValue } of migrations) { if (shouldMigrateTlonSetting(fileValue, settingsValue)) { try {
await api!.poke({
app: "settings",
mark: "settings-event",
json: { "put-entry": { "bucket-key": "tlon", "entry-key": key,
value: fileValue,
desk: "moltbot",
},
},
});
runtime.log?.(`[tlon] Migrated ${key} from config to settings store`);
} catch (err) {
runtime.log?.(`[tlon] Failed to migrate ${key}: ${String(err)}`);
}
}
}
}
// Load settings from settings store (hot-reloadable config) try {
currentSettings = await settingsManager.load();
// Migrate file config to settings store if not already present
await migrateConfigToSettings();
({
effectiveDmAllowlist,
effectiveShowModelSig,
effectiveAutoAcceptDmInvites,
effectiveAutoAcceptGroupInvites,
effectiveGroupInviteAllowlist,
effectiveAutoDiscoverChannels,
effectiveOwnerShip,
pendingApprovals,
currentSettings,
} = applyTlonSettingsOverrides({
account,
currentSettings,
log: (message) => runtime.log?.(message),
}));
} catch (err) {
runtime.log?.(`[tlon] Settings store not available, using file config: ${String(err)}`);
}
// Run channel discovery AFTER settings are loaded (so settings store value is used) if (effectiveAutoDiscoverChannels) { try { const initData = await fetchInitData(api, runtime); if (initData.channels.length > 0) {
groupChannels = initData.channels;
}
initForeigns = initData.foreigns;
} catch (error: unknown) {
runtime.error?.(`[tlon] Auto-discovery failed: ${formatErrorMessage(error)}`);
}
}
// Merge manual config with auto-discovered channels if (account.groupChannels.length > 0) {
groupChannels = mergeUniqueStrings(groupChannels, account.groupChannels);
runtime.log?.(
`[tlon] Added ${account.groupChannels.length} manual groupChannels to monitoring`,
);
}
// Also merge settings store groupChannels (may have been set via tlon settings command)
groupChannels = mergeUniqueStrings(groupChannels, currentSettings.groupChannels);
if (groupChannels.length > 0) {
runtime.log?.(
`[tlon] Monitoring ${groupChannels.length} group channel(s): ${groupChannels.join(", ")}`,
);
} else {
runtime.log?.("[tlon] No group channels to monitor (DMs only)");
}
// Check if a ship is the owner (always allowed to DM) function isOwner(ship: string): boolean { if (!effectiveOwnerShip) { returnfalse;
} return normalizeShip(ship) === effectiveOwnerShip;
}
// Download any images from the message content
let attachments: Array<{ path: string; contentType: string }> = []; if (messageContent) { try {
attachments = await downloadMessageImages(messageContent); if (attachments.length > 0) {
runtime.log?.(`[tlon] Downloaded ${attachments.length} image(s) from message`);
}
} catch (error: unknown) {
runtime.log?.(`[tlon] Failed to download images: ${formatErrorMessage(error)}`);
}
}
// Fetch thread context when entering a thread for the first time if (isThreadReply && parentId && groupChannel) { try { const threadHistory = await fetchThreadHistory(api, groupChannel, parentId, 20, runtime); if (threadHistory.length > 0) { const threadContext = threadHistory
.slice(-10) // Last 10 messages for context
.map((msg) => `${msg.author}: ${msg.content}`)
.join("\n");
// Prepend thread context to the message // Include note about ongoing conversation for agent judgment const contextNote = `[Thread conversation - ${threadHistory.length} previous replies. You are participating in thisthread. Only respond if relevant or helpful - you don't need to reply to every message.]`;
messageText = `${contextNote}\n\n[Previous messages]\n${threadContext}\n\n[Current message]\n${messageText}`;
runtime?.log?.(
`[tlon] Added thread context (${threadHistory.length} replies) to message`,
);
}
} catch (error: unknown) {
runtime?.log?.(`[tlon] Could not fetch thread context: ${formatErrorMessage(error)}`); // Continue without thread context - not critical
}
}
if (isGroup && groupChannel && isSummarizationRequest(messageText)) { try { const history = await getChannelHistory(api, groupChannel, 50, runtime); if (history.length === 0) { const noHistoryMsg = "I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue."; if (isGroup) { const parsed = parseChannelNest(groupChannel); if (parsed) {
await sendGroupMessage({
api: api,
fromShip: botShipName,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
text: noHistoryMsg,
});
}
} else {
await sendDm({
api: api,
fromShip: botShipName,
toShip: senderShip,
text: noHistoryMsg,
});
} return;
}
// Log when non-owner attempts a slash command (will be silently ignored by Gateway) if (!commandAuthorized) {
console.log(
`[tlon] Command attempt denied: ${senderShip} is not owner (owner=${effectiveOwnerShip ?? "not configured"})`,
);
}
}
// Prepend attachment annotations to message body (similar to Signal format)
let bodyWithAttachments = messageText; if (attachments.length > 0) { const mediaLines = attachments
.map((a) => `[media attached: ${a.path} (${a.contentType}) | ${a.path}]`)
.join("\n");
bodyWithAttachments = mediaLines + "\n" + messageText;
}
// Track which channels we're interested in for filtering firehose events const watchedChannels = new Set<string>(groupChannels); const _watchedDMs = new Set<string>();
const refreshWatchedChannels = async (): Promise<number> => { const discoveredChannels = await fetchAllChannels(api, runtime);
let newCount = 0; for (const channelNest of discoveredChannels) { if (!watchedChannels.has(channelNest)) {
watchedChannels.add(channelNest);
newCount++;
}
} return newCount;
};
// Get thread info early for participation check const seal = isThreadReply ? asRecord(replySet?.seal) : asRecord(set?.seal); const parentId = readString(seal, "parent-id") ?? readString(seal, "parent") ?? null;
// Check if we should respond: // 1. Direct mention always triggers response // 2. Thread replies where we've participated - respond if relevant (let agent decide) const mentioned = isBotMentioned(rawText, botShipName, botNickname ?? undefined); const inParticipatedThread =
isThreadReply && parentId && participatedThreads.has(parentId);
if (!mentioned && !inParticipatedThread) { return;
}
// Log why we're responding if (inParticipatedThread && !mentioned) {
runtime.log?.(
`[tlon] Responding to thread we participated in (no mention): ${parentId}`,
);
}
// Firehose handler for all DM messages (/v3) // Track which DM invites we've already processed to avoid duplicate accepts const processedDmInvites = new Set<string>();
const handleChatFirehose = async (event: unknown) => { try { // Handle DM invite lists (arrays) if (Array.isArray(event)) { for (const invite of event as DmInvite[]) { const ship = normalizeShip(invite.ship || ""); if (!ship || processedDmInvites.has(ship)) { continue;
}
// Owner is always allowed if (isOwner(ship)) { try {
await api.poke({
app: "chat",
mark: "chat-dm-rsvp",
json: { ship, ok: true },
});
processedDmInvites.add(ship);
runtime.log?.(`[tlon] Auto-accepted DM invite from owner ${ship}`);
} catch (err) {
runtime.error?.(`[tlon] Failed to auto-accept DM from owner: ${String(err)}`);
} continue;
}
// Auto-accept if on allowlist and auto-accept is enabled if (effectiveAutoAcceptDmInvites && isDmAllowed(ship, effectiveDmAllowlist)) { try {
await api.poke({
app: "chat",
mark: "chat-dm-rsvp",
json: { ship, ok: true },
});
processedDmInvites.add(ship);
runtime.log?.(`[tlon] Auto-accepted DM invite from ${ship}`);
} catch (err) {
runtime.error?.(`[tlon] Failed to auto-accept DM from ${ship}: ${String(err)}`);
} continue;
}
// If owner is configured and ship is not on allowlist, queue approval if (effectiveOwnerShip && !isDmAllowed(ship, effectiveDmAllowlist)) { const approval = createPendingApproval({
type: "dm",
requestingShip: ship,
messagePreview: "(DM invite - no message yet)",
});
await queueApprovalRequest(approval);
processedDmInvites.add(ship); // Mark as processed to avoid duplicate notifications
}
} return;
} const eventRecord = asRecord(event); if (!eventRecord) { return;
}
const whom = eventRecord.whom; // DM partner ship or club ID const messageId = readString(eventRecord, "id"); const response = asRecord(eventRecord.response); if (!messageId || !response) { return;
}
// Handle add events (new messages) const essay = asRecord(asRecord(response.add)?.essay); if (!essay) { return;
}
// Ignore the bot's own outbound DM events. if (authorShip === botShipName) { return;
} if (!senderShip || senderShip === botShipName) { return;
}
// Log mismatch between author and partner for debugging if (authorShip && partnerShip && authorShip !== partnerShip) {
runtime.log?.(
`[tlon] DM ship mismatch (author=${authorShip}, partner=${partnerShip}) - routing to partner`,
);
}
const rawText = extractMessageText(essay.content); if (!rawText.trim()) { return;
}
// Check if this is the owner sending an approval response const messageText = rawText; if (isOwner(senderShip) && isApprovalResponse(messageText)) { const handled = await handleApprovalResponse(messageText); if (handled) {
runtime.log?.(`[tlon] Processed approval response from owner: ${messageText}`); return;
}
}
// Check if this is the owner sending an admin command if (isOwner(senderShip) && isAdminCommand(messageText)) { const handled = await handleAdminCommand(messageText); if (handled) {
runtime.log?.(`[tlon] Processed admin command from owner: ${messageText}`); return;
}
}
// Subscribe to settings store for hot-reloading config
settingsManager.onChange((newSettings) => {
currentSettings = newSettings;
// Update watched channels if settings changed if (newSettings.groupChannels?.length) { const newChannels = newSettings.groupChannels; for (const ch of newChannels) { if (!watchedChannels.has(ch)) {
watchedChannels.add(ch);
runtime.log?.(`[tlon] Settings: now watching channel ${ch}`);
}
} // Note: we don't remove channels from watchedChannels to avoid missing messages // during transitions. The authorization check handles access control.
}
// Recompute effective settings from the latest snapshot so deletions // cleanly fall back to file config and empty arrays remain authoritative.
({
effectiveDmAllowlist,
effectiveShowModelSig,
effectiveAutoAcceptDmInvites,
effectiveAutoAcceptGroupInvites,
effectiveGroupInviteAllowlist,
effectiveAutoDiscoverChannels,
effectiveOwnerShip,
pendingApprovals,
} = applyTlonSettingsOverrides({
account,
currentSettings: newSettings,
log: (message) => runtime.log?.(message),
}));
});
try {
await settingsManager.startSubscription();
} catch (err) { // Settings subscription is optional - don't fail if it doesn't work
runtime.log?.(`[tlon] Settings subscription not available: ${String(err)}`);
}
// Subscribe to groups-ui for real-time channel additions (when invites are accepted) try {
await api.subscribe({
app: "groups",
path: "/groups/ui",
event: async (event: unknown) => { try { const eventRecord = asRecord(event); // Handle group/channel join events // Event structure: { group: { flag: "~host/group-name", ... }, channels: { ... } } if (eventRecord) { // Check for new channels being added to groups const channels = asRecord(eventRecord.channels); if (channels) { for (const [channelNest, _channelData] of Object.entries(channels)) { // Only monitor chat channels if (!channelNest.startsWith("chat/")) { continue;
}
// If this is a new channel we're not watching yet, add it if (!watchedChannels.has(channelNest)) {
watchedChannels.add(channelNest);
runtime.log?.(
`[tlon] Auto-detected new channel (invite accepted): ${channelNest}`,
);
// Persist to settings store so it survives restarts if (effectiveAutoAcceptGroupInvites) { try { const currentChannels = currentSettings.groupChannels || []; if (!currentChannels.includes(channelNest)) { const updatedChannels = [...currentChannels, channelNest]; // Poke settings store to persist
await api.poke({
app: "settings",
mark: "settings-event",
json: { "put-entry": { "bucket-key": "tlon", "entry-key": "groupChannels",
value: updatedChannels,
desk: "moltbot",
},
},
});
runtime.log?.(`[tlon] Persisted ${channelNest} to settings store`);
}
} catch (err) {
runtime.error?.(
`[tlon] Failed to persist channel to settings: ${String(err)}`,
);
}
}
}
}
}
// Also check for the "join" event structure const join = asRecord(eventRecord.join); if (join) { const joinChannels = Array.isArray(join.channels) ? join.channels : []; if (joinChannels.length > 0) { for (const channelNest of joinChannels) { if (typeof channelNest !== "string") { continue;
} if (!channelNest.startsWith("chat/")) { continue;
} if (!watchedChannels.has(channelNest)) {
watchedChannels.add(channelNest);
runtime.log?.(`[tlon] Auto-detected joined channel: ${channelNest}`);
// Persist to settings store if (effectiveAutoAcceptGroupInvites) { try { const currentChannels = currentSettings.groupChannels || []; if (!currentChannels.includes(channelNest)) { const updatedChannels = [...currentChannels, channelNest];
await api.poke({
app: "settings",
mark: "settings-event",
json: { "put-entry": { "bucket-key": "tlon", "entry-key": "groupChannels",
value: updatedChannels,
desk: "moltbot",
},
},
});
runtime.log?.(`[tlon] Persisted ${channelNest} to settings store`);
}
} catch (err) {
runtime.error?.(
`[tlon] Failed to persist channel to settings: ${String(err)}`,
);
}
}
}
}
}
}
}
} catch (error: unknown) {
runtime.error?.(`[tlon] Error handling groups-ui event: ${formatErrorMessage(error)}`);
}
},
err: (error) => {
runtime.error?.(`[tlon] Groups-ui subscription error: ${String(error)}`);
},
quit: () => {
runtime.log?.("[tlon] Groups-ui subscription ended");
},
});
runtime.log?.("[tlon] Subscribed to groups-ui for real-time channel detection");
} catch (err) { // Groups-ui subscription is optional - channel discovery will still work via polling
runtime.log?.(`[tlon] Groups-ui subscription failed (will rely on polling): ${String(err)}`);
}
// Subscribe to foreigns for auto-accepting group invites // Always subscribe so we can hot-reload the setting via settings store
{ const processedGroupInvites = new Set<string>();
// Helper to process pending invites const processPendingInvites = async (foreigns: Foreigns) => { if (!foreigns || typeof foreigns !== "object") { return;
}
for (const [groupFlag, foreign] of Object.entries(foreigns)) { if (processedGroupInvites.has(groupFlag)) { continue;
} if (!foreign.invites || foreign.invites.length === 0) { continue;
}
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.