import { Type } from "@sinclair/typebox"; import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { type MessagePollResult, type MessageSendResult, sendMessage, sendPoll, } from "../../infra/outbound/message.js"; import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js"; import { dispatchProviderMessageAction, listProviderMessageActions, supportsProviderMessageButtons, } from "../../providers/plugins/message-actions.js"; import type { ProviderMessageActionName } from "../../providers/plugins/types.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, } from "../../utils/message-provider.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringArrayParam, readStringParam, } from "./common.js"; const AllMessageActions = [ "send", "poll", "react", "reactions", "read", "edit", "delete", "pin", "unpin", "list-pins", "permissions", "thread-create", "thread-list", "thread-reply", "search", "sticker", "member-info", "role-info", "emoji-list", "emoji-upload", "sticker-upload", "role-add", "role-remove", "channel-info", "channel-list", "channel-create", "channel-edit", "channel-delete", "channel-move", "category-create", "category-edit", "category-delete", "voice-status", "event-list", "event-create", "timeout", "kick", "ban", ]; const MessageToolCommonSchema = { provider: Type.Optional(Type.String()), to: Type.Optional(Type.String()), message: Type.Optional(Type.String()), media: Type.Optional(Type.String()), buttons: Type.Optional( Type.Array( Type.Array( Type.Object({ text: Type.String(), callback_data: Type.String(), }), ), { description: "Telegram inline keyboard buttons (array of button rows)", }, ), ), messageId: Type.Optional(Type.String()), replyTo: Type.Optional(Type.String()), threadId: Type.Optional(Type.String()), accountId: Type.Optional(Type.String()), dryRun: Type.Optional(Type.Boolean()), bestEffort: Type.Optional(Type.Boolean()), gifPlayback: Type.Optional(Type.Boolean()), emoji: Type.Optional(Type.String()), remove: Type.Optional(Type.Boolean()), limit: Type.Optional(Type.Number()), before: Type.Optional(Type.String()), after: Type.Optional(Type.String()), around: Type.Optional(Type.String()), pollQuestion: Type.Optional(Type.String()), pollOption: Type.Optional(Type.Array(Type.String())), pollDurationHours: Type.Optional(Type.Number()), pollMulti: Type.Optional(Type.Boolean()), channelId: Type.Optional(Type.String()), channelIds: Type.Optional(Type.Array(Type.String())), guildId: Type.Optional(Type.String()), userId: Type.Optional(Type.String()), authorId: Type.Optional(Type.String()), authorIds: Type.Optional(Type.Array(Type.String())), roleId: Type.Optional(Type.String()), roleIds: Type.Optional(Type.Array(Type.String())), emojiName: Type.Optional(Type.String()), stickerId: Type.Optional(Type.Array(Type.String())), stickerName: Type.Optional(Type.String()), stickerDesc: Type.Optional(Type.String()), stickerTags: Type.Optional(Type.String()), threadName: Type.Optional(Type.String()), autoArchiveMin: Type.Optional(Type.Number()), query: Type.Optional(Type.String()), eventName: Type.Optional(Type.String()), eventType: Type.Optional(Type.String()), startTime: Type.Optional(Type.String()), endTime: Type.Optional(Type.String()), desc: Type.Optional(Type.String()), location: Type.Optional(Type.String()), durationMin: Type.Optional(Type.Number()), until: Type.Optional(Type.String()), reason: Type.Optional(Type.String()), deleteDays: Type.Optional(Type.Number()), includeArchived: Type.Optional(Type.Boolean()), participant: Type.Optional(Type.String()), fromMe: Type.Optional(Type.Boolean()), gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), name: Type.Optional(Type.String()), type: Type.Optional(Type.Number()), parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), topic: Type.Optional(Type.String()), position: Type.Optional(Type.Number()), nsfw: Type.Optional(Type.Boolean()), rateLimitPerUser: Type.Optional(Type.Number()), categoryId: Type.Optional(Type.String()), }; function buildMessageToolSchemaFromActions( actions: string[], options: { includeButtons: boolean }, ) { const props: Record = { ...MessageToolCommonSchema }; if (!options.includeButtons) delete props.buttons; const schemas: Array> = []; if (actions.includes("send")) { schemas.push( Type.Object({ action: Type.Literal("send"), to: Type.String(), message: Type.String(), ...props, }), ); } const nonSendActions = actions.filter((action) => action !== "send"); if (nonSendActions.length > 0) { schemas.push( Type.Object({ action: Type.Union( nonSendActions.map((action) => Type.Literal(action)), ), ...props, }), ); } return schemas.length === 1 ? schemas[0] : Type.Union(schemas); } const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { includeButtons: true, }); type MessageToolOptions = { agentAccountId?: string; config?: ClawdbotConfig; currentChannelId?: string; currentThreadTs?: string; replyToMode?: "off" | "first" | "all"; hasRepliedRef?: { value: boolean }; }; function buildMessageToolSchema(cfg: ClawdbotConfig) { const actions = listProviderMessageActions(cfg); const includeButtons = supportsProviderMessageButtons(cfg); return buildMessageToolSchemaFromActions( actions.length > 0 ? actions : ["send"], { includeButtons }, ); } function resolveAgentAccountId(value?: string): string | undefined { const trimmed = value?.trim(); if (!trimmed) return undefined; return normalizeAccountId(trimmed); } export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const agentAccountId = resolveAgentAccountId(options?.agentAccountId); const schema = options?.config ? buildMessageToolSchema(options.config) : MessageToolSchema; return { label: "Message", name: "message", description: "Send messages and provider actions (polls, reactions, pins, threads, etc.) via configured provider plugins.", parameters: schema, execute: async (_toolCallId, args) => { const params = args as Record; const cfg = options?.config ?? loadConfig(); const action = readStringParam(params, "action", { required: true, }) as ProviderMessageActionName; const providerSelection = await resolveMessageProviderSelection({ cfg, provider: readStringParam(params, "provider"), }); const provider = providerSelection.provider; const accountId = readStringParam(params, "accountId") ?? agentAccountId; const dryRun = Boolean(params.dryRun); const gateway = { url: readStringParam(params, "gatewayUrl", { trim: false }), token: readStringParam(params, "gatewayToken", { trim: false }), timeoutMs: readNumberParam(params, "timeoutMs"), clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, clientDisplayName: "agent", mode: GATEWAY_CLIENT_MODES.BACKEND, }; const toolContext = options?.currentChannelId || options?.currentThreadTs || options?.replyToMode || options?.hasRepliedRef ? { currentChannelId: options?.currentChannelId, currentThreadTs: options?.currentThreadTs, replyToMode: options?.replyToMode, hasRepliedRef: options?.hasRepliedRef, } : undefined; if (action === "send") { const to = readStringParam(params, "to", { required: true }); let message = readStringParam(params, "message", { required: true, allowEmpty: true, }); // Let send accept the same inline directives we use elsewhere. // Provider plugins consume `replyTo` / `media` / `buttons` from params. const parsed = parseReplyDirectives(message); message = parsed.text; params.message = message; if (!params.replyTo && parsed.replyToId) params.replyTo = parsed.replyToId; if (!params.media) { params.media = parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined; } const mediaUrl = readStringParam(params, "media", { trim: false }); const gifPlayback = typeof params.gifPlayback === "boolean" ? params.gifPlayback : false; const bestEffort = typeof params.bestEffort === "boolean" ? params.bestEffort : undefined; if (dryRun) { const result: MessageSendResult = await sendMessage({ to, content: message, mediaUrl: mediaUrl || undefined, provider: provider || undefined, accountId: accountId ?? undefined, gifPlayback, dryRun, bestEffort, gateway, }); return jsonResult(result); } const handled = await dispatchProviderMessageAction({ provider, action, cfg, params, accountId, gateway, toolContext, dryRun, }); if (handled) return handled; const result: MessageSendResult = await sendMessage({ to, content: message, mediaUrl: mediaUrl || undefined, provider: provider || undefined, accountId: accountId ?? undefined, gifPlayback, dryRun, bestEffort, gateway, }); return jsonResult(result); } if (action === "poll") { const to = readStringParam(params, "to", { required: true }); const question = readStringParam(params, "pollQuestion", { required: true, }); const options = readStringArrayParam(params, "pollOption", { required: true }) ?? []; const allowMultiselect = typeof params.pollMulti === "boolean" ? params.pollMulti : undefined; const durationHours = readNumberParam(params, "pollDurationHours", { integer: true, }); const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1; if (dryRun) { const result: MessagePollResult = await sendPoll({ to, question, options, maxSelections, durationHours: durationHours ?? undefined, provider, dryRun, gateway, }); return jsonResult(result); } const handled = await dispatchProviderMessageAction({ provider, action, cfg, params, accountId, gateway, toolContext, dryRun, }); if (handled) return handled; const result: MessagePollResult = await sendPoll({ to, question, options, maxSelections, durationHours: durationHours ?? undefined, provider, dryRun, gateway, }); return jsonResult(result); } const handled = await dispatchProviderMessageAction({ provider, action, cfg, params, accountId, gateway, toolContext, dryRun, }); if (handled) return handled; throw new Error( `Message action ${action} not supported for provider ${provider}.`, ); }, }; }