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
3845 lines
110 KiB
TypeScript
3845 lines
110 KiB
TypeScript
/**
|
|
* `DiscordService` — the connector's main gateway service. Wraps a discord.js
|
|
* `Client`, logs in with the configured bot token(s), and drives inbound and
|
|
* outbound messages, voice, slash-command and interaction handling, reactions,
|
|
* channel-history backfill, and profile sync.
|
|
*
|
|
* It emits `DiscordEventTypes.*` on the runtime and composes most of the other
|
|
* files in this plugin (messages, messaging, debouncer, history, events,
|
|
* interactions, reactions, voice, identity, allowlist) as helpers. This is the
|
|
* service registered from `index.ts` under the `"discord"` type key.
|
|
*/
|
|
import {
|
|
ChannelType,
|
|
type Character,
|
|
type Content,
|
|
createUniqueUuid,
|
|
type EventPayload,
|
|
getConnectorAdminWhitelist,
|
|
type IAgentRuntime,
|
|
type Media,
|
|
type Memory,
|
|
MemoryType,
|
|
type MessageConnectorChatContext,
|
|
type MessageConnectorCreateThreadParams,
|
|
type MessageConnectorPostToThreadParams,
|
|
type MessageConnectorQueryContext,
|
|
type MessageConnectorTarget,
|
|
type MessageConnectorTypingParams,
|
|
type MessageConnectorUserContext,
|
|
parseBooleanFromText,
|
|
type Room,
|
|
Service,
|
|
setConnectorAdminWhitelist,
|
|
stringToUuid,
|
|
type TargetInfo,
|
|
type ThreadHandle,
|
|
type UUID,
|
|
type World,
|
|
} from "@elizaos/core";
|
|
/**
|
|
* IMPORTANT: Discord ID Handling - Why stringToUuid() instead of asUUID()
|
|
*
|
|
* Discord uses "snowflake" IDs - large 64-bit integers represented as strings
|
|
* (e.g., "1253563208833433701"). These are NOT valid UUIDs.
|
|
*
|
|
* UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (8-4-4-4-12 hex digits with dashes)
|
|
* Discord ID: 1253563208833433701 (plain number string)
|
|
*
|
|
* The two UUID-related functions behave differently:
|
|
*
|
|
* - `asUUID(str)` - VALIDATES that the string is already a valid UUID format.
|
|
* If not, it throws: "Error: Invalid UUID format: 1253563208833433701"
|
|
* Use only when you're certain the input is already a valid UUID.
|
|
*
|
|
* - `stringToUuid(str)` - CONVERTS any string into a deterministic UUID by hashing it.
|
|
* Always succeeds. The same input always produces the same UUID output.
|
|
* Use this for Discord snowflake IDs.
|
|
*
|
|
* When working with Discord IDs in ElizaOS:
|
|
*
|
|
* 1. `stringToUuid(discordId)` - For storing Discord IDs in UUID fields (e.g., `messageServerId`).
|
|
*
|
|
* 2. `createUniqueUuid(runtime, discordId)` - For `worldId` and `roomId`. This adds the agent's
|
|
* ID to the hash, ensuring each agent has its own unique namespace for the same Discord server.
|
|
*
|
|
* 3. `messageServerId` - The correct property name for server IDs on Room and World objects.
|
|
*
|
|
* 4. Discord-specific events (e.g., DiscordEventTypes.VOICE_STATE_UPDATE) are not in core's
|
|
* EventPayloadMap. When emitting these events, cast to `string[]` and payload to `any`
|
|
* to use the generic emitEvent overload.
|
|
*/
|
|
import {
|
|
ActivityType,
|
|
type AttachmentBuilder,
|
|
type BaseGuildVoiceChannel,
|
|
type Channel,
|
|
type Collection,
|
|
ChannelType as DiscordChannelType,
|
|
Client as DiscordJsClient,
|
|
Events,
|
|
GatewayIntentBits,
|
|
type Guild,
|
|
type GuildMember,
|
|
type GuildTextBasedChannel,
|
|
type Interaction,
|
|
type Message,
|
|
type MessageReaction,
|
|
type PartialMessageReaction,
|
|
Partials,
|
|
type PartialUser,
|
|
PermissionsBitField,
|
|
type TextChannel,
|
|
ThreadAutoArchiveDuration,
|
|
type ThreadChannel,
|
|
type User,
|
|
type Webhook,
|
|
} from "discord.js";
|
|
import {
|
|
DiscordAccountClientPool,
|
|
type DiscordAccountClientState,
|
|
} from "./account-client-pool";
|
|
import {
|
|
DEFAULT_ACCOUNT_ID,
|
|
listEnabledDiscordAccounts,
|
|
normalizeAccountId,
|
|
type ResolvedDiscordAccount,
|
|
resolveDefaultDiscordAccountId,
|
|
} from "./accounts";
|
|
import type { IDiscordAudioSink } from "./audio-sink";
|
|
import type { ICompatRuntime } from "./compat";
|
|
import { DISCORD_SERVICE_NAME } from "./constants";
|
|
import type { ChannelDebouncer } from "./debouncer";
|
|
import { DiscordVoiceTargetAudioSink } from "./discord-audio-sink";
|
|
import { handleGuildCreate as handleGuildCreateExtracted } from "./discord-commands";
|
|
import {
|
|
type DiscordServiceInternals,
|
|
setupDiscordEventListeners,
|
|
} from "./discord-events";
|
|
import {
|
|
buildMemoryFromMessage as buildMemoryFromMessageExtracted,
|
|
fetchChannelHistory as fetchChannelHistoryExtracted,
|
|
type HistoryServiceInternals,
|
|
} from "./discord-history";
|
|
import {
|
|
handleInteractionCreate as handleInteractionCreateExtracted,
|
|
type InteractionServiceInternals,
|
|
onReady as onReadyExtracted,
|
|
} from "./discord-interactions";
|
|
import {
|
|
handleReactionAdd as handleReactionAddExtracted,
|
|
handleReactionRemove as handleReactionRemoveExtracted,
|
|
type ReactionServiceInternals,
|
|
} from "./discord-reactions";
|
|
import { getDiscordSettings } from "./environment";
|
|
import {
|
|
extractDiscordOwnerUserIds,
|
|
extractDiscordTeamAdminUserIds,
|
|
parseDiscordOwnerUserIds,
|
|
resolveDiscordRuntimeEntityId,
|
|
resolveElizaOwnerEntityId,
|
|
} from "./identity";
|
|
import {
|
|
beginDiscordOutboundDelivery,
|
|
createDiscordMessageMemoryOnce,
|
|
type DiscordOutboundDeliveryReservation,
|
|
MessageManager,
|
|
} from "./messages";
|
|
import {
|
|
registerDiscordSlashCommands,
|
|
type SlashCommandRegistrationHost,
|
|
} from "./slash-command-registration";
|
|
import type {
|
|
BuildMemoryFromMessageOptions,
|
|
ChannelHistoryOptions,
|
|
ChannelHistoryResult,
|
|
DiscordSettings,
|
|
DiscordSlashCommand,
|
|
IDiscordService,
|
|
} from "./types";
|
|
import { DiscordEventTypes } from "./types";
|
|
import {
|
|
buildOutboundDiscordAttachment,
|
|
MAX_MESSAGE_LENGTH,
|
|
normalizeDiscordMessageText,
|
|
splitMessage,
|
|
} from "./utils";
|
|
import { VoiceManager } from "./voice";
|
|
import {
|
|
type DiscordVoiceTarget,
|
|
type DiscordVoiceTargetRegistration,
|
|
DiscordVoiceTargetRegistry,
|
|
} from "./voice-target-registry";
|
|
|
|
const DISCORD_SNOWFLAKE_PATTERN = /^\d{15,20}$/;
|
|
type MessageConnectorRegistration = Parameters<
|
|
IAgentRuntime["registerMessageConnector"]
|
|
>[0];
|
|
|
|
type DiscordSettingsForEvents = DiscordSettings & {
|
|
shouldIgnoreBotMessages: boolean;
|
|
};
|
|
|
|
type DiscordAccountServiceFacade = IDiscordService &
|
|
DiscordServiceInternals &
|
|
HistoryServiceInternals &
|
|
InteractionServiceInternals &
|
|
ReactionServiceInternals & {
|
|
client: DiscordJsClient;
|
|
discordSettings: DiscordSettingsForEvents;
|
|
commandRegistrationQueue: Promise<void>;
|
|
addAllowedChannel(channelId: string): boolean;
|
|
removeAllowedChannel(channelId: string): boolean;
|
|
getAllowedChannels(): string[];
|
|
registerVoiceTarget(target: DiscordVoiceTargetRegistration): void;
|
|
unregisterVoiceTarget(
|
|
accountId: string,
|
|
guildId: string,
|
|
channelId: string,
|
|
): void;
|
|
isVoiceChannelClaimed(guildId: string, channelId: string): boolean;
|
|
};
|
|
|
|
// Initial-login retry schedule. discord.js only auto-reconnects once a gateway
|
|
// session exists, so a transient failure of the FIRST `client.login()` is
|
|
// otherwise terminal — the process stays "active" but deaf (#15855). We retry
|
|
// the initial connect with capped exponential backoff (base doubles per
|
|
// attempt, clamped) and keep retrying indefinitely until a session is
|
|
// established; the network coming back is the only success condition.
|
|
const DISCORD_LOGIN_RETRY_BASE_MS = 1_000;
|
|
const DISCORD_LOGIN_RETRY_MAX_MS = 60_000;
|
|
// While an account is stuck in the failed state, warn at most this often so the
|
|
// retry storm surfaces as an observable heartbeat without flooding the log.
|
|
const DISCORD_LOGIN_HEARTBEAT_MIN_INTERVAL_MS = 30_000;
|
|
const DISCORD_TERMINAL_INITIAL_LOGIN_CLOSE_CODES = new Set([
|
|
4004, // Authentication failed: the configured token cannot open a session.
|
|
4010, // Invalid shard: retrying the same shard parameters cannot succeed.
|
|
4011, // Sharding required: configuration must change before login can work.
|
|
4012, // Invalid API version: discord.js/package config must change.
|
|
4013, // Invalid intents: bot gateway intents are misconfigured.
|
|
4014, // Disallowed intents: privileged intents must be enabled in Discord.
|
|
]);
|
|
|
|
// Forward Content.metadata onto the persisted Memory (e.g. `transient: true`
|
|
// for orchestrator status posts). Plain-object guard so arrays/instances don't leak through.
|
|
function extractContentMetadata(
|
|
content: Content | undefined,
|
|
): Record<string, unknown> {
|
|
const meta = content?.metadata;
|
|
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return {};
|
|
return meta as Record<string, unknown>;
|
|
}
|
|
|
|
function isGuildTextBasedChannel(
|
|
channel: Channel | null,
|
|
): channel is GuildTextBasedChannel {
|
|
if (!channel) return false;
|
|
const candidate = channel as Channel & {
|
|
isTextBased?: () => boolean;
|
|
guild?: unknown;
|
|
};
|
|
return candidate.isTextBased?.() === true && Boolean(candidate.guild);
|
|
}
|
|
|
|
type ConnectorFetchMessagesParams = {
|
|
target?: TargetInfo;
|
|
accountId?: string;
|
|
limit?: number;
|
|
before?: string;
|
|
after?: string;
|
|
cursor?: string;
|
|
channelId?: string;
|
|
roomId?: UUID;
|
|
threadId?: string;
|
|
};
|
|
|
|
type ConnectorSearchMessagesParams = ConnectorFetchMessagesParams & {
|
|
query?: string;
|
|
author?: string;
|
|
};
|
|
|
|
type ConnectorMessageMutationParams = {
|
|
target?: TargetInfo;
|
|
accountId?: string;
|
|
channelId?: string;
|
|
roomId?: UUID;
|
|
threadId?: string;
|
|
messageId?: string;
|
|
emoji?: string;
|
|
remove?: boolean;
|
|
pin?: boolean;
|
|
text?: string;
|
|
content?: Content;
|
|
};
|
|
|
|
type ConnectorChannelMutationParams = {
|
|
target?: TargetInfo;
|
|
accountId?: string;
|
|
channelId?: string;
|
|
roomId?: UUID;
|
|
alias?: string;
|
|
};
|
|
|
|
type ConnectorUserLookupParams = {
|
|
accountId?: string;
|
|
userId?: string;
|
|
username?: string;
|
|
handle?: string;
|
|
query?: string;
|
|
};
|
|
|
|
type ConnectorTypingParams = MessageConnectorTypingParams & {
|
|
accountId?: string;
|
|
channelId?: string;
|
|
roomId?: UUID;
|
|
};
|
|
|
|
type ConnectorCreateThreadParams = MessageConnectorCreateThreadParams & {
|
|
accountId?: string;
|
|
channelId?: string;
|
|
roomId?: UUID;
|
|
};
|
|
|
|
type ConnectorPostToThreadParams = MessageConnectorPostToThreadParams & {
|
|
accountId?: string;
|
|
};
|
|
|
|
function discordReplyReferenceFromContent(
|
|
content: Content,
|
|
): string | undefined {
|
|
const record = content as Record<string, unknown>;
|
|
const metadata =
|
|
record.metadata && typeof record.metadata === "object"
|
|
? (record.metadata as Record<string, unknown>)
|
|
: undefined;
|
|
const candidates = [
|
|
record.replyToExternalMessageId,
|
|
record.inReplyTo,
|
|
metadata?.originConnectorMessageId,
|
|
metadata?.replyToExternalMessageId,
|
|
metadata?.platformMessageId,
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate === "string" && /^\d{16,22}$/.test(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
type ExtendedMessageConnectorRegistration = MessageConnectorRegistration & {
|
|
listServers?: (context: MessageConnectorQueryContext) => Promise<World[]>;
|
|
fetchMessages?: (
|
|
context: MessageConnectorQueryContext,
|
|
params: ConnectorFetchMessagesParams,
|
|
) => Promise<Memory[]>;
|
|
searchMessages?: (
|
|
context: MessageConnectorQueryContext,
|
|
params: ConnectorSearchMessagesParams,
|
|
) => Promise<Memory[]>;
|
|
reactHandler?: (
|
|
runtime: IAgentRuntime,
|
|
params: ConnectorMessageMutationParams,
|
|
) => Promise<void>;
|
|
editHandler?: (
|
|
runtime: IAgentRuntime,
|
|
params: ConnectorMessageMutationParams,
|
|
) => Promise<Memory>;
|
|
deleteHandler?: (
|
|
runtime: IAgentRuntime,
|
|
params: ConnectorMessageMutationParams,
|
|
) => Promise<void>;
|
|
pinHandler?: (
|
|
runtime: IAgentRuntime,
|
|
params: ConnectorMessageMutationParams,
|
|
) => Promise<void>;
|
|
joinHandler?: (
|
|
runtime: IAgentRuntime,
|
|
params: ConnectorChannelMutationParams,
|
|
) => Promise<Room | null>;
|
|
leaveHandler?: (
|
|
runtime: IAgentRuntime,
|
|
params: ConnectorChannelMutationParams,
|
|
) => Promise<void>;
|
|
getUser?: (
|
|
runtime: IAgentRuntime,
|
|
params: ConnectorUserLookupParams,
|
|
) => Promise<unknown>;
|
|
};
|
|
|
|
const DISCORD_CONNECTOR_CONTEXTS = ["social", "connectors"];
|
|
const DISCORD_CONNECTOR_CAPABILITIES = [
|
|
"send_message",
|
|
"read_messages",
|
|
"search_messages",
|
|
"resolve_targets",
|
|
"list_rooms",
|
|
"list_servers",
|
|
"chat_context",
|
|
"user_context",
|
|
"react_message",
|
|
"edit_message",
|
|
"delete_message",
|
|
"pin_message",
|
|
"join_channel",
|
|
"leave_channel",
|
|
"get_user",
|
|
"typing_indicator",
|
|
"create_thread",
|
|
"post_to_thread",
|
|
"webhook_identity",
|
|
"rich_components",
|
|
"rich_embed",
|
|
];
|
|
|
|
function normalizeDiscordConnectorQuery(value: string): string {
|
|
return value
|
|
.trim()
|
|
.replace(/^<#(\d+)>$/, "$1")
|
|
.replace(/^<@!?(\d+)>$/, "$1")
|
|
.replace(/^#/, "")
|
|
.replace(/^@/, "")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function scoreDiscordConnectorMatch(
|
|
query: string,
|
|
id: string,
|
|
labels: Array<string | null | undefined>,
|
|
): number {
|
|
if (!query) {
|
|
return 0.45;
|
|
}
|
|
if (id === query) {
|
|
return 1;
|
|
}
|
|
|
|
let bestScore = 0;
|
|
for (const label of labels) {
|
|
const normalized = label?.trim().toLowerCase();
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
if (normalized === query) {
|
|
bestScore = Math.max(bestScore, 0.95);
|
|
} else if (normalized.startsWith(query)) {
|
|
bestScore = Math.max(bestScore, 0.85);
|
|
} else if (normalized.includes(query)) {
|
|
bestScore = Math.max(bestScore, 0.7);
|
|
}
|
|
}
|
|
return bestScore;
|
|
}
|
|
|
|
function isDiscordTextTarget(channel: unknown): boolean {
|
|
const maybeChannel = channel as {
|
|
isTextBased?: () => boolean;
|
|
isVoiceBased?: () => boolean;
|
|
};
|
|
return Boolean(
|
|
maybeChannel.isTextBased?.() && !maybeChannel.isVoiceBased?.(),
|
|
);
|
|
}
|
|
|
|
function normalizeDiscordTargetUserId(value: unknown): string | null {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return DISCORD_SNOWFLAKE_PATTERN.test(trimmed) ? trimmed : null;
|
|
}
|
|
|
|
function extractDiscordUserIdFromMetadata(metadata: unknown): string | null {
|
|
if (!metadata || typeof metadata !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const record = metadata as Record<string, unknown>;
|
|
const discord =
|
|
record.discord && typeof record.discord === "object"
|
|
? (record.discord as Record<string, unknown>)
|
|
: null;
|
|
|
|
return (
|
|
normalizeDiscordTargetUserId(discord?.userId) ??
|
|
normalizeDiscordTargetUserId(discord?.id) ??
|
|
normalizeDiscordTargetUserId(record.originalId)
|
|
);
|
|
}
|
|
|
|
function stringArraySetting(value: unknown): string[] | undefined {
|
|
if (Array.isArray(value)) {
|
|
const values = value
|
|
.map((item) => String(item).trim())
|
|
.filter((item) => item.length > 0);
|
|
return values.length > 0 ? values : undefined;
|
|
}
|
|
if (typeof value === "string" && value.trim()) {
|
|
const values = value
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter((item) => item.length > 0);
|
|
return values.length > 0 ? values : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function accountIdFromRecord(value: unknown): string | undefined {
|
|
if (!value || typeof value !== "object") {
|
|
return undefined;
|
|
}
|
|
const accountId = (value as { accountId?: unknown }).accountId;
|
|
return typeof accountId === "string" && accountId.trim()
|
|
? accountId.trim()
|
|
: undefined;
|
|
}
|
|
|
|
type DiscordAccountSettingsConfig = ResolvedDiscordAccount["config"] &
|
|
Partial<DiscordSettings> & {
|
|
allowedChannelIds?: string[];
|
|
channelIds?: string[];
|
|
listenChannelIds?: string[];
|
|
dm?: {
|
|
policy?: DiscordSettings["dmPolicy"];
|
|
allowFrom?: Array<string | number>;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* DiscordService class representing a service for interacting with Discord.
|
|
* @extends Service
|
|
* @implements IDiscordService
|
|
* @property {string} serviceType - The type of service, set to DISCORD_SERVICE_NAME.
|
|
* @property {string} capabilityDescription - A description of the service's capabilities.
|
|
* @property {DiscordJsClient} client - The DiscordJsClient used for communication.
|
|
* @property {Character} character - The character associated with the service.
|
|
* @property {MessageManager} messageManager - The manager for handling messages.
|
|
* @property {VoiceManager} voiceManager - The manager for handling voice communication.
|
|
*/
|
|
|
|
export class DiscordService extends Service implements IDiscordService {
|
|
// Override runtime type for messageServerId cross-core compatibility (see compat.ts)
|
|
protected declare runtime: ICompatRuntime;
|
|
|
|
static serviceType: string = DISCORD_SERVICE_NAME;
|
|
capabilityDescription =
|
|
"The agent is able to send and receive messages on discord";
|
|
/**
|
|
* Connector account ID this service instance speaks for. Single-account
|
|
* env-only deployments use DEFAULT_ACCOUNT_ID. When the multi-account
|
|
* pool is wired in, each pool slot owns one client and one accountId.
|
|
*/
|
|
public accountId: string = DEFAULT_ACCOUNT_ID;
|
|
private defaultAccountId = DEFAULT_ACCOUNT_ID;
|
|
private readonly accountPool = new DiscordAccountClientPool();
|
|
private readonly voiceTargets = new DiscordVoiceTargetRegistry();
|
|
private readonly audioSinks = new Map<string, IDiscordAudioSink>();
|
|
client: DiscordJsClient | null = null;
|
|
character: Character;
|
|
discordSettings: DiscordSettings;
|
|
messageManager?: MessageManager;
|
|
voiceManager?: VoiceManager;
|
|
private channelDebouncer?: ChannelDebouncer;
|
|
private _loginFailed = false;
|
|
private timeouts: ReturnType<typeof setTimeout>[] = [];
|
|
public clientReadyPromise: Promise<void> | null = null;
|
|
/**
|
|
* List of allowed channel IDs (parsed from CHANNEL_IDS env var).
|
|
* If undefined, all channels are allowed.
|
|
*/
|
|
private allowedChannelIds?: string[];
|
|
|
|
/**
|
|
* Set of dynamically added channel IDs through joinChannel action.
|
|
* These are merged with allowedChannelIds for runtime channel management.
|
|
*/
|
|
private dynamicChannelIds: Set<string> = new Set();
|
|
private ownerDiscordUserIds: Set<string> = new Set();
|
|
|
|
// Slash command registration state. Mutated by registerSlashCommands and
|
|
// read by onReadyExtracted via the InteractionServiceInternals contract.
|
|
public slashCommands: DiscordSlashCommand[] = [];
|
|
private commandRegistrationQueue: Promise<void> = Promise.resolve();
|
|
public allowAllSlashCommands: Set<string> = new Set();
|
|
|
|
/**
|
|
* Resolves Discord IDs that should alias to the canonical owner entity.
|
|
* Discord team members stay separate entities and are registered only as
|
|
* connector admins so their messages remain attributable.
|
|
* Called from the extracted onReady handler once the client is ready.
|
|
*/
|
|
public async refreshOwnerDiscordUserIds(
|
|
client: DiscordJsClient,
|
|
): Promise<void> {
|
|
const explicitSetting = this.runtime.getSetting(
|
|
"ELIZA_DISCORD_OWNER_USER_IDS_JSON",
|
|
);
|
|
const hasExplicitSetting =
|
|
explicitSetting !== undefined &&
|
|
explicitSetting !== null &&
|
|
!(typeof explicitSetting === "string" && explicitSetting.trim() === "");
|
|
|
|
let ownerIds: string[];
|
|
let teamAdminIds: string[] = [];
|
|
if (hasExplicitSetting) {
|
|
ownerIds = parseDiscordOwnerUserIds(
|
|
Array.isArray(explicitSetting)
|
|
? explicitSetting
|
|
: typeof explicitSetting === "string"
|
|
? explicitSetting
|
|
: [String(explicitSetting)],
|
|
);
|
|
} else {
|
|
let application: unknown;
|
|
try {
|
|
application =
|
|
client.application && typeof client.application.fetch === "function"
|
|
? await client.application.fetch()
|
|
: client.application;
|
|
} catch (error) {
|
|
this.runtime.logger.error(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Failed to fetch Discord application — owner will not be recognized. " +
|
|
"Set ELIZA_DISCORD_OWNER_USER_IDS_JSON to fix this.",
|
|
);
|
|
application = client.application;
|
|
}
|
|
ownerIds = [...new Set(extractDiscordOwnerUserIds(application))];
|
|
teamAdminIds = [
|
|
...new Set(
|
|
extractDiscordTeamAdminUserIds(application).filter(
|
|
(userId) => !ownerIds.includes(userId),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
this.ownerDiscordUserIds = new Set(ownerIds);
|
|
if (ownerIds.length === 0) {
|
|
this.runtime.logger.warn(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
},
|
|
"No Discord owner user IDs resolved — owner will not be recognized from Discord messages. " +
|
|
"Set ELIZA_DISCORD_OWNER_USER_IDS_JSON to fix this.",
|
|
);
|
|
}
|
|
if (ownerIds.length === 0 && teamAdminIds.length === 0) {
|
|
return;
|
|
}
|
|
const existingWhitelist = getConnectorAdminWhitelist(this.runtime);
|
|
const nextDiscordAdmins = [
|
|
...new Set([
|
|
...(existingWhitelist.discord ?? []),
|
|
...ownerIds,
|
|
...teamAdminIds,
|
|
]),
|
|
];
|
|
setConnectorAdminWhitelist(this.runtime, {
|
|
...existingWhitelist,
|
|
discord: nextDiscordAdmins,
|
|
});
|
|
this.runtime.logger.info(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
ownerDiscordUserIds: ownerIds,
|
|
teamAdminDiscordUserIds: teamAdminIds,
|
|
},
|
|
"Resolved Discord privileged identities for owner mapping and connector admin access",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Registers slash commands with Discord. Called from the onReady event
|
|
* handler via the DISCORD_REGISTER_COMMANDS event emitted by
|
|
* registerBuiltinSlashCommands(). Merges incoming commands with the
|
|
* existing set, then pushes them to Discord both globally (for DMs) and
|
|
* per-guild (for instant availability).
|
|
*/
|
|
public async registerSlashCommands(
|
|
commands: DiscordSlashCommand[],
|
|
accountId?: string | null,
|
|
): Promise<void> {
|
|
const service = this;
|
|
// `runtime` is protected on DiscordService, so the host object copies it
|
|
// rather than passing `this` directly (which TS structurally rejects: a
|
|
// protected member can't satisfy a public interface field). The mutable
|
|
// fields are live getters/setters so the extracted registration logic's
|
|
// writes (queue chaining, merged command list) land back on the service.
|
|
const host: SlashCommandRegistrationHost = {
|
|
runtime: this.runtime,
|
|
get slashCommands() {
|
|
return service.slashCommands;
|
|
},
|
|
set slashCommands(value) {
|
|
service.slashCommands = value;
|
|
},
|
|
allowAllSlashCommands: this.allowAllSlashCommands,
|
|
get commandRegistrationQueue() {
|
|
return service.commandRegistrationQueue;
|
|
},
|
|
set commandRegistrationQueue(value) {
|
|
service.commandRegistrationQueue = value;
|
|
},
|
|
requireAccountState: (id) => this.requireAccountState(id),
|
|
};
|
|
return registerDiscordSlashCommands(
|
|
host,
|
|
commands,
|
|
parseBooleanFromText,
|
|
accountId,
|
|
);
|
|
}
|
|
|
|
private async resolveDiscordTargetUserId(
|
|
targetEntityId: string,
|
|
): Promise<string | null> {
|
|
const directId = normalizeDiscordTargetUserId(targetEntityId);
|
|
if (directId) {
|
|
return directId;
|
|
}
|
|
|
|
if (targetEntityId === resolveElizaOwnerEntityId(this.runtime)) {
|
|
const knownOwnerUserId = this.ownerDiscordUserIds.values().next().value;
|
|
if (typeof knownOwnerUserId === "string" && knownOwnerUserId.length > 0) {
|
|
return knownOwnerUserId;
|
|
}
|
|
}
|
|
|
|
const directEntity = this.runtime.getEntityById
|
|
? await this.runtime.getEntityById(targetEntityId as UUID)
|
|
: null;
|
|
const directMetadataUserId = extractDiscordUserIdFromMetadata(
|
|
directEntity?.metadata,
|
|
);
|
|
if (directMetadataUserId) {
|
|
return directMetadataUserId;
|
|
}
|
|
|
|
if (typeof this.runtime.getRelationships !== "function") {
|
|
return null;
|
|
}
|
|
|
|
const identityLinks = await this.runtime.getRelationships({
|
|
entityIds: [targetEntityId as UUID],
|
|
tags: ["identity_link"],
|
|
});
|
|
for (const relationship of identityLinks) {
|
|
const metadata =
|
|
relationship.metadata && typeof relationship.metadata === "object"
|
|
? (relationship.metadata as Record<string, unknown>)
|
|
: null;
|
|
if (metadata?.status !== "confirmed") {
|
|
continue;
|
|
}
|
|
const linkedEntityId =
|
|
relationship.sourceEntityId === targetEntityId
|
|
? relationship.targetEntityId
|
|
: relationship.targetEntityId === targetEntityId
|
|
? relationship.sourceEntityId
|
|
: null;
|
|
if (!linkedEntityId || linkedEntityId === targetEntityId) {
|
|
continue;
|
|
}
|
|
const linkedEntity = this.runtime.getEntityById
|
|
? await this.runtime.getEntityById(linkedEntityId as UUID)
|
|
: null;
|
|
const linkedMetadataUserId = extractDiscordUserIdFromMetadata(
|
|
linkedEntity?.metadata,
|
|
);
|
|
if (linkedMetadataUserId) {
|
|
return linkedMetadataUserId;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private resolveDiscordSettingsForAccount(
|
|
account: ResolvedDiscordAccount,
|
|
): DiscordSettings {
|
|
const base = getDiscordSettings(this.runtime);
|
|
const config = account.config as DiscordAccountSettingsConfig;
|
|
const dmAllowFrom = config.dm?.allowFrom
|
|
?.map((value) => String(value).trim())
|
|
.filter((value) => value.length > 0);
|
|
|
|
return {
|
|
...base,
|
|
allowedChannelIds:
|
|
stringArraySetting(config.allowedChannelIds) ??
|
|
stringArraySetting(config.channelIds) ??
|
|
base.allowedChannelIds,
|
|
shouldIgnoreBotMessages:
|
|
config.shouldIgnoreBotMessages ?? base.shouldIgnoreBotMessages,
|
|
shouldIgnoreDirectMessages:
|
|
config.shouldIgnoreDirectMessages ?? base.shouldIgnoreDirectMessages,
|
|
shouldRespondOnlyToMentions:
|
|
config.shouldRespondOnlyToMentions ?? base.shouldRespondOnlyToMentions,
|
|
replyToMode: config.replyToMode ?? base.replyToMode,
|
|
dmPolicy: config.dm?.policy ?? base.dmPolicy,
|
|
allowFrom:
|
|
dmAllowFrom && dmAllowFrom.length > 0 ? dmAllowFrom : base.allowFrom,
|
|
syncProfile: config.syncProfile ?? base.syncProfile,
|
|
profileName: config.profileName ?? base.profileName,
|
|
profileAvatar: config.profileAvatar ?? base.profileAvatar,
|
|
autoReply: config.autoReply ?? base.autoReply,
|
|
};
|
|
}
|
|
|
|
private resolveListenChannelIdsForAccount(
|
|
account: ResolvedDiscordAccount,
|
|
): string[] | undefined {
|
|
return (
|
|
stringArraySetting(
|
|
(account.config as DiscordAccountSettingsConfig).listenChannelIds,
|
|
) ??
|
|
stringArraySetting(this.runtime.getSetting("DISCORD_LISTEN_CHANNEL_IDS"))
|
|
);
|
|
}
|
|
|
|
private createDiscordJsClient(): DiscordJsClient {
|
|
return new DiscordJsClient({
|
|
intents: [
|
|
GatewayIntentBits.Guilds,
|
|
GatewayIntentBits.GuildMembers,
|
|
GatewayIntentBits.GuildPresences,
|
|
GatewayIntentBits.DirectMessages,
|
|
GatewayIntentBits.GuildVoiceStates,
|
|
GatewayIntentBits.MessageContent,
|
|
GatewayIntentBits.GuildMessages,
|
|
GatewayIntentBits.DirectMessageTyping,
|
|
GatewayIntentBits.GuildMessageTyping,
|
|
GatewayIntentBits.GuildMessageReactions,
|
|
],
|
|
partials: [
|
|
Partials.Channel,
|
|
Partials.Message,
|
|
Partials.User,
|
|
Partials.Reaction,
|
|
],
|
|
});
|
|
}
|
|
|
|
private syncLegacyDefaultAliases(
|
|
state: DiscordAccountClientState | null,
|
|
): void {
|
|
this.accountId = state?.accountId ?? this.defaultAccountId;
|
|
this.client = state?.client ?? null;
|
|
this.discordSettings = state?.settings ?? getDiscordSettings(this.runtime);
|
|
this.messageManager = state?.messageManager;
|
|
this.voiceManager = state?.voiceManager;
|
|
this.channelDebouncer = state?.channelDebouncer;
|
|
this.allowedChannelIds = state?.allowedChannelIds;
|
|
this.dynamicChannelIds = state?.dynamicChannelIds ?? new Set();
|
|
this.clientReadyPromise = state?.clientReadyPromise ?? null;
|
|
}
|
|
|
|
private getAccountState(
|
|
accountId?: string | null,
|
|
): DiscordAccountClientState | null {
|
|
const requested = accountId
|
|
? normalizeAccountId(accountId)
|
|
: this.defaultAccountId;
|
|
return this.accountPool.get(requested) ?? null;
|
|
}
|
|
|
|
private getDefaultAccountState(): DiscordAccountClientState | null {
|
|
return this.accountPool.getDefault() ?? null;
|
|
}
|
|
|
|
private requireAccountState(
|
|
accountId?: string | null,
|
|
): DiscordAccountClientState {
|
|
const normalized = accountId
|
|
? normalizeAccountId(accountId)
|
|
: this.defaultAccountId;
|
|
const state = this.getAccountState(normalized);
|
|
if (!state) {
|
|
throw new Error(`Discord account is not configured: ${normalized}`);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
private resolveAccountIdFromTarget(
|
|
target?: TargetInfo | null,
|
|
fallback?: unknown,
|
|
): string {
|
|
return normalizeAccountId(
|
|
accountIdFromRecord(target) ??
|
|
accountIdFromRecord(fallback) ??
|
|
this.defaultAccountId,
|
|
);
|
|
}
|
|
|
|
public getDefaultAccountId(): string {
|
|
return this.defaultAccountId;
|
|
}
|
|
|
|
public getAccountIds(): string[] {
|
|
return this.accountPool.listAccountIds();
|
|
}
|
|
|
|
public getClient(accountId?: string | null): DiscordJsClient | null {
|
|
const state = this.getAccountState(accountId);
|
|
if (state?.client) {
|
|
return state.client;
|
|
}
|
|
const requested = accountId
|
|
? normalizeAccountId(accountId)
|
|
: this.defaultAccountId;
|
|
const defaultAccountId = this.defaultAccountId;
|
|
return requested === defaultAccountId ? (this.client ?? null) : null;
|
|
}
|
|
|
|
public getVoiceTargets(query?: {
|
|
accountId?: string | null;
|
|
guildId?: string | null;
|
|
channelId?: string | null;
|
|
}): DiscordVoiceTarget[] {
|
|
const targets = this.voiceTargets.list();
|
|
if (!query) {
|
|
return targets;
|
|
}
|
|
return targets.filter((target) => {
|
|
if (
|
|
query.accountId &&
|
|
target.accountId !== normalizeAccountId(query.accountId)
|
|
) {
|
|
return false;
|
|
}
|
|
if (query.guildId && target.guildId !== query.guildId) {
|
|
return false;
|
|
}
|
|
if (query.channelId && target.channelId !== query.channelId) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
public getVoiceTarget(query: {
|
|
targetId?: string | null;
|
|
accountId?: string | null;
|
|
guildId?: string | null;
|
|
channelId?: string | null;
|
|
}): DiscordVoiceTarget | null {
|
|
if (query.targetId) {
|
|
return this.voiceTargets.get(query.targetId);
|
|
}
|
|
return this.voiceTargets.find({
|
|
accountId: query.accountId
|
|
? normalizeAccountId(query.accountId)
|
|
: undefined,
|
|
guildId: query.guildId,
|
|
channelId: query.channelId,
|
|
});
|
|
}
|
|
|
|
public getAudioSink(query: {
|
|
targetId?: string | null;
|
|
accountId?: string | null;
|
|
guildId?: string | null;
|
|
channelId?: string | null;
|
|
}): IDiscordAudioSink | null {
|
|
const target = this.getVoiceTarget(query);
|
|
if (!target) {
|
|
return null;
|
|
}
|
|
const existing = this.audioSinks.get(target.id);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const sink = new DiscordVoiceTargetAudioSink(target);
|
|
this.audioSinks.set(target.id, sink);
|
|
return sink;
|
|
}
|
|
|
|
public async setListeningActivity(
|
|
activity: string,
|
|
options?: { accountId?: string | null; url?: string },
|
|
): Promise<boolean> {
|
|
const accountId = normalizeAccountId(
|
|
options?.accountId ?? this.defaultAccountId,
|
|
);
|
|
const client = this.getClient(accountId);
|
|
if (!client?.isReady() || !client.user) {
|
|
this.runtime.logger.warn(
|
|
{ src: "plugin:discord", agentId: this.runtime.agentId, accountId },
|
|
"Cannot set Discord listening activity before client is ready",
|
|
);
|
|
return false;
|
|
}
|
|
await client.user.setActivity(activity, {
|
|
type: ActivityType.Listening,
|
|
url: options?.url,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
public async clearActivity(options?: {
|
|
accountId?: string | null;
|
|
}): Promise<boolean> {
|
|
const accountId = normalizeAccountId(
|
|
options?.accountId ?? this.defaultAccountId,
|
|
);
|
|
const client = this.getClient(accountId);
|
|
if (!client?.isReady() || !client.user) {
|
|
this.runtime.logger.warn(
|
|
{ src: "plugin:discord", agentId: this.runtime.agentId, accountId },
|
|
"Cannot clear Discord activity before client is ready",
|
|
);
|
|
return false;
|
|
}
|
|
client.user.setPresence({ activities: [] });
|
|
return true;
|
|
}
|
|
|
|
public async setVoiceChannelStatus(
|
|
channelId: string,
|
|
status: string,
|
|
options?: { accountId?: string | null },
|
|
): Promise<boolean> {
|
|
const accountId = normalizeAccountId(
|
|
options?.accountId ?? this.defaultAccountId,
|
|
);
|
|
const client = this.getClient(accountId);
|
|
if (!client?.isReady()) {
|
|
this.runtime.logger.warn(
|
|
{ src: "plugin:discord", agentId: this.runtime.agentId, accountId },
|
|
"Cannot set Discord voice channel status before client is ready",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const channel = await client.channels.fetch(channelId);
|
|
if (!channel?.isVoiceBased?.()) {
|
|
this.runtime.logger.warn(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
accountId,
|
|
channelId,
|
|
},
|
|
"Discord channel is not a voice channel",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const voiceChannel = channel as BaseGuildVoiceChannel;
|
|
const normalizedStatus = status.trim().slice(0, 500);
|
|
await client.rest.put(`/channels/${voiceChannel.id}/voice-status`, {
|
|
body: {
|
|
status: normalizedStatus || null,
|
|
},
|
|
});
|
|
return true;
|
|
}
|
|
|
|
private registerVoiceTarget(target: DiscordVoiceTargetRegistration): void {
|
|
this.voiceTargets.register({
|
|
...target,
|
|
accountId: normalizeAccountId(target.accountId),
|
|
});
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
accountId: target.accountId,
|
|
guildId: target.channel.guild.id,
|
|
channelId: target.channel.id,
|
|
},
|
|
"Registered Discord voice target",
|
|
);
|
|
}
|
|
|
|
private unregisterVoiceTarget(
|
|
accountId: string,
|
|
guildId: string,
|
|
channelId: string,
|
|
): void {
|
|
const normalizedAccountId = normalizeAccountId(accountId);
|
|
const target = this.voiceTargets.find({
|
|
accountId: normalizedAccountId,
|
|
guildId,
|
|
channelId,
|
|
});
|
|
if (target) {
|
|
this.audioSinks.get(target.id)?.destroy();
|
|
this.audioSinks.delete(target.id);
|
|
}
|
|
this.voiceTargets.unregister(normalizedAccountId, guildId, channelId);
|
|
}
|
|
|
|
private isVoiceChannelClaimed(guildId: string, channelId: string): boolean {
|
|
return this.voiceTargets
|
|
.list()
|
|
.some(
|
|
(target) =>
|
|
target.guildId === guildId && target.channelId === channelId,
|
|
);
|
|
}
|
|
|
|
public getAccountLabel(accountId?: string | null): string {
|
|
const state = this.getAccountState(accountId);
|
|
return state?.account.name ?? state?.accountId ?? this.defaultAccountId;
|
|
}
|
|
|
|
private createAccountServiceFacade(
|
|
state?: DiscordAccountClientState | null,
|
|
): DiscordAccountServiceFacade {
|
|
const parent = this;
|
|
const accountId = () => state?.accountId ?? parent.accountId;
|
|
const accountClient = (): DiscordJsClient => {
|
|
const client = state?.client ?? parent.client;
|
|
if (!client) {
|
|
throw new Error(
|
|
`Discord client is not available for account ${accountId()}`,
|
|
);
|
|
}
|
|
return client;
|
|
};
|
|
const accountSettings = (): DiscordSettingsForEvents => {
|
|
const settings = state?.settings ?? parent.discordSettings;
|
|
return {
|
|
...settings,
|
|
shouldIgnoreBotMessages: settings.shouldIgnoreBotMessages ?? false,
|
|
};
|
|
};
|
|
const facade: DiscordAccountServiceFacade = {
|
|
get accountId() {
|
|
return accountId();
|
|
},
|
|
get client() {
|
|
return accountClient();
|
|
},
|
|
set client(value: DiscordJsClient) {
|
|
if (state) {
|
|
state.client = value;
|
|
}
|
|
if (!state || state.accountId === parent.defaultAccountId) {
|
|
parent.client = value;
|
|
}
|
|
},
|
|
get runtime() {
|
|
return parent.runtime;
|
|
},
|
|
get character() {
|
|
return parent.character;
|
|
},
|
|
get discordSettings() {
|
|
return accountSettings();
|
|
},
|
|
set discordSettings(value: DiscordSettingsForEvents) {
|
|
if (state) {
|
|
state.settings = value;
|
|
}
|
|
if (!state || state.accountId === parent.defaultAccountId) {
|
|
parent.discordSettings = value;
|
|
}
|
|
},
|
|
get messageManager() {
|
|
return state?.messageManager ?? parent.messageManager;
|
|
},
|
|
set messageManager(value: MessageManager | undefined) {
|
|
if (state) {
|
|
state.messageManager = value;
|
|
}
|
|
if (!state || state.accountId === parent.defaultAccountId) {
|
|
parent.messageManager = value;
|
|
}
|
|
},
|
|
get voiceManager() {
|
|
return state?.voiceManager ?? parent.voiceManager;
|
|
},
|
|
set voiceManager(value: VoiceManager | undefined) {
|
|
if (state) {
|
|
state.voiceManager = value;
|
|
}
|
|
if (!state || state.accountId === parent.defaultAccountId) {
|
|
parent.voiceManager = value;
|
|
}
|
|
},
|
|
get channelDebouncer() {
|
|
return state?.channelDebouncer ?? parent.channelDebouncer;
|
|
},
|
|
set channelDebouncer(value: ChannelDebouncer | undefined) {
|
|
if (state) {
|
|
state.channelDebouncer = value;
|
|
}
|
|
if (!state || state.accountId === parent.defaultAccountId) {
|
|
parent.channelDebouncer = value;
|
|
}
|
|
},
|
|
get allowedChannelIds() {
|
|
return state?.allowedChannelIds ?? parent.allowedChannelIds;
|
|
},
|
|
set allowedChannelIds(value: string[] | undefined) {
|
|
if (state) {
|
|
state.allowedChannelIds = value;
|
|
}
|
|
if (!state || state.accountId === parent.defaultAccountId) {
|
|
parent.allowedChannelIds = value;
|
|
}
|
|
},
|
|
get listenChannelIds() {
|
|
return state?.listenChannelIds;
|
|
},
|
|
get allowAllSlashCommands() {
|
|
return parent.allowAllSlashCommands;
|
|
},
|
|
get slashCommands() {
|
|
return parent.slashCommands;
|
|
},
|
|
set slashCommands(value: DiscordSlashCommand[]) {
|
|
parent.slashCommands = value;
|
|
},
|
|
get commandRegistrationQueue() {
|
|
return parent.commandRegistrationQueue;
|
|
},
|
|
set commandRegistrationQueue(value: Promise<void>) {
|
|
parent.commandRegistrationQueue = value;
|
|
},
|
|
get timeouts() {
|
|
return parent.timeouts;
|
|
},
|
|
isChannelAllowed: (channelId: string) =>
|
|
parent.isChannelAllowed(channelId, state?.accountId),
|
|
addAllowedChannel: (channelId: string) =>
|
|
parent.addAllowedChannel(channelId, state?.accountId),
|
|
removeAllowedChannel: (channelId: string) =>
|
|
parent.removeAllowedChannel(channelId, state?.accountId),
|
|
getAllowedChannels: () => parent.getAllowedChannels(state?.accountId),
|
|
registerVoiceTarget: (target: DiscordVoiceTargetRegistration) =>
|
|
parent.registerVoiceTarget(target),
|
|
unregisterVoiceTarget: (
|
|
targetAccountId: string,
|
|
guildId: string,
|
|
channelId: string,
|
|
) => parent.unregisterVoiceTarget(targetAccountId, guildId, channelId),
|
|
isVoiceChannelClaimed: (guildId: string, channelId: string) =>
|
|
parent.isVoiceChannelClaimed(guildId, channelId),
|
|
resolveDiscordEntityId: (userId: string) =>
|
|
parent.resolveDiscordEntityId(userId),
|
|
getChannelType: (channel: Channel) => parent.getChannelType(channel),
|
|
isGuildTextBasedChannel,
|
|
buildMemoryFromMessage: (
|
|
message: Message,
|
|
options?: BuildMemoryFromMessageOptions,
|
|
) =>
|
|
parent.buildMemoryFromMessage(message, {
|
|
...options,
|
|
accountId: state?.accountId ?? parent.accountId,
|
|
}),
|
|
handleInteractionCreate: (interaction: Interaction) =>
|
|
parent.handleInteractionCreateForAccount(accountId(), interaction),
|
|
handleGuildCreate: (guild: Guild) =>
|
|
parent.handleGuildCreateForAccount(accountId(), guild),
|
|
handleGuildMemberAdd: (member: GuildMember) =>
|
|
parent.handleGuildMemberAddForAccount(accountId(), member),
|
|
handleReactionAdd: (
|
|
reaction: MessageReaction | PartialMessageReaction,
|
|
user: User | PartialUser,
|
|
) => parent.handleReactionAddForAccount(accountId(), reaction, user),
|
|
handleReactionRemove: (
|
|
reaction: MessageReaction | PartialMessageReaction,
|
|
user: User | PartialUser,
|
|
) => parent.handleReactionRemoveForAccount(accountId(), reaction, user),
|
|
refreshOwnerDiscordUserIds: (client: unknown) => {
|
|
if (!(client instanceof DiscordJsClient)) {
|
|
throw new Error("Discord client is not available for owner refresh");
|
|
}
|
|
return parent.refreshOwnerDiscordUserIds(client);
|
|
},
|
|
registerSlashCommands: (commands: DiscordSlashCommand[]) =>
|
|
parent.registerSlashCommands(commands, state?.accountId),
|
|
clientReadyPromise:
|
|
state?.clientReadyPromise ?? parent.clientReadyPromise,
|
|
accountToken: state?.account.token,
|
|
};
|
|
return facade;
|
|
}
|
|
|
|
private initializeAccount(account: ResolvedDiscordAccount): void {
|
|
const accountId = normalizeAccountId(account.accountId);
|
|
const settings = this.resolveDiscordSettingsForAccount(account);
|
|
const state: DiscordAccountClientState = {
|
|
accountId,
|
|
account: { ...account, accountId },
|
|
client: this.createDiscordJsClient(),
|
|
settings,
|
|
allowedChannelIds: settings.allowedChannelIds,
|
|
listenChannelIds: this.resolveListenChannelIdsForAccount(account),
|
|
dynamicChannelIds: new Set(),
|
|
clientReadyPromise: null,
|
|
loginFailed: false,
|
|
loginStopRequested: false,
|
|
};
|
|
|
|
this.accountPool.set(state);
|
|
const facade = this.createAccountServiceFacade(state);
|
|
state.voiceManager = new VoiceManager(facade, this.runtime);
|
|
state.messageManager = new MessageManager(facade, this.runtime);
|
|
|
|
// Initial login now retries with backoff instead of settling terminal on
|
|
// a transient transport failure (#15855). The promise resolves on the
|
|
// first successful ClientReady (any attempt) and only rejects on a
|
|
// terminal post-ready onReady failure — never on a login rejection.
|
|
state.clientReadyPromise = new Promise<void>((resolve, reject) => {
|
|
state.loginReadyReject = reject;
|
|
this.attemptDiscordLogin(state, account.token, 0, resolve, reject);
|
|
});
|
|
|
|
state.clientReadyPromise.catch((error) => {
|
|
if (state.loginStopRequested) {
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
accountId: state.accountId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Discord client ready promise rejected during service stop",
|
|
);
|
|
return;
|
|
}
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
accountId: state.accountId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Discord client ready promise rejected (already logged above)",
|
|
);
|
|
state.loginFailed = true;
|
|
if (state.accountId === this.defaultAccountId) {
|
|
this._loginFailed = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Drives one initial-login attempt for an account and re-arms the next on
|
|
* failure. discord.js destroys the client when `login()` rejects, so each
|
|
* attempt binds a fresh client (created here once the prior one was torn
|
|
* down), re-attaches the gateway listeners, and races ClientReady against the
|
|
* login rejection / gateway Error. Success resolves the ready promise and
|
|
* clears the failed state; a transient failure discards the client and
|
|
* schedules `attempt + 1` after a capped-exponential backoff, keeping the
|
|
* connector self-healing instead of running deaf-but-active (#15855). Once
|
|
* ClientReady fires, discord.js owns reconnection — this loop stops.
|
|
*/
|
|
private attemptDiscordLogin(
|
|
state: DiscordAccountClientState,
|
|
token: string,
|
|
attempt: number,
|
|
resolve: () => void,
|
|
reject: (error: unknown) => void,
|
|
): void {
|
|
state.loginReadyReject = reject;
|
|
if (state.loginStopRequested) {
|
|
reject(this.createLoginStoppedError(state));
|
|
return;
|
|
}
|
|
if (!state.client) {
|
|
state.client = this.createDiscordJsClient();
|
|
}
|
|
const client = state.client;
|
|
// Rebind message/reaction/guild listeners onto this (possibly fresh)
|
|
// client; the prior attempt's debouncer is discarded with its client.
|
|
state.channelDebouncer?.destroy();
|
|
this.setupEventListenersForAccount(state);
|
|
|
|
// ClientReady, gateway Error, and the login() rejection can all fire for
|
|
// one attempt — settle exactly once so we never both resolve and retry.
|
|
let settled = false;
|
|
|
|
const settleStopped = (): void => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
reject(this.createLoginStoppedError(state));
|
|
};
|
|
|
|
const settleTerminal = (error: unknown, closeCode?: number): void => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
state.loginFailed = true;
|
|
if (state.accountId === this.defaultAccountId) {
|
|
this._loginFailed = true;
|
|
this.syncLegacyDefaultAliases(state);
|
|
}
|
|
state.loginReadyReject = undefined;
|
|
state.client?.destroy().catch((destroyError: unknown) => {
|
|
// error-policy:J6 best-effort teardown of the client we are replacing
|
|
this.runtime.logger.debug(
|
|
`Discord client teardown after terminal login failure for account ${state.accountId}: ${
|
|
destroyError instanceof Error
|
|
? destroyError.message
|
|
: String(destroyError)
|
|
}`,
|
|
);
|
|
});
|
|
state.client = null;
|
|
const terminalError = this.createTerminalLoginError(
|
|
state,
|
|
error,
|
|
closeCode,
|
|
);
|
|
this.runtime.logger.error(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
accountId: state.accountId,
|
|
closeCode,
|
|
error:
|
|
terminalError instanceof Error
|
|
? terminalError.message
|
|
: String(terminalError),
|
|
},
|
|
"Discord initial login stopped on terminal gateway close",
|
|
);
|
|
reject(terminalError);
|
|
};
|
|
|
|
const scheduleRetry = (error: unknown): void => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
if (state.loginStopRequested) {
|
|
settleStopped();
|
|
return;
|
|
}
|
|
settled = true;
|
|
state.loginFailed = true;
|
|
if (state.accountId === this.defaultAccountId) {
|
|
this._loginFailed = true;
|
|
this.syncLegacyDefaultAliases(state);
|
|
}
|
|
state.client?.destroy().catch((destroyError: unknown) => {
|
|
// error-policy:J6 best-effort teardown of the client we are replacing
|
|
this.runtime.logger.debug(
|
|
`Discord client teardown after failed login for account ${state.accountId}: ${
|
|
destroyError instanceof Error
|
|
? destroyError.message
|
|
: String(destroyError)
|
|
}`,
|
|
);
|
|
});
|
|
state.client = null;
|
|
const delayMs = this.computeLoginBackoffMs(attempt);
|
|
this.emitLoginFailureHeartbeat(state, error, attempt, delayMs);
|
|
const timer = setTimeout(() => {
|
|
state.loginRetryTimer = undefined;
|
|
this.attemptDiscordLogin(state, token, attempt + 1, resolve, reject);
|
|
}, delayMs);
|
|
state.loginRetryTimer = timer;
|
|
this.timeouts.push(timer);
|
|
};
|
|
|
|
client.once(Events.ClientReady, async (readyClient) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
if (state.loginStopRequested) {
|
|
settleStopped();
|
|
return;
|
|
}
|
|
settled = true;
|
|
if (state.loginRetryTimer) {
|
|
clearTimeout(state.loginRetryTimer);
|
|
state.loginRetryTimer = undefined;
|
|
}
|
|
state.loginFailed = false;
|
|
state.lastLoginHeartbeatAt = undefined;
|
|
if (state.accountId === this.defaultAccountId) {
|
|
this._loginFailed = false;
|
|
}
|
|
try {
|
|
await this.onReadyForAccount(state.accountId, readyClient);
|
|
state.loginReadyReject = undefined;
|
|
resolve();
|
|
} catch (error) {
|
|
// A post-ready onReady failure (backfill/voice scan) is terminal, not
|
|
// a login-transport problem — surface it rather than looping login.
|
|
this.runtime.logger.error(
|
|
`Error in Discord onReady for account ${state.accountId}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
state.loginReadyReject = undefined;
|
|
reject(error);
|
|
}
|
|
});
|
|
client.once(Events.ShardDisconnect, (closeEvent: unknown) => {
|
|
if (state.loginStopRequested) {
|
|
settleStopped();
|
|
return;
|
|
}
|
|
const closeCode = this.getGatewayCloseCode(closeEvent);
|
|
if (this.isTerminalInitialLoginCloseCode(closeCode)) {
|
|
settleTerminal(closeEvent, closeCode);
|
|
return;
|
|
}
|
|
scheduleRetry(closeEvent);
|
|
});
|
|
client.once(Events.Error, (error: unknown) => {
|
|
this.runtime.logger.error(
|
|
`Discord client error for account ${state.accountId}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
scheduleRetry(error);
|
|
});
|
|
client.login(token).catch((error: unknown) => {
|
|
const closeCode = this.getGatewayCloseCode(error);
|
|
if (
|
|
this.isTerminalInitialLoginCloseCode(closeCode) ||
|
|
this.isTerminalInitialLoginError(error)
|
|
) {
|
|
settleTerminal(error, closeCode);
|
|
return;
|
|
}
|
|
scheduleRetry(error);
|
|
});
|
|
}
|
|
|
|
// Capped exponential backoff for the initial-login retry loop: the delay
|
|
// doubles per attempt and clamps at DISCORD_LOGIN_RETRY_MAX_MS.
|
|
private computeLoginBackoffMs(attempt: number): number {
|
|
const scaled = DISCORD_LOGIN_RETRY_BASE_MS * 2 ** attempt;
|
|
return Math.min(scaled, DISCORD_LOGIN_RETRY_MAX_MS);
|
|
}
|
|
|
|
private getGatewayCloseCode(closeEvent: unknown): number | undefined {
|
|
if (
|
|
typeof closeEvent === "object" &&
|
|
closeEvent !== null &&
|
|
"code" in closeEvent
|
|
) {
|
|
const code = (closeEvent as { code?: unknown }).code;
|
|
return typeof code === "number" ? code : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private isTerminalInitialLoginCloseCode(
|
|
closeCode: number | undefined,
|
|
): boolean {
|
|
return (
|
|
closeCode !== undefined &&
|
|
DISCORD_TERMINAL_INITIAL_LOGIN_CLOSE_CODES.has(closeCode)
|
|
);
|
|
}
|
|
|
|
private isTerminalInitialLoginError(error: unknown): boolean {
|
|
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
return false;
|
|
}
|
|
return (error as { code?: unknown }).code === "TokenInvalid";
|
|
}
|
|
|
|
private createLoginStoppedError(state: DiscordAccountClientState): Error {
|
|
return new Error(
|
|
`Discord initial login stopped before ClientReady for account ${state.accountId}`,
|
|
);
|
|
}
|
|
|
|
private createTerminalLoginError(
|
|
state: DiscordAccountClientState,
|
|
error: unknown,
|
|
closeCode?: number,
|
|
): Error {
|
|
const reason =
|
|
typeof error === "object" && error !== null && "reason" in error
|
|
? (error as { reason?: unknown }).reason
|
|
: undefined;
|
|
const detail =
|
|
typeof reason === "string" && reason.length > 0
|
|
? reason
|
|
: error instanceof Error
|
|
? error.message
|
|
: String(error);
|
|
return new Error(
|
|
`Discord initial login terminal failure for account ${state.accountId}${
|
|
closeCode === undefined ? "" : ` (gateway close ${closeCode})`
|
|
}: ${detail}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Warn-level heartbeat naming the account and the login failure, throttled to
|
|
* at most once per DISCORD_LOGIN_HEARTBEAT_MIN_INTERVAL_MS so a fast retry
|
|
* storm surfaces observably (#15855) without flooding the log.
|
|
*/
|
|
private emitLoginFailureHeartbeat(
|
|
state: DiscordAccountClientState,
|
|
error: unknown,
|
|
attempt: number,
|
|
delayMs: number,
|
|
): void {
|
|
const now = Date.now();
|
|
const last = state.lastLoginHeartbeatAt;
|
|
if (
|
|
last !== undefined &&
|
|
now - last < DISCORD_LOGIN_HEARTBEAT_MIN_INTERVAL_MS
|
|
) {
|
|
return;
|
|
}
|
|
state.lastLoginHeartbeatAt = now;
|
|
this.runtime.logger.warn(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
accountId: state.accountId,
|
|
attempt: attempt + 1,
|
|
retryInMs: delayMs,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
`Discord account ${state.accountId} failed to log in and is connected-but-deaf; retrying in ${delayMs}ms (attempt ${attempt + 1})`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Constructor for Discord client.
|
|
* Initializes the Discord client with specified intents and partials,
|
|
* sets up event listeners, and ensures all servers exist.
|
|
*
|
|
* @param {IAgentRuntime} runtime - The AgentRuntime instance
|
|
*/
|
|
constructor(runtime?: IAgentRuntime) {
|
|
super(runtime);
|
|
|
|
// Load Discord settings with proper priority (env vars > character settings > defaults)
|
|
this.discordSettings = getDiscordSettings(this.runtime);
|
|
|
|
this.character = this.runtime.character;
|
|
|
|
this.defaultAccountId = normalizeAccountId(
|
|
resolveDefaultDiscordAccountId(this.runtime),
|
|
);
|
|
this.accountPool.setDefaultAccountId(this.defaultAccountId);
|
|
this.accountId = this.defaultAccountId;
|
|
|
|
const accounts = listEnabledDiscordAccounts(this.runtime);
|
|
if (accounts.length === 0) {
|
|
this.runtime.logger.warn("Discord API Token not provided");
|
|
this.syncLegacyDefaultAliases(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
for (const account of accounts) {
|
|
this.initializeAccount(account);
|
|
}
|
|
|
|
const defaultState = this.getDefaultAccountState();
|
|
if (defaultState) {
|
|
this.defaultAccountId = defaultState.accountId;
|
|
this.accountPool.setDefaultAccountId(defaultState.accountId);
|
|
}
|
|
this.syncLegacyDefaultAliases(defaultState);
|
|
this.runtime.logger.info(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
defaultAccountId: this.defaultAccountId,
|
|
accountIds: this.getAccountIds(),
|
|
},
|
|
"Initialized Discord account client pool",
|
|
);
|
|
} catch (error) {
|
|
this.runtime.logger.error(
|
|
`Error initializing Discord client: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
this.syncLegacyDefaultAliases(null);
|
|
}
|
|
}
|
|
|
|
public isHealthy(): boolean {
|
|
const state = this.getDefaultAccountState();
|
|
if (this._loginFailed || !state?.client || state.loginFailed) {
|
|
return false;
|
|
}
|
|
return state.client.isReady();
|
|
}
|
|
|
|
static async start(runtime: IAgentRuntime) {
|
|
const service = new DiscordService(runtime);
|
|
return service;
|
|
}
|
|
|
|
/**
|
|
* The SendHandlerFunction implementation for Discord.
|
|
* @param {IAgentRuntime} runtime - The runtime instance.
|
|
* @param {TargetInfo} target - The target information for the message.
|
|
* @param {Content} content - The content of the message to send.
|
|
* @returns {Promise<void>} A promise that resolves when the message is sent or rejects on error.
|
|
* @throws {Error} If the client is not ready, target is invalid, or sending fails.
|
|
*/
|
|
async handleSendMessage(
|
|
runtime: IAgentRuntime,
|
|
target: TargetInfo,
|
|
content: Content,
|
|
): Promise<Memory | undefined> {
|
|
let outboundReservation: DiscordOutboundDeliveryReservation | undefined;
|
|
// Resolve the connector account this outbound message must use.
|
|
// Priority: explicit target.accountId > this service instance's default.
|
|
// `Content.metadata` is intentionally NOT consulted because it may be
|
|
// user-supplied per the MessageMetadata contract — actions thread the
|
|
// trusted inbound `Memory.metadata.accountId` into `target.accountId`.
|
|
const accountId = this.resolveAccountIdFromTarget(target);
|
|
const state = this.getAccountState(accountId);
|
|
const client = state?.client ?? null;
|
|
if (!client?.isReady()) {
|
|
runtime.logger.error("Client not ready");
|
|
throw new Error(`Discord client is not ready for account ${accountId}.`);
|
|
}
|
|
|
|
// Reference content to avoid an unused-parameter lint hit; outbound
|
|
// resolution only consults `target.accountId` for trust reasons.
|
|
void content;
|
|
|
|
let targetChannel: Channel | undefined | null = null;
|
|
let resolvedChannelId: string | null = null;
|
|
|
|
try {
|
|
if (target.channelId) {
|
|
resolvedChannelId = target.channelId;
|
|
targetChannel = await client.channels.fetch(target.channelId);
|
|
} else if (target.roomId) {
|
|
const room =
|
|
typeof runtime.getRoom === "function"
|
|
? await runtime.getRoom(target.roomId as UUID)
|
|
: null;
|
|
const roomChannelId =
|
|
room?.channelId && typeof room.channelId === "string"
|
|
? room.channelId
|
|
: null;
|
|
if (!roomChannelId) {
|
|
throw new Error(
|
|
`Could not resolve Discord channel ID for room ${target.roomId}`,
|
|
);
|
|
}
|
|
resolvedChannelId = roomChannelId;
|
|
targetChannel = await client.channels.fetch(roomChannelId);
|
|
} else if (target.entityId) {
|
|
const discordUserId = await this.resolveDiscordTargetUserId(
|
|
target.entityId as string,
|
|
);
|
|
if (!discordUserId) {
|
|
throw new Error(
|
|
`Could not resolve Discord user ID for runtime entity ${target.entityId}`,
|
|
);
|
|
}
|
|
const user = await client.users.fetch(discordUserId);
|
|
if (user) {
|
|
targetChannel = user.dmChannel ?? (await user.createDM());
|
|
}
|
|
} else {
|
|
throw new Error(
|
|
"Discord SendHandler requires channelId, roomId, or entityId.",
|
|
);
|
|
}
|
|
|
|
if (!targetChannel) {
|
|
const targetStr = JSON.stringify(target, (_key, value) => {
|
|
if (typeof value === "bigint") {
|
|
return value.toString();
|
|
}
|
|
return value;
|
|
});
|
|
throw new Error(
|
|
`Could not find target Discord channel/DM for target: ${targetStr}`,
|
|
);
|
|
}
|
|
|
|
const allowedByParentThread =
|
|
typeof targetChannel.isThread === "function" &&
|
|
targetChannel.isThread() &&
|
|
"parentId" in targetChannel &&
|
|
typeof targetChannel.parentId === "string" &&
|
|
targetChannel.parentId.length > 0 &&
|
|
this.isChannelAllowed(targetChannel.parentId, accountId);
|
|
if (
|
|
state?.allowedChannelIds &&
|
|
!this.isChannelAllowed(targetChannel.id, accountId) &&
|
|
!allowedByParentThread
|
|
) {
|
|
const resolvedFromText =
|
|
resolvedChannelId && resolvedChannelId !== targetChannel.id
|
|
? ` (resolved from ${resolvedChannelId})`
|
|
: "";
|
|
runtime.logger.warn(
|
|
`Channel ${targetChannel.id}${resolvedFromText} not in allowed list, skipping send`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (targetChannel.isTextBased() && !targetChannel.isVoiceBased()) {
|
|
if (
|
|
"send" in targetChannel &&
|
|
typeof targetChannel.send === "function"
|
|
) {
|
|
const files: AttachmentBuilder[] = [];
|
|
if (content.attachments && content.attachments.length > 0) {
|
|
for (const media of content.attachments) {
|
|
if (media.url) {
|
|
files.push(
|
|
await buildOutboundDiscordAttachment(media, runtime),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const sentMessages: Message[] = [];
|
|
const roomId = createUniqueUuid(runtime, targetChannel.id);
|
|
const channelType = await this.getChannelType(
|
|
targetChannel as Channel,
|
|
);
|
|
|
|
const textContent = normalizeDiscordMessageText(content.text);
|
|
const outboundReplyToMessageId =
|
|
discordReplyReferenceFromContent(content);
|
|
if (textContent || files.length > 0) {
|
|
const outboundDedupe = beginDiscordOutboundDelivery({
|
|
accountId,
|
|
channelId: targetChannel.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") {
|
|
runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: runtime.agentId,
|
|
channelId: targetChannel.id,
|
|
accountId,
|
|
textPreview: textContent
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, 200),
|
|
},
|
|
"Suppressing duplicate Discord connector delivery",
|
|
);
|
|
return;
|
|
}
|
|
outboundReservation = outboundDedupe.reservation;
|
|
if (textContent) {
|
|
const chunks = splitMessage(textContent, MAX_MESSAGE_LENGTH);
|
|
if (chunks.length > 1) {
|
|
for (let i = 0; i < chunks.length - 1; i++) {
|
|
const sent = await targetChannel.send({
|
|
content: chunks[i],
|
|
...(outboundReplyToMessageId && i === 0
|
|
? {
|
|
reply: {
|
|
messageReference: outboundReplyToMessageId,
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
sentMessages.push(sent);
|
|
}
|
|
const sent = await targetChannel.send({
|
|
content: chunks[chunks.length - 1],
|
|
files: files.length > 0 ? files : undefined,
|
|
...(outboundReplyToMessageId && chunks.length === 1
|
|
? {
|
|
reply: {
|
|
messageReference: outboundReplyToMessageId,
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
sentMessages.push(sent);
|
|
} else {
|
|
const sent = await targetChannel.send({
|
|
content: chunks[0],
|
|
files: files.length > 0 ? files : undefined,
|
|
...(outboundReplyToMessageId
|
|
? {
|
|
reply: {
|
|
messageReference: outboundReplyToMessageId,
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
sentMessages.push(sent);
|
|
}
|
|
} else {
|
|
const sent = await targetChannel.send({
|
|
files,
|
|
...(outboundReplyToMessageId
|
|
? {
|
|
reply: {
|
|
messageReference: outboundReplyToMessageId,
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
sentMessages.push(sent);
|
|
}
|
|
if (sentMessages.length > 0) {
|
|
outboundReservation.commit();
|
|
outboundReservation = undefined;
|
|
}
|
|
} else {
|
|
outboundReservation?.release();
|
|
outboundReservation = undefined;
|
|
runtime.logger.warn("No text content or attachments provided");
|
|
}
|
|
|
|
const targetChannelGuild =
|
|
"guild" in targetChannel ? targetChannel.guild : null;
|
|
const serverId = targetChannelGuild?.id
|
|
? targetChannelGuild.id
|
|
: targetChannel.id;
|
|
const worldId = createUniqueUuid(runtime, serverId) as UUID;
|
|
const worldName = targetChannelGuild?.name
|
|
? targetChannelGuild.name
|
|
: undefined;
|
|
|
|
const clientUser = client.user;
|
|
await this.runtime.ensureConnection({
|
|
entityId: runtime.agentId,
|
|
roomId,
|
|
roomName:
|
|
"name" in targetChannel && typeof targetChannel.name === "string"
|
|
? targetChannel.name
|
|
: clientUser.displayName || clientUser.username || undefined,
|
|
userName: clientUser.username ? clientUser.username : undefined,
|
|
name: clientUser.displayName || clientUser.username || undefined,
|
|
source: "discord",
|
|
channelId: targetChannel.id,
|
|
messageServerId: stringToUuid(serverId),
|
|
type: channelType,
|
|
worldId,
|
|
worldName,
|
|
metadata: {
|
|
accountId,
|
|
},
|
|
});
|
|
|
|
let lastPersistedMemory: Memory | undefined;
|
|
for (const sentMsg of sentMessages) {
|
|
try {
|
|
const hasAttachments = sentMsg.attachments.size > 0;
|
|
|
|
const memory: Memory = {
|
|
id: createUniqueUuid(runtime, sentMsg.id),
|
|
entityId: runtime.agentId,
|
|
agentId: runtime.agentId,
|
|
roomId,
|
|
content: {
|
|
text: sentMsg.content || textContent || " ",
|
|
url: sentMsg.url,
|
|
channelType,
|
|
...(outboundReplyToMessageId
|
|
? {
|
|
inReplyTo: createUniqueUuid(
|
|
runtime,
|
|
outboundReplyToMessageId,
|
|
),
|
|
}
|
|
: {}),
|
|
...(hasAttachments && content.attachments
|
|
? { attachments: content.attachments }
|
|
: {}),
|
|
...(content.action ? { action: content.action } : {}),
|
|
},
|
|
metadata: {
|
|
type: MemoryType.MESSAGE,
|
|
accountId,
|
|
platformMessageId: sentMsg.id,
|
|
...extractContentMetadata(content),
|
|
},
|
|
createdAt: sentMsg.createdTimestamp || Date.now(),
|
|
};
|
|
|
|
await createDiscordMessageMemoryOnce(runtime, memory, {
|
|
operation: "discord-connector-send",
|
|
platformMessageId: sentMsg.id,
|
|
});
|
|
lastPersistedMemory = memory;
|
|
runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: runtime.agentId,
|
|
messageId: sentMsg.id,
|
|
},
|
|
"Saved sent message to memory",
|
|
);
|
|
} catch (error) {
|
|
runtime.logger.warn(
|
|
`Failed to save sent message ${sentMsg.id} to memory: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
return lastPersistedMemory;
|
|
} else {
|
|
throw new Error(
|
|
`Target channel ${targetChannel.id} does not have a send method.`,
|
|
);
|
|
}
|
|
} else {
|
|
throw new Error(
|
|
`Target channel ${targetChannel.id} is not a valid text-based channel for sending messages.`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
outboundReservation?.release();
|
|
runtime.logger.error(
|
|
`Error sending message to ${JSON.stringify(target)}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private buildConnectorChannelTarget(
|
|
channel: Channel,
|
|
score = 0.5,
|
|
accountId = this.defaultAccountId,
|
|
): MessageConnectorTarget | null {
|
|
if (!isDiscordTextTarget(channel)) {
|
|
return null;
|
|
}
|
|
|
|
const channelRecord = channel as Channel & {
|
|
guild?: Guild;
|
|
name?: string;
|
|
parentId?: string | null;
|
|
isThread?: () => boolean;
|
|
url?: string;
|
|
};
|
|
const parentId =
|
|
typeof channelRecord.parentId === "string"
|
|
? channelRecord.parentId
|
|
: undefined;
|
|
const isThread = Boolean(channelRecord.isThread());
|
|
const state = this.getAccountState(accountId);
|
|
if (
|
|
state?.allowedChannelIds &&
|
|
!this.isChannelAllowed(channel.id, accountId) &&
|
|
!(parentId && this.isChannelAllowed(parentId, accountId))
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const guild = channelRecord.guild;
|
|
const roomId = createUniqueUuid(this.runtime, channel.id) as UUID;
|
|
const label =
|
|
typeof channelRecord.name === "string" && channelRecord.name.length > 0
|
|
? `${isThread ? "Thread" : "#"}${channelRecord.name}`
|
|
: channel.id;
|
|
|
|
return {
|
|
target: {
|
|
source: "discord",
|
|
accountId,
|
|
roomId,
|
|
channelId: channel.id,
|
|
serverId: guild?.id,
|
|
threadId: isThread ? channel.id : undefined,
|
|
// The inbound gate drops messages on the [room, parent] mute chain
|
|
// (discord-events), so listings carry the same parent linkage for
|
|
// muted-state inheritance — a thread of a muted parent must never
|
|
// list as unmuted while its messages are being dropped.
|
|
parentChannelId: parentId,
|
|
} as TargetInfo,
|
|
label,
|
|
kind: isThread ? "thread" : "channel",
|
|
description: guild?.name ? `${label} in ${guild.name}` : label,
|
|
score,
|
|
contexts: ["social", "connectors"],
|
|
metadata: {
|
|
accountId,
|
|
discordChannelId: channel.id,
|
|
discordGuildId: guild?.id,
|
|
discordGuildName: guild?.name,
|
|
discordParentChannelId: parentId,
|
|
channelName: channelRecord.name,
|
|
isThread,
|
|
url: channelRecord.url,
|
|
},
|
|
};
|
|
}
|
|
|
|
private buildConnectorUserTarget(
|
|
user: User,
|
|
guild?: Guild | null,
|
|
displayName?: string,
|
|
score = 0.5,
|
|
accountId = this.defaultAccountId,
|
|
): MessageConnectorTarget | null {
|
|
if (!user || user.bot) {
|
|
return null;
|
|
}
|
|
|
|
const label = displayName || user.globalName || user.username || user.id;
|
|
return {
|
|
target: {
|
|
source: "discord",
|
|
accountId,
|
|
entityId: user.id as UUID,
|
|
serverId: guild?.id,
|
|
} as TargetInfo,
|
|
label: `@${label}`,
|
|
kind: "user",
|
|
description: guild?.name
|
|
? `Discord user in ${guild.name}`
|
|
: "Discord user",
|
|
score,
|
|
contexts: ["social", "connectors"],
|
|
metadata: {
|
|
accountId,
|
|
discordUserId: user.id,
|
|
discordUsername: user.username,
|
|
discordGlobalName: user.globalName,
|
|
discordGuildId: guild?.id,
|
|
discordGuildName: guild?.name,
|
|
},
|
|
};
|
|
}
|
|
|
|
private dedupeConnectorTargets(
|
|
targets: MessageConnectorTarget[],
|
|
): MessageConnectorTarget[] {
|
|
const byKey = new Map<string, MessageConnectorTarget>();
|
|
for (const target of targets) {
|
|
const key = [
|
|
target.kind ?? "target",
|
|
target.target.channelId ?? "",
|
|
target.target.entityId ?? "",
|
|
target.target.threadId ?? "",
|
|
].join(":");
|
|
const existing = byKey.get(key);
|
|
if (!existing || (target.score ?? 0) > (existing.score ?? 0)) {
|
|
byKey.set(key, target);
|
|
}
|
|
}
|
|
return Array.from(byKey.values()).sort(
|
|
(a, b) => (b.score ?? 0) - (a.score ?? 0),
|
|
);
|
|
}
|
|
|
|
public async resolveConnectorTargets(
|
|
query: string,
|
|
context: MessageConnectorQueryContext,
|
|
): Promise<MessageConnectorTarget[]> {
|
|
const accountId = this.resolveAccountIdFromTarget(context.target, context);
|
|
const client = this.getClient(accountId);
|
|
if (!client) {
|
|
return [];
|
|
}
|
|
|
|
const normalizedQuery = normalizeDiscordConnectorQuery(query);
|
|
const results: MessageConnectorTarget[] = [];
|
|
const guilds = Array.from(client.guilds.cache.values());
|
|
|
|
for (const guild of guilds) {
|
|
const cachedChannels = Array.from(guild.channels.cache.values());
|
|
for (const channel of cachedChannels) {
|
|
if (!channel || !isDiscordTextTarget(channel)) {
|
|
continue;
|
|
}
|
|
const channelRecord = channel as Channel & { name?: string };
|
|
const score = scoreDiscordConnectorMatch(normalizedQuery, channel.id, [
|
|
channelRecord.name,
|
|
]);
|
|
if (score <= 0) {
|
|
continue;
|
|
}
|
|
const target = this.buildConnectorChannelTarget(
|
|
channel,
|
|
score,
|
|
accountId,
|
|
);
|
|
if (target) {
|
|
results.push(target);
|
|
}
|
|
}
|
|
|
|
if (normalizedQuery.length >= 2) {
|
|
try {
|
|
const members = await guild.members.fetch({
|
|
query: normalizedQuery,
|
|
limit: 10,
|
|
});
|
|
for (const member of members.values()) {
|
|
const score = scoreDiscordConnectorMatch(
|
|
normalizedQuery,
|
|
member.id,
|
|
[
|
|
member.displayName,
|
|
member.user.username,
|
|
member.user.globalName,
|
|
member.user.tag,
|
|
],
|
|
);
|
|
const target = this.buildConnectorUserTarget(
|
|
member.user,
|
|
guild,
|
|
member.displayName,
|
|
score || 0.65,
|
|
accountId,
|
|
);
|
|
if (target) {
|
|
results.push(target);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
guildId: guild.id,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Discord connector member query failed",
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const member of guild.members.cache.values()) {
|
|
const score = scoreDiscordConnectorMatch(normalizedQuery, member.id, [
|
|
member.displayName,
|
|
member.user.username,
|
|
member.user.globalName,
|
|
member.user.tag,
|
|
]);
|
|
if (score <= 0) {
|
|
continue;
|
|
}
|
|
const target = this.buildConnectorUserTarget(
|
|
member.user,
|
|
guild,
|
|
member.displayName,
|
|
score,
|
|
accountId,
|
|
);
|
|
if (target) {
|
|
results.push(target);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (DISCORD_SNOWFLAKE_PATTERN.test(normalizedQuery)) {
|
|
try {
|
|
const channel = await client.channels.fetch(normalizedQuery);
|
|
if (channel) {
|
|
const target = this.buildConnectorChannelTarget(
|
|
channel,
|
|
1,
|
|
accountId,
|
|
);
|
|
if (target) {
|
|
results.push(target);
|
|
}
|
|
}
|
|
} catch {
|
|
// Snowflake may be a user id; try user lookup below.
|
|
}
|
|
try {
|
|
const user = await client.users.fetch(normalizedQuery);
|
|
const target = this.buildConnectorUserTarget(
|
|
user,
|
|
null,
|
|
undefined,
|
|
1,
|
|
accountId,
|
|
);
|
|
if (target) {
|
|
results.push(target);
|
|
}
|
|
} catch {
|
|
// No exact user match.
|
|
}
|
|
}
|
|
|
|
if (context.target?.channelId) {
|
|
try {
|
|
const channel = await client.channels.fetch(context.target.channelId);
|
|
if (channel) {
|
|
const target = this.buildConnectorChannelTarget(
|
|
channel,
|
|
0.6,
|
|
accountId,
|
|
);
|
|
if (target) {
|
|
results.push(target);
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore stale current-channel hints.
|
|
}
|
|
}
|
|
|
|
return this.dedupeConnectorTargets(results).slice(0, 25);
|
|
}
|
|
|
|
public async listConnectorRooms(
|
|
context: MessageConnectorQueryContext,
|
|
): Promise<MessageConnectorTarget[]> {
|
|
const accountId = this.resolveAccountIdFromTarget(context.target, context);
|
|
const client = this.getClient(accountId);
|
|
if (!client) {
|
|
return [];
|
|
}
|
|
|
|
const targets: MessageConnectorTarget[] = [];
|
|
for (const guild of client.guilds.cache.values()) {
|
|
for (const channel of guild.channels.cache.values()) {
|
|
const target = this.buildConnectorChannelTarget(
|
|
channel as Channel,
|
|
0.5,
|
|
accountId,
|
|
);
|
|
if (target) {
|
|
targets.push(target);
|
|
}
|
|
}
|
|
}
|
|
// The complete set is the contract: list_channels/list_connections derive
|
|
// channel + muted counts from the returned length, so any cap here makes
|
|
// those counts silently wrong past the cap. The gateway cache already
|
|
// holds every channel per guild (GUILD_CREATE delivers the full list);
|
|
// bounding what gets rendered is the op layer's job.
|
|
return this.dedupeConnectorTargets(targets);
|
|
}
|
|
|
|
public async listRecentConnectorTargets(
|
|
context: MessageConnectorQueryContext,
|
|
): Promise<MessageConnectorTarget[]> {
|
|
const accountId = this.resolveAccountIdFromTarget(context.target, context);
|
|
const client = this.getClient(accountId);
|
|
const targets: MessageConnectorTarget[] = [];
|
|
const currentRoom =
|
|
context.roomId && typeof context.runtime.getRoom === "function"
|
|
? await context.runtime.getRoom(context.roomId)
|
|
: null;
|
|
const currentChannelId =
|
|
context.target?.channelId ??
|
|
(currentRoom?.source === "discord" ? currentRoom.channelId : undefined);
|
|
|
|
if (currentChannelId && client) {
|
|
try {
|
|
const channel = await client.channels.fetch(currentChannelId);
|
|
if (channel) {
|
|
const target = this.buildConnectorChannelTarget(
|
|
channel,
|
|
0.95,
|
|
accountId,
|
|
);
|
|
if (target) {
|
|
targets.push(target);
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore stale current-channel hints.
|
|
}
|
|
}
|
|
|
|
targets.push(...(await this.listConnectorRooms(context)));
|
|
return this.dedupeConnectorTargets(targets).slice(0, 25);
|
|
}
|
|
|
|
public async getConnectorChatContext(
|
|
target: TargetInfo,
|
|
context: MessageConnectorQueryContext,
|
|
): Promise<MessageConnectorChatContext | null> {
|
|
const accountId = this.resolveAccountIdFromTarget(target, context);
|
|
const client = this.getClient(accountId);
|
|
if (!client) {
|
|
return null;
|
|
}
|
|
|
|
const room =
|
|
target.roomId && typeof context.runtime.getRoom === "function"
|
|
? await context.runtime.getRoom(target.roomId)
|
|
: null;
|
|
const channelId = target.channelId ?? room?.channelId;
|
|
if (!channelId) {
|
|
return null;
|
|
}
|
|
|
|
const channel = await client.channels.fetch(channelId);
|
|
if (!channel || !isDiscordTextTarget(channel)) {
|
|
return null;
|
|
}
|
|
|
|
const channelRecord = channel as Channel & {
|
|
name?: string;
|
|
topic?: string | null;
|
|
guild?: Guild;
|
|
messages?: {
|
|
fetch: (options: {
|
|
limit: number;
|
|
}) => Promise<Collection<string, Message>>;
|
|
};
|
|
};
|
|
const recentMessages: MessageConnectorChatContext["recentMessages"] = [];
|
|
if (channelRecord.messages?.fetch) {
|
|
try {
|
|
const fetched = await channelRecord.messages.fetch({ limit: 10 });
|
|
for (const message of Array.from(fetched.values()).reverse()) {
|
|
if (!message.content.trim()) {
|
|
continue;
|
|
}
|
|
recentMessages.push({
|
|
entityId: this.resolveDiscordEntityId(message.author.id),
|
|
name:
|
|
message.member?.displayName ||
|
|
message.author.globalName ||
|
|
message.author.username,
|
|
text: message.content,
|
|
timestamp: message.createdTimestamp,
|
|
metadata: {
|
|
accountId,
|
|
discordMessageId: message.id,
|
|
discordUserId: message.author.id,
|
|
},
|
|
});
|
|
}
|
|
} catch (error) {
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
channelId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Discord connector chat context history fetch failed",
|
|
);
|
|
}
|
|
}
|
|
|
|
const label =
|
|
typeof channelRecord.name === "string" && channelRecord.name.length > 0
|
|
? `#${channelRecord.name}`
|
|
: channelId;
|
|
return {
|
|
target: {
|
|
source: "discord",
|
|
accountId,
|
|
roomId: target.roomId ?? room?.id,
|
|
channelId,
|
|
serverId: target.serverId ?? channelRecord.guild?.id,
|
|
threadId: target.threadId,
|
|
} as TargetInfo,
|
|
label,
|
|
summary:
|
|
channelRecord.topic ||
|
|
(channelRecord.guild?.name
|
|
? `Discord channel in ${channelRecord.guild.name}`
|
|
: undefined),
|
|
recentMessages,
|
|
metadata: {
|
|
accountId,
|
|
discordChannelId: channelId,
|
|
discordGuildId: channelRecord.guild?.id,
|
|
discordGuildName: channelRecord.guild?.name,
|
|
},
|
|
};
|
|
}
|
|
|
|
public async getConnectorUserContext(
|
|
entityId: UUID | string,
|
|
context: MessageConnectorQueryContext,
|
|
): Promise<MessageConnectorUserContext | null> {
|
|
const accountId = this.resolveAccountIdFromTarget(context.target, context);
|
|
const client = this.getClient(accountId);
|
|
if (!client) {
|
|
return null;
|
|
}
|
|
|
|
const discordUserId = await this.resolveDiscordTargetUserId(
|
|
String(entityId),
|
|
);
|
|
if (!discordUserId) {
|
|
return null;
|
|
}
|
|
|
|
const user = await client.users.fetch(discordUserId);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
entityId,
|
|
label: user.globalName || user.username || user.id,
|
|
aliases: [user.username, user.globalName, user.tag].filter(
|
|
(value): value is string => Boolean(value),
|
|
),
|
|
handles: { discord: user.id },
|
|
metadata: {
|
|
accountId,
|
|
discordUserId: user.id,
|
|
discordUsername: user.username,
|
|
discordGlobalName: user.globalName,
|
|
requestRoomId: context.roomId,
|
|
},
|
|
};
|
|
}
|
|
|
|
private async resolveConnectorTextChannel(
|
|
target?: TargetInfo | null,
|
|
fallback?: ConnectorFetchMessagesParams | ConnectorChannelMutationParams,
|
|
): Promise<
|
|
Channel & {
|
|
id: string;
|
|
name?: string;
|
|
guild?: Guild;
|
|
messages: TextChannel["messages"];
|
|
permissionsFor?: TextChannel["permissionsFor"];
|
|
}
|
|
> {
|
|
const accountId = this.resolveAccountIdFromTarget(target, fallback);
|
|
const client = this.getClient(accountId);
|
|
if (!client) {
|
|
throw new Error("Discord client is not initialized.");
|
|
}
|
|
|
|
let channelId =
|
|
target?.channelId ??
|
|
(fallback && "channelId" in fallback ? fallback.channelId : undefined);
|
|
const roomId =
|
|
target?.roomId ??
|
|
(fallback && "roomId" in fallback ? fallback.roomId : undefined);
|
|
|
|
if (roomId && !channelId) {
|
|
const room = await this.runtime.getRoom(roomId);
|
|
channelId = room?.channelId;
|
|
}
|
|
|
|
if (!channelId && fallback && "alias" in fallback && fallback.alias) {
|
|
const normalizedAlias = normalizeDiscordConnectorQuery(fallback.alias);
|
|
for (const guild of client.guilds.cache.values()) {
|
|
const found = guild.channels.cache.find((channel) => {
|
|
if (!channel || !isDiscordTextTarget(channel)) {
|
|
return false;
|
|
}
|
|
const channelRecord = channel as Channel & { name?: string };
|
|
return (
|
|
channel.id === normalizedAlias ||
|
|
channelRecord.name?.toLowerCase() === normalizedAlias
|
|
);
|
|
});
|
|
if (found) {
|
|
channelId = found.id;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!channelId) {
|
|
throw new Error("Discord connector operation requires a channel target.");
|
|
}
|
|
|
|
const channel = await client.channels.fetch(channelId);
|
|
if (!channel || !isDiscordTextTarget(channel) || !("messages" in channel)) {
|
|
throw new Error(
|
|
`Discord channel ${channelId} is not a text message channel.`,
|
|
);
|
|
}
|
|
return channel as Channel & {
|
|
id: string;
|
|
name?: string;
|
|
guild?: Guild;
|
|
messages: TextChannel["messages"];
|
|
permissionsFor?: TextChannel["permissionsFor"];
|
|
};
|
|
}
|
|
|
|
private async fetchConnectorDiscordMessage(
|
|
params: ConnectorMessageMutationParams,
|
|
): Promise<Message> {
|
|
const messageId = params.messageId;
|
|
if (!messageId) {
|
|
throw new Error("Discord message operation requires messageId.");
|
|
}
|
|
const channel = await this.resolveConnectorTextChannel(
|
|
params.target,
|
|
params,
|
|
);
|
|
return (await channel.messages.fetch(messageId)) as Message;
|
|
}
|
|
|
|
public async listConnectorServers(
|
|
context: MessageConnectorQueryContext,
|
|
): Promise<World[]> {
|
|
const accountId = this.resolveAccountIdFromTarget(context.target, context);
|
|
const client = this.getClient(accountId);
|
|
if (!client) {
|
|
return [];
|
|
}
|
|
return Promise.all(
|
|
Array.from(client.guilds.cache.values()).map(async (guild) => {
|
|
const worldId = createUniqueUuid(this.runtime, guild.id);
|
|
// The persisted world carries durable metadata (server-wide
|
|
// agentMuteState, ownership/roles) that a freshly fabricated World
|
|
// would drop — start from it and refresh the live guild fields.
|
|
const persisted = await this.runtime.getWorld(worldId);
|
|
return {
|
|
...persisted,
|
|
id: worldId,
|
|
agentId: this.runtime.agentId,
|
|
name: guild.name,
|
|
messageServerId: stringToUuid(guild.id),
|
|
metadata: {
|
|
...persisted?.metadata,
|
|
source: "discord",
|
|
accountId,
|
|
discordGuildId: guild.id,
|
|
memberCount: guild.memberCount,
|
|
},
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
public async fetchConnectorMessages(
|
|
_context: MessageConnectorQueryContext,
|
|
params: ConnectorFetchMessagesParams,
|
|
): Promise<Memory[]> {
|
|
const accountId = this.resolveAccountIdFromTarget(params.target, params);
|
|
const channel = await this.resolveConnectorTextChannel(
|
|
params.target,
|
|
params,
|
|
);
|
|
const limit = Number.isFinite(params.limit)
|
|
? Math.max(1, Math.min(Number(params.limit), 100))
|
|
: 25;
|
|
const fetchParams: { limit: number; before?: string; after?: string } = {
|
|
limit,
|
|
};
|
|
if (params.before ?? params.cursor) {
|
|
fetchParams.before = params.before ?? params.cursor;
|
|
}
|
|
if (params.after) {
|
|
fetchParams.after = params.after;
|
|
}
|
|
|
|
const fetched = await channel.messages.fetch(fetchParams);
|
|
const memories: Memory[] = [];
|
|
for (const discordMessage of fetched.values()) {
|
|
const memory = await this.buildMemoryFromMessage(
|
|
discordMessage as Message,
|
|
{ accountId },
|
|
);
|
|
if (memory) {
|
|
memories.push(memory);
|
|
}
|
|
}
|
|
return memories.sort(
|
|
(left, right) =>
|
|
Number(right.createdAt ?? 0) - Number(left.createdAt ?? 0),
|
|
);
|
|
}
|
|
|
|
public async searchConnectorMessages(
|
|
context: MessageConnectorQueryContext,
|
|
params: ConnectorSearchMessagesParams,
|
|
): Promise<Memory[]> {
|
|
const query = params.query?.trim().toLowerCase();
|
|
if (!query) {
|
|
return [];
|
|
}
|
|
const author = params.author?.trim().toLowerCase();
|
|
const memories = await this.fetchConnectorMessages(context, {
|
|
...params,
|
|
limit: Math.max(params.limit ?? 100, 100),
|
|
});
|
|
return memories
|
|
.filter((memory) => {
|
|
const text = String(memory.content.text ?? "").toLowerCase();
|
|
const name = String(memory.content.name ?? "").toLowerCase();
|
|
const metadata = memory.metadata as Record<string, unknown> | undefined;
|
|
const sender = metadata?.sender as Record<string, unknown> | undefined;
|
|
const username = String(sender?.username ?? "").toLowerCase();
|
|
const matchesQuery = text.includes(query) || name.includes(query);
|
|
const matchesAuthor =
|
|
!author || name.includes(author) || username.includes(author);
|
|
return matchesQuery && matchesAuthor;
|
|
})
|
|
.slice(0, params.limit ?? 25);
|
|
}
|
|
|
|
public async reactConnectorMessage(
|
|
_runtime: IAgentRuntime,
|
|
params: ConnectorMessageMutationParams,
|
|
): Promise<void> {
|
|
const accountId = this.resolveAccountIdFromTarget(params.target, params);
|
|
const state = this.requireAccountState(accountId);
|
|
const emoji = params.emoji?.trim();
|
|
if (!emoji) {
|
|
throw new Error("Discord reaction requires emoji.");
|
|
}
|
|
const targetMessage = await this.fetchConnectorDiscordMessage(params);
|
|
if (params.remove) {
|
|
const clientUserId = state.client?.user?.id;
|
|
const reaction = targetMessage.reactions.cache.find(
|
|
(candidate) =>
|
|
candidate.emoji.name === emoji ||
|
|
candidate.emoji.toString() === emoji,
|
|
);
|
|
if (reaction && clientUserId) {
|
|
await reaction.users.remove(clientUserId);
|
|
}
|
|
return;
|
|
}
|
|
await targetMessage.react(emoji);
|
|
}
|
|
|
|
public async editConnectorMessage(
|
|
_runtime: IAgentRuntime,
|
|
params: ConnectorMessageMutationParams,
|
|
): Promise<Memory> {
|
|
const accountId = this.resolveAccountIdFromTarget(params.target, params);
|
|
const state = this.requireAccountState(accountId);
|
|
const text = params.content?.text ?? params.text;
|
|
if (!text?.trim()) {
|
|
throw new Error("Discord edit requires non-empty text.");
|
|
}
|
|
const targetMessage = await this.fetchConnectorDiscordMessage(params);
|
|
if (targetMessage.author.id !== state.client?.user?.id) {
|
|
throw new Error(
|
|
"Discord connector can only edit the bot's own messages.",
|
|
);
|
|
}
|
|
const edited = await targetMessage.edit(text);
|
|
const memory = await this.buildMemoryFromMessage(edited as Message, {
|
|
accountId,
|
|
extraMetadata: extractContentMetadata(params.content),
|
|
});
|
|
if (!memory) {
|
|
throw new Error(
|
|
"Discord edit succeeded but could not build updated memory.",
|
|
);
|
|
}
|
|
return memory;
|
|
}
|
|
|
|
public async deleteConnectorMessage(
|
|
_runtime: IAgentRuntime,
|
|
params: ConnectorMessageMutationParams,
|
|
): Promise<void> {
|
|
const targetMessage = await this.fetchConnectorDiscordMessage(params);
|
|
await targetMessage.delete();
|
|
}
|
|
|
|
public async pinConnectorMessage(
|
|
_runtime: IAgentRuntime,
|
|
params: ConnectorMessageMutationParams,
|
|
): Promise<void> {
|
|
const targetMessage = await this.fetchConnectorDiscordMessage(params);
|
|
if (params.pin === false) {
|
|
await targetMessage.unpin();
|
|
return;
|
|
}
|
|
await targetMessage.pin();
|
|
}
|
|
|
|
public async sendConnectorTyping(
|
|
_runtime: IAgentRuntime,
|
|
params: ConnectorTypingParams,
|
|
): Promise<void> {
|
|
const channel = await this.resolveConnectorTextChannel(
|
|
params.target,
|
|
params,
|
|
);
|
|
await (channel as TextChannel).sendTyping();
|
|
}
|
|
|
|
public async createConnectorThread(
|
|
_runtime: IAgentRuntime,
|
|
params: ConnectorCreateThreadParams,
|
|
): Promise<ThreadHandle> {
|
|
const channel = (await this.resolveConnectorTextChannel(
|
|
params.target,
|
|
params,
|
|
)) as TextChannel;
|
|
if (!channel.threads) {
|
|
throw new Error(
|
|
`Discord channel ${channel.id} does not support thread creation.`,
|
|
);
|
|
}
|
|
const name = (params.name ?? "thread").slice(0, 100);
|
|
let startMessage: Message | undefined;
|
|
if (params.parentMessageId) {
|
|
try {
|
|
startMessage = (await channel.messages.fetch(
|
|
params.parentMessageId,
|
|
)) as Message;
|
|
} catch (err) {
|
|
this.runtime.logger?.warn?.(
|
|
{
|
|
src: "plugin:discord",
|
|
channelId: channel.id,
|
|
parentMessageId: params.parentMessageId,
|
|
err: err instanceof Error ? err.message : String(err),
|
|
},
|
|
"createConnectorThread: parent message lookup failed; creating channel-level thread",
|
|
);
|
|
}
|
|
}
|
|
const thread = await channel.threads.create({
|
|
name,
|
|
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
...(startMessage ? { startMessage } : {}),
|
|
});
|
|
return { threadId: thread.id, parentChannelId: channel.id };
|
|
}
|
|
|
|
public async postToConnectorThread(
|
|
runtime: IAgentRuntime,
|
|
params: ConnectorPostToThreadParams,
|
|
): Promise<Memory | undefined> {
|
|
const accountId = this.resolveAccountIdFromTarget(params.target, params);
|
|
const state = this.requireAccountState(accountId);
|
|
const client = state.client;
|
|
if (!client?.isReady()) {
|
|
throw new Error(`Discord client is not ready for account ${accountId}.`);
|
|
}
|
|
const text = params.content.text ?? "";
|
|
if (!text.trim()) {
|
|
throw new Error("postToConnectorThread requires non-empty content.text.");
|
|
}
|
|
const threadChannel = (await client.channels.fetch(
|
|
params.thread.threadId,
|
|
)) as ThreadChannel | null;
|
|
if (!threadChannel) {
|
|
throw new Error(`Discord thread ${params.thread.threadId} not found.`);
|
|
}
|
|
|
|
if (params.identity?.name && params.thread.parentChannelId) {
|
|
const parent = (await client.channels.fetch(
|
|
params.thread.parentChannelId,
|
|
)) as TextChannel | null;
|
|
if (parent) {
|
|
const webhook = await this.findOrCreateWebhook(
|
|
parent,
|
|
params.identity.name,
|
|
);
|
|
if (webhook) {
|
|
const sent = await webhook.send({
|
|
content: text,
|
|
threadId: params.thread.threadId,
|
|
username: params.identity.name,
|
|
...(params.identity.avatarUrl
|
|
? { avatarURL: params.identity.avatarUrl }
|
|
: {}),
|
|
});
|
|
const memory = await this.buildMemoryFromMessage(sent as Message, {
|
|
accountId,
|
|
extraMetadata: extractContentMetadata(params.content),
|
|
});
|
|
return memory ?? undefined;
|
|
}
|
|
runtime.logger?.warn?.(
|
|
{
|
|
src: "plugin:discord",
|
|
channelId: parent.id,
|
|
requestedIdentity: params.identity.name,
|
|
},
|
|
"postToConnectorThread: webhook unavailable (likely missing MANAGE_WEBHOOKS or 10-per-channel limit); falling back to bot identity",
|
|
);
|
|
}
|
|
}
|
|
|
|
const sent = await threadChannel.send(text);
|
|
const memory = await this.buildMemoryFromMessage(sent as Message, {
|
|
accountId,
|
|
extraMetadata: extractContentMetadata(params.content),
|
|
});
|
|
return memory ?? undefined;
|
|
}
|
|
|
|
private async findOrCreateWebhook(
|
|
channel: TextChannel,
|
|
name: string,
|
|
): Promise<Webhook | null> {
|
|
let existing: Collection<string, Webhook> | undefined;
|
|
try {
|
|
existing = await channel.fetchWebhooks();
|
|
} catch (err) {
|
|
this.runtime.logger?.warn?.(
|
|
{
|
|
src: "plugin:discord",
|
|
channelId: channel.id,
|
|
err: err instanceof Error ? err.message : String(err),
|
|
},
|
|
"findOrCreateWebhook: fetchWebhooks failed",
|
|
);
|
|
return null;
|
|
}
|
|
const found = existing.find((w) => w.name === name);
|
|
if (found) return found;
|
|
try {
|
|
return await channel.createWebhook({ name });
|
|
} catch (err) {
|
|
this.runtime.logger?.warn?.(
|
|
{
|
|
src: "plugin:discord",
|
|
channelId: channel.id,
|
|
name,
|
|
err: err instanceof Error ? err.message : String(err),
|
|
},
|
|
"findOrCreateWebhook: createWebhook failed (likely 10-webhook channel limit or permissions)",
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async joinConnectorChannel(
|
|
_runtime: IAgentRuntime,
|
|
params: ConnectorChannelMutationParams,
|
|
): Promise<Room> {
|
|
const accountId = this.resolveAccountIdFromTarget(params.target, params);
|
|
const channel = await this.resolveConnectorTextChannel(
|
|
params.target,
|
|
params,
|
|
);
|
|
this.addAllowedChannel(channel.id, accountId);
|
|
|
|
const guild = "guild" in channel ? channel.guild : null;
|
|
const roomId = createUniqueUuid(this.runtime, channel.id);
|
|
const worldId = createUniqueUuid(this.runtime, guild?.id ?? channel.id);
|
|
const room: Room = {
|
|
id: roomId,
|
|
agentId: this.runtime.agentId,
|
|
name: channel.name ?? channel.id,
|
|
source: "discord",
|
|
type: await this.getChannelType(channel as Channel),
|
|
channelId: channel.id,
|
|
worldId,
|
|
serverId: guild?.id,
|
|
metadata: {
|
|
accountId,
|
|
discordChannelId: channel.id,
|
|
discordGuildId: guild?.id,
|
|
discordGuildName: guild?.name,
|
|
},
|
|
};
|
|
|
|
const runtimeWithEnsure = this.runtime as typeof this.runtime & {
|
|
ensureRoomExists?: (room: Room) => Promise<void>;
|
|
createRoom?: (room: Room) => Promise<UUID | undefined>;
|
|
};
|
|
if (typeof runtimeWithEnsure.ensureRoomExists === "function") {
|
|
await runtimeWithEnsure.ensureRoomExists(room);
|
|
} else if (typeof runtimeWithEnsure.createRoom === "function") {
|
|
const existing = await this.runtime.getRoom(roomId);
|
|
if (!existing) {
|
|
await runtimeWithEnsure.createRoom(room);
|
|
}
|
|
}
|
|
|
|
return (await this.runtime.getRoom(roomId)) ?? room;
|
|
}
|
|
|
|
public async leaveConnectorChannel(
|
|
_runtime: IAgentRuntime,
|
|
params: ConnectorChannelMutationParams,
|
|
): Promise<void> {
|
|
const accountId = this.resolveAccountIdFromTarget(params.target, params);
|
|
const channel = await this.resolveConnectorTextChannel(
|
|
params.target,
|
|
params,
|
|
);
|
|
this.removeAllowedChannel(channel.id, accountId);
|
|
}
|
|
|
|
public async getConnectorUser(
|
|
_runtime: IAgentRuntime,
|
|
params: ConnectorUserLookupParams,
|
|
): Promise<unknown> {
|
|
const accountId = normalizeAccountId(
|
|
params.accountId ?? this.defaultAccountId,
|
|
);
|
|
const client = this.getClient(accountId);
|
|
if (!client) {
|
|
return null;
|
|
}
|
|
const lookup =
|
|
params.userId ?? params.handle ?? params.username ?? params.query;
|
|
if (!lookup) {
|
|
return null;
|
|
}
|
|
|
|
let user: User | null = null;
|
|
if (DISCORD_SNOWFLAKE_PATTERN.test(lookup)) {
|
|
user = await client.users.fetch(lookup).catch(() => null);
|
|
}
|
|
if (!user) {
|
|
const normalized = normalizeDiscordConnectorQuery(lookup);
|
|
for (const guild of client.guilds.cache.values()) {
|
|
const cached = guild.members.cache.find((member) =>
|
|
[
|
|
member.id,
|
|
member.displayName,
|
|
member.user.username,
|
|
member.user.globalName,
|
|
member.user.tag,
|
|
]
|
|
.filter((value): value is string => Boolean(value))
|
|
.some((value) =>
|
|
normalizeDiscordConnectorQuery(value).includes(normalized),
|
|
),
|
|
);
|
|
if (cached) {
|
|
user = cached.user;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: this.resolveDiscordEntityId(user.id),
|
|
agentId: this.runtime.agentId,
|
|
names: [user.globalName, user.username, user.tag].filter(
|
|
(value): value is string => Boolean(value),
|
|
),
|
|
metadata: {
|
|
source: "discord",
|
|
accountId,
|
|
discord: {
|
|
accountId,
|
|
id: user.id,
|
|
userId: user.id,
|
|
username: user.username,
|
|
globalName: user.globalName,
|
|
tag: user.tag,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set up event listeners for the client.
|
|
* Delegates to the extracted setupDiscordEventListeners() function.
|
|
* @private
|
|
*/
|
|
private setupEventListenersForAccount(state: DiscordAccountClientState) {
|
|
if (!state.client) {
|
|
return;
|
|
}
|
|
|
|
const { channelDebouncer } = setupDiscordEventListeners(
|
|
this.createAccountServiceFacade(state),
|
|
);
|
|
|
|
state.channelDebouncer = channelDebouncer;
|
|
if (state.accountId === this.defaultAccountId) {
|
|
this.channelDebouncer = channelDebouncer;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles tasks to be performed once the Discord client is fully ready. Delegates to extracted module.
|
|
* @private
|
|
*/
|
|
private async onReadyForAccount(
|
|
accountId: string,
|
|
readyClient: DiscordJsClient<true>,
|
|
) {
|
|
const state = this.requireAccountState(accountId);
|
|
await onReadyExtracted(this.createAccountServiceFacade(state), readyClient);
|
|
const voiceChannelIds = String(
|
|
this.runtime.getSetting("DISCORD_VOICE_CHANNEL_ID") ?? "",
|
|
)
|
|
.split(",")
|
|
.map((id) => id.trim())
|
|
.filter(Boolean);
|
|
if (voiceChannelIds.length > 0 && state.voiceManager) {
|
|
const guilds = await readyClient.guilds.fetch();
|
|
for (const [, guild] of guilds) {
|
|
const fullGuild = await guild.fetch();
|
|
await state.voiceManager.scanGuild(fullGuild);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Registers send handlers for the Discord service instance.
|
|
* @static
|
|
*/
|
|
static registerSendHandlers(
|
|
runtime: IAgentRuntime,
|
|
serviceInstance: DiscordService,
|
|
) {
|
|
if (serviceInstance) {
|
|
if (typeof runtime.registerMessageConnector === "function") {
|
|
const accountIds =
|
|
typeof serviceInstance.getAccountIds === "function"
|
|
? serviceInstance.getAccountIds()
|
|
: [];
|
|
const defaultAccountId =
|
|
typeof serviceInstance.getDefaultAccountId === "function"
|
|
? serviceInstance.getDefaultAccountId()
|
|
: DEFAULT_ACCOUNT_ID;
|
|
const registerConnector = (
|
|
accountId: string | undefined,
|
|
legacy = false,
|
|
) => {
|
|
const scopedTarget = (target: TargetInfo): TargetInfo =>
|
|
({
|
|
...target,
|
|
accountId: accountIdFromRecord(target) ?? accountId,
|
|
}) as TargetInfo;
|
|
const scopedContext = (
|
|
context: MessageConnectorQueryContext,
|
|
): MessageConnectorQueryContext =>
|
|
({
|
|
...context,
|
|
accountId: accountIdFromRecord(context) ?? accountId,
|
|
target: context.target ? scopedTarget(context.target) : undefined,
|
|
}) as MessageConnectorQueryContext;
|
|
const scopedFetchParams = <
|
|
T extends
|
|
| ConnectorFetchMessagesParams
|
|
| ConnectorSearchMessagesParams
|
|
| ConnectorMessageMutationParams
|
|
| ConnectorChannelMutationParams
|
|
| ConnectorUserLookupParams
|
|
| ConnectorTypingParams
|
|
| ConnectorCreateThreadParams
|
|
| ConnectorPostToThreadParams,
|
|
>(
|
|
params: T,
|
|
): T => ({
|
|
...params,
|
|
accountId: params.accountId ?? accountId,
|
|
...("target" in params && params.target
|
|
? { target: scopedTarget(params.target) }
|
|
: {}),
|
|
});
|
|
const label = accountId
|
|
? `Discord (${serviceInstance.getAccountLabel(accountId)})`
|
|
: "Discord";
|
|
const registration: ExtendedMessageConnectorRegistration = {
|
|
source: "discord",
|
|
...(accountId ? { accountId } : {}),
|
|
...(accountId
|
|
? {
|
|
account: {
|
|
source: "discord",
|
|
accountId,
|
|
label: serviceInstance.getAccountLabel(accountId),
|
|
},
|
|
}
|
|
: {}),
|
|
label,
|
|
description:
|
|
"Discord connector for sending, reading, searching, reacting to, editing, deleting, pinning, joining, and leaving messages/channels.",
|
|
capabilities: [...DISCORD_CONNECTOR_CAPABILITIES],
|
|
supportedTargetKinds: ["channel", "thread", "user"],
|
|
contexts: [...DISCORD_CONNECTOR_CONTEXTS],
|
|
metadata: {
|
|
service: DISCORD_SERVICE_NAME,
|
|
supportsAttachments: true,
|
|
maxMessageLength: MAX_MESSAGE_LENGTH,
|
|
defaultAccountId,
|
|
...(accountId ? { accountId } : {}),
|
|
},
|
|
resolveTargets: (query, context) =>
|
|
serviceInstance.resolveConnectorTargets(
|
|
query,
|
|
scopedContext(context),
|
|
),
|
|
listRecentTargets: (context) =>
|
|
serviceInstance.listRecentConnectorTargets(
|
|
scopedContext(context),
|
|
),
|
|
listRooms: (context) =>
|
|
serviceInstance.listConnectorRooms(scopedContext(context)),
|
|
listServers: (context) =>
|
|
serviceInstance.listConnectorServers(scopedContext(context)),
|
|
fetchMessages: (context, params) =>
|
|
serviceInstance.fetchConnectorMessages(
|
|
scopedContext(context),
|
|
scopedFetchParams(params),
|
|
),
|
|
searchMessages: (context, params) =>
|
|
serviceInstance.searchConnectorMessages(
|
|
scopedContext(context),
|
|
scopedFetchParams(params),
|
|
),
|
|
reactHandler: (runtime, params) =>
|
|
serviceInstance.reactConnectorMessage(
|
|
runtime,
|
|
scopedFetchParams(params),
|
|
),
|
|
editHandler: (runtime, params) =>
|
|
serviceInstance.editConnectorMessage(
|
|
runtime,
|
|
scopedFetchParams(params),
|
|
),
|
|
deleteHandler: (runtime, params) =>
|
|
serviceInstance.deleteConnectorMessage(
|
|
runtime,
|
|
scopedFetchParams(params),
|
|
),
|
|
pinHandler: (runtime, params) =>
|
|
serviceInstance.pinConnectorMessage(
|
|
runtime,
|
|
scopedFetchParams(params),
|
|
),
|
|
joinHandler: (runtime, params) =>
|
|
serviceInstance.joinConnectorChannel(
|
|
runtime,
|
|
scopedFetchParams(params),
|
|
),
|
|
leaveHandler: (runtime, params) =>
|
|
serviceInstance.leaveConnectorChannel(
|
|
runtime,
|
|
scopedFetchParams(params),
|
|
),
|
|
getUser: (runtime, params) =>
|
|
serviceInstance.getConnectorUser(
|
|
runtime,
|
|
scopedFetchParams(params),
|
|
),
|
|
getChatContext: (target, context) =>
|
|
serviceInstance.getConnectorChatContext(
|
|
scopedTarget(target),
|
|
scopedContext(context),
|
|
),
|
|
getUserContext: (entityId, context) =>
|
|
serviceInstance.getConnectorUserContext(
|
|
entityId,
|
|
scopedContext(context),
|
|
),
|
|
sendHandler: (runtime, target, content) =>
|
|
serviceInstance.handleSendMessage(
|
|
runtime,
|
|
scopedTarget(target),
|
|
content,
|
|
),
|
|
typingHandler: (runtime, params) =>
|
|
serviceInstance.sendConnectorTyping(
|
|
runtime,
|
|
scopedFetchParams(params),
|
|
),
|
|
createThreadHandler: (runtime, params) =>
|
|
serviceInstance.createConnectorThread(
|
|
runtime,
|
|
scopedFetchParams(params),
|
|
),
|
|
postToThreadHandler: (runtime, params) =>
|
|
serviceInstance.postToConnectorThread(
|
|
runtime,
|
|
scopedFetchParams(params),
|
|
),
|
|
};
|
|
runtime.registerMessageConnector(registration);
|
|
runtime.logger.info(
|
|
accountId && !legacy
|
|
? `Registered Discord message connector for account ${accountId}`
|
|
: "Registered Discord message connector",
|
|
);
|
|
};
|
|
|
|
registerConnector(undefined, true);
|
|
for (const accountId of accountIds) {
|
|
registerConnector(accountId);
|
|
}
|
|
} else {
|
|
const sendHandler =
|
|
serviceInstance.handleSendMessage.bind(serviceInstance);
|
|
runtime.registerSendHandler("discord", sendHandler);
|
|
runtime.logger.info("Registered send handler");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches all members who have access to a specific text channel.
|
|
*/
|
|
public async getTextChannelMembers(
|
|
channelId: string,
|
|
useCache: boolean = true,
|
|
accountId?: string | null,
|
|
): Promise<Array<{ id: string; username: string; displayName: string }>> {
|
|
const state = this.getAccountState(accountId);
|
|
const client = state?.client ?? null;
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
accountId: state?.accountId ?? this.defaultAccountId,
|
|
channelId,
|
|
useCache,
|
|
},
|
|
"Fetching members for text channel",
|
|
);
|
|
|
|
try {
|
|
const channel = client
|
|
? ((await client.channels.fetch(channelId)) as TextChannel)
|
|
: null;
|
|
|
|
if (!channel) {
|
|
this.runtime.logger.error(
|
|
{ src: "plugin:discord", agentId: this.runtime.agentId, channelId },
|
|
"Channel not found",
|
|
);
|
|
return [];
|
|
}
|
|
|
|
if (channel.type !== DiscordChannelType.GuildText) {
|
|
this.runtime.logger.error(
|
|
{ src: "plugin:discord", agentId: this.runtime.agentId, channelId },
|
|
"Channel is not a text channel",
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const guild = channel.guild;
|
|
if (!guild) {
|
|
this.runtime.logger.error(
|
|
{ src: "plugin:discord", agentId: this.runtime.agentId, channelId },
|
|
"Channel is not in a guild",
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const useCacheOnly = useCache && guild.memberCount > 1000;
|
|
let members: Collection<string, GuildMember>;
|
|
|
|
if (useCacheOnly) {
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
guildId: guild.id,
|
|
memberCount: guild.memberCount.toLocaleString(),
|
|
},
|
|
"Using cached members for large guild",
|
|
);
|
|
members = guild.members.cache;
|
|
} else {
|
|
try {
|
|
if (useCache && guild.members.cache.size > 0) {
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
cacheSize: guild.members.cache.size,
|
|
},
|
|
"Using cached members",
|
|
);
|
|
members = guild.members.cache;
|
|
} else {
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
guildId: guild.id,
|
|
},
|
|
"Fetching members for guild",
|
|
);
|
|
members = await guild.members.fetch();
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
memberCount: members.size.toLocaleString(),
|
|
},
|
|
"Fetched members",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
this.runtime.logger.error(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Error fetching members",
|
|
);
|
|
members = guild.members.cache;
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
cacheSize: members.size,
|
|
},
|
|
"Fallback to cache",
|
|
);
|
|
}
|
|
}
|
|
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
channelId: channel.id,
|
|
},
|
|
"Filtering members for channel access",
|
|
);
|
|
const memberArray: GuildMember[] = Array.from(members.values());
|
|
const channelMembers = memberArray
|
|
.filter((member: GuildMember) => {
|
|
const clientUser = client?.user;
|
|
if (member.user.bot && clientUser && member.id !== clientUser.id) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
channel
|
|
.permissionsFor(member)
|
|
.has(PermissionsBitField.Flags.ViewChannel) || false
|
|
);
|
|
})
|
|
.map((member: GuildMember) => ({
|
|
id: member.id,
|
|
username: member.user.username,
|
|
displayName: member.displayName || member.user.username,
|
|
}));
|
|
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
channelId: channel.id,
|
|
memberCount: channelMembers.length.toLocaleString(),
|
|
},
|
|
"Found members with channel access",
|
|
);
|
|
return channelMembers;
|
|
} catch (error) {
|
|
this.runtime.logger.error(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Error fetching channel members",
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches the topic/description of a Discord text channel.
|
|
*/
|
|
public async getChannelTopic(
|
|
channelId: string,
|
|
accountId?: string | null,
|
|
): Promise<string | null> {
|
|
try {
|
|
const client = this.getClient(accountId);
|
|
const channel = client ? await client.channels.fetch(channelId) : null;
|
|
if (channel && "topic" in channel) {
|
|
return (channel as TextChannel).topic;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
channelId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Failed to fetch channel topic",
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a channel ID is allowed based on both env config and dynamic additions.
|
|
*/
|
|
public isChannelAllowed(
|
|
channelId: string,
|
|
accountId?: string | null,
|
|
): boolean {
|
|
const state = this.getAccountState(accountId);
|
|
const allowedChannelIds =
|
|
state?.allowedChannelIds ??
|
|
(accountId ? undefined : this.allowedChannelIds);
|
|
const dynamicChannelIds =
|
|
state?.dynamicChannelIds ??
|
|
(accountId ? new Set<string>() : this.dynamicChannelIds);
|
|
if (!allowedChannelIds) {
|
|
return true;
|
|
}
|
|
return (
|
|
allowedChannelIds.includes(channelId) || dynamicChannelIds.has(channelId)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Adds a channel to the dynamic allowed list.
|
|
*/
|
|
public addAllowedChannel(
|
|
channelId: string,
|
|
accountId?: string | null,
|
|
): boolean {
|
|
const state = this.getAccountState(accountId);
|
|
const client = state?.client ?? this.client;
|
|
if (!client?.channels.cache.has(channelId)) {
|
|
return false;
|
|
}
|
|
(state?.dynamicChannelIds ?? this.dynamicChannelIds).add(channelId);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Removes a channel from the dynamic allowed list.
|
|
*/
|
|
public removeAllowedChannel(
|
|
channelId: string,
|
|
accountId?: string | null,
|
|
): boolean {
|
|
const state = this.getAccountState(accountId);
|
|
const allowedChannelIds =
|
|
state?.allowedChannelIds ?? this.allowedChannelIds;
|
|
const dynamicChannelIds =
|
|
state?.dynamicChannelIds ?? this.dynamicChannelIds;
|
|
if (allowedChannelIds?.includes(channelId)) {
|
|
return false;
|
|
}
|
|
return dynamicChannelIds.delete(channelId);
|
|
}
|
|
|
|
/**
|
|
* Gets the list of all allowed channels (env + dynamic).
|
|
*/
|
|
public getAllowedChannels(accountId?: string | null): string[] {
|
|
const state = this.getAccountState(accountId);
|
|
const envChannels =
|
|
state?.allowedChannelIds ?? this.allowedChannelIds ?? [];
|
|
const dynamicChannels = Array.from(
|
|
state?.dynamicChannelIds ?? this.dynamicChannelIds,
|
|
);
|
|
return [...new Set([...envChannels, ...dynamicChannels])];
|
|
}
|
|
|
|
/**
|
|
* Fetches and persists message history from a Discord channel. Delegates to extracted module.
|
|
*/
|
|
public async fetchChannelHistory(
|
|
channelId: string,
|
|
options: ChannelHistoryOptions = {},
|
|
): Promise<ChannelHistoryResult> {
|
|
const state = this.getAccountState(options.accountId);
|
|
return fetchChannelHistoryExtracted(
|
|
this.createAccountServiceFacade(state),
|
|
channelId,
|
|
options,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Builds a Memory object from a Discord Message. Delegates to extracted module.
|
|
*/
|
|
public async buildMemoryFromMessage(
|
|
message: Message,
|
|
options?: {
|
|
processedContent?: string;
|
|
processedAttachments?: Media[];
|
|
extraContent?: Record<string, unknown>;
|
|
extraMetadata?: Record<string, unknown>;
|
|
accountId?: string;
|
|
},
|
|
): Promise<Memory | null> {
|
|
// Always stamp the connector accountId on inbound memory. Explicit
|
|
// per-call overrides win for legacy callers that already supply one.
|
|
const merged = {
|
|
...options,
|
|
accountId: options?.accountId ?? this.accountId,
|
|
};
|
|
return buildMemoryFromMessageExtracted(
|
|
this.createAccountServiceFacade(this.getAccountState(merged.accountId)),
|
|
message,
|
|
merged,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Maps a Discord snowflake user id to the runtime entity UUID, substituting
|
|
* the canonical Eliza owner entity when the user is a known Discord owner.
|
|
*/
|
|
public resolveDiscordEntityId(userId: string): UUID {
|
|
return resolveDiscordRuntimeEntityId(
|
|
this.runtime,
|
|
userId,
|
|
this.ownerDiscordUserIds,
|
|
) as UUID;
|
|
}
|
|
|
|
/**
|
|
* Handles reaction addition. Delegates to extracted module.
|
|
*/
|
|
public async handleReactionAdd(
|
|
reaction: MessageReaction | PartialMessageReaction,
|
|
user: User | PartialUser,
|
|
): Promise<void> {
|
|
await this.handleReactionAddForAccount(
|
|
this.defaultAccountId,
|
|
reaction,
|
|
user,
|
|
);
|
|
}
|
|
|
|
private async handleReactionAddForAccount(
|
|
accountId: string,
|
|
reaction: MessageReaction | PartialMessageReaction,
|
|
user: User | PartialUser,
|
|
): Promise<void> {
|
|
const state = this.requireAccountState(accountId);
|
|
await handleReactionAddExtracted(
|
|
this.createAccountServiceFacade(state),
|
|
reaction,
|
|
user,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handles reaction removal. Delegates to extracted module.
|
|
*/
|
|
public async handleReactionRemove(
|
|
reaction: MessageReaction | PartialMessageReaction,
|
|
user: User | PartialUser,
|
|
): Promise<void> {
|
|
await this.handleReactionRemoveForAccount(
|
|
this.defaultAccountId,
|
|
reaction,
|
|
user,
|
|
);
|
|
}
|
|
|
|
private async handleReactionRemoveForAccount(
|
|
accountId: string,
|
|
reaction: MessageReaction | PartialMessageReaction,
|
|
user: User | PartialUser,
|
|
): Promise<void> {
|
|
const state = this.requireAccountState(accountId);
|
|
await handleReactionRemoveExtracted(
|
|
this.createAccountServiceFacade(state),
|
|
reaction,
|
|
user,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handles guild creation (bot joined a guild). Delegates to extracted module.
|
|
*/
|
|
public async handleGuildCreate(guild: Guild): Promise<void> {
|
|
await this.handleGuildCreateForAccount(this.defaultAccountId, guild);
|
|
}
|
|
|
|
private async handleGuildCreateForAccount(
|
|
accountId: string,
|
|
guild: Guild,
|
|
): Promise<void> {
|
|
await handleGuildCreateExtracted(
|
|
this.createAccountServiceFacade(this.getAccountState(accountId)),
|
|
guild,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handles interaction creation (slash commands, modals, etc). Delegates to
|
|
* extracted module.
|
|
*/
|
|
public async handleInteractionCreate(
|
|
interaction: Interaction,
|
|
): Promise<void> {
|
|
await this.handleInteractionCreateForAccount(
|
|
this.defaultAccountId,
|
|
interaction,
|
|
);
|
|
}
|
|
|
|
private async handleInteractionCreateForAccount(
|
|
accountId: string,
|
|
interaction: Interaction,
|
|
): Promise<void> {
|
|
const state = this.requireAccountState(accountId);
|
|
await handleInteractionCreateExtracted(
|
|
this.createAccountServiceFacade(state),
|
|
interaction,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handles a new guild member joining — emits an ENTITY_JOINED event so the
|
|
* runtime can create the entity record.
|
|
*/
|
|
public async handleGuildMemberAdd(member: GuildMember): Promise<void> {
|
|
await this.handleGuildMemberAddForAccount(this.defaultAccountId, member);
|
|
}
|
|
|
|
private async handleGuildMemberAddForAccount(
|
|
accountId: string,
|
|
member: GuildMember,
|
|
): Promise<void> {
|
|
this.runtime.logger.info(
|
|
`New member joined: ${member.user.username} (${member.id})`,
|
|
);
|
|
|
|
const guild = member.guild;
|
|
const tag = member.user.bot
|
|
? `${member.user.username}#${member.user.discriminator}`
|
|
: member.user.username;
|
|
|
|
const worldId = createUniqueUuid(this.runtime, guild.id);
|
|
const entityId = this.resolveDiscordEntityId(member.id);
|
|
|
|
this.runtime.emitEvent(
|
|
[DiscordEventTypes.ENTITY_JOINED] as string[],
|
|
{
|
|
runtime: this.runtime,
|
|
entityId,
|
|
worldId,
|
|
source: "discord",
|
|
metadata: {
|
|
accountId,
|
|
type: member.user.bot ? "bot" : "user",
|
|
originalId: member.id,
|
|
username: tag,
|
|
displayName: member.displayName || member.user.username,
|
|
roles: member.roles.cache.map((r) => r.name),
|
|
joinedAt: member.joinedAt?.getTime
|
|
? member.joinedAt.getTime()
|
|
: undefined,
|
|
},
|
|
member,
|
|
} as EventPayload,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stops the Discord service and cleans up resources.
|
|
*/
|
|
public async stop(): Promise<void> {
|
|
this.runtime.logger.info("Stopping Discord service");
|
|
this.timeouts.forEach(clearTimeout);
|
|
this.timeouts = [];
|
|
|
|
const states = this.accountPool.list();
|
|
for (const state of states) {
|
|
state.loginStopRequested = true;
|
|
if (state.loginRetryTimer) {
|
|
clearTimeout(state.loginRetryTimer);
|
|
state.loginRetryTimer = undefined;
|
|
}
|
|
const rejectLoginReady = state.loginReadyReject;
|
|
state.loginReadyReject = undefined;
|
|
rejectLoginReady?.(this.createLoginStoppedError(state));
|
|
}
|
|
for (const state of states) {
|
|
state.channelDebouncer?.destroy();
|
|
state.channelDebouncer = undefined;
|
|
}
|
|
this.channelDebouncer = undefined;
|
|
|
|
for (const state of states) {
|
|
try {
|
|
state.voiceManager?.stop();
|
|
this.voiceTargets.unregisterAccount(state.accountId);
|
|
} catch (error) {
|
|
this.runtime.logger.warn(
|
|
`Discord voice cleanup failed: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const state of states) {
|
|
const client = state.client;
|
|
if (!client) {
|
|
continue;
|
|
}
|
|
try {
|
|
await client.destroy();
|
|
this.runtime.logger.info(
|
|
`Discord client destroyed for account ${state.accountId}`,
|
|
);
|
|
} catch (error) {
|
|
this.runtime.logger.warn(
|
|
`Discord client destroy failed: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
} finally {
|
|
state.client = null;
|
|
}
|
|
}
|
|
|
|
for (const sink of this.audioSinks.values()) {
|
|
sink.destroy();
|
|
}
|
|
this.audioSinks.clear();
|
|
this.voiceTargets.clear();
|
|
|
|
this.accountPool.clear();
|
|
this.clientReadyPromise = null;
|
|
this.messageManager = undefined;
|
|
this.voiceManager = undefined;
|
|
this.client = null;
|
|
this.runtime.logger.info("Discord service stopped");
|
|
}
|
|
|
|
/**
|
|
* Asynchronously retrieves the type of a given channel.
|
|
*/
|
|
async getChannelType(channel: Channel): Promise<ChannelType> {
|
|
switch (channel.type) {
|
|
case DiscordChannelType.DM:
|
|
return ChannelType.DM;
|
|
|
|
case DiscordChannelType.GroupDM:
|
|
return ChannelType.DM;
|
|
|
|
case DiscordChannelType.GuildText:
|
|
case DiscordChannelType.GuildNews:
|
|
case DiscordChannelType.PublicThread:
|
|
case DiscordChannelType.PrivateThread:
|
|
case DiscordChannelType.AnnouncementThread:
|
|
case DiscordChannelType.GuildForum:
|
|
return ChannelType.GROUP;
|
|
|
|
case DiscordChannelType.GuildVoice:
|
|
case DiscordChannelType.GuildStageVoice:
|
|
return ChannelType.VOICE_GROUP;
|
|
|
|
default:
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
channelType: channel.type,
|
|
},
|
|
"Unknown channel type, defaulting to GROUP",
|
|
);
|
|
return ChannelType.GROUP;
|
|
}
|
|
}
|
|
}
|