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/src/inbound.ts ADDED
@@ -0,0 +1,310 @@
1
+ import type { OpenClawConfig, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import {
3
+ createReplyPrefixOptions,
4
+ logInboundDrop,
5
+ resolveControlCommandGate,
6
+ resolveMentionGatingWithBypass,
7
+ } from "openclaw/plugin-sdk";
8
+ import type { GroupMeCallbackData, ResolvedGroupMeAccount, CoreConfig } from "./types.js";
9
+ import { extractImageUrls, detectGroupMeMention } from "./parse.js";
10
+ import { resolveSenderAccess } from "./policy.js";
11
+ import { getGroupMeRuntime } from "./runtime.js";
12
+ import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText } from "./send.js";
13
+
14
+ const CHANNEL_ID = "groupme" as const;
15
+
16
+ function resolveTextChunkLimit(account: ResolvedGroupMeAccount): number {
17
+ const configured = account.config.textChunkLimit;
18
+ if (!Number.isFinite(configured)) {
19
+ return GROUPME_MAX_TEXT_LENGTH;
20
+ }
21
+ const value = Math.floor(configured as number);
22
+ if (value <= 0) {
23
+ return GROUPME_MAX_TEXT_LENGTH;
24
+ }
25
+ return Math.min(value, GROUPME_MAX_TEXT_LENGTH);
26
+ }
27
+
28
+ function chunkReplyText(params: {
29
+ text: string;
30
+ limit: number;
31
+ core: ReturnType<typeof getGroupMeRuntime>;
32
+ }): string[] {
33
+ const trimmed = params.text.trim();
34
+ if (!trimmed) {
35
+ return [];
36
+ }
37
+
38
+ return params.core.channel.text.chunkMarkdownText(trimmed, params.limit).filter(Boolean);
39
+ }
40
+
41
+ async function deliverGroupMeReply(params: {
42
+ payload: ReplyPayload;
43
+ account: ResolvedGroupMeAccount;
44
+ cfg: CoreConfig;
45
+ target: string;
46
+ statusSink?: (patch: { lastOutboundAt?: number }) => void;
47
+ }) {
48
+ const { payload, account, cfg, target, statusSink } = params;
49
+ const core = getGroupMeRuntime();
50
+
51
+ const text = payload.text ?? "";
52
+ const mediaUrls = payload.mediaUrls?.length
53
+ ? payload.mediaUrls
54
+ : payload.mediaUrl
55
+ ? [payload.mediaUrl]
56
+ : [];
57
+
58
+ if (!text.trim() && mediaUrls.length === 0) {
59
+ return;
60
+ }
61
+
62
+ const chunks = chunkReplyText({
63
+ text,
64
+ limit: resolveTextChunkLimit(account),
65
+ core,
66
+ });
67
+
68
+ const sendTextChunk = async (chunk: string) => {
69
+ await sendGroupMeText({
70
+ cfg,
71
+ to: target,
72
+ text: chunk,
73
+ accountId: account.accountId,
74
+ });
75
+ statusSink?.({ lastOutboundAt: Date.now() });
76
+ core.channel.activity.record({
77
+ channel: CHANNEL_ID,
78
+ accountId: account.accountId,
79
+ direction: "outbound",
80
+ });
81
+ };
82
+
83
+ if (mediaUrls.length === 0) {
84
+ for (const chunk of chunks) {
85
+ await sendTextChunk(chunk);
86
+ }
87
+ return;
88
+ }
89
+
90
+ const [firstMedia, ...restMedia] = mediaUrls;
91
+ const [firstChunk, ...restChunks] = chunks;
92
+
93
+ await sendGroupMeMedia({
94
+ cfg,
95
+ to: target,
96
+ text: firstChunk ?? "",
97
+ mediaUrl: firstMedia,
98
+ accountId: account.accountId,
99
+ });
100
+ statusSink?.({ lastOutboundAt: Date.now() });
101
+ core.channel.activity.record({
102
+ channel: CHANNEL_ID,
103
+ accountId: account.accountId,
104
+ direction: "outbound",
105
+ });
106
+
107
+ for (const chunk of restChunks) {
108
+ await sendTextChunk(chunk);
109
+ }
110
+
111
+ for (const mediaUrl of restMedia) {
112
+ await sendGroupMeMedia({
113
+ cfg,
114
+ to: target,
115
+ text: "",
116
+ mediaUrl,
117
+ accountId: account.accountId,
118
+ });
119
+ statusSink?.({ lastOutboundAt: Date.now() });
120
+ core.channel.activity.record({
121
+ channel: CHANNEL_ID,
122
+ accountId: account.accountId,
123
+ direction: "outbound",
124
+ });
125
+ }
126
+ }
127
+
128
+ export async function handleGroupMeInbound(params: {
129
+ message: GroupMeCallbackData;
130
+ account: ResolvedGroupMeAccount;
131
+ config: CoreConfig;
132
+ runtime: RuntimeEnv;
133
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
134
+ }): Promise<void> {
135
+ const { message, account, config, runtime, statusSink } = params;
136
+ const core = getGroupMeRuntime();
137
+
138
+ const inboundTimestamp = message.createdAt * 1000;
139
+ statusSink?.({ lastInboundAt: inboundTimestamp });
140
+ core.channel.activity.record({
141
+ channel: CHANNEL_ID,
142
+ accountId: account.accountId,
143
+ direction: "inbound",
144
+ at: inboundTimestamp,
145
+ });
146
+
147
+ const allowFrom = account.config.allowFrom ?? [];
148
+ const senderAllowed = resolveSenderAccess({
149
+ senderId: message.senderId,
150
+ allowFrom,
151
+ });
152
+ if (!senderAllowed) {
153
+ runtime.log?.(`groupme: drop sender ${message.senderId} (not in allowFrom)`);
154
+ return;
155
+ }
156
+
157
+ const route = core.channel.routing.resolveAgentRoute({
158
+ cfg: config as OpenClawConfig,
159
+ channel: CHANNEL_ID,
160
+ accountId: account.accountId,
161
+ peer: {
162
+ kind: "group",
163
+ id: message.groupId,
164
+ },
165
+ });
166
+
167
+ const mentionRegexes = core.channel.mentions.buildMentionRegexes(
168
+ config as OpenClawConfig,
169
+ route.agentId,
170
+ );
171
+ const wasMentioned = detectGroupMeMention({
172
+ text: message.text,
173
+ botName: account.config.botName,
174
+ channelMentionPatterns: account.config.mentionPatterns,
175
+ mentionRegexes,
176
+ });
177
+
178
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
179
+ cfg: config as OpenClawConfig,
180
+ surface: CHANNEL_ID,
181
+ });
182
+ const hasControlCommand = core.channel.text.hasControlCommand(
183
+ message.text,
184
+ config as OpenClawConfig,
185
+ );
186
+
187
+ const commandGate = resolveControlCommandGate({
188
+ useAccessGroups: config.commands?.useAccessGroups !== false,
189
+ authorizers: [{ configured: allowFrom.length > 0, allowed: senderAllowed }],
190
+ allowTextCommands,
191
+ hasControlCommand,
192
+ });
193
+ if (commandGate.shouldBlock) {
194
+ logInboundDrop({
195
+ log: (line) => runtime.log?.(line),
196
+ channel: CHANNEL_ID,
197
+ reason: "control command (unauthorized)",
198
+ target: message.senderId,
199
+ });
200
+ return;
201
+ }
202
+
203
+ const mentionGate = resolveMentionGatingWithBypass({
204
+ isGroup: true,
205
+ requireMention: account.config.requireMention ?? true,
206
+ canDetectMention: true,
207
+ wasMentioned,
208
+ hasAnyMention: false,
209
+ allowTextCommands,
210
+ hasControlCommand,
211
+ commandAuthorized: commandGate.commandAuthorized,
212
+ });
213
+
214
+ if (mentionGate.shouldSkip) {
215
+ runtime.log?.("groupme: skip message (mention required, not mentioned)");
216
+ return;
217
+ }
218
+
219
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
220
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
221
+ agentId: route.agentId,
222
+ });
223
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
224
+ storePath,
225
+ sessionKey: route.sessionKey,
226
+ });
227
+
228
+ const imageUrls = extractImageUrls(message.attachments);
229
+ const rawBody = message.text;
230
+ const bodyForAgent =
231
+ rawBody.trim() ||
232
+ (imageUrls.length > 0 ? imageUrls.map((url) => `Image: ${url}`).join("\n") : rawBody);
233
+
234
+ const body = core.channel.reply.formatAgentEnvelope({
235
+ channel: "GroupMe",
236
+ from: message.name,
237
+ timestamp: inboundTimestamp,
238
+ previousTimestamp,
239
+ envelope: envelopeOptions,
240
+ body: bodyForAgent,
241
+ });
242
+
243
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
244
+ Body: body,
245
+ BodyForAgent: bodyForAgent,
246
+ RawBody: rawBody,
247
+ CommandBody: rawBody,
248
+ From: `groupme:user:${message.senderId}`,
249
+ To: `groupme:group:${message.groupId}`,
250
+ SessionKey: route.sessionKey,
251
+ AccountId: route.accountId,
252
+ ChatType: "group",
253
+ ConversationLabel: `groupme:${message.groupId}`,
254
+ SenderName: message.name,
255
+ SenderId: message.senderId,
256
+ Provider: CHANNEL_ID,
257
+ Surface: CHANNEL_ID,
258
+ WasMentioned: mentionGate.effectiveWasMentioned,
259
+ MessageSid: message.id,
260
+ Timestamp: inboundTimestamp,
261
+ OriginatingChannel: CHANNEL_ID,
262
+ OriginatingTo: `groupme:group:${message.groupId}`,
263
+ CommandAuthorized: commandGate.commandAuthorized,
264
+ MediaUrl: imageUrls[0],
265
+ MediaUrls: imageUrls.length > 0 ? imageUrls : undefined,
266
+ });
267
+
268
+ await core.channel.session.recordInboundSession({
269
+ storePath,
270
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
271
+ ctx: ctxPayload,
272
+ onRecordError: (err) => {
273
+ runtime.error?.(`groupme: failed updating session meta: ${String(err)}`);
274
+ },
275
+ });
276
+
277
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
278
+ cfg: config as OpenClawConfig,
279
+ agentId: route.agentId,
280
+ channel: CHANNEL_ID,
281
+ accountId: account.accountId,
282
+ });
283
+
284
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
285
+ ctx: ctxPayload,
286
+ cfg: config as OpenClawConfig,
287
+ dispatcherOptions: {
288
+ ...prefixOptions,
289
+ deliver: async (payload) => {
290
+ await deliverGroupMeReply({
291
+ payload,
292
+ account,
293
+ cfg: config,
294
+ target: `groupme:group:${message.groupId}`,
295
+ statusSink,
296
+ });
297
+ },
298
+ onError: (err, info) => {
299
+ runtime.error?.(`groupme ${info.kind} reply failed: ${String(err)}`);
300
+ },
301
+ },
302
+ replyOptions: {
303
+ onModelSelected,
304
+ disableBlockStreaming:
305
+ typeof account.config.blockStreaming === "boolean"
306
+ ? !account.config.blockStreaming
307
+ : undefined,
308
+ },
309
+ });
310
+ }
@@ -0,0 +1,186 @@
1
+ import type { AddressInfo } from "node:net";
2
+ import type { RuntimeEnv } from "openclaw/plugin-sdk";
3
+ import { createServer } from "node:http";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { CoreConfig, ResolvedGroupMeAccount } from "./types.js";
6
+
7
+ const handleGroupMeInboundMock = vi.hoisted(() => vi.fn(async () => undefined));
8
+
9
+ vi.mock("./inbound.js", () => ({
10
+ handleGroupMeInbound: handleGroupMeInboundMock,
11
+ }));
12
+
13
+ import { createGroupMeWebhookHandler } from "./monitor.js";
14
+
15
+ async function withServer(
16
+ handler: Parameters<typeof createServer>[0],
17
+ fn: (baseUrl: string) => Promise<void>,
18
+ ) {
19
+ const server = createServer(handler);
20
+ await new Promise<void>((resolve, reject) => {
21
+ const onError = (error: Error) => {
22
+ server.off("listening", onListening);
23
+ reject(error);
24
+ };
25
+ const onListening = () => {
26
+ server.off("error", onError);
27
+ resolve();
28
+ };
29
+ server.once("error", onError);
30
+ server.once("listening", onListening);
31
+ server.listen(0, "127.0.0.1");
32
+ });
33
+
34
+ const address = server.address() as AddressInfo | null;
35
+ if (!address) {
36
+ throw new Error("missing server address");
37
+ }
38
+
39
+ try {
40
+ await fn(`http://127.0.0.1:${address.port}`);
41
+ } finally {
42
+ await new Promise<void>((resolve) => server.close(() => resolve()));
43
+ }
44
+ }
45
+
46
+ function isListenPermissionError(error: unknown): boolean {
47
+ if (!error || typeof error !== "object") {
48
+ return false;
49
+ }
50
+ const maybeErr = error as { code?: unknown; syscall?: unknown };
51
+ return maybeErr.code === "EPERM" && maybeErr.syscall === "listen";
52
+ }
53
+
54
+ async function runIfServerAllowed(fn: () => Promise<void>): Promise<void> {
55
+ try {
56
+ await fn();
57
+ } catch (error) {
58
+ if (isListenPermissionError(error)) {
59
+ return;
60
+ }
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ function buildRuntime(): RuntimeEnv {
66
+ return {
67
+ log: vi.fn(),
68
+ error: vi.fn(),
69
+ exit: (() => {
70
+ throw new Error("exit");
71
+ }) as RuntimeEnv["exit"],
72
+ };
73
+ }
74
+
75
+ const account: ResolvedGroupMeAccount = {
76
+ accountId: "default",
77
+ enabled: true,
78
+ configured: true,
79
+ botId: "bot-1",
80
+ accessToken: "token-1",
81
+ config: {
82
+ botId: "bot-1",
83
+ accessToken: "token-1",
84
+ },
85
+ };
86
+
87
+ const config = {} as CoreConfig;
88
+
89
+ describe("createGroupMeWebhookHandler", () => {
90
+ it("returns 405 for non-POST", async () => {
91
+ const runtime = buildRuntime();
92
+ const handler = createGroupMeWebhookHandler({ account, config, runtime });
93
+
94
+ await runIfServerAllowed(async () => {
95
+ await withServer(
96
+ async (req, res) => handler(req, res),
97
+ async (baseUrl) => {
98
+ const response = await fetch(`${baseUrl}/groupme`, { method: "GET" });
99
+ expect(response.status).toBe(405);
100
+ expect(await response.text()).toBe("Method Not Allowed");
101
+ },
102
+ );
103
+ });
104
+ });
105
+
106
+ it("returns 400 for invalid JSON", async () => {
107
+ const runtime = buildRuntime();
108
+ const handler = createGroupMeWebhookHandler({ account, config, runtime });
109
+
110
+ await runIfServerAllowed(async () => {
111
+ await withServer(
112
+ async (req, res) => handler(req, res),
113
+ async (baseUrl) => {
114
+ const response = await fetch(`${baseUrl}/groupme`, {
115
+ method: "POST",
116
+ headers: { "content-type": "application/json" },
117
+ body: "{",
118
+ });
119
+ expect(response.status).toBe(400);
120
+ },
121
+ );
122
+ });
123
+ });
124
+
125
+ it("acknowledges parseable payload and dispatches inbound", async () => {
126
+ handleGroupMeInboundMock.mockClear();
127
+ const runtime = buildRuntime();
128
+ const handler = createGroupMeWebhookHandler({ account, config, runtime });
129
+
130
+ const payload = {
131
+ id: "msg-1",
132
+ text: "hello",
133
+ name: "Alice",
134
+ sender_type: "user",
135
+ sender_id: "123",
136
+ user_id: "123",
137
+ group_id: "456",
138
+ source_guid: "source",
139
+ created_at: 1_700_000_000,
140
+ system: false,
141
+ attachments: [],
142
+ };
143
+
144
+ await runIfServerAllowed(async () => {
145
+ await withServer(
146
+ async (req, res) => handler(req, res),
147
+ async (baseUrl) => {
148
+ const response = await fetch(`${baseUrl}/groupme`, {
149
+ method: "POST",
150
+ headers: { "content-type": "application/json" },
151
+ body: JSON.stringify(payload),
152
+ });
153
+ expect(response.status).toBe(200);
154
+ expect(await response.text()).toBe("ok");
155
+
156
+ // Wait for fire-and-forget processing.
157
+ await new Promise((resolve) => setTimeout(resolve, 0));
158
+ expect(handleGroupMeInboundMock).toHaveBeenCalledTimes(1);
159
+ },
160
+ );
161
+ });
162
+ });
163
+
164
+ it("drops unparseable payload after returning 200", async () => {
165
+ handleGroupMeInboundMock.mockClear();
166
+ const runtime = buildRuntime();
167
+ const handler = createGroupMeWebhookHandler({ account, config, runtime });
168
+
169
+ await runIfServerAllowed(async () => {
170
+ await withServer(
171
+ async (req, res) => handler(req, res),
172
+ async (baseUrl) => {
173
+ const response = await fetch(`${baseUrl}/groupme`, {
174
+ method: "POST",
175
+ headers: { "content-type": "application/json" },
176
+ body: JSON.stringify({ nope: true }),
177
+ });
178
+ expect(response.status).toBe(200);
179
+ await new Promise((resolve) => setTimeout(resolve, 0));
180
+ expect(handleGroupMeInboundMock).not.toHaveBeenCalled();
181
+ expect(runtime.log).toHaveBeenCalledWith("groupme: unparseable callback payload");
182
+ },
183
+ );
184
+ });
185
+ });
186
+ });
package/src/monitor.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { RuntimeEnv } from "openclaw/plugin-sdk";
3
+ import { readJsonBodyWithLimit, requestBodyErrorToText } from "openclaw/plugin-sdk";
4
+ import type { CoreConfig, ResolvedGroupMeAccount } from "./types.js";
5
+ import { handleGroupMeInbound } from "./inbound.js";
6
+ import { parseGroupMeCallback, shouldProcessCallback } from "./parse.js";
7
+
8
+ export type GroupMeWebhookHandlerParams = {
9
+ account: ResolvedGroupMeAccount;
10
+ config: CoreConfig;
11
+ runtime: RuntimeEnv;
12
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
13
+ };
14
+
15
+ export function createGroupMeWebhookHandler(
16
+ params: GroupMeWebhookHandlerParams,
17
+ ): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
18
+ return async (req, res) => {
19
+ if (req.method !== "POST") {
20
+ res.statusCode = 405;
21
+ res.setHeader("Allow", "POST");
22
+ res.end("Method Not Allowed");
23
+ return;
24
+ }
25
+
26
+ const body = await readJsonBodyWithLimit(req, {
27
+ maxBytes: 64 * 1024,
28
+ timeoutMs: 15_000,
29
+ emptyObjectOnEmpty: false,
30
+ });
31
+ if (!body.ok) {
32
+ res.statusCode =
33
+ body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
34
+ res.end(body.code === "INVALID_JSON" ? body.error : requestBodyErrorToText(body.code));
35
+ return;
36
+ }
37
+
38
+ res.statusCode = 200;
39
+ res.end("ok");
40
+
41
+ const message = parseGroupMeCallback(body.value);
42
+ if (!message) {
43
+ params.runtime.log?.("groupme: unparseable callback payload");
44
+ return;
45
+ }
46
+
47
+ const ignoreReason = shouldProcessCallback(message);
48
+ if (ignoreReason) {
49
+ params.runtime.log?.(`groupme: ignoring message (${ignoreReason})`);
50
+ return;
51
+ }
52
+
53
+ void handleGroupMeInbound({
54
+ message,
55
+ account: params.account,
56
+ config: params.config,
57
+ runtime: params.runtime,
58
+ statusSink: params.statusSink,
59
+ }).catch((err) => {
60
+ params.runtime.error?.(`groupme: inbound processing failed: ${String(err)}`);
61
+ });
62
+ };
63
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ looksLikeGroupMeTargetId,
4
+ normalizeGroupMeAllowEntry,
5
+ normalizeGroupMeGroupId,
6
+ normalizeGroupMeTarget,
7
+ normalizeGroupMeUserId,
8
+ } from "./normalize.js";
9
+
10
+ describe("groupme normalize", () => {
11
+ it("normalizes user and group ids", () => {
12
+ expect(normalizeGroupMeUserId(" 123 ")).toBe("123");
13
+ expect(normalizeGroupMeGroupId(456)).toBe("456");
14
+ });
15
+
16
+ it("returns undefined for empty IDs", () => {
17
+ expect(normalizeGroupMeUserId(" ")).toBeUndefined();
18
+ expect(normalizeGroupMeGroupId("")).toBeUndefined();
19
+ });
20
+
21
+ it("normalizes prefixed targets", () => {
22
+ expect(normalizeGroupMeTarget("groupme:group:12345")).toBe("12345");
23
+ expect(normalizeGroupMeTarget("groupme:user:54321")).toBe("54321");
24
+ expect(normalizeGroupMeTarget("group:abc")).toBe("abc");
25
+ expect(normalizeGroupMeTarget("12345")).toBe("12345");
26
+ });
27
+
28
+ it("returns undefined for empty target", () => {
29
+ expect(normalizeGroupMeTarget(" ")).toBeUndefined();
30
+ });
31
+
32
+ it("normalizes allow entries and keeps wildcard", () => {
33
+ expect(normalizeGroupMeAllowEntry("groupme:user:123")).toBe("123");
34
+ expect(normalizeGroupMeAllowEntry("*")).toBe("*");
35
+ });
36
+
37
+ it("validates likely target IDs", () => {
38
+ expect(looksLikeGroupMeTargetId("groupme:group:123")).toBe(true);
39
+ expect(looksLikeGroupMeTargetId("abc-123")).toBe(true);
40
+ expect(looksLikeGroupMeTargetId("has spaces")).toBe(false);
41
+ expect(looksLikeGroupMeTargetId(" ")).toBe(false);
42
+ });
43
+ });
@@ -0,0 +1,43 @@
1
+ function normalizeStringId(raw: string | number): string | undefined {
2
+ const normalized = String(raw).trim();
3
+ return normalized || undefined;
4
+ }
5
+
6
+ export function normalizeGroupMeUserId(raw: string | number): string | undefined {
7
+ return normalizeStringId(raw);
8
+ }
9
+
10
+ export function normalizeGroupMeGroupId(raw: string | number): string | undefined {
11
+ return normalizeStringId(raw);
12
+ }
13
+
14
+ const TARGET_PREFIX_RE = /^(groupme:)?(user:|group:)?/i;
15
+
16
+ export function normalizeGroupMeTarget(raw: string): string | undefined {
17
+ const trimmed = raw.trim();
18
+ if (!trimmed) {
19
+ return undefined;
20
+ }
21
+
22
+ const stripped = trimmed.replace(TARGET_PREFIX_RE, "").trim();
23
+ return stripped || undefined;
24
+ }
25
+
26
+ export function normalizeGroupMeAllowEntry(raw: string): string | undefined {
27
+ const normalized = normalizeGroupMeTarget(raw);
28
+ if (!normalized) {
29
+ return undefined;
30
+ }
31
+ return normalized.toLowerCase() === "*" ? "*" : normalized;
32
+ }
33
+
34
+ export function looksLikeGroupMeTargetId(raw: string): boolean {
35
+ const normalized = normalizeGroupMeTarget(raw);
36
+ if (!normalized) {
37
+ return false;
38
+ }
39
+ if (normalized === "*") {
40
+ return false;
41
+ }
42
+ return !/\s/.test(normalized);
43
+ }