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.
- package/README.md +147 -45
- package/channel-plugin-api.ts +3 -0
- package/dist/channel-plugin-api.js +3 -0
- package/dist/index.js +15 -9
- package/dist/runtime-setter-api.js +1 -0
- package/dist/secret-contract-api.js +1 -0
- package/dist/setup-entry.js +16 -0
- package/dist/setup-plugin-api.js +3 -0
- package/dist/src/accounts.js +24 -48
- package/dist/src/channel.js +63 -29
- package/dist/src/config-schema.js +10 -11
- package/dist/src/groupme-api.js +9 -5
- package/dist/src/inbound.js +18 -10
- package/dist/src/monitor.js +25 -27
- package/dist/src/normalize.js +6 -0
- package/dist/src/onboarding.js +364 -337
- package/dist/src/parse.js +4 -14
- package/dist/src/policy.js +1 -1
- package/dist/src/rate-limit.js +12 -7
- package/dist/src/replay-cache.js +0 -3
- package/dist/src/secret-contract.js +49 -0
- package/dist/src/security.js +17 -34
- package/dist/src/send.js +19 -13
- package/index.ts +15 -10
- package/openclaw.plugin.json +14 -15
- package/package.json +43 -9
- package/runtime-setter-api.ts +1 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +17 -0
- package/setup-plugin-api.ts +3 -0
- package/src/accounts.ts +29 -68
- package/src/channel.ts +74 -64
- package/src/config-schema.ts +10 -11
- package/src/groupme-api.ts +21 -5
- package/src/history.ts +1 -1
- package/src/inbound.ts +45 -75
- package/src/monitor.ts +37 -52
- package/src/normalize.ts +7 -1
- package/src/onboarding.ts +449 -409
- package/src/parse.ts +6 -23
- package/src/policy.ts +1 -4
- package/src/rate-limit.ts +15 -12
- package/src/replay-cache.ts +1 -4
- package/src/runtime.ts +1 -1
- package/src/secret-contract.ts +66 -0
- package/src/security.ts +28 -66
- package/src/send.ts +32 -38
- package/src/types.ts +7 -7
package/src/accounts.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DEFAULT_ACCOUNT_ID,
|
|
3
|
-
normalizeAccountId,
|
|
4
|
-
} from "openclaw/plugin-sdk/account-id";
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
5
2
|
import type {
|
|
6
3
|
CoreConfig,
|
|
7
4
|
GroupMeAccountConfig,
|
|
@@ -9,13 +6,6 @@ import type {
|
|
|
9
6
|
ResolvedGroupMeAccount,
|
|
10
7
|
} from "./types.js";
|
|
11
8
|
|
|
12
|
-
const ENV_BOT_ID = "GROUPME_BOT_ID";
|
|
13
|
-
const ENV_ACCESS_TOKEN = "GROUPME_ACCESS_TOKEN";
|
|
14
|
-
const ENV_BOT_NAME = "GROUPME_BOT_NAME";
|
|
15
|
-
const ENV_CALLBACK_URL = "GROUPME_CALLBACK_URL";
|
|
16
|
-
const ENV_GROUP_ID = "GROUPME_GROUP_ID";
|
|
17
|
-
const ENV_PUBLIC_DOMAIN = "GROUPME_PUBLIC_DOMAIN";
|
|
18
|
-
|
|
19
9
|
export function readTrimmed(value: unknown): string | undefined {
|
|
20
10
|
if (typeof value !== "string") {
|
|
21
11
|
return undefined;
|
|
@@ -24,6 +14,17 @@ export function readTrimmed(value: unknown): string | undefined {
|
|
|
24
14
|
return trimmed || undefined;
|
|
25
15
|
}
|
|
26
16
|
|
|
17
|
+
export function hasSecretInput(value: unknown): boolean {
|
|
18
|
+
if (typeof value === "string") {
|
|
19
|
+
return Boolean(value.trim());
|
|
20
|
+
}
|
|
21
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function trimSecretInput(value: GroupMeAccountConfig["botId"]): GroupMeAccountConfig["botId"] {
|
|
25
|
+
return typeof value === "string" ? readTrimmed(value) : value;
|
|
26
|
+
}
|
|
27
|
+
|
|
27
28
|
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
|
|
28
29
|
const accounts = cfg.channels?.groupme?.accounts;
|
|
29
30
|
if (!accounts || typeof accounts !== "object") {
|
|
@@ -54,22 +55,15 @@ function resolveAccountConfig(
|
|
|
54
55
|
return accounts[accountId];
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
const hit = Object.keys(accounts).find(
|
|
58
|
-
(key) => normalizeAccountId(key) === accountId,
|
|
59
|
-
);
|
|
58
|
+
const hit = Object.keys(accounts).find((key) => normalizeAccountId(key) === accountId);
|
|
60
59
|
return hit ? accounts[hit] : undefined;
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
function mergeAccountConfig(
|
|
64
|
-
cfg: CoreConfig,
|
|
65
|
-
accountId: string,
|
|
66
|
-
): GroupMeAccountConfig {
|
|
62
|
+
function mergeAccountConfig(cfg: CoreConfig, accountId: string): GroupMeAccountConfig {
|
|
67
63
|
const raw = (cfg.channels?.groupme ?? {}) as GroupMeConfig;
|
|
68
64
|
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
|
69
65
|
const account =
|
|
70
|
-
accountId === DEFAULT_ACCOUNT_ID
|
|
71
|
-
? {}
|
|
72
|
-
: (resolveAccountConfig(cfg, accountId) ?? {});
|
|
66
|
+
accountId === DEFAULT_ACCOUNT_ID ? {} : (resolveAccountConfig(cfg, accountId) ?? {});
|
|
73
67
|
|
|
74
68
|
return {
|
|
75
69
|
...base,
|
|
@@ -78,13 +72,8 @@ function mergeAccountConfig(
|
|
|
78
72
|
}
|
|
79
73
|
|
|
80
74
|
export function listGroupMeAccountIds(cfg: CoreConfig): string[] {
|
|
81
|
-
const sorted = listConfiguredAccountIds(cfg).toSorted((a, b) =>
|
|
82
|
-
|
|
83
|
-
);
|
|
84
|
-
return [
|
|
85
|
-
DEFAULT_ACCOUNT_ID,
|
|
86
|
-
...sorted.filter((id) => id !== DEFAULT_ACCOUNT_ID),
|
|
87
|
-
];
|
|
75
|
+
const sorted = listConfiguredAccountIds(cfg).toSorted((a, b) => a.localeCompare(b));
|
|
76
|
+
return [DEFAULT_ACCOUNT_ID, ...sorted.filter((id) => id !== DEFAULT_ACCOUNT_ID)];
|
|
88
77
|
}
|
|
89
78
|
|
|
90
79
|
export function resolveDefaultGroupMeAccountId(cfg: CoreConfig): string {
|
|
@@ -102,64 +91,36 @@ export function resolveGroupMeAccount(params: {
|
|
|
102
91
|
}): ResolvedGroupMeAccount {
|
|
103
92
|
const normalizedRequested = normalizeAccountId(params.accountId);
|
|
104
93
|
const accountId =
|
|
105
|
-
normalizedRequested ||
|
|
106
|
-
resolveDefaultGroupMeAccountId(params.cfg) ||
|
|
107
|
-
DEFAULT_ACCOUNT_ID;
|
|
94
|
+
normalizedRequested || resolveDefaultGroupMeAccountId(params.cfg) || DEFAULT_ACCOUNT_ID;
|
|
108
95
|
|
|
109
96
|
const merged = mergeAccountConfig(params.cfg, accountId);
|
|
110
97
|
const baseEnabled = params.cfg.channels?.groupme?.enabled !== false;
|
|
111
98
|
const accountEnabled = merged.enabled !== false;
|
|
112
99
|
const enabled = baseEnabled && accountEnabled;
|
|
113
100
|
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
readTrimmed(merged.accessToken) ||
|
|
121
|
-
(isDefaultAccount
|
|
122
|
-
? readTrimmed(process.env[ENV_ACCESS_TOKEN])
|
|
123
|
-
: undefined) ||
|
|
124
|
-
"";
|
|
125
|
-
const botName =
|
|
126
|
-
readTrimmed(merged.botName) ||
|
|
127
|
-
(isDefaultAccount ? readTrimmed(process.env[ENV_BOT_NAME]) : undefined) ||
|
|
128
|
-
undefined;
|
|
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) ||
|
|
146
|
-
undefined;
|
|
101
|
+
const botId = readTrimmed(merged.botId) ?? "";
|
|
102
|
+
const accessToken = readTrimmed(merged.accessToken) ?? "";
|
|
103
|
+
const botName = readTrimmed(merged.botName);
|
|
104
|
+
const groupId = readTrimmed(merged.groupId);
|
|
105
|
+
const webhookPath = readTrimmed(merged.webhookPath);
|
|
106
|
+
const publicDomain = readTrimmed(merged.publicDomain);
|
|
147
107
|
|
|
148
108
|
const config: GroupMeAccountConfig = {
|
|
149
109
|
...merged,
|
|
150
|
-
botId,
|
|
151
|
-
accessToken,
|
|
110
|
+
botId: trimSecretInput(merged.botId),
|
|
111
|
+
accessToken: trimSecretInput(merged.accessToken),
|
|
112
|
+
callbackToken: trimSecretInput(merged.callbackToken),
|
|
152
113
|
botName,
|
|
153
114
|
groupId,
|
|
154
115
|
publicDomain,
|
|
155
|
-
|
|
116
|
+
webhookPath,
|
|
156
117
|
};
|
|
157
118
|
|
|
158
119
|
return {
|
|
159
120
|
accountId,
|
|
160
121
|
name: readTrimmed(merged.name),
|
|
161
122
|
enabled,
|
|
162
|
-
configured:
|
|
123
|
+
configured: hasSecretInput(merged.botId),
|
|
163
124
|
botId,
|
|
164
125
|
accessToken,
|
|
165
126
|
config,
|
package/src/channel.ts
CHANGED
|
@@ -1,23 +1,18 @@
|
|
|
1
|
+
import { missingTargetError } from "openclaw/plugin-sdk/channel-feedback";
|
|
1
2
|
import {
|
|
2
3
|
applyAccountNameToChannelSection,
|
|
3
4
|
buildChannelConfigSchema,
|
|
5
|
+
type ChannelPlugin,
|
|
4
6
|
DEFAULT_ACCOUNT_ID,
|
|
5
7
|
deleteAccountFromConfigSection,
|
|
6
8
|
migrateBaseNameToDefaultAccount,
|
|
7
|
-
missingTargetError,
|
|
8
9
|
normalizeAccountId,
|
|
9
|
-
registerPluginHttpRoute,
|
|
10
10
|
setAccountEnabledInConfigSection,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from "openclaw/plugin-sdk";
|
|
14
|
-
import type {
|
|
15
|
-
CoreConfig,
|
|
16
|
-
GroupMeConfig,
|
|
17
|
-
GroupMeProbe,
|
|
18
|
-
ResolvedGroupMeAccount,
|
|
19
|
-
} from "./types.js";
|
|
11
|
+
} from "openclaw/plugin-sdk/core";
|
|
12
|
+
import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/setup";
|
|
13
|
+
import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress";
|
|
20
14
|
import {
|
|
15
|
+
hasSecretInput,
|
|
21
16
|
listGroupMeAccountIds,
|
|
22
17
|
resolveDefaultGroupMeAccountId,
|
|
23
18
|
resolveGroupMeAccount,
|
|
@@ -25,22 +20,18 @@ import {
|
|
|
25
20
|
import { GroupMeConfigSchema } from "./config-schema.js";
|
|
26
21
|
import { createGroupMeWebhookHandler } from "./monitor.js";
|
|
27
22
|
import {
|
|
23
|
+
looksLikeGroupMeTargetId,
|
|
28
24
|
normalizeGroupMeAllowEntry,
|
|
29
25
|
normalizeGroupMeTarget,
|
|
30
|
-
looksLikeGroupMeTargetId,
|
|
31
26
|
} from "./normalize.js";
|
|
32
27
|
import { groupmeOnboardingAdapter } from "./onboarding.js";
|
|
33
28
|
import { getGroupMeRuntime } from "./runtime.js";
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
GROUPME_MAX_TEXT_LENGTH,
|
|
37
|
-
sendGroupMeMedia,
|
|
38
|
-
sendGroupMeText,
|
|
39
|
-
} from "./send.js";
|
|
29
|
+
import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText } from "./send.js";
|
|
30
|
+
import type { CoreConfig, GroupMeConfig, GroupMeProbe, ResolvedGroupMeAccount } from "./types.js";
|
|
40
31
|
|
|
41
32
|
const CHANNEL_ID = "groupme" as const;
|
|
42
33
|
|
|
43
|
-
function
|
|
34
|
+
function normalizeWebhookPath(raw: string | undefined): string {
|
|
44
35
|
const trimmed = raw?.trim() ?? "";
|
|
45
36
|
if (!trimmed) {
|
|
46
37
|
return "/groupme";
|
|
@@ -49,24 +40,37 @@ function normalizeCallbackUrl(raw: string | undefined): string {
|
|
|
49
40
|
const parsed = new URL(trimmed, "http://localhost");
|
|
50
41
|
return parsed.pathname || "/groupme";
|
|
51
42
|
} catch {
|
|
52
|
-
|
|
43
|
+
// Unparseable input (e.g. "http://%"): strip any query/fragment and ensure a
|
|
44
|
+
// leading slash so we still return a route-shaped path rather than throwing.
|
|
45
|
+
// This is a display/registration fallback for malformed config, not a parser.
|
|
46
|
+
// The `?? trimmed` and empty-`noQuery` guards are defensive: split() always yields
|
|
47
|
+
// a [0], and a non-empty trimmed string can't reduce to an empty noQuery here.
|
|
48
|
+
/* v8 ignore start */
|
|
49
|
+
const noQuery = trimmed.split(/[?#]/)[0] ?? trimmed;
|
|
53
50
|
if (!noQuery) {
|
|
54
51
|
return "/groupme";
|
|
55
52
|
}
|
|
53
|
+
/* v8 ignore stop */
|
|
56
54
|
return noQuery.startsWith("/") ? noQuery : `/${noQuery}`;
|
|
57
55
|
}
|
|
58
56
|
}
|
|
59
57
|
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return
|
|
58
|
+
function parseWebhookSetupInput(raw: string): {
|
|
59
|
+
webhookPath: string;
|
|
60
|
+
callbackToken?: string;
|
|
61
|
+
} {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = new URL(raw.trim(), "http://localhost");
|
|
64
|
+
const callbackToken = parsed.searchParams.get("k")?.trim() || undefined;
|
|
65
|
+
return {
|
|
66
|
+
webhookPath: parsed.pathname || "/groupme",
|
|
67
|
+
callbackToken,
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
return {
|
|
71
|
+
webhookPath: normalizeWebhookPath(raw),
|
|
72
|
+
};
|
|
68
73
|
}
|
|
69
|
-
return redactCallbackUrl(normalized, security);
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
const meta = {
|
|
@@ -81,13 +85,10 @@ const meta = {
|
|
|
81
85
|
quickstartAllowFrom: true,
|
|
82
86
|
};
|
|
83
87
|
|
|
84
|
-
export const groupmePlugin: ChannelPlugin<
|
|
85
|
-
ResolvedGroupMeAccount,
|
|
86
|
-
GroupMeProbe
|
|
87
|
-
> = {
|
|
88
|
+
export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe> = {
|
|
88
89
|
id: CHANNEL_ID,
|
|
89
90
|
meta,
|
|
90
|
-
|
|
91
|
+
setupWizard: groupmeOnboardingAdapter,
|
|
91
92
|
setup: {
|
|
92
93
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
93
94
|
|
|
@@ -123,12 +124,15 @@ export const groupmePlugin: ChannelPlugin<
|
|
|
123
124
|
|
|
124
125
|
const updates: Record<string, unknown> = { enabled: true };
|
|
125
126
|
if (input.token?.trim()) updates.botId = input.token.trim();
|
|
126
|
-
if (input.accessToken?.trim())
|
|
127
|
-
updates.accessToken = input.accessToken.trim();
|
|
127
|
+
if (input.accessToken?.trim()) updates.accessToken = input.accessToken.trim();
|
|
128
128
|
if (input.webhookUrl?.trim()) {
|
|
129
|
-
|
|
129
|
+
const parsed = parseWebhookSetupInput(input.webhookUrl);
|
|
130
|
+
updates.webhookPath = parsed.webhookPath;
|
|
131
|
+
if (parsed.callbackToken) updates.callbackToken = parsed.callbackToken;
|
|
130
132
|
} else if (input.webhookPath?.trim()) {
|
|
131
|
-
|
|
133
|
+
const parsed = parseWebhookSetupInput(input.webhookPath);
|
|
134
|
+
updates.webhookPath = parsed.webhookPath;
|
|
135
|
+
if (parsed.callbackToken) updates.callbackToken = parsed.callbackToken;
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
const section = (next.channels?.groupme ?? {}) as GroupMeConfig;
|
|
@@ -185,8 +189,7 @@ export const groupmePlugin: ChannelPlugin<
|
|
|
185
189
|
listAccountIds: (cfg) => listGroupMeAccountIds(cfg as CoreConfig),
|
|
186
190
|
resolveAccount: (cfg, accountId) =>
|
|
187
191
|
resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }),
|
|
188
|
-
defaultAccountId: (cfg) =>
|
|
189
|
-
resolveDefaultGroupMeAccountId(cfg as CoreConfig),
|
|
192
|
+
defaultAccountId: (cfg) => resolveDefaultGroupMeAccountId(cfg as CoreConfig),
|
|
190
193
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
191
194
|
setAccountEnabledInConfigSection({
|
|
192
195
|
cfg: cfg as CoreConfig,
|
|
@@ -204,10 +207,11 @@ export const groupmePlugin: ChannelPlugin<
|
|
|
204
207
|
"name",
|
|
205
208
|
"botId",
|
|
206
209
|
"accessToken",
|
|
210
|
+
"callbackToken",
|
|
207
211
|
"botName",
|
|
208
212
|
"groupId",
|
|
209
213
|
"publicDomain",
|
|
210
|
-
"
|
|
214
|
+
"webhookPath",
|
|
211
215
|
"mentionPatterns",
|
|
212
216
|
"requireMention",
|
|
213
217
|
"historyLimit",
|
|
@@ -223,15 +227,15 @@ export const groupmePlugin: ChannelPlugin<
|
|
|
223
227
|
name: account.name,
|
|
224
228
|
enabled: account.enabled,
|
|
225
229
|
configured: account.configured,
|
|
226
|
-
botId: account.botId ? "***" : "",
|
|
230
|
+
botId: hasSecretInput(account.config.botId) ? "***" : "",
|
|
227
231
|
publicDomain: account.config.publicDomain ?? "",
|
|
228
|
-
|
|
232
|
+
webhookPath: normalizeWebhookPath(account.config.webhookPath),
|
|
233
|
+
callbackToken: hasSecretInput(account.config.callbackToken) ? "***" : "",
|
|
229
234
|
}),
|
|
230
235
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
231
|
-
(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
).map((entry) => String(entry)),
|
|
236
|
+
(resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
|
237
|
+
(entry) => String(entry),
|
|
238
|
+
),
|
|
235
239
|
formatAllowFrom: ({ allowFrom }) =>
|
|
236
240
|
allowFrom
|
|
237
241
|
.map((entry) => normalizeGroupMeAllowEntry(String(entry)))
|
|
@@ -248,8 +252,7 @@ export const groupmePlugin: ChannelPlugin<
|
|
|
248
252
|
},
|
|
249
253
|
outbound: {
|
|
250
254
|
deliveryMode: "direct",
|
|
251
|
-
chunker: (text, limit) =>
|
|
252
|
-
getGroupMeRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
255
|
+
chunker: (text, limit) => getGroupMeRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
253
256
|
chunkerMode: "markdown",
|
|
254
257
|
textChunkLimit: GROUPME_MAX_TEXT_LENGTH,
|
|
255
258
|
resolveTarget: ({ to }) => {
|
|
@@ -355,7 +358,7 @@ export const groupmePlugin: ChannelPlugin<
|
|
|
355
358
|
buildChannelSummary: ({ snapshot }) => ({
|
|
356
359
|
configured: snapshot.configured ?? false,
|
|
357
360
|
running: snapshot.running ?? false,
|
|
358
|
-
|
|
361
|
+
webhookPath: snapshot.webhookPath ?? null,
|
|
359
362
|
lastStartAt: snapshot.lastStartAt ?? null,
|
|
360
363
|
lastStopAt: snapshot.lastStopAt ?? null,
|
|
361
364
|
lastInboundAt: snapshot.lastInboundAt ?? null,
|
|
@@ -367,9 +370,9 @@ export const groupmePlugin: ChannelPlugin<
|
|
|
367
370
|
name: account.name,
|
|
368
371
|
enabled: account.enabled,
|
|
369
372
|
configured: account.configured,
|
|
370
|
-
botId: account.botId ? "***" : "",
|
|
371
|
-
tokenSource: account.accessToken ? "configured" : "none",
|
|
372
|
-
webhookPath:
|
|
373
|
+
botId: hasSecretInput(account.config.botId) ? "***" : "",
|
|
374
|
+
tokenSource: hasSecretInput(account.config.accessToken) ? "configured" : "none",
|
|
375
|
+
webhookPath: normalizeWebhookPath(account.config.webhookPath),
|
|
373
376
|
running: runtime?.running ?? false,
|
|
374
377
|
lastStartAt: runtime?.lastStartAt ?? null,
|
|
375
378
|
lastStopAt: runtime?.lastStopAt ?? null,
|
|
@@ -388,11 +391,7 @@ export const groupmePlugin: ChannelPlugin<
|
|
|
388
391
|
);
|
|
389
392
|
}
|
|
390
393
|
|
|
391
|
-
const callbackPath =
|
|
392
|
-
const redactedCallbackPath = redactWebhookPath(
|
|
393
|
-
account,
|
|
394
|
-
account.config.callbackUrl ?? callbackPath,
|
|
395
|
-
);
|
|
394
|
+
const callbackPath = normalizeWebhookPath(account.config.webhookPath);
|
|
396
395
|
const unregister = registerPluginHttpRoute({
|
|
397
396
|
path: callbackPath,
|
|
398
397
|
fallbackPath: "/groupme",
|
|
@@ -400,9 +399,12 @@ export const groupmePlugin: ChannelPlugin<
|
|
|
400
399
|
account,
|
|
401
400
|
config: ctx.cfg as CoreConfig,
|
|
402
401
|
runtime: ctx.runtime,
|
|
403
|
-
|
|
404
|
-
|
|
402
|
+
// Thin setStatus adapter; exercised end-to-end through the live gateway, not in
|
|
403
|
+
// unit isolation (the webhook-flow tests drive the handler with their own sink).
|
|
404
|
+
/* v8 ignore next */
|
|
405
|
+
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
|
|
405
406
|
}),
|
|
407
|
+
auth: "plugin",
|
|
406
408
|
pluginId: CHANNEL_ID,
|
|
407
409
|
accountId: account.accountId,
|
|
408
410
|
log: (message) => ctx.log?.info(message),
|
|
@@ -412,17 +414,24 @@ export const groupmePlugin: ChannelPlugin<
|
|
|
412
414
|
accountId: account.accountId,
|
|
413
415
|
running: true,
|
|
414
416
|
mode: "webhook",
|
|
415
|
-
webhookPath:
|
|
417
|
+
webhookPath: callbackPath,
|
|
416
418
|
lastStartAt: Date.now(),
|
|
417
419
|
lastError: null,
|
|
418
420
|
});
|
|
419
421
|
|
|
420
|
-
ctx.log?.info(
|
|
421
|
-
|
|
422
|
-
)
|
|
422
|
+
ctx.log?.info(`[${account.accountId}] GroupMe webhook listening on ${callbackPath}`);
|
|
423
|
+
|
|
424
|
+
const markStopped = () => {
|
|
425
|
+
ctx.setStatus({
|
|
426
|
+
accountId: account.accountId,
|
|
427
|
+
running: false,
|
|
428
|
+
lastStopAt: Date.now(),
|
|
429
|
+
});
|
|
430
|
+
};
|
|
423
431
|
|
|
424
432
|
if (ctx.abortSignal.aborted) {
|
|
425
433
|
unregister();
|
|
434
|
+
markStopped();
|
|
426
435
|
return;
|
|
427
436
|
}
|
|
428
437
|
|
|
@@ -431,6 +440,7 @@ export const groupmePlugin: ChannelPlugin<
|
|
|
431
440
|
"abort",
|
|
432
441
|
() => {
|
|
433
442
|
unregister();
|
|
443
|
+
markStopped();
|
|
434
444
|
resolve();
|
|
435
445
|
},
|
|
436
446
|
{ once: true },
|
package/src/config-schema.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BlockStreamingCoalesceSchema,
|
|
3
3
|
MarkdownConfigSchema,
|
|
4
|
-
} from "openclaw/plugin-sdk";
|
|
4
|
+
} from "openclaw/plugin-sdk/channel-config-primitives";
|
|
5
|
+
import { buildOptionalSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
|
|
7
8
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
|
9
|
+
const optionalSecretInput = buildOptionalSecretInputSchema();
|
|
8
10
|
|
|
9
11
|
const GroupMeReplaySchema = z
|
|
10
12
|
.object({
|
|
@@ -50,9 +52,7 @@ const GroupMeProxySecuritySchema = z
|
|
|
50
52
|
trustedProxyCidrs: z.array(z.string()).optional(),
|
|
51
53
|
allowedPublicHosts: z.array(z.string()).optional(),
|
|
52
54
|
requireHttpsProto: z.boolean().optional().default(false),
|
|
53
|
-
rejectStatus: z
|
|
54
|
-
.union([z.literal(400), z.literal(403), z.literal(404)])
|
|
55
|
-
.optional(),
|
|
55
|
+
rejectStatus: z.union([z.literal(400), z.literal(403), z.literal(404)]).optional(),
|
|
56
56
|
})
|
|
57
57
|
.strict();
|
|
58
58
|
|
|
@@ -67,16 +67,17 @@ const GroupMeSecuritySchema = z
|
|
|
67
67
|
})
|
|
68
68
|
.strict();
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
const GroupMeAccountSchemaBase = z
|
|
71
71
|
.object({
|
|
72
72
|
name: z.string().optional(),
|
|
73
73
|
enabled: z.boolean().optional(),
|
|
74
|
-
botId:
|
|
75
|
-
accessToken:
|
|
74
|
+
botId: optionalSecretInput,
|
|
75
|
+
accessToken: optionalSecretInput,
|
|
76
|
+
callbackToken: optionalSecretInput,
|
|
76
77
|
botName: z.string().optional(),
|
|
77
78
|
groupId: z.string().optional(),
|
|
78
79
|
publicDomain: z.string().optional(),
|
|
79
|
-
|
|
80
|
+
webhookPath: z.string().optional(),
|
|
80
81
|
mentionPatterns: z.array(z.string()).optional(),
|
|
81
82
|
requireMention: z.boolean().optional().default(true),
|
|
82
83
|
historyLimit: z.number().int().nonnegative().optional(),
|
|
@@ -92,8 +93,6 @@ export const GroupMeAccountSchemaBase = z
|
|
|
92
93
|
.strict();
|
|
93
94
|
|
|
94
95
|
export const GroupMeConfigSchema = GroupMeAccountSchemaBase.extend({
|
|
95
|
-
accounts: z
|
|
96
|
-
.record(z.string(), GroupMeAccountSchemaBase.optional())
|
|
97
|
-
.optional(),
|
|
96
|
+
accounts: z.record(z.string(), GroupMeAccountSchemaBase.optional()).optional(),
|
|
98
97
|
defaultAccount: z.string().optional(),
|
|
99
98
|
}).strict();
|
package/src/groupme-api.ts
CHANGED
|
@@ -2,6 +2,13 @@ import type { GroupMeApiBot, GroupMeApiGroup } from "./types.js";
|
|
|
2
2
|
|
|
3
3
|
const GROUPME_API_BASE = "https://api.groupme.com/v3";
|
|
4
4
|
|
|
5
|
+
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
6
|
+
|
|
7
|
+
type GroupMeApiOptions = {
|
|
8
|
+
fetchFn?: FetchLike;
|
|
9
|
+
apiBaseUrl?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
5
12
|
async function readApiError(response: Response): Promise<string> {
|
|
6
13
|
const fallback = `GroupMe API error: ${response.status} ${response.statusText}`;
|
|
7
14
|
try {
|
|
@@ -38,18 +45,23 @@ function readBotResponse(payload: unknown): GroupMeApiBot {
|
|
|
38
45
|
return bot as GroupMeApiBot;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
export async function fetchGroups(
|
|
48
|
+
export async function fetchGroups(
|
|
49
|
+
accessToken: string,
|
|
50
|
+
options: GroupMeApiOptions = {},
|
|
51
|
+
): Promise<GroupMeApiGroup[]> {
|
|
52
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
53
|
+
const apiBaseUrl = options.apiBaseUrl ?? GROUPME_API_BASE;
|
|
42
54
|
const groups: GroupMeApiGroup[] = [];
|
|
43
55
|
let page = 1;
|
|
44
56
|
|
|
45
57
|
while (true) {
|
|
46
|
-
const url = new URL(`${
|
|
58
|
+
const url = new URL(`${apiBaseUrl}/groups`);
|
|
47
59
|
url.searchParams.set("token", accessToken);
|
|
48
60
|
url.searchParams.set("per_page", "100");
|
|
49
61
|
url.searchParams.set("omit", "memberships");
|
|
50
62
|
url.searchParams.set("page", String(page));
|
|
51
63
|
|
|
52
|
-
const response = await
|
|
64
|
+
const response = await fetchFn(url);
|
|
53
65
|
if (!response.ok) {
|
|
54
66
|
throw new Error(await readApiError(response));
|
|
55
67
|
}
|
|
@@ -72,11 +84,15 @@ export async function createBot(params: {
|
|
|
72
84
|
name: string;
|
|
73
85
|
groupId: string;
|
|
74
86
|
callbackUrl: string;
|
|
87
|
+
fetchFn?: FetchLike;
|
|
88
|
+
apiBaseUrl?: string;
|
|
75
89
|
}): Promise<GroupMeApiBot> {
|
|
76
|
-
const
|
|
90
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
91
|
+
const apiBaseUrl = params.apiBaseUrl ?? GROUPME_API_BASE;
|
|
92
|
+
const url = new URL(`${apiBaseUrl}/bots`);
|
|
77
93
|
url.searchParams.set("token", params.accessToken);
|
|
78
94
|
|
|
79
|
-
const response = await
|
|
95
|
+
const response = await fetchFn(url, {
|
|
80
96
|
method: "POST",
|
|
81
97
|
headers: { "content-type": "application/json" },
|
|
82
98
|
body: JSON.stringify({
|
package/src/history.ts
CHANGED