manual improvement 2 & add telegram bot

This commit is contained in:
hamid
2026-07-27 23:58:16 +03:30
parent baa3cc63cd
commit e6a8f93a1e
57 changed files with 4181 additions and 2484 deletions
+29
View File
@@ -0,0 +1,29 @@
# Copy to .env and fill in. Never commit .env.
# From @BotFather — the full token, e.g. 1234567890:AAH....
TELEGRAM_BOT_TOKEN=
# Comma-separated Telegram chat ids that receive every OTP.
# Each of these users MUST have sent the bot at least one message first
# (Telegram forbids a bot from opening a conversation).
# Discover them with: GET http://localhost:5010/chat_ids (with the X-Api-Key header)
TELEGRAM_CHAT_IDS=
# REQUIRED. Shared secret the caller must send as the `X-Api-Key` header.
# Minimum 16 chars; the process refuses to start without it.
# Generate one: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
# The same value goes into the .NET side's Seams:Sms:Telegram:ApiKey (user-secrets, never committed).
API_KEY=
# HTTP listener
PORT=5010
HOST=127.0.0.1
# Set to `true` to keep the OTP code out of this process's stdout logs.
# The code is still delivered over Telegram either way.
REDACT_CODE_IN_LOGS=false
# api.telegram.org is filtered in Iran. On Node 24+, these two make the built-in fetch
# use your local proxy; point HTTPS_PROXY at whatever your VPN/proxy client listens on.
# NODE_USE_ENV_PROXY=1
# HTTPS_PROXY=http://127.0.0.1:10809
+4
View File
@@ -0,0 +1,4 @@
.env
.env.local
node_modules/
*.log
+153
View File
@@ -0,0 +1,153 @@
# Prompt — wire the Telegram OTP relay into the .NET API
> Paste everything below into a fresh Claude Code session opened at the repo root.
---
Implement a **Development-only Telegram OTP delivery channel** on the server, behind the existing
`ISmsSender` seam. It must be **opt-in from `appsettings`** — an environment that does not configure it
behaves byte-for-byte as it does today.
## Context you should read first
- `server/CLAUDE.md` → the "External rails go real — config-selected vendor adapters" paragraph and the
"Startup wiring" section.
- `server/src/Core/Baya.Application/Contracts/Common/ISmsSender.cs` — the contract (`SendOtpAsync`, `SendAsync`).
- `server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/KavenegarSmsSender.cs` — the shape
every real SMS adapter follows. Mirror it.
- `server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs``SmsOptions` + `SeamProviders`.
- `server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs`
→ the `RegisterSms` method — the config-selected registration pattern.
- `telegram-otp-bot/README.md` — the relay's HTTP contract (it already exists and runs; do not modify it).
## What the relay already is
A standalone zero-dependency Node service at `telegram-otp-bot/` (its own project — **not** part of
`client/` or `server/`). It forwards messages to a fixed list of Telegram chat ids. Contract:
| Route | Auth | Request | Success | Failure |
| --- | --- | --- | --- | --- |
| `POST /send_otp` | `X-Api-Key` header | `{"phone":"...","code":"..."}` | `200` `{"ok":true,"delivered":[...],"failed":[...]}` | `502` when **no** recipient got it; `503` no recipients configured; `401` bad key; `400` bad body |
| `POST /send` | `X-Api-Key` header | `{"phone":"...","message":"..."}` | same | same |
| `GET /health` | none | — | `200` `{"ok":true}` | — |
It is a **broadcast**, not per-user routing: every configured Telegram recipient receives every code,
regardless of which phone requested it. That is exactly why this is Development-only.
## Requirements
### 1. Config surface — opt-in, default off
Add to `SmsOptions` (`SeamOptions.cs`) a nested `TelegramOptions Telegram { get; set; } = new();` with:
- `BaseUrl` — the relay root, e.g. `http://127.0.0.1:5010`.
- `ApiKey`**the shared secret sent as the `X-Api-Key` header. It is a secret**: the committed
`appsettings*.json` carries an empty string or the repo's `SET_VIA_USER_SECRETS_OR_ENV` placeholder,
never a real value. Real value via user-secrets / environment only.
- `TimeoutSeconds` — default `10`.
Add `public const string Telegram = "telegram";` to `SeamProviders`. Document on the options class, in the
`SmsOptions` XML doc, and in the `SeamProviders` SMS-gateway group that `telegram` is a **Development
convenience channel, not an SMS gateway**.
The feature is selected exactly like every other rail:
```jsonc
// server/src/API/Baya.Web.Api/appsettings.Development.json
"Seams": {
"Sms": {
"Provider": "telegram",
"Telegram": {
"BaseUrl": "http://127.0.0.1:5010",
"ApiKey": "", // real value via user-secrets: Seams:Sms:Telegram:ApiKey
"TimeoutSeconds": 10
}
}
}
```
**Default must stay `mock`.** Do not change the committed `Provider` value in any shared
`appsettings.json` — document the opt-in instead (see §5). An unconfigured environment must resolve
`LoggingSmsSender` exactly as it does now.
### 2. The adapter
New `TelegramSmsSender : ISmsSender` in
`server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/`, following `KavenegarSmsSender`'s
shape — primary-constructor injection of `HttpClient` + `IOptions<SeamOptions>` + `ILogger<T>`,
`System.Text.Json`, **no new NuGet package**.
- `SendOtpAsync(phone, code, ct)``POST {BaseUrl}/send_otp` with `{"phone":…,"code":…}`.
- `SendAsync(phone, message, ct)``POST {BaseUrl}/send` with `{"phone":…,"message":…}`.
- Send `X-Api-Key: {ApiKey}` on every request.
- Snake-case JSON body keys (`phone`, `code`, `message`) — the relay reads exactly those names.
- **Non-2xx, or a body with `ok != true`, is a delivery failure** — log a warning and throw
`InvalidOperationException`, exactly as `KavenegarSmsSender` does, so `RequestOtpCommand` reports a real
send failure instead of silently "succeeding". A `502` from the relay means nobody received the code and
must not be swallowed.
- **Never log the OTP code.** Log the phone tail only — reuse Kavenegar's `Tail(phone)` helper approach.
- Guard against an unconfigured `ApiKey`: throw a clear startup/first-call error naming
`Seams:Sms:Telegram:ApiKey` rather than sending an unauthenticated request that the relay will 401.
### 3. Registration
In `ServiceCollectionExtension.RegisterSms`, add a branch **before** the `smsir`/`ghasedak`
`NotSupportedException`:
```csharp
else if (Is(provider, SeamProviders.Telegram))
{
services.AddHttpClient(HttpClients.Telegram, c => { /* BaseAddress + Timeout from options */ });
services.AddSingleton<ISmsSender>(sp => new TelegramSmsSender(...));
}
```
Add the `HttpClients.Telegram` named-client constant alongside the existing ones. Follow the file's own
`Is(...)` / `BaseOrDefault(...)` / `Client(sp, name)` helpers — do not invent a parallel style.
### 4. Keep the `/dev/last_otp` bridge working
`Program.cs` currently disables the Development OTP-capture decorator whenever a non-mock SMS provider is
selected (`usingMockSms`), because a real gateway must never have the code logged/captured. **Telegram is
the exception**: it is a Development-only channel, and the `GET /api/v1/dev/last_otp/{phone}` helper and its
e2e tests should keep working alongside it.
Widen that condition to allow the capture bridge for `mock` **or** `telegram` (keep it strictly disallowed
for `kavenegar` and any future real gateway), and update the explanatory comment above it to say why —
the current comment asserts the bridge is off for *every* non-mock provider, and that will become wrong.
Rename the local to something accurate (e.g. `otpCaptureAllowedProvider`). The `IsDevelopment()` guard stays.
### 5. Docs — same change, non-negotiable
- `server/CLAUDE.md` → the "External rails go real" paragraph: add `TelegramSmsSender`
(`Sms:Provider=telegram`) to the adapter list, flagged **Development-only, broadcast, not a gateway**,
and note that it is the one non-mock SMS provider that keeps the OTP-capture bridge enabled.
- `server/CLAUDE.md` → "Startup wiring": update the `AddDevelopmentOtpCapture()` comment to match the new
condition.
- `dev/post-phase/refinement/RUNBOOK.md` → a short "OTP over Telegram" subsection: start the relay
(`cd telegram-otp-bot && npm start`), set the same secret on both sides
(`dotnet user-secrets set "Seams:Sms:Telegram:ApiKey" "<value>"`), flip `Seams:Sms:Provider` to
`telegram` in `appsettings.Development.json`, log in, read the code in Telegram. Link to
`telegram-otp-bot/README.md` for bot creation and chat-id discovery.
### 6. Tests
Add unit tests for `TelegramSmsSender` next to the existing seam tests (`Baya.Test.Foundation`), using a
stubbed `HttpMessageHandler` — no network:
- `SendOtpAsync` posts to `/send_otp` with the right body **and** the `X-Api-Key` header.
- A `502` / `{"ok":false}` response throws.
- The OTP code never appears in the log output.
- Registration: `Provider = "mock"` (and an unset provider) still resolves `LoggingSmsSender`; `Provider =
"telegram"` resolves `TelegramSmsSender`. **This is the regression that matters** — the feature must be
invisible unless opted in.
## Constraints
- Server-side only. Do not touch `client/`.
- No handler changes — `RequestOtpCommand` depends on `ISmsSender` and must stay untouched.
- No new NuGet packages; versions are centrally pinned in `Directory.Packages.props` regardless.
- Follow `server/CONVENTIONS.md`: `sealed` classes, no unused usings/locals, *why*-comments only.
- Never commit the API key or the bot token.
- Finish with `dotnet build Baya.sln` (zero new warnings) and `dotnet test Baya.sln` (all green), and report
the actual output.
+125
View File
@@ -0,0 +1,125 @@
# balinyaar-telegram-otp-bot
A **standalone, dev-only** Telegram relay. It is not part of `client/` or `server/` — it is its own
tiny Node project with **zero dependencies** (Node 18+ built-ins only: `node:http` + global `fetch`).
Its whole job: expose an HTTP endpoint that the .NET API calls, and forward the message to a fixed
list of Telegram chat ids. That replaces "read the OTP out of the server log" during manual testing —
you get the code on your phone instead, without paying an Iranian SMS gateway.
> **Development only.** There is no per-user routing: *every* configured recipient receives *every*
> OTP, regardless of which phone number requested it. That is fine for a test group; it is not an SMS
> gateway. Do not point a real environment at this.
---
## Setup
1. **Create the bot.** Message [@BotFather](https://t.me/BotFather) → `/newbot` → follow the prompts →
copy the token (`1234567890:AAH...`).
2. **Configure.**
```bash
cd telegram-otp-bot
cp .env.example .env # PowerShell: Copy-Item .env.example .env
node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
```
Paste the token into `TELEGRAM_BOT_TOKEN` and the generated secret into `API_KEY`
(**required** — the process refuses to start without it). Leave `TELEGRAM_CHAT_IDS` empty for now.
3. **Run it.**
```bash
npm start
```
No `npm install` needed — there are no dependencies. On boot it prints the bot's `@username`, which
confirms the token works.
4. **Discover the chat ids.** Each recipient opens the bot in Telegram and presses **Start** (a bot
cannot open a conversation — the user must message it first). Then:
```bash
curl -H "X-Api-Key: $API_KEY" http://localhost:5010/chat_ids
```
Copy the returned `chat_id` values into `TELEGRAM_CHAT_IDS` (comma-separated) and restart.
5. **Test it.**
```bash
curl -X POST http://localhost:5010/send_otp \
-H "X-Api-Key: $API_KEY" \
-H 'content-type: application/json' \
-d '{"phone":"09120000001","code":"123456"}'
```
---
## HTTP API
Base URL: `http://127.0.0.1:5010` (configurable via `HOST`/`PORT`).
**Every route except `GET /health` requires the `X-Api-Key` header**, compared against `API_KEY` in
constant time. There is no way to disable it: `API_KEY` is mandatory (min 16 chars) and the process
exits at boot without one, so the relay is never reachable unauthenticated. A rejected request is
logged with the caller's address.
| Route | Auth | Body | Purpose |
| --- | --- | --- | --- |
| `GET /health` | — | — | Liveness only; echoes no configuration. |
| `GET /chat_ids` | `X-Api-Key` | — | Chat ids that recently messaged the bot (setup helper). |
| `POST /send_otp` | `X-Api-Key` | `{ "phone": "...", "code": "..." }` | Broadcast a login code. |
| `POST /send` | `X-Api-Key` | `{ "phone": "...", "message": "..." }` | Broadcast a free-form transactional message. |
The two POST routes mirror the server's `ISmsSender` (`SendOtpAsync` / `SendAsync`) one-for-one, so a
`TelegramSmsSender` adapter is a thin HTTP call per method.
**Response**
```json
{ "ok": true, "delivered": ["11111111"], "failed": [{ "chatId": "22222222", "error": "chat not found" }] }
```
- `200` — at least one recipient received it (partial delivery still counts; one reachable reader is
enough to complete a login).
- `502` — **no** recipient received it. The caller should treat this as a delivery failure so the OTP
command fails loudly rather than pretending an undeliverable code was sent.
- `503` — `TELEGRAM_CHAT_IDS` is empty.
- `400` / `401` — bad body / missing-or-wrong `X-Api-Key`.
---
## Configuration
| Variable | Default | Meaning |
| --- | --- | --- |
| `TELEGRAM_BOT_TOKEN` | — | **Required.** From @BotFather. Process exits without it. |
| `API_KEY` | — | **Required**, min 16 chars. Shared secret expected in `X-Api-Key`. Process exits without it. |
| `TELEGRAM_CHAT_IDS` | — | Comma-separated recipient chat ids. Empty ⇒ every send returns `503`. |
| `PORT` | `5010` | HTTP port. |
| `HOST` | `127.0.0.1` | Bind address. Keep it loopback unless the API runs on another machine. |
| `REDACT_CODE_IN_LOGS` | `false` | Keep the code out of *this process's* stdout (still delivered). |
The API key is the only access control — there is no IP allow-list and no TLS. Keep `HOST` on
loopback when the API runs on the same machine; if you must expose it, put it behind something that
terminates TLS, or the key travels in clear text.
Values come from `.env` (git-ignored) or from real environment variables, which take precedence.
## Troubleshooting
| Symptom | Cause |
| --- | --- |
| `chat not found` in `failed[]` | That user never messaged the bot. Press **Start** in Telegram. |
| `GET /chat_ids` returns nothing | No message in the last 24h, or a webhook is set on the bot (`getUpdates` returns nothing while a webhook is registered — call `deleteWebhook`). |
| `token check FAILED` / `fetch failed` | Wrong/revoked token, or no outbound access to `api.telegram.org` — see below. |
| `401` on every call | The caller isn't sending `X-Api-Key`, or its value differs from `API_KEY`. |
### Reaching Telegram from Iran
`api.telegram.org` is filtered, so the machine running this needs a proxy. On **Node 24+** the built-in
`fetch` honours the standard proxy variables once opted in — uncomment these in `.env`:
```
NODE_USE_ENV_PROXY=1
HTTPS_PROXY=http://127.0.0.1:10809
```
pointing `HTTPS_PROXY` at whatever your VPN/proxy client listens on. On older Node, run the process
under a system-wide/TUN-mode proxy instead — `fetch` there ignores the env vars.
+14
View File
@@ -0,0 +1,14 @@
{
"name": "balinyaar-telegram-otp-bot",
"version": "1.0.0",
"private": true,
"description": "Standalone dev-only Telegram relay: exposes an HTTP endpoint the Balinyaar API calls to deliver OTPs to a fixed list of Telegram chat ids.",
"type": "module",
"main": "src/server.js",
"engines": {
"node": ">=18"
},
"scripts": {
"start": "node src/server.js"
}
}
+70
View File
@@ -0,0 +1,70 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/**
* Loads `.env` into process.env without a dependency. Node's own `--env-file` needs 20.6+;
* parsing it here keeps the runbook a plain `npm start` on any Node 18+.
* Real environment variables always win, so a container/CI can override the file.
*/
function loadDotEnv() {
let raw;
try {
raw = readFileSync(resolve(projectRoot, '.env'), 'utf8');
} catch {
return;
}
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
value = value.slice(1, -1);
}
if (!(key in process.env)) process.env[key] = value;
}
}
loadDotEnv();
const botToken = (process.env.TELEGRAM_BOT_TOKEN ?? '').trim();
if (!botToken) {
console.error('FATAL: TELEGRAM_BOT_TOKEN is not set. Copy .env.example to .env and paste the token from @BotFather.');
process.exit(1);
}
const chatIds = (process.env.TELEGRAM_CHAT_IDS ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean);
// The API key is mandatory, with no opt-out: an OTP relay that anyone can POST to is an open
// message cannon pointed at the test group's phones. Fail to start rather than listen unprotected.
const apiKey = (process.env.API_KEY ?? '').trim();
if (!apiKey) {
console.error('FATAL: API_KEY is not set. Choose any shared secret, put it in .env, and configure the same value on the caller.');
process.exit(1);
}
if (apiKey.length < 16) {
console.error(`FATAL: API_KEY is too short (${apiKey.length} chars). Use at least 16 — e.g. output of: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"`);
process.exit(1);
}
export const config = {
botToken,
chatIds,
apiKey,
host: (process.env.HOST ?? '127.0.0.1').trim(),
port: Number(process.env.PORT ?? 5010),
redactCodeInLogs: (process.env.REDACT_CODE_IN_LOGS ?? 'false').toLowerCase() === 'true',
};
+147
View File
@@ -0,0 +1,147 @@
import { createServer } from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import { config } from './env.js';
import { broadcast, discoverChatIds, getBotIdentity } from './telegram.js';
const MAX_BODY_BYTES = 16 * 1024;
function json(res, status, payload) {
const body = JSON.stringify(payload);
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) });
res.end(body);
}
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
reject(new Error('request body too large'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('error', reject);
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8').trim();
if (!raw) return resolve({});
try {
resolve(JSON.parse(raw));
} catch {
reject(new Error('request body is not valid JSON'));
}
});
});
}
/** Constant-time `X-Api-Key` comparison. The key is always required — `config` guarantees one exists. */
function isAuthorized(req) {
const supplied = req.headers['x-api-key'];
if (typeof supplied !== 'string') return false;
const a = Buffer.from(supplied);
const b = Buffer.from(config.apiKey);
return a.length === b.length && timingSafeEqual(a, b);
}
function otpMessage(phone, code) {
return ['🔐 Balinyaar — login code', '', `phone: ${phone}`, `code: ${code}`, '', new Date().toISOString()].join('\n');
}
function plainMessage(phone, message) {
return ['📩 Balinyaar', '', `phone: ${phone}`, '', message].join('\n');
}
async function deliver(res, text, logLine) {
if (config.chatIds.length === 0) {
console.error('refused: TELEGRAM_CHAT_IDS is empty — nowhere to deliver');
return json(res, 503, { ok: false, error: 'no recipients configured (TELEGRAM_CHAT_IDS is empty)' });
}
const { delivered, failed } = await broadcast(text);
console.log(`${logLine} → delivered ${delivered.length}/${config.chatIds.length}`);
for (const f of failed) console.error(`${f.chatId}: ${f.error}`);
// Partial delivery still counts as sent — one reachable recipient is enough to read the code.
// Only a total failure is reported upstream, so the API's OTP command fails loudly instead of
// pretending an undeliverable code was sent.
const status = delivered.length > 0 ? 200 : 502;
return json(res, status, { ok: delivered.length > 0, delivered, failed });
}
const routes = {
// The only unauthenticated route, and it deliberately echoes no configuration — it exists so a
// caller can answer "is the relay up?" without holding the key.
'GET /health': async (_req, res) => json(res, 200, { ok: true }),
'GET /chat_ids': async (_req, res) => {
const chats = await discoverChatIds();
return json(res, 200, {
ok: true,
chats,
hint: chats.length
? 'Copy the chat_id values into TELEGRAM_CHAT_IDS (comma-separated) and restart.'
: 'No recent messages. Open the bot in Telegram, press Start / send it any message, then call this again.',
});
},
'POST /send_otp': async (req, res) => {
const { phone, code } = await readJsonBody(req);
if (!phone || !code) return json(res, 400, { ok: false, error: '`phone` and `code` are required' });
const shown = config.redactCodeInLogs ? '******' : code;
return deliver(res, otpMessage(phone, code), `otp ${shown} for ${phone}`);
},
'POST /send': async (req, res) => {
const { phone, message } = await readJsonBody(req);
if (!phone || !message) return json(res, 400, { ok: false, error: '`phone` and `message` are required' });
return deliver(res, plainMessage(phone, message), `message for ${phone}`);
},
};
const server = createServer(async (req, res) => {
const path = new URL(req.url, 'http://localhost').pathname.replace(/\/+$/, '') || '/';
const key = `${req.method} ${path}`;
const handler = routes[key];
if (!handler) {
return json(res, 404, { ok: false, error: `no route for ${key}`, routes: Object.keys(routes) });
}
if (key !== 'GET /health' && !isAuthorized(req)) {
console.error(`401 ${key} from ${req.socket.remoteAddress} — missing or invalid X-Api-Key`);
return json(res, 401, { ok: false, error: 'missing or invalid X-Api-Key' });
}
try {
await handler(req, res);
} catch (error) {
console.error(`${key} failed:`, error.message);
if (!res.headersSent) json(res, 500, { ok: false, error: error.message });
}
});
server.listen(config.port, config.host, async () => {
console.log(`balinyaar telegram-otp-bot listening on http://${config.host}:${config.port}`);
console.log(` recipients: ${config.chatIds.length ? config.chatIds.join(', ') : '(none — call GET /chat_ids to discover)'}`);
console.log(' auth: X-Api-Key required on every route except GET /health');
try {
const me = await getBotIdentity();
console.log(` bot: @${me.username} (${me.id})`);
} catch (error) {
console.error(` bot: token check FAILED — ${error.message}`);
}
});
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => server.close(() => process.exit(0)));
}
+76
View File
@@ -0,0 +1,76 @@
import { config } from './env.js';
const API_BASE = `https://api.telegram.org/bot${config.botToken}`;
async function callApi(method, payload, { timeoutMs = 10_000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${API_BASE}/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
const body = await response.json().catch(() => ({}));
if (!response.ok || body.ok !== true) {
// Telegram carries the real failure in `description` (e.g. "chat not found" when the
// recipient never messaged the bot first) — surface it verbatim so the cause is obvious.
const reason = body.description ?? `HTTP ${response.status}`;
throw new Error(reason);
}
return body.result;
} finally {
clearTimeout(timer);
}
}
/** Sends one message to every configured chat id. Never rejects — per-recipient outcomes are returned. */
export async function broadcast(text) {
const results = await Promise.all(
config.chatIds.map(async (chatId) => {
try {
await callApi('sendMessage', { chat_id: chatId, text, disable_notification: false });
return { chatId, ok: true };
} catch (error) {
return { chatId, ok: false, error: error.message };
}
}),
);
return {
delivered: results.filter((r) => r.ok).map((r) => r.chatId),
failed: results.filter((r) => !r.ok).map(({ chatId, error }) => ({ chatId, error })),
};
}
/**
* Lists the chat ids that have messaged the bot recently, so a new recipient can be discovered
* without hunting through the Telegram API by hand. getUpdates only sees messages newer than the
* last 24h and returns nothing while a webhook is set.
*/
export async function discoverChatIds() {
const updates = await callApi('getUpdates', { limit: 100, timeout: 0 });
const seen = new Map();
for (const update of updates) {
const chat = update.message?.chat ?? update.edited_message?.chat ?? update.channel_post?.chat;
if (!chat) continue;
seen.set(String(chat.id), {
chat_id: String(chat.id),
type: chat.type,
username: chat.username ?? null,
title: chat.title ?? ([chat.first_name, chat.last_name].filter(Boolean).join(' ') || null),
});
}
return [...seen.values()];
}
export async function getBotIdentity() {
return callApi('getMe', {});
}