diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 94875bc..c5e959f 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -3,7 +3,8 @@ idf_component_register( "mimi.c" "bus/message_bus.c" "wifi/wifi_manager.c" - "telegram/telegram_bot.c" + "channels/telegram/telegram_bot.c" + "channels/feishu/feishu_bot.c" "llm/llm_proxy.c" "agent/agent_loop.c" "agent/context_builder.c" diff --git a/main/channels/feishu/feishu_bot.c b/main/channels/feishu/feishu_bot.c new file mode 100644 index 0000000..5817e0d --- /dev/null +++ b/main/channels/feishu/feishu_bot.c @@ -0,0 +1,657 @@ +#include "feishu_bot.h" +#include "mimi_config.h" +#include "bus/message_bus.h" +#include "proxy/http_proxy.h" + +#include +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include "esp_http_client.h" +#include "esp_http_server.h" +#include "esp_crt_bundle.h" +#include "esp_timer.h" +#include "nvs.h" +#include "cJSON.h" + +static const char *TAG = "feishu"; + +/* ── Feishu API endpoints ──────────────────────────────────── */ +#define FEISHU_API_BASE "https://open.feishu.cn/open-apis" +#define FEISHU_AUTH_URL FEISHU_API_BASE "/auth/v3/tenant_access_token/internal" +#define FEISHU_SEND_MSG_URL FEISHU_API_BASE "/im/v1/messages" +#define FEISHU_REPLY_MSG_URL FEISHU_API_BASE "/im/v1/messages/%s/reply" + +/* ── Credentials & token state ─────────────────────────────── */ +static char s_app_id[64] = MIMI_SECRET_FEISHU_APP_ID; +static char s_app_secret[128] = MIMI_SECRET_FEISHU_APP_SECRET; +static char s_tenant_token[512] = {0}; +static int64_t s_token_expire_time = 0; + +/* ── Message deduplication ─────────────────────────────────── */ +#define FEISHU_DEDUP_CACHE_SIZE 64 + +static uint64_t s_seen_msg_keys[FEISHU_DEDUP_CACHE_SIZE] = {0}; +static size_t s_seen_msg_idx = 0; + +static uint64_t fnv1a64(const char *s) +{ + uint64_t h = 1469598103934665603ULL; + if (!s) return h; + while (*s) { + h ^= (unsigned char)(*s++); + h *= 1099511628211ULL; + } + return h; +} + +static bool dedup_check_and_record(const char *message_id) +{ + uint64_t key = fnv1a64(message_id); + for (size_t i = 0; i < FEISHU_DEDUP_CACHE_SIZE; i++) { + if (s_seen_msg_keys[i] == key) return true; + } + s_seen_msg_keys[s_seen_msg_idx] = key; + s_seen_msg_idx = (s_seen_msg_idx + 1) % FEISHU_DEDUP_CACHE_SIZE; + return false; +} + +/* ── 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; +} + +/* ── Get / refresh tenant access token ─────────────────────── */ +static esp_err_t feishu_get_tenant_token(void) +{ + if (s_app_id[0] == '\0' || s_app_secret[0] == '\0') { + ESP_LOGW(TAG, "No Feishu credentials configured"); + return ESP_ERR_INVALID_STATE; + } + + int64_t now = esp_timer_get_time() / 1000000LL; + if (s_tenant_token[0] != '\0' && s_token_expire_time > now + 300) { + return ESP_OK; + } + + cJSON *body = cJSON_CreateObject(); + cJSON_AddStringToObject(body, "app_id", s_app_id); + cJSON_AddStringToObject(body, "app_secret", s_app_secret); + char *json_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!json_str) return ESP_ERR_NO_MEM; + + http_resp_t resp = { .buf = calloc(1, 2048), .len = 0, .cap = 2048 }; + if (!resp.buf) { free(json_str); return ESP_ERR_NO_MEM; } + + esp_http_client_config_t config = { + .url = FEISHU_AUTH_URL, + .event_handler = http_event_handler, + .user_data = &resp, + .timeout_ms = 10000, + .buffer_size = 2048, + .buffer_size_tx = 2048, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { free(json_str); free(resp.buf); return ESP_FAIL; } + + 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, json_str, strlen(json_str)); + + esp_err_t err = esp_http_client_perform(client); + esp_http_client_cleanup(client); + free(json_str); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "Token request HTTP failed: %s", esp_err_to_name(err)); + free(resp.buf); + return err; + } + + cJSON *root = cJSON_Parse(resp.buf); + free(resp.buf); + if (!root) { ESP_LOGE(TAG, "Failed to parse token response"); return ESP_FAIL; } + + cJSON *code = cJSON_GetObjectItem(root, "code"); + if (!code || code->valueint != 0) { + ESP_LOGE(TAG, "Token request failed: code=%d", code ? code->valueint : -1); + cJSON_Delete(root); + return ESP_FAIL; + } + + cJSON *token = cJSON_GetObjectItem(root, "tenant_access_token"); + cJSON *expire = cJSON_GetObjectItem(root, "expire"); + + if (token && cJSON_IsString(token)) { + strncpy(s_tenant_token, token->valuestring, sizeof(s_tenant_token) - 1); + s_token_expire_time = now + (expire ? expire->valueint : 7200) - 300; + ESP_LOGI(TAG, "Got tenant access token (expires in %ds)", + expire ? expire->valueint : 7200); + } + + cJSON_Delete(root); + return ESP_OK; +} + +/* ── Feishu API call helper ────────────────────────────────── */ +static char *feishu_api_call(const char *url, const char *method, const char *post_data) +{ + if (feishu_get_tenant_token() != ESP_OK) return NULL; + + 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 = 15000, + .buffer_size = 2048, + .buffer_size_tx = 2048, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { free(resp.buf); return NULL; } + + char auth_header[600]; + snprintf(auth_header, sizeof(auth_header), "Bearer %s", s_tenant_token); + esp_http_client_set_header(client, "Authorization", auth_header); + esp_http_client_set_header(client, "Content-Type", "application/json; charset=utf-8"); + + if (strcmp(method, "POST") == 0) { + esp_http_client_set_method(client, HTTP_METHOD_POST); + if (post_data) { + 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, "API call failed: %s", esp_err_to_name(err)); + free(resp.buf); + return NULL; + } + + return resp.buf; +} + +/* ── Webhook event handler ─────────────────────────────────── */ + +/* + * Feishu Event Callback v2 schema: + * { + * "schema": "2.0", + * "header": { "event_type": "im.message.receive_v1", "event_id": "...", ... }, + * "event": { + * "sender": { "sender_id": { "open_id": "ou_xxx" }, "sender_type": "user" }, + * "message": { + * "message_id": "om_xxx", + * "chat_id": "oc_xxx", + * "chat_type": "p2p" | "group", + * "message_type": "text", + * "content": "{\"text\":\"hello\"}" + * } + * } + * } + * + * URL verification challenge: + * { "challenge": "xxx", "token": "yyy", "type": "url_verification" } + */ + +static void handle_message_event(cJSON *event) +{ + cJSON *message = cJSON_GetObjectItem(event, "message"); + if (!message) return; + + cJSON *message_id_j = cJSON_GetObjectItem(message, "message_id"); + cJSON *chat_id_j = cJSON_GetObjectItem(message, "chat_id"); + cJSON *chat_type_j = cJSON_GetObjectItem(message, "chat_type"); + cJSON *msg_type_j = cJSON_GetObjectItem(message, "message_type"); + cJSON *content_j = cJSON_GetObjectItem(message, "content"); + + if (!chat_id_j || !cJSON_IsString(chat_id_j)) return; + if (!content_j || !cJSON_IsString(content_j)) return; + + const char *message_id = cJSON_IsString(message_id_j) ? message_id_j->valuestring : ""; + const char *chat_id = chat_id_j->valuestring; + const char *chat_type = cJSON_IsString(chat_type_j) ? chat_type_j->valuestring : "p2p"; + const char *msg_type = cJSON_IsString(msg_type_j) ? msg_type_j->valuestring : "text"; + + /* Deduplication */ + if (message_id[0] && dedup_check_and_record(message_id)) { + ESP_LOGD(TAG, "Duplicate message %s, skipping", message_id); + return; + } + + /* Only handle text messages for now */ + if (strcmp(msg_type, "text") != 0) { + ESP_LOGI(TAG, "Ignoring non-text message type: %s", msg_type); + return; + } + + /* Parse the content JSON to extract text */ + cJSON *content_obj = cJSON_Parse(content_j->valuestring); + if (!content_obj) { + ESP_LOGW(TAG, "Failed to parse message content JSON"); + return; + } + + cJSON *text_j = cJSON_GetObjectItem(content_obj, "text"); + if (!text_j || !cJSON_IsString(text_j)) { + cJSON_Delete(content_obj); + return; + } + + const char *text = text_j->valuestring; + + /* Strip @bot mention prefix if present (Feishu adds @_user_1 for mentions) */ + const char *cleaned = text; + if (strncmp(cleaned, "@_user_1 ", 9) == 0) { + cleaned += 9; + } + /* Skip leading whitespace */ + while (*cleaned == ' ' || *cleaned == '\n') cleaned++; + + if (cleaned[0] == '\0') { + cJSON_Delete(content_obj); + return; + } + + /* Get sender info */ + const char *sender_id = ""; + cJSON *sender = cJSON_GetObjectItem(event, "sender"); + if (sender) { + cJSON *sender_id_obj = cJSON_GetObjectItem(sender, "sender_id"); + if (sender_id_obj) { + cJSON *open_id = cJSON_GetObjectItem(sender_id_obj, "open_id"); + if (open_id && cJSON_IsString(open_id)) { + sender_id = open_id->valuestring; + } + } + } + + ESP_LOGI(TAG, "Message from %s in %s(%s): %.60s%s", + sender_id, chat_id, chat_type, cleaned, + strlen(cleaned) > 60 ? "..." : ""); + + /* For p2p (DM) chats, use sender open_id as chat_id for session routing. + * For group chats, use the chat_id (group ID). + * This matches the moltbot reference pattern where DMs route by sender. */ + const char *route_id = chat_id; + if (strcmp(chat_type, "p2p") == 0 && sender_id[0]) { + route_id = sender_id; + } + + /* Push to inbound message bus */ + mimi_msg_t msg = {0}; + strncpy(msg.channel, MIMI_CHAN_FEISHU, sizeof(msg.channel) - 1); + strncpy(msg.chat_id, route_id, sizeof(msg.chat_id) - 1); + msg.content = strdup(cleaned); + + if (msg.content) { + if (message_bus_push_inbound(&msg) != ESP_OK) { + ESP_LOGW(TAG, "Inbound queue full, dropping feishu message"); + free(msg.content); + } + } + + cJSON_Delete(content_obj); +} + +static esp_err_t feishu_webhook_handler(httpd_req_t *req) +{ + /* Read request body */ + int content_len = req->content_len; + if (content_len <= 0 || content_len > MIMI_FEISHU_WEBHOOK_MAX_BODY) { + ESP_LOGW(TAG, "Invalid content length: %d", content_len); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid body"); + return ESP_FAIL; + } + + char *body = calloc(1, content_len + 1); + if (!body) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OOM"); + return ESP_ERR_NO_MEM; + } + + int received = 0; + while (received < content_len) { + int ret = httpd_req_recv(req, body + received, content_len - received); + if (ret <= 0) { + free(body); + if (ret == HTTPD_SOCK_ERR_TIMEOUT) { + httpd_resp_send_err(req, HTTPD_408_REQ_TIMEOUT, "Timeout"); + } + return ESP_FAIL; + } + received += ret; + } + body[content_len] = '\0'; + + ESP_LOGD(TAG, "Webhook body: %.200s", body); + + cJSON *root = cJSON_Parse(body); + free(body); + + if (!root) { + ESP_LOGW(TAG, "Failed to parse webhook JSON"); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad JSON"); + return ESP_FAIL; + } + + /* URL verification challenge */ + cJSON *type_j = cJSON_GetObjectItem(root, "type"); + if (type_j && cJSON_IsString(type_j) && + strcmp(type_j->valuestring, "url_verification") == 0) { + cJSON *challenge = cJSON_GetObjectItem(root, "challenge"); + if (challenge && cJSON_IsString(challenge)) { + ESP_LOGI(TAG, "URL verification challenge received"); + cJSON *resp_obj = cJSON_CreateObject(); + cJSON_AddStringToObject(resp_obj, "challenge", challenge->valuestring); + char *resp_str = cJSON_PrintUnformatted(resp_obj); + cJSON_Delete(resp_obj); + if (resp_str) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, resp_str); + free(resp_str); + } + cJSON_Delete(root); + return ESP_OK; + } + } + + /* Event callback v2 */ + cJSON *header = cJSON_GetObjectItem(root, "header"); + cJSON *event = cJSON_GetObjectItem(root, "event"); + + if (header && event) { + /* Check for duplicate event_id */ + cJSON *event_id = cJSON_GetObjectItem(header, "event_id"); + if (event_id && cJSON_IsString(event_id)) { + if (dedup_check_and_record(event_id->valuestring)) { + ESP_LOGD(TAG, "Duplicate event %s, skipping", event_id->valuestring); + httpd_resp_sendstr(req, "{\"code\":0}"); + cJSON_Delete(root); + return ESP_OK; + } + } + + cJSON *event_type = cJSON_GetObjectItem(header, "event_type"); + if (event_type && cJSON_IsString(event_type)) { + const char *et = event_type->valuestring; + if (strcmp(et, "im.message.receive_v1") == 0) { + handle_message_event(event); + } else { + ESP_LOGI(TAG, "Unhandled event type: %s", et); + } + } + } + + /* Always respond 200 OK to Feishu to prevent retries */ + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"code\":0}"); + + cJSON_Delete(root); + return ESP_OK; +} + +/* ── Webhook HTTP server ───────────────────────────────────── */ +static httpd_handle_t s_webhook_server = NULL; + +static esp_err_t feishu_start_webhook_server(void) +{ + if (s_webhook_server) { + ESP_LOGW(TAG, "Webhook server already running"); + return ESP_OK; + } + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = MIMI_FEISHU_WEBHOOK_PORT; + config.stack_size = MIMI_FEISHU_POLL_STACK; + config.max_uri_handlers = 4; + config.lru_purge_enable = true; + + esp_err_t err = httpd_start(&s_webhook_server, &config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to start webhook server: %s", esp_err_to_name(err)); + return err; + } + + /* Register the webhook endpoint */ + httpd_uri_t webhook_uri = { + .uri = MIMI_FEISHU_WEBHOOK_PATH, + .method = HTTP_POST, + .handler = feishu_webhook_handler, + .user_ctx = NULL, + }; + httpd_register_uri_handler(s_webhook_server, &webhook_uri); + + ESP_LOGI(TAG, "Feishu webhook server started on port %d, path: %s", + MIMI_FEISHU_WEBHOOK_PORT, MIMI_FEISHU_WEBHOOK_PATH); + + return ESP_OK; +} + +/* ── Public API ────────────────────────────────────────────── */ + +esp_err_t feishu_bot_init(void) +{ + nvs_handle_t nvs; + if (nvs_open(MIMI_NVS_FEISHU, NVS_READONLY, &nvs) == ESP_OK) { + char tmp_id[64] = {0}; + char tmp_secret[128] = {0}; + size_t len_id = sizeof(tmp_id); + size_t len_secret = sizeof(tmp_secret); + + if (nvs_get_str(nvs, MIMI_NVS_KEY_FEISHU_APP_ID, tmp_id, &len_id) == ESP_OK && tmp_id[0]) { + strncpy(s_app_id, tmp_id, sizeof(s_app_id) - 1); + } + if (nvs_get_str(nvs, MIMI_NVS_KEY_FEISHU_APP_SECRET, tmp_secret, &len_secret) == ESP_OK && tmp_secret[0]) { + strncpy(s_app_secret, tmp_secret, sizeof(s_app_secret) - 1); + } + nvs_close(nvs); + } + + if (s_app_id[0] && s_app_secret[0]) { + ESP_LOGI(TAG, "Feishu credentials loaded (app_id=%.8s...)", s_app_id); + } else { + ESP_LOGW(TAG, "No Feishu credentials. Use CLI: set_feishu_creds "); + } + + return ESP_OK; +} + +esp_err_t feishu_bot_start(void) +{ + if (s_app_id[0] == '\0' || s_app_secret[0] == '\0') { + ESP_LOGW(TAG, "Feishu not configured, skipping webhook server start"); + return ESP_OK; + } + + /* Pre-fetch tenant token so we're ready to send immediately */ + esp_err_t token_err = feishu_get_tenant_token(); + if (token_err != ESP_OK) { + ESP_LOGW(TAG, "Initial token fetch failed (will retry on first API call)"); + } + + return feishu_start_webhook_server(); +} + +esp_err_t feishu_send_message(const char *chat_id, const char *text) +{ + if (s_app_id[0] == '\0' || s_app_secret[0] == '\0') { + ESP_LOGW(TAG, "Cannot send: no credentials configured"); + return ESP_ERR_INVALID_STATE; + } + + /* Determine receive_id_type based on ID prefix */ + const char *id_type = "chat_id"; + if (strncmp(chat_id, "ou_", 3) == 0) { + id_type = "open_id"; + } + + char url[256]; + snprintf(url, sizeof(url), "%s?receive_id_type=%s", FEISHU_SEND_MSG_URL, id_type); + + size_t text_len = strlen(text); + size_t offset = 0; + int all_ok = 1; + + while (offset < text_len) { + size_t chunk = text_len - offset; + if (chunk > MIMI_FEISHU_MAX_MSG_LEN) { + chunk = MIMI_FEISHU_MAX_MSG_LEN; + } + + char *segment = malloc(chunk + 1); + if (!segment) return ESP_ERR_NO_MEM; + memcpy(segment, text + offset, chunk); + segment[chunk] = '\0'; + + /* Build content JSON: {"text":"..."} */ + cJSON *content = cJSON_CreateObject(); + cJSON_AddStringToObject(content, "text", segment); + char *content_str = cJSON_PrintUnformatted(content); + cJSON_Delete(content); + free(segment); + + if (!content_str) { offset += chunk; all_ok = 0; continue; } + + /* Build message body */ + cJSON *body = cJSON_CreateObject(); + cJSON_AddStringToObject(body, "receive_id", chat_id); + cJSON_AddStringToObject(body, "msg_type", "text"); + cJSON_AddStringToObject(body, "content", content_str); + free(content_str); + + char *json_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + + if (json_str) { + char *resp = feishu_api_call(url, "POST", json_str); + free(json_str); + + if (resp) { + cJSON *root = cJSON_Parse(resp); + if (root) { + cJSON *code = cJSON_GetObjectItem(root, "code"); + if (code && code->valueint != 0) { + cJSON *msg = cJSON_GetObjectItem(root, "msg"); + ESP_LOGW(TAG, "Send failed: code=%d, msg=%s", + code->valueint, msg ? msg->valuestring : "unknown"); + all_ok = 0; + } else { + ESP_LOGI(TAG, "Sent to %s (%d bytes)", chat_id, (int)chunk); + } + cJSON_Delete(root); + } + free(resp); + } else { + ESP_LOGE(TAG, "Failed to send message chunk"); + all_ok = 0; + } + } + + offset += chunk; + } + + return all_ok ? ESP_OK : ESP_FAIL; +} + +esp_err_t feishu_reply_message(const char *message_id, const char *text) +{ + if (s_app_id[0] == '\0' || s_app_secret[0] == '\0') { + return ESP_ERR_INVALID_STATE; + } + + char url[256]; + snprintf(url, sizeof(url), FEISHU_REPLY_MSG_URL, message_id); + + cJSON *content = cJSON_CreateObject(); + cJSON_AddStringToObject(content, "text", text); + char *content_str = cJSON_PrintUnformatted(content); + cJSON_Delete(content); + if (!content_str) return ESP_ERR_NO_MEM; + + cJSON *body = cJSON_CreateObject(); + cJSON_AddStringToObject(body, "msg_type", "text"); + cJSON_AddStringToObject(body, "content", content_str); + free(content_str); + + char *json_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!json_str) return ESP_ERR_NO_MEM; + + char *resp = feishu_api_call(url, "POST", json_str); + free(json_str); + + esp_err_t ret = ESP_FAIL; + if (resp) { + cJSON *root = cJSON_Parse(resp); + if (root) { + cJSON *code = cJSON_GetObjectItem(root, "code"); + if (code && code->valueint == 0) { + ret = ESP_OK; + } else { + cJSON *msg = cJSON_GetObjectItem(root, "msg"); + ESP_LOGW(TAG, "Reply failed: code=%d, msg=%s", + code ? code->valueint : -1, msg ? msg->valuestring : "unknown"); + } + cJSON_Delete(root); + } + free(resp); + } + + return ret; +} + +esp_err_t feishu_set_credentials(const char *app_id, const char *app_secret) +{ + nvs_handle_t nvs; + ESP_ERROR_CHECK(nvs_open(MIMI_NVS_FEISHU, NVS_READWRITE, &nvs)); + ESP_ERROR_CHECK(nvs_set_str(nvs, MIMI_NVS_KEY_FEISHU_APP_ID, app_id)); + ESP_ERROR_CHECK(nvs_set_str(nvs, MIMI_NVS_KEY_FEISHU_APP_SECRET, app_secret)); + ESP_ERROR_CHECK(nvs_commit(nvs)); + nvs_close(nvs); + + strncpy(s_app_id, app_id, sizeof(s_app_id) - 1); + strncpy(s_app_secret, app_secret, sizeof(s_app_secret) - 1); + + /* Clear cached token to force re-auth */ + s_tenant_token[0] = '\0'; + s_token_expire_time = 0; + + ESP_LOGI(TAG, "Feishu credentials saved"); + return ESP_OK; +}