matrix-plugin/config-loader.ts

264 lines
9.1 KiB
TypeScript

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<string, unknown> | null, envOverrides: Partial<MergedOptions>): 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<string, unknown> | 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<string | null> {
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<LoadConfigResult> {
pluginOptions = pluginOptions || {}
const envOverrides: Partial<MergedOptions> = {
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<string, unknown> | 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 }
}