openclaw-groupme 0.0.3 → 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/send.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { SsrFBlockedError, fetchWithSsrFGuard } from "openclaw/plugin-sdk";
2
3
  import type { CoreConfig } from "./types.js";
3
4
  import { resolveGroupMeAccount } from "./accounts.js";
5
+ import { getGroupMeRuntime } from "./runtime.js";
6
+ import { resolveGroupMeSecurity } from "./security.js";
4
7
 
5
8
  export const GROUPME_API_BASE = "https://api.groupme.com/v3";
6
9
  export const GROUPME_IMAGE_SERVICE = "https://image.groupme.com";
@@ -11,52 +14,46 @@ export type SendGroupMeResult = {
11
14
  timestamp: number;
12
15
  };
13
16
 
14
- type FetchLike = typeof fetch;
15
-
16
- type GroupMeBotPostPayload = {
17
- bot_id: string;
18
- text: string;
19
- picture_url?: string;
20
- };
17
+ type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
18
+ type RuntimeFetchRemoteMedia = (params: {
19
+ url: string;
20
+ fetchImpl?: FetchLike;
21
+ maxBytes?: number;
22
+ maxRedirects?: number;
23
+ ssrfPolicy?: {
24
+ allowPrivateNetwork?: boolean;
25
+ };
26
+ }) => Promise<{
27
+ buffer: Buffer;
28
+ contentType?: string;
29
+ }>;
21
30
 
