项目文件夹

文件
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

1987 行
59 KiB
TypeScript

/**
* Outbound message creation and send logic used by `DiscordService` — builds
* and dispatches replies to Discord (content, attachments, chunking, pairing
* gate) and maps interaction URLs into the outgoing payload.
*/
import { createHash } from "node:crypto";
import {
ChannelType,
type Content,
ContentType,
createUniqueUuid,
type EventPayload,
EventType,
type FetchedDocumentUrl as FetchedKnowledgeUrl,
fetchDocumentFromUrl,
type HandlerCallback,
type IAgentRuntime,
isInAllowlist,
lifeOpsPassiveConnectorsEnabled,
type Media,
type Memory,
MemoryType,
type Service,
ServiceType,
stringToUuid,
type UUID,
} from "@elizaos/core";
import {
type ActionRowBuilder,
type AttachmentBuilder,
type Channel,
type Client,
ChannelType as DiscordChannelType,
type Message as DiscordMessage,
type MessageActionRowComponentBuilder,
type TextChannel,
} from "discord.js";
import { isDiscordUserAddressed } from "./addressing";
import { AttachmentManager } from "./attachments";
// See service.ts for detailed documentation on Discord ID handling.
// Key point: Discord snowflake IDs (e.g., "1253563208833433701") are NOT valid UUIDs.
// Use stringToUuid() to convert them, not asUUID() which would throw an error.
import type { ICompatRuntime } from "./compat";
import { checkDiscordDmAccess } from "./dm-access";
import { createDraftStreamController } from "./draft-stream";
import { getDiscordSettings } from "./environment";
import { buildDiscordWorldMetadata } from "./identity";
import { formatInboundEnvelope } from "./inbound-envelope";
import { buildDiscordReplyPayload } from "./interactions";
import {
appendCoalescedDiscordMetadata,
type DiscordMessageWithCoalescedMetadata,
} from "./message-coalesce";
import {
applyDiscordStalenessGuard,
type DiscordStalenessConfig,
getDiscordChannelMessageSequence,
getDiscordStalenessConfig,
} from "./staleness";
import {
createStatusReactionController,
type StatusReactionScope,
shouldShowStatusReaction,
} from "./status-reactions";
import {
DiscordEventTypes,
type DiscordSettings,
type IDiscordService,
type JsonObject,
type JsonValue,
} from "./types";
import { createTypingController } from "./typing";
import {
buildDiscordComponents,
buildOutboundDiscordAttachment,
canSendMessage,
extractUrls,
getMessageService,
getMessagingAPI,
normalizeDiscordMessageText,
sendMessageInChunks,
} from "./utils";
const INTERACTION_ONLY_FALLBACK_TEXT = "Choose an option:";
// Filler tokens carrying no answer content — two single-fact replies differing
// only in these words are the same fact reworded.
const NUMERIC_FACT_STOPWORDS = new Set<string>([
"the",
"is",
"are",
"was",
"at",
"of",
"to",
"in",
"on",
"for",
"it",
"its",
"that",
"this",
"and",
"currently",
"current",
"now",
"right",
"about",
"approximately",
"around",
"price",
"priced",
"cost",
"costs",
"value",
"trading",
"worth",
"usd",
"dollars",
]);
// Only guard short single-fact replies; longer output can restate context while
// adding new information.
const NUMERIC_FACT_MAX_LEN = 160;
/**
* Significant tokens of a short numeric single-fact reply (a number plus its
* subject), or null when the reply is long or carries no number. Two replies
* whose token sets are in a subset relationship are the same fact reworded —
* the tool-turn double where an action relays the raw value ("$61,883 USD") and
* the planner restates it as a sentence ("Bitcoin is currently priced at
* $61,883 USD"). The connector is the one point every delivery converges, so
* catching it here covers the multiple independent runtime delivery paths that
* #15601's exact-text reservation cannot (the two texts differ). Conservative:
* requires a number, so only single-fact answers (prices, counts, temps) are
* ever collapsed; additive replies carry new numbers/entities and pass.
*/
export function numericFactSignatureTokens(text: string): Set<string> | null {
const trimmed = text.trim();
if (trimmed.length === 0 || trimmed.length > NUMERIC_FACT_MAX_LEN)
return null;
const tokens = trimmed
.toLowerCase()
.replace(/[^a-z0-9., ]+/g, " ")
.split(/\s+/)
.map((token) => token.replace(/^[.,]+/, "").replace(/[.,]+$/, ""))
.filter(
(token) =>
token.length > 0 &&
(/[0-9]/.test(token) || token.length >= 4) &&
!NUMERIC_FACT_STOPWORDS.has(token),
);
const set = new Set(tokens);
const hasNumber = [...set].some((token) => /[0-9]/.test(token));
return hasNumber && set.size > 0 ? set : null;
}
export function isSubsetOrEqual(a: Set<string>, b: Set<string>): boolean {
for (const token of a) if (!b.has(token)) return false;
return true;
}
export function resolveGenerationTimeoutMs(
timeoutSetting: unknown,
fallbackSetting: unknown,
mediaGenerationTimeoutSetting?: unknown,
): number | null {
const hasExplicitDiscordTimeout =
timeoutSetting !== undefined &&
timeoutSetting !== null &&
String(timeoutSetting).trim() !== "";
const parsed = Number.parseInt(
String(timeoutSetting ?? fallbackSetting ?? "120000"),
10,
);
let base: number | null;
if (!Number.isFinite(parsed)) {
base = 120_000;
} else {
base = parsed > 0 ? Math.max(30_000, parsed) : null;
}
if (hasExplicitDiscordTimeout || base === null) {
return base;
}
const mediaParsed = Number.parseInt(
String(mediaGenerationTimeoutSetting ?? ""),
10,
);
if (!Number.isFinite(mediaParsed) || mediaParsed <= 0) {
return base;
}
return Math.max(base, Math.max(30_000, mediaParsed));
}
function isJsonValue(value: unknown): value is JsonValue {
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return true;
}
if (Array.isArray(value)) {
return value.every(isJsonValue);
}
if (typeof value === "object" && value !== null) {
return Object.values(value).every(isJsonValue);
}
return false;
}
function compactJsonObject(record: Record<string, unknown>): JsonObject {
const json: JsonObject = {};
for (const [key, value] of Object.entries(record)) {
if (value === undefined) continue;
if (isJsonValue(value)) {
json[key] = value;
}
}
return json;
}
function normalizeReplyToMode(
replyToMode: DiscordSettings["replyToMode"],
): "off" | "first" | "all" {
if (replyToMode === "off" || replyToMode === "all") {
return replyToMode;
}
return "first";
}
function getAddressingContent(message: DiscordMessage): string {
return (
(message as DiscordMessageWithCoalescedMetadata)
.__discordAddressingContent ?? message.content
);
}
function fetchedUrlToAttachment(
url: string,
fetched: FetchedKnowledgeUrl,
): Media {
const hasReadableText = fetched.contentType !== "binary";
return {
id: webpageAttachmentId(url),
url,
title: fetched.filename || "Web Page",
source: fetched.contentType === "transcript" ? "YouTube" : "Web",
text: hasReadableText ? fetched.content : "",
contentType: ContentType.LINK,
};
}
function webpageAttachmentId(url: string): string {
return `webpage-${createHash("sha256").update(url).digest("hex").slice(0, 24)}`;
}
const ACTIVE_TASK_AGENT_STATUSES = new Set([
"active",
"blocked",
"tool_running",
]);
const DISCORD_OUTBOUND_DEDUPE_WINDOW_MS = 2000;
const DISCORD_OUTBOUND_DEDUPE_MAX_KEYS = 512;
const recentOutboundDiscordDeliveries = new Map<string, number>();
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function stringField(
record: Record<string, unknown> | null,
field: string,
): string | undefined {
const value = record?.[field];
return typeof value === "string" && value.length > 0 ? value : undefined;
}
export function hasActiveTaskAgentWorkForMessage(
runtime: Pick<IAgentRuntime, "getService">,
messageId: string,
): boolean {
try {
const coordinator = asRecord(runtime.getService("SWARM_COORDINATOR"));
const tasks = coordinator?.tasks;
if (!(tasks instanceof Map)) {
return false;
}
for (const taskValue of tasks.values()) {
const task = asRecord(taskValue);
const status = stringField(task, "status");
if (!status || !ACTIVE_TASK_AGENT_STATUSES.has(status)) {
continue;
}
const metadata = asRecord(task?.originMetadata);
const originMessageId = stringField(metadata, "messageId");
if (originMessageId === messageId) {
return true;
}
}
} catch {
return false;
}
return false;
}
export function shouldSuppressTimeoutForInFlightDispatchForTests({
generationTimedOut,
responseDispatchInFlight,
}: {
generationTimedOut: boolean;
responseDispatchInFlight: boolean;
}): boolean {
return generationTimedOut && responseDispatchInFlight;
}
export interface DiscordOutboundDeliveryReservation {
commit(): void;
release(): void;
}
export type BeginDiscordOutboundDeliveryResult =
| { kind: "duplicate" }
| { kind: "deliver"; reservation: DiscordOutboundDeliveryReservation };
export interface DiscordOutboundDeliveryParams {
accountId?: string;
channelId: string;
replyToMessageId?: string;
text?: string;
attachmentUrls?: readonly string[];
now?: number;
windowMs?: number;
state?: Map<string, number>;
}
function normalizeOutboundText(text: string | undefined): string {
return typeof text === "string"
? text.replace(/\s+/g, " ").trim().toLowerCase()
: "";
}
function outboundAttachmentIdentity(
attachmentUrls: readonly string[] | undefined,
): string {
return attachmentUrls?.filter(Boolean).sort().join(",") ?? "";
}
function pruneOutboundDedupeState(
state: Map<string, number>,
now: number,
windowMs: number,
): void {
for (const [key, timestamp] of state) {
if (now - Math.abs(timestamp) > windowMs) {
state.delete(key);
}
}
if (state.size <= DISCORD_OUTBOUND_DEDUPE_MAX_KEYS) return;
const overflow = state.size - DISCORD_OUTBOUND_DEDUPE_MAX_KEYS;
let removed = 0;
for (const key of state.keys()) {
if (removed >= overflow) break;
state.delete(key);
removed += 1;
}
}
/**
* Reserve one outbound Discord delivery. Discord can receive the same logical
* tool-backed answer through the inbound response callback and the generic
* message-connector send path in the same event-loop burst; this guard shares a
* short process-local window across both paths so the first REST send wins.
*/
export function beginDiscordOutboundDelivery(
params: DiscordOutboundDeliveryParams,
): BeginDiscordOutboundDeliveryResult {
const text = normalizeOutboundText(params.text);
const attachments = outboundAttachmentIdentity(params.attachmentUrls);
if (!text && !attachments) {
return {
kind: "deliver",
reservation: { commit() {}, release() {} },
};
}
const now = params.now ?? Date.now();
const windowMs = params.windowMs ?? DISCORD_OUTBOUND_DEDUPE_WINDOW_MS;
const state = params.state ?? recentOutboundDiscordDeliveries;
const key = [
params.accountId ?? "default",
params.channelId,
params.replyToMessageId ?? "",
attachments,
text,
].join("\u0000");
pruneOutboundDedupeState(state, now, windowMs);
const previous = state.get(key);
if (
previous !== undefined &&
Math.abs(now - Math.abs(previous)) <= windowMs
) {
return { kind: "duplicate" };
}
state.set(key, -now);
let settled = false;
return {
kind: "deliver",
reservation: {
commit() {
if (settled) return;
settled = true;
state.set(key, now);
},
release() {
if (settled) return;
settled = true;
if (state.get(key) === -now) {
state.delete(key);
}
},
},
};
}
/**
* Outcome of {@link runGenerationWithAbortableTimeout}.
*
* - `timedOut` — the timeout won the race; the abort signal was fired.
* - `settled` — the generation promise fulfilled or rejected before the
* timeout. When `timedOut` is true, `settled` reflects
* whether the orphaned generation had ALREADY completed at
* the moment the timeout fired (almost always `false`).
* - `error` — the rejection value when generation rejected on its own
* (not a timeout). `undefined` on success or timeout.
*/
export interface AbortableTimeoutResult {
timedOut: boolean;
settled: boolean;
error?: unknown;
}
/**
* Runs a single generation attempt against a wall-clock timeout, wiring an
* {@link AbortController} so that a timeout ACTUALLY CANCELS the underlying
* work instead of leaving it running as an orphan.
*
* Why this exists (the bug):
* The previous Discord dispatch did `Promise.race([generationPromise,
* timeoutPromise])` where `generationPromise` called
* `messageService.handleMessage(runtime, message, callback)` with NO abort
* signal. When the timeout won the race we set a `generationTimedOut` flag
* and sent the "I timed out" reply — but the model call kept running,
* burning tokens and (worse) racing to emit a late response into the same
* room. The alternating "timeout / then instant" pattern is the classic
* signature of an orphaned run that resolves late and poisons the next slot.
*
* The core message service ALREADY threads
* `MessageProcessingOptions.abortSignal` → `StreamingContext.abortSignal` →
* `runtime.useModel` (`params.signal ??= abortSignal`) → provider fetch
* (see packages/core/src/services/message.ts and
* message-handler-abort.test.ts). The only missing link was the connector
* never CREATING a controller and never PASSING the signal down. This helper
* closes that gap.
*
* Contract:
* - `generate(signal)` MUST forward `signal` into the generation call so the
* abort actually propagates. The helper cannot enforce this — the call
* site is responsible for plumbing `{ abortSignal: signal }` through.
* - On timeout: `controller.abort()` fires, `timedOut` is `true`, and the
* orphaned promise's eventual rejection is swallowed so it never surfaces
* as an unhandled rejection.
* - `timeoutMs === null` disables the timeout entirely (media / long jobs);
* the generation is awaited to completion and no controller races it.
*
* @param generate Callback receiving the abort signal; returns the
* generation promise. Must forward the signal downstream.
* @param timeoutMs Wall-clock budget in ms, or `null` to disable the timeout.
* @returns {@link AbortableTimeoutResult} describing how the race
* resolved.
*/
export async function runGenerationWithAbortableTimeout(
generate: (signal: AbortSignal) => Promise<unknown>,
timeoutMs: number | null,
): Promise<AbortableTimeoutResult> {
const controller = new AbortController();
let settled = false;
const generationPromise = Promise.resolve()
.then(() => generate(controller.signal))
.then(
() => {
settled = true;
return { kind: "ok" as const };
},
(error: unknown) => {
settled = true;
return { kind: "error" as const, error };
},
);
// Never let the orphaned generation surface as an unhandled rejection.
// The `.then(onRejected)` above already converts rejection into a value,
// but attach a defensive catch in case `generate` throws synchronously
// off the microtask edge.
void generationPromise.catch(() => {});
if (timeoutMs === null) {
const outcome = await generationPromise;
return {
timedOut: false,
settled: true,
...(outcome.kind === "error" ? { error: outcome.error } : {}),
};
}
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<"timeout">((resolve) => {
timeoutHandle = setTimeout(() => resolve("timeout"), timeoutMs);
});
try {
const winner = await Promise.race([generationPromise, timeoutPromise]);
if (winner === "timeout") {
// Timeout won: abort the underlying work so the orphaned run stops
// burning tokens and cannot race a late response into the room.
controller.abort();
return { timedOut: true, settled };
}
return {
timedOut: false,
settled: true,
...(winner.kind === "error" ? { error: winner.error } : {}),
};
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
export async function createDiscordMessageMemoryOnce(
runtime: Pick<
IAgentRuntime,
"agentId" | "createMemory" | "getMemoryById" | "logger"
>,
memory: Memory,
context: {
operation: string;
platformMessageId?: string;
} = { operation: "discord-message-persist" },
): Promise<Memory | null> {
if (!memory.id) {
const id = await runtime.createMemory(memory, "messages");
return { ...memory, id };
}
const existing = await runtime.getMemoryById(memory.id);
if (existing) {
runtime.logger.debug(
{
src: "plugin:discord",
agentId: runtime.agentId,
memoryId: memory.id,
messageId: context.platformMessageId,
operation: context.operation,
},
"Skipping duplicate Discord message memory",
);
return existing;
}
await runtime.createMemory(memory, "messages");
return memory;
}
/** Options handed to `User.send` when delivering a Discord DM reply. */
export interface DmSendOptions {
content: string;
files?: AttachmentBuilder[];
components?: ActionRowBuilder<MessageActionRowComponentBuilder>[];
}
/**
* Build the option bag for a DM reply using the same widget rows as guild
* sends. Discord supports action rows of buttons and string selects in DMs, so
* the connector does not need a DM-specific fallback for the component types it
* emits.
*
* `components`/`files` keys are omitted entirely when empty so we never send an
* empty `components: []` (which Discord rejects) or an empty `files: []`.
*
* @param textContent - Prose to send (already normalized, may be the
* "Choose an option:" fallback when the reply is components-only).
* @param files - Outbound attachments, if any.
* @param components - Already-built discord.js action rows (from
* `buildDiscordComponents`), if any.
*/
export function buildDmSendOptions(
textContent: string,
files: AttachmentBuilder[],
components: ActionRowBuilder<MessageActionRowComponentBuilder>[] | undefined,
): DmSendOptions {
return {
content: textContent,
...(files.length > 0 ? { files } : {}),
...(components && components.length > 0 ? { components } : {}),
};
}
/**
* Class representing a Message Manager for handling Discord messages.
*/
export class MessageManager {
private client: Client;
private runtime: ICompatRuntime;
private attachmentManager: AttachmentManager;
private getChannelType: (channel: Channel) => Promise<ChannelType>;
private discordSettings: DiscordSettings;
private discordService: IDiscordService;
private accountId: string;
private statusReactionScope: StatusReactionScope;
private envelopeEnabled: boolean;
private draftStreamingEnabled: boolean;
private stalenessConfig: DiscordStalenessConfig;
private recentlyProcessedMessageIds = new Map<string, number>();
private static readonly PROCESSED_MESSAGE_TTL_MS = 2 * 60 * 1000;
/**
* Constructor for a new instance of MessageManager.
* @param {IDiscordService} discordService - The Discord service instance.
* @param {ICompatRuntime} runtime - The agent runtime instance (with cross-core compat).
* @throws {Error} If the Discord client is not initialized
*/
constructor(discordService: IDiscordService, runtime: ICompatRuntime) {
// Guard against null client - fail fast with a clear error
if (!discordService.client) {
const errorMsg =
"Discord client not initialized - cannot create MessageManager";
runtime.logger.error(
{ src: "plugin:discord", agentId: runtime.agentId },
errorMsg,
);
throw new Error(errorMsg);
}
this.client = discordService.client;
this.runtime = runtime;
this.attachmentManager = new AttachmentManager(this.runtime);
this.getChannelType = discordService.getChannelType;
this.discordService = discordService;
this.accountId = discordService.accountId ?? "default";
// Load Discord settings with proper priority (env vars > character settings > defaults)
this.discordSettings =
discordService.discordSettings ?? getDiscordSettings(this.runtime);
const reactionScopeSetting = this.runtime.getSetting(
"DISCORD_STATUS_REACTIONS",
) as string | undefined;
this.statusReactionScope = (
["all", "group-mentions", "none"].includes(reactionScopeSetting ?? "")
? reactionScopeSetting
: "group-mentions"
) as StatusReactionScope;
const envelopeSetting = this.runtime.getSetting(
"DISCORD_ENVELOPE_ENABLED",
) as string | undefined;
this.envelopeEnabled =
envelopeSetting !== "false" && envelopeSetting !== "0";
const draftStreamSetting = this.runtime.getSetting(
"DISCORD_DRAFT_STREAMING",
) as string | undefined;
this.draftStreamingEnabled =
draftStreamSetting === "true" || draftStreamSetting === "1";
this.stalenessConfig = getDiscordStalenessConfig((key) =>
this.runtime.getSetting(key),
);
}
/**
* Check DM access based on the configured dmPolicy.
*
* @param message - The Discord DM message
* @returns Access check result with allowed status and optional reply message
*/
private async checkDmAccess(message: DiscordMessage): Promise<{
allowed: boolean;
replyMessage?: string;
}> {
return checkDiscordDmAccess(
this.runtime,
this.discordSettings,
message.author,
);
}
private async persistInboundMemory(memory: Memory): Promise<void> {
if (!memory.id) {
return;
}
await createDiscordMessageMemoryOnce(this.runtime, memory, {
operation: "discord-inbound",
});
}
private markMessageAsProcessing(messageId: string): boolean {
const now = Date.now();
for (const [candidateId, processedAt] of this.recentlyProcessedMessageIds) {
if (now - processedAt > MessageManager.PROCESSED_MESSAGE_TTL_MS) {
this.recentlyProcessedMessageIds.delete(candidateId);
}
}
if (this.recentlyProcessedMessageIds.has(messageId)) {
return false;
}
this.recentlyProcessedMessageIds.set(messageId, now);
return true;
}
/**
* Handles incoming Discord messages and processes them accordingly.
*
* @param {DiscordMessage} message - The Discord message to be handled
*/
async handleMessage(message: DiscordMessage) {
// this filtering is already done in setupEventListeners
/*
if (
(this.discordSettings.allowedChannelIds && this.discordSettings.allowedChannelIds.length) &&
!this.discordSettings.allowedChannelIds.some((id: string) => id === message.channel.id)
) {
return;
}
*/
const clientUser = this.client.user;
if (
message.interaction ||
(clientUser && message.author.id === clientUser.id)
) {
return;
}
if (this.discordSettings.shouldIgnoreBotMessages && message.author?.bot) {
return;
}
if (message.id && !this.markMessageAsProcessing(message.id)) {
this.runtime.logger.debug(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
messageId: message.id,
},
"Skipping duplicate Discord message",
);
return;
}
// DM policy check - applies access control policies for direct messages
if (message.channel.type === DiscordChannelType.DM) {
const userId = message.author.id;
if (this.discordSettings.shouldIgnoreDirectMessages) {
const staticallyAllowed =
this.discordSettings.allowFrom?.includes(userId) === true;
const dynamicallyAllowed = await isInAllowlist(
this.runtime,
"discord",
userId,
);
if (!staticallyAllowed && !dynamicallyAllowed) {
return;
}
}
const accessCheck = await this.checkDmAccess(message);
if (!accessCheck.allowed) {
// If a reply message was generated (new pairing request), send it
if (accessCheck.replyMessage) {
try {
await message.author.send(accessCheck.replyMessage);
} catch (err) {
this.runtime.logger.warn(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
userId: message.author.id,
error: err instanceof Error ? err.message : String(err),
},
"Failed to send pairing reply",
);
}
}
return;
}
}
const isBotPlatformMentioned = !!(
clientUser?.id && message.mentions.users?.has(clientUser.id)
);
const isReplyToBot =
!!message.reference?.messageId &&
message.mentions.repliedUser?.id === clientUser?.id;
const isBotAddressed = isDiscordUserAddressed({
text: getAddressingContent(message),
userId: clientUser?.id,
hasMessageReference: Boolean(message.reference?.messageId),
repliedUserId: message.mentions.repliedUser?.id,
});
const mentionedOtherUsers = message.mentions.users
? Array.from(message.mentions.users.values()).some(
(user) => user.id !== clientUser?.id && user.id !== message.author.id,
)
: false;
const isReplyToOtherUser =
!!message.reference?.messageId &&
!!message.mentions.repliedUser?.id &&
message.mentions.repliedUser.id !== clientUser?.id &&
message.mentions.repliedUser.id !== message.author.id;
const isInThread = message.channel.isThread();
const isDM = message.channel.type === DiscordChannelType.DM;
const strictModeEnabled =
this.discordSettings.shouldRespondOnlyToMentions === true;
const replyToMode = normalizeReplyToMode(this.discordSettings.replyToMode);
const outboundReplyToMessageId =
!isDM && replyToMode !== "off" && isBotAddressed ? message.id : undefined;
const strictModeShouldProcess = isDM || isBotAddressed;
const userName = message.author.bot
? `${message.author.username}#${message.author.discriminator}`
: message.author.username;
const name =
message.member?.displayName ??
message.author.globalName ??
message.author.displayName ??
message.author.username;
const channelId = message.channel.id;
const roomId = createUniqueUuid(this.runtime, channelId);
const roomName =
message.guild &&
"name" in message.channel &&
typeof message.channel.name === "string"
? message.channel.name
: name || userName;
// Determine channel type and server ID for ensureConnection
// messageServerId is a Discord snowflake string, converted to UUID when needed
let type: ChannelType;
let messageServerId: string | undefined;
if (message.guild) {
// Use the gateway-cached guild directly; do NOT call
// `await message.guild.fetch()`. That issues a REST GET /guilds/{id} on
// EVERY message; in a large, busy guild (thousands of members) the
// per-message fetch storm saturates discord.js's REST queue and starves
// message handling — the bot goes silent in big servers while staying
// fine in small ones (rate-limits are queued, not thrown, so nothing
// shows in the logs). `guild.id` (all that's used below) is already on
// the cached object.
const guild = message.guild;
type = await this.getChannelType(message.channel as Channel);
if (type === null) {
// usually a forum type post
this.runtime.logger.warn(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
channelId: message.channel.id,
},
"Null channel type",
);
}
messageServerId = guild.id;
} else {
type = ChannelType.DM;
messageServerId = message.channel.id;
}
try {
let { processedContent, attachments } =
await this.processMessage(message);
const currentMessageText = processedContent;
// Audio attachments already processed in processMessage via attachmentManager
if (this.envelopeEnabled && processedContent) {
try {
const envelope = await formatInboundEnvelope(
message,
processedContent,
);
processedContent = envelope.formattedContent;
} catch {
// Envelope formatting is best-effort only.
}
}
if (!processedContent && !attachments?.length) {
// Only process messages that are not empty
return;
}
// Users often mention a teammate and then ask the bot by name in the
// same message. Only short-circuit these messages when the bot is not
// also clearly addressed.
const ignoresOtherTarget =
!isDM && !isBotAddressed && (mentionedOtherUsers || isReplyToOtherUser);
// Use the service's buildMemoryFromMessage method with pre-processed content
const newMessage = await this.discordService.buildMemoryFromMessage(
message,
{
processedContent,
processedAttachments: attachments,
extraContent: {
currentMessageText,
mentionContext: {
isMention: isBotPlatformMentioned && isBotAddressed,
isReply: isReplyToBot,
isThread: isInThread,
mentionType:
isBotPlatformMentioned && isBotAddressed
? "platform_mention"
: isReplyToBot
? "reply"
: isInThread
? "thread"
: "none",
},
},
extraMetadata: compactJsonObject(
appendCoalescedDiscordMetadata(message, {
// Reply attribution for cross-agent filtering
// WHY: When user replies to another bot's message, we need to know
// so other agents can ignore it (only the replied-to agent should respond)
...(message.mentions.repliedUser
? {
replyToAuthor: {
id: message.mentions.repliedUser.id,
displayName:
message.mentions.repliedUser.globalName ??
message.mentions.repliedUser.username,
username: message.mentions.repliedUser.username,
isBot: message.mentions.repliedUser.bot,
},
replyToSenderId: message.mentions.repliedUser.id,
replyToSenderName:
message.mentions.repliedUser.globalName ??
message.mentions.repliedUser.username,
replyToSenderUserName:
message.mentions.repliedUser.username,
}
: {}),
...(message.reference?.messageId
? {
replyToMessageId: createUniqueUuid(
this.runtime,
message.reference.messageId,
),
replyToExternalMessageId: message.reference.messageId,
}
: {}),
}),
),
},
);
if (!newMessage) {
this.runtime.logger.warn(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
messageId: message.id,
},
"Failed to build memory from message",
);
return;
}
await this.runtime.ensureConnection({
entityId: newMessage.entityId,
roomId,
roomName,
userName,
name,
source: "discord",
channelId: message.channel.id,
// Convert Discord snowflake to UUID (see service.ts header for why stringToUuid not asUUID)
messageServerId: messageServerId
? stringToUuid(messageServerId)
: undefined,
type,
worldId: createUniqueUuid(this.runtime, messageServerId ?? roomId),
worldName: message.guild?.name,
// Preserve the raw Discord user id in source metadata for role and allowlist checks.
userId: message.author.id as UUID,
metadata: {
...buildDiscordWorldMetadata(
this.runtime,
message.guild?.ownerId ?? undefined,
),
accountId: this.accountId,
},
});
if (
!this.discordSettings.autoReply ||
lifeOpsPassiveConnectorsEnabled(this.runtime)
) {
await this.persistInboundMemory(newMessage);
this.runtime.logger.debug(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
channelId: message.channel.id,
},
"Auto-reply disabled; message ingested without response",
);
return;
}
if (ignoresOtherTarget) {
await this.persistInboundMemory(newMessage);
this.runtime.logger.debug(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
channelId: message.channel.id,
},
"Ignoring message that targets another mentioned user",
);
return;
}
if (strictModeEnabled && !strictModeShouldProcess) {
await this.persistInboundMemory(newMessage);
this.runtime.logger.debug(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
channelId: message.channel.id,
},
"Strict mode: ignoring message (no mention or reply)",
);
return;
}
if (strictModeEnabled) {
this.runtime.logger.debug(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
channelId: message.channel.id,
},
"Strict mode: processing message",
);
}
const canSendResult = canSendMessage(message.channel);
if (!canSendResult.canSend) {
await this.persistInboundMemory(newMessage);
return this.runtime.logger.warn(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
channelId: message.channel.id,
reason: canSendResult.reason,
},
"Cannot send message to channel",
);
}
const messageId = newMessage.id;
const stalenessStartSequence = getDiscordChannelMessageSequence(
this,
message.channel.id,
);
const channel = message.channel as TextChannel;
const typingController = createTypingController(channel);
const clientUserId = this.client.user?.id;
const useReactions = shouldShowStatusReaction(
this.statusReactionScope,
message,
clientUserId,
);
const statusReactions = useReactions
? createStatusReactionController(message)
: null;
const draftStream = this.draftStreamingEnabled
? createDraftStreamController({
log: (entry) =>
this.runtime.logger.debug(
{ src: "plugin:discord", agentId: this.runtime.agentId },
entry,
),
warn: (entry) =>
this.runtime.logger.warn(
{ src: "plugin:discord", agentId: this.runtime.agentId },
entry,
),
})
: null;
let typingStarted = false;
let responseEmitted = false;
let responseDispatchInFlight = false;
let generationTimedOut = false;
const generationTimeoutMs = resolveGenerationTimeoutMs(
this.runtime.getSetting("DISCORD_GENERATION_TIMEOUT_MS") ??
process.env.DISCORD_GENERATION_TIMEOUT_MS,
this.runtime.getSetting("MESSAGE_TIMEOUT_MS") ??
process.env.MESSAGE_TIMEOUT_MS,
this.runtime.getSetting("ZEROLLAMA_VIDEO_TIMEOUT_MS") ??
process.env.ZEROLLAMA_VIDEO_TIMEOUT_MS,
);
const finalizePendingDraft = async () => {
if (draftStream?.isStarted() && !draftStream.isDone()) {
await draftStream.finalize("");
}
};
const abortPendingDraft = async () => {
if (draftStream?.isStarted() && !draftStream.isDone()) {
await draftStream.abort(
"An error occurred while generating the response.",
);
}
};
const sendFailureReply = async (text: string) => {
try {
await channel.send({
content: text,
...(outboundReplyToMessageId && replyToMode !== "off"
? {
reply: {
messageReference: outboundReplyToMessageId,
},
}
: {}),
});
responseEmitted = true;
} catch (sendError) {
this.runtime.logger.warn(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
error:
sendError instanceof Error
? sendError.message
: String(sendError),
},
"Failed to send Discord failure reply",
);
}
};
const runResponseDispatch = async <T>(
dispatch: () => Promise<T>,
): Promise<T> => {
responseDispatchInFlight = true;
try {
return await dispatch();
} finally {
responseDispatchInFlight = false;
}
};
if (draftStream) {
await draftStream.start(channel, outboundReplyToMessageId, replyToMode);
}
// Typing indicator is deferred until the runtime actually invokes the
// handler callback (see the `typingStarted` guard further down). This
// avoids showing "Eliza is typing…" for messages the agent decides to
// IGNORE/NONE, and lines up with the message-service preamble that
// fires the callback the moment we commit to responding.
statusReactions?.setQueued();
statusReactions?.setThinking();
const callback: HandlerCallback = async (content: Content) => {
let outboundReservation: DiscordOutboundDeliveryReservation | undefined;
try {
const pendingAttachmentCount = Array.isArray(content.attachments)
? content.attachments.filter((media) => Boolean(media?.url)).length
: 0;
// Long-running media (e.g. Wan video ~10 min) can outlive the Discord
// generation timeout. Still deliver attachments when the job finishes.
if (generationTimedOut && pendingAttachmentCount === 0) {
return [];
}
// target is set but not addressed to us handling
if (
content.target &&
typeof content.target === "string" &&
content.target.toLowerCase() !== "discord"
) {
return [];
}
const stalenessDecision = applyDiscordStalenessGuard({
config: this.stalenessConfig,
owner: this,
message,
startSequence: stalenessStartSequence,
content,
});
if (stalenessDecision.stale) {
this.runtime.logger.warn(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
channelId: message.channel.id,
messageId: message.id,
messagesSinceTurnStart:
stalenessDecision.messagesSinceTurnStart,
threshold: this.stalenessConfig.threshold,
behavior: stalenessDecision.behavior,
},
"Discord response completed after newer channel messages arrived",
);
}
if (!stalenessDecision.shouldSend) {
typingController.stop();
statusReactions?.setDone();
await finalizePendingDraft();
return [];
}
if (message.id && !content.inReplyTo) {
content.inReplyTo = createUniqueUuid(this.runtime, message.id);
}
// Reasoning-tag / native tool-syntax sanitization happens once at
// the shared outbound boundary in @elizaos/core (#15888) — the
// text arriving here is already sanitized.
// Project embedded interaction blocks (choices, task cards, …) onto
// native Discord components, and strip their markers from the prose.
const rendered = buildDiscordReplyPayload(this.runtime, content);
const hasComponents = rendered.components.length > 0;
let textContent = normalizeDiscordMessageText(rendered.text);
if (textContent.trim().length === 0 && hasComponents) {
textContent = INTERACTION_ONLY_FALLBACK_TEXT;
}
const hasText = textContent.trim().length > 0;
let attachmentCount = Array.isArray(content.attachments)
? content.attachments.filter((media) => Boolean(media?.url)).length
: 0;
// Skip attachment URLs already delivered by an action callback this turn.
if (attachmentCount > 0 && content.inReplyTo) {
const callbackDedup = message as DiscordMessage & {
_elizaSentReplyKeys?: Set<string>;
_elizaSentAttachmentUrls?: Set<string>;
};
callbackDedup._elizaSentAttachmentUrls ??= new Set();
const sentAttachmentUrls = callbackDedup._elizaSentAttachmentUrls;
const pendingAttachments = (content.attachments ?? []).filter(
(media) =>
Boolean(media?.url) && !sentAttachmentUrls.has(media.url),
);
if (pendingAttachments.length === 0) {
content = { ...content, attachments: undefined };
attachmentCount = 0;
} else if (
pendingAttachments.length !== (content.attachments ?? []).length
) {
content = { ...content, attachments: pendingAttachments };
attachmentCount = pendingAttachments.length;
}
}
if (!hasText && attachmentCount === 0) {
return [];
}
if (!typingStarted) {
typingStarted = true;
typingController.start();
}
// Dedup: error when the runtime emits identical text
// twice in response to the same inbound message (e.g.
// planner follow-up repeating action output).
if (hasText && content.inReplyTo) {
const dedupKey = `${content.inReplyTo}::${textContent.replace(/\s+/g, " ").trim()}`;
const callbackDedup = message as DiscordMessage & {
_elizaSentReplyKeys?: Set<string>;
_elizaSentFactSignatures?: Array<Set<string>>;
};
callbackDedup._elizaSentReplyKeys ??= new Set();
callbackDedup._elizaSentFactSignatures ??= [];
// Paraphrase/subset guard (#15585): a short numeric single-fact
// reply that restates one already sent for this inbound message —
// even in different words or as the bare value — is a redundant
// second bubble the exact-text key above cannot catch.
// Directional: suppress only when the NEW reply adds nothing over
// an already-sent one (its tokens ⊆ a prior's). This drops the
// bare-value/paraphrase second bubble while always letting a
// genuinely-additive follow-up (which carries a token the prior
// lacks) through, regardless of delivery order.
const factSignature = numericFactSignatureTokens(textContent);
const repeatsPriorFact =
factSignature !== null &&
callbackDedup._elizaSentFactSignatures.some((prior) =>
isSubsetOrEqual(factSignature, prior),
);
if (
callbackDedup._elizaSentReplyKeys.has(dedupKey) ||
repeatsPriorFact
) {
this.runtime.logger.debug(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
messageId: message.id,
reason: repeatsPriorFact
? "fact-signature"
: "identical-text",
textPreview: textContent
.replace(/\s+/g, " ")
.trim()
.slice(0, 200),
},
"Suppressing duplicate callback reply",
);
return [];
}
callbackDedup._elizaSentReplyKeys.add(dedupKey);
if (factSignature !== null) {
callbackDedup._elizaSentFactSignatures.push(factSignature);
}
}
const outboundDedupe = beginDiscordOutboundDelivery({
accountId: this.accountId,
channelId: channel.id,
replyToMessageId:
outboundReplyToMessageId ??
(typeof content.inReplyTo === "string"
? content.inReplyTo
: undefined),
text: textContent,
attachmentUrls: content.attachments
?.map((media) => media.url)
.filter((url): url is string => typeof url === "string"),
});
if (outboundDedupe.kind === "duplicate") {
this.runtime.logger.debug(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
channelId: channel.id,
messageId: message.id,
textPreview: textContent
.replace(/\s+/g, " ")
.trim()
.slice(0, 200),
},
"Suppressing duplicate Discord outbound delivery",
);
return [];
}
outboundReservation = outboundDedupe.reservation;
const files: AttachmentBuilder[] = [];
if (content.attachments && content.attachments.length > 0) {
for (const media of content.attachments) {
if (media.url) {
files.push(
await buildOutboundDiscordAttachment(media, this.runtime),
);
}
}
if (files.length > 0) {
this.runtime.logger.info(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
messageId: message.id,
attachmentCount: files.length,
},
"Sending Discord message attachments",
);
}
}
let messages: DiscordMessage[] = [];
if (draftStream?.isStarted() && !draftStream.isDone()) {
if (hasText || files.length === 0) {
const draftComponents = hasComponents
? buildDiscordComponents(rendered.components)
: undefined;
messages = await runResponseDispatch(() =>
draftStream.finalize(textContent, draftComponents),
);
} else {
await finalizePendingDraft();
}
if (files.length > 0) {
try {
const attachmentMessage = await runResponseDispatch(() =>
channel.send({
files,
...(outboundReplyToMessageId &&
(replyToMode === "all" || !hasText)
? {
reply: {
messageReference: outboundReplyToMessageId,
},
}
: {}),
}),
);
messages.push(attachmentMessage);
} catch (error) {
this.runtime.logger.warn(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
error:
error instanceof Error ? error.message : String(error),
},
"Failed to send Discord attachments after draft finalize",
);
}
}
} else if (content && content.channelType === "DM") {
const user = await this.client.users.fetch(message.author.id);
if (!user) {
this.runtime.logger.warn(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
entityId: message.author.id,
},
"User not found for DM",
);
return [];
}
const dmComponents = hasComponents
? buildDiscordComponents(rendered.components)
: undefined;
const dmMessage = await runResponseDispatch(() =>
user.send(buildDmSendOptions(textContent, files, dmComponents)),
);
messages = [dmMessage];
} else {
if (!message.id) {
this.runtime.logger.warn(
{ src: "plugin:discord", agentId: this.runtime.agentId },
"Cannot send message: message.id is missing",
);
return [];
}
messages = await runResponseDispatch(() =>
sendMessageInChunks(
channel,
textContent,
outboundReplyToMessageId ?? "",
files,
hasComponents ? rendered.components : undefined,
this.runtime,
replyToMode,
),
);
}
const attemptedSend = hasText || attachmentCount > 0;
if (attemptedSend && messages.length === 0) {
throw new Error(
"Discord response callback completed without sending any messages",
);
}
if (messages.length > 0) {
outboundReservation.commit();
outboundReservation = undefined;
}
const memories: Memory[] = [];
for (const m of messages) {
const actions = content.actions;
// Only attach files to the memory for the message that actually carries them
const hasAttachments = m.attachments?.size > 0;
const memory: Memory = {
id: createUniqueUuid(this.runtime, m.id),
entityId: this.runtime.agentId,
agentId: this.runtime.agentId,
content: {
...content,
source: "discord",
text: m.content || textContent || " ",
actions,
inReplyTo: messageId,
url: m.url,
channelType: type,
// Only include attachments for the message chunk that actually has them
attachments:
hasAttachments && content.attachments
? content.attachments
: undefined,
},
roomId,
metadata: {
type: MemoryType.MESSAGE,
accountId: this.accountId,
platformMessageId: m.id,
},
createdAt: m.createdTimestamp,
};
memories.push(memory);
}
for (const m of memories) {
await createDiscordMessageMemoryOnce(this.runtime, m, {
operation: "discord-response-callback",
platformMessageId:
typeof m.metadata?.platformMessageId === "string"
? m.metadata.platformMessageId
: undefined,
});
}
if (memories.length > 0) {
responseEmitted = true;
}
if (
messages.length > 0 &&
content.attachments?.length &&
content.inReplyTo
) {
const callbackDedup = message as DiscordMessage & {
_elizaSentAttachmentUrls?: Set<string>;
};
callbackDedup._elizaSentAttachmentUrls ??= new Set();
for (const media of content.attachments) {
if (media.url) {
callbackDedup._elizaSentAttachmentUrls.add(media.url);
}
}
}
typingController.stop();
statusReactions?.setDone();
return memories;
} catch (error) {
outboundReservation?.release();
this.runtime.logger.error(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
error: error instanceof Error ? error.message : String(error),
},
"Error handling message callback",
);
typingController.stop();
statusReactions?.setError();
await abortPendingDraft();
throw error;
}
};
const messagingAPI = getMessagingAPI(this.runtime);
const messageService = getMessageService(this.runtime);
// AbortController for the whole generation attempt. On timeout we fire
// this so the underlying model call ACTUALLY cancels instead of running
// on as an orphan (the root cause of the alternating timeout / instant
// pattern). The signal threads into `messageService.handleMessage`
// options → StreamingContext → runtime.useModel → provider fetch. See
// runGenerationWithAbortableTimeout above and __tests__/generation-abort.
const generationAbortController = new AbortController();
const generationSignal = generationAbortController.signal;
let generationTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
try {
const generationPromise = (async () => {
if (messageService) {
this.runtime.logger.debug(
{ src: "plugin:discord", agentId: this.runtime.agentId },
"Using messageService API",
);
await messageService.handleMessage(
this.runtime,
newMessage,
callback,
{ abortSignal: generationSignal },
);
} else if (messagingAPI?.handleMessage) {
this.runtime.logger.debug(
{ src: "plugin:discord", agentId: this.runtime.agentId },
"Using messaging API handleMessage",
);
await messagingAPI.handleMessage(this.runtime.agentId, newMessage, {
onResponse: callback,
});
} else if (messagingAPI?.sendMessage) {
this.runtime.logger.debug(
{ src: "plugin:discord", agentId: this.runtime.agentId },
"Using messaging API sendMessage",
);
await messagingAPI.sendMessage(this.runtime.agentId, newMessage, {
onResponse: callback,
});
} else {
this.runtime.logger.debug(
{ src: "plugin:discord", agentId: this.runtime.agentId },
"Using event-based message handling",
);
const payload: EventPayload & {
message: Memory;
callback: HandlerCallback;
accountId: string;
} = {
runtime: this.runtime,
message: newMessage,
callback,
source: "discord",
accountId: this.accountId,
};
await this.runtime.emitEvent(
[
DiscordEventTypes.MESSAGE_RECEIVED,
EventType.MESSAGE_RECEIVED,
] as string[],
payload,
);
}
})();
// Never let the orphaned generation surface as an unhandled
// rejection once we stop awaiting it on timeout.
generationPromise.catch(() => {});
if (generationTimeoutMs === null) {
await generationPromise;
} else {
const timeoutPromise = new Promise<never>((_, reject) => {
generationTimeoutHandle = setTimeout(() => {
generationTimedOut = true;
// Abort the underlying generation BEFORE rejecting so the
// orphaned model call stops burning tokens and cannot race a
// late response into this room. Without this the run stayed
// live and poisoned the next message slot.
generationAbortController.abort();
reject(
new Error(
`Discord generation timeout after ${generationTimeoutMs}ms`,
),
);
}, generationTimeoutMs);
});
await Promise.race([generationPromise, timeoutPromise]);
}
} catch (generationError) {
const activeTaskAgentWork =
generationTimedOut &&
!!messageId &&
hasActiveTaskAgentWorkForMessage(this.runtime, messageId);
this.runtime.logger.error(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
messageId: message.id,
timeoutMs: generationTimeoutMs,
activeTaskAgentWork,
error:
generationError instanceof Error
? generationError.message
: String(generationError),
},
"Discord generation failed or timed out",
);
typingController.stop();
if (activeTaskAgentWork) {
statusReactions?.setDone();
await abortPendingDraft();
this.runtime.logger.warn(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
messageId: message.id,
memoryId: messageId,
roomId,
timeoutMs: generationTimeoutMs,
},
"Suppressing Discord timeout reply while task-agent work is still active",
);
return;
}
if (
shouldSuppressTimeoutForInFlightDispatchForTests({
generationTimedOut,
responseDispatchInFlight,
})
) {
this.runtime.logger.warn(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
messageId: message.id,
memoryId: messageId,
roomId,
timeoutMs: generationTimeoutMs,
},
"Suppressing Discord timeout handling while response dispatch is in flight",
);
return;
}
statusReactions?.setError();
await abortPendingDraft();
if (!responseEmitted) {
await sendFailureReply(
generationTimedOut
? "I timed out while generating that reply. Please retry."
: "I hit a provider issue while generating the reply. Please retry.",
);
}
return;
} finally {
if (generationTimeoutHandle) {
clearTimeout(generationTimeoutHandle);
}
}
if (!responseEmitted) {
typingController.stop();
statusReactions?.setDone();
await finalizePendingDraft();
}
} catch (error) {
this.runtime.logger.error(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
error: error instanceof Error ? error.message : String(error),
},
"Error handling message",
);
}
}
/**
* Processes the message content, mentions, code blocks, attachments, and URLs to generate
* processed content and media attachments.
*
* @param {DiscordMessage} message The message to process
* @returns {Promise<{ processedContent: string; attachments: Media[] }>} Processed content and media attachments
*/
async processMessage(
message: DiscordMessage,
): Promise<{ processedContent: string; attachments: Media[] }> {
let processedContent = message.content;
const attachments: Media[] = [];
if (message.embeds?.length) {
for (const i in message.embeds) {
const embed = message.embeds[i];
// type: rich
processedContent += `\nEmbed #${parseInt(i, 10) + 1}:\n`;
processedContent += ` Title:${embed.title ?? "(none)"}\n`;
processedContent += ` Description:${embed.description ?? "(none)"}\n`;
}
}
const mentionRegex = /<@!?(\d+)>/g;
processedContent = processedContent.replace(
mentionRegex,
(match, entityId) => {
const user = message.mentions.users.get(entityId);
if (user) {
return `${user.username} (@${entityId})`;
}
return match;
},
);
const codeBlockRegex = /```([\s\S]*?)```/g;
let match: RegExpExecArray | null = codeBlockRegex.exec(processedContent);
while (match !== null) {
const fullMatch = match[0];
const codeBlock = match[1];
const lines = codeBlock.split("\n");
const title = lines[0];
const description = lines.slice(0, 3).join("\n");
const attachmentId =
`code-${Date.now()}-${Math.floor(Math.random() * 1000)}`.slice(-5);
attachments.push({
id: attachmentId,
url: "",
title: title || "Code Block",
source: "Code",
description,
text: codeBlock,
});
processedContent = processedContent.replace(
fullMatch,
`Code Block (${attachmentId})`,
);
match = codeBlockRegex.exec(processedContent);
}
if (message.attachments.size > 0) {
attachments.push(
...(await this.attachmentManager.processAttachments(
message.attachments,
)),
);
}
// Extract and clean URLs from the message content
const urls = extractUrls(processedContent, this.runtime);
for (const url of urls) {
// Use string literal type for getService, assume methods exist at runtime
const videoService = this.runtime.getService(ServiceType.VIDEO) as
| ({
isVideoUrl?: (url: string) => boolean;
processVideo?: (
url: string,
runtime: IAgentRuntime,
) => Promise<{
title: string;
description: string;
text: string;
}>;
} & Service)
| null;
if (
typeof videoService?.isVideoUrl === "function" &&
typeof videoService.processVideo === "function" &&
videoService.isVideoUrl(url)
) {
try {
const videoInfo = await videoService.processVideo(url, this.runtime);
attachments.push({
id: `youtube-${Date.now()}`,
url,
title: videoInfo.title,
source: "YouTube",
description: videoInfo.description,
text: videoInfo.text,
});
} catch (error) {
// Handle video processing errors gracefully - the URL is still preserved in the message
const errorMsg =
error instanceof Error ? error.message : String(error);
this.runtime.logger.warn(
`Failed to process video ${url}: ${errorMsg}`,
);
}
} else {
try {
const fetched = await fetchDocumentFromUrl(url);
attachments.push(fetchedUrlToAttachment(url, fetched));
continue;
} catch (error) {
const errorMsg =
error instanceof Error ? error.message : String(error);
this.runtime.logger.debug(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
url,
error: errorMsg,
},
"Direct URL enrichment failed; trying browser service fallback",
);
}
const browserService = this.runtime.getService(ServiceType.BROWSER) as
| ({
getPageContent?: (
url: string,
runtime: IAgentRuntime,
) => Promise<{ title?: string; description?: string }>;
} & Service)
| null;
if (!browserService) {
this.runtime.logger.debug(
{ src: "plugin:discord", agentId: this.runtime.agentId },
"Skipping URL enrichment because browser service is unavailable",
);
continue;
}
try {
this.runtime.logger.debug(
`Fetching page content for cleaned URL: "${url}"`,
);
if (typeof browserService.getPageContent !== "function") {
continue;
}
const { title, description: summary } =
await browserService.getPageContent(url, this.runtime);
attachments.push({
id: webpageAttachmentId(url),
url,
title: title || "Web Page",
source: "Web",
description: summary,
text: summary,
contentType: ContentType.LINK,
});
} catch (error) {
// Silently handle browser errors (certificate issues, timeouts, dead sites, etc.)
// The URL is still preserved in the message content, just without scraped metadata
const errorMsg =
error instanceof Error ? error.message : String(error);
const errorString = String(error);
// Check for common expected failures that don't need logging
const isExpectedFailure =
errorMsg.includes("ERR_CERT") ||
errorString.includes("ERR_CERT") ||
errorMsg.includes("Timeout") ||
errorString.includes("Timeout") ||
errorMsg.includes("ERR_NAME_NOT_RESOLVED") ||
errorString.includes("ERR_NAME_NOT_RESOLVED") ||
errorMsg.includes("ERR_HTTP_RESPONSE_CODE_FAILURE") ||
errorString.includes("ERR_HTTP_RESPONSE_CODE_FAILURE");
if (!isExpectedFailure) {
this.runtime.logger.warn(
`Failed to fetch page content for ${url}: ${errorMsg}`,
);
}
// Expected failures are silently handled - no logging needed
}
}
}
return { processedContent, attachments };
}
/**
* Asynchronously fetches the bot's username and discriminator from Discord API.
*
* @param {string} botToken The token of the bot to authenticate the request
* @returns {Promise<string>} A promise that resolves with the bot's username and discriminator
* @throws {Error} If there is an error while fetching the bot details
*/
async fetchBotName(botToken: string) {
const url = "https://discord.com/api/v10/users/@me";
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bot ${botToken}`,
},
});
if (!response.ok) {
throw new Error(`Error fetching bot details: ${response.statusText}`);
}
const data = await response.json();
const discriminator = data.discriminator;
return (
(data as { username: string }).username +
(discriminator ? `#${discriminator}` : "")
);
}
}