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/LICENSE +21 -0
- package/README.md +348 -70
- package/package.json +36 -11
- package/src/accounts.ts +51 -23
- package/src/channel.ts +154 -16
- package/src/config-schema.ts +73 -3
- package/src/groupme-api.ts +98 -0
- package/src/history.ts +54 -0
- package/src/inbound.ts +128 -23
- package/src/monitor.ts +275 -33
- package/src/normalize.ts +1 -9
- package/src/onboarding.ts +136 -36
- package/src/parse.ts +32 -33
- package/src/policy.ts +5 -2
- package/src/rate-limit.ts +128 -0
- package/src/replay-cache.ts +71 -0
- package/src/security.ts +460 -0
- package/src/send.ts +237 -51
- package/src/types.ts +98 -1
- package/.github/workflows/publish-npm.yml +0 -30
- package/openclaw.plugin.json +0 -9
- package/src/monitor.test.ts +0 -186
- package/src/normalize.test.ts +0 -43
- package/src/parse.test.ts +0 -162
- package/src/policy.test.ts +0 -23
- package/src/send.test.ts +0 -153
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 =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
31
|
+
export async function sendGroupMeMessage(params: {
|
|
23
32
|
botId: string;
|
|
24
33
|
text: string;
|
|
25
34
|
pictureUrl?: string;
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/openclaw.plugin.json
DELETED
package/src/monitor.test.ts
DELETED
|
@@ -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
|
-
});
|