refactor(core): dedupe shared config and runtime helpers

This commit is contained in:
Peter Steinberger
2026-02-16 14:52:03 +00:00
parent 544ffbcf7b
commit 04892ee230
68 changed files with 1966 additions and 2018 deletions

View File

@@ -8,3 +8,57 @@ export function formatAllowFromLowercase(params: {
.map((entry) => (params.stripPrefixRe ? entry.replace(params.stripPrefixRe, "") : entry))
.map((entry) => entry.toLowerCase());
}
type ParsedChatAllowTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string }
| { kind: "handle"; handle: string };
export function isAllowedParsedChatSender<TParsed extends ParsedChatAllowTarget>(params: {
allowFrom: Array<string | number>;
sender: string;
chatId?: number | null;
chatGuid?: string | null;
chatIdentifier?: string | null;
normalizeSender: (sender: string) => string;
parseAllowTarget: (entry: string) => TParsed;
}): boolean {
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
if (allowFrom.length === 0) {
return true;
}
if (allowFrom.includes("*")) {
return true;
}
const senderNormalized = params.normalizeSender(params.sender);
const chatId = params.chatId ?? undefined;
const chatGuid = params.chatGuid?.trim();
const chatIdentifier = params.chatIdentifier?.trim();
for (const entry of allowFrom) {
if (!entry) {
continue;
}
const parsed = params.parseAllowTarget(entry);
if (parsed.kind === "chat_id" && chatId !== undefined) {
if (parsed.chatId === chatId) {
return true;
}
} else if (parsed.kind === "chat_guid" && chatGuid) {
if (parsed.chatGuid === chatGuid) {
return true;
}
} else if (parsed.kind === "chat_identifier" && chatIdentifier) {
if (parsed.chatIdentifier === chatIdentifier) {
return true;
}
} else if (parsed.kind === "handle" && senderNormalized) {
if (parsed.handle === senderNormalized) {
return true;
}
}
}
return false;
}

View File

@@ -0,0 +1,50 @@
import type { OpenClawConfig } from "../config/config.js";
export type ResolveSenderCommandAuthorizationParams = {
cfg: OpenClawConfig;
rawBody: string;
isGroup: boolean;
dmPolicy: string;
configuredAllowFrom: string[];
senderId: string;
isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean;
readAllowFromStore: () => Promise<string[]>;
shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean;
resolveCommandAuthorizedFromAuthorizers: (params: {
useAccessGroups: boolean;
authorizers: Array<{ configured: boolean; allowed: boolean }>;
}) => boolean;
};
export async function resolveSenderCommandAuthorization(
params: ResolveSenderCommandAuthorizationParams,
): Promise<{
shouldComputeAuth: boolean;
effectiveAllowFrom: string[];
senderAllowedForCommands: boolean;
commandAuthorized: boolean | undefined;
}> {
const shouldComputeAuth = params.shouldComputeCommandAuthorized(params.rawBody, params.cfg);
const storeAllowFrom =
!params.isGroup && (params.dmPolicy !== "open" || shouldComputeAuth)
? await params.readAllowFromStore().catch(() => [])
: [];
const effectiveAllowFrom = [...params.configuredAllowFrom, ...storeAllowFrom];
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = params.isSenderAllowed(params.senderId, effectiveAllowFrom);
const commandAuthorized = shouldComputeAuth
? params.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
return {
shouldComputeAuth,
effectiveAllowFrom,
senderAllowedForCommands,
commandAuthorized,
};
}

View File

@@ -84,6 +84,11 @@ export type { OpenClawConfig as ClawdbotConfig } from "../config/config.js";
export type { FileLockHandle, FileLockOptions } from "./file-lock.js";
export { acquireFileLock, withFileLock } from "./file-lock.js";
export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js";
export {
registerWebhookTarget,
rejectNonPostWebhookRequest,
resolveWebhookTargets,
} from "./webhook-targets.js";
export type { AgentMediaPayload } from "./agent-media-payload.js";
export { buildAgentMediaPayload } from "./agent-media-payload.js";
export {
@@ -141,9 +146,13 @@ export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";
export type { RuntimeEnv } from "../runtime.js";
export type { WizardPrompter } from "../wizard/prompts.js";
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
export { formatAllowFromLowercase } from "./allow-from.js";
export { formatAllowFromLowercase, isAllowedParsedChatSender } from "./allow-from.js";
export { resolveSenderCommandAuthorization } from "./command-auth.js";
export { handleSlackMessageAction } from "./slack-message-actions.js";
export { extractToolSend } from "./tool-send.js";
export { resolveChannelAccountConfigBasePath } from "./config-paths.js";
export { chunkTextForOutbound } from "./text-chunking.js";
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
export type { ChatType } from "../channels/chat-type.js";
/** @deprecated Use ChatType instead */
export type { RoutePeerKind } from "../routing/resolve-route.js";
@@ -173,6 +182,7 @@ export {
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
export { SsrFBlockedError, isBlockedHostname, isPrivateIpAddress } from "../infra/net/ssrf.js";
export type { LookupFn, SsrFPolicy } from "../infra/net/ssrf.js";
export { rawDataToString } from "../infra/ws.js";
export { isWSLSync, isWSL2Sync, isWSLEnv } from "../infra/wsl.js";
export { isTruthyEnvValue } from "../infra/env.js";
export { resolveToolsBySender } from "../config/group-policy.js";

View File

@@ -0,0 +1,35 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { safeParseJson } from "../utils.js";
export async function readJsonFileWithFallback<T>(
filePath: string,
fallback: T,
): Promise<{ value: T; exists: boolean }> {
try {
const raw = await fs.promises.readFile(filePath, "utf-8");
const parsed = safeParseJson<T>(raw);
if (parsed == null) {
return { value: fallback, exists: true };
}
return { value: parsed, exists: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return { value: fallback, exists: false };
}
return { value: fallback, exists: false };
}
}
export async function writeJsonFileAtomically(filePath: string, value: unknown): Promise<void> {
const dir = path.dirname(filePath);
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
encoding: "utf-8",
});
await fs.promises.chmod(tmp, 0o600);
await fs.promises.rename(tmp, filePath);
}

