/** * Settings Store integration for hot-reloading Tlon plugin config. * * Settings are stored in Urbit's %settings agent under: * desk: "moltbot" * bucket: "tlon" * * This allows config changes via poke from any Landscape client * without requiring a gateway restart. */ import type { UrbitSSEClient } from "./urbit/sse-client.js"; export type TlonSettingsStore = { groupChannels?: string[]; dmAllowlist?: string[]; autoDiscover?: boolean; showModelSig?: boolean; channelRules?: Record; defaultAuthorizedShips?: string[]; }; export type TlonSettingsState = { current: TlonSettingsStore; loaded: boolean; }; const SETTINGS_DESK = "moltbot"; const SETTINGS_BUCKET = "tlon"; /** * Parse channelRules - handles both JSON string and object formats. * Settings-store doesn't support nested objects, so we store as JSON string. */ function parseChannelRules( value: unknown ): Record | undefined { if (!value) return undefined; // If it's a string, try to parse as JSON if (typeof value === "string") { try { const parsed = JSON.parse(value); if (isChannelRulesObject(parsed)) return parsed; } catch { return undefined; } } // If it's already an object, use directly if (isChannelRulesObject(value)) return value; return undefined; } /** * Parse settings from the raw Urbit settings-store response. * The response shape is: { [bucket]: { [key]: value } } */ function parseSettingsResponse(raw: unknown): TlonSettingsStore { if (!raw || typeof raw !== "object") return {}; const desk = raw as Record; const bucket = desk[SETTINGS_BUCKET]; if (!bucket || typeof bucket !== "object") return {}; const settings = bucket as Record; return { groupChannels: Array.isArray(settings.groupChannels) ? settings.groupChannels.filter((x): x is string => typeof x === "string") : undefined, dmAllowlist: Array.isArray(settings.dmAllowlist) ? settings.dmAllowlist.filter((x): x is string => typeof x === "string") : undefined, autoDiscover: typeof settings.autoDiscover === "boolean" ? settings.autoDiscover : undefined, showModelSig: typeof settings.showModelSig === "boolean" ? settings.showModelSig : undefined, channelRules: parseChannelRules(settings.channelRules), defaultAuthorizedShips: Array.isArray(settings.defaultAuthorizedShips) ? settings.defaultAuthorizedShips.filter((x): x is string => typeof x === "string") : undefined, }; } function isChannelRulesObject( val: unknown ): val is Record { if (!val || typeof val !== "object" || Array.isArray(val)) return false; for (const [, rule] of Object.entries(val)) { if (!rule || typeof rule !== "object") return false; } return true; } /** * Parse a single settings entry update event. */ function parseSettingsEvent(event: unknown): { key: string; value: unknown } | null { if (!event || typeof event !== "object") return null; const evt = event as Record; // Handle put-entry events if (evt["put-entry"]) { const put = evt["put-entry"] as Record; if (put.desk !== SETTINGS_DESK || put["bucket-key"] !== SETTINGS_BUCKET) { return null; } return { key: String(put["entry-key"] ?? ""), value: put.value, }; } // Handle del-entry events if (evt["del-entry"]) { const del = evt["del-entry"] as Record; if (del.desk !== SETTINGS_DESK || del["bucket-key"] !== SETTINGS_BUCKET) { return null; } return { key: String(del["entry-key"] ?? ""), value: undefined, }; } return null; } /** * Apply a single settings update to the current state. */ function applySettingsUpdate( current: TlonSettingsStore, key: string, value: unknown ): TlonSettingsStore { const next = { ...current }; switch (key) { case "groupChannels": next.groupChannels = Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : undefined; break; case "dmAllowlist": next.dmAllowlist = Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : undefined; break; case "autoDiscover": next.autoDiscover = typeof value === "boolean" ? value : undefined; break; case "showModelSig": next.showModelSig = typeof value === "boolean" ? value : undefined; break; case "channelRules": next.channelRules = parseChannelRules(value); break; case "defaultAuthorizedShips": next.defaultAuthorizedShips = Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : undefined; break; } return next; } export type SettingsLogger = { log?: (msg: string) => void; error?: (msg: string) => void; }; /** * Create a settings store subscription manager. * * Usage: * const settings = createSettingsManager(api, logger); * await settings.load(); * settings.subscribe((newSettings) => { ... }); */ export function createSettingsManager( api: UrbitSSEClient, logger?: SettingsLogger ) { let state: TlonSettingsState = { current: {}, loaded: false, }; const listeners = new Set<(settings: TlonSettingsStore) => void>(); const notify = () => { for (const listener of listeners) { try { listener(state.current); } catch (err) { logger?.error?.(`[settings] Listener error: ${String(err)}`); } } }; return { /** * Get current settings (may be empty if not loaded yet). */ get current(): TlonSettingsStore { return state.current; }, /** * Whether initial settings have been loaded. */ get loaded(): boolean { return state.loaded; }, /** * Load initial settings via scry. */ async load(): Promise { try { const raw = await api.scry(`/desk/${SETTINGS_DESK}.json`); state.current = parseSettingsResponse(raw); state.loaded = true; logger?.log?.(`[settings] Loaded: ${JSON.stringify(state.current)}`); return state.current; } catch (err) { // Settings desk may not exist yet - that's fine, use defaults logger?.log?.(`[settings] No settings found (using defaults): ${String(err)}`); state.current = {}; state.loaded = true; return state.current; } }, /** * Subscribe to settings changes. */ async startSubscription(): Promise { await api.subscribe({ app: "settings", path: "/desk/" + SETTINGS_DESK, event: (event) => { const update = parseSettingsEvent(event); if (!update) return; logger?.log?.(`[settings] Update: ${update.key} = ${JSON.stringify(update.value)}`); state.current = applySettingsUpdate(state.current, update.key, update.value); notify(); }, err: (error) => { logger?.error?.(`[settings] Subscription error: ${String(error)}`); }, quit: () => { logger?.log?.("[settings] Subscription ended"); }, }); logger?.log?.("[settings] Subscribed to settings updates"); }, /** * Register a listener for settings changes. */ onChange(listener: (settings: TlonSettingsStore) => void): () => void { listeners.add(listener); return () => listeners.delete(listener); }, }; }