Initial import

This commit is contained in:
sirily
2026-03-10 14:03:52 +03:00
commit 6c0ca4e28b
102 changed files with 6598 additions and 0 deletions

15
apps/bot/AGENTS.md Normal file
View File

@@ -0,0 +1,15 @@
# AGENTS.md
## Scope
Applies within `apps/bot`.
## Responsibilities
- Telegram admin bot runtime
- allowlist checks
- alert delivery
- low-friction admin commands
## Rules
- Pairing approval must never happen inside the bot runtime itself.
- The bot may initiate pending pairing, but only the server-side CLI completes it.
- Every command that changes state must produce an audit log entry.

3
apps/bot/README.md Normal file
View File

@@ -0,0 +1,3 @@
# apps/bot
Planned Telegram admin bot runtime. MVP should use long polling unless deployment requirements change.

15
apps/bot/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "@nproxy/bot",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js"
},
"dependencies": {
"@nproxy/config": "workspace:*",
"@nproxy/db": "workspace:*",
"@nproxy/providers": "workspace:*"
}
}

0
apps/bot/src/.gitkeep Normal file
View File

100
apps/bot/src/main.ts Normal file
View File

@@ -0,0 +1,100 @@
import { loadConfig } from "@nproxy/config";
import { createPrismaTelegramBotStore, prisma } from "@nproxy/db";
import { createTelegramBotApiTransport, type TelegramUpdate } from "@nproxy/providers";
const config = loadConfig();
const telegramStore = createPrismaTelegramBotStore(prisma);
const telegramTransport = createTelegramBotApiTransport(config.telegram.botToken);
const pairingExpiresInMinutes = 15;
let nextUpdateOffset: number | undefined;
let isPolling = false;
console.log(
JSON.stringify({
service: "bot",
mode: config.telegram.botMode,
}),
);
void pollLoop();
process.once("SIGTERM", async () => {
await prisma.$disconnect();
process.exit(0);
});
process.once("SIGINT", async () => {
await prisma.$disconnect();
process.exit(0);
});
async function pollLoop(): Promise<void> {
if (isPolling) {
return;
}
isPolling = true;
try {
while (true) {
const updates = await telegramTransport.getUpdates({
...(nextUpdateOffset !== undefined ? { offset: nextUpdateOffset } : {}),
timeoutSeconds: 25,
});
for (const update of updates) {
await handleUpdate(update);
nextUpdateOffset = update.update_id + 1;
}
if (updates.length === 0) {
console.log("bot polling heartbeat");
}
}
} catch (error) {
console.error("bot polling failed", error);
setTimeout(() => {
isPolling = false;
void pollLoop();
}, 5000);
}
}
async function handleUpdate(update: TelegramUpdate): Promise<void> {
const message = update.message;
const from = message?.from;
if (!message || !from) {
return;
}
const telegramUserId = String(from.id);
const displayNameSnapshot = [from.first_name, from.last_name].filter(Boolean).join(" ");
const isAllowed = await telegramStore.isTelegramAdminAllowed(telegramUserId);
if (isAllowed) {
await telegramTransport.sendMessage({
chatId: message.chat.id,
text: "Admin access is active.",
});
return;
}
const challenge = await telegramStore.getOrCreatePendingPairingChallenge(
{
telegramUserId,
...(from.username ? { telegramUsername: from.username } : {}),
displayNameSnapshot: displayNameSnapshot || "Telegram user",
},
pairingExpiresInMinutes,
);
await telegramTransport.sendMessage({
chatId: message.chat.id,
text: [
`Your pairing code is: ${challenge.code}`,
`Run nproxy pair ${challenge.code} on the server.`,
`Expires at ${challenge.expiresAt.toISOString()}.`,
].join("\n"),
});
}

12
apps/bot/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}