diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 57d6badc0..5820196aa 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -23,96 +23,170 @@ import { jsonResult, readNumberParam, readStringParam } from "./common.js"; const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES; -const MessageToolCommonSchema = { - channel: Type.Optional(Type.String()), - to: Type.Optional(channelTargetSchema()), - targets: Type.Optional(channelTargetsSchema()), - message: Type.Optional(Type.String()), - media: Type.Optional(Type.String()), - buttons: Type.Optional( - Type.Array( +function buildRoutingSchema() { + return { + channel: Type.Optional(Type.String()), + to: Type.Optional(channelTargetSchema()), + targets: Type.Optional(channelTargetsSchema()), + accountId: Type.Optional(Type.String()), + dryRun: Type.Optional(Type.Boolean()), + }; +} + +function buildSendSchema(options: { includeButtons: boolean }) { + const props: Record = { + message: Type.Optional(Type.String()), + media: Type.Optional(Type.String()), + replyTo: Type.Optional(Type.String()), + threadId: Type.Optional(Type.String()), + bestEffort: Type.Optional(Type.Boolean()), + gifPlayback: Type.Optional(Type.Boolean()), + buttons: Type.Optional( Type.Array( - Type.Object({ - text: Type.String(), - callback_data: Type.String(), - }), + Type.Array( + Type.Object({ + text: Type.String(), + callback_data: Type.String(), + }), + ), + { + description: "Telegram inline keyboard buttons (array of button rows)", + }, ), - { - 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(channelTargetSchema()), - channelIds: Type.Optional(channelTargetsSchema()), - 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.String()), - 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()), - clearParent: Type.Optional( - Type.Boolean({ - description: "Clear the parent/category when supported by the provider.", - }), - ), -}; + }; + if (!options.includeButtons) delete props.buttons; + return props; +} + +function buildReactionSchema() { + return { + messageId: Type.Optional(Type.String()), + emoji: Type.Optional(Type.String()), + remove: Type.Optional(Type.Boolean()), + }; +} + +function buildFetchSchema() { + return { + limit: Type.Optional(Type.Number()), + before: Type.Optional(Type.String()), + after: Type.Optional(Type.String()), + around: Type.Optional(Type.String()), + fromMe: Type.Optional(Type.Boolean()), + includeArchived: Type.Optional(Type.Boolean()), + }; +} + +function buildPollSchema() { + return { + pollQuestion: Type.Optional(Type.String()), + pollOption: Type.Optional(Type.Array(Type.String())), + pollDurationHours: Type.Optional(Type.Number()), + pollMulti: Type.Optional(Type.Boolean()), + }; +} + +function buildChannelTargetSchema() { + return { + channelId: Type.Optional(channelTargetSchema()), + channelIds: Type.Optional(channelTargetsSchema()), + 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())), + participant: Type.Optional(Type.String()), + }; +} + +function buildStickerSchema() { + return { + 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()), + }; +} + +function buildThreadSchema() { + return { + threadName: Type.Optional(Type.String()), + autoArchiveMin: Type.Optional(Type.Number()), + }; +} + +function buildEventSchema() { + return { + 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()), + }; +} + +function buildModerationSchema() { + return { + reason: Type.Optional(Type.String()), + deleteDays: Type.Optional(Type.Number()), + }; +} + +function buildGatewaySchema() { + return { + gatewayUrl: Type.Optional(Type.String()), + gatewayToken: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Number()), + }; +} + +function buildChannelManagementSchema() { + return { + name: Type.Optional(Type.String()), + type: Type.Optional(Type.Number()), + parentId: Type.Optional(Type.String()), + 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()), + clearParent: Type.Optional( + Type.Boolean({ + description: "Clear the parent/category when supported by the provider.", + }), + ), + }; +} + +function buildMessageToolSchemaProps(options: { includeButtons: boolean }) { + return { + ...buildRoutingSchema(), + ...buildSendSchema(options), + ...buildReactionSchema(), + ...buildFetchSchema(), + ...buildPollSchema(), + ...buildChannelTargetSchema(), + ...buildStickerSchema(), + ...buildThreadSchema(), + ...buildEventSchema(), + ...buildModerationSchema(), + ...buildGatewaySchema(), + ...buildChannelManagementSchema(), + }; +} function buildMessageToolSchemaFromActions( actions: readonly string[], options: { includeButtons: boolean }, ) { - const props: Record = { ...MessageToolCommonSchema }; - if (!options.includeButtons) delete props.buttons; - + const props = buildMessageToolSchemaProps(options); return Type.Object({ action: stringEnum(actions), ...props, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index c6aef33d7..e0580cd1a 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -150,6 +150,7 @@ function applyCrossContextMessageDecoration({ } return applied.message; } + function readBooleanParam(params: Record, key: string): boolean | undefined { const raw = params[key]; if (typeof raw === "boolean") return raw; @@ -227,295 +228,291 @@ async function resolveActionTarget(params: { } } -export async function runMessageAction( - input: RunMessageActionParams, -): Promise { - const cfg = input.cfg; - const params = { ...input.params }; - parseButtonsParam(params); +type ResolvedActionContext = { + cfg: ClawdbotConfig; + params: Record; + channel: ChannelId; + accountId?: string | null; + dryRun: boolean; + gateway?: MessageActionRunnerGateway; + input: RunMessageActionParams; +}; - const action = input.action; - if (action === "broadcast") { - const broadcastEnabled = cfg.tools?.message?.broadcast?.enabled !== false; - if (!broadcastEnabled) { - throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true."); - } - const rawTargets = readStringArrayParam(params, "targets", { required: true }) ?? []; - if (rawTargets.length === 0) { - throw new Error("Broadcast requires at least one target in --targets."); - } - const channelHint = readStringParam(params, "channel"); - const configured = await listConfiguredMessageChannels(cfg); - if (configured.length === 0) { - throw new Error("Broadcast requires at least one configured channel."); - } - const targetChannels = - channelHint && channelHint.trim().toLowerCase() !== "all" - ? [await resolveChannel(cfg, { channel: channelHint })] - : configured; - const results: Array<{ - channel: ChannelId; - to: string; - ok: boolean; - error?: string; - result?: MessageSendResult; - }> = []; - for (const targetChannel of targetChannels) { - for (const target of rawTargets) { - try { - const resolved = await resolveMessagingTarget({ - cfg, - channel: targetChannel, - input: target, - }); - if (!resolved.ok) throw resolved.error; - const sendResult = await runMessageAction({ - ...input, - action: "send", - params: { - ...params, - channel: targetChannel, - to: resolved.target.to, - }, - }); - results.push({ +function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined { + if (!input.gateway) return undefined; + return { + url: input.gateway.url, + token: input.gateway.token, + timeoutMs: input.gateway.timeoutMs, + clientName: input.gateway.clientName, + clientDisplayName: input.gateway.clientDisplayName, + mode: input.gateway.mode, + }; +} + +async function handleBroadcastAction( + input: RunMessageActionParams, + params: Record, +): Promise { + const broadcastEnabled = input.cfg.tools?.message?.broadcast?.enabled !== false; + if (!broadcastEnabled) { + throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true."); + } + const rawTargets = readStringArrayParam(params, "targets", { required: true }) ?? []; + if (rawTargets.length === 0) { + throw new Error("Broadcast requires at least one target in --targets."); + } + const channelHint = readStringParam(params, "channel"); + const configured = await listConfiguredMessageChannels(input.cfg); + if (configured.length === 0) { + throw new Error("Broadcast requires at least one configured channel."); + } + const targetChannels = + channelHint && channelHint.trim().toLowerCase() !== "all" + ? [await resolveChannel(input.cfg, { channel: channelHint })] + : configured; + const results: Array<{ + channel: ChannelId; + to: string; + ok: boolean; + error?: string; + result?: MessageSendResult; + }> = []; + for (const targetChannel of targetChannels) { + for (const target of rawTargets) { + try { + const resolved = await resolveMessagingTarget({ + cfg: input.cfg, + channel: targetChannel, + input: target, + }); + if (!resolved.ok) throw resolved.error; + const sendResult = await runMessageAction({ + ...input, + action: "send", + params: { + ...params, channel: targetChannel, to: resolved.target.to, - ok: true, - result: sendResult.kind === "send" ? sendResult.sendResult : undefined, - }); - } catch (err) { - results.push({ - channel: targetChannel, - to: target, - ok: false, - error: err instanceof Error ? err.message : String(err), - }); - } + }, + }); + results.push({ + channel: targetChannel, + to: resolved.target.to, + ok: true, + result: sendResult.kind === "send" ? sendResult.sendResult : undefined, + }); + } catch (err) { + results.push({ + channel: targetChannel, + to: target, + ok: false, + error: err instanceof Error ? err.message : String(err), + }); } } - return { - kind: "broadcast", - channel: (targetChannels[0] ?? "discord") as ChannelId, - action: "broadcast", - handledBy: input.dryRun ? "dry-run" : "core", - payload: { results }, - dryRun: Boolean(input.dryRun), - }; + } + return { + kind: "broadcast", + channel: (targetChannels[0] ?? "discord") as ChannelId, + action: "broadcast", + handledBy: input.dryRun ? "dry-run" : "core", + payload: { results }, + dryRun: Boolean(input.dryRun), + }; +} + +async function handleSendAction(ctx: ResolvedActionContext): Promise { + const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx; + const action: ChannelMessageActionName = "send"; + const to = readStringParam(params, "to", { required: true }); + // Allow message to be omitted when sending media-only (e.g., voice notes) + const mediaHint = readStringParam(params, "media", { trim: false }); + let message = + readStringParam(params, "message", { + required: !mediaHint, // Only require message if no media hint + allowEmpty: true, + }) ?? ""; + + 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 channel = await resolveChannel(cfg, params); - const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId; - const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); - - await resolveActionTarget({ - cfg, - channel, - action, - args: params, - accountId, - }); - - enforceCrossContextPolicy({ - channel, - action, - args: params, - toolContext: input.toolContext, - cfg, - }); - - const gateway = input.gateway - ? { - url: input.gateway.url, - token: input.gateway.token, - timeoutMs: input.gateway.timeoutMs, - clientName: input.gateway.clientName, - clientDisplayName: input.gateway.clientDisplayName, - mode: input.gateway.mode, - } - : undefined; - - if (action === "send") { - const to = readStringParam(params, "to", { required: true }); - // Allow message to be omitted when sending media-only (e.g., voice notes) - const mediaHint = readStringParam(params, "media", { trim: false }); - let message = - readStringParam(params, "message", { - required: !mediaHint, // Only require message if no media hint - allowEmpty: true, - }) ?? ""; - - 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 decoration = - shouldApplyCrossContextMarker(action) && input.toolContext - ? await buildCrossContextDecoration({ - cfg, - channel, - target: to, - toolContext: input.toolContext, - accountId: accountId ?? undefined, - }) - : null; - if (decoration) { - message = applyCrossContextMessageDecoration({ - params, - message, - decoration, - preferEmbeds: true, - }); - } - - const mediaUrl = readStringParam(params, "media", { trim: false }); - const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false; - const bestEffort = readBooleanParam(params, "bestEffort"); - if (!dryRun) { - const handled = await dispatchChannelMessageAction({ - channel, - action, - cfg, - params, - accountId: accountId ?? undefined, - gateway, - toolContext: input.toolContext, - dryRun, - }); - if (handled) { - return { - kind: "send", + const decoration = + shouldApplyCrossContextMarker(action) && input.toolContext + ? await buildCrossContextDecoration({ + cfg, channel, - action, - to, - handledBy: "plugin", - payload: extractToolPayload(handled), - toolResult: handled, - dryRun, - }; - } - } + target: to, + toolContext: input.toolContext, + accountId: accountId ?? undefined, + }) + : null; + if (decoration) { + message = applyCrossContextMessageDecoration({ + params, + message, + decoration, + preferEmbeds: true, + }); + } - const result: MessageSendResult = await sendMessage({ + const mediaUrl = readStringParam(params, "media", { trim: false }); + const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false; + const bestEffort = readBooleanParam(params, "bestEffort"); + if (!dryRun) { + const handled = await dispatchChannelMessageAction({ + channel, + action, cfg, - to, - content: message, - mediaUrl: mediaUrl || undefined, - channel: channel || undefined, + params, accountId: accountId ?? undefined, - gifPlayback, - dryRun, - bestEffort: bestEffort ?? undefined, - deps: input.deps, gateway, - mirror: - input.sessionKey && !dryRun - ? { - sessionKey: input.sessionKey, - agentId: input.agentId, - } - : undefined, - }); - - return { - kind: "send", - channel, - action, - to, - handledBy: "core", - payload: result, - sendResult: result, + toolContext: input.toolContext, dryRun, - }; - } - - if (action === "poll") { - const to = readStringParam(params, "to", { required: true }); - const question = readStringParam(params, "pollQuestion", { - required: true, }); - const options = readStringArrayParam(params, "pollOption", { required: true }) ?? []; - if (options.length < 2) { - throw new Error("pollOption requires at least two values"); - } - const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false; - const durationHours = readNumberParam(params, "pollDurationHours", { - integer: true, - }); - const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1; - const decoration = - shouldApplyCrossContextMarker(action) && input.toolContext - ? await buildCrossContextDecoration({ - cfg, - channel, - target: to, - toolContext: input.toolContext, - accountId: accountId ?? undefined, - }) - : null; - if (decoration) { - const base = typeof params.message === "string" ? params.message : ""; - applyCrossContextMessageDecoration({ - params, - message: base, - decoration, - preferEmbeds: true, - }); - } - - if (!dryRun) { - const handled = await dispatchChannelMessageAction({ + if (handled) { + return { + kind: "send", channel, action, - cfg, - params, - accountId: accountId ?? undefined, - gateway, - toolContext: input.toolContext, + to, + handledBy: "plugin", + payload: extractToolPayload(handled), + toolResult: handled, dryRun, - }); - if (handled) { - return { - kind: "poll", - channel, - action, - to, - handledBy: "plugin", - payload: extractToolPayload(handled), - toolResult: handled, - dryRun, - }; - } + }; } - - const result: MessagePollResult = await sendPoll({ - cfg, - to, - question, - options, - maxSelections, - durationHours: durationHours ?? undefined, - channel, - dryRun, - gateway, - }); - - return { - kind: "poll", - channel, - action, - to, - handledBy: "core", - payload: result, - pollResult: result, - dryRun, - }; } + const result: MessageSendResult = await sendMessage({ + cfg, + to, + content: message, + mediaUrl: mediaUrl || undefined, + channel: channel || undefined, + accountId: accountId ?? undefined, + gifPlayback, + dryRun, + bestEffort: bestEffort ?? undefined, + deps: input.deps, + gateway, + mirror: + input.sessionKey && !dryRun + ? { + sessionKey: input.sessionKey, + agentId: input.agentId, + } + : undefined, + }); + + return { + kind: "send", + channel, + action, + to, + handledBy: "core", + payload: result, + sendResult: result, + dryRun, + }; +} + +async function handlePollAction(ctx: ResolvedActionContext): Promise { + const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx; + const action: ChannelMessageActionName = "poll"; + const to = readStringParam(params, "to", { required: true }); + const question = readStringParam(params, "pollQuestion", { + required: true, + }); + const options = readStringArrayParam(params, "pollOption", { required: true }) ?? []; + if (options.length < 2) { + throw new Error("pollOption requires at least two values"); + } + const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false; + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + }); + const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1; + const decoration = + shouldApplyCrossContextMarker(action) && input.toolContext + ? await buildCrossContextDecoration({ + cfg, + channel, + target: to, + toolContext: input.toolContext, + accountId: accountId ?? undefined, + }) + : null; + if (decoration) { + const base = typeof params.message === "string" ? params.message : ""; + applyCrossContextMessageDecoration({ + params, + message: base, + decoration, + preferEmbeds: true, + }); + } + + if (!dryRun) { + const handled = await dispatchChannelMessageAction({ + channel, + action, + cfg, + params, + accountId: accountId ?? undefined, + gateway, + toolContext: input.toolContext, + dryRun, + }); + if (handled) { + return { + kind: "poll", + channel, + action, + to, + handledBy: "plugin", + payload: extractToolPayload(handled), + toolResult: handled, + dryRun, + }; + } + } + + const result: MessagePollResult = await sendPoll({ + cfg, + to, + question, + options, + maxSelections, + durationHours: durationHours ?? undefined, + channel, + dryRun, + gateway, + }); + + return { + kind: "poll", + channel, + action, + to, + handledBy: "core", + payload: result, + pollResult: result, + dryRun, + }; +} + +async function handlePluginAction(ctx: ResolvedActionContext): Promise { + const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx; + const action = input.action as Exclude< + ChannelMessageActionName, + "send" | "poll" | "broadcast" + >; if (dryRun) { return { kind: "action", @@ -550,3 +547,72 @@ export async function runMessageAction( dryRun, }; } + +export async function runMessageAction( + input: RunMessageActionParams, +): Promise { + const cfg = input.cfg; + const params = { ...input.params }; + parseButtonsParam(params); + + const action = input.action; + if (action === "broadcast") { + return handleBroadcastAction(input, params); + } + + const channel = await resolveChannel(cfg, params); + const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId; + const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); + + await resolveActionTarget({ + cfg, + channel, + action, + args: params, + accountId, + }); + + enforceCrossContextPolicy({ + channel, + action, + args: params, + toolContext: input.toolContext, + cfg, + }); + + const gateway = resolveGateway(input); + + if (action === "send") { + return handleSendAction({ + cfg, + params, + channel, + accountId, + dryRun, + gateway, + input, + }); + } + + if (action === "poll") { + return handlePollAction({ + cfg, + params, + channel, + accountId, + dryRun, + gateway, + input, + }); + } + + return handlePluginAction({ + cfg, + params, + channel, + accountId, + dryRun, + gateway, + input, + }); +}