426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
165 lines
5.5 KiB
TypeScript
165 lines
5.5 KiB
TypeScript
/**
|
|
* Render the interactive blocks an agent embeds in a reply (choice pickers,
|
|
* suggestion chips, task cards, secret/OAuth requests) as native Discord
|
|
* components — action rows of buttons — and decode the custom id when the user
|
|
* clicks one.
|
|
*
|
|
* The block vocabulary, parsing, neutral layout, and the callback codec all live
|
|
* in `@elizaos/core` (`messaging/interactions`) so the dashboard, Telegram, and
|
|
* Discord render the same agent output identically. This module is the thin
|
|
* Discord-specific projection: neutral buttons → `DiscordActionRow` specs that
|
|
* `buildComponents` (utils.ts) turns into discord.js builders.
|
|
*/
|
|
|
|
import {
|
|
buildInteractionUrlResolver,
|
|
type Content,
|
|
type IAgentRuntime,
|
|
type InteractionBlock,
|
|
type NeutralButton,
|
|
parseInteractionBlocks,
|
|
toNeutralLayout,
|
|
} from "@elizaos/core";
|
|
import type { DiscordActionRow, DiscordComponentOptions } from "./types";
|
|
|
|
/** Discord allows ≤5 buttons per action row and ≤5 action rows per message. */
|
|
const MAX_BUTTONS_PER_ROW = 5;
|
|
const MAX_ROWS = 5;
|
|
const MAX_CUSTOM_ID_BYTES = 100;
|
|
|
|
/** discord.js ButtonStyle numeric values. */
|
|
const BUTTON_STYLE = { primary: 1, secondary: 2, danger: 4 } as const;
|
|
const LINK_STYLE = 5;
|
|
|
|
export interface DiscordInteractionRender {
|
|
/** Prose with interaction markers stripped (plus any non-button block text). */
|
|
text: string;
|
|
/** Action-row specs; empty when the reply has no native controls. */
|
|
components: DiscordActionRow[];
|
|
/** True when a block expects a free-text reply (allowCustom / unrendered form). */
|
|
needsFreeTextReply: boolean;
|
|
}
|
|
|
|
export interface DiscordInteractionOptions {
|
|
/** Resolve a link-out URL for task / form / secret blocks. */
|
|
resolveUrl?: (block: InteractionBlock) => string | undefined;
|
|
/** Resolve a link-out URL for `navigate` followup chips. */
|
|
resolveNavigateUrl?: (payload: string) => string | undefined;
|
|
}
|
|
|
|
function toComponent(button: NeutralButton): DiscordComponentOptions | null {
|
|
if (button.url) {
|
|
// Link buttons carry no custom_id; discord.js requires the URL + Link style.
|
|
return {
|
|
type: 2,
|
|
custom_id: "",
|
|
label: button.label,
|
|
style: LINK_STYLE,
|
|
url: button.url,
|
|
};
|
|
}
|
|
if (button.callbackData) {
|
|
return {
|
|
type: 2,
|
|
custom_id: button.callbackData,
|
|
label: button.label,
|
|
style: BUTTON_STYLE[button.style ?? "secondary"],
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Project a reply's interaction blocks onto Discord action rows + the prose to
|
|
* display. Plain replies (no blocks) pass through unchanged with no components,
|
|
* so this is a safe no-op on the common path.
|
|
*/
|
|
export function renderDiscordInteractions(
|
|
content: Content,
|
|
opts: DiscordInteractionOptions = {},
|
|
): DiscordInteractionRender {
|
|
const { blocks, cleanedText } = parseInteractionBlocks(content.text ?? "");
|
|
if (blocks.length === 0) {
|
|
return {
|
|
text: content.text ?? "",
|
|
components: [],
|
|
needsFreeTextReply: false,
|
|
};
|
|
}
|
|
|
|
const rows: DiscordActionRow[] = [];
|
|
const extraLines: string[] = [];
|
|
let needsFreeTextReply = false;
|
|
|
|
for (const block of blocks) {
|
|
const layout = toNeutralLayout(block, {
|
|
resolveUrl: opts.resolveUrl,
|
|
resolveNavigateUrl: opts.resolveNavigateUrl,
|
|
maxButtonsPerRow: MAX_BUTTONS_PER_ROW,
|
|
maxCallbackBytes: MAX_CUSTOM_ID_BYTES,
|
|
});
|
|
let producedButton = false;
|
|
for (const row of layout.rows) {
|
|
const components = (row.buttons ?? [])
|
|
.map(toComponent)
|
|
.filter((c): c is DiscordComponentOptions => c !== null);
|
|
if (components.length > 0) {
|
|
rows.push({ type: 1, components });
|
|
producedButton = true;
|
|
}
|
|
}
|
|
if (layout.needsFallback) needsFreeTextReply = true;
|
|
if (!producedButton && layout.text) extraLines.push(layout.text);
|
|
}
|
|
|
|
// Discord hard-caps a message at 5 action rows. When controls overflow the
|
|
// cap, surface the dropped options as prose and invite a typed reply so no
|
|
// option is silently unreachable.
|
|
const visibleRows = rows.slice(0, MAX_ROWS);
|
|
const droppedButtons = rows.slice(MAX_ROWS).flatMap((row) => row.components);
|
|
if (droppedButtons.length > 0) {
|
|
needsFreeTextReply = true;
|
|
const droppedLabels = droppedButtons
|
|
.map((button) =>
|
|
button.url && button.label
|
|
? `${button.label} (${button.url})`
|
|
: (button.label ?? ""),
|
|
)
|
|
.filter((label) => label.trim().length > 0);
|
|
if (droppedLabels.length > 0) {
|
|
extraLines.push(
|
|
`More options (reply with one): ${droppedLabels.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const text = [cleanedText, ...extraLines]
|
|
.filter((s) => s.trim().length > 0)
|
|
.join("\n\n");
|
|
return { text, components: visibleRows, needsFreeTextReply };
|
|
}
|
|
|
|
/**
|
|
* Canonical entry point for turning an outbound `Content` into Discord text +
|
|
* components. Every Discord send path — the streaming/DM/channel reply in
|
|
* `messages.ts` and the button-tap replay in `discord-interactions.ts` — routes
|
|
* through here so link-out blocks (task cards, `navigate` chips) resolve their
|
|
* URL identically. Rendering without the resolver silently drops those buttons
|
|
* (a `[TASK:…]` reply degrades to bare prose), so the resolver must not be a
|
|
* per-call-site detail: it is derived once, here, from the deployment's app
|
|
* origin (`ELIZA_APP_URL`, then `ELIZA_CLOUD_URL`).
|
|
*/
|
|
export function buildDiscordReplyPayload(
|
|
runtime: Pick<IAgentRuntime, "getSetting">,
|
|
content: Content,
|
|
): DiscordInteractionRender {
|
|
const rawAppUrl =
|
|
runtime.getSetting("ELIZA_APP_URL") ??
|
|
runtime.getSetting("ELIZA_CLOUD_URL");
|
|
const appBaseUrl = typeof rawAppUrl === "string" ? rawAppUrl : undefined;
|
|
return renderDiscordInteractions(
|
|
content,
|
|
buildInteractionUrlResolver(appBaseUrl),
|
|
);
|
|
}
|