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/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 botId"}`,
76
+ `GroupMe (${accountId}): ${configured ? "configured" : "needs access token"}`,
64
77
  account.config.accessToken?.trim()
65
78
  ? "Access token configured"
66
- : "Access token missing (needed for image uploads)",
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 bot ID",
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
- await prompter.note(
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 ID",
86
- validate: (value) => (value.trim() ? undefined : "Bot ID is required"),
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: "Access token",
93
- validate: (value) => (value.trim() ? undefined : "Access token is required"),
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 botName = (
98
- await prompter.text({
99
- message: "Bot name (mention fallback)",
100
- initialValue: "openclaw",
101
- })
102
- ).trim();
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
- const callbackPath = (
105
- await prompter.text({
106
- message: "Webhook path",
107
- initialValue: "/groupme",
108
- validate: (value) => (value.trim().startsWith("/") ? undefined : "Path must start with /"),
109
- })
110
- ).trim();
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
- callbackPath,
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
- `1. Set GroupMe callback URL to https://<your-domain>${callbackPath}`,
133
- "2. Restart gateway",
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 (typeof num !== "number") {
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.map((entry) => readString(entry)).filter((entry): entry is string => Boolean(entry));
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
- const imageAttachment: GroupMeImageAttachment = {
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
- const locationAttachment: GroupMeLocationAttachment = {
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
- const mentionsAttachment: GroupMeMentionsAttachment = {
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
- const emojiAttachment: GroupMeEmojiAttachment = {
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
- const attachments: GroupMeAttachment[] = [];
146
- for (const entry of value) {
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(data: unknown): GroupMeCallbackData | null {
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 (!id || !name || !senderType || !senderId || !userId || !groupId || !sourceGuid) {
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((attachment): attachment is GroupMeImageAttachment => attachment.type === "image")
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.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "").toLowerCase();
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 { normalizeGroupMeAllowEntry, normalizeGroupMeUserId } from "./normalize.js";
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 = normalizeGroupMeUserId(params.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
+ }