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