moltbot-channel-feishu 0.0.8
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 +68 -0
- package/clawdbot.plugin.json +33 -0
- package/moltbot.plugin.json +33 -0
- package/package.json +86 -0
- package/src/api/client.ts +140 -0
- package/src/api/directory.ts +186 -0
- package/src/api/media.ts +335 -0
- package/src/api/messages.ts +290 -0
- package/src/api/reactions.ts +155 -0
- package/src/config/schema.ts +183 -0
- package/src/core/dispatcher.ts +227 -0
- package/src/core/gateway.ts +202 -0
- package/src/core/handler.ts +231 -0
- package/src/core/parser.ts +112 -0
- package/src/core/policy.ts +199 -0
- package/src/core/reply-dispatcher.ts +151 -0
- package/src/core/runtime.ts +27 -0
- package/src/index.ts +108 -0
- package/src/plugin/channel.ts +367 -0
- package/src/plugin/index.ts +28 -0
- package/src/plugin/onboarding.ts +378 -0
- package/src/types/clawdbot.d.ts +377 -0
- package/src/types/events.ts +72 -0
- package/src/types/index.ts +6 -0
- package/src/types/messages.ts +172 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket gateway for real-time Feishu events.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
6
|
+
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "clawdbot/plugin-sdk";
|
|
7
|
+
import type { Config } from "../config/schema.js";
|
|
8
|
+
import type { MessageReceivedEvent, BotAddedEvent, BotRemovedEvent } from "../types/index.js";
|
|
9
|
+
import { createWsClient, probeConnection } from "../api/client.js";
|
|
10
|
+
import { handleMessage } from "./handler.js";
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Types
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
export interface GatewayOptions {
|
|
17
|
+
cfg: ClawdbotConfig;
|
|
18
|
+
runtime?: RuntimeEnv;
|
|
19
|
+
abortSignal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GatewayState {
|
|
23
|
+
botOpenId: string | undefined;
|
|
24
|
+
wsClient: Lark.WSClient | null;
|
|
25
|
+
chatHistories: Map<string, HistoryEntry[]>;
|
|
26
|
+
/** Message ID deduplication cache to prevent duplicate event processing */
|
|
27
|
+
processedMessages: Set<string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Gateway State
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
/** Max processed messages to cache (LRU-style) */
|
|
35
|
+
const MAX_PROCESSED_MESSAGES = 5000;
|
|
36
|
+
|
|
37
|
+
const state: GatewayState = {
|
|
38
|
+
botOpenId: undefined,
|
|
39
|
+
wsClient: null,
|
|
40
|
+
chatHistories: new Map(),
|
|
41
|
+
processedMessages: new Set(),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get the current bot's open_id.
|
|
46
|
+
*/
|
|
47
|
+
export function getBotOpenId(): string | undefined {
|
|
48
|
+
return state.botOpenId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Gateway Lifecycle
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Start the WebSocket gateway.
|
|
57
|
+
* Connects to Feishu and begins processing events.
|
|
58
|
+
*/
|
|
59
|
+
export async function startGateway(options: GatewayOptions): Promise<void> {
|
|
60
|
+
const { cfg, runtime, abortSignal } = options;
|
|
61
|
+
const feishuCfg = cfg.channels?.feishu as Config | undefined;
|
|
62
|
+
const log = (msg: string) => runtime?.log?.(msg);
|
|
63
|
+
const error = (msg: string) => runtime?.error?.(msg);
|
|
64
|
+
|
|
65
|
+
if (!feishuCfg) {
|
|
66
|
+
throw new Error("Feishu not configured");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Probe to get bot info
|
|
70
|
+
const probeResult = await probeConnection(feishuCfg);
|
|
71
|
+
if (probeResult.ok) {
|
|
72
|
+
state.botOpenId = probeResult.botOpenId;
|
|
73
|
+
log(`Gateway: bot open_id resolved: ${state.botOpenId ?? "unknown"}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Only websocket mode is supported
|
|
77
|
+
log("Gateway: starting WebSocket connection...");
|
|
78
|
+
|
|
79
|
+
const wsClient = createWsClient(feishuCfg);
|
|
80
|
+
state.wsClient = wsClient;
|
|
81
|
+
|
|
82
|
+
const eventDispatcher = new Lark.EventDispatcher({});
|
|
83
|
+
|
|
84
|
+
// Register event handlers
|
|
85
|
+
eventDispatcher.register({
|
|
86
|
+
"im.message.receive_v1": async (data: unknown) => {
|
|
87
|
+
try {
|
|
88
|
+
const event = data as MessageReceivedEvent;
|
|
89
|
+
const messageId = event.message?.message_id;
|
|
90
|
+
|
|
91
|
+
// Dedupe: skip if already processed (Feishu SDK may push duplicates)
|
|
92
|
+
if (messageId && state.processedMessages.has(messageId)) {
|
|
93
|
+
log(`Gateway: skipping duplicate message ${messageId}`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Track processed message
|
|
98
|
+
if (messageId) {
|
|
99
|
+
state.processedMessages.add(messageId);
|
|
100
|
+
// LRU-style cleanup: remove oldest entries when cache is full
|
|
101
|
+
if (state.processedMessages.size > MAX_PROCESSED_MESSAGES) {
|
|
102
|
+
const oldest = state.processedMessages.values().next().value;
|
|
103
|
+
if (oldest) state.processedMessages.delete(oldest);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await handleMessage({
|
|
108
|
+
cfg,
|
|
109
|
+
event,
|
|
110
|
+
botOpenId: state.botOpenId,
|
|
111
|
+
runtime,
|
|
112
|
+
chatHistories: state.chatHistories,
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
error(`Gateway: error handling message: ${String(err)}`);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
"im.message.message_read_v1": async () => {
|
|
120
|
+
// Ignore read receipts
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
"im.chat.member.bot.added_v1": async (data: unknown) => {
|
|
124
|
+
try {
|
|
125
|
+
const event = data as BotAddedEvent;
|
|
126
|
+
log(`Gateway: bot added to chat ${event.chat_id}`);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
error(`Gateway: error handling bot added: ${String(err)}`);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
"im.chat.member.bot.deleted_v1": async (data: unknown) => {
|
|
133
|
+
try {
|
|
134
|
+
const event = data as BotRemovedEvent;
|
|
135
|
+
log(`Gateway: bot removed from chat ${event.chat_id}`);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
error(`Gateway: error handling bot removed: ${String(err)}`);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Track reconnection attempts via polling (SDK handles reconnection internally)
|
|
143
|
+
let reconnectCheckInterval: ReturnType<typeof setInterval> | null = null;
|
|
144
|
+
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const cleanup = () => {
|
|
147
|
+
if (reconnectCheckInterval) {
|
|
148
|
+
clearInterval(reconnectCheckInterval);
|
|
149
|
+
reconnectCheckInterval = null;
|
|
150
|
+
}
|
|
151
|
+
if (state.wsClient === wsClient) {
|
|
152
|
+
state.wsClient = null;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const handleAbort = () => {
|
|
157
|
+
log("Gateway: abort signal received, stopping...");
|
|
158
|
+
cleanup();
|
|
159
|
+
resolve();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (abortSignal?.aborted) {
|
|
163
|
+
cleanup();
|
|
164
|
+
resolve();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
wsClient.start({ eventDispatcher });
|
|
172
|
+
log("Gateway: WebSocket client started");
|
|
173
|
+
|
|
174
|
+
// Monitor reconnection status (SDK handles reconnection internally)
|
|
175
|
+
reconnectCheckInterval = setInterval(() => {
|
|
176
|
+
try {
|
|
177
|
+
const reconnectInfo = wsClient.getReconnectInfo?.();
|
|
178
|
+
if (reconnectInfo && reconnectInfo.nextConnectTime > 0) {
|
|
179
|
+
const nextConnect = new Date(reconnectInfo.nextConnectTime).toISOString();
|
|
180
|
+
log(`Gateway: reconnection scheduled at ${nextConnect}`);
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// getReconnectInfo may not be available in all SDK versions
|
|
184
|
+
}
|
|
185
|
+
}, 30000); // Check every 30 seconds
|
|
186
|
+
} catch (err) {
|
|
187
|
+
cleanup();
|
|
188
|
+
abortSignal?.removeEventListener("abort", handleAbort);
|
|
189
|
+
reject(err);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Stop the WebSocket gateway.
|
|
196
|
+
*/
|
|
197
|
+
export function stopGateway(): void {
|
|
198
|
+
state.wsClient = null;
|
|
199
|
+
state.botOpenId = undefined;
|
|
200
|
+
state.chatHistories.clear();
|
|
201
|
+
state.processedMessages.clear();
|
|
202
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message handler for Feishu events.
|
|
3
|
+
* Integrates with Clawdbot runtime for routing and agent execution.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry, PluginRuntime } from "clawdbot/plugin-sdk";
|
|
7
|
+
import {
|
|
8
|
+
buildPendingHistoryContextFromMap,
|
|
9
|
+
recordPendingHistoryEntryIfEnabled,
|
|
10
|
+
clearHistoryEntriesIfEnabled,
|
|
11
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
12
|
+
} from "clawdbot/plugin-sdk";
|
|
13
|
+
|
|
14
|
+
import type { Config } from "../config/schema.js";
|
|
15
|
+
import type { MessageReceivedEvent } from "../types/index.js";
|
|
16
|
+
import { parseMessageEvent } from "./parser.js";
|
|
17
|
+
import { checkGroupPolicy, shouldRequireMention, matchAllowlist } from "./policy.js";
|
|
18
|
+
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
|
19
|
+
import { getMessage } from "../api/messages.js";
|
|
20
|
+
import { getRuntime } from "./runtime.js";
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Types
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
export interface MessageHandlerParams {
|
|
27
|
+
cfg: ClawdbotConfig;
|
|
28
|
+
event: MessageReceivedEvent;
|
|
29
|
+
botOpenId?: string;
|
|
30
|
+
runtime?: RuntimeEnv;
|
|
31
|
+
chatHistories?: Map<string, HistoryEntry[]>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Message Handler
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
export async function handleMessage(params: MessageHandlerParams): Promise<void> {
|
|
39
|
+
const { cfg, event, botOpenId, runtime, chatHistories } = params;
|
|
40
|
+
const feishuCfg = cfg.channels?.feishu as Config | undefined;
|
|
41
|
+
const log = runtime?.log ?? console.log;
|
|
42
|
+
const error = runtime?.error ?? console.error;
|
|
43
|
+
|
|
44
|
+
// Early guard: require feishu config
|
|
45
|
+
if (!feishuCfg) {
|
|
46
|
+
log("Feishu config not found, skipping message");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Parse the event
|
|
51
|
+
const parsed = parseMessageEvent(event, botOpenId);
|
|
52
|
+
const isGroup = parsed.chatType === "group";
|
|
53
|
+
|
|
54
|
+
log(`Received message from ${parsed.senderOpenId} in ${parsed.chatId} (${parsed.chatType})`);
|
|
55
|
+
|
|
56
|
+
const historyLimit = Math.max(
|
|
57
|
+
0,
|
|
58
|
+
feishuCfg.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Check policies
|
|
62
|
+
if (isGroup) {
|
|
63
|
+
const result = checkGroupPolicy(feishuCfg, parsed.chatId, parsed.senderOpenId);
|
|
64
|
+
|
|
65
|
+
if (!result.allowed) {
|
|
66
|
+
log(`Sender ${parsed.senderOpenId} not in group allowlist`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const requireMention = shouldRequireMention(feishuCfg, parsed.chatType, parsed.chatId);
|
|
71
|
+
|
|
72
|
+
if (requireMention && !parsed.mentionedBot) {
|
|
73
|
+
log(`Message in group ${parsed.chatId} did not mention bot, recording to history`);
|
|
74
|
+
if (chatHistories) {
|
|
75
|
+
recordPendingHistoryEntryIfEnabled({
|
|
76
|
+
historyMap: chatHistories,
|
|
77
|
+
historyKey: parsed.chatId,
|
|
78
|
+
limit: historyLimit,
|
|
79
|
+
entry: {
|
|
80
|
+
sender: parsed.senderOpenId,
|
|
81
|
+
body: parsed.content,
|
|
82
|
+
timestamp: Date.now(),
|
|
83
|
+
messageId: parsed.messageId,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
|
91
|
+
const allowFrom = feishuCfg?.allowFrom ?? [];
|
|
92
|
+
|
|
93
|
+
if (dmPolicy === "allowlist") {
|
|
94
|
+
const match = matchAllowlist(allowFrom as (string | number)[], parsed.senderOpenId);
|
|
95
|
+
if (!match) {
|
|
96
|
+
log(`Sender ${parsed.senderOpenId} not in DM allowlist`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Dispatch to agent
|
|
103
|
+
try {
|
|
104
|
+
const core = getRuntime() as PluginRuntime;
|
|
105
|
+
|
|
106
|
+
const feishuFrom = isGroup ? `feishu:group:${parsed.chatId}` : `feishu:${parsed.senderOpenId}`;
|
|
107
|
+
const feishuTo = isGroup ? `chat:${parsed.chatId}` : `user:${parsed.senderOpenId}`;
|
|
108
|
+
|
|
109
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
110
|
+
cfg,
|
|
111
|
+
channel: "feishu",
|
|
112
|
+
peer: {
|
|
113
|
+
kind: isGroup ? "group" : "dm",
|
|
114
|
+
id: isGroup ? parsed.chatId : parsed.senderOpenId,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const preview = parsed.content.replace(/\s+/g, " ").slice(0, 160);
|
|
119
|
+
const inboundLabel = isGroup
|
|
120
|
+
? `Feishu message in group ${parsed.chatId}`
|
|
121
|
+
: `Feishu DM from ${parsed.senderOpenId}`;
|
|
122
|
+
|
|
123
|
+
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
|
124
|
+
sessionKey: route.sessionKey,
|
|
125
|
+
contextKey: `feishu:message:${parsed.chatId}:${parsed.messageId}`,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Fetch quoted message if replying
|
|
129
|
+
let quotedContent: string | undefined;
|
|
130
|
+
if (parsed.parentId) {
|
|
131
|
+
try {
|
|
132
|
+
const quotedMsg = await getMessage(feishuCfg, parsed.parentId);
|
|
133
|
+
if (quotedMsg) {
|
|
134
|
+
quotedContent = quotedMsg.content;
|
|
135
|
+
log(`Fetched quoted message: ${quotedContent?.slice(0, 100)}`);
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
log(`Failed to fetch quoted message: ${String(err)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
143
|
+
|
|
144
|
+
// Build message body
|
|
145
|
+
let messageBody = parsed.content;
|
|
146
|
+
if (quotedContent) {
|
|
147
|
+
messageBody = `[Replying to: "${quotedContent}"]\n\n${parsed.content}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
151
|
+
channel: "Feishu",
|
|
152
|
+
from: isGroup ? parsed.chatId : parsed.senderOpenId,
|
|
153
|
+
timestamp: new Date(),
|
|
154
|
+
envelope: envelopeOptions,
|
|
155
|
+
body: messageBody,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
let combinedBody = body;
|
|
159
|
+
const historyKey = isGroup ? parsed.chatId : undefined;
|
|
160
|
+
|
|
161
|
+
if (isGroup && historyKey && chatHistories) {
|
|
162
|
+
combinedBody = buildPendingHistoryContextFromMap({
|
|
163
|
+
historyMap: chatHistories,
|
|
164
|
+
historyKey,
|
|
165
|
+
limit: historyLimit,
|
|
166
|
+
currentMessage: combinedBody,
|
|
167
|
+
formatEntry: (entry: HistoryEntry) =>
|
|
168
|
+
core.channel.reply.formatAgentEnvelope({
|
|
169
|
+
channel: "Feishu",
|
|
170
|
+
from: parsed.chatId,
|
|
171
|
+
timestamp: entry.timestamp,
|
|
172
|
+
body: `${entry.sender}: ${entry.body}`,
|
|
173
|
+
envelope: envelopeOptions,
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
179
|
+
Body: combinedBody,
|
|
180
|
+
RawBody: parsed.content,
|
|
181
|
+
CommandBody: parsed.content,
|
|
182
|
+
From: feishuFrom,
|
|
183
|
+
To: feishuTo,
|
|
184
|
+
SessionKey: route.sessionKey,
|
|
185
|
+
AccountId: route.accountId,
|
|
186
|
+
ChatType: isGroup ? "group" : "direct",
|
|
187
|
+
GroupSubject: isGroup ? parsed.chatId : undefined,
|
|
188
|
+
SenderName: parsed.senderOpenId,
|
|
189
|
+
SenderId: parsed.senderOpenId,
|
|
190
|
+
Provider: "feishu" as const,
|
|
191
|
+
Surface: "feishu" as const,
|
|
192
|
+
MessageSid: parsed.messageId,
|
|
193
|
+
Timestamp: Date.now(),
|
|
194
|
+
WasMentioned: parsed.mentionedBot,
|
|
195
|
+
CommandAuthorized: true,
|
|
196
|
+
OriginatingChannel: "feishu" as const,
|
|
197
|
+
OriginatingTo: feishuTo,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcher({
|
|
201
|
+
cfg,
|
|
202
|
+
agentId: route.agentId,
|
|
203
|
+
runtime: runtime as RuntimeEnv,
|
|
204
|
+
chatId: parsed.chatId,
|
|
205
|
+
replyToMessageId: parsed.messageId,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
log(`Dispatching to agent (session=${route.sessionKey})`);
|
|
209
|
+
|
|
210
|
+
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
|
211
|
+
ctx: ctxPayload,
|
|
212
|
+
cfg,
|
|
213
|
+
dispatcher,
|
|
214
|
+
replyOptions,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
markDispatchIdle();
|
|
218
|
+
|
|
219
|
+
if (isGroup && historyKey && chatHistories) {
|
|
220
|
+
clearHistoryEntriesIfEnabled({
|
|
221
|
+
historyMap: chatHistories,
|
|
222
|
+
historyKey,
|
|
223
|
+
limit: historyLimit,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
log(`Dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
error(`Failed to dispatch message: ${String(err)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message event parsing utilities.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { MessageReceivedEvent, MessageMention, ParsedMessage } from "../types/index.js";
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Content Parsing
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse message content based on message type.
|
|
13
|
+
* Extracts text from JSON-wrapped content.
|
|
14
|
+
*/
|
|
15
|
+
export function parseMessageContent(content: string, messageType: string): string {
|
|
16
|
+
try {
|
|
17
|
+
const parsed: unknown = JSON.parse(content);
|
|
18
|
+
if (
|
|
19
|
+
messageType === "text" &&
|
|
20
|
+
typeof parsed === "object" &&
|
|
21
|
+
parsed !== null &&
|
|
22
|
+
"text" in parsed
|
|
23
|
+
) {
|
|
24
|
+
return String((parsed as { text: unknown }).text);
|
|
25
|
+
}
|
|
26
|
+
return content;
|
|
27
|
+
} catch {
|
|
28
|
+
return content;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Mention Detection
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if the bot was mentioned in a message.
|
|
38
|
+
*/
|
|
39
|
+
export function isBotMentioned(
|
|
40
|
+
mentions: MessageMention[] | undefined,
|
|
41
|
+
botOpenId: string | undefined
|
|
42
|
+
): boolean {
|
|
43
|
+
if (!mentions || mentions.length === 0) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// If we don't know our bot's open_id, assume any mention is us
|
|
48
|
+
if (!botOpenId) {
|
|
49
|
+
return mentions.length > 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return mentions.some((m) => m.id.open_id === botOpenId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Remove bot mention text from message content.
|
|
57
|
+
* Cleans up both @name and mention keys.
|
|
58
|
+
*/
|
|
59
|
+
export function stripMentions(text: string, mentions: MessageMention[] | undefined): string {
|
|
60
|
+
if (!mentions || mentions.length === 0) {
|
|
61
|
+
return text;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let result = text;
|
|
65
|
+
for (const mention of mentions) {
|
|
66
|
+
// Remove @name format
|
|
67
|
+
const namePattern = new RegExp(`@${escapeRegex(mention.name)}\\s*`, "g");
|
|
68
|
+
result = result.replace(namePattern, "").trim();
|
|
69
|
+
|
|
70
|
+
// Remove mention key format (e.g., @_user_xxx)
|
|
71
|
+
result = result.replace(new RegExp(escapeRegex(mention.key), "g"), "").trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Escape special regex characters in a string.
|
|
79
|
+
*/
|
|
80
|
+
function escapeRegex(str: string): string {
|
|
81
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Event Parsing
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse a raw message event into a standardized format.
|
|
90
|
+
*/
|
|
91
|
+
export function parseMessageEvent(event: MessageReceivedEvent, botOpenId?: string): ParsedMessage {
|
|
92
|
+
const message = event.message;
|
|
93
|
+
const sender = event.sender;
|
|
94
|
+
|
|
95
|
+
const rawContent = parseMessageContent(message.content, message.message_type);
|
|
96
|
+
const mentionedBot = isBotMentioned(message.mentions, botOpenId);
|
|
97
|
+
const content = stripMentions(rawContent, message.mentions);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
chatId: message.chat_id,
|
|
101
|
+
messageId: message.message_id,
|
|
102
|
+
senderId: sender.sender_id.user_id ?? sender.sender_id.open_id ?? "",
|
|
103
|
+
senderOpenId: sender.sender_id.open_id ?? "",
|
|
104
|
+
senderName: undefined, // Not available in event, would need API lookup
|
|
105
|
+
chatType: message.chat_type,
|
|
106
|
+
mentionedBot,
|
|
107
|
+
rootId: message.root_id ?? undefined,
|
|
108
|
+
parentId: message.parent_id ?? undefined,
|
|
109
|
+
content,
|
|
110
|
+
contentType: message.message_type,
|
|
111
|
+
};
|
|
112
|
+
}
|