22
- function buildGroupMeBotPostPayload(params: {
31
+ export async function sendGroupMeMessage(params: {
23
32
  botId: string;
24
33
  text: string;
25
34
  pictureUrl?: string;
26
- }): GroupMeBotPostPayload {
27
- const payload: GroupMeBotPostPayload = {
35
+ fetchFn?: FetchLike;
36
+ }): Promise<SendGroupMeResult> {
37
+ const fetchFn = params.fetchFn ?? fetch;
38
+ const payload: { bot_id: string; text: string; picture_url?: string } = {
28
39
  bot_id: params.botId,
29
40
  text: params.text,
30
41
  };
31
42
  if (params.pictureUrl) {
32
43
  payload.picture_url = params.pictureUrl;
33
44
  }
34
- return payload;
35
- }
36
-
37
- export async function sendGroupMeMessage(params: {
38
- botId: string;
39
- text: string;
40
- pictureUrl?: string;
41
- fetchFn?: FetchLike;
42
- }): Promise<SendGroupMeResult> {
43
- const fetchFn = params.fetchFn ?? fetch;
44
45
  const response = await fetchFn(`${GROUPME_API_BASE}/bots/post`, {
45
46
  method: "POST",
46
47
  headers: {
47
48
  "Content-Type": "application/json",
48
49
  },
49
- body: JSON.stringify(
50
- buildGroupMeBotPostPayload({
51
- botId: params.botId,
52
- text: params.text,
53
- pictureUrl: params.pictureUrl,
54
- }),
55
- ),
50
+ body: JSON.stringify(payload),
56
51
  });
57
52
 
58
53
  if (!response.ok) {
59
- throw new Error(`GroupMe API error: ${response.status} ${response.statusText}`);
54
+ throw new Error(
55
+ `GroupMe API error: ${response.status} ${response.statusText}`,
56
+ );
60
57
  }
61
58
 
62
59
  return {
@@ -66,22 +63,10 @@ export async function sendGroupMeMessage(params: {
66
63
  }
67
64
 
68
65
  function extractPictureUrl(value: unknown): string | null {
69
- if (!value || typeof value !== "object" || Array.isArray(value)) {
70
- return null;
71
- }
72
-
73
- const payload = (value as { payload?: unknown }).payload;
74
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
75
- return null;
76
- }
77
-
78
- const pictureUrl = (payload as { picture_url?: unknown }).picture_url;
79
- if (typeof pictureUrl !== "string") {
80
- return null;
81
- }
82
-
83
- const trimmed = pictureUrl.trim();
84
- return trimmed || null;
66
+ const url = (value as { payload?: { picture_url?: unknown } })?.payload
67
+ ?.picture_url;
68
+ if (typeof url !== "string") return null;
69
+ return url.trim() || null;
85
70
  }
86
71
 
87
72
  export async function uploadGroupMeImage(params: {
@@ -115,18 +100,214 @@ export async function uploadGroupMeImage(params: {
115
100
 
116
101
  async function downloadRemoteMedia(params: {
117
102
  mediaUrl: string;
103
+ allowPrivateNetworks: boolean;
104
+ maxDownloadBytes: number;
105
+ requestTimeoutMs: number;
106
+ allowedMimePrefixes: string[];
118
107
  fetchFn?: FetchLike;
119
108
  }): Promise<{ data: Buffer; contentType: string }> {
120
- const fetchFn = params.fetchFn ?? fetch;
121
- const response = await fetchFn(params.mediaUrl);
122
- if (!response.ok) {
123
- throw new Error(`GroupMe media download failed: ${response.status} ${response.statusText}`);
109
+ const timedFetch = wrapFetchWithTimeout(
110
+ params.fetchFn,
111
+ params.requestTimeoutMs,
112
+ );
113
+
114
+ try {
115
+ const runtimeFetcher = getGroupMeRuntime().channel.media
116
+ .fetchRemoteMedia as RuntimeFetchRemoteMedia;
117
+ const fetched = await runtimeFetcher({
118
+ url: params.mediaUrl,
119
+ fetchImpl: timedFetch,
120
+ maxBytes: params.maxDownloadBytes,
121
+ maxRedirects: 3,
122
+ ssrfPolicy: {
123
+ allowPrivateNetwork: params.allowPrivateNetworks,
124
+ },
125
+ });
126
+
127
+ const contentType = enforceMimePolicy({
128
+ contentType: fetched.contentType,
129
+ allowedMimePrefixes: params.allowedMimePrefixes,
130
+ });
131
+ return { data: fetched.buffer, contentType };
132
+ } catch (error) {
133
+ if (!isRuntimeNotInitializedError(error)) {
134
+ if (isSsrfRelatedError(error)) {
135
+ throw new Error(`GroupMe media download blocked by SSRF policy`);
136
+ }
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ try {
142
+ const guarded = await fetchWithSsrFGuard({
143
+ url: params.mediaUrl,
144
+ fetchImpl: timedFetch,
145
+ maxRedirects: 3,
146
+ policy: {
147
+ allowPrivateNetwork: params.allowPrivateNetworks,
148
+ },
149
+ auditContext: "groupme-outbound-media",
150
+ });
151
+
152
+ try {
153
+ const response = guarded.response;
154
+ if (!response.ok) {
155
+ throw new Error(
156
+ `GroupMe media download failed: ${response.status} ${response.statusText}`,
157
+ );
158
+ }
159
+
160
+ const contentLength = Number(response.headers.get("content-length"));
161
+ if (
162
+ Number.isFinite(contentLength) &&
163
+ contentLength > params.maxDownloadBytes
164
+ ) {
165
+ throw new Error(
166
+ `GroupMe media download exceeds maxDownloadBytes (${contentLength} > ${params.maxDownloadBytes})`,
167
+ );
168
+ }
169
+
170
+ const contentType = enforceMimePolicy({
171
+ contentType: response.headers.get("content-type") ?? "",
172
+ allowedMimePrefixes: params.allowedMimePrefixes,
173
+ });
174
+
175
+ const data = await readResponseBodyWithLimit(
176
+ response,
177
+ params.maxDownloadBytes,
178
+ );
179
+ return { data, contentType };
180
+ } finally {
181
+ await guarded.release();
182
+ }
183
+ } catch (error) {
184
+ if (error instanceof SsrFBlockedError) {
185
+ throw new Error(`GroupMe media download blocked by SSRF policy`);
186
+ }
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ function wrapFetchWithTimeout(
192
+ fetchFn: FetchLike | undefined,
193
+ timeoutMs: number,
194
+ ): FetchLike {
195
+ const base = fetchFn ?? fetch;
196
+ return async (input: RequestInfo | URL, init?: RequestInit) => {
197
+ const controller = new AbortController();
198
+ const timeout = setTimeout(() => {
199
+ controller.abort("GroupMe media fetch timed out");
200
+ }, timeoutMs);
201
+
202
+ const upstreamSignal = init?.signal;
203
+ const onAbort = () => controller.abort(upstreamSignal?.reason);
204
+ if (upstreamSignal) {
205
+ if (upstreamSignal.aborted) {
206
+ onAbort();
207
+ } else {
208
+ upstreamSignal.addEventListener("abort", onAbort, { once: true });
209
+ }
210
+ }
211
+
212
+ try {
213
+ return await base(input, {
214
+ ...init,
215
+ signal: controller.signal,
216
+ });
217
+ } finally {
218
+ clearTimeout(timeout);
219
+ if (upstreamSignal) {
220
+ upstreamSignal.removeEventListener("abort", onAbort);
221
+ }
222
+ }
223
+ };
224
+ }
225
+
226
+ function enforceMimePolicy(params: {
227
+ contentType: string | undefined;
228
+ allowedMimePrefixes: string[];
229
+ }): string {
230
+ const contentType = (params.contentType ?? "")
231
+ .split(";")[0]
232
+ ?.trim()
233
+ .toLowerCase();
234
+ if (
235
+ !contentType ||
236
+ !params.allowedMimePrefixes.some((prefix) =>
237
+ contentType.startsWith(prefix.toLowerCase()),
238
+ )
239
+ ) {
240
+ throw new Error(
241
+ `GroupMe media download blocked by MIME policy (${contentType || "missing content-type"})`,
242
+ );
243
+ }
244
+ return contentType;
245
+ }
246
+
247
+ function isRuntimeNotInitializedError(error: unknown): boolean {
248
+ if (!(error instanceof Error)) {
249
+ return false;
124
250
  }
251
+ return /runtime not initialized/i.test(error.message);
252
+ }
125
253
 
126
- const contentType = response.headers.get("content-type") || "image/jpeg";
127
- const data = Buffer.from(await response.arrayBuffer());
254
+ function isSsrfRelatedError(error: unknown): boolean {
255
+ if (error instanceof SsrFBlockedError) {
256
+ return true;
257
+ }
258
+ if (!(error instanceof Error)) {
259
+ return false;
260
+ }
261
+ return /ssrf/i.test(error.message);
262
+ }
128
263
 
129
- return { data, contentType };
264
+ async function readResponseBodyWithLimit(
265
+ response: Response,
266
+ maxDownloadBytes: number,
267
+ ): Promise<Buffer> {
268
+ const reader = response.body?.getReader();
269
+ if (!reader) {
270
+ const fallback = Buffer.from(await response.arrayBuffer());
271
+ if (fallback.length > maxDownloadBytes) {
272
+ throw new Error(
273
+ `GroupMe media download exceeds maxDownloadBytes (${fallback.length} > ${maxDownloadBytes})`,
274
+ );
275
+ }
276
+ return fallback;
277
+ }
278
+
279
+ const chunks: Uint8Array[] = [];
280
+ let totalBytes = 0;
281
+ let exceededLimit = false;
282
+ try {
283
+ while (true) {
284
+ const next = await reader.read();
285
+ if (next.done) {
286
+ break;
287
+ }
288
+ const chunk = next.value;
289
+ if (!chunk || chunk.length === 0) {
290
+ continue;
291
+ }
292
+ totalBytes += chunk.length;
293
+ if (totalBytes > maxDownloadBytes) {
294
+ exceededLimit = true;
295
+ throw new Error(
296
+ `GroupMe media download exceeds maxDownloadBytes (${totalBytes} > ${maxDownloadBytes})`,
297
+ );
298
+ }
299
+ chunks.push(chunk);
300
+ }
301
+ } finally {
302
+ if (exceededLimit) {
303
+ try {
304
+ await reader.cancel();
305
+ } catch {
306
+ // Ignore cancellation errors; preserve original failure reason.
307
+ }
308
+ }
309
+ }
310
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
130
311
  }
131
312
 
132
313
  export async function sendGroupMeText(params: {
@@ -173,8 +354,13 @@ export async function sendGroupMeMedia(params: {
173
354
  );
174
355
  }
175
356
 
357
+ const security = resolveGroupMeSecurity(account.config);
176
358
  const { data, contentType } = await downloadRemoteMedia({
177
359
  mediaUrl: params.mediaUrl,
360
+ allowPrivateNetworks: security.media.allowPrivateNetworks,
361
+ maxDownloadBytes: security.media.maxDownloadBytes,
362
+ requestTimeoutMs: security.media.requestTimeoutMs,
363
+ allowedMimePrefixes: security.media.allowedMimePrefixes,
178
364
  fetchFn: params.fetchFn,
179
365
  });
180
366
 
package/src/types.ts CHANGED
@@ -6,15 +6,63 @@ import type {
6
6
 
7
7
  export type GroupMeAllowFromEntry = string | number;
8
8
 
9
+ export type GroupMeReplayConfig = {
10
+ ttlSeconds?: number;
11
+ maxEntries?: number;
12
+ };
13
+
14
+ export type GroupMeRateLimitConfig = {
15
+ windowMs?: number;
16
+ maxRequestsPerIp?: number;
17
+ maxRequestsPerSender?: number;
18
+ maxConcurrent?: number;
19
+ };
20
+
21
+ export type GroupMeMediaSecurityConfig = {
22
+ allowPrivateNetworks?: boolean;
23
+ maxDownloadBytes?: number;
24
+ requestTimeoutMs?: number;
25
+ allowedMimePrefixes?: string[];
26
+ };
27
+
28
+ export type GroupMeLoggingSecurityConfig = {
29
+ redactSecrets?: boolean;
30
+ logRejectedRequests?: boolean;
31
+ };
32
+
33
+ export type GroupMeCommandBypassSecurityConfig = {
34
+ requireAllowFrom?: boolean;
35
+ requireMentionForCommands?: boolean;
36
+ };
37
+
38
+ export type GroupMeProxySecurityConfig = {
39
+ trustedProxyCidrs?: string[];
40
+ allowedPublicHosts?: string[];
41
+ requireHttpsProto?: boolean;
42
+ rejectStatus?: 400 | 403 | 404;
43
+ };
44
+
45
+ export type GroupMeSecurityConfig = {
46
+ replay?: GroupMeReplayConfig;
47
+ rateLimit?: GroupMeRateLimitConfig;
48
+ media?: GroupMeMediaSecurityConfig;
49
+ logging?: GroupMeLoggingSecurityConfig;
50
+ commandBypass?: GroupMeCommandBypassSecurityConfig;
51
+ proxy?: GroupMeProxySecurityConfig;
52
+ };
53
+
9
54
  export type GroupMeAccountConfig = {
10
55
  name?: string;
11
56
  enabled?: boolean;
12
57
  botId?: string;
13
58
  accessToken?: string;
14
59
  botName?: string;
15
- callbackPath?: string;
60
+ groupId?: string;
61
+ publicDomain?: string;
62
+ callbackUrl?: string;
16
63
  mentionPatterns?: string[];
17
64
  requireMention?: boolean;
65
+ historyLimit?: number;
18
66
  allowFrom?: GroupMeAllowFromEntry[];
19
67
  markdown?: MarkdownConfig;
20
68
  textChunkLimit?: number;
@@ -22,6 +70,7 @@ export type GroupMeAccountConfig = {
22
70
  blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
23
71
  responsePrefix?: string;
24
72
  mediaMaxMb?: number;
73
+ security?: GroupMeSecurityConfig;
25
74
  };
26
75
 
27
76
  export type GroupMeConfig = GroupMeAccountConfig & {
@@ -101,3 +150,51 @@ export type GroupMeProbe = {
101
150
  botId?: string;
102
151
  error?: string;
103
152
  };
153
+
154
+ export type CallbackAuthResult =
155
+ | { ok: true; tokenId: "active" }
156
+ | {
157
+ ok: false;
158
+ reason: "missing" | "mismatch" | "disabled";
159
+ };
160
+
161
+ export type GroupMeApiGroup = {
162
+ id: string;
163
+ name: string;
164
+ description: string;
165
+ image_url: string | null;
166
+ creator_user_id: string;
167
+ created_at: number;
168
+ updated_at: number;
169
+ messages: {
170
+ count: number;
171
+ last_message_created_at: number;
172
+ preview: {
173
+ nickname: string;
174
+ text: string;
175
+ };
176
+ };
177
+ };
178
+
179
+ export type GroupMeApiBot = {
180
+ bot_id: string;
181
+ group_id: string;
182
+ name: string;
183
+ avatar_url: string | null;
184
+ callback_url: string;
185
+ dm_notification: boolean;
186
+ active: boolean;
187
+ };
188
+
189
+ export type ReplayCheck =
190
+ | { kind: "accepted"; key: string }
191
+ | { kind: "duplicate"; key: string };
192
+
193
+ export type WebhookDecision =
194
+ | { kind: "accept"; message: GroupMeCallbackData; release: () => void }
195
+ | {
196
+ kind: "reject";
197
+ status: number;
198
+ reason: string;
199
+ logLevel: "debug" | "warn";
200
+ };
@@ -1,30 +0,0 @@
1
- name: Publish to npm
2
-
3
- on:
4
- release:
5
- types: [published]
6
- workflow_dispatch:
7
-
8
- permissions:
9
- contents: read
10
- id-token: write
11
-
12
- jobs:
13
- publish:
14
- runs-on: ubuntu-latest
15
-
16
- steps:
17
- - name: Checkout
18
- uses: actions/checkout@v4
19
-
20
- - name: Setup Node.js
21
- uses: actions/setup-node@v4
22
- with:
23
- node-version: 24
24
- registry-url: https://registry.npmjs.org
25
-
26
- - name: Show package version
27
- run: npm pkg get name version
28
-
29
- - name: Publish package
30
- run: npm publish --provenance --access public
@@ -1,9 +0,0 @@
1
- {
2
- "id": "groupme",
3
- "channels": ["groupme"],
4
- "configSchema": {
5
- "type": "object",
6
- "additionalProperties": false,
7
- "properties": {}
8
- }
9
- }
@@ -1,186 +0,0 @@
1
- import type { AddressInfo } from "node:net";
2
- import type { RuntimeEnv } from "openclaw/plugin-sdk";
3
- import { createServer } from "node:http";
4
- import { describe, expect, it, vi } from "vitest";
5
- import type { CoreConfig, ResolvedGroupMeAccount } from "./types.js";
6
-
7
- const handleGroupMeInboundMock = vi.hoisted(() => vi.fn(async () => undefined));
8
-
9
- vi.mock("./inbound.js", () => ({
10
- handleGroupMeInbound: handleGroupMeInboundMock,
11
- }));
12
-
13
- import { createGroupMeWebhookHandler } from "./monitor.js";
14
-
15
- async function withServer(
16
- handler: Parameters<typeof createServer>[0],
17
- fn: (baseUrl: string) => Promise<void>,
18
- ) {
19
- const server = createServer(handler);
20
- await new Promise<void>((resolve, reject) => {
21
- const onError = (error: Error) => {
22
- server.off("listening", onListening);
23
- reject(error);
24
- };
25
- const onListening = () => {
26
- server.off("error", onError);
27
- resolve();
28
- };
29
- server.once("error", onError);
30
- server.once("listening", onListening);
31
- server.listen(0, "127.0.0.1");
32
- });
33
-
34
- const address = server.address() as AddressInfo | null;
35
- if (!address) {
36
- throw new Error("missing server address");
37
- }
38
-
39
- try {
40
- await fn(`http://127.0.0.1:${address.port}`);
41
- } finally {
42
- await new Promise<void>((resolve) => server.close(() => resolve()));
43
- }
44
- }
45
-
46
- function isListenPermissionError(error: unknown): boolean {
47
- if (!error || typeof error !== "object") {
48
- return false;
49
- }
50
- const maybeErr = error as { code?: unknown; syscall?: unknown };
51
- return maybeErr.code === "EPERM" && maybeErr.syscall === "listen";
52
- }
53
-
54
- async function runIfServerAllowed(fn: () => Promise<void>): Promise<void> {
55
- try {
56
- await fn();
57
- } catch (error) {
58
- if (isListenPermissionError(error)) {
59
- return;
60
- }
61
- throw error;
62
- }
63
- }
64
-
65
- function buildRuntime(): RuntimeEnv {
66
- return {
67
- log: vi.fn(),
68
- error: vi.fn(),
69
- exit: (() => {
70
- throw new Error("exit");
71
- }) as RuntimeEnv["exit"],
72
- };
73
- }
74
-
75
- const account: ResolvedGroupMeAccount = {
76
- accountId: "default",
77
- enabled: true,
78
- configured: true,
79
- botId: "bot-1",
80
- accessToken: "token-1",
81
- config: {
82
- botId: "bot-1",
83
- accessToken: "token-1",
84
- },
85
- };
86
-
87
- const config = {} as CoreConfig;
88
-
89
- describe("createGroupMeWebhookHandler", () => {
90
- it("returns 405 for non-POST", async () => {
91
- const runtime = buildRuntime();
92
- const handler = createGroupMeWebhookHandler({ account, config, runtime });
93
-
94
- await runIfServerAllowed(async () => {
95
- await withServer(
96
- async (req, res) => handler(req, res),
97
- async (baseUrl) => {
98
- const response = await fetch(`${baseUrl}/groupme`, { method: "GET" });
99
- expect(response.status).toBe(405);
100
- expect(await response.text()).toBe("Method Not Allowed");
101
- },
102
- );
103
- });
104
- });
105
-
106
- it("returns 400 for invalid JSON", async () => {
107
- const runtime = buildRuntime();
108
- const handler = createGroupMeWebhookHandler({ account, config, runtime });
109
-
110
- await runIfServerAllowed(async () => {
111
- await withServer(
112
- async (req, res) => handler(req, res),
113
- async (baseUrl) => {
114
- const response = await fetch(`${baseUrl}/groupme`, {
115
- method: "POST",
116
- headers: { "content-type": "application/json" },
117
- body: "{",
118
- });
119
- expect(response.status).toBe(400);
120
- },
121
- );
122
- });
123
- });
124
-
125
- it("acknowledges parseable payload and dispatches inbound", async () => {
126
- handleGroupMeInboundMock.mockClear();
127
- const runtime = buildRuntime();
128
- const handler = createGroupMeWebhookHandler({ account, config, runtime });
129
-
130
- const payload = {
131
- id: "msg-1",
132
- text: "hello",
133
- name: "Alice",
134
- sender_type: "user",
135
- sender_id: "123",
136
- user_id: "123",
137
- group_id: "456",
138
- source_guid: "source",
139
- created_at: 1_700_000_000,
140
- system: false,
141
- attachments: [],
142
- };
143
-
144
- await runIfServerAllowed(async () => {
145
- await withServer(
146
- async (req, res) => handler(req, res),
147
- async (baseUrl) => {
148
- const response = await fetch(`${baseUrl}/groupme`, {
149
- method: "POST",
150
- headers: { "content-type": "application/json" },
151
- body: JSON.stringify(payload),
152
- });
153
- expect(response.status).toBe(200);
154
- expect(await response.text()).toBe("ok");
155
-
156
- // Wait for fire-and-forget processing.
157
- await new Promise((resolve) => setTimeout(resolve, 0));
158
- expect(handleGroupMeInboundMock).toHaveBeenCalledTimes(1);
159
- },
160
- );
161
- });
162
- });
163
-
164
- it("drops unparseable payload after returning 200", async () => {
165
- handleGroupMeInboundMock.mockClear();
166
- const runtime = buildRuntime();
167
- const handler = createGroupMeWebhookHandler({ account, config, runtime });
168
-
169
- await runIfServerAllowed(async () => {
170
- await withServer(
171
- async (req, res) => handler(req, res),
172
- async (baseUrl) => {
173
- const response = await fetch(`${baseUrl}/groupme`, {
174
- method: "POST",
175
- headers: { "content-type": "application/json" },
176
- body: JSON.stringify({ nope: true }),
177
- });
178
- expect(response.status).toBe(200);
179
- await new Promise((resolve) => setTimeout(resolve, 0));
180
- expect(handleGroupMeInboundMock).not.toHaveBeenCalled();
181
- expect(runtime.log).toHaveBeenCalledWith("groupme: unparseable callback payload");
182
- },
183
- );
184
- });
185
- });
186
- });