Changes
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..567609b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+build/
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..136444b
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,53 @@
+cmake_minimum_required(VERSION 3.24)
+project(telegrammer)
+
+set(CMAKE_CXX_STANDARD 23)
+set(CMAKE_CXX_EXTENSIONS OFF)
+
+# Dependencies
+include(FetchContent)
+
+FetchContent_Declare(
+ libmw
+ GIT_REPOSITORY https://github.com/MetroWind/libmw.git
+)
+
+FetchContent_Declare(
+ spdlog
+ GIT_REPOSITORY https://github.com/gabime/spdlog.git
+ GIT_TAG v1.12.0
+)
+
+FetchContent_Declare(
+ json
+ GIT_REPOSITORY https://github.com/nlohmann/json.git
+ GIT_TAG v3.11.3
+)
+
+FetchContent_Declare(
+ cxxopts
+ GIT_REPOSITORY https://github.com/jarro2783/cxxopts.git
+ GIT_TAG v3.1.1
+)
+
+set(SPDLOG_USE_STD_FORMAT ON)
+set(LIBMW_BUILD_URL ON)
+set(LIBMW_BUILD_HTTP_SERVER ON)
+FetchContent_MakeAvailable(libmw spdlog json cxxopts)
+
+add_executable(telegrammer src/main.cpp)
+
+target_link_libraries(telegrammer PRIVATE
+ mw::mw
+ mw::http-server
+ mw::url
+ nlohmann_json::nlohmann_json
+ spdlog::spdlog
+ cxxopts
+)
+
+target_include_directories(telegrammer PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+)
+
+target_compile_options(telegrammer PRIVATE -Wall -Wextra -Wpedantic)
diff --git a/design.md b/design.md
new file mode 100644
index 0000000..23d0eb7
--- /dev/null
+++ b/design.md
@@ -0,0 +1,114 @@
+# 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,
+ "text": "Hello World"
+}
+```
+
+**Response:**
+* `200 OK`: Message sent successfully.
+* `400 Bad Request`: Invalid JSON or missing fields.
+* `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.
diff --git a/prd.md b/prd.md
new file mode 100644
index 0000000..204fff1
--- /dev/null
+++ b/prd.md
@@ -0,0 +1,11 @@
+A Telegram bot written in C++ that act as an API.
+
+* It exposes a HTTP interface
+* Other programs can POST to this interface with a JSON payload to
+ make the bot post a message
+* Other programs can subscribe to a chat’s messages via a HTTP hook.
+ Whenever the bot see a message in the chat, it POSTs the info of the
+ message to the programs HTTP callback endpoint.
+* Use Cmake for build. Use [libmw](https://github.com/MetroWind/libmw)
+ for HTTP server and HTTP client, and various tools. You can find an
+ example project using libmw at @shrt.
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644
index 0000000..1b42db3
--- /dev/null
+++ b/src/main.cpp
@@ -0,0 +1,258 @@
+#include <iostream>
+#include <string>
+#include <vector>
+#include <map>
+#include <mutex>
+#include <thread>
+#include <chrono>
+#include <expected>
+
+#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>
+
+using json = nlohmann::json;
+
+class TelegramClient
+{
+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
+{
+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));
+ }
+
+ 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 App : public mw::HTTPServer
+{
+public:
+ App(mw::IPSocketInfo listen_info, std::string token)
+ : mw::HTTPServer(listen_info),
+ tg_client_(std::move(token))
+ {}
+
+ void setup()
+ {
+ server.Post("/send", [this](const Request& req, Response& res)
+ {
+ try
+ {
+ auto body = json::parse(req.body);
+ if(!body.contains("chat_id") || !body.contains("text"))
+ {
+ res.status = 400;
+ res.set_content("Missing chat_id or text", "text/plain");
+ return;
+ }
+
+ ASSIGN_OR_RESPOND_ERROR(auto result, tg_client_.sendMessage(body["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");
+ }
+ });
+
+ server.Post("/subscribe", [this](const Request& req, Response& res)
+ {
+ 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");
+ }
+ catch(const std::exception& e)
+ {
+ res.status = 400;
+ res.set_content(e.what(), "text/plain");
+ }
+ });
+ }
+
+ void runPolling()
+ {
+ int64_t offset = 0;
+ while(running_)
+ {
+ 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"]);
+ }
+ }
+ }
+ }
+ }
+
+ void stopPolling()
+ {
+ running_ = false;
+ }
+
+private:
+ void dispatchMessage(const json& message)
+ {
+ int64_t chat_id = message["chat"]["id"];
+ auto callbacks = sub_manager_.getSubscribers(chat_id);
+
+ for(const auto& url : callbacks)
+ {
+ 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();
+ }
+ }
+
+ TelegramClient tg_client_;
+ SubscriptionManager sub_manager_;
+ bool running_ = true;
+};
+
+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>())
+ ("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"))
+ ("help", "Print help");
+
+ try
+ {
+ auto result = cmd_options.parse(argc, argv);
+
+ if (result.count("help"))
+ {
+ std::cout << cmd_options.help() << std::endl;
+ return 0;
+ }
+
+ if (!result.count("token"))
+ {
+ spdlog::error("Token is required. Use --token <TOKEN>");
+ std::cout << cmd_options.help() << std::endl;
+ 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);
+ app.setup();
+
+ auto start_res = app.start();
+ if(!start_res.has_value())
+ {
+ spdlog::error("Failed to start server: {}", mw::errorMsg(start_res.error()));
+ return 1;
+ }
+
+ spdlog::info("Server listening on {}:{}", host, port);
+
+ std::thread polling_thread([&app]() { app.runPolling(); });
+
+ app.wait();
+ app.stopPolling();
+ if(polling_thread.joinable()) polling_thread.join();
+ }
+ catch (const cxxopts::exceptions::exception& e)
+ {
+ spdlog::error("Error parsing options: {}", e.what());
+ return 1;
+ }
+
+ return 0;
+}
diff --git a/test_api.sh b/test_api.sh
new file mode 100644
index 0000000..b3bef1d
--- /dev/null
+++ b/test_api.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+# Simple test script for Telegrammer API
+# NOTE: This requires Telegrammer to be running.
+
+API_URL="http://localhost:8080"
+
+echo "Testing /subscribe..."
+curl -X POST "$API_URL/subscribe" \
+ -H "Content-Type: application/json" \
+ -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" \
+ -d '{"chat_id": 12345, "text": "Test message"}'
+echo -e "\n"