openclaw-groupme 0.4.4 → 0.5.1
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/README.md +147 -45
- package/channel-plugin-api.ts +3 -0
- package/dist/channel-plugin-api.js +3 -0
- package/dist/index.js +15 -9
- package/dist/runtime-setter-api.js +1 -0
- package/dist/secret-contract-api.js +1 -0
- package/dist/setup-entry.js +16 -0
- package/dist/setup-plugin-api.js +3 -0
- package/dist/src/accounts.js +24 -48
- package/dist/src/channel.js +63 -29
- package/dist/src/config-schema.js +10 -11
- package/dist/src/groupme-api.js +9 -5
- package/dist/src/inbound.js +18 -10
- package/dist/src/monitor.js +25 -27
- package/dist/src/normalize.js +6 -0
- package/dist/src/onboarding.js +364 -337
- package/dist/src/parse.js +4 -14
- package/dist/src/policy.js +1 -1
- package/dist/src/rate-limit.js +12 -7
- package/dist/src/replay-cache.js +0 -3
- package/dist/src/secret-contract.js +49 -0
- package/dist/src/security.js +17 -34
- package/dist/src/send.js +19 -13
- package/index.ts +15 -10
- package/openclaw.plugin.json +14 -15
- package/package.json +43 -9
- package/runtime-setter-api.ts +1 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +17 -0
- package/setup-plugin-api.ts +3 -0
- package/src/accounts.ts +29 -68
- package/src/channel.ts +74 -64
- package/src/config-schema.ts +10 -11
- package/src/groupme-api.ts +21 -5
- package/src/history.ts +1 -1
- package/src/inbound.ts +45 -75
- package/src/monitor.ts +37 -52
- package/src/normalize.ts +7 -1
- package/src/onboarding.ts +449 -409
- package/src/parse.ts +6 -23
- package/src/policy.ts +1 -4
- package/src/rate-limit.ts +15 -12
- package/src/replay-cache.ts +1 -4
- package/src/runtime.ts +1 -1
- package/src/secret-contract.ts +66 -0
- package/src/security.ts +28 -66
- package/src/send.ts +32 -38
- package/src/types.ts +7 -7
package/dist/src/channel.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { missingTargetError } from "openclaw/plugin-sdk/channel-feedback";
|
|
2
|
+
import { applyAccountNameToChannelSection, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, migrateBaseNameToDefaultAccount, normalizeAccountId, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress";
|
|
4
|
+
import { hasSecretInput, listGroupMeAccountIds, resolveDefaultGroupMeAccountId, resolveGroupMeAccount, } from "./accounts.js";
|
|
3
5
|
import { GroupMeConfigSchema } from "./config-schema.js";
|
|
4
6
|
import { createGroupMeWebhookHandler } from "./monitor.js";
|
|
5
|
-
import { normalizeGroupMeAllowEntry, normalizeGroupMeTarget,
|
|
7
|
+
import { looksLikeGroupMeTargetId, normalizeGroupMeAllowEntry, normalizeGroupMeTarget, } from "./normalize.js";
|
|
6
8
|
import { groupmeOnboardingAdapter } from "./onboarding.js";
|
|
7
9
|
import { getGroupMeRuntime } from "./runtime.js";
|
|
8
|
-
import {
|
|
9
|
-
import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText, } from "./send.js";
|
|
10
|
+
import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText } from "./send.js";
|
|
10
11
|
const CHANNEL_ID = "groupme";
|
|
11
|
-
function
|
|
12
|
+
function normalizeWebhookPath(raw) {
|
|
12
13
|
const trimmed = raw?.trim() ?? "";
|
|
13
14
|
if (!trimmed) {
|
|
14
15
|
return "/groupme";
|
|
@@ -18,20 +19,34 @@ function normalizeCallbackUrl(raw) {
|
|
|
18
19
|
return parsed.pathname || "/groupme";
|
|
19
20
|
}
|
|
20
21
|
catch {
|
|
21
|
-
|
|
22
|
+
// Unparseable input (e.g. "http://%"): strip any query/fragment and ensure a
|
|
23
|
+
// leading slash so we still return a route-shaped path rather than throwing.
|
|
24
|
+
// This is a display/registration fallback for malformed config, not a parser.
|
|
25
|
+
// The `?? trimmed` and empty-`noQuery` guards are defensive: split() always yields
|
|
26
|
+
// a [0], and a non-empty trimmed string can't reduce to an empty noQuery here.
|
|
27
|
+
/* v8 ignore start */
|
|
28
|
+
const noQuery = trimmed.split(/[?#]/)[0] ?? trimmed;
|
|
22
29
|
if (!noQuery) {
|
|
23
30
|
return "/groupme";
|
|
24
31
|
}
|
|
32
|
+
/* v8 ignore stop */
|
|
25
33
|
return noQuery.startsWith("/") ? noQuery : `/${noQuery}`;
|
|
26
34
|
}
|
|
27
35
|
}
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return
|
|
36
|
+
function parseWebhookSetupInput(raw) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = new URL(raw.trim(), "http://localhost");
|
|
39
|
+
const callbackToken = parsed.searchParams.get("k")?.trim() || undefined;
|
|
40
|
+
return {
|
|
41
|
+
webhookPath: parsed.pathname || "/groupme",
|
|
42
|
+
callbackToken,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return {
|
|
47
|
+
webhookPath: normalizeWebhookPath(raw),
|
|
48
|
+
};
|
|
33
49
|
}
|
|
34
|
-
return redactCallbackUrl(normalized, security);
|
|
35
50
|
}
|
|
36
51
|
const meta = {
|
|
37
52
|
id: CHANNEL_ID,
|
|
@@ -47,7 +62,7 @@ const meta = {
|
|
|
47
62
|
export const groupmePlugin = {
|
|
48
63
|
id: CHANNEL_ID,
|
|
49
64
|
meta,
|
|
50
|
-
|
|
65
|
+
setupWizard: groupmeOnboardingAdapter,
|
|
51
66
|
setup: {
|
|
52
67
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
53
68
|
applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({
|
|
@@ -81,10 +96,16 @@ export const groupmePlugin = {
|
|
|
81
96
|
if (input.accessToken?.trim())
|
|
82
97
|
updates.accessToken = input.accessToken.trim();
|
|
83
98
|
if (input.webhookUrl?.trim()) {
|
|
84
|
-
|
|
99
|
+
const parsed = parseWebhookSetupInput(input.webhookUrl);
|
|
100
|
+
updates.webhookPath = parsed.webhookPath;
|
|
101
|
+
if (parsed.callbackToken)
|
|
102
|
+
updates.callbackToken = parsed.callbackToken;
|
|
85
103
|
}
|
|
86
104
|
else if (input.webhookPath?.trim()) {
|
|
87
|
-
|
|
105
|
+
const parsed = parseWebhookSetupInput(input.webhookPath);
|
|
106
|
+
updates.webhookPath = parsed.webhookPath;
|
|
107
|
+
if (parsed.callbackToken)
|
|
108
|
+
updates.callbackToken = parsed.callbackToken;
|
|
88
109
|
}
|
|
89
110
|
const section = (next.channels?.groupme ?? {});
|
|
90
111
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
@@ -154,10 +175,11 @@ export const groupmePlugin = {
|
|
|
154
175
|
"name",
|
|
155
176
|
"botId",
|
|
156
177
|
"accessToken",
|
|
178
|
+
"callbackToken",
|
|
157
179
|
"botName",
|
|
158
180
|
"groupId",
|
|
159
181
|
"publicDomain",
|
|
160
|
-
"
|
|
182
|
+
"webhookPath",
|
|
161
183
|
"mentionPatterns",
|
|
162
184
|
"requireMention",
|
|
163
185
|
"historyLimit",
|
|
@@ -173,12 +195,12 @@ export const groupmePlugin = {
|
|
|
173
195
|
name: account.name,
|
|
174
196
|
enabled: account.enabled,
|
|
175
197
|
configured: account.configured,
|
|
176
|
-
botId: account.botId ? "***" : "",
|
|
198
|
+
botId: hasSecretInput(account.config.botId) ? "***" : "",
|
|
177
199
|
publicDomain: account.config.publicDomain ?? "",
|
|
178
|
-
|
|
200
|
+
webhookPath: normalizeWebhookPath(account.config.webhookPath),
|
|
201
|
+
callbackToken: hasSecretInput(account.config.callbackToken) ? "***" : "",
|
|
179
202
|
}),
|
|
180
|
-
resolveAllowFrom: ({ cfg, accountId }) => (resolveGroupMeAccount({ cfg: cfg, accountId }).config
|
|
181
|
-
.allowFrom ?? []).map((entry) => String(entry)),
|
|
203
|
+
resolveAllowFrom: ({ cfg, accountId }) => (resolveGroupMeAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry)),
|
|
182
204
|
formatAllowFrom: ({ allowFrom }) => allowFrom
|
|
183
205
|
.map((entry) => normalizeGroupMeAllowEntry(String(entry)))
|
|
184
206
|
.filter((entry) => Boolean(entry)),
|
|
@@ -297,7 +319,7 @@ export const groupmePlugin = {
|
|
|
297
319
|
buildChannelSummary: ({ snapshot }) => ({
|
|
298
320
|
configured: snapshot.configured ?? false,
|
|
299
321
|
running: snapshot.running ?? false,
|
|
300
|
-
|
|
322
|
+
webhookPath: snapshot.webhookPath ?? null,
|
|
301
323
|
lastStartAt: snapshot.lastStartAt ?? null,
|
|
302
324
|
lastStopAt: snapshot.lastStopAt ?? null,
|
|
303
325
|
lastInboundAt: snapshot.lastInboundAt ?? null,
|
|
@@ -309,9 +331,9 @@ export const groupmePlugin = {
|
|
|
309
331
|
name: account.name,
|
|
310
332
|
enabled: account.enabled,
|
|
311
333
|
configured: account.configured,
|
|
312
|
-
botId: account.botId ? "***" : "",
|
|
313
|
-
tokenSource: account.accessToken ? "configured" : "none",
|
|
314
|
-
webhookPath:
|
|
334
|
+
botId: hasSecretInput(account.config.botId) ? "***" : "",
|
|
335
|
+
tokenSource: hasSecretInput(account.config.accessToken) ? "configured" : "none",
|
|
336
|
+
webhookPath: normalizeWebhookPath(account.config.webhookPath),
|
|
315
337
|
running: runtime?.running ?? false,
|
|
316
338
|
lastStartAt: runtime?.lastStartAt ?? null,
|
|
317
339
|
lastStopAt: runtime?.lastStopAt ?? null,
|
|
@@ -327,8 +349,7 @@ export const groupmePlugin = {
|
|
|
327
349
|
if (!account.configured) {
|
|
328
350
|
throw new Error(`GroupMe is not configured for account "${account.accountId}" (missing botId).`);
|
|
329
351
|
}
|
|
330
|
-
const callbackPath =
|
|
331
|
-
const redactedCallbackPath = redactWebhookPath(account, account.config.callbackUrl ?? callbackPath);
|
|
352
|
+
const callbackPath = normalizeWebhookPath(account.config.webhookPath);
|
|
332
353
|
const unregister = registerPluginHttpRoute({
|
|
333
354
|
path: callbackPath,
|
|
334
355
|
fallbackPath: "/groupme",
|
|
@@ -336,8 +357,12 @@ export const groupmePlugin = {
|
|
|
336
357
|
account,
|
|
337
358
|
config: ctx.cfg,
|
|
338
359
|
runtime: ctx.runtime,
|
|
360
|
+
// Thin setStatus adapter; exercised end-to-end through the live gateway, not in
|
|
361
|
+
// unit isolation (the webhook-flow tests drive the handler with their own sink).
|
|
362
|
+
/* v8 ignore next */
|
|
339
363
|
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
|
|
340
364
|
}),
|
|
365
|
+
auth: "plugin",
|
|
341
366
|
pluginId: CHANNEL_ID,
|
|
342
367
|
accountId: account.accountId,
|
|
343
368
|
log: (message) => ctx.log?.info(message),
|
|
@@ -346,18 +371,27 @@ export const groupmePlugin = {
|
|
|
346
371
|
accountId: account.accountId,
|
|
347
372
|
running: true,
|
|
348
373
|
mode: "webhook",
|
|
349
|
-
webhookPath:
|
|
374
|
+
webhookPath: callbackPath,
|
|
350
375
|
lastStartAt: Date.now(),
|
|
351
376
|
lastError: null,
|
|
352
377
|
});
|
|
353
|
-
ctx.log?.info(`[${account.accountId}] GroupMe webhook listening on ${
|
|
378
|
+
ctx.log?.info(`[${account.accountId}] GroupMe webhook listening on ${callbackPath}`);
|
|
379
|
+
const markStopped = () => {
|
|
380
|
+
ctx.setStatus({
|
|
381
|
+
accountId: account.accountId,
|
|
382
|
+
running: false,
|
|
383
|
+
lastStopAt: Date.now(),
|
|
384
|
+
});
|
|
385
|
+
};
|
|
354
386
|
if (ctx.abortSignal.aborted) {
|
|
355
387
|
unregister();
|
|
388
|
+
markStopped();
|
|
356
389
|
return;
|
|
357
390
|
}
|
|
358
391
|
await new Promise((resolve) => {
|
|
359
392
|
ctx.abortSignal.addEventListener("abort", () => {
|
|
360
393
|
unregister();
|
|
394
|
+
markStopped();
|
|
361
395
|
resolve();
|
|
362
396
|
}, { once: true });
|
|
363
397
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { BlockStreamingCoalesceSchema, MarkdownConfigSchema, } from "openclaw/plugin-sdk";
|
|
1
|
+
import { BlockStreamingCoalesceSchema, MarkdownConfigSchema, } from "openclaw/plugin-sdk/channel-config-primitives";
|
|
2
|
+
import { buildOptionalSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
|
5
|
+
const optionalSecretInput = buildOptionalSecretInputSchema();
|
|
4
6
|
const GroupMeReplaySchema = z
|
|
5
7
|
.object({
|
|
6
8
|
ttlSeconds: z.number().int().positive().optional(),
|
|
@@ -40,9 +42,7 @@ const GroupMeProxySecuritySchema = z
|
|
|
40
42
|
trustedProxyCidrs: z.array(z.string()).optional(),
|
|
41
43
|
allowedPublicHosts: z.array(z.string()).optional(),
|
|
42
44
|
requireHttpsProto: z.boolean().optional().default(false),
|
|
43
|
-
rejectStatus: z
|
|
44
|
-
.union([z.literal(400), z.literal(403), z.literal(404)])
|
|
45
|
-
.optional(),
|
|
45
|
+
rejectStatus: z.union([z.literal(400), z.literal(403), z.literal(404)]).optional(),
|
|
46
46
|
})
|
|
47
47
|
.strict();
|
|
48
48
|
const GroupMeSecuritySchema = z
|
|
@@ -55,16 +55,17 @@ const GroupMeSecuritySchema = z
|
|
|
55
55
|
proxy: GroupMeProxySecuritySchema.optional(),
|
|
56
56
|
})
|
|
57
57
|
.strict();
|
|
58
|
-
|
|
58
|
+
const GroupMeAccountSchemaBase = z
|
|
59
59
|
.object({
|
|
60
60
|
name: z.string().optional(),
|
|
61
61
|
enabled: z.boolean().optional(),
|
|
62
|
-
botId:
|
|
63
|
-
accessToken:
|
|
62
|
+
botId: optionalSecretInput,
|
|
63
|
+
accessToken: optionalSecretInput,
|
|
64
|
+
callbackToken: optionalSecretInput,
|
|
64
65
|
botName: z.string().optional(),
|
|
65
66
|
groupId: z.string().optional(),
|
|
66
67
|
publicDomain: z.string().optional(),
|
|
67
|
-
|
|
68
|
+
webhookPath: z.string().optional(),
|
|
68
69
|
mentionPatterns: z.array(z.string()).optional(),
|
|
69
70
|
requireMention: z.boolean().optional().default(true),
|
|
70
71
|
historyLimit: z.number().int().nonnegative().optional(),
|
|
@@ -79,8 +80,6 @@ export const GroupMeAccountSchemaBase = z
|
|
|
79
80
|
})
|
|
80
81
|
.strict();
|
|
81
82
|
export const GroupMeConfigSchema = GroupMeAccountSchemaBase.extend({
|
|
82
|
-
accounts: z
|
|
83
|
-
.record(z.string(), GroupMeAccountSchemaBase.optional())
|
|
84
|
-
.optional(),
|
|
83
|
+
accounts: z.record(z.string(), GroupMeAccountSchemaBase.optional()).optional(),
|
|
85
84
|
defaultAccount: z.string().optional(),
|
|
86
85
|
}).strict();
|
package/dist/src/groupme-api.js
CHANGED
|
@@ -33,16 +33,18 @@ function readBotResponse(payload) {
|
|
|
33
33
|
}
|
|
34
34
|
return bot;
|
|
35
35
|
}
|
|
36
|
-
export async function fetchGroups(accessToken) {
|
|
36
|
+
export async function fetchGroups(accessToken, options = {}) {
|
|
37
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
38
|
+
const apiBaseUrl = options.apiBaseUrl ?? GROUPME_API_BASE;
|
|
37
39
|
const groups = [];
|
|
38
40
|
let page = 1;
|
|
39
41
|
while (true) {
|
|
40
|
-
const url = new URL(`${
|
|
42
|
+
const url = new URL(`${apiBaseUrl}/groups`);
|
|
41
43
|
url.searchParams.set("token", accessToken);
|
|
42
44
|
url.searchParams.set("per_page", "100");
|
|
43
45
|
url.searchParams.set("omit", "memberships");
|
|
44
46
|
url.searchParams.set("page", String(page));
|
|
45
|
-
const response = await
|
|
47
|
+
const response = await fetchFn(url);
|
|
46
48
|
if (!response.ok) {
|
|
47
49
|
throw new Error(await readApiError(response));
|
|
48
50
|
}
|
|
@@ -57,9 +59,11 @@ export async function fetchGroups(accessToken) {
|
|
|
57
59
|
return groups;
|
|
58
60
|
}
|
|
59
61
|
export async function createBot(params) {
|
|
60
|
-
const
|
|
62
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
63
|
+
const apiBaseUrl = params.apiBaseUrl ?? GROUPME_API_BASE;
|
|
64
|
+
const url = new URL(`${apiBaseUrl}/bots`);
|
|
61
65
|
url.searchParams.set("token", params.accessToken);
|
|
62
|
-
const response = await
|
|
66
|
+
const response = await fetchFn(url, {
|
|
63
67
|
method: "POST",
|
|
64
68
|
headers: { "content-type": "application/json" },
|
|
65
69
|
body: JSON.stringify({
|
package/dist/src/inbound.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { logInboundDrop } from "openclaw/plugin-sdk/channel-logging";
|
|
2
|
+
import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-mention-gating";
|
|
3
|
+
import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-reply-options-runtime";
|
|
4
|
+
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating";
|
|
5
|
+
import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, recordPendingHistoryEntryIfEnabled, } from "openclaw/plugin-sdk/reply-history";
|
|
2
6
|
import { buildGroupMeHistoryEntry, formatGroupMeHistoryEntry, resolveGroupMeBodyForAgent, } from "./history.js";
|
|
3
|
-
import {
|
|
7
|
+
import { detectGroupMeMention, extractImageUrls } from "./parse.js";
|
|
4
8
|
import { resolveSenderAccess } from "./policy.js";
|
|
5
9
|
import { getGroupMeRuntime } from "./runtime.js";
|
|
6
|
-
import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText, } from "./send.js";
|
|
7
10
|
import { resolveGroupMeSecurity } from "./security.js";
|
|
11
|
+
import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText } from "./send.js";
|
|
8
12
|
const CHANNEL_ID = "groupme";
|
|
9
13
|
function resolveTextChunkLimit(account) {
|
|
10
14
|
const configured = account.config.textChunkLimit;
|
|
@@ -22,9 +26,7 @@ function chunkReplyText(params) {
|
|
|
22
26
|
if (!trimmed) {
|
|
23
27
|
return [];
|
|
24
28
|
}
|
|
25
|
-
return params.core.channel.text
|
|
26
|
-
.chunkMarkdownText(trimmed, params.limit)
|
|
27
|
-
.filter(Boolean);
|
|
29
|
+
return params.core.channel.text.chunkMarkdownText(trimmed, params.limit).filter(Boolean);
|
|
28
30
|
}
|
|
29
31
|
async function deliverGroupMeReply(params) {
|
|
30
32
|
const { payload, account, cfg, target, statusSink } = params;
|
|
@@ -98,7 +100,7 @@ async function deliverGroupMeReply(params) {
|
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
102
|
export async function handleGroupMeInbound(params) {
|
|
101
|
-
const { message, account, config, runtime, groupHistories, historyLimit, statusSink
|
|
103
|
+
const { message, account, config, runtime, groupHistories, historyLimit, statusSink } = params;
|
|
102
104
|
const core = getGroupMeRuntime();
|
|
103
105
|
const inboundTimestamp = message.createdAt * 1000;
|
|
104
106
|
statusSink?.({ lastInboundAt: inboundTimestamp });
|
|
@@ -167,9 +169,7 @@ export async function handleGroupMeInbound(params) {
|
|
|
167
169
|
hasAnyMention: false,
|
|
168
170
|
allowTextCommands,
|
|
169
171
|
hasControlCommand: commandBypassCanSkipMention ? hasControlCommand : false,
|
|
170
|
-
commandAuthorized: commandBypassCanSkipMention
|
|
171
|
-
? commandGate.commandAuthorized
|
|
172
|
-
: false,
|
|
172
|
+
commandAuthorized: commandBypassCanSkipMention ? commandGate.commandAuthorized : false,
|
|
173
173
|
});
|
|
174
174
|
const imageUrls = extractImageUrls(message.attachments);
|
|
175
175
|
const rawBody = message.text;
|
|
@@ -213,6 +213,14 @@ export async function handleGroupMeInbound(params) {
|
|
|
213
213
|
envelope: envelopeOptions,
|
|
214
214
|
body: bodyForAgent,
|
|
215
215
|
});
|
|
216
|
+
// Snapshot-then-clear the per-group buffer. This runs synchronously before the
|
|
217
|
+
// first `await` below, so it is atomic with respect to other inbound handlers for
|
|
218
|
+
// the same group (handlers run un-awaited and concurrently up to maxConcurrent).
|
|
219
|
+
// Accepted behavior: if two mentions for the same group arrive nearly together,
|
|
220
|
+
// the first handler consumes the buffered context and the second sees an empty
|
|
221
|
+
// buffer rather than re-reading the same entries — buffered context is consumed
|
|
222
|
+
// exactly once, never duplicated. Messages buffered while a reply is in flight are
|
|
223
|
+
// preserved because the clear happens before dispatch.
|
|
216
224
|
const shouldUseHistoryBuffer = requireMention && historyLimit > 0;
|
|
217
225
|
const historyEntriesForContext = shouldUseHistoryBuffer
|
|
218
226
|
? [...(groupHistories.get(message.groupId) ?? [])]
|
package/dist/src/monitor.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { readJsonBodyWithLimit, requestBodyErrorToText, } from "openclaw/plugin-sdk";
|
|
1
|
+
import { readJsonBodyWithLimit, requestBodyErrorToText, } from "openclaw/plugin-sdk/webhook-request-guards";
|
|
2
2
|
import { resolveGroupMeHistoryLimit } from "./history.js";
|
|
3
3
|
import { handleGroupMeInbound } from "./inbound.js";
|
|
4
4
|
import { parseGroupMeCallback, shouldProcessCallback } from "./parse.js";
|
|
5
5
|
import { GroupMeRateLimiter } from "./rate-limit.js";
|
|
6
|
-
import {
|
|
7
|
-
import { checkGroupBinding,
|
|
6
|
+
import { buildReplayKey, GroupMeReplayCache } from "./replay-cache.js";
|
|
7
|
+
import { checkGroupBinding, redactWebhookUrl, resolveGroupMeSecurity, validateProxyRequest, verifyCallbackAuth, } from "./security.js";
|
|
8
8
|
// GroupMe callbacks are small JSON payloads; use tighter limits than the SDK
|
|
9
9
|
// defaults (DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1 MB, DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30 s).
|
|
10
10
|
const GROUPME_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
|
|
@@ -14,7 +14,7 @@ function rejectDecision(params) {
|
|
|
14
14
|
kind: "reject",
|
|
15
15
|
status: params.status,
|
|
16
16
|
reason: params.reason,
|
|
17
|
-
logLevel: params.logLevel
|
|
17
|
+
logLevel: params.logLevel,
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
20
|
const STATUS_TEXT = {
|
|
@@ -36,6 +36,9 @@ function asRequestBodyErrorCode(value) {
|
|
|
36
36
|
value === "CONNECTION_CLOSED") {
|
|
37
37
|
return value;
|
|
38
38
|
}
|
|
39
|
+
// Only ever called with the three codes above (INVALID_JSON returns earlier in
|
|
40
|
+
// the handler), so this null path is unreachable defense-in-depth.
|
|
41
|
+
/* v8 ignore next */
|
|
39
42
|
return null;
|
|
40
43
|
}
|
|
41
44
|
function logWebhookRejection(params) {
|
|
@@ -43,7 +46,7 @@ function logWebhookRejection(params) {
|
|
|
43
46
|
return;
|
|
44
47
|
}
|
|
45
48
|
const url = params.security.logging.redactSecrets
|
|
46
|
-
?
|
|
49
|
+
? redactWebhookUrl(`${params.reqUrl.pathname}${params.reqUrl.search}`, params.security)
|
|
47
50
|
: `${params.reqUrl.pathname}${params.reqUrl.search}`;
|
|
48
51
|
const line = `groupme: webhook rejected (${params.decision.reason}) status=${params.decision.status} url=${url}`;
|
|
49
52
|
if (params.decision.logLevel === "warn") {
|
|
@@ -88,16 +91,13 @@ async function decideWebhookRequest(params) {
|
|
|
88
91
|
emptyObjectOnEmpty: false,
|
|
89
92
|
});
|
|
90
93
|
if (!body.ok) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
else {
|
|
99
|
-
status = 400;
|
|
100
|
-
}
|
|
94
|
+
// Map the SDK body-error code to an HTTP status; unmapped codes (INVALID_JSON,
|
|
95
|
+
// CONNECTION_CLOSED, …) fall back to 400.
|
|
96
|
+
const bodyErrorStatus = {
|
|
97
|
+
PAYLOAD_TOO_LARGE: 413,
|
|
98
|
+
REQUEST_BODY_TIMEOUT: 408,
|
|
99
|
+
};
|
|
100
|
+
const status = bodyErrorStatus[body.code] ?? 400;
|
|
101
101
|
return rejectDecision({
|
|
102
102
|
status,
|
|
103
103
|
reason: `body_${body.code.toLowerCase()}`,
|
|
@@ -131,18 +131,13 @@ async function decideWebhookRequest(params) {
|
|
|
131
131
|
logLevel: "warn",
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (!params.security.rateLimit.enabled) {
|
|
145
|
-
return { kind: "accept", message, release: () => undefined };
|
|
134
|
+
const replay = params.replayCache.checkAndRemember(buildReplayKey(message));
|
|
135
|
+
if (replay.kind === "duplicate") {
|
|
136
|
+
return rejectDecision({
|
|
137
|
+
status: 200,
|
|
138
|
+
reason: "duplicate_replay",
|
|
139
|
+
logLevel: "debug",
|
|
140
|
+
});
|
|
146
141
|
}
|
|
147
142
|
const rate = params.rateLimiter.evaluate({
|
|
148
143
|
ip: proxyValidation.context.clientIp,
|
|
@@ -164,6 +159,9 @@ export function createGroupMeWebhookHandler(params) {
|
|
|
164
159
|
if (!security.groupId) {
|
|
165
160
|
params.runtime.error?.("groupme: WARNING — no groupId configured; all inbound messages will be rejected. Set groupId in your account config.");
|
|
166
161
|
}
|
|
162
|
+
if (!security.callbackToken) {
|
|
163
|
+
params.runtime.error?.("groupme: WARNING — no callbackToken configured; inbound callbacks are not token-authenticated. Anyone who learns the webhook path and group_id can post. Set callbackToken in your account config.");
|
|
164
|
+
}
|
|
167
165
|
const replayCache = new GroupMeReplayCache({
|
|
168
166
|
ttlSeconds: security.replay.ttlSeconds,
|
|
169
167
|
maxEntries: security.replay.maxEntries,
|
package/dist/src/normalize.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export function normalizeStringId(raw) {
|
|
2
|
+
if (typeof raw !== "string" && typeof raw !== "number") {
|
|
3
|
+
return undefined;
|
|
4
|
+
}
|
|
5
|
+
if (typeof raw === "number" && !Number.isFinite(raw)) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
2
8
|
const normalized = String(raw).trim();
|
|
3
9
|
return normalized || undefined;
|
|
4
10
|
}
|