Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 203f44a..1dee65d 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -10,6 +10,7 @@ include(FetchContent)
FetchContent_Declare(
libmw
GIT_REPOSITORY https://github.com/MetroWind/libmw.git
+ GIT_TAG 00f857d93fda0f0eb84bb8ab540b58063f1c91b2
)
FetchContent_Declare(
@@ -36,9 +37,22 @@ set(LIBMW_BUILD_HTTP_SERVER ON)
set(LIBMW_BUILD_SQLITE ON)
FetchContent_MakeAvailable(libmw spdlog json cxxopts)
-add_executable(telegrammer src/main.cpp)
+find_package(OpenSSL REQUIRED)
+find_package(Threads REQUIRED)
+
+add_library(telegrammer_core STATIC
+ src/api_server.cpp
+ src/application.cpp
+ src/database.cpp
+ src/delivery_store.cpp
+ src/dispatcher.cpp
+ src/key_store.cpp
+ src/poller.cpp
+ src/subscription_store.cpp
+ src/telegram_client.cpp
+)
-target_link_libraries(telegrammer PRIVATE
+target_link_libraries(telegrammer_core PUBLIC
mw::mw
mw::http-server
mw::url
@@ -46,10 +60,23 @@ target_link_libraries(telegrammer PRIVATE
nlohmann_json::nlohmann_json
spdlog::spdlog
cxxopts
+ OpenSSL::Crypto
+ Threads::Threads
)
-target_include_directories(telegrammer PRIVATE
+target_include_directories(telegrammer_core PUBLIC
${libmw_SOURCE_DIR}/includes
+ ${CMAKE_CURRENT_SOURCE_DIR}/src
)
+target_compile_options(telegrammer_core PRIVATE -Wall -Wextra -Wpedantic)
+
+add_executable(telegrammer src/main.cpp)
+target_link_libraries(telegrammer PRIVATE telegrammer_core)
target_compile_options(telegrammer PRIVATE -Wall -Wextra -Wpedantic)
+
+enable_testing()
+add_executable(telegrammer_core_test tests/core_test.cpp)
+target_link_libraries(telegrammer_core_test PRIVATE telegrammer_core)
+target_compile_options(telegrammer_core_test PRIVATE -Wall -Wextra -Wpedantic)
+add_test(NAME telegrammer_core_test COMMAND telegrammer_core_test)
diff --git a/README.md b/README.md
index 316f420..d785de9 100644
--- a/README.md
+++ b/README.md
@@ -22,13 +22,14 @@ Instead of embedding Telegram logic into every service, you run **Telegrammer**
## Build
-This project uses CMake and fetches dependencies (like `libmw`, `nlohmann/json`, `spdlog`) automatically.
+This project uses CMake and fetches pinned dependencies (like `libmw`,
+`nlohmann/json`, and `spdlog`) automatically.
```bash
mkdir build
cd build
cmake ..
-make -j$(nproc)
+cmake --build . -j24
```
## Usage
@@ -36,7 +37,7 @@ make -j$(nproc)
Run the executable from the build directory. You must provide your Telegram Bot Token.
```bash
-./telegrammer --token "YOUR_TELEGRAM_BOT_TOKEN"
+./telegrammer --token "YOUR_TELEGRAM_BOT_TOKEN" --db telegrammer.db
```
### Command Line Options
@@ -45,11 +46,14 @@ Run the executable from the build directory. You must provide your Telegram Bot
| :--- | :--- | :--- |
| `-t, --token` | **Required.** Your Telegram Bot Token. | |
| `-p, --port` | Port to listen on. | `8080` |
-| `-h, --host` | Interface to bind to. | `0.0.0.0` |
+| `-h, --host` | Interface to bind to. | `127.0.0.1` |
| `--db` | Path to the SQLite database file. | `telegrammer.db` |
-| `--add-key <name>` | Generate and add a new API key for `<name>`. | |
+| `--add-key <name>` | Generate and add a new API key for `<name>`; prints it once. | |
| `--delete-key <name>` | Delete the API key for `<name>`. | |
| `--list-keys` | List all registered API keys. | |
+| `--list-failed-deliveries` | List dead-letter callback jobs. | |
+| `--retry-delivery <id>` | Reset one dead-letter callback job. | |
+| `--delete-delivery <id>` | Delete one dead-letter callback job. | |
| `--help` | Show help message. | |
## API Key Management
@@ -58,9 +62,15 @@ Before using the API, you must generate an API key:
```bash
./telegrammer --add-key my_service
-# Output: Added key for 'my_service': YOUR_GENERATED_KEY
+# Output: YOUR_GENERATED_KEY
```
+The generated 64-character key is printed only by `--add-key`. Store it
+securely; `--list-keys` prints names and creation times, not credentials. The
+daemon stores SHA-256 digests rather than bearer tokens. The current development
+schema is intentionally not migrated: remove an old plaintext development
+database before starting this build.
+
## API Reference
**Authentication:**
@@ -81,7 +91,8 @@ Send a text message to a chat. You can target a user by `chat_id` OR `username`.
```
**Using Username:**
-*Note: The user must have previously messaged the bot for the username resolution to work.*
+*Note: the user must have previously messaged the bot in a private chat for
+username resolution to work. IDs are the stable destination form.*
```json
{
@@ -94,7 +105,10 @@ Send a text message to a chat. You can target a user by `chat_id` OR `username`.
Register a callback URL for a specific chat. When the bot receives a message in that chat, it will POST the full Telegram Message JSON to your URL.
-> **Note:** Subscriptions are currently stored in memory and do not persist across application restarts.
+Subscriptions are durable, owned by the API key that created them, and
+idempotent for the same key, chat, and callback URL. Callback delivery is
+queued in SQLite, retried with bounded backoff, and dead-lettered after the
+retry budget is exhausted.
**Payload:**
@@ -108,6 +122,34 @@ Register a callback URL for a specific chat. When the bot receives a message in
**Callback Payload:**
Your server will receive a POST request containing the standard [Telegram Message Object](https://core.telegram.org/bots/api#message).
+### 3. Subscription management
+
+`GET /subscriptions` lists only the authenticated key's subscriptions.
+`DELETE /subscriptions/{id}` removes an owned subscription and its queued
+deliveries, returning `204 No Content`. Unknown or foreign IDs return `404`.
+
+`GET /health` returns polling state and queue size. It requires authentication
+and returns `503` when polling has a persistent failure or the queue is
+saturated.
+
+All API errors are JSON objects with stable uppercase error codes. Requests use
+`Content-Type: application/json`, reject unknown fields, and are limited to
+64 KiB. The service accepts loopback callbacks as well as remote HTTP/HTTPS
+URLs; it does not follow callback redirects.
+
+The daemon is the only process that acquires `<database>.lock` while running.
+Management commands use short SQLite connections and can be run against the
+same database while the daemon is active.
+
+For the packaged systemd unit, put `TELEGRAM_BOT_TOKEN=...` in
+`/etc/telegrammer/telegrammer.env` with mode `0600`. Provision a key against
+the service database as the service user, for example:
+
+```bash
+sudo -u telegrammer /usr/bin/telegrammer \
+ --db /var/lib/telegrammer/telegrammer.db --add-key my-service
+```
+
## Dependencies
* [libmw](https://github.com/MetroWind/libmw) - HTTP Server/Client & Utilities.
diff --git a/design.md b/design.md
index 2004ac6..1208cfe 100644
--- a/design.md
+++ b/design.md
@@ -1,118 +1,21 @@
-# Telegrammer Design Document
-
-## 1. Overview
-Telegrammer is a C++ based service acting as a bridge between the Telegram Bot API and other local applications. It provides a simplified HTTP API for sending messages and a webhook-style subscription mechanism for receiving messages. It utilizes `libmw` for network operations.
-
-## 2. Architecture
-
-The system consists of three main components:
-
-1. **API Server (`mw::HTTPServer`)**: Handles incoming HTTP requests from local applications to send messages or subscribe to events.
-2. **Telegram Client (`mw::HTTPSession`)**: Manages communication with the Telegram Bot API.
-3. **Poller / Dispatcher**: Periodically fetches updates from Telegram (Long Polling) and dispatches them to registered subscriber callbacks via HTTP POST.
-
-### Data Flow
-
-**Sending Messages:**
-`Local App` --(HTTP POST)--> `Telegrammer (Server)` --(HTTP POST)--> `Telegram API`
-
-**Receiving Messages:**
-`Telegram API` --(Long Poll Response)--> `Telegrammer (Poller)` --(Lookup)--> `Subscription Manager` --(HTTP POST)--> `Local App (Callback)`
-
-## 3. Configuration
-
-Configuration is handled via Command Line Arguments:
-* `--token`: The API token obtained from @BotFather (required).
-* `--port`: Port to listen on (default: `8080`).
-* `--host`: Interface to bind to (default: `0.0.0.0`).
-* `--help`: Show help message.
-
-## 4. API Specification
-
-### 4.1. Send Message
-**Endpoint:** `POST /send`
-**Content-Type:** `application/json`
-
-**Request Body:**
-```json
-{
- "chat_id": 123456789,
- "username": "some_user",
- "text": "Hello World"
-}
-```
-* `text` (Required): The message content.
-* `chat_id` (Optional): The target chat ID.
-* `username` (Optional): If `chat_id` is not provided, the bot will look up the most recent `chat_id` associated with this username.
-
-**Response:**
-* `200 OK`: Message sent successfully.
-* `400 Bad Request`: Missing `text`, or neither `chat_id` nor `username` provided, or username not found.
-* `500 Internal Error`: Upstream Telegram error.
-
-### 4.2. Subscribe
-**Endpoint:** `POST /subscribe`
-**Content-Type:** `application/json`
-
-Registers a callback URL for a specific chat. When a message is received in that chat, Telegrammer will POST the message payload to the `callback_url`.
-
-**Request Body:**
-```json
-{
- "chat_id": 123456789,
- "callback_url": "http://localhost:9090/webhook"
-}
-```
-
-**Response:**
-* `200 OK`: Subscribed successfully.
-
-**Callback Payload:**
-The callback URL will receive a POST request with `application/json` content. The body will be the JSON object of the [message](https://core.telegram.org/bots/api#message) from the Telegram [Update object](https://core.telegram.org/bots/api#update).
-
-Example:
-```json
-{
- "message_id": 123,
- "from": {
- "id": 456,
- "is_bot": false,
- "first_name": "John"
- },
- "chat": {
- "id": 123456789,
- "type": "private"
- },
- "date": 1672531200,
- "text": "Hello bot"
-}
-```
-
-## 5. Internal Components
-
-### 5.1. TelegramClient Class
-Wraps `mw::HTTPSession` to abstract Telegram API calls.
-
-* `sendMessage(int64_t chat_id, string text)`
-* `getUpdates(int64_t offset, int timeout)`: Uses long polling to wait for new messages.
-
-### 5.2. SubscriptionManager Class
-Thread-safe container for managing subscriptions.
-
-* **Structure:** `std::map<int64_t, std::vector<std::string>> subscribers;` (Chat ID -> List of URLs)
-* `addSubscription(chat_id, url)`
-* `getSubscribers(chat_id)`
-
-### 5.3. Polling Loop
-Runs in a separate thread.
-1. Calls `TelegramClient::getUpdates` with a timeout (e.g., 30s).
-2. On return, updates the `offset` to `last_update_id + 1`.
-3. Iterates through messages.
-4. Queries `SubscriptionManager` for the `chat_id`.
-5. If subscribers exist, uses a separate `mw::HTTPSession` to POST the message JSON to the registered URL.
-
-## 6. Technologies
-* **C++23**
-* **libmw**: For `HTTPServer` and `HTTPSession` (Client).
-* **nlohmann/json**: For JSON serialization/deserialization.
-* **spdlog**: For logging.
+# Telegrammer API design
+
+The implemented API and delivery architecture are specified in
+[`designs/design-0-api.md`](designs/design-0-api.md). That document supersedes
+the original in-memory subscription design.
+
+The current development implementation provides:
+
+- authenticated JSON `POST /send` and `POST /subscribe` routes;
+- durable, idempotent subscriptions with authenticated list/delete routes;
+- a durable SQLite polling cursor, private-chat username observations, and
+ callback delivery queue;
+- bounded callback retries, dead-letter management commands, and health state;
+- secure one-time API-key generation with digest-only storage;
+- a single-daemon `<database>.lock` acquired with `flock`;
+- loopback binding by default and a service state directory for systemd.
+
+The development schema is intentionally not versioned yet. The existing
+plaintext `api_keys(name,key,created_at)` layout is rejected rather than
+migrated. Production migration and backup tooling must be added before using
+this state transition as a release upgrade.
diff --git a/designs/design-0-api.md b/designs/design-0-api.md
index 82915c7..a1f385e 100644
--- a/designs/design-0-api.md
+++ b/designs/design-0-api.md
@@ -214,11 +214,17 @@ fixed worker pool takes the place of detached callback threads.
## 5. Persistence and migrations
-Maintain `PRAGMA user_version` and sequential migrations inside transactions.
-Recognize the existing unversioned `api_keys(name,key,created_at)` schema
-explicitly; refuse unknown layouts without modifying them. Back up the database
-with SQLite's backup API before a destructive migration. Restrict backups to
-the service user, since the old backup contains usable credentials.
+The current development database is not schema-versioned: its
+`PRAGMA user_version` is `0`, and the existing layout is the legacy
+`api_keys(name,key,created_at)` table. This implementation therefore creates
+the new development schema directly, does not set or bump `user_version`, and
+refuses the legacy table and other incompatible target-table layouts with a
+clear error. Remove or replace that development database before starting this
+build. A nonzero `user_version` is also rejected because no migration path is
+implemented yet. Before production release, add an explicit `user_version` migration
+sequence and a SQLite backup step; production migration is intentionally out
+of scope for this development implementation. Restrict future backups to the
+service user, since the old backup contains usable credentials.
Target schema (timestamps are integer Unix seconds):
@@ -280,11 +286,15 @@ Telegram has acknowledged its offset can lose callbacks; a lost completion
write can cause duplicate delivery. Recent credential and subscription changes
can also roll back under the same failure conditions.
-Only one running daemon may use this state database. Acquire an exclusive
-process lock alongside it before recovering jobs or starting polling; CLI
-credential commands may still use separate SQLite connections. Validate the
-bot identity through `getMe` before polling. Reject a database associated with
-a different bot instead of applying its cursor and subscriptions to that bot.
+Only one running daemon may use this state database. Open `${db}.lock` with
+`O_CREAT|O_RDWR|O_CLOEXEC|O_NOFOLLOW`, mode `0600`, then acquire
+`flock(LOCK_EX|LOCK_NB)` and retain the descriptor for the whole daemon
+lifetime. Treat `EWOULDBLOCK`/`EAGAIN` as an actionable "another daemon is
+running" error, and do not delete the lock file on shutdown. CLI credential and
+delivery-management commands deliberately do not acquire this daemon lock;
+SQLite's busy timeout and WAL handle their short operations. Validate the bot
+identity through `getMe` before polling. Reject a database associated with a
+different bot instead of applying its cursor and subscriptions to that bot.
Generate keys from 32 cryptographically secure random bytes and encode as
64 hexadecimal characters. Prefer an appropriate libmw crypto primitive if
@@ -298,11 +308,11 @@ Use a supported libmw hashing primitive or OpenSSL EVP. Print a new key only
once from `--add-key`; `--list-keys` prints names and creation times. Check bind,
query, and RNG errors explicitly. Reject empty names and names over 128 bytes.
-Migrate existing plaintext keys by hashing their exact strings, preserving
-their validity. Existing keys retain their old entropy and should be rotated;
-hashing does not strengthen them. Remove the old column transactionally.
-Explain that removing plaintext columns does not erase old backups, WAL files,
-or storage remnants. Do not claim cryptographic erasure.
+The future production migration should hash existing plaintext keys exactly,
+preserve their validity, and remove the old column transactionally. Existing
+keys retain their old entropy and should be rotated; hashing does not
+strengthen them. Explain that removing plaintext columns does not erase old
+backups, WAL files, or storage remnants. Do not claim cryptographic erasure.
`--delete-key` deletes owned subscriptions and queued deliveries in the same
transaction. Already accepted sends and in-flight callbacks may finish. No
@@ -402,15 +412,16 @@ must configure it. No payloads appear in ordinary logs or listing output.
## 8. Startup, shutdown, and packaging
-Validate configuration, database access, migrations, and the process lock before
-starting workers. Register HTTP routes exactly once: libmw's `start()` already
-calls the virtual `setup()`, so remove the explicit `app.setup()` call.
+Validate configuration, database access, the development schema, and the
+process lock before starting workers. `ApiServer::start()` registers HTTP
+routes exactly once before its synchronous bind; `main()` does not call
+`setup()` separately.
-The inspected libmw server starts a thread and spins until the server reports
-running; a bind failure can leave that spin waiting forever. Fix this in libmw
-or use a corrected libmw revision with a startup success/failure handshake.
-Testing that fix is an integration prerequisite; do not work around it with
-an arbitrary startup sleep in Telegrammer.
+The pinned libmw revision exposes the underlying httplib bind/listen split, but
+its convenience wrapper can still spin after a bind failure. `ApiServer` binds
+synchronously with that underlying API, disables `SO_REUSEPORT`, and only then
+starts the serving thread; an occupied port therefore returns a startup error
+without an arbitrary sleep. Its serving thread is joined during shutdown.
On SIGINT/SIGTERM, use a dedicated POSIX signal-wait thread or a self-pipe.
Never invoke logging, database operations, or server methods directly inside
@@ -445,12 +456,14 @@ are rejected; username lookup targets observed private chats; keys are no
longer listed; the default bind address changes; subscriptions become durable.
Existing valid bearer strings and successful send envelopes remain valid.
-Back up the database before migration, stop the old daemon, deploy, migrate,
-and re-register previous in-memory subscriptions. There is no historical
-subscription state to recover from the old executable. Rollback requires the
-backup and the old binary; do not run the old executable against migrated
-state. Reverting loses new queue state, so drain or explicitly account for
-pending deliveries before rollback.
+For this development transition, stop the old daemon, preserve any needed
+backup, remove or replace the old plaintext-key database, and provision new
+keys. There is no historical subscription or queue state to recover from the
+old executable. Before production release, add the versioned migration and
+backup procedure described in Section 5. Rollback requires the backup and the
+old binary; do not run the old executable against migrated state. Reverting
+loses new queue state, so drain or explicitly account for pending deliveries
+before rollback.
## 10. Implementation sequence
@@ -459,8 +472,9 @@ pending deliveries before rollback.
2. Implement HTTP status/error mapping, numeric and URL validation, explicit
timeouts, contained polling failures, and synchronized shutdown. Fix and pin
the libmw startup behavior. Deliver this as the core correctness phase.
-3. Migrate keys and add secure generation, digest lookup, revocation semantics,
- and service database configuration. Test old database migration and rollback.
+3. Add secure key generation, digest lookup, revocation semantics, and service
+ database configuration. Reject the old development schema explicitly; add
+ production migration and rollback tests before release.
4. Add persistent owned subscriptions, list/delete endpoints, private username
state, and bot identity binding. Document compatibility changes.
5. Add atomic cursor/job ingestion, capacity limits, callback workers, retries,
@@ -498,7 +512,7 @@ database or bot during automated tests.
| Recovery | Crash after remote acceptance, active-job reset, failed-job CLI |
| Revocation | Pending jobs cancelled, accepted/in-flight behavior documented |
| Lifecycle | Occupied port, stop during poll/backoff/callback, thread joining |
-| Migration | Existing keys still work, no raw keys listed, failure rolls back |
+| Migration | Legacy schema is rejected in dev; future migration preserves keys |
| Packaging | Service-user startup with explicit writable state directory |
Use barriers or fixture controls to crash the process at transaction and
diff --git a/packages/arch/telegrammer.service b/packages/arch/telegrammer.service
index b00fe8f..c0bf647 100644
--- a/packages/arch/telegrammer.service
+++ b/packages/arch/telegrammer.service
@@ -5,9 +5,16 @@ After=network.target
[Service]
User=telegrammer
Group=telegrammer
-# Ensure the token is passed via environment variable or edited into the service file
-ExecStart=/usr/bin/telegrammer --token some_token
+StateDirectory=telegrammer
+UMask=0077
+EnvironmentFile=-/etc/telegrammer/telegrammer.env
+ExecStart=/usr/bin/telegrammer \
+ --token=${TELEGRAM_BOT_TOKEN} \
+ --db=/var/lib/telegrammer/telegrammer.db
+TimeoutStopSec=60
Restart=on-failure
+RestartSec=5
+NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
diff --git a/src/api_server.cpp b/src/api_server.cpp
new file mode 100644
index 0000000..c00c18b
--- /dev/null
+++ b/src/api_server.cpp
@@ -0,0 +1,729 @@
+#include "api_server.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <cctype>
+#include <charconv>
+#include <cstdint>
+#include <format>
+#include <initializer_list>
+#include <string>
+#include <utility>
+
+#include <mw/url.hpp>
+
+#include "service_error.hpp"
+
+namespace telegrammer
+{
+
+namespace
+{
+
+std::string lower(std::string value)
+{
+ std::transform(value.begin(), value.end(), value.begin(),
+ [](unsigned char character)
+ {
+ return static_cast<char>(std::tolower(character));
+ });
+ return value;
+}
+
+std::optional<std::string_view> allowedMethods(std::string_view path)
+{
+ if(path == "/send")
+ {
+ return "POST";
+ }
+ if(path == "/subscribe")
+ {
+ return "POST";
+ }
+ if(path == "/subscriptions")
+ {
+ return "GET";
+ }
+ if(path == "/health")
+ {
+ return "GET";
+ }
+ if(path.starts_with("/subscriptions/") &&
+ path.size() > std::string_view("/subscriptions/").size() &&
+ path.find('/', std::string_view("/subscriptions/").size()) ==
+ std::string_view::npos)
+ {
+ return "DELETE";
+ }
+ return std::nullopt;
+}
+
+httplib::TaskQueue* createRequestQueue()
+{
+ return new httplib::ThreadPool(8, 8, 64);
+}
+
+void configureServerSocket(socket_t socket)
+{
+#ifdef SO_REUSEPORT
+ [[maybe_unused]] bool reuse_port =
+ httplib::set_socket_opt(socket, SOL_SOCKET, SO_REUSEPORT, 0);
+#else
+ [[maybe_unused]] bool reuse_address =
+ httplib::set_socket_opt(socket, SOL_SOCKET, SO_REUSEADDR, 0);
+#endif
+}
+
+bool hasOnlyFields(const json& body,
+ std::initializer_list<std::string_view> fields)
+{
+ for(const auto& item: body.items())
+ {
+ bool known = false;
+ for(std::string_view field: fields)
+ {
+ if(item.key() == field)
+ {
+ known = true;
+ break;
+ }
+ }
+ if(!known)
+ {
+ return false;
+ }
+ }
+ return true;
+}
+
+bool getInt64(const json& body, const char* name, int64_t& result)
+{
+ if(!body.contains(name) || !body[name].is_number_integer())
+ {
+ return false;
+ }
+ try
+ {
+ result = body[name].get<int64_t>();
+ }
+ catch(const std::exception&)
+ {
+ return false;
+ }
+ return result != 0;
+}
+
+bool validUtf8(std::string_view value, std::size_t& code_points)
+{
+ code_points = 0;
+ for(std::size_t i = 0; i < value.size(); ++code_points)
+ {
+ unsigned char first = static_cast<unsigned char>(value[i]);
+ std::size_t length = 0;
+ uint32_t code_point = 0;
+ if(first <= 0x7f)
+ {
+ length = 1;
+ code_point = first;
+ }
+ else if(first >= 0xc2 && first <= 0xdf)
+ {
+ length = 2;
+ code_point = first & 0x1f;
+ }
+ else if(first >= 0xe0 && first <= 0xef)
+ {
+ length = 3;
+ code_point = first & 0x0f;
+ }
+ else if(first >= 0xf0 && first <= 0xf4)
+ {
+ length = 4;
+ code_point = first & 0x07;
+ }
+ else
+ {
+ return false;
+ }
+ if(i + length > value.size())
+ {
+ return false;
+ }
+ for(std::size_t j = 1; j < length; ++j)
+ {
+ unsigned char continuation =
+ static_cast<unsigned char>(value[i + j]);
+ if((continuation & 0xc0) != 0x80)
+ {
+ return false;
+ }
+ code_point = (code_point << 6) | (continuation & 0x3f);
+ }
+ if((length == 2 && code_point < 0x80) ||
+ (length == 3 && code_point < 0x800) ||
+ (length == 4 && code_point < 0x10000) ||
+ code_point > 0x10ffff ||
+ (code_point >= 0xd800 && code_point <= 0xdfff))
+ {
+ return false;
+ }
+ i += length;
+ }
+ return code_points <= 4096;
+}
+
+std::optional<std::string> normalizedUsername(const std::string& input)
+{
+ if(input.empty() || input.size() > 64)
+ {
+ return std::nullopt;
+ }
+ std::string result = input;
+ if(result.starts_with('@'))
+ {
+ result.erase(0, 1);
+ }
+ if(result.empty() || result.size() > 64)
+ {
+ return std::nullopt;
+ }
+ for(char character: result)
+ {
+ bool valid = (character >= 'a' && character <= 'z') ||
+ (character >= 'A' && character <= 'Z') ||
+ (character >= '0' && character <= '9') ||
+ character == '_';
+ if(!valid)
+ {
+ return std::nullopt;
+ }
+ }
+ return lower(result);
+}
+
+mw::E<std::string> canonicalCallbackUrl(const std::string& input)
+{
+ if(input.empty() || input.size() > 2048)
+ {
+ return std::unexpected(serviceError(
+ 400, "INVALID_CALLBACK_URL", "Callback URL must be 1 to 2048 bytes",
+ std::nullopt, false));
+ }
+ for(unsigned char character: input)
+ {
+ if(std::iscntrl(character) || std::isspace(character))
+ {
+ return std::unexpected(serviceError(
+ 400, "INVALID_CALLBACK_URL", "Callback URL contains whitespace"));
+ }
+ }
+ auto parsed = mw::URL::fromStr(input);
+ if(!parsed.has_value() || !parsed->valid())
+ {
+ return std::unexpected(serviceError(
+ 400, "INVALID_CALLBACK_URL", "Callback URL is invalid"));
+ }
+ std::string scheme = lower(parsed->scheme());
+ if((scheme != "http" && scheme != "https") || parsed->host().empty() ||
+ !parsed->user().empty() || !parsed->password().empty() ||
+ !parsed->fragment().empty())
+ {
+ return std::unexpected(serviceError(
+ 400, "INVALID_CALLBACK_URL", "Callback URL must be HTTP or HTTPS"));
+ }
+ return parsed->str();
+}
+
+} // namespace
+
+ApiServer::ApiServer(mw::IPSocketInfo listen_info, KeyStore& keys,
+ SubscriptionStore& subscriptions,
+ DeliveryStore& deliveries, TelegramApi& telegram,
+ RuntimeState& state)
+ : mw::HTTPServer(listen_info), listen_info_(std::move(listen_info)),
+ keys_(keys), subscriptions_(subscriptions), deliveries_(deliveries),
+ telegram_(telegram), state_(state)
+{}
+
+ApiServer::~ApiServer()
+{
+ stop();
+ wait();
+}
+
+mw::E<void> ApiServer::start()
+{
+ setup();
+ if(!server.bind_to_port(listen_info_.address, listen_info_.port))
+ {
+ return std::unexpected(mw::runtimeError(
+ std::format("Unable to bind {}:{}", listen_info_.address,
+ listen_info_.port)));
+ }
+ server_thread_ = std::thread([this]()
+ {
+ server.listen_after_bind();
+ });
+ server.wait_until_ready();
+ return {};
+}
+
+void ApiServer::stop()
+{
+ server.stop();
+}
+
+void ApiServer::wait()
+{
+ if(server_thread_.joinable())
+ {
+ server_thread_.join();
+ }
+}
+
+void ApiServer::setup()
+{
+ server.new_task_queue = createRequestQueue;
+ server.set_socket_options(configureServerSocket);
+ server.set_payload_max_length(64 * 1024);
+ server.set_read_timeout(std::chrono::seconds(5));
+ server.set_write_timeout(std::chrono::seconds(5));
+ server.set_default_headers({{"Cache-Control", "no-store"}});
+ server.set_error_handler([]([[maybe_unused]] const Request& request,
+ Response& response)
+ {
+ if(!response.body.empty())
+ {
+ return;
+ }
+ int status = response.status;
+ writeError(response, status,
+ status == 404 ? "NOT_FOUND" :
+ status == 405 ? "METHOD_NOT_ALLOWED" :
+ status == 413 ? "PAYLOAD_TOO_LARGE" :
+ "HTTP_ERROR",
+ status == 404 ? "Route not found" :
+ status == 405 ? "Method not allowed" :
+ status == 413 ? "Request body is too large" :
+ "HTTP request failed");
+ });
+ server.set_exception_handler(
+ [](const Request& request, Response& response,
+ std::exception_ptr exception)
+ {
+ [[maybe_unused]] const Request& ignored_request = request;
+ [[maybe_unused]] std::exception_ptr ignored_exception = exception;
+ writeError(response, 500, "INTERNAL_ERROR",
+ "The request could not be completed");
+ });
+ server.set_pre_request_handler(
+ [this](const Request& request, Response& response)
+ {
+ if(!authenticate(request, response))
+ {
+ return httplib::Server::HandlerResponse::Handled;
+ }
+ return httplib::Server::HandlerResponse::Unhandled;
+ });
+ server.set_pre_routing_handler(
+ [this](const Request& request, Response& response)
+ {
+ auto methods = allowedMethods(request.path);
+ if(!methods.has_value() || request.method == *methods)
+ {
+ return httplib::Server::HandlerResponse::Unhandled;
+ }
+ if(!authenticate(request, response))
+ {
+ return httplib::Server::HandlerResponse::Handled;
+ }
+ response.status = 405;
+ response.set_header("Allow", std::string(*methods));
+ return httplib::Server::HandlerResponse::Handled;
+ });
+
+ server.Post("/send", [this](const Request& request, Response& response)
+ {
+ send(request, response);
+ });
+ server.Post("/subscribe",
+ [this](const Request& request, Response& response)
+ {
+ subscribe(request, response);
+ });
+ server.Get("/subscriptions",
+ [this](const Request& request, Response& response)
+ {
+ listSubscriptions(request, response);
+ });
+ server.Delete("/subscriptions/:id",
+ [this](const Request& request, Response& response)
+ {
+ deleteSubscription(request, response);
+ });
+ server.Get("/health",
+ [this](const Request& request, Response& response)
+ {
+ health(request, response);
+ });
+}
+
+bool ApiServer::authenticate(const Request& request, Response& response)
+{
+ if(request.get_header_value_count("Authorization") != 1)
+ {
+ writeError(response, 401, "UNAUTHORIZED", "Bearer credentials required");
+ response.set_header("WWW-Authenticate", "Bearer");
+ return false;
+ }
+ std::string authorization = request.get_header_value("Authorization");
+ if(authorization.size() <= 7 ||
+ lower(authorization.substr(0, 7)) != "bearer " ||
+ authorization.find_first_of(" \t\r\n", 7) != std::string::npos)
+ {
+ writeError(response, 401, "UNAUTHORIZED", "Bearer credentials required");
+ response.set_header("WWW-Authenticate", "Bearer");
+ return false;
+ }
+
+ auto identity = keys_.authenticate(authorization.substr(7));
+ if(!identity.has_value())
+ {
+ writeError(response, identity.error());
+ return false;
+ }
+ if(!identity->has_value())
+ {
+ writeError(response, 401, "UNAUTHORIZED", "Invalid bearer credential");
+ response.set_header("WWW-Authenticate", "Bearer");
+ return false;
+ }
+ response.user_data.set("key_id", identity->value().id);
+ response.user_data.set("key_name", identity->value().name);
+ return true;
+}
+
+bool ApiServer::requireJson(const Request& request, Response& response)
+{
+ if(!request.has_header("Content-Type"))
+ {
+ writeError(response, 415, "UNSUPPORTED_MEDIA_TYPE",
+ "Content-Type must be application/json");
+ return false;
+ }
+ std::string content_type = lower(request.get_header_value("Content-Type"));
+ std::size_t separator = content_type.find(';');
+ std::string media_type = content_type.substr(0, separator);
+ while(!media_type.empty() && media_type.back() == ' ')
+ {
+ media_type.pop_back();
+ }
+ if(media_type != "application/json")
+ {
+ writeError(response, 415, "UNSUPPORTED_MEDIA_TYPE",
+ "Content-Type must be application/json");
+ return false;
+ }
+ return true;
+}
+
+std::optional<json> ApiServer::parseObject(const Request& request,
+ Response& response)
+{
+ if(!requireJson(request, response))
+ {
+ return std::nullopt;
+ }
+ try
+ {
+ json body = json::parse(request.body);
+ if(!body.is_object())
+ {
+ writeError(response, 400, "INVALID_REQUEST",
+ "Request body must be a JSON object");
+ return std::nullopt;
+ }
+ return body;
+ }
+ catch(const std::exception&)
+ {
+ writeError(response, 400, "INVALID_REQUEST", "Request body is invalid JSON");
+ return std::nullopt;
+ }
+}
+
+std::optional<int64_t> ApiServer::ownerId(const Response& response) const
+{
+ const int64_t* value = response.user_data.get<int64_t>("key_id");
+ if(value == nullptr)
+ {
+ return std::nullopt;
+ }
+ return *value;
+}
+
+void ApiServer::writeSuccess(Response& response, const json& body, int status)
+{
+ response.status = status;
+ response.set_content(body.dump(), "application/json");
+ response.set_header("Cache-Control", "no-store");
+}
+
+void ApiServer::writeNoContent(Response& response)
+{
+ response.status = 204;
+ response.set_header("Cache-Control", "no-store");
+}
+
+void ApiServer::writeError(Response& response, int status,
+ std::string_view code, std::string_view message,
+ std::optional<std::string_view> field,
+ std::optional<int> retry_after)
+{
+ json error = {{"ok", false}};
+ error["error"] = {{"code", code}, {"message", message}};
+ if(field.has_value())
+ {
+ error["error"]["field"] = *field;
+ }
+ response.status = status;
+ response.set_content(error.dump(), "application/json");
+ response.set_header("Cache-Control", "no-store");
+ if(retry_after.has_value())
+ {
+ response.set_header("Retry-After", std::to_string(*retry_after));
+ }
+}
+
+void ApiServer::writeError(Response& response, const mw::Error& error)
+{
+ if(const ServiceError* service_error = asServiceError(error);
+ service_error != nullptr)
+ {
+ writeError(response, service_error->status, service_error->code,
+ service_error->msg, std::nullopt,
+ service_error->retry_after);
+ return;
+ }
+ writeError(response, 500, "INTERNAL_ERROR",
+ "The request could not be completed");
+}
+
+void ApiServer::send(const Request& request, Response& response)
+{
+ auto body = parseObject(request, response);
+ if(!body.has_value())
+ {
+ return;
+ }
+ if(!hasOnlyFields(*body, {"chat_id", "username", "text"}) ||
+ !body->contains("text") || !(*body)["text"].is_string())
+ {
+ writeError(response, 400, "INVALID_REQUEST",
+ "Request must contain only chat_id, username, and text");
+ return;
+ }
+ std::string text = (*body)["text"].get<std::string>();
+ std::size_t code_points = 0;
+ if(text.empty() || !validUtf8(text, code_points))
+ {
+ writeError(response, 400, "INVALID_REQUEST",
+ "text must be valid nonempty UTF-8", "text");
+ return;
+ }
+
+ bool has_chat_id = body->contains("chat_id");
+ bool has_username = body->contains("username");
+ if(has_chat_id == has_username)
+ {
+ writeError(response, 400, "INVALID_REQUEST",
+ "Exactly one of chat_id and username is required");
+ return;
+ }
+
+ int64_t chat_id = 0;
+ if(has_chat_id)
+ {
+ if(!getInt64(*body, "chat_id", chat_id))
+ {
+ writeError(response, 400, "INVALID_REQUEST",
+ "chat_id must be a nonzero signed integer", "chat_id");
+ return;
+ }
+ }
+ else
+ {
+ if(!(*body)["username"].is_string())
+ {
+ writeError(response, 400, "INVALID_REQUEST",
+ "username must be a string", "username");
+ return;
+ }
+ auto username = normalizedUsername(
+ (*body)["username"].get<std::string>());
+ if(!username.has_value())
+ {
+ writeError(response, 400, "INVALID_REQUEST",
+ "username is invalid", "username");
+ return;
+ }
+ auto resolved = deliveries_.resolveUsername(*username);
+ if(!resolved.has_value())
+ {
+ writeError(response, resolved.error());
+ return;
+ }
+ if(!resolved->has_value())
+ {
+ writeError(response, 404, "USERNAME_NOT_FOUND",
+ "Username has not been observed in a private chat");
+ return;
+ }
+ chat_id = **resolved;
+ }
+
+ auto result = telegram_.sendMessage(chat_id, text);
+ if(!result.has_value())
+ {
+ writeError(response, result.error());
+ return;
+ }
+ writeSuccess(response, *result);
+}
+
+void ApiServer::subscribe(const Request& request, Response& response)
+{
+ auto body = parseObject(request, response);
+ if(!body.has_value())
+ {
+ return;
+ }
+ if(!hasOnlyFields(*body, {"chat_id", "callback_url"}) ||
+ !body->contains("chat_id") || !body->contains("callback_url"))
+ {
+ writeError(response, 400, "INVALID_REQUEST",
+ "Request must contain chat_id and callback_url");
+ return;
+ }
+ int64_t chat_id = 0;
+ if(!getInt64(*body, "chat_id", chat_id))
+ {
+ writeError(response, 400, "INVALID_REQUEST",
+ "chat_id must be a nonzero signed integer", "chat_id");
+ return;
+ }
+ if(!(*body)["callback_url"].is_string())
+ {
+ writeError(response, 400, "INVALID_REQUEST",
+ "callback_url must be a string", "callback_url");
+ return;
+ }
+ auto url = canonicalCallbackUrl(
+ (*body)["callback_url"].get<std::string>());
+ if(!url.has_value())
+ {
+ writeError(response, url.error());
+ return;
+ }
+ auto owner = ownerId(response);
+ if(!owner.has_value())
+ {
+ writeError(response, 500, "INTERNAL_ERROR", "Missing request identity");
+ return;
+ }
+ auto result = subscriptions_.add(*owner, chat_id, *url);
+ if(!result.has_value())
+ {
+ writeError(response, result.error());
+ return;
+ }
+ writeSuccess(response, { {"ok", true}, {"subscription_id", result->id} });
+}
+
+void ApiServer::listSubscriptions(const Request& request, Response& response)
+{
+ [[maybe_unused]] const Request& ignored_request = request;
+ auto owner = ownerId(response);
+ if(!owner.has_value())
+ {
+ writeError(response, 500, "INTERNAL_ERROR", "Missing request identity");
+ return;
+ }
+ auto result = subscriptions_.list(*owner);
+ if(!result.has_value())
+ {
+ writeError(response, result.error());
+ return;
+ }
+ json subscriptions = json::array();
+ for(const Subscription& subscription: *result)
+ {
+ subscriptions.push_back({
+ {"id", subscription.id}, {"chat_id", subscription.chat_id},
+ {"callback_url", subscription.callback_url},
+ {"created_at", subscription.created_at}});
+ }
+ writeSuccess(response,
+ {{"ok", true}, {"subscriptions", subscriptions}});
+}
+
+void ApiServer::deleteSubscription(const Request& request,
+ Response& response)
+{
+ auto owner = ownerId(response);
+ if(!owner.has_value())
+ {
+ writeError(response, 500, "INTERNAL_ERROR", "Missing request identity");
+ return;
+ }
+ auto iterator = request.path_params.find("id");
+ int64_t id = 0;
+ if(iterator == request.path_params.end())
+ {
+ writeError(response, 400, "INVALID_REQUEST", "Subscription ID is invalid");
+ return;
+ }
+ const std::string& value = iterator->second;
+ const char* end = value.data() + value.size();
+ auto parsed = std::from_chars(value.data(), end, id);
+ if(parsed.ec != std::errc{} || parsed.ptr != end || id <= 0)
+ {
+ writeError(response, 400, "INVALID_REQUEST", "Subscription ID is invalid");
+ return;
+ }
+ auto result = subscriptions_.remove(*owner, id);
+ if(!result.has_value())
+ {
+ writeError(response, result.error());
+ return;
+ }
+ if(!*result)
+ {
+ writeError(response, 404, "NOT_FOUND", "Subscription not found");
+ return;
+ }
+ writeNoContent(response);
+}
+
+void ApiServer::health(const Request& request, Response& response)
+{
+ [[maybe_unused]] const Request& ignored_request = request;
+ auto queue = deliveries_.count();
+ if(!queue.has_value())
+ {
+ writeError(response, queue.error());
+ return;
+ }
+ bool ready = state_.polling_ready.load();
+ bool saturated = *queue >= 100000;
+ bool degraded = state_.degraded.load() || saturated;
+ json body = {{"ok", ready && !degraded},
+ {"polling_ready", ready},
+ {"degraded", degraded},
+ {"queue_saturated", saturated},
+ {"queue_size", *queue},
+ {"last_success", state_.last_success.load()}};
+ writeSuccess(response, body, ready && !degraded ? 200 : 503);
+}
+
+} // namespace telegrammer
diff --git a/src/api_server.hpp b/src/api_server.hpp
new file mode 100644
index 0000000..fdf36a5
--- /dev/null
+++ b/src/api_server.hpp
@@ -0,0 +1,75 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <string_view>
+#include <thread>
+
+#include <mw/http_server.hpp>
+
+#include "delivery_store.hpp"
+#include "key_store.hpp"
+#include "runtime_state.hpp"
+#include "subscription_store.hpp"
+#include "telegram_client.hpp"
+
+namespace telegrammer
+{
+
+/// Serves authenticated API routes and translates service errors to JSON.
+class ApiServer : public mw::HTTPServer
+{
+public:
+ /// Construct an API server over the shared application components.
+ ApiServer(mw::IPSocketInfo listen_info, KeyStore& keys,
+ SubscriptionStore& subscriptions, DeliveryStore& deliveries,
+ TelegramApi& telegram, RuntimeState& state);
+
+ /// Stop the listener and join its serving thread during destruction.
+ ~ApiServer();
+
+ /// Bind synchronously, then start serving requests in a joined thread.
+ mw::E<void> start();
+
+ /// Stop accepting requests without destroying component dependencies.
+ void stop();
+
+ /// Wait for the serving thread to finish.
+ void wait();
+
+protected:
+ /// Register routes and common HTTP server limits.
+ void setup() override;
+
+private:
+ mw::IPSocketInfo listen_info_;
+ KeyStore& keys_;
+ SubscriptionStore& subscriptions_;
+ DeliveryStore& deliveries_;
+ TelegramApi& telegram_;
+ RuntimeState& state_;
+ std::thread server_thread_;
+
+ bool authenticate(const Request& request, Response& response);
+ bool requireJson(const Request& request, Response& response);
+ std::optional<json> parseObject(const Request& request, Response& response);
+ std::optional<int64_t> ownerId(const Response& response) const;
+
+ static void writeSuccess(Response& response, const json& body,
+ int status = 200);
+ static void writeNoContent(Response& response);
+ static void writeError(Response& response, int status,
+ std::string_view code, std::string_view message,
+ std::optional<std::string_view> field =
+ std::nullopt,
+ std::optional<int> retry_after = std::nullopt);
+ static void writeError(Response& response, const mw::Error& error);
+
+ void send(const Request& request, Response& response);
+ void subscribe(const Request& request, Response& response);
+ void listSubscriptions(const Request& request, Response& response);
+ void deleteSubscription(const Request& request, Response& response);
+ void health(const Request& request, Response& response);
+};
+
+} // namespace telegrammer
diff --git a/src/application.cpp b/src/application.cpp
new file mode 100644
index 0000000..91caed9
--- /dev/null
+++ b/src/application.cpp
@@ -0,0 +1,138 @@
+#include "application.hpp"
+
+#include <cerrno>
+#include <csignal>
+#include <ctime>
+#include <cstring>
+#include <pthread.h>
+
+#include <spdlog/spdlog.h>
+
+#include "service_error.hpp"
+
+namespace telegrammer
+{
+
+Application::Application(ApplicationConfig config)
+ : database_lock_(std::move(config.database_lock)),
+ listen_info_(config.listen_info), db_path_(std::move(config.db_path)),
+ keys_(db_path_),
+ subscriptions_(db_path_), deliveries_(db_path_),
+ telegram_(std::move(config.token)), dispatcher_(deliveries_),
+ poller_(telegram_, deliveries_, dispatcher_, state_, 0),
+ server_(config.listen_info, keys_, subscriptions_, deliveries_,
+ telegram_, state_)
+{}
+
+void Application::requestStop()
+{
+ if(stopping_.exchange(true))
+ {
+ return;
+ }
+ server_.stop();
+ poller_.stop();
+ dispatcher_.stop();
+}
+
+void Application::waitForSignal(std::stop_token stop_token)
+{
+ sigset_t signals;
+ sigemptyset(&signals);
+ sigaddset(&signals, SIGINT);
+ sigaddset(&signals, SIGTERM);
+
+ while(!stop_token.stop_requested())
+ {
+ timespec timeout{1, 0};
+ int signal = sigtimedwait(&signals, nullptr, &timeout);
+ if(signal == SIGINT || signal == SIGTERM)
+ {
+ requestStop();
+ return;
+ }
+ if(signal < 0 && errno != EAGAIN && errno != EINTR)
+ {
+ spdlog::error("Signal wait failed: {}", std::strerror(errno));
+ requestStop();
+ return;
+ }
+ }
+}
+
+int Application::run()
+{
+ sigset_t signals;
+ sigemptyset(&signals);
+ sigaddset(&signals, SIGINT);
+ sigaddset(&signals, SIGTERM);
+ if(pthread_sigmask(SIG_BLOCK, &signals, nullptr) != 0)
+ {
+ spdlog::error("Unable to block termination signals");
+ return 1;
+ }
+
+ auto identity = telegram_.getMe();
+ if(!identity.has_value())
+ {
+ const ServiceError* error = asServiceError(identity.error());
+ if(error != nullptr)
+ {
+ spdlog::error("Unable to validate Telegram bot [{}]: {}",
+ error->code, error->msg);
+ }
+ else
+ {
+ spdlog::error("Unable to validate Telegram bot: {}",
+ mw::errorMsg(identity.error()));
+ }
+ return 1;
+ }
+ int64_t bot_id = (*identity)["result"]["id"].get<int64_t>();
+
+ auto bot_result = deliveries_.ensureBot(bot_id);
+ if(!bot_result.has_value())
+ {
+ spdlog::error("Unable to configure bot state: {}",
+ mw::errorMsg(bot_result.error()));
+ return 1;
+ }
+ auto reset_result = deliveries_.resetInFlight();
+ if(!reset_result.has_value())
+ {
+ spdlog::error("Unable to recover callback jobs: {}",
+ mw::errorMsg(reset_result.error()));
+ return 1;
+ }
+ poller_.setBotId(bot_id);
+ state_.polling_ready = true;
+ state_.degraded = false;
+
+ auto start_result = server_.start();
+ if(!start_result.has_value())
+ {
+ spdlog::error("Failed to start server: {}",
+ mw::errorMsg(start_result.error()));
+ return 1;
+ }
+
+ spdlog::info("Server listening on {}:{}", listen_info_.address,
+ listen_info_.port);
+ dispatcher_.start();
+ poller_.start();
+ signal_watcher_ = std::jthread([this](std::stop_token stop_token)
+ {
+ waitForSignal(stop_token);
+ });
+
+ server_.wait();
+ requestStop();
+ signal_watcher_.request_stop();
+ if(signal_watcher_.joinable())
+ {
+ signal_watcher_.join();
+ }
+ return 0;
+}
+
+} // namespace telegrammer
diff --git a/src/application.hpp b/src/application.hpp
new file mode 100644
index 0000000..0c0370d
--- /dev/null
+++ b/src/application.hpp
@@ -0,0 +1,62 @@
+#pragma once
+
+#include <atomic>
+#include <memory>
+#include <string>
+#include <thread>
+
+#include "api_server.hpp"
+#include "database.hpp"
+#include "dispatcher.hpp"
+#include "key_store.hpp"
+#include "poller.hpp"
+#include "runtime_state.hpp"
+#include "subscription_store.hpp"
+#include "telegram_client.hpp"
+
+namespace telegrammer
+{
+
+/// Contains the validated settings and held lock for one daemon instance.
+struct ApplicationConfig
+{
+ /// Address and port for the authenticated HTTP API.
+ mw::IPSocketInfo listen_info;
+ /// Telegram bot token used by the production client.
+ std::string token;
+ /// SQLite state database path.
+ std::string db_path;
+ /// Exclusive daemon lock retained for the application lifetime.
+ std::unique_ptr<DatabaseLock> database_lock;
+};
+
+/// Owns the daemon components and coordinates their complete lifecycle.
+class Application
+{
+public:
+ /// Construct a daemon from validated configuration and a held lock.
+ explicit Application(ApplicationConfig config);
+
+ /// Start, run, and cleanly stop the daemon.
+ int run();
+
+private:
+ std::unique_ptr<DatabaseLock> database_lock_;
+ mw::IPSocketInfo listen_info_;
+ std::string db_path_;
+ KeyStore keys_;
+ SubscriptionStore subscriptions_;
+ DeliveryStore deliveries_;
+ TelegramClient telegram_;
+ RuntimeState state_;
+ Dispatcher dispatcher_;
+ Poller poller_;
+ ApiServer server_;
+ std::jthread signal_watcher_;
+ std::atomic<bool> stopping_ = false;
+
+ void requestStop();
+ void waitForSignal(std::stop_token stop_token);
+};
+
+} // namespace telegrammer
diff --git a/src/database.cpp b/src/database.cpp
new file mode 100644
index 0000000..33baebf
--- /dev/null
+++ b/src/database.cpp
@@ -0,0 +1,308 @@
+#include "database.hpp"
+
+#include <array>
+#include <cerrno>
+#include <chrono>
+#include <cstring>
+#include <fcntl.h>
+#include <optional>
+#include <set>
+#include <stdexcept>
+#include <string_view>
+#include <sys/file.h>
+#include <sys/stat.h>
+#include <unistd.h>
+#include <vector>
+
+#include "service_error.hpp"
+
+namespace telegrammer
+{
+
+namespace
+{
+
+mw::E<void> execute(mw::SQLite& db, std::string_view sql)
+{
+ auto result = db.execute(std::string(sql));
+ if(!result.has_value())
+ {
+ return std::unexpected(result.error());
+ }
+ return {};
+}
+
+enum class TableShape
+{
+ MISSING,
+ CURRENT,
+ LEGACY,
+ INCOMPATIBLE
+};
+
+TableShape tableShape(mw::SQLite& db, const char* table,
+ const std::set<std::string>& expected_columns)
+{
+ auto table_rows = db.eval<std::string>(
+ std::string("SELECT name FROM sqlite_master WHERE type = 'table' "
+ "AND name = '") + table + "';");
+ if(!table_rows.has_value())
+ {
+ return TableShape::INCOMPATIBLE;
+ }
+
+ // The table names passed here are fixed internal values. SQLite does not
+ // accept a bound parameter in PRAGMA table_info(), so keep the values
+ // out of user input and quote them explicitly.
+ auto columns = db.eval<int64_t, std::string, std::string, int64_t,
+ std::optional<std::string>, int64_t>(
+ std::string("PRAGMA table_info('") + table + "');");
+ if(!columns.has_value())
+ {
+ return TableShape::INCOMPATIBLE;
+ }
+ if(columns->empty())
+ {
+ return TableShape::MISSING;
+ }
+
+ std::set<std::string> actual_columns;
+ for(const auto& column: *columns)
+ {
+ actual_columns.insert(std::get<1>(column));
+ }
+ if(std::strcmp(table, "api_keys") == 0 &&
+ actual_columns.contains("key") &&
+ !actual_columns.contains("key_digest"))
+ {
+ return TableShape::LEGACY;
+ }
+ return actual_columns == expected_columns ? TableShape::CURRENT
+ : TableShape::INCOMPATIBLE;
+}
+
+} // namespace
+
+DatabaseLock::DatabaseLock(const std::string& db_path)
+ : lock_path_(db_path + ".lock")
+{
+ fd_ = ::open(lock_path_.c_str(),
+ O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW,
+ 0600);
+ if(fd_ < 0)
+ {
+ throw std::runtime_error(
+ "Failed to open database lock '" + lock_path_ + "': " +
+ std::strerror(errno));
+ }
+ if(::fchmod(fd_, 0600) != 0)
+ {
+ int error_code = errno;
+ ::close(fd_);
+ fd_ = -1;
+ throw std::runtime_error(
+ "Failed to secure database lock '" + lock_path_ + "': " +
+ std::strerror(error_code));
+ }
+
+ if(::flock(fd_, LOCK_EX | LOCK_NB) == 0)
+ {
+ return;
+ }
+
+ int error_code = errno;
+ ::close(fd_);
+ fd_ = -1;
+
+ if(error_code == EWOULDBLOCK || error_code == EAGAIN)
+ {
+ throw std::runtime_error(
+ "Another Telegrammer daemon is using database '" + db_path +
+ "'");
+ }
+
+ throw std::runtime_error(
+ "Failed to lock database '" + db_path + "': " +
+ std::strerror(error_code));
+}
+
+DatabaseLock::~DatabaseLock()
+{
+ if(fd_ >= 0)
+ {
+ ::close(fd_);
+ }
+}
+
+mw::E<std::unique_ptr<mw::SQLite>> openDatabase(const std::string& db_path)
+{
+ auto result = mw::SQLite::connectFile(db_path, 5000);
+ if(!result.has_value())
+ {
+ return std::unexpected(serviceError(
+ 503, "DATABASE_UNAVAILABLE", "The state database is unavailable"));
+ }
+
+ auto db = std::move(*result);
+ for(const char* pragma: {
+ "PRAGMA foreign_keys = ON;",
+ "PRAGMA synchronous = NORMAL;"})
+ {
+ auto pragma_result = db->execute(pragma);
+ if(!pragma_result.has_value())
+ {
+ return std::unexpected(serviceError(
+ 503, "DATABASE_UNAVAILABLE",
+ "The state database is unavailable"));
+ }
+ }
+
+ return db;
+}
+
+mw::E<void> initializeDatabase(const std::string& db_path)
+{
+ auto result = openDatabase(db_path);
+ if(!result.has_value())
+ {
+ return std::unexpected(result.error());
+ }
+ mw::SQLite& db = **result;
+
+ auto user_version = db.evalToValue<int64_t>("PRAGMA user_version;");
+ if(!user_version.has_value() || *user_version != 0)
+ {
+ return std::unexpected(serviceError(
+ 500, "DATABASE_SCHEMA",
+ "The database has an unsupported schema version."));
+ }
+
+ const std::set<std::string> api_keys_columns = {
+ "id", "name", "key_digest", "created_at"};
+ const std::set<std::string> subscriptions_columns = {
+ "id", "owner_id", "chat_id", "callback_url", "created_at"};
+ const std::set<std::string> poll_state_columns = {
+ "singleton", "bot_id", "next_offset"};
+ const std::set<std::string> usernames_columns = {
+ "username", "chat_id", "observed_at"};
+ const std::set<std::string> deliveries_columns = {
+ "id", "subscription_id", "update_id", "payload", "state",
+ "attempt_count", "next_attempt_at", "created_at", "last_error"};
+
+ const TableShape api_keys_shape =
+ tableShape(db, "api_keys", api_keys_columns);
+ if(api_keys_shape == TableShape::LEGACY)
+ {
+ return std::unexpected(serviceError(
+ 500, "DATABASE_SCHEMA",
+ "The database uses the development API-key schema. "
+ "Remove it before starting the new development build."));
+ }
+ if(api_keys_shape == TableShape::INCOMPATIBLE)
+ {
+ return std::unexpected(serviceError(
+ 500, "DATABASE_SCHEMA",
+ "The database has an incompatible api_keys table."));
+ }
+
+ const std::array<std::pair<const char*, std::set<std::string>>, 4>
+ existing_tables = {{{"subscriptions", subscriptions_columns},
+ {"poll_state", poll_state_columns},
+ {"usernames", usernames_columns},
+ {"deliveries", deliveries_columns}}};
+ for(const auto& [table, columns]: existing_tables)
+ {
+ if(tableShape(db, table, columns) == TableShape::INCOMPATIBLE)
+ {
+ return std::unexpected(serviceError(
+ 500, "DATABASE_SCHEMA",
+ std::string("The database has an incompatible ") + table +
+ " table."));
+ }
+ }
+
+ auto begin_result = execute(db, "BEGIN IMMEDIATE;");
+ if(!begin_result.has_value())
+ {
+ return std::unexpected(serviceError(
+ 503, "DATABASE_UNAVAILABLE", "Unable to initialize the database"));
+ }
+ auto rollback = [&db]()
+ {
+ [[maybe_unused]] auto result = db.execute("ROLLBACK;");
+ };
+
+ // SQLite's prepare API executes one statement per call. Keep the schema
+ // statements separate so every failure can be reported precisely.
+ const std::vector<std::string> statements = {
+ "CREATE TABLE IF NOT EXISTS api_keys ("
+ "id INTEGER PRIMARY KEY, "
+ "name TEXT NOT NULL UNIQUE, "
+ "key_digest TEXT NOT NULL UNIQUE, "
+ "created_at INTEGER NOT NULL"
+ ");",
+ "CREATE TABLE IF NOT EXISTS subscriptions ("
+ "id INTEGER PRIMARY KEY, "
+ "owner_id INTEGER NOT NULL REFERENCES api_keys(id) "
+ "ON DELETE CASCADE, "
+ "chat_id INTEGER NOT NULL, "
+ "callback_url TEXT NOT NULL, "
+ "created_at INTEGER NOT NULL, "
+ "UNIQUE(owner_id, chat_id, callback_url)"
+ ");",
+ "CREATE TABLE IF NOT EXISTS poll_state ("
+ "singleton INTEGER PRIMARY KEY CHECK(singleton = 1), "
+ "bot_id INTEGER NOT NULL, "
+ "next_offset INTEGER NOT NULL"
+ ");",
+ "CREATE TABLE IF NOT EXISTS usernames ("
+ "username TEXT PRIMARY KEY, "
+ "chat_id INTEGER NOT NULL UNIQUE, "
+ "observed_at INTEGER NOT NULL"
+ ");",
+ "CREATE TABLE IF NOT EXISTS deliveries ("
+ "id INTEGER PRIMARY KEY, "
+ "subscription_id INTEGER NOT NULL "
+ "REFERENCES subscriptions(id) ON DELETE CASCADE, "
+ "update_id INTEGER NOT NULL, "
+ "payload TEXT NOT NULL, "
+ "state TEXT NOT NULL CHECK(state IN ('PENDING','IN_FLIGHT','DEAD')), "
+ "attempt_count INTEGER NOT NULL DEFAULT 0, "
+ "next_attempt_at INTEGER NOT NULL, "
+ "created_at INTEGER NOT NULL, "
+ "last_error TEXT, "
+ "UNIQUE(subscription_id, update_id)"
+ ");",
+ "CREATE INDEX IF NOT EXISTS deliveries_due "
+ "ON deliveries(state, next_attempt_at, id);"};
+
+ for(const std::string& statement: statements)
+ {
+ auto statement_result = execute(db, statement);
+ if(!statement_result.has_value())
+ {
+ rollback();
+ return std::unexpected(serviceError(
+ 503, "DATABASE_UNAVAILABLE", "Unable to initialize the database"));
+ }
+ }
+
+ auto commit_result = execute(db, "COMMIT;");
+ if(!commit_result.has_value())
+ {
+ rollback();
+ return std::unexpected(serviceError(
+ 503, "DATABASE_UNAVAILABLE", "Unable to initialize the database"));
+ }
+
+ return {};
+}
+
+int64_t nowSeconds()
+{
+ return std::chrono::duration_cast<std::chrono::seconds>(
+ std::chrono::system_clock::now().time_since_epoch())
+ .count();
+}
+
+} // namespace telegrammer
diff --git a/src/database.hpp b/src/database.hpp
new file mode 100644
index 0000000..5711095
--- /dev/null
+++ b/src/database.hpp
@@ -0,0 +1,40 @@
+#pragma once
+
+#include <cstdint>
+#include <memory>
+#include <string>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+
+namespace telegrammer
+{
+
+/// Holds an advisory exclusive lock for one running Telegrammer daemon.
+class DatabaseLock
+{
+public:
+ /// Acquire the non-blocking lock associated with a database path.
+ explicit DatabaseLock(const std::string& db_path);
+
+ /// Release the lock when the daemon has stopped.
+ ~DatabaseLock();
+
+ DatabaseLock(const DatabaseLock&) = delete;
+ DatabaseLock& operator=(const DatabaseLock&) = delete;
+
+private:
+ std::string lock_path_;
+ int fd_ = -1;
+};
+
+/// Open a configured SQLite connection for one short operation.
+mw::E<std::unique_ptr<mw::SQLite>> openDatabase(const std::string& db_path);
+
+/// Create the development schema and reject incompatible old layouts.
+mw::E<void> initializeDatabase(const std::string& db_path);
+
+/// Return the current wall-clock time as Unix seconds.
+int64_t nowSeconds();
+
+} // namespace telegrammer
diff --git a/src/delivery_store.cpp b/src/delivery_store.cpp
new file mode 100644
index 0000000..087b3f6
--- /dev/null
+++ b/src/delivery_store.cpp
@@ -0,0 +1,882 @@
+#include "delivery_store.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <limits>
+#include <random>
+#include <string_view>
+#include <tuple>
+#include <utility>
+
+#include "database.hpp"
+#include "service_error.hpp"
+
+namespace telegrammer
+{
+
+namespace
+{
+
+struct ValidatedUpdate
+{
+ int64_t update_id;
+ std::optional<json> message;
+ int64_t chat_id = 0;
+ bool private_chat = false;
+ std::optional<std::string> username;
+};
+
+mw::E<void> execute(mw::SQLite& db, const std::string& sql)
+{
+ auto result = db.execute(sql);
+ if(!result.has_value())
+ {
+ return std::unexpected(result.error());
+ }
+ return {};
+}
+
+mw::Error databaseError([[maybe_unused]] const mw::Error& error)
+{
+ return serviceError(503, "DATABASE_UNAVAILABLE",
+ "The state database is unavailable");
+}
+
+mw::Error invalidUpdate(std::string_view message)
+{
+ return serviceError(502, "INVALID_UPDATE", message, std::nullopt, true);
+}
+
+bool getInt64(const json& value, int64_t& result)
+{
+ if(!value.is_number_integer())
+ {
+ return false;
+ }
+ try
+ {
+ result = value.get<int64_t>();
+ return true;
+ }
+ catch(const std::exception&)
+ {
+ return false;
+ }
+}
+
+std::string lower(std::string value)
+{
+ std::transform(value.begin(), value.end(), value.begin(),
+ [](unsigned char character)
+ {
+ if(character >= 'A' && character <= 'Z')
+ {
+ return static_cast<char>(character + ('a' - 'A'));
+ }
+ return static_cast<char>(character);
+ });
+ return value;
+}
+
+mw::E<std::vector<ValidatedUpdate>> validateUpdates(const json& updates)
+{
+ if(!updates.is_array())
+ {
+ return std::unexpected(invalidUpdate("Telegram result is not an array"));
+ }
+
+ std::vector<ValidatedUpdate> result;
+ result.reserve(updates.size());
+ for(const json& update: updates)
+ {
+ if(!update.is_object() || !update.contains("update_id"))
+ {
+ return std::unexpected(invalidUpdate(
+ "Telegram update has no update_id"));
+ }
+ int64_t update_id = 0;
+ if(!getInt64(update["update_id"], update_id) || update_id < 0)
+ {
+ return std::unexpected(invalidUpdate(
+ "Telegram update_id is invalid"));
+ }
+
+ ValidatedUpdate item{update_id, std::nullopt, 0, false, std::nullopt};
+ if(update.contains("message"))
+ {
+ const json& message = update["message"];
+ if(!message.is_object() || !message.contains("chat") ||
+ !message["chat"].is_object() ||
+ !message["chat"].contains("id") ||
+ !getInt64(message["chat"]["id"], item.chat_id) ||
+ item.chat_id == 0)
+ {
+ return std::unexpected(invalidUpdate(
+ "Telegram message has an invalid chat"));
+ }
+ item.message = message;
+ if(message["chat"].contains("type") &&
+ message["chat"]["type"].is_string())
+ {
+ item.private_chat = message["chat"]["type"] == "private";
+ }
+ if(item.private_chat && message.contains("from") &&
+ message["from"].is_object() &&
+ message["from"].contains("username") &&
+ message["from"]["username"].is_string())
+ {
+ std::string value =
+ message["from"]["username"].get<std::string>();
+ if(!value.empty() && value.size() <= 64)
+ {
+ if(value.starts_with('@'))
+ {
+ value.erase(0, 1);
+ }
+ if(!value.empty())
+ {
+ item.username = lower(value);
+ }
+ }
+ }
+ }
+ result.push_back(std::move(item));
+ }
+
+ std::sort(result.begin(), result.end(),
+ [](const ValidatedUpdate& left, const ValidatedUpdate& right)
+ {
+ return left.update_id < right.update_id;
+ });
+ return result;
+}
+
+mw::E<std::optional<std::pair<int64_t, int64_t>>> pollState(
+ mw::SQLite& db)
+{
+ auto rows = db.eval<int64_t, int64_t>(
+ "SELECT bot_id, next_offset FROM poll_state WHERE singleton = 1;");
+ if(!rows.has_value())
+ {
+ return std::unexpected(databaseError(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::nullopt;
+ }
+ return std::pair{std::get<0>((*rows)[0]), std::get<1>((*rows)[0])};
+}
+
+mw::E<void> ensureBotInTransaction(mw::SQLite& db, int64_t bot_id)
+{
+ auto state = pollState(db);
+ if(!state.has_value())
+ {
+ return std::unexpected(state.error());
+ }
+ if(state->has_value())
+ {
+ if(state->value().first != bot_id)
+ {
+ return std::unexpected(serviceError(
+ 409, "BOT_MISMATCH",
+ "The state database belongs to another Telegram bot"));
+ }
+ return {};
+ }
+
+ auto statement = db.statementFromStr(
+ "INSERT INTO poll_state (singleton, bot_id, next_offset) "
+ "VALUES (1, ?, 0);");
+ if(!statement.has_value())
+ {
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(bot_id);
+ if(!bind_result.has_value())
+ {
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto insert_result = db.execute(std::move(*statement));
+ if(!insert_result.has_value())
+ {
+ return std::unexpected(databaseError(insert_result.error()));
+ }
+ return {};
+}
+
+} // namespace
+
+DeliveryStore::DeliveryStore(std::string db_path)
+ : db_path_(std::move(db_path))
+{}
+
+mw::E<void> DeliveryStore::ensureBot(int64_t bot_id) const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto begin_result = execute(**db_result, "BEGIN IMMEDIATE;");
+ if(!begin_result.has_value())
+ {
+ return std::unexpected(databaseError(begin_result.error()));
+ }
+ auto result = ensureBotInTransaction(**db_result, bot_id);
+ if(!result.has_value())
+ {
+ [[maybe_unused]] auto rollback = (*db_result)->execute("ROLLBACK;");
+ return std::unexpected(result.error());
+ }
+ auto commit_result = execute(**db_result, "COMMIT;");
+ if(!commit_result.has_value())
+ {
+ [[maybe_unused]] auto rollback = (*db_result)->execute("ROLLBACK;");
+ return std::unexpected(databaseError(commit_result.error()));
+ }
+ return {};
+}
+
+mw::E<int64_t> DeliveryStore::offset(int64_t bot_id) const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto state = pollState(**db_result);
+ if(!state.has_value())
+ {
+ return std::unexpected(state.error());
+ }
+ if(!state->has_value())
+ {
+ auto ensure_result = ensureBot(bot_id);
+ if(!ensure_result.has_value())
+ {
+ return std::unexpected(ensure_result.error());
+ }
+ return 0;
+ }
+ if(state->value().first != bot_id)
+ {
+ return std::unexpected(serviceError(
+ 409, "BOT_MISMATCH",
+ "The state database belongs to another Telegram bot"));
+ }
+ return state->value().second;
+}
+
+mw::E<void> DeliveryStore::ingest(int64_t bot_id, const json& updates) const
+{
+ auto validated = validateUpdates(updates);
+ if(!validated.has_value())
+ {
+ return std::unexpected(validated.error());
+ }
+
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ mw::SQLite& db = **db_result;
+ auto begin_result = execute(db, "BEGIN IMMEDIATE;");
+ if(!begin_result.has_value())
+ {
+ return std::unexpected(databaseError(begin_result.error()));
+ }
+ auto rollback = [&db]()
+ {
+ [[maybe_unused]] auto result = db.execute("ROLLBACK;");
+ };
+
+ auto bot_result = ensureBotInTransaction(db, bot_id);
+ if(!bot_result.has_value())
+ {
+ rollback();
+ return std::unexpected(bot_result.error());
+ }
+ auto state = pollState(db);
+ if(!state.has_value())
+ {
+ rollback();
+ return std::unexpected(state.error());
+ }
+ int64_t next_offset = state->value().second;
+
+ for(const ValidatedUpdate& update: *validated)
+ {
+ if(update.update_id < next_offset)
+ {
+ continue;
+ }
+ if(update.update_id == std::numeric_limits<int64_t>::max())
+ {
+ rollback();
+ return std::unexpected(invalidUpdate("Telegram update_id overflow"));
+ }
+
+ if(update.message.has_value())
+ {
+ if(update.private_chat)
+ {
+ auto delete_username = db.statementFromStr(
+ "DELETE FROM usernames WHERE chat_id = ?;");
+ if(!delete_username.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(
+ delete_username.error()));
+ }
+ auto bind_result = delete_username->bind(update.chat_id);
+ if(!bind_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto delete_result = db.execute(std::move(*delete_username));
+ if(!delete_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(delete_result.error()));
+ }
+ if(update.username.has_value())
+ {
+ auto username_statement = db.statementFromStr(
+ "INSERT OR REPLACE INTO usernames "
+ "(username, chat_id, observed_at) VALUES (?, ?, ?);");
+ if(!username_statement.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(
+ username_statement.error()));
+ }
+ auto username_bind = username_statement->bind(
+ *update.username, update.chat_id, nowSeconds());
+ if(!username_bind.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(
+ username_bind.error()));
+ }
+ auto username_result =
+ db.execute(std::move(*username_statement));
+ if(!username_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(
+ username_result.error()));
+ }
+ }
+ }
+
+ auto subscription_statement = db.statementFromStr(
+ "SELECT id, callback_url FROM subscriptions "
+ "WHERE chat_id = ?;");
+ if(!subscription_statement.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(
+ subscription_statement.error()));
+ }
+ auto subscription_bind =
+ subscription_statement->bind(update.chat_id);
+ if(!subscription_bind.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(subscription_bind.error()));
+ }
+ auto subscription_rows = db.eval<int64_t, std::string>(
+ std::move(*subscription_statement));
+ if(!subscription_rows.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(subscription_rows.error()));
+ }
+
+ for(const auto& subscription: *subscription_rows)
+ {
+ auto delivery_statement = db.statementFromStr(
+ "INSERT OR IGNORE INTO deliveries "
+ "(subscription_id, update_id, payload, state, "
+ "attempt_count, next_attempt_at, created_at) "
+ "VALUES (?, ?, ?, 'PENDING', 0, ?, ?);");
+ if(!delivery_statement.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(
+ delivery_statement.error()));
+ }
+ std::string payload = update.message->dump();
+ auto delivery_bind = delivery_statement->bind(
+ std::get<0>(subscription), update.update_id, payload,
+ nowSeconds(), nowSeconds());
+ if(!delivery_bind.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(
+ delivery_bind.error()));
+ }
+ auto delivery_result = db.execute(std::move(*delivery_statement));
+ if(!delivery_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(
+ delivery_result.error()));
+ }
+ auto queue_size = db.evalToValue<int64_t>(
+ "SELECT COUNT(*) FROM deliveries;");
+ if(!queue_size.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(queue_size.error()));
+ }
+ if(*queue_size > 100000)
+ {
+ rollback();
+ return std::unexpected(serviceError(
+ 503, "QUEUE_FULL", "The delivery queue is full",
+ std::nullopt, true));
+ }
+ }
+ }
+ next_offset = update.update_id + 1;
+ }
+
+ auto offset_statement = db.statementFromStr(
+ "UPDATE poll_state SET next_offset = ? WHERE singleton = 1;");
+ if(!offset_statement.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(offset_statement.error()));
+ }
+ auto offset_bind = offset_statement->bind(next_offset);
+ if(!offset_bind.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(offset_bind.error()));
+ }
+ auto offset_result = db.execute(std::move(*offset_statement));
+ if(!offset_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(offset_result.error()));
+ }
+ auto commit_result = execute(db, "COMMIT;");
+ if(!commit_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(commit_result.error()));
+ }
+ return {};
+}
+
+mw::E<std::optional<int64_t>> DeliveryStore::resolveUsername(
+ const std::string& username) const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto statement = (*db_result)->statementFromStr(
+ "SELECT chat_id FROM usernames WHERE username = ?;");
+ if(!statement.has_value())
+ {
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(username);
+ if(!bind_result.has_value())
+ {
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto rows = (*db_result)->eval<int64_t>(std::move(*statement));
+ if(!rows.has_value())
+ {
+ return std::unexpected(databaseError(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::nullopt;
+ }
+ return std::get<0>((*rows)[0]);
+}
+
+mw::E<std::optional<DeliveryJob>> DeliveryStore::claimNext() const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ mw::SQLite& db = **db_result;
+ auto begin_result = execute(db, "BEGIN IMMEDIATE;");
+ if(!begin_result.has_value())
+ {
+ return std::unexpected(databaseError(begin_result.error()));
+ }
+ auto rollback = [&db]()
+ {
+ [[maybe_unused]] auto result = db.execute("ROLLBACK;");
+ };
+
+ auto rows = db.eval<int64_t, int64_t, int64_t, int64_t, int, std::string,
+ std::string, int64_t, int64_t, std::string>(
+ "SELECT d.id, p.bot_id, d.subscription_id, d.update_id, "
+ "d.attempt_count, s.callback_url, d.payload, "
+ "d.next_attempt_at, d.created_at, COALESCE(d.last_error, '') "
+ "FROM deliveries d "
+ "JOIN subscriptions s ON s.id = d.subscription_id "
+ "JOIN poll_state p ON p.singleton = 1 "
+ "WHERE d.state = 'PENDING' AND d.next_attempt_at <= "
+ "unixepoch() AND NOT EXISTS ("
+ "SELECT 1 FROM deliveries active "
+ "WHERE active.subscription_id = d.subscription_id "
+ "AND active.state = 'IN_FLIGHT') "
+ "ORDER BY d.next_attempt_at, d.id LIMIT 1;");
+ if(!rows.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(rows.error()));
+ }
+ if(rows->empty())
+ {
+ auto commit_result = execute(db, "COMMIT;");
+ if(!commit_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(commit_result.error()));
+ }
+ return std::nullopt;
+ }
+
+ const auto& row = (*rows)[0];
+ int64_t delivery_id = std::get<0>(row);
+ auto statement = db.statementFromStr(
+ "UPDATE deliveries SET state = 'IN_FLIGHT', "
+ "attempt_count = attempt_count + 1 "
+ "WHERE id = ? AND state = 'PENDING';");
+ if(!statement.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(delivery_id);
+ if(!bind_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto update_result = db.execute(std::move(*statement));
+ if(!update_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(update_result.error()));
+ }
+ auto commit_result = execute(db, "COMMIT;");
+ if(!commit_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(commit_result.error()));
+ }
+
+ return DeliveryJob{delivery_id,
+ std::get<1>(row),
+ std::get<2>(row),
+ std::get<3>(row),
+ std::get<4>(row) + 1,
+ std::get<7>(row),
+ std::get<8>(row),
+ std::get<9>(row),
+ std::get<5>(row),
+ std::get<6>(row)};
+}
+
+mw::E<void> DeliveryStore::complete(int64_t delivery_id) const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto statement = (*db_result)->statementFromStr(
+ "DELETE FROM deliveries WHERE id = ? AND state = 'IN_FLIGHT';");
+ if(!statement.has_value())
+ {
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(delivery_id);
+ if(!bind_result.has_value())
+ {
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto delete_result = (*db_result)->execute(std::move(*statement));
+ if(!delete_result.has_value())
+ {
+ return std::unexpected(databaseError(delete_result.error()));
+ }
+ return {};
+}
+
+mw::E<void> DeliveryStore::fail(int64_t delivery_id,
+ const std::string& error,
+ bool retryable,
+ std::optional<int> retry_after) const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ mw::SQLite& db = **db_result;
+ auto begin_result = execute(db, "BEGIN IMMEDIATE;");
+ if(!begin_result.has_value())
+ {
+ return std::unexpected(databaseError(begin_result.error()));
+ }
+ auto rollback = [&db]()
+ {
+ [[maybe_unused]] auto result = db.execute("ROLLBACK;");
+ };
+
+ auto state_statement = db.statementFromStr(
+ "SELECT attempt_count, created_at FROM deliveries "
+ "WHERE id = ? AND state = 'IN_FLIGHT';");
+ if(!state_statement.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(state_statement.error()));
+ }
+ auto bind_result = state_statement->bind(delivery_id);
+ if(!bind_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto rows = db.eval<int, int64_t>(std::move(*state_statement));
+ if(!rows.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(rows.error()));
+ }
+ if(rows->empty())
+ {
+ auto commit_result = execute(db, "COMMIT;");
+ if(!commit_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(commit_result.error()));
+ }
+ return {};
+ }
+ int attempts = std::get<0>((*rows)[0]);
+ int64_t created_at = std::get<1>((*rows)[0]);
+ int64_t current_time = nowSeconds();
+ constexpr int64_t MAX_DELIVERY_AGE = 24 * 60 * 60;
+ bool expired = current_time >= created_at &&
+ current_time - created_at >= MAX_DELIVERY_AGE;
+ bool dead = !retryable || attempts >= 8 || expired;
+ int64_t next_attempt = current_time;
+ if(!dead)
+ {
+ int exponent = std::min(attempts - 1, 8);
+ int64_t delay = 1LL << std::max(exponent, 0);
+ delay = std::min<int64_t>(delay, 300);
+ thread_local std::mt19937 random_generator(static_cast<unsigned>(
+ std::chrono::steady_clock::now().time_since_epoch().count()));
+ std::uniform_int_distribution<int64_t> jitter(
+ 0, std::max<int64_t>(delay / 4, 1));
+ delay += jitter(random_generator);
+ if(retry_after.has_value())
+ {
+ delay = std::max<int64_t>(delay, *retry_after);
+ }
+ int64_t age = current_time >= created_at ?
+ current_time - created_at : 0;
+ int64_t remaining = MAX_DELIVERY_AGE -
+ std::min(age, MAX_DELIVERY_AGE);
+ if(remaining <= 0 || delay > remaining)
+ {
+ dead = true;
+ }
+ else
+ {
+ next_attempt += delay;
+ }
+ }
+ std::string safe_error = error.substr(0, 512);
+ auto update = db.statementFromStr(
+ "UPDATE deliveries SET state = ?, next_attempt_at = ?, "
+ "last_error = ? WHERE id = ? AND state = 'IN_FLIGHT';");
+ if(!update.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(update.error()));
+ }
+ auto update_bind = update->bind(dead ? "DEAD" : "PENDING", next_attempt,
+ safe_error, delivery_id);
+ if(!update_bind.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(update_bind.error()));
+ }
+ auto update_result = db.execute(std::move(*update));
+ if(!update_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(update_result.error()));
+ }
+ auto commit_result = execute(db, "COMMIT;");
+ if(!commit_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(commit_result.error()));
+ }
+ return {};
+}
+
+mw::E<void> DeliveryStore::resetInFlight() const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto result = (*db_result)->execute(
+ "UPDATE deliveries SET "
+ "state = CASE WHEN attempt_count >= 8 OR "
+ "created_at <= unixepoch() - 86400 THEN 'DEAD' ELSE 'PENDING' END, "
+ "next_attempt_at = unixepoch(), "
+ "last_error = CASE WHEN attempt_count >= 8 OR "
+ "created_at <= unixepoch() - 86400 "
+ "THEN COALESCE(last_error, 'Delivery budget exhausted after restart') "
+ "ELSE last_error END "
+ "WHERE state = 'IN_FLIGHT';");
+ if(!result.has_value())
+ {
+ return std::unexpected(databaseError(result.error()));
+ }
+ return {};
+}
+
+mw::E<int64_t> DeliveryStore::count() const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto result = (*db_result)->evalToValue<int64_t>(
+ "SELECT COUNT(*) FROM deliveries;");
+ if(!result.has_value())
+ {
+ return std::unexpected(databaseError(result.error()));
+ }
+ return *result;
+}
+
+mw::E<std::vector<DeliveryJob>> DeliveryStore::listDead() const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto rows = (*db_result)->eval<int64_t, int64_t, int64_t, int64_t, int,
+ int64_t, int64_t, std::string>(
+ "SELECT d.id, p.bot_id, d.subscription_id, d.update_id, "
+ "d.attempt_count, d.next_attempt_at, d.created_at, "
+ "COALESCE(d.last_error, '') "
+ "FROM deliveries d "
+ "JOIN poll_state p ON p.singleton = 1 WHERE d.state = 'DEAD' "
+ "ORDER BY d.id;");
+ if(!rows.has_value())
+ {
+ return std::unexpected(databaseError(rows.error()));
+ }
+ std::vector<DeliveryJob> result;
+ result.reserve(rows->size());
+ for(const auto& row: *rows)
+ {
+ result.push_back({std::get<0>(row), std::get<1>(row),
+ std::get<2>(row), std::get<3>(row),
+ std::get<4>(row), std::get<5>(row),
+ std::get<6>(row), std::get<7>(row), {}, {}});
+ }
+ return result;
+}
+
+mw::E<bool> DeliveryStore::retry(int64_t delivery_id) const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto statement = (*db_result)->statementFromStr(
+ "UPDATE deliveries SET state = 'PENDING', attempt_count = 0, "
+ "next_attempt_at = unixepoch(), last_error = NULL "
+ "WHERE id = ? AND state = 'DEAD';");
+ if(!statement.has_value())
+ {
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(delivery_id);
+ if(!bind_result.has_value())
+ {
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto result = (*db_result)->execute(std::move(*statement));
+ if(!result.has_value())
+ {
+ return std::unexpected(databaseError(result.error()));
+ }
+ return (*db_result)->changedRowsCount() > 0;
+}
+
+mw::E<bool> DeliveryStore::deleteDead(int64_t delivery_id) const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto statement = (*db_result)->statementFromStr(
+ "DELETE FROM deliveries WHERE id = ? AND state = 'DEAD';");
+ if(!statement.has_value())
+ {
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(delivery_id);
+ if(!bind_result.has_value())
+ {
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto result = (*db_result)->execute(std::move(*statement));
+ if(!result.has_value())
+ {
+ return std::unexpected(databaseError(result.error()));
+ }
+ return (*db_result)->changedRowsCount() > 0;
+}
+
+mw::E<int64_t> DeliveryStore::purgeExpiredDead() const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto result = (*db_result)->execute(
+ "DELETE FROM deliveries WHERE state = 'DEAD' "
+ "AND next_attempt_at < unixepoch() - 604800;");
+ if(!result.has_value())
+ {
+ return std::unexpected(databaseError(result.error()));
+ }
+ return (*db_result)->changedRowsCount();
+}
+
+} // namespace telegrammer
diff --git a/src/delivery_store.hpp b/src/delivery_store.hpp
new file mode 100644
index 0000000..752bbc0
--- /dev/null
+++ b/src/delivery_store.hpp
@@ -0,0 +1,94 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+#include <mw/error.hpp>
+
+namespace telegrammer
+{
+
+/// JSON representation used at Telegram and callback boundaries.
+using json = nlohmann::json;
+
+/// Describes one durable callback attempt and its operator-visible metadata.
+struct DeliveryJob
+{
+ /// Database identifier for the delivery row.
+ int64_t id;
+ /// Telegram bot identity associated with the row.
+ int64_t bot_id;
+ /// Subscription that owns the callback.
+ int64_t subscription_id;
+ /// Telegram update identifier used for deduplication.
+ int64_t update_id;
+ /// Number of attempts already claimed, including the current claim.
+ int attempt_count;
+ /// Wall-clock time at which the next attempt may run.
+ int64_t next_attempt_at;
+ /// Wall-clock time at which the delivery was first queued.
+ int64_t created_at;
+ /// Sanitized failure reason retained for dead-letter inspection.
+ std::string last_error;
+ /// Callback destination for an active delivery.
+ std::string callback_url;
+ /// Original Telegram Message JSON for an active delivery.
+ std::string payload;
+};
+
+/// Owns the polling cursor, username observations, and callback queue.
+class DeliveryStore
+{
+public:
+ /// Construct a delivery store backed by the supplied SQLite database.
+ explicit DeliveryStore(std::string db_path);
+
+ /// Bind the database to one Telegram bot identity.
+ mw::E<void> ensureBot(int64_t bot_id) const;
+
+ /// Read the next persistent polling offset for the configured bot.
+ mw::E<int64_t> offset(int64_t bot_id) const;
+
+ /// Atomically ingest one validated Telegram update batch.
+ mw::E<void> ingest(int64_t bot_id, const json& updates) const;
+
+ /// Resolve a recently observed private-chat username.
+ mw::E<std::optional<int64_t>> resolveUsername(
+ const std::string& username) const;
+
+ /// Claim the oldest eligible callback job for one worker.
+ mw::E<std::optional<DeliveryJob>> claimNext() const;
+
+ /// Delete a successfully delivered job.
+ mw::E<void> complete(int64_t delivery_id) const;
+
+ /// Retry or dead-letter a failed callback attempt.
+ mw::E<void> fail(int64_t delivery_id, const std::string& error,
+ bool retryable, std::optional<int> retry_after) const;
+
+ /// Return in-flight jobs to the pending state after a restart.
+ mw::E<void> resetInFlight() const;
+
+ /// Return the total number of retained delivery rows.
+ mw::E<int64_t> count() const;
+
+ /// List retained dead-letter jobs for operator inspection.
+ mw::E<std::vector<DeliveryJob>> listDead() const;
+
+ /// Reset one dead-letter job for another delivery attempt.
+ mw::E<bool> retry(int64_t delivery_id) const;
+
+ /// Delete one dead-letter job.
+ mw::E<bool> deleteDead(int64_t delivery_id) const;
+
+ /// Purge dead-letter jobs older than the retention period.
+ mw::E<int64_t> purgeExpiredDead() const;
+
+private:
+ std::string db_path_;
+};
+
+} // namespace telegrammer
diff --git a/src/dispatcher.cpp b/src/dispatcher.cpp
new file mode 100644
index 0000000..3304501
--- /dev/null
+++ b/src/dispatcher.cpp
@@ -0,0 +1,262 @@
+#include "dispatcher.hpp"
+
+#include <chrono>
+#include <cctype>
+#include <ctime>
+#include <format>
+#include <optional>
+#include <string>
+
+#include <curl/curl.h>
+#include <mw/http_client.hpp>
+#include <spdlog/spdlog.h>
+
+#include "service_error.hpp"
+
+namespace telegrammer
+{
+
+namespace
+{
+
+std::optional<int> retryAfterDate(const std::string& value)
+{
+ time_t retry_time = curl_getdate(value.c_str(), nullptr);
+ time_t current_time = std::time(nullptr);
+ if(retry_time < current_time || retry_time - current_time > 86400)
+ {
+ return std::nullopt;
+ }
+ return static_cast<int>(retry_time - current_time);
+}
+
+std::optional<int> retryAfter(const mw::HTTPResponse& response)
+{
+ auto iterator = response.header.find("Retry-After");
+ if(iterator == response.header.end())
+ {
+ iterator = response.header.find("retry-after");
+ }
+ if(iterator == response.header.end())
+ {
+ return std::nullopt;
+ }
+ try
+ {
+ std::size_t position = 0;
+ int value = std::stoi(iterator->second, &position);
+ if(position != iterator->second.size() || value < 0 || value > 86400)
+ {
+ return retryAfterDate(iterator->second);
+ }
+ return value;
+ }
+ catch(const std::exception&)
+ {
+ return retryAfterDate(iterator->second);
+ }
+}
+
+std::string deliveryId(const DeliveryJob& job)
+{
+ return std::format("{}-{}-{}", job.bot_id, job.subscription_id,
+ job.update_id);
+}
+
+} // namespace
+
+Dispatcher::Dispatcher(DeliveryStore& store, std::size_t worker_count)
+ : store_(store), worker_count_(worker_count)
+{}
+
+bool Dispatcher::shouldPurge()
+{
+ std::lock_guard lock(purge_mutex_);
+ auto now = std::chrono::steady_clock::now();
+ if(now < next_purge_)
+ {
+ return false;
+ }
+ next_purge_ = now + std::chrono::hours(1);
+ return true;
+}
+
+void Dispatcher::start()
+{
+ for(std::size_t i = 0; i < worker_count_; ++i)
+ {
+ workers_.emplace_back([this](std::stop_token stop_token)
+ {
+ run(stop_token);
+ });
+ }
+}
+
+void Dispatcher::stop()
+{
+ for(std::jthread& worker: workers_)
+ {
+ worker.request_stop();
+ }
+ condition_.notify_all();
+ workers_.clear();
+}
+
+void Dispatcher::notify()
+{
+ condition_.notify_all();
+}
+
+void Dispatcher::run(std::stop_token stop_token)
+{
+ while(!stop_token.stop_requested())
+ {
+ if(shouldPurge())
+ {
+ auto purged = store_.purgeExpiredDead();
+ if(!purged.has_value())
+ {
+ spdlog::error("Unable to purge dead callback jobs: {}",
+ mw::errorMsg(purged.error()));
+ }
+ else if(*purged > 0)
+ {
+ spdlog::warn("Purged {} expired dead callback jobs", *purged);
+ }
+ }
+
+ std::optional<DeliveryJob> active_job;
+ try
+ {
+ auto result = store_.claimNext();
+ if(!result.has_value())
+ {
+ spdlog::error("Unable to claim callback job: {}",
+ mw::errorMsg(result.error()));
+ std::unique_lock lock(mutex_);
+ condition_.wait_for(lock, stop_token,
+ std::chrono::seconds(1),
+ [] { return false; });
+ continue;
+ }
+ if(!result->has_value())
+ {
+ std::unique_lock lock(mutex_);
+ condition_.wait_for(lock, stop_token,
+ std::chrono::seconds(1),
+ [] { return false; });
+ continue;
+ }
+
+ DeliveryJob job = std::move(result->value());
+ active_job = job;
+ mw::HTTPSession session;
+ auto connection = session.connectionTimeout(
+ std::chrono::seconds(3));
+ auto transfer = session.transferTimeout(std::chrono::seconds(10));
+ auto size = session.maxSize(64 * 1024);
+ auto protocols = session.allowedProtocols("http,https");
+ session.followRedirects(false);
+ if(!connection.has_value() || !transfer.has_value() ||
+ !size.has_value() || !protocols.has_value())
+ {
+ auto failure = store_.fail(
+ job.id, "Unable to configure callback HTTP client", true,
+ std::nullopt);
+ if(!failure.has_value())
+ {
+ spdlog::error("Unable to record callback failure {}: {}",
+ job.id, mw::errorMsg(failure.error()));
+ }
+ active_job.reset();
+ continue;
+ }
+
+ mw::HTTPRequest request(job.callback_url);
+ request.setContentType("application/json");
+ request.addHeader("X-Telegrammer-Delivery-Id", deliveryId(job));
+ request.setPayload(job.payload);
+ auto response = session.post(request);
+ if(response.has_value() && (*response)->status >= 200 &&
+ (*response)->status < 300)
+ {
+ auto complete = store_.complete(job.id);
+ if(!complete.has_value())
+ {
+ spdlog::error("Unable to complete callback job {}: {}",
+ job.id, mw::errorMsg(complete.error()));
+ auto failure = store_.fail(
+ job.id, "Unable to record callback completion", true,
+ std::nullopt);
+ if(!failure.has_value())
+ {
+ spdlog::error("Unable to recover callback job {}: {}",
+ job.id, mw::errorMsg(failure.error()));
+ }
+ }
+ active_job.reset();
+ continue;
+ }
+
+ bool should_retry = true;
+ std::optional<int> retry_after;
+ std::string failure_message = "Callback request failed";
+ if(response.has_value())
+ {
+ int status = (*response)->status;
+ should_retry = status == 408 || status == 429 || status >= 500;
+ retry_after = retryAfter(**response);
+ failure_message = std::format("Callback returned HTTP {}",
+ status);
+ }
+ else
+ {
+ failure_message = "Callback transport failed";
+ }
+ auto failure = store_.fail(job.id, failure_message, should_retry,
+ retry_after);
+ if(!failure.has_value())
+ {
+ spdlog::error("Unable to record callback failure {}: {}",
+ job.id, mw::errorMsg(failure.error()));
+ }
+ active_job.reset();
+ }
+ catch(const std::exception& error)
+ {
+ spdlog::error("Callback worker failed: {}", error.what());
+ if(active_job.has_value())
+ {
+ auto failure = store_.fail(active_job->id, "Callback worker failed",
+ true, std::nullopt);
+ if(!failure.has_value())
+ {
+ spdlog::error("Unable to recover callback job {}: {}",
+ active_job->id, mw::errorMsg(failure.error()));
+ }
+ }
+ std::unique_lock lock(mutex_);
+ condition_.wait_for(lock, stop_token, std::chrono::seconds(1),
+ [] { return false; });
+ }
+ catch(...)
+ {
+ spdlog::error("Callback worker failed with an unknown exception");
+ if(active_job.has_value())
+ {
+ auto failure = store_.fail(active_job->id, "Callback worker failed",
+ true, std::nullopt);
+ if(!failure.has_value())
+ {
+ spdlog::error("Unable to recover callback job {}: {}",
+ active_job->id, mw::errorMsg(failure.error()));
+ }
+ }
+ std::unique_lock lock(mutex_);
+ condition_.wait_for(lock, stop_token, std::chrono::seconds(1),
+ [] { return false; });
+ }
+ }
+}
+
+} // namespace telegrammer
diff --git a/src/dispatcher.hpp b/src/dispatcher.hpp
new file mode 100644
index 0000000..5c23380
--- /dev/null
+++ b/src/dispatcher.hpp
@@ -0,0 +1,44 @@
+#pragma once
+
+#include <condition_variable>
+#include <cstddef>
+#include <chrono>
+#include <mutex>
+#include <thread>
+#include <vector>
+
+#include "delivery_store.hpp"
+
+namespace telegrammer
+{
+
+/// Delivers queued callbacks with bounded concurrency and retries.
+class Dispatcher
+{
+public:
+ /// Construct a dispatcher with a fixed number of callback workers.
+ Dispatcher(DeliveryStore& store, std::size_t worker_count = 4);
+
+ /// Start callback workers.
+ void start();
+
+ /// Request workers to stop and wait for active callbacks.
+ void stop();
+
+ /// Wake workers after new delivery jobs are committed.
+ void notify();
+
+private:
+ DeliveryStore& store_;
+ std::size_t worker_count_;
+ std::mutex mutex_;
+ std::condition_variable_any condition_;
+ std::mutex purge_mutex_;
+ std::chrono::steady_clock::time_point next_purge_{};
+ std::vector<std::jthread> workers_;
+
+ bool shouldPurge();
+ void run(std::stop_token stop_token);
+};
+
+} // namespace telegrammer
diff --git a/src/key_store.cpp b/src/key_store.cpp
new file mode 100644
index 0000000..7ce4c58
--- /dev/null
+++ b/src/key_store.cpp
@@ -0,0 +1,258 @@
+#include "key_store.hpp"
+
+#include <array>
+#include <format>
+#include <openssl/evp.h>
+#include <openssl/rand.h>
+
+#include "database.hpp"
+#include "service_error.hpp"
+
+namespace telegrammer
+{
+
+namespace
+{
+
+mw::E<void> execute(mw::SQLite& db, const std::string& sql)
+{
+ auto result = db.execute(sql);
+ if(!result.has_value())
+ {
+ return std::unexpected(result.error());
+ }
+ return {};
+}
+
+mw::Error databaseError([[maybe_unused]] const mw::Error& error)
+{
+ return serviceError(503, "DATABASE_UNAVAILABLE",
+ "The state database is unavailable");
+}
+
+bool isUniqueError(const mw::Error& error)
+{
+ return mw::errorMsg(error).find("UNIQUE") != std::string::npos;
+}
+
+} // namespace
+
+KeyStore::KeyStore(std::string db_path)
+ : db_path_(std::move(db_path))
+{}
+
+mw::E<std::string> KeyStore::generateKey()
+{
+ std::array<unsigned char, 32> bytes{};
+ if(RAND_priv_bytes(bytes.data(), static_cast<int>(bytes.size())) != 1)
+ {
+ return std::unexpected(serviceError(
+ 500, "KEY_GENERATION_FAILED", "Unable to generate API key"));
+ }
+
+ std::string key;
+ key.reserve(bytes.size() * 2);
+ for(unsigned char byte: bytes)
+ {
+ key += std::format("{:02x}", byte);
+ }
+ return key;
+}
+
+mw::E<std::string> KeyStore::digestKey(const std::string& key)
+{
+ std::array<unsigned char, EVP_MAX_MD_SIZE> digest{};
+ unsigned int digest_size = 0;
+ EVP_MD_CTX* context = EVP_MD_CTX_new();
+ if(context == nullptr)
+ {
+ return std::unexpected(serviceError(
+ 500, "KEY_HASH_FAILED", "Unable to initialize key hashing"));
+ }
+
+ bool success = EVP_DigestInit_ex(context, EVP_sha256(), nullptr) == 1 &&
+ EVP_DigestUpdate(context, key.data(), key.size()) == 1 &&
+ EVP_DigestFinal_ex(context, digest.data(), &digest_size) ==
+ 1;
+ EVP_MD_CTX_free(context);
+ if(!success || digest_size != 32)
+ {
+ return std::unexpected(serviceError(
+ 500, "KEY_HASH_FAILED", "Unable to hash API key"));
+ }
+
+ std::string result;
+ result.reserve(digest_size * 2);
+ for(unsigned int i = 0; i < digest_size; ++i)
+ {
+ result += std::format("{:02x}", digest[i]);
+ }
+ return result;
+}
+
+mw::E<std::string> KeyStore::addKey(const std::string& name) const
+{
+ if(name.empty() || name.size() > 128)
+ {
+ return std::unexpected(serviceError(
+ 400, "INVALID_KEY_NAME", "Key name must be 1 to 128 bytes"));
+ }
+
+ auto key = generateKey();
+ if(!key.has_value())
+ {
+ return std::unexpected(key.error());
+ }
+ auto digest = digestKey(*key);
+ if(!digest.has_value())
+ {
+ return std::unexpected(digest.error());
+ }
+
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ mw::SQLite& db = **db_result;
+
+ auto statement = db.statementFromStr(
+ "INSERT INTO api_keys (name, key_digest, created_at) "
+ "VALUES (?, ?, ?);");
+ if(!statement.has_value())
+ {
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(name, *digest, nowSeconds());
+ if(!bind_result.has_value())
+ {
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto insert_result = db.execute(std::move(*statement));
+ if(!insert_result.has_value())
+ {
+ if(isUniqueError(insert_result.error()))
+ {
+ return std::unexpected(serviceError(
+ 409, "KEY_EXISTS", "A key with that name already exists"));
+ }
+ return std::unexpected(databaseError(insert_result.error()));
+ }
+
+ return *key;
+}
+
+mw::E<std::optional<KeyIdentity>> KeyStore::authenticate(
+ const std::string& key) const
+{
+ auto digest = digestKey(key);
+ if(!digest.has_value())
+ {
+ return std::unexpected(digest.error());
+ }
+
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ mw::SQLite& db = **db_result;
+
+ auto statement = db.statementFromStr(
+ "SELECT id, name FROM api_keys WHERE key_digest = ? LIMIT 1;");
+ if(!statement.has_value())
+ {
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(*digest);
+ if(!bind_result.has_value())
+ {
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto rows = db.eval<int64_t, std::string>(std::move(*statement));
+ if(!rows.has_value())
+ {
+ return std::unexpected(databaseError(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::nullopt;
+ }
+ return KeyIdentity{std::get<0>((*rows)[0]), std::get<1>((*rows)[0])};
+}
+
+mw::E<std::vector<KeyInfo>> KeyStore::listKeys() const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto rows = (*db_result)->eval<int64_t, std::string, int64_t>(
+ "SELECT id, name, created_at FROM api_keys ORDER BY id;");
+ if(!rows.has_value())
+ {
+ return std::unexpected(databaseError(rows.error()));
+ }
+
+ std::vector<KeyInfo> result;
+ result.reserve(rows->size());
+ for(const auto& row: *rows)
+ {
+ result.push_back(
+ {std::get<0>(row), std::get<1>(row), std::get<2>(row)});
+ }
+ return result;
+}
+
+mw::E<bool> KeyStore::deleteKey(const std::string& name) const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ mw::SQLite& db = **db_result;
+
+ auto begin_result = execute(db, "BEGIN IMMEDIATE;");
+ if(!begin_result.has_value())
+ {
+ return std::unexpected(databaseError(begin_result.error()));
+ }
+
+ auto rollback = [&db]()
+ {
+ [[maybe_unused]] auto result = db.execute("ROLLBACK;");
+ };
+
+ auto statement = db.statementFromStr(
+ "DELETE FROM api_keys WHERE name = ?;");
+ if(!statement.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(name);
+ if(!bind_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto delete_result = db.execute(std::move(*statement));
+ if(!delete_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(delete_result.error()));
+ }
+ bool deleted = db.changedRowsCount() > 0;
+
+ auto commit_result = execute(db, "COMMIT;");
+ if(!commit_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(commit_result.error()));
+ }
+ return deleted;
+}
+
+} // namespace telegrammer
diff --git a/src/key_store.hpp b/src/key_store.hpp
new file mode 100644
index 0000000..ccd626a
--- /dev/null
+++ b/src/key_store.hpp
@@ -0,0 +1,60 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <mw/error.hpp>
+
+namespace telegrammer
+{
+
+/// Identifies the API key that authenticated a request.
+struct KeyIdentity
+{
+ /// Stable database identity of the key.
+ int64_t id;
+ /// Operator-assigned key name.
+ std::string name;
+};
+
+/// Metadata exposed by key listing without credential material.
+struct KeyInfo
+{
+ /// Stable database identity of the key.
+ int64_t id;
+ /// Operator-assigned key name.
+ std::string name;
+ /// Creation time as Unix seconds.
+ int64_t created_at;
+};
+
+/// Provides credential creation, lookup, listing, and revocation.
+class KeyStore
+{
+public:
+ /// Construct a store backed by the supplied SQLite database.
+ explicit KeyStore(std::string db_path);
+
+ /// Generate and persist a new bearer credential.
+ mw::E<std::string> addKey(const std::string& name) const;
+
+ /// Resolve a bearer credential to its owning identity.
+ mw::E<std::optional<KeyIdentity>> authenticate(
+ const std::string& key) const;
+
+ /// List key metadata without returning credential material.
+ mw::E<std::vector<KeyInfo>> listKeys() const;
+
+ /// Revoke a key and its owned subscriptions and deliveries.
+ mw::E<bool> deleteKey(const std::string& name) const;
+
+private:
+ std::string db_path_;
+
+ static mw::E<std::string> generateKey();
+ static mw::E<std::string> digestKey(const std::string& key);
+};
+
+} // namespace telegrammer
diff --git a/src/main.cpp b/src/main.cpp
index 0530e94..9984264 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,538 +1,234 @@
+#include <cstdint>
#include <iostream>
+#include <memory>
#include <string>
-#include <vector>
-#include <map>
-#include <mutex>
-#include <thread>
-#include <chrono>
-#include <expected>
-#include <optional>
-#include <random>
-#include <filesystem>
+#include <string_view>
+#include <utility>
#include <cxxopts.hpp>
-#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>
-#include <mw/http_server.hpp>
-#include <mw/http_client.hpp>
-#include <mw/url.hpp>
-#include <mw/error.hpp>
-#include <mw/utils.hpp>
-#include <mw/database.hpp>
-using json = nlohmann::json;
+#include "application.hpp"
+#include "database.hpp"
+#include "delivery_store.hpp"
+#include "key_store.hpp"
+#include "service_error.hpp"
-class ApiKeyManager
+namespace telegrammer
{
-public:
- explicit ApiKeyManager(const std::string& db_path)
- {
- auto db_res = mw::SQLite::connectFile(db_path);
- if(!db_res.has_value())
- {
- throw std::runtime_error("Failed to connect to DB: " +
- mw::errorMsg(db_res.error()));
- }
- db_ = std::move(*db_res);
-
- auto create_res = db_->execute(
- "CREATE TABLE IF NOT EXISTS api_keys ("
- "name TEXT PRIMARY KEY, "
- "key TEXT NOT NULL, "
- "created_at INTEGER DEFAULT (unixepoch())"
- ");");
- if(!create_res.has_value())
- {
- throw std::runtime_error("Failed to create table: " +
- mw::errorMsg(create_res.error()));
- }
- }
-
- mw::E<std::string> addKey(const std::string& name)
- {
- std::string key = generateKey();
- auto stmt = db_->statementFromStr(
- "INSERT INTO api_keys (name, key) VALUES (?, ?);");
- if(!stmt.has_value()) return std::unexpected(stmt.error());
-
- auto bind_res = stmt->bind(name, key);
- if(!bind_res.has_value()) return std::unexpected(bind_res.error());
-
- auto exec_res = db_->execute(std::move(*stmt));
- if(!exec_res.has_value()) return std::unexpected(exec_res.error());
-
- return key;
- }
-
- mw::E<void> deleteKey(const std::string& name)
- {
- auto stmt = db_->statementFromStr(
- "DELETE FROM api_keys WHERE name = ?;");
- if(!stmt.has_value()) return std::unexpected(stmt.error());
-
- auto bind_res = stmt->bind(name);
- if(!bind_res.has_value()) return std::unexpected(bind_res.error());
-
- return db_->execute(std::move(*stmt));
- }
-
- mw::E<std::vector<std::pair<std::string, std::string>>> listKeys()
- {
- return db_->eval<std::string, std::string>(
- "SELECT name, key FROM api_keys;")
- .transform([](auto rows)
- {
- std::vector<std::pair<std::string, std::string>> keys;
- for(const auto& row : rows)
- {
- keys.emplace_back(std::get<0>(row), std::get<1>(row));
- }
- return keys;
- });
- }
-
- bool isValid(const std::string& key)
- {
- auto stmt = db_->statementFromStr(
- "SELECT COUNT(*) FROM api_keys WHERE key = ?;");
- if(!stmt.has_value()) return false;
- stmt->bind(key);
- auto res = db_->evalToValue<int>(std::move(*stmt));
- return res.has_value() && *res > 0;
- }
-
-private:
- std::unique_ptr<mw::SQLite> db_;
- std::string generateKey()
- {
- static const char alphanum[] =
- "0123456789"
- "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
- "abcdefghijklmnopqrstuvwxyz";
- std::random_device rd;
- std::mt19937 gen(rd());
- std::uniform_int_distribution<> dis(0, sizeof(alphanum) - 2);
-
- std::string key;
- key.reserve(32);
- for(int i = 0; i < 32; ++i)
- {
- key += alphanum[dis(gen)];
- }
- return key;
- }
-};
-
-class TelegramClient
+namespace
{
-public:
- explicit TelegramClient(std::string token)
- : token_(std::move(token)),
- base_url_(std::format("https://api.telegram.org/bot{}/", token_))
- {}
-
- mw::E<json> sendMessage(int64_t chat_id, const std::string& text)
- {
- mw::HTTPRequest req(base_url_ + "sendMessage");
- req.setContentType("application/json");
- req.setPayload(json{{"chat_id", chat_id}, {"text", text}}.dump());
- mw::HTTPSession session;
- auto res = session.post(req);
- if(!res.has_value())
- {
- return std::unexpected(res.error());
- }
-
- return json::parse((*res)->payloadAsStr());
- }
-
- mw::E<json> getUpdates(int64_t offset, int timeout)
- {
- std::string url = std::format("{}getUpdates?offset={}&timeout={}",
- base_url_, offset, timeout);
- mw::HTTPSession session;
- auto res = session.get(url);
- if(!res.has_value())
- {
- return std::unexpected(res.error());
- }
-
- return json::parse((*res)->payloadAsStr());
- }
-
-private:
- std::string token_;
- std::string base_url_;
-};
-
-class SubscriptionManager
+bool hasManagementCommand(const cxxopts::ParseResult& result)
{
-public:
- void addSubscription(int64_t chat_id, std::string callback_url)
- {
- std::lock_guard lock(mutex_);
- subscribers_[chat_id].push_back(std::move(callback_url));
- }
+ return result.count("add-key") || result.count("delete-key") ||
+ result.count("list-keys") ||
+ result.count("list-failed-deliveries") ||
+ result.count("retry-delivery") || result.count("delete-delivery");
+}
- std::vector<std::string> getSubscribers(int64_t chat_id)
- {
- std::lock_guard lock(mutex_);
- if(auto it = subscribers_.find(chat_id); it != subscribers_.end())
- {
- return it->second;
- }
- return {};
- }
-
-private:
- std::mutex mutex_;
- std::map<int64_t, std::vector<std::string>> subscribers_;
-};
-
-class UsernameResolver
+int managementCommandCount(const cxxopts::ParseResult& result)
+{
+ return static_cast<int>(result.count("add-key")) +
+ static_cast<int>(result.count("delete-key")) +
+ static_cast<int>(result.count("list-keys")) +
+ static_cast<int>(result.count("list-failed-deliveries")) +
+ static_cast<int>(result.count("retry-delivery")) +
+ static_cast<int>(result.count("delete-delivery"));
+}
+
+void logError(std::string_view action, const mw::Error& error)
{
-public:
- void update(const std::string& username, int64_t chat_id)
+ if(const ServiceError* service_error = asServiceError(error);
+ service_error != nullptr)
{
- std::lock_guard lock(mutex_);
- username_map_[username] = chat_id;
+ spdlog::error("{} [{}]: {}", action, service_error->code,
+ service_error->msg);
+ return;
}
+ spdlog::error("{}: {}", action, mw::errorMsg(error));
+}
- std::optional<int64_t> resolve(const std::string& username)
+int runManagementCommand(const cxxopts::ParseResult& result,
+ const std::string& db_path)
+{
+ auto init_result = initializeDatabase(db_path);
+ if(!init_result.has_value())
{
- std::lock_guard lock(mutex_);
- if(auto it = username_map_.find(username); it != username_map_.end())
- {
- return it->second;
- }
- return std::nullopt;
+ logError("Unable to initialize database", init_result.error());
+ return 1;
}
-private:
- std::mutex mutex_;
- std::map<std::string, int64_t> username_map_;
-};
-
-class App : public mw::HTTPServer
-{
-public:
- App(mw::IPSocketInfo listen_info, std::string token,
- std::shared_ptr<ApiKeyManager> key_manager)
- : mw::HTTPServer(listen_info),
- tg_client_(std::move(token)),
- key_manager_(std::move(key_manager))
- {}
-
- void setup()
+ if(result.count("add-key") || result.count("delete-key") ||
+ result.count("list-keys"))
{
- server.Post("/send", [this](const Request& req, Response& res)
+ KeyStore keys(db_path);
+ if(result.count("add-key"))
{
- if(!checkAuth(req))
- {
- res.status = 401;
- res.set_content("Unauthorized", "text/plain");
- return;
- }
-
- try
+ auto key = keys.addKey(result["add-key"].as<std::string>());
+ if(!key.has_value())
{
- auto body = json::parse(req.body);
- if(!body.contains("text"))
- {
- res.status = 400;
- res.set_content("Missing text", "text/plain");
- return;
- }
-
- int64_t chat_id = 0;
- if(body.contains("chat_id"))
- {
- chat_id = body["chat_id"];
- }
- else if(body.contains("username"))
- {
- auto username = body["username"].get<std::string>();
- auto resolved = username_resolver_.resolve(username);
- if(!resolved.has_value())
- {
- res.status = 404;
- res.set_content("Username not found", "text/plain");
- return;
- }
- chat_id = *resolved;
- }
- else
- {
- res.status = 400;
- res.set_content("Missing chat_id or username",
- "text/plain");
- return;
- }
-
- ASSIGN_OR_RESPOND_ERROR(auto result,
- tg_client_.sendMessage(chat_id,
- body["text"]),
- res);
- res.status = 200;
- res.set_content(result.dump(), "application/json");
- }
- catch(const std::exception& e)
- {
- res.status = 400;
- res.set_content(e.what(), "text/plain");
+ logError("Unable to add API key", key.error());
+ return 1;
}
- });
-
- server.Post("/subscribe", [this](const Request& req, Response& res)
+ std::cout << *key << std::endl;
+ return 0;
+ }
+ if(result.count("delete-key"))
{
- if(!checkAuth(req))
+ auto deleted = keys.deleteKey(
+ result["delete-key"].as<std::string>());
+ if(!deleted.has_value())
{
- res.status = 401;
- res.set_content("Unauthorized", "text/plain");
- return;
- }
-
- try
- {
- auto body = json::parse(req.body);
- if(!body.contains("chat_id") || !body.contains("callback_url"))
- {
- res.status = 400;
- res.set_content("Missing chat_id or callback_url",
- "text/plain");
- return;
- }
-
- sub_manager_.addSubscription(body["chat_id"],
- body["callback_url"]);
- res.status = 200;
- res.set_content("Subscribed", "text/plain");
+ logError("Unable to delete API key", deleted.error());
+ return 1;
}
- catch(const std::exception& e)
+ if(!*deleted)
{
- res.status = 400;
- res.set_content(e.what(), "text/plain");
+ spdlog::error("API key was not found");
+ return 1;
}
- });
- }
-
- void runPolling()
- {
- int64_t offset = 0;
- while(running_)
+ return 0;
+ }
+ auto keys_result = keys.listKeys();
+ if(!keys_result.has_value())
{
- auto updates = tg_client_.getUpdates(offset, 30);
- if(!updates.has_value())
- {
- spdlog::error("Failed to get updates: {}",
- mw::errorMsg(updates.error()));
- std::this_thread::sleep_for(std::chrono::seconds(5));
- continue;
- }
-
- if((*updates)["ok"] == true)
- {
- for(const auto& update : (*updates)["result"])
- {
- offset = update["update_id"].get<int64_t>() + 1;
- if(update.contains("message"))
- {
- dispatchMessage(update["message"]);
- }
- }
- }
+ logError("Unable to list API keys", keys_result.error());
+ return 1;
}
- }
-
- void stopPolling()
- {
- running_ = false;
- }
-
-private:
- bool checkAuth(const Request& req)
- {
- if(!req.has_header("Authorization")) return false;
- std::string auth = req.get_header_value("Authorization");
- if(auth.starts_with("Bearer "))
+ for(const KeyInfo& key: *keys_result)
{
- std::string key = auth.substr(7);
- return key_manager_->isValid(key);
+ std::cout << key.name << " " << key.created_at << std::endl;
}
- return false;
+ return 0;
}
- void dispatchMessage(const json& message)
+ DeliveryStore deliveries(db_path);
+ if(result.count("list-failed-deliveries"))
{
- int64_t chat_id = message["chat"]["id"];
-
- if(message.contains("from") && message["from"].contains("username"))
+ auto failed = deliveries.listDead();
+ if(!failed.has_value())
{
- std::string username = message["from"]["username"];
- username_resolver_.update(username, chat_id);
+ logError("Unable to list failed deliveries", failed.error());
+ return 1;
}
-
- auto callbacks = sub_manager_.getSubscribers(chat_id);
-
- for(const auto& url : callbacks)
+ for(const DeliveryJob& job: *failed)
{
- std::thread([url, message]()
- {
- mw::HTTPSession session;
- mw::HTTPRequest req(url);
- req.setContentType("application/json");
- req.setPayload(message.dump());
- auto res = session.post(req);
- if(!res.has_value())
- {
- spdlog::error("Failed to post to callback {}: {}", url,
- mw::errorMsg(res.error()));
- }
- }).detach();
+ std::cout << job.id << " " << job.subscription_id << " "
+ << job.update_id << " " << job.created_at << " "
+ << job.next_attempt_at << " " << job.attempt_count
+ << " " << job.last_error << std::endl;
}
+ return 0;
}
+ if(result.count("retry-delivery"))
+ {
+ auto retried = deliveries.retry(
+ result["retry-delivery"].as<int64_t>());
+ if(!retried.has_value())
+ {
+ logError("Unable to retry delivery", retried.error());
+ return 1;
+ }
+ return *retried ? 0 : 1;
+ }
+ auto deleted = deliveries.deleteDead(
+ result["delete-delivery"].as<int64_t>());
+ if(!deleted.has_value())
+ {
+ logError("Unable to delete delivery", deleted.error());
+ return 1;
+ }
+ return *deleted ? 0 : 1;
+}
- TelegramClient tg_client_;
- SubscriptionManager sub_manager_;
- UsernameResolver username_resolver_;
- std::shared_ptr<ApiKeyManager> key_manager_;
- bool running_ = true;
-};
+} // namespace
+
+} // namespace telegrammer
int main(int argc, char** argv)
{
- cxxopts::Options cmd_options("telegrammer", "Telegram Bot API Gateway");
- cmd_options.add_options()
- ("t,token", "Telegram Bot Token", cxxopts::value<std::string>())
+ cxxopts::Options options("telegrammer", "Telegram Bot API Gateway");
+ options.add_options()
+ ("t,token", "Telegram Bot Token",
+ cxxopts::value<std::string>())
("p,port", "Port to listen on",
cxxopts::value<int>()->default_value("8080"))
("h,host", "Interface to bind to",
- cxxopts::value<std::string>()->default_value("0.0.0.0"))
+ cxxopts::value<std::string>()->default_value("127.0.0.1"))
("db", "Path to database file",
cxxopts::value<std::string>()->default_value("telegrammer.db"))
- ("add-key", "Add a new API key with name",
+ ("add-key", "Generate a new API key",
cxxopts::value<std::string>())
("delete-key", "Delete an API key by name",
cxxopts::value<std::string>())
- ("list-keys", "List all API keys")
+ ("list-keys", "List API key metadata")
+ ("list-failed-deliveries", "List dead-letter callback jobs")
+ ("retry-delivery", "Retry a dead-letter callback job",
+ cxxopts::value<int64_t>())
+ ("delete-delivery", "Delete a dead-letter callback job",
+ cxxopts::value<int64_t>())
("help", "Print help");
try
{
- auto result = cmd_options.parse(argc, argv);
-
+ auto result = options.parse(argc, argv);
if(result.count("help"))
{
- std::cout << cmd_options.help() << std::endl;
+ std::cout << options.help() << std::endl;
return 0;
}
- std::string db_path = result["db"].as<std::string>();
- auto key_manager = std::make_shared<ApiKeyManager>(db_path);
-
- if(result.count("add-key"))
+ if(telegrammer::managementCommandCount(result) > 1)
{
- std::string name = result["add-key"].as<std::string>();
- auto key = key_manager->addKey(name);
- if(key.has_value())
- {
- std::cout << "Added key for '" << name << "': " << *key
- << std::endl;
- return 0;
- }
- else
- {
- spdlog::error("Failed to add key: {}",
- mw::errorMsg(key.error()));
- return 1;
- }
+ spdlog::error("Management commands are mutually exclusive");
+ return 1;
}
- if(result.count("delete-key"))
+ std::string db_path = result["db"].as<std::string>();
+ if(telegrammer::hasManagementCommand(result))
{
- std::string name = result["delete-key"].as<std::string>();
- auto res = key_manager->deleteKey(name);
- if(res.has_value())
- {
- std::cout << "Deleted key for '" << name << "'" << std::endl;
- return 0;
- }
- else
- {
- spdlog::error("Failed to delete key: {}",
- mw::errorMsg(res.error()));
- return 1;
- }
+ return telegrammer::runManagementCommand(result, db_path);
}
-
- if(result.count("list-keys"))
+ if(!result.count("token"))
{
- auto keys = key_manager->listKeys();
- if(keys.has_value())
- {
- std::cout << "API Keys:" << std::endl;
- for(const auto& [name, key] : *keys)
- {
- std::cout << "- " << name << ": " << key << std::endl;
- }
- return 0;
- }
- else
- {
- spdlog::error("Failed to list keys: {}",
- mw::errorMsg(keys.error()));
- return 1;
- }
+ spdlog::error("Token is required to start the server");
+ std::cout << options.help() << std::endl;
+ return 1;
}
-
- if(!result.count("token"))
+ if(result["token"].as<std::string>().empty())
{
- spdlog::error("Token is required to start server. "
- "Use --token <TOKEN>");
- std::cout << cmd_options.help() << std::endl;
+ spdlog::error("Token must not be empty");
return 1;
}
-
- std::string token = result["token"].as<std::string>();
- std::string host = result["host"].as<std::string>();
int port = result["port"].as<int>();
-
- mw::IPSocketInfo listen_info;
- listen_info.address = host;
- listen_info.port = port;
-
- App app(listen_info, token, key_manager);
- app.setup();
-
- auto start_res = app.start();
- if(!start_res.has_value())
+ if(port <= 0 || port > 65535)
{
- spdlog::error("Failed to start server: {}",
- mw::errorMsg(start_res.error()));
+ spdlog::error("Port must be between 1 and 65535");
return 1;
}
- spdlog::info("Server listening on {}:{}", host, port);
-
- std::thread polling_thread([&app]()
+ auto database_lock = std::make_unique<telegrammer::DatabaseLock>(
+ db_path);
+ auto init_result = telegrammer::initializeDatabase(db_path);
+ if(!init_result.has_value())
{
- app.runPolling();
- });
-
- app.wait();
- app.stopPolling();
- if(polling_thread.joinable())
- {
- polling_thread.join();
+ telegrammer::logError("Unable to initialize database",
+ init_result.error());
+ return 1;
}
+
+ telegrammer::ApplicationConfig config{
+ {result["host"].as<std::string>(), port},
+ result["token"].as<std::string>(), db_path,
+ std::move(database_lock)};
+ telegrammer::Application application(std::move(config));
+ return application.run();
}
- catch(const std::exception& e)
+ catch(const std::exception& error)
{
- spdlog::error("Error: {}", e.what());
+ spdlog::error("Error: {}", error.what());
return 1;
}
-
- return 0;
-}
\ No newline at end of file
+}
diff --git a/src/poller.cpp b/src/poller.cpp
new file mode 100644
index 0000000..69ed3d1
--- /dev/null
+++ b/src/poller.cpp
@@ -0,0 +1,198 @@
+#include "poller.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <optional>
+#include <random>
+
+#include <spdlog/spdlog.h>
+
+#include "database.hpp"
+#include "service_error.hpp"
+
+namespace telegrammer
+{
+
+Poller::Poller(TelegramApi& client, DeliveryStore& store,
+ Dispatcher& dispatcher, RuntimeState& state, int64_t bot_id)
+ : client_(client), store_(store), dispatcher_(dispatcher), state_(state),
+ bot_id_(bot_id)
+{}
+
+void Poller::start()
+{
+ worker_ = std::jthread([this](std::stop_token stop_token)
+ {
+ run(stop_token);
+ });
+}
+
+void Poller::setBotId(int64_t bot_id)
+{
+ bot_id_ = bot_id;
+}
+
+void Poller::stop()
+{
+ worker_.request_stop();
+ condition_.notify_all();
+ if(worker_.joinable())
+ {
+ worker_.join();
+ }
+}
+
+bool Poller::wait(std::stop_token stop_token,
+ std::chrono::milliseconds duration)
+{
+ std::unique_lock lock(mutex_);
+ return condition_.wait_for(lock, stop_token, duration,
+ [] { return false; });
+}
+
+void Poller::run(std::stop_token stop_token)
+{
+ int backoff_seconds = 1;
+ int consecutive_failures = 0;
+ auto first_failure = std::chrono::steady_clock::time_point{};
+ std::mt19937 random_generator(static_cast<unsigned>(
+ std::chrono::steady_clock::now().time_since_epoch().count()));
+
+ auto markFailure = [&](bool permanent)
+ {
+ auto current = std::chrono::steady_clock::now();
+ if(consecutive_failures == 0)
+ {
+ first_failure = current;
+ }
+ ++consecutive_failures;
+ bool persistent = permanent || consecutive_failures >= 5 ||
+ current - first_failure >= std::chrono::minutes(5);
+ state_.degraded = persistent;
+ if(permanent)
+ {
+ state_.polling_ready = false;
+ }
+ };
+ auto markSuccess = [&]()
+ {
+ consecutive_failures = 0;
+ first_failure = {};
+ state_.polling_ready = true;
+ state_.degraded = false;
+ state_.last_success = nowSeconds();
+ };
+ auto waitBackoff = [&](std::optional<int> minimum_delay = std::nullopt)
+ {
+ int delay_seconds = backoff_seconds;
+ if(minimum_delay.has_value())
+ {
+ delay_seconds = std::max(delay_seconds, *minimum_delay);
+ }
+ std::uniform_int_distribution<int> jitter(
+ 0, std::max(delay_seconds * 250, 1));
+ wait(stop_token,
+ std::chrono::seconds(delay_seconds) +
+ std::chrono::milliseconds(jitter(random_generator)));
+ backoff_seconds = std::min(backoff_seconds * 2, 60);
+ };
+
+ while(!stop_token.stop_requested())
+ {
+ try
+ {
+ auto offset = store_.offset(bot_id_);
+ if(!offset.has_value())
+ {
+ const ServiceError* error = asServiceError(offset.error());
+ bool permanent = error != nullptr && !error->retryable;
+ markFailure(permanent);
+ spdlog::error("Unable to read polling state: {}",
+ mw::errorMsg(offset.error()));
+ if(permanent)
+ {
+ break;
+ }
+ waitBackoff();
+ continue;
+ }
+
+ auto updates = client_.getUpdates(*offset, 30);
+ if(!updates.has_value())
+ {
+ const ServiceError* error = asServiceError(updates.error());
+ bool permanent = error != nullptr && !error->retryable;
+ markFailure(permanent);
+ if(error != nullptr)
+ {
+ spdlog::error("Telegram polling failed [{}]: {}",
+ error->code, error->msg);
+ if(permanent)
+ {
+ break;
+ }
+ }
+ else
+ {
+ spdlog::error("Telegram polling failed: {}",
+ mw::errorMsg(updates.error()));
+ }
+ waitBackoff(error == nullptr ? std::nullopt :
+ error->retry_after);
+ continue;
+ }
+
+ auto ingest_result = store_.ingest(bot_id_, (*updates)["result"]);
+ if(!ingest_result.has_value())
+ {
+ const ServiceError* error =
+ asServiceError(ingest_result.error());
+ bool permanent = error != nullptr &&
+ (!error->retryable ||
+ error->code == "QUEUE_FULL");
+ markFailure(permanent);
+ if(error != nullptr)
+ {
+ spdlog::error("Unable to ingest Telegram updates [{}]: {}",
+ error->code, error->msg);
+ }
+ else
+ {
+ spdlog::error("Unable to ingest Telegram updates: {}",
+ mw::errorMsg(ingest_result.error()));
+ }
+ waitBackoff();
+ continue;
+ }
+
+ markSuccess();
+ backoff_seconds = 1;
+ dispatcher_.notify();
+
+ // Add bounded jitter to avoid synchronized retries after an
+ // outage. A successful long poll normally does not wait here.
+ if((*updates)["result"].empty())
+ {
+ std::uniform_int_distribution<int> jitter(0, 250);
+ wait(stop_token,
+ std::chrono::seconds(0) +
+ std::chrono::milliseconds(jitter(random_generator)));
+ }
+ }
+ catch(const std::exception& error)
+ {
+ markFailure(false);
+ spdlog::error("Polling worker failed: {}", error.what());
+ waitBackoff();
+ }
+ catch(...)
+ {
+ markFailure(false);
+ spdlog::error("Polling worker failed with an unknown exception");
+ waitBackoff();
+ }
+ }
+ state_.polling_ready = false;
+}
+
+} // namespace telegrammer
diff --git a/src/poller.hpp b/src/poller.hpp
new file mode 100644
index 0000000..04848f9
--- /dev/null
+++ b/src/poller.hpp
@@ -0,0 +1,48 @@
+#pragma once
+
+#include <condition_variable>
+#include <chrono>
+#include <cstdint>
+#include <mutex>
+#include <thread>
+
+#include "dispatcher.hpp"
+#include "runtime_state.hpp"
+#include "telegram_client.hpp"
+
+namespace telegrammer
+{
+
+/// Long-polls Telegram and commits updates before advancing the offset.
+class Poller
+{
+public:
+ /// Construct a poller for one bot identity and persistent state database.
+ Poller(TelegramApi& client, DeliveryStore& store,
+ Dispatcher& dispatcher, RuntimeState& state, int64_t bot_id);
+
+ /// Start the polling thread.
+ void start();
+
+ /// Set the validated Telegram bot identity before polling starts.
+ void setBotId(int64_t bot_id);
+
+ /// Request polling to stop and wait for the thread.
+ void stop();
+
+private:
+ TelegramApi& client_;
+ DeliveryStore& store_;
+ Dispatcher& dispatcher_;
+ RuntimeState& state_;
+ int64_t bot_id_;
+ std::mutex mutex_;
+ std::condition_variable_any condition_;
+ std::jthread worker_;
+
+ void run(std::stop_token stop_token);
+ bool wait(std::stop_token stop_token,
+ std::chrono::milliseconds duration);
+};
+
+} // namespace telegrammer
diff --git a/src/runtime_state.hpp b/src/runtime_state.hpp
new file mode 100644
index 0000000..be323a6
--- /dev/null
+++ b/src/runtime_state.hpp
@@ -0,0 +1,20 @@
+#pragma once
+
+#include <atomic>
+#include <cstdint>
+
+namespace telegrammer
+{
+
+/// Publishes coarse daemon health state without exposing sensitive data.
+struct RuntimeState
+{
+ /// Whether the polling component is currently able to run.
+ std::atomic<bool> polling_ready = false;
+ /// Whether polling has entered a persistent degraded state.
+ std::atomic<bool> degraded = true;
+ /// Unix seconds of the last successfully committed poll batch.
+ std::atomic<int64_t> last_success = 0;
+};
+
+} // namespace telegrammer
diff --git a/src/service_error.hpp b/src/service_error.hpp
new file mode 100644
index 0000000..2cbc796
--- /dev/null
+++ b/src/service_error.hpp
@@ -0,0 +1,43 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <string_view>
+
+#include <mw/error.hpp>
+
+namespace telegrammer
+{
+
+/// Describes an expected failure and its local HTTP interpretation.
+struct ServiceError
+{
+ /// HTTP status used when this error crosses the API boundary.
+ int status = 500;
+ /// Stable machine-readable error code.
+ std::string code;
+ /// Sanitized human-readable explanation.
+ std::string msg;
+ /// Optional client retry delay in seconds.
+ std::optional<int> retry_after;
+ /// Whether background workers may retry the operation.
+ bool retryable = false;
+};
+
+/// Construct a typed service error for an expected failure.
+inline mw::Error serviceError(int status, std::string_view code,
+ std::string_view message,
+ std::optional<int> retry_after = std::nullopt,
+ bool retryable = false)
+{
+ return ServiceError{status, std::string(code), std::string(message),
+ retry_after, retryable};
+}
+
+/// Return the typed service error stored in a libmw error, if any.
+inline const ServiceError* asServiceError(const mw::Error& error)
+{
+ return error.as<ServiceError>();
+}
+
+} // namespace telegrammer
diff --git a/src/subscription_store.cpp b/src/subscription_store.cpp
new file mode 100644
index 0000000..605b76c
--- /dev/null
+++ b/src/subscription_store.cpp
@@ -0,0 +1,268 @@
+#include "subscription_store.hpp"
+
+#include <string>
+#include <utility>
+
+#include "database.hpp"
+#include "service_error.hpp"
+
+namespace telegrammer
+{
+
+namespace
+{
+
+mw::E<void> execute(mw::SQLite& db, const std::string& sql)
+{
+ auto result = db.execute(sql);
+ if(!result.has_value())
+ {
+ return std::unexpected(result.error());
+ }
+ return {};
+}
+
+mw::Error databaseError([[maybe_unused]] const mw::Error& error)
+{
+ return serviceError(503, "DATABASE_UNAVAILABLE",
+ "The state database is unavailable");
+}
+
+mw::E<Subscription> readSubscription(mw::SQLite& db, int64_t id)
+{
+ auto statement = db.statementFromStr(
+ "SELECT id, owner_id, chat_id, callback_url, created_at "
+ "FROM subscriptions WHERE id = ?;");
+ if(!statement.has_value())
+ {
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(id);
+ if(!bind_result.has_value())
+ {
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto rows = db.eval<int64_t, int64_t, int64_t, std::string, int64_t>(
+ std::move(*statement));
+ if(!rows.has_value())
+ {
+ return std::unexpected(databaseError(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::unexpected(serviceError(
+ 500, "DATABASE_INCONSISTENT", "Subscription disappeared"));
+ }
+ const auto& row = (*rows)[0];
+ return Subscription{std::get<0>(row), std::get<1>(row),
+ std::get<2>(row), std::get<3>(row),
+ std::get<4>(row)};
+}
+
+} // namespace
+
+SubscriptionStore::SubscriptionStore(std::string db_path)
+ : db_path_(std::move(db_path))
+{}
+
+mw::E<Subscription> SubscriptionStore::add(
+ int64_t owner_id, int64_t chat_id, const std::string& callback_url) const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ mw::SQLite& db = **db_result;
+
+ auto begin_result = execute(db, "BEGIN IMMEDIATE;");
+ if(!begin_result.has_value())
+ {
+ return std::unexpected(databaseError(begin_result.error()));
+ }
+ auto rollback = [&db]()
+ {
+ [[maybe_unused]] auto result = db.execute("ROLLBACK;");
+ };
+
+ auto existing = db.statementFromStr(
+ "SELECT id FROM subscriptions "
+ "WHERE owner_id = ? AND chat_id = ? AND callback_url = ?;");
+ if(!existing.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(existing.error()));
+ }
+ auto existing_bind = existing->bind(owner_id, chat_id, callback_url);
+ if(!existing_bind.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(existing_bind.error()));
+ }
+ auto existing_rows = db.eval<int64_t>(std::move(*existing));
+ if(!existing_rows.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(existing_rows.error()));
+ }
+ if(!existing_rows->empty())
+ {
+ auto subscription = readSubscription(
+ db, std::get<0>((*existing_rows)[0]));
+ if(!subscription.has_value())
+ {
+ rollback();
+ return std::unexpected(subscription.error());
+ }
+ auto commit_result = execute(db, "COMMIT;");
+ if(!commit_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(commit_result.error()));
+ }
+ return *subscription;
+ }
+
+ auto owner_count = db.statementFromStr(
+ "SELECT COUNT(*) FROM subscriptions WHERE owner_id = ?;");
+ if(!owner_count.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(owner_count.error()));
+ }
+ auto owner_bind = owner_count->bind(owner_id);
+ if(!owner_bind.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(owner_bind.error()));
+ }
+ auto owner_rows = db.evalToValue<int64_t>(std::move(*owner_count));
+ if(!owner_rows.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(owner_rows.error()));
+ }
+ if(*owner_rows >= 100)
+ {
+ rollback();
+ return std::unexpected(serviceError(
+ 409, "SUBSCRIPTION_LIMIT", "The key has too many subscriptions"));
+ }
+
+ auto total_rows = db.evalToValue<int64_t>(
+ "SELECT COUNT(*) FROM subscriptions;");
+ if(!total_rows.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(total_rows.error()));
+ }
+ if(*total_rows >= 1000)
+ {
+ rollback();
+ return std::unexpected(serviceError(
+ 409, "SUBSCRIPTION_LIMIT", "The daemon has too many subscriptions"));
+ }
+
+ auto statement = db.statementFromStr(
+ "INSERT INTO subscriptions "
+ "(owner_id, chat_id, callback_url, created_at) "
+ "VALUES (?, ?, ?, ?);");
+ if(!statement.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(owner_id, chat_id, callback_url,
+ nowSeconds());
+ if(!bind_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto insert_result = db.execute(std::move(*statement));
+ if(!insert_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(insert_result.error()));
+ }
+ int64_t id = db.lastInsertRowID();
+ auto subscription = readSubscription(db, id);
+ if(!subscription.has_value())
+ {
+ rollback();
+ return std::unexpected(subscription.error());
+ }
+ auto commit_result = execute(db, "COMMIT;");
+ if(!commit_result.has_value())
+ {
+ rollback();
+ return std::unexpected(databaseError(commit_result.error()));
+ }
+ return *subscription;
+}
+
+mw::E<std::vector<Subscription>> SubscriptionStore::list(
+ int64_t owner_id) const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto statement = (*db_result)->statementFromStr(
+ "SELECT id, owner_id, chat_id, callback_url, created_at "
+ "FROM subscriptions WHERE owner_id = ? ORDER BY id;");
+ if(!statement.has_value())
+ {
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(owner_id);
+ if(!bind_result.has_value())
+ {
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto rows = (*db_result)->eval<int64_t, int64_t, int64_t, std::string,
+ int64_t>(std::move(*statement));
+ if(!rows.has_value())
+ {
+ return std::unexpected(databaseError(rows.error()));
+ }
+ std::vector<Subscription> result;
+ result.reserve(rows->size());
+ for(const auto& row: *rows)
+ {
+ result.push_back({std::get<0>(row), std::get<1>(row),
+ std::get<2>(row), std::get<3>(row),
+ std::get<4>(row)});
+ }
+ return result;
+}
+
+mw::E<bool> SubscriptionStore::remove(int64_t owner_id,
+ int64_t subscription_id) const
+{
+ auto db_result = openDatabase(db_path_);
+ if(!db_result.has_value())
+ {
+ return std::unexpected(db_result.error());
+ }
+ auto statement = (*db_result)->statementFromStr(
+ "DELETE FROM subscriptions WHERE id = ? AND owner_id = ?;");
+ if(!statement.has_value())
+ {
+ return std::unexpected(databaseError(statement.error()));
+ }
+ auto bind_result = statement->bind(subscription_id, owner_id);
+ if(!bind_result.has_value())
+ {
+ return std::unexpected(databaseError(bind_result.error()));
+ }
+ auto delete_result = (*db_result)->execute(std::move(*statement));
+ if(!delete_result.has_value())
+ {
+ return std::unexpected(databaseError(delete_result.error()));
+ }
+ return (*db_result)->changedRowsCount() > 0;
+}
+
+} // namespace telegrammer
diff --git a/src/subscription_store.hpp b/src/subscription_store.hpp
new file mode 100644
index 0000000..6d79254
--- /dev/null
+++ b/src/subscription_store.hpp
@@ -0,0 +1,48 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include <mw/error.hpp>
+
+namespace telegrammer
+{
+
+/// Durable callback registration owned by one API key.
+struct Subscription
+{
+ /// Stable subscription identifier.
+ int64_t id;
+ /// API-key identity that owns the registration.
+ int64_t owner_id;
+ /// Telegram chat receiving the subscription.
+ int64_t chat_id;
+ /// Canonical HTTP or HTTPS callback URL.
+ std::string callback_url;
+ /// Creation time as Unix seconds.
+ int64_t created_at;
+};
+
+/// Persists callback subscriptions and enforces their ownership limits.
+class SubscriptionStore
+{
+public:
+ /// Construct a store backed by the supplied SQLite database.
+ explicit SubscriptionStore(std::string db_path);
+
+ /// Insert an owned subscription, or return the existing identical row.
+ mw::E<Subscription> add(int64_t owner_id, int64_t chat_id,
+ const std::string& callback_url) const;
+
+ /// List subscriptions owned by one API key.
+ mw::E<std::vector<Subscription>> list(int64_t owner_id) const;
+
+ /// Delete an owned subscription and its queued deliveries.
+ mw::E<bool> remove(int64_t owner_id, int64_t subscription_id) const;
+
+private:
+ std::string db_path_;
+};
+
+} // namespace telegrammer
diff --git a/src/telegram_client.cpp b/src/telegram_client.cpp
new file mode 100644
index 0000000..6cf2675
--- /dev/null
+++ b/src/telegram_client.cpp
@@ -0,0 +1,263 @@
+#include "telegram_client.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <cctype>
+#include <format>
+#include <optional>
+#include <string_view>
+
+#include <mw/http_client.hpp>
+#include <mw/url.hpp>
+
+#include "service_error.hpp"
+
+namespace telegrammer
+{
+
+namespace
+{
+
+std::string lower(std::string value)
+{
+ std::transform(value.begin(), value.end(), value.begin(),
+ [](unsigned char character)
+ {
+ return static_cast<char>(std::tolower(character));
+ });
+ return value;
+}
+
+std::optional<int> retryAfter(const json& body)
+{
+ if(!body.is_object() || !body.contains("parameters") ||
+ !body["parameters"].is_object() ||
+ !body["parameters"].contains("retry_after") ||
+ !body["parameters"]["retry_after"].is_number_integer())
+ {
+ return std::nullopt;
+ }
+
+ int64_t value = 0;
+ try
+ {
+ value = body["parameters"]["retry_after"].get<int64_t>();
+ }
+ catch(const std::exception&)
+ {
+ return std::nullopt;
+ }
+ if(value <= 0 || value > 3600)
+ {
+ return std::nullopt;
+ }
+ return static_cast<int>(value);
+}
+
+mw::Error telegramFailure(int status, const json& body)
+{
+ int error_code = 0;
+ if(body.is_object() && body.contains("error_code") &&
+ body["error_code"].is_number_integer())
+ {
+ try
+ {
+ error_code = body["error_code"].get<int>();
+ }
+ catch(const std::exception&)
+ {
+ error_code = 0;
+ }
+ }
+
+ std::string description = "Telegram rejected the request";
+ if(body.is_object() && body.contains("description") &&
+ body["description"].is_string())
+ {
+ description = body["description"].get<std::string>();
+ }
+
+ std::optional<int> retry_after = retryAfter(body);
+ if(status == 429 || error_code == 429 || retry_after.has_value())
+ {
+ return serviceError(429, "TELEGRAM_RATE_LIMITED", description,
+ retry_after, true);
+ }
+ if(status == 400 || status == 403 || error_code == 400 ||
+ error_code == 403)
+ {
+ return serviceError(422, "TELEGRAM_REJECTED", description);
+ }
+ if(status == 401 || error_code == 401)
+ {
+ return serviceError(502, "UPSTREAM_ERROR", description);
+ }
+ return serviceError(502, "UPSTREAM_ERROR", description,
+ std::nullopt, status >= 500);
+}
+
+bool looksLikeTimeout(const std::string& message)
+{
+ std::string value = lower(message);
+ return value.find("timeout") != std::string::npos ||
+ value.find("timed out") != std::string::npos;
+}
+
+} // namespace
+
+TelegramClient::TelegramClient(std::string token)
+ : token_(std::move(token)),
+ base_url_(std::format("https://api.telegram.org/bot{}/", token_))
+{}
+
+mw::E<json> TelegramClient::request(const std::string& method,
+ const std::string& payload,
+ bool post) const
+{
+ mw::HTTPSession session;
+ auto connection_result = session.connectionTimeout(
+ std::chrono::seconds(5));
+ auto transfer_result = session.transferTimeout(
+ std::chrono::seconds(post ? 10 : 40));
+ auto size_result = session.maxSize(16 * 1024 * 1024);
+ auto protocol_result = session.allowedProtocols("https");
+ session.followRedirects(false);
+ if(!connection_result.has_value() || !transfer_result.has_value() ||
+ !size_result.has_value() || !protocol_result.has_value())
+ {
+ return std::unexpected(serviceError(
+ 500, "HTTP_CLIENT_CONFIGURATION", "Unable to configure HTTP"));
+ }
+
+ mw::HTTPResponse const* response = nullptr;
+ if(post)
+ {
+ mw::HTTPRequest request(base_url_ + method);
+ request.setContentType("application/json");
+ request.setPayload(payload);
+ auto result = session.post(request);
+ if(!result.has_value())
+ {
+ std::string message = mw::errorMsg(result.error());
+ if(looksLikeTimeout(message))
+ {
+ return std::unexpected(serviceError(
+ 504, "UPSTREAM_TIMEOUT", "Telegram request timed out",
+ std::nullopt, true));
+ }
+ return std::unexpected(serviceError(
+ 502, "UPSTREAM_UNAVAILABLE", "Telegram is unavailable",
+ std::nullopt, true));
+ }
+ response = *result;
+ }
+ else
+ {
+ auto result = session.get(base_url_ + method + payload);
+ if(!result.has_value())
+ {
+ std::string message = mw::errorMsg(result.error());
+ if(looksLikeTimeout(message))
+ {
+ return std::unexpected(serviceError(
+ 504, "UPSTREAM_TIMEOUT", "Telegram request timed out",
+ std::nullopt, true));
+ }
+ return std::unexpected(serviceError(
+ 502, "UPSTREAM_UNAVAILABLE", "Telegram is unavailable",
+ std::nullopt, true));
+ }
+ response = *result;
+ }
+
+ json body;
+ try
+ {
+ body = json::parse(response->payloadAsStr());
+ }
+ catch(const std::exception&)
+ {
+ return std::unexpected(serviceError(
+ 502, "UPSTREAM_ERROR", "Telegram returned invalid JSON"));
+ }
+
+ bool successful = response->status >= 200 && response->status < 300 &&
+ body.is_object() && body.contains("ok") &&
+ body["ok"].is_boolean() && body["ok"].get<bool>();
+ if(!successful)
+ {
+ return std::unexpected(telegramFailure(response->status, body));
+ }
+ if(!body.contains("result"))
+ {
+ return std::unexpected(serviceError(
+ 502, "UPSTREAM_ERROR", "Telegram returned an incomplete response"));
+ }
+ return body;
+}
+
+mw::E<json> TelegramClient::getMe() const
+{
+ auto result = request("getMe", "", false);
+ if(!result.has_value())
+ {
+ return std::unexpected(result.error());
+ }
+ if(!(*result)["result"].is_object() ||
+ !(*result)["result"].contains("id") ||
+ !(*result)["result"]["id"].is_number_integer())
+ {
+ return std::unexpected(serviceError(
+ 502, "UPSTREAM_ERROR", "Telegram returned an invalid bot identity"));
+ }
+ try
+ {
+ [[maybe_unused]] auto bot_id =
+ (*result)["result"]["id"].get<int64_t>();
+ }
+ catch(const std::exception&)
+ {
+ return std::unexpected(serviceError(
+ 502, "UPSTREAM_ERROR", "Telegram returned an invalid bot identity"));
+ }
+ return *result;
+}
+
+mw::E<json> TelegramClient::sendMessage(int64_t chat_id,
+ const std::string& text) const
+{
+ auto result = request("sendMessage", json{{"chat_id", chat_id},
+ {"text", text}}
+ .dump(),
+ true);
+ if(!result.has_value())
+ {
+ return std::unexpected(result.error());
+ }
+ if(!(*result)["result"].is_object())
+ {
+ return std::unexpected(serviceError(
+ 502, "UPSTREAM_ERROR", "Telegram returned an invalid message"));
+ }
+ return *result;
+}
+
+mw::E<json> TelegramClient::getUpdates(int64_t offset, int timeout) const
+{
+ std::string query = std::format(
+ "?offset={}&timeout={}&allowed_updates={}", offset, timeout,
+ mw::URL::encode("[\"message\"]"));
+ auto result = request("getUpdates", query, false);
+ if(!result.has_value())
+ {
+ return std::unexpected(result.error());
+ }
+ if(!(*result)["result"].is_array())
+ {
+ return std::unexpected(serviceError(
+ 502, "UPSTREAM_ERROR", "Telegram returned invalid updates"));
+ }
+ return *result;
+}
+
+} // namespace telegrammer
diff --git a/src/telegram_client.hpp b/src/telegram_client.hpp
new file mode 100644
index 0000000..baace9e
--- /dev/null
+++ b/src/telegram_client.hpp
@@ -0,0 +1,58 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+
+#include <nlohmann/json.hpp>
+#include <mw/error.hpp>
+
+namespace telegrammer
+{
+
+using json = nlohmann::json;
+
+/// Abstracts the Telegram operations consumed by the daemon.
+class TelegramApi
+{
+public:
+ /// Destroy an API implementation through its interface.
+ virtual ~TelegramApi() = default;
+
+ /// Validate the token and return the bot identity envelope.
+ virtual mw::E<json> getMe() const = 0;
+
+ /// Send one text message and return Telegram's successful envelope.
+ virtual mw::E<json> sendMessage(int64_t chat_id,
+ const std::string& text) const = 0;
+
+ /// Long-poll Telegram for message updates.
+ virtual mw::E<json> getUpdates(int64_t offset, int timeout) const = 0;
+};
+
+/// Provides the production HTTPS implementation of the Telegram API.
+class TelegramClient final : public TelegramApi
+{
+public:
+ /// Construct a client for one bot token.
+ explicit TelegramClient(std::string token);
+
+ /// Validate the token and return the bot identity envelope.
+ mw::E<json> getMe() const override;
+
+ /// Send one text message and return Telegram's successful envelope.
+ mw::E<json> sendMessage(int64_t chat_id,
+ const std::string& text) const override;
+
+ /// Long-poll Telegram for message updates.
+ mw::E<json> getUpdates(int64_t offset, int timeout) const override;
+
+private:
+ std::string token_;
+ std::string base_url_;
+
+ mw::E<json> request(const std::string& method,
+ const std::string& payload,
+ bool post) const;
+};
+
+} // namespace telegrammer
diff --git a/test_api.sh b/test_api.sh
old mode 100644
new mode 100755
index ef325b2..b64e166
--- a/test_api.sh
+++ b/test_api.sh
@@ -1,38 +1,55 @@
-#!/bin/bash
+#!/usr/bin/env bash
-# Simple test script for Telegrammer API
-# NOTE: This requires Telegrammer to be running.
-# Usage: ./test_api.sh <API_KEY>
-# If no key provided, it assumes you've added one manually or disabled auth (not possible anymore).
+set -euo pipefail
-API_URL="http://localhost:8080"
-API_KEY="$1"
+api_url="${API_URL:-http://127.0.0.1:8080}"
+api_key="${API_KEY:-${1:-}}"
+if [[ -z "${api_key}" ]]; then
+ echo "Usage: API_KEY=... $0"
+ exit 2
+fi
+
+body_file="$(mktemp)"
+trap 'rm -f "${body_file}"' EXIT
+
+request()
+{
+ local expected_status="$1"
+ shift
+ local actual_status
+ actual_status="$(curl --silent --show-error --output "${body_file}" \
+ --write-out '%{http_code}' "$@")"
+ if [[ "${actual_status}" != "${expected_status}" ]]; then
+ echo "Expected HTTP ${expected_status}, got ${actual_status}" >&2
+ exit 1
+ fi
+}
+
+request 401 "${api_url}/health"
+grep -q '"code":"UNAUTHORIZED"' "${body_file}"
-if [ -z "$API_KEY" ]; then
- echo "Error: API Key required."
- echo "Usage: $0 <API_KEY>"
+auth_header="Authorization: Bearer ${api_key}"
+request 200 --header "${auth_header}" "${api_url}/subscriptions"
+grep -q '"subscriptions"' "${body_file}"
+
+request 200 --header "${auth_header}" \
+ --header 'Content-Type: application/json' \
+ --data '{"chat_id":123456789,"callback_url":"http://127.0.0.1:9/telegrammer-smoke"}' \
+ "${api_url}/subscribe"
+grep -q '"subscription_id"' "${body_file}"
+subscription_id="$(sed -n 's/.*"subscription_id":\([0-9][0-9]*\).*/\1/p' "${body_file}")"
+if [[ -z "${subscription_id}" ]]; then
+ echo "The subscribe response did not contain a subscription ID" >&2
exit 1
fi
-echo "Using API Key: $API_KEY"
-
-echo "Testing /subscribe..."
-curl -X POST "$API_URL/subscribe" \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer $API_KEY" \
- -d '{"chat_id": 12345, "callback_url": "http://localhost:9090/webhook"}'
-echo -e "\n"
-
-echo "Testing /send (should fail if token is invalid, but test API structure)..."
-curl -X POST "$API_URL/send" \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer $API_KEY" \
- -d '{"chat_id": 12345, "text": "Test message"}'
-echo -e "\n"
-
-echo "Testing /send with username (requires prior message from user)..."
-curl -X POST "$API_URL/send" \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer $API_KEY" \
- -d '{"username": "some_user", "text": "Test message to username"}'
-echo -e "\n"
\ No newline at end of file
+request 200 --header "${auth_header}" \
+ --header 'Content-Type: application/json' \
+ --data '{"chat_id":123456789,"callback_url":"http://127.0.0.1:9/telegrammer-smoke"}' \
+ "${api_url}/subscribe"
+grep -q "\"subscription_id\":${subscription_id}" "${body_file}"
+
+request 204 --request DELETE --header "${auth_header}" \
+ "${api_url}/subscriptions/${subscription_id}"
+
+echo "Telegrammer API smoke test passed"
diff --git a/tests/core_test.cpp b/tests/core_test.cpp
new file mode 100644
index 0000000..abc1d0c
--- /dev/null
+++ b/tests/core_test.cpp
@@ -0,0 +1,332 @@
+#include <filesystem>
+#include <format>
+#include <chrono>
+#include <iostream>
+#include <netinet/in.h>
+#include <stdexcept>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include <httplib.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include "api_server.hpp"
+#include "database.hpp"
+#include "delivery_store.hpp"
+#include "key_store.hpp"
+#include "subscription_store.hpp"
+#include "telegram_client.hpp"
+#include "service_error.hpp"
+
+namespace
+{
+
+std::filesystem::path makeDirectory()
+{
+ std::string pattern = "/tmp/telegrammer-test-XXXXXX";
+ std::vector<char> buffer(pattern.begin(), pattern.end());
+ buffer.push_back('\0');
+ char* result = ::mkdtemp(buffer.data());
+ if(result == nullptr)
+ {
+ throw std::runtime_error("mkdtemp failed");
+ }
+ return result;
+}
+
+void require(bool condition, std::string_view message)
+{
+ if(!condition)
+ {
+ throw std::runtime_error(std::string(message));
+ }
+}
+
+int freePort()
+{
+ int socket_fd = ::socket(AF_INET, SOCK_STREAM, 0);
+ require(socket_fd >= 0, "socket failed");
+ sockaddr_in address{};
+ address.sin_family = AF_INET;
+ address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ address.sin_port = 0;
+ require(::bind(socket_fd, reinterpret_cast<sockaddr*>(&address),
+ sizeof(address)) == 0,
+ "bind failed");
+ socklen_t length = sizeof(address);
+ require(::getsockname(socket_fd, reinterpret_cast<sockaddr*>(&address),
+ &length) == 0,
+ "getsockname failed");
+ int port = ntohs(address.sin_port);
+ ::close(socket_fd);
+ return port;
+}
+
+class FakeTelegram final : public telegrammer::TelegramApi
+{
+public:
+ mutable int64_t sent_chat_id = 0;
+ mutable std::string sent_text;
+
+ mw::E<telegrammer::json> getMe() const override
+ {
+ return telegrammer::json{
+ {"ok", true}, {"result", {{"id", 77}, {"is_bot", true}}}};
+ }
+
+ mw::E<telegrammer::json> sendMessage(
+ int64_t chat_id, const std::string& text) const override
+ {
+ sent_chat_id = chat_id;
+ sent_text = text;
+ return telegrammer::json{
+ {"ok", true},
+ {"result", {{"message_id", 42}, {"chat", {{"id", chat_id}}}}}};
+ }
+
+ mw::E<telegrammer::json> getUpdates(
+ int64_t, int) const override
+ {
+ return telegrammer::json{
+ {"ok", true}, {"result", telegrammer::json::array()}};
+ }
+};
+
+void requireJsonResponse(const httplib::Result& response, int status,
+ std::string_view message)
+{
+ require(response != nullptr, message);
+ require(response->status == status, message);
+}
+
+} // namespace
+
+int main()
+{
+ auto directory = makeDirectory();
+ try
+ {
+ auto database = directory / "state.db";
+ std::string database_path = database.string();
+
+ auto initialized = telegrammer::initializeDatabase(database_path);
+ require(initialized.has_value(), "database initialization failed");
+ auto database_connection = telegrammer::openDatabase(database_path);
+ require(database_connection.has_value(), "database could not be reopened");
+ auto user_version = (*database_connection)->evalToValue<int64_t>(
+ "PRAGMA user_version;");
+ require(user_version.has_value() && *user_version == 0,
+ "development schema unexpectedly changed user_version");
+
+ telegrammer::DatabaseLock first_lock(database_path);
+ bool second_lock_rejected = false;
+ try
+ {
+ telegrammer::DatabaseLock second_lock(database_path);
+ }
+ catch(const std::exception&)
+ {
+ second_lock_rejected = true;
+ }
+ require(second_lock_rejected, "database lock was not exclusive");
+
+ telegrammer::KeyStore keys(database_path);
+ auto token = keys.addKey("test-client");
+ require(token.has_value(), "key generation failed");
+ require(token->size() == 64, "generated key has the wrong length");
+
+ auto identity = keys.authenticate(*token);
+ require(identity.has_value() && identity->has_value(),
+ "generated key did not authenticate");
+ require(identity->value().name == "test-client", "wrong key identity");
+
+ auto listed_keys = keys.listKeys();
+ require(listed_keys.has_value() && listed_keys->size() == 1,
+ "key listing failed");
+ require(listed_keys->at(0).name == "test-client",
+ "key listing returned the wrong name");
+
+ telegrammer::SubscriptionStore subscriptions(database_path);
+ auto subscription = subscriptions.add(identity->value().id, 123,
+ "http://127.0.0.1/hook");
+ require(subscription.has_value(), "subscription creation failed");
+ auto duplicate = subscriptions.add(identity->value().id, 123,
+ "http://127.0.0.1/hook");
+ require(duplicate.has_value(), "duplicate subscription failed");
+ require(duplicate->id == subscription->id,
+ "duplicate subscription was not idempotent");
+
+ telegrammer::DeliveryStore deliveries(database_path);
+ require(deliveries.ensureBot(77).has_value(), "bot binding failed");
+ telegrammer::json updates = telegrammer::json::array({telegrammer::json{
+ {"update_id", 1},
+ {"message", {
+ {"message_id", 5},
+ {"from", {{"username", "Test_User"}}},
+ {"chat", {{"id", 123}, {"type", "private"}}},
+ {"text", "hello"}
+ }}}});
+ require(deliveries.ingest(77, updates).has_value(),
+ "update ingestion failed");
+
+ auto resolved = deliveries.resolveUsername("test_user");
+ require(resolved.has_value() && resolved->has_value(),
+ "username was not observed");
+ require(resolved->value() == 123, "username resolved to the wrong chat");
+
+ auto job = deliveries.claimNext();
+ require(job.has_value() && job->has_value(), "delivery was not queued");
+ require(job->value().subscription_id == subscription->id,
+ "delivery has the wrong subscription");
+ require(job->value().payload.find("hello") != std::string::npos,
+ "delivery payload was not preserved");
+ require(deliveries.complete(job->value().id).has_value(),
+ "delivery completion failed");
+
+ auto failed_update = telegrammer::json::array({telegrammer::json{
+ {"update_id", 2},
+ {"message", {
+ {"message_id", 6},
+ {"chat", {{"id", 123}, {"type", "private"}}},
+ {"text", "retry me"}
+ }}}});
+ require(deliveries.ingest(77, failed_update).has_value(),
+ "second update ingestion failed");
+ auto failed_job = deliveries.claimNext();
+ require(failed_job.has_value() && failed_job->has_value(),
+ "second delivery was not queued");
+ require(deliveries.fail(failed_job->value().id, "HTTP 400", false,
+ std::nullopt)
+ .has_value(),
+ "dead-letter transition failed");
+ auto dead_jobs = deliveries.listDead();
+ require(dead_jobs.has_value() && dead_jobs->size() == 1,
+ "dead-letter listing failed");
+ require(dead_jobs->at(0).last_error == "HTTP 400",
+ "dead-letter reason was not retained");
+ require(deliveries.retry(failed_job->value().id).has_value(),
+ "dead-letter retry command failed");
+ auto retried_job = deliveries.claimNext();
+ require(retried_job.has_value() && retried_job->has_value(),
+ "retried delivery was not queued");
+ require(deliveries.complete(retried_job->value().id).has_value(),
+ "retried delivery completion failed");
+
+ auto legacy_path = (directory / "legacy.db").string();
+ auto legacy_connection = mw::SQLite::connectFile(legacy_path);
+ require(legacy_connection.has_value(), "legacy database could not be made");
+ require((*legacy_connection)
+ ->execute("CREATE TABLE api_keys (name TEXT PRIMARY KEY, "
+ "key TEXT NOT NULL, created_at INTEGER);")
+ .has_value(),
+ "legacy schema could not be made");
+ auto legacy_initialization =
+ telegrammer::initializeDatabase(legacy_path);
+ require(!legacy_initialization.has_value(),
+ "legacy schema was accepted");
+ const telegrammer::ServiceError* schema_error =
+ telegrammer::asServiceError(legacy_initialization.error());
+ require(schema_error != nullptr && schema_error->code == "DATABASE_SCHEMA",
+ "legacy schema returned the wrong error");
+
+ FakeTelegram telegram;
+ telegrammer::RuntimeState state;
+ state.polling_ready = true;
+ state.degraded = false;
+ int port = freePort();
+ telegrammer::ApiServer server(
+ {"127.0.0.1", port}, keys, subscriptions, deliveries, telegram, state);
+ require(server.start().has_value(), "API server did not start");
+ httplib::Client client("127.0.0.1", port);
+ client.set_connection_timeout(std::chrono::seconds(2));
+ auto unauthorized = client.Get("/health");
+ requireJsonResponse(unauthorized, 401, "missing auth was not rejected");
+ require(unauthorized->get_header_value("WWW-Authenticate") == "Bearer",
+ "auth challenge was missing");
+ auto auth_header = httplib::Headers{
+ {"Authorization", "Bearer " + *token}};
+ auto health = client.Get("/health", auth_header);
+ requireJsonResponse(health, 200, "health endpoint failed");
+ require(telegrammer::json::parse(health->body)["ok"] == true,
+ "health response was not an object success envelope");
+ telegrammer::ApiServer conflicting_server(
+ {"127.0.0.1", port}, keys, subscriptions, deliveries, telegram, state);
+ require(!conflicting_server.start().has_value(),
+ "occupied port was reported as available");
+ auto wrong_method = client.Post("/health", auth_header, "{}",
+ "application/json");
+ requireJsonResponse(wrong_method, 405,
+ "unsupported method did not return 405");
+ require(wrong_method->get_header_value("Allow") == "GET",
+ "unsupported method did not return Allow");
+ auto bad_media = client.Post("/subscribe", auth_header, "{}",
+ "text/plain");
+ requireJsonResponse(bad_media, 415, "media type validation failed");
+ auto subscribe_response = client.Post(
+ "/subscribe", auth_header,
+ R"({"chat_id":123,"callback_url":"http://127.0.0.1/hook"})",
+ "application/json");
+ requireJsonResponse(subscribe_response, 200, "HTTP subscription failed");
+ auto subscribe_body = telegrammer::json::parse(subscribe_response->body);
+ require(subscribe_body.is_object() && subscribe_body["ok"] == true,
+ "subscription response was not a JSON object");
+ int64_t subscription_id = subscribe_body["subscription_id"].get<int64_t>();
+ auto duplicate_response = client.Post(
+ "/subscribe", auth_header,
+ R"({"chat_id":123,"callback_url":"http://127.0.0.1/hook"})",
+ "application/json");
+ requireJsonResponse(duplicate_response, 200,
+ "duplicate HTTP subscription failed");
+ require(telegrammer::json::parse(duplicate_response->body)
+ ["subscription_id"] == subscription_id,
+ "duplicate HTTP subscription was not idempotent");
+ auto send_response = client.Post(
+ "/send", auth_header,
+ R"({"chat_id":123,"text":"hello from api"})", "application/json");
+ requireJsonResponse(send_response, 200, "HTTP send failed");
+ require(telegram.sent_chat_id == 123 && telegram.sent_text == "hello from api",
+ "HTTP send did not reach Telegram client");
+ auto both_destinations = client.Post(
+ "/send", auth_header,
+ R"({"chat_id":123,"username":"alice","text":"bad"})",
+ "application/json");
+ requireJsonResponse(both_destinations, 400,
+ "ambiguous send destination was accepted");
+ auto subscriptions_response = client.Get("/subscriptions", auth_header);
+ requireJsonResponse(subscriptions_response, 200,
+ "subscription listing failed");
+ require(telegrammer::json::parse(subscriptions_response->body)
+ ["subscriptions"].is_array(),
+ "subscription listing was not an array");
+ auto malformed_delete = client.Delete(
+ std::format("/subscriptions/{}junk", subscription_id), auth_header);
+ requireJsonResponse(malformed_delete, 400,
+ "malformed subscription ID was accepted");
+ auto delete_response = client.Delete(
+ std::format("/subscriptions/{}", subscription_id), auth_header);
+ requireJsonResponse(delete_response, 204, "subscription deletion failed");
+ server.stop();
+ server.wait();
+
+ auto removed = subscriptions.remove(identity->value().id,
+ subscription->id);
+ require(removed.has_value() && !*removed,
+ "HTTP subscription deletion was not persisted");
+ auto deleted_key = keys.deleteKey("test-client");
+ require(deleted_key.has_value() && *deleted_key, "key deletion failed");
+ auto revoked = keys.authenticate(*token);
+ require(revoked.has_value() && !revoked->has_value(),
+ "deleted key still authenticated");
+
+ std::filesystem::remove_all(directory);
+ return 0;
+ }
+ catch(const std::exception& error)
+ {
+ std::filesystem::remove_all(directory);
+ std::cerr << error.what() << std::endl;
+ return 1;
+ }
+}