Skip to content

Commit 69ba163

Browse files
frostebiteclaude
andauthored
Add channel management commands for triage (#14)
* fix(deploy): use yarn instead of npm ci (package-lock.json removed) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add channel management commands for triage channel New triage commands to view and update channel configurations: - !channel list <guild> — Show detailed channel config - !channel set <guild> <channel> <key> <value> — Update a property - !channel add <guild> <channel> [text|forum] — Add a new channel - !channel remove <guild> <channel> — Remove a channel Changes are saved to config.json, committed, and pushed to main to persist and trigger auto-deploy. Also adds reloadConfig() and saveConfig() to config.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 223bda1 commit 69ba163

2 files changed

Lines changed: 207 additions & 5 deletions

File tree

src/config.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1-
import { readFile } from 'node:fs/promises'
1+
import { readFile, writeFile } from 'node:fs/promises'
22
import { join } from 'node:path'
33

44
let cachedConfig: Record<string, unknown> = {} as Record<string, unknown>
55
let configLoaded = false
66

7+
export function getConfigPath(): string {
8+
return join(process.cwd(), 'config.json')
9+
}
10+
711
export async function getConfig(): Promise<Record<string, unknown>> {
812
if (configLoaded) {
913
return cachedConfig
1014
}
11-
const configPath = join(process.cwd(), 'config.json')
1215
try {
13-
const payload = await readFile(configPath, 'utf-8')
16+
const payload = await readFile(getConfigPath(), 'utf-8')
1417
cachedConfig = JSON.parse(payload)
1518
} catch {
1619
cachedConfig = {}
@@ -19,6 +22,17 @@ export async function getConfig(): Promise<Record<string, unknown>> {
1922
return cachedConfig
2023
}
2124

25+
/** Re-read config.json from disk and update the cache. Returns the fresh config. */
26+
export async function reloadConfig(): Promise<Record<string, unknown>> {
27+
configLoaded = false
28+
return getConfig()
29+
}
30+
31+
/** Write the current cached config back to config.json. */
32+
export async function saveConfig(): Promise<void> {
33+
await writeFile(getConfigPath(), JSON.stringify(cachedConfig, null, 2) + '\n', 'utf-8')
34+
}
35+
2236
export function getValue<T>(config: Record<string, unknown>, path: string[], fallback: T): T {
2337
let current: unknown = config
2438
for (const segment of path) {

src/core/live.ts

Lines changed: 190 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { spawn, execSync } from 'node:child_process'
33
import { once } from 'node:events'
44
import { readFile, writeFile, readdir } from 'node:fs/promises'
55
import { join } from 'node:path'
6-
import { getConfig, getValue, resolveGuilds, resolveGuildId, getSystemPrompt, GuildConfig, ChannelConfig } from '../config'
6+
import { getConfig, getValue, resolveGuilds, resolveGuildId, getSystemPrompt, GuildConfig, ChannelConfig, reloadConfig, saveConfig } from '../config'
77
import { updateState, setPostedDiscordResponse, loadState, getPostedDiscordResponses, getLastOnlineAt, getFirstOnlineAt, setLastOnlineAt, getTriageRecord, setTriageRecord } from '../state'
88
import { ensureDir } from '../utils/fs'
99
import { REPO_ROOT, RESPONSES_DIR, DATA_DIR } from '../utils/paths'
@@ -113,7 +113,7 @@ async function postDeployChangelog(
113113
if (commitList) {
114114
lines.push('', commitList)
115115
}
116-
lines.push('', '**Commands:** `!status` `!channels` `!settings` `!sync-data` `!help` | `/ask` | `@bot social <topic>`')
116+
lines.push('', '**Commands:** `!status` `!channels` `!settings` `!channel` `!sync-data` `!help` | `/ask` | `@bot social <topic>`')
117117

118118
const msg = lines.join('\n')
119119
for (const [, ch] of triageChannels) {
@@ -125,6 +125,20 @@ async function postDeployChangelog(
125125
}
126126
}
127127

128+
/** Commit config.json and push to main so changes persist and deploy. */
129+
async function commitAndPushConfig(description: string): Promise<string> {
130+
const execOpts = { cwd: REPO_ROOT, encoding: 'utf-8' as const }
131+
try {
132+
execSync('git add config.json', execOpts)
133+
execSync(`git commit -m "config: ${description}\n\nCo-Authored-By: GameCI Help Bot <help-bot@game.ci>"`, execOpts)
134+
execSync('git pull --rebase origin main', execOpts)
135+
execSync('git push origin main', execOpts)
136+
return 'Pushed to main. Deploy will trigger automatically.'
137+
} catch (err: any) {
138+
return `Push failed: ${err.message ?? err}`
139+
}
140+
}
141+
128142
export interface LiveOptions {
129143
dispatchMode?: string
130144
repos?: string[]
@@ -505,13 +519,187 @@ export async function runLive(options: LiveOptions): Promise<void> {
505519
}
506520
return
507521
}
522+
// --- Channel management commands ---
523+
if (stripped.startsWith('channel ')) {
524+
const parts = stripped.slice('channel '.length).trim().split(/\s+/)
525+
const sub = parts[0]
526+
527+
// Helper: find guild config by name (case-insensitive)
528+
const findGuild = (name: string) => {
529+
const dc = getValue(config, ['discord'], {} as Record<string, unknown>)
530+
const gs = dc['guilds'] as GuildConfig[] | undefined
531+
if (!gs) return undefined
532+
return gs.find((g: GuildConfig) => g.name.toLowerCase() === name.toLowerCase())
533+
}
534+
535+
// Helper: rebuild in-memory guild mappings after config change
536+
const refreshMappings = () => {
537+
const dc = getValue(config, ['discord'], {} as Record<string, unknown>)
538+
const freshGuilds = resolveGuilds(dc)
539+
for (const guild of freshGuilds) {
540+
const gId = resolveGuildId(guild)
541+
if (!gId) continue
542+
const existing = guildMappings.get(gId)
543+
if (existing) {
544+
existing.guildConfig = guild
545+
existing.channelNameMap.clear()
546+
existing.channelMap.clear()
547+
for (const ch of guild.channels) {
548+
existing.channelNameMap.set(ch.name, ch)
549+
}
550+
// Re-resolve channel IDs from cache
551+
const dGuild = client.guilds.cache.get(gId)
552+
if (dGuild) {
553+
for (const [, channel] of dGuild.channels.cache) {
554+
if (channel.type === ChannelType.GuildText || channel.type === ChannelType.GuildForum) {
555+
const chCfg = existing.channelNameMap.get(channel.name)
556+
if (chCfg) existing.channelMap.set(channel.id, chCfg)
557+
}
558+
}
559+
}
560+
}
561+
}
562+
}
563+
564+
// !channel list <guild>
565+
if (sub === 'list') {
566+
const guildName = parts.slice(1).join(' ')
567+
if (!guildName) {
568+
await message.reply({ content: 'Usage: `!channel list <guild-name>`', allowedMentions: { repliedUser: false } }).catch(() => {})
569+
return
570+
}
571+
const guild = findGuild(guildName)
572+
if (!guild) {
573+
await message.reply({ content: `Guild "${guildName}" not found in config.`, allowedMentions: { repliedUser: false } }).catch(() => {})
574+
return
575+
}
576+
const lines = [`**Channels for ${guild.name}:**`]
577+
for (const ch of guild.channels) {
578+
const props = [
579+
ch.channel_type ?? 'text',
580+
ch.monitor !== false ? 'monitoring' : 'not monitored',
581+
ch.read_threads !== false ? 'threads' : 'no threads',
582+
ch.reply_mode ?? 'bot_api',
583+
]
584+
lines.push(`\`#${ch.name}\` — ${props.join(', ')}`)
585+
if (ch.system_prompt) lines.push(` *prompt:* ${ch.system_prompt.substring(0, 100)}${ch.system_prompt.length > 100 ? '...' : ''}`)
586+
}
587+
await message.reply({ content: lines.join('\n'), allowedMentions: { repliedUser: false } }).catch(() => {})
588+
return
589+
}
590+
591+
// !channel set <guild> <channel> <key> <value>
592+
if (sub === 'set') {
593+
if (parts.length < 5) {
594+
await message.reply({ content: 'Usage: `!channel set <guild> <channel> <key> <value>`\nKeys: `monitor`, `read_threads`, `channel_type`, `reply_mode`, `system_prompt`', allowedMentions: { repliedUser: false } }).catch(() => {})
595+
return
596+
}
597+
const guildName = parts[1]
598+
const channelName = parts[2]
599+
const key = parts[3]
600+
const value = parts.slice(4).join(' ')
601+
const guild = findGuild(guildName)
602+
if (!guild) {
603+
await message.reply({ content: `Guild "${guildName}" not found.`, allowedMentions: { repliedUser: false } }).catch(() => {})
604+
return
605+
}
606+
const ch = guild.channels.find((c: ChannelConfig) => c.name.toLowerCase() === channelName.toLowerCase())
607+
if (!ch) {
608+
await message.reply({ content: `Channel "${channelName}" not found in guild "${guild.name}".`, allowedMentions: { repliedUser: false } }).catch(() => {})
609+
return
610+
}
611+
const validKeys = ['monitor', 'read_threads', 'channel_type', 'reply_mode', 'system_prompt']
612+
if (!validKeys.includes(key)) {
613+
await message.reply({ content: `Invalid key "${key}". Valid keys: ${validKeys.map(k => `\`${k}\``).join(', ')}`, allowedMentions: { repliedUser: false } }).catch(() => {})
614+
return
615+
}
616+
// Parse booleans
617+
let parsed: unknown = value
618+
if (value === 'true') parsed = true
619+
else if (value === 'false') parsed = false
620+
;(ch as any)[key] = parsed
621+
await saveConfig()
622+
refreshMappings()
623+
const pushResult = await commitAndPushConfig(`${key}=${value} for #${ch.name} in ${guild.name}`)
624+
await message.reply({ content: `Updated \`#${ch.name}\` → \`${key}\` = \`${value}\`\n${pushResult}`, allowedMentions: { repliedUser: false } }).catch(() => {})
625+
return
626+
}
627+
628+
// !channel add <guild> <channel-name> [channel_type]
629+
if (sub === 'add') {
630+
if (parts.length < 3) {
631+
await message.reply({ content: 'Usage: `!channel add <guild> <channel-name> [text|forum]`', allowedMentions: { repliedUser: false } }).catch(() => {})
632+
return
633+
}
634+
const guildName = parts[1]
635+
const channelName = parts[2]
636+
const channelType = (parts[3] ?? 'text') as 'text' | 'forum'
637+
const guild = findGuild(guildName)
638+
if (!guild) {
639+
await message.reply({ content: `Guild "${guildName}" not found.`, allowedMentions: { repliedUser: false } }).catch(() => {})
640+
return
641+
}
642+
if (guild.channels.find((c: ChannelConfig) => c.name.toLowerCase() === channelName.toLowerCase())) {
643+
await message.reply({ content: `Channel "${channelName}" already exists in guild "${guild.name}".`, allowedMentions: { repliedUser: false } }).catch(() => {})
644+
return
645+
}
646+
const newCh: ChannelConfig = {
647+
name: channelName,
648+
channel_type: channelType,
649+
reply_mode: 'bot_api',
650+
read_threads: true,
651+
monitor: true,
652+
}
653+
guild.channels.push(newCh)
654+
await saveConfig()
655+
refreshMappings()
656+
const pushResult = await commitAndPushConfig(`add #${channelName} to ${guild.name}`)
657+
await message.reply({ content: `Added \`#${channelName}\` (${channelType}, monitored) to **${guild.name}**\n${pushResult}`, allowedMentions: { repliedUser: false } }).catch(() => {})
658+
return
659+
}
660+
661+
// !channel remove <guild> <channel-name>
662+
if (sub === 'remove') {
663+
if (parts.length < 3) {
664+
await message.reply({ content: 'Usage: `!channel remove <guild> <channel-name>`', allowedMentions: { repliedUser: false } }).catch(() => {})
665+
return
666+
}
667+
const guildName = parts[1]
668+
const channelName = parts[2]
669+
const guild = findGuild(guildName)
670+
if (!guild) {
671+
await message.reply({ content: `Guild "${guildName}" not found.`, allowedMentions: { repliedUser: false } }).catch(() => {})
672+
return
673+
}
674+
const idx = guild.channels.findIndex((c: ChannelConfig) => c.name.toLowerCase() === channelName.toLowerCase())
675+
if (idx === -1) {
676+
await message.reply({ content: `Channel "${channelName}" not found in guild "${guild.name}".`, allowedMentions: { repliedUser: false } }).catch(() => {})
677+
return
678+
}
679+
guild.channels.splice(idx, 1)
680+
await saveConfig()
681+
refreshMappings()
682+
const pushResult = await commitAndPushConfig(`remove #${channelName} from ${guild.name}`)
683+
await message.reply({ content: `Removed \`#${channelName}\` from **${guild.name}**\n${pushResult}`, allowedMentions: { repliedUser: false } }).catch(() => {})
684+
return
685+
}
686+
687+
// Unknown subcommand
688+
await message.reply({
689+
content: '**Channel commands:**\n`!channel list <guild>` — Show channel details\n`!channel set <guild> <channel> <key> <value>` — Update a channel property\n`!channel add <guild> <channel> [text|forum]` — Add a new channel\n`!channel remove <guild> <channel>` — Remove a channel',
690+
allowedMentions: { repliedUser: false },
691+
}).catch(() => {})
692+
return
693+
}
694+
508695
if (stripped === 'help') {
509696
const lines = [
510697
'**Triage commands** (`!` or `+` prefix):',
511698
'`!status` — Bot uptime, version, dispatch mode',
512699
'`!channels` — List monitored guilds and channels',
513700
'`!settings` — Show current configuration',
514701
'`!sync-data` — Sync data to private GitHub repo',
702+
'`!channel list|set|add|remove` — Manage channel config',
515703
'`!help` — This message',
516704
'',
517705
'**Slash commands:**',

0 commit comments

Comments
 (0)