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
311 lines
8.2 KiB
TypeScript
311 lines
8.2 KiB
TypeScript
/**
|
|
* Syncs the bot's Discord username and avatar on startup from the character
|
|
* profile, gated by `DISCORD_SYNC_PROFILE`. Hashes the avatar bytes to skip
|
|
* uploads when nothing changed.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type { IAgentRuntime } from "@elizaos/core";
|
|
import { resolveStateDir, resolveUserPath } from "@elizaos/core";
|
|
import type { ClientUser } from "discord.js";
|
|
import type { DiscordSettings } from "./types";
|
|
|
|
const MAX_PROFILE_AVATAR_BYTES = 8 * 1024 * 1024;
|
|
const PROFILE_SYNC_STATE_FILE = "discord-profile-sync.v1.json";
|
|
const DEFAULT_DISCORD_PROFILE_AVATAR = "/avatars/eliza.png";
|
|
|
|
type PersistedDiscordProfileSyncState = {
|
|
avatarHash?: string;
|
|
username?: string;
|
|
};
|
|
|
|
function resolveProfileSyncStatePath(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): string {
|
|
return path.join(resolveStateDir(env), "cache", PROFILE_SYNC_STATE_FILE);
|
|
}
|
|
|
|
async function readPersistedProfileSyncState(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Promise<PersistedDiscordProfileSyncState> {
|
|
try {
|
|
const raw = await fs.readFile(resolveProfileSyncStatePath(env), "utf8");
|
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
return {
|
|
...(typeof parsed.avatarHash === "string"
|
|
? { avatarHash: parsed.avatarHash }
|
|
: {}),
|
|
...(typeof parsed.username === "string"
|
|
? { username: parsed.username }
|
|
: {}),
|
|
};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function writePersistedProfileSyncState(
|
|
state: PersistedDiscordProfileSyncState,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Promise<void> {
|
|
const statePath = resolveProfileSyncStatePath(env);
|
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
await fs.writeFile(statePath, JSON.stringify(state, null, 2), {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
}
|
|
|
|
function normalizeDesiredDiscordName(
|
|
runtime: IAgentRuntime,
|
|
settings: DiscordSettings,
|
|
): string | undefined {
|
|
const configured = settings.profileName?.trim();
|
|
if (configured) {
|
|
return configured;
|
|
}
|
|
|
|
const characterName = runtime.character.name?.trim();
|
|
if (characterName) {
|
|
return characterName;
|
|
}
|
|
|
|
const characterUserName = runtime.character.username?.trim();
|
|
return characterUserName || undefined;
|
|
}
|
|
|
|
function readNestedOptionalString(
|
|
value: unknown,
|
|
pathSegments: string[],
|
|
): string | undefined {
|
|
let cursor: unknown = value;
|
|
for (const segment of pathSegments) {
|
|
if (!cursor || typeof cursor !== "object") {
|
|
return undefined;
|
|
}
|
|
cursor = (cursor as Record<string, unknown>)[segment];
|
|
}
|
|
|
|
return typeof cursor === "string" && cursor.trim().length > 0
|
|
? cursor.trim()
|
|
: undefined;
|
|
}
|
|
|
|
function normalizeDesiredDiscordAvatarSource(
|
|
runtime: IAgentRuntime,
|
|
settings: DiscordSettings,
|
|
): string | undefined {
|
|
const configured = settings.profileAvatar?.trim();
|
|
if (configured) {
|
|
return configured;
|
|
}
|
|
|
|
const character = runtime.character as Record<string, unknown> | undefined;
|
|
const fromIdentity =
|
|
readNestedOptionalString(character, ["identity", "avatar"]) ??
|
|
readNestedOptionalString(character, ["settings", "identity", "avatar"]);
|
|
if (fromIdentity) {
|
|
return fromIdentity;
|
|
}
|
|
|
|
const fromCharacter =
|
|
readNestedOptionalString(character, ["avatar"]) ??
|
|
readNestedOptionalString(character, ["settings", "avatar"]);
|
|
if (fromCharacter) {
|
|
return fromCharacter;
|
|
}
|
|
|
|
return DEFAULT_DISCORD_PROFILE_AVATAR;
|
|
}
|
|
|
|
function extractDataUriPayload(source: string): Buffer | null {
|
|
const match = source.match(/^data:image\/[^;]+;base64,([a-z0-9+/=]+)$/i);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return Buffer.from(match[1], "base64");
|
|
}
|
|
|
|
function buildLocalAvatarPathCandidates(source: string): string[] {
|
|
const candidates = new Set<string>();
|
|
const trimmed = source.trim();
|
|
if (!trimmed) {
|
|
return [];
|
|
}
|
|
|
|
candidates.add(resolveUserPath(trimmed));
|
|
|
|
const normalized = trimmed.replace(/\\/g, "/");
|
|
const withoutLeadingSlash = normalized.replace(/^\/+/, "");
|
|
if (!withoutLeadingSlash) {
|
|
return [...candidates];
|
|
}
|
|
|
|
const repoRoot = process.cwd();
|
|
const publicRoots = [
|
|
path.join(repoRoot, "cloud", "public"),
|
|
path.join(repoRoot, "apps", "web", "public"),
|
|
path.join(repoRoot, "public"),
|
|
];
|
|
|
|
for (const publicRoot of publicRoots) {
|
|
candidates.add(path.join(publicRoot, withoutLeadingSlash));
|
|
if (!withoutLeadingSlash.startsWith("avatars/")) {
|
|
candidates.add(path.join(publicRoot, "avatars", withoutLeadingSlash));
|
|
}
|
|
}
|
|
|
|
return [...candidates];
|
|
}
|
|
|
|
async function readAvatarBytesFromLocalCandidates(
|
|
source: string,
|
|
): Promise<Buffer> {
|
|
let lastError: unknown = null;
|
|
for (const candidate of buildLocalAvatarPathCandidates(source)) {
|
|
try {
|
|
return await fs.readFile(candidate);
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
|
|
if (lastError instanceof Error) {
|
|
throw lastError;
|
|
}
|
|
throw new Error(`Unable to resolve Discord profile avatar source: ${source}`);
|
|
}
|
|
|
|
async function loadDiscordProfileAvatarBytes(
|
|
source: string,
|
|
runtime: IAgentRuntime,
|
|
): Promise<{ bytes: Buffer; hash: string } | null> {
|
|
const trimmed = source.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
let bytes: Buffer | null = extractDataUriPayload(trimmed);
|
|
if (!bytes) {
|
|
let remoteUrl: URL | null = null;
|
|
try {
|
|
const parsedUrl = new URL(trimmed);
|
|
if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") {
|
|
remoteUrl = parsedUrl;
|
|
}
|
|
} catch {
|
|
// error-policy:J3 the avatar source is untrusted input that may be a data URI,
|
|
// URL, or local path; a URL parse failure just means "not a remote URL" and
|
|
// falls through to the local-candidate reader below — not an error.
|
|
}
|
|
|
|
if (remoteUrl) {
|
|
const fetchImpl = runtime.fetch ?? globalThis.fetch;
|
|
if (typeof fetchImpl !== "function") {
|
|
return null;
|
|
}
|
|
const response = await fetchImpl(trimmed, {
|
|
headers: { Accept: "image/*" },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const contentType = response.headers.get("content-type");
|
|
if (!contentType?.toLowerCase().startsWith("image/")) {
|
|
throw new Error(
|
|
`Expected image content-type, got ${contentType ?? "unknown"}`,
|
|
);
|
|
}
|
|
bytes = Buffer.from(await response.arrayBuffer());
|
|
} else {
|
|
bytes = await readAvatarBytesFromLocalCandidates(trimmed);
|
|
}
|
|
}
|
|
|
|
if (!bytes || bytes.length === 0) {
|
|
return null;
|
|
}
|
|
if (bytes.length > MAX_PROFILE_AVATAR_BYTES) {
|
|
throw new Error(
|
|
`Discord profile avatar exceeds ${MAX_PROFILE_AVATAR_BYTES} bytes`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
bytes,
|
|
hash: createHash("sha256").update(bytes).digest("hex"),
|
|
};
|
|
}
|
|
|
|
export async function syncDiscordClientProfile(
|
|
runtime: IAgentRuntime,
|
|
clientUser: Pick<ClientUser, "username"> & {
|
|
setAvatar?: (avatar: Buffer | string | null) => Promise<unknown>;
|
|
setUsername?: (username: string) => Promise<unknown>;
|
|
},
|
|
settings: DiscordSettings,
|
|
): Promise<void> {
|
|
if (settings.syncProfile === false) {
|
|
return;
|
|
}
|
|
|
|
const desiredName = normalizeDesiredDiscordName(runtime, settings);
|
|
const desiredAvatarSource = normalizeDesiredDiscordAvatarSource(
|
|
runtime,
|
|
settings,
|
|
);
|
|
if (!desiredName && !desiredAvatarSource) {
|
|
return;
|
|
}
|
|
|
|
const persisted = await readPersistedProfileSyncState();
|
|
const nextState: PersistedDiscordProfileSyncState = { ...persisted };
|
|
let stateChanged = false;
|
|
|
|
if (desiredName) {
|
|
if (persisted.username !== desiredName) {
|
|
if (clientUser.username !== desiredName) {
|
|
if (typeof clientUser.setUsername === "function") {
|
|
await clientUser.setUsername(desiredName);
|
|
runtime.logger.info(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: runtime.agentId,
|
|
discordProfileName: desiredName,
|
|
},
|
|
"Synchronized Discord bot username from connector settings",
|
|
);
|
|
}
|
|
}
|
|
nextState.username = desiredName;
|
|
stateChanged = true;
|
|
}
|
|
}
|
|
|
|
if (desiredAvatarSource) {
|
|
const avatar = await loadDiscordProfileAvatarBytes(
|
|
desiredAvatarSource,
|
|
runtime,
|
|
);
|
|
if (avatar && persisted.avatarHash !== avatar.hash) {
|
|
if (typeof clientUser.setAvatar === "function") {
|
|
await clientUser.setAvatar(avatar.bytes);
|
|
runtime.logger.info(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: runtime.agentId,
|
|
},
|
|
"Synchronized Discord bot avatar from connector settings",
|
|
);
|
|
}
|
|
nextState.avatarHash = avatar.hash;
|
|
stateChanged = true;
|
|
}
|
|
}
|
|
|
|
if (stateChanged) {
|
|
await writePersistedProfileSyncState(nextState);
|
|
}
|
|
}
|