openclaw-groupme 0.0.1
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 +121 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +32 -0
- package/src/accounts.ts +139 -0
- package/src/channel.ts +294 -0
- package/src/config-schema.ts +29 -0
- package/src/inbound.ts +310 -0
- package/src/monitor.test.ts +186 -0
- package/src/monitor.ts +63 -0
- package/src/normalize.test.ts +43 -0
- package/src/normalize.ts +43 -0
- package/src/onboarding.ts +154 -0
- package/src/parse.test.ts +162 -0
- package/src/parse.ts +273 -0
- package/src/policy.test.ts +23 -0
- package/src/policy.ts +26 -0
- package/src/runtime.ts +14 -0
- package/src/send.test.ts +153 -0
- package/src/send.ts +194 -0
- package/src/types.ts +103 -0
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# openclaw-groupme
|
|
2
|
+
|
|
3
|
+
GroupMe channel plugin for OpenClaw (GroupMe Bot API, group chats only).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
openclaw plugins install openclaw-groupme
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Restart the gateway after installing the plugin.
|
|
12
|
+
|
|
13
|
+
## What this plugin needs
|
|
14
|
+
|
|
15
|
+
1. A GroupMe bot (from https://dev.groupme.com/bots)
|
|
16
|
+
2. A public HTTPS URL that can reach your OpenClaw gateway webhook endpoint
|
|
17
|
+
3. Your GroupMe `bot_id` (required)
|
|
18
|
+
4. Your GroupMe `access token` (recommended, required for image uploads)
|
|
19
|
+
|
|
20
|
+
## Step-by-step setup
|
|
21
|
+
|
|
22
|
+
1. Install plugin:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
openclaw plugins install openclaw-groupme
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
2. Create a GroupMe bot:
|
|
29
|
+
- Go to https://dev.groupme.com/bots
|
|
30
|
+
- Create/select a bot for your target group
|
|
31
|
+
- Copy the bot's `bot_id`
|
|
32
|
+
- Copy your GroupMe `access token`
|
|
33
|
+
|
|
34
|
+
3. Configure OpenClaw:
|
|
35
|
+
- Option A (interactive):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
openclaw channels add --channel groupme
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- Option B (manual config): add this under your OpenClaw config:
|
|
42
|
+
|
|
43
|
+
```json5
|
|
44
|
+
{
|
|
45
|
+
channels: {
|
|
46
|
+
groupme: {
|
|
47
|
+
enabled: true,
|
|
48
|
+
botId: "YOUR_GROUPME_BOT_ID",
|
|
49
|
+
accessToken: "YOUR_GROUPME_ACCESS_TOKEN",
|
|
50
|
+
botName: "openclaw",
|
|
51
|
+
callbackPath: "/groupme",
|
|
52
|
+
requireMention: true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
4. Point GroupMe to your webhook:
|
|
59
|
+
- Callback URL format: `https://<your-public-domain><callbackPath>`
|
|
60
|
+
- Example: `https://bot.example.com/groupme`
|
|
61
|
+
- Set this URL in the GroupMe bot settings
|
|
62
|
+
|
|
63
|
+
5. Restart OpenClaw gateway:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
openclaw gateway restart
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
6. Verify channel status:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
openclaw channels status --probe
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
7. Send a test message in the GroupMe group:
|
|
76
|
+
- With default settings, the bot only responds when mentioned (`requireMention: true`)
|
|
77
|
+
- Mention either `@<botName>` or a configured mention pattern
|
|
78
|
+
|
|
79
|
+
## Config reference (common fields)
|
|
80
|
+
|
|
81
|
+
- `botId` (string, required): GroupMe Bot ID
|
|
82
|
+
- `accessToken` (string): needed for image upload / media replies
|
|
83
|
+
- `botName` (string): mention fallback name used by mention detection
|
|
84
|
+
- `callbackPath` (string, default `/groupme`): webhook route path
|
|
85
|
+
- `requireMention` (boolean, default `true`): require mention before responding
|
|
86
|
+
- `mentionPatterns` (string[]): custom regex patterns that count as a mention
|
|
87
|
+
- `allowFrom` (array of string/number): sender allowlist (`"*"` to allow all)
|
|
88
|
+
- `textChunkLimit` (number): max outbound text chunk size (capped at 1000)
|
|
89
|
+
|
|
90
|
+
## Environment variables (default account fallback)
|
|
91
|
+
|
|
92
|
+
For the default account only, these env vars are supported:
|
|
93
|
+
|
|
94
|
+
- `GROUPME_BOT_ID`
|
|
95
|
+
- `GROUPME_ACCESS_TOKEN`
|
|
96
|
+
- `GROUPME_BOT_NAME`
|
|
97
|
+
- `GROUPME_CALLBACK_PATH`
|
|
98
|
+
|
|
99
|
+
If both config and env are set, config values take precedence.
|
|
100
|
+
|
|
101
|
+
## Notes and limitations
|
|
102
|
+
|
|
103
|
+
- Group chats only (no DM channel mode)
|
|
104
|
+
- Inbound bot/system messages are ignored
|
|
105
|
+
- GroupMe message text limit is 1000 chars per chunk
|
|
106
|
+
- Media replies require `accessToken` so OpenClaw can upload images to GroupMe
|
|
107
|
+
|
|
108
|
+
## Troubleshooting
|
|
109
|
+
|
|
110
|
+
- Bot does not respond:
|
|
111
|
+
- Confirm webhook URL is public + HTTPS and matches `callbackPath`
|
|
112
|
+
- Confirm `botId` is correct
|
|
113
|
+
- If `requireMention: true`, mention the bot in the message
|
|
114
|
+
- Check `allowFrom` (if set)
|
|
115
|
+
- Image replies fail:
|
|
116
|
+
- Ensure `accessToken` is configured
|
|
117
|
+
- Check runtime logs:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
openclaw channels logs --channel groupme
|
|
121
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
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";
|
|
5
|
+
|
|
6
|
+
const plugin = {
|
|
7
|
+
id: "groupme",
|
|
8
|
+
name: "GroupMe",
|
|
9
|
+
description: "GroupMe channel plugin",
|
|
10
|
+
configSchema: emptyPluginConfigSchema(),
|
|
11
|
+
register(api: OpenClawPluginApi) {
|
|
12
|
+
setGroupMeRuntime(api.runtime);
|
|
13
|
+
api.registerChannel({ plugin: groupmePlugin as ChannelPlugin });
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-groupme",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "OpenClaw GroupMe channel plugin",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"zod": "^4.3.6"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"openclaw": "workspace:*"
|
|
11
|
+
},
|
|
12
|
+
"openclaw": {
|
|
13
|
+
"extensions": [
|
|
14
|
+
"./index.ts"
|
|
15
|
+
],
|
|
16
|
+
"channel": {
|
|
17
|
+
"id": "groupme",
|
|
18
|
+
"label": "GroupMe",
|
|
19
|
+
"selectionLabel": "GroupMe (Bot API)",
|
|
20
|
+
"docsPath": "/channels/groupme",
|
|
21
|
+
"docsLabel": "groupme",
|
|
22
|
+
"blurb": "GroupMe bot webhook integration (group chats only).",
|
|
23
|
+
"order": 95,
|
|
24
|
+
"quickstartAllowFrom": true
|
|
25
|
+
},
|
|
26
|
+
"install": {
|
|
27
|
+
"npmSpec": "@openclaw/groupme",
|
|
28
|
+
"localPath": "extensions/groupme",
|
|
29
|
+
"defaultChoice": "npm"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
2
|
+
import type {
|
|
3
|
+
CoreConfig,
|
|
4
|
+
GroupMeAccountConfig,
|
|
5
|
+
GroupMeConfig,
|
|
6
|
+
ResolvedGroupMeAccount,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
|
|
9
|
+
const ENV_BOT_ID = "GROUPME_BOT_ID";
|
|
10
|
+
const ENV_ACCESS_TOKEN = "GROUPME_ACCESS_TOKEN";
|
|
11
|
+
const ENV_BOT_NAME = "GROUPME_BOT_NAME";
|
|
12
|
+
const ENV_CALLBACK_PATH = "GROUPME_CALLBACK_PATH";
|
|
13
|
+
|
|
14
|
+
function readTrimmed(value: unknown): string | undefined {
|
|
15
|
+
if (typeof value !== "string") {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const trimmed = value.trim();
|
|
19
|
+
return trimmed || undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
|
|
23
|
+
const accounts = cfg.channels?.groupme?.accounts;
|
|
24
|
+
if (!accounts || typeof accounts !== "object") {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ids = new Set<string>();
|
|
29
|
+
for (const key of Object.keys(accounts)) {
|
|
30
|
+
const normalized = normalizeAccountId(key);
|
|
31
|
+
if (normalized) {
|
|
32
|
+
ids.add(normalized);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return [...ids];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveAccountConfig(
|
|
40
|
+
cfg: CoreConfig,
|
|
41
|
+
accountId: string,
|
|
42
|
+
): GroupMeAccountConfig | undefined {
|
|
43
|
+
const accounts = cfg.channels?.groupme?.accounts;
|
|
44
|
+
if (!accounts || typeof accounts !== "object") {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (Object.hasOwn(accounts, accountId)) {
|
|
49
|
+
return accounts[accountId];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hit = Object.keys(accounts).find((key) => normalizeAccountId(key) === accountId);
|
|
53
|
+
return hit ? accounts[hit] : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mergeAccountConfig(cfg: CoreConfig, accountId: string): GroupMeAccountConfig {
|
|
57
|
+
const raw = (cfg.channels?.groupme ?? {}) as GroupMeConfig;
|
|
58
|
+
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
|
59
|
+
const account =
|
|
60
|
+
accountId === DEFAULT_ACCOUNT_ID ? {} : (resolveAccountConfig(cfg, accountId) ?? {});
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
...base,
|
|
64
|
+
...account,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function listGroupMeAccountIds(cfg: CoreConfig): string[] {
|
|
69
|
+
const ids = new Set<string>([DEFAULT_ACCOUNT_ID]);
|
|
70
|
+
for (const id of listConfiguredAccountIds(cfg)) {
|
|
71
|
+
ids.add(id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ordered = [...ids].toSorted((a, b) => a.localeCompare(b));
|
|
75
|
+
if (ordered[0] !== DEFAULT_ACCOUNT_ID) {
|
|
76
|
+
ordered.unshift(DEFAULT_ACCOUNT_ID);
|
|
77
|
+
return Array.from(new Set(ordered));
|
|
78
|
+
}
|
|
79
|
+
return ordered;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resolveDefaultGroupMeAccountId(cfg: CoreConfig): string {
|
|
83
|
+
const configuredDefault = readTrimmed(cfg.channels?.groupme?.defaultAccount);
|
|
84
|
+
if (configuredDefault) {
|
|
85
|
+
return normalizeAccountId(configuredDefault);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return DEFAULT_ACCOUNT_ID;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function resolveGroupMeAccount(params: {
|
|
92
|
+
cfg: CoreConfig;
|
|
93
|
+
accountId?: string | null;
|
|
94
|
+
}): ResolvedGroupMeAccount {
|
|
95
|
+
const normalizedRequested = normalizeAccountId(params.accountId);
|
|
96
|
+
const accountId =
|
|
97
|
+
normalizedRequested || resolveDefaultGroupMeAccountId(params.cfg) || DEFAULT_ACCOUNT_ID;
|
|
98
|
+
|
|
99
|
+
const merged = mergeAccountConfig(params.cfg, accountId);
|
|
100
|
+
const baseEnabled = params.cfg.channels?.groupme?.enabled !== false;
|
|
101
|
+
const accountEnabled = merged.enabled !== false;
|
|
102
|
+
const enabled = baseEnabled && accountEnabled;
|
|
103
|
+
|
|
104
|
+
const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID;
|
|
105
|
+
const botId =
|
|
106
|
+
readTrimmed(merged.botId) ||
|
|
107
|
+
(isDefaultAccount ? readTrimmed(process.env[ENV_BOT_ID]) : undefined) ||
|
|
108
|
+
"";
|
|
109
|
+
const accessToken =
|
|
110
|
+
readTrimmed(merged.accessToken) ||
|
|
111
|
+
(isDefaultAccount ? readTrimmed(process.env[ENV_ACCESS_TOKEN]) : undefined) ||
|
|
112
|
+
"";
|
|
113
|
+
const botName =
|
|
114
|
+
readTrimmed(merged.botName) ||
|
|
115
|
+
(isDefaultAccount ? readTrimmed(process.env[ENV_BOT_NAME]) : undefined) ||
|
|
116
|
+
undefined;
|
|
117
|
+
const callbackPath =
|
|
118
|
+
readTrimmed(merged.callbackPath) ||
|
|
119
|
+
(isDefaultAccount ? readTrimmed(process.env[ENV_CALLBACK_PATH]) : undefined) ||
|
|
120
|
+
undefined;
|
|
121
|
+
|
|
122
|
+
const config: GroupMeAccountConfig = {
|
|
123
|
+
...merged,
|
|
124
|
+
botId,
|
|
125
|
+
accessToken,
|
|
126
|
+
botName,
|
|
127
|
+
callbackPath,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
accountId,
|
|
132
|
+
name: readTrimmed(merged.name),
|
|
133
|
+
enabled,
|
|
134
|
+
configured: Boolean(botId),
|
|
135
|
+
botId,
|
|
136
|
+
accessToken,
|
|
137
|
+
config,
|
|
138
|
+
};
|
|
139
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildChannelConfigSchema,
|
|
3
|
+
DEFAULT_ACCOUNT_ID,
|
|
4
|
+
deleteAccountFromConfigSection,
|
|
5
|
+
missingTargetError,
|
|
6
|
+
registerPluginHttpRoute,
|
|
7
|
+
setAccountEnabledInConfigSection,
|
|
8
|
+
type ChannelPlugin,
|
|
9
|
+
} from "openclaw/plugin-sdk";
|
|
10
|
+
import type { CoreConfig, GroupMeProbe, ResolvedGroupMeAccount } from "./types.js";
|
|
11
|
+
import {
|
|
12
|
+
listGroupMeAccountIds,
|
|
13
|
+
resolveDefaultGroupMeAccountId,
|
|
14
|
+
resolveGroupMeAccount,
|
|
15
|
+
} from "./accounts.js";
|
|
16
|
+
import { GroupMeConfigSchema } from "./config-schema.js";
|
|
17
|
+
import { createGroupMeWebhookHandler } from "./monitor.js";
|
|
18
|
+
import {
|
|
19
|
+
normalizeGroupMeAllowEntry,
|
|
20
|
+
normalizeGroupMeTarget,
|
|
21
|
+
looksLikeGroupMeTargetId,
|
|
22
|
+
} from "./normalize.js";
|
|
23
|
+
import { groupmeOnboardingAdapter } from "./onboarding.js";
|
|
24
|
+
import { getGroupMeRuntime } from "./runtime.js";
|
|
25
|
+
import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText } from "./send.js";
|
|
26
|
+
|
|
27
|
+
const CHANNEL_ID = "groupme" as const;
|
|
28
|
+
|
|
29
|
+
const meta = {
|
|
30
|
+
id: CHANNEL_ID,
|
|
31
|
+
label: "GroupMe",
|
|
32
|
+
selectionLabel: "GroupMe (Bot API)",
|
|
33
|
+
docsPath: "/channels/groupme",
|
|
34
|
+
docsLabel: "groupme",
|
|
35
|
+
blurb: "GroupMe bot webhook integration (group chats only).",
|
|
36
|
+
aliases: ["gm"],
|
|
37
|
+
order: 95,
|
|
38
|
+
quickstartAllowFrom: true,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const groupmePlugin: ChannelPlugin<ResolvedGroupMeAccount, GroupMeProbe> = {
|
|
42
|
+
id: CHANNEL_ID,
|
|
43
|
+
meta,
|
|
44
|
+
onboarding: groupmeOnboardingAdapter,
|
|
45
|
+
capabilities: {
|
|
46
|
+
chatTypes: ["group"],
|
|
47
|
+
media: true,
|
|
48
|
+
blockStreaming: true,
|
|
49
|
+
},
|
|
50
|
+
reload: { configPrefixes: ["channels.groupme"] },
|
|
51
|
+
configSchema: buildChannelConfigSchema(GroupMeConfigSchema),
|
|
52
|
+
config: {
|
|
53
|
+
listAccountIds: (cfg) => listGroupMeAccountIds(cfg as CoreConfig),
|
|
54
|
+
resolveAccount: (cfg, accountId) =>
|
|
55
|
+
resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }),
|
|
56
|
+
defaultAccountId: (cfg) => resolveDefaultGroupMeAccountId(cfg as CoreConfig),
|
|
57
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
58
|
+
setAccountEnabledInConfigSection({
|
|
59
|
+
cfg: cfg as CoreConfig,
|
|
60
|
+
sectionKey: CHANNEL_ID,
|
|
61
|
+
accountId,
|
|
62
|
+
enabled,
|
|
63
|
+
allowTopLevel: true,
|
|
64
|
+
}),
|
|
65
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
66
|
+
deleteAccountFromConfigSection({
|
|
67
|
+
cfg: cfg as CoreConfig,
|
|
68
|
+
sectionKey: CHANNEL_ID,
|
|
69
|
+
accountId,
|
|
70
|
+
clearBaseFields: [
|
|
71
|
+
"name",
|
|
72
|
+
"botId",
|
|
73
|
+
"accessToken",
|
|
74
|
+
"botName",
|
|
75
|
+
"callbackPath",
|
|
76
|
+
"mentionPatterns",
|
|
77
|
+
"requireMention",
|
|
78
|
+
"allowFrom",
|
|
79
|
+
"textChunkLimit",
|
|
80
|
+
"responsePrefix",
|
|
81
|
+
],
|
|
82
|
+
}),
|
|
83
|
+
isConfigured: (account) => account.configured,
|
|
84
|
+
describeAccount: (account) => ({
|
|
85
|
+
accountId: account.accountId,
|
|
86
|
+
name: account.name,
|
|
87
|
+
enabled: account.enabled,
|
|
88
|
+
configured: account.configured,
|
|
89
|
+
botId: account.botId ? "***" : "",
|
|
90
|
+
callbackPath: account.config.callbackPath,
|
|
91
|
+
}),
|
|
92
|
+
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
93
|
+
(resolveGroupMeAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
|
94
|
+
(entry) => String(entry),
|
|
95
|
+
),
|
|
96
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
97
|
+
allowFrom
|
|
98
|
+
.map((entry) => normalizeGroupMeAllowEntry(String(entry)))
|
|
99
|
+
.filter((entry): entry is string => Boolean(entry)),
|
|
100
|
+
},
|
|
101
|
+
groups: {
|
|
102
|
+
resolveRequireMention: ({ cfg, accountId }) => {
|
|
103
|
+
const account = resolveGroupMeAccount({
|
|
104
|
+
cfg: cfg as CoreConfig,
|
|
105
|
+
accountId,
|
|
106
|
+
});
|
|
107
|
+
return account.config.requireMention ?? true;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
outbound: {
|
|
111
|
+
deliveryMode: "direct",
|
|
112
|
+
chunker: (text, limit) => getGroupMeRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
113
|
+
chunkerMode: "markdown",
|
|
114
|
+
textChunkLimit: GROUPME_MAX_TEXT_LENGTH,
|
|
115
|
+
resolveTarget: ({ to }) => {
|
|
116
|
+
const normalized = normalizeGroupMeTarget(to?.trim() ?? "");
|
|
117
|
+
if (!normalized) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: missingTargetError("GroupMe", "<group-id>"),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
to: normalized,
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
sendText: async ({ cfg, to, text, accountId }) => {
|
|
130
|
+
const result = await sendGroupMeText({
|
|
131
|
+
cfg: cfg as CoreConfig,
|
|
132
|
+
to,
|
|
133
|
+
text,
|
|
134
|
+
accountId,
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
channel: CHANNEL_ID,
|
|
138
|
+
messageId: result.messageId,
|
|
139
|
+
timestamp: result.timestamp,
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
|
|
143
|
+
if (!mediaUrl?.trim()) {
|
|
144
|
+
throw new Error("GroupMe media send requires a mediaUrl");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const result = await sendGroupMeMedia({
|
|
148
|
+
cfg: cfg as CoreConfig,
|
|
149
|
+
to,
|
|
150
|
+
text,
|
|
151
|
+
mediaUrl,
|
|
152
|
+
accountId,
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
channel: CHANNEL_ID,
|
|
156
|
+
messageId: result.messageId,
|
|
157
|
+
timestamp: result.timestamp,
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
messaging: {
|
|
162
|
+
normalizeTarget: normalizeGroupMeTarget,
|
|
163
|
+
targetResolver: {
|
|
164
|
+
looksLikeId: (raw) => looksLikeGroupMeTargetId(raw),
|
|
165
|
+
hint: "<group-id>",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
resolver: {
|
|
169
|
+
resolveTargets: async ({ inputs, kind }) => {
|
|
170
|
+
return inputs.map((input) => {
|
|
171
|
+
const normalized = normalizeGroupMeTarget(input);
|
|
172
|
+
if (!normalized) {
|
|
173
|
+
return {
|
|
174
|
+
input,
|
|
175
|
+
resolved: false,
|
|
176
|
+
note: "empty target",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
input,
|
|
182
|
+
resolved: true,
|
|
183
|
+
id: normalized,
|
|
184
|
+
name: normalized,
|
|
185
|
+
note: kind === "user" ? "GroupMe bots are group-only" : undefined,
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
directory: {
|
|
191
|
+
self: async () => null,
|
|
192
|
+
listPeers: async ({ cfg, accountId, query, limit }) => {
|
|
193
|
+
const account = resolveGroupMeAccount({
|
|
194
|
+
cfg: cfg as CoreConfig,
|
|
195
|
+
accountId,
|
|
196
|
+
});
|
|
197
|
+
const q = query?.trim().toLowerCase() ?? "";
|
|
198
|
+
return (account.config.allowFrom ?? [])
|
|
199
|
+
.map((entry) => normalizeGroupMeAllowEntry(String(entry)))
|
|
200
|
+
.filter((entry): entry is string => Boolean(entry) && entry !== "*")
|
|
201
|
+
.filter((entry) => (q ? entry.toLowerCase().includes(q) : true))
|
|
202
|
+
.slice(0, limit && limit > 0 ? limit : undefined)
|
|
203
|
+
.map((id) => ({ kind: "user", id }) as const);
|
|
204
|
+
},
|
|
205
|
+
listGroups: async () => [],
|
|
206
|
+
},
|
|
207
|
+
status: {
|
|
208
|
+
defaultRuntime: {
|
|
209
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
210
|
+
running: false,
|
|
211
|
+
lastStartAt: null,
|
|
212
|
+
lastStopAt: null,
|
|
213
|
+
lastError: null,
|
|
214
|
+
},
|
|
215
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
216
|
+
configured: snapshot.configured ?? false,
|
|
217
|
+
running: snapshot.running ?? false,
|
|
218
|
+
callbackPath: snapshot.webhookPath ?? null,
|
|
219
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
220
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
221
|
+
lastInboundAt: snapshot.lastInboundAt ?? null,
|
|
222
|
+
lastOutboundAt: snapshot.lastOutboundAt ?? null,
|
|
223
|
+
lastError: snapshot.lastError ?? null,
|
|
224
|
+
}),
|
|
225
|
+
buildAccountSnapshot: ({ account, runtime }) => ({
|
|
226
|
+
accountId: account.accountId,
|
|
227
|
+
name: account.name,
|
|
228
|
+
enabled: account.enabled,
|
|
229
|
+
configured: account.configured,
|
|
230
|
+
botId: account.botId ? "***" : "",
|
|
231
|
+
tokenSource: account.accessToken ? "configured" : "none",
|
|
232
|
+
webhookPath: account.config.callbackPath ?? "/groupme",
|
|
233
|
+
running: runtime?.running ?? false,
|
|
234
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
235
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
236
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
237
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
238
|
+
lastError: runtime?.lastError ?? null,
|
|
239
|
+
mode: "webhook",
|
|
240
|
+
}),
|
|
241
|
+
},
|
|
242
|
+
gateway: {
|
|
243
|
+
startAccount: async (ctx) => {
|
|
244
|
+
const account = ctx.account;
|
|
245
|
+
if (!account.configured) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`GroupMe is not configured for account "${account.accountId}" (missing botId).`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const callbackPath = account.config.callbackPath?.trim() || "/groupme";
|
|
252
|
+
const unregister = registerPluginHttpRoute({
|
|
253
|
+
path: callbackPath,
|
|
254
|
+
fallbackPath: "/groupme",
|
|
255
|
+
handler: createGroupMeWebhookHandler({
|
|
256
|
+
account,
|
|
257
|
+
config: ctx.cfg as CoreConfig,
|
|
258
|
+
runtime: ctx.runtime,
|
|
259
|
+
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
|
|
260
|
+
}),
|
|
261
|
+
pluginId: CHANNEL_ID,
|
|
262
|
+
accountId: account.accountId,
|
|
263
|
+
log: (message) => ctx.log?.info(message),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
ctx.setStatus({
|
|
267
|
+
accountId: account.accountId,
|
|
268
|
+
running: true,
|
|
269
|
+
mode: "webhook",
|
|
270
|
+
webhookPath: callbackPath,
|
|
271
|
+
lastStartAt: Date.now(),
|
|
272
|
+
lastError: null,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
ctx.log?.info(`[${account.accountId}] GroupMe webhook listening on ${callbackPath}`);
|
|
276
|
+
|
|
277
|
+
if (ctx.abortSignal.aborted) {
|
|
278
|
+
unregister();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
await new Promise<void>((resolve) => {
|
|
283
|
+
ctx.abortSignal.addEventListener(
|
|
284
|
+
"abort",
|
|
285
|
+
() => {
|
|
286
|
+
unregister();
|
|
287
|
+
resolve();
|
|
288
|
+
},
|
|
289
|
+
{ once: true },
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { BlockStreamingCoalesceSchema, MarkdownConfigSchema } from "openclaw/plugin-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
const allowFromEntry = z.union([z.string(), z.number()]);
|
|
5
|
+
|
|
6
|
+
export const GroupMeAccountSchemaBase = z
|
|
7
|
+
.object({
|
|
8
|
+
name: z.string().optional(),
|
|
9
|
+
enabled: z.boolean().optional(),
|
|
10
|
+
botId: z.string().optional(),
|
|
11
|
+
accessToken: z.string().optional(),
|
|
12
|
+
botName: z.string().optional(),
|
|
13
|
+
callbackPath: z.string().optional(),
|
|
14
|
+
mentionPatterns: z.array(z.string()).optional(),
|
|
15
|
+
requireMention: z.boolean().optional().default(true),
|
|
16
|
+
allowFrom: z.array(allowFromEntry).optional(),
|
|
17
|
+
markdown: MarkdownConfigSchema,
|
|
18
|
+
textChunkLimit: z.number().int().positive().optional(),
|
|
19
|
+
blockStreaming: z.boolean().optional(),
|
|
20
|
+
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
21
|
+
responsePrefix: z.string().optional(),
|
|
22
|
+
mediaMaxMb: z.number().positive().optional(),
|
|
23
|
+
})
|
|
24
|
+
.strict();
|
|
25
|
+
|
|
26
|
+
export const GroupMeConfigSchema = GroupMeAccountSchemaBase.extend({
|
|
27
|
+
accounts: z.record(z.string(), GroupMeAccountSchemaBase.optional()).optional(),
|
|
28
|
+
defaultAccount: z.string().optional(),
|
|
29
|
+
}).strict();
|