From 44a82520e2beaf527cb71460ca686aea3090e287 Mon Sep 17 00:00:00 2001 From: crispyberry Date: Thu, 5 Feb 2026 18:55:28 +0800 Subject: [PATCH] feat: add Telegram bot with long polling and message splitting Direct HTTPS long polling against Telegram Bot API (getUpdates), JSON parsing with cJSON, auto-split at 4096 chars, Markdown with plain-text fallback. Co-Authored-By: Claude Opus 4.5 --- main/telegram/telegram_bot.c | 301 +++++++++++++++++++++++++++++++++++ main/telegram/telegram_bot.h | 27 ++++ 2 files changed, 328 insertions(+) create mode 100644 main/telegram/telegram_bot.c create mode 100644 main/telegram/telegram_bot.h diff --git a/main/telegram/telegram_bot.c b/main/telegram/telegram_bot.c new file mode 100644 index 0000000..b721068 --- /dev/null +++ b/main/telegram/telegram_bot.c @@ -0,0 +1,301 @@ +#include "telegram_bot.h" +#include "mimi_config.h" +#include "bus/message_bus.h" + +#include +#include +#include "esp_log.h" +#include "esp_http_client.h" +#include "nvs.h" +#include "cJSON.h" + +static const char *TAG = "telegram"; + +static char s_bot_token[128] = {0}; +static int64_t s_update_offset = 0; + +/* HTTP response accumulator */ +typedef struct { + char *buf; + size_t len; + size_t cap; +} http_resp_t; + +static esp_err_t http_event_handler(esp_http_client_event_t *evt) +{ + http_resp_t *resp = (http_resp_t *)evt->user_data; + if (evt->event_id == HTTP_EVENT_ON_DATA) { + if (resp->len + evt->data_len >= resp->cap) { + size_t new_cap = resp->cap * 2; + if (new_cap < resp->len + evt->data_len + 1) { + new_cap = resp->len + evt->data_len + 1; + } + char *tmp = realloc(resp->buf, new_cap); + if (!tmp) return ESP_ERR_NO_MEM; + resp->buf = tmp; + resp->cap = new_cap; + } + memcpy(resp->buf + resp->len, evt->data, evt->data_len); + resp->len += evt->data_len; + resp->buf[resp->len] = '\0'; + } + return ESP_OK; +} + +static char *tg_api_call(const char *method, const char *post_data) +{ + char url[256]; + snprintf(url, sizeof(url), "https://api.telegram.org/bot%s/%s", s_bot_token, method); + + http_resp_t resp = { + .buf = calloc(1, 4096), + .len = 0, + .cap = 4096, + }; + if (!resp.buf) return NULL; + + esp_http_client_config_t config = { + .url = url, + .event_handler = http_event_handler, + .user_data = &resp, + .timeout_ms = (MIMI_TG_POLL_TIMEOUT_S + 5) * 1000, + .buffer_size = 2048, + .buffer_size_tx = 2048, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { + free(resp.buf); + return NULL; + } + + if (post_data) { + esp_http_client_set_method(client, HTTP_METHOD_POST); + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_http_client_set_post_field(client, post_data, strlen(post_data)); + } + + esp_err_t err = esp_http_client_perform(client); + esp_http_client_cleanup(client); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "HTTP request failed: %s", esp_err_to_name(err)); + free(resp.buf); + return NULL; + } + + return resp.buf; +} + +static void process_updates(const char *json_str) +{ + cJSON *root = cJSON_Parse(json_str); + if (!root) return; + + cJSON *ok = cJSON_GetObjectItem(root, "ok"); + if (!cJSON_IsTrue(ok)) { + cJSON_Delete(root); + return; + } + + cJSON *result = cJSON_GetObjectItem(root, "result"); + if (!cJSON_IsArray(result)) { + cJSON_Delete(root); + return; + } + + cJSON *update; + cJSON_ArrayForEach(update, result) { + /* Track offset */ + cJSON *update_id = cJSON_GetObjectItem(update, "update_id"); + if (cJSON_IsNumber(update_id)) { + int64_t uid = (int64_t)update_id->valuedouble; + if (uid >= s_update_offset) { + s_update_offset = uid + 1; + } + } + + /* Extract message */ + cJSON *message = cJSON_GetObjectItem(update, "message"); + if (!message) continue; + + cJSON *text = cJSON_GetObjectItem(message, "text"); + if (!text || !cJSON_IsString(text)) continue; + + cJSON *chat = cJSON_GetObjectItem(message, "chat"); + if (!chat) continue; + + cJSON *chat_id = cJSON_GetObjectItem(chat, "id"); + if (!chat_id) continue; + + char chat_id_str[32]; + snprintf(chat_id_str, sizeof(chat_id_str), "%.0f", chat_id->valuedouble); + + ESP_LOGI(TAG, "Message from chat %s: %.40s...", chat_id_str, text->valuestring); + + /* Push to inbound bus */ + mimi_msg_t msg = {0}; + strncpy(msg.channel, MIMI_CHAN_TELEGRAM, sizeof(msg.channel) - 1); + strncpy(msg.chat_id, chat_id_str, sizeof(msg.chat_id) - 1); + msg.content = strdup(text->valuestring); + if (msg.content) { + message_bus_push_inbound(&msg); + } + } + + cJSON_Delete(root); +} + +static void telegram_poll_task(void *arg) +{ + ESP_LOGI(TAG, "Telegram polling task started"); + + while (1) { + if (s_bot_token[0] == '\0') { + ESP_LOGW(TAG, "No bot token configured, waiting..."); + vTaskDelay(pdMS_TO_TICKS(5000)); + continue; + } + + char params[128]; + snprintf(params, sizeof(params), + "getUpdates?offset=%" PRId64 "&timeout=%d", + s_update_offset, MIMI_TG_POLL_TIMEOUT_S); + + char *resp = tg_api_call(params, NULL); + if (resp) { + process_updates(resp); + free(resp); + } else { + /* Back off on error */ + vTaskDelay(pdMS_TO_TICKS(3000)); + } + } +} + +/* --- Public API --- */ + +esp_err_t telegram_bot_init(void) +{ + /* Load token from NVS */ + nvs_handle_t nvs; + esp_err_t err = nvs_open(MIMI_NVS_TG, NVS_READONLY, &nvs); + if (err == ESP_OK) { + size_t len = sizeof(s_bot_token); + nvs_get_str(nvs, MIMI_NVS_KEY_TG_TOKEN, s_bot_token, &len); + nvs_close(nvs); + } + + if (s_bot_token[0]) { + ESP_LOGI(TAG, "Telegram bot token loaded (len=%d)", (int)strlen(s_bot_token)); + } else { + ESP_LOGW(TAG, "No Telegram bot token. Use CLI: set_tg_token "); + } + return ESP_OK; +} + +esp_err_t telegram_bot_start(void) +{ + BaseType_t ret = xTaskCreatePinnedToCore( + telegram_poll_task, "tg_poll", + MIMI_TG_POLL_STACK, NULL, + MIMI_TG_POLL_PRIO, NULL, MIMI_TG_POLL_CORE); + + return (ret == pdPASS) ? ESP_OK : ESP_FAIL; +} + +esp_err_t telegram_send_message(const char *chat_id, const char *text) +{ + if (s_bot_token[0] == '\0') { + ESP_LOGW(TAG, "Cannot send: no bot token"); + return ESP_ERR_INVALID_STATE; + } + + /* Split long messages at 4096-char boundary */ + size_t text_len = strlen(text); + size_t offset = 0; + + while (offset < text_len) { + size_t chunk = text_len - offset; + if (chunk > MIMI_TG_MAX_MSG_LEN) { + chunk = MIMI_TG_MAX_MSG_LEN; + } + + /* Build JSON body */ + cJSON *body = cJSON_CreateObject(); + cJSON_AddStringToObject(body, "chat_id", chat_id); + + /* Create null-terminated chunk */ + char *segment = malloc(chunk + 1); + if (!segment) { + cJSON_Delete(body); + return ESP_ERR_NO_MEM; + } + memcpy(segment, text + offset, chunk); + segment[chunk] = '\0'; + + cJSON_AddStringToObject(body, "text", segment); + cJSON_AddStringToObject(body, "parse_mode", "Markdown"); + + char *json_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + free(segment); + + if (json_str) { + char *resp = tg_api_call("sendMessage", json_str); + free(json_str); + if (resp) { + /* Check for Markdown parse error, retry as plain text */ + cJSON *root = cJSON_Parse(resp); + if (root) { + cJSON *ok_field = cJSON_GetObjectItem(root, "ok"); + if (!cJSON_IsTrue(ok_field)) { + ESP_LOGW(TAG, "Markdown send failed, retrying plain"); + cJSON_Delete(root); + free(resp); + + /* Retry without parse_mode */ + cJSON *body2 = cJSON_CreateObject(); + cJSON_AddStringToObject(body2, "chat_id", chat_id); + char *seg2 = malloc(chunk + 1); + if (seg2) { + memcpy(seg2, text + offset, chunk); + seg2[chunk] = '\0'; + cJSON_AddStringToObject(body2, "text", seg2); + free(seg2); + } + char *json2 = cJSON_PrintUnformatted(body2); + cJSON_Delete(body2); + if (json2) { + char *resp2 = tg_api_call("sendMessage", json2); + free(json2); + free(resp2); + } + } else { + cJSON_Delete(root); + free(resp); + } + } else { + free(resp); + } + } + } + + offset += chunk; + } + + return ESP_OK; +} + +esp_err_t telegram_set_token(const char *token) +{ + nvs_handle_t nvs; + ESP_ERROR_CHECK(nvs_open(MIMI_NVS_TG, NVS_READWRITE, &nvs)); + ESP_ERROR_CHECK(nvs_set_str(nvs, MIMI_NVS_KEY_TG_TOKEN, token)); + ESP_ERROR_CHECK(nvs_commit(nvs)); + nvs_close(nvs); + + strncpy(s_bot_token, token, sizeof(s_bot_token) - 1); + ESP_LOGI(TAG, "Telegram bot token saved"); + return ESP_OK; +} diff --git a/main/telegram/telegram_bot.h b/main/telegram/telegram_bot.h new file mode 100644 index 0000000..dc92a6b --- /dev/null +++ b/main/telegram/telegram_bot.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esp_err.h" + +/** + * Initialize the Telegram bot. + * Reads bot token from NVS. + */ +esp_err_t telegram_bot_init(void); + +/** + * Start the Telegram polling task (long polling on Core 0). + */ +esp_err_t telegram_bot_start(void); + +/** + * Send a text message to a Telegram chat. + * Automatically splits messages longer than 4096 chars. + * @param chat_id Telegram chat ID (numeric string) + * @param text Message text (supports Markdown) + */ +esp_err_t telegram_send_message(const char *chat_id, const char *text); + +/** + * Save the Telegram bot token to NVS. + */ +esp_err_t telegram_set_token(const char *token);