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/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
|
-
|
|
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
package/src/rate-limit.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
|
|
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 =
|
|
45
|
-
this.maxRequestsPerIp =
|
|
46
|
-
this.maxRequestsPerSender =
|
|
47
|
-
|
|
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
|
|
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
|
}
|
package/src/replay-cache.ts
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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]
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
const
|
|
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 {
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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(`${
|
|
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
|
|
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
|
}
|