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/parse.ts CHANGED
@@ -68,9 +68,7 @@ function parseStringArray(value: unknown): string[] {
68
68
  return [];
69
69
  }
70
70
 
71
- return value
72
- .map((entry) => readString(entry))
73
- .filter((entry): entry is string => Boolean(entry));
71
+ return value.map((entry) => readString(entry)).filter((entry): entry is string => Boolean(entry));
74
72
  }
75
73
 
76
74
  function parseAttachment(entry: unknown): GroupMeAttachment | null {
@@ -136,9 +134,7 @@ function parseAttachments(value: unknown): GroupMeAttachment[] {
136
134
  .filter((parsed): parsed is GroupMeAttachment => parsed !== null);
137
135
  }
138
136
 
139
- export function parseGroupMeCallback(
140
- data: unknown,
141
- ): GroupMeCallbackData | null {
137
+ export function parseGroupMeCallback(data: unknown): GroupMeCallbackData | null {
142
138
  if (!isRecord(data)) {
143
139
  return null;
144
140
  }
@@ -152,15 +148,7 @@ export function parseGroupMeCallback(
152
148
  const sourceGuid = readString(data.source_guid);
153
149
  const createdAt = readNumber(data.created_at);
154
150
 
155
- if (
156
- !id ||
157
- !name ||
158
- !senderType ||
159
- !senderId ||
160
- !userId ||
161
- !groupId ||
162
- !sourceGuid
163
- ) {
151
+ if (!id || !name || !senderType || !senderId || !userId || !groupId || !sourceGuid) {
164
152
  return null;
165
153
  }
166
154
  if (typeof createdAt !== "number") {
@@ -186,7 +174,7 @@ export function parseGroupMeCallback(
186
174
  };
187
175
  }
188
176
 
189
- export function hasImageAttachment(attachments: GroupMeAttachment[]): boolean {
177
+ function hasImageAttachment(attachments: GroupMeAttachment[]): boolean {
190
178
  return attachments.some((attachment) => attachment.type === "image");
191
179
  }
192
180
 
@@ -206,17 +194,12 @@ export function shouldProcessCallback(msg: GroupMeCallbackData): string | null {
206
194
 
207
195
  export function extractImageUrls(attachments: GroupMeAttachment[]): string[] {
208
196
  return attachments
209
- .filter(
210
- (attachment): attachment is GroupMeImageAttachment =>
211
- attachment.type === "image",
212
- )
197
+ .filter((attachment): attachment is GroupMeImageAttachment => attachment.type === "image")
213
198
  .map((attachment) => attachment.url);
214
199
  }
215
200
 
216
201
  function normalizeMentionText(text: string): string {
217
- return text
218
- .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
219
- .toLowerCase();
202
+ return text.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "").toLowerCase();
220
203
  }
221
204
 
222
205
  function buildRegexes(patterns?: string[]): RegExp[] {
package/src/policy.ts CHANGED
@@ -1,7 +1,4 @@
1
- import {
2
- normalizeGroupMeAllowEntry,
3
- normalizeStringId,
4
- } from "./normalize.js";
1
+ import { normalizeGroupMeAllowEntry, normalizeStringId } from "./normalize.js";
5
2
 
6
3
  export function resolveSenderAccess(params: {
7
4
  senderId: string;
package/src/rate-limit.ts CHANGED
@@ -1,10 +1,15 @@
1
- export type RateLimitCheck =
1
+ type RateLimitCheck =
2
2
  | { kind: "accepted"; release: () => void }
3
3
  | { kind: "rejected"; scope: "ip" | "sender" | "concurrency" };
4
4
 
5
5
  type SlidingWindowState = Map<string, number[]>;
6
6
  const DEFAULT_MAX_TRACKED_KEYS = 10_000;
7
7
 
8
+ function positiveIntegerAtLeastOne(value: number): number {
9
+ const normalized = Math.floor(value);
10
+ return Number.isFinite(normalized) ? Math.max(1, normalized) : 1;
11
+ }
12
+
8
13
  function allowInWindow(params: {
9
14
  state: SlidingWindowState;
10
15
  key: string;
@@ -41,13 +46,10 @@ export class GroupMeRateLimiter {
41
46
  maxRequestsPerSender: number;
42
47
  maxConcurrent: number;
43
48
  }) {
44
- this.windowMs = Math.max(1, Math.floor(params.windowMs));
45
- this.maxRequestsPerIp = Math.max(1, Math.floor(params.maxRequestsPerIp));
46
- this.maxRequestsPerSender = Math.max(
47
- 1,
48
- Math.floor(params.maxRequestsPerSender),
49
- );
50
- this.maxConcurrent = Math.max(1, Math.floor(params.maxConcurrent));
49
+ this.windowMs = positiveIntegerAtLeastOne(params.windowMs);
50
+ this.maxRequestsPerIp = positiveIntegerAtLeastOne(params.maxRequestsPerIp);
51
+ this.maxRequestsPerSender = positiveIntegerAtLeastOne(params.maxRequestsPerSender);
52
+ this.maxConcurrent = positiveIntegerAtLeastOne(params.maxConcurrent);
51
53
  this.maxTrackedKeys = DEFAULT_MAX_TRACKED_KEYS;
52
54
  }
53
55
 
@@ -117,12 +119,13 @@ export class GroupMeRateLimiter {
117
119
  }
118
120
 
119
121
  private capStateSize(state: SlidingWindowState) {
122
+ // Only triggers past DEFAULT_MAX_TRACKED_KEYS (10k) distinct keys within a single
123
+ // window — not exercised in unit tests.
124
+ /* v8 ignore start */
120
125
  while (state.size > this.maxTrackedKeys) {
121
- const oldest = state.keys().next().value as string | undefined;
122
- if (!oldest) {
123
- return;
124
- }
126
+ const oldest = state.keys().next().value as string;
125
127
  state.delete(oldest);
126
128
  }
129
+ /* v8 ignore stop */
127
130
  }
128
131
  }
@@ -44,10 +44,7 @@ export class GroupMeReplayCache {
44
44
 
45
45
  private evictOverflow() {
46
46
  while (this.entries.size > this.maxEntries) {
47
- const oldest = this.entries.keys().next().value as string | undefined;
48
- if (!oldest) {
49
- return;
50
- }
47
+ const oldest = this.entries.keys().next().value as string;
51
48
  this.entries.delete(oldest);
52
49
  }
53
50
  }
package/src/runtime.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginRuntime } from "openclaw/plugin-sdk";
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
2
2
 
3
3
  let runtime: PluginRuntime | null = null;
4
4
 
@@ -0,0 +1,66 @@
1
+ import {
2
+ collectSimpleChannelFieldAssignments,
3
+ getChannelSurface,
4
+ type ResolverContext,
5
+ type SecretDefaults,
6
+ type SecretTargetRegistryEntry,
7
+ } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
8
+
9
+ const secretFields = ["botId", "accessToken", "callbackToken"] as const;
10
+
11
+ export const secretTargetRegistryEntries: SecretTargetRegistryEntry[] = secretFields.flatMap(
12
+ (field) => [
13
+ {
14
+ id: `channels.groupme.accounts.*.${field}`,
15
+ targetType: `channels.groupme.accounts.*.${field}`,
16
+ configFile: "openclaw.json",
17
+ pathPattern: `channels.groupme.accounts.*.${field}`,
18
+ secretShape: "secret_input",
19
+ expectedResolvedValue: "string",
20
+ includeInPlan: true,
21
+ includeInConfigure: true,
22
+ includeInAudit: true,
23
+ },
24
+ {
25
+ id: `channels.groupme.${field}`,
26
+ targetType: `channels.groupme.${field}`,
27
+ configFile: "openclaw.json",
28
+ pathPattern: `channels.groupme.${field}`,
29
+ secretShape: "secret_input",
30
+ expectedResolvedValue: "string",
31
+ includeInPlan: true,
32
+ includeInConfigure: true,
33
+ includeInAudit: true,
34
+ },
35
+ ],
36
+ );
37
+
38
+ export function collectRuntimeConfigAssignments(params: {
39
+ config: { channels?: Record<string, unknown> };
40
+ defaults?: SecretDefaults;
41
+ context: ResolverContext;
42
+ }): void {
43
+ const resolved = getChannelSurface(params.config, "groupme");
44
+ if (!resolved) {
45
+ return;
46
+ }
47
+
48
+ const { channel, surface } = resolved;
49
+ for (const field of secretFields) {
50
+ collectSimpleChannelFieldAssignments({
51
+ channelKey: "groupme",
52
+ field,
53
+ channel,
54
+ surface,
55
+ defaults: params.defaults,
56
+ context: params.context,
57
+ topInactiveReason: `no enabled account inherits this top-level GroupMe ${field}.`,
58
+ accountInactiveReason: "GroupMe account is disabled.",
59
+ });
60
+ }
61
+ }
62
+
63
+ export const channelSecrets = {
64
+ secretTargetRegistryEntries,
65
+ collectRuntimeConfigAssignments,
66
+ };
package/src/security.ts CHANGED
@@ -2,11 +2,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
2
2
  import type { IncomingHttpHeaders } from "node:http";
3
3
  import { BlockList, isIP } from "node:net";
4
4
  import { readTrimmed } from "./accounts.js";
5
- import type {
6
- CallbackAuthResult,
7
- GroupMeAccountConfig,
8
- GroupMeSecurityConfig,
9
- } from "./types.js";
5
+ import type { CallbackAuthResult, GroupMeAccountConfig, GroupMeSecurityConfig } from "./types.js";
10
6
 
11
7
  type ProxyRule = {
12
8
  kind: "cidr" | "ip";
@@ -21,12 +17,10 @@ export type ResolvedGroupMeSecurity = {
21
17
  callbackRejectStatus: 404;
22
18
  groupId: string;
23
19
  replay: {
24
- enabled: boolean;
25
20
  ttlSeconds: number;
26
21
  maxEntries: number;
27
22
  };
28
23
  rateLimit: {
29
- enabled: boolean;
30
24
  windowMs: number;
31
25
  maxRequestsPerIp: number;
32
26
  maxRequestsPerSender: number;
@@ -56,7 +50,7 @@ export type ResolvedGroupMeSecurity = {
56
50
  };
57
51
  };
58
52
 
59
- export type GroupMeWebhookRequestContext = {
53
+ type GroupMeWebhookRequestContext = {
60
54
  remoteIp: string;
61
55
  clientIp: string;
62
56
  host: string;
@@ -65,7 +59,7 @@ export type GroupMeWebhookRequestContext = {
65
59
  usingForwardedHeaders: boolean;
66
60
  };
67
61
 
68
- export type GroupMeProxyValidation =
62
+ type GroupMeProxyValidation =
69
63
  | { ok: true; context: GroupMeWebhookRequestContext }
70
64
  | {
71
65
  ok: false;
@@ -86,7 +80,7 @@ function normalizeIpCandidate(raw: string): string {
86
80
  return "";
87
81
  }
88
82
  if (value.includes(",")) {
89
- value = value.split(",")[0]?.trim() ?? "";
83
+ value = value.split(",")[0].trim();
90
84
  }
91
85
  if (value.startsWith("[")) {
92
86
  const endIndex = value.indexOf("]");
@@ -108,10 +102,10 @@ function normalizeIpCandidate(raw: string): string {
108
102
  const maybeWithPort = value.split(":");
109
103
  if (
110
104
  maybeWithPort.length === 2 &&
111
- /^\d+$/.test(maybeWithPort[1] ?? "") &&
112
- isIP(maybeWithPort[0] ?? "") === 4
105
+ /^\d+$/.test(maybeWithPort[1]) &&
106
+ isIP(maybeWithPort[0]) === 4
113
107
  ) {
114
- value = maybeWithPort[0] ?? "";
108
+ value = maybeWithPort[0];
115
109
  }
116
110
  }
117
111
  return isIP(value) === 0 ? "" : value;
@@ -134,7 +128,7 @@ function normalizeHost(value: string): string {
134
128
  return "";
135
129
  }
136
130
  if (host.includes(",")) {
137
- host = host.split(",")[0]?.trim() ?? "";
131
+ host = host.split(",")[0].trim();
138
132
  }
139
133
  if (!host) {
140
134
  return "";
@@ -150,11 +144,8 @@ function normalizeHost(value: string): string {
150
144
  return "";
151
145
  }
152
146
  const maybeWithoutPort = host.split(":");
153
- if (
154
- maybeWithoutPort.length === 2 &&
155
- /^\d+$/.test(maybeWithoutPort[1] ?? "")
156
- ) {
157
- host = maybeWithoutPort[0] ?? "";
147
+ if (maybeWithoutPort.length === 2 && /^\d+$/.test(maybeWithoutPort[1])) {
148
+ host = maybeWithoutPort[0];
158
149
  }
159
150
  return host.trim();
160
151
  }
@@ -163,19 +154,22 @@ function parseProxyRules(entries: string[]): ProxyRule[] {
163
154
  const rules: ProxyRule[] = [];
164
155
  for (const entry of entries) {
165
156
  const raw = entry.trim();
157
+ // resolveGroupMeSecurity already trims and Boolean-filters these entries, so a
158
+ // blank value never reaches this guard; kept as defense in depth.
159
+ /* v8 ignore start */
166
160
  if (!raw) {
167
161
  continue;
168
162
  }
163
+ /* v8 ignore stop */
169
164
  if (raw.includes("/")) {
170
165
  const [network, prefixRaw] = raw.split("/");
171
- const normalizedNetwork = normalizeIpCandidate(network ?? "");
166
+ const normalizedNetwork = normalizeIpCandidate(network);
172
167
  const ipVersion = isIP(normalizedNetwork);
173
168
  if (!ipVersion) {
174
169
  continue;
175
170
  }
176
171
  const prefix = Number(prefixRaw);
177
- const maxPrefix =
178
- ipVersion === 4 ? IPV4_MAX_CIDR_PREFIX : IPV6_MAX_CIDR_PREFIX;
172
+ const maxPrefix = ipVersion === 4 ? IPV4_MAX_CIDR_PREFIX : IPV6_MAX_CIDR_PREFIX;
179
173
  if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) {
180
174
  continue;
181
175
  }
@@ -229,24 +223,11 @@ function createTrustedProxyMatcher(entries: string[]): (ip: string) => boolean {
229
223
  };
230
224
  }
231
225
 
232
- function extractCallbackToken(callbackUrl: string | undefined): string {
233
- const raw = callbackUrl?.trim() ?? "";
234
- if (!raw) {
235
- return "";
236
- }
237
- try {
238
- const parsed = new URL(raw, "http://localhost");
239
- return parsed.searchParams.get("k")?.trim() ?? "";
240
- } catch {
241
- throw new Error(`Invalid callbackUrl: unable to parse "${raw}"`);
242
- }
243
- }
244
-
245
226
  export function resolveGroupMeSecurity(
246
227
  accountConfig: GroupMeAccountConfig,
247
228
  ): ResolvedGroupMeSecurity {
248
229
  const security = (accountConfig.security ?? {}) as GroupMeSecurityConfig;
249
- const callbackToken = extractCallbackToken(accountConfig.callbackUrl);
230
+ const callbackToken = readTrimmed(accountConfig.callbackToken) ?? "";
250
231
  const groupId = readTrimmed(accountConfig.groupId) ?? "";
251
232
 
252
233
  const allowedMimePrefixes = Array.isArray(security.media?.allowedMimePrefixes)
@@ -270,12 +251,10 @@ export function resolveGroupMeSecurity(
270
251
  callbackRejectStatus: 404,
271
252
  groupId,
272
253
  replay: {
273
- enabled: true,
274
254
  ttlSeconds: positiveIntOrDefault(security.replay?.ttlSeconds, 600),
275
255
  maxEntries: positiveIntOrDefault(security.replay?.maxEntries, 10_000),
276
256
  },
277
257
  rateLimit: {
278
- enabled: true,
279
258
  windowMs: positiveIntOrDefault(security.rateLimit?.windowMs, 60_000),
280
259
  maxRequestsPerIp: positiveIntOrDefault(security.rateLimit?.maxRequestsPerIp, 120),
281
260
  maxRequestsPerSender: positiveIntOrDefault(security.rateLimit?.maxRequestsPerSender, 60),
@@ -285,8 +264,7 @@ export function resolveGroupMeSecurity(
285
264
  allowPrivateNetworks: security.media?.allowPrivateNetworks === true,
286
265
  maxDownloadBytes: positiveIntOrDefault(security.media?.maxDownloadBytes, 15 * 1024 * 1024),
287
266
  requestTimeoutMs: positiveIntOrDefault(security.media?.requestTimeoutMs, 10_000),
288
- allowedMimePrefixes:
289
- allowedMimePrefixes.length > 0 ? allowedMimePrefixes : ["image/"],
267
+ allowedMimePrefixes: allowedMimePrefixes.length > 0 ? allowedMimePrefixes : ["image/"],
290
268
  },
291
269
  logging: {
292
270
  redactSecrets: security.logging?.redactSecrets !== false,
@@ -294,8 +272,7 @@ export function resolveGroupMeSecurity(
294
272
  },
295
273
  commandBypass: {
296
274
  requireAllowFrom: security.commandBypass?.requireAllowFrom !== false,
297
- requireMentionForCommands:
298
- security.commandBypass?.requireMentionForCommands === true,
275
+ requireMentionForCommands: security.commandBypass?.requireMentionForCommands === true,
299
276
  },
300
277
  proxy: {
301
278
  enabled: security.proxy != null,
@@ -343,10 +320,7 @@ export function checkGroupBinding(params: {
343
320
  return { ok: true };
344
321
  }
345
322
 
346
- export function redactCallbackUrl(
347
- raw: string,
348
- security: ResolvedGroupMeSecurity,
349
- ): string {
323
+ export function redactWebhookUrl(raw: string, security: ResolvedGroupMeSecurity): string {
350
324
  if (!security.callbackToken) {
351
325
  return raw;
352
326
  }
@@ -371,9 +345,7 @@ export function validateProxyRequest(params: {
371
345
  }): GroupMeProxyValidation {
372
346
  const remoteIp = normalizeIpCandidate(params.remoteAddress) || "unknown";
373
347
  const proxyConfig = params.security.proxy;
374
- const defaultProto: "http" | "https" = params.socketEncrypted
375
- ? "https"
376
- : "http";
348
+ const defaultProto: "http" | "https" = params.socketEncrypted ? "https" : "http";
377
349
  const hostHeader = normalizeHost(getHeaderValue(params.headers, "host"));
378
350
 
379
351
  if (!proxyConfig.enabled) {
@@ -391,32 +363,22 @@ export function validateProxyRequest(params: {
391
363
  }
392
364
 
393
365
  const fromTrustedProxy =
394
- proxyConfig.trustedProxyCidrs.length > 0 &&
395
- proxyConfig.isTrustedProxy(remoteIp);
366
+ proxyConfig.trustedProxyCidrs.length > 0 && proxyConfig.isTrustedProxy(remoteIp);
396
367
 
397
- const forwardedFor = normalizeIpCandidate(
398
- getHeaderValue(params.headers, "x-forwarded-for"),
399
- );
400
- const forwardedHost = normalizeHost(
401
- getHeaderValue(params.headers, "x-forwarded-host"),
402
- );
368
+ const forwardedFor = normalizeIpCandidate(getHeaderValue(params.headers, "x-forwarded-for"));
369
+ const forwardedHost = normalizeHost(getHeaderValue(params.headers, "x-forwarded-host"));
403
370
  const forwardedProtoRaw = getHeaderValue(params.headers, "x-forwarded-proto")
404
371
  .split(",")[0]
405
372
  ?.trim()
406
373
  .toLowerCase();
407
374
  const forwardedProto: "http" | "https" | null =
408
- forwardedProtoRaw === "http" || forwardedProtoRaw === "https"
409
- ? forwardedProtoRaw
410
- : null;
375
+ forwardedProtoRaw === "http" || forwardedProtoRaw === "https" ? forwardedProtoRaw : null;
411
376
 
412
377
  const usingForwardedHeaders =
413
378
  fromTrustedProxy && Boolean(forwardedFor || forwardedHost || forwardedProto);
414
- const effectiveClientIp =
415
- usingForwardedHeaders && forwardedFor ? forwardedFor : remoteIp;
416
- const effectiveHost =
417
- usingForwardedHeaders && forwardedHost ? forwardedHost : hostHeader;
418
- const effectiveProto =
419
- usingForwardedHeaders && forwardedProto ? forwardedProto : defaultProto;
379
+ const effectiveClientIp = usingForwardedHeaders && forwardedFor ? forwardedFor : remoteIp;
380
+ const effectiveHost = usingForwardedHeaders && forwardedHost ? forwardedHost : hostHeader;
381
+ const effectiveProto = usingForwardedHeaders && forwardedProto ? forwardedProto : defaultProto;
420
382
 
421
383
  if (!effectiveHost) {
422
384
  return {
package/src/send.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { SsrFBlockedError, fetchWithSsrFGuard } from "openclaw/plugin-sdk";
3
- import type { CoreConfig } from "./types.js";
2
+ import { fetchWithSsrFGuard, SsrFBlockedError } from "openclaw/plugin-sdk/infra-runtime";
4
3
  import { resolveGroupMeAccount } from "./accounts.js";
5
4
  import { getGroupMeRuntime } from "./runtime.js";
6
5
  import { resolveGroupMeSecurity } from "./security.js";
6
+ import type { CoreConfig } from "./types.js";
7
7
 
8
- export const GROUPME_API_BASE = "https://api.groupme.com/v3";
9
- export const GROUPME_IMAGE_SERVICE = "https://image.groupme.com";
8
+ const GROUPME_API_BASE = "https://api.groupme.com/v3";
9
+ const GROUPME_IMAGE_SERVICE = "https://image.groupme.com";
10
10
  export const GROUPME_MAX_TEXT_LENGTH = 1000;
11
11
 
12
- export type SendGroupMeResult = {
12
+ type SendGroupMeResult = {
13
13
  messageId: string;
14
14
  timestamp: number;
15
15
  };
@@ -33,8 +33,10 @@ export async function sendGroupMeMessage(params: {
33
33
  text: string;
34
34
  pictureUrl?: string;
35
35
  fetchFn?: FetchLike;
36
+ apiBaseUrl?: string;
36
37
  }): Promise<SendGroupMeResult> {
37
38
  const fetchFn = params.fetchFn ?? fetch;
39
+ const apiBaseUrl = params.apiBaseUrl ?? GROUPME_API_BASE;
38
40
  const payload: { bot_id: string; text: string; picture_url?: string } = {
39
41
  bot_id: params.botId,
40
42
  text: params.text,
@@ -42,7 +44,7 @@ export async function sendGroupMeMessage(params: {
42
44
  if (params.pictureUrl) {
43
45
  payload.picture_url = params.pictureUrl;
44
46
  }
45
- const response = await fetchFn(`${GROUPME_API_BASE}/bots/post`, {
47
+ const response = await fetchFn(`${apiBaseUrl}/bots/post`, {
46
48
  method: "POST",
47
49
  headers: {
48
50
  "Content-Type": "application/json",
@@ -51,9 +53,7 @@ export async function sendGroupMeMessage(params: {
51
53
  });
52
54
 
53
55
  if (!response.ok) {
54
- throw new Error(
55
- `GroupMe API error: ${response.status} ${response.statusText}`,
56
- );
56
+ throw new Error(`GroupMe API error: ${response.status} ${response.statusText}`);
57
57
  }
58
58
 
59
59
  return {
@@ -63,8 +63,7 @@ export async function sendGroupMeMessage(params: {
63
63
  }
64
64
 
65
65
  function extractPictureUrl(value: unknown): string | null {
66
- const url = (value as { payload?: { picture_url?: unknown } })?.payload
67
- ?.picture_url;
66
+ const url = (value as { payload?: { picture_url?: unknown } })?.payload?.picture_url;
68
67
  if (typeof url !== "string") return null;
69
68
  return url.trim() || null;
70
69
  }
@@ -74,9 +73,11 @@ export async function uploadGroupMeImage(params: {
74
73
  imageData: Buffer;
75
74
  contentType?: string;
76
75
  fetchFn?: FetchLike;
76
+ imageBaseUrl?: string;
77
77
  }): Promise<string> {
78
78
  const fetchFn = params.fetchFn ?? fetch;
79
- const response = await fetchFn(`${GROUPME_IMAGE_SERVICE}/pictures`, {
79
+ const imageBaseUrl = params.imageBaseUrl ?? GROUPME_IMAGE_SERVICE;
80
+ const response = await fetchFn(`${imageBaseUrl}/pictures`, {
80
81
  method: "POST",
81
82
  headers: {
82
83
  "X-Access-Token": params.accessToken,
@@ -106,10 +107,7 @@ async function downloadRemoteMedia(params: {
106
107
  allowedMimePrefixes: string[];
107
108
  fetchFn?: FetchLike;
108
109
  }): Promise<{ data: Buffer; contentType: string }> {
109
- const timedFetch = wrapFetchWithTimeout(
110
- params.fetchFn,
111
- params.requestTimeoutMs,
112
- );
110
+ const timedFetch = wrapFetchWithTimeout(params.fetchFn, params.requestTimeoutMs);
113
111
 
114
112
  try {
115
113
  const runtimeFetcher = getGroupMeRuntime().channel.media
@@ -152,16 +150,11 @@ async function downloadRemoteMedia(params: {
152
150
  try {
153
151
  const response = guarded.response;
154
152
  if (!response.ok) {
155
- throw new Error(
156
- `GroupMe media download failed: ${response.status} ${response.statusText}`,
157
- );
153
+ throw new Error(`GroupMe media download failed: ${response.status} ${response.statusText}`);
158
154
  }
159
155
 
160
156
  const contentLength = Number(response.headers.get("content-length"));
161
- if (
162
- Number.isFinite(contentLength) &&
163
- contentLength > params.maxDownloadBytes
164
- ) {
157
+ if (Number.isFinite(contentLength) && contentLength > params.maxDownloadBytes) {
165
158
  throw new Error(
166
159
  `GroupMe media download exceeds maxDownloadBytes (${contentLength} > ${params.maxDownloadBytes})`,
167
160
  );
@@ -172,10 +165,7 @@ async function downloadRemoteMedia(params: {
172
165
  allowedMimePrefixes: params.allowedMimePrefixes,
173
166
  });
174
167
 
175
- const data = await readResponseBodyWithLimit(
176
- response,
177
- params.maxDownloadBytes,
178
- );
168
+ const data = await readResponseBodyWithLimit(response, params.maxDownloadBytes);
179
169
  return { data, contentType };
180
170
  } finally {
181
171
  await guarded.release();
@@ -188,10 +178,7 @@ async function downloadRemoteMedia(params: {
188
178
  }
189
179
  }
190
180
 
191
- function wrapFetchWithTimeout(
192
- fetchFn: FetchLike | undefined,
193
- timeoutMs: number,
194
- ): FetchLike {
181
+ function wrapFetchWithTimeout(fetchFn: FetchLike | undefined, timeoutMs: number): FetchLike {
195
182
  const base = fetchFn ?? fetch;
196
183
  return async (input: RequestInfo | URL, init?: RequestInit) => {
197
184
  const controller = new AbortController();
@@ -199,6 +186,9 @@ function wrapFetchWithTimeout(
199
186
  controller.abort("GroupMe media fetch timed out");
200
187
  }, timeoutMs);
201
188
 
189
+ // Upstream init.signal bridging: our call sites never pass one, so the abort
190
+ // wiring below is kept for callers that do but is not exercised by tests.
191
+ /* v8 ignore start */
202
192
  const upstreamSignal = init?.signal;
203
193
  const onAbort = () => controller.abort(upstreamSignal?.reason);
204
194
  if (upstreamSignal) {
@@ -208,6 +198,7 @@ function wrapFetchWithTimeout(
208
198
  upstreamSignal.addEventListener("abort", onAbort, { once: true });
209
199
  }
210
200
  }
201
+ /* v8 ignore stop */
211
202
 
212
203
  try {
213
204
  return await base(input, {
@@ -216,9 +207,11 @@ function wrapFetchWithTimeout(
216
207
  });
217
208
  } finally {
218
209
  clearTimeout(timeout);
210
+ /* v8 ignore start */
219
211
  if (upstreamSignal) {
220
212
  upstreamSignal.removeEventListener("abort", onAbort);
221
213
  }
214
+ /* v8 ignore stop */
222
215
  }
223
216
  };
224
217
  }
@@ -227,15 +220,10 @@ function enforceMimePolicy(params: {
227
220
  contentType: string | undefined;
228
221
  allowedMimePrefixes: string[];
229
222
  }): string {
230
- const contentType = (params.contentType ?? "")
231
- .split(";")[0]
232
- ?.trim()
233
- .toLowerCase();
223
+ const contentType = (params.contentType ?? "").split(";")[0]?.trim().toLowerCase();
234
224
  if (
235
225
  !contentType ||
236
- !params.allowedMimePrefixes.some((prefix) =>
237
- contentType.startsWith(prefix.toLowerCase()),
238
- )
226
+ !params.allowedMimePrefixes.some((prefix) => contentType.startsWith(prefix.toLowerCase()))
239
227
  ) {
240
228
  throw new Error(
241
229
  `GroupMe media download blocked by MIME policy (${contentType || "missing content-type"})`,
@@ -316,6 +304,7 @@ export async function sendGroupMeText(params: {
316
304
  text: string;
317
305
  accountId?: string | null;
318
306
  fetchFn?: FetchLike;
307
+ apiBaseUrl?: string;
319
308
  }): Promise<SendGroupMeResult> {
320
309
  const account = resolveGroupMeAccount({
321
310
  cfg: params.cfg,
@@ -329,6 +318,7 @@ export async function sendGroupMeText(params: {
329
318
  botId: account.botId,
330
319
  text: params.text,
331
320
  fetchFn: params.fetchFn,
321
+ apiBaseUrl: params.apiBaseUrl,
332
322
  });
333
323
  }
334
324
 
@@ -339,6 +329,8 @@ export async function sendGroupMeMedia(params: {
339
329
  mediaUrl: string;
340
330
  accountId?: string | null;
341
331
  fetchFn?: FetchLike;
332
+ apiBaseUrl?: string;
333
+ imageBaseUrl?: string;
342
334
  }): Promise<SendGroupMeResult> {
343
335
  const account = resolveGroupMeAccount({
344
336
  cfg: params.cfg,
@@ -369,6 +361,7 @@ export async function sendGroupMeMedia(params: {
369
361
  imageData: data,
370
362
  contentType,
371
363
  fetchFn: params.fetchFn,
364
+ imageBaseUrl: params.imageBaseUrl,
372
365
  });
373
366
 
374
367
  return sendGroupMeMessage({
@@ -376,5 +369,6 @@ export async function sendGroupMeMedia(params: {
376
369
  text: params.text,
377
370
  pictureUrl,
378
371
  fetchFn: params.fetchFn,
372
+ apiBaseUrl: params.apiBaseUrl,
379
373
  });
380
374
  }