@hybridaione/hybridclaw 0.2.3 → 0.2.7
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 +95 -0
- package/CONTRIBUTING.md +33 -0
- package/README.md +57 -19
- package/SECURITY.md +17 -0
- package/biome.json +35 -0
- package/config.example.json +72 -7
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/approval-policy.ts +1405 -0
- package/container/src/browser-tools.ts +672 -136
- package/container/src/extensions.ts +36 -12
- package/container/src/hybridai-client.ts +65 -16
- package/container/src/index.ts +463 -110
- package/container/src/ipc.ts +5 -3
- package/container/src/token-usage.ts +107 -18
- package/container/src/tools.ts +600 -225
- package/container/src/types.ts +49 -2
- package/container/src/web-fetch.ts +89 -32
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +15 -4
- 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 +24 -4
- 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 +31 -0
- package/dist/channels/discord/inbound.d.ts.map +1 -1
- package/dist/channels/discord/inbound.js +156 -6
- 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 +7 -4
- package/dist/channels/discord/runtime.d.ts.map +1 -1
- package/dist/channels/discord/runtime.js +755 -150
- 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 +19 -12
- 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 +77 -23
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +113 -18
- package/dist/config.js.map +1 -1
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +59 -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 +624 -87
- 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 +303 -26
- 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 +62 -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/hybridai-models.d.ts +8 -0
- package/dist/hybridai-models.d.ts.map +1 -0
- package/dist/hybridai-models.js +94 -0
- package/dist/hybridai-models.js.map +1 -0
- 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 +11 -7
- package/dist/prompt-hooks.js.map +1 -1
- package/dist/runtime-config.d.ts +104 -1
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +503 -24
- 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 +43 -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 +150 -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 +72 -13
- package/package.json +8 -2
- package/src/agent.ts +31 -4
- 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 +65 -14
- package/src/channels/discord/human-delay.ts +48 -0
- package/src/channels/discord/inbound.ts +190 -8
- 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 +1161 -205
- package/src/channels/discord/stream.ts +85 -31
- 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 +174 -57
- package/src/config.ts +208 -36
- package/src/container-runner.ts +134 -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 +996 -220
- package/src/gateway-types.ts +33 -0
- package/src/gateway.ts +487 -52
- package/src/health.ts +66 -26
- package/src/heartbeat.ts +99 -22
- package/src/hybridai-bots.ts +14 -5
- package/src/hybridai-models.ts +158 -0
- 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 +42 -17
- package/src/runtime-config.ts +1112 -122
- package/src/scheduled-task-runner.ts +77 -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 +199 -2
- package/src/update.ts +79 -23
- package/src/workspace.ts +63 -11
- package/tests/approval-policy.test.ts +335 -0
- package/tests/discord.basic.test.ts +213 -4
- package/tests/discord.human-presence.test.ts +85 -0
- package/tests/gateway-service.media-routing.test.ts +8 -2
- package/tests/hybridai-client.test.ts +112 -0
- package/tests/hybridai-models.test.ts +46 -0
- package/tests/memory-service.test.ts +1114 -0
- package/tests/token-efficiency.basic.test.ts +8 -2
- package/tests/token-usage.cache.test.ts +128 -0
- 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
|
@@ -1,59 +1,98 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
type ApplicationCommandDataResolvable,
|
|
3
3
|
ApplicationCommandOptionType,
|
|
4
|
-
AttachmentBuilder,
|
|
4
|
+
type AttachmentBuilder,
|
|
5
5
|
Client,
|
|
6
|
-
GatewayIntentBits,
|
|
7
|
-
type ApplicationCommandDataResolvable,
|
|
8
6
|
type Message as DiscordMessage,
|
|
7
|
+
GatewayIntentBits,
|
|
9
8
|
Partials,
|
|
10
9
|
} from 'discord.js';
|
|
11
10
|
|
|
12
11
|
import {
|
|
12
|
+
DISCORD_ACK_REACTION,
|
|
13
|
+
DISCORD_ACK_REACTION_SCOPE,
|
|
14
|
+
DISCORD_COMMAND_ALLOWED_USER_IDS,
|
|
15
|
+
DISCORD_COMMAND_MODE,
|
|
13
16
|
DISCORD_COMMAND_USER_ID,
|
|
14
17
|
DISCORD_COMMANDS_ONLY,
|
|
18
|
+
DISCORD_DEBOUNCE_MS,
|
|
15
19
|
DISCORD_FREE_RESPONSE_CHANNELS,
|
|
16
20
|
DISCORD_GROUP_POLICY,
|
|
17
21
|
DISCORD_GUILD_MEMBERS_INTENT,
|
|
18
22
|
DISCORD_GUILDS,
|
|
19
|
-
|
|
23
|
+
DISCORD_HUMAN_DELAY,
|
|
24
|
+
DISCORD_LIFECYCLE_REACTIONS,
|
|
25
|
+
DISCORD_MAX_CONCURRENT_PER_CHANNEL,
|
|
20
26
|
DISCORD_PREFIX,
|
|
27
|
+
DISCORD_PRESENCE_INTENT,
|
|
28
|
+
DISCORD_RATE_LIMIT_EXEMPT_ROLES,
|
|
29
|
+
DISCORD_RATE_LIMIT_PER_USER,
|
|
30
|
+
DISCORD_REMOVE_ACK_AFTER_REPLY,
|
|
21
31
|
DISCORD_RESPOND_TO_ALL_MESSAGES,
|
|
32
|
+
DISCORD_SELF_PRESENCE,
|
|
33
|
+
DISCORD_SUPPRESS_PATTERNS,
|
|
22
34
|
DISCORD_TOKEN,
|
|
35
|
+
DISCORD_TYPING_MODE,
|
|
23
36
|
} from '../../config.js';
|
|
37
|
+
import { logger } from '../../logger.js';
|
|
38
|
+
import type { MediaContextItem } from '../../types.js';
|
|
24
39
|
import { buildAttachmentContext } from './attachments.js';
|
|
40
|
+
import {
|
|
41
|
+
DEFAULT_DEBOUNCE_MAX_BUFFER,
|
|
42
|
+
resolveInboundDebounceMs,
|
|
43
|
+
shouldDebounceInbound,
|
|
44
|
+
} from './debounce.js';
|
|
45
|
+
import {
|
|
46
|
+
formatError,
|
|
47
|
+
prepareChunkedPayloads,
|
|
48
|
+
sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery,
|
|
49
|
+
sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery,
|
|
50
|
+
sendChunkedReply as sendChunkedReplyFromDelivery,
|
|
51
|
+
} from './delivery.js';
|
|
52
|
+
import type { HumanDelayConfig } from './human-delay.js';
|
|
25
53
|
import {
|
|
26
54
|
buildSessionIdFromContext as buildSessionIdFromContextInbound,
|
|
27
55
|
cleanIncomingContent as cleanIncomingContentInbound,
|
|
56
|
+
type DiscordGuildMessageMode,
|
|
57
|
+
hasLooseBotMention as hasLooseBotMentionInbound,
|
|
28
58
|
hasPrefixInvocation as hasPrefixInvocationInbound,
|
|
59
|
+
hasSlashCommandInvocation as hasSlashCommandInvocationInbound,
|
|
60
|
+
isAddressedToChannel as isAddressedToChannelInbound,
|
|
61
|
+
isAuthorizedCommandUser as isAuthorizedCommandUserInbound,
|
|
29
62
|
isTrigger as isTriggerInbound,
|
|
30
|
-
parseCommand as parseCommandInbound,
|
|
31
|
-
type DiscordGuildMessageMode,
|
|
32
63
|
type ParsedCommand,
|
|
64
|
+
parseCommand as parseCommandInbound,
|
|
65
|
+
shouldReplyInFreeMode as shouldReplyInFreeModeInbound,
|
|
66
|
+
shouldSkipFreeReplyBecauseOtherUsersMentioned as shouldSkipFreeReplyBecauseOtherUsersMentionedInbound,
|
|
33
67
|
} from './inbound.js';
|
|
34
68
|
import {
|
|
35
69
|
addMentionAlias,
|
|
36
70
|
extractMentionAliasHints,
|
|
37
|
-
normalizeMentionAlias,
|
|
38
71
|
type MentionLookup,
|
|
72
|
+
normalizeMentionAlias,
|
|
39
73
|
} from './mentions.js';
|
|
40
74
|
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
75
|
+
DiscordAutoPresenceController,
|
|
76
|
+
type PresenceHealthState,
|
|
77
|
+
} from './presence.js';
|
|
78
|
+
import { SlidingWindowRateLimiter } from './rate-limiter.js';
|
|
79
|
+
import {
|
|
80
|
+
addAckReaction,
|
|
81
|
+
type LifecyclePhase,
|
|
82
|
+
LifecycleReactionController,
|
|
83
|
+
} from './reactions.js';
|
|
84
|
+
import { DiscordStreamManager } from './stream.js';
|
|
47
85
|
import {
|
|
48
|
-
createDiscordToolActionRunner,
|
|
49
86
|
type CachedDiscordPresence,
|
|
87
|
+
createDiscordToolActionRunner,
|
|
50
88
|
type DiscordToolActionRequest,
|
|
51
89
|
} from './tool-actions.js';
|
|
52
|
-
import {
|
|
53
|
-
import { logger } from '../../logger.js';
|
|
54
|
-
import type { MediaContextItem } from '../../types.js';
|
|
90
|
+
import { createTypingController } from './typing.js';
|
|
55
91
|
|
|
56
|
-
export type ReplyFn = (
|
|
92
|
+
export type ReplyFn = (
|
|
93
|
+
content: string,
|
|
94
|
+
files?: AttachmentBuilder[],
|
|
95
|
+
) => Promise<void>;
|
|
57
96
|
|
|
58
97
|
interface PendingGuildHistoryEntry {
|
|
59
98
|
messageId: string;
|
|
@@ -76,6 +115,7 @@ export interface MessageRunContext {
|
|
|
76
115
|
abortSignal: AbortSignal;
|
|
77
116
|
stream: DiscordStreamManager;
|
|
78
117
|
mentionLookup: MentionLookup;
|
|
118
|
+
emitLifecyclePhase: (phase: LifecyclePhase) => void;
|
|
79
119
|
}
|
|
80
120
|
|
|
81
121
|
export type MessageHandler = (
|
|
@@ -94,6 +134,8 @@ export type CommandHandler = (
|
|
|
94
134
|
sessionId: string,
|
|
95
135
|
guildId: string | null,
|
|
96
136
|
channelId: string,
|
|
137
|
+
userId: string,
|
|
138
|
+
username: string,
|
|
97
139
|
args: string[],
|
|
98
140
|
reply: ReplyFn,
|
|
99
141
|
) => Promise<void>;
|
|
@@ -103,7 +145,6 @@ let messageHandler: MessageHandler;
|
|
|
103
145
|
let commandHandler: CommandHandler;
|
|
104
146
|
let activeConversationRuns = 0;
|
|
105
147
|
let botMentionRegex: RegExp | null = null;
|
|
106
|
-
const MESSAGE_DEBOUNCE_MS = 2_500;
|
|
107
148
|
const DISCORD_RETRY_MAX_ATTEMPTS = 3;
|
|
108
149
|
const DISCORD_RETRY_BASE_DELAY_MS = 500;
|
|
109
150
|
const GUILD_INBOUND_HISTORY_LIMIT = 20;
|
|
@@ -113,8 +154,44 @@ const PARTICIPANT_MEMORY_MAX_CHANNELS = 200;
|
|
|
113
154
|
const PARTICIPANT_MEMORY_MAX_USERS_PER_CHANNEL = 200;
|
|
114
155
|
const PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER = 8;
|
|
115
156
|
const MAX_PRESENCE_CACHE_USERS = 5_000;
|
|
157
|
+
const RATE_LIMIT_NOTIFY_COOLDOWN_MS = 12_000;
|
|
158
|
+
const CONCURRENCY_RETRY_DELAY_MS = 250;
|
|
159
|
+
const PRESENCE_WINDOW_MS = 5 * 60_000;
|
|
160
|
+
const PRESENCE_DEGRADED_DURATION_MS = 45_000;
|
|
161
|
+
const PRESENCE_EXHAUSTED_ERROR_RE =
|
|
162
|
+
/(api down|unavailable|rate limit|too many active containers|quota|token limit|timeout)/i;
|
|
163
|
+
const FRIENDLY_RATE_LIMIT_MESSAGE =
|
|
164
|
+
"You're sending messages too fast — give me a moment to catch up!";
|
|
165
|
+
const READ_WITHOUT_REPLY_RE =
|
|
166
|
+
/^(thanks|thank you|thx|ty|got it|ok|okay|cool|perfect|awesome|sounds good|roger)[!. ]*$/i;
|
|
167
|
+
const READ_WITHOUT_REPLY_PROBABILITY = 0.6;
|
|
168
|
+
const STARTUP_STAGGER_WINDOW_MS = 120_000;
|
|
169
|
+
const STARTUP_STAGGER_MIN_DELAY_MS = 500;
|
|
170
|
+
const STARTUP_STAGGER_MAX_DELAY_MS = 3_500;
|
|
171
|
+
const SELECTIVE_SILENCE_BASE_PROBABILITY = 0.25;
|
|
172
|
+
const SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY = 0.5;
|
|
173
|
+
const SELECTIVE_SILENCE_RECENT_WINDOW_MS = 60_000;
|
|
174
|
+
const NIGHT_HOURS_START = 22;
|
|
175
|
+
const NIGHT_HOURS_END = 7;
|
|
176
|
+
const CONVERSATION_COOLDOWN_RESET_MS = 20 * 60_000;
|
|
177
|
+
const CONVERSATION_COOLDOWN_THRESHOLD = 5;
|
|
178
|
+
const CONVERSATION_COOLDOWN_MAX_FACTOR = 2.5;
|
|
116
179
|
|
|
117
180
|
const discordPresenceCache = new Map<string, CachedDiscordPresence>();
|
|
181
|
+
const userRateLimiter = new SlidingWindowRateLimiter(60_000);
|
|
182
|
+
const recentConversationMetrics: Array<{
|
|
183
|
+
atMs: number;
|
|
184
|
+
durationMs: number;
|
|
185
|
+
ok: boolean;
|
|
186
|
+
exhaustedHint: boolean;
|
|
187
|
+
}> = [];
|
|
188
|
+
let consecutiveConversationFailures = 0;
|
|
189
|
+
let presenceController: DiscordAutoPresenceController | null = null;
|
|
190
|
+
let startupConnectedAtMs = 0;
|
|
191
|
+
const conversationExchangeByKey = new Map<
|
|
192
|
+
string,
|
|
193
|
+
{ count: number; lastAtMs: number }
|
|
194
|
+
>();
|
|
118
195
|
|
|
119
196
|
function setDiscordPresence(userId: string, data: CachedDiscordPresence): void {
|
|
120
197
|
discordPresenceCache.set(userId, data);
|
|
@@ -138,7 +215,10 @@ function buildMentionLookup(
|
|
|
138
215
|
const lookup: MentionLookup = { byAlias: new Map<string, Set<string>>() };
|
|
139
216
|
const botUserId = client.user?.id || '';
|
|
140
217
|
|
|
141
|
-
const addUser = (
|
|
218
|
+
const addUser = (
|
|
219
|
+
userId: string,
|
|
220
|
+
aliases: Array<string | null | undefined>,
|
|
221
|
+
): void => {
|
|
142
222
|
if (!userId || userId === botUserId) return;
|
|
143
223
|
for (const alias of aliases) {
|
|
144
224
|
addMentionAlias(lookup, alias, userId);
|
|
@@ -153,7 +233,8 @@ function buildMentionLookup(
|
|
|
153
233
|
for (const mentioned of msg.mentions.users.values()) {
|
|
154
234
|
const aliases = [mentioned.username];
|
|
155
235
|
const mentionedMember = msg.mentions.members?.get(mentioned.id);
|
|
156
|
-
if (mentionedMember?.displayName)
|
|
236
|
+
if (mentionedMember?.displayName)
|
|
237
|
+
aliases.push(mentionedMember.displayName);
|
|
157
238
|
addUser(mentioned.id, aliases);
|
|
158
239
|
}
|
|
159
240
|
|
|
@@ -182,18 +263,25 @@ function summarizePendingHistoryEntry(entry: PendingGuildHistoryEntry): string {
|
|
|
182
263
|
const author = entry.displayName || entry.username || 'user';
|
|
183
264
|
const authorLabel = entry.isBot ? `${author} [bot]` : author;
|
|
184
265
|
const content = entry.content.trim();
|
|
185
|
-
const snippet =
|
|
266
|
+
const snippet =
|
|
267
|
+
content.length > 300 ? `${content.slice(0, 297)}...` : content;
|
|
186
268
|
return `${authorLabel}: ${snippet}`;
|
|
187
269
|
}
|
|
188
270
|
|
|
189
|
-
function buildPendingHistoryContext(
|
|
271
|
+
function buildPendingHistoryContext(
|
|
272
|
+
entries: PendingGuildHistoryEntry[],
|
|
273
|
+
): string {
|
|
190
274
|
if (entries.length === 0) return '';
|
|
191
275
|
const selected: string[] = [];
|
|
192
276
|
let totalChars = 0;
|
|
193
277
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
194
278
|
const line = summarizePendingHistoryEntry(entries[i]);
|
|
195
279
|
if (!line) continue;
|
|
196
|
-
if (
|
|
280
|
+
if (
|
|
281
|
+
totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS &&
|
|
282
|
+
selected.length > 0
|
|
283
|
+
)
|
|
284
|
+
break;
|
|
197
285
|
selected.push(line);
|
|
198
286
|
totalChars += line.length + 1;
|
|
199
287
|
}
|
|
@@ -212,10 +300,13 @@ async function buildInboundHistorySnapshot(
|
|
|
212
300
|
msg: DiscordMessage,
|
|
213
301
|
excludeMessageIds: Set<string>,
|
|
214
302
|
): Promise<{ entries: PendingGuildHistoryEntry[]; context: string }> {
|
|
215
|
-
if (!msg.guild || !('messages' in msg.channel))
|
|
303
|
+
if (!msg.guild || !('messages' in msg.channel))
|
|
304
|
+
return { entries: [], context: '' };
|
|
216
305
|
|
|
217
306
|
try {
|
|
218
|
-
const recentMessages = await msg.channel.messages.fetch({
|
|
307
|
+
const recentMessages = await msg.channel.messages.fetch({
|
|
308
|
+
limit: GUILD_INBOUND_HISTORY_LIMIT,
|
|
309
|
+
});
|
|
219
310
|
const entries: PendingGuildHistoryEntry[] = [];
|
|
220
311
|
let hiddenTextCount = 0;
|
|
221
312
|
let hiddenBotTextCount = 0;
|
|
@@ -225,7 +316,11 @@ async function buildInboundHistorySnapshot(
|
|
|
225
316
|
if (plainText) return plainText;
|
|
226
317
|
|
|
227
318
|
const embedChunks = recent.embeds
|
|
228
|
-
.map((embed) =>
|
|
319
|
+
.map((embed) =>
|
|
320
|
+
[embed.title?.trim(), embed.description?.trim()]
|
|
321
|
+
.filter(Boolean)
|
|
322
|
+
.join(' — '),
|
|
323
|
+
)
|
|
229
324
|
.map((part) => part.trim())
|
|
230
325
|
.filter(Boolean)
|
|
231
326
|
.slice(0, 3);
|
|
@@ -241,7 +336,9 @@ async function buildInboundHistorySnapshot(
|
|
|
241
336
|
return `[attachments] ${attachmentNames.join(', ')}`;
|
|
242
337
|
}
|
|
243
338
|
|
|
244
|
-
const systemContent = recent.system
|
|
339
|
+
const systemContent = recent.system
|
|
340
|
+
? (recent.cleanContent || '').trim()
|
|
341
|
+
: '';
|
|
245
342
|
if (systemContent) return `[system] ${systemContent}`;
|
|
246
343
|
|
|
247
344
|
hiddenTextCount += 1;
|
|
@@ -261,11 +358,16 @@ async function buildInboundHistorySnapshot(
|
|
|
261
358
|
username: recent.author.username || 'user',
|
|
262
359
|
displayName: recent.member?.displayName || null,
|
|
263
360
|
isBot: Boolean(recent.author.bot),
|
|
264
|
-
timestampMs: Number.isFinite(recent.createdTimestamp)
|
|
361
|
+
timestampMs: Number.isFinite(recent.createdTimestamp)
|
|
362
|
+
? recent.createdTimestamp
|
|
363
|
+
: 0,
|
|
265
364
|
content,
|
|
266
365
|
});
|
|
267
366
|
}
|
|
268
|
-
entries.sort(
|
|
367
|
+
entries.sort(
|
|
368
|
+
(a, b) =>
|
|
369
|
+
a.timestampMs - b.timestampMs || a.messageId.localeCompare(b.messageId),
|
|
370
|
+
);
|
|
269
371
|
let context = buildPendingHistoryContext(entries);
|
|
270
372
|
if (hiddenTextCount > 0) {
|
|
271
373
|
const visibilityNote = [
|
|
@@ -282,18 +384,26 @@ async function buildInboundHistorySnapshot(
|
|
|
282
384
|
context,
|
|
283
385
|
};
|
|
284
386
|
} catch (error) {
|
|
285
|
-
logger.debug(
|
|
387
|
+
logger.debug(
|
|
388
|
+
{ error, guildId: msg.guild.id, channelId: msg.channelId },
|
|
389
|
+
'Failed to build inbound channel history snapshot',
|
|
390
|
+
);
|
|
286
391
|
return { entries: [], context: '' };
|
|
287
392
|
}
|
|
288
393
|
}
|
|
289
394
|
|
|
290
|
-
function addParticipantAlias(
|
|
395
|
+
function addParticipantAlias(
|
|
396
|
+
info: ParticipantInfo,
|
|
397
|
+
alias: string | null | undefined,
|
|
398
|
+
): void {
|
|
291
399
|
const normalized = normalizeMentionAlias(alias);
|
|
292
400
|
if (!normalized) return;
|
|
293
401
|
info.aliases.add(normalized);
|
|
294
402
|
}
|
|
295
403
|
|
|
296
|
-
function formatDiscordHandleFromAlias(
|
|
404
|
+
function formatDiscordHandleFromAlias(
|
|
405
|
+
alias: string | null | undefined,
|
|
406
|
+
): string | null {
|
|
297
407
|
const normalized = normalizeMentionAlias(alias);
|
|
298
408
|
if (!normalized) return null;
|
|
299
409
|
return `@${normalized}`;
|
|
@@ -376,7 +486,8 @@ function buildParticipantContext(
|
|
|
376
486
|
.slice(0, PARTICIPANT_CONTEXT_MAX_USERS)
|
|
377
487
|
.map((entry) => {
|
|
378
488
|
const aliases = Array.from(entry.aliases).slice(0, 3);
|
|
379
|
-
const preferredHandle =
|
|
489
|
+
const preferredHandle =
|
|
490
|
+
formatDiscordHandleFromAlias(aliases[0]) || `id:${entry.id}`;
|
|
380
491
|
const botSuffix = botParticipantIds.has(entry.id) ? ' [bot]' : '';
|
|
381
492
|
return `- ${preferredHandle}${botSuffix} id:${entry.id} aliases: ${aliases.join(', ')}`;
|
|
382
493
|
});
|
|
@@ -422,23 +533,51 @@ export async function runDiscordToolAction(
|
|
|
422
533
|
}
|
|
423
534
|
|
|
424
535
|
function getSessionId(msg: DiscordMessage): string {
|
|
425
|
-
return buildSessionIdFromContext(
|
|
536
|
+
return buildSessionIdFromContext(
|
|
537
|
+
msg.guild?.id ?? null,
|
|
538
|
+
msg.channelId,
|
|
539
|
+
msg.author.id,
|
|
540
|
+
);
|
|
426
541
|
}
|
|
427
542
|
|
|
428
543
|
function hasPrefixInvocation(content: string): boolean {
|
|
429
544
|
return hasPrefixInvocationInbound(content, botMentionRegex, DISCORD_PREFIX);
|
|
430
545
|
}
|
|
431
546
|
|
|
547
|
+
function hasSlashCommandInvocation(content: string): boolean {
|
|
548
|
+
return hasSlashCommandInvocationInbound(content, botMentionRegex);
|
|
549
|
+
}
|
|
550
|
+
|
|
432
551
|
function isAuthorizedCommandUserId(userId: string): boolean {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
552
|
+
return isAuthorizedCommandUserInbound({
|
|
553
|
+
mode: DISCORD_COMMAND_MODE,
|
|
554
|
+
userId,
|
|
555
|
+
allowedUserIds: DISCORD_COMMAND_ALLOWED_USER_IDS,
|
|
556
|
+
legacyCommandUserId: DISCORD_COMMAND_USER_ID,
|
|
557
|
+
});
|
|
436
558
|
}
|
|
437
559
|
|
|
438
|
-
function buildSessionIdFromContext(
|
|
560
|
+
function buildSessionIdFromContext(
|
|
561
|
+
guildId: string | null,
|
|
562
|
+
channelId: string,
|
|
563
|
+
userId: string,
|
|
564
|
+
): string {
|
|
439
565
|
return buildSessionIdFromContextInbound(guildId, channelId, userId);
|
|
440
566
|
}
|
|
441
567
|
|
|
568
|
+
interface ResolvedChannelBehavior {
|
|
569
|
+
guildMessageMode: DiscordGuildMessageMode;
|
|
570
|
+
typingMode: 'instant' | 'thinking' | 'streaming' | 'never';
|
|
571
|
+
debounceMs: number;
|
|
572
|
+
ackReaction: string;
|
|
573
|
+
ackReactionScope: 'all' | 'group-mentions' | 'direct' | 'off';
|
|
574
|
+
removeAckAfterReply: boolean;
|
|
575
|
+
humanDelay: HumanDelayConfig;
|
|
576
|
+
rateLimitPerUser: number;
|
|
577
|
+
suppressPatterns: string[];
|
|
578
|
+
maxConcurrentPerChannel: number;
|
|
579
|
+
}
|
|
580
|
+
|
|
442
581
|
function resolveGuildMessageMode(msg: DiscordMessage): DiscordGuildMessageMode {
|
|
443
582
|
if (!msg.guild) return 'free';
|
|
444
583
|
if (DISCORD_GROUP_POLICY === 'disabled') return 'off';
|
|
@@ -455,19 +594,133 @@ function resolveGuildMessageMode(msg: DiscordMessage): DiscordGuildMessageMode {
|
|
|
455
594
|
return 'mention';
|
|
456
595
|
}
|
|
457
596
|
|
|
458
|
-
function
|
|
597
|
+
function resolveChannelBehavior(msg: DiscordMessage): ResolvedChannelBehavior {
|
|
598
|
+
const guildConfig = msg.guild ? DISCORD_GUILDS[msg.guild.id] : undefined;
|
|
599
|
+
const channelConfig = guildConfig?.channels[msg.channelId];
|
|
600
|
+
return {
|
|
601
|
+
guildMessageMode: resolveGuildMessageMode(msg),
|
|
602
|
+
typingMode: channelConfig?.typingMode ?? DISCORD_TYPING_MODE,
|
|
603
|
+
debounceMs: resolveInboundDebounceMs(
|
|
604
|
+
DISCORD_DEBOUNCE_MS,
|
|
605
|
+
channelConfig?.debounceMs,
|
|
606
|
+
),
|
|
607
|
+
ackReaction:
|
|
608
|
+
(channelConfig?.ackReaction ?? DISCORD_ACK_REACTION).trim() ||
|
|
609
|
+
DISCORD_ACK_REACTION,
|
|
610
|
+
ackReactionScope:
|
|
611
|
+
channelConfig?.ackReactionScope ?? DISCORD_ACK_REACTION_SCOPE,
|
|
612
|
+
removeAckAfterReply:
|
|
613
|
+
channelConfig?.removeAckAfterReply ?? DISCORD_REMOVE_ACK_AFTER_REPLY,
|
|
614
|
+
humanDelay: channelConfig?.humanDelay ?? DISCORD_HUMAN_DELAY,
|
|
615
|
+
rateLimitPerUser: Math.max(
|
|
616
|
+
0,
|
|
617
|
+
channelConfig?.rateLimitPerUser ?? DISCORD_RATE_LIMIT_PER_USER,
|
|
618
|
+
),
|
|
619
|
+
suppressPatterns:
|
|
620
|
+
channelConfig?.suppressPatterns ?? DISCORD_SUPPRESS_PATTERNS,
|
|
621
|
+
maxConcurrentPerChannel: Math.max(
|
|
622
|
+
1,
|
|
623
|
+
channelConfig?.maxConcurrentPerChannel ??
|
|
624
|
+
DISCORD_MAX_CONCURRENT_PER_CHANNEL,
|
|
625
|
+
),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function isTrigger(
|
|
630
|
+
msg: DiscordMessage,
|
|
631
|
+
behavior: ResolvedChannelBehavior,
|
|
632
|
+
): boolean {
|
|
459
633
|
return isTriggerInbound({
|
|
460
634
|
content: msg.content,
|
|
461
635
|
isDm: !msg.guild,
|
|
462
636
|
commandsOnly: DISCORD_COMMANDS_ONLY,
|
|
463
637
|
respondToAllMessages: DISCORD_RESPOND_TO_ALL_MESSAGES,
|
|
464
|
-
guildMessageMode:
|
|
638
|
+
guildMessageMode: behavior.guildMessageMode,
|
|
465
639
|
prefix: DISCORD_PREFIX,
|
|
466
640
|
botMentionRegex,
|
|
467
641
|
hasBotMention: Boolean(client.user && msg.mentions.has(client.user)),
|
|
642
|
+
suppressPatterns: behavior.suppressPatterns,
|
|
468
643
|
});
|
|
469
644
|
}
|
|
470
645
|
|
|
646
|
+
function shouldHandleFreeModeMessage(
|
|
647
|
+
msg: DiscordMessage,
|
|
648
|
+
behavior: ResolvedChannelBehavior,
|
|
649
|
+
content: string,
|
|
650
|
+
): boolean {
|
|
651
|
+
if (!msg.guild) return true;
|
|
652
|
+
|
|
653
|
+
const hasPrefixedInvocation = hasPrefixInvocation(msg.content || '');
|
|
654
|
+
const hasBotMention = Boolean(client.user && msg.mentions.has(client.user));
|
|
655
|
+
const hasLooseBotMention =
|
|
656
|
+
client.user != null
|
|
657
|
+
? hasLooseBotMentionInbound(content, [
|
|
658
|
+
client.user.username,
|
|
659
|
+
client.user.globalName || '',
|
|
660
|
+
msg.guild?.members.me?.displayName || '',
|
|
661
|
+
])
|
|
662
|
+
: false;
|
|
663
|
+
const isReplyToBot = Boolean(
|
|
664
|
+
client.user && msg.mentions.repliedUser?.id === client.user.id,
|
|
665
|
+
);
|
|
666
|
+
const mentionedUserIds = Array.from(msg.mentions.users.keys());
|
|
667
|
+
const botUserId = client.user?.id ?? null;
|
|
668
|
+
|
|
669
|
+
if (
|
|
670
|
+
shouldSkipFreeReplyBecauseOtherUsersMentionedInbound({
|
|
671
|
+
guildMessageMode: behavior.guildMessageMode,
|
|
672
|
+
hasBotMention,
|
|
673
|
+
hasPrefixInvocation: hasPrefixedInvocation,
|
|
674
|
+
botUserId,
|
|
675
|
+
mentionedUserIds,
|
|
676
|
+
})
|
|
677
|
+
) {
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return shouldReplyInFreeModeInbound({
|
|
682
|
+
guildMessageMode: behavior.guildMessageMode,
|
|
683
|
+
content,
|
|
684
|
+
hasBotMention,
|
|
685
|
+
hasLooseBotMention,
|
|
686
|
+
isAddressedToChannel: isAddressedToChannelInbound(content),
|
|
687
|
+
hasPrefixInvocation: hasPrefixedInvocation,
|
|
688
|
+
isReplyToBot,
|
|
689
|
+
hasAttachments: msg.attachments.size > 0,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function shouldApplyAckReaction(
|
|
694
|
+
msg: DiscordMessage,
|
|
695
|
+
behavior: ResolvedChannelBehavior,
|
|
696
|
+
): boolean {
|
|
697
|
+
const scope = behavior.ackReactionScope;
|
|
698
|
+
if (scope === 'off') return false;
|
|
699
|
+
if (scope === 'all') return true;
|
|
700
|
+
if (scope === 'direct') return !msg.guild;
|
|
701
|
+
if (!msg.guild || !client.user) return false;
|
|
702
|
+
return msg.mentions.has(client.user);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function isRateLimitExempt(msg: DiscordMessage): boolean {
|
|
706
|
+
if (msg.author.id === DISCORD_COMMAND_USER_ID.trim()) return true;
|
|
707
|
+
if (!msg.guild) return false;
|
|
708
|
+
if (!msg.member || DISCORD_RATE_LIMIT_EXEMPT_ROLES.length === 0) return false;
|
|
709
|
+
|
|
710
|
+
const exemptByName = new Set(
|
|
711
|
+
DISCORD_RATE_LIMIT_EXEMPT_ROLES.map((role) =>
|
|
712
|
+
role.trim().toLowerCase(),
|
|
713
|
+
).filter(Boolean),
|
|
714
|
+
);
|
|
715
|
+
if (exemptByName.size === 0) return false;
|
|
716
|
+
|
|
717
|
+
for (const role of msg.member.roles.cache.values()) {
|
|
718
|
+
const normalized = role.name.trim().toLowerCase();
|
|
719
|
+
if (exemptByName.has(normalized)) return true;
|
|
720
|
+
}
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
|
|
471
724
|
function parseCommand(content: string): ParsedCommand {
|
|
472
725
|
return parseCommandInbound(content, botMentionRegex, DISCORD_PREFIX);
|
|
473
726
|
}
|
|
@@ -475,19 +728,29 @@ function parseCommand(content: string): ParsedCommand {
|
|
|
475
728
|
function isRetryableDiscordError(error: unknown): boolean {
|
|
476
729
|
const maybe = error as DiscordErrorLike;
|
|
477
730
|
const status = maybe.status ?? maybe.httpStatus;
|
|
478
|
-
return
|
|
731
|
+
return (
|
|
732
|
+
status === 429 ||
|
|
733
|
+
(typeof status === 'number' && status >= 500 && status <= 599)
|
|
734
|
+
);
|
|
479
735
|
}
|
|
480
736
|
|
|
481
737
|
function retryDelayMs(error: unknown, fallbackMs: number): number {
|
|
482
738
|
const maybe = error as DiscordErrorLike;
|
|
483
739
|
const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
|
|
484
|
-
if (
|
|
740
|
+
if (
|
|
741
|
+
typeof retryAfterSeconds === 'number' &&
|
|
742
|
+
Number.isFinite(retryAfterSeconds) &&
|
|
743
|
+
retryAfterSeconds > 0
|
|
744
|
+
) {
|
|
485
745
|
return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
|
|
486
746
|
}
|
|
487
747
|
return fallbackMs + Math.floor(Math.random() * 250);
|
|
488
748
|
}
|
|
489
749
|
|
|
490
|
-
async function withDiscordRetry<T>(
|
|
750
|
+
async function withDiscordRetry<T>(
|
|
751
|
+
label: string,
|
|
752
|
+
fn: () => Promise<T>,
|
|
753
|
+
): Promise<T> {
|
|
491
754
|
let attempt = 0;
|
|
492
755
|
let delayMs = DISCORD_RETRY_BASE_DELAY_MS;
|
|
493
756
|
while (true) {
|
|
@@ -495,11 +758,17 @@ async function withDiscordRetry<T>(label: string, fn: () => Promise<T>): Promise
|
|
|
495
758
|
try {
|
|
496
759
|
return await fn();
|
|
497
760
|
} catch (error) {
|
|
498
|
-
if (
|
|
761
|
+
if (
|
|
762
|
+
attempt >= DISCORD_RETRY_MAX_ATTEMPTS ||
|
|
763
|
+
!isRetryableDiscordError(error)
|
|
764
|
+
) {
|
|
499
765
|
throw error;
|
|
500
766
|
}
|
|
501
767
|
const waitMs = retryDelayMs(error, delayMs);
|
|
502
|
-
logger.warn(
|
|
768
|
+
logger.warn(
|
|
769
|
+
{ label, attempt, waitMs, error },
|
|
770
|
+
'Discord API call failed; retrying',
|
|
771
|
+
);
|
|
503
772
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
504
773
|
delayMs = Math.min(delayMs * 2, 4_000);
|
|
505
774
|
}
|
|
@@ -513,7 +782,8 @@ function cleanIncomingContent(content: string): string {
|
|
|
513
782
|
function summarizeContextMessage(msg: DiscordMessage): string {
|
|
514
783
|
const author = msg.author?.username || 'user';
|
|
515
784
|
const content = (msg.content || '').trim();
|
|
516
|
-
const snippet =
|
|
785
|
+
const snippet =
|
|
786
|
+
content.length > 500 ? `${content.slice(0, 497)}...` : content;
|
|
517
787
|
return `${author}: ${snippet || '(no text)'}`;
|
|
518
788
|
}
|
|
519
789
|
|
|
@@ -526,16 +796,25 @@ function buildChannelInfoContext(msg: DiscordMessage): string {
|
|
|
526
796
|
`- channel_id: ${msg.channelId}`,
|
|
527
797
|
];
|
|
528
798
|
|
|
529
|
-
const namedChannel = msg.channel as unknown as {
|
|
530
|
-
|
|
799
|
+
const namedChannel = msg.channel as unknown as {
|
|
800
|
+
name?: string;
|
|
801
|
+
topic?: string;
|
|
802
|
+
parent?: { name?: string | null } | null;
|
|
803
|
+
};
|
|
804
|
+
const channelName =
|
|
805
|
+
typeof namedChannel.name === 'string' ? namedChannel.name.trim() : '';
|
|
531
806
|
if (channelName) {
|
|
532
807
|
lines.push(`- channel_name: #${channelName}`);
|
|
533
808
|
}
|
|
534
|
-
const channelTopic =
|
|
809
|
+
const channelTopic =
|
|
810
|
+
typeof namedChannel.topic === 'string' ? namedChannel.topic.trim() : '';
|
|
535
811
|
if (channelTopic) {
|
|
536
812
|
lines.push(`- channel_topic: ${channelTopic}`);
|
|
537
813
|
}
|
|
538
|
-
const parentName =
|
|
814
|
+
const parentName =
|
|
815
|
+
typeof namedChannel.parent?.name === 'string'
|
|
816
|
+
? namedChannel.parent.name.trim()
|
|
817
|
+
: '';
|
|
539
818
|
if (parentName) {
|
|
540
819
|
lines.push(`- parent_channel: ${parentName}`);
|
|
541
820
|
}
|
|
@@ -547,14 +826,21 @@ function buildChannelInfoContext(msg: DiscordMessage): string {
|
|
|
547
826
|
async function buildReplyContext(msg: DiscordMessage): Promise<string> {
|
|
548
827
|
const blocks: string[] = [];
|
|
549
828
|
|
|
550
|
-
if (
|
|
829
|
+
if (
|
|
830
|
+
'isThread' in msg.channel &&
|
|
831
|
+
typeof msg.channel.isThread === 'function' &&
|
|
832
|
+
msg.channel.isThread()
|
|
833
|
+
) {
|
|
551
834
|
try {
|
|
552
835
|
const starter = await msg.channel.fetchStarterMessage();
|
|
553
836
|
if (starter) {
|
|
554
837
|
blocks.push(`[Thread starter]\n${summarizeContextMessage(starter)}`);
|
|
555
838
|
}
|
|
556
839
|
} catch (error) {
|
|
557
|
-
logger.debug(
|
|
840
|
+
logger.debug(
|
|
841
|
+
{ error, channelId: msg.channelId },
|
|
842
|
+
'Failed to fetch thread starter message',
|
|
843
|
+
);
|
|
558
844
|
}
|
|
559
845
|
}
|
|
560
846
|
|
|
@@ -579,63 +865,18 @@ async function buildReplyContext(msg: DiscordMessage): Promise<string> {
|
|
|
579
865
|
return `${blocks.join('\n\n')}\n\n`;
|
|
580
866
|
}
|
|
581
867
|
|
|
582
|
-
async function addProcessingReaction(msg: DiscordMessage): Promise<() => Promise<void>> {
|
|
583
|
-
if (!client.user) return async () => {};
|
|
584
|
-
const botUserId = client.user.id;
|
|
585
|
-
try {
|
|
586
|
-
await withDiscordRetry('react', () => msg.react('👀'));
|
|
587
|
-
} catch (error) {
|
|
588
|
-
logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to add processing reaction');
|
|
589
|
-
return async () => {};
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
return async () => {
|
|
593
|
-
try {
|
|
594
|
-
const reaction = msg.reactions.resolve('👀');
|
|
595
|
-
if (!reaction) return;
|
|
596
|
-
await withDiscordRetry('reaction-remove', () => reaction.users.remove(botUserId));
|
|
597
|
-
} catch (error) {
|
|
598
|
-
logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to remove processing reaction');
|
|
599
|
-
}
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
function startTypingLoop(msg: DiscordMessage): { stop: () => void } {
|
|
604
|
-
let stopped = false;
|
|
605
|
-
const sendTyping = async (): Promise<void> => {
|
|
606
|
-
if (stopped) return;
|
|
607
|
-
if (!('sendTyping' in msg.channel)) return;
|
|
608
|
-
try {
|
|
609
|
-
await msg.channel.sendTyping();
|
|
610
|
-
} catch (error) {
|
|
611
|
-
logger.debug({ error, channelId: msg.channelId }, 'Failed to send typing indicator');
|
|
612
|
-
}
|
|
613
|
-
};
|
|
614
|
-
|
|
615
|
-
void sendTyping();
|
|
616
|
-
const timer = setInterval(() => {
|
|
617
|
-
void sendTyping();
|
|
618
|
-
}, 8_000);
|
|
619
|
-
|
|
620
|
-
return {
|
|
621
|
-
stop: () => {
|
|
622
|
-
if (stopped) return;
|
|
623
|
-
stopped = true;
|
|
624
|
-
clearInterval(timer);
|
|
625
|
-
},
|
|
626
|
-
};
|
|
627
|
-
}
|
|
628
|
-
|
|
629
868
|
async function sendChunkedReply(
|
|
630
869
|
msg: DiscordMessage,
|
|
631
870
|
text: string,
|
|
632
871
|
files?: AttachmentBuilder[],
|
|
633
872
|
mentionLookup?: MentionLookup,
|
|
873
|
+
humanDelay?: HumanDelayConfig,
|
|
634
874
|
): Promise<void> {
|
|
635
875
|
await sendChunkedReplyFromDelivery({
|
|
636
876
|
msg,
|
|
637
877
|
text,
|
|
638
878
|
withRetry: withDiscordRetry,
|
|
879
|
+
...(humanDelay ? { humanDelay } : {}),
|
|
639
880
|
...(files ? { files } : {}),
|
|
640
881
|
...(mentionLookup ? { mentionLookup } : {}),
|
|
641
882
|
});
|
|
@@ -646,18 +887,22 @@ async function sendChunkedDirectReply(
|
|
|
646
887
|
text: string,
|
|
647
888
|
files?: AttachmentBuilder[],
|
|
648
889
|
mentionLookup?: MentionLookup,
|
|
890
|
+
humanDelay?: HumanDelayConfig,
|
|
649
891
|
): Promise<void> {
|
|
650
892
|
await sendChunkedDirectReplyFromDelivery({
|
|
651
893
|
msg,
|
|
652
894
|
text,
|
|
653
895
|
withRetry: withDiscordRetry,
|
|
896
|
+
...(humanDelay ? { humanDelay } : {}),
|
|
654
897
|
...(files ? { files } : {}),
|
|
655
898
|
...(mentionLookup ? { mentionLookup } : {}),
|
|
656
899
|
});
|
|
657
900
|
}
|
|
658
901
|
|
|
659
902
|
async function sendChunkedInteractionReply(
|
|
660
|
-
interaction: Parameters<
|
|
903
|
+
interaction: Parameters<
|
|
904
|
+
typeof sendChunkedInteractionReplyFromDelivery
|
|
905
|
+
>[0]['interaction'],
|
|
661
906
|
text: string,
|
|
662
907
|
files?: AttachmentBuilder[],
|
|
663
908
|
): Promise<void> {
|
|
@@ -673,6 +918,7 @@ async function ensureSlashCommands(): Promise<void> {
|
|
|
673
918
|
interface SlashCommandDefinition {
|
|
674
919
|
name: string;
|
|
675
920
|
description: string;
|
|
921
|
+
dmPermission?: boolean;
|
|
676
922
|
options?: Array<{
|
|
677
923
|
type: ApplicationCommandOptionType.String;
|
|
678
924
|
name: string;
|
|
@@ -686,6 +932,33 @@ async function ensureSlashCommands(): Promise<void> {
|
|
|
686
932
|
{
|
|
687
933
|
name: 'status',
|
|
688
934
|
description: 'Show HybridClaw runtime status (only visible to you)',
|
|
935
|
+
dmPermission: true,
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
name: 'approve',
|
|
939
|
+
description: 'View/respond to pending tool approval requests (private)',
|
|
940
|
+
dmPermission: true,
|
|
941
|
+
options: [
|
|
942
|
+
{
|
|
943
|
+
type: ApplicationCommandOptionType.String,
|
|
944
|
+
name: 'action',
|
|
945
|
+
description: 'Action to perform',
|
|
946
|
+
required: false,
|
|
947
|
+
choices: [
|
|
948
|
+
{ name: 'view', value: 'view' },
|
|
949
|
+
{ name: 'yes', value: 'yes' },
|
|
950
|
+
{ name: 'session', value: 'session' },
|
|
951
|
+
{ name: 'agent', value: 'agent' },
|
|
952
|
+
{ name: 'no', value: 'no' },
|
|
953
|
+
],
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
type: ApplicationCommandOptionType.String,
|
|
957
|
+
name: 'approval_id',
|
|
958
|
+
description: 'Optional approval id (defaults to latest pending)',
|
|
959
|
+
required: false,
|
|
960
|
+
},
|
|
961
|
+
],
|
|
689
962
|
},
|
|
690
963
|
{
|
|
691
964
|
name: 'channel-mode',
|
|
@@ -704,70 +977,251 @@ async function ensureSlashCommands(): Promise<void> {
|
|
|
704
977
|
},
|
|
705
978
|
],
|
|
706
979
|
},
|
|
980
|
+
{
|
|
981
|
+
name: 'channel-policy',
|
|
982
|
+
description: 'Set guild channel policy to open, allowlist, or disabled',
|
|
983
|
+
options: [
|
|
984
|
+
{
|
|
985
|
+
type: ApplicationCommandOptionType.String,
|
|
986
|
+
name: 'policy',
|
|
987
|
+
description: 'Guild channel policy',
|
|
988
|
+
required: true,
|
|
989
|
+
choices: [
|
|
990
|
+
{ name: 'open', value: 'open' },
|
|
991
|
+
{ name: 'allowlist', value: 'allowlist' },
|
|
992
|
+
{ name: 'disabled', value: 'disabled' },
|
|
993
|
+
],
|
|
994
|
+
},
|
|
995
|
+
],
|
|
996
|
+
},
|
|
707
997
|
];
|
|
708
998
|
|
|
709
999
|
if (!client.application) return;
|
|
1000
|
+
const globalDefinitions = definitions.filter(
|
|
1001
|
+
(definition) =>
|
|
1002
|
+
definition.name === 'status' || definition.name === 'approve',
|
|
1003
|
+
);
|
|
1004
|
+
try {
|
|
1005
|
+
for (const definition of globalDefinitions) {
|
|
1006
|
+
// POST is an upsert by name for global commands. Keep command IDs stable
|
|
1007
|
+
// to avoid stale-client command references in DMs.
|
|
1008
|
+
await client.application.commands.create(
|
|
1009
|
+
definition as unknown as ApplicationCommandDataResolvable,
|
|
1010
|
+
);
|
|
1011
|
+
logger.info(
|
|
1012
|
+
{ scope: 'global', command: definition.name },
|
|
1013
|
+
'Upserted slash command',
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
logger.warn({ error }, 'Failed to register global slash commands');
|
|
1018
|
+
}
|
|
1019
|
+
|
|
710
1020
|
await Promise.allSettled(
|
|
711
1021
|
[...client.guilds.cache.values()].map(async (guild) => {
|
|
712
1022
|
try {
|
|
713
1023
|
const existing = await guild.commands.fetch();
|
|
714
1024
|
for (const definition of definitions) {
|
|
715
|
-
const current = existing.find(
|
|
1025
|
+
const current = existing.find(
|
|
1026
|
+
(command) => command.name === definition.name,
|
|
1027
|
+
);
|
|
716
1028
|
if (!current) {
|
|
717
|
-
await guild.commands.create(
|
|
718
|
-
|
|
1029
|
+
await guild.commands.create(
|
|
1030
|
+
definition as unknown as ApplicationCommandDataResolvable,
|
|
1031
|
+
);
|
|
1032
|
+
logger.info(
|
|
1033
|
+
{ guildId: guild.id, command: definition.name },
|
|
1034
|
+
'Registered slash command',
|
|
1035
|
+
);
|
|
719
1036
|
continue;
|
|
720
1037
|
}
|
|
721
|
-
await guild.commands.edit(
|
|
722
|
-
|
|
1038
|
+
await guild.commands.edit(
|
|
1039
|
+
current.id,
|
|
1040
|
+
definition as unknown as ApplicationCommandDataResolvable,
|
|
1041
|
+
);
|
|
1042
|
+
logger.info(
|
|
1043
|
+
{ guildId: guild.id, command: definition.name },
|
|
1044
|
+
'Updated slash command',
|
|
1045
|
+
);
|
|
723
1046
|
}
|
|
724
1047
|
} catch (error) {
|
|
725
|
-
logger.warn(
|
|
1048
|
+
logger.warn(
|
|
1049
|
+
{ error, guildId: guild.id },
|
|
1050
|
+
'Failed to register Discord slash commands',
|
|
1051
|
+
);
|
|
726
1052
|
}
|
|
727
1053
|
}),
|
|
728
1054
|
);
|
|
729
1055
|
}
|
|
730
1056
|
|
|
731
|
-
function
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1057
|
+
function trimRecentConversationMetrics(nowMs = Date.now()): void {
|
|
1058
|
+
const cutoff = nowMs - PRESENCE_WINDOW_MS;
|
|
1059
|
+
while (
|
|
1060
|
+
recentConversationMetrics.length > 0 &&
|
|
1061
|
+
recentConversationMetrics[0].atMs < cutoff
|
|
1062
|
+
) {
|
|
1063
|
+
recentConversationMetrics.shift();
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function recordConversationMetric(params: {
|
|
1068
|
+
durationMs: number;
|
|
1069
|
+
ok: boolean;
|
|
1070
|
+
error?: unknown;
|
|
1071
|
+
}): void {
|
|
1072
|
+
const nowMs = Date.now();
|
|
1073
|
+
const errorText =
|
|
1074
|
+
params.error instanceof Error
|
|
1075
|
+
? params.error.message
|
|
1076
|
+
: String(params.error || '');
|
|
1077
|
+
const exhaustedHint =
|
|
1078
|
+
!params.ok && PRESENCE_EXHAUSTED_ERROR_RE.test(errorText);
|
|
1079
|
+
if (params.ok) {
|
|
1080
|
+
consecutiveConversationFailures = 0;
|
|
1081
|
+
} else {
|
|
1082
|
+
consecutiveConversationFailures += 1;
|
|
1083
|
+
}
|
|
1084
|
+
recentConversationMetrics.push({
|
|
1085
|
+
atMs: nowMs,
|
|
1086
|
+
durationMs: Math.max(0, Math.floor(params.durationMs)),
|
|
1087
|
+
ok: params.ok,
|
|
1088
|
+
exhaustedHint,
|
|
1089
|
+
});
|
|
1090
|
+
trimRecentConversationMetrics(nowMs);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function resolvePresenceHealthState(): PresenceHealthState {
|
|
1094
|
+
trimRecentConversationMetrics();
|
|
1095
|
+
if (activeConversationRuns >= 4) return 'degraded';
|
|
1096
|
+
if (consecutiveConversationFailures >= 3) return 'exhausted';
|
|
1097
|
+
if (recentConversationMetrics.some((entry) => entry.exhaustedHint))
|
|
1098
|
+
return 'exhausted';
|
|
1099
|
+
|
|
1100
|
+
if (recentConversationMetrics.length === 0) return 'healthy';
|
|
1101
|
+
const totalDuration = recentConversationMetrics.reduce(
|
|
1102
|
+
(sum, entry) => sum + entry.durationMs,
|
|
1103
|
+
0,
|
|
1104
|
+
);
|
|
1105
|
+
const avgDuration = totalDuration / recentConversationMetrics.length;
|
|
1106
|
+
const failureCount = recentConversationMetrics.filter(
|
|
1107
|
+
(entry) => !entry.ok,
|
|
1108
|
+
).length;
|
|
1109
|
+
const errorRate = failureCount / recentConversationMetrics.length;
|
|
1110
|
+
if (errorRate >= 0.25 || avgDuration >= PRESENCE_DEGRADED_DURATION_MS)
|
|
1111
|
+
return 'degraded';
|
|
1112
|
+
return 'healthy';
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function randomIntInRange(minMs: number, maxMs: number): number {
|
|
1116
|
+
const lo = Math.floor(Math.max(0, minMs));
|
|
1117
|
+
const hi = Math.floor(Math.max(lo, maxMs));
|
|
1118
|
+
if (hi <= lo) return lo;
|
|
1119
|
+
return lo + Math.floor(Math.random() * (hi - lo + 1));
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function isNightOrWeekend(now: Date): boolean {
|
|
1123
|
+
const day = now.getDay();
|
|
1124
|
+
const hour = now.getHours();
|
|
1125
|
+
const weekend = day === 0 || day === 6;
|
|
1126
|
+
const night = hour >= NIGHT_HOURS_START || hour < NIGHT_HOURS_END;
|
|
1127
|
+
return weekend || night;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function buildConversationCooldownKey(
|
|
1131
|
+
channelId: string,
|
|
1132
|
+
userId: string,
|
|
1133
|
+
): string {
|
|
1134
|
+
return `${channelId}:${userId}`;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function resolveHumanDelayWithBehavior(
|
|
1138
|
+
base: HumanDelayConfig,
|
|
1139
|
+
cooldownKey: string,
|
|
1140
|
+
): HumanDelayConfig {
|
|
1141
|
+
if (base.mode === 'off') return base;
|
|
1142
|
+
const now = new Date();
|
|
1143
|
+
let factor = isNightOrWeekend(now) ? 1.5 : 1;
|
|
1144
|
+
const record = conversationExchangeByKey.get(cooldownKey);
|
|
1145
|
+
if (record) {
|
|
1146
|
+
const elapsedMs = Date.now() - record.lastAtMs;
|
|
1147
|
+
if (elapsedMs > CONVERSATION_COOLDOWN_RESET_MS) {
|
|
1148
|
+
conversationExchangeByKey.delete(cooldownKey);
|
|
1149
|
+
} else if (record.count > CONVERSATION_COOLDOWN_THRESHOLD) {
|
|
1150
|
+
const extra = Math.min(
|
|
1151
|
+
CONVERSATION_COOLDOWN_MAX_FACTOR - 1,
|
|
1152
|
+
(record.count - CONVERSATION_COOLDOWN_THRESHOLD) * 0.12,
|
|
1153
|
+
);
|
|
1154
|
+
factor += Math.max(0, extra);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (factor <= 1) return base;
|
|
1159
|
+
const minMs = Math.round((base.minMs ?? 800) * factor);
|
|
1160
|
+
const maxMs = Math.round((base.maxMs ?? 2_500) * factor);
|
|
1161
|
+
return {
|
|
1162
|
+
mode: 'custom',
|
|
1163
|
+
minMs,
|
|
1164
|
+
maxMs: Math.max(minMs, maxMs),
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function noteConversationExchange(cooldownKey: string): void {
|
|
1169
|
+
const nowMs = Date.now();
|
|
1170
|
+
const existing = conversationExchangeByKey.get(cooldownKey);
|
|
1171
|
+
if (!existing || nowMs - existing.lastAtMs > CONVERSATION_COOLDOWN_RESET_MS) {
|
|
1172
|
+
conversationExchangeByKey.set(cooldownKey, { count: 1, lastAtMs: nowMs });
|
|
738
1173
|
return;
|
|
739
1174
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1175
|
+
conversationExchangeByKey.set(cooldownKey, {
|
|
1176
|
+
count: existing.count + 1,
|
|
1177
|
+
lastAtMs: nowMs,
|
|
743
1178
|
});
|
|
744
1179
|
}
|
|
745
1180
|
|
|
746
|
-
|
|
1181
|
+
function pickReadWithoutReplyEmoji(): string {
|
|
1182
|
+
return Math.random() < 0.7 ? '👍' : '✅';
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
export function initDiscord(
|
|
1186
|
+
onMessage: MessageHandler,
|
|
1187
|
+
onCommand: CommandHandler,
|
|
1188
|
+
): Client {
|
|
747
1189
|
messageHandler = onMessage;
|
|
748
1190
|
commandHandler = onCommand;
|
|
749
1191
|
|
|
750
1192
|
interface QueuedConversationMessage {
|
|
751
1193
|
msg: DiscordMessage;
|
|
752
1194
|
content: string;
|
|
753
|
-
|
|
1195
|
+
behavior: ResolvedChannelBehavior;
|
|
1196
|
+
clearAckReaction: () => Promise<void>;
|
|
1197
|
+
wasExplicitlyAddressed: boolean;
|
|
1198
|
+
cooldownKey: string;
|
|
754
1199
|
}
|
|
755
1200
|
interface PendingConversationBatch {
|
|
756
1201
|
items: QueuedConversationMessage[];
|
|
757
1202
|
timer: ReturnType<typeof setTimeout>;
|
|
1203
|
+
typingController: ReturnType<typeof createTypingController>;
|
|
1204
|
+
lifecycleController: LifecycleReactionController | null;
|
|
758
1205
|
}
|
|
759
1206
|
interface InFlightConversation {
|
|
760
1207
|
abortController: AbortController;
|
|
761
1208
|
stream: DiscordStreamManager;
|
|
762
1209
|
messageIds: Set<string>;
|
|
763
1210
|
aborted: boolean;
|
|
1211
|
+
emitLifecyclePhase: (phase: LifecyclePhase) => void;
|
|
764
1212
|
}
|
|
765
1213
|
const pendingBatches = new Map<string, PendingConversationBatch>();
|
|
766
1214
|
const inFlightByMessageId = new Map<string, InFlightConversation>();
|
|
1215
|
+
const channelConcurrencyById = new Map<string, number>();
|
|
767
1216
|
const negativeFeedbackByChannel = new Map<string, string>();
|
|
768
|
-
const participantMemoryByChannel = new Map<
|
|
769
|
-
|
|
770
|
-
|
|
1217
|
+
const participantMemoryByChannel = new Map<
|
|
1218
|
+
string,
|
|
1219
|
+
Map<string, Set<string>>
|
|
1220
|
+
>();
|
|
1221
|
+
|
|
1222
|
+
const touchParticipantMemoryChannel = (
|
|
1223
|
+
channelId: string,
|
|
1224
|
+
): Map<string, Set<string>> => {
|
|
771
1225
|
const existing = participantMemoryByChannel.get(channelId);
|
|
772
1226
|
if (existing) {
|
|
773
1227
|
participantMemoryByChannel.delete(channelId);
|
|
@@ -784,7 +1238,11 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
784
1238
|
return created;
|
|
785
1239
|
};
|
|
786
1240
|
|
|
787
|
-
const rememberParticipantAliasForChannel = (
|
|
1241
|
+
const rememberParticipantAliasForChannel = (
|
|
1242
|
+
channelId: string,
|
|
1243
|
+
userId: string,
|
|
1244
|
+
rawAlias: string | null | undefined,
|
|
1245
|
+
): void => {
|
|
788
1246
|
if (!userId || userId === client.user?.id) return;
|
|
789
1247
|
const alias = normalizeMentionAlias(rawAlias);
|
|
790
1248
|
if (!alias) return;
|
|
@@ -801,7 +1259,9 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
801
1259
|
}
|
|
802
1260
|
aliases.add(alias);
|
|
803
1261
|
if (aliases.size > PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER) {
|
|
804
|
-
const kept = new Set(
|
|
1262
|
+
const kept = new Set(
|
|
1263
|
+
Array.from(aliases).slice(-PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER),
|
|
1264
|
+
);
|
|
805
1265
|
channelMemory.set(userId, kept);
|
|
806
1266
|
}
|
|
807
1267
|
// Refresh user recency.
|
|
@@ -812,14 +1272,21 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
812
1272
|
}
|
|
813
1273
|
};
|
|
814
1274
|
|
|
815
|
-
const rememberParticipantForChannel = (
|
|
1275
|
+
const rememberParticipantForChannel = (
|
|
1276
|
+
channelId: string,
|
|
1277
|
+
userId: string,
|
|
1278
|
+
aliases: Array<string | null | undefined>,
|
|
1279
|
+
): void => {
|
|
816
1280
|
if (!userId || userId === client.user?.id) return;
|
|
817
1281
|
for (const alias of aliases) {
|
|
818
1282
|
rememberParticipantAliasForChannel(channelId, userId, alias);
|
|
819
1283
|
}
|
|
820
1284
|
};
|
|
821
1285
|
|
|
822
|
-
const observeMessageParticipants = (
|
|
1286
|
+
const observeMessageParticipants = (
|
|
1287
|
+
msg: DiscordMessage,
|
|
1288
|
+
content: string,
|
|
1289
|
+
): void => {
|
|
823
1290
|
if (!msg.guild) return;
|
|
824
1291
|
rememberParticipantForChannel(msg.channelId, msg.author.id, [
|
|
825
1292
|
msg.author.username,
|
|
@@ -833,10 +1300,142 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
833
1300
|
]);
|
|
834
1301
|
}
|
|
835
1302
|
for (const hint of extractMentionAliasHints(content)) {
|
|
836
|
-
rememberParticipantAliasForChannel(
|
|
1303
|
+
rememberParticipantAliasForChannel(
|
|
1304
|
+
msg.channelId,
|
|
1305
|
+
hint.userId,
|
|
1306
|
+
hint.alias,
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
const waitForChannelConcurrencySlot = async (
|
|
1312
|
+
channelId: string,
|
|
1313
|
+
maxConcurrent: number,
|
|
1314
|
+
abortSignal?: AbortSignal,
|
|
1315
|
+
): Promise<() => void> => {
|
|
1316
|
+
const boundedMax = Math.max(1, Math.floor(maxConcurrent));
|
|
1317
|
+
while ((channelConcurrencyById.get(channelId) ?? 0) >= boundedMax) {
|
|
1318
|
+
if (abortSignal?.aborted) {
|
|
1319
|
+
throw new Error(
|
|
1320
|
+
'Conversation aborted while waiting for channel concurrency slot.',
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
await new Promise((resolve) =>
|
|
1324
|
+
setTimeout(resolve, CONCURRENCY_RETRY_DELAY_MS),
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
if (abortSignal?.aborted) {
|
|
1328
|
+
throw new Error(
|
|
1329
|
+
'Conversation aborted before acquiring channel concurrency slot.',
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
channelConcurrencyById.set(
|
|
1333
|
+
channelId,
|
|
1334
|
+
(channelConcurrencyById.get(channelId) ?? 0) + 1,
|
|
1335
|
+
);
|
|
1336
|
+
return () => {
|
|
1337
|
+
const current = channelConcurrencyById.get(channelId) ?? 0;
|
|
1338
|
+
const next = Math.max(0, current - 1);
|
|
1339
|
+
if (next === 0) {
|
|
1340
|
+
channelConcurrencyById.delete(channelId);
|
|
1341
|
+
} else {
|
|
1342
|
+
channelConcurrencyById.set(channelId, next);
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
const enforcePerUserRateLimit = async (
|
|
1348
|
+
msg: DiscordMessage,
|
|
1349
|
+
behavior: ResolvedChannelBehavior,
|
|
1350
|
+
): Promise<boolean> => {
|
|
1351
|
+
const limit = Math.max(0, Math.floor(behavior.rateLimitPerUser));
|
|
1352
|
+
if (limit === 0) return true;
|
|
1353
|
+
if (isRateLimitExempt(msg)) return true;
|
|
1354
|
+
|
|
1355
|
+
const key = `${msg.channelId}:${msg.author.id}`;
|
|
1356
|
+
const decision = userRateLimiter.check(key, limit);
|
|
1357
|
+
if (decision.allowed) return true;
|
|
1358
|
+
|
|
1359
|
+
if (userRateLimiter.shouldNotify(key, RATE_LIMIT_NOTIFY_COOLDOWN_MS)) {
|
|
1360
|
+
try {
|
|
1361
|
+
await withDiscordRetry('rate-limit-reply', () =>
|
|
1362
|
+
msg.reply({ content: FRIENDLY_RATE_LIMIT_MESSAGE }),
|
|
1363
|
+
);
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
logger.debug(
|
|
1366
|
+
{ error, channelId: msg.channelId, userId: msg.author.id },
|
|
1367
|
+
'Failed to send rate-limit warning',
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
return false;
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1374
|
+
const maybeHandleReadWithoutReply = async (
|
|
1375
|
+
msg: DiscordMessage,
|
|
1376
|
+
content: string,
|
|
1377
|
+
): Promise<boolean> => {
|
|
1378
|
+
if (!content.trim()) return false;
|
|
1379
|
+
if (!msg.guild) return false;
|
|
1380
|
+
if (msg.attachments.size > 0) return false;
|
|
1381
|
+
if (hasPrefixInvocation(msg.content || '')) return false;
|
|
1382
|
+
if (hasSlashCommandInvocation(msg.content || '')) return false;
|
|
1383
|
+
if (client.user && msg.mentions.has(client.user)) return false;
|
|
1384
|
+
if (content.trim().length > 80) return false;
|
|
1385
|
+
if (!READ_WITHOUT_REPLY_RE.test(content.trim())) return false;
|
|
1386
|
+
if (Math.random() > READ_WITHOUT_REPLY_PROBABILITY) return false;
|
|
1387
|
+
|
|
1388
|
+
try {
|
|
1389
|
+
await withDiscordRetry('reaction-read-without-reply', () =>
|
|
1390
|
+
msg.react(pickReadWithoutReplyEmoji()),
|
|
1391
|
+
);
|
|
1392
|
+
return true;
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
logger.debug(
|
|
1395
|
+
{ error, channelId: msg.channelId, messageId: msg.id },
|
|
1396
|
+
'Failed read-without-reply reaction',
|
|
1397
|
+
);
|
|
1398
|
+
return false;
|
|
837
1399
|
}
|
|
838
1400
|
};
|
|
839
1401
|
|
|
1402
|
+
const shouldSelectivelySilence = (params: {
|
|
1403
|
+
sourceItem: QueuedConversationMessage;
|
|
1404
|
+
inboundHistory: PendingGuildHistoryEntry[];
|
|
1405
|
+
behavior: ResolvedChannelBehavior;
|
|
1406
|
+
}): boolean => {
|
|
1407
|
+
if (!params.sourceItem.msg.guild) return false;
|
|
1408
|
+
if (params.behavior.guildMessageMode !== 'free') return false;
|
|
1409
|
+
if (params.sourceItem.wasExplicitlyAddressed) return false;
|
|
1410
|
+
|
|
1411
|
+
const nowMs = Date.now();
|
|
1412
|
+
const peerMessages = params.inboundHistory
|
|
1413
|
+
.filter(
|
|
1414
|
+
(entry) =>
|
|
1415
|
+
!entry.isBot &&
|
|
1416
|
+
entry.userId !== params.sourceItem.msg.author.id &&
|
|
1417
|
+
nowMs - entry.timestampMs <= SELECTIVE_SILENCE_RECENT_WINDOW_MS,
|
|
1418
|
+
)
|
|
1419
|
+
.sort((a, b) => a.timestampMs - b.timestampMs);
|
|
1420
|
+
if (peerMessages.length === 0) return false;
|
|
1421
|
+
|
|
1422
|
+
const sourceText = params.sourceItem.content.trim();
|
|
1423
|
+
const asksQuestion = sourceText.includes('?');
|
|
1424
|
+
const latestPeer =
|
|
1425
|
+
peerMessages[peerMessages.length - 1]?.content?.trim().toLowerCase() ||
|
|
1426
|
+
'';
|
|
1427
|
+
const peerLooksLikeAnswer =
|
|
1428
|
+
latestPeer.length >= 24 ||
|
|
1429
|
+
/\\b(you can|try|use|it is|it's|because|should|here|answer|fix)\\b/.test(
|
|
1430
|
+
latestPeer,
|
|
1431
|
+
);
|
|
1432
|
+
const probability =
|
|
1433
|
+
asksQuestion && peerLooksLikeAnswer
|
|
1434
|
+
? SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY
|
|
1435
|
+
: SELECTIVE_SILENCE_BASE_PROBABILITY;
|
|
1436
|
+
return Math.random() < probability;
|
|
1437
|
+
};
|
|
1438
|
+
|
|
840
1439
|
const intents: GatewayIntentBits[] = [
|
|
841
1440
|
GatewayIntentBits.Guilds,
|
|
842
1441
|
GatewayIntentBits.GuildMessages,
|
|
@@ -844,12 +1443,18 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
844
1443
|
GatewayIntentBits.MessageContent,
|
|
845
1444
|
GatewayIntentBits.DirectMessages,
|
|
846
1445
|
];
|
|
847
|
-
if (DISCORD_GUILD_MEMBERS_INTENT)
|
|
1446
|
+
if (DISCORD_GUILD_MEMBERS_INTENT)
|
|
1447
|
+
intents.push(GatewayIntentBits.GuildMembers);
|
|
848
1448
|
if (DISCORD_PRESENCE_INTENT) intents.push(GatewayIntentBits.GuildPresences);
|
|
849
1449
|
|
|
850
1450
|
client = new Client({
|
|
851
1451
|
intents,
|
|
852
|
-
partials: [
|
|
1452
|
+
partials: [
|
|
1453
|
+
Partials.Channel,
|
|
1454
|
+
Partials.Message,
|
|
1455
|
+
Partials.Reaction,
|
|
1456
|
+
Partials.User,
|
|
1457
|
+
],
|
|
853
1458
|
});
|
|
854
1459
|
|
|
855
1460
|
client.on('presenceUpdate', (_oldPresence, nextPresence) => {
|
|
@@ -868,18 +1473,35 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
868
1473
|
|
|
869
1474
|
client.on('clientReady', () => {
|
|
870
1475
|
logger.info({ user: client.user?.tag }, 'Discord bot connected');
|
|
1476
|
+
startupConnectedAtMs = Date.now();
|
|
871
1477
|
if (client.user) {
|
|
872
1478
|
botMentionRegex = new RegExp(`<@!?${client.user.id}>`, 'g');
|
|
873
1479
|
}
|
|
874
|
-
|
|
1480
|
+
presenceController?.stop();
|
|
1481
|
+
presenceController = new DiscordAutoPresenceController({
|
|
1482
|
+
client,
|
|
1483
|
+
getConfig: () => DISCORD_SELF_PRESENCE,
|
|
1484
|
+
resolveState: resolvePresenceHealthState,
|
|
1485
|
+
});
|
|
1486
|
+
presenceController.start();
|
|
875
1487
|
void ensureSlashCommands();
|
|
876
1488
|
});
|
|
877
1489
|
|
|
878
1490
|
client.on('interactionCreate', async (interaction) => {
|
|
879
1491
|
if (!interaction.isChatInputCommand()) return;
|
|
880
|
-
if (
|
|
1492
|
+
if (
|
|
1493
|
+
interaction.commandName !== 'status' &&
|
|
1494
|
+
interaction.commandName !== 'approve' &&
|
|
1495
|
+
interaction.commandName !== 'channel-mode' &&
|
|
1496
|
+
interaction.commandName !== 'channel-policy'
|
|
1497
|
+
) {
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
881
1500
|
|
|
882
|
-
if (
|
|
1501
|
+
if (
|
|
1502
|
+
interaction.guildId &&
|
|
1503
|
+
!isAuthorizedCommandUserId(interaction.user.id)
|
|
1504
|
+
) {
|
|
883
1505
|
await sendChunkedInteractionReply(
|
|
884
1506
|
interaction,
|
|
885
1507
|
'You are not authorized to run commands for this bot.',
|
|
@@ -889,19 +1511,65 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
889
1511
|
|
|
890
1512
|
const guildId = interaction.guildId ?? null;
|
|
891
1513
|
const channelId = interaction.channelId;
|
|
892
|
-
const sessionId = buildSessionIdFromContext(
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1514
|
+
const sessionId = buildSessionIdFromContext(
|
|
1515
|
+
guildId,
|
|
1516
|
+
channelId,
|
|
1517
|
+
interaction.user.id,
|
|
1518
|
+
);
|
|
1519
|
+
const args = (() => {
|
|
1520
|
+
if (interaction.commandName === 'status') return ['status'];
|
|
1521
|
+
if (interaction.commandName === 'approve') {
|
|
1522
|
+
const action =
|
|
1523
|
+
interaction.options
|
|
1524
|
+
.getString('action', false)
|
|
1525
|
+
?.trim()
|
|
1526
|
+
.toLowerCase() || 'view';
|
|
1527
|
+
if (
|
|
1528
|
+
action !== 'view' &&
|
|
1529
|
+
action !== 'yes' &&
|
|
1530
|
+
action !== 'session' &&
|
|
1531
|
+
action !== 'agent' &&
|
|
1532
|
+
action !== 'no'
|
|
1533
|
+
) {
|
|
1534
|
+
return null;
|
|
1535
|
+
}
|
|
1536
|
+
const approvalId =
|
|
1537
|
+
interaction.options.getString('approval_id', false)?.trim() || '';
|
|
1538
|
+
return approvalId
|
|
1539
|
+
? ['approve', action, approvalId]
|
|
1540
|
+
: ['approve', action];
|
|
1541
|
+
}
|
|
1542
|
+
if (interaction.commandName === 'channel-mode') {
|
|
1543
|
+
if (!interaction.guildId) return null;
|
|
1544
|
+
const selectedMode = interaction.options
|
|
1545
|
+
.getString('mode', true)
|
|
1546
|
+
.trim()
|
|
1547
|
+
.toLowerCase();
|
|
1548
|
+
if (
|
|
1549
|
+
selectedMode !== 'off' &&
|
|
1550
|
+
selectedMode !== 'mention' &&
|
|
1551
|
+
selectedMode !== 'free'
|
|
1552
|
+
)
|
|
1553
|
+
return null;
|
|
1554
|
+
return ['channel', 'mode', selectedMode];
|
|
1555
|
+
}
|
|
1556
|
+
if (!interaction.guildId) return null;
|
|
1557
|
+
const selectedPolicy = interaction.options
|
|
1558
|
+
.getString('policy', true)
|
|
1559
|
+
.trim()
|
|
1560
|
+
.toLowerCase();
|
|
1561
|
+
if (
|
|
1562
|
+
selectedPolicy !== 'open' &&
|
|
1563
|
+
selectedPolicy !== 'allowlist' &&
|
|
1564
|
+
selectedPolicy !== 'disabled'
|
|
1565
|
+
)
|
|
1566
|
+
return null;
|
|
1567
|
+
return ['channel', 'policy', selectedPolicy];
|
|
1568
|
+
})();
|
|
901
1569
|
if (!args) {
|
|
902
1570
|
await sendChunkedInteractionReply(
|
|
903
1571
|
interaction,
|
|
904
|
-
'This command can only be used in a server channel with
|
|
1572
|
+
'This command can only be used in a server channel with a valid mode/policy option.',
|
|
905
1573
|
);
|
|
906
1574
|
return;
|
|
907
1575
|
}
|
|
@@ -910,8 +1578,11 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
910
1578
|
sessionId,
|
|
911
1579
|
guildId,
|
|
912
1580
|
channelId,
|
|
1581
|
+
interaction.user.id,
|
|
1582
|
+
interaction.user.username,
|
|
913
1583
|
args,
|
|
914
|
-
async (text, files) =>
|
|
1584
|
+
async (text, files) =>
|
|
1585
|
+
sendChunkedInteractionReply(interaction, text, files),
|
|
915
1586
|
);
|
|
916
1587
|
} catch (error) {
|
|
917
1588
|
const detail = error instanceof Error ? error.message : String(error);
|
|
@@ -919,7 +1590,10 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
919
1590
|
{ error, guildId, channelId, userId: interaction.user.id },
|
|
920
1591
|
'Discord slash command failed',
|
|
921
1592
|
);
|
|
922
|
-
await sendChunkedInteractionReply(
|
|
1593
|
+
await sendChunkedInteractionReply(
|
|
1594
|
+
interaction,
|
|
1595
|
+
formatError('Gateway Error', detail),
|
|
1596
|
+
);
|
|
923
1597
|
}
|
|
924
1598
|
});
|
|
925
1599
|
|
|
@@ -937,10 +1611,16 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
937
1611
|
const channelId = msg.channelId;
|
|
938
1612
|
const userId = msg.author.id;
|
|
939
1613
|
const username = msg.author.username;
|
|
940
|
-
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
1614
|
+
const behavior = sourceItem.behavior;
|
|
1615
|
+
const startedAt = Date.now();
|
|
1616
|
+
let releaseChannelSlot: (() => void) | null = null;
|
|
1617
|
+
|
|
1618
|
+
const batchedContent =
|
|
1619
|
+
items.length > 1
|
|
1620
|
+
? items
|
|
1621
|
+
.map((item, index) => `Message ${index + 1}:\n${item.content}`)
|
|
1622
|
+
.join('\n\n')
|
|
1623
|
+
: sourceItem.content;
|
|
944
1624
|
const channelInfoContext = buildChannelInfoContext(msg);
|
|
945
1625
|
const replyContext = await buildReplyContext(msg);
|
|
946
1626
|
const feedbackNote = negativeFeedbackByChannel.get(channelId) || '';
|
|
@@ -948,9 +1628,16 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
948
1628
|
negativeFeedbackByChannel.delete(channelId);
|
|
949
1629
|
}
|
|
950
1630
|
const currentBatchMessageIds = new Set(items.map((item) => item.msg.id));
|
|
951
|
-
const inboundHistory = await buildInboundHistorySnapshot(
|
|
952
|
-
|
|
953
|
-
|
|
1631
|
+
const inboundHistory = await buildInboundHistorySnapshot(
|
|
1632
|
+
msg,
|
|
1633
|
+
currentBatchMessageIds,
|
|
1634
|
+
);
|
|
1635
|
+
const attachmentContext = await buildAttachmentContext(
|
|
1636
|
+
items.map((item) => item.msg),
|
|
1637
|
+
);
|
|
1638
|
+
const rememberedParticipants = participantMemoryByChannel.get(
|
|
1639
|
+
msg.channelId,
|
|
1640
|
+
);
|
|
954
1641
|
const participantContext = buildParticipantContext(
|
|
955
1642
|
items.map((item) => item.msg),
|
|
956
1643
|
inboundHistory.entries,
|
|
@@ -962,25 +1649,70 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
962
1649
|
rememberedParticipants,
|
|
963
1650
|
);
|
|
964
1651
|
const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${channelInfoContext}${replyContext}${inboundHistory.context}${attachmentContext.context}${participantContext}${batchedContent}`;
|
|
1652
|
+
const selectiveSilence = shouldSelectivelySilence({
|
|
1653
|
+
sourceItem,
|
|
1654
|
+
inboundHistory: inboundHistory.entries,
|
|
1655
|
+
behavior,
|
|
1656
|
+
});
|
|
965
1657
|
|
|
966
1658
|
const abortController = new AbortController();
|
|
967
|
-
const
|
|
1659
|
+
const typingController = pending.typingController;
|
|
1660
|
+
const lifecycleController = pending.lifecycleController;
|
|
1661
|
+
const emitLifecyclePhase = (phase: LifecyclePhase): void => {
|
|
1662
|
+
if (phase === 'queued') {
|
|
1663
|
+
typingController.setPhase('received');
|
|
1664
|
+
} else if (phase === 'thinking') {
|
|
1665
|
+
typingController.setPhase('thinking');
|
|
1666
|
+
} else if (phase === 'toolUse') {
|
|
1667
|
+
typingController.setPhase('toolUse');
|
|
1668
|
+
} else if (phase === 'streaming') {
|
|
1669
|
+
typingController.setPhase('streaming');
|
|
1670
|
+
} else {
|
|
1671
|
+
typingController.setPhase('done');
|
|
1672
|
+
}
|
|
1673
|
+
lifecycleController?.setPhase(phase);
|
|
1674
|
+
};
|
|
1675
|
+
|
|
968
1676
|
const stream = new DiscordStreamManager(msg, {
|
|
969
|
-
onFirstMessage: () =>
|
|
1677
|
+
onFirstMessage: () => emitLifecyclePhase('streaming'),
|
|
1678
|
+
humanDelay: behavior.humanDelay,
|
|
970
1679
|
});
|
|
971
1680
|
const inFlight: InFlightConversation = {
|
|
972
1681
|
abortController,
|
|
973
1682
|
stream,
|
|
974
1683
|
messageIds: new Set(items.map((item) => item.msg.id)),
|
|
975
1684
|
aborted: false,
|
|
1685
|
+
emitLifecyclePhase,
|
|
976
1686
|
};
|
|
977
1687
|
for (const messageId of inFlight.messageIds) {
|
|
978
1688
|
inFlightByMessageId.set(messageId, inFlight);
|
|
979
1689
|
}
|
|
980
1690
|
|
|
981
1691
|
try {
|
|
1692
|
+
if (selectiveSilence) {
|
|
1693
|
+
emitLifecyclePhase('done');
|
|
1694
|
+
if (Math.random() < 0.5) {
|
|
1695
|
+
await withDiscordRetry('reaction-selective-silence', () =>
|
|
1696
|
+
msg.react(pickReadWithoutReplyEmoji()),
|
|
1697
|
+
).catch(() => {});
|
|
1698
|
+
}
|
|
1699
|
+
recordConversationMetric({
|
|
1700
|
+
durationMs: Date.now() - startedAt,
|
|
1701
|
+
ok: true,
|
|
1702
|
+
});
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
releaseChannelSlot = await waitForChannelConcurrencySlot(
|
|
1707
|
+
channelId,
|
|
1708
|
+
behavior.maxConcurrentPerChannel,
|
|
1709
|
+
abortController.signal,
|
|
1710
|
+
);
|
|
1711
|
+
if (abortController.signal.aborted) {
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
982
1714
|
activeConversationRuns += 1;
|
|
983
|
-
|
|
1715
|
+
emitLifecyclePhase('thinking');
|
|
984
1716
|
await messageHandler(
|
|
985
1717
|
sessionId,
|
|
986
1718
|
guildId,
|
|
@@ -990,8 +1722,14 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
990
1722
|
combinedContent,
|
|
991
1723
|
attachmentContext.media,
|
|
992
1724
|
async (text, files) => {
|
|
993
|
-
|
|
994
|
-
await sendChunkedReply(
|
|
1725
|
+
emitLifecyclePhase('streaming');
|
|
1726
|
+
await sendChunkedReply(
|
|
1727
|
+
msg,
|
|
1728
|
+
text,
|
|
1729
|
+
files,
|
|
1730
|
+
mentionLookup,
|
|
1731
|
+
behavior.humanDelay,
|
|
1732
|
+
);
|
|
995
1733
|
},
|
|
996
1734
|
{
|
|
997
1735
|
sourceMessage: msg,
|
|
@@ -999,66 +1737,190 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
999
1737
|
abortSignal: abortController.signal,
|
|
1000
1738
|
stream,
|
|
1001
1739
|
mentionLookup,
|
|
1740
|
+
emitLifecyclePhase,
|
|
1002
1741
|
},
|
|
1003
1742
|
);
|
|
1743
|
+
emitLifecyclePhase('done');
|
|
1744
|
+
recordConversationMetric({
|
|
1745
|
+
durationMs: Date.now() - startedAt,
|
|
1746
|
+
ok: true,
|
|
1747
|
+
});
|
|
1748
|
+
noteConversationExchange(sourceItem.cooldownKey);
|
|
1004
1749
|
} catch (error) {
|
|
1005
|
-
|
|
1750
|
+
if (abortController.signal.aborted || inFlight.aborted) {
|
|
1751
|
+
logger.debug(
|
|
1752
|
+
{ channelId, sessionId },
|
|
1753
|
+
'Conversation batch aborted before completion',
|
|
1754
|
+
);
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
emitLifecyclePhase('error');
|
|
1758
|
+
recordConversationMetric({
|
|
1759
|
+
durationMs: Date.now() - startedAt,
|
|
1760
|
+
ok: false,
|
|
1761
|
+
error,
|
|
1762
|
+
});
|
|
1763
|
+
logger.error(
|
|
1764
|
+
{ error, channelId, sessionId },
|
|
1765
|
+
'Conversation batch handling failed',
|
|
1766
|
+
);
|
|
1006
1767
|
const detail = error instanceof Error ? error.message : String(error);
|
|
1007
1768
|
if (stream.hasSentMessages()) {
|
|
1008
1769
|
await stream.fail(formatError('Gateway Error', detail));
|
|
1009
1770
|
} else {
|
|
1010
|
-
await sendChunkedReply(
|
|
1771
|
+
await sendChunkedReply(
|
|
1772
|
+
msg,
|
|
1773
|
+
formatError('Gateway Error', detail),
|
|
1774
|
+
undefined,
|
|
1775
|
+
mentionLookup,
|
|
1776
|
+
behavior.humanDelay,
|
|
1777
|
+
);
|
|
1011
1778
|
}
|
|
1012
1779
|
} finally {
|
|
1013
1780
|
activeConversationRuns = Math.max(0, activeConversationRuns - 1);
|
|
1014
|
-
|
|
1781
|
+
if (releaseChannelSlot) {
|
|
1782
|
+
releaseChannelSlot();
|
|
1783
|
+
}
|
|
1015
1784
|
for (const messageId of inFlight.messageIds) {
|
|
1016
1785
|
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
1017
1786
|
inFlightByMessageId.delete(messageId);
|
|
1018
1787
|
}
|
|
1019
1788
|
}
|
|
1020
|
-
|
|
1021
|
-
await Promise.all(
|
|
1022
|
-
|
|
1023
|
-
|
|
1789
|
+
typingController.stop();
|
|
1790
|
+
await Promise.all(
|
|
1791
|
+
items.map(async (item) => {
|
|
1792
|
+
await item.clearAckReaction();
|
|
1793
|
+
}),
|
|
1794
|
+
);
|
|
1024
1795
|
}
|
|
1025
1796
|
};
|
|
1026
1797
|
|
|
1027
1798
|
const queueConversationMessage = async (
|
|
1028
1799
|
msg: DiscordMessage,
|
|
1029
1800
|
content: string,
|
|
1801
|
+
behavior: ResolvedChannelBehavior,
|
|
1030
1802
|
): Promise<void> => {
|
|
1031
1803
|
const key = `${msg.channelId}:${msg.author.id}`;
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1804
|
+
const cooldownKey = buildConversationCooldownKey(
|
|
1805
|
+
msg.channelId,
|
|
1806
|
+
msg.author.id,
|
|
1807
|
+
);
|
|
1808
|
+
const adjustedHumanDelay = resolveHumanDelayWithBehavior(
|
|
1809
|
+
behavior.humanDelay,
|
|
1810
|
+
cooldownKey,
|
|
1811
|
+
);
|
|
1812
|
+
const queuedBehavior: ResolvedChannelBehavior = {
|
|
1813
|
+
...behavior,
|
|
1814
|
+
humanDelay: adjustedHumanDelay,
|
|
1815
|
+
};
|
|
1816
|
+
const wasExplicitlyAddressed =
|
|
1817
|
+
!msg.guild ||
|
|
1818
|
+
hasPrefixInvocation(msg.content || '') ||
|
|
1819
|
+
Boolean(client.user && msg.mentions.has(client.user)) ||
|
|
1820
|
+
Boolean(client.user && msg.mentions.repliedUser?.id === client.user.id);
|
|
1821
|
+
|
|
1822
|
+
let clearAckReaction: () => Promise<void> = async () => {};
|
|
1823
|
+
if (client.user && shouldApplyAckReaction(msg, behavior)) {
|
|
1824
|
+
const clearReaction = await addAckReaction({
|
|
1825
|
+
message: msg,
|
|
1826
|
+
emoji: behavior.ackReaction,
|
|
1827
|
+
withRetry: withDiscordRetry,
|
|
1828
|
+
botUserId: client.user.id,
|
|
1829
|
+
});
|
|
1830
|
+
clearAckReaction = behavior.removeAckAfterReply
|
|
1831
|
+
? clearReaction
|
|
1832
|
+
: async () => {};
|
|
1833
|
+
}
|
|
1834
|
+
const queued: QueuedConversationMessage = {
|
|
1835
|
+
msg,
|
|
1836
|
+
content,
|
|
1837
|
+
behavior: queuedBehavior,
|
|
1838
|
+
clearAckReaction,
|
|
1839
|
+
wasExplicitlyAddressed,
|
|
1840
|
+
cooldownKey,
|
|
1841
|
+
};
|
|
1034
1842
|
const existing = pendingBatches.get(key);
|
|
1843
|
+
const shouldDebounceMessage = shouldDebounceInbound({
|
|
1844
|
+
content: msg.content || '',
|
|
1845
|
+
hasAttachments: msg.attachments.size > 0,
|
|
1846
|
+
isPrefixedCommand: hasPrefixInvocation(msg.content || ''),
|
|
1847
|
+
});
|
|
1035
1848
|
|
|
1036
1849
|
if (!existing) {
|
|
1850
|
+
const typingController = createTypingController(msg, behavior.typingMode);
|
|
1851
|
+
typingController.setPhase('received');
|
|
1852
|
+
const lifecycleController =
|
|
1853
|
+
client.user && DISCORD_LIFECYCLE_REACTIONS.enabled
|
|
1854
|
+
? new LifecycleReactionController({
|
|
1855
|
+
message: msg,
|
|
1856
|
+
withRetry: withDiscordRetry,
|
|
1857
|
+
botUserId: client.user.id,
|
|
1858
|
+
config: {
|
|
1859
|
+
enabled: DISCORD_LIFECYCLE_REACTIONS.enabled,
|
|
1860
|
+
removeOnComplete: DISCORD_LIFECYCLE_REACTIONS.removeOnComplete,
|
|
1861
|
+
phases: DISCORD_LIFECYCLE_REACTIONS.phases,
|
|
1862
|
+
},
|
|
1863
|
+
})
|
|
1864
|
+
: null;
|
|
1865
|
+
lifecycleController?.setPhase('queued');
|
|
1866
|
+
const baseDelayMs = shouldDebounceMessage ? behavior.debounceMs : 0;
|
|
1867
|
+
const startupStaggerMs =
|
|
1868
|
+
startupConnectedAtMs > 0 &&
|
|
1869
|
+
Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
|
|
1870
|
+
!wasExplicitlyAddressed
|
|
1871
|
+
? randomIntInRange(
|
|
1872
|
+
STARTUP_STAGGER_MIN_DELAY_MS,
|
|
1873
|
+
STARTUP_STAGGER_MAX_DELAY_MS,
|
|
1874
|
+
)
|
|
1875
|
+
: 0;
|
|
1876
|
+
const delayMs = baseDelayMs + startupStaggerMs;
|
|
1037
1877
|
const timer = setTimeout(() => {
|
|
1038
1878
|
void dispatchConversationBatch(key);
|
|
1039
|
-
},
|
|
1879
|
+
}, delayMs);
|
|
1040
1880
|
pendingBatches.set(key, {
|
|
1041
1881
|
items: [queued],
|
|
1042
1882
|
timer,
|
|
1883
|
+
typingController,
|
|
1884
|
+
lifecycleController,
|
|
1043
1885
|
});
|
|
1044
1886
|
return;
|
|
1045
1887
|
}
|
|
1046
1888
|
|
|
1889
|
+
existing.typingController.setPhase('received');
|
|
1890
|
+
existing.lifecycleController?.setPhase('queued');
|
|
1047
1891
|
clearTimeout(existing.timer);
|
|
1048
1892
|
existing.items.push(queued);
|
|
1893
|
+
const shouldFlushImmediately =
|
|
1894
|
+
!shouldDebounceMessage ||
|
|
1895
|
+
existing.items.length >= DEFAULT_DEBOUNCE_MAX_BUFFER;
|
|
1896
|
+
const baseDelayMs = shouldFlushImmediately ? 0 : behavior.debounceMs;
|
|
1897
|
+
const startupStaggerMs =
|
|
1898
|
+
startupConnectedAtMs > 0 &&
|
|
1899
|
+
Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
|
|
1900
|
+
!wasExplicitlyAddressed
|
|
1901
|
+
? randomIntInRange(
|
|
1902
|
+
STARTUP_STAGGER_MIN_DELAY_MS,
|
|
1903
|
+
STARTUP_STAGGER_MAX_DELAY_MS,
|
|
1904
|
+
)
|
|
1905
|
+
: 0;
|
|
1906
|
+
const delayMs = baseDelayMs + startupStaggerMs;
|
|
1049
1907
|
existing.timer = setTimeout(() => {
|
|
1050
1908
|
void dispatchConversationBatch(key);
|
|
1051
|
-
},
|
|
1909
|
+
}, delayMs);
|
|
1052
1910
|
};
|
|
1053
1911
|
|
|
1054
1912
|
const dropPendingMessage = async (messageId: string): Promise<void> => {
|
|
1055
1913
|
for (const [key, pending] of pendingBatches) {
|
|
1056
|
-
const index = pending.items.findIndex(
|
|
1914
|
+
const index = pending.items.findIndex(
|
|
1915
|
+
(item) => item.msg.id === messageId,
|
|
1916
|
+
);
|
|
1057
1917
|
if (index === -1) continue;
|
|
1058
1918
|
const [removed] = pending.items.splice(index, 1);
|
|
1059
|
-
await removed.
|
|
1919
|
+
await removed.clearAckReaction();
|
|
1060
1920
|
if (pending.items.length === 0) {
|
|
1061
1921
|
clearTimeout(pending.timer);
|
|
1922
|
+
pending.typingController.stop();
|
|
1923
|
+
await pending.lifecycleController?.clear();
|
|
1062
1924
|
pendingBatches.delete(key);
|
|
1063
1925
|
}
|
|
1064
1926
|
return;
|
|
@@ -1069,21 +1931,35 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1069
1931
|
messageId: string,
|
|
1070
1932
|
nextMsg: DiscordMessage,
|
|
1071
1933
|
nextContent: string,
|
|
1934
|
+
nextBehavior: ResolvedChannelBehavior,
|
|
1072
1935
|
): Promise<boolean> => {
|
|
1073
1936
|
for (const [key, pending] of pendingBatches) {
|
|
1074
|
-
const index = pending.items.findIndex(
|
|
1937
|
+
const index = pending.items.findIndex(
|
|
1938
|
+
(item) => item.msg.id === messageId,
|
|
1939
|
+
);
|
|
1075
1940
|
if (index === -1) continue;
|
|
1076
1941
|
|
|
1077
1942
|
if (!nextContent) {
|
|
1078
1943
|
const [removed] = pending.items.splice(index, 1);
|
|
1079
|
-
await removed.
|
|
1944
|
+
await removed.clearAckReaction();
|
|
1080
1945
|
} else {
|
|
1081
1946
|
pending.items[index].msg = nextMsg;
|
|
1082
1947
|
pending.items[index].content = nextContent;
|
|
1948
|
+
pending.items[index].behavior = nextBehavior;
|
|
1949
|
+
pending.items[index].wasExplicitlyAddressed =
|
|
1950
|
+
!nextMsg.guild ||
|
|
1951
|
+
hasPrefixInvocation(nextMsg.content || '') ||
|
|
1952
|
+
Boolean(client.user && nextMsg.mentions.has(client.user));
|
|
1953
|
+
pending.items[index].cooldownKey = buildConversationCooldownKey(
|
|
1954
|
+
nextMsg.channelId,
|
|
1955
|
+
nextMsg.author.id,
|
|
1956
|
+
);
|
|
1083
1957
|
}
|
|
1084
1958
|
|
|
1085
1959
|
if (pending.items.length === 0) {
|
|
1086
1960
|
clearTimeout(pending.timer);
|
|
1961
|
+
pending.typingController.stop();
|
|
1962
|
+
await pending.lifecycleController?.clear();
|
|
1087
1963
|
pendingBatches.delete(key);
|
|
1088
1964
|
}
|
|
1089
1965
|
return true;
|
|
@@ -1097,6 +1973,7 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1097
1973
|
const sessionId = getSessionId(msg);
|
|
1098
1974
|
const guildId = msg.guild?.id || null;
|
|
1099
1975
|
const channelId = msg.channelId;
|
|
1976
|
+
const behavior = resolveChannelBehavior(msg);
|
|
1100
1977
|
const content = cleanIncomingContent(msg.content);
|
|
1101
1978
|
observeMessageParticipants(msg, content);
|
|
1102
1979
|
const immediateMentionLookup = buildMentionLookup(
|
|
@@ -1106,11 +1983,23 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1106
1983
|
);
|
|
1107
1984
|
|
|
1108
1985
|
const reply: ReplyFn = async (text, files) => {
|
|
1109
|
-
await sendChunkedReply(
|
|
1986
|
+
await sendChunkedReply(
|
|
1987
|
+
msg,
|
|
1988
|
+
text,
|
|
1989
|
+
files,
|
|
1990
|
+
immediateMentionLookup,
|
|
1991
|
+
behavior.humanDelay,
|
|
1992
|
+
);
|
|
1110
1993
|
};
|
|
1111
1994
|
const commandReply: ReplyFn = async (text, files) => {
|
|
1112
1995
|
try {
|
|
1113
|
-
await sendChunkedDirectReply(
|
|
1996
|
+
await sendChunkedDirectReply(
|
|
1997
|
+
msg,
|
|
1998
|
+
text,
|
|
1999
|
+
files,
|
|
2000
|
+
immediateMentionLookup,
|
|
2001
|
+
behavior.humanDelay,
|
|
2002
|
+
);
|
|
1114
2003
|
} catch (error) {
|
|
1115
2004
|
logger.warn(
|
|
1116
2005
|
{ error, userId: msg.author.id, channelId: msg.channelId },
|
|
@@ -1120,50 +2009,73 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1120
2009
|
};
|
|
1121
2010
|
|
|
1122
2011
|
const parsed = parseCommand(msg.content);
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
const ignorePrefixCommand = prefixedToken === 'status';
|
|
2012
|
+
const hasPrefixedInvocation = hasPrefixInvocation(msg.content);
|
|
2013
|
+
const hasSlashInvocation = hasSlashCommandInvocation(msg.content);
|
|
2014
|
+
const hasCommandInvocation = hasPrefixedInvocation || hasSlashInvocation;
|
|
1127
2015
|
if (DISCORD_COMMANDS_ONLY) {
|
|
1128
|
-
if (!
|
|
1129
|
-
if (!isAuthorizedCommandUserId(msg.author.id)) {
|
|
2016
|
+
if (!hasCommandInvocation) return;
|
|
2017
|
+
if (msg.guild && !isAuthorizedCommandUserId(msg.author.id)) {
|
|
1130
2018
|
logger.debug(
|
|
1131
2019
|
{ userId: msg.author.id, channelId: msg.channelId },
|
|
1132
2020
|
'Ignoring unauthorized Discord command in commands-only mode',
|
|
1133
2021
|
);
|
|
1134
2022
|
return;
|
|
1135
2023
|
}
|
|
1136
|
-
if (ignorePrefixCommand) {
|
|
1137
|
-
return;
|
|
1138
|
-
}
|
|
1139
2024
|
if (!parsed.isCommand) {
|
|
1140
2025
|
if (!content) {
|
|
1141
2026
|
await commandReply(`How can I help? Try \`${DISCORD_PREFIX} help\`.`);
|
|
1142
2027
|
} else {
|
|
1143
|
-
await commandReply(
|
|
2028
|
+
await commandReply(
|
|
2029
|
+
`Unknown command. Try \`${DISCORD_PREFIX} help\`.`,
|
|
2030
|
+
);
|
|
1144
2031
|
}
|
|
1145
2032
|
return;
|
|
1146
2033
|
}
|
|
1147
|
-
await commandHandler(
|
|
2034
|
+
await commandHandler(
|
|
2035
|
+
sessionId,
|
|
2036
|
+
guildId,
|
|
2037
|
+
channelId,
|
|
2038
|
+
msg.author.id,
|
|
2039
|
+
msg.author.username,
|
|
2040
|
+
[parsed.command, ...parsed.args],
|
|
2041
|
+
commandReply,
|
|
2042
|
+
);
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
if (
|
|
2047
|
+
msg.guild &&
|
|
2048
|
+
hasCommandInvocation &&
|
|
2049
|
+
!isAuthorizedCommandUserId(msg.author.id)
|
|
2050
|
+
) {
|
|
2051
|
+
logger.debug(
|
|
2052
|
+
{ userId: msg.author.id, channelId: msg.channelId },
|
|
2053
|
+
'Ignoring unauthorized Discord prefixed command',
|
|
2054
|
+
);
|
|
1148
2055
|
return;
|
|
1149
2056
|
}
|
|
1150
2057
|
|
|
1151
|
-
if (!isTrigger(msg)) return;
|
|
2058
|
+
if (!isTrigger(msg, behavior)) return;
|
|
1152
2059
|
|
|
1153
|
-
if (
|
|
2060
|
+
if (parsed.isCommand && hasCommandInvocation) {
|
|
2061
|
+
await commandHandler(
|
|
2062
|
+
sessionId,
|
|
2063
|
+
guildId,
|
|
2064
|
+
channelId,
|
|
2065
|
+
msg.author.id,
|
|
2066
|
+
msg.author.username,
|
|
2067
|
+
[parsed.command, ...parsed.args],
|
|
2068
|
+
commandReply,
|
|
2069
|
+
);
|
|
1154
2070
|
return;
|
|
1155
2071
|
}
|
|
1156
2072
|
|
|
1157
|
-
if (
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
} else {
|
|
1164
|
-
await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
|
|
1165
|
-
return;
|
|
1166
|
-
}
|
|
2073
|
+
if (!shouldHandleFreeModeMessage(msg, behavior, content)) {
|
|
2074
|
+
logger.debug(
|
|
2075
|
+
{ channelId: msg.channelId, messageId: msg.id, userId: msg.author.id },
|
|
2076
|
+
'Skipping Discord free-mode message by relevance/mention gate',
|
|
2077
|
+
);
|
|
2078
|
+
return;
|
|
1167
2079
|
}
|
|
1168
2080
|
|
|
1169
2081
|
if (!content) {
|
|
@@ -1171,7 +2083,18 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1171
2083
|
return;
|
|
1172
2084
|
}
|
|
1173
2085
|
|
|
1174
|
-
await
|
|
2086
|
+
const readWithoutReplyHandled = await maybeHandleReadWithoutReply(
|
|
2087
|
+
msg,
|
|
2088
|
+
content,
|
|
2089
|
+
);
|
|
2090
|
+
if (readWithoutReplyHandled) {
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
const rateLimitAllowed = await enforcePerUserRateLimit(msg, behavior);
|
|
2095
|
+
if (!rateLimitAllowed) return;
|
|
2096
|
+
|
|
2097
|
+
await queueConversationMessage(msg, content, behavior);
|
|
1175
2098
|
});
|
|
1176
2099
|
|
|
1177
2100
|
client.on('messageUpdate', async (_oldMsg, nextMsg) => {
|
|
@@ -1183,14 +2106,30 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1183
2106
|
if (fetched.author?.bot) return;
|
|
1184
2107
|
|
|
1185
2108
|
const updatedContent = cleanIncomingContent(fetched.content || '');
|
|
2109
|
+
const behavior = resolveChannelBehavior(fetched);
|
|
1186
2110
|
observeMessageParticipants(fetched, updatedContent);
|
|
1187
|
-
if (!isTrigger(fetched))
|
|
1188
|
-
|
|
2111
|
+
if (!isTrigger(fetched, behavior)) {
|
|
2112
|
+
await updatePendingMessage(fetched.id, fetched, '', behavior);
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
if (
|
|
2116
|
+
hasPrefixInvocation(fetched.content || '') &&
|
|
2117
|
+
!isAuthorizedCommandUserId(fetched.author.id)
|
|
2118
|
+
) {
|
|
2119
|
+
await updatePendingMessage(fetched.id, fetched, '', behavior);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
if (!shouldHandleFreeModeMessage(fetched, behavior, updatedContent)) {
|
|
2123
|
+
await updatePendingMessage(fetched.id, fetched, '', behavior);
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
await updatePendingMessage(fetched.id, fetched, updatedContent, behavior);
|
|
1189
2127
|
|
|
1190
2128
|
const inFlight = inFlightByMessageId.get(fetched.id);
|
|
1191
2129
|
if (!inFlight || inFlight.aborted) return;
|
|
1192
2130
|
inFlight.aborted = true;
|
|
1193
2131
|
inFlight.abortController.abort();
|
|
2132
|
+
inFlight.emitLifecyclePhase('error');
|
|
1194
2133
|
for (const messageId of inFlight.messageIds) {
|
|
1195
2134
|
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
1196
2135
|
inFlightByMessageId.delete(messageId);
|
|
@@ -1198,7 +2137,9 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1198
2137
|
}
|
|
1199
2138
|
await inFlight.stream.discard();
|
|
1200
2139
|
if (updatedContent) {
|
|
1201
|
-
await
|
|
2140
|
+
const rateLimitAllowed = await enforcePerUserRateLimit(fetched, behavior);
|
|
2141
|
+
if (!rateLimitAllowed) return;
|
|
2142
|
+
await queueConversationMessage(fetched, updatedContent, behavior);
|
|
1202
2143
|
}
|
|
1203
2144
|
});
|
|
1204
2145
|
|
|
@@ -1208,6 +2149,7 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1208
2149
|
if (!inFlight || inFlight.aborted) return;
|
|
1209
2150
|
inFlight.aborted = true;
|
|
1210
2151
|
inFlight.abortController.abort();
|
|
2152
|
+
inFlight.emitLifecyclePhase('error');
|
|
1211
2153
|
for (const messageId of inFlight.messageIds) {
|
|
1212
2154
|
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
1213
2155
|
inFlightByMessageId.delete(messageId);
|
|
@@ -1243,16 +2185,30 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1243
2185
|
return client;
|
|
1244
2186
|
}
|
|
1245
2187
|
|
|
2188
|
+
export async function setDiscordMaintenancePresence(): Promise<void> {
|
|
2189
|
+
if (!presenceController) return;
|
|
2190
|
+
await presenceController.setMaintenance();
|
|
2191
|
+
}
|
|
2192
|
+
|
|
1246
2193
|
/**
|
|
1247
2194
|
* Send a message to a channel by ID (used by scheduler).
|
|
1248
2195
|
*/
|
|
1249
|
-
export async function sendToChannel(
|
|
2196
|
+
export async function sendToChannel(
|
|
2197
|
+
channelId: string,
|
|
2198
|
+
text: string,
|
|
2199
|
+
files?: AttachmentBuilder[],
|
|
2200
|
+
): Promise<void> {
|
|
1250
2201
|
const channel = await client.channels.fetch(channelId);
|
|
1251
2202
|
if (channel && 'send' in channel) {
|
|
1252
2203
|
const payloads = prepareChunkedPayloads(text, files);
|
|
1253
|
-
const send = (
|
|
1254
|
-
|
|
1255
|
-
|
|
2204
|
+
const send = (
|
|
2205
|
+
channel as unknown as {
|
|
2206
|
+
send: (payload: {
|
|
2207
|
+
content: string;
|
|
2208
|
+
files?: AttachmentBuilder[];
|
|
2209
|
+
}) => Promise<void>;
|
|
2210
|
+
}
|
|
2211
|
+
).send;
|
|
1256
2212
|
for (const payload of payloads) {
|
|
1257
2213
|
await withDiscordRetry('send-channel', () => send(payload));
|
|
1258
2214
|
}
|