BareGit

Initial commit

Author: MetroWind <chris.corsair@gmail.com>
Date: Mon Jan 12 11:37:55 2026 -0800
Commit: baeba019865c5b8f2be554e1794098cc30b6da9b

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"