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:
@@ -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
12
src/plugins/http-path.ts
Normal 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}`;
|
||||
}
|
||||
53
src/plugins/http-registry.ts
Normal file
53
src/plugins/http-registry.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -9,6 +9,7 @@ const createEmptyRegistry = (): PluginRegistry => ({
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user