openclaw-groupme 0.0.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/inbound.ts
CHANGED
|
@@ -1,15 +1,37 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
OpenClawConfig,
|
|
3
|
+
ReplyPayload,
|
|
4
|
+
RuntimeEnv,
|
|
5
|
+
} from "openclaw/plugin-sdk";
|
|
2
6
|
import {
|
|
7
|
+
buildPendingHistoryContextFromMap,
|
|
8
|
+
clearHistoryEntriesIfEnabled,
|
|
3
9
|
createReplyPrefixOptions,
|
|
4
10
|
logInboundDrop,
|
|
11
|
+
recordPendingHistoryEntryIfEnabled,
|
|
5
12
|
resolveControlCommandGate,
|
|
6
13
|
resolveMentionGatingWithBypass,
|
|
14
|
+
type HistoryEntry,
|
|
7
15
|
} from "openclaw/plugin-sdk";
|
|
8
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
GroupMeCallbackData,
|
|
18
|
+
ResolvedGroupMeAccount,
|
|
19
|
+
CoreConfig,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
import {
|
|
22
|
+
buildGroupMeHistoryEntry,
|
|
23
|
+
formatGroupMeHistoryEntry,
|
|
24
|
+
resolveGroupMeBodyForAgent,
|
|
25
|
+
} from "./history.js";
|
|
9
26
|
import { extractImageUrls, detectGroupMeMention } from "./parse.js";
|
|
10
27
|
import { resolveSenderAccess } from "./policy.js";
|
|
11
28
|
import { getGroupMeRuntime } from "./runtime.js";
|
|
12
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
GROUPME_MAX_TEXT_LENGTH,
|
|
31
|
+
sendGroupMeMedia,
|
|
32
|
+
sendGroupMeText,
|
|
33
|
+
} from "./send.js";
|
|
34
|
+
import { resolveGroupMeSecurity } from "./security.js";
|
|
13
35
|
|
|
14
36
|
const CHANNEL_ID = "groupme" as const;
|
|
15
37
|
|
|
@@ -35,7 +57,9 @@ function chunkReplyText(params: {
|
|
|
35
57
|
return [];
|
|
36
58
|
}
|
|
37
59
|
|
|
38
|
-
return params.core.channel.text
|
|
60
|
+
return params.core.channel.text
|
|
61
|
+
.chunkMarkdownText(trimmed, params.limit)
|
|
62
|
+
.filter(Boolean);
|
|
39
63
|
}
|
|
40
64
|
|
|
41
65
|
async function deliverGroupMeReply(params: {
|
|
@@ -130,9 +154,22 @@ export async function handleGroupMeInbound(params: {
|
|
|
130
154
|
account: ResolvedGroupMeAccount;
|
|
131
155
|
config: CoreConfig;
|
|
132
156
|
runtime: RuntimeEnv;
|
|
133
|
-
|
|
157
|
+
groupHistories: Map<string, HistoryEntry[]>;
|
|
158
|
+
historyLimit: number;
|
|
159
|
+
statusSink?: (patch: {
|
|
160
|
+
lastInboundAt?: number;
|
|
161
|
+
lastOutboundAt?: number;
|
|
162
|
+
}) => void;
|
|
134
163
|
}): Promise<void> {
|
|
135
|
-
const {
|
|
164
|
+
const {
|
|
165
|
+
message,
|
|
166
|
+
account,
|
|
167
|
+
config,
|
|
168
|
+
runtime,
|
|
169
|
+
groupHistories,
|
|
170
|
+
historyLimit,
|
|
171
|
+
statusSink,
|
|
172
|
+
} = params;
|
|
136
173
|
const core = getGroupMeRuntime();
|
|
137
174
|
|
|
138
175
|
const inboundTimestamp = message.createdAt * 1000;
|
|
@@ -145,12 +182,15 @@ export async function handleGroupMeInbound(params: {
|
|
|
145
182
|
});
|
|
146
183
|
|
|
147
184
|
const allowFrom = account.config.allowFrom ?? [];
|
|
185
|
+
const security = resolveGroupMeSecurity(account.config);
|
|
148
186
|
const senderAllowed = resolveSenderAccess({
|
|
149
187
|
senderId: message.senderId,
|
|
150
188
|
allowFrom,
|
|
151
189
|
});
|
|
152
190
|
if (!senderAllowed) {
|
|
153
|
-
runtime.log?.(
|
|
191
|
+
runtime.log?.(
|
|
192
|
+
`groupme: drop sender ${message.senderId} (not in allowFrom)`,
|
|
193
|
+
);
|
|
154
194
|
return;
|
|
155
195
|
}
|
|
156
196
|
|
|
@@ -168,6 +208,7 @@ export async function handleGroupMeInbound(params: {
|
|
|
168
208
|
config as OpenClawConfig,
|
|
169
209
|
route.agentId,
|
|
170
210
|
);
|
|
211
|
+
const requireMention = account.config.requireMention ?? true;
|
|
171
212
|
const wasMentioned = detectGroupMeMention({
|
|
172
213
|
text: message.text,
|
|
173
214
|
botName: account.config.botName,
|
|
@@ -183,9 +224,17 @@ export async function handleGroupMeInbound(params: {
|
|
|
183
224
|
message.text,
|
|
184
225
|
config as OpenClawConfig,
|
|
185
226
|
);
|
|
227
|
+
const commandBypassNeedsAllowFrom =
|
|
228
|
+
security.commandBypass.requireAllowFrom && hasControlCommand;
|
|
229
|
+
const commandBypassCanSkipMention = !(
|
|
230
|
+
security.commandBypass.requireMentionForCommands &&
|
|
231
|
+
requireMention &&
|
|
232
|
+
hasControlCommand
|
|
233
|
+
);
|
|
186
234
|
|
|
187
235
|
const commandGate = resolveControlCommandGate({
|
|
188
|
-
useAccessGroups:
|
|
236
|
+
useAccessGroups:
|
|
237
|
+
config.commands?.useAccessGroups !== false || commandBypassNeedsAllowFrom,
|
|
189
238
|
authorizers: [{ configured: allowFrom.length > 0, allowed: senderAllowed }],
|
|
190
239
|
allowTextCommands,
|
|
191
240
|
hasControlCommand,
|
|
@@ -202,35 +251,60 @@ export async function handleGroupMeInbound(params: {
|
|
|
202
251
|
|
|
203
252
|
const mentionGate = resolveMentionGatingWithBypass({
|
|
204
253
|
isGroup: true,
|
|
205
|
-
requireMention
|
|
254
|
+
requireMention,
|
|
206
255
|
canDetectMention: true,
|
|
207
256
|
wasMentioned,
|
|
208
257
|
hasAnyMention: false,
|
|
209
258
|
allowTextCommands,
|
|
210
|
-
hasControlCommand,
|
|
211
|
-
commandAuthorized:
|
|
259
|
+
hasControlCommand: commandBypassCanSkipMention ? hasControlCommand : false,
|
|
260
|
+
commandAuthorized: commandBypassCanSkipMention
|
|
261
|
+
? commandGate.commandAuthorized
|
|
262
|
+
: false,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const imageUrls = extractImageUrls(message.attachments);
|
|
266
|
+
const rawBody = message.text;
|
|
267
|
+
const bodyForAgent = resolveGroupMeBodyForAgent({
|
|
268
|
+
rawBody,
|
|
269
|
+
imageUrls,
|
|
212
270
|
});
|
|
213
271
|
|
|
214
272
|
if (mentionGate.shouldSkip) {
|
|
215
|
-
|
|
273
|
+
const buffered = recordPendingHistoryEntryIfEnabled({
|
|
274
|
+
historyMap: groupHistories,
|
|
275
|
+
historyKey: message.groupId,
|
|
276
|
+
limit: historyLimit,
|
|
277
|
+
entry: buildGroupMeHistoryEntry({
|
|
278
|
+
senderName: message.name,
|
|
279
|
+
body: bodyForAgent,
|
|
280
|
+
timestamp: inboundTimestamp,
|
|
281
|
+
messageId: message.id,
|
|
282
|
+
}),
|
|
283
|
+
});
|
|
284
|
+
if (buffered.length > 0) {
|
|
285
|
+
runtime.log?.(
|
|
286
|
+
`groupme: buffered message from ${message.name} (${buffered.length}/${historyLimit})`,
|
|
287
|
+
);
|
|
288
|
+
} else {
|
|
289
|
+
runtime.log?.("groupme: skip message (mention required, not mentioned)");
|
|
290
|
+
}
|
|
216
291
|
return;
|
|
217
292
|
}
|
|
218
293
|
|
|
219
|
-
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
294
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(
|
|
295
|
+
config as OpenClawConfig,
|
|
296
|
+
);
|
|
297
|
+
const storePath = core.channel.session.resolveStorePath(
|
|
298
|
+
config.session?.store,
|
|
299
|
+
{
|
|
300
|
+
agentId: route.agentId,
|
|
301
|
+
},
|
|
302
|
+
);
|
|
223
303
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
224
304
|
storePath,
|
|
225
305
|
sessionKey: route.sessionKey,
|
|
226
306
|
});
|
|
227
307
|
|
|
228
|
-
const imageUrls = extractImageUrls(message.attachments);
|
|
229
|
-
const rawBody = message.text;
|
|
230
|
-
const bodyForAgent =
|
|
231
|
-
rawBody.trim() ||
|
|
232
|
-
(imageUrls.length > 0 ? imageUrls.map((url) => `Image: ${url}`).join("\n") : rawBody);
|
|
233
|
-
|
|
234
308
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
235
309
|
channel: "GroupMe",
|
|
236
310
|
from: message.name,
|
|
@@ -239,10 +313,41 @@ export async function handleGroupMeInbound(params: {
|
|
|
239
313
|
envelope: envelopeOptions,
|
|
240
314
|
body: bodyForAgent,
|
|
241
315
|
});
|
|
316
|
+
const shouldUseHistoryBuffer = requireMention && historyLimit > 0;
|
|
317
|
+
const historyEntriesForContext = shouldUseHistoryBuffer
|
|
318
|
+
? [...(groupHistories.get(message.groupId) ?? [])]
|
|
319
|
+
: [];
|
|
320
|
+
if (shouldUseHistoryBuffer) {
|
|
321
|
+
clearHistoryEntriesIfEnabled({
|
|
322
|
+
historyMap: groupHistories,
|
|
323
|
+
historyKey: message.groupId,
|
|
324
|
+
limit: historyLimit,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const combinedBody =
|
|
329
|
+
shouldUseHistoryBuffer
|
|
330
|
+
? buildPendingHistoryContextFromMap({
|
|
331
|
+
historyMap: new Map([[message.groupId, historyEntriesForContext]]),
|
|
332
|
+
historyKey: message.groupId,
|
|
333
|
+
limit: historyLimit,
|
|
334
|
+
currentMessage: body,
|
|
335
|
+
formatEntry: formatGroupMeHistoryEntry,
|
|
336
|
+
})
|
|
337
|
+
: body;
|
|
338
|
+
const inboundHistory =
|
|
339
|
+
shouldUseHistoryBuffer
|
|
340
|
+
? historyEntriesForContext.map((entry) => ({
|
|
341
|
+
sender: entry.sender,
|
|
342
|
+
body: entry.body,
|
|
343
|
+
timestamp: entry.timestamp,
|
|
344
|
+
}))
|
|
345
|
+
: undefined;
|
|
242
346
|
|
|
243
347
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
244
|
-
Body:
|
|
348
|
+
Body: combinedBody,
|
|
245
349
|
BodyForAgent: bodyForAgent,
|
|
350
|
+
InboundHistory: inboundHistory,
|
|
246
351
|
RawBody: rawBody,
|
|
247
352
|
CommandBody: rawBody,
|
|
248
353
|
From: `groupme:user:${message.senderId}`,
|
package/src/monitor.ts
CHANGED
|
@@ -1,54 +1,288 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { HistoryEntry } from "openclaw/plugin-sdk";
|
|
2
3
|
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
|
3
|
-
import {
|
|
4
|
-
|
|
4
|
+
import {
|
|
5
|
+
readJsonBodyWithLimit,
|
|
6
|
+
requestBodyErrorToText,
|
|
7
|
+
} from "openclaw/plugin-sdk";
|
|
8
|
+
import { resolveGroupMeHistoryLimit } from "./history.js";
|
|
5
9
|
import { handleGroupMeInbound } from "./inbound.js";
|
|
6
10
|
import { parseGroupMeCallback, shouldProcessCallback } from "./parse.js";
|
|
11
|
+
import { GroupMeRateLimiter } from "./rate-limit.js";
|
|
12
|
+
import { GroupMeReplayCache, buildReplayKey } from "./replay-cache.js";
|
|
13
|
+
import {
|
|
14
|
+
checkGroupBinding,
|
|
15
|
+
redactCallbackUrl,
|
|
16
|
+
resolveGroupMeSecurity,
|
|
17
|
+
validateProxyRequest,
|
|
18
|
+
type ResolvedGroupMeSecurity,
|
|
19
|
+
verifyCallbackAuth,
|
|
20
|
+
} from "./security.js";
|
|
21
|
+
import type {
|
|
22
|
+
CoreConfig,
|
|
23
|
+
ResolvedGroupMeAccount,
|
|
24
|
+
WebhookDecision,
|
|
25
|
+
} from "./types.js";
|
|
7
26
|
|
|
8
27
|
export type GroupMeWebhookHandlerParams = {
|
|
9
28
|
account: ResolvedGroupMeAccount;
|
|
10
29
|
config: CoreConfig;
|
|
11
30
|
runtime: RuntimeEnv;
|
|
12
|
-
statusSink?: (patch: {
|
|
31
|
+
statusSink?: (patch: {
|
|
32
|
+
lastInboundAt?: number;
|
|
33
|
+
lastOutboundAt?: number;
|
|
34
|
+
}) => void;
|
|
13
35
|
};
|
|
14
36
|
|
|
37
|
+
function rejectDecision(params: {
|
|
38
|
+
status: number;
|
|
39
|
+
reason: string;
|
|
40
|
+
logLevel?: "debug" | "warn";
|
|
41
|
+
}): WebhookDecision {
|
|
42
|
+
return {
|
|
43
|
+
kind: "reject",
|
|
44
|
+
status: params.status,
|
|
45
|
+
reason: params.reason,
|
|
46
|
+
logLevel: params.logLevel ?? "warn",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const STATUS_TEXT: Record<number, string> = {
|
|
51
|
+
400: "Bad Request",
|
|
52
|
+
401: "Unauthorized",
|
|
53
|
+
403: "Forbidden",
|
|
54
|
+
404: "Not Found",
|
|
55
|
+
405: "Method Not Allowed",
|
|
56
|
+
408: "Request Timeout",
|
|
57
|
+
413: "Payload Too Large",
|
|
58
|
+
429: "Too Many Requests",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function formatRejectionBody(status: number): string {
|
|
62
|
+
return STATUS_TEXT[status] ?? "rejected";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function asRequestBodyErrorCode(
|
|
66
|
+
value: string,
|
|
67
|
+
): "PAYLOAD_TOO_LARGE" | "REQUEST_BODY_TIMEOUT" | "CONNECTION_CLOSED" | null {
|
|
68
|
+
if (
|
|
69
|
+
value === "PAYLOAD_TOO_LARGE" ||
|
|
70
|
+
value === "REQUEST_BODY_TIMEOUT" ||
|
|
71
|
+
value === "CONNECTION_CLOSED"
|
|
72
|
+
) {
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function logWebhookRejection(params: {
|
|
79
|
+
runtime: RuntimeEnv;
|
|
80
|
+
security: ResolvedGroupMeSecurity;
|
|
81
|
+
decision: Extract<WebhookDecision, { kind: "reject" }>;
|
|
82
|
+
reqUrl: URL;
|
|
83
|
+
}) {
|
|
84
|
+
if (!params.security.logging.logRejectedRequests) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const url = params.security.logging.redactSecrets
|
|
88
|
+
? redactCallbackUrl(
|
|
89
|
+
`${params.reqUrl.pathname}${params.reqUrl.search}`,
|
|
90
|
+
params.security,
|
|
91
|
+
)
|
|
92
|
+
: `${params.reqUrl.pathname}${params.reqUrl.search}`;
|
|
93
|
+
const line = `groupme: webhook rejected (${params.decision.reason}) status=${params.decision.status} url=${url}`;
|
|
94
|
+
if (params.decision.logLevel === "warn") {
|
|
95
|
+
params.runtime.error?.(line);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
params.runtime.log?.(line);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function decideWebhookRequest(params: {
|
|
102
|
+
req: IncomingMessage;
|
|
103
|
+
security: ResolvedGroupMeSecurity;
|
|
104
|
+
replayCache: GroupMeReplayCache;
|
|
105
|
+
rateLimiter: GroupMeRateLimiter;
|
|
106
|
+
}): Promise<WebhookDecision> {
|
|
107
|
+
const reqUrl = new URL(params.req.url ?? "/", "http://localhost");
|
|
108
|
+
|
|
109
|
+
if (params.req.method !== "POST") {
|
|
110
|
+
return rejectDecision({
|
|
111
|
+
status: 405,
|
|
112
|
+
reason: "invalid_method",
|
|
113
|
+
logLevel: "debug",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const auth = verifyCallbackAuth({ url: reqUrl, security: params.security });
|
|
118
|
+
if (!auth.ok && auth.reason !== "disabled") {
|
|
119
|
+
return rejectDecision({
|
|
120
|
+
status: params.security.callbackRejectStatus,
|
|
121
|
+
reason: `auth_${auth.reason}`,
|
|
122
|
+
logLevel: "warn",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const proxyValidation = validateProxyRequest({
|
|
127
|
+
headers: params.req.headers,
|
|
128
|
+
remoteAddress: params.req.socket.remoteAddress ?? "",
|
|
129
|
+
socketEncrypted: Boolean((params.req.socket as { encrypted?: boolean }).encrypted),
|
|
130
|
+
security: params.security,
|
|
131
|
+
});
|
|
132
|
+
if (!proxyValidation.ok) {
|
|
133
|
+
return rejectDecision({
|
|
134
|
+
status: proxyValidation.status,
|
|
135
|
+
reason: `proxy_${proxyValidation.reason}`,
|
|
136
|
+
logLevel: "warn",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const body = await readJsonBodyWithLimit(params.req, {
|
|
141
|
+
maxBytes: 64 * 1024,
|
|
142
|
+
timeoutMs: 15_000,
|
|
143
|
+
emptyObjectOnEmpty: false,
|
|
144
|
+
});
|
|
145
|
+
if (!body.ok) {
|
|
146
|
+
let status: number;
|
|
147
|
+
if (body.code === "PAYLOAD_TOO_LARGE") {
|
|
148
|
+
status = 413;
|
|
149
|
+
} else if (body.code === "REQUEST_BODY_TIMEOUT") {
|
|
150
|
+
status = 408;
|
|
151
|
+
} else {
|
|
152
|
+
status = 400;
|
|
153
|
+
}
|
|
154
|
+
return rejectDecision({
|
|
155
|
+
status,
|
|
156
|
+
reason: `body_${body.code.toLowerCase()}`,
|
|
157
|
+
logLevel: "debug",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const message = parseGroupMeCallback(body.value);
|
|
162
|
+
if (!message) {
|
|
163
|
+
return rejectDecision({
|
|
164
|
+
status: 400,
|
|
165
|
+
reason: "parse_invalid_callback",
|
|
166
|
+
logLevel: "debug",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const ignoreReason = shouldProcessCallback(message);
|
|
171
|
+
if (ignoreReason) {
|
|
172
|
+
return rejectDecision({
|
|
173
|
+
status: 200,
|
|
174
|
+
reason: `ignore_${ignoreReason.replace(/\s+/g, "_")}`,
|
|
175
|
+
logLevel: "debug",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const groupBinding = checkGroupBinding({
|
|
180
|
+
groupId: params.security.groupId,
|
|
181
|
+
inboundGroupId: message.groupId,
|
|
182
|
+
});
|
|
183
|
+
if (!groupBinding.ok) {
|
|
184
|
+
return rejectDecision({
|
|
185
|
+
status: 403,
|
|
186
|
+
reason: "group_binding_mismatch",
|
|
187
|
+
logLevel: "warn",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (params.security.replay.enabled) {
|
|
192
|
+
const replay = params.replayCache.checkAndRemember(buildReplayKey(message));
|
|
193
|
+
if (replay.kind === "duplicate") {
|
|
194
|
+
return rejectDecision({
|
|
195
|
+
status: 200,
|
|
196
|
+
reason: "duplicate_replay",
|
|
197
|
+
logLevel: "debug",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!params.security.rateLimit.enabled) {
|
|
203
|
+
return { kind: "accept", message, release: () => undefined };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const rate = params.rateLimiter.evaluate({
|
|
207
|
+
ip: proxyValidation.context.clientIp,
|
|
208
|
+
senderId: message.senderId,
|
|
209
|
+
});
|
|
210
|
+
if (rate.kind === "rejected") {
|
|
211
|
+
return rejectDecision({
|
|
212
|
+
status: 429,
|
|
213
|
+
reason: `rate_limited_${rate.scope}`,
|
|
214
|
+
logLevel: "warn",
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { kind: "accept", message, release: rate.release };
|
|
219
|
+
}
|
|
220
|
+
|
|
15
221
|
export function createGroupMeWebhookHandler(
|
|
16
222
|
params: GroupMeWebhookHandlerParams,
|
|
17
223
|
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
224
|
+
const groupHistories = new Map<string, HistoryEntry[]>();
|
|
225
|
+
const historyLimit = resolveGroupMeHistoryLimit(
|
|
226
|
+
params.account.config.historyLimit,
|
|
227
|
+
);
|
|
228
|
+
const security = resolveGroupMeSecurity(params.account.config);
|
|
229
|
+
const replayCache = new GroupMeReplayCache({
|
|
230
|
+
ttlSeconds: security.replay.ttlSeconds,
|
|
231
|
+
maxEntries: security.replay.maxEntries,
|
|
232
|
+
});
|
|
233
|
+
const rateLimiter = new GroupMeRateLimiter({
|
|
234
|
+
windowMs: security.rateLimit.windowMs,
|
|
235
|
+
maxRequestsPerIp: security.rateLimit.maxRequestsPerIp,
|
|
236
|
+
maxRequestsPerSender: security.rateLimit.maxRequestsPerSender,
|
|
237
|
+
maxConcurrent: security.rateLimit.maxConcurrent,
|
|
238
|
+
});
|
|
25
239
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
240
|
+
return async (req, res) => {
|
|
241
|
+
const decision = await decideWebhookRequest({
|
|
242
|
+
req,
|
|
243
|
+
security,
|
|
244
|
+
replayCache,
|
|
245
|
+
rateLimiter,
|
|
30
246
|
});
|
|
31
|
-
if (!body.ok) {
|
|
32
|
-
res.statusCode =
|
|
33
|
-
body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
|
|
34
|
-
res.end(body.code === "INVALID_JSON" ? body.error : requestBodyErrorToText(body.code));
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
247
|
|
|
38
|
-
|
|
39
|
-
|
|
248
|
+
if (decision.kind === "reject") {
|
|
249
|
+
const reqUrl = new URL(req.url ?? "/", "http://localhost");
|
|
250
|
+
logWebhookRejection({
|
|
251
|
+
runtime: params.runtime,
|
|
252
|
+
security,
|
|
253
|
+
decision,
|
|
254
|
+
reqUrl,
|
|
255
|
+
});
|
|
40
256
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
257
|
+
if (decision.status === 405) {
|
|
258
|
+
res.setHeader("Allow", "POST");
|
|
259
|
+
}
|
|
260
|
+
res.statusCode = decision.status;
|
|
261
|
+
if (decision.status === 200) {
|
|
262
|
+
res.end("ok");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (decision.reason.startsWith("body_")) {
|
|
266
|
+
const code = decision.reason.slice("body_".length).toUpperCase();
|
|
267
|
+
if (code === "INVALID_JSON") {
|
|
268
|
+
res.end("Invalid JSON");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const requestBodyErrorCode = asRequestBodyErrorCode(code);
|
|
272
|
+
if (requestBodyErrorCode) {
|
|
273
|
+
res.end(
|
|
274
|
+
requestBodyErrorToText(requestBodyErrorCode),
|
|
275
|
+
);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
res.end(formatRejectionBody(decision.status));
|
|
44
280
|
return;
|
|
45
281
|
}
|
|
46
282
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
283
|
+
const { message, release } = decision;
|
|
284
|
+
res.statusCode = 200;
|
|
285
|
+
res.end("ok");
|
|
52
286
|
|
|
53
287
|
void handleGroupMeInbound({
|
|
54
288
|
message,
|
|
@@ -56,8 +290,16 @@ export function createGroupMeWebhookHandler(
|
|
|
56
290
|
config: params.config,
|
|
57
291
|
runtime: params.runtime,
|
|
58
292
|
statusSink: params.statusSink,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
})
|
|
293
|
+
groupHistories,
|
|
294
|
+
historyLimit,
|
|
295
|
+
})
|
|
296
|
+
.catch((err) => {
|
|
297
|
+
params.runtime.error?.(
|
|
298
|
+
`groupme: inbound processing failed: ${String(err)}`,
|
|
299
|
+
);
|
|
300
|
+
})
|
|
301
|
+
.finally(() => {
|
|
302
|
+
release();
|
|
303
|
+
});
|
|
62
304
|
};
|
|
63
305
|
}
|
package/src/normalize.ts
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
function normalizeStringId(raw: string | number): string | undefined {
|
|
1
|
+
export function normalizeStringId(raw: string | number): string | undefined {
|
|
2
2
|
const normalized = String(raw).trim();
|
|
3
3
|
return normalized || undefined;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
export function normalizeGroupMeUserId(raw: string | number): string | undefined {
|
|
7
|
-
return normalizeStringId(raw);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function normalizeGroupMeGroupId(raw: string | number): string | undefined {
|
|
11
|
-
return normalizeStringId(raw);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
6
|
const TARGET_PREFIX_RE = /^(groupme:)?(user:|group:)?/i;
|
|
15
7
|
|
|
16
8
|
export function normalizeGroupMeTarget(raw: string): string | undefined {
|