@elizaos/plugin-webhooks 2.0.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +138 -0
- package/dist/index.js +465 -0
- package/package.json +44 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Plugin } from '@elizaos/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module auth
|
|
5
|
+
* @description Token-based authentication for webhook endpoints.
|
|
6
|
+
*
|
|
7
|
+
* Supports three methods (in priority order):
|
|
8
|
+
* 1. Authorization: Bearer <token>
|
|
9
|
+
* 2. x-otto-token: <token>
|
|
10
|
+
* 3. ?token=<token> (deprecated, logs a warning)
|
|
11
|
+
*/
|
|
12
|
+
declare function extractToken(req: {
|
|
13
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
14
|
+
query?: Record<string, string | string[]>;
|
|
15
|
+
url?: string;
|
|
16
|
+
}): string | undefined;
|
|
17
|
+
declare function validateToken(req: {
|
|
18
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
19
|
+
query?: Record<string, string | string[]>;
|
|
20
|
+
url?: string;
|
|
21
|
+
}, expectedToken: string): boolean;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @module mappings
|
|
25
|
+
* @description Hook mapping resolution and template rendering.
|
|
26
|
+
*
|
|
27
|
+
* Mappings let you define how arbitrary webhook payloads (like Gmail Pub/Sub)
|
|
28
|
+
* are transformed into wake or agent actions.
|
|
29
|
+
*
|
|
30
|
+
* Config shape (from Otto/openclaw):
|
|
31
|
+
* hooks.mappings: [
|
|
32
|
+
* {
|
|
33
|
+
* match: { path: "gmail" },
|
|
34
|
+
* action: "agent",
|
|
35
|
+
* name: "Gmail",
|
|
36
|
+
* sessionKey: "hook:gmail:{{messages[0].id}}",
|
|
37
|
+
* messageTemplate: "New email from {{messages[0].from}}...",
|
|
38
|
+
* wakeMode: "now",
|
|
39
|
+
* deliver: true,
|
|
40
|
+
* channel: "last",
|
|
41
|
+
* }
|
|
42
|
+
* ]
|
|
43
|
+
*/
|
|
44
|
+
interface HookMapping {
|
|
45
|
+
match?: {
|
|
46
|
+
path?: string;
|
|
47
|
+
source?: string;
|
|
48
|
+
};
|
|
49
|
+
action?: "wake" | "agent";
|
|
50
|
+
wakeMode?: "now" | "next-heartbeat";
|
|
51
|
+
name?: string;
|
|
52
|
+
sessionKey?: string;
|
|
53
|
+
messageTemplate?: string;
|
|
54
|
+
textTemplate?: string;
|
|
55
|
+
deliver?: boolean;
|
|
56
|
+
channel?: string;
|
|
57
|
+
to?: string;
|
|
58
|
+
model?: string;
|
|
59
|
+
thinking?: string;
|
|
60
|
+
timeoutSeconds?: number;
|
|
61
|
+
allowUnsafeExternalContent?: boolean;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Find a mapping that matches the given hook name (path segment after /hooks/).
|
|
65
|
+
*/
|
|
66
|
+
declare function findMapping(mappings: HookMapping[], hookName: string, payload: Record<string, unknown>): HookMapping | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Render a Mustache-style template against a data object.
|
|
69
|
+
*
|
|
70
|
+
* Supports:
|
|
71
|
+
* {{field}} -> data.field
|
|
72
|
+
* {{nested.field}} -> data.nested.field
|
|
73
|
+
* {{array[0].field}} -> data.array[0].field
|
|
74
|
+
*
|
|
75
|
+
* Unresolved placeholders are left as-is.
|
|
76
|
+
*/
|
|
77
|
+
declare function renderTemplate(template: string, data: Record<string, unknown>): string;
|
|
78
|
+
/**
|
|
79
|
+
* Apply a mapping to a payload, producing the final wake or agent parameters.
|
|
80
|
+
*/
|
|
81
|
+
declare function applyMapping(mapping: HookMapping, hookName: string, payload: Record<string, unknown>): {
|
|
82
|
+
action: "wake" | "agent";
|
|
83
|
+
text?: string;
|
|
84
|
+
message?: string;
|
|
85
|
+
name?: string;
|
|
86
|
+
sessionKey?: string;
|
|
87
|
+
wakeMode: "now" | "next-heartbeat";
|
|
88
|
+
deliver?: boolean;
|
|
89
|
+
channel?: string;
|
|
90
|
+
to?: string;
|
|
91
|
+
model?: string;
|
|
92
|
+
thinking?: string;
|
|
93
|
+
timeoutSeconds?: number;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @module plugin-webhooks
|
|
98
|
+
* @description ElizaOS plugin for HTTP webhook ingress.
|
|
99
|
+
*
|
|
100
|
+
* Exposes three route groups via the plugin `routes` array:
|
|
101
|
+
* POST /hooks/wake - Enqueue system event + optional immediate heartbeat
|
|
102
|
+
* POST /hooks/agent - Run isolated agent turn + optional delivery
|
|
103
|
+
* POST /hooks/:name - Mapped webhook (resolves via hooks.mappings config)
|
|
104
|
+
*
|
|
105
|
+
* No separate HTTP server is created – routes register on the runtime's
|
|
106
|
+
* existing HTTP server via the Eliza plugin system.
|
|
107
|
+
*
|
|
108
|
+
* Cross-plugin communication:
|
|
109
|
+
* Emits HEARTBEAT_WAKE and HEARTBEAT_SYSTEM_EVENT events which are
|
|
110
|
+
* consumed by @elizaos/plugin-cron's heartbeat worker.
|
|
111
|
+
*
|
|
112
|
+
* @example Config (character.settings):
|
|
113
|
+
* ```json5
|
|
114
|
+
* {
|
|
115
|
+
* hooks: {
|
|
116
|
+
* enabled: true,
|
|
117
|
+
* token: "shared-secret",
|
|
118
|
+
* presets: ["gmail"],
|
|
119
|
+
* mappings: [
|
|
120
|
+
* {
|
|
121
|
+
* match: { path: "github" },
|
|
122
|
+
* action: "agent",
|
|
123
|
+
* name: "GitHub",
|
|
124
|
+
* messageTemplate: "New event: {{action}} on {{repository.full_name}}",
|
|
125
|
+
* wakeMode: "now",
|
|
126
|
+
* deliver: true,
|
|
127
|
+
* channel: "discord",
|
|
128
|
+
* to: "channel:123456789",
|
|
129
|
+
* }
|
|
130
|
+
* ],
|
|
131
|
+
* }
|
|
132
|
+
* }
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
|
|
136
|
+
declare const webhooksPlugin: Plugin;
|
|
137
|
+
|
|
138
|
+
export { type HookMapping, applyMapping, webhooksPlugin as default, extractToken, findMapping, renderTemplate, validateToken, webhooksPlugin };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// src/handlers.ts
|
|
2
|
+
import {
|
|
3
|
+
ChannelType,
|
|
4
|
+
logger as logger2,
|
|
5
|
+
stringToUuid
|
|
6
|
+
} from "@elizaos/core";
|
|
7
|
+
|
|
8
|
+
// ../../../../node_modules/uuid/dist/esm/stringify.js
|
|
9
|
+
var byteToHex = [];
|
|
10
|
+
for (let i = 0; i < 256; ++i) {
|
|
11
|
+
byteToHex.push((i + 256).toString(16).slice(1));
|
|
12
|
+
}
|
|
13
|
+
function unsafeStringify(arr, offset = 0) {
|
|
14
|
+
return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ../../../../node_modules/uuid/dist/esm/rng.js
|
|
18
|
+
import { randomFillSync } from "crypto";
|
|
19
|
+
var rnds8Pool = new Uint8Array(256);
|
|
20
|
+
var poolPtr = rnds8Pool.length;
|
|
21
|
+
function rng() {
|
|
22
|
+
if (poolPtr > rnds8Pool.length - 16) {
|
|
23
|
+
randomFillSync(rnds8Pool);
|
|
24
|
+
poolPtr = 0;
|
|
25
|
+
}
|
|
26
|
+
return rnds8Pool.slice(poolPtr, poolPtr += 16);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ../../../../node_modules/uuid/dist/esm/native.js
|
|
30
|
+
import { randomUUID } from "crypto";
|
|
31
|
+
var native_default = { randomUUID };
|
|
32
|
+
|
|
33
|
+
// ../../../../node_modules/uuid/dist/esm/v4.js
|
|
34
|
+
function v4(options, buf, offset) {
|
|
35
|
+
if (native_default.randomUUID && !buf && !options) {
|
|
36
|
+
return native_default.randomUUID();
|
|
37
|
+
}
|
|
38
|
+
options = options || {};
|
|
39
|
+
const rnds = options.random ?? options.rng?.() ?? rng();
|
|
40
|
+
if (rnds.length < 16) {
|
|
41
|
+
throw new Error("Random bytes length must be >= 16");
|
|
42
|
+
}
|
|
43
|
+
rnds[6] = rnds[6] & 15 | 64;
|
|
44
|
+
rnds[8] = rnds[8] & 63 | 128;
|
|
45
|
+
if (buf) {
|
|
46
|
+
offset = offset || 0;
|
|
47
|
+
if (offset < 0 || offset + 16 > buf.length) {
|
|
48
|
+
throw new RangeError(`UUID byte range ${offset}:${offset + 15} is out of buffer bounds`);
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i < 16; ++i) {
|
|
51
|
+
buf[offset + i] = rnds[i];
|
|
52
|
+
}
|
|
53
|
+
return buf;
|
|
54
|
+
}
|
|
55
|
+
return unsafeStringify(rnds);
|
|
56
|
+
}
|
|
57
|
+
var v4_default = v4;
|
|
58
|
+
|
|
59
|
+
// src/auth.ts
|
|
60
|
+
import { timingSafeEqual } from "crypto";
|
|
61
|
+
import { logger } from "@elizaos/core";
|
|
62
|
+
function extractToken(req) {
|
|
63
|
+
const headers = req.headers ?? {};
|
|
64
|
+
const authHeader = headers.authorization ?? headers.Authorization;
|
|
65
|
+
const authStr = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
|
66
|
+
if (typeof authStr === "string" && authStr.startsWith("Bearer ")) {
|
|
67
|
+
return authStr.slice(7).trim();
|
|
68
|
+
}
|
|
69
|
+
const ottoHeader = headers["x-otto-token"] ?? headers["X-Otto-Token"];
|
|
70
|
+
const ottoStr = Array.isArray(ottoHeader) ? ottoHeader[0] : ottoHeader;
|
|
71
|
+
if (typeof ottoStr === "string" && ottoStr.trim()) {
|
|
72
|
+
return ottoStr.trim();
|
|
73
|
+
}
|
|
74
|
+
let queryToken;
|
|
75
|
+
if (req.query && typeof req.query.token === "string") {
|
|
76
|
+
queryToken = req.query.token;
|
|
77
|
+
} else if (typeof req.url === "string") {
|
|
78
|
+
const url = new URL(req.url, "http://localhost");
|
|
79
|
+
queryToken = url.searchParams.get("token") ?? void 0;
|
|
80
|
+
}
|
|
81
|
+
if (queryToken) {
|
|
82
|
+
logger.warn(
|
|
83
|
+
"[Webhooks] Query-param token auth is deprecated; use Authorization header instead"
|
|
84
|
+
);
|
|
85
|
+
return queryToken.trim();
|
|
86
|
+
}
|
|
87
|
+
return void 0;
|
|
88
|
+
}
|
|
89
|
+
function validateToken(req, expectedToken) {
|
|
90
|
+
const provided = extractToken(req);
|
|
91
|
+
if (!provided) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const providedBuf = Buffer.from(provided, "utf-8");
|
|
95
|
+
const expectedBuf = Buffer.from(expectedToken, "utf-8");
|
|
96
|
+
if (providedBuf.length !== expectedBuf.length) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return timingSafeEqual(providedBuf, expectedBuf);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/mappings.ts
|
|
103
|
+
function findMapping(mappings, hookName, payload) {
|
|
104
|
+
for (const mapping of mappings) {
|
|
105
|
+
if (mapping.match?.path && mapping.match.path === hookName) {
|
|
106
|
+
return mapping;
|
|
107
|
+
}
|
|
108
|
+
if (mapping.match?.source && typeof payload.source === "string") {
|
|
109
|
+
if (payload.source === mapping.match.source) {
|
|
110
|
+
return mapping;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return void 0;
|
|
115
|
+
}
|
|
116
|
+
function renderTemplate(template, data) {
|
|
117
|
+
return template.replace(/\{\{([^}]+)\}\}/g, (_match, expr) => {
|
|
118
|
+
const path = expr.trim();
|
|
119
|
+
const value = resolvePath(data, path);
|
|
120
|
+
if (value === void 0 || value === null) {
|
|
121
|
+
return `{{${expr}}}`;
|
|
122
|
+
}
|
|
123
|
+
if (typeof value === "object") {
|
|
124
|
+
return JSON.stringify(value);
|
|
125
|
+
}
|
|
126
|
+
return String(value);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function resolvePath(obj, path) {
|
|
130
|
+
const normalizedPath = path.replace(/\[(\d+)\]/g, ".$1");
|
|
131
|
+
const parts = normalizedPath.split(".");
|
|
132
|
+
let current = obj;
|
|
133
|
+
for (const part of parts) {
|
|
134
|
+
if (current === null || current === void 0) {
|
|
135
|
+
return void 0;
|
|
136
|
+
}
|
|
137
|
+
if (typeof current === "object") {
|
|
138
|
+
current = current[part];
|
|
139
|
+
} else {
|
|
140
|
+
return void 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return current;
|
|
144
|
+
}
|
|
145
|
+
function applyMapping(mapping, hookName, payload) {
|
|
146
|
+
const action = mapping.action ?? "agent";
|
|
147
|
+
const wakeMode = mapping.wakeMode ?? "now";
|
|
148
|
+
if (action === "wake") {
|
|
149
|
+
const textTemplate = mapping.textTemplate ?? mapping.messageTemplate;
|
|
150
|
+
const text = textTemplate ? renderTemplate(textTemplate, payload) : typeof payload.text === "string" ? payload.text : `Webhook received: ${hookName}`;
|
|
151
|
+
return { action: "wake", text, wakeMode };
|
|
152
|
+
}
|
|
153
|
+
const messageTemplate = mapping.messageTemplate;
|
|
154
|
+
const message = messageTemplate ? renderTemplate(messageTemplate, payload) : typeof payload.message === "string" ? payload.message : `Webhook payload from ${hookName}`;
|
|
155
|
+
const sessionKey = mapping.sessionKey ? renderTemplate(mapping.sessionKey, payload) : `hook:${hookName}:${Date.now()}`;
|
|
156
|
+
return {
|
|
157
|
+
action: "agent",
|
|
158
|
+
message,
|
|
159
|
+
name: mapping.name ?? hookName,
|
|
160
|
+
sessionKey,
|
|
161
|
+
wakeMode,
|
|
162
|
+
deliver: mapping.deliver ?? true,
|
|
163
|
+
channel: mapping.channel ?? "last",
|
|
164
|
+
to: mapping.to,
|
|
165
|
+
model: mapping.model,
|
|
166
|
+
thinking: mapping.thinking,
|
|
167
|
+
timeoutSeconds: mapping.timeoutSeconds
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/handlers.ts
|
|
172
|
+
var GMAIL_PRESET_MAPPING = {
|
|
173
|
+
match: { path: "gmail" },
|
|
174
|
+
action: "agent",
|
|
175
|
+
name: "Gmail",
|
|
176
|
+
sessionKey: "hook:gmail:{{messages[0].id}}",
|
|
177
|
+
messageTemplate: "New email from {{messages[0].from}}\nSubject: {{messages[0].subject}}\n{{messages[0].snippet}}\n{{messages[0].body}}",
|
|
178
|
+
wakeMode: "now",
|
|
179
|
+
deliver: true,
|
|
180
|
+
channel: "last"
|
|
181
|
+
};
|
|
182
|
+
function resolveHooksConfig(runtime) {
|
|
183
|
+
const settings = runtime.character?.settings ?? {};
|
|
184
|
+
const hooks = settings.hooks ?? {};
|
|
185
|
+
if (hooks.enabled === false) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const token = typeof hooks.token === "string" ? hooks.token.trim() : "";
|
|
189
|
+
if (!token) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const rawMappings = Array.isArray(hooks.mappings) ? hooks.mappings : [];
|
|
193
|
+
const mappings = rawMappings.filter(
|
|
194
|
+
(m) => typeof m === "object" && m !== null
|
|
195
|
+
);
|
|
196
|
+
const presets = Array.isArray(hooks.presets) ? hooks.presets.filter((p) => typeof p === "string") : [];
|
|
197
|
+
if (presets.includes("gmail")) {
|
|
198
|
+
const hasGmailMapping = mappings.some((m) => m.match?.path === "gmail");
|
|
199
|
+
if (!hasGmailMapping) {
|
|
200
|
+
mappings.push(GMAIL_PRESET_MAPPING);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { token, mappings, presets };
|
|
204
|
+
}
|
|
205
|
+
async function deliverToChannel(runtime, content, channel, to) {
|
|
206
|
+
let source;
|
|
207
|
+
let channelId;
|
|
208
|
+
if (channel !== "last") {
|
|
209
|
+
source = channel;
|
|
210
|
+
channelId = to;
|
|
211
|
+
} else {
|
|
212
|
+
const internalSources = /* @__PURE__ */ new Set(["cron", "webhook", "heartbeat", "internal"]);
|
|
213
|
+
const rooms = await runtime.getRooms(runtime.agentId).catch(() => []);
|
|
214
|
+
let found = false;
|
|
215
|
+
for (const room of rooms) {
|
|
216
|
+
if (room.source && !internalSources.has(room.source)) {
|
|
217
|
+
source = room.source;
|
|
218
|
+
channelId = to ?? room.channelId ?? void 0;
|
|
219
|
+
found = true;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!found) {
|
|
224
|
+
logger2.warn(`[Webhooks] No delivery target resolved for channel "last"`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
source = source;
|
|
228
|
+
}
|
|
229
|
+
await runtime.sendMessageToTarget({ source, channelId }, content);
|
|
230
|
+
logger2.info(`[Webhooks] Delivered to ${source}${channelId ? `:${channelId}` : ""}`);
|
|
231
|
+
}
|
|
232
|
+
async function emitHeartbeatWake(runtime, text, source) {
|
|
233
|
+
await runtime.emitEvent("HEARTBEAT_WAKE", {
|
|
234
|
+
runtime,
|
|
235
|
+
text,
|
|
236
|
+
source: source ?? "webhook"
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async function emitHeartbeatSystemEvent(runtime, text, source) {
|
|
240
|
+
await runtime.emitEvent("HEARTBEAT_SYSTEM_EVENT", {
|
|
241
|
+
runtime,
|
|
242
|
+
text,
|
|
243
|
+
source: source ?? "webhook"
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
async function runIsolatedAgentTurn(runtime, params) {
|
|
247
|
+
const roomId = stringToUuid(`${runtime.agentId}-${params.sessionKey}`);
|
|
248
|
+
const existing = await runtime.getRoom(roomId);
|
|
249
|
+
if (!existing) {
|
|
250
|
+
await runtime.createRoom({
|
|
251
|
+
id: roomId,
|
|
252
|
+
name: `Hook: ${params.name}`,
|
|
253
|
+
source: "webhook",
|
|
254
|
+
type: ChannelType.GROUP,
|
|
255
|
+
channelId: params.sessionKey
|
|
256
|
+
});
|
|
257
|
+
await runtime.addParticipant(runtime.agentId, roomId);
|
|
258
|
+
}
|
|
259
|
+
const messageId = v4_default();
|
|
260
|
+
const memory = {
|
|
261
|
+
id: messageId,
|
|
262
|
+
entityId: runtime.agentId,
|
|
263
|
+
roomId,
|
|
264
|
+
agentId: runtime.agentId,
|
|
265
|
+
content: { text: `[${params.name}] ${params.message}` },
|
|
266
|
+
createdAt: Date.now()
|
|
267
|
+
};
|
|
268
|
+
let responseText = "";
|
|
269
|
+
const callback = async (response) => {
|
|
270
|
+
if (response.text) {
|
|
271
|
+
responseText += response.text;
|
|
272
|
+
}
|
|
273
|
+
return [];
|
|
274
|
+
};
|
|
275
|
+
const timeoutMs = params.timeoutSeconds ? params.timeoutSeconds * 1e3 : 3e5;
|
|
276
|
+
let timer;
|
|
277
|
+
const timeout = new Promise((_, reject) => {
|
|
278
|
+
timer = setTimeout(() => reject(new Error("Agent turn timeout")), timeoutMs);
|
|
279
|
+
});
|
|
280
|
+
await Promise.race([
|
|
281
|
+
runtime.messageService.handleMessage(runtime, memory, callback),
|
|
282
|
+
timeout
|
|
283
|
+
]).finally(() => clearTimeout(timer));
|
|
284
|
+
return responseText;
|
|
285
|
+
}
|
|
286
|
+
async function handleWake(req, res, runtime) {
|
|
287
|
+
const config = resolveHooksConfig(runtime);
|
|
288
|
+
if (!config) {
|
|
289
|
+
res.status(404).json({ error: "Hooks not enabled" });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!validateToken(req, config.token)) {
|
|
293
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const body = req.body ?? {};
|
|
297
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
298
|
+
if (!text) {
|
|
299
|
+
res.status(400).json({ error: "Missing required field: text" });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const mode = body.mode === "next-heartbeat" ? "next-heartbeat" : "now";
|
|
303
|
+
await emitHeartbeatSystemEvent(runtime, text, "hook:wake");
|
|
304
|
+
if (mode === "now") {
|
|
305
|
+
await emitHeartbeatWake(runtime, void 0, "hook:wake");
|
|
306
|
+
}
|
|
307
|
+
logger2.info(`[Webhooks] /hooks/wake: "${text.slice(0, 80)}" (mode: ${mode})`);
|
|
308
|
+
res.json({ ok: true });
|
|
309
|
+
}
|
|
310
|
+
async function handleAgent(req, res, runtime) {
|
|
311
|
+
const config = resolveHooksConfig(runtime);
|
|
312
|
+
if (!config) {
|
|
313
|
+
res.status(404).json({ error: "Hooks not enabled" });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (!validateToken(req, config.token)) {
|
|
317
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const body = req.body ?? {};
|
|
321
|
+
const message = typeof body.message === "string" ? body.message.trim() : "";
|
|
322
|
+
if (!message) {
|
|
323
|
+
res.status(400).json({ error: "Missing required field: message" });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const name = typeof body.name === "string" ? body.name : "Webhook";
|
|
327
|
+
const sessionKey = typeof body.sessionKey === "string" ? body.sessionKey : `hook:${v4_default()}`;
|
|
328
|
+
const wakeMode = body.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now";
|
|
329
|
+
const deliver = body.deliver !== false;
|
|
330
|
+
const channel = typeof body.channel === "string" ? body.channel : "last";
|
|
331
|
+
const to = typeof body.to === "string" ? body.to : void 0;
|
|
332
|
+
const model = typeof body.model === "string" ? body.model : void 0;
|
|
333
|
+
const timeoutSeconds = typeof body.timeoutSeconds === "number" ? body.timeoutSeconds : void 0;
|
|
334
|
+
logger2.info(`[Webhooks] /hooks/agent: "${message.slice(0, 80)}" (session: ${sessionKey})`);
|
|
335
|
+
const runAsync = async () => {
|
|
336
|
+
const responseText = await runIsolatedAgentTurn(runtime, {
|
|
337
|
+
message,
|
|
338
|
+
name,
|
|
339
|
+
sessionKey,
|
|
340
|
+
model,
|
|
341
|
+
timeoutSeconds
|
|
342
|
+
});
|
|
343
|
+
if (deliver && responseText.trim() && responseText.trim() !== "HEARTBEAT_OK") {
|
|
344
|
+
await deliverToChannel(runtime, { text: responseText }, channel, to).catch(
|
|
345
|
+
(err) => {
|
|
346
|
+
logger2.warn(`[Webhooks] Delivery failed for hook agent: ${err.message}`);
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (responseText.trim() && responseText.trim() !== "HEARTBEAT_OK") {
|
|
351
|
+
const summary = responseText.length > 200 ? `${responseText.slice(0, 200)}\u2026` : responseText;
|
|
352
|
+
await emitHeartbeatSystemEvent(
|
|
353
|
+
runtime,
|
|
354
|
+
`[Hook "${name}" completed] ${summary}`,
|
|
355
|
+
`hook:${name}`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (wakeMode === "now") {
|
|
359
|
+
await emitHeartbeatWake(runtime, void 0, `hook:${name}`);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
runAsync().catch((err) => {
|
|
363
|
+
logger2.error(`[Webhooks] Agent hook run failed: ${err.message}`);
|
|
364
|
+
});
|
|
365
|
+
res.status(202).json({ ok: true, sessionKey });
|
|
366
|
+
}
|
|
367
|
+
async function handleMapped(req, res, runtime) {
|
|
368
|
+
const config = resolveHooksConfig(runtime);
|
|
369
|
+
if (!config) {
|
|
370
|
+
res.status(404).json({ error: "Hooks not enabled" });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (!validateToken(req, config.token)) {
|
|
374
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const hookName = req.params?.name ?? "";
|
|
378
|
+
if (!hookName) {
|
|
379
|
+
res.status(400).json({ error: "Missing hook name" });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const body = req.body ?? {};
|
|
383
|
+
const mapping = findMapping(config.mappings, hookName, body);
|
|
384
|
+
if (!mapping) {
|
|
385
|
+
res.status(404).json({ error: `No mapping found for hook: ${hookName}` });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const resolved = applyMapping(mapping, hookName, body);
|
|
389
|
+
logger2.info(`[Webhooks] /hooks/${hookName}: action=${resolved.action}`);
|
|
390
|
+
if (resolved.action === "wake") {
|
|
391
|
+
await emitHeartbeatSystemEvent(runtime, resolved.text ?? "", `hook:${hookName}`);
|
|
392
|
+
if (resolved.wakeMode === "now") {
|
|
393
|
+
await emitHeartbeatWake(runtime, void 0, `hook:${hookName}`);
|
|
394
|
+
}
|
|
395
|
+
res.json({ ok: true });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const runAsync = async () => {
|
|
399
|
+
const responseText = await runIsolatedAgentTurn(runtime, {
|
|
400
|
+
message: resolved.message ?? "",
|
|
401
|
+
name: resolved.name ?? hookName,
|
|
402
|
+
sessionKey: resolved.sessionKey ?? `hook:${hookName}:${Date.now()}`,
|
|
403
|
+
model: resolved.model,
|
|
404
|
+
timeoutSeconds: resolved.timeoutSeconds
|
|
405
|
+
});
|
|
406
|
+
if (resolved.deliver && responseText.trim() && responseText.trim() !== "HEARTBEAT_OK") {
|
|
407
|
+
await deliverToChannel(
|
|
408
|
+
runtime,
|
|
409
|
+
{ text: responseText },
|
|
410
|
+
resolved.channel ?? "last",
|
|
411
|
+
resolved.to
|
|
412
|
+
).catch((err) => {
|
|
413
|
+
logger2.warn(`[Webhooks] Delivery failed for mapped hook "${hookName}": ${err.message}`);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (responseText.trim() && responseText.trim() !== "HEARTBEAT_OK") {
|
|
417
|
+
const summary = responseText.length > 200 ? `${responseText.slice(0, 200)}\u2026` : responseText;
|
|
418
|
+
await emitHeartbeatSystemEvent(
|
|
419
|
+
runtime,
|
|
420
|
+
`[Hook "${resolved.name ?? hookName}" completed] ${summary}`,
|
|
421
|
+
`hook:${hookName}`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
if (resolved.wakeMode === "now") {
|
|
425
|
+
await emitHeartbeatWake(runtime, void 0, `hook:${hookName}`);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
runAsync().catch((err) => {
|
|
429
|
+
logger2.error(`[Webhooks] Mapped hook "${hookName}" run failed: ${err.message}`);
|
|
430
|
+
});
|
|
431
|
+
res.status(202).json({ ok: true });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// src/index.ts
|
|
435
|
+
var webhooksPlugin = {
|
|
436
|
+
name: "webhooks",
|
|
437
|
+
description: "HTTP webhook ingress for external triggers",
|
|
438
|
+
routes: [
|
|
439
|
+
{
|
|
440
|
+
type: "POST",
|
|
441
|
+
path: "/hooks/wake",
|
|
442
|
+
handler: handleWake
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
type: "POST",
|
|
446
|
+
path: "/hooks/agent",
|
|
447
|
+
handler: handleAgent
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
type: "POST",
|
|
451
|
+
path: "/hooks/:name",
|
|
452
|
+
handler: handleMapped
|
|
453
|
+
}
|
|
454
|
+
]
|
|
455
|
+
};
|
|
456
|
+
var index_default = webhooksPlugin;
|
|
457
|
+
export {
|
|
458
|
+
applyMapping,
|
|
459
|
+
index_default as default,
|
|
460
|
+
extractToken,
|
|
461
|
+
findMapping,
|
|
462
|
+
renderTemplate,
|
|
463
|
+
validateToken,
|
|
464
|
+
webhooksPlugin
|
|
465
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elizaos/plugin-webhooks",
|
|
3
|
+
"version": "2.0.0-alpha.3",
|
|
4
|
+
"description": "HTTP webhook ingress for external triggers (wake, agent, mapped hooks)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.ts --format esm --dts",
|
|
20
|
+
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"clean": "rm -rf dist .turbo node_modules",
|
|
23
|
+
"lint": "bunx @biomejs/biome check --write --unsafe .",
|
|
24
|
+
"lint:check": "bunx @biomejs/biome check .",
|
|
25
|
+
"format": "bunx @biomejs/biome format --write .",
|
|
26
|
+
"format:check": "bunx @biomejs/biome format .",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@elizaos/core": "2.0.0-alpha.3"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@elizaos/core": "2.0.0-alpha.3"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"tsup": "^8.0.0",
|
|
37
|
+
"typescript": "^5.0.0",
|
|
38
|
+
"vitest": "^3.0.0",
|
|
39
|
+
"@biomejs/biome": "^2.3.11"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|