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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { ChannelOnboardingAdapter } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
|
4
|
+
import type { CoreConfig, GroupMeConfig } from "./types.js";
|
|
5
|
+
import { resolveGroupMeAccount } from "./accounts.js";
|
|
6
|
+
|
|
7
|
+
function applyGroupMeConfig(params: {
|
|
8
|
+
cfg: OpenClawConfig;
|
|
9
|
+
accountId: string;
|
|
10
|
+
updates: Record<string, unknown>;
|
|
11
|
+
}): OpenClawConfig {
|
|
12
|
+
const { cfg, accountId, updates } = params;
|
|
13
|
+
const section = (cfg.channels?.groupme ?? {}) as GroupMeConfig;
|
|
14
|
+
|
|
15
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
16
|
+
return {
|
|
17
|
+
...cfg,
|
|
18
|
+
channels: {
|
|
19
|
+
...cfg.channels,
|
|
20
|
+
groupme: {
|
|
21
|
+
...section,
|
|
22
|
+
...updates,
|
|
23
|
+
enabled: true,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
...cfg,
|
|
31
|
+
channels: {
|
|
32
|
+
...cfg.channels,
|
|
33
|
+
groupme: {
|
|
34
|
+
...section,
|
|
35
|
+
enabled: true,
|
|
36
|
+
accounts: {
|
|
37
|
+
...(section.accounts ?? {}),
|
|
38
|
+
[accountId]: {
|
|
39
|
+
...(section.accounts?.[accountId] ?? {}),
|
|
40
|
+
...updates,
|
|
41
|
+
enabled: true,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const groupmeOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
50
|
+
channel: "groupme",
|
|
51
|
+
getStatus: async ({ cfg, accountOverrides }) => {
|
|
52
|
+
const accountId = accountOverrides.groupme ?? DEFAULT_ACCOUNT_ID;
|
|
53
|
+
const account = resolveGroupMeAccount({
|
|
54
|
+
cfg: cfg as CoreConfig,
|
|
55
|
+
accountId,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const configured = account.configured;
|
|
59
|
+
return {
|
|
60
|
+
channel: "groupme",
|
|
61
|
+
configured,
|
|
62
|
+
statusLines: [
|
|
63
|
+
`GroupMe (${accountId}): ${configured ? "configured" : "needs botId"}`,
|
|
64
|
+
account.config.accessToken?.trim()
|
|
65
|
+
? "Access token configured"
|
|
66
|
+
: "Access token missing (needed for image uploads)",
|
|
67
|
+
],
|
|
68
|
+
selectionHint: configured ? "configured" : "needs bot ID",
|
|
69
|
+
quickstartScore: configured ? 1 : 0,
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
configure: async ({ cfg, prompter, accountOverrides }) => {
|
|
73
|
+
const accountId = accountOverrides.groupme ?? DEFAULT_ACCOUNT_ID;
|
|
74
|
+
|
|
75
|
+
await prompter.note(
|
|
76
|
+
[
|
|
77
|
+
"GroupMe bots are bound to a single group.",
|
|
78
|
+
"Create a bot at https://dev.groupme.com/bots and copy bot_id + access token.",
|
|
79
|
+
].join("\n"),
|
|
80
|
+
"GroupMe setup",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const botId = (
|
|
84
|
+
await prompter.text({
|
|
85
|
+
message: "Bot ID",
|
|
86
|
+
validate: (value) => (value.trim() ? undefined : "Bot ID is required"),
|
|
87
|
+
})
|
|
88
|
+
).trim();
|
|
89
|
+
|
|
90
|
+
const accessToken = (
|
|
91
|
+
await prompter.text({
|
|
92
|
+
message: "Access token",
|
|
93
|
+
validate: (value) => (value.trim() ? undefined : "Access token is required"),
|
|
94
|
+
})
|
|
95
|
+
).trim();
|
|
96
|
+
|
|
97
|
+
const botName = (
|
|
98
|
+
await prompter.text({
|
|
99
|
+
message: "Bot name (mention fallback)",
|
|
100
|
+
initialValue: "openclaw",
|
|
101
|
+
})
|
|
102
|
+
).trim();
|
|
103
|
+
|
|
104
|
+
const callbackPath = (
|
|
105
|
+
await prompter.text({
|
|
106
|
+
message: "Webhook path",
|
|
107
|
+
initialValue: "/groupme",
|
|
108
|
+
validate: (value) => (value.trim().startsWith("/") ? undefined : "Path must start with /"),
|
|
109
|
+
})
|
|
110
|
+
).trim();
|
|
111
|
+
|
|
112
|
+
const requireMention = await prompter.confirm({
|
|
113
|
+
message: "Require mention to respond?",
|
|
114
|
+
initialValue: true,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const next = applyGroupMeConfig({
|
|
118
|
+
cfg,
|
|
119
|
+
accountId,
|
|
120
|
+
updates: {
|
|
121
|
+
botId,
|
|
122
|
+
accessToken,
|
|
123
|
+
botName,
|
|
124
|
+
callbackPath,
|
|
125
|
+
requireMention,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await prompter.note(
|
|
130
|
+
[
|
|
131
|
+
"Next steps:",
|
|
132
|
+
`1. Set GroupMe callback URL to https://<your-domain>${callbackPath}`,
|
|
133
|
+
"2. Restart gateway",
|
|
134
|
+
"3. Send a message in the group to test",
|
|
135
|
+
].join("\n"),
|
|
136
|
+
"GroupMe next steps",
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
cfg: next,
|
|
141
|
+
accountId,
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
disable: (cfg) => ({
|
|
145
|
+
...cfg,
|
|
146
|
+
channels: {
|
|
147
|
+
...cfg.channels,
|
|
148
|
+
groupme: {
|
|
149
|
+
...(cfg.channels?.groupme ?? {}),
|
|
150
|
+
enabled: false,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
detectGroupMeMention,
|
|
4
|
+
extractImageUrls,
|
|
5
|
+
parseGroupMeCallback,
|
|
6
|
+
shouldProcessCallback,
|
|
7
|
+
} from "./parse.js";
|
|
8
|
+
|
|
9
|
+
const validPayload = {
|
|
10
|
+
id: "msg-1",
|
|
11
|
+
text: "hello @oddclaw",
|
|
12
|
+
name: "Alice",
|
|
13
|
+
sender_type: "user",
|
|
14
|
+
sender_id: "123",
|
|
15
|
+
user_id: "123",
|
|
16
|
+
group_id: "999",
|
|
17
|
+
source_guid: "src-1",
|
|
18
|
+
created_at: 1_700_000_000,
|
|
19
|
+
system: false,
|
|
20
|
+
avatar_url: "https://i.groupme.com/a.png",
|
|
21
|
+
attachments: [{ type: "image", url: "https://i.groupme.com/img" }],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("parseGroupMeCallback", () => {
|
|
25
|
+
it("parses a valid callback payload", () => {
|
|
26
|
+
const parsed = parseGroupMeCallback(validPayload);
|
|
27
|
+
expect(parsed).not.toBeNull();
|
|
28
|
+
expect(parsed?.id).toBe("msg-1");
|
|
29
|
+
expect(parsed?.senderType).toBe("user");
|
|
30
|
+
expect(parsed?.attachments).toHaveLength(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns null for invalid payload", () => {
|
|
34
|
+
expect(parseGroupMeCallback(null)).toBeNull();
|
|
35
|
+
expect(parseGroupMeCallback({})).toBeNull();
|
|
36
|
+
expect(
|
|
37
|
+
parseGroupMeCallback({
|
|
38
|
+
...validPayload,
|
|
39
|
+
sender_id: null,
|
|
40
|
+
}),
|
|
41
|
+
).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("handles missing text field by normalizing to empty string", () => {
|
|
45
|
+
const parsed = parseGroupMeCallback({
|
|
46
|
+
...validPayload,
|
|
47
|
+
text: null,
|
|
48
|
+
});
|
|
49
|
+
expect(parsed?.text).toBe("");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("extracts image URLs from attachments", () => {
|
|
53
|
+
const parsed = parseGroupMeCallback({
|
|
54
|
+
...validPayload,
|
|
55
|
+
attachments: [
|
|
56
|
+
{ type: "image", url: "https://i.groupme.com/one" },
|
|
57
|
+
{ type: "emoji", placeholder: "x", charmap: [[1, 2]] },
|
|
58
|
+
{ type: "image", url: "https://i.groupme.com/two" },
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
expect(extractImageUrls(parsed?.attachments ?? [])).toEqual([
|
|
62
|
+
"https://i.groupme.com/one",
|
|
63
|
+
"https://i.groupme.com/two",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("shouldProcessCallback", () => {
|
|
69
|
+
it("accepts user messages", () => {
|
|
70
|
+
const parsed = parseGroupMeCallback(validPayload);
|
|
71
|
+
expect(parsed).not.toBeNull();
|
|
72
|
+
expect(shouldProcessCallback(parsed!)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("rejects bot messages", () => {
|
|
76
|
+
const parsed = parseGroupMeCallback({
|
|
77
|
+
...validPayload,
|
|
78
|
+
sender_type: "bot",
|
|
79
|
+
});
|
|
80
|
+
expect(parsed).not.toBeNull();
|
|
81
|
+
expect(shouldProcessCallback(parsed!)).toBe("non-user message");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects system messages", () => {
|
|
85
|
+
const parsed = parseGroupMeCallback({
|
|
86
|
+
...validPayload,
|
|
87
|
+
system: true,
|
|
88
|
+
});
|
|
89
|
+
expect(parsed).not.toBeNull();
|
|
90
|
+
expect(shouldProcessCallback(parsed!)).toBe("system message");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("rejects empty messages with no attachments", () => {
|
|
94
|
+
const parsed = parseGroupMeCallback({
|
|
95
|
+
...validPayload,
|
|
96
|
+
text: " ",
|
|
97
|
+
attachments: [],
|
|
98
|
+
});
|
|
99
|
+
expect(parsed).not.toBeNull();
|
|
100
|
+
expect(shouldProcessCallback(parsed!)).toBe("empty message");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("accepts image-only messages", () => {
|
|
104
|
+
const parsed = parseGroupMeCallback({
|
|
105
|
+
...validPayload,
|
|
106
|
+
text: "",
|
|
107
|
+
attachments: [{ type: "image", url: "https://i.groupme.com/only" }],
|
|
108
|
+
});
|
|
109
|
+
expect(parsed).not.toBeNull();
|
|
110
|
+
expect(shouldProcessCallback(parsed!)).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("detectGroupMeMention", () => {
|
|
115
|
+
it("detects exact bot name mention", () => {
|
|
116
|
+
expect(detectGroupMeMention({ text: "oddclaw help", botName: "oddclaw" })).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("detects @botname mention", () => {
|
|
120
|
+
expect(detectGroupMeMention({ text: "@oddclaw help", botName: "oddclaw" })).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("is case-insensitive", () => {
|
|
124
|
+
expect(detectGroupMeMention({ text: "ODDCLAW", botName: "oddclaw" })).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("uses mentionPatterns regex", () => {
|
|
128
|
+
expect(
|
|
129
|
+
detectGroupMeMention({
|
|
130
|
+
text: "hey there",
|
|
131
|
+
channelMentionPatterns: ["hey\\s+there"],
|
|
132
|
+
}),
|
|
133
|
+
).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("uses agent mention regexes", () => {
|
|
137
|
+
expect(
|
|
138
|
+
detectGroupMeMention({
|
|
139
|
+
text: "Need oddclaw now",
|
|
140
|
+
mentionRegexes: [/\boddclaw\b/i],
|
|
141
|
+
}),
|
|
142
|
+
).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns false for unrelated messages", () => {
|
|
146
|
+
expect(detectGroupMeMention({ text: "random chat", botName: "oddclaw" })).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("handles empty text", () => {
|
|
150
|
+
expect(detectGroupMeMention({ text: "", botName: "oddclaw" })).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("ignores invalid regex patterns", () => {
|
|
154
|
+
expect(
|
|
155
|
+
detectGroupMeMention({
|
|
156
|
+
text: "oddclaw",
|
|
157
|
+
botName: "oddclaw",
|
|
158
|
+
channelMentionPatterns: ["[(invalid"],
|
|
159
|
+
}),
|
|
160
|
+
).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
});
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GroupMeAttachment,
|
|
3
|
+
GroupMeCallbackData,
|
|
4
|
+
GroupMeEmojiAttachment,
|
|
5
|
+
GroupMeImageAttachment,
|
|
6
|
+
GroupMeLocationAttachment,
|
|
7
|
+
GroupMeMentionsAttachment,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
|
|
10
|
+
type JsonRecord = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
13
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readString(value: unknown): string | undefined {
|
|
17
|
+
if (typeof value !== "string") {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
return trimmed || undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readBoolean(value: unknown): boolean | undefined {
|
|
25
|
+
return typeof value === "boolean" ? value : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readNumber(value: unknown): number | undefined {
|
|
29
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
if (typeof value === "string") {
|
|
33
|
+
const parsed = Number(value);
|
|
34
|
+
if (Number.isFinite(parsed)) {
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseNumberMatrix(value: unknown): number[][] {
|
|
42
|
+
if (!Array.isArray(value)) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const rows: number[][] = [];
|
|
47
|
+
for (const row of value) {
|
|
48
|
+
if (!Array.isArray(row)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const parsedRow: number[] = [];
|
|
52
|
+
for (const cell of row) {
|
|
53
|
+
const num = readNumber(cell);
|
|
54
|
+
if (typeof num !== "number") {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
parsedRow.push(num);
|
|
58
|
+
}
|
|
59
|
+
if (parsedRow.length > 0) {
|
|
60
|
+
rows.push(parsedRow);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return rows;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseStringArray(value: unknown): string[] {
|
|
67
|
+
if (!Array.isArray(value)) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return value.map((entry) => readString(entry)).filter((entry): entry is string => Boolean(entry));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseAttachment(entry: unknown): GroupMeAttachment | null {
|
|
75
|
+
if (!isRecord(entry)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const type = readString(entry.type);
|
|
80
|
+
if (!type) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (type === "image") {
|
|
85
|
+
const url = readString(entry.url);
|
|
86
|
+
if (!url) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const imageAttachment: GroupMeImageAttachment = {
|
|
90
|
+
type,
|
|
91
|
+
url,
|
|
92
|
+
};
|
|
93
|
+
return imageAttachment;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (type === "location") {
|
|
97
|
+
const lat = readString(entry.lat);
|
|
98
|
+
const lng = readString(entry.lng);
|
|
99
|
+
const name = readString(entry.name);
|
|
100
|
+
if (!lat || !lng || !name) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const locationAttachment: GroupMeLocationAttachment = {
|
|
104
|
+
type,
|
|
105
|
+
lat,
|
|
106
|
+
lng,
|
|
107
|
+
name,
|
|
108
|
+
};
|
|
109
|
+
return locationAttachment;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (type === "mentions") {
|
|
113
|
+
const mentionsAttachment: GroupMeMentionsAttachment = {
|
|
114
|
+
type,
|
|
115
|
+
user_ids: parseStringArray(entry.user_ids),
|
|
116
|
+
loci: parseNumberMatrix(entry.loci),
|
|
117
|
+
};
|
|
118
|
+
return mentionsAttachment;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (type === "emoji") {
|
|
122
|
+
const placeholder = readString(entry.placeholder);
|
|
123
|
+
if (!placeholder) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const emojiAttachment: GroupMeEmojiAttachment = {
|
|
127
|
+
type,
|
|
128
|
+
placeholder,
|
|
129
|
+
charmap: parseNumberMatrix(entry.charmap),
|
|
130
|
+
};
|
|
131
|
+
return emojiAttachment;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
...entry,
|
|
136
|
+
type,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseAttachments(value: unknown): GroupMeAttachment[] {
|
|
141
|
+
if (!Array.isArray(value)) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const attachments: GroupMeAttachment[] = [];
|
|
146
|
+
for (const entry of value) {
|
|
147
|
+
const parsed = parseAttachment(entry);
|
|
148
|
+
if (parsed) {
|
|
149
|
+
attachments.push(parsed);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return attachments;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function parseGroupMeCallback(data: unknown): GroupMeCallbackData | null {
|
|
156
|
+
if (!isRecord(data)) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const id = readString(data.id);
|
|
161
|
+
const name = readString(data.name);
|
|
162
|
+
const senderType = readString(data.sender_type);
|
|
163
|
+
const senderId = readString(data.sender_id);
|
|
164
|
+
const userId = readString(data.user_id);
|
|
165
|
+
const groupId = readString(data.group_id);
|
|
166
|
+
const sourceGuid = readString(data.source_guid);
|
|
167
|
+
const createdAt = readNumber(data.created_at);
|
|
168
|
+
|
|
169
|
+
if (!id || !name || !senderType || !senderId || !userId || !groupId || !sourceGuid) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
if (typeof createdAt !== "number") {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const avatarUrl = readString(data.avatar_url) ?? null;
|
|
177
|
+
const text = typeof data.text === "string" ? data.text : "";
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
id,
|
|
181
|
+
text,
|
|
182
|
+
name,
|
|
183
|
+
senderType,
|
|
184
|
+
senderId,
|
|
185
|
+
userId,
|
|
186
|
+
groupId,
|
|
187
|
+
sourceGuid,
|
|
188
|
+
createdAt,
|
|
189
|
+
system: readBoolean(data.system) ?? false,
|
|
190
|
+
avatarUrl,
|
|
191
|
+
attachments: parseAttachments(data.attachments),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function hasImageAttachment(attachments: GroupMeAttachment[]): boolean {
|
|
196
|
+
return attachments.some((attachment) => attachment.type === "image");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function shouldProcessCallback(msg: GroupMeCallbackData): string | null {
|
|
200
|
+
if (msg.senderType !== "user") {
|
|
201
|
+
return "non-user message";
|
|
202
|
+
}
|
|
203
|
+
if (msg.system) {
|
|
204
|
+
return "system message";
|
|
205
|
+
}
|
|
206
|
+
if (!msg.text.trim() && !hasImageAttachment(msg.attachments)) {
|
|
207
|
+
return "empty message";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function extractImageUrls(attachments: GroupMeAttachment[]): string[] {
|
|
214
|
+
return attachments
|
|
215
|
+
.filter((attachment): attachment is GroupMeImageAttachment => attachment.type === "image")
|
|
216
|
+
.map((attachment) => attachment.url);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeMentionText(text: string): string {
|
|
220
|
+
return text.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "").toLowerCase();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildRegexes(patterns?: string[]): RegExp[] {
|
|
224
|
+
if (!patterns || patterns.length === 0) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return patterns
|
|
229
|
+
.map((pattern) => {
|
|
230
|
+
try {
|
|
231
|
+
return new RegExp(pattern, "i");
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
.filter((entry): entry is RegExp => Boolean(entry));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function escapeRegexLiteral(value: string): string {
|
|
240
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function detectGroupMeMention(params: {
|
|
244
|
+
text: string;
|
|
245
|
+
botName?: string;
|
|
246
|
+
channelMentionPatterns?: string[];
|
|
247
|
+
mentionRegexes?: RegExp[];
|
|
248
|
+
}): boolean {
|
|
249
|
+
const text = params.text?.trim() ?? "";
|
|
250
|
+
if (!text) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const normalizedText = normalizeMentionText(text);
|
|
255
|
+
const channelRegexes = buildRegexes(params.channelMentionPatterns);
|
|
256
|
+
if (channelRegexes.some((regex) => regex.test(text))) {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const mentionRegexes = params.mentionRegexes ?? [];
|
|
261
|
+
if (mentionRegexes.some((regex) => regex.test(normalizedText))) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const botName = params.botName?.trim();
|
|
266
|
+
if (!botName) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const escaped = escapeRegexLiteral(botName);
|
|
271
|
+
const botRegex = new RegExp(`\\b@?${escaped}\\b`, "i");
|
|
272
|
+
return botRegex.test(text);
|
|
273
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveSenderAccess } from "./policy.js";
|
|
3
|
+
|
|
4
|
+
describe("resolveSenderAccess", () => {
|
|
5
|
+
it("allows all when allowFrom is empty", () => {
|
|
6
|
+
expect(resolveSenderAccess({ senderId: "123", allowFrom: [] })).toBe(true);
|
|
7
|
+
expect(resolveSenderAccess({ senderId: "123" })).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("allows wildcard", () => {
|
|
11
|
+
expect(resolveSenderAccess({ senderId: "123", allowFrom: ["*"] })).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("allows listed sender", () => {
|
|
15
|
+
expect(resolveSenderAccess({ senderId: "123", allowFrom: ["123"] })).toBe(true);
|
|
16
|
+
expect(resolveSenderAccess({ senderId: "123", allowFrom: [123] })).toBe(true);
|
|
17
|
+
expect(resolveSenderAccess({ senderId: "123", allowFrom: ["groupme:user:123"] })).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("blocks unlisted sender", () => {
|
|
21
|
+
expect(resolveSenderAccess({ senderId: "999", allowFrom: ["123", "456"] })).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { normalizeGroupMeAllowEntry, normalizeGroupMeUserId } from "./normalize.js";
|
|
2
|
+
|
|
3
|
+
export function resolveSenderAccess(params: {
|
|
4
|
+
senderId: string;
|
|
5
|
+
allowFrom?: Array<string | number>;
|
|
6
|
+
}): boolean {
|
|
7
|
+
const senderId = normalizeGroupMeUserId(params.senderId);
|
|
8
|
+
if (!senderId) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const allowFrom = params.allowFrom ?? [];
|
|
13
|
+
if (allowFrom.length === 0) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const normalizedAllow = allowFrom
|
|
18
|
+
.map((entry) => normalizeGroupMeAllowEntry(String(entry)))
|
|
19
|
+
.filter((entry): entry is string => Boolean(entry));
|
|
20
|
+
|
|
21
|
+
if (normalizedAllow.includes("*")) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return normalizedAllow.includes(senderId);
|
|
26
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setGroupMeRuntime(next: PluginRuntime) {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getGroupMeRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("GroupMe runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|