feat: Add Line plugin (#1630)

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

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

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

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

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

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

---------

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

View File

@@ -6,7 +6,11 @@
*/
import type { ClawdbotConfig } from "../config/config.js";
import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js";
import type {
ClawdbotPluginCommandDefinition,
PluginCommandContext,
PluginCommandResult,
} from "./types.js";
import { logVerbose } from "../globals.js";
type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & {
@@ -218,7 +222,7 @@ export async function executePluginCommand(params: {
isAuthorizedSender: boolean;
commandBody: string;
config: ClawdbotConfig;
}): Promise<{ text: string }> {
}): Promise<PluginCommandResult> {
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
// Check authorization
@@ -249,7 +253,7 @@ export async function executePluginCommand(params: {
logVerbose(
`Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`,
);
return { text: result.text };
return result;
} catch (err) {
const error = err as Error;
logVerbose(`Plugin command /${command.name} error: ${error.message}`);

12
src/plugins/http-path.ts Normal file
View File

@@ -0,0 +1,12 @@
export function normalizePluginHttpPath(
path?: string | null,
fallback?: string | null,
): string | null {
const trimmed = path?.trim();
if (!trimmed) {
const fallbackTrimmed = fallback?.trim();
if (!fallbackTrimmed) return null;
return fallbackTrimmed.startsWith("/") ? fallbackTrimmed : `/${fallbackTrimmed}`;
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}

View File

@@ -0,0 +1,53 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { PluginHttpRouteRegistration, PluginRegistry } from "./registry.js";
import { requireActivePluginRegistry } from "./runtime.js";
import { normalizePluginHttpPath } from "./http-path.js";
export type PluginHttpRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<void> | void;
export function registerPluginHttpRoute(params: {
path?: string | null;
fallbackPath?: string | null;
handler: PluginHttpRouteHandler;
pluginId?: string;
source?: string;
accountId?: string;
log?: (message: string) => void;
registry?: PluginRegistry;
}): () => void {
const registry = params.registry ?? requireActivePluginRegistry();
const routes = registry.httpRoutes ?? [];
registry.httpRoutes = routes;
const normalizedPath = normalizePluginHttpPath(params.path, params.fallbackPath);
const suffix = params.accountId ? ` for account "${params.accountId}"` : "";
if (!normalizedPath) {
params.log?.(`plugin: webhook path missing${suffix}`);
return () => {};
}
if (routes.some((entry) => entry.path === normalizedPath)) {
const pluginHint = params.pluginId ? ` (${params.pluginId})` : "";
params.log?.(`plugin: webhook path ${normalizedPath} already registered${suffix}${pluginHint}`);
return () => {};
}
const entry: PluginHttpRouteRegistration = {
path: normalizedPath,
handler: params.handler,
pluginId: params.pluginId,
source: params.source,
};
routes.push(entry);
return () => {
const index = routes.indexOf(entry);
if (index >= 0) {
routes.splice(index, 1);
}
};
}

View File

@@ -350,6 +350,33 @@ describe("loadClawdbotPlugins", () => {
expect(httpPlugin?.httpHandlers).toBe(1);
});
it("registers http routes", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
id: "http-route-demo",
body: `export default { id: "http-route-demo", register(api) {
api.registerHttpRoute({ path: "/demo", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } });
} };`,
});
const registry = loadClawdbotPlugins({
cache: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["http-route-demo"],
},
},
});
const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-route-demo");
expect(route).toBeDefined();
expect(route?.path).toBe("/demo");
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-route-demo");
expect(httpPlugin?.httpHandlers).toBe(1);
});
it("respects explicit disable in config", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({

View File

@@ -13,6 +13,7 @@ import type {
ClawdbotPluginCliRegistrar,
ClawdbotPluginCommandDefinition,
ClawdbotPluginHttpHandler,
ClawdbotPluginHttpRouteHandler,
ClawdbotPluginHookOptions,
ProviderPlugin,
ClawdbotPluginService,
@@ -31,6 +32,7 @@ import { registerPluginCommand } from "./commands.js";
import type { PluginRuntime } from "./runtime/types.js";
import type { HookEntry } from "../hooks/types.js";
import path from "node:path";
import { normalizePluginHttpPath } from "./http-path.js";
export type PluginToolRegistration = {
pluginId: string;
@@ -53,6 +55,13 @@ export type PluginHttpRegistration = {
source: string;
};
export type PluginHttpRouteRegistration = {
pluginId?: string;
path: string;
handler: ClawdbotPluginHttpRouteHandler;
source?: string;
};
export type PluginChannelRegistration = {
pluginId: string;
plugin: ChannelPlugin;
@@ -121,6 +130,7 @@ export type PluginRegistry = {
providers: PluginProviderRegistration[];
gatewayHandlers: GatewayRequestHandlers;
httpHandlers: PluginHttpRegistration[];
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
services: PluginServiceRegistration[];
commands: PluginCommandRegistration[];
@@ -143,6 +153,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
@@ -280,6 +291,38 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
};
const registerHttpRoute = (
record: PluginRecord,
params: { path: string; handler: ClawdbotPluginHttpRouteHandler },
) => {
const normalizedPath = normalizePluginHttpPath(params.path);
if (!normalizedPath) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: "http route registration missing path",
});
return;
}
if (registry.httpRoutes.some((entry) => entry.path === normalizedPath)) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `http route already registered: ${normalizedPath}`,
});
return;
}
record.httpHandlers += 1;
registry.httpRoutes.push({
pluginId: record.id,
path: normalizedPath,
handler: params.handler,
source: record.source,
});
};
const registerChannel = (
record: PluginRecord,
registration: ClawdbotPluginChannelRegistration | ChannelPlugin,
@@ -439,6 +482,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerHook: (events, handler, opts) =>
registerHook(record, events, handler, opts, params.config),
registerHttpHandler: (handler) => registerHttpHandler(record, handler),
registerHttpRoute: (params) => registerHttpRoute(record, params),
registerChannel: (registration) => registerChannel(record, registration),
registerProvider: (provider) => registerProvider(record, provider),
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),

