@@ -3,7 +3,7 @@ import { spawn, execSync } from 'node:child_process'
33import { once } from 'node:events'
44import { readFile , writeFile , readdir } from 'node:fs/promises'
55import { 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'
77import { updateState , setPostedDiscordResponse , loadState , getPostedDiscordResponses , getLastOnlineAt , getFirstOnlineAt , setLastOnlineAt , getTriageRecord , setTriageRecord } from '../state'
88import { ensureDir } from '../utils/fs'
99import { 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+
128142export 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