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/accounts.ts CHANGED
@@ -1,7 +1,4 @@
1
- import {
2
- DEFAULT_ACCOUNT_ID,
3
- normalizeAccountId,
4
- } from "openclaw/plugin-sdk/account-id";
1
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
5
2
  import type {
6
3
  CoreConfig,
7
4
  GroupMeAccountConfig,
@@ -9,13 +6,6 @@ import type {
9
6
  ResolvedGroupMeAccount,
10
7
  } from "./types.js";
11
8
 
12
- const ENV_BOT_ID = "GROUPME_BOT_ID";
13
- const ENV_ACCESS_TOKEN = "GROUPME_ACCESS_TOKEN";
14
- const ENV_BOT_NAME = "GROUPME_BOT_NAME";
15
- const ENV_CALLBACK_URL = "GROUPME_CALLBACK_URL";
16
- const ENV_GROUP_ID = "GROUPME_GROUP_ID";
17
- const ENV_PUBLIC_DOMAIN = "GROUPME_PUBLIC_DOMAIN";
18
-
19
9
  export function readTrimmed(value: unknown): string | undefined {
20
10
  if (typeof value !== "string") {
21
11
  return undefined;
@@ -24,6 +14,17 @@ export function readTrimmed(value: unknown): string | undefined {
24
14
  return trimmed || undefined;
25
15
  }
26
16
 
17
+ export function hasSecretInput(value: unknown): boolean {
18
+ if (typeof value === "string") {
19
+ return Boolean(value.trim());
20
+ }
21
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
22
+ }
23
+
24
+ function trimSecretInput(value: GroupMeAccountConfig["botId"]): GroupMeAccountConfig["botId"] {
25
+ return typeof value === "string" ? readTrimmed(value) : value;
26
+ }
27
+
27
28
  function listConfiguredAccountIds(cfg: CoreConfig): string[] {
28
29
  const accounts = cfg.channels?.groupme?.accounts;
29
30
  if (!accounts || typeof accounts !== "object") {
@@ -54,22 +55,15 @@ function resolveAccountConfig(
54
55
  return accounts[accountId];
55
56
  }
56
57
 
57
- const hit = Object.keys(accounts).find(
58
- (key) => normalizeAccountId(key) === accountId,
59
- );
58
+ const hit = Object.keys(accounts).find((key) => normalizeAccountId(key) === accountId);
60
59
  return hit ? accounts[hit] : undefined;
61
60
  }
62
61
 
63
- function mergeAccountConfig(
64
- cfg: CoreConfig,
65
- accountId: string,
66
- ): GroupMeAccountConfig {
62
+ function mergeAccountConfig(cfg: CoreConfig, accountId: string): GroupMeAccountConfig {
67
63
  const raw = (cfg.channels?.groupme ?? {}) as GroupMeConfig;
68
64
  const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
69
65
  const account =
70
- accountId === DEFAULT_ACCOUNT_ID
71
- ? {}
72
- : (resolveAccountConfig(cfg, accountId) ?? {});
66
+ accountId === DEFAULT_ACCOUNT_ID ? {} : (resolveAccountConfig(cfg, accountId) ?? {});
73
67
 
74
68
  return {
75
69
  ...base,
@@ -78,13 +72,8 @@ function mergeAccountConfig(
78
72
  }
79
73
 
80
74
  export function listGroupMeAccountIds(cfg: CoreConfig): string[] {
81
- const sorted = listConfiguredAccountIds(cfg).toSorted((a, b) =>
82
- a.localeCompare(b),
83
- );
84
- return [
85
- DEFAULT_ACCOUNT_ID,
86
- ...sorted.filter((id) => id !== DEFAULT_ACCOUNT_ID),
87
- ];
75
+ const sorted = listConfiguredAccountIds(cfg).toSorted((a, b) => a.localeCompare(b));
76
+ return [DEFAULT_ACCOUNT_ID, ...sorted.filter((id) => id !== DEFAULT_ACCOUNT_ID)];
88
77
  }
89
78
 
90
79
  export function resolveDefaultGroupMeAccountId(cfg: CoreConfig): string {
@@ -102,64 +91,36 @@ export function resolveGroupMeAccount(params: {
102
91
  }): ResolvedGroupMeAccount {
103
92
  const normalizedRequested = normalizeAccountId(params.accountId);
104
93
  const accountId =
105
- normalizedRequested ||
106
- resolveDefaultGroupMeAccountId(params.cfg) ||
107
- DEFAULT_ACCOUNT_ID;
94
+ normalizedRequested || resolveDefaultGroupMeAccountId(params.cfg) || DEFAULT_ACCOUNT_ID;
108
95
 
109
96
  const merged = mergeAccountConfig(params.cfg, accountId);
110
97
  const baseEnabled = params.cfg.channels?.groupme?.enabled !== false;
111
98
  const accountEnabled = merged.enabled !== false;
112
99
  const enabled = baseEnabled && accountEnabled;
113
100
 
114
- const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID;
115
- const botId =
116
- readTrimmed(merged.botId) ||
117
- (isDefaultAccount ? readTrimmed(process.env[ENV_BOT_ID]) : undefined) ||
118
- "";
119
- const accessToken =
120
- readTrimmed(merged.accessToken) ||
121
- (isDefaultAccount
122
- ? readTrimmed(process.env[ENV_ACCESS_TOKEN])
123
- : undefined) ||
124
- "";
125
- const botName =
126
- readTrimmed(merged.botName) ||
127
- (isDefaultAccount ? readTrimmed(process.env[ENV_BOT_NAME]) : undefined) ||
128
- undefined;
129
- const groupId =
130
- readTrimmed(merged.groupId) ||
131
- (isDefaultAccount
132
- ? readTrimmed(process.env[ENV_GROUP_ID])
133
- : undefined) ||
134
- undefined;
135
- const callbackUrl =
136
- readTrimmed(merged.callbackUrl) ||
137
- (isDefaultAccount
138
- ? readTrimmed(process.env[ENV_CALLBACK_URL])
139
- : undefined) ||
140
- undefined;
141
- const publicDomain =
142
- readTrimmed(merged.publicDomain) ||
143
- (isDefaultAccount
144
- ? readTrimmed(process.env[ENV_PUBLIC_DOMAIN])
145
- : undefined) ||
146
- undefined;
101
+ const botId = readTrimmed(merged.botId) ?? "";
102
+ const accessToken = readTrimmed(merged.accessToken) ?? "";
103
+ const botName = readTrimmed(merged.botName);
104
+ const groupId = readTrimmed(merged.groupId);
105
+ const webhookPath = readTrimmed(merged.webhookPath);
106
+ const publicDomain = readTrimmed(merged.publicDomain);
147
107
 
148
108
  const config: GroupMeAccountConfig = {
149
109
  ...merged,
150
- botId,
151
- accessToken,
110
+ botId: trimSecretInput(merged.botId),
111
+ accessToken: trimSecretInput(merged.accessToken),
112
+ callbackToken: trimSecretInput(merged.callbackToken),
152
113
  botName,
153
114
  groupId,
154
115
  publicDomain,
155
- callbackUrl,
116
+ webhookPath,
156
117
  };
157
118
 
158
119
  return {
159
120
  accountId,
160
121
  name: readTrimmed(merged.name),
161
122
  enabled,
162
- configured: Boolean(botId),
123
+ configured: hasSecretInput(merged.botId),
163
124
  botId,
164
125
  accessToken,
165
126
  config,
package/src/channel.ts CHANGED
@@ -1,23 +1,18 @@
1
+ import { missingTargetError } from "openclaw/plugin-sdk/channel-feedback";
1
2
  import {
2
3
  applyAccountNameToChannelSection,
3
4
  buildChannelConfigSchema,
5
+ type ChannelPlugin,
4
6
  DEFAULT_ACCOUNT_ID,
5
7
  deleteAccountFromConfigSection,
6
8
  migrateBaseNameToDefaultAccount,
7
- missingTargetError,
8
9
  normalizeAccountId,
9
- registerPluginHttpRoute,
10
10
  setAccountEnabledInConfigSection,
11
- type ChannelPlugin,
12
- type ChannelSetupAdapter,
13
- } from "openclaw/plugin-sdk";
14
- import type {
15
- CoreConfig,
16
- GroupMeConfig,
17
- GroupMeProbe,
18
- ResolvedGroupMeAccount,
19
- } from "./types.js";
11
+ } from "openclaw/plugin-sdk/core";
12
+ import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/setup";
13
+ import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress";
20
14
  import {
15
+ hasSecretInput,
21
16
  listGroupMeAccountIds,
22
17
  resolveDefaultGroupMeAccountId,
23
18
  resolveGroupMeAccount,
@@ -25,22 +20,18 @@ import {
25
20
  import { GroupMeConfigSchema } from "./config-schema.js";
26
21
  import { createGroupMeWebhookHandler } from "./monitor.js";
27
22
  import {
23
+ looksLikeGroupMeTargetId,
28
24
  normalizeGroupMeAllowEntry,
29
25
  normalizeGroupMeTarget,
30
- looksLikeGroupMeTargetId,
31
26
  } from "./normalize.js";
32
27
  import { groupmeOnboardingAdapter } from "./onboarding.js";
33
28
  import { getGroupMeRuntime } from "./runtime.js";
34
- import { redactCallbackUrl, resolveGroupMeSecurity } from "./security.js";
35
- import {
36
- GROUPME_MAX_TEXT_LENGTH,
37
- sendGroupMeMedia,
38
- sendGroupMeText,
39
- } from "./send.js";
29
+ import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText } from "./send.js";
30
+ import type { CoreConfig, GroupMeConfig, GroupMeProbe, ResolvedGroupMeAccount } from "./types.js";
40
31
 
41
32
  const CHANNEL_ID = "groupme" as const;
42
33
 
43
- function normalizeCallbackUrl(raw: string | undefined): string {
34
+ function normalizeWebhookPath(raw: string | undefined): string {
44
35
  const trimmed = raw?.trim() ?? "";
45
36
  if (!trimmed) {
46
37
  return "/groupme";
@@ -49,24 +40,37 @@ function normalizeCallbackUrl(raw: string | undefined): string {
49
40
  const parsed = new URL(trimmed, "http://localhost");
50
41
  return parsed.pathname || "/groupme";
51
42
  } catch {
52
- const noQuery = trimmed.split("?")[0] ?? trimmed;
43
+ // Unparseable input (e.g. "http://%"): strip any query/fragment and ensure a
44
+ // leading slash so we still return a route-shaped path rather than throwing.
45
+ // This is a display/registration fallback for malformed config, not a parser.
46
+ // The `?? trimmed` and empty-`noQuery` guards are defensive: split() always yields
47
+ // a [0], and a non-empty trimmed string can't reduce to an empty noQuery here.
48
+ /* v8 ignore start */
49
+ const noQuery = trimmed.split(/[?#]/)[0] ?? trimmed;
53
50
  if (!noQuery) {
54
51
  return "/groupme";
55
52
  }
53
+ /* v8 ignore stop */
56
54
  return noQuery.startsWith("/") ? noQuery : `/${noQuery}`;
57
55
  }
58
56
  }
59
57
 
60
- function redactWebhookPath(
61
- account: ResolvedGroupMeAccount,
62
- callbackUrl: string | undefined,
63
- ): string {
64
- const normalized = callbackUrl?.trim() || "/groupme";
65
- const security = resolveGroupMeSecurity(account.config);
66
- if (!security.logging.redactSecrets) {
67
- return normalized;
58
+ function parseWebhookSetupInput(raw: string): {
59
+ webhookPath: string;
60
+ callbackToken?: string;
61
+ } {
62
+ try {
63
+ const parsed = new URL(raw.trim(), "http://localhost");
64
+ const callbackToken = parsed.searchParams.get("k")?.trim() || undefined;
65
+ return {
66
+ webhookPath: parsed.pathname || "/groupme",
67
+ callbackToken,
68
+ };
69
+ } catch {
70
+ return {
71
+ webhookPath: normalizeWebhookPath(raw),
72
+ };
68
73
  }
69
- return redactCallbackUrl(normalized, security);
70
74
  }
71
75
 
72
76
  const meta = {
@@ -81,13 +85,10 @@ const meta = {
81
85
  quickstartAllowFrom: true,
82
86
  };
83
87
 
84
- export const groupmePlugin: ChannelPlugin<
85
- ResolvedGroupMeAccount,
86
- GroupMeProbe
87
- > = {
88
+ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe> = {
88
89
  id: CHANNEL_ID,
89
90
  meta,
90
- onboarding: groupmeOnboardingAdapter,
91
+ setupWizard: groupmeOnboardingAdapter,
91
92
  setup: {
92
93
  resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
93
94
 
@@ -123,12 +124,15 @@ export const groupmePlugin: ChannelPlugin<
123
124
 
124
125
  const updates: Record<string, unknown> = { enabled: true };
125
126
  if (input.token?.trim()) updates.botId = input.token.trim();
126
- if (input.accessToken?.trim())
127
- updates.accessToken = input.accessToken.trim();
127
+ if (input.accessToken?.trim()) updates.accessToken = input.accessToken.trim();
128
128
  if (input.webhookUrl?.trim()) {
129
- updates.callbackUrl = input.webhookUrl.trim();
129
+ const parsed = parseWebhookSetupInput(input.webhookUrl);
130
+ updates.webhookPath = parsed.webhookPath;
131
+ if (parsed.callbackToken) updates.callbackToken = parsed.callbackToken;
130
132
  } else if (input.webhookPath?.trim()) {
131
- updates.callbackUrl = input.webhookPath.trim();
133
+ const parsed = parseWebhookSetupInput(input.webhookPath);
134
+ updates.webhookPath = parsed.webhookPath;
135
+ if (parsed.callbackToken) updates.callbackToken = parsed.callbackToken;
132
136
  }
133
137
 
134
138
  const section = (next.channels?.groupme ?? {}) as GroupMeConfig;
@@ -185,8 +189,7 @@ export const groupmePlugin: ChannelPlugin<
185
189
  listAccountIds: (cfg) => listGroupMeAccountIds(cfg as CoreConfig),
186
190
  resolveAccount: (cfg, accountId) =>
187
191
  resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }),
188
- defaultAccountId: (cfg) =>
189
- resolveDefaultGroupMeAccountId(cfg as CoreConfig),
192
+ defaultAccountId: (cfg) => resolveDefaultGroupMeAccountId(cfg as CoreConfig),
190
193
  setAccountEnabled: ({ cfg, accountId, enabled }) =>
191
194
  setAccountEnabledInConfigSection({
192
195
  cfg: cfg as CoreConfig,
@@ -204,10 +207,11 @@ export const groupmePlugin: ChannelPlugin<
204
207
  "name",
205
208
  "botId",
206
209
  "accessToken",
210
+ "callbackToken",
207
211
  "botName",
208
212
  "groupId",
209
213
  "publicDomain",
210
- "callbackUrl",
214
+ "webhookPath",
211
215
  "mentionPatterns",
212
216
  "requireMention",
213
217
  "historyLimit",
@@ -223,15 +227,15 @@ export const groupmePlugin: ChannelPlugin<
223
227
  name: account.name,
224
228
  enabled: account.enabled,
225
229
  configured: account.configured,
226
- botId: account.botId ? "***" : "",
230
+ botId: hasSecretInput(account.config.botId) ? "***" : "",
227
231
  publicDomain: account.config.publicDomain ?? "",
228
- callbackUrl: redactWebhookPath(account, account.config.callbackUrl),
232
+ webhookPath: normalizeWebhookPath(account.config.webhookPath),
233
+ callbackToken: hasSecretInput(account.config.callbackToken) ? "***" : "",
229
234
  }),
230
235
  resolveAllowFrom: ({ cfg, accountId }) =>
231
- (
232
- resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }).config
233
- .allowFrom ?? []
234
- ).map((entry) => String(entry)),
236
+ (resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
237
+ (entry) => String(entry),
238
+ ),
235
239
  formatAllowFrom: ({ allowFrom }) =>
236
240
  allowFrom
237
241
  .map((entry) => normalizeGroupMeAllowEntry(String(entry)))
@@ -248,8 +252,7 @@ export const groupmePlugin: ChannelPlugin<
248
252
  },
249
253
  outbound: {
250
254
  deliveryMode: "direct",
251
- chunker: (text, limit) =>
252
- getGroupMeRuntime().channel.text.chunkMarkdownText(text, limit),
255
+ chunker: (text, limit) => getGroupMeRuntime().channel.text.chunkMarkdownText(text, limit),
253
256
  chunkerMode: "markdown",
254
257
  textChunkLimit: GROUPME_MAX_TEXT_LENGTH,
255
258
  resolveTarget: ({ to }) => {
@@ -355,7 +358,7 @@ export const groupmePlugin: ChannelPlugin<
355
358
  buildChannelSummary: ({ snapshot }) => ({
356
359
  configured: snapshot.configured ?? false,
357
360
  running: snapshot.running ?? false,
358
- callbackUrl: snapshot.webhookPath ?? null,
361
+ webhookPath: snapshot.webhookPath ?? null,
359
362
  lastStartAt: snapshot.lastStartAt ?? null,
360
363
  lastStopAt: snapshot.lastStopAt ?? null,
361
364
  lastInboundAt: snapshot.lastInboundAt ?? null,
@@ -367,9 +370,9 @@ export const groupmePlugin: ChannelPlugin<
367
370
  name: account.name,
368
371
  enabled: account.enabled,
369
372
  configured: account.configured,
370
- botId: account.botId ? "***" : "",
371
- tokenSource: account.accessToken ? "configured" : "none",
372
- webhookPath: redactWebhookPath(account, account.config.callbackUrl),
373
+ botId: hasSecretInput(account.config.botId) ? "***" : "",
374
+ tokenSource: hasSecretInput(account.config.accessToken) ? "configured" : "none",
375
+ webhookPath: normalizeWebhookPath(account.config.webhookPath),
373
376
  running: runtime?.running ?? false,
374
377
  lastStartAt: runtime?.lastStartAt ?? null,
375
378
  lastStopAt: runtime?.lastStopAt ?? null,
@@ -388,11 +391,7 @@ export const groupmePlugin: ChannelPlugin<
388
391
  );
389
392
  }
390
393
 
391
- const callbackPath = normalizeCallbackUrl(account.config.callbackUrl);
392
- const redactedCallbackPath = redactWebhookPath(
393
- account,
394
- account.config.callbackUrl ?? callbackPath,
395
- );
394
+ const callbackPath = normalizeWebhookPath(account.config.webhookPath);
396
395
  const unregister = registerPluginHttpRoute({
397
396
  path: callbackPath,
398
397
  fallbackPath: "/groupme",
@@ -400,9 +399,12 @@ export const groupmePlugin: ChannelPlugin<
400
399
  account,
401
400
  config: ctx.cfg as CoreConfig,
402
401
  runtime: ctx.runtime,
403
- statusSink: (patch) =>
404
- ctx.setStatus({ accountId: account.accountId, ...patch }),
402
+ // Thin setStatus adapter; exercised end-to-end through the live gateway, not in
403
+ // unit isolation (the webhook-flow tests drive the handler with their own sink).
404
+ /* v8 ignore next */
405
+ statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
405
406
  }),
407
+ auth: "plugin",
406
408
  pluginId: CHANNEL_ID,
407
409
  accountId: account.accountId,
408
410
  log: (message) => ctx.log?.info(message),
@@ -412,17 +414,24 @@ export const groupmePlugin: ChannelPlugin<
412
414
  accountId: account.accountId,
413
415
  running: true,
414
416
  mode: "webhook",
415
- webhookPath: redactedCallbackPath,
417
+ webhookPath: callbackPath,
416
418
  lastStartAt: Date.now(),
417
419
  lastError: null,
418
420
  });
419
421
 
420
- ctx.log?.info(
421
- `[${account.accountId}] GroupMe webhook listening on ${redactedCallbackPath}`,
422
- );
422
+ ctx.log?.info(`[${account.accountId}] GroupMe webhook listening on ${callbackPath}`);
423
+
424
+ const markStopped = () => {
425
+ ctx.setStatus({
426
+ accountId: account.accountId,
427
+ running: false,
428
+ lastStopAt: Date.now(),
429
+ });
430
+ };
423
431
 
424
432
  if (ctx.abortSignal.aborted) {
425
433
  unregister();
434
+ markStopped();
426
435
  return;
427
436
  }
428
437
 
@@ -431,6 +440,7 @@ export const groupmePlugin: ChannelPlugin<
431
440
  "abort",
432
441
  () => {
433
442
  unregister();
443
+ markStopped();
434
444
  resolve();
435
445
  },
436
446
  { once: true },
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  BlockStreamingCoalesceSchema,
3
3
  MarkdownConfigSchema,
4
- } from "openclaw/plugin-sdk";
4
+ } from "openclaw/plugin-sdk/channel-config-primitives";
5
+ import { buildOptionalSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
5
6
  import { z } from "zod";
6
7
 
7
8
  const allowFromEntry = z.union([z.string(), z.number()]);
9
+ const optionalSecretInput = buildOptionalSecretInputSchema();
8
10
 
9
11
  const GroupMeReplaySchema = z
10
12
  .object({
@@ -50,9 +52,7 @@ const GroupMeProxySecuritySchema = z
50
52
  trustedProxyCidrs: z.array(z.string()).optional(),
51
53
  allowedPublicHosts: z.array(z.string()).optional(),
52
54
  requireHttpsProto: z.boolean().optional().default(false),
53
- rejectStatus: z
54
- .union([z.literal(400), z.literal(403), z.literal(404)])
55
- .optional(),
55
+ rejectStatus: z.union([z.literal(400), z.literal(403), z.literal(404)]).optional(),
56
56
  })
57
57
  .strict();
58
58
 
@@ -67,16 +67,17 @@ const GroupMeSecuritySchema = z
67
67
  })
68
68
  .strict();
69
69
 
70
- export const GroupMeAccountSchemaBase = z
70
+ const GroupMeAccountSchemaBase = z
71
71
  .object({
72
72
  name: z.string().optional(),
73
73
  enabled: z.boolean().optional(),
74
- botId: z.string().optional(),
75
- accessToken: z.string().optional(),
74
+ botId: optionalSecretInput,
75
+ accessToken: optionalSecretInput,
76
+ callbackToken: optionalSecretInput,
76
77
  botName: z.string().optional(),
77
78
  groupId: z.string().optional(),
78
79
  publicDomain: z.string().optional(),
79
- callbackUrl: z.string().optional(),
80
+ webhookPath: z.string().optional(),
80
81
  mentionPatterns: z.array(z.string()).optional(),
81
82
  requireMention: z.boolean().optional().default(true),
82
83
  historyLimit: z.number().int().nonnegative().optional(),
@@ -92,8 +93,6 @@ export const GroupMeAccountSchemaBase = z
92
93
  .strict();
93
94
 
94
95
  export const GroupMeConfigSchema = GroupMeAccountSchemaBase.extend({
95
- accounts: z
96
- .record(z.string(), GroupMeAccountSchemaBase.optional())
97
- .optional(),
96
+ accounts: z.record(z.string(), GroupMeAccountSchemaBase.optional()).optional(),
98
97
  defaultAccount: z.string().optional(),
99
98
  }).strict();
@@ -2,6 +2,13 @@ import type { GroupMeApiBot, GroupMeApiGroup } from "./types.js";
2
2
 
3
3
  const GROUPME_API_BASE = "https://api.groupme.com/v3";
4
4
 
5
+ type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
6
+
7
+ type GroupMeApiOptions = {
8
+ fetchFn?: FetchLike;
9
+ apiBaseUrl?: string;
10
+ };
11
+
5
12
  async function readApiError(response: Response): Promise<string> {
6
13
  const fallback = `GroupMe API error: ${response.status} ${response.statusText}`;
7
14
  try {
@@ -38,18 +45,23 @@ function readBotResponse(payload: unknown): GroupMeApiBot {
38
45
  return bot as GroupMeApiBot;
39
46
  }
40
47
 
41
- export async function fetchGroups(accessToken: string): Promise<GroupMeApiGroup[]> {
48
+ export async function fetchGroups(
49
+ accessToken: string,
50
+ options: GroupMeApiOptions = {},
51
+ ): Promise<GroupMeApiGroup[]> {
52
+ const fetchFn = options.fetchFn ?? fetch;
53
+ const apiBaseUrl = options.apiBaseUrl ?? GROUPME_API_BASE;
42
54
  const groups: GroupMeApiGroup[] = [];
43
55
  let page = 1;
44
56
 
45
57
  while (true) {
46
- const url = new URL(`${GROUPME_API_BASE}/groups`);
58
+ const url = new URL(`${apiBaseUrl}/groups`);
47
59
  url.searchParams.set("token", accessToken);
48
60
  url.searchParams.set("per_page", "100");
49
61
  url.searchParams.set("omit", "memberships");
50
62
  url.searchParams.set("page", String(page));
51
63
 
52
- const response = await fetch(url);
64
+ const response = await fetchFn(url);
53
65
  if (!response.ok) {
54
66
  throw new Error(await readApiError(response));
55
67
  }
@@ -72,11 +84,15 @@ export async function createBot(params: {
72
84
  name: string;
73
85
  groupId: string;
74
86
  callbackUrl: string;
87
+ fetchFn?: FetchLike;
88
+ apiBaseUrl?: string;
75
89
  }): Promise<GroupMeApiBot> {
76
- const url = new URL(`${GROUPME_API_BASE}/bots`);
90
+ const fetchFn = params.fetchFn ?? fetch;
91
+ const apiBaseUrl = params.apiBaseUrl ?? GROUPME_API_BASE;
92
+ const url = new URL(`${apiBaseUrl}/bots`);
77
93
  url.searchParams.set("token", params.accessToken);
78
94
 
79
- const response = await fetch(url, {
95
+ const response = await fetchFn(url, {
80
96
  method: "POST",
81
97
  headers: { "content-type": "application/json" },
82
98
  body: JSON.stringify({
package/src/history.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { HistoryEntry } from "openclaw/plugin-sdk";
1
+ import type { HistoryEntry } from "openclaw/plugin-sdk/core";
2
2
 
3
3
  export const DEFAULT_GROUPME_HISTORY_LIMIT = 20;
4
4