codeep 2.25.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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * One question, two places it can be answered, and exactly one answer.
3
+ *
4
+ * The terminal and the phone are both live at once. Whichever comes back first
5
+ * decides, and the other has to be taken down — a dialog left on screen after
6
+ * the phone answered would ask again, and a Telegram message left with three
7
+ * live buttons invites a tap that can no longer do anything.
8
+ *
9
+ * Kept apart from the agent loop and from the Telegram client because the part
10
+ * that goes wrong is the ordering, and the ordering can be tested with two
11
+ * promises and no network.
12
+ */
13
+ export interface RaceParticipant<T> {
14
+ /** Resolves when this side is answered. Must never reject: a rejection here
15
+ * would take down a run over a failure to *ask*, which is not the same thing
16
+ * as a denial and must not be treated as one. */
17
+ answer: Promise<T | null>;
18
+ /** Take this side down because the other one won. Must be safe to call when
19
+ * this side never started, and must not itself answer anything. */
20
+ withdraw: (winner: string) => void | Promise<void>;
21
+ }
22
+ /**
23
+ * Settle a question from whichever side answers first.
24
+ *
25
+ * A side that resolves `null` has declined to decide — it failed to send, or it
26
+ * was cancelled — and is not treated as a winner. When both come back null,
27
+ * nobody answered, and the caller decides what that means. It must not mean
28
+ * approval.
29
+ */
30
+ export declare function raceApproval<T>(terminal: RaceParticipant<T>, remote: RaceParticipant<T> | null, describe: (answer: T) => string): Promise<{
31
+ answer: T | null;
32
+ from: 'terminal' | 'remote' | 'nobody';
33
+ }>;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * One question, two places it can be answered, and exactly one answer.
3
+ *
4
+ * The terminal and the phone are both live at once. Whichever comes back first
5
+ * decides, and the other has to be taken down — a dialog left on screen after
6
+ * the phone answered would ask again, and a Telegram message left with three
7
+ * live buttons invites a tap that can no longer do anything.
8
+ *
9
+ * Kept apart from the agent loop and from the Telegram client because the part
10
+ * that goes wrong is the ordering, and the ordering can be tested with two
11
+ * promises and no network.
12
+ */
13
+ /**
14
+ * Settle a question from whichever side answers first.
15
+ *
16
+ * A side that resolves `null` has declined to decide — it failed to send, or it
17
+ * was cancelled — and is not treated as a winner. When both come back null,
18
+ * nobody answered, and the caller decides what that means. It must not mean
19
+ * approval.
20
+ */
21
+ export async function raceApproval(terminal, remote, describe) {
22
+ // With no remote side there is nothing to race and nothing to withdraw.
23
+ if (!remote) {
24
+ return { answer: await terminal.answer, from: 'terminal' };
25
+ }
26
+ // Not Promise.race. Race settles on the first promise to *finish*, including
27
+ // one that finished by declining — and the branch for the side that answers
28
+ // later still runs, consuming the answer into a result nobody reads. Each
29
+ // side instead gets one shot at a shared resolver, and only a real answer
30
+ // takes it.
31
+ return new Promise(resolve => {
32
+ let settled = false;
33
+ const claim = async (from, answer, loser) => {
34
+ // A null is a side stepping aside, not an answer. Let the other run on.
35
+ if (answer === null || settled)
36
+ return;
37
+ settled = true;
38
+ // Withdrawal is best-effort: the decision is already made, and a network
39
+ // failure closing the other side must not undo it or throw in its place.
40
+ try {
41
+ await loser.withdraw(describe(answer));
42
+ }
43
+ catch { /* already decided */ }
44
+ resolve({ answer, from });
45
+ };
46
+ void terminal.answer.then(a => claim('terminal', a, remote));
47
+ void remote.answer.then(a => claim('remote', a, terminal));
48
+ // Both declined. Nobody answered — which the caller must not read as
49
+ // approval, and which is why this returns a name for it rather than a null
50
+ // that looks like every other null.
51
+ void Promise.all([terminal.answer, remote.answer]).then(() => {
52
+ if (settled)
53
+ return;
54
+ settled = true;
55
+ resolve({ answer: null, from: 'nobody' });
56
+ });
57
+ });
58
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Answer a pending tool confirmation from a phone.
3
+ *
4
+ * The agent already parks on `onRequestPermission` and resumes when that promise
5
+ * settles. This adds a second way to settle it — nothing in the gate changes,
6
+ * and whichever answers first wins. A run that would have sat at an empty desk
7
+ * until someone came back can now continue.
8
+ *
9
+ * Telegram carries it because Telegram runs the infrastructure: the bot API is
10
+ * polled outbound from this machine, so there is no server to host, no inbound
11
+ * port, and no per-user cost. Long polling, never a webhook — a webhook needs a
12
+ * public address, which is precisely what we do not want to need. That is also
13
+ * why this works identically on Linux and Windows, where the Mac app's CloudKit
14
+ * route does not exist.
15
+ *
16
+ * SECURITY: a Telegram bot is reachable by anyone who learns its username. Every
17
+ * update is checked against the configured chat id before it can decide
18
+ * anything. Without that check a stranger who found the bot could approve a
19
+ * destructive command on someone else's machine. See `isFromOwner`.
20
+ *
21
+ * PRIVACY: the message carries the command line, so Telegram sees it. Stated in
22
+ * the docs rather than hidden — it is the reason this is opt-in.
23
+ *
24
+ * Ported from the Mac app's `TelegramApproval.swift`; the wire format, the token
25
+ * matching and the ownership check are deliberately identical, so a bug found on
26
+ * one side is findable on the other.
27
+ */
28
+ /** What the phone sent back. Mirrors the three buttons the desktop offers. */
29
+ export type TelegramAnswer = 'run' | 'skip' | 'cancel';
30
+ import { type TelegramCredentials } from './telegramUpdates';
31
+ export type { TelegramCredentials } from './telegramUpdates';
32
+ export { nextOffset } from './telegramUpdates';
33
+ /**
34
+ * The message text.
35
+ *
36
+ * Fenced, because a command containing underscores or asterisks would otherwise
37
+ * be mangled by Markdown parsing into something that is not what will run — and
38
+ * approving a command you were shown incorrectly is the one failure this whole
39
+ * feature must not have.
40
+ */
41
+ export declare function composeMessage(command: string, toolName: string, isDestructive: boolean): string;
42
+ /**
43
+ * Only the configured chat may decide.
44
+ *
45
+ * A bot's username is discoverable, so without this an unrelated Telegram user
46
+ * could approve a command on someone else's machine. Telegram sends the id as a
47
+ * number; it is compared as a string because that is how the user typed it.
48
+ */
49
+ export declare function isFromOwner(callback: unknown, chatID: string): boolean;
50
+ /**
51
+ * Split `run:<token>` into its parts.
52
+ *
53
+ * A callback whose token does not match the question in flight is ignored, and
54
+ * that is what stops a stale button — tapped after the run moved on — from
55
+ * answering a later question it was never shown.
56
+ */
57
+ export declare function parseCallbackData(data: string): {
58
+ answer: TelegramAnswer;
59
+ token: string;
60
+ } | null;
61
+ /** The keyboard sent with the question. Shape pinned by a test — a renamed
62
+ * callback_data field would leave three buttons that silently do nothing. */
63
+ export declare function buildKeyboard(token: string): Record<string, unknown>;
64
+ /**
65
+ * Telegram's three buttons, in the terms the agent's gate speaks.
66
+ *
67
+ * The gate has four outcomes and `classifyPermissionOutcome` fails closed on
68
+ * anything it does not recognise, so this must be exhaustive rather than
69
+ * defaulted — a typo here would read as a denial, which is safe but silently
70
+ * wrong, and the user would tap Run and watch nothing happen.
71
+ */
72
+ export declare function outcomeForAnswer(answer: TelegramAnswer): 'allow_once' | 'reject_once' | 'reject_always';
73
+ /**
74
+ * How a decision reads on the other device, once it is too late to change it.
75
+ *
76
+ * The withdrawn side says what happened rather than just going blank, so
77
+ * someone reaching for their phone a moment late learns what they missed
78
+ * instead of finding a message that silently lost its buttons.
79
+ */
80
+ export declare function describePermissionOutcome(outcome: string): string;
81
+ /**
82
+ * Telegram's failure, phrased for someone who is setting this up.
83
+ *
84
+ * `chat not found` is the one people actually hit: the id belongs to a
85
+ * conversation with a *different* bot, or the bot has never been messaged from
86
+ * that chat at all. Repeating the API's words and adding what they mean beats
87
+ * a generic failure that sends them back to the docs.
88
+ */
89
+ export declare function describeApiError(status: number, json: Record<string, unknown> | null): string;
90
+ export declare class TelegramApproval {
91
+ private readonly credentials;
92
+ private outstanding;
93
+ /** Set while a question is open; called to stop listening once it closes. */
94
+ private unlisten;
95
+ /** Called once when the question could not be put at all. Not for a missing
96
+ * answer — only for a failure to ask. */
97
+ private readonly onProblem?;
98
+ constructor(credentials: TelegramCredentials, onProblem?: (reason: string) => void);
99
+ /**
100
+ * Send the question and wait.
101
+ *
102
+ * Resolves `null` when no answer arrived — the terminal was used instead, the
103
+ * caller aborted, or Telegram could not be reached. **A null is never
104
+ * approval**: the caller keeps its own gate and decides for itself.
105
+ */
106
+ ask(command: string, toolName: string, isDestructive: boolean, signal?: AbortSignal): Promise<TelegramAnswer | null>;
107
+ /**
108
+ * The terminal answered first. Close the question on the phone so nobody taps
109
+ * a button that would do nothing, and say where it was decided.
110
+ */
111
+ withdraw(decidedInTerminal: string): Promise<void>;
112
+ /**
113
+ * Why the last call failed, in Telegram's own words.
114
+ *
115
+ * Kept because swallowing it made a misconfiguration indistinguishable from
116
+ * silence: a wrong chat id answers `chat not found` on the very first send,
117
+ * and reporting nothing left the user watching a phone that was never going
118
+ * to ring.
119
+ */
120
+ private lastError;
121
+ private post;
122
+ private sendQuestion;
123
+ private edit;
124
+ private handle;
125
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Answer a pending tool confirmation from a phone.
3
+ *
4
+ * The agent already parks on `onRequestPermission` and resumes when that promise
5
+ * settles. This adds a second way to settle it — nothing in the gate changes,
6
+ * and whichever answers first wins. A run that would have sat at an empty desk
7
+ * until someone came back can now continue.
8
+ *
9
+ * Telegram carries it because Telegram runs the infrastructure: the bot API is
10
+ * polled outbound from this machine, so there is no server to host, no inbound
11
+ * port, and no per-user cost. Long polling, never a webhook — a webhook needs a
12
+ * public address, which is precisely what we do not want to need. That is also
13
+ * why this works identically on Linux and Windows, where the Mac app's CloudKit
14
+ * route does not exist.
15
+ *
16
+ * SECURITY: a Telegram bot is reachable by anyone who learns its username. Every
17
+ * update is checked against the configured chat id before it can decide
18
+ * anything. Without that check a stranger who found the bot could approve a
19
+ * destructive command on someone else's machine. See `isFromOwner`.
20
+ *
21
+ * PRIVACY: the message carries the command line, so Telegram sees it. Stated in
22
+ * the docs rather than hidden — it is the reason this is opt-in.
23
+ *
24
+ * Ported from the Mac app's `TelegramApproval.swift`; the wire format, the token
25
+ * matching and the ownership check are deliberately identical, so a bug found on
26
+ * one side is findable on the other.
27
+ */
28
+ const ANSWERS = ['run', 'skip', 'cancel'];
29
+ import { sharedUpdates } from './telegramUpdates.js';
30
+ export { nextOffset } from './telegramUpdates.js';
31
+ /** Longest command we put in a message. Telegram caps at 4096 for the whole
32
+ * text; this keeps room for the heading and the fences, and a command longer
33
+ * than this is not something anyone reads off a phone anyway. */
34
+ const MAX_COMMAND_CHARS = 300;
35
+ /**
36
+ * The message text.
37
+ *
38
+ * Fenced, because a command containing underscores or asterisks would otherwise
39
+ * be mangled by Markdown parsing into something that is not what will run — and
40
+ * approving a command you were shown incorrectly is the one failure this whole
41
+ * feature must not have.
42
+ */
43
+ export function composeMessage(command, toolName, isDestructive) {
44
+ const head = isDestructive
45
+ ? '⚠️ Codeep wants to run a destructive tool'
46
+ : 'Codeep needs approval';
47
+ const trimmed = command.length > MAX_COMMAND_CHARS
48
+ ? command.slice(0, MAX_COMMAND_CHARS - 1) + '…'
49
+ : command;
50
+ // A fence inside the command would close ours early and leak the rest as
51
+ // prose. Neutralise it rather than trusting the input.
52
+ const safe = trimmed.replace(/```/g, "'''");
53
+ return `${head}\n\n\`${toolName}\`\n\n\`\`\`\n${safe}\n\`\`\``;
54
+ }
55
+ /**
56
+ * Only the configured chat may decide.
57
+ *
58
+ * A bot's username is discoverable, so without this an unrelated Telegram user
59
+ * could approve a command on someone else's machine. Telegram sends the id as a
60
+ * number; it is compared as a string because that is how the user typed it.
61
+ */
62
+ export function isFromOwner(callback, chatID) {
63
+ if (!chatID)
64
+ return false;
65
+ const message = callback?.message;
66
+ const chat = message?.chat;
67
+ const id = chat?.id;
68
+ if (typeof id === 'number')
69
+ return String(id) === chatID;
70
+ if (typeof id === 'string')
71
+ return id === chatID;
72
+ return false;
73
+ }
74
+ /**
75
+ * Split `run:<token>` into its parts.
76
+ *
77
+ * A callback whose token does not match the question in flight is ignored, and
78
+ * that is what stops a stale button — tapped after the run moved on — from
79
+ * answering a later question it was never shown.
80
+ */
81
+ export function parseCallbackData(data) {
82
+ const separator = data.indexOf(':');
83
+ if (separator <= 0)
84
+ return null;
85
+ const answer = data.slice(0, separator);
86
+ const token = data.slice(separator + 1);
87
+ if (!token)
88
+ return null;
89
+ if (!ANSWERS.includes(answer))
90
+ return null;
91
+ return { answer: answer, token };
92
+ }
93
+ /** The keyboard sent with the question. Shape pinned by a test — a renamed
94
+ * callback_data field would leave three buttons that silently do nothing. */
95
+ export function buildKeyboard(token) {
96
+ return {
97
+ inline_keyboard: [[
98
+ { text: 'Run', callback_data: `run:${token}` },
99
+ { text: 'Skip', callback_data: `skip:${token}` },
100
+ { text: 'Cancel', callback_data: `cancel:${token}` },
101
+ ]],
102
+ };
103
+ }
104
+ /**
105
+ * Telegram's three buttons, in the terms the agent's gate speaks.
106
+ *
107
+ * The gate has four outcomes and `classifyPermissionOutcome` fails closed on
108
+ * anything it does not recognise, so this must be exhaustive rather than
109
+ * defaulted — a typo here would read as a denial, which is safe but silently
110
+ * wrong, and the user would tap Run and watch nothing happen.
111
+ */
112
+ export function outcomeForAnswer(answer) {
113
+ switch (answer) {
114
+ case 'run': return 'allow_once';
115
+ // Skip this one and carry on — not a standing refusal.
116
+ case 'skip': return 'reject_once';
117
+ // Stop asking. The phone has no "always allow": granting a blanket
118
+ // permission is a decision that belongs at the keyboard, where you can see
119
+ // what you are granting it to.
120
+ case 'cancel': return 'reject_always';
121
+ }
122
+ }
123
+ /**
124
+ * How a decision reads on the other device, once it is too late to change it.
125
+ *
126
+ * The withdrawn side says what happened rather than just going blank, so
127
+ * someone reaching for their phone a moment late learns what they missed
128
+ * instead of finding a message that silently lost its buttons.
129
+ */
130
+ export function describePermissionOutcome(outcome) {
131
+ switch (outcome) {
132
+ case 'allow_once': return 'allowed';
133
+ case 'allow_always': return 'allowed, and always from now on';
134
+ case 'reject_once': return 'skipped';
135
+ case 'reject_always': return 'denied';
136
+ default: return 'decided';
137
+ }
138
+ }
139
+ /**
140
+ * Telegram's failure, phrased for someone who is setting this up.
141
+ *
142
+ * `chat not found` is the one people actually hit: the id belongs to a
143
+ * conversation with a *different* bot, or the bot has never been messaged from
144
+ * that chat at all. Repeating the API's words and adding what they mean beats
145
+ * a generic failure that sends them back to the docs.
146
+ */
147
+ export function describeApiError(status, json) {
148
+ const description = typeof json?.description === 'string' ? json.description : '';
149
+ if (/chat not found/i.test(description)) {
150
+ return 'Telegram says "chat not found" — the chat ID does not belong to a conversation with this bot. Message the bot from that chat, then read the ID back from getUpdates.';
151
+ }
152
+ if (/bot was blocked/i.test(description)) {
153
+ return 'Telegram says the bot was blocked by this chat. Unblock it and try again.';
154
+ }
155
+ if (status === 401) {
156
+ return 'Telegram rejected the bot token. Re-enter it with /telegram.';
157
+ }
158
+ return description ? `Telegram refused the message: ${description}` : `Telegram returned HTTP ${status}.`;
159
+ }
160
+ // ─── The client ───────────────────────────────────────────────────────────────
161
+ const API = 'https://api.telegram.org';
162
+ /** Sending a message or editing one — no long poll lives here any more. */
163
+ const REQUEST_TIMEOUT_MS = 15_000;
164
+ export class TelegramApproval {
165
+ credentials;
166
+ outstanding = null;
167
+ /** Set while a question is open; called to stop listening once it closes. */
168
+ unlisten = null;
169
+ /** Called once when the question could not be put at all. Not for a missing
170
+ * answer — only for a failure to ask. */
171
+ onProblem;
172
+ constructor(credentials, onProblem) {
173
+ this.credentials = credentials;
174
+ this.onProblem = onProblem;
175
+ }
176
+ /**
177
+ * Send the question and wait.
178
+ *
179
+ * Resolves `null` when no answer arrived — the terminal was used instead, the
180
+ * caller aborted, or Telegram could not be reached. **A null is never
181
+ * approval**: the caller keeps its own gate and decides for itself.
182
+ */
183
+ async ask(command, toolName, isDestructive, signal) {
184
+ const token = randomToken();
185
+ const messageID = await this.sendQuestion(composeMessage(command, toolName, isDestructive), token);
186
+ if (messageID === null) {
187
+ // Say it once, here, rather than leaving the caller to guess from a null
188
+ // that also means "answered elsewhere" and "cancelled".
189
+ this.onProblem?.(this.lastError ?? 'the question could not be sent');
190
+ return null;
191
+ }
192
+ return new Promise(resolve => {
193
+ let settled = false;
194
+ const finish = (answer) => {
195
+ if (settled)
196
+ return;
197
+ settled = true;
198
+ this.outstanding = null;
199
+ this.unlisten?.();
200
+ this.unlisten = null;
201
+ resolve(answer);
202
+ };
203
+ this.outstanding = { token, messageID, resolve: finish };
204
+ if (signal) {
205
+ if (signal.aborted) {
206
+ finish(null);
207
+ return;
208
+ }
209
+ signal.addEventListener('abort', () => finish(null), { once: true });
210
+ }
211
+ // Listen on the bot's one poller rather than opening a second. Two
212
+ // cursors on the same bot silently eat each other's updates.
213
+ this.unlisten = sharedUpdates(this.credentials.botToken)
214
+ .subscribe('callback_query', callback => this.handle(callback));
215
+ });
216
+ }
217
+ /**
218
+ * The terminal answered first. Close the question on the phone so nobody taps
219
+ * a button that would do nothing, and say where it was decided.
220
+ */
221
+ async withdraw(decidedInTerminal) {
222
+ const pending = this.outstanding;
223
+ if (!pending)
224
+ return;
225
+ this.outstanding = null;
226
+ this.unlisten?.();
227
+ this.unlisten = null;
228
+ pending.resolve(null);
229
+ await this.edit(pending.messageID, `Answered in the terminal — ${decidedInTerminal}.`);
230
+ }
231
+ // ── plumbing ──
232
+ /**
233
+ * Why the last call failed, in Telegram's own words.
234
+ *
235
+ * Kept because swallowing it made a misconfiguration indistinguishable from
236
+ * silence: a wrong chat id answers `chat not found` on the very first send,
237
+ * and reporting nothing left the user watching a phone that was never going
238
+ * to ring.
239
+ */
240
+ lastError = null;
241
+ async post(method, body) {
242
+ try {
243
+ const response = await fetch(`${API}/bot${this.credentials.botToken}/${method}`, {
244
+ method: 'POST',
245
+ headers: { 'Content-Type': 'application/json' },
246
+ body: JSON.stringify(body),
247
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
248
+ });
249
+ const json = await response.json().catch(() => null);
250
+ if (!response.ok || json?.ok === false) {
251
+ this.lastError = describeApiError(response.status, json);
252
+ return null;
253
+ }
254
+ this.lastError = null;
255
+ return json;
256
+ }
257
+ catch (error) {
258
+ // Network, timeout, malformed JSON. Still "no answer from the phone",
259
+ // but now it can say which.
260
+ this.lastError = error?.message || 'could not reach Telegram';
261
+ return null;
262
+ }
263
+ }
264
+ async sendQuestion(text, token) {
265
+ const json = await this.post('sendMessage', {
266
+ chat_id: this.credentials.chatID,
267
+ text,
268
+ parse_mode: 'Markdown',
269
+ reply_markup: buildKeyboard(token),
270
+ });
271
+ const result = json?.result;
272
+ return typeof result?.message_id === 'number' ? result.message_id : null;
273
+ }
274
+ async edit(messageID, text) {
275
+ await this.post('editMessageText', {
276
+ chat_id: this.credentials.chatID,
277
+ message_id: messageID,
278
+ text,
279
+ });
280
+ }
281
+ async handle(callback) {
282
+ const pending = this.outstanding;
283
+ if (!pending)
284
+ return;
285
+ if (!isFromOwner(callback, this.credentials.chatID))
286
+ return;
287
+ const data = callback.data;
288
+ if (typeof data !== 'string')
289
+ return;
290
+ const parsed = parseCallbackData(data);
291
+ if (!parsed || parsed.token !== pending.token)
292
+ return;
293
+ this.outstanding = null;
294
+ this.unlisten?.();
295
+ this.unlisten = null;
296
+ const id = callback.id;
297
+ if (typeof id === 'string')
298
+ await this.post('answerCallbackQuery', { callback_query_id: id });
299
+ await this.edit(pending.messageID, `${capitalise(parsed.answer)} — sent to your terminal.`);
300
+ pending.resolve(parsed.answer);
301
+ }
302
+ }
303
+ function randomToken() {
304
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
305
+ }
306
+ function capitalise(value) {
307
+ return value.charAt(0).toUpperCase() + value.slice(1);
308
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Where the Telegram bot token lives, and how the two halves are read back.
3
+ *
4
+ * Deliberately separate from `telegramApproval.ts`, which stays free of I/O so
5
+ * the parts that decide whether a stranger may approve your commands can be
6
+ * tested without a keychain, a config file, or a network.
7
+ *
8
+ * The token goes in the OS keychain — it *is* the bot, and anyone holding it can
9
+ * send messages as you. The chat id is ordinary config: it names a conversation
10
+ * and is useless on its own.
11
+ */
12
+ import type { TelegramCredentials } from './telegramApproval.js';
13
+ export declare function setTelegramToken(token: string): Promise<void>;
14
+ export declare function clearTelegramToken(): Promise<void>;
15
+ export declare function hasTelegramToken(): Promise<boolean>;
16
+ /**
17
+ * Both halves, or nothing.
18
+ *
19
+ * Returns null when the feature is off or either half is missing. Half-
20
+ * configured must behave exactly like off: a token with no chat id would send
21
+ * the question nowhere, and a chat id with no token cannot send at all — and
22
+ * both would otherwise stall a run waiting for an answer that was never asked.
23
+ */
24
+ export declare function loadTelegramCredentials(): Promise<TelegramCredentials | null>;
25
+ /**
26
+ * The same pair, for the inbox, behind its own switch.
27
+ *
28
+ * Not folded into the call above: someone can want the phone to be told a run
29
+ * finished without wanting the phone to be able to start one, and the reverse.
30
+ * One flag serving both would make turning either off turn both off.
31
+ */
32
+ export declare function loadTelegramInboxCredentials(): Promise<TelegramCredentials | null>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Where the Telegram bot token lives, and how the two halves are read back.
3
+ *
4
+ * Deliberately separate from `telegramApproval.ts`, which stays free of I/O so
5
+ * the parts that decide whether a stranger may approve your commands can be
6
+ * tested without a keychain, a config file, or a network.
7
+ *
8
+ * The token goes in the OS keychain — it *is* the bot, and anyone holding it can
9
+ * send messages as you. The chat id is ordinary config: it names a conversation
10
+ * and is useless on its own.
11
+ */
12
+ import { config } from '../config/index.js';
13
+ import { createSecureStorage } from './keychain.js';
14
+ /**
15
+ * The keychain is keyed by provider id, and this is not a provider.
16
+ *
17
+ * Reusing the store rather than adding a second keychain integration is the
18
+ * point; going through `setApiKey` is not, because that also records the id in
19
+ * `configuredProviderIds` and Telegram would start appearing in provider lists
20
+ * as something you could log out of.
21
+ */
22
+ const CREDENTIAL_ID = 'telegram';
23
+ export async function setTelegramToken(token) {
24
+ await createSecureStorage(config).setApiKey(CREDENTIAL_ID, token.trim());
25
+ }
26
+ export async function clearTelegramToken() {
27
+ await createSecureStorage(config).deleteApiKey(CREDENTIAL_ID);
28
+ }
29
+ export async function hasTelegramToken() {
30
+ return !!(await createSecureStorage(config).getApiKey(CREDENTIAL_ID));
31
+ }
32
+ /**
33
+ * Both halves, or nothing.
34
+ *
35
+ * Returns null when the feature is off or either half is missing. Half-
36
+ * configured must behave exactly like off: a token with no chat id would send
37
+ * the question nowhere, and a chat id with no token cannot send at all — and
38
+ * both would otherwise stall a run waiting for an answer that was never asked.
39
+ */
40
+ export async function loadTelegramCredentials() {
41
+ // Strictly true. A truthy check would accept the string 'true' left behind
42
+ // by an earlier build whose toggle wrote strings — and then the feature
43
+ // would be live while the settings row read OFF.
44
+ if (config.get('telegramApproval') !== true)
45
+ return null;
46
+ return readCredentials();
47
+ }
48
+ /**
49
+ * The same pair, for the inbox, behind its own switch.
50
+ *
51
+ * Not folded into the call above: someone can want the phone to be told a run
52
+ * finished without wanting the phone to be able to start one, and the reverse.
53
+ * One flag serving both would make turning either off turn both off.
54
+ */
55
+ export async function loadTelegramInboxCredentials() {
56
+ if (config.get('telegramInbox') !== true)
57
+ return null;
58
+ return readCredentials();
59
+ }
60
+ async function readCredentials() {
61
+ const chatID = String(config.get('telegramChatId') || '').trim();
62
+ if (!chatID)
63
+ return null;
64
+ const botToken = await createSecureStorage(config).getApiKey(CREDENTIAL_ID);
65
+ if (!botToken)
66
+ return null;
67
+ return { botToken, chatID };
68
+ }