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 +14 -0
- package/dist/src/accounts.js +119 -0
- package/dist/src/channel.js +366 -0
- package/dist/src/config-schema.js +86 -0
- package/dist/src/groupme-api.js +80 -0
- package/dist/src/history.js +37 -0
- package/dist/src/inbound.js +308 -0
- package/dist/src/monitor.js +234 -0
- package/dist/src/normalize.js +30 -0
- package/dist/src/onboarding.js +422 -0
- package/dist/src/parse.js +217 -0
- package/dist/src/policy.js +18 -0
- package/dist/src/rate-limit.js +95 -0
- package/dist/src/replay-cache.js +55 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/security.js +332 -0
- package/dist/src/send.js +271 -0
- package/dist/src/types.js +1 -0
- package/package.json +5 -2
package/dist/src/send.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { SsrFBlockedError, fetchWithSsrFGuard } from "openclaw/plugin-sdk";
|
|
3
|
+
import { resolveGroupMeAccount } from "./accounts.js";
|
|
4
|
+
import { getGroupMeRuntime } from "./runtime.js";
|
|
5
|
+
import { resolveGroupMeSecurity } from "./security.js";
|
|
6
|
+
export const GROUPME_API_BASE = "https://api.groupme.com/v3";
|
|
7
|
+
export const GROUPME_IMAGE_SERVICE = "https://image.groupme.com";
|
|
8
|
+
export const GROUPME_MAX_TEXT_LENGTH = 1000;
|
|
9
|
+
export async function sendGroupMeMessage(params) {
|
|
10
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
11
|
+
const payload = {
|
|
12
|
+
bot_id: params.botId,
|
|
13
|
+
text: params.text,
|
|
14
|
+
};
|
|
15
|
+
if (params.pictureUrl) {
|
|
16
|
+
payload.picture_url = params.pictureUrl;
|
|
17
|
+
}
|
|
18
|
+
const response = await fetchFn(`${GROUPME_API_BASE}/bots/post`, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: {
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify(payload),
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
throw new Error(`GroupMe API error: ${response.status} ${response.statusText}`);
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
messageId: randomUUID(),
|
|
30
|
+
timestamp: Date.now(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function extractPictureUrl(value) {
|
|
34
|
+
const url = value?.payload
|
|
35
|
+
?.picture_url;
|
|
36
|
+
if (typeof url !== "string")
|
|
37
|
+
return null;
|
|
38
|
+
return url.trim() || null;
|
|
39
|
+
}
|
|
40
|
+
export async function uploadGroupMeImage(params) {
|
|
41
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
42
|
+
const response = await fetchFn(`${GROUPME_IMAGE_SERVICE}/pictures`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
"X-Access-Token": params.accessToken,
|
|
46
|
+
"Content-Type": params.contentType ?? "image/jpeg",
|
|
47
|
+
},
|
|
48
|
+
body: new Uint8Array(params.imageData),
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(`GroupMe image upload failed: ${response.status}`);
|
|
52
|
+
}
|
|
53
|
+
const json = (await response.json());
|
|
54
|
+
const pictureUrl = extractPictureUrl(json);
|
|
55
|
+
if (!pictureUrl) {
|
|
56
|
+
throw new Error("GroupMe image upload: no picture_url in response");
|
|
57
|
+
}
|
|
58
|
+
return pictureUrl;
|
|
59
|
+
}
|
|
60
|
+
async function downloadRemoteMedia(params) {
|
|
61
|
+
const timedFetch = wrapFetchWithTimeout(params.fetchFn, params.requestTimeoutMs);
|
|
62
|
+
try {
|
|
63
|
+
const runtimeFetcher = getGroupMeRuntime().channel.media
|
|
64
|
+
.fetchRemoteMedia;
|
|
65
|
+
const fetched = await runtimeFetcher({
|
|
66
|
+
url: params.mediaUrl,
|
|
67
|
+
fetchImpl: timedFetch,
|
|
68
|
+
maxBytes: params.maxDownloadBytes,
|
|
69
|
+
maxRedirects: 3,
|
|
70
|
+
ssrfPolicy: {
|
|
71
|
+
allowPrivateNetwork: params.allowPrivateNetworks,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const contentType = enforceMimePolicy({
|
|
75
|
+
contentType: fetched.contentType,
|
|
76
|
+
allowedMimePrefixes: params.allowedMimePrefixes,
|
|
77
|
+
});
|
|
78
|
+
return { data: fetched.buffer, contentType };
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (!isRuntimeNotInitializedError(error)) {
|
|
82
|
+
if (isSsrfRelatedError(error)) {
|
|
83
|
+
throw new Error(`GroupMe media download blocked by SSRF policy`);
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const guarded = await fetchWithSsrFGuard({
|
|
90
|
+
url: params.mediaUrl,
|
|
91
|
+
fetchImpl: timedFetch,
|
|
92
|
+
maxRedirects: 3,
|
|
93
|
+
policy: {
|
|
94
|
+
allowPrivateNetwork: params.allowPrivateNetworks,
|
|
95
|
+
},
|
|
96
|
+
auditContext: "groupme-outbound-media",
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
const response = guarded.response;
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
throw new Error(`GroupMe media download failed: ${response.status} ${response.statusText}`);
|
|
102
|
+
}
|
|
103
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
104
|
+
if (Number.isFinite(contentLength) &&
|
|
105
|
+
contentLength > params.maxDownloadBytes) {
|
|
106
|
+
throw new Error(`GroupMe media download exceeds maxDownloadBytes (${contentLength} > ${params.maxDownloadBytes})`);
|
|
107
|
+
}
|
|
108
|
+
const contentType = enforceMimePolicy({
|
|
109
|
+
contentType: response.headers.get("content-type") ?? "",
|
|
110
|
+
allowedMimePrefixes: params.allowedMimePrefixes,
|
|
111
|
+
});
|
|
112
|
+
const data = await readResponseBodyWithLimit(response, params.maxDownloadBytes);
|
|
113
|
+
return { data, contentType };
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await guarded.release();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error instanceof SsrFBlockedError) {
|
|
121
|
+
throw new Error(`GroupMe media download blocked by SSRF policy`);
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function wrapFetchWithTimeout(fetchFn, timeoutMs) {
|
|
127
|
+
const base = fetchFn ?? fetch;
|
|
128
|
+
return async (input, init) => {
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
const timeout = setTimeout(() => {
|
|
131
|
+
controller.abort("GroupMe media fetch timed out");
|
|
132
|
+
}, timeoutMs);
|
|
133
|
+
const upstreamSignal = init?.signal;
|
|
134
|
+
const onAbort = () => controller.abort(upstreamSignal?.reason);
|
|
135
|
+
if (upstreamSignal) {
|
|
136
|
+
if (upstreamSignal.aborted) {
|
|
137
|
+
onAbort();
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
upstreamSignal.addEventListener("abort", onAbort, { once: true });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
return await base(input, {
|
|
145
|
+
...init,
|
|
146
|
+
signal: controller.signal,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
clearTimeout(timeout);
|
|
151
|
+
if (upstreamSignal) {
|
|
152
|
+
upstreamSignal.removeEventListener("abort", onAbort);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function enforceMimePolicy(params) {
|
|
158
|
+
const contentType = (params.contentType ?? "")
|
|
159
|
+
.split(";")[0]
|
|
160
|
+
?.trim()
|
|
161
|
+
.toLowerCase();
|
|
162
|
+
if (!contentType ||
|
|
163
|
+
!params.allowedMimePrefixes.some((prefix) => contentType.startsWith(prefix.toLowerCase()))) {
|
|
164
|
+
throw new Error(`GroupMe media download blocked by MIME policy (${contentType || "missing content-type"})`);
|
|
165
|
+
}
|
|
166
|
+
return contentType;
|
|
167
|
+
}
|
|
168
|
+
function isRuntimeNotInitializedError(error) {
|
|
169
|
+
if (!(error instanceof Error)) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return /runtime not initialized/i.test(error.message);
|
|
173
|
+
}
|
|
174
|
+
function isSsrfRelatedError(error) {
|
|
175
|
+
if (error instanceof SsrFBlockedError) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
if (!(error instanceof Error)) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
return /ssrf/i.test(error.message);
|
|
182
|
+
}
|
|
183
|
+
async function readResponseBodyWithLimit(response, maxDownloadBytes) {
|
|
184
|
+
const reader = response.body?.getReader();
|
|
185
|
+
if (!reader) {
|
|
186
|
+
const fallback = Buffer.from(await response.arrayBuffer());
|
|
187
|
+
if (fallback.length > maxDownloadBytes) {
|
|
188
|
+
throw new Error(`GroupMe media download exceeds maxDownloadBytes (${fallback.length} > ${maxDownloadBytes})`);
|
|
189
|
+
}
|
|
190
|
+
return fallback;
|
|
191
|
+
}
|
|
192
|
+
const chunks = [];
|
|
193
|
+
let totalBytes = 0;
|
|
194
|
+
let exceededLimit = false;
|
|
195
|
+
try {
|
|
196
|
+
while (true) {
|
|
197
|
+
const next = await reader.read();
|
|
198
|
+
if (next.done) {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
const chunk = next.value;
|
|
202
|
+
if (!chunk || chunk.length === 0) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
totalBytes += chunk.length;
|
|
206
|
+
if (totalBytes > maxDownloadBytes) {
|
|
207
|
+
exceededLimit = true;
|
|
208
|
+
throw new Error(`GroupMe media download exceeds maxDownloadBytes (${totalBytes} > ${maxDownloadBytes})`);
|
|
209
|
+
}
|
|
210
|
+
chunks.push(chunk);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
if (exceededLimit) {
|
|
215
|
+
try {
|
|
216
|
+
await reader.cancel();
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Ignore cancellation errors; preserve original failure reason.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
|
224
|
+
}
|
|
225
|
+
export async function sendGroupMeText(params) {
|
|
226
|
+
const account = resolveGroupMeAccount({
|
|
227
|
+
cfg: params.cfg,
|
|
228
|
+
accountId: params.accountId,
|
|
229
|
+
});
|
|
230
|
+
if (!account.botId) {
|
|
231
|
+
throw new Error(`GroupMe account "${account.accountId}" is missing botId`);
|
|
232
|
+
}
|
|
233
|
+
return sendGroupMeMessage({
|
|
234
|
+
botId: account.botId,
|
|
235
|
+
text: params.text,
|
|
236
|
+
fetchFn: params.fetchFn,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
export async function sendGroupMeMedia(params) {
|
|
240
|
+
const account = resolveGroupMeAccount({
|
|
241
|
+
cfg: params.cfg,
|
|
242
|
+
accountId: params.accountId,
|
|
243
|
+
});
|
|
244
|
+
if (!account.botId) {
|
|
245
|
+
throw new Error(`GroupMe account "${account.accountId}" is missing botId`);
|
|
246
|
+
}
|
|
247
|
+
if (!account.accessToken) {
|
|
248
|
+
throw new Error(`GroupMe account "${account.accountId}" is missing accessToken required for image uploads`);
|
|
249
|
+
}
|
|
250
|
+
const security = resolveGroupMeSecurity(account.config);
|
|
251
|
+
const { data, contentType } = await downloadRemoteMedia({
|
|
252
|
+
mediaUrl: params.mediaUrl,
|
|
253
|
+
allowPrivateNetworks: security.media.allowPrivateNetworks,
|
|
254
|
+
maxDownloadBytes: security.media.maxDownloadBytes,
|
|
255
|
+
requestTimeoutMs: security.media.requestTimeoutMs,
|
|
256
|
+
allowedMimePrefixes: security.media.allowedMimePrefixes,
|
|
257
|
+
fetchFn: params.fetchFn,
|
|
258
|
+
});
|
|
259
|
+
const pictureUrl = await uploadGroupMeImage({
|
|
260
|
+
accessToken: account.accessToken,
|
|
261
|
+
imageData: data,
|
|
262
|
+
contentType,
|
|
263
|
+
fetchFn: params.fetchFn,
|
|
264
|
+
});
|
|
265
|
+
return sendGroupMeMessage({
|
|
266
|
+
botId: account.botId,
|
|
267
|
+
text: params.text,
|
|
268
|
+
pictureUrl,
|
|
269
|
+
fetchFn: params.fetchFn,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-groupme",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "OpenClaw GroupMe channel plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -24,12 +24,15 @@
|
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"openclaw.plugin.json",
|
|
27
|
+
"dist/**/*.js",
|
|
27
28
|
"index.ts",
|
|
28
29
|
"src/**/*.ts",
|
|
29
30
|
"LICENSE",
|
|
30
31
|
"README.md"
|
|
31
32
|
],
|
|
32
33
|
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.build.json",
|
|
35
|
+
"prepack": "npm run build",
|
|
33
36
|
"test": "vitest run",
|
|
34
37
|
"typecheck": "tsc --noEmit"
|
|
35
38
|
},
|
|
@@ -52,7 +55,7 @@
|
|
|
52
55
|
},
|
|
53
56
|
"openclaw": {
|
|
54
57
|
"extensions": [
|
|
55
|
-
"./index.
|
|
58
|
+
"./dist/index.js"
|
|
56
59
|
],
|
|
57
60
|
"compat": {
|
|
58
61
|
"pluginApi": ">=2026.2.26 <2026.6.0"
|