telegram-ask 0.1.1
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 +201 -0
- package/README.md +143 -0
- package/dist/approval/constants.js +10 -0
- package/dist/approval/formatter.js +27 -0
- package/dist/approval/index.js +5 -0
- package/dist/approval/parser.js +43 -0
- package/dist/approval/pending.js +47 -0
- package/dist/approval/service.js +65 -0
- package/dist/approval/types.js +1 -0
- package/dist/cli.js +161 -0
- package/dist/commands/constants.js +14 -0
- package/dist/commands/index.js +8 -0
- package/dist/commands/init.js +159 -0
- package/dist/commands/owner.js +18 -0
- package/dist/commands/pairing-wait.js +65 -0
- package/dist/commands/prompts.js +55 -0
- package/dist/commands/reset-owner.js +58 -0
- package/dist/commands/status.js +42 -0
- package/dist/commands/test.js +22 -0
- package/dist/commands/types.js +1 -0
- package/dist/commands/uninstall.js +30 -0
- package/dist/config/constants.js +13 -0
- package/dist/config/index.js +2 -0
- package/dist/config/schema.js +7 -0
- package/dist/config/store.js +104 -0
- package/dist/config/types.js +1 -0
- package/dist/daemon/index.js +1 -0
- package/dist/daemon/service.js +82 -0
- package/dist/install/compose.js +123 -0
- package/dist/install/constants.js +28 -0
- package/dist/install/envfile.js +50 -0
- package/dist/install/index.js +9 -0
- package/dist/install/paths.js +23 -0
- package/dist/install/quadlet.js +167 -0
- package/dist/install/registry.js +98 -0
- package/dist/install/schema.js +10 -0
- package/dist/install/state.js +24 -0
- package/dist/install/systemd.js +151 -0
- package/dist/install/templates.js +87 -0
- package/dist/install/types.js +1 -0
- package/dist/server/constants.js +2 -0
- package/dist/server/index.js +2 -0
- package/dist/server/schema.js +6 -0
- package/dist/server/server.js +74 -0
- package/dist/server/types.js +1 -0
- package/dist/system/constants.js +6 -0
- package/dist/system/exec.js +30 -0
- package/dist/system/files.js +50 -0
- package/dist/system/host.js +48 -0
- package/dist/system/index.js +3 -0
- package/dist/system/types.js +1 -0
- package/dist/telegram/adapter.js +99 -0
- package/dist/telegram/constants.js +12 -0
- package/dist/telegram/index.js +3 -0
- package/dist/telegram/pairing.js +60 -0
- package/dist/telegram/types.js +1 -0
- package/package.json +43 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DEFAULT_PORT, readConfigFile } from '../config/index.js';
|
|
4
|
+
import { createComposeTarget } from './compose.js';
|
|
5
|
+
import { INTEGER_PATTERN, TARGET_ORDER } from './constants.js';
|
|
6
|
+
import { readEnvFile } from './envfile.js';
|
|
7
|
+
import { createQuadletTarget } from './quadlet.js';
|
|
8
|
+
import { readInstall } from './state.js';
|
|
9
|
+
import { createSystemdTarget, findUnitConfigFile, resolveDefaultConfigFile } from './systemd.js';
|
|
10
|
+
/** Builds all three install targets from the same dependencies. */
|
|
11
|
+
export function createTargets(deps, cli) {
|
|
12
|
+
return {
|
|
13
|
+
quadlet: createQuadletTarget(deps),
|
|
14
|
+
compose: createComposeTarget(deps),
|
|
15
|
+
systemd: createSystemdTarget(deps, cli),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Asks each target if it is usable, in `TARGET_ORDER`. */
|
|
19
|
+
export async function detectTargets(targets) {
|
|
20
|
+
const detected = [];
|
|
21
|
+
for (const name of TARGET_ORDER) {
|
|
22
|
+
detected.push({ name, ...(await targets[name].available()) });
|
|
23
|
+
}
|
|
24
|
+
return detected;
|
|
25
|
+
}
|
|
26
|
+
/** First available target in the given order, or undefined when none is usable. */
|
|
27
|
+
export function findDefaultTarget(detected) {
|
|
28
|
+
return detected.find((candidate) => candidate.ok)?.name;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Install recorded in install.json, else a legacy systemd install rebuilt from its unit file
|
|
32
|
+
* (`legacy: true`); undefined when neither exists.
|
|
33
|
+
*/
|
|
34
|
+
export function findExistingInstall(paths, _targets, env = process.env) {
|
|
35
|
+
const record = readInstall(paths.installFile);
|
|
36
|
+
if (record) {
|
|
37
|
+
return { record, legacy: false };
|
|
38
|
+
}
|
|
39
|
+
let unit;
|
|
40
|
+
try {
|
|
41
|
+
unit = readFileSync(paths.systemdUnit, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
// The legacy unit may pin its own config path; prefer it over this shell's env.
|
|
47
|
+
const configFile = findUnitConfigFile(unit) ?? resolveDefaultConfigFile(paths, env);
|
|
48
|
+
let port = DEFAULT_PORT;
|
|
49
|
+
try {
|
|
50
|
+
port = readConfigFile(configFile).port ?? DEFAULT_PORT;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Unreadable config: fall back to the default port.
|
|
54
|
+
}
|
|
55
|
+
return { record: { target: 'systemd', port, version: 'legacy', configFile }, legacy: true };
|
|
56
|
+
}
|
|
57
|
+
function readEnvSecrets(path) {
|
|
58
|
+
const values = readEnvFile(path);
|
|
59
|
+
return {
|
|
60
|
+
...(values.BOT_TOKEN ? { token: values.BOT_TOKEN } : {}),
|
|
61
|
+
...(values.OWNER_ID && INTEGER_PATTERN.test(values.OWNER_ID)
|
|
62
|
+
? { ownerId: Number(values.OWNER_ID) }
|
|
63
|
+
: {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function readConfigSecrets(path) {
|
|
67
|
+
try {
|
|
68
|
+
const config = readConfigFile(path);
|
|
69
|
+
return {
|
|
70
|
+
...(config.botToken ? { token: config.botToken } : {}),
|
|
71
|
+
...(config.ownerId !== undefined && Number.isSafeInteger(config.ownerId)
|
|
72
|
+
? { ownerId: config.ownerId }
|
|
73
|
+
: {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Corrupt config: treat as nothing left over.
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Token and owner left behind by an uninstall that kept them (no install.json any more).
|
|
83
|
+
* Only reads files; never runs a container. First source with a token wins.
|
|
84
|
+
*/
|
|
85
|
+
export function findLeftoverSecrets(paths, env = process.env) {
|
|
86
|
+
const sources = [
|
|
87
|
+
() => readEnvSecrets(join(paths.quadletDirectory, '.env')),
|
|
88
|
+
() => readEnvSecrets(join(paths.composeDirectory, '.env')),
|
|
89
|
+
() => readConfigSecrets(resolveDefaultConfigFile(paths, env)),
|
|
90
|
+
];
|
|
91
|
+
for (const read of sources) {
|
|
92
|
+
const secrets = read();
|
|
93
|
+
if (secrets.token !== undefined) {
|
|
94
|
+
return secrets;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Shape of install.json. */
|
|
3
|
+
export const installRecordSchema = z.object({
|
|
4
|
+
target: z.enum(['systemd', 'compose', 'quadlet']),
|
|
5
|
+
port: z.number(),
|
|
6
|
+
version: z.string(),
|
|
7
|
+
cliPath: z.string().optional(),
|
|
8
|
+
/** Absolute config.json path the systemd unit was installed with. */
|
|
9
|
+
configFile: z.string().optional(),
|
|
10
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { writeFileAtomic } from '../system/index.js';
|
|
3
|
+
import { SECRET_FILE_MODE } from './constants.js';
|
|
4
|
+
import { installRecordSchema } from './schema.js';
|
|
5
|
+
/** Undefined when the file is missing, unreadable or invalid. */
|
|
6
|
+
export function readInstall(file) {
|
|
7
|
+
let parsed;
|
|
8
|
+
try {
|
|
9
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const result = installRecordSchema.safeParse(parsed);
|
|
15
|
+
return result.success ? result.data : undefined;
|
|
16
|
+
}
|
|
17
|
+
/** Atomic write with owner-only permissions. */
|
|
18
|
+
export function writeInstall(file, record) {
|
|
19
|
+
writeFileAtomic(file, `${JSON.stringify(record, null, 2)}\n`, SECRET_FILE_MODE);
|
|
20
|
+
}
|
|
21
|
+
/** Deletes install.json; fine if it is already gone. */
|
|
22
|
+
export function removeInstall(file) {
|
|
23
|
+
rmSync(file, { force: true });
|
|
24
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { accessSync, constants, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { readConfigFile, updateConfigFile } from '../config/index.js';
|
|
4
|
+
import { ensureLinger } from '../system/index.js';
|
|
5
|
+
import { CONFIG_ENV_PREFIX, ENVIRONMENT_LINE_PATTERN, ESCAPED_CHAR_PATTERN, PACKAGE_NAME, SERVICE_NAME, } from './constants.js';
|
|
6
|
+
import { formatSystemdUnit } from './templates.js';
|
|
7
|
+
function isUnder(file, root) {
|
|
8
|
+
return root !== '' && file.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
9
|
+
}
|
|
10
|
+
function isWritable(directory) {
|
|
11
|
+
try {
|
|
12
|
+
accessSync(directory, constants.W_OK);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The config file a systemd install uses when nothing recorded one: $TELEGRAM_ASK_CONFIG or the default, absolute. */
|
|
20
|
+
export function resolveDefaultConfigFile(paths, env) {
|
|
21
|
+
return resolve(env.TELEGRAM_ASK_CONFIG || paths.configFile);
|
|
22
|
+
}
|
|
23
|
+
/** Best-effort read of the TELEGRAM_ASK_CONFIG value from a unit's Environment= lines. */
|
|
24
|
+
export function findUnitConfigFile(unit) {
|
|
25
|
+
for (const line of unit.split('\n')) {
|
|
26
|
+
const rawValue = ENVIRONMENT_LINE_PATTERN.exec(line)?.[1];
|
|
27
|
+
if (rawValue === undefined) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const trimmed = rawValue.trim();
|
|
31
|
+
const unquoted = trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')
|
|
32
|
+
? trimmed.slice(1, -1).replace(ESCAPED_CHAR_PATTERN, '$1')
|
|
33
|
+
: trimmed;
|
|
34
|
+
const value = unquoted.replaceAll('%%', '%');
|
|
35
|
+
if (value.startsWith(CONFIG_ENV_PREFIX) && value.length > CONFIG_ENV_PREFIX.length) {
|
|
36
|
+
return resolve(value.slice(CONFIG_ENV_PREFIX.length));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* systemd user service target running the npm-installed CLI; keeps its settings in the config file.
|
|
43
|
+
*/
|
|
44
|
+
export function createSystemdTarget(deps, cli) {
|
|
45
|
+
const { exec, paths, env, user } = deps;
|
|
46
|
+
const systemctl = (...args) => exec('systemctl', ['--user', ...args]);
|
|
47
|
+
// The unit always pins TELEGRAM_ASK_CONFIG to this absolute path, and install.json records it,
|
|
48
|
+
// so later commands don't depend on the environment they happen to run in.
|
|
49
|
+
const configFile = resolveDefaultConfigFile(paths, env);
|
|
50
|
+
const resolveConfigFile = (record) => record.configFile ?? configFile;
|
|
51
|
+
const fallbackRoot = join(paths.userNpmPrefix, 'lib', 'node_modules');
|
|
52
|
+
async function resolveCli(options) {
|
|
53
|
+
const root = (await exec('npm', ['root', '-g'])).stdout.trim();
|
|
54
|
+
if (isUnder(cli.selfPath, root)) {
|
|
55
|
+
options.print(`Using the installed CLI at ${cli.selfPath}`);
|
|
56
|
+
return cli.selfPath;
|
|
57
|
+
}
|
|
58
|
+
if (isUnder(cli.selfPath, fallbackRoot)) {
|
|
59
|
+
options.print(`Using the installed CLI at ${cli.selfPath}`);
|
|
60
|
+
return cli.selfPath;
|
|
61
|
+
}
|
|
62
|
+
const packageSpec = options.tarball ?? `${PACKAGE_NAME}@${options.version}`;
|
|
63
|
+
const prefix = (await exec('npm', ['prefix', '-g'])).stdout.trim();
|
|
64
|
+
let installRoot;
|
|
65
|
+
let args;
|
|
66
|
+
if (prefix !== '' && isWritable(prefix)) {
|
|
67
|
+
options.print(`Installing ${packageSpec} globally with npm`);
|
|
68
|
+
args = ['i', '-g', packageSpec];
|
|
69
|
+
installRoot = root;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
options.print(`Global npm prefix isn't writable; installing ${packageSpec} into ${paths.userNpmPrefix}`);
|
|
73
|
+
mkdirSync(paths.userNpmPrefix, { recursive: true });
|
|
74
|
+
args = ['i', '-g', '--prefix', paths.userNpmPrefix, packageSpec];
|
|
75
|
+
installRoot = fallbackRoot;
|
|
76
|
+
}
|
|
77
|
+
const result = await exec('npm', args);
|
|
78
|
+
if (result.code !== 0) {
|
|
79
|
+
throw new Error(`npm install failed: ${result.stderr.trim()}`);
|
|
80
|
+
}
|
|
81
|
+
return join(installRoot, PACKAGE_NAME, 'dist', 'cli.js');
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
name: 'systemd',
|
|
85
|
+
async available() {
|
|
86
|
+
const result = await systemctl('show-environment');
|
|
87
|
+
return result.code === 0
|
|
88
|
+
? { ok: true }
|
|
89
|
+
: { ok: false, reason: 'systemd user session not available' };
|
|
90
|
+
},
|
|
91
|
+
async install(options) {
|
|
92
|
+
const cliPath = await resolveCli(options);
|
|
93
|
+
// Only touch ownerId when we have one; clearOwner is the explicit way to drop it.
|
|
94
|
+
updateConfigFile(configFile, {
|
|
95
|
+
botToken: options.token,
|
|
96
|
+
port: options.port,
|
|
97
|
+
...(options.ownerId === undefined ? {} : { ownerId: options.ownerId }),
|
|
98
|
+
});
|
|
99
|
+
mkdirSync(dirname(paths.systemdUnit), { recursive: true });
|
|
100
|
+
writeFileSync(paths.systemdUnit, formatSystemdUnit({ node: cli.nodePath, cli: cliPath, config: configFile }));
|
|
101
|
+
for (const args of [['daemon-reload'], ['enable', '--now', SERVICE_NAME]]) {
|
|
102
|
+
const result = await systemctl(...args);
|
|
103
|
+
if (result.code !== 0) {
|
|
104
|
+
throw new Error(`systemctl ${args.join(' ')} failed: ${result.stderr.trim()}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
await ensureLinger(exec, user, options.print);
|
|
108
|
+
return {
|
|
109
|
+
target: 'systemd',
|
|
110
|
+
port: options.port,
|
|
111
|
+
version: options.version,
|
|
112
|
+
cliPath,
|
|
113
|
+
configFile,
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
async readSecrets(record) {
|
|
117
|
+
const config = readConfigFile(resolveConfigFile(record));
|
|
118
|
+
return {
|
|
119
|
+
...(config.botToken === undefined ? {} : { token: config.botToken }),
|
|
120
|
+
...(config.ownerId === undefined ? {} : { ownerId: config.ownerId }),
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
async remove(record, purge) {
|
|
124
|
+
await systemctl('disable', '--now', SERVICE_NAME);
|
|
125
|
+
rmSync(paths.systemdUnit, { force: true });
|
|
126
|
+
await systemctl('daemon-reload');
|
|
127
|
+
if (purge) {
|
|
128
|
+
rmSync(resolveConfigFile(record), { force: true });
|
|
129
|
+
const prefix = record.cliPath !== undefined && isUnder(record.cliPath, fallbackRoot)
|
|
130
|
+
? `--prefix ${paths.userNpmPrefix} `
|
|
131
|
+
: '';
|
|
132
|
+
console.log(`The npm package was left in place; remove it with: npm rm -g ${prefix}${PACKAGE_NAME}`);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
async restart() {
|
|
136
|
+
const result = await systemctl('restart', SERVICE_NAME);
|
|
137
|
+
if (result.code !== 0) {
|
|
138
|
+
throw new Error(`systemctl restart failed (exit ${result.code})`);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
async clearOwner(record) {
|
|
142
|
+
updateConfigFile(resolveConfigFile(record), { ownerId: null });
|
|
143
|
+
},
|
|
144
|
+
async isRunning() {
|
|
145
|
+
return (await systemctl('is-active', '--quiet', SERVICE_NAME)).code === 0;
|
|
146
|
+
},
|
|
147
|
+
logsHint() {
|
|
148
|
+
return `journalctl --user -u ${SERVICE_NAME} -f`;
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
/** Wraps in double quotes, escaping backslash and `"`, and doubling `%` (systemd specifier). */
|
|
3
|
+
export function quoteSystemdValue(value) {
|
|
4
|
+
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`;
|
|
5
|
+
}
|
|
6
|
+
/** Quotes one ExecStart word; `$` is also escaped there because systemd expands `$VAR` in ExecStart. */
|
|
7
|
+
export function quoteExecWord(word) {
|
|
8
|
+
return quoteSystemdValue(word).replaceAll('$', () => '$$');
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Image that installs the package from npm, or from a local tarball when given, and runs `start` as
|
|
12
|
+
* the node user.
|
|
13
|
+
*/
|
|
14
|
+
export function formatDockerfile(options) {
|
|
15
|
+
const install = options.tarball === undefined
|
|
16
|
+
? `RUN npm i -g telegram-ask@${options.version}`
|
|
17
|
+
: `COPY ${basename(options.tarball)} /tmp/pkg.tgz\nRUN npm i -g /tmp/pkg.tgz && rm /tmp/pkg.tgz`;
|
|
18
|
+
return `FROM node:24-slim
|
|
19
|
+
ENV NODE_ENV=production TELEGRAM_ASK_CONFIG=/data/config.json HOST=0.0.0.0
|
|
20
|
+
${install}
|
|
21
|
+
RUN mkdir /data && chown node:node /data
|
|
22
|
+
VOLUME /data
|
|
23
|
+
USER node
|
|
24
|
+
CMD ["telegram-ask","start"]
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
27
|
+
/** Compose file publishing `port` on loopback only, with a named volume for /data. */
|
|
28
|
+
export function formatComposeYaml(port) {
|
|
29
|
+
return `name: telegram-ask
|
|
30
|
+
services:
|
|
31
|
+
bot:
|
|
32
|
+
build: .
|
|
33
|
+
restart: unless-stopped
|
|
34
|
+
env_file: .env
|
|
35
|
+
ports:
|
|
36
|
+
- "127.0.0.1:${port}:${port}"
|
|
37
|
+
volumes:
|
|
38
|
+
- data:/data
|
|
39
|
+
volumes:
|
|
40
|
+
data: {}
|
|
41
|
+
`;
|
|
42
|
+
}
|
|
43
|
+
/** KEY=VALUE lines for the container; `OWNER_ID` is included only when known. */
|
|
44
|
+
export function formatEnvFile(options) {
|
|
45
|
+
const lines = [
|
|
46
|
+
`BOT_TOKEN=${options.token}`,
|
|
47
|
+
`PORT=${options.port}`,
|
|
48
|
+
...(options.ownerId === undefined ? [] : [`OWNER_ID=${options.ownerId}`]),
|
|
49
|
+
];
|
|
50
|
+
return lines.join('\n') + '\n';
|
|
51
|
+
}
|
|
52
|
+
/** Quadlet .container unit; publishes `port` on loopback only and reads the env file. */
|
|
53
|
+
export function formatQuadletContainer(options) {
|
|
54
|
+
return `[Unit]
|
|
55
|
+
Description=telegram-ask service
|
|
56
|
+
|
|
57
|
+
[Container]
|
|
58
|
+
Image=localhost/telegram-ask:${options.version}
|
|
59
|
+
ContainerName=telegram-ask
|
|
60
|
+
Volume=telegram-ask-data:/data
|
|
61
|
+
EnvironmentFile=${quoteSystemdValue(options.envFile)}
|
|
62
|
+
PublishPort=127.0.0.1:${options.port}:${options.port}
|
|
63
|
+
|
|
64
|
+
[Service]
|
|
65
|
+
Restart=on-failure
|
|
66
|
+
|
|
67
|
+
[Install]
|
|
68
|
+
WantedBy=default.target
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
/** User unit that runs `node cli start` with TELEGRAM_ASK_CONFIG pinned to `config`. */
|
|
72
|
+
export function formatSystemdUnit(options) {
|
|
73
|
+
return `[Unit]
|
|
74
|
+
Description=telegram-ask service
|
|
75
|
+
After=network-online.target
|
|
76
|
+
Wants=network-online.target
|
|
77
|
+
|
|
78
|
+
[Service]
|
|
79
|
+
Environment=${quoteSystemdValue(`TELEGRAM_ASK_CONFIG=${options.config}`)}
|
|
80
|
+
ExecStart=${quoteExecWord(options.node)} ${quoteExecWord(options.cli)} start
|
|
81
|
+
Restart=on-failure
|
|
82
|
+
RestartSec=5
|
|
83
|
+
|
|
84
|
+
[Install]
|
|
85
|
+
WantedBy=default.target
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Shape of the /status response; `code` is present only while unpaired. */
|
|
3
|
+
export const statusInfoSchema = z.discriminatedUnion('paired', [
|
|
4
|
+
z.object({ paired: z.literal(true), bot: z.string() }),
|
|
5
|
+
z.object({ paired: z.literal(false), code: z.string(), bot: z.string() }),
|
|
6
|
+
]);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import { parseApprovalRequest, SendError } from '../approval/index.js';
|
|
3
|
+
import { MAX_BODY_BYTES } from './constants.js';
|
|
4
|
+
/**
|
|
5
|
+
* Builds the HTTP API (POST /request, GET /status) without listening. Aborting the connection
|
|
6
|
+
* cancels the pending request; errors return JSON bodies.
|
|
7
|
+
*/
|
|
8
|
+
export function createApiServer(service, status) {
|
|
9
|
+
// Pending requests can hang for minutes; close() shouldn't wait for them.
|
|
10
|
+
const app = Fastify({ bodyLimit: MAX_BODY_BYTES, forceCloseConnections: true });
|
|
11
|
+
// Take the body as text whatever the content type, so bad JSON gets our own 400.
|
|
12
|
+
app.removeAllContentTypeParsers();
|
|
13
|
+
app.addContentTypeParser('*', { parseAs: 'string' }, (_request, body, done) => done(null, body));
|
|
14
|
+
app.setNotFoundHandler((request, reply) => {
|
|
15
|
+
// Not new URL(): it throws on targets like '//'.
|
|
16
|
+
const path = request.url.split('?')[0];
|
|
17
|
+
if (path === '/request') {
|
|
18
|
+
return reply.code(405).header('Allow', 'POST').send({ error: 'method not allowed' });
|
|
19
|
+
}
|
|
20
|
+
if (path === '/status') {
|
|
21
|
+
return reply.code(405).header('Allow', 'GET').send({ error: 'method not allowed' });
|
|
22
|
+
}
|
|
23
|
+
return reply.code(404).send({ error: 'not found' });
|
|
24
|
+
});
|
|
25
|
+
app.setErrorHandler((error, request, reply) => {
|
|
26
|
+
if (error instanceof SendError) {
|
|
27
|
+
return reply.code(502).send({ error: 'failed to send Telegram message' });
|
|
28
|
+
}
|
|
29
|
+
if (error.statusCode === 413) {
|
|
30
|
+
// Drop the rest of the upload once the response is flushed.
|
|
31
|
+
reply.raw.once('finish', () => request.raw.destroy());
|
|
32
|
+
return reply.code(413).header('Connection', 'close').send({ error: 'request body too large' });
|
|
33
|
+
}
|
|
34
|
+
if (error.statusCode !== undefined && error.statusCode < 500) {
|
|
35
|
+
return reply.code(error.statusCode).send({ error: error.message });
|
|
36
|
+
}
|
|
37
|
+
console.error('unexpected error handling /request', error);
|
|
38
|
+
return reply.code(500).send({ error: 'internal server error' });
|
|
39
|
+
});
|
|
40
|
+
app.get('/status', async () => status());
|
|
41
|
+
app.post('/request', async (request, reply) => {
|
|
42
|
+
if (!status().paired) {
|
|
43
|
+
return reply.code(503).send({ error: 'not paired' });
|
|
44
|
+
}
|
|
45
|
+
let json;
|
|
46
|
+
try {
|
|
47
|
+
json = JSON.parse(request.body ?? '');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return reply.code(400).send({ error: 'body must be valid JSON' });
|
|
51
|
+
}
|
|
52
|
+
const parsed = parseApprovalRequest(json);
|
|
53
|
+
if (!parsed.ok) {
|
|
54
|
+
return reply.code(400).send({ error: parsed.error });
|
|
55
|
+
}
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
reply.raw.on('close', () => {
|
|
58
|
+
if (!reply.raw.writableEnded) {
|
|
59
|
+
controller.abort();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
// The client may have gone while the body was being read, before 'close' was observed.
|
|
63
|
+
if (reply.raw.destroyed) {
|
|
64
|
+
controller.abort();
|
|
65
|
+
}
|
|
66
|
+
const result = await service.request(parsed.value, controller.signal);
|
|
67
|
+
if (result.kind === 'cancelled') {
|
|
68
|
+
// Normally because the client is gone, so this write is a no-op.
|
|
69
|
+
return reply.code(500).send({ error: 'request was cancelled' });
|
|
70
|
+
}
|
|
71
|
+
return result.outcome;
|
|
72
|
+
});
|
|
73
|
+
return app;
|
|
74
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Matches `Linger=yes` in `loginctl show-user` output. */
|
|
2
|
+
export const LINGER_ON_PATTERN = /Linger=yes/;
|
|
3
|
+
/** Matches `Linger=no` in `loginctl show-user` output. */
|
|
4
|
+
export const LINGER_OFF_PATTERN = /Linger=no/;
|
|
5
|
+
/** How long to wait for Telegram when checking a token. */
|
|
6
|
+
export const TOKEN_CHECK_TIMEOUT_SECONDS = 10;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/** Thrown by `realExec` when the binary doesn't exist. */
|
|
3
|
+
export class MissingCommandError extends Error {
|
|
4
|
+
command;
|
|
5
|
+
constructor(command) {
|
|
6
|
+
super(`command ${JSON.stringify(command)} not found`);
|
|
7
|
+
this.name = 'MissingCommandError';
|
|
8
|
+
this.command = command;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Never rejects on a non-zero exit; rejects only when the binary doesn't exist. */
|
|
12
|
+
export const realExec = (command, args, options = {}) => new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn(command, args, {
|
|
14
|
+
env: options.env ?? process.env,
|
|
15
|
+
stdio: options.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
|
16
|
+
});
|
|
17
|
+
let stdout = '';
|
|
18
|
+
let stderr = '';
|
|
19
|
+
child.stdout?.on('data', (chunk) => (stdout += chunk.toString()));
|
|
20
|
+
child.stderr?.on('data', (chunk) => (stderr += chunk.toString()));
|
|
21
|
+
child.on('error', (error) => {
|
|
22
|
+
if (error.code === 'ENOENT') {
|
|
23
|
+
reject(new MissingCommandError(command));
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
reject(error);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
30
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { closeSync, fchmodSync, fsyncSync, mkdirSync, openSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Writes `contents` to `path` via a temp file, fsync and rename, so readers never see a partial
|
|
5
|
+
* file. Creates the parent directory. A symlinked `path` is written through: the link is kept and
|
|
6
|
+
* its target replaced. On failure removes the temp file (best effort) and rethrows; an existing
|
|
7
|
+
* file is left as it was.
|
|
8
|
+
*/
|
|
9
|
+
export function writeFileAtomic(path, contents, mode) {
|
|
10
|
+
const target = resolveWriteTarget(path);
|
|
11
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
12
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
13
|
+
try {
|
|
14
|
+
const fd = openSync(temp, 'w', mode);
|
|
15
|
+
try {
|
|
16
|
+
// The umask can strip bits when the file is created.
|
|
17
|
+
fchmodSync(fd, mode);
|
|
18
|
+
writeFileSync(fd, contents);
|
|
19
|
+
fsyncSync(fd);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
closeSync(fd);
|
|
23
|
+
}
|
|
24
|
+
renameSync(temp, target);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
try {
|
|
28
|
+
rmSync(temp, { force: true });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Best effort: the original error is the one worth reporting.
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function resolveWriteTarget(path) {
|
|
37
|
+
try {
|
|
38
|
+
return realpathSync(path);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (isMissingFileError(error)) {
|
|
42
|
+
return path;
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** True when `error` is a Node error with code `ENOENT`. */
|
|
48
|
+
export function isMissingFileError(error) {
|
|
49
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
50
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Api } from 'grammy';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { LINGER_OFF_PATTERN, LINGER_ON_PATTERN, TOKEN_CHECK_TIMEOUT_SECONDS } from './constants.js';
|
|
4
|
+
/** True when 127.0.0.1:`port` can be bound; any bind error counts as in use. */
|
|
5
|
+
export function isPortFree(port) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
const server = net.createServer();
|
|
8
|
+
server.once('error', () => resolve(false));
|
|
9
|
+
server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Enables systemd linger for `user` so the service survives logout. Never throws; prints the manual
|
|
14
|
+
* command if it can't.
|
|
15
|
+
*/
|
|
16
|
+
export async function ensureLinger(exec, user, print) {
|
|
17
|
+
const printFallback = () => print(`Run "loginctl enable-linger ${user}" so it keeps running after logout.`);
|
|
18
|
+
try {
|
|
19
|
+
const show = await exec('loginctl', ['show-user', user, '-p', 'Linger']);
|
|
20
|
+
if (show.code === 0 && LINGER_ON_PATTERN.test(show.stdout)) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (show.code === 0 && !LINGER_OFF_PATTERN.test(show.stdout)) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const result = await exec('loginctl', ['enable-linger', user]);
|
|
27
|
+
if (result.code !== 0) {
|
|
28
|
+
printFallback();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
printFallback();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Returns the bot username, or undefined on any failure. Never exposes the token or error text. */
|
|
36
|
+
export async function validateToken(token, apiRoot) {
|
|
37
|
+
try {
|
|
38
|
+
// No global fetch here: Node's fetch rejects grammY's polyfilled AbortSignal on every call.
|
|
39
|
+
const botUser = await new Api(token, {
|
|
40
|
+
timeoutSeconds: TOKEN_CHECK_TIMEOUT_SECONDS,
|
|
41
|
+
...(apiRoot === undefined ? {} : { apiRoot }),
|
|
42
|
+
}).getMe();
|
|
43
|
+
return botUser.username || undefined;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|