openclaw-custom-channel 2026.2.26
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/index.ts +17 -0
- package/package.json +26 -0
- package/src/accounts.ts +79 -0
- package/src/channel.ts +391 -0
- package/src/client.ts +113 -0
- package/src/runtime.ts +19 -0
- package/src/security.ts +114 -0
- package/src/types.ts +60 -0
- package/src/webhook-handler.ts +204 -0
package/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
+
import { createCustomChannelPlugin } from "./src/channel.js";
|
|
4
|
+
import { setCustomRuntime } from "./src/runtime.js";
|
|
5
|
+
|
|
6
|
+
const plugin = {
|
|
7
|
+
id: "custom",
|
|
8
|
+
name: "Custom",
|
|
9
|
+
description: "Custom channel plugin for OpenClaw - webhook-based chat and group chat",
|
|
10
|
+
configSchema: emptyPluginConfigSchema(),
|
|
11
|
+
register(api: OpenClawPluginApi) {
|
|
12
|
+
setCustomRuntime(api.runtime);
|
|
13
|
+
api.registerChannel({ plugin: createCustomChannelPlugin() });
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-custom-channel",
|
|
3
|
+
"version": "2026.2.26",
|
|
4
|
+
"description": "Custom channel plugin for OpenClaw - webhook-based integration for chat and group chat",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"zod": "^4.3.6"
|
|
8
|
+
},
|
|
9
|
+
"openclaw": {
|
|
10
|
+
"extensions": ["./index.ts"],
|
|
11
|
+
"channel": {
|
|
12
|
+
"id": "custom",
|
|
13
|
+
"label": "Custom",
|
|
14
|
+
"selectionLabel": "Custom (Webhook)",
|
|
15
|
+
"docsPath": "/channels/custom",
|
|
16
|
+
"docsLabel": "custom",
|
|
17
|
+
"blurb": "Connect your own chat backend via webhook for direct and group messages.",
|
|
18
|
+
"order": 95
|
|
19
|
+
},
|
|
20
|
+
"install": {
|
|
21
|
+
"npmSpec": "openclaw-custom-channel",
|
|
22
|
+
"localPath": "extensions/custom",
|
|
23
|
+
"defaultChoice": "npm"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account resolution: reads config from channels.custom,
|
|
3
|
+
* merges per-account overrides, falls back to environment variables.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CustomChannelConfig, ResolvedCustomAccount } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const CHANNEL_ID = "custom";
|
|
9
|
+
|
|
10
|
+
function getChannelConfig(cfg: unknown): CustomChannelConfig | undefined {
|
|
11
|
+
const channels = (cfg as Record<string, unknown>)?.channels as Record<string, unknown> | undefined;
|
|
12
|
+
return channels?.[CHANNEL_ID] as CustomChannelConfig | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseAllowedUserIds(raw: string | string[] | undefined): string[] {
|
|
16
|
+
if (!raw) return [];
|
|
17
|
+
if (Array.isArray(raw)) return raw.filter(Boolean).map(String);
|
|
18
|
+
return raw
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((s) => s.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function listAccountIds(cfg: unknown): string[] {
|
|
25
|
+
const channelCfg = getChannelConfig(cfg);
|
|
26
|
+
if (!channelCfg) return [];
|
|
27
|
+
|
|
28
|
+
const ids = new Set<string>();
|
|
29
|
+
|
|
30
|
+
const hasBaseToken = channelCfg.token || process.env.CUSTOM_CHANNEL_TOKEN;
|
|
31
|
+
if (hasBaseToken) {
|
|
32
|
+
ids.add("default");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (channelCfg.accounts) {
|
|
36
|
+
for (const id of Object.keys(channelCfg.accounts)) {
|
|
37
|
+
ids.add(id);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return Array.from(ids);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveAccount(
|
|
45
|
+
cfg: unknown,
|
|
46
|
+
accountId?: string | null,
|
|
47
|
+
): ResolvedCustomAccount {
|
|
48
|
+
const channelCfg = getChannelConfig(cfg) ?? {};
|
|
49
|
+
const id = accountId || "default";
|
|
50
|
+
|
|
51
|
+
const accountOverride = channelCfg.accounts?.[id] ?? {};
|
|
52
|
+
|
|
53
|
+
const envToken = process.env.CUSTOM_CHANNEL_TOKEN ?? "";
|
|
54
|
+
const envIncomingUrl = process.env.CUSTOM_CHANNEL_INCOMING_URL ?? "";
|
|
55
|
+
const envAllowFrom = process.env.CUSTOM_CHANNEL_ALLOW_FROM ?? "";
|
|
56
|
+
const envBotName = process.env.OPENCLAW_BOT_NAME ?? "OpenClaw";
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
accountId: id,
|
|
60
|
+
enabled: accountOverride.enabled ?? channelCfg.enabled ?? true,
|
|
61
|
+
token: accountOverride.token ?? channelCfg.token ?? envToken,
|
|
62
|
+
incomingUrl:
|
|
63
|
+
accountOverride.incomingUrl ?? channelCfg.incomingUrl ?? envIncomingUrl,
|
|
64
|
+
webhookPath:
|
|
65
|
+
accountOverride.webhookPath ?? channelCfg.webhookPath ?? "/webhook/custom",
|
|
66
|
+
dmPolicy:
|
|
67
|
+
accountOverride.dmPolicy ?? channelCfg.dmPolicy ?? "allowlist",
|
|
68
|
+
allowedUserIds: parseAllowedUserIds(
|
|
69
|
+
accountOverride.allowFrom ?? channelCfg.allowFrom ?? envAllowFrom,
|
|
70
|
+
),
|
|
71
|
+
rateLimitPerMinute:
|
|
72
|
+
accountOverride.rateLimitPerMinute ??
|
|
73
|
+
channelCfg.rateLimitPerMinute ??
|
|
74
|
+
30,
|
|
75
|
+
botName: accountOverride.botName ?? channelCfg.botName ?? envBotName,
|
|
76
|
+
allowInsecureSsl:
|
|
77
|
+
accountOverride.allowInsecureSsl ?? channelCfg.allowInsecureSsl ?? false,
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Channel Plugin for OpenClaw.
|
|
3
|
+
*
|
|
4
|
+
* Webhook-based integration supporting direct chat and group chat.
|
|
5
|
+
* Adapt webhook payload format and client.sendMessage to your backend API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_ACCOUNT_ID,
|
|
11
|
+
setAccountEnabledInConfigSection,
|
|
12
|
+
registerPluginHttpRoute,
|
|
13
|
+
buildChannelConfigSchema,
|
|
14
|
+
} from "openclaw/plugin-sdk";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { listAccountIds, resolveAccount } from "./accounts.js";
|
|
17
|
+
import { sendMessage } from "./client.js";
|
|
18
|
+
import { getCustomRuntime } from "./runtime.js";
|
|
19
|
+
import type { ResolvedCustomAccount } from "./types.js";
|
|
20
|
+
import { createWebhookHandler } from "./webhook-handler.js";
|
|
21
|
+
|
|
22
|
+
const CHANNEL_ID = "custom";
|
|
23
|
+
|
|
24
|
+
const CustomConfigSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
enabled: z.boolean().optional(),
|
|
27
|
+
token: z.string().optional(),
|
|
28
|
+
incomingUrl: z.string().url().optional(),
|
|
29
|
+
webhookPath: z.string().optional(),
|
|
30
|
+
dmPolicy: z.enum(["open", "allowlist", "pairing", "disabled"]).optional(),
|
|
31
|
+
allowFrom: z.union([z.string(), z.array(z.string())]).optional(),
|
|
32
|
+
rateLimitPerMinute: z.number().optional(),
|
|
33
|
+
botName: z.string().optional(),
|
|
34
|
+
allowInsecureSsl: z.boolean().optional(),
|
|
35
|
+
})
|
|
36
|
+
.passthrough();
|
|
37
|
+
|
|
38
|
+
const CustomChannelConfigSchema = buildChannelConfigSchema(CustomConfigSchema);
|
|
39
|
+
|
|
40
|
+
const activeRouteUnregisters = new Map<string, () => void>();
|
|
41
|
+
|
|
42
|
+
export function createCustomChannelPlugin() {
|
|
43
|
+
return {
|
|
44
|
+
id: CHANNEL_ID,
|
|
45
|
+
|
|
46
|
+
meta: {
|
|
47
|
+
id: CHANNEL_ID,
|
|
48
|
+
label: "Custom",
|
|
49
|
+
selectionLabel: "Custom (Webhook)",
|
|
50
|
+
detailLabel: "Custom (Webhook)",
|
|
51
|
+
docsPath: "/channels/custom",
|
|
52
|
+
blurb: "Connect your own chat backend via webhook for direct and group messages",
|
|
53
|
+
order: 95,
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
capabilities: {
|
|
57
|
+
chatTypes: ["direct" as const, "group" as const],
|
|
58
|
+
media: true,
|
|
59
|
+
threads: false,
|
|
60
|
+
reactions: false,
|
|
61
|
+
edit: false,
|
|
62
|
+
unsend: false,
|
|
63
|
+
reply: false,
|
|
64
|
+
effects: false,
|
|
65
|
+
blockStreaming: false,
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
69
|
+
|
|
70
|
+
configSchema: CustomChannelConfigSchema,
|
|
71
|
+
|
|
72
|
+
config: {
|
|
73
|
+
listAccountIds: (cfg: unknown) => listAccountIds(cfg),
|
|
74
|
+
resolveAccount: (cfg: unknown, accountId?: string | null) =>
|
|
75
|
+
resolveAccount(cfg, accountId),
|
|
76
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
77
|
+
setAccountEnabled: ({
|
|
78
|
+
cfg,
|
|
79
|
+
accountId,
|
|
80
|
+
enabled,
|
|
81
|
+
}: {
|
|
82
|
+
cfg: unknown;
|
|
83
|
+
accountId: string;
|
|
84
|
+
enabled: boolean;
|
|
85
|
+
}) =>
|
|
86
|
+
setAccountEnabledInConfigSection({
|
|
87
|
+
cfg: cfg as OpenClawConfig,
|
|
88
|
+
sectionKey: CHANNEL_ID,
|
|
89
|
+
accountId,
|
|
90
|
+
enabled,
|
|
91
|
+
allowTopLevel: true,
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
pairing: {
|
|
96
|
+
idLabel: "customUserId",
|
|
97
|
+
normalizeAllowEntry: (entry: string) => entry.toLowerCase().trim(),
|
|
98
|
+
notifyApproval: async ({
|
|
99
|
+
cfg,
|
|
100
|
+
id,
|
|
101
|
+
}: {
|
|
102
|
+
cfg: unknown;
|
|
103
|
+
id: string;
|
|
104
|
+
}) => {
|
|
105
|
+
const account = resolveAccount(cfg);
|
|
106
|
+
if (!account.incomingUrl) return;
|
|
107
|
+
await sendMessage(
|
|
108
|
+
account.incomingUrl,
|
|
109
|
+
"OpenClaw: your access has been approved.",
|
|
110
|
+
id,
|
|
111
|
+
account.allowInsecureSsl,
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
security: {
|
|
117
|
+
resolveDmPolicy: ({
|
|
118
|
+
cfg,
|
|
119
|
+
accountId,
|
|
120
|
+
account,
|
|
121
|
+
}: {
|
|
122
|
+
cfg: unknown;
|
|
123
|
+
accountId?: string | null;
|
|
124
|
+
account: ResolvedCustomAccount;
|
|
125
|
+
}) => {
|
|
126
|
+
const resolvedAccountId =
|
|
127
|
+
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
128
|
+
const channelCfg = (cfg as Record<string, unknown>)?.channels?.[
|
|
129
|
+
CHANNEL_ID
|
|
130
|
+
] as Record<string, unknown> | undefined;
|
|
131
|
+
const useAccountPath = Boolean(channelCfg?.accounts?.[resolvedAccountId]);
|
|
132
|
+
const basePath = useAccountPath
|
|
133
|
+
? `channels.${CHANNEL_ID}.accounts.${resolvedAccountId}.`
|
|
134
|
+
: `channels.${CHANNEL_ID}.`;
|
|
135
|
+
return {
|
|
136
|
+
policy: account.dmPolicy ?? "allowlist",
|
|
137
|
+
allowFrom: account.allowedUserIds,
|
|
138
|
+
policyPath: `${basePath}dmPolicy`,
|
|
139
|
+
allowFromPath: basePath,
|
|
140
|
+
approveHint: "openclaw pairing approve custom <code>",
|
|
141
|
+
normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
collectWarnings: ({ account }: { account: ResolvedCustomAccount }) => {
|
|
145
|
+
const warnings: string[] = [];
|
|
146
|
+
if (!account.token && account.dmPolicy !== "open") {
|
|
147
|
+
warnings.push(
|
|
148
|
+
"- Custom: token not configured. Consider adding token for webhook validation.",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (!account.incomingUrl) {
|
|
152
|
+
warnings.push(
|
|
153
|
+
"- Custom: incomingUrl not configured. The bot cannot send replies.",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (account.dmPolicy === "open") {
|
|
157
|
+
warnings.push(
|
|
158
|
+
'- Custom: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production.',
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (
|
|
162
|
+
account.dmPolicy === "allowlist" &&
|
|
163
|
+
account.allowedUserIds.length === 0
|
|
164
|
+
) {
|
|
165
|
+
warnings.push(
|
|
166
|
+
'- Custom: dmPolicy="allowlist" with empty allowFrom blocks all senders. Add users or set dmPolicy="open".',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return warnings;
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
messaging: {
|
|
174
|
+
normalizeTarget: (target: string) => {
|
|
175
|
+
const trimmed = target.trim();
|
|
176
|
+
if (!trimmed) return undefined;
|
|
177
|
+
return trimmed.replace(/^custom:/i, "").trim();
|
|
178
|
+
},
|
|
179
|
+
targetResolver: {
|
|
180
|
+
looksLikeId: (id: string) => {
|
|
181
|
+
const trimmed = id?.trim();
|
|
182
|
+
if (!trimmed) return false;
|
|
183
|
+
return /^custom:/i.test(trimmed) || trimmed.length > 0;
|
|
184
|
+
},
|
|
185
|
+
hint: "<userId> or <groupId>",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
directory: {
|
|
190
|
+
self: async () => null,
|
|
191
|
+
listPeers: async () => [],
|
|
192
|
+
listGroups: async () => [],
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
outbound: {
|
|
196
|
+
deliveryMode: "gateway" as const,
|
|
197
|
+
textChunkLimit: 2000,
|
|
198
|
+
|
|
199
|
+
sendText: async ({
|
|
200
|
+
to,
|
|
201
|
+
text,
|
|
202
|
+
accountId,
|
|
203
|
+
account: ctxAccount,
|
|
204
|
+
}: {
|
|
205
|
+
to: string;
|
|
206
|
+
text: string;
|
|
207
|
+
accountId?: string | null;
|
|
208
|
+
account?: ResolvedCustomAccount;
|
|
209
|
+
}) => {
|
|
210
|
+
const account: ResolvedCustomAccount =
|
|
211
|
+
ctxAccount ?? resolveAccount({}, accountId);
|
|
212
|
+
|
|
213
|
+
if (!account.incomingUrl) {
|
|
214
|
+
throw new Error("Custom channel incoming URL not configured");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const ok = await sendMessage(
|
|
218
|
+
account.incomingUrl,
|
|
219
|
+
text,
|
|
220
|
+
to,
|
|
221
|
+
account.allowInsecureSsl,
|
|
222
|
+
);
|
|
223
|
+
if (!ok) {
|
|
224
|
+
throw new Error("Failed to send message to Custom channel");
|
|
225
|
+
}
|
|
226
|
+
return { channel: CHANNEL_ID, messageId: `custom-${Date.now()}`, chatId: to };
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
sendMedia: async ({
|
|
230
|
+
to,
|
|
231
|
+
mediaUrl,
|
|
232
|
+
accountId,
|
|
233
|
+
account: ctxAccount,
|
|
234
|
+
}: {
|
|
235
|
+
to: string;
|
|
236
|
+
mediaUrl?: string;
|
|
237
|
+
accountId?: string | null;
|
|
238
|
+
account?: ResolvedCustomAccount;
|
|
239
|
+
}) => {
|
|
240
|
+
const account: ResolvedCustomAccount =
|
|
241
|
+
ctxAccount ?? resolveAccount({}, accountId);
|
|
242
|
+
|
|
243
|
+
if (!account.incomingUrl) {
|
|
244
|
+
throw new Error("Custom channel incoming URL not configured");
|
|
245
|
+
}
|
|
246
|
+
if (!mediaUrl) {
|
|
247
|
+
throw new Error("No media URL provided");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const { sendFileUrl } = await import("./client.js");
|
|
251
|
+
const ok = await sendFileUrl(
|
|
252
|
+
account.incomingUrl,
|
|
253
|
+
mediaUrl,
|
|
254
|
+
to,
|
|
255
|
+
account.allowInsecureSsl,
|
|
256
|
+
);
|
|
257
|
+
if (!ok) {
|
|
258
|
+
throw new Error("Failed to send media to Custom channel");
|
|
259
|
+
}
|
|
260
|
+
return { channel: CHANNEL_ID, messageId: `custom-${Date.now()}`, chatId: to };
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
gateway: {
|
|
265
|
+
startAccount: async (ctx: {
|
|
266
|
+
cfg: unknown;
|
|
267
|
+
accountId: string;
|
|
268
|
+
account: ResolvedCustomAccount;
|
|
269
|
+
log?: { info?: (msg: string) => void; warn?: (msg: string) => void };
|
|
270
|
+
}) => {
|
|
271
|
+
const { cfg, accountId, log } = ctx;
|
|
272
|
+
const account = resolveAccount(cfg, accountId);
|
|
273
|
+
|
|
274
|
+
if (!account.enabled) {
|
|
275
|
+
log?.info?.(`Custom account ${accountId} is disabled, skipping`);
|
|
276
|
+
return { stop: () => {} };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!account.token && account.dmPolicy !== "open") {
|
|
280
|
+
log?.warn?.(
|
|
281
|
+
`Custom account ${accountId} has no token; webhook validation disabled`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
if (!account.incomingUrl) {
|
|
285
|
+
log?.warn?.(
|
|
286
|
+
`Custom account ${accountId} incomingUrl not configured; replies will fail`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (
|
|
290
|
+
account.dmPolicy === "allowlist" &&
|
|
291
|
+
account.allowedUserIds.length === 0
|
|
292
|
+
) {
|
|
293
|
+
log?.warn?.(
|
|
294
|
+
`Custom account ${accountId} dmPolicy=allowlist but empty allowFrom; refusing to start`,
|
|
295
|
+
);
|
|
296
|
+
return { stop: () => {} };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
log?.info?.(
|
|
300
|
+
`Starting Custom channel (account: ${accountId}, path: ${account.webhookPath})`,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const handler = createWebhookHandler({
|
|
304
|
+
account,
|
|
305
|
+
deliver: async (msg) => {
|
|
306
|
+
const rt = getCustomRuntime();
|
|
307
|
+
const currentCfg = await rt.config.loadConfig();
|
|
308
|
+
|
|
309
|
+
const targetTo = msg.groupId ?? msg.from;
|
|
310
|
+
|
|
311
|
+
const msgCtx = {
|
|
312
|
+
Body: msg.body,
|
|
313
|
+
From: msg.from,
|
|
314
|
+
To: account.botName,
|
|
315
|
+
SessionKey: msg.sessionKey,
|
|
316
|
+
AccountId: account.accountId,
|
|
317
|
+
OriginatingChannel: CHANNEL_ID,
|
|
318
|
+
OriginatingTo: targetTo,
|
|
319
|
+
ChatType: msg.chatType,
|
|
320
|
+
SenderName: msg.senderName,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
324
|
+
ctx: msgCtx,
|
|
325
|
+
cfg: currentCfg,
|
|
326
|
+
dispatcherOptions: {
|
|
327
|
+
deliver: async (payload: { text?: string; body?: string }) => {
|
|
328
|
+
const text = payload?.text ?? payload?.body;
|
|
329
|
+
if (text && account.incomingUrl) {
|
|
330
|
+
await sendMessage(
|
|
331
|
+
account.incomingUrl,
|
|
332
|
+
text,
|
|
333
|
+
targetTo,
|
|
334
|
+
account.allowInsecureSsl,
|
|
335
|
+
msg.chatType === "group" ? "group" : "direct",
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
onReplyStart: () => {
|
|
340
|
+
log?.info?.(`Agent reply started for ${msg.from}`);
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
},
|
|
345
|
+
log,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const routeKey = `${accountId}:${account.webhookPath}`;
|
|
349
|
+
const prevUnregister = activeRouteUnregisters.get(routeKey);
|
|
350
|
+
if (prevUnregister) {
|
|
351
|
+
log?.info?.(`Deregistering stale route before re-registering: ${account.webhookPath}`);
|
|
352
|
+
prevUnregister();
|
|
353
|
+
activeRouteUnregisters.delete(routeKey);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const unregister = registerPluginHttpRoute({
|
|
357
|
+
path: account.webhookPath,
|
|
358
|
+
pluginId: CHANNEL_ID,
|
|
359
|
+
accountId: account.accountId,
|
|
360
|
+
log: (msg: string) => log?.info?.(msg),
|
|
361
|
+
handler,
|
|
362
|
+
});
|
|
363
|
+
activeRouteUnregisters.set(routeKey, unregister);
|
|
364
|
+
|
|
365
|
+
log?.info?.(`Registered HTTP route: ${account.webhookPath} for Custom channel`);
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
stop: () => {
|
|
369
|
+
log?.info?.(`Stopping Custom channel (account: ${accountId})`);
|
|
370
|
+
if (typeof unregister === "function") unregister();
|
|
371
|
+
activeRouteUnregisters.delete(routeKey);
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
stopAccount: async (ctx: { accountId: string; log?: { info?: (msg: string) => void } }) => {
|
|
377
|
+
ctx.log?.info?.(`Custom account ${ctx.accountId} stopped`);
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
agentPrompt: {
|
|
382
|
+
messageToolHints: () => [
|
|
383
|
+
"",
|
|
384
|
+
"### Custom Channel",
|
|
385
|
+
"Adapt formatting hints to your backend. Default: plain text.",
|
|
386
|
+
"Use JSON payload for outbound: { to, text, chatType }.",
|
|
387
|
+
"",
|
|
388
|
+
],
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for sending messages to your custom backend.
|
|
3
|
+
*
|
|
4
|
+
* Sends JSON payload to the incomingUrl. Adapt the payload format to match
|
|
5
|
+
* your backend's API (e.g. { to, text } or { userId, message }).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as http from "node:http";
|
|
9
|
+
import * as https from "node:https";
|
|
10
|
+
|
|
11
|
+
const MIN_SEND_INTERVAL_MS = 200;
|
|
12
|
+
let lastSendTime = 0;
|
|
13
|
+
|
|
14
|
+
export interface SendPayload {
|
|
15
|
+
to: string;
|
|
16
|
+
text: string;
|
|
17
|
+
chatType?: "direct" | "group";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function sleep(ms: number): Promise<void> {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function doPost(url: string, body: string, allowInsecureSsl: boolean): Promise<boolean> {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
let parsedUrl: URL;
|
|
27
|
+
try {
|
|
28
|
+
parsedUrl = new URL(url);
|
|
29
|
+
} catch {
|
|
30
|
+
reject(new Error(`Invalid URL: ${url}`));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const transport = parsedUrl.protocol === "https:" ? https : http;
|
|
34
|
+
|
|
35
|
+
const req = transport.request(
|
|
36
|
+
url,
|
|
37
|
+
{
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
"Content-Length": Buffer.byteLength(body),
|
|
42
|
+
},
|
|
43
|
+
timeout: 30_000,
|
|
44
|
+
rejectUnauthorized: !allowInsecureSsl,
|
|
45
|
+
},
|
|
46
|
+
(res) => {
|
|
47
|
+
res.on("data", () => {});
|
|
48
|
+
res.on("end", () => {
|
|
49
|
+
resolve(res.statusCode === 200 || res.statusCode === 201);
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
req.on("error", reject);
|
|
55
|
+
req.on("timeout", () => {
|
|
56
|
+
req.destroy();
|
|
57
|
+
reject(new Error("Request timeout"));
|
|
58
|
+
});
|
|
59
|
+
req.write(body);
|
|
60
|
+
req.end();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Send a text message to the custom backend.
|
|
66
|
+
* Payload format: { to, text, chatType }. Adjust to match your API.
|
|
67
|
+
*/
|
|
68
|
+
export async function sendMessage(
|
|
69
|
+
incomingUrl: string,
|
|
70
|
+
text: string,
|
|
71
|
+
to: string,
|
|
72
|
+
allowInsecureSsl = false,
|
|
73
|
+
chatType: "direct" | "group" = "direct",
|
|
74
|
+
): Promise<boolean> {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const elapsed = now - lastSendTime;
|
|
77
|
+
if (elapsed < MIN_SEND_INTERVAL_MS) {
|
|
78
|
+
await sleep(MIN_SEND_INTERVAL_MS - elapsed);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const payload: SendPayload = { to, text, chatType };
|
|
82
|
+
const body = JSON.stringify(payload);
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const ok = await doPost(incomingUrl, body, allowInsecureSsl);
|
|
86
|
+
lastSendTime = Date.now();
|
|
87
|
+
return ok;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Send media (file URL) to the custom backend.
|
|
95
|
+
* Extend payload as needed for your API.
|
|
96
|
+
*/
|
|
97
|
+
export async function sendFileUrl(
|
|
98
|
+
incomingUrl: string,
|
|
99
|
+
fileUrl: string,
|
|
100
|
+
to: string,
|
|
101
|
+
allowInsecureSsl = false,
|
|
102
|
+
): Promise<boolean> {
|
|
103
|
+
const payload = { to, fileUrl, chatType: "direct" as const };
|
|
104
|
+
const body = JSON.stringify(payload);
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const ok = await doPost(incomingUrl, body, allowInsecureSsl);
|
|
108
|
+
lastSendTime = Date.now();
|
|
109
|
+
return ok;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin runtime singleton.
|
|
3
|
+
* Stores the PluginRuntime from api.runtime (set during register()).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
7
|
+
|
|
8
|
+
let runtime: PluginRuntime | null = null;
|
|
9
|
+
|
|
10
|
+
export function setCustomRuntime(r: PluginRuntime): void {
|
|
11
|
+
runtime = r;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getCustomRuntime(): PluginRuntime {
|
|
15
|
+
if (!runtime) {
|
|
16
|
+
throw new Error("Custom channel runtime not initialized - plugin not registered");
|
|
17
|
+
}
|
|
18
|
+
return runtime;
|
|
19
|
+
}
|
package/src/security.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security: token validation, rate limiting, input sanitization, user allowlist.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as crypto from "node:crypto";
|
|
6
|
+
|
|
7
|
+
export type DmAuthorizationResult =
|
|
8
|
+
| { allowed: true }
|
|
9
|
+
| { allowed: false; reason: "disabled" | "allowlist-empty" | "not-allowlisted" };
|
|
10
|
+
|
|
11
|
+
export function validateToken(received: string, expected: string): boolean {
|
|
12
|
+
if (!received || !expected) return false;
|
|
13
|
+
const key = "openclaw-token-cmp";
|
|
14
|
+
const a = crypto.createHmac("sha256", key).update(received).digest();
|
|
15
|
+
const b = crypto.createHmac("sha256", key).update(expected).digest();
|
|
16
|
+
return crypto.timingSafeEqual(a, b);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function checkUserAllowed(userId: string, allowedUserIds: string[]): boolean {
|
|
20
|
+
if (allowedUserIds.length === 0) return false;
|
|
21
|
+
return allowedUserIds.includes(userId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function authorizeUserForDm(
|
|
25
|
+
userId: string,
|
|
26
|
+
dmPolicy: "open" | "allowlist" | "pairing" | "disabled",
|
|
27
|
+
allowedUserIds: string[],
|
|
28
|
+
): DmAuthorizationResult {
|
|
29
|
+
if (dmPolicy === "disabled") {
|
|
30
|
+
return { allowed: false, reason: "disabled" };
|
|
31
|
+
}
|
|
32
|
+
if (dmPolicy === "open") {
|
|
33
|
+
return { allowed: true };
|
|
34
|
+
}
|
|
35
|
+
if (allowedUserIds.length === 0) {
|
|
36
|
+
return { allowed: false, reason: "allowlist-empty" };
|
|
37
|
+
}
|
|
38
|
+
if (!checkUserAllowed(userId, allowedUserIds)) {
|
|
39
|
+
return { allowed: false, reason: "not-allowlisted" };
|
|
40
|
+
}
|
|
41
|
+
return { allowed: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sanitizeInput(text: string): string {
|
|
45
|
+
const dangerousPatterns = [
|
|
46
|
+
/ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|prompts?)/gi,
|
|
47
|
+
/you\s+are\s+now\s+/gi,
|
|
48
|
+
/system:\s*/gi,
|
|
49
|
+
/<\|.*?\|>/g,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
let sanitized = text;
|
|
53
|
+
for (const pattern of dangerousPatterns) {
|
|
54
|
+
sanitized = sanitized.replace(pattern, "[FILTERED]");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const maxLength = 4000;
|
|
58
|
+
if (sanitized.length > maxLength) {
|
|
59
|
+
sanitized = sanitized.slice(0, maxLength) + "... [truncated]";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return sanitized;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class RateLimiter {
|
|
66
|
+
private requests = new Map<string, number[]>();
|
|
67
|
+
private limit: number;
|
|
68
|
+
private windowMs: number;
|
|
69
|
+
private lastCleanup = 0;
|
|
70
|
+
private cleanupIntervalMs: number;
|
|
71
|
+
|
|
72
|
+
constructor(limit = 30, windowSeconds = 60) {
|
|
73
|
+
this.limit = limit;
|
|
74
|
+
this.windowMs = windowSeconds * 1000;
|
|
75
|
+
this.cleanupIntervalMs = this.windowMs * 5;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
check(userId: string): boolean {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const windowStart = now - this.windowMs;
|
|
81
|
+
|
|
82
|
+
if (now - this.lastCleanup > this.cleanupIntervalMs) {
|
|
83
|
+
this.cleanup(windowStart);
|
|
84
|
+
this.lastCleanup = now;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let timestamps = this.requests.get(userId);
|
|
88
|
+
if (timestamps) {
|
|
89
|
+
timestamps = timestamps.filter((ts) => ts > windowStart);
|
|
90
|
+
} else {
|
|
91
|
+
timestamps = [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (timestamps.length >= this.limit) {
|
|
95
|
+
this.requests.set(userId, timestamps);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
timestamps.push(now);
|
|
100
|
+
this.requests.set(userId, timestamps);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private cleanup(windowStart: number): void {
|
|
105
|
+
for (const [userId, timestamps] of this.requests) {
|
|
106
|
+
const active = timestamps.filter((ts) => ts > windowStart);
|
|
107
|
+
if (active.length === 0) {
|
|
108
|
+
this.requests.delete(userId);
|
|
109
|
+
} else {
|
|
110
|
+
this.requests.set(userId, active);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for the Custom channel plugin.
|
|
3
|
+
*
|
|
4
|
+
* Adapt these types to match your backend's webhook payload and config.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Raw channel config from openclaw.json channels.custom */
|
|
8
|
+
export interface CustomChannelConfig {
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
token?: string;
|
|
11
|
+
incomingUrl?: string;
|
|
12
|
+
webhookPath?: string;
|
|
13
|
+
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
|
|
14
|
+
allowFrom?: string | string[];
|
|
15
|
+
rateLimitPerMinute?: number;
|
|
16
|
+
botName?: string;
|
|
17
|
+
allowInsecureSsl?: boolean;
|
|
18
|
+
accounts?: Record<string, CustomAccountRaw>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Raw per-account config (overrides base config) */
|
|
22
|
+
export interface CustomAccountRaw {
|
|
23
|
+
enabled?: boolean;
|
|
24
|
+
token?: string;
|
|
25
|
+
incomingUrl?: string;
|
|
26
|
+
webhookPath?: string;
|
|
27
|
+
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
|
|
28
|
+
allowFrom?: string | string[];
|
|
29
|
+
rateLimitPerMinute?: number;
|
|
30
|
+
botName?: string;
|
|
31
|
+
allowInsecureSsl?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fully resolved account config with defaults applied */
|
|
35
|
+
export interface ResolvedCustomAccount {
|
|
36
|
+
accountId: string;
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
token: string;
|
|
39
|
+
incomingUrl: string;
|
|
40
|
+
webhookPath: string;
|
|
41
|
+
dmPolicy: "open" | "allowlist" | "pairing" | "disabled";
|
|
42
|
+
allowedUserIds: string[];
|
|
43
|
+
rateLimitPerMinute: number;
|
|
44
|
+
botName: string;
|
|
45
|
+
allowInsecureSsl: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Webhook payload format from your backend.
|
|
50
|
+
* Customize fields to match your chat system's webhook schema.
|
|
51
|
+
*/
|
|
52
|
+
export interface CustomWebhookPayload {
|
|
53
|
+
token?: string;
|
|
54
|
+
userId: string;
|
|
55
|
+
userName?: string;
|
|
56
|
+
groupId?: string;
|
|
57
|
+
groupName?: string;
|
|
58
|
+
text: string;
|
|
59
|
+
chatType?: "direct" | "group";
|
|
60
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound webhook handler for the Custom channel.
|
|
3
|
+
*
|
|
4
|
+
* Expects JSON body with: { userId, userName?, groupId?, groupName?, text, chatType? }
|
|
5
|
+
* Optional: token for validation.
|
|
6
|
+
*
|
|
7
|
+
* Customize parsePayload() to match your backend's webhook format.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
11
|
+
import {
|
|
12
|
+
validateToken,
|
|
13
|
+
authorizeUserForDm,
|
|
14
|
+
sanitizeInput,
|
|
15
|
+
RateLimiter,
|
|
16
|
+
} from "./security.js";
|
|
17
|
+
import type { CustomWebhookPayload, ResolvedCustomAccount } from "./types.js";
|
|
18
|
+
|
|
19
|
+
const rateLimiters = new Map<string, RateLimiter>();
|
|
20
|
+
|
|
21
|
+
function getRateLimiter(account: ResolvedCustomAccount): RateLimiter {
|
|
22
|
+
let rl = rateLimiters.get(account.accountId);
|
|
23
|
+
if (!rl) {
|
|
24
|
+
rl = new RateLimiter(account.rateLimitPerMinute);
|
|
25
|
+
rateLimiters.set(account.accountId, rl);
|
|
26
|
+
}
|
|
27
|
+
return rl;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const chunks: Buffer[] = [];
|
|
33
|
+
let size = 0;
|
|
34
|
+
const maxSize = 1_048_576;
|
|
35
|
+
|
|
36
|
+
req.on("data", (chunk: Buffer) => {
|
|
37
|
+
size += chunk.length;
|
|
38
|
+
if (size > maxSize) {
|
|
39
|
+
req.destroy();
|
|
40
|
+
reject(new Error("Request body too large"));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
chunks.push(chunk);
|
|
44
|
+
});
|
|
45
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
46
|
+
req.on("error", reject);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parsePayload(body: string): CustomWebhookPayload | null {
|
|
51
|
+
let parsed: Record<string, unknown>;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(body) as Record<string, unknown>;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const userId = String(parsed.userId ?? "").trim();
|
|
59
|
+
const text = String(parsed.text ?? "").trim();
|
|
60
|
+
|
|
61
|
+
if (!userId || !text) return null;
|
|
62
|
+
|
|
63
|
+
const chatType =
|
|
64
|
+
parsed.chatType === "group" ? "group" : "direct";
|
|
65
|
+
const groupId = parsed.groupId ? String(parsed.groupId) : undefined;
|
|
66
|
+
const groupName = parsed.groupName ? String(parsed.groupName) : undefined;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
token: parsed.token ? String(parsed.token) : undefined,
|
|
70
|
+
userId,
|
|
71
|
+
userName: parsed.userName ? String(parsed.userName) : undefined,
|
|
72
|
+
groupId,
|
|
73
|
+
groupName,
|
|
74
|
+
text,
|
|
75
|
+
chatType,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function respond(res: ServerResponse, statusCode: number, body: Record<string, unknown>): void {
|
|
80
|
+
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
|
81
|
+
res.end(JSON.stringify(body));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface WebhookHandlerDeps {
|
|
85
|
+
account: ResolvedCustomAccount;
|
|
86
|
+
/** Deliver to agent; replies are sent via dispatcherOptions.deliver inside. */
|
|
87
|
+
deliver: (msg: {
|
|
88
|
+
body: string;
|
|
89
|
+
from: string;
|
|
90
|
+
senderName: string;
|
|
91
|
+
chatType: string;
|
|
92
|
+
sessionKey: string;
|
|
93
|
+
accountId: string;
|
|
94
|
+
groupId?: string;
|
|
95
|
+
groupName?: string;
|
|
96
|
+
}) => Promise<void>;
|
|
97
|
+
log?: {
|
|
98
|
+
info: (...args: unknown[]) => void;
|
|
99
|
+
warn: (...args: unknown[]) => void;
|
|
100
|
+
error: (...args: unknown[]) => void;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createWebhookHandler(deps: WebhookHandlerDeps) {
|
|
105
|
+
const { account, deliver, log } = deps;
|
|
106
|
+
const rateLimiter = getRateLimiter(account);
|
|
107
|
+
|
|
108
|
+
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
109
|
+
if (req.method !== "POST") {
|
|
110
|
+
respond(res, 405, { error: "Method not allowed" });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let body: string;
|
|
115
|
+
try {
|
|
116
|
+
body = await readBody(req);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log?.error?.("Failed to read request body", err);
|
|
119
|
+
respond(res, 400, { error: "Invalid request body" });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const payload = parsePayload(body);
|
|
124
|
+
if (!payload) {
|
|
125
|
+
respond(res, 400, { error: "Missing required fields (userId, text)" });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (account.token && payload.token && !validateToken(payload.token, account.token)) {
|
|
130
|
+
log?.warn?.(`Invalid token from ${req.socket?.remoteAddress}`);
|
|
131
|
+
respond(res, 401, { error: "Invalid token" });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const auth = authorizeUserForDm(
|
|
136
|
+
payload.userId,
|
|
137
|
+
account.dmPolicy,
|
|
138
|
+
account.allowedUserIds,
|
|
139
|
+
);
|
|
140
|
+
if (!auth.allowed) {
|
|
141
|
+
if (auth.reason === "disabled") {
|
|
142
|
+
respond(res, 403, { error: "DMs are disabled" });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (auth.reason === "allowlist-empty") {
|
|
146
|
+
log?.warn?.("Custom channel allowlist is empty while dmPolicy=allowlist");
|
|
147
|
+
respond(res, 403, {
|
|
148
|
+
error: "Allowlist is empty. Configure allowFrom or use dmPolicy=open.",
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
log?.warn?.(`Unauthorized user: ${payload.userId}`);
|
|
153
|
+
respond(res, 403, { error: "User not authorized" });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!rateLimiter.check(payload.userId)) {
|
|
158
|
+
log?.warn?.(`Rate limit exceeded for user: ${payload.userId}`);
|
|
159
|
+
respond(res, 429, { error: "Rate limit exceeded" });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const cleanText = sanitizeInput(payload.text);
|
|
164
|
+
if (!cleanText) {
|
|
165
|
+
respond(res, 200, { ok: true });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const preview = cleanText.length > 100 ? `${cleanText.slice(0, 100)}...` : cleanText;
|
|
170
|
+
log?.info?.(`Message from ${payload.userName ?? payload.userId} (${payload.userId}): ${preview}`);
|
|
171
|
+
|
|
172
|
+
respond(res, 200, { ok: true, message: "Processing..." });
|
|
173
|
+
|
|
174
|
+
const sessionKey = payload.groupId
|
|
175
|
+
? `custom-${payload.groupId}-${payload.userId}`
|
|
176
|
+
: `custom-${payload.userId}`;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
await deliver({
|
|
180
|
+
body: cleanText,
|
|
181
|
+
from: payload.userId,
|
|
182
|
+
senderName: payload.userName ?? payload.userId,
|
|
183
|
+
chatType: payload.chatType ?? "direct",
|
|
184
|
+
sessionKey,
|
|
185
|
+
accountId: account.accountId,
|
|
186
|
+
groupId: payload.groupId,
|
|
187
|
+
groupName: payload.groupName,
|
|
188
|
+
});
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
191
|
+
log?.error?.(`Failed to process message: ${errMsg}`);
|
|
192
|
+
if (account.incomingUrl) {
|
|
193
|
+
const { sendMessage } = await import("./client.js");
|
|
194
|
+
await sendMessage(
|
|
195
|
+
account.incomingUrl,
|
|
196
|
+
"Sorry, an error occurred while processing your message.",
|
|
197
|
+
payload.groupId ?? payload.userId,
|
|
198
|
+
account.allowInsecureSsl,
|
|
199
|
+
payload.chatType ?? "direct",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|