View File

@@ -0,0 +1,162 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ChannelMessageActionContext } from "../channels/plugins/types.js";
import { readNumberParam, readStringParam } from "../agents/tools/common.js";
type SlackActionInvoke = (
action: Record<string, unknown>,
cfg: ChannelMessageActionContext["cfg"],
toolContext?: ChannelMessageActionContext["toolContext"],
) => Promise<AgentToolResult<unknown>>;
export async function handleSlackMessageAction(params: {
providerId: string;
ctx: ChannelMessageActionContext;
invoke: SlackActionInvoke;
normalizeChannelId?: (channelId: string) => string;
includeReadThreadId?: boolean;
}): Promise<AgentToolResult<unknown>> {
const { providerId, ctx, invoke, normalizeChannelId, includeReadThreadId = false } = params;
const { action, cfg, params: actionParams } = ctx;
const accountId = ctx.accountId ?? undefined;
const resolveChannelId = () => {
const channelId =
readStringParam(actionParams, "channelId") ??
readStringParam(actionParams, "to", { required: true });
return normalizeChannelId ? normalizeChannelId(channelId) : channelId;
};
if (action === "send") {
const to = readStringParam(actionParams, "to", { required: true });
const content = readStringParam(actionParams, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
const threadId = readStringParam(actionParams, "threadId");
const replyTo = readStringParam(actionParams, "replyTo");
return await invoke(
{
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
accountId,
threadTs: threadId ?? replyTo ?? undefined,
},
cfg,
ctx.toolContext,
);
}
if (action === "react") {
const messageId = readStringParam(actionParams, "messageId", {
required: true,
});
const emoji = readStringParam(actionParams, "emoji", { allowEmpty: true });
const remove = typeof actionParams.remove === "boolean" ? actionParams.remove : undefined;
return await invoke(
{
action: "react",
channelId: resolveChannelId(),
messageId,
emoji,
remove,
accountId,
},
cfg,
);
}
if (action === "reactions") {
const messageId = readStringParam(actionParams, "messageId", {
required: true,
});
const limit = readNumberParam(actionParams, "limit", { integer: true });
return await invoke(
{
action: "reactions",
channelId: resolveChannelId(),
messageId,
limit,
accountId,
},
cfg,
);
}
if (action === "read") {
const limit = readNumberParam(actionParams, "limit", { integer: true });
const readAction: Record<string, unknown> = {
action: "readMessages",
channelId: resolveChannelId(),
limit,
before: readStringParam(actionParams, "before"),
after: readStringParam(actionParams, "after"),
accountId,
};
if (includeReadThreadId) {
readAction.threadId = readStringParam(actionParams, "threadId");
}
return await invoke(readAction, cfg);
}
if (action === "edit") {
const messageId = readStringParam(actionParams, "messageId", {
required: true,
});
const content = readStringParam(actionParams, "message", { required: true });
return await invoke(
{
action: "editMessage",
channelId: resolveChannelId(),
messageId,
content,
accountId,
},
cfg,
);
}
if (action === "delete") {
const messageId = readStringParam(actionParams, "messageId", {
required: true,
});
return await invoke(
{
action: "deleteMessage",
channelId: resolveChannelId(),
messageId,
accountId,
},
cfg,
);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(actionParams, "messageId", { required: true });
return await invoke(
{
action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
channelId: resolveChannelId(),
messageId,
accountId,
},
cfg,
);
}
if (action === "member-info") {
const userId = readStringParam(actionParams, "userId", { required: true });
return await invoke({ action: "memberInfo", userId, accountId }, cfg);
}
if (action === "emoji-list") {
const limit = readNumberParam(actionParams, "limit", { integer: true });
return await invoke({ action: "emojiList", limit, accountId }, cfg);
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
}

View File

@@ -0,0 +1,15 @@
export function extractToolSend(
args: Record<string, unknown>,
expectedAction = "sendMessage",
): { to: string; accountId?: string } | null {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== expectedAction) {
return null;
}
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) {
return null;
}
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
}

View File

@@ -0,0 +1,49 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { normalizeWebhookPath } from "./webhook-path.js";
export type RegisteredWebhookTarget<T> = {
target: T;
unregister: () => void;
};
export function registerWebhookTarget<T extends { path: string }>(
targetsByPath: Map<string, T[]>,
target: T,
): RegisteredWebhookTarget<T> {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = targetsByPath.get(key) ?? [];
targetsByPath.set(key, [...existing, normalizedTarget]);
const unregister = () => {
const updated = (targetsByPath.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) {
targetsByPath.set(key, updated);
return;
}
targetsByPath.delete(key);
};
return { target: normalizedTarget, unregister };
}
export function resolveWebhookTargets<T>(
req: IncomingMessage,
targetsByPath: Map<string, T[]>,
): { path: string; targets: T[] } | null {
const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname);
const targets = targetsByPath.get(path);
if (!targets || targets.length === 0) {
return null;
}
return { path, targets };
}
export function rejectNonPostWebhookRequest(req: IncomingMessage, res: ServerResponse): boolean {
if (req.method === "POST") {
return false;
}
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
return true;
}