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.
- package/README.md +147 -45
- package/channel-plugin-api.ts +3 -0
- package/dist/channel-plugin-api.js +3 -0
- package/dist/index.js +15 -9
- package/dist/runtime-setter-api.js +1 -0
- package/dist/secret-contract-api.js +1 -0
- package/dist/setup-entry.js +16 -0
- package/dist/setup-plugin-api.js +3 -0
- package/dist/src/accounts.js +24 -48
- package/dist/src/channel.js +63 -29
- package/dist/src/config-schema.js +10 -11
- package/dist/src/groupme-api.js +9 -5
- package/dist/src/inbound.js +18 -10
- package/dist/src/monitor.js +25 -27
- package/dist/src/normalize.js +6 -0
- package/dist/src/onboarding.js +364 -337
- package/dist/src/parse.js +4 -14
- package/dist/src/policy.js +1 -1
- package/dist/src/rate-limit.js +12 -7
- package/dist/src/replay-cache.js +0 -3
- package/dist/src/secret-contract.js +49 -0
- package/dist/src/security.js +17 -34
- package/dist/src/send.js +19 -13
- package/index.ts +15 -10
- package/openclaw.plugin.json +14 -15
- package/package.json +43 -9
- package/runtime-setter-api.ts +1 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +17 -0
- package/setup-plugin-api.ts +3 -0
- package/src/accounts.ts +29 -68
- package/src/channel.ts +74 -64
- package/src/config-schema.ts +10 -11
- package/src/groupme-api.ts +21 -5
- package/src/history.ts +1 -1
- package/src/inbound.ts +45 -75
- package/src/monitor.ts +37 -52
- package/src/normalize.ts +7 -1
- package/src/onboarding.ts +449 -409
- package/src/parse.ts +6 -23
- package/src/policy.ts +1 -4
- package/src/rate-limit.ts +15 -12
- package/src/replay-cache.ts +1 -4
- package/src/runtime.ts +1 -1
- package/src/secret-contract.ts +66 -0
- package/src/security.ts +28 -66
- package/src/send.ts +32 -38
- package/src/types.ts +7 -7
package/dist/src/parse.js
CHANGED
|
@@ -50,9 +50,7 @@ function parseStringArray(value) {
|
|
|
50
50
|
if (!Array.isArray(value)) {
|
|
51
51
|
return [];
|
|
52
52
|
}
|
|
53
|
-
return value
|
|
54
|
-
.map((entry) => readString(entry))
|
|
55
|
-
.filter((entry) => Boolean(entry));
|
|
53
|
+
return value.map((entry) => readString(entry)).filter((entry) => Boolean(entry));
|
|
56
54
|
}
|
|
57
55
|
function parseAttachment(entry) {
|
|
58
56
|
if (!isRecord(entry)) {
|
|
@@ -121,13 +119,7 @@ export function parseGroupMeCallback(data) {
|
|
|
121
119
|
const groupId = readString(data.group_id);
|
|
122
120
|
const sourceGuid = readString(data.source_guid);
|
|
123
121
|
const createdAt = readNumber(data.created_at);
|
|
124
|
-
if (!id ||
|
|
125
|
-
!name ||
|
|
126
|
-
!senderType ||
|
|
127
|
-
!senderId ||
|
|
128
|
-
!userId ||
|
|
129
|
-
!groupId ||
|
|
130
|
-
!sourceGuid) {
|
|
122
|
+
if (!id || !name || !senderType || !senderId || !userId || !groupId || !sourceGuid) {
|
|
131
123
|
return null;
|
|
132
124
|
}
|
|
133
125
|
if (typeof createdAt !== "number") {
|
|
@@ -150,7 +142,7 @@ export function parseGroupMeCallback(data) {
|
|
|
150
142
|
attachments: parseAttachments(data.attachments),
|
|
151
143
|
};
|
|
152
144
|
}
|
|
153
|
-
|
|
145
|
+
function hasImageAttachment(attachments) {
|
|
154
146
|
return attachments.some((attachment) => attachment.type === "image");
|
|
155
147
|
}
|
|
156
148
|
export function shouldProcessCallback(msg) {
|
|
@@ -171,9 +163,7 @@ export function extractImageUrls(attachments) {
|
|
|
171
163
|
.map((attachment) => attachment.url);
|
|
172
164
|
}
|
|
173
165
|
function normalizeMentionText(text) {
|
|
174
|
-
return text
|
|
175
|
-
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
|
|
176
|
-
.toLowerCase();
|
|
166
|
+
return text.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "").toLowerCase();
|
|
177
167
|
}
|
|
178
168
|
function buildRegexes(patterns) {
|
|
179
169
|
if (!patterns || patterns.length === 0) {
|
package/dist/src/policy.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeGroupMeAllowEntry, normalizeStringId
|
|
1
|
+
import { normalizeGroupMeAllowEntry, normalizeStringId } from "./normalize.js";
|
|
2
2
|
export function resolveSenderAccess(params) {
|
|
3
3
|
const senderId = normalizeStringId(params.senderId);
|
|
4
4
|
if (!senderId) {
|
package/dist/src/rate-limit.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
const DEFAULT_MAX_TRACKED_KEYS = 10_000;
|
|
2
|
+
function positiveIntegerAtLeastOne(value) {
|
|
3
|
+
const normalized = Math.floor(value);
|
|
4
|
+
return Number.isFinite(normalized) ? Math.max(1, normalized) : 1;
|
|
5
|
+
}
|
|
2
6
|
function allowInWindow(params) {
|
|
3
7
|
const { state, key, limit, windowMs, now } = params;
|
|
4
8
|
const current = state.get(key) ?? [];
|
|
@@ -22,10 +26,10 @@ export class GroupMeRateLimiter {
|
|
|
22
26
|
bySender = new Map();
|
|
23
27
|
inFlight = 0;
|
|
24
28
|
constructor(params) {
|
|
25
|
-
this.windowMs =
|
|
26
|
-
this.maxRequestsPerIp =
|
|
27
|
-
this.maxRequestsPerSender =
|
|
28
|
-
this.maxConcurrent =
|
|
29
|
+
this.windowMs = positiveIntegerAtLeastOne(params.windowMs);
|
|
30
|
+
this.maxRequestsPerIp = positiveIntegerAtLeastOne(params.maxRequestsPerIp);
|
|
31
|
+
this.maxRequestsPerSender = positiveIntegerAtLeastOne(params.maxRequestsPerSender);
|
|
32
|
+
this.maxConcurrent = positiveIntegerAtLeastOne(params.maxConcurrent);
|
|
29
33
|
this.maxTrackedKeys = DEFAULT_MAX_TRACKED_KEYS;
|
|
30
34
|
}
|
|
31
35
|
evaluate(params, now = Date.now()) {
|
|
@@ -84,12 +88,13 @@ export class GroupMeRateLimiter {
|
|
|
84
88
|
}
|
|
85
89
|
}
|
|
86
90
|
capStateSize(state) {
|
|
91
|
+
// Only triggers past DEFAULT_MAX_TRACKED_KEYS (10k) distinct keys within a single
|
|
92
|
+
// window — not exercised in unit tests.
|
|
93
|
+
/* v8 ignore start */
|
|
87
94
|
while (state.size > this.maxTrackedKeys) {
|
|
88
95
|
const oldest = state.keys().next().value;
|
|
89
|
-
if (!oldest) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
96
|
state.delete(oldest);
|
|
93
97
|
}
|
|
98
|
+
/* v8 ignore stop */
|
|
94
99
|
}
|
|
95
100
|
}
|
package/dist/src/replay-cache.js
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { collectSimpleChannelFieldAssignments, getChannelSurface, } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
|
2
|
+
const secretFields = ["botId", "accessToken", "callbackToken"];
|
|
3
|
+
export const secretTargetRegistryEntries = secretFields.flatMap((field) => [
|
|
4
|
+
{
|
|
5
|
+
id: `channels.groupme.accounts.*.${field}`,
|
|
6
|
+
targetType: `channels.groupme.accounts.*.${field}`,
|
|
7
|
+
configFile: "openclaw.json",
|
|
8
|
+
pathPattern: `channels.groupme.accounts.*.${field}`,
|
|
9
|
+
secretShape: "secret_input",
|
|
10
|
+
expectedResolvedValue: "string",
|
|
11
|
+
includeInPlan: true,
|
|
12
|
+
includeInConfigure: true,
|
|
13
|
+
includeInAudit: true,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: `channels.groupme.${field}`,
|
|
17
|
+
targetType: `channels.groupme.${field}`,
|
|
18
|
+
configFile: "openclaw.json",
|
|
19
|
+
pathPattern: `channels.groupme.${field}`,
|
|
20
|
+
secretShape: "secret_input",
|
|
21
|
+
expectedResolvedValue: "string",
|
|
22
|
+
includeInPlan: true,
|
|
23
|
+
includeInConfigure: true,
|
|
24
|
+
includeInAudit: true,
|
|
25
|
+
},
|
|
26
|
+
]);
|
|
27
|
+
export function collectRuntimeConfigAssignments(params) {
|
|
28
|
+
const resolved = getChannelSurface(params.config, "groupme");
|
|
29
|
+
if (!resolved) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const { channel, surface } = resolved;
|
|
33
|
+
for (const field of secretFields) {
|
|
34
|
+
collectSimpleChannelFieldAssignments({
|
|
35
|
+
channelKey: "groupme",
|
|
36
|
+
field,
|
|
37
|
+
channel,
|
|
38
|
+
surface,
|
|
39
|
+
defaults: params.defaults,
|
|
40
|
+
context: params.context,
|
|
41
|
+
topInactiveReason: `no enabled account inherits this top-level GroupMe ${field}.`,
|
|
42
|
+
accountInactiveReason: "GroupMe account is disabled.",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export const channelSecrets = {
|
|
47
|
+
secretTargetRegistryEntries,
|
|
48
|
+
collectRuntimeConfigAssignments,
|
|
49
|
+
};
|
package/dist/src/security.js
CHANGED
|
@@ -15,7 +15,7 @@ function normalizeIpCandidate(raw) {
|
|
|
15
15
|
return "";
|
|
16
16
|
}
|
|
17
17
|
if (value.includes(",")) {
|
|
18
|
-
value = value.split(",")[0]
|
|
18
|
+
value = value.split(",")[0].trim();
|
|
19
19
|
}
|
|
20
20
|
if (value.startsWith("[")) {
|
|
21
21
|
const endIndex = value.indexOf("]");
|
|
@@ -36,9 +36,9 @@ function normalizeIpCandidate(raw) {
|
|
|
36
36
|
if (isIP(value) === 0) {
|
|
37
37
|
const maybeWithPort = value.split(":");
|
|
38
38
|
if (maybeWithPort.length === 2 &&
|
|
39
|
-
/^\d+$/.test(maybeWithPort[1]
|
|
40
|
-
isIP(maybeWithPort[0]
|
|
41
|
-
value = maybeWithPort[0]
|
|
39
|
+
/^\d+$/.test(maybeWithPort[1]) &&
|
|
40
|
+
isIP(maybeWithPort[0]) === 4) {
|
|
41
|
+
value = maybeWithPort[0];
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
return isIP(value) === 0 ? "" : value;
|
|
@@ -59,7 +59,7 @@ function normalizeHost(value) {
|
|
|
59
59
|
return "";
|
|
60
60
|
}
|
|
61
61
|
if (host.includes(",")) {
|
|
62
|
-
host = host.split(",")[0]
|
|
62
|
+
host = host.split(",")[0].trim();
|
|
63
63
|
}
|
|
64
64
|
if (!host) {
|
|
65
65
|
return "";
|
|
@@ -75,9 +75,8 @@ function normalizeHost(value) {
|
|
|
75
75
|
return "";
|
|
76
76
|
}
|
|
77
77
|
const maybeWithoutPort = host.split(":");
|
|
78
|
-
if (maybeWithoutPort.length === 2 &&
|
|
79
|
-
|
|
80
|
-
host = maybeWithoutPort[0] ?? "";
|
|
78
|
+
if (maybeWithoutPort.length === 2 && /^\d+$/.test(maybeWithoutPort[1])) {
|
|
79
|
+
host = maybeWithoutPort[0];
|
|
81
80
|
}
|
|
82
81
|
return host.trim();
|
|
83
82
|
}
|
|
@@ -85,12 +84,16 @@ function parseProxyRules(entries) {
|
|
|
85
84
|
const rules = [];
|
|
86
85
|
for (const entry of entries) {
|
|
87
86
|
const raw = entry.trim();
|
|
87
|
+
// resolveGroupMeSecurity already trims and Boolean-filters these entries, so a
|
|
88
|
+
// blank value never reaches this guard; kept as defense in depth.
|
|
89
|
+
/* v8 ignore start */
|
|
88
90
|
if (!raw) {
|
|
89
91
|
continue;
|
|
90
92
|
}
|
|
93
|
+
/* v8 ignore stop */
|
|
91
94
|
if (raw.includes("/")) {
|
|
92
95
|
const [network, prefixRaw] = raw.split("/");
|
|
93
|
-
const normalizedNetwork = normalizeIpCandidate(network
|
|
96
|
+
const normalizedNetwork = normalizeIpCandidate(network);
|
|
94
97
|
const ipVersion = isIP(normalizedNetwork);
|
|
95
98
|
if (!ipVersion) {
|
|
96
99
|
continue;
|
|
@@ -148,22 +151,9 @@ function createTrustedProxyMatcher(entries) {
|
|
|
148
151
|
return blockList.check(normalized, "ipv6");
|
|
149
152
|
};
|
|
150
153
|
}
|
|
151
|
-
function extractCallbackToken(callbackUrl) {
|
|
152
|
-
const raw = callbackUrl?.trim() ?? "";
|
|
153
|
-
if (!raw) {
|
|
154
|
-
return "";
|
|
155
|
-
}
|
|
156
|
-
try {
|
|
157
|
-
const parsed = new URL(raw, "http://localhost");
|
|
158
|
-
return parsed.searchParams.get("k")?.trim() ?? "";
|
|
159
|
-
}
|
|
160
|
-
catch {
|
|
161
|
-
throw new Error(`Invalid callbackUrl: unable to parse "${raw}"`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
154
|
export function resolveGroupMeSecurity(accountConfig) {
|
|
165
155
|
const security = (accountConfig.security ?? {});
|
|
166
|
-
const callbackToken =
|
|
156
|
+
const callbackToken = readTrimmed(accountConfig.callbackToken) ?? "";
|
|
167
157
|
const groupId = readTrimmed(accountConfig.groupId) ?? "";
|
|
168
158
|
const allowedMimePrefixes = Array.isArray(security.media?.allowedMimePrefixes)
|
|
169
159
|
? security.media.allowedMimePrefixes
|
|
@@ -185,12 +175,10 @@ export function resolveGroupMeSecurity(accountConfig) {
|
|
|
185
175
|
callbackRejectStatus: 404,
|
|
186
176
|
groupId,
|
|
187
177
|
replay: {
|
|
188
|
-
enabled: true,
|
|
189
178
|
ttlSeconds: positiveIntOrDefault(security.replay?.ttlSeconds, 600),
|
|
190
179
|
maxEntries: positiveIntOrDefault(security.replay?.maxEntries, 10_000),
|
|
191
180
|
},
|
|
192
181
|
rateLimit: {
|
|
193
|
-
enabled: true,
|
|
194
182
|
windowMs: positiveIntOrDefault(security.rateLimit?.windowMs, 60_000),
|
|
195
183
|
maxRequestsPerIp: positiveIntOrDefault(security.rateLimit?.maxRequestsPerIp, 120),
|
|
196
184
|
maxRequestsPerSender: positiveIntOrDefault(security.rateLimit?.maxRequestsPerSender, 60),
|
|
@@ -245,7 +233,7 @@ export function checkGroupBinding(params) {
|
|
|
245
233
|
}
|
|
246
234
|
return { ok: true };
|
|
247
235
|
}
|
|
248
|
-
export function
|
|
236
|
+
export function redactWebhookUrl(raw, security) {
|
|
249
237
|
if (!security.callbackToken) {
|
|
250
238
|
return raw;
|
|
251
239
|
}
|
|
@@ -264,9 +252,7 @@ export function redactCallbackUrl(raw, security) {
|
|
|
264
252
|
export function validateProxyRequest(params) {
|
|
265
253
|
const remoteIp = normalizeIpCandidate(params.remoteAddress) || "unknown";
|
|
266
254
|
const proxyConfig = params.security.proxy;
|
|
267
|
-
const defaultProto = params.socketEncrypted
|
|
268
|
-
? "https"
|
|
269
|
-
: "http";
|
|
255
|
+
const defaultProto = params.socketEncrypted ? "https" : "http";
|
|
270
256
|
const hostHeader = normalizeHost(getHeaderValue(params.headers, "host"));
|
|
271
257
|
if (!proxyConfig.enabled) {
|
|
272
258
|
return {
|
|
@@ -281,17 +267,14 @@ export function validateProxyRequest(params) {
|
|
|
281
267
|
},
|
|
282
268
|
};
|
|
283
269
|
}
|
|
284
|
-
const fromTrustedProxy = proxyConfig.trustedProxyCidrs.length > 0 &&
|
|
285
|
-
proxyConfig.isTrustedProxy(remoteIp);
|
|
270
|
+
const fromTrustedProxy = proxyConfig.trustedProxyCidrs.length > 0 && proxyConfig.isTrustedProxy(remoteIp);
|
|
286
271
|
const forwardedFor = normalizeIpCandidate(getHeaderValue(params.headers, "x-forwarded-for"));
|
|
287
272
|
const forwardedHost = normalizeHost(getHeaderValue(params.headers, "x-forwarded-host"));
|
|
288
273
|
const forwardedProtoRaw = getHeaderValue(params.headers, "x-forwarded-proto")
|
|
289
274
|
.split(",")[0]
|
|
290
275
|
?.trim()
|
|
291
276
|
.toLowerCase();
|
|
292
|
-
const forwardedProto = forwardedProtoRaw === "http" || forwardedProtoRaw === "https"
|
|
293
|
-
? forwardedProtoRaw
|
|
294
|
-
: null;
|
|
277
|
+
const forwardedProto = forwardedProtoRaw === "http" || forwardedProtoRaw === "https" ? forwardedProtoRaw : null;
|
|
295
278
|
const usingForwardedHeaders = fromTrustedProxy && Boolean(forwardedFor || forwardedHost || forwardedProto);
|
|
296
279
|
const effectiveClientIp = usingForwardedHeaders && forwardedFor ? forwardedFor : remoteIp;
|
|
297
280
|
const effectiveHost = usingForwardedHeaders && forwardedHost ? forwardedHost : hostHeader;
|
package/dist/src/send.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { fetchWithSsrFGuard, SsrFBlockedError } from "openclaw/plugin-sdk/infra-runtime";
|
|
3
3
|
import { resolveGroupMeAccount } from "./accounts.js";
|
|
4
4
|
import { getGroupMeRuntime } from "./runtime.js";
|
|
5
5
|
import { resolveGroupMeSecurity } from "./security.js";
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const GROUPME_API_BASE = "https://api.groupme.com/v3";
|
|
7
|
+
const GROUPME_IMAGE_SERVICE = "https://image.groupme.com";
|
|
8
8
|
export const GROUPME_MAX_TEXT_LENGTH = 1000;
|
|
9
9
|
export async function sendGroupMeMessage(params) {
|
|
10
10
|
const fetchFn = params.fetchFn ?? fetch;
|
|
11
|
+
const apiBaseUrl = params.apiBaseUrl ?? GROUPME_API_BASE;
|
|
11
12
|
const payload = {
|
|
12
13
|
bot_id: params.botId,
|
|
13
14
|
text: params.text,
|
|
@@ -15,7 +16,7 @@ export async function sendGroupMeMessage(params) {
|
|
|
15
16
|
if (params.pictureUrl) {
|
|
16
17
|
payload.picture_url = params.pictureUrl;
|
|
17
18
|
}
|
|
18
|
-
const response = await fetchFn(`${
|
|
19
|
+
const response = await fetchFn(`${apiBaseUrl}/bots/post`, {
|
|
19
20
|
method: "POST",
|
|
20
21
|
headers: {
|
|
21
22
|
"Content-Type": "application/json",
|
|
@@ -31,15 +32,15 @@ export async function sendGroupMeMessage(params) {
|
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
34
|
function extractPictureUrl(value) {
|
|
34
|
-
const url = value?.payload
|
|
35
|
-
?.picture_url;
|
|
35
|
+
const url = value?.payload?.picture_url;
|
|
36
36
|
if (typeof url !== "string")
|
|
37
37
|
return null;
|
|
38
38
|
return url.trim() || null;
|
|
39
39
|
}
|
|
40
40
|
export async function uploadGroupMeImage(params) {
|
|
41
41
|
const fetchFn = params.fetchFn ?? fetch;
|
|
42
|
-
const
|
|
42
|
+
const imageBaseUrl = params.imageBaseUrl ?? GROUPME_IMAGE_SERVICE;
|
|
43
|
+
const response = await fetchFn(`${imageBaseUrl}/pictures`, {
|
|
43
44
|
method: "POST",
|
|
44
45
|
headers: {
|
|
45
46
|
"X-Access-Token": params.accessToken,
|
|
@@ -101,8 +102,7 @@ async function downloadRemoteMedia(params) {
|
|
|
101
102
|
throw new Error(`GroupMe media download failed: ${response.status} ${response.statusText}`);
|
|
102
103
|
}
|
|
103
104
|
const contentLength = Number(response.headers.get("content-length"));
|
|
104
|
-
if (Number.isFinite(contentLength) &&
|
|
105
|
-
contentLength > params.maxDownloadBytes) {
|
|
105
|
+
if (Number.isFinite(contentLength) && contentLength > params.maxDownloadBytes) {
|
|
106
106
|
throw new Error(`GroupMe media download exceeds maxDownloadBytes (${contentLength} > ${params.maxDownloadBytes})`);
|
|
107
107
|
}
|
|
108
108
|
const contentType = enforceMimePolicy({
|
|
@@ -130,6 +130,9 @@ function wrapFetchWithTimeout(fetchFn, timeoutMs) {
|
|
|
130
130
|
const timeout = setTimeout(() => {
|
|
131
131
|
controller.abort("GroupMe media fetch timed out");
|
|
132
132
|
}, timeoutMs);
|
|
133
|
+
// Upstream init.signal bridging: our call sites never pass one, so the abort
|
|
134
|
+
// wiring below is kept for callers that do but is not exercised by tests.
|
|
135
|
+
/* v8 ignore start */
|
|
133
136
|
const upstreamSignal = init?.signal;
|
|
134
137
|
const onAbort = () => controller.abort(upstreamSignal?.reason);
|
|
135
138
|
if (upstreamSignal) {
|
|
@@ -140,6 +143,7 @@ function wrapFetchWithTimeout(fetchFn, timeoutMs) {
|
|
|
140
143
|
upstreamSignal.addEventListener("abort", onAbort, { once: true });
|
|
141
144
|
}
|
|
142
145
|
}
|
|
146
|
+
/* v8 ignore stop */
|
|
143
147
|
try {
|
|
144
148
|
return await base(input, {
|
|
145
149
|
...init,
|
|
@@ -148,17 +152,16 @@ function wrapFetchWithTimeout(fetchFn, timeoutMs) {
|
|
|
148
152
|
}
|
|
149
153
|
finally {
|
|
150
154
|
clearTimeout(timeout);
|
|
155
|
+
/* v8 ignore start */
|
|
151
156
|
if (upstreamSignal) {
|
|
152
157
|
upstreamSignal.removeEventListener("abort", onAbort);
|
|
153
158
|
}
|
|
159
|
+
/* v8 ignore stop */
|
|
154
160
|
}
|
|
155
161
|
};
|
|
156
162
|
}
|
|
157
163
|
function enforceMimePolicy(params) {
|
|
158
|
-
const contentType = (params.contentType ?? "")
|
|
159
|
-
.split(";")[0]
|
|
160
|
-
?.trim()
|
|
161
|
-
.toLowerCase();
|
|
164
|
+
const contentType = (params.contentType ?? "").split(";")[0]?.trim().toLowerCase();
|
|
162
165
|
if (!contentType ||
|
|
163
166
|
!params.allowedMimePrefixes.some((prefix) => contentType.startsWith(prefix.toLowerCase()))) {
|
|
164
167
|
throw new Error(`GroupMe media download blocked by MIME policy (${contentType || "missing content-type"})`);
|
|
@@ -234,6 +237,7 @@ export async function sendGroupMeText(params) {
|
|
|
234
237
|
botId: account.botId,
|
|
235
238
|
text: params.text,
|
|
236
239
|
fetchFn: params.fetchFn,
|
|
240
|
+
apiBaseUrl: params.apiBaseUrl,
|
|
237
241
|
});
|
|
238
242
|
}
|
|
239
243
|
export async function sendGroupMeMedia(params) {
|
|
@@ -261,11 +265,13 @@ export async function sendGroupMeMedia(params) {
|
|
|
261
265
|
imageData: data,
|
|
262
266
|
contentType,
|
|
263
267
|
fetchFn: params.fetchFn,
|
|
268
|
+
imageBaseUrl: params.imageBaseUrl,
|
|
264
269
|
});
|
|
265
270
|
return sendGroupMeMessage({
|
|
266
271
|
botId: account.botId,
|
|
267
272
|
text: params.text,
|
|
268
273
|
pictureUrl,
|
|
269
274
|
fetchFn: params.fetchFn,
|
|
275
|
+
apiBaseUrl: params.apiBaseUrl,
|
|
270
276
|
});
|
|
271
277
|
}
|
package/index.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
-
import { groupmePlugin } from "./src/channel.js";
|
|
4
|
-
import { setGroupMeRuntime } from "./src/runtime.js";
|
|
1
|
+
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
|
5
2
|
|
|
6
|
-
const plugin = {
|
|
3
|
+
const plugin = defineBundledChannelEntry({
|
|
7
4
|
id: "groupme",
|
|
8
5
|
name: "GroupMe",
|
|
9
6
|
description: "GroupMe channel plugin",
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
importMetaUrl: import.meta.url,
|
|
8
|
+
plugin: {
|
|
9
|
+
specifier: "./channel-plugin-api.js",
|
|
10
|
+
exportName: "groupmePlugin",
|
|
14
11
|
},
|
|
15
|
-
|
|
12
|
+
secrets: {
|
|
13
|
+
specifier: "./secret-contract-api.js",
|
|
14
|
+
exportName: "channelSecrets",
|
|
15
|
+
},
|
|
16
|
+
runtime: {
|
|
17
|
+
specifier: "./runtime-setter-api.js",
|
|
18
|
+
exportName: "setGroupMeRuntime",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
16
21
|
|
|
17
22
|
export default plugin;
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,24 +2,23 @@
|
|
|
2
2
|
"id": "groupme",
|
|
3
3
|
"name": "GroupMe",
|
|
4
4
|
"description": "GroupMe channel plugin for OpenClaw.",
|
|
5
|
-
"channels": [
|
|
6
|
-
"groupme"
|
|
7
|
-
],
|
|
5
|
+
"channels": ["groupme"],
|
|
8
6
|
"activation": {
|
|
9
|
-
"onStartup":
|
|
10
|
-
"onChannels": [
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
"onStartup": false,
|
|
8
|
+
"onChannels": ["groupme"]
|
|
9
|
+
},
|
|
10
|
+
"channelConfigs": {
|
|
11
|
+
"groupme": {
|
|
12
|
+
"label": "GroupMe",
|
|
13
|
+
"description": "GroupMe Bot API webhook integration for group chats.",
|
|
14
|
+
"schema": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": true
|
|
17
|
+
}
|
|
18
|
+
}
|
|
13
19
|
},
|
|
14
20
|
"channelEnvVars": {
|
|
15
|
-
"groupme": [
|
|
16
|
-
"GROUPME_BOT_ID",
|
|
17
|
-
"GROUPME_ACCESS_TOKEN",
|
|
18
|
-
"GROUPME_BOT_NAME",
|
|
19
|
-
"GROUPME_GROUP_ID",
|
|
20
|
-
"GROUPME_PUBLIC_DOMAIN",
|
|
21
|
-
"GROUPME_CALLBACK_URL"
|
|
22
|
-
]
|
|
21
|
+
"groupme": ["GROUPME_BOT_ID", "GROUPME_ACCESS_TOKEN", "GROUPME_CALLBACK_TOKEN"]
|
|
23
22
|
},
|
|
24
23
|
"configSchema": {
|
|
25
24
|
"type": "object",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-groupme",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "OpenClaw GroupMe channel plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -20,33 +20,59 @@
|
|
|
20
20
|
},
|
|
21
21
|
"type": "module",
|
|
22
22
|
"engines": {
|
|
23
|
-
"node": ">=
|
|
23
|
+
"node": ">=22.19.0"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"openclaw.plugin.json",
|
|
27
27
|
"dist/**/*.js",
|
|
28
28
|
"index.ts",
|
|
29
|
+
"channel-plugin-api.ts",
|
|
30
|
+
"runtime-setter-api.ts",
|
|
31
|
+
"secret-contract-api.ts",
|
|
32
|
+
"setup-entry.ts",
|
|
33
|
+
"setup-plugin-api.ts",
|
|
29
34
|
"src/**/*.ts",
|
|
30
35
|
"LICENSE",
|
|
31
36
|
"README.md"
|
|
32
37
|
],
|
|
33
38
|
"scripts": {
|
|
34
39
|
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"check": "npm run lint && npm run typecheck && npm run test:unit && npm run test:integration && npm run build && npm run knip",
|
|
41
|
+
"commitlint": "commitlint --edit",
|
|
42
|
+
"format": "biome format --write .",
|
|
43
|
+
"format:check": "biome format .",
|
|
44
|
+
"knip": "knip",
|
|
45
|
+
"lint": "biome check .",
|
|
46
|
+
"lint:fix": "biome check --write .",
|
|
35
47
|
"prepack": "npm run build",
|
|
36
|
-
"
|
|
48
|
+
"prepare": "lefthook install",
|
|
49
|
+
"test": "npm run test:unit",
|
|
50
|
+
"test:coverage": "vitest run tests/unit tests/integration --coverage --no-file-parallelism",
|
|
51
|
+
"test:integration": "vitest run tests/integration --no-file-parallelism",
|
|
52
|
+
"test:live": "vitest run tests/live",
|
|
53
|
+
"test:live:api": "vitest run tests/live/groupme-api-live.test.ts",
|
|
54
|
+
"test:live:plugin-outbound": "vitest run tests/live/groupme-plugin-outbound-live.test.ts",
|
|
55
|
+
"test:unit": "vitest run tests/unit",
|
|
56
|
+
"test:watch": "vitest tests/unit tests/integration",
|
|
37
57
|
"typecheck": "tsc --noEmit"
|
|
38
58
|
},
|
|
39
59
|
"dependencies": {
|
|
40
60
|
"zod": ">=4.3.6 <5"
|
|
41
61
|
},
|
|
42
62
|
"devDependencies": {
|
|
43
|
-
"@
|
|
44
|
-
"
|
|
63
|
+
"@biomejs/biome": "^2.4.16",
|
|
64
|
+
"@commitlint/cli": "^20.2.0",
|
|
65
|
+
"@commitlint/config-conventional": "^20.0.0",
|
|
66
|
+
"@types/node": "^22.19.20",
|
|
67
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
68
|
+
"knip": "^5.71.0",
|
|
69
|
+
"lefthook": "^2.1.9",
|
|
70
|
+
"openclaw": "2026.6.1",
|
|
45
71
|
"typescript": "^6.0.3",
|
|
46
72
|
"vitest": "^4.0.18"
|
|
47
73
|
},
|
|
48
74
|
"peerDependencies": {
|
|
49
|
-
"openclaw": ">=2026.
|
|
75
|
+
"openclaw": ">=2026.6.1"
|
|
50
76
|
},
|
|
51
77
|
"peerDependenciesMeta": {
|
|
52
78
|
"openclaw": {
|
|
@@ -57,17 +83,25 @@
|
|
|
57
83
|
"extensions": [
|
|
58
84
|
"./dist/index.js"
|
|
59
85
|
],
|
|
86
|
+
"setupEntry": "./dist/setup-entry.js",
|
|
60
87
|
"compat": {
|
|
61
|
-
"pluginApi": ">=2026.
|
|
88
|
+
"pluginApi": ">=2026.6.1"
|
|
62
89
|
},
|
|
63
90
|
"build": {
|
|
64
|
-
"openclawVersion": "2026.
|
|
91
|
+
"openclawVersion": "2026.6.1"
|
|
65
92
|
},
|
|
66
93
|
"channel": {
|
|
67
94
|
"id": "groupme",
|
|
68
95
|
"label": "GroupMe",
|
|
69
96
|
"selectionLabel": "GroupMe (Bot API)",
|
|
70
|
-
"blurb": "GroupMe bot webhook integration (group chats only)."
|
|
97
|
+
"blurb": "GroupMe bot webhook integration (group chats only).",
|
|
98
|
+
"doctorCapabilities": {
|
|
99
|
+
"groupModel": "sender",
|
|
100
|
+
"groupAllowFromFallbackToAllowFrom": false
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"startup": {
|
|
104
|
+
"deferConfiguredChannelFullLoadUntilAfterListen": true
|
|
71
105
|
},
|
|
72
106
|
"install": {
|
|
73
107
|
"npmSpec": "openclaw-groupme"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { setGroupMeRuntime } from "./src/runtime.js";
|
package/setup-entry.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
|
2
|
+
|
|
3
|
+
export default defineBundledChannelSetupEntry({
|
|
4
|
+
importMetaUrl: import.meta.url,
|
|
5
|
+
plugin: {
|
|
6
|
+
specifier: "./setup-plugin-api.js",
|
|
7
|
+
exportName: "groupmeSetupPlugin",
|
|
8
|
+
},
|
|
9
|
+
secrets: {
|
|
10
|
+
specifier: "./secret-contract-api.js",
|
|
11
|
+
exportName: "channelSecrets",
|
|
12
|
+
},
|
|
13
|
+
runtime: {
|
|
14
|
+
specifier: "./runtime-setter-api.js",
|
|
15
|
+
exportName: "setGroupMeRuntime",
|
|
16
|
+
},
|
|
17
|
+
});
|