264 lines
9.1 KiB
TypeScript
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 }
|
|
}
|