import fs from "fs" import path from "path" import os from "os" import { log } from "./logger.js" interface PluginOptions { homeserver?: string userId?: string accessToken?: string password?: string deviceId?: string autoJoin?: boolean triggerPatterns?: string[] ignoreRooms?: string[] ignoreUsers?: string[] allowedUsers?: string[] formatHtml?: boolean threadIsolation?: boolean respondToThreadReplies?: boolean rateLimitSeconds?: number botName?: string storagePath?: string } interface MergedOptions { homeserver: string userId?: string accessToken?: string password?: string deviceId: string autoJoin: boolean triggerPatterns: string[] ignoreRooms: string[] ignoreUsers: string[] allowedUsers: string[] formatHtml: boolean threadIsolation: boolean respondToThreadReplies: boolean rateLimitSeconds: number botName: string storagePath?: string } interface LoadConfigResult { options: MergedOptions configPath: string | null } const CONFIG_FILENAMES = ["matrix.json", "matrix.jsonc"] function mergeDefaults(raw: Record | null, envOverrides: Partial): MergedOptions { const base: MergedOptions = { homeserver: envOverrides.homeserver || "https://matrix.org", userId: envOverrides.userId, accessToken: envOverrides.accessToken, password: envOverrides.password, deviceId: envOverrides.deviceId || "opencode-matrix-plugin", autoJoin: envOverrides.autoJoin !== false, triggerPatterns: envOverrides.triggerPatterns || ["!oc "], ignoreRooms: envOverrides.ignoreRooms || [], ignoreUsers: envOverrides.ignoreUsers || [], allowedUsers: envOverrides.allowedUsers || [], formatHtml: envOverrides.formatHtml || false, threadIsolation: envOverrides.threadIsolation !== false, respondToThreadReplies: envOverrides.respondToThreadReplies !== false, rateLimitSeconds: envOverrides.rateLimitSeconds || 5, botName: envOverrides.botName || "opencode", storagePath: envOverrides.storagePath, } if (!raw) return base return { homeserver: raw.homeserver as string || base.homeserver, userId: raw.userId as string || base.userId, accessToken: raw.accessToken as string || base.accessToken, password: raw.password as string || base.password, deviceId: (raw.deviceId as string) || base.deviceId, autoJoin: (raw.autoJoin as boolean) !== undefined ? (raw.autoJoin as boolean) : base.autoJoin, triggerPatterns: (raw.triggerPatterns as string[]) || base.triggerPatterns, ignoreRooms: (raw.ignoreRooms as string[]) || base.ignoreRooms, ignoreUsers: (raw.ignoreUsers as string[]) || base.ignoreUsers, allowedUsers: (raw.allowedUsers as string[]) || base.allowedUsers, formatHtml: (raw.formatHtml as boolean) !== undefined ? (raw.formatHtml as boolean) : base.formatHtml, threadIsolation: (raw.threadIsolation as boolean) !== undefined ? (raw.threadIsolation as boolean) : base.threadIsolation, respondToThreadReplies: (raw.respondToThreadReplies as boolean) !== undefined ? (raw.respondToThreadReplies as boolean) : base.respondToThreadReplies, rateLimitSeconds: (raw.rateLimitSeconds as number) || base.rateLimitSeconds, botName: (raw.botName as string) || base.botName, storagePath: (raw.storagePath as string) || base.storagePath, } } function tryReadConfig(dir: string): Record | null { for (const filename of CONFIG_FILENAMES) { const filepath = path.join(dir, filename) if (!fs.existsSync(filepath)) continue try { const content = fs.readFileSync(filepath, "utf-8") const parsed = JSON.parse(content) if (typeof parsed === "object" && parsed !== null) { return parsed } } catch (e) { log(`Failed to read config: ${filepath}`) return null } } return null } function findProjectConfigDir(): string | null { let dir = process.cwd() const root = path.parse(dir).root while (dir && dir !== root) { const opencodeDir = path.join(dir, ".opencode") if (fs.existsSync(opencodeDir) && fs.statSync(opencodeDir).isDirectory()) { return opencodeDir } dir = path.dirname(dir) } const currentOpencode = path.join(process.cwd(), ".opencode") if (fs.existsSync(currentOpencode) && fs.statSync(currentOpencode).isDirectory()) { return currentOpencode } return null } function findGlobalConfigDir(): string { return path.join(os.homedir(), ".config", "opencode") } const DEFAULT_CONFIG_CONTENT = JSON.stringify({ homeserver: "https://matrix.org", userId: "", accessToken: "", password: "", deviceId: "opencode-matrix-plugin", autoJoin: true, triggerPatterns: ["!oc "], ignoreRooms: [], ignoreUsers: [], allowedUsers: [], formatHtml: false, threadIsolation: true, respondToThreadReplies: true, rateLimitSeconds: 5, botName: "opencode", enabled: true, }, null, 2) async function ensureGlobalConfig(globalConfigDir: string): Promise { log(`ensureGlobalConfig called, dir=${globalConfigDir}`) for (const filename of CONFIG_FILENAMES) { const filepath = path.join(globalConfigDir, filename) if (fs.existsSync(filepath)) { log(`config already exists: ${filepath}`) return filepath } } log("no config found, creating default") try { log("mkdirSync globalConfigDir") fs.mkdirSync(globalConfigDir, { recursive: true }) const filepath = path.join(globalConfigDir, "matrix.json") log("writing file: " + filepath) fs.writeFileSync(filepath, DEFAULT_CONFIG_CONTENT, { mode: 0o600 }) log("Created default config: " + filepath) return filepath } catch (err) { log("Failed to create default config: " + err) return null } } export async function loadConfig(pluginOptions: PluginOptions = {}): Promise { pluginOptions = pluginOptions || {} const envOverrides: Partial = { homeserver: process.env.MATRIX_HOMESERVER, userId: process.env.MATRIX_USER_ID, accessToken: process.env.MATRIX_ACCESS_TOKEN, password: process.env.MATRIX_PASSWORD, deviceId: process.env.MATRIX_DEVICE_ID || undefined, triggerPatterns: process.env.MATRIX_TRIGGER ? [process.env.MATRIX_TRIGGER] : undefined, allowedUsers: process.env.MATRIX_ALLOWED_USERS ? process.env.MATRIX_ALLOWED_USERS.split(",").map(s => s.trim()).filter(Boolean) : undefined, storagePath: process.env.MATRIX_STORAGE_PATH, } const projectConfigDir = findProjectConfigDir() log(`projectConfigDir=${projectConfigDir}`) let rawConfig: Record | null = null let configPath: string | null = null if (projectConfigDir) { const projectConfig = tryReadConfig(projectConfigDir) log(`projectConfig=${projectConfig ? "found" : "null"}`) if (projectConfig) { rawConfig = projectConfig const foundFile = CONFIG_FILENAMES.find((f) => fs.existsSync(path.join(projectConfigDir, f))) if (foundFile) { configPath = path.join(projectConfigDir, foundFile) } } } const globalConfigDir = findGlobalConfigDir() const globalConfig = tryReadConfig(globalConfigDir) log(`globalConfigDir=${globalConfigDir}`) log(`globalConfig=${globalConfig ? "found" : "null"}`) log(`rawConfig=${rawConfig ? "found" : "null"}`) if (!rawConfig && globalConfig) { rawConfig = globalConfig configPath = path.join(globalConfigDir, "matrix.json") log("using global config") } if (!rawConfig) { log("no config found, calling ensureGlobalConfig") const createdPath = await ensureGlobalConfig(globalConfigDir) log(`ensureGlobalConfig returned=${createdPath}`) if (createdPath) { configPath = createdPath } } if (rawConfig && (rawConfig.enabled as boolean | undefined) === false) { return { options: { ...mergeDefaults(null, envOverrides), homeserver: "" } as MergedOptions, configPath, } } const merged = mergeDefaults(rawConfig, envOverrides) const finalOptions: MergedOptions = { homeserver: pluginOptions.homeserver || merged.homeserver, userId: pluginOptions.userId || merged.userId, accessToken: pluginOptions.accessToken || merged.accessToken, password: pluginOptions.password || merged.password, deviceId: pluginOptions.deviceId || merged.deviceId, autoJoin: pluginOptions.autoJoin !== undefined ? pluginOptions.autoJoin : merged.autoJoin, triggerPatterns: pluginOptions.triggerPatterns || merged.triggerPatterns, ignoreRooms: pluginOptions.ignoreRooms || merged.ignoreRooms, ignoreUsers: pluginOptions.ignoreUsers || merged.ignoreUsers, allowedUsers: pluginOptions.allowedUsers || merged.allowedUsers, formatHtml: pluginOptions.formatHtml !== undefined ? pluginOptions.formatHtml : merged.formatHtml, threadIsolation: pluginOptions.threadIsolation !== undefined ? pluginOptions.threadIsolation : merged.threadIsolation, respondToThreadReplies: pluginOptions.respondToThreadReplies !== undefined ? pluginOptions.respondToThreadReplies : merged.respondToThreadReplies, rateLimitSeconds: pluginOptions.rateLimitSeconds || merged.rateLimitSeconds, botName: pluginOptions.botName || merged.botName, storagePath: pluginOptions.storagePath || merged.storagePath, } return { options: finalOptions, configPath } }