telegram-claude-mcp 2.0.1 → 2.0.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.
- package/bin/daemon-ctl.js +0 -0
- package/bin/daemon.js +0 -0
- package/bin/proxy.js +0 -0
- package/bin/setup.js +5 -1
- package/hooks/stop-hook.sh +2 -0
- package/hooks-v2/hooks-config-example.json +28 -0
- package/hooks-v2/progress-post-tool.sh +61 -0
- package/hooks-v2/progress-pre-tool.sh +53 -0
- package/hooks-v2/progress-stop.sh +34 -0
- package/hooks-v2/stop-hook.sh +2 -0
- package/package.json +1 -1
- package/src/daemon/index.ts +48 -1
- package/src/daemon/progress-display.ts +184 -0
- package/src/daemon/progress-tracker.ts +365 -0
- package/src/daemon/telegram-multi.ts +33 -0
- package/src/telegram.ts +3 -0
package/bin/daemon-ctl.js
CHANGED
|
File without changes
|
package/bin/daemon.js
CHANGED
|
File without changes
|
package/bin/proxy.js
CHANGED
|
File without changes
|
package/bin/setup.js
CHANGED
|
@@ -162,7 +162,11 @@ RESPONSE=$(curl -s -X POST "$HOOK_URL" \\
|
|
|
162
162
|
if [ $? -eq 0 ]; then
|
|
163
163
|
DECISION=$(echo "$RESPONSE" | jq -r '.decision // empty')
|
|
164
164
|
REASON=$(echo "$RESPONSE" | jq -r '.reason // empty')
|
|
165
|
-
[ "$DECISION" = "block" ] && [ -n "$REASON" ]
|
|
165
|
+
if [ "$DECISION" = "block" ] && [ -n "$REASON" ]; then
|
|
166
|
+
echo "$RESPONSE"
|
|
167
|
+
# Exit code 2 tells Claude Code to continue with the reason as instructions
|
|
168
|
+
exit 2
|
|
169
|
+
fi
|
|
166
170
|
fi
|
|
167
171
|
`;
|
|
168
172
|
|
package/hooks/stop-hook.sh
CHANGED
|
@@ -108,6 +108,8 @@ REASON=$(echo "$RESPONSE" | jq -r '.reason // empty')
|
|
|
108
108
|
if [ "$DECISION" = "block" ] && [ -n "$REASON" ]; then
|
|
109
109
|
# User provided instructions - continue with them
|
|
110
110
|
echo "$RESPONSE"
|
|
111
|
+
# Exit code 2 tells Claude Code to continue with the reason as instructions
|
|
112
|
+
exit 2
|
|
111
113
|
else
|
|
112
114
|
# User said done or no response - allow stop
|
|
113
115
|
exit 0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"_comment": "Example Claude Code hooks configuration for progress tracking. Copy relevant sections to ~/.claude/settings.json or .claude/settings.json in your project.",
|
|
4
|
+
|
|
5
|
+
"hooks": {
|
|
6
|
+
"PreToolUse": [
|
|
7
|
+
{
|
|
8
|
+
"matcher": "*",
|
|
9
|
+
"command": "SESSION_NAME=${SESSION_NAME:-$(basename \"$PWD\")} /path/to/call-me/server/hooks-v2/progress-pre-tool.sh"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"PostToolUse": [
|
|
13
|
+
{
|
|
14
|
+
"matcher": "*",
|
|
15
|
+
"command": "SESSION_NAME=${SESSION_NAME:-$(basename \"$PWD\")} /path/to/call-me/server/hooks-v2/progress-post-tool.sh"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"Stop": [
|
|
19
|
+
{
|
|
20
|
+
"command": "SESSION_NAME=${SESSION_NAME:-$(basename \"$PWD\")} /path/to/call-me/server/hooks-v2/progress-stop.sh"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"_comment": "Optional: Interactive stop hook (waits for user response)",
|
|
24
|
+
"command": "SESSION_NAME=${SESSION_NAME:-$(basename \"$PWD\")} /path/to/call-me/server/hooks-v2/stop-hook.sh"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse progress hook for telegram-claude-daemon
|
|
3
|
+
# Sends tool completion events to update progress display
|
|
4
|
+
#
|
|
5
|
+
# This hook is fire-and-forget - it doesn't block Claude's execution
|
|
6
|
+
|
|
7
|
+
DAEMON_PORT="${TELEGRAM_CLAUDE_PORT:-3333}"
|
|
8
|
+
DAEMON_HOST="${TELEGRAM_CLAUDE_HOST:-localhost}"
|
|
9
|
+
HOOK_URL="http://${DAEMON_HOST}:${DAEMON_PORT}/progress"
|
|
10
|
+
|
|
11
|
+
# Read input from stdin
|
|
12
|
+
INPUT=$(cat)
|
|
13
|
+
|
|
14
|
+
# Extract tool info
|
|
15
|
+
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // .toolName // .name // empty')
|
|
16
|
+
TOOL_RESULT=$(echo "$INPUT" | jq -c '.tool_result // .result // {}')
|
|
17
|
+
|
|
18
|
+
# Check if tool succeeded
|
|
19
|
+
IS_ERROR=$(echo "$INPUT" | jq -r '.is_error // .isError // false')
|
|
20
|
+
if [ "$IS_ERROR" = "true" ]; then
|
|
21
|
+
SUCCESS="false"
|
|
22
|
+
else
|
|
23
|
+
SUCCESS="true"
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
# Skip if no tool name
|
|
27
|
+
if [ -z "$TOOL_NAME" ]; then
|
|
28
|
+
exit 0
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# Skip progress updates for Telegram tools (would be recursive)
|
|
32
|
+
case "$TOOL_NAME" in
|
|
33
|
+
mcp__telegram__*|send_message|continue_chat|notify_user|end_chat)
|
|
34
|
+
exit 0
|
|
35
|
+
;;
|
|
36
|
+
esac
|
|
37
|
+
|
|
38
|
+
# Build payload
|
|
39
|
+
PAYLOAD=$(jq -n \
|
|
40
|
+
--arg type "post_tool" \
|
|
41
|
+
--arg session_name "${SESSION_NAME:-default}" \
|
|
42
|
+
--arg tool_name "$TOOL_NAME" \
|
|
43
|
+
--argjson success "$SUCCESS" \
|
|
44
|
+
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
45
|
+
'{
|
|
46
|
+
type: $type,
|
|
47
|
+
session_name: $session_name,
|
|
48
|
+
tool_name: $tool_name,
|
|
49
|
+
success: $success,
|
|
50
|
+
timestamp: $timestamp
|
|
51
|
+
}')
|
|
52
|
+
|
|
53
|
+
# Fire and forget - send in background and don't wait
|
|
54
|
+
(curl -s -X POST "$HOOK_URL" \
|
|
55
|
+
-H "Content-Type: application/json" \
|
|
56
|
+
-H "X-Session-Name: ${SESSION_NAME:-default}" \
|
|
57
|
+
-d "$PAYLOAD" \
|
|
58
|
+
--max-time 2 2>/dev/null) &
|
|
59
|
+
|
|
60
|
+
# Exit immediately - don't block Claude
|
|
61
|
+
exit 0
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse progress hook for telegram-claude-daemon
|
|
3
|
+
# Sends tool start events to update progress display
|
|
4
|
+
#
|
|
5
|
+
# This hook is fire-and-forget - it doesn't block Claude's execution
|
|
6
|
+
|
|
7
|
+
DAEMON_PORT="${TELEGRAM_CLAUDE_PORT:-3333}"
|
|
8
|
+
DAEMON_HOST="${TELEGRAM_CLAUDE_HOST:-localhost}"
|
|
9
|
+
HOOK_URL="http://${DAEMON_HOST}:${DAEMON_PORT}/progress"
|
|
10
|
+
|
|
11
|
+
# Read input from stdin
|
|
12
|
+
INPUT=$(cat)
|
|
13
|
+
|
|
14
|
+
# Extract tool info
|
|
15
|
+
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // .toolName // .name // empty')
|
|
16
|
+
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // .toolInput // .input // .arguments // {}')
|
|
17
|
+
|
|
18
|
+
# Skip if no tool name
|
|
19
|
+
if [ -z "$TOOL_NAME" ]; then
|
|
20
|
+
exit 0
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
# Skip progress updates for Telegram tools (would be recursive)
|
|
24
|
+
case "$TOOL_NAME" in
|
|
25
|
+
mcp__telegram__*|send_message|continue_chat|notify_user|end_chat)
|
|
26
|
+
exit 0
|
|
27
|
+
;;
|
|
28
|
+
esac
|
|
29
|
+
|
|
30
|
+
# Build payload
|
|
31
|
+
PAYLOAD=$(jq -n \
|
|
32
|
+
--arg type "pre_tool" \
|
|
33
|
+
--arg session_name "${SESSION_NAME:-default}" \
|
|
34
|
+
--arg tool_name "$TOOL_NAME" \
|
|
35
|
+
--argjson tool_input "$TOOL_INPUT" \
|
|
36
|
+
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
37
|
+
'{
|
|
38
|
+
type: $type,
|
|
39
|
+
session_name: $session_name,
|
|
40
|
+
tool_name: $tool_name,
|
|
41
|
+
tool_input: $tool_input,
|
|
42
|
+
timestamp: $timestamp
|
|
43
|
+
}')
|
|
44
|
+
|
|
45
|
+
# Fire and forget - send in background and don't wait
|
|
46
|
+
(curl -s -X POST "$HOOK_URL" \
|
|
47
|
+
-H "Content-Type: application/json" \
|
|
48
|
+
-H "X-Session-Name: ${SESSION_NAME:-default}" \
|
|
49
|
+
-d "$PAYLOAD" \
|
|
50
|
+
--max-time 2 2>/dev/null) &
|
|
51
|
+
|
|
52
|
+
# Exit immediately - don't block Claude
|
|
53
|
+
exit 0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Stop progress hook for telegram-claude-daemon
|
|
3
|
+
# Sends stop events to finalize progress display
|
|
4
|
+
#
|
|
5
|
+
# This hook is fire-and-forget - it doesn't block Claude's execution
|
|
6
|
+
# Note: This is separate from stop-hook.sh which handles interactive stop
|
|
7
|
+
|
|
8
|
+
DAEMON_PORT="${TELEGRAM_CLAUDE_PORT:-3333}"
|
|
9
|
+
DAEMON_HOST="${TELEGRAM_CLAUDE_HOST:-localhost}"
|
|
10
|
+
HOOK_URL="http://${DAEMON_HOST}:${DAEMON_PORT}/progress"
|
|
11
|
+
|
|
12
|
+
# Read input from stdin (if any)
|
|
13
|
+
INPUT=$(cat)
|
|
14
|
+
|
|
15
|
+
# Build payload
|
|
16
|
+
PAYLOAD=$(jq -n \
|
|
17
|
+
--arg type "stop" \
|
|
18
|
+
--arg session_name "${SESSION_NAME:-default}" \
|
|
19
|
+
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
20
|
+
'{
|
|
21
|
+
type: $type,
|
|
22
|
+
session_name: $session_name,
|
|
23
|
+
timestamp: $timestamp
|
|
24
|
+
}')
|
|
25
|
+
|
|
26
|
+
# Fire and forget - send in background and don't wait
|
|
27
|
+
(curl -s -X POST "$HOOK_URL" \
|
|
28
|
+
-H "Content-Type: application/json" \
|
|
29
|
+
-H "X-Session-Name: ${SESSION_NAME:-default}" \
|
|
30
|
+
-d "$PAYLOAD" \
|
|
31
|
+
--max-time 2 2>/dev/null) &
|
|
32
|
+
|
|
33
|
+
# Exit immediately - don't block Claude
|
|
34
|
+
exit 0
|
package/hooks-v2/stop-hook.sh
CHANGED
|
@@ -41,5 +41,7 @@ if [ $? -eq 0 ] && [ -n "$RESPONSE" ]; then
|
|
|
41
41
|
DECISION=$(echo "$RESPONSE" | jq -r '.decision // empty')
|
|
42
42
|
if [ "$DECISION" = "block" ]; then
|
|
43
43
|
echo "$RESPONSE"
|
|
44
|
+
# Exit code 2 tells Claude Code to continue with the reason as instructions
|
|
45
|
+
exit 2
|
|
44
46
|
fi
|
|
45
47
|
fi
|
package/package.json
CHANGED
package/src/daemon/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { existsSync, unlinkSync, writeFileSync, mkdirSync } from 'fs';
|
|
|
13
13
|
import { dirname } from 'path';
|
|
14
14
|
import { SessionManager } from './session-manager.js';
|
|
15
15
|
import { MultiTelegramManager, type HookEvent } from './telegram-multi.js';
|
|
16
|
+
import { ProgressTracker, type ProgressEvent } from './progress-tracker.js';
|
|
16
17
|
import {
|
|
17
18
|
DAEMON_SOCKET_PATH,
|
|
18
19
|
DAEMON_PID_FILE,
|
|
@@ -96,6 +97,16 @@ async function main() {
|
|
|
96
97
|
|
|
97
98
|
telegram.start();
|
|
98
99
|
|
|
100
|
+
// Create progress tracker
|
|
101
|
+
const progressTracker = new ProgressTracker(
|
|
102
|
+
telegram.getBot(),
|
|
103
|
+
telegram.getChatId(),
|
|
104
|
+
sessionManager
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Register progress tracker's callback handler for mute buttons
|
|
108
|
+
telegram.registerCallbackInterceptor((query) => progressTracker.handleCallback(query));
|
|
109
|
+
|
|
99
110
|
// Track socket buffers for each connection
|
|
100
111
|
const socketBuffers = new Map<net.Socket, string>();
|
|
101
112
|
|
|
@@ -223,6 +234,41 @@ async function main() {
|
|
|
223
234
|
return;
|
|
224
235
|
}
|
|
225
236
|
|
|
237
|
+
// Progress tracking endpoint
|
|
238
|
+
if (url === '/progress' || url === '/hooks/progress') {
|
|
239
|
+
const event = data as ProgressEvent;
|
|
240
|
+
|
|
241
|
+
console.error(`[HTTP] Progress: ${event.type} - ${event.tool_name || ''} (session: ${sessionName})`);
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
switch (event.type) {
|
|
245
|
+
case 'start':
|
|
246
|
+
await progressTracker.handleSessionStart(sessionName);
|
|
247
|
+
break;
|
|
248
|
+
case 'pre_tool':
|
|
249
|
+
await progressTracker.handlePreTool(sessionName, event.tool_name!, event.tool_input);
|
|
250
|
+
break;
|
|
251
|
+
case 'post_tool':
|
|
252
|
+
await progressTracker.handlePostTool(sessionName, event.tool_name!, event.success ?? true);
|
|
253
|
+
break;
|
|
254
|
+
case 'stop':
|
|
255
|
+
await progressTracker.handleStop(sessionName);
|
|
256
|
+
break;
|
|
257
|
+
case 'notification':
|
|
258
|
+
await progressTracker.handleNotification(sessionName, event.tool_name || '');
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
263
|
+
res.end(JSON.stringify({ ok: true }));
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error('[HTTP] Progress error:', error);
|
|
266
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
267
|
+
res.end(JSON.stringify({ ok: true })); // Don't fail hooks
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
226
272
|
// Status endpoint
|
|
227
273
|
if (url === '/status') {
|
|
228
274
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -284,9 +330,10 @@ async function main() {
|
|
|
284
330
|
process.on('SIGINT', shutdown);
|
|
285
331
|
process.on('SIGTERM', shutdown);
|
|
286
332
|
|
|
287
|
-
// Periodic cleanup of dead sessions
|
|
333
|
+
// Periodic cleanup of dead sessions and progress state
|
|
288
334
|
setInterval(() => {
|
|
289
335
|
sessionManager.cleanup();
|
|
336
|
+
progressTracker.cleanup();
|
|
290
337
|
}, 30000);
|
|
291
338
|
}
|
|
292
339
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress Display Utilities
|
|
3
|
+
*
|
|
4
|
+
* Visual rendering for progress tracking in Telegram messages.
|
|
5
|
+
* Inspired by DnD-Books telegram-bot ProgressIndicator.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export enum ProgressStage {
|
|
9
|
+
IDLE = 'idle',
|
|
10
|
+
THINKING = 'thinking',
|
|
11
|
+
TOOL_QUEUED = 'queued',
|
|
12
|
+
TOOL_RUNNING = 'running',
|
|
13
|
+
TOOL_COMPLETE = 'complete',
|
|
14
|
+
WAITING_USER = 'waiting',
|
|
15
|
+
FINISHED = 'finished',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface StageDisplay {
|
|
19
|
+
emoji: string;
|
|
20
|
+
label: string;
|
|
21
|
+
progress: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const stageDisplay: Record<ProgressStage, StageDisplay> = {
|
|
25
|
+
[ProgressStage.IDLE]: { emoji: '💤', label: 'Idle', progress: 0 },
|
|
26
|
+
[ProgressStage.THINKING]: { emoji: '🧠', label: 'Thinking', progress: 10 },
|
|
27
|
+
[ProgressStage.TOOL_QUEUED]: { emoji: '📋', label: 'Tool queued', progress: 20 },
|
|
28
|
+
[ProgressStage.TOOL_RUNNING]: { emoji: '🔧', label: 'Executing', progress: 50 },
|
|
29
|
+
[ProgressStage.TOOL_COMPLETE]: { emoji: '✅', label: 'Tool done', progress: 80 },
|
|
30
|
+
[ProgressStage.WAITING_USER]: { emoji: '💬', label: 'Waiting for you', progress: 90 },
|
|
31
|
+
[ProgressStage.FINISHED]: { emoji: '🏁', label: 'Finished', progress: 100 },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a visual progress bar using block characters.
|
|
36
|
+
*/
|
|
37
|
+
export function buildProgressBar(percent: number): string {
|
|
38
|
+
const total = 10;
|
|
39
|
+
const filled = Math.floor(percent / 10);
|
|
40
|
+
const empty = total - filled;
|
|
41
|
+
|
|
42
|
+
const filledChar = '▓';
|
|
43
|
+
const emptyChar = '░';
|
|
44
|
+
|
|
45
|
+
return `[${filledChar.repeat(filled)}${emptyChar.repeat(empty)}] ${percent}%`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Format tool name for display.
|
|
50
|
+
*/
|
|
51
|
+
export function formatToolName(toolName: string): string {
|
|
52
|
+
// Shorten common tool names
|
|
53
|
+
const shortNames: Record<string, string> = {
|
|
54
|
+
'Read': 'Read',
|
|
55
|
+
'Write': 'Write',
|
|
56
|
+
'Edit': 'Edit',
|
|
57
|
+
'Bash': 'Bash',
|
|
58
|
+
'Glob': 'Glob',
|
|
59
|
+
'Grep': 'Grep',
|
|
60
|
+
'Task': 'Task',
|
|
61
|
+
'WebFetch': 'Web',
|
|
62
|
+
'WebSearch': 'Search',
|
|
63
|
+
'TodoWrite': 'Todo',
|
|
64
|
+
'AskUserQuestion': 'Ask',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return shortNames[toolName] || toolName;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Format elapsed time for display.
|
|
72
|
+
*/
|
|
73
|
+
export function formatElapsedTime(startTime: number): string {
|
|
74
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
75
|
+
|
|
76
|
+
if (elapsed < 60) {
|
|
77
|
+
return `${elapsed}s`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const minutes = Math.floor(elapsed / 60);
|
|
81
|
+
const seconds = elapsed % 60;
|
|
82
|
+
return `${minutes}m ${seconds}s`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build tool statistics summary.
|
|
87
|
+
*/
|
|
88
|
+
export function buildToolStats(toolCounts: Map<string, number>): string {
|
|
89
|
+
if (toolCounts.size === 0) return '';
|
|
90
|
+
|
|
91
|
+
const entries = Array.from(toolCounts.entries())
|
|
92
|
+
.sort((a, b) => b[1] - a[1])
|
|
93
|
+
.slice(0, 5) // Top 5 tools
|
|
94
|
+
.map(([tool, count]) => `${formatToolName(tool)} (${count})`)
|
|
95
|
+
.join(', ');
|
|
96
|
+
|
|
97
|
+
return entries;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ProgressMessageOptions {
|
|
101
|
+
sessionName: string;
|
|
102
|
+
stage: ProgressStage;
|
|
103
|
+
currentTool: string | null;
|
|
104
|
+
toolStack: string[];
|
|
105
|
+
startTime: number;
|
|
106
|
+
toolsExecuted: number;
|
|
107
|
+
toolCounts: Map<string, number>;
|
|
108
|
+
showDetails?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the complete progress message text.
|
|
113
|
+
*/
|
|
114
|
+
export function buildProgressMessage(options: ProgressMessageOptions): string {
|
|
115
|
+
const {
|
|
116
|
+
sessionName,
|
|
117
|
+
stage,
|
|
118
|
+
currentTool,
|
|
119
|
+
toolStack,
|
|
120
|
+
startTime,
|
|
121
|
+
toolsExecuted,
|
|
122
|
+
toolCounts,
|
|
123
|
+
showDetails = true,
|
|
124
|
+
} = options;
|
|
125
|
+
|
|
126
|
+
const display = stageDisplay[stage];
|
|
127
|
+
const elapsed = formatElapsedTime(startTime);
|
|
128
|
+
|
|
129
|
+
let text = `[${sessionName}] Working...\n\n`;
|
|
130
|
+
|
|
131
|
+
// Current stage with emoji
|
|
132
|
+
text += `${display.emoji} *${display.label}*`;
|
|
133
|
+
|
|
134
|
+
// Current tool (if any)
|
|
135
|
+
if (currentTool) {
|
|
136
|
+
const toolDisplay = toolStack.length > 1
|
|
137
|
+
? toolStack.map(formatToolName).join(' > ')
|
|
138
|
+
: formatToolName(currentTool);
|
|
139
|
+
text += `: \`${toolDisplay}\``;
|
|
140
|
+
}
|
|
141
|
+
text += '\n\n';
|
|
142
|
+
|
|
143
|
+
// Progress bar
|
|
144
|
+
text += buildProgressBar(display.progress) + '\n\n';
|
|
145
|
+
|
|
146
|
+
// Stats line
|
|
147
|
+
text += `⏱ ${elapsed}`;
|
|
148
|
+
if (toolsExecuted > 0) {
|
|
149
|
+
text += ` | Tools: ${toolsExecuted}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Detailed tool breakdown (for finished state)
|
|
153
|
+
if (showDetails && stage === ProgressStage.FINISHED && toolCounts.size > 0) {
|
|
154
|
+
text += '\n\n---\n';
|
|
155
|
+
text += `Tools used: ${buildToolStats(toolCounts)}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return text;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Build a completion message.
|
|
163
|
+
*/
|
|
164
|
+
export function buildCompletionMessage(options: {
|
|
165
|
+
sessionName: string;
|
|
166
|
+
startTime: number;
|
|
167
|
+
toolsExecuted: number;
|
|
168
|
+
toolCounts: Map<string, number>;
|
|
169
|
+
}): string {
|
|
170
|
+
const { sessionName, startTime, toolsExecuted, toolCounts } = options;
|
|
171
|
+
const elapsed = formatElapsedTime(startTime);
|
|
172
|
+
|
|
173
|
+
let text = `[${sessionName}] Task Complete\n\n`;
|
|
174
|
+
text += `🏁 *Finished*\n\n`;
|
|
175
|
+
text += buildProgressBar(100) + '\n\n';
|
|
176
|
+
text += `⏱ ${elapsed} | Tools: ${toolsExecuted}`;
|
|
177
|
+
|
|
178
|
+
if (toolCounts.size > 0) {
|
|
179
|
+
text += '\n\n---\n';
|
|
180
|
+
text += `Tools used: ${buildToolStats(toolCounts)}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return text;
|
|
184
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress Tracker
|
|
3
|
+
*
|
|
4
|
+
* Manages per-session progress state and updates Telegram messages
|
|
5
|
+
* in-place using editMessageText.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import TelegramBot from 'node-telegram-bot-api';
|
|
9
|
+
import type { SessionManager } from './session-manager.js';
|
|
10
|
+
import {
|
|
11
|
+
ProgressStage,
|
|
12
|
+
buildProgressMessage,
|
|
13
|
+
buildCompletionMessage,
|
|
14
|
+
} from './progress-display.js';
|
|
15
|
+
|
|
16
|
+
export interface SessionProgress {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
sessionName: string;
|
|
19
|
+
|
|
20
|
+
// Message tracking
|
|
21
|
+
progressMessageId: number | null;
|
|
22
|
+
lastUpdateTime: number;
|
|
23
|
+
|
|
24
|
+
// Progress state
|
|
25
|
+
stage: ProgressStage;
|
|
26
|
+
currentTool: string | null;
|
|
27
|
+
toolStack: string[];
|
|
28
|
+
|
|
29
|
+
// Statistics
|
|
30
|
+
startTime: number;
|
|
31
|
+
toolsExecuted: number;
|
|
32
|
+
toolCounts: Map<string, number>;
|
|
33
|
+
|
|
34
|
+
// Settings
|
|
35
|
+
isActive: boolean;
|
|
36
|
+
showProgress: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProgressEvent {
|
|
40
|
+
type: 'pre_tool' | 'post_tool' | 'notification' | 'stop' | 'start';
|
|
41
|
+
session_name: string;
|
|
42
|
+
timestamp: string;
|
|
43
|
+
tool_name?: string;
|
|
44
|
+
tool_input?: Record<string, unknown>;
|
|
45
|
+
success?: boolean;
|
|
46
|
+
error?: string;
|
|
47
|
+
transcript_path?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ProgressTracker {
|
|
51
|
+
private sessions: Map<string, SessionProgress> = new Map();
|
|
52
|
+
private bot: TelegramBot;
|
|
53
|
+
private chatId: number;
|
|
54
|
+
private sessionManager: SessionManager;
|
|
55
|
+
|
|
56
|
+
// Rate limiting
|
|
57
|
+
private lastUpdate: Map<string, number> = new Map();
|
|
58
|
+
private pendingUpdates: Map<string, NodeJS.Timeout> = new Map();
|
|
59
|
+
private readonly MIN_UPDATE_INTERVAL = 1000; // 1 second minimum between updates
|
|
60
|
+
|
|
61
|
+
constructor(bot: TelegramBot, chatId: number, sessionManager: SessionManager) {
|
|
62
|
+
this.bot = bot;
|
|
63
|
+
this.chatId = chatId;
|
|
64
|
+
this.sessionManager = sessionManager;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get or create progress state for a session.
|
|
69
|
+
*/
|
|
70
|
+
private getOrCreateProgress(sessionName: string): SessionProgress {
|
|
71
|
+
let progress = this.sessions.get(sessionName);
|
|
72
|
+
|
|
73
|
+
if (!progress) {
|
|
74
|
+
const session = this.sessionManager.getByName(sessionName);
|
|
75
|
+
progress = {
|
|
76
|
+
sessionId: session?.sessionId || sessionName,
|
|
77
|
+
sessionName,
|
|
78
|
+
progressMessageId: null,
|
|
79
|
+
lastUpdateTime: 0,
|
|
80
|
+
stage: ProgressStage.IDLE,
|
|
81
|
+
currentTool: null,
|
|
82
|
+
toolStack: [],
|
|
83
|
+
startTime: Date.now(),
|
|
84
|
+
toolsExecuted: 0,
|
|
85
|
+
toolCounts: new Map(),
|
|
86
|
+
isActive: true,
|
|
87
|
+
showProgress: true,
|
|
88
|
+
};
|
|
89
|
+
this.sessions.set(sessionName, progress);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return progress;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Handle session start event.
|
|
97
|
+
*/
|
|
98
|
+
async handleSessionStart(sessionName: string): Promise<void> {
|
|
99
|
+
const progress = this.getOrCreateProgress(sessionName);
|
|
100
|
+
progress.startTime = Date.now();
|
|
101
|
+
progress.stage = ProgressStage.THINKING;
|
|
102
|
+
progress.isActive = true;
|
|
103
|
+
progress.toolsExecuted = 0;
|
|
104
|
+
progress.toolCounts.clear();
|
|
105
|
+
progress.toolStack = [];
|
|
106
|
+
progress.currentTool = null;
|
|
107
|
+
|
|
108
|
+
await this.sendOrUpdateProgressMessage(progress);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Handle PreToolUse hook event.
|
|
113
|
+
*/
|
|
114
|
+
async handlePreTool(sessionName: string, toolName: string, toolInput?: Record<string, unknown>): Promise<void> {
|
|
115
|
+
const progress = this.getOrCreateProgress(sessionName);
|
|
116
|
+
|
|
117
|
+
// Reset start time if this is the first tool
|
|
118
|
+
if (progress.stage === ProgressStage.IDLE) {
|
|
119
|
+
progress.startTime = Date.now();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Push tool onto stack (for nested tool calls)
|
|
123
|
+
progress.toolStack.push(toolName);
|
|
124
|
+
progress.currentTool = toolName;
|
|
125
|
+
progress.stage = ProgressStage.TOOL_RUNNING;
|
|
126
|
+
progress.isActive = true;
|
|
127
|
+
|
|
128
|
+
await this.sendOrUpdateProgressMessage(progress);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Handle PostToolUse hook event.
|
|
133
|
+
*/
|
|
134
|
+
async handlePostTool(sessionName: string, toolName: string, success: boolean = true): Promise<void> {
|
|
135
|
+
const progress = this.getOrCreateProgress(sessionName);
|
|
136
|
+
|
|
137
|
+
// Pop tool from stack
|
|
138
|
+
const poppedTool = progress.toolStack.pop();
|
|
139
|
+
|
|
140
|
+
// Update statistics
|
|
141
|
+
progress.toolsExecuted++;
|
|
142
|
+
const currentCount = progress.toolCounts.get(toolName) || 0;
|
|
143
|
+
progress.toolCounts.set(toolName, currentCount + 1);
|
|
144
|
+
|
|
145
|
+
// Update state
|
|
146
|
+
if (progress.toolStack.length > 0) {
|
|
147
|
+
// Still have parent tools running
|
|
148
|
+
progress.currentTool = progress.toolStack[progress.toolStack.length - 1];
|
|
149
|
+
progress.stage = ProgressStage.TOOL_RUNNING;
|
|
150
|
+
} else {
|
|
151
|
+
// All tools complete
|
|
152
|
+
progress.currentTool = null;
|
|
153
|
+
progress.stage = ProgressStage.TOOL_COMPLETE;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await this.sendOrUpdateProgressMessage(progress);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Handle Stop hook event.
|
|
161
|
+
*/
|
|
162
|
+
async handleStop(sessionName: string): Promise<void> {
|
|
163
|
+
const progress = this.sessions.get(sessionName);
|
|
164
|
+
|
|
165
|
+
if (!progress) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
progress.stage = ProgressStage.FINISHED;
|
|
170
|
+
progress.currentTool = null;
|
|
171
|
+
progress.toolStack = [];
|
|
172
|
+
progress.isActive = false;
|
|
173
|
+
|
|
174
|
+
// Send final completion message
|
|
175
|
+
await this.sendCompletionMessage(progress);
|
|
176
|
+
|
|
177
|
+
// Clean up pending updates
|
|
178
|
+
const pendingTimeout = this.pendingUpdates.get(sessionName);
|
|
179
|
+
if (pendingTimeout) {
|
|
180
|
+
clearTimeout(pendingTimeout);
|
|
181
|
+
this.pendingUpdates.delete(sessionName);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Handle notification event.
|
|
187
|
+
*/
|
|
188
|
+
async handleNotification(sessionName: string, message: string): Promise<void> {
|
|
189
|
+
const progress = this.getOrCreateProgress(sessionName);
|
|
190
|
+
|
|
191
|
+
// Just update the timestamp, don't change stage
|
|
192
|
+
progress.lastUpdateTime = Date.now();
|
|
193
|
+
|
|
194
|
+
// Optionally could update the message with notification info
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Toggle progress display for a session.
|
|
199
|
+
*/
|
|
200
|
+
setShowProgress(sessionName: string, show: boolean): void {
|
|
201
|
+
const progress = this.sessions.get(sessionName);
|
|
202
|
+
if (progress) {
|
|
203
|
+
progress.showProgress = show;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Send or update the progress message with rate limiting.
|
|
209
|
+
*/
|
|
210
|
+
private async sendOrUpdateProgressMessage(progress: SessionProgress): Promise<void> {
|
|
211
|
+
if (!progress.showProgress) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
const lastTime = this.lastUpdate.get(progress.sessionName) || 0;
|
|
217
|
+
|
|
218
|
+
if (now - lastTime < this.MIN_UPDATE_INTERVAL) {
|
|
219
|
+
// Schedule update for later if not already scheduled
|
|
220
|
+
if (!this.pendingUpdates.has(progress.sessionName)) {
|
|
221
|
+
const timeout = setTimeout(async () => {
|
|
222
|
+
this.pendingUpdates.delete(progress.sessionName);
|
|
223
|
+
await this.doUpdateProgressMessage(progress);
|
|
224
|
+
}, this.MIN_UPDATE_INTERVAL - (now - lastTime));
|
|
225
|
+
|
|
226
|
+
this.pendingUpdates.set(progress.sessionName, timeout);
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
await this.doUpdateProgressMessage(progress);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Actually send or update the progress message.
|
|
236
|
+
*/
|
|
237
|
+
private async doUpdateProgressMessage(progress: SessionProgress): Promise<void> {
|
|
238
|
+
this.lastUpdate.set(progress.sessionName, Date.now());
|
|
239
|
+
|
|
240
|
+
const messageText = buildProgressMessage({
|
|
241
|
+
sessionName: progress.sessionName,
|
|
242
|
+
stage: progress.stage,
|
|
243
|
+
currentTool: progress.currentTool,
|
|
244
|
+
toolStack: progress.toolStack,
|
|
245
|
+
startTime: progress.startTime,
|
|
246
|
+
toolsExecuted: progress.toolsExecuted,
|
|
247
|
+
toolCounts: progress.toolCounts,
|
|
248
|
+
showDetails: false,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
if (progress.progressMessageId) {
|
|
253
|
+
// Edit existing message
|
|
254
|
+
await this.bot.editMessageText(messageText, {
|
|
255
|
+
chat_id: this.chatId,
|
|
256
|
+
message_id: progress.progressMessageId,
|
|
257
|
+
parse_mode: 'Markdown',
|
|
258
|
+
});
|
|
259
|
+
} else {
|
|
260
|
+
// Send new message with mute button
|
|
261
|
+
const sent = await this.bot.sendMessage(this.chatId, messageText, {
|
|
262
|
+
parse_mode: 'Markdown',
|
|
263
|
+
reply_markup: {
|
|
264
|
+
inline_keyboard: [[
|
|
265
|
+
{ text: '🔕 Mute', callback_data: `progress_mute:${progress.sessionName}` },
|
|
266
|
+
]],
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
progress.progressMessageId = sent.message_id;
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// Message might have been deleted or content unchanged
|
|
273
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
274
|
+
|
|
275
|
+
// If message was deleted or not found, create a new one
|
|
276
|
+
if (errorMessage.includes('message to edit not found') ||
|
|
277
|
+
errorMessage.includes('message is not modified')) {
|
|
278
|
+
// Content unchanged is fine, just skip
|
|
279
|
+
if (!errorMessage.includes('message is not modified')) {
|
|
280
|
+
progress.progressMessageId = null;
|
|
281
|
+
// Don't retry immediately to avoid spam
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
console.error('[ProgressTracker] Error updating message:', errorMessage);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Send final completion message.
|
|
291
|
+
*/
|
|
292
|
+
private async sendCompletionMessage(progress: SessionProgress): Promise<void> {
|
|
293
|
+
const messageText = buildCompletionMessage({
|
|
294
|
+
sessionName: progress.sessionName,
|
|
295
|
+
startTime: progress.startTime,
|
|
296
|
+
toolsExecuted: progress.toolsExecuted,
|
|
297
|
+
toolCounts: progress.toolCounts,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
if (progress.progressMessageId) {
|
|
302
|
+
// Edit existing message to show completion
|
|
303
|
+
await this.bot.editMessageText(messageText, {
|
|
304
|
+
chat_id: this.chatId,
|
|
305
|
+
message_id: progress.progressMessageId,
|
|
306
|
+
parse_mode: 'Markdown',
|
|
307
|
+
reply_markup: { inline_keyboard: [] }, // Remove mute button
|
|
308
|
+
});
|
|
309
|
+
} else {
|
|
310
|
+
// Send new completion message
|
|
311
|
+
await this.bot.sendMessage(this.chatId, messageText, {
|
|
312
|
+
parse_mode: 'Markdown',
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
317
|
+
if (!errorMessage.includes('message is not modified')) {
|
|
318
|
+
console.error('[ProgressTracker] Error sending completion:', errorMessage);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Clean up session progress
|
|
323
|
+
this.sessions.delete(progress.sessionName);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Handle callback query for mute button.
|
|
328
|
+
*/
|
|
329
|
+
async handleCallback(query: TelegramBot.CallbackQuery): Promise<boolean> {
|
|
330
|
+
if (!query.data?.startsWith('progress_mute:')) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const sessionName = query.data.replace('progress_mute:', '');
|
|
335
|
+
this.setShowProgress(sessionName, false);
|
|
336
|
+
|
|
337
|
+
// Remove the progress message
|
|
338
|
+
const progress = this.sessions.get(sessionName);
|
|
339
|
+
if (progress?.progressMessageId && query.message) {
|
|
340
|
+
try {
|
|
341
|
+
await this.bot.deleteMessage(this.chatId, progress.progressMessageId);
|
|
342
|
+
} catch {
|
|
343
|
+
// Ignore deletion errors
|
|
344
|
+
}
|
|
345
|
+
progress.progressMessageId = null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await this.bot.answerCallbackQuery(query.id, { text: 'Progress muted' });
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Clean up inactive sessions.
|
|
354
|
+
*/
|
|
355
|
+
cleanup(): void {
|
|
356
|
+
const now = Date.now();
|
|
357
|
+
const maxAge = 30 * 60 * 1000; // 30 minutes
|
|
358
|
+
|
|
359
|
+
for (const [sessionName, progress] of this.sessions) {
|
|
360
|
+
if (!progress.isActive && now - progress.lastUpdateTime > maxAge) {
|
|
361
|
+
this.sessions.delete(sessionName);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -54,6 +54,7 @@ export class MultiTelegramManager {
|
|
|
54
54
|
private pendingPermissions: Map<string, PendingPermission> = new Map();
|
|
55
55
|
private messageToSession: Map<number, string> = new Map(); // messageId -> sessionId
|
|
56
56
|
private isRunning = false;
|
|
57
|
+
private callbackInterceptors: Array<(query: TelegramBot.CallbackQuery) => Promise<boolean>> = [];
|
|
57
58
|
|
|
58
59
|
constructor(config: MultiTelegramConfig, sessionManager: SessionManager) {
|
|
59
60
|
this.config = {
|
|
@@ -68,6 +69,28 @@ export class MultiTelegramManager {
|
|
|
68
69
|
this.setupCallbackHandler();
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Get the underlying bot instance for advanced operations.
|
|
74
|
+
*/
|
|
75
|
+
getBot(): TelegramBot {
|
|
76
|
+
return this.bot;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get the configured chat ID.
|
|
81
|
+
*/
|
|
82
|
+
getChatId(): number {
|
|
83
|
+
return this.config.chatId;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Register a callback interceptor that handles callback queries before the default handler.
|
|
88
|
+
* Return true to indicate the query was handled and stop further processing.
|
|
89
|
+
*/
|
|
90
|
+
registerCallbackInterceptor(handler: (query: TelegramBot.CallbackQuery) => Promise<boolean>): void {
|
|
91
|
+
this.callbackInterceptors.push(handler);
|
|
92
|
+
}
|
|
93
|
+
|
|
71
94
|
start(): void {
|
|
72
95
|
this.isRunning = true;
|
|
73
96
|
console.error('[MultiTelegram] Bot started');
|
|
@@ -544,6 +567,16 @@ export class MultiTelegramManager {
|
|
|
544
567
|
this.bot.on('callback_query', async (query) => {
|
|
545
568
|
if (!query.data || !query.message) return;
|
|
546
569
|
|
|
570
|
+
// Check interceptors first
|
|
571
|
+
for (const interceptor of this.callbackInterceptors) {
|
|
572
|
+
try {
|
|
573
|
+
const handled = await interceptor(query);
|
|
574
|
+
if (handled) return;
|
|
575
|
+
} catch (error) {
|
|
576
|
+
console.error('[MultiTelegram] Callback interceptor error:', error);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
547
580
|
const [action, messageId] = query.data.split(':');
|
|
548
581
|
const key = messageId;
|
|
549
582
|
|
package/src/telegram.ts
CHANGED
|
@@ -417,9 +417,12 @@ export class TelegramManager {
|
|
|
417
417
|
}
|
|
418
418
|
|
|
419
419
|
// User provided instructions - continue
|
|
420
|
+
// Include multiple fields for compatibility with different Claude Code versions
|
|
420
421
|
return {
|
|
421
422
|
decision: 'block',
|
|
422
423
|
reason: response,
|
|
424
|
+
continue: true,
|
|
425
|
+
stopReason: response,
|
|
423
426
|
};
|
|
424
427
|
} catch (err) {
|
|
425
428
|
clearInterval(reminderInterval);
|