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.
Files changed (48) hide show
  1. package/README.md +147 -45
  2. package/channel-plugin-api.ts +3 -0
  3. package/dist/channel-plugin-api.js +3 -0
  4. package/dist/index.js +15 -9
  5. package/dist/runtime-setter-api.js +1 -0
  6. package/dist/secret-contract-api.js +1 -0
  7. package/dist/setup-entry.js +16 -0
  8. package/dist/setup-plugin-api.js +3 -0
  9. package/dist/src/accounts.js +24 -48
  10. package/dist/src/channel.js +63 -29
  11. package/dist/src/config-schema.js +10 -11
  12. package/dist/src/groupme-api.js +9 -5
  13. package/dist/src/inbound.js +18 -10
  14. package/dist/src/monitor.js +25 -27
  15. package/dist/src/normalize.js +6 -0
  16. package/dist/src/onboarding.js +364 -337
  17. package/dist/src/parse.js +4 -14
  18. package/dist/src/policy.js +1 -1
  19. package/dist/src/rate-limit.js +12 -7
  20. package/dist/src/replay-cache.js +0 -3
  21. package/dist/src/secret-contract.js +49 -0
  22. package/dist/src/security.js +17 -34
  23. package/dist/src/send.js +19 -13
  24. package/index.ts +15 -10
  25. package/openclaw.plugin.json +14 -15
  26. package/package.json +43 -9
  27. package/runtime-setter-api.ts +1 -0
  28. package/secret-contract-api.ts +5 -0
  29. package/setup-entry.ts +17 -0
  30. package/setup-plugin-api.ts +3 -0
  31. package/src/accounts.ts +29 -68
  32. package/src/channel.ts +74 -64
  33. package/src/config-schema.ts +10 -11
  34. package/src/groupme-api.ts +21 -5
  35. package/src/history.ts +1 -1
  36. package/src/inbound.ts +45 -75
  37. package/src/monitor.ts +37 -52
  38. package/src/normalize.ts +7 -1
  39. package/src/onboarding.ts +449 -409
  40. package/src/parse.ts +6 -23
  41. package/src/policy.ts +1 -4
  42. package/src/rate-limit.ts +15 -12
  43. package/src/replay-cache.ts +1 -4
  44. package/src/runtime.ts +1 -1
  45. package/src/secret-contract.ts +66 -0
  46. package/src/security.ts +28 -66
  47. package/src/send.ts +32 -38
  48. package/src/types.ts +7 -7
@@ -1,14 +1,15 @@
1
- import { applyAccountNameToChannelSection, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, migrateBaseNameToDefaultAccount, missingTargetError, normalizeAccountId, registerPluginHttpRoute, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk";
2
- import { listGroupMeAccountIds, resolveDefaultGroupMeAccountId, resolveGroupMeAccount, } from "./accounts.js";
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, looksLikeGroupMeTargetId, } from "./normalize.js";
7
+ import { looksLikeGroupMeTargetId, normalizeGroupMeAllowEntry, normalizeGroupMeTarget, } from "./normalize.js";
6
8
  import { groupmeOnboardingAdapter } from "./onboarding.js";
7
9
  import { getGroupMeRuntime } from "./runtime.js";
8
- import { redactCallbackUrl, resolveGroupMeSecurity } from "./security.js";
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 normalizeCallbackUrl(raw) {
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
- const noQuery = trimmed.split("?")[0] ?? trimmed;
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 redactWebhookPath(account, callbackUrl) {
29
- const normalized = callbackUrl?.trim() || "/groupme";
30
- const security = resolveGroupMeSecurity(account.config);
31
- if (!security.logging.redactSecrets) {
32
- return normalized;
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
- onboarding: groupmeOnboardingAdapter,
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
- updates.callbackUrl = input.webhookUrl.trim();
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
- updates.callbackUrl = input.webhookPath.trim();
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
- "callbackUrl",
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
- callbackUrl: redactWebhookPath(account, account.config.callbackUrl),
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
- callbackUrl: snapshot.webhookPath ?? null,
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: redactWebhookPath(account, account.config.callbackUrl),
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 = normalizeCallbackUrl(account.config.callbackUrl);
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: redactedCallbackPath,
374
+ webhookPath: callbackPath,
350
375
  lastStartAt: Date.now(),
351
376
  lastError: null,
352
377
  });
353
- ctx.log?.info(`[${account.accountId}] GroupMe webhook listening on ${redactedCallbackPath}`);
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
- export const GroupMeAccountSchemaBase = z
58
+ const GroupMeAccountSchemaBase = z
59
59
  .object({
60
60
  name: z.string().optional(),
61
61
  enabled: z.boolean().optional(),
62
- botId: z.string().optional(),
63
- accessToken: z.string().optional(),
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
- callbackUrl: z.string().optional(),
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();
@@ -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(`${GROUPME_API_BASE}/groups`);
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 fetch(url);
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 url = new URL(`${GROUPME_API_BASE}/bots`);
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 fetch(url, {
66
+ const response = await fetchFn(url, {
63
67
  method: "POST",
64
68
  headers: { "content-type": "application/json" },
65
69
  body: JSON.stringify({
@@ -1,10 +1,14 @@
1
- import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, createReplyPrefixOptions, logInboundDrop, recordPendingHistoryEntryIfEnabled, resolveControlCommandGate, resolveMentionGatingWithBypass, } from "openclaw/plugin-sdk";
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 { extractImageUrls, detectGroupMeMention } from "./parse.js";
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, } = params;
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) ?? [])]
@@ -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 { GroupMeReplayCache, buildReplayKey } from "./replay-cache.js";
7
- import { checkGroupBinding, redactCallbackUrl, resolveGroupMeSecurity, validateProxyRequest, verifyCallbackAuth, } from "./security.js";
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 ?? "warn",
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
- ? redactCallbackUrl(`${params.reqUrl.pathname}${params.reqUrl.search}`, params.security)
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
- let status;
92
- if (body.code === "PAYLOAD_TOO_LARGE") {
93
- status = 413;
94
- }
95
- else if (body.code === "REQUEST_BODY_TIMEOUT") {
96
- status = 408;
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
- if (params.security.replay.enabled) {
135
- const replay = params.replayCache.checkAndRemember(buildReplayKey(message));
136
- if (replay.kind === "duplicate") {
137
- return rejectDecision({
138
- status: 200,
139
- reason: "duplicate_replay",
140
- logLevel: "debug",
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,
@@ -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
  }