codeep 3.0.0 → 3.1.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/dist/config/index.d.ts +6 -0
- package/dist/config/index.js +1 -0
- package/dist/config/providers.js +4 -2
- package/dist/renderer/App.d.ts +5 -1
- package/dist/renderer/App.js +12 -4
- package/dist/renderer/agentExecution.js +56 -5
- package/dist/renderer/components/Settings.js +11 -0
- package/dist/renderer/index.d.ts +12 -6
- package/dist/renderer/layout.d.ts +7 -0
- package/dist/renderer/layout.js +1 -1
- package/dist/renderer/main.js +46 -2
- package/dist/utils/telegramApproval.d.ts +5 -20
- package/dist/utils/telegramApproval.js +16 -55
- package/dist/utils/telegramCredentials.d.ts +8 -0
- package/dist/utils/telegramCredentials.js +15 -0
- package/dist/utils/telegramInbox.d.ts +93 -0
- package/dist/utils/telegramInbox.js +188 -0
- package/dist/utils/telegramNotify.d.ts +76 -0
- package/dist/utils/telegramNotify.js +132 -0
- package/dist/utils/telegramUpdates.d.ts +71 -0
- package/dist/utils/telegramUpdates.js +175 -0
- package/dist/utils/tokenTracker.js +51 -8
- package/dist/utils/tools.js +6 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting work from the phone.
|
|
3
|
+
*
|
|
4
|
+
* Approval lets the phone answer a question the agent asked. This lets the
|
|
5
|
+
* phone ask one — which is a different thing, and a larger one. A chat that can
|
|
6
|
+
* only say Run, Skip or Cancel is bounded by what the agent already decided to
|
|
7
|
+
* do; a chat that can send a prompt is a keyboard attached to this machine.
|
|
8
|
+
* That is why it is off by default and has its own switch rather than riding on
|
|
9
|
+
* the approval one.
|
|
10
|
+
*/
|
|
11
|
+
import { sharedUpdates } from './telegramUpdates.js';
|
|
12
|
+
/** Longest prompt accepted. Past this it is a paste, not an instruction. */
|
|
13
|
+
export const MAX_PROMPT_LENGTH = 2000;
|
|
14
|
+
/**
|
|
15
|
+
* How old a message may be and still run.
|
|
16
|
+
*
|
|
17
|
+
* Telegram keeps undelivered updates for 24 hours. Without this, a message
|
|
18
|
+
* typed at midnight to a machine that was switched off would be handed to the
|
|
19
|
+
* agent the moment the CLI next started — hours later, in a repository that has
|
|
20
|
+
* moved on, with nobody watching. A prompt is an instruction for now.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_MESSAGE_AGE_MS = 5 * 60_000;
|
|
23
|
+
/**
|
|
24
|
+
* Whether a message update came from the one chat allowed to drive this agent.
|
|
25
|
+
*
|
|
26
|
+
* A bot's username is discoverable, so without this anyone who found it could
|
|
27
|
+
* type into somebody else's terminal. Mirrors the approval check deliberately:
|
|
28
|
+
* both gate the same machine, and they should not be able to disagree.
|
|
29
|
+
*/
|
|
30
|
+
export function messageFromOwner(message, chatID) {
|
|
31
|
+
if (!chatID)
|
|
32
|
+
return false;
|
|
33
|
+
const chat = message?.chat;
|
|
34
|
+
const id = chat?.id;
|
|
35
|
+
if (typeof id === 'number')
|
|
36
|
+
return String(id) === chatID;
|
|
37
|
+
if (typeof id === 'string')
|
|
38
|
+
return id === chatID;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Turn a Telegram message into a prompt, or say why it is not one.
|
|
43
|
+
*
|
|
44
|
+
* Returns a reason rather than null throughout: every rejection here has a
|
|
45
|
+
* different thing to tell the sender, and a bot that goes quiet is
|
|
46
|
+
* indistinguishable from one that is switched off.
|
|
47
|
+
*/
|
|
48
|
+
export function extractPrompt(message, chatID, now = Date.now()) {
|
|
49
|
+
if (!messageFromOwner(message, chatID))
|
|
50
|
+
return { ok: false, reason: 'not-from-owner' };
|
|
51
|
+
const text = message?.text;
|
|
52
|
+
// A photo, sticker or voice note has no `text` at all.
|
|
53
|
+
if (typeof text !== 'string')
|
|
54
|
+
return { ok: false, reason: 'not-text' };
|
|
55
|
+
const trimmed = text.trim();
|
|
56
|
+
if (trimmed.length === 0)
|
|
57
|
+
return { ok: false, reason: 'empty' };
|
|
58
|
+
if (trimmed.length > MAX_PROMPT_LENGTH)
|
|
59
|
+
return { ok: false, reason: 'too-long' };
|
|
60
|
+
// `/start` and friends are addressed to the bot, not to the agent.
|
|
61
|
+
if (trimmed.startsWith('/'))
|
|
62
|
+
return { ok: false, reason: 'command' };
|
|
63
|
+
const date = message?.date;
|
|
64
|
+
if (typeof date === 'number' && now - date * 1000 > MAX_MESSAGE_AGE_MS) {
|
|
65
|
+
return { ok: false, reason: 'stale' };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, text: trimmed };
|
|
68
|
+
}
|
|
69
|
+
/** What to say back, so silence never has to be interpreted. */
|
|
70
|
+
export function describeRejection(reason) {
|
|
71
|
+
switch (reason) {
|
|
72
|
+
// Answering a stranger confirms the bot is live and attached to something
|
|
73
|
+
// worth probing. They get nothing.
|
|
74
|
+
case 'not-from-owner': return null;
|
|
75
|
+
case 'not-text': return 'I can only take text — send the instruction as a message.';
|
|
76
|
+
case 'empty': return null;
|
|
77
|
+
case 'too-long': return `That is longer than ${MAX_PROMPT_LENGTH} characters. Send a shorter instruction, or paste the text into the terminal.`;
|
|
78
|
+
case 'stale': return 'That message is older than five minutes, so I have not run it. Send it again if you still want it.';
|
|
79
|
+
case 'command': return 'Send an instruction in plain words — I do not take slash commands here.';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The one instruction waiting for the agent to be free.
|
|
84
|
+
*
|
|
85
|
+
* One slot, and a newer message replaces an older one. A queue that grows is a
|
|
86
|
+
* queue that runs things you have forgotten asking for: send two corrections
|
|
87
|
+
* while a run is busy and you meant the second, not both in order. Replacing
|
|
88
|
+
* says so out loud rather than quietly dropping either.
|
|
89
|
+
*/
|
|
90
|
+
export class InboxQueue {
|
|
91
|
+
pending = null;
|
|
92
|
+
/** @returns whether this displaced an instruction that had not run yet. */
|
|
93
|
+
offer(text) {
|
|
94
|
+
const replaced = this.pending !== null;
|
|
95
|
+
this.pending = text;
|
|
96
|
+
return { replaced };
|
|
97
|
+
}
|
|
98
|
+
/** Hand over what is waiting, and empty the slot. */
|
|
99
|
+
take() {
|
|
100
|
+
const out = this.pending;
|
|
101
|
+
this.pending = null;
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
get waiting() {
|
|
105
|
+
return this.pending !== null;
|
|
106
|
+
}
|
|
107
|
+
clear() {
|
|
108
|
+
this.pending = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Confirmation for a prompt that will run later, so the sender can stop wondering. */
|
|
112
|
+
export function describeQueued(replaced) {
|
|
113
|
+
return replaced
|
|
114
|
+
? 'Queued — replaces the one you sent before it. It runs when the current task finishes.'
|
|
115
|
+
: 'Queued — it runs when the current task finishes.';
|
|
116
|
+
}
|
|
117
|
+
/** Confirmation for a prompt that starts immediately. */
|
|
118
|
+
export function describeStarted(text) {
|
|
119
|
+
const short = text.length > 60 ? `${text.slice(0, 57)}…` : text;
|
|
120
|
+
return `Started — ${short}`;
|
|
121
|
+
}
|
|
122
|
+
// ─── Whose run is it ──────────────────────────────────────────────────────────
|
|
123
|
+
/**
|
|
124
|
+
* Whether the run now finishing was started from the phone.
|
|
125
|
+
*
|
|
126
|
+
* It decides whether the agent's answer travels. Start a run at the terminal
|
|
127
|
+
* and the answer is already on the screen you are sitting at; sending it to
|
|
128
|
+
* Telegram would put a file's contents, a command line, or a secret inside an
|
|
129
|
+
* error message into a chat that syncs to Telegram's servers, for nothing. Ask
|
|
130
|
+
* from the phone and the answer is the entire point — you are not at the desk,
|
|
131
|
+
* and "finished, 33K tokens" answers nothing you asked.
|
|
132
|
+
*
|
|
133
|
+
* Module-level because the inbox and the notice sit either side of the run and
|
|
134
|
+
* share no object: the inbox is wired up once in main.ts, the notice fires deep
|
|
135
|
+
* inside executeAgentTask.
|
|
136
|
+
*/
|
|
137
|
+
let startedFromPhone = false;
|
|
138
|
+
export function markRunFromPhone() {
|
|
139
|
+
startedFromPhone = true;
|
|
140
|
+
}
|
|
141
|
+
/** True once per run that came from the phone, then false again. */
|
|
142
|
+
export function takeRunFromPhone() {
|
|
143
|
+
const was = startedFromPhone;
|
|
144
|
+
startedFromPhone = false;
|
|
145
|
+
return was;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Listen for instructions from the phone until the returned function is called.
|
|
149
|
+
*
|
|
150
|
+
* The queue is drained by `drain`, which the host calls when a run ends —
|
|
151
|
+
* rather than polled from here — so that "the agent is free" is decided in one
|
|
152
|
+
* place instead of two that can disagree.
|
|
153
|
+
*/
|
|
154
|
+
export function attachTelegramInbox(credentials, host) {
|
|
155
|
+
const queue = new InboxQueue();
|
|
156
|
+
const unsubscribe = sharedUpdates(credentials.botToken).subscribe('message', message => {
|
|
157
|
+
const result = extractPrompt(message, credentials.chatID);
|
|
158
|
+
if (!result.ok) {
|
|
159
|
+
const explanation = describeRejection(result.reason);
|
|
160
|
+
if (explanation)
|
|
161
|
+
host.reply(explanation);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (host.isBusy()) {
|
|
165
|
+
host.reply(describeQueued(queue.offer(result.text).replaced));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
host.reply(describeStarted(result.text));
|
|
169
|
+
markRunFromPhone();
|
|
170
|
+
host.submit(result.text);
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
stop: () => { unsubscribe(); queue.clear(); },
|
|
174
|
+
drain: () => {
|
|
175
|
+
// Re-check busy: a run can start from the terminal between the last one
|
|
176
|
+
// ending and this firing, and two agents on one workspace is not a thing
|
|
177
|
+
// this queue gets to cause.
|
|
178
|
+
if (host.isBusy())
|
|
179
|
+
return;
|
|
180
|
+
const next = queue.take();
|
|
181
|
+
if (next === null)
|
|
182
|
+
return;
|
|
183
|
+
host.reply(describeStarted(next));
|
|
184
|
+
markRunFromPhone();
|
|
185
|
+
host.submit(next);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { TelegramCredentials } from './telegramApproval';
|
|
2
|
+
/**
|
|
3
|
+
* Runs shorter than this are not worth a notification.
|
|
4
|
+
*
|
|
5
|
+
* The problem this solves is walking away — starting something substantial,
|
|
6
|
+
* making coffee, and not knowing it finished. A `git status` that returned in
|
|
7
|
+
* two seconds was never going to outlast your attention, and a phone that
|
|
8
|
+
* buzzes for every one of those gets muted within a day, taking the
|
|
9
|
+
* notifications that mattered with it.
|
|
10
|
+
*/
|
|
11
|
+
export declare const NOTIFY_AFTER_MS = 60000;
|
|
12
|
+
export declare function shouldNotify(elapsedMs: number, enabled: boolean): boolean;
|
|
13
|
+
/** `92s` / `4m 12s` / `1h 03m` — short enough to read on a lock screen. */
|
|
14
|
+
export declare function formatDuration(ms: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* Telegram refuses a message over 4096 characters outright.
|
|
17
|
+
*
|
|
18
|
+
* Kept well under: the rest of the summary shares the message, and a reply
|
|
19
|
+
* that fills a phone screen twice over is not read on a phone anyway.
|
|
20
|
+
*/
|
|
21
|
+
export declare const MAX_ANSWER_LENGTH = 3000;
|
|
22
|
+
/**
|
|
23
|
+
* Markdown out, plain words in.
|
|
24
|
+
*
|
|
25
|
+
* The agent writes for a terminal that renders markdown, so its answer arrives
|
|
26
|
+
* on a phone as `**140 datoteka**` — asterisks and backticks read as noise
|
|
27
|
+
* exactly where the answer should be easiest to read.
|
|
28
|
+
*
|
|
29
|
+
* Stripped rather than handed to Telegram as `parse_mode`, which would be the
|
|
30
|
+
* obvious fix and the wrong one: Telegram rejects a whole message whose markup
|
|
31
|
+
* is unbalanced, and an agent's answer is arbitrary text. One stray asterisk
|
|
32
|
+
* and the notice does not arrive at all. Ugly beats missing, and this is
|
|
33
|
+
* neither.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately conservative. Only paired markers are touched, and single
|
|
36
|
+
* underscores are left alone entirely — `execute_command` and `snake_case`
|
|
37
|
+
* appear in these answers constantly, and mangling an identifier to italicise
|
|
38
|
+
* nothing is worse than leaving a marker visible.
|
|
39
|
+
*/
|
|
40
|
+
export declare function stripMarkdown(text: string): string;
|
|
41
|
+
export interface RunSummary {
|
|
42
|
+
/** What the run was called — the session display name, not the full prompt. */
|
|
43
|
+
task: string;
|
|
44
|
+
elapsedMs: number;
|
|
45
|
+
/** Set when the run ended on an error, so the message can say so. */
|
|
46
|
+
failure?: string;
|
|
47
|
+
tokens?: number;
|
|
48
|
+
/** Pay-per-use dollars. Omitted on a flat-fee plan, where any figure is invented. */
|
|
49
|
+
costUsd?: number;
|
|
50
|
+
/**
|
|
51
|
+
* The agent's own reply, for a run that was started from the phone.
|
|
52
|
+
*
|
|
53
|
+
* Omitted for a run started at the terminal, where the answer is already on
|
|
54
|
+
* the screen the person is sitting at and sending it would put file contents
|
|
55
|
+
* or a command line into a chat for nothing.
|
|
56
|
+
*/
|
|
57
|
+
answer?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The notification text.
|
|
61
|
+
*
|
|
62
|
+
* Carries the agent's answer only when the run was started from the phone. A
|
|
63
|
+
* finished run can end with anything in it — a file it read, a command it ran,
|
|
64
|
+
* a secret inside an error — and this goes to a chat that syncs to Telegram's
|
|
65
|
+
* servers, so it travels only where it was actually asked for. Start a run at
|
|
66
|
+
* the terminal and this says there is a result to come back to, and no more.
|
|
67
|
+
*/
|
|
68
|
+
export declare function composeRunSummary(summary: RunSummary): string;
|
|
69
|
+
/**
|
|
70
|
+
* Send it, and say nothing if it fails.
|
|
71
|
+
*
|
|
72
|
+
* A notification that could not be delivered is not worth interrupting the
|
|
73
|
+
* terminal for — the run already finished and its result is on screen. Returns
|
|
74
|
+
* whether it went, so a caller that does care can look.
|
|
75
|
+
*/
|
|
76
|
+
export declare function sendTelegramNotice(credentials: TelegramCredentials, text: string): Promise<boolean>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telling you the run is done, on the phone that already answers its questions.
|
|
3
|
+
*
|
|
4
|
+
* Separate from TelegramApproval on purpose: that class exists to ask a
|
|
5
|
+
* question and wait for one of three answers, and none of its machinery — the
|
|
6
|
+
* keyboard, the token, the outstanding-message bookkeeping — means anything
|
|
7
|
+
* for a message nobody replies to.
|
|
8
|
+
*/
|
|
9
|
+
const API = 'https://api.telegram.org/bot';
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
11
|
+
/**
|
|
12
|
+
* Runs shorter than this are not worth a notification.
|
|
13
|
+
*
|
|
14
|
+
* The problem this solves is walking away — starting something substantial,
|
|
15
|
+
* making coffee, and not knowing it finished. A `git status` that returned in
|
|
16
|
+
* two seconds was never going to outlast your attention, and a phone that
|
|
17
|
+
* buzzes for every one of those gets muted within a day, taking the
|
|
18
|
+
* notifications that mattered with it.
|
|
19
|
+
*/
|
|
20
|
+
export const NOTIFY_AFTER_MS = 60_000;
|
|
21
|
+
export function shouldNotify(elapsedMs, enabled) {
|
|
22
|
+
return enabled && elapsedMs >= NOTIFY_AFTER_MS;
|
|
23
|
+
}
|
|
24
|
+
/** `92s` / `4m 12s` / `1h 03m` — short enough to read on a lock screen. */
|
|
25
|
+
export function formatDuration(ms) {
|
|
26
|
+
const seconds = Math.round(ms / 1000);
|
|
27
|
+
if (seconds < 90)
|
|
28
|
+
return `${seconds}s`;
|
|
29
|
+
const minutes = Math.floor(seconds / 60);
|
|
30
|
+
if (minutes < 60)
|
|
31
|
+
return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`;
|
|
32
|
+
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Telegram refuses a message over 4096 characters outright.
|
|
36
|
+
*
|
|
37
|
+
* Kept well under: the rest of the summary shares the message, and a reply
|
|
38
|
+
* that fills a phone screen twice over is not read on a phone anyway.
|
|
39
|
+
*/
|
|
40
|
+
export const MAX_ANSWER_LENGTH = 3000;
|
|
41
|
+
/**
|
|
42
|
+
* Markdown out, plain words in.
|
|
43
|
+
*
|
|
44
|
+
* The agent writes for a terminal that renders markdown, so its answer arrives
|
|
45
|
+
* on a phone as `**140 datoteka**` — asterisks and backticks read as noise
|
|
46
|
+
* exactly where the answer should be easiest to read.
|
|
47
|
+
*
|
|
48
|
+
* Stripped rather than handed to Telegram as `parse_mode`, which would be the
|
|
49
|
+
* obvious fix and the wrong one: Telegram rejects a whole message whose markup
|
|
50
|
+
* is unbalanced, and an agent's answer is arbitrary text. One stray asterisk
|
|
51
|
+
* and the notice does not arrive at all. Ugly beats missing, and this is
|
|
52
|
+
* neither.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately conservative. Only paired markers are touched, and single
|
|
55
|
+
* underscores are left alone entirely — `execute_command` and `snake_case`
|
|
56
|
+
* appear in these answers constantly, and mangling an identifier to italicise
|
|
57
|
+
* nothing is worse than leaving a marker visible.
|
|
58
|
+
*/
|
|
59
|
+
export function stripMarkdown(text) {
|
|
60
|
+
return text
|
|
61
|
+
// Fenced blocks: keep the code, drop the fence and any language tag.
|
|
62
|
+
.replace(/```[a-zA-Z0-9-]*\n?([\s\S]*?)```/g, '$1')
|
|
63
|
+
// Headings, which a phone shows as literal hashes.
|
|
64
|
+
.replace(/^#{1,6}[ \t]+/gm, '')
|
|
65
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
66
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
67
|
+
// Single backticks only — a lone one is left alone rather than eating the
|
|
68
|
+
// rest of the answer looking for a partner.
|
|
69
|
+
.replace(/`([^`\n]+)`/g, '$1')
|
|
70
|
+
.trim();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The notification text.
|
|
74
|
+
*
|
|
75
|
+
* Carries the agent's answer only when the run was started from the phone. A
|
|
76
|
+
* finished run can end with anything in it — a file it read, a command it ran,
|
|
77
|
+
* a secret inside an error — and this goes to a chat that syncs to Telegram's
|
|
78
|
+
* servers, so it travels only where it was actually asked for. Start a run at
|
|
79
|
+
* the terminal and this says there is a result to come back to, and no more.
|
|
80
|
+
*/
|
|
81
|
+
export function composeRunSummary(summary) {
|
|
82
|
+
const head = summary.failure ? '⚠️ Codeep stopped' : '✅ Codeep finished';
|
|
83
|
+
const lines = [`${head} — ${summary.task}`, `took ${formatDuration(summary.elapsedMs)}`];
|
|
84
|
+
if (summary.failure)
|
|
85
|
+
lines.push(summary.failure);
|
|
86
|
+
const cost = [];
|
|
87
|
+
if (typeof summary.tokens === 'number' && summary.tokens > 0) {
|
|
88
|
+
cost.push(`${Math.round(summary.tokens / 1000)}K tokens`);
|
|
89
|
+
}
|
|
90
|
+
if (typeof summary.costUsd === 'number' && summary.costUsd > 0) {
|
|
91
|
+
cost.push(`$${summary.costUsd.toFixed(summary.costUsd < 0.01 ? 4 : 2)}`);
|
|
92
|
+
}
|
|
93
|
+
if (cost.length > 0)
|
|
94
|
+
lines.push(cost.join(' · '));
|
|
95
|
+
const answer = summary.answer ? stripMarkdown(summary.answer) : undefined;
|
|
96
|
+
if (answer) {
|
|
97
|
+
lines.push('');
|
|
98
|
+
lines.push(answer.length > MAX_ANSWER_LENGTH
|
|
99
|
+
// Say it was cut rather than ending mid-sentence and looking finished.
|
|
100
|
+
? `${answer.slice(0, MAX_ANSWER_LENGTH)}\n\n[…cut — the full answer is in the terminal]`
|
|
101
|
+
: answer);
|
|
102
|
+
}
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Send it, and say nothing if it fails.
|
|
107
|
+
*
|
|
108
|
+
* A notification that could not be delivered is not worth interrupting the
|
|
109
|
+
* terminal for — the run already finished and its result is on screen. Returns
|
|
110
|
+
* whether it went, so a caller that does care can look.
|
|
111
|
+
*/
|
|
112
|
+
export async function sendTelegramNotice(credentials, text) {
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch(`${API}${credentials.botToken}/sendMessage`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
chat_id: credentials.chatID,
|
|
119
|
+
text,
|
|
120
|
+
// No parse_mode: a task name is arbitrary user text and Telegram
|
|
121
|
+
// rejects the whole message on unbalanced Markdown, which would turn a
|
|
122
|
+
// stray underscore in a branch name into a silently dropped notice.
|
|
123
|
+
disable_notification: false,
|
|
124
|
+
}),
|
|
125
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
126
|
+
});
|
|
127
|
+
return response.ok;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One long poll, one offset, both kinds of update.
|
|
3
|
+
*
|
|
4
|
+
* Telegram's getUpdates has a single cursor per bot. `offset` confirms every
|
|
5
|
+
* update older than itself **regardless of `allowed_updates`** — that parameter
|
|
6
|
+
* only filters what comes back in the response, not what the call acknowledges.
|
|
7
|
+
* So two pollers on one bot do not coexist: each advances the cursor past
|
|
8
|
+
* updates the other never saw, and both start losing traffic silently. An
|
|
9
|
+
* approval tapped on the phone would simply not register, with nothing anywhere
|
|
10
|
+
* to say why.
|
|
11
|
+
*
|
|
12
|
+
* Hence this. Everything that wants updates subscribes here, the loop asks for
|
|
13
|
+
* every type any subscriber could want, and dispatch happens locally where
|
|
14
|
+
* losing one is impossible.
|
|
15
|
+
*/
|
|
16
|
+
export interface TelegramCredentials {
|
|
17
|
+
/** From @BotFather. A credential — belongs in the keychain, never in config. */
|
|
18
|
+
botToken: string;
|
|
19
|
+
/** The single chat allowed to answer. Anything else is ignored. */
|
|
20
|
+
chatID: string;
|
|
21
|
+
}
|
|
22
|
+
export type UpdateKind = 'callback_query' | 'message';
|
|
23
|
+
export type UpdateHandler = (payload: unknown) => void | Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Told when a poll fails, and when one succeeds after failing.
|
|
26
|
+
*
|
|
27
|
+
* Without this the loop was completely silent: a webhook left configured on the
|
|
28
|
+
* bot answers 409 to every getUpdates, a revoked token answers 401, and both
|
|
29
|
+
* looked exactly like a phone nobody had messaged. Diagnosing it meant reading
|
|
30
|
+
* the source.
|
|
31
|
+
*/
|
|
32
|
+
export type PollObserver = (event: {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
detail: string;
|
|
35
|
+
}) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Where the cursor goes after a batch.
|
|
38
|
+
*
|
|
39
|
+
* Never backwards: a retry that returns an older batch, or a response with ids
|
|
40
|
+
* this build does not understand, must not re-deliver what was already handled.
|
|
41
|
+
*/
|
|
42
|
+
export declare function nextOffset(current: number, updates: {
|
|
43
|
+
update_id?: unknown;
|
|
44
|
+
}[]): number;
|
|
45
|
+
/** Every kind this loop asks for, so one cursor can serve every subscriber. */
|
|
46
|
+
export declare const POLLED_KINDS: readonly UpdateKind[];
|
|
47
|
+
export declare class TelegramUpdates {
|
|
48
|
+
private readonly botToken;
|
|
49
|
+
private readonly handlers;
|
|
50
|
+
private offset;
|
|
51
|
+
private running;
|
|
52
|
+
private readonly idlePauseMs;
|
|
53
|
+
private observer;
|
|
54
|
+
/** Only the first failure of a streak is reported, then the recovery. */
|
|
55
|
+
private failing;
|
|
56
|
+
constructor(botToken: string, idlePauseMs?: number);
|
|
57
|
+
/** Watch the health of the poll itself, separately from its payload. */
|
|
58
|
+
observe(observer: PollObserver | null): void;
|
|
59
|
+
private report;
|
|
60
|
+
/**
|
|
61
|
+
* Listen for one kind of update. Returns the function that stops listening.
|
|
62
|
+
*
|
|
63
|
+
* The loop runs while anyone is listening and stops when the last subscriber
|
|
64
|
+
* leaves, so a CLI with the inbox switched off never opens a connection.
|
|
65
|
+
*/
|
|
66
|
+
subscribe(kind: UpdateKind, handler: UpdateHandler): () => void;
|
|
67
|
+
private subscriberCount;
|
|
68
|
+
private loop;
|
|
69
|
+
private getUpdates;
|
|
70
|
+
}
|
|
71
|
+
export declare function sharedUpdates(botToken: string): TelegramUpdates;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One long poll, one offset, both kinds of update.
|
|
3
|
+
*
|
|
4
|
+
* Telegram's getUpdates has a single cursor per bot. `offset` confirms every
|
|
5
|
+
* update older than itself **regardless of `allowed_updates`** — that parameter
|
|
6
|
+
* only filters what comes back in the response, not what the call acknowledges.
|
|
7
|
+
* So two pollers on one bot do not coexist: each advances the cursor past
|
|
8
|
+
* updates the other never saw, and both start losing traffic silently. An
|
|
9
|
+
* approval tapped on the phone would simply not register, with nothing anywhere
|
|
10
|
+
* to say why.
|
|
11
|
+
*
|
|
12
|
+
* Hence this. Everything that wants updates subscribes here, the loop asks for
|
|
13
|
+
* every type any subscriber could want, and dispatch happens locally where
|
|
14
|
+
* losing one is impossible.
|
|
15
|
+
*/
|
|
16
|
+
const API = 'https://api.telegram.org';
|
|
17
|
+
/** Server-side long-poll window. The request blocks for up to this long. */
|
|
18
|
+
const POLL_SECONDS = 25;
|
|
19
|
+
/** Local ceiling, comfortably past the server's own. */
|
|
20
|
+
const REQUEST_TIMEOUT_MS = (POLL_SECONDS + 10) * 1000;
|
|
21
|
+
/**
|
|
22
|
+
* Pause after a poll that brought nothing back.
|
|
23
|
+
*
|
|
24
|
+
* Telegram's long poll already blocks server-side for POLL_SECONDS, so in
|
|
25
|
+
* normal running this never fires. It exists for the case where the request
|
|
26
|
+
* returns immediately — a network failure, or a server that ignored the
|
|
27
|
+
* timeout — which without it turns this into a loop that hammers the API as
|
|
28
|
+
* fast as the connection allows.
|
|
29
|
+
*/
|
|
30
|
+
const IDLE_PAUSE_MS = 1000;
|
|
31
|
+
/**
|
|
32
|
+
* Where the cursor goes after a batch.
|
|
33
|
+
*
|
|
34
|
+
* Never backwards: a retry that returns an older batch, or a response with ids
|
|
35
|
+
* this build does not understand, must not re-deliver what was already handled.
|
|
36
|
+
*/
|
|
37
|
+
export function nextOffset(current, updates) {
|
|
38
|
+
let out = current;
|
|
39
|
+
for (const update of updates) {
|
|
40
|
+
if (typeof update.update_id === 'number')
|
|
41
|
+
out = Math.max(out, update.update_id + 1);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/** Every kind this loop asks for, so one cursor can serve every subscriber. */
|
|
46
|
+
export const POLLED_KINDS = ['callback_query', 'message'];
|
|
47
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
48
|
+
export class TelegramUpdates {
|
|
49
|
+
botToken;
|
|
50
|
+
handlers = new Map();
|
|
51
|
+
offset = 0;
|
|
52
|
+
running = false;
|
|
53
|
+
idlePauseMs;
|
|
54
|
+
observer = null;
|
|
55
|
+
/** Only the first failure of a streak is reported, then the recovery. */
|
|
56
|
+
failing = false;
|
|
57
|
+
constructor(botToken, idlePauseMs = IDLE_PAUSE_MS) {
|
|
58
|
+
this.botToken = botToken;
|
|
59
|
+
this.idlePauseMs = idlePauseMs;
|
|
60
|
+
}
|
|
61
|
+
/** Watch the health of the poll itself, separately from its payload. */
|
|
62
|
+
observe(observer) {
|
|
63
|
+
this.observer = observer;
|
|
64
|
+
}
|
|
65
|
+
report(ok, detail) {
|
|
66
|
+
if (ok === !this.failing)
|
|
67
|
+
return; // nothing changed; stay quiet
|
|
68
|
+
this.failing = !ok;
|
|
69
|
+
this.observer?.({ ok, detail });
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Listen for one kind of update. Returns the function that stops listening.
|
|
73
|
+
*
|
|
74
|
+
* The loop runs while anyone is listening and stops when the last subscriber
|
|
75
|
+
* leaves, so a CLI with the inbox switched off never opens a connection.
|
|
76
|
+
*/
|
|
77
|
+
subscribe(kind, handler) {
|
|
78
|
+
let set = this.handlers.get(kind);
|
|
79
|
+
if (!set) {
|
|
80
|
+
set = new Set();
|
|
81
|
+
this.handlers.set(kind, set);
|
|
82
|
+
}
|
|
83
|
+
set.add(handler);
|
|
84
|
+
if (!this.running)
|
|
85
|
+
void this.loop();
|
|
86
|
+
return () => {
|
|
87
|
+
set.delete(handler);
|
|
88
|
+
if (this.subscriberCount() === 0)
|
|
89
|
+
this.running = false;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
subscriberCount() {
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const set of this.handlers.values())
|
|
95
|
+
total += set.size;
|
|
96
|
+
return total;
|
|
97
|
+
}
|
|
98
|
+
async loop() {
|
|
99
|
+
if (this.running)
|
|
100
|
+
return;
|
|
101
|
+
this.running = true;
|
|
102
|
+
while (this.running && this.subscriberCount() > 0) {
|
|
103
|
+
const json = await this.getUpdates();
|
|
104
|
+
if (!this.running)
|
|
105
|
+
break;
|
|
106
|
+
const updates = Array.isArray(json?.result) ? json.result : [];
|
|
107
|
+
// Advance before dispatching. A handler that throws must not make the
|
|
108
|
+
// loop re-read the same update forever.
|
|
109
|
+
this.offset = nextOffset(this.offset, updates);
|
|
110
|
+
for (const update of updates) {
|
|
111
|
+
for (const kind of POLLED_KINDS) {
|
|
112
|
+
const payload = update[kind];
|
|
113
|
+
if (payload === undefined)
|
|
114
|
+
continue;
|
|
115
|
+
for (const handler of this.handlers.get(kind) ?? []) {
|
|
116
|
+
try {
|
|
117
|
+
await handler(payload);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// One subscriber's failure is not the others' problem, and is
|
|
121
|
+
// certainly not a reason to stop reading the bot.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Covers an empty batch as well as a failed request: both mean the loop
|
|
127
|
+
// would otherwise come straight back with nothing to do.
|
|
128
|
+
if (updates.length === 0)
|
|
129
|
+
await sleep(this.idlePauseMs);
|
|
130
|
+
}
|
|
131
|
+
this.running = false;
|
|
132
|
+
}
|
|
133
|
+
async getUpdates() {
|
|
134
|
+
try {
|
|
135
|
+
const response = await fetch(`${API}/bot${this.botToken}/getUpdates`, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
offset: this.offset,
|
|
140
|
+
timeout: POLL_SECONDS,
|
|
141
|
+
allowed_updates: POLLED_KINDS,
|
|
142
|
+
}),
|
|
143
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
144
|
+
});
|
|
145
|
+
const json = await response.json().catch(() => null);
|
|
146
|
+
if (!response.ok || json?.ok === false) {
|
|
147
|
+
const description = typeof json?.description === 'string' ? json.description : `HTTP ${response.status}`;
|
|
148
|
+
this.report(false, description);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
this.report(true, 'reading updates again');
|
|
152
|
+
return json;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
this.report(false, error?.message || 'could not reach Telegram');
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The one poller per bot token.
|
|
162
|
+
*
|
|
163
|
+
* Approvals are constructed per dangerous tool call and the inbox lives for the
|
|
164
|
+
* whole session; both must reach the same cursor, so the instance is keyed by
|
|
165
|
+
* token rather than owned by either.
|
|
166
|
+
*/
|
|
167
|
+
const shared = new Map();
|
|
168
|
+
export function sharedUpdates(botToken) {
|
|
169
|
+
let instance = shared.get(botToken);
|
|
170
|
+
if (!instance) {
|
|
171
|
+
instance = new TelegramUpdates(botToken);
|
|
172
|
+
shared.set(botToken, instance);
|
|
173
|
+
}
|
|
174
|
+
return instance;
|
|
175
|
+
}
|