openclaw-groupme 0.3.0 → 0.4.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 CHANGED
@@ -5,7 +5,13 @@ An [OpenClaw](https://github.com/oddrationale/openclaw) channel plugin that brin
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- openclaw plugins install openclaw-groupme
8
+ openclaw plugins install clawhub:openclaw-groupme
9
+ ```
10
+
11
+ You can also install directly from npm if you want npm to be the explicit source:
12
+
13
+ ```bash
14
+ openclaw plugins install npm:openclaw-groupme
9
15
  ```
10
16
 
11
17
  After installing, restart the gateway so it picks up the new plugin:
@@ -148,6 +154,20 @@ If you prefer editing config files directly, here's what a complete setup looks
148
154
  }
149
155
  ```
150
156
 
157
+ ## Reconfiguring an Existing Setup
158
+
159
+ If GroupMe is already configured and you run `openclaw configure` again, you'll get a menu of targeted actions instead of repeating the full wizard:
160
+
161
+ - **Skip** — leave everything as-is
162
+ - **Rotate access token** — replace the stored access token (validates the new token by fetching your groups)
163
+ - **Change group** — pick a different GroupMe group and optionally register a new bot in it
164
+ - **Regenerate callback URL** — create a new random callback path and secret token (you'll need to update the bot's callback URL in GroupMe afterward)
165
+ - **Toggle requireMention** — flip mention-required mode on or off
166
+ - **Update public domain** — change the public domain used for callback URLs
167
+ - **Full re-setup** — start from scratch with the interactive wizard
168
+
169
+ This lets you make quick adjustments — like rotating a leaked token or moving the bot to a different group — without tearing down the whole config.
170
+
151
171
  ## Response Modes
152
172
 
153
173
  ### Always respond (`requireMention: false`)
@@ -349,7 +369,18 @@ https://bot.example.com/groupme/e60b3e59da98950f?k=775c9958da544c73e6d97c04f8849
349
369
 
350
370
  ## Notes and Limitations
351
371
 
352
- - **Group chats only** — no DM support (GroupMe Bot API limitation)
372
+ ### GroupMe Bot API Constraints
373
+
374
+ GroupMe bots are intentionally limited compared to full user accounts. These constraints come from the GroupMe Bot API itself, not from this plugin:
375
+
376
+ - **Group chats only** — bots cannot send or receive direct messages. Each bot is bound to a single group.
377
+ - **One group per bot** — a bot registration is tied to exactly one group. To serve multiple groups, register a separate bot (and account) for each.
378
+ - **No message history** — bots only see messages as they arrive via the callback webhook. They cannot fetch past messages from the group. (This plugin buffers recent messages locally for context when `requireMention` is enabled.)
379
+ - **No reactions** — bots cannot like or unlike messages.
380
+ - **Text and images only** — bots can post text and attach a single image per message. Other attachment types (video, files, locations) are not supported.
381
+
382
+ ### Plugin-Specific Notes
383
+
353
384
  - Bot and system messages from GroupMe are automatically ignored
354
385
  - GroupMe has a 1000-character limit per message — longer replies are chunked automatically
355
386
  - Image replies require `accessToken` so the plugin can upload images to GroupMe's Image Service
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-groupme",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "OpenClaw GroupMe channel plugin",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -36,12 +36,13 @@
36
36
  "zod": ">=4.3.6 <5"
37
37
  },
38
38
  "devDependencies": {
39
- "openclaw": ">=2026.2.14",
40
- "typescript": "^5.9.3",
39
+ "@types/node": "20.19.41",
40
+ "openclaw": "2026.2.26",
41
+ "typescript": "^6.0.3",
41
42
  "vitest": "^4.0.18"
42
43
  },
43
44
  "peerDependencies": {
44
- "openclaw": ">=2026.2.14"
45
+ "openclaw": ">=2026.2.26 <2026.6.0"
45
46
  },
46
47
  "peerDependenciesMeta": {
47
48
  "openclaw": {
@@ -52,6 +53,12 @@
52
53
  "extensions": [
53
54
  "./index.ts"
54
55
  ],
56
+ "compat": {
57
+ "pluginApi": ">=2026.2.26 <2026.6.0"
58
+ },
59
+ "build": {
60
+ "openclawVersion": "2026.2.26"
61
+ },
55
62
  "channel": {
56
63
  "id": "groupme",
57
64
  "label": "GroupMe",
package/src/channel.ts CHANGED
@@ -164,6 +164,15 @@ export const groupmePlugin: ChannelPlugin<
164
164
  },
165
165
  };
166
166
  },
