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,697 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import { executeChannelAction } from './actions-channels.js';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
function makeMockGuild(channels) {
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
for (const ch of channels) {
|
|
10
|
+
cache.set(ch.id, {
|
|
11
|
+
id: ch.id,
|
|
12
|
+
name: ch.name,
|
|
13
|
+
type: ch.type,
|
|
14
|
+
parent: ch.parentName ? { name: ch.parentName } : null,
|
|
15
|
+
topic: ch.topic ?? null,
|
|
16
|
+
createdAt: ch.createdAt ?? null,
|
|
17
|
+
edit: vi.fn(async () => { }),
|
|
18
|
+
delete: vi.fn(async () => { }),
|
|
19
|
+
setParent: vi.fn(async () => { }),
|
|
20
|
+
setPosition: vi.fn(async () => { }),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
channels: {
|
|
25
|
+
cache: {
|
|
26
|
+
get: (id) => cache.get(id),
|
|
27
|
+
find: (fn) => {
|
|
28
|
+
for (const ch of cache.values()) {
|
|
29
|
+
if (fn(ch))
|
|
30
|
+
return ch;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
},
|
|
34
|
+
values: () => cache.values(),
|
|
35
|
+
get size() { return cache.size; },
|
|
36
|
+
},
|
|
37
|
+
create: vi.fn(async (opts) => ({
|
|
38
|
+
name: opts.name,
|
|
39
|
+
id: 'new-id',
|
|
40
|
+
})),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function makeCtx(guild) {
|
|
45
|
+
return {
|
|
46
|
+
guild,
|
|
47
|
+
client: {},
|
|
48
|
+
channelId: 'test-channel',
|
|
49
|
+
messageId: 'test-message',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// channelList
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
describe('channelList', () => {
|
|
56
|
+
it('channels grouped by category include IDs', async () => {
|
|
57
|
+
const guild = makeMockGuild([
|
|
58
|
+
{ id: 'cat1', name: 'Dev', type: ChannelType.GuildCategory },
|
|
59
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText, parentName: 'Dev' },
|
|
60
|
+
]);
|
|
61
|
+
const ctx = makeCtx(guild);
|
|
62
|
+
const result = await executeChannelAction({ type: 'channelList' }, ctx);
|
|
63
|
+
expect(result.ok).toBe(true);
|
|
64
|
+
expect(result.summary).toContain('#general (id:ch1)');
|
|
65
|
+
});
|
|
66
|
+
it('uncategorized channels include IDs', async () => {
|
|
67
|
+
const guild = makeMockGuild([
|
|
68
|
+
{ id: 'ch1', name: 'random', type: ChannelType.GuildText },
|
|
69
|
+
]);
|
|
70
|
+
const ctx = makeCtx(guild);
|
|
71
|
+
const result = await executeChannelAction({ type: 'channelList' }, ctx);
|
|
72
|
+
expect(result.ok).toBe(true);
|
|
73
|
+
expect(result.summary).toContain('#random (id:ch1)');
|
|
74
|
+
});
|
|
75
|
+
it('categories themselves excluded from output', async () => {
|
|
76
|
+
const guild = makeMockGuild([
|
|
77
|
+
{ id: 'cat1', name: 'Dev', type: ChannelType.GuildCategory },
|
|
78
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText, parentName: 'Dev' },
|
|
79
|
+
]);
|
|
80
|
+
const ctx = makeCtx(guild);
|
|
81
|
+
const result = await executeChannelAction({ type: 'channelList' }, ctx);
|
|
82
|
+
expect(result.ok).toBe(true);
|
|
83
|
+
const summary = result.summary;
|
|
84
|
+
// Category name appears as a grouping label, not as a channel entry
|
|
85
|
+
expect(summary).not.toContain('#Dev');
|
|
86
|
+
expect(summary).not.toContain('(id:cat1)');
|
|
87
|
+
});
|
|
88
|
+
it('empty server returns (no channels)', async () => {
|
|
89
|
+
const guild = makeMockGuild([]);
|
|
90
|
+
const ctx = makeCtx(guild);
|
|
91
|
+
const result = await executeChannelAction({ type: 'channelList' }, ctx);
|
|
92
|
+
expect(result).toEqual({ ok: true, summary: '(no channels)' });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// channelEdit
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
describe('channelEdit', () => {
|
|
99
|
+
it('edits channel name and topic', async () => {
|
|
100
|
+
const guild = makeMockGuild([
|
|
101
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText },
|
|
102
|
+
]);
|
|
103
|
+
const ctx = makeCtx(guild);
|
|
104
|
+
const result = await executeChannelAction({ type: 'channelEdit', channelId: 'ch1', name: 'renamed', topic: 'New topic' }, ctx);
|
|
105
|
+
expect(result).toEqual({ ok: true, summary: 'Edited #general: name → renamed, topic updated' });
|
|
106
|
+
const ch = guild.channels.cache.get('ch1');
|
|
107
|
+
expect(ch.edit).toHaveBeenCalledWith({ name: 'renamed', topic: 'New topic' });
|
|
108
|
+
});
|
|
109
|
+
it('edits only the name', async () => {
|
|
110
|
+
const guild = makeMockGuild([
|
|
111
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText },
|
|
112
|
+
]);
|
|
113
|
+
const ctx = makeCtx(guild);
|
|
114
|
+
const result = await executeChannelAction({ type: 'channelEdit', channelId: 'ch1', name: 'renamed' }, ctx);
|
|
115
|
+
expect(result.ok).toBe(true);
|
|
116
|
+
const ch = guild.channels.cache.get('ch1');
|
|
117
|
+
expect(ch.edit).toHaveBeenCalledWith({ name: 'renamed' });
|
|
118
|
+
});
|
|
119
|
+
it('fails when channel not found', async () => {
|
|
120
|
+
const guild = makeMockGuild([]);
|
|
121
|
+
const ctx = makeCtx(guild);
|
|
122
|
+
const result = await executeChannelAction({ type: 'channelEdit', channelId: 'nope', name: 'x' }, ctx);
|
|
123
|
+
expect(result).toEqual({ ok: false, error: 'Channel "nope" not found' });
|
|
124
|
+
});
|
|
125
|
+
it('fails when no fields provided', async () => {
|
|
126
|
+
const guild = makeMockGuild([
|
|
127
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText },
|
|
128
|
+
]);
|
|
129
|
+
const ctx = makeCtx(guild);
|
|
130
|
+
const result = await executeChannelAction({ type: 'channelEdit', channelId: 'ch1' }, ctx);
|
|
131
|
+
expect(result).toEqual({ ok: false, error: 'channelEdit requires at least one of name or topic' });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// channelDelete
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
describe('channelDelete', () => {
|
|
138
|
+
it('deletes a channel', async () => {
|
|
139
|
+
const guild = makeMockGuild([
|
|
140
|
+
{ id: 'ch1', name: 'to-delete', type: ChannelType.GuildText },
|
|
141
|
+
]);
|
|
142
|
+
const ctx = makeCtx(guild);
|
|
143
|
+
const result = await executeChannelAction({ type: 'channelDelete', channelId: 'ch1' }, ctx);
|
|
144
|
+
expect(result).toEqual({ ok: true, summary: 'Deleted #to-delete' });
|
|
145
|
+
const ch = guild.channels.cache.get('ch1');
|
|
146
|
+
expect(ch.delete).toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
it('fails when channel not found', async () => {
|
|
149
|
+
const guild = makeMockGuild([]);
|
|
150
|
+
const ctx = makeCtx(guild);
|
|
151
|
+
const result = await executeChannelAction({ type: 'channelDelete', channelId: 'nope' }, ctx);
|
|
152
|
+
expect(result).toEqual({ ok: false, error: 'Channel "nope" not found' });
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// channelCreate types
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
describe('channelCreate', () => {
|
|
159
|
+
it('creates a text channel by default', async () => {
|
|
160
|
+
const guild = makeMockGuild([]);
|
|
161
|
+
const ctx = makeCtx(guild);
|
|
162
|
+
const result = await executeChannelAction({ type: 'channelCreate', name: 'general' }, ctx);
|
|
163
|
+
expect(result.ok).toBe(true);
|
|
164
|
+
expect(guild.channels.create).toHaveBeenCalledWith(expect.objectContaining({ type: ChannelType.GuildText }));
|
|
165
|
+
});
|
|
166
|
+
it('creates a voice channel', async () => {
|
|
167
|
+
const guild = makeMockGuild([]);
|
|
168
|
+
const ctx = makeCtx(guild);
|
|
169
|
+
const result = await executeChannelAction({ type: 'channelCreate', name: 'voice-chat', channelType: 'voice' }, ctx);
|
|
170
|
+
expect(result.ok).toBe(true);
|
|
171
|
+
expect(guild.channels.create).toHaveBeenCalledWith(expect.objectContaining({ type: ChannelType.GuildVoice }));
|
|
172
|
+
});
|
|
173
|
+
it('creates an announcement channel', async () => {
|
|
174
|
+
const guild = makeMockGuild([]);
|
|
175
|
+
const ctx = makeCtx(guild);
|
|
176
|
+
const result = await executeChannelAction({ type: 'channelCreate', name: 'news', channelType: 'announcement' }, ctx);
|
|
177
|
+
expect(result.ok).toBe(true);
|
|
178
|
+
expect(guild.channels.create).toHaveBeenCalledWith(expect.objectContaining({ type: ChannelType.GuildAnnouncement }));
|
|
179
|
+
});
|
|
180
|
+
it('creates a stage channel', async () => {
|
|
181
|
+
const guild = makeMockGuild([]);
|
|
182
|
+
const ctx = makeCtx(guild);
|
|
183
|
+
const result = await executeChannelAction({ type: 'channelCreate', name: 'stage-talk', channelType: 'stage' }, ctx);
|
|
184
|
+
expect(result.ok).toBe(true);
|
|
185
|
+
expect(guild.channels.create).toHaveBeenCalledWith(expect.objectContaining({ type: ChannelType.GuildStageVoice }));
|
|
186
|
+
});
|
|
187
|
+
it('creates under a parent category', async () => {
|
|
188
|
+
const guild = makeMockGuild([
|
|
189
|
+
{ id: 'cat1', name: 'Dev', type: ChannelType.GuildCategory },
|
|
190
|
+
]);
|
|
191
|
+
const ctx = makeCtx(guild);
|
|
192
|
+
const result = await executeChannelAction({ type: 'channelCreate', name: 'dev-chat', parent: 'Dev' }, ctx);
|
|
193
|
+
expect(result.ok).toBe(true);
|
|
194
|
+
expect(result.summary).toContain('under Dev');
|
|
195
|
+
expect(guild.channels.create).toHaveBeenCalledWith(expect.objectContaining({ parent: 'cat1' }));
|
|
196
|
+
});
|
|
197
|
+
it('fails when parent category not found', async () => {
|
|
198
|
+
const guild = makeMockGuild([]);
|
|
199
|
+
const ctx = makeCtx(guild);
|
|
200
|
+
const result = await executeChannelAction({ type: 'channelCreate', name: 'test', parent: 'NonExistent' }, ctx);
|
|
201
|
+
expect(result).toEqual({ ok: false, error: 'Category "NonExistent" not found' });
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// channelInfo
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
describe('channelInfo', () => {
|
|
208
|
+
it('returns channel details', async () => {
|
|
209
|
+
const guild = makeMockGuild([
|
|
210
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText, parentName: 'Text', topic: 'Main channel' },
|
|
211
|
+
]);
|
|
212
|
+
const ctx = makeCtx(guild);
|
|
213
|
+
const result = await executeChannelAction({ type: 'channelInfo', channelId: 'ch1' }, ctx);
|
|
214
|
+
expect(result.ok).toBe(true);
|
|
215
|
+
const summary = result.summary;
|
|
216
|
+
expect(summary).toContain('Name: #general');
|
|
217
|
+
expect(summary).toContain('ID: ch1');
|
|
218
|
+
expect(summary).toContain('Category: Text');
|
|
219
|
+
expect(summary).toContain('Topic: Main channel');
|
|
220
|
+
});
|
|
221
|
+
it('fails when channel not found', async () => {
|
|
222
|
+
const guild = makeMockGuild([]);
|
|
223
|
+
const ctx = makeCtx(guild);
|
|
224
|
+
const result = await executeChannelAction({ type: 'channelInfo', channelId: 'nope' }, ctx);
|
|
225
|
+
expect(result).toEqual({ ok: false, error: 'Channel "nope" not found' });
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// categoryCreate
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// channelMove
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
describe('channelMove', () => {
|
|
235
|
+
it('moves channel to a category by name', async () => {
|
|
236
|
+
const guild = makeMockGuild([
|
|
237
|
+
{ id: 'cat1', name: 'Projects', type: ChannelType.GuildCategory },
|
|
238
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText },
|
|
239
|
+
]);
|
|
240
|
+
const ctx = makeCtx(guild);
|
|
241
|
+
const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', parent: 'Projects' }, ctx);
|
|
242
|
+
expect(result).toEqual({ ok: true, summary: 'Moved #general: moved to Projects' });
|
|
243
|
+
const ch = guild.channels.cache.get('ch1');
|
|
244
|
+
expect(ch.setParent).toHaveBeenCalledWith('cat1');
|
|
245
|
+
});
|
|
246
|
+
it('moves channel to a category by ID', async () => {
|
|
247
|
+
const guild = makeMockGuild([
|
|
248
|
+
{ id: 'cat1', name: 'Projects', type: ChannelType.GuildCategory },
|
|
249
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText },
|
|
250
|
+
]);
|
|
251
|
+
const ctx = makeCtx(guild);
|
|
252
|
+
const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', parent: 'cat1' }, ctx);
|
|
253
|
+
expect(result.ok).toBe(true);
|
|
254
|
+
const ch = guild.channels.cache.get('ch1');
|
|
255
|
+
expect(ch.setParent).toHaveBeenCalledWith('cat1');
|
|
256
|
+
});
|
|
257
|
+
it('removes channel from category with empty string', async () => {
|
|
258
|
+
const guild = makeMockGuild([
|
|
259
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText, parentName: 'Old' },
|
|
260
|
+
]);
|
|
261
|
+
const ctx = makeCtx(guild);
|
|
262
|
+
const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', parent: '' }, ctx);
|
|
263
|
+
expect(result).toEqual({ ok: true, summary: 'Moved #general: removed from category' });
|
|
264
|
+
const ch = guild.channels.cache.get('ch1');
|
|
265
|
+
expect(ch.setParent).toHaveBeenCalledWith(null);
|
|
266
|
+
});
|
|
267
|
+
it('sets channel position', async () => {
|
|
268
|
+
const guild = makeMockGuild([
|
|
269
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText },
|
|
270
|
+
]);
|
|
271
|
+
const ctx = makeCtx(guild);
|
|
272
|
+
const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', position: 3 }, ctx);
|
|
273
|
+
expect(result).toEqual({ ok: true, summary: 'Moved #general: position → 3' });
|
|
274
|
+
const ch = guild.channels.cache.get('ch1');
|
|
275
|
+
expect(ch.setPosition).toHaveBeenCalledWith(3);
|
|
276
|
+
});
|
|
277
|
+
it('moves and repositions in one call', async () => {
|
|
278
|
+
const guild = makeMockGuild([
|
|
279
|
+
{ id: 'cat1', name: 'Dev', type: ChannelType.GuildCategory },
|
|
280
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText },
|
|
281
|
+
]);
|
|
282
|
+
const ctx = makeCtx(guild);
|
|
283
|
+
const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', parent: 'Dev', position: 0 }, ctx);
|
|
284
|
+
expect(result).toEqual({ ok: true, summary: 'Moved #general: moved to Dev, position → 0' });
|
|
285
|
+
const ch = guild.channels.cache.get('ch1');
|
|
286
|
+
expect(ch.setParent).toHaveBeenCalledWith('cat1');
|
|
287
|
+
expect(ch.setPosition).toHaveBeenCalledWith(0);
|
|
288
|
+
});
|
|
289
|
+
it('fails when neither parent nor position given', async () => {
|
|
290
|
+
const guild = makeMockGuild([
|
|
291
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText },
|
|
292
|
+
]);
|
|
293
|
+
const ctx = makeCtx(guild);
|
|
294
|
+
const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1' }, ctx);
|
|
295
|
+
expect(result).toEqual({ ok: false, error: 'channelMove requires at least one of parent or position' });
|
|
296
|
+
});
|
|
297
|
+
it('fails when channel not found', async () => {
|
|
298
|
+
const guild = makeMockGuild([]);
|
|
299
|
+
const ctx = makeCtx(guild);
|
|
300
|
+
const result = await executeChannelAction({ type: 'channelMove', channelId: 'nope', parent: 'Dev' }, ctx);
|
|
301
|
+
expect(result).toEqual({ ok: false, error: 'Channel "nope" not found' });
|
|
302
|
+
});
|
|
303
|
+
it('fails when category not found', async () => {
|
|
304
|
+
const guild = makeMockGuild([
|
|
305
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText },
|
|
306
|
+
]);
|
|
307
|
+
const ctx = makeCtx(guild);
|
|
308
|
+
const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', parent: 'NonExistent' }, ctx);
|
|
309
|
+
expect(result).toEqual({ ok: false, error: 'Category "NonExistent" not found' });
|
|
310
|
+
});
|
|
311
|
+
it('resolves category name case-insensitively', async () => {
|
|
312
|
+
const guild = makeMockGuild([
|
|
313
|
+
{ id: 'cat1', name: 'Projects', type: ChannelType.GuildCategory },
|
|
314
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText },
|
|
315
|
+
]);
|
|
316
|
+
const ctx = makeCtx(guild);
|
|
317
|
+
const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', parent: 'projects' }, ctx);
|
|
318
|
+
expect(result.ok).toBe(true);
|
|
319
|
+
const ch = guild.channels.cache.get('ch1');
|
|
320
|
+
expect(ch.setParent).toHaveBeenCalledWith('cat1');
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// threadListArchived
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
describe('threadListArchived', () => {
|
|
327
|
+
function makeMockForumGuild(threads) {
|
|
328
|
+
const threadMap = new Map();
|
|
329
|
+
for (const t of threads) {
|
|
330
|
+
threadMap.set(t.id, { id: t.id, name: t.name });
|
|
331
|
+
}
|
|
332
|
+
const forumChannel = {
|
|
333
|
+
id: 'forum1',
|
|
334
|
+
name: 'beads',
|
|
335
|
+
type: ChannelType.GuildForum,
|
|
336
|
+
parent: null,
|
|
337
|
+
threads: {
|
|
338
|
+
fetchArchived: vi.fn(async () => ({ threads: threadMap })),
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
const cache = new Map();
|
|
342
|
+
cache.set('forum1', forumChannel);
|
|
343
|
+
return {
|
|
344
|
+
guild: {
|
|
345
|
+
channels: {
|
|
346
|
+
cache: {
|
|
347
|
+
get: (id) => cache.get(id),
|
|
348
|
+
find: (fn) => {
|
|
349
|
+
for (const ch of cache.values()) {
|
|
350
|
+
if (fn(ch))
|
|
351
|
+
return ch;
|
|
352
|
+
}
|
|
353
|
+
return undefined;
|
|
354
|
+
},
|
|
355
|
+
values: () => cache.values(),
|
|
356
|
+
get size() { return cache.size; },
|
|
357
|
+
},
|
|
358
|
+
create: vi.fn(async (opts) => ({ name: opts.name, id: 'new-id' })),
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
forumChannel,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
it('lists archived threads in a forum channel', async () => {
|
|
365
|
+
const { guild } = makeMockForumGuild([
|
|
366
|
+
{ id: 't1', name: 'Thread Alpha' },
|
|
367
|
+
{ id: 't2', name: 'Thread Beta' },
|
|
368
|
+
]);
|
|
369
|
+
const ctx = makeCtx(guild);
|
|
370
|
+
const result = await executeChannelAction({ type: 'threadListArchived', channelId: 'forum1' }, ctx);
|
|
371
|
+
expect(result.ok).toBe(true);
|
|
372
|
+
const summary = result.summary;
|
|
373
|
+
expect(summary).toContain('Archived threads in #beads (2)');
|
|
374
|
+
expect(summary).toContain('• Thread Alpha (id:t1)');
|
|
375
|
+
expect(summary).toContain('• Thread Beta (id:t2)');
|
|
376
|
+
});
|
|
377
|
+
it('returns message when no archived threads', async () => {
|
|
378
|
+
const { guild } = makeMockForumGuild([]);
|
|
379
|
+
const ctx = makeCtx(guild);
|
|
380
|
+
const result = await executeChannelAction({ type: 'threadListArchived', channelId: 'forum1' }, ctx);
|
|
381
|
+
expect(result).toEqual({ ok: true, summary: 'No archived threads in #beads' });
|
|
382
|
+
});
|
|
383
|
+
it('passes limit to fetchArchived', async () => {
|
|
384
|
+
const { guild, forumChannel } = makeMockForumGuild([]);
|
|
385
|
+
const ctx = makeCtx(guild);
|
|
386
|
+
await executeChannelAction({ type: 'threadListArchived', channelId: 'forum1', limit: 10 }, ctx);
|
|
387
|
+
expect(forumChannel.threads.fetchArchived).toHaveBeenCalledWith({ limit: 10, fetchAll: true });
|
|
388
|
+
});
|
|
389
|
+
it('fails when channel not found', async () => {
|
|
390
|
+
const { guild } = makeMockForumGuild([]);
|
|
391
|
+
const ctx = makeCtx(guild);
|
|
392
|
+
const result = await executeChannelAction({ type: 'threadListArchived', channelId: 'nope' }, ctx);
|
|
393
|
+
expect(result).toEqual({ ok: false, error: 'Channel "nope" not found' });
|
|
394
|
+
});
|
|
395
|
+
it('fails for non-forum/text channel', async () => {
|
|
396
|
+
const guild = makeMockGuild([
|
|
397
|
+
{ id: 'voice1', name: 'voice-chat', type: ChannelType.GuildVoice },
|
|
398
|
+
]);
|
|
399
|
+
const ctx = makeCtx(guild);
|
|
400
|
+
const result = await executeChannelAction({ type: 'threadListArchived', channelId: 'voice1' }, ctx);
|
|
401
|
+
expect(result).toEqual({ ok: false, error: 'Channel #voice-chat is not a forum or text channel' });
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// categoryCreate
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
describe('categoryCreate', () => {
|
|
408
|
+
it('creates a category', async () => {
|
|
409
|
+
const guild = makeMockGuild([]);
|
|
410
|
+
const ctx = makeCtx(guild);
|
|
411
|
+
const result = await executeChannelAction({ type: 'categoryCreate', name: 'Projects' }, ctx);
|
|
412
|
+
expect(result).toEqual({ ok: true, summary: 'Created category "Projects"' });
|
|
413
|
+
expect(guild.channels.create).toHaveBeenCalledWith({
|
|
414
|
+
name: 'Projects',
|
|
415
|
+
type: ChannelType.GuildCategory,
|
|
416
|
+
position: undefined,
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// threadEdit
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
describe('threadEdit', () => {
|
|
424
|
+
function makeThreadCtx(opts) {
|
|
425
|
+
const thread = {
|
|
426
|
+
id: opts.threadId,
|
|
427
|
+
name: opts.threadName,
|
|
428
|
+
guildId: opts.guildId,
|
|
429
|
+
isThread: () => true,
|
|
430
|
+
parent: { type: opts.parentType },
|
|
431
|
+
appliedTags: opts.appliedTags ?? [],
|
|
432
|
+
edit: vi.fn(async () => { }),
|
|
433
|
+
};
|
|
434
|
+
const channelsCache = new Map();
|
|
435
|
+
const client = {
|
|
436
|
+
channels: {
|
|
437
|
+
cache: {
|
|
438
|
+
get: (id) => (opts.inCache !== false && id === opts.threadId ? thread : undefined),
|
|
439
|
+
},
|
|
440
|
+
fetch: vi.fn(async (id) => {
|
|
441
|
+
if (id === opts.threadId)
|
|
442
|
+
return thread;
|
|
443
|
+
throw new Error('Unknown channel');
|
|
444
|
+
}),
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
const guild = makeMockGuild([]);
|
|
448
|
+
guild.id = opts.guildId;
|
|
449
|
+
return {
|
|
450
|
+
thread,
|
|
451
|
+
ctx: { ...makeCtx(guild), client },
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
it('edits appliedTags on a forum thread (cache hit)', async () => {
|
|
455
|
+
const { thread, ctx } = makeThreadCtx({
|
|
456
|
+
threadId: 't1', threadName: 'My Thread', guildId: 'g1',
|
|
457
|
+
parentType: ChannelType.GuildForum, inCache: true,
|
|
458
|
+
});
|
|
459
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', appliedTags: ['tag1', 'tag2'] }, ctx);
|
|
460
|
+
expect(result.ok).toBe(true);
|
|
461
|
+
expect(result.summary).toContain('appliedTags → [tag1, tag2]');
|
|
462
|
+
expect(thread.edit).toHaveBeenCalledWith({ appliedTags: ['tag1', 'tag2'] });
|
|
463
|
+
});
|
|
464
|
+
it('edits thread name only', async () => {
|
|
465
|
+
const { thread, ctx } = makeThreadCtx({
|
|
466
|
+
threadId: 't1', threadName: 'Old Name', guildId: 'g1',
|
|
467
|
+
parentType: ChannelType.GuildForum, inCache: true,
|
|
468
|
+
});
|
|
469
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
|
|
470
|
+
expect(result.ok).toBe(true);
|
|
471
|
+
expect(result.summary).toContain('name → New Name');
|
|
472
|
+
expect(thread.edit).toHaveBeenCalledWith({ name: 'New Name' });
|
|
473
|
+
});
|
|
474
|
+
it('edits both appliedTags and name', async () => {
|
|
475
|
+
const { thread, ctx } = makeThreadCtx({
|
|
476
|
+
threadId: 't1', threadName: 'Old Name', guildId: 'g1',
|
|
477
|
+
parentType: ChannelType.GuildForum, inCache: true,
|
|
478
|
+
});
|
|
479
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', appliedTags: ['tag1'], name: 'New Name' }, ctx);
|
|
480
|
+
expect(result.ok).toBe(true);
|
|
481
|
+
expect(thread.edit).toHaveBeenCalledWith({ appliedTags: ['tag1'], name: 'New Name' });
|
|
482
|
+
});
|
|
483
|
+
it('fetches thread from API when not in cache', async () => {
|
|
484
|
+
const { thread, ctx } = makeThreadCtx({
|
|
485
|
+
threadId: 't1', threadName: 'My Thread', guildId: 'g1',
|
|
486
|
+
parentType: ChannelType.GuildForum, inCache: false,
|
|
487
|
+
});
|
|
488
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', appliedTags: ['tag1'] }, ctx);
|
|
489
|
+
expect(result.ok).toBe(true);
|
|
490
|
+
expect(ctx.client.channels.fetch).toHaveBeenCalledWith('t1');
|
|
491
|
+
expect(thread.edit).toHaveBeenCalled();
|
|
492
|
+
});
|
|
493
|
+
it('fails when thread not found', async () => {
|
|
494
|
+
const guild = makeMockGuild([]);
|
|
495
|
+
guild.id = 'g1';
|
|
496
|
+
const client = {
|
|
497
|
+
channels: {
|
|
498
|
+
cache: { get: () => undefined },
|
|
499
|
+
fetch: vi.fn(async () => { throw new Error('Unknown'); }),
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
const ctx = { ...makeCtx(guild), client };
|
|
503
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 'missing', appliedTags: ['tag1'] }, ctx);
|
|
504
|
+
expect(result).toEqual({ ok: false, error: 'Thread "missing" not found' });
|
|
505
|
+
});
|
|
506
|
+
it('fails when thread belongs to a different guild', async () => {
|
|
507
|
+
const { ctx } = makeThreadCtx({
|
|
508
|
+
threadId: 't1', threadName: 'My Thread', guildId: 'other-guild',
|
|
509
|
+
parentType: ChannelType.GuildForum, inCache: true,
|
|
510
|
+
});
|
|
511
|
+
// ctx.guild.id is set by makeCtx which uses makeMockGuild — override it
|
|
512
|
+
ctx.guild.id = 'this-guild';
|
|
513
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', appliedTags: ['tag1'] }, ctx);
|
|
514
|
+
expect(result).toEqual({ ok: false, error: 'Thread "t1" does not belong to this guild' });
|
|
515
|
+
});
|
|
516
|
+
it('rejects appliedTags when parent is not a forum channel', async () => {
|
|
517
|
+
const { ctx } = makeThreadCtx({
|
|
518
|
+
threadId: 't1', threadName: 'My Thread', guildId: 'g1',
|
|
519
|
+
parentType: ChannelType.GuildText, inCache: true,
|
|
520
|
+
});
|
|
521
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', appliedTags: ['tag1'] }, ctx);
|
|
522
|
+
expect(result).toEqual({
|
|
523
|
+
ok: false,
|
|
524
|
+
error: 'Thread "t1" is not in a forum channel — appliedTags only applies to forum threads',
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
it('rejects appliedTags exceeding 5', async () => {
|
|
528
|
+
const { ctx } = makeThreadCtx({
|
|
529
|
+
threadId: 't1', threadName: 'My Thread', guildId: 'g1',
|
|
530
|
+
parentType: ChannelType.GuildForum, inCache: true,
|
|
531
|
+
});
|
|
532
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', appliedTags: ['a', 'b', 'c', 'd', 'e', 'f'] }, ctx);
|
|
533
|
+
expect(result).toEqual({ ok: false, error: 'appliedTags exceeds Discord maximum of 5 (got 6)' });
|
|
534
|
+
});
|
|
535
|
+
it('fails when neither appliedTags nor name provided', async () => {
|
|
536
|
+
const guild = makeMockGuild([]);
|
|
537
|
+
const ctx = makeCtx(guild);
|
|
538
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1' }, ctx);
|
|
539
|
+
expect(result).toEqual({ ok: false, error: 'threadEdit requires at least one of appliedTags or name' });
|
|
540
|
+
});
|
|
541
|
+
it('finds archived thread via forum-channel fallback', async () => {
|
|
542
|
+
const thread = {
|
|
543
|
+
id: 't1',
|
|
544
|
+
name: 'Old Name',
|
|
545
|
+
guildId: 'g1',
|
|
546
|
+
archived: true,
|
|
547
|
+
isThread: () => true,
|
|
548
|
+
parent: { type: ChannelType.GuildForum },
|
|
549
|
+
appliedTags: [],
|
|
550
|
+
edit: vi.fn(async () => { }),
|
|
551
|
+
setArchived: vi.fn(async () => { }),
|
|
552
|
+
};
|
|
553
|
+
const forumChannel = {
|
|
554
|
+
id: 'forum1',
|
|
555
|
+
name: 'tasks',
|
|
556
|
+
type: ChannelType.GuildForum,
|
|
557
|
+
threads: {
|
|
558
|
+
fetchActive: vi.fn(async () => ({ threads: new Map() })),
|
|
559
|
+
fetchArchived: vi.fn(async () => ({
|
|
560
|
+
threads: new Map([['t1', thread]]),
|
|
561
|
+
})),
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
const guildChannelsCache = new Map([['forum1', forumChannel]]);
|
|
565
|
+
const guild = {
|
|
566
|
+
id: 'g1',
|
|
567
|
+
channels: {
|
|
568
|
+
cache: {
|
|
569
|
+
get: (id) => guildChannelsCache.get(id),
|
|
570
|
+
find: () => undefined,
|
|
571
|
+
values: () => guildChannelsCache.values(),
|
|
572
|
+
size: guildChannelsCache.size,
|
|
573
|
+
},
|
|
574
|
+
create: vi.fn(),
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
const client = {
|
|
578
|
+
channels: {
|
|
579
|
+
cache: { get: () => undefined },
|
|
580
|
+
fetch: vi.fn(async () => { throw new Error('Unknown'); }),
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
const ctx = { ...makeCtx(guild), client };
|
|
584
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
|
|
585
|
+
expect(result.ok).toBe(true);
|
|
586
|
+
expect(thread.setArchived).toHaveBeenCalledWith(false);
|
|
587
|
+
expect(thread.edit).toHaveBeenCalledWith({ name: 'New Name' });
|
|
588
|
+
expect(thread.setArchived).toHaveBeenCalledWith(true);
|
|
589
|
+
// Archived checked before active (the common case for this fallback).
|
|
590
|
+
expect(forumChannel.threads.fetchArchived).toHaveBeenCalled();
|
|
591
|
+
});
|
|
592
|
+
it('finds active thread via forum-channel fallback', async () => {
|
|
593
|
+
const thread = {
|
|
594
|
+
id: 't1',
|
|
595
|
+
name: 'Old Name',
|
|
596
|
+
guildId: 'g1',
|
|
597
|
+
archived: false,
|
|
598
|
+
isThread: () => true,
|
|
599
|
+
parent: { type: ChannelType.GuildForum },
|
|
600
|
+
appliedTags: [],
|
|
601
|
+
edit: vi.fn(async () => { }),
|
|
602
|
+
setArchived: vi.fn(async () => { }),
|
|
603
|
+
};
|
|
604
|
+
const forumChannel = {
|
|
605
|
+
id: 'forum1',
|
|
606
|
+
name: 'tasks',
|
|
607
|
+
type: ChannelType.GuildForum,
|
|
608
|
+
threads: {
|
|
609
|
+
fetchActive: vi.fn(async () => ({
|
|
610
|
+
threads: new Map([['t1', thread]]),
|
|
611
|
+
})),
|
|
612
|
+
fetchArchived: vi.fn(async () => ({ threads: new Map() })),
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
const guildChannelsCache = new Map([['forum1', forumChannel]]);
|
|
616
|
+
const guild = {
|
|
617
|
+
id: 'g1',
|
|
618
|
+
channels: {
|
|
619
|
+
cache: {
|
|
620
|
+
get: (id) => guildChannelsCache.get(id),
|
|
621
|
+
find: () => undefined,
|
|
622
|
+
values: () => guildChannelsCache.values(),
|
|
623
|
+
size: guildChannelsCache.size,
|
|
624
|
+
},
|
|
625
|
+
create: vi.fn(),
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
const client = {
|
|
629
|
+
channels: {
|
|
630
|
+
cache: { get: () => undefined },
|
|
631
|
+
fetch: vi.fn(async () => { throw new Error('Unknown'); }),
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
const ctx = { ...makeCtx(guild), client };
|
|
635
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
|
|
636
|
+
expect(result.ok).toBe(true);
|
|
637
|
+
expect(thread.edit).toHaveBeenCalledWith({ name: 'New Name' });
|
|
638
|
+
// Should NOT have called setArchived since the thread was active
|
|
639
|
+
expect(thread.setArchived).not.toHaveBeenCalled();
|
|
640
|
+
// fetchArchived checked first (archived is the common case), then active
|
|
641
|
+
expect(forumChannel.threads.fetchArchived).toHaveBeenCalled();
|
|
642
|
+
expect(forumChannel.threads.fetchActive).toHaveBeenCalled();
|
|
643
|
+
});
|
|
644
|
+
it('unarchives before editing and re-archives after', async () => {
|
|
645
|
+
const { thread, ctx } = makeThreadCtx({
|
|
646
|
+
threadId: 't1', threadName: 'Old Name', guildId: 'g1',
|
|
647
|
+
parentType: ChannelType.GuildForum, inCache: true,
|
|
648
|
+
});
|
|
649
|
+
thread.archived = true;
|
|
650
|
+
thread.setArchived = vi.fn(async () => { });
|
|
651
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
|
|
652
|
+
expect(result.ok).toBe(true);
|
|
653
|
+
expect(thread.setArchived).toHaveBeenCalledWith(false);
|
|
654
|
+
expect(thread.edit).toHaveBeenCalledWith({ name: 'New Name' });
|
|
655
|
+
expect(thread.setArchived).toHaveBeenCalledWith(true);
|
|
656
|
+
});
|
|
657
|
+
it('does not re-archive a non-archived thread after editing', async () => {
|
|
658
|
+
const { thread, ctx } = makeThreadCtx({
|
|
659
|
+
threadId: 't1', threadName: 'Old Name', guildId: 'g1',
|
|
660
|
+
parentType: ChannelType.GuildForum, inCache: true,
|
|
661
|
+
});
|
|
662
|
+
thread.archived = false;
|
|
663
|
+
thread.setArchived = vi.fn(async () => { });
|
|
664
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
|
|
665
|
+
expect(result.ok).toBe(true);
|
|
666
|
+
expect(thread.edit).toHaveBeenCalledWith({ name: 'New Name' });
|
|
667
|
+
expect(thread.setArchived).not.toHaveBeenCalled();
|
|
668
|
+
});
|
|
669
|
+
it('still edits when setArchived(false) throws', async () => {
|
|
670
|
+
const { thread, ctx } = makeThreadCtx({
|
|
671
|
+
threadId: 't1', threadName: 'Old Name', guildId: 'g1',
|
|
672
|
+
parentType: ChannelType.GuildForum, inCache: true,
|
|
673
|
+
});
|
|
674
|
+
thread.archived = true;
|
|
675
|
+
thread.setArchived = vi.fn(async (v) => {
|
|
676
|
+
if (!v)
|
|
677
|
+
throw new Error('MANAGE_THREADS required');
|
|
678
|
+
});
|
|
679
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
|
|
680
|
+
expect(result.ok).toBe(true);
|
|
681
|
+
expect(thread.edit).toHaveBeenCalledWith({ name: 'New Name' });
|
|
682
|
+
});
|
|
683
|
+
it('reports warning when re-archive fails', async () => {
|
|
684
|
+
const { thread, ctx } = makeThreadCtx({
|
|
685
|
+
threadId: 't1', threadName: 'Old Name', guildId: 'g1',
|
|
686
|
+
parentType: ChannelType.GuildForum, inCache: true,
|
|
687
|
+
});
|
|
688
|
+
thread.archived = true;
|
|
689
|
+
thread.setArchived = vi.fn(async (v) => {
|
|
690
|
+
if (v)
|
|
691
|
+
throw new Error('rate limited');
|
|
692
|
+
});
|
|
693
|
+
const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
|
|
694
|
+
expect(result.ok).toBe(true);
|
|
695
|
+
expect(result.summary).toContain('warning: failed to re-archive');
|
|
696
|
+
});
|
|
697
|
+
});
|