View File

@@ -9,6 +9,7 @@ const createEmptyRegistry = (): PluginRegistry => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],

View File

@@ -125,6 +125,25 @@ import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
import { registerMemoryCli } from "../../cli/memory-cli.js";
import { formatNativeDependencyHint } from "./native-deps.js";
import { textToSpeechTelephony } from "../../tts/tts.js";
import {
listLineAccountIds,
normalizeAccountId as normalizeLineAccountId,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "../../line/accounts.js";
import { probeLineBot } from "../../line/probe.js";
import {
createQuickReplyItems,
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
sendMessageLine,
} from "../../line/send.js";
import { monitorLineProvider } from "../../line/monitor.js";
import { buildTemplateMessageFromPayload } from "../../line/template-messages.js";
import type { PluginRuntime } from "./types.js";
@@ -299,6 +318,23 @@ export function createPluginRuntime(): PluginRuntime {
handleWhatsAppAction,
createLoginTool: createWhatsAppLoginTool,
},
line: {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
normalizeAccountId: normalizeLineAccountId,
probeLineBot,
sendMessageLine,
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
monitorLineProvider,
},
},
logging: {
shouldLogVerbose,

View File

@@ -148,6 +148,26 @@ type HandleWhatsAppAction =
type CreateWhatsAppLoginTool =
typeof import("../../channels/plugins/agent-tools/whatsapp-login.js").createWhatsAppLoginTool;
// LINE channel types
type ListLineAccountIds = typeof import("../../line/accounts.js").listLineAccountIds;
type ResolveDefaultLineAccountId =
typeof import("../../line/accounts.js").resolveDefaultLineAccountId;
type ResolveLineAccount = typeof import("../../line/accounts.js").resolveLineAccount;
type NormalizeLineAccountId = typeof import("../../line/accounts.js").normalizeAccountId;
type ProbeLineBot = typeof import("../../line/probe.js").probeLineBot;
type SendMessageLine = typeof import("../../line/send.js").sendMessageLine;
type PushMessageLine = typeof import("../../line/send.js").pushMessageLine;
type PushMessagesLine = typeof import("../../line/send.js").pushMessagesLine;
type PushFlexMessage = typeof import("../../line/send.js").pushFlexMessage;
type PushTemplateMessage = typeof import("../../line/send.js").pushTemplateMessage;
type PushLocationMessage = typeof import("../../line/send.js").pushLocationMessage;
type PushTextMessageWithQuickReplies =
typeof import("../../line/send.js").pushTextMessageWithQuickReplies;
type CreateQuickReplyItems = typeof import("../../line/send.js").createQuickReplyItems;
type BuildTemplateMessageFromPayload =
typeof import("../../line/template-messages.js").buildTemplateMessageFromPayload;
type MonitorLineProvider = typeof import("../../line/monitor.js").monitorLineProvider;
export type RuntimeLogger = {
debug?: (message: string) => void;
info: (message: string) => void;
@@ -310,6 +330,23 @@ export type PluginRuntime = {
handleWhatsAppAction: HandleWhatsAppAction;
createLoginTool: CreateWhatsAppLoginTool;
};
line: {
listLineAccountIds: ListLineAccountIds;
resolveDefaultLineAccountId: ResolveDefaultLineAccountId;
resolveLineAccount: ResolveLineAccount;
normalizeAccountId: NormalizeLineAccountId;
probeLineBot: ProbeLineBot;
sendMessageLine: SendMessageLine;
pushMessageLine: PushMessageLine;
pushMessagesLine: PushMessagesLine;
pushFlexMessage: PushFlexMessage;
pushTemplateMessage: PushTemplateMessage;
pushLocationMessage: PushLocationMessage;
pushTextMessageWithQuickReplies: PushTextMessageWithQuickReplies;
createQuickReplyItems: CreateQuickReplyItems;
buildTemplateMessageFromPayload: BuildTemplateMessageFromPayload;
monitorLineProvider: MonitorLineProvider;
};
};
logging: {
shouldLogVerbose: ShouldLogVerbose;

View File

@@ -12,6 +12,7 @@ import type { InternalHookHandler } from "../hooks/internal-hooks.js";
import type { HookEntry } from "../hooks/types.js";
import type { ModelProviderConfig } from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { createVpsAwareOAuthHandlers } from "../commands/oauth-flow.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
@@ -154,10 +155,7 @@ export type PluginCommandContext = {
/**
* Result returned by a plugin command handler.
*/
export type PluginCommandResult = {
/** Text response to send back to the user */
text: string;
};
export type PluginCommandResult = ReplyPayload;
/**
* Handler function for plugin commands.
@@ -187,6 +185,11 @@ export type ClawdbotPluginHttpHandler = (
res: ServerResponse,
) => Promise<boolean> | boolean;
export type ClawdbotPluginHttpRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<void> | void;
export type ClawdbotPluginCliContext = {
program: Command;
config: ClawdbotConfig;
@@ -249,6 +252,7 @@ export type ClawdbotPluginApi = {
opts?: ClawdbotPluginHookOptions,
) => void;
registerHttpHandler: (handler: ClawdbotPluginHttpHandler) => void;
registerHttpRoute: (params: { path: string; handler: ClawdbotPluginHttpRouteHandler }) => void;
registerChannel: (registration: ClawdbotPluginChannelRegistration | ChannelPlugin) => void;
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void;
registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void;