@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 +15 -0
- package/README.md +11 -9
- package/config.example.json +4 -1
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/package.json +1 -1
- package/src/channels/discord/inbound.ts +8 -2
- package/src/channels/discord/runtime.ts +88 -19
- package/src/config.ts +6 -0
- package/src/gateway-service.ts +95 -0
- package/src/runtime-config.ts +77 -0
- package/tests/discord.basic.test.ts +52 -0
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.
|
|
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.
|
|
16
|
+
## What's new in v0.2.3
|
|
17
17
|
|
|
18
|
-
- Added Discord
|
|
19
|
-
- Added `
|
|
20
|
-
- Added
|
|
21
|
-
-
|
|
22
|
-
-
|
|
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)
|
package/config.example.json
CHANGED
|
@@ -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.
|
|
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.
|
|
9
|
+
"version": "0.2.3",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@mozilla/readability": "^0.6.0",
|
|
12
12
|
"agent-browser": "^0.15.1",
|
package/container/package.json
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
650
|
-
|
|
651
|
-
name:
|
|
652
|
-
description:
|
|
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
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
await guild.commands.edit(current.id, definition);
|
|
668
|
-
logger.info({ guildId: guild.id }, 'Updated slash command
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|
package/src/gateway-service.ts
CHANGED
|
@@ -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') {
|
package/src/runtime-config.ts
CHANGED
|
@@ -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
|
+
});
|