cursor-telegram-mcp 0.5.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 +272 -0
- package/dist/agentRunner.js +332 -0
- package/dist/answerWaiters.js +64 -0
- package/dist/cli.js +66 -0
- package/dist/config.js +160 -0
- package/dist/doctor.js +116 -0
- package/dist/formatTelegram.js +28 -0
- package/dist/index.js +334 -0
- package/dist/login.js +59 -0
- package/dist/parseInbound.js +93 -0
- package/dist/session.js +49 -0
- package/dist/setup.js +127 -0
- package/dist/splitMessage.js +61 -0
- package/dist/store.js +81 -0
- package/dist/taskQueue.js +33 -0
- package/dist/telegram.js +241 -0
- package/dist/transcript.js +56 -0
- package/dist/worker.js +667 -0
- package/mcp.client.template.json +12 -0
- package/package.json +58 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split long outbound text into Telegram-sized chunks (~4000 chars).
|
|
3
|
+
* Prefers paragraph breaks, then single newlines, then hard split.
|
|
4
|
+
*/
|
|
5
|
+
const DEFAULT_MAX = 4000;
|
|
6
|
+
function hardSplit(text, max) {
|
|
7
|
+
const chunks = [];
|
|
8
|
+
for (let i = 0; i < text.length; i += max) {
|
|
9
|
+
chunks.push(text.slice(i, i + max));
|
|
10
|
+
}
|
|
11
|
+
return chunks;
|
|
12
|
+
}
|
|
13
|
+
function splitBlock(block, max) {
|
|
14
|
+
if (block.length <= max)
|
|
15
|
+
return [block];
|
|
16
|
+
const paragraphs = block.split(/\n\n+/);
|
|
17
|
+
if (paragraphs.length > 1) {
|
|
18
|
+
const out = [];
|
|
19
|
+
let current = "";
|
|
20
|
+
for (const para of paragraphs) {
|
|
21
|
+
const candidate = current === "" ? para : `${current}\n\n${para}`;
|
|
22
|
+
if (candidate.length <= max) {
|
|
23
|
+
current = candidate;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
if (current !== "")
|
|
27
|
+
out.push(...splitBlock(current, max));
|
|
28
|
+
current = para;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (current !== "")
|
|
32
|
+
out.push(...splitBlock(current, max));
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
const lines = block.split("\n");
|
|
36
|
+
if (lines.length > 1) {
|
|
37
|
+
const out = [];
|
|
38
|
+
let current = "";
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
const candidate = current === "" ? line : `${current}\n${line}`;
|
|
41
|
+
if (candidate.length <= max) {
|
|
42
|
+
current = candidate;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
if (current !== "")
|
|
46
|
+
out.push(...splitBlock(current, max));
|
|
47
|
+
current = line;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (current !== "")
|
|
51
|
+
out.push(...splitBlock(current, max));
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
return hardSplit(block, max);
|
|
55
|
+
}
|
|
56
|
+
/** Split text into chunks no longer than max (default 4000). */
|
|
57
|
+
export function splitMessage(text, max = DEFAULT_MAX) {
|
|
58
|
+
if (text.length <= max)
|
|
59
|
+
return [text];
|
|
60
|
+
return splitBlock(text, max);
|
|
61
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Keep answered questions this long after they are answered, then prune. */
|
|
2
|
+
const ANSWERED_TTL_MS = 60 * 60_000;
|
|
3
|
+
export class QuestionStore {
|
|
4
|
+
nextId = 1;
|
|
5
|
+
questions = new Map();
|
|
6
|
+
/** Create a new pending question. */
|
|
7
|
+
addQuestion(projectLabel, question) {
|
|
8
|
+
this.prune();
|
|
9
|
+
const record = {
|
|
10
|
+
id: `Q-${this.nextId++}`,
|
|
11
|
+
projectLabel,
|
|
12
|
+
question,
|
|
13
|
+
status: "pending",
|
|
14
|
+
createdAt: Date.now(),
|
|
15
|
+
};
|
|
16
|
+
this.questions.set(record.id, record);
|
|
17
|
+
return record;
|
|
18
|
+
}
|
|
19
|
+
/** Attach the outgoing Telegram message id to a question. */
|
|
20
|
+
setSentMessageId(id, messageId) {
|
|
21
|
+
const record = this.questions.get(id);
|
|
22
|
+
if (record)
|
|
23
|
+
record.sentMessageId = messageId;
|
|
24
|
+
}
|
|
25
|
+
/** Look up a single question by id. */
|
|
26
|
+
get(id) {
|
|
27
|
+
return this.questions.get(id);
|
|
28
|
+
}
|
|
29
|
+
/** Number of still-unanswered questions. */
|
|
30
|
+
pendingCount() {
|
|
31
|
+
let n = 0;
|
|
32
|
+
for (const q of this.questions.values())
|
|
33
|
+
if (q.status === "pending")
|
|
34
|
+
n++;
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
/** Pending questions, oldest first. */
|
|
38
|
+
listPending() {
|
|
39
|
+
return [...this.questions.values()]
|
|
40
|
+
.filter((q) => q.status === "pending")
|
|
41
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Match an inbound message to a pending question and record the answer.
|
|
45
|
+
* Prefers an exact reply-to match; otherwise answers the oldest pending
|
|
46
|
+
* question (FIFO). Returns the answered record, or null if nothing matched.
|
|
47
|
+
*/
|
|
48
|
+
matchAndAnswer(incoming) {
|
|
49
|
+
const pendings = [...this.questions.values()]
|
|
50
|
+
.filter((q) => q.status === "pending")
|
|
51
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
52
|
+
if (pendings.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
let target;
|
|
55
|
+
if (incoming.quotedMessageId) {
|
|
56
|
+
target = pendings.find((q) => q.sentMessageId && q.sentMessageId === incoming.quotedMessageId);
|
|
57
|
+
}
|
|
58
|
+
if (!target)
|
|
59
|
+
target = pendings[0];
|
|
60
|
+
target.status = "answered";
|
|
61
|
+
target.answer = incoming.text;
|
|
62
|
+
if (incoming.attachments.length > 0) {
|
|
63
|
+
target.answerAttachments = incoming.attachments;
|
|
64
|
+
}
|
|
65
|
+
target.answeredAt = incoming.timestamp || Date.now();
|
|
66
|
+
return target;
|
|
67
|
+
}
|
|
68
|
+
/** Drop answered questions older than the TTL. */
|
|
69
|
+
prune() {
|
|
70
|
+
const cutoff = Date.now() - ANSWERED_TTL_MS;
|
|
71
|
+
for (const [id, q] of this.questions) {
|
|
72
|
+
if (q.status === "answered" && (q.answeredAt ?? q.createdAt) < cutoff) {
|
|
73
|
+
this.questions.delete(id);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Build a fresh in-memory store. */
|
|
79
|
+
export function createStore() {
|
|
80
|
+
return new QuestionStore();
|
|
81
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory FIFO queue for /ask, /plan, and plain-text tasks while the worker
|
|
3
|
+
* is busy planning, executing, or answering.
|
|
4
|
+
*/
|
|
5
|
+
export class TaskQueue {
|
|
6
|
+
items = [];
|
|
7
|
+
enqueue(item) {
|
|
8
|
+
this.items.push(item);
|
|
9
|
+
return this.items.length;
|
|
10
|
+
}
|
|
11
|
+
dequeue() {
|
|
12
|
+
return this.items.shift();
|
|
13
|
+
}
|
|
14
|
+
length() {
|
|
15
|
+
return this.items.length;
|
|
16
|
+
}
|
|
17
|
+
/** Short summary for STATUS output. */
|
|
18
|
+
preview(max = 3) {
|
|
19
|
+
if (this.items.length === 0)
|
|
20
|
+
return "none";
|
|
21
|
+
const shown = this.items.slice(0, max).map((item, i) => {
|
|
22
|
+
const label = item.kind === "ask" ? "ask" : "plan";
|
|
23
|
+
const snippet = item.text.slice(0, 40).replace(/\s+/g, " ");
|
|
24
|
+
const suffix = item.text.length > 40 ? "..." : "";
|
|
25
|
+
return `${i + 1}:${label} "${snippet}${suffix}"`;
|
|
26
|
+
});
|
|
27
|
+
const extra = this.items.length > max ? ` (+${this.items.length - max} more)` : "";
|
|
28
|
+
return `${this.items.length} (${shown.join("; ")}${extra})`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function createTaskQueue() {
|
|
32
|
+
return new TaskQueue();
|
|
33
|
+
}
|
package/dist/telegram.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Bot API transport (long polling).
|
|
3
|
+
*
|
|
4
|
+
* The Bot API is plain HTTPS - no QR, eSIM, or on-disk session state. Inbound
|
|
5
|
+
* messages arrive via `getUpdates` long polling, so no public URL/webhook is
|
|
6
|
+
* needed: this runs entirely on your machine.
|
|
7
|
+
*/
|
|
8
|
+
import { createWriteStream } from "node:fs";
|
|
9
|
+
import { mkdir, readdir, stat, unlink } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { finished } from "node:stream/promises";
|
|
12
|
+
export function createStderrLogger(level = "info") {
|
|
13
|
+
const order = { error: 0, warn: 1, info: 2 };
|
|
14
|
+
const threshold = order[level];
|
|
15
|
+
const write = (lvl, msg) => {
|
|
16
|
+
if (order[lvl] <= threshold)
|
|
17
|
+
process.stderr.write(`[telegram:${lvl}] ${msg}\n`);
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
info: (m) => write("info", m),
|
|
21
|
+
warn: (m) => write("warn", m),
|
|
22
|
+
error: (m) => write("error", m),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function silentLogger() {
|
|
26
|
+
return { info: () => { }, warn: () => { }, error: () => { } };
|
|
27
|
+
}
|
|
28
|
+
const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
29
|
+
const DEFAULT_MEDIA_DIR = ".telegram-media";
|
|
30
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
31
|
+
const DEFAULT_MEDIA_TTL_MS = 24 * 60 * 60_000;
|
|
32
|
+
/**
|
|
33
|
+
* TIMEOUTS (three different values — do not conflate):
|
|
34
|
+
* - GETUPDATES_LONG_POLL_SEC (30): Bot API long-poll for inbound messages only
|
|
35
|
+
* (worker receive path). NOT how often the agent should poll.
|
|
36
|
+
* - waitMs / ask_human_and_wait: agent-side long-poll on GET /response/:id;
|
|
37
|
+
* wakes immediately when the human replies (see MCP tools in index.ts).
|
|
38
|
+
* - TG_RESPONSE_TIMEOUT_MIN (default 30 minutes): how long a question stays
|
|
39
|
+
* open before status timed_out. Unrelated to getUpdates.
|
|
40
|
+
*/
|
|
41
|
+
export const GETUPDATES_LONG_POLL_SEC = 30;
|
|
42
|
+
function extForMime(mime) {
|
|
43
|
+
if (mime === "image/png")
|
|
44
|
+
return ".png";
|
|
45
|
+
if (mime === "image/webp")
|
|
46
|
+
return ".webp";
|
|
47
|
+
return ".jpg";
|
|
48
|
+
}
|
|
49
|
+
/** Thin wrapper around the Telegram Bot API: validate, long-poll, sendText. */
|
|
50
|
+
export class TelegramClient {
|
|
51
|
+
opts;
|
|
52
|
+
logger;
|
|
53
|
+
base;
|
|
54
|
+
handlers = [];
|
|
55
|
+
mediaDir;
|
|
56
|
+
maxAttachmentBytes;
|
|
57
|
+
mediaTtlMs;
|
|
58
|
+
open = false;
|
|
59
|
+
stopping = false;
|
|
60
|
+
offset = 0;
|
|
61
|
+
me = null;
|
|
62
|
+
constructor(opts) {
|
|
63
|
+
this.opts = opts;
|
|
64
|
+
this.logger = opts.logger ?? silentLogger();
|
|
65
|
+
this.base = `https://api.telegram.org/bot${opts.botToken}`;
|
|
66
|
+
this.mediaDir = opts.mediaDir ?? join(process.cwd(), DEFAULT_MEDIA_DIR);
|
|
67
|
+
this.maxAttachmentBytes = opts.maxAttachmentBytes ?? DEFAULT_MAX_BYTES;
|
|
68
|
+
this.mediaTtlMs = opts.mediaTtlMs ?? DEFAULT_MEDIA_TTL_MS;
|
|
69
|
+
}
|
|
70
|
+
isOpen() {
|
|
71
|
+
return this.open;
|
|
72
|
+
}
|
|
73
|
+
username() {
|
|
74
|
+
return this.me?.username;
|
|
75
|
+
}
|
|
76
|
+
onIncoming(handler) {
|
|
77
|
+
this.handlers.push(handler);
|
|
78
|
+
}
|
|
79
|
+
async call(method, params) {
|
|
80
|
+
const res = await fetch(`${this.base}/${method}`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "content-type": "application/json" },
|
|
83
|
+
body: JSON.stringify(params ?? {}),
|
|
84
|
+
});
|
|
85
|
+
const data = (await res.json());
|
|
86
|
+
if (!data.ok) {
|
|
87
|
+
throw new Error(`Telegram ${method} failed: ${data.description ?? `HTTP ${res.status}`}`);
|
|
88
|
+
}
|
|
89
|
+
return data.result;
|
|
90
|
+
}
|
|
91
|
+
async connect() {
|
|
92
|
+
await mkdir(this.mediaDir, { recursive: true });
|
|
93
|
+
this.me = await this.call("getMe");
|
|
94
|
+
await this.call("deleteWebhook", {}).catch(() => undefined);
|
|
95
|
+
this.open = true;
|
|
96
|
+
this.opts.onReady?.();
|
|
97
|
+
void this.pollLoop();
|
|
98
|
+
void this.cleanupOldMedia();
|
|
99
|
+
const sweep = setInterval(() => void this.cleanupOldMedia(), 60 * 60_000);
|
|
100
|
+
sweep.unref?.();
|
|
101
|
+
}
|
|
102
|
+
async pollLoop() {
|
|
103
|
+
while (!this.stopping) {
|
|
104
|
+
try {
|
|
105
|
+
const updates = await this.call("getUpdates", {
|
|
106
|
+
offset: this.offset,
|
|
107
|
+
timeout: GETUPDATES_LONG_POLL_SEC,
|
|
108
|
+
allowed_updates: ["message"],
|
|
109
|
+
});
|
|
110
|
+
for (const update of updates) {
|
|
111
|
+
this.offset = update.update_id + 1;
|
|
112
|
+
const msg = await this.toIncoming(update.message);
|
|
113
|
+
if (msg)
|
|
114
|
+
for (const h of this.handlers)
|
|
115
|
+
h(msg);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (this.stopping)
|
|
120
|
+
break;
|
|
121
|
+
this.logger.warn(`getUpdates error (retrying): ${String(err)}`);
|
|
122
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
pickImage(message) {
|
|
127
|
+
if (message.photo && message.photo.length > 0) {
|
|
128
|
+
const largest = message.photo[message.photo.length - 1];
|
|
129
|
+
return { fileId: largest.file_id, mimeType: "image/jpeg", size: largest.file_size };
|
|
130
|
+
}
|
|
131
|
+
if (message.document?.mime_type && ALLOWED_MIME.has(message.document.mime_type)) {
|
|
132
|
+
return {
|
|
133
|
+
fileId: message.document.file_id,
|
|
134
|
+
mimeType: message.document.mime_type,
|
|
135
|
+
size: message.document.file_size,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
async downloadFile(fileId, mimeType) {
|
|
141
|
+
const file = await this.call("getFile", { file_id: fileId });
|
|
142
|
+
if (!file.file_path)
|
|
143
|
+
throw new Error("Telegram getFile returned no file_path");
|
|
144
|
+
if (file.file_size != null && file.file_size > this.maxAttachmentBytes) {
|
|
145
|
+
throw new Error(`Attachment too large (${file.file_size} bytes, max ${this.maxAttachmentBytes})`);
|
|
146
|
+
}
|
|
147
|
+
const url = `https://api.telegram.org/file/bot${this.opts.botToken}/${file.file_path}`;
|
|
148
|
+
const res = await fetch(url);
|
|
149
|
+
if (!res.ok)
|
|
150
|
+
throw new Error(`Download failed: HTTP ${res.status}`);
|
|
151
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
152
|
+
if (buf.length > this.maxAttachmentBytes) {
|
|
153
|
+
throw new Error(`Attachment too large (${buf.length} bytes, max ${this.maxAttachmentBytes})`);
|
|
154
|
+
}
|
|
155
|
+
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${extForMime(mimeType)}`;
|
|
156
|
+
const localPath = join(this.mediaDir, name);
|
|
157
|
+
const ws = createWriteStream(localPath);
|
|
158
|
+
ws.write(buf);
|
|
159
|
+
ws.end();
|
|
160
|
+
await finished(ws);
|
|
161
|
+
return { fileId, mimeType, localPath, sizeBytes: buf.length };
|
|
162
|
+
}
|
|
163
|
+
async toIncoming(message) {
|
|
164
|
+
if (!message)
|
|
165
|
+
return null;
|
|
166
|
+
const text = (message.text ?? message.caption ?? "").trim();
|
|
167
|
+
const image = this.pickImage(message);
|
|
168
|
+
if (!text && !image)
|
|
169
|
+
return null;
|
|
170
|
+
let attachments = [];
|
|
171
|
+
if (image) {
|
|
172
|
+
if (image.size != null && image.size > this.maxAttachmentBytes) {
|
|
173
|
+
this.logger.warn(`Skipping oversized attachment ${image.fileId}`);
|
|
174
|
+
return {
|
|
175
|
+
text: text || "Image too large (max 10 MB). Send a smaller image.",
|
|
176
|
+
fromChatId: String(message.chat.id),
|
|
177
|
+
quotedMessageId: message.reply_to_message != null
|
|
178
|
+
? String(message.reply_to_message.message_id)
|
|
179
|
+
: undefined,
|
|
180
|
+
timestamp: message.date ? message.date * 1000 : Date.now(),
|
|
181
|
+
attachments: [],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
attachments = [await this.downloadFile(image.fileId, image.mimeType)];
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
this.logger.warn(`Failed to download attachment: ${String(err)}`);
|
|
189
|
+
return {
|
|
190
|
+
text: text || `Could not download image: ${String(err)}`,
|
|
191
|
+
fromChatId: String(message.chat.id),
|
|
192
|
+
quotedMessageId: message.reply_to_message != null
|
|
193
|
+
? String(message.reply_to_message.message_id)
|
|
194
|
+
: undefined,
|
|
195
|
+
timestamp: message.date ? message.date * 1000 : Date.now(),
|
|
196
|
+
attachments: [],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const photoOnly = attachments.length > 0 && text === "";
|
|
201
|
+
return {
|
|
202
|
+
text,
|
|
203
|
+
fromChatId: String(message.chat.id),
|
|
204
|
+
quotedMessageId: message.reply_to_message != null ? String(message.reply_to_message.message_id) : undefined,
|
|
205
|
+
timestamp: message.date ? message.date * 1000 : Date.now(),
|
|
206
|
+
attachments,
|
|
207
|
+
photoOnly: photoOnly || undefined,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async sendText(chatId, text) {
|
|
211
|
+
const sent = await this.call("sendMessage", { chat_id: chatId, text });
|
|
212
|
+
return sent?.message_id != null ? String(sent.message_id) : undefined;
|
|
213
|
+
}
|
|
214
|
+
static sameChat(a, b) {
|
|
215
|
+
return a.trim() !== "" && a.trim() === b.trim();
|
|
216
|
+
}
|
|
217
|
+
async cleanupOldMedia() {
|
|
218
|
+
try {
|
|
219
|
+
const entries = await readdir(this.mediaDir);
|
|
220
|
+
const cutoff = Date.now() - this.mediaTtlMs;
|
|
221
|
+
for (const name of entries) {
|
|
222
|
+
const path = join(this.mediaDir, name);
|
|
223
|
+
try {
|
|
224
|
+
const st = await stat(path);
|
|
225
|
+
if (st.mtimeMs < cutoff)
|
|
226
|
+
await unlink(path);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// ignore per-file errors
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// media dir may not exist yet
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async close() {
|
|
238
|
+
this.stopping = true;
|
|
239
|
+
this.open = false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only Markdown transcript of the rolling remote chat.
|
|
3
|
+
*
|
|
4
|
+
* Every turn of the phone-driven conversation (your prompt, the plan, the
|
|
5
|
+
* result, an answer) is appended to a single file (default
|
|
6
|
+
* `<agentCwd>/remote-chat.md`). The file lives in the repo so you can read the
|
|
7
|
+
* same conversation on your computer that you are driving from your phone, and
|
|
8
|
+
* git tracks its history.
|
|
9
|
+
*/
|
|
10
|
+
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
const HEADING = {
|
|
13
|
+
you: "You (phone)",
|
|
14
|
+
plan: "Plan",
|
|
15
|
+
done: "Done",
|
|
16
|
+
ask: "Ask",
|
|
17
|
+
answer: "Answer",
|
|
18
|
+
error: "Error",
|
|
19
|
+
system: "System",
|
|
20
|
+
};
|
|
21
|
+
const FILE_HEADER = "# Remote chat\n\n" +
|
|
22
|
+
"Live transcript of the conversation you drive from your phone over Telegram. " +
|
|
23
|
+
"Each turn is appended below; this file is safe to commit for history.\n";
|
|
24
|
+
function timestamp() {
|
|
25
|
+
return new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
26
|
+
}
|
|
27
|
+
/** A transcript writer bound to a single file path. */
|
|
28
|
+
export class Transcript {
|
|
29
|
+
path;
|
|
30
|
+
constructor(path) {
|
|
31
|
+
this.path = path;
|
|
32
|
+
}
|
|
33
|
+
get filePath() {
|
|
34
|
+
return this.path;
|
|
35
|
+
}
|
|
36
|
+
ensureFile() {
|
|
37
|
+
if (existsSync(this.path))
|
|
38
|
+
return;
|
|
39
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
40
|
+
writeFileSync(this.path, FILE_HEADER, "utf8");
|
|
41
|
+
}
|
|
42
|
+
/** Append a single conversation turn. Best-effort; never throws. */
|
|
43
|
+
append(entry) {
|
|
44
|
+
try {
|
|
45
|
+
this.ensureFile();
|
|
46
|
+
const label = HEADING[entry.role];
|
|
47
|
+
const suffix = entry.id ? ` (${entry.id})` : "";
|
|
48
|
+
const body = entry.text.trim() === "" ? "(empty)" : entry.text.trim();
|
|
49
|
+
const block = `\n## ${timestamp()} - ${label}${suffix}\n\n${body}\n`;
|
|
50
|
+
appendFileSync(this.path, block, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Best-effort: a failed transcript write must not break the chat.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|