openclaw-groupme 0.4.2 → 0.4.4
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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const DEFAULT_GROUPME_HISTORY_LIMIT = 20;
|
|
2
|
+
export function resolveGroupMeHistoryLimit(configured) {
|
|
3
|
+
if (!Number.isFinite(configured)) {
|
|
4
|
+
return DEFAULT_GROUPME_HISTORY_LIMIT;
|
|
5
|
+
}
|
|
6
|
+
const normalized = Math.floor(configured);
|
|
7
|
+
if (normalized < 0) {
|
|
8
|
+
return DEFAULT_GROUPME_HISTORY_LIMIT;
|
|
9
|
+
}
|
|
10
|
+
return normalized;
|
|
11
|
+
}
|
|
12
|
+
export function resolveGroupMeBodyForAgent(params) {
|
|
13
|
+
const { rawBody, imageUrls } = params;
|
|
14
|
+
const trimmed = rawBody.trim();
|
|
15
|
+
if (trimmed) {
|
|
16
|
+
return trimmed;
|
|
17
|
+
}
|
|
18
|
+
if (imageUrls.length > 0) {
|
|
19
|
+
return imageUrls.map((url) => `Image: ${url}`).join("\n");
|
|
20
|
+
}
|
|
21
|
+
return rawBody;
|
|
22
|
+
}
|
|
23
|
+
export function buildGroupMeHistoryEntry(params) {
|
|
24
|
+
const body = params.body.trim();
|
|
25
|
+
if (!body) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
sender: params.senderName,
|
|
30
|
+
body,
|
|
31
|
+
timestamp: params.timestamp,
|
|
32
|
+
messageId: params.messageId,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function formatGroupMeHistoryEntry(entry) {
|
|
36
|
+
return `${entry.sender}: ${entry.body}`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, createReplyPrefixOptions, logInboundDrop, recordPendingHistoryEntryIfEnabled, resolveControlCommandGate, resolveMentionGatingWithBypass, } from "openclaw/plugin-sdk";
|
|
2
|
+
import { buildGroupMeHistoryEntry, formatGroupMeHistoryEntry, resolveGroupMeBodyForAgent, } from "./history.js";
|
|
3
|
+
import { extractImageUrls, detectGroupMeMention } from "./parse.js";
|
|
4
|
+
import { resolveSenderAccess } from "./policy.js";
|
|
5
|
+
import { getGroupMeRuntime } from "./runtime.js";
|
|
6
|
+
import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText, } from "./send.js";
|
|
7
|
+
import { resolveGroupMeSecurity } from "./security.js";
|
|
8
|
+
const CHANNEL_ID = "groupme";
|
|
9
|
+
function resolveTextChunkLimit(account) {
|
|
10
|
+
const configured = account.config.textChunkLimit;
|
|
11
|
+
if (!Number.isFinite(configured)) {
|
|
12
|
+
return GROUPME_MAX_TEXT_LENGTH;
|
|
13
|
+
}
|
|
14
|
+
const value = Math.floor(configured);
|
|
15
|
+
if (value <= 0) {
|
|
16
|
+
return GROUPME_MAX_TEXT_LENGTH;
|
|
17
|
+
}
|
|
18
|
+
return Math.min(value, GROUPME_MAX_TEXT_LENGTH);
|
|
19
|
+
}
|
|
20
|
+
function chunkReplyText(params) {
|
|
21
|
+
const trimmed = params.text.trim();
|
|
22
|
+
if (!trimmed) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return params.core.channel.text
|
|
26
|
+
.chunkMarkdownText(trimmed, params.limit)
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
async function deliverGroupMeReply(params) {
|
|
30
|
+
const { payload, account, cfg, target, statusSink } = params;
|
|
31
|
+
const core = getGroupMeRuntime();
|
|
32
|
+
const text = payload.text ?? "";
|
|
33
|
+
const mediaUrls = payload.mediaUrls?.length
|
|
34
|
+
? payload.mediaUrls
|
|
35
|
+
: payload.mediaUrl
|
|
36
|
+
? [payload.mediaUrl]
|
|
37
|
+
: [];
|
|
38
|
+
if (!text.trim() && mediaUrls.length === 0) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const chunks = chunkReplyText({
|
|
42
|
+
text,
|
|
43
|
+
limit: resolveTextChunkLimit(account),
|
|
44
|
+
core,
|
|
45
|
+
});
|
|
46
|
+
const sendTextChunk = async (chunk) => {
|
|
47
|
+
await sendGroupMeText({
|
|
48
|
+
cfg,
|
|
49
|
+
to: target,
|
|
50
|
+
text: chunk,
|
|
51
|
+
accountId: account.accountId,
|
|
52
|
+
});
|
|
53
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
54
|
+
core.channel.activity.record({
|
|
55
|
+
channel: CHANNEL_ID,
|
|
56
|
+
accountId: account.accountId,
|
|
57
|
+
direction: "outbound",
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
if (mediaUrls.length === 0) {
|
|
61
|
+
for (const chunk of chunks) {
|
|
62
|
+
await sendTextChunk(chunk);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const [firstMedia, ...restMedia] = mediaUrls;
|
|
67
|
+
const [firstChunk, ...restChunks] = chunks;
|
|
68
|
+
await sendGroupMeMedia({
|
|
69
|
+
cfg,
|
|
70
|
+
to: target,
|
|
71
|
+
text: firstChunk ?? "",
|
|
72
|
+
mediaUrl: firstMedia,
|
|
73
|
+
accountId: account.accountId,
|
|
74
|
+
});
|
|
75
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
76
|
+
core.channel.activity.record({
|
|
77
|
+
channel: CHANNEL_ID,
|
|
78
|
+
accountId: account.accountId,
|
|
79
|
+
direction: "outbound",
|
|
80
|
+
});
|
|
81
|
+
for (const chunk of restChunks) {
|
|
82
|
+
await sendTextChunk(chunk);
|
|
83
|
+
}
|
|
84
|
+
for (const mediaUrl of restMedia) {
|
|
85
|
+
await sendGroupMeMedia({
|
|
86
|
+
cfg,
|
|
87
|
+
to: target,
|
|
88
|
+
text: "",
|
|
89
|
+
mediaUrl,
|
|
90
|
+
accountId: account.accountId,
|
|
91
|
+
});
|
|
92
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
93
|
+
core.channel.activity.record({
|
|
94
|
+
channel: CHANNEL_ID,
|
|
95
|
+
accountId: account.accountId,
|
|
96
|
+
direction: "outbound",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export async function handleGroupMeInbound(params) {
|
|
101
|
+
const { message, account, config, runtime, groupHistories, historyLimit, statusSink, } = params;
|
|
102
|
+
const core = getGroupMeRuntime();
|
|
103
|
+
const inboundTimestamp = message.createdAt * 1000;
|
|
104
|
+
statusSink?.({ lastInboundAt: inboundTimestamp });
|
|
105
|
+
core.channel.activity.record({
|
|
106
|
+
channel: CHANNEL_ID,
|
|
107
|
+
accountId: account.accountId,
|
|
108
|
+
direction: "inbound",
|
|
109
|
+
at: inboundTimestamp,
|
|
110
|
+
});
|
|
111
|
+
const allowFrom = account.config.allowFrom ?? [];
|
|
112
|
+
const security = resolveGroupMeSecurity(account.config);
|
|
113
|
+
const senderAllowed = resolveSenderAccess({
|
|
114
|
+
senderId: message.senderId,
|
|
115
|
+
allowFrom,
|
|
116
|
+
});
|
|
117
|
+
if (!senderAllowed) {
|
|
118
|
+
runtime.log?.(`groupme: drop sender ${message.senderId} (not in allowFrom)`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
122
|
+
cfg: config,
|
|
123
|
+
channel: CHANNEL_ID,
|
|
124
|
+
accountId: account.accountId,
|
|
125
|
+
peer: {
|
|
126
|
+
kind: "group",
|
|
127
|
+
id: message.groupId,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
|
|
131
|
+
const requireMention = account.config.requireMention ?? true;
|
|
132
|
+
const wasMentioned = detectGroupMeMention({
|
|
133
|
+
text: message.text,
|
|
134
|
+
botName: account.config.botName,
|
|
135
|
+
channelMentionPatterns: account.config.mentionPatterns,
|
|
136
|
+
mentionRegexes,
|
|
137
|
+
});
|
|
138
|
+
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
|
139
|
+
cfg: config,
|
|
140
|
+
surface: CHANNEL_ID,
|
|
141
|
+
});
|
|
142
|
+
const hasControlCommand = core.channel.text.hasControlCommand(message.text, config);
|
|
143
|
+
const commandBypassNeedsAllowFrom = security.commandBypass.requireAllowFrom && hasControlCommand;
|
|
144
|
+
const commandBypassCanSkipMention = !(security.commandBypass.requireMentionForCommands &&
|
|
145
|
+
requireMention &&
|
|
146
|
+
hasControlCommand);
|
|
147
|
+
const commandGate = resolveControlCommandGate({
|
|
148
|
+
useAccessGroups: config.commands?.useAccessGroups !== false || commandBypassNeedsAllowFrom,
|
|
149
|
+
authorizers: [{ configured: allowFrom.length > 0, allowed: senderAllowed }],
|
|
150
|
+
allowTextCommands,
|
|
151
|
+
hasControlCommand,
|
|
152
|
+
});
|
|
153
|
+
if (commandGate.shouldBlock) {
|
|
154
|
+
logInboundDrop({
|
|
155
|
+
log: (line) => runtime.log?.(line),
|
|
156
|
+
channel: CHANNEL_ID,
|
|
157
|
+
reason: "control command (unauthorized)",
|
|
158
|
+
target: message.senderId,
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const mentionGate = resolveMentionGatingWithBypass({
|
|
163
|
+
isGroup: true,
|
|
164
|
+
requireMention,
|
|
165
|
+
canDetectMention: true,
|
|
166
|
+
wasMentioned,
|
|
167
|
+
hasAnyMention: false,
|
|
168
|
+
allowTextCommands,
|
|
169
|
+
hasControlCommand: commandBypassCanSkipMention ? hasControlCommand : false,
|
|
170
|
+
commandAuthorized: commandBypassCanSkipMention
|
|
171
|
+
? commandGate.commandAuthorized
|
|
172
|
+
: false,
|
|
173
|
+
});
|
|
174
|
+
const imageUrls = extractImageUrls(message.attachments);
|
|
175
|
+
const rawBody = message.text;
|
|
176
|
+
const bodyForAgent = resolveGroupMeBodyForAgent({
|
|
177
|
+
rawBody,
|
|
178
|
+
imageUrls,
|
|
179
|
+
});
|
|
180
|
+
if (mentionGate.shouldSkip) {
|
|
181
|
+
const buffered = recordPendingHistoryEntryIfEnabled({
|
|
182
|
+
historyMap: groupHistories,
|
|
183
|
+
historyKey: message.groupId,
|
|
184
|
+
limit: historyLimit,
|
|
185
|
+
entry: buildGroupMeHistoryEntry({
|
|
186
|
+
senderName: message.name,
|
|
187
|
+
body: bodyForAgent,
|
|
188
|
+
timestamp: inboundTimestamp,
|
|
189
|
+
messageId: message.id,
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
if (buffered.length > 0) {
|
|
193
|
+
runtime.log?.(`groupme: buffered message from ${message.name} (${buffered.length}/${historyLimit})`);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
runtime.log?.("groupme: skip message (mention required, not mentioned)");
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
201
|
+
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
202
|
+
agentId: route.agentId,
|
|
203
|
+
});
|
|
204
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
205
|
+
storePath,
|
|
206
|
+
sessionKey: route.sessionKey,
|
|
207
|
+
});
|
|
208
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
209
|
+
channel: "GroupMe",
|
|
210
|
+
from: message.name,
|
|
211
|
+
timestamp: inboundTimestamp,
|
|
212
|
+
previousTimestamp,
|
|
213
|
+
envelope: envelopeOptions,
|
|
214
|
+
body: bodyForAgent,
|
|
215
|
+
});
|
|
216
|
+
const shouldUseHistoryBuffer = requireMention && historyLimit > 0;
|
|
217
|
+
const historyEntriesForContext = shouldUseHistoryBuffer
|
|
218
|
+
? [...(groupHistories.get(message.groupId) ?? [])]
|
|
219
|
+
: [];
|
|
220
|
+
if (shouldUseHistoryBuffer) {
|
|
221
|
+
clearHistoryEntriesIfEnabled({
|
|
222
|
+
historyMap: groupHistories,
|
|
223
|
+
historyKey: message.groupId,
|
|
224
|
+
limit: historyLimit,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const combinedBody = shouldUseHistoryBuffer
|
|
228
|
+
? buildPendingHistoryContextFromMap({
|
|
229
|
+
historyMap: new Map([[message.groupId, historyEntriesForContext]]),
|
|
230
|
+
historyKey: message.groupId,
|
|
231
|
+
limit: historyLimit,
|
|
232
|
+
currentMessage: body,
|
|
233
|
+
formatEntry: formatGroupMeHistoryEntry,
|
|
234
|
+
})
|
|
235
|
+
: body;
|
|
236
|
+
const inboundHistory = shouldUseHistoryBuffer
|
|
237
|
+
? historyEntriesForContext.map((entry) => ({
|
|
238
|
+
sender: entry.sender,
|
|
239
|
+
body: entry.body,
|
|
240
|
+
timestamp: entry.timestamp,
|
|
241
|
+
}))
|
|
242
|
+
: undefined;
|
|
243
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
244
|
+
Body: combinedBody,
|
|
245
|
+
BodyForAgent: bodyForAgent,
|
|
246
|
+
InboundHistory: inboundHistory,
|
|
247
|
+
RawBody: rawBody,
|
|
248
|
+
CommandBody: rawBody,
|
|
249
|
+
From: `groupme:user:${message.senderId}`,
|
|
250
|
+
To: `groupme:group:${message.groupId}`,
|
|
251
|
+
SessionKey: route.sessionKey,
|
|
252
|
+
AccountId: route.accountId,
|
|
253
|
+
ChatType: "group",
|
|
254
|
+
ConversationLabel: `groupme:${message.groupId}`,
|
|
255
|
+
SenderName: message.name,
|
|
256
|
+
SenderId: message.senderId,
|
|
257
|
+
Provider: CHANNEL_ID,
|
|
258
|
+
Surface: CHANNEL_ID,
|
|
259
|
+
WasMentioned: mentionGate.effectiveWasMentioned,
|
|
260
|
+
MessageSid: message.id,
|
|
261
|
+
Timestamp: inboundTimestamp,
|
|
262
|
+
OriginatingChannel: CHANNEL_ID,
|
|
263
|
+
OriginatingTo: `groupme:group:${message.groupId}`,
|
|
264
|
+
GroupSpace: message.groupId,
|
|
265
|
+
CommandAuthorized: commandGate.commandAuthorized,
|
|
266
|
+
MediaUrl: imageUrls[0],
|
|
267
|
+
MediaUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
268
|
+
});
|
|
269
|
+
await core.channel.session.recordInboundSession({
|
|
270
|
+
storePath,
|
|
271
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
272
|
+
ctx: ctxPayload,
|
|
273
|
+
onRecordError: (err) => {
|
|
274
|
+
runtime.error?.(`groupme: failed updating session meta: ${String(err)}`);
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
278
|
+
cfg: config,
|
|
279
|
+
agentId: route.agentId,
|
|
280
|
+
channel: CHANNEL_ID,
|
|
281
|
+
accountId: account.accountId,
|
|
282
|
+
});
|
|
283
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
284
|
+
ctx: ctxPayload,
|
|
285
|
+
cfg: config,
|
|
286
|
+
dispatcherOptions: {
|
|
287
|
+
...prefixOptions,
|
|
288
|
+
deliver: async (payload) => {
|
|
289
|
+
await deliverGroupMeReply({
|
|
290
|
+
payload,
|
|
291
|
+
account,
|
|
292
|
+
cfg: config,
|
|
293
|
+
target: `groupme:group:${message.groupId}`,
|
|
294
|
+
statusSink,
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
onError: (err, info) => {
|
|
298
|
+
runtime.error?.(`groupme ${info.kind} reply failed: ${String(err)}`);
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
replyOptions: {
|
|
302
|
+
onModelSelected,
|
|
303
|
+
disableBlockStreaming: typeof account.config.blockStreaming === "boolean"
|
|
304
|
+
? !account.config.blockStreaming
|
|
305
|
+
: undefined,
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { readJsonBodyWithLimit, requestBodyErrorToText, } from "openclaw/plugin-sdk";
|
|
2
|
+
import { resolveGroupMeHistoryLimit } from "./history.js";
|
|
3
|
+
import { handleGroupMeInbound } from "./inbound.js";
|
|
4
|
+
import { parseGroupMeCallback, shouldProcessCallback } from "./parse.js";
|
|
5
|
+
import { GroupMeRateLimiter } from "./rate-limit.js";
|
|
6
|
+
import { GroupMeReplayCache, buildReplayKey } from "./replay-cache.js";
|
|
7
|
+
import { checkGroupBinding, redactCallbackUrl, resolveGroupMeSecurity, validateProxyRequest, verifyCallbackAuth, } from "./security.js";
|
|
8
|
+
// GroupMe callbacks are small JSON payloads; use tighter limits than the SDK
|
|
9
|
+
// defaults (DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1 MB, DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30 s).
|
|
10
|
+
const GROUPME_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
|
|
11
|
+
const GROUPME_WEBHOOK_BODY_TIMEOUT_MS = 15_000;
|
|
12
|
+
function rejectDecision(params) {
|
|
13
|
+
return {
|
|
14
|
+
kind: "reject",
|
|
15
|
+
status: params.status,
|
|
16
|
+
reason: params.reason,
|
|
17
|
+
logLevel: params.logLevel ?? "warn",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const STATUS_TEXT = {
|
|
21
|
+
400: "Bad Request",
|
|
22
|
+
401: "Unauthorized",
|
|
23
|
+
403: "Forbidden",
|
|
24
|
+
404: "Not Found",
|
|
25
|
+
405: "Method Not Allowed",
|
|
26
|
+
408: "Request Timeout",
|
|
27
|
+
413: "Payload Too Large",
|
|
28
|
+
429: "Too Many Requests",
|
|
29
|
+
};
|
|
30
|
+
function formatRejectionBody(status) {
|
|
31
|
+
return STATUS_TEXT[status] ?? "rejected";
|
|
32
|
+
}
|
|
33
|
+
function asRequestBodyErrorCode(value) {
|
|
34
|
+
if (value === "PAYLOAD_TOO_LARGE" ||
|
|
35
|
+
value === "REQUEST_BODY_TIMEOUT" ||
|
|
36
|
+
value === "CONNECTION_CLOSED") {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function logWebhookRejection(params) {
|
|
42
|
+
if (!params.security.logging.logRejectedRequests) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const url = params.security.logging.redactSecrets
|
|
46
|
+
? redactCallbackUrl(`${params.reqUrl.pathname}${params.reqUrl.search}`, params.security)
|
|
47
|
+
: `${params.reqUrl.pathname}${params.reqUrl.search}`;
|
|
48
|
+
const line = `groupme: webhook rejected (${params.decision.reason}) status=${params.decision.status} url=${url}`;
|
|
49
|
+
if (params.decision.logLevel === "warn") {
|
|
50
|
+
params.runtime.error?.(line);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
params.runtime.log?.(line);
|
|
54
|
+
}
|
|
55
|
+
async function decideWebhookRequest(params) {
|
|
56
|
+
const reqUrl = new URL(params.req.url ?? "/", "http://localhost");
|
|
57
|
+
if (params.req.method !== "POST") {
|
|
58
|
+
return rejectDecision({
|
|
59
|
+
status: 405,
|
|
60
|
+
reason: "invalid_method",
|
|
61
|
+
logLevel: "debug",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const auth = verifyCallbackAuth({ url: reqUrl, security: params.security });
|
|
65
|
+
if (!auth.ok && auth.reason !== "disabled") {
|
|
66
|
+
return rejectDecision({
|
|
67
|
+
status: params.security.callbackRejectStatus,
|
|
68
|
+
reason: `auth_${auth.reason}`,
|
|
69
|
+
logLevel: "warn",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const proxyValidation = validateProxyRequest({
|
|
73
|
+
headers: params.req.headers,
|
|
74
|
+
remoteAddress: params.req.socket.remoteAddress ?? "",
|
|
75
|
+
socketEncrypted: Boolean(params.req.socket.encrypted),
|
|
76
|
+
security: params.security,
|
|
77
|
+
});
|
|
78
|
+
if (!proxyValidation.ok) {
|
|
79
|
+
return rejectDecision({
|
|
80
|
+
status: proxyValidation.status,
|
|
81
|
+
reason: `proxy_${proxyValidation.reason}`,
|
|
82
|
+
logLevel: "warn",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const body = await readJsonBodyWithLimit(params.req, {
|
|
86
|
+
maxBytes: GROUPME_WEBHOOK_MAX_BODY_BYTES,
|
|
87
|
+
timeoutMs: GROUPME_WEBHOOK_BODY_TIMEOUT_MS,
|
|
88
|
+
emptyObjectOnEmpty: false,
|
|
89
|
+
});
|
|
90
|
+
if (!body.ok) {
|
|
91
|
+
let status;
|
|
92
|
+
if (body.code === "PAYLOAD_TOO_LARGE") {
|
|
93
|
+
status = 413;
|
|
94
|
+
}
|
|
95
|
+
else if (body.code === "REQUEST_BODY_TIMEOUT") {
|
|
96
|
+
status = 408;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
status = 400;
|
|
100
|
+
}
|
|
101
|
+
return rejectDecision({
|
|
102
|
+
status,
|
|
103
|
+
reason: `body_${body.code.toLowerCase()}`,
|
|
104
|
+
logLevel: "debug",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const message = parseGroupMeCallback(body.value);
|
|
108
|
+
if (!message) {
|
|
109
|
+
return rejectDecision({
|
|
110
|
+
status: 400,
|
|
111
|
+
reason: "parse_invalid_callback",
|
|
112
|
+
logLevel: "debug",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const ignoreReason = shouldProcessCallback(message);
|
|
116
|
+
if (ignoreReason) {
|
|
117
|
+
return rejectDecision({
|
|
118
|
+
status: 200,
|
|
119
|
+
reason: `ignore_${ignoreReason.replace(/\s+/g, "_")}`,
|
|
120
|
+
logLevel: "debug",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const groupBinding = checkGroupBinding({
|
|
124
|
+
groupId: params.security.groupId,
|
|
125
|
+
inboundGroupId: message.groupId,
|
|
126
|
+
});
|
|
127
|
+
if (!groupBinding.ok) {
|
|
128
|
+
return rejectDecision({
|
|
129
|
+
status: 403,
|
|
130
|
+
reason: "group_binding_mismatch",
|
|
131
|
+
logLevel: "warn",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (params.security.replay.enabled) {
|
|
135
|
+
const replay = params.replayCache.checkAndRemember(buildReplayKey(message));
|
|
136
|
+
if (replay.kind === "duplicate") {
|
|
137
|
+
return rejectDecision({
|
|
138
|
+
status: 200,
|
|
139
|
+
reason: "duplicate_replay",
|
|
140
|
+
logLevel: "debug",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!params.security.rateLimit.enabled) {
|
|
145
|
+
return { kind: "accept", message, release: () => undefined };
|
|
146
|
+
}
|
|
147
|
+
const rate = params.rateLimiter.evaluate({
|
|
148
|
+
ip: proxyValidation.context.clientIp,
|
|
149
|
+
senderId: message.senderId,
|
|
150
|
+
});
|
|
151
|
+
if (rate.kind === "rejected") {
|
|
152
|
+
return rejectDecision({
|
|
153
|
+
status: 429,
|
|
154
|
+
reason: `rate_limited_${rate.scope}`,
|
|
155
|
+
logLevel: "warn",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return { kind: "accept", message, release: rate.release };
|
|
159
|
+
}
|
|
160
|
+
export function createGroupMeWebhookHandler(params) {
|
|
161
|
+
const groupHistories = new Map();
|
|
162
|
+
const historyLimit = resolveGroupMeHistoryLimit(params.account.config.historyLimit);
|
|
163
|
+
const security = resolveGroupMeSecurity(params.account.config);
|
|
164
|
+
if (!security.groupId) {
|
|
165
|
+
params.runtime.error?.("groupme: WARNING — no groupId configured; all inbound messages will be rejected. Set groupId in your account config.");
|
|
166
|
+
}
|
|
167
|
+
const replayCache = new GroupMeReplayCache({
|
|
168
|
+
ttlSeconds: security.replay.ttlSeconds,
|
|
169
|
+
maxEntries: security.replay.maxEntries,
|
|
170
|
+
});
|
|
171
|
+
const rateLimiter = new GroupMeRateLimiter({
|
|
172
|
+
windowMs: security.rateLimit.windowMs,
|
|
173
|
+
maxRequestsPerIp: security.rateLimit.maxRequestsPerIp,
|
|
174
|
+
maxRequestsPerSender: security.rateLimit.maxRequestsPerSender,
|
|
175
|
+
maxConcurrent: security.rateLimit.maxConcurrent,
|
|
176
|
+
});
|
|
177
|
+
return async (req, res) => {
|
|
178
|
+
const decision = await decideWebhookRequest({
|
|
179
|
+
req,
|
|
180
|
+
security,
|
|
181
|
+
replayCache,
|
|
182
|
+
rateLimiter,
|
|
183
|
+
});
|
|
184
|
+
if (decision.kind === "reject") {
|
|
185
|
+
const reqUrl = new URL(req.url ?? "/", "http://localhost");
|
|
186
|
+
logWebhookRejection({
|
|
187
|
+
runtime: params.runtime,
|
|
188
|
+
security,
|
|
189
|
+
decision,
|
|
190
|
+
reqUrl,
|
|
191
|
+
});
|
|
192
|
+
if (decision.status === 405) {
|
|
193
|
+
res.setHeader("Allow", "POST");
|
|
194
|
+
}
|
|
195
|
+
res.statusCode = decision.status;
|
|
196
|
+
if (decision.status === 200) {
|
|
197
|
+
res.end("ok");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (decision.reason.startsWith("body_")) {
|
|
201
|
+
const code = decision.reason.slice("body_".length).toUpperCase();
|
|
202
|
+
if (code === "INVALID_JSON") {
|
|
203
|
+
res.end("Invalid JSON");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const requestBodyErrorCode = asRequestBodyErrorCode(code);
|
|
207
|
+
if (requestBodyErrorCode) {
|
|
208
|
+
res.end(requestBodyErrorToText(requestBodyErrorCode));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
res.end(formatRejectionBody(decision.status));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const { message, release } = decision;
|
|
216
|
+
res.statusCode = 200;
|
|
217
|
+
res.end("ok");
|
|
218
|
+
void handleGroupMeInbound({
|
|
219
|
+
message,
|
|
220
|
+
account: params.account,
|
|
221
|
+
config: params.config,
|
|
222
|
+
runtime: params.runtime,
|
|
223
|
+
statusSink: params.statusSink,
|
|
224
|
+
groupHistories,
|
|
225
|
+
historyLimit,
|
|
226
|
+
})
|
|
227
|
+
.catch((err) => {
|
|
228
|
+
params.runtime.error?.(`groupme: inbound processing failed: ${String(err)}`);
|
|
229
|
+
})
|
|
230
|
+
.finally(() => {
|
|
231
|
+
release();
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function normalizeStringId(raw) {
|
|
2
|
+
const normalized = String(raw).trim();
|
|
3
|
+
return normalized || undefined;
|
|
4
|
+
}
|
|
5
|
+
const TARGET_PREFIX_RE = /^(groupme:)?(user:|group:)?/i;
|
|
6
|
+
export function normalizeGroupMeTarget(raw) {
|
|
7
|
+
const trimmed = raw.trim();
|
|
8
|
+
if (!trimmed) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
const stripped = trimmed.replace(TARGET_PREFIX_RE, "").trim();
|
|
12
|
+
return stripped || undefined;
|
|
13
|
+
}
|
|
14
|
+
export function normalizeGroupMeAllowEntry(raw) {
|
|
15
|
+
const normalized = normalizeGroupMeTarget(raw);
|
|
16
|
+
if (!normalized) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
return normalized.toLowerCase() === "*" ? "*" : normalized;
|
|
20
|
+
}
|
|
21
|
+
export function looksLikeGroupMeTargetId(raw) {
|
|
22
|
+
const normalized = normalizeGroupMeTarget(raw);
|
|
23
|
+
if (!normalized) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (normalized === "*") {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return !/\s/.test(normalized);
|
|
30
|
+
}
|