@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/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;
@@ -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;