2214 feat: support native WhatsApp location messages
Add location parameters to the message tool and wire them through WhatsApp web outbound delivery to send native location pins.
This commit is contained in:
parent
c8063bdcd8
commit
876641e11b
@ -1,55 +1,73 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import { BLUEBUBBLES_GROUP_ACTIONS } from '../../channels/plugins/bluebubbles-actions.js';
|
||||||
import {
|
import {
|
||||||
listChannelMessageActions,
|
listChannelMessageActions,
|
||||||
supportsChannelMessageButtons,
|
supportsChannelMessageButtons,
|
||||||
supportsChannelMessageCards,
|
supportsChannelMessageCards,
|
||||||
} from "../../channels/plugins/message-actions.js";
|
} from '../../channels/plugins/message-actions.js';
|
||||||
import {
|
import {
|
||||||
CHANNEL_MESSAGE_ACTION_NAMES,
|
CHANNEL_MESSAGE_ACTION_NAMES,
|
||||||
type ChannelMessageActionName,
|
type ChannelMessageActionName,
|
||||||
} from "../../channels/plugins/types.js";
|
} from '../../channels/plugins/types.js';
|
||||||
import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js";
|
import type { ClawdbotConfig } from '../../config/config.js';
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import { loadConfig } from '../../config/config.js';
|
||||||
import { loadConfig } from "../../config/config.js";
|
import {
|
||||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
|
GATEWAY_CLIENT_IDS,
|
||||||
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
|
GATEWAY_CLIENT_MODES,
|
||||||
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
|
} from '../../gateway/protocol/client-info.js';
|
||||||
import { resolveSessionAgentId } from "../agent-scope.js";
|
import {
|
||||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
getToolResult,
|
||||||
import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js";
|
runMessageAction,
|
||||||
import { listChannelSupportedActions } from "../channel-tools.js";
|
} from '../../infra/outbound/message-action-runner.js';
|
||||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
import { normalizeTargetForProvider } from '../../infra/outbound/target-normalization.js';
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import { normalizeAccountId } from '../../routing/session-key.js';
|
||||||
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
import { normalizeMessageChannel } from '../../utils/message-channel.js';
|
||||||
|
import { resolveSessionAgentId } from '../agent-scope.js';
|
||||||
|
import { listChannelSupportedActions } from '../channel-tools.js';
|
||||||
|
import {
|
||||||
|
channelTargetSchema,
|
||||||
|
channelTargetsSchema,
|
||||||
|
stringEnum,
|
||||||
|
} from '../schema/typebox.js';
|
||||||
|
import type { AnyAgentTool } from './common.js';
|
||||||
|
import { jsonResult, readNumberParam, readStringParam } from './common.js';
|
||||||
|
|
||||||
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
||||||
function buildRoutingSchema() {
|
function buildRoutingSchema() {
|
||||||
return {
|
return {
|
||||||
channel: Type.Optional(Type.String()),
|
channel: Type.Optional(Type.String()),
|
||||||
target: Type.Optional(channelTargetSchema({ description: "Target channel/user id or name." })),
|
target: Type.Optional(
|
||||||
|
channelTargetSchema({ description: 'Target channel/user id or name.' })
|
||||||
|
),
|
||||||
targets: Type.Optional(channelTargetsSchema()),
|
targets: Type.Optional(channelTargetsSchema()),
|
||||||
accountId: Type.Optional(Type.String()),
|
accountId: Type.Optional(Type.String()),
|
||||||
dryRun: Type.Optional(Type.Boolean()),
|
dryRun: Type.Optional(Type.Boolean()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) {
|
function buildSendSchema(options: {
|
||||||
|
includeButtons: boolean;
|
||||||
|
includeCards: boolean;
|
||||||
|
}) {
|
||||||
const props: Record<string, unknown> = {
|
const props: Record<string, unknown> = {
|
||||||
message: Type.Optional(Type.String()),
|
message: Type.Optional(Type.String()),
|
||||||
effectId: Type.Optional(
|
effectId: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
description: "Message effect name/id for sendWithEffect (e.g., invisible ink).",
|
description:
|
||||||
}),
|
'Message effect name/id for sendWithEffect (e.g., invisible ink).',
|
||||||
|
})
|
||||||
),
|
),
|
||||||
effect: Type.Optional(
|
effect: Type.Optional(
|
||||||
Type.String({ description: "Alias for effectId (e.g., invisible-ink, balloons)." }),
|
Type.String({
|
||||||
|
description: 'Alias for effectId (e.g., invisible-ink, balloons).',
|
||||||
|
})
|
||||||
),
|
),
|
||||||
media: Type.Optional(Type.String()),
|
media: Type.Optional(Type.String()),
|
||||||
filename: Type.Optional(Type.String()),
|
filename: Type.Optional(Type.String()),
|
||||||
buffer: Type.Optional(
|
buffer: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
description: "Base64 payload for attachments (optionally a data: URL).",
|
description: 'Base64 payload for attachments (optionally a data: URL).',
|
||||||
}),
|
})
|
||||||
),
|
),
|
||||||
contentType: Type.Optional(Type.String()),
|
contentType: Type.Optional(Type.String()),
|
||||||
mimeType: Type.Optional(Type.String()),
|
mimeType: Type.Optional(Type.String()),
|
||||||
@ -61,27 +79,57 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
|
|||||||
asVoice: Type.Optional(Type.Boolean()),
|
asVoice: Type.Optional(Type.Boolean()),
|
||||||
bestEffort: Type.Optional(Type.Boolean()),
|
bestEffort: Type.Optional(Type.Boolean()),
|
||||||
gifPlayback: Type.Optional(Type.Boolean()),
|
gifPlayback: Type.Optional(Type.Boolean()),
|
||||||
|
latitude: Type.Optional(
|
||||||
|
Type.Number({
|
||||||
|
description:
|
||||||
|
'Latitude for location message (required with longitude for native WhatsApp location pin).',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
longitude: Type.Optional(
|
||||||
|
Type.Number({
|
||||||
|
description:
|
||||||
|
'Longitude for location message (required with latitude for native WhatsApp location pin).',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
locationName: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description:
|
||||||
|
"Optional name for location message (e.g., 'Home', 'Office').",
|
||||||
|
})
|
||||||
|
),
|
||||||
|
locationAddress: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description: 'Optional address text for location message.',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
locationAccuracy: Type.Optional(
|
||||||
|
Type.Number({
|
||||||
|
description: 'Optional accuracy in meters for location message.',
|
||||||
|
})
|
||||||
|
),
|
||||||
buttons: Type.Optional(
|
buttons: Type.Optional(
|
||||||
Type.Array(
|
Type.Array(
|
||||||
Type.Array(
|
Type.Array(
|
||||||
Type.Object({
|
Type.Object({
|
||||||
text: Type.String(),
|
text: Type.String(),
|
||||||
callback_data: Type.String(),
|
callback_data: Type.String(),
|
||||||
}),
|
})
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
description: "Telegram inline keyboard buttons (array of button rows)",
|
description:
|
||||||
},
|
'Telegram inline keyboard buttons (array of button rows)',
|
||||||
),
|
}
|
||||||
|
)
|
||||||
),
|
),
|
||||||
card: Type.Optional(
|
card: Type.Optional(
|
||||||
Type.Object(
|
Type.Object(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
additionalProperties: true,
|
additionalProperties: true,
|
||||||
description: "Adaptive Card JSON object (when supported by the channel)",
|
description:
|
||||||
},
|
'Adaptive Card JSON object (when supported by the channel)',
|
||||||
),
|
}
|
||||||
|
)
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
if (!options.includeButtons) delete props.buttons;
|
if (!options.includeButtons) delete props.buttons;
|
||||||
@ -123,10 +171,14 @@ function buildPollSchema() {
|
|||||||
function buildChannelTargetSchema() {
|
function buildChannelTargetSchema() {
|
||||||
return {
|
return {
|
||||||
channelId: Type.Optional(
|
channelId: Type.Optional(
|
||||||
Type.String({ description: "Channel id filter (search/thread list/event create)." }),
|
Type.String({
|
||||||
|
description: 'Channel id filter (search/thread list/event create).',
|
||||||
|
})
|
||||||
),
|
),
|
||||||
channelIds: Type.Optional(
|
channelIds: Type.Optional(
|
||||||
Type.Array(Type.String({ description: "Channel id filter (repeatable)." })),
|
Type.Array(
|
||||||
|
Type.String({ description: 'Channel id filter (repeatable).' })
|
||||||
|
)
|
||||||
),
|
),
|
||||||
guildId: Type.Optional(Type.String()),
|
guildId: Type.Optional(Type.String()),
|
||||||
userId: Type.Optional(Type.String()),
|
userId: Type.Optional(Type.String()),
|
||||||
@ -196,13 +248,17 @@ function buildChannelManagementSchema() {
|
|||||||
categoryId: Type.Optional(Type.String()),
|
categoryId: Type.Optional(Type.String()),
|
||||||
clearParent: Type.Optional(
|
clearParent: Type.Optional(
|
||||||
Type.Boolean({
|
Type.Boolean({
|
||||||
description: "Clear the parent/category when supported by the provider.",
|
description:
|
||||||
}),
|
'Clear the parent/category when supported by the provider.',
|
||||||
|
})
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessageToolSchemaProps(options: { includeButtons: boolean; includeCards: boolean }) {
|
function buildMessageToolSchemaProps(options: {
|
||||||
|
includeButtons: boolean;
|
||||||
|
includeCards: boolean;
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
...buildRoutingSchema(),
|
...buildRoutingSchema(),
|
||||||
...buildSendSchema(options),
|
...buildSendSchema(options),
|
||||||
@ -221,7 +277,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean; include
|
|||||||
|
|
||||||
function buildMessageToolSchemaFromActions(
|
function buildMessageToolSchemaFromActions(
|
||||||
actions: readonly string[],
|
actions: readonly string[],
|
||||||
options: { includeButtons: boolean; includeCards: boolean },
|
options: { includeButtons: boolean; includeCards: boolean }
|
||||||
) {
|
) {
|
||||||
const props = buildMessageToolSchemaProps(options);
|
const props = buildMessageToolSchemaProps(options);
|
||||||
return Type.Object({
|
return Type.Object({
|
||||||
@ -242,7 +298,7 @@ type MessageToolOptions = {
|
|||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
currentChannelProvider?: string;
|
currentChannelProvider?: string;
|
||||||
currentThreadTs?: string;
|
currentThreadTs?: string;
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: 'off' | 'first' | 'all';
|
||||||
hasRepliedRef?: { value: boolean };
|
hasRepliedRef?: { value: boolean };
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -250,10 +306,13 @@ function buildMessageToolSchema(cfg: ClawdbotConfig) {
|
|||||||
const actions = listChannelMessageActions(cfg);
|
const actions = listChannelMessageActions(cfg);
|
||||||
const includeButtons = supportsChannelMessageButtons(cfg);
|
const includeButtons = supportsChannelMessageButtons(cfg);
|
||||||
const includeCards = supportsChannelMessageCards(cfg);
|
const includeCards = supportsChannelMessageCards(cfg);
|
||||||
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
|
return buildMessageToolSchemaFromActions(
|
||||||
|
actions.length > 0 ? actions : ['send'],
|
||||||
|
{
|
||||||
includeButtons,
|
includeButtons,
|
||||||
includeCards,
|
includeCards,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAgentAccountId(value?: string): string | undefined {
|
function resolveAgentAccountId(value?: string): string | undefined {
|
||||||
@ -268,19 +327,21 @@ function filterActionsForContext(params: {
|
|||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
}): ChannelMessageActionName[] {
|
}): ChannelMessageActionName[] {
|
||||||
const channel = normalizeMessageChannel(params.channel);
|
const channel = normalizeMessageChannel(params.channel);
|
||||||
if (!channel || channel !== "bluebubbles") return params.actions;
|
if (!channel || channel !== 'bluebubbles') return params.actions;
|
||||||
const currentChannelId = params.currentChannelId?.trim();
|
const currentChannelId = params.currentChannelId?.trim();
|
||||||
if (!currentChannelId) return params.actions;
|
if (!currentChannelId) return params.actions;
|
||||||
const normalizedTarget =
|
const normalizedTarget =
|
||||||
normalizeTargetForProvider(channel, currentChannelId) ?? currentChannelId;
|
normalizeTargetForProvider(channel, currentChannelId) ?? currentChannelId;
|
||||||
const lowered = normalizedTarget.trim().toLowerCase();
|
const lowered = normalizedTarget.trim().toLowerCase();
|
||||||
const isGroupTarget =
|
const isGroupTarget =
|
||||||
lowered.startsWith("chat_guid:") ||
|
lowered.startsWith('chat_guid:') ||
|
||||||
lowered.startsWith("chat_id:") ||
|
lowered.startsWith('chat_id:') ||
|
||||||
lowered.startsWith("chat_identifier:") ||
|
lowered.startsWith('chat_identifier:') ||
|
||||||
lowered.startsWith("group:");
|
lowered.startsWith('group:');
|
||||||
if (isGroupTarget) return params.actions;
|
if (isGroupTarget) return params.actions;
|
||||||
return params.actions.filter((action) => !BLUEBUBBLES_GROUP_ACTIONS.has(action));
|
return params.actions.filter(
|
||||||
|
(action) => !BLUEBUBBLES_GROUP_ACTIONS.has(action)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessageToolDescription(options?: {
|
function buildMessageToolDescription(options?: {
|
||||||
@ -288,7 +349,8 @@ function buildMessageToolDescription(options?: {
|
|||||||
currentChannel?: string;
|
currentChannel?: string;
|
||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const baseDescription = "Send, delete, and manage messages via channel plugins.";
|
const baseDescription =
|
||||||
|
'Send, delete, and manage messages via channel plugins.';
|
||||||
|
|
||||||
// If we have a current channel, show only its supported actions
|
// If we have a current channel, show only its supported actions
|
||||||
if (options?.currentChannel) {
|
if (options?.currentChannel) {
|
||||||
@ -302,8 +364,8 @@ function buildMessageToolDescription(options?: {
|
|||||||
});
|
});
|
||||||
if (channelActions.length > 0) {
|
if (channelActions.length > 0) {
|
||||||
// Always include "send" as a base action
|
// Always include "send" as a base action
|
||||||
const allActions = new Set(["send", ...channelActions]);
|
const allActions = new Set(['send', ...channelActions]);
|
||||||
const actionList = Array.from(allActions).sort().join(", ");
|
const actionList = Array.from(allActions).sort().join(', ');
|
||||||
return `${baseDescription} Current channel (${options.currentChannel}) supports: ${actionList}.`;
|
return `${baseDescription} Current channel (${options.currentChannel}) supports: ${actionList}.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -312,7 +374,7 @@ function buildMessageToolDescription(options?: {
|
|||||||
if (options?.config) {
|
if (options?.config) {
|
||||||
const actions = listChannelMessageActions(options.config);
|
const actions = listChannelMessageActions(options.config);
|
||||||
if (actions.length > 0) {
|
if (actions.length > 0) {
|
||||||
return `${baseDescription} Supports actions: ${actions.join(", ")}.`;
|
return `${baseDescription} Supports actions: ${actions.join(', ')}.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,7 +383,9 @@ function buildMessageToolDescription(options?: {
|
|||||||
|
|
||||||
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||||
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
|
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
|
||||||
const schema = options?.config ? buildMessageToolSchema(options.config) : MessageToolSchema;
|
const schema = options?.config
|
||||||
|
? buildMessageToolSchema(options.config)
|
||||||
|
: MessageToolSchema;
|
||||||
const description = buildMessageToolDescription({
|
const description = buildMessageToolDescription({
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
currentChannel: options?.currentChannelProvider,
|
currentChannel: options?.currentChannelProvider,
|
||||||
@ -329,34 +393,34 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: "Message",
|
label: 'Message',
|
||||||
name: "message",
|
name: 'message',
|
||||||
description,
|
description,
|
||||||
parameters: schema,
|
parameters: schema,
|
||||||
execute: async (_toolCallId, args, signal) => {
|
execute: async (_toolCallId, args, signal) => {
|
||||||
// Check if already aborted before doing any work
|
// Check if already aborted before doing any work
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
const err = new Error("Message send aborted");
|
const err = new Error('Message send aborted');
|
||||||
err.name = "AbortError";
|
err.name = 'AbortError';
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
const cfg = options?.config ?? loadConfig();
|
const cfg = options?.config ?? loadConfig();
|
||||||
const action = readStringParam(params, "action", {
|
const action = readStringParam(params, 'action', {
|
||||||
required: true,
|
required: true,
|
||||||
}) as ChannelMessageActionName;
|
}) as ChannelMessageActionName;
|
||||||
|
|
||||||
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
|
const accountId = readStringParam(params, 'accountId') ?? agentAccountId;
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
params.accountId = accountId;
|
params.accountId = accountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gateway = {
|
const gateway = {
|
||||||
url: readStringParam(params, "gatewayUrl", { trim: false }),
|
url: readStringParam(params, 'gatewayUrl', { trim: false }),
|
||||||
token: readStringParam(params, "gatewayToken", { trim: false }),
|
token: readStringParam(params, 'gatewayToken', { trim: false }),
|
||||||
timeoutMs: readNumberParam(params, "timeoutMs"),
|
timeoutMs: readNumberParam(params, 'timeoutMs'),
|
||||||
clientName: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
|
clientName: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
|
||||||
clientDisplayName: "agent",
|
clientDisplayName: 'agent',
|
||||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -386,7 +450,10 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
gateway,
|
gateway,
|
||||||
toolContext,
|
toolContext,
|
||||||
agentId: options?.agentSessionKey
|
agentId: options?.agentSessionKey
|
||||||
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
|
? resolveSessionAgentId({
|
||||||
|
sessionKey: options.agentSessionKey,
|
||||||
|
config: cfg,
|
||||||
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
abortSignal: signal,
|
abortSignal: signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,30 +1,66 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from 'commander';
|
||||||
import type { MessageCliHelpers } from "./helpers.js";
|
import type { MessageCliHelpers } from './helpers.js';
|
||||||
|
|
||||||
export function registerMessageSendCommand(message: Command, helpers: MessageCliHelpers) {
|
export function registerMessageSendCommand(
|
||||||
|
message: Command,
|
||||||
|
helpers: MessageCliHelpers
|
||||||
|
) {
|
||||||
helpers
|
helpers
|
||||||
.withMessageBase(
|
.withMessageBase(
|
||||||
helpers
|
helpers
|
||||||
.withRequiredMessageTarget(
|
.withRequiredMessageTarget(
|
||||||
message
|
message
|
||||||
.command("send")
|
.command('send')
|
||||||
.description("Send a message")
|
.description('Send a message')
|
||||||
.option("-m, --message <text>", "Message body (required unless --media is set)"),
|
.option(
|
||||||
|
'-m, --message <text>',
|
||||||
|
'Message body (required unless --media is set)'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--media <path-or-url>",
|
'--media <path-or-url>',
|
||||||
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
|
'Attach media (image/audio/video/document). Accepts local paths or URLs.'
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--buttons <json>",
|
'--buttons <json>',
|
||||||
"Telegram inline keyboard buttons as JSON (array of button rows)",
|
'Telegram inline keyboard buttons as JSON (array of button rows)'
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--card <json>',
|
||||||
|
'Adaptive Card JSON object (when supported by the channel)'
|
||||||
|
)
|
||||||
|
.option('--reply-to <id>', 'Reply-to message id')
|
||||||
|
.option('--thread-id <id>', 'Thread id (Telegram forum thread)')
|
||||||
|
.option(
|
||||||
|
'--gif-playback',
|
||||||
|
'Treat video media as GIF playback (WhatsApp only).',
|
||||||
|
false
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--latitude <number>',
|
||||||
|
'Latitude for location message (required with --longitude for native WhatsApp location pin)',
|
||||||
|
(value: string) => Number(value)
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--longitude <number>',
|
||||||
|
'Longitude for location message (required with --latitude for native WhatsApp location pin)',
|
||||||
|
(value: string) => Number(value)
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--location-name <text>',
|
||||||
|
"Optional name for location message (e.g., 'Home', 'Office')"
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--location-address <text>',
|
||||||
|
'Optional address text for location message'
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--location-accuracy <number>',
|
||||||
|
'Optional accuracy in meters for location message',
|
||||||
|
(value: string) => Number(value)
|
||||||
)
|
)
|
||||||
.option("--card <json>", "Adaptive Card JSON object (when supported by the channel)")
|
|
||||||
.option("--reply-to <id>", "Reply-to message id")
|
|
||||||
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
|
|
||||||
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false),
|
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
await helpers.runMessageAction("send", opts);
|
await helpers.runMessageAction('send', opts);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,35 +3,43 @@ import {
|
|||||||
chunkMarkdownTextWithMode,
|
chunkMarkdownTextWithMode,
|
||||||
resolveChunkMode,
|
resolveChunkMode,
|
||||||
resolveTextChunkLimit,
|
resolveTextChunkLimit,
|
||||||
} from "../../auto-reply/chunk.js";
|
} from '../../auto-reply/chunk.js';
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from '../../auto-reply/types.js';
|
||||||
import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js";
|
import { resolveChannelMediaMaxBytes } from '../../channels/plugins/media-limits.js';
|
||||||
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
|
import { loadChannelOutboundAdapter } from '../../channels/plugins/outbound/load.js';
|
||||||
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
|
import type { ChannelOutboundAdapter } from '../../channels/plugins/types.js';
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from '../../config/config.js';
|
||||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
import { resolveMarkdownTableMode } from '../../config/markdown-tables.js';
|
||||||
import type { sendMessageDiscord } from "../../discord/send.js";
|
|
||||||
import type { sendMessageIMessage } from "../../imessage/send.js";
|
|
||||||
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
|
|
||||||
import { sendMessageSignal } from "../../signal/send.js";
|
|
||||||
import type { sendMessageSlack } from "../../slack/send.js";
|
|
||||||
import type { sendMessageTelegram } from "../../telegram/send.js";
|
|
||||||
import type { sendMessageWhatsApp } from "../../web/outbound.js";
|
|
||||||
import {
|
import {
|
||||||
appendAssistantMessageToSessionTranscript,
|
appendAssistantMessageToSessionTranscript,
|
||||||
resolveMirroredTranscriptText,
|
resolveMirroredTranscriptText,
|
||||||
} from "../../config/sessions.js";
|
} from '../../config/sessions.js';
|
||||||
import type { NormalizedOutboundPayload } from "./payloads.js";
|
import type { sendMessageDiscord } from '../../discord/send.js';
|
||||||
import { normalizeReplyPayloadsForDelivery } from "./payloads.js";
|
import type { sendMessageIMessage } from '../../imessage/send.js';
|
||||||
import type { OutboundChannel } from "./targets.js";
|
import {
|
||||||
|
markdownToSignalTextChunks,
|
||||||
|
type SignalTextStyleRange,
|
||||||
|
} from '../../signal/format.js';
|
||||||
|
import { sendMessageSignal } from '../../signal/send.js';
|
||||||
|
import type { sendMessageSlack } from '../../slack/send.js';
|
||||||
|
import type { sendMessageTelegram } from '../../telegram/send.js';
|
||||||
|
import type { sendMessageWhatsApp } from '../../web/outbound.js';
|
||||||
|
import type { NormalizedOutboundPayload } from './payloads.js';
|
||||||
|
import { normalizeReplyPayloadsForDelivery } from './payloads.js';
|
||||||
|
import type { OutboundChannel } from './targets.js';
|
||||||
|
|
||||||
export type { NormalizedOutboundPayload } from "./payloads.js";
|
export { normalizeOutboundPayloads } from './payloads.js';
|
||||||
export { normalizeOutboundPayloads } from "./payloads.js";
|
export type { NormalizedOutboundPayload } from './payloads.js';
|
||||||
|
|
||||||
type SendMatrixMessage = (
|
type SendMatrixMessage = (
|
||||||
to: string,
|
to: string,
|
||||||
text: string,
|
text: string,
|
||||||
opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number },
|
opts?: {
|
||||||
|
mediaUrl?: string;
|
||||||
|
replyToId?: string;
|
||||||
|
threadId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
) => Promise<{ messageId: string; roomId: string }>;
|
) => Promise<{ messageId: string; roomId: string }>;
|
||||||
|
|
||||||
export type OutboundSendDeps = {
|
export type OutboundSendDeps = {
|
||||||
@ -45,12 +53,12 @@ export type OutboundSendDeps = {
|
|||||||
sendMSTeams?: (
|
sendMSTeams?: (
|
||||||
to: string,
|
to: string,
|
||||||
text: string,
|
text: string,
|
||||||
opts?: { mediaUrl?: string },
|
opts?: { mediaUrl?: string }
|
||||||
) => Promise<{ messageId: string; conversationId: string }>;
|
) => Promise<{ messageId: string; conversationId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OutboundDeliveryResult = {
|
export type OutboundDeliveryResult = {
|
||||||
channel: Exclude<OutboundChannel, "none">;
|
channel: Exclude<OutboundChannel, 'none'>;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
@ -67,23 +75,26 @@ type Chunker = (text: string, limit: number) => string[];
|
|||||||
|
|
||||||
type ChannelHandler = {
|
type ChannelHandler = {
|
||||||
chunker: Chunker | null;
|
chunker: Chunker | null;
|
||||||
chunkerMode?: "text" | "markdown";
|
chunkerMode?: 'text' | 'markdown';
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
sendPayload?: (payload: ReplyPayload) => Promise<OutboundDeliveryResult>;
|
sendPayload?: (payload: ReplyPayload) => Promise<OutboundDeliveryResult>;
|
||||||
sendText: (text: string) => Promise<OutboundDeliveryResult>;
|
sendText: (text: string) => Promise<OutboundDeliveryResult>;
|
||||||
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>;
|
sendMedia: (
|
||||||
|
caption: string,
|
||||||
|
mediaUrl: string
|
||||||
|
) => Promise<OutboundDeliveryResult>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function throwIfAborted(abortSignal?: AbortSignal): void {
|
function throwIfAborted(abortSignal?: AbortSignal): void {
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error("Outbound delivery aborted");
|
throw new Error('Outbound delivery aborted');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel docking: outbound delivery delegates to plugin.outbound adapters.
|
// Channel docking: outbound delivery delegates to plugin.outbound adapters.
|
||||||
async function createChannelHandler(params: {
|
async function createChannelHandler(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
channel: Exclude<OutboundChannel, "none">;
|
channel: Exclude<OutboundChannel, 'none'>;
|
||||||
to: string;
|
to: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
replyToId?: string | null;
|
replyToId?: string | null;
|
||||||
@ -115,7 +126,7 @@ async function createChannelHandler(params: {
|
|||||||
function createPluginHandler(params: {
|
function createPluginHandler(params: {
|
||||||
outbound?: ChannelOutboundAdapter;
|
outbound?: ChannelOutboundAdapter;
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
channel: Exclude<OutboundChannel, "none">;
|
channel: Exclude<OutboundChannel, 'none'>;
|
||||||
to: string;
|
to: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
replyToId?: string | null;
|
replyToId?: string | null;
|
||||||
@ -138,7 +149,7 @@ function createPluginHandler(params: {
|
|||||||
outbound.sendPayload!({
|
outbound.sendPayload!({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
to: params.to,
|
to: params.to,
|
||||||
text: payload.text ?? "",
|
text: payload.text ?? '',
|
||||||
mediaUrl: payload.mediaUrl,
|
mediaUrl: payload.mediaUrl,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
replyToId: params.replyToId,
|
replyToId: params.replyToId,
|
||||||
@ -176,7 +187,7 @@ function createPluginHandler(params: {
|
|||||||
|
|
||||||
export async function deliverOutboundPayloads(params: {
|
export async function deliverOutboundPayloads(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
channel: Exclude<OutboundChannel, "none">;
|
channel: Exclude<OutboundChannel, 'none'>;
|
||||||
to: string;
|
to: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
payloads: ReplyPayload[];
|
payloads: ReplyPayload[];
|
||||||
@ -186,6 +197,13 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
gifPlayback?: boolean;
|
gifPlayback?: boolean;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
bestEffort?: boolean;
|
bestEffort?: boolean;
|
||||||
|
location?: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
accuracy?: number;
|
||||||
|
};
|
||||||
onError?: (err: unknown, payload: NormalizedOutboundPayload) => void;
|
onError?: (err: unknown, payload: NormalizedOutboundPayload) => void;
|
||||||
onPayload?: (payload: NormalizedOutboundPayload) => void;
|
onPayload?: (payload: NormalizedOutboundPayload) => void;
|
||||||
mirror?: {
|
mirror?: {
|
||||||
@ -216,11 +234,13 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
fallbackLimit: handler.textChunkLimit,
|
fallbackLimit: handler.textChunkLimit,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
const chunkMode = handler.chunker ? resolveChunkMode(cfg, channel, accountId) : "length";
|
const chunkMode = handler.chunker
|
||||||
const isSignalChannel = channel === "signal";
|
? resolveChunkMode(cfg, channel, accountId)
|
||||||
|
: 'length';
|
||||||
|
const isSignalChannel = channel === 'signal';
|
||||||
const signalTableMode = isSignalChannel
|
const signalTableMode = isSignalChannel
|
||||||
? resolveMarkdownTableMode({ cfg, channel: "signal", accountId })
|
? resolveMarkdownTableMode({ cfg, channel: 'signal', accountId })
|
||||||
: "code";
|
: 'code';
|
||||||
const signalMaxBytes = isSignalChannel
|
const signalMaxBytes = isSignalChannel
|
||||||
? resolveChannelMediaMaxBytes({
|
? resolveChannelMediaMaxBytes({
|
||||||
cfg,
|
cfg,
|
||||||
@ -237,11 +257,11 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
results.push(await handler.sendText(text));
|
results.push(await handler.sendText(text));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (chunkMode === "newline") {
|
if (chunkMode === 'newline') {
|
||||||
const mode = handler.chunkerMode ?? "text";
|
const mode = handler.chunkerMode ?? 'text';
|
||||||
const blockChunks =
|
const blockChunks =
|
||||||
mode === "markdown"
|
mode === 'markdown'
|
||||||
? chunkMarkdownTextWithMode(text, textLimit, "newline")
|
? chunkMarkdownTextWithMode(text, textLimit, 'newline')
|
||||||
: chunkByParagraph(text, textLimit);
|
: chunkByParagraph(text, textLimit);
|
||||||
|
|
||||||
if (!blockChunks.length && text) blockChunks.push(text);
|
if (!blockChunks.length && text) blockChunks.push(text);
|
||||||
@ -262,14 +282,17 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendSignalText = async (text: string, styles: SignalTextStyleRange[]) => {
|
const sendSignalText = async (
|
||||||
|
text: string,
|
||||||
|
styles: SignalTextStyleRange[]
|
||||||
|
) => {
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
return {
|
return {
|
||||||
channel: "signal" as const,
|
channel: 'signal' as const,
|
||||||
...(await sendSignal(to, text, {
|
...(await sendSignal(to, text, {
|
||||||
maxBytes: signalMaxBytes,
|
maxBytes: signalMaxBytes,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
textMode: "plain",
|
textMode: 'plain',
|
||||||
textStyles: styles,
|
textStyles: styles,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@ -282,7 +305,9 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, {
|
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, {
|
||||||
tableMode: signalTableMode,
|
tableMode: signalTableMode,
|
||||||
})
|
})
|
||||||
: markdownToSignalTextChunks(text, textLimit, { tableMode: signalTableMode });
|
: markdownToSignalTextChunks(text, textLimit, {
|
||||||
|
tableMode: signalTableMode,
|
||||||
|
});
|
||||||
if (signalChunks.length === 0 && text) {
|
if (signalChunks.length === 0 && text) {
|
||||||
signalChunks = [{ text, styles: [] }];
|
signalChunks = [{ text, styles: [] }];
|
||||||
}
|
}
|
||||||
@ -294,28 +319,122 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
|
|
||||||
const sendSignalMedia = async (caption: string, mediaUrl: string) => {
|
const sendSignalMedia = async (caption: string, mediaUrl: string) => {
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY, {
|
const formatted = markdownToSignalTextChunks(
|
||||||
|
caption,
|
||||||
|
Number.POSITIVE_INFINITY,
|
||||||
|
{
|
||||||
tableMode: signalTableMode,
|
tableMode: signalTableMode,
|
||||||
})[0] ?? {
|
}
|
||||||
|
)[0] ?? {
|
||||||
text: caption,
|
text: caption,
|
||||||
styles: [],
|
styles: [],
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
channel: "signal" as const,
|
channel: 'signal' as const,
|
||||||
...(await sendSignal(to, formatted.text, {
|
...(await sendSignal(to, formatted.text, {
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
maxBytes: signalMaxBytes,
|
maxBytes: signalMaxBytes,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
textMode: "plain",
|
textMode: 'plain',
|
||||||
textStyles: formatted.styles,
|
textStyles: formatted.styles,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads);
|
const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads);
|
||||||
|
|
||||||
|
// Special handling for WhatsApp location messages
|
||||||
|
if (channel === 'whatsapp' && params.location && deps?.sendWhatsApp) {
|
||||||
|
const firstPayload = normalizedPayloads[0];
|
||||||
|
const hasMedia =
|
||||||
|
(firstPayload?.mediaUrls?.length ?? 0) > 0 || firstPayload?.mediaUrl;
|
||||||
|
// Only send location if there's no media (location messages are standalone)
|
||||||
|
if (!hasMedia) {
|
||||||
|
const text = firstPayload?.text ?? '';
|
||||||
|
try {
|
||||||
|
throwIfAborted(abortSignal);
|
||||||
|
const result = await deps.sendWhatsApp(to, text, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
location: params.location,
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
channel: 'whatsapp',
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
// If there are more payloads, continue processing them
|
||||||
|
if (normalizedPayloads.length > 1) {
|
||||||
|
const remainingPayloads = normalizedPayloads.slice(1);
|
||||||
|
for (const payload of remainingPayloads) {
|
||||||
|
const payloadSummary: NormalizedOutboundPayload = {
|
||||||
|
text: payload.text ?? '',
|
||||||
|
mediaUrls:
|
||||||
|
payload.mediaUrls ??
|
||||||
|
(payload.mediaUrl ? [payload.mediaUrl] : []),
|
||||||
|
channelData: payload.channelData,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
throwIfAborted(abortSignal);
|
||||||
|
params.onPayload?.(payloadSummary);
|
||||||
|
if (handler.sendPayload && payload.channelData) {
|
||||||
|
results.push(await handler.sendPayload(payload));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (payloadSummary.mediaUrls.length === 0) {
|
||||||
|
if (isSignalChannel) {
|
||||||
|
await sendSignalTextChunks(payloadSummary.text);
|
||||||
|
} else {
|
||||||
|
await sendTextChunks(payloadSummary.text);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let first = true;
|
||||||
|
for (const url of payloadSummary.mediaUrls) {
|
||||||
|
throwIfAborted(abortSignal);
|
||||||
|
const caption = first ? payloadSummary.text : '';
|
||||||
|
first = false;
|
||||||
|
if (isSignalChannel) {
|
||||||
|
results.push(await sendSignalMedia(caption, url));
|
||||||
|
} else {
|
||||||
|
results.push(await handler.sendMedia(caption, url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!params.bestEffort) throw err;
|
||||||
|
params.onError?.(err, payloadSummary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (params.mirror && results.length > 0) {
|
||||||
|
const mirrorText = resolveMirroredTranscriptText({
|
||||||
|
text: params.mirror.text,
|
||||||
|
mediaUrls: params.mirror.mediaUrls,
|
||||||
|
});
|
||||||
|
if (mirrorText) {
|
||||||
|
await appendAssistantMessageToSessionTranscript({
|
||||||
|
agentId: params.mirror.agentId,
|
||||||
|
sessionKey: params.mirror.sessionKey,
|
||||||
|
text: mirrorText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} catch (err) {
|
||||||
|
if (!params.bestEffort) throw err;
|
||||||
|
params.onError?.(err, {
|
||||||
|
text: '',
|
||||||
|
mediaUrls: [],
|
||||||
|
channelData: undefined,
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const payload of normalizedPayloads) {
|
for (const payload of normalizedPayloads) {
|
||||||
const payloadSummary: NormalizedOutboundPayload = {
|
const payloadSummary: NormalizedOutboundPayload = {
|
||||||
text: payload.text ?? "",
|
text: payload.text ?? '',
|
||||||
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
|
mediaUrls:
|
||||||
|
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
|
||||||
channelData: payload.channelData,
|
channelData: payload.channelData,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@ -337,7 +456,7 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
let first = true;
|
let first = true;
|
||||||
for (const url of payloadSummary.mediaUrls) {
|
for (const url of payloadSummary.mediaUrls) {
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
const caption = first ? payloadSummary.text : "";
|
const caption = first ? payloadSummary.text : '';
|
||||||
first = false;
|
first = false;
|
||||||
if (isSignalChannel) {
|
if (isSignalChannel) {
|
||||||
results.push(await sendSignalMedia(caption, url));
|
results.push(await sendSignalMedia(caption, url));
|
||||||
|
|||||||
@ -1,48 +1,60 @@
|
|||||||
import path from "node:path";
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from '@mariozechner/pi-agent-core';
|
||||||
|
import { resolveSessionAgentId } from '../../agents/agent-scope.js';
|
||||||
import {
|
import {
|
||||||
readNumberParam,
|
readNumberParam,
|
||||||
readStringArrayParam,
|
readStringArrayParam,
|
||||||
readStringParam,
|
readStringParam,
|
||||||
} from "../../agents/tools/common.js";
|
} from '../../agents/tools/common.js';
|
||||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
import { parseReplyDirectives } from '../../auto-reply/reply/reply-directives.js';
|
||||||
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
|
import { dispatchChannelMessageAction } from '../../channels/plugins/message-actions.js';
|
||||||
import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js";
|
|
||||||
import type {
|
import type {
|
||||||
ChannelId,
|
ChannelId,
|
||||||
ChannelMessageActionName,
|
ChannelMessageActionName,
|
||||||
ChannelThreadingToolContext,
|
ChannelThreadingToolContext,
|
||||||
} from "../../channels/plugins/types.js";
|
} from '../../channels/plugins/types.js';
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from '../../config/config.js';
|
||||||
|
import { extensionForMime } from '../../media/mime.js';
|
||||||
|
import { parseSlackTarget } from '../../slack/targets.js';
|
||||||
import {
|
import {
|
||||||
isDeliverableMessageChannel,
|
isDeliverableMessageChannel,
|
||||||
normalizeMessageChannel,
|
normalizeMessageChannel,
|
||||||
type GatewayClientMode,
|
type GatewayClientMode,
|
||||||
type GatewayClientName,
|
type GatewayClientName,
|
||||||
} from "../../utils/message-channel.js";
|
} from '../../utils/message-channel.js';
|
||||||
|
import { loadWebMedia } from '../../web/media.js';
|
||||||
import {
|
import {
|
||||||
listConfiguredMessageChannels,
|
listConfiguredMessageChannels,
|
||||||
resolveMessageChannelSelection,
|
resolveMessageChannelSelection,
|
||||||
} from "./channel-selection.js";
|
} from './channel-selection.js';
|
||||||
import { applyTargetToParams } from "./channel-target.js";
|
import { applyTargetToParams } from './channel-target.js';
|
||||||
import { ensureOutboundSessionEntry, resolveOutboundSessionRoute } from "./outbound-session.js";
|
import type { OutboundSendDeps } from './deliver.js';
|
||||||
import type { OutboundSendDeps } from "./deliver.js";
|
import {
|
||||||
import type { MessagePollResult, MessageSendResult } from "./message.js";
|
actionHasTarget,
|
||||||
|
actionRequiresTarget,
|
||||||
|
} from './message-action-spec.js';
|
||||||
|
import type { MessagePollResult, MessageSendResult } from './message.js';
|
||||||
import {
|
import {
|
||||||
applyCrossContextDecoration,
|
applyCrossContextDecoration,
|
||||||
buildCrossContextDecoration,
|
buildCrossContextDecoration,
|
||||||
type CrossContextDecoration,
|
|
||||||
enforceCrossContextPolicy,
|
enforceCrossContextPolicy,
|
||||||
shouldApplyCrossContextMarker,
|
shouldApplyCrossContextMarker,
|
||||||
} from "./outbound-policy.js";
|
type CrossContextDecoration,
|
||||||
import { executePollAction, executeSendAction } from "./outbound-send-service.js";
|
} from './outbound-policy.js';
|
||||||
import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js";
|
import {
|
||||||
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js";
|
executePollAction,
|
||||||
import { loadWebMedia } from "../../web/media.js";
|
executeSendAction,
|
||||||
import { extensionForMime } from "../../media/mime.js";
|
} from './outbound-send-service.js';
|
||||||
import { parseSlackTarget } from "../../slack/targets.js";
|
import {
|
||||||
|
ensureOutboundSessionEntry,
|
||||||
|
resolveOutboundSessionRoute,
|
||||||
|
} from './outbound-session.js';
|
||||||
|
import {
|
||||||
|
resolveChannelTarget,
|
||||||
|
type ResolvedMessagingTarget,
|
||||||
|
} from './target-resolver.js';
|
||||||
|
|
||||||
export type MessageActionRunnerGateway = {
|
export type MessageActionRunnerGateway = {
|
||||||
url?: string;
|
url?: string;
|
||||||
@ -69,21 +81,21 @@ export type RunMessageActionParams = {
|
|||||||
|
|
||||||
export type MessageActionRunResult =
|
export type MessageActionRunResult =
|
||||||
| {
|
| {
|
||||||
kind: "send";
|
kind: 'send';
|
||||||
channel: ChannelId;
|
channel: ChannelId;
|
||||||
action: "send";
|
action: 'send';
|
||||||
to: string;
|
to: string;
|
||||||
handledBy: "plugin" | "core";
|
handledBy: 'plugin' | 'core';
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
toolResult?: AgentToolResult<unknown>;
|
toolResult?: AgentToolResult<unknown>;
|
||||||
sendResult?: MessageSendResult;
|
sendResult?: MessageSendResult;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "broadcast";
|
kind: 'broadcast';
|
||||||
channel: ChannelId;
|
channel: ChannelId;
|
||||||
action: "broadcast";
|
action: 'broadcast';
|
||||||
handledBy: "core" | "dry-run";
|
handledBy: 'core' | 'dry-run';
|
||||||
payload: {
|
payload: {
|
||||||
results: Array<{
|
results: Array<{
|
||||||
channel: ChannelId;
|
channel: ChannelId;
|
||||||
@ -96,30 +108,30 @@ export type MessageActionRunResult =
|
|||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "poll";
|
kind: 'poll';
|
||||||
channel: ChannelId;
|
channel: ChannelId;
|
||||||
action: "poll";
|
action: 'poll';
|
||||||
to: string;
|
to: string;
|
||||||
handledBy: "plugin" | "core";
|
handledBy: 'plugin' | 'core';
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
toolResult?: AgentToolResult<unknown>;
|
toolResult?: AgentToolResult<unknown>;
|
||||||
pollResult?: MessagePollResult;
|
pollResult?: MessagePollResult;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "action";
|
kind: 'action';
|
||||||
channel: ChannelId;
|
channel: ChannelId;
|
||||||
action: Exclude<ChannelMessageActionName, "send" | "poll">;
|
action: Exclude<ChannelMessageActionName, 'send' | 'poll'>;
|
||||||
handledBy: "plugin" | "dry-run";
|
handledBy: 'plugin' | 'dry-run';
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
toolResult?: AgentToolResult<unknown>;
|
toolResult?: AgentToolResult<unknown>;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getToolResult(
|
export function getToolResult(
|
||||||
result: MessageActionRunResult,
|
result: MessageActionRunResult
|
||||||
): AgentToolResult<unknown> | undefined {
|
): AgentToolResult<unknown> | undefined {
|
||||||
return "toolResult" in result ? result.toolResult : undefined;
|
return 'toolResult' in result ? result.toolResult : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractToolPayload(result: AgentToolResult<unknown>): unknown {
|
function extractToolPayload(result: AgentToolResult<unknown>): unknown {
|
||||||
@ -128,9 +140,9 @@ function extractToolPayload(result: AgentToolResult<unknown>): unknown {
|
|||||||
? result.content.find(
|
? result.content.find(
|
||||||
(block) =>
|
(block) =>
|
||||||
block &&
|
block &&
|
||||||
typeof block === "object" &&
|
typeof block === 'object' &&
|
||||||
(block as { type?: unknown }).type === "text" &&
|
(block as { type?: unknown }).type === 'text' &&
|
||||||
typeof (block as { text?: unknown }).text === "string",
|
typeof (block as { text?: unknown }).text === 'string'
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const text = (textBlock as { text?: string } | undefined)?.text;
|
const text = (textBlock as { text?: string } | undefined)?.text;
|
||||||
@ -197,13 +209,16 @@ async function maybeApplyCrossContextMarker(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
function readBooleanParam(
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
key: string
|
||||||
|
): boolean | undefined {
|
||||||
const raw = params[key];
|
const raw = params[key];
|
||||||
if (typeof raw === "boolean") return raw;
|
if (typeof raw === 'boolean') return raw;
|
||||||
if (typeof raw === "string") {
|
if (typeof raw === 'string') {
|
||||||
const trimmed = raw.trim().toLowerCase();
|
const trimmed = raw.trim().toLowerCase();
|
||||||
if (trimmed === "true") return true;
|
if (trimmed === 'true') return true;
|
||||||
if (trimmed === "false") return false;
|
if (trimmed === 'false') return false;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -215,11 +230,14 @@ function resolveSlackAutoThreadId(params: {
|
|||||||
const context = params.toolContext;
|
const context = params.toolContext;
|
||||||
if (!context?.currentThreadTs || !context.currentChannelId) return undefined;
|
if (!context?.currentThreadTs || !context.currentChannelId) return undefined;
|
||||||
// Only mirror auto-threading when Slack would reply in the active thread for this channel.
|
// Only mirror auto-threading when Slack would reply in the active thread for this channel.
|
||||||
if (context.replyToMode !== "all" && context.replyToMode !== "first") return undefined;
|
if (context.replyToMode !== 'all' && context.replyToMode !== 'first')
|
||||||
const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" });
|
return undefined;
|
||||||
if (!parsedTarget || parsedTarget.kind !== "channel") return undefined;
|
const parsedTarget = parseSlackTarget(params.to, { defaultKind: 'channel' });
|
||||||
if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) return undefined;
|
if (!parsedTarget || parsedTarget.kind !== 'channel') return undefined;
|
||||||
if (context.replyToMode === "first" && context.hasRepliedRef?.value) return undefined;
|
if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase())
|
||||||
|
return undefined;
|
||||||
|
if (context.replyToMode === 'first' && context.hasRepliedRef?.value)
|
||||||
|
return undefined;
|
||||||
return context.currentThreadTs;
|
return context.currentThreadTs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,31 +247,35 @@ function resolveAttachmentMaxBytes(params: {
|
|||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): number | undefined {
|
}): number | undefined {
|
||||||
const fallback = params.cfg.agents?.defaults?.mediaMaxMb;
|
const fallback = params.cfg.agents?.defaults?.mediaMaxMb;
|
||||||
if (params.channel !== "bluebubbles") {
|
if (params.channel !== 'bluebubbles') {
|
||||||
return typeof fallback === "number" ? fallback * 1024 * 1024 : undefined;
|
return typeof fallback === 'number' ? fallback * 1024 * 1024 : undefined;
|
||||||
}
|
}
|
||||||
const accountId = typeof params.accountId === "string" ? params.accountId.trim() : "";
|
const accountId =
|
||||||
|
typeof params.accountId === 'string' ? params.accountId.trim() : '';
|
||||||
const channelCfg = params.cfg.channels?.bluebubbles;
|
const channelCfg = params.cfg.channels?.bluebubbles;
|
||||||
const channelObj =
|
const channelObj =
|
||||||
channelCfg && typeof channelCfg === "object"
|
channelCfg && typeof channelCfg === 'object'
|
||||||
? (channelCfg as Record<string, unknown>)
|
? (channelCfg as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
const channelMediaMax =
|
const channelMediaMax =
|
||||||
typeof channelObj?.mediaMaxMb === "number" ? channelObj.mediaMaxMb : undefined;
|
typeof channelObj?.mediaMaxMb === 'number'
|
||||||
|
? channelObj.mediaMaxMb
|
||||||
|
: undefined;
|
||||||
const accountsObj =
|
const accountsObj =
|
||||||
channelObj?.accounts && typeof channelObj.accounts === "object"
|
channelObj?.accounts && typeof channelObj.accounts === 'object'
|
||||||
? (channelObj.accounts as Record<string, unknown>)
|
? (channelObj.accounts as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
const accountCfg = accountId && accountsObj ? accountsObj[accountId] : undefined;
|
const accountCfg =
|
||||||
|
accountId && accountsObj ? accountsObj[accountId] : undefined;
|
||||||
const accountMediaMax =
|
const accountMediaMax =
|
||||||
accountCfg && typeof accountCfg === "object"
|
accountCfg && typeof accountCfg === 'object'
|
||||||
? (accountCfg as Record<string, unknown>).mediaMaxMb
|
? (accountCfg as Record<string, unknown>).mediaMaxMb
|
||||||
: undefined;
|
: undefined;
|
||||||
const limitMb =
|
const limitMb =
|
||||||
(typeof accountMediaMax === "number" ? accountMediaMax : undefined) ??
|
(typeof accountMediaMax === 'number' ? accountMediaMax : undefined) ??
|
||||||
channelMediaMax ??
|
channelMediaMax ??
|
||||||
params.cfg.agents?.defaults?.mediaMaxMb;
|
params.cfg.agents?.defaults?.mediaMaxMb;
|
||||||
return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined;
|
return typeof limitMb === 'number' ? limitMb * 1024 * 1024 : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferAttachmentFilename(params: {
|
function inferAttachmentFilename(params: {
|
||||||
@ -263,7 +285,7 @@ function inferAttachmentFilename(params: {
|
|||||||
const mediaHint = params.mediaHint?.trim();
|
const mediaHint = params.mediaHint?.trim();
|
||||||
if (mediaHint) {
|
if (mediaHint) {
|
||||||
try {
|
try {
|
||||||
if (mediaHint.startsWith("file://")) {
|
if (mediaHint.startsWith('file://')) {
|
||||||
const filePath = fileURLToPath(mediaHint);
|
const filePath = fileURLToPath(mediaHint);
|
||||||
const base = path.basename(filePath);
|
const base = path.basename(filePath);
|
||||||
if (base) return base;
|
if (base) return base;
|
||||||
@ -279,15 +301,21 @@ function inferAttachmentFilename(params: {
|
|||||||
// fall through to content-type based default
|
// fall through to content-type based default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ext = params.contentType ? extensionForMime(params.contentType) : undefined;
|
const ext = params.contentType
|
||||||
return ext ? `attachment${ext}` : "attachment";
|
? extensionForMime(params.contentType)
|
||||||
|
: undefined;
|
||||||
|
return ext ? `attachment${ext}` : 'attachment';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBase64Payload(params: { base64?: string; contentType?: string }): {
|
function normalizeBase64Payload(params: {
|
||||||
|
base64?: string;
|
||||||
|
contentType?: string;
|
||||||
|
}): {
|
||||||
base64?: string;
|
base64?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
} {
|
} {
|
||||||
if (!params.base64) return { base64: params.base64, contentType: params.contentType };
|
if (!params.base64)
|
||||||
|
return { base64: params.base64, contentType: params.contentType };
|
||||||
const match = /^data:([^;]+);base64,(.*)$/i.exec(params.base64.trim());
|
const match = /^data:([^;]+);base64,(.*)$/i.exec(params.base64.trim());
|
||||||
if (!match) return { base64: params.base64, contentType: params.contentType };
|
if (!match) return { base64: params.base64, contentType: params.contentType };
|
||||||
const [, mime, payload] = match;
|
const [, mime, payload] = match;
|
||||||
@ -305,16 +333,17 @@ async function hydrateSetGroupIconParams(params: {
|
|||||||
action: ChannelMessageActionName;
|
action: ChannelMessageActionName;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (params.action !== "setGroupIcon") return;
|
if (params.action !== 'setGroupIcon') return;
|
||||||
|
|
||||||
const mediaHint = readStringParam(params.args, "media", { trim: false });
|
const mediaHint = readStringParam(params.args, 'media', { trim: false });
|
||||||
const fileHint =
|
const fileHint =
|
||||||
readStringParam(params.args, "path", { trim: false }) ??
|
readStringParam(params.args, 'path', { trim: false }) ??
|
||||||
readStringParam(params.args, "filePath", { trim: false });
|
readStringParam(params.args, 'filePath', { trim: false });
|
||||||
const contentTypeParam =
|
const contentTypeParam =
|
||||||
readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType");
|
readStringParam(params.args, 'contentType') ??
|
||||||
|
readStringParam(params.args, 'mimeType');
|
||||||
|
|
||||||
const rawBuffer = readStringParam(params.args, "buffer", { trim: false });
|
const rawBuffer = readStringParam(params.args, 'buffer', { trim: false });
|
||||||
const normalized = normalizeBase64Payload({
|
const normalized = normalizeBase64Payload({
|
||||||
base64: rawBuffer,
|
base64: rawBuffer,
|
||||||
contentType: contentTypeParam ?? undefined,
|
contentType: contentTypeParam ?? undefined,
|
||||||
@ -326,17 +355,21 @@ async function hydrateSetGroupIconParams(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = readStringParam(params.args, "filename");
|
const filename = readStringParam(params.args, 'filename');
|
||||||
const mediaSource = mediaHint ?? fileHint;
|
const mediaSource = mediaHint ?? fileHint;
|
||||||
|
|
||||||
if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) {
|
if (
|
||||||
|
!params.dryRun &&
|
||||||
|
!readStringParam(params.args, 'buffer', { trim: false }) &&
|
||||||
|
mediaSource
|
||||||
|
) {
|
||||||
const maxBytes = resolveAttachmentMaxBytes({
|
const maxBytes = resolveAttachmentMaxBytes({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channel: params.channel,
|
channel: params.channel,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
const media = await loadWebMedia(mediaSource, maxBytes);
|
const media = await loadWebMedia(mediaSource, maxBytes);
|
||||||
params.args.buffer = media.buffer.toString("base64");
|
params.args.buffer = media.buffer.toString('base64');
|
||||||
if (!contentTypeParam && media.contentType) {
|
if (!contentTypeParam && media.contentType) {
|
||||||
params.args.contentType = media.contentType;
|
params.args.contentType = media.contentType;
|
||||||
}
|
}
|
||||||
@ -362,19 +395,24 @@ async function hydrateSendAttachmentParams(params: {
|
|||||||
action: ChannelMessageActionName;
|
action: ChannelMessageActionName;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (params.action !== "sendAttachment") return;
|
if (params.action !== 'sendAttachment') return;
|
||||||
|
|
||||||
const mediaHint = readStringParam(params.args, "media", { trim: false });
|
const mediaHint = readStringParam(params.args, 'media', { trim: false });
|
||||||
const fileHint =
|
const fileHint =
|
||||||
readStringParam(params.args, "path", { trim: false }) ??
|
readStringParam(params.args, 'path', { trim: false }) ??
|
||||||
readStringParam(params.args, "filePath", { trim: false });
|
readStringParam(params.args, 'filePath', { trim: false });
|
||||||
const contentTypeParam =
|
const contentTypeParam =
|
||||||
readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType");
|
readStringParam(params.args, 'contentType') ??
|
||||||
const caption = readStringParam(params.args, "caption", { allowEmpty: true })?.trim();
|
readStringParam(params.args, 'mimeType');
|
||||||
const message = readStringParam(params.args, "message", { allowEmpty: true })?.trim();
|
const caption = readStringParam(params.args, 'caption', {
|
||||||
|
allowEmpty: true,
|
||||||
|
})?.trim();
|
||||||
|
const message = readStringParam(params.args, 'message', {
|
||||||
|
allowEmpty: true,
|
||||||
|
})?.trim();
|
||||||
if (!caption && message) params.args.caption = message;
|
if (!caption && message) params.args.caption = message;
|
||||||
|
|
||||||
const rawBuffer = readStringParam(params.args, "buffer", { trim: false });
|
const rawBuffer = readStringParam(params.args, 'buffer', { trim: false });
|
||||||
const normalized = normalizeBase64Payload({
|
const normalized = normalizeBase64Payload({
|
||||||
base64: rawBuffer,
|
base64: rawBuffer,
|
||||||
contentType: contentTypeParam ?? undefined,
|
contentType: contentTypeParam ?? undefined,
|
||||||
@ -386,17 +424,21 @@ async function hydrateSendAttachmentParams(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = readStringParam(params.args, "filename");
|
const filename = readStringParam(params.args, 'filename');
|
||||||
const mediaSource = mediaHint ?? fileHint;
|
const mediaSource = mediaHint ?? fileHint;
|
||||||
|
|
||||||
if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) {
|
if (
|
||||||
|
!params.dryRun &&
|
||||||
|
!readStringParam(params.args, 'buffer', { trim: false }) &&
|
||||||
|
mediaSource
|
||||||
|
) {
|
||||||
const maxBytes = resolveAttachmentMaxBytes({
|
const maxBytes = resolveAttachmentMaxBytes({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channel: params.channel,
|
channel: params.channel,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
const media = await loadWebMedia(mediaSource, maxBytes);
|
const media = await loadWebMedia(mediaSource, maxBytes);
|
||||||
params.args.buffer = media.buffer.toString("base64");
|
params.args.buffer = media.buffer.toString('base64');
|
||||||
if (!contentTypeParam && media.contentType) {
|
if (!contentTypeParam && media.contentType) {
|
||||||
params.args.contentType = media.contentType;
|
params.args.contentType = media.contentType;
|
||||||
}
|
}
|
||||||
@ -416,7 +458,7 @@ async function hydrateSendAttachmentParams(params: {
|
|||||||
|
|
||||||
function parseButtonsParam(params: Record<string, unknown>): void {
|
function parseButtonsParam(params: Record<string, unknown>): void {
|
||||||
const raw = params.buttons;
|
const raw = params.buttons;
|
||||||
if (typeof raw !== "string") return;
|
if (typeof raw !== 'string') return;
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
delete params.buttons;
|
delete params.buttons;
|
||||||
@ -425,13 +467,13 @@ function parseButtonsParam(params: Record<string, unknown>): void {
|
|||||||
try {
|
try {
|
||||||
params.buttons = JSON.parse(trimmed) as unknown;
|
params.buttons = JSON.parse(trimmed) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("--buttons must be valid JSON");
|
throw new Error('--buttons must be valid JSON');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCardParam(params: Record<string, unknown>): void {
|
function parseCardParam(params: Record<string, unknown>): void {
|
||||||
const raw = params.card;
|
const raw = params.card;
|
||||||
if (typeof raw !== "string") return;
|
if (typeof raw !== 'string') return;
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
delete params.card;
|
delete params.card;
|
||||||
@ -440,12 +482,15 @@ function parseCardParam(params: Record<string, unknown>): void {
|
|||||||
try {
|
try {
|
||||||
params.card = JSON.parse(trimmed) as unknown;
|
params.card = JSON.parse(trimmed) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("--card must be valid JSON");
|
throw new Error('--card must be valid JSON');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveChannel(cfg: ClawdbotConfig, params: Record<string, unknown>) {
|
async function resolveChannel(
|
||||||
const channelHint = readStringParam(params, "channel");
|
cfg: ClawdbotConfig,
|
||||||
|
params: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const channelHint = readStringParam(params, 'channel');
|
||||||
const selection = await resolveMessageChannelSelection({
|
const selection = await resolveMessageChannelSelection({
|
||||||
cfg,
|
cfg,
|
||||||
channel: channelHint,
|
channel: channelHint,
|
||||||
@ -461,7 +506,7 @@ async function resolveActionTarget(params: {
|
|||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): Promise<ResolvedMessagingTarget | undefined> {
|
}): Promise<ResolvedMessagingTarget | undefined> {
|
||||||
let resolvedTarget: ResolvedMessagingTarget | undefined;
|
let resolvedTarget: ResolvedMessagingTarget | undefined;
|
||||||
const toRaw = typeof params.args.to === "string" ? params.args.to.trim() : "";
|
const toRaw = typeof params.args.to === 'string' ? params.args.to.trim() : '';
|
||||||
if (toRaw) {
|
if (toRaw) {
|
||||||
const resolved = await resolveChannelTarget({
|
const resolved = await resolveChannelTarget({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
@ -477,20 +522,27 @@ async function resolveActionTarget(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const channelIdRaw =
|
const channelIdRaw =
|
||||||
typeof params.args.channelId === "string" ? params.args.channelId.trim() : "";
|
typeof params.args.channelId === 'string'
|
||||||
|
? params.args.channelId.trim()
|
||||||
|
: '';
|
||||||
if (channelIdRaw) {
|
if (channelIdRaw) {
|
||||||
const resolved = await resolveChannelTarget({
|
const resolved = await resolveChannelTarget({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channel: params.channel,
|
channel: params.channel,
|
||||||
input: channelIdRaw,
|
input: channelIdRaw,
|
||||||
accountId: params.accountId ?? undefined,
|
accountId: params.accountId ?? undefined,
|
||||||
preferredKind: "group",
|
preferredKind: 'group',
|
||||||
});
|
});
|
||||||
if (resolved.ok) {
|
if (resolved.ok) {
|
||||||
if (resolved.target.kind === "user") {
|
if (resolved.target.kind === 'user') {
|
||||||
throw new Error(`Channel id "${channelIdRaw}" resolved to a user target.`);
|
throw new Error(
|
||||||
|
`Channel id "${channelIdRaw}" resolved to a user target.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
params.args.channelId = resolved.target.to.replace(/^(channel|group):/i, "");
|
params.args.channelId = resolved.target.to.replace(
|
||||||
|
/^(channel|group):/i,
|
||||||
|
''
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
throw resolved.error;
|
throw resolved.error;
|
||||||
}
|
}
|
||||||
@ -510,7 +562,9 @@ type ResolvedActionContext = {
|
|||||||
resolvedTarget?: ResolvedMessagingTarget;
|
resolvedTarget?: ResolvedMessagingTarget;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
};
|
};
|
||||||
function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined {
|
function resolveGateway(
|
||||||
|
input: RunMessageActionParams
|
||||||
|
): MessageActionRunnerGateway | undefined {
|
||||||
if (!input.gateway) return undefined;
|
if (!input.gateway) return undefined;
|
||||||
return {
|
return {
|
||||||
url: input.gateway.url,
|
url: input.gateway.url,
|
||||||
@ -524,24 +578,28 @@ function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGatew
|
|||||||
|
|
||||||
async function handleBroadcastAction(
|
async function handleBroadcastAction(
|
||||||
input: RunMessageActionParams,
|
input: RunMessageActionParams,
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>
|
||||||
): Promise<MessageActionRunResult> {
|
): Promise<MessageActionRunResult> {
|
||||||
throwIfAborted(input.abortSignal);
|
throwIfAborted(input.abortSignal);
|
||||||
const broadcastEnabled = input.cfg.tools?.message?.broadcast?.enabled !== false;
|
const broadcastEnabled =
|
||||||
|
input.cfg.tools?.message?.broadcast?.enabled !== false;
|
||||||
if (!broadcastEnabled) {
|
if (!broadcastEnabled) {
|
||||||
throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true.");
|
throw new Error(
|
||||||
|
'Broadcast is disabled. Set tools.message.broadcast.enabled to true.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const rawTargets = readStringArrayParam(params, "targets", { required: true }) ?? [];
|
const rawTargets =
|
||||||
|
readStringArrayParam(params, 'targets', { required: true }) ?? [];
|
||||||
if (rawTargets.length === 0) {
|
if (rawTargets.length === 0) {
|
||||||
throw new Error("Broadcast requires at least one target in --targets.");
|
throw new Error('Broadcast requires at least one target in --targets.');
|
||||||
}
|
}
|
||||||
const channelHint = readStringParam(params, "channel");
|
const channelHint = readStringParam(params, 'channel');
|
||||||
const configured = await listConfiguredMessageChannels(input.cfg);
|
const configured = await listConfiguredMessageChannels(input.cfg);
|
||||||
if (configured.length === 0) {
|
if (configured.length === 0) {
|
||||||
throw new Error("Broadcast requires at least one configured channel.");
|
throw new Error('Broadcast requires at least one configured channel.');
|
||||||
}
|
}
|
||||||
const targetChannels =
|
const targetChannels =
|
||||||
channelHint && channelHint.trim().toLowerCase() !== "all"
|
channelHint && channelHint.trim().toLowerCase() !== 'all'
|
||||||
? [await resolveChannel(input.cfg, { channel: channelHint })]
|
? [await resolveChannel(input.cfg, { channel: channelHint })]
|
||||||
: configured;
|
: configured;
|
||||||
const results: Array<{
|
const results: Array<{
|
||||||
@ -551,7 +609,8 @@ async function handleBroadcastAction(
|
|||||||
error?: string;
|
error?: string;
|
||||||
result?: MessageSendResult;
|
result?: MessageSendResult;
|
||||||
}> = [];
|
}> = [];
|
||||||
const isAbortError = (err: unknown): boolean => err instanceof Error && err.name === "AbortError";
|
const isAbortError = (err: unknown): boolean =>
|
||||||
|
err instanceof Error && err.name === 'AbortError';
|
||||||
for (const targetChannel of targetChannels) {
|
for (const targetChannel of targetChannels) {
|
||||||
throwIfAborted(input.abortSignal);
|
throwIfAborted(input.abortSignal);
|
||||||
for (const target of rawTargets) {
|
for (const target of rawTargets) {
|
||||||
@ -565,7 +624,7 @@ async function handleBroadcastAction(
|
|||||||
if (!resolved.ok) throw resolved.error;
|
if (!resolved.ok) throw resolved.error;
|
||||||
const sendResult = await runMessageAction({
|
const sendResult = await runMessageAction({
|
||||||
...input,
|
...input,
|
||||||
action: "send",
|
action: 'send',
|
||||||
params: {
|
params: {
|
||||||
...params,
|
...params,
|
||||||
channel: targetChannel,
|
channel: targetChannel,
|
||||||
@ -576,7 +635,8 @@ async function handleBroadcastAction(
|
|||||||
channel: targetChannel,
|
channel: targetChannel,
|
||||||
to: resolved.target.to,
|
to: resolved.target.to,
|
||||||
ok: true,
|
ok: true,
|
||||||
result: sendResult.kind === "send" ? sendResult.sendResult : undefined,
|
result:
|
||||||
|
sendResult.kind === 'send' ? sendResult.sendResult : undefined,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isAbortError(err)) throw err;
|
if (isAbortError(err)) throw err;
|
||||||
@ -590,10 +650,10 @@ async function handleBroadcastAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
kind: "broadcast",
|
kind: 'broadcast',
|
||||||
channel: (targetChannels[0] ?? "discord") as ChannelId,
|
channel: (targetChannels[0] ?? 'discord') as ChannelId,
|
||||||
action: "broadcast",
|
action: 'broadcast',
|
||||||
handledBy: input.dryRun ? "dry-run" : "core",
|
handledBy: input.dryRun ? 'dry-run' : 'core',
|
||||||
payload: { results },
|
payload: { results },
|
||||||
dryRun: Boolean(input.dryRun),
|
dryRun: Boolean(input.dryRun),
|
||||||
};
|
};
|
||||||
@ -601,13 +661,15 @@ async function handleBroadcastAction(
|
|||||||
|
|
||||||
function throwIfAborted(abortSignal?: AbortSignal): void {
|
function throwIfAborted(abortSignal?: AbortSignal): void {
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
const err = new Error("Message send aborted");
|
const err = new Error('Message send aborted');
|
||||||
err.name = "AbortError";
|
err.name = 'AbortError';
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> {
|
async function handleSendAction(
|
||||||
|
ctx: ResolvedActionContext
|
||||||
|
): Promise<MessageActionRunResult> {
|
||||||
const {
|
const {
|
||||||
cfg,
|
cfg,
|
||||||
params,
|
params,
|
||||||
@ -621,19 +683,19 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
abortSignal,
|
abortSignal,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
const action: ChannelMessageActionName = "send";
|
const action: ChannelMessageActionName = 'send';
|
||||||
const to = readStringParam(params, "to", { required: true });
|
const to = readStringParam(params, 'to', { required: true });
|
||||||
// Support media, path, and filePath parameters for attachments
|
// Support media, path, and filePath parameters for attachments
|
||||||
const mediaHint =
|
const mediaHint =
|
||||||
readStringParam(params, "media", { trim: false }) ??
|
readStringParam(params, 'media', { trim: false }) ??
|
||||||
readStringParam(params, "path", { trim: false }) ??
|
readStringParam(params, 'path', { trim: false }) ??
|
||||||
readStringParam(params, "filePath", { trim: false });
|
readStringParam(params, 'filePath', { trim: false });
|
||||||
const hasCard = params.card != null && typeof params.card === "object";
|
const hasCard = params.card != null && typeof params.card === 'object';
|
||||||
let message =
|
let message =
|
||||||
readStringParam(params, "message", {
|
readStringParam(params, 'message', {
|
||||||
required: !mediaHint && !hasCard,
|
required: !mediaHint && !hasCard,
|
||||||
allowEmpty: true,
|
allowEmpty: true,
|
||||||
}) ?? "";
|
}) ?? '';
|
||||||
|
|
||||||
const parsed = parseReplyDirectives(message);
|
const parsed = parseReplyDirectives(message);
|
||||||
const mergedMediaUrls: string[] = [];
|
const mergedMediaUrls: string[] = [];
|
||||||
@ -668,15 +730,68 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
preferEmbeds: true,
|
preferEmbeds: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
const mediaUrl = readStringParam(params, 'media', { trim: false });
|
||||||
const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false;
|
const gifPlayback = readBooleanParam(params, 'gifPlayback') ?? false;
|
||||||
const bestEffort = readBooleanParam(params, "bestEffort");
|
const bestEffort = readBooleanParam(params, 'bestEffort');
|
||||||
|
|
||||||
const replyToId = readStringParam(params, "replyTo");
|
// Extract location parameters
|
||||||
const threadId = readStringParam(params, "threadId");
|
const latitude = readNumberParam(params, 'latitude');
|
||||||
|
const longitude = readNumberParam(params, 'longitude');
|
||||||
|
const locationName = readStringParam(params, 'locationName');
|
||||||
|
const locationAddress = readStringParam(params, 'locationAddress');
|
||||||
|
const locationAccuracy = readNumberParam(params, 'locationAccuracy');
|
||||||
|
|
||||||
|
// Validate location coordinates if provided
|
||||||
|
let location:
|
||||||
|
| {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
accuracy?: number;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (latitude != null && longitude != null) {
|
||||||
|
// Validate latitude range: -90 to 90
|
||||||
|
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid latitude: ${latitude}. Must be between -90 and 90.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Validate longitude range: -180 to 180
|
||||||
|
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid longitude: ${longitude}. Must be between -180 and 180.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Validate accuracy if provided (must be positive)
|
||||||
|
if (
|
||||||
|
locationAccuracy != null &&
|
||||||
|
(!Number.isFinite(locationAccuracy) || locationAccuracy < 0)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid location accuracy: ${locationAccuracy}. Must be a positive number.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
location = {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
name: locationName ?? undefined,
|
||||||
|
address: locationAddress ?? undefined,
|
||||||
|
accuracy: locationAccuracy ?? undefined,
|
||||||
|
};
|
||||||
|
} else if (latitude != null || longitude != null) {
|
||||||
|
// If only one coordinate is provided, that's an error
|
||||||
|
throw new Error(
|
||||||
|
'Both latitude and longitude are required for location messages.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyToId = readStringParam(params, 'replyTo');
|
||||||
|
const threadId = readStringParam(params, 'threadId');
|
||||||
// Slack auto-threading can inject threadTs without explicit params; mirror to that session key.
|
// Slack auto-threading can inject threadTs without explicit params; mirror to that session key.
|
||||||
const slackAutoThreadId =
|
const slackAutoThreadId =
|
||||||
channel === "slack" && !replyToId && !threadId
|
channel === 'slack' && !replyToId && !threadId
|
||||||
? resolveSlackAutoThreadId({ to, toolContext: input.toolContext })
|
? resolveSlackAutoThreadId({ to, toolContext: input.toolContext })
|
||||||
: undefined;
|
: undefined;
|
||||||
const outboundRoute =
|
const outboundRoute =
|
||||||
@ -702,7 +817,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const mirrorMediaUrls =
|
const mirrorMediaUrls =
|
||||||
mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined;
|
mergedMediaUrls.length > 0
|
||||||
|
? mergedMediaUrls
|
||||||
|
: mediaUrl
|
||||||
|
? [mediaUrl]
|
||||||
|
: undefined;
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
const send = await executeSendAction({
|
const send = await executeSendAction({
|
||||||
ctx: {
|
ctx: {
|
||||||
@ -731,10 +850,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
mediaUrls: mergedMediaUrls.length ? mergedMediaUrls : undefined,
|
mediaUrls: mergedMediaUrls.length ? mergedMediaUrls : undefined,
|
||||||
gifPlayback,
|
gifPlayback,
|
||||||
bestEffort: bestEffort ?? undefined,
|
bestEffort: bestEffort ?? undefined,
|
||||||
|
location,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: "send",
|
kind: 'send',
|
||||||
channel,
|
channel,
|
||||||
action,
|
action,
|
||||||
to,
|
to,
|
||||||
@ -746,24 +866,36 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> {
|
async function handlePollAction(
|
||||||
const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx;
|
ctx: ResolvedActionContext
|
||||||
|
): Promise<MessageActionRunResult> {
|
||||||
|
const {
|
||||||
|
cfg,
|
||||||
|
params,
|
||||||
|
channel,
|
||||||
|
accountId,
|
||||||
|
dryRun,
|
||||||
|
gateway,
|
||||||
|
input,
|
||||||
|
abortSignal,
|
||||||
|
} = ctx;
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
const action: ChannelMessageActionName = "poll";
|
const action: ChannelMessageActionName = 'poll';
|
||||||
const to = readStringParam(params, "to", { required: true });
|
const to = readStringParam(params, 'to', { required: true });
|
||||||
const question = readStringParam(params, "pollQuestion", {
|
const question = readStringParam(params, 'pollQuestion', {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const options = readStringArrayParam(params, "pollOption", { required: true }) ?? [];
|
const options =
|
||||||
|
readStringArrayParam(params, 'pollOption', { required: true }) ?? [];
|
||||||
if (options.length < 2) {
|
if (options.length < 2) {
|
||||||
throw new Error("pollOption requires at least two values");
|
throw new Error('pollOption requires at least two values');
|
||||||
}
|
}
|
||||||
const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false;
|
const allowMultiselect = readBooleanParam(params, 'pollMulti') ?? false;
|
||||||
const durationHours = readNumberParam(params, "pollDurationHours", {
|
const durationHours = readNumberParam(params, 'pollDurationHours', {
|
||||||
integer: true,
|
integer: true,
|
||||||
});
|
});
|
||||||
const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1;
|
const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1;
|
||||||
const base = typeof params.message === "string" ? params.message : "";
|
const base = typeof params.message === 'string' ? params.message : '';
|
||||||
await maybeApplyCrossContextMarker({
|
await maybeApplyCrossContextMarker({
|
||||||
cfg,
|
cfg,
|
||||||
channel,
|
channel,
|
||||||
@ -794,7 +926,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: "poll",
|
kind: 'poll',
|
||||||
channel,
|
channel,
|
||||||
action,
|
action,
|
||||||
to,
|
to,
|
||||||
@ -806,16 +938,30 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> {
|
async function handlePluginAction(
|
||||||
const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx;
|
ctx: ResolvedActionContext
|
||||||
|
): Promise<MessageActionRunResult> {
|
||||||
|
const {
|
||||||
|
cfg,
|
||||||
|
params,
|
||||||
|
channel,
|
||||||
|
accountId,
|
||||||
|
dryRun,
|
||||||
|
gateway,
|
||||||
|
input,
|
||||||
|
abortSignal,
|
||||||
|
} = ctx;
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
const action = input.action as Exclude<ChannelMessageActionName, "send" | "poll" | "broadcast">;
|
const action = input.action as Exclude<
|
||||||
|
ChannelMessageActionName,
|
||||||
|
'send' | 'poll' | 'broadcast'
|
||||||
|
>;
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
return {
|
return {
|
||||||
kind: "action",
|
kind: 'action',
|
||||||
channel,
|
channel,
|
||||||
action,
|
action,
|
||||||
handledBy: "dry-run",
|
handledBy: 'dry-run',
|
||||||
payload: { ok: true, dryRun: true, channel, action },
|
payload: { ok: true, dryRun: true, channel, action },
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
};
|
};
|
||||||
@ -832,13 +978,15 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
|
|||||||
dryRun,
|
dryRun,
|
||||||
});
|
});
|
||||||
if (!handled) {
|
if (!handled) {
|
||||||
throw new Error(`Message action ${action} not supported for channel ${channel}.`);
|
throw new Error(
|
||||||
|
`Message action ${action} not supported for channel ${channel}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
kind: "action",
|
kind: 'action',
|
||||||
channel,
|
channel,
|
||||||
action,
|
action,
|
||||||
handledBy: "plugin",
|
handledBy: 'plugin',
|
||||||
payload: extractToolPayload(handled),
|
payload: extractToolPayload(handled),
|
||||||
toolResult: handled,
|
toolResult: handled,
|
||||||
dryRun,
|
dryRun,
|
||||||
@ -846,7 +994,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runMessageAction(
|
export async function runMessageAction(
|
||||||
input: RunMessageActionParams,
|
input: RunMessageActionParams
|
||||||
): Promise<MessageActionRunResult> {
|
): Promise<MessageActionRunResult> {
|
||||||
const cfg = input.cfg;
|
const cfg = input.cfg;
|
||||||
const params = { ...input.params };
|
const params = { ...input.params };
|
||||||
@ -859,14 +1007,16 @@ export async function runMessageAction(
|
|||||||
parseCardParam(params);
|
parseCardParam(params);
|
||||||
|
|
||||||
const action = input.action;
|
const action = input.action;
|
||||||
if (action === "broadcast") {
|
if (action === 'broadcast') {
|
||||||
return handleBroadcastAction(input, params);
|
return handleBroadcastAction(input, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
const explicitTarget = typeof params.target === "string" ? params.target.trim() : "";
|
const explicitTarget =
|
||||||
|
typeof params.target === 'string' ? params.target.trim() : '';
|
||||||
const hasLegacyTarget =
|
const hasLegacyTarget =
|
||||||
(typeof params.to === "string" && params.to.trim().length > 0) ||
|
(typeof params.to === 'string' && params.to.trim().length > 0) ||
|
||||||
(typeof params.channelId === "string" && params.channelId.trim().length > 0);
|
(typeof params.channelId === 'string' &&
|
||||||
|
params.channelId.trim().length > 0);
|
||||||
if (explicitTarget && hasLegacyTarget) {
|
if (explicitTarget && hasLegacyTarget) {
|
||||||
delete params.to;
|
delete params.to;
|
||||||
delete params.channelId;
|
delete params.channelId;
|
||||||
@ -883,8 +1033,9 @@ export async function runMessageAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!explicitTarget && actionRequiresTarget(action) && hasLegacyTarget) {
|
if (!explicitTarget && actionRequiresTarget(action) && hasLegacyTarget) {
|
||||||
const legacyTo = typeof params.to === "string" ? params.to.trim() : "";
|
const legacyTo = typeof params.to === 'string' ? params.to.trim() : '';
|
||||||
const legacyChannelId = typeof params.channelId === "string" ? params.channelId.trim() : "";
|
const legacyChannelId =
|
||||||
|
typeof params.channelId === 'string' ? params.channelId.trim() : '';
|
||||||
const legacyTarget = legacyTo || legacyChannelId;
|
const legacyTarget = legacyTo || legacyChannelId;
|
||||||
if (legacyTarget) {
|
if (legacyTarget) {
|
||||||
params.target = legacyTarget;
|
params.target = legacyTarget;
|
||||||
@ -892,9 +1043,12 @@ export async function runMessageAction(
|
|||||||
delete params.channelId;
|
delete params.channelId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const explicitChannel = typeof params.channel === "string" ? params.channel.trim() : "";
|
const explicitChannel =
|
||||||
|
typeof params.channel === 'string' ? params.channel.trim() : '';
|
||||||
if (!explicitChannel) {
|
if (!explicitChannel) {
|
||||||
const inferredChannel = normalizeMessageChannel(input.toolContext?.currentChannelProvider);
|
const inferredChannel = normalizeMessageChannel(
|
||||||
|
input.toolContext?.currentChannelProvider
|
||||||
|
);
|
||||||
if (inferredChannel && isDeliverableMessageChannel(inferredChannel)) {
|
if (inferredChannel && isDeliverableMessageChannel(inferredChannel)) {
|
||||||
params.channel = inferredChannel;
|
params.channel = inferredChannel;
|
||||||
}
|
}
|
||||||
@ -908,11 +1062,12 @@ export async function runMessageAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channel = await resolveChannel(cfg, params);
|
const channel = await resolveChannel(cfg, params);
|
||||||
const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId;
|
const accountId =
|
||||||
|
readStringParam(params, 'accountId') ?? input.defaultAccountId;
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
params.accountId = accountId;
|
params.accountId = accountId;
|
||||||
}
|
}
|
||||||
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
|
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, 'dryRun'));
|
||||||
|
|
||||||
await hydrateSendAttachmentParams({
|
await hydrateSendAttachmentParams({
|
||||||
cfg,
|
cfg,
|
||||||
@ -950,7 +1105,7 @@ export async function runMessageAction(
|
|||||||
|
|
||||||
const gateway = resolveGateway(input);
|
const gateway = resolveGateway(input);
|
||||||
|
|
||||||
if (action === "send") {
|
if (action === 'send') {
|
||||||
return handleSendAction({
|
return handleSendAction({
|
||||||
cfg,
|
cfg,
|
||||||
params,
|
params,
|
||||||
@ -965,7 +1120,7 @@ export async function runMessageAction(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "poll") {
|
if (action === 'poll') {
|
||||||
return handlePollAction({
|
return handlePollAction({
|
||||||
cfg,
|
cfg,
|
||||||
params,
|
params,
|
||||||
|
|||||||
@ -1,25 +1,28 @@
|
|||||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
import {
|
||||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
getChannelPlugin,
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
normalizeChannelId,
|
||||||
import { loadConfig } from "../../config/config.js";
|
} from '../../channels/plugins/index.js';
|
||||||
import { callGateway, randomIdempotencyKey } from "../../gateway/call.js";
|
import type { ChannelId } from '../../channels/plugins/types.js';
|
||||||
import type { PollInput } from "../../polls.js";
|
import type { ClawdbotConfig } from '../../config/config.js';
|
||||||
import { normalizePollInput } from "../../polls.js";
|
import { loadConfig } from '../../config/config.js';
|
||||||
|
import { callGateway, randomIdempotencyKey } from '../../gateway/call.js';
|
||||||
|
import type { PollInput } from '../../polls.js';
|
||||||
|
import { normalizePollInput } from '../../polls.js';
|
||||||
import {
|
import {
|
||||||
GATEWAY_CLIENT_MODES,
|
GATEWAY_CLIENT_MODES,
|
||||||
GATEWAY_CLIENT_NAMES,
|
GATEWAY_CLIENT_NAMES,
|
||||||
type GatewayClientMode,
|
type GatewayClientMode,
|
||||||
type GatewayClientName,
|
type GatewayClientName,
|
||||||
} from "../../utils/message-channel.js";
|
} from '../../utils/message-channel.js';
|
||||||
import { resolveMessageChannelSelection } from "./channel-selection.js";
|
import { resolveMessageChannelSelection } from './channel-selection.js';
|
||||||
import {
|
import {
|
||||||
deliverOutboundPayloads,
|
deliverOutboundPayloads,
|
||||||
type OutboundDeliveryResult,
|
type OutboundDeliveryResult,
|
||||||
type OutboundSendDeps,
|
type OutboundSendDeps,
|
||||||
} from "./deliver.js";
|
} from './deliver.js';
|
||||||
import { normalizeReplyPayloadsForDelivery } from "./payloads.js";
|
import { normalizeReplyPayloadsForDelivery } from './payloads.js';
|
||||||
import type { OutboundChannel } from "./targets.js";
|
import type { OutboundChannel } from './targets.js';
|
||||||
import { resolveOutboundTarget } from "./targets.js";
|
import { resolveOutboundTarget } from './targets.js';
|
||||||
|
|
||||||
export type MessageGatewayOptions = {
|
export type MessageGatewayOptions = {
|
||||||
url?: string;
|
url?: string;
|
||||||
@ -40,6 +43,13 @@ type MessageSendParams = {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
bestEffort?: boolean;
|
bestEffort?: boolean;
|
||||||
|
location?: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
accuracy?: number;
|
||||||
|
};
|
||||||
deps?: OutboundSendDeps;
|
deps?: OutboundSendDeps;
|
||||||
cfg?: ClawdbotConfig;
|
cfg?: ClawdbotConfig;
|
||||||
gateway?: MessageGatewayOptions;
|
gateway?: MessageGatewayOptions;
|
||||||
@ -56,7 +66,7 @@ type MessageSendParams = {
|
|||||||
export type MessageSendResult = {
|
export type MessageSendResult = {
|
||||||
channel: string;
|
channel: string;
|
||||||
to: string;
|
to: string;
|
||||||
via: "direct" | "gateway";
|
via: 'direct' | 'gateway';
|
||||||
mediaUrl: string | null;
|
mediaUrl: string | null;
|
||||||
mediaUrls?: string[];
|
mediaUrls?: string[];
|
||||||
result?: OutboundDeliveryResult | { messageId: string };
|
result?: OutboundDeliveryResult | { messageId: string };
|
||||||
@ -83,7 +93,7 @@ export type MessagePollResult = {
|
|||||||
options: string[];
|
options: string[];
|
||||||
maxSelections: number;
|
maxSelections: number;
|
||||||
durationHours: number | null;
|
durationHours: number | null;
|
||||||
via: "gateway";
|
via: 'gateway';
|
||||||
result?: {
|
result?: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
toJid?: string;
|
toJid?: string;
|
||||||
@ -99,7 +109,7 @@ function resolveGatewayOptions(opts?: MessageGatewayOptions) {
|
|||||||
url: opts?.url,
|
url: opts?.url,
|
||||||
token: opts?.token,
|
token: opts?.token,
|
||||||
timeoutMs:
|
timeoutMs:
|
||||||
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
|
typeof opts?.timeoutMs === 'number' && Number.isFinite(opts.timeoutMs)
|
||||||
? Math.max(1, Math.floor(opts.timeoutMs))
|
? Math.max(1, Math.floor(opts.timeoutMs))
|
||||||
: 10_000,
|
: 10_000,
|
||||||
clientName: opts?.clientName ?? GATEWAY_CLIENT_NAMES.CLI,
|
clientName: opts?.clientName ?? GATEWAY_CLIENT_NAMES.CLI,
|
||||||
@ -108,7 +118,9 @@ function resolveGatewayOptions(opts?: MessageGatewayOptions) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendMessage(params: MessageSendParams): Promise<MessageSendResult> {
|
export async function sendMessage(
|
||||||
|
params: MessageSendParams
|
||||||
|
): Promise<MessageSendResult> {
|
||||||
const cfg = params.cfg ?? loadConfig();
|
const cfg = params.cfg ?? loadConfig();
|
||||||
const channel = params.channel?.trim()
|
const channel = params.channel?.trim()
|
||||||
? normalizeChannelId(params.channel)
|
? normalizeChannelId(params.channel)
|
||||||
@ -120,7 +132,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
|||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
throw new Error(`Unknown channel: ${channel}`);
|
throw new Error(`Unknown channel: ${channel}`);
|
||||||
}
|
}
|
||||||
const deliveryMode = plugin.outbound?.deliveryMode ?? "direct";
|
const deliveryMode = plugin.outbound?.deliveryMode ?? 'direct';
|
||||||
const normalizedPayloads = normalizeReplyPayloadsForDelivery([
|
const normalizedPayloads = normalizeReplyPayloadsForDelivery([
|
||||||
{
|
{
|
||||||
text: params.content,
|
text: params.content,
|
||||||
@ -131,9 +143,10 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
|||||||
const mirrorText = normalizedPayloads
|
const mirrorText = normalizedPayloads
|
||||||
.map((payload) => payload.text)
|
.map((payload) => payload.text)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join('\n');
|
||||||
const mirrorMediaUrls = normalizedPayloads.flatMap(
|
const mirrorMediaUrls = normalizedPayloads.flatMap(
|
||||||
(payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
|
(payload) =>
|
||||||
|
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : [])
|
||||||
);
|
);
|
||||||
const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null;
|
const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null;
|
||||||
|
|
||||||
@ -141,21 +154,21 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
|||||||
return {
|
return {
|
||||||
channel,
|
channel,
|
||||||
to: params.to,
|
to: params.to,
|
||||||
via: deliveryMode === "gateway" ? "gateway" : "direct",
|
via: deliveryMode === 'gateway' ? 'gateway' : 'direct',
|
||||||
mediaUrl: primaryMediaUrl,
|
mediaUrl: primaryMediaUrl,
|
||||||
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
|
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deliveryMode !== "gateway") {
|
if (deliveryMode !== 'gateway') {
|
||||||
const outboundChannel = channel as Exclude<OutboundChannel, "none">;
|
const outboundChannel = channel as Exclude<OutboundChannel, 'none'>;
|
||||||
const resolvedTarget = resolveOutboundTarget({
|
const resolvedTarget = resolveOutboundTarget({
|
||||||
channel: outboundChannel,
|
channel: outboundChannel,
|
||||||
to: params.to,
|
to: params.to,
|
||||||
cfg,
|
cfg,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
mode: "explicit",
|
mode: 'explicit',
|
||||||
});
|
});
|
||||||
if (!resolvedTarget.ok) throw resolvedTarget.error;
|
if (!resolvedTarget.ok) throw resolvedTarget.error;
|
||||||
|
|
||||||
@ -169,6 +182,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
|||||||
deps: params.deps,
|
deps: params.deps,
|
||||||
bestEffort: params.bestEffort,
|
bestEffort: params.bestEffort,
|
||||||
abortSignal: params.abortSignal,
|
abortSignal: params.abortSignal,
|
||||||
|
location: params.location,
|
||||||
mirror: params.mirror
|
mirror: params.mirror
|
||||||
? {
|
? {
|
||||||
...params.mirror,
|
...params.mirror,
|
||||||
@ -181,7 +195,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
|||||||
return {
|
return {
|
||||||
channel,
|
channel,
|
||||||
to: params.to,
|
to: params.to,
|
||||||
via: "direct",
|
via: 'direct',
|
||||||
mediaUrl: primaryMediaUrl,
|
mediaUrl: primaryMediaUrl,
|
||||||
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
|
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
|
||||||
result: results.at(-1),
|
result: results.at(-1),
|
||||||
@ -192,7 +206,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
|||||||
const result = await callGateway<{ messageId: string }>({
|
const result = await callGateway<{ messageId: string }>({
|
||||||
url: gateway.url,
|
url: gateway.url,
|
||||||
token: gateway.token,
|
token: gateway.token,
|
||||||
method: "send",
|
method: 'send',
|
||||||
params: {
|
params: {
|
||||||
to: params.to,
|
to: params.to,
|
||||||
message: params.content,
|
message: params.content,
|
||||||
@ -213,14 +227,16 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
|||||||
return {
|
return {
|
||||||
channel,
|
channel,
|
||||||
to: params.to,
|
to: params.to,
|
||||||
via: "gateway",
|
via: 'gateway',
|
||||||
mediaUrl: primaryMediaUrl,
|
mediaUrl: primaryMediaUrl,
|
||||||
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
|
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
|
||||||
result,
|
result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendPoll(params: MessagePollParams): Promise<MessagePollResult> {
|
export async function sendPoll(
|
||||||
|
params: MessagePollParams
|
||||||
|
): Promise<MessagePollResult> {
|
||||||
const cfg = params.cfg ?? loadConfig();
|
const cfg = params.cfg ?? loadConfig();
|
||||||
const channel = params.channel?.trim()
|
const channel = params.channel?.trim()
|
||||||
? normalizeChannelId(params.channel)
|
? normalizeChannelId(params.channel)
|
||||||
@ -252,7 +268,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
|||||||
options: normalized.options,
|
options: normalized.options,
|
||||||
maxSelections: normalized.maxSelections,
|
maxSelections: normalized.maxSelections,
|
||||||
durationHours: normalized.durationHours ?? null,
|
durationHours: normalized.durationHours ?? null,
|
||||||
via: "gateway",
|
via: 'gateway',
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -267,7 +283,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
|||||||
}>({
|
}>({
|
||||||
url: gateway.url,
|
url: gateway.url,
|
||||||
token: gateway.token,
|
token: gateway.token,
|
||||||
method: "poll",
|
method: 'poll',
|
||||||
params: {
|
params: {
|
||||||
to: params.to,
|
to: params.to,
|
||||||
question: normalized.question,
|
question: normalized.question,
|
||||||
@ -290,7 +306,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
|||||||
options: normalized.options,
|
options: normalized.options,
|
||||||
maxSelections: normalized.maxSelections,
|
maxSelections: normalized.maxSelections,
|
||||||
durationHours: normalized.durationHours ?? null,
|
durationHours: normalized.durationHours ?? null,
|
||||||
via: "gateway",
|
via: 'gateway',
|
||||||
result,
|
result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from '@mariozechner/pi-agent-core';
|
||||||
import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js";
|
import { dispatchChannelMessageAction } from '../../channels/plugins/message-actions.js';
|
||||||
import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js";
|
import type {
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
ChannelId,
|
||||||
import { appendAssistantMessageToSessionTranscript } from "../../config/sessions.js";
|
ChannelThreadingToolContext,
|
||||||
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js";
|
} from '../../channels/plugins/types.js';
|
||||||
import type { OutboundSendDeps } from "./deliver.js";
|
import type { ClawdbotConfig } from '../../config/config.js';
|
||||||
import type { MessagePollResult, MessageSendResult } from "./message.js";
|
import { appendAssistantMessageToSessionTranscript } from '../../config/sessions.js';
|
||||||
import { sendMessage, sendPoll } from "./message.js";
|
import type {
|
||||||
|
GatewayClientMode,
|
||||||
|
GatewayClientName,
|
||||||
|
} from '../../utils/message-channel.js';
|
||||||
|
import type { OutboundSendDeps } from './deliver.js';
|
||||||
|
import type { MessagePollResult, MessageSendResult } from './message.js';
|
||||||
|
import { sendMessage, sendPoll } from './message.js';
|
||||||
|
|
||||||
export type OutboundGatewayContext = {
|
export type OutboundGatewayContext = {
|
||||||
url?: string;
|
url?: string;
|
||||||
@ -41,9 +47,9 @@ function extractToolPayload(result: AgentToolResult<unknown>): unknown {
|
|||||||
? result.content.find(
|
? result.content.find(
|
||||||
(block) =>
|
(block) =>
|
||||||
block &&
|
block &&
|
||||||
typeof block === "object" &&
|
typeof block === 'object' &&
|
||||||
(block as { type?: unknown }).type === "text" &&
|
(block as { type?: unknown }).type === 'text' &&
|
||||||
typeof (block as { text?: unknown }).text === "string",
|
typeof (block as { text?: unknown }).text === 'string'
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const text = (textBlock as { text?: string } | undefined)?.text;
|
const text = (textBlock as { text?: string } | undefined)?.text;
|
||||||
@ -59,8 +65,8 @@ function extractToolPayload(result: AgentToolResult<unknown>): unknown {
|
|||||||
|
|
||||||
function throwIfAborted(abortSignal?: AbortSignal): void {
|
function throwIfAborted(abortSignal?: AbortSignal): void {
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
const err = new Error("Message send aborted");
|
const err = new Error('Message send aborted');
|
||||||
err.name = "AbortError";
|
err.name = 'AbortError';
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -73,8 +79,15 @@ export async function executeSendAction(params: {
|
|||||||
mediaUrls?: string[];
|
mediaUrls?: string[];
|
||||||
gifPlayback?: boolean;
|
gifPlayback?: boolean;
|
||||||
bestEffort?: boolean;
|
bestEffort?: boolean;
|
||||||
|
location?: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
accuracy?: number;
|
||||||
|
};
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
handledBy: "plugin" | "core";
|
handledBy: 'plugin' | 'core';
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
toolResult?: AgentToolResult<unknown>;
|
toolResult?: AgentToolResult<unknown>;
|
||||||
sendResult?: MessageSendResult;
|
sendResult?: MessageSendResult;
|
||||||
@ -83,7 +96,7 @@ export async function executeSendAction(params: {
|
|||||||
if (!params.ctx.dryRun) {
|
if (!params.ctx.dryRun) {
|
||||||
const handled = await dispatchChannelMessageAction({
|
const handled = await dispatchChannelMessageAction({
|
||||||
channel: params.ctx.channel,
|
channel: params.ctx.channel,
|
||||||
action: "send",
|
action: 'send',
|
||||||
cfg: params.ctx.cfg,
|
cfg: params.ctx.cfg,
|
||||||
params: params.ctx.params,
|
params: params.ctx.params,
|
||||||
accountId: params.ctx.accountId ?? undefined,
|
accountId: params.ctx.accountId ?? undefined,
|
||||||
@ -106,7 +119,7 @@ export async function executeSendAction(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
handledBy: "plugin",
|
handledBy: 'plugin',
|
||||||
payload: extractToolPayload(handled),
|
payload: extractToolPayload(handled),
|
||||||
toolResult: handled,
|
toolResult: handled,
|
||||||
};
|
};
|
||||||
@ -125,6 +138,7 @@ export async function executeSendAction(params: {
|
|||||||
gifPlayback: params.gifPlayback,
|
gifPlayback: params.gifPlayback,
|
||||||
dryRun: params.ctx.dryRun,
|
dryRun: params.ctx.dryRun,
|
||||||
bestEffort: params.bestEffort ?? undefined,
|
bestEffort: params.bestEffort ?? undefined,
|
||||||
|
location: params.location,
|
||||||
deps: params.ctx.deps,
|
deps: params.ctx.deps,
|
||||||
gateway: params.ctx.gateway,
|
gateway: params.ctx.gateway,
|
||||||
mirror: params.ctx.mirror,
|
mirror: params.ctx.mirror,
|
||||||
@ -132,7 +146,7 @@ export async function executeSendAction(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handledBy: "core",
|
handledBy: 'core',
|
||||||
payload: result,
|
payload: result,
|
||||||
sendResult: result,
|
sendResult: result,
|
||||||
};
|
};
|
||||||
@ -146,7 +160,7 @@ export async function executePollAction(params: {
|
|||||||
maxSelections: number;
|
maxSelections: number;
|
||||||
durationHours?: number;
|
durationHours?: number;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
handledBy: "plugin" | "core";
|
handledBy: 'plugin' | 'core';
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
toolResult?: AgentToolResult<unknown>;
|
toolResult?: AgentToolResult<unknown>;
|
||||||
pollResult?: MessagePollResult;
|
pollResult?: MessagePollResult;
|
||||||
@ -154,7 +168,7 @@ export async function executePollAction(params: {
|
|||||||
if (!params.ctx.dryRun) {
|
if (!params.ctx.dryRun) {
|
||||||
const handled = await dispatchChannelMessageAction({
|
const handled = await dispatchChannelMessageAction({
|
||||||
channel: params.ctx.channel,
|
channel: params.ctx.channel,
|
||||||
action: "poll",
|
action: 'poll',
|
||||||
cfg: params.ctx.cfg,
|
cfg: params.ctx.cfg,
|
||||||
params: params.ctx.params,
|
params: params.ctx.params,
|
||||||
accountId: params.ctx.accountId ?? undefined,
|
accountId: params.ctx.accountId ?? undefined,
|
||||||
@ -164,7 +178,7 @@ export async function executePollAction(params: {
|
|||||||
});
|
});
|
||||||
if (handled) {
|
if (handled) {
|
||||||
return {
|
return {
|
||||||
handledBy: "plugin",
|
handledBy: 'plugin',
|
||||||
payload: extractToolPayload(handled),
|
payload: extractToolPayload(handled),
|
||||||
toolResult: handled,
|
toolResult: handled,
|
||||||
};
|
};
|
||||||
@ -184,7 +198,7 @@ export async function executePollAction(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handledBy: "core",
|
handledBy: 'core',
|
||||||
payload: result,
|
payload: result,
|
||||||
pollResult: result,
|
pollResult: result,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from '../cli/command-format.js';
|
||||||
import type { PollInput } from "../polls.js";
|
import type { PollInput } from '../polls.js';
|
||||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
import { DEFAULT_ACCOUNT_ID } from '../routing/session-key.js';
|
||||||
|
|
||||||
export type ActiveWebSendOptions = {
|
export type ActiveWebSendOptions = {
|
||||||
gifPlayback?: boolean;
|
gifPlayback?: boolean;
|
||||||
@ -14,6 +14,13 @@ export type ActiveWebListener = {
|
|||||||
mediaBuffer?: Buffer,
|
mediaBuffer?: Buffer,
|
||||||
mediaType?: string,
|
mediaType?: string,
|
||||||
options?: ActiveWebSendOptions,
|
options?: ActiveWebSendOptions,
|
||||||
|
location?: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
accuracy?: number;
|
||||||
|
}
|
||||||
) => Promise<{ messageId: string }>;
|
) => Promise<{ messageId: string }>;
|
||||||
sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>;
|
sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>;
|
||||||
sendReaction: (
|
sendReaction: (
|
||||||
@ -21,7 +28,7 @@ export type ActiveWebListener = {
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
emoji: string,
|
emoji: string,
|
||||||
fromMe: boolean,
|
fromMe: boolean,
|
||||||
participant?: string,
|
participant?: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
sendComposingTo: (to: string) => Promise<void>;
|
sendComposingTo: (to: string) => Promise<void>;
|
||||||
close?: () => Promise<void>;
|
close?: () => Promise<void>;
|
||||||
@ -32,7 +39,7 @@ let _currentListener: ActiveWebListener | null = null;
|
|||||||
const listeners = new Map<string, ActiveWebListener>();
|
const listeners = new Map<string, ActiveWebListener>();
|
||||||
|
|
||||||
export function resolveWebAccountId(accountId?: string | null): string {
|
export function resolveWebAccountId(accountId?: string | null): string {
|
||||||
return (accountId ?? "").trim() || DEFAULT_ACCOUNT_ID;
|
return (accountId ?? '').trim() || DEFAULT_ACCOUNT_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireActiveWebListener(accountId?: string | null): {
|
export function requireActiveWebListener(accountId?: string | null): {
|
||||||
@ -43,7 +50,7 @@ export function requireActiveWebListener(accountId?: string | null): {
|
|||||||
const listener = listeners.get(id) ?? null;
|
const listener = listeners.get(id) ?? null;
|
||||||
if (!listener) {
|
if (!listener) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No active WhatsApp Web listener (account: ${id}). Start the gateway, then link WhatsApp with: ${formatCliCommand(`clawdbot channels login --channel whatsapp --account ${id}`)}.`,
|
`No active WhatsApp Web listener (account: ${id}). Start the gateway, then link WhatsApp with: ${formatCliCommand(`clawdbot channels login --channel whatsapp --account ${id}`)}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { accountId: id, listener };
|
return { accountId: id, listener };
|
||||||
@ -52,14 +59,14 @@ export function requireActiveWebListener(accountId?: string | null): {
|
|||||||
export function setActiveWebListener(listener: ActiveWebListener | null): void;
|
export function setActiveWebListener(listener: ActiveWebListener | null): void;
|
||||||
export function setActiveWebListener(
|
export function setActiveWebListener(
|
||||||
accountId: string | null | undefined,
|
accountId: string | null | undefined,
|
||||||
listener: ActiveWebListener | null,
|
listener: ActiveWebListener | null
|
||||||
): void;
|
): void;
|
||||||
export function setActiveWebListener(
|
export function setActiveWebListener(
|
||||||
accountIdOrListener: string | ActiveWebListener | null | undefined,
|
accountIdOrListener: string | ActiveWebListener | null | undefined,
|
||||||
maybeListener?: ActiveWebListener | null,
|
maybeListener?: ActiveWebListener | null
|
||||||
): void {
|
): void {
|
||||||
const { accountId, listener } =
|
const { accountId, listener } =
|
||||||
typeof accountIdOrListener === "string"
|
typeof accountIdOrListener === 'string'
|
||||||
? { accountId: accountIdOrListener, listener: maybeListener ?? null }
|
? { accountId: accountIdOrListener, listener: maybeListener ?? null }
|
||||||
: {
|
: {
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
@ -77,7 +84,9 @@ export function setActiveWebListener(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveWebListener(accountId?: string | null): ActiveWebListener | null {
|
export function getActiveWebListener(
|
||||||
|
accountId?: string | null
|
||||||
|
): ActiveWebListener | null {
|
||||||
const id = resolveWebAccountId(accountId);
|
const id = resolveWebAccountId(accountId);
|
||||||
return listeners.get(id) ?? null;
|
return listeners.get(id) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import type { AnyMessageContent, WAPresence } from "@whiskeysockets/baileys";
|
import type { AnyMessageContent, WAPresence } from '@whiskeysockets/baileys';
|
||||||
import { recordChannelActivity } from "../../infra/channel-activity.js";
|
import { recordChannelActivity } from '../../infra/channel-activity.js';
|
||||||
import { toWhatsappJid } from "../../utils.js";
|
import { toWhatsappJid } from '../../utils.js';
|
||||||
import type { ActiveWebSendOptions } from "../active-listener.js";
|
import type { ActiveWebSendOptions } from '../active-listener.js';
|
||||||
|
|
||||||
export function createWebSendApi(params: {
|
export function createWebSendApi(params: {
|
||||||
sock: {
|
sock: {
|
||||||
sendMessage: (jid: string, content: AnyMessageContent) => Promise<unknown>;
|
sendMessage: (jid: string, content: AnyMessageContent) => Promise<unknown>;
|
||||||
sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>;
|
sendPresenceUpdate: (
|
||||||
|
presence: WAPresence,
|
||||||
|
jid?: string
|
||||||
|
) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
defaultAccountId: string;
|
defaultAccountId: string;
|
||||||
}) {
|
}) {
|
||||||
@ -17,19 +20,38 @@ export function createWebSendApi(params: {
|
|||||||
mediaBuffer?: Buffer,
|
mediaBuffer?: Buffer,
|
||||||
mediaType?: string,
|
mediaType?: string,
|
||||||
sendOptions?: ActiveWebSendOptions,
|
sendOptions?: ActiveWebSendOptions,
|
||||||
|
location?: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
accuracy?: number;
|
||||||
|
}
|
||||||
): Promise<{ messageId: string }> => {
|
): Promise<{ messageId: string }> => {
|
||||||
const jid = toWhatsappJid(to);
|
const jid = toWhatsappJid(to);
|
||||||
let payload: AnyMessageContent;
|
let payload: AnyMessageContent;
|
||||||
if (mediaBuffer && mediaType) {
|
|
||||||
if (mediaType.startsWith("image/")) {
|
// Location messages take precedence
|
||||||
|
if (location) {
|
||||||
|
payload = {
|
||||||
|
location: {
|
||||||
|
degreesLatitude: location.latitude,
|
||||||
|
degreesLongitude: location.longitude,
|
||||||
|
name: location.name,
|
||||||
|
address: location.address,
|
||||||
|
accuracyInMeters: location.accuracy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (mediaBuffer && mediaType) {
|
||||||
|
if (mediaType.startsWith('image/')) {
|
||||||
payload = {
|
payload = {
|
||||||
image: mediaBuffer,
|
image: mediaBuffer,
|
||||||
caption: text || undefined,
|
caption: text || undefined,
|
||||||
mimetype: mediaType,
|
mimetype: mediaType,
|
||||||
};
|
};
|
||||||
} else if (mediaType.startsWith("audio/")) {
|
} else if (mediaType.startsWith('audio/')) {
|
||||||
payload = { audio: mediaBuffer, ptt: true, mimetype: mediaType };
|
payload = { audio: mediaBuffer, ptt: true, mimetype: mediaType };
|
||||||
} else if (mediaType.startsWith("video/")) {
|
} else if (mediaType.startsWith('video/')) {
|
||||||
const gifPlayback = sendOptions?.gifPlayback;
|
const gifPlayback = sendOptions?.gifPlayback;
|
||||||
payload = {
|
payload = {
|
||||||
video: mediaBuffer,
|
video: mediaBuffer,
|
||||||
@ -40,7 +62,7 @@ export function createWebSendApi(params: {
|
|||||||
} else {
|
} else {
|
||||||
payload = {
|
payload = {
|
||||||
document: mediaBuffer,
|
document: mediaBuffer,
|
||||||
fileName: "file",
|
fileName: 'file',
|
||||||
caption: text || undefined,
|
caption: text || undefined,
|
||||||
mimetype: mediaType,
|
mimetype: mediaType,
|
||||||
};
|
};
|
||||||
@ -51,19 +73,19 @@ export function createWebSendApi(params: {
|
|||||||
const result = await params.sock.sendMessage(jid, payload);
|
const result = await params.sock.sendMessage(jid, payload);
|
||||||
const accountId = sendOptions?.accountId ?? params.defaultAccountId;
|
const accountId = sendOptions?.accountId ?? params.defaultAccountId;
|
||||||
recordChannelActivity({
|
recordChannelActivity({
|
||||||
channel: "whatsapp",
|
channel: 'whatsapp',
|
||||||
accountId,
|
accountId,
|
||||||
direction: "outbound",
|
direction: 'outbound',
|
||||||
});
|
});
|
||||||
const messageId =
|
const messageId =
|
||||||
typeof result === "object" && result && "key" in result
|
typeof result === 'object' && result && 'key' in result
|
||||||
? String((result as { key?: { id?: string } }).key?.id ?? "unknown")
|
? String((result as { key?: { id?: string } }).key?.id ?? 'unknown')
|
||||||
: "unknown";
|
: 'unknown';
|
||||||
return { messageId };
|
return { messageId };
|
||||||
},
|
},
|
||||||
sendPoll: async (
|
sendPoll: async (
|
||||||
to: string,
|
to: string,
|
||||||
poll: { question: string; options: string[]; maxSelections?: number },
|
poll: { question: string; options: string[]; maxSelections?: number }
|
||||||
): Promise<{ messageId: string }> => {
|
): Promise<{ messageId: string }> => {
|
||||||
const jid = toWhatsappJid(to);
|
const jid = toWhatsappJid(to);
|
||||||
const result = await params.sock.sendMessage(jid, {
|
const result = await params.sock.sendMessage(jid, {
|
||||||
@ -74,14 +96,14 @@ export function createWebSendApi(params: {
|
|||||||
},
|
},
|
||||||
} as AnyMessageContent);
|
} as AnyMessageContent);
|
||||||
recordChannelActivity({
|
recordChannelActivity({
|
||||||
channel: "whatsapp",
|
channel: 'whatsapp',
|
||||||
accountId: params.defaultAccountId,
|
accountId: params.defaultAccountId,
|
||||||
direction: "outbound",
|
direction: 'outbound',
|
||||||
});
|
});
|
||||||
const messageId =
|
const messageId =
|
||||||
typeof result === "object" && result && "key" in result
|
typeof result === 'object' && result && 'key' in result
|
||||||
? String((result as { key?: { id?: string } }).key?.id ?? "unknown")
|
? String((result as { key?: { id?: string } }).key?.id ?? 'unknown')
|
||||||
: "unknown";
|
: 'unknown';
|
||||||
return { messageId };
|
return { messageId };
|
||||||
},
|
},
|
||||||
sendReaction: async (
|
sendReaction: async (
|
||||||
@ -89,7 +111,7 @@ export function createWebSendApi(params: {
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
emoji: string,
|
emoji: string,
|
||||||
fromMe: boolean,
|
fromMe: boolean,
|
||||||
participant?: string,
|
participant?: string
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const jid = toWhatsappJid(chatJid);
|
const jid = toWhatsappJid(chatJid);
|
||||||
await params.sock.sendMessage(jid, {
|
await params.sock.sendMessage(jid, {
|
||||||
@ -106,7 +128,7 @@ export function createWebSendApi(params: {
|
|||||||
},
|
},
|
||||||
sendComposingTo: async (to: string): Promise<void> => {
|
sendComposingTo: async (to: string): Promise<void> => {
|
||||||
const jid = toWhatsappJid(to);
|
const jid = toWhatsappJid(to);
|
||||||
await params.sock.sendPresenceUpdate("composing", jid);
|
await params.sock.sendPresenceUpdate('composing', jid);
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,23 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
import { resetLogger, setLoggerOverride } from '../logging.js';
|
||||||
import { setActiveWebListener } from "./active-listener.js";
|
import { setActiveWebListener } from './active-listener.js';
|
||||||
|
|
||||||
const loadWebMediaMock = vi.fn();
|
const loadWebMediaMock = vi.fn();
|
||||||
vi.mock("./media.js", () => ({
|
vi.mock('./media.js', () => ({
|
||||||
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } from "./outbound.js";
|
import {
|
||||||
|
sendMessageWhatsApp,
|
||||||
|
sendPollWhatsApp,
|
||||||
|
sendReactionWhatsApp,
|
||||||
|
} from './outbound.js';
|
||||||
|
|
||||||
describe("web outbound", () => {
|
describe('web outbound', () => {
|
||||||
const sendComposingTo = vi.fn(async () => {});
|
const sendComposingTo = vi.fn(async () => {});
|
||||||
const sendMessage = vi.fn(async () => ({ messageId: "msg123" }));
|
const sendMessage = vi.fn(async () => ({ messageId: 'msg123' }));
|
||||||
const sendPoll = vi.fn(async () => ({ messageId: "poll123" }));
|
const sendPoll = vi.fn(async () => ({ messageId: 'poll123' }));
|
||||||
const sendReaction = vi.fn(async () => {});
|
const sendReaction = vi.fn(async () => {});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -32,137 +36,197 @@ describe("web outbound", () => {
|
|||||||
setActiveWebListener(null);
|
setActiveWebListener(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends message via active listener", async () => {
|
it('sends message via active listener', async () => {
|
||||||
const result = await sendMessageWhatsApp("+1555", "hi", { verbose: false });
|
const result = await sendMessageWhatsApp('+1555', 'hi', { verbose: false });
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
messageId: "msg123",
|
messageId: 'msg123',
|
||||||
toJid: "1555@s.whatsapp.net",
|
toJid: '1555@s.whatsapp.net',
|
||||||
});
|
});
|
||||||
expect(sendComposingTo).toHaveBeenCalledWith("+1555");
|
expect(sendComposingTo).toHaveBeenCalledWith('+1555');
|
||||||
expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined);
|
expect(sendMessage).toHaveBeenCalledWith(
|
||||||
|
'+1555',
|
||||||
|
'hi',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws a helpful error when no active listener exists", async () => {
|
it('throws a helpful error when no active listener exists', async () => {
|
||||||
setActiveWebListener(null);
|
setActiveWebListener(null);
|
||||||
await expect(
|
await expect(
|
||||||
sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }),
|
sendMessageWhatsApp('+1555', 'hi', { verbose: false, accountId: 'work' })
|
||||||
).rejects.toThrow(/No active WhatsApp Web listener/);
|
).rejects.toThrow(/No active WhatsApp Web listener/);
|
||||||
await expect(
|
await expect(
|
||||||
sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }),
|
sendMessageWhatsApp('+1555', 'hi', { verbose: false, accountId: 'work' })
|
||||||
).rejects.toThrow(/channels login/);
|
).rejects.toThrow(/channels login/);
|
||||||
await expect(
|
await expect(
|
||||||
sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }),
|
sendMessageWhatsApp('+1555', 'hi', { verbose: false, accountId: 'work' })
|
||||||
).rejects.toThrow(/account: work/);
|
).rejects.toThrow(/account: work/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps audio to PTT with opus mime when ogg", async () => {
|
it('maps audio to PTT with opus mime when ogg', async () => {
|
||||||
const buf = Buffer.from("audio");
|
const buf = Buffer.from('audio');
|
||||||
loadWebMediaMock.mockResolvedValueOnce({
|
loadWebMediaMock.mockResolvedValueOnce({
|
||||||
buffer: buf,
|
buffer: buf,
|
||||||
contentType: "audio/ogg",
|
contentType: 'audio/ogg',
|
||||||
kind: "audio",
|
kind: 'audio',
|
||||||
});
|
});
|
||||||
await sendMessageWhatsApp("+1555", "voice note", {
|
await sendMessageWhatsApp('+1555', 'voice note', {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: "/tmp/voice.ogg",
|
mediaUrl: '/tmp/voice.ogg',
|
||||||
});
|
});
|
||||||
expect(sendMessage).toHaveBeenLastCalledWith(
|
expect(sendMessage).toHaveBeenLastCalledWith(
|
||||||
"+1555",
|
'+1555',
|
||||||
"voice note",
|
'voice note',
|
||||||
buf,
|
buf,
|
||||||
"audio/ogg; codecs=opus",
|
'audio/ogg; codecs=opus',
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps video with caption", async () => {
|
it('maps video with caption', async () => {
|
||||||
const buf = Buffer.from("video");
|
const buf = Buffer.from('video');
|
||||||
loadWebMediaMock.mockResolvedValueOnce({
|
loadWebMediaMock.mockResolvedValueOnce({
|
||||||
buffer: buf,
|
buffer: buf,
|
||||||
contentType: "video/mp4",
|
contentType: 'video/mp4',
|
||||||
kind: "video",
|
kind: 'video',
|
||||||
});
|
});
|
||||||
await sendMessageWhatsApp("+1555", "clip", {
|
await sendMessageWhatsApp('+1555', 'clip', {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: "/tmp/video.mp4",
|
mediaUrl: '/tmp/video.mp4',
|
||||||
});
|
});
|
||||||
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "clip", buf, "video/mp4");
|
expect(sendMessage).toHaveBeenLastCalledWith(
|
||||||
|
'+1555',
|
||||||
|
'clip',
|
||||||
|
buf,
|
||||||
|
'video/mp4',
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks gif playback for video when requested", async () => {
|
it('marks gif playback for video when requested', async () => {
|
||||||
const buf = Buffer.from("gifvid");
|
const buf = Buffer.from('gifvid');
|
||||||
loadWebMediaMock.mockResolvedValueOnce({
|
loadWebMediaMock.mockResolvedValueOnce({
|
||||||
buffer: buf,
|
buffer: buf,
|
||||||
contentType: "video/mp4",
|
contentType: 'video/mp4',
|
||||||
kind: "video",
|
kind: 'video',
|
||||||
});
|
});
|
||||||
await sendMessageWhatsApp("+1555", "gif", {
|
await sendMessageWhatsApp('+1555', 'gif', {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: "/tmp/anim.mp4",
|
mediaUrl: '/tmp/anim.mp4',
|
||||||
gifPlayback: true,
|
gifPlayback: true,
|
||||||
});
|
});
|
||||||
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "gif", buf, "video/mp4", {
|
expect(sendMessage).toHaveBeenLastCalledWith(
|
||||||
|
'+1555',
|
||||||
|
'gif',
|
||||||
|
buf,
|
||||||
|
'video/mp4',
|
||||||
|
{
|
||||||
gifPlayback: true,
|
gifPlayback: true,
|
||||||
});
|
accountId: undefined,
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps image with caption", async () => {
|
it('maps image with caption', async () => {
|
||||||
const buf = Buffer.from("img");
|
const buf = Buffer.from('img');
|
||||||
loadWebMediaMock.mockResolvedValueOnce({
|
loadWebMediaMock.mockResolvedValueOnce({
|
||||||
buffer: buf,
|
buffer: buf,
|
||||||
contentType: "image/jpeg",
|
contentType: 'image/jpeg',
|
||||||
kind: "image",
|
kind: 'image',
|
||||||
});
|
});
|
||||||
await sendMessageWhatsApp("+1555", "pic", {
|
await sendMessageWhatsApp('+1555', 'pic', {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: "/tmp/pic.jpg",
|
mediaUrl: '/tmp/pic.jpg',
|
||||||
});
|
});
|
||||||
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "pic", buf, "image/jpeg");
|
expect(sendMessage).toHaveBeenLastCalledWith(
|
||||||
|
'+1555',
|
||||||
|
'pic',
|
||||||
|
buf,
|
||||||
|
'image/jpeg',
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps other kinds to document with filename", async () => {
|
it('maps other kinds to document with filename', async () => {
|
||||||
const buf = Buffer.from("pdf");
|
const buf = Buffer.from('pdf');
|
||||||
loadWebMediaMock.mockResolvedValueOnce({
|
loadWebMediaMock.mockResolvedValueOnce({
|
||||||
buffer: buf,
|
buffer: buf,
|
||||||
contentType: "application/pdf",
|
contentType: 'application/pdf',
|
||||||
kind: "document",
|
kind: 'document',
|
||||||
fileName: "file.pdf",
|
fileName: 'file.pdf',
|
||||||
});
|
});
|
||||||
await sendMessageWhatsApp("+1555", "doc", {
|
await sendMessageWhatsApp('+1555', 'doc', {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: "/tmp/file.pdf",
|
mediaUrl: '/tmp/file.pdf',
|
||||||
});
|
});
|
||||||
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "doc", buf, "application/pdf");
|
expect(sendMessage).toHaveBeenLastCalledWith(
|
||||||
|
'+1555',
|
||||||
|
'doc',
|
||||||
|
buf,
|
||||||
|
'application/pdf',
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends polls via active listener", async () => {
|
it('sends location via active listener', async () => {
|
||||||
|
const location = {
|
||||||
|
latitude: 33.538368,
|
||||||
|
longitude: -7.760028,
|
||||||
|
name: 'Home',
|
||||||
|
address: 'Some Address',
|
||||||
|
accuracy: 12,
|
||||||
|
};
|
||||||
|
await sendMessageWhatsApp('+1555', 'pin', {
|
||||||
|
verbose: false,
|
||||||
|
location,
|
||||||
|
});
|
||||||
|
expect(sendMessage).toHaveBeenLastCalledWith(
|
||||||
|
'+1555',
|
||||||
|
'pin',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
location
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends polls via active listener', async () => {
|
||||||
const result = await sendPollWhatsApp(
|
const result = await sendPollWhatsApp(
|
||||||
"+1555",
|
'+1555',
|
||||||
{ question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 2 },
|
{ question: 'Lunch?', options: ['Pizza', 'Sushi'], maxSelections: 2 },
|
||||||
{ verbose: false },
|
{ verbose: false }
|
||||||
);
|
);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
messageId: "poll123",
|
messageId: 'poll123',
|
||||||
toJid: "1555@s.whatsapp.net",
|
toJid: '1555@s.whatsapp.net',
|
||||||
});
|
});
|
||||||
expect(sendPoll).toHaveBeenCalledWith("+1555", {
|
expect(sendPoll).toHaveBeenCalledWith('+1555', {
|
||||||
question: "Lunch?",
|
question: 'Lunch?',
|
||||||
options: ["Pizza", "Sushi"],
|
options: ['Pizza', 'Sushi'],
|
||||||
maxSelections: 2,
|
maxSelections: 2,
|
||||||
durationHours: undefined,
|
durationHours: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends reactions via active listener", async () => {
|
it('sends reactions via active listener', async () => {
|
||||||
await sendReactionWhatsApp("1555@s.whatsapp.net", "msg123", "✅", {
|
await sendReactionWhatsApp('1555@s.whatsapp.net', 'msg123', '✅', {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
fromMe: false,
|
fromMe: false,
|
||||||
});
|
});
|
||||||
expect(sendReaction).toHaveBeenCalledWith(
|
expect(sendReaction).toHaveBeenCalledWith(
|
||||||
"1555@s.whatsapp.net",
|
'1555@s.whatsapp.net',
|
||||||
"msg123",
|
'msg123',
|
||||||
"✅",
|
'✅',
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,16 +1,21 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { getChildLogger } from "../logging/logger.js";
|
import { loadConfig } from '../config/config.js';
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { resolveMarkdownTableMode } from '../config/markdown-tables.js';
|
||||||
import { normalizePollInput, type PollInput } from "../polls.js";
|
import { getChildLogger } from '../logging/logger.js';
|
||||||
import { toWhatsappJid } from "../utils.js";
|
import { createSubsystemLogger } from '../logging/subsystem.js';
|
||||||
import { loadConfig } from "../config/config.js";
|
import { convertMarkdownTables } from '../markdown/tables.js';
|
||||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
import { normalizePollInput, type PollInput } from '../polls.js';
|
||||||
import { convertMarkdownTables } from "../markdown/tables.js";
|
import { toWhatsappJid } from '../utils.js';
|
||||||
import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js";
|
import {
|
||||||
import { loadWebMedia } from "./media.js";
|
type ActiveWebSendOptions,
|
||||||
|
requireActiveWebListener,
|
||||||
|
} from './active-listener.js';
|
||||||
|
import { loadWebMedia } from './media.js';
|
||||||
|
|
||||||
const outboundLog = createSubsystemLogger("gateway/channels/whatsapp").child("outbound");
|
const outboundLog = createSubsystemLogger('gateway/channels/whatsapp').child(
|
||||||
|
'outbound'
|
||||||
|
);
|
||||||
|
|
||||||
export async function sendMessageWhatsApp(
|
export async function sendMessageWhatsApp(
|
||||||
to: string,
|
to: string,
|
||||||
@ -20,23 +25,29 @@ export async function sendMessageWhatsApp(
|
|||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
gifPlayback?: boolean;
|
gifPlayback?: boolean;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
},
|
location?: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
accuracy?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
): Promise<{ messageId: string; toJid: string }> {
|
): Promise<{ messageId: string; toJid: string }> {
|
||||||
let text = body;
|
let text = body;
|
||||||
const correlationId = randomUUID();
|
const correlationId = randomUUID();
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener(
|
const { listener: active, accountId: resolvedAccountId } =
|
||||||
options.accountId,
|
requireActiveWebListener(options.accountId);
|
||||||
);
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const tableMode = resolveMarkdownTableMode({
|
const tableMode = resolveMarkdownTableMode({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "whatsapp",
|
channel: 'whatsapp',
|
||||||
accountId: resolvedAccountId ?? options.accountId,
|
accountId: resolvedAccountId ?? options.accountId,
|
||||||
});
|
});
|
||||||
text = convertMarkdownTables(text ?? "", tableMode);
|
text = convertMarkdownTables(text ?? '', tableMode);
|
||||||
const logger = getChildLogger({
|
const logger = getChildLogger({
|
||||||
module: "web-outbound",
|
module: 'web-outbound',
|
||||||
correlationId,
|
correlationId,
|
||||||
to,
|
to,
|
||||||
});
|
});
|
||||||
@ -49,22 +60,28 @@ export async function sendMessageWhatsApp(
|
|||||||
const caption = text || undefined;
|
const caption = text || undefined;
|
||||||
mediaBuffer = media.buffer;
|
mediaBuffer = media.buffer;
|
||||||
mediaType = media.contentType;
|
mediaType = media.contentType;
|
||||||
if (media.kind === "audio") {
|
if (media.kind === 'audio') {
|
||||||
// WhatsApp expects explicit opus codec for PTT voice notes.
|
// WhatsApp expects explicit opus codec for PTT voice notes.
|
||||||
mediaType =
|
mediaType =
|
||||||
media.contentType === "audio/ogg"
|
media.contentType === 'audio/ogg'
|
||||||
? "audio/ogg; codecs=opus"
|
? 'audio/ogg; codecs=opus'
|
||||||
: (media.contentType ?? "application/octet-stream");
|
: (media.contentType ?? 'application/octet-stream');
|
||||||
} else if (media.kind === "video") {
|
} else if (media.kind === 'video') {
|
||||||
text = caption ?? "";
|
text = caption ?? '';
|
||||||
} else if (media.kind === "image") {
|
} else if (media.kind === 'image') {
|
||||||
text = caption ?? "";
|
text = caption ?? '';
|
||||||
} else {
|
} else {
|
||||||
text = caption ?? "";
|
text = caption ?? '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
outboundLog.info(`Sending message -> ${jid}${options.mediaUrl ? " (media)" : ""}`);
|
const hasLocation = Boolean(options.location);
|
||||||
logger.info({ jid, hasMedia: Boolean(options.mediaUrl) }, "sending message");
|
outboundLog.info(
|
||||||
|
`Sending message -> ${jid}${options.mediaUrl ? ' (media)' : ''}${hasLocation ? ' (location)' : ''}`
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
{ jid, hasMedia: Boolean(options.mediaUrl), hasLocation },
|
||||||
|
'sending message'
|
||||||
|
);
|
||||||
await active.sendComposingTo(to);
|
await active.sendComposingTo(to);
|
||||||
const hasExplicitAccountId = Boolean(options.accountId?.trim());
|
const hasExplicitAccountId = Boolean(options.accountId?.trim());
|
||||||
const accountId = hasExplicitAccountId ? resolvedAccountId : undefined;
|
const accountId = hasExplicitAccountId ? resolvedAccountId : undefined;
|
||||||
@ -76,19 +93,34 @@ export async function sendMessageWhatsApp(
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const result = sendOptions
|
const result = sendOptions
|
||||||
? await active.sendMessage(to, text, mediaBuffer, mediaType, sendOptions)
|
? await active.sendMessage(
|
||||||
: await active.sendMessage(to, text, mediaBuffer, mediaType);
|
to,
|
||||||
const messageId = (result as { messageId?: string })?.messageId ?? "unknown";
|
text,
|
||||||
|
mediaBuffer,
|
||||||
|
mediaType,
|
||||||
|
sendOptions,
|
||||||
|
options.location
|
||||||
|
)
|
||||||
|
: await active.sendMessage(
|
||||||
|
to,
|
||||||
|
text,
|
||||||
|
mediaBuffer,
|
||||||
|
mediaType,
|
||||||
|
undefined,
|
||||||
|
options.location
|
||||||
|
);
|
||||||
|
const messageId =
|
||||||
|
(result as { messageId?: string })?.messageId ?? 'unknown';
|
||||||
const durationMs = Date.now() - startedAt;
|
const durationMs = Date.now() - startedAt;
|
||||||
outboundLog.info(
|
outboundLog.info(
|
||||||
`Sent message ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""} (${durationMs}ms)`,
|
`Sent message ${messageId} -> ${jid}${options.mediaUrl ? ' (media)' : ''}${hasLocation ? ' (location)' : ''} (${durationMs}ms)`
|
||||||
);
|
);
|
||||||
logger.info({ jid, messageId }, "sent message");
|
logger.info({ jid, messageId, hasLocation }, 'sent message');
|
||||||
return { messageId, toJid: jid };
|
return { messageId, toJid: jid };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ err: String(err), to, hasMedia: Boolean(options.mediaUrl) },
|
{ err: String(err), to, hasMedia: Boolean(options.mediaUrl) },
|
||||||
"failed to send via web session",
|
'failed to send via web session'
|
||||||
);
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@ -103,12 +135,12 @@ export async function sendReactionWhatsApp(
|
|||||||
fromMe?: boolean;
|
fromMe?: boolean;
|
||||||
participant?: string;
|
participant?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
},
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const correlationId = randomUUID();
|
const correlationId = randomUUID();
|
||||||
const { listener: active } = requireActiveWebListener(options.accountId);
|
const { listener: active } = requireActiveWebListener(options.accountId);
|
||||||
const logger = getChildLogger({
|
const logger = getChildLogger({
|
||||||
module: "web-outbound",
|
module: 'web-outbound',
|
||||||
correlationId,
|
correlationId,
|
||||||
chatJid,
|
chatJid,
|
||||||
messageId,
|
messageId,
|
||||||
@ -116,20 +148,20 @@ export async function sendReactionWhatsApp(
|
|||||||
try {
|
try {
|
||||||
const jid = toWhatsappJid(chatJid);
|
const jid = toWhatsappJid(chatJid);
|
||||||
outboundLog.info(`Sending reaction "${emoji}" -> message ${messageId}`);
|
outboundLog.info(`Sending reaction "${emoji}" -> message ${messageId}`);
|
||||||
logger.info({ chatJid: jid, messageId, emoji }, "sending reaction");
|
logger.info({ chatJid: jid, messageId, emoji }, 'sending reaction');
|
||||||
await active.sendReaction(
|
await active.sendReaction(
|
||||||
chatJid,
|
chatJid,
|
||||||
messageId,
|
messageId,
|
||||||
emoji,
|
emoji,
|
||||||
options.fromMe ?? false,
|
options.fromMe ?? false,
|
||||||
options.participant,
|
options.participant
|
||||||
);
|
);
|
||||||
outboundLog.info(`Sent reaction "${emoji}" -> message ${messageId}`);
|
outboundLog.info(`Sent reaction "${emoji}" -> message ${messageId}`);
|
||||||
logger.info({ chatJid: jid, messageId, emoji }, "sent reaction");
|
logger.info({ chatJid: jid, messageId, emoji }, 'sent reaction');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ err: String(err), chatJid, messageId, emoji },
|
{ err: String(err), chatJid, messageId, emoji },
|
||||||
"failed to send reaction via web session",
|
'failed to send reaction via web session'
|
||||||
);
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@ -138,13 +170,13 @@ export async function sendReactionWhatsApp(
|
|||||||
export async function sendPollWhatsApp(
|
export async function sendPollWhatsApp(
|
||||||
to: string,
|
to: string,
|
||||||
poll: PollInput,
|
poll: PollInput,
|
||||||
options: { verbose: boolean; accountId?: string },
|
options: { verbose: boolean; accountId?: string }
|
||||||
): Promise<{ messageId: string; toJid: string }> {
|
): Promise<{ messageId: string; toJid: string }> {
|
||||||
const correlationId = randomUUID();
|
const correlationId = randomUUID();
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const { listener: active } = requireActiveWebListener(options.accountId);
|
const { listener: active } = requireActiveWebListener(options.accountId);
|
||||||
const logger = getChildLogger({
|
const logger = getChildLogger({
|
||||||
module: "web-outbound",
|
module: 'web-outbound',
|
||||||
correlationId,
|
correlationId,
|
||||||
to,
|
to,
|
||||||
});
|
});
|
||||||
@ -159,18 +191,19 @@ export async function sendPollWhatsApp(
|
|||||||
optionCount: normalized.options.length,
|
optionCount: normalized.options.length,
|
||||||
maxSelections: normalized.maxSelections,
|
maxSelections: normalized.maxSelections,
|
||||||
},
|
},
|
||||||
"sending poll",
|
'sending poll'
|
||||||
);
|
);
|
||||||
const result = await active.sendPoll(to, normalized);
|
const result = await active.sendPoll(to, normalized);
|
||||||
const messageId = (result as { messageId?: string })?.messageId ?? "unknown";
|
const messageId =
|
||||||
|
(result as { messageId?: string })?.messageId ?? 'unknown';
|
||||||
const durationMs = Date.now() - startedAt;
|
const durationMs = Date.now() - startedAt;
|
||||||
outboundLog.info(`Sent poll ${messageId} -> ${jid} (${durationMs}ms)`);
|
outboundLog.info(`Sent poll ${messageId} -> ${jid} (${durationMs}ms)`);
|
||||||
logger.info({ jid, messageId }, "sent poll");
|
logger.info({ jid, messageId }, 'sent poll');
|
||||||
return { messageId, toJid: jid };
|
return { messageId, toJid: jid };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ err: String(err), to, question: poll.question },
|
{ err: String(err), to, question: poll.question },
|
||||||
"failed to send poll via web session",
|
'failed to send poll via web session'
|
||||||
);
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user