@tobeyoureyes/feishu 1.0.0
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 +290 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +42 -0
- package/src/api.ts +1160 -0
- package/src/auth.ts +133 -0
- package/src/channel.ts +883 -0
- package/src/context.ts +292 -0
- package/src/dedupe.ts +85 -0
- package/src/dispatch.ts +185 -0
- package/src/history.ts +130 -0
- package/src/inbound.ts +83 -0
- package/src/message.ts +386 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +330 -0
- package/src/webhook.ts +549 -0
- package/src/websocket.ts +372 -0
package/src/context.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu message context builder
|
|
3
|
+
*
|
|
4
|
+
* Converts inbound Feishu messages to the standard OpenClaw message context format.
|
|
5
|
+
* Uses the PluginRuntime API (core.channel.*) for all core functionality.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
9
|
+
import { getFeishuRuntime } from "./runtime.js";
|
|
10
|
+
import type { ResolvedFeishuAccount } from "./types.js";
|
|
11
|
+
import type { FeishuInboundMessage } from "./webhook.js";
|
|
12
|
+
import type { HistoryEntry } from "./history.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Feishu message context - returned by buildFeishuMessageContext
|
|
16
|
+
*/
|
|
17
|
+
export interface FeishuMessageContext {
|
|
18
|
+
ctxPayload: Record<string, unknown>;
|
|
19
|
+
message: FeishuInboundMessage;
|
|
20
|
+
account: ResolvedFeishuAccount;
|
|
21
|
+
chatId: string;
|
|
22
|
+
isGroup: boolean;
|
|
23
|
+
route: {
|
|
24
|
+
agentId?: string;
|
|
25
|
+
sessionKey: string;
|
|
26
|
+
accountId: string;
|
|
27
|
+
mainSessionKey?: string;
|
|
28
|
+
};
|
|
29
|
+
sendTyping: () => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BuildFeishuMessageContextParams {
|
|
33
|
+
message: FeishuInboundMessage;
|
|
34
|
+
account: ResolvedFeishuAccount;
|
|
35
|
+
cfg: OpenClawConfig;
|
|
36
|
+
botOpenId?: string;
|
|
37
|
+
sendTyping?: () => Promise<void>;
|
|
38
|
+
/** Pending group history entries for context */
|
|
39
|
+
pendingHistory?: HistoryEntry[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if bot was mentioned in the message
|
|
44
|
+
*/
|
|
45
|
+
function isBotMentioned(message: FeishuInboundMessage, botOpenId?: string): boolean {
|
|
46
|
+
if (!botOpenId || !message.mentions) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return message.mentions.some((m) => m.id === botOpenId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Strip bot mention from message text
|
|
54
|
+
*/
|
|
55
|
+
function stripBotMention(text: string, mentions?: FeishuInboundMessage["mentions"]): string {
|
|
56
|
+
if (!mentions || mentions.length === 0) {
|
|
57
|
+
return text;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let result = text;
|
|
61
|
+
for (const mention of mentions) {
|
|
62
|
+
// Remove @_user_X placeholder and surrounding whitespace
|
|
63
|
+
const placeholder = mention.key;
|
|
64
|
+
result = result.replace(new RegExp(`\\s*${placeholder}\\s*`, "g"), " ").trim();
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build sender label for envelope
|
|
71
|
+
*/
|
|
72
|
+
function buildSenderLabel(message: FeishuInboundMessage): string {
|
|
73
|
+
return message.senderOpenId ?? message.senderId;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build conversation label
|
|
78
|
+
*/
|
|
79
|
+
function buildConversationLabel(message: FeishuInboundMessage, isGroup: boolean): string {
|
|
80
|
+
if (isGroup) {
|
|
81
|
+
return `group:${message.chatId}`;
|
|
82
|
+
}
|
|
83
|
+
return buildSenderLabel(message);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Format a history entry for display in context
|
|
88
|
+
*/
|
|
89
|
+
function formatHistoryEntry(entry: HistoryEntry): string {
|
|
90
|
+
return `[${entry.sender}]: ${entry.body}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build history context string from entries
|
|
95
|
+
*/
|
|
96
|
+
function buildHistoryContext(entries: HistoryEntry[], currentMessage: string): string {
|
|
97
|
+
if (entries.length === 0) {
|
|
98
|
+
return currentMessage;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const historyLines = entries.map(formatHistoryEntry);
|
|
102
|
+
return `[Recent conversation context]\n${historyLines.join("\n")}\n[/Recent conversation context]\n\n${currentMessage}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build Feishu message context for dispatch
|
|
107
|
+
*
|
|
108
|
+
* Uses PluginRuntime API (core.channel.*) for all core functionality:
|
|
109
|
+
* - core.channel.routing.resolveAgentRoute
|
|
110
|
+
* - core.channel.session.resolveStorePath
|
|
111
|
+
* - core.channel.session.readSessionUpdatedAt
|
|
112
|
+
* - core.channel.reply.resolveEnvelopeFormatOptions
|
|
113
|
+
* - core.channel.reply.formatAgentEnvelope
|
|
114
|
+
* - core.channel.reply.finalizeInboundContext
|
|
115
|
+
* - core.channel.session.recordInboundSession
|
|
116
|
+
*/
|
|
117
|
+
export async function buildFeishuMessageContext(
|
|
118
|
+
params: BuildFeishuMessageContextParams,
|
|
119
|
+
): Promise<FeishuMessageContext | null> {
|
|
120
|
+
const { message, account, cfg, botOpenId, sendTyping, pendingHistory } = params;
|
|
121
|
+
const core = getFeishuRuntime();
|
|
122
|
+
|
|
123
|
+
const isGroup = message.chatType === "group";
|
|
124
|
+
const peerId = message.chatId;
|
|
125
|
+
|
|
126
|
+
// Resolve agent route using core API
|
|
127
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
128
|
+
cfg,
|
|
129
|
+
channel: "feishu",
|
|
130
|
+
accountId: account.accountId,
|
|
131
|
+
peer: {
|
|
132
|
+
kind: isGroup ? "group" : "dm",
|
|
133
|
+
id: peerId,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const sessionKey = route.sessionKey;
|
|
138
|
+
|
|
139
|
+
// DM policy check
|
|
140
|
+
if (!isGroup) {
|
|
141
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
142
|
+
if (dmPolicy === "disabled") {
|
|
143
|
+
if (core.logging.shouldLogVerbose()) {
|
|
144
|
+
core.logging.getChildLogger({ module: "feishu" }).debug("blocked DM (dmPolicy=disabled)");
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (dmPolicy !== "open") {
|
|
150
|
+
const allowFrom = account.config.allowFrom ?? [];
|
|
151
|
+
const senderId = message.senderOpenId ?? message.senderId;
|
|
152
|
+
const allowed = allowFrom.length === 0 || allowFrom.includes(senderId);
|
|
153
|
+
if (!allowed && dmPolicy === "allowlist") {
|
|
154
|
+
if (core.logging.shouldLogVerbose()) {
|
|
155
|
+
core.logging.getChildLogger({ module: "feishu" }).debug(`blocked unauthorized DM sender ${senderId}`);
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
// pairing mode would need additional handling
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Group mention gating
|
|
164
|
+
if (isGroup) {
|
|
165
|
+
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
|
166
|
+
if (groupPolicy === "disabled") {
|
|
167
|
+
if (core.logging.shouldLogVerbose()) {
|
|
168
|
+
core.logging.getChildLogger({ module: "feishu" }).debug("blocked group message (groupPolicy=disabled)");
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const requireMention = account.requireMention;
|
|
174
|
+
const wasMentioned = isBotMentioned(message, botOpenId);
|
|
175
|
+
|
|
176
|
+
// Simple mention gating: if requireMention is true and bot was not mentioned, skip
|
|
177
|
+
if (requireMention && !wasMentioned) {
|
|
178
|
+
if (core.logging.shouldLogVerbose()) {
|
|
179
|
+
core.logging.getChildLogger({ module: "feishu" }).debug("skipping group message (no mention)");
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Build message body
|
|
186
|
+
const rawBody = message.displayText ?? message.text ?? "";
|
|
187
|
+
const bodyForAgent = isGroup ? stripBotMention(rawBody, message.mentions) : rawBody;
|
|
188
|
+
|
|
189
|
+
const senderLabel = buildSenderLabel(message);
|
|
190
|
+
const conversationLabel = buildConversationLabel(message, isGroup);
|
|
191
|
+
|
|
192
|
+
// Resolve store path and get previous timestamp using core API
|
|
193
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
194
|
+
agentId: route.agentId,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
198
|
+
storePath,
|
|
199
|
+
sessionKey,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Format envelope with previous timestamp for elapsed time display
|
|
203
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
204
|
+
const baseBody = core.channel.reply.formatAgentEnvelope({
|
|
205
|
+
channel: "Feishu",
|
|
206
|
+
from: conversationLabel,
|
|
207
|
+
timestamp: message.createTime,
|
|
208
|
+
previousTimestamp,
|
|
209
|
+
body: bodyForAgent,
|
|
210
|
+
envelope: envelopeOptions,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Build reply context
|
|
214
|
+
const replySuffix = message.replyToBody
|
|
215
|
+
? `\n\n[Replying to ${message.replyToSenderId ?? "unknown"}]\n${message.replyToBody}\n[/Replying]`
|
|
216
|
+
: "";
|
|
217
|
+
|
|
218
|
+
const messageWithReply = baseBody + replySuffix;
|
|
219
|
+
|
|
220
|
+
// Build group history context if available
|
|
221
|
+
let combinedBody: string;
|
|
222
|
+
if (isGroup && pendingHistory && pendingHistory.length > 0) {
|
|
223
|
+
combinedBody = buildHistoryContext(pendingHistory, messageWithReply);
|
|
224
|
+
} else {
|
|
225
|
+
combinedBody = messageWithReply;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Build context payload using core API
|
|
229
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
230
|
+
Body: combinedBody,
|
|
231
|
+
RawBody: rawBody,
|
|
232
|
+
CommandBody: bodyForAgent,
|
|
233
|
+
From: isGroup ? `feishu:group:${message.chatId}` : `feishu:${message.chatId}`,
|
|
234
|
+
To: `feishu:${message.chatId}`,
|
|
235
|
+
SessionKey: sessionKey,
|
|
236
|
+
AccountId: route.accountId,
|
|
237
|
+
ChatType: isGroup ? "group" : "direct",
|
|
238
|
+
ConversationLabel: conversationLabel,
|
|
239
|
+
SenderName: senderLabel,
|
|
240
|
+
SenderId: message.senderOpenId ?? message.senderId,
|
|
241
|
+
Provider: "feishu" as const,
|
|
242
|
+
Surface: "feishu" as const,
|
|
243
|
+
MessageSid: message.messageId,
|
|
244
|
+
ReplyToId: message.replyToId,
|
|
245
|
+
ReplyToBody: message.replyToBody,
|
|
246
|
+
ReplyToSender: message.replyToSenderId,
|
|
247
|
+
Timestamp: message.createTime,
|
|
248
|
+
WasMentioned: isGroup ? isBotMentioned(message, botOpenId) : undefined,
|
|
249
|
+
MediaPath: message.mediaPath,
|
|
250
|
+
MediaType: message.mediaType,
|
|
251
|
+
MediaUrl: message.mediaPath,
|
|
252
|
+
OriginatingChannel: "feishu" as const,
|
|
253
|
+
OriginatingTo: `feishu:${message.chatId}`,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Record session using core API
|
|
257
|
+
await core.channel.session.recordInboundSession({
|
|
258
|
+
storePath,
|
|
259
|
+
sessionKey: (ctxPayload as { SessionKey?: string }).SessionKey ?? sessionKey,
|
|
260
|
+
ctx: ctxPayload,
|
|
261
|
+
updateLastRoute: !isGroup
|
|
262
|
+
? {
|
|
263
|
+
sessionKey: route.mainSessionKey ?? sessionKey,
|
|
264
|
+
channel: "feishu",
|
|
265
|
+
to: message.chatId,
|
|
266
|
+
accountId: route.accountId,
|
|
267
|
+
}
|
|
268
|
+
: undefined,
|
|
269
|
+
onRecordError: (err) => {
|
|
270
|
+
if (core.logging.shouldLogVerbose()) {
|
|
271
|
+
core.logging.getChildLogger({ module: "feishu" }).debug(`failed updating session meta: ${String(err)}`);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (core.logging.shouldLogVerbose()) {
|
|
277
|
+
const preview = combinedBody.slice(0, 200).replace(/\n/g, "\\n");
|
|
278
|
+
core.logging.getChildLogger({ module: "feishu" }).debug(
|
|
279
|
+
`inbound: chatId=${message.chatId} from=${(ctxPayload as { From?: string }).From} len=${combinedBody.length} preview="${preview}"`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
ctxPayload,
|
|
285
|
+
message,
|
|
286
|
+
account,
|
|
287
|
+
chatId: message.chatId,
|
|
288
|
+
isGroup,
|
|
289
|
+
route,
|
|
290
|
+
sendTyping: sendTyping ?? (async () => {}),
|
|
291
|
+
};
|
|
292
|
+
}
|
package/src/dedupe.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu message deduplication module
|
|
3
|
+
*
|
|
4
|
+
* Provides LRU-based deduplication to prevent processing duplicate messages,
|
|
5
|
+
* which can occur due to WebSocket reconnections or webhook retries.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface DedupeCache {
|
|
9
|
+
/** Check if a message has already been processed */
|
|
10
|
+
isProcessed(messageId: string): boolean;
|
|
11
|
+
/** Mark a message as processed */
|
|
12
|
+
markProcessed(messageId: string): void;
|
|
13
|
+
/** Get the current size of the cache */
|
|
14
|
+
size(): number;
|
|
15
|
+
/** Clear all entries */
|
|
16
|
+
clear(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DedupeCacheOptions {
|
|
20
|
+
/** Maximum number of message IDs to track (default: 1000) */
|
|
21
|
+
maxSize?: number;
|
|
22
|
+
/** Cleanup when reaching this threshold (default: 800) */
|
|
23
|
+
cleanupThreshold?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a deduplication cache for Feishu messages
|
|
28
|
+
*
|
|
29
|
+
* Uses a Set with LRU-like cleanup to track processed message IDs.
|
|
30
|
+
* When the cache reaches maxSize, it removes the oldest entries
|
|
31
|
+
* down to cleanupThreshold.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* const dedupe = createFeishuDedupeCache();
|
|
36
|
+
*
|
|
37
|
+
* if (dedupe.isProcessed(messageId)) {
|
|
38
|
+
* return; // Skip duplicate
|
|
39
|
+
* }
|
|
40
|
+
* dedupe.markProcessed(messageId);
|
|
41
|
+
* // Process message...
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function createFeishuDedupeCache(options: DedupeCacheOptions = {}): DedupeCache {
|
|
45
|
+
const { maxSize = 1000, cleanupThreshold = 800 } = options;
|
|
46
|
+
|
|
47
|
+
const processedMessages = new Set<string>();
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
isProcessed(messageId: string): boolean {
|
|
51
|
+
return processedMessages.has(messageId);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
markProcessed(messageId: string): void {
|
|
55
|
+
// LRU-like cleanup: remove oldest entries when reaching max size
|
|
56
|
+
if (processedMessages.size >= maxSize) {
|
|
57
|
+
const toDelete = processedMessages.size - cleanupThreshold;
|
|
58
|
+
const iterator = processedMessages.values();
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < toDelete; i++) {
|
|
61
|
+
const value = iterator.next().value;
|
|
62
|
+
if (value) {
|
|
63
|
+
processedMessages.delete(value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
processedMessages.add(messageId);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
size(): number {
|
|
72
|
+
return processedMessages.size;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
clear(): void {
|
|
76
|
+
processedMessages.clear();
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Default deduplication cache configuration
|
|
83
|
+
*/
|
|
84
|
+
export const DEFAULT_DEDUPE_MAX_SIZE = 1000;
|
|
85
|
+
export const DEFAULT_DEDUPE_CLEANUP_THRESHOLD = 800;
|
package/src/dispatch.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu message dispatch module
|
|
3
|
+
*
|
|
4
|
+
* Handles dispatching messages to the AI agent and delivering replies.
|
|
5
|
+
* Uses the PluginRuntime API (core.channel.*) for all core functionality.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
9
|
+
import { getFeishuRuntime } from "./runtime.js";
|
|
10
|
+
import type { FeishuMessageContext } from "./context.js";
|
|
11
|
+
import type { ResolvedFeishuAccount, FeishuRenderMode } from "./types.js";
|
|
12
|
+
import * as api from "./api.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reply payload structure
|
|
16
|
+
*/
|
|
17
|
+
export interface ReplyPayload {
|
|
18
|
+
text: string;
|
|
19
|
+
replyToId?: string;
|
|
20
|
+
mediaUrl?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Dispatch options
|
|
25
|
+
*/
|
|
26
|
+
export interface DispatchFeishuMessageParams {
|
|
27
|
+
/** Message context from buildFeishuMessageContext */
|
|
28
|
+
context: FeishuMessageContext;
|
|
29
|
+
/** OpenClaw configuration */
|
|
30
|
+
cfg: OpenClawConfig;
|
|
31
|
+
/** Callback for sending text replies */
|
|
32
|
+
onSendReply?: (params: {
|
|
33
|
+
text: string;
|
|
34
|
+
replyToId?: string;
|
|
35
|
+
renderMode?: FeishuRenderMode;
|
|
36
|
+
}) => Promise<{ ok: boolean; messageId?: string; error?: string }>;
|
|
37
|
+
/** Callback for sending media */
|
|
38
|
+
onSendMedia?: (params: {
|
|
39
|
+
text?: string;
|
|
40
|
+
mediaUrl: string;
|
|
41
|
+
replyToId?: string;
|
|
42
|
+
}) => Promise<{ ok: boolean; messageId?: string; error?: string }>;
|
|
43
|
+
/** Callback when reply starts */
|
|
44
|
+
onReplyStart?: () => void;
|
|
45
|
+
/** Callback when dispatch is idle */
|
|
46
|
+
onIdle?: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Dispatch result
|
|
51
|
+
*/
|
|
52
|
+
export interface DispatchResult {
|
|
53
|
+
/** Whether a final reply was queued/sent */
|
|
54
|
+
queuedFinal: boolean;
|
|
55
|
+
/** Reply counts */
|
|
56
|
+
counts: {
|
|
57
|
+
final: number;
|
|
58
|
+
interim: number;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Dispatch Feishu message to AI agent and deliver replies
|
|
64
|
+
*
|
|
65
|
+
* Uses PluginRuntime API (core.channel.*) for dispatch:
|
|
66
|
+
* - core.channel.reply.createReplyDispatcherWithTyping
|
|
67
|
+
* - core.channel.reply.resolveHumanDelayConfig
|
|
68
|
+
* - core.channel.reply.dispatchReplyFromConfig
|
|
69
|
+
* - core.channel.text.resolveTextChunkLimit
|
|
70
|
+
* - core.channel.text.resolveMarkdownTableMode
|
|
71
|
+
*/
|
|
72
|
+
export async function dispatchFeishuMessage(
|
|
73
|
+
params: DispatchFeishuMessageParams,
|
|
74
|
+
): Promise<DispatchResult> {
|
|
75
|
+
const { context, cfg, onSendReply, onSendMedia, onReplyStart, onIdle } = params;
|
|
76
|
+
const core = getFeishuRuntime();
|
|
77
|
+
const logger = core.logging.getChildLogger({ module: "feishu" });
|
|
78
|
+
|
|
79
|
+
const { ctxPayload, account, chatId, route } = context;
|
|
80
|
+
|
|
81
|
+
// Get text configuration
|
|
82
|
+
const _textLimit = core.channel.text.resolveTextChunkLimit(cfg, "feishu");
|
|
83
|
+
const _tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
84
|
+
cfg,
|
|
85
|
+
channel: "feishu",
|
|
86
|
+
accountId: route.accountId,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Create dispatcher with typing indicator support
|
|
90
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
91
|
+
core.channel.reply.createReplyDispatcherWithTyping({
|
|
92
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
93
|
+
deliver: async (payload) => {
|
|
94
|
+
const text = payload.text ?? "";
|
|
95
|
+
const mediaUrl = payload.mediaUrl;
|
|
96
|
+
|
|
97
|
+
logger.info(`deliver called: text=${text.slice(0, 100)} mediaUrl=${mediaUrl}`);
|
|
98
|
+
|
|
99
|
+
if (mediaUrl && onSendMedia) {
|
|
100
|
+
logger.info(`sending media to ${context.message.messageId}`);
|
|
101
|
+
const result = await onSendMedia({
|
|
102
|
+
text,
|
|
103
|
+
mediaUrl,
|
|
104
|
+
replyToId: context.message.messageId,
|
|
105
|
+
});
|
|
106
|
+
logger.info(`media send result: ${JSON.stringify(result)}`);
|
|
107
|
+
} else if (text && onSendReply) {
|
|
108
|
+
// Determine render mode based on content
|
|
109
|
+
const renderMode = api.shouldUseCardRendering(text) ? "card" : account.renderMode;
|
|
110
|
+
logger.info(`sending reply to ${context.message.messageId} renderMode=${renderMode}`);
|
|
111
|
+
const result = await onSendReply({
|
|
112
|
+
text,
|
|
113
|
+
replyToId: context.message.messageId,
|
|
114
|
+
renderMode,
|
|
115
|
+
});
|
|
116
|
+
logger.info(`reply send result: ${JSON.stringify(result)}`);
|
|
117
|
+
} else {
|
|
118
|
+
logger.warn(`deliver called but no handler: text=${!!text} onSendReply=${!!onSendReply}`);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
onError: (err, info) => {
|
|
122
|
+
logger.error(`${info.kind} reply failed: ${String(err)}`);
|
|
123
|
+
},
|
|
124
|
+
onReplyStart,
|
|
125
|
+
onIdle,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// Dispatch to agent
|
|
130
|
+
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
|
131
|
+
ctx: ctxPayload,
|
|
132
|
+
cfg,
|
|
133
|
+
dispatcher,
|
|
134
|
+
replyOptions,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
markDispatchIdle();
|
|
138
|
+
|
|
139
|
+
if (queuedFinal && core.logging.shouldLogVerbose()) {
|
|
140
|
+
logger.debug(`delivered ${counts.final} reply${counts.final === 1 ? "" : "ies"} to ${chatId}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { queuedFinal, counts };
|
|
144
|
+
} catch (err) {
|
|
145
|
+
markDispatchIdle();
|
|
146
|
+
logger.error(`dispatch failed: ${String(err)}`);
|
|
147
|
+
return { queuedFinal: false, counts: { final: 0, interim: 0 } };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create default reply sender using Feishu API
|
|
153
|
+
*/
|
|
154
|
+
export function createDefaultReplySender(account: ResolvedFeishuAccount) {
|
|
155
|
+
return async (params: {
|
|
156
|
+
text: string;
|
|
157
|
+
replyToId?: string;
|
|
158
|
+
renderMode?: FeishuRenderMode;
|
|
159
|
+
}) => {
|
|
160
|
+
if (!params.replyToId) {
|
|
161
|
+
return { ok: false, error: "Missing replyToId" };
|
|
162
|
+
}
|
|
163
|
+
return api.replyMessage(account, params.replyToId, params.text, {
|
|
164
|
+
renderMode: params.renderMode,
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Create default media sender using Feishu API
|
|
171
|
+
*/
|
|
172
|
+
export function createDefaultMediaSender(account: ResolvedFeishuAccount) {
|
|
173
|
+
return async (params: {
|
|
174
|
+
text?: string;
|
|
175
|
+
mediaUrl: string;
|
|
176
|
+
replyToId?: string;
|
|
177
|
+
}) => {
|
|
178
|
+
if (!params.replyToId) {
|
|
179
|
+
return { ok: false, error: "Missing replyToId" };
|
|
180
|
+
}
|
|
181
|
+
// For now, send media URL as text
|
|
182
|
+
const text = params.text ? `${params.text}\n\n${params.mediaUrl}` : params.mediaUrl;
|
|
183
|
+
return api.replyMessage(account, params.replyToId, text);
|
|
184
|
+
};
|
|
185
|
+
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu group chat history management module
|
|
3
|
+
*
|
|
4
|
+
* Manages pending message history for group chats to provide context
|
|
5
|
+
* when the bot is mentioned. Messages are accumulated until the bot
|
|
6
|
+
* responds, then cleared.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* History entry for a group chat message
|
|
11
|
+
*/
|
|
12
|
+
export interface HistoryEntry {
|
|
13
|
+
/** Sender identifier (open_id or user_id) */
|
|
14
|
+
sender: string;
|
|
15
|
+
/** Message body text */
|
|
16
|
+
body: string;
|
|
17
|
+
/** Message timestamp (Unix milliseconds) */
|
|
18
|
+
timestamp: number;
|
|
19
|
+
/** Feishu message ID */
|
|
20
|
+
messageId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GroupHistoryManager {
|
|
24
|
+
/** Record a message to group history */
|
|
25
|
+
record(chatId: string, entry: HistoryEntry): void;
|
|
26
|
+
/** Get pending history entries for a group */
|
|
27
|
+
get(chatId: string): HistoryEntry[];
|
|
28
|
+
/** Clear history after bot replies */
|
|
29
|
+
clear(chatId: string): void;
|
|
30
|
+
/** Get number of groups being tracked */
|
|
31
|
+
groupCount(): number;
|
|
32
|
+
/** Clear all history */
|
|
33
|
+
clearAll(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GroupHistoryManagerOptions {
|
|
37
|
+
/** Maximum messages to keep per group (default: 10) */
|
|
38
|
+
historyLimit?: number;
|
|
39
|
+
/** Maximum groups to track (default: 100) */
|
|
40
|
+
maxGroups?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create a group history manager for Feishu
|
|
45
|
+
*
|
|
46
|
+
* Tracks pending messages in group chats that haven't triggered a bot response.
|
|
47
|
+
* When the bot is mentioned, the history provides context for the conversation.
|
|
48
|
+
* After the bot replies, the history is cleared.
|
|
49
|
+
*
|
|
50
|
+
* Uses LRU eviction for groups when maxGroups is exceeded.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* const history = createGroupHistoryManager();
|
|
55
|
+
*
|
|
56
|
+
* // Record messages that don't trigger the bot
|
|
57
|
+
* history.record(chatId, { sender, body, timestamp, messageId });
|
|
58
|
+
*
|
|
59
|
+
* // When bot is mentioned, get history for context
|
|
60
|
+
* const pendingHistory = history.get(chatId);
|
|
61
|
+
*
|
|
62
|
+
* // After bot replies, clear the history
|
|
63
|
+
* history.clear(chatId);
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function createGroupHistoryManager(
|
|
67
|
+
options: GroupHistoryManagerOptions = {},
|
|
68
|
+
): GroupHistoryManager {
|
|
69
|
+
const { historyLimit = 10, maxGroups = 100 } = options;
|
|
70
|
+
|
|
71
|
+
const groupHistories = new Map<string, HistoryEntry[]>();
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
record(chatId: string, entry: HistoryEntry): void {
|
|
75
|
+
// LRU eviction: remove oldest groups when exceeding limit
|
|
76
|
+
if (groupHistories.size > maxGroups) {
|
|
77
|
+
const keysToDelete = groupHistories.size - maxGroups;
|
|
78
|
+
const iterator = groupHistories.keys();
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < keysToDelete; i++) {
|
|
81
|
+
const key = iterator.next().value;
|
|
82
|
+
if (key) {
|
|
83
|
+
groupHistories.delete(key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Get or create history for this chat
|
|
89
|
+
let history = groupHistories.get(chatId);
|
|
90
|
+
if (!history) {
|
|
91
|
+
history = [];
|
|
92
|
+
groupHistories.set(chatId, history);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Add entry and trim to limit (FIFO)
|
|
96
|
+
history.push(entry);
|
|
97
|
+
while (history.length > historyLimit) {
|
|
98
|
+
history.shift();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Refresh insertion order for LRU (move to end)
|
|
102
|
+
if (groupHistories.has(chatId)) {
|
|
103
|
+
groupHistories.delete(chatId);
|
|
104
|
+
groupHistories.set(chatId, history);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
get(chatId: string): HistoryEntry[] {
|
|
109
|
+
return groupHistories.get(chatId) ?? [];
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
clear(chatId: string): void {
|
|
113
|
+
groupHistories.set(chatId, []);
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
groupCount(): number {
|
|
117
|
+
return groupHistories.size;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
clearAll(): void {
|
|
121
|
+
groupHistories.clear();
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Default history management configuration
|
|
128
|
+
*/
|
|
129
|
+
export const DEFAULT_GROUP_HISTORY_LIMIT = 10;
|
|
130
|
+
export const DEFAULT_MAX_GROUPS = 100;
|