mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:08:37 +00:00
fix(security): enforce explicit ingress owner context
This commit is contained in:
@@ -944,6 +944,7 @@ Auto-join example:
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `voice.tts` overrides `messages.tts` for voice playback only.
|
- `voice.tts` overrides `messages.tts` for voice playback only.
|
||||||
|
- Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`); non-owner speakers cannot access owner-only tools (for example `gateway` and `cron`).
|
||||||
- Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it.
|
- Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it.
|
||||||
- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options.
|
- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options.
|
||||||
- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset.
|
- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset.
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
|
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
|
||||||
"build:strict-smoke": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts",
|
"build:strict-smoke": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts",
|
||||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||||
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:plugins:no-register-http-handler && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
|
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
|
||||||
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
|
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
|
||||||
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
|
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
|
||||||
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
|
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
|
||||||
@@ -100,6 +100,7 @@
|
|||||||
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
|
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
|
||||||
"ios:run": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'",
|
"ios:run": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'",
|
||||||
"lint": "oxlint --type-aware",
|
"lint": "oxlint --type-aware",
|
||||||
|
"lint:agent:ingress-owner": "node scripts/check-ingress-agent-owner-context.mjs",
|
||||||
"lint:all": "pnpm lint && pnpm lint:swift",
|
"lint:all": "pnpm lint && pnpm lint:swift",
|
||||||
"lint:auth:no-pairing-store-group": "node scripts/check-no-pairing-store-group-auth.mjs",
|
"lint:auth:no-pairing-store-group": "node scripts/check-no-pairing-store-group-auth.mjs",
|
||||||
"lint:auth:pairing-account-scope": "node scripts/check-pairing-account-scope.mjs",
|
"lint:auth:pairing-account-scope": "node scripts/check-pairing-account-scope.mjs",
|
||||||
|
|||||||
45
scripts/check-ingress-agent-owner-context.mjs
Normal file
45
scripts/check-ingress-agent-owner-context.mjs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import ts from "typescript";
|
||||||
|
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
|
||||||
|
import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
|
||||||
|
|
||||||
|
const sourceRoots = ["src/gateway", "src/discord/voice"];
|
||||||
|
const enforcedFiles = new Set([
|
||||||
|
"src/discord/voice/manager.ts",
|
||||||
|
"src/gateway/openai-http.ts",
|
||||||
|
"src/gateway/openresponses-http.ts",
|
||||||
|
"src/gateway/server-methods/agent.ts",
|
||||||
|
"src/gateway/server-node-events.ts",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function findLegacyAgentCommandCallLines(content, fileName = "source.ts") {
|
||||||
|
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||||
|
const lines = [];
|
||||||
|
const visit = (node) => {
|
||||||
|
if (ts.isCallExpression(node)) {
|
||||||
|
const callee = unwrapExpression(node.expression);
|
||||||
|
if (ts.isIdentifier(callee) && callee.text === "agentCommand") {
|
||||||
|
lines.push(toLine(sourceFile, callee));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
};
|
||||||
|
visit(sourceFile);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function main() {
|
||||||
|
await runCallsiteGuard({
|
||||||
|
importMetaUrl: import.meta.url,
|
||||||
|
sourceRoots,
|
||||||
|
findCallLines: findLegacyAgentCommandCallLines,
|
||||||
|
skipRelativePath: (relPath) => !enforcedFiles.has(relPath.replaceAll(path.sep, "/")),
|
||||||
|
header: "Found ingress callsites using local agentCommand() (must be explicit owner-aware):",
|
||||||
|
footer:
|
||||||
|
"Use agentCommandFromIngress(...) and pass senderIsOwner explicitly at ingress boundaries.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
runAsScript(import.meta.url, main);
|
||||||
@@ -15,7 +15,7 @@ import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
|
|||||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
|
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||||
import { agentCommand } from "./agent.js";
|
import { agentCommand, agentCommandFromIngress } from "./agent.js";
|
||||||
import * as agentDeliveryModule from "./agent/delivery.js";
|
import * as agentDeliveryModule from "./agent/delivery.js";
|
||||||
|
|
||||||
vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
|
vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
|
||||||
@@ -316,6 +316,27 @@ describe("agentCommand", () => {
|
|||||||
expect(callArgs?.senderIsOwner).toBe(expected);
|
expect(callArgs?.senderIsOwner).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires explicit senderIsOwner for ingress runs", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const store = path.join(home, "sessions.json");
|
||||||
|
mockConfig(home, store);
|
||||||
|
await expect(
|
||||||
|
// Runtime guard for non-TS callers; TS callsites are statically typed.
|
||||||
|
agentCommandFromIngress({ message: "hi", to: "+1555" } as never, runtime),
|
||||||
|
).rejects.toThrow("senderIsOwner must be explicitly set for ingress agent runs.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors explicit senderIsOwner for ingress runs", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const store = path.join(home, "sessions.json");
|
||||||
|
mockConfig(home, store);
|
||||||
|
await agentCommandFromIngress({ message: "hi", to: "+1555", senderIsOwner: false }, runtime);
|
||||||
|
const ingressCall = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||||
|
expect(ingressCall?.senderIsOwner).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("resumes when session-id is provided", async () => {
|
it("resumes when session-id is provided", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const store = path.join(home, "sessions.json");
|
const store = path.join(home, "sessions.json");
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ import { deliverAgentCommandResult } from "./agent/delivery.js";
|
|||||||
import { resolveAgentRunContext } from "./agent/run-context.js";
|
import { resolveAgentRunContext } from "./agent/run-context.js";
|
||||||
import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js";
|
import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js";
|
||||||
import { resolveSession } from "./agent/session.js";
|
import { resolveSession } from "./agent/session.js";
|
||||||
import type { AgentCommandOpts } from "./agent/types.js";
|
import type { AgentCommandIngressOpts, AgentCommandOpts } from "./agent/types.js";
|
||||||
|
|
||||||
type PersistSessionEntryParams = {
|
type PersistSessionEntryParams = {
|
||||||
sessionStore: Record<string, SessionEntry>;
|
sessionStore: Record<string, SessionEntry>;
|
||||||
@@ -160,7 +160,7 @@ function runAgentAttempt(params: {
|
|||||||
resolvedThinkLevel: ThinkLevel;
|
resolvedThinkLevel: ThinkLevel;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
runId: string;
|
runId: string;
|
||||||
opts: AgentCommandOpts;
|
opts: AgentCommandOpts & { senderIsOwner: boolean };
|
||||||
runContext: ReturnType<typeof resolveAgentRunContext>;
|
runContext: ReturnType<typeof resolveAgentRunContext>;
|
||||||
spawnedBy: string | undefined;
|
spawnedBy: string | undefined;
|
||||||
messageChannel: ReturnType<typeof resolveMessageChannel>;
|
messageChannel: ReturnType<typeof resolveMessageChannel>;
|
||||||
@@ -172,7 +172,6 @@ function runAgentAttempt(params: {
|
|||||||
sessionStore?: Record<string, SessionEntry>;
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
storePath?: string;
|
storePath?: string;
|
||||||
}) {
|
}) {
|
||||||
const senderIsOwner = params.opts.senderIsOwner ?? true;
|
|
||||||
const effectivePrompt = resolveFallbackRetryPrompt({
|
const effectivePrompt = resolveFallbackRetryPrompt({
|
||||||
body: params.body,
|
body: params.body,
|
||||||
isFallbackRetry: params.isFallbackRetry,
|
isFallbackRetry: params.isFallbackRetry,
|
||||||
@@ -292,7 +291,7 @@ function runAgentAttempt(params: {
|
|||||||
currentThreadTs: params.runContext.currentThreadTs,
|
currentThreadTs: params.runContext.currentThreadTs,
|
||||||
replyToMode: params.runContext.replyToMode,
|
replyToMode: params.runContext.replyToMode,
|
||||||
hasRepliedRef: params.runContext.hasRepliedRef,
|
hasRepliedRef: params.runContext.hasRepliedRef,
|
||||||
senderIsOwner,
|
senderIsOwner: params.opts.senderIsOwner,
|
||||||
sessionFile: params.sessionFile,
|
sessionFile: params.sessionFile,
|
||||||
workspaceDir: params.workspaceDir,
|
workspaceDir: params.workspaceDir,
|
||||||
config: params.cfg,
|
config: params.cfg,
|
||||||
@@ -318,8 +317,8 @@ function runAgentAttempt(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function agentCommand(
|
async function agentCommandInternal(
|
||||||
opts: AgentCommandOpts,
|
opts: AgentCommandOpts & { senderIsOwner: boolean },
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
deps: CliDeps = createDefaultDeps(),
|
deps: CliDeps = createDefaultDeps(),
|
||||||
) {
|
) {
|
||||||
@@ -922,3 +921,36 @@ export async function agentCommand(
|
|||||||
clearAgentRunContext(runId);
|
clearAgentRunContext(runId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function agentCommand(
|
||||||
|
opts: AgentCommandOpts,
|
||||||
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
|
deps: CliDeps = createDefaultDeps(),
|
||||||
|
) {
|
||||||
|
return await agentCommandInternal(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
senderIsOwner: opts.senderIsOwner ?? true,
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function agentCommandFromIngress(
|
||||||
|
opts: AgentCommandIngressOpts,
|
||||||
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
|
deps: CliDeps = createDefaultDeps(),
|
||||||
|
) {
|
||||||
|
if (typeof opts.senderIsOwner !== "boolean") {
|
||||||
|
throw new Error("senderIsOwner must be explicitly set for ingress agent runs.");
|
||||||
|
}
|
||||||
|
return await agentCommandInternal(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
senderIsOwner: opts.senderIsOwner,
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,3 +81,8 @@ export type AgentCommandOpts = {
|
|||||||
/** Per-call stream param overrides (best-effort). */
|
/** Per-call stream param overrides (best-effort). */
|
||||||
streamParams?: AgentStreamParams;
|
streamParams?: AgentStreamParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentCommandIngressOpts = Omit<AgentCommandOpts, "senderIsOwner"> & {
|
||||||
|
/** Ingress callsites must always pass explicit owner authorization state. */
|
||||||
|
senderIsOwner: boolean;
|
||||||
|
};
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
resolveDiscordChannelConfigWithFallback,
|
resolveDiscordChannelConfigWithFallback,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
resolveDiscordMemberAccessState,
|
resolveDiscordMemberAccessState,
|
||||||
|
resolveDiscordOwnerAccess,
|
||||||
resolveDiscordOwnerAllowFrom,
|
resolveDiscordOwnerAllowFrom,
|
||||||
} from "./allow-list.js";
|
} from "./allow-list.js";
|
||||||
import { formatDiscordUserTag } from "./format.js";
|
import { formatDiscordUserTag } from "./format.js";
|
||||||
@@ -764,18 +765,15 @@ function resolveComponentCommandAuthorized(params: {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerAllowList = normalizeDiscordAllowList(ctx.allowFrom, ["discord:", "user:", "pk:"]);
|
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
|
||||||
const ownerOk = ownerAllowList
|
allowFrom: ctx.allowFrom,
|
||||||
? resolveDiscordAllowListMatch({
|
sender: {
|
||||||
allowList: ownerAllowList,
|
id: interactionCtx.user.id,
|
||||||
candidate: {
|
name: interactionCtx.user.username,
|
||||||
id: interactionCtx.user.id,
|
tag: formatDiscordUserTag(interactionCtx.user),
|
||||||
name: interactionCtx.user.username,
|
},
|
||||||
tag: formatDiscordUserTag(interactionCtx.user),
|
allowNameMatching: params.allowNameMatching,
|
||||||
},
|
});
|
||||||
allowNameMatching: params.allowNameMatching,
|
|
||||||
}).allowed
|
|
||||||
: false;
|
|
||||||
|
|
||||||
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
|
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
|
||||||
channelConfig,
|
channelConfig,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export type DiscordAllowList = {
|
|||||||
|
|
||||||
export type DiscordAllowListMatch = AllowlistMatch<"wildcard" | "id" | "name" | "tag">;
|
export type DiscordAllowListMatch = AllowlistMatch<"wildcard" | "id" | "name" | "tag">;
|
||||||
|
|
||||||
|
const DISCORD_OWNER_ALLOWLIST_PREFIXES = ["discord:", "user:", "pk:"];
|
||||||
|
|
||||||
export type DiscordGuildEntryResolved = {
|
export type DiscordGuildEntryResolved = {
|
||||||
id?: string;
|
id?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
@@ -265,6 +267,32 @@ export function resolveDiscordOwnerAllowFrom(params: {
|
|||||||
return [match.matchKey];
|
return [match.matchKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveDiscordOwnerAccess(params: {
|
||||||
|
allowFrom?: string[];
|
||||||
|
sender: { id: string; name?: string; tag?: string };
|
||||||
|
allowNameMatching?: boolean;
|
||||||
|
}): {
|
||||||
|
ownerAllowList: DiscordAllowList | null;
|
||||||
|
ownerAllowed: boolean;
|
||||||
|
} {
|
||||||
|
const ownerAllowList = normalizeDiscordAllowList(
|
||||||
|
params.allowFrom,
|
||||||
|
DISCORD_OWNER_ALLOWLIST_PREFIXES,
|
||||||
|
);
|
||||||
|
const ownerAllowed = ownerAllowList
|
||||||
|
? allowListMatches(
|
||||||
|
ownerAllowList,
|
||||||
|
{
|
||||||
|
id: params.sender.id,
|
||||||
|
name: params.sender.name,
|
||||||
|
tag: params.sender.tag,
|
||||||
|
},
|
||||||
|
{ allowNameMatching: params.allowNameMatching },
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
return { ownerAllowList, ownerAllowed };
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveDiscordCommandAuthorized(params: {
|
export function resolveDiscordCommandAuthorized(params: {
|
||||||
isDirectMessage: boolean;
|
isDirectMessage: boolean;
|
||||||
allowFrom?: string[];
|
allowFrom?: string[];
|
||||||
|
|||||||
@@ -30,13 +30,12 @@ import { DEFAULT_ACCOUNT_ID, resolveAgentIdFromSessionKey } from "../../routing/
|
|||||||
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
|
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
|
||||||
import { sendMessageDiscord } from "../send.js";
|
import { sendMessageDiscord } from "../send.js";
|
||||||
import {
|
import {
|
||||||
allowListMatches,
|
|
||||||
isDiscordGroupAllowedByPolicy,
|
isDiscordGroupAllowedByPolicy,
|
||||||
normalizeDiscordAllowList,
|
|
||||||
normalizeDiscordSlug,
|
normalizeDiscordSlug,
|
||||||
resolveDiscordChannelConfigWithFallback,
|
resolveDiscordChannelConfigWithFallback,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
resolveDiscordMemberAccessState,
|
resolveDiscordMemberAccessState,
|
||||||
|
resolveDiscordOwnerAccess,
|
||||||
resolveDiscordShouldRequireMention,
|
resolveDiscordShouldRequireMention,
|
||||||
resolveGroupDmAllow,
|
resolveGroupDmAllow,
|
||||||
} from "./allow-list.js";
|
} from "./allow-list.js";
|
||||||
@@ -549,22 +548,15 @@ export async function preflightDiscordMessage(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isDirectMessage) {
|
if (!isDirectMessage) {
|
||||||
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, [
|
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
|
||||||
"discord:",
|
allowFrom: params.allowFrom,
|
||||||
"user:",
|
sender: {
|
||||||
"pk:",
|
id: sender.id,
|
||||||
]);
|
name: sender.name,
|
||||||
const ownerOk = ownerAllowList
|
tag: sender.tag,
|
||||||
? allowListMatches(
|
},
|
||||||
ownerAllowList,
|
allowNameMatching,
|
||||||
{
|
});
|
||||||
id: sender.id,
|
|
||||||
name: sender.name,
|
|
||||||
tag: sender.tag,
|
|
||||||
},
|
|
||||||
{ allowNameMatching },
|
|
||||||
)
|
|
||||||
: false;
|
|
||||||
const commandGate = resolveControlCommandGate({
|
const commandGate = resolveControlCommandGate({
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
authorizers: [
|
authorizers: [
|
||||||
|
|||||||
@@ -54,13 +54,12 @@ import { withTimeout } from "../../utils/with-timeout.js";
|
|||||||
import { loadWebMedia } from "../../web/media.js";
|
import { loadWebMedia } from "../../web/media.js";
|
||||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||||
import {
|
import {
|
||||||
allowListMatches,
|
|
||||||
isDiscordGroupAllowedByPolicy,
|
isDiscordGroupAllowedByPolicy,
|
||||||
normalizeDiscordAllowList,
|
|
||||||
normalizeDiscordSlug,
|
normalizeDiscordSlug,
|
||||||
resolveDiscordChannelConfigWithFallback,
|
resolveDiscordChannelConfigWithFallback,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
resolveDiscordMemberAccessState,
|
resolveDiscordMemberAccessState,
|
||||||
|
resolveDiscordOwnerAccess,
|
||||||
resolveDiscordOwnerAllowFrom,
|
resolveDiscordOwnerAllowFrom,
|
||||||
} from "./allow-list.js";
|
} from "./allow-list.js";
|
||||||
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
||||||
@@ -1270,22 +1269,15 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
|
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
|
||||||
: [];
|
: [];
|
||||||
const allowNameMatching = isDangerousNameMatchingEnabled(discordConfig);
|
const allowNameMatching = isDangerousNameMatchingEnabled(discordConfig);
|
||||||
const ownerAllowList = normalizeDiscordAllowList(
|
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
|
||||||
discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? [],
|
allowFrom: discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? [],
|
||||||
["discord:", "user:", "pk:"],
|
sender: {
|
||||||
);
|
id: sender.id,
|
||||||
const ownerOk =
|
name: sender.name,
|
||||||
ownerAllowList && user
|
tag: sender.tag,
|
||||||
? allowListMatches(
|
},
|
||||||
ownerAllowList,
|
allowNameMatching,
|
||||||
{
|
});
|
||||||
id: sender.id,
|
|
||||||
name: sender.name,
|
|
||||||
tag: sender.tag,
|
|
||||||
},
|
|
||||||
{ allowNameMatching },
|
|
||||||
)
|
|
||||||
: false;
|
|
||||||
const guildInfo = resolveDiscordGuildEntry({
|
const guildInfo = resolveDiscordGuildEntry({
|
||||||
guild: interaction.guild ?? undefined,
|
guild: interaction.guild ?? undefined,
|
||||||
guildEntries: discordConfig?.guilds,
|
guildEntries: discordConfig?.guilds,
|
||||||
|
|||||||
@@ -15,10 +15,9 @@ import type { OpenClawConfig } from "../../config/config.js";
|
|||||||
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
|
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
|
||||||
import type { DiscordAccountConfig } from "../../config/types.js";
|
import type { DiscordAccountConfig } from "../../config/types.js";
|
||||||
import {
|
import {
|
||||||
allowListMatches,
|
|
||||||
isDiscordGroupAllowedByPolicy,
|
isDiscordGroupAllowedByPolicy,
|
||||||
normalizeDiscordAllowList,
|
|
||||||
normalizeDiscordSlug,
|
normalizeDiscordSlug,
|
||||||
|
resolveDiscordOwnerAccess,
|
||||||
resolveDiscordChannelConfigWithFallback,
|
resolveDiscordChannelConfigWithFallback,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
resolveDiscordMemberAccessState,
|
resolveDiscordMemberAccessState,
|
||||||
@@ -160,21 +159,15 @@ async function authorizeVoiceCommand(
|
|||||||
allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig),
|
allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ownerAllowList = normalizeDiscordAllowList(
|
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
|
||||||
params.discordConfig.allowFrom ?? params.discordConfig.dm?.allowFrom ?? [],
|
allowFrom: params.discordConfig.allowFrom ?? params.discordConfig.dm?.allowFrom ?? [],
|
||||||
["discord:", "user:", "pk:"],
|
sender: {
|
||||||
);
|
id: sender.id,
|
||||||
const ownerOk = ownerAllowList
|
name: sender.name,
|
||||||
? allowListMatches(
|
tag: sender.tag,
|
||||||
ownerAllowList,
|
},
|
||||||
{
|
allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig),
|
||||||
id: sender.id,
|
});
|
||||||
name: sender.name,
|
|
||||||
tag: sender.tag,
|
|
||||||
},
|
|
||||||
{ allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig) },
|
|
||||||
)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
const authorizers = params.useAccessGroups
|
const authorizers = params.useAccessGroups
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ const {
|
|||||||
entersStateMock,
|
entersStateMock,
|
||||||
createAudioPlayerMock,
|
createAudioPlayerMock,
|
||||||
resolveAgentRouteMock,
|
resolveAgentRouteMock,
|
||||||
|
agentCommandMock,
|
||||||
|
buildProviderRegistryMock,
|
||||||
|
createMediaAttachmentCacheMock,
|
||||||
|
normalizeMediaAttachmentsMock,
|
||||||
|
runCapabilityMock,
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
type EventHandler = (...args: unknown[]) => unknown;
|
type EventHandler = (...args: unknown[]) => unknown;
|
||||||
type MockConnection = {
|
type MockConnection = {
|
||||||
@@ -62,6 +67,15 @@ const {
|
|||||||
state: { status: "idle" },
|
state: { status: "idle" },
|
||||||
})),
|
})),
|
||||||
resolveAgentRouteMock: vi.fn(() => ({ agentId: "agent-1", sessionKey: "discord:g1:c1" })),
|
resolveAgentRouteMock: vi.fn(() => ({ agentId: "agent-1", sessionKey: "discord:g1:c1" })),
|
||||||
|
agentCommandMock: vi.fn(async (_opts?: unknown, _runtime?: unknown) => ({ payloads: [] })),
|
||||||
|
buildProviderRegistryMock: vi.fn(() => ({})),
|
||||||
|
createMediaAttachmentCacheMock: vi.fn(() => ({
|
||||||
|
cleanup: vi.fn(async () => undefined),
|
||||||
|
})),
|
||||||
|
normalizeMediaAttachmentsMock: vi.fn(() => [{ kind: "audio", path: "/tmp/test.wav" }]),
|
||||||
|
runCapabilityMock: vi.fn(async () => ({
|
||||||
|
outputs: [{ kind: "audio.transcription", text: "hello from voice" }],
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,6 +99,17 @@ vi.mock("../../routing/resolve-route.js", () => ({
|
|||||||
resolveAgentRoute: resolveAgentRouteMock,
|
resolveAgentRoute: resolveAgentRouteMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../commands/agent.js", () => ({
|
||||||
|
agentCommandFromIngress: agentCommandMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../media-understanding/runner.js", () => ({
|
||||||
|
buildProviderRegistry: buildProviderRegistryMock,
|
||||||
|
createMediaAttachmentCache: createMediaAttachmentCacheMock,
|
||||||
|
normalizeMediaAttachments: normalizeMediaAttachmentsMock,
|
||||||
|
runCapability: runCapabilityMock,
|
||||||
|
}));
|
||||||
|
|
||||||
let managerModule: typeof import("./manager.js");
|
let managerModule: typeof import("./manager.js");
|
||||||
|
|
||||||
function createClient() {
|
function createClient() {
|
||||||
@@ -122,15 +147,27 @@ describe("DiscordVoiceManager", () => {
|
|||||||
entersStateMock.mockResolvedValue(undefined);
|
entersStateMock.mockResolvedValue(undefined);
|
||||||
createAudioPlayerMock.mockClear();
|
createAudioPlayerMock.mockClear();
|
||||||
resolveAgentRouteMock.mockClear();
|
resolveAgentRouteMock.mockClear();
|
||||||
|
agentCommandMock.mockReset();
|
||||||
|
agentCommandMock.mockResolvedValue({ payloads: [] });
|
||||||
|
buildProviderRegistryMock.mockReset();
|
||||||
|
buildProviderRegistryMock.mockReturnValue({});
|
||||||
|
createMediaAttachmentCacheMock.mockClear();
|
||||||
|
normalizeMediaAttachmentsMock.mockReset();
|
||||||
|
normalizeMediaAttachmentsMock.mockReturnValue([{ kind: "audio", path: "/tmp/test.wav" }]);
|
||||||
|
runCapabilityMock.mockReset();
|
||||||
|
runCapabilityMock.mockResolvedValue({
|
||||||
|
outputs: [{ kind: "audio.transcription", text: "hello from voice" }],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const createManager = (
|
const createManager = (
|
||||||
discordConfig: ConstructorParameters<
|
discordConfig: ConstructorParameters<
|
||||||
typeof managerModule.DiscordVoiceManager
|
typeof managerModule.DiscordVoiceManager
|
||||||
>[0]["discordConfig"] = {},
|
>[0]["discordConfig"] = {},
|
||||||
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
) =>
|
) =>
|
||||||
new managerModule.DiscordVoiceManager({
|
new managerModule.DiscordVoiceManager({
|
||||||
client: createClient() as never,
|
client: (clientOverride ?? createClient()) as never,
|
||||||
cfg: {},
|
cfg: {},
|
||||||
discordConfig,
|
discordConfig,
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@@ -248,4 +285,119 @@ describe("DiscordVoiceManager", () => {
|
|||||||
|
|
||||||
expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2);
|
expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes senderIsOwner=true for allowlisted voice speakers", async () => {
|
||||||
|
const client = createClient();
|
||||||
|
client.fetchMember.mockResolvedValue({
|
||||||
|
nickname: "Owner Nick",
|
||||||
|
user: {
|
||||||
|
id: "u-owner",
|
||||||
|
username: "owner",
|
||||||
|
globalName: "Owner",
|
||||||
|
discriminator: "1234",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manager = createManager({ allowFrom: ["discord:u-owner"] }, client);
|
||||||
|
await (
|
||||||
|
manager as unknown as {
|
||||||
|
processSegment: (params: {
|
||||||
|
entry: unknown;
|
||||||
|
wavPath: string;
|
||||||
|
userId: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
).processSegment({
|
||||||
|
entry: {
|
||||||
|
guildId: "g1",
|
||||||
|
channelId: "c1",
|
||||||
|
route: { sessionKey: "discord:g1:c1", agentId: "agent-1" },
|
||||||
|
},
|
||||||
|
wavPath: "/tmp/test.wav",
|
||||||
|
userId: "u-owner",
|
||||||
|
durationSeconds: 1.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as
|
||||||
|
| { senderIsOwner?: boolean }
|
||||||
|
| undefined;
|
||||||
|
expect(commandArgs?.senderIsOwner).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes senderIsOwner=false for non-owner voice speakers", async () => {
|
||||||
|
const client = createClient();
|
||||||
|
client.fetchMember.mockResolvedValue({
|
||||||
|
nickname: "Guest Nick",
|
||||||
|
user: {
|
||||||
|
id: "u-guest",
|
||||||
|
username: "guest",
|
||||||
|
globalName: "Guest",
|
||||||
|
discriminator: "4321",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manager = createManager({ allowFrom: ["discord:u-owner"] }, client);
|
||||||
|
await (
|
||||||
|
manager as unknown as {
|
||||||
|
processSegment: (params: {
|
||||||
|
entry: unknown;
|
||||||
|
wavPath: string;
|
||||||
|
userId: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
).processSegment({
|
||||||
|
entry: {
|
||||||
|
guildId: "g1",
|
||||||
|
channelId: "c1",
|
||||||
|
route: { sessionKey: "discord:g1:c1", agentId: "agent-1" },
|
||||||
|
},
|
||||||
|
wavPath: "/tmp/test.wav",
|
||||||
|
userId: "u-guest",
|
||||||
|
durationSeconds: 1.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as
|
||||||
|
| { senderIsOwner?: boolean }
|
||||||
|
| undefined;
|
||||||
|
expect(commandArgs?.senderIsOwner).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses speaker context cache for repeated segments from the same speaker", async () => {
|
||||||
|
const client = createClient();
|
||||||
|
client.fetchMember.mockResolvedValue({
|
||||||
|
nickname: "Cached Speaker",
|
||||||
|
user: {
|
||||||
|
id: "u-cache",
|
||||||
|
username: "cache",
|
||||||
|
globalName: "Cache",
|
||||||
|
discriminator: "1111",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manager = createManager({ allowFrom: ["discord:u-cache"] }, client);
|
||||||
|
const runSegment = async () =>
|
||||||
|
await (
|
||||||
|
manager as unknown as {
|
||||||
|
processSegment: (params: {
|
||||||
|
entry: unknown;
|
||||||
|
wavPath: string;
|
||||||
|
userId: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
).processSegment({
|
||||||
|
entry: {
|
||||||
|
guildId: "g1",
|
||||||
|
channelId: "c1",
|
||||||
|
route: { sessionKey: "discord:g1:c1", agentId: "agent-1" },
|
||||||
|
},
|
||||||
|
wavPath: "/tmp/test.wav",
|
||||||
|
userId: "u-cache",
|
||||||
|
durationSeconds: 1.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runSegment();
|
||||||
|
await runSegment();
|
||||||
|
|
||||||
|
expect(client.fetchMember).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import {
|
|||||||
} from "@discordjs/voice";
|
} from "@discordjs/voice";
|
||||||
import { resolveAgentDir } from "../../agents/agent-scope.js";
|
import { resolveAgentDir } from "../../agents/agent-scope.js";
|
||||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||||
import { agentCommand } from "../../commands/agent.js";
|
import { agentCommandFromIngress } from "../../commands/agent.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
|
||||||
import type { DiscordAccountConfig, TtsConfig } from "../../config/types.js";
|
import type { DiscordAccountConfig, TtsConfig } from "../../config/types.js";
|
||||||
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||||
import { formatErrorMessage } from "../../infra/errors.js";
|
import { formatErrorMessage } from "../../infra/errors.js";
|
||||||
@@ -35,6 +36,8 @@ import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
|||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import { parseTtsDirectives } from "../../tts/tts-core.js";
|
import { parseTtsDirectives } from "../../tts/tts-core.js";
|
||||||
import { resolveTtsConfig, textToSpeech, type ResolvedTtsConfig } from "../../tts/tts.js";
|
import { resolveTtsConfig, textToSpeech, type ResolvedTtsConfig } from "../../tts/tts.js";
|
||||||
|
import { resolveDiscordOwnerAccess } from "../monitor/allow-list.js";
|
||||||
|
import { formatDiscordUserTag } from "../monitor/format.js";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ const SPEAKING_READY_TIMEOUT_MS = 60_000;
|
|||||||
const DECRYPT_FAILURE_WINDOW_MS = 30_000;
|
const DECRYPT_FAILURE_WINDOW_MS = 30_000;
|
||||||
const DECRYPT_FAILURE_RECONNECT_THRESHOLD = 3;
|
const DECRYPT_FAILURE_RECONNECT_THRESHOLD = 3;
|
||||||
const DECRYPT_FAILURE_PATTERN = /DecryptionFailed\(/;
|
const DECRYPT_FAILURE_PATTERN = /DecryptionFailed\(/;
|
||||||
|
const SPEAKER_CONTEXT_CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
const logger = createSubsystemLogger("discord/voice");
|
const logger = createSubsystemLogger("discord/voice");
|
||||||
|
|
||||||
@@ -275,6 +279,16 @@ export class DiscordVoiceManager {
|
|||||||
private botUserId?: string;
|
private botUserId?: string;
|
||||||
private readonly voiceEnabled: boolean;
|
private readonly voiceEnabled: boolean;
|
||||||
private autoJoinTask: Promise<void> | null = null;
|
private autoJoinTask: Promise<void> | null = null;
|
||||||
|
private readonly ownerAllowFrom: string[];
|
||||||
|
private readonly allowDangerousNameMatching: boolean;
|
||||||
|
private readonly speakerContextCache = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
senderIsOwner: boolean;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private params: {
|
private params: {
|
||||||
@@ -288,6 +302,9 @@ export class DiscordVoiceManager {
|
|||||||
) {
|
) {
|
||||||
this.botUserId = params.botUserId;
|
this.botUserId = params.botUserId;
|
||||||
this.voiceEnabled = params.discordConfig.voice?.enabled !== false;
|
this.voiceEnabled = params.discordConfig.voice?.enabled !== false;
|
||||||
|
this.ownerAllowFrom =
|
||||||
|
params.discordConfig.allowFrom ?? params.discordConfig.dm?.allowFrom ?? [];
|
||||||
|
this.allowDangerousNameMatching = isDangerousNameMatchingEnabled(params.discordConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
setBotUserId(id?: string) {
|
setBotUserId(id?: string) {
|
||||||
@@ -625,15 +642,16 @@ export class DiscordVoiceManager {
|
|||||||
`transcription ok (${transcript.length} chars): guild ${entry.guildId} channel ${entry.channelId}`,
|
`transcription ok (${transcript.length} chars): guild ${entry.guildId} channel ${entry.channelId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const speakerLabel = await this.resolveSpeakerLabel(entry.guildId, userId);
|
const speaker = await this.resolveSpeakerContext(entry.guildId, userId);
|
||||||
const prompt = speakerLabel ? `${speakerLabel}: ${transcript}` : transcript;
|
const prompt = speaker.label ? `${speaker.label}: ${transcript}` : transcript;
|
||||||
|
|
||||||
const result = await agentCommand(
|
const result = await agentCommandFromIngress(
|
||||||
{
|
{
|
||||||
message: prompt,
|
message: prompt,
|
||||||
sessionKey: entry.route.sessionKey,
|
sessionKey: entry.route.sessionKey,
|
||||||
agentId: entry.route.agentId,
|
agentId: entry.route.agentId,
|
||||||
messageChannel: "discord",
|
messageChannel: "discord",
|
||||||
|
senderIsOwner: speaker.senderIsOwner,
|
||||||
deliver: false,
|
deliver: false,
|
||||||
},
|
},
|
||||||
this.params.runtime,
|
this.params.runtime,
|
||||||
@@ -757,16 +775,113 @@ export class DiscordVoiceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveSpeakerLabel(guildId: string, userId: string): Promise<string | undefined> {
|
private resolveSpeakerIsOwner(params: { id: string; name?: string; tag?: string }): boolean {
|
||||||
|
return resolveDiscordOwnerAccess({
|
||||||
|
allowFrom: this.ownerAllowFrom,
|
||||||
|
sender: {
|
||||||
|
id: params.id,
|
||||||
|
name: params.name,
|
||||||
|
tag: params.tag,
|
||||||
|
},
|
||||||
|
allowNameMatching: this.allowDangerousNameMatching,
|
||||||
|
}).ownerAllowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSpeakerContextCacheKey(guildId: string, userId: string): string {
|
||||||
|
return `${guildId}:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCachedSpeakerContext(
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
):
|
||||||
|
| {
|
||||||
|
label: string;
|
||||||
|
senderIsOwner: boolean;
|
||||||
|
}
|
||||||
|
| undefined {
|
||||||
|
const key = this.resolveSpeakerContextCacheKey(guildId, userId);
|
||||||
|
const cached = this.speakerContextCache.get(key);
|
||||||
|
if (!cached) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (cached.expiresAt <= Date.now()) {
|
||||||
|
this.speakerContextCache.delete(key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: cached.label,
|
||||||
|
senderIsOwner: cached.senderIsOwner,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCachedSpeakerContext(
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
context: { label: string; senderIsOwner: boolean },
|
||||||
|
): void {
|
||||||
|
const key = this.resolveSpeakerContextCacheKey(guildId, userId);
|
||||||
|
this.speakerContextCache.set(key, {
|
||||||
|
label: context.label,
|
||||||
|
senderIsOwner: context.senderIsOwner,
|
||||||
|
expiresAt: Date.now() + SPEAKER_CONTEXT_CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveSpeakerContext(
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{
|
||||||
|
label: string;
|
||||||
|
senderIsOwner: boolean;
|
||||||
|
}> {
|
||||||
|
const cached = this.getCachedSpeakerContext(guildId, userId);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const identity = await this.resolveSpeakerIdentity(guildId, userId);
|
||||||
|
const context = {
|
||||||
|
label: identity.label,
|
||||||
|
senderIsOwner: this.resolveSpeakerIsOwner({
|
||||||
|
id: identity.id,
|
||||||
|
name: identity.name,
|
||||||
|
tag: identity.tag,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
this.setCachedSpeakerContext(guildId, userId, context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveSpeakerIdentity(
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
name?: string;
|
||||||
|
tag?: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const member = await this.params.client.fetchMember(guildId, userId);
|
const member = await this.params.client.fetchMember(guildId, userId);
|
||||||
return member.nickname ?? member.user?.globalName ?? member.user?.username ?? userId;
|
const username = member.user?.username ?? undefined;
|
||||||
|
return {
|
||||||
|
id: userId,
|
||||||
|
label: member.nickname ?? member.user?.globalName ?? username ?? userId,
|
||||||
|
name: username,
|
||||||
|
tag: member.user ? formatDiscordUserTag(member.user) : undefined,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
const user = await this.params.client.fetchUser(userId);
|
const user = await this.params.client.fetchUser(userId);
|
||||||
return user.globalName ?? user.username ?? userId;
|
const username = user.username ?? undefined;
|
||||||
|
return {
|
||||||
|
id: userId,
|
||||||
|
label: user.globalName ?? username ?? userId,
|
||||||
|
name: username,
|
||||||
|
tag: formatDiscordUserTag(user),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return userId;
|
return { id: userId, label: userId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { createDefaultDeps } from "../cli/deps.js";
|
import { createDefaultDeps } from "../cli/deps.js";
|
||||||
import { agentCommand } from "../commands/agent.js";
|
import { agentCommandFromIngress } from "../commands/agent.js";
|
||||||
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
|
||||||
import { logWarn } from "../logger.js";
|
import { logWarn } from "../logger.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@@ -55,6 +55,8 @@ function buildAgentCommandInput(params: {
|
|||||||
deliver: false as const,
|
deliver: false as const,
|
||||||
messageChannel: params.messageChannel,
|
messageChannel: params.messageChannel,
|
||||||
bestEffortDeliver: false as const,
|
bestEffortDeliver: false as const,
|
||||||
|
// HTTP API callers are authenticated operator clients for this gateway context.
|
||||||
|
senderIsOwner: true as const,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +249,7 @@ export async function handleOpenAiHttpRequest(
|
|||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
try {
|
try {
|
||||||
const result = await agentCommand(commandInput, defaultRuntime, deps);
|
const result = await agentCommandFromIngress(commandInput, defaultRuntime, deps);
|
||||||
|
|
||||||
const content = resolveAgentResponseText(result);
|
const content = resolveAgentResponseText(result);
|
||||||
|
|
||||||
@@ -327,7 +329,7 @@ export async function handleOpenAiHttpRequest(
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await agentCommand(commandInput, defaultRuntime, deps);
|
const result = await agentCommandFromIngress(commandInput, defaultRuntime, deps);
|
||||||
|
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import type { ClientToolDefinition } from "../agents/pi-embedded-runner/run/params.js";
|
import type { ClientToolDefinition } from "../agents/pi-embedded-runner/run/params.js";
|
||||||
import { createDefaultDeps } from "../cli/deps.js";
|
import { createDefaultDeps } from "../cli/deps.js";
|
||||||
import { agentCommand } from "../commands/agent.js";
|
import { agentCommandFromIngress } from "../commands/agent.js";
|
||||||
import type { ImageContent } from "../commands/agent/types.js";
|
import type { ImageContent } from "../commands/agent/types.js";
|
||||||
import type { GatewayHttpResponsesConfig } from "../config/types.gateway.js";
|
import type { GatewayHttpResponsesConfig } from "../config/types.gateway.js";
|
||||||
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
|
||||||
@@ -236,7 +236,7 @@ async function runResponsesAgentCommand(params: {
|
|||||||
messageChannel: string;
|
messageChannel: string;
|
||||||
deps: ReturnType<typeof createDefaultDeps>;
|
deps: ReturnType<typeof createDefaultDeps>;
|
||||||
}) {
|
}) {
|
||||||
return agentCommand(
|
return agentCommandFromIngress(
|
||||||
{
|
{
|
||||||
message: params.message,
|
message: params.message,
|
||||||
images: params.images.length > 0 ? params.images : undefined,
|
images: params.images.length > 0 ? params.images : undefined,
|
||||||
@@ -248,6 +248,8 @@ async function runResponsesAgentCommand(params: {
|
|||||||
deliver: false,
|
deliver: false,
|
||||||
messageChannel: params.messageChannel,
|
messageChannel: params.messageChannel,
|
||||||
bestEffortDeliver: false,
|
bestEffortDeliver: false,
|
||||||
|
// HTTP API callers are authenticated operator clients for this gateway context.
|
||||||
|
senderIsOwner: true,
|
||||||
},
|
},
|
||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
params.deps,
|
params.deps,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { listAgentIds } from "../../agents/agent-scope.js";
|
import { listAgentIds } from "../../agents/agent-scope.js";
|
||||||
import type { AgentInternalEvent } from "../../agents/internal-events.js";
|
import type { AgentInternalEvent } from "../../agents/internal-events.js";
|
||||||
import { BARE_SESSION_RESET_PROMPT } from "../../auto-reply/reply/session-reset-prompt.js";
|
import { BARE_SESSION_RESET_PROMPT } from "../../auto-reply/reply/session-reset-prompt.js";
|
||||||
import { agentCommand } from "../../commands/agent.js";
|
import { agentCommandFromIngress } from "../../commands/agent.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
mergeSessionEntry,
|
mergeSessionEntry,
|
||||||
@@ -600,7 +600,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
|
|
||||||
const resolvedThreadId = explicitThreadId ?? deliveryPlan.resolvedThreadId;
|
const resolvedThreadId = explicitThreadId ?? deliveryPlan.resolvedThreadId;
|
||||||
|
|
||||||
void agentCommand(
|
void agentCommandFromIngress(
|
||||||
{
|
{
|
||||||
message,
|
message,
|
||||||
images,
|
images,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||||
import { createOutboundSendDeps } from "../cli/outbound-send-deps.js";
|
import { createOutboundSendDeps } from "../cli/outbound-send-deps.js";
|
||||||
import { agentCommand } from "../commands/agent.js";
|
import { agentCommandFromIngress } from "../commands/agent.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { updateSessionStore } from "../config/sessions.js";
|
import { updateSessionStore } from "../config/sessions.js";
|
||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
@@ -303,7 +303,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
clientRunId: `voice-${randomUUID()}`,
|
clientRunId: `voice-${randomUUID()}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
void agentCommand(
|
void agentCommandFromIngress(
|
||||||
{
|
{
|
||||||
message: text,
|
message: text,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -434,7 +434,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void agentCommand(
|
void agentCommandFromIngress(
|
||||||
{
|
{
|
||||||
message,
|
message,
|
||||||
images,
|
images,
|
||||||
|
|||||||
Reference in New Issue
Block a user