openclaw-groupme 0.0.4 → 0.4.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 +373 -70
- package/package.json +36 -11
- package/src/accounts.ts +51 -23
- package/src/channel.ts +163 -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 +129 -23
- package/src/monitor.ts +285 -33
- package/src/normalize.ts +1 -9
- package/src/onboarding.ts +413 -38
- 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 +457 -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,99 @@ 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
|
+
|
|
168
|
+
resolveBindingAccountId: ({ cfg, accountId }) => {
|
|
169
|
+
if (accountId) return accountId;
|
|
170
|
+
const ids = listGroupMeAccountIds(cfg as CoreConfig);
|
|
171
|
+
if (ids.length <= 1) return DEFAULT_ACCOUNT_ID;
|
|
172
|
+
const section = (cfg as CoreConfig).channels?.groupme;
|
|
173
|
+
const explicitDefault = section?.defaultAccount?.trim();
|
|
174
|
+
return explicitDefault ? resolveDefaultGroupMeAccountId(cfg as CoreConfig) : undefined;
|
|
175
|
+
},
|
|
176
|
+
} satisfies ChannelSetupAdapter,
|
|
45
177
|
capabilities: {
|
|
46
178
|
chatTypes: ["group"],
|
|
47
179
|
media: true,
|
|
@@ -53,7 +185,8 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
53
185
|
listAccountIds: (cfg) => listGroupMeAccountIds(cfg as CoreConfig),
|
|
54
186
|
resolveAccount: (cfg, accountId) =>
|
|
55
187
|
resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }),
|
|
56
|
-
defaultAccountId: (cfg) =>
|
|
188
|
+
defaultAccountId: (cfg) =>
|
|
189
|
+
resolveDefaultGroupMeAccountId(cfg as CoreConfig),
|
|
57
190
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
58
191
|
setAccountEnabledInConfigSection({
|
|
59
192
|
cfg: cfg as CoreConfig,
|
|
@@ -72,12 +205,16 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
72
205
|
"botId",
|
|
73
206
|
"accessToken",
|
|
74
207
|
"botName",
|
|
75
|
-
"
|
|
208
|
+
"groupId",
|
|
209
|
+
"publicDomain",
|
|
210
|
+
"callbackUrl",
|
|
76
211
|
"mentionPatterns",
|
|
77
212
|
"requireMention",
|
|
213
|
+
"historyLimit",
|
|
78
214
|
"allowFrom",
|
|
79
215
|
"textChunkLimit",
|
|
80
216
|
"responsePrefix",
|
|
217
|
+
"security",
|
|
81
218
|
],
|
|
82
219
|
}),
|
|
83
220
|
isConfigured: (account) => account.configured,
|
|
@@ -87,12 +224,14 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
87
224
|
enabled: account.enabled,
|
|
88
225
|
configured: account.configured,
|
|
89
226
|
botId: account.botId ? "***" : "",
|
|
90
|
-
|
|
227
|
+
publicDomain: account.config.publicDomain ?? "",
|
|
228
|
+
callbackUrl: redactWebhookPath(account, account.config.callbackUrl),
|
|
91
229
|
}),
|
|
92
230
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
93
|
-
(
|
|
94
|
-
(
|
|
95
|
-
|
|
231
|
+
(
|
|
232
|
+
resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }).config
|
|
233
|
+
.allowFrom ?? []
|
|
234
|
+
).map((entry) => String(entry)),
|
|
96
235
|
formatAllowFrom: ({ allowFrom }) =>
|
|
97
236
|
allowFrom
|
|
98
237
|
.map((entry) => normalizeGroupMeAllowEntry(String(entry)))
|
|
@@ -109,7 +248,8 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
109
248
|
},
|
|
110
249
|
outbound: {
|
|
111
250
|
deliveryMode: "direct",
|
|
112
|
-
chunker: (text, limit) =>
|
|
251
|
+
chunker: (text, limit) =>
|
|
252
|
+
getGroupMeRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
113
253
|
chunkerMode: "markdown",
|
|
114
254
|
textChunkLimit: GROUPME_MAX_TEXT_LENGTH,
|
|
115
255
|
resolveTarget: ({ to }) => {
|
|
@@ -215,7 +355,7 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
215
355
|
buildChannelSummary: ({ snapshot }) => ({
|
|
216
356
|
configured: snapshot.configured ?? false,
|
|
217
357
|
running: snapshot.running ?? false,
|
|
218
|
-
|
|
358
|
+
callbackUrl: snapshot.webhookPath ?? null,
|
|
219
359
|
lastStartAt: snapshot.lastStartAt ?? null,
|
|
220
360
|
lastStopAt: snapshot.lastStopAt ?? null,
|
|
221
361
|
lastInboundAt: snapshot.lastInboundAt ?? null,
|
|
@@ -229,7 +369,7 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
229
369
|
configured: account.configured,
|
|
230
370
|
botId: account.botId ? "***" : "",
|
|
231
371
|
tokenSource: account.accessToken ? "configured" : "none",
|
|
232
|
-
webhookPath: account.config.
|
|
372
|
+
webhookPath: redactWebhookPath(account, account.config.callbackUrl),
|
|
233
373
|
running: runtime?.running ?? false,
|
|
234
374
|
lastStartAt: runtime?.lastStartAt ?? null,
|
|
235
375
|
lastStopAt: runtime?.lastStopAt ?? null,
|
|
@@ -248,7 +388,11 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
248
388
|
);
|
|
249
389
|
}
|
|
250
390
|
|
|
251
|
-
const callbackPath = account.config.
|
|
391
|
+
const callbackPath = normalizeCallbackUrl(account.config.callbackUrl);
|
|
392
|
+
const redactedCallbackPath = redactWebhookPath(
|
|
393
|
+
account,
|
|
394
|
+
account.config.callbackUrl ?? callbackPath,
|
|
395
|
+
);
|
|
252
396
|
const unregister = registerPluginHttpRoute({
|
|
253
397
|
path: callbackPath,
|
|
254
398
|
fallbackPath: "/groupme",
|
|
@@ -256,7 +400,8 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
256
400
|
account,
|
|
257
401
|
config: ctx.cfg as CoreConfig,
|
|
258
402
|
runtime: ctx.runtime,
|
|
259
|
-
statusSink: (patch) =>
|
|
403
|
+
statusSink: (patch) =>
|
|
404
|
+
ctx.setStatus({ accountId: account.accountId, ...patch }),
|
|
260
405
|
}),
|
|
261
406
|
pluginId: CHANNEL_ID,
|
|
262
407
|
accountId: account.accountId,
|
|
@@ -267,12 +412,14 @@ export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe>
|
|
|
267
412
|
accountId: account.accountId,
|
|
268
413
|
running: true,
|
|
269
414
|
mode: "webhook",
|
|
270
|
-
webhookPath:
|
|
415
|
+
webhookPath: redactedCallbackPath,
|
|
271
416
|
lastStartAt: Date.now(),
|
|
272
417
|
lastError: null,
|
|
273
418
|
});
|
|
274
419
|
|
|
275
|
-
ctx.log?.info(
|
|
420
|
+
ctx.log?.info(
|
|
421
|
+
`[${account.accountId}] GroupMe webhook listening on ${redactedCallbackPath}`,
|
|
422
|
+
);
|
|
276
423
|
|
|
277
424
|
if (ctx.abortSignal.aborted) {
|
|
278
425
|
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
|
+
}
|