@43world/43chat-openclaw-plugin 0.1.6
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 +101 -0
- package/REQUIREMENTS.md +573 -0
- package/index.ts +22 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +48 -0
- package/src/accounts.ts +137 -0
- package/src/bot.ts +484 -0
- package/src/channel.ts +415 -0
- package/src/client.ts +433 -0
- package/src/config-schema.ts +37 -0
- package/src/monitor.ts +277 -0
- package/src/outbound.ts +59 -0
- package/src/plugin-sdk-compat.ts +27 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +35 -0
- package/src/targets.ts +58 -0
- package/src/types.ts +182 -0
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@43world/43chat-openclaw-plugin",
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "43World: 43Chat OpenClaw channel plugin",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test:unit": "vitest run",
|
|
8
|
+
"ci:check": "npx tsc --noEmit && npm run test:unit",
|
|
9
|
+
"build": "tsc"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"files": [
|
|
13
|
+
"index.ts",
|
|
14
|
+
"src/**/*.ts",
|
|
15
|
+
"!src/**/__tests__/**",
|
|
16
|
+
"!src/**/*.test.ts",
|
|
17
|
+
"REQUIREMENTS.md",
|
|
18
|
+
"README.md",
|
|
19
|
+
"openclaw.plugin.json"
|
|
20
|
+
],
|
|
21
|
+
"openclaw": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./index.ts"
|
|
24
|
+
],
|
|
25
|
+
"channel": {
|
|
26
|
+
"id": "43chat",
|
|
27
|
+
"label": "43Chat",
|
|
28
|
+
"selectionLabel": "43Chat",
|
|
29
|
+
"docsPath": "/channels/43chat",
|
|
30
|
+
"docsLabel": "43chat",
|
|
31
|
+
"blurb": "43Chat OpenAPI + SSE channel.",
|
|
32
|
+
"order": 85
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"zod": "^4.3.6"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^25.0.10",
|
|
40
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
41
|
+
"openclaw": "^2026.3.1",
|
|
42
|
+
"typescript": "^5.7.0",
|
|
43
|
+
"vitest": "^2.1.8"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"openclaw": ">=2026.3.1"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { Chat43Config, Resolved43ChatAccount } from "./types.js";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
8
|
+
|
|
9
|
+
function readOptionalNonBlankString(value: unknown): string | undefined {
|
|
10
|
+
if (typeof value === "number" && !Number.isNaN(value)) {
|
|
11
|
+
return String(value);
|
|
12
|
+
}
|
|
13
|
+
if (typeof value !== "string") {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
return trimmed ? trimmed : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeBaseUrl(value: string | undefined): string | undefined {
|
|
21
|
+
if (!value) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return value.replace(/\/+$/, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolve43ChatAccount({
|
|
28
|
+
cfg,
|
|
29
|
+
accountId = DEFAULT_ACCOUNT_ID,
|
|
30
|
+
}: {
|
|
31
|
+
cfg: ClawdbotConfig;
|
|
32
|
+
accountId?: string;
|
|
33
|
+
}): Resolved43ChatAccount {
|
|
34
|
+
const chatCfg = cfg.channels?.["43chat"] as Chat43Config | undefined;
|
|
35
|
+
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
let topLevelBaseUrl: string | undefined;
|
|
40
|
+
let topLevelApiKey: string | undefined;
|
|
41
|
+
|
|
42
|
+
// Determine apiKey
|
|
43
|
+
if (!chatCfg || !readOptionalNonBlankString(chatCfg.apiKey)) {
|
|
44
|
+
// Try to read api_key from ~/.config/43chat/credentials.json
|
|
45
|
+
try {
|
|
46
|
+
const credPath = join(homedir(), ".config", "43chat", "credentials.json");
|
|
47
|
+
const content = readFileSync(credPath, "utf-8");
|
|
48
|
+
const parsed = JSON.parse(content);
|
|
49
|
+
if (parsed && typeof parsed.api_key === "string" && parsed.api_key.trim()) {
|
|
50
|
+
topLevelApiKey = parsed.api_key.trim();
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
topLevelApiKey = undefined;
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
topLevelApiKey = readOptionalNonBlankString(chatCfg.apiKey);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Determine baseUrl
|
|
60
|
+
if (!chatCfg || !readOptionalNonBlankString(chatCfg.baseUrl)) {
|
|
61
|
+
topLevelBaseUrl = "https://43chat.cn";
|
|
62
|
+
} else {
|
|
63
|
+
topLevelBaseUrl = normalizeBaseUrl(readOptionalNonBlankString(chatCfg.baseUrl));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isDefault && chatCfg) {
|
|
67
|
+
const configured = Boolean(topLevelBaseUrl && topLevelApiKey);
|
|
68
|
+
return {
|
|
69
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
70
|
+
enabled: chatCfg.enabled ?? true,
|
|
71
|
+
configured,
|
|
72
|
+
name: "Default",
|
|
73
|
+
baseUrl: topLevelBaseUrl,
|
|
74
|
+
apiKey: topLevelApiKey,
|
|
75
|
+
config: {
|
|
76
|
+
baseUrl: topLevelBaseUrl,
|
|
77
|
+
apiKey: topLevelApiKey,
|
|
78
|
+
dmPolicy: chatCfg.dmPolicy ?? "open",
|
|
79
|
+
allowFrom: chatCfg.allowFrom ?? [],
|
|
80
|
+
requestTimeoutMs: chatCfg.requestTimeoutMs,
|
|
81
|
+
sseReconnectDelayMs: chatCfg.sseReconnectDelayMs,
|
|
82
|
+
sseMaxReconnectDelayMs: chatCfg.sseMaxReconnectDelayMs,
|
|
83
|
+
textChunkLimit: chatCfg.textChunkLimit,
|
|
84
|
+
chunkMode: chatCfg.chunkMode ?? "newline",
|
|
85
|
+
blockStreaming: chatCfg.blockStreaming ?? false,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const accountCfg = chatCfg?.accounts?.[accountId];
|
|
91
|
+
const merged = {
|
|
92
|
+
baseUrl:
|
|
93
|
+
normalizeBaseUrl(readOptionalNonBlankString(accountCfg?.baseUrl))
|
|
94
|
+
?? topLevelBaseUrl,
|
|
95
|
+
apiKey: readOptionalNonBlankString(accountCfg?.apiKey) ?? topLevelApiKey,
|
|
96
|
+
dmPolicy: accountCfg?.dmPolicy ?? chatCfg?.dmPolicy ?? "open",
|
|
97
|
+
allowFrom: accountCfg?.allowFrom ?? chatCfg?.allowFrom ?? [],
|
|
98
|
+
requestTimeoutMs: accountCfg?.requestTimeoutMs ?? chatCfg?.requestTimeoutMs,
|
|
99
|
+
sseReconnectDelayMs: accountCfg?.sseReconnectDelayMs ?? chatCfg?.sseReconnectDelayMs,
|
|
100
|
+
sseMaxReconnectDelayMs:
|
|
101
|
+
accountCfg?.sseMaxReconnectDelayMs ?? chatCfg?.sseMaxReconnectDelayMs,
|
|
102
|
+
textChunkLimit: accountCfg?.textChunkLimit ?? chatCfg?.textChunkLimit,
|
|
103
|
+
chunkMode: accountCfg?.chunkMode ?? chatCfg?.chunkMode ?? "newline",
|
|
104
|
+
blockStreaming: accountCfg?.blockStreaming ?? chatCfg?.blockStreaming ?? false,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
accountId,
|
|
109
|
+
enabled: accountCfg?.enabled ?? chatCfg?.enabled ?? true,
|
|
110
|
+
configured: Boolean(merged.baseUrl && merged.apiKey),
|
|
111
|
+
name: accountCfg?.name,
|
|
112
|
+
baseUrl: merged.baseUrl,
|
|
113
|
+
apiKey: merged.apiKey,
|
|
114
|
+
config: {
|
|
115
|
+
...merged,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function list43ChatAccountIds(cfg: ClawdbotConfig): string[] {
|
|
121
|
+
const chatCfg = cfg.channels?.["43chat"] as Chat43Config | undefined;
|
|
122
|
+
const ids = [DEFAULT_ACCOUNT_ID];
|
|
123
|
+
if (chatCfg?.accounts) {
|
|
124
|
+
ids.push(...Object.keys(chatCfg.accounts));
|
|
125
|
+
}
|
|
126
|
+
return ids;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function resolveDefault43ChatAccountId(_cfg: ClawdbotConfig): string {
|
|
130
|
+
return DEFAULT_ACCOUNT_ID;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function listEnabled43ChatAccounts(cfg: ClawdbotConfig): Resolved43ChatAccount[] {
|
|
134
|
+
return list43ChatAccountIds(cfg)
|
|
135
|
+
.map((accountId) => resolve43ChatAccount({ cfg, accountId }))
|
|
136
|
+
.filter((account) => account.enabled && account.configured);
|
|
137
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
3
|
+
import { resolve43ChatAccount } from "./accounts.js";
|
|
4
|
+
import { get43ChatRuntime } from "./runtime.js";
|
|
5
|
+
import { sendMessage43Chat } from "./send.js";
|
|
6
|
+
import type {
|
|
7
|
+
Chat43AnySSEEvent,
|
|
8
|
+
Chat43FriendAcceptedEventData,
|
|
9
|
+
Chat43FriendRequestEventData,
|
|
10
|
+
Chat43GroupInvitationEventData,
|
|
11
|
+
Chat43GroupMemberJoinedEventData,
|
|
12
|
+
Chat43GroupMessageEventData,
|
|
13
|
+
Chat43MessageContext,
|
|
14
|
+
Chat43PrivateMessageEventData,
|
|
15
|
+
Chat43SystemNoticeEventData,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
18
|
+
type InboundDescriptor = {
|
|
19
|
+
dedupeKey: string;
|
|
20
|
+
messageId: string;
|
|
21
|
+
chatType: "direct" | "group";
|
|
22
|
+
target: string;
|
|
23
|
+
fromAddress: string;
|
|
24
|
+
senderId: string;
|
|
25
|
+
senderName: string;
|
|
26
|
+
text: string;
|
|
27
|
+
timestamp: number;
|
|
28
|
+
groupSubject?: string;
|
|
29
|
+
conversationLabel: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const processedEvents = new Map<string, number>();
|
|
33
|
+
const MAX_PROCESSED_EVENTS = 2048;
|
|
34
|
+
|
|
35
|
+
function rememberProcessedEvent(key: string): boolean {
|
|
36
|
+
if (processedEvents.has(key)) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
processedEvents.set(key, Date.now());
|
|
40
|
+
if (processedEvents.size > MAX_PROCESSED_EVENTS) {
|
|
41
|
+
const entries = Array.from(processedEvents.entries())
|
|
42
|
+
.sort((a, b) => a[1] - b[1])
|
|
43
|
+
.slice(0, Math.floor(MAX_PROCESSED_EVENTS / 2));
|
|
44
|
+
for (const [entryKey] of entries) {
|
|
45
|
+
processedEvents.delete(entryKey);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fallbackMessageId(event: Chat43AnySSEEvent): string {
|
|
52
|
+
return createHash("sha1")
|
|
53
|
+
.update(JSON.stringify(event))
|
|
54
|
+
.digest("hex")
|
|
55
|
+
.slice(0, 16);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveBusinessId(event: Chat43AnySSEEvent): string {
|
|
59
|
+
switch (event.event_type) {
|
|
60
|
+
case "private_message":
|
|
61
|
+
return String((event.data as Chat43PrivateMessageEventData).message_id || fallbackMessageId(event));
|
|
62
|
+
case "group_message":
|
|
63
|
+
return String((event.data as Chat43GroupMessageEventData).message_id || fallbackMessageId(event));
|
|
64
|
+
case "friend_request":
|
|
65
|
+
return `friend_request:${(event.data as Chat43FriendRequestEventData).request_id}`;
|
|
66
|
+
case "friend_accepted":
|
|
67
|
+
return `friend_accepted:${(event.data as Chat43FriendAcceptedEventData).request_id}`;
|
|
68
|
+
case "group_invitation":
|
|
69
|
+
return `group_invitation:${(event.data as Chat43GroupInvitationEventData).invitation_id}`;
|
|
70
|
+
case "group_member_joined":
|
|
71
|
+
return `group_member_joined:${(event.data as Chat43GroupMemberJoinedEventData).group_id}:${(event.data as Chat43GroupMemberJoinedEventData).user_id}:${(event.data as Chat43GroupMemberJoinedEventData).join_method}`;
|
|
72
|
+
case "system_notice":
|
|
73
|
+
return `system_notice:${(event.data as Chat43SystemNoticeEventData).notice_id || fallbackMessageId(event)}`;
|
|
74
|
+
default:
|
|
75
|
+
return fallbackMessageId(event);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildInboundDescriptor(event: Chat43AnySSEEvent): InboundDescriptor | null {
|
|
80
|
+
const businessId = resolveBusinessId(event);
|
|
81
|
+
const messageId = businessId;
|
|
82
|
+
const dedupeKey = `${event.event_type}:${event.id || businessId}`;
|
|
83
|
+
|
|
84
|
+
switch (event.event_type) {
|
|
85
|
+
case "private_message": {
|
|
86
|
+
const data = event.data as Chat43PrivateMessageEventData;
|
|
87
|
+
const senderId = String(data.from_user_id);
|
|
88
|
+
const senderName = data.from_nickname || senderId;
|
|
89
|
+
const content = String(data.content ?? "").trim();
|
|
90
|
+
let text: string;
|
|
91
|
+
switch (data.content_type) {
|
|
92
|
+
case "text":
|
|
93
|
+
text = `[43Chat私聊消息][类型:文本][来自用户:${senderName} 用户ID:${senderId}][内容:${content}]`;
|
|
94
|
+
break;
|
|
95
|
+
case "image":
|
|
96
|
+
text = `[43Chat私聊消息][类型:图片][来自用户:${senderName} 用户ID:${senderId}][图片对象:${content || "<empty>"}]`;
|
|
97
|
+
break;
|
|
98
|
+
case "file":
|
|
99
|
+
text = `[43Chat私聊消息][类型:文件][来自用户:${senderName} 用户ID:${senderId}][文件对象:${content || "<empty>"}]`;
|
|
100
|
+
break;
|
|
101
|
+
case "sharegroup":
|
|
102
|
+
text = `[43Chat私聊消息][类型:群组卡片][来自用户:${senderName} 用户ID:${senderId}][卡片对象:${content || "<empty>"}]`;
|
|
103
|
+
break;
|
|
104
|
+
case "shareuser":
|
|
105
|
+
text = `[43Chat私聊消息][类型:用户卡片][来自用户:${senderName} 用户ID:${senderId}][卡片对象:${content || "<empty>"}]`;
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
text = `[43Chat私聊消息][类型:${data.content_type}][来自用户:${senderName} 用户ID:${senderId}][内容:${content || "<empty>"}]`;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (!text) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
dedupeKey,
|
|
116
|
+
messageId,
|
|
117
|
+
chatType: "direct",
|
|
118
|
+
target: `user:${senderId}`,
|
|
119
|
+
fromAddress: `43chat:user:${senderId}`,
|
|
120
|
+
senderId,
|
|
121
|
+
senderName: senderId,
|
|
122
|
+
text,
|
|
123
|
+
timestamp: data.timestamp || event.timestamp || Date.now(),
|
|
124
|
+
conversationLabel: `user:${senderId}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
case "group_message": {
|
|
128
|
+
const data = event.data as Chat43GroupMessageEventData;
|
|
129
|
+
const groupId = String(data.group_id);
|
|
130
|
+
const senderId = String(data.from_user_id);
|
|
131
|
+
const senderName = data.from_nickname || senderId;
|
|
132
|
+
const content = String(data.content ?? "").trim();
|
|
133
|
+
let text: string;
|
|
134
|
+
switch (data.content_type) {
|
|
135
|
+
case "text":
|
|
136
|
+
text = `[43Chat群消息][类型:文本][来自用户:${senderName} 用户ID:${senderId}][内容:${content}]`;
|
|
137
|
+
break;
|
|
138
|
+
case "image":
|
|
139
|
+
text = `[43Chat群消息][类型:图片][来自用户:${senderName} 用户ID:${senderId}][图片对象:${content || "<empty>"}]`;
|
|
140
|
+
break;
|
|
141
|
+
case "file":
|
|
142
|
+
text = `[43Chat群消息][类型:文件][来自用户:${senderName} 用户ID:${senderId}][文件对象:${content || "<empty>"}]`;
|
|
143
|
+
break;
|
|
144
|
+
case "sharegroup":
|
|
145
|
+
text = `[43Chat群消息][类型:群组卡片][来自用户:${senderName} 用户ID:${senderId}][卡片对象:${content || "<empty>"}]`;
|
|
146
|
+
break;
|
|
147
|
+
case "shareuser":
|
|
148
|
+
text = `[43Chat群消息][类型:用户卡片][来自用户:${senderName} 用户ID:${senderId}][卡片对象:${content || "<empty>"}]`;
|
|
149
|
+
break;
|
|
150
|
+
default:
|
|
151
|
+
text = `[43Chat群消息][类型:${data.content_type}][来自用户:${senderName} 用户ID:${senderId}][内容:${content || "<empty>"}]`;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
if (!text) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
dedupeKey,
|
|
159
|
+
messageId,
|
|
160
|
+
chatType: "group",
|
|
161
|
+
target: `group:${groupId}`,
|
|
162
|
+
fromAddress: `43chat:group:${groupId}`,
|
|
163
|
+
senderId,
|
|
164
|
+
senderName: senderId,
|
|
165
|
+
text,
|
|
166
|
+
timestamp: data.timestamp || event.timestamp || Date.now(),
|
|
167
|
+
groupSubject: groupId,
|
|
168
|
+
conversationLabel: `group:${groupId}`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
case "friend_request": {
|
|
172
|
+
const data = event.data as Chat43FriendRequestEventData;
|
|
173
|
+
const senderId = String(data.from_user_id);
|
|
174
|
+
return {
|
|
175
|
+
dedupeKey,
|
|
176
|
+
messageId,
|
|
177
|
+
chatType: "direct",
|
|
178
|
+
target: `user:${senderId}`,
|
|
179
|
+
fromAddress: `43chat:user:${senderId}`,
|
|
180
|
+
senderId,
|
|
181
|
+
senderName: data.from_nickname || senderId,
|
|
182
|
+
text: `[43Chat好友请求] 用户 ${senderId}${data.from_nickname ? `(${data.from_nickname})` : ""} 请求添加好友,附言:${data.request_msg || "无"},request_id=${data.request_id}`,
|
|
183
|
+
timestamp: data.timestamp || event.timestamp || Date.now(),
|
|
184
|
+
conversationLabel: `user:${senderId}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
case "friend_accepted": {
|
|
188
|
+
const data = event.data as Chat43FriendAcceptedEventData;
|
|
189
|
+
const senderId = String(data.from_user_id);
|
|
190
|
+
return {
|
|
191
|
+
dedupeKey,
|
|
192
|
+
messageId,
|
|
193
|
+
chatType: "direct",
|
|
194
|
+
target: `user:${senderId}`,
|
|
195
|
+
fromAddress: `43chat:user:${senderId}`,
|
|
196
|
+
senderId,
|
|
197
|
+
senderName: data.from_nickname || senderId,
|
|
198
|
+
text: `[43Chat好友通过] 用户 ${senderId}${data.from_nickname ? `(${data.from_nickname})` : ""} 已通过好友请求,request_id=${data.request_id}`,
|
|
199
|
+
timestamp: data.timestamp || event.timestamp || Date.now(),
|
|
200
|
+
conversationLabel: `user:${senderId}`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
case "group_invitation": {
|
|
204
|
+
const data = event.data as Chat43GroupInvitationEventData;
|
|
205
|
+
const groupId = String(data.group_id);
|
|
206
|
+
const inviterId = String(data.inviter_id);
|
|
207
|
+
return {
|
|
208
|
+
dedupeKey,
|
|
209
|
+
messageId,
|
|
210
|
+
chatType: "group",
|
|
211
|
+
target: `group:${groupId}`,
|
|
212
|
+
fromAddress: `43chat:group:${groupId}`,
|
|
213
|
+
senderId: inviterId,
|
|
214
|
+
senderName: data.inviter_name || inviterId,
|
|
215
|
+
text: `[43Chat群通知] 你收到群组邀请/入群申请通知,group_id=${groupId},group_name=${data.group_name || "未知群"},inviter=${data.inviter_name || inviterId}(${inviterId}),message=${data.invite_msg || "无"},invitation_id=${data.invitation_id}`,
|
|
216
|
+
timestamp: data.timestamp || event.timestamp || Date.now(),
|
|
217
|
+
groupSubject: data.group_name || groupId,
|
|
218
|
+
conversationLabel: `group:${groupId}`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
case "group_member_joined": {
|
|
222
|
+
const data = event.data as Chat43GroupMemberJoinedEventData;
|
|
223
|
+
const groupId = String(data.group_id);
|
|
224
|
+
const userId = String(data.user_id);
|
|
225
|
+
return {
|
|
226
|
+
dedupeKey,
|
|
227
|
+
messageId,
|
|
228
|
+
chatType: "group",
|
|
229
|
+
target: `group:${groupId}`,
|
|
230
|
+
fromAddress: `43chat:group:${groupId}`,
|
|
231
|
+
senderId: userId,
|
|
232
|
+
senderName: data.nickname || userId,
|
|
233
|
+
text: `[43Chat群通知] 新成员入群,group_id=${groupId},group_name=${data.group_name || "未知群"},user_id=${userId},nickname=${data.nickname || userId},join_method=${data.join_method || "unknown"}`,
|
|
234
|
+
timestamp: data.timestamp || event.timestamp || Date.now(),
|
|
235
|
+
groupSubject: data.group_name || groupId,
|
|
236
|
+
conversationLabel: `group:${groupId}`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
case "system_notice": {
|
|
240
|
+
const data = event.data as Chat43SystemNoticeEventData;
|
|
241
|
+
return {
|
|
242
|
+
dedupeKey,
|
|
243
|
+
messageId,
|
|
244
|
+
chatType: "direct",
|
|
245
|
+
target: "user:0",
|
|
246
|
+
fromAddress: "43chat:user:0",
|
|
247
|
+
senderId: "0",
|
|
248
|
+
senderName: "system",
|
|
249
|
+
text: `[43Chat系统通知][${data.level || "info"}] ${data.title || "系统通知"}: ${data.content || ""}`.trim(),
|
|
250
|
+
timestamp: data.timestamp || event.timestamp || Date.now(),
|
|
251
|
+
conversationLabel: "user:0",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
default:
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function chunkReplyText(
|
|
260
|
+
text: string,
|
|
261
|
+
chunkMode: "length" | "newline" | "raw",
|
|
262
|
+
textChunkLimit: number,
|
|
263
|
+
chunkTextWithMode: (text: string, limit: number, mode: "length" | "newline") => Iterable<string>,
|
|
264
|
+
): string[] {
|
|
265
|
+
if (!text) {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
if (chunkMode === "raw") {
|
|
269
|
+
return [text];
|
|
270
|
+
}
|
|
271
|
+
return Array.from(chunkTextWithMode(text, textChunkLimit, chunkMode));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function map43ChatEventToInboundDescriptor(event: Chat43AnySSEEvent): InboundDescriptor | null {
|
|
275
|
+
return buildInboundDescriptor(event);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export type Handle43ChatEventParams = {
|
|
279
|
+
cfg: ClawdbotConfig;
|
|
280
|
+
event: Chat43AnySSEEvent;
|
|
281
|
+
accountId: string;
|
|
282
|
+
runtime?: RuntimeEnv;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
export async function handle43ChatEvent(
|
|
286
|
+
params: Handle43ChatEventParams,
|
|
287
|
+
): Promise<Chat43MessageContext | null> {
|
|
288
|
+
const { cfg, event, accountId, runtime } = params;
|
|
289
|
+
const consoleRef = (globalThis as any)?.console;
|
|
290
|
+
const log = runtime?.log ?? consoleRef?.log?.bind(consoleRef) ?? (() => {});
|
|
291
|
+
const error = runtime?.error ?? consoleRef?.error?.bind(consoleRef) ?? (() => {});
|
|
292
|
+
|
|
293
|
+
log(
|
|
294
|
+
`43chat[${accountId}]: inbound event ${event.event_type} (${resolveBusinessId(event)}) ${JSON.stringify(
|
|
295
|
+
event,
|
|
296
|
+
)}`,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const account = resolve43ChatAccount({ cfg, accountId });
|
|
300
|
+
if (!account.enabled || !account.configured) {
|
|
301
|
+
error(`43chat[${accountId}]: account not enabled or configured`);
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const inbound = buildInboundDescriptor(event);
|
|
306
|
+
if (!inbound) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (rememberProcessedEvent(inbound.dedupeKey)) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const core = get43ChatRuntime();
|
|
315
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
316
|
+
cfg,
|
|
317
|
+
channel: "43chat",
|
|
318
|
+
accountId,
|
|
319
|
+
peer: {
|
|
320
|
+
kind: inbound.chatType === "group" ? "group" : "direct",
|
|
321
|
+
id: inbound.target,
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (!route.agentId) {
|
|
326
|
+
log(`43chat[${accountId}]: no agent route found for ${inbound.target}`);
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 暂时不需要预览
|
|
331
|
+
//const preview = inbound.text.replace(/\s+/g, " ").slice(0, 160);
|
|
332
|
+
const preview = "";
|
|
333
|
+
core.system.enqueueSystemEvent(`43Chat[${accountId}] ${inbound.chatType} ${inbound.target}: ${preview}`, {
|
|
334
|
+
sessionKey: route.sessionKey,
|
|
335
|
+
contextKey: `43chat:${inbound.messageId}`,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const body = core.channel.reply.formatInboundEnvelope({
|
|
339
|
+
channel: "43Chat",
|
|
340
|
+
from: inbound.conversationLabel,
|
|
341
|
+
body: inbound.text,
|
|
342
|
+
timestamp: inbound.timestamp,
|
|
343
|
+
chatType: inbound.chatType,
|
|
344
|
+
sender: {
|
|
345
|
+
name: inbound.senderName,
|
|
346
|
+
id: inbound.senderId,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
351
|
+
Body: body,
|
|
352
|
+
BodyForAgent: inbound.text,
|
|
353
|
+
BodyForCommands: inbound.text,
|
|
354
|
+
RawBody: inbound.text,
|
|
355
|
+
CommandBody: inbound.text,
|
|
356
|
+
From: inbound.fromAddress,
|
|
357
|
+
To: inbound.target,
|
|
358
|
+
SessionKey: route.sessionKey,
|
|
359
|
+
AccountId: route.accountId,
|
|
360
|
+
ChatType: inbound.chatType,
|
|
361
|
+
ConversationLabel: inbound.conversationLabel,
|
|
362
|
+
GroupSubject: inbound.groupSubject,
|
|
363
|
+
SenderName: inbound.senderName,
|
|
364
|
+
SenderId: inbound.senderId,
|
|
365
|
+
Provider: "43chat" as const,
|
|
366
|
+
Surface: "43chat" as const,
|
|
367
|
+
MessageSid: inbound.messageId,
|
|
368
|
+
Timestamp: inbound.timestamp,
|
|
369
|
+
WasMentioned: true,
|
|
370
|
+
CommandAuthorized: true,
|
|
371
|
+
OriginatingChannel: "43chat" as const,
|
|
372
|
+
OriginatingTo: inbound.target,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "43chat", accountId, {
|
|
376
|
+
fallbackLimit: account.config.textChunkLimit ?? 1800,
|
|
377
|
+
});
|
|
378
|
+
const chunkMode = account.config.chunkMode
|
|
379
|
+
?? core.channel.text.resolveChunkMode(cfg, "43chat", accountId);
|
|
380
|
+
|
|
381
|
+
const sendReply = async (text: string): Promise<void> => {
|
|
382
|
+
log(`43chat[${accountId}]: send reply ${text}`);
|
|
383
|
+
const chunks = chunkReplyText(
|
|
384
|
+
text,
|
|
385
|
+
chunkMode,
|
|
386
|
+
textChunkLimit,
|
|
387
|
+
core.channel.text.chunkTextWithMode,
|
|
388
|
+
).filter((chunk) => chunk.length > 0);
|
|
389
|
+
|
|
390
|
+
for (const chunk of chunks) {
|
|
391
|
+
await sendMessage43Chat({
|
|
392
|
+
cfg,
|
|
393
|
+
to: inbound.target,
|
|
394
|
+
text: chunk,
|
|
395
|
+
accountId,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
|
401
|
+
deliver: async (reply: { text?: string; mediaUrl?: string; mediaUrls?: string[]; replyToCurrent?: boolean}, { kind }) => {
|
|
402
|
+
log(`43chat[${accountId}]: reply ${kind} ${JSON.stringify(reply)}`);
|
|
403
|
+
// 此处暂时不回复消息
|
|
404
|
+
if (!reply.replyToCurrent) {
|
|
405
|
+
log(`43chat[${accountId}]: reply ${kind} ${JSON.stringify(reply)} not reply to current`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
// 只发最终回复,忽略 tool/block
|
|
409
|
+
if (kind !== "final") {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const mediaUrl = (reply as { mediaUrl?: string }).mediaUrl;
|
|
413
|
+
const mediaUrls = (reply as { mediaUrls?: string[] }).mediaUrls;
|
|
414
|
+
const text = reply.text ?? "";
|
|
415
|
+
|
|
416
|
+
if (!text.trim() && (mediaUrl || (Array.isArray(mediaUrls) && mediaUrls.length > 0))) {
|
|
417
|
+
await sendReply("[43Chat 插件暂不支持媒体消息发送]");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (!text.trim()) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
await sendReply(text);
|
|
425
|
+
},
|
|
426
|
+
onError: (err: unknown, info: { kind: string }) => {
|
|
427
|
+
if (err instanceof Error) {
|
|
428
|
+
error(`43chat[${accountId}] ${info.kind} reply failed: ${err.message}`);
|
|
429
|
+
} else {
|
|
430
|
+
error(`43chat[${accountId}] ${info.kind} reply failed: ${String(err ?? "unknown error")}`);
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
},
|
|
434
|
+
onIdle: () => {
|
|
435
|
+
log(`43chat[${accountId}]: reply dispatcher idle`);
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
const runDispatch = () =>
|
|
441
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
442
|
+
ctx: ctxPayload,
|
|
443
|
+
cfg,
|
|
444
|
+
dispatcher,
|
|
445
|
+
replyOptions: {
|
|
446
|
+
...replyOptions,
|
|
447
|
+
disableBlockStreaming: !(account.config.blockStreaming ?? false),
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const withReplyDispatcher = (core.channel.reply as {
|
|
452
|
+
withReplyDispatcher?: (params: {
|
|
453
|
+
dispatcher: unknown;
|
|
454
|
+
run: () => Promise<{ queuedFinal: boolean; counts: { final: number } }>;
|
|
455
|
+
onSettled?: () => Promise<void> | void;
|
|
456
|
+
}) => Promise<{ queuedFinal: boolean; counts: { final: number } }>;
|
|
457
|
+
}).withReplyDispatcher;
|
|
458
|
+
|
|
459
|
+
if (typeof withReplyDispatcher === "function") {
|
|
460
|
+
await withReplyDispatcher({
|
|
461
|
+
dispatcher,
|
|
462
|
+
run: runDispatch,
|
|
463
|
+
onSettled: () => markDispatchIdle(),
|
|
464
|
+
});
|
|
465
|
+
} else {
|
|
466
|
+
try {
|
|
467
|
+
await runDispatch();
|
|
468
|
+
} finally {
|
|
469
|
+
markDispatchIdle();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} catch (err) {
|
|
473
|
+
error(`43chat[${accountId}]: failed to dispatch message: ${String(err)}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
messageId: inbound.messageId,
|
|
478
|
+
senderId: inbound.senderId,
|
|
479
|
+
text: inbound.text,
|
|
480
|
+
timestamp: inbound.timestamp,
|
|
481
|
+
target: inbound.target,
|
|
482
|
+
chatType: inbound.chatType,
|
|
483
|
+
};
|
|
484
|
+
}
|