feat: Add Line plugin (#1630)

* feat: add LINE plugin (#1630) (thanks @plum-dawg)

* feat: complete LINE plugin (#1630) (thanks @plum-dawg)

* chore: drop line plugin node_modules (#1630) (thanks @plum-dawg)

* test: mock /context report in commands test (#1630) (thanks @plum-dawg)

* test: limit macOS CI workers to avoid OOM (#1630) (thanks @plum-dawg)

* test: reduce macOS CI vitest workers (#1630) (thanks @plum-dawg)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
plum-dawg
2026-01-25 07:22:36 -05:00
committed by GitHub
parent 101d0f451f
commit c96ffa7186
85 changed files with 11365 additions and 60 deletions

View File

@@ -311,6 +311,28 @@ describe("deliverOutboundPayloads", () => {
expect(results).toEqual([{ channel: "whatsapp", messageId: "w2", toJid: "jid" }]);
});
it("passes normalized payload to onError", async () => {
const sendWhatsApp = vi.fn().mockRejectedValue(new Error("boom"));
const onError = vi.fn();
const cfg: ClawdbotConfig = {};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "hi", mediaUrl: "https://x.test/a.jpg" }],
deps: { sendWhatsApp },
bestEffort: true,
onError,
});
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ text: "hi", mediaUrls: ["https://x.test/a.jpg"] }),
);
});
it("mirrors delivered output when mirror options are provided", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: ClawdbotConfig = {

View File

@@ -22,7 +22,7 @@ import {
resolveMirroredTranscriptText,
} from "../../config/sessions.js";
import type { NormalizedOutboundPayload } from "./payloads.js";
import { normalizeOutboundPayloads } from "./payloads.js";
import { normalizeReplyPayloadsForDelivery } from "./payloads.js";
import type { OutboundChannel } from "./targets.js";
export type { NormalizedOutboundPayload } from "./payloads.js";
@@ -69,6 +69,7 @@ type ChannelHandler = {
chunker: Chunker | null;
chunkerMode?: "text" | "markdown";
textChunkLimit?: number;
sendPayload?: (payload: ReplyPayload) => Promise<OutboundDeliveryResult>;
sendText: (text: string) => Promise<OutboundDeliveryResult>;
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>;
};
@@ -132,6 +133,21 @@ function createPluginHandler(params: {
chunker,
chunkerMode,
textChunkLimit: outbound.textChunkLimit,
sendPayload: outbound.sendPayload
? async (payload) =>
outbound.sendPayload!({
cfg: params.cfg,
to: params.to,
text: payload.text ?? "",
mediaUrl: payload.mediaUrl,
accountId: params.accountId,
replyToId: params.replyToId,
threadId: params.threadId,
gifPlayback: params.gifPlayback,
deps: params.deps,
payload,
})
: undefined,
sendText: async (text) =>
sendText({
cfg: params.cfg,
@@ -294,24 +310,33 @@ export async function deliverOutboundPayloads(params: {
})),
};
};
const normalizedPayloads = normalizeOutboundPayloads(payloads);
const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads);
for (const payload of normalizedPayloads) {
const payloadSummary: NormalizedOutboundPayload = {
text: payload.text ?? "",
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
channelData: payload.channelData,
};
try {
throwIfAborted(abortSignal);
params.onPayload?.(payload);
if (payload.mediaUrls.length === 0) {
params.onPayload?.(payloadSummary);
if (handler.sendPayload && payload.channelData) {
results.push(await handler.sendPayload(payload));
continue;
}
if (payloadSummary.mediaUrls.length === 0) {
if (isSignalChannel) {
await sendSignalTextChunks(payload.text);
await sendSignalTextChunks(payloadSummary.text);
} else {
await sendTextChunks(payload.text);
await sendTextChunks(payloadSummary.text);
}
continue;
}
let first = true;
for (const url of payload.mediaUrls) {
for (const url of payloadSummary.mediaUrls) {
throwIfAborted(abortSignal);
const caption = first ? payload.text : "";
const caption = first ? payloadSummary.text : "";
first = false;
if (isSignalChannel) {
results.push(await sendSignalMedia(caption, url));
@@ -321,7 +346,7 @@ export async function deliverOutboundPayloads(params: {
}
} catch (err) {
if (!params.bestEffort) throw err;
params.onError?.(err, payload);
params.onError?.(err, payloadSummary);
}
}
if (params.mirror && results.length > 0) {

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { formatOutboundPayloadLog, normalizeOutboundPayloadsForJson } from "./payloads.js";
import {
formatOutboundPayloadLog,
normalizeOutboundPayloads,
normalizeOutboundPayloadsForJson,
} from "./payloads.js";
describe("normalizeOutboundPayloadsForJson", () => {
it("normalizes payloads with mediaUrl and mediaUrls", () => {
@@ -11,16 +15,18 @@ describe("normalizeOutboundPayloadsForJson", () => {
{ text: "multi", mediaUrls: ["https://x.test/1.png"] },
]),
).toEqual([
{ text: "hi", mediaUrl: null, mediaUrls: undefined },
{ text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined },
{
text: "photo",
mediaUrl: "https://x.test/a.jpg",
mediaUrls: ["https://x.test/a.jpg"],
channelData: undefined,
},
{
text: "multi",
mediaUrl: null,
mediaUrls: ["https://x.test/1.png"],
channelData: undefined,
},
]);
});
@@ -37,11 +43,20 @@ describe("normalizeOutboundPayloadsForJson", () => {
text: "",
mediaUrl: null,
mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"],
channelData: undefined,
},
]);
});
});
describe("normalizeOutboundPayloads", () => {
it("keeps channelData-only payloads", () => {
const channelData = { line: { flexMessage: { altText: "Card", contents: {} } } };
const normalized = normalizeOutboundPayloads([{ channelData }]);
expect(normalized).toEqual([{ text: "", mediaUrls: [], channelData }]);
});
});
describe("formatOutboundPayloadLog", () => {
it("trims trailing text and appends media lines", () => {
expect(

View File

@@ -5,12 +5,14 @@ import type { ReplyPayload } from "../../auto-reply/types.js";
export type NormalizedOutboundPayload = {
text: string;
mediaUrls: string[];
channelData?: Record<string, unknown>;
};
export type OutboundPayloadJson = {
text: string;
mediaUrl: string | null;
mediaUrls?: string[];
channelData?: Record<string, unknown>;
};
function mergeMediaUrls(...lists: Array<Array<string | undefined> | undefined>): string[] {
@@ -58,11 +60,23 @@ export function normalizeReplyPayloadsForDelivery(payloads: ReplyPayload[]): Rep
export function normalizeOutboundPayloads(payloads: ReplyPayload[]): NormalizedOutboundPayload[] {
return normalizeReplyPayloadsForDelivery(payloads)
.map((payload) => ({
text: payload.text ?? "",
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
}))
.filter((payload) => payload.text || payload.mediaUrls.length > 0);
.map((payload) => {
const channelData = payload.channelData;
const normalized: NormalizedOutboundPayload = {
text: payload.text ?? "",
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
};
if (channelData && Object.keys(channelData).length > 0) {
normalized.channelData = channelData;
}
return normalized;
})
.filter(
(payload) =>
payload.text ||
payload.mediaUrls.length > 0 ||
Boolean(payload.channelData && Object.keys(payload.channelData).length > 0),
);
}
export function normalizeOutboundPayloadsForJson(payloads: ReplyPayload[]): OutboundPayloadJson[] {
@@ -70,6 +84,7 @@ export function normalizeOutboundPayloadsForJson(payloads: ReplyPayload[]): Outb
text: payload.text ?? "",
mediaUrl: payload.mediaUrl ?? null,
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
channelData: payload.channelData,
}));
}