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,99 @@
|
|
|
1
|
+
import { updateConfigFile } from '../config/index.js';
|
|
2
|
+
import { CALLBACK_DATA_PATTERN } from './constants.js';
|
|
3
|
+
/** Button payload `<id>:<optionIndex>`; `routeTap` parses it. */
|
|
4
|
+
export function formatCallbackData(id, optionIndex) {
|
|
5
|
+
return `${id}:${optionIndex}`;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* 'ignored' for anyone but the owner, 'expired' for malformed data, else whatever `tap` returns.
|
|
9
|
+
*/
|
|
10
|
+
export function routeTap(fromId, data, ownerId, tap) {
|
|
11
|
+
if (ownerId === undefined || fromId !== ownerId) {
|
|
12
|
+
return 'ignored';
|
|
13
|
+
}
|
|
14
|
+
const [, id, rawIndex] = (data === undefined ? null : CALLBACK_DATA_PATTERN.exec(data)) ?? [];
|
|
15
|
+
if (id === undefined || rawIndex === undefined) {
|
|
16
|
+
return 'expired';
|
|
17
|
+
}
|
|
18
|
+
return tap(id, Number(rawIndex));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Sends requests as inline-keyboard messages to the owner; the final edit removes the buttons.
|
|
22
|
+
* Sending throws when nobody is paired.
|
|
23
|
+
*/
|
|
24
|
+
export function createTelegramMessenger(bot, owner) {
|
|
25
|
+
return {
|
|
26
|
+
async sendRequest(id, text, options) {
|
|
27
|
+
if (owner.id === undefined) {
|
|
28
|
+
throw new Error('bot is not paired yet');
|
|
29
|
+
}
|
|
30
|
+
const sent = await bot.api.sendMessage(owner.id, text, {
|
|
31
|
+
reply_markup: {
|
|
32
|
+
inline_keyboard: options.map((label, index) => [
|
|
33
|
+
{ text: label, callback_data: formatCallbackData(id, index) },
|
|
34
|
+
]),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return { chatId: sent.chat.id, messageId: sent.message_id };
|
|
38
|
+
},
|
|
39
|
+
async finalize(reference, text) {
|
|
40
|
+
await bot.api.editMessageText(reference.chatId, reference.messageId, text, {
|
|
41
|
+
reply_markup: { inline_keyboard: [] },
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Routes button taps to `tap`; always answers the callback so the button stops spinning. */
|
|
47
|
+
export function registerTapHandler(bot, owner, tap) {
|
|
48
|
+
bot.on('callback_query:data', async (context) => {
|
|
49
|
+
const route = routeTap(context.from.id, context.callbackQuery.data, owner.id, tap);
|
|
50
|
+
if (route === 'expired') {
|
|
51
|
+
await context.answerCallbackQuery({ text: 'Expired' });
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
await context.answerCallbackQuery();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Reply text for a pairing attempt, or undefined to stay silent (not a private chat, blocked, or
|
|
60
|
+
* already paired). Calls `onPaired` on success.
|
|
61
|
+
*/
|
|
62
|
+
export function handlePairingMessage(message, pairing, onPaired) {
|
|
63
|
+
if (message.chatType !== 'private') {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const result = pairing.attempt(message.fromId, message.text);
|
|
67
|
+
if (result === 'paired') {
|
|
68
|
+
onPaired(message.fromId);
|
|
69
|
+
return 'Paired. Approval requests will appear here.';
|
|
70
|
+
}
|
|
71
|
+
if (result === 'wrong') {
|
|
72
|
+
return "This bot isn't paired. Check the service logs for the pairing code.";
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/** Feeds text messages to `handlePairingMessage` and sends any reply. */
|
|
77
|
+
export function registerPairingHandler(bot, pairing, onPaired) {
|
|
78
|
+
bot.on('message:text', async (context) => {
|
|
79
|
+
const reply = handlePairingMessage({ fromId: context.from.id, chatType: context.chat.type, text: context.message.text }, pairing, onPaired);
|
|
80
|
+
if (reply !== undefined) {
|
|
81
|
+
await context.reply(reply);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Callback that sets the owner in memory first, then saves it to the config file. A failed save is
|
|
87
|
+
* logged, not thrown.
|
|
88
|
+
*/
|
|
89
|
+
export function createOnPaired(owner, path, log = console.error) {
|
|
90
|
+
return (id) => {
|
|
91
|
+
owner.id = id;
|
|
92
|
+
try {
|
|
93
|
+
updateConfigFile(path, { ownerId: id });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
log(`paired with ${id} but couldn't save the owner to ${path}; it'll be lost on restart`);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Uppercase letters and digits without look-alikes (I, L, O, 0, 1). */
|
|
2
|
+
export const PAIRING_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
3
|
+
/** Characters in a pairing code (shown as two groups of four). */
|
|
4
|
+
export const PAIRING_CODE_LENGTH = 8;
|
|
5
|
+
/** Wrong guesses allowed per Telegram user before they are blocked. */
|
|
6
|
+
export const DEFAULT_MAX_FAILURES = 5;
|
|
7
|
+
/** Leading `/start` or `/start@botname` on a pairing message. */
|
|
8
|
+
export const START_COMMAND_PATTERN = /^\/start(@\w+)?/i;
|
|
9
|
+
/** Dashes and whitespace ignored when comparing a typed code. */
|
|
10
|
+
export const CODE_SEPARATOR_PATTERN = /[-\s]/g;
|
|
11
|
+
/** Matches `<id>:<optionIndex>`, the format `formatCallbackData` writes. */
|
|
12
|
+
export const CALLBACK_DATA_PATTERN = /^([^:]+):(\d+)$/;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createOnPaired, createTelegramMessenger, formatCallbackData, handlePairingMessage, registerPairingHandler, registerTapHandler, routeTap, } from './adapter.js';
|
|
2
|
+
export { PAIRING_ALPHABET } from './constants.js';
|
|
3
|
+
export { formatCode, generateCode, Pairing } from './pairing.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import { CODE_SEPARATOR_PATTERN, DEFAULT_MAX_FAILURES, PAIRING_ALPHABET, PAIRING_CODE_LENGTH, START_COMMAND_PATTERN, } from './constants.js';
|
|
3
|
+
/** Random pairing code from `PAIRING_ALPHABET`; `random` is injectable for tests. */
|
|
4
|
+
export function generateCode(random = randomInt) {
|
|
5
|
+
let code = '';
|
|
6
|
+
for (let index = 0; index < PAIRING_CODE_LENGTH; index += 1) {
|
|
7
|
+
code += PAIRING_ALPHABET[random(PAIRING_ALPHABET.length)];
|
|
8
|
+
}
|
|
9
|
+
return code;
|
|
10
|
+
}
|
|
11
|
+
/** Splits a code into two groups of four with a dash, for display. */
|
|
12
|
+
export function formatCode(code) {
|
|
13
|
+
return `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
14
|
+
}
|
|
15
|
+
function normalizeCode(text) {
|
|
16
|
+
return text
|
|
17
|
+
.trim()
|
|
18
|
+
.replace(START_COMMAND_PATTERN, '')
|
|
19
|
+
.toUpperCase()
|
|
20
|
+
.replace(CODE_SEPARATOR_PATTERN, '');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* One-shot pairing state: the first correct code claims the bot, and each sender gets a limited
|
|
24
|
+
* number of wrong guesses.
|
|
25
|
+
*/
|
|
26
|
+
export class Pairing {
|
|
27
|
+
#code;
|
|
28
|
+
#maxFailures;
|
|
29
|
+
#failures = new Map();
|
|
30
|
+
constructor(code, maxFailures = DEFAULT_MAX_FAILURES) {
|
|
31
|
+
this.#code = code.toUpperCase();
|
|
32
|
+
this.#maxFailures = maxFailures;
|
|
33
|
+
}
|
|
34
|
+
/** The code to show, or undefined once paired. */
|
|
35
|
+
get code() {
|
|
36
|
+
return this.#code;
|
|
37
|
+
}
|
|
38
|
+
get paired() {
|
|
39
|
+
return this.#code === undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Compares `text` (case, dashes and a leading /start ignored) to the code. Wrong guesses are
|
|
43
|
+
* counted per sender and block at the limit.
|
|
44
|
+
*/
|
|
45
|
+
attempt(fromId, text) {
|
|
46
|
+
if (this.#code === undefined) {
|
|
47
|
+
return 'notPairing';
|
|
48
|
+
}
|
|
49
|
+
const failures = this.#failures.get(fromId) ?? 0;
|
|
50
|
+
if (failures >= this.#maxFailures) {
|
|
51
|
+
return 'blocked';
|
|
52
|
+
}
|
|
53
|
+
if (normalizeCode(text) === this.#code) {
|
|
54
|
+
this.#code = undefined;
|
|
55
|
+
return 'paired';
|
|
56
|
+
}
|
|
57
|
+
this.#failures.set(fromId, failures + 1);
|
|
58
|
+
return 'wrong';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "telegram-ask",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Local HTTP API that asks you a question on Telegram with inline buttons and returns your answer",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"approval",
|
|
7
|
+
"bot",
|
|
8
|
+
"human-in-the-loop",
|
|
9
|
+
"telegram"
|
|
10
|
+
],
|
|
11
|
+
"license": "Apache-2.0",
|
|
12
|
+
"author": "Cozmin",
|
|
13
|
+
"bin": {
|
|
14
|
+
"telegram-ask": "dist/cli.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"start": "node src/cli.ts start",
|
|
22
|
+
"cli": "node src/cli.ts",
|
|
23
|
+
"test": "vp test",
|
|
24
|
+
"check": "vp check",
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"prepack": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@clack/prompts": "^1.8.1",
|
|
30
|
+
"commander": "^15.0.0",
|
|
31
|
+
"fastify": "^5.12.7",
|
|
32
|
+
"grammy": "^1.46.0",
|
|
33
|
+
"zod": "^4.6.7"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "24.19.2",
|
|
37
|
+
"typescript": "7.0.2",
|
|
38
|
+
"vite-plus": "1.3.0"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=24.2"
|
|
42
|
+
}
|
|
43
|
+
}
|