openclaw-groupme 0.0.4 → 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/onboarding.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import type { ChannelOnboardingAdapter } from "openclaw/plugin-sdk";
|
|
2
3
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
4
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
|
4
|
-
import type { CoreConfig, GroupMeConfig } from "./types.js";
|
|
5
5
|
import { resolveGroupMeAccount } from "./accounts.js";
|
|
6
|
+
import { createBot, fetchGroups } from "./groupme-api.js";
|
|
7
|
+
import type { CoreConfig, GroupMeConfig } from "./types.js";
|
|
6
8
|
|
|
7
9
|
function applyGroupMeConfig(params: {
|
|
8
10
|
cfg: OpenClawConfig;
|
|
@@ -46,6 +48,13 @@ function applyGroupMeConfig(params: {
|
|
|
46
48
|
};
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
function redactMiddle(value: string): string {
|
|
52
|
+
if (value.length <= 10) {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
return `${value.slice(0, 6)}...${value.slice(-3)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
49
58
|
export const groupmeOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
50
59
|
channel: "groupme",
|
|
51
60
|
getStatus: async ({ cfg, accountOverrides }) => {
|
|
@@ -56,72 +65,164 @@ export const groupmeOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
56
65
|
});
|
|
57
66
|
|
|
58
67
|
const configured = account.configured;
|
|
68
|
+
const callbackUrlConfigured = Boolean(account.config.callbackUrl?.trim());
|
|
69
|
+
const groupIdConfigured = Boolean(account.config.groupId?.trim());
|
|
70
|
+
const publicDomainConfigured = Boolean(account.config.publicDomain?.trim());
|
|
71
|
+
|
|
59
72
|
return {
|
|
60
73
|
channel: "groupme",
|
|
61
74
|
configured,
|
|
62
75
|
statusLines: [
|
|
63
|
-
`GroupMe (${accountId}): ${configured ? "configured" : "needs
|
|
76
|
+
`GroupMe (${accountId}): ${configured ? "configured" : "needs access token"}`,
|
|
64
77
|
account.config.accessToken?.trim()
|
|
65
78
|
? "Access token configured"
|
|
66
|
-
: "Access token missing
|
|
79
|
+
: "Access token missing",
|
|
80
|
+
callbackUrlConfigured
|
|
81
|
+
? "Webhook callback URL configured"
|
|
82
|
+
: "Webhook callback URL missing",
|
|
83
|
+
publicDomainConfigured
|
|
84
|
+
? "Public domain configured"
|
|
85
|
+
: "Public domain missing",
|
|
86
|
+
groupIdConfigured ? "Group ID configured" : "Group ID missing",
|
|
67
87
|
],
|
|
68
|
-
selectionHint: configured ? "configured" : "needs
|
|
88
|
+
selectionHint: configured ? "configured" : "needs access token",
|
|
69
89
|
quickstartScore: configured ? 1 : 0,
|
|
70
90
|
};
|
|
71
91
|
},
|
|
72
92
|
configure: async ({ cfg, prompter, accountOverrides }) => {
|
|
73
93
|
const accountId = accountOverrides.groupme ?? DEFAULT_ACCOUNT_ID;
|
|
74
94
|
|
|
75
|
-
|
|
76
|
-
[
|
|
77
|
-
"GroupMe bots are bound to a single group.",
|
|
78
|
-
"Create a bot at https://dev.groupme.com/bots and copy bot_id + access token.",
|
|
79
|
-
].join("\n"),
|
|
80
|
-
"GroupMe setup",
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
const botId = (
|
|
95
|
+
const botNameInput = (
|
|
84
96
|
await prompter.text({
|
|
85
|
-
message: "Bot
|
|
86
|
-
|
|
97
|
+
message: "Bot name",
|
|
98
|
+
initialValue: "openclaw",
|
|
87
99
|
})
|
|
88
100
|
).trim();
|
|
101
|
+
const botName = botNameInput || "openclaw";
|
|
89
102
|
|
|
90
103
|
const accessToken = (
|
|
91
104
|
await prompter.text({
|
|
92
|
-
message: "
|
|
93
|
-
validate: (value) =>
|
|
105
|
+
message: "GroupMe access token",
|
|
106
|
+
validate: (value) =>
|
|
107
|
+
value.trim() ? undefined : "Access token is required",
|
|
94
108
|
})
|
|
95
109
|
).trim();
|
|
96
110
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
111
|
+
const groupsSpin = prompter.progress("Fetching your GroupMe groups...");
|
|
112
|
+
let groups: Awaited<ReturnType<typeof fetchGroups>>;
|
|
113
|
+
try {
|
|
114
|
+
groups = await fetchGroups(accessToken);
|
|
115
|
+
} catch {
|
|
116
|
+
groupsSpin.stop("Failed");
|
|
117
|
+
await prompter.note(
|
|
118
|
+
"Could not fetch groups. Check your access token and try again.",
|
|
119
|
+
"GroupMe setup failed",
|
|
120
|
+
);
|
|
121
|
+
throw new Error("Could not fetch groups");
|
|
122
|
+
}
|
|
103
123
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
124
|
+
if (groups.length === 0) {
|
|
125
|
+
groupsSpin.stop("No groups found");
|
|
126
|
+
await prompter.note(
|
|
127
|
+
"No groups found. Create or join a GroupMe group first.",
|
|
128
|
+
"GroupMe setup failed",
|
|
129
|
+
);
|
|
130
|
+
throw new Error("No GroupMe groups found");
|
|
131
|
+
}
|
|
132
|
+
groupsSpin.stop(`Found ${groups.length} groups`);
|
|
111
133
|
|
|
134
|
+
const groupId = await prompter.select<string>({
|
|
135
|
+
message: "Select a GroupMe group",
|
|
136
|
+
options: groups.map((group) => ({
|
|
137
|
+
value: group.id,
|
|
138
|
+
label: group.name || group.id,
|
|
139
|
+
hint: group.id,
|
|
140
|
+
})),
|
|
141
|
+
});
|
|
142
|
+
const selectedGroup = groups.find((group) => group.id === groupId);
|
|
112
143
|
const requireMention = await prompter.confirm({
|
|
113
144
|
message: "Require mention to respond?",
|
|
114
145
|
initialValue: true,
|
|
115
146
|
});
|
|
147
|
+
const publicDomainRaw = (
|
|
148
|
+
await prompter.text({
|
|
149
|
+
message: "Public domain (must be reachable — GroupMe will ping it)",
|
|
150
|
+
validate: (value) => {
|
|
151
|
+
const trimmed = value.trim();
|
|
152
|
+
if (!trimmed) {
|
|
153
|
+
return "Public domain is required";
|
|
154
|
+
}
|
|
155
|
+
const normalized = trimmed
|
|
156
|
+
.replace(/^https?:\/\//, "")
|
|
157
|
+
.replace(/\/+$/, "");
|
|
158
|
+
if (!normalized) {
|
|
159
|
+
return "Public domain is required";
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
).trim();
|
|
165
|
+
let publicDomain: string;
|
|
166
|
+
try {
|
|
167
|
+
if (/^https?:\/\//i.test(publicDomainRaw)) {
|
|
168
|
+
const url = new URL(publicDomainRaw);
|
|
169
|
+
publicDomain = url.port ? `${url.hostname}:${url.port}` : url.hostname;
|
|
170
|
+
} else {
|
|
171
|
+
const withoutLeadingSlashes = publicDomainRaw.replace(/^\/+/, "");
|
|
172
|
+
publicDomain = withoutLeadingSlashes.split(/[\/?#]/, 1)[0];
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// Fallback: best-effort stripping of scheme and any path/query/fragment
|
|
176
|
+
const noScheme = publicDomainRaw.replace(/^https?:\/\//i, "");
|
|
177
|
+
publicDomain = noScheme.split(/[\/?#]/, 1)[0];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const pathSegment = randomBytes(8).toString("hex");
|
|
181
|
+
const callbackToken = randomBytes(32).toString("hex");
|
|
182
|
+
const callbackUrl = `/groupme/${pathSegment}?k=${callbackToken}`;
|
|
183
|
+
await prompter.note(
|
|
184
|
+
`Generated webhook callback URL: /groupme/${pathSegment}?k=***`,
|
|
185
|
+
"Generated callback URL",
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const botSpin = prompter.progress("Registering bot with GroupMe...");
|
|
189
|
+
let botId = "";
|
|
190
|
+
try {
|
|
191
|
+
const bot = await createBot({
|
|
192
|
+
accessToken,
|
|
193
|
+
name: botName,
|
|
194
|
+
groupId,
|
|
195
|
+
callbackUrl: `https://${publicDomain}${callbackUrl}`,
|
|
196
|
+
});
|
|
197
|
+
botId = bot.bot_id;
|
|
198
|
+
botSpin.stop("Bot registered");
|
|
199
|
+
} catch (error) {
|
|
200
|
+
botSpin.stop("Failed");
|
|
201
|
+
const detail = error instanceof Error ? `\n\nDetails: ${error.message}` : "";
|
|
202
|
+
await prompter.note(
|
|
203
|
+
`Failed to register bot with GroupMe. Check your access token and try again.${detail}`,
|
|
204
|
+
"GroupMe setup failed",
|
|
205
|
+
);
|
|
206
|
+
throw new Error("Failed to register GroupMe bot", {
|
|
207
|
+
cause: error instanceof Error ? error : undefined,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await prompter.note(
|
|
212
|
+
`Bot "${botName}" registered in group "${selectedGroup?.name ?? groupId}" (bot ID: ${redactMiddle(botId)})`,
|
|
213
|
+
"GroupMe bot registered",
|
|
214
|
+
);
|
|
116
215
|
|
|
117
216
|
const next = applyGroupMeConfig({
|
|
118
217
|
cfg,
|
|
119
218
|
accountId,
|
|
120
219
|
updates: {
|
|
121
|
-
botId,
|
|
122
|
-
accessToken,
|
|
123
220
|
botName,
|
|
124
|
-
|
|
221
|
+
accessToken,
|
|
222
|
+
botId,
|
|
223
|
+
groupId,
|
|
224
|
+
publicDomain,
|
|
225
|
+
callbackUrl,
|
|
125
226
|
requireMention,
|
|
126
227
|
},
|
|
127
228
|
});
|
|
@@ -129,9 +230,8 @@ export const groupmeOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
129
230
|
await prompter.note(
|
|
130
231
|
[
|
|
131
232
|
"Next steps:",
|
|
132
|
-
|
|
133
|
-
"2.
|
|
134
|
-
"3. Send a message in the group to test",
|
|
233
|
+
"1. Restart the gateway: openclaw gateway restart",
|
|
234
|
+
"2. Send a message in the group to test",
|
|
135
235
|
].join("\n"),
|
|
136
236
|
"GroupMe next steps",
|
|
137
237
|
);
|
package/src/parse.ts
CHANGED
|
@@ -51,7 +51,7 @@ function parseNumberMatrix(value: unknown): number[][] {
|
|
|
51
51
|
const parsedRow: number[] = [];
|
|
52
52
|
for (const cell of row) {
|
|
53
53
|
const num = readNumber(cell);
|
|
54
|
-
if (
|
|
54
|
+
if (num === undefined) {
|
|
55
55
|
continue;
|
|
56
56
|
}
|
|
57
57
|
parsedRow.push(num);
|
|
@@ -68,7 +68,9 @@ function parseStringArray(value: unknown): string[] {
|
|
|
68
68
|
return [];
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
return value
|
|
71
|
+
return value
|
|
72
|
+
.map((entry) => readString(entry))
|
|
73
|
+
.filter((entry): entry is string => Boolean(entry));
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
function parseAttachment(entry: unknown): GroupMeAttachment | null {
|
|
@@ -86,11 +88,7 @@ function parseAttachment(entry: unknown): GroupMeAttachment | null {
|
|
|
86
88
|
if (!url) {
|
|
87
89
|
return null;
|
|
88
90
|
}
|
|
89
|
-
|
|
90
|
-
type,
|
|
91
|
-
url,
|
|
92
|
-
};
|
|
93
|
-
return imageAttachment;
|
|
91
|
+
return { type, url } satisfies GroupMeImageAttachment;
|
|
94
92
|
}
|
|
95
93
|
|
|
96
94
|
if (type === "location") {
|
|
@@ -100,22 +98,15 @@ function parseAttachment(entry: unknown): GroupMeAttachment | null {
|
|
|
100
98
|
if (!lat || !lng || !name) {
|
|
101
99
|
return null;
|
|
102
100
|
}
|
|
103
|
-
|
|
104
|
-
type,
|
|
105
|
-
lat,
|
|
106
|
-
lng,
|
|
107
|
-
name,
|
|
108
|
-
};
|
|
109
|
-
return locationAttachment;
|
|
101
|
+
return { type, lat, lng, name } satisfies GroupMeLocationAttachment;
|
|
110
102
|
}
|
|
111
103
|
|
|
112
104
|
if (type === "mentions") {
|
|
113
|
-
|
|
105
|
+
return {
|
|
114
106
|
type,
|
|
115
107
|
user_ids: parseStringArray(entry.user_ids),
|
|
116
108
|
loci: parseNumberMatrix(entry.loci),
|
|
117
|
-
};
|
|
118
|
-
return mentionsAttachment;
|
|
109
|
+
} satisfies GroupMeMentionsAttachment;
|
|
119
110
|
}
|
|
120
111
|
|
|
121
112
|
if (type === "emoji") {
|
|
@@ -123,12 +114,11 @@ function parseAttachment(entry: unknown): GroupMeAttachment | null {
|
|
|
123
114
|
if (!placeholder) {
|
|
124
115
|
return null;
|
|
125
116
|
}
|
|
126
|
-
|
|
117
|
+
return {
|
|
127
118
|
type,
|
|
128
119
|
placeholder,
|
|
129
120
|
charmap: parseNumberMatrix(entry.charmap),
|
|
130
|
-
};
|
|
131
|
-
return emojiAttachment;
|
|
121
|
+
} satisfies GroupMeEmojiAttachment;
|
|
132
122
|
}
|
|
133
123
|
|
|
134
124
|
return {
|
|
@@ -141,18 +131,14 @@ function parseAttachments(value: unknown): GroupMeAttachment[] {
|
|
|
141
131
|
if (!Array.isArray(value)) {
|
|
142
132
|
return [];
|
|
143
133
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const parsed = parseAttachment(entry);
|
|
148
|
-
if (parsed) {
|
|
149
|
-
attachments.push(parsed);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return attachments;
|
|
134
|
+
return value
|
|
135
|
+
.map((entry) => parseAttachment(entry))
|
|
136
|
+
.filter((parsed): parsed is GroupMeAttachment => parsed !== null);
|
|
153
137
|
}
|
|
154
138
|
|
|
155
|
-
export function parseGroupMeCallback(
|
|
139
|
+
export function parseGroupMeCallback(
|
|
140
|
+
data: unknown,
|
|
141
|
+
): GroupMeCallbackData | null {
|
|
156
142
|
if (!isRecord(data)) {
|
|
157
143
|
return null;
|
|
158
144
|
}
|
|
@@ -166,7 +152,15 @@ export function parseGroupMeCallback(data: unknown): GroupMeCallbackData | null
|
|
|
166
152
|
const sourceGuid = readString(data.source_guid);
|
|
167
153
|
const createdAt = readNumber(data.created_at);
|
|
168
154
|
|
|
169
|
-
if (
|
|
155
|
+
if (
|
|
156
|
+
!id ||
|
|
157
|
+
!name ||
|
|
158
|
+
!senderType ||
|
|
159
|
+
!senderId ||
|
|
160
|
+
!userId ||
|
|
161
|
+
!groupId ||
|
|
162
|
+
!sourceGuid
|
|
163
|
+
) {
|
|
170
164
|
return null;
|
|
171
165
|
}
|
|
172
166
|
if (typeof createdAt !== "number") {
|
|
@@ -212,12 +206,17 @@ export function shouldProcessCallback(msg: GroupMeCallbackData): string | null {
|
|
|
212
206
|
|
|
213
207
|
export function extractImageUrls(attachments: GroupMeAttachment[]): string[] {
|
|
214
208
|
return attachments
|
|
215
|
-
.filter(
|
|
209
|
+
.filter(
|
|
210
|
+
(attachment): attachment is GroupMeImageAttachment =>
|
|
211
|
+
attachment.type === "image",
|
|
212
|
+
)
|
|
216
213
|
.map((attachment) => attachment.url);
|
|
217
214
|
}
|
|
218
215
|
|
|
219
216
|
function normalizeMentionText(text: string): string {
|
|
220
|
-
return text
|
|
217
|
+
return text
|
|
218
|
+
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
|
|
219
|
+
.toLowerCase();
|
|
221
220
|
}
|
|
222
221
|
|
|
223
222
|
function buildRegexes(patterns?: string[]): RegExp[] {
|
package/src/policy.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
normalizeGroupMeAllowEntry,
|
|
3
|
+
normalizeStringId,
|
|
4
|
+
} from "./normalize.js";
|
|
2
5
|
|
|
3
6
|
export function resolveSenderAccess(params: {
|
|
4
7
|
senderId: string;
|
|
5
8
|
allowFrom?: Array<string | number>;
|
|
6
9
|
}): boolean {
|
|
7
|
-
const senderId =
|
|
10
|
+
const senderId = normalizeStringId(params.senderId);
|
|
8
11
|
if (!senderId) {
|
|
9
12
|
return false;
|
|
10
13
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export type RateLimitCheck =
|
|
2
|
+
| { kind: "accepted"; release: () => void }
|
|
3
|
+
| { kind: "rejected"; scope: "ip" | "sender" | "concurrency" };
|
|
4
|
+
|
|
5
|
+
type SlidingWindowState = Map<string, number[]>;
|
|
6
|
+
const DEFAULT_MAX_TRACKED_KEYS = 10_000;
|
|
7
|
+
|
|
8
|
+
function allowInWindow(params: {
|
|
9
|
+
state: SlidingWindowState;
|
|
10
|
+
key: string;
|
|
11
|
+
limit: number;
|
|
12
|
+
windowMs: number;
|
|
13
|
+
now: number;
|
|
14
|
+
}): boolean {
|
|
15
|
+
const { state, key, limit, windowMs, now } = params;
|
|
16
|
+
const current = state.get(key) ?? [];
|
|
17
|
+
const minTs = now - windowMs;
|
|
18
|
+
const retained = current.filter((ts) => ts > minTs);
|
|
19
|
+
if (retained.length >= limit) {
|
|
20
|
+
state.set(key, retained);
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
retained.push(now);
|
|
24
|
+
state.set(key, retained);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class GroupMeRateLimiter {
|
|
29
|
+
private readonly windowMs: number;
|
|
30
|
+
private readonly maxRequestsPerIp: number;
|
|
31
|
+
private readonly maxRequestsPerSender: number;
|
|
32
|
+
private readonly maxConcurrent: number;
|
|
33
|
+
private readonly maxTrackedKeys: number;
|
|
34
|
+
private readonly byIp: SlidingWindowState = new Map();
|
|
35
|
+
private readonly bySender: SlidingWindowState = new Map();
|
|
36
|
+
private inFlight = 0;
|
|
37
|
+
|
|
38
|
+
constructor(params: {
|
|
39
|
+
windowMs: number;
|
|
40
|
+
maxRequestsPerIp: number;
|
|
41
|
+
maxRequestsPerSender: number;
|
|
42
|
+
maxConcurrent: number;
|
|
43
|
+
}) {
|
|
44
|
+
this.windowMs = Math.max(1, Math.floor(params.windowMs));
|
|
45
|
+
this.maxRequestsPerIp = Math.max(1, Math.floor(params.maxRequestsPerIp));
|
|
46
|
+
this.maxRequestsPerSender = Math.max(
|
|
47
|
+
1,
|
|
48
|
+
Math.floor(params.maxRequestsPerSender),
|
|
49
|
+
);
|
|
50
|
+
this.maxConcurrent = Math.max(1, Math.floor(params.maxConcurrent));
|
|
51
|
+
this.maxTrackedKeys = DEFAULT_MAX_TRACKED_KEYS;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
evaluate(params: { ip: string; senderId: string }, now = Date.now()): RateLimitCheck {
|
|
55
|
+
const ipKey = params.ip.trim() || "unknown";
|
|
56
|
+
const senderKey = params.senderId.trim() || "unknown";
|
|
57
|
+
this.pruneState(this.byIp, now);
|
|
58
|
+
this.pruneState(this.bySender, now);
|
|
59
|
+
this.capStateSize(this.byIp);
|
|
60
|
+
this.capStateSize(this.bySender);
|
|
61
|
+
|
|
62
|
+
if (this.inFlight >= this.maxConcurrent) {
|
|
63
|
+
return { kind: "rejected", scope: "concurrency" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (
|
|
67
|
+
!allowInWindow({
|
|
68
|
+
state: this.byIp,
|
|
69
|
+
key: ipKey,
|
|
70
|
+
limit: this.maxRequestsPerIp,
|
|
71
|
+
windowMs: this.windowMs,
|
|
72
|
+
now,
|
|
73
|
+
})
|
|
74
|
+
) {
|
|
75
|
+
return { kind: "rejected", scope: "ip" };
|
|
76
|
+
}
|
|
77
|
+
if (
|
|
78
|
+
!allowInWindow({
|
|
79
|
+
state: this.bySender,
|
|
80
|
+
key: senderKey,
|
|
81
|
+
limit: this.maxRequestsPerSender,
|
|
82
|
+
windowMs: this.windowMs,
|
|
83
|
+
now,
|
|
84
|
+
})
|
|
85
|
+
) {
|
|
86
|
+
return { kind: "rejected", scope: "sender" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.inFlight += 1;
|
|
90
|
+
let released = false;
|
|
91
|
+
return {
|
|
92
|
+
kind: "accepted",
|
|
93
|
+
release: () => {
|
|
94
|
+
if (released) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
released = true;
|
|
98
|
+
this.inFlight = Math.max(0, this.inFlight - 1);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
inflightCount(): number {
|
|
104
|
+
return this.inFlight;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private pruneState(state: SlidingWindowState, now: number) {
|
|
108
|
+
const minTs = now - this.windowMs;
|
|
109
|
+
for (const [key, timestamps] of state) {
|
|
110
|
+
const retained = timestamps.filter((ts) => ts > minTs);
|
|
111
|
+
if (retained.length === 0) {
|
|
112
|
+
state.delete(key);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
state.set(key, retained);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private capStateSize(state: SlidingWindowState) {
|
|
120
|
+
while (state.size > this.maxTrackedKeys) {
|
|
121
|
+
const oldest = state.keys().next().value as string | undefined;
|
|
122
|
+
if (!oldest) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
state.delete(oldest);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { GroupMeCallbackData, ReplayCheck } from "./types.js";
|
|
3
|
+
|
|
4
|
+
type ReplayEntry = {
|
|
5
|
+
expiresAt: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export class GroupMeReplayCache {
|
|
9
|
+
private readonly ttlMs: number;
|
|
10
|
+
private readonly maxEntries: number;
|
|
11
|
+
private readonly entries = new Map<string, ReplayEntry>();
|
|
12
|
+
|
|
13
|
+
constructor(params: { ttlSeconds: number; maxEntries: number }) {
|
|
14
|
+
this.ttlMs = Math.max(1, Math.floor(params.ttlSeconds * 1000));
|
|
15
|
+
this.maxEntries = Math.max(1, Math.floor(params.maxEntries));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
checkAndRemember(key: string, now = Date.now()): ReplayCheck {
|
|
19
|
+
this.pruneExpired(now);
|
|
20
|
+
|
|
21
|
+
const existing = this.entries.get(key);
|
|
22
|
+
if (existing && existing.expiresAt > now) {
|
|
23
|
+
return { kind: "duplicate", key };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.entries.delete(key);
|
|
27
|
+
this.entries.set(key, { expiresAt: now + this.ttlMs });
|
|
28
|
+
this.evictOverflow();
|
|
29
|
+
return { kind: "accepted", key };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
size(): number {
|
|
33
|
+
return this.entries.size;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private pruneExpired(now: number) {
|
|
37
|
+
for (const [key, entry] of this.entries) {
|
|
38
|
+
if (entry.expiresAt > now) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
this.entries.delete(key);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private evictOverflow() {
|
|
46
|
+
while (this.entries.size > this.maxEntries) {
|
|
47
|
+
const oldest = this.entries.keys().next().value as string | undefined;
|
|
48
|
+
if (!oldest) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
this.entries.delete(oldest);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildReplayKey(message: GroupMeCallbackData): string {
|
|
57
|
+
const id = message.id.trim();
|
|
58
|
+
if (id) {
|
|
59
|
+
return `id:${id}`;
|
|
60
|
+
}
|
|
61
|
+
const sourceGuid = message.sourceGuid.trim();
|
|
62
|
+
if (sourceGuid) {
|
|
63
|
+
return `source_guid:${sourceGuid}`;
|
|
64
|
+
}
|
|
65
|
+
const fallback = createHash("sha256")
|
|
66
|
+
.update(
|
|
67
|
+
`${message.groupId}\u0000${message.senderId}\u0000${message.createdAt}\u0000${message.text}`,
|
|
68
|
+
)
|
|
69
|
+
.digest("hex");
|
|
70
|
+
return `fallback:${fallback}`;
|
|
71
|
+
}
|