openclaw-groupme 0.0.4 → 0.3.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,90 @@ 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
+ } satisfies ChannelSetupAdapter,
45
168
  capabilities: {
46
169
  chatTypes: ["group"],
47
170
  media: true,
@@ -53,7 +176,8 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
53
176
  listAccountIds: (cfg) => listGroupMeAccountIds(cfg as CoreConfig),
54
177
  resolveAccount: (cfg, accountId) =>
55
178
  resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }),
56
- defaultAccountId: (cfg) => resolveDefaultGroupMeAccountId(cfg as CoreConfig),
179
+ defaultAccountId: (cfg) =>
180
+ resolveDefaultGroupMeAccountId(cfg as CoreConfig),
57
181
  setAccountEnabled: ({ cfg, accountId, enabled }) =>
58
182
  setAccountEnabledInConfigSection({
59
183
  cfg: cfg as CoreConfig,
@@ -72,12 +196,16 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
72
196
  "botId",
73
197
  "accessToken",
74
198
  "botName",
75
- "callbackPath",
199
+ "groupId",
200
+ "publicDomain",
201
+ "callbackUrl",
76
202
  "mentionPatterns",
77
203
  "requireMention",
204
+ "historyLimit",
78
205
  "allowFrom",
79
206
  "textChunkLimit",
80
207
  "responsePrefix",
208
+ "security",
81
209
  ],
82
210
  }),
83
211
  isConfigured: (account) => account.configured,
@@ -87,12 +215,14 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
87
215
  enabled: account.enabled,
88
216
  configured: account.configured,
89
217
  botId: account.botId ? "***" : "",
90
- callbackPath: account.config.callbackPath,
218
+ publicDomain: account.config.publicDomain ?? "",
219
+ callbackUrl: redactWebhookPath(account, account.config.callbackUrl),
91
220
  }),
92
221
  resolveAllowFrom: ({ cfg, accountId }) =>
93
- (resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
94
- (entry) => String(entry),
95
- ),
222
+ (
223
+ resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }).config
224
+ .allowFrom ?? []
225
+ ).map((entry) => String(entry)),
96
226
  formatAllowFrom: ({ allowFrom }) =>
97
227
  allowFrom
98
228
  .map((entry) => normalizeGroupMeAllowEntry(String(entry)))
@@ -109,7 +239,8 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
109
239
  },
110
240
  outbound: {
111
241
  deliveryMode: "direct",
112
- chunker: (text, limit) => getGroupMeRuntime().channel.text.chunkMarkdownText(text, limit),
242
+ chunker: (text, limit) =>
243
+ getGroupMeRuntime().channel.text.chunkMarkdownText(text, limit),
113
244
  chunkerMode: "markdown",
114
245
  textChunkLimit: GROUPME_MAX_TEXT_LENGTH,
115
246
  resolveTarget: ({ to }) => {
@@ -215,7 +346,7 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
215
346
  buildChannelSummary: ({ snapshot }) => ({
216
347
  configured: snapshot.configured ?? false,
217
348
  running: snapshot.running ?? false,
218
- callbackPath: snapshot.webhookPath ?? null,
349
+ callbackUrl: snapshot.webhookPath ?? null,
219
350
  lastStartAt: snapshot.lastStartAt ?? null,
220
351
  lastStopAt: snapshot.lastStopAt ?? null,
221
352
  lastInboundAt: snapshot.lastInboundAt ?? null,
@@ -229,7 +360,7 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
229
360
  configured: account.configured,
230
361
  botId: account.botId ? "***" : "",
231
362
  tokenSource: account.accessToken ? "configured" : "none",
232
- webhookPath: account.config.callbackPath ?? "/groupme",
363
+ webhookPath: redactWebhookPath(account, account.config.callbackUrl),
233
364
  running: runtime?.running ?? false,
234
365
  lastStartAt: runtime?.lastStartAt ?? null,
235
366
  lastStopAt: runtime?.lastStopAt ?? null,
@@ -248,7 +379,11 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
248
379
  );
249
380
  }
250
381
 
251
- const callbackPath = account.config.callbackPath?.trim() || "/groupme";
382
+ const callbackPath = normalizeCallbackUrl(account.config.callbackUrl);
383
+ const redactedCallbackPath = redactWebhookPath(
384
+ account,
385
+ account.config.callbackUrl ?? callbackPath,
386
+ );
252
387
  const unregister = registerPluginHttpRoute({
253
388
  path: callbackPath,
254
389
  fallbackPath: "/groupme",
@@ -256,7 +391,8 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
256
391
  account,
257
392
  config: ctx.cfg as CoreConfig,
258
393
  runtime: ctx.runtime,
259
- statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
394
+ statusSink: (patch) =>
395
+ ctx.setStatus({ accountId: account.accountId, ...patch }),
260
396
  }),
261
397
  pluginId: CHANNEL_ID,
262
398
  accountId: account.accountId,
@@ -267,12 +403,14 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
267
403
  accountId: account.accountId,
268
404
  running: true,
269
405
  mode: "webhook",
270
- webhookPath: callbackPath,
406
+ webhookPath: redactedCallbackPath,
271
407
  lastStartAt: Date.now(),
272
408
  lastError: null,
273
409
  });
274
410
 
275
- ctx.log?.info(`[${account.accountId}] GroupMe webhook listening on ${callbackPath}`);
411
+ ctx.log?.info(
412
+ `[${account.accountId}] GroupMe webhook listening on ${redactedCallbackPath}`,
413
+ );
276
414
 
277
415
  if (ctx.abortSignal.aborted) {
278
416
  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
+ }