mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
8668aeb969
* 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>
297 lines
10 KiB
TypeScript
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,
|
|
},
|
|
})) ?? []
|
|
);
|
|
},
|
|
};
|