discoclaw 0.1.0
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/.context/README.md +42 -0
- package/.context/architecture.md +58 -0
- package/.context/bot-setup.md +24 -0
- package/.context/dev.md +230 -0
- package/.context/discord.md +144 -0
- package/.context/memory.md +257 -0
- package/.context/ops.md +59 -0
- package/.context/pa-safety.md +47 -0
- package/.context/pa.md +118 -0
- package/.context/project.md +43 -0
- package/.context/runtime.md +253 -0
- package/.context/tasks.md +71 -0
- package/.context/tools.md +75 -0
- package/.env.example +88 -0
- package/.env.example.full +378 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/dist/beads/auto-tag.js +2 -0
- package/dist/beads/auto-tag.test.js +62 -0
- package/dist/beads/bd-cli.js +9 -0
- package/dist/beads/bd-cli.test.js +495 -0
- package/dist/beads/bead-hooks-cli.js +149 -0
- package/dist/beads/bead-sync-cli.js +5 -0
- package/dist/beads/bead-sync-cli.test.js +72 -0
- package/dist/beads/bead-sync-coordinator.js +4 -0
- package/dist/beads/bead-sync-coordinator.test.js +239 -0
- package/dist/beads/bead-sync-watcher.js +2 -0
- package/dist/beads/bead-sync-watcher.test.js +96 -0
- package/dist/beads/bead-sync.js +7 -0
- package/dist/beads/bead-sync.test.js +876 -0
- package/dist/beads/bead-thread-cache.js +8 -0
- package/dist/beads/bead-thread-cache.test.js +91 -0
- package/dist/beads/discord-sync.js +18 -0
- package/dist/beads/discord-sync.test.js +782 -0
- package/dist/beads/find-bead-by-thread.test.js +36 -0
- package/dist/beads/forum-guard.js +2 -0
- package/dist/beads/forum-guard.test.js +204 -0
- package/dist/beads/initialize.js +3 -0
- package/dist/beads/initialize.test.js +304 -0
- package/dist/beads/types.js +10 -0
- package/dist/cli/daemon-installer.js +225 -0
- package/dist/cli/daemon-installer.test.js +289 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/init-wizard.js +374 -0
- package/dist/cli/init-wizard.test.js +191 -0
- package/dist/config.js +385 -0
- package/dist/config.test.js +589 -0
- package/dist/cron/auto-tag.js +100 -0
- package/dist/cron/auto-tag.test.js +91 -0
- package/dist/cron/cadence.js +74 -0
- package/dist/cron/cadence.test.js +53 -0
- package/dist/cron/cron-sync-coordinator.js +66 -0
- package/dist/cron/cron-sync-coordinator.test.js +118 -0
- package/dist/cron/cron-sync.js +165 -0
- package/dist/cron/cron-sync.test.js +228 -0
- package/dist/cron/cron-tag-map-watcher.js +128 -0
- package/dist/cron/cron-tag-map-watcher.test.js +155 -0
- package/dist/cron/default-timezone.js +23 -0
- package/dist/cron/default-timezone.test.js +30 -0
- package/dist/cron/discord-sync.js +205 -0
- package/dist/cron/discord-sync.test.js +353 -0
- package/dist/cron/executor.js +303 -0
- package/dist/cron/executor.test.js +614 -0
- package/dist/cron/forum-sync.js +347 -0
- package/dist/cron/forum-sync.test.js +539 -0
- package/dist/cron/job-lock.js +164 -0
- package/dist/cron/job-lock.test.js +178 -0
- package/dist/cron/parser.js +68 -0
- package/dist/cron/parser.test.js +115 -0
- package/dist/cron/run-control.js +24 -0
- package/dist/cron/run-control.test.js +27 -0
- package/dist/cron/run-stats.js +265 -0
- package/dist/cron/run-stats.test.js +160 -0
- package/dist/cron/scheduler.js +97 -0
- package/dist/cron/scheduler.test.js +112 -0
- package/dist/cron/tag-map.js +47 -0
- package/dist/cron/tag-map.test.js +64 -0
- package/dist/cron/types.js +1 -0
- package/dist/discoclaw-plan-format.test.js +137 -0
- package/dist/discoclaw-recipe-format.test.js +137 -0
- package/dist/discord/abort-registry.js +70 -0
- package/dist/discord/action-categories.js +36 -0
- package/dist/discord/action-types.js +1 -0
- package/dist/discord/action-utils.js +58 -0
- package/dist/discord/action-utils.test.js +58 -0
- package/dist/discord/actions-beads.js +1 -0
- package/dist/discord/actions-beads.test.js +372 -0
- package/dist/discord/actions-bot-profile.js +107 -0
- package/dist/discord/actions-bot-profile.test.js +138 -0
- package/dist/discord/actions-channels.js +427 -0
- package/dist/discord/actions-channels.test.js +697 -0
- package/dist/discord/actions-config.js +173 -0
- package/dist/discord/actions-config.test.js +322 -0
- package/dist/discord/actions-crons.js +586 -0
- package/dist/discord/actions-crons.test.js +499 -0
- package/dist/discord/actions-defer.js +60 -0
- package/dist/discord/actions-defer.test.js +134 -0
- package/dist/discord/actions-forge.js +134 -0
- package/dist/discord/actions-forge.test.js +206 -0
- package/dist/discord/actions-guild.js +301 -0
- package/dist/discord/actions-guild.test.js +386 -0
- package/dist/discord/actions-memory.js +106 -0
- package/dist/discord/actions-memory.test.js +248 -0
- package/dist/discord/actions-messaging.js +401 -0
- package/dist/discord/actions-messaging.test.js +738 -0
- package/dist/discord/actions-moderation.js +65 -0
- package/dist/discord/actions-moderation.test.js +88 -0
- package/dist/discord/actions-plan.js +445 -0
- package/dist/discord/actions-plan.test.js +610 -0
- package/dist/discord/actions-poll.js +38 -0
- package/dist/discord/actions-poll.test.js +93 -0
- package/dist/discord/actions-tasks.js +3 -0
- package/dist/discord/actions-tasks.test.js +418 -0
- package/dist/discord/actions.js +600 -0
- package/dist/discord/actions.test.js +522 -0
- package/dist/discord/allowed-mentions.js +3 -0
- package/dist/discord/allowed-mentions.test.js +17 -0
- package/dist/discord/allowlist.js +29 -0
- package/dist/discord/allowlist.test.js +24 -0
- package/dist/discord/audit-handler.js +191 -0
- package/dist/discord/audit-handler.test.js +361 -0
- package/dist/discord/bot.js +141 -0
- package/dist/discord/channel-context.js +181 -0
- package/dist/discord/defer-scheduler.js +45 -0
- package/dist/discord/destructive-confirmation.js +128 -0
- package/dist/discord/destructive-confirmation.test.js +49 -0
- package/dist/discord/discord-plan-auto-implement.test.js +18 -0
- package/dist/discord/durable-memory.js +145 -0
- package/dist/discord/durable-memory.test.js +281 -0
- package/dist/discord/durable-write-queue.js +4 -0
- package/dist/discord/file-download.js +308 -0
- package/dist/discord/file-download.test.js +303 -0
- package/dist/discord/forge-audit-verdict.js +140 -0
- package/dist/discord/forge-auto-implement.js +80 -0
- package/dist/discord/forge-auto-implement.test.js +110 -0
- package/dist/discord/forge-commands.js +698 -0
- package/dist/discord/forge-commands.test.js +1606 -0
- package/dist/discord/forge-plan-registry.js +68 -0
- package/dist/discord/forge-plan-registry.test.js +127 -0
- package/dist/discord/forum-count-sync.js +130 -0
- package/dist/discord/forum-count-sync.test.js +200 -0
- package/dist/discord/health-command.js +98 -0
- package/dist/discord/health-command.test.js +195 -0
- package/dist/discord/help-command.js +22 -0
- package/dist/discord/help-command.test.js +49 -0
- package/dist/discord/image-download.js +201 -0
- package/dist/discord/image-download.test.js +499 -0
- package/dist/discord/inflight-replies.js +228 -0
- package/dist/discord/inflight-replies.test.js +295 -0
- package/dist/discord/json-extract.js +110 -0
- package/dist/discord/keyed-queue.js +22 -0
- package/dist/discord/memory-commands.js +85 -0
- package/dist/discord/memory-commands.test.js +159 -0
- package/dist/discord/memory-timing.integration.test.js +159 -0
- package/dist/discord/message-coordinator.js +2347 -0
- package/dist/discord/message-coordinator.onboarding.test.js +183 -0
- package/dist/discord/message-coordinator.plan-run.test.js +264 -0
- package/dist/discord/message-history.js +53 -0
- package/dist/discord/message-history.test.js +95 -0
- package/dist/discord/models-command.js +59 -0
- package/dist/discord/models-command.test.js +150 -0
- package/dist/discord/nickname.test.js +76 -0
- package/dist/discord/onboarding-completion.js +55 -0
- package/dist/discord/onboarding-completion.test.js +176 -0
- package/dist/discord/output-common.js +178 -0
- package/dist/discord/output-common.test.js +198 -0
- package/dist/discord/output-utils.js +156 -0
- package/dist/discord/parse-identity-name.test.js +129 -0
- package/dist/discord/plan-commands.js +612 -0
- package/dist/discord/plan-commands.test.js +1622 -0
- package/dist/discord/plan-manager.js +1491 -0
- package/dist/discord/plan-manager.test.js +2380 -0
- package/dist/discord/plan-parser.js +110 -0
- package/dist/discord/plan-parser.test.js +63 -0
- package/dist/discord/plan-run-phase-start.js +20 -0
- package/dist/discord/plan-run-phase-start.test.js +29 -0
- package/dist/discord/platform-message.js +45 -0
- package/dist/discord/platform-message.test.js +110 -0
- package/dist/discord/prompt-common.js +240 -0
- package/dist/discord/prompt-common.test.js +423 -0
- package/dist/discord/reaction-handler.js +691 -0
- package/dist/discord/reaction-handler.test.js +1574 -0
- package/dist/discord/reaction-prompts.js +118 -0
- package/dist/discord/reaction-prompts.test.js +253 -0
- package/dist/discord/reply-reference.js +66 -0
- package/dist/discord/reply-reference.test.js +125 -0
- package/dist/discord/restart-command.js +143 -0
- package/dist/discord/restart-command.test.js +196 -0
- package/dist/discord/runtime-utils.js +43 -0
- package/dist/discord/runtime-utils.test.js +112 -0
- package/dist/discord/session-key.js +7 -0
- package/dist/discord/session-key.test.js +13 -0
- package/dist/discord/shortterm-memory.js +166 -0
- package/dist/discord/shortterm-memory.test.js +345 -0
- package/dist/discord/shutdown-context.js +122 -0
- package/dist/discord/shutdown-context.test.js +279 -0
- package/dist/discord/startup-profile.test.js +214 -0
- package/dist/discord/status-channel.js +190 -0
- package/dist/discord/status-channel.test.js +282 -0
- package/dist/discord/status-command.js +206 -0
- package/dist/discord/status-command.test.js +341 -0
- package/dist/discord/streaming-progress.js +107 -0
- package/dist/discord/streaming-progress.test.js +93 -0
- package/dist/discord/summarizer.js +89 -0
- package/dist/discord/summarizer.test.js +245 -0
- package/dist/discord/system-bootstrap.js +396 -0
- package/dist/discord/system-bootstrap.test.js +724 -0
- package/dist/discord/thread-context.js +169 -0
- package/dist/discord/thread-context.test.js +386 -0
- package/dist/discord/tool-aware-queue.js +116 -0
- package/dist/discord/tool-aware-queue.test.js +180 -0
- package/dist/discord/update-command.js +127 -0
- package/dist/discord/update-command.test.js +275 -0
- package/dist/discord/user-errors.js +40 -0
- package/dist/discord/user-errors.test.js +31 -0
- package/dist/discord/user-turn-to-durable.js +111 -0
- package/dist/discord/user-turn-to-durable.test.js +273 -0
- package/dist/discord-followup.test.js +677 -0
- package/dist/discord.channel-context.test.js +95 -0
- package/dist/discord.fail-closed.test.js +199 -0
- package/dist/discord.health-command.integration.test.js +140 -0
- package/dist/discord.js +190 -0
- package/dist/discord.prompt-context.test.js +1431 -0
- package/dist/discord.render.test.js +621 -0
- package/dist/discord.status-wiring.test.js +187 -0
- package/dist/engine/claudeCli.js +137 -0
- package/dist/engine/types.js +1 -0
- package/dist/group-queue.js +25 -0
- package/dist/health/credential-check.js +175 -0
- package/dist/health/credential-check.test.js +401 -0
- package/dist/health/startup-healing.js +139 -0
- package/dist/health/startup-healing.test.js +298 -0
- package/dist/identity.js +36 -0
- package/dist/index.js +1378 -0
- package/dist/logging/logger-like.js +1 -0
- package/dist/observability/memory-sampler.js +51 -0
- package/dist/observability/memory-sampler.test.js +93 -0
- package/dist/observability/metrics.js +88 -0
- package/dist/observability/metrics.test.js +42 -0
- package/dist/onboarding/onboarding-flow.js +246 -0
- package/dist/onboarding/onboarding-flow.test.js +238 -0
- package/dist/onboarding/onboarding-writer.js +102 -0
- package/dist/onboarding/onboarding-writer.test.js +143 -0
- package/dist/pidlock.js +187 -0
- package/dist/pidlock.test.js +128 -0
- package/dist/pipeline/engine.js +206 -0
- package/dist/pipeline/engine.test.js +771 -0
- package/dist/root-policy.js +21 -0
- package/dist/root-policy.test.js +55 -0
- package/dist/runtime/claude-code-cli.js +35 -0
- package/dist/runtime/claude-code-cli.test.js +1199 -0
- package/dist/runtime/cli-adapter.js +584 -0
- package/dist/runtime/cli-output-parsers.js +108 -0
- package/dist/runtime/cli-shared.js +96 -0
- package/dist/runtime/cli-shared.test.js +104 -0
- package/dist/runtime/cli-strategy.js +6 -0
- package/dist/runtime/codex-cli.js +16 -0
- package/dist/runtime/codex-cli.test.js +862 -0
- package/dist/runtime/concurrency-limit.js +80 -0
- package/dist/runtime/concurrency-limit.test.js +137 -0
- package/dist/runtime/gemini-cli.js +16 -0
- package/dist/runtime/gemini-cli.test.js +413 -0
- package/dist/runtime/long-running-process.js +415 -0
- package/dist/runtime/long-running-process.test.js +318 -0
- package/dist/runtime/model-smoke-helpers.js +160 -0
- package/dist/runtime/model-smoke.test.js +194 -0
- package/dist/runtime/model-tiers.js +33 -0
- package/dist/runtime/model-tiers.test.js +65 -0
- package/dist/runtime/openai-auth.js +151 -0
- package/dist/runtime/openai-auth.test.js +361 -0
- package/dist/runtime/openai-compat.js +178 -0
- package/dist/runtime/openai-compat.test.js +449 -0
- package/dist/runtime/process-pool.js +93 -0
- package/dist/runtime/process-pool.test.js +148 -0
- package/dist/runtime/registry.js +15 -0
- package/dist/runtime/registry.test.js +47 -0
- package/dist/runtime/session-scanner.js +186 -0
- package/dist/runtime/session-scanner.test.js +257 -0
- package/dist/runtime/strategies/claude-strategy.js +193 -0
- package/dist/runtime/strategies/codex-strategy.js +161 -0
- package/dist/runtime/strategies/gemini-strategy.js +64 -0
- package/dist/runtime/strategies/template-strategy.js +85 -0
- package/dist/runtime/tool-capabilities.js +27 -0
- package/dist/runtime/tool-capabilities.test.js +24 -0
- package/dist/runtime/tool-labels.js +48 -0
- package/dist/runtime/types.js +2 -0
- package/dist/sessionManager.js +47 -0
- package/dist/sessions.js +18 -0
- package/dist/tasks/architecture-contract.js +33 -0
- package/dist/tasks/architecture-contract.test.js +90 -0
- package/dist/tasks/auto-tag.js +50 -0
- package/dist/tasks/auto-tag.test.js +64 -0
- package/dist/tasks/bd-cli.js +164 -0
- package/dist/tasks/bd-cli.test.js +359 -0
- package/dist/tasks/bead-sync.js +1 -0
- package/dist/tasks/context-summary.js +27 -0
- package/dist/tasks/discord-sync.js +3 -0
- package/dist/tasks/discord-sync.test.js +685 -0
- package/dist/tasks/discord-types.js +4 -0
- package/dist/tasks/find-task-by-thread.test.js +36 -0
- package/dist/tasks/forum-guard.js +81 -0
- package/dist/tasks/forum-guard.test.js +192 -0
- package/dist/tasks/initialize.js +77 -0
- package/dist/tasks/initialize.test.js +263 -0
- package/dist/tasks/logger-types.js +1 -0
- package/dist/tasks/metrics-types.js +3 -0
- package/dist/tasks/migrate.js +33 -0
- package/dist/tasks/migrate.test.js +156 -0
- package/dist/tasks/path-defaults.js +67 -0
- package/dist/tasks/path-defaults.test.js +73 -0
- package/dist/tasks/runtime-types.js +1 -0
- package/dist/tasks/service.js +33 -0
- package/dist/tasks/service.test.js +51 -0
- package/dist/tasks/store.js +238 -0
- package/dist/tasks/store.test.js +417 -0
- package/dist/tasks/sync-context.js +1 -0
- package/dist/tasks/sync-contract.js +24 -0
- package/dist/tasks/sync-contract.test.js +25 -0
- package/dist/tasks/sync-coordinator-metrics.js +41 -0
- package/dist/tasks/sync-coordinator-retries.js +71 -0
- package/dist/tasks/sync-coordinator.js +96 -0
- package/dist/tasks/sync-coordinator.test.js +501 -0
- package/dist/tasks/sync-types.js +1 -0
- package/dist/tasks/sync-watcher.js +27 -0
- package/dist/tasks/sync-watcher.test.js +92 -0
- package/dist/tasks/tag-map.js +36 -0
- package/dist/tasks/tag-map.test.js +54 -0
- package/dist/tasks/task-action-contract.js +16 -0
- package/dist/tasks/task-action-contract.test.js +16 -0
- package/dist/tasks/task-action-executor.js +18 -0
- package/dist/tasks/task-action-executor.test.js +420 -0
- package/dist/tasks/task-action-mutation-helpers.js +17 -0
- package/dist/tasks/task-action-mutations.js +151 -0
- package/dist/tasks/task-action-prompt.js +62 -0
- package/dist/tasks/task-action-read-ops.js +73 -0
- package/dist/tasks/task-action-runner-types.js +1 -0
- package/dist/tasks/task-action-thread-sync.js +82 -0
- package/dist/tasks/task-actions.js +3 -0
- package/dist/tasks/task-cli.js +227 -0
- package/dist/tasks/task-context.js +1 -0
- package/dist/tasks/task-lifecycle.js +46 -0
- package/dist/tasks/task-lifecycle.test.js +35 -0
- package/dist/tasks/task-sync-apply-plan.js +95 -0
- package/dist/tasks/task-sync-apply-types.js +12 -0
- package/dist/tasks/task-sync-apply.js +319 -0
- package/dist/tasks/task-sync-cli.js +89 -0
- package/dist/tasks/task-sync-cli.test.js +70 -0
- package/dist/tasks/task-sync-engine.js +88 -0
- package/dist/tasks/task-sync-engine.test.js +934 -0
- package/dist/tasks/task-sync-phase-apply.js +171 -0
- package/dist/tasks/task-sync-pipeline.js +2 -0
- package/dist/tasks/task-sync-pipeline.test.js +265 -0
- package/dist/tasks/task-sync-reconcile-plan.js +182 -0
- package/dist/tasks/task-sync-reconcile.js +144 -0
- package/dist/tasks/task-sync.js +56 -0
- package/dist/tasks/task-sync.test.js +86 -0
- package/dist/tasks/thread-cache.js +42 -0
- package/dist/tasks/thread-cache.test.js +89 -0
- package/dist/tasks/thread-contracts.test.js +711 -0
- package/dist/tasks/thread-forum-ops.js +68 -0
- package/dist/tasks/thread-helpers.js +86 -0
- package/dist/tasks/thread-helpers.test.js +33 -0
- package/dist/tasks/thread-lifecycle-ops.js +144 -0
- package/dist/tasks/thread-ops-shared.js +21 -0
- package/dist/tasks/thread-ops.js +2 -0
- package/dist/tasks/types.js +20 -0
- package/dist/tasks/types.test.js +60 -0
- package/dist/test-setup.js +11 -0
- package/dist/test-setup.test.js +42 -0
- package/dist/transport/types.js +1 -0
- package/dist/validate.js +41 -0
- package/dist/validate.test.js +94 -0
- package/dist/version.js +15 -0
- package/dist/version.test.js +31 -0
- package/dist/webhook/server.js +199 -0
- package/dist/webhook/server.test.js +460 -0
- package/dist/workspace-bootstrap.js +135 -0
- package/dist/workspace-bootstrap.test.js +514 -0
- package/dist/workspace-permissions.js +134 -0
- package/dist/workspace-permissions.test.js +181 -0
- package/package.json +74 -0
- package/scripts/cron/cron-tag-map.json +9 -0
- package/scripts/tasks/tag-map.json +10 -0
- package/systemd/discoclaw.service +19 -0
- package/templates/recipes/integration.discoclaw-recipe.md +171 -0
- package/templates/workspace/AGENTS.md +217 -0
- package/templates/workspace/BOOTSTRAP.md +1 -0
- package/templates/workspace/HEARTBEAT.md +10 -0
- package/templates/workspace/IDENTITY.md +16 -0
- package/templates/workspace/MEMORY.md +24 -0
- package/templates/workspace/SOUL.md +52 -0
- package/templates/workspace/TOOLS.md +304 -0
- package/templates/workspace/USER.md +37 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { NO_MENTIONS } from './allowed-mentions.js';
|
|
2
|
+
export const REACTION_PROMPT_ACTION_TYPES = new Set(['reactionPrompt']);
|
|
3
|
+
/**
|
|
4
|
+
* In-memory store for pending reaction prompts.
|
|
5
|
+
* Keyed by the bot's prompt message ID so the reaction handler can look up
|
|
6
|
+
* and match the correct record.
|
|
7
|
+
*/
|
|
8
|
+
const pendingPrompts = new Map();
|
|
9
|
+
/**
|
|
10
|
+
* Register a pending prompt in the store.
|
|
11
|
+
*/
|
|
12
|
+
function registerPrompt(messageId, question, choices) {
|
|
13
|
+
pendingPrompts.set(messageId, { question, choices: new Set(choices) });
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Called by the reaction handler when a user reacts to any message.
|
|
17
|
+
* If the message ID matches a pending prompt and the emoji is a valid choice,
|
|
18
|
+
* returns the matched record data (and deletes it from the map).
|
|
19
|
+
*
|
|
20
|
+
* Returns { question, chosenEmoji } if matched, null otherwise.
|
|
21
|
+
* The reaction handler should continue into its normal AI invocation flow
|
|
22
|
+
* with a prompt that conveys the user's choice.
|
|
23
|
+
*/
|
|
24
|
+
export function tryResolveReactionPrompt(messageId, emoji) {
|
|
25
|
+
const pending = pendingPrompts.get(messageId);
|
|
26
|
+
if (!pending)
|
|
27
|
+
return null;
|
|
28
|
+
if (!pending.choices.has(emoji))
|
|
29
|
+
return null;
|
|
30
|
+
pendingPrompts.delete(messageId);
|
|
31
|
+
return { question: pending.question, chosenEmoji: emoji };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Returns the number of currently pending prompts (useful for tests).
|
|
35
|
+
*/
|
|
36
|
+
export function pendingPromptCount() {
|
|
37
|
+
return pendingPrompts.size;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Clear all pending prompts — for use in tests only.
|
|
41
|
+
*/
|
|
42
|
+
export function _resetForTest() {
|
|
43
|
+
pendingPrompts.clear();
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Executor
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
const MIN_CHOICES = 2;
|
|
49
|
+
const MAX_CHOICES = 9;
|
|
50
|
+
export async function executeReactionPromptAction(action, ctx) {
|
|
51
|
+
// Validate question.
|
|
52
|
+
if (typeof action.question !== 'string' || !action.question.trim()) {
|
|
53
|
+
return { ok: false, error: 'reactionPrompt requires a non-empty question string' };
|
|
54
|
+
}
|
|
55
|
+
// Validate choices.
|
|
56
|
+
if (!Array.isArray(action.choices) || action.choices.length < MIN_CHOICES || action.choices.length > MAX_CHOICES) {
|
|
57
|
+
return { ok: false, error: `reactionPrompt requires ${MIN_CHOICES}–${MAX_CHOICES} choices` };
|
|
58
|
+
}
|
|
59
|
+
for (const c of action.choices) {
|
|
60
|
+
if (typeof c !== 'string' || !c.trim()) {
|
|
61
|
+
return { ok: false, error: 'reactionPrompt: each choice must be a non-empty emoji string' };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Resolve the channel.
|
|
65
|
+
const { guild, channelId } = ctx;
|
|
66
|
+
const channel = guild.channels.cache.get(channelId);
|
|
67
|
+
if (!channel || !('send' in channel)) {
|
|
68
|
+
return { ok: false, error: `reactionPrompt: channel "${channelId}" not found or not a text channel` };
|
|
69
|
+
}
|
|
70
|
+
// Send the prompt message (question text only — no emoji in the body).
|
|
71
|
+
let promptMessage;
|
|
72
|
+
try {
|
|
73
|
+
promptMessage = await channel.send({
|
|
74
|
+
content: action.question,
|
|
75
|
+
allowedMentions: NO_MENTIONS,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
80
|
+
return { ok: false, error: `reactionPrompt: failed to send prompt message: ${msg}` };
|
|
81
|
+
}
|
|
82
|
+
// Register the prompt *before* adding reactions so the reaction handler
|
|
83
|
+
// can match it as soon as the first reaction arrives.
|
|
84
|
+
registerPrompt(promptMessage.id, action.question, action.choices);
|
|
85
|
+
// Add reactions for each choice.
|
|
86
|
+
for (const emoji of action.choices) {
|
|
87
|
+
try {
|
|
88
|
+
await promptMessage.react(emoji);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
// Clean up the pending record so it doesn't leak on react failure.
|
|
92
|
+
pendingPrompts.delete(promptMessage.id);
|
|
93
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
94
|
+
return { ok: false, error: `reactionPrompt: failed to add reaction "${emoji}": ${msg}` };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, summary: 'Prompt sent — awaiting user reaction' };
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Prompt section (injected into the AI's system prompt)
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
export function reactionPromptSection() {
|
|
103
|
+
return `### Reaction Prompts
|
|
104
|
+
|
|
105
|
+
**reactionPrompt** — Present a yes/no or multiple-choice question to the user via emoji reactions instead of requiring a typed reply. The bot sends a dedicated message with the question text, adds each choice as a reaction, and returns immediately. When the user reacts, their choice triggers a follow-up AI invocation automatically with a prompt that conveys the user's decision.
|
|
106
|
+
|
|
107
|
+
\`\`\`
|
|
108
|
+
<discord-action>{"type":"reactionPrompt","question":"Should I proceed?","choices":["✅","❌"]}</discord-action>
|
|
109
|
+
\`\`\`
|
|
110
|
+
|
|
111
|
+
- \`question\` (required): The question text displayed to the user.
|
|
112
|
+
- \`choices\` (required): 2–9 emoji strings. Each will be added as a reaction to the prompt message.
|
|
113
|
+
- \`timeoutSeconds\` (optional): Accepted for compatibility but not used — the prompt waits indefinitely and the user's reaction triggers a follow-up automatically.
|
|
114
|
+
|
|
115
|
+
The action returns immediately with a confirmation that the prompt was sent. When the user reacts with a valid choice, a follow-up invocation is triggered automatically so you can act on the decision.
|
|
116
|
+
|
|
117
|
+
Use this for binary confirmations (✅/❌) or short option lists — not for open-ended text input.`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
2
|
+
import { executeReactionPromptAction, tryResolveReactionPrompt, pendingPromptCount, reactionPromptSection, _resetForTest, } from './reaction-prompts.js';
|
|
3
|
+
import { QUERY_ACTION_TYPES } from './action-categories.js';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
function makeCtx(overrides = {}) {
|
|
8
|
+
const reactFn = vi.fn().mockResolvedValue(undefined);
|
|
9
|
+
const sendFn = vi.fn().mockResolvedValue({ id: 'prompt-msg-1', react: reactFn });
|
|
10
|
+
return {
|
|
11
|
+
guild: {
|
|
12
|
+
channels: {
|
|
13
|
+
cache: {
|
|
14
|
+
get: vi.fn().mockReturnValue({ send: sendFn }),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
client: {},
|
|
19
|
+
channelId: 'ch-1',
|
|
20
|
+
messageId: 'msg-1',
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function makeAction(overrides = {}) {
|
|
25
|
+
return {
|
|
26
|
+
type: 'reactionPrompt',
|
|
27
|
+
question: 'Should I proceed?',
|
|
28
|
+
choices: ['✅', '❌'],
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
_resetForTest();
|
|
34
|
+
});
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Action executor — validation
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
describe('executeReactionPromptAction — validation', () => {
|
|
39
|
+
it('rejects empty question', async () => {
|
|
40
|
+
const ctx = makeCtx();
|
|
41
|
+
const result = await executeReactionPromptAction(makeAction({ question: '' }), ctx);
|
|
42
|
+
expect(result).toEqual({ ok: false, error: 'reactionPrompt requires a non-empty question string' });
|
|
43
|
+
});
|
|
44
|
+
it('rejects whitespace-only question', async () => {
|
|
45
|
+
const ctx = makeCtx();
|
|
46
|
+
const result = await executeReactionPromptAction(makeAction({ question: ' ' }), ctx);
|
|
47
|
+
expect(result).toEqual({ ok: false, error: 'reactionPrompt requires a non-empty question string' });
|
|
48
|
+
});
|
|
49
|
+
it('rejects fewer than 2 choices', async () => {
|
|
50
|
+
const ctx = makeCtx();
|
|
51
|
+
const result = await executeReactionPromptAction(makeAction({ choices: ['✅'] }), ctx);
|
|
52
|
+
expect(result.ok).toBe(false);
|
|
53
|
+
expect(result.error).toContain('2–9 choices');
|
|
54
|
+
});
|
|
55
|
+
it('rejects more than 9 choices', async () => {
|
|
56
|
+
const ctx = makeCtx();
|
|
57
|
+
const result = await executeReactionPromptAction(makeAction({ choices: ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟'] }), ctx);
|
|
58
|
+
expect(result.ok).toBe(false);
|
|
59
|
+
expect(result.error).toContain('2–9 choices');
|
|
60
|
+
});
|
|
61
|
+
it('rejects empty string in choices array', async () => {
|
|
62
|
+
const ctx = makeCtx();
|
|
63
|
+
const result = await executeReactionPromptAction(makeAction({ choices: ['✅', ''] }), ctx);
|
|
64
|
+
expect(result.ok).toBe(false);
|
|
65
|
+
expect(result.error).toContain('non-empty emoji string');
|
|
66
|
+
});
|
|
67
|
+
it('fails when channel not found', async () => {
|
|
68
|
+
const ctx = makeCtx();
|
|
69
|
+
ctx.guild.channels.cache.get.mockReturnValue(undefined);
|
|
70
|
+
const result = await executeReactionPromptAction(makeAction(), ctx);
|
|
71
|
+
expect(result.ok).toBe(false);
|
|
72
|
+
expect(result.error).toContain('not found or not a text channel');
|
|
73
|
+
});
|
|
74
|
+
it('fails when channel has no send method', async () => {
|
|
75
|
+
const ctx = makeCtx();
|
|
76
|
+
ctx.guild.channels.cache.get.mockReturnValue({ id: 'ch-1' }); // no send
|
|
77
|
+
const result = await executeReactionPromptAction(makeAction(), ctx);
|
|
78
|
+
expect(result.ok).toBe(false);
|
|
79
|
+
expect(result.error).toContain('not found or not a text channel');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Action executor — happy path
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
describe('executeReactionPromptAction — happy path', () => {
|
|
86
|
+
it('sends prompt message with question text only and adds reactions', async () => {
|
|
87
|
+
const reactFn = vi.fn().mockResolvedValue(undefined);
|
|
88
|
+
const promptMsg = { id: 'prompt-1', react: reactFn };
|
|
89
|
+
const sendFn = vi.fn().mockResolvedValue(promptMsg);
|
|
90
|
+
const ctx = makeCtx();
|
|
91
|
+
ctx.guild.channels.cache.get.mockReturnValue({ send: sendFn });
|
|
92
|
+
const result = await executeReactionPromptAction(makeAction({ choices: ['✅', '❌'] }), ctx);
|
|
93
|
+
expect(result).toEqual({ ok: true, summary: 'Prompt sent — awaiting user reaction' });
|
|
94
|
+
expect(sendFn).toHaveBeenCalledOnce();
|
|
95
|
+
const sentContent = sendFn.mock.calls[0][0].content;
|
|
96
|
+
expect(sentContent).toBe('Should I proceed?');
|
|
97
|
+
expect(reactFn).toHaveBeenCalledWith('✅');
|
|
98
|
+
expect(reactFn).toHaveBeenCalledWith('❌');
|
|
99
|
+
});
|
|
100
|
+
it('registers prompt data accessible via tryResolveReactionPrompt', async () => {
|
|
101
|
+
const reactFn = vi.fn().mockResolvedValue(undefined);
|
|
102
|
+
const promptMsg = { id: 'prompt-2', react: reactFn };
|
|
103
|
+
const sendFn = vi.fn().mockResolvedValue(promptMsg);
|
|
104
|
+
const ctx = makeCtx();
|
|
105
|
+
ctx.guild.channels.cache.get.mockReturnValue({ send: sendFn });
|
|
106
|
+
await executeReactionPromptAction(makeAction({ choices: ['🔴', '🟡', '🟢'] }), ctx);
|
|
107
|
+
const resolved = tryResolveReactionPrompt('prompt-2', '🟢');
|
|
108
|
+
expect(resolved).toEqual({ question: 'Should I proceed?', chosenEmoji: '🟢' });
|
|
109
|
+
});
|
|
110
|
+
it('accepts any valid emoji choice', async () => {
|
|
111
|
+
const reactFn = vi.fn().mockResolvedValue(undefined);
|
|
112
|
+
const promptMsg = { id: 'prompt-3', react: reactFn };
|
|
113
|
+
const sendFn = vi.fn().mockResolvedValue(promptMsg);
|
|
114
|
+
const ctx = makeCtx();
|
|
115
|
+
ctx.guild.channels.cache.get.mockReturnValue({ send: sendFn });
|
|
116
|
+
const result = await executeReactionPromptAction(makeAction({ choices: ['👍', '👎'] }), ctx);
|
|
117
|
+
expect(result).toEqual({ ok: true, summary: 'Prompt sent — awaiting user reaction' });
|
|
118
|
+
const resolved = tryResolveReactionPrompt('prompt-3', '👎');
|
|
119
|
+
expect(resolved).toEqual({ question: 'Should I proceed?', chosenEmoji: '👎' });
|
|
120
|
+
});
|
|
121
|
+
it('registry is populated before react() calls — ordering invariant', async () => {
|
|
122
|
+
let countDuringReact = -1;
|
|
123
|
+
const reactFn = vi.fn().mockImplementation(async () => {
|
|
124
|
+
// Capture pendingPromptCount at the moment react() is first called.
|
|
125
|
+
if (countDuringReact === -1) {
|
|
126
|
+
countDuringReact = pendingPromptCount();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
const promptMsg = { id: 'prompt-order', react: reactFn };
|
|
130
|
+
const sendFn = vi.fn().mockResolvedValue(promptMsg);
|
|
131
|
+
const ctx = makeCtx();
|
|
132
|
+
ctx.guild.channels.cache.get.mockReturnValue({ send: sendFn });
|
|
133
|
+
await executeReactionPromptAction(makeAction({ choices: ['✅', '❌'] }), ctx);
|
|
134
|
+
// Registration must have occurred before the first react() call.
|
|
135
|
+
expect(countDuringReact).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
it('accepts timeoutSeconds without error (field is ignored)', async () => {
|
|
138
|
+
const reactFn = vi.fn().mockResolvedValue(undefined);
|
|
139
|
+
const promptMsg = { id: 'prompt-clamp', react: reactFn };
|
|
140
|
+
const sendFn = vi.fn().mockResolvedValue(promptMsg);
|
|
141
|
+
const ctx = makeCtx();
|
|
142
|
+
ctx.guild.channels.cache.get.mockReturnValue({ send: sendFn });
|
|
143
|
+
const result = await executeReactionPromptAction(makeAction({ timeoutSeconds: 9999 }), ctx);
|
|
144
|
+
expect(result).toEqual({ ok: true, summary: 'Prompt sent — awaiting user reaction' });
|
|
145
|
+
});
|
|
146
|
+
it('removes prompt from store after resolution via tryResolveReactionPrompt', async () => {
|
|
147
|
+
const reactFn = vi.fn().mockResolvedValue(undefined);
|
|
148
|
+
const promptMsg = { id: 'prompt-cleanup', react: reactFn };
|
|
149
|
+
const sendFn = vi.fn().mockResolvedValue(promptMsg);
|
|
150
|
+
const ctx = makeCtx();
|
|
151
|
+
ctx.guild.channels.cache.get.mockReturnValue({ send: sendFn });
|
|
152
|
+
const result = await executeReactionPromptAction(makeAction(), ctx);
|
|
153
|
+
expect(result.ok).toBe(true);
|
|
154
|
+
expect(pendingPromptCount()).toBe(1);
|
|
155
|
+
tryResolveReactionPrompt('prompt-cleanup', '✅');
|
|
156
|
+
expect(pendingPromptCount()).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Action executor — error paths
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
describe('executeReactionPromptAction — error paths', () => {
|
|
163
|
+
it('returns error when send throws', async () => {
|
|
164
|
+
const ctx = makeCtx();
|
|
165
|
+
ctx.guild.channels.cache.get.mockReturnValue({
|
|
166
|
+
send: vi.fn().mockRejectedValue(new Error('Missing Permissions')),
|
|
167
|
+
});
|
|
168
|
+
const result = await executeReactionPromptAction(makeAction(), ctx);
|
|
169
|
+
expect(result.ok).toBe(false);
|
|
170
|
+
expect(result.error).toContain('Missing Permissions');
|
|
171
|
+
});
|
|
172
|
+
it('returns error when react throws and cleans up pending prompt', async () => {
|
|
173
|
+
const reactFn = vi.fn().mockRejectedValue(new Error('Unknown Emoji'));
|
|
174
|
+
const promptMsg = { id: 'prompt-react-err', react: reactFn };
|
|
175
|
+
const sendFn = vi.fn().mockResolvedValue(promptMsg);
|
|
176
|
+
const ctx = makeCtx();
|
|
177
|
+
ctx.guild.channels.cache.get.mockReturnValue({ send: sendFn });
|
|
178
|
+
const result = await executeReactionPromptAction(makeAction(), ctx);
|
|
179
|
+
expect(result.ok).toBe(false);
|
|
180
|
+
expect(result.error).toContain('Unknown Emoji');
|
|
181
|
+
expect(pendingPromptCount()).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// tryResolveReactionPrompt
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
describe('tryResolveReactionPrompt', () => {
|
|
188
|
+
it('returns null for unknown message ID', () => {
|
|
189
|
+
expect(tryResolveReactionPrompt('nonexistent', '✅')).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
it('returns null when emoji is not a valid choice', async () => {
|
|
192
|
+
const reactFn = vi.fn().mockResolvedValue(undefined);
|
|
193
|
+
const promptMsg = { id: 'prompt-invalid-emoji', react: reactFn };
|
|
194
|
+
const sendFn = vi.fn().mockResolvedValue(promptMsg);
|
|
195
|
+
const ctx = makeCtx();
|
|
196
|
+
ctx.guild.channels.cache.get.mockReturnValue({ send: sendFn });
|
|
197
|
+
await executeReactionPromptAction(makeAction({ choices: ['✅', '❌'] }), ctx);
|
|
198
|
+
expect(tryResolveReactionPrompt('prompt-invalid-emoji', '🔥')).toBeNull();
|
|
199
|
+
expect(pendingPromptCount()).toBe(1);
|
|
200
|
+
const resolved = tryResolveReactionPrompt('prompt-invalid-emoji', '✅');
|
|
201
|
+
expect(resolved).toEqual({ question: 'Should I proceed?', chosenEmoji: '✅' });
|
|
202
|
+
});
|
|
203
|
+
it('returns matched data when emoji matches', async () => {
|
|
204
|
+
const reactFn = vi.fn().mockResolvedValue(undefined);
|
|
205
|
+
const promptMsg = { id: 'prompt-match', react: reactFn };
|
|
206
|
+
const sendFn = vi.fn().mockResolvedValue(promptMsg);
|
|
207
|
+
const ctx = makeCtx();
|
|
208
|
+
ctx.guild.channels.cache.get.mockReturnValue({ send: sendFn });
|
|
209
|
+
await executeReactionPromptAction(makeAction(), ctx);
|
|
210
|
+
const result = tryResolveReactionPrompt('prompt-match', '✅');
|
|
211
|
+
expect(result).toEqual({ question: 'Should I proceed?', chosenEmoji: '✅' });
|
|
212
|
+
});
|
|
213
|
+
it('only resolves once — second call returns null', async () => {
|
|
214
|
+
const reactFn = vi.fn().mockResolvedValue(undefined);
|
|
215
|
+
const promptMsg = { id: 'prompt-once', react: reactFn };
|
|
216
|
+
const sendFn = vi.fn().mockResolvedValue(promptMsg);
|
|
217
|
+
const ctx = makeCtx();
|
|
218
|
+
ctx.guild.channels.cache.get.mockReturnValue({ send: sendFn });
|
|
219
|
+
await executeReactionPromptAction(makeAction(), ctx);
|
|
220
|
+
expect(tryResolveReactionPrompt('prompt-once', '✅')).not.toBeNull();
|
|
221
|
+
expect(tryResolveReactionPrompt('prompt-once', '✅')).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// reactionPromptSection
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
describe('reactionPromptSection', () => {
|
|
228
|
+
it('contains the action type name', () => {
|
|
229
|
+
expect(reactionPromptSection()).toContain('reactionPrompt');
|
|
230
|
+
});
|
|
231
|
+
it('documents required fields', () => {
|
|
232
|
+
const section = reactionPromptSection();
|
|
233
|
+
expect(section).toContain('question');
|
|
234
|
+
expect(section).toContain('choices');
|
|
235
|
+
expect(section).toContain('timeoutSeconds');
|
|
236
|
+
});
|
|
237
|
+
it('includes a usage example', () => {
|
|
238
|
+
const section = reactionPromptSection();
|
|
239
|
+
expect(section).toContain('<discord-action>');
|
|
240
|
+
expect(section).toContain('✅');
|
|
241
|
+
});
|
|
242
|
+
it('mentions choice count limits', () => {
|
|
243
|
+
expect(reactionPromptSection()).toContain('2–9');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// QUERY_ACTION_TYPES regression guard
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
describe('QUERY_ACTION_TYPES exclusion', () => {
|
|
250
|
+
it('reactionPrompt is not in QUERY_ACTION_TYPES (fire-and-forget, not a query)', () => {
|
|
251
|
+
expect(QUERY_ACTION_TYPES.has('reactionPrompt')).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { MAX_IMAGES_PER_INVOCATION } from '../runtime/types.js';
|
|
2
|
+
import { downloadMessageImages, resolveMediaType } from './image-download.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a Discord reply reference into a prompt section and any image attachments.
|
|
5
|
+
*
|
|
6
|
+
* Returns null if the message has no reference or if the fetch fails.
|
|
7
|
+
* Images from the referenced message share the global MAX_IMAGES_PER_INVOCATION budget;
|
|
8
|
+
* pass `usedImages` to account for images already claimed.
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveReplyReference(msg, botDisplayName, log, usedImages = 0) {
|
|
11
|
+
const refId = msg.reference?.messageId;
|
|
12
|
+
if (!refId)
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const refMsg = await msg.channel.messages.fetch(refId);
|
|
16
|
+
// Author name
|
|
17
|
+
const author = refMsg.author.bot
|
|
18
|
+
? (botDisplayName ?? 'Discoclaw')
|
|
19
|
+
: (refMsg.author.displayName || refMsg.author.username);
|
|
20
|
+
const content = String(refMsg.content ?? '');
|
|
21
|
+
// Note non-image attachments inline
|
|
22
|
+
const attachmentNotes = [];
|
|
23
|
+
const imageAttachments = [];
|
|
24
|
+
if (refMsg.attachments) {
|
|
25
|
+
const atts = refMsg.attachments instanceof Map
|
|
26
|
+
? [...refMsg.attachments.values()]
|
|
27
|
+
: [...refMsg.attachments.values()];
|
|
28
|
+
for (const att of atts) {
|
|
29
|
+
if (resolveMediaType(att)) {
|
|
30
|
+
imageAttachments.push(att);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const name = att.name ?? 'unknown';
|
|
34
|
+
attachmentNotes.push(`[attachment: ${name}]`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Download images from the referenced message (shared budget)
|
|
39
|
+
let images = [];
|
|
40
|
+
if (imageAttachments.length > 0) {
|
|
41
|
+
const remaining = Math.max(0, MAX_IMAGES_PER_INVOCATION - usedImages);
|
|
42
|
+
if (remaining > 0) {
|
|
43
|
+
try {
|
|
44
|
+
const dlResult = await downloadMessageImages(imageAttachments, remaining);
|
|
45
|
+
images = dlResult.images;
|
|
46
|
+
if (dlResult.errors.length > 0) {
|
|
47
|
+
log?.warn({ errors: dlResult.errors }, 'discord:reply-ref image download errors');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
log?.warn({ err }, 'discord:reply-ref image download failed');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Build section
|
|
56
|
+
let section = `[${author}]: ${content}`;
|
|
57
|
+
if (attachmentNotes.length > 0) {
|
|
58
|
+
section += '\n' + attachmentNotes.join('\n');
|
|
59
|
+
}
|
|
60
|
+
return { section, images };
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
log?.warn({ err, refId }, 'discord:reply-ref fetch failed');
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { resolveReplyReference } from './reply-reference.js';
|
|
3
|
+
function makeMsg(opts) {
|
|
4
|
+
const refMsg = {
|
|
5
|
+
author: opts.refAuthor ?? { username: 'TestUser', displayName: 'Test User' },
|
|
6
|
+
content: opts.refContent ?? 'hello world',
|
|
7
|
+
attachments: opts.refAttachments ?? new Map(),
|
|
8
|
+
};
|
|
9
|
+
return {
|
|
10
|
+
reference: opts.refId ? { messageId: opts.refId } : null,
|
|
11
|
+
channel: {
|
|
12
|
+
messages: {
|
|
13
|
+
fetch: opts.fetchFails
|
|
14
|
+
? vi.fn().mockRejectedValue(new Error('Unknown Message'))
|
|
15
|
+
: vi.fn().mockResolvedValue(refMsg),
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
describe('resolveReplyReference', () => {
|
|
21
|
+
const originalFetch = globalThis.fetch;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
globalThis.fetch = vi.fn();
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
globalThis.fetch = originalFetch;
|
|
27
|
+
});
|
|
28
|
+
it('returns null when no reference', async () => {
|
|
29
|
+
const msg = makeMsg({});
|
|
30
|
+
expect(await resolveReplyReference(msg, 'Weston')).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
it('returns section with author and content', async () => {
|
|
33
|
+
const msg = makeMsg({ refId: '999', refContent: 'check this out' });
|
|
34
|
+
const result = await resolveReplyReference(msg, 'Weston');
|
|
35
|
+
expect(result).not.toBeNull();
|
|
36
|
+
expect(result.section).toBe('[Test User]: check this out');
|
|
37
|
+
expect(result.images).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
it('uses botDisplayName for bot authors', async () => {
|
|
40
|
+
const msg = makeMsg({
|
|
41
|
+
refId: '999',
|
|
42
|
+
refAuthor: { bot: true, username: 'discoclaw', displayName: 'Discoclaw' },
|
|
43
|
+
refContent: 'I said something',
|
|
44
|
+
});
|
|
45
|
+
const result = await resolveReplyReference(msg, 'Weston');
|
|
46
|
+
expect(result.section).toBe('[Weston]: I said something');
|
|
47
|
+
});
|
|
48
|
+
it('falls back to Discoclaw when botDisplayName is undefined', async () => {
|
|
49
|
+
const msg = makeMsg({
|
|
50
|
+
refId: '999',
|
|
51
|
+
refAuthor: { bot: true, username: 'discoclaw' },
|
|
52
|
+
refContent: 'bot message',
|
|
53
|
+
});
|
|
54
|
+
const result = await resolveReplyReference(msg, undefined);
|
|
55
|
+
expect(result.section).toBe('[Discoclaw]: bot message');
|
|
56
|
+
});
|
|
57
|
+
it('uses username when displayName is empty', async () => {
|
|
58
|
+
const msg = makeMsg({
|
|
59
|
+
refId: '999',
|
|
60
|
+
refAuthor: { username: 'dave123', displayName: '' },
|
|
61
|
+
refContent: 'yo',
|
|
62
|
+
});
|
|
63
|
+
const result = await resolveReplyReference(msg, 'Weston');
|
|
64
|
+
expect(result.section).toBe('[dave123]: yo');
|
|
65
|
+
});
|
|
66
|
+
it('notes non-image attachments inline', async () => {
|
|
67
|
+
const atts = new Map([
|
|
68
|
+
['1', { url: 'https://cdn.discordapp.com/a.pdf', name: 'report.pdf', contentType: 'application/pdf', size: 100 }],
|
|
69
|
+
]);
|
|
70
|
+
const msg = makeMsg({ refId: '999', refContent: 'see attached', refAttachments: atts });
|
|
71
|
+
const result = await resolveReplyReference(msg, 'Weston');
|
|
72
|
+
expect(result.section).toContain('[attachment: report.pdf]');
|
|
73
|
+
expect(result.images).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
it('downloads image attachments from referenced message', async () => {
|
|
76
|
+
const imgData = (() => { const b = Buffer.alloc(45); Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).copy(b); return b; })();
|
|
77
|
+
globalThis.fetch.mockResolvedValue({
|
|
78
|
+
ok: true,
|
|
79
|
+
arrayBuffer: () => Promise.resolve(imgData.buffer.slice(imgData.byteOffset, imgData.byteOffset + imgData.byteLength)),
|
|
80
|
+
});
|
|
81
|
+
const atts = new Map([
|
|
82
|
+
['1', { url: 'https://cdn.discordapp.com/attachments/1/2/photo.png', name: 'photo.png', contentType: 'image/png', size: 100 }],
|
|
83
|
+
]);
|
|
84
|
+
const msg = makeMsg({ refId: '999', refContent: 'look at this', refAttachments: atts });
|
|
85
|
+
const result = await resolveReplyReference(msg, 'Weston');
|
|
86
|
+
expect(result.images).toHaveLength(1);
|
|
87
|
+
expect(result.images[0].mediaType).toBe('image/png');
|
|
88
|
+
});
|
|
89
|
+
it('respects shared image budget', async () => {
|
|
90
|
+
const imgData = (() => { const b = Buffer.alloc(45); Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).copy(b); return b; })();
|
|
91
|
+
globalThis.fetch.mockResolvedValue({
|
|
92
|
+
ok: true,
|
|
93
|
+
arrayBuffer: () => Promise.resolve(imgData.buffer.slice(imgData.byteOffset, imgData.byteOffset + imgData.byteLength)),
|
|
94
|
+
});
|
|
95
|
+
const atts = new Map([
|
|
96
|
+
['1', { url: 'https://cdn.discordapp.com/attachments/1/2/a.png', name: 'a.png', contentType: 'image/png', size: 100 }],
|
|
97
|
+
['2', { url: 'https://cdn.discordapp.com/attachments/1/2/b.png', name: 'b.png', contentType: 'image/png', size: 100 }],
|
|
98
|
+
]);
|
|
99
|
+
const msg = makeMsg({ refId: '999', refContent: 'images', refAttachments: atts });
|
|
100
|
+
// Budget already has 9 used, only 1 remaining
|
|
101
|
+
const result = await resolveReplyReference(msg, 'Weston', undefined, 9);
|
|
102
|
+
expect(result.images).toHaveLength(1);
|
|
103
|
+
});
|
|
104
|
+
it('skips images when budget is exhausted', async () => {
|
|
105
|
+
const atts = new Map([
|
|
106
|
+
['1', { url: 'https://cdn.discordapp.com/attachments/1/2/a.png', name: 'a.png', contentType: 'image/png', size: 100 }],
|
|
107
|
+
]);
|
|
108
|
+
const msg = makeMsg({ refId: '999', refContent: 'no budget', refAttachments: atts });
|
|
109
|
+
const result = await resolveReplyReference(msg, 'Weston', undefined, 10);
|
|
110
|
+
expect(result.images).toHaveLength(0);
|
|
111
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
it('returns null on fetch failure (deleted message)', async () => {
|
|
114
|
+
const msg = makeMsg({ refId: '999', fetchFails: true });
|
|
115
|
+
const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
116
|
+
const result = await resolveReplyReference(msg, 'Weston', log);
|
|
117
|
+
expect(result).toBeNull();
|
|
118
|
+
expect(log.warn).toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
it('handles empty content gracefully', async () => {
|
|
121
|
+
const msg = makeMsg({ refId: '999', refContent: '' });
|
|
122
|
+
const result = await resolveReplyReference(msg, 'Weston');
|
|
123
|
+
expect(result.section).toBe('[Test User]: ');
|
|
124
|
+
});
|
|
125
|
+
});
|