import { readFile } from 'fs/promises' import { join } from 'path' import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' import { AgentBridgeClient } from '../../services/hermes/agent-bridge' import { restartGatewayForProfile } from '../../services/hermes/gateway-autostart' import { saveEnvValueForProfile } from '../../services/config-helpers' import { logger } from '../../services/logger' import { safeFileStore } from '../../services/safe-file-store' const PLATFORM_SECTIONS = new Set([ 'telegram', 'discord', 'slack', 'whatsapp', 'matrix', 'weixin', 'wecom', 'feishu', 'dingtalk', 'qqbot', 'approvals', ]) function requestedProfile(ctx: any): string { return ctx.state?.profile?.name || getActiveProfileName() || 'default' } const configPath = (profile: string) => join(getProfileDir(profile), 'config.yaml') const envPath = (profile: string) => join(getProfileDir(profile), '.env') const envPlatformMap: Record = { TELEGRAM_BOT_TOKEN: ['telegram', 'token'], DISCORD_BOT_TOKEN: ['discord', 'token'], SLACK_BOT_TOKEN: ['slack', 'token'], MATRIX_ACCESS_TOKEN: ['matrix', 'token'], MATRIX_HOMESERVER: ['matrix', 'extra.homeserver'], FEISHU_APP_ID: ['feishu', 'extra.app_id'], FEISHU_APP_SECRET: ['feishu', 'extra.app_secret'], DINGTALK_CLIENT_ID: ['dingtalk', 'extra.client_id'], DINGTALK_CLIENT_SECRET: ['dingtalk', 'extra.client_secret'], DINGTALK_APP_KEY: ['dingtalk', 'extra.app_key'], DINGTALK_ALLOWED_USERS: ['dingtalk', 'allowed_users'], DINGTALK_ALLOW_ALL_USERS: ['dingtalk', 'allow_all_users'], QQ_APP_ID: ['qqbot', 'extra.app_id'], QQ_CLIENT_SECRET: ['qqbot', 'extra.client_secret'], QQ_ALLOWED_USERS: ['qqbot', 'allowed_users'], QQ_ALLOW_ALL_USERS: ['qqbot', 'allow_all_users'], WECOM_BOT_ID: ['wecom', 'extra.bot_id'], WECOM_SECRET: ['wecom', 'extra.secret'], WEIXIN_TOKEN: ['weixin', 'token'], WEIXIN_ACCOUNT_ID: ['weixin', 'extra.account_id'], WEIXIN_BASE_URL: ['weixin', 'extra.base_url'], WHATSAPP_ENABLED: ['whatsapp', 'enabled'], } const platformEnvMap: Record> = {} for (const [envVar, [platform, cfgPath]] of Object.entries(envPlatformMap)) { if (!platformEnvMap[platform]) platformEnvMap[platform] = {} platformEnvMap[platform][cfgPath] = envVar } function parseEnv(raw: string): Record { const env: Record = {} for (const line of raw.split('\n')) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) continue const eqIdx = trimmed.indexOf('=') if (eqIdx === -1) continue const key = trimmed.slice(0, eqIdx).trim() const val = trimmed.slice(eqIdx + 1).trim() if (val) env[key] = val } return env } function setNested(obj: Record, path: string, value: any) { const parts = path.split('.') let cur = obj for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) cur[parts[i]] = {}; cur = cur[parts[i]] } cur[parts[parts.length - 1]] = value } function deepMerge(target: Record, source: Record): Record { for (const key of Object.keys(source)) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) { target[key] = deepMerge(target[key], source[key]) } else { target[key] = source[key] } } return target } async function destroyBridgeProfile(profile: string): Promise { try { const result = await new AgentBridgeClient({ connectRetryMs: 0, timeoutMs: 5000 }).destroyProfile(profile) logger.info('[config] destroyed bridge sessions after gateway restart profile=%s destroyed=%s', profile, result.destroyed) } catch (err) { logger.warn(err, '[config] failed to destroy bridge sessions after gateway restart profile=%s', profile) } } async function readEnvPlatforms(profile: string): Promise> { try { const raw = await readFile(envPath(profile), 'utf-8') const env = parseEnv(raw) const platforms: Record = {} for (const [envKey, [platform, cfgPath]] of Object.entries(envPlatformMap)) { const val = env[envKey] if (val === undefined || val === '') continue if (!platforms[platform]) platforms[platform] = {} let finalVal: any = val if (cfgPath === 'enabled' || cfgPath === 'allow_all_users') finalVal = val === 'true' setNested(platforms[platform], cfgPath, finalVal) } return platforms } catch { return {} } } async function readConfig(profile: string): Promise> { return safeFileStore.readYaml(configPath(profile)) } export async function getConfig(ctx: any) { try { const profile = requestedProfile(ctx) const config = await readConfig(profile) const envPlatforms = await readEnvPlatforms(profile) if (Object.keys(envPlatforms).length > 0) { const existing = config.platforms || {} for (const [platform, vals] of Object.entries(envPlatforms)) { existing[platform] = deepMerge(existing[platform] || {}, vals as Record) } config.platforms = existing } const { section, sections } = ctx.query if (section) { ctx.body = { [section as string]: config[section as string] || {} } } else if (sections) { const keys = (sections as string).split(',') const result: Record = {} for (const key of keys) { result[key.trim()] = config[key.trim()] || {} } ctx.body = result } else { ctx.body = config } } catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } } } export async function updateConfig(ctx: any) { const { section, values, restart } = ctx.request.body as { section: string; values: Record; restart?: boolean } if (!section || !values) { ctx.status = 400; ctx.body = { error: 'Missing section or values' }; return } try { const profile = requestedProfile(ctx) await safeFileStore.updateYaml(configPath(profile), (config) => { config[section] = deepMerge(config[section] || {}, values) return config }, { backup: true, dumpOptions: { forceQuotes: true, }, }) // Platform adapters still run through Hermes gateway; restart it so channel // config changes (Feishu/Weixin/etc.) are applied, then refresh bridge sessions. if (restart !== false && PLATFORM_SECTIONS.has(section)) { try { const restartResult = await restartGatewayForProfile(profile) logger.info('[config] gateway restarted after config update section=%s profile=%s result=%j', section, profile, restartResult) await destroyBridgeProfile(profile) } catch (err) { logger.error(err, 'Gateway restart failed') ctx.status = 500 ctx.body = { error: err instanceof Error ? err.message : 'Gateway restart failed' } return } } ctx.body = { success: true } } catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } } } export async function updateCredentials(ctx: any) { const { platform, values } = ctx.request.body as { platform: string; values: Record } if (!platform || !values) { ctx.status = 400; ctx.body = { error: 'Missing platform or values' }; return } try { const profile = requestedProfile(ctx) const envMap = platformEnvMap[platform] if (!envMap) { ctx.status = 400; ctx.body = { error: `Unknown platform: ${platform}` }; return } const flatValues: Record = {} for (const [key, val] of Object.entries(values)) { if (key === 'extra' && val && typeof val === 'object') { for (const [subKey, subVal] of Object.entries(val as Record)) { flatValues[`extra.${subKey}`] = subVal } } else { flatValues[key] = val } } await safeFileStore.updateYaml(configPath(profile), async (config) => { for (const [cfgPath, val] of Object.entries(flatValues)) { const envVar = envMap[cfgPath] if (!envVar) continue if (val === undefined || val === null || val === '') { await saveEnvValueForProfile(profile, envVar, '') const parts = cfgPath.split('.') let obj: any = config.platforms?.[platform] if (obj) { if (parts.length === 1) { delete obj[parts[0]] } else { let cur = obj for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) break; cur = cur[parts[i]] } delete cur[parts[parts.length - 1]] if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra } if (Object.keys(obj).length === 0) { if (!config.platforms) config.platforms = {}; delete config.platforms[platform] } } } else { await saveEnvValueForProfile(profile, envVar, String(val)) } } return config }, { backup: true, dumpOptions: { forceQuotes: true, }, }) // Platform adapters still run through Hermes gateway; restart it so channel // credentials are applied, then refresh bridge sessions. try { const restartResult = await restartGatewayForProfile(profile) logger.info('[config] gateway restarted after credentials update platform=%s profile=%s result=%j', platform, profile, restartResult) await destroyBridgeProfile(profile) } catch (err) { logger.error(err, 'Gateway restart failed') ctx.status = 500 ctx.body = { error: err instanceof Error ? err.message : 'Gateway restart failed' } return } ctx.body = { success: true } } catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } } }