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