openclaw-groupme 0.4.2 → 0.4.3
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,422 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
|
3
|
+
import { resolveGroupMeAccount } from "./accounts.js";
|
|
4
|
+
import { createBot, fetchGroups } from "./groupme-api.js";
|
|
5
|
+
function applyGroupMeConfig(params) {
|
|
6
|
+
const { cfg, accountId, updates } = params;
|
|
7
|
+
const section = (cfg.channels?.groupme ?? {});
|
|
8
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
9
|
+
return {
|
|
10
|
+
...cfg,
|
|
11
|
+
channels: {
|
|
12
|
+
...cfg.channels,
|
|
13
|
+
groupme: {
|
|
14
|
+
...section,
|
|
15
|
+
...updates,
|
|
16
|
+
enabled: true,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
...cfg,
|
|
23
|
+
channels: {
|
|
24
|
+
...cfg.channels,
|
|
25
|
+
groupme: {
|
|
26
|
+
...section,
|
|
27
|
+
enabled: true,
|
|
28
|
+
accounts: {
|
|
29
|
+
...(section.accounts ?? {}),
|
|
30
|
+
[accountId]: {
|
|
31
|
+
...(section.accounts?.[accountId] ?? {}),
|
|
32
|
+
...updates,
|
|
33
|
+
enabled: true,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function parsePublicDomain(raw) {
|
|
41
|
+
const trimmed = raw.trim();
|
|
42
|
+
try {
|
|
43
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
44
|
+
const url = new URL(trimmed);
|
|
45
|
+
return url.port ? `${url.hostname}:${url.port}` : url.hostname;
|
|
46
|
+
}
|
|
47
|
+
const withoutLeadingSlashes = trimmed.replace(/^\/+/, "");
|
|
48
|
+
return withoutLeadingSlashes.split(/[\/?#]/, 1)[0];
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
const noScheme = trimmed.replace(/^https?:\/\//i, "");
|
|
52
|
+
return noScheme.split(/[\/?#]/, 1)[0];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function generateCallbackUrl() {
|
|
56
|
+
const pathSegment = randomBytes(8).toString("hex");
|
|
57
|
+
const callbackToken = randomBytes(32).toString("hex");
|
|
58
|
+
return `/groupme/${pathSegment}?k=${callbackToken}`;
|
|
59
|
+
}
|
|
60
|
+
function redactMiddle(value) {
|
|
61
|
+
if (value.length <= 10) {
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
return `${value.slice(0, 6)}...${value.slice(-3)}`;
|
|
65
|
+
}
|
|
66
|
+
export const groupmeOnboardingAdapter = {
|
|
67
|
+
channel: "groupme",
|
|
68
|
+
getStatus: async ({ cfg, accountOverrides }) => {
|
|
69
|
+
const accountId = accountOverrides.groupme ?? DEFAULT_ACCOUNT_ID;
|
|
70
|
+
const account = resolveGroupMeAccount({
|
|
71
|
+
cfg: cfg,
|
|
72
|
+
accountId,
|
|
73
|
+
});
|
|
74
|
+
const configured = account.configured;
|
|
75
|
+
const callbackUrlConfigured = Boolean(account.config.callbackUrl?.trim());
|
|
76
|
+
const groupIdConfigured = Boolean(account.config.groupId?.trim());
|
|
77
|
+
const publicDomainConfigured = Boolean(account.config.publicDomain?.trim());
|
|
78
|
+
return {
|
|
79
|
+
channel: "groupme",
|
|
80
|
+
configured,
|
|
81
|
+
statusLines: [
|
|
82
|
+
`GroupMe (${accountId}): ${configured ? "configured" : "needs access token"}`,
|
|
83
|
+
account.config.accessToken?.trim()
|
|
84
|
+
? "Access token configured"
|
|
85
|
+
: "Access token missing",
|
|
86
|
+
callbackUrlConfigured
|
|
87
|
+
? "Webhook callback URL configured"
|
|
88
|
+
: "Webhook callback URL missing",
|
|
89
|
+
publicDomainConfigured
|
|
90
|
+
? "Public domain configured"
|
|
91
|
+
: "Public domain missing",
|
|
92
|
+
groupIdConfigured ? "Group ID configured" : "Group ID missing",
|
|
93
|
+
],
|
|
94
|
+
selectionHint: configured ? "configured" : "needs access token",
|
|
95
|
+
quickstartScore: configured ? 1 : 0,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
configure: async ({ cfg, prompter, accountOverrides }) => {
|
|
99
|
+
const accountId = accountOverrides.groupme ?? DEFAULT_ACCOUNT_ID;
|
|
100
|
+
const botNameInput = (await prompter.text({
|
|
101
|
+
message: "Bot name",
|
|
102
|
+
initialValue: "openclaw",
|
|
103
|
+
})).trim();
|
|
104
|
+
const botName = botNameInput || "openclaw";
|
|
105
|
+
const accessToken = (await prompter.text({
|
|
106
|
+
message: "GroupMe access token",
|
|
107
|
+
validate: (value) => value.trim() ? undefined : "Access token is required",
|
|
108
|
+
})).trim();
|
|
109
|
+
const groupsSpin = prompter.progress("Fetching your GroupMe groups...");
|
|
110
|
+
let groups;
|
|
111
|
+
try {
|
|
112
|
+
groups = await fetchGroups(accessToken);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
groupsSpin.stop("Failed");
|
|
116
|
+
await prompter.note("Could not fetch groups. Check your access token and try again.", "GroupMe setup failed");
|
|
117
|
+
throw new Error("Could not fetch groups");
|
|
118
|
+
}
|
|
119
|
+
if (groups.length === 0) {
|
|
120
|
+
groupsSpin.stop("No groups found");
|
|
121
|
+
await prompter.note("No groups found. Create or join a GroupMe group first.", "GroupMe setup failed");
|
|
122
|
+
throw new Error("No GroupMe groups found");
|
|
123
|
+
}
|
|
124
|
+
groupsSpin.stop(`Found ${groups.length} groups`);
|
|
125
|
+
const groupId = await prompter.select({
|
|
126
|
+
message: "Select a GroupMe group",
|
|
127
|
+
options: groups.map((group) => ({
|
|
128
|
+
value: group.id,
|
|
129
|
+
label: group.name || group.id,
|
|
130
|
+
hint: group.id,
|
|
131
|
+
})),
|
|
132
|
+
});
|
|
133
|
+
const selectedGroup = groups.find((group) => group.id === groupId);
|
|
134
|
+
const requireMention = await prompter.confirm({
|
|
135
|
+
message: "Require mention to respond?",
|
|
136
|
+
initialValue: true,
|
|
137
|
+
});
|
|
138
|
+
const publicDomainRaw = (await prompter.text({
|
|
139
|
+
message: "Public domain (must be reachable — GroupMe will ping it)",
|
|
140
|
+
validate: (value) => {
|
|
141
|
+
const trimmed = value.trim();
|
|
142
|
+
if (!trimmed) {
|
|
143
|
+
return "Public domain is required";
|
|
144
|
+
}
|
|
145
|
+
const normalized = trimmed
|
|
146
|
+
.replace(/^https?:\/\//, "")
|
|
147
|
+
.replace(/\/+$/, "");
|
|
148
|
+
if (!normalized) {
|
|
149
|
+
return "Public domain is required";
|
|
150
|
+
}
|
|
151
|
+
const parsed = parsePublicDomain(trimmed);
|
|
152
|
+
if (!parsed) {
|
|
153
|
+
return "Public domain is required";
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
},
|
|
157
|
+
})).trim();
|
|
158
|
+
const publicDomain = parsePublicDomain(publicDomainRaw);
|
|
159
|
+
const callbackUrl = generateCallbackUrl();
|
|
160
|
+
const pathSegment = callbackUrl.split("?")[0].split("/").pop();
|
|
161
|
+
await prompter.note(`Generated webhook callback URL: /groupme/${pathSegment}?k=***`, "Generated callback URL");
|
|
162
|
+
const botSpin = prompter.progress("Registering bot with GroupMe...");
|
|
163
|
+
let botId = "";
|
|
164
|
+
try {
|
|
165
|
+
const bot = await createBot({
|
|
166
|
+
accessToken,
|
|
167
|
+
name: botName,
|
|
168
|
+
groupId,
|
|
169
|
+
callbackUrl: `https://${publicDomain}${callbackUrl}`,
|
|
170
|
+
});
|
|
171
|
+
botId = bot.bot_id;
|
|
172
|
+
botSpin.stop("Bot registered");
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
botSpin.stop("Failed");
|
|
176
|
+
const detail = error instanceof Error ? `\n\nDetails: ${error.message}` : "";
|
|
177
|
+
await prompter.note(`Failed to register bot with GroupMe. Check your access token and try again.${detail}`, "GroupMe setup failed");
|
|
178
|
+
throw new Error("Failed to register GroupMe bot", {
|
|
179
|
+
cause: error instanceof Error ? error : undefined,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
await prompter.note(`Bot "${botName}" registered in group "${selectedGroup?.name ?? groupId}" (bot ID: ${redactMiddle(botId)})`, "GroupMe bot registered");
|
|
183
|
+
const next = applyGroupMeConfig({
|
|
184
|
+
cfg,
|
|
185
|
+
accountId,
|
|
186
|
+
updates: {
|
|
187
|
+
botName,
|
|
188
|
+
accessToken,
|
|
189
|
+
botId,
|
|
190
|
+
groupId,
|
|
191
|
+
publicDomain,
|
|
192
|
+
callbackUrl,
|
|
193
|
+
requireMention,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
await prompter.note([
|
|
197
|
+
"Next steps:",
|
|
198
|
+
"1. Restart the gateway: openclaw gateway restart",
|
|
199
|
+
"2. Send a message in the group to test",
|
|
200
|
+
].join("\n"), "GroupMe next steps");
|
|
201
|
+
return {
|
|
202
|
+
cfg: next,
|
|
203
|
+
accountId,
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
configureWhenConfigured: async ({ cfg, prompter, runtime, accountOverrides, }) => {
|
|
207
|
+
const accountId = accountOverrides.groupme ?? DEFAULT_ACCOUNT_ID;
|
|
208
|
+
const account = resolveGroupMeAccount({
|
|
209
|
+
cfg: cfg,
|
|
210
|
+
accountId,
|
|
211
|
+
});
|
|
212
|
+
const action = await prompter.select({
|
|
213
|
+
message: "GroupMe is already configured. What would you like to do?",
|
|
214
|
+
options: [
|
|
215
|
+
{ value: "skip", label: "Skip", hint: "no changes" },
|
|
216
|
+
{ value: "rotate_token", label: "Rotate access token" },
|
|
217
|
+
{ value: "change_group", label: "Change group" },
|
|
218
|
+
{ value: "regen_callback", label: "Regenerate callback URL" },
|
|
219
|
+
{ value: "toggle_mention", label: "Toggle requireMention" },
|
|
220
|
+
{ value: "update_domain", label: "Update public domain" },
|
|
221
|
+
{ value: "full_setup", label: "Full re-setup", hint: "start from scratch" },
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
if (action === "skip") {
|
|
225
|
+
return "skip";
|
|
226
|
+
}
|
|
227
|
+
if (action === "full_setup") {
|
|
228
|
+
return groupmeOnboardingAdapter.configure({
|
|
229
|
+
cfg,
|
|
230
|
+
prompter,
|
|
231
|
+
runtime,
|
|
232
|
+
accountOverrides,
|
|
233
|
+
options: {},
|
|
234
|
+
shouldPromptAccountIds: false,
|
|
235
|
+
forceAllowFrom: false,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (action === "rotate_token") {
|
|
239
|
+
const newToken = (await prompter.text({
|
|
240
|
+
message: "New GroupMe access token",
|
|
241
|
+
validate: (value) => value.trim() ? undefined : "Access token is required",
|
|
242
|
+
})).trim();
|
|
243
|
+
const spin = prompter.progress("Validating access token...");
|
|
244
|
+
try {
|
|
245
|
+
await fetchGroups(newToken);
|
|
246
|
+
spin.stop("Token validated");
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
spin.stop("Failed");
|
|
250
|
+
await prompter.note("Could not validate token. Check your access token and try again.", "Validation failed");
|
|
251
|
+
throw new Error("Could not validate access token");
|
|
252
|
+
}
|
|
253
|
+
const next = applyGroupMeConfig({
|
|
254
|
+
cfg,
|
|
255
|
+
accountId,
|
|
256
|
+
updates: { accessToken: newToken },
|
|
257
|
+
});
|
|
258
|
+
await prompter.note("Access token updated.", "Token rotated");
|
|
259
|
+
return { cfg: next, accountId };
|
|
260
|
+
}
|
|
261
|
+
if (action === "change_group") {
|
|
262
|
+
const existingToken = account.accessToken;
|
|
263
|
+
if (!existingToken) {
|
|
264
|
+
await prompter.note("No access token configured. Use \"Rotate access token\" first.", "Missing token");
|
|
265
|
+
return "skip";
|
|
266
|
+
}
|
|
267
|
+
const spin = prompter.progress("Fetching your GroupMe groups...");
|
|
268
|
+
let groups;
|
|
269
|
+
try {
|
|
270
|
+
groups = await fetchGroups(existingToken);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
spin.stop("Failed");
|
|
274
|
+
await prompter.note("Could not fetch groups. Check your access token and try again.", "GroupMe error");
|
|
275
|
+
throw new Error("Could not fetch groups");
|
|
276
|
+
}
|
|
277
|
+
if (groups.length === 0) {
|
|
278
|
+
spin.stop("No groups found");
|
|
279
|
+
await prompter.note("No groups found. Create or join a GroupMe group first.", "No groups");
|
|
280
|
+
return "skip";
|
|
281
|
+
}
|
|
282
|
+
spin.stop(`Found ${groups.length} groups`);
|
|
283
|
+
const newGroupId = await prompter.select({
|
|
284
|
+
message: "Select a GroupMe group",
|
|
285
|
+
options: groups.map((group) => ({
|
|
286
|
+
value: group.id,
|
|
287
|
+
label: group.name || group.id,
|
|
288
|
+
hint: group.id === account.config.groupId ? "current" : group.id,
|
|
289
|
+
})),
|
|
290
|
+
});
|
|
291
|
+
const selectedGroup = groups.find((g) => g.id === newGroupId);
|
|
292
|
+
const updates = { groupId: newGroupId };
|
|
293
|
+
const registerNew = await prompter.confirm({
|
|
294
|
+
message: "Register a new bot in this group?",
|
|
295
|
+
initialValue: true,
|
|
296
|
+
});
|
|
297
|
+
if (!registerNew) {
|
|
298
|
+
const newBotId = (await prompter.text({
|
|
299
|
+
message: "Bot ID for the new group (existing bot won't work in a different group)",
|
|
300
|
+
validate: (value) => value.trim() ? undefined : "Bot ID is required",
|
|
301
|
+
})).trim();
|
|
302
|
+
updates.botId = newBotId;
|
|
303
|
+
}
|
|
304
|
+
if (registerNew) {
|
|
305
|
+
const botName = account.config.botName || "openclaw";
|
|
306
|
+
let publicDomain = account.config.publicDomain;
|
|
307
|
+
if (!publicDomain) {
|
|
308
|
+
const domainRaw = (await prompter.text({
|
|
309
|
+
message: "Public domain (required for bot registration)",
|
|
310
|
+
validate: (value) => {
|
|
311
|
+
const trimmed = value.trim();
|
|
312
|
+
if (!trimmed)
|
|
313
|
+
return "Public domain is required";
|
|
314
|
+
const normalized = trimmed
|
|
315
|
+
.replace(/^https?:\/\//, "")
|
|
316
|
+
.replace(/\/+$/, "");
|
|
317
|
+
if (!normalized)
|
|
318
|
+
return "Public domain is required";
|
|
319
|
+
const parsed = parsePublicDomain(trimmed);
|
|
320
|
+
if (!parsed)
|
|
321
|
+
return "Public domain is required";
|
|
322
|
+
return undefined;
|
|
323
|
+
},
|
|
324
|
+
})).trim();
|
|
325
|
+
publicDomain = parsePublicDomain(domainRaw);
|
|
326
|
+
updates.publicDomain = publicDomain;
|
|
327
|
+
}
|
|
328
|
+
let rawCallbackUrl = account.config.callbackUrl;
|
|
329
|
+
if (!rawCallbackUrl) {
|
|
330
|
+
rawCallbackUrl = generateCallbackUrl();
|
|
331
|
+
updates.callbackUrl = rawCallbackUrl;
|
|
332
|
+
}
|
|
333
|
+
const parsedCallback = new URL(rawCallbackUrl, "http://localhost");
|
|
334
|
+
const callbackPath = `${parsedCallback.pathname}${parsedCallback.search}`;
|
|
335
|
+
const botSpin = prompter.progress("Registering bot with GroupMe...");
|
|
336
|
+
try {
|
|
337
|
+
const bot = await createBot({
|
|
338
|
+
accessToken: existingToken,
|
|
339
|
+
name: botName,
|
|
340
|
+
groupId: newGroupId,
|
|
341
|
+
callbackUrl: `https://${publicDomain}${callbackPath}`,
|
|
342
|
+
});
|
|
343
|
+
updates.botId = bot.bot_id;
|
|
344
|
+
botSpin.stop("Bot registered");
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
botSpin.stop("Failed");
|
|
348
|
+
const detail = error instanceof Error ? `\n\nDetails: ${error.message}` : "";
|
|
349
|
+
await prompter.note(`Failed to register bot.${detail}`, "Bot registration failed");
|
|
350
|
+
throw new Error("Failed to register GroupMe bot", {
|
|
351
|
+
cause: error instanceof Error ? error : undefined,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const next = applyGroupMeConfig({ cfg, accountId, updates });
|
|
356
|
+
await prompter.note(`Group changed to "${selectedGroup?.name ?? newGroupId}".`, "Group updated");
|
|
357
|
+
return { cfg: next, accountId };
|
|
358
|
+
}
|
|
359
|
+
if (action === "regen_callback") {
|
|
360
|
+
const callbackUrl = generateCallbackUrl();
|
|
361
|
+
const next = applyGroupMeConfig({
|
|
362
|
+
cfg,
|
|
363
|
+
accountId,
|
|
364
|
+
updates: { callbackUrl },
|
|
365
|
+
});
|
|
366
|
+
await prompter.note([
|
|
367
|
+
"Callback URL regenerated.",
|
|
368
|
+
"Remember to update your GroupMe bot settings or re-register the bot.",
|
|
369
|
+
].join("\n"), "Callback URL updated");
|
|
370
|
+
return { cfg: next, accountId };
|
|
371
|
+
}
|
|
372
|
+
if (action === "toggle_mention") {
|
|
373
|
+
const current = account.config.requireMention ?? true;
|
|
374
|
+
const next = applyGroupMeConfig({
|
|
375
|
+
cfg,
|
|
376
|
+
accountId,
|
|
377
|
+
updates: { requireMention: !current },
|
|
378
|
+
});
|
|
379
|
+
await prompter.note(`requireMention changed from ${current} to ${!current}.`, "Mention setting updated");
|
|
380
|
+
return { cfg: next, accountId };
|
|
381
|
+
}
|
|
382
|
+
if (action === "update_domain") {
|
|
383
|
+
const newDomainRaw = (await prompter.text({
|
|
384
|
+
message: "New public domain",
|
|
385
|
+
initialValue: account.config.publicDomain ?? "",
|
|
386
|
+
validate: (value) => {
|
|
387
|
+
const trimmed = value.trim();
|
|
388
|
+
if (!trimmed)
|
|
389
|
+
return "Public domain is required";
|
|
390
|
+
const normalized = trimmed
|
|
391
|
+
.replace(/^https?:\/\//, "")
|
|
392
|
+
.replace(/\/+$/, "");
|
|
393
|
+
if (!normalized)
|
|
394
|
+
return "Public domain is required";
|
|
395
|
+
const parsed = parsePublicDomain(trimmed);
|
|
396
|
+
if (!parsed)
|
|
397
|
+
return "Public domain is required";
|
|
398
|
+
return undefined;
|
|
399
|
+
},
|
|
400
|
+
})).trim();
|
|
401
|
+
const publicDomain = parsePublicDomain(newDomainRaw);
|
|
402
|
+
const next = applyGroupMeConfig({
|
|
403
|
+
cfg,
|
|
404
|
+
accountId,
|
|
405
|
+
updates: { publicDomain },
|
|
406
|
+
});
|
|
407
|
+
await prompter.note(`Public domain updated to "${publicDomain}".`, "Domain updated");
|
|
408
|
+
return { cfg: next, accountId };
|
|
409
|
+
}
|
|
410
|
+
return "skip";
|
|
411
|
+
},
|
|
412
|
+
disable: (cfg) => ({
|
|
413
|
+
...cfg,
|
|
414
|
+
channels: {
|
|
415
|
+
...cfg.channels,
|
|
416
|
+
groupme: {
|
|
417
|
+
...(cfg.channels?.groupme ?? {}),
|
|
418
|
+
enabled: false,
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
}),
|
|
422
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function readString(value) {
|
|
5
|
+
if (typeof value !== "string") {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
const trimmed = value.trim();
|
|
9
|
+
return trimmed || undefined;
|
|
10
|
+
}
|
|
11
|
+
function readBoolean(value) {
|
|
12
|
+
return typeof value === "boolean" ? value : undefined;
|
|
13
|
+
}
|
|
14
|
+
function readNumber(value) {
|
|
15
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === "string") {
|
|
19
|
+
const parsed = Number(value);
|
|
20
|
+
if (Number.isFinite(parsed)) {
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function parseNumberMatrix(value) {
|
|
27
|
+
if (!Array.isArray(value)) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (const row of value) {
|
|
32
|
+
if (!Array.isArray(row)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const parsedRow = [];
|
|
36
|
+
for (const cell of row) {
|
|
37
|
+
const num = readNumber(cell);
|
|
38
|
+
if (num === undefined) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
parsedRow.push(num);
|
|
42
|
+
}
|
|
43
|
+
if (parsedRow.length > 0) {
|
|
44
|
+
rows.push(parsedRow);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return rows;
|
|
48
|
+
}
|
|
49
|
+
function parseStringArray(value) {
|
|
50
|
+
if (!Array.isArray(value)) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
return value
|
|
54
|
+
.map((entry) => readString(entry))
|
|
55
|
+
.filter((entry) => Boolean(entry));
|
|
56
|
+
}
|
|
57
|
+
function parseAttachment(entry) {
|
|
58
|
+
if (!isRecord(entry)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const type = readString(entry.type);
|
|
62
|
+
if (!type) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (type === "image") {
|
|
66
|
+
const url = readString(entry.url);
|
|
67
|
+
if (!url) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return { type, url };
|
|
71
|
+
}
|
|
72
|
+
if (type === "location") {
|
|
73
|
+
const lat = readString(entry.lat);
|
|
74
|
+
const lng = readString(entry.lng);
|
|
75
|
+
const name = readString(entry.name);
|
|
76
|
+
if (!lat || !lng || !name) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return { type, lat, lng, name };
|
|
80
|
+
}
|
|
81
|
+
if (type === "mentions") {
|
|
82
|
+
return {
|
|
83
|
+
type,
|
|
84
|
+
user_ids: parseStringArray(entry.user_ids),
|
|
85
|
+
loci: parseNumberMatrix(entry.loci),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (type === "emoji") {
|
|
89
|
+
const placeholder = readString(entry.placeholder);
|
|
90
|
+
if (!placeholder) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
type,
|
|
95
|
+
placeholder,
|
|
96
|
+
charmap: parseNumberMatrix(entry.charmap),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
...entry,
|
|
101
|
+
type,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function parseAttachments(value) {
|
|
105
|
+
if (!Array.isArray(value)) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
return value
|
|
109
|
+
.map((entry) => parseAttachment(entry))
|
|
110
|
+
.filter((parsed) => parsed !== null);
|
|
111
|
+
}
|
|
112
|
+
export function parseGroupMeCallback(data) {
|
|
113
|
+
if (!isRecord(data)) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const id = readString(data.id);
|
|
117
|
+
const name = readString(data.name);
|
|
118
|
+
const senderType = readString(data.sender_type);
|
|
119
|
+
const senderId = readString(data.sender_id);
|
|
120
|
+
const userId = readString(data.user_id);
|
|
121
|
+
const groupId = readString(data.group_id);
|
|
122
|
+
const sourceGuid = readString(data.source_guid);
|
|
123
|
+
const createdAt = readNumber(data.created_at);
|
|
124
|
+
if (!id ||
|
|
125
|
+
!name ||
|
|
126
|
+
!senderType ||
|
|
127
|
+
!senderId ||
|
|
128
|
+
!userId ||
|
|
129
|
+
!groupId ||
|
|
130
|
+
!sourceGuid) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (typeof createdAt !== "number") {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const avatarUrl = readString(data.avatar_url) ?? null;
|
|
137
|
+
const text = typeof data.text === "string" ? data.text : "";
|
|
138
|
+
return {
|
|
139
|
+
id,
|
|
140
|
+
text,
|
|
141
|
+
name,
|
|
142
|
+
senderType,
|
|
143
|
+
senderId,
|
|
144
|
+
userId,
|
|
145
|
+
groupId,
|
|
146
|
+
sourceGuid,
|
|
147
|
+
createdAt,
|
|
148
|
+
system: readBoolean(data.system) ?? false,
|
|
149
|
+
avatarUrl,
|
|
150
|
+
attachments: parseAttachments(data.attachments),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export function hasImageAttachment(attachments) {
|
|
154
|
+
return attachments.some((attachment) => attachment.type === "image");
|
|
155
|
+
}
|
|
156
|
+
export function shouldProcessCallback(msg) {
|
|
157
|
+
if (msg.senderType !== "user") {
|
|
158
|
+
return "non-user message";
|
|
159
|
+
}
|
|
160
|
+
if (msg.system) {
|
|
161
|
+
return "system message";
|
|
162
|
+
}
|
|
163
|
+
if (!msg.text.trim() && !hasImageAttachment(msg.attachments)) {
|
|
164
|
+
return "empty message";
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
export function extractImageUrls(attachments) {
|
|
169
|
+
return attachments
|
|
170
|
+
.filter((attachment) => attachment.type === "image")
|
|
171
|
+
.map((attachment) => attachment.url);
|
|
172
|
+
}
|
|
173
|
+
function normalizeMentionText(text) {
|
|
174
|
+
return text
|
|
175
|
+
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
|
|
176
|
+
.toLowerCase();
|
|
177
|
+
}
|
|
178
|
+
function buildRegexes(patterns) {
|
|
179
|
+
if (!patterns || patterns.length === 0) {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
return patterns
|
|
183
|
+
.map((pattern) => {
|
|
184
|
+
try {
|
|
185
|
+
return new RegExp(pattern, "i");
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
.filter((entry) => Boolean(entry));
|
|
192
|
+
}
|
|
193
|
+
function escapeRegexLiteral(value) {
|
|
194
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
195
|
+
}
|
|
196
|
+
export function detectGroupMeMention(params) {
|
|
197
|
+
const text = params.text?.trim() ?? "";
|
|
198
|
+
if (!text) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
const normalizedText = normalizeMentionText(text);
|
|
202
|
+
const channelRegexes = buildRegexes(params.channelMentionPatterns);
|
|
203
|
+
if (channelRegexes.some((regex) => regex.test(text))) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
const mentionRegexes = params.mentionRegexes ?? [];
|
|
207
|
+
if (mentionRegexes.some((regex) => regex.test(normalizedText))) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
const botName = params.botName?.trim();
|
|
211
|
+
if (!botName) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
const escaped = escapeRegexLiteral(botName);
|
|
215
|
+
const botRegex = new RegExp(`\\b@?${escaped}\\b`, "i");
|
|
216
|
+
return botRegex.test(text);
|
|
217
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { normalizeGroupMeAllowEntry, normalizeStringId, } from "./normalize.js";
|
|
2
|
+
export function resolveSenderAccess(params) {
|
|
3
|
+
const senderId = normalizeStringId(params.senderId);
|
|
4
|
+
if (!senderId) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
const allowFrom = params.allowFrom ?? [];
|
|
8
|
+
if (allowFrom.length === 0) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
const normalizedAllow = allowFrom
|
|
12
|
+
.map((entry) => normalizeGroupMeAllowEntry(String(entry)))
|
|
13
|
+
.filter((entry) => Boolean(entry));
|
|
14
|
+
if (normalizedAllow.includes("*")) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
return normalizedAllow.includes(senderId);
|
|
18
|
+
}
|