| import fs from "node:fs/promises"; |
| import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; |
| import { detectMime } from "../../media/mime.js"; |
| import { readSnakeCaseParamRaw } from "../../param-key.js"; |
| import type { ImageSanitizationLimits } from "../image-sanitization.js"; |
| import { sanitizeToolResultImages } from "../tool-images.js"; |
|
|
| |
| 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 readParamRaw(params: Record<string, unknown>, key: string): unknown { |
| return readSnakeCaseParamRaw(params, key); |
| } |
|
|
| 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; |
| }; |
|
|
| |
| |
| |
| |
| |
| 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 result.length ? result : undefined; |
| } |
|
|