@adongguo/dingtalk 0.1.3

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.
@@ -0,0 +1,64 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+
3
+ // DingTalk doesn't have a message reactions API
4
+ // This module provides stub implementations for interface compatibility
5
+
6
+ export type DingTalkReaction = {
7
+ reactionId: string;
8
+ emojiType: string;
9
+ operatorType: "app" | "user";
10
+ operatorId: string;
11
+ };
12
+
13
+ /**
14
+ * Add a reaction (emoji) to a message.
15
+ * Note: DingTalk doesn't support message reactions via bot API.
16
+ */
17
+ export async function addReactionDingTalk(_params: {
18
+ cfg: ClawdbotConfig;
19
+ messageId: string;
20
+ emojiType: string;
21
+ }): Promise<{ reactionId: string }> {
22
+ // DingTalk doesn't support message reactions via bot API
23
+ throw new Error("DingTalk does not support message reactions via bot API");
24
+ }
25
+
26
+ /**
27
+ * Remove a reaction from a message.
28
+ * Note: DingTalk doesn't support message reactions via bot API.
29
+ */
30
+ export async function removeReactionDingTalk(_params: {
31
+ cfg: ClawdbotConfig;
32
+ messageId: string;
33
+ reactionId: string;
34
+ }): Promise<void> {
35
+ // DingTalk doesn't support message reactions via bot API
36
+ throw new Error("DingTalk does not support message reactions via bot API");
37
+ }
38
+
39
+ /**
40
+ * List all reactions for a message.
41
+ * Note: DingTalk doesn't support message reactions via bot API.
42
+ */
43
+ export async function listReactionsDingTalk(_params: {
44
+ cfg: ClawdbotConfig;
45
+ messageId: string;
46
+ emojiType?: string;
47
+ }): Promise<DingTalkReaction[]> {
48
+ // DingTalk doesn't support message reactions via bot API
49
+ return [];
50
+ }
51
+
52
+ /**
53
+ * Common emoji types for convenience.
54
+ * Note: These are placeholders since DingTalk doesn't support reactions.
55
+ */
56
+ export const DingTalkEmoji = {
57
+ THUMBSUP: "THUMBSUP",
58
+ THUMBSDOWN: "THUMBSDOWN",
59
+ HEART: "HEART",
60
+ SMILE: "SMILE",
61
+ OK: "OK",
62
+ } as const;
63
+
64
+ export type DingTalkEmojiType = (typeof DingTalkEmoji)[keyof typeof DingTalkEmoji];
@@ -0,0 +1,167 @@
1
+ import type { DWClient } from "dingtalk-stream";
2
+ import {
3
+ createReplyPrefixContext,
4
+ createTypingCallbacks,
5
+ logTypingFailure,
6
+ type ClawdbotConfig,
7
+ type RuntimeEnv,
8
+ type ReplyPayload,
9
+ } from "openclaw/plugin-sdk";
10
+ import { getDingTalkRuntime } from "./runtime.js";
11
+ import { sendMessageDingTalk, sendActionCardDingTalk } from "./send.js";
12
+ import type { DingTalkConfig } from "./types.js";
13
+ import {
14
+ addTypingIndicator,
15
+ removeTypingIndicator,
16
+ type TypingIndicatorState,
17
+ } from "./typing.js";
18
+
19
+ /**
20
+ * Detect if text contains markdown elements that benefit from card rendering.
21
+ * Used by auto render mode.
22
+ */
23
+ function shouldUseCard(text: string): boolean {
24
+ // Code blocks (fenced)
25
+ if (/```[\s\S]*?```/.test(text)) return true;
26
+ // Tables (at least header + separator row with |)
27
+ if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) return true;
28
+ return false;
29
+ }
30
+
31
+ export type CreateDingTalkReplyDispatcherParams = {
32
+ cfg: ClawdbotConfig;
33
+ agentId: string;
34
+ runtime: RuntimeEnv;
35
+ conversationId: string;
36
+ sessionWebhook: string;
37
+ client?: DWClient;
38
+ };
39
+
40
+ export function createDingTalkReplyDispatcher(params: CreateDingTalkReplyDispatcherParams) {
41
+ const core = getDingTalkRuntime();
42
+ const { cfg, agentId, conversationId, sessionWebhook, client } = params;
43
+
44
+ const prefixContext = createReplyPrefixContext({
45
+ cfg,
46
+ agentId,
47
+ });
48
+
49
+ // DingTalk doesn't have a native typing indicator API.
50
+ // We could use emoji reactions if available.
51
+ let typingState: TypingIndicatorState | null = null;
52
+
53
+ const typingCallbacks = createTypingCallbacks({
54
+ start: async () => {
55
+ // DingTalk typing indicator is optional and may not work for all bots
56
+ try {
57
+ typingState = await addTypingIndicator({ cfg, sessionWebhook });
58
+ params.runtime.log?.(`dingtalk: added typing indicator`);
59
+ } catch {
60
+ // Typing indicator not available, ignore
61
+ }
62
+ },
63
+ stop: async () => {
64
+ if (!typingState) return;
65
+ try {
66
+ await removeTypingIndicator({ cfg, state: typingState });
67
+ typingState = null;
68
+ params.runtime.log?.(`dingtalk: removed typing indicator`);
69
+ } catch {
70
+ // Ignore errors
71
+ }
72
+ },
73
+ onStartError: (err) => {
74
+ logTypingFailure({
75
+ log: (message) => params.runtime.log?.(message),
76
+ channel: "dingtalk",
77
+ action: "start",
78
+ error: err,
79
+ });
80
+ },
81
+ onStopError: (err) => {
82
+ logTypingFailure({
83
+ log: (message) => params.runtime.log?.(message),
84
+ channel: "dingtalk",
85
+ action: "stop",
86
+ error: err,
87
+ });
88
+ },
89
+ });
90
+
91
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit({
92
+ cfg,
93
+ channel: "dingtalk",
94
+ defaultLimit: 4000,
95
+ });
96
+ const chunkMode = core.channel.text.resolveChunkMode(cfg, "dingtalk");
97
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
98
+ cfg,
99
+ channel: "dingtalk",
100
+ });
101
+
102
+ const { dispatcher, replyOptions, markDispatchIdle } =
103
+ core.channel.reply.createReplyDispatcherWithTyping({
104
+ responsePrefix: prefixContext.responsePrefix,
105
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
106
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
107
+ onReplyStart: typingCallbacks.onReplyStart,
108
+ deliver: async (payload: ReplyPayload) => {
109
+ params.runtime.log?.(`dingtalk deliver called: text=${payload.text?.slice(0, 100)}`);
110
+ const text = payload.text ?? "";
111
+ if (!text.trim()) {
112
+ params.runtime.log?.(`dingtalk deliver: empty text, skipping`);
113
+ return;
114
+ }
115
+
116
+ // Check render mode: auto (default), raw, or card
117
+ const dingtalkCfg = cfg.channels?.dingtalk as DingTalkConfig | undefined;
118
+ const renderMode = dingtalkCfg?.renderMode ?? "auto";
119
+
120
+ // Determine if we should use card for this message
121
+ const useCard =
122
+ renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
123
+
124
+ if (useCard) {
125
+ // Card mode: send as ActionCard with markdown rendering
126
+ const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
127
+ params.runtime.log?.(`dingtalk deliver: sending ${chunks.length} card chunks to ${conversationId}`);
128
+ for (const chunk of chunks) {
129
+ await sendActionCardDingTalk({
130
+ cfg,
131
+ sessionWebhook,
132
+ title: "Reply",
133
+ text: chunk,
134
+ client,
135
+ });
136
+ }
137
+ } else {
138
+ // Raw mode: send as plain text with table conversion
139
+ const converted = core.channel.text.convertMarkdownTables(text, tableMode);
140
+ const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
141
+ params.runtime.log?.(`dingtalk deliver: sending ${chunks.length} text chunks to ${conversationId}`);
142
+ for (const chunk of chunks) {
143
+ await sendMessageDingTalk({
144
+ cfg,
145
+ sessionWebhook,
146
+ text: chunk,
147
+ client,
148
+ });
149
+ }
150
+ }
151
+ },
152
+ onError: (err, info) => {
153
+ params.runtime.error?.(`dingtalk ${info.kind} reply failed: ${String(err)}`);
154
+ typingCallbacks.onIdle?.();
155
+ },
156
+ onIdle: typingCallbacks.onIdle,
157
+ });
158
+
159
+ return {
160
+ dispatcher,
161
+ replyOptions: {
162
+ ...replyOptions,
163
+ onModelSelected: prefixContext.onModelSelected,
164
+ },
165
+ markDispatchIdle,
166
+ };
167
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setDingTalkRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getDingTalkRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("DingTalk runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }
package/src/send.ts ADDED
@@ -0,0 +1,314 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import type { DWClient } from "dingtalk-stream";
3
+ import type {
4
+ DingTalkConfig,
5
+ DingTalkSendResult,
6
+ DingTalkTextMessage,
7
+ DingTalkMarkdownMessage,
8
+ DingTalkActionCardMessage,
9
+ DingTalkOutboundMessage,
10
+ } from "./types.js";
11
+ import { getDingTalkRuntime } from "./runtime.js";
12
+
13
+ export type DingTalkMessageInfo = {
14
+ messageId: string;
15
+ conversationId: string;
16
+ senderId?: string;
17
+ content: string;
18
+ contentType: string;
19
+ createTime?: number;
20
+ };
21
+
22
+ /**
23
+ * Send a message via DingTalk sessionWebhook.
24
+ * This is the primary method for sending messages in response to incoming messages.
25
+ */
26
+ export async function sendViaWebhook(params: {
27
+ sessionWebhook: string;
28
+ message: DingTalkOutboundMessage;
29
+ accessToken?: string;
30
+ }): Promise<DingTalkSendResult> {
31
+ const { sessionWebhook, message, accessToken } = params;
32
+
33
+ const headers: Record<string, string> = {
34
+ "Content-Type": "application/json",
35
+ };
36
+
37
+ if (accessToken) {
38
+ headers["x-acs-dingtalk-access-token"] = accessToken;
39
+ }
40
+
41
+ const response = await fetch(sessionWebhook, {
42
+ method: "POST",
43
+ headers,
44
+ body: JSON.stringify(message),
45
+ });
46
+
47
+ if (!response.ok) {
48
+ const text = await response.text();
49
+ throw new Error(`DingTalk webhook send failed: ${response.status} ${text}`);
50
+ }
51
+
52
+ const result = await response.json() as { errcode?: number; errmsg?: string; processQueryKey?: string };
53
+
54
+ if (result.errcode && result.errcode !== 0) {
55
+ throw new Error(`DingTalk send failed: ${result.errmsg || `code ${result.errcode}`}`);
56
+ }
57
+
58
+ return {
59
+ conversationId: "",
60
+ processQueryKey: result.processQueryKey,
61
+ };
62
+ }
63
+
64
+ export type SendDingTalkMessageParams = {
65
+ cfg: ClawdbotConfig;
66
+ sessionWebhook: string;
67
+ text: string;
68
+ atUserIds?: string[];
69
+ client?: DWClient;
70
+ };
71
+
72
+ export async function sendMessageDingTalk(params: SendDingTalkMessageParams): Promise<DingTalkSendResult> {
73
+ const { cfg, sessionWebhook, text, atUserIds, client } = params;
74
+ const dingtalkCfg = cfg.channels?.dingtalk as DingTalkConfig | undefined;
75
+ if (!dingtalkCfg) {
76
+ throw new Error("DingTalk channel not configured");
77
+ }
78
+
79
+ const tableMode = getDingTalkRuntime().channel.text.resolveMarkdownTableMode({
80
+ cfg,
81
+ channel: "dingtalk",
82
+ });
83
+ const messageText = getDingTalkRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
84
+
85
+ const message: DingTalkTextMessage = {
86
+ msgtype: "text",
87
+ text: {
88
+ content: messageText,
89
+ },
90
+ };
91
+
92
+ if (atUserIds && atUserIds.length > 0) {
93
+ message.at = {
94
+ atUserIds,
95
+ isAtAll: false,
96
+ };
97
+ }
98
+
99
+ let accessToken: string | undefined;
100
+ if (client) {
101
+ try {
102
+ accessToken = await client.getAccessToken();
103
+ } catch {
104
+ // Proceed without access token
105
+ }
106
+ }
107
+
108
+ return sendViaWebhook({ sessionWebhook, message, accessToken });
109
+ }
110
+
111
+ export type SendDingTalkMarkdownParams = {
112
+ cfg: ClawdbotConfig;
113
+ sessionWebhook: string;
114
+ title: string;
115
+ text: string;
116
+ atUserIds?: string[];
117
+ client?: DWClient;
118
+ };
119
+
120
+ export async function sendMarkdownDingTalk(params: SendDingTalkMarkdownParams): Promise<DingTalkSendResult> {
121
+ const { sessionWebhook, title, text, atUserIds, client } = params;
122
+
123
+ const message: DingTalkMarkdownMessage = {
124
+ msgtype: "markdown",
125
+ markdown: {
126
+ title,
127
+ text,
128
+ },
129
+ };
130
+
131
+ if (atUserIds && atUserIds.length > 0) {
132
+ message.at = {
133
+ atUserIds,
134
+ isAtAll: false,
135
+ };
136
+ }
137
+
138
+ let accessToken: string | undefined;
139
+ if (client) {
140
+ try {
141
+ accessToken = await client.getAccessToken();
142
+ } catch {
143
+ // Proceed without access token
144
+ }
145
+ }
146
+
147
+ return sendViaWebhook({ sessionWebhook, message, accessToken });
148
+ }
149
+
150
+ export type SendDingTalkActionCardParams = {
151
+ cfg: ClawdbotConfig;
152
+ sessionWebhook: string;
153
+ title: string;
154
+ text: string;
155
+ singleTitle?: string;
156
+ singleURL?: string;
157
+ client?: DWClient;
158
+ };
159
+
160
+ export async function sendActionCardDingTalk(params: SendDingTalkActionCardParams): Promise<DingTalkSendResult> {
161
+ const { sessionWebhook, title, text, singleTitle, singleURL, client } = params;
162
+
163
+ const message: DingTalkActionCardMessage = {
164
+ msgtype: "actionCard",
165
+ actionCard: {
166
+ title,
167
+ text,
168
+ singleTitle,
169
+ singleURL,
170
+ },
171
+ };
172
+
173
+ let accessToken: string | undefined;
174
+ if (client) {
175
+ try {
176
+ accessToken = await client.getAccessToken();
177
+ } catch {
178
+ // Proceed without access token
179
+ }
180
+ }
181
+
182
+ return sendViaWebhook({ sessionWebhook, message, accessToken });
183
+ }
184
+
185
+ /**
186
+ * Build an ActionCard message with markdown content.
187
+ * ActionCards render markdown properly (code blocks, tables, links, etc.)
188
+ */
189
+ export function buildMarkdownCard(text: string, title?: string): DingTalkActionCardMessage {
190
+ return {
191
+ msgtype: "actionCard",
192
+ actionCard: {
193
+ title: title || "Message",
194
+ text,
195
+ },
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Send a message as an ActionCard (for better markdown rendering).
201
+ */
202
+ export async function sendMarkdownCardDingTalk(params: {
203
+ cfg: ClawdbotConfig;
204
+ sessionWebhook: string;
205
+ text: string;
206
+ title?: string;
207
+ client?: DWClient;
208
+ }): Promise<DingTalkSendResult> {
209
+ const { cfg, sessionWebhook, text, title, client } = params;
210
+ const message = buildMarkdownCard(text, title);
211
+
212
+ let accessToken: string | undefined;
213
+ if (client) {
214
+ try {
215
+ accessToken = await client.getAccessToken();
216
+ } catch {
217
+ // Proceed without access token
218
+ }
219
+ }
220
+
221
+ return sendViaWebhook({ sessionWebhook, message, accessToken });
222
+ }
223
+
224
+ // ============ Simplified Send Functions (for streaming-handler) ============
225
+
226
+ /**
227
+ * Send a text message via sessionWebhook (simplified, no cfg required).
228
+ */
229
+ export async function sendDingTalkTextMessage(params: {
230
+ sessionWebhook: string;
231
+ text: string;
232
+ atUserId?: string;
233
+ client?: DWClient;
234
+ }): Promise<DingTalkSendResult> {
235
+ const { sessionWebhook, text, atUserId, client } = params;
236
+
237
+ const message: DingTalkTextMessage = {
238
+ msgtype: "text",
239
+ text: { content: text },
240
+ };
241
+
242
+ if (atUserId) {
243
+ message.at = { atUserIds: [atUserId], isAtAll: false };
244
+ }
245
+
246
+ let accessToken: string | undefined;
247
+ if (client) {
248
+ try {
249
+ accessToken = await client.getAccessToken();
250
+ } catch {
251
+ // Proceed without access token
252
+ }
253
+ }
254
+
255
+ return sendViaWebhook({ sessionWebhook, message, accessToken });
256
+ }
257
+
258
+ /**
259
+ * Send a message via sessionWebhook with smart text/markdown selection.
260
+ */
261
+ export async function sendDingTalkMessage(params: {
262
+ sessionWebhook: string;
263
+ text: string;
264
+ useMarkdown?: boolean;
265
+ title?: string;
266
+ atUserId?: string;
267
+ client?: DWClient;
268
+ }): Promise<DingTalkSendResult> {
269
+ const { sessionWebhook, text, useMarkdown, title, atUserId, client } = params;
270
+
271
+ // Auto-detect markdown
272
+ const hasMarkdown = /^[#*>-]|[*_`#\[\]]/.test(text) || text.includes("\n");
273
+ const shouldUseMarkdown = useMarkdown !== false && (useMarkdown || hasMarkdown);
274
+
275
+ let accessToken: string | undefined;
276
+ if (client) {
277
+ try {
278
+ accessToken = await client.getAccessToken();
279
+ } catch {
280
+ // Proceed without access token
281
+ }
282
+ }
283
+
284
+ if (shouldUseMarkdown) {
285
+ const markdownTitle =
286
+ title || text.split("\n")[0].replace(/^[#*\s\->]+/, "").slice(0, 20) || "Message";
287
+
288
+ const message: DingTalkMarkdownMessage = {
289
+ msgtype: "markdown",
290
+ markdown: {
291
+ title: markdownTitle,
292
+ text: atUserId ? `${text} @${atUserId}` : text,
293
+ },
294
+ };
295
+
296
+ if (atUserId) {
297
+ message.at = { atUserIds: [atUserId], isAtAll: false };
298
+ }
299
+
300
+ return sendViaWebhook({ sessionWebhook, message, accessToken });
301
+ }
302
+
303
+ // Plain text
304
+ const message: DingTalkTextMessage = {
305
+ msgtype: "text",
306
+ text: { content: text },
307
+ };
308
+
309
+ if (atUserId) {
310
+ message.at = { atUserIds: [atUserId], isAtAll: false };
311
+ }
312
+
313
+ return sendViaWebhook({ sessionWebhook, message, accessToken });
314
+ }
package/src/session.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Session Management for DingTalk
3
+ *
4
+ * Handles session timeout and new session commands.
5
+ * Provides session key generation for conversation persistence.
6
+ */
7
+
8
+ // ============ Types ============
9
+
10
+ export interface UserSession {
11
+ lastActivity: number;
12
+ sessionId: string; // Format: dingtalk:<senderId> or dingtalk:<senderId>:<timestamp>
13
+ }
14
+
15
+ interface Logger {
16
+ info?: (msg: string) => void;
17
+ warn?: (msg: string) => void;
18
+ error?: (msg: string) => void;
19
+ }
20
+
21
+ // ============ Constants ============
22
+
23
+ /** Commands that trigger a new session */
24
+ const NEW_SESSION_COMMANDS = ["/new", "/reset", "/clear", "新会话", "重新开始", "清空对话"];
25
+
26
+ /** Default session timeout: 30 minutes */
27
+ export const DEFAULT_SESSION_TIMEOUT = 1800000;
28
+
29
+ // ============ Session Storage ============
30
+
31
+ /** User session cache Map<senderId, UserSession> */
32
+ const userSessions = new Map<string, UserSession>();
33
+
34
+ // ============ Functions ============
35
+
36
+ /**
37
+ * Check if a message is a new session command.
38
+ */
39
+ export function isNewSessionCommand(text: string): boolean {
40
+ const trimmed = text.trim().toLowerCase();
41
+ return NEW_SESSION_COMMANDS.some((cmd) => trimmed === cmd.toLowerCase());
42
+ }
43
+
44
+ /**
45
+ * Get list of new session commands for display purposes.
46
+ */
47
+ export function getNewSessionCommands(): readonly string[] {
48
+ return NEW_SESSION_COMMANDS;
49
+ }
50
+
51
+ /**
52
+ * Get or create a session key for a user.
53
+ *
54
+ * @param senderId - The sender's ID
55
+ * @param forceNew - Force creation of a new session
56
+ * @param sessionTimeout - Session timeout in milliseconds
57
+ * @param log - Optional logger
58
+ * @returns Session key and whether it's a new session
59
+ */
60
+ export function getSessionKey(
61
+ senderId: string,
62
+ forceNew: boolean,
63
+ sessionTimeout: number,
64
+ log?: Logger,
65
+ ): { sessionKey: string; isNew: boolean } {
66
+ const now = Date.now();
67
+ const existing = userSessions.get(senderId);
68
+
69
+ // Force new session
70
+ if (forceNew) {
71
+ const sessionId = `dingtalk:${senderId}:${now}`;
72
+ userSessions.set(senderId, { lastActivity: now, sessionId });
73
+ log?.info?.(`[DingTalk][Session] User requested new session: ${senderId}`);
74
+ return { sessionKey: sessionId, isNew: true };
75
+ }
76
+
77
+ // Check timeout
78
+ if (existing) {
79
+ const elapsed = now - existing.lastActivity;
80
+ if (elapsed > sessionTimeout) {
81
+ const sessionId = `dingtalk:${senderId}:${now}`;
82
+ userSessions.set(senderId, { lastActivity: now, sessionId });
83
+ log?.info?.(
84
+ `[DingTalk][Session] Session timeout (${Math.round(elapsed / 60000)} min), auto new session: ${senderId}`,
85
+ );
86
+ return { sessionKey: sessionId, isNew: true };
87
+ }
88
+ // Update activity time
89
+ existing.lastActivity = now;
90
+ return { sessionKey: existing.sessionId, isNew: false };
91
+ }
92
+
93
+ // First session
94
+ const sessionId = `dingtalk:${senderId}`;
95
+ userSessions.set(senderId, { lastActivity: now, sessionId });
96
+ log?.info?.(`[DingTalk][Session] New user first session: ${senderId}`);
97
+ return { sessionKey: sessionId, isNew: false };
98
+ }
99
+
100
+ /**
101
+ * Clear a user's session.
102
+ */
103
+ export function clearSession(senderId: string): void {
104
+ userSessions.delete(senderId);
105
+ }
106
+
107
+ /**
108
+ * Get session info for a user (for debugging).
109
+ */
110
+ export function getSessionInfo(senderId: string): UserSession | undefined {
111
+ return userSessions.get(senderId);
112
+ }
113
+
114
+ /**
115
+ * Clear all sessions (for testing or reset).
116
+ */
117
+ export function clearAllSessions(): void {
118
+ userSessions.clear();
119
+ }
120
+
121
+ /**
122
+ * Get the number of active sessions.
123
+ */
124
+ export function getActiveSessionCount(): number {
125
+ return userSessions.size;
126
+ }
127
+
128
+ /**
129
+ * Clean up expired sessions.
130
+ * Call this periodically to prevent memory leaks.
131
+ */
132
+ export function cleanupExpiredSessions(sessionTimeout: number): number {
133
+ const now = Date.now();
134
+ let cleaned = 0;
135
+
136
+ for (const [senderId, session] of userSessions.entries()) {
137
+ if (now - session.lastActivity > sessionTimeout) {
138
+ userSessions.delete(senderId);
139
+ cleaned++;
140
+ }
141
+ }
142
+
143
+ return cleaned;
144
+ }