Merged via squash. Prepared head SHA: f77824419e3d166f727474a9953a063a2b4547f2 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
341 lines
9.0 KiB
TypeScript
341 lines
9.0 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
import { detectMime } from "../../media/mime.js";
|
|
import type { ImageSanitizationLimits } from "../image-sanitization.js";
|
|
import { sanitizeToolResultImages } from "../tool-images.js";
|
|
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
export type AnyAgentTool = AgentTool<any, unknown> & {
|
|
ownerOnly?: boolean;
|
|
};
|
|
|
|
export type StringParamOptions = {
|
|
required?: boolean;
|
|
trim?: boolean;
|
|
label?: string;
|
|
allowEmpty?: boolean;
|
|
};
|
|
|
|
export type ActionGate<T extends Record<string, boolean | undefined>> = (
|
|
key: keyof T,
|
|
defaultValue?: boolean,
|
|
) => boolean;
|
|
|
|
export const OWNER_ONLY_TOOL_ERROR = "Tool restricted to owner senders.";
|
|
|
|
export class ToolInputError extends Error {
|
|
readonly status: number = 400;
|
|
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "ToolInputError";
|
|
}
|
|
}
|
|
|
|
export class ToolAuthorizationError extends ToolInputError {
|
|
override readonly status = 403;
|
|
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "ToolAuthorizationError";
|
|
}
|
|
}
|
|
|
|
export function createActionGate<T extends Record<string, boolean | undefined>>(
|
|
actions: T | undefined,
|
|
): ActionGate<T> {
|
|
return (key, defaultValue = true) => {
|
|
const value = actions?.[key];
|
|
if (value === undefined) {
|
|
return defaultValue;
|
|
}
|
|
return value !== false;
|
|
};
|
|
}
|
|
|
|
function toSnakeCaseKey(key: string): string {
|
|
return key
|
|
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function readParamRaw(params: Record<string, unknown>, key: string): unknown {
|
|
if (Object.hasOwn(params, key)) {
|
|
return params[key];
|
|
}
|
|
const snakeKey = toSnakeCaseKey(key);
|
|
if (snakeKey !== key && Object.hasOwn(params, snakeKey)) {
|
|
return params[snakeKey];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function readStringParam(
|
|
params: Record<string, unknown>,
|
|
key: string,
|
|
options: StringParamOptions & { required: true },
|
|
): string;
|
|
export function readStringParam(
|
|
params: Record<string, unknown>,
|
|
key: string,
|
|
options?: StringParamOptions,
|
|
): string | undefined;
|
|
export function readStringParam(
|
|
params: Record<string, unknown>,
|
|
key: string,
|
|
options: StringParamOptions = {},
|
|
) {
|
|
const { required = false, trim = true, label = key, allowEmpty = false } = options;
|
|
const raw = readParamRaw(params, key);
|
|
if (typeof raw !== "string") {
|
|
if (required) {
|
|
throw new ToolInputError(`${label} required`);
|
|
}
|
|
return undefined;
|
|
}
|
|
const value = trim ? raw.trim() : raw;
|
|
if (!value && !allowEmpty) {
|
|
if (required) {
|
|
throw new ToolInputError(`${label} required`);
|
|
}
|
|
return undefined;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function readStringOrNumberParam(
|
|
params: Record<string, unknown>,
|
|
key: string,
|
|
options: { required?: boolean; label?: string } = {},
|
|
): string | undefined {
|
|
const { required = false, label = key } = options;
|
|
const raw = readParamRaw(params, key);
|
|
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
return String(raw);
|
|
}
|
|
if (typeof raw === "string") {
|
|
const value = raw.trim();
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
if (required) {
|
|
throw new ToolInputError(`${label} required`);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function readNumberParam(
|
|
params: Record<string, unknown>,
|
|
key: string,
|
|
options: { required?: boolean; label?: string; integer?: boolean; strict?: boolean } = {},
|
|
): number | undefined {
|
|
const { required = false, label = key, integer = false, strict = false } = options;
|
|
const raw = readParamRaw(params, key);
|
|
let value: number | undefined;
|
|
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
value = raw;
|
|
} else if (typeof raw === "string") {
|
|
const trimmed = raw.trim();
|
|
if (trimmed) {
|
|
const parsed = strict ? Number(trimmed) : Number.parseFloat(trimmed);
|
|
if (Number.isFinite(parsed)) {
|
|
value = parsed;
|
|
}
|
|
}
|
|
}
|
|
if (value === undefined) {
|
|
if (required) {
|
|
throw new ToolInputError(`${label} required`);
|
|
}
|
|
return undefined;
|
|
}
|
|
return integer ? Math.trunc(value) : value;
|
|
}
|
|
|
|
export function readStringArrayParam(
|
|
params: Record<string, unknown>,
|
|
key: string,
|
|
options: StringParamOptions & { required: true },
|
|
): string[];
|
|
export function readStringArrayParam(
|
|
params: Record<string, unknown>,
|
|
key: string,
|
|
options?: StringParamOptions,
|
|
): string[] | undefined;
|
|
export function readStringArrayParam(
|
|
params: Record<string, unknown>,
|
|
key: string,
|
|
options: StringParamOptions = {},
|
|
) {
|
|
const { required = false, label = key } = options;
|
|
const raw = readParamRaw(params, key);
|
|
if (Array.isArray(raw)) {
|
|
const values = raw
|
|
.filter((entry) => typeof entry === "string")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
if (values.length === 0) {
|
|
if (required) {
|
|
throw new ToolInputError(`${label} required`);
|
|
}
|
|
return undefined;
|
|
}
|
|
return values;
|
|
}
|
|
if (typeof raw === "string") {
|
|
const value = raw.trim();
|
|
if (!value) {
|
|
if (required) {
|
|
throw new ToolInputError(`${label} required`);
|
|
}
|
|
return undefined;
|
|
}
|
|
return [value];
|
|
}
|
|
if (required) {
|
|
throw new ToolInputError(`${label} required`);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export type ReactionParams = {
|
|
emoji: string;
|
|
remove: boolean;
|
|
isEmpty: boolean;
|
|
};
|
|
|
|
export function readReactionParams(
|
|
params: Record<string, unknown>,
|
|
options: {
|
|
emojiKey?: string;
|
|
removeKey?: string;
|
|
removeErrorMessage: string;
|
|
},
|
|
): ReactionParams {
|
|
const emojiKey = options.emojiKey ?? "emoji";
|
|
const removeKey = options.removeKey ?? "remove";
|
|
const remove = typeof params[removeKey] === "boolean" ? params[removeKey] : false;
|
|
const emoji = readStringParam(params, emojiKey, {
|
|
required: true,
|
|
allowEmpty: true,
|
|
});
|
|
if (remove && !emoji) {
|
|
throw new ToolInputError(options.removeErrorMessage);
|
|
}
|
|
return { emoji, remove, isEmpty: !emoji };
|
|
}
|
|
|
|
export function jsonResult(payload: unknown): AgentToolResult<unknown> {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(payload, null, 2),
|
|
},
|
|
],
|
|
details: payload,
|
|
};
|
|
}
|
|
|
|
export function wrapOwnerOnlyToolExecution(
|
|
tool: AnyAgentTool,
|
|
senderIsOwner: boolean,
|
|
): AnyAgentTool {
|
|
if (tool.ownerOnly !== true || senderIsOwner || !tool.execute) {
|
|
return tool;
|
|
}
|
|
return {
|
|
...tool,
|
|
execute: async () => {
|
|
throw new Error(OWNER_ONLY_TOOL_ERROR);
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function imageResult(params: {
|
|
label: string;
|
|
path: string;
|
|
base64: string;
|
|
mimeType: string;
|
|
extraText?: string;
|
|
details?: Record<string, unknown>;
|
|
imageSanitization?: ImageSanitizationLimits;
|
|
}): Promise<AgentToolResult<unknown>> {
|
|
const content: AgentToolResult<unknown>["content"] = [
|
|
{
|
|
type: "text",
|
|
text: params.extraText ?? `MEDIA:${params.path}`,
|
|
},
|
|
{
|
|
type: "image",
|
|
data: params.base64,
|
|
mimeType: params.mimeType,
|
|
},
|
|
];
|
|
const result: AgentToolResult<unknown> = {
|
|
content,
|
|
details: { path: params.path, ...params.details },
|
|
};
|
|
return await sanitizeToolResultImages(result, params.label, params.imageSanitization);
|
|
}
|
|
|
|
export async function imageResultFromFile(params: {
|
|
label: string;
|
|
path: string;
|
|
extraText?: string;
|
|
details?: Record<string, unknown>;
|
|
imageSanitization?: ImageSanitizationLimits;
|
|
}): Promise<AgentToolResult<unknown>> {
|
|
const buf = await fs.readFile(params.path);
|
|
const mimeType = (await detectMime({ buffer: buf.slice(0, 256) })) ?? "image/png";
|
|
return await imageResult({
|
|
label: params.label,
|
|
path: params.path,
|
|
base64: buf.toString("base64"),
|
|
mimeType,
|
|
extraText: params.extraText,
|
|
details: params.details,
|
|
imageSanitization: params.imageSanitization,
|
|
});
|
|
}
|
|
|
|
export type AvailableTag = {
|
|
id?: string;
|
|
name: string;
|
|
moderated?: boolean;
|
|
emoji_id?: string | null;
|
|
emoji_name?: string | null;
|
|
};
|
|
|
|
/**
|
|
* Validate and parse an `availableTags` parameter from untrusted input.
|
|
* Returns `undefined` when the value is missing or not an array.
|
|
* Entries that lack a string `name` are silently dropped.
|
|
*/
|
|
export function parseAvailableTags(raw: unknown): AvailableTag[] | undefined {
|
|
if (raw === undefined || raw === null) {
|
|
return undefined;
|
|
}
|
|
if (!Array.isArray(raw)) {
|
|
return undefined;
|
|
}
|
|
const result = raw
|
|
.filter(
|
|
(t): t is Record<string, unknown> =>
|
|
typeof t === "object" && t !== null && typeof t.name === "string",
|
|
)
|
|
.map((t) => ({
|
|
...(t.id !== undefined && typeof t.id === "string" ? { id: t.id } : {}),
|
|
name: t.name as string,
|
|
...(typeof t.moderated === "boolean" ? { moderated: t.moderated } : {}),
|
|
...(t.emoji_id === null || typeof t.emoji_id === "string" ? { emoji_id: t.emoji_id } : {}),
|
|
...(t.emoji_name === null || typeof t.emoji_name === "string"
|
|
? { emoji_name: t.emoji_name }
|
|
: {}),
|
|
}));
|
|
// Return undefined instead of empty array to avoid accidentally clearing all tags
|
|
return result.length ? result : undefined;
|
|
}
|