Files
openclaw/extensions/discord/src/voice/transcripts-source.ts
Pavan Kumar Gondhi 8668aeb969 fix(discord): bind transcript capture to source account [AI] (#118579)
* fix(discord): bind transcript capture to source account

* style(agents): keep transcript tool wiring compact

* fix(transcripts): declare account binding channels

* fix(transcripts): report effective capture account

* fix(transcripts): enforce account lifecycle ownership

* fix(transcripts): preserve cross-surface control

* fix(copilot): preserve transcript channel context

* fix(transcripts): fail closed for legacy channel owners

* fix(transcripts): add trusted legacy recovery

* fix(transcripts): preserve auto-start cleanup ownership

* fix(transcripts): reject untrusted account starts

* fix(transcripts): keep persisted ownership authoritative

* fix(transcripts): harden legacy recovery

* fix(transcripts): preserve agent ownership boundary

* fix(transcripts): scope account binding to source channel

* fix(transcripts): preserve unattributed owner isolation

* fix(transcripts): own configured captures by account

* docs(plugins): clarify transcript auto-start ownership

* test(transcripts): cover account-less recovery

* docs(transcripts): scope legacy recovery by provider

* fix(discord): reuse eligible account ordering for transcripts

* test(discord): use neutral transcript account fixtures

* fix(transcripts): keep accountless recovery local

* fix(discord): resolve transcript accounts by voice capability

* fix(transcripts): bound account resolution failures

* fix(transcripts): bound account tool output

* fix(transcripts): honor unresolved provider accounts

* fix(transcripts): preserve binding when providers are missing

* fix(transcripts): fail closed on unknown binding provenance

* fix(transcripts): qualify account lifecycle capability

* fix(transcripts): normalize provable legacy owners

* fix(transcripts): bind scheduled capture to caller authority

* fix(transcripts): preserve scheduled caller identity tuple

* fix(transcripts): preserve channel-less scheduled authority

* fix(plugin-sdk): publish transcript provider types

* fix(transcripts): use exact lifecycle ownership tokens

* fix(transcripts): preserve local ownerless lifecycle access

* fix(transcripts): allow local configured capture control

* fix(transcripts): preserve scheduled caller channel

* fix(transcripts): retain named-agent legacy recovery

* fix(transcripts): deny unrelated remote channels

* fix(doctor): validate transcript owner inference

* fix(transcripts): restrict legacy remote recovery

* fix(ci): align transcript Doctor checks

* fix(transcripts): require Doctor-owned legacy metadata

* fix(transcripts): reject unowned remote capture starts

* fix(transcripts): reject unbound Discord lifecycle calls

* fix(transcripts): distinguish legacy owner rows

* test(discord): keep unavailable account fixture typed

* fix(transcripts): mark current imports for Doctor

* fix(transcripts): complete account ownership validation

* fix(discord): restore transcript package boundary

* fix(discord): preserve bundled transcript entry boundary

* docs(transcripts): clarify Discord auto-start account

* fix(transcripts): bind account-owned imports

* fix: preserve transcript and cron policy state

* fix(cron): preserve scheduled transcript authority

* fix(discord): keep legacy transcript rows local

* fix(transcripts): narrow account ownership boundary

* fix(transcripts): preserve trusted caller ownership

* fix(discord): enforce transcript source authorization

* fix(ci): bound Control UI gzip build variance

* test(qa): align transcript scenario contracts

* fix(agents): repair rebased caller context

* fix(discord): restore rebased account ownership

* test(discord): restore voice account fixtures

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-08-15 12:10:43 -07:00

297 lines
10 KiB
TypeScript

import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { summarizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
// Discord plugin module implements transcripts source behavior.
import type {
TranscriptSourceProvider,
TranscriptStartRequest,
} from "openclaw/plugin-sdk/transcripts";
import { listEnabledDiscordAccounts, resolveDiscordAccount } from "../accounts.js";
import { authorizeDiscordVoiceIngress } from "./access.js";
import { resolveDiscordVoiceEnabled } from "./config.js";
import { resolveDiscordVoiceAccess } from "./owner-access.js";
import type { DiscordVoiceManager } from "./voice-runtime.js";
const managersByAccountId = new Map<string, DiscordVoiceManager>();
const managerWaiters = new Set<{
accountId?: string;
resolve: () => void;
}>();
const ACCOUNT_ID_ERROR_MAX_CHARS = 64;
const ACCOUNT_ID_ERROR_MAX_ENTRIES = 4;
function formatAccountIdForError(accountId: string): string {
return JSON.stringify(truncateUtf16Safe(accountId, ACCOUNT_ID_ERROR_MAX_CHARS));
}
function summarizeAccountIdsForError(accountIds: readonly string[]): string {
return summarizeStringEntries({
entries: accountIds.map(formatAccountIdForError),
limit: ACCOUNT_ID_ERROR_MAX_ENTRIES,
});
}
export function setDiscordTranscriptsVoiceManager(params: {
accountId: string;
manager: DiscordVoiceManager | null;
}): void {
if (params.manager) {
managersByAccountId.set(params.accountId, params.manager);
for (const waiter of managerWaiters) {
if (!waiter.accountId || waiter.accountId === params.accountId) {
waiter.resolve();
}
}
} else {
managersByAccountId.delete(params.accountId);
}
}
const resolveDiscordTranscriptsAccountId: NonNullable<
NonNullable<TranscriptSourceProvider["accessControl"]>["resolveAccountId"]
> = ({ cfg, source }) => {
const requestedAccountId = source.accountId?.trim();
const configuredVoiceAccounts = cfg
? listEnabledDiscordAccounts(cfg).filter((account) =>
resolveDiscordVoiceEnabled(account.config.voice),
)
: [];
// Configuration owns capability; the manager map is transient readiness state.
// Falling back to it only supports direct provider calls that have no config.
const capableAccountIds = (
cfg
? configuredVoiceAccounts
.filter((account) => account.tokenStatus === "available")
.map((account) => account.accountId)
: [...managersByAccountId.keys()]
).toSorted();
if (requestedAccountId) {
// A provider can be called directly without config while its manager is starting.
// With config, reject accounts that can never register a voice manager.
if (!cfg || capableAccountIds.includes(requestedAccountId)) {
return { ok: true, value: requestedAccountId };
}
if (
resolveDiscordAccount({ cfg, accountId: requestedAccountId }).tokenStatus ===
"configured_unavailable"
) {
return {
ok: false,
error: `Discord account ${formatAccountIdForError(requestedAccountId)} has configured credentials that are unavailable in this runtime; resolve its SecretRef before using this account.`,
};
}
return {
ok: false,
error: `Discord account ${formatAccountIdForError(requestedAccountId)} is not enabled for voice.`,
};
}
if (capableAccountIds.length === 1) {
return { ok: true, value: capableAccountIds[0] };
}
if (capableAccountIds.length === 0) {
return {
ok: false,
error:
"No Discord account has available credentials and voice enabled; configure credentials and enable voice for an account.",
};
}
const configuredDefaultAccountId = cfg?.channels?.discord?.defaultAccount?.trim();
if (configuredDefaultAccountId) {
const normalizedDefaultAccountId = normalizeAccountId(configuredDefaultAccountId);
if (capableAccountIds.includes(normalizedDefaultAccountId)) {
return { ok: true, value: normalizedDefaultAccountId };
}
}
if (capableAccountIds.includes(DEFAULT_ACCOUNT_ID)) {
return { ok: true, value: DEFAULT_ACCOUNT_ID };
}
return {
ok: false,
error: `Multiple Discord accounts are enabled for voice (${summarizeAccountIdsForError(capableAccountIds)}); specify accountId.`,
};
};
async function waitForManager(
request: TranscriptStartRequest,
): Promise<{ ok: true; value: DiscordVoiceManager | undefined } | { ok: false; error: string }> {
const accountResolution = resolveDiscordTranscriptsAccountId({
cfg: request.cfg,
source: request.session.source,
});
if (!accountResolution.ok) {
return accountResolution;
}
const accountId = accountResolution.value;
const existing = accountId ? managersByAccountId.get(accountId) : undefined;
if (existing) {
return { ok: true, value: existing };
}
if (request.abortSignal?.aborted) {
return { ok: true, value: undefined };
}
const startupWaitMs = request.startupWaitMs ?? 0;
if (startupWaitMs <= 0) {
return { ok: true, value: undefined };
}
await new Promise<void>((resolve) => {
const waiter = {
accountId,
resolve: () => {
clearTimeout(timer);
request.abortSignal?.removeEventListener("abort", waiter.resolve);
managerWaiters.delete(waiter);
resolve();
},
};
const timer = setTimeout(waiter.resolve, startupWaitMs);
timer.unref?.();
request.abortSignal?.addEventListener("abort", waiter.resolve, { once: true });
managerWaiters.add(waiter);
});
if (request.abortSignal?.aborted) {
return { ok: true, value: undefined };
}
return { ok: true, value: accountId ? managersByAccountId.get(accountId) : undefined };
}
export const discordVoiceTranscriptsSourceProvider: TranscriptSourceProvider = {
id: "discord-voice",
aliases: ["discord"],
accessControl: {
channelId: "discord",
resolveAccountId: resolveDiscordTranscriptsAccountId,
async authorize({ caller, cfg, source }) {
if (caller.kind === "operator") {
return { ok: true, value: undefined };
}
const guildId = source.guildId?.trim();
const channelId = source.channelId?.trim();
const callerAccountId = caller.accountId?.trim();
const sourceAccountId = source.accountId?.trim();
if (
caller.channel !== "discord" ||
!cfg ||
!callerAccountId ||
sourceAccountId !== callerAccountId ||
!guildId ||
!channelId ||
caller.groupSpace !== guildId
) {
return { ok: false, error: "You are not authorized to use this command." };
}
const manager = managersByAccountId.get(callerAccountId);
const target = await manager?.resolveAccessTarget({ guildId, channelId });
if (!target) {
return { ok: false, error: "Discord voice access target is unavailable." };
}
const account = resolveDiscordAccount({ cfg, accountId: callerAccountId });
const access = await authorizeDiscordVoiceIngress({
cfg,
discordConfig: account.config,
accountId: account.accountId,
guild: target.guild,
guildId,
channelId,
...(target.channelName ? { channelName: target.channelName } : {}),
channelSlug: target.channelSlug,
...(target.parentId ? { parentId: target.parentId } : {}),
...(target.parentName ? { parentName: target.parentName } : {}),
...(target.parentSlug ? { parentSlug: target.parentSlug } : {}),
scope: target.scope,
memberRoleIds: [...caller.roleIds],
admissionAllowFrom: resolveDiscordVoiceAccess({
cfg,
discordConfig: account.config,
accountId: account.accountId,
}).admissionAllowFrom,
sender: { id: caller.senderId },
});
return access.ok ? { ok: true, value: undefined } : { ok: false, error: access.message };
},
},
name: "Discord Voice",
sourceKinds: ["live-audio"],
async start(request) {
const managerResolution = await waitForManager(request);
if (!managerResolution.ok) {
return managerResolution;
}
const manager = managerResolution.value;
if (!manager) {
return { ok: false, error: "Discord voice manager is not available." };
}
if (request.abortSignal?.aborted) {
return { ok: false, error: "Discord transcripts start aborted." };
}
const guildId = request.session.source.guildId?.trim();
const channelId = request.session.source.channelId?.trim();
if (!guildId || !channelId) {
return { ok: false, error: "Discord transcripts require guildId and channelId." };
}
const joined = await manager.join(
{ guildId, channelId },
{
transcripts: {
sessionId: request.session.sessionId,
onUtterance: request.onUtterance,
},
},
);
if (!joined.ok) {
return { ok: false, error: joined.message };
}
return { ok: true, session: request.session };
},
async stop(request) {
const accountId = request.source.accountId?.trim();
if (!accountId) {
return {
ok: false,
error: "Discord transcripts require accountId to stop a voice session.",
};
}
const manager = managersByAccountId.get(accountId);
if (!manager) {
return { ok: false, error: "Discord voice manager is not available." };
}
const guildId = request.source.guildId?.trim();
if (!guildId) {
return { ok: false, error: "Discord transcripts require guildId." };
}
const result = await manager.leave(
{
guildId,
channelId: request.source.channelId,
},
{
transcriptsSessionId: request.sessionId,
},
);
if (!result.ok) {
return { ok: false, error: result.message };
}
return { ok: true, sessionId: request.sessionId, stoppedAt: new Date().toISOString() };
},
async status(source) {
const accountId = source.accountId?.trim();
if (!accountId) {
return [];
}
const manager = managersByAccountId.get(accountId);
return (
manager?.status().map((entry) => ({
active: entry.ok,
message: entry.message,
source: {
providerId: "discord-voice",
accountId,
guildId: entry.guildId,
channelId: entry.channelId,
},
})) ?? []
);
},
};