openclaw-groupme 0.4.2 → 0.4.3

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/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
2
+ import { groupmePlugin } from "./src/channel.js";
3
+ import { setGroupMeRuntime } from "./src/runtime.js";
4
+ const plugin = {
5
+ id: "groupme",
6
+ name: "GroupMe",
7
+ description: "GroupMe channel plugin",
8
+ configSchema: emptyPluginConfigSchema(),
9
+ register(api) {
10
+ setGroupMeRuntime(api.runtime);
11
+ api.registerChannel({ plugin: groupmePlugin });
12
+ },
13
+ };
14
+ export default plugin;
@@ -0,0 +1,119 @@
1
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId, } from "openclaw/plugin-sdk/account-id";
2
+ const ENV_BOT_ID = "GROUPME_BOT_ID";
3
+ const ENV_ACCESS_TOKEN = "GROUPME_ACCESS_TOKEN";
4
+ const ENV_BOT_NAME = "GROUPME_BOT_NAME";
5
+ const ENV_CALLBACK_URL = "GROUPME_CALLBACK_URL";
6
+ const ENV_GROUP_ID = "GROUPME_GROUP_ID";
7
+ const ENV_PUBLIC_DOMAIN = "GROUPME_PUBLIC_DOMAIN";
8
+ export function readTrimmed(value) {
9
+ if (typeof value !== "string") {
10
+ return undefined;
11
+ }
12
+ const trimmed = value.trim();
13
+ return trimmed || undefined;
14
+ }
15
+ function listConfiguredAccountIds(cfg) {
16
+ const accounts = cfg.channels?.groupme?.accounts;
17
+ if (!accounts || typeof accounts !== "object") {
18
+ return [];
19
+ }
20
+ const ids = new Set();
21
+ for (const key of Object.keys(accounts)) {
22
+ const normalized = normalizeAccountId(key);
23
+ if (normalized) {
24
+ ids.add(normalized);
25
+ }
26
+ }
27
+ return [...ids];
28
+ }
29
+ function resolveAccountConfig(cfg, accountId) {
30
+ const accounts = cfg.channels?.groupme?.accounts;
31
+ if (!accounts || typeof accounts !== "object") {
32
+ return undefined;
33
+ }
34
+ if (Object.hasOwn(accounts, accountId)) {
35
+ return accounts[accountId];
36
+ }
37
+ const hit = Object.keys(accounts).find((key) => normalizeAccountId(key) === accountId);
38
+ return hit ? accounts[hit] : undefined;
39
+ }
40
+ function mergeAccountConfig(cfg, accountId) {
41
+ const raw = (cfg.channels?.groupme ?? {});
42
+ const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
43
+ const account = accountId === DEFAULT_ACCOUNT_ID
44
+ ? {}
45
+ : (resolveAccountConfig(cfg, accountId) ?? {});
46
+ return {
47
+ ...base,
48
+ ...account,
49
+ };
50
+ }
51
+ export function listGroupMeAccountIds(cfg) {
52
+ const sorted = listConfiguredAccountIds(cfg).toSorted((a, b) => a.localeCompare(b));
53
+ return [
54
+ DEFAULT_ACCOUNT_ID,
55
+ ...sorted.filter((id) => id !== DEFAULT_ACCOUNT_ID),
56
+ ];
57
+ }
58
+ export function resolveDefaultGroupMeAccountId(cfg) {
59
+ const configuredDefault = readTrimmed(cfg.channels?.groupme?.defaultAccount);
60
+ if (configuredDefault) {
61
+ return normalizeAccountId(configuredDefault);
62
+ }
63
+ return DEFAULT_ACCOUNT_ID;
64
+ }
65
+ export function resolveGroupMeAccount(params) {
66
+ const normalizedRequested = normalizeAccountId(params.accountId);
67
+ const accountId = normalizedRequested ||
68
+ resolveDefaultGroupMeAccountId(params.cfg) ||
69
+ DEFAULT_ACCOUNT_ID;
70
+ const merged = mergeAccountConfig(params.cfg, accountId);
71
+ const baseEnabled = params.cfg.channels?.groupme?.enabled !== false;
72
+ const accountEnabled = merged.enabled !== false;
73
+ const enabled = baseEnabled && accountEnabled;
74
+ const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID;
75
+ const botId = readTrimmed(merged.botId) ||
76
+ (isDefaultAccount ? readTrimmed(process.env[ENV_BOT_ID]) : undefined) ||
77
+ "";
78
+ const accessToken = readTrimmed(merged.accessToken) ||
79
+ (isDefaultAccount
80
+ ? readTrimmed(process.env[ENV_ACCESS_TOKEN])
81
+ : undefined) ||
82
+ "";
83
+ const botName = readTrimmed(merged.botName) ||
84
+ (isDefaultAccount ? readTrimmed(process.env[ENV_BOT_NAME]) : undefined) ||
85
+ undefined;
86
+ const groupId = readTrimmed(merged.groupId) ||
87
+ (isDefaultAccount
88
+ ? readTrimmed(process.env[ENV_GROUP_ID])
89
+ : undefined) ||
90
+ undefined;
91
+ const callbackUrl = readTrimmed(merged.callbackUrl) ||
92
+ (isDefaultAccount
93
+ ? readTrimmed(process.env[ENV_CALLBACK_URL])
94
+ : undefined) ||
95
+ undefined;
96
+ const publicDomain = readTrimmed(merged.publicDomain) ||
97
+ (isDefaultAccount
98
+ ? readTrimmed(process.env[ENV_PUBLIC_DOMAIN])
99
+ : undefined) ||
100
+ undefined;
101
+ const config = {
102
+ ...merged,
103
+ botId,
104
+ accessToken,
105
+ botName,
106
+ groupId,
107
+ publicDomain,
108
+ callbackUrl,
109
+ };
110
+ return {
111
+ accountId,
112
+ name: readTrimmed(merged.name),
113
+ enabled,
114
+ configured: Boolean(botId),
115
+ botId,
116
+ accessToken,
117
+ config,
118
+ };
119
+ }
@@ -0,0 +1,366 @@
1
+ import { applyAccountNameToChannelSection, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, migrateBaseNameToDefaultAccount, missingTargetError, normalizeAccountId, registerPluginHttpRoute, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk";
2
+ import { listGroupMeAccountIds, resolveDefaultGroupMeAccountId, resolveGroupMeAccount, } from "./accounts.js";
3
+ import { GroupMeConfigSchema } from "./config-schema.js";
4
+ import { createGroupMeWebhookHandler } from "./monitor.js";
5
+ import { normalizeGroupMeAllowEntry, normalizeGroupMeTarget, looksLikeGroupMeTargetId, } from "./normalize.js";
6
+ import { groupmeOnboardingAdapter } from "./onboarding.js";
7
+ import { getGroupMeRuntime } from "./runtime.js";
8
+ import { redactCallbackUrl, resolveGroupMeSecurity } from "./security.js";
9
+ import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText, } from "./send.js";
10
+ const CHANNEL_ID = "groupme";
11
+ function normalizeCallbackUrl(raw) {
12
+ const trimmed = raw?.trim() ?? "";
13
+ if (!trimmed) {
14
+ return "/groupme";
15
+ }
16
+ try {
17
+ const parsed = new URL(trimmed, "http://localhost");
18
+ return parsed.pathname || "/groupme";
19
+ }
20
+ catch {
21
+ const noQuery = trimmed.split("?")[0] ?? trimmed;
22
+ if (!noQuery) {
23
+ return "/groupme";
24
+ }
25
+ return noQuery.startsWith("/") ? noQuery : `/${noQuery}`;
26
+ }
27
+ }
28
+ function redactWebhookPath(account, callbackUrl) {
29
+ const normalized = callbackUrl?.trim() || "/groupme";
30
+ const security = resolveGroupMeSecurity(account.config);
31
+ if (!security.logging.redactSecrets) {
32
+ return normalized;
33
+ }
34
+ return redactCallbackUrl(normalized, security);
35
+ }
36
+ const meta = {
37
+ id: CHANNEL_ID,
38
+ label: "GroupMe",
39
+ selectionLabel: "GroupMe (Bot API)",
40
+ docsPath: "/channels/groupme",
41
+ docsLabel: "groupme",
42
+ blurb: "GroupMe bot webhook integration (group chats only).",
43
+ aliases: ["gm"],
44
+ order: 95,
45
+ quickstartAllowFrom: true,
46
+ };
47
+ export const groupmePlugin = {
48
+ id: CHANNEL_ID,
49
+ meta,
50
+ onboarding: groupmeOnboardingAdapter,
51
+ setup: {
52
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
53
+ applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({
54
+ cfg,
55
+ channelKey: "groupme",
56
+ accountId,
57
+ name,
58
+ }),
59
+ validateInput: ({ input }) => {
60
+ if (!input.token?.trim()) {
61
+ return "GroupMe Bot ID is required (--token <bot-id>)";
62
+ }
63
+ return null;
64
+ },
65
+ applyAccountConfig: ({ cfg, accountId, input }) => {
66
+ let next = applyAccountNameToChannelSection({
67
+ cfg,
68
+ channelKey: "groupme",
69
+ accountId,
70
+ name: input.name,
71
+ });
72
+ if (accountId !== DEFAULT_ACCOUNT_ID) {
73
+ next = migrateBaseNameToDefaultAccount({
74
+ cfg: next,
75
+ channelKey: "groupme",
76
+ });
77
+ }
78
+ const updates = { enabled: true };
79
+ if (input.token?.trim())
80
+ updates.botId = input.token.trim();
81
+ if (input.accessToken?.trim())
82
+ updates.accessToken = input.accessToken.trim();
83
+ if (input.webhookUrl?.trim()) {
84
+ updates.callbackUrl = input.webhookUrl.trim();
85
+ }
86
+ else if (input.webhookPath?.trim()) {
87
+ updates.callbackUrl = input.webhookPath.trim();
88
+ }
89
+ const section = (next.channels?.groupme ?? {});
90
+ if (accountId === DEFAULT_ACCOUNT_ID) {
91
+ return {
92
+ ...next,
93
+ channels: {
94
+ ...next.channels,
95
+ groupme: {
96
+ ...section,
97
+ ...updates,
98
+ },
99
+ },
100
+ };
101
+ }
102
+ return {
103
+ ...next,
104
+ channels: {
105
+ ...next.channels,
106
+ groupme: {
107
+ ...section,
108
+ enabled: true,
109
+ accounts: {
110
+ ...(section.accounts ?? {}),
111
+ [accountId]: {
112
+ ...(section.accounts?.[accountId] ?? {}),
113
+ ...updates,
114
+ },
115
+ },
116
+ },
117
+ },
118
+ };
119
+ },
120
+ resolveBindingAccountId: ({ cfg, accountId }) => {
121
+ if (accountId)
122
+ return accountId;
123
+ const ids = listGroupMeAccountIds(cfg);
124
+ if (ids.length <= 1)
125
+ return DEFAULT_ACCOUNT_ID;
126
+ const section = cfg.channels?.groupme;
127
+ const explicitDefault = section?.defaultAccount?.trim();
128
+ return explicitDefault ? resolveDefaultGroupMeAccountId(cfg) : undefined;
129
+ },
130
+ },
131
+ capabilities: {
132
+ chatTypes: ["group"],
133
+ media: true,
134
+ blockStreaming: true,
135
+ },
136
+ reload: { configPrefixes: ["channels.groupme"] },
137
+ configSchema: buildChannelConfigSchema(GroupMeConfigSchema),
138
+ config: {
139
+ listAccountIds: (cfg) => listGroupMeAccountIds(cfg),
140
+ resolveAccount: (cfg, accountId) => resolveGroupMeAccount({ cfg: cfg, accountId }),
141
+ defaultAccountId: (cfg) => resolveDefaultGroupMeAccountId(cfg),
142
+ setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({
143
+ cfg: cfg,
144
+ sectionKey: CHANNEL_ID,
145
+ accountId,
146
+ enabled,
147
+ allowTopLevel: true,
148
+ }),
149
+ deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({
150
+ cfg: cfg,
151
+ sectionKey: CHANNEL_ID,
152
+ accountId,
153
+ clearBaseFields: [
154
+ "name",
155
+ "botId",
156
+ "accessToken",
157
+ "botName",
158
+ "groupId",
159
+ "publicDomain",
160
+ "callbackUrl",
161
+ "mentionPatterns",
162
+ "requireMention",
163
+ "historyLimit",
164
+ "allowFrom",
165
+ "textChunkLimit",
166
+ "responsePrefix",
167
+ "security",
168
+ ],
169
+ }),
170
+ isConfigured: (account) => account.configured,
171
+ describeAccount: (account) => ({
172
+ accountId: account.accountId,
173
+ name: account.name,
174
+ enabled: account.enabled,
175
+ configured: account.configured,
176
+ botId: account.botId ? "***" : "",
177
+ publicDomain: account.config.publicDomain ?? "",
178
+ callbackUrl: redactWebhookPath(account, account.config.callbackUrl),
179
+ }),
180
+ resolveAllowFrom: ({ cfg, accountId }) => (resolveGroupMeAccount({ cfg: cfg, accountId }).config
181
+ .allowFrom ?? []).map((entry) => String(entry)),
182
+ formatAllowFrom: ({ allowFrom }) => allowFrom
183
+ .map((entry) => normalizeGroupMeAllowEntry(String(entry)))
184
+ .filter((entry) => Boolean(entry)),
185
+ },
186
+ groups: {
187
+ resolveRequireMention: ({ cfg, accountId }) => {
188
+ const account = resolveGroupMeAccount({
189
+ cfg: cfg,
190
+ accountId,
191
+ });
192
+ return account.config.requireMention ?? true;
193
+ },
194
+ },
195
+ outbound: {
196
+ deliveryMode: "direct",
197
+ chunker: (text, limit) => getGroupMeRuntime().channel.text.chunkMarkdownText(text, limit),
198
+ chunkerMode: "markdown",
199
+ textChunkLimit: GROUPME_MAX_TEXT_LENGTH,
200
+ resolveTarget: ({ to }) => {
201
+ const normalized = normalizeGroupMeTarget(to?.trim() ?? "");
202
+ if (!normalized) {
203
+ return {
204
+ ok: false,
205
+ error: missingTargetError("GroupMe", "<group-id>"),
206
+ };
207
+ }
208
+ return {
209
+ ok: true,
210
+ to: normalized,
211
+ };
212
+ },
213
+ sendText: async ({ cfg, to, text, accountId }) => {
214
+ const result = await sendGroupMeText({
215
+ cfg: cfg,
216
+ to,
217
+ text,
218
+ accountId,
219
+ });
220
+ return {
221
+ channel: CHANNEL_ID,
222
+ messageId: result.messageId,
223
+ timestamp: result.timestamp,
224
+ };
225
+ },
226
+ sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
227
+ if (!mediaUrl?.trim()) {
228
+ throw new Error("GroupMe media send requires a mediaUrl");
229
+ }
230
+ const result = await sendGroupMeMedia({
231
+ cfg: cfg,
232
+ to,
233
+ text,
234
+ mediaUrl,
235
+ accountId,
236
+ });
237
+ return {
238
+ channel: CHANNEL_ID,
239
+ messageId: result.messageId,
240
+ timestamp: result.timestamp,
241
+ };
242
+ },
243
+ },
244
+ messaging: {
245
+ normalizeTarget: normalizeGroupMeTarget,
246
+ targetResolver: {
247
+ looksLikeId: (raw) => looksLikeGroupMeTargetId(raw),
248
+ hint: "<group-id>",
249
+ },
250
+ },
251
+ resolver: {
252
+ resolveTargets: async ({ inputs, kind }) => {
253
+ return inputs.map((input) => {
254
+ const normalized = normalizeGroupMeTarget(input);
255
+ if (!normalized) {
256
+ return {
257
+ input,
258
+ resolved: false,
259
+ note: "empty target",
260
+ };
261
+ }
262
+ return {
263
+ input,
264
+ resolved: true,
265
+ id: normalized,
266
+ name: normalized,
267
+ note: kind === "user" ? "GroupMe bots are group-only" : undefined,
268
+ };
269
+ });
270
+ },
271
+ },
272
+ directory: {
273
+ self: async () => null,
274
+ listPeers: async ({ cfg, accountId, query, limit }) => {
275
+ const account = resolveGroupMeAccount({
276
+ cfg: cfg,
277
+ accountId,
278
+ });
279
+ const q = query?.trim().toLowerCase() ?? "";
280
+ return (account.config.allowFrom ?? [])
281
+ .map((entry) => normalizeGroupMeAllowEntry(String(entry)))
282
+ .filter((entry) => Boolean(entry) && entry !== "*")
283
+ .filter((entry) => (q ? entry.toLowerCase().includes(q) : true))
284
+ .slice(0, limit && limit > 0 ? limit : undefined)
285
+ .map((id) => ({ kind: "user", id }));
286
+ },
287
+ listGroups: async () => [],
288
+ },
289
+ status: {
290
+ defaultRuntime: {
291
+ accountId: DEFAULT_ACCOUNT_ID,
292
+ running: false,
293
+ lastStartAt: null,
294
+ lastStopAt: null,
295
+ lastError: null,
296
+ },
297
+ buildChannelSummary: ({ snapshot }) => ({
298
+ configured: snapshot.configured ?? false,
299
+ running: snapshot.running ?? false,
300
+ callbackUrl: snapshot.webhookPath ?? null,
301
+ lastStartAt: snapshot.lastStartAt ?? null,
302
+ lastStopAt: snapshot.lastStopAt ?? null,
303
+ lastInboundAt: snapshot.lastInboundAt ?? null,
304
+ lastOutboundAt: snapshot.lastOutboundAt ?? null,
305
+ lastError: snapshot.lastError ?? null,
306
+ }),
307
+ buildAccountSnapshot: ({ account, runtime }) => ({
308
+ accountId: account.accountId,
309
+ name: account.name,
310
+ enabled: account.enabled,
311
+ configured: account.configured,
312
+ botId: account.botId ? "***" : "",
313
+ tokenSource: account.accessToken ? "configured" : "none",
314
+ webhookPath: redactWebhookPath(account, account.config.callbackUrl),
315
+ running: runtime?.running ?? false,
316
+ lastStartAt: runtime?.lastStartAt ?? null,
317
+ lastStopAt: runtime?.lastStopAt ?? null,
318
+ lastInboundAt: runtime?.lastInboundAt ?? null,
319
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
320
+ lastError: runtime?.lastError ?? null,
321
+ mode: "webhook",
322
+ }),
323
+ },
324
+ gateway: {
325
+ startAccount: async (ctx) => {
326
+ const account = ctx.account;
327
+ if (!account.configured) {
328
+ throw new Error(`GroupMe is not configured for account "${account.accountId}" (missing botId).`);
329
+ }
330
+ const callbackPath = normalizeCallbackUrl(account.config.callbackUrl);
331
+ const redactedCallbackPath = redactWebhookPath(account, account.config.callbackUrl ?? callbackPath);
332
+ const unregister = registerPluginHttpRoute({
333
+ path: callbackPath,
334
+ fallbackPath: "/groupme",
335
+ handler: createGroupMeWebhookHandler({
336
+ account,
337
+ config: ctx.cfg,
338
+ runtime: ctx.runtime,
339
+ statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
340
+ }),
341
+ pluginId: CHANNEL_ID,
342
+ accountId: account.accountId,
343
+ log: (message) => ctx.log?.info(message),
344
+ });
345
+ ctx.setStatus({
346
+ accountId: account.accountId,
347
+ running: true,
348
+ mode: "webhook",
349
+ webhookPath: redactedCallbackPath,
350
+ lastStartAt: Date.now(),
351
+ lastError: null,
352
+ });
353
+ ctx.log?.info(`[${account.accountId}] GroupMe webhook listening on ${redactedCallbackPath}`);
354
+ if (ctx.abortSignal.aborted) {
355
+ unregister();
356
+ return;
357
+ }
358
+ await new Promise((resolve) => {
359
+ ctx.abortSignal.addEventListener("abort", () => {
360
+ unregister();
361
+ resolve();
362
+ }, { once: true });
363
+ });
364
+ },
365
+ },
366
+ };
@@ -0,0 +1,86 @@
1
+ import { BlockStreamingCoalesceSchema, MarkdownConfigSchema, } from "openclaw/plugin-sdk";
2
+ import { z } from "zod";
3
+ const allowFromEntry = z.union([z.string(), z.number()]);
4
+ const GroupMeReplaySchema = z
5
+ .object({
6
+ ttlSeconds: z.number().int().positive().optional(),
7
+ maxEntries: z.number().int().positive().optional(),
8
+ })
9
+ .strict();
10
+ const GroupMeRateLimitSchema = z
11
+ .object({
12
+ windowMs: z.number().int().positive().optional(),
13
+ maxRequestsPerIp: z.number().int().positive().optional(),
14
+ maxRequestsPerSender: z.number().int().positive().optional(),
15
+ maxConcurrent: z.number().int().positive().optional(),
16
+ })
17
+ .strict();
18
+ const GroupMeMediaSecuritySchema = z
19
+ .object({
20
+ allowPrivateNetworks: z.boolean().optional().default(false),
21
+ maxDownloadBytes: z.number().int().positive().optional(),
22
+ requestTimeoutMs: z.number().int().positive().optional(),
23
+ allowedMimePrefixes: z.array(z.string()).optional(),
24
+ })
25
+ .strict();
26
+ const GroupMeLoggingSecuritySchema = z
27
+ .object({
28
+ redactSecrets: z.boolean().optional().default(true),
29
+ logRejectedRequests: z.boolean().optional().default(true),
30
+ })
31
+ .strict();
32
+ const GroupMeCommandBypassSecuritySchema = z
33
+ .object({
34
+ requireAllowFrom: z.boolean().optional().default(true),
35
+ requireMentionForCommands: z.boolean().optional().default(false),
36
+ })
37
+ .strict();
38
+ const GroupMeProxySecuritySchema = z
39
+ .object({
40
+ trustedProxyCidrs: z.array(z.string()).optional(),
41
+ allowedPublicHosts: z.array(z.string()).optional(),
42
+ requireHttpsProto: z.boolean().optional().default(false),
43
+ rejectStatus: z
44
+ .union([z.literal(400), z.literal(403), z.literal(404)])
45
+ .optional(),
46
+ })
47
+ .strict();
48
+ const GroupMeSecuritySchema = z
49
+ .object({
50
+ replay: GroupMeReplaySchema.optional(),
51
+ rateLimit: GroupMeRateLimitSchema.optional(),
52
+ media: GroupMeMediaSecuritySchema.optional(),
53
+ logging: GroupMeLoggingSecuritySchema.optional(),
54
+ commandBypass: GroupMeCommandBypassSecuritySchema.optional(),
55
+ proxy: GroupMeProxySecuritySchema.optional(),
56
+ })
57
+ .strict();
58
+ export const GroupMeAccountSchemaBase = z
59
+ .object({
60
+ name: z.string().optional(),
61
+ enabled: z.boolean().optional(),
62
+ botId: z.string().optional(),
63
+ accessToken: z.string().optional(),
64
+ botName: z.string().optional(),
65
+ groupId: z.string().optional(),
66
+ publicDomain: z.string().optional(),
67
+ callbackUrl: z.string().optional(),
68
+ mentionPatterns: z.array(z.string()).optional(),
69
+ requireMention: z.boolean().optional().default(true),
70
+ historyLimit: z.number().int().nonnegative().optional(),
71
+ allowFrom: z.array(allowFromEntry).optional(),
72
+ markdown: MarkdownConfigSchema,
73
+ textChunkLimit: z.number().int().positive().optional(),
74
+ blockStreaming: z.boolean().optional(),
75
+ blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
76
+ responsePrefix: z.string().optional(),
77
+ mediaMaxMb: z.number().positive().optional(),
78
+ security: GroupMeSecuritySchema.optional(),
79
+ })
80
+ .strict();
81
+ export const GroupMeConfigSchema = GroupMeAccountSchemaBase.extend({
82
+ accounts: z
83
+ .record(z.string(), GroupMeAccountSchemaBase.optional())
84
+ .optional(),
85
+ defaultAccount: z.string().optional(),
86
+ }).strict();
@@ -0,0 +1,80 @@
1
+ const GROUPME_API_BASE = "https://api.groupme.com/v3";
2
+ async function readApiError(response) {
3
+ const fallback = `GroupMe API error: ${response.status} ${response.statusText}`;
4
+ try {
5
+ const payload = (await response.json());
6
+ const errors = payload?.meta?.errors;
7
+ if (Array.isArray(errors) && errors.length > 0) {
8
+ const text = errors
9
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
10
+ .filter(Boolean)
11
+ .join("; ");
12
+ if (text) {
13
+ return `${fallback} (${text})`;
14
+ }
15
+ }
16
+ }
17
+ catch {
18
+ // Ignore JSON parse errors; fall back to generic status text.
19
+ }
20
+ return fallback;
21
+ }
22
+ function readGroupsResponse(payload) {
23
+ const response = payload?.response;
24
+ if (!Array.isArray(response)) {
25
+ throw new Error("GroupMe groups fetch returned an invalid payload");
26
+ }
27
+ return response;
28
+ }
29
+ function readBotResponse(payload) {
30
+ const bot = payload?.response?.bot;
31
+ if (!bot) {
32
+ throw new Error("GroupMe bot creation returned an invalid payload");
33
+ }
34
+ return bot;
35
+ }
36
+ export async function fetchGroups(accessToken) {
37
+ const groups = [];
38
+ let page = 1;
39
+ while (true) {
40
+ const url = new URL(`${GROUPME_API_BASE}/groups`);
41
+ url.searchParams.set("token", accessToken);
42
+ url.searchParams.set("per_page", "100");
43
+ url.searchParams.set("omit", "memberships");
44
+ url.searchParams.set("page", String(page));
45
+ const response = await fetch(url);
46
+ if (!response.ok) {
47
+ throw new Error(await readApiError(response));
48
+ }
49
+ const payload = await response.json();
50
+ const pageGroups = readGroupsResponse(payload);
51
+ if (pageGroups.length === 0) {
52
+ break;
53
+ }
54
+ groups.push(...pageGroups);
55
+ page += 1;
56
+ }
57
+ return groups;
58
+ }
59
+ export async function createBot(params) {
60
+ const url = new URL(`${GROUPME_API_BASE}/bots`);
61
+ url.searchParams.set("token", params.accessToken);
62
+ const response = await fetch(url, {
63
+ method: "POST",
64
+ headers: { "content-type": "application/json" },
65
+ body: JSON.stringify({
66
+ bot: {
67
+ name: params.name,
68
+ group_id: params.groupId,
69
+ callback_url: params.callbackUrl,
70
+ active: true,
71
+ },
72
+ }),
73
+ });
74
+ if (!response.ok) {
75
+ const apiError = await readApiError(response);
76
+ throw new Error(`GroupMe bot creation failed: ${apiError}`);
77
+ }
78
+ const payload = await response.json();
79
+ return readBotResponse(payload);
80
+ }