openclaw-groupme 0.4.3 → 0.5.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.
Files changed (48) hide show
  1. package/README.md +147 -45
  2. package/channel-plugin-api.ts +3 -0
  3. package/dist/channel-plugin-api.js +3 -0
  4. package/dist/index.js +15 -9
  5. package/dist/runtime-setter-api.js +1 -0
  6. package/dist/secret-contract-api.js +1 -0
  7. package/dist/setup-entry.js +16 -0
  8. package/dist/setup-plugin-api.js +3 -0
  9. package/dist/src/accounts.js +24 -48
  10. package/dist/src/channel.js +63 -29
  11. package/dist/src/config-schema.js +10 -11
  12. package/dist/src/groupme-api.js +9 -5
  13. package/dist/src/inbound.js +18 -10
  14. package/dist/src/monitor.js +25 -27
  15. package/dist/src/normalize.js +6 -0
  16. package/dist/src/onboarding.js +364 -337
  17. package/dist/src/parse.js +4 -14
  18. package/dist/src/policy.js +1 -1
  19. package/dist/src/rate-limit.js +12 -7
  20. package/dist/src/replay-cache.js +0 -3
  21. package/dist/src/secret-contract.js +49 -0
  22. package/dist/src/security.js +17 -34
  23. package/dist/src/send.js +19 -13
  24. package/index.ts +15 -10
  25. package/openclaw.plugin.json +14 -15
  26. package/package.json +43 -9
  27. package/runtime-setter-api.ts +1 -0
  28. package/secret-contract-api.ts +5 -0
  29. package/setup-entry.ts +17 -0
  30. package/setup-plugin-api.ts +3 -0
  31. package/src/accounts.ts +29 -68
  32. package/src/channel.ts +74 -64
  33. package/src/config-schema.ts +10 -11
  34. package/src/groupme-api.ts +21 -5
  35. package/src/history.ts +1 -1
  36. package/src/inbound.ts +45 -75
  37. package/src/monitor.ts +37 -52
  38. package/src/normalize.ts +7 -1
  39. package/src/onboarding.ts +449 -409
  40. package/src/parse.ts +6 -23
  41. package/src/policy.ts +1 -4
  42. package/src/rate-limit.ts +15 -12
  43. package/src/replay-cache.ts +1 -4
  44. package/src/runtime.ts +1 -1
  45. package/src/secret-contract.ts +66 -0
  46. package/src/security.ts +28 -66
  47. package/src/send.ts +32 -38
  48. package/src/types.ts +7 -7
package/src/inbound.ts CHANGED
@@ -1,37 +1,25 @@
1
- import type {
2
- OpenClawConfig,
3
- ReplyPayload,
4
- RuntimeEnv,
5
- } from "openclaw/plugin-sdk";
1
+ import { logInboundDrop } from "openclaw/plugin-sdk/channel-logging";
2
+ import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-mention-gating";
3
+ import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-reply-options-runtime";
4
+ import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating";
5
+ import type { HistoryEntry, OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk/core";
6
6
  import {
7
7
  buildPendingHistoryContextFromMap,
8
8
  clearHistoryEntriesIfEnabled,
9
- createReplyPrefixOptions,
10
- logInboundDrop,
11
9
  recordPendingHistoryEntryIfEnabled,
12
- resolveControlCommandGate,
13
- resolveMentionGatingWithBypass,
14
- type HistoryEntry,
15
- } from "openclaw/plugin-sdk";
16
- import type {
17
- GroupMeCallbackData,
18
- ResolvedGroupMeAccount,
19
- CoreConfig,
20
- } from "./types.js";
10
+ } from "openclaw/plugin-sdk/reply-history";
11
+ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
21
12
  import {
22
13
  buildGroupMeHistoryEntry,
23
14
  formatGroupMeHistoryEntry,
24
15
  resolveGroupMeBodyForAgent,
25
16
  } from "./history.js";
26
- import { extractImageUrls, detectGroupMeMention } from "./parse.js";
17
+ import { detectGroupMeMention, extractImageUrls } from "./parse.js";
27
18
  import { resolveSenderAccess } from "./policy.js";
28
19
  import { getGroupMeRuntime } from "./runtime.js";
29
- import {
30
- GROUPME_MAX_TEXT_LENGTH,
31
- sendGroupMeMedia,
32
- sendGroupMeText,
33
- } from "./send.js";
34
20
  import { resolveGroupMeSecurity } from "./security.js";
21
+ import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText } from "./send.js";
22
+ import type { CoreConfig, GroupMeCallbackData, ResolvedGroupMeAccount } from "./types.js";
35
23
 
36
24
  const CHANNEL_ID = "groupme" as const;
37
25
 
