claude-notification-plugin 1.1.104 → 1.1.108
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/.claude-plugin/plugin.json +20 -20
- package/README.md +24 -10
- package/bin/cli.js +3 -2
- package/bin/install.js +10 -5
- package/commit-sha +1 -1
- package/listener/LISTENER-DETAILED.md +1 -1
- package/listener/listener.js +34 -8
- package/listener/pty-runner.js +47 -23
- package/listener/screen-renderer.js +41 -0
- package/listener/telegram-poller.js +131 -36
- package/notifier/notifier.js +15 -11
- package/package.json +69 -65
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "claude-notification-plugin",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
|
|
5
|
-
"author": {
|
|
6
|
-
"name": "Viacheslav Makarov",
|
|
7
|
-
"email": "npmjs@bazilio.ru"
|
|
8
|
-
},
|
|
9
|
-
"homepage": "https://github.com/Bazilio-san/claude-notification-plugin#readme",
|
|
10
|
-
"repository": "https://github.com/Bazilio-san/claude-notification-plugin",
|
|
11
|
-
"license": "MIT",
|
|
12
|
-
"keywords": [
|
|
13
|
-
"notification",
|
|
14
|
-
"telegram",
|
|
15
|
-
"windows",
|
|
16
|
-
"sound",
|
|
17
|
-
"voice",
|
|
18
|
-
"hooks"
|
|
19
|
-
]
|
|
20
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-notification-plugin",
|
|
3
|
+
"version": "1.1.108",
|
|
4
|
+
"description": "Telegram listener daemon + Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Viacheslav Makarov",
|
|
7
|
+
"email": "npmjs@bazilio.ru"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/Bazilio-san/claude-notification-plugin#readme",
|
|
10
|
+
"repository": "https://github.com/Bazilio-san/claude-notification-plugin",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"notification",
|
|
14
|
+
"telegram",
|
|
15
|
+
"windows",
|
|
16
|
+
"sound",
|
|
17
|
+
"voice",
|
|
18
|
+
"hooks"
|
|
19
|
+
]
|
|
20
|
+
}
|
package/README.md
CHANGED
|
@@ -2,11 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
**Send a message in Telegram, and the task starts running on your PC.**
|
|
4
4
|
|
|
5
|
-
Cross-platform notifications for Claude Code task completion.
|
|
6
|
-
Sends alerts to Telegram and desktop (Windows, macOS, Linux) when Claude finishes working.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
Cross-platform notifications for Claude Code task completion.
|
|
6
|
+
Sends alerts to Telegram and desktop (Windows, macOS, Linux) when Claude finishes working.
|
|
7
|
+
|
|
8
|
+
## Start Here (Listener)
|
|
9
|
+
|
|
10
|
+
If you want Telegram-first remote control, start with the Listener daemon:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
claude-notify listener setup
|
|
14
|
+
claude-notify listener start
|
|
15
|
+
claude-notify listener status
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Deep internals and troubleshooting: [Detailed Guide](listener/LISTENER-DETAILED.md)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Features
|
|
10
22
|
|
|
11
23
|
- **[Telegram Listener](#telegram-listener)** — your remote control for Claude (supports worktrees)
|
|
12
24
|
- Telegram bot messages with auto-delete
|
|
@@ -118,7 +130,7 @@ ENV: `CLAUDE_NOTIFY_TELEGRAM_TOKEN`
|
|
|
118
130
|
**telegram.chatId** — Chat ID to send messages to.
|
|
119
131
|
ENV: `CLAUDE_NOTIFY_TELEGRAM_CHAT_ID`
|
|
120
132
|
|
|
121
|
-
**telegram.deleteAfterHours** — Auto-delete old Telegram messages after N hours. `0` to disable. Default: **24**
|
|
133
|
+
**telegram.deleteAfterHours** — Auto-delete old Telegram messages after N hours (applies to notifier and listener bot messages). `0` to disable. Default: **24**
|
|
122
134
|
|
|
123
135
|
**telegram.includeLastCcMessageInTelegram** — Append Claude's last message to the notification (truncated to 3500 chars). Default: **true**
|
|
124
136
|
ENV: `CLAUDE_NOTIFY_INCLUDE_LAST_CC_MESSAGE_IN_TELEGRAM`
|
|
@@ -219,14 +231,16 @@ fix the login bug → runs in "default" project
|
|
|
219
231
|
&api/feature/auth implement OAuth2 → runs in a worktree (auto-created)
|
|
220
232
|
```
|
|
221
233
|
|
|
222
|
-
The bot replies with status and results:
|
|
234
|
+
The bot replies with status and results:
|
|
223
235
|
|
|
224
236
|
```
|
|
225
237
|
⏳ [&api] Running: add pagination to GET /users
|
|
226
238
|
...
|
|
227
|
-
✅ [&api] Done: add pagination to GET /users
|
|
228
|
-
<claude's output>
|
|
229
|
-
```
|
|
239
|
+
✅ [&api] Done: add pagination to GET /users
|
|
240
|
+
<claude's output>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
If Claude returns a temporary API failure (`StopFailure`, e.g. `529 overloaded`), the listener saves the reported session ID and the next task for the same target auto-resumes it.
|
|
230
244
|
|
|
231
245
|
### 4. Manage the daemon
|
|
232
246
|
|
package/bin/cli.js
CHANGED
|
@@ -32,10 +32,11 @@ switch (command) {
|
|
|
32
32
|
console.log(`Usage: claude-notify <command> [options]
|
|
33
33
|
|
|
34
34
|
Commands:
|
|
35
|
+
listener <action> Manage the Telegram Listener daemon
|
|
36
|
+
Actions: start, stop, status, logs, restart
|
|
35
37
|
install Setup plugin registration, Telegram config, hooks
|
|
36
38
|
uninstall Remove plugin, hooks, config, CLI wrappers
|
|
37
|
-
|
|
38
|
-
Actions: start, stop, status, logs, restart`);
|
|
39
|
+
`);
|
|
39
40
|
process.exit(command ? 1 : 0);
|
|
40
41
|
}
|
|
41
42
|
}
|
package/bin/install.js
CHANGED
|
@@ -737,11 +737,16 @@ Plugin hooks (via hooks/hooks.json):
|
|
|
737
737
|
Config: ${CONFIG_PATH}
|
|
738
738
|
${telegramStatus}${platformTip}
|
|
739
739
|
|
|
740
|
-
Log: ${INSTALL_LOG_PATH}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
740
|
+
Log: ${INSTALL_LOG_PATH}
|
|
741
|
+
|
|
742
|
+
Listener quick start:
|
|
743
|
+
claude-notify listener setup
|
|
744
|
+
claude-notify listener start
|
|
745
|
+
claude-notify listener status
|
|
746
|
+
|
|
747
|
+
To uninstall: claude-notify uninstall
|
|
748
|
+
|
|
749
|
+
To disable per project, add to .claude/settings.local.json: { "env": { "CLAUDE_NOTIFY_DISABLE": "1" } }`);
|
|
745
750
|
|
|
746
751
|
closeLog();
|
|
747
752
|
}
|
package/commit-sha
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
0daaa9a945591246c6de6c117d9cf9b4add7ab25
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Telegram Listener is a background daemon that receives tasks from a Telegram chat
|
|
4
4
|
and executes them on your machine via an interactive Claude Code PTY session. The result is sent back to Telegram.
|
|
5
5
|
|
|
6
|
-
**[Quick Start here](../
|
|
6
|
+
**[Quick Start here](../README.md#telegram-listener)**
|
|
7
7
|
|
|
8
8
|
# Detailed Guide
|
|
9
9
|
|
package/listener/listener.js
CHANGED
|
@@ -133,7 +133,9 @@ const resumeLastSessionEnabled = listenerConfig.resumeLastSession !== false; //
|
|
|
133
133
|
const sessionsListLimit = listenerConfig.sessionsListLimit || 5;
|
|
134
134
|
const sessionWorkingThresholdSec = listenerConfig.sessionWorkingThresholdSec || 2;
|
|
135
135
|
|
|
136
|
-
const poller = new TelegramPoller(token, chatId, logger
|
|
136
|
+
const poller = new TelegramPoller(token, chatId, logger, {
|
|
137
|
+
deleteAfterHours: config.telegram?.deleteAfterHours,
|
|
138
|
+
});
|
|
137
139
|
const queue = new WorkQueue(
|
|
138
140
|
logger,
|
|
139
141
|
listenerConfig.maxQueuePerWorkDir || 10,
|
|
@@ -298,25 +300,39 @@ async function notifyTaskCompletion (workDir, task, kind, payload = {}) {
|
|
|
298
300
|
runner.cleanActivitySignal(workDir);
|
|
299
301
|
const entry = queue.queues[workDir];
|
|
300
302
|
const label = formatLabel(entry?.project, entry?.branch);
|
|
301
|
-
|
|
303
|
+
let output = payload.text || '';
|
|
302
304
|
|
|
303
305
|
// Build header
|
|
304
306
|
let header;
|
|
305
307
|
let queueResult;
|
|
306
308
|
if (kind === 'error') {
|
|
307
|
-
|
|
309
|
+
const resumeSid = payload.resumeSessionId || payload.sessionId || null;
|
|
310
|
+
if (resumeSid) {
|
|
311
|
+
setStoredSessionId(workDir, resumeSid);
|
|
312
|
+
}
|
|
313
|
+
const resumeHint = resumeSid
|
|
314
|
+
? `\nSaved session: <code>${resumeSid}</code>\nNext task for this target will auto-resume it.`
|
|
315
|
+
: '';
|
|
316
|
+
header = `❌ <code>${label}</code>\nError${resumeHint}`;
|
|
308
317
|
queueResult = `ERROR: ${payload.errorMsg}`;
|
|
309
318
|
} else if (kind === 'timeout') {
|
|
310
319
|
const reason = payload.reason || `no activity for ${payload.timeoutMin} min`;
|
|
311
320
|
header = `⏰ <code>${label}</code>\nTask forcefully stopped — ${reason}`;
|
|
312
321
|
queueResult = 'TIMEOUT';
|
|
313
322
|
} else if (task.raw) {
|
|
314
|
-
|
|
315
|
-
|
|
323
|
+
const rawCmd = (task.text || '').trim().toLowerCase();
|
|
324
|
+
// /clear wipes Claude's context — reset our counters to match. The PTY
|
|
325
|
+
// buffer after /clear is just an empty prompt + status bar, so skip the
|
|
326
|
+
// body dump and report a clean confirmation instead.
|
|
327
|
+
if (rawCmd === '/clear') {
|
|
316
328
|
sessions.delete(workDir);
|
|
329
|
+
header = `🧹 <code>${label}</code> <code>/clear</code> — session reset`;
|
|
330
|
+
queueResult = '/clear';
|
|
331
|
+
output = '';
|
|
332
|
+
} else {
|
|
333
|
+
header = `📨 <code>${label}</code> sent <code>${escapeHtml(task.text)}</code>`;
|
|
334
|
+
queueResult = output;
|
|
317
335
|
}
|
|
318
|
-
header = `📨 <code>${label}</code> sent <code>${escapeHtml(task.text)}</code>`;
|
|
319
|
-
queueResult = output;
|
|
320
336
|
} else {
|
|
321
337
|
// Update session tracking for non-raw completions
|
|
322
338
|
const session = sessions.get(workDir) || { taskCount: 0 };
|
|
@@ -406,7 +422,17 @@ async function notifyTaskCompletion (workDir, task, kind, payload = {}) {
|
|
|
406
422
|
}
|
|
407
423
|
|
|
408
424
|
runner.on('complete', (workDir, task, result) => notifyTaskCompletion(workDir, task, 'complete', result));
|
|
409
|
-
runner.on('error', (workDir, task,
|
|
425
|
+
runner.on('error', (workDir, task, errorData) => {
|
|
426
|
+
if (typeof errorData === 'string') {
|
|
427
|
+
notifyTaskCompletion(workDir, task, 'error', { errorMsg: errorData });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
notifyTaskCompletion(workDir, task, 'error', {
|
|
431
|
+
errorMsg: errorData?.message || 'Unknown error',
|
|
432
|
+
sessionId: errorData?.sessionId || null,
|
|
433
|
+
resumeSessionId: errorData?.resumeSessionId || null,
|
|
434
|
+
});
|
|
435
|
+
});
|
|
410
436
|
runner.on('timeout', (workDir, task) => notifyTaskCompletion(workDir, task, 'timeout', {
|
|
411
437
|
timeoutMin: Math.round(taskTimeout / 60000),
|
|
412
438
|
}));
|
package/listener/pty-runner.js
CHANGED
|
@@ -4,7 +4,8 @@ import fs from 'fs';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { EventEmitter } from 'events';
|
|
6
6
|
import { PTY_SIGNAL_DIR } from '../bin/constants.js';
|
|
7
|
-
import {
|
|
7
|
+
import { cleanRenderedScreen } from './telegram-poller.js';
|
|
8
|
+
import { renderPtyScreen } from './screen-renderer.js';
|
|
8
9
|
|
|
9
10
|
const DEFAULT_TIMEOUT = 600_000; // 10 minutes
|
|
10
11
|
// Built-in slash-commands (forwarded via %cmd) rarely emit a Stop hook event,
|
|
@@ -153,6 +154,8 @@ export class PtyRunner extends EventEmitter {
|
|
|
153
154
|
} else if (type === 'error') {
|
|
154
155
|
// StopFailure — emit error, abort task
|
|
155
156
|
this._unlinkSafe(filePath);
|
|
157
|
+
const errorSignalSessionId = marker.sessionId
|
|
158
|
+
|| (f.startsWith('err_') ? f.slice(4, -5) : null);
|
|
156
159
|
for (const [workDir, session] of this.sessions) {
|
|
157
160
|
if (session.state === 'busy' && this._normalizePath(session.workDir) === this._normalizePath(cwd)) {
|
|
158
161
|
if (session._pendingId && this.pendingMarkers.has(session._pendingId)) {
|
|
@@ -163,11 +166,22 @@ export class PtyRunner extends EventEmitter {
|
|
|
163
166
|
session.currentTask = null;
|
|
164
167
|
this._destroyPty(workDir);
|
|
165
168
|
const errorMsg = `API error: ${marker.error}${marker.errorDetails ? ' — ' + marker.errorDetails : ''}`;
|
|
169
|
+
const resumeMatch = (marker.errorDetails || marker.lastAssistantMessage || '')
|
|
170
|
+
.match(/(?:^|\s)--resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
|
171
|
+
const resumeSessionId = resumeMatch?.[1] || null;
|
|
166
172
|
this.logger.error(`Hook signal: ${errorMsg} in ${workDir}`);
|
|
167
173
|
if (this.taskLogger) {
|
|
168
174
|
this.taskLogger.logAnswer(task?.project || 'unknown', task?.branch || 'main', errorMsg, 1);
|
|
169
175
|
}
|
|
170
|
-
this.emit('error', workDir, task,
|
|
176
|
+
this.emit('error', workDir, task, {
|
|
177
|
+
message: errorMsg,
|
|
178
|
+
sessionId: errorSignalSessionId && errorSignalSessionId !== 'unknown'
|
|
179
|
+
? errorSignalSessionId
|
|
180
|
+
: null,
|
|
181
|
+
resumeSessionId,
|
|
182
|
+
errorType: marker.error || 'unknown',
|
|
183
|
+
errorDetails: marker.errorDetails || '',
|
|
184
|
+
});
|
|
171
185
|
break;
|
|
172
186
|
}
|
|
173
187
|
}
|
|
@@ -253,31 +267,37 @@ export class PtyRunner extends EventEmitter {
|
|
|
253
267
|
}
|
|
254
268
|
|
|
255
269
|
const CHECK_INTERVAL = 5000;
|
|
256
|
-
|
|
270
|
+
let idleCheckInFlight = false;
|
|
271
|
+
const checker = setInterval(async () => {
|
|
257
272
|
const lastActivity = session?._lastActivityTime || 0;
|
|
258
273
|
const idleMs = lastActivity > 0 ? Date.now() - lastActivity : 0;
|
|
259
274
|
|
|
260
275
|
// Idle-prompt fallback: PTY went quiet AND its tail shows Claude's
|
|
261
276
|
// idle status bar — treat as completed even if the Stop hook never
|
|
262
277
|
// produced a signal (happens with resumed sessions on Windows ConPTY).
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
278
|
+
// Rendering is async (xterm-headless), so guard against re-entry while
|
|
279
|
+
// an earlier tick is still rendering.
|
|
280
|
+
if (idleMs > IDLE_PROMPT_FALLBACK_MS && session && !idleCheckInFlight) {
|
|
281
|
+
idleCheckInFlight = true;
|
|
282
|
+
try {
|
|
283
|
+
const rendered = await renderPtyScreen(session._buffer || '');
|
|
284
|
+
if (IDLE_PROMPT_RE.test(rendered)) {
|
|
285
|
+
clearInterval(checker);
|
|
286
|
+
this.pendingMarkers.delete(pendingId);
|
|
287
|
+
const text = cleanRenderedScreen(rendered).trim().slice(-2000);
|
|
288
|
+
this.logger.warn(`PTY idle-prompt fallback completion in ${session.workDir} (no Stop signal in ${Math.round(idleMs / 1000)}s)`);
|
|
289
|
+
resolve({
|
|
290
|
+
lastAssistantMessage: text,
|
|
291
|
+
sessionId: session.sessionId || null,
|
|
292
|
+
cost: 0,
|
|
293
|
+
numTurns: 0,
|
|
294
|
+
durationMs: idleMs,
|
|
295
|
+
isIdleFallback: true,
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
} finally {
|
|
300
|
+
idleCheckInFlight = false;
|
|
281
301
|
}
|
|
282
302
|
}
|
|
283
303
|
|
|
@@ -437,7 +457,7 @@ export class PtyRunner extends EventEmitter {
|
|
|
437
457
|
this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', result.text, 0);
|
|
438
458
|
}
|
|
439
459
|
this.emit('complete', workDir, task, result);
|
|
440
|
-
}).catch((err) => {
|
|
460
|
+
}).catch(async (err) => {
|
|
441
461
|
session.state = 'idle';
|
|
442
462
|
session.currentTask = null;
|
|
443
463
|
|
|
@@ -445,7 +465,11 @@ export class PtyRunner extends EventEmitter {
|
|
|
445
465
|
if (task.raw) {
|
|
446
466
|
// Slash commands (e.g. /clear, /cost) usually don't emit a Stop hook.
|
|
447
467
|
// Treat inactivity as successful completion and keep the PTY alive.
|
|
448
|
-
|
|
468
|
+
// Render through xterm-headless so the *final* screen is what we
|
|
469
|
+
// report — naive ANSI stripping leaves transient popup rows in the
|
|
470
|
+
// output even though the real terminal has redrawn over them.
|
|
471
|
+
const rendered = await renderPtyScreen(session._buffer || '');
|
|
472
|
+
const cleaned = cleanRenderedScreen(rendered).trim();
|
|
449
473
|
const tail = cleaned.length > 2000 ? cleaned.slice(-2000) : cleaned;
|
|
450
474
|
const result = {
|
|
451
475
|
text: tail,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import xtermPkg from '@xterm/headless';
|
|
2
|
+
|
|
3
|
+
const { Terminal } = xtermPkg;
|
|
4
|
+
|
|
5
|
+
// Must match the dimensions PTY sessions are spawned with (pty-runner.js).
|
|
6
|
+
// Mismatch produces wrap artifacts in the rendered viewport.
|
|
7
|
+
const COLS = 120;
|
|
8
|
+
const ROWS = 40;
|
|
9
|
+
|
|
10
|
+
// Run raw PTY bytes through a headless xterm.js so the *final* visible screen
|
|
11
|
+
// is what we read out — cursor positioning, line erases, scroll-up, etc. are
|
|
12
|
+
// all honored. Without this, transient UI (e.g. the slash-command popup that
|
|
13
|
+
// briefly appears while typing `/clear`) leaks into the captured output even
|
|
14
|
+
// though the real terminal has long since cleared those rows.
|
|
15
|
+
//
|
|
16
|
+
// Returns the viewport text (ROWS lines joined by \n) with trailing empty
|
|
17
|
+
// lines trimmed. Scrollback is intentionally ignored — Telegram messages
|
|
18
|
+
// reflect the on-screen state, not the entire session history.
|
|
19
|
+
export async function renderPtyScreen (raw) {
|
|
20
|
+
if (!raw) {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
const term = new Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true });
|
|
24
|
+
await new Promise((resolve) => term.write(raw, resolve));
|
|
25
|
+
const buf = term.buffer.active;
|
|
26
|
+
const lines = [];
|
|
27
|
+
const startY = buf.viewportY;
|
|
28
|
+
for (let y = 0; y < ROWS; y++) {
|
|
29
|
+
const line = buf.getLine(startY + y);
|
|
30
|
+
lines.push(line ? line.translateToString(true) : '');
|
|
31
|
+
}
|
|
32
|
+
while (lines.length && !lines[lines.length - 1].trim()) {
|
|
33
|
+
lines.pop();
|
|
34
|
+
}
|
|
35
|
+
if (typeof term.dispose === 'function') {
|
|
36
|
+
term.dispose();
|
|
37
|
+
}
|
|
38
|
+
return lines.join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { COLS as RENDER_COLS, ROWS as RENDER_ROWS };
|
|
@@ -7,17 +7,22 @@ const MAX_MESSAGE_LENGTH = 4096;
|
|
|
7
7
|
// of looping silently.
|
|
8
8
|
const MAX_CONSECUTIVE_409 = 8;
|
|
9
9
|
|
|
10
|
-
export class TelegramPoller {
|
|
11
|
-
constructor (token, chatId, logger) {
|
|
12
|
-
this.token = token;
|
|
13
|
-
this.chatId = String(chatId);
|
|
14
|
-
this.logger = logger;
|
|
15
|
-
this.baseUrl = `https://api.telegram.org/bot${token}`;
|
|
16
|
-
this.offset = 0;
|
|
17
|
-
this._errorBackoff = 0; // current backoff in ms (0 = no backoff)
|
|
18
|
-
this._consecutiveErrors = 0;
|
|
19
|
-
this._consecutive409 = 0;
|
|
20
|
-
|
|
10
|
+
export class TelegramPoller {
|
|
11
|
+
constructor (token, chatId, logger, options = {}) {
|
|
12
|
+
this.token = token;
|
|
13
|
+
this.chatId = String(chatId);
|
|
14
|
+
this.logger = logger;
|
|
15
|
+
this.baseUrl = `https://api.telegram.org/bot${token}`;
|
|
16
|
+
this.offset = 0;
|
|
17
|
+
this._errorBackoff = 0; // current backoff in ms (0 = no backoff)
|
|
18
|
+
this._consecutiveErrors = 0;
|
|
19
|
+
this._consecutive409 = 0;
|
|
20
|
+
const deleteAfterHours = Number(options.deleteAfterHours);
|
|
21
|
+
this._deleteAfterMs = Number.isFinite(deleteAfterHours) && deleteAfterHours > 0
|
|
22
|
+
? deleteAfterHours * 3600_000
|
|
23
|
+
: 0;
|
|
24
|
+
this._sentMessages = [];
|
|
25
|
+
}
|
|
21
26
|
|
|
22
27
|
async flush () {
|
|
23
28
|
try {
|
|
@@ -153,9 +158,12 @@ export class TelegramPoller {
|
|
|
153
158
|
body: JSON.stringify({ ...base, parse_mode: 'HTML' }),
|
|
154
159
|
});
|
|
155
160
|
let data = await res.json();
|
|
156
|
-
if (data.ok) {
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
if (data.ok) {
|
|
162
|
+
const messageId = data.result.message_id;
|
|
163
|
+
this._trackSentMessage(messageId);
|
|
164
|
+
await this._cleanupOldMessages();
|
|
165
|
+
return messageId;
|
|
166
|
+
}
|
|
159
167
|
const htmlErr = data.description || `error_code ${data.error_code}`;
|
|
160
168
|
// Retry without HTML parse mode (covers entity-parsing errors)
|
|
161
169
|
res = await fetch(`${this.baseUrl}/sendMessage`, {
|
|
@@ -164,10 +172,13 @@ export class TelegramPoller {
|
|
|
164
172
|
body: JSON.stringify(base),
|
|
165
173
|
});
|
|
166
174
|
data = await res.json();
|
|
167
|
-
if (data.ok) {
|
|
168
|
-
this.logger.warn(`sendMessage: HTML failed (${htmlErr}), plain succeeded`);
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
if (data.ok) {
|
|
176
|
+
this.logger.warn(`sendMessage: HTML failed (${htmlErr}), plain succeeded`);
|
|
177
|
+
const messageId = data.result.message_id;
|
|
178
|
+
this._trackSentMessage(messageId);
|
|
179
|
+
await this._cleanupOldMessages();
|
|
180
|
+
return messageId;
|
|
181
|
+
}
|
|
171
182
|
this.logger.error(`sendMessage failed: HTML=${htmlErr}, plain=${data.description || data.error_code}`);
|
|
172
183
|
return null;
|
|
173
184
|
} catch (err) {
|
|
@@ -262,23 +273,57 @@ export class TelegramPoller {
|
|
|
262
273
|
}
|
|
263
274
|
}
|
|
264
275
|
|
|
265
|
-
async sendDocument (buffer, filename, caption) {
|
|
266
|
-
try {
|
|
267
|
-
const formData = new FormData();
|
|
268
|
-
formData.append('chat_id', this.chatId);
|
|
269
|
-
formData.append('document', new Blob([buffer]), filename);
|
|
270
|
-
if (caption) {
|
|
271
|
-
formData.append('caption', caption.slice(0, 1024));
|
|
272
|
-
}
|
|
273
|
-
await fetch(`${this.baseUrl}/sendDocument`, {
|
|
274
|
-
method: 'POST',
|
|
275
|
-
body: formData,
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
276
|
+
async sendDocument (buffer, filename, caption) {
|
|
277
|
+
try {
|
|
278
|
+
const formData = new FormData();
|
|
279
|
+
formData.append('chat_id', this.chatId);
|
|
280
|
+
formData.append('document', new Blob([buffer]), filename);
|
|
281
|
+
if (caption) {
|
|
282
|
+
formData.append('caption', caption.slice(0, 1024));
|
|
283
|
+
}
|
|
284
|
+
const res = await fetch(`${this.baseUrl}/sendDocument`, {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
body: formData,
|
|
287
|
+
});
|
|
288
|
+
const data = await res.json();
|
|
289
|
+
if (data.ok && data.result?.message_id) {
|
|
290
|
+
this._trackSentMessage(data.result.message_id);
|
|
291
|
+
await this._cleanupOldMessages();
|
|
292
|
+
}
|
|
293
|
+
} catch (err) {
|
|
294
|
+
this.logger.error(`sendDocument error: ${err.message}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_trackSentMessage (messageId) {
|
|
299
|
+
if (!messageId || this._deleteAfterMs <= 0) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
this._sentMessages.push({
|
|
303
|
+
id: messageId,
|
|
304
|
+
ts: Date.now(),
|
|
305
|
+
});
|
|
306
|
+
if (this._sentMessages.length > 1000) {
|
|
307
|
+
this._sentMessages = this._sentMessages.slice(-500);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async _cleanupOldMessages () {
|
|
312
|
+
if (this._deleteAfterMs <= 0 || this._sentMessages.length === 0) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
const keep = [];
|
|
317
|
+
for (const msg of this._sentMessages) {
|
|
318
|
+
if (now - msg.ts > this._deleteAfterMs) {
|
|
319
|
+
await this.deleteMessage(msg.id);
|
|
320
|
+
} else {
|
|
321
|
+
keep.push(msg);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
this._sentMessages = keep;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
282
327
|
|
|
283
328
|
function escapeHtml (text) {
|
|
284
329
|
return text
|
|
@@ -416,4 +461,54 @@ function cleanPtyOutput (raw) {
|
|
|
416
461
|
return cleaned.join('\n');
|
|
417
462
|
}
|
|
418
463
|
|
|
419
|
-
|
|
464
|
+
// Softer cleaner for *rendered* terminal screens (post xterm-headless emulation).
|
|
465
|
+
// Unlike cleanPtyOutput, this preserves `❯ <command>` lines because the
|
|
466
|
+
// rendered screen already reflects the final state — that prompt line is the
|
|
467
|
+
// actual command echo + result, not a transient input being typed.
|
|
468
|
+
function cleanRenderedScreen (rendered) {
|
|
469
|
+
if (!rendered) {
|
|
470
|
+
return '';
|
|
471
|
+
}
|
|
472
|
+
const lines = rendered.split('\n');
|
|
473
|
+
const cleaned = [];
|
|
474
|
+
for (const line of lines) {
|
|
475
|
+
const trimmed = line.trimEnd();
|
|
476
|
+
if (!trimmed.trim()) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
// Empty prompt arrow (no command on it) — drop
|
|
480
|
+
if (/^❯\s*$/.test(trimmed)) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
// Pure divider lines (≥50% box-drawing chars)
|
|
484
|
+
const specialChars = (trimmed.match(/[▐▝▘▛▜█▌▀▄░▒▓─━═╌┄│┃┌┐└┘├┤┬┴┼╔╗╚╝╠╣╦╩╬]/g) || []).length;
|
|
485
|
+
if (specialChars > trimmed.length * 0.5) {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
// Status bar — bypass/auto/plan permissions
|
|
489
|
+
if (/[⏵⏴]\s*[⏵⏴]?\s*(bypass|auto|plan)\s+permissions?/i.test(trimmed)) {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (/shift\+tab\s*to\s*cycle/i.test(trimmed)) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (/ctrl\+[a-z]\s+to\s/i.test(trimmed)) {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
// Effort indicator: "◉ xhigh · /effort"
|
|
499
|
+
if (/^\s*◉\s+\S+\s*·\s*\/effort\s*$/.test(trimmed)) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
// Help hint: "/ide for WebStorm" etc. at far-right column
|
|
503
|
+
if (/^\s+\/(ide|model|help)\s+(for|to)\s+/i.test(trimmed)) {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (/Pasting\s*text/i.test(trimmed)) {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
cleaned.push(trimmed);
|
|
510
|
+
}
|
|
511
|
+
return cleaned.join('\n');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export { escapeHtml, stripAnsi, cleanPtyOutput, cleanRenderedScreen };
|
package/notifier/notifier.js
CHANGED
|
@@ -214,15 +214,16 @@ function writePtySignalFile (event) {
|
|
|
214
214
|
});
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
function writeErrorSignalFile (event) {
|
|
218
|
-
const sessionId = event.session_id || 'unknown';
|
|
219
|
-
writeSignalFile(`err_${sessionId}.json`, {
|
|
220
|
-
type: 'error',
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
217
|
+
function writeErrorSignalFile (event) {
|
|
218
|
+
const sessionId = event.session_id || 'unknown';
|
|
219
|
+
writeSignalFile(`err_${sessionId}.json`, {
|
|
220
|
+
type: 'error',
|
|
221
|
+
sessionId,
|
|
222
|
+
cwd: event.cwd || process.cwd(),
|
|
223
|
+
error: event.error || 'unknown',
|
|
224
|
+
errorDetails: event.error_details || '',
|
|
225
|
+
lastAssistantMessage: event.last_assistant_message || '',
|
|
226
|
+
timestamp: Date.now(),
|
|
226
227
|
});
|
|
227
228
|
}
|
|
228
229
|
|
|
@@ -413,8 +414,11 @@ async function sendTelegram (config, state) {
|
|
|
413
414
|
}
|
|
414
415
|
|
|
415
416
|
// Delete old messages
|
|
416
|
-
const
|
|
417
|
-
|
|
417
|
+
const deleteAfter = Number(config.telegram.deleteAfterHours);
|
|
418
|
+
const maxAge = Number.isFinite(deleteAfter) && deleteAfter > 0
|
|
419
|
+
? deleteAfter * 3600_000
|
|
420
|
+
: 0;
|
|
421
|
+
if (maxAge > 0 && state.sentMessages?.length) {
|
|
418
422
|
const now = Date.now();
|
|
419
423
|
const keep = [];
|
|
420
424
|
for (const msg of state.sentMessages) {
|
package/package.json
CHANGED
|
@@ -1,65 +1,69 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "claude-notification-plugin",
|
|
3
|
-
"productName": "claude-notification-plugin",
|
|
4
|
-
"version": "1.1.
|
|
5
|
-
"description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"engines": {
|
|
8
|
-
"node": ">=18.0.0"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
".claude-plugin/",
|
|
12
|
-
"bin/",
|
|
13
|
-
"claude_img/claude.png",
|
|
14
|
-
"hooks/",
|
|
15
|
-
"listener/",
|
|
16
|
-
"notifier/",
|
|
17
|
-
"commit-sha",
|
|
18
|
-
"README.md",
|
|
19
|
-
"LICENSE"
|
|
20
|
-
],
|
|
21
|
-
"bin": {
|
|
22
|
-
"claude-notify": "bin/cli.js"
|
|
23
|
-
},
|
|
24
|
-
"scripts": {
|
|
25
|
-
"prepack": "git rev-parse HEAD > commit-sha",
|
|
26
|
-
"postinstall": "node bin/install.js",
|
|
27
|
-
"lint": "eslint .",
|
|
28
|
-
"lint:fix": "eslint --fix .",
|
|
29
|
-
"listener:restart": "claude-notify listener restart",
|
|
30
|
-
"listener:stop": "claude-notify listener stop",
|
|
31
|
-
"listener:start": "claude-notify listener start",
|
|
32
|
-
"listener:status": "claude-notify listener status"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"claude"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
}
|
|
65
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-notification-plugin",
|
|
3
|
+
"productName": "claude-notification-plugin",
|
|
4
|
+
"version": "1.1.108",
|
|
5
|
+
"description": "Telegram listener daemon + Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18.0.0"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
".claude-plugin/",
|
|
12
|
+
"bin/",
|
|
13
|
+
"claude_img/claude.png",
|
|
14
|
+
"hooks/",
|
|
15
|
+
"listener/",
|
|
16
|
+
"notifier/",
|
|
17
|
+
"commit-sha",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"claude-notify": "bin/cli.js"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"prepack": "git rev-parse HEAD > commit-sha",
|
|
26
|
+
"postinstall": "node bin/install.js",
|
|
27
|
+
"lint": "eslint .",
|
|
28
|
+
"lint:fix": "eslint --fix .",
|
|
29
|
+
"listener:restart": "claude-notify listener restart",
|
|
30
|
+
"listener:stop": "claude-notify listener stop",
|
|
31
|
+
"listener:start": "claude-notify listener start",
|
|
32
|
+
"listener:status": "claude-notify listener status",
|
|
33
|
+
"agents:link": "node scripts/claude-2-agents-symlink.js setup",
|
|
34
|
+
"agents:link:status": "node scripts/claude-2-agents-symlink.js status",
|
|
35
|
+
"agents:link:remove": "node scripts/claude-2-agents-symlink.js remove"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"claude",
|
|
39
|
+
"claude-code",
|
|
40
|
+
"notifications",
|
|
41
|
+
"telegram",
|
|
42
|
+
"hooks",
|
|
43
|
+
"macos",
|
|
44
|
+
"linux",
|
|
45
|
+
"cross-platform"
|
|
46
|
+
],
|
|
47
|
+
"author": {
|
|
48
|
+
"name": "Viacheslav Makarov",
|
|
49
|
+
"email": "npmjs@bazilio.ru"
|
|
50
|
+
},
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/Bazilio-san/claude-notification-plugin.git"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/Bazilio-san/claude-notification-plugin#readme",
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@xterm/headless": "^6.0.0",
|
|
62
|
+
"node-notifier": "^10.0.1",
|
|
63
|
+
"node-pty": "^1.1.0"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"eslint-plugin-import": "^2.31.0",
|
|
67
|
+
"eslint-plugin-unused-imports": "^4.4.1"
|
|
68
|
+
}
|
|
69
|
+
}
|