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/dist/src/parse.js CHANGED
@@ -50,9 +50,7 @@ function parseStringArray(value) {
50
50
  if (!Array.isArray(value)) {
51
51
  return [];
52
52
  }
53
- return value
54
- .map((entry) => readString(entry))
55
- .filter((entry) => Boolean(entry));
53
+ return value.map((entry) => readString(entry)).filter((entry) => Boolean(entry));
56
54
  }
57
55
  function parseAttachment(entry) {
58
56
  if (!isRecord(entry)) {
@@ -121,13 +119,7 @@ export function parseGroupMeCallback(data) {
121
119
  const groupId = readString(data.group_id);
122
120
  const sourceGuid = readString(data.source_guid);
123
121
  const createdAt = readNumber(data.created_at);
124
- if (!id ||
125
- !name ||
126
- !senderType ||
127
- !senderId ||
128
- !userId ||
129
- !groupId ||
130
- !sourceGuid) {
122
+ if (!id || !name || !senderType || !senderId || !userId || !groupId || !sourceGuid) {
131
123
  return null;
132
124
  }
133
125
  if (typeof createdAt !== "number") {
@@ -150,7 +142,7 @@ export function parseGroupMeCallback(data) {
150
142
  attachments: parseAttachments(data.attachments),
151
143
  };
152
144
  }
153
- export function hasImageAttachment(attachments) {
145
+ function hasImageAttachment(attachments) {
154
146
  return attachments.some((attachment) => attachment.type === "image");
155
147
  }
156
148
  export function shouldProcessCallback(msg) {
@@ -171,9 +163,7 @@ export function extractImageUrls(attachments) {
171
163
  .map((attachment) => attachment.url);
172
164
  }
173
165
  function normalizeMentionText(text) {
174
- return text
175
- .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
176
- .toLowerCase();
166
+ return text.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "").toLowerCase();
177
167
  }
178
168
  function buildRegexes(patterns) {
179
169
  if (!patterns || patterns.length === 0) {
@@ -1,4 +1,4 @@
1
- import { normalizeGroupMeAllowEntry, normalizeStringId, } from "./normalize.js";
1
+ import { normalizeGroupMeAllowEntry, normalizeStringId } from "./normalize.js";
2
2
  export function resolveSenderAccess(params) {
3
3
  const senderId = normalizeStringId(params.senderId);
4
4
  if (!senderId) {
@@ -1,4 +1,8 @@
1
1
  const DEFAULT_MAX_TRACKED_KEYS = 10_000;
2
+ function positiveIntegerAtLeastOne(value) {
3
+ const normalized = Math.floor(value);
4
+ return Number.isFinite(normalized) ? Math.max(1, normalized) : 1;
5
+ }
2
6
  function allowInWindow(params) {
3
7
  const { state, key, limit, windowMs, now } = params;
4
8
  const current = state.get(key) ?? [];
@@ -22,10 +26,10 @@ export class GroupMeRateLimiter {
22
26
  bySender = new Map();
23
27
  inFlight = 0;
24
28
  constructor(params) {
25
- this.windowMs = Math.max(1, Math.floor(params.windowMs));
26
- this.maxRequestsPerIp = Math.max(1, Math.floor(params.maxRequestsPerIp));
27
- this.maxRequestsPerSender = Math.max(1, Math.floor(params.maxRequestsPerSender));
28
- this.maxConcurrent = Math.max(1, Math.floor(params.maxConcurrent));
29
+ this.windowMs = positiveIntegerAtLeastOne(params.windowMs);
30
+ this.maxRequestsPerIp = positiveIntegerAtLeastOne(params.maxRequestsPerIp);
31
+ this.maxRequestsPerSender = positiveIntegerAtLeastOne(params.maxRequestsPerSender);
32
+ this.maxConcurrent = positiveIntegerAtLeastOne(params.maxConcurrent);
29
33
  this.maxTrackedKeys = DEFAULT_MAX_TRACKED_KEYS;
30
34
  }
31
35
  evaluate(params, now = Date.now()) {
@@ -84,12 +88,13 @@ export class GroupMeRateLimiter {
84
88
  }
85
89
  }
86
90
  capStateSize(state) {
91
+ // Only triggers past DEFAULT_MAX_TRACKED_KEYS (10k) distinct keys within a single
92
+ // window — not exercised in unit tests.
93
+ /* v8 ignore start */
87
94
  while (state.size > this.maxTrackedKeys) {
88
95
  const oldest = state.keys().next().value;
89
- if (!oldest) {
90
- return;
91
- }
92
96
  state.delete(oldest);
93
97
  }
98
+ /* v8 ignore stop */
94
99
  }
95
100
  }
@@ -32,9 +32,6 @@ export class GroupMeReplayCache {
32
32
  evictOverflow() {
33
33
  while (this.entries.size > this.maxEntries) {
34
34
  const oldest = this.entries.keys().next().value;
35
- if (!oldest) {
36
- return;
37
- }
38
35
  this.entries.delete(oldest);
39
36
  }
40
37
  }
@@ -0,0 +1,49 @@
1
+ import { collectSimpleChannelFieldAssignments, getChannelSurface, } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
2
+ const secretFields = ["botId", "accessToken", "callbackToken"];
3
+ export const secretTargetRegistryEntries = secretFields.flatMap((field) => [
4
+ {
5
+ id: `channels.groupme.accounts.*.${field}`,
6
+ targetType: `channels.groupme.accounts.*.${field}`,
7
+ configFile: "openclaw.json",
8
+ pathPattern: `channels.groupme.accounts.*.${field}`,
9
+ secretShape: "secret_input",
10
+ expectedResolvedValue: "string",
11
+ includeInPlan: true,
12
+ includeInConfigure: true,
13
+ includeInAudit: true,
14
+ },
15
+ {
16
+ id: `channels.groupme.${field}`,
17
+ targetType: `channels.groupme.${field}`,
18
+ configFile: "openclaw.json",
19
+ pathPattern: `channels.groupme.${field}`,
20
+ secretShape: "secret_input",
21
+ expectedResolvedValue: "string",
22
+ includeInPlan: true,
23
+ includeInConfigure: true,
24
+ includeInAudit: true,
25
+ },
26
+ ]);
27
+ export function collectRuntimeConfigAssignments(params) {
28
+ const resolved = getChannelSurface(params.config, "groupme");
29
+ if (!resolved) {
30
+ return;
31
+ }
32
+ const { channel, surface } = resolved;
33
+ for (const field of secretFields) {
34
+ collectSimpleChannelFieldAssignments({
35
+ channelKey: "groupme",
36
+ field,
37
+ channel,
38
+ surface,
39
+ defaults: params.defaults,
40
+ context: params.context,
41
+ topInactiveReason: `no enabled account inherits this top-level GroupMe ${field}.`,
42
+ accountInactiveReason: "GroupMe account is disabled.",
43
+ });
44
+ }
45
+ }
46
+ export const channelSecrets = {
47
+ secretTargetRegistryEntries,
48
+ collectRuntimeConfigAssignments,
49
+ };
@@ -15,7 +15,7 @@ function normalizeIpCandidate(raw) {
15
15
  return "";
16
16
  }
17
17
  if (value.includes(",")) {
18
- value = value.split(",")[0]?.trim() ?? "";
18
+ value = value.split(",")[0].trim();
19
19
  }
20
20
  if (value.startsWith("[")) {
21
21
  const endIndex = value.indexOf("]");
@@ -36,9 +36,9 @@ function normalizeIpCandidate(raw) {
36
36
  if (isIP(value) === 0) {
37
37
  const maybeWithPort = value.split(":");
38
38
  if (maybeWithPort.length === 2 &&
39
- /^\d+$/.test(maybeWithPort[1] ?? "") &&
40
- isIP(maybeWithPort[0] ?? "") === 4) {
41
- value = maybeWithPort[0] ?? "";
39
+ /^\d+$/.test(maybeWithPort[1]) &&
40
+ isIP(maybeWithPort[0]) === 4) {
41
+ value = maybeWithPort[0];
42
42
  }
43
43
  }
44
44
  return isIP(value) === 0 ? "" : value;
@@ -59,7 +59,7 @@ function normalizeHost(value) {
59
59
  return "";
60
60
  }
61
61
  if (host.includes(",")) {
62
- host = host.split(",")[0]?.trim() ?? "";
62
+ host = host.split(",")[0].trim();
63
63
  }
64
64
  if (!host) {
65
65
  return "";
@@ -75,9 +75,8 @@ function normalizeHost(value) {
75
75
  return "";
76
76
  }
77
77
  const maybeWithoutPort = host.split(":");
78
- if (maybeWithoutPort.length === 2 &&
79
- /^\d+$/.test(maybeWithoutPort[1] ?? "")) {
80
- host = maybeWithoutPort[0] ?? "";
78
+ if (maybeWithoutPort.length === 2 && /^\d+$/.test(maybeWithoutPort[1])) {
79
+ host = maybeWithoutPort[0];
81
80
  }
82
81
  return host.trim();
83
82
  }
@@ -85,12 +84,16 @@ function parseProxyRules(entries) {
85
84
  const rules = [];
86
85
  for (const entry of entries) {
87
86
  const raw = entry.trim();
87
+ // resolveGroupMeSecurity already trims and Boolean-filters these entries, so a
88
+ // blank value never reaches this guard; kept as defense in depth.
89
+ /* v8 ignore start */
88
90
  if (!raw) {
89
91
  continue;
90
92
  }
93
+ /* v8 ignore stop */
91
94
  if (raw.includes("/")) {
92
95
  const [network, prefixRaw] = raw.split("/");
93
- const normalizedNetwork = normalizeIpCandidate(network ?? "");
96
+ const normalizedNetwork = normalizeIpCandidate(network);
94
97
  const ipVersion = isIP(normalizedNetwork);
95
98
  if (!ipVersion) {
96
99
  continue;
@@ -148,22 +151,9 @@ function createTrustedProxyMatcher(entries) {
148
151
  return blockList.check(normalized, "ipv6");
149
152
  };
150
153
  }
151
- function extractCallbackToken(callbackUrl) {
152
- const raw = callbackUrl?.trim() ?? "";
153
- if (!raw) {
154
- return "";
155
- }
156
- try {
157
- const parsed = new URL(raw, "http://localhost");
158
- return parsed.searchParams.get("k")?.trim() ?? "";
159
- }
160
- catch {
161
- throw new Error(`Invalid callbackUrl: unable to parse "${raw}"`);
162
- }
163
- }
164
154
  export function resolveGroupMeSecurity(accountConfig) {
165
155
  const security = (accountConfig.security ?? {});
166
- const callbackToken = extractCallbackToken(accountConfig.callbackUrl);
156
+ const callbackToken = readTrimmed(accountConfig.callbackToken) ?? "";
167
157
  const groupId = readTrimmed(accountConfig.groupId) ?? "";
168
158
  const allowedMimePrefixes = Array.isArray(security.media?.allowedMimePrefixes)
169
159
  ? security.media.allowedMimePrefixes
@@ -185,12 +175,10 @@ export function resolveGroupMeSecurity(accountConfig) {
185
175
  callbackRejectStatus: 404,
186
176
  groupId,
187
177
  replay: {
188
- enabled: true,
189
178
  ttlSeconds: positiveIntOrDefault(security.replay?.ttlSeconds, 600),
190
179
  maxEntries: positiveIntOrDefault(security.replay?.maxEntries, 10_000),
191
180
  },
192
181
  rateLimit: {
193
- enabled: true,
194
182
  windowMs: positiveIntOrDefault(security.rateLimit?.windowMs, 60_000),
195
183
  maxRequestsPerIp: positiveIntOrDefault(security.rateLimit?.maxRequestsPerIp, 120),
196
184
  maxRequestsPerSender: positiveIntOrDefault(security.rateLimit?.maxRequestsPerSender, 60),
@@ -245,7 +233,7 @@ export function checkGroupBinding(params) {
245
233
  }
246
234
  return { ok: true };
247
235
  }
248
- export function redactCallbackUrl(raw, security) {
236
+ export function redactWebhookUrl(raw, security) {
249
237
  if (!security.callbackToken) {
250
238
  return raw;
251
239
  }
@@ -264,9 +252,7 @@ export function redactCallbackUrl(raw, security) {
264
252
  export function validateProxyRequest(params) {
265
253
  const remoteIp = normalizeIpCandidate(params.remoteAddress) || "unknown";
266
254
  const proxyConfig = params.security.proxy;
267
- const defaultProto = params.socketEncrypted
268
- ? "https"
269
- : "http";
255
+ const defaultProto = params.socketEncrypted ? "https" : "http";
270
256
  const hostHeader = normalizeHost(getHeaderValue(params.headers, "host"));
271
257
  if (!proxyConfig.enabled) {
272
258
  return {
@@ -281,17 +267,14 @@ export function validateProxyRequest(params) {
281
267
  },
282
268
  };
283
269
  }
284
- const fromTrustedProxy = proxyConfig.trustedProxyCidrs.length > 0 &&
285
- proxyConfig.isTrustedProxy(remoteIp);
270
+ const fromTrustedProxy = proxyConfig.trustedProxyCidrs.length > 0 && proxyConfig.isTrustedProxy(remoteIp);
286
271
  const forwardedFor = normalizeIpCandidate(getHeaderValue(params.headers, "x-forwarded-for"));
287
272
  const forwardedHost = normalizeHost(getHeaderValue(params.headers, "x-forwarded-host"));
288
273
  const forwardedProtoRaw = getHeaderValue(params.headers, "x-forwarded-proto")
289
274
  .split(",")[0]
290
275
  ?.trim()
291
276
  .toLowerCase();
292
- const forwardedProto = forwardedProtoRaw === "http" || forwardedProtoRaw === "https"
293
- ? forwardedProtoRaw
294
- : null;
277
+ const forwardedProto = forwardedProtoRaw === "http" || forwardedProtoRaw === "https" ? forwardedProtoRaw : null;
295
278
  const usingForwardedHeaders = fromTrustedProxy && Boolean(forwardedFor || forwardedHost || forwardedProto);
296
279
  const effectiveClientIp = usingForwardedHeaders && forwardedFor ? forwardedFor : remoteIp;
297
280
  const effectiveHost = usingForwardedHeaders && forwardedHost ? forwardedHost : hostHeader;
package/dist/src/send.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { SsrFBlockedError, fetchWithSsrFGuard } from "openclaw/plugin-sdk";
2
+ import { fetchWithSsrFGuard, SsrFBlockedError } from "openclaw/plugin-sdk/infra-runtime";
3
3
  import { resolveGroupMeAccount } from "./accounts.js";
4
4
  import { getGroupMeRuntime } from "./runtime.js";
5
5
  import { resolveGroupMeSecurity } from "./security.js";
6
- export const GROUPME_API_BASE = "https://api.groupme.com/v3";
7
- export const GROUPME_IMAGE_SERVICE = "https://image.groupme.com";
6
+ const GROUPME_API_BASE = "https://api.groupme.com/v3";
7
+ const GROUPME_IMAGE_SERVICE = "https://image.groupme.com";
8
8
  export const GROUPME_MAX_TEXT_LENGTH = 1000;
9
9
  export async function sendGroupMeMessage(params) {
10
10
  const fetchFn = params.fetchFn ?? fetch;
11
+ const apiBaseUrl = params.apiBaseUrl ?? GROUPME_API_BASE;
11
12
  const payload = {
12
13
  bot_id: params.botId,
13
14
  text: params.text,
@@ -15,7 +16,7 @@ export async function sendGroupMeMessage(params) {
15
16
  if (params.pictureUrl) {
16
17
  payload.picture_url = params.pictureUrl;
17
18
  }
18
- const response = await fetchFn(`${GROUPME_API_BASE}/bots/post`, {
19
+ const response = await fetchFn(`${apiBaseUrl}/bots/post`, {
19
20
  method: "POST",
20
21
  headers: {
21
22
  "Content-Type": "application/json",
@@ -31,15 +32,15 @@ export async function sendGroupMeMessage(params) {
31
32
  };
32
33
  }
33
34
  function extractPictureUrl(value) {
34
- const url = value?.payload
35
- ?.picture_url;
35
+ const url = value?.payload?.picture_url;
36
36
  if (typeof url !== "string")
37
37
  return null;
38
38
  return url.trim() || null;
39
39
  }
40
40
  export async function uploadGroupMeImage(params) {
41
41
  const fetchFn = params.fetchFn ?? fetch;
42
- const response = await fetchFn(`${GROUPME_IMAGE_SERVICE}/pictures`, {
42
+ const imageBaseUrl = params.imageBaseUrl ?? GROUPME_IMAGE_SERVICE;
43
+ const response = await fetchFn(`${imageBaseUrl}/pictures`, {
43
44
  method: "POST",
44
45
  headers: {
45
46
  "X-Access-Token": params.accessToken,
@@ -101,8 +102,7 @@ async function downloadRemoteMedia(params) {
101
102
  throw new Error(`GroupMe media download failed: ${response.status} ${response.statusText}`);
102
103
  }
103
104
  const contentLength = Number(response.headers.get("content-length"));
104
- if (Number.isFinite(contentLength) &&
105
- contentLength > params.maxDownloadBytes) {
105
+ if (Number.isFinite(contentLength) && contentLength > params.maxDownloadBytes) {
106
106
  throw new Error(`GroupMe media download exceeds maxDownloadBytes (${contentLength} > ${params.maxDownloadBytes})`);
107
107
  }
108
108
  const contentType = enforceMimePolicy({
@@ -130,6 +130,9 @@ function wrapFetchWithTimeout(fetchFn, timeoutMs) {
130
130
  const timeout = setTimeout(() => {
131
131
  controller.abort("GroupMe media fetch timed out");
132
132
  }, timeoutMs);
133
+ // Upstream init.signal bridging: our call sites never pass one, so the abort
134
+ // wiring below is kept for callers that do but is not exercised by tests.
135
+ /* v8 ignore start */
133
136
  const upstreamSignal = init?.signal;
134
137
  const onAbort = () => controller.abort(upstreamSignal?.reason);
135
138
  if (upstreamSignal) {
@@ -140,6 +143,7 @@ function wrapFetchWithTimeout(fetchFn, timeoutMs) {
140
143
  upstreamSignal.addEventListener("abort", onAbort, { once: true });
141
144
  }
142
145
  }
146
+ /* v8 ignore stop */
143
147
  try {
144
148
  return await base(input, {
145
149
  ...init,
@@ -148,17 +152,16 @@ function wrapFetchWithTimeout(fetchFn, timeoutMs) {
148
152
  }
149
153
  finally {
150
154
  clearTimeout(timeout);
155
+ /* v8 ignore start */
151
156
  if (upstreamSignal) {
152
157
  upstreamSignal.removeEventListener("abort", onAbort);
153
158
  }
159
+ /* v8 ignore stop */
154
160
  }
155
161
  };
156
162
  }
157
163
  function enforceMimePolicy(params) {
158
- const contentType = (params.contentType ?? "")
159
- .split(";")[0]
160
- ?.trim()
161
- .toLowerCase();
164
+ const contentType = (params.contentType ?? "").split(";")[0]?.trim().toLowerCase();
162
165
  if (!contentType ||
163
166
  !params.allowedMimePrefixes.some((prefix) => contentType.startsWith(prefix.toLowerCase()))) {
164
167
  throw new Error(`GroupMe media download blocked by MIME policy (${contentType || "missing content-type"})`);
@@ -234,6 +237,7 @@ export async function sendGroupMeText(params) {
234
237
  botId: account.botId,
235
238
  text: params.text,
236
239
  fetchFn: params.fetchFn,
240
+ apiBaseUrl: params.apiBaseUrl,
237
241
  });
238
242
  }
239
243
  export async function sendGroupMeMedia(params) {
@@ -261,11 +265,13 @@ export async function sendGroupMeMedia(params) {
261
265
  imageData: data,
262
266
  contentType,
263
267
  fetchFn: params.fetchFn,
268
+ imageBaseUrl: params.imageBaseUrl,
264
269
  });
265
270
  return sendGroupMeMessage({
266
271
  botId: account.botId,
267
272
  text: params.text,
268
273
  pictureUrl,
269
274
  fetchFn: params.fetchFn,
275
+ apiBaseUrl: params.apiBaseUrl,
270
276
  });
271
277
  }
package/index.ts CHANGED
@@ -1,17 +1,22 @@
1
- import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
- import { groupmePlugin } from "./src/channel.js";
4
- import { setGroupMeRuntime } from "./src/runtime.js";
1
+ import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
5
2
 
6
- const plugin = {
3
+ const plugin = defineBundledChannelEntry({
7
4
  id: "groupme",
8
5
  name: "GroupMe",
9
6
  description: "GroupMe channel plugin",
10
- configSchema: emptyPluginConfigSchema(),
11
- register(api: OpenClawPluginApi) {
12
- setGroupMeRuntime(api.runtime);
13
- api.registerChannel({ plugin: groupmePlugin as ChannelPlugin });
7
+ importMetaUrl: import.meta.url,
8
+ plugin: {
9
+ specifier: "./channel-plugin-api.js",
10
+ exportName: "groupmePlugin",
14
11
  },
15
- };
12
+ secrets: {
13
+ specifier: "./secret-contract-api.js",
14
+ exportName: "channelSecrets",
15
+ },
16
+ runtime: {
17
+ specifier: "./runtime-setter-api.js",
18
+ exportName: "setGroupMeRuntime",
19
+ },
20
+ });
16
21
 
17
22
  export default plugin;
@@ -2,24 +2,23 @@
2
2
  "id": "groupme",
3
3
  "name": "GroupMe",
4
4
  "description": "GroupMe channel plugin for OpenClaw.",
5
- "channels": [
6
- "groupme"
7
- ],
5
+ "channels": ["groupme"],
8
6
  "activation": {
9
- "onStartup": true,
10
- "onChannels": [
11
- "groupme"
12
- ]
7
+ "onStartup": false,
8
+ "onChannels": ["groupme"]
9
+ },
10
+ "channelConfigs": {
11
+ "groupme": {
12
+ "label": "GroupMe",
13
+ "description": "GroupMe Bot API webhook integration for group chats.",
14
+ "schema": {
15
+ "type": "object",
16
+ "additionalProperties": true
17
+ }
18
+ }
13
19
  },
14
20
  "channelEnvVars": {
15
- "groupme": [
16
- "GROUPME_BOT_ID",
17
- "GROUPME_ACCESS_TOKEN",
18
- "GROUPME_BOT_NAME",
19
- "GROUPME_GROUP_ID",
20
- "GROUPME_PUBLIC_DOMAIN",
21
- "GROUPME_CALLBACK_URL"
22
- ]
21
+ "groupme": ["GROUPME_BOT_ID", "GROUPME_ACCESS_TOKEN", "GROUPME_CALLBACK_TOKEN"]
23
22
  },
24
23
  "configSchema": {
25
24
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-groupme",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "OpenClaw GroupMe channel plugin",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -20,33 +20,59 @@
20
20
  },
21
21
  "type": "module",
22
22
  "engines": {
23
- "node": ">=18"
23
+ "node": ">=22.19.0"
24
24
  },
25
25
  "files": [
26
26
  "openclaw.plugin.json",
27
27
  "dist/**/*.js",
28
28
  "index.ts",
29
+ "channel-plugin-api.ts",
30
+ "runtime-setter-api.ts",
31
+ "secret-contract-api.ts",
32
+ "setup-entry.ts",
33
+ "setup-plugin-api.ts",
29
34
  "src/**/*.ts",
30
35
  "LICENSE",
31
36
  "README.md"
32
37
  ],
33
38
  "scripts": {
34
39
  "build": "tsc -p tsconfig.build.json",
40
+ "check": "npm run lint && npm run typecheck && npm run test:unit && npm run test:integration && npm run build && npm run knip",
41
+ "commitlint": "commitlint --edit",
42
+ "format": "biome format --write .",
43
+ "format:check": "biome format .",
44
+ "knip": "knip",
45
+ "lint": "biome check .",
46
+ "lint:fix": "biome check --write .",
35
47
  "prepack": "npm run build",
36
- "test": "vitest run",
48
+ "prepare": "lefthook install",
49
+ "test": "npm run test:unit",
50
+ "test:coverage": "vitest run tests/unit tests/integration --coverage --no-file-parallelism",
51
+ "test:integration": "vitest run tests/integration --no-file-parallelism",
52
+ "test:live": "vitest run tests/live",
53
+ "test:live:api": "vitest run tests/live/groupme-api-live.test.ts",
54
+ "test:live:plugin-outbound": "vitest run tests/live/groupme-plugin-outbound-live.test.ts",
55
+ "test:unit": "vitest run tests/unit",
56
+ "test:watch": "vitest tests/unit tests/integration",
37
57
  "typecheck": "tsc --noEmit"
38
58
  },
39
59
  "dependencies": {
40
60
  "zod": ">=4.3.6 <5"
41
61
  },
42
62
  "devDependencies": {
43
- "@types/node": "20.19.41",
44
- "openclaw": "2026.2.26",
63
+ "@biomejs/biome": "^2.4.16",
64
+ "@commitlint/cli": "^20.2.0",
65
+ "@commitlint/config-conventional": "^20.0.0",
66
+ "@types/node": "^22.19.20",
67
+ "@vitest/coverage-v8": "^4.1.8",
68
+ "knip": "^5.71.0",
69
+ "lefthook": "^2.1.9",
70
+ "openclaw": "2026.6.1",
45
71
  "typescript": "^6.0.3",
46
72
  "vitest": "^4.0.18"
47
73
  },
48
74
  "peerDependencies": {
49
- "openclaw": ">=2026.2.26 <2026.6.0"
75
+ "openclaw": ">=2026.6.1"
50
76
  },
51
77
  "peerDependenciesMeta": {
52
78
  "openclaw": {
@@ -57,17 +83,25 @@
57
83
  "extensions": [
58
84
  "./dist/index.js"
59
85
  ],
86
+ "setupEntry": "./dist/setup-entry.js",
60
87
  "compat": {
61
- "pluginApi": ">=2026.2.26 <2026.6.0"
88
+ "pluginApi": ">=2026.6.1"
62
89
  },
63
90
  "build": {
64
- "openclawVersion": "2026.2.26"
91
+ "openclawVersion": "2026.6.1"
65
92
  },
66
93
  "channel": {
67
94
  "id": "groupme",
68
95
  "label": "GroupMe",
69
96
  "selectionLabel": "GroupMe (Bot API)",
70
- "blurb": "GroupMe bot webhook integration (group chats only)."
97
+ "blurb": "GroupMe bot webhook integration (group chats only).",
98
+ "doctorCapabilities": {
99
+ "groupModel": "sender",
100
+ "groupAllowFromFallbackToAllowFrom": false
101
+ }
102
+ },
103
+ "startup": {
104
+ "deferConfiguredChannelFullLoadUntilAfterListen": true
71
105
  },
72
106
  "install": {
73
107
  "npmSpec": "openclaw-groupme"
@@ -0,0 +1 @@
1
+ export { setGroupMeRuntime } from "./src/runtime.js";
@@ -0,0 +1,5 @@
1
+ export {
2
+ channelSecrets,
3
+ collectRuntimeConfigAssignments,
4
+ secretTargetRegistryEntries,
5
+ } from "./src/secret-contract.js";
package/setup-entry.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
2
+
3
+ export default defineBundledChannelSetupEntry({
4
+ importMetaUrl: import.meta.url,
5
+ plugin: {
6
+ specifier: "./setup-plugin-api.js",
7
+ exportName: "groupmeSetupPlugin",
8
+ },
9
+ secrets: {
10
+ specifier: "./secret-contract-api.js",
11
+ exportName: "channelSecrets",
12
+ },
13
+ runtime: {
14
+ specifier: "./runtime-setter-api.js",
15
+ exportName: "setGroupMeRuntime",
16
+ },
17
+ });
@@ -0,0 +1,3 @@
1
+ // Keep setup entry imports narrow while reusing the same channel plugin setup
2
+ // adapter until GroupMe grows a separate setup-only surface.
3
+ export { groupmePlugin as groupmeSetupPlugin } from "./src/channel.js";