openclaw-groupme 0.0.4 → 0.4.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.
package/src/accounts.ts CHANGED
@@ -1,4 +1,7 @@
1
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
1
+ import {
2
+ DEFAULT_ACCOUNT_ID,
3
+ normalizeAccountId,
4
+ } from "openclaw/plugin-sdk/account-id";
2
5
  import type {
3
6
  CoreConfig,
4
7
  GroupMeAccountConfig,
@@ -9,9 +12,11 @@ import type {
9
12
  const ENV_BOT_ID = "GROUPME_BOT_ID";
10
13
  const ENV_ACCESS_TOKEN = "GROUPME_ACCESS_TOKEN";
11
14
  const ENV_BOT_NAME = "GROUPME_BOT_NAME";
12
- const ENV_CALLBACK_PATH = "GROUPME_CALLBACK_PATH";
15
+ const ENV_CALLBACK_URL = "GROUPME_CALLBACK_URL";
16
+ const ENV_GROUP_ID = "GROUPME_GROUP_ID";
17
+ const ENV_PUBLIC_DOMAIN = "GROUPME_PUBLIC_DOMAIN";
13
18
 
14
- function readTrimmed(value: unknown): string | undefined {
19
+ export function readTrimmed(value: unknown): string | undefined {
15
20
  if (typeof value !== "string") {
16
21
  return undefined;
17
22
  }
@@ -49,15 +54,22 @@ function resolveAccountConfig(
49
54
  return accounts[accountId];
50
55
  }
51
56
 
52
- const hit = Object.keys(accounts).find((key) => normalizeAccountId(key) === accountId);
57
+ const hit = Object.keys(accounts).find(
58
+ (key) => normalizeAccountId(key) === accountId,
59
+ );
53
60
  return hit ? accounts[hit] : undefined;
54
61
  }
55
62
 
56
- function mergeAccountConfig(cfg: CoreConfig, accountId: string): GroupMeAccountConfig {
63
+ function mergeAccountConfig(
64
+ cfg: CoreConfig,
65
+ accountId: string,
66
+ ): GroupMeAccountConfig {
57
67
  const raw = (cfg.channels?.groupme ?? {}) as GroupMeConfig;
58
68
  const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
59
69
  const account =
60
- accountId === DEFAULT_ACCOUNT_ID ? {} : (resolveAccountConfig(cfg, accountId) ?? {});
70
+ accountId === DEFAULT_ACCOUNT_ID
71
+ ? {}
72
+ : (resolveAccountConfig(cfg, accountId) ?? {});
61
73
 
62
74
  return {
63
75
  ...base,
@@ -66,17 +78,13 @@ function mergeAccountConfig(cfg: CoreConfig, accountId: string): GroupMeAccountC
66
78
  }
67
79
 
68
80
  export function listGroupMeAccountIds(cfg: CoreConfig): string[] {
69
- const ids = new Set<string>([DEFAULT_ACCOUNT_ID]);
70
- for (const id of listConfiguredAccountIds(cfg)) {
71
- ids.add(id);
72
- }
73
-
74
- const ordered = [...ids].toSorted((a, b) => a.localeCompare(b));
75
- if (ordered[0] !== DEFAULT_ACCOUNT_ID) {
76
- ordered.unshift(DEFAULT_ACCOUNT_ID);
77
- return Array.from(new Set(ordered));
78
- }
79
- return ordered;
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
+ ];
80
88
  }
81
89
 
82
90
  export function resolveDefaultGroupMeAccountId(cfg: CoreConfig): string {
@@ -94,7 +102,9 @@ export function resolveGroupMeAccount(params: {
94
102
  }): ResolvedGroupMeAccount {
95
103
  const normalizedRequested = normalizeAccountId(params.accountId);
96
104
  const accountId =
97
- normalizedRequested || resolveDefaultGroupMeAccountId(params.cfg) || DEFAULT_ACCOUNT_ID;
105
+ normalizedRequested ||
106
+ resolveDefaultGroupMeAccountId(params.cfg) ||
107
+ DEFAULT_ACCOUNT_ID;
98
108
 
99
109
  const merged = mergeAccountConfig(params.cfg, accountId);
100
110
  const baseEnabled = params.cfg.channels?.groupme?.enabled !== false;
@@ -108,15 +118,31 @@ export function resolveGroupMeAccount(params: {
108
118
  "";
109
119
  const accessToken =
110
120
  readTrimmed(merged.accessToken) ||
111
- (isDefaultAccount ? readTrimmed(process.env[ENV_ACCESS_TOKEN]) : undefined) ||
121
+ (isDefaultAccount
122
+ ? readTrimmed(process.env[ENV_ACCESS_TOKEN])
123
+ : undefined) ||
112
124
  "";
113
125
  const botName =
114
126
  readTrimmed(merged.botName) ||
115
127
  (isDefaultAccount ? readTrimmed(process.env[ENV_BOT_NAME]) : undefined) ||
116
128
  undefined;
117
- const callbackPath =
118
- readTrimmed(merged.callbackPath) ||
119
- (isDefaultAccount ? readTrimmed(process.env[ENV_CALLBACK_PATH]) : 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) ||
120
146
  undefined;
121
147
 
122
148
  const config: GroupMeAccountConfig = {
@@ -124,7 +150,9 @@ export function resolveGroupMeAccount(params: {
124
150
  botId,
125
151
  accessToken,
126
152
  botName,
127
- callbackPath,
153
+ groupId,
154
+ publicDomain,
155
+ callbackUrl,
128
156
  };
129
157
 
130
158
  return {
package/src/channel.ts CHANGED
@@ -1,13 +1,22 @@
1
1
  import {
2
+ applyAccountNameToChannelSection,
2
3
  buildChannelConfigSchema,
3
4
  DEFAULT_ACCOUNT_ID,
4
5
  deleteAccountFromConfigSection,
6
+ migrateBaseNameToDefaultAccount,
5
7
  missingTargetError,
8
+ normalizeAccountId,
6
9
  registerPluginHttpRoute,
7
10
  setAccountEnabledInConfigSection,
8
11
  type ChannelPlugin,
12
+ type ChannelSetupAdapter,
9
13
  } from "openclaw/plugin-sdk";
10
- import type { CoreConfig, GroupMeProbe, ResolvedGroupMeAccount } from "./types.js";
14
+ import type {
15
+ CoreConfig,
16
+ GroupMeConfig,
17
+ GroupMeProbe,
18
+ ResolvedGroupMeAccount,
19
+ } from "./types.js";
11
20
  import {
12
21
  listGroupMeAccountIds,
13
22
  resolveDefaultGroupMeAccountId,
@@ -22,10 +31,44 @@ import {
22
31
  } from "./normalize.js";
23
32
  import { groupmeOnboardingAdapter } from "./onboarding.js";
24
33
  import { getGroupMeRuntime } from "./runtime.js";
25
- import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText } from "./send.js";
34
+ import { redactCallbackUrl, resolveGroupMeSecurity } from "./security.js";
35
+ import {
36
+ GROUPME_MAX_TEXT_LENGTH,
37
+ sendGroupMeMedia,
38
+ sendGroupMeText,
39
+ } from "./send.js";
26
40
 
27
41
  const CHANNEL_ID = "groupme" as const;
28
42
 
43
+ function normalizeCallbackUrl(raw: string | undefined): string {
44
+ const trimmed = raw?.trim() ?? "";
45
+ if (!trimmed) {
46
+ return "/groupme";
47
+ }
48
+ try {
49
+ const parsed = new URL(trimmed, "http://localhost");
50
+ return parsed.pathname || "/groupme";
51
+ } catch {
52
+ const noQuery = trimmed.split("?")[0] ?? trimmed;
53
+ if (!noQuery) {
54
+ return "/groupme";
55
+ }
56
+ return noQuery.startsWith("/") ? noQuery : `/${noQuery}`;
57
+ }
58
+ }
59
+
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;
68
+ }
69
+ return redactCallbackUrl(normalized, security);
70
+ }
71
+
29
72
  const meta = {
30
73
  id: CHANNEL_ID,
31
74
  label: "GroupMe",
@@ -38,10 +81,99 @@ const meta = {
38
81
  quickstartAllowFrom: true,
39
82
  };
40
83
 
41
- export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe> = {
84
+ export const groupmePlugin: ChannelPlugin<
85
+ ResolvedGroupMeAccount,
86
+ GroupMeProbe
87
+ > = {
42
88
  id: CHANNEL_ID,
43
89
  meta,
44
90
  onboarding: groupmeOnboardingAdapter,
91
+ setup: {
92
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
93
+
94
+ applyAccountName: ({ cfg, accountId, name }) =>
95
+ applyAccountNameToChannelSection({
96
+ cfg,
97
+ channelKey: "groupme",
98
+ accountId,
99
+ name,
100
+ }),
101
+
102
+ validateInput: ({ input }) => {
103
+ if (!input.token?.trim()) {
104
+ return "GroupMe Bot ID is required (--token <bot-id>)";
105
+ }
106
+ return null;
107
+ },
108
+
109
+ applyAccountConfig: ({ cfg, accountId, input }) => {
110
+ let next = applyAccountNameToChannelSection({
111
+ cfg,
112
+ channelKey: "groupme",
113
+ accountId,
114
+ name: input.name,
115
+ });
116
+
117
+ if (accountId !== DEFAULT_ACCOUNT_ID) {
118
+ next = migrateBaseNameToDefaultAccount({
119
+ cfg: next,
120
+ channelKey: "groupme",
121
+ });
122
+ }
123
+
124
+ const updates: Record<string, unknown> = { enabled: true };
125
+ if (input.token?.trim()) updates.botId = input.token.trim();
126
+ if (input.accessToken?.trim())
127
+ updates.accessToken = input.accessToken.trim();
128
+ if (input.webhookUrl?.trim()) {
129
+ updates.callbackUrl = input.webhookUrl.trim();
130
+ } else if (input.webhookPath?.trim()) {
131
+ updates.callbackUrl = input.webhookPath.trim();
132
+ }
133
+
134
+ const section = (next.channels?.groupme ?? {}) as GroupMeConfig;
135
+
136
+ if (accountId === DEFAULT_ACCOUNT_ID) {
137
+ return {
138
+ ...next,
139
+ channels: {
140
+ ...next.channels,
141
+ groupme: {
142
+ ...section,
143
+ ...updates,
144
+ },
145
+ },
146
+ };
147
+ }
148
+
149
+ return {
150
+ ...next,
151
+ channels: {
152
+ ...next.channels,
153
+ groupme: {
154
+ ...section,
155
+ enabled: true,
156
+ accounts: {
157
+ ...(section.accounts ?? {}),
158
+ [accountId]: {
159
+ ...(section.accounts?.[accountId] ?? {}),
160
+ ...updates,
161
+ },
162
+ },
163
+ },
164
+ },
165
+ };
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
+ },
176
+ } satisfies ChannelSetupAdapter,
45
177
  capabilities: {
46
178
  chatTypes: ["group"],
47
179
  media: true,
@@ -53,7 +185,8 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
53
185
  listAccountIds: (cfg) => listGroupMeAccountIds(cfg as CoreConfig),
54
186
  resolveAccount: (cfg, accountId) =>
55
187
  resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }),
56
- defaultAccountId: (cfg) => resolveDefaultGroupMeAccountId(cfg as CoreConfig),
188
+ defaultAccountId: (cfg) =>
189
+ resolveDefaultGroupMeAccountId(cfg as CoreConfig),
57
190
  setAccountEnabled: ({ cfg, accountId, enabled }) =>
58
191
  setAccountEnabledInConfigSection({
59
192
  cfg: cfg as CoreConfig,
@@ -72,12 +205,16 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
72
205
  "botId",
73
206
  "accessToken",
74
207
  "botName",
75
- "callbackPath",
208
+ "groupId",
209
+ "publicDomain",
210
+ "callbackUrl",
76
211
  "mentionPatterns",
77
212
  "requireMention",
213
+ "historyLimit",
78
214
  "allowFrom",
79
215
  "textChunkLimit",
80
216
  "responsePrefix",
217
+ "security",
81
218
  ],
82
219
  }),
83
220
  isConfigured: (account) => account.configured,
@@ -87,12 +224,14 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
87
224
  enabled: account.enabled,
88
225
  configured: account.configured,
89
226
  botId: account.botId ? "***" : "",
90
- callbackPath: account.config.callbackPath,
227
+ publicDomain: account.config.publicDomain ?? "",
228
+ callbackUrl: redactWebhookPath(account, account.config.callbackUrl),
91
229
  }),
92
230
  resolveAllowFrom: ({ cfg, accountId }) =>
93
- (resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
94
- (entry) => String(entry),
95
- ),
231
+ (
232
+ resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }).config
233
+ .allowFrom ?? []
234
+ ).map((entry) => String(entry)),
96
235
  formatAllowFrom: ({ allowFrom }) =>
97
236
  allowFrom
98
237
  .map((entry) => normalizeGroupMeAllowEntry(String(entry)))
@@ -109,7 +248,8 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
109
248
  },
110
249
  outbound: {
111
250
  deliveryMode: "direct",
112
- chunker: (text, limit) => getGroupMeRuntime().channel.text.chunkMarkdownText(text, limit),
251
+ chunker: (text, limit) =>
252
+ getGroupMeRuntime().channel.text.chunkMarkdownText(text, limit),
113
253
  chunkerMode: "markdown",
114
254
  textChunkLimit: GROUPME_MAX_TEXT_LENGTH,
115
255
  resolveTarget: ({ to }) => {
@@ -215,7 +355,7 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
215
355
  buildChannelSummary: ({ snapshot }) => ({
216
356
  configured: snapshot.configured ?? false,
217
357
  running: snapshot.running ?? false,
218
- callbackPath: snapshot.webhookPath ?? null,
358
+ callbackUrl: snapshot.webhookPath ?? null,
219
359
  lastStartAt: snapshot.lastStartAt ?? null,
220
360
  lastStopAt: snapshot.lastStopAt ?? null,
221
361
  lastInboundAt: snapshot.lastInboundAt ?? null,
@@ -229,7 +369,7 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
229
369
  configured: account.configured,
230
370
  botId: account.botId ? "***" : "",
231
371
  tokenSource: account.accessToken ? "configured" : "none",
232
- webhookPath: account.config.callbackPath ?? "/groupme",
372
+ webhookPath: redactWebhookPath(account, account.config.callbackUrl),
233
373
  running: runtime?.running ?? false,
234
374
  lastStartAt: runtime?.lastStartAt ?? null,
235
375
  lastStopAt: runtime?.lastStopAt ?? null,
@@ -248,7 +388,11 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
248
388
  );
249
389
  }
250
390
 
251
- const callbackPath = account.config.callbackPath?.trim() || "/groupme";
391
+ const callbackPath = normalizeCallbackUrl(account.config.callbackUrl);
392
+ const redactedCallbackPath = redactWebhookPath(
393
+ account,
394
+ account.config.callbackUrl ?? callbackPath,
395
+ );
252
396
  const unregister = registerPluginHttpRoute({
253
397
  path: callbackPath,
254
398
  fallbackPath: "/groupme",
@@ -256,7 +400,8 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
256
400
  account,
257
401
  config: ctx.cfg as CoreConfig,
258
402
  runtime: ctx.runtime,
259
- statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
403
+ statusSink: (patch) =>
404
+ ctx.setStatus({ accountId: account.accountId, ...patch }),
260
405
  }),
261
406
  pluginId: CHANNEL_ID,
262
407
  accountId: account.accountId,
@@ -267,12 +412,14 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
267
412
  accountId: account.accountId,
268
413
  running: true,
269
414
  mode: "webhook",
270
- webhookPath: callbackPath,
415
+ webhookPath: redactedCallbackPath,
271
416
  lastStartAt: Date.now(),
272
417
  lastError: null,
273
418
  });
274
419
 
275
- ctx.log?.info(`[${account.accountId}] GroupMe webhook listening on ${callbackPath}`);
420
+ ctx.log?.info(
421
+ `[${account.accountId}] GroupMe webhook listening on ${redactedCallbackPath}`,
422
+ );
276
423
 
277
424
  if (ctx.abortSignal.aborted) {
278
425
  unregister();
@@ -1,8 +1,72 @@
1
- import { BlockStreamingCoalesceSchema, MarkdownConfigSchema } from "openclaw/plugin-sdk";
1
+ import {
2
+ BlockStreamingCoalesceSchema,
3
+ MarkdownConfigSchema,
4
+ } from "openclaw/plugin-sdk";
2
5
  import { z } from "zod";
3
6
 
4
7
  const allowFromEntry = z.union([z.string(), z.number()]);
5
8
 
9
+ const GroupMeReplaySchema = z
10
+ .object({
11
+ ttlSeconds: z.number().int().positive().optional(),
12
+ maxEntries: z.number().int().positive().optional(),
13
+ })
14
+ .strict();
15
+
16
+ const GroupMeRateLimitSchema = z
17
+ .object({
18
+ windowMs: z.number().int().positive().optional(),
19
+ maxRequestsPerIp: z.number().int().positive().optional(),
20
+ maxRequestsPerSender: z.number().int().positive().optional(),
21
+ maxConcurrent: z.number().int().positive().optional(),
22
+ })
23
+ .strict();
24
+
25
+ const GroupMeMediaSecuritySchema = z
26
+ .object({
27
+ allowPrivateNetworks: z.boolean().optional().default(false),
28
+ maxDownloadBytes: z.number().int().positive().optional(),
29
+ requestTimeoutMs: z.number().int().positive().optional(),
30
+ allowedMimePrefixes: z.array(z.string()).optional(),
31
+ })
32
+ .strict();
33
+
34
+ const GroupMeLoggingSecuritySchema = z
35
+ .object({
36
+ redactSecrets: z.boolean().optional().default(true),
37
+ logRejectedRequests: z.boolean().optional().default(true),
38
+ })
39
+ .strict();
40
+
41
+ const GroupMeCommandBypassSecuritySchema = z
42
+ .object({
43
+ requireAllowFrom: z.boolean().optional().default(true),
44
+ requireMentionForCommands: z.boolean().optional().default(false),
45
+ })
46
+ .strict();
47
+
48
+ const GroupMeProxySecuritySchema = z
49
+ .object({
50
+ trustedProxyCidrs: z.array(z.string()).optional(),
51
+ allowedPublicHosts: z.array(z.string()).optional(),
52
+ requireHttpsProto: z.boolean().optional().default(false),
53
+ rejectStatus: z
54
+ .union([z.literal(400), z.literal(403), z.literal(404)])
55
+ .optional(),
56
+ })
57
+ .strict();
58
+
59
+ const GroupMeSecuritySchema = z
60
+ .object({
61
+ replay: GroupMeReplaySchema.optional(),
62
+ rateLimit: GroupMeRateLimitSchema.optional(),
63
+ media: GroupMeMediaSecuritySchema.optional(),
64
+ logging: GroupMeLoggingSecuritySchema.optional(),
65
+ commandBypass: GroupMeCommandBypassSecuritySchema.optional(),
66
+ proxy: GroupMeProxySecuritySchema.optional(),
67
+ })
68
+ .strict();
69
+
6
70
  export const GroupMeAccountSchemaBase = z
7
71
  .object({
8
72
  name: z.string().optional(),
@@ -10,9 +74,12 @@ export const GroupMeAccountSchemaBase = z
10
74
  botId: z.string().optional(),
11
75
  accessToken: z.string().optional(),
12
76
  botName: z.string().optional(),
13
- callbackPath: z.string().optional(),
77
+ groupId: z.string().optional(),
78
+ publicDomain: z.string().optional(),
79
+ callbackUrl: z.string().optional(),
14
80
  mentionPatterns: z.array(z.string()).optional(),
15
81
  requireMention: z.boolean().optional().default(true),
82
+ historyLimit: z.number().int().nonnegative().optional(),
16
83
  allowFrom: z.array(allowFromEntry).optional(),
17
84
  markdown: MarkdownConfigSchema,
18
85
  textChunkLimit: z.number().int().positive().optional(),
@@ -20,10 +87,13 @@ export const GroupMeAccountSchemaBase = z
20
87
  blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
21
88
  responsePrefix: z.string().optional(),
22
89
  mediaMaxMb: z.number().positive().optional(),
90
+ security: GroupMeSecuritySchema.optional(),
23
91
  })
24
92
  .strict();
25
93
 
26
94
  export const GroupMeConfigSchema = GroupMeAccountSchemaBase.extend({
27
- accounts: z.record(z.string(), GroupMeAccountSchemaBase.optional()).optional(),
95
+ accounts: z
96
+ .record(z.string(), GroupMeAccountSchemaBase.optional())
97
+ .optional(),
28
98
  defaultAccount: z.string().optional(),
29
99
  }).strict();
@@ -0,0 +1,98 @@
1
+ import type { GroupMeApiBot, GroupMeApiGroup } from "./types.js";
2
+
3
+ const GROUPME_API_BASE = "https://api.groupme.com/v3";
4
+
5
+ async function readApiError(response: Response): Promise<string> {
6
+ const fallback = `GroupMe API error: ${response.status} ${response.statusText}`;
7
+ try {
8
+ const payload = (await response.json()) as { meta?: { errors?: unknown } };
9
+ const errors = payload?.meta?.errors;
10
+ if (Array.isArray(errors) && errors.length > 0) {
11
+ const text = errors
12
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
13
+ .filter(Boolean)
14
+ .join("; ");
15
+ if (text) {
16
+ return `${fallback} (${text})`;
17
+ }
18
+ }
19
+ } catch {
20
+ // Ignore JSON parse errors; fall back to generic status text.
21
+ }
22
+ return fallback;
23
+ }
24
+
25
+ function readGroupsResponse(payload: unknown): GroupMeApiGroup[] {
26
+ const response = (payload as { response?: unknown })?.response;
27
+ if (!Array.isArray(response)) {
28
+ throw new Error("GroupMe groups fetch returned an invalid payload");
29
+ }
30
+ return response as GroupMeApiGroup[];
31
+ }
32
+
33
+ function readBotResponse(payload: unknown): GroupMeApiBot {
34
+ const bot = (payload as { response?: { bot?: unknown } })?.response?.bot;
35
+ if (!bot) {
36
+ throw new Error("GroupMe bot creation returned an invalid payload");
37
+ }
38
+ return bot as GroupMeApiBot;
39
+ }
40
+
41
+ export async function fetchGroups(accessToken: string): Promise<GroupMeApiGroup[]> {
42
+ const groups: GroupMeApiGroup[] = [];
43
+ let page = 1;
44
+
45
+ while (true) {
46
+ const url = new URL(`${GROUPME_API_BASE}/groups`);
47
+ url.searchParams.set("token", accessToken);
48
+ url.searchParams.set("per_page", "100");
49
+ url.searchParams.set("omit", "memberships");
50
+ url.searchParams.set("page", String(page));
51
+
52
+ const response = await fetch(url);
53
+ if (!response.ok) {
54
+ throw new Error(await readApiError(response));
55
+ }
56
+
57
+ const payload = await response.json();
58
+ const pageGroups = readGroupsResponse(payload);
59
+ if (pageGroups.length === 0) {
60
+ break;
61
+ }
62
+
63
+ groups.push(...pageGroups);
64
+ page += 1;
65
+ }
66
+
67
+ return groups;
68
+ }
69
+
70
+ export async function createBot(params: {
71
+ accessToken: string;
72
+ name: string;
73
+ groupId: string;
74
+ callbackUrl: string;
75
+ }): Promise<GroupMeApiBot> {
76
+ const url = new URL(`${GROUPME_API_BASE}/bots`);
77
+ url.searchParams.set("token", params.accessToken);
78
+
79
+ const response = await fetch(url, {
80
+ method: "POST",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({
83
+ bot: {
84
+ name: params.name,
85
+ group_id: params.groupId,
86
+ callback_url: params.callbackUrl,
87
+ active: true,
88
+ },
89
+ }),
90
+ });
91
+ if (!response.ok) {
92
+ const apiError = await readApiError(response);
93
+ throw new Error(`GroupMe bot creation failed: ${apiError}`);
94
+ }
95
+
96
+ const payload = await response.json();
97
+ return readBotResponse(payload);
98
+ }
package/src/history.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { HistoryEntry } from "openclaw/plugin-sdk";
2
+
3
+ export const DEFAULT_GROUPME_HISTORY_LIMIT = 20;
4
+
5
+ export function resolveGroupMeHistoryLimit(configured?: number): number {
6
+ if (!Number.isFinite(configured)) {
7
+ return DEFAULT_GROUPME_HISTORY_LIMIT;
8
+ }
9
+
10
+ const normalized = Math.floor(configured as number);
11
+ if (normalized < 0) {
12
+ return DEFAULT_GROUPME_HISTORY_LIMIT;
13
+ }
14
+
15
+ return normalized;
16
+ }
17
+
18
+ export function resolveGroupMeBodyForAgent(params: {
19
+ rawBody: string;
20
+ imageUrls: string[];
21
+ }): string {
22
+ const { rawBody, imageUrls } = params;
23
+ const trimmed = rawBody.trim();
24
+ if (trimmed) {
25
+ return trimmed;
26
+ }
27
+ if (imageUrls.length > 0) {
28
+ return imageUrls.map((url) => `Image: ${url}`).join("\n");
29
+ }
30
+ return rawBody;
31
+ }
32
+
33
+ export function buildGroupMeHistoryEntry(params: {
34
+ senderName: string;
35
+ body: string;
36
+ timestamp: number;
37
+ messageId: string;
38
+ }): HistoryEntry | null {
39
+ const body = params.body.trim();
40
+ if (!body) {
41
+ return null;
42
+ }
43
+
44
+ return {
45
+ sender: params.senderName,
46
+ body,
47
+ timestamp: params.timestamp,
48
+ messageId: params.messageId,
49
+ };
50
+ }
51
+
52
+ export function formatGroupMeHistoryEntry(entry: HistoryEntry): string {
53
+ return `${entry.sender}: ${entry.body}`;
54
+ }