From 5d30ee2232bc878bf1843420e51de03df610de48 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Thu, 29 Jan 2026 20:13:08 -0600 Subject: [PATCH] Tlon plugin: add settings store integration for hot-reload config - Add settings.ts module for settings-store subscription - Monitor subscribes to moltbot/tlon settings bucket - groupChannels, dmAllowlist, channelRules hot-reload without restart - Settings store values override file config when present - Graceful fallback if settings store not available --- extensions/tlon/src/monitor/index.ts | 100 ++++++++++- extensions/tlon/src/settings.ts | 255 +++++++++++++++++++++++++++ 2 files changed, 348 insertions(+), 7 deletions(-) create mode 100644 extensions/tlon/src/settings.ts diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 269bbfe78..8f0c2dccd 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -18,6 +18,7 @@ import { isSummarizationRequest, } from "./utils.js"; import { fetchAllChannels } from "./discovery.js"; +import { createSettingsManager, type TlonSettingsStore } from "../settings.js"; export type MonitorTlonOpts = { runtime?: RuntimeEnv; @@ -30,9 +31,14 @@ type ChannelAuthorization = { allowedShips?: string[]; }; +/** + * Resolve channel authorization by merging file config with settings store. + * Settings store takes precedence for fields it defines. + */ function resolveChannelAuthorization( cfg: MoltbotConfig, channelNest: string, + settings?: TlonSettingsStore, ): { mode: "restricted" | "open"; allowedShips: string[] } { const tlonConfig = cfg.channels?.tlon as | { @@ -40,9 +46,18 @@ function resolveChannelAuthorization( defaultAuthorizedShips?: string[]; } | undefined; - const rules = tlonConfig?.authorization?.channelRules ?? {}; - const rule = rules[channelNest]; - const allowedShips = rule?.allowedShips ?? tlonConfig?.defaultAuthorizedShips ?? []; + + // Merge channel rules: settings override file config + const fileRules = tlonConfig?.authorization?.channelRules ?? {}; + const settingsRules = settings?.channelRules ?? {}; + const rule = settingsRules[channelNest] ?? fileRules[channelNest]; + + // Merge default authorized ships: settings override file config + const defaultShips = settings?.defaultAuthorizedShips + ?? tlonConfig?.defaultAuthorizedShips + ?? []; + + const allowedShips = rule?.allowedShips ?? defaultShips; const mode = rule?.mode ?? "restricted"; return { mode, allowedShips }; } @@ -94,6 +109,17 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise 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 currentSettings: TlonSettingsStore = {}; // Fetch bot's nickname from contacts try { @@ -133,6 +159,26 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { + 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.length > 0 + ? newSettings.dmAllowlist + : account.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}`); + } + }); + + 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)}`); + } + // Discover channels to watch if (account.autoDiscoverChannels !== false) { const discoveredChannels = await fetchAllChannels(api!, runtime); diff --git a/extensions/tlon/src/settings.ts b/extensions/tlon/src/settings.ts new file mode 100644 index 000000000..78c8230cf --- /dev/null +++ b/extensions/tlon/src/settings.ts @@ -0,0 +1,255 @@ +/** + * 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 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: isChannelRulesObject(settings.channelRules) + ? settings.channelRules + : undefined, + 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 = isChannelRulesObject(value) ? value : undefined; + 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); + }, + }; +}