Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

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);
}
}