mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-27 00:17:29 +00:00
refactor(plugins): move remaining channel and provider ownership out of src
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
KILOCODE_DEFAULT_COST,
|
||||
KILOCODE_DEFAULT_MAX_TOKENS,
|
||||
KILOCODE_MODEL_CATALOG,
|
||||
} from "../providers/kilocode-shared.js";
|
||||
} from "../../extensions/kilocode/shared.js";
|
||||
|
||||
const log = createSubsystemLogger("kilocode-models");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getMediaUnderstandingProvider } from "../../media-understanding/providers/index.js";
|
||||
import { getMediaUnderstandingProvider } from "../../media-understanding/provider-registry.js";
|
||||
import { buildProviderRegistry } from "../../media-understanding/runner.js";
|
||||
import { loadWebMedia } from "../../media/web-media.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
createAgendaCard,
|
||||
createAppleTvRemoteCard,
|
||||
createDeviceControlCard,
|
||||
createMediaPlayerCard,
|
||||
createEventCard,
|
||||
createAgendaCard,
|
||||
createDeviceControlCard,
|
||||
createAppleTvRemoteCard,
|
||||
} from "../../line/flex-templates.js";
|
||||
import type { LineChannelData } from "../../line/types.js";
|
||||
} from "../../plugin-sdk/line.js";
|
||||
import type { LineChannelData } from "../../plugin-sdk/line.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
listLineAccountIds,
|
||||
} from "../../../line/accounts.js";
|
||||
} from "../../../../extensions/line/runtime-api.js";
|
||||
import {
|
||||
bundledChannelPlugins,
|
||||
bundledChannelRuntimeSetters,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeWhatsAppTarget } from "../../../whatsapp/normalize.js";
|
||||
import { normalizeWhatsAppTarget } from "../../../plugin-sdk/whatsapp-shared.js";
|
||||
import { looksLikeHandleOrPhoneTarget, trimMessagingTarget } from "./shared.js";
|
||||
|
||||
export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined {
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
listWhatsAppDirectoryPeersFromConfig,
|
||||
} from "../../../extensions/whatsapp/src/directory-config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { LineProbeResult } from "../../line/types.js";
|
||||
import type { LineProbeResult } from "../../plugin-sdk/line.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
@@ -505,12 +505,6 @@ describe("resolveChannelConfigWrites", () => {
|
||||
const cfg = makeSlackConfigWritesCfg("Work");
|
||||
expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores account ids when the channel is missing", () => {
|
||||
expect(resolveChannelConfigWrites({ cfg: {}, channelId: "slack", accountId: "work" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authorizeConfigWrite", () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { resolveOutboundSendDep } from "../../infra/outbound/send-deps.js";
|
||||
import { createAttachedChannelResultAdapter } from "../../plugin-sdk/channel-send-result.js";
|
||||
import { resolveWhatsAppOutboundTarget } from "../../plugin-sdk/whatsapp-core.js";
|
||||
import type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js";
|
||||
import { escapeRegExp } from "../../utils.js";
|
||||
import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js";
|
||||
import type { ChannelOutboundAdapter } from "./types.js";
|
||||
|
||||
export const WHATSAPP_GROUP_INTRO_HINT =
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
|
||||
type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint;
|
||||
|
||||
vi.mock("../providers/github-copilot-auth.js", () => ({
|
||||
vi.mock("../../extensions/github-copilot/login.js", () => ({
|
||||
githubCopilotLoginCommand: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
|
||||
export { githubCopilotLoginCommand } from "../../extensions/github-copilot/login.js";
|
||||
export {
|
||||
modelsAliasesAddCommand,
|
||||
modelsAliasesListCommand,
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
resolveSessionDeliveryTarget,
|
||||
} from "../../infra/outbound/targets.js";
|
||||
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
|
||||
import { normalizeWhatsAppTarget } from "../../plugin-sdk/whatsapp-shared.js";
|
||||
import { buildChannelAccountBindings } from "../../routing/bindings.js";
|
||||
import { normalizeAccountId, normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
|
||||
|
||||
export type DeliveryTargetResolution =
|
||||
| {
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildFalImageGenerationProvider } from "../../../extensions/fal/image-generation-provider.js";
|
||||
import * as modelAuth from "../../agents/model-auth.js";
|
||||
|
||||
function expectFalJsonPost(
|
||||
fetchMock: ReturnType<typeof vi.fn>,
|
||||
params: {
|
||||
call: number;
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
params.call,
|
||||
params.url,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Key fal-test-key",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const request = fetchMock.mock.calls[params.call - 1]?.[1];
|
||||
expect(request).toBeTruthy();
|
||||
expect(JSON.parse(String(request?.body))).toEqual(params.body);
|
||||
}
|
||||
|
||||
describe("fal image-generation provider", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("generates image buffers from the fal sync API", async () => {
|
||||
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "fal-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
images: [
|
||||
{
|
||||
url: "https://v3.fal.media/files/example/generated.png",
|
||||
content_type: "image/png",
|
||||
},
|
||||
],
|
||||
prompt: "draw a cat",
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
arrayBuffer: async () => Buffer.from("png-data"),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildFalImageGenerationProvider();
|
||||
const result = await provider.generateImage({
|
||||
provider: "fal",
|
||||
model: "fal-ai/flux/dev",
|
||||
prompt: "draw a cat",
|
||||
cfg: {},
|
||||
count: 2,
|
||||
size: "1536x1024",
|
||||
});
|
||||
|
||||
expectFalJsonPost(fetchMock, {
|
||||
call: 1,
|
||||
url: "https://fal.run/fal-ai/flux/dev",
|
||||
body: {
|
||||
prompt: "draw a cat",
|
||||
image_size: { width: 1536, height: 1024 },
|
||||
num_images: 2,
|
||||
output_format: "png",
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://v3.fal.media/files/example/generated.png",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
images: [
|
||||
{
|
||||
buffer: Buffer.from("png-data"),
|
||||
mimeType: "image/png",
|
||||
fileName: "image-1.png",
|
||||
},
|
||||
],
|
||||
model: "fal-ai/flux/dev",
|
||||
metadata: { prompt: "draw a cat" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses image-to-image endpoint and data-uri input for edits", async () => {
|
||||
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "fal-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
images: [{ url: "https://v3.fal.media/files/example/edited.png" }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
arrayBuffer: async () => Buffer.from("edited-data"),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildFalImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "fal",
|
||||
model: "fal-ai/flux/dev",
|
||||
prompt: "turn this into a noir poster",
|
||||
cfg: {},
|
||||
resolution: "2K",
|
||||
inputImages: [
|
||||
{
|
||||
buffer: Buffer.from("source-image"),
|
||||
mimeType: "image/jpeg",
|
||||
fileName: "source.jpg",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectFalJsonPost(fetchMock, {
|
||||
call: 1,
|
||||
url: "https://fal.run/fal-ai/flux/dev/image-to-image",
|
||||
body: {
|
||||
prompt: "turn this into a noir poster",
|
||||
image_size: { width: 2048, height: 2048 },
|
||||
num_images: 1,
|
||||
output_format: "png",
|
||||
image_url: `data:image/jpeg;base64,${Buffer.from("source-image").toString("base64")}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps aspect ratio for text generation without forcing a square default", async () => {
|
||||
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "fal-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
images: [{ url: "https://v3.fal.media/files/example/wide.png" }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
arrayBuffer: async () => Buffer.from("wide-data"),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildFalImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "fal",
|
||||
model: "fal-ai/flux/dev",
|
||||
prompt: "wide cinematic shot",
|
||||
cfg: {},
|
||||
aspectRatio: "16:9",
|
||||
});
|
||||
|
||||
expectFalJsonPost(fetchMock, {
|
||||
call: 1,
|
||||
url: "https://fal.run/fal-ai/flux/dev",
|
||||
body: {
|
||||
prompt: "wide cinematic shot",
|
||||
image_size: "landscape_16_9",
|
||||
num_images: 1,
|
||||
output_format: "png",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("combines resolution and aspect ratio for text generation", async () => {
|
||||
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "fal-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
images: [{ url: "https://v3.fal.media/files/example/portrait.png" }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
arrayBuffer: async () => Buffer.from("portrait-data"),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildFalImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "fal",
|
||||
model: "fal-ai/flux/dev",
|
||||
prompt: "portrait poster",
|
||||
cfg: {},
|
||||
resolution: "2K",
|
||||
aspectRatio: "9:16",
|
||||
});
|
||||
|
||||
expectFalJsonPost(fetchMock, {
|
||||
call: 1,
|
||||
url: "https://fal.run/fal-ai/flux/dev",
|
||||
body: {
|
||||
prompt: "portrait poster",
|
||||
image_size: { width: 1152, height: 2048 },
|
||||
num_images: 1,
|
||||
output_format: "png",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects multi-image edit requests for now", async () => {
|
||||
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "fal-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
|
||||
const provider = buildFalImageGenerationProvider();
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: "fal",
|
||||
model: "fal-ai/flux/dev",
|
||||
prompt: "combine these",
|
||||
cfg: {},
|
||||
inputImages: [
|
||||
{ buffer: Buffer.from("one"), mimeType: "image/png" },
|
||||
{ buffer: Buffer.from("two"), mimeType: "image/png" },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow("at most one reference image");
|
||||
});
|
||||
|
||||
it("rejects aspect ratio overrides for the current edit endpoint", async () => {
|
||||
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "fal-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
|
||||
const provider = buildFalImageGenerationProvider();
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: "fal",
|
||||
model: "fal-ai/flux/dev",
|
||||
prompt: "make it widescreen",
|
||||
cfg: {},
|
||||
aspectRatio: "16:9",
|
||||
inputImages: [{ buffer: Buffer.from("one"), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow("does not support aspectRatio overrides");
|
||||
});
|
||||
});
|
||||
@@ -1,265 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildGoogleImageGenerationProvider } from "../../../extensions/google/image-generation-provider.js";
|
||||
import * as modelAuth from "../../agents/model-auth.js";
|
||||
|
||||
describe("Google image-generation provider", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("generates image buffers from the Gemini generateContent API", async () => {
|
||||
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "google-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{ text: "generated" },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: "image/png",
|
||||
data: Buffer.from("png-data").toString("base64"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildGoogleImageGenerationProvider();
|
||||
const result = await provider.generateImage({
|
||||
provider: "google",
|
||||
model: "gemini-3.1-flash-image-preview",
|
||||
prompt: "draw a cat",
|
||||
cfg: {},
|
||||
size: "1536x1024",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: "draw a cat" }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseModalities: ["TEXT", "IMAGE"],
|
||||
imageConfig: {
|
||||
aspectRatio: "3:2",
|
||||
imageSize: "2K",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
images: [
|
||||
{
|
||||
buffer: Buffer.from("png-data"),
|
||||
mimeType: "image/png",
|
||||
fileName: "image-1.png",
|
||||
},
|
||||
],
|
||||
model: "gemini-3.1-flash-image-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts OAuth JSON auth and inline_data responses", async () => {
|
||||
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: JSON.stringify({ token: "oauth-token" }),
|
||||
source: "profile",
|
||||
mode: "token",
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inline_data: {
|
||||
mime_type: "image/jpeg",
|
||||
data: Buffer.from("jpg-data").toString("base64"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildGoogleImageGenerationProvider();
|
||||
const result = await provider.generateImage({
|
||||
provider: "google",
|
||||
model: "gemini-3.1-flash-image-preview",
|
||||
prompt: "draw a dog",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
);
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(new Headers(init.headers).get("authorization")).toBe("Bearer oauth-token");
|
||||
expect(result).toEqual({
|
||||
images: [
|
||||
{
|
||||
buffer: Buffer.from("jpg-data"),
|
||||
mimeType: "image/jpeg",
|
||||
fileName: "image-1.jpg",
|
||||
},
|
||||
],
|
||||
model: "gemini-3.1-flash-image-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends reference images and explicit resolution for edit flows", async () => {
|
||||
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "google-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: "image/png",
|
||||
data: Buffer.from("png-data").toString("base64"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildGoogleImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-image-preview",
|
||||
prompt: "Change only the sky to a sunset.",
|
||||
cfg: {},
|
||||
resolution: "4K",
|
||||
inputImages: [
|
||||
{
|
||||
buffer: Buffer.from("reference-bytes"),
|
||||
mimeType: "image/png",
|
||||
fileName: "reference.png",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: "image/png",
|
||||
data: Buffer.from("reference-bytes").toString("base64"),
|
||||
},
|
||||
},
|
||||
{ text: "Change only the sky to a sunset." },
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseModalities: ["TEXT", "IMAGE"],
|
||||
imageConfig: {
|
||||
imageSize: "4K",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards explicit aspect ratio without forcing a default when size is omitted", async () => {
|
||||
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "google-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: "image/png",
|
||||
data: Buffer.from("png-data").toString("base64"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildGoogleImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-image-preview",
|
||||
prompt: "portrait photo",
|
||||
cfg: {},
|
||||
aspectRatio: "9:16",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: "portrait photo" }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseModalities: ["TEXT", "IMAGE"],
|
||||
imageConfig: {
|
||||
aspectRatio: "9:16",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildOpenAIImageGenerationProvider } from "../../../extensions/openai/image-generation-provider.js";
|
||||
import * as modelAuth from "../../agents/model-auth.js";
|
||||
|
||||
describe("OpenAI image-generation provider", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("generates PNG buffers from the OpenAI Images API", async () => {
|
||||
const resolveApiKeySpy = vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "sk-test",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
b64_json: Buffer.from("png-data").toString("base64"),
|
||||
revised_prompt: "revised",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildOpenAIImageGenerationProvider();
|
||||
const authStore = { version: 1, profiles: {} };
|
||||
const result = await provider.generateImage({
|
||||
provider: "openai",
|
||||
model: "gpt-image-1",
|
||||
prompt: "draw a cat",
|
||||
cfg: {},
|
||||
authStore,
|
||||
});
|
||||
|
||||
expect(resolveApiKeySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "openai",
|
||||
store: authStore,
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.openai.com/v1/images/generations",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: "gpt-image-1",
|
||||
prompt: "draw a cat",
|
||||
n: 1,
|
||||
size: "1024x1024",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
images: [
|
||||
{
|
||||
buffer: Buffer.from("png-data"),
|
||||
mimeType: "image/png",
|
||||
fileName: "image-1.png",
|
||||
revisedPrompt: "revised",
|
||||
},
|
||||
],
|
||||
model: "gpt-image-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps supported aspect ratios onto OpenAI size presets", async () => {
|
||||
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "sk-test",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ b64_json: Buffer.from("png-data").toString("base64") }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildOpenAIImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "openai",
|
||||
model: "gpt-image-1.5",
|
||||
prompt: "draw a portrait",
|
||||
aspectRatio: "2:3",
|
||||
cfg: {},
|
||||
authStore: { version: 1, profiles: {} },
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.openai.com/v1/images/generations",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
model: "gpt-image-1.5",
|
||||
prompt: "draw a portrait",
|
||||
n: 1,
|
||||
size: "1024x1536",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("advertises only exact aspect ratios supported by OpenAI size presets", () => {
|
||||
const provider = buildOpenAIImageGenerationProvider();
|
||||
const geometry = provider.capabilities.geometry;
|
||||
|
||||
expect(provider.capabilities.generate.supportsAspectRatio).toBe(true);
|
||||
expect(geometry).toBeDefined();
|
||||
if (!geometry) {
|
||||
throw new Error("expected OpenAI image generation geometry capabilities");
|
||||
}
|
||||
expect(geometry.aspectRatios).toEqual(["1:1", "2:3", "3:2"]);
|
||||
});
|
||||
|
||||
it("prefers an explicit size over aspect ratio mapping", async () => {
|
||||
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "sk-test",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ b64_json: Buffer.from("png-data").toString("base64") }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildOpenAIImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "openai",
|
||||
model: "gpt-image-1.5",
|
||||
prompt: "draw a landscape",
|
||||
size: "1024x1024",
|
||||
aspectRatio: "16:9",
|
||||
cfg: {},
|
||||
authStore: { version: 1, profiles: {} },
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.openai.com/v1/images/generations",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
model: "gpt-image-1.5",
|
||||
prompt: "draw a landscape",
|
||||
n: 1,
|
||||
size: "1024x1024",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects reference-image edits for now", async () => {
|
||||
const provider = buildOpenAIImageGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: "openai",
|
||||
model: "gpt-image-1",
|
||||
prompt: "Edit this image",
|
||||
cfg: {},
|
||||
inputImages: [{ buffer: Buffer.from("x"), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow("does not support reference-image edits");
|
||||
});
|
||||
});
|
||||
@@ -24,9 +24,9 @@ import { getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { recordSessionMetaFromInbound, resolveStorePath } from "../../config/sessions.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../plugin-sdk/whatsapp-shared.js";
|
||||
import { buildAgentSessionKey, type RoutePeer } from "../../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../../routing/session-key.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
|
||||
import type { ResolvedMessagingTarget } from "./target-resolver.js";
|
||||
|
||||
export type OutboundSessionRoute = {
|
||||
|
||||
@@ -1,15 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { parseTelegramTarget } from "../../../extensions/telegram/api.js";
|
||||
import { telegramOutbound, whatsappOutbound } from "../../../test/channel-outbounds.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../plugin-sdk/whatsapp-shared.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { resolveOutboundTarget } from "./targets.js";
|
||||
import { createTargetsTestRegistry } from "./targets.test-helpers.js";
|
||||
|
||||
const telegramMessaging = {
|
||||
parseExplicitTarget: ({ raw }: { raw: string }) => {
|
||||
const target = parseTelegramTarget(raw);
|
||||
return {
|
||||
to: target.chatId,
|
||||
threadId: target.messageThreadId,
|
||||
chatType: target.chatType === "unknown" ? undefined : target.chatType,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const whatsappMessaging = {
|
||||
inferTargetChatType: ({ to }: { to: string }) => {
|
||||
const normalized = normalizeWhatsAppTarget(to);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return isWhatsAppGroupJid(normalized) ? ("group" as const) : ("direct" as const);
|
||||
},
|
||||
targetResolver: {
|
||||
hint: "<E.164|group JID>",
|
||||
},
|
||||
};
|
||||
|
||||
export function installResolveOutboundTargetPluginRegistryHooks(): void {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createTargetsTestRegistry());
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
plugin: {
|
||||
...createOutboundTestPlugin({
|
||||
id: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
outbound: whatsappOutbound,
|
||||
messaging: whatsappMessaging,
|
||||
}),
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveDefaultTo: ({ cfg }: { cfg: OpenClawConfig }) =>
|
||||
typeof cfg.channels?.whatsapp?.defaultTo === "string"
|
||||
? cfg.channels.whatsapp.defaultTo
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: {
|
||||
...createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
outbound: telegramOutbound,
|
||||
messaging: telegramMessaging,
|
||||
}),
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveDefaultTo: ({ cfg }: { cfg: OpenClawConfig }) =>
|
||||
typeof cfg.channels?.telegram?.defaultTo === "string"
|
||||
? cfg.channels.telegram.defaultTo
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTargetsTestRegistry([]));
|
||||
setActivePluginRegistry(createTestRegistry());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js";
|
||||
import { telegramOutbound, whatsappOutbound } from "../../../test/channel-outbounds.js";
|
||||
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../plugin-sdk/whatsapp-shared.js";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
|
||||
vi.mock("./channel-resolution.js", async () => {
|
||||
const { getActivePluginRegistry } = await import("../../plugins/runtime.js");
|
||||
|
||||
return {
|
||||
normalizeDeliverableOutboundChannel: (raw?: string | null) =>
|
||||
typeof raw === "string" && raw.trim() ? raw.trim().toLowerCase() : undefined,
|
||||
resolveOutboundChannelPlugin: ({ channel }: { channel: string }) =>
|
||||
getActivePluginRegistry()?.channels.find((entry) => entry?.plugin?.id === channel)?.plugin,
|
||||
};
|
||||
});
|
||||
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import {
|
||||
resolveHeartbeatDeliveryTarget,
|
||||
resolveOutboundTarget,
|
||||
@@ -25,15 +17,34 @@ import {
|
||||
installResolveOutboundTargetPluginRegistryHooks,
|
||||
runResolveOutboundTargetCoreTests,
|
||||
} from "./targets.shared-test.js";
|
||||
import {
|
||||
createNoopOutboundChannelPlugin,
|
||||
createTargetsTestRegistry,
|
||||
createTelegramTestPlugin,
|
||||
createWhatsAppTestPlugin,
|
||||
} from "./targets.test-helpers.js";
|
||||
|
||||
runResolveOutboundTargetCoreTests();
|
||||
|
||||
const telegramMessaging = {
|
||||
parseExplicitTarget: ({ raw }: { raw: string }) => {
|
||||
const target = parseTelegramTarget(raw);
|
||||
return {
|
||||
to: target.chatId,
|
||||
threadId: target.messageThreadId,
|
||||
chatType: target.chatType === "unknown" ? undefined : target.chatType,
|
||||
};
|
||||
},
|
||||
inferTargetChatType: ({ to }: { to: string }) => {
|
||||
const target = parseTelegramTarget(to);
|
||||
return target.chatType === "unknown" ? undefined : target.chatType;
|
||||
},
|
||||
};
|
||||
|
||||
const whatsappMessaging = {
|
||||
inferTargetChatType: ({ to }: { to: string }) => {
|
||||
const normalized = normalizeWhatsAppTarget(to);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return isWhatsAppGroupJid(normalized) ? ("group" as const) : ("direct" as const);
|
||||
},
|
||||
};
|
||||
|
||||
const noopOutbound = (channel: "discord" | "imessage" | "slack"): ChannelOutboundAdapter => ({
|
||||
deliveryMode: "direct",
|
||||
sendText: async () => ({ channel, messageId: `${channel}-msg` }),
|
||||
@@ -41,21 +52,40 @@ const noopOutbound = (channel: "discord" | "imessage" | "slack"): ChannelOutboun
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
createTestRegistry([
|
||||
{
|
||||
...createNoopOutboundChannelPlugin("discord"),
|
||||
outbound: noopOutbound("discord"),
|
||||
pluginId: "discord",
|
||||
plugin: createOutboundTestPlugin({ id: "discord", outbound: noopOutbound("discord") }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
...createNoopOutboundChannelPlugin("imessage"),
|
||||
outbound: noopOutbound("imessage"),
|
||||
pluginId: "imessage",
|
||||
plugin: createOutboundTestPlugin({ id: "imessage", outbound: noopOutbound("imessage") }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
...createNoopOutboundChannelPlugin("slack"),
|
||||
outbound: noopOutbound("slack"),
|
||||
pluginId: "slack",
|
||||
plugin: createOutboundTestPlugin({ id: "slack", outbound: noopOutbound("slack") }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
outbound: telegramOutbound,
|
||||
messaging: telegramMessaging,
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "whatsapp",
|
||||
outbound: whatsappOutbound,
|
||||
messaging: whatsappMessaging,
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
createTelegramTestPlugin(),
|
||||
createWhatsAppTestPlugin(),
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -113,7 +143,7 @@ describe("resolveOutboundTarget defaultTo config fallback", () => {
|
||||
});
|
||||
|
||||
it("falls back to the active registry when the cached channel map is stale", () => {
|
||||
const registry = createTargetsTestRegistry([]);
|
||||
const registry = createTestRegistry([]);
|
||||
setActivePluginRegistry(registry, "stale-registry-test");
|
||||
|
||||
// Warm the cached channel map before mutating the registry in place.
|
||||
@@ -123,7 +153,11 @@ describe("resolveOutboundTarget defaultTo config fallback", () => {
|
||||
|
||||
registry.channels.push({
|
||||
pluginId: "telegram",
|
||||
plugin: createTelegramTestPlugin(),
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
outbound: telegramOutbound,
|
||||
messaging: telegramMessaging,
|
||||
}),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
@@ -354,7 +388,7 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("keeps raw :topic: targets when the telegram plugin registry is unavailable", () => {
|
||||
setActivePluginRegistry(createTargetsTestRegistry([]));
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
|
||||
const resolved = resolveSessionDeliveryTarget({
|
||||
entry: {
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveLineAccount,
|
||||
resolveDefaultLineAccountId,
|
||||
normalizeAccountId,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
} from "./accounts.js";
|
||||
|
||||
describe("LINE accounts", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const createSecretFile = (fileName: string, contents: string) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-line-account-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, fileName);
|
||||
fs.writeFileSync(filePath, contents, "utf8");
|
||||
return filePath;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.LINE_CHANNEL_ACCESS_TOKEN;
|
||||
delete process.env.LINE_CHANNEL_SECRET;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveLineAccount", () => {
|
||||
it("resolves account from config", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
channelAccessToken: "test-token",
|
||||
channelSecret: "test-secret",
|
||||
name: "Test Bot",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.accountId).toBe(DEFAULT_ACCOUNT_ID);
|
||||
expect(account.enabled).toBe(true);
|
||||
expect(account.channelAccessToken).toBe("test-token");
|
||||
expect(account.channelSecret).toBe("test-secret");
|
||||
expect(account.name).toBe("Test Bot");
|
||||
expect(account.tokenSource).toBe("config");
|
||||
});
|
||||
|
||||
it("resolves account from environment variables", () => {
|
||||
process.env.LINE_CHANNEL_ACCESS_TOKEN = "env-token";
|
||||
process.env.LINE_CHANNEL_SECRET = "env-secret";
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.channelAccessToken).toBe("env-token");
|
||||
expect(account.channelSecret).toBe("env-secret");
|
||||
expect(account.tokenSource).toBe("env");
|
||||
});
|
||||
|
||||
it("resolves named account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
business: {
|
||||
enabled: true,
|
||||
channelAccessToken: "business-token",
|
||||
channelSecret: "business-secret",
|
||||
name: "Business Bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg, accountId: "business" });
|
||||
|
||||
expect(account.accountId).toBe("business");
|
||||
expect(account.enabled).toBe(true);
|
||||
expect(account.channelAccessToken).toBe("business-token");
|
||||
expect(account.channelSecret).toBe("business-secret");
|
||||
expect(account.name).toBe("Business Bot");
|
||||
});
|
||||
|
||||
it("returns empty token when not configured", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.channelAccessToken).toBe("");
|
||||
expect(account.channelSecret).toBe("");
|
||||
expect(account.tokenSource).toBe("none");
|
||||
});
|
||||
|
||||
it("resolves default account credentials from files", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
tokenFile: createSecretFile("token.txt", "file-token\n"),
|
||||
secretFile: createSecretFile("secret.txt", "file-secret\n"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.channelAccessToken).toBe("file-token");
|
||||
expect(account.channelSecret).toBe("file-secret");
|
||||
expect(account.tokenSource).toBe("file");
|
||||
});
|
||||
|
||||
it("resolves named account credentials from account-level files", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
accounts: {
|
||||
business: {
|
||||
tokenFile: createSecretFile("business-token.txt", "business-file-token\n"),
|
||||
secretFile: createSecretFile("business-secret.txt", "business-file-secret\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg, accountId: "business" });
|
||||
|
||||
expect(account.channelAccessToken).toBe("business-file-token");
|
||||
expect(account.channelSecret).toBe("business-file-secret");
|
||||
expect(account.tokenSource).toBe("file");
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("rejects symlinked token and secret files", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-line-account-"));
|
||||
tempDirs.push(dir);
|
||||
const tokenFile = path.join(dir, "token.txt");
|
||||
const tokenLink = path.join(dir, "token-link.txt");
|
||||
const secretFile = path.join(dir, "secret.txt");
|
||||
const secretLink = path.join(dir, "secret-link.txt");
|
||||
fs.writeFileSync(tokenFile, "file-token\n", "utf8");
|
||||
fs.writeFileSync(secretFile, "file-secret\n", "utf8");
|
||||
fs.symlinkSync(tokenFile, tokenLink);
|
||||
fs.symlinkSync(secretFile, secretLink);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
tokenFile: tokenLink,
|
||||
secretFile: secretLink,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
expect(account.channelAccessToken).toBe("");
|
||||
expect(account.channelSecret).toBe("");
|
||||
expect(account.tokenSource).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultLineAccountId", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "prefers channels.line.defaultAccount when configured",
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
defaultAccount: "business",
|
||||
accounts: {
|
||||
business: { enabled: true },
|
||||
support: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expected: "business",
|
||||
},
|
||||
{
|
||||
name: "normalizes channels.line.defaultAccount before lookup",
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
defaultAccount: "Business Ops",
|
||||
accounts: {
|
||||
"business-ops": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expected: "business-ops",
|
||||
},
|
||||
{
|
||||
name: "returns first named account when default not configured",
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
accounts: {
|
||||
business: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expected: "business",
|
||||
},
|
||||
{
|
||||
name: "falls back when channels.line.defaultAccount is missing",
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
defaultAccount: "missing",
|
||||
accounts: {
|
||||
business: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expected: "business",
|
||||
},
|
||||
{
|
||||
name: "prefers the default account when base credentials are configured",
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
channelAccessToken: "base-token",
|
||||
accounts: {
|
||||
business: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expected: DEFAULT_ACCOUNT_ID,
|
||||
},
|
||||
])("$name", ({ cfg, expected }) => {
|
||||
expect(resolveDefaultLineAccountId(cfg)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAccountId", () => {
|
||||
it("trims and lowercases account ids", () => {
|
||||
expect(normalizeAccountId(" Business ")).toBe("business");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
import {
|
||||
listCombinedAccountIds,
|
||||
resolveListedDefaultAccountId,
|
||||
} from "../channels/plugins/account-helpers.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { tryReadSecretFileSync } from "../infra/secret-file.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId as normalizeSharedAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "../routing/account-id.js";
|
||||
import { resolveAccountEntry } from "../routing/account-lookup.js";
|
||||
import type {
|
||||
LineConfig,
|
||||
LineAccountConfig,
|
||||
ResolvedLineAccount,
|
||||
LineTokenSource,
|
||||
} from "./types.js";
|
||||
|
||||
export { DEFAULT_ACCOUNT_ID } from "../routing/account-id.js";
|
||||
|
||||
function readFileIfExists(filePath: string | undefined): string | undefined {
|
||||
return tryReadSecretFileSync(filePath, "LINE credential file", { rejectSymlink: true });
|
||||
}
|
||||
|
||||
function resolveToken(params: {
|
||||
accountId: string;
|
||||
baseConfig?: LineConfig;
|
||||
accountConfig?: LineAccountConfig;
|
||||
}): { token: string; tokenSource: LineTokenSource } {
|
||||
const { accountId, baseConfig, accountConfig } = params;
|
||||
|
||||
// Check account-level config first
|
||||
if (accountConfig?.channelAccessToken?.trim()) {
|
||||
return { token: accountConfig.channelAccessToken.trim(), tokenSource: "config" };
|
||||
}
|
||||
|
||||
// Check account-level token file
|
||||
const accountFileToken = readFileIfExists(accountConfig?.tokenFile);
|
||||
if (accountFileToken) {
|
||||
return { token: accountFileToken, tokenSource: "file" };
|
||||
}
|
||||
|
||||
// For default account, check base config and env
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (baseConfig?.channelAccessToken?.trim()) {
|
||||
return { token: baseConfig.channelAccessToken.trim(), tokenSource: "config" };
|
||||
}
|
||||
|
||||
const baseFileToken = readFileIfExists(baseConfig?.tokenFile);
|
||||
if (baseFileToken) {
|
||||
return { token: baseFileToken, tokenSource: "file" };
|
||||
}
|
||||
|
||||
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim();
|
||||
if (envToken) {
|
||||
return { token: envToken, tokenSource: "env" };
|
||||
}
|
||||
}
|
||||
|
||||
return { token: "", tokenSource: "none" };
|
||||
}
|
||||
|
||||
function resolveSecret(params: {
|
||||
accountId: string;
|
||||
baseConfig?: LineConfig;
|
||||
accountConfig?: LineAccountConfig;
|
||||
}): string {
|
||||
const { accountId, baseConfig, accountConfig } = params;
|
||||
|
||||
// Check account-level config first
|
||||
if (accountConfig?.channelSecret?.trim()) {
|
||||
return accountConfig.channelSecret.trim();
|
||||
}
|
||||
|
||||
// Check account-level secret file
|
||||
const accountFileSecret = readFileIfExists(accountConfig?.secretFile);
|
||||
if (accountFileSecret) {
|
||||
return accountFileSecret;
|
||||
}
|
||||
|
||||
// For default account, check base config and env
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (baseConfig?.channelSecret?.trim()) {
|
||||
return baseConfig.channelSecret.trim();
|
||||
}
|
||||
|
||||
const baseFileSecret = readFileIfExists(baseConfig?.secretFile);
|
||||
if (baseFileSecret) {
|
||||
return baseFileSecret;
|
||||
}
|
||||
|
||||
const envSecret = process.env.LINE_CHANNEL_SECRET?.trim();
|
||||
if (envSecret) {
|
||||
return envSecret;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function resolveLineAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}): ResolvedLineAccount {
|
||||
const cfg = params.cfg;
|
||||
const accountId = normalizeSharedAccountId(params.accountId);
|
||||
const lineConfig = cfg.channels?.line as LineConfig | undefined;
|
||||
const accounts = lineConfig?.accounts;
|
||||
const accountConfig =
|
||||
accountId !== DEFAULT_ACCOUNT_ID ? resolveAccountEntry(accounts, accountId) : undefined;
|
||||
|
||||
const { token, tokenSource } = resolveToken({
|
||||
accountId,
|
||||
baseConfig: lineConfig,
|
||||
accountConfig,
|
||||
});
|
||||
|
||||
const secret = resolveSecret({
|
||||
accountId,
|
||||
baseConfig: lineConfig,
|
||||
accountConfig,
|
||||
});
|
||||
|
||||
const {
|
||||
accounts: _ignoredAccounts,
|
||||
defaultAccount: _ignoredDefaultAccount,
|
||||
...lineBase
|
||||
} = (lineConfig ?? {}) as LineConfig & {
|
||||
accounts?: unknown;
|
||||
defaultAccount?: unknown;
|
||||
};
|
||||
const mergedConfig: LineConfig & LineAccountConfig = {
|
||||
...lineBase,
|
||||
...accountConfig,
|
||||
};
|
||||
|
||||
const enabled =
|
||||
accountConfig?.enabled ??
|
||||
(accountId === DEFAULT_ACCOUNT_ID ? (lineConfig?.enabled ?? true) : false);
|
||||
|
||||
const name =
|
||||
accountConfig?.name ?? (accountId === DEFAULT_ACCOUNT_ID ? lineConfig?.name : undefined);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name,
|
||||
enabled,
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
tokenSource,
|
||||
config: mergedConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export function listLineAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const lineConfig = cfg.channels?.line as LineConfig | undefined;
|
||||
const hasBaseCredentials = Boolean(
|
||||
lineConfig?.channelAccessToken?.trim() ||
|
||||
lineConfig?.tokenFile ||
|
||||
process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim(),
|
||||
);
|
||||
const preferred = normalizeOptionalAccountId(lineConfig?.defaultAccount);
|
||||
const configuredAccountIds = [
|
||||
...new Set(
|
||||
Object.keys(lineConfig?.accounts ?? {})
|
||||
.filter(Boolean)
|
||||
.map(normalizeSharedAccountId),
|
||||
),
|
||||
];
|
||||
return listCombinedAccountIds({
|
||||
configuredAccountIds,
|
||||
implicitAccountId: hasBaseCredentials ? (preferred ?? DEFAULT_ACCOUNT_ID) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDefaultLineAccountId(cfg: OpenClawConfig): string {
|
||||
return resolveListedDefaultAccountId({
|
||||
accountIds: listLineAccountIds(cfg),
|
||||
configuredDefaultAccountId: normalizeOptionalAccountId(
|
||||
(cfg.channels?.line as LineConfig | undefined)?.defaultAccount,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAccountId(accountId: string | undefined): string {
|
||||
return normalizeSharedAccountId(accountId);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
|
||||
export type Action = messagingApi.Action;
|
||||
|
||||
/**
|
||||
* Create a message action (sends text when tapped)
|
||||
*/
|
||||
export function messageAction(label: string, text?: string): Action {
|
||||
return {
|
||||
type: "message",
|
||||
label: label.slice(0, 20),
|
||||
text: text ?? label,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a URI action (opens a URL when tapped)
|
||||
*/
|
||||
export function uriAction(label: string, uri: string): Action {
|
||||
return {
|
||||
type: "uri",
|
||||
label: label.slice(0, 20),
|
||||
uri,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a postback action (sends data to webhook when tapped)
|
||||
*/
|
||||
export function postbackAction(label: string, data: string, displayText?: string): Action {
|
||||
return {
|
||||
type: "postback",
|
||||
label: label.slice(0, 20),
|
||||
data: data.slice(0, 300),
|
||||
displayText: displayText?.slice(0, 300),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a datetime picker action
|
||||
*/
|
||||
export function datetimePickerAction(
|
||||
label: string,
|
||||
data: string,
|
||||
mode: "date" | "time" | "datetime",
|
||||
options?: {
|
||||
initial?: string;
|
||||
max?: string;
|
||||
min?: string;
|
||||
},
|
||||
): Action {
|
||||
return {
|
||||
type: "datetimepicker",
|
||||
label: label.slice(0, 20),
|
||||
data: data.slice(0, 300),
|
||||
mode,
|
||||
initial: options?.initial,
|
||||
max: options?.max,
|
||||
min: options?.min,
|
||||
};
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LineAutoReplyDeps } from "./auto-reply-delivery.js";
|
||||
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
|
||||
import { sendLineReplyChunks } from "./reply-chunks.js";
|
||||
|
||||
const createFlexMessage = (altText: string, contents: unknown) => ({
|
||||
type: "flex" as const,
|
||||
altText,
|
||||
contents,
|
||||
});
|
||||
|
||||
const createImageMessage = (url: string) => ({
|
||||
type: "image" as const,
|
||||
originalContentUrl: url,
|
||||
previewImageUrl: url,
|
||||
});
|
||||
|
||||
const createLocationMessage = (location: {
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}) => ({
|
||||
type: "location" as const,
|
||||
...location,
|
||||
});
|
||||
|
||||
describe("deliverLineAutoReply", () => {
|
||||
const baseDeliveryParams = {
|
||||
to: "line:user:1",
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
accountId: "acc",
|
||||
textLimit: 5000,
|
||||
};
|
||||
|
||||
function createDeps(overrides?: Partial<LineAutoReplyDeps>) {
|
||||
const replyMessageLine = vi.fn(async () => ({}));
|
||||
const pushMessageLine = vi.fn(async () => ({}));
|
||||
const pushTextMessageWithQuickReplies = vi.fn(async () => ({}));
|
||||
const createTextMessageWithQuickReplies = vi.fn((text: string) => ({
|
||||
type: "text" as const,
|
||||
text,
|
||||
}));
|
||||
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
|
||||
const pushMessagesLine = vi.fn(async () => ({ messageId: "push", chatId: "u1" }));
|
||||
|
||||
const deps: LineAutoReplyDeps = {
|
||||
buildTemplateMessageFromPayload: () => null,
|
||||
processLineMessage: (text) => ({ text, flexMessages: [] }),
|
||||
chunkMarkdownText: (text) => [text],
|
||||
sendLineReplyChunks,
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
createQuickReplyItems: createQuickReplyItems as LineAutoReplyDeps["createQuickReplyItems"],
|
||||
pushMessagesLine,
|
||||
createFlexMessage: createFlexMessage as LineAutoReplyDeps["createFlexMessage"],
|
||||
createImageMessage,
|
||||
createLocationMessage,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
deps,
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
createQuickReplyItems,
|
||||
pushMessagesLine,
|
||||
};
|
||||
}
|
||||
|
||||
it("uses reply token for text before sending rich messages", async () => {
|
||||
const lineData = {
|
||||
flexMessage: { altText: "Card", contents: { type: "bubble" } },
|
||||
};
|
||||
const { deps, replyMessageLine, pushMessagesLine, createQuickReplyItems } = createDeps();
|
||||
|
||||
const result = await deliverLineAutoReply({
|
||||
...baseDeliveryParams,
|
||||
payload: { text: "hello", channelData: { line: lineData } },
|
||||
lineData,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(replyMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(replyMessageLine).toHaveBeenCalledWith("token", [{ type: "text", text: "hello" }], {
|
||||
accountId: "acc",
|
||||
});
|
||||
expect(pushMessagesLine).toHaveBeenCalledTimes(1);
|
||||
expect(pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:1",
|
||||
[createFlexMessage("Card", { type: "bubble" })],
|
||||
{ accountId: "acc" },
|
||||
);
|
||||
expect(createQuickReplyItems).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses reply token for rich-only payloads", async () => {
|
||||
const lineData = {
|
||||
flexMessage: { altText: "Card", contents: { type: "bubble" } },
|
||||
quickReplies: ["A"],
|
||||
};
|
||||
const { deps, replyMessageLine, pushMessagesLine, createQuickReplyItems } = createDeps({
|
||||
processLineMessage: () => ({ text: "", flexMessages: [] }),
|
||||
chunkMarkdownText: () => [],
|
||||
sendLineReplyChunks: vi.fn(async () => ({ replyTokenUsed: false })),
|
||||
});
|
||||
|
||||
const result = await deliverLineAutoReply({
|
||||
...baseDeliveryParams,
|
||||
payload: { channelData: { line: lineData } },
|
||||
lineData,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(replyMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(replyMessageLine).toHaveBeenCalledWith(
|
||||
"token",
|
||||
[
|
||||
{
|
||||
...createFlexMessage("Card", { type: "bubble" }),
|
||||
quickReply: { items: ["A"] },
|
||||
},
|
||||
],
|
||||
{ accountId: "acc" },
|
||||
);
|
||||
expect(pushMessagesLine).not.toHaveBeenCalled();
|
||||
expect(createQuickReplyItems).toHaveBeenCalledWith(["A"]);
|
||||
});
|
||||
|
||||
it("sends rich messages before quick-reply text so quick replies remain visible", async () => {
|
||||
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
|
||||
type: "text" as const,
|
||||
text,
|
||||
quickReply: { items: ["A"] },
|
||||
}));
|
||||
|
||||
const lineData = {
|
||||
flexMessage: { altText: "Card", contents: { type: "bubble" } },
|
||||
quickReplies: ["A"],
|
||||
};
|
||||
const { deps, pushMessagesLine, replyMessageLine } = createDeps({
|
||||
createTextMessageWithQuickReplies:
|
||||
createTextMessageWithQuickReplies as LineAutoReplyDeps["createTextMessageWithQuickReplies"],
|
||||
});
|
||||
|
||||
await deliverLineAutoReply({
|
||||
...baseDeliveryParams,
|
||||
payload: { text: "hello", channelData: { line: lineData } },
|
||||
lineData,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:1",
|
||||
[createFlexMessage("Card", { type: "bubble" })],
|
||||
{ accountId: "acc" },
|
||||
);
|
||||
expect(replyMessageLine).toHaveBeenCalledWith(
|
||||
"token",
|
||||
[
|
||||
{
|
||||
type: "text",
|
||||
text: "hello",
|
||||
quickReply: { items: ["A"] },
|
||||
},
|
||||
],
|
||||
{ accountId: "acc" },
|
||||
);
|
||||
const pushOrder = pushMessagesLine.mock.invocationCallOrder[0];
|
||||
const replyOrder = replyMessageLine.mock.invocationCallOrder[0];
|
||||
expect(pushOrder).toBeLessThan(replyOrder);
|
||||
});
|
||||
|
||||
it("falls back to push when reply token delivery fails", async () => {
|
||||
const lineData = {
|
||||
flexMessage: { altText: "Card", contents: { type: "bubble" } },
|
||||
};
|
||||
const failingReplyMessageLine = vi.fn(async () => {
|
||||
throw new Error("reply failed");
|
||||
});
|
||||
const { deps, pushMessagesLine } = createDeps({
|
||||
processLineMessage: () => ({ text: "", flexMessages: [] }),
|
||||
chunkMarkdownText: () => [],
|
||||
replyMessageLine: failingReplyMessageLine as LineAutoReplyDeps["replyMessageLine"],
|
||||
});
|
||||
|
||||
const result = await deliverLineAutoReply({
|
||||
...baseDeliveryParams,
|
||||
payload: { channelData: { line: lineData } },
|
||||
lineData,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(failingReplyMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:1",
|
||||
[createFlexMessage("Card", { type: "bubble" })],
|
||||
{ accountId: "acc" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { FlexContainer } from "./flex-templates.js";
|
||||
import type { ProcessedLineMessage } from "./markdown-to-line.js";
|
||||
import type { SendLineReplyChunksParams } from "./reply-chunks.js";
|
||||
import type { LineChannelData, LineTemplateMessagePayload } from "./types.js";
|
||||
|
||||
export type LineAutoReplyDeps = {
|
||||
buildTemplateMessageFromPayload: (
|
||||
payload: LineTemplateMessagePayload,
|
||||
) => messagingApi.TemplateMessage | null;
|
||||
processLineMessage: (text: string) => ProcessedLineMessage;
|
||||
chunkMarkdownText: (text: string, limit: number) => string[];
|
||||
sendLineReplyChunks: (params: SendLineReplyChunksParams) => Promise<{ replyTokenUsed: boolean }>;
|
||||
createQuickReplyItems: (labels: string[]) => messagingApi.QuickReply;
|
||||
pushMessagesLine: (
|
||||
to: string,
|
||||
messages: messagingApi.Message[],
|
||||
opts?: { accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
createFlexMessage: (altText: string, contents: FlexContainer) => messagingApi.FlexMessage;
|
||||
createImageMessage: (
|
||||
originalContentUrl: string,
|
||||
previewImageUrl?: string,
|
||||
) => messagingApi.ImageMessage;
|
||||
createLocationMessage: (location: {
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}) => messagingApi.LocationMessage;
|
||||
} & Pick<
|
||||
SendLineReplyChunksParams,
|
||||
| "replyMessageLine"
|
||||
| "pushMessageLine"
|
||||
| "pushTextMessageWithQuickReplies"
|
||||
| "createTextMessageWithQuickReplies"
|
||||
| "onReplyError"
|
||||
>;
|
||||
|
||||
export async function deliverLineAutoReply(params: {
|
||||
payload: ReplyPayload;
|
||||
lineData: LineChannelData;
|
||||
to: string;
|
||||
replyToken?: string | null;
|
||||
replyTokenUsed: boolean;
|
||||
accountId?: string;
|
||||
textLimit: number;
|
||||
deps: LineAutoReplyDeps;
|
||||
}): Promise<{ replyTokenUsed: boolean }> {
|
||||
const { payload, lineData, replyToken, accountId, to, textLimit, deps } = params;
|
||||
let replyTokenUsed = params.replyTokenUsed;
|
||||
|
||||
const pushLineMessages = async (messages: messagingApi.Message[]): Promise<void> => {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < messages.length; i += 5) {
|
||||
await deps.pushMessagesLine(to, messages.slice(i, i + 5), {
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const sendLineMessages = async (
|
||||
messages: messagingApi.Message[],
|
||||
allowReplyToken: boolean,
|
||||
): Promise<void> => {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining = messages;
|
||||
if (allowReplyToken && replyToken && !replyTokenUsed) {
|
||||
const replyBatch = remaining.slice(0, 5);
|
||||
try {
|
||||
await deps.replyMessageLine(replyToken, replyBatch, {
|
||||
accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
deps.onReplyError?.(err);
|
||||
await pushLineMessages(replyBatch);
|
||||
}
|
||||
replyTokenUsed = true;
|
||||
remaining = remaining.slice(replyBatch.length);
|
||||
}
|
||||
|
||||
if (remaining.length > 0) {
|
||||
await pushLineMessages(remaining);
|
||||
}
|
||||
};
|
||||
|
||||
const richMessages: messagingApi.Message[] = [];
|
||||
const hasQuickReplies = Boolean(lineData.quickReplies?.length);
|
||||
|
||||
if (lineData.flexMessage) {
|
||||
richMessages.push(
|
||||
deps.createFlexMessage(
|
||||
lineData.flexMessage.altText.slice(0, 400),
|
||||
lineData.flexMessage.contents as FlexContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (lineData.templateMessage) {
|
||||
const templateMsg = deps.buildTemplateMessageFromPayload(lineData.templateMessage);
|
||||
if (templateMsg) {
|
||||
richMessages.push(templateMsg);
|
||||
}
|
||||
}
|
||||
|
||||
if (lineData.location) {
|
||||
richMessages.push(deps.createLocationMessage(lineData.location));
|
||||
}
|
||||
|
||||
const processed = payload.text
|
||||
? deps.processLineMessage(payload.text)
|
||||
: { text: "", flexMessages: [] };
|
||||
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
richMessages.push(deps.createFlexMessage(flexMsg.altText.slice(0, 400), flexMsg.contents));
|
||||
}
|
||||
|
||||
const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : [];
|
||||
|
||||
const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls;
|
||||
const mediaMessages = mediaUrls
|
||||
.map((url) => url?.trim())
|
||||
.filter((url): url is string => Boolean(url))
|
||||
.map((url) => deps.createImageMessage(url));
|
||||
|
||||
if (chunks.length > 0) {
|
||||
const hasRichOrMedia = richMessages.length > 0 || mediaMessages.length > 0;
|
||||
if (hasQuickReplies && hasRichOrMedia) {
|
||||
try {
|
||||
await sendLineMessages([...richMessages, ...mediaMessages], false);
|
||||
} catch (err) {
|
||||
deps.onReplyError?.(err);
|
||||
}
|
||||
}
|
||||
const { replyTokenUsed: nextReplyTokenUsed } = await deps.sendLineReplyChunks({
|
||||
to,
|
||||
chunks,
|
||||
quickReplies: lineData.quickReplies,
|
||||
replyToken,
|
||||
replyTokenUsed,
|
||||
accountId,
|
||||
replyMessageLine: deps.replyMessageLine,
|
||||
pushMessageLine: deps.pushMessageLine,
|
||||
pushTextMessageWithQuickReplies: deps.pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies: deps.createTextMessageWithQuickReplies,
|
||||
});
|
||||
replyTokenUsed = nextReplyTokenUsed;
|
||||
if (!hasQuickReplies || !hasRichOrMedia) {
|
||||
await sendLineMessages(richMessages, false);
|
||||
if (mediaMessages.length > 0) {
|
||||
await sendLineMessages(mediaMessages, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const combined = [...richMessages, ...mediaMessages];
|
||||
if (hasQuickReplies && combined.length > 0) {
|
||||
const quickReply = deps.createQuickReplyItems(lineData.quickReplies!);
|
||||
const targetIndex =
|
||||
replyToken && !replyTokenUsed ? Math.min(4, combined.length - 1) : combined.length - 1;
|
||||
const target = combined[targetIndex] as messagingApi.Message & {
|
||||
quickReply?: messagingApi.QuickReply;
|
||||
};
|
||||
combined[targetIndex] = { ...target, quickReply };
|
||||
}
|
||||
await sendLineMessages(combined, true);
|
||||
}
|
||||
|
||||
return { replyTokenUsed };
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import {
|
||||
firstDefined,
|
||||
isSenderIdAllowed,
|
||||
mergeDmAllowFromSources,
|
||||
} from "../channels/allow-from.js";
|
||||
|
||||
export type NormalizedAllowFrom = {
|
||||
entries: string[];
|
||||
hasWildcard: boolean;
|
||||
hasEntries: boolean;
|
||||
};
|
||||
|
||||
function normalizeAllowEntry(value: string | number): string {
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return "*";
|
||||
}
|
||||
return trimmed.replace(/^line:(?:user:)?/i, "");
|
||||
}
|
||||
|
||||
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
|
||||
const entries = (list ?? []).map((value) => normalizeAllowEntry(value)).filter(Boolean);
|
||||
const hasWildcard = entries.includes("*");
|
||||
return {
|
||||
entries,
|
||||
hasWildcard,
|
||||
hasEntries: entries.length > 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeDmAllowFromWithStore = (params: {
|
||||
allowFrom?: Array<string | number>;
|
||||
storeAllowFrom?: string[];
|
||||
dmPolicy?: string;
|
||||
}): NormalizedAllowFrom => normalizeAllowFrom(mergeDmAllowFromSources(params));
|
||||
|
||||
export const isSenderAllowed = (params: {
|
||||
allow: NormalizedAllowFrom;
|
||||
senderId?: string;
|
||||
}): boolean => {
|
||||
const { allow, senderId } = params;
|
||||
return isSenderIdAllowed(allow, senderId, false);
|
||||
};
|
||||
|
||||
export { firstDefined };
|
||||
@@ -1,893 +0,0 @@
|
||||
import type { MessageEvent, PostbackEvent } from "@line/bot-sdk";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LineAccountConfig } from "./types.js";
|
||||
|
||||
// Avoid pulling in globals/pairing/media dependencies; this suite only asserts
|
||||
// allowlist/groupPolicy gating and message-context wiring.
|
||||
vi.mock("../globals.js", () => ({
|
||||
danger: (text: string) => text,
|
||||
logVerbose: () => {},
|
||||
shouldLogVerbose: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../pairing/pairing-labels.js", () => ({
|
||||
resolvePairingIdLabel: () => "lineUserId",
|
||||
}));
|
||||
|
||||
vi.mock("../pairing/pairing-messages.js", () => ({
|
||||
buildPairingReply: () => "pairing-reply",
|
||||
}));
|
||||
|
||||
vi.mock("./download.js", () => ({
|
||||
downloadLineMedia: async () => {
|
||||
throw new Error("downloadLineMedia should not be called from bot-handlers tests");
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
pushMessageLine: async () => {
|
||||
throw new Error("pushMessageLine should not be called from bot-handlers tests");
|
||||
},
|
||||
replyMessageLine: async () => {
|
||||
throw new Error("replyMessageLine should not be called from bot-handlers tests");
|
||||
},
|
||||
}));
|
||||
|
||||
const { buildLineMessageContextMock, buildLinePostbackContextMock } = vi.hoisted(() => ({
|
||||
buildLineMessageContextMock: vi.fn(async () => ({
|
||||
ctxPayload: { From: "line:group:group-1" },
|
||||
replyToken: "reply-token",
|
||||
route: { agentId: "default" },
|
||||
isGroup: true,
|
||||
accountId: "default",
|
||||
})),
|
||||
buildLinePostbackContextMock: vi.fn(async () => null as unknown),
|
||||
}));
|
||||
|
||||
vi.mock("./bot-message-context.js", () => ({
|
||||
buildLineMessageContext: buildLineMessageContextMock,
|
||||
buildLinePostbackContext: buildLinePostbackContextMock,
|
||||
getLineSourceInfo: (source: {
|
||||
type?: string;
|
||||
userId?: string;
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
}) => ({
|
||||
userId: source.userId,
|
||||
groupId: source.type === "group" ? source.groupId : undefined,
|
||||
roomId: source.type === "room" ? source.roomId : undefined,
|
||||
isGroup: source.type === "group" || source.type === "room",
|
||||
}),
|
||||
}));
|
||||
|
||||
const { readAllowFromStoreMock, upsertPairingRequestMock } = vi.hoisted(() => ({
|
||||
readAllowFromStoreMock: vi.fn(async () => [] as string[]),
|
||||
upsertPairingRequestMock: vi.fn(async () => ({ code: "CODE", created: true })),
|
||||
}));
|
||||
|
||||
let handleLineWebhookEvents: typeof import("./bot-handlers.js").handleLineWebhookEvents;
|
||||
let createLineWebhookReplayCache: typeof import("./bot-handlers.js").createLineWebhookReplayCache;
|
||||
type LineWebhookContext = Parameters<typeof import("./bot-handlers.js").handleLineWebhookEvents>[1];
|
||||
|
||||
const createRuntime = () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() });
|
||||
|
||||
function createReplayMessageEvent(params: {
|
||||
messageId: string;
|
||||
groupId: string;
|
||||
userId: string;
|
||||
webhookEventId: string;
|
||||
isRedelivery: boolean;
|
||||
}) {
|
||||
return {
|
||||
type: "message",
|
||||
message: { id: params.messageId, type: "text", text: "hello", quoteToken: "quote-token" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId: params.groupId, userId: params.userId },
|
||||
mode: "active",
|
||||
webhookEventId: params.webhookEventId,
|
||||
deliveryContext: { isRedelivery: params.isRedelivery },
|
||||
} as MessageEvent;
|
||||
}
|
||||
|
||||
function createTestMessageEvent(params: {
|
||||
message: MessageEvent["message"];
|
||||
source: MessageEvent["source"];
|
||||
webhookEventId: string;
|
||||
timestamp?: number;
|
||||
replyToken?: string;
|
||||
isRedelivery?: boolean;
|
||||
}) {
|
||||
return {
|
||||
type: "message",
|
||||
message: params.message,
|
||||
replyToken: params.replyToken ?? "reply-token",
|
||||
timestamp: params.timestamp ?? Date.now(),
|
||||
source: params.source,
|
||||
mode: "active",
|
||||
webhookEventId: params.webhookEventId,
|
||||
deliveryContext: { isRedelivery: params.isRedelivery ?? false },
|
||||
} as MessageEvent;
|
||||
}
|
||||
|
||||
function createLineWebhookTestContext(params: {
|
||||
processMessage: LineWebhookContext["processMessage"];
|
||||
groupPolicy?: LineAccountConfig["groupPolicy"];
|
||||
dmPolicy?: LineAccountConfig["dmPolicy"];
|
||||
requireMention?: boolean;
|
||||
groupHistories?: Map<string, import("../auto-reply/reply/history.js").HistoryEntry[]>;
|
||||
replayCache?: ReturnType<typeof createLineWebhookReplayCache>;
|
||||
}): Parameters<typeof handleLineWebhookEvents>[1] {
|
||||
const lineConfig = {
|
||||
...(params.groupPolicy ? { groupPolicy: params.groupPolicy } : {}),
|
||||
...(params.dmPolicy ? { dmPolicy: params.dmPolicy } : {}),
|
||||
};
|
||||
return {
|
||||
cfg: { channels: { line: lineConfig } },
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: {
|
||||
...lineConfig,
|
||||
...(params.requireMention === undefined
|
||||
? {}
|
||||
: { groups: { "*": { requireMention: params.requireMention } } }),
|
||||
},
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage: params.processMessage,
|
||||
...(params.groupHistories ? { groupHistories: params.groupHistories } : {}),
|
||||
...(params.replayCache ? { replayCache: params.replayCache } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenGroupReplayContext(
|
||||
processMessage: LineWebhookContext["processMessage"],
|
||||
replayCache: ReturnType<typeof createLineWebhookReplayCache>,
|
||||
): Parameters<typeof handleLineWebhookEvents>[1] {
|
||||
return createLineWebhookTestContext({
|
||||
processMessage,
|
||||
groupPolicy: "open",
|
||||
requireMention: false,
|
||||
replayCache,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectGroupMessageBlocked(params: {
|
||||
processMessage: LineWebhookContext["processMessage"];
|
||||
event: MessageEvent;
|
||||
context: Parameters<typeof handleLineWebhookEvents>[1];
|
||||
}) {
|
||||
await handleLineWebhookEvents([params.event], params.context);
|
||||
expect(params.processMessage).not.toHaveBeenCalled();
|
||||
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function expectRequireMentionGroupMessageProcessed(event: MessageEvent) {
|
||||
const processMessage = vi.fn();
|
||||
await handleLineWebhookEvents(
|
||||
[event],
|
||||
createLineWebhookTestContext({
|
||||
processMessage,
|
||||
groupPolicy: "open",
|
||||
requireMention: true,
|
||||
}),
|
||||
);
|
||||
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
async function startInflightReplayDuplicate(params: {
|
||||
event: MessageEvent;
|
||||
processMessage: LineWebhookContext["processMessage"];
|
||||
}) {
|
||||
const context = createOpenGroupReplayContext(
|
||||
params.processMessage,
|
||||
createLineWebhookReplayCache(),
|
||||
);
|
||||
const firstRun = handleLineWebhookEvents([params.event], context);
|
||||
await Promise.resolve();
|
||||
const secondRun = handleLineWebhookEvents([params.event], context);
|
||||
return { firstRun, secondRun };
|
||||
}
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: readAllowFromStoreMock,
|
||||
upsertChannelPairingRequest: upsertPairingRequestMock,
|
||||
}));
|
||||
|
||||
describe("handleLineWebhookEvents", () => {
|
||||
beforeAll(async () => {
|
||||
({ handleLineWebhookEvents, createLineWebhookReplayCache } = await import("./bot-handlers.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
buildLineMessageContextMock.mockClear();
|
||||
buildLinePostbackContextMock.mockClear();
|
||||
readAllowFromStoreMock.mockClear();
|
||||
upsertPairingRequestMock.mockClear();
|
||||
});
|
||||
|
||||
it("blocks group messages when groupPolicy is disabled", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "m1", type: "text", text: "hi" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId: "group-1", userId: "user-1" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-1",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
await handleLineWebhookEvents([event], {
|
||||
cfg: { channels: { line: { groupPolicy: "disabled" } } },
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: { groupPolicy: "disabled" },
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
});
|
||||
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks group messages when allowlist is empty", async () => {
|
||||
const processMessage = vi.fn();
|
||||
await expectGroupMessageBlocked({
|
||||
processMessage,
|
||||
event: createTestMessageEvent({
|
||||
message: { id: "m2", type: "text", text: "hi", quoteToken: "quote-token" },
|
||||
source: { type: "group", groupId: "group-1", userId: "user-2" },
|
||||
webhookEventId: "evt-2",
|
||||
}),
|
||||
context: createLineWebhookTestContext({
|
||||
processMessage,
|
||||
groupPolicy: "allowlist",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("allows group messages when sender is in groupAllowFrom", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "m3", type: "text", text: "hi" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId: "group-1", userId: "user-3" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-3",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
await handleLineWebhookEvents([event], {
|
||||
cfg: {
|
||||
channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-3"] } },
|
||||
},
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["user-3"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
});
|
||||
|
||||
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks group sender not in groupAllowFrom even when sender is paired in DM store", async () => {
|
||||
readAllowFromStoreMock.mockResolvedValueOnce(["user-store"]);
|
||||
const processMessage = vi.fn();
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "m5", type: "text", text: "hi" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId: "group-1", userId: "user-store" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-5",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
await handleLineWebhookEvents([event], {
|
||||
cfg: {
|
||||
channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-group"] } },
|
||||
},
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: { groupPolicy: "allowlist", groupAllowFrom: ["user-group"] },
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
});
|
||||
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledWith("line", undefined, "default");
|
||||
});
|
||||
|
||||
it("blocks group messages without sender id when groupPolicy is allowlist", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "m5a", type: "text", text: "hi" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId: "group-1" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-5a",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
await handleLineWebhookEvents([event], {
|
||||
cfg: {
|
||||
channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-5"] } },
|
||||
},
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: { groupPolicy: "allowlist", groupAllowFrom: ["user-5"] },
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
});
|
||||
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not authorize group messages from DM pairing-store entries when group allowlist is empty", async () => {
|
||||
readAllowFromStoreMock.mockResolvedValueOnce(["user-5"]);
|
||||
const processMessage = vi.fn();
|
||||
await expectGroupMessageBlocked({
|
||||
processMessage,
|
||||
event: createTestMessageEvent({
|
||||
message: { id: "m5b", type: "text", text: "hi", quoteToken: "quote-token" },
|
||||
source: { type: "group", groupId: "group-1", userId: "user-5" },
|
||||
webhookEventId: "evt-5b",
|
||||
}),
|
||||
context: {
|
||||
cfg: { channels: { line: { groupPolicy: "allowlist" } } },
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks group messages when wildcard group config disables groups", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "m4", type: "text", text: "hi" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId: "group-2", userId: "user-4" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-4",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
await handleLineWebhookEvents([event], {
|
||||
cfg: { channels: { line: { groupPolicy: "open" } } },
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: { groupPolicy: "open", groups: { "*": { enabled: false } } },
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
});
|
||||
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes DM pairing requests to accountId", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "m5", type: "text", text: "hi" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "user", userId: "user-5" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-5",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
await handleLineWebhookEvents([event], {
|
||||
cfg: { channels: { line: { dmPolicy: "pairing" } } },
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: { dmPolicy: "pairing", allowFrom: ["user-owner"] },
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
});
|
||||
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "line",
|
||||
id: "user-5",
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not authorize DM senders from another account's pairing-store entries", async () => {
|
||||
const processMessage = vi.fn();
|
||||
readAllowFromStoreMock.mockImplementation(async (...args: unknown[]) => {
|
||||
const accountId = args[2] as string | undefined;
|
||||
if (accountId === "work") {
|
||||
return [];
|
||||
}
|
||||
return ["cross-account-user"];
|
||||
});
|
||||
upsertPairingRequestMock.mockResolvedValue({ code: "CODE", created: false });
|
||||
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "m6", type: "text", text: "hi" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "user", userId: "cross-account-user" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-6",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
await handleLineWebhookEvents([event], {
|
||||
cfg: { channels: { line: { dmPolicy: "pairing" } } },
|
||||
account: {
|
||||
accountId: "work",
|
||||
enabled: true,
|
||||
channelAccessToken: "token-work", // pragma: allowlist secret
|
||||
channelSecret: "secret-work", // pragma: allowlist secret
|
||||
tokenSource: "config",
|
||||
config: { dmPolicy: "pairing" },
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
});
|
||||
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledWith("line", undefined, "work");
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "line",
|
||||
id: "cross-account-user",
|
||||
accountId: "work",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("deduplicates replayed webhook events by webhookEventId before processing", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = createReplayMessageEvent({
|
||||
messageId: "m-replay",
|
||||
groupId: "group-replay",
|
||||
userId: "user-replay",
|
||||
webhookEventId: "evt-replay-1",
|
||||
isRedelivery: true,
|
||||
});
|
||||
const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache());
|
||||
|
||||
await handleLineWebhookEvents([event], context);
|
||||
await handleLineWebhookEvents([event], context);
|
||||
|
||||
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips concurrent redeliveries while the first event is still processing", async () => {
|
||||
let resolveFirst: (() => void) | undefined;
|
||||
const firstDone = new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const processMessage = vi.fn(async () => {
|
||||
await firstDone;
|
||||
});
|
||||
const event = createReplayMessageEvent({
|
||||
messageId: "m-inflight",
|
||||
groupId: "group-inflight",
|
||||
userId: "user-inflight",
|
||||
webhookEventId: "evt-inflight-1",
|
||||
isRedelivery: true,
|
||||
});
|
||||
const { firstRun, secondRun } = await startInflightReplayDuplicate({ event, processMessage });
|
||||
resolveFirst?.();
|
||||
await Promise.all([firstRun, secondRun]);
|
||||
|
||||
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("mirrors in-flight replay failures so concurrent duplicates also fail", async () => {
|
||||
let rejectFirst: ((err: Error) => void) | undefined;
|
||||
const firstDone = new Promise<void>((_, reject) => {
|
||||
rejectFirst = reject;
|
||||
});
|
||||
const processMessage = vi.fn(async () => {
|
||||
await firstDone;
|
||||
});
|
||||
const event = createReplayMessageEvent({
|
||||
messageId: "m-inflight-fail",
|
||||
groupId: "group-inflight",
|
||||
userId: "user-inflight",
|
||||
webhookEventId: "evt-inflight-fail-1",
|
||||
isRedelivery: true,
|
||||
});
|
||||
const { firstRun, secondRun } = await startInflightReplayDuplicate({ event, processMessage });
|
||||
rejectFirst?.(new Error("transient inflight failure"));
|
||||
|
||||
await expect(firstRun).rejects.toThrow("transient inflight failure");
|
||||
await expect(secondRun).rejects.toThrow("transient inflight failure");
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deduplicates redeliveries by LINE message id when webhookEventId changes", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "m-dup-1", type: "text", text: "hello" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId: "group-dup", userId: "user-dup" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-dup-1",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
const context: Parameters<typeof handleLineWebhookEvents>[1] = {
|
||||
cfg: {
|
||||
channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-dup"] } },
|
||||
},
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["user-dup"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
replayCache: createLineWebhookReplayCache(),
|
||||
};
|
||||
|
||||
await handleLineWebhookEvents([event], context);
|
||||
await handleLineWebhookEvents(
|
||||
[
|
||||
{
|
||||
...event,
|
||||
webhookEventId: "evt-dup-redelivery",
|
||||
deliveryContext: { isRedelivery: true },
|
||||
} as MessageEvent,
|
||||
],
|
||||
context,
|
||||
);
|
||||
|
||||
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deduplicates postback redeliveries by webhookEventId when replyToken changes", async () => {
|
||||
const processMessage = vi.fn();
|
||||
buildLinePostbackContextMock.mockResolvedValue({
|
||||
ctxPayload: { From: "line:user:user-postback" },
|
||||
route: { agentId: "default" },
|
||||
isGroup: false,
|
||||
accountId: "default",
|
||||
});
|
||||
const event = {
|
||||
type: "postback",
|
||||
postback: { data: "action=confirm" },
|
||||
replyToken: "reply-token-1",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "user", userId: "user-postback" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-postback-1",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as PostbackEvent;
|
||||
|
||||
const context: Parameters<typeof handleLineWebhookEvents>[1] = {
|
||||
cfg: { channels: { line: { dmPolicy: "open" } } },
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: { dmPolicy: "open" },
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
replayCache: createLineWebhookReplayCache(),
|
||||
};
|
||||
|
||||
await handleLineWebhookEvents([event], context);
|
||||
await handleLineWebhookEvents(
|
||||
[
|
||||
{
|
||||
...event,
|
||||
replyToken: "reply-token-2",
|
||||
deliveryContext: { isRedelivery: true },
|
||||
} as PostbackEvent,
|
||||
],
|
||||
context,
|
||||
);
|
||||
|
||||
expect(buildLinePostbackContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips group messages by default when requireMention is not configured", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = createTestMessageEvent({
|
||||
message: { id: "m-default-skip", type: "text", text: "hi there", quoteToken: "q-default" },
|
||||
source: { type: "group", groupId: "group-default", userId: "user-default" },
|
||||
webhookEventId: "evt-default-skip",
|
||||
});
|
||||
|
||||
await handleLineWebhookEvents(
|
||||
[event],
|
||||
createLineWebhookTestContext({
|
||||
processMessage,
|
||||
groupPolicy: "open",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records unmentioned group messages as pending history", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const groupHistories = new Map<
|
||||
string,
|
||||
import("../auto-reply/reply/history.js").HistoryEntry[]
|
||||
>();
|
||||
const event = createTestMessageEvent({
|
||||
message: { id: "m-hist-1", type: "text", text: "hello history", quoteToken: "q-hist-1" },
|
||||
timestamp: 1700000000000,
|
||||
source: { type: "group", groupId: "group-hist-1", userId: "user-hist" },
|
||||
webhookEventId: "evt-hist-1",
|
||||
});
|
||||
|
||||
await handleLineWebhookEvents(
|
||||
[event],
|
||||
createLineWebhookTestContext({
|
||||
processMessage,
|
||||
groupPolicy: "open",
|
||||
groupHistories,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
const entries = groupHistories.get("group-hist-1");
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries?.[0]).toMatchObject({
|
||||
sender: "user:user-hist",
|
||||
body: "hello history",
|
||||
timestamp: 1700000000000,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips group messages without mention when requireMention is set", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = createTestMessageEvent({
|
||||
message: { id: "m-mention-1", type: "text", text: "hi there", quoteToken: "q-mention-1" },
|
||||
source: { type: "group", groupId: "group-mention", userId: "user-mention" },
|
||||
webhookEventId: "evt-mention-1",
|
||||
});
|
||||
|
||||
await handleLineWebhookEvents(
|
||||
[event],
|
||||
createLineWebhookTestContext({
|
||||
processMessage,
|
||||
groupPolicy: "open",
|
||||
requireMention: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("processes group messages with bot mention when requireMention is set", async () => {
|
||||
const processMessage = vi.fn();
|
||||
// Simulate a LINE text message with mention.mentionees containing isSelf=true
|
||||
const event = createTestMessageEvent({
|
||||
message: {
|
||||
id: "m-mention-2",
|
||||
type: "text",
|
||||
text: "@Bot hi there",
|
||||
mention: {
|
||||
mentionees: [{ index: 0, length: 4, type: "user", isSelf: true }],
|
||||
},
|
||||
} as unknown as MessageEvent["message"],
|
||||
source: { type: "group", groupId: "group-mention", userId: "user-mention" },
|
||||
webhookEventId: "evt-mention-2",
|
||||
});
|
||||
|
||||
await handleLineWebhookEvents(
|
||||
[event],
|
||||
createLineWebhookTestContext({
|
||||
processMessage,
|
||||
groupPolicy: "open",
|
||||
requireMention: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("processes group messages with @all mention when requireMention is set", async () => {
|
||||
const event = createTestMessageEvent({
|
||||
message: {
|
||||
id: "m-mention-3",
|
||||
type: "text",
|
||||
text: "@All hi there",
|
||||
mention: {
|
||||
mentionees: [{ index: 0, length: 4, type: "all" }],
|
||||
},
|
||||
} as MessageEvent["message"],
|
||||
source: { type: "group", groupId: "group-mention", userId: "user-mention" },
|
||||
webhookEventId: "evt-mention-3",
|
||||
});
|
||||
|
||||
await expectRequireMentionGroupMessageProcessed(event);
|
||||
});
|
||||
|
||||
it("does not apply requireMention gating to DM messages", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = createTestMessageEvent({
|
||||
message: { id: "m-mention-dm", type: "text", text: "hi", quoteToken: "q-mention-dm" },
|
||||
source: { type: "user", userId: "user-dm" },
|
||||
webhookEventId: "evt-mention-dm",
|
||||
});
|
||||
|
||||
await handleLineWebhookEvents(
|
||||
[event],
|
||||
createLineWebhookTestContext({
|
||||
processMessage,
|
||||
dmPolicy: "open",
|
||||
requireMention: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows non-text group messages through when requireMention is set (cannot detect mention)", async () => {
|
||||
// Image message -- LINE only carries mention metadata on text messages.
|
||||
const event = createTestMessageEvent({
|
||||
message: {
|
||||
id: "m-mention-img",
|
||||
type: "image",
|
||||
contentProvider: { type: "line" },
|
||||
quoteToken: "q-mention-img",
|
||||
},
|
||||
source: { type: "group", groupId: "group-1", userId: "user-img" },
|
||||
webhookEventId: "evt-mention-img",
|
||||
});
|
||||
|
||||
await expectRequireMentionGroupMessageProcessed(event);
|
||||
});
|
||||
|
||||
it("does not bypass mention gating when non-bot mention is present with control command", async () => {
|
||||
const processMessage = vi.fn();
|
||||
// Text message mentions another user (not bot) together with a control command.
|
||||
const event = createTestMessageEvent({
|
||||
message: {
|
||||
id: "m-mention-other",
|
||||
type: "text",
|
||||
text: "@other !status",
|
||||
mention: { mentionees: [{ index: 0, length: 6, type: "user", isSelf: false }] },
|
||||
} as unknown as MessageEvent["message"],
|
||||
source: { type: "group", groupId: "group-1", userId: "user-other" },
|
||||
webhookEventId: "evt-mention-other",
|
||||
});
|
||||
|
||||
await handleLineWebhookEvents(
|
||||
[event],
|
||||
createLineWebhookTestContext({
|
||||
processMessage,
|
||||
groupPolicy: "open",
|
||||
requireMention: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Should be skipped because there is a non-bot mention and the bot was not mentioned.
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not mark replay cache when event processing fails", async () => {
|
||||
const processMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("transient failure"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const event = createReplayMessageEvent({
|
||||
messageId: "m-fail-then-retry",
|
||||
groupId: "group-retry",
|
||||
userId: "user-retry",
|
||||
webhookEventId: "evt-fail-then-retry",
|
||||
isRedelivery: false,
|
||||
});
|
||||
const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache());
|
||||
|
||||
await expect(handleLineWebhookEvents([event], context)).rejects.toThrow("transient failure");
|
||||
await handleLineWebhookEvents([event], context);
|
||||
|
||||
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(2);
|
||||
expect(processMessage).toHaveBeenCalledTimes(2);
|
||||
expect(context.runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("line: event handler failed: Error: transient failure"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,715 +0,0 @@
|
||||
import type {
|
||||
WebhookEvent,
|
||||
MessageEvent,
|
||||
FollowEvent,
|
||||
UnfollowEvent,
|
||||
JoinEvent,
|
||||
LeaveEvent,
|
||||
PostbackEvent,
|
||||
} from "@line/bot-sdk";
|
||||
import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||
import {
|
||||
clearHistoryEntriesIfEnabled,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
type HistoryEntry,
|
||||
} from "../auto-reply/reply/history.js";
|
||||
import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
|
||||
import { resolveControlCommandGate } from "../channels/command-gating.js";
|
||||
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "../config/runtime-group-policy.js";
|
||||
import { danger, logVerbose } from "../globals.js";
|
||||
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest,
|
||||
} from "../pairing/pairing-store.js";
|
||||
import { createChannelPairingChallengeIssuer } from "../plugin-sdk/channel-pairing.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import {
|
||||
firstDefined,
|
||||
isSenderAllowed,
|
||||
normalizeAllowFrom,
|
||||
normalizeDmAllowFromWithStore,
|
||||
type NormalizedAllowFrom,
|
||||
} from "./bot-access.js";
|
||||
import {
|
||||
getLineSourceInfo,
|
||||
buildLineMessageContext,
|
||||
buildLinePostbackContext,
|
||||
type LineInboundContext,
|
||||
} from "./bot-message-context.js";
|
||||
import { downloadLineMedia } from "./download.js";
|
||||
import { resolveLineGroupConfigEntry } from "./group-keys.js";
|
||||
import { pushMessageLine, replyMessageLine } from "./send.js";
|
||||
import type { LineGroupConfig, ResolvedLineAccount } from "./types.js";
|
||||
|
||||
interface MediaRef {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
const LINE_DOWNLOADABLE_MESSAGE_TYPES: ReadonlySet<string> = new Set([
|
||||
"image",
|
||||
"video",
|
||||
"audio",
|
||||
"file",
|
||||
]);
|
||||
|
||||
function isDownloadableLineMessageType(
|
||||
messageType: MessageEvent["message"]["type"],
|
||||
): messageType is "image" | "video" | "audio" | "file" {
|
||||
return LINE_DOWNLOADABLE_MESSAGE_TYPES.has(messageType);
|
||||
}
|
||||
|
||||
export interface LineHandlerContext {
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
runtime: RuntimeEnv;
|
||||
mediaMaxBytes: number;
|
||||
processMessage: (ctx: LineInboundContext) => Promise<void>;
|
||||
replayCache?: LineWebhookReplayCache;
|
||||
groupHistories?: Map<string, HistoryEntry[]>;
|
||||
historyLimit?: number;
|
||||
}
|
||||
|
||||
const LINE_WEBHOOK_REPLAY_WINDOW_MS = 10 * 60 * 1000;
|
||||
const LINE_WEBHOOK_REPLAY_MAX_ENTRIES = 4096;
|
||||
const LINE_WEBHOOK_REPLAY_PRUNE_INTERVAL_MS = 1000;
|
||||
export type LineWebhookReplayCache = {
|
||||
seenEvents: Map<string, number>;
|
||||
inFlightEvents: Map<string, Promise<void>>;
|
||||
lastPruneAtMs: number;
|
||||
};
|
||||
|
||||
export function createLineWebhookReplayCache(): LineWebhookReplayCache {
|
||||
return {
|
||||
seenEvents: new Map<string, number>(),
|
||||
inFlightEvents: new Map<string, Promise<void>>(),
|
||||
lastPruneAtMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function pruneLineWebhookReplayCache(cache: LineWebhookReplayCache, nowMs: number): void {
|
||||
const minSeenAt = nowMs - LINE_WEBHOOK_REPLAY_WINDOW_MS;
|
||||
for (const [key, seenAt] of cache.seenEvents) {
|
||||
if (seenAt < minSeenAt) {
|
||||
cache.seenEvents.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (cache.seenEvents.size > LINE_WEBHOOK_REPLAY_MAX_ENTRIES) {
|
||||
const deleteCount = cache.seenEvents.size - LINE_WEBHOOK_REPLAY_MAX_ENTRIES;
|
||||
let deleted = 0;
|
||||
for (const key of cache.seenEvents.keys()) {
|
||||
if (deleted >= deleteCount) {
|
||||
break;
|
||||
}
|
||||
cache.seenEvents.delete(key);
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildLineWebhookReplayKey(
|
||||
event: WebhookEvent,
|
||||
accountId: string,
|
||||
): { key: string; eventId: string } | null {
|
||||
if (event.type === "message") {
|
||||
const messageId = event.message?.id?.trim();
|
||||
if (messageId) {
|
||||
return {
|
||||
key: `${accountId}|message:${messageId}`,
|
||||
eventId: `message:${messageId}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
const eventId = (event as { webhookEventId?: string }).webhookEventId?.trim();
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = (
|
||||
event as {
|
||||
source?: { type?: string; userId?: string; groupId?: string; roomId?: string };
|
||||
}
|
||||
).source;
|
||||
const sourceId =
|
||||
source?.type === "group"
|
||||
? `group:${source.groupId ?? ""}`
|
||||
: source?.type === "room"
|
||||
? `room:${source.roomId ?? ""}`
|
||||
: `user:${source?.userId ?? ""}`;
|
||||
return { key: `${accountId}|${event.type}|${sourceId}|${eventId}`, eventId: `event:${eventId}` };
|
||||
}
|
||||
|
||||
type LineReplayCandidate = {
|
||||
key: string;
|
||||
eventId: string;
|
||||
seenAtMs: number;
|
||||
cache: LineWebhookReplayCache;
|
||||
};
|
||||
|
||||
type LineInFlightReplayResult = {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (err: unknown) => void;
|
||||
};
|
||||
|
||||
function getLineReplayCandidate(
|
||||
event: WebhookEvent,
|
||||
context: LineHandlerContext,
|
||||
): LineReplayCandidate | null {
|
||||
const replay = buildLineWebhookReplayKey(event, context.account.accountId);
|
||||
const cache = context.replayCache;
|
||||
if (!replay || !cache) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
if (
|
||||
nowMs - cache.lastPruneAtMs >= LINE_WEBHOOK_REPLAY_PRUNE_INTERVAL_MS ||
|
||||
cache.seenEvents.size >= LINE_WEBHOOK_REPLAY_MAX_ENTRIES
|
||||
) {
|
||||
pruneLineWebhookReplayCache(cache, nowMs);
|
||||
cache.lastPruneAtMs = nowMs;
|
||||
}
|
||||
return { key: replay.key, eventId: replay.eventId, seenAtMs: nowMs, cache };
|
||||
}
|
||||
|
||||
function shouldSkipLineReplayEvent(
|
||||
candidate: LineReplayCandidate,
|
||||
): { skip: true; inFlightResult?: Promise<void> } | { skip: false } {
|
||||
const inFlightResult = candidate.cache.inFlightEvents.get(candidate.key);
|
||||
if (inFlightResult) {
|
||||
logVerbose(`line: skipped in-flight replayed webhook event ${candidate.eventId}`);
|
||||
return { skip: true, inFlightResult };
|
||||
}
|
||||
if (candidate.cache.seenEvents.has(candidate.key)) {
|
||||
logVerbose(`line: skipped replayed webhook event ${candidate.eventId}`);
|
||||
return { skip: true };
|
||||
}
|
||||
return { skip: false };
|
||||
}
|
||||
|
||||
function markLineReplayEventInFlight(candidate: LineReplayCandidate): LineInFlightReplayResult {
|
||||
let resolve!: () => void;
|
||||
let reject!: (err: unknown) => void;
|
||||
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
// Prevent unhandled rejection warnings when no concurrent duplicate awaits
|
||||
// this in-flight reservation.
|
||||
void promise.catch(() => {});
|
||||
candidate.cache.inFlightEvents.set(candidate.key, promise);
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function clearLineReplayEventInFlight(candidate: LineReplayCandidate): void {
|
||||
candidate.cache.inFlightEvents.delete(candidate.key);
|
||||
}
|
||||
|
||||
function rememberLineReplayEvent(candidate: LineReplayCandidate): void {
|
||||
candidate.cache.seenEvents.set(candidate.key, candidate.seenAtMs);
|
||||
}
|
||||
|
||||
function resolveLineGroupConfig(params: {
|
||||
config: ResolvedLineAccount["config"];
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
}): LineGroupConfig | undefined {
|
||||
return resolveLineGroupConfigEntry(params.config.groups, {
|
||||
groupId: params.groupId,
|
||||
roomId: params.roomId,
|
||||
});
|
||||
}
|
||||
|
||||
async function sendLinePairingReply(params: {
|
||||
senderId: string;
|
||||
replyToken?: string;
|
||||
context: LineHandlerContext;
|
||||
}): Promise<void> {
|
||||
const { senderId, replyToken, context } = params;
|
||||
const idLabel = (() => {
|
||||
try {
|
||||
return resolvePairingIdLabel("line");
|
||||
} catch {
|
||||
return "lineUserId";
|
||||
}
|
||||
})();
|
||||
await createChannelPairingChallengeIssuer({
|
||||
channel: "line",
|
||||
upsertPairingRequest: async ({ id, meta }) =>
|
||||
await upsertChannelPairingRequest({
|
||||
channel: "line",
|
||||
id,
|
||||
accountId: context.account.accountId,
|
||||
meta,
|
||||
}),
|
||||
})({
|
||||
senderId,
|
||||
senderIdLine: `Your ${idLabel}: ${senderId}`,
|
||||
onCreated: () => {
|
||||
logVerbose(`line pairing request sender=${senderId}`);
|
||||
},
|
||||
sendPairingReply: async (text) => {
|
||||
if (replyToken) {
|
||||
try {
|
||||
await replyMessageLine(replyToken, [{ type: "text", text }], {
|
||||
accountId: context.account.accountId,
|
||||
channelAccessToken: context.account.channelAccessToken,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await pushMessageLine(`line:${senderId}`, text, {
|
||||
accountId: context.account.accountId,
|
||||
channelAccessToken: context.account.channelAccessToken,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function shouldProcessLineEvent(
|
||||
event: MessageEvent | PostbackEvent,
|
||||
context: LineHandlerContext,
|
||||
): Promise<{ allowed: boolean; commandAuthorized: boolean }> {
|
||||
const denied = { allowed: false, commandAuthorized: false };
|
||||
const { cfg, account } = context;
|
||||
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(event.source);
|
||||
const senderId = userId ?? "";
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
|
||||
const storeAllowFrom = await readChannelAllowFromStore(
|
||||
"line",
|
||||
undefined,
|
||||
account.accountId,
|
||||
).catch(() => []);
|
||||
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
||||
allowFrom: account.config.allowFrom,
|
||||
storeAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId });
|
||||
const groupAllowOverride = groupConfig?.allowFrom;
|
||||
const fallbackGroupAllowFrom = account.config.allowFrom?.length
|
||||
? account.config.allowFrom
|
||||
: undefined;
|
||||
const groupAllowFrom = firstDefined(
|
||||
groupAllowOverride,
|
||||
account.config.groupAllowFrom,
|
||||
fallbackGroupAllowFrom,
|
||||
);
|
||||
// Group sender policy must be derived from explicit group config only.
|
||||
// Pairing store entries are DM-oriented and must not expand group allowlists.
|
||||
const effectiveGroupAllow = normalizeAllowFrom(groupAllowFrom);
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.line !== undefined,
|
||||
groupPolicy: account.config.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "line",
|
||||
accountId: account.accountId,
|
||||
log: (message) => logVerbose(message),
|
||||
});
|
||||
|
||||
if (isGroup) {
|
||||
if (groupConfig?.enabled === false) {
|
||||
logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`);
|
||||
return denied;
|
||||
}
|
||||
if (typeof groupAllowOverride !== "undefined") {
|
||||
if (!senderId) {
|
||||
logVerbose("Blocked line group message (group allowFrom override, no sender ID)");
|
||||
return denied;
|
||||
}
|
||||
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
|
||||
logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`);
|
||||
return denied;
|
||||
}
|
||||
}
|
||||
const senderGroupAccess = evaluateMatchedGroupAccessForPolicy({
|
||||
groupPolicy,
|
||||
requireMatchInput: true,
|
||||
hasMatchInput: Boolean(senderId),
|
||||
allowlistConfigured: effectiveGroupAllow.entries.length > 0,
|
||||
allowlistMatched:
|
||||
Boolean(senderId) &&
|
||||
isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId,
|
||||
}),
|
||||
});
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
|
||||
logVerbose("Blocked line group message (groupPolicy: disabled)");
|
||||
return denied;
|
||||
}
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "missing_match_input") {
|
||||
logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)");
|
||||
return denied;
|
||||
}
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
|
||||
logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)");
|
||||
return denied;
|
||||
}
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "not_allowlisted") {
|
||||
logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
|
||||
return denied;
|
||||
}
|
||||
return {
|
||||
allowed: true,
|
||||
commandAuthorized: resolveLineCommandAuthorized({
|
||||
cfg,
|
||||
event,
|
||||
senderId,
|
||||
allow: effectiveGroupAllow,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose("Blocked line sender (dmPolicy: disabled)");
|
||||
return denied;
|
||||
}
|
||||
|
||||
const dmAllowed = dmPolicy === "open" || isSenderAllowed({ allow: effectiveDmAllow, senderId });
|
||||
if (!dmAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
if (!senderId) {
|
||||
logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)");
|
||||
return denied;
|
||||
}
|
||||
await sendLinePairingReply({
|
||||
senderId,
|
||||
replyToken: "replyToken" in event ? event.replyToken : undefined,
|
||||
context,
|
||||
});
|
||||
} else {
|
||||
logVerbose(`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${dmPolicy})`);
|
||||
}
|
||||
return denied;
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
commandAuthorized: resolveLineCommandAuthorized({
|
||||
cfg,
|
||||
event,
|
||||
senderId,
|
||||
allow: effectiveDmAllow,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract the mentionees array from a LINE text message (SDK types omit it).
|
||||
* LINE webhook payloads include `mention.mentionees` on text messages with
|
||||
* `isSelf: true` for the bot and `type: "all"` for @All mentions.
|
||||
* The `@line/bot-sdk` types don't expose these fields, so we use a type assertion.
|
||||
*/
|
||||
function getLineMentionees(
|
||||
message: MessageEvent["message"],
|
||||
): Array<{ type?: string; isSelf?: boolean }> {
|
||||
if (message.type !== "text") {
|
||||
return [];
|
||||
}
|
||||
const mentionees = (
|
||||
message as Record<string, unknown> & {
|
||||
mention?: { mentionees?: Array<{ type?: string; isSelf?: boolean }> };
|
||||
}
|
||||
).mention?.mentionees;
|
||||
return Array.isArray(mentionees) ? mentionees : [];
|
||||
}
|
||||
|
||||
function isLineBotMentioned(message: MessageEvent["message"]): boolean {
|
||||
return getLineMentionees(message).some((m) => m.isSelf === true || m.type === "all");
|
||||
}
|
||||
|
||||
/** True when *any* @mention exists (bot or other users). */
|
||||
function hasAnyLineMention(message: MessageEvent["message"]): boolean {
|
||||
return getLineMentionees(message).length > 0;
|
||||
}
|
||||
|
||||
function resolveEventRawText(event: MessageEvent | PostbackEvent): string {
|
||||
if (event.type === "message") {
|
||||
const msg = event.message;
|
||||
if (msg.type === "text") {
|
||||
return msg.text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (event.type === "postback") {
|
||||
return event.postback?.data?.trim() ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveLineCommandAuthorized(params: {
|
||||
cfg: OpenClawConfig;
|
||||
event: MessageEvent | PostbackEvent;
|
||||
senderId?: string;
|
||||
allow: NormalizedAllowFrom;
|
||||
}): boolean {
|
||||
const senderAllowedForCommands = isSenderAllowed({
|
||||
allow: params.allow,
|
||||
senderId: params.senderId,
|
||||
});
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
const rawText = resolveEventRawText(params.event);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: params.allow.hasEntries, allowed: senderAllowedForCommands }],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommand(rawText, params.cfg),
|
||||
});
|
||||
return commandGate.commandAuthorized;
|
||||
}
|
||||
|
||||
async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise<void> {
|
||||
const { cfg, account, runtime, mediaMaxBytes, processMessage } = context;
|
||||
const message = event.message;
|
||||
|
||||
const decision = await shouldProcessLineEvent(event, context);
|
||||
if (!decision.allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mention gating: skip group messages that don't @mention the bot when required.
|
||||
// Default requireMention to true (consistent with all other channels) unless
|
||||
// the group config explicitly sets it to false.
|
||||
const { isGroup, groupId, roomId } = getLineSourceInfo(event.source);
|
||||
if (isGroup) {
|
||||
const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId });
|
||||
const requireMention = groupConfig?.requireMention !== false;
|
||||
const rawText = message.type === "text" ? message.text : "";
|
||||
const peerId = groupId ?? roomId ?? event.source.userId ?? "unknown";
|
||||
const { agentId } = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "line",
|
||||
accountId: account.accountId,
|
||||
peer: { kind: "group", id: peerId },
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(cfg, agentId);
|
||||
const wasMentionedByNative = isLineBotMentioned(message);
|
||||
const wasMentionedByPattern =
|
||||
message.type === "text" ? matchesMentionPatterns(rawText, mentionRegexes) : false;
|
||||
const wasMentioned = wasMentionedByNative || wasMentionedByPattern;
|
||||
const mentionGate = resolveMentionGatingWithBypass({
|
||||
isGroup: true,
|
||||
requireMention,
|
||||
// Only text messages carry mention metadata; non-text (image/video/etc.)
|
||||
// cannot be gated on mentions, so we let them through.
|
||||
canDetectMention: message.type === "text",
|
||||
wasMentioned,
|
||||
hasAnyMention: hasAnyLineMention(message),
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommand(rawText, cfg),
|
||||
commandAuthorized: decision.commandAuthorized,
|
||||
});
|
||||
if (mentionGate.shouldSkip) {
|
||||
logVerbose(`line: skipping group message (requireMention, not mentioned)`);
|
||||
// Store as pending history so the agent has context when later mentioned.
|
||||
const historyKey = groupId ?? roomId;
|
||||
const senderId =
|
||||
event.source.type === "group" || event.source.type === "room"
|
||||
? (event.source.userId ?? "unknown")
|
||||
: "unknown";
|
||||
if (historyKey && context.groupHistories) {
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: context.groupHistories,
|
||||
historyKey,
|
||||
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
entry: {
|
||||
sender: `user:${senderId}`,
|
||||
body: rawText || `<${message.type}>`,
|
||||
timestamp: event.timestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Download media if applicable
|
||||
const allMedia: MediaRef[] = [];
|
||||
|
||||
if (isDownloadableLineMessageType(message.type)) {
|
||||
try {
|
||||
const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes);
|
||||
allMedia.push({
|
||||
path: media.path,
|
||||
contentType: media.contentType,
|
||||
});
|
||||
} catch (err) {
|
||||
const errMsg = String(err);
|
||||
if (errMsg.includes("exceeds") && errMsg.includes("limit")) {
|
||||
logVerbose(`line: media exceeds size limit for message ${message.id}`);
|
||||
// Continue without media
|
||||
} else {
|
||||
runtime.error?.(danger(`line: failed to download media: ${errMsg}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messageContext = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia,
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: decision.commandAuthorized,
|
||||
groupHistories: context.groupHistories,
|
||||
historyLimit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
});
|
||||
|
||||
if (!messageContext) {
|
||||
logVerbose("line: skipping empty message");
|
||||
return;
|
||||
}
|
||||
|
||||
await processMessage(messageContext);
|
||||
|
||||
// Clear pending history after a handled group turn so stale skipped messages
|
||||
// don't replay on subsequent mentions ("since last reply" semantics).
|
||||
if (isGroup && context.groupHistories) {
|
||||
const historyKey = groupId ?? roomId;
|
||||
if (historyKey && context.groupHistories.has(historyKey)) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: context.groupHistories,
|
||||
historyKey,
|
||||
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFollowEvent(event: FollowEvent, _context: LineHandlerContext): Promise<void> {
|
||||
const userId = event.source.type === "user" ? event.source.userId : undefined;
|
||||
logVerbose(`line: user ${userId ?? "unknown"} followed`);
|
||||
// Could implement welcome message here
|
||||
}
|
||||
|
||||
async function handleUnfollowEvent(
|
||||
event: UnfollowEvent,
|
||||
_context: LineHandlerContext,
|
||||
): Promise<void> {
|
||||
const userId = event.source.type === "user" ? event.source.userId : undefined;
|
||||
logVerbose(`line: user ${userId ?? "unknown"} unfollowed`);
|
||||
}
|
||||
|
||||
async function handleJoinEvent(event: JoinEvent, _context: LineHandlerContext): Promise<void> {
|
||||
const groupId = event.source.type === "group" ? event.source.groupId : undefined;
|
||||
const roomId = event.source.type === "room" ? event.source.roomId : undefined;
|
||||
logVerbose(`line: bot joined ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
|
||||
}
|
||||
|
||||
async function handleLeaveEvent(event: LeaveEvent, _context: LineHandlerContext): Promise<void> {
|
||||
const groupId = event.source.type === "group" ? event.source.groupId : undefined;
|
||||
const roomId = event.source.type === "room" ? event.source.roomId : undefined;
|
||||
logVerbose(`line: bot left ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
|
||||
}
|
||||
|
||||
async function handlePostbackEvent(
|
||||
event: PostbackEvent,
|
||||
context: LineHandlerContext,
|
||||
): Promise<void> {
|
||||
const data = event.postback.data;
|
||||
logVerbose(`line: received postback: ${data}`);
|
||||
|
||||
const decision = await shouldProcessLineEvent(event, context);
|
||||
if (!decision.allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const postbackContext = await buildLinePostbackContext({
|
||||
event,
|
||||
cfg: context.cfg,
|
||||
account: context.account,
|
||||
commandAuthorized: decision.commandAuthorized,
|
||||
});
|
||||
if (!postbackContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.processMessage(postbackContext);
|
||||
}
|
||||
|
||||
export async function handleLineWebhookEvents(
|
||||
events: WebhookEvent[],
|
||||
context: LineHandlerContext,
|
||||
): Promise<void> {
|
||||
let firstError: unknown;
|
||||
for (const event of events) {
|
||||
const replayCandidate = getLineReplayCandidate(event, context);
|
||||
const replaySkip = replayCandidate ? shouldSkipLineReplayEvent(replayCandidate) : null;
|
||||
if (replaySkip?.skip) {
|
||||
if (replaySkip.inFlightResult) {
|
||||
try {
|
||||
await replaySkip.inFlightResult;
|
||||
} catch (err) {
|
||||
context.runtime.error?.(danger(`line: replayed in-flight event failed: ${String(err)}`));
|
||||
firstError ??= err;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const inFlightReservation = replayCandidate
|
||||
? markLineReplayEventInFlight(replayCandidate)
|
||||
: null;
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "message":
|
||||
await handleMessageEvent(event, context);
|
||||
break;
|
||||
case "follow":
|
||||
await handleFollowEvent(event, context);
|
||||
break;
|
||||
case "unfollow":
|
||||
await handleUnfollowEvent(event, context);
|
||||
break;
|
||||
case "join":
|
||||
await handleJoinEvent(event, context);
|
||||
break;
|
||||
case "leave":
|
||||
await handleLeaveEvent(event, context);
|
||||
break;
|
||||
case "postback":
|
||||
await handlePostbackEvent(event, context);
|
||||
break;
|
||||
default:
|
||||
logVerbose(`line: unhandled event type: ${(event as WebhookEvent).type}`);
|
||||
}
|
||||
if (replayCandidate) {
|
||||
rememberLineReplayEvent(replayCandidate);
|
||||
inFlightReservation?.resolve();
|
||||
clearLineReplayEventInFlight(replayCandidate);
|
||||
}
|
||||
} catch (err) {
|
||||
if (replayCandidate) {
|
||||
inFlightReservation?.reject(err);
|
||||
clearLineReplayEventInFlight(replayCandidate);
|
||||
}
|
||||
context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
|
||||
firstError ??= err;
|
||||
}
|
||||
}
|
||||
if (firstError) {
|
||||
throw firstError;
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { MessageEvent, PostbackEvent } from "@line/bot-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { buildLineMessageContext, buildLinePostbackContext } from "./bot-message-context.js";
|
||||
import type { ResolvedLineAccount } from "./types.js";
|
||||
|
||||
describe("buildLineMessageContext", () => {
|
||||
let tmpDir: string;
|
||||
let storePath: string;
|
||||
let cfg: OpenClawConfig;
|
||||
const account: ResolvedLineAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: {},
|
||||
};
|
||||
|
||||
const createMessageEvent = (
|
||||
source: MessageEvent["source"],
|
||||
overrides?: Partial<MessageEvent>,
|
||||
): MessageEvent =>
|
||||
({
|
||||
type: "message",
|
||||
message: { id: "1", type: "text", text: "hello" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source,
|
||||
mode: "active",
|
||||
webhookEventId: "evt-1",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
...overrides,
|
||||
}) as MessageEvent;
|
||||
|
||||
const createPostbackEvent = (
|
||||
source: PostbackEvent["source"],
|
||||
overrides?: Partial<PostbackEvent>,
|
||||
): PostbackEvent =>
|
||||
({
|
||||
type: "postback",
|
||||
postback: { data: "action=select" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source,
|
||||
mode: "active",
|
||||
webhookEventId: "evt-2",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
...overrides,
|
||||
}) as PostbackEvent;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-line-context-"));
|
||||
storePath = path.join(tmpDir, "sessions.json");
|
||||
cfg = { session: { store: storePath } };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes group message replies to the group id", async () => {
|
||||
const event = createMessageEvent({ type: "group", groupId: "group-1", userId: "user-1" });
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(context).not.toBeNull();
|
||||
if (!context) {
|
||||
throw new Error("context missing");
|
||||
}
|
||||
|
||||
expect(context.ctxPayload.OriginatingTo).toBe("line:group:group-1");
|
||||
expect(context.ctxPayload.To).toBe("line:group:group-1");
|
||||
});
|
||||
|
||||
it("routes group postback replies to the group id", async () => {
|
||||
const event = createPostbackEvent({ type: "group", groupId: "group-2", userId: "user-2" });
|
||||
|
||||
const context = await buildLinePostbackContext({
|
||||
event,
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.OriginatingTo).toBe("line:group:group-2");
|
||||
expect(context?.ctxPayload.To).toBe("line:group:group-2");
|
||||
});
|
||||
|
||||
it("routes room postback replies to the room id", async () => {
|
||||
const event = createPostbackEvent({ type: "room", roomId: "room-1", userId: "user-3" });
|
||||
|
||||
const context = await buildLinePostbackContext({
|
||||
event,
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.OriginatingTo).toBe("line:room:room-1");
|
||||
expect(context?.ctxPayload.To).toBe("line:room:room-1");
|
||||
});
|
||||
|
||||
it("resolves prefixed-only group config through the inbound message context", async () => {
|
||||
const event = createMessageEvent({ type: "group", groupId: "group-1", userId: "user-1" });
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account: {
|
||||
...account,
|
||||
config: {
|
||||
groups: {
|
||||
"group:group-1": {
|
||||
systemPrompt: "Use the prefixed group config",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.GroupSystemPrompt).toBe("Use the prefixed group config");
|
||||
});
|
||||
|
||||
it("resolves prefixed-only room config through the inbound message context", async () => {
|
||||
const event = createMessageEvent({ type: "room", roomId: "room-1", userId: "user-1" });
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account: {
|
||||
...account,
|
||||
config: {
|
||||
groups: {
|
||||
"room:room-1": {
|
||||
systemPrompt: "Use the prefixed room config",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.GroupSystemPrompt).toBe("Use the prefixed room config");
|
||||
});
|
||||
|
||||
it("keeps non-text message contexts fail-closed for command auth", async () => {
|
||||
const event = createMessageEvent(
|
||||
{ type: "user", userId: "user-audio" },
|
||||
{
|
||||
message: { id: "audio-1", type: "audio", duration: 1000 } as MessageEvent["message"],
|
||||
},
|
||||
);
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
|
||||
expect(context).not.toBeNull();
|
||||
expect(context?.ctxPayload.CommandAuthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("sets CommandAuthorized=true when authorized", async () => {
|
||||
const event = createMessageEvent({ type: "user", userId: "user-auth" });
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.CommandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("sets CommandAuthorized=false when not authorized", async () => {
|
||||
const event = createMessageEvent({ type: "user", userId: "user-noauth" });
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.CommandAuthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("sets CommandAuthorized on postback context", async () => {
|
||||
const event = createPostbackEvent({ type: "user", userId: "user-pb" });
|
||||
|
||||
const context = await buildLinePostbackContext({
|
||||
event,
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.CommandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("group peer binding matches raw groupId without prefix (#21907)", async () => {
|
||||
const groupId = "Cc7e3bece1234567890abcdef"; // pragma: allowlist secret
|
||||
const bindingCfg: OpenClawConfig = {
|
||||
session: { store: storePath },
|
||||
agents: {
|
||||
list: [{ id: "main" }, { id: "line-group-agent" }],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "line-group-agent",
|
||||
match: { channel: "line", peer: { kind: "group", id: groupId } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "msg-1", type: "text", text: "hello" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId, userId: "user-1" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-1",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg: bindingCfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(context).not.toBeNull();
|
||||
expect(context!.route.agentId).toBe("line-group-agent");
|
||||
expect(context!.route.matchedBy).toBe("binding.peer");
|
||||
});
|
||||
|
||||
it("room peer binding matches raw roomId without prefix (#21907)", async () => {
|
||||
const roomId = "Rr1234567890abcdef";
|
||||
const bindingCfg: OpenClawConfig = {
|
||||
session: { store: storePath },
|
||||
agents: {
|
||||
list: [{ id: "main" }, { id: "line-room-agent" }],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "line-room-agent",
|
||||
match: { channel: "line", peer: { kind: "group", id: roomId } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "msg-2", type: "text", text: "hello" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "room", roomId, userId: "user-2" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-2",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg: bindingCfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(context).not.toBeNull();
|
||||
expect(context!.route.agentId).toBe("line-room-agent");
|
||||
expect(context!.route.matchedBy).toBe("binding.peer");
|
||||
});
|
||||
});
|
||||
@@ -1,519 +0,0 @@
|
||||
import type { MessageEvent, StickerEventMessage, EventSource, PostbackEvent } from "@line/bot-sdk";
|
||||
import { formatInboundEnvelope } from "../auto-reply/envelope.js";
|
||||
import { type HistoryEntry } from "../auto-reply/reply/history.js";
|
||||
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
||||
import { formatLocationText, toLocationContext } from "../channels/location.js";
|
||||
import { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
|
||||
import { recordInboundSession } from "../channels/session.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js";
|
||||
import { normalizeAllowFrom } from "./bot-access.js";
|
||||
import { resolveLineGroupConfigEntry, resolveLineGroupHistoryKey } from "./group-keys.js";
|
||||
import type { ResolvedLineAccount, LineGroupConfig } from "./types.js";
|
||||
|
||||
interface MediaRef {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
interface BuildLineMessageContextParams {
|
||||
event: MessageEvent;
|
||||
allMedia: MediaRef[];
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
commandAuthorized: boolean;
|
||||
groupHistories?: Map<string, HistoryEntry[]>;
|
||||
historyLimit?: number;
|
||||
}
|
||||
|
||||
export type LineSourceInfo = {
|
||||
userId?: string;
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
isGroup: boolean;
|
||||
};
|
||||
|
||||
export function getLineSourceInfo(source: EventSource): LineSourceInfo {
|
||||
const userId =
|
||||
source.type === "user"
|
||||
? source.userId
|
||||
: source.type === "group"
|
||||
? source.userId
|
||||
: source.type === "room"
|
||||
? source.userId
|
||||
: undefined;
|
||||
const groupId = source.type === "group" ? source.groupId : undefined;
|
||||
const roomId = source.type === "room" ? source.roomId : undefined;
|
||||
const isGroup = source.type === "group" || source.type === "room";
|
||||
|
||||
return { userId, groupId, roomId, isGroup };
|
||||
}
|
||||
|
||||
function buildPeerId(source: EventSource): string {
|
||||
const groupKey = resolveLineGroupHistoryKey({
|
||||
groupId: source.type === "group" ? source.groupId : undefined,
|
||||
roomId: source.type === "room" ? source.roomId : undefined,
|
||||
});
|
||||
if (groupKey) {
|
||||
return groupKey;
|
||||
}
|
||||
if (source.type === "user" && source.userId) {
|
||||
return source.userId;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function resolveLineInboundRoute(params: {
|
||||
source: EventSource;
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
}): {
|
||||
userId?: string;
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
isGroup: boolean;
|
||||
peerId: string;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
} {
|
||||
recordChannelActivity({
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
|
||||
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(params.source);
|
||||
const peerId = buildPeerId(params.source);
|
||||
const route = resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
});
|
||||
|
||||
return { userId, groupId, roomId, isGroup, peerId, route };
|
||||
}
|
||||
|
||||
// Common LINE sticker package descriptions
|
||||
const STICKER_PACKAGES: Record<string, string> = {
|
||||
"1": "Moon & James",
|
||||
"2": "Cony & Brown",
|
||||
"3": "Brown & Friends",
|
||||
"4": "Moon Special",
|
||||
"11537": "Cony",
|
||||
"11538": "Brown",
|
||||
"11539": "Moon",
|
||||
"6136": "Cony's Happy Life",
|
||||
"6325": "Brown's Life",
|
||||
"6359": "Choco",
|
||||
"6362": "Sally",
|
||||
"6370": "Edward",
|
||||
"789": "LINE Characters",
|
||||
};
|
||||
|
||||
function describeStickerKeywords(sticker: StickerEventMessage): string {
|
||||
// Use sticker keywords if available (LINE provides these for some stickers)
|
||||
const keywords = (sticker as StickerEventMessage & { keywords?: string[] }).keywords;
|
||||
if (keywords && keywords.length > 0) {
|
||||
return keywords.slice(0, 3).join(", ");
|
||||
}
|
||||
|
||||
// Use sticker text if available
|
||||
const stickerText = (sticker as StickerEventMessage & { text?: string }).text;
|
||||
if (stickerText) {
|
||||
return stickerText;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractMessageText(message: MessageEvent["message"]): string {
|
||||
if (message.type === "text") {
|
||||
return message.text;
|
||||
}
|
||||
if (message.type === "location") {
|
||||
const loc = message;
|
||||
return (
|
||||
formatLocationText({
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
name: loc.title,
|
||||
address: loc.address,
|
||||
}) ?? ""
|
||||
);
|
||||
}
|
||||
if (message.type === "sticker") {
|
||||
const sticker = message;
|
||||
const packageName = STICKER_PACKAGES[sticker.packageId] ?? "sticker";
|
||||
const keywords = describeStickerKeywords(sticker);
|
||||
|
||||
if (keywords) {
|
||||
return `[Sent a ${packageName} sticker: ${keywords}]`;
|
||||
}
|
||||
return `[Sent a ${packageName} sticker]`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractMediaPlaceholder(message: MessageEvent["message"]): string {
|
||||
switch (message.type) {
|
||||
case "image":
|
||||
return "<media:image>";
|
||||
case "video":
|
||||
return "<media:video>";
|
||||
case "audio":
|
||||
return "<media:audio>";
|
||||
case "file":
|
||||
return "<media:document>";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
type LineRouteInfo = ReturnType<typeof resolveAgentRoute>;
|
||||
type LineSourceInfoWithPeerId = LineSourceInfo & { peerId: string };
|
||||
|
||||
function resolveLineConversationLabel(params: {
|
||||
isGroup: boolean;
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
senderLabel: string;
|
||||
}): string {
|
||||
return params.isGroup
|
||||
? params.groupId
|
||||
? `group:${params.groupId}`
|
||||
: params.roomId
|
||||
? `room:${params.roomId}`
|
||||
: "unknown-group"
|
||||
: params.senderLabel;
|
||||
}
|
||||
|
||||
function resolveLineAddresses(params: {
|
||||
isGroup: boolean;
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
userId?: string;
|
||||
peerId: string;
|
||||
}): { fromAddress: string; toAddress: string; originatingTo: string } {
|
||||
const fromAddress = params.isGroup
|
||||
? params.groupId
|
||||
? `line:group:${params.groupId}`
|
||||
: params.roomId
|
||||
? `line:room:${params.roomId}`
|
||||
: `line:${params.peerId}`
|
||||
: `line:${params.userId ?? params.peerId}`;
|
||||
const toAddress = params.isGroup ? fromAddress : `line:${params.userId ?? params.peerId}`;
|
||||
const originatingTo = params.isGroup ? fromAddress : `line:${params.userId ?? params.peerId}`;
|
||||
return { fromAddress, toAddress, originatingTo };
|
||||
}
|
||||
|
||||
function resolveLineGroupSystemPrompt(
|
||||
groups: Record<string, LineGroupConfig | undefined> | undefined,
|
||||
source: LineSourceInfoWithPeerId,
|
||||
): string | undefined {
|
||||
const entry = resolveLineGroupConfigEntry(groups, {
|
||||
groupId: source.groupId,
|
||||
roomId: source.roomId,
|
||||
});
|
||||
return entry?.systemPrompt?.trim() || undefined;
|
||||
}
|
||||
|
||||
async function finalizeLineInboundContext(params: {
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
event: MessageEvent | PostbackEvent;
|
||||
route: LineRouteInfo;
|
||||
source: LineSourceInfoWithPeerId;
|
||||
rawBody: string;
|
||||
timestamp: number;
|
||||
messageSid: string;
|
||||
commandAuthorized: boolean;
|
||||
media: {
|
||||
firstPath: string | undefined;
|
||||
firstContentType?: string;
|
||||
paths?: string[];
|
||||
types?: string[];
|
||||
};
|
||||
locationContext?: ReturnType<typeof toLocationContext>;
|
||||
verboseLog: { kind: "inbound" | "postback"; mediaCount?: number };
|
||||
inboundHistory?: Pick<HistoryEntry, "sender" | "body" | "timestamp">[];
|
||||
}) {
|
||||
const { fromAddress, toAddress, originatingTo } = resolveLineAddresses({
|
||||
isGroup: params.source.isGroup,
|
||||
groupId: params.source.groupId,
|
||||
roomId: params.source.roomId,
|
||||
userId: params.source.userId,
|
||||
peerId: params.source.peerId,
|
||||
});
|
||||
|
||||
const senderId = params.source.userId ?? "unknown";
|
||||
const senderLabel = params.source.userId ? `user:${params.source.userId}` : "unknown";
|
||||
const conversationLabel = resolveLineConversationLabel({
|
||||
isGroup: params.source.isGroup,
|
||||
groupId: params.source.groupId,
|
||||
roomId: params.source.roomId,
|
||||
senderLabel,
|
||||
});
|
||||
|
||||
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
|
||||
cfg: params.cfg,
|
||||
agentId: params.route.agentId,
|
||||
sessionKey: params.route.sessionKey,
|
||||
});
|
||||
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "LINE",
|
||||
from: conversationLabel,
|
||||
timestamp: params.timestamp,
|
||||
body: params.rawBody,
|
||||
chatType: params.source.isGroup ? "group" : "direct",
|
||||
sender: {
|
||||
id: senderId,
|
||||
},
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: params.rawBody,
|
||||
RawBody: params.rawBody,
|
||||
CommandBody: params.rawBody,
|
||||
From: fromAddress,
|
||||
To: toAddress,
|
||||
SessionKey: params.route.sessionKey,
|
||||
AccountId: params.route.accountId,
|
||||
ChatType: params.source.isGroup ? "group" : "direct",
|
||||
ConversationLabel: conversationLabel,
|
||||
GroupSubject: params.source.isGroup
|
||||
? (params.source.groupId ?? params.source.roomId)
|
||||
: undefined,
|
||||
SenderId: senderId,
|
||||
Provider: "line",
|
||||
Surface: "line",
|
||||
MessageSid: params.messageSid,
|
||||
Timestamp: params.timestamp,
|
||||
MediaPath: params.media.firstPath,
|
||||
MediaType: params.media.firstContentType,
|
||||
MediaUrl: params.media.firstPath,
|
||||
MediaPaths: params.media.paths,
|
||||
MediaUrls: params.media.paths,
|
||||
MediaTypes: params.media.types,
|
||||
...params.locationContext,
|
||||
CommandAuthorized: params.commandAuthorized,
|
||||
OriginatingChannel: "line" as const,
|
||||
OriginatingTo: originatingTo,
|
||||
GroupSystemPrompt: params.source.isGroup
|
||||
? resolveLineGroupSystemPrompt(params.account.config.groups, params.source)
|
||||
: undefined,
|
||||
InboundHistory: params.inboundHistory,
|
||||
});
|
||||
|
||||
const pinnedMainDmOwner = !params.source.isGroup
|
||||
? resolvePinnedMainDmOwnerFromAllowlist({
|
||||
dmScope: params.cfg.session?.dmScope,
|
||||
allowFrom: params.account.config.allowFrom,
|
||||
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
|
||||
})
|
||||
: null;
|
||||
await recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? params.route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
updateLastRoute: !params.source.isGroup
|
||||
? {
|
||||
sessionKey: params.route.mainSessionKey,
|
||||
channel: "line",
|
||||
to: params.source.userId ?? params.source.peerId,
|
||||
accountId: params.route.accountId,
|
||||
mainDmOwnerPin:
|
||||
pinnedMainDmOwner && params.source.userId
|
||||
? {
|
||||
ownerRecipient: pinnedMainDmOwner,
|
||||
senderRecipient: params.source.userId,
|
||||
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
||||
logVerbose(
|
||||
`line: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
||||
);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
onRecordError: (err) => {
|
||||
logVerbose(`line: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
|
||||
const mediaInfo =
|
||||
params.verboseLog.kind === "inbound" && (params.verboseLog.mediaCount ?? 0) > 1
|
||||
? ` mediaCount=${params.verboseLog.mediaCount}`
|
||||
: "";
|
||||
const label = params.verboseLog.kind === "inbound" ? "line inbound" : "line postback";
|
||||
logVerbose(
|
||||
`${label}: from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return { ctxPayload, replyToken: (params.event as { replyToken: string }).replyToken };
|
||||
}
|
||||
|
||||
export async function buildLineMessageContext(params: BuildLineMessageContextParams) {
|
||||
const { event, allMedia, cfg, account, commandAuthorized, groupHistories, historyLimit } = params;
|
||||
|
||||
const source = event.source;
|
||||
const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({
|
||||
source,
|
||||
cfg,
|
||||
account,
|
||||
});
|
||||
|
||||
const message = event.message;
|
||||
const messageId = message.id;
|
||||
const timestamp = event.timestamp;
|
||||
|
||||
// Build message body
|
||||
const textContent = extractMessageText(message);
|
||||
const placeholder = extractMediaPlaceholder(message);
|
||||
|
||||
let rawBody = textContent || placeholder;
|
||||
if (!rawBody && allMedia.length > 0) {
|
||||
rawBody = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
|
||||
}
|
||||
|
||||
if (!rawBody && allMedia.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let locationContext: ReturnType<typeof toLocationContext> | undefined;
|
||||
if (message.type === "location") {
|
||||
const loc = message;
|
||||
locationContext = toLocationContext({
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
name: loc.title,
|
||||
address: loc.address,
|
||||
});
|
||||
}
|
||||
|
||||
// Build pending history for group chats: unmentioned messages accumulated in
|
||||
// groupHistories are passed as InboundHistory so the agent has context about
|
||||
// the conversation that preceded the mention.
|
||||
const historyKey = isGroup ? peerId : undefined;
|
||||
const inboundHistory =
|
||||
historyKey && groupHistories && (historyLimit ?? 0) > 0
|
||||
? (groupHistories.get(historyKey) ?? []).map((entry) => ({
|
||||
sender: entry.sender,
|
||||
body: entry.body,
|
||||
timestamp: entry.timestamp,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const { ctxPayload } = await finalizeLineInboundContext({
|
||||
cfg,
|
||||
account,
|
||||
event,
|
||||
route,
|
||||
source: { userId, groupId, roomId, isGroup, peerId },
|
||||
rawBody,
|
||||
timestamp,
|
||||
messageSid: messageId,
|
||||
commandAuthorized,
|
||||
media: {
|
||||
firstPath: allMedia[0]?.path,
|
||||
firstContentType: allMedia[0]?.contentType,
|
||||
paths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
|
||||
types:
|
||||
allMedia.length > 0
|
||||
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
||||
: undefined,
|
||||
},
|
||||
locationContext,
|
||||
verboseLog: { kind: "inbound", mediaCount: allMedia.length },
|
||||
inboundHistory,
|
||||
});
|
||||
|
||||
return {
|
||||
ctxPayload,
|
||||
event,
|
||||
userId,
|
||||
groupId,
|
||||
roomId,
|
||||
isGroup,
|
||||
route,
|
||||
replyToken: event.replyToken,
|
||||
accountId: account.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildLinePostbackContext(params: {
|
||||
event: PostbackEvent;
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
commandAuthorized: boolean;
|
||||
}) {
|
||||
const { event, cfg, account, commandAuthorized } = params;
|
||||
|
||||
const source = event.source;
|
||||
const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({
|
||||
source,
|
||||
cfg,
|
||||
account,
|
||||
});
|
||||
|
||||
const timestamp = event.timestamp;
|
||||
const rawData = event.postback?.data?.trim() ?? "";
|
||||
if (!rawData) {
|
||||
return null;
|
||||
}
|
||||
let rawBody = rawData;
|
||||
if (rawData.includes("line.action=")) {
|
||||
const params = new URLSearchParams(rawData);
|
||||
const action = params.get("line.action") ?? "";
|
||||
const device = params.get("line.device");
|
||||
rawBody = device ? `line action ${action} device ${device}` : `line action ${action}`;
|
||||
}
|
||||
|
||||
const messageSid = event.replyToken ? `postback:${event.replyToken}` : `postback:${timestamp}`;
|
||||
const { ctxPayload } = await finalizeLineInboundContext({
|
||||
cfg,
|
||||
account,
|
||||
event,
|
||||
route,
|
||||
source: { userId, groupId, roomId, isGroup, peerId },
|
||||
rawBody,
|
||||
timestamp,
|
||||
messageSid,
|
||||
commandAuthorized,
|
||||
media: {
|
||||
firstPath: "",
|
||||
firstContentType: undefined,
|
||||
paths: undefined,
|
||||
types: undefined,
|
||||
},
|
||||
verboseLog: { kind: "postback" },
|
||||
});
|
||||
|
||||
return {
|
||||
ctxPayload,
|
||||
event,
|
||||
userId,
|
||||
groupId,
|
||||
roomId,
|
||||
isGroup,
|
||||
route,
|
||||
replyToken: event.replyToken,
|
||||
accountId: account.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
export type LineMessageContext = NonNullable<Awaited<ReturnType<typeof buildLineMessageContext>>>;
|
||||
export type LinePostbackContext = NonNullable<Awaited<ReturnType<typeof buildLinePostbackContext>>>;
|
||||
export type LineInboundContext = LineMessageContext | LinePostbackContext;
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { WebhookRequestBody } from "@line/bot-sdk";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { resolveLineAccount } from "./accounts.js";
|
||||
import { createLineWebhookReplayCache, handleLineWebhookEvents } from "./bot-handlers.js";
|
||||
import type { LineInboundContext } from "./bot-message-context.js";
|
||||
import type { ResolvedLineAccount } from "./types.js";
|
||||
import { startLineWebhook } from "./webhook.js";
|
||||
|
||||
export interface LineBotOptions {
|
||||
channelAccessToken: string;
|
||||
channelSecret: string;
|
||||
accountId?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
config?: OpenClawConfig;
|
||||
mediaMaxMb?: number;
|
||||
onMessage?: (ctx: LineInboundContext) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface LineBot {
|
||||
handleWebhook: (body: WebhookRequestBody) => Promise<void>;
|
||||
account: ResolvedLineAccount;
|
||||
}
|
||||
|
||||
export function createLineBot(opts: LineBotOptions): LineBot {
|
||||
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
|
||||
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account = resolveLineAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? account.config.mediaMaxMb ?? 10) * 1024 * 1024;
|
||||
|
||||
const processMessage =
|
||||
opts.onMessage ??
|
||||
(async () => {
|
||||
logVerbose("line: no message handler configured");
|
||||
});
|
||||
const replayCache = createLineWebhookReplayCache();
|
||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||
|
||||
const handleWebhook = async (body: WebhookRequestBody): Promise<void> => {
|
||||
if (!body.events || body.events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleLineWebhookEvents(body.events, {
|
||||
cfg,
|
||||
account,
|
||||
runtime,
|
||||
mediaMaxBytes,
|
||||
processMessage,
|
||||
replayCache,
|
||||
groupHistories,
|
||||
historyLimit: cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
handleWebhook,
|
||||
account,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLineWebhookCallback(
|
||||
bot: LineBot,
|
||||
channelSecret: string,
|
||||
path = "/line/webhook",
|
||||
): { path: string; handler: (req: Request, res: Response, _next: NextFunction) => Promise<void> } {
|
||||
const { handler } = startLineWebhook({
|
||||
channelSecret,
|
||||
onEvents: bot.handleWebhook,
|
||||
path,
|
||||
});
|
||||
|
||||
return { path, handler };
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export function resolveLineChannelAccessToken(
|
||||
explicit: string | undefined,
|
||||
params: { accountId: string; channelAccessToken: string },
|
||||
): string {
|
||||
if (explicit?.trim()) {
|
||||
return explicit.trim();
|
||||
}
|
||||
if (!params.channelAccessToken) {
|
||||
throw new Error(
|
||||
`LINE channel access token missing for account "${params.accountId}" (set channels.line.channelAccessToken or LINE_CHANNEL_ACCESS_TOKEN).`,
|
||||
);
|
||||
}
|
||||
return params.channelAccessToken.trim();
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const DmPolicySchema = z.enum(["open", "allowlist", "pairing", "disabled"]);
|
||||
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
|
||||
|
||||
const LineCommonConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
channelAccessToken: z.string().optional(),
|
||||
channelSecret: z.string().optional(),
|
||||
tokenFile: z.string().optional(),
|
||||
secretFile: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
});
|
||||
|
||||
const LineGroupConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const LineAccountConfigSchema = LineCommonConfigSchema.extend({
|
||||
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
|
||||
}).strict();
|
||||
|
||||
export const LineConfigSchema = LineCommonConfigSchema.extend({
|
||||
accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
|
||||
}).strict();
|
||||
|
||||
export type LineConfigSchemaType = z.infer<typeof LineConfigSchema>;
|
||||
@@ -1,97 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
|
||||
const getMessageContentMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@line/bot-sdk", () => ({
|
||||
messagingApi: {
|
||||
MessagingApiBlobClient: class {
|
||||
getMessageContent(messageId: string) {
|
||||
return getMessageContentMock(messageId);
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../globals.js", () => ({
|
||||
logVerbose: () => {},
|
||||
}));
|
||||
|
||||
import { downloadLineMedia } from "./download.js";
|
||||
|
||||
async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
|
||||
for (const part of parts) {
|
||||
yield part;
|
||||
}
|
||||
}
|
||||
|
||||
describe("downloadLineMedia", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not derive temp file path from external messageId", async () => {
|
||||
const messageId = "a/../../../../etc/passwd";
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
||||
|
||||
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await downloadLineMedia(messageId, "token");
|
||||
const writtenPath = writeSpy.mock.calls[0]?.[0];
|
||||
|
||||
expect(result.size).toBe(jpeg.length);
|
||||
expect(result.contentType).toBe("image/jpeg");
|
||||
expect(typeof writtenPath).toBe("string");
|
||||
if (typeof writtenPath !== "string") {
|
||||
throw new Error("expected string temp file path");
|
||||
}
|
||||
expect(result.path).toBe(writtenPath);
|
||||
expect(writtenPath).toContain("line-media-");
|
||||
expect(writtenPath).toMatch(/\.jpg$/);
|
||||
expect(writtenPath).not.toContain(messageId);
|
||||
expect(writtenPath).not.toContain("..");
|
||||
|
||||
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
|
||||
const rel = path.relative(tmpRoot, path.resolve(writtenPath));
|
||||
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects oversized media before writing to disk", async () => {
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([Buffer.alloc(4), Buffer.alloc(4)]));
|
||||
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(undefined);
|
||||
|
||||
await expect(downloadLineMedia("mid", "token", 7)).rejects.toThrow(/Media exceeds/i);
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("classifies M4A ftyp major brand as audio/mp4", async () => {
|
||||
const m4aHeader = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
|
||||
]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([m4aHeader]));
|
||||
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await downloadLineMedia("mid-audio", "token");
|
||||
const writtenPath = writeSpy.mock.calls[0]?.[0];
|
||||
|
||||
expect(result.contentType).toBe("audio/mp4");
|
||||
expect(result.path).toMatch(/\.m4a$/);
|
||||
expect(writtenPath).toBe(result.path);
|
||||
});
|
||||
|
||||
it("detects MP4 video from ftyp major brand (isom)", async () => {
|
||||
const mp4 = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
|
||||
]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([mp4]));
|
||||
vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await downloadLineMedia("mid-mp4", "token");
|
||||
|
||||
expect(result.contentType).toBe("video/mp4");
|
||||
expect(result.path).toMatch(/\.mp4$/);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import { messagingApi } from "@line/bot-sdk";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { buildRandomTempFilePath } from "../plugin-sdk/temp-path.js";
|
||||
|
||||
interface DownloadResult {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const AUDIO_BRANDS = new Set(["m4a ", "m4b ", "m4p ", "m4r ", "f4a ", "f4b "]);
|
||||
|
||||
export async function downloadLineMedia(
|
||||
messageId: string,
|
||||
channelAccessToken: string,
|
||||
maxBytes = 10 * 1024 * 1024,
|
||||
): Promise<DownloadResult> {
|
||||
const client = new messagingApi.MessagingApiBlobClient({
|
||||
channelAccessToken,
|
||||
});
|
||||
|
||||
const response = await client.getMessageContent(messageId);
|
||||
|
||||
// response is a Readable stream
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for await (const chunk of response as AsyncIterable<Buffer>) {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > maxBytes) {
|
||||
throw new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
// Determine content type from magic bytes
|
||||
const contentType = detectContentType(buffer);
|
||||
const ext = getExtensionForContentType(contentType);
|
||||
|
||||
// Use random temp names; never derive paths from external message identifiers.
|
||||
const filePath = buildRandomTempFilePath({ prefix: "line-media", extension: ext });
|
||||
|
||||
await fs.promises.writeFile(filePath, buffer);
|
||||
|
||||
logVerbose(`line: downloaded media ${messageId} to ${filePath} (${buffer.length} bytes)`);
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
contentType,
|
||||
size: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
function detectContentType(buffer: Buffer): string {
|
||||
const hasFtypBox =
|
||||
buffer.length >= 12 &&
|
||||
buffer[4] === 0x66 &&
|
||||
buffer[5] === 0x74 &&
|
||||
buffer[6] === 0x79 &&
|
||||
buffer[7] === 0x70;
|
||||
|
||||
// Check magic bytes
|
||||
if (buffer.length >= 2) {
|
||||
// JPEG
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
// PNG
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
||||
return "image/png";
|
||||
}
|
||||
// GIF
|
||||
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
|
||||
return "image/gif";
|
||||
}
|
||||
// WebP
|
||||
if (
|
||||
buffer[0] === 0x52 &&
|
||||
buffer[1] === 0x49 &&
|
||||
buffer[2] === 0x46 &&
|
||||
buffer[3] === 0x46 &&
|
||||
buffer[8] === 0x57 &&
|
||||
buffer[9] === 0x45 &&
|
||||
buffer[10] === 0x42 &&
|
||||
buffer[11] === 0x50
|
||||
) {
|
||||
return "image/webp";
|
||||
}
|
||||
if (hasFtypBox) {
|
||||
// ISO BMFF containers share `ftyp`; use major brand to separate common
|
||||
// M4A audio payloads from video mp4 containers.
|
||||
const majorBrand = buffer.toString("ascii", 8, 12).toLowerCase();
|
||||
if (AUDIO_BRANDS.has(majorBrand)) {
|
||||
return "audio/mp4";
|
||||
}
|
||||
return "video/mp4";
|
||||
}
|
||||
}
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function getExtensionForContentType(contentType: string): string {
|
||||
switch (contentType) {
|
||||
case "image/jpeg":
|
||||
return ".jpg";
|
||||
case "image/png":
|
||||
return ".png";
|
||||
case "image/gif":
|
||||
return ".gif";
|
||||
case "image/webp":
|
||||
return ".webp";
|
||||
case "video/mp4":
|
||||
return ".mp4";
|
||||
case "audio/mp4":
|
||||
return ".m4a";
|
||||
case "audio/mpeg":
|
||||
return ".mp3";
|
||||
default:
|
||||
return ".bin";
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createInfoCard,
|
||||
createListCard,
|
||||
createImageCard,
|
||||
createActionCard,
|
||||
createCarousel,
|
||||
createEventCard,
|
||||
createDeviceControlCard,
|
||||
} from "./flex-templates.js";
|
||||
|
||||
describe("createInfoCard", () => {
|
||||
it("includes footer when provided", () => {
|
||||
const card = createInfoCard("Title", "Body", "Footer text");
|
||||
|
||||
const footer = card.footer as { contents: Array<{ text: string }> };
|
||||
expect(footer.contents[0].text).toBe("Footer text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createListCard", () => {
|
||||
it("limits items to 8", () => {
|
||||
const items = Array.from({ length: 15 }, (_, i) => ({ title: `Item ${i}` }));
|
||||
const card = createListCard("List", items);
|
||||
|
||||
const body = card.body as { contents: Array<{ type: string; contents?: unknown[] }> };
|
||||
// The list items are in the third content (after title and separator)
|
||||
const listBox = body.contents[2] as { contents: unknown[] };
|
||||
expect(listBox.contents.length).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createImageCard", () => {
|
||||
it("includes body text when provided", () => {
|
||||
const card = createImageCard("https://example.com/img.jpg", "Title", "Body text");
|
||||
|
||||
const body = card.body as { contents: Array<{ text: string }> };
|
||||
expect(body.contents.length).toBe(2);
|
||||
expect(body.contents[1].text).toBe("Body text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createActionCard", () => {
|
||||
it("limits actions to 4", () => {
|
||||
const actions = Array.from({ length: 6 }, (_, i) => ({
|
||||
label: `Action ${i}`,
|
||||
action: { type: "message" as const, label: `A${i}`, text: `action${i}` },
|
||||
}));
|
||||
const card = createActionCard("Title", "Body", actions);
|
||||
|
||||
const footer = card.footer as { contents: unknown[] };
|
||||
expect(footer.contents.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCarousel", () => {
|
||||
it("limits to 12 bubbles", () => {
|
||||
const bubbles = Array.from({ length: 15 }, (_, i) => createInfoCard(`Card ${i}`, `Body ${i}`));
|
||||
const carousel = createCarousel(bubbles);
|
||||
|
||||
expect(carousel.contents.length).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDeviceControlCard", () => {
|
||||
it("limits controls to 6", () => {
|
||||
const card = createDeviceControlCard({
|
||||
deviceName: "Device",
|
||||
controls: Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Control ${i}`,
|
||||
data: `action=${i}`,
|
||||
})),
|
||||
});
|
||||
|
||||
// Should have max 3 rows of 2 buttons
|
||||
const footer = card.footer as { contents: unknown[] };
|
||||
expect(footer.contents.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEventCard", () => {
|
||||
it("includes all optional fields together", () => {
|
||||
const card = createEventCard({
|
||||
title: "Team Offsite",
|
||||
date: "February 15, 2026",
|
||||
time: "9:00 AM - 5:00 PM",
|
||||
location: "Mountain View Office",
|
||||
description: "Annual team building event",
|
||||
});
|
||||
|
||||
expect(card.size).toBe("mega");
|
||||
const body = card.body as { contents: Array<{ type: string }> };
|
||||
expect(body.contents).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
export {
|
||||
createActionCard,
|
||||
createCarousel,
|
||||
createImageCard,
|
||||
createInfoCard,
|
||||
createListCard,
|
||||
createNotificationBubble,
|
||||
} from "./flex-templates/basic-cards.js";
|
||||
export {
|
||||
createAgendaCard,
|
||||
createEventCard,
|
||||
createReceiptCard,
|
||||
} from "./flex-templates/schedule-cards.js";
|
||||
export {
|
||||
createAppleTvRemoteCard,
|
||||
createDeviceControlCard,
|
||||
createMediaPlayerCard,
|
||||
} from "./flex-templates/media-control-cards.js";
|
||||
export { toFlexMessage } from "./flex-templates/message.js";
|
||||
|
||||
export type {
|
||||
Action,
|
||||
CardAction,
|
||||
FlexBox,
|
||||
FlexBubble,
|
||||
FlexButton,
|
||||
FlexCarousel,
|
||||
FlexComponent,
|
||||
FlexContainer,
|
||||
FlexImage,
|
||||
FlexText,
|
||||
ListItem,
|
||||
} from "./flex-templates/types.js";
|
||||
@@ -1,395 +0,0 @@
|
||||
import { attachFooterText } from "./common.js";
|
||||
import type {
|
||||
Action,
|
||||
CardAction,
|
||||
FlexBox,
|
||||
FlexBubble,
|
||||
FlexButton,
|
||||
FlexCarousel,
|
||||
FlexComponent,
|
||||
FlexImage,
|
||||
FlexText,
|
||||
ListItem,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Create an info card with title, body, and optional footer
|
||||
*
|
||||
* Editorial design: Clean hierarchy with accent bar, generous spacing,
|
||||
* and subtle background zones for visual separation.
|
||||
*/
|
||||
export function createInfoCard(title: string, body: string, footer?: string): FlexBubble {
|
||||
const bubble: FlexBubble = {
|
||||
type: "bubble",
|
||||
size: "mega",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
// Title with accent bar
|
||||
{
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [],
|
||||
width: "4px",
|
||||
backgroundColor: "#06C755",
|
||||
cornerRadius: "2px",
|
||||
} as FlexBox,
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
flex: 1,
|
||||
margin: "lg",
|
||||
} as FlexText,
|
||||
],
|
||||
} as FlexBox,
|
||||
// Body text in subtle container
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: body,
|
||||
size: "md",
|
||||
color: "#444444",
|
||||
wrap: true,
|
||||
lineSpacing: "6px",
|
||||
} as FlexText,
|
||||
],
|
||||
margin: "xl",
|
||||
paddingAll: "lg",
|
||||
backgroundColor: "#F8F9FA",
|
||||
cornerRadius: "lg",
|
||||
} as FlexBox,
|
||||
],
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
};
|
||||
|
||||
if (footer) {
|
||||
attachFooterText(bubble, footer);
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a list card with title and multiple items
|
||||
*
|
||||
* Editorial design: Numbered/bulleted list with clear visual hierarchy,
|
||||
* accent dots for each item, and generous spacing.
|
||||
*/
|
||||
export function createListCard(title: string, items: ListItem[]): FlexBubble {
|
||||
const itemContents: FlexComponent[] = items.slice(0, 8).map((item, index) => {
|
||||
const itemContents: FlexComponent[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: item.title,
|
||||
size: "md",
|
||||
weight: "bold",
|
||||
color: "#1a1a1a",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
];
|
||||
|
||||
if (item.subtitle) {
|
||||
itemContents.push({
|
||||
type: "text",
|
||||
text: item.subtitle,
|
||||
size: "sm",
|
||||
color: "#888888",
|
||||
wrap: true,
|
||||
margin: "xs",
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
const itemBox: FlexBox = {
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
// Accent dot
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [],
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
backgroundColor: index === 0 ? "#06C755" : "#DDDDDD",
|
||||
cornerRadius: "4px",
|
||||
} as FlexBox,
|
||||
],
|
||||
width: "20px",
|
||||
alignItems: "center",
|
||||
paddingTop: "sm",
|
||||
} as FlexBox,
|
||||
// Item content
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: itemContents,
|
||||
flex: 1,
|
||||
} as FlexBox,
|
||||
],
|
||||
margin: index > 0 ? "lg" : undefined,
|
||||
};
|
||||
|
||||
if (item.action) {
|
||||
itemBox.action = item.action;
|
||||
}
|
||||
|
||||
return itemBox;
|
||||
});
|
||||
|
||||
return {
|
||||
type: "bubble",
|
||||
size: "mega",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
{
|
||||
type: "separator",
|
||||
margin: "lg",
|
||||
color: "#EEEEEE",
|
||||
},
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: itemContents,
|
||||
margin: "lg",
|
||||
} as FlexBox,
|
||||
],
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image card with image, title, and optional body text
|
||||
*/
|
||||
export function createImageCard(
|
||||
imageUrl: string,
|
||||
title: string,
|
||||
body?: string,
|
||||
options?: {
|
||||
aspectRatio?: "1:1" | "1.51:1" | "1.91:1" | "4:3" | "16:9" | "20:13" | "2:1" | "3:1";
|
||||
aspectMode?: "cover" | "fit";
|
||||
action?: Action;
|
||||
},
|
||||
): FlexBubble {
|
||||
const bubble: FlexBubble = {
|
||||
type: "bubble",
|
||||
hero: {
|
||||
type: "image",
|
||||
url: imageUrl,
|
||||
size: "full",
|
||||
aspectRatio: options?.aspectRatio ?? "20:13",
|
||||
aspectMode: options?.aspectMode ?? "cover",
|
||||
action: options?.action,
|
||||
} as FlexImage,
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
],
|
||||
paddingAll: "lg",
|
||||
},
|
||||
};
|
||||
|
||||
if (body && bubble.body) {
|
||||
bubble.body.contents.push({
|
||||
type: "text",
|
||||
text: body,
|
||||
size: "md",
|
||||
wrap: true,
|
||||
margin: "md",
|
||||
color: "#666666",
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an action card with title, body, and action buttons
|
||||
*/
|
||||
export function createActionCard(
|
||||
title: string,
|
||||
body: string,
|
||||
actions: CardAction[],
|
||||
options?: {
|
||||
imageUrl?: string;
|
||||
aspectRatio?: "1:1" | "1.51:1" | "1.91:1" | "4:3" | "16:9" | "20:13" | "2:1" | "3:1";
|
||||
},
|
||||
): FlexBubble {
|
||||
const bubble: FlexBubble = {
|
||||
type: "bubble",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
{
|
||||
type: "text",
|
||||
text: body,
|
||||
size: "md",
|
||||
wrap: true,
|
||||
margin: "md",
|
||||
color: "#666666",
|
||||
} as FlexText,
|
||||
],
|
||||
paddingAll: "lg",
|
||||
},
|
||||
footer: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: actions.slice(0, 4).map(
|
||||
(action, index) =>
|
||||
({
|
||||
type: "button",
|
||||
action: action.action,
|
||||
style: index === 0 ? "primary" : "secondary",
|
||||
margin: index > 0 ? "sm" : undefined,
|
||||
}) as FlexButton,
|
||||
),
|
||||
paddingAll: "md",
|
||||
},
|
||||
};
|
||||
|
||||
if (options?.imageUrl) {
|
||||
bubble.hero = {
|
||||
type: "image",
|
||||
url: options.imageUrl,
|
||||
size: "full",
|
||||
aspectRatio: options.aspectRatio ?? "20:13",
|
||||
aspectMode: "cover",
|
||||
} as FlexImage;
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a carousel container from multiple bubbles
|
||||
* LINE allows max 12 bubbles in a carousel
|
||||
*/
|
||||
export function createCarousel(bubbles: FlexBubble[]): FlexCarousel {
|
||||
return {
|
||||
type: "carousel",
|
||||
contents: bubbles.slice(0, 12),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a notification bubble (for alerts, status updates)
|
||||
*
|
||||
* Editorial design: Bold status indicator with accent color,
|
||||
* clear typography, optional icon for context.
|
||||
*/
|
||||
export function createNotificationBubble(
|
||||
text: string,
|
||||
options?: {
|
||||
icon?: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
title?: string;
|
||||
},
|
||||
): FlexBubble {
|
||||
// Color based on notification type
|
||||
const colors = {
|
||||
info: { accent: "#3B82F6", bg: "#EFF6FF" },
|
||||
success: { accent: "#06C755", bg: "#F0FDF4" },
|
||||
warning: { accent: "#F59E0B", bg: "#FFFBEB" },
|
||||
error: { accent: "#EF4444", bg: "#FEF2F2" },
|
||||
};
|
||||
const typeColors = colors[options?.type ?? "info"];
|
||||
|
||||
const contents: FlexComponent[] = [];
|
||||
|
||||
// Accent bar
|
||||
contents.push({
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [],
|
||||
width: "4px",
|
||||
backgroundColor: typeColors.accent,
|
||||
cornerRadius: "2px",
|
||||
} as FlexBox);
|
||||
|
||||
// Content section
|
||||
const textContents: FlexComponent[] = [];
|
||||
|
||||
if (options?.title) {
|
||||
textContents.push({
|
||||
type: "text",
|
||||
text: options.title,
|
||||
size: "md",
|
||||
weight: "bold",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
textContents.push({
|
||||
type: "text",
|
||||
text,
|
||||
size: options?.title ? "sm" : "md",
|
||||
color: options?.title ? "#666666" : "#333333",
|
||||
wrap: true,
|
||||
margin: options?.title ? "sm" : undefined,
|
||||
} as FlexText);
|
||||
|
||||
contents.push({
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: textContents,
|
||||
flex: 1,
|
||||
paddingStart: "lg",
|
||||
} as FlexBox);
|
||||
|
||||
return {
|
||||
type: "bubble",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents,
|
||||
paddingAll: "xl",
|
||||
backgroundColor: typeColors.bg,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { FlexBox, FlexBubble, FlexText } from "./types.js";
|
||||
|
||||
export function attachFooterText(bubble: FlexBubble, footer: string) {
|
||||
bubble.footer = {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: footer,
|
||||
size: "xs",
|
||||
color: "#AAAAAA",
|
||||
wrap: true,
|
||||
align: "center",
|
||||
} as FlexText,
|
||||
],
|
||||
paddingAll: "lg",
|
||||
backgroundColor: "#FAFAFA",
|
||||
} as FlexBox;
|
||||
}
|
||||
@@ -1,555 +0,0 @@
|
||||
import type {
|
||||
FlexBox,
|
||||
FlexBubble,
|
||||
FlexButton,
|
||||
FlexComponent,
|
||||
FlexImage,
|
||||
FlexText,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Create a media player card for Sonos, Spotify, Apple Music, etc.
|
||||
*
|
||||
* Editorial design: Album art hero with gradient overlay for text,
|
||||
* prominent now-playing indicator, refined playback controls.
|
||||
*/
|
||||
export function createMediaPlayerCard(params: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
source?: string;
|
||||
imageUrl?: string;
|
||||
isPlaying?: boolean;
|
||||
progress?: string;
|
||||
controls?: {
|
||||
previous?: { data: string };
|
||||
play?: { data: string };
|
||||
pause?: { data: string };
|
||||
next?: { data: string };
|
||||
};
|
||||
extraActions?: Array<{ label: string; data: string }>;
|
||||
}): FlexBubble {
|
||||
const { title, subtitle, source, imageUrl, isPlaying, progress, controls, extraActions } = params;
|
||||
|
||||
// Track info section
|
||||
const trackInfo: FlexComponent[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
];
|
||||
|
||||
if (subtitle) {
|
||||
trackInfo.push({
|
||||
type: "text",
|
||||
text: subtitle,
|
||||
size: "md",
|
||||
color: "#666666",
|
||||
wrap: true,
|
||||
margin: "sm",
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
// Status row with source and playing indicator
|
||||
const statusItems: FlexComponent[] = [];
|
||||
|
||||
if (isPlaying !== undefined) {
|
||||
statusItems.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [],
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
backgroundColor: isPlaying ? "#06C755" : "#CCCCCC",
|
||||
cornerRadius: "4px",
|
||||
} as FlexBox,
|
||||
{
|
||||
type: "text",
|
||||
text: isPlaying ? "Now Playing" : "Paused",
|
||||
size: "xs",
|
||||
color: isPlaying ? "#06C755" : "#888888",
|
||||
weight: "bold",
|
||||
margin: "sm",
|
||||
} as FlexText,
|
||||
],
|
||||
alignItems: "center",
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
if (source) {
|
||||
statusItems.push({
|
||||
type: "text",
|
||||
text: source,
|
||||
size: "xs",
|
||||
color: "#AAAAAA",
|
||||
margin: statusItems.length > 0 ? "lg" : undefined,
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
statusItems.push({
|
||||
type: "text",
|
||||
text: progress,
|
||||
size: "xs",
|
||||
color: "#888888",
|
||||
align: "end",
|
||||
flex: 1,
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
const bodyContents: FlexComponent[] = [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: trackInfo,
|
||||
} as FlexBox,
|
||||
];
|
||||
|
||||
if (statusItems.length > 0) {
|
||||
bodyContents.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: statusItems,
|
||||
margin: "lg",
|
||||
alignItems: "center",
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
const bubble: FlexBubble = {
|
||||
type: "bubble",
|
||||
size: "mega",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: bodyContents,
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
};
|
||||
|
||||
// Album art hero
|
||||
if (imageUrl) {
|
||||
bubble.hero = {
|
||||
type: "image",
|
||||
url: imageUrl,
|
||||
size: "full",
|
||||
aspectRatio: "1:1",
|
||||
aspectMode: "cover",
|
||||
} as FlexImage;
|
||||
}
|
||||
|
||||
// Control buttons in footer
|
||||
if (controls || extraActions?.length) {
|
||||
const footerContents: FlexComponent[] = [];
|
||||
|
||||
// Main playback controls with refined styling
|
||||
if (controls) {
|
||||
const controlButtons: FlexComponent[] = [];
|
||||
|
||||
if (controls.previous) {
|
||||
controlButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: "⏮",
|
||||
data: controls.previous.data,
|
||||
},
|
||||
style: "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
} as FlexButton);
|
||||
}
|
||||
|
||||
if (controls.play) {
|
||||
controlButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: "▶",
|
||||
data: controls.play.data,
|
||||
},
|
||||
style: isPlaying ? "secondary" : "primary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
margin: controls.previous ? "md" : undefined,
|
||||
} as FlexButton);
|
||||
}
|
||||
|
||||
if (controls.pause) {
|
||||
controlButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: "⏸",
|
||||
data: controls.pause.data,
|
||||
},
|
||||
style: isPlaying ? "primary" : "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
margin: controlButtons.length > 0 ? "md" : undefined,
|
||||
} as FlexButton);
|
||||
}
|
||||
|
||||
if (controls.next) {
|
||||
controlButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: "⏭",
|
||||
data: controls.next.data,
|
||||
},
|
||||
style: "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
margin: controlButtons.length > 0 ? "md" : undefined,
|
||||
} as FlexButton);
|
||||
}
|
||||
|
||||
if (controlButtons.length > 0) {
|
||||
footerContents.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: controlButtons,
|
||||
} as FlexBox);
|
||||
}
|
||||
}
|
||||
|
||||
// Extra actions
|
||||
if (extraActions?.length) {
|
||||
footerContents.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: extraActions.slice(0, 2).map(
|
||||
(action, index) =>
|
||||
({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: action.label.slice(0, 15),
|
||||
data: action.data,
|
||||
},
|
||||
style: "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
margin: index > 0 ? "md" : undefined,
|
||||
}) as FlexButton,
|
||||
),
|
||||
margin: "md",
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
if (footerContents.length > 0) {
|
||||
bubble.footer = {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: footerContents,
|
||||
paddingAll: "lg",
|
||||
backgroundColor: "#FAFAFA",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Apple TV remote card with a D-pad and control rows.
|
||||
*/
|
||||
export function createAppleTvRemoteCard(params: {
|
||||
deviceName: string;
|
||||
status?: string;
|
||||
actionData: {
|
||||
up: string;
|
||||
down: string;
|
||||
left: string;
|
||||
right: string;
|
||||
select: string;
|
||||
menu: string;
|
||||
home: string;
|
||||
play: string;
|
||||
pause: string;
|
||||
volumeUp: string;
|
||||
volumeDown: string;
|
||||
mute: string;
|
||||
};
|
||||
}): FlexBubble {
|
||||
const { deviceName, status, actionData } = params;
|
||||
|
||||
const headerContents: FlexComponent[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: deviceName,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
];
|
||||
|
||||
if (status) {
|
||||
headerContents.push({
|
||||
type: "text",
|
||||
text: status,
|
||||
size: "sm",
|
||||
color: "#666666",
|
||||
wrap: true,
|
||||
margin: "sm",
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
const makeButton = (
|
||||
label: string,
|
||||
data: string,
|
||||
style: "primary" | "secondary" = "secondary",
|
||||
): FlexButton => ({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label,
|
||||
data,
|
||||
},
|
||||
style,
|
||||
height: "sm",
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
const dpadRows: FlexComponent[] = [
|
||||
{
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [{ type: "filler" }, makeButton("↑", actionData.up), { type: "filler" }],
|
||||
} as FlexBox,
|
||||
{
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
makeButton("←", actionData.left),
|
||||
makeButton("OK", actionData.select, "primary"),
|
||||
makeButton("→", actionData.right),
|
||||
],
|
||||
margin: "md",
|
||||
} as FlexBox,
|
||||
{
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [{ type: "filler" }, makeButton("↓", actionData.down), { type: "filler" }],
|
||||
margin: "md",
|
||||
} as FlexBox,
|
||||
];
|
||||
|
||||
const menuRow: FlexComponent = {
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [makeButton("Menu", actionData.menu), makeButton("Home", actionData.home)],
|
||||
margin: "lg",
|
||||
} as FlexBox;
|
||||
|
||||
const playbackRow: FlexComponent = {
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [makeButton("Play", actionData.play), makeButton("Pause", actionData.pause)],
|
||||
margin: "md",
|
||||
} as FlexBox;
|
||||
|
||||
const volumeRow: FlexComponent = {
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
makeButton("Vol +", actionData.volumeUp),
|
||||
makeButton("Mute", actionData.mute),
|
||||
makeButton("Vol -", actionData.volumeDown),
|
||||
],
|
||||
margin: "md",
|
||||
} as FlexBox;
|
||||
|
||||
return {
|
||||
type: "bubble",
|
||||
size: "mega",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: headerContents,
|
||||
} as FlexBox,
|
||||
{
|
||||
type: "separator",
|
||||
margin: "lg",
|
||||
color: "#EEEEEE",
|
||||
},
|
||||
...dpadRows,
|
||||
menuRow,
|
||||
playbackRow,
|
||||
volumeRow,
|
||||
],
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a device control card for Apple TV, smart home devices, etc.
|
||||
*
|
||||
* Editorial design: Device-focused header with status indicator,
|
||||
* clean control grid with clear visual hierarchy.
|
||||
*/
|
||||
export function createDeviceControlCard(params: {
|
||||
deviceName: string;
|
||||
deviceType?: string;
|
||||
status?: string;
|
||||
isOnline?: boolean;
|
||||
imageUrl?: string;
|
||||
controls: Array<{
|
||||
label: string;
|
||||
icon?: string;
|
||||
data: string;
|
||||
style?: "primary" | "secondary";
|
||||
}>;
|
||||
}): FlexBubble {
|
||||
const { deviceName, deviceType, status, isOnline, imageUrl, controls } = params;
|
||||
|
||||
// Device header with status indicator
|
||||
const headerContents: FlexComponent[] = [
|
||||
{
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
// Status dot
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [],
|
||||
width: "10px",
|
||||
height: "10px",
|
||||
backgroundColor: isOnline !== false ? "#06C755" : "#FF5555",
|
||||
cornerRadius: "5px",
|
||||
} as FlexBox,
|
||||
{
|
||||
type: "text",
|
||||
text: deviceName,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
flex: 1,
|
||||
margin: "md",
|
||||
} as FlexText,
|
||||
],
|
||||
alignItems: "center",
|
||||
} as FlexBox,
|
||||
];
|
||||
|
||||
if (deviceType) {
|
||||
headerContents.push({
|
||||
type: "text",
|
||||
text: deviceType,
|
||||
size: "sm",
|
||||
color: "#888888",
|
||||
margin: "sm",
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
headerContents.push({
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: status,
|
||||
size: "sm",
|
||||
color: "#444444",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
],
|
||||
margin: "lg",
|
||||
paddingAll: "md",
|
||||
backgroundColor: "#F8F9FA",
|
||||
cornerRadius: "md",
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
const bubble: FlexBubble = {
|
||||
type: "bubble",
|
||||
size: "mega",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: headerContents,
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
};
|
||||
|
||||
if (imageUrl) {
|
||||
bubble.hero = {
|
||||
type: "image",
|
||||
url: imageUrl,
|
||||
size: "full",
|
||||
aspectRatio: "16:9",
|
||||
aspectMode: "cover",
|
||||
} as FlexImage;
|
||||
}
|
||||
|
||||
// Control buttons in refined grid layout (2 per row)
|
||||
if (controls.length > 0) {
|
||||
const rows: FlexComponent[] = [];
|
||||
const limitedControls = controls.slice(0, 6);
|
||||
|
||||
for (let i = 0; i < limitedControls.length; i += 2) {
|
||||
const rowButtons: FlexComponent[] = [];
|
||||
|
||||
for (let j = i; j < Math.min(i + 2, limitedControls.length); j++) {
|
||||
const ctrl = limitedControls[j];
|
||||
const buttonLabel = ctrl.icon ? `${ctrl.icon} ${ctrl.label}` : ctrl.label;
|
||||
|
||||
rowButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: buttonLabel.slice(0, 18),
|
||||
data: ctrl.data,
|
||||
},
|
||||
style: ctrl.style ?? "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
margin: j > i ? "md" : undefined,
|
||||
} as FlexButton);
|
||||
}
|
||||
|
||||
// If odd number of controls in last row, add spacer
|
||||
if (rowButtons.length === 1) {
|
||||
rowButtons.push({
|
||||
type: "filler",
|
||||
});
|
||||
}
|
||||
|
||||
rows.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: rowButtons,
|
||||
margin: i > 0 ? "md" : undefined,
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
bubble.footer = {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: rows,
|
||||
paddingAll: "lg",
|
||||
backgroundColor: "#FAFAFA",
|
||||
};
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import type { FlexContainer } from "./types.js";
|
||||
|
||||
/**
|
||||
* Wrap a FlexContainer in a FlexMessage
|
||||
*/
|
||||
export function toFlexMessage(altText: string, contents: FlexContainer): messagingApi.FlexMessage {
|
||||
return {
|
||||
type: "flex",
|
||||
altText,
|
||||
contents,
|
||||
};
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
import { attachFooterText } from "./common.js";
|
||||
import type { Action, FlexBox, FlexBubble, FlexComponent, FlexText } from "./types.js";
|
||||
|
||||
function buildTitleSubtitleHeader(params: { title: string; subtitle?: string }): FlexComponent[] {
|
||||
const { title, subtitle } = params;
|
||||
const headerContents: FlexComponent[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
];
|
||||
|
||||
if (subtitle) {
|
||||
headerContents.push({
|
||||
type: "text",
|
||||
text: subtitle,
|
||||
size: "sm",
|
||||
color: "#888888",
|
||||
margin: "sm",
|
||||
wrap: true,
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
return headerContents;
|
||||
}
|
||||
|
||||
function buildCardHeaderSections(headerContents: FlexComponent[]): FlexComponent[] {
|
||||
return [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: headerContents,
|
||||
paddingBottom: "lg",
|
||||
} as FlexBox,
|
||||
{
|
||||
type: "separator",
|
||||
color: "#EEEEEE",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createMegaBubbleWithFooter(params: {
|
||||
bodyContents: FlexComponent[];
|
||||
footer?: string;
|
||||
}): FlexBubble {
|
||||
const bubble: FlexBubble = {
|
||||
type: "bubble",
|
||||
size: "mega",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: params.bodyContents,
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
};
|
||||
|
||||
if (params.footer) {
|
||||
attachFooterText(bubble, params.footer);
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a receipt/summary card (for orders, transactions, data tables)
|
||||
*
|
||||
* Editorial design: Clean table layout with alternating row backgrounds,
|
||||
* prominent total section, and clear visual hierarchy.
|
||||
*/
|
||||
export function createReceiptCard(params: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
items: Array<{ name: string; value: string; highlight?: boolean }>;
|
||||
total?: { label: string; value: string };
|
||||
footer?: string;
|
||||
}): FlexBubble {
|
||||
const { title, subtitle, items, total, footer } = params;
|
||||
|
||||
const itemRows: FlexComponent[] = items.slice(0, 12).map(
|
||||
(item, index) =>
|
||||
({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: item.name,
|
||||
size: "sm",
|
||||
color: item.highlight ? "#111111" : "#666666",
|
||||
weight: item.highlight ? "bold" : "regular",
|
||||
flex: 3,
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
{
|
||||
type: "text",
|
||||
text: item.value,
|
||||
size: "sm",
|
||||
color: item.highlight ? "#06C755" : "#333333",
|
||||
weight: item.highlight ? "bold" : "regular",
|
||||
flex: 2,
|
||||
align: "end",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
],
|
||||
paddingAll: "md",
|
||||
backgroundColor: index % 2 === 0 ? "#FFFFFF" : "#FAFAFA",
|
||||
}) as FlexBox,
|
||||
);
|
||||
|
||||
// Header section
|
||||
const headerContents = buildTitleSubtitleHeader({ title, subtitle });
|
||||
|
||||
const bodyContents: FlexComponent[] = [
|
||||
...buildCardHeaderSections(headerContents),
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: itemRows,
|
||||
margin: "md",
|
||||
cornerRadius: "md",
|
||||
borderWidth: "light",
|
||||
borderColor: "#EEEEEE",
|
||||
} as FlexBox,
|
||||
];
|
||||
|
||||
// Total section with emphasis
|
||||
if (total) {
|
||||
bodyContents.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: total.label,
|
||||
size: "lg",
|
||||
weight: "bold",
|
||||
color: "#111111",
|
||||
flex: 2,
|
||||
} as FlexText,
|
||||
{
|
||||
type: "text",
|
||||
text: total.value,
|
||||
size: "xl",
|
||||
weight: "bold",
|
||||
color: "#06C755",
|
||||
flex: 2,
|
||||
align: "end",
|
||||
} as FlexText,
|
||||
],
|
||||
margin: "xl",
|
||||
paddingAll: "lg",
|
||||
backgroundColor: "#F0FDF4",
|
||||
cornerRadius: "lg",
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
return createMegaBubbleWithFooter({ bodyContents, footer });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a calendar event card (for meetings, appointments, reminders)
|
||||
*
|
||||
* Editorial design: Date as hero, strong typographic hierarchy,
|
||||
* color-blocked zones, full text wrapping for readability.
|
||||
*/
|
||||
export function createEventCard(params: {
|
||||
title: string;
|
||||
date: string;
|
||||
time?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
calendar?: string;
|
||||
isAllDay?: boolean;
|
||||
action?: Action;
|
||||
}): FlexBubble {
|
||||
const { title, date, time, location, description, calendar, isAllDay, action } = params;
|
||||
|
||||
// Hero date block - the most important information
|
||||
const dateBlock: FlexBox = {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: date.toUpperCase(),
|
||||
size: "sm",
|
||||
weight: "bold",
|
||||
color: "#06C755",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
{
|
||||
type: "text",
|
||||
text: isAllDay ? "ALL DAY" : (time ?? ""),
|
||||
size: "xxl",
|
||||
weight: "bold",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
margin: "xs",
|
||||
} as FlexText,
|
||||
],
|
||||
paddingBottom: "lg",
|
||||
borderWidth: "none",
|
||||
};
|
||||
|
||||
// If no time and not all day, hide the time display
|
||||
if (!time && !isAllDay) {
|
||||
dateBlock.contents = [
|
||||
{
|
||||
type: "text",
|
||||
text: date,
|
||||
size: "xl",
|
||||
weight: "bold",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
];
|
||||
}
|
||||
|
||||
// Event title with accent bar
|
||||
const titleBlock: FlexBox = {
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [],
|
||||
width: "4px",
|
||||
backgroundColor: "#06C755",
|
||||
cornerRadius: "2px",
|
||||
} as FlexBox,
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
size: "lg",
|
||||
weight: "bold",
|
||||
color: "#1a1a1a",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
...(calendar
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: calendar,
|
||||
size: "xs",
|
||||
color: "#888888",
|
||||
margin: "sm",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
flex: 1,
|
||||
paddingStart: "lg",
|
||||
} as FlexBox,
|
||||
],
|
||||
paddingTop: "lg",
|
||||
paddingBottom: "lg",
|
||||
borderWidth: "light",
|
||||
borderColor: "#EEEEEE",
|
||||
};
|
||||
|
||||
const bodyContents: FlexComponent[] = [dateBlock, titleBlock];
|
||||
|
||||
// Details section (location + description) in subtle background
|
||||
const hasDetails = location || description;
|
||||
if (hasDetails) {
|
||||
const detailItems: FlexComponent[] = [];
|
||||
|
||||
if (location) {
|
||||
detailItems.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "📍",
|
||||
size: "sm",
|
||||
flex: 0,
|
||||
} as FlexText,
|
||||
{
|
||||
type: "text",
|
||||
text: location,
|
||||
size: "sm",
|
||||
color: "#444444",
|
||||
margin: "md",
|
||||
flex: 1,
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
],
|
||||
alignItems: "flex-start",
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
if (description) {
|
||||
detailItems.push({
|
||||
type: "text",
|
||||
text: description,
|
||||
size: "sm",
|
||||
color: "#666666",
|
||||
wrap: true,
|
||||
margin: location ? "lg" : "none",
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
bodyContents.push({
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: detailItems,
|
||||
margin: "lg",
|
||||
paddingAll: "lg",
|
||||
backgroundColor: "#F8F9FA",
|
||||
cornerRadius: "lg",
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "bubble",
|
||||
size: "mega",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: bodyContents,
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
action,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a calendar agenda card showing multiple events
|
||||
*
|
||||
* Editorial timeline design: Time-focused left column with event details
|
||||
* on the right. Visual accent bars indicate event priority/recency.
|
||||
*/
|
||||
export function createAgendaCard(params: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
events: Array<{
|
||||
title: string;
|
||||
time?: string;
|
||||
location?: string;
|
||||
calendar?: string;
|
||||
isNow?: boolean;
|
||||
}>;
|
||||
footer?: string;
|
||||
}): FlexBubble {
|
||||
const { title, subtitle, events, footer } = params;
|
||||
|
||||
// Header with title and optional subtitle
|
||||
const headerContents = buildTitleSubtitleHeader({ title, subtitle });
|
||||
|
||||
// Event timeline items
|
||||
const eventItems: FlexComponent[] = events.slice(0, 6).map((event, index) => {
|
||||
const isActive = event.isNow || index === 0;
|
||||
const accentColor = isActive ? "#06C755" : "#E5E5E5";
|
||||
|
||||
// Time column (fixed width)
|
||||
const timeColumn: FlexBox = {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: event.time ?? "—",
|
||||
size: "sm",
|
||||
weight: isActive ? "bold" : "regular",
|
||||
color: isActive ? "#06C755" : "#666666",
|
||||
align: "end",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
],
|
||||
width: "65px",
|
||||
justifyContent: "flex-start",
|
||||
};
|
||||
|
||||
// Accent dot
|
||||
const dotColumn: FlexBox = {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [],
|
||||
width: "10px",
|
||||
height: "10px",
|
||||
backgroundColor: accentColor,
|
||||
cornerRadius: "5px",
|
||||
} as FlexBox,
|
||||
],
|
||||
width: "24px",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
paddingTop: "xs",
|
||||
};
|
||||
|
||||
// Event details column
|
||||
const detailContents: FlexComponent[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: event.title,
|
||||
size: "md",
|
||||
weight: "bold",
|
||||
color: "#1a1a1a",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
];
|
||||
|
||||
// Secondary info line
|
||||
const secondaryParts: string[] = [];
|
||||
if (event.location) {
|
||||
secondaryParts.push(event.location);
|
||||
}
|
||||
if (event.calendar) {
|
||||
secondaryParts.push(event.calendar);
|
||||
}
|
||||
|
||||
if (secondaryParts.length > 0) {
|
||||
detailContents.push({
|
||||
type: "text",
|
||||
text: secondaryParts.join(" · "),
|
||||
size: "xs",
|
||||
color: "#888888",
|
||||
wrap: true,
|
||||
margin: "xs",
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
const detailColumn: FlexBox = {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: detailContents,
|
||||
flex: 1,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [timeColumn, dotColumn, detailColumn],
|
||||
margin: index > 0 ? "xl" : undefined,
|
||||
alignItems: "flex-start",
|
||||
} as FlexBox;
|
||||
});
|
||||
|
||||
const bodyContents: FlexComponent[] = [
|
||||
...buildCardHeaderSections(headerContents),
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: eventItems,
|
||||
paddingTop: "xl",
|
||||
} as FlexBox,
|
||||
];
|
||||
|
||||
return createMegaBubbleWithFooter({ bodyContents, footer });
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
|
||||
export type FlexContainer = messagingApi.FlexContainer;
|
||||
export type FlexBubble = messagingApi.FlexBubble;
|
||||
export type FlexCarousel = messagingApi.FlexCarousel;
|
||||
export type FlexBox = messagingApi.FlexBox;
|
||||
export type FlexText = messagingApi.FlexText;
|
||||
export type FlexImage = messagingApi.FlexImage;
|
||||
export type FlexButton = messagingApi.FlexButton;
|
||||
export type FlexComponent = messagingApi.FlexComponent;
|
||||
export type Action = messagingApi.Action;
|
||||
|
||||
export interface ListItem {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: Action;
|
||||
}
|
||||
|
||||
export interface CardAction {
|
||||
label: string;
|
||||
action: Action;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveExactLineGroupConfigKey,
|
||||
resolveLineGroupConfigEntry,
|
||||
resolveLineGroupHistoryKey,
|
||||
resolveLineGroupLookupIds,
|
||||
resolveLineGroupsConfig,
|
||||
} from "./group-keys.js";
|
||||
|
||||
describe("resolveLineGroupLookupIds", () => {
|
||||
it("expands raw ids to both prefixed candidates", () => {
|
||||
expect(resolveLineGroupLookupIds("abc123")).toEqual(["abc123", "group:abc123", "room:abc123"]);
|
||||
});
|
||||
|
||||
it("preserves prefixed ids while also checking the raw id", () => {
|
||||
expect(resolveLineGroupLookupIds("room:abc123")).toEqual(["abc123", "room:abc123"]);
|
||||
expect(resolveLineGroupLookupIds("group:abc123")).toEqual(["abc123", "group:abc123"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLineGroupConfigEntry", () => {
|
||||
it("matches raw, prefixed, and wildcard group config entries", () => {
|
||||
const groups = {
|
||||
"group:g1": { requireMention: false },
|
||||
"room:r1": { systemPrompt: "Room prompt" },
|
||||
"*": { requireMention: true },
|
||||
};
|
||||
|
||||
expect(resolveLineGroupConfigEntry(groups, { groupId: "g1" })).toEqual({
|
||||
requireMention: false,
|
||||
});
|
||||
expect(resolveLineGroupConfigEntry(groups, { roomId: "r1" })).toEqual({
|
||||
systemPrompt: "Room prompt",
|
||||
});
|
||||
expect(resolveLineGroupConfigEntry(groups, { groupId: "missing" })).toEqual({
|
||||
requireMention: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLineGroupHistoryKey", () => {
|
||||
it("uses the raw group or room id as the shared LINE peer key", () => {
|
||||
expect(resolveLineGroupHistoryKey({ groupId: "g1" })).toBe("g1");
|
||||
expect(resolveLineGroupHistoryKey({ roomId: "r1" })).toBe("r1");
|
||||
expect(resolveLineGroupHistoryKey({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("account-scoped LINE groups", () => {
|
||||
it("resolves the effective account-scoped groups map", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
groups: {
|
||||
"group:g1": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveLineGroupsConfig(cfg, "work")).toEqual({
|
||||
"group:g1": { requireMention: false },
|
||||
});
|
||||
expect(resolveExactLineGroupConfigKey({ cfg, accountId: "work", groupId: "g1" })).toBe(
|
||||
"group:g1",
|
||||
);
|
||||
expect(resolveExactLineGroupConfigKey({ cfg, accountId: "default", groupId: "g1" })).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { normalizeAccountId } from "../routing/account-id.js";
|
||||
import { resolveAccountEntry } from "../routing/account-lookup.js";
|
||||
import type { LineConfig, LineGroupConfig } from "./types.js";
|
||||
|
||||
export function resolveLineGroupLookupIds(groupId?: string | null): string[] {
|
||||
const normalized = groupId?.trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
if (normalized.startsWith("group:") || normalized.startsWith("room:")) {
|
||||
const rawId = normalized.split(":").slice(1).join(":");
|
||||
return rawId ? [rawId, normalized] : [normalized];
|
||||
}
|
||||
return [normalized, `group:${normalized}`, `room:${normalized}`];
|
||||
}
|
||||
|
||||
export function resolveLineGroupConfigEntry<T>(
|
||||
groups: Record<string, T | undefined> | undefined,
|
||||
params: { groupId?: string | null; roomId?: string | null },
|
||||
): T | undefined {
|
||||
if (!groups) {
|
||||
return undefined;
|
||||
}
|
||||
for (const candidate of resolveLineGroupLookupIds(params.groupId)) {
|
||||
const hit = groups[candidate];
|
||||
if (hit) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
for (const candidate of resolveLineGroupLookupIds(params.roomId)) {
|
||||
const hit = groups[candidate];
|
||||
if (hit) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
return groups["*"];
|
||||
}
|
||||
|
||||
export function resolveLineGroupsConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string | null,
|
||||
): Record<string, LineGroupConfig | undefined> | undefined {
|
||||
const lineConfig = cfg.channels?.line as LineConfig | undefined;
|
||||
if (!lineConfig) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const accountGroups = resolveAccountEntry(lineConfig.accounts, normalizedAccountId)?.groups;
|
||||
return accountGroups ?? lineConfig.groups;
|
||||
}
|
||||
|
||||
export function resolveExactLineGroupConfigKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
groupId?: string | null;
|
||||
}): string | undefined {
|
||||
const groups = resolveLineGroupsConfig(params.cfg, params.accountId);
|
||||
if (!groups) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveLineGroupLookupIds(params.groupId).find((candidate) =>
|
||||
Object.hasOwn(groups, candidate),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveLineGroupHistoryKey(params: {
|
||||
groupId?: string | null;
|
||||
roomId?: string | null;
|
||||
}): string | undefined {
|
||||
return params.groupId?.trim() || params.roomId?.trim() || undefined;
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractMarkdownTables,
|
||||
extractCodeBlocks,
|
||||
extractLinks,
|
||||
stripMarkdown,
|
||||
processLineMessage,
|
||||
convertTableToFlexBubble,
|
||||
convertCodeBlockToFlexBubble,
|
||||
hasMarkdownToConvert,
|
||||
} from "./markdown-to-line.js";
|
||||
|
||||
describe("extractMarkdownTables", () => {
|
||||
it("extracts a simple 2-column table", () => {
|
||||
const text = `Here is a table:
|
||||
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| foo | 123 |
|
||||
| bar | 456 |
|
||||
|
||||
And some more text.`;
|
||||
|
||||
const { tables, textWithoutTables } = extractMarkdownTables(text);
|
||||
|
||||
expect(tables).toHaveLength(1);
|
||||
expect(tables[0].headers).toEqual(["Name", "Value"]);
|
||||
expect(tables[0].rows).toEqual([
|
||||
["foo", "123"],
|
||||
["bar", "456"],
|
||||
]);
|
||||
expect(textWithoutTables).toContain("Here is a table:");
|
||||
expect(textWithoutTables).toContain("And some more text.");
|
||||
expect(textWithoutTables).not.toContain("|");
|
||||
});
|
||||
|
||||
it("extracts multiple tables", () => {
|
||||
const text = `Table 1:
|
||||
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
|
||||
Table 2:
|
||||
|
||||
| X | Y |
|
||||
|---|---|
|
||||
| 3 | 4 |`;
|
||||
|
||||
const { tables } = extractMarkdownTables(text);
|
||||
|
||||
expect(tables).toHaveLength(2);
|
||||
expect(tables[0].headers).toEqual(["A", "B"]);
|
||||
expect(tables[1].headers).toEqual(["X", "Y"]);
|
||||
});
|
||||
|
||||
it("handles tables with alignment markers", () => {
|
||||
const text = `| Left | Center | Right |
|
||||
|:-----|:------:|------:|
|
||||
| a | b | c |`;
|
||||
|
||||
const { tables } = extractMarkdownTables(text);
|
||||
|
||||
expect(tables).toHaveLength(1);
|
||||
expect(tables[0].headers).toEqual(["Left", "Center", "Right"]);
|
||||
expect(tables[0].rows).toEqual([["a", "b", "c"]]);
|
||||
});
|
||||
|
||||
it("returns empty when no tables present", () => {
|
||||
const text = "Just some plain text without tables.";
|
||||
|
||||
const { tables, textWithoutTables } = extractMarkdownTables(text);
|
||||
|
||||
expect(tables).toHaveLength(0);
|
||||
expect(textWithoutTables).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractCodeBlocks", () => {
|
||||
it("extracts code blocks across language/no-language/multiple variants", () => {
|
||||
const withLanguage = `Here is some code:
|
||||
|
||||
\`\`\`javascript
|
||||
const x = 1;
|
||||
console.log(x);
|
||||
\`\`\`
|
||||
|
||||
And more text.`;
|
||||
const withLanguageResult = extractCodeBlocks(withLanguage);
|
||||
expect(withLanguageResult.codeBlocks).toHaveLength(1);
|
||||
expect(withLanguageResult.codeBlocks[0].language).toBe("javascript");
|
||||
expect(withLanguageResult.codeBlocks[0].code).toBe("const x = 1;\nconsole.log(x);");
|
||||
expect(withLanguageResult.textWithoutCode).toContain("Here is some code:");
|
||||
expect(withLanguageResult.textWithoutCode).toContain("And more text.");
|
||||
expect(withLanguageResult.textWithoutCode).not.toContain("```");
|
||||
|
||||
const withoutLanguage = `\`\`\`
|
||||
plain code
|
||||
\`\`\``;
|
||||
const withoutLanguageResult = extractCodeBlocks(withoutLanguage);
|
||||
expect(withoutLanguageResult.codeBlocks).toHaveLength(1);
|
||||
expect(withoutLanguageResult.codeBlocks[0].language).toBeUndefined();
|
||||
expect(withoutLanguageResult.codeBlocks[0].code).toBe("plain code");
|
||||
|
||||
const multiple = `\`\`\`python
|
||||
print("hello")
|
||||
\`\`\`
|
||||
|
||||
Some text
|
||||
|
||||
\`\`\`bash
|
||||
echo "world"
|
||||
\`\`\``;
|
||||
const multipleResult = extractCodeBlocks(multiple);
|
||||
expect(multipleResult.codeBlocks).toHaveLength(2);
|
||||
expect(multipleResult.codeBlocks[0].language).toBe("python");
|
||||
expect(multipleResult.codeBlocks[1].language).toBe("bash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLinks", () => {
|
||||
it("extracts markdown links", () => {
|
||||
const text = "Check out [Google](https://google.com) and [GitHub](https://github.com).";
|
||||
|
||||
const { links, textWithLinks } = extractLinks(text);
|
||||
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toEqual({ text: "Google", url: "https://google.com" });
|
||||
expect(links[1]).toEqual({ text: "GitHub", url: "https://github.com" });
|
||||
expect(textWithLinks).toBe("Check out Google and GitHub.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripMarkdown", () => {
|
||||
it("strips inline markdown marker variants", () => {
|
||||
const cases = [
|
||||
["strips bold **", "This is **bold** text", "This is bold text"],
|
||||
["strips bold __", "This is __bold__ text", "This is bold text"],
|
||||
["strips italic *", "This is *italic* text", "This is italic text"],
|
||||
["strips italic _", "This is _italic_ text", "This is italic text"],
|
||||
["strips strikethrough", "This is ~~deleted~~ text", "This is deleted text"],
|
||||
["removes hr ---", "Above\n---\nBelow", "Above\n\nBelow"],
|
||||
["removes hr ***", "Above\n***\nBelow", "Above\n\nBelow"],
|
||||
["strips inline code markers", "Use `const` keyword", "Use const keyword"],
|
||||
] as const;
|
||||
for (const [name, input, expected] of cases) {
|
||||
expect(stripMarkdown(input), name).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles complex markdown", () => {
|
||||
const input = `# Title
|
||||
|
||||
This is **bold** and *italic* text.
|
||||
|
||||
> A quote
|
||||
|
||||
Some ~~deleted~~ content.`;
|
||||
|
||||
const result = stripMarkdown(input);
|
||||
|
||||
expect(result).toContain("Title");
|
||||
expect(result).toContain("This is bold and italic text.");
|
||||
expect(result).toContain("A quote");
|
||||
expect(result).toContain("Some deleted content.");
|
||||
expect(result).not.toContain("#");
|
||||
expect(result).not.toContain("**");
|
||||
expect(result).not.toContain("~~");
|
||||
expect(result).not.toContain(">");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertTableToFlexBubble", () => {
|
||||
it("replaces empty cells with placeholders", () => {
|
||||
const table = {
|
||||
headers: ["A", "B"],
|
||||
rows: [["", ""]],
|
||||
};
|
||||
|
||||
const bubble = convertTableToFlexBubble(table);
|
||||
const body = bubble.body as {
|
||||
contents: Array<{ contents?: Array<{ contents?: Array<{ text: string }> }> }>;
|
||||
};
|
||||
const rowsBox = body.contents[2] as { contents: Array<{ contents: Array<{ text: string }> }> };
|
||||
|
||||
expect(rowsBox.contents[0].contents[0].text).toBe("-");
|
||||
expect(rowsBox.contents[0].contents[1].text).toBe("-");
|
||||
});
|
||||
|
||||
it("strips bold markers and applies weight for fully bold cells", () => {
|
||||
const table = {
|
||||
headers: ["**Name**", "Status"],
|
||||
rows: [["**Alpha**", "OK"]],
|
||||
};
|
||||
|
||||
const bubble = convertTableToFlexBubble(table);
|
||||
const body = bubble.body as {
|
||||
contents: Array<{ contents?: Array<{ text: string; weight?: string }> }>;
|
||||
};
|
||||
const headerRow = body.contents[0] as { contents: Array<{ text: string; weight?: string }> };
|
||||
const dataRow = body.contents[2] as { contents: Array<{ text: string; weight?: string }> };
|
||||
|
||||
expect(headerRow.contents[0].text).toBe("Name");
|
||||
expect(headerRow.contents[0].weight).toBe("bold");
|
||||
expect(dataRow.contents[0].text).toBe("Alpha");
|
||||
expect(dataRow.contents[0].weight).toBe("bold");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertCodeBlockToFlexBubble", () => {
|
||||
it("creates a code card with language label", () => {
|
||||
const block = { language: "typescript", code: "const x = 1;" };
|
||||
|
||||
const bubble = convertCodeBlockToFlexBubble(block);
|
||||
|
||||
const body = bubble.body as { contents: Array<{ text: string }> };
|
||||
expect(body.contents[0].text).toBe("Code (typescript)");
|
||||
});
|
||||
|
||||
it("creates a code card without language", () => {
|
||||
const block = { code: "plain code" };
|
||||
|
||||
const bubble = convertCodeBlockToFlexBubble(block);
|
||||
|
||||
const body = bubble.body as { contents: Array<{ text: string }> };
|
||||
expect(body.contents[0].text).toBe("Code");
|
||||
});
|
||||
|
||||
it("truncates very long code", () => {
|
||||
const longCode = "x".repeat(3000);
|
||||
const block = { code: longCode };
|
||||
|
||||
const bubble = convertCodeBlockToFlexBubble(block);
|
||||
|
||||
const body = bubble.body as { contents: Array<{ contents: Array<{ text: string }> }> };
|
||||
const codeText = body.contents[1].contents[0].text;
|
||||
expect(codeText.length).toBeLessThan(longCode.length);
|
||||
expect(codeText).toContain("...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processLineMessage", () => {
|
||||
it("processes text with code blocks", () => {
|
||||
const text = `Check this code:
|
||||
|
||||
\`\`\`js
|
||||
console.log("hi");
|
||||
\`\`\`
|
||||
|
||||
That's it.`;
|
||||
|
||||
const result = processLineMessage(text);
|
||||
|
||||
expect(result.flexMessages).toHaveLength(1);
|
||||
expect(result.text).toContain("Check this code:");
|
||||
expect(result.text).toContain("That's it.");
|
||||
expect(result.text).not.toContain("```");
|
||||
});
|
||||
|
||||
it("handles mixed content", () => {
|
||||
const text = `# Summary
|
||||
|
||||
Here's **important** info:
|
||||
|
||||
| Item | Count |
|
||||
|------|-------|
|
||||
| A | 5 |
|
||||
|
||||
\`\`\`python
|
||||
print("done")
|
||||
\`\`\`
|
||||
|
||||
> Note: Check the link [here](https://example.com).`;
|
||||
|
||||
const result = processLineMessage(text);
|
||||
|
||||
// Should have 2 flex messages (table + code)
|
||||
expect(result.flexMessages).toHaveLength(2);
|
||||
|
||||
// Text should be cleaned
|
||||
expect(result.text).toContain("Summary");
|
||||
expect(result.text).toContain("important");
|
||||
expect(result.text).toContain("Note: Check the link here.");
|
||||
expect(result.text).not.toContain("#");
|
||||
expect(result.text).not.toContain("**");
|
||||
expect(result.text).not.toContain("|");
|
||||
expect(result.text).not.toContain("```");
|
||||
expect(result.text).not.toContain("[here]");
|
||||
});
|
||||
|
||||
it("handles plain text unchanged", () => {
|
||||
const text = "Just plain text with no markdown.";
|
||||
|
||||
const result = processLineMessage(text);
|
||||
|
||||
expect(result.text).toBe(text);
|
||||
expect(result.flexMessages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasMarkdownToConvert", () => {
|
||||
it("detects supported markdown patterns", () => {
|
||||
const cases = [
|
||||
`| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |`,
|
||||
"```js\ncode\n```",
|
||||
"**bold**",
|
||||
"~~deleted~~",
|
||||
"# Title",
|
||||
"> quote",
|
||||
];
|
||||
|
||||
for (const text of cases) {
|
||||
expect(hasMarkdownToConvert(text)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for plain text", () => {
|
||||
expect(hasMarkdownToConvert("Just plain text.")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,451 +0,0 @@
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import { createReceiptCard, toFlexMessage, type FlexBubble } from "./flex-templates.js";
|
||||
|
||||
type FlexMessage = messagingApi.FlexMessage;
|
||||
type FlexComponent = messagingApi.FlexComponent;
|
||||
type FlexText = messagingApi.FlexText;
|
||||
type FlexBox = messagingApi.FlexBox;
|
||||
|
||||
export interface ProcessedLineMessage {
|
||||
/** The processed text with markdown stripped */
|
||||
text: string;
|
||||
/** Flex messages extracted from tables/code blocks */
|
||||
flexMessages: FlexMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex patterns for markdown detection
|
||||
*/
|
||||
const MARKDOWN_TABLE_REGEX = /^\|(.+)\|[\r\n]+\|[-:\s|]+\|[\r\n]+((?:\|.+\|[\r\n]*)+)/gm;
|
||||
const MARKDOWN_CODE_BLOCK_REGEX = /```(\w*)\n([\s\S]*?)```/g;
|
||||
const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
|
||||
/**
|
||||
* Detect and extract markdown tables from text
|
||||
*/
|
||||
export function extractMarkdownTables(text: string): {
|
||||
tables: MarkdownTable[];
|
||||
textWithoutTables: string;
|
||||
} {
|
||||
const tables: MarkdownTable[] = [];
|
||||
let textWithoutTables = text;
|
||||
|
||||
// Reset regex state
|
||||
MARKDOWN_TABLE_REGEX.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
const matches: { fullMatch: string; table: MarkdownTable }[] = [];
|
||||
|
||||
while ((match = MARKDOWN_TABLE_REGEX.exec(text)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const headerLine = match[1];
|
||||
const bodyLines = match[2];
|
||||
|
||||
const headers = parseTableRow(headerLine);
|
||||
const rows = bodyLines
|
||||
.trim()
|
||||
.split(/[\r\n]+/)
|
||||
.filter((line) => line.trim())
|
||||
.map(parseTableRow);
|
||||
|
||||
if (headers.length > 0 && rows.length > 0) {
|
||||
matches.push({
|
||||
fullMatch,
|
||||
table: { headers, rows },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove tables from text in reverse order to preserve indices
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const { fullMatch, table } = matches[i];
|
||||
tables.unshift(table);
|
||||
textWithoutTables = textWithoutTables.replace(fullMatch, "");
|
||||
}
|
||||
|
||||
return { tables, textWithoutTables };
|
||||
}
|
||||
|
||||
export interface MarkdownTable {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single table row (pipe-separated values)
|
||||
*/
|
||||
function parseTableRow(row: string): string[] {
|
||||
return row
|
||||
.split("|")
|
||||
.map((cell) => cell.trim())
|
||||
.filter((cell, index, arr) => {
|
||||
// Filter out empty cells at start/end (from leading/trailing pipes)
|
||||
if (index === 0 && cell === "") {
|
||||
return false;
|
||||
}
|
||||
if (index === arr.length - 1 && cell === "") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a markdown table to a LINE Flex Message bubble
|
||||
*/
|
||||
export function convertTableToFlexBubble(table: MarkdownTable): FlexBubble {
|
||||
const parseCell = (
|
||||
value: string | undefined,
|
||||
): { text: string; bold: boolean; hasMarkup: boolean } => {
|
||||
const raw = value?.trim() ?? "";
|
||||
if (!raw) {
|
||||
return { text: "-", bold: false, hasMarkup: false };
|
||||
}
|
||||
|
||||
let hasMarkup = false;
|
||||
const stripped = raw.replace(/\*\*(.+?)\*\*/g, (_, inner) => {
|
||||
hasMarkup = true;
|
||||
return String(inner);
|
||||
});
|
||||
const text = stripped.trim() || "-";
|
||||
const bold = /^\*\*.+\*\*$/.test(raw);
|
||||
|
||||
return { text, bold, hasMarkup };
|
||||
};
|
||||
|
||||
const headerCells = table.headers.map((header) => parseCell(header));
|
||||
const rowCells = table.rows.map((row) => row.map((cell) => parseCell(cell)));
|
||||
const hasInlineMarkup =
|
||||
headerCells.some((cell) => cell.hasMarkup) ||
|
||||
rowCells.some((row) => row.some((cell) => cell.hasMarkup));
|
||||
|
||||
// For simple 2-column tables, use receipt card format
|
||||
if (table.headers.length === 2 && !hasInlineMarkup) {
|
||||
const items = rowCells.map((row) => ({
|
||||
name: row[0]?.text ?? "-",
|
||||
value: row[1]?.text ?? "-",
|
||||
}));
|
||||
|
||||
return createReceiptCard({
|
||||
title: headerCells.map((cell) => cell.text).join(" / "),
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
// For multi-column tables, create a custom layout
|
||||
const headerRow: FlexComponent = {
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: headerCells.map((cell) => ({
|
||||
type: "text",
|
||||
text: cell.text,
|
||||
weight: "bold",
|
||||
size: "sm",
|
||||
color: "#333333",
|
||||
flex: 1,
|
||||
wrap: true,
|
||||
})) as FlexText[],
|
||||
paddingBottom: "sm",
|
||||
} as FlexBox;
|
||||
|
||||
const dataRows: FlexComponent[] = rowCells.slice(0, 10).map((row, rowIndex) => {
|
||||
const rowContents = table.headers.map((_, colIndex) => {
|
||||
const cell = row[colIndex] ?? { text: "-", bold: false, hasMarkup: false };
|
||||
return {
|
||||
type: "text",
|
||||
text: cell.text,
|
||||
size: "sm",
|
||||
color: "#666666",
|
||||
flex: 1,
|
||||
wrap: true,
|
||||
weight: cell.bold ? "bold" : undefined,
|
||||
};
|
||||
}) as FlexText[];
|
||||
|
||||
return {
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: rowContents,
|
||||
margin: rowIndex === 0 ? "md" : "sm",
|
||||
} as FlexBox;
|
||||
});
|
||||
|
||||
return {
|
||||
type: "bubble",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [headerRow, { type: "separator", margin: "sm" }, ...dataRows],
|
||||
paddingAll: "lg",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and extract code blocks from text
|
||||
*/
|
||||
export function extractCodeBlocks(text: string): {
|
||||
codeBlocks: CodeBlock[];
|
||||
textWithoutCode: string;
|
||||
} {
|
||||
const codeBlocks: CodeBlock[] = [];
|
||||
let textWithoutCode = text;
|
||||
|
||||
// Reset regex state
|
||||
MARKDOWN_CODE_BLOCK_REGEX.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
const matches: { fullMatch: string; block: CodeBlock }[] = [];
|
||||
|
||||
while ((match = MARKDOWN_CODE_BLOCK_REGEX.exec(text)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const language = match[1] || undefined;
|
||||
const code = match[2];
|
||||
|
||||
matches.push({
|
||||
fullMatch,
|
||||
block: { language, code: code.trim() },
|
||||
});
|
||||
}
|
||||
|
||||
// Remove code blocks in reverse order
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const { fullMatch, block } = matches[i];
|
||||
codeBlocks.unshift(block);
|
||||
textWithoutCode = textWithoutCode.replace(fullMatch, "");
|
||||
}
|
||||
|
||||
return { codeBlocks, textWithoutCode };
|
||||
}
|
||||
|
||||
export interface CodeBlock {
|
||||
language?: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a code block to a LINE Flex Message bubble
|
||||
*/
|
||||
export function convertCodeBlockToFlexBubble(block: CodeBlock): FlexBubble {
|
||||
const titleText = block.language ? `Code (${block.language})` : "Code";
|
||||
|
||||
// Truncate very long code to fit LINE's limits
|
||||
const displayCode = block.code.length > 2000 ? block.code.slice(0, 2000) + "\n..." : block.code;
|
||||
|
||||
return {
|
||||
type: "bubble",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: titleText,
|
||||
weight: "bold",
|
||||
size: "sm",
|
||||
color: "#666666",
|
||||
} as FlexText,
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: displayCode,
|
||||
size: "xs",
|
||||
color: "#333333",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
],
|
||||
backgroundColor: "#F5F5F5",
|
||||
paddingAll: "md",
|
||||
cornerRadius: "md",
|
||||
margin: "sm",
|
||||
} as FlexBox,
|
||||
],
|
||||
paddingAll: "lg",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract markdown links from text
|
||||
*/
|
||||
export function extractLinks(text: string): { links: MarkdownLink[]; textWithLinks: string } {
|
||||
const links: MarkdownLink[] = [];
|
||||
|
||||
// Reset regex state
|
||||
MARKDOWN_LINK_REGEX.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = MARKDOWN_LINK_REGEX.exec(text)) !== null) {
|
||||
links.push({
|
||||
text: match[1],
|
||||
url: match[2],
|
||||
});
|
||||
}
|
||||
|
||||
// Replace markdown links with just the text (for plain text output)
|
||||
const textWithLinks = text.replace(MARKDOWN_LINK_REGEX, "$1");
|
||||
|
||||
return { links, textWithLinks };
|
||||
}
|
||||
|
||||
export interface MarkdownLink {
|
||||
text: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Flex Message with tappable link buttons
|
||||
*/
|
||||
export function convertLinksToFlexBubble(links: MarkdownLink[]): FlexBubble {
|
||||
const buttons: FlexComponent[] = links.slice(0, 4).map((link, index) => ({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "uri",
|
||||
label: link.text.slice(0, 20), // LINE button label limit
|
||||
uri: link.url,
|
||||
},
|
||||
style: index === 0 ? "primary" : "secondary",
|
||||
margin: index > 0 ? "sm" : undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
type: "bubble",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Links",
|
||||
weight: "bold",
|
||||
size: "md",
|
||||
color: "#333333",
|
||||
} as FlexText,
|
||||
],
|
||||
paddingAll: "lg",
|
||||
paddingBottom: "sm",
|
||||
},
|
||||
footer: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: buttons,
|
||||
paddingAll: "md",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markdown formatting from text (for plain text output)
|
||||
* Handles: bold, italic, strikethrough, headers, blockquotes, horizontal rules
|
||||
*/
|
||||
export function stripMarkdown(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// Remove bold: **text** or __text__
|
||||
result = result.replace(/\*\*(.+?)\*\*/g, "$1");
|
||||
result = result.replace(/__(.+?)__/g, "$1");
|
||||
|
||||
// Remove italic: *text* or _text_ (but not already processed)
|
||||
result = result.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "$1");
|
||||
result = result.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, "$1");
|
||||
|
||||
// Remove strikethrough: ~~text~~
|
||||
result = result.replace(/~~(.+?)~~/g, "$1");
|
||||
|
||||
// Remove headers: # Title, ## Title, etc.
|
||||
result = result.replace(/^#{1,6}\s+(.+)$/gm, "$1");
|
||||
|
||||
// Remove blockquotes: > text
|
||||
result = result.replace(/^>\s?(.*)$/gm, "$1");
|
||||
|
||||
// Remove horizontal rules: ---, ***, ___
|
||||
result = result.replace(/^[-*_]{3,}$/gm, "");
|
||||
|
||||
// Remove inline code: `code`
|
||||
result = result.replace(/`([^`]+)`/g, "$1");
|
||||
|
||||
// Clean up extra whitespace
|
||||
result = result.replace(/\n{3,}/g, "\n\n");
|
||||
result = result.trim();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function: Process text for LINE output
|
||||
* - Extracts tables → Flex Messages
|
||||
* - Extracts code blocks → Flex Messages
|
||||
* - Strips remaining markdown
|
||||
* - Returns processed text + Flex Messages
|
||||
*/
|
||||
export function processLineMessage(text: string): ProcessedLineMessage {
|
||||
const flexMessages: FlexMessage[] = [];
|
||||
let processedText = text;
|
||||
|
||||
// 1. Extract and convert tables
|
||||
const { tables, textWithoutTables } = extractMarkdownTables(processedText);
|
||||
processedText = textWithoutTables;
|
||||
|
||||
for (const table of tables) {
|
||||
const bubble = convertTableToFlexBubble(table);
|
||||
flexMessages.push(toFlexMessage("Table", bubble));
|
||||
}
|
||||
|
||||
// 2. Extract and convert code blocks
|
||||
const { codeBlocks, textWithoutCode } = extractCodeBlocks(processedText);
|
||||
processedText = textWithoutCode;
|
||||
|
||||
for (const block of codeBlocks) {
|
||||
const bubble = convertCodeBlockToFlexBubble(block);
|
||||
flexMessages.push(toFlexMessage("Code", bubble));
|
||||
}
|
||||
|
||||
// 3. Handle links - convert [text](url) to plain text for display
|
||||
// (We could also create link buttons, but that can get noisy)
|
||||
const { textWithLinks } = extractLinks(processedText);
|
||||
processedText = textWithLinks;
|
||||
|
||||
// 4. Strip remaining markdown formatting
|
||||
processedText = stripMarkdown(processedText);
|
||||
|
||||
return {
|
||||
text: processedText,
|
||||
flexMessages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains markdown that needs conversion
|
||||
*/
|
||||
export function hasMarkdownToConvert(text: string): boolean {
|
||||
// Check for tables
|
||||
MARKDOWN_TABLE_REGEX.lastIndex = 0;
|
||||
if (MARKDOWN_TABLE_REGEX.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for code blocks
|
||||
MARKDOWN_CODE_BLOCK_REGEX.lastIndex = 0;
|
||||
if (MARKDOWN_CODE_BLOCK_REGEX.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for other markdown patterns
|
||||
if (/\*\*[^*]+\*\*/.test(text)) {
|
||||
return true;
|
||||
} // bold
|
||||
if (/~~[^~]+~~/.test(text)) {
|
||||
return true;
|
||||
} // strikethrough
|
||||
if (/^#{1,6}\s+/m.test(text)) {
|
||||
return true;
|
||||
} // headers
|
||||
if (/^>\s+/m.test(text)) {
|
||||
return true;
|
||||
} // blockquotes
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { monitorLineProvider } from "./monitor.js";
|
||||
|
||||
describe("monitorLineProvider fail-closed webhook auth", () => {
|
||||
it("rejects startup when channel secret is missing", async () => {
|
||||
await expect(
|
||||
monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: " ",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
}),
|
||||
).rejects.toThrow("LINE webhook mode requires a non-empty channel secret.");
|
||||
});
|
||||
|
||||
it("rejects startup when channel access token is missing", async () => {
|
||||
await expect(
|
||||
monitorLineProvider({
|
||||
channelAccessToken: " ",
|
||||
channelSecret: "secret",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
}),
|
||||
).rejects.toThrow("LINE webhook mode requires a non-empty channel access token.");
|
||||
});
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
const { createLineBotMock, registerPluginHttpRouteMock, unregisterHttpMock } = vi.hoisted(() => ({
|
||||
createLineBotMock: vi.fn(() => ({
|
||||
account: { accountId: "default" },
|
||||
handleWebhook: vi.fn(),
|
||||
})),
|
||||
registerPluginHttpRouteMock: vi.fn(),
|
||||
unregisterHttpMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./bot.js", () => ({
|
||||
createLineBot: createLineBotMock,
|
||||
}));
|
||||
|
||||
vi.mock("../auto-reply/chunk.js", () => ({
|
||||
chunkMarkdownText: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/reply-prefix.js", () => ({
|
||||
createReplyPrefixOptions: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../globals.js", () => ({
|
||||
danger: (value: unknown) => String(value),
|
||||
logVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/http-path.js", () => ({
|
||||
normalizePluginHttpPath: (_path: string | undefined, fallback: string) => fallback,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/http-registry.js", () => ({
|
||||
registerPluginHttpRoute: registerPluginHttpRouteMock,
|
||||
}));
|
||||
|
||||
vi.mock("./webhook-node.js", () => ({
|
||||
createLineNodeWebhookHandler: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock("./auto-reply-delivery.js", () => ({
|
||||
deliverLineAutoReply: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./markdown-to-line.js", () => ({
|
||||
processLineMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./reply-chunks.js", () => ({
|
||||
sendLineReplyChunks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
createFlexMessage: vi.fn(),
|
||||
createImageMessage: vi.fn(),
|
||||
createLocationMessage: vi.fn(),
|
||||
createQuickReplyItems: vi.fn(),
|
||||
createTextMessageWithQuickReplies: vi.fn(),
|
||||
getUserDisplayName: vi.fn(),
|
||||
pushMessageLine: vi.fn(),
|
||||
pushMessagesLine: vi.fn(),
|
||||
pushTextMessageWithQuickReplies: vi.fn(),
|
||||
replyMessageLine: vi.fn(),
|
||||
showLoadingAnimation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./template-messages.js", () => ({
|
||||
buildTemplateMessageFromPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("monitorLineProvider lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
createLineBotMock.mockClear();
|
||||
unregisterHttpMock.mockClear();
|
||||
registerPluginHttpRouteMock.mockClear().mockReturnValue(unregisterHttpMock);
|
||||
});
|
||||
|
||||
it("waits for abort before resolving", async () => {
|
||||
const { monitorLineProvider } = await import("./monitor.js");
|
||||
const abort = new AbortController();
|
||||
let resolved = false;
|
||||
|
||||
const task = monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
abortSignal: abort.signal,
|
||||
}).then((monitor) => {
|
||||
resolved = true;
|
||||
return monitor;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1));
|
||||
expect(registerPluginHttpRouteMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auth: "plugin" }),
|
||||
);
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops immediately when signal is already aborted", async () => {
|
||||
const { monitorLineProvider } = await import("./monitor.js");
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
|
||||
await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns immediately without abort signal and stop is idempotent", async () => {
|
||||
const { monitorLineProvider } = await import("./monitor.js");
|
||||
|
||||
const monitor = await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
expect(unregisterHttpMock).not.toHaveBeenCalled();
|
||||
monitor.stop();
|
||||
monitor.stop();
|
||||
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMockIncomingRequest } from "../../test/helpers/mock-incoming-request.js";
|
||||
import { readLineWebhookRequestBody } from "./webhook-node.js";
|
||||
|
||||
describe("readLineWebhookRequestBody", () => {
|
||||
it("reads body within limit", async () => {
|
||||
const req = createMockIncomingRequest(['{"events":[{"type":"message"}]}']);
|
||||
const body = await readLineWebhookRequestBody(req, 1024);
|
||||
expect(body).toContain('"events"');
|
||||
});
|
||||
|
||||
it("rejects oversized body", async () => {
|
||||
const req = createMockIncomingRequest(["x".repeat(2048)]);
|
||||
await expect(readLineWebhookRequestBody(req, 128)).rejects.toThrow("PayloadTooLarge");
|
||||
});
|
||||
});
|
||||
@@ -1,335 +0,0 @@
|
||||
import type { WebhookRequestBody } from "@line/bot-sdk";
|
||||
import { chunkMarkdownText } from "../auto-reply/chunk.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { danger, logVerbose } from "../globals.js";
|
||||
import { waitForAbortSignal } from "../infra/abort-signal.js";
|
||||
import { createChannelReplyPipeline } from "../plugin-sdk/channel-reply-pipeline.js";
|
||||
import { normalizePluginHttpPath } from "../plugins/http-path.js";
|
||||
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
|
||||
import { createLineBot } from "./bot.js";
|
||||
import { processLineMessage } from "./markdown-to-line.js";
|
||||
import { sendLineReplyChunks } from "./reply-chunks.js";
|
||||
import {
|
||||
replyMessageLine,
|
||||
showLoadingAnimation,
|
||||
getUserDisplayName,
|
||||
createQuickReplyItems,
|
||||
createTextMessageWithQuickReplies,
|
||||
pushTextMessageWithQuickReplies,
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
createFlexMessage,
|
||||
createImageMessage,
|
||||
createLocationMessage,
|
||||
} from "./send.js";
|
||||
import { buildTemplateMessageFromPayload } from "./template-messages.js";
|
||||
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
|
||||
import { createLineNodeWebhookHandler } from "./webhook-node.js";
|
||||
|
||||
export interface MonitorLineProviderOptions {
|
||||
channelAccessToken: string;
|
||||
channelSecret: string;
|
||||
accountId?: string;
|
||||
config: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
webhookUrl?: string;
|
||||
webhookPath?: string;
|
||||
}
|
||||
|
||||
export interface LineProviderMonitor {
|
||||
account: ResolvedLineAccount;
|
||||
handleWebhook: (body: WebhookRequestBody) => Promise<void>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
// Track runtime state in memory (simplified version)
|
||||
const runtimeState = new Map<
|
||||
string,
|
||||
{
|
||||
running: boolean;
|
||||
lastStartAt: number | null;
|
||||
lastStopAt: number | null;
|
||||
lastError: string | null;
|
||||
lastInboundAt?: number | null;
|
||||
lastOutboundAt?: number | null;
|
||||
}
|
||||
>();
|
||||
|
||||
function recordChannelRuntimeState(params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
state: Partial<{
|
||||
running: boolean;
|
||||
lastStartAt: number | null;
|
||||
lastStopAt: number | null;
|
||||
lastError: string | null;
|
||||
lastInboundAt: number | null;
|
||||
lastOutboundAt: number | null;
|
||||
}>;
|
||||
}): void {
|
||||
const key = `${params.channel}:${params.accountId}`;
|
||||
const existing = runtimeState.get(key) ?? {
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
runtimeState.set(key, { ...existing, ...params.state });
|
||||
}
|
||||
|
||||
export function getLineRuntimeState(accountId: string) {
|
||||
return runtimeState.get(`line:${accountId}`);
|
||||
}
|
||||
|
||||
function startLineLoadingKeepalive(params: {
|
||||
userId: string;
|
||||
accountId?: string;
|
||||
intervalMs?: number;
|
||||
loadingSeconds?: number;
|
||||
}): () => void {
|
||||
const intervalMs = params.intervalMs ?? 18_000;
|
||||
const loadingSeconds = params.loadingSeconds ?? 20;
|
||||
let stopped = false;
|
||||
|
||||
const trigger = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
void showLoadingAnimation(params.userId, {
|
||||
accountId: params.accountId,
|
||||
loadingSeconds,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
trigger();
|
||||
const timer = setInterval(trigger, intervalMs);
|
||||
|
||||
return () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
|
||||
export async function monitorLineProvider(
|
||||
opts: MonitorLineProviderOptions,
|
||||
): Promise<LineProviderMonitor> {
|
||||
const {
|
||||
channelAccessToken,
|
||||
channelSecret,
|
||||
accountId,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal,
|
||||
webhookPath,
|
||||
} = opts;
|
||||
const resolvedAccountId = accountId ?? "default";
|
||||
const token = channelAccessToken.trim();
|
||||
const secret = channelSecret.trim();
|
||||
|
||||
if (!token) {
|
||||
throw new Error("LINE webhook mode requires a non-empty channel access token.");
|
||||
}
|
||||
if (!secret) {
|
||||
throw new Error("LINE webhook mode requires a non-empty channel secret.");
|
||||
}
|
||||
|
||||
// Record starting state
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
// Create the bot
|
||||
const bot = createLineBot({
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
accountId,
|
||||
runtime,
|
||||
config,
|
||||
onMessage: async (ctx) => {
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ctxPayload, replyToken, route } = ctx;
|
||||
|
||||
// Record inbound activity
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
lastInboundAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup);
|
||||
|
||||
// Fetch display name for logging (non-blocking)
|
||||
const displayNamePromise = ctx.userId
|
||||
? getUserDisplayName(ctx.userId, { accountId: ctx.accountId })
|
||||
: Promise.resolve(ctxPayload.From);
|
||||
|
||||
// Show loading animation while processing (non-blocking, best-effort)
|
||||
const stopLoading = shouldShowLoading
|
||||
? startLineLoadingKeepalive({ userId: ctx.userId!, accountId: ctx.accountId })
|
||||
: null;
|
||||
|
||||
const displayName = await displayNamePromise;
|
||||
logVerbose(`line: received message from ${displayName} (${ctxPayload.From})`);
|
||||
|
||||
// Dispatch to auto-reply system for AI response
|
||||
try {
|
||||
const textLimit = 5000; // LINE max message length
|
||||
let replyTokenUsed = false; // Track if we've used the one-time reply token
|
||||
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
|
||||
cfg: config,
|
||||
agentId: route.agentId,
|
||||
channel: "line",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
...replyPipeline,
|
||||
deliver: async (payload, _info) => {
|
||||
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||
|
||||
// Show loading animation before each delivery (non-blocking)
|
||||
if (ctx.userId && !ctx.isGroup) {
|
||||
void showLoadingAnimation(ctx.userId, { accountId: ctx.accountId }).catch(() => {});
|
||||
}
|
||||
|
||||
const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({
|
||||
payload,
|
||||
lineData,
|
||||
to: ctxPayload.From,
|
||||
replyToken,
|
||||
replyTokenUsed,
|
||||
accountId: ctx.accountId,
|
||||
textLimit,
|
||||
deps: {
|
||||
buildTemplateMessageFromPayload,
|
||||
processLineMessage,
|
||||
chunkMarkdownText,
|
||||
sendLineReplyChunks,
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createQuickReplyItems,
|
||||
createTextMessageWithQuickReplies,
|
||||
pushMessagesLine,
|
||||
createFlexMessage,
|
||||
createImageMessage,
|
||||
createLocationMessage,
|
||||
onReplyError: (replyErr) => {
|
||||
logVerbose(
|
||||
`line: reply token failed, falling back to push: ${String(replyErr)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
replyTokenUsed = nextReplyTokenUsed;
|
||||
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
lastOutboundAt: Date.now(),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
|
||||
if (!queuedFinal) {
|
||||
logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`line: auto-reply failed: ${String(err)}`));
|
||||
|
||||
// Send error message to user
|
||||
if (replyToken) {
|
||||
try {
|
||||
await replyMessageLine(
|
||||
replyToken,
|
||||
[{ type: "text", text: "Sorry, I encountered an error processing your message." }],
|
||||
{ accountId: ctx.accountId },
|
||||
);
|
||||
} catch (replyErr) {
|
||||
runtime.error?.(danger(`line: error reply failed: ${String(replyErr)}`));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
stopLoading?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Register HTTP webhook handler
|
||||
const normalizedPath = normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook";
|
||||
const unregisterHttp = registerPluginHttpRoute({
|
||||
path: normalizedPath,
|
||||
auth: "plugin",
|
||||
replaceExisting: true,
|
||||
pluginId: "line",
|
||||
accountId: resolvedAccountId,
|
||||
log: (msg) => logVerbose(msg),
|
||||
handler: createLineNodeWebhookHandler({ channelSecret: secret, bot, runtime }),
|
||||
});
|
||||
|
||||
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
|
||||
|
||||
// Handle abort signal
|
||||
let stopped = false;
|
||||
const stopHandler = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
|
||||
unregisterHttp();
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
stopHandler();
|
||||
} else if (abortSignal) {
|
||||
abortSignal.addEventListener("abort", stopHandler, { once: true });
|
||||
await waitForAbortSignal(abortSignal);
|
||||
}
|
||||
|
||||
return {
|
||||
account: bot.account,
|
||||
handleWebhook: bot.handleWebhook,
|
||||
stop: () => {
|
||||
stopHandler();
|
||||
abortSignal?.removeEventListener("abort", stopHandler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
const { getBotInfoMock, MessagingApiClientMock } = vi.hoisted(() => {
|
||||
const getBotInfoMock = vi.fn();
|
||||
const MessagingApiClientMock = vi.fn(function () {
|
||||
return { getBotInfo: getBotInfoMock };
|
||||
});
|
||||
return { getBotInfoMock, MessagingApiClientMock };
|
||||
});
|
||||
|
||||
vi.mock("@line/bot-sdk", () => ({
|
||||
messagingApi: { MessagingApiClient: MessagingApiClientMock },
|
||||
}));
|
||||
|
||||
let probeLineBot: typeof import("./probe.js").probeLineBot;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
getBotInfoMock.mockClear();
|
||||
});
|
||||
|
||||
describe("probeLineBot", () => {
|
||||
beforeAll(async () => {
|
||||
({ probeLineBot } = await import("./probe.js"));
|
||||
});
|
||||
|
||||
it("returns timeout when bot info stalls", async () => {
|
||||
vi.useFakeTimers();
|
||||
getBotInfoMock.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const probePromise = probeLineBot("token", 10);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
const result = await probePromise;
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("timeout");
|
||||
});
|
||||
|
||||
it("returns bot info when available", async () => {
|
||||
getBotInfoMock.mockResolvedValue({
|
||||
displayName: "OpenClaw",
|
||||
userId: "U123",
|
||||
basicId: "@openclaw",
|
||||
pictureUrl: "https://example.com/bot.png",
|
||||
});
|
||||
|
||||
const result = await probeLineBot("token", 50);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.bot?.userId).toBe("U123");
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { messagingApi } from "@line/bot-sdk";
|
||||
import { withTimeout } from "../utils/with-timeout.js";
|
||||
import type { LineProbeResult } from "./types.js";
|
||||
|
||||
export async function probeLineBot(
|
||||
channelAccessToken: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<LineProbeResult> {
|
||||
if (!channelAccessToken?.trim()) {
|
||||
return { ok: false, error: "Channel access token not configured" };
|
||||
}
|
||||
|
||||
const client = new messagingApi.MessagingApiClient({
|
||||
channelAccessToken: channelAccessToken.trim(),
|
||||
});
|
||||
|
||||
try {
|
||||
const profile = await withTimeout(client.getBotInfo(), timeoutMs);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
bot: {
|
||||
displayName: profile.displayName,
|
||||
userId: profile.userId,
|
||||
basicId: profile.basicId,
|
||||
pictureUrl: profile.pictureUrl,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { sendLineReplyChunks } from "./reply-chunks.js";
|
||||
|
||||
function createReplyChunksHarness() {
|
||||
const replyMessageLine = vi.fn(async () => ({}));
|
||||
const pushMessageLine = vi.fn(async () => ({}));
|
||||
const pushTextMessageWithQuickReplies = vi.fn(async () => ({}));
|
||||
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
|
||||
type: "text" as const,
|
||||
text,
|
||||
}));
|
||||
|
||||
return {
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sendLineReplyChunks", () => {
|
||||
it("uses reply token for all chunks when possible", async () => {
|
||||
const {
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
} = createReplyChunksHarness();
|
||||
|
||||
const result = await sendLineReplyChunks({
|
||||
to: "line:group:1",
|
||||
chunks: ["one", "two", "three"],
|
||||
quickReplies: ["A", "B"],
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
accountId: "default",
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(replyMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(createTextMessageWithQuickReplies).toHaveBeenCalledWith("three", ["A", "B"]);
|
||||
expect(replyMessageLine).toHaveBeenCalledWith(
|
||||
"token",
|
||||
[
|
||||
{ type: "text", text: "one" },
|
||||
{ type: "text", text: "two" },
|
||||
{ type: "text", text: "three" },
|
||||
],
|
||||
{ accountId: "default" },
|
||||
);
|
||||
expect(pushMessageLine).not.toHaveBeenCalled();
|
||||
expect(pushTextMessageWithQuickReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches quick replies to a single reply chunk", async () => {
|
||||
const { replyMessageLine, pushMessageLine, pushTextMessageWithQuickReplies } =
|
||||
createReplyChunksHarness();
|
||||
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
|
||||
type: "text" as const,
|
||||
text,
|
||||
quickReply: { items: [] },
|
||||
}));
|
||||
|
||||
const result = await sendLineReplyChunks({
|
||||
to: "line:user:1",
|
||||
chunks: ["only"],
|
||||
quickReplies: ["A"],
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(createTextMessageWithQuickReplies).toHaveBeenCalledWith("only", ["A"]);
|
||||
expect(replyMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(pushMessageLine).not.toHaveBeenCalled();
|
||||
expect(pushTextMessageWithQuickReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replies with up to five chunks before pushing the rest", async () => {
|
||||
const {
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
} = createReplyChunksHarness();
|
||||
|
||||
const chunks = ["1", "2", "3", "4", "5", "6", "7"];
|
||||
const result = await sendLineReplyChunks({
|
||||
to: "line:group:1",
|
||||
chunks,
|
||||
quickReplies: ["A"],
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(replyMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(replyMessageLine).toHaveBeenCalledWith(
|
||||
"token",
|
||||
[
|
||||
{ type: "text", text: "1" },
|
||||
{ type: "text", text: "2" },
|
||||
{ type: "text", text: "3" },
|
||||
{ type: "text", text: "4" },
|
||||
{ type: "text", text: "5" },
|
||||
],
|
||||
{ accountId: undefined },
|
||||
);
|
||||
expect(pushMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(pushMessageLine).toHaveBeenCalledWith("line:group:1", "6", { accountId: undefined });
|
||||
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledTimes(1);
|
||||
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledWith("line:group:1", "7", ["A"], {
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(createTextMessageWithQuickReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to push flow when replying fails", async () => {
|
||||
const {
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
} = createReplyChunksHarness();
|
||||
const onReplyError = vi.fn();
|
||||
replyMessageLine.mockRejectedValueOnce(new Error("reply failed"));
|
||||
|
||||
const result = await sendLineReplyChunks({
|
||||
to: "line:group:1",
|
||||
chunks: ["1", "2", "3"],
|
||||
quickReplies: ["A"],
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
accountId: "default",
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
onReplyError,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(onReplyError).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(pushMessageLine).toHaveBeenNthCalledWith(1, "line:group:1", "1", {
|
||||
accountId: "default",
|
||||
});
|
||||
expect(pushMessageLine).toHaveBeenNthCalledWith(2, "line:group:1", "2", {
|
||||
accountId: "default",
|
||||
});
|
||||
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledWith("line:group:1", "3", ["A"], {
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
|
||||
export type LineReplyMessage = messagingApi.TextMessage;
|
||||
|
||||
export type SendLineReplyChunksParams = {
|
||||
to: string;
|
||||
chunks: string[];
|
||||
quickReplies?: string[];
|
||||
replyToken?: string | null;
|
||||
replyTokenUsed?: boolean;
|
||||
accountId?: string;
|
||||
replyMessageLine: (
|
||||
replyToken: string,
|
||||
messages: messagingApi.Message[],
|
||||
opts?: { accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
pushMessageLine: (to: string, text: string, opts?: { accountId?: string }) => Promise<unknown>;
|
||||
pushTextMessageWithQuickReplies: (
|
||||
to: string,
|
||||
text: string,
|
||||
quickReplies: string[],
|
||||
opts?: { accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
createTextMessageWithQuickReplies: (text: string, quickReplies: string[]) => LineReplyMessage;
|
||||
onReplyError?: (err: unknown) => void;
|
||||
};
|
||||
|
||||
export async function sendLineReplyChunks(
|
||||
params: SendLineReplyChunksParams,
|
||||
): Promise<{ replyTokenUsed: boolean }> {
|
||||
const hasQuickReplies = Boolean(params.quickReplies?.length);
|
||||
let replyTokenUsed = Boolean(params.replyTokenUsed);
|
||||
|
||||
if (params.chunks.length === 0) {
|
||||
return { replyTokenUsed };
|
||||
}
|
||||
|
||||
if (params.replyToken && !replyTokenUsed) {
|
||||
try {
|
||||
const replyBatch = params.chunks.slice(0, 5);
|
||||
const remaining = params.chunks.slice(replyBatch.length);
|
||||
|
||||
const replyMessages: LineReplyMessage[] = replyBatch.map((chunk) => ({
|
||||
type: "text",
|
||||
text: chunk,
|
||||
}));
|
||||
|
||||
if (hasQuickReplies && remaining.length === 0 && replyMessages.length > 0) {
|
||||
const lastIndex = replyMessages.length - 1;
|
||||
replyMessages[lastIndex] = params.createTextMessageWithQuickReplies(
|
||||
replyBatch[lastIndex],
|
||||
params.quickReplies!,
|
||||
);
|
||||
}
|
||||
|
||||
await params.replyMessageLine(params.replyToken, replyMessages, {
|
||||
accountId: params.accountId,
|
||||
});
|
||||
replyTokenUsed = true;
|
||||
|
||||
for (let i = 0; i < remaining.length; i += 1) {
|
||||
const isLastChunk = i === remaining.length - 1;
|
||||
if (isLastChunk && hasQuickReplies) {
|
||||
await params.pushTextMessageWithQuickReplies(
|
||||
params.to,
|
||||
remaining[i],
|
||||
params.quickReplies!,
|
||||
{ accountId: params.accountId },
|
||||
);
|
||||
} else {
|
||||
await params.pushMessageLine(params.to, remaining[i], {
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { replyTokenUsed };
|
||||
} catch (err) {
|
||||
params.onReplyError?.(err);
|
||||
replyTokenUsed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < params.chunks.length; i += 1) {
|
||||
const isLastChunk = i === params.chunks.length - 1;
|
||||
if (isLastChunk && hasQuickReplies) {
|
||||
await params.pushTextMessageWithQuickReplies(
|
||||
params.to,
|
||||
params.chunks[i],
|
||||
params.quickReplies!,
|
||||
{ accountId: params.accountId },
|
||||
);
|
||||
} else {
|
||||
await params.pushMessageLine(params.to, params.chunks[i], {
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { replyTokenUsed };
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createGridLayout,
|
||||
messageAction,
|
||||
uriAction,
|
||||
postbackAction,
|
||||
datetimePickerAction,
|
||||
createDefaultMenuConfig,
|
||||
} from "./rich-menu.js";
|
||||
|
||||
describe("messageAction", () => {
|
||||
it("creates message actions with explicit or default text", () => {
|
||||
const cases = [
|
||||
{ name: "explicit text", label: "Help", text: "/help", expectedText: "/help" },
|
||||
{ name: "defaults to label", label: "Click", text: undefined, expectedText: "Click" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const action = testCase.text
|
||||
? messageAction(testCase.label, testCase.text)
|
||||
: messageAction(testCase.label);
|
||||
expect(action.type, testCase.name).toBe("message");
|
||||
expect(action.label, testCase.name).toBe(testCase.label);
|
||||
expect((action as { text: string }).text, testCase.name).toBe(testCase.expectedText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("uriAction", () => {
|
||||
it("creates a URI action", () => {
|
||||
const action = uriAction("Open", "https://example.com");
|
||||
|
||||
expect(action.type).toBe("uri");
|
||||
expect(action.label).toBe("Open");
|
||||
expect((action as { uri: string }).uri).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("action label truncation", () => {
|
||||
it.each([
|
||||
{
|
||||
createAction: () => messageAction("This is a very long label text"),
|
||||
expectedLabel: "This is a very long ",
|
||||
},
|
||||
{
|
||||
createAction: () => uriAction("Click here to visit our website", "https://example.com"),
|
||||
expectedLabel: "Click here to visit ",
|
||||
},
|
||||
])("truncates labels to 20 characters", ({ createAction, expectedLabel }) => {
|
||||
const action = createAction();
|
||||
expect(action.label).toBe(expectedLabel);
|
||||
expect((action.label ?? "").length).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("postbackAction", () => {
|
||||
it("creates a postback action", () => {
|
||||
const action = postbackAction("Select", "action=select&item=1", "Selected item 1");
|
||||
|
||||
expect(action.type).toBe("postback");
|
||||
expect(action.label).toBe("Select");
|
||||
expect((action as { data: string }).data).toBe("action=select&item=1");
|
||||
expect((action as { displayText: string }).displayText).toBe("Selected item 1");
|
||||
});
|
||||
|
||||
it("applies postback payload truncation and displayText behavior", () => {
|
||||
const truncatedData = postbackAction("Test", "x".repeat(400));
|
||||
expect((truncatedData as { data: string }).data.length).toBe(300);
|
||||
|
||||
const truncatedDisplay = postbackAction("Test", "data", "y".repeat(400));
|
||||
expect((truncatedDisplay as { displayText: string }).displayText?.length).toBe(300);
|
||||
|
||||
const noDisplayText = postbackAction("Test", "data");
|
||||
expect((noDisplayText as { displayText?: string }).displayText).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("datetimePickerAction", () => {
|
||||
it("creates picker actions for all supported modes", () => {
|
||||
const cases = [
|
||||
{ label: "Pick date", data: "date_picked", mode: "date" as const },
|
||||
{ label: "Pick time", data: "time_picked", mode: "time" as const },
|
||||
{ label: "Pick datetime", data: "datetime_picked", mode: "datetime" as const },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const action = datetimePickerAction(testCase.label, testCase.data, testCase.mode);
|
||||
expect(action.type).toBe("datetimepicker");
|
||||
expect(action.label).toBe(testCase.label);
|
||||
expect((action as { mode: string }).mode).toBe(testCase.mode);
|
||||
expect((action as { data: string }).data).toBe(testCase.data);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes initial/min/max when provided", () => {
|
||||
const action = datetimePickerAction("Pick", "data", "date", {
|
||||
initial: "2024-06-15",
|
||||
min: "2024-01-01",
|
||||
max: "2024-12-31",
|
||||
});
|
||||
|
||||
expect((action as { initial: string }).initial).toBe("2024-06-15");
|
||||
expect((action as { min: string }).min).toBe("2024-01-01");
|
||||
expect((action as { max: string }).max).toBe("2024-12-31");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createGridLayout", () => {
|
||||
function createSixSimpleActions() {
|
||||
return [
|
||||
messageAction("A1"),
|
||||
messageAction("A2"),
|
||||
messageAction("A3"),
|
||||
messageAction("A4"),
|
||||
messageAction("A5"),
|
||||
messageAction("A6"),
|
||||
] as [
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
];
|
||||
}
|
||||
|
||||
it("computes expected 2x3 layout for supported menu heights", () => {
|
||||
const actions = createSixSimpleActions();
|
||||
const cases = [
|
||||
{ height: 1686, firstRowY: 0, secondRowY: 843, rowHeight: 843 },
|
||||
{ height: 843, firstRowY: 0, secondRowY: 421, rowHeight: 421 },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const areas = createGridLayout(testCase.height, actions);
|
||||
expect(areas.length).toBe(6);
|
||||
expect(areas[0]?.bounds.y).toBe(testCase.firstRowY);
|
||||
expect(areas[0]?.bounds.height).toBe(testCase.rowHeight);
|
||||
expect(areas[3]?.bounds.y).toBe(testCase.secondRowY);
|
||||
expect(areas[0]?.bounds.x).toBe(0);
|
||||
expect(areas[1]?.bounds.x).toBe(833);
|
||||
expect(areas[2]?.bounds.x).toBe(1666);
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns correct actions to areas", () => {
|
||||
const actions = [
|
||||
messageAction("Help", "/help"),
|
||||
messageAction("Status", "/status"),
|
||||
messageAction("Settings", "/settings"),
|
||||
messageAction("About", "/about"),
|
||||
messageAction("Feedback", "/feedback"),
|
||||
messageAction("Contact", "/contact"),
|
||||
] as [
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
];
|
||||
|
||||
const areas = createGridLayout(843, actions);
|
||||
|
||||
expect((areas[0].action as { text: string }).text).toBe("/help");
|
||||
expect((areas[1].action as { text: string }).text).toBe("/status");
|
||||
expect((areas[2].action as { text: string }).text).toBe("/settings");
|
||||
expect((areas[3].action as { text: string }).text).toBe("/about");
|
||||
expect((areas[4].action as { text: string }).text).toBe("/feedback");
|
||||
expect((areas[5].action as { text: string }).text).toBe("/contact");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDefaultMenuConfig", () => {
|
||||
it("creates a valid default menu configuration", () => {
|
||||
const config = createDefaultMenuConfig();
|
||||
|
||||
expect(config.size.width).toBe(2500);
|
||||
expect(config.size.height).toBe(843);
|
||||
expect(config.selected).toBe(false);
|
||||
expect(config.name).toBe("Default Menu");
|
||||
expect(config.chatBarText).toBe("Menu");
|
||||
expect(config.areas.length).toBe(6);
|
||||
});
|
||||
|
||||
it("has valid area bounds", () => {
|
||||
const config = createDefaultMenuConfig();
|
||||
|
||||
for (const area of config.areas) {
|
||||
expect(area.bounds.x).toBeGreaterThanOrEqual(0);
|
||||
expect(area.bounds.y).toBeGreaterThanOrEqual(0);
|
||||
expect(area.bounds.width).toBeGreaterThan(0);
|
||||
expect(area.bounds.height).toBeGreaterThan(0);
|
||||
expect(area.bounds.x + area.bounds.width).toBeLessThanOrEqual(2500);
|
||||
expect(area.bounds.y + area.bounds.height).toBeLessThanOrEqual(843);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses message actions with expected default commands", () => {
|
||||
const config = createDefaultMenuConfig();
|
||||
|
||||
for (const area of config.areas) {
|
||||
expect(area.action.type).toBe("message");
|
||||
}
|
||||
const commands = config.areas.map((a) => (a.action as { text: string }).text);
|
||||
expect(commands).toContain("/help");
|
||||
expect(commands).toContain("/status");
|
||||
expect(commands).toContain("/settings");
|
||||
});
|
||||
});
|
||||
@@ -1,393 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { messagingApi } from "@line/bot-sdk";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { resolveLineAccount } from "./accounts.js";
|
||||
import { datetimePickerAction, messageAction, postbackAction, uriAction } from "./actions.js";
|
||||
import { resolveLineChannelAccessToken } from "./channel-access-token.js";
|
||||
|
||||
type RichMenuRequest = messagingApi.RichMenuRequest;
|
||||
type RichMenuResponse = messagingApi.RichMenuResponse;
|
||||
type RichMenuArea = messagingApi.RichMenuArea;
|
||||
type Action = messagingApi.Action;
|
||||
const USER_BATCH_SIZE = 500;
|
||||
|
||||
export interface RichMenuSize {
|
||||
width: 2500;
|
||||
height: 1686 | 843;
|
||||
}
|
||||
|
||||
export interface RichMenuAreaRequest {
|
||||
bounds: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
action: Action;
|
||||
}
|
||||
|
||||
export interface CreateRichMenuParams {
|
||||
size: RichMenuSize;
|
||||
selected?: boolean;
|
||||
name: string;
|
||||
chatBarText: string;
|
||||
areas: RichMenuAreaRequest[];
|
||||
}
|
||||
|
||||
interface RichMenuOpts {
|
||||
channelAccessToken?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
function getClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiClient {
|
||||
const cfg = loadConfig();
|
||||
const account = resolveLineAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
|
||||
|
||||
return new messagingApi.MessagingApiClient({
|
||||
channelAccessToken: token,
|
||||
});
|
||||
}
|
||||
|
||||
function getBlobClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiBlobClient {
|
||||
const cfg = loadConfig();
|
||||
const account = resolveLineAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
|
||||
|
||||
return new messagingApi.MessagingApiBlobClient({
|
||||
channelAccessToken: token,
|
||||
});
|
||||
}
|
||||
|
||||
function chunkUserIds(userIds: string[]): string[][] {
|
||||
const batches: string[][] = [];
|
||||
for (let i = 0; i < userIds.length; i += USER_BATCH_SIZE) {
|
||||
batches.push(userIds.slice(i, i + USER_BATCH_SIZE));
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new rich menu
|
||||
* @returns The rich menu ID
|
||||
*/
|
||||
export async function createRichMenu(
|
||||
menu: CreateRichMenuParams,
|
||||
opts: RichMenuOpts = {},
|
||||
): Promise<string> {
|
||||
const client = getClient(opts);
|
||||
|
||||
const richMenuRequest: RichMenuRequest = {
|
||||
size: menu.size,
|
||||
selected: menu.selected ?? false,
|
||||
name: menu.name.slice(0, 300), // LINE limit
|
||||
chatBarText: menu.chatBarText.slice(0, 14), // LINE limit
|
||||
areas: menu.areas as RichMenuArea[],
|
||||
};
|
||||
|
||||
const response = await client.createRichMenu(richMenuRequest);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: created rich menu ${response.richMenuId}`);
|
||||
}
|
||||
|
||||
return response.richMenuId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image for a rich menu
|
||||
* Image requirements:
|
||||
* - Format: JPEG or PNG
|
||||
* - Size: Must match the rich menu size (2500x1686 or 2500x843)
|
||||
* - Max file size: 1MB
|
||||
*/
|
||||
export async function uploadRichMenuImage(
|
||||
richMenuId: string,
|
||||
imagePath: string,
|
||||
opts: RichMenuOpts = {},
|
||||
): Promise<void> {
|
||||
const blobClient = getBlobClient(opts);
|
||||
|
||||
const imageData = await readFile(imagePath);
|
||||
const contentType = imagePath.toLowerCase().endsWith(".png") ? "image/png" : "image/jpeg";
|
||||
|
||||
await blobClient.setRichMenuImage(richMenuId, new Blob([imageData], { type: contentType }));
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: uploaded image to rich menu ${richMenuId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default rich menu for all users
|
||||
*/
|
||||
export async function setDefaultRichMenu(
|
||||
richMenuId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
await client.setDefaultRichMenu(richMenuId);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: set default rich menu to ${richMenuId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the default rich menu
|
||||
*/
|
||||
export async function cancelDefaultRichMenu(opts: RichMenuOpts = {}): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
await client.cancelDefaultRichMenu();
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: cancelled default rich menu`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default rich menu ID
|
||||
*/
|
||||
export async function getDefaultRichMenuId(opts: RichMenuOpts = {}): Promise<string | null> {
|
||||
const client = getClient(opts);
|
||||
|
||||
try {
|
||||
const response = await client.getDefaultRichMenuId();
|
||||
return response.richMenuId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a rich menu to a specific user
|
||||
*/
|
||||
export async function linkRichMenuToUser(
|
||||
userId: string,
|
||||
richMenuId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
await client.linkRichMenuIdToUser(userId, richMenuId);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: linked rich menu ${richMenuId} to user ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a rich menu to multiple users (up to 500)
|
||||
*/
|
||||
export async function linkRichMenuToUsers(
|
||||
userIds: string[],
|
||||
richMenuId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
for (const batch of chunkUserIds(userIds)) {
|
||||
await client.linkRichMenuIdToUsers({
|
||||
richMenuId,
|
||||
userIds: batch,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: linked rich menu ${richMenuId} to ${userIds.length} users`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a rich menu from a specific user
|
||||
*/
|
||||
export async function unlinkRichMenuFromUser(
|
||||
userId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
await client.unlinkRichMenuIdFromUser(userId);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: unlinked rich menu from user ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink rich menus from multiple users (up to 500)
|
||||
*/
|
||||
export async function unlinkRichMenuFromUsers(
|
||||
userIds: string[],
|
||||
opts: RichMenuOpts = {},
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
for (const batch of chunkUserIds(userIds)) {
|
||||
await client.unlinkRichMenuIdFromUsers({
|
||||
userIds: batch,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: unlinked rich menu from ${userIds.length} users`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rich menu linked to a specific user
|
||||
*/
|
||||
export async function getRichMenuIdOfUser(
|
||||
userId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
): Promise<string | null> {
|
||||
const client = getClient(opts);
|
||||
|
||||
try {
|
||||
const response = await client.getRichMenuIdOfUser(userId);
|
||||
return response.richMenuId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of all rich menus
|
||||
*/
|
||||
export async function getRichMenuList(opts: RichMenuOpts = {}): Promise<RichMenuResponse[]> {
|
||||
const client = getClient(opts);
|
||||
|
||||
const response = await client.getRichMenuList();
|
||||
return response.richmenus ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific rich menu by ID
|
||||
*/
|
||||
export async function getRichMenu(
|
||||
richMenuId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
): Promise<RichMenuResponse | null> {
|
||||
const client = getClient(opts);
|
||||
|
||||
try {
|
||||
return await client.getRichMenu(richMenuId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a rich menu
|
||||
*/
|
||||
export async function deleteRichMenu(richMenuId: string, opts: RichMenuOpts = {}): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
await client.deleteRichMenu(richMenuId);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: deleted rich menu ${richMenuId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a rich menu alias
|
||||
*/
|
||||
export async function createRichMenuAlias(
|
||||
richMenuId: string,
|
||||
aliasId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
await client.createRichMenuAlias({
|
||||
richMenuId,
|
||||
richMenuAliasId: aliasId,
|
||||
});
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: created alias ${aliasId} for rich menu ${richMenuId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a rich menu alias
|
||||
*/
|
||||
export async function deleteRichMenuAlias(aliasId: string, opts: RichMenuOpts = {}): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
await client.deleteRichMenuAlias(aliasId);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: deleted alias ${aliasId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Menu Template Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a standard 2x3 grid layout for rich menu areas
|
||||
* Returns 6 areas in a 2-row, 3-column layout
|
||||
*/
|
||||
export function createGridLayout(
|
||||
height: 1686 | 843,
|
||||
actions: [Action, Action, Action, Action, Action, Action],
|
||||
): RichMenuAreaRequest[] {
|
||||
const colWidth = Math.floor(2500 / 3);
|
||||
const rowHeight = Math.floor(height / 2);
|
||||
|
||||
return [
|
||||
// Top row
|
||||
{ bounds: { x: 0, y: 0, width: colWidth, height: rowHeight }, action: actions[0] },
|
||||
{ bounds: { x: colWidth, y: 0, width: colWidth, height: rowHeight }, action: actions[1] },
|
||||
{ bounds: { x: colWidth * 2, y: 0, width: colWidth, height: rowHeight }, action: actions[2] },
|
||||
// Bottom row
|
||||
{ bounds: { x: 0, y: rowHeight, width: colWidth, height: rowHeight }, action: actions[3] },
|
||||
{
|
||||
bounds: { x: colWidth, y: rowHeight, width: colWidth, height: rowHeight },
|
||||
action: actions[4],
|
||||
},
|
||||
{
|
||||
bounds: { x: colWidth * 2, y: rowHeight, width: colWidth, height: rowHeight },
|
||||
action: actions[5],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export { datetimePickerAction, messageAction, postbackAction, uriAction };
|
||||
|
||||
/**
|
||||
* Create a default help/status/settings menu
|
||||
* This is a convenience function to quickly set up a standard menu
|
||||
*/
|
||||
export function createDefaultMenuConfig(): CreateRichMenuParams {
|
||||
return {
|
||||
size: { width: 2500, height: 843 },
|
||||
selected: false,
|
||||
name: "Default Menu",
|
||||
chatBarText: "Menu",
|
||||
areas: createGridLayout(843, [
|
||||
messageAction("Help", "/help"),
|
||||
messageAction("Status", "/status"),
|
||||
messageAction("Settings", "/settings"),
|
||||
messageAction("About", "/about"),
|
||||
messageAction("Feedback", "/feedback"),
|
||||
messageAction("Contact", "/contact"),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type { RichMenuRequest, RichMenuResponse, RichMenuArea, Action };
|
||||
@@ -1,228 +0,0 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
pushMessageMock,
|
||||
replyMessageMock,
|
||||
showLoadingAnimationMock,
|
||||
getProfileMock,
|
||||
MessagingApiClientMock,
|
||||
loadConfigMock,
|
||||
resolveLineAccountMock,
|
||||
resolveLineChannelAccessTokenMock,
|
||||
recordChannelActivityMock,
|
||||
logVerboseMock,
|
||||
} = vi.hoisted(() => {
|
||||
const pushMessageMock = vi.fn();
|
||||
const replyMessageMock = vi.fn();
|
||||
const showLoadingAnimationMock = vi.fn();
|
||||
const getProfileMock = vi.fn();
|
||||
const MessagingApiClientMock = vi.fn(function () {
|
||||
return {
|
||||
pushMessage: pushMessageMock,
|
||||
replyMessage: replyMessageMock,
|
||||
showLoadingAnimation: showLoadingAnimationMock,
|
||||
getProfile: getProfileMock,
|
||||
};
|
||||
});
|
||||
const loadConfigMock = vi.fn(() => ({}));
|
||||
const resolveLineAccountMock = vi.fn(() => ({ accountId: "default" }));
|
||||
const resolveLineChannelAccessTokenMock = vi.fn(() => "line-token");
|
||||
const recordChannelActivityMock = vi.fn();
|
||||
const logVerboseMock = vi.fn();
|
||||
return {
|
||||
pushMessageMock,
|
||||
replyMessageMock,
|
||||
showLoadingAnimationMock,
|
||||
getProfileMock,
|
||||
MessagingApiClientMock,
|
||||
loadConfigMock,
|
||||
resolveLineAccountMock,
|
||||
resolveLineChannelAccessTokenMock,
|
||||
recordChannelActivityMock,
|
||||
logVerboseMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@line/bot-sdk", () => ({
|
||||
messagingApi: { MessagingApiClient: MessagingApiClientMock },
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: loadConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveLineAccount: resolveLineAccountMock,
|
||||
}));
|
||||
|
||||
vi.mock("./channel-access-token.js", () => ({
|
||||
resolveLineChannelAccessToken: resolveLineChannelAccessTokenMock,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/channel-activity.js", () => ({
|
||||
recordChannelActivity: recordChannelActivityMock,
|
||||
}));
|
||||
|
||||
vi.mock("../globals.js", () => ({
|
||||
logVerbose: logVerboseMock,
|
||||
}));
|
||||
|
||||
let sendModule: typeof import("./send.js");
|
||||
|
||||
describe("LINE send helpers", () => {
|
||||
beforeAll(async () => {
|
||||
sendModule = await import("./send.js");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
pushMessageMock.mockReset();
|
||||
replyMessageMock.mockReset();
|
||||
showLoadingAnimationMock.mockReset();
|
||||
getProfileMock.mockReset();
|
||||
MessagingApiClientMock.mockClear();
|
||||
loadConfigMock.mockReset();
|
||||
resolveLineAccountMock.mockReset();
|
||||
resolveLineChannelAccessTokenMock.mockReset();
|
||||
recordChannelActivityMock.mockReset();
|
||||
logVerboseMock.mockReset();
|
||||
|
||||
loadConfigMock.mockReturnValue({});
|
||||
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
|
||||
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
|
||||
pushMessageMock.mockResolvedValue({});
|
||||
replyMessageMock.mockResolvedValue({});
|
||||
showLoadingAnimationMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("limits quick reply items to 13", () => {
|
||||
const labels = Array.from({ length: 20 }, (_, index) => `Option ${index + 1}`);
|
||||
const quickReply = sendModule.createQuickReplyItems(labels);
|
||||
|
||||
expect(quickReply.items).toHaveLength(13);
|
||||
});
|
||||
|
||||
it("pushes images via normalized LINE target", async () => {
|
||||
const result = await sendModule.pushImageMessage(
|
||||
"line:user:U123",
|
||||
"https://example.com/original.jpg",
|
||||
undefined,
|
||||
{ verbose: true },
|
||||
);
|
||||
|
||||
expect(pushMessageMock).toHaveBeenCalledWith({
|
||||
to: "U123",
|
||||
messages: [
|
||||
{
|
||||
type: "image",
|
||||
originalContentUrl: "https://example.com/original.jpg",
|
||||
previewImageUrl: "https://example.com/original.jpg",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(recordChannelActivityMock).toHaveBeenCalledWith({
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
direction: "outbound",
|
||||
});
|
||||
expect(logVerboseMock).toHaveBeenCalledWith("line: pushed image to U123");
|
||||
expect(result).toEqual({ messageId: "push", chatId: "U123" });
|
||||
});
|
||||
|
||||
it("replies when reply token is provided", async () => {
|
||||
const result = await sendModule.sendMessageLine("line:group:C1", "Hello", {
|
||||
replyToken: "reply-token",
|
||||
mediaUrl: "https://example.com/media.jpg",
|
||||
verbose: true,
|
||||
});
|
||||
|
||||
expect(replyMessageMock).toHaveBeenCalledTimes(1);
|
||||
expect(pushMessageMock).not.toHaveBeenCalled();
|
||||
expect(replyMessageMock).toHaveBeenCalledWith({
|
||||
replyToken: "reply-token",
|
||||
messages: [
|
||||
{
|
||||
type: "image",
|
||||
originalContentUrl: "https://example.com/media.jpg",
|
||||
previewImageUrl: "https://example.com/media.jpg",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(logVerboseMock).toHaveBeenCalledWith("line: replied to C1");
|
||||
expect(result).toEqual({ messageId: "reply", chatId: "C1" });
|
||||
});
|
||||
|
||||
it("throws when push messages are empty", async () => {
|
||||
await expect(sendModule.pushMessagesLine("U123", [])).rejects.toThrow(
|
||||
"Message must be non-empty for LINE sends",
|
||||
);
|
||||
});
|
||||
|
||||
it("logs HTTP body when push fails", async () => {
|
||||
const err = new Error("LINE push failed") as Error & {
|
||||
status: number;
|
||||
statusText: string;
|
||||
body: string;
|
||||
};
|
||||
err.status = 400;
|
||||
err.statusText = "Bad Request";
|
||||
err.body = "invalid flex payload";
|
||||
pushMessageMock.mockRejectedValueOnce(err);
|
||||
|
||||
await expect(
|
||||
sendModule.pushMessagesLine("U999", [{ type: "text", text: "hello" }]),
|
||||
).rejects.toThrow("LINE push failed");
|
||||
|
||||
expect(logVerboseMock).toHaveBeenCalledWith(
|
||||
"line: push message failed (400 Bad Request): invalid flex payload",
|
||||
);
|
||||
});
|
||||
|
||||
it("caches profile results by default", async () => {
|
||||
getProfileMock.mockResolvedValue({
|
||||
displayName: "Peter",
|
||||
pictureUrl: "https://example.com/peter.jpg",
|
||||
});
|
||||
|
||||
const first = await sendModule.getUserProfile("U-cache");
|
||||
const second = await sendModule.getUserProfile("U-cache");
|
||||
|
||||
expect(first).toEqual({
|
||||
displayName: "Peter",
|
||||
pictureUrl: "https://example.com/peter.jpg",
|
||||
});
|
||||
expect(second).toEqual(first);
|
||||
expect(getProfileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues when loading animation is unsupported", async () => {
|
||||
showLoadingAnimationMock.mockRejectedValueOnce(new Error("unsupported"));
|
||||
|
||||
await expect(sendModule.showLoadingAnimation("line:room:R1")).resolves.toBeUndefined();
|
||||
|
||||
expect(logVerboseMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("line: loading animation failed (non-fatal)"),
|
||||
);
|
||||
});
|
||||
|
||||
it("pushes quick-reply text and caps to 13 buttons", async () => {
|
||||
await sendModule.pushTextMessageWithQuickReplies(
|
||||
"U-quick",
|
||||
"Pick one",
|
||||
Array.from({ length: 20 }, (_, index) => `Choice ${index + 1}`),
|
||||
);
|
||||
|
||||
expect(pushMessageMock).toHaveBeenCalledTimes(1);
|
||||
const firstCall = pushMessageMock.mock.calls[0] as [
|
||||
{ messages: Array<{ quickReply?: { items: unknown[] } }> },
|
||||
];
|
||||
expect(firstCall[0].messages[0].quickReply?.items).toHaveLength(13);
|
||||
});
|
||||
});
|
||||
448
src/line/send.ts
448
src/line/send.ts
@@ -1,448 +0,0 @@
|
||||
import { messagingApi } from "@line/bot-sdk";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
import { resolveLineAccount } from "./accounts.js";
|
||||
import { resolveLineChannelAccessToken } from "./channel-access-token.js";
|
||||
import { createTextMessageWithQuickReplies } from "./quick-replies.js";
|
||||
import type { LineSendResult } from "./types.js";
|
||||
export { createQuickReplyItems, createTextMessageWithQuickReplies } from "./quick-replies.js";
|
||||
|
||||
// Use the messaging API types directly
|
||||
type Message = messagingApi.Message;
|
||||
type TextMessage = messagingApi.TextMessage;
|
||||
type ImageMessage = messagingApi.ImageMessage;
|
||||
type LocationMessage = messagingApi.LocationMessage;
|
||||
type FlexMessage = messagingApi.FlexMessage;
|
||||
type FlexContainer = messagingApi.FlexContainer;
|
||||
type TemplateMessage = messagingApi.TemplateMessage;
|
||||
|
||||
// Cache for user profiles
|
||||
const userProfileCache = new Map<
|
||||
string,
|
||||
{ displayName: string; pictureUrl?: string; fetchedAt: number }
|
||||
>();
|
||||
const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface LineSendOpts {
|
||||
cfg?: OpenClawConfig;
|
||||
channelAccessToken?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
mediaUrl?: string;
|
||||
replyToken?: string;
|
||||
}
|
||||
|
||||
type LineClientOpts = Pick<LineSendOpts, "cfg" | "channelAccessToken" | "accountId">;
|
||||
type LinePushOpts = Pick<LineSendOpts, "cfg" | "channelAccessToken" | "accountId" | "verbose">;
|
||||
|
||||
interface LinePushBehavior {
|
||||
errorContext?: string;
|
||||
verboseMessage?: (chatId: string, messageCount: number) => string;
|
||||
}
|
||||
|
||||
interface LineReplyBehavior {
|
||||
verboseMessage?: (messageCount: number) => string;
|
||||
}
|
||||
|
||||
function normalizeTarget(to: string): string {
|
||||
const trimmed = to.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Recipient is required for LINE sends");
|
||||
}
|
||||
|
||||
// Strip internal prefixes
|
||||
let normalized = trimmed
|
||||
.replace(/^line:group:/i, "")
|
||||
.replace(/^line:room:/i, "")
|
||||
.replace(/^line:user:/i, "")
|
||||
.replace(/^line:/i, "");
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error("Recipient is required for LINE sends");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createLineMessagingClient(opts: LineClientOpts): {
|
||||
account: ReturnType<typeof resolveLineAccount>;
|
||||
client: messagingApi.MessagingApiClient;
|
||||
} {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const account = resolveLineAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
|
||||
const client = new messagingApi.MessagingApiClient({
|
||||
channelAccessToken: token,
|
||||
});
|
||||
return { account, client };
|
||||
}
|
||||
|
||||
function createLinePushContext(
|
||||
to: string,
|
||||
opts: LineClientOpts,
|
||||
): {
|
||||
account: ReturnType<typeof resolveLineAccount>;
|
||||
client: messagingApi.MessagingApiClient;
|
||||
chatId: string;
|
||||
} {
|
||||
const { account, client } = createLineMessagingClient(opts);
|
||||
const chatId = normalizeTarget(to);
|
||||
return { account, client, chatId };
|
||||
}
|
||||
|
||||
function createTextMessage(text: string): TextMessage {
|
||||
return { type: "text", text };
|
||||
}
|
||||
|
||||
export function createImageMessage(
|
||||
originalContentUrl: string,
|
||||
previewImageUrl?: string,
|
||||
): ImageMessage {
|
||||
return {
|
||||
type: "image",
|
||||
originalContentUrl,
|
||||
previewImageUrl: previewImageUrl ?? originalContentUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLocationMessage(location: {
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}): LocationMessage {
|
||||
return {
|
||||
type: "location",
|
||||
title: location.title.slice(0, 100), // LINE limit
|
||||
address: location.address.slice(0, 100), // LINE limit
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
};
|
||||
}
|
||||
|
||||
function logLineHttpError(err: unknown, context: string): void {
|
||||
if (!err || typeof err !== "object") {
|
||||
return;
|
||||
}
|
||||
const { status, statusText, body } = err as {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
body?: string;
|
||||
};
|
||||
if (typeof body === "string") {
|
||||
const summary = status ? `${status} ${statusText ?? ""}`.trim() : "unknown status";
|
||||
logVerbose(`line: ${context} failed (${summary}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
function recordLineOutboundActivity(accountId: string): void {
|
||||
recordChannelActivity({
|
||||
channel: "line",
|
||||
accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
}
|
||||
|
||||
async function pushLineMessages(
|
||||
to: string,
|
||||
messages: Message[],
|
||||
opts: LinePushOpts = {},
|
||||
behavior: LinePushBehavior = {},
|
||||
): Promise<LineSendResult> {
|
||||
if (messages.length === 0) {
|
||||
throw new Error("Message must be non-empty for LINE sends");
|
||||
}
|
||||
|
||||
const { account, client, chatId } = createLinePushContext(to, opts);
|
||||
const pushRequest = client.pushMessage({
|
||||
to: chatId,
|
||||
messages,
|
||||
});
|
||||
|
||||
if (behavior.errorContext) {
|
||||
const errorContext = behavior.errorContext;
|
||||
await pushRequest.catch((err) => {
|
||||
logLineHttpError(err, errorContext);
|
||||
throw err;
|
||||
});
|
||||
} else {
|
||||
await pushRequest;
|
||||
}
|
||||
|
||||
recordLineOutboundActivity(account.accountId);
|
||||
|
||||
if (opts.verbose) {
|
||||
const logMessage =
|
||||
behavior.verboseMessage?.(chatId, messages.length) ??
|
||||
`line: pushed ${messages.length} messages to ${chatId}`;
|
||||
logVerbose(logMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: "push",
|
||||
chatId,
|
||||
};
|
||||
}
|
||||
|
||||
async function replyLineMessages(
|
||||
replyToken: string,
|
||||
messages: Message[],
|
||||
opts: LinePushOpts = {},
|
||||
behavior: LineReplyBehavior = {},
|
||||
): Promise<void> {
|
||||
const { account, client } = createLineMessagingClient(opts);
|
||||
|
||||
await client.replyMessage({
|
||||
replyToken,
|
||||
messages,
|
||||
});
|
||||
|
||||
recordLineOutboundActivity(account.accountId);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(
|
||||
behavior.verboseMessage?.(messages.length) ??
|
||||
`line: replied with ${messages.length} messages`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMessageLine(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: LineSendOpts = {},
|
||||
): Promise<LineSendResult> {
|
||||
const chatId = normalizeTarget(to);
|
||||
|
||||
const messages: Message[] = [];
|
||||
|
||||
// Add media if provided
|
||||
if (opts.mediaUrl?.trim()) {
|
||||
messages.push(createImageMessage(opts.mediaUrl.trim()));
|
||||
}
|
||||
|
||||
// Add text message
|
||||
if (text?.trim()) {
|
||||
messages.push(createTextMessage(text.trim()));
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
throw new Error("Message must be non-empty for LINE sends");
|
||||
}
|
||||
|
||||
// Use reply if we have a reply token, otherwise push
|
||||
if (opts.replyToken) {
|
||||
await replyLineMessages(opts.replyToken, messages, opts, {
|
||||
verboseMessage: () => `line: replied to ${chatId}`,
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: "reply",
|
||||
chatId,
|
||||
};
|
||||
}
|
||||
|
||||
// Push message (for proactive messaging)
|
||||
return pushLineMessages(chatId, messages, opts, {
|
||||
verboseMessage: (resolvedChatId) => `line: pushed message to ${resolvedChatId}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pushMessageLine(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: LineSendOpts = {},
|
||||
): Promise<LineSendResult> {
|
||||
// Force push (no reply token)
|
||||
return sendMessageLine(to, text, { ...opts, replyToken: undefined });
|
||||
}
|
||||
|
||||
export async function replyMessageLine(
|
||||
replyToken: string,
|
||||
messages: Message[],
|
||||
opts: LinePushOpts = {},
|
||||
): Promise<void> {
|
||||
await replyLineMessages(replyToken, messages, opts);
|
||||
}
|
||||
|
||||
export async function pushMessagesLine(
|
||||
to: string,
|
||||
messages: Message[],
|
||||
opts: LinePushOpts = {},
|
||||
): Promise<LineSendResult> {
|
||||
return pushLineMessages(to, messages, opts, {
|
||||
errorContext: "push message",
|
||||
});
|
||||
}
|
||||
|
||||
export function createFlexMessage(
|
||||
altText: string,
|
||||
contents: messagingApi.FlexContainer,
|
||||
): messagingApi.FlexMessage {
|
||||
return {
|
||||
type: "flex",
|
||||
altText,
|
||||
contents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Push an image message to a user/group
|
||||
*/
|
||||
export async function pushImageMessage(
|
||||
to: string,
|
||||
originalContentUrl: string,
|
||||
previewImageUrl?: string,
|
||||
opts: LinePushOpts = {},
|
||||
): Promise<LineSendResult> {
|
||||
return pushLineMessages(to, [createImageMessage(originalContentUrl, previewImageUrl)], opts, {
|
||||
verboseMessage: (chatId) => `line: pushed image to ${chatId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a location message to a user/group
|
||||
*/
|
||||
export async function pushLocationMessage(
|
||||
to: string,
|
||||
location: {
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
},
|
||||
opts: LinePushOpts = {},
|
||||
): Promise<LineSendResult> {
|
||||
return pushLineMessages(to, [createLocationMessage(location)], opts, {
|
||||
verboseMessage: (chatId) => `line: pushed location to ${chatId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a Flex Message to a user/group
|
||||
*/
|
||||
export async function pushFlexMessage(
|
||||
to: string,
|
||||
altText: string,
|
||||
contents: FlexContainer,
|
||||
opts: LinePushOpts = {},
|
||||
): Promise<LineSendResult> {
|
||||
const flexMessage: FlexMessage = {
|
||||
type: "flex",
|
||||
altText: altText.slice(0, 400), // LINE limit
|
||||
contents,
|
||||
};
|
||||
|
||||
return pushLineMessages(to, [flexMessage], opts, {
|
||||
errorContext: "push flex message",
|
||||
verboseMessage: (chatId) => `line: pushed flex message to ${chatId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a Template Message to a user/group
|
||||
*/
|
||||
export async function pushTemplateMessage(
|
||||
to: string,
|
||||
template: TemplateMessage,
|
||||
opts: LinePushOpts = {},
|
||||
): Promise<LineSendResult> {
|
||||
return pushLineMessages(to, [template], opts, {
|
||||
verboseMessage: (chatId) => `line: pushed template message to ${chatId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a text message with quick reply buttons
|
||||
*/
|
||||
export async function pushTextMessageWithQuickReplies(
|
||||
to: string,
|
||||
text: string,
|
||||
quickReplyLabels: string[],
|
||||
opts: LinePushOpts = {},
|
||||
): Promise<LineSendResult> {
|
||||
const message = createTextMessageWithQuickReplies(text, quickReplyLabels);
|
||||
|
||||
return pushLineMessages(to, [message], opts, {
|
||||
verboseMessage: (chatId) => `line: pushed message with quick replies to ${chatId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create quick reply buttons to attach to a message
|
||||
*/
|
||||
/**
|
||||
* Show loading animation to user (lasts up to 20 seconds or until next message)
|
||||
*/
|
||||
export async function showLoadingAnimation(
|
||||
chatId: string,
|
||||
opts: { channelAccessToken?: string; accountId?: string; loadingSeconds?: number } = {},
|
||||
): Promise<void> {
|
||||
const { client } = createLineMessagingClient(opts);
|
||||
|
||||
try {
|
||||
await client.showLoadingAnimation({
|
||||
chatId: normalizeTarget(chatId),
|
||||
loadingSeconds: opts.loadingSeconds ?? 20,
|
||||
});
|
||||
logVerbose(`line: showing loading animation to ${chatId}`);
|
||||
} catch (err) {
|
||||
// Loading animation may fail for groups or unsupported clients - ignore
|
||||
logVerbose(`line: loading animation failed (non-fatal): ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user profile (display name, picture URL)
|
||||
*/
|
||||
export async function getUserProfile(
|
||||
userId: string,
|
||||
opts: { channelAccessToken?: string; accountId?: string; useCache?: boolean } = {},
|
||||
): Promise<{ displayName: string; pictureUrl?: string } | null> {
|
||||
const useCache = opts.useCache ?? true;
|
||||
|
||||
// Check cache first
|
||||
if (useCache) {
|
||||
const cached = userProfileCache.get(userId);
|
||||
if (cached && Date.now() - cached.fetchedAt < PROFILE_CACHE_TTL_MS) {
|
||||
return { displayName: cached.displayName, pictureUrl: cached.pictureUrl };
|
||||
}
|
||||
}
|
||||
|
||||
const { client } = createLineMessagingClient(opts);
|
||||
|
||||
try {
|
||||
const profile = await client.getProfile(userId);
|
||||
const result = {
|
||||
displayName: profile.displayName,
|
||||
pictureUrl: profile.pictureUrl,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
userProfileCache.set(userId, {
|
||||
...result,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
logVerbose(`line: failed to fetch profile for ${userId}: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's display name (with fallback to userId)
|
||||
*/
|
||||
export async function getUserDisplayName(
|
||||
userId: string,
|
||||
opts: { channelAccessToken?: string; accountId?: string } = {},
|
||||
): Promise<string> {
|
||||
const profile = await getUserProfile(userId, opts);
|
||||
return profile?.displayName ?? userId;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export function validateLineSignature(
|
||||
body: string,
|
||||
signature: string,
|
||||
channelSecret: string,
|
||||
): boolean {
|
||||
const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
|
||||
const hashBuffer = Buffer.from(hash);
|
||||
const signatureBuffer = Buffer.from(signature);
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks.
|
||||
if (hashBuffer.length !== signatureBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(hashBuffer, signatureBuffer);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createConfirmTemplate,
|
||||
createButtonTemplate,
|
||||
createTemplateCarousel,
|
||||
createCarouselColumn,
|
||||
createImageCarousel,
|
||||
createImageCarouselColumn,
|
||||
createProductCarousel,
|
||||
messageAction,
|
||||
} from "./template-messages.js";
|
||||
|
||||
describe("createConfirmTemplate", () => {
|
||||
it("truncates text to 240 characters", () => {
|
||||
const longText = "x".repeat(300);
|
||||
const template = createConfirmTemplate(longText, messageAction("Yes"), messageAction("No"));
|
||||
|
||||
expect((template.template as { text: string }).text.length).toBe(240);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createButtonTemplate", () => {
|
||||
it("limits actions to 4", () => {
|
||||
const actions = Array.from({ length: 6 }, (_, i) => messageAction(`Button ${i}`));
|
||||
const template = createButtonTemplate("Title", "Text", actions);
|
||||
|
||||
expect((template.template as { actions: unknown[] }).actions.length).toBe(4);
|
||||
});
|
||||
|
||||
it("truncates title to 40 characters", () => {
|
||||
const longTitle = "x".repeat(50);
|
||||
const template = createButtonTemplate(longTitle, "Text", [messageAction("OK")]);
|
||||
|
||||
expect((template.template as { title: string }).title.length).toBe(40);
|
||||
});
|
||||
|
||||
it("truncates text to 60 chars when no thumbnail is provided", () => {
|
||||
const longText = "x".repeat(100);
|
||||
const template = createButtonTemplate("Title", longText, [messageAction("OK")]);
|
||||
|
||||
expect((template.template as { text: string }).text.length).toBe(60);
|
||||
});
|
||||
|
||||
it("keeps longer text when thumbnail is provided", () => {
|
||||
const longText = "x".repeat(100);
|
||||
const template = createButtonTemplate("Title", longText, [messageAction("OK")], {
|
||||
thumbnailImageUrl: "https://example.com/thumb.jpg",
|
||||
});
|
||||
|
||||
expect((template.template as { text: string }).text.length).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCarouselColumn", () => {
|
||||
it("limits actions to 3", () => {
|
||||
const column = createCarouselColumn({
|
||||
text: "Text",
|
||||
actions: [
|
||||
messageAction("A1"),
|
||||
messageAction("A2"),
|
||||
messageAction("A3"),
|
||||
messageAction("A4"),
|
||||
messageAction("A5"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(column.actions.length).toBe(3);
|
||||
});
|
||||
|
||||
it("truncates text to 120 characters", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({ text: longText, actions: [messageAction("OK")] });
|
||||
|
||||
expect(column.text.length).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("carousel column limits", () => {
|
||||
it.each([
|
||||
{
|
||||
createTemplate: () =>
|
||||
createTemplateCarousel(
|
||||
Array.from({ length: 15 }, () =>
|
||||
createCarouselColumn({ text: "Text", actions: [messageAction("OK")] }),
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
createTemplate: () =>
|
||||
createImageCarousel(
|
||||
Array.from({ length: 15 }, (_, i) =>
|
||||
createImageCarouselColumn(`https://example.com/${i}.jpg`, messageAction("View")),
|
||||
),
|
||||
),
|
||||
},
|
||||
])("limits columns to 10", ({ createTemplate }) => {
|
||||
const template = createTemplate();
|
||||
expect((template.template as { columns: unknown[] }).columns.length).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProductCarousel", () => {
|
||||
it.each([
|
||||
{
|
||||
title: "Product",
|
||||
description: "Desc",
|
||||
actionLabel: "Buy",
|
||||
actionUrl: "https://shop.com/buy",
|
||||
expectedType: "uri",
|
||||
},
|
||||
{
|
||||
title: "Product",
|
||||
description: "Desc",
|
||||
actionLabel: "Select",
|
||||
actionData: "product_id=123",
|
||||
expectedType: "postback",
|
||||
},
|
||||
])("uses expected action type for product action", ({ expectedType, ...item }) => {
|
||||
const template = createProductCarousel([item]);
|
||||
const columns = (template.template as { columns: Array<{ actions: Array<{ type: string }> }> })
|
||||
.columns;
|
||||
expect(columns[0].actions[0].type).toBe(expectedType);
|
||||
});
|
||||
});
|
||||
@@ -1,355 +0,0 @@
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import {
|
||||
datetimePickerAction,
|
||||
messageAction,
|
||||
postbackAction,
|
||||
uriAction,
|
||||
type Action,
|
||||
} from "./actions.js";
|
||||
|
||||
export { datetimePickerAction, messageAction, postbackAction, uriAction };
|
||||
|
||||
type TemplateMessage = messagingApi.TemplateMessage;
|
||||
type ConfirmTemplate = messagingApi.ConfirmTemplate;
|
||||
type ButtonsTemplate = messagingApi.ButtonsTemplate;
|
||||
type CarouselTemplate = messagingApi.CarouselTemplate;
|
||||
type CarouselColumn = messagingApi.CarouselColumn;
|
||||
type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate;
|
||||
type ImageCarouselColumn = messagingApi.ImageCarouselColumn;
|
||||
|
||||
type TemplatePayloadAction = {
|
||||
type?: "uri" | "postback" | "message";
|
||||
uri?: string;
|
||||
data?: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function buildTemplatePayloadAction(action: TemplatePayloadAction): Action {
|
||||
if (action.type === "uri" && action.uri) {
|
||||
return uriAction(action.label, action.uri);
|
||||
}
|
||||
if (action.type === "postback" && action.data) {
|
||||
return postbackAction(action.label, action.data, action.label);
|
||||
}
|
||||
return messageAction(action.label, action.data ?? action.label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a confirm template (yes/no style dialog)
|
||||
*/
|
||||
export function createConfirmTemplate(
|
||||
text: string,
|
||||
confirmAction: Action,
|
||||
cancelAction: Action,
|
||||
altText?: string,
|
||||
): TemplateMessage {
|
||||
const template: ConfirmTemplate = {
|
||||
type: "confirm",
|
||||
text: text.slice(0, 240), // LINE limit
|
||||
actions: [confirmAction, cancelAction],
|
||||
};
|
||||
|
||||
return {
|
||||
type: "template",
|
||||
altText: altText?.slice(0, 400) ?? text.slice(0, 400),
|
||||
template,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a button template with title, text, and action buttons
|
||||
*/
|
||||
export function createButtonTemplate(
|
||||
title: string,
|
||||
text: string,
|
||||
actions: Action[],
|
||||
options?: {
|
||||
thumbnailImageUrl?: string;
|
||||
imageAspectRatio?: "rectangle" | "square";
|
||||
imageSize?: "cover" | "contain";
|
||||
imageBackgroundColor?: string;
|
||||
defaultAction?: Action;
|
||||
altText?: string;
|
||||
},
|
||||
): TemplateMessage {
|
||||
const hasThumbnail = Boolean(options?.thumbnailImageUrl?.trim());
|
||||
const textLimit = hasThumbnail ? 160 : 60;
|
||||
const template: ButtonsTemplate = {
|
||||
type: "buttons",
|
||||
title: title.slice(0, 40), // LINE limit
|
||||
text: text.slice(0, textLimit), // LINE limit (60 if no thumbnail, 160 with thumbnail)
|
||||
actions: actions.slice(0, 4), // LINE limit: max 4 actions
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
|
||||
imageSize: options?.imageSize ?? "cover",
|
||||
imageBackgroundColor: options?.imageBackgroundColor,
|
||||
defaultAction: options?.defaultAction,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "template",
|
||||
altText: options?.altText?.slice(0, 400) ?? `${title}: ${text}`.slice(0, 400),
|
||||
template,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a carousel template with multiple columns
|
||||
*/
|
||||
export function createTemplateCarousel(
|
||||
columns: CarouselColumn[],
|
||||
options?: {
|
||||
imageAspectRatio?: "rectangle" | "square";
|
||||
imageSize?: "cover" | "contain";
|
||||
altText?: string;
|
||||
},
|
||||
): TemplateMessage {
|
||||
const template: CarouselTemplate = {
|
||||
type: "carousel",
|
||||
columns: columns.slice(0, 10), // LINE limit: max 10 columns
|
||||
imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
|
||||
imageSize: options?.imageSize ?? "cover",
|
||||
};
|
||||
|
||||
return {
|
||||
type: "template",
|
||||
altText: options?.altText?.slice(0, 400) ?? "View carousel",
|
||||
template,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a carousel column for use with createTemplateCarousel
|
||||
*/
|
||||
export function createCarouselColumn(params: {
|
||||
title?: string;
|
||||
text: string;
|
||||
actions: Action[];
|
||||
thumbnailImageUrl?: string;
|
||||
imageBackgroundColor?: string;
|
||||
defaultAction?: Action;
|
||||
}): CarouselColumn {
|
||||
return {
|
||||
title: params.title?.slice(0, 40),
|
||||
text: params.text.slice(0, 120), // LINE limit
|
||||
actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column
|
||||
thumbnailImageUrl: params.thumbnailImageUrl,
|
||||
imageBackgroundColor: params.imageBackgroundColor,
|
||||
defaultAction: params.defaultAction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image carousel template (simpler, image-focused carousel)
|
||||
*/
|
||||
export function createImageCarousel(
|
||||
columns: ImageCarouselColumn[],
|
||||
altText?: string,
|
||||
): TemplateMessage {
|
||||
const template: ImageCarouselTemplate = {
|
||||
type: "image_carousel",
|
||||
columns: columns.slice(0, 10), // LINE limit: max 10 columns
|
||||
};
|
||||
|
||||
return {
|
||||
type: "template",
|
||||
altText: altText?.slice(0, 400) ?? "View images",
|
||||
template,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image carousel column for use with createImageCarousel
|
||||
*/
|
||||
export function createImageCarouselColumn(imageUrl: string, action: Action): ImageCarouselColumn {
|
||||
return {
|
||||
imageUrl,
|
||||
action,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Action Helpers (same as rich-menu but re-exported for convenience)
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Builders
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a simple yes/no confirmation dialog
|
||||
*/
|
||||
export function createYesNoConfirm(
|
||||
question: string,
|
||||
options?: {
|
||||
yesText?: string;
|
||||
noText?: string;
|
||||
yesData?: string;
|
||||
noData?: string;
|
||||
altText?: string;
|
||||
},
|
||||
): TemplateMessage {
|
||||
const yesAction: Action = options?.yesData
|
||||
? postbackAction(options.yesText ?? "Yes", options.yesData, options.yesText ?? "Yes")
|
||||
: messageAction(options?.yesText ?? "Yes");
|
||||
|
||||
const noAction: Action = options?.noData
|
||||
? postbackAction(options.noText ?? "No", options.noData, options.noText ?? "No")
|
||||
: messageAction(options?.noText ?? "No");
|
||||
|
||||
return createConfirmTemplate(question, yesAction, noAction, options?.altText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a button menu with simple text buttons
|
||||
*/
|
||||
export function createButtonMenu(
|
||||
title: string,
|
||||
text: string,
|
||||
buttons: Array<{ label: string; text?: string }>,
|
||||
options?: {
|
||||
thumbnailImageUrl?: string;
|
||||
altText?: string;
|
||||
},
|
||||
): TemplateMessage {
|
||||
const actions = buttons.slice(0, 4).map((btn) => messageAction(btn.label, btn.text));
|
||||
|
||||
return createButtonTemplate(title, text, actions, {
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
altText: options?.altText,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a button menu with URL links
|
||||
*/
|
||||
export function createLinkMenu(
|
||||
title: string,
|
||||
text: string,
|
||||
links: Array<{ label: string; url: string }>,
|
||||
options?: {
|
||||
thumbnailImageUrl?: string;
|
||||
altText?: string;
|
||||
},
|
||||
): TemplateMessage {
|
||||
const actions = links.slice(0, 4).map((link) => uriAction(link.label, link.url));
|
||||
|
||||
return createButtonTemplate(title, text, actions, {
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
altText: options?.altText,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple product/item carousel
|
||||
*/
|
||||
export function createProductCarousel(
|
||||
products: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl?: string;
|
||||
price?: string;
|
||||
actionLabel?: string;
|
||||
actionUrl?: string;
|
||||
actionData?: string;
|
||||
}>,
|
||||
altText?: string,
|
||||
): TemplateMessage {
|
||||
const columns = products.slice(0, 10).map((product) => {
|
||||
const actions: Action[] = [];
|
||||
|
||||
// Add main action
|
||||
if (product.actionUrl) {
|
||||
actions.push(uriAction(product.actionLabel ?? "View", product.actionUrl));
|
||||
} else if (product.actionData) {
|
||||
actions.push(postbackAction(product.actionLabel ?? "Select", product.actionData));
|
||||
} else {
|
||||
actions.push(messageAction(product.actionLabel ?? "Select", product.title));
|
||||
}
|
||||
|
||||
return createCarouselColumn({
|
||||
title: product.title,
|
||||
text: product.price
|
||||
? `${product.description}\n${product.price}`.slice(0, 120)
|
||||
: product.description,
|
||||
thumbnailImageUrl: product.imageUrl,
|
||||
actions,
|
||||
});
|
||||
});
|
||||
|
||||
return createTemplateCarousel(columns, { altText });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ReplyPayload Conversion
|
||||
// ============================================================================
|
||||
|
||||
import type { LineTemplateMessagePayload } from "./types.js";
|
||||
|
||||
/**
|
||||
* Convert a TemplateMessagePayload from ReplyPayload to a LINE TemplateMessage
|
||||
*/
|
||||
export function buildTemplateMessageFromPayload(
|
||||
payload: LineTemplateMessagePayload,
|
||||
): TemplateMessage | null {
|
||||
switch (payload.type) {
|
||||
case "confirm": {
|
||||
const confirmAction = payload.confirmData.startsWith("http")
|
||||
? uriAction(payload.confirmLabel, payload.confirmData)
|
||||
: payload.confirmData.includes("=")
|
||||
? postbackAction(payload.confirmLabel, payload.confirmData, payload.confirmLabel)
|
||||
: messageAction(payload.confirmLabel, payload.confirmData);
|
||||
|
||||
const cancelAction = payload.cancelData.startsWith("http")
|
||||
? uriAction(payload.cancelLabel, payload.cancelData)
|
||||
: payload.cancelData.includes("=")
|
||||
? postbackAction(payload.cancelLabel, payload.cancelData, payload.cancelLabel)
|
||||
: messageAction(payload.cancelLabel, payload.cancelData);
|
||||
|
||||
return createConfirmTemplate(payload.text, confirmAction, cancelAction, payload.altText);
|
||||
}
|
||||
|
||||
case "buttons": {
|
||||
const actions: Action[] = payload.actions
|
||||
.slice(0, 4)
|
||||
.map((action) => buildTemplatePayloadAction(action));
|
||||
|
||||
return createButtonTemplate(payload.title, payload.text, actions, {
|
||||
thumbnailImageUrl: payload.thumbnailImageUrl,
|
||||
altText: payload.altText,
|
||||
});
|
||||
}
|
||||
|
||||
case "carousel": {
|
||||
const columns: CarouselColumn[] = payload.columns.slice(0, 10).map((col) => {
|
||||
const colActions: Action[] = col.actions
|
||||
.slice(0, 3)
|
||||
.map((action) => buildTemplatePayloadAction(action));
|
||||
|
||||
return createCarouselColumn({
|
||||
title: col.title,
|
||||
text: col.text,
|
||||
thumbnailImageUrl: col.thumbnailImageUrl,
|
||||
actions: colActions,
|
||||
});
|
||||
});
|
||||
|
||||
return createTemplateCarousel(columns, { altText: payload.altText });
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
TemplateMessage,
|
||||
ConfirmTemplate,
|
||||
ButtonsTemplate,
|
||||
CarouselTemplate,
|
||||
CarouselColumn,
|
||||
ImageCarouselTemplate,
|
||||
ImageCarouselColumn,
|
||||
Action,
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
import type {
|
||||
WebhookEvent,
|
||||
TextMessage,
|
||||
ImageMessage,
|
||||
VideoMessage,
|
||||
AudioMessage,
|
||||
StickerMessage,
|
||||
LocationMessage,
|
||||
} from "@line/bot-sdk";
|
||||
import type { BaseProbeResult } from "../channels/plugins/types.js";
|
||||
|
||||
export type LineTokenSource = "config" | "env" | "file" | "none";
|
||||
|
||||
interface LineAccountBaseConfig {
|
||||
enabled?: boolean;
|
||||
channelAccessToken?: string;
|
||||
channelSecret?: string;
|
||||
tokenFile?: string;
|
||||
secretFile?: string;
|
||||
name?: string;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
/** Outbound response prefix override for this account. */
|
||||
responsePrefix?: string;
|
||||
mediaMaxMb?: number;
|
||||
webhookPath?: string;
|
||||
groups?: Record<string, LineGroupConfig>;
|
||||
}
|
||||
|
||||
export interface LineConfig extends LineAccountBaseConfig {
|
||||
/** Per-account overrides keyed by account id. */
|
||||
accounts?: Record<string, LineAccountConfig>;
|
||||
/** Optional default account id when multiple accounts are configured. */
|
||||
defaultAccount?: string;
|
||||
}
|
||||
|
||||
export interface LineAccountConfig extends LineAccountBaseConfig {}
|
||||
|
||||
export interface LineGroupConfig {
|
||||
enabled?: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
requireMention?: boolean;
|
||||
systemPrompt?: string;
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
export interface ResolvedLineAccount {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
channelAccessToken: string;
|
||||
channelSecret: string;
|
||||
tokenSource: LineTokenSource;
|
||||
config: LineConfig & LineAccountConfig;
|
||||
}
|
||||
|
||||
export type LineMessageType =
|
||||
| TextMessage
|
||||
| ImageMessage
|
||||
| VideoMessage
|
||||
| AudioMessage
|
||||
| StickerMessage
|
||||
| LocationMessage;
|
||||
|
||||
export interface LineWebhookContext {
|
||||
event: WebhookEvent;
|
||||
replyToken?: string;
|
||||
userId?: string;
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
}
|
||||
|
||||
export interface LineSendResult {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
export type LineProbeResult = BaseProbeResult<string> & {
|
||||
bot?: {
|
||||
displayName?: string;
|
||||
userId?: string;
|
||||
basicId?: string;
|
||||
pictureUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type LineFlexMessagePayload = {
|
||||
altText: string;
|
||||
contents: unknown;
|
||||
};
|
||||
|
||||
export type LineTemplateMessagePayload =
|
||||
| {
|
||||
type: "confirm";
|
||||
text: string;
|
||||
confirmLabel: string;
|
||||
confirmData: string;
|
||||
cancelLabel: string;
|
||||
cancelData: string;
|
||||
altText?: string;
|
||||
}
|
||||
| {
|
||||
type: "buttons";
|
||||
title: string;
|
||||
text: string;
|
||||
actions: Array<{
|
||||
type: "message" | "uri" | "postback";
|
||||
label: string;
|
||||
data?: string;
|
||||
uri?: string;
|
||||
}>;
|
||||
thumbnailImageUrl?: string;
|
||||
altText?: string;
|
||||
}
|
||||
| {
|
||||
type: "carousel";
|
||||
columns: Array<{
|
||||
title?: string;
|
||||
text: string;
|
||||
thumbnailImageUrl?: string;
|
||||
actions: Array<{
|
||||
type: "message" | "uri" | "postback";
|
||||
label: string;
|
||||
data?: string;
|
||||
uri?: string;
|
||||
}>;
|
||||
}>;
|
||||
altText?: string;
|
||||
};
|
||||
|
||||
export type LineChannelData = {
|
||||
quickReplies?: string[];
|
||||
location?: {
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
flexMessage?: LineFlexMessagePayload;
|
||||
templateMessage?: LineTemplateMessagePayload;
|
||||
};
|
||||
@@ -1,243 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createLineNodeWebhookHandler } from "./webhook-node.js";
|
||||
|
||||
const sign = (body: string, secret: string) =>
|
||||
crypto.createHmac("SHA256", secret).update(body).digest("base64");
|
||||
|
||||
function createRes() {
|
||||
const headers: Record<string, string> = {};
|
||||
const resObj = {
|
||||
statusCode: 0,
|
||||
headersSent: false,
|
||||
setHeader: (k: string, v: string) => {
|
||||
headers[k.toLowerCase()] = v;
|
||||
},
|
||||
end: vi.fn((data?: unknown) => {
|
||||
resObj.headersSent = true;
|
||||
// Keep payload available for assertions
|
||||
resObj.body = data;
|
||||
}),
|
||||
body: undefined as unknown,
|
||||
};
|
||||
const res = resObj as unknown as ServerResponse & { body?: unknown };
|
||||
return { res, headers };
|
||||
}
|
||||
|
||||
function createPostWebhookTestHarness(rawBody: string, secret = "secret") {
|
||||
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: secret,
|
||||
bot,
|
||||
runtime,
|
||||
readBody: async () => rawBody,
|
||||
});
|
||||
return { bot, handler, secret };
|
||||
}
|
||||
|
||||
const runSignedPost = async (params: {
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
rawBody: string;
|
||||
secret: string;
|
||||
res: ServerResponse;
|
||||
}) =>
|
||||
await params.handler(
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "x-line-signature": sign(params.rawBody, params.secret) },
|
||||
} as unknown as IncomingMessage,
|
||||
params.res,
|
||||
);
|
||||
|
||||
describe("createLineNodeWebhookHandler", () => {
|
||||
it("returns 200 for GET", async () => {
|
||||
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: "secret",
|
||||
bot,
|
||||
runtime,
|
||||
readBody: async () => "",
|
||||
});
|
||||
|
||||
const { res } = createRes();
|
||||
await handler({ method: "GET", headers: {} } as unknown as IncomingMessage, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("OK");
|
||||
});
|
||||
|
||||
it("returns 204 for HEAD", async () => {
|
||||
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: "secret",
|
||||
bot,
|
||||
runtime,
|
||||
readBody: async () => "",
|
||||
});
|
||||
|
||||
const { res } = createRes();
|
||||
await handler({ method: "HEAD", headers: {} } as unknown as IncomingMessage, res);
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(res.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects verification-shaped requests without a signature", async () => {
|
||||
const rawBody = JSON.stringify({ events: [] });
|
||||
const { bot, handler } = createPostWebhookTestHarness(rawBody);
|
||||
|
||||
const { res, headers } = createRes();
|
||||
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(headers["content-type"]).toBe("application/json");
|
||||
expect(res.body).toBe(JSON.stringify({ error: "Missing X-Line-Signature header" }));
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts signed verification-shaped requests without dispatching events", async () => {
|
||||
const rawBody = JSON.stringify({ events: [] });
|
||||
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
|
||||
|
||||
const { res, headers } = createRes();
|
||||
await runSignedPost({ handler, rawBody, secret, res });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(headers["content-type"]).toBe("application/json");
|
||||
expect(res.body).toBe(JSON.stringify({ status: "ok" }));
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 405 for non-GET/HEAD/POST methods", async () => {
|
||||
const { bot, handler } = createPostWebhookTestHarness(JSON.stringify({ events: [] }));
|
||||
|
||||
const { res, headers } = createRes();
|
||||
await handler({ method: "PUT", headers: {} } as unknown as IncomingMessage, res);
|
||||
|
||||
expect(res.statusCode).toBe(405);
|
||||
expect(headers.allow).toBe("GET, HEAD, POST");
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects missing signature when events are non-empty", async () => {
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const { bot, handler } = createPostWebhookTestHarness(rawBody);
|
||||
|
||||
const { res } = createRes();
|
||||
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unsigned POST requests before reading the body", async () => {
|
||||
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const readBody = vi.fn(async () => JSON.stringify({ events: [{ type: "message" }] }));
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: "secret",
|
||||
bot,
|
||||
runtime,
|
||||
readBody,
|
||||
});
|
||||
|
||||
const { res } = createRes();
|
||||
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(readBody).not.toHaveBeenCalled();
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses strict pre-auth limits for signed POST requests", async () => {
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const readBody = vi.fn(async (_req: IncomingMessage, maxBytes: number, timeoutMs?: number) => {
|
||||
expect(maxBytes).toBe(64 * 1024);
|
||||
expect(timeoutMs).toBe(5_000);
|
||||
return rawBody;
|
||||
});
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: "secret",
|
||||
bot,
|
||||
runtime,
|
||||
readBody,
|
||||
maxBodyBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
const { res } = createRes();
|
||||
await runSignedPost({ handler, rawBody, secret: "secret", res });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(readBody).toHaveBeenCalledTimes(1);
|
||||
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects invalid signature", async () => {
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const { bot, handler } = createPostWebhookTestHarness(rawBody);
|
||||
|
||||
const { res } = createRes();
|
||||
await handler(
|
||||
{ method: "POST", headers: { "x-line-signature": "bad" } } as unknown as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts valid signature and dispatches events", async () => {
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
|
||||
|
||||
const { res } = createRes();
|
||||
await runSignedPost({ handler, rawBody, secret, res });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(bot.handleWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ events: expect.any(Array) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 500 when event processing fails and does not acknowledge with 200", async () => {
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const { secret } = createPostWebhookTestHarness(rawBody);
|
||||
const failingBot = {
|
||||
handleWebhook: vi.fn(async () => {
|
||||
throw new Error("transient failure");
|
||||
}),
|
||||
};
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const failingHandler = createLineNodeWebhookHandler({
|
||||
channelSecret: secret,
|
||||
bot: failingBot,
|
||||
runtime,
|
||||
readBody: async () => rawBody,
|
||||
});
|
||||
|
||||
const { res } = createRes();
|
||||
await runSignedPost({ handler: failingHandler, rawBody, secret, res });
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toBe(JSON.stringify({ error: "Internal server error" }));
|
||||
expect(failingBot.handleWebhook).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid JSON payload even when signature is valid", async () => {
|
||||
const rawBody = "not json";
|
||||
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
|
||||
|
||||
const { res } = createRes();
|
||||
await runSignedPost({ handler, rawBody, secret, res });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { WebhookRequestBody } from "@line/bot-sdk";
|
||||
import { danger, logVerbose } from "../globals.js";
|
||||
import {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "../infra/http-body.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { validateLineSignature } from "./signature.js";
|
||||
import { parseLineWebhookBody } from "./webhook-utils.js";
|
||||
|
||||
const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
||||
const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024;
|
||||
const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5_000;
|
||||
|
||||
export async function readLineWebhookRequestBody(
|
||||
req: IncomingMessage,
|
||||
maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES,
|
||||
timeoutMs = LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
|
||||
): Promise<string> {
|
||||
return await readRequestBodyWithLimit(req, {
|
||||
maxBytes,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
type ReadBodyFn = (req: IncomingMessage, maxBytes: number, timeoutMs?: number) => Promise<string>;
|
||||
|
||||
export function createLineNodeWebhookHandler(params: {
|
||||
channelSecret: string;
|
||||
bot: { handleWebhook: (body: WebhookRequestBody) => Promise<void> };
|
||||
runtime: RuntimeEnv;
|
||||
readBody?: ReadBodyFn;
|
||||
maxBodyBytes?: number;
|
||||
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||
const maxBodyBytes = params.maxBodyBytes ?? LINE_WEBHOOK_MAX_BODY_BYTES;
|
||||
const readBody = params.readBody ?? readLineWebhookRequestBody;
|
||||
|
||||
return async (req: IncomingMessage, res: ServerResponse) => {
|
||||
// Some webhook validators and health probes use GET/HEAD.
|
||||
if (req.method === "GET" || req.method === "HEAD") {
|
||||
if (req.method === "HEAD") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("OK");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only accept POST requests
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "GET, HEAD, POST");
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const signatureHeader = req.headers["x-line-signature"];
|
||||
const signature =
|
||||
typeof signatureHeader === "string"
|
||||
? signatureHeader.trim()
|
||||
: Array.isArray(signatureHeader)
|
||||
? (signatureHeader[0] ?? "").trim()
|
||||
: "";
|
||||
|
||||
if (!signature) {
|
||||
logVerbose("line: webhook missing X-Line-Signature header");
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Missing X-Line-Signature header" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody = await readBody(
|
||||
req,
|
||||
Math.min(maxBodyBytes, LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES),
|
||||
LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!validateLineSignature(rawBody, signature, params.channelSecret)) {
|
||||
logVerbose("line: webhook signature validation failed");
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Invalid signature" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = parseLineWebhookBody(rawBody);
|
||||
|
||||
if (!body) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Invalid webhook payload" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.events && body.events.length > 0) {
|
||||
logVerbose(`line: received ${body.events.length} webhook events`);
|
||||
await params.bot.handleWebhook(body);
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
} catch (err) {
|
||||
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
||||
res.statusCode = 413;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Payload too large" }));
|
||||
return;
|
||||
}
|
||||
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
||||
res.statusCode = 408;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
|
||||
return;
|
||||
}
|
||||
params.runtime.error?.(danger(`line webhook error: ${String(err)}`));
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Internal server error" }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { WebhookRequestBody } from "@line/bot-sdk";
|
||||
|
||||
export function parseLineWebhookBody(rawBody: string): WebhookRequestBody | null {
|
||||
try {
|
||||
return JSON.parse(rawBody) as WebhookRequestBody;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { WebhookRequestBody } from "@line/bot-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createLineWebhookMiddleware, startLineWebhook } from "./webhook.js";
|
||||
|
||||
const sign = (body: string, secret: string) =>
|
||||
crypto.createHmac("SHA256", secret).update(body).digest("base64");
|
||||
|
||||
const createRes = () => {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
headersSent: false,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
res.status.mockReturnValue(res);
|
||||
res.json.mockReturnValue(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
const SECRET = "secret";
|
||||
|
||||
async function invokeWebhook(params: {
|
||||
body: unknown;
|
||||
headers?: Record<string, string>;
|
||||
onEvents?: ReturnType<typeof vi.fn>;
|
||||
autoSign?: boolean;
|
||||
}) {
|
||||
const onEventsMock = params.onEvents ?? vi.fn(async () => {});
|
||||
const middleware = createLineWebhookMiddleware({
|
||||
channelSecret: SECRET,
|
||||
onEvents: onEventsMock as unknown as (body: WebhookRequestBody) => Promise<void>,
|
||||
});
|
||||
|
||||
const headers = { ...params.headers };
|
||||
const autoSign = params.autoSign ?? true;
|
||||
if (autoSign && !headers["x-line-signature"]) {
|
||||
if (typeof params.body === "string") {
|
||||
headers["x-line-signature"] = sign(params.body, SECRET);
|
||||
} else if (Buffer.isBuffer(params.body)) {
|
||||
headers["x-line-signature"] = sign(params.body.toString("utf-8"), SECRET);
|
||||
}
|
||||
}
|
||||
|
||||
const req = {
|
||||
headers,
|
||||
body: params.body,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
const res = createRes();
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
await middleware(req, res, {} as any);
|
||||
return { res, onEvents: onEventsMock };
|
||||
}
|
||||
|
||||
describe("createLineWebhookMiddleware", () => {
|
||||
it("rejects startup when channel secret is missing", () => {
|
||||
expect(() =>
|
||||
startLineWebhook({
|
||||
channelSecret: " ",
|
||||
onEvents: async () => {},
|
||||
}),
|
||||
).toThrow(/requires a non-empty channel secret/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["raw string body", JSON.stringify({ events: [{ type: "message" }] })],
|
||||
["raw buffer body", Buffer.from(JSON.stringify({ events: [{ type: "follow" }] }), "utf-8")],
|
||||
])("parses JSON from %s", async (_label, body) => {
|
||||
const { res, onEvents } = await invokeWebhook({ body });
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(onEvents).toHaveBeenCalledWith(expect.objectContaining({ events: expect.any(Array) }));
|
||||
});
|
||||
|
||||
it("rejects invalid JSON payloads", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({ body: "not json" });
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects webhooks with invalid signatures", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({
|
||||
body: JSON.stringify({ events: [{ type: "message" }] }),
|
||||
headers: { "x-line-signature": "invalid-signature" },
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects verification-shaped requests without a signature", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({
|
||||
body: JSON.stringify({ events: [] }),
|
||||
headers: {},
|
||||
autoSign: false,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" });
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts signed verification-shaped requests without dispatching events", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({
|
||||
body: JSON.stringify({ events: [] }),
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ status: "ok" });
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects oversized signed payloads before JSON parsing", async () => {
|
||||
const largeBody = JSON.stringify({ events: [], payload: "x".repeat(70 * 1024) });
|
||||
const { res, onEvents } = await invokeWebhook({ body: largeBody });
|
||||
expect(res.status).toHaveBeenCalledWith(413);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Payload too large" });
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects missing signature when events are non-empty", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({
|
||||
body: JSON.stringify({ events: [{ type: "message" }] }),
|
||||
headers: {},
|
||||
autoSign: false,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" });
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects signed requests when raw body is missing", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({
|
||||
body: { events: [{ type: "message" }] },
|
||||
headers: { "x-line-signature": "signed" },
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: "Missing raw request body for signature verification",
|
||||
});
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the signed raw body instead of a pre-parsed req.body object", async () => {
|
||||
const onEvents = vi.fn(async (_body: WebhookRequestBody) => {});
|
||||
const rawBody = JSON.stringify({
|
||||
events: [{ type: "message", source: { userId: "signed-user" } }],
|
||||
});
|
||||
const reqBody = {
|
||||
events: [{ type: "message", source: { userId: "tampered-user" } }],
|
||||
};
|
||||
const middleware = createLineWebhookMiddleware({
|
||||
channelSecret: SECRET,
|
||||
onEvents,
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: { "x-line-signature": sign(rawBody, SECRET) },
|
||||
rawBody,
|
||||
body: reqBody,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
const res = createRes();
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
await middleware(req, res, {} as any);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(onEvents).toHaveBeenCalledTimes(1);
|
||||
const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined;
|
||||
expect(processedBody?.events?.[0]?.source?.userId).toBe("signed-user");
|
||||
expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user");
|
||||
});
|
||||
|
||||
it("uses signed raw buffer body instead of a pre-parsed req.body object", async () => {
|
||||
const onEvents = vi.fn(async (_body: WebhookRequestBody) => {});
|
||||
const rawBodyText = JSON.stringify({
|
||||
events: [{ type: "message", source: { userId: "signed-buffer-user" } }],
|
||||
});
|
||||
const reqBody = {
|
||||
events: [{ type: "message", source: { userId: "tampered-user" } }],
|
||||
};
|
||||
const middleware = createLineWebhookMiddleware({
|
||||
channelSecret: SECRET,
|
||||
onEvents,
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: { "x-line-signature": sign(rawBodyText, SECRET) },
|
||||
rawBody: Buffer.from(rawBodyText, "utf-8"),
|
||||
body: reqBody,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
const res = createRes();
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
await middleware(req, res, {} as any);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(onEvents).toHaveBeenCalledTimes(1);
|
||||
const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined;
|
||||
expect(processedBody?.events?.[0]?.source?.userId).toBe("signed-buffer-user");
|
||||
expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user");
|
||||
});
|
||||
|
||||
it("rejects invalid signed raw JSON even when req.body is a valid object", async () => {
|
||||
const onEvents = vi.fn(async (_body: WebhookRequestBody) => {});
|
||||
const rawBody = "not-json";
|
||||
const middleware = createLineWebhookMiddleware({
|
||||
channelSecret: SECRET,
|
||||
onEvents,
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: { "x-line-signature": sign(rawBody, SECRET) },
|
||||
rawBody,
|
||||
body: { events: [{ type: "message" }] },
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
const res = createRes();
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
await middleware(req, res, {} as any);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Invalid webhook payload" });
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 500 when event processing fails and does not acknowledge with 200", async () => {
|
||||
const onEvents = vi.fn(async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const middleware = createLineWebhookMiddleware({
|
||||
channelSecret: SECRET,
|
||||
onEvents,
|
||||
runtime,
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: { "x-line-signature": sign(rawBody, SECRET) },
|
||||
body: rawBody,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
const res = createRes();
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
await middleware(req, res, {} as any);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.status).not.toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" });
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { WebhookRequestBody } from "@line/bot-sdk";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { logVerbose, danger } from "../globals.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { validateLineSignature } from "./signature.js";
|
||||
import { parseLineWebhookBody } from "./webhook-utils.js";
|
||||
|
||||
const LINE_WEBHOOK_MAX_RAW_BODY_BYTES = 64 * 1024;
|
||||
|
||||
export interface LineWebhookOptions {
|
||||
channelSecret: string;
|
||||
onEvents: (body: WebhookRequestBody) => Promise<void>;
|
||||
runtime?: RuntimeEnv;
|
||||
}
|
||||
|
||||
function readRawBody(req: Request): string | null {
|
||||
const rawBody =
|
||||
(req as { rawBody?: string | Buffer }).rawBody ??
|
||||
(typeof req.body === "string" || Buffer.isBuffer(req.body) ? req.body : null);
|
||||
if (!rawBody) {
|
||||
return null;
|
||||
}
|
||||
return Buffer.isBuffer(rawBody) ? rawBody.toString("utf-8") : rawBody;
|
||||
}
|
||||
|
||||
function parseWebhookBody(rawBody?: string | null): WebhookRequestBody | null {
|
||||
if (!rawBody) {
|
||||
return null;
|
||||
}
|
||||
return parseLineWebhookBody(rawBody);
|
||||
}
|
||||
|
||||
export function createLineWebhookMiddleware(
|
||||
options: LineWebhookOptions,
|
||||
): (req: Request, res: Response, _next: NextFunction) => Promise<void> {
|
||||
const { channelSecret, onEvents, runtime } = options;
|
||||
|
||||
return async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const signature = req.headers["x-line-signature"];
|
||||
|
||||
if (!signature || typeof signature !== "string") {
|
||||
res.status(400).json({ error: "Missing X-Line-Signature header" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody = readRawBody(req);
|
||||
|
||||
if (!rawBody) {
|
||||
res.status(400).json({ error: "Missing raw request body for signature verification" });
|
||||
return;
|
||||
}
|
||||
if (Buffer.byteLength(rawBody, "utf-8") > LINE_WEBHOOK_MAX_RAW_BODY_BYTES) {
|
||||
res.status(413).json({ error: "Payload too large" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateLineSignature(rawBody, signature, channelSecret)) {
|
||||
logVerbose("line: webhook signature validation failed");
|
||||
res.status(401).json({ error: "Invalid signature" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep processing tied to the exact bytes that passed signature verification.
|
||||
const body = parseWebhookBody(rawBody);
|
||||
|
||||
if (!body) {
|
||||
res.status(400).json({ error: "Invalid webhook payload" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.events && body.events.length > 0) {
|
||||
logVerbose(`line: received ${body.events.length} webhook events`);
|
||||
await onEvents(body);
|
||||
}
|
||||
|
||||
res.status(200).json({ status: "ok" });
|
||||
} catch (err) {
|
||||
runtime?.error?.(danger(`line webhook error: ${String(err)}`));
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface StartLineWebhookOptions {
|
||||
channelSecret: string;
|
||||
onEvents: (body: WebhookRequestBody) => Promise<void>;
|
||||
runtime?: RuntimeEnv;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export function startLineWebhook(options: StartLineWebhookOptions): {
|
||||
path: string;
|
||||
handler: (req: Request, res: Response, _next: NextFunction) => Promise<void>;
|
||||
} {
|
||||
const channelSecret =
|
||||
typeof options.channelSecret === "string" ? options.channelSecret.trim() : "";
|
||||
if (!channelSecret) {
|
||||
throw new Error(
|
||||
"LINE webhook mode requires a non-empty channel secret. " +
|
||||
"Set channels.line.channelSecret in your config.",
|
||||
);
|
||||
}
|
||||
const path = options.path ?? "/line/webhook";
|
||||
const middleware = createLineWebhookMiddleware({
|
||||
channelSecret,
|
||||
onEvents: options.onEvents,
|
||||
runtime: options.runtime,
|
||||
});
|
||||
|
||||
return { path, handler: middleware };
|
||||
}
|
||||
@@ -163,20 +163,22 @@ describe("applyMediaUnderstanding – echo transcript", () => {
|
||||
vi.doMock("../infra/outbound/deliver-runtime.js", () => ({
|
||||
deliverOutboundPayloads: (...args: unknown[]) => mockDeliverOutboundPayloads(...args),
|
||||
}));
|
||||
vi.doMock("./providers/index.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./providers/index.js")>();
|
||||
const { deepgramProvider } =
|
||||
await import("../../extensions/deepgram/media-understanding-provider.js");
|
||||
const { groqProvider } =
|
||||
await import("../../extensions/groq/media-understanding-provider.js");
|
||||
vi.doMock("./provider-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./provider-registry.js")>();
|
||||
const { deepgramMediaUnderstandingProvider } = await import(
|
||||
"../../extensions/deepgram/media-understanding-provider.js"
|
||||
);
|
||||
const { groqMediaUnderstandingProvider } = await import(
|
||||
"../../extensions/groq/media-understanding-provider.js"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
buildMediaUnderstandingRegistry: (
|
||||
overrides?: Record<string, MediaUnderstandingProvider>,
|
||||
) => {
|
||||
const registry = new Map<string, MediaUnderstandingProvider>([
|
||||
["groq", groqProvider],
|
||||
["deepgram", deepgramProvider],
|
||||
["groq", groqMediaUnderstandingProvider],
|
||||
["deepgram", deepgramMediaUnderstandingProvider],
|
||||
]);
|
||||
for (const [key, provider] of Object.entries(overrides ?? {})) {
|
||||
const normalizedKey = actual.normalizeMediaProviderId(key);
|
||||
|
||||
@@ -246,20 +246,22 @@ describe("applyMediaUnderstanding", () => {
|
||||
vi.doMock("../process/exec.js", () => ({
|
||||
runExec: runExecMock,
|
||||
}));
|
||||
vi.doMock("./providers/index.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./providers/index.js")>();
|
||||
const { deepgramProvider } =
|
||||
await import("../../extensions/deepgram/media-understanding-provider.js");
|
||||
const { groqProvider } =
|
||||
await import("../../extensions/groq/media-understanding-provider.js");
|
||||
vi.doMock("./provider-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./provider-registry.js")>();
|
||||
const { deepgramMediaUnderstandingProvider } = await import(
|
||||
"../../extensions/deepgram/media-understanding-provider.js"
|
||||
);
|
||||
const { groqMediaUnderstandingProvider } = await import(
|
||||
"../../extensions/groq/media-understanding-provider.js"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
buildMediaUnderstandingRegistry: (
|
||||
overrides?: Record<string, MediaUnderstandingProvider>,
|
||||
) => {
|
||||
const registry = new Map<string, MediaUnderstandingProvider>([
|
||||
["groq", groqProvider],
|
||||
["deepgram", deepgramProvider],
|
||||
["groq", groqMediaUnderstandingProvider],
|
||||
["deepgram", deepgramMediaUnderstandingProvider],
|
||||
]);
|
||||
for (const [key, provider] of Object.entries(overrides ?? {})) {
|
||||
const normalizedKey = actual.normalizeMediaProviderId(key);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { detectMime } from "../media/mime.js";
|
||||
import { buildRandomTempFilePath } from "../plugin-sdk/temp-path.js";
|
||||
import { normalizeAttachmentPath } from "./attachments.normalize.js";
|
||||
import { MediaUnderstandingSkipError } from "./errors.js";
|
||||
import { fetchWithTimeout } from "./providers/shared.js";
|
||||
import { fetchWithTimeout } from "./shared.js";
|
||||
import type { MediaAttachment } from "./types.js";
|
||||
|
||||
type MediaBufferResult = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MockInstance } from "vitest";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import * as ssrf from "../../infra/net/ssrf.js";
|
||||
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
|
||||
import * as ssrf from "../infra/net/ssrf.js";
|
||||
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
|
||||
|
||||
export function resolveRequestUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transcribeDeepgramAudio } from "../../../../extensions/deepgram/media-understanding-provider.js";
|
||||
import { isTruthyEnvValue } from "../../../infra/env.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { transcribeDeepgramAudio } from "../../extensions/deepgram/audio.js";
|
||||
|
||||
const DEEPGRAM_KEY = process.env.DEEPGRAM_API_KEY ?? "";
|
||||
const DEEPGRAM_MODEL = process.env.DEEPGRAM_MODEL?.trim() || "nova-3";
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transcribeDeepgramAudio } from "../../../../extensions/deepgram/media-understanding-provider.js";
|
||||
import {
|
||||
createAuthCaptureJsonFetch,
|
||||
createRequestCaptureJsonFetch,
|
||||
installPinnedHostnameTestHooks,
|
||||
} from "../audio.test-helpers.js";
|
||||
} from "./audio.test-helpers.js";
|
||||
import { transcribeDeepgramAudio } from "../../extensions/deepgram/audio.js";
|
||||
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describeGeminiVideo } from "../../../../extensions/google/media-understanding-provider.js";
|
||||
import * as ssrf from "../../../infra/net/ssrf.js";
|
||||
import { withFetchPreconnect } from "../../../test-utils/fetch-mock.js";
|
||||
import { createRequestCaptureJsonFetch } from "../audio.test-helpers.js";
|
||||
import { describeGeminiVideo } from "../../extensions/google/media-understanding-provider.js";
|
||||
import * as ssrf from "../infra/net/ssrf.js";
|
||||
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
|
||||
import { createRequestCaptureJsonFetch } from "./audio.test-helpers.js";
|
||||
|
||||
const TEST_NET_IP = "203.0.113.10";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
createLazyRuntimeMethodBinder,
|
||||
createLazyRuntimeModule,
|
||||
} from "../../shared/lazy-runtime.js";
|
||||
} from "../shared/lazy-runtime.js";
|
||||
|
||||
const loadImageRuntime = createLazyRuntimeModule(() => import("./image.js"));
|
||||
const bindImageRuntime = createLazyRuntimeMethodBinder(loadImageRuntime);
|
||||
@@ -37,7 +37,7 @@ vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/minimax-vlm.js", () => ({
|
||||
vi.mock("../agents/minimax-vlm.js", () => ({
|
||||
isMinimaxVlmProvider: (provider: string) =>
|
||||
provider === "minimax" || provider === "minimax-portal",
|
||||
isMinimaxVlmModel: (provider: string, modelId: string) =>
|
||||
@@ -45,17 +45,17 @@ vi.mock("../../agents/minimax-vlm.js", () => ({
|
||||
minimaxUnderstandImage: minimaxUnderstandImageMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/models-config.js", () => ({
|
||||
vi.mock("../agents/models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: ensureOpenClawModelsJsonMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/model-auth.js", () => ({
|
||||
vi.mock("../agents/model-auth.js", () => ({
|
||||
getApiKeyForModel: getApiKeyForModelMock,
|
||||
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
|
||||
requireApiKey: requireApiKeyMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/pi-model-discovery-runtime.js", () => ({
|
||||
vi.mock("../agents/pi-model-discovery-runtime.js", () => ({
|
||||
discoverAuthStorage: () => ({
|
||||
setRuntimeApiKey: setRuntimeApiKeyMock,
|
||||
}),
|
||||
@@ -1,27 +1,27 @@
|
||||
import type { Api, Context, Model } from "@mariozechner/pi-ai";
|
||||
import { complete } from "@mariozechner/pi-ai";
|
||||
import { isMinimaxVlmModel, minimaxUnderstandImage } from "../../agents/minimax-vlm.js";
|
||||
import { isMinimaxVlmModel, minimaxUnderstandImage } from "../agents/minimax-vlm.js";
|
||||
import {
|
||||
getApiKeyForModel,
|
||||
requireApiKey,
|
||||
resolveApiKeyForProvider,
|
||||
} from "../../agents/model-auth.js";
|
||||
import { normalizeModelRef } from "../../agents/model-selection.js";
|
||||
import { ensureOpenClawModelsJson } from "../../agents/models-config.js";
|
||||
import { coerceImageAssistantText } from "../../agents/tools/image-tool.helpers.js";
|
||||
} from "../agents/model-auth.js";
|
||||
import { normalizeModelRef } from "../agents/model-selection.js";
|
||||
import { ensureOpenClawModelsJson } from "../agents/models-config.js";
|
||||
import { coerceImageAssistantText } from "../agents/tools/image-tool.helpers.js";
|
||||
import type {
|
||||
ImageDescriptionRequest,
|
||||
ImageDescriptionResult,
|
||||
ImagesDescriptionRequest,
|
||||
ImagesDescriptionResult,
|
||||
} from "../types.js";
|
||||
} from "./types.js";
|
||||
|
||||
let piModelDiscoveryRuntimePromise: Promise<
|
||||
typeof import("../../agents/pi-model-discovery-runtime.js")
|
||||
typeof import("../agents/pi-model-discovery-runtime.js")
|
||||
> | null = null;
|
||||
|
||||
function loadPiModelDiscoveryRuntime() {
|
||||
piModelDiscoveryRuntimePromise ??= import("../../agents/pi-model-discovery-runtime.js");
|
||||
piModelDiscoveryRuntimePromise ??= import("../agents/pi-model-discovery-runtime.js");
|
||||
return piModelDiscoveryRuntimePromise;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mistralMediaUnderstandingProvider } from "../../../../extensions/mistral/media-understanding-provider.js";
|
||||
import { mistralMediaUnderstandingProvider } from "../../extensions/mistral/media-understanding-provider.js";
|
||||
import {
|
||||
createRequestCaptureJsonFetch,
|
||||
installPinnedHostnameTestHooks,
|
||||
} from "../audio.test-helpers.js";
|
||||
} from "./audio.test-helpers.js";
|
||||
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeMoonshotVideo } from "../../../../extensions/moonshot/media-understanding-provider.js";
|
||||
import { describeMoonshotVideo } from "../../extensions/moonshot/media-understanding-provider.js";
|
||||
import {
|
||||
createRequestCaptureJsonFetch,
|
||||
installPinnedHostnameTestHooks,
|
||||
} from "../audio.test-helpers.js";
|
||||
} from "./audio.test-helpers.js";
|
||||
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "node:path";
|
||||
import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../types.js";
|
||||
import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "./types.js";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
normalizeBaseUrl,
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transcribeOpenAiAudio } from "../../../../extensions/openai/media-understanding-provider.js";
|
||||
import { transcribeOpenAiAudio } from "../../extensions/openai/media-understanding-provider.js";
|
||||
import {
|
||||
createAuthCaptureJsonFetch,
|
||||
createRequestCaptureJsonFetch,
|
||||
installPinnedHostnameTestHooks,
|
||||
} from "../audio.test-helpers.js";
|
||||
} from "./audio.test-helpers.js";
|
||||
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
|
||||
const { loadOpenClawPluginsMock } = vi.hoisted(() => ({
|
||||
loadOpenClawPluginsMock: vi.fn(() => createEmptyPluginRegistry()),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/loader.js", () => ({
|
||||
loadOpenClawPlugins: loadOpenClawPluginsMock,
|
||||
}));
|
||||
|
||||
import { buildMediaUnderstandingRegistry, getMediaUnderstandingProvider } from "./index.js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { buildMediaUnderstandingRegistry, getMediaUnderstandingProvider } from "./provider-registry.js";
|
||||
|
||||
describe("media-understanding provider registry", () => {
|
||||
afterEach(() => {
|
||||
loadOpenClawPluginsMock.mockReset();
|
||||
loadOpenClawPluginsMock.mockReturnValue(createEmptyPluginRegistry());
|
||||
resetPluginRuntimeStateForTest();
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
it("keeps core-owned fallback providers registered by default", () => {
|
||||
const registry = buildMediaUnderstandingRegistry();
|
||||
const groqProvider = getMediaUnderstandingProvider("groq", registry);
|
||||
const deepgramProvider = getMediaUnderstandingProvider("deepgram", registry);
|
||||
|
||||
expect(groqProvider?.id).toBe("groq");
|
||||
expect(groqProvider?.capabilities).toEqual(["audio"]);
|
||||
expect(deepgramProvider?.id).toBe("deepgram");
|
||||
expect(deepgramProvider?.capabilities).toEqual(["audio"]);
|
||||
});
|
||||
|
||||
it("merges plugin-registered media providers into the active registry", async () => {
|
||||
@@ -60,11 +60,4 @@ describe("media-understanding provider registry", () => {
|
||||
|
||||
expect(provider?.id).toBe("google");
|
||||
});
|
||||
|
||||
it("does not load plugins when config is absent and no runtime registry is active", () => {
|
||||
const registry = buildMediaUnderstandingRegistry();
|
||||
|
||||
expect([...registry.keys()]).toEqual([]);
|
||||
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,15 @@
|
||||
import { normalizeProviderId } from "../../agents/model-selection.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { loadOpenClawPlugins } from "../../plugins/loader.js";
|
||||
import { getActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import type { MediaUnderstandingProvider } from "../types.js";
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadOpenClawPlugins } from "../plugins/loader.js";
|
||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { deepgramMediaUnderstandingProvider } from "../../extensions/deepgram/media-understanding-provider.js";
|
||||
import { groqMediaUnderstandingProvider } from "../../extensions/groq/media-understanding-provider.js";
|
||||
import type { MediaUnderstandingProvider } from "./types.js";
|
||||
|
||||
const PROVIDERS: MediaUnderstandingProvider[] = [
|
||||
groqMediaUnderstandingProvider,
|
||||
deepgramMediaUnderstandingProvider,
|
||||
];
|
||||
|
||||
function mergeProviderIntoRegistry(
|
||||
registry: Map<string, MediaUnderstandingProvider>,
|
||||
@@ -33,15 +40,16 @@ export function buildMediaUnderstandingRegistry(
|
||||
cfg?: OpenClawConfig,
|
||||
): Map<string, MediaUnderstandingProvider> {
|
||||
const registry = new Map<string, MediaUnderstandingProvider>();
|
||||
const active = getActivePluginRegistry();
|
||||
const activeEntries = active?.mediaUnderstandingProviders ?? [];
|
||||
for (const entry of activeEntries) {
|
||||
mergeProviderIntoRegistry(registry, entry.provider);
|
||||
for (const provider of PROVIDERS) {
|
||||
mergeProviderIntoRegistry(registry, provider);
|
||||
}
|
||||
if (activeEntries.length === 0 && cfg) {
|
||||
for (const entry of loadOpenClawPlugins({ config: cfg }).mediaUnderstandingProviders) {
|
||||
mergeProviderIntoRegistry(registry, entry.provider);
|
||||
}
|
||||
const active = getActivePluginRegistry();
|
||||
const pluginRegistry =
|
||||
(active?.mediaUnderstandingProviders?.length ?? 0) > 0
|
||||
? active
|
||||
: loadOpenClawPlugins({ config: cfg });
|
||||
for (const entry of pluginRegistry?.mediaUnderstandingProviders ?? []) {
|
||||
mergeProviderIntoRegistry(registry, entry.provider);
|
||||
}
|
||||
if (overrides) {
|
||||
for (const [key, provider] of Object.entries(overrides)) {
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
DEFAULT_MEDIA_CONCURRENCY,
|
||||
DEFAULT_PROMPT,
|
||||
} from "./defaults.js";
|
||||
import { normalizeMediaProviderId } from "./providers/index.js";
|
||||
import { normalizeMediaProviderId } from "./provider-registry.js";
|
||||
import { normalizeMediaUnderstandingChatType, resolveMediaUnderstandingScope } from "./scope.js";
|
||||
import type { MediaUnderstandingCapability } from "./types.js";
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
import { MediaUnderstandingSkipError } from "./errors.js";
|
||||
import { fileExists } from "./fs.js";
|
||||
import { extractGeminiResponse } from "./output-extract.js";
|
||||
import { describeImageWithModel } from "./providers/image.js";
|
||||
import { getMediaUnderstandingProvider, normalizeMediaProviderId } from "./providers/index.js";
|
||||
import { describeImageWithModel } from "./image.js";
|
||||
import { getMediaUnderstandingProvider, normalizeMediaProviderId } from "./provider-registry.js";
|
||||
import { resolveMaxBytes, resolveMaxChars, resolvePrompt, resolveTimeoutMs } from "./resolve.js";
|
||||
import type {
|
||||
MediaUnderstandingCapability,
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
buildMediaUnderstandingRegistry,
|
||||
getMediaUnderstandingProvider,
|
||||
normalizeMediaProviderId,
|
||||
} from "./providers/index.js";
|
||||
} from "./provider-registry.js";
|
||||
import { resolveModelEntries, resolveScopeDecision } from "./resolve.js";
|
||||
import {
|
||||
buildModelDecision,
|
||||
@@ -494,7 +494,7 @@ export async function resolveAutoImageModel(params: {
|
||||
agentDir?: string;
|
||||
activeModel?: ActiveMediaModel;
|
||||
}): Promise<ActiveMediaModel | null> {
|
||||
const providerRegistry = buildProviderRegistry(undefined, params.cfg);
|
||||
const providerRegistry = buildProviderRegistry();
|
||||
const toActive = (entry: MediaUnderstandingModelConfig | null): ActiveMediaModel | null => {
|
||||
if (!entry || entry.type === "cli") {
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { getMediaUnderstandingProvider } from "./providers/index.js";
|
||||
import { getMediaUnderstandingProvider } from "./provider-registry.js";
|
||||
import {
|
||||
buildProviderRegistry,
|
||||
createMediaAttachmentCache,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { GuardedFetchResult } from "../../infra/net/fetch-guard.js";
|
||||
import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js";
|
||||
import type { LookupFn, SsrFPolicy } from "../../infra/net/ssrf.js";
|
||||
export { fetchWithTimeout } from "../../utils/fetch-timeout.js";
|
||||
import type { GuardedFetchResult } from "../infra/net/fetch-guard.js";
|
||||
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
|
||||
import type { LookupFn, SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
export { fetchWithTimeout } from "../utils/fetch-timeout.js";
|
||||
|
||||
const MAX_ERROR_CHARS = 300;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { OPENAI_DEFAULT_EMBEDDING_MODEL } from "../providers/openai-defaults.js";
|
||||
import { OPENAI_DEFAULT_EMBEDDING_MODEL } from "../plugins/provider-model-defaults.js";
|
||||
import { normalizeEmbeddingModelWithPrefixes } from "./embeddings-model-normalize.js";
|
||||
import {
|
||||
createRemoteEmbeddingProvider,
|
||||
|
||||
@@ -63,7 +63,7 @@ export {
|
||||
resolveServicePrefixedAllowTarget,
|
||||
resolveServicePrefixedTarget,
|
||||
} from "../../extensions/imessage/api.js";
|
||||
export { stripMarkdown } from "../line/markdown-to-line.js";
|
||||
export { stripMarkdown } from "./text-runtime.js";
|
||||
export { parseFiniteNumber } from "../infra/parse-finite-number.js";
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
export type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Shared feedback helpers for typing indicators, ack reactions, and status reactions.
|
||||
export {
|
||||
removeAckReactionAfterReply,
|
||||
shouldAckReaction,
|
||||
@@ -8,6 +7,7 @@ export {
|
||||
type WhatsAppAckReactionMode,
|
||||
} from "../channels/ack-reactions.js";
|
||||
export { logAckFailure, logTypingFailure, type LogFn } from "../channels/logging.js";
|
||||
export { missingTargetError } from "../infra/outbound/target-errors.js";
|
||||
export {
|
||||
CODING_TOOL_TOKENS,
|
||||
createStatusReactionController,
|
||||
|
||||
@@ -10,7 +10,11 @@ export * from "../channels/plugins/normalize/whatsapp.js";
|
||||
export * from "../channels/plugins/outbound/interactive.js";
|
||||
export * from "../channels/plugins/whatsapp-heartbeat.js";
|
||||
export * from "../polls.js";
|
||||
export * from "../whatsapp/normalize.js";
|
||||
export {
|
||||
isWhatsAppGroupJid,
|
||||
isWhatsAppUserTarget,
|
||||
normalizeWhatsAppTarget,
|
||||
} from "../../extensions/whatsapp/src/normalize-target.js";
|
||||
export {
|
||||
createAccountStatusSink,
|
||||
keepHttpServerTaskAlive,
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
export * from "../cli/command-format.js";
|
||||
export * from "../cli/parse-duration.js";
|
||||
export * from "../cli/wait.js";
|
||||
export { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
export * from "../version.js";
|
||||
|
||||
@@ -7,6 +7,8 @@ export {
|
||||
readConfigFileSnapshotForWrite,
|
||||
writeConfigFile,
|
||||
} from "../config/io.js";
|
||||
export { logConfigUpdated } from "../config/logging.js";
|
||||
export { updateConfig } from "../commands/models/shared.js";
|
||||
export { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
export {
|
||||
resolveChannelGroupPolicy,
|
||||
|
||||
@@ -87,6 +87,7 @@ export {
|
||||
unregisterSessionBindingAdapter,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
export * from "../pairing/pairing-challenge.js";
|
||||
export { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
|
||||
export * from "../pairing/pairing-messages.js";
|
||||
export * from "../pairing/pairing-store.js";
|
||||
export {
|
||||
@@ -106,3 +107,4 @@ export {
|
||||
resolvePluginConversationBindingApproval,
|
||||
toPluginConversationBinding,
|
||||
} from "../plugins/conversation-binding.js";
|
||||
export { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export type { LineChannelData, LineConfig } from "../line/types.js";
|
||||
export type { LineChannelData, LineConfig } from "../../extensions/line/api.js";
|
||||
export {
|
||||
createTopLevelChannelDmPolicy,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
@@ -14,10 +14,10 @@ export {
|
||||
normalizeAccountId,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
} from "../line/accounts.js";
|
||||
export { resolveExactLineGroupConfigKey } from "../line/group-keys.js";
|
||||
export type { ResolvedLineAccount } from "../line/types.js";
|
||||
export { LineConfigSchema } from "../line/config-schema.js";
|
||||
} from "../../extensions/line/api.js";
|
||||
export { resolveExactLineGroupConfigKey } from "../../extensions/line/api.js";
|
||||
export type { ResolvedLineAccount } from "../../extensions/line/api.js";
|
||||
export { LineConfigSchema } from "../../extensions/line/api.js";
|
||||
export {
|
||||
createActionCard,
|
||||
createImageCard,
|
||||
@@ -26,5 +26,5 @@ export {
|
||||
createReceiptCard,
|
||||
type CardAction,
|
||||
type ListItem,
|
||||
} from "../line/flex-templates.js";
|
||||
export { processLineMessage } from "../line/markdown-to-line.js";
|
||||
} from "../../extensions/line/api.js";
|
||||
export { processLineMessage } from "../../extensions/line/api.js";
|
||||
|
||||
9
src/plugin-sdk/line-runtime.ts
Normal file
9
src/plugin-sdk/line-runtime.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Private runtime surface for the bundled LINE plugin. Keep runtime ownership
|
||||
// in the plugin package.
|
||||
|
||||
export * from "../../extensions/line/src/bot-access.js";
|
||||
export * from "../../extensions/line/src/bot-handlers.js";
|
||||
export * from "../../extensions/line/src/bot-message-context.js";
|
||||
export * from "../../extensions/line/src/bot.js";
|
||||
export * from "../../extensions/line/src/download.js";
|
||||
export * from "../../extensions/line/src/monitor.js";
|
||||
@@ -31,16 +31,22 @@ export {
|
||||
normalizeAccountId,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
} from "../line/accounts.js";
|
||||
export { LineConfigSchema } from "../line/config-schema.js";
|
||||
export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js";
|
||||
} from "../../extensions/line/api.js";
|
||||
export { LineConfigSchema } from "../../extensions/line/api.js";
|
||||
export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../../extensions/line/api.js";
|
||||
export type { LineProbeResult } from "../../extensions/line/api.js";
|
||||
export {
|
||||
createActionCard,
|
||||
createAgendaCard,
|
||||
createAppleTvRemoteCard,
|
||||
createDeviceControlCard,
|
||||
createEventCard,
|
||||
createImageCard,
|
||||
createInfoCard,
|
||||
createListCard,
|
||||
createMediaPlayerCard,
|
||||
createReceiptCard,
|
||||
type CardAction,
|
||||
type ListItem,
|
||||
} from "../line/flex-templates.js";
|
||||
export { processLineMessage } from "../line/markdown-to-line.js";
|
||||
} from "../../extensions/line/api.js";
|
||||
export { processLineMessage } from "../../extensions/line/api.js";
|
||||
|
||||
@@ -18,7 +18,7 @@ export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js
|
||||
export * from "./agent-media-payload.js";
|
||||
export * from "../media-understanding/audio-preflight.ts";
|
||||
export * from "../media-understanding/defaults.js";
|
||||
export * from "../media-understanding/providers/image-runtime.ts";
|
||||
export * from "../media-understanding/image-runtime.ts";
|
||||
export * from "../media-understanding/runner.js";
|
||||
export * from "../polls.js";
|
||||
export {
|
||||
|
||||
@@ -16,12 +16,12 @@ export type {
|
||||
export {
|
||||
describeImageWithModel,
|
||||
describeImagesWithModel,
|
||||
} from "../media-understanding/providers/image-runtime.js";
|
||||
export { transcribeOpenAiCompatibleAudio } from "../media-understanding/providers/openai-compatible-audio.js";
|
||||
} from "../media-understanding/image-runtime.js";
|
||||
export { transcribeOpenAiCompatibleAudio } from "../media-understanding/openai-compatible-audio.js";
|
||||
export {
|
||||
assertOkOrThrowHttpError,
|
||||
normalizeBaseUrl,
|
||||
postJsonRequest,
|
||||
postTranscriptionRequest,
|
||||
requireTranscriptionText,
|
||||
} from "../media-understanding/providers/shared.js";
|
||||
} from "../media-understanding/shared.js";
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { loginChutes } from "../commands/chutes-oauth.js";
|
||||
export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js";
|
||||
export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
|
||||
export { githubCopilotLoginCommand } from "../../extensions/github-copilot/login.js";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Curated auth + onboarding helpers for provider plugins.
|
||||
// Keep this surface focused on reusable provider-owned login flows.
|
||||
// Public auth/onboarding helpers for provider plugins.
|
||||
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export type { SecretInput } from "../config/types.secrets.js";
|
||||
@@ -16,6 +15,7 @@ export {
|
||||
resolveOAuthApiKeyMarker,
|
||||
resolveNonEnvSecretRefApiKeyMarker,
|
||||
} from "../agents/model-auth-markers.js";
|
||||
export { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js";
|
||||
export {
|
||||
formatApiKeyPreview,
|
||||
normalizeApiKeyInput,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
KILOCODE_DEFAULT_MAX_TOKENS,
|
||||
KILOCODE_DEFAULT_MODEL_ID,
|
||||
KILOCODE_DEFAULT_MODEL_NAME,
|
||||
} from "../providers/kilocode-shared.js";
|
||||
} from "../../extensions/kilocode/shared.js";
|
||||
|
||||
export type { ModelApi, ModelProviderConfig } from "../config/types.models.js";
|
||||
export type { ModelDefinitionConfig } from "../config/types.models.js";
|
||||
@@ -35,7 +35,11 @@ export {
|
||||
applyOpenAIConfig,
|
||||
OPENAI_CODEX_DEFAULT_MODEL,
|
||||
OPENAI_DEFAULT_AUDIO_TRANSCRIPTION_MODEL,
|
||||
OPENAI_DEFAULT_EMBEDDING_MODEL,
|
||||
OPENAI_DEFAULT_IMAGE_MODEL,
|
||||
OPENAI_DEFAULT_MODEL,
|
||||
OPENAI_DEFAULT_TTS_MODEL,
|
||||
OPENAI_DEFAULT_TTS_VOICE,
|
||||
} from "../plugins/provider-model-defaults.js";
|
||||
export { OPENCODE_GO_DEFAULT_MODEL_REF } from "../plugins/provider-model-defaults.js";
|
||||
export { OPENCODE_ZEN_DEFAULT_MODEL } from "../plugins/provider-model-defaults.js";
|
||||
@@ -107,7 +111,7 @@ export {
|
||||
KILOCODE_DEFAULT_MODEL_ID,
|
||||
KILOCODE_DEFAULT_MODEL_NAME,
|
||||
KILOCODE_MODEL_CATALOG,
|
||||
} from "../providers/kilocode-shared.js";
|
||||
} from "../../extensions/kilocode/shared.js";
|
||||
export {
|
||||
discoverVercelAiGatewayModels,
|
||||
VERCEL_AI_GATEWAY_BASE_URL,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user