From 77a50db9ea2a8cc44b9ce19e85987093d8970bdb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 17:00:06 +0100 Subject: [PATCH] feat(qa): add Mantis Discord status reaction scenario (#76747) * feat(qa): add Mantis Discord status reaction scenario * fix(qa): retry Discord rate limits in Mantis runs * refactor(qa): reuse Discord API retry helper * fix(qa): import Discord API through package surface * fix(ci): generate Discord boundary declarations * fix(ci): keep xai boundary overrides stable --- .../mantis-discord-status-reactions.yml | 256 +++++++++ docs/concepts/mantis.md | 35 +- docs/concepts/qa-e2e-automation.md | 15 +- extensions/discord/api.ts | 1 + extensions/discord/src/api.test.ts | 21 +- extensions/discord/src/api.ts | 64 ++- extensions/qa-lab/package.json | 1 + .../live-transports/discord/cli.runtime.ts | 1 + .../discord/discord-live.runtime.test.ts | 177 +++++-- .../discord/discord-live.runtime.ts | 495 +++++++++++++++--- .../tsconfig.package-boundary.paths.json | 1 + pnpm-lock.yaml | 3 + scripts/lib/extension-package-boundary.ts | 2 + ...e-extension-package-boundary-artifacts.mjs | 44 ++ test/vitest/vitest.shared.config.ts | 4 + 15 files changed, 990 insertions(+), 130 deletions(-) create mode 100644 .github/workflows/mantis-discord-status-reactions.yml diff --git a/.github/workflows/mantis-discord-status-reactions.yml b/.github/workflows/mantis-discord-status-reactions.yml new file mode 100644 index 00000000000..7ae98725919 --- /dev/null +++ b/.github/workflows/mantis-discord-status-reactions.yml @@ -0,0 +1,256 @@ +name: Mantis Discord Status Reactions + +on: + workflow_dispatch: + inputs: + baseline_ref: + description: Ref, tag, or SHA expected to reproduce queued-only behavior + required: true + default: 0bf06e953fdda290799fc9fb9244a8f67fdae593 + type: string + candidate_ref: + description: Ref, tag, or SHA expected to show queued -> thinking -> done + required: true + default: main + type: string + +permissions: + contents: read + pull-requests: read + +concurrency: + group: mantis-discord-status-reactions-${{ inputs.baseline_ref }}-${{ inputs.candidate_ref }}-${{ github.run_attempt }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + NODE_VERSION: "24.x" + PNPM_VERSION: "10.33.0" + OPENCLAW_BUILD_PRIVATE_QA: "1" + OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" + +jobs: + authorize_actor: + name: Authorize workflow actor + runs-on: blacksmith-8vcpu-ubuntu-2404 + steps: + - name: Require maintainer-level repository access + uses: actions/github-script@v8 + with: + script: | + const allowed = new Set(["admin", "maintain", "write"]); + const { owner, repo } = context.repo; + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: context.actor, + }); + const permission = data.permission; + core.info(`Actor ${context.actor} permission: ${permission}`); + if (!allowed.has(permission)) { + core.setFailed( + `Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`, + ); + } + + validate_refs: + name: Validate selected refs + needs: authorize_actor + runs-on: blacksmith-8vcpu-ubuntu-2404 + outputs: + baseline_revision: ${{ steps.validate.outputs.baseline_revision }} + candidate_revision: ${{ steps.validate.outputs.candidate_revision }} + steps: + - name: Checkout harness ref + uses: actions/checkout@v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Validate refs are trusted + id: validate + env: + GH_TOKEN: ${{ github.token }} + BASELINE_REF: ${{ inputs.baseline_ref }} + CANDIDATE_REF: ${{ inputs.candidate_ref }} + shell: bash + run: | + set -euo pipefail + + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + + validate_ref() { + local label="$1" + local input_ref="$2" + local revision="" + local reason="" + + revision="$(git rev-parse "${input_ref}^{commit}")" + if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then + reason="main-ancestor" + elif git tag --points-at "$revision" | grep -Eq '^v'; then + reason="release-tag" + else + local pr_head_count + pr_head_count="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \ + --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length' + )" + if [[ "$pr_head_count" != "0" ]]; then + reason="open-pr-head" + fi + fi + + if [[ -z "$reason" ]]; then + echo "${label} ref '${input_ref}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run." >&2 + exit 1 + fi + + echo "${label}_revision=${revision}" >> "$GITHUB_OUTPUT" + { + echo "${label}: \`${input_ref}\`" + echo "${label} SHA: \`${revision}\`" + echo "${label} trust reason: \`${reason}\`" + } >> "$GITHUB_STEP_SUMMARY" + } + + validate_ref baseline "$BASELINE_REF" + validate_ref candidate "$CANDIDATE_REF" + + run_status_reactions: + name: Run Discord status reaction before/after + needs: validate_refs + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 180 + environment: qa-live-shared + steps: + - name: Checkout harness ref + uses: actions/checkout@v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "true" + + - name: Build Mantis harness + run: pnpm build + + - name: Prepare baseline and candidate worktrees + shell: bash + env: + BASELINE_SHA: ${{ needs.validate_refs.outputs.baseline_revision }} + CANDIDATE_SHA: ${{ needs.validate_refs.outputs.candidate_revision }} + run: | + set -euo pipefail + + mkdir -p .artifacts/qa-e2e/mantis/discord-status-reactions/worktrees + git worktree add --detach .artifacts/qa-e2e/mantis/discord-status-reactions/worktrees/baseline "$BASELINE_SHA" + git worktree add --detach .artifacts/qa-e2e/mantis/discord-status-reactions/worktrees/candidate "$CANDIDATE_SHA" + + for lane in baseline candidate; do + lane_dir=".artifacts/qa-e2e/mantis/discord-status-reactions/worktrees/${lane}" + echo "Installing ${lane} worktree dependencies" + pnpm --dir "$lane_dir" install --frozen-lockfile + echo "Building ${lane} worktree" + pnpm --dir "$lane_dir" build + done + + - name: Run baseline and candidate + id: run_mantis + shell: bash + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }} + OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} + OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1" + OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1" + run: | + set -euo pipefail + + require_var() { + local key="$1" + if [[ -z "${!key:-}" ]]; then + echo "Missing required ${key}." >&2 + exit 1 + fi + } + + require_var OPENAI_API_KEY + require_var OPENCLAW_QA_CONVEX_SITE_URL + require_var OPENCLAW_QA_CONVEX_SECRET_CI + + root=".artifacts/qa-e2e/mantis/discord-status-reactions" + mkdir -p "$root" + echo "output_dir=${root}" >> "$GITHUB_OUTPUT" + + run_lane() { + local lane="$1" + local repo_root="$root/worktrees/$lane" + local output_dir="$root/$lane" + pnpm openclaw qa discord \ + --repo-root "$repo_root" \ + --output-dir "$output_dir" \ + --provider-mode live-frontier \ + --model openai/gpt-5.4 \ + --alt-model openai/gpt-5.4 \ + --fast \ + --credential-source convex \ + --credential-role ci \ + --scenario discord-status-reactions-tool-only \ + --allow-failures + } + + run_lane baseline + run_lane candidate + + baseline_status="$(jq -r '.scenarios[0].status' "$root/baseline/discord-qa-summary.json")" + candidate_status="$(jq -r '.scenarios[0].status' "$root/candidate/discord-qa-summary.json")" + + jq -n \ + --arg baseline_status "$baseline_status" \ + --arg candidate_status "$candidate_status" \ + --arg baseline_sha "${{ needs.validate_refs.outputs.baseline_revision }}" \ + --arg candidate_sha "${{ needs.validate_refs.outputs.candidate_revision }}" \ + '{ + scenario: "discord-status-reactions-tool-only", + baseline: { sha: $baseline_sha, expected: "queued-only", status: $baseline_status, reproduced: ($baseline_status == "fail") }, + candidate: { sha: $candidate_sha, expected: "queued -> thinking -> done", status: $candidate_status, fixed: ($candidate_status == "pass") }, + pass: (($baseline_status == "fail") and ($candidate_status == "pass")) + }' > "$root/comparison.json" + + { + echo "# Mantis Discord Status Reactions" + echo + echo "- Scenario: \`discord-status-reactions-tool-only\`" + echo "- Baseline status: \`${baseline_status}\`" + echo "- Candidate status: \`${candidate_status}\`" + echo "- Baseline screenshot: \`baseline/discord-status-reactions-tool-only-timeline.png\`" + echo "- Candidate screenshot: \`candidate/discord-status-reactions-tool-only-timeline.png\`" + } > "$root/mantis-report.md" + + cat "$root/mantis-report.md" >> "$GITHUB_STEP_SUMMARY" + + if [[ "$baseline_status" != "fail" ]]; then + echo "Baseline did not reproduce queued-only behavior." >&2 + exit 1 + fi + if [[ "$candidate_status" != "pass" ]]; then + echo "Candidate did not show queued -> thinking -> done." >&2 + exit 1 + fi + + - name: Upload Mantis status reaction artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: mantis-discord-status-reactions-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.run_mantis.outputs.output_dir }} + retention-days: 14 + if-no-files-found: warn diff --git a/docs/concepts/mantis.md b/docs/concepts/mantis.md index c40a90f20d8..cdc8b7bc515 100644 --- a/docs/concepts/mantis.md +++ b/docs/concepts/mantis.md @@ -84,14 +84,16 @@ pnpm openclaw qa mantis run \ ``` The GitHub smoke workflow is `Mantis Discord Smoke`. The before and after GitHub -workflow should accept equivalent inputs: +workflow for the first real scenario is `Mantis Discord Status Reactions`. It +accepts: -- `transport`: `discord` for the first version. -- `scenario`: one or more scenario ids. -- `baseline_ref`: default `origin/main` or the linked issue's reported bad tag. -- `candidate_ref`: the PR head SHA. -- `machine_provider`: `aws` by default, with later `hetzner` fallback. -- `post_to_pr`: whether ClawSweeper should comment with the result. +- `baseline_ref`: the ref expected to reproduce queued-only behavior. +- `candidate_ref`: the ref expected to show `queued -> thinking -> done`. + +It checks out the workflow harness ref, builds separate baseline and candidate +worktrees, runs `discord-status-reactions-tool-only` against each worktree, and +uploads `baseline/`, `candidate/`, `comparison.json`, and `mantis-report.md` as +Actions artifacts. ClawSweeper command examples: @@ -179,6 +181,25 @@ lifecycle transition in tool-only mode. Candidate evidence should show lifecycle status reactions running when `messages.statusReactions.enabled` is explicitly true. +The executable first slice is the opt-in Discord live QA scenario: + +```bash +pnpm openclaw qa discord \ + --scenario discord-status-reactions-tool-only \ + --provider-mode live-frontier \ + --model openai/gpt-5.4 \ + --alt-model openai/gpt-5.4 \ + --fast \ + --output-dir .artifacts/qa-e2e/mantis/discord-status-reactions-candidate +``` + +It configures the SUT with always-on guild handling, `visibleReplies: +"message_tool"`, `ackReaction: "๐Ÿ‘€"`, and explicit status reactions. The oracle +polls the real Discord triggering message and expects the observed sequence +`๐Ÿ‘€ -> ๐Ÿค” -> ๐Ÿ‘`. Artifacts include `discord-qa-reaction-timelines.json`, +`discord-status-reactions-tool-only-timeline.html`, and +`discord-status-reactions-tool-only-timeline.png`. + ## Existing QA Pieces Mantis should build on the existing private QA stack instead of starting from diff --git a/docs/concepts/qa-e2e-automation.md b/docs/concepts/qa-e2e-automation.md index ebcbeda90d0..c6d5aeda9c5 100644 --- a/docs/concepts/qa-e2e-automation.md +++ b/docs/concepts/qa-e2e-automation.md @@ -225,7 +225,7 @@ Output artifacts: pnpm openclaw qa discord ``` -Targets one real private Discord guild channel with two bots: a driver bot controlled by the harness and a SUT bot started by the child OpenClaw gateway through the bundled Discord plugin. Verifies channel mention handling and that the SUT bot has registered the native `/help` command with Discord. +Targets one real private Discord guild channel with two bots: a driver bot controlled by the harness and a SUT bot started by the child OpenClaw gateway through the bundled Discord plugin. Verifies channel mention handling, that the SUT bot has registered the native `/help` command with Discord, and opt-in Mantis evidence scenarios. Required env when `--credential-source env`: @@ -244,12 +244,25 @@ Scenarios (`extensions/qa-lab/src/live-transports/discord/discord-live.runtime.t - `discord-canary` - `discord-mention-gating` - `discord-native-help-command-registration` +- `discord-status-reactions-tool-only` โ€” opt-in Mantis scenario. Runs by itself because it switches the SUT to always-on, tool-only guild replies with `messages.statusReactions.enabled=true`, then captures a REST reaction timeline plus an HTML/PNG visual artifact. + +Run the Mantis status-reaction scenario explicitly: + +```bash +pnpm openclaw qa discord \ + --scenario discord-status-reactions-tool-only \ + --provider-mode live-frontier \ + --model openai/gpt-5.4 \ + --alt-model openai/gpt-5.4 \ + --fast +``` Output artifacts: - `discord-qa-report.md` - `discord-qa-summary.json` - `discord-qa-observed-messages.json` โ€” bodies redacted unless `OPENCLAW_QA_DISCORD_CAPTURE_CONTENT=1`. +- `discord-qa-reaction-timelines.json` and `discord-status-reactions-tool-only-timeline.png` when the status-reaction scenario runs. ### Convex credential pool diff --git a/extensions/discord/api.ts b/extensions/discord/api.ts index ae957256410..603eb62141b 100644 --- a/extensions/discord/api.ts +++ b/extensions/discord/api.ts @@ -22,6 +22,7 @@ export { resolveDiscordMaxLinesPerMessage, } from "./src/accounts.js"; export { tryHandleDiscordMessageActionGuildAdmin } from "./src/actions/handle-action.guild-admin.js"; +export { DiscordApiError, fetchDiscord, requestDiscord } from "./src/api.js"; export { buildDiscordComponentMessage } from "./src/components.js"; type DiscordMessageActionHandler = typeof import("./src/channel-actions.runtime.js").handleDiscordMessageAction; diff --git a/extensions/discord/src/api.test.ts b/extensions/discord/src/api.test.ts index 28acd657e02..69d8a75bc81 100644 --- a/extensions/discord/src/api.test.ts +++ b/extensions/discord/src/api.test.ts @@ -1,6 +1,6 @@ import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DiscordApiError, fetchDiscord } from "./api.js"; +import { DiscordApiError, fetchDiscord, requestDiscord } from "./api.js"; import { jsonResponse } from "./test-http-helpers.js"; describe("fetchDiscord", () => { @@ -127,4 +127,23 @@ describe("fetchDiscord", () => { expect(result).toHaveLength(1); expect(calls).toBe(2); }); + + it("sends JSON request bodies through the shared retry helper", async () => { + let request: RequestInit | undefined; + const fetcher = withFetchPreconnect(async (_url, init) => { + request = init; + return jsonResponse({ id: "42" }, 200); + }); + + const result = await requestDiscord<{ id: string }>("/channels/c/messages", "test", { + body: { content: "hello" }, + fetcher, + retry: { attempts: 1 }, + }); + + expect(result).toEqual({ id: "42" }); + expect(request?.method).toBe("POST"); + expect(request?.body).toBe(JSON.stringify({ content: "hello" })); + expect(new Headers(request?.headers).get("content-type")).toBe("application/json"); + }); }); diff --git a/extensions/discord/src/api.ts b/extensions/discord/src/api.ts index 161dd9c75f6..6daa72751b3 100644 --- a/extensions/discord/src/api.ts +++ b/extensions/discord/src/api.ts @@ -126,13 +126,45 @@ type DiscordFetchOptions = { label?: string; }; -export async function fetchDiscord( +type DiscordApiRequestOptions = DiscordFetchOptions & { + body?: unknown; + fetcher?: typeof fetch; + headers?: Record; + method?: string; + signal?: AbortSignal; + timeoutMs?: number; +}; + +function normalizeDiscordRequestBody(body: unknown, headers: Headers): BodyInit | null | undefined { + if (body === undefined) { + return undefined; + } + if ( + typeof body === "string" || + body instanceof Blob || + body instanceof FormData || + body instanceof URLSearchParams || + body instanceof ArrayBuffer + ) { + return body; + } + headers.set("Content-Type", headers.get("Content-Type") ?? "application/json"); + return JSON.stringify(body); +} + +function resolveDiscordRequestSignal(options: DiscordApiRequestOptions) { + if (options.signal || typeof options.timeoutMs !== "number") { + return options.signal; + } + return AbortSignal.timeout(options.timeoutMs); +} + +export async function requestDiscord( path: string, token: string, - fetcher: typeof fetch = fetch, - options?: DiscordFetchOptions, + options?: DiscordApiRequestOptions, ): Promise { - const fetchImpl = resolveFetch(fetcher); + const fetchImpl = resolveFetch(options?.fetcher ?? fetch); if (!fetchImpl) { throw new Error("fetch is not available"); } @@ -140,11 +172,17 @@ export async function fetchDiscord( const retryConfig = resolveRetryConfig(DISCORD_API_RETRY_DEFAULTS, options?.retry); return retryAsync( async () => { + const headers = new Headers(options?.headers); + headers.set("Authorization", `Bot ${token}`); + const body = normalizeDiscordRequestBody(options?.body, headers); const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, { - headers: { Authorization: `Bot ${token}` }, + method: options?.method ?? (body === undefined ? "GET" : "POST"), + headers, + body, + signal: resolveDiscordRequestSignal(options ?? {}), }); + const text = await res.text().catch(() => ""); if (!res.ok) { - const text = await res.text().catch(() => ""); const detail = formatDiscordApiErrorText(text, res); const suffix = detail ? `: ${detail}` : ""; const retryAfter = @@ -157,7 +195,10 @@ export async function fetchDiscord( retryAfter, ); } - return (await res.json()) as T; + if (!text.trim()) { + return undefined as T; + } + return JSON.parse(text) as T; }, { ...retryConfig, @@ -167,3 +208,12 @@ export async function fetchDiscord( }, ); } + +export async function fetchDiscord( + path: string, + token: string, + fetcher: typeof fetch = fetch, + options?: DiscordFetchOptions, +): Promise { + return await requestDiscord(path, token, { ...options, fetcher, method: "GET" }); +} diff --git a/extensions/qa-lab/package.json b/extensions/qa-lab/package.json index 1f86f6d69ce..133dcce4d17 100644 --- a/extensions/qa-lab/package.json +++ b/extensions/qa-lab/package.json @@ -12,6 +12,7 @@ "zod": "^4.4.1" }, "devDependencies": { + "@openclaw/discord": "workspace:*", "@openclaw/plugin-sdk": "workspace:*", "openclaw": "workspace:*" }, diff --git a/extensions/qa-lab/src/live-transports/discord/cli.runtime.ts b/extensions/qa-lab/src/live-transports/discord/cli.runtime.ts index 4ebbd8d4039..bb059b15f32 100644 --- a/extensions/qa-lab/src/live-transports/discord/cli.runtime.ts +++ b/extensions/qa-lab/src/live-transports/discord/cli.runtime.ts @@ -12,6 +12,7 @@ export async function runQaDiscordCommand(opts: LiveTransportQaCommandOptions) { report: result.reportPath, summary: result.summaryPath, "observed messages": result.observedMessagesPath, + ...(result.reactionTimelinesPath ? { "reaction timelines": result.reactionTimelinesPath } : {}), ...(result.gatewayDebugDirPath ? { "gateway debug logs": result.gatewayDebugDirPath } : {}), }); if ( diff --git a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.test.ts index 8a02caea2e9..e1ccc2f26f6 100644 --- a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.test.ts @@ -6,29 +6,8 @@ import { } from "../shared/live-transport-scenarios.js"; import { __testing } from "./discord-live.runtime.js"; -const fetchWithSsrFGuardMock = vi.hoisted(() => - vi.fn(async (params: { url: string; init?: RequestInit; signal?: AbortSignal }) => ({ - response: await fetch(params.url, { - ...params.init, - signal: params.signal, - }), - release: async () => {}, - })), -); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/ssrf-runtime", - ); - return { - ...actual, - fetchWithSsrFGuard: fetchWithSsrFGuardMock, - }; -}); - describe("discord live qa runtime", () => { afterEach(() => { - fetchWithSsrFGuardMock.mockClear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -162,6 +141,47 @@ describe("discord live qa runtime", () => { }); }); + it("injects tool-only Discord status reaction config for the Mantis scenario", () => { + const next = __testing.buildDiscordQaConfig( + {}, + { + guildId: "123456789012345678", + channelId: "223456789012345678", + driverBotId: "423456789012345678", + sutAccountId: "sut", + sutBotToken: "sut-token", + }, + { statusReactionsToolOnly: true }, + ); + + expect(next.messages).toMatchObject({ + ackReaction: "๐Ÿ‘€", + ackReactionScope: "all", + groupChat: { visibleReplies: "message_tool" }, + statusReactions: { + enabled: true, + timing: { debounceMs: 0 }, + }, + }); + expect(next.channels?.discord).toMatchObject({ + accounts: { + sut: { + allowBots: true, + guilds: { + "123456789012345678": { + requireMention: false, + channels: { + "223456789012345678": { + requireMention: false, + }, + }, + }, + }, + }, + }, + }); + }); + it("normalizes observed Discord messages", () => { expect( __testing.normalizeDiscordObservedMessage({ @@ -227,6 +247,80 @@ describe("discord live qa runtime", () => { "discord-mention-gating", "discord-native-help-command-registration", ]); + expect( + __testing.findScenario(["discord-status-reactions-tool-only"]).map((scenario) => scenario.id), + ).toEqual(["discord-status-reactions-tool-only"]); + }); + + it("collects the status reaction sequence across timeline snapshots", () => { + expect( + __testing.collectSeenReactionSequence( + [ + { + elapsedMs: 0, + observedAt: "2026-05-03T12:00:00.000Z", + reactions: [{ emoji: "๐Ÿ‘€", count: 1, me: true }], + }, + { + elapsedMs: 250, + observedAt: "2026-05-03T12:00:00.250Z", + reactions: [ + { emoji: "๐Ÿ‘€", count: 1, me: true }, + { emoji: "๐Ÿค”", count: 1, me: true }, + ], + }, + { + elapsedMs: 500, + observedAt: "2026-05-03T12:00:00.500Z", + reactions: [{ emoji: "๐Ÿ‘", count: 1, me: true }], + }, + ], + ["๐Ÿ‘€", "๐Ÿค”", "๐Ÿ‘"], + ), + ).toEqual(["๐Ÿ‘€", "๐Ÿค”", "๐Ÿ‘"]); + }); + + it("normalizes reaction snapshots from Discord messages", () => { + expect( + __testing.normalizeDiscordReactionSnapshot({ + startedAtMs: new Date("2026-05-03T12:00:00.000Z").getTime(), + observedAt: new Date("2026-05-03T12:00:01.000Z"), + message: { + id: "523456789012345678", + channel_id: "223456789012345678", + reactions: [ + { count: 1, emoji: { name: "๐Ÿค”" }, me: true }, + { count: 2, emoji: { name: "๐Ÿ‘€" }, me: false }, + ], + }, + }), + ).toEqual({ + elapsedMs: 1000, + observedAt: "2026-05-03T12:00:01.000Z", + reactions: [ + { emoji: "๐Ÿ‘€", count: 2, me: false }, + { emoji: "๐Ÿค”", count: 1, me: true }, + ], + }); + }); + + it("renders a human-readable status reaction timeline artifact", () => { + const html = __testing.renderDiscordStatusReactionHtml({ + scenarioTitle: "Discord status reactions", + expectedSequence: ["๐Ÿ‘€", "๐Ÿค”", "๐Ÿ‘"], + seenSequence: ["๐Ÿ‘€", "๐Ÿค”"], + snapshots: [ + { + elapsedMs: 0, + observedAt: "2026-05-03T12:00:00.000Z", + reactions: [{ emoji: "๐Ÿ‘€", count: 1, me: true }], + }, + ], + }); + + expect(html).toContain("Discord status reactions"); + expect(html).toContain("Expected: ๐Ÿ‘€ โ†’ ๐Ÿค” โ†’ ๐Ÿ‘"); + expect(html).toContain("Seen: ๐Ÿ‘€ โ†’ ๐Ÿค”"); }); it("waits for the Discord account to become connected, not just running", async () => { @@ -387,7 +481,7 @@ describe("discord live qa runtime", () => { } }); - it("adds an abort deadline to Discord API requests", async () => { + it("uses the Discord API helper timeout for identity probes", async () => { const controller = new AbortController(); const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); let signal: AbortSignal | undefined; @@ -404,22 +498,45 @@ describe("discord live qa runtime", () => { }), ); - await expect( - __testing.callDiscordApi({ - token: "token", - path: "/users/@me", - timeoutMs: 25, - }), - ).resolves.toEqual({ + await expect(__testing.getCurrentDiscordUser("token")).resolves.toEqual({ id: "423456789012345678", }); - expect(timeoutSpy).toHaveBeenCalledWith(25); + expect(timeoutSpy).toHaveBeenCalledWith(15_000); expect(signal).toBe(controller.signal); expect(signal?.aborted).toBe(false); controller.abort(); expect(signal?.aborted).toBe(true); }); + it("retries Discord REST requests after a 429 rate limit", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: "You are being rate limited.", retry_after: 0 }), { + status: 429, + headers: { + "content-type": "application/json", + }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: "423456789012345678" }), { + status: 200, + headers: { + "content-type": "application/json", + }, + }), + ), + ); + + await expect(__testing.getCurrentDiscordUser("token")).resolves.toEqual({ + id: "423456789012345678", + }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + it("redacts observed message content by default in artifacts", () => { expect( __testing.buildObservedMessagesArtifact({ diff --git a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts index 2174655e073..3a1a31648b1 100644 --- a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts @@ -1,9 +1,12 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { requestDiscord } from "@openclaw/discord/api.js"; +import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { chromium } from "playwright-core"; import { z } from "zod"; import { startQaGatewayChild } from "../../gateway-child.js"; import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js"; @@ -36,7 +39,8 @@ type DiscordQaRuntimeEnv = { type DiscordQaScenarioId = | "discord-canary" | "discord-mention-gating" - | "discord-native-help-command-registration"; + | "discord-native-help-command-registration" + | "discord-status-reactions-tool-only"; type DiscordQaScenarioRun = | { @@ -49,6 +53,11 @@ type DiscordQaScenarioRun = | { kind: "application-command-registration"; expectedCommandNames: string[]; + } + | { + kind: "status-reactions-tool-only"; + expectedSequence: string[]; + input: string; }; type DiscordQaScenarioDefinition = LiveTransportScenarioDefinition & { @@ -66,11 +75,21 @@ type DiscordMessage = { channel_id: string; guild_id?: string; content?: string; + reactions?: DiscordReaction[]; timestamp?: string; author?: DiscordUser; referenced_message?: { id?: string } | null; }; +type DiscordReaction = { + count?: number; + emoji?: { + id?: string | null; + name?: string | null; + }; + me?: boolean; +}; + type DiscordApplicationCommand = { id: string; name?: string; @@ -107,6 +126,7 @@ type DiscordObservedMessageArtifact = { }; type DiscordQaScenarioResult = { + artifactPaths?: Record; id: string; title: string; status: "pass" | "fail"; @@ -116,6 +136,7 @@ type DiscordQaScenarioResult = { type DiscordQaRunResult = { outputDir: string; reportPath: string; + reactionTimelinesPath?: string; summaryPath: string; observedMessagesPath: string; gatewayDebugDirPath?: string; @@ -123,6 +144,12 @@ type DiscordQaRunResult = { }; type DiscordQaSummary = { + artifacts: { + observedMessagesPath: string; + reactionTimelinesPath?: string; + reportPath: string; + summaryPath: string; + }; credentials: { credentialId?: string; kind: string; @@ -143,7 +170,28 @@ type DiscordQaSummary = { scenarios: DiscordQaScenarioResult[]; }; -const DISCORD_API_BASE_URL = "https://discord.com/api/v10"; +type DiscordReactionSnapshot = { + elapsedMs: number; + observedAt: string; + reactions: Array<{ + count: number; + emoji: string; + me: boolean; + }>; +}; + +type DiscordStatusReactionTimeline = { + expectedSequence: string[]; + htmlPath?: string; + scenarioId: DiscordQaScenarioId; + scenarioTitle: string; + screenshotPath?: string; + screenshotWarning?: string; + seenSequence: string[]; + snapshots: DiscordReactionSnapshot[]; + triggerMessageId: string; +}; + const DISCORD_QA_CAPTURE_CONTENT_ENV = "OPENCLAW_QA_DISCORD_CAPTURE_CONTENT"; const QA_REDACT_PUBLIC_METADATA_ENV = "OPENCLAW_QA_REDACT_PUBLIC_METADATA"; const DISCORD_QA_ENV_KEYS = [ @@ -195,8 +243,29 @@ const DISCORD_QA_SCENARIOS: DiscordQaScenarioDefinition[] = [ expectedCommandNames: ["help"], }), }, + { + id: "discord-status-reactions-tool-only", + title: "Discord explicit status reactions run in tool-only reply mode", + timeoutMs: 75_000, + buildRun: () => { + const token = `DISCORD_QA_STATUS_${randomUUID().slice(0, 8).toUpperCase()}`; + return { + kind: "status-reactions-tool-only", + input: [ + `Mantis status reaction QA marker ${token}.`, + "Think briefly, then reply with only this exact marker:", + token, + ].join(" "), + expectedSequence: ["๐Ÿ‘€", DEFAULT_EMOJIS.thinking, DEFAULT_EMOJIS.done], + }; + }, + }, ]; +const DISCORD_QA_DEFAULT_SCENARIOS = DISCORD_QA_SCENARIOS.filter( + (scenario) => scenario.id !== "discord-status-reactions-tool-only", +); + const DISCORD_QA_STANDARD_SCENARIO_IDS = collectLiveTransportStandardScenarioCoverage({ scenarios: DISCORD_QA_SCENARIOS, }); @@ -272,12 +341,41 @@ function buildDiscordQaConfig( sutAccountId: string; sutBotToken: string; }, + options: { + statusReactionsToolOnly?: boolean; + } = {}, ): OpenClawConfig { const pluginAllow = [...new Set([...(baseCfg.plugins?.allow ?? []), "discord"])]; const pluginEntries = { ...baseCfg.plugins?.entries, discord: { enabled: true }, }; + const requireMention = !options.statusReactionsToolOnly; + const messages = options.statusReactionsToolOnly + ? { + ...baseCfg.messages, + ackReaction: "๐Ÿ‘€", + ackReactionScope: "all" as const, + groupChat: { + ...baseCfg.messages?.groupChat, + visibleReplies: "message_tool" as const, + }, + statusReactions: { + ...baseCfg.messages?.statusReactions, + enabled: true, + timing: { + ...baseCfg.messages?.statusReactions?.timing, + debounceMs: 0, + }, + }, + } + : { + ...baseCfg.messages, + groupChat: { + ...baseCfg.messages?.groupChat, + visibleReplies: "automatic" as const, + }, + }; return { ...baseCfg, plugins: { @@ -285,13 +383,7 @@ function buildDiscordQaConfig( allow: pluginAllow, entries: pluginEntries, }, - messages: { - ...baseCfg.messages, - groupChat: { - ...baseCfg.messages?.groupChat, - visibleReplies: "automatic", - }, - }, + messages, channels: { ...baseCfg.channels, discord: { @@ -301,16 +393,16 @@ function buildDiscordQaConfig( [params.sutAccountId]: { enabled: true, token: params.sutBotToken, - allowBots: "mentions", + allowBots: options.statusReactionsToolOnly ? true : "mentions", groupPolicy: "allowlist", guilds: { [params.guildId]: { - requireMention: true, + requireMention, users: [params.driverBotId], channels: { [params.channelId]: { enabled: true, - requireMention: true, + requireMention, users: [params.driverBotId], }, }, @@ -323,70 +415,34 @@ function buildDiscordQaConfig( }; } -async function callDiscordApi(params: { - token: string; - path: string; - init?: RequestInit; - timeoutMs?: number; -}): Promise { - const headers = new Headers(params.init?.headers); - headers.set("authorization", `Bot ${params.token}`); - if (params.init?.body) { - headers.set("content-type", "application/json"); - } - const { response, release } = await fetchWithSsrFGuard({ - url: `${DISCORD_API_BASE_URL}${params.path}`, - init: { - ...params.init, - headers, - }, - signal: AbortSignal.timeout(params.timeoutMs ?? 15_000), - policy: { hostnameAllowlist: ["discord.com"] }, - auditContext: "qa-lab-discord-live", - }); - try { - const text = await response.text(); - const payload = text.trim() ? (JSON.parse(text) as unknown) : undefined; - if (!response.ok) { - const message = - typeof payload === "object" && - payload !== null && - typeof (payload as { message?: unknown }).message === "string" - ? (payload as { message: string }).message - : text.trim(); - throw new Error( - message || `Discord API ${params.path} failed with status ${response.status}`, - ); - } - return payload as T; - } finally { - await release(); - } -} - async function getCurrentDiscordUser(token: string) { - return await callDiscordApi({ - token, - path: "/users/@me", + return await requestDiscord("/users/@me", token, { + timeoutMs: 15_000, }); } async function sendChannelMessage(token: string, channelId: string, content: string) { - return await callDiscordApi({ - token, - path: `/channels/${channelId}/messages`, - init: { - method: "POST", - body: JSON.stringify({ - content, - allowed_mentions: { - parse: ["users"], - }, - }), + return await requestDiscord(`/channels/${channelId}/messages`, token, { + body: { + content, + allowed_mentions: { + parse: ["users"], + }, }, + timeoutMs: 15_000, }); } +async function getChannelMessage(params: { token: string; channelId: string; messageId: string }) { + return await requestDiscord( + `/channels/${params.channelId}/messages/${params.messageId}`, + params.token, + { + timeoutMs: 15_000, + }, + ); +} + async function listChannelMessagesAfter(params: { token: string; channelId: string; @@ -396,17 +452,215 @@ async function listChannelMessagesAfter(params: { after: params.afterSnowflake, limit: "50", }); - return await callDiscordApi({ - token: params.token, - path: `/channels/${params.channelId}/messages?${query.toString()}`, + return await requestDiscord( + `/channels/${params.channelId}/messages?${query.toString()}`, + params.token, + { + timeoutMs: 15_000, + }, + ); +} + +function reactionEmojiName(reaction: DiscordReaction) { + return reaction.emoji?.name?.trim() || reaction.emoji?.id?.trim() || ""; +} + +function normalizeDiscordReactionSnapshot(params: { + message: DiscordMessage; + observedAt: Date; + startedAtMs: number; +}): DiscordReactionSnapshot { + return { + elapsedMs: Math.max(0, params.observedAt.getTime() - params.startedAtMs), + observedAt: params.observedAt.toISOString(), + reactions: (params.message.reactions ?? []) + .map((reaction) => ({ + emoji: reactionEmojiName(reaction), + count: Math.max(0, Math.floor(reaction.count ?? 0)), + me: reaction.me === true, + })) + .filter((reaction) => reaction.emoji.length > 0) + .toSorted((a, b) => a.emoji.localeCompare(b.emoji)), + }; +} + +function collectSeenReactionSequence( + snapshots: readonly DiscordReactionSnapshot[], + expectedSequence: readonly string[], +) { + const seen = new Set(); + const sequence: string[] = []; + for (const snapshot of snapshots) { + const snapshotEmojis = new Set(snapshot.reactions.map((reaction) => reaction.emoji)); + for (const emoji of expectedSequence) { + if (snapshotEmojis.has(emoji) && !seen.has(emoji)) { + seen.add(emoji); + sequence.push(emoji); + } + } + } + return sequence; +} + +function escapeHtml(value: string) { + return value + .replace(/&/gu, "&") + .replace(//gu, ">") + .replace(/"/gu, """); +} + +function renderDiscordStatusReactionHtml(params: { + expectedSequence: readonly string[]; + scenarioTitle: string; + seenSequence: readonly string[]; + snapshots: readonly DiscordReactionSnapshot[]; +}) { + const rows = params.snapshots + .map((snapshot) => { + const reactions = snapshot.reactions + .map( + (reaction) => + `${escapeHtml(reaction.emoji)}${reaction.count}`, + ) + .join(""); + return `${snapshot.elapsedMs}ms${escapeHtml(snapshot.observedAt)}${reactions || 'none'}`; + }) + .join("\n"); + return ` + + + + ${escapeHtml(params.scenarioTitle)} + + + +
+

${escapeHtml(params.scenarioTitle)}

+
Expected: ${params.expectedSequence.map(escapeHtml).join(" โ†’ ")} ยท Seen: ${params.seenSequence.map(escapeHtml).join(" โ†’ ") || "none"}
+
+
Mantis Discord QA
+
Reaction timeline captured from the real Discord triggering message via REST polling.
+
+ ${params.expectedSequence + .map( + (emoji) => + `${escapeHtml(emoji)}`, + ) + .join("")} +
+
+ + + ${rows} +
ElapsedObserved AtReactions
+
+ +`; +} + +async function writeDiscordStatusReactionEvidence(params: { + outputDir: string; + timeline: DiscordStatusReactionTimeline; +}) { + const htmlPath = path.join(params.outputDir, `${params.timeline.scenarioId}-timeline.html`); + const screenshotPath = path.join(params.outputDir, `${params.timeline.scenarioId}-timeline.png`); + const html = renderDiscordStatusReactionHtml({ + expectedSequence: params.timeline.expectedSequence, + scenarioTitle: params.timeline.scenarioTitle, + seenSequence: params.timeline.seenSequence, + snapshots: params.timeline.snapshots, }); + await fs.writeFile(htmlPath, html, { encoding: "utf8", mode: 0o600 }); + try { + const browser = await chromium.launch({ + channel: "chrome", + headless: true, + }); + try { + const page = await browser.newPage({ viewport: { width: 1104, height: 760 } }); + await page.goto(pathToFileURL(htmlPath).toString(), { + waitUntil: "domcontentloaded", + timeout: 15_000, + }); + await page.screenshot({ path: screenshotPath, fullPage: true }); + return { htmlPath, screenshotPath }; + } finally { + await browser.close(); + } + } catch (error) { + return { htmlPath, screenshotWarning: formatErrorMessage(error) }; + } +} + +async function observeStatusReactionTimeline(params: { + channelId: string; + expectedSequence: string[]; + messageId: string; + scenarioId: DiscordQaScenarioId; + scenarioTitle: string; + timeoutMs: number; + token: string; +}) { + const startedAtMs = Date.now(); + const snapshots: DiscordReactionSnapshot[] = []; + let seenSequence: string[] = []; + while (Date.now() - startedAtMs < params.timeoutMs) { + const observedAt = new Date(); + const message = await getChannelMessage({ + token: params.token, + channelId: params.channelId, + messageId: params.messageId, + }); + snapshots.push( + normalizeDiscordReactionSnapshot({ + message, + observedAt, + startedAtMs, + }), + ); + seenSequence = collectSeenReactionSequence(snapshots, params.expectedSequence); + if (params.expectedSequence.every((emoji) => seenSequence.includes(emoji))) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return { + expectedSequence: params.expectedSequence, + scenarioId: params.scenarioId, + scenarioTitle: params.scenarioTitle, + seenSequence, + snapshots, + triggerMessageId: params.messageId, + } satisfies DiscordStatusReactionTimeline; } async function listApplicationCommands(params: { token: string; applicationId: string }) { - return await callDiscordApi({ - token: params.token, - path: `/applications/${params.applicationId}/commands`, - }); + return await requestDiscord( + `/applications/${params.applicationId}/commands`, + params.token, + { + timeoutMs: 15_000, + }, + ); } function compareDiscordSnowflakes(a: string, b: string) { @@ -566,6 +820,11 @@ function renderDiscordQaMarkdown(params: { lines.push(""); lines.push(`- Status: ${scenario.status}`); lines.push(`- Details: ${scenario.details}`); + if (scenario.artifactPaths && Object.keys(scenario.artifactPaths).length > 0) { + for (const [label, artifactPath] of Object.entries(scenario.artifactPaths)) { + lines.push(`- ${label}: \`${artifactPath}\``); + } + } lines.push(""); } if (params.gatewayDebugDirPath) { @@ -625,10 +884,11 @@ function buildObservedMessagesArtifact(params: { } function findScenario(ids?: string[]) { + const scenarios = ids && ids.length > 0 ? DISCORD_QA_SCENARIOS : DISCORD_QA_DEFAULT_SCENARIOS; return selectLiveTransportScenarios({ ids, laneLabel: "Discord", - scenarios: DISCORD_QA_SCENARIOS, + scenarios, }); } @@ -717,6 +977,14 @@ export async function runDiscordQaLive(params: { const alternateModel = params.alternateModel?.trim() || defaultQaModelForMode(providerMode, true); const sutAccountId = params.sutAccountId?.trim() || "sut"; const scenarios = findScenario(params.scenarioIds); + const statusReactionScenarioRequested = scenarios.some( + (scenario) => scenario.id === "discord-status-reactions-tool-only", + ); + if (statusReactionScenarioRequested && scenarios.length > 1) { + throw new Error( + "discord-status-reactions-tool-only must run by itself because it changes Discord tool-only reply config.", + ); + } const credentialLease = await acquireQaCredentialLease({ kind: "discord", @@ -732,6 +1000,7 @@ export async function runDiscordQaLive(params: { const runtimeEnv = credentialLease.payload; const observedMessages: DiscordObservedMessage[] = []; + const reactionTimelines: DiscordStatusReactionTimeline[] = []; const redactPublicMetadata = isTruthyOptIn(process.env[QA_REDACT_PUBLIC_METADATA_ENV]); const includeObservedMessageContent = isTruthyOptIn(process.env[DISCORD_QA_CAPTURE_CONTENT_ENV]); const startedAt = new Date().toISOString(); @@ -766,13 +1035,17 @@ export async function runDiscordQaLive(params: { fastMode: params.fastMode, controlUiEnabled: false, mutateConfig: (cfg) => - buildDiscordQaConfig(cfg, { - guildId: runtimeEnv.guildId, - channelId: runtimeEnv.channelId, - driverBotId: driverIdentity.id, - sutAccountId, - sutBotToken: runtimeEnv.sutBotToken, - }), + buildDiscordQaConfig( + cfg, + { + guildId: runtimeEnv.guildId, + channelId: runtimeEnv.channelId, + driverBotId: driverIdentity.id, + sutAccountId, + sutBotToken: runtimeEnv.sutBotToken, + }, + { statusReactionsToolOnly: statusReactionScenarioRequested }, + ), }); try { await waitForDiscordChannelRunning(gatewayHarness.gateway, sutAccountId); @@ -803,6 +1076,39 @@ export async function runDiscordQaLive(params: { runtimeEnv.channelId, scenarioRun.input, ); + if (scenarioRun.kind === "status-reactions-tool-only") { + const timeline = await observeStatusReactionTimeline({ + token: runtimeEnv.driverBotToken, + channelId: runtimeEnv.channelId, + expectedSequence: scenarioRun.expectedSequence, + messageId: sent.id, + scenarioId: scenario.id, + scenarioTitle: scenario.title, + timeoutMs: scenario.timeoutMs, + }); + const evidence = await writeDiscordStatusReactionEvidence({ outputDir, timeline }); + const enrichedTimeline = { ...timeline, ...evidence }; + reactionTimelines.push(enrichedTimeline); + const missing = scenarioRun.expectedSequence.filter( + (emoji) => !timeline.seenSequence.includes(emoji), + ); + scenarioResults.push({ + id: scenario.id, + title: scenario.title, + status: missing.length === 0 ? "pass" : "fail", + details: + missing.length === 0 + ? `reaction timeline matched ${timeline.seenSequence.join(" -> ")}` + : `reaction timeline missing ${missing.join(", ")}; saw ${timeline.seenSequence.join(" -> ") || "none"}`, + artifactPaths: { + ...(enrichedTimeline.htmlPath ? { html: enrichedTimeline.htmlPath } : {}), + ...(enrichedTimeline.screenshotPath + ? { screenshot: enrichedTimeline.screenshotPath } + : {}), + }, + }); + continue; + } const matched = await pollChannelMessages({ token: runtimeEnv.driverBotToken, channelId: runtimeEnv.channelId, @@ -885,6 +1191,14 @@ export async function runDiscordQaLive(params: { const passedCount = scenarioResults.filter((entry) => entry.status === "pass").length; const failedCount = scenarioResults.filter((entry) => entry.status === "fail").length; const summary: DiscordQaSummary = { + artifacts: { + reportPath: path.join(outputDir, "discord-qa-report.md"), + summaryPath: path.join(outputDir, "discord-qa-summary.json"), + observedMessagesPath: path.join(outputDir, "discord-qa-observed-messages.json"), + ...(reactionTimelines.length > 0 + ? { reactionTimelinesPath: path.join(outputDir, "discord-qa-reaction-timelines.json") } + : {}), + }, credentials: { source: credentialLease.source, kind: credentialLease.kind, @@ -907,6 +1221,7 @@ export async function runDiscordQaLive(params: { const reportPath = path.join(outputDir, "discord-qa-report.md"); const summaryPath = path.join(outputDir, "discord-qa-summary.json"); const observedMessagesPath = path.join(outputDir, "discord-qa-observed-messages.json"); + const reactionTimelinesPath = path.join(outputDir, "discord-qa-reaction-timelines.json"); await fs.writeFile( reportPath, `${renderDiscordQaMarkdown({ @@ -939,10 +1254,17 @@ export async function runDiscordQaLive(params: { )}\n`, { encoding: "utf8", mode: 0o600 }, ); + if (reactionTimelines.length > 0) { + await fs.writeFile(reactionTimelinesPath, `${JSON.stringify(reactionTimelines, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + } const artifactPaths = { report: reportPath, summary: summaryPath, observedMessages: observedMessagesPath, + ...(reactionTimelines.length > 0 ? { reactionTimelines: reactionTimelinesPath } : {}), ...(preservedGatewayDebugArtifacts ? { gatewayDebug: gatewayDebugDirPath } : {}), }; if (cleanupIssues.length > 0) { @@ -958,6 +1280,7 @@ export async function runDiscordQaLive(params: { return { outputDir, reportPath, + ...(reactionTimelines.length > 0 ? { reactionTimelinesPath } : {}), summaryPath, observedMessagesPath, ...(preservedGatewayDebugArtifacts ? { gatewayDebugDirPath } : {}), @@ -968,16 +1291,20 @@ export async function runDiscordQaLive(params: { export const __testing = { DISCORD_QA_SCENARIOS, DISCORD_QA_STANDARD_SCENARIO_IDS, + collectSeenReactionSequence, assertDiscordScenarioReply, assertDiscordApplicationCommandsRegistered, buildDiscordQaConfig, buildObservedMessagesArtifact, - callDiscordApi, findScenario, + getCurrentDiscordUser, + getChannelMessage, listApplicationCommands, matchesDiscordScenarioReply, + normalizeDiscordReactionSnapshot, normalizeDiscordObservedMessage, parseDiscordQaCredentialPayload, + renderDiscordStatusReactionHtml, resolveDiscordQaRuntimeEnv, waitForDiscordChannelRunning, }; diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index c0448ba22ef..1cb6134ae1b 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -36,6 +36,7 @@ ], "openclaw/plugin-sdk/ssrf-runtime": ["../dist/plugin-sdk/src/plugin-sdk/ssrf-runtime.d.ts"], "@openclaw/qa-channel/api.js": ["../dist/plugin-sdk/extensions/qa-channel/api.d.ts"], + "@openclaw/discord/api.js": ["../dist/plugin-sdk/extensions/discord/api.d.ts"], "@openclaw/*.js": ["../packages/plugin-sdk/dist/extensions/*.d.ts", "../extensions/*"], "@openclaw/*": ["../packages/plugin-sdk/dist/extensions/*", "../extensions/*"], "@openclaw/plugin-sdk/*": ["../dist/plugin-sdk/src/plugin-sdk/*.d.ts"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14913eedc30..4481d9697d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1189,6 +1189,9 @@ importers: specifier: ^4.4.1 version: 4.4.1 devDependencies: + '@openclaw/discord': + specifier: workspace:* + version: link:../discord '@openclaw/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk diff --git a/scripts/lib/extension-package-boundary.ts b/scripts/lib/extension-package-boundary.ts index d084ee6d623..0851b7f9a50 100644 --- a/scripts/lib/extension-package-boundary.ts +++ b/scripts/lib/extension-package-boundary.ts @@ -46,6 +46,7 @@ export const EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS = { ], "openclaw/plugin-sdk/ssrf-runtime": ["../dist/plugin-sdk/src/plugin-sdk/ssrf-runtime.d.ts"], "@openclaw/qa-channel/api.js": ["../dist/plugin-sdk/extensions/qa-channel/api.d.ts"], + "@openclaw/discord/api.js": ["../dist/plugin-sdk/extensions/discord/api.d.ts"], "@openclaw/*.js": ["../packages/plugin-sdk/dist/extensions/*.d.ts", "../extensions/*"], "@openclaw/*": ["../packages/plugin-sdk/dist/extensions/*", "../extensions/*"], "@openclaw/plugin-sdk/*": ["../dist/plugin-sdk/src/plugin-sdk/*.d.ts"], @@ -68,6 +69,7 @@ export const EXTENSION_PACKAGE_BOUNDARY_XAI_PATHS = { (({ "openclaw/plugin-sdk/channel-secret-basic-runtime": _omitBasic, "openclaw/plugin-sdk/channel-secret-tts-runtime": _omitTts, + "@openclaw/discord/api.js": _omitDiscord, ...rest }) => rest)(EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS), "../", diff --git a/scripts/prepare-extension-package-boundary-artifacts.mjs b/scripts/prepare-extension-package-boundary-artifacts.mjs index 1cc16e95e3e..24583a654d5 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mjs +++ b/scripts/prepare-extension-package-boundary-artifacts.mjs @@ -45,6 +45,13 @@ const QA_CHANNEL_DTS_INPUTS = [ ]; const QA_CHANNEL_DTS_STAMP = "dist/plugin-sdk/extensions/qa-channel/.boundary-dts.stamp"; const QA_CHANNEL_DTS_REQUIRED_OUTPUTS = ["dist/plugin-sdk/extensions/qa-channel/api.d.ts"]; +const DISCORD_DTS_INPUTS = [ + "extensions/discord/api.ts", + "extensions/discord/src/api.ts", + "extensions/discord/tsconfig.json", +]; +const DISCORD_DTS_STAMP = "dist/plugin-sdk/extensions/discord/.boundary-dts.stamp"; +const DISCORD_DTS_REQUIRED_OUTPUTS = ["dist/plugin-sdk/extensions/discord/api.d.ts"]; const ENTRY_SHIMS_INPUTS = [ "scripts/write-plugin-sdk-entry-dts.ts", "scripts/lib/plugin-sdk-entrypoints.json", @@ -290,6 +297,12 @@ async function main(argv = process.argv.slice(2)) { outputPaths: [QA_CHANNEL_DTS_STAMP, ...QA_CHANNEL_DTS_REQUIRED_OUTPUTS], includeFile: isRelevantTypeInput, }) && !hasMissingOutput(QA_CHANNEL_DTS_REQUIRED_OUTPUTS); + const discordDtsFresh = + isArtifactSetFresh({ + inputPaths: DISCORD_DTS_INPUTS, + outputPaths: [DISCORD_DTS_STAMP, ...DISCORD_DTS_REQUIRED_OUTPUTS], + includeFile: isRelevantTypeInput, + }) && !hasMissingOutput(DISCORD_DTS_REQUIRED_OUTPUTS); const prerequisiteSteps = []; const dependentSteps = []; @@ -357,6 +370,37 @@ async function main(argv = process.argv.slice(2)) { } else { process.stdout.write("[qa-channel boundary dts] fresh; skipping\n"); } + if (!discordDtsFresh) { + removeIncrementalStateForMissingOutput({ + outputPaths: DISCORD_DTS_REQUIRED_OUTPUTS, + tsBuildInfoPath: "dist/plugin-sdk/extensions/discord/.tsbuildinfo", + }); + dependentSteps.push({ + label: "discord boundary dts", + args: [ + runTsgoScript, + "-p", + "extensions/discord/tsconfig.json", + "--declaration", + "true", + "--emitDeclarationOnly", + "true", + "--noEmit", + "false", + "--outDir", + "dist/plugin-sdk/extensions/discord", + "--rootDir", + "extensions/discord", + "--tsBuildInfoFile", + "dist/plugin-sdk/extensions/discord/.tsbuildinfo", + ], + env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" }, + timeoutMs: 300_000, + stampPath: DISCORD_DTS_STAMP, + }); + } else { + process.stdout.write("[discord boundary dts] fresh; skipping\n"); + } } if (prerequisiteSteps.length > 0) { diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index e37495b2d06..a1c1804bbaa 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -139,6 +139,10 @@ export const sharedVitestConfig = { find: "@openclaw/qa-channel/api.js", replacement: path.join(repoRoot, "extensions", "qa-channel", "api.ts"), }, + { + find: "@openclaw/discord/api.js", + replacement: path.join(repoRoot, "extensions", "discord", "api.ts"), + }, ...sourcePluginSdkSubpaths.map((subpath) => ({ find: `openclaw/plugin-sdk/${subpath}`, replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`),