@hybridaione/hybridclaw 0.2.2 → 0.2.6
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/.github/workflows/ci.yml +70 -0
- package/.husky/pre-commit +1 -0
- package/CHANGELOG.md +85 -0
- package/CONTRIBUTING.md +33 -0
- package/README.md +41 -16
- package/SECURITY.md +17 -0
- package/biome.json +35 -0
- package/config.example.json +71 -8
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/approval-policy.ts +1303 -0
- package/container/src/browser-tools.ts +431 -136
- package/container/src/extensions.ts +36 -12
- package/container/src/hybridai-client.ts +34 -13
- package/container/src/index.ts +451 -109
- package/container/src/ipc.ts +5 -3
- package/container/src/token-usage.ts +20 -10
- package/container/src/tools.ts +599 -225
- package/container/src/types.ts +32 -2
- package/container/src/web-fetch.ts +89 -32
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -2
- package/dist/agent.js.map +1 -1
- package/dist/audit-cli.d.ts.map +1 -1
- package/dist/audit-cli.js +4 -2
- package/dist/audit-cli.js.map +1 -1
- package/dist/audit-events.d.ts.map +1 -1
- package/dist/audit-events.js +53 -3
- package/dist/audit-events.js.map +1 -1
- package/dist/audit-trail.d.ts.map +1 -1
- package/dist/audit-trail.js +17 -8
- package/dist/audit-trail.js.map +1 -1
- package/dist/channels/discord/attachments.d.ts.map +1 -1
- package/dist/channels/discord/attachments.js +14 -7
- package/dist/channels/discord/attachments.js.map +1 -1
- package/dist/channels/discord/debounce.d.ts +9 -0
- package/dist/channels/discord/debounce.d.ts.map +1 -0
- package/dist/channels/discord/debounce.js +20 -0
- package/dist/channels/discord/debounce.js.map +1 -0
- package/dist/channels/discord/delivery.d.ts +4 -1
- package/dist/channels/discord/delivery.d.ts.map +1 -1
- package/dist/channels/discord/delivery.js +19 -3
- package/dist/channels/discord/delivery.js.map +1 -1
- package/dist/channels/discord/human-delay.d.ts +16 -0
- package/dist/channels/discord/human-delay.d.ts.map +1 -0
- package/dist/channels/discord/human-delay.js +29 -0
- package/dist/channels/discord/human-delay.js.map +1 -0
- package/dist/channels/discord/inbound.d.ts +4 -0
- package/dist/channels/discord/inbound.d.ts.map +1 -1
- package/dist/channels/discord/inbound.js +45 -4
- package/dist/channels/discord/inbound.js.map +1 -1
- package/dist/channels/discord/mentions.d.ts.map +1 -1
- package/dist/channels/discord/mentions.js +16 -4
- package/dist/channels/discord/mentions.js.map +1 -1
- package/dist/channels/discord/presence.d.ts +33 -0
- package/dist/channels/discord/presence.d.ts.map +1 -0
- package/dist/channels/discord/presence.js +111 -0
- package/dist/channels/discord/presence.js.map +1 -0
- package/dist/channels/discord/rate-limiter.d.ts +14 -0
- package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
- package/dist/channels/discord/rate-limiter.js +49 -0
- package/dist/channels/discord/rate-limiter.js.map +1 -0
- package/dist/channels/discord/reactions.d.ts +38 -0
- package/dist/channels/discord/reactions.d.ts.map +1 -0
- package/dist/channels/discord/reactions.js +151 -0
- package/dist/channels/discord/reactions.js.map +1 -0
- package/dist/channels/discord/runtime.d.ts +6 -3
- package/dist/channels/discord/runtime.d.ts.map +1 -1
- package/dist/channels/discord/runtime.js +621 -125
- package/dist/channels/discord/runtime.js.map +1 -1
- package/dist/channels/discord/stream.d.ts +4 -1
- package/dist/channels/discord/stream.d.ts.map +1 -1
- package/dist/channels/discord/stream.js +16 -8
- package/dist/channels/discord/stream.js.map +1 -1
- package/dist/channels/discord/tool-actions.d.ts.map +1 -1
- package/dist/channels/discord/tool-actions.js +24 -12
- package/dist/channels/discord/tool-actions.js.map +1 -1
- package/dist/channels/discord/typing.d.ts +15 -0
- package/dist/channels/discord/typing.d.ts.map +1 -0
- package/dist/channels/discord/typing.js +106 -0
- package/dist/channels/discord/typing.js.map +1 -0
- package/dist/chunk.d.ts.map +1 -1
- package/dist/chunk.js +4 -2
- package/dist/chunk.js.map +1 -1
- package/dist/cli.js +47 -22
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +103 -18
- package/dist/config.js.map +1 -1
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +58 -26
- package/dist/container-runner.js.map +1 -1
- package/dist/container-setup.d.ts.map +1 -1
- package/dist/container-setup.js +10 -9
- package/dist/container-setup.js.map +1 -1
- package/dist/conversation.d.ts +2 -2
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +1 -1
- package/dist/conversation.js.map +1 -1
- package/dist/db.d.ts +118 -2
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +1568 -50
- package/dist/db.js.map +1 -1
- package/dist/delegation-manager.d.ts.map +1 -1
- package/dist/delegation-manager.js +3 -2
- package/dist/delegation-manager.js.map +1 -1
- package/dist/gateway-client.d.ts +2 -2
- package/dist/gateway-client.d.ts.map +1 -1
- package/dist/gateway-client.js +10 -4
- package/dist/gateway-client.js.map +1 -1
- package/dist/gateway-service.d.ts +3 -3
- package/dist/gateway-service.d.ts.map +1 -1
- package/dist/gateway-service.js +563 -73
- package/dist/gateway-service.js.map +1 -1
- package/dist/gateway-types.d.ts +24 -0
- package/dist/gateway-types.d.ts.map +1 -1
- package/dist/gateway-types.js.map +1 -1
- package/dist/gateway.js +179 -24
- package/dist/gateway.js.map +1 -1
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +20 -10
- package/dist/health.js.map +1 -1
- package/dist/heartbeat.d.ts +4 -0
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +48 -20
- package/dist/heartbeat.js.map +1 -1
- package/dist/hybridai-bots.d.ts.map +1 -1
- package/dist/hybridai-bots.js +4 -2
- package/dist/hybridai-bots.js.map +1 -1
- package/dist/instruction-approval-audit.d.ts.map +1 -1
- package/dist/instruction-approval-audit.js.map +1 -1
- package/dist/instruction-integrity.d.ts.map +1 -1
- package/dist/instruction-integrity.js +8 -2
- package/dist/instruction-integrity.js.map +1 -1
- package/dist/ipc.d.ts.map +1 -1
- package/dist/ipc.js +6 -1
- package/dist/ipc.js.map +1 -1
- package/dist/logger.js.map +1 -1
- package/dist/memory-consolidation.d.ts +17 -0
- package/dist/memory-consolidation.d.ts.map +1 -0
- package/dist/memory-consolidation.js +25 -0
- package/dist/memory-consolidation.js.map +1 -0
- package/dist/memory-service.d.ts +200 -0
- package/dist/memory-service.d.ts.map +1 -0
- package/dist/memory-service.js +294 -0
- package/dist/memory-service.js.map +1 -0
- package/dist/mount-security.d.ts.map +1 -1
- package/dist/mount-security.js +31 -7
- package/dist/mount-security.js.map +1 -1
- package/dist/observability-ingest.d.ts.map +1 -1
- package/dist/observability-ingest.js +32 -11
- package/dist/observability-ingest.js.map +1 -1
- package/dist/onboarding.d.ts.map +1 -1
- package/dist/onboarding.js +32 -9
- package/dist/onboarding.js.map +1 -1
- package/dist/proactive-policy.d.ts.map +1 -1
- package/dist/proactive-policy.js +2 -1
- package/dist/proactive-policy.js.map +1 -1
- package/dist/prompt-hooks.d.ts.map +1 -1
- package/dist/prompt-hooks.js +9 -7
- package/dist/prompt-hooks.js.map +1 -1
- package/dist/runtime-config.d.ts +98 -1
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +477 -23
- package/dist/runtime-config.js.map +1 -1
- package/dist/scheduled-task-runner.d.ts +1 -0
- package/dist/scheduled-task-runner.d.ts.map +1 -1
- package/dist/scheduled-task-runner.js +29 -10
- package/dist/scheduled-task-runner.js.map +1 -1
- package/dist/scheduler.d.ts +43 -4
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +530 -56
- package/dist/scheduler.js.map +1 -1
- package/dist/session-export.d.ts +26 -0
- package/dist/session-export.d.ts.map +1 -0
- package/dist/session-export.js +149 -0
- package/dist/session-export.js.map +1 -0
- package/dist/session-maintenance.d.ts.map +1 -1
- package/dist/session-maintenance.js +75 -13
- package/dist/session-maintenance.js.map +1 -1
- package/dist/session-transcripts.d.ts.map +1 -1
- package/dist/session-transcripts.js.map +1 -1
- package/dist/side-effects.d.ts.map +1 -1
- package/dist/side-effects.js +14 -2
- package/dist/side-effects.js.map +1 -1
- package/dist/skills-guard.d.ts.map +1 -1
- package/dist/skills-guard.js +893 -130
- package/dist/skills-guard.js.map +1 -1
- package/dist/skills.d.ts +5 -0
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +29 -15
- package/dist/skills.js.map +1 -1
- package/dist/token-efficiency.d.ts.map +1 -1
- package/dist/token-efficiency.js.map +1 -1
- package/dist/tui.js +92 -11
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +146 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +24 -1
- package/dist/types.js.map +1 -1
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +42 -14
- package/dist/update.js.map +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +49 -9
- package/dist/workspace.js.map +1 -1
- package/docs/chat.html +9 -3
- package/docs/index.html +37 -13
- package/package.json +8 -2
- package/src/agent.ts +16 -3
- package/src/audit-cli.ts +44 -16
- package/src/audit-events.ts +69 -5
- package/src/audit-trail.ts +41 -15
- package/src/channels/discord/attachments.ts +81 -27
- package/src/channels/discord/debounce.ts +25 -0
- package/src/channels/discord/delivery.ts +57 -13
- package/src/channels/discord/human-delay.ts +48 -0
- package/src/channels/discord/inbound.ts +66 -7
- package/src/channels/discord/mentions.ts +42 -18
- package/src/channels/discord/presence.ts +148 -0
- package/src/channels/discord/rate-limiter.ts +58 -0
- package/src/channels/discord/reactions.ts +211 -0
- package/src/channels/discord/runtime.ts +1048 -182
- package/src/channels/discord/stream.ts +73 -27
- package/src/channels/discord/tool-actions.ts +78 -37
- package/src/channels/discord/typing.ts +140 -0
- package/src/chunk.ts +12 -4
- package/src/cli.ts +141 -56
- package/src/config.ts +192 -34
- package/src/container-runner.ts +132 -42
- package/src/container-setup.ts +57 -22
- package/src/conversation.ts +9 -7
- package/src/db.ts +2217 -84
- package/src/delegation-manager.ts +6 -2
- package/src/gateway-client.ts +41 -17
- package/src/gateway-service.ts +1019 -201
- package/src/gateway-types.ts +33 -0
- package/src/gateway.ts +321 -48
- package/src/health.ts +66 -26
- package/src/heartbeat.ts +84 -22
- package/src/hybridai-bots.ts +14 -5
- package/src/instruction-approval-audit.ts +4 -1
- package/src/instruction-integrity.ts +30 -9
- package/src/ipc.ts +23 -5
- package/src/logger.ts +4 -1
- package/src/memory-consolidation.ts +41 -0
- package/src/memory-service.ts +606 -0
- package/src/mount-security.ts +58 -13
- package/src/observability-ingest.ts +134 -35
- package/src/onboarding.ts +126 -35
- package/src/proactive-policy.ts +3 -1
- package/src/prompt-hooks.ts +40 -17
- package/src/runtime-config.ts +1114 -99
- package/src/scheduled-task-runner.ts +63 -11
- package/src/scheduler.ts +683 -60
- package/src/session-export.ts +196 -0
- package/src/session-maintenance.ts +125 -22
- package/src/session-transcripts.ts +12 -3
- package/src/side-effects.ts +28 -5
- package/src/skills-guard.ts +1067 -219
- package/src/skills.ts +163 -65
- package/src/token-efficiency.ts +31 -9
- package/src/tui.ts +166 -25
- package/src/types.ts +195 -2
- package/src/update.ts +79 -23
- package/src/workspace.ts +63 -11
- package/tests/approval-policy.test.ts +224 -0
- package/tests/discord.basic.test.ts +82 -2
- package/tests/discord.human-presence.test.ts +85 -0
- package/tests/gateway-service.media-routing.test.ts +8 -2
- package/tests/memory-service.test.ts +1114 -0
- package/tests/token-efficiency.basic.test.ts +8 -2
- package/vitest.e2e.config.ts +3 -1
- package/vitest.integration.config.ts +3 -1
- package/vitest.live.config.ts +3 -1
- package/vitest.unit.config.ts +9 -0
|
@@ -4,7 +4,15 @@ export interface ParsedCommand {
|
|
|
4
4
|
args: string[];
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
export
|
|
7
|
+
export type DiscordGuildMessageMode = 'off' | 'mention' | 'free';
|
|
8
|
+
|
|
9
|
+
const GREETING_ONLY_RE =
|
|
10
|
+
/^(hi|hey|hello|yo|sup|thanks|thank you|thx|ok|okay|got it|roger|cool)[!. ]*$/i;
|
|
11
|
+
|
|
12
|
+
export function stripBotMentions(
|
|
13
|
+
text: string,
|
|
14
|
+
botMentionRegex: RegExp | null,
|
|
15
|
+
): string {
|
|
8
16
|
if (!botMentionRegex) return text;
|
|
9
17
|
return text.replace(botMentionRegex, '').trim();
|
|
10
18
|
}
|
|
@@ -30,7 +38,11 @@ export function hasPrefixInvocation(
|
|
|
30
38
|
return text.startsWith(prefix);
|
|
31
39
|
}
|
|
32
40
|
|
|
33
|
-
export function buildSessionIdFromContext(
|
|
41
|
+
export function buildSessionIdFromContext(
|
|
42
|
+
guildId: string | null,
|
|
43
|
+
channelId: string,
|
|
44
|
+
userId: string,
|
|
45
|
+
): string {
|
|
34
46
|
return guildId ? `${guildId}:${channelId}` : `dm:${userId}`;
|
|
35
47
|
}
|
|
36
48
|
|
|
@@ -45,28 +57,75 @@ export function parseCommand(
|
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
const parts = text.split(/\s+/);
|
|
48
|
-
const subcommands = [
|
|
60
|
+
const subcommands = [
|
|
61
|
+
'bot',
|
|
62
|
+
'rag',
|
|
63
|
+
'model',
|
|
64
|
+
'usage',
|
|
65
|
+
'export',
|
|
66
|
+
'sessions',
|
|
67
|
+
'audit',
|
|
68
|
+
'schedule',
|
|
69
|
+
'channel',
|
|
70
|
+
'clear',
|
|
71
|
+
'help',
|
|
72
|
+
];
|
|
49
73
|
if (parts.length > 0 && subcommands.includes(parts[0].toLowerCase())) {
|
|
50
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
isCommand: true,
|
|
76
|
+
command: parts[0].toLowerCase(),
|
|
77
|
+
args: parts.slice(1),
|
|
78
|
+
};
|
|
51
79
|
}
|
|
52
80
|
|
|
53
81
|
return { isCommand: false, command: '', args: [] };
|
|
54
82
|
}
|
|
55
83
|
|
|
84
|
+
export function shouldSuppressAutoReply(
|
|
85
|
+
content: string,
|
|
86
|
+
suppressPatterns?: string[],
|
|
87
|
+
): boolean {
|
|
88
|
+
const normalized = content.trim().toLowerCase();
|
|
89
|
+
if (!normalized) return false;
|
|
90
|
+
if (GREETING_ONLY_RE.test(normalized)) return true;
|
|
91
|
+
if (!suppressPatterns || suppressPatterns.length === 0) return false;
|
|
92
|
+
return suppressPatterns.some((pattern) => {
|
|
93
|
+
const needle = pattern.trim().toLowerCase();
|
|
94
|
+
if (!needle) return false;
|
|
95
|
+
return normalized.includes(needle);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
56
99
|
export function isTrigger(params: {
|
|
57
100
|
content: string;
|
|
58
101
|
isDm: boolean;
|
|
59
102
|
commandsOnly: boolean;
|
|
60
103
|
respondToAllMessages: boolean;
|
|
104
|
+
guildMessageMode: DiscordGuildMessageMode;
|
|
61
105
|
prefix: string;
|
|
62
106
|
botMentionRegex: RegExp | null;
|
|
63
107
|
hasBotMention: boolean;
|
|
108
|
+
suppressPatterns?: string[];
|
|
64
109
|
}): boolean {
|
|
110
|
+
const stripped = stripBotMentions(params.content, params.botMentionRegex);
|
|
111
|
+
|
|
65
112
|
if (params.commandsOnly) {
|
|
66
|
-
return hasPrefixInvocation(
|
|
113
|
+
return hasPrefixInvocation(
|
|
114
|
+
params.content,
|
|
115
|
+
params.botMentionRegex,
|
|
116
|
+
params.prefix,
|
|
117
|
+
);
|
|
67
118
|
}
|
|
119
|
+
if (
|
|
120
|
+
hasPrefixInvocation(params.content, params.botMentionRegex, params.prefix)
|
|
121
|
+
)
|
|
122
|
+
return true;
|
|
123
|
+
if (shouldSuppressAutoReply(stripped, params.suppressPatterns)) return false;
|
|
68
124
|
if (params.isDm) return true;
|
|
69
|
-
if (params.
|
|
125
|
+
if (params.guildMessageMode === 'off') return false;
|
|
126
|
+
if (params.guildMessageMode === 'free') return true;
|
|
127
|
+
// Keep `respondToAllMessages` consumed for compatibility; mode resolution decides guild behavior.
|
|
128
|
+
void params.respondToAllMessages;
|
|
70
129
|
if (params.hasBotMention) return true;
|
|
71
|
-
return
|
|
130
|
+
return false;
|
|
72
131
|
}
|
|
@@ -21,7 +21,11 @@ export function normalizeMentionAlias(raw: string | null | undefined): string {
|
|
|
21
21
|
return lowered;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function addMentionAlias(
|
|
24
|
+
export function addMentionAlias(
|
|
25
|
+
lookup: MentionLookup,
|
|
26
|
+
rawAlias: string | null | undefined,
|
|
27
|
+
userId: string,
|
|
28
|
+
): void {
|
|
25
29
|
const alias = normalizeMentionAlias(rawAlias);
|
|
26
30
|
if (!alias) return;
|
|
27
31
|
let ids = lookup.byAlias.get(alias);
|
|
@@ -36,7 +40,10 @@ export function extractMentionAliasHints(text: string): MentionAliasHint[] {
|
|
|
36
40
|
if (!text) return [];
|
|
37
41
|
|
|
38
42
|
const hints = new Map<string, MentionAliasHint>();
|
|
39
|
-
const collect = (
|
|
43
|
+
const collect = (
|
|
44
|
+
rawAlias: string | null | undefined,
|
|
45
|
+
rawUserId: string | null | undefined,
|
|
46
|
+
): void => {
|
|
40
47
|
const userId = (rawUserId || '').trim();
|
|
41
48
|
if (!/^\d{16,22}$/.test(userId)) return;
|
|
42
49
|
const alias = normalizeMentionAlias(rawAlias);
|
|
@@ -45,32 +52,44 @@ export function extractMentionAliasHints(text: string): MentionAliasHint[] {
|
|
|
45
52
|
if (!hints.has(key)) hints.set(key, { alias, userId });
|
|
46
53
|
};
|
|
47
54
|
|
|
48
|
-
const aliasToId =
|
|
55
|
+
const aliasToId =
|
|
56
|
+
/(^|[\s,;:.!?])@?([\p{L}\p{N}._-]{2,32})\s*(?:ist|is|=|->|=>|means|heißt)\s*(?:<@!?(\d{16,22})>|(\d{16,22}))/giu;
|
|
49
57
|
let match: RegExpExecArray | null;
|
|
50
|
-
while (
|
|
58
|
+
while (true) {
|
|
59
|
+
match = aliasToId.exec(text);
|
|
60
|
+
if (match === null) break;
|
|
51
61
|
collect(match[2], match[3] || match[4]);
|
|
52
62
|
}
|
|
53
63
|
|
|
54
|
-
const idToAlias =
|
|
55
|
-
|
|
64
|
+
const idToAlias =
|
|
65
|
+
/(?:<@!?(\d{16,22})>|(\d{16,22}))\s*(?:ist|is|=|->|=>|means|heißt)\s*@?([\p{L}\p{N}._-]{2,32})/giu;
|
|
66
|
+
while (true) {
|
|
67
|
+
match = idToAlias.exec(text);
|
|
68
|
+
if (match === null) break;
|
|
56
69
|
collect(match[3], match[1] || match[2]);
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
return Array.from(hints.values());
|
|
60
73
|
}
|
|
61
74
|
|
|
62
|
-
export function rewriteUserMentions(
|
|
75
|
+
export function rewriteUserMentions(
|
|
76
|
+
text: string,
|
|
77
|
+
lookup: MentionLookup,
|
|
78
|
+
): string {
|
|
63
79
|
if (!text) return text;
|
|
64
80
|
if (!lookup.byAlias.size) return text;
|
|
65
|
-
return text.replace(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
return text.replace(
|
|
82
|
+
/(^|[\s([{:>])@([\p{L}\p{N}._-]{2,32})\b/gu,
|
|
83
|
+
(full, prefix: string, rawAlias: string) => {
|
|
84
|
+
const alias = normalizeMentionAlias(rawAlias);
|
|
85
|
+
if (!alias) return full;
|
|
86
|
+
const ids = lookup.byAlias.get(alias);
|
|
87
|
+
if (!ids || ids.size !== 1) return full;
|
|
88
|
+
const [id] = Array.from(ids);
|
|
89
|
+
if (!id) return full;
|
|
90
|
+
return `${prefix}<@${id}>`;
|
|
91
|
+
},
|
|
92
|
+
);
|
|
74
93
|
}
|
|
75
94
|
|
|
76
95
|
function extractMentionAliases(text: string): string[] {
|
|
@@ -78,7 +97,9 @@ function extractMentionAliases(text: string): string[] {
|
|
|
78
97
|
const aliases = new Set<string>();
|
|
79
98
|
const re = /(^|[\s([{:>])@([\p{L}\p{N}._-]{2,32})\b/gu;
|
|
80
99
|
let match: RegExpExecArray | null;
|
|
81
|
-
while (
|
|
100
|
+
while (true) {
|
|
101
|
+
match = re.exec(text);
|
|
102
|
+
if (match === null) break;
|
|
82
103
|
const alias = normalizeMentionAlias(match[2]);
|
|
83
104
|
if (!alias) continue;
|
|
84
105
|
aliases.add(alias);
|
|
@@ -97,7 +118,10 @@ async function enrichMentionLookupFromGuild(
|
|
|
97
118
|
for (const alias of aliases) {
|
|
98
119
|
if (lookup.byAlias.has(alias)) continue;
|
|
99
120
|
try {
|
|
100
|
-
const members = await msg.guild.members.search({
|
|
121
|
+
const members = await msg.guild.members.search({
|
|
122
|
+
query: alias,
|
|
123
|
+
limit: 5,
|
|
124
|
+
});
|
|
101
125
|
const exactMatches = Array.from(members.values()).filter((member) => {
|
|
102
126
|
const username = normalizeMentionAlias(member.user?.username || '');
|
|
103
127
|
const displayName = normalizeMentionAlias(member.displayName || '');
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ActivityType, type Client, type PresenceStatusData } from 'discord.js';
|
|
2
|
+
|
|
3
|
+
import { logger } from '../../logger.js';
|
|
4
|
+
|
|
5
|
+
export type PresenceHealthState =
|
|
6
|
+
| 'healthy'
|
|
7
|
+
| 'degraded'
|
|
8
|
+
| 'exhausted'
|
|
9
|
+
| 'maintenance';
|
|
10
|
+
export type DiscordPresenceActivityType =
|
|
11
|
+
| 'playing'
|
|
12
|
+
| 'watching'
|
|
13
|
+
| 'listening'
|
|
14
|
+
| 'competing'
|
|
15
|
+
| 'custom';
|
|
16
|
+
|
|
17
|
+
export interface DiscordAutoPresenceConfig {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
intervalMs: number;
|
|
20
|
+
healthyText: string;
|
|
21
|
+
degradedText: string;
|
|
22
|
+
exhaustedText: string;
|
|
23
|
+
activityType: DiscordPresenceActivityType;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type PresenceConfigResolver = () => DiscordAutoPresenceConfig;
|
|
27
|
+
type PresenceStateResolver = () => PresenceHealthState;
|
|
28
|
+
|
|
29
|
+
function toDiscordActivityType(
|
|
30
|
+
type: DiscordPresenceActivityType,
|
|
31
|
+
): ActivityType {
|
|
32
|
+
switch (type) {
|
|
33
|
+
case 'playing':
|
|
34
|
+
return ActivityType.Playing;
|
|
35
|
+
case 'listening':
|
|
36
|
+
return ActivityType.Listening;
|
|
37
|
+
case 'competing':
|
|
38
|
+
return ActivityType.Competing;
|
|
39
|
+
case 'custom':
|
|
40
|
+
return ActivityType.Custom;
|
|
41
|
+
case 'watching':
|
|
42
|
+
default:
|
|
43
|
+
return ActivityType.Watching;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function presenceTextForState(
|
|
48
|
+
config: DiscordAutoPresenceConfig,
|
|
49
|
+
state: PresenceHealthState,
|
|
50
|
+
): string {
|
|
51
|
+
if (state === 'degraded') return config.degradedText;
|
|
52
|
+
if (state === 'exhausted') return config.exhaustedText;
|
|
53
|
+
return config.healthyText;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function presenceStatusForState(
|
|
57
|
+
state: PresenceHealthState,
|
|
58
|
+
): PresenceStatusData {
|
|
59
|
+
if (state === 'maintenance') return 'invisible';
|
|
60
|
+
if (state === 'exhausted') return 'dnd';
|
|
61
|
+
if (state === 'degraded') return 'idle';
|
|
62
|
+
return 'online';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class DiscordAutoPresenceController {
|
|
66
|
+
private readonly client: Client;
|
|
67
|
+
private readonly getConfig: PresenceConfigResolver;
|
|
68
|
+
private readonly resolveState: PresenceStateResolver;
|
|
69
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
70
|
+
private running = false;
|
|
71
|
+
private maintenance = false;
|
|
72
|
+
private lastFingerprint = '';
|
|
73
|
+
|
|
74
|
+
constructor(params: {
|
|
75
|
+
client: Client;
|
|
76
|
+
getConfig: PresenceConfigResolver;
|
|
77
|
+
resolveState: PresenceStateResolver;
|
|
78
|
+
}) {
|
|
79
|
+
this.client = params.client;
|
|
80
|
+
this.getConfig = params.getConfig;
|
|
81
|
+
this.resolveState = params.resolveState;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
start(): void {
|
|
85
|
+
this.stop();
|
|
86
|
+
this.running = true;
|
|
87
|
+
const tick = async (): Promise<void> => {
|
|
88
|
+
if (!this.running) return;
|
|
89
|
+
await this.evaluateNow();
|
|
90
|
+
if (!this.running) return;
|
|
91
|
+
const intervalMs = Math.max(
|
|
92
|
+
5_000,
|
|
93
|
+
Math.floor(this.getConfig().intervalMs),
|
|
94
|
+
);
|
|
95
|
+
this.timer = setTimeout(() => {
|
|
96
|
+
void tick();
|
|
97
|
+
}, intervalMs);
|
|
98
|
+
};
|
|
99
|
+
this.timer = setTimeout(() => {
|
|
100
|
+
void tick();
|
|
101
|
+
}, 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
stop(): void {
|
|
105
|
+
this.running = false;
|
|
106
|
+
if (!this.timer) return;
|
|
107
|
+
clearTimeout(this.timer);
|
|
108
|
+
this.timer = null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async setMaintenance(): Promise<void> {
|
|
112
|
+
this.maintenance = true;
|
|
113
|
+
await this.evaluateNow();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async evaluateNow(): Promise<void> {
|
|
117
|
+
const config = this.getConfig();
|
|
118
|
+
if (!this.client.user) return;
|
|
119
|
+
if (!config.enabled && !this.maintenance) return;
|
|
120
|
+
|
|
121
|
+
const state = this.maintenance ? 'maintenance' : this.resolveState();
|
|
122
|
+
const status = presenceStatusForState(state);
|
|
123
|
+
const activityText =
|
|
124
|
+
state === 'maintenance' ? '' : presenceTextForState(config, state);
|
|
125
|
+
const activityType = toDiscordActivityType(config.activityType);
|
|
126
|
+
const fingerprint = `${state}:${status}:${activityType}:${activityText}`;
|
|
127
|
+
if (fingerprint === this.lastFingerprint) return;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
if (state === 'maintenance') {
|
|
131
|
+
await this.client.user.setPresence({
|
|
132
|
+
status,
|
|
133
|
+
activities: [],
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
await this.client.user.setPresence({
|
|
137
|
+
status,
|
|
138
|
+
activities: activityText
|
|
139
|
+
? [{ name: activityText, type: activityType }]
|
|
140
|
+
: [],
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
this.lastFingerprint = fingerprint;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
logger.debug({ error, state }, 'Failed to update Discord presence');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export interface RateLimitDecision {
|
|
2
|
+
allowed: boolean;
|
|
3
|
+
remaining: number;
|
|
4
|
+
retryAfterMs: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class SlidingWindowRateLimiter {
|
|
8
|
+
private readonly windowMs: number;
|
|
9
|
+
private readonly buckets = new Map<string, number[]>();
|
|
10
|
+
private readonly notifyAt = new Map<string, number>();
|
|
11
|
+
|
|
12
|
+
constructor(windowMs = 60_000) {
|
|
13
|
+
this.windowMs = Math.max(1_000, Math.floor(windowMs));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
check(key: string, limit: number, nowMs = Date.now()): RateLimitDecision {
|
|
17
|
+
const boundedLimit = Math.max(0, Math.floor(limit));
|
|
18
|
+
if (!key || boundedLimit === 0) {
|
|
19
|
+
return {
|
|
20
|
+
allowed: true,
|
|
21
|
+
remaining: Number.POSITIVE_INFINITY,
|
|
22
|
+
retryAfterMs: 0,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const cutoff = nowMs - this.windowMs;
|
|
27
|
+
const timestamps = this.buckets.get(key) ?? [];
|
|
28
|
+
const active = timestamps.filter((ts) => ts > cutoff);
|
|
29
|
+
|
|
30
|
+
if (active.length >= boundedLimit) {
|
|
31
|
+
this.buckets.set(key, active);
|
|
32
|
+
const oldest = active[0];
|
|
33
|
+
const retryAfterMs = Math.max(0, oldest + this.windowMs - nowMs);
|
|
34
|
+
return {
|
|
35
|
+
allowed: false,
|
|
36
|
+
remaining: 0,
|
|
37
|
+
retryAfterMs,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
active.push(nowMs);
|
|
42
|
+
this.buckets.set(key, active);
|
|
43
|
+
return {
|
|
44
|
+
allowed: true,
|
|
45
|
+
remaining: Math.max(0, boundedLimit - active.length),
|
|
46
|
+
retryAfterMs: 0,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
shouldNotify(key: string, cooldownMs = 10_000, nowMs = Date.now()): boolean {
|
|
51
|
+
if (!key) return true;
|
|
52
|
+
const boundedCooldown = Math.max(1_000, Math.floor(cooldownMs));
|
|
53
|
+
const nextAllowedAt = this.notifyAt.get(key) ?? 0;
|
|
54
|
+
if (nowMs < nextAllowedAt) return false;
|
|
55
|
+
this.notifyAt.set(key, nowMs + boundedCooldown);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { Message as DiscordMessage } from 'discord.js';
|
|
2
|
+
|
|
3
|
+
import { logger } from '../../logger.js';
|
|
4
|
+
|
|
5
|
+
export type LifecyclePhase =
|
|
6
|
+
| 'queued'
|
|
7
|
+
| 'thinking'
|
|
8
|
+
| 'toolUse'
|
|
9
|
+
| 'streaming'
|
|
10
|
+
| 'done'
|
|
11
|
+
| 'error';
|
|
12
|
+
export type DiscordRetryFn = <T>(
|
|
13
|
+
label: string,
|
|
14
|
+
fn: () => Promise<T>,
|
|
15
|
+
) => Promise<T>;
|
|
16
|
+
|
|
17
|
+
const MIN_REACTION_GAP_MS = 350;
|
|
18
|
+
const DONE_REACTION_VISIBILITY_MS = 1_000;
|
|
19
|
+
|
|
20
|
+
function sleep(ms: number): Promise<void> {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function findReactionByEmoji(
|
|
25
|
+
message: DiscordMessage,
|
|
26
|
+
emoji: string,
|
|
27
|
+
): { users: { remove: (userId: string) => Promise<unknown> } } | null {
|
|
28
|
+
const direct = message.reactions.resolve(emoji);
|
|
29
|
+
if (direct) return direct;
|
|
30
|
+
const trimmed = emoji.trim();
|
|
31
|
+
if (!trimmed) return null;
|
|
32
|
+
const fallback = message.reactions.cache.find(
|
|
33
|
+
(reaction) =>
|
|
34
|
+
reaction.emoji.toString() === trimmed || reaction.emoji.name === trimmed,
|
|
35
|
+
);
|
|
36
|
+
return fallback ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function addAckReaction(params: {
|
|
40
|
+
message: DiscordMessage;
|
|
41
|
+
emoji: string;
|
|
42
|
+
withRetry: DiscordRetryFn;
|
|
43
|
+
botUserId: string;
|
|
44
|
+
}): Promise<() => Promise<void>> {
|
|
45
|
+
const reactionEmoji = params.emoji.trim();
|
|
46
|
+
if (!reactionEmoji) {
|
|
47
|
+
return async () => {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await params.withRetry('reaction-ack-add', () =>
|
|
52
|
+
params.message.react(reactionEmoji),
|
|
53
|
+
);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
logger.debug(
|
|
56
|
+
{
|
|
57
|
+
error,
|
|
58
|
+
channelId: params.message.channelId,
|
|
59
|
+
messageId: params.message.id,
|
|
60
|
+
reactionEmoji,
|
|
61
|
+
},
|
|
62
|
+
'Failed to add ack reaction',
|
|
63
|
+
);
|
|
64
|
+
return async () => {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return async () => {
|
|
68
|
+
try {
|
|
69
|
+
const reaction = findReactionByEmoji(params.message, reactionEmoji);
|
|
70
|
+
if (!reaction) return;
|
|
71
|
+
await params.withRetry('reaction-ack-remove', () =>
|
|
72
|
+
reaction.users.remove(params.botUserId),
|
|
73
|
+
);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
logger.debug(
|
|
76
|
+
{
|
|
77
|
+
error,
|
|
78
|
+
channelId: params.message.channelId,
|
|
79
|
+
messageId: params.message.id,
|
|
80
|
+
reactionEmoji,
|
|
81
|
+
},
|
|
82
|
+
'Failed to remove ack reaction',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface LifecycleReactionConfig {
|
|
89
|
+
enabled: boolean;
|
|
90
|
+
removeOnComplete: boolean;
|
|
91
|
+
phases: Record<LifecyclePhase, string>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class LifecycleReactionController {
|
|
95
|
+
private readonly message: DiscordMessage;
|
|
96
|
+
private readonly withRetry: DiscordRetryFn;
|
|
97
|
+
private readonly botUserId: string;
|
|
98
|
+
private readonly config: LifecycleReactionConfig;
|
|
99
|
+
private currentEmoji: string | null = null;
|
|
100
|
+
private currentPhase: LifecyclePhase | null = null;
|
|
101
|
+
private queue = Promise.resolve();
|
|
102
|
+
private lastReactionAt = 0;
|
|
103
|
+
|
|
104
|
+
constructor(params: {
|
|
105
|
+
message: DiscordMessage;
|
|
106
|
+
withRetry: DiscordRetryFn;
|
|
107
|
+
botUserId: string;
|
|
108
|
+
config: LifecycleReactionConfig;
|
|
109
|
+
}) {
|
|
110
|
+
this.message = params.message;
|
|
111
|
+
this.withRetry = params.withRetry;
|
|
112
|
+
this.botUserId = params.botUserId;
|
|
113
|
+
this.config = params.config;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
setPhase(phase: LifecyclePhase): void {
|
|
117
|
+
if (!this.config.enabled) return;
|
|
118
|
+
if (this.currentPhase === phase) return;
|
|
119
|
+
this.currentPhase = phase;
|
|
120
|
+
this.queue = this.queue
|
|
121
|
+
.then(async () => {
|
|
122
|
+
await this.transitionToPhase(phase);
|
|
123
|
+
})
|
|
124
|
+
.catch((error) => {
|
|
125
|
+
logger.debug(
|
|
126
|
+
{
|
|
127
|
+
error,
|
|
128
|
+
channelId: this.message.channelId,
|
|
129
|
+
messageId: this.message.id,
|
|
130
|
+
phase,
|
|
131
|
+
},
|
|
132
|
+
'Lifecycle reaction transition failed',
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async clear(): Promise<void> {
|
|
138
|
+
if (!this.config.enabled) return;
|
|
139
|
+
await this.queue;
|
|
140
|
+
if (!this.currentEmoji) return;
|
|
141
|
+
await this.removeReaction(this.currentEmoji);
|
|
142
|
+
this.currentEmoji = null;
|
|
143
|
+
this.currentPhase = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async transitionToPhase(phase: LifecyclePhase): Promise<void> {
|
|
147
|
+
const nextEmoji = (this.config.phases[phase] || '').trim();
|
|
148
|
+
if (!nextEmoji) return;
|
|
149
|
+
if (this.currentEmoji && this.currentEmoji !== nextEmoji) {
|
|
150
|
+
await this.removeReaction(this.currentEmoji);
|
|
151
|
+
this.currentEmoji = null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await this.addReaction(nextEmoji);
|
|
155
|
+
this.currentEmoji = nextEmoji;
|
|
156
|
+
|
|
157
|
+
if (phase === 'done' && this.config.removeOnComplete) {
|
|
158
|
+
await sleep(DONE_REACTION_VISIBILITY_MS);
|
|
159
|
+
await this.removeReaction(nextEmoji);
|
|
160
|
+
this.currentEmoji = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async waitForReactionWindow(): Promise<void> {
|
|
165
|
+
const elapsed = Date.now() - this.lastReactionAt;
|
|
166
|
+
if (elapsed >= MIN_REACTION_GAP_MS) return;
|
|
167
|
+
await sleep(MIN_REACTION_GAP_MS - elapsed);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async addReaction(emoji: string): Promise<void> {
|
|
171
|
+
await this.waitForReactionWindow();
|
|
172
|
+
try {
|
|
173
|
+
await this.withRetry('reaction-lifecycle-add', () =>
|
|
174
|
+
this.message.react(emoji),
|
|
175
|
+
);
|
|
176
|
+
this.lastReactionAt = Date.now();
|
|
177
|
+
} catch (error) {
|
|
178
|
+
logger.debug(
|
|
179
|
+
{
|
|
180
|
+
error,
|
|
181
|
+
channelId: this.message.channelId,
|
|
182
|
+
messageId: this.message.id,
|
|
183
|
+
emoji,
|
|
184
|
+
},
|
|
185
|
+
'Failed to add lifecycle reaction',
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async removeReaction(emoji: string): Promise<void> {
|
|
191
|
+
await this.waitForReactionWindow();
|
|
192
|
+
try {
|
|
193
|
+
const reaction = findReactionByEmoji(this.message, emoji);
|
|
194
|
+
if (!reaction) return;
|
|
195
|
+
await this.withRetry('reaction-lifecycle-remove', () =>
|
|
196
|
+
reaction.users.remove(this.botUserId),
|
|
197
|
+
);
|
|
198
|
+
this.lastReactionAt = Date.now();
|
|
199
|
+
} catch (error) {
|
|
200
|
+
logger.debug(
|
|
201
|
+
{
|
|
202
|
+
error,
|
|
203
|
+
channelId: this.message.channelId,
|
|
204
|
+
messageId: this.message.id,
|
|
205
|
+
emoji,
|
|
206
|
+
},
|
|
207
|
+
'Failed to remove lifecycle reaction',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|