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 <noreply@anthropic.com>
This commit is contained in:
301
main/telegram/telegram_bot.c
Normal file
301
main/telegram/telegram_bot.c
Normal file
@@ -0,0 +1,301 @@
|
||||
#include "telegram_bot.h"
|
||||
#include "mimi_config.h"
|
||||
#include "bus/message_bus.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#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 <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;
|
||||
}
|
||||
27
main/telegram/telegram_bot.h
Normal file
27
main/telegram/telegram_bot.h
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user