openclaw-groupme 0.4.3 → 0.5.0

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
package/src/onboarding.ts CHANGED
@@ -1,13 +1,24 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import type {
3
- ChannelOnboardingAdapter,
4
- OpenClawConfig,
5
- } from "openclaw/plugin-sdk";
6
- import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
7
- import { resolveGroupMeAccount } from "./accounts.js";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
3
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/core";
4
+ import type { ChannelSetupWizardAdapter } from "openclaw/plugin-sdk/setup";
5
+ import { hasSecretInput, resolveGroupMeAccount } from "./accounts.js";
8
6
  import { createBot, fetchGroups } from "./groupme-api.js";
9
7
  import type { CoreConfig, GroupMeConfig } from "./types.js";
10
8
 
9
+ type GroupMeOnboardingApi = {
10
+ fetchGroups?: typeof fetchGroups;
11
+ createBot?: typeof createBot;
12
+ };
13
+
14
+ function readSecretInputString(value: unknown): string | undefined {
15
+ if (typeof value !== "string") {
16
+ return undefined;
17
+ }
18
+ const trimmed = value.trim();
19
+ return trimmed || undefined;
20
+ }
21
+
11
22
  function applyGroupMeConfig(params: {
12
23
  cfg: OpenClawConfig;
13
24
  accountId: string;
@@ -52,23 +63,71 @@ function applyGroupMeConfig(params: {
52
63
 
53
64
  function parsePublicDomain(raw: string): string {
54
65
  const trimmed = raw.trim();
66
+ let candidate = "";
55
67
  try {
56
68
  if (/^https?:\/\//i.test(trimmed)) {
57
69
  const url = new URL(trimmed);
58
- return url.port ? `${url.hostname}:${url.port}` : url.hostname;
70
+ candidate = url.port ? `${url.hostname}:${url.port}` : url.hostname;
71
+ } else {
72
+ const withoutLeadingSlashes = trimmed.replace(/^\/+/, "");
73
+ candidate = withoutLeadingSlashes.split(/[/?#]/, 1)[0] ?? "";
59
74
  }
60
- const withoutLeadingSlashes = trimmed.replace(/^\/+/, "");
61
- return withoutLeadingSlashes.split(/[\/?#]/, 1)[0];
62
75
  } catch {
63
76
  const noScheme = trimmed.replace(/^https?:\/\//i, "");
64
- return noScheme.split(/[\/?#]/, 1)[0];
77
+ candidate = noScheme.split(/[/?#]/, 1)[0] ?? "";
78
+ }
79
+
80
+ if (!candidate || /[\s/?#@]/.test(candidate)) {
81
+ return "";
82
+ }
83
+
84
+ try {
85
+ const url = new URL(`https://${candidate}`);
86
+ return url.hostname ? candidate : "";
87
+ } catch {
88
+ return "";
65
89
  }
66
90
  }
67
91
 
68
- function generateCallbackUrl(): string {
92
+ function validatePublicDomainInput(value: string): string | undefined {
93
+ const trimmed = value.trim();
94
+ if (!trimmed) {
95
+ return "Public domain is required";
96
+ }
97
+ if (!parsePublicDomain(trimmed)) {
98
+ return "Public domain must be a valid host";
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ function requirePublicDomain(raw: string): string {
104
+ const publicDomain = parsePublicDomain(raw);
105
+ if (!publicDomain) {
106
+ throw new Error("Invalid public domain");
107
+ }
108
+ return publicDomain;
109
+ }
110
+
111
+ function generateCallbackSettings(): {
112
+ webhookPath: string;
113
+ callbackToken: string;
114
+ } {
69
115
  const pathSegment = randomBytes(8).toString("hex");
70
- const callbackToken = randomBytes(32).toString("hex");
71
- return `/groupme/${pathSegment}?k=${callbackToken}`;
116
+ return {
117
+ webhookPath: `/groupme/${pathSegment}`,
118
+ callbackToken: randomBytes(32).toString("hex"),
119
+ };
120
+ }
121
+
122
+ function buildPublicCallbackUrl(params: {
123
+ publicDomain: string;
124
+ webhookPath: string;
125
+ callbackToken: string;
126
+ }): string {
127
+ const path = params.webhookPath.startsWith("/") ? params.webhookPath : `/${params.webhookPath}`;
128
+ const url = new URL(`https://${params.publicDomain}${path}`);
129
+ url.searchParams.set("k", params.callbackToken);
130
+ return url.toString();
72
131
  }
73
132
 
74
133
  function redactMiddle(value: string): string {
@@ -78,452 +137,433 @@ function redactMiddle(value: string): string {
78
137
  return `${value.slice(0, 6)}...${value.slice(-3)}`;
79
138
  }
80
139
 
81
- export const groupmeOnboardingAdapter: ChannelOnboardingAdapter = {
82
- channel: "groupme",
83
- getStatus: async ({ cfg, accountOverrides }) => {
84
- const accountId = accountOverrides.groupme ?? DEFAULT_ACCOUNT_ID;
85
- const account = resolveGroupMeAccount({
86
- cfg: cfg as CoreConfig,
87
- accountId,
88
- });
89
-
90
- const configured = account.configured;
91
- const callbackUrlConfigured = Boolean(account.config.callbackUrl?.trim());
92
- const groupIdConfigured = Boolean(account.config.groupId?.trim());
93
- const publicDomainConfigured = Boolean(account.config.publicDomain?.trim());
94
-
95
- return {
96
- channel: "groupme",
97
- configured,
98
- statusLines: [
99
- `GroupMe (${accountId}): ${configured ? "configured" : "needs access token"}`,
100
- account.config.accessToken?.trim()
101
- ? "Access token configured"
102
- : "Access token missing",
103
- callbackUrlConfigured
104
- ? "Webhook callback URL configured"
105
- : "Webhook callback URL missing",
106
- publicDomainConfigured
107
- ? "Public domain configured"
108
- : "Public domain missing",
109
- groupIdConfigured ? "Group ID configured" : "Group ID missing",
110
- ],
111
- selectionHint: configured ? "configured" : "needs access token",
112
- quickstartScore: configured ? 1 : 0,
113
- };
114
- },
115
- configure: async ({ cfg, prompter, accountOverrides }) => {
116
- const accountId = accountOverrides.groupme ?? DEFAULT_ACCOUNT_ID;
117
-
118
- const botNameInput = (
119
- await prompter.text({
120
- message: "Bot name",
121
- initialValue: "openclaw",
122
- })
123
- ).trim();
124
- const botName = botNameInput || "openclaw";
125
-
126
- const accessToken = (
127
- await prompter.text({
128
- message: "GroupMe access token",
129
- validate: (value) =>
130
- value.trim() ? undefined : "Access token is required",
131
- })
132
- ).trim();
133
-
134
- const groupsSpin = prompter.progress("Fetching your GroupMe groups...");
135
- let groups: Awaited<ReturnType<typeof fetchGroups>>;
136
- try {
137
- groups = await fetchGroups(accessToken);
138
- } catch {
139
- groupsSpin.stop("Failed");
140
- await prompter.note(
141
- "Could not fetch groups. Check your access token and try again.",
142
- "GroupMe setup failed",
143
- );
144
- throw new Error("Could not fetch groups");
145
- }
146
-
147
- if (groups.length === 0) {
148
- groupsSpin.stop("No groups found");
149
- await prompter.note(
150
- "No groups found. Create or join a GroupMe group first.",
151
- "GroupMe setup failed",
152
- );
153
- throw new Error("No GroupMe groups found");
154
- }
155
- groupsSpin.stop(`Found ${groups.length} groups`);
156
-
157
- const groupId = await prompter.select<string>({
158
- message: "Select a GroupMe group",
159
- options: groups.map((group) => ({
160
- value: group.id,
161
- label: group.name || group.id,
162
- hint: group.id,
163
- })),
164
- });
165
- const selectedGroup = groups.find((group) => group.id === groupId);
166
- const requireMention = await prompter.confirm({
167
- message: "Require mention to respond?",
168
- initialValue: true,
169
- });
170
- const publicDomainRaw = (
171
- await prompter.text({
172
- message: "Public domain (must be reachable — GroupMe will ping it)",
173
- validate: (value) => {
174
- const trimmed = value.trim();
175
- if (!trimmed) {
176
- return "Public domain is required";
177
- }
178
- const normalized = trimmed
179
- .replace(/^https?:\/\//, "")
180
- .replace(/\/+$/, "");
181
- if (!normalized) {
182
- return "Public domain is required";
183
- }
184
- const parsed = parsePublicDomain(trimmed);
185
- if (!parsed) {
186
- return "Public domain is required";
187
- }
188
- return undefined;
189
- },
190
- })
191
- ).trim();
192
- const publicDomain = parsePublicDomain(publicDomainRaw);
193
-
194
- const callbackUrl = generateCallbackUrl();
195
- const pathSegment = callbackUrl.split("?")[0].split("/").pop()!;
196
- await prompter.note(
197
- `Generated webhook callback URL: /groupme/${pathSegment}?k=***`,
198
- "Generated callback URL",
199
- );
200
-
201
- const botSpin = prompter.progress("Registering bot with GroupMe...");
202
- let botId = "";
203
- try {
204
- const bot = await createBot({
205
- accessToken,
206
- name: botName,
207
- groupId,
208
- callbackUrl: `https://${publicDomain}${callbackUrl}`,
209
- });
210
- botId = bot.bot_id;
211
- botSpin.stop("Bot registered");
212
- } catch (error) {
213
- botSpin.stop("Failed");
214
- const detail = error instanceof Error ? `\n\nDetails: ${error.message}` : "";
215
- await prompter.note(
216
- `Failed to register bot with GroupMe. Check your access token and try again.${detail}`,
217
- "GroupMe setup failed",
218
- );
219
- throw new Error("Failed to register GroupMe bot", {
220
- cause: error instanceof Error ? error : undefined,
140
+ export function createGroupMeOnboardingAdapter(
141
+ api: GroupMeOnboardingApi = {},
142
+ ): ChannelSetupWizardAdapter {
143
+ const fetchGroupsImpl = api.fetchGroups ?? fetchGroups;
144
+ const createBotImpl = api.createBot ?? createBot;
145
+
146
+ const adapter: ChannelSetupWizardAdapter = {
147
+ channel: "groupme",
148
+ getStatus: async ({ cfg, accountOverrides }) => {
149
+ const accountId = accountOverrides.groupme ?? DEFAULT_ACCOUNT_ID;
150
+ const account = resolveGroupMeAccount({
151
+ cfg: cfg as CoreConfig,
152
+ accountId,
221
153
  });
222
- }
223
-
224
- await prompter.note(
225
- `Bot "${botName}" registered in group "${selectedGroup?.name ?? groupId}" (bot ID: ${redactMiddle(botId)})`,
226
- "GroupMe bot registered",
227
- );
228
-
229
- const next = applyGroupMeConfig({
230
- cfg,
231
- accountId,
232
- updates: {
233
- botName,
234
- accessToken,
235
- botId,
236
- groupId,
237
- publicDomain,
238
- callbackUrl,
239
- requireMention,
240
- },
241
- });
242
-
243
- await prompter.note(
244
- [
245
- "Next steps:",
246
- "1. Restart the gateway: openclaw gateway restart",
247
- "2. Send a message in the group to test",
248
- ].join("\n"),
249
- "GroupMe next steps",
250
- );
251
-
252
- return {
253
- cfg: next,
254
- accountId,
255
- };
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
154
 
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
- }
155
+ const configured = account.configured;
156
+ const webhookPathConfigured = Boolean(account.config.webhookPath?.trim());
157
+ const callbackTokenConfigured = hasSecretInput(account.config.callbackToken);
158
+ const groupIdConfigured = Boolean(account.config.groupId?.trim());
159
+ const publicDomainConfigured = Boolean(account.config.publicDomain?.trim());
160
+
161
+ return {
162
+ channel: "groupme",
163
+ configured,
164
+ statusLines: [
165
+ `GroupMe (${accountId}): ${configured ? "configured" : "needs access token"}`,
166
+ hasSecretInput(account.config.accessToken)
167
+ ? "Access token configured"
168
+ : "Access token missing",
169
+ webhookPathConfigured ? "Webhook path configured" : "Webhook path missing",
170
+ callbackTokenConfigured ? "Callback token configured" : "Callback token missing",
171
+ publicDomainConfigured ? "Public domain configured" : "Public domain missing",
172
+ groupIdConfigured ? "Group ID configured" : "Group ID missing",
173
+ ],
174
+ selectionHint: configured ? "configured" : "needs access token",
175
+ quickstartScore: configured ? 1 : 0,
176
+ };
177
+ },
178
+ configure: async ({ cfg, prompter, accountOverrides }) => {
179
+ const accountId = accountOverrides.groupme ?? DEFAULT_ACCOUNT_ID;
297
180
 
298
- if (action === "rotate_token") {
299
- const newToken = (
181
+ const botNameInput = (
300
182
  await prompter.text({
301
- message: "New GroupMe access token",
302
- validate: (value) =>
303
- value.trim() ? undefined : "Access token is required",
183
+ message: "Bot name",
184
+ initialValue: "openclaw",
304
185
  })
305
186
  ).trim();
187
+ const botName = botNameInput || "openclaw";
306
188
 
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
- }
189
+ const accessToken = (
190
+ await prompter.text({
191
+ message: "GroupMe access token",
192
+ validate: (value) => (value.trim() ? undefined : "Access token is required"),
193
+ })
194
+ ).trim();
338
195
 
339
- const spin = prompter.progress("Fetching your GroupMe groups...");
196
+ const groupsSpin = prompter.progress("Fetching your GroupMe groups...");
340
197
  let groups: Awaited<ReturnType<typeof fetchGroups>>;
341
198
  try {
342
- groups = await fetchGroups(existingToken);
199
+ groups = await fetchGroupsImpl(accessToken);
343
200
  } catch {
344
- spin.stop("Failed");
201
+ groupsSpin.stop("Failed");
345
202
  await prompter.note(
346
203
  "Could not fetch groups. Check your access token and try again.",
347
- "GroupMe error",
204
+ "GroupMe setup failed",
348
205
  );
349
206
  throw new Error("Could not fetch groups");
350
207
  }
351
208
 
352
209
  if (groups.length === 0) {
353
- spin.stop("No groups found");
210
+ groupsSpin.stop("No groups found");
354
211
  await prompter.note(
355
212
  "No groups found. Create or join a GroupMe group first.",
356
- "No groups",
213
+ "GroupMe setup failed",
357
214
  );
358
- return "skip";
215
+ throw new Error("No GroupMe groups found");
359
216
  }
360
- spin.stop(`Found ${groups.length} groups`);
217
+ groupsSpin.stop(`Found ${groups.length} groups`);
361
218
 
362
- const newGroupId = await prompter.select<string>({
219
+ const groupId = await prompter.select<string>({
363
220
  message: "Select a GroupMe group",
364
221
  options: groups.map((group) => ({
365
222
  value: group.id,
366
223
  label: group.name || group.id,
367
- hint: group.id === account.config.groupId ? "current" : group.id,
224
+ hint: group.id,
368
225
  })),
369
226
  });
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?",
227
+ const selectedGroup = groups.find((group) => group.id === groupId);
228
+ const requireMention = await prompter.confirm({
229
+ message: "Require mention to respond?",
376
230
  initialValue: true,
377
231
  });
232
+ const publicDomainRaw = (
233
+ await prompter.text({
234
+ message: "Public domain (must be reachable — GroupMe will ping it)",
235
+ validate: validatePublicDomainInput,
236
+ })
237
+ ).trim();
238
+ const publicDomain = requirePublicDomain(publicDomainRaw);
378
239
 
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}`;
240
+ const { webhookPath, callbackToken } = generateCallbackSettings();
241
+ const pathSegment = webhookPath.split("/").pop() ?? webhookPath;
242
+ await prompter.note(
243
+ `Generated webhook URL path and token placeholder: /groupme/${pathSegment}?k=***`,
244
+ "Generated webhook settings",
245
+ );
421
246
 
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
- }
247
+ const botSpin = prompter.progress("Registering bot with GroupMe...");
248
+ let botId = "";
249
+ try {
250
+ const bot = await createBotImpl({
251
+ accessToken,
252
+ name: botName,
253
+ groupId,
254
+ callbackUrl: buildPublicCallbackUrl({
255
+ publicDomain,
256
+ webhookPath,
257
+ callbackToken,
258
+ }),
259
+ });
260
+ botId = bot.bot_id;
261
+ botSpin.stop("Bot registered");
262
+ } catch (error) {
263
+ botSpin.stop("Failed");
264
+ const detail = error instanceof Error ? `\n\nDetails: ${error.message}` : "";
265
+ await prompter.note(
266
+ `Failed to register bot with GroupMe. Check your access token and try again.${detail}`,
267
+ "GroupMe setup failed",
268
+ );
269
+ throw new Error("Failed to register GroupMe bot", {
270
+ cause: error instanceof Error ? error : undefined,
271
+ });
444
272
  }
445
273
 
446
- const next = applyGroupMeConfig({ cfg, accountId, updates });
447
274
  await prompter.note(
448
- `Group changed to "${selectedGroup?.name ?? newGroupId}".`,
449
- "Group updated",
275
+ `Bot "${botName}" registered in group "${selectedGroup?.name ?? groupId}" (bot ID: ${redactMiddle(botId)})`,
276
+ "GroupMe bot registered",
450
277
  );
451
- return { cfg: next, accountId };
452
- }
453
278
 
454
- if (action === "regen_callback") {
455
- const callbackUrl = generateCallbackUrl();
456
279
  const next = applyGroupMeConfig({
457
280
  cfg,
458
281
  accountId,
459
- updates: { callbackUrl },
282
+ updates: {
283
+ botName,
284
+ accessToken,
285
+ botId,
286
+ groupId,
287
+ publicDomain,
288
+ webhookPath,
289
+ callbackToken,
290
+ requireMention,
291
+ },
460
292
  });
293
+
461
294
  await prompter.note(
462
295
  [
463
- "Callback URL regenerated.",
464
- "Remember to update your GroupMe bot settings or re-register the bot.",
296
+ "Next steps:",
297
+ "1. Restart the gateway: openclaw gateway restart",
298
+ "2. Send a message in the group to test",
465
299
  ].join("\n"),
466
- "Callback URL updated",
300
+ "GroupMe next steps",
467
301
  );
468
- return { cfg: next, accountId };
469
- }
470
302
 
471
- if (action === "toggle_mention") {
472
- const current = account.config.requireMention ?? true;
473
- const next = applyGroupMeConfig({
474
- cfg,
303
+ return {
304
+ cfg: next,
305
+ accountId,
306
+ };
307
+ },
308
+ configureWhenConfigured: async ({ cfg, prompter, runtime, accountOverrides }) => {
309
+ const accountId = accountOverrides.groupme ?? DEFAULT_ACCOUNT_ID;
310
+ const account = resolveGroupMeAccount({
311
+ cfg: cfg as CoreConfig,
475
312
  accountId,
476
- updates: { requireMention: !current },
477
313
  });
478
- await prompter.note(
479
- `requireMention changed from ${current} to ${!current}.`,
480
- "Mention setting updated",
481
- );
482
- return { cfg: next, accountId };
483
- }
484
314
 
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 },
315
+ const action = await prompter.select<string>({
316
+ message: "GroupMe is already configured. What would you like to do?",
317
+ options: [
318
+ { value: "skip", label: "Skip", hint: "no changes" },
319
+ { value: "rotate_token", label: "Rotate access token" },
320
+ { value: "change_group", label: "Change group" },
321
+ { value: "regen_callback", label: "Regenerate webhook settings" },
322
+ { value: "toggle_mention", label: "Toggle requireMention" },
323
+ { value: "update_domain", label: "Update public domain" },
324
+ { value: "full_setup", label: "Full re-setup", hint: "start from scratch" },
325
+ ],
509
326
  });
510
- await prompter.note(
511
- `Public domain updated to "${publicDomain}".`,
512
- "Domain updated",
513
- );
514
- return { cfg: next, accountId };
515
- }
516
327
 
517
- return "skip";
518
- },
519
- disable: (cfg) => ({
520
- ...cfg,
521
- channels: {
522
- ...cfg.channels,
523
- groupme: {
524
- ...(cfg.channels?.groupme ?? {}),
525
- enabled: false,
526
- },
328
+ if (action === "skip") {
329
+ return "skip";
330
+ }
331
+
332
+ if (action === "full_setup") {
333
+ return adapter.configure({
334
+ cfg,
335
+ prompter,
336
+ runtime,
337
+ accountOverrides,
338
+ options: {},
339
+ shouldPromptAccountIds: false,
340
+ forceAllowFrom: false,
341
+ });
342
+ }
343
+
344
+ if (action === "rotate_token") {
345
+ const newToken = (
346
+ await prompter.text({
347
+ message: "New GroupMe access token",
348
+ validate: (value) => (value.trim() ? undefined : "Access token is required"),
349
+ })
350
+ ).trim();
351
+
352
+ const spin = prompter.progress("Validating access token...");
353
+ try {
354
+ await fetchGroupsImpl(newToken);
355
+ spin.stop("Token validated");
356
+ } catch {
357
+ spin.stop("Failed");
358
+ await prompter.note(
359
+ "Could not validate token. Check your access token and try again.",
360
+ "Validation failed",
361
+ );
362
+ throw new Error("Could not validate access token");
363
+ }
364
+
365
+ const next = applyGroupMeConfig({
366
+ cfg,
367
+ accountId,
368
+ updates: { accessToken: newToken },
369
+ });
370
+ await prompter.note("Access token updated.", "Token rotated");
371
+ return { cfg: next, accountId };
372
+ }
373
+
374
+ if (action === "change_group") {
375
+ const existingToken = account.accessToken;
376
+ if (!existingToken) {
377
+ await prompter.note(
378
+ 'No access token configured. Use "Rotate access token" first.',
379
+ "Missing token",
380
+ );
381
+ return "skip";
382
+ }
383
+
384
+ const spin = prompter.progress("Fetching your GroupMe groups...");
385
+ let groups: Awaited<ReturnType<typeof fetchGroups>>;
386
+ try {
387
+ groups = await fetchGroupsImpl(existingToken);
388
+ } catch {
389
+ spin.stop("Failed");
390
+ await prompter.note(
391
+ "Could not fetch groups. Check your access token and try again.",
392
+ "GroupMe error",
393
+ );
394
+ throw new Error("Could not fetch groups");
395
+ }
396
+
397
+ if (groups.length === 0) {
398
+ spin.stop("No groups found");
399
+ await prompter.note(
400
+ "No groups found. Create or join a GroupMe group first.",
401
+ "No groups",
402
+ );
403
+ return "skip";
404
+ }
405
+ spin.stop(`Found ${groups.length} groups`);
406
+
407
+ const newGroupId = await prompter.select<string>({
408
+ message: "Select a GroupMe group",
409
+ options: groups.map((group) => ({
410
+ value: group.id,
411
+ label: group.name || group.id,
412
+ hint: group.id === account.config.groupId ? "current" : group.id,
413
+ })),
414
+ });
415
+
416
+ const selectedGroup = groups.find((g) => g.id === newGroupId);
417
+ const updates: Record<string, unknown> = { groupId: newGroupId };
418
+
419
+ const registerNew = await prompter.confirm({
420
+ message: "Register a new bot in this group?",
421
+ initialValue: true,
422
+ });
423
+
424
+ if (!registerNew) {
425
+ const newBotId = (
426
+ await prompter.text({
427
+ message: "Bot ID for the new group (existing bot won't work in a different group)",
428
+ validate: (value) => (value.trim() ? undefined : "Bot ID is required"),
429
+ })
430
+ ).trim();
431
+ updates.botId = newBotId;
432
+ }
433
+
434
+ if (registerNew) {
435
+ const botName = account.config.botName || "openclaw";
436
+ let publicDomain = account.config.publicDomain;
437
+ if (!publicDomain) {
438
+ const domainRaw = (
439
+ await prompter.text({
440
+ message: "Public domain (required for bot registration)",
441
+ validate: validatePublicDomainInput,
442
+ })
443
+ ).trim();
444
+ publicDomain = requirePublicDomain(domainRaw);
445
+ updates.publicDomain = publicDomain;
446
+ }
447
+ let webhookPath = account.config.webhookPath?.trim();
448
+ let callbackToken = readSecretInputString(account.config.callbackToken);
449
+ const hasSecretBackedCallbackToken =
450
+ !callbackToken && hasSecretInput(account.config.callbackToken);
451
+ if (hasSecretBackedCallbackToken) {
452
+ await prompter.note(
453
+ [
454
+ "Callback token is configured as a secret reference.",
455
+ "Re-registering a GroupMe bot requires the literal callback token to build the callback URL.",
456
+ "Regenerate webhook settings or update the bot outside this setup flow.",
457
+ ].join("\n"),
458
+ "Secret callback token",
459
+ );
460
+ return "skip";
461
+ }
462
+ if (!webhookPath || !callbackToken) {
463
+ const generated = generateCallbackSettings();
464
+ webhookPath = webhookPath || generated.webhookPath;
465
+ callbackToken = callbackToken || generated.callbackToken;
466
+ updates.webhookPath = webhookPath;
467
+ updates.callbackToken = callbackToken;
468
+ }
469
+
470
+ const botSpin = prompter.progress("Registering bot with GroupMe...");
471
+ try {
472
+ const bot = await createBotImpl({
473
+ accessToken: existingToken,
474
+ name: botName,
475
+ groupId: newGroupId,
476
+ callbackUrl: buildPublicCallbackUrl({
477
+ publicDomain,
478
+ webhookPath,
479
+ callbackToken,
480
+ }),
481
+ });
482
+ updates.botId = bot.bot_id;
483
+ botSpin.stop("Bot registered");
484
+ } catch (error) {
485
+ botSpin.stop("Failed");
486
+ const detail = error instanceof Error ? `\n\nDetails: ${error.message}` : "";
487
+ await prompter.note(`Failed to register bot.${detail}`, "Bot registration failed");
488
+ throw new Error("Failed to register GroupMe bot", {
489
+ cause: error instanceof Error ? error : undefined,
490
+ });
491
+ }
492
+ }
493
+
494
+ const next = applyGroupMeConfig({ cfg, accountId, updates });
495
+ await prompter.note(
496
+ `Group changed to "${selectedGroup?.name ?? newGroupId}".`,
497
+ "Group updated",
498
+ );
499
+ return { cfg: next, accountId };
500
+ }
501
+
502
+ if (action === "regen_callback") {
503
+ const { webhookPath, callbackToken } = generateCallbackSettings();
504
+ const next = applyGroupMeConfig({
505
+ cfg,
506
+ accountId,
507
+ updates: { webhookPath, callbackToken },
508
+ });
509
+ await prompter.note(
510
+ [
511
+ "Webhook path and callback token regenerated.",
512
+ "Remember to update your GroupMe bot settings or re-register the bot.",
513
+ ].join("\n"),
514
+ "Webhook settings updated",
515
+ );
516
+ return { cfg: next, accountId };
517
+ }
518
+
519
+ if (action === "toggle_mention") {
520
+ const current = account.config.requireMention ?? true;
521
+ const next = applyGroupMeConfig({
522
+ cfg,
523
+ accountId,
524
+ updates: { requireMention: !current },
525
+ });
526
+ await prompter.note(
527
+ `requireMention changed from ${current} to ${!current}.`,
528
+ "Mention setting updated",
529
+ );
530
+ return { cfg: next, accountId };
531
+ }
532
+
533
+ if (action === "update_domain") {
534
+ const newDomainRaw = (
535
+ await prompter.text({
536
+ message: "New public domain",
537
+ initialValue: account.config.publicDomain ?? "",
538
+ validate: validatePublicDomainInput,
539
+ })
540
+ ).trim();
541
+
542
+ const publicDomain = requirePublicDomain(newDomainRaw);
543
+ const next = applyGroupMeConfig({
544
+ cfg,
545
+ accountId,
546
+ updates: { publicDomain },
547
+ });
548
+ await prompter.note(`Public domain updated to "${publicDomain}".`, "Domain updated");
549
+ return { cfg: next, accountId };
550
+ }
551
+
552
+ return "skip";
527
553
  },
528
- }),
529
- };
554
+ disable: (cfg) => ({
555
+ ...cfg,
556
+ channels: {
557
+ ...cfg.channels,
558
+ groupme: {
559
+ ...(cfg.channels?.groupme ?? {}),
560
+ enabled: false,
561
+ },
562
+ },
563
+ }),
564
+ };
565
+
566
+ return adapter;
567
+ }
568
+
569
+ export const groupmeOnboardingAdapter = createGroupMeOnboardingAdapter();