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
This commit is contained in:
parent
b44d129c56
commit
5d30ee2232
@ -18,6 +18,7 @@ import {
|
|||||||
isSummarizationRequest,
|
isSummarizationRequest,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
import { fetchAllChannels } from "./discovery.js";
|
import { fetchAllChannels } from "./discovery.js";
|
||||||
|
import { createSettingsManager, type TlonSettingsStore } from "../settings.js";
|
||||||
|
|
||||||
export type MonitorTlonOpts = {
|
export type MonitorTlonOpts = {
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
@ -30,9 +31,14 @@ type ChannelAuthorization = {
|
|||||||
allowedShips?: string[];
|
allowedShips?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve channel authorization by merging file config with settings store.
|
||||||
|
* Settings store takes precedence for fields it defines.
|
||||||
|
*/
|
||||||
function resolveChannelAuthorization(
|
function resolveChannelAuthorization(
|
||||||
cfg: MoltbotConfig,
|
cfg: MoltbotConfig,
|
||||||
channelNest: string,
|
channelNest: string,
|
||||||
|
settings?: TlonSettingsStore,
|
||||||
): { mode: "restricted" | "open"; allowedShips: string[] } {
|
): { mode: "restricted" | "open"; allowedShips: string[] } {
|
||||||
const tlonConfig = cfg.channels?.tlon as
|
const tlonConfig = cfg.channels?.tlon as
|
||||||
| {
|
| {
|
||||||
@ -40,9 +46,18 @@ function resolveChannelAuthorization(
|
|||||||
defaultAuthorizedShips?: string[];
|
defaultAuthorizedShips?: string[];
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
const rules = tlonConfig?.authorization?.channelRules ?? {};
|
|
||||||
const rule = rules[channelNest];
|
// Merge channel rules: settings override file config
|
||||||
const allowedShips = rule?.allowedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
|
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";
|
const mode = rule?.mode ?? "restricted";
|
||||||
return { mode, allowedShips };
|
return { mode, allowedShips };
|
||||||
}
|
}
|
||||||
@ -94,6 +109,17 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
|||||||
const processedTracker = createProcessedMessageTracker(2000);
|
const processedTracker = createProcessedMessageTracker(2000);
|
||||||
let groupChannels: string[] = [];
|
let groupChannels: string[] = [];
|
||||||
let botNickname: string | null = null;
|
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 currentSettings: TlonSettingsStore = {};
|
||||||
|
|
||||||
// Fetch bot's nickname from contacts
|
// Fetch bot's nickname from contacts
|
||||||
try {
|
try {
|
||||||
@ -133,6 +159,26 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
|||||||
runtime.log?.("[tlon] No group channels to monitor (DMs only)");
|
runtime.log?.("[tlon] No group channels to monitor (DMs only)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load settings from settings store (hot-reloadable config)
|
||||||
|
try {
|
||||||
|
currentSettings = await settingsManager.load();
|
||||||
|
|
||||||
|
// Apply settings overrides
|
||||||
|
if (currentSettings.groupChannels?.length) {
|
||||||
|
groupChannels = currentSettings.groupChannels;
|
||||||
|
runtime.log?.(`[tlon] Using groupChannels from settings store: ${groupChannels.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (currentSettings.dmAllowlist?.length) {
|
||||||
|
effectiveDmAllowlist = currentSettings.dmAllowlist;
|
||||||
|
runtime.log?.(`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (currentSettings.showModelSig !== undefined) {
|
||||||
|
effectiveShowModelSig = currentSettings.showModelSig;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
runtime.log?.(`[tlon] Settings store not available, using file config: ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
const processMessage = async (params: {
|
const processMessage = async (params: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
senderShip: string;
|
senderShip: string;
|
||||||
@ -256,7 +302,8 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
|||||||
let replyText = payload.text;
|
let replyText = payload.text;
|
||||||
if (!replyText) return;
|
if (!replyText) return;
|
||||||
|
|
||||||
const showSignature = account.showModelSignature ?? cfg.channels?.tlon?.showModelSignature ?? false;
|
// Use settings store value if set, otherwise fall back to file config
|
||||||
|
const showSignature = effectiveShowModelSig;
|
||||||
if (showSignature) {
|
if (showSignature) {
|
||||||
const modelInfo =
|
const modelInfo =
|
||||||
payload.metadata?.model || payload.model || route.model || cfg.agents?.defaults?.model?.primary;
|
payload.metadata?.model || payload.model || route.model || cfg.agents?.defaults?.model?.primary;
|
||||||
@ -333,7 +380,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
|||||||
const mentioned = isBotMentioned(messageText, botShipName, botNickname ?? undefined);
|
const mentioned = isBotMentioned(messageText, botShipName, botNickname ?? undefined);
|
||||||
if (!mentioned) return;
|
if (!mentioned) return;
|
||||||
|
|
||||||
const { mode, allowedShips } = resolveChannelAuthorization(cfg, nest);
|
const { mode, allowedShips } = resolveChannelAuthorization(cfg, nest, currentSettings);
|
||||||
if (mode === "restricted") {
|
if (mode === "restricted") {
|
||||||
if (allowedShips.length === 0) {
|
if (allowedShips.length === 0) {
|
||||||
runtime.log?.(`[tlon] Access denied: ${senderShip} in ${nest} (no allowlist)`);
|
runtime.log?.(`[tlon] Access denied: ${senderShip} in ${nest} (no allowlist)`);
|
||||||
@ -392,8 +439,8 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
|||||||
const messageText = extractMessageText(essay.content);
|
const messageText = extractMessageText(essay.content);
|
||||||
if (!messageText) return;
|
if (!messageText) return;
|
||||||
|
|
||||||
// For DMs, check allowlist
|
// For DMs, check allowlist (uses settings store if available)
|
||||||
if (!isDmAllowed(senderShip, account.dmAllowlist)) {
|
if (!isDmAllowed(senderShip, effectiveDmAllowlist)) {
|
||||||
runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
|
runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -471,6 +518,45 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
|||||||
});
|
});
|
||||||
runtime.log?.("[tlon] Subscribed to contacts updates (/v1/news)");
|
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.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
|
// Discover channels to watch
|
||||||
if (account.autoDiscoverChannels !== false) {
|
if (account.autoDiscoverChannels !== false) {
|
||||||
const discoveredChannels = await fetchAllChannels(api!, runtime);
|
const discoveredChannels = await fetchAllChannels(api!, runtime);
|
||||||
|
|||||||
255
extensions/tlon/src/settings.ts
Normal file
255
extensions/tlon/src/settings.ts
Normal file
@ -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<string, {
|
||||||
|
mode?: "restricted" | "open";
|
||||||
|
allowedShips?: string[];
|
||||||
|
}>;
|
||||||
|
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<string, unknown>;
|
||||||
|
const bucket = desk[SETTINGS_BUCKET];
|
||||||
|
if (!bucket || typeof bucket !== "object") return {};
|
||||||
|
|
||||||
|
const settings = bucket as Record<string, unknown>;
|
||||||
|
|
||||||
|
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<string, { mode?: "restricted" | "open"; allowedShips?: string[] }> {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
// Handle put-entry events
|
||||||
|
if (evt["put-entry"]) {
|
||||||
|
const put = evt["put-entry"] as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<TlonSettingsStore> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user