@hybridaione/hybridclaw 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,21 @@
8
8
 
9
9
  ### Fixed
10
10
 
11
+ ## [0.2.3](https://github.com/HybridAIOne/hybridclaw/tree/v0.2.3)
12
+
13
+ ### Added
14
+
15
+ - **Discord channel policy config**: Added typed runtime config support for `discord.groupPolicy` (`open`/`allowlist`/`disabled`), `discord.freeResponseChannels`, and per-guild/per-channel mode overrides at `discord.guilds.<guildId>.channels.<channelId>.mode`.
16
+ - **Discord channel mode slash command**: Added `/channel-mode` with `off`, `mention`, and `free` options to set the active guild channel behavior directly from Discord.
17
+ - **Gateway channel control commands**: Added `channel mode` and `channel policy` command flows for inspecting/updating Discord channel response behavior via `!claw` commands.
18
+
19
+ ### Changed
20
+
21
+ - **Discord trigger enforcement**: Guild message handling now applies channel mode + group policy before normal trigger checks, while still allowing prefixed commands in disabled channels.
22
+ - **Activation/status labeling**: Runtime status output now reflects `disabled`/`allowlist`/mixed free-channel activation modes instead of only legacy mention/all-messages labels.
23
+
24
+ ### Fixed
25
+
11
26
  ## [0.2.2](https://github.com/HybridAIOne/hybridclaw/tree/v0.2.2)
12
27
 
13
28
  ### Added
package/README.md CHANGED
@@ -11,17 +11,15 @@ npm install -g @hybridaione/hybridclaw
11
11
  hybridclaw onboarding
