jaypost 0.0.0 → 0.7.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,105 @@
1
+ //#region src/format/types.d.ts
2
+ /** How the caller's text is read: shown as is, or as the markdown subset. */
3
+ type MessageFormat = 'plain' | 'markdown';
4
+ //#endregion
5
+ //#region src/format/escape.d.ts
6
+ /**
7
+ * Backslash-escapes every character the markdown subset could treat as markup, so that
8
+ * `format: "markdown"` shows `text` as typed. Never throws.
9
+ */
10
+ export declare function escapeMarkdown(text: string): string;
11
+ /**
12
+ * Wraps `code` in a fenced block that shows it verbatim, with a fence longer than any backtick
13
+ * run that starts a line of `code`. Throws a RangeError when `language` isn't a plain word.
14
+ */
15
+ export declare function formatCodeBlock(code: string, language?: string): string;
16
+ //#endregion
17
+ //#region src/client/types.d.ts
18
+ /** Where `Client` finds the service, and the project name its messages carry by default. */
19
+ type ClientOptions = {
20
+ /** Default `127.0.0.1`. */
21
+ readonly host?: string;
22
+ /** Default 8787. */
23
+ readonly port?: number;
24
+ /** Shown as `[project]` on the first line of every message, unless a call sets its own. */
25
+ readonly project?: string;
26
+ /**
27
+ * Sent as a bearer token. Default: `JAYPOST_TOKEN`, else `~/.config/jaypost/token`,
28
+ * looked up on every call. Throws a `RangeError` at construction if it isn't a valid token.
29
+ */
30
+ readonly token?: string;
31
+ };
32
+ /**
33
+ * One file sent along with a message: read from `path` (named by its basename unless `name` is
34
+ * set), or given as `text` or `data` under a `name`. At most 10 MiB, and never empty.
35
+ */
36
+ type AttachmentInput = {
37
+ readonly path: string;
38
+ readonly name?: string;
39
+ } | {
40
+ readonly name: string;
41
+ readonly text: string;
42
+ } | {
43
+ readonly name: string;
44
+ readonly data: Uint8Array;
45
+ };
46
+ /**
47
+ * A question answered by buttons and/or a typed reply; `timeout` in seconds, default 300, max 86400 (24 h); longer values are
48
+ * capped.
49
+ */
50
+ type AskRequest = {
51
+ readonly text: string;
52
+ /** Button labels; required unless `allowText`. */
53
+ readonly options?: readonly string[];
54
+ /** Let the owner answer with a text reply. */
55
+ readonly allowText?: boolean;
56
+ /** Answer returned on timeout; one of `options`, or with `allowText` any short answer. */
57
+ readonly timeoutOption: string;
58
+ readonly timeout?: number;
59
+ readonly project?: string;
60
+ /** Default plain; markdown renders a subset, see the README. */
61
+ readonly format?: MessageFormat;
62
+ /** One file sent with the question. Needs a service that supports attachments. */
63
+ readonly attachment?: AttachmentInput;
64
+ /** Aborting it cancels the question; the chat message is edited to a "cancelled" line. */
65
+ readonly signal?: AbortSignal;
66
+ };
67
+ /** The tapped option or typed reply, or `timeoutOption` with `timedOut` set. */
68
+ type Answer = {
69
+ readonly answer: string;
70
+ readonly timedOut: boolean;
71
+ };
72
+ /** A message that doesn't need an answer. */
73
+ type Notification = {
74
+ readonly text: string;
75
+ readonly project?: string;
76
+ /** Default plain; markdown renders a subset, see the README. */
77
+ readonly format?: MessageFormat;
78
+ /** One file sent with the message. Needs a service that supports attachments. */
79
+ readonly attachment?: AttachmentInput;
80
+ readonly signal?: AbortSignal;
81
+ };
82
+ //#endregion
83
+ //#region src/client/client.d.ts
84
+ /** Thrown by `Client` when the service is unreachable, rejects the input or can't send. */
85
+ export declare class JaypostError extends Error {
86
+ name: string;
87
+ }
88
+ /**
89
+ * Asks questions and sends notifications through a running jaypost service. Calls throw
90
+ * `JaypostError` when the token lookup finds an unusable token.
91
+ */
92
+ export declare class Client {
93
+ #private;
94
+ constructor(options?: ClientOptions);
95
+ /**
96
+ * Resolves once the user taps a button or replies with text (`allowText`), or the timeout fires
97
+ * (then with `timeoutOption`). Throws `JaypostError` on failure (including an unusable token
98
+ * lookup), or the signal's reason if aborted.
99
+ */
100
+ request(request: AskRequest): Promise<Answer>;
101
+ /** Resolves once Telegram accepts the message. Throws like `request`. */
102
+ notify(notification: Notification): Promise<void>;
103
+ }
104
+ //#endregion
105
+ export type { Answer, AskRequest, AttachmentInput, ClientOptions, MessageFormat, Notification };
package/dist/index.js ADDED
@@ -0,0 +1,579 @@
1
+ import { createReadStream, readFileSync } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { request } from "node:http";
5
+ import { homedir } from "node:os";
6
+ //#region src/approval/constants.ts
7
+ /** Hard cap on a request's timeout, in seconds (24 h); longer values are clamped. */
8
+ const MAX_TIMEOUT_SECONDS = 86400;
9
+ /** Largest attachment accepted by the API, in decoded bytes (10 MiB). */
10
+ const MAX_ATTACHMENT_BYTES = 10485760;
11
+ /** Fixed copy for failures, shown to agents and scripts as is. */
12
+ const CANCELLED_MESSAGE = "cancelled";
13
+ const TOO_OLD_MESSAGE = "jaypost service is too old for notifications (run jaypost init to upgrade)";
14
+ const NOT_SUPPORTED_MESSAGE = "notifications not supported by this provider";
15
+ const NOT_PAIRED_MESSAGE = "jaypost isn't paired yet (run jaypost status)";
16
+ const SEND_FAILED_MESSAGE = "couldn't send the message to the user";
17
+ /** Extra time the HTTP call gets beyond the approval timeout, so the service answers first. */
18
+ const FETCH_GRACE_MS = 3e4;
19
+ /** Shown when the service answers 401 and no token was sent. */
20
+ const TOKEN_REQUIRED_MESSAGE = "jaypost service needs a token; set JAYPOST_TOKEN to the contents of ~/.config/jaypost/token on the host";
21
+ /** Shown when the service answers 401 to a token that was sent. */
22
+ const TOKEN_REJECTED_MESSAGE = "jaypost service rejected the token; check JAYPOST_TOKEN, or re-run jaypost init if the token file changed";
23
+ /** Shown when a long /request timed out early, which only a service without long timeouts does. */
24
+ const TIMEOUT_TOO_OLD_MESSAGE = "jaypost service is too old for timeouts over 300 s (run jaypost init to upgrade)";
25
+ /** Shown when the service has no /request/async. */
26
+ const BACKGROUND_TOO_OLD_MESSAGE = "jaypost service is too old for background questions (run jaypost init to upgrade)";
27
+ /** Extra time a call with an attachment gets, for the upload and the service's send. */
28
+ const ATTACHMENT_GRACE_MS = 12e4;
29
+ /** Shown when the service's /status doesn't report an attachment limit. */
30
+ const ATTACHMENT_TOO_OLD_MESSAGE = "jaypost service is too old for attachments (run jaypost init to upgrade)";
31
+ /** Shown when the provider can't send attachments. */
32
+ const ATTACHMENT_NOT_SUPPORTED_MESSAGE = "attachments not supported by this provider";
33
+ //#endregion
34
+ //#region src/client/http.ts
35
+ /** POSTs JSON to the local service; see `callApi`. */
36
+ function postApi(port, path, body, options) {
37
+ return callApi(port, "POST", path, body, options);
38
+ }
39
+ /**
40
+ * Calls the local service with JSON (no body when `body` is undefined). 200 and 202 are ok. Never
41
+ * throws; no retries. Uses node:http rather than fetch, whose 300 s headers timeout would cut off
42
+ * a request that waits the full approval timeout.
43
+ */
44
+ async function callApi(port, method, path, body, options) {
45
+ const timeout = AbortSignal.timeout(Math.ceil(options.timeoutMs));
46
+ const signal = AbortSignal.any([options.signal, timeout]);
47
+ let response;
48
+ try {
49
+ response = await send(options.host ?? "127.0.0.1", port, method, path, body === void 0 ? void 0 : JSON.stringify(body), signal, options.token);
50
+ } catch {
51
+ return {
52
+ kind: "failed",
53
+ message: describeTransportFailure(port, options, timeout)
54
+ };
55
+ }
56
+ if (response.status === 200 || response.status === 202) return parseOk(response.text);
57
+ return {
58
+ kind: "failed",
59
+ message: describeStatus(response.status, path, response.text, options.token !== void 0)
60
+ };
61
+ }
62
+ const KEEPALIVE_MS = 6e4;
63
+ /**
64
+ * HTTP timeout for a /request call: the capped approval timeout plus a grace period, and more
65
+ * when the call uploads an attachment.
66
+ */
67
+ function toFetchTimeoutMs(timeoutSeconds, hasAttachment = false) {
68
+ return Math.min(timeoutSeconds ?? 300, MAX_TIMEOUT_SECONDS) * 1e3 + toSendTimeoutMs(hasAttachment);
69
+ }
70
+ /** HTTP timeout for a call that returns once the message is sent (/notify, /request/async). */
71
+ function toSendTimeoutMs(hasAttachment) {
72
+ return FETCH_GRACE_MS + (hasAttachment ? ATTACHMENT_GRACE_MS : 0);
73
+ }
74
+ /**
75
+ * Asks a question through /request. Fails with `TIMEOUT_TOO_OLD_MESSAGE` when a request over
76
+ * 300 s times out early, since only a service without long timeouts caps it at 300 s. Never throws.
77
+ */
78
+ async function requestApi(port, body, options) {
79
+ const now = options.now ?? (() => performance.now());
80
+ const startMs = now();
81
+ const result = await postApi(port, "/request", body, {
82
+ ...options,
83
+ timeoutMs: toFetchTimeoutMs(body.timeout, body.attachment !== void 0)
84
+ });
85
+ const elapsedMs = now() - startMs;
86
+ if (result.kind === "failed") return result;
87
+ if (!isOutcome(result.body)) return {
88
+ kind: "failed",
89
+ message: `jaypost service sent an unreadable answer: ${JSON.stringify(result.body)}`
90
+ };
91
+ const { answer, timedOut } = result.body;
92
+ if (timedOut && isTooEarly(body.timeout, elapsedMs)) return {
93
+ kind: "failed",
94
+ message: TIMEOUT_TOO_OLD_MESSAGE
95
+ };
96
+ return {
97
+ kind: "ok",
98
+ outcome: {
99
+ answer,
100
+ timedOut
101
+ }
102
+ };
103
+ }
104
+ function isTooEarly(timeoutSeconds, elapsedMs) {
105
+ if (timeoutSeconds === void 0 || timeoutSeconds <= 300) return false;
106
+ return elapsedMs < Math.min(timeoutSeconds, MAX_TIMEOUT_SECONDS) * 1e3 - FETCH_GRACE_MS;
107
+ }
108
+ /** Rejects on connection errors, resets and aborts; an abort destroys the socket. */
109
+ function send(host, port, method, path, payload, signal, token) {
110
+ return new Promise((resolve, reject) => {
111
+ const request$1 = request({
112
+ host,
113
+ port,
114
+ path,
115
+ method,
116
+ headers: {
117
+ ...payload === void 0 ? {} : {
118
+ "content-type": "application/json",
119
+ "content-length": Buffer.byteLength(payload)
120
+ },
121
+ ...token === void 0 ? {} : { authorization: `Bearer ${token}` }
122
+ },
123
+ signal
124
+ }, (response) => {
125
+ let text = "";
126
+ response.setEncoding("utf8");
127
+ response.on("data", (chunk) => {
128
+ text += chunk;
129
+ });
130
+ response.on("error", reject);
131
+ response.on("close", () => {
132
+ if (!response.complete) {
133
+ reject(/* @__PURE__ */ new Error("response cut off"));
134
+ return;
135
+ }
136
+ resolve({
137
+ status: response.statusCode ?? 0,
138
+ text
139
+ });
140
+ });
141
+ });
142
+ request$1.on("socket", (socket) => {
143
+ socket.setKeepAlive(true, KEEPALIVE_MS);
144
+ });
145
+ request$1.on("error", reject);
146
+ request$1.end(payload);
147
+ });
148
+ }
149
+ function describeTransportFailure(port, options, timeout) {
150
+ if (options.signal.aborted) return CANCELLED_MESSAGE;
151
+ if (timeout.aborted) return `jaypost service didn't answer within ${options.timeoutMs / 1e3} s`;
152
+ return `jaypost service not reachable on ${options.host ?? "127.0.0.1"}:${port} (run jaypost status)`;
153
+ }
154
+ /** The copy for a 404 on a request id. */
155
+ function describeUnknownRequest(id) {
156
+ return `jaypost doesn't know request ${id} (the service restarted, or the answer expired after 24 h)`;
157
+ }
158
+ /** True for a /request answer body: `{ answer, timedOut }`. */
159
+ function isOutcome(body) {
160
+ return typeof body === "object" && body !== null && "answer" in body && typeof body.answer === "string" && "timedOut" in body && typeof body.timedOut === "boolean";
161
+ }
162
+ function parseOk(text) {
163
+ try {
164
+ return {
165
+ kind: "ok",
166
+ body: JSON.parse(text)
167
+ };
168
+ } catch {
169
+ return {
170
+ kind: "failed",
171
+ message: `jaypost service sent an unreadable answer: ${text.trim()}`
172
+ };
173
+ }
174
+ }
175
+ /** The 401 copy: a token was never sent, or it was sent and refused. */
176
+ function describeUnauthorized(sentToken) {
177
+ return sentToken ? TOKEN_REJECTED_MESSAGE : TOKEN_REQUIRED_MESSAGE;
178
+ }
179
+ function describeStatus(status, path, text, sentToken) {
180
+ const error = readErrorField(text);
181
+ if (status === 401) return describeUnauthorized(sentToken);
182
+ if (status === 400 && error !== void 0) return error;
183
+ if (status === 404 && path === "/notify") return TOO_OLD_MESSAGE;
184
+ if (status === 404 && path === "/request/async") return BACKGROUND_TOO_OLD_MESSAGE;
185
+ if (status === 404 && path.startsWith("/request/")) return describeUnknownRequest(path.slice(9).split("?")[0] ?? "");
186
+ if (status === 501) return error === "attachments not supported" ? ATTACHMENT_NOT_SUPPORTED_MESSAGE : NOT_SUPPORTED_MESSAGE;
187
+ if (status === 503) return NOT_PAIRED_MESSAGE;
188
+ if (status === 502) return SEND_FAILED_MESSAGE;
189
+ return `jaypost service error ${status}: ${error ?? text.trim()}`;
190
+ }
191
+ function readErrorField(text) {
192
+ try {
193
+ const parsed = JSON.parse(text);
194
+ if (typeof parsed === "object" && parsed !== null && "error" in parsed) return typeof parsed.error === "string" ? parsed.error : void 0;
195
+ } catch {}
196
+ }
197
+ //#endregion
198
+ //#region src/client/attachment.ts
199
+ /**
200
+ * Reads the attachment locally, then asks the service for its size limit, so a bad file or an
201
+ * old or unsupporting service fails before any upload. Never throws; doesn't log names or content.
202
+ */
203
+ async function prepareAttachment(port, source, options) {
204
+ const label = describeSource(source);
205
+ const read = await readSource(source, label, options.signal);
206
+ if (read.kind === "failed") return read;
207
+ const status = await callApi(port, "GET", "/status", void 0, {
208
+ ...options,
209
+ timeoutMs: FETCH_GRACE_MS
210
+ });
211
+ if (status.kind === "failed") return status;
212
+ const limit = readLimit(status.body);
213
+ if (limit === void 0) return {
214
+ kind: "failed",
215
+ message: ATTACHMENT_TOO_OLD_MESSAGE
216
+ };
217
+ if (limit === 0) return {
218
+ kind: "failed",
219
+ message: ATTACHMENT_NOT_SUPPORTED_MESSAGE
220
+ };
221
+ if (read.byteLength > limit) return {
222
+ kind: "failed",
223
+ message: describeOver(label, limit)
224
+ };
225
+ return {
226
+ kind: "ok",
227
+ attachment: read.body
228
+ };
229
+ }
230
+ function describeSource(source) {
231
+ if ("stream" in source) return "attachment from stdin";
232
+ if ("path" in source) return `attachment ${JSON.stringify(source.path)}`;
233
+ return `attachment ${JSON.stringify(source.name)}`;
234
+ }
235
+ function describeOver(label, limit) {
236
+ return `${label} is over ${limit} bytes`;
237
+ }
238
+ function describeReadFailure(label, error) {
239
+ return `couldn't read ${label}: ${error instanceof Error ? error.message : String(error)}`;
240
+ }
241
+ function readLimit(body) {
242
+ if (typeof body === "object" && body !== null && "maxAttachmentBytes" in body) return typeof body.maxAttachmentBytes === "number" ? body.maxAttachmentBytes : void 0;
243
+ }
244
+ function readSource(source, label, signal) {
245
+ if ("stream" in source) return readStream(source.stream, source.name, label, signal);
246
+ if ("path" in source) return readPath(source.path, source.name, label);
247
+ if ("text" in source) return Promise.resolve({
248
+ kind: "read",
249
+ body: {
250
+ name: source.name,
251
+ text: source.text
252
+ },
253
+ byteLength: Buffer.byteLength(source.text)
254
+ });
255
+ return Promise.resolve({
256
+ kind: "read",
257
+ body: {
258
+ name: source.name,
259
+ data: Buffer.from(source.data).toString("base64")
260
+ },
261
+ byteLength: source.data.byteLength
262
+ });
263
+ }
264
+ async function readPath(path, name, label) {
265
+ const absolute = resolve(path);
266
+ let collected;
267
+ try {
268
+ const info = await stat(absolute);
269
+ if (!info.isFile()) return {
270
+ kind: "failed",
271
+ message: `${label} is not a file`
272
+ };
273
+ if (info.size > 10485760) return {
274
+ kind: "failed",
275
+ message: describeOver(label, MAX_ATTACHMENT_BYTES)
276
+ };
277
+ const file = createReadStream(absolute);
278
+ try {
279
+ collected = await collect(file);
280
+ } finally {
281
+ file.destroy();
282
+ }
283
+ } catch (error) {
284
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return {
285
+ kind: "failed",
286
+ message: `${label} not found`
287
+ };
288
+ return {
289
+ kind: "failed",
290
+ message: describeReadFailure(label, error)
291
+ };
292
+ }
293
+ if (collected.kind === "over") return {
294
+ kind: "failed",
295
+ message: describeOver(label, MAX_ATTACHMENT_BYTES)
296
+ };
297
+ return {
298
+ kind: "read",
299
+ body: {
300
+ name: name ?? basename(absolute),
301
+ data: Buffer.concat(collected.chunks).toString("base64")
302
+ },
303
+ byteLength: collected.byteLength
304
+ };
305
+ }
306
+ async function readStream(stream, name, label, signal) {
307
+ let collected;
308
+ try {
309
+ collected = await collect(stream, signal);
310
+ } catch (error) {
311
+ return {
312
+ kind: "failed",
313
+ message: describeReadFailure(label, error)
314
+ };
315
+ }
316
+ if (collected.kind === "over") return {
317
+ kind: "failed",
318
+ message: describeOver(label, MAX_ATTACHMENT_BYTES)
319
+ };
320
+ return {
321
+ kind: "read",
322
+ body: {
323
+ name,
324
+ data: Buffer.concat(collected.chunks).toString("base64")
325
+ },
326
+ byteLength: collected.byteLength
327
+ };
328
+ }
329
+ async function collect(source, signal) {
330
+ signal?.throwIfAborted();
331
+ const iterator = source[Symbol.asyncIterator]();
332
+ const aborted = new Promise((_resolve, reject) => {
333
+ signal?.addEventListener("abort", () => reject(signal.reason), { once: true });
334
+ });
335
+ aborted.catch(() => {});
336
+ const chunks = [];
337
+ let total = 0;
338
+ try {
339
+ for (;;) {
340
+ const step = await Promise.race([iterator.next(), aborted]);
341
+ if (step.done === true) return {
342
+ kind: "collected",
343
+ chunks,
344
+ byteLength: total
345
+ };
346
+ const buffer = Buffer.from(step.value);
347
+ total += buffer.byteLength;
348
+ if (total > 10485760) return { kind: "over" };
349
+ chunks.push(buffer);
350
+ }
351
+ } finally {
352
+ iterator.return?.()?.catch?.(() => {});
353
+ }
354
+ }
355
+ //#endregion
356
+ //#region src/auth/constants.ts
357
+ /** Env var clients and the service read the token from. */
358
+ const TOKEN_ENV_VAR = "JAYPOST_TOKEN";
359
+ /** Client token file name inside the config directory. */
360
+ const TOKEN_FILE_NAME = "token";
361
+ /** The RFC 6750 b64token alphabet, 32 to 256 characters, so `openssl rand -base64` output fits. */
362
+ const TOKEN_PATTERN = /^[A-Za-z0-9._~+/-]{32,256}$/;
363
+ /** Client result when no token is configured. */
364
+ const NO_CLIENT_TOKEN = { kind: "none" };
365
+ //#endregion
366
+ //#region src/config/paths.ts
367
+ /** Env value, with an empty string counting as unset. */
368
+ function findEnvValue(env, key) {
369
+ const value = env[key];
370
+ return value === void 0 || value === "" ? void 0 : value;
371
+ }
372
+ /**
373
+ * Path of config.json: $JAYPOST_CONFIG, else under $XDG_CONFIG_HOME or ~/.config. Empty env
374
+ * values count as unset.
375
+ */
376
+ function resolveConfigPath(env = process.env) {
377
+ const explicit = findEnvValue(env, "JAYPOST_CONFIG");
378
+ if (explicit) return explicit;
379
+ const base = findEnvValue(env, "XDG_CONFIG_HOME") ?? join(findEnvValue(env, "HOME") ?? homedir(), ".config");
380
+ return join(base, "jaypost", "config.json");
381
+ }
382
+ /** Default config directory, ignoring $JAYPOST_CONFIG; install files live here. */
383
+ function resolveConfigDirectory(env = process.env) {
384
+ const { JAYPOST_CONFIG: _ignored, ...rest } = env;
385
+ return dirname(resolveConfigPath(rest));
386
+ }
387
+ //#endregion
388
+ //#region src/system/files.ts
389
+ /** True when `error` is a Node error with code `ENOENT`. */
390
+ function isMissingFileError(error) {
391
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
392
+ }
393
+ //#endregion
394
+ //#region src/auth/token.ts
395
+ /** The trimmed value when it is a valid token, else undefined. Never throws. */
396
+ function parseToken(raw) {
397
+ const trimmed = raw.trim();
398
+ return TOKEN_PATTERN.test(trimmed) ? trimmed : void 0;
399
+ }
400
+ /** Path of the client token file in the install config directory; ignores JAYPOST_CONFIG. */
401
+ function resolveTokenFile(env = process.env) {
402
+ return join(resolveConfigDirectory(env), TOKEN_FILE_NAME);
403
+ }
404
+ /** Raw text of the token file, or undefined when it is missing; other read errors are rethrown. */
405
+ function readTokenFile(path) {
406
+ try {
407
+ return readFileSync(path, "utf8");
408
+ } catch (error) {
409
+ if (isMissingFileError(error)) return;
410
+ throw error;
411
+ }
412
+ }
413
+ /**
414
+ * Finds the client token: the env var (empty counts as unset), then the file, else none. A source
415
+ * that exists but is invalid or unreadable gives `unusable`, with a message that names the source
416
+ * and never the value. Never throws.
417
+ */
418
+ function resolveClientToken(env, tokenFile) {
419
+ const fromEnv = findEnvValue(env, TOKEN_ENV_VAR);
420
+ if (fromEnv !== void 0) {
421
+ const token = parseToken(fromEnv);
422
+ return token === void 0 ? {
423
+ kind: "unusable",
424
+ message: `${TOKEN_ENV_VAR} isn't a valid jaypost token`
425
+ } : {
426
+ kind: "token",
427
+ token,
428
+ source: "env"
429
+ };
430
+ }
431
+ let raw;
432
+ try {
433
+ raw = readTokenFile(tokenFile);
434
+ } catch (error) {
435
+ const reason = error instanceof Error ? error.message : String(error);
436
+ return {
437
+ kind: "unusable",
438
+ message: `token file ${JSON.stringify(tokenFile)} couldn't be read: ${reason}`
439
+ };
440
+ }
441
+ if (raw === void 0) return NO_CLIENT_TOKEN;
442
+ const token = parseToken(raw);
443
+ return token === void 0 ? {
444
+ kind: "unusable",
445
+ message: `token file ${JSON.stringify(tokenFile)} isn't a valid jaypost token`
446
+ } : {
447
+ kind: "token",
448
+ token,
449
+ source: "file"
450
+ };
451
+ }
452
+ //#endregion
453
+ //#region src/client/client.ts
454
+ /** Thrown by `Client` when the service is unreachable, rejects the input or can't send. */
455
+ var JaypostError = class extends Error {
456
+ name = "JaypostError";
457
+ };
458
+ /**
459
+ * Asks questions and sends notifications through a running jaypost service. Calls throw
460
+ * `JaypostError` when the token lookup finds an unusable token.
461
+ */
462
+ var Client = class {
463
+ #host;
464
+ #port;
465
+ #project;
466
+ #token;
467
+ constructor(options = {}) {
468
+ this.#host = options.host ?? "127.0.0.1";
469
+ this.#port = options.port ?? 8787;
470
+ this.#project = options.project;
471
+ if (options.token !== void 0) {
472
+ const token = parseToken(options.token);
473
+ if (token === void 0) throw new RangeError("token isn't a valid jaypost token");
474
+ this.#token = token;
475
+ }
476
+ }
477
+ /**
478
+ * Resolves once the user taps a button or replies with text (`allowText`), or the timeout fires
479
+ * (then with `timeoutOption`). Throws `JaypostError` on failure (including an unusable token
480
+ * lookup), or the signal's reason if aborted.
481
+ */
482
+ async request(request) {
483
+ const project = request.project ?? this.#project;
484
+ const signal = request.signal ?? new AbortController().signal;
485
+ const token = this.#resolveToken();
486
+ const attachment = await this.#prepare(request.attachment, signal, token);
487
+ const result = await requestApi(this.#port, {
488
+ text: request.text,
489
+ ...request.options === void 0 ? {} : { options: request.options },
490
+ ...request.allowText === void 0 ? {} : { allowText: request.allowText },
491
+ timeoutOption: request.timeoutOption,
492
+ ...request.timeout === void 0 ? {} : { timeout: request.timeout },
493
+ ...project === void 0 ? {} : { project },
494
+ ...request.format === void 0 ? {} : { format: request.format },
495
+ ...attachment === void 0 ? {} : { attachment }
496
+ }, {
497
+ signal,
498
+ host: this.#host,
499
+ ...token === void 0 ? {} : { token }
500
+ });
501
+ if (signal.aborted) throw signal.reason;
502
+ if (result.kind === "failed") throw new JaypostError(result.message);
503
+ return result.outcome;
504
+ }
505
+ /** Resolves once Telegram accepts the message. Throws like `request`. */
506
+ async notify(notification) {
507
+ const attachment = await this.#prepare(notification.attachment, notification.signal ?? new AbortController().signal, this.#resolveToken());
508
+ await this.#post("/notify", {
509
+ text: notification.text,
510
+ ...notification.format === void 0 ? {} : { format: notification.format },
511
+ ...attachment === void 0 ? {} : { attachment }
512
+ }, notification, toSendTimeoutMs(attachment !== void 0));
513
+ }
514
+ /** Reads and checks the attachment, if any; throws `JaypostError` when it can't be sent. */
515
+ async #prepare(input, signal, token) {
516
+ if (input === void 0) return;
517
+ const result = await prepareAttachment(this.#port, input, {
518
+ signal,
519
+ host: this.#host,
520
+ ...token === void 0 ? {} : { token }
521
+ });
522
+ if (signal.aborted) throw signal.reason;
523
+ if (result.kind === "failed") throw new JaypostError(result.message);
524
+ return result.attachment;
525
+ }
526
+ async #post(path, body, call, timeoutMs) {
527
+ const project = call.project ?? this.#project;
528
+ const signal = call.signal ?? new AbortController().signal;
529
+ const token = this.#resolveToken();
530
+ const result = await postApi(this.#port, path, {
531
+ ...body,
532
+ ...project === void 0 ? {} : { project }
533
+ }, {
534
+ signal,
535
+ timeoutMs,
536
+ host: this.#host,
537
+ ...token === void 0 ? {} : { token }
538
+ });
539
+ if (signal.aborted) throw signal.reason;
540
+ if (result.kind === "failed") throw new JaypostError(result.message);
541
+ return result.body;
542
+ }
543
+ #resolveToken() {
544
+ if (this.#token !== void 0) return this.#token;
545
+ const found = resolveClientToken(process.env, resolveTokenFile(process.env));
546
+ switch (found.kind) {
547
+ case "token": return found.token;
548
+ case "none": return;
549
+ case "unusable": throw new JaypostError(found.message);
550
+ }
551
+ }
552
+ };
553
+ //#endregion
554
+ //#region src/format/constants.ts
555
+ /** A fenced block's language is kept only when its first info word looks like this. */
556
+ const LANGUAGE_PATTERN = /^[A-Za-z0-9_+#.-]{1,32}$/;
557
+ //#endregion
558
+ //#region src/format/escape.ts
559
+ const MARKUP_PATTERN = /[\\`*~#>]/g;
560
+ const FENCE_RUN_PATTERN = /^ {0,3}(`+)/gm;
561
+ /**
562
+ * Backslash-escapes every character the markdown subset could treat as markup, so that
563
+ * `format: "markdown"` shows `text` as typed. Never throws.
564
+ */
565
+ function escapeMarkdown(text) {
566
+ return text.replace(MARKUP_PATTERN, "\\$&");
567
+ }
568
+ /**
569
+ * Wraps `code` in a fenced block that shows it verbatim, with a fence longer than any backtick
570
+ * run that starts a line of `code`. Throws a RangeError when `language` isn't a plain word.
571
+ */
572
+ function formatCodeBlock(code, language) {
573
+ if (language !== void 0 && !LANGUAGE_PATTERN.test(language)) throw new RangeError(`language ${JSON.stringify(language)} must match ${LANGUAGE_PATTERN.source}`);
574
+ const longestRun = [...code.matchAll(FENCE_RUN_PATTERN)].reduce((longest, match) => Math.max(longest, match[1]?.length ?? 0), 0);
575
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
576
+ return `${fence}${language ?? ""}\n${code}\n${fence}`;
577
+ }
578
+ //#endregion
579
+ export { Client, JaypostError, escapeMarkdown, formatCodeBlock };