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/LICENSE +21 -0
- package/README.md +348 -70
- package/package.json +36 -11
- package/src/accounts.ts +51 -23
- package/src/channel.ts +154 -16
- package/src/config-schema.ts +73 -3
- package/src/groupme-api.ts +98 -0
- package/src/history.ts +54 -0
- package/src/inbound.ts +128 -23
- package/src/monitor.ts +275 -33
- package/src/normalize.ts +1 -9
- package/src/onboarding.ts +136 -36
- package/src/parse.ts +32 -33
- package/src/policy.ts +5 -2
- package/src/rate-limit.ts +128 -0
- package/src/replay-cache.ts +71 -0
- package/src/security.ts +460 -0
- package/src/send.ts +237 -51
- package/src/types.ts +98 -1
- package/.github/workflows/publish-npm.yml +0 -30
- package/openclaw.plugin.json +0 -9
- package/src/monitor.test.ts +0 -186
- package/src/normalize.test.ts +0 -43
- package/src/parse.test.ts +0 -162
- package/src/policy.test.ts +0 -23
- package/src/send.test.ts +0 -153
package/src/accounts.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_ACCOUNT_ID,
|
|
3
|
+
normalizeAccountId,
|
|
4
|
+
} from "openclaw/plugin-sdk/account-id";
|
|
2
5
|
import type {
|
|
3
6
|
CoreConfig,
|
|
4
7
|
GroupMeAccountConfig,
|
|
@@ -9,9 +12,11 @@ import type {
|
|
|
9
12
|
const ENV_BOT_ID = "GROUPME_BOT_ID";
|
|
10
13
|
const ENV_ACCESS_TOKEN = "GROUPME_ACCESS_TOKEN";
|
|
11
14
|
const ENV_BOT_NAME = "GROUPME_BOT_NAME";
|
|
12
|
-
const
|
|
15
|
+
const ENV_CALLBACK_URL = "GROUPME_CALLBACK_URL";
|
|
16
|
+
const ENV_GROUP_ID = "GROUPME_GROUP_ID";
|
|
17
|
+
const ENV_PUBLIC_DOMAIN = "GROUPME_PUBLIC_DOMAIN";
|
|
13
18
|
|
|
14
|
-
function readTrimmed(value: unknown): string | undefined {
|
|
19
|
+
export function readTrimmed(value: unknown): string | undefined {
|
|
15
20
|
if (typeof value !== "string") {
|
|
16
21
|
return undefined;
|
|
17
22
|
}
|
|
@@ -49,15 +54,22 @@ function resolveAccountConfig(
|
|
|
49
54
|
return accounts[accountId];
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
const hit = Object.keys(accounts).find(
|
|
57
|
+
const hit = Object.keys(accounts).find(
|
|
58
|
+
(key) => normalizeAccountId(key) === accountId,
|
|
59
|
+
);
|
|
53
60
|
return hit ? accounts[hit] : undefined;
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
function mergeAccountConfig(
|
|
63
|
+
function mergeAccountConfig(
|
|
64
|
+
cfg: CoreConfig,
|
|
65
|
+
accountId: string,
|
|
66
|
+
): GroupMeAccountConfig {
|
|
57
67
|
const raw = (cfg.channels?.groupme ?? {}) as GroupMeConfig;
|
|
58
68
|
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
|
59
69
|
const account =
|
|
60
|
-
accountId === DEFAULT_ACCOUNT_ID
|
|
70
|
+
accountId === DEFAULT_ACCOUNT_ID
|
|
71
|
+
? {}
|
|
72
|
+
: (resolveAccountConfig(cfg, accountId) ?? {});
|
|
61
73
|
|
|
62
74
|
return {
|
|
63
75
|
...base,
|
|
@@ -66,17 +78,13 @@ function mergeAccountConfig(cfg: CoreConfig, accountId: string): GroupMeAccountC
|
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
export function listGroupMeAccountIds(cfg: CoreConfig): string[] {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
ordered.unshift(DEFAULT_ACCOUNT_ID);
|
|
77
|
-
return Array.from(new Set(ordered));
|
|
78
|
-
}
|
|
79
|
-
return ordered;
|
|
81
|
+
const sorted = listConfiguredAccountIds(cfg).toSorted((a, b) =>
|
|
82
|
+
a.localeCompare(b),
|
|
83
|
+
);
|
|
84
|
+
return [
|
|
85
|
+
DEFAULT_ACCOUNT_ID,
|
|
86
|
+
...sorted.filter((id) => id !== DEFAULT_ACCOUNT_ID),
|
|
87
|
+
];
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
export function resolveDefaultGroupMeAccountId(cfg: CoreConfig): string {
|
|
@@ -94,7 +102,9 @@ export function resolveGroupMeAccount(params: {
|
|
|
94
102
|
}): ResolvedGroupMeAccount {
|
|
95
103
|
const normalizedRequested = normalizeAccountId(params.accountId);
|
|
96
104
|
const accountId =
|
|
97
|
-
normalizedRequested ||
|
|
105
|
+
normalizedRequested ||
|
|
106
|
+
resolveDefaultGroupMeAccountId(params.cfg) ||
|
|
107
|
+
DEFAULT_ACCOUNT_ID;
|
|
98
108
|
|
|
99
109
|
const merged = mergeAccountConfig(params.cfg, accountId);
|
|
100
110
|
const baseEnabled = params.cfg.channels?.groupme?.enabled !== false;
|
|
@@ -108,15 +118,31 @@ export function resolveGroupMeAccount(params: {
|
|
|
108
118
|
"";
|
|
109
119
|
const accessToken =
|
|
110
120
|
readTrimmed(merged.accessToken) ||
|
|
111
|
-
(isDefaultAccount
|
|
121
|
+
(isDefaultAccount
|
|
122
|
+
? readTrimmed(process.env[ENV_ACCESS_TOKEN])
|
|
123
|
+
: undefined) ||
|
|
112
124
|
"";
|
|
113
125
|
const botName =
|
|
114
126
|
readTrimmed(merged.botName) ||
|
|
115
127
|
(isDefaultAccount ? readTrimmed(process.env[ENV_BOT_NAME]) : undefined) ||
|
|
116
128
|
undefined;
|
|
117
|
-
const
|
|
118
|
-
readTrimmed(merged.
|
|
119
|
-
(isDefaultAccount
|
|
129
|
+
const groupId =
|
|
130
|
+
readTrimmed(merged.groupId) ||
|
|
131
|
+
(isDefaultAccount
|
|
132
|
+
? readTrimmed(process.env[ENV_GROUP_ID])
|
|
133
|
+
: undefined) ||
|
|
134
|
+
undefined;
|
|
135
|
+
const callbackUrl =
|
|
136
|
+
readTrimmed(merged.callbackUrl) ||
|
|
137
|
+
(isDefaultAccount
|
|
138
|
+
? readTrimmed(process.env[ENV_CALLBACK_URL])
|
|
139
|
+
: undefined) ||
|
|
140
|
+
undefined;
|
|
141
|
+
const publicDomain =
|
|
142
|
+
readTrimmed(merged.publicDomain) ||
|
|
143
|
+
(isDefaultAccount
|
|
144
|
+
? readTrimmed(process.env[ENV_PUBLIC_DOMAIN])
|
|
145
|
+
: undefined) ||
|
|
120
146
|
undefined;
|
|
121
147
|
|
|
122
148
|
const config: GroupMeAccountConfig = {
|
|
@@ -124,7 +150,9 @@ export function resolveGroupMeAccount(params: {
|
|
|
124
150
|
botId,
|
|
125
151
|
accessToken,
|
|
126
152
|
botName,
|
|
127
|
-
|
|
153
|
+
groupId,
|
|
154
|
+
publicDomain,
|
|
155
|
+
callbackUrl,
|
|
128
156
|
};
|
|
129
157
|
|
|
130
158
|
return {
|
package/src/channel.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import {
|
|
2
|
+
applyAccountNameToChannelSection,
|
|
2
3
|
buildChannelConfigSchema,
|
|
3
4
|
DEFAULT_ACCOUNT_ID,
|
|
4
5
|
deleteAccountFromConfigSection,
|
|
6
|
+
migrateBaseNameToDefaultAccount,
|
|
5
7
|
missingTargetError,
|
|
8
|
+
normalizeAccountId,
|
|
6
9
|
registerPluginHttpRoute,
|
|
7
10
|
setAccountEnabledInConfigSection,
|
|
8
11
|
type ChannelPlugin,
|
|
12
|
+
type ChannelSetupAdapter,
|
|
9
13
|
} from "openclaw/plugin-sdk";
|
|
10
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
CoreConfig,
|
|
16
|
+
GroupMeConfig,
|
|
17
|
+
GroupMeProbe,
|
|
18
|
+
ResolvedGroupMeAccount,
|
|
19
|
+
} from "./types.js";
|
|
11
20
|
import {
|
|
12
21
|
listGroupMeAccountIds,
|
|
13
22
|
resolveDefaultGroupMeAccountId,
|
|
@@ -22,10 +31,44 @@ import {
|
|
|
22
31
|
} from "./normalize.js";
|
|
23
32
|
import { groupmeOnboardingAdapter } from "./onboarding.js";
|
|
24
33
|
import { getGroupMeRuntime } from "./runtime.js";
|
|
25
|
-
import {
|
|
34
|
+
import { redactCallbackUrl, resolveGroupMeSecurity } from "./security.js";
|
|
35
|
+
import {
|
|
36
|
+
GROUPME_MAX_TEXT_LENGTH,
|
|
37
|
+
sendGroupMeMedia,
|
|
38
|
+
sendGroupMeText,
|
|
39
|
+
} from "./send.js";
|
|
26
40
|
|
|
27
41
|
const CHANNEL_ID = "groupme" as const;
|
|
28
42
|
|
|
43
|
+
function normalizeCallbackUrl(raw: string | undefined): string {
|
|
44
|
+
const trimmed = raw?.trim() ?? "";
|
|
45
|
+
if (!trimmed) {
|
|
46
|
+
return "/groupme";
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const parsed = new URL(trimmed, "http://localhost");
|
|
50
|
+
return parsed.pathname || "/groupme";
|
|
51
|
+
} catch {
|
|
52
|
+
const noQuery = trimmed.split("?")[0] ?? trimmed;
|
|
53
|
+
if (!noQuery) {
|
|
54
|
+
return "/groupme";
|
|
55
|
+
}
|
|
56
|
+
return noQuery.startsWith("/") ? noQuery : `/${noQuery}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function redactWebhookPath(
|
|
61
|
+
account: ResolvedGroupMeAccount,
|
|
62
|
+
callbackUrl: string | undefined,
|
|
63
|
+
): string {
|
|
64
|
+
const normalized = callbackUrl?.trim() || "/groupme";
|
|
65
|
+
const security = resolveGroupMeSecurity(account.config);
|
|
66
|
+
if (!security.logging.redactSecrets) {
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
69
|
+
return redactCallbackUrl(normalized, security);
|
|
70
|
+
}
|
|
71
|
+
|
|
29
72
|
const meta = {
|
|
30
73
|
id: CHANNEL_ID,
|
|
31
74
|
label: "GroupMe",
|
|
@@ -38,10 +81,90 @@ const meta = {
|
|
|
38
81
|
quickstartAllowFrom: true,
|
|
39
82
|
};
|
|
40
83
|
|
|
41
|
-
export const groupmePlugin: ChannelPlugin<
|
|
84
|
+
export const groupmePlugin: ChannelPlugin<
|
|
85
|
+
ResolvedGroupMeAccount,
|
|
86
|
+
GroupMeProbe
|
|
87
|
+
> = {
|
|
42
88
|
id: CHANNEL_ID,
|
|
43
89
|
meta,
|
|
44
90
|
onboarding: groupmeOnboardingAdapter,
|
|
91
|
+
setup: {
|
|
92
|
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
93
|
+
|
|
94
|
+
applyAccountName: ({ cfg, accountId, name }) =>
|
|
95
|
+
applyAccountNameToChannelSection({
|
|
96
|
+
cfg,
|
|
97
|
+
channelKey: "groupme",
|
|
98
|
+
accountId,
|
|
99
|
+
name,
|
|
100
|
+
}),
|
|
101
|
+
|
|
102
|
+
validateInput: ({ input }) => {
|
|
103
|
+
if (!input.token?.trim()) {
|
|
104
|
+
return "GroupMe Bot ID is required (--token <bot-id>)";
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
110
|
+
let next = applyAccountNameToChannelSection({
|
|
111
|
+
cfg,
|
|
112
|
+
channelKey: "groupme",
|
|
113
|
+
accountId,
|
|
114
|
+
name: input.name,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (accountId !== DEFAULT_ACCOUNT_ID) {
|
|
118
|
+
next = migrateBaseNameToDefaultAccount({
|
|
119
|
+
cfg: next,
|
|
120
|
+
channelKey: "groupme",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const updates: Record<string, unknown> = { enabled: true };
|
|
125
|
+
if (input.token?.trim()) updates.botId = input.token.trim();
|
|
126
|
+
if (input.accessToken?.trim())
|
|
127
|
+
updates.accessToken = input.accessToken.trim();
|
|
128
|
+
if (input.webhookUrl?.trim()) {
|
|
129
|
+
updates.callbackUrl = input.webhookUrl.trim();
|
|
130
|
+
} else if (input.webhookPath?.trim()) {
|
|
131
|
+
updates.callbackUrl = input.webhookPath.trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const section = (next.channels?.groupme ?? {}) as GroupMeConfig;
|
|
135
|
+
|
|
136
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
137
|
+
return {
|
|
138
|
+
...next,
|
|
139
|
+
channels: {
|
|
140
|
+
...next.channels,
|
|
141
|
+
groupme: {
|
|
142
|
+
...section,
|
|
143
|
+
...updates,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
...next,
|
|
151
|
+
channels: {
|
|
152
|
+
...next.channels,
|
|
153
|
+
groupme: {
|
|
154
|
+
...section,
|
|
155
|
+
enabled: true,
|
|
156
|
+
accounts: {
|
|
157
|
+
...(section.accounts ?? {}),
|
|
158
|
+
[accountId]: {
|
|
159
|
+
...(section.accounts?.[accountId] ?? {}),
|
|
160
|
+
...updates,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
} satisfies ChannelSetupAdapter,
|
|
45
168
|
capabilities: {
|
|
46
169
|
chatTypes: ["group"],
|
|
47
170
|
media: true,
|
|
@@ -53,7 +176,8 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
53
176
|
listAccountIds: (cfg) => listGroupMeAccountIds(cfg as CoreConfig),
|
|
54
177
|
resolveAccount: (cfg, accountId) =>
|
|
55
178
|
resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }),
|
|
56
|
-
defaultAccountId: (cfg) =>
|
|
179
|
+
defaultAccountId: (cfg) =>
|
|
180
|
+
resolveDefaultGroupMeAccountId(cfg as CoreConfig),
|
|
57
181
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
58
182
|
setAccountEnabledInConfigSection({
|
|
59
183
|
cfg: cfg as CoreConfig,
|
|
@@ -72,12 +196,16 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
72
196
|
"botId",
|
|
73
197
|
"accessToken",
|
|
74
198
|
"botName",
|
|
75
|
-
"
|
|
199
|
+
"groupId",
|
|
200
|
+
"publicDomain",
|
|
201
|
+
"callbackUrl",
|
|
76
202
|
"mentionPatterns",
|
|
77
203
|
"requireMention",
|
|
204
|
+
"historyLimit",
|
|
78
205
|
"allowFrom",
|
|
79
206
|
"textChunkLimit",
|
|
80
207
|
"responsePrefix",
|
|
208
|
+
"security",
|
|
81
209
|
],
|
|
82
210
|
}),
|
|
83
211
|
isConfigured: (account) => account.configured,
|
|
@@ -87,12 +215,14 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
87
215
|
enabled: account.enabled,
|
|
88
216
|
configured: account.configured,
|
|
89
217
|
botId: account.botId ? "***" : "",
|
|
90
|
-
|
|
218
|
+
publicDomain: account.config.publicDomain ?? "",
|
|
219
|
+
callbackUrl: redactWebhookPath(account, account.config.callbackUrl),
|
|
91
220
|
}),
|
|
92
221
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
93
|
-
(
|
|
94
|
-
(
|
|
95
|
-
|
|
222
|
+
(
|
|
223
|
+
resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }).config
|
|
224
|
+
.allowFrom ?? []
|
|
225
|
+
).map((entry) => String(entry)),
|
|
96
226
|
formatAllowFrom: ({ allowFrom }) =>
|
|
97
227
|
allowFrom
|
|
98
228
|
.map((entry) => normalizeGroupMeAllowEntry(String(entry)))
|
|
@@ -109,7 +239,8 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
109
239
|
},
|
|
110
240
|
outbound: {
|
|
111
241
|
deliveryMode: "direct",
|
|
112
|
-
chunker: (text, limit) =>
|
|
242
|
+
chunker: (text, limit) =>
|
|
243
|
+
getGroupMeRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
113
244
|
chunkerMode: "markdown",
|
|
114
245
|
textChunkLimit: GROUPME_MAX_TEXT_LENGTH,
|
|
115
246
|
resolveTarget: ({ to }) => {
|
|
@@ -215,7 +346,7 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
215
346
|
buildChannelSummary: ({ snapshot }) => ({
|
|
216
347
|
configured: snapshot.configured ?? false,
|
|
217
348
|
running: snapshot.running ?? false,
|
|
218
|
-
|
|
349
|
+
callbackUrl: snapshot.webhookPath ?? null,
|
|
219
350
|
lastStartAt: snapshot.lastStartAt ?? null,
|
|
220
351
|
lastStopAt: snapshot.lastStopAt ?? null,
|
|
221
352
|
lastInboundAt: snapshot.lastInboundAt ?? null,
|
|
@@ -229,7 +360,7 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
229
360
|
configured: account.configured,
|
|
230
361
|
botId: account.botId ? "***" : "",
|
|
231
362
|
tokenSource: account.accessToken ? "configured" : "none",
|
|
232
|
-
webhookPath: account.config.
|
|
363
|
+
webhookPath: redactWebhookPath(account, account.config.callbackUrl),
|
|
233
364
|
running: runtime?.running ?? false,
|
|
234
365
|
lastStartAt: runtime?.lastStartAt ?? null,
|
|
235
366
|
lastStopAt: runtime?.lastStopAt ?? null,
|
|
@@ -248,7 +379,11 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
248
379
|
);
|
|
249
380
|
}
|
|
250
381
|
|
|
251
|
-
const callbackPath = account.config.
|
|
382
|
+
const callbackPath = normalizeCallbackUrl(account.config.callbackUrl);
|
|
383
|
+
const redactedCallbackPath = redactWebhookPath(
|
|
384
|
+
account,
|
|
385
|
+
account.config.callbackUrl ?? callbackPath,
|
|
386
|
+
);
|
|
252
387
|
const unregister = registerPluginHttpRoute({
|
|
253
388
|
path: callbackPath,
|
|
254
389
|
fallbackPath: "/groupme",
|
|
@@ -256,7 +391,8 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
256
391
|
account,
|
|
257
392
|
config: ctx.cfg as CoreConfig,
|
|
258
393
|
runtime: ctx.runtime,
|
|
259
|
-
statusSink: (patch) =>
|
|
394
|
+
statusSink: (patch) =>
|
|
395
|
+
ctx.setStatus({ accountId: account.accountId, ...patch }),
|
|
260
396
|
}),
|
|
261
397
|
pluginId: CHANNEL_ID,
|
|
262
398
|
accountId: account.accountId,
|
|
@@ -267,12 +403,14 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
267
403
|
accountId: account.accountId,
|
|
268
404
|
running: true,
|
|
269
405
|
mode: "webhook",
|
|
270
|
-
webhookPath:
|
|
406
|
+
webhookPath: redactedCallbackPath,
|
|
271
407
|
lastStartAt: Date.now(),
|
|
272
408
|
lastError: null,
|
|
273
409
|
});
|
|
274
410
|
|
|
275
|
-
ctx.log?.info(
|
|
411
|
+
ctx.log?.info(
|
|
412
|
+
`[${account.accountId}] GroupMe webhook listening on ${redactedCallbackPath}`,
|
|
413
|
+
);
|
|
276
414
|
|
|
277
415
|
if (ctx.abortSignal.aborted) {
|
|
278
416
|
unregister();
|
package/src/config-schema.ts
CHANGED
|
@@ -1,8 +1,72 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
BlockStreamingCoalesceSchema,
|
|
3
|
+
MarkdownConfigSchema,
|
|
4
|
+
} from "openclaw/plugin-sdk";
|
|
2
5
|
import { z } from "zod";
|
|
3
6
|
|
|
4
7
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
|
5
8
|
|
|
9
|
+
const GroupMeReplaySchema = z
|
|
10
|
+
.object({
|
|
11
|
+
ttlSeconds: z.number().int().positive().optional(),
|
|
12
|
+
maxEntries: z.number().int().positive().optional(),
|
|
13
|
+
})
|
|
14
|
+
.strict();
|
|
15
|
+
|
|
16
|
+
const GroupMeRateLimitSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
windowMs: z.number().int().positive().optional(),
|
|
19
|
+
maxRequestsPerIp: z.number().int().positive().optional(),
|
|
20
|
+
maxRequestsPerSender: z.number().int().positive().optional(),
|
|
21
|
+
maxConcurrent: z.number().int().positive().optional(),
|
|
22
|
+
})
|
|
23
|
+
.strict();
|
|
24
|
+
|
|
25
|
+
const GroupMeMediaSecuritySchema = z
|
|
26
|
+
.object({
|
|
27
|
+
allowPrivateNetworks: z.boolean().optional().default(false),
|
|
28
|
+
maxDownloadBytes: z.number().int().positive().optional(),
|
|
29
|
+
requestTimeoutMs: z.number().int().positive().optional(),
|
|
30
|
+
allowedMimePrefixes: z.array(z.string()).optional(),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
|
|
34
|
+
const GroupMeLoggingSecuritySchema = z
|
|
35
|
+
.object({
|
|
36
|
+
redactSecrets: z.boolean().optional().default(true),
|
|
37
|
+
logRejectedRequests: z.boolean().optional().default(true),
|
|
38
|
+
})
|
|
39
|
+
.strict();
|
|
40
|
+
|
|
41
|
+
const GroupMeCommandBypassSecuritySchema = z
|
|
42
|
+
.object({
|
|
43
|
+
requireAllowFrom: z.boolean().optional().default(true),
|
|
44
|
+
requireMentionForCommands: z.boolean().optional().default(false),
|
|
45
|
+
})
|
|
46
|
+
.strict();
|
|
47
|
+
|
|
48
|
+
const GroupMeProxySecuritySchema = z
|
|
49
|
+
.object({
|
|
50
|
+
trustedProxyCidrs: z.array(z.string()).optional(),
|
|
51
|
+
allowedPublicHosts: z.array(z.string()).optional(),
|
|
52
|
+
requireHttpsProto: z.boolean().optional().default(false),
|
|
53
|
+
rejectStatus: z
|
|
54
|
+
.union([z.literal(400), z.literal(403), z.literal(404)])
|
|
55
|
+
.optional(),
|
|
56
|
+
})
|
|
57
|
+
.strict();
|
|
58
|
+
|
|
59
|
+
const GroupMeSecuritySchema = z
|
|
60
|
+
.object({
|
|
61
|
+
replay: GroupMeReplaySchema.optional(),
|
|
62
|
+
rateLimit: GroupMeRateLimitSchema.optional(),
|
|
63
|
+
media: GroupMeMediaSecuritySchema.optional(),
|
|
64
|
+
logging: GroupMeLoggingSecuritySchema.optional(),
|
|
65
|
+
commandBypass: GroupMeCommandBypassSecuritySchema.optional(),
|
|
66
|
+
proxy: GroupMeProxySecuritySchema.optional(),
|
|
67
|
+
})
|
|
68
|
+
.strict();
|
|
69
|
+
|
|
6
70
|
export const GroupMeAccountSchemaBase = z
|
|
7
71
|
.object({
|
|
8
72
|
name: z.string().optional(),
|
|
@@ -10,9 +74,12 @@ export const GroupMeAccountSchemaBase = z
|
|
|
10
74
|
botId: z.string().optional(),
|
|
11
75
|
accessToken: z.string().optional(),
|
|
12
76
|
botName: z.string().optional(),
|
|
13
|
-
|
|
77
|
+
groupId: z.string().optional(),
|
|
78
|
+
publicDomain: z.string().optional(),
|
|
79
|
+
callbackUrl: z.string().optional(),
|
|
14
80
|
mentionPatterns: z.array(z.string()).optional(),
|
|
15
81
|
requireMention: z.boolean().optional().default(true),
|
|
82
|
+
historyLimit: z.number().int().nonnegative().optional(),
|
|
16
83
|
allowFrom: z.array(allowFromEntry).optional(),
|
|
17
84
|
markdown: MarkdownConfigSchema,
|
|
18
85
|
textChunkLimit: z.number().int().positive().optional(),
|
|
@@ -20,10 +87,13 @@ export const GroupMeAccountSchemaBase = z
|
|
|
20
87
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
21
88
|
responsePrefix: z.string().optional(),
|
|
22
89
|
mediaMaxMb: z.number().positive().optional(),
|
|
90
|
+
security: GroupMeSecuritySchema.optional(),
|
|
23
91
|
})
|
|
24
92
|
.strict();
|
|
25
93
|
|
|
26
94
|
export const GroupMeConfigSchema = GroupMeAccountSchemaBase.extend({
|
|
27
|
-
accounts: z
|
|
95
|
+
accounts: z
|
|
96
|
+
.record(z.string(), GroupMeAccountSchemaBase.optional())
|
|
97
|
+
.optional(),
|
|
28
98
|
defaultAccount: z.string().optional(),
|
|
29
99
|
}).strict();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { GroupMeApiBot, GroupMeApiGroup } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const GROUPME_API_BASE = "https://api.groupme.com/v3";
|
|
4
|
+
|
|
5
|
+
async function readApiError(response: Response): Promise<string> {
|
|
6
|
+
const fallback = `GroupMe API error: ${response.status} ${response.statusText}`;
|
|
7
|
+
try {
|
|
8
|
+
const payload = (await response.json()) as { meta?: { errors?: unknown } };
|
|
9
|
+
const errors = payload?.meta?.errors;
|
|
10
|
+
if (Array.isArray(errors) && errors.length > 0) {
|
|
11
|
+
const text = errors
|
|
12
|
+
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.join("; ");
|
|
15
|
+
if (text) {
|
|
16
|
+
return `${fallback} (${text})`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// Ignore JSON parse errors; fall back to generic status text.
|
|
21
|
+
}
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readGroupsResponse(payload: unknown): GroupMeApiGroup[] {
|
|
26
|
+
const response = (payload as { response?: unknown })?.response;
|
|
27
|
+
if (!Array.isArray(response)) {
|
|
28
|
+
throw new Error("GroupMe groups fetch returned an invalid payload");
|
|
29
|
+
}
|
|
30
|
+
return response as GroupMeApiGroup[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readBotResponse(payload: unknown): GroupMeApiBot {
|
|
34
|
+
const bot = (payload as { response?: { bot?: unknown } })?.response?.bot;
|
|
35
|
+
if (!bot) {
|
|
36
|
+
throw new Error("GroupMe bot creation returned an invalid payload");
|
|
37
|
+
}
|
|
38
|
+
return bot as GroupMeApiBot;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function fetchGroups(accessToken: string): Promise<GroupMeApiGroup[]> {
|
|
42
|
+
const groups: GroupMeApiGroup[] = [];
|
|
43
|
+
let page = 1;
|
|
44
|
+
|
|
45
|
+
while (true) {
|
|
46
|
+
const url = new URL(`${GROUPME_API_BASE}/groups`);
|
|
47
|
+
url.searchParams.set("token", accessToken);
|
|
48
|
+
url.searchParams.set("per_page", "100");
|
|
49
|
+
url.searchParams.set("omit", "memberships");
|
|
50
|
+
url.searchParams.set("page", String(page));
|
|
51
|
+
|
|
52
|
+
const response = await fetch(url);
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
throw new Error(await readApiError(response));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const payload = await response.json();
|
|
58
|
+
const pageGroups = readGroupsResponse(payload);
|
|
59
|
+
if (pageGroups.length === 0) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
groups.push(...pageGroups);
|
|
64
|
+
page += 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return groups;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function createBot(params: {
|
|
71
|
+
accessToken: string;
|
|
72
|
+
name: string;
|
|
73
|
+
groupId: string;
|
|
74
|
+
callbackUrl: string;
|
|
75
|
+
}): Promise<GroupMeApiBot> {
|
|
76
|
+
const url = new URL(`${GROUPME_API_BASE}/bots`);
|
|
77
|
+
url.searchParams.set("token", params.accessToken);
|
|
78
|
+
|
|
79
|
+
const response = await fetch(url, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json" },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
bot: {
|
|
84
|
+
name: params.name,
|
|
85
|
+
group_id: params.groupId,
|
|
86
|
+
callback_url: params.callbackUrl,
|
|
87
|
+
active: true,
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const apiError = await readApiError(response);
|
|
93
|
+
throw new Error(`GroupMe bot creation failed: ${apiError}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const payload = await response.json();
|
|
97
|
+
return readBotResponse(payload);
|
|
98
|
+
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { HistoryEntry } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_GROUPME_HISTORY_LIMIT = 20;
|
|
4
|
+
|
|
5
|
+
export function resolveGroupMeHistoryLimit(configured?: number): number {
|
|
6
|
+
if (!Number.isFinite(configured)) {
|
|
7
|
+
return DEFAULT_GROUPME_HISTORY_LIMIT;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const normalized = Math.floor(configured as number);
|
|
11
|
+
if (normalized < 0) {
|
|
12
|
+
return DEFAULT_GROUPME_HISTORY_LIMIT;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return normalized;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveGroupMeBodyForAgent(params: {
|
|
19
|
+
rawBody: string;
|
|
20
|
+
imageUrls: string[];
|
|
21
|
+
}): string {
|
|
22
|
+
const { rawBody, imageUrls } = params;
|
|
23
|
+
const trimmed = rawBody.trim();
|
|
24
|
+
if (trimmed) {
|
|
25
|
+
return trimmed;
|
|
26
|
+
}
|
|
27
|
+
if (imageUrls.length > 0) {
|
|
28
|
+
return imageUrls.map((url) => `Image: ${url}`).join("\n");
|
|
29
|
+
}
|
|
30
|
+
return rawBody;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildGroupMeHistoryEntry(params: {
|
|
34
|
+
senderName: string;
|
|
35
|
+
body: string;
|
|
36
|
+
timestamp: number;
|
|
37
|
+
messageId: string;
|
|
38
|
+
}): HistoryEntry | null {
|
|
39
|
+
const body = params.body.trim();
|
|
40
|
+
if (!body) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
sender: params.senderName,
|
|
46
|
+
body,
|
|
47
|
+
timestamp: params.timestamp,
|
|
48
|
+
messageId: params.messageId,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatGroupMeHistoryEntry(entry: HistoryEntry): string {
|
|
53
|
+
return `${entry.sender}: ${entry.body}`;
|
|
54
|
+
}
|