167
+
168
+ resolveBindingAccountId: ({ cfg, accountId }) => {
169
+ if (accountId) return accountId;
170
+ const ids = listGroupMeAccountIds(cfg as CoreConfig);
171
+ if (ids.length <= 1) return DEFAULT_ACCOUNT_ID;
172
+ const section = (cfg as CoreConfig).channels?.groupme;
173
+ const explicitDefault = section?.defaultAccount?.trim();
174
+ return explicitDefault ? resolveDefaultGroupMeAccountId(cfg as CoreConfig) : undefined;
175
+ },
167
176
  } satisfies ChannelSetupAdapter,
168
177
  capabilities: {
169
178
  chatTypes: ["group"],
package/src/inbound.ts CHANGED
@@ -365,6 +365,7 @@ export async function handleGroupMeInbound(params: {
365
365
  Timestamp: inboundTimestamp,
366
366
  OriginatingChannel: CHANNEL_ID,
367
367
  OriginatingTo: `groupme:group:${message.groupId}`,
368
+ GroupSpace: message.groupId,
368
369
  CommandAuthorized: commandGate.commandAuthorized,
369
370
  MediaUrl: imageUrls[0],
370
371
  MediaUrls: imageUrls.length > 0 ? imageUrls : undefined,
package/src/monitor.ts CHANGED
@@ -24,6 +24,11 @@ import type {
24
24
  WebhookDecision,
25
25
  } from "./types.js";
26
26
 
27
+ // GroupMe callbacks are small JSON payloads; use tighter limits than the SDK
28
+ // defaults (DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1 MB, DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30 s).
29
+ const GROUPME_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
30
+ const GROUPME_WEBHOOK_BODY_TIMEOUT_MS = 15_000;
31
+
27
32
  export type GroupMeWebhookHandlerParams = {
28
33
  account: ResolvedGroupMeAccount;
29
34
  config: CoreConfig;
@@ -138,8 +143,8 @@ async function decideWebhookRequest(params: {
138
143
  }
139
144
 
140
145
  const body = await readJsonBodyWithLimit(params.req, {
141
- maxBytes: 64 * 1024,
142
- timeoutMs: 15_000,
146
+ maxBytes: GROUPME_WEBHOOK_MAX_BODY_BYTES,
147
+ timeoutMs: GROUPME_WEBHOOK_BODY_TIMEOUT_MS,
143
148
  emptyObjectOnEmpty: false,
144
149
  });
145
150
  if (!body.ok) {
@@ -226,6 +231,11 @@ export function createGroupMeWebhookHandler(
226
231
  params.account.config.historyLimit,
227
232
  );
228
233
  const security = resolveGroupMeSecurity(params.account.config);
234
+ if (!security.groupId) {
235
+ params.runtime.error?.(
236
+ "groupme: WARNING — no groupId configured; all inbound messages will be rejected. Set groupId in your account config.",
237
+ );
238
+ }
229
239
  const replayCache = new GroupMeReplayCache({
230
240
  ttlSeconds: security.replay.ttlSeconds,
231
241
  maxEntries: security.replay.maxEntries,
package/src/onboarding.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import type { ChannelOnboardingAdapter } from "openclaw/plugin-sdk";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import type {
3
+ ChannelOnboardingAdapter,
4
+ OpenClawConfig,
5
+ } from "openclaw/plugin-sdk";
4
6
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
5
7
  import { resolveGroupMeAccount } from "./accounts.js";
6
8
  import { createBot, fetchGroups } from "./groupme-api.js";
@@ -48,6 +50,27 @@ function applyGroupMeConfig(params: {
48
50
  };
49
51
  }
50
52
 
53
+ function parsePublicDomain(raw: string): string {
54
+ const trimmed = raw.trim();
55
+ try {
56
+ if (/^https?:\/\//i.test(trimmed)) {
57
+ const url = new URL(trimmed);
58
+ return url.port ? `${url.hostname}:${url.port}` : url.hostname;
59
+ }
60
+ const withoutLeadingSlashes = trimmed.replace(/^\/+/, "");
61
+ return withoutLeadingSlashes.split(/[\/?#]/, 1)[0];
62
+ } catch {
63
+ const noScheme = trimmed.replace(/^https?:\/\//i, "");
64
+ return noScheme.split(/[\/?#]/, 1)[0];
65
+ }
66
+ }
67
+
68
+ function generateCallbackUrl(): string {
69
+ const pathSegment = randomBytes(8).toString("hex");
70
+ const callbackToken = randomBytes(32).toString("hex");
71
+ return `/groupme/${pathSegment}?k=${callbackToken}`;
72
+ }
73
+
51
74
  function redactMiddle(value: string): string {
52
75
  if (value.length <= 10) {
53
76
  return value;
@@ -158,28 +181,18 @@ export const groupmeOnboardingAdapter: ChannelOnboardingAdapter = {
158
181
  if (!normalized) {
159
182
  return "Public domain is required";
160
183
  }
184
+ const parsed = parsePublicDomain(trimmed);
185
+ if (!parsed) {
186
+ return "Public domain is required";
187
+ }
161
188
  return undefined;
162
189
  },
163
190
  })
164
191
  ).trim();
165
- let publicDomain: string;
166
- try {
167
- if (/^https?:\/\//i.test(publicDomainRaw)) {
168
- const url = new URL(publicDomainRaw);
169
- publicDomain = url.port ? `${url.hostname}:${url.port}` : url.hostname;
170
- } else {
171
- const withoutLeadingSlashes = publicDomainRaw.replace(/^\/+/, "");
172
- publicDomain = withoutLeadingSlashes.split(/[\/?#]/, 1)[0];
173
- }
174
- } catch {
175
- // Fallback: best-effort stripping of scheme and any path/query/fragment
176
- const noScheme = publicDomainRaw.replace(/^https?:\/\//i, "");
177
- publicDomain = noScheme.split(/[\/?#]/, 1)[0];
178
- }
192
+ const publicDomain = parsePublicDomain(publicDomainRaw);
179
193
 
180
- const pathSegment = randomBytes(8).toString("hex");
181
- const callbackToken = randomBytes(32).toString("hex");
182
- const callbackUrl = `/groupme/${pathSegment}?k=${callbackToken}`;
194
+ const callbackUrl = generateCallbackUrl();
195
+ const pathSegment = callbackUrl.split("?")[0].split("/").pop()!;
183
196
  await prompter.note(
184
197
  `Generated webhook callback URL: /groupme/${pathSegment}?k=***`,
185
198
  "Generated callback URL",
@@ -241,6 +254,268 @@ export const groupmeOnboardingAdapter: ChannelOnboardingAdapter = {
241
254
  accountId,
242
255
  };
243
256
  },
257
+ configureWhenConfigured: async ({
258
+ cfg,
259
+ prompter,
260
+ runtime,
261
+ accountOverrides,
262
+ }) => {
263
+ const accountId = accountOverrides.groupme ?? DEFAULT_ACCOUNT_ID;
264
+ const account = resolveGroupMeAccount({
265
+ cfg: cfg as CoreConfig,
266
+ accountId,
267
+ });
268
+
269
+ const action = await prompter.select<string>({
270
+ message: "GroupMe is already configured. What would you like to do?",
271
+ options: [
272
+ { value: "skip", label: "Skip", hint: "no changes" },
273
+ { value: "rotate_token", label: "Rotate access token" },
274
+ { value: "change_group", label: "Change group" },
275
+ { value: "regen_callback", label: "Regenerate callback URL" },
276
+ { value: "toggle_mention", label: "Toggle requireMention" },
277
+ { value: "update_domain", label: "Update public domain" },
278
+ { value: "full_setup", label: "Full re-setup", hint: "start from scratch" },
279
+ ],
280
+ });
281
+
282
+ if (action === "skip") {
283
+ return "skip";
284
+ }
285
+
286
+ if (action === "full_setup") {
287
+ return groupmeOnboardingAdapter.configure({
288
+ cfg,
289
+ prompter,
290
+ runtime,
291
+ accountOverrides,
292
+ options: {},
293
+ shouldPromptAccountIds: false,
294
+ forceAllowFrom: false,
295
+ });
296
+ }
297
+
298
+ if (action === "rotate_token") {
299
+ const newToken = (
300
+ await prompter.text({
301
+ message: "New GroupMe access token",
302
+ validate: (value) =>
303
+ value.trim() ? undefined : "Access token is required",
304
+ })
305
+ ).trim();
306
+
307
+ const spin = prompter.progress("Validating access token...");
308
+ try {
309
+ await fetchGroups(newToken);
310
+ spin.stop("Token validated");
311
+ } catch {
312
+ spin.stop("Failed");
313
+ await prompter.note(
314
+ "Could not validate token. Check your access token and try again.",
315
+ "Validation failed",
316
+ );
317
+ throw new Error("Could not validate access token");
318
+ }
319
+
320
+ const next = applyGroupMeConfig({
321
+ cfg,
322
+ accountId,
323
+ updates: { accessToken: newToken },
324
+ });
325
+ await prompter.note("Access token updated.", "Token rotated");
326
+ return { cfg: next, accountId };
327
+ }
328
+
329
+ if (action === "change_group") {
330
+ const existingToken = account.accessToken;
331
+ if (!existingToken) {
332
+ await prompter.note(
333
+ "No access token configured. Use \"Rotate access token\" first.",
334
+ "Missing token",
335
+ );
336
+ return "skip";
337
+ }
338
+
339
+ const spin = prompter.progress("Fetching your GroupMe groups...");
340
+ let groups: Awaited<ReturnType<typeof fetchGroups>>;
341
+ try {
342
+ groups = await fetchGroups(existingToken);
343
+ } catch {
344
+ spin.stop("Failed");
345
+ await prompter.note(
346
+ "Could not fetch groups. Check your access token and try again.",
347
+ "GroupMe error",
348
+ );
349
+ throw new Error("Could not fetch groups");
350
+ }
351
+
352
+ if (groups.length === 0) {
353
+ spin.stop("No groups found");
354
+ await prompter.note(
355
+ "No groups found. Create or join a GroupMe group first.",
356
+ "No groups",
357
+ );
358
+ return "skip";
359
+ }
360
+ spin.stop(`Found ${groups.length} groups`);
361
+
362
+ const newGroupId = await prompter.select<string>({
363
+ message: "Select a GroupMe group",
364
+ options: groups.map((group) => ({
365
+ value: group.id,
366
+ label: group.name || group.id,
367
+ hint: group.id === account.config.groupId ? "current" : group.id,
368
+ })),
369
+ });
370
+
371
+ const selectedGroup = groups.find((g) => g.id === newGroupId);
372
+ const updates: Record<string, unknown> = { groupId: newGroupId };
373
+
374
+ const registerNew = await prompter.confirm({
375
+ message: "Register a new bot in this group?",
376
+ initialValue: true,
377
+ });
378
+
379
+ if (!registerNew) {
380
+ const newBotId = (
381
+ await prompter.text({
382
+ message:
383
+ "Bot ID for the new group (existing bot won't work in a different group)",
384
+ validate: (value) =>
385
+ value.trim() ? undefined : "Bot ID is required",
386
+ })
387
+ ).trim();
388
+ updates.botId = newBotId;
389
+ }
390
+
391
+ if (registerNew) {
392
+ const botName = account.config.botName || "openclaw";
393
+ let publicDomain = account.config.publicDomain;
394
+ if (!publicDomain) {
395
+ const domainRaw = (
396
+ await prompter.text({
397
+ message: "Public domain (required for bot registration)",
398
+ validate: (value) => {
399
+ const trimmed = value.trim();
400
+ if (!trimmed) return "Public domain is required";
401
+ const normalized = trimmed
402
+ .replace(/^https?:\/\//, "")
403
+ .replace(/\/+$/, "");
404
+ if (!normalized) return "Public domain is required";
405
+ const parsed = parsePublicDomain(trimmed);
406
+ if (!parsed) return "Public domain is required";
407
+ return undefined;
408
+ },
409
+ })
410
+ ).trim();
411
+ publicDomain = parsePublicDomain(domainRaw);
412
+ updates.publicDomain = publicDomain;
413
+ }
414
+ let rawCallbackUrl = account.config.callbackUrl;
415
+ if (!rawCallbackUrl) {
416
+ rawCallbackUrl = generateCallbackUrl();
417
+ updates.callbackUrl = rawCallbackUrl;
418
+ }
419
+ const parsedCallback = new URL(rawCallbackUrl, "http://localhost");
420
+ const callbackPath = `${parsedCallback.pathname}${parsedCallback.search}`;
421
+
422
+ const botSpin = prompter.progress("Registering bot with GroupMe...");
423
+ try {
424
+ const bot = await createBot({
425
+ accessToken: existingToken,
426
+ name: botName,
427
+ groupId: newGroupId,
428
+ callbackUrl: `https://${publicDomain}${callbackPath}`,
429
+ });
430
+ updates.botId = bot.bot_id;
431
+ botSpin.stop("Bot registered");
432
+ } catch (error) {
433
+ botSpin.stop("Failed");
434
+ const detail =
435
+ error instanceof Error ? `\n\nDetails: ${error.message}` : "";
436
+ await prompter.note(
437
+ `Failed to register bot.${detail}`,
438
+ "Bot registration failed",
439
+ );
440
+ throw new Error("Failed to register GroupMe bot", {
441
+ cause: error instanceof Error ? error : undefined,
442
+ });
443
+ }
444
+ }
445
+
446
+ const next = applyGroupMeConfig({ cfg, accountId, updates });
447
+ await prompter.note(
448
+ `Group changed to "${selectedGroup?.name ?? newGroupId}".`,
449
+ "Group updated",
450
+ );
451
+ return { cfg: next, accountId };
452
+ }
453
+
454
+ if (action === "regen_callback") {
455
+ const callbackUrl = generateCallbackUrl();
456
+ const next = applyGroupMeConfig({
457
+ cfg,
458
+ accountId,
459
+ updates: { callbackUrl },
460
+ });
461
+ await prompter.note(
462
+ [
463
+ "Callback URL regenerated.",
464
+ "Remember to update your GroupMe bot settings or re-register the bot.",
465
+ ].join("\n"),
466
+ "Callback URL updated",
467
+ );
468
+ return { cfg: next, accountId };
469
+ }
470
+
471
+ if (action === "toggle_mention") {
472
+ const current = account.config.requireMention ?? true;
473
+ const next = applyGroupMeConfig({
474
+ cfg,
475
+ accountId,
476
+ updates: { requireMention: !current },
477
+ });
478
+ await prompter.note(
479
+ `requireMention changed from ${current} to ${!current}.`,
480
+ "Mention setting updated",
481
+ );
482
+ return { cfg: next, accountId };
483
+ }
484
+
485
+ if (action === "update_domain") {
486
+ const newDomainRaw = (
487
+ await prompter.text({
488
+ message: "New public domain",
489
+ initialValue: account.config.publicDomain ?? "",
490
+ validate: (value) => {
491
+ const trimmed = value.trim();
492
+ if (!trimmed) return "Public domain is required";
493
+ const normalized = trimmed
494
+ .replace(/^https?:\/\//, "")
495
+ .replace(/\/+$/, "");
496
+ if (!normalized) return "Public domain is required";
497
+ const parsed = parsePublicDomain(trimmed);
498
+ if (!parsed) return "Public domain is required";
499
+ return undefined;
500
+ },
501
+ })
502
+ ).trim();
503
+
504
+ const publicDomain = parsePublicDomain(newDomainRaw);
505
+ const next = applyGroupMeConfig({
506
+ cfg,
507
+ accountId,
508
+ updates: { publicDomain },
509
+ });
510
+ await prompter.note(
511
+ `Public domain updated to "${publicDomain}".`,
512
+ "Domain updated",
513
+ );
514
+ return { cfg: next, accountId };
515
+ }
516
+
517
+ return "skip";
518
+ },
244
519
  disable: (cfg) => ({
245
520
  ...cfg,
246
521
  channels: {
package/src/security.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { timingSafeEqual } from "node:crypto";
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
2
  import type { IncomingHttpHeaders } from "node:http";
3
3
  import { BlockList, isIP } from "node:net";
4
4
  import { readTrimmed } from "./accounts.js";
@@ -309,12 +309,9 @@ export function resolveGroupMeSecurity(
309
309
  }
310
310
 
311
311
  function safeEqualToken(left: string, right: string): boolean {
312
- const leftBuffer = Buffer.from(left);
313
- const rightBuffer = Buffer.from(right);
314
- if (leftBuffer.length !== rightBuffer.length) {
315
- return false;
316
- }
317
- return timingSafeEqual(leftBuffer, rightBuffer);
312
+ const leftHash = createHash("sha256").update(left).digest();
313
+ const rightHash = createHash("sha256").update(right).digest();
314
+ return timingSafeEqual(leftHash, rightHash);
318
315
  }
319
316
 
320
317
  export function verifyCallbackAuth(params: {