feat: add i18n, platform channels page, and WeChat QR login
- Add vue-i18n with auto-detect browser language and manual toggle (EN/中文) - Move platform channels to separate page with credential management - Support Telegram, Discord, Slack, WhatsApp, Matrix, Feishu, Weixin, WeCom - Add WeChat QR code login (opens in browser, polls status, auto-saves) - Write platform credentials to ~/.hermes/.env matching hermes gateway setup - Auto restart gateway after platform config changes - Add settings store with per-section save for all config categories - Persist session group collapse state across navigation - Fix pre-existing TypeScript build errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,8 @@ import { sessionRoutes } from './routes/sessions'
|
||||
import { webhookRoutes } from './routes/webhook'
|
||||
import { logRoutes } from './routes/logs'
|
||||
import { fsRoutes } from './routes/filesystem'
|
||||
import { configRoutes } from './routes/config'
|
||||
import { weixinRoutes } from './routes/weixin'
|
||||
import * as hermesCli from './services/hermes-cli'
|
||||
const { restartGateway } = hermesCli
|
||||
|
||||
@@ -30,6 +32,8 @@ export async function bootstrap() {
|
||||
app.use(uploadRoutes.routes())
|
||||
app.use(sessionRoutes.routes())
|
||||
app.use(fsRoutes.routes())
|
||||
app.use(configRoutes.routes())
|
||||
app.use(weixinRoutes.routes())
|
||||
|
||||
// Health endpoint with version
|
||||
app.use(async (ctx, next) => {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import Router from '@koa/router'
|
||||
import { readFile, writeFile, copyFile } from 'fs/promises'
|
||||
import { chmod } from 'fs/promises'
|
||||
import { resolve } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import YAML from 'js-yaml'
|
||||
import { restartGateway } from '../services/hermes-cli'
|
||||
|
||||
// Platform sections that require gateway restart after config change
|
||||
const PLATFORM_SECTIONS = new Set([
|
||||
'telegram', 'discord', 'slack', 'whatsapp', 'matrix',
|
||||
'weixin', 'wecom', 'feishu', 'dingtalk',
|
||||
])
|
||||
|
||||
const configPath = resolve(homedir(), '.hermes/config.yaml')
|
||||
const envPath = resolve(homedir(), '.hermes/.env')
|
||||
|
||||
// Env var → (platform, configPath in PlatformConfig) mapping
|
||||
// Matches hermes _apply_env_overrides() in gateway/config.py
|
||||
const envPlatformMap: Record<string, [string, string]> = {
|
||||
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 has no _apply_env_overrides entry in hermes;
|
||||
// the adapter reads these env vars directly at runtime.
|
||||
DINGTALK_APP_KEY: ['dingtalk', 'extra.app_key'],
|
||||
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'],
|
||||
}
|
||||
|
||||
// Reverse map: (platform, configPath) → env var
|
||||
const platformEnvMap: Record<string, Record<string, string>> = {}
|
||||
for (const [envVar, [platform, configPath]] of Object.entries(envPlatformMap)) {
|
||||
if (!platformEnvMap[platform]) platformEnvMap[platform] = {}
|
||||
platformEnvMap[platform][configPath] = envVar
|
||||
}
|
||||
|
||||
function parseEnv(raw: string): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
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<string, any>, 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 getNested(obj: Record<string, any>, path: string): any {
|
||||
const parts = path.split('.')
|
||||
let cur = obj
|
||||
for (const p of parts) {
|
||||
if (!cur || typeof cur !== 'object') return undefined
|
||||
cur = cur[p]
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
async function readEnvPlatforms(): Promise<Record<string, any>> {
|
||||
try {
|
||||
const raw = await readFile(envPath, 'utf-8')
|
||||
const env = parseEnv(raw)
|
||||
const platforms: Record<string, any> = {}
|
||||
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') finalVal = val === 'true'
|
||||
setNested(platforms[platform], cfgPath, finalVal)
|
||||
}
|
||||
return platforms
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// Write a KEY=value to .env (matching hermes save_env_value behavior)
|
||||
// If value is empty, remove the line instead
|
||||
async function saveEnvValue(key: string, value: string): Promise<void> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(envPath, 'utf-8')
|
||||
} catch {
|
||||
raw = ''
|
||||
}
|
||||
|
||||
const remove = !value
|
||||
const lines = raw.split('\n')
|
||||
let found = false
|
||||
const result: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('#')) {
|
||||
// Check if there's a commented-out version of this key
|
||||
if (trimmed.startsWith(`# ${key}=`)) {
|
||||
if (!remove) {
|
||||
result.push(`${key}=${value}`)
|
||||
}
|
||||
found = true
|
||||
} else {
|
||||
result.push(line)
|
||||
}
|
||||
} else {
|
||||
const eqIdx = trimmed.indexOf('=')
|
||||
if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) {
|
||||
if (!remove) {
|
||||
result.push(`${key}=${value}`)
|
||||
}
|
||||
found = true
|
||||
} else {
|
||||
result.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found && !remove) {
|
||||
result.push(`${key}=${value}`)
|
||||
}
|
||||
|
||||
// Remove trailing empty lines, keep exactly one trailing newline
|
||||
let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
|
||||
await writeFile(envPath, output, 'utf-8')
|
||||
// Set permissions to 0600 (owner only), matching hermes behavior
|
||||
try { await chmod(envPath, 0o600) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function readConfig(): Promise<Record<string, any>> {
|
||||
const raw = await readFile(configPath, 'utf-8')
|
||||
return (YAML.load(raw) as Record<string, any>) || {}
|
||||
}
|
||||
|
||||
async function writeConfig(data: Record<string, any>): Promise<void> {
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
const yamlStr = YAML.dump(data, {
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
quotingType: '"',
|
||||
forceQuotes: false,
|
||||
})
|
||||
await writeFile(configPath, yamlStr, 'utf-8')
|
||||
}
|
||||
|
||||
export const configRoutes = new Router()
|
||||
|
||||
// GET /api/config — read config sections
|
||||
configRoutes.get('/api/config', async (ctx) => {
|
||||
try {
|
||||
const config = await readConfig()
|
||||
// Merge .env platform credentials into platforms section
|
||||
const envPlatforms = await readEnvPlatforms()
|
||||
if (Object.keys(envPlatforms).length > 0) {
|
||||
// Deep-merge: env values fill in missing, don't overwrite config.yaml
|
||||
const existing = config.platforms || {}
|
||||
for (const [platform, vals] of Object.entries(envPlatforms)) {
|
||||
existing[platform] = { ...(existing[platform] || {}), ...(vals as Record<string, any>) }
|
||||
}
|
||||
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<string, any> = {}
|
||||
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 }
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config — update a config section (writes to config.yaml)
|
||||
configRoutes.put('/api/config', async (ctx) => {
|
||||
const { section, values } = ctx.request.body as {
|
||||
section: string
|
||||
values: Record<string, any>
|
||||
}
|
||||
|
||||
if (!section || !values) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing section or values' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await readConfig()
|
||||
config[section] = { ...(config[section] || {}), ...values }
|
||||
await writeConfig(config)
|
||||
// Restart gateway for platform/channel config changes
|
||||
if (PLATFORM_SECTIONS.has(section)) {
|
||||
await restartGateway()
|
||||
}
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config/credentials — save platform credentials to .env
|
||||
// Body: { platform: string, values: Record<string, any> }
|
||||
// values keys match PlatformConfig paths: 'token', 'extra.app_id', 'extra.app_secret', etc.
|
||||
configRoutes.put('/api/config/credentials', async (ctx) => {
|
||||
const { platform, values } = ctx.request.body as {
|
||||
platform: string
|
||||
values: Record<string, any>
|
||||
}
|
||||
|
||||
if (!platform || !values) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing platform or values' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const envMap = platformEnvMap[platform]
|
||||
if (!envMap) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Unknown platform: ${platform}` }
|
||||
return
|
||||
}
|
||||
|
||||
// Also clean up config.yaml platforms.<platform> to keep in sync
|
||||
const config = await readConfig()
|
||||
let configChanged = false
|
||||
|
||||
// Flatten nested values: { extra: { app_id: '' } } → { 'extra.app_id': '' }
|
||||
const flatValues: Record<string, any> = {}
|
||||
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<string, any>)) {
|
||||
flatValues[`extra.${subKey}`] = subVal
|
||||
}
|
||||
} else {
|
||||
flatValues[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
for (const [cfgPath, val] of Object.entries(flatValues)) {
|
||||
const envVar = envMap[cfgPath]
|
||||
if (!envVar) continue
|
||||
if (val === undefined || val === null || val === '') {
|
||||
await saveEnvValue(envVar, '')
|
||||
// Remove from config.yaml too
|
||||
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]]
|
||||
// Clean up empty extra
|
||||
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]
|
||||
}
|
||||
configChanged = true
|
||||
}
|
||||
} else {
|
||||
await saveEnvValue(envVar, String(val))
|
||||
}
|
||||
}
|
||||
|
||||
if (configChanged) {
|
||||
await writeConfig(config)
|
||||
}
|
||||
|
||||
// Restart gateway for platform credential changes
|
||||
await restartGateway()
|
||||
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import Router from '@koa/router'
|
||||
import axios from 'axios'
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { chmod } from 'fs/promises'
|
||||
import { resolve } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { restartGateway } from '../services/hermes-cli'
|
||||
|
||||
const envPath = resolve(homedir(), '.hermes/.env')
|
||||
const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
|
||||
|
||||
export const weixinRoutes = new Router()
|
||||
|
||||
// GET /api/weixin/qrcode — fetch QR code from Tencent iLink API
|
||||
weixinRoutes.get('/api/weixin/qrcode', async (ctx) => {
|
||||
try {
|
||||
const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_bot_qrcode`, {
|
||||
params: { bot_type: 3 },
|
||||
timeout: 15000,
|
||||
})
|
||||
const data = res.data
|
||||
if (!data || !data.qrcode) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to get QR code' }
|
||||
return
|
||||
}
|
||||
ctx.body = {
|
||||
qrcode: data.qrcode,
|
||||
qrcode_url: data.qrcode_img_content,
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message || 'Failed to connect to iLink API' }
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/weixin/qrcode/status — poll QR scan status
|
||||
weixinRoutes.get('/api/weixin/qrcode/status', async (ctx) => {
|
||||
const qrcode = ctx.query.qrcode as string
|
||||
if (!qrcode) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing qrcode parameter' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_qrcode_status`, {
|
||||
params: { qrcode },
|
||||
timeout: 35000,
|
||||
})
|
||||
const data = res.data
|
||||
const status = data?.status || 'wait'
|
||||
ctx.body = { status }
|
||||
|
||||
// If confirmed, return credentials so frontend can save them
|
||||
if (status === 'confirmed') {
|
||||
ctx.body = {
|
||||
status: 'confirmed',
|
||||
account_id: data.ilink_bot_id,
|
||||
token: data.bot_token,
|
||||
base_url: data.baseurl,
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message || 'Failed to poll QR status' }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/weixin/save — save weixin credentials to .env
|
||||
weixinRoutes.post('/api/weixin/save', async (ctx) => {
|
||||
const { account_id, token, base_url } = ctx.request.body as {
|
||||
account_id: string
|
||||
token: string
|
||||
base_url?: string
|
||||
}
|
||||
|
||||
if (!account_id || !token) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing account_id or token' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(envPath, 'utf-8')
|
||||
} catch {
|
||||
raw = ''
|
||||
}
|
||||
|
||||
const entries: Record<string, string> = {
|
||||
WEIXIN_ACCOUNT_ID: account_id,
|
||||
WEIXIN_TOKEN: token,
|
||||
}
|
||||
if (base_url) entries.WEIXIN_BASE_URL = base_url
|
||||
|
||||
const lines = raw.split('\n')
|
||||
const existingKeys = new Set<string>()
|
||||
|
||||
const result: string[] = []
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('#')) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
const eqIdx = trimmed.indexOf('=')
|
||||
if (eqIdx !== -1) {
|
||||
const key = trimmed.slice(0, eqIdx).trim()
|
||||
if (key in entries) {
|
||||
result.push(`${key}=${entries[key]}`)
|
||||
existingKeys.add(key)
|
||||
continue
|
||||
}
|
||||
}
|
||||
result.push(line)
|
||||
}
|
||||
|
||||
for (const [key, val] of Object.entries(entries)) {
|
||||
if (!existingKeys.has(key)) {
|
||||
result.push(`${key}=${val}`)
|
||||
}
|
||||
}
|
||||
|
||||
let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
|
||||
await writeFile(envPath, output, 'utf-8')
|
||||
try { await chmod(envPath, 0o600) } catch { /* ignore */ }
|
||||
await restartGateway()
|
||||
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user