1484 lines
52 KiB
TypeScript
1484 lines
52 KiB
TypeScript
import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "../../api.js";
|
|
import { createLoggerBackedRuntime, createReplyPrefixOptions } from "../../api.js";
|
|
import { getTlonRuntime } from "../runtime.js";
|
|
import { createSettingsManager, type TlonSettingsStore } from "../settings.js";
|
|
import { normalizeShip, parseChannelNest } from "../targets.js";
|
|
import { resolveTlonAccount } from "../types.js";
|
|
import { authenticate } from "../urbit/auth.js";
|
|
import { ssrfPolicyFromAllowPrivateNetwork } from "../urbit/context.js";
|
|
import type { Foreigns, DmInvite } from "../urbit/foreigns.js";
|
|
import { sendDm, sendGroupMessage } from "../urbit/send.js";
|
|
import { UrbitSSEClient } from "../urbit/sse-client.js";
|
|
import { createTlonApprovalRuntime } from "./approval-runtime.js";
|
|
import {
|
|
type PendingApproval,
|
|
type AdminCommand,
|
|
createPendingApproval,
|
|
isApprovalResponse,
|
|
isAdminCommand,
|
|
} from "./approval.js";
|
|
import { resolveChannelAuthorization } from "./authorization.js";
|
|
import { createTlonCitationResolver } from "./cites.js";
|
|
import { fetchAllChannels, fetchInitData } from "./discovery.js";
|
|
import { cacheMessage, getChannelHistory, fetchThreadHistory } from "./history.js";
|
|
import { downloadMessageImages } from "./media.js";
|
|
import { createProcessedMessageTracker } from "./processed-messages.js";
|
|
import {
|
|
applyTlonSettingsOverrides,
|
|
buildTlonSettingsMigrations,
|
|
mergeUniqueStrings,
|
|
} from "./settings-helpers.js";
|
|
import {
|
|
extractMessageText,
|
|
extractCites,
|
|
formatModelName,
|
|
isBotMentioned,
|
|
stripBotMention,
|
|
isDmAllowed,
|
|
isSummarizationRequest,
|
|
resolveAuthorizedMessageText,
|
|
type ParsedCite,
|
|
} from "./utils.js";
|
|
|
|
export type MonitorTlonOpts = {
|
|
runtime?: RuntimeEnv;
|
|
abortSignal?: AbortSignal;
|
|
accountId?: string | null;
|
|
};
|
|
|
|
export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<void> {
|
|
const core = getTlonRuntime();
|
|
const cfg = core.config.loadConfig() as OpenClawConfig;
|
|
if (cfg.channels?.tlon?.enabled === false) {
|
|
return;
|
|
}
|
|
|
|
const logger = core.logging.getChildLogger({ module: "tlon-auto-reply" });
|
|
const runtime: RuntimeEnv =
|
|
opts.runtime ??
|
|
createLoggerBackedRuntime({
|
|
logger,
|
|
});
|
|
|
|
const account = resolveTlonAccount(cfg, opts.accountId ?? undefined);
|
|
if (!account.enabled) {
|
|
return;
|
|
}
|
|
if (!account.configured || !account.ship || !account.url || !account.code) {
|
|
throw new Error("Tlon account not configured (ship/url/code required)");
|
|
}
|
|
|
|
const botShipName = normalizeShip(account.ship);
|
|
runtime.log?.(`[tlon] Starting monitor for ${botShipName}`);
|
|
|
|
const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork);
|
|
|
|
// 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) {
|
|
throw new Error("Aborted while waiting to authenticate");
|
|
}
|
|
try {
|
|
runtime.log?.(`[tlon] Attempting authentication to ${accountUrl}...`);
|
|
return await authenticate(accountUrl, accountCode, { ssrfPolicy });
|
|
} catch (error: any) {
|
|
runtime.error?.(
|
|
`[tlon] Failed to authenticate (attempt ${attempt}): ${error?.message ?? String(error)}`,
|
|
);
|
|
if (attempt >= maxAttempts) {
|
|
throw error;
|
|
}
|
|
const delay = Math.min(30000, 1000 * Math.pow(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;
|
|
|
|
// Settings store manager for hot-reloading config
|
|
const settingsManager = createSettingsManager(api, {
|
|
log: (msg) => runtime.log?.(msg),
|
|
error: (msg) => runtime.error?.(msg),
|
|
});
|
|
|
|
// 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: any) {
|
|
runtime.log?.(`[tlon] Could not fetch nickname: ${error?.message ?? String(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) {
|
|
// Only migrate if file has a value and settings store doesn't
|
|
const hasFileValue = Array.isArray(fileValue) ? fileValue.length > 0 : fileValue != null;
|
|
const hasSettingsValue = Array.isArray(settingsValue)
|
|
? settingsValue.length > 0
|
|
: settingsValue != null;
|
|
|
|
if (hasFileValue && !hasSettingsValue) {
|
|
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: any) {
|
|
runtime.error?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(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) {
|
|
return false;
|
|
}
|
|
return normalizeShip(ship) === effectiveOwnerShip;
|
|
}
|
|
|
|
/**
|
|
* Extract the DM partner ship from the 'whom' field.
|
|
* This is the canonical source for DM routing (more reliable than essay.author).
|
|
* Returns empty string if whom doesn't contain a valid patp-like value.
|
|
*/
|
|
function extractDmPartnerShip(whom: unknown): string {
|
|
const raw =
|
|
typeof whom === "string"
|
|
? whom
|
|
: whom && typeof whom === "object" && "ship" in whom && typeof whom.ship === "string"
|
|
? whom.ship
|
|
: "";
|
|
const normalized = normalizeShip(raw);
|
|
// Keep DM routing strict: accept only patp-like values.
|
|
return /^~?[a-z-]+$/i.test(normalized) ? normalized : "";
|
|
}
|
|
|
|
const processMessage = async (params: {
|
|
messageId: string;
|
|
senderShip: string;
|
|
messageText: string;
|
|
messageContent?: unknown; // Raw Tlon content for media extraction
|
|
isGroup: boolean;
|
|
channelNest?: string;
|
|
hostShip?: string;
|
|
channelName?: string;
|
|
timestamp: number;
|
|
parentId?: string | null;
|
|
isThreadReply?: boolean;
|
|
}) => {
|
|
const {
|
|
messageId,
|
|
senderShip,
|
|
isGroup,
|
|
channelNest,
|
|
hostShip,
|
|
channelName,
|
|
timestamp,
|
|
parentId,
|
|
isThreadReply,
|
|
messageContent,
|
|
} = params;
|
|
const groupChannel = channelNest; // For compatibility
|
|
let messageText = params.messageText;
|
|
|
|
// 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: any) {
|
|
runtime.log?.(`[tlon] Failed to download images: ${error?.message ?? String(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 this thread. 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: any) {
|
|
runtime?.log?.(`[tlon] Could not fetch thread context: ${error?.message ?? String(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;
|
|
}
|
|
|
|
const historyText = history
|
|
.map(
|
|
(msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`,
|
|
)
|
|
.join("\n");
|
|
|
|
messageText =
|
|
`Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\n` +
|
|
"Provide a concise summary highlighting:\n" +
|
|
"1. Main topics discussed\n" +
|
|
"2. Key decisions or conclusions\n" +
|
|
"3. Action items if any\n" +
|
|
"4. Notable participants";
|
|
} catch (error: any) {
|
|
const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error?.message ?? String(error)}`;
|
|
if (isGroup && groupChannel) {
|
|
const parsed = parseChannelNest(groupChannel);
|
|
if (parsed) {
|
|
await sendGroupMessage({
|
|
api: api,
|
|
fromShip: botShipName,
|
|
hostShip: parsed.hostShip,
|
|
channelName: parsed.channelName,
|
|
text: errorMsg,
|
|
});
|
|
}
|
|
} else {
|
|
await sendDm({ api: api, fromShip: botShipName, toShip: senderShip, text: errorMsg });
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const route = core.channel.routing.resolveAgentRoute({
|
|
cfg,
|
|
channel: "tlon",
|
|
accountId: opts.accountId ?? undefined,
|
|
peer: {
|
|
kind: isGroup ? "group" : "direct",
|
|
id: isGroup ? (groupChannel ?? senderShip) : senderShip,
|
|
},
|
|
});
|
|
|
|
// Warn if multiple users share a DM session (insecure dmScope configuration)
|
|
if (!isGroup) {
|
|
const sessionKey = route.sessionKey;
|
|
if (!dmSendersBySession.has(sessionKey)) {
|
|
dmSendersBySession.set(sessionKey, new Set());
|
|
}
|
|
const senders = dmSendersBySession.get(sessionKey)!;
|
|
if (senders.size > 0 && !senders.has(senderShip)) {
|
|
// Log warning
|
|
runtime.log?.(
|
|
`[tlon] ⚠️ SECURITY: Multiple users sharing DM session. ` +
|
|
`Configure "session.dmScope: per-channel-peer" in OpenClaw config.`,
|
|
);
|
|
|
|
// Notify owner via DM (once per monitor session)
|
|
if (!sharedSessionWarningSent && effectiveOwnerShip) {
|
|
sharedSessionWarningSent = true;
|
|
const warningMsg =
|
|
`⚠️ Security Warning: Multiple users are sharing a DM session with this bot. ` +
|
|
`This can leak conversation context between users.\n\n` +
|
|
`Fix: Add to your OpenClaw config:\n` +
|
|
`session:\n dmScope: "per-channel-peer"\n\n` +
|
|
`Docs: https://docs.openclaw.ai/concepts/session#secure-dm-mode`;
|
|
|
|
// Send async, don't block message processing
|
|
sendDm({
|
|
api,
|
|
fromShip: botShipName,
|
|
toShip: effectiveOwnerShip,
|
|
text: warningMsg,
|
|
}).catch((err) =>
|
|
runtime.error?.(`[tlon] Failed to send security warning to owner: ${err}`),
|
|
);
|
|
}
|
|
}
|
|
senders.add(senderShip);
|
|
}
|
|
|
|
const senderRole = isOwner(senderShip) ? "owner" : "user";
|
|
const fromLabel = isGroup
|
|
? `${senderShip} [${senderRole}] in ${channelNest}`
|
|
: `${senderShip} [${senderRole}]`;
|
|
|
|
// Compute command authorization for slash commands (owner-only)
|
|
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(
|
|
messageText,
|
|
cfg,
|
|
);
|
|
let commandAuthorized = false;
|
|
|
|
if (shouldComputeAuth) {
|
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
const senderIsOwner = isOwner(senderShip);
|
|
|
|
commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
useAccessGroups,
|
|
authorizers: [{ configured: Boolean(effectiveOwnerShip), allowed: senderIsOwner }],
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
|
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
channel: "Tlon",
|
|
from: fromLabel,
|
|
timestamp,
|
|
body: bodyWithAttachments,
|
|
});
|
|
|
|
// Strip bot ship mention for CommandBody so "/status" is recognized as command-only
|
|
const commandBody = isGroup ? stripBotMention(messageText, botShipName) : messageText;
|
|
|
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
Body: body,
|
|
RawBody: messageText,
|
|
CommandBody: commandBody,
|
|
From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
|
|
To: `tlon:${botShipName}`,
|
|
SessionKey: route.sessionKey,
|
|
AccountId: route.accountId,
|
|
ChatType: isGroup ? "group" : "direct",
|
|
ConversationLabel: fromLabel,
|
|
SenderName: senderShip,
|
|
SenderId: senderShip,
|
|
SenderRole: senderRole,
|
|
CommandAuthorized: commandAuthorized,
|
|
CommandSource: "text" as const,
|
|
Provider: "tlon",
|
|
Surface: "tlon",
|
|
MessageSid: messageId,
|
|
// Include downloaded media attachments
|
|
...(attachments.length > 0 && { Attachments: attachments }),
|
|
OriginatingChannel: "tlon",
|
|
OriginatingTo: `tlon:${isGroup ? groupChannel : botShipName}`,
|
|
// Include thread context for automatic reply routing
|
|
...(parentId && { ThreadId: String(parentId), ReplyToId: String(parentId) }),
|
|
});
|
|
|
|
const dispatchStartTime = Date.now();
|
|
|
|
const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(
|
|
cfg,
|
|
route.agentId,
|
|
).responsePrefix;
|
|
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
|
|
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
ctx: ctxPayload,
|
|
cfg,
|
|
dispatcherOptions: {
|
|
responsePrefix,
|
|
humanDelay,
|
|
deliver: async (payload: ReplyPayload) => {
|
|
let replyText = payload.text;
|
|
if (!replyText) {
|
|
return;
|
|
}
|
|
|
|
// Use settings store value if set, otherwise fall back to file config
|
|
const showSignature = effectiveShowModelSig;
|
|
if (showSignature) {
|
|
const extPayload = payload as ReplyPayload & {
|
|
metadata?: { model?: string };
|
|
model?: string;
|
|
};
|
|
const extRoute = route as typeof route & { model?: string };
|
|
const defaultModel = cfg.agents?.defaults?.model;
|
|
const modelInfo =
|
|
extPayload.metadata?.model ||
|
|
extPayload.model ||
|
|
extRoute.model ||
|
|
(typeof defaultModel === "string" ? defaultModel : defaultModel?.primary);
|
|
extPayload.metadata?.model ||
|
|
extPayload.model ||
|
|
extRoute.model ||
|
|
(typeof defaultModel === "string" ? defaultModel : defaultModel?.primary);
|
|
replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
|
|
}
|
|
|
|
if (isGroup && groupChannel) {
|
|
const parsed = parseChannelNest(groupChannel);
|
|
if (!parsed) {
|
|
return;
|
|
}
|
|
await sendGroupMessage({
|
|
api: api,
|
|
fromShip: botShipName,
|
|
hostShip: parsed.hostShip,
|
|
channelName: parsed.channelName,
|
|
text: replyText,
|
|
replyToId: parentId ?? undefined,
|
|
});
|
|
// Track thread participation for future replies without mention
|
|
if (parentId) {
|
|
participatedThreads.add(String(parentId));
|
|
runtime.log?.(`[tlon] Now tracking thread for future replies: ${parentId}`);
|
|
}
|
|
} else {
|
|
await sendDm({ api: api, fromShip: botShipName, toShip: senderShip, text: replyText });
|
|
}
|
|
},
|
|
onError: (err, info) => {
|
|
const dispatchDuration = Date.now() - dispatchStartTime;
|
|
runtime.error?.(
|
|
`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`,
|
|
);
|
|
},
|
|
},
|
|
});
|
|
};
|
|
|
|
// 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;
|
|
};
|
|
|
|
const { resolveAllCites } = createTlonCitationResolver({
|
|
api: { scry: (path) => api!.scry(path) },
|
|
runtime,
|
|
});
|
|
|
|
const { queueApprovalRequest, handleApprovalResponse, handleAdminCommand } =
|
|
createTlonApprovalRuntime({
|
|
api: {
|
|
poke: (payload) => api!.poke(payload),
|
|
scry: (path) => api!.scry(path),
|
|
},
|
|
runtime,
|
|
botShipName,
|
|
getPendingApprovals: () => pendingApprovals,
|
|
setPendingApprovals: (approvals) => {
|
|
pendingApprovals = approvals;
|
|
},
|
|
getCurrentSettings: () => currentSettings,
|
|
setCurrentSettings: (settings) => {
|
|
currentSettings = settings;
|
|
},
|
|
getEffectiveDmAllowlist: () => effectiveDmAllowlist,
|
|
setEffectiveDmAllowlist: (ships) => {
|
|
effectiveDmAllowlist = ships;
|
|
},
|
|
getEffectiveOwnerShip: () => effectiveOwnerShip,
|
|
processApprovedMessage: async (approval) => {
|
|
if (!approval.originalMessage) {
|
|
return;
|
|
}
|
|
if (approval.type === "dm") {
|
|
await processMessage({
|
|
messageId: approval.originalMessage.messageId,
|
|
senderShip: approval.requestingShip,
|
|
messageText: approval.originalMessage.messageText,
|
|
messageContent: approval.originalMessage.messageContent,
|
|
isGroup: false,
|
|
timestamp: approval.originalMessage.timestamp,
|
|
});
|
|
return;
|
|
}
|
|
if (approval.type === "channel" && approval.channelNest) {
|
|
const parsedChannel = parseChannelNest(approval.channelNest);
|
|
await processMessage({
|
|
messageId: approval.originalMessage.messageId,
|
|
senderShip: approval.requestingShip,
|
|
messageText: approval.originalMessage.messageText,
|
|
messageContent: approval.originalMessage.messageContent,
|
|
isGroup: true,
|
|
channelNest: approval.channelNest,
|
|
hostShip: parsedChannel?.hostShip,
|
|
channelName: parsedChannel?.channelName,
|
|
timestamp: approval.originalMessage.timestamp,
|
|
parentId: approval.originalMessage.parentId,
|
|
isThreadReply: approval.originalMessage.isThreadReply,
|
|
});
|
|
}
|
|
},
|
|
refreshWatchedChannels,
|
|
});
|
|
|
|
// Firehose handler for all channel messages (/v2)
|
|
const handleChannelsFirehose = async (event: any) => {
|
|
try {
|
|
const nest = event?.nest;
|
|
if (!nest) {
|
|
return;
|
|
}
|
|
|
|
// Only process channels we're watching
|
|
if (!watchedChannels.has(nest)) {
|
|
return;
|
|
}
|
|
|
|
const response = event?.response;
|
|
if (!response) {
|
|
return;
|
|
}
|
|
|
|
// Handle post responses (new posts and replies)
|
|
const essay = response?.post?.["r-post"]?.set?.essay;
|
|
const memo = response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
|
|
if (!essay && !memo) {
|
|
return;
|
|
}
|
|
|
|
const content = memo || essay;
|
|
const isThreadReply = Boolean(memo);
|
|
const messageId = isThreadReply ? response?.post?.["r-post"]?.reply?.id : response?.post?.id;
|
|
|
|
if (!processedTracker.mark(messageId)) {
|
|
return;
|
|
}
|
|
|
|
const senderShip = normalizeShip(content.author ?? "");
|
|
if (!senderShip || senderShip === botShipName) {
|
|
return;
|
|
}
|
|
|
|
const rawText = extractMessageText(content.content);
|
|
if (!rawText.trim()) {
|
|
return;
|
|
}
|
|
|
|
cacheMessage(nest, {
|
|
author: senderShip,
|
|
content: rawText,
|
|
timestamp: content.sent || Date.now(),
|
|
id: messageId,
|
|
});
|
|
|
|
// Get thread info early for participation check
|
|
const seal = isThreadReply
|
|
? response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
|
|
: response?.post?.["r-post"]?.set?.seal;
|
|
const parentId = seal?.["parent-id"] || 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(String(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}`);
|
|
}
|
|
|
|
// Owner is always allowed
|
|
if (isOwner(senderShip)) {
|
|
runtime.log?.(`[tlon] Owner ${senderShip} is always allowed in channels`);
|
|
} else {
|
|
const { mode, allowedShips } = resolveChannelAuthorization(cfg, nest, currentSettings);
|
|
if (mode === "restricted") {
|
|
const normalizedAllowed = allowedShips.map(normalizeShip);
|
|
if (!normalizedAllowed.includes(senderShip)) {
|
|
// If owner is configured, queue approval request
|
|
if (effectiveOwnerShip) {
|
|
const approval = createPendingApproval({
|
|
type: "channel",
|
|
requestingShip: senderShip,
|
|
channelNest: nest,
|
|
messagePreview: rawText.substring(0, 100),
|
|
originalMessage: {
|
|
messageId: messageId ?? "",
|
|
messageText: rawText,
|
|
messageContent: content.content,
|
|
timestamp: content.sent || Date.now(),
|
|
parentId: parentId ?? undefined,
|
|
isThreadReply,
|
|
},
|
|
});
|
|
await queueApprovalRequest(approval);
|
|
} else {
|
|
runtime.log?.(
|
|
`[tlon] Access denied: ${senderShip} in ${nest} (allowed: ${allowedShips.join(", ")})`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const messageText = await resolveAuthorizedMessageText({
|
|
rawText,
|
|
content: content.content,
|
|
authorizedForCites: true,
|
|
resolveAllCites,
|
|
});
|
|
|
|
const parsed = parseChannelNest(nest);
|
|
await processMessage({
|
|
messageId: messageId ?? "",
|
|
senderShip,
|
|
messageText,
|
|
messageContent: content.content, // Pass raw content for media extraction
|
|
isGroup: true,
|
|
channelNest: nest,
|
|
hostShip: parsed?.hostShip,
|
|
channelName: parsed?.channelName,
|
|
timestamp: content.sent || Date.now(),
|
|
parentId,
|
|
isThreadReply,
|
|
});
|
|
} catch (error: any) {
|
|
runtime.error?.(
|
|
`[tlon] Error handling channel firehose event: ${error?.message ?? String(error)}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
// 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: any) => {
|
|
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;
|
|
}
|
|
if (!("whom" in event) || !("response" in event)) {
|
|
return;
|
|
}
|
|
|
|
const whom = event.whom; // DM partner ship or club ID
|
|
const messageId = event.id;
|
|
const response = event.response;
|
|
|
|
// Handle add events (new messages)
|
|
const essay = response?.add?.essay;
|
|
if (!essay) {
|
|
return;
|
|
}
|
|
|
|
if (!processedTracker.mark(messageId)) {
|
|
return;
|
|
}
|
|
|
|
const authorShip = normalizeShip(essay.author ?? "");
|
|
const partnerShip = extractDmPartnerShip(whom);
|
|
const senderShip = partnerShip || authorShip;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// Owner is always allowed to DM (bypass allowlist)
|
|
if (isOwner(senderShip)) {
|
|
const resolvedMessageText = await resolveAuthorizedMessageText({
|
|
rawText,
|
|
content: essay.content,
|
|
authorizedForCites: true,
|
|
resolveAllCites,
|
|
});
|
|
runtime.log?.(`[tlon] Processing DM from owner ${senderShip}`);
|
|
await processMessage({
|
|
messageId: messageId ?? "",
|
|
senderShip,
|
|
messageText: resolvedMessageText,
|
|
messageContent: essay.content,
|
|
isGroup: false,
|
|
timestamp: essay.sent || Date.now(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
// For DMs from others, check allowlist
|
|
if (!isDmAllowed(senderShip, effectiveDmAllowlist)) {
|
|
// If owner is configured, queue approval request
|
|
if (effectiveOwnerShip) {
|
|
const approval = createPendingApproval({
|
|
type: "dm",
|
|
requestingShip: senderShip,
|
|
messagePreview: messageText.substring(0, 100),
|
|
originalMessage: {
|
|
messageId: messageId ?? "",
|
|
messageText,
|
|
messageContent: essay.content,
|
|
timestamp: essay.sent || Date.now(),
|
|
},
|
|
});
|
|
await queueApprovalRequest(approval);
|
|
} else {
|
|
runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await processMessage({
|
|
messageText: await resolveAuthorizedMessageText({
|
|
rawText,
|
|
content: essay.content,
|
|
authorizedForCites: true,
|
|
resolveAllCites,
|
|
}),
|
|
messageId: messageId ?? "",
|
|
senderShip,
|
|
messageContent: essay.content, // Pass raw content for media extraction
|
|
isGroup: false,
|
|
timestamp: essay.sent || Date.now(),
|
|
});
|
|
} catch (error: any) {
|
|
runtime.error?.(
|
|
`[tlon] Error handling chat firehose event: ${error?.message ?? String(error)}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
try {
|
|
runtime.log?.("[tlon] Subscribing to firehose updates...");
|
|
|
|
// Subscribe to channels firehose (/v2)
|
|
await api.subscribe({
|
|
app: "channels",
|
|
path: "/v2",
|
|
event: handleChannelsFirehose,
|
|
err: (error) => {
|
|
runtime.error?.(`[tlon] Channels firehose error: ${String(error)}`);
|
|
},
|
|
quit: () => {
|
|
runtime.log?.("[tlon] Channels firehose subscription ended");
|
|
},
|
|
});
|
|
runtime.log?.("[tlon] Subscribed to channels firehose (/v2)");
|
|
|
|
// Subscribe to chat/DM firehose (/v3)
|
|
await api.subscribe({
|
|
app: "chat",
|
|
path: "/v3",
|
|
event: handleChatFirehose,
|
|
err: (error) => {
|
|
runtime.error?.(`[tlon] Chat firehose error: ${String(error)}`);
|
|
},
|
|
quit: () => {
|
|
runtime.log?.("[tlon] Chat firehose subscription ended");
|
|
},
|
|
});
|
|
runtime.log?.("[tlon] Subscribed to chat firehose (/v3)");
|
|
|
|
// Subscribe to contacts updates to track nickname changes
|
|
await api.subscribe({
|
|
app: "contacts",
|
|
path: "/v1/news",
|
|
event: (event: any) => {
|
|
try {
|
|
// Look for self profile updates
|
|
if (event?.self) {
|
|
const selfUpdate = event.self;
|
|
if (selfUpdate?.contact?.nickname?.value !== undefined) {
|
|
const newNickname = selfUpdate.contact.nickname.value || null;
|
|
if (newNickname !== botNickname) {
|
|
botNickname = newNickname;
|
|
runtime.log?.(`[tlon] Nickname updated: ${botNickname}`);
|
|
}
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
runtime.error?.(
|
|
`[tlon] Error handling contacts event: ${error?.message ?? String(error)}`,
|
|
);
|
|
}
|
|
},
|
|
err: (error) => {
|
|
runtime.error?.(`[tlon] Contacts subscription error: ${String(error)}`);
|
|
},
|
|
quit: () => {
|
|
runtime.log?.("[tlon] Contacts subscription ended");
|
|
},
|
|
});
|
|
runtime.log?.("[tlon] Subscribed to contacts updates (/v1/news)");
|
|
|
|
// 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.
|
|
}
|
|
|
|
// Update DM allowlist
|
|
if (newSettings.dmAllowlist !== undefined) {
|
|
effectiveDmAllowlist = newSettings.dmAllowlist;
|
|
runtime.log?.(`[tlon] Settings: dmAllowlist updated to ${effectiveDmAllowlist.join(", ")}`);
|
|
}
|
|
|
|
// Update model signature setting
|
|
if (newSettings.showModelSig !== undefined) {
|
|
effectiveShowModelSig = newSettings.showModelSig;
|
|
runtime.log?.(`[tlon] Settings: showModelSig = ${effectiveShowModelSig}`);
|
|
}
|
|
|
|
// Update auto-accept DM invites setting
|
|
if (newSettings.autoAcceptDmInvites !== undefined) {
|
|
effectiveAutoAcceptDmInvites = newSettings.autoAcceptDmInvites;
|
|
runtime.log?.(`[tlon] Settings: autoAcceptDmInvites = ${effectiveAutoAcceptDmInvites}`);
|
|
}
|
|
|
|
// Update auto-accept group invites setting
|
|
if (newSettings.autoAcceptGroupInvites !== undefined) {
|
|
effectiveAutoAcceptGroupInvites = newSettings.autoAcceptGroupInvites;
|
|
runtime.log?.(
|
|
`[tlon] Settings: autoAcceptGroupInvites = ${effectiveAutoAcceptGroupInvites}`,
|
|
);
|
|
}
|
|
|
|
// Update group invite allowlist
|
|
if (newSettings.groupInviteAllowlist !== undefined) {
|
|
effectiveGroupInviteAllowlist = newSettings.groupInviteAllowlist;
|
|
runtime.log?.(
|
|
`[tlon] Settings: groupInviteAllowlist updated to ${effectiveGroupInviteAllowlist.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
if (newSettings.defaultAuthorizedShips !== undefined) {
|
|
runtime.log?.(
|
|
`[tlon] Settings: defaultAuthorizedShips updated to ${(newSettings.defaultAuthorizedShips || []).join(", ")}`,
|
|
);
|
|
}
|
|
|
|
// Update auto-discover channels
|
|
if (newSettings.autoDiscoverChannels !== undefined) {
|
|
effectiveAutoDiscoverChannels = newSettings.autoDiscoverChannels;
|
|
runtime.log?.(`[tlon] Settings: autoDiscoverChannels = ${effectiveAutoDiscoverChannels}`);
|
|
}
|
|
|
|
// Update owner ship
|
|
if (newSettings.ownerShip !== undefined) {
|
|
effectiveOwnerShip = newSettings.ownerShip
|
|
? normalizeShip(newSettings.ownerShip)
|
|
: account.ownerShip
|
|
? normalizeShip(account.ownerShip)
|
|
: null;
|
|
runtime.log?.(`[tlon] Settings: ownerShip = ${effectiveOwnerShip}`);
|
|
}
|
|
|
|
// Update pending approvals
|
|
if (newSettings.pendingApprovals !== undefined) {
|
|
pendingApprovals = newSettings.pendingApprovals;
|
|
runtime.log?.(
|
|
`[tlon] Settings: pendingApprovals updated (${pendingApprovals.length} items)`,
|
|
);
|
|
}
|
|
});
|
|
|
|
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: any) => {
|
|
try {
|
|
// Handle group/channel join events
|
|
// Event structure: { group: { flag: "~host/group-name", ... }, channels: { ... } }
|
|
if (event && typeof event === "object") {
|
|
// Check for new channels being added to groups
|
|
if (event.channels && typeof event.channels === "object") {
|
|
const channels = event.channels as Record<string, any>;
|
|
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
|
|
if (event.join && typeof event.join === "object") {
|
|
const join = event.join as { group?: string; channels?: string[] };
|
|
if (join.channels) {
|
|
for (const channelNest of join.channels) {
|
|
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: any) {
|
|
runtime.error?.(
|
|
`[tlon] Error handling groups-ui event: ${error?.message ?? String(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;
|
|
}
|
|
|
|
const validInvite = foreign.invites.find((inv) => inv.valid);
|
|
if (!validInvite) {
|
|
continue;
|
|
}
|
|
|
|
const inviterShip = validInvite.from;
|
|
const normalizedInviter = normalizeShip(inviterShip);
|
|
|
|
// Owner invites are always accepted
|
|
if (isOwner(inviterShip)) {
|
|
try {
|
|
await api.poke({
|
|
app: "groups",
|
|
mark: "group-join",
|
|
json: {
|
|
flag: groupFlag,
|
|
"join-all": true,
|
|
},
|
|
});
|
|
processedGroupInvites.add(groupFlag);
|
|
runtime.log?.(`[tlon] Auto-accepted group invite from owner: ${groupFlag}`);
|
|
} catch (err) {
|
|
runtime.error?.(`[tlon] Failed to accept group invite from owner: ${String(err)}`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Skip if auto-accept is disabled
|
|
if (!effectiveAutoAcceptGroupInvites) {
|
|
// If owner is configured, queue approval
|
|
if (effectiveOwnerShip) {
|
|
const approval = createPendingApproval({
|
|
type: "group",
|
|
requestingShip: inviterShip,
|
|
groupFlag,
|
|
});
|
|
await queueApprovalRequest(approval);
|
|
processedGroupInvites.add(groupFlag);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Check if inviter is on allowlist
|
|
const isAllowed =
|
|
effectiveGroupInviteAllowlist.length > 0
|
|
? effectiveGroupInviteAllowlist
|
|
.map((s) => normalizeShip(s))
|
|
.some((s) => s === normalizedInviter)
|
|
: false; // Fail-safe: empty allowlist means deny
|
|
|
|
if (!isAllowed) {
|
|
// If owner is configured, queue approval
|
|
if (effectiveOwnerShip) {
|
|
const approval = createPendingApproval({
|
|
type: "group",
|
|
requestingShip: inviterShip,
|
|
groupFlag,
|
|
});
|
|
await queueApprovalRequest(approval);
|
|
processedGroupInvites.add(groupFlag);
|
|
} else {
|
|
runtime.log?.(
|
|
`[tlon] Rejected group invite from ${inviterShip} (not in groupInviteAllowlist): ${groupFlag}`,
|
|
);
|
|
processedGroupInvites.add(groupFlag);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Inviter is on allowlist - accept the invite
|
|
try {
|
|
await api.poke({
|
|
app: "groups",
|
|
mark: "group-join",
|
|
json: {
|
|
flag: groupFlag,
|
|
"join-all": true,
|
|
},
|
|
});
|
|
processedGroupInvites.add(groupFlag);
|
|
runtime.log?.(
|
|
`[tlon] Auto-accepted group invite: ${groupFlag} (from ${validInvite.from})`,
|
|
);
|
|
} catch (err) {
|
|
runtime.error?.(`[tlon] Failed to auto-accept group ${groupFlag}: ${String(err)}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Process existing pending invites from init data
|
|
if (initForeigns) {
|
|
await processPendingInvites(initForeigns);
|
|
}
|
|
|
|
try {
|
|
await api.subscribe({
|
|
app: "groups",
|
|
path: "/v1/foreigns",
|
|
event: (data: unknown) => {
|
|
void (async () => {
|
|
try {
|
|
await processPendingInvites(data as Foreigns);
|
|
} catch (error: any) {
|
|
runtime.error?.(
|
|
`[tlon] Error handling foreigns event: ${error?.message ?? String(error)}`,
|
|
);
|
|
}
|
|
})();
|
|
},
|
|
err: (error) => {
|
|
runtime.error?.(`[tlon] Foreigns subscription error: ${String(error)}`);
|
|
},
|
|
quit: () => {
|
|
runtime.log?.("[tlon] Foreigns subscription ended");
|
|
},
|
|
});
|
|
runtime.log?.(
|
|
"[tlon] Subscribed to foreigns (/v1/foreigns) for auto-accepting group invites",
|
|
);
|
|
} catch (err) {
|
|
runtime.log?.(`[tlon] Foreigns subscription failed: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
// Discover channels to watch
|
|
if (effectiveAutoDiscoverChannels) {
|
|
const discoveredChannels = await fetchAllChannels(api, runtime);
|
|
for (const channelNest of discoveredChannels) {
|
|
watchedChannels.add(channelNest);
|
|
}
|
|
runtime.log?.(`[tlon] Watching ${watchedChannels.size} channel(s)`);
|
|
}
|
|
|
|
// Log watched channels
|
|
for (const channelNest of watchedChannels) {
|
|
runtime.log?.(`[tlon] Watching channel: ${channelNest}`);
|
|
}
|
|
|
|
runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream...");
|
|
await api.connect();
|
|
runtime.log?.("[tlon] Connected! Firehose subscriptions active");
|
|
|
|
// Periodically refresh channel discovery
|
|
const pollInterval = setInterval(
|
|
async () => {
|
|
if (!opts.abortSignal?.aborted) {
|
|
try {
|
|
if (effectiveAutoDiscoverChannels) {
|
|
const discoveredChannels = await fetchAllChannels(api, runtime);
|
|
for (const channelNest of discoveredChannels) {
|
|
if (!watchedChannels.has(channelNest)) {
|
|
watchedChannels.add(channelNest);
|
|
runtime.log?.(`[tlon] Now watching new channel: ${channelNest}`);
|
|
}
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
runtime.error?.(`[tlon] Channel refresh error: ${error?.message ?? String(error)}`);
|
|
}
|
|
}
|
|
},
|
|
2 * 60 * 1000,
|
|
);
|
|
|
|
if (opts.abortSignal) {
|
|
const signal = opts.abortSignal;
|
|
await new Promise((resolve) => {
|
|
signal.addEventListener(
|
|
"abort",
|
|
() => {
|
|
clearInterval(pollInterval);
|
|
resolve(null);
|
|
},
|
|
{ once: true },
|
|
);
|
|
});
|
|
} else {
|
|
await new Promise(() => {});
|
|
}
|
|
} finally {
|
|
try {
|
|
await api?.close();
|
|
} catch (error: any) {
|
|
runtime.error?.(`[tlon] Cleanup error: ${error?.message ?? String(error)}`);
|
|
}
|
|
}
|
|
}
|