claude-code-telegram-gateway 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/LICENSE +21 -0
- package/README.md +156 -0
- package/SETUP.md +75 -0
- package/bin/claude-tg.js +30 -0
- package/com.claude.telegram-gateway.plist +49 -0
- package/config.example.json +29 -0
- package/gateway.js +1311 -0
- package/install-service.sh +64 -0
- package/package.json +56 -0
- package/resume-hook.js +17 -0
- package/setup.js +108 -0
- package/systemd/claude-gateway.service +23 -0
- package/test/MANUAL-TESTS.md +85 -0
- package/test/check-telegram.js +55 -0
- package/test/gateway.test.js +523 -0
- package/test/inject-probe.sh +49 -0
- package/test/reset-topics.js +42 -0
- package/uninstall-service.sh +17 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { test } = require('node:test');
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
|
|
8
|
+
// Requiring gateway.js is safe: boot is guarded behind require.main === module.
|
|
9
|
+
const g = require('../gateway.js');
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// summarizeToolInput
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
test('summarizeToolInput: Bash shows the command, whitespace-collapsed', () => {
|
|
15
|
+
assert.equal(g.summarizeToolInput('Bash', { command: 'ls -la\n/tmp' }), 'ls -la /tmp');
|
|
16
|
+
});
|
|
17
|
+
test('summarizeToolInput: file tools show the path', () => {
|
|
18
|
+
assert.equal(g.summarizeToolInput('Read', { file_path: '/a/b.js' }), '/a/b.js');
|
|
19
|
+
assert.equal(g.summarizeToolInput('Grep', { pattern: 'foo' }), 'foo');
|
|
20
|
+
});
|
|
21
|
+
test('summarizeToolInput: unknown input falls back to compact JSON', () => {
|
|
22
|
+
assert.equal(g.summarizeToolInput('Weird', { foo: 'bar' }), '{"foo":"bar"}');
|
|
23
|
+
});
|
|
24
|
+
test('summarizeToolInput: null/empty input returns empty string', () => {
|
|
25
|
+
assert.equal(g.summarizeToolInput('X', null), '');
|
|
26
|
+
});
|
|
27
|
+
test('summarizeToolInput: long command is truncated to 120 chars', () => {
|
|
28
|
+
const long = 'x'.repeat(500);
|
|
29
|
+
assert.equal(g.summarizeToolInput('Bash', { command: long }).length, 120);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// createFeed β the stream-json event reducer
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const EVENTS = [
|
|
36
|
+
{ type: 'system', subtype: 'init', session_id: 'init-id' },
|
|
37
|
+
{ type: 'assistant', message: { content: [{ type: 'thinking', thinking: 'hmm' }] } },
|
|
38
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'echo hi' } }] } },
|
|
39
|
+
{ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello' } } },
|
|
40
|
+
{ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: ' world' } } },
|
|
41
|
+
{ type: 'result', subtype: 'success', is_error: false, session_id: 'sess-1', result: 'Hello world' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
test('createFeed: builds tool step + streamed text in order', () => {
|
|
45
|
+
const feed = g.createFeed(true);
|
|
46
|
+
for (const e of EVENTS) feed.handle(e);
|
|
47
|
+
assert.equal(feed.render(), 'π§ Bash: echo hi\nHello world');
|
|
48
|
+
});
|
|
49
|
+
test('createFeed: captures result session id and error flag', () => {
|
|
50
|
+
const feed = g.createFeed(true);
|
|
51
|
+
for (const e of EVENTS) feed.handle(e);
|
|
52
|
+
assert.equal(feed.sessionId, 'sess-1');
|
|
53
|
+
assert.equal(feed.isError, false);
|
|
54
|
+
assert.equal(feed.sawContent, true);
|
|
55
|
+
});
|
|
56
|
+
test('createFeed: handle() signals visible changes only', () => {
|
|
57
|
+
const feed = g.createFeed(true);
|
|
58
|
+
assert.equal(feed.handle(EVENTS[0]), false, 'system init = no visible change');
|
|
59
|
+
assert.equal(feed.handle(EVENTS[1]), false, 'thinking block = no visible change');
|
|
60
|
+
assert.equal(feed.handle(EVENTS[2]), true, 'tool_use = visible');
|
|
61
|
+
assert.equal(feed.handle(EVENTS[3]), true, 'text delta = visible');
|
|
62
|
+
assert.equal(feed.handle(EVENTS[5]), false, 'result = no visible change');
|
|
63
|
+
});
|
|
64
|
+
test('createFeed: showTools=false hides tool steps', () => {
|
|
65
|
+
const feed = g.createFeed(false);
|
|
66
|
+
for (const e of EVENTS) feed.handle(e);
|
|
67
|
+
assert.equal(feed.render(), 'Hello world');
|
|
68
|
+
});
|
|
69
|
+
test('createFeed: is_error / non-success subtype sets isError', () => {
|
|
70
|
+
const feed = g.createFeed(true);
|
|
71
|
+
feed.handle({ type: 'result', subtype: 'error_during_execution', is_error: true, session_id: 's', result: 'boom' });
|
|
72
|
+
assert.equal(feed.isError, true);
|
|
73
|
+
assert.equal(feed.resultText, 'boom');
|
|
74
|
+
});
|
|
75
|
+
test('createFeed: finish() falls back to result text when nothing streamed', () => {
|
|
76
|
+
const feed = g.createFeed(true);
|
|
77
|
+
feed.handle({ type: 'result', subtype: 'success', is_error: false, session_id: 's', result: 'final only' });
|
|
78
|
+
assert.equal(feed.sawContent, false);
|
|
79
|
+
assert.equal(feed.finish(), 'final only');
|
|
80
|
+
});
|
|
81
|
+
test('createFeed: empty feed renders the working placeholder', () => {
|
|
82
|
+
assert.equal(g.createFeed(true).render(), 'βοΈ Workingβ¦');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// LiveMessage β throttled in-place editing + page rollover
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
function mockLive(live) {
|
|
89
|
+
live._calls = [];
|
|
90
|
+
let n = 0;
|
|
91
|
+
live._sendNew = async (t) => { const id = ++n; live._calls.push({ op: 'send', id, len: t.length }); return id; };
|
|
92
|
+
live._editCur = async (t) => {
|
|
93
|
+
if (live.curId == null || t === live.sentForCur) return; // real dedupe behavior
|
|
94
|
+
live.sentForCur = t;
|
|
95
|
+
live.lastEditAt = Date.now();
|
|
96
|
+
live._calls.push({ op: 'edit', id: live.curId, len: t.length, text: t });
|
|
97
|
+
};
|
|
98
|
+
return live;
|
|
99
|
+
}
|
|
100
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
101
|
+
|
|
102
|
+
test('LiveMessage: coalesces rapid updates into one send + a final edit', async () => {
|
|
103
|
+
const L = mockLive(new g.LiveMessage('c', 't'));
|
|
104
|
+
await L.set('step\n'); // first content -> send
|
|
105
|
+
await L.set('step\nA'); // immediate first edit
|
|
106
|
+
await L.set('step\nAB'); // throttled
|
|
107
|
+
await L.set('step\nABC'); // throttled (coalesced)
|
|
108
|
+
await L.finalize('step\nABC done');
|
|
109
|
+
await sleep(30);
|
|
110
|
+
const sends = L._calls.filter((c) => c.op === 'send');
|
|
111
|
+
assert.equal(sends.length, 1, 'exactly one message created');
|
|
112
|
+
assert.ok(L.sentForCur.endsWith('done'), 'final edit lands the complete text');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('LiveMessage: dedupes identical content (no redundant edit)', async () => {
|
|
116
|
+
const L = mockLive(new g.LiveMessage('c', 't'));
|
|
117
|
+
await L.set('hello');
|
|
118
|
+
await L.finalize('hello'); // same text
|
|
119
|
+
await sleep(10);
|
|
120
|
+
const edits = L._calls.filter((c) => c.op === 'edit');
|
|
121
|
+
assert.equal(edits.length, 0, 'no edit when text is unchanged');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('LiveMessage: rolls a >3800-char transcript across multiple messages', async () => {
|
|
125
|
+
const L = mockLive(new g.LiveMessage('c', 't'));
|
|
126
|
+
const big = Array.from({ length: 9000 }, (_, i) => (i % 80 === 79 ? '\n' : 'x')).join('');
|
|
127
|
+
await L.finalize(big);
|
|
128
|
+
await sleep(20);
|
|
129
|
+
const sends = L._calls.filter((c) => c.op === 'send');
|
|
130
|
+
assert.equal(sends.length, 3, 'splits into 3 pages');
|
|
131
|
+
assert.ok(sends.every((c) => c.len <= 3800), 'every page within Telegram cap');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Session discovery β readSessionInfo / listSessions / matchSessions
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
function makeFixtures() {
|
|
138
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gw-home-'));
|
|
139
|
+
const proj = path.join(home, '.claude', 'projects', 'proj1');
|
|
140
|
+
fs.mkdirSync(proj, { recursive: true });
|
|
141
|
+
const write = (id, cwd, lines, ageSec) => {
|
|
142
|
+
const p = path.join(proj, id + '.jsonl');
|
|
143
|
+
fs.writeFileSync(p, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
144
|
+
const t = Date.now() / 1000 - ageSec;
|
|
145
|
+
fs.utimesSync(p, t, t);
|
|
146
|
+
};
|
|
147
|
+
// Newest -> oldest via ageSec.
|
|
148
|
+
write('sess-login', '/test/repo', [
|
|
149
|
+
{ type: 'user', cwd: '/test/repo', message: { role: 'user', content: 'Fix the login bug' } },
|
|
150
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'sure' }] } },
|
|
151
|
+
], 100);
|
|
152
|
+
write('sess-shinzo', '/test/repo', [
|
|
153
|
+
{ type: 'user', cwd: '/test/repo', message: { role: 'user', content: 'Hi' } },
|
|
154
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Configuring the Shinzo keyword server.' }] } },
|
|
155
|
+
], 50);
|
|
156
|
+
write('sess-hello', '/test/repo', [
|
|
157
|
+
{ type: 'user', cwd: '/test/repo', message: { role: 'user', content: 'Hello world project' } },
|
|
158
|
+
], 200);
|
|
159
|
+
write('sess-other', '/other/repo', [
|
|
160
|
+
{ type: 'user', cwd: '/other/repo', message: { role: 'user', content: 'unrelated work' } },
|
|
161
|
+
], 10);
|
|
162
|
+
return home;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function withFixtureHome(fn) {
|
|
166
|
+
const realHome = process.env.HOME;
|
|
167
|
+
const home = makeFixtures();
|
|
168
|
+
process.env.HOME = home;
|
|
169
|
+
try { return await fn(); }
|
|
170
|
+
finally { process.env.HOME = realHome; fs.rmSync(home, { recursive: true, force: true }); }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
test('readSessionInfo: extracts cwd + first user message as label', async () => {
|
|
174
|
+
await withFixtureHome(async () => {
|
|
175
|
+
const file = path.join(process.env.HOME, '.claude', 'projects', 'proj1', 'sess-login.jsonl');
|
|
176
|
+
const info = await g.readSessionInfo(file);
|
|
177
|
+
assert.equal(info.id, 'sess-login');
|
|
178
|
+
assert.equal(info.cwd, '/test/repo');
|
|
179
|
+
assert.equal(info.label, 'Fix the login bug');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('listSessions: filters by cwd and sorts newest-first', async () => {
|
|
184
|
+
await withFixtureHome(async () => {
|
|
185
|
+
const list = await g.listSessions('/test/repo');
|
|
186
|
+
assert.deepEqual(list.map((s) => s.id), ['sess-shinzo', 'sess-login', 'sess-hello'],
|
|
187
|
+
'excludes /other/repo, newest first');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('matchSessions: matches on the label (first message)', async () => {
|
|
192
|
+
await withFixtureHome(async () => {
|
|
193
|
+
const m = await g.matchSessions('/test/repo', 'login');
|
|
194
|
+
assert.equal(m.length, 1);
|
|
195
|
+
assert.equal(m[0].id, 'sess-login');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('matchSessions: falls back to full-text content search', async () => {
|
|
200
|
+
await withFixtureHome(async () => {
|
|
201
|
+
const m = await g.matchSessions('/test/repo', 'Shinzo'); // only in content, not label
|
|
202
|
+
assert.equal(m.length, 1);
|
|
203
|
+
assert.equal(m[0].id, 'sess-shinzo');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('matchSessions: returns empty when nothing matches', async () => {
|
|
208
|
+
await withFixtureHome(async () => {
|
|
209
|
+
assert.deepEqual(await g.matchSessions('/test/repo', 'zzz-nomatch'), []);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Formatting helpers
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
test('relTime: humanizes recent timestamps', () => {
|
|
217
|
+
assert.equal(g.relTime(Date.now()), 'just now');
|
|
218
|
+
assert.match(g.relTime(Date.now() - 5 * 60 * 1000), /^5m ago$/);
|
|
219
|
+
assert.match(g.relTime(Date.now() - 3 * 3600 * 1000), /^3h ago$/);
|
|
220
|
+
assert.match(g.relTime(Date.now() - 2 * 86400 * 1000), /^2d ago$/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('formatSessionList: renders label, age and id, capped', () => {
|
|
224
|
+
const sessions = [
|
|
225
|
+
{ id: 'aaa', label: 'First task', mtime: Date.now() - 60000 },
|
|
226
|
+
{ id: 'bbb', label: '', mtime: Date.now() - 3600000 },
|
|
227
|
+
];
|
|
228
|
+
const out = g.formatSessionList(sessions);
|
|
229
|
+
assert.match(out, /First task/);
|
|
230
|
+
assert.match(out, /id: aaa/);
|
|
231
|
+
assert.match(out, /\(no first message\)/); // empty label fallback
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// renderTranscriptLine β stored transcript record -> Telegram post strings
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
test('renderTranscriptLine: assistant text is posted verbatim', () => {
|
|
238
|
+
const o = { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello there' }] } };
|
|
239
|
+
assert.deepEqual(g.renderTranscriptLine(o), ['Hello there']);
|
|
240
|
+
});
|
|
241
|
+
test('renderTranscriptLine: assistant tool_use -> π§ line', () => {
|
|
242
|
+
const o = { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'echo hi' } }] } };
|
|
243
|
+
assert.deepEqual(g.renderTranscriptLine(o), ['π§ Bash: echo hi']);
|
|
244
|
+
});
|
|
245
|
+
test('renderTranscriptLine: showTools=false hides tool_use', () => {
|
|
246
|
+
const o = { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'echo hi' } }] } };
|
|
247
|
+
assert.deepEqual(g.renderTranscriptLine(o, false), []);
|
|
248
|
+
});
|
|
249
|
+
test('renderTranscriptLine: real desk user text -> π₯οΈ desk prefix', () => {
|
|
250
|
+
const o = { type: 'user', isMeta: false, message: { content: 'Fix the bug' } };
|
|
251
|
+
assert.deepEqual(g.renderTranscriptLine(o), ['π₯οΈ desk: Fix the bug']);
|
|
252
|
+
});
|
|
253
|
+
test('renderTranscriptLine: skips thinking / meta / command-caveats / tool_result', () => {
|
|
254
|
+
assert.deepEqual(g.renderTranscriptLine({ type: 'assistant', message: { content: [{ type: 'thinking', thinking: 'x' }] } }), []);
|
|
255
|
+
assert.deepEqual(g.renderTranscriptLine({ type: 'user', isMeta: true, message: { content: 'meta' } }), []);
|
|
256
|
+
assert.deepEqual(g.renderTranscriptLine({ type: 'user', message: { content: '<local-command>hi</local-command>' } }), []);
|
|
257
|
+
assert.deepEqual(g.renderTranscriptLine({ type: 'user', message: { content: [{ type: 'tool_result', content: 'out' }] } }), []);
|
|
258
|
+
assert.deepEqual(g.renderTranscriptLine({ type: 'system' }), []);
|
|
259
|
+
});
|
|
260
|
+
test('renderTranscriptLine: mixed text + tool blocks preserve order', () => {
|
|
261
|
+
const o = { type: 'assistant', message: { content: [
|
|
262
|
+
{ type: 'text', text: 'Running now' },
|
|
263
|
+
{ type: 'tool_use', name: 'Read', input: { file_path: '/a.js' } },
|
|
264
|
+
] } };
|
|
265
|
+
assert.deepEqual(g.renderTranscriptLine(o), ['Running now', 'π§ Read: /a.js']);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// readNewLines β incremental offset reader
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
test('readNewLines: reads complete records and advances offset; keeps partial tail', () => {
|
|
272
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gw-nl-'));
|
|
273
|
+
const f = path.join(dir, 's.jsonl');
|
|
274
|
+
try {
|
|
275
|
+
fs.writeFileSync(f, JSON.stringify({ a: 1 }) + '\n' + JSON.stringify({ a: 2 }) + '\n');
|
|
276
|
+
const r1 = g.readNewLines(f, 0);
|
|
277
|
+
assert.equal(r1.lines.length, 2);
|
|
278
|
+
assert.equal(r1.lines[1].a, 2);
|
|
279
|
+
assert.equal(r1.newOffset, fs.statSync(f).size);
|
|
280
|
+
|
|
281
|
+
// Append a partial (no trailing newline) then a completing newline.
|
|
282
|
+
fs.appendFileSync(f, JSON.stringify({ a: 3 })); // incomplete
|
|
283
|
+
const r2 = g.readNewLines(f, r1.newOffset);
|
|
284
|
+
assert.equal(r2.lines.length, 0, 'incomplete line not yet emitted');
|
|
285
|
+
assert.equal(r2.newOffset, r1.newOffset, 'offset unchanged until line completes');
|
|
286
|
+
|
|
287
|
+
fs.appendFileSync(f, '\n'); // complete it
|
|
288
|
+
const r3 = g.readNewLines(f, r2.newOffset);
|
|
289
|
+
assert.equal(r3.lines.length, 1);
|
|
290
|
+
assert.equal(r3.lines[0].a, 3);
|
|
291
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
292
|
+
});
|
|
293
|
+
test('readNewLines: multi-byte content keeps byte offsets correct', () => {
|
|
294
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gw-nl2-'));
|
|
295
|
+
const f = path.join(dir, 's.jsonl');
|
|
296
|
+
try {
|
|
297
|
+
fs.writeFileSync(f, JSON.stringify({ t: 'π§ Γ©mojis' }) + '\n');
|
|
298
|
+
const r = g.readNewLines(f, 0);
|
|
299
|
+
assert.equal(r.lines[0].t, 'π§ Γ©mojis');
|
|
300
|
+
assert.equal(r.newOffset, fs.statSync(f).size);
|
|
301
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Activity windows
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
test('isActive / shouldPrune / isDeskBusy boundaries (defaults 30m / 7d / 15s)', () => {
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
assert.equal(g.isActive(now - 60_000, now), true); // 1m ago -> active
|
|
310
|
+
assert.equal(g.isActive(now - 40 * 60_000, now), false); // 40m ago -> not
|
|
311
|
+
assert.equal(g.shouldPrune(now - 3 * 86400_000, now), false); // 3d -> keep
|
|
312
|
+
assert.equal(g.shouldPrune(now - 8 * 86400_000, now), true); // 8d -> prune
|
|
313
|
+
assert.equal(g.isDeskBusy(now - 5_000, now), true); // 5s -> busy
|
|
314
|
+
assert.equal(g.isDeskBusy(now - 60_000, now), false); // 60s -> idle
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Link store internals
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
test('invertRepoMappings: repoDir -> chatId', () => {
|
|
321
|
+
const inv = g.invertRepoMappings({ '-100abc': '/repo/a', '-100def': '/repo/b' });
|
|
322
|
+
assert.equal(inv['/repo/a'], '-100abc');
|
|
323
|
+
assert.equal(inv['/repo/b'], '-100def');
|
|
324
|
+
});
|
|
325
|
+
test('splitThreadKey: handles negative chat ids with underscores', () => {
|
|
326
|
+
assert.deepEqual(g.splitThreadKey('-1001234567890_104'), ['-1001234567890', '104']);
|
|
327
|
+
});
|
|
328
|
+
test('buildThreadIndex: maps chat_thread -> sessionId', () => {
|
|
329
|
+
const idx = g.buildThreadIndex({ sidA: { chatId: '-100', threadId: 5 } });
|
|
330
|
+
assert.equal(idx.get('-100_5'), 'sidA');
|
|
331
|
+
});
|
|
332
|
+
test('migrateLegacy: converts old sessions.json entries into links', () => {
|
|
333
|
+
const links = {};
|
|
334
|
+
g.migrateLegacy(links, { '-1001234567890_104': 'sid-1' });
|
|
335
|
+
assert.equal(links['sid-1'].chatId, '-1001234567890');
|
|
336
|
+
assert.equal(links['sid-1'].threadId, 104);
|
|
337
|
+
assert.equal(links['sid-1'].offset, 0);
|
|
338
|
+
});
|
|
339
|
+
test('migrateLegacy: does not overwrite an existing link', () => {
|
|
340
|
+
const links = { 'sid-1': { chatId: 'x', threadId: 1, offset: 999 } };
|
|
341
|
+
g.migrateLegacy(links, { 'a_2': 'sid-1' });
|
|
342
|
+
assert.equal(links['sid-1'].offset, 999);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Topic naming / opener formatting
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
test('topicName: slugifies the label, falls back to short id', () => {
|
|
349
|
+
assert.equal(g.topicName({ id: 'abcdef12-0000', label: 'Fix login' }), 'π€ fix-login');
|
|
350
|
+
assert.match(g.topicName({ id: 'abcdef12-0000', label: '' }), /^π€ claude-abcdef$/);
|
|
351
|
+
});
|
|
352
|
+
test('openerText: mentions the session id and the cr resume hint', () => {
|
|
353
|
+
const t = g.openerText({ id: 'abcdef12-3456', label: 'Fix login', mtime: Date.now() });
|
|
354
|
+
assert.match(t, /abcdef12/);
|
|
355
|
+
assert.match(t, /cr/);
|
|
356
|
+
assert.match(t, /Fix login/);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// shouldAutoCreate β sidechain / empty-session filter (#3)
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
test('shouldAutoCreate: true only when a real user message (label) exists', () => {
|
|
363
|
+
assert.equal(g.shouldAutoCreate({ id: 'x', label: 'Fix login bug' }), true);
|
|
364
|
+
assert.equal(g.shouldAutoCreate({ id: 'x', label: '' }), false); // sub-agent/command-only
|
|
365
|
+
assert.equal(g.shouldAutoCreate({ id: 'x', label: null }), false);
|
|
366
|
+
assert.equal(g.shouldAutoCreate(null), false);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// ignoredSessions persistence (#5)
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
test('loadIgnored / persistIgnored: round-trip through disk', () => {
|
|
373
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gw-ign-'));
|
|
374
|
+
const file = path.join(dir, 'ignored.json');
|
|
375
|
+
try {
|
|
376
|
+
g.persistIgnored(file, new Set(['sid-a', 'sid-b']));
|
|
377
|
+
const restored = g.loadIgnored(file, new Set());
|
|
378
|
+
assert.ok(restored.has('sid-a') && restored.has('sid-b'));
|
|
379
|
+
assert.equal(restored.size, 2);
|
|
380
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
381
|
+
});
|
|
382
|
+
test('loadIgnored: missing file yields empty set (no throw)', () => {
|
|
383
|
+
const set = g.loadIgnored(path.join(os.tmpdir(), 'does-not-exist-xyz.json'), new Set());
|
|
384
|
+
assert.equal(set.size, 0);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// persisted β held/ephemeral detector (gap #1 handling)
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
test('persisted: transcript growth means the turn stuck; no growth means desk held it open', () => {
|
|
391
|
+
assert.equal(g.persisted(1000, 1200), true); // grew -> saved
|
|
392
|
+
assert.equal(g.persisted(1000, 1000), false); // no growth -> desk open, ephemeral
|
|
393
|
+
assert.equal(g.persisted(1000, 999), false);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// renderTranscriptLine β tool errors surface, successes stay quiet
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
test('renderTranscriptLine: surfaces tool errors from desk runs', () => {
|
|
400
|
+
const o = { type: 'user', message: { content: [{ type: 'tool_result', is_error: true, content: 'ENOENT: missing.txt' }] } };
|
|
401
|
+
assert.deepEqual(g.renderTranscriptLine(o), ['β οΈ tool error: ENOENT: missing.txt']);
|
|
402
|
+
});
|
|
403
|
+
test('renderTranscriptLine: successful tool results stay quiet', () => {
|
|
404
|
+
const o = { type: 'user', message: { content: [{ type: 'tool_result', is_error: false, content: 'lots of output' }] } };
|
|
405
|
+
assert.deepEqual(g.renderTranscriptLine(o), []);
|
|
406
|
+
});
|
|
407
|
+
test('renderTranscriptLine: tool error content as text blocks', () => {
|
|
408
|
+
const o = { type: 'user', message: { content: [{ type: 'tool_result', is_error: true, content: [{ type: 'text', text: 'boom happened' }] }] } };
|
|
409
|
+
assert.deepEqual(g.renderTranscriptLine(o), ['β οΈ tool error: boom happened']);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// deskUrl β editor deep link
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
test('deskUrl: builds the VS Code deep link with the session id', () => {
|
|
416
|
+
const u = g.deskUrl('abc-123-def');
|
|
417
|
+
assert.match(u, /^vscode:\/\/anthropic\.claude-code\/open\?session=abc-123-def$/);
|
|
418
|
+
});
|
|
419
|
+
test('deskUrl: url-encodes the session id', () => {
|
|
420
|
+
assert.match(g.deskUrl('a b/c'), /session=a%20b%2Fc$/);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// lastExchange β seed a new topic with where the session left off
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
test('lastExchange: returns the final user prompt + assistant response', () => {
|
|
427
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gw-le-'));
|
|
428
|
+
const f = path.join(dir, 's.jsonl');
|
|
429
|
+
try {
|
|
430
|
+
const lines = [
|
|
431
|
+
{ type: 'user', message: { content: 'first question' } },
|
|
432
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'first answer' }] } },
|
|
433
|
+
{ type: 'user', message: { content: 'second question' } },
|
|
434
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: {} }, { type: 'text', text: 'the final answer' }] } },
|
|
435
|
+
];
|
|
436
|
+
fs.writeFileSync(f, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
437
|
+
const r = g.lastExchange(f);
|
|
438
|
+
assert.equal(r.lastUser, 'second question');
|
|
439
|
+
assert.equal(r.lastText, 'the final answer');
|
|
440
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
441
|
+
});
|
|
442
|
+
test('lastExchange: missing file is safe', () => {
|
|
443
|
+
assert.deepEqual(g.lastExchange('/nope/missing.jsonl'), { lastText: null, lastUser: null });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// heldByOtherPids β self-pid filtering (the spurious-fork fix)
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
test('heldByOtherPids: filters the gateway\'s own pid out of lsof output', () => {
|
|
450
|
+
assert.deepEqual(g.heldByOtherPids('123\n456\n', 456), [123]); // other holder remains
|
|
451
|
+
assert.deepEqual(g.heldByOtherPids('456\n', 456), []); // only self β not held
|
|
452
|
+
assert.deepEqual(g.heldByOtherPids('', 456), []); // nobody β not held
|
|
453
|
+
assert.deepEqual(g.heldByOtherPids('123\n789\n', 456), [123, 789]); // multiple others
|
|
454
|
+
assert.deepEqual(g.heldByOtherPids('garbage\n123\n', 456), [123]); // non-numeric lines ignored
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Stall/approval notices β updatePendingTools + dueStallNotices
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
test('updatePendingTools: tracks tool_use, clears on tool_result', () => {
|
|
461
|
+
const state = {};
|
|
462
|
+
const t0 = 1000;
|
|
463
|
+
g.updatePendingTools(state, [
|
|
464
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tu1', name: 'Bash', input: { command: 'npm test' } }] } },
|
|
465
|
+
], t0);
|
|
466
|
+
assert.ok(state.tu1, 'pending after tool_use');
|
|
467
|
+
assert.equal(state.tu1.name, 'Bash');
|
|
468
|
+
const resolved = g.updatePendingTools(state, [
|
|
469
|
+
{ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tu1', content: 'ok' }] } },
|
|
470
|
+
], t0 + 500);
|
|
471
|
+
assert.equal(state.tu1, undefined, 'cleared after tool_result');
|
|
472
|
+
assert.deepEqual(resolved, [], 'not announced β no resolution notice');
|
|
473
|
+
});
|
|
474
|
+
test('updatePendingTools: resolution of a NOTIFIED entry is returned for announcement', () => {
|
|
475
|
+
const state = { tu1: { name: 'Bash', summary: 'x', ts: 0, notified: true } };
|
|
476
|
+
const resolved = g.updatePendingTools(state, [
|
|
477
|
+
{ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tu1' }] } },
|
|
478
|
+
], 99999);
|
|
479
|
+
assert.equal(resolved.length, 1);
|
|
480
|
+
assert.equal(resolved[0].name, 'Bash');
|
|
481
|
+
});
|
|
482
|
+
test('dueStallNotices: fires once past threshold, never twice', () => {
|
|
483
|
+
const state = { tu1: { name: 'Bash', summary: 'slow', ts: 0, notified: false } };
|
|
484
|
+
assert.equal(g.dueStallNotices(state, 30_000, 60_000).length, 0, 'below threshold');
|
|
485
|
+
const due = g.dueStallNotices(state, 61_000, 60_000);
|
|
486
|
+
assert.equal(due.length, 1, 'fires at threshold');
|
|
487
|
+
assert.equal(g.dueStallNotices(state, 120_000, 60_000).length, 0, 'does not repeat');
|
|
488
|
+
});
|
|
489
|
+
test('dueStallNotices: disabled threshold or missing state is safe', () => {
|
|
490
|
+
assert.deepEqual(g.dueStallNotices({ a: { ts: 0, notified: false } }, 99999, 0), []);
|
|
491
|
+
assert.deepEqual(g.dueStallNotices(undefined, 99999, 60_000), []);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Phone approvals β createApprovalRegistry
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
test('approvalRegistry: resolve allows and returns meta once', async () => {
|
|
498
|
+
const reg = g.createApprovalRegistry();
|
|
499
|
+
const { id, promise } = reg.create({ chatId: 'c', threadId: 7 }, 0);
|
|
500
|
+
const meta = reg.resolve(id, true, 'user1');
|
|
501
|
+
assert.deepEqual(meta, { chatId: 'c', threadId: 7 });
|
|
502
|
+
const res = await promise;
|
|
503
|
+
assert.equal(res.allowed, true);
|
|
504
|
+
assert.equal(res.by, 'user1');
|
|
505
|
+
assert.equal(reg.resolve(id, false), null, 'second resolve is a no-op');
|
|
506
|
+
assert.equal(reg.size(), 0);
|
|
507
|
+
});
|
|
508
|
+
test('approvalRegistry: times out to a deny', async () => {
|
|
509
|
+
const reg = g.createApprovalRegistry();
|
|
510
|
+
const { promise } = reg.create({ chatId: 'c', threadId: 7 }, 30);
|
|
511
|
+
const res = await promise;
|
|
512
|
+
assert.equal(res.allowed, false);
|
|
513
|
+
assert.equal(res.timedOut, true);
|
|
514
|
+
assert.equal(reg.size(), 0, 'timed-out entry cleaned up');
|
|
515
|
+
});
|
|
516
|
+
test('approvalRegistry: deny resolution', async () => {
|
|
517
|
+
const reg = g.createApprovalRegistry();
|
|
518
|
+
const { id, promise } = reg.create({}, 0);
|
|
519
|
+
reg.resolve(id, false, 'user1');
|
|
520
|
+
const res = await promise;
|
|
521
|
+
assert.equal(res.allowed, false);
|
|
522
|
+
assert.ok(!res.timedOut);
|
|
523
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Probe: what happens when we inject a headless turn (claude -p --resume) into a session that
|
|
3
|
+
# may be OPEN in the native TUI? This is the core "phone -> desk" assumption behind idle-gated
|
|
4
|
+
# injection. Run the accompanying runbook (test/MANUAL-TESTS.md, Test A) alongside this.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# test/inject-probe.sh # auto-picks the most recently modified session in the repo
|
|
8
|
+
# test/inject-probe.sh <session-uuid> # probe a specific session
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
12
|
+
CLAUDE="$(node -e 'const c=require("./config.json");process.stdout.write(c.CLAUDE_PATH||"claude")' 2>/dev/null || echo claude)"
|
|
13
|
+
REPO="$(node -e 'const c=require("./config.json");const v=Object.values(c.REPO_MAPPINGS||{});process.stdout.write(v[0]||process.env.HOME)' 2>/dev/null)"
|
|
14
|
+
PROJ="$HOME/.claude/projects/$(echo "$REPO" | sed 's|/|-|g')"
|
|
15
|
+
|
|
16
|
+
SID="${1:-}"
|
|
17
|
+
if [[ -z "$SID" ]]; then
|
|
18
|
+
SID="$(basename "$(ls -t "$PROJ"/*.jsonl 2>/dev/null | head -1)" .jsonl)"
|
|
19
|
+
fi
|
|
20
|
+
FILE="$PROJ/$SID.jsonl"
|
|
21
|
+
if [[ ! -f "$FILE" ]]; then echo "β No session file at $FILE" >&2; exit 1; fi
|
|
22
|
+
|
|
23
|
+
echo "Repo: $REPO"
|
|
24
|
+
echo "Session: $SID"
|
|
25
|
+
echo "File: $FILE"
|
|
26
|
+
PRE_SIZE=$(stat -f%z "$FILE"); PRE_MTIME=$(stat -f%m "$FILE")
|
|
27
|
+
echo "Pre: size=$PRE_SIZE mtime=$PRE_MTIME"
|
|
28
|
+
echo
|
|
29
|
+
echo "β Injecting headless turn (this is what the gateway does when it thinks the desk is idle)β¦"
|
|
30
|
+
# IMPORTANT: claude resolves --resume against the CURRENT directory's project folder, so we must
|
|
31
|
+
# run from the repo (exactly as the gateway does via spawn cwd:repoDir). Running elsewhere yields
|
|
32
|
+
# a misleading "No conversation found".
|
|
33
|
+
set +e
|
|
34
|
+
OUT=$(cd "$REPO" && printf '%s' "Reply with exactly the word: INJECTED_OK" | "$CLAUDE" -p --resume "$SID" \
|
|
35
|
+
--output-format json --permission-mode bypassPermissions 2>&1)
|
|
36
|
+
RC=$?
|
|
37
|
+
set -e
|
|
38
|
+
|
|
39
|
+
echo "$OUT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);console.log(" exit_ok:",!j.is_error,"\n same_session_id:",j.session_id);console.log(" result:",JSON.stringify(j.result));}catch(e){console.log(" β οΈ non-JSON output (possible lock/conflict):\n"+s.slice(0,400))}})'
|
|
40
|
+
echo " raw_exit_code: $RC"
|
|
41
|
+
|
|
42
|
+
POST_SIZE=$(stat -f%z "$FILE");
|
|
43
|
+
echo
|
|
44
|
+
echo "Post: size=$POST_SIZE (grew by $((POST_SIZE-PRE_SIZE)) bytes) β appended to the SAME file: $([ "$POST_SIZE" -gt "$PRE_SIZE" ] && echo yes || echo NO)"
|
|
45
|
+
NEW_FILES=$(find "$PROJ" -name '*.jsonl' -newermt "@$PRE_MTIME" ! -name "$SID.jsonl" 2>/dev/null | wc -l | tr -d ' ')
|
|
46
|
+
echo "New sibling session files created by the injection: $NEW_FILES (expect 0 β >0 means it forked)"
|
|
47
|
+
echo
|
|
48
|
+
echo "NEXT: switch to the TUI terminal, send another message, and confirm the TUI still works and"
|
|
49
|
+
echo "shows a coherent history. See test/MANUAL-TESTS.md (Test A) for how to read the result."
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Delete every forum topic the gateway created (listed in links.json) and clear the store.
|
|
4
|
+
// This only removes the Telegram MIRROR topics β the underlying Claude sessions on disk are
|
|
5
|
+
// untouched. Use to recover from a bad run before reinstalling. Run: node test/reset-topics.js
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'config.json'), 'utf8'));
|
|
11
|
+
const LINKS = path.join(__dirname, '..', 'links.json');
|
|
12
|
+
const links = fs.existsSync(LINKS) ? JSON.parse(fs.readFileSync(LINKS, 'utf8')) : {};
|
|
13
|
+
|
|
14
|
+
function tg(method, payload) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const data = JSON.stringify(payload);
|
|
17
|
+
const req = https.request({
|
|
18
|
+
hostname: 'api.telegram.org', port: 443, path: `/bot${cfg.BOT_TOKEN}/${method}`, method: 'POST',
|
|
19
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
|
20
|
+
}, (res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => { try { resolve(JSON.parse(b)); } catch (e) { resolve({ ok: false }); } }); });
|
|
21
|
+
req.on('error', () => resolve({ ok: false })); req.write(data); req.end();
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
(async () => {
|
|
26
|
+
const entries = Object.entries(links);
|
|
27
|
+
if (!entries.length) { console.log('links.json is already empty β nothing to reset.'); return; }
|
|
28
|
+
console.log(`Deleting ${entries.length} mirror topic(s)β¦`);
|
|
29
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30
|
+
for (const [sid, l] of entries) {
|
|
31
|
+
let r = await tg('deleteForumTopic', { chat_id: l.chatId, message_thread_id: l.threadId });
|
|
32
|
+
if (!r.ok && /Too Many Requests/i.test(r.description || '')) { // back off and retry once
|
|
33
|
+
const wait = ((r.parameters && r.parameters.retry_after) || 5) * 1000;
|
|
34
|
+
console.log(` rate-limited; waiting ${wait / 1000}sβ¦`); await sleep(wait);
|
|
35
|
+
r = await tg('deleteForumTopic', { chat_id: l.chatId, message_thread_id: l.threadId });
|
|
36
|
+
}
|
|
37
|
+
console.log(` thread ${l.threadId} (${sid.slice(0, 8)}${l.label ? ' β ' + l.label.slice(0, 30) : ''}): ${r.ok ? 'π deleted' : 'β οΈ ' + (r.description || 'failed')}`);
|
|
38
|
+
await sleep(400);
|
|
39
|
+
}
|
|
40
|
+
fs.writeFileSync(LINKS, '{}');
|
|
41
|
+
console.log('β
Cleared links.json. The Claude sessions on disk are untouched; reinstall to re-create clean topics.');
|
|
42
|
+
})();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Stop and remove the Claude Code Telegram gateway launchd service.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
LABEL="com.claude.telegram-gateway"
|
|
5
|
+
PLIST_DST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
|
6
|
+
|
|
7
|
+
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
|
|
8
|
+
rm -f "$PLIST_DST"
|
|
9
|
+
|
|
10
|
+
# Strip the auto-resume block from ~/.zshrc (between the marker lines).
|
|
11
|
+
RC="$HOME/.zshrc"
|
|
12
|
+
if [ -f "$RC" ] && grep -qF 'claude-gateway auto-resume' "$RC"; then
|
|
13
|
+
sed -i '' '/# >>> claude-gateway auto-resume >>>/,/# <<< claude-gateway auto-resume <<</d' "$RC" 2>/dev/null \
|
|
14
|
+
|| sed -i '/# >>> claude-gateway auto-resume >>>/,/# <<< claude-gateway auto-resume <<</d' "$RC"
|
|
15
|
+
echo "π Removed the auto-resume hook from $RC."
|
|
16
|
+
fi
|
|
17
|
+
echo "β
Stopped and removed $LABEL."
|