12
12
  ```
13
13
 
14
- Latest release: [v0.2.2](https://github.com/HybridAIOne/hybridclaw/releases/tag/v0.2.2)
14
+ Latest release: [v0.2.3](https://github.com/HybridAIOne/hybridclaw/releases/tag/v0.2.3)
15
15
 
16
- ## What's new in v0.2.2
16
+ ## What's new in v0.2.3
17
17
 
18
- - Added Discord attachment ingest/cache with structured media context (`path`, `mime`, `size`, `original_url`) passed into the agent pipeline
19
- - Added `vision_analyze`/`image` tools for Discord-uploaded image analysis (local cached path first, Discord CDN fallback)
20
- - Added native model vision image-part injection for vision-capable models, with safe fallback if multimodal input is rejected
21
- - Routed Discord image questions away from `browser_vision` (unless explicitly about the active browser tab/page)
22
- - Completed Discord runtime migration into `src/channels/discord/*` and removed the legacy root-level `src/discord.ts` shim
23
- - Switched tests from compiled `dist-tests` artifacts to direct TypeScript execution via Vitest
24
- - Moved basic tests to `tests/` with explicit scope naming conventions
18
+ - Added Discord guild channel policy controls with typed config: `discord.groupPolicy`, `discord.freeResponseChannels`, and `discord.guilds.<guildId>.channels.<channelId>.mode`
19
+ - Added `/channel-mode` slash command to switch a channel between `off`, `mention`, and `free`
20
+ - Added `!claw channel mode` and `!claw channel policy` command flows for in-chat policy changes
21
+ - Enforced channel mode/policy in Discord trigger logic while keeping prefixed commands available
22
+ - Updated status/activation labeling to reflect allowlist/disabled/mixed channel policy modes
25
23
 
26
24
  ## HybridAI Advantage
27
25
 
@@ -108,6 +106,10 @@ HybridClaw uses typed runtime config in `config.json` (auto-created on first run
108
106
  - `discord.respondToAllMessages` changes guild trigger behavior: `false` (default) replies only on mention/`!claw`; `true` replies to every user message in the channel
109
107
  - `discord.commandUserId` restricts `!claw <command>` admin commands to a single Discord user ID (all other messages still use normal chat handling)
110
108
  - `discord.commandsOnly` optional hard mode: if `true`, the bot ignores non-`!claw` messages and only accepts prefixed commands (optionally limited by `discord.commandUserId`)
109
+ - `discord.groupPolicy` controls guild channel scope: `open` (default), `allowlist`, or `disabled`
110
+ - `discord.freeResponseChannels` is a Hermes-style channel ID list that gets free-response behavior while other channels remain mention-gated
111
+ - `discord.guilds.<guildId>.channels.<channelId>.mode` sets per-channel behavior to `off`, `mention`, or `free` (works with `allowlist` policy)
112
+ - Discord slash commands: `/status` and `/channel-mode <off|mention|free>` (ephemeral replies)
111
113
  - `skills.extraDirs` adds additional enterprise/shared skill roots (lowest precedence tier)
112
114
  - `proactive.*` controls autonomous behavior (`activeHours`, `delegation`, `autoRetry`, `ralph`)
113
115
  - `proactive.ralph.maxIterations` enables Ralph loop (`0` off, `-1` unlimited, `>0` extra autonomous iterations before forcing completion)
@@ -15,7 +15,10 @@
15
15
  "presenceIntent": false,
16
16
  "respondToAllMessages": false,
17
17
  "commandsOnly": false,
18
- "commandUserId": ""
18
+ "commandUserId": "",
19
+ "groupPolicy": "open",
20
+ "freeResponseChannels": [],
21
+ "guilds": {}
19
22
  },
20
23
  "hybridai": {
21
24
  "baseUrl": "https://hybridai.one",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "hybridclaw-agent",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "hybridclaw-agent",
9
- "version": "0.2.2",
9
+ "version": "0.2.3",
10
10
  "dependencies": {
11
11
  "@mozilla/readability": "^0.6.0",
12
12
  "agent-browser": "^0.15.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hybridclaw-agent",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsc",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hybridaione/hybridclaw",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Personal AI assistant bot for Discord, powered by HybridAI",
6
6
  "publishConfig": {
@@ -4,6 +4,8 @@ export interface ParsedCommand {
4
4
  args: string[];
5
5
  }
6
6
 
7
+ export type DiscordGuildMessageMode = 'off' | 'mention' | 'free';
8
+
7
9
  export function stripBotMentions(text: string, botMentionRegex: RegExp | null): string {
8
10
  if (!botMentionRegex) return text;
9
11
  return text.replace(botMentionRegex, '').trim();
@@ -45,7 +47,7 @@ export function parseCommand(
45
47
  }
46
48
 
47
49
  const parts = text.split(/\s+/);
48
- const subcommands = ['bot', 'rag', 'model', 'sessions', 'audit', 'schedule', 'clear', 'help'];
50
+ const subcommands = ['bot', 'rag', 'model', 'sessions', 'audit', 'schedule', 'channel', 'clear', 'help'];
49
51
  if (parts.length > 0 && subcommands.includes(parts[0].toLowerCase())) {
50
52
  return { isCommand: true, command: parts[0].toLowerCase(), args: parts.slice(1) };
51
53
  }
@@ -58,6 +60,7 @@ export function isTrigger(params: {
58
60
  isDm: boolean;
59
61
  commandsOnly: boolean;
60
62
  respondToAllMessages: boolean;
63
+ guildMessageMode: DiscordGuildMessageMode;
61
64
  prefix: string;
62
65
  botMentionRegex: RegExp | null;
63
66
  hasBotMention: boolean;
@@ -66,7 +69,10 @@ export function isTrigger(params: {
66
69
  return hasPrefixInvocation(params.content, params.botMentionRegex, params.prefix);
67
70
  }
68
71
  if (params.isDm) return true;
72
+ if (hasPrefixInvocation(params.content, params.botMentionRegex, params.prefix)) return true;
73
+ if (params.guildMessageMode === 'off') return false;
74
+ if (params.guildMessageMode === 'free') return true;
69
75
  if (params.respondToAllMessages) return true;
70
76
  if (params.hasBotMention) return true;
71
- return params.content.startsWith(params.prefix);
77
+ return false;
72
78
  }
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  ActivityType,
3
+ ApplicationCommandOptionType,
3
4
  AttachmentBuilder,
4
5
  Client,
5
6
  GatewayIntentBits,
7
+ type ApplicationCommandDataResolvable,
6
8
  type Message as DiscordMessage,
7
9
  Partials,
8
10
  } from 'discord.js';
@@ -10,7 +12,10 @@ import {
10
12
  import {
11
13
  DISCORD_COMMAND_USER_ID,
12
14
  DISCORD_COMMANDS_ONLY,
15
+ DISCORD_FREE_RESPONSE_CHANNELS,
16
+ DISCORD_GROUP_POLICY,
13
17
  DISCORD_GUILD_MEMBERS_INTENT,
18
+ DISCORD_GUILDS,
14
19
  DISCORD_PRESENCE_INTENT,
15
20
  DISCORD_PREFIX,
16
21
  DISCORD_RESPOND_TO_ALL_MESSAGES,
@@ -23,6 +28,7 @@ import {
23
28
  hasPrefixInvocation as hasPrefixInvocationInbound,
24
29
  isTrigger as isTriggerInbound,
25
30
  parseCommand as parseCommandInbound,
31
+ type DiscordGuildMessageMode,
26
32
  type ParsedCommand,
27
33
  } from './inbound.js';
28
34
  import {
@@ -433,12 +439,29 @@ function buildSessionIdFromContext(guildId: string | null, channelId: string, us
433
439
  return buildSessionIdFromContextInbound(guildId, channelId, userId);
434
440
  }
435
441
 
442
+ function resolveGuildMessageMode(msg: DiscordMessage): DiscordGuildMessageMode {
443
+ if (!msg.guild) return 'free';
444
+ if (DISCORD_GROUP_POLICY === 'disabled') return 'off';
445
+
446
+ const guildConfig = DISCORD_GUILDS[msg.guild.id];
447
+ const explicitMode = guildConfig?.channels[msg.channelId]?.mode;
448
+ if (DISCORD_GROUP_POLICY === 'allowlist') {
449
+ return explicitMode ?? 'off';
450
+ }
451
+ if (explicitMode) return explicitMode;
452
+ if (DISCORD_FREE_RESPONSE_CHANNELS.includes(msg.channelId)) return 'free';
453
+ if (guildConfig) return guildConfig.defaultMode;
454
+ if (DISCORD_RESPOND_TO_ALL_MESSAGES) return 'free';
455
+ return 'mention';
456
+ }
457
+
436
458
  function isTrigger(msg: DiscordMessage): boolean {
437
459
  return isTriggerInbound({
438
460
  content: msg.content,
439
461
  isDm: !msg.guild,
440
462
  commandsOnly: DISCORD_COMMANDS_ONLY,
441
463
  respondToAllMessages: DISCORD_RESPOND_TO_ALL_MESSAGES,
464
+ guildMessageMode: resolveGuildMessageMode(msg),
442
465
  prefix: DISCORD_PREFIX,
443
466
  botMentionRegex,
444
467
  hasBotMention: Boolean(client.user && msg.mentions.has(client.user)),
@@ -646,29 +669,60 @@ async function sendChunkedInteractionReply(
646
669
  });
647
670
  }
648
671
 
649
- async function ensureSlashStatusCommand(): Promise<void> {
650
- const definition = {
651
- name: 'status',
652
- description: 'Show HybridClaw runtime status (only visible to you)',
653
- };
672
+ async function ensureSlashCommands(): Promise<void> {
673
+ interface SlashCommandDefinition {
674
+ name: string;
675
+ description: string;
676
+ options?: Array<{
677
+ type: ApplicationCommandOptionType.String;
678
+ name: string;
679
+ description: string;
680
+ required?: boolean;
681
+ choices?: Array<{ name: string; value: string }>;
682
+ }>;
683
+ }
684
+
685
+ const definitions: SlashCommandDefinition[] = [
686
+ {
687
+ name: 'status',
688
+ description: 'Show HybridClaw runtime status (only visible to you)',
689
+ },
690
+ {
691
+ name: 'channel-mode',
692
+ description: 'Set this channel to off, mention-only, or free-response',
693
+ options: [
694
+ {
695
+ type: ApplicationCommandOptionType.String,
696
+ name: 'mode',
697
+ description: 'Response mode for this channel',
698
+ required: true,
699
+ choices: [
700
+ { name: 'off', value: 'off' },
701
+ { name: 'mention', value: 'mention' },
702
+ { name: 'free', value: 'free' },
703
+ ],
704
+ },
705
+ ],
706
+ },
707
+ ];
654
708
 
655
709
  if (!client.application) return;
656
710
  await Promise.allSettled(
657
711
  [...client.guilds.cache.values()].map(async (guild) => {
658
712
  try {
659
713
  const existing = await guild.commands.fetch();
660
- const current = existing.find((command) => command.name === definition.name);
661
- if (!current) {
662
- await guild.commands.create(definition);
663
- logger.info({ guildId: guild.id }, 'Registered slash command /status');
664
- return;
665
- }
666
- if (current.description !== definition.description) {
667
- await guild.commands.edit(current.id, definition);
668
- logger.info({ guildId: guild.id }, 'Updated slash command /status');
714
+ for (const definition of definitions) {
715
+ const current = existing.find((command) => command.name === definition.name);
716
+ if (!current) {
717
+ await guild.commands.create(definition as unknown as ApplicationCommandDataResolvable);
718
+ logger.info({ guildId: guild.id, command: definition.name }, 'Registered slash command');
719
+ continue;
720
+ }
721
+ await guild.commands.edit(current.id, definition as unknown as ApplicationCommandDataResolvable);
722
+ logger.info({ guildId: guild.id, command: definition.name }, 'Updated slash command');
669
723
  }
670
724
  } catch (error) {
671
- logger.warn({ error, guildId: guild.id }, 'Failed to register slash command /status');
725
+ logger.warn({ error, guildId: guild.id }, 'Failed to register Discord slash commands');
672
726
  }
673
727
  }),
674
728
  );
@@ -818,12 +872,12 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
818
872
  botMentionRegex = new RegExp(`<@!?${client.user.id}>`, 'g');
819
873
  }
820
874
  updatePresence();
821
- void ensureSlashStatusCommand();
875
+ void ensureSlashCommands();
822
876
  });
823
877
 
824
878
  client.on('interactionCreate', async (interaction) => {
825
879
  if (!interaction.isChatInputCommand()) return;
826
- if (interaction.commandName !== 'status') return;
880
+ if (interaction.commandName !== 'status' && interaction.commandName !== 'channel-mode') return;
827
881
 
828
882
  if (!isAuthorizedCommandUserId(interaction.user.id)) {
829
883
  await sendChunkedInteractionReply(
@@ -836,19 +890,34 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
836
890
  const guildId = interaction.guildId ?? null;
837
891
  const channelId = interaction.channelId;
838
892
  const sessionId = buildSessionIdFromContext(guildId, channelId, interaction.user.id);
893
+ const args = interaction.commandName === 'status'
894
+ ? ['status']
895
+ : (() => {
896
+ if (!interaction.guildId) return null;
897
+ const selectedMode = interaction.options.getString('mode', true).trim().toLowerCase();
898
+ if (selectedMode !== 'off' && selectedMode !== 'mention' && selectedMode !== 'free') return null;
899
+ return ['channel', 'mode', selectedMode];
900
+ })();
901
+ if (!args) {
902
+ await sendChunkedInteractionReply(
903
+ interaction,
904
+ 'This command can only be used in a server channel with mode `off`, `mention`, or `free`.',
905
+ );
906
+ return;
907
+ }
839
908
  try {
840
909
  await commandHandler(
841
910
  sessionId,
842
911
  guildId,
843
912
  channelId,
844
- ['status'],
913
+ args,
845
914
  async (text, files) => sendChunkedInteractionReply(interaction, text, files),
846
915
  );
847
916
  } catch (error) {
848
917
  const detail = error instanceof Error ? error.message : String(error);
849
918
  logger.error(
850
919
  { error, guildId, channelId, userId: interaction.user.id },
851
- 'Discord slash /status command failed',
920
+ 'Discord slash command failed',
852
921
  );
853
922
  await sendChunkedInteractionReply(interaction, formatError('Gateway Error', detail));
854
923
  }
package/src/config.ts CHANGED
@@ -62,6 +62,9 @@ export let DISCORD_PRESENCE_INTENT = false;
62
62
  export let DISCORD_RESPOND_TO_ALL_MESSAGES = false;
63
63
  export let DISCORD_COMMANDS_ONLY = false;
64
64
  export let DISCORD_COMMAND_USER_ID = '';
65
+ export let DISCORD_GROUP_POLICY: RuntimeConfig['discord']['groupPolicy'] = 'open';
66
+ export let DISCORD_FREE_RESPONSE_CHANNELS: string[] = [];
67
+ export let DISCORD_GUILDS: RuntimeConfig['discord']['guilds'] = {};
65
68
 
66
69
  export let HYBRIDAI_BASE_URL = 'https://hybridai.one';
67
70
  export let HYBRIDAI_MODEL = 'gpt-5-nano';
@@ -138,6 +141,9 @@ function applyRuntimeConfig(config: RuntimeConfig): void {
138
141
  DISCORD_RESPOND_TO_ALL_MESSAGES = config.discord.respondToAllMessages;
139
142
  DISCORD_COMMANDS_ONLY = config.discord.commandsOnly;
140
143
  DISCORD_COMMAND_USER_ID = config.discord.commandUserId;
144
+ DISCORD_GROUP_POLICY = config.discord.groupPolicy;
145
+ DISCORD_FREE_RESPONSE_CHANNELS = [...config.discord.freeResponseChannels];
146
+ DISCORD_GUILDS = JSON.parse(JSON.stringify(config.discord.guilds)) as RuntimeConfig['discord']['guilds'];
141
147
 
142
148
  HYBRIDAI_BASE_URL = config.hybridai.baseUrl;
143
149
  HYBRIDAI_MODEL = config.hybridai.defaultModel;
@@ -4,6 +4,9 @@ import { spawnSync } from 'child_process';
4
4
  import {
5
5
  APP_VERSION,
6
6
  DISCORD_COMMANDS_ONLY,
7
+ DISCORD_FREE_RESPONSE_CHANNELS,
8
+ DISCORD_GROUP_POLICY,
9
+ DISCORD_GUILDS,
7
10
  DISCORD_RESPOND_TO_ALL_MESSAGES,
8
11
  HYBRIDAI_CHATBOT_ID,
9
12
  HYBRIDAI_ENABLE_RAG,
@@ -107,6 +110,8 @@ const ORCHESTRATOR_SUBAGENT_ALLOWED_TOOLS = [...BASE_SUBAGENT_ALLOWED_TOOLS, 'de
107
110
  const MAX_DELEGATION_TASKS = 6;
108
111
  const MAX_DELEGATION_USER_CHARS = 500;
109
112
  const MAX_RALPH_ITERATIONS = 64;
113
+ const DISCORD_CHANNEL_MODE_VALUES = new Set(['off', 'mention', 'free']);
114
+ const DISCORD_GROUP_POLICY_VALUES = new Set(['open', 'allowlist', 'disabled']);
110
115
  const IMAGE_QUESTION_RE =
111
116
  /(what(?:'s| is)? on (?:the )?(?:image|picture|photo|screenshot)|describe (?:this|the) (?:image|picture|photo)|image|picture|photo|screenshot|ocr|diagram|chart|grafik|bild|foto|was steht|was ist auf dem bild)/i;
112
117
  const BROWSER_TAB_RE =
@@ -403,10 +408,35 @@ function formatPercent(value: number | null): string {
403
408
 
404
409
  function resolveActivationModeLabel(): string {
405
410
  if (DISCORD_COMMANDS_ONLY) return 'commands-only';
411
+ if (DISCORD_GROUP_POLICY === 'disabled') return 'disabled';
412
+ if (DISCORD_GROUP_POLICY === 'allowlist') return 'allowlist';
413
+ if (DISCORD_FREE_RESPONSE_CHANNELS.length > 0) return `mention + ${DISCORD_FREE_RESPONSE_CHANNELS.length} free channel(s)`;
406
414
  if (DISCORD_RESPOND_TO_ALL_MESSAGES) return 'all messages';
407
415
  return 'mention';
408
416
  }
409
417
 
418
+ function resolveGuildChannelMode(guildId: string | null, channelId: string): 'off' | 'mention' | 'free' {
419
+ if (!guildId) return 'free';
420
+ if (DISCORD_GROUP_POLICY === 'disabled') return 'off';
421
+ const guild = DISCORD_GUILDS[guildId];
422
+ const explicit = guild?.channels[channelId]?.mode;
423
+ if (DISCORD_GROUP_POLICY === 'allowlist') {
424
+ return explicit ?? 'off';
425
+ }
426
+ if (explicit === 'off' || explicit === 'mention' || explicit === 'free') {
427
+ return explicit;
428
+ }
429
+ if (DISCORD_FREE_RESPONSE_CHANNELS.includes(channelId)) return 'free';
430
+ if (guild) {
431
+ const defaultMode = guild.defaultMode;
432
+ if (defaultMode === 'off' || defaultMode === 'mention' || defaultMode === 'free') {
433
+ return defaultMode;
434
+ }
435
+ }
436
+ if (DISCORD_RESPOND_TO_ALL_MESSAGES) return 'free';
437
+ return 'mention';
438
+ }
439
+
410
440
  interface SessionStatusSnapshot {
411
441
  promptTokens: number | null;
412
442
  completionTokens: number | null;
@@ -1578,9 +1608,12 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1578
1608
  '`model set <name>` — Set model for this session',
1579
1609
  '`model info` — Show current model',
1580
1610
  '`rag [on|off]` — Toggle or set RAG mode',
1611
+ '`channel mode [off|mention|free]` — Set or inspect this Discord channel response mode',
1612
+ '`channel policy [open|allowlist|disabled]` — Set or inspect guild channel policy',
1581
1613
  '`ralph [on|off|set <n>|info]` — Configure Ralph loop (0 off, -1 unlimited)',
1582
1614
  '`clear` — Clear session history',
1583
1615
  '`/status` — Show runtime status (Discord slash command, private to caller)',
1616
+ '`/channel-mode <off|mention|free>` — Set this Discord channel response mode',
1584
1617
  '`sessions` — List active sessions',
1585
1618
  '`schedule add "<cron>" <prompt>` — Add scheduled task',
1586
1619
  '`schedule list` — List scheduled tasks',
@@ -1682,6 +1715,68 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1682
1715
  return badCommand('Usage', 'Usage: `rag [on|off]`');
1683
1716
  }
1684
1717
 
1718
+ case 'channel': {
1719
+ const sub = (req.args[1] || '').toLowerCase();
1720
+ if (sub === 'mode' || !sub) {
1721
+ const guildId = req.guildId;
1722
+ if (!guildId) {
1723
+ return badCommand('Guild Only', '`channel mode` is only available in Discord guild channels.');
1724
+ }
1725
+ const requestedMode = (req.args[sub ? 2 : 1] || '').toLowerCase();
1726
+ if (!requestedMode) {
1727
+ const currentMode = resolveGuildChannelMode(guildId, req.channelId);
1728
+ return infoCommand(
1729
+ 'Channel Mode',
1730
+ [
1731
+ `Current mode: \`${currentMode}\``,
1732
+ `Group policy: \`${DISCORD_GROUP_POLICY}\``,
1733
+ `Config path: \`discord.guilds.${guildId}.channels.${req.channelId}.mode\``,
1734
+ 'Usage: `channel mode off|mention|free`',
1735
+ ].join('\n'),
1736
+ );
1737
+ }
1738
+ if (!DISCORD_CHANNEL_MODE_VALUES.has(requestedMode)) {
1739
+ return badCommand('Usage', 'Usage: `channel mode off|mention|free`');
1740
+ }
1741
+ const mode = requestedMode as 'off' | 'mention' | 'free';
1742
+ updateRuntimeConfig((draft) => {
1743
+ const guild = draft.discord.guilds[guildId] ?? { defaultMode: 'mention', channels: {} };
1744
+ guild.channels[req.channelId] = { mode };
1745
+ draft.discord.guilds[guildId] = guild;
1746
+ });
1747
+ return plainCommand(
1748
+ `Set channel mode to \`${mode}\` for this channel. (Policy: \`${DISCORD_GROUP_POLICY}\`)`,
1749
+ );
1750
+ }
1751
+
1752
+ if (sub === 'policy') {
1753
+ const requestedPolicy = (req.args[2] || '').toLowerCase();
1754
+ if (!requestedPolicy) {
1755
+ return infoCommand(
1756
+ 'Channel Policy',
1757
+ [
1758
+ `Current policy: \`${DISCORD_GROUP_POLICY}\``,
1759
+ 'Policies:',
1760
+ '• `open` — all guild channels are active unless a per-channel mode overrides',
1761
+ '• `allowlist` — only channels listed under `discord.guilds.<guild>.channels` are active',
1762
+ '• `disabled` — all guild channels are disabled',
1763
+ 'Usage: `channel policy open|allowlist|disabled`',
1764
+ ].join('\n'),
1765
+ );
1766
+ }
1767
+ if (!DISCORD_GROUP_POLICY_VALUES.has(requestedPolicy)) {
1768
+ return badCommand('Usage', 'Usage: `channel policy open|allowlist|disabled`');
1769
+ }
1770
+ const policy = requestedPolicy as 'open' | 'allowlist' | 'disabled';
1771
+ updateRuntimeConfig((draft) => {
1772
+ draft.discord.groupPolicy = policy;
1773
+ });
1774
+ return plainCommand(`Discord group policy set to \`${policy}\`.`);
1775
+ }
1776
+
1777
+ return badCommand('Usage', 'Usage: `channel mode [off|mention|free]` or `channel policy [open|allowlist|disabled]`');
1778
+ }
1779
+
1685
1780
  case 'ralph': {
1686
1781
  const sub = (req.args[1] || '').toLowerCase();
1687
1782
  if (!sub || sub === 'info' || sub === 'status') {
@@ -24,6 +24,14 @@ export interface RuntimeSecurityConfig {
24
24
  trustModelAcceptedBy: string;
25
25
  }
26
26
 
27
+ export type DiscordGroupPolicy = 'open' | 'allowlist' | 'disabled';
28
+ export type DiscordChannelMode = 'off' | 'mention' | 'free';
29
+
30
+ export interface RuntimeDiscordGuildConfig {
31
+ defaultMode: DiscordChannelMode;
32
+ channels: Record<string, { mode: DiscordChannelMode }>;
33
+ }
34
+
27
35
  export interface RuntimeConfig {
28
36
  version: number;
29
37
  security: RuntimeSecurityConfig;
@@ -37,6 +45,9 @@ export interface RuntimeConfig {
37
45
  respondToAllMessages: boolean;
38
46
  commandsOnly: boolean;
39
47
  commandUserId: string;
48
+ groupPolicy: DiscordGroupPolicy;
49
+ freeResponseChannels: string[];
50
+ guilds: Record<string, RuntimeDiscordGuildConfig>;
40
51
  };
41
52
  hybridai: {
42
53
  baseUrl: string;
@@ -143,6 +154,9 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
143
154
  respondToAllMessages: false,
144
155
  commandsOnly: false,
145
156
  commandUserId: '',
157
+ groupPolicy: 'open',
158
+ freeResponseChannels: [],
159
+ guilds: {},
146
160
  },
147
161
  hybridai: {
148
162
  baseUrl: 'https://hybridai.one',
@@ -312,6 +326,60 @@ function normalizeStringArray(value: unknown, fallback: string[]): string[] {
312
326
  return fallback;
313
327
  }
314
328
 
329
+ function normalizeDiscordGroupPolicy(value: unknown, fallback: DiscordGroupPolicy): DiscordGroupPolicy {
330
+ if (typeof value !== 'string') return fallback;
331
+ const normalized = value.trim().toLowerCase();
332
+ if (normalized === 'open' || normalized === 'allowlist' || normalized === 'disabled') {
333
+ return normalized;
334
+ }
335
+ return fallback;
336
+ }
337
+
338
+ function normalizeDiscordChannelMode(value: unknown, fallback: DiscordChannelMode): DiscordChannelMode {
339
+ if (typeof value !== 'string') return fallback;
340
+ const normalized = value.trim().toLowerCase();
341
+ if (normalized === 'off' || normalized === 'mention' || normalized === 'free') return normalized;
342
+ if (normalized === 'free-response' || normalized === 'free_response') return 'free';
343
+ return fallback;
344
+ }
345
+
346
+ function normalizeDiscordGuildConfig(
347
+ value: unknown,
348
+ fallback: RuntimeDiscordGuildConfig,
349
+ ): RuntimeDiscordGuildConfig {
350
+ if (!isRecord(value)) return fallback;
351
+ const defaultMode = normalizeDiscordChannelMode(value.defaultMode, fallback.defaultMode);
352
+ const rawChannels = isRecord(value.channels) ? value.channels : {};
353
+ const channels: Record<string, { mode: DiscordChannelMode }> = {};
354
+ for (const [rawChannelId, rawChannelConfig] of Object.entries(rawChannels)) {
355
+ const channelId = rawChannelId.trim();
356
+ if (!channelId) continue;
357
+ if (typeof rawChannelConfig === 'string') {
358
+ channels[channelId] = { mode: normalizeDiscordChannelMode(rawChannelConfig, defaultMode) };
359
+ continue;
360
+ }
361
+ if (!isRecord(rawChannelConfig)) continue;
362
+ channels[channelId] = { mode: normalizeDiscordChannelMode(rawChannelConfig.mode, defaultMode) };
363
+ }
364
+
365
+ return { defaultMode, channels };
366
+ }
367
+
368
+ function normalizeDiscordGuildMap(
369
+ value: unknown,
370
+ fallback: Record<string, RuntimeDiscordGuildConfig>,
371
+ ): Record<string, RuntimeDiscordGuildConfig> {
372
+ if (!isRecord(value)) return fallback;
373
+ const guilds: Record<string, RuntimeDiscordGuildConfig> = {};
374
+ for (const [rawGuildId, rawGuildConfig] of Object.entries(value)) {
375
+ const guildId = rawGuildId.trim();
376
+ if (!guildId) continue;
377
+ const fallbackGuild = fallback[guildId] ?? { defaultMode: 'mention', channels: {} };
378
+ guilds[guildId] = normalizeDiscordGuildConfig(rawGuildConfig, fallbackGuild);
379
+ }
380
+ return guilds;
381
+ }
382
+
315
383
  function normalizeLogLevel(value: unknown, fallback: LogLevel): LogLevel {
316
384
  const normalized = normalizeString(value, fallback, { allowEmpty: false }).toLowerCase();
317
385
  if (KNOWN_LOG_LEVELS.has(normalized)) return normalized as LogLevel;
@@ -419,6 +487,15 @@ function normalizeRuntimeConfig(patch?: DeepPartial<RuntimeConfig>): RuntimeConf
419
487
  DEFAULT_RUNTIME_CONFIG.discord.commandUserId,
420
488
  { allowEmpty: true },
421
489
  ),
490
+ groupPolicy: normalizeDiscordGroupPolicy(
491
+ rawDiscord.groupPolicy,
492
+ DEFAULT_RUNTIME_CONFIG.discord.groupPolicy,
493
+ ),
494
+ freeResponseChannels: normalizeStringArray(
495
+ rawDiscord.freeResponseChannels,
496
+ DEFAULT_RUNTIME_CONFIG.discord.freeResponseChannels,
497
+ ),
498
+ guilds: normalizeDiscordGuildMap(rawDiscord.guilds, DEFAULT_RUNTIME_CONFIG.discord.guilds),
422
499
  },
423
500
  hybridai: {
424
501
  baseUrl: hybridBaseUrl,
@@ -1,6 +1,7 @@
1
1
  import { expect, test } from 'vitest';
2
2
 
3
3
  import { buildResponseText } from '../src/channels/discord/delivery.js';
4
+ import { isTrigger, parseCommand } from '../src/channels/discord/inbound.js';
4
5
  import { rewriteUserMentions, type MentionLookup } from '../src/channels/discord/mentions.js';
5
6
 
6
7
  function createLookup(entries: Record<string, string[]>): MentionLookup {
@@ -41,3 +42,54 @@ test('buildResponseText leaves text unchanged when no tools were used', () => {
41
42
  const output = buildResponseText('Done.');
42
43
  expect(output).toBe('Done.');
43
44
  });
45
+
46
+ test('isTrigger blocks non-command chatter when channel mode is off', () => {
47
+ const shouldTrigger = isTrigger({
48
+ content: 'hello',
49
+ isDm: false,
50
+ commandsOnly: false,
51
+ respondToAllMessages: false,
52
+ guildMessageMode: 'off',
53
+ prefix: '!claw',
54
+ botMentionRegex: null,
55
+ hasBotMention: false,
56
+ });
57
+ expect(shouldTrigger).toBe(false);
58
+ });
59
+
60
+ test('isTrigger still allows prefixed commands when channel mode is off', () => {
61
+ const shouldTrigger = isTrigger({
62
+ content: '!claw status',
63
+ isDm: false,
64
+ commandsOnly: false,
65
+ respondToAllMessages: false,
66
+ guildMessageMode: 'off',
67
+ prefix: '!claw',
68
+ botMentionRegex: null,
69
+ hasBotMention: false,
70
+ });
71
+ expect(shouldTrigger).toBe(true);
72
+ });
73
+
74
+ test('isTrigger allows free-response mode in guild channels', () => {
75
+ const shouldTrigger = isTrigger({
76
+ content: 'hello',
77
+ isDm: false,
78
+ commandsOnly: false,
79
+ respondToAllMessages: false,
80
+ guildMessageMode: 'free',
81
+ prefix: '!claw',
82
+ botMentionRegex: null,
83
+ hasBotMention: false,
84
+ });
85
+ expect(shouldTrigger).toBe(true);
86
+ });
87
+
88
+ test('parseCommand recognizes channel command namespace', () => {
89
+ const parsed = parseCommand('!claw channel mode free', null, '!claw');
90
+ expect(parsed).toEqual({
91
+ isCommand: true,
92
+ command: 'channel',
93
+ args: ['mode', 'free'],
94
+ });
95
+ });