@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/LICENSE +21 -0
- package/README.md +74 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +41 -0
- package/src/accounts.ts +131 -0
- package/src/bot.ts +338 -0
- package/src/channel.ts +307 -0
- package/src/infoflow-req-parse.ts +451 -0
- package/src/logging.ts +141 -0
- package/src/monitor.ts +177 -0
- package/src/reply-dispatcher.ts +88 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +527 -0
- package/src/targets.ts +130 -0
- package/src/types.ts +127 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ChengBo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# @chbo297/openclaw-infoflow
|
|
2
|
+
|
|
3
|
+
Baidu Infoflow (如流) channel plugin for OpenClaw.
|
|
4
|
+
|
|
5
|
+
## Install (npm)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
openclaw plugins install @chbo297/infoflow
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install (local checkout)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
openclaw plugins install ./path/to/openclaw-infoflow
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Config
|
|
18
|
+
|
|
19
|
+
```json5
|
|
20
|
+
{
|
|
21
|
+
channels: {
|
|
22
|
+
infoflow: {
|
|
23
|
+
enabled: true,
|
|
24
|
+
apiHost: "https://apiin.im.baidu.com",
|
|
25
|
+
checkToken: "your-check-token",
|
|
26
|
+
encodingAESKey: "your-encoding-aes-key",
|
|
27
|
+
appKey: "your-app-key",
|
|
28
|
+
appSecret: "your-app-secret",
|
|
29
|
+
dmPolicy: "open", // "open" | "pairing" | "allowlist"
|
|
30
|
+
groupPolicy: "open", // "open" | "allowlist" | "disabled"
|
|
31
|
+
requireMention: true, // Bot only responds when @mentioned in groups
|
|
32
|
+
robotName: "YourBotName", // Required for @mention detection
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Multi-account support
|
|
39
|
+
|
|
40
|
+
```json5
|
|
41
|
+
{
|
|
42
|
+
channels: {
|
|
43
|
+
infoflow: {
|
|
44
|
+
enabled: true,
|
|
45
|
+
accounts: {
|
|
46
|
+
work: {
|
|
47
|
+
checkToken: "token-1",
|
|
48
|
+
encodingAESKey: "key-1",
|
|
49
|
+
appKey: "app-key-1",
|
|
50
|
+
appSecret: "secret-1",
|
|
51
|
+
},
|
|
52
|
+
personal: {
|
|
53
|
+
checkToken: "token-2",
|
|
54
|
+
encodingAESKey: "key-2",
|
|
55
|
+
appKey: "app-key-2",
|
|
56
|
+
appSecret: "secret-2",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
defaultAccount: "work",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Webhook
|
|
66
|
+
|
|
67
|
+
Configure your Infoflow bot webhook URL to:
|
|
68
|
+
`https://your-domain/webhook/infoflow`
|
|
69
|
+
|
|
70
|
+
Restart the gateway after config changes.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { infoflowPlugin } from "./src/channel.js";
|
|
3
|
+
import { handleInfoflowWebhookRequest } from "./src/monitor.js";
|
|
4
|
+
import { setInfoflowRuntime } from "./src/runtime.js";
|
|
5
|
+
|
|
6
|
+
const plugin = {
|
|
7
|
+
id: "infoflow",
|
|
8
|
+
name: "Infoflow",
|
|
9
|
+
description: "OpenClaw Infoflow channel plugin",
|
|
10
|
+
configSchema: emptyPluginConfigSchema(),
|
|
11
|
+
register(api: OpenClawPluginApi) {
|
|
12
|
+
setInfoflowRuntime(api.runtime);
|
|
13
|
+
api.registerChannel({ plugin: infoflowPlugin });
|
|
14
|
+
api.registerHttpHandler(handleInfoflowWebhookRequest);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chbo297/infoflow",
|
|
3
|
+
"version": "2026.2.23",
|
|
4
|
+
"description": "OpenClaw Infoflow (如流) channel plugin for Baidu enterprise messaging",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"openclaw",
|
|
10
|
+
"openclaw-plugin",
|
|
11
|
+
"infoflow",
|
|
12
|
+
"baidu",
|
|
13
|
+
"chatbot",
|
|
14
|
+
"ai-agent",
|
|
15
|
+
"enterprise-messaging"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/chbo297/openclaw-infoflow"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"openclaw": "*"
|
|
23
|
+
},
|
|
24
|
+
"openclaw": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./index.ts"
|
|
27
|
+
],
|
|
28
|
+
"channel": {
|
|
29
|
+
"id": "infoflow",
|
|
30
|
+
"label": "Infoflow",
|
|
31
|
+
"selectionLabel": "Infoflow (如流)",
|
|
32
|
+
"docsPath": "/channels/infoflow",
|
|
33
|
+
"blurb": "Baidu Infoflow messaging platform.",
|
|
34
|
+
"order": 40
|
|
35
|
+
},
|
|
36
|
+
"install": {
|
|
37
|
+
"npmSpec": "@chbo297/infoflow",
|
|
38
|
+
"defaultChoice": "npm"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infoflow account resolution and configuration helpers.
|
|
3
|
+
* Handles multi-account support with config merging.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig } from "openclaw/plugin-sdk";
|
|
7
|
+
import type { InfoflowAccountConfig, ResolvedInfoflowAccount } from "./types.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Config Access Helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the raw Infoflow channel section from config.
|
|
15
|
+
*/
|
|
16
|
+
export function getChannelSection(cfg: OpenClawConfig): InfoflowAccountConfig | undefined {
|
|
17
|
+
return cfg.channels?.["infoflow"] as InfoflowAccountConfig | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Account ID Resolution
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* List all configured Infoflow account IDs.
|
|
26
|
+
* Returns [DEFAULT_ACCOUNT_ID] if no accounts are configured (backward compatibility).
|
|
27
|
+
*/
|
|
28
|
+
export function listInfoflowAccountIds(cfg: OpenClawConfig): string[] {
|
|
29
|
+
const accounts = getChannelSection(cfg)?.accounts;
|
|
30
|
+
if (!accounts || typeof accounts !== "object") {
|
|
31
|
+
return [DEFAULT_ACCOUNT_ID];
|
|
32
|
+
}
|
|
33
|
+
const ids = Object.keys(accounts).filter(Boolean);
|
|
34
|
+
return ids.length === 0 ? [DEFAULT_ACCOUNT_ID] : ids.toSorted((a, b) => a.localeCompare(b));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the default account ID for Infoflow.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveDefaultInfoflowAccountId(cfg: OpenClawConfig): string {
|
|
41
|
+
const channel = getChannelSection(cfg);
|
|
42
|
+
if (channel?.defaultAccount?.trim()) {
|
|
43
|
+
return channel.defaultAccount.trim();
|
|
44
|
+
}
|
|
45
|
+
const ids = listInfoflowAccountIds(cfg);
|
|
46
|
+
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
|
47
|
+
return DEFAULT_ACCOUNT_ID;
|
|
48
|
+
}
|
|
49
|
+
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Config Merging
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Merge top-level Infoflow config with account-specific overrides.
|
|
58
|
+
* Account fields override base fields.
|
|
59
|
+
*/
|
|
60
|
+
function mergeInfoflowAccountConfig(
|
|
61
|
+
cfg: OpenClawConfig,
|
|
62
|
+
accountId: string,
|
|
63
|
+
): {
|
|
64
|
+
apiHost: string;
|
|
65
|
+
checkToken: string;
|
|
66
|
+
encodingAESKey: string;
|
|
67
|
+
appKey: string;
|
|
68
|
+
appSecret: string;
|
|
69
|
+
enabled?: boolean;
|
|
70
|
+
name?: string;
|
|
71
|
+
robotName?: string;
|
|
72
|
+
requireMention?: boolean;
|
|
73
|
+
} {
|
|
74
|
+
const raw = getChannelSection(cfg) ?? {};
|
|
75
|
+
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
|
76
|
+
const account = raw.accounts?.[accountId] ?? {};
|
|
77
|
+
return { ...base, ...account } as {
|
|
78
|
+
apiHost: string;
|
|
79
|
+
checkToken: string;
|
|
80
|
+
encodingAESKey: string;
|
|
81
|
+
appKey: string;
|
|
82
|
+
appSecret: string;
|
|
83
|
+
enabled?: boolean;
|
|
84
|
+
name?: string;
|
|
85
|
+
robotName?: string;
|
|
86
|
+
requireMention?: boolean;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Account Resolution
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve a complete Infoflow account with merged config.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveInfoflowAccount(params: {
|
|
98
|
+
cfg: OpenClawConfig;
|
|
99
|
+
accountId?: string | null;
|
|
100
|
+
}): ResolvedInfoflowAccount {
|
|
101
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
102
|
+
const baseEnabled = getChannelSection(params.cfg)?.enabled !== false;
|
|
103
|
+
const merged = mergeInfoflowAccountConfig(params.cfg, accountId);
|
|
104
|
+
const accountEnabled = merged.enabled !== false;
|
|
105
|
+
const enabled = baseEnabled && accountEnabled;
|
|
106
|
+
const apiHost = merged.apiHost ?? "";
|
|
107
|
+
const checkToken = merged.checkToken ?? "";
|
|
108
|
+
const encodingAESKey = merged.encodingAESKey ?? "";
|
|
109
|
+
const appKey = merged.appKey ?? "";
|
|
110
|
+
const appSecret = merged.appSecret ?? "";
|
|
111
|
+
const configured =
|
|
112
|
+
Boolean(checkToken) && Boolean(encodingAESKey) && Boolean(appKey) && Boolean(appSecret);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
accountId,
|
|
116
|
+
name: merged.name?.trim() || undefined,
|
|
117
|
+
enabled,
|
|
118
|
+
configured,
|
|
119
|
+
config: {
|
|
120
|
+
enabled: merged.enabled,
|
|
121
|
+
name: merged.name,
|
|
122
|
+
apiHost,
|
|
123
|
+
checkToken,
|
|
124
|
+
encodingAESKey,
|
|
125
|
+
appKey,
|
|
126
|
+
appSecret,
|
|
127
|
+
robotName: merged.robotName?.trim() || undefined,
|
|
128
|
+
requireMention: merged.requireMention,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { resolveInfoflowAccount } from "./accounts.js";
|
|
2
|
+
import { getInfoflowBotLog } from "./logging.js";
|
|
3
|
+
import { createInfoflowReplyDispatcher } from "./reply-dispatcher.js";
|
|
4
|
+
import { getInfoflowRuntime } from "./runtime.js";
|
|
5
|
+
import type {
|
|
6
|
+
InfoflowChatType,
|
|
7
|
+
InfoflowMessageEvent,
|
|
8
|
+
HandleInfoflowMessageParams,
|
|
9
|
+
HandlePrivateChatParams,
|
|
10
|
+
HandleGroupChatParams,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
// Re-export types for external consumers
|
|
14
|
+
export type { InfoflowChatType, InfoflowMessageEvent } from "./types.js";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// @mention detection types and helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Body item in Infoflow group message, supporting TEXT, AT, LINK types.
|
|
22
|
+
*/
|
|
23
|
+
type InfoflowBodyItem = {
|
|
24
|
+
type?: string;
|
|
25
|
+
content?: string;
|
|
26
|
+
label?: string;
|
|
27
|
+
/** Robot ID when type is AT */
|
|
28
|
+
robotid?: number;
|
|
29
|
+
/** Robot/user name when type is AT */
|
|
30
|
+
name?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if the bot was @mentioned in the message body.
|
|
35
|
+
* Matches configured robotName against AT elements (case-insensitive).
|
|
36
|
+
*/
|
|
37
|
+
function checkBotMentioned(bodyItems: InfoflowBodyItem[], robotName?: string): boolean {
|
|
38
|
+
if (!robotName) {
|
|
39
|
+
return false; // Cannot detect mentions without configured robotName
|
|
40
|
+
}
|
|
41
|
+
const normalizedRobotName = robotName.toLowerCase();
|
|
42
|
+
for (const item of bodyItems) {
|
|
43
|
+
if (item.type === "AT" && item.name) {
|
|
44
|
+
if (item.name.toLowerCase() === normalizedRobotName) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Handles an incoming private chat message from Infoflow.
|
|
54
|
+
* Receives the raw decrypted message data and dispatches to the agent.
|
|
55
|
+
*/
|
|
56
|
+
export async function handlePrivateChatMessage(params: HandlePrivateChatParams): Promise<void> {
|
|
57
|
+
const { cfg, msgData, accountId, statusSink } = params;
|
|
58
|
+
const core = getInfoflowRuntime();
|
|
59
|
+
const verbose = core.logging.shouldLogVerbose();
|
|
60
|
+
|
|
61
|
+
// Extract sender and content from msgData (flexible field names)
|
|
62
|
+
const fromuser = String(msgData.FromUserId ?? msgData.fromuserid ?? msgData.from ?? "");
|
|
63
|
+
const mes = String(msgData.Content ?? msgData.content ?? msgData.text ?? msgData.mes ?? "");
|
|
64
|
+
|
|
65
|
+
// Extract sender name (FromUserName is more human-readable than FromUserId)
|
|
66
|
+
const senderName = String(msgData.FromUserName ?? msgData.username ?? fromuser);
|
|
67
|
+
|
|
68
|
+
// Extract message ID for dedup tracking
|
|
69
|
+
const messageId = msgData.MsgId ?? msgData.msgid ?? msgData.messageid;
|
|
70
|
+
const messageIdStr = messageId != null ? String(messageId) : undefined;
|
|
71
|
+
|
|
72
|
+
// Extract timestamp (CreateTime is in seconds, convert to milliseconds)
|
|
73
|
+
const createTime = msgData.CreateTime ?? msgData.createtime;
|
|
74
|
+
const timestamp = createTime != null ? Number(createTime) * 1000 : Date.now();
|
|
75
|
+
|
|
76
|
+
if (verbose) {
|
|
77
|
+
getInfoflowBotLog().debug?.(
|
|
78
|
+
`[infoflow] private chat: fromuser=${fromuser}, senderName=${senderName}, raw msgData: ${JSON.stringify(msgData)}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!fromuser || !mes.trim()) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Delegate to the common message handler (private chat)
|
|
87
|
+
await handleInfoflowMessage({
|
|
88
|
+
cfg,
|
|
89
|
+
event: {
|
|
90
|
+
fromuser,
|
|
91
|
+
mes,
|
|
92
|
+
chatType: "direct",
|
|
93
|
+
senderName,
|
|
94
|
+
messageId: messageIdStr,
|
|
95
|
+
timestamp,
|
|
96
|
+
},
|
|
97
|
+
accountId,
|
|
98
|
+
statusSink,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Handles an incoming group chat message from Infoflow.
|
|
104
|
+
* Receives the raw decrypted message data and dispatches to the agent.
|
|
105
|
+
*/
|
|
106
|
+
export async function handleGroupChatMessage(params: HandleGroupChatParams): Promise<void> {
|
|
107
|
+
const { cfg, msgData, accountId, statusSink } = params;
|
|
108
|
+
const core = getInfoflowRuntime();
|
|
109
|
+
const verbose = core.logging.shouldLogVerbose();
|
|
110
|
+
|
|
111
|
+
// Extract sender from nested structure or flat fields
|
|
112
|
+
const header = (msgData.message as Record<string, unknown>)?.header as
|
|
113
|
+
| Record<string, unknown>
|
|
114
|
+
| undefined;
|
|
115
|
+
const fromuser = String(header?.fromuserid ?? msgData.fromuserid ?? msgData.from ?? "");
|
|
116
|
+
|
|
117
|
+
// Extract message ID (priority: header.messageid > header.msgid > MsgId)
|
|
118
|
+
const messageId = header?.messageid ?? header?.msgid ?? msgData.MsgId;
|
|
119
|
+
const messageIdStr = messageId != null ? String(messageId) : undefined;
|
|
120
|
+
|
|
121
|
+
const rawGroupId = msgData.groupid ?? header?.groupid;
|
|
122
|
+
const groupid =
|
|
123
|
+
typeof rawGroupId === "number" ? rawGroupId : rawGroupId ? Number(rawGroupId) : undefined;
|
|
124
|
+
|
|
125
|
+
// Extract timestamp (time is in milliseconds)
|
|
126
|
+
const rawTime = msgData.time ?? header?.servertime;
|
|
127
|
+
const timestamp = rawTime != null ? Number(rawTime) : Date.now();
|
|
128
|
+
|
|
129
|
+
if (verbose) {
|
|
130
|
+
getInfoflowBotLog().debug?.(
|
|
131
|
+
`[infoflow] group chat: fromuser=${fromuser}, groupid=${groupid}, raw msgData: ${JSON.stringify(msgData)}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!fromuser) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Extract message content from body array or flat content field
|
|
140
|
+
const message = msgData.message as Record<string, unknown> | undefined;
|
|
141
|
+
const bodyItems = (message?.body ?? msgData.body ?? []) as InfoflowBodyItem[];
|
|
142
|
+
|
|
143
|
+
// Resolve account to get robotName for mention detection
|
|
144
|
+
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
145
|
+
const robotName = account.config.robotName;
|
|
146
|
+
|
|
147
|
+
// Check if bot was @mentioned
|
|
148
|
+
const wasMentioned = checkBotMentioned(bodyItems, robotName);
|
|
149
|
+
|
|
150
|
+
// Build two versions: mes (for CommandBody, no @xxx) and rawMes (for RawBody, with @xxx)
|
|
151
|
+
let textContent = "";
|
|
152
|
+
let rawTextContent = "";
|
|
153
|
+
if (Array.isArray(bodyItems)) {
|
|
154
|
+
for (const item of bodyItems) {
|
|
155
|
+
if (item.type === "TEXT") {
|
|
156
|
+
textContent += item.content ?? "";
|
|
157
|
+
rawTextContent += item.content ?? "";
|
|
158
|
+
} else if (item.type === "LINK") {
|
|
159
|
+
const label = item.label ?? "";
|
|
160
|
+
if (label) {
|
|
161
|
+
textContent += ` ${label} `;
|
|
162
|
+
rawTextContent += ` ${label} `;
|
|
163
|
+
}
|
|
164
|
+
} else if (item.type === "AT") {
|
|
165
|
+
// AT elements only go into rawTextContent, not textContent
|
|
166
|
+
const name = item.name ?? "";
|
|
167
|
+
if (name) {
|
|
168
|
+
rawTextContent += `@${name} `;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const mes = textContent.trim() || String(msgData.content ?? msgData.text ?? "");
|
|
175
|
+
const rawMes = rawTextContent.trim() || mes;
|
|
176
|
+
|
|
177
|
+
if (!mes) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Extract sender name from header or fallback to fromuser
|
|
182
|
+
const senderName = String(header?.username ?? header?.nickname ?? msgData.username ?? fromuser);
|
|
183
|
+
|
|
184
|
+
// Delegate to the common message handler (group chat)
|
|
185
|
+
await handleInfoflowMessage({
|
|
186
|
+
cfg,
|
|
187
|
+
event: {
|
|
188
|
+
fromuser,
|
|
189
|
+
mes,
|
|
190
|
+
rawMes,
|
|
191
|
+
chatType: "group",
|
|
192
|
+
groupId: groupid,
|
|
193
|
+
senderName,
|
|
194
|
+
wasMentioned,
|
|
195
|
+
messageId: messageIdStr,
|
|
196
|
+
timestamp,
|
|
197
|
+
},
|
|
198
|
+
accountId,
|
|
199
|
+
statusSink,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolves route, builds envelope, records session meta, and dispatches reply for one incoming Infoflow message.
|
|
205
|
+
* Called from monitor after webhook request is validated.
|
|
206
|
+
*/
|
|
207
|
+
export async function handleInfoflowMessage(params: HandleInfoflowMessageParams): Promise<void> {
|
|
208
|
+
const { cfg, event, accountId, statusSink } = params;
|
|
209
|
+
const { fromuser, mes, chatType, groupId, senderName } = event;
|
|
210
|
+
|
|
211
|
+
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
212
|
+
const core = getInfoflowRuntime();
|
|
213
|
+
const verbose = core.logging.shouldLogVerbose();
|
|
214
|
+
|
|
215
|
+
if (verbose) {
|
|
216
|
+
getInfoflowBotLog().debug?.(
|
|
217
|
+
`[infoflow] handleInfoflowMessage invoked: accountId=${accountId}, chatType=${event.chatType}, fromuser=${event.fromuser}, groupId=${event.groupId}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const isGroup = chatType === "group";
|
|
222
|
+
// Convert groupId (number) to string for peerId since routing expects string
|
|
223
|
+
const peerId = isGroup ? (groupId !== undefined ? String(groupId) : fromuser) : fromuser;
|
|
224
|
+
|
|
225
|
+
// Resolve route based on chat type
|
|
226
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
227
|
+
cfg,
|
|
228
|
+
channel: "infoflow",
|
|
229
|
+
accountId: account.accountId,
|
|
230
|
+
peer: {
|
|
231
|
+
kind: isGroup ? "group" : "direct",
|
|
232
|
+
id: peerId,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
237
|
+
agentId: route.agentId,
|
|
238
|
+
});
|
|
239
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
240
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
241
|
+
storePath,
|
|
242
|
+
sessionKey: route.sessionKey,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Build conversation label and from address based on chat type
|
|
246
|
+
const fromLabel = isGroup ? `group:${groupId}` : senderName || fromuser;
|
|
247
|
+
const fromAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
|
|
248
|
+
const toAddress = isGroup ? `infoflow:${groupId}` : `infoflow:${account.accountId}`;
|
|
249
|
+
|
|
250
|
+
if (verbose) {
|
|
251
|
+
getInfoflowBotLog().debug?.(
|
|
252
|
+
`[infoflow] dispatch: chatType=${chatType}, agentId=${route.agentId}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
257
|
+
channel: "Infoflow",
|
|
258
|
+
from: fromLabel,
|
|
259
|
+
timestamp: Date.now(),
|
|
260
|
+
previousTimestamp,
|
|
261
|
+
envelope: envelopeOptions,
|
|
262
|
+
body: mes,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
266
|
+
Body: body,
|
|
267
|
+
RawBody: event.rawMes ?? mes,
|
|
268
|
+
CommandBody: mes,
|
|
269
|
+
From: fromAddress,
|
|
270
|
+
To: toAddress,
|
|
271
|
+
SessionKey: route.sessionKey,
|
|
272
|
+
AccountId: route.accountId,
|
|
273
|
+
ChatType: chatType,
|
|
274
|
+
ConversationLabel: fromLabel,
|
|
275
|
+
GroupSubject: isGroup ? `group:${groupId}` : undefined,
|
|
276
|
+
SenderName: senderName || fromuser,
|
|
277
|
+
SenderId: fromuser,
|
|
278
|
+
Provider: "infoflow",
|
|
279
|
+
Surface: "infoflow",
|
|
280
|
+
MessageSid: event.messageId ?? `${Date.now()}`,
|
|
281
|
+
Timestamp: event.timestamp ?? Date.now(),
|
|
282
|
+
OriginatingChannel: "infoflow",
|
|
283
|
+
OriginatingTo: toAddress,
|
|
284
|
+
WasMentioned: isGroup ? event.wasMentioned : undefined,
|
|
285
|
+
CommandAuthorized: true,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Record session using recordInboundSession for proper session tracking
|
|
289
|
+
await core.channel.session.recordInboundSession({
|
|
290
|
+
storePath,
|
|
291
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
292
|
+
ctx: ctxPayload,
|
|
293
|
+
onRecordError: (err) => {
|
|
294
|
+
getInfoflowBotLog().error(`[infoflow] failed updating session meta: ${String(err)}`);
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Mention gating: skip reply if requireMention is enabled and bot was not mentioned
|
|
299
|
+
// Session is already recorded above for context history
|
|
300
|
+
if (isGroup) {
|
|
301
|
+
const requireMention = account.config.requireMention !== false;
|
|
302
|
+
const canDetectMention = Boolean(account.config.robotName);
|
|
303
|
+
const wasMentioned = event.wasMentioned === true;
|
|
304
|
+
|
|
305
|
+
if (requireMention && canDetectMention && !wasMentioned) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Build unified target: "group:<id>" for group chat, username for private chat
|
|
311
|
+
const to = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
|
|
312
|
+
|
|
313
|
+
const { dispatcherOptions, replyOptions } = createInfoflowReplyDispatcher({
|
|
314
|
+
cfg,
|
|
315
|
+
agentId: route.agentId,
|
|
316
|
+
accountId: account.accountId,
|
|
317
|
+
to,
|
|
318
|
+
statusSink,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
322
|
+
ctx: ctxPayload,
|
|
323
|
+
cfg,
|
|
324
|
+
dispatcherOptions,
|
|
325
|
+
replyOptions,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (verbose) {
|
|
329
|
+
getInfoflowBotLog().debug?.(`[infoflow] dispatch complete: ${chatType} from ${fromuser}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Test-only exports (@internal)
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
/** @internal — Check if bot was mentioned in message body. Only exported for tests. */
|
|
338
|
+
export const _checkBotMentioned = checkBotMentioned;
|