@chbo297/infoflow 2026.2.23

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/send.ts ADDED
@@ -0,0 +1,527 @@
1
+ /**
2
+ * Outbound send API: POST messages to the Infoflow service.
3
+ * Supports both private (DM) and group chat messages.
4
+ */
5
+
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
8
+ import { resolveInfoflowAccount } from "./accounts.js";
9
+ import { recordSentMessageId } from "./infoflow-req-parse.js";
10
+ import { getInfoflowSendLog } from "./logging.js";
11
+ import { getInfoflowRuntime } from "./runtime.js";
12
+ import type {
13
+ InfoflowGroupMessageBodyItem,
14
+ InfoflowMessageContentItem,
15
+ ResolvedInfoflowAccount,
16
+ } from "./types.js";
17
+
18
+ const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds
19
+
20
+ /**
21
+ * Ensures apiHost uses HTTPS for security (secrets in transit).
22
+ * Allows HTTP only for localhost/127.0.0.1 (local development).
23
+ */
24
+ function ensureHttps(apiHost: string): string {
25
+ if (apiHost.startsWith("http://")) {
26
+ const url = new URL(apiHost);
27
+ const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
28
+ if (!isLocal) {
29
+ return apiHost.replace(/^http:/, "https:");
30
+ }
31
+ }
32
+ return apiHost;
33
+ }
34
+
35
+ // Infoflow API paths (host is configured via apiHost in config)
36
+ const INFOFLOW_AUTH_PATH = "/api/v1/auth/app_access_token";
37
+ const INFOFLOW_PRIVATE_SEND_PATH = "/api/v1/app/message/send";
38
+ const INFOFLOW_GROUP_SEND_PATH = "/api/v1/robot/msg/groupmsgsend";
39
+
40
+ // Token cache to avoid fetching token for every message
41
+ // Use Map keyed by appKey to support multi-account isolation
42
+ const tokenCacheMap = new Map<string, { token: string; expiresAt: number }>();
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Helper Functions
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Parses link content format: "href" or "[label]href"
50
+ * Returns both href and label (label defaults to href if not specified)
51
+ */
52
+ function parseLinkContent(content: string): { href: string; label: string } {
53
+ if (content.startsWith("[")) {
54
+ const closeBracket = content.indexOf("]");
55
+ if (closeBracket > 1) {
56
+ return {
57
+ label: content.slice(1, closeBracket),
58
+ href: content.slice(closeBracket + 1),
59
+ };
60
+ }
61
+ }
62
+ return { href: content, label: content };
63
+ }
64
+
65
+ /**
66
+ * Extracts message ID from Infoflow API response data.
67
+ * Handles different response formats:
68
+ * - Private: data.msgkey
69
+ * - Group: data.data.messageid or data.data.msgid (nested)
70
+ * - Fallback: data.messageid or data.msgid (flat)
71
+ */
72
+ function extractMessageId(data: Record<string, unknown>): string | undefined {
73
+ // Try data.msgkey (private message format)
74
+ if (data.msgkey != null) {
75
+ return String(data.msgkey);
76
+ }
77
+
78
+ // Try nested data.data structure (group message format)
79
+ const innerData = data.data as Record<string, unknown> | undefined;
80
+ if (innerData && typeof innerData === "object") {
81
+ // Try data.data.messageid
82
+ if (innerData.messageid != null) {
83
+ return String(innerData.messageid);
84
+ }
85
+ // Try data.data.msgid
86
+ if (innerData.msgid != null) {
87
+ return String(innerData.msgid);
88
+ }
89
+ }
90
+
91
+ // Fallback: try flat structure
92
+ if (data.messageid != null) {
93
+ return String(data.messageid);
94
+ }
95
+ if (data.msgid != null) {
96
+ return String(data.msgid);
97
+ }
98
+
99
+ return undefined;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Token Management
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Gets the app access token from Infoflow API.
108
+ * Token is cached and reused until expiry.
109
+ */
110
+ export async function getAppAccessToken(params: {
111
+ apiHost: string;
112
+ appKey: string;
113
+ appSecret: string;
114
+ timeoutMs?: number;
115
+ }): Promise<{ ok: boolean; token?: string; error?: string }> {
116
+ const { apiHost, appKey, appSecret, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
117
+
118
+ // Check cache first (by appKey for multi-account isolation)
119
+ const cached = tokenCacheMap.get(appKey);
120
+ if (cached && cached.expiresAt > Date.now()) {
121
+ return { ok: true, token: cached.token };
122
+ }
123
+
124
+ let timeout: ReturnType<typeof setTimeout> | undefined;
125
+ try {
126
+ const controller = new AbortController();
127
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
128
+
129
+ // app_secret needs to be MD5 hashed (lowercase)
130
+ const md5Secret = createHash("md5").update(appSecret).digest("hex").toLowerCase();
131
+
132
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_AUTH_PATH}`, {
133
+ method: "POST",
134
+ headers: { "Content-Type": "application/json" },
135
+ body: JSON.stringify({ app_key: appKey, app_secret: md5Secret }),
136
+ signal: controller.signal,
137
+ });
138
+
139
+ if (!res.ok) {
140
+ return { ok: false, error: `HTTP ${res.status}` };
141
+ }
142
+
143
+ const data = (await res.json()) as Record<string, unknown>;
144
+
145
+ if (data.errcode && data.errcode !== 0) {
146
+ const errMsg = String(data.errmsg ?? `errcode ${data.errcode}`);
147
+ return { ok: false, error: errMsg };
148
+ }
149
+
150
+ const dataField = data.data as { app_access_token?: string; expires_in?: number } | undefined;
151
+ const token = dataField?.app_access_token;
152
+ const expiresIn = dataField?.expires_in ?? 7200; // default 2 hours
153
+
154
+ if (!token) {
155
+ return { ok: false, error: "no token in response" };
156
+ }
157
+
158
+ // Cache token by appKey (with 5 minute buffer before expiry)
159
+ tokenCacheMap.set(appKey, {
160
+ token,
161
+ expiresAt: Date.now() + (expiresIn - 300) * 1000,
162
+ });
163
+
164
+ return { ok: true, token };
165
+ } catch (err) {
166
+ const errMsg = err instanceof Error ? err.message : String(err);
167
+ return { ok: false, error: errMsg };
168
+ } finally {
169
+ clearTimeout(timeout);
170
+ }
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Private Chat (DM) Message Sending
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /**
178
+ * Sends a private (DM) message to a user.
179
+ * @param account - Resolved Infoflow account with config
180
+ * @param toUser - Recipient's uuapName (email prefix), multiple users separated by |
181
+ * @param contents - Array of content items (text/markdown; "at" is ignored for private messages)
182
+ */
183
+ export async function sendInfoflowPrivateMessage(params: {
184
+ account: ResolvedInfoflowAccount;
185
+ toUser: string;
186
+ contents: InfoflowMessageContentItem[];
187
+ timeoutMs?: number;
188
+ }): Promise<{ ok: boolean; error?: string; invaliduser?: string; msgkey?: string }> {
189
+ const { account, toUser, contents, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
190
+ const { apiHost, appKey, appSecret } = account.config;
191
+
192
+ // Validate account config
193
+ if (!appKey || !appSecret) {
194
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
195
+ }
196
+
197
+ // Check if contents contain link type
198
+ const hasLink = contents.some((item) => item.type.toLowerCase() === "link");
199
+
200
+ // Get token first
201
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
202
+ if (!tokenResult.ok || !tokenResult.token) {
203
+ getInfoflowSendLog().error(`[infoflow:sendPrivate] token error: ${tokenResult.error}`);
204
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
205
+ }
206
+
207
+ let timeout: ReturnType<typeof setTimeout> | undefined;
208
+ try {
209
+ const controller = new AbortController();
210
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
211
+
212
+ let payload: Record<string, unknown>;
213
+
214
+ if (hasLink) {
215
+ // Build richtext format payload when link is present
216
+ const richtextContent: Array<{ type: string; text?: string; href?: string; label?: string }> =
217
+ [];
218
+
219
+ for (const item of contents) {
220
+ const type = item.type.toLowerCase();
221
+ if (type === "text") {
222
+ richtextContent.push({ type: "text", text: item.content });
223
+ } else if (type === "md" || type === "markdown") {
224
+ richtextContent.push({ type: "text", text: item.content });
225
+ } else if (type === "link") {
226
+ if (item.content) {
227
+ const { href, label } = parseLinkContent(item.content);
228
+ richtextContent.push({ type: "a", href, label });
229
+ }
230
+ }
231
+ }
232
+
233
+ if (richtextContent.length === 0) {
234
+ return { ok: false, error: "no valid content for private message" };
235
+ }
236
+
237
+ payload = {
238
+ touser: toUser,
239
+ msgtype: "richtext",
240
+ richtext: { content: richtextContent },
241
+ };
242
+ } else {
243
+ // Original logic: filter text/markdown contents and merge with '\n'
244
+ const textParts: string[] = [];
245
+ let hasMarkdown = false;
246
+
247
+ for (const item of contents) {
248
+ const type = item.type.toLowerCase();
249
+ if (type === "text") {
250
+ textParts.push(item.content);
251
+ } else if (type === "md" || type === "markdown") {
252
+ textParts.push(item.content);
253
+ hasMarkdown = true;
254
+ }
255
+ }
256
+
257
+ if (textParts.length === 0) {
258
+ return { ok: false, error: "no valid content for private message" };
259
+ }
260
+
261
+ const mergedContent = textParts.join("\n");
262
+ const msgtype: string = hasMarkdown ? "md" : "text";
263
+
264
+ payload = { touser: toUser, msgtype };
265
+ if (msgtype === "text") {
266
+ payload.text = { content: mergedContent };
267
+ } else {
268
+ payload.md = { content: mergedContent };
269
+ }
270
+ }
271
+
272
+ const headers = {
273
+ Authorization: `Bearer-${tokenResult.token}`,
274
+ "Content-Type": "application/json; charset=utf-8",
275
+ LOGID: randomUUID(),
276
+ };
277
+
278
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_PRIVATE_SEND_PATH}`, {
279
+ method: "POST",
280
+ headers,
281
+ body: JSON.stringify(payload),
282
+ signal: controller.signal,
283
+ });
284
+
285
+ const data = JSON.parse(await res.text()) as Record<string, unknown>;
286
+
287
+ // Check outer code first
288
+ const code = typeof data.code === "string" ? data.code : "";
289
+ if (code !== "ok") {
290
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
291
+ getInfoflowSendLog().error(`[infoflow:sendPrivate] failed: ${errMsg}`);
292
+ return { ok: false, error: errMsg };
293
+ }
294
+
295
+ // Check inner data.errcode
296
+ const innerData = data.data as Record<string, unknown> | undefined;
297
+ const errcode = innerData?.errcode;
298
+ if (errcode != null && errcode !== 0) {
299
+ const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
300
+ getInfoflowSendLog().error(`[infoflow:sendPrivate] failed: ${errMsg}`);
301
+ return {
302
+ ok: false,
303
+ error: errMsg,
304
+ invaliduser: innerData?.invaliduser as string | undefined,
305
+ };
306
+ }
307
+
308
+ // Extract message ID and record for dedup
309
+ const msgkey = extractMessageId(innerData ?? {});
310
+ if (msgkey) {
311
+ recordSentMessageId(msgkey);
312
+ }
313
+
314
+ return { ok: true, invaliduser: innerData?.invaliduser as string | undefined, msgkey };
315
+ } catch (err) {
316
+ const errMsg = err instanceof Error ? err.message : String(err);
317
+ getInfoflowSendLog().error(`[infoflow:sendPrivate] exception: ${errMsg}`);
318
+ return { ok: false, error: errMsg };
319
+ } finally {
320
+ clearTimeout(timeout);
321
+ }
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Group Chat Message Sending
326
+ // ---------------------------------------------------------------------------
327
+
328
+ /**
329
+ * Sends a group chat message.
330
+ * @param account - Resolved Infoflow account with config
331
+ * @param groupId - Target group ID (numeric)
332
+ * @param contents - Array of content items (text/markdown/at)
333
+ */
334
+ export async function sendInfoflowGroupMessage(params: {
335
+ account: ResolvedInfoflowAccount;
336
+ groupId: number;
337
+ contents: InfoflowMessageContentItem[];
338
+ timeoutMs?: number;
339
+ }): Promise<{ ok: boolean; error?: string; messageid?: string }> {
340
+ const { account, groupId, contents, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
341
+ const { apiHost, appKey, appSecret } = account.config;
342
+
343
+ // Validate account config
344
+ if (!appKey || !appSecret) {
345
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
346
+ }
347
+
348
+ // Validate contents
349
+ if (contents.length === 0) {
350
+ return { ok: false, error: "contents array is empty" };
351
+ }
352
+
353
+ // Build group message body from contents
354
+ let hasMarkdown = false;
355
+ const body: InfoflowGroupMessageBodyItem[] = [];
356
+ for (const item of contents) {
357
+ const type = item.type.toLowerCase();
358
+ if (type === "text") {
359
+ body.push({ type: "TEXT", content: item.content });
360
+ } else if (type === "md" || type === "markdown") {
361
+ body.push({ type: "MD", content: item.content });
362
+ hasMarkdown = true;
363
+ } else if (type === "at") {
364
+ // Parse AT content: "all" means atall, otherwise comma-separated user IDs
365
+ if (item.content === "all") {
366
+ body.push({ type: "AT", atall: true, atuserids: [] });
367
+ } else {
368
+ const userIds = item.content
369
+ .split(",")
370
+ .map((s) => s.trim())
371
+ .filter(Boolean);
372
+ if (userIds.length > 0) {
373
+ body.push({ type: "AT", atuserids: userIds });
374
+ }
375
+ }
376
+ } else if (type === "link") {
377
+ // Group messages only use href (label is ignored)
378
+ if (item.content) {
379
+ const { href } = parseLinkContent(item.content);
380
+ body.push({ type: "LINK", href });
381
+ }
382
+ }
383
+ }
384
+
385
+ const headerMsgType = hasMarkdown ? "MD" : "TEXT";
386
+
387
+ // Get token first
388
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
389
+ if (!tokenResult.ok || !tokenResult.token) {
390
+ getInfoflowSendLog().error(`[infoflow:sendGroup] token error: ${tokenResult.error}`);
391
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
392
+ }
393
+
394
+ let timeout: ReturnType<typeof setTimeout> | undefined;
395
+ try {
396
+ const controller = new AbortController();
397
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
398
+
399
+ const payload = {
400
+ message: {
401
+ header: {
402
+ toid: groupId,
403
+ totype: "GROUP",
404
+ msgtype: headerMsgType,
405
+ clientmsgid: Date.now(),
406
+ role: "robot",
407
+ },
408
+ body,
409
+ },
410
+ };
411
+
412
+ // NOTE: Infoflow API requires "Bearer-<token>" format (with hyphen, not space).
413
+ // This is a non-standard format specific to Infoflow service. Do not modify
414
+ // unless the Infoflow API specification changes.
415
+ const headers = {
416
+ Authorization: `Bearer-${tokenResult.token}`,
417
+ "Content-Type": "application/json",
418
+ };
419
+
420
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_SEND_PATH}`, {
421
+ method: "POST",
422
+ headers,
423
+ body: JSON.stringify(payload),
424
+ signal: controller.signal,
425
+ });
426
+
427
+ const data = JSON.parse(await res.text()) as Record<string, unknown>;
428
+
429
+ // Check outer code first
430
+ const code = typeof data.code === "string" ? data.code : "";
431
+ if (code !== "ok") {
432
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
433
+ getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
434
+ return { ok: false, error: errMsg };
435
+ }
436
+
437
+ // Check inner data.errcode
438
+ const innerData = data.data as Record<string, unknown> | undefined;
439
+ const errcode = innerData?.errcode;
440
+ if (errcode != null && errcode !== 0) {
441
+ const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
442
+ getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
443
+ return { ok: false, error: errMsg };
444
+ }
445
+
446
+ // Extract message ID from nested data.data structure and record for dedup
447
+ const nestedData = innerData?.data as Record<string, unknown> | undefined;
448
+ const messageid = extractMessageId(nestedData ?? innerData ?? {});
449
+ if (messageid) {
450
+ recordSentMessageId(messageid);
451
+ }
452
+
453
+ return { ok: true, messageid };
454
+ } catch (err) {
455
+ const errMsg = err instanceof Error ? err.message : String(err);
456
+ getInfoflowSendLog().error(`[infoflow:sendGroup] exception: ${errMsg}`);
457
+ return { ok: false, error: errMsg };
458
+ } finally {
459
+ clearTimeout(timeout);
460
+ }
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // Unified Message Sending
465
+ // ---------------------------------------------------------------------------
466
+
467
+ /**
468
+ * Unified message sending entry point.
469
+ * Parses the `to` target and dispatches to group or private message sending.
470
+ * @param cfg - OpenClaw config
471
+ * @param to - Target: "username" for private, "group:123" for group
472
+ * @param contents - Array of content items (text/markdown/at)
473
+ * @param accountId - Optional account ID for multi-account support
474
+ */
475
+ export async function sendInfoflowMessage(params: {
476
+ cfg: OpenClawConfig;
477
+ to: string;
478
+ contents: InfoflowMessageContentItem[];
479
+ accountId?: string;
480
+ }): Promise<{ ok: boolean; error?: string; messageId?: string }> {
481
+ const { cfg, to, contents, accountId } = params;
482
+
483
+ // Resolve account config
484
+ const account = resolveInfoflowAccount({ cfg, accountId });
485
+ const { appKey, appSecret } = account.config;
486
+
487
+ if (!appKey || !appSecret) {
488
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
489
+ }
490
+
491
+ // Validate contents
492
+ if (contents.length === 0) {
493
+ return { ok: false, error: "contents array is empty" };
494
+ }
495
+
496
+ // Parse target: remove "infoflow:" prefix if present
497
+ const target = to.replace(/^infoflow:/i, "");
498
+
499
+ // Check if target is a group (format: group:123)
500
+ const groupMatch = target.match(/^group:(\d+)/i);
501
+ if (groupMatch) {
502
+ const groupId = Number(groupMatch[1]);
503
+ const result = await sendInfoflowGroupMessage({ account, groupId, contents });
504
+ return {
505
+ ok: result.ok,
506
+ error: result.error,
507
+ messageId: result.messageid,
508
+ };
509
+ }
510
+
511
+ // Private message (DM)
512
+ const result = await sendInfoflowPrivateMessage({ account, toUser: target, contents });
513
+ return {
514
+ ok: result.ok,
515
+ error: result.error,
516
+ messageId: result.msgkey,
517
+ };
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // Test-only exports (@internal — not part of the public API)
522
+ // ---------------------------------------------------------------------------
523
+
524
+ /** @internal — Clears the token cache. Only use in tests. */
525
+ export function _resetTokenCache(): void {
526
+ tokenCacheMap.clear();
527
+ }
package/src/targets.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Infoflow target resolution utilities.
3
+ * Handles user and group ID formats for message targeting.
4
+ */
5
+
6
+ import { getInfoflowSendLog } from "./logging.js";
7
+ import { getInfoflowRuntime } from "./runtime.js";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Target Format Constants
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Prefix for group targets: "group:123456" */
14
+ const GROUP_PREFIX = "group:";
15
+
16
+ /** Prefix for user targets (optional): "user:chengbo05" */
17
+ const USER_PREFIX = "user:";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Target Normalization
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Normalizes an Infoflow target string.
25
+ * Strips channel prefix and normalizes format.
26
+ *
27
+ * Examples:
28
+ * "infoflow:chengbo05" -> "chengbo05"
29
+ * "infoflow:group:123456" -> "group:123456"
30
+ * "user:chengbo05" -> "chengbo05"
31
+ * "group:123456" -> "group:123456"
32
+ * "chengbo05" -> "chengbo05"
33
+ * "123456" -> "group:123456" (pure digits treated as group)
34
+ */
35
+ export function normalizeInfoflowTarget(raw: string): string | undefined {
36
+ // Get verbose state once at start
37
+ let verbose = false;
38
+ try {
39
+ verbose = getInfoflowRuntime().logging.shouldLogVerbose();
40
+ } catch {
41
+ // runtime not available, keep verbose = false
42
+ }
43
+
44
+ if (verbose) {
45
+ getInfoflowSendLog().debug?.(`[infoflow:normalizeTarget] input: "${raw}"`);
46
+ }
47
+
48
+ const trimmed = raw.trim();
49
+ if (!trimmed) {
50
+ if (verbose) {
51
+ getInfoflowSendLog().debug?.(`[infoflow:normalizeTarget] empty input, returning undefined`);
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ // Strip infoflow: prefix
57
+ let target = trimmed.replace(/^infoflow:/i, "");
58
+
59
+ // Strip user: prefix (normalize to plain username)
60
+ if (target.toLowerCase().startsWith(USER_PREFIX)) {
61
+ target = target.slice(USER_PREFIX.length);
62
+ }
63
+
64
+ // Keep group: prefix as-is
65
+ if (target.toLowerCase().startsWith(GROUP_PREFIX)) {
66
+ if (verbose) {
67
+ getInfoflowSendLog().debug?.(`[infoflow:normalizeTarget] output: "${target}" (group)`);
68
+ }
69
+ return target;
70
+ }
71
+
72
+ // Pure digits -> treat as group ID
73
+ if (/^\d+$/.test(target)) {
74
+ const result = `${GROUP_PREFIX}${target}`;
75
+ if (verbose) {
76
+ getInfoflowSendLog().debug?.(
77
+ `[infoflow:normalizeTarget] output: "${result}" (digits -> group)`,
78
+ );
79
+ }
80
+ return result;
81
+ }
82
+
83
+ // Otherwise it's a username
84
+ if (verbose) {
85
+ getInfoflowSendLog().debug?.(`[infoflow:normalizeTarget] output: "${target}" (username)`);
86
+ }
87
+ return target;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Target ID Detection
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Checks if the input looks like a valid Infoflow target ID.
96
+ * Returns true if the system should use this value directly without directory lookup.
97
+ *
98
+ * Valid formats:
99
+ * - group:123456 (group ID with prefix)
100
+ * - user:chengbo05 (user ID with prefix)
101
+ * - 123456789 (pure digits = group ID)
102
+ * - chengbo05 (alphanumeric starting with letter = username/uuapName)
103
+ */
104
+ export function looksLikeInfoflowId(raw: string): boolean {
105
+ const trimmed = raw.trim();
106
+ if (!trimmed) {
107
+ return false;
108
+ }
109
+
110
+ // Strip infoflow: prefix for checking
111
+ const target = trimmed.replace(/^infoflow:/i, "");
112
+
113
+ // Explicit prefixes are always valid
114
+ if (/^(group|user):/i.test(target)) {
115
+ return true;
116
+ }
117
+
118
+ // Pure digits (group ID)
119
+ if (/^\d+$/.test(target)) {
120
+ return true;
121
+ }
122
+
123
+ // Alphanumeric starting with letter (username/uuapName)
124
+ // e.g., chengbo05, zhangsan, user123
125
+ if (/^[a-zA-Z][a-zA-Z0-9_]*$/.test(target)) {
126
+ return true;
127
+ }
128
+
129
+ return false;
130
+ }