@@ -57,9 +45,7 @@ function chunkReplyText(params: {
57
45
  return [];
58
46
  }
59
47
 
60
- return params.core.channel.text
61
- .chunkMarkdownText(trimmed, params.limit)
62
- .filter(Boolean);
48
+ return params.core.channel.text.chunkMarkdownText(trimmed, params.limit).filter(Boolean);
63
49
  }
64
50
 
65
51
  async function deliverGroupMeReply(params: {
@@ -156,20 +142,9 @@ export async function handleGroupMeInbound(params: {
156
142
  runtime: RuntimeEnv;
157
143
  groupHistories: Map<string, HistoryEntry[]>;
158
144
  historyLimit: number;
159
- statusSink?: (patch: {
160
- lastInboundAt?: number;
161
- lastOutboundAt?: number;
162
- }) => void;
145
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
163
146
  }): Promise<void> {
164
- const {
165
- message,
166
- account,
167
- config,
168
- runtime,
169
- groupHistories,
170
- historyLimit,
171
- statusSink,
172
- } = params;
147
+ const { message, account, config, runtime, groupHistories, historyLimit, statusSink } = params;
173
148
  const core = getGroupMeRuntime();
174
149
 
175
150
  const inboundTimestamp = message.createdAt * 1000;
@@ -188,9 +163,7 @@ export async function handleGroupMeInbound(params: {
188
163
  allowFrom,
189
164
  });
190
165
  if (!senderAllowed) {
191
- runtime.log?.(
192
- `groupme: drop sender ${message.senderId} (not in allowFrom)`,
193
- );
166
+ runtime.log?.(`groupme: drop sender ${message.senderId} (not in allowFrom)`);
194
167
  return;
195
168
  }
196
169
 
@@ -224,8 +197,7 @@ export async function handleGroupMeInbound(params: {
224
197
  message.text,
225
198
  config as OpenClawConfig,
226
199
  );
227
- const commandBypassNeedsAllowFrom =
228
- security.commandBypass.requireAllowFrom && hasControlCommand;
200
+ const commandBypassNeedsAllowFrom = security.commandBypass.requireAllowFrom && hasControlCommand;
229
201
  const commandBypassCanSkipMention = !(
230
202
  security.commandBypass.requireMentionForCommands &&
231
203
  requireMention &&
@@ -233,8 +205,7 @@ export async function handleGroupMeInbound(params: {
233
205
  );
234
206
 
235
207
  const commandGate = resolveControlCommandGate({
236
- useAccessGroups:
237
- config.commands?.useAccessGroups !== false || commandBypassNeedsAllowFrom,
208
+ useAccessGroups: config.commands?.useAccessGroups !== false || commandBypassNeedsAllowFrom,
238
209
  authorizers: [{ configured: allowFrom.length > 0, allowed: senderAllowed }],
239
210
  allowTextCommands,
240
211
  hasControlCommand,
@@ -257,9 +228,7 @@ export async function handleGroupMeInbound(params: {
257
228
  hasAnyMention: false,
258
229
  allowTextCommands,
259
230
  hasControlCommand: commandBypassCanSkipMention ? hasControlCommand : false,
260
- commandAuthorized: commandBypassCanSkipMention
261
- ? commandGate.commandAuthorized
262
- : false,
231
+ commandAuthorized: commandBypassCanSkipMention ? commandGate.commandAuthorized : false,
263
232
  });
264
233
 
265
234
  const imageUrls = extractImageUrls(message.attachments);
@@ -291,15 +260,10 @@ export async function handleGroupMeInbound(params: {
291
260
  return;
292
261
  }
293
262
 
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
- );
263
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
264
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
265
+ agentId: route.agentId,
266
+ });
303
267
  const previousTimestamp = core.channel.session.readSessionUpdatedAt({
304
268
  storePath,
305
269
  sessionKey: route.sessionKey,
@@ -313,6 +277,14 @@ export async function handleGroupMeInbound(params: {
313
277
  envelope: envelopeOptions,
314
278
  body: bodyForAgent,
315
279
  });
280
+ // Snapshot-then-clear the per-group buffer. This runs synchronously before the
281
+ // first `await` below, so it is atomic with respect to other inbound handlers for
282
+ // the same group (handlers run un-awaited and concurrently up to maxConcurrent).
283
+ // Accepted behavior: if two mentions for the same group arrive nearly together,
284
+ // the first handler consumes the buffered context and the second sees an empty
285
+ // buffer rather than re-reading the same entries — buffered context is consumed
286
+ // exactly once, never duplicated. Messages buffered while a reply is in flight are
287
+ // preserved because the clear happens before dispatch.
316
288
  const shouldUseHistoryBuffer = requireMention && historyLimit > 0;
317
289
  const historyEntriesForContext = shouldUseHistoryBuffer
318
290
  ? [...(groupHistories.get(message.groupId) ?? [])]
@@ -325,24 +297,22 @@ export async function handleGroupMeInbound(params: {
325
297
  });
326
298
  }
327
299
 
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;
300
+ const combinedBody = shouldUseHistoryBuffer
301
+ ? buildPendingHistoryContextFromMap({
302
+ historyMap: new Map([[message.groupId, historyEntriesForContext]]),
303
+ historyKey: message.groupId,
304
+ limit: historyLimit,
305
+ currentMessage: body,
306
+ formatEntry: formatGroupMeHistoryEntry,
307
+ })
308
+ : body;
309
+ const inboundHistory = shouldUseHistoryBuffer
310
+ ? historyEntriesForContext.map((entry) => ({
311
+ sender: entry.sender,
312
+ body: entry.body,
313
+ timestamp: entry.timestamp,
314
+ }))
315
+ : undefined;
346
316
 
347
317
  const ctxPayload = core.channel.reply.finalizeInboundContext({
348
318
  Body: combinedBody,
package/src/monitor.ts CHANGED
@@ -1,54 +1,47 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import type { HistoryEntry } from "openclaw/plugin-sdk";
3
- import type { RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import type { HistoryEntry } from "openclaw/plugin-sdk/core";
3
+ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
4
4
  import {
5
5
  readJsonBodyWithLimit,
6
6
  requestBodyErrorToText,
7
- } from "openclaw/plugin-sdk";
7
+ } from "openclaw/plugin-sdk/webhook-request-guards";
8
8
  import { resolveGroupMeHistoryLimit } from "./history.js";
9
9
  import { handleGroupMeInbound } from "./inbound.js";
10
10
  import { parseGroupMeCallback, shouldProcessCallback } from "./parse.js";
11
11
  import { GroupMeRateLimiter } from "./rate-limit.js";
12
- import { GroupMeReplayCache, buildReplayKey } from "./replay-cache.js";
12
+ import { buildReplayKey, GroupMeReplayCache } from "./replay-cache.js";
13
13
  import {
14
14
  checkGroupBinding,
15
- redactCallbackUrl,
15
+ type ResolvedGroupMeSecurity,
16
+ redactWebhookUrl,
16
17
  resolveGroupMeSecurity,
17
18
  validateProxyRequest,
18
- type ResolvedGroupMeSecurity,
19
19
  verifyCallbackAuth,
20
20
  } from "./security.js";
21
- import type {
22
- CoreConfig,
23
- ResolvedGroupMeAccount,
24
- WebhookDecision,
25
- } from "./types.js";
21
+ import type { CoreConfig, ResolvedGroupMeAccount, WebhookDecision } from "./types.js";
26
22
 
27
23
  // GroupMe callbacks are small JSON payloads; use tighter limits than the SDK
28
24
  // defaults (DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1 MB, DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30 s).
29
25
  const GROUPME_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
30
26
  const GROUPME_WEBHOOK_BODY_TIMEOUT_MS = 15_000;
31
27
 
32
- export type GroupMeWebhookHandlerParams = {
28
+ type GroupMeWebhookHandlerParams = {
33
29
  account: ResolvedGroupMeAccount;
34
30
  config: CoreConfig;
35
31
  runtime: RuntimeEnv;
36
- statusSink?: (patch: {
37
- lastInboundAt?: number;
38
- lastOutboundAt?: number;
39
- }) => void;
32
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
40
33
  };
41
34
 
42
35
  function rejectDecision(params: {
43
36
  status: number;
44
37
  reason: string;
45
- logLevel?: "debug" | "warn";
38
+ logLevel: "debug" | "warn";
46
39
  }): WebhookDecision {
47
40
  return {
48
41
  kind: "reject",
49
42
  status: params.status,
50
43
  reason: params.reason,
51
- logLevel: params.logLevel ?? "warn",
44
+ logLevel: params.logLevel,
52
45
  };
53
46
  }
54
47
 
@@ -77,6 +70,9 @@ function asRequestBodyErrorCode(
77
70
  ) {
78
71
  return value;
79
72
  }
73
+ // Only ever called with the three codes above (INVALID_JSON returns earlier in
74
+ // the handler), so this null path is unreachable defense-in-depth.
75
+ /* v8 ignore next */
80
76
  return null;
81
77
  }
82
78
 
@@ -90,10 +86,7 @@ function logWebhookRejection(params: {
90
86
  return;
91
87
  }
92
88
  const url = params.security.logging.redactSecrets
93
- ? redactCallbackUrl(
94
- `${params.reqUrl.pathname}${params.reqUrl.search}`,
95
- params.security,
96
- )
89
+ ? redactWebhookUrl(`${params.reqUrl.pathname}${params.reqUrl.search}`, params.security)
97
90
  : `${params.reqUrl.pathname}${params.reqUrl.search}`;
98
91
  const line = `groupme: webhook rejected (${params.decision.reason}) status=${params.decision.status} url=${url}`;
99
92
  if (params.decision.logLevel === "warn") {
@@ -148,14 +141,13 @@ async function decideWebhookRequest(params: {
148
141
  emptyObjectOnEmpty: false,
149
142
  });
150
143
  if (!body.ok) {
151
- let status: number;
152
- if (body.code === "PAYLOAD_TOO_LARGE") {
153
- status = 413;
154
- } else if (body.code === "REQUEST_BODY_TIMEOUT") {
155
- status = 408;
156
- } else {
157
- status = 400;
158
- }
144
+ // Map the SDK body-error code to an HTTP status; unmapped codes (INVALID_JSON,
145
+ // CONNECTION_CLOSED, ) fall back to 400.
146
+ const bodyErrorStatus: Record<string, number | undefined> = {
147
+ PAYLOAD_TOO_LARGE: 413,
148
+ REQUEST_BODY_TIMEOUT: 408,
149
+ };
150
+ const status = bodyErrorStatus[body.code] ?? 400;
159
151
  return rejectDecision({
160
152
  status,
161
153
  reason: `body_${body.code.toLowerCase()}`,
@@ -193,19 +185,13 @@ async function decideWebhookRequest(params: {
193
185
  });
194
186
  }
195
187
 
196
- if (params.security.replay.enabled) {
197
- const replay = params.replayCache.checkAndRemember(buildReplayKey(message));
198
- if (replay.kind === "duplicate") {
199
- return rejectDecision({
200
- status: 200,
201
- reason: "duplicate_replay",
202
- logLevel: "debug",
203
- });
204
- }
205
- }
206
-
207
- if (!params.security.rateLimit.enabled) {
208
- return { kind: "accept", message, release: () => undefined };
188
+ const replay = params.replayCache.checkAndRemember(buildReplayKey(message));
189
+ if (replay.kind === "duplicate") {
190
+ return rejectDecision({
191
+ status: 200,
192
+ reason: "duplicate_replay",
193
+ logLevel: "debug",
194
+ });
209
195
  }
210
196
 
211
197
  const rate = params.rateLimiter.evaluate({
@@ -227,15 +213,18 @@ export function createGroupMeWebhookHandler(
227
213
  params: GroupMeWebhookHandlerParams,
228
214
  ): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
229
215
  const groupHistories = new Map<string, HistoryEntry[]>();
230
- const historyLimit = resolveGroupMeHistoryLimit(
231
- params.account.config.historyLimit,
232
- );
216
+ const historyLimit = resolveGroupMeHistoryLimit(params.account.config.historyLimit);
233
217
  const security = resolveGroupMeSecurity(params.account.config);
234
218
  if (!security.groupId) {
235
219
  params.runtime.error?.(
236
220
  "groupme: WARNING — no groupId configured; all inbound messages will be rejected. Set groupId in your account config.",
237
221
  );
238
222
  }
223
+ if (!security.callbackToken) {
224
+ params.runtime.error?.(
225
+ "groupme: WARNING — no callbackToken configured; inbound callbacks are not token-authenticated. Anyone who learns the webhook path and group_id can post. Set callbackToken in your account config.",
226
+ );
227
+ }
239
228
  const replayCache = new GroupMeReplayCache({
240
229
  ttlSeconds: security.replay.ttlSeconds,
241
230
  maxEntries: security.replay.maxEntries,
@@ -280,9 +269,7 @@ export function createGroupMeWebhookHandler(
280
269
  }
281
270
  const requestBodyErrorCode = asRequestBodyErrorCode(code);
282
271
  if (requestBodyErrorCode) {
283
- res.end(
284
- requestBodyErrorToText(requestBodyErrorCode),
285
- );
272
+ res.end(requestBodyErrorToText(requestBodyErrorCode));
286
273
  return;
287
274
  }
288
275
  }
@@ -304,9 +291,7 @@ export function createGroupMeWebhookHandler(
304
291
  historyLimit,
305
292
  })
306
293
  .catch((err) => {
307
- params.runtime.error?.(
308
- `groupme: inbound processing failed: ${String(err)}`,
309
- );
294
+ params.runtime.error?.(`groupme: inbound processing failed: ${String(err)}`);
310
295
  })
311
296
  .finally(() => {
312
297
  release();
package/src/normalize.ts CHANGED
@@ -1,4 +1,10 @@
1
- export function normalizeStringId(raw: string | number): string | undefined {
1
+ export function normalizeStringId(raw: unknown): string | undefined {
2
+ if (typeof raw !== "string" && typeof raw !== "number") {
3
+ return undefined;
4
+ }
5
+ if (typeof raw === "number" && !Number.isFinite(raw)) {
6
+ return undefined;
7
+ }
2
8
  const normalized = String(raw).trim();
3
9
  return normalized || undefined;
4
10
  }