diff --git a/application/basic_demo/fatfs_image/skills/im_send_file.md b/application/basic_demo/fatfs_image/skills/im_send_file.md index c90cbb3..72e88e8 100644 --- a/application/basic_demo/fatfs_image/skills/im_send_file.md +++ b/application/basic_demo/fatfs_image/skills/im_send_file.md @@ -4,7 +4,7 @@ Use this skill when the user wants the device to send an existing local non-imag ## When to use - The user asks to send back a file, report, log, archive, JSON, CSV, or other non-image output. -- The target conversation is already the active Telegram, QQ, or WeChat chat, or the user provides an explicit target `chat_id`. +- The target conversation is already the active Telegram, QQ, Feishu, or WeChat chat, or the user provides an explicit target `chat_id`. ## Available tools - `list_dir`: inspect device storage and confirm the file path @@ -24,9 +24,9 @@ Use this skill when the user wants the device to send an existing local non-imag ## Sending rules - Use this skill only for non-image files such as `.txt`, `.json`, `.log`, `.csv`, `.zip`, or archives. -- Use `cap_cli` with `cap call tg_send_file ''` or `cap call qq_send_file ''`. +- Use `cap_cli` with `cap call tg_send_file ''`, `cap call qq_send_file ''`, or `cap call feishu_send_file ''`. - The JSON payload should include an explicit `chat_id`, `path`, and optional `caption`. -- Use Telegram file capability for Telegram chats and QQ file capability for QQ chats. +- Use Telegram file capability for Telegram chats, QQ file capability for QQ chats, and Feishu file capability for Feishu chats. - Do not claim WeChat generic file-send support unless a real `wechat_send_file` capability exists. - Pass `caption` only when the user wants an accompanying message. - The second argument of `cap call` must be one complete JSON string. Do not rewrite it as CLI flags or `key=value`. @@ -43,10 +43,15 @@ Send a file to a QQ group through `cap_cli`: cap call qq_send_file '{"chat_id":"group1234567890","path":"/spiffs/reports/status.json","caption":"Latest status report."}' ``` +Send a file to a Feishu chat through `cap_cli`: +```text +cap call feishu_send_file '{"chat_id":"ou_xxx","path":"/fatfs/data/reports/status.json","caption":"Latest status report."}' +``` + ## Workflow 1. Confirm the target file exists with `list_dir` if needed. -2. Identify the target channel: Telegram, QQ, or WeChat. -3. Choose `tg_send_file` or `qq_send_file` based on the target channel. +2. Identify the target channel: Telegram, QQ, Feishu, or WeChat. +3. Choose `tg_send_file`, `qq_send_file`, or `feishu_send_file` based on the target channel. 4. For WeChat, stop and explain that this firmware variant does not expose `wechat_send_file`. 5. Execute the capability through `cap_cli` as `cap call ''` with explicit `chat_id`, `path`, and optional `caption`. 6. Tell the user whether the send succeeded. diff --git a/application/basic_demo/fatfs_image/skills/im_send_picture.md b/application/basic_demo/fatfs_image/skills/im_send_picture.md index f1e8630..a236bba 100644 --- a/application/basic_demo/fatfs_image/skills/im_send_picture.md +++ b/application/basic_demo/fatfs_image/skills/im_send_picture.md @@ -4,7 +4,7 @@ Use this skill when the user wants the device to send an existing local image ba ## When to use - The user asks to send back a picture, image, photo, screenshot, camera frame, or visual result. -- The target conversation is already the active Telegram, QQ, or WeChat chat, or the user provides an explicit target `chat_id`. +- The target conversation is already the active Telegram, QQ, Feishu, or WeChat chat, or the user provides an explicit target `chat_id`. ## Available tools - `list_dir`: inspect device storage and confirm the image path @@ -17,9 +17,9 @@ Use this skill when the user wants the device to send an existing local image ba ## Sending rules - Use this skill only for image files such as `.jpg`, `.jpeg`, `.png`, `.gif`, or `.webp`. -- Use `cap_cli` with `cap call tg_send_image ''`, `cap call qq_send_image ''`, or `cap call wechat_send_image ''`. +- Use `cap_cli` with `cap call tg_send_image ''`, `cap call qq_send_image ''`, `cap call feishu_send_image ''`, or `cap call wechat_send_image ''`. - The JSON payload should include an explicit `chat_id`, `path`, and optional `caption`. -- Use `tg_send_image` for Telegram chats, `qq_send_image` for QQ chats, and `wechat_send_image` for WeChat chats. +- Use `tg_send_image` for Telegram chats, `qq_send_image` for QQ chats, `feishu_send_image` for Feishu chats, and `wechat_send_image` for WeChat chats. - Pass `caption` only when the user wants an accompanying message. - The second argument of `cap call` must be one complete JSON string. Do not rewrite it as `--chat_id`, `path=...`, or other non-JSON forms. @@ -35,6 +35,11 @@ Send an image to a QQ chat through `cap_cli`: cap call qq_send_image '{"chat_id":"group123","path":"/spiffs/qq/capture.jpg","caption":"Here is the image."}' ``` +Send an image to a Feishu chat through `cap_cli`: +```text +cap call feishu_send_image '{"chat_id":"ou_xxx","path":"/fatfs/data/inbox/feishu/photo.jpg","caption":"Here is the image."}' +``` + Send an image to a WeChat chat through `cap_cli`: ```text cap call wechat_send_image '{"chat_id":"room123","path":"/spiffs/wechat/capture.jpg","caption":"Here is the image."}' @@ -42,8 +47,8 @@ cap call wechat_send_image '{"chat_id":"room123","path":"/spiffs/wechat/capture. ## Workflow 1. Confirm the target image exists with `list_dir` if needed. -2. Identify the target channel: Telegram, QQ, or WeChat. -3. Build the matching capability call: `tg_send_image`, `qq_send_image`, or `wechat_send_image`. +2. Identify the target channel: Telegram, QQ, Feishu, or WeChat. +3. Build the matching capability call: `tg_send_image`, `qq_send_image`, `feishu_send_image`, or `wechat_send_image`. 4. Execute it through `cap_cli` as `cap call ''` with explicit `chat_id`, `path`, and optional `caption`. 5. Tell the user whether the send succeeded. diff --git a/application/basic_demo/main/CMakeLists.txt b/application/basic_demo/main/CMakeLists.txt index 69c0a97..54a7956 100644 --- a/application/basic_demo/main/CMakeLists.txt +++ b/application/basic_demo/main/CMakeLists.txt @@ -12,6 +12,7 @@ idf_component_register( REQUIRES cap_cli cap_files + cap_im_feishu cap_im_qq cap_im_tg cap_im_wechat diff --git a/application/basic_demo/main/Kconfig.projbuild b/application/basic_demo/main/Kconfig.projbuild index d2a9d60..b18313c 100644 --- a/application/basic_demo/main/Kconfig.projbuild +++ b/application/basic_demo/main/Kconfig.projbuild @@ -12,41 +12,84 @@ config BASIC_DEMO_WIFI_PASSWORD help Default Wi-Fi password used to populate basic demo settings. +choice BASIC_DEMO_LLM_PROVIDER + prompt "Default LLM provider preset" + default BASIC_DEMO_LLM_PROVIDER_QWEN + help + Select the provider preset used to populate the shared LLM settings below. + OpenAI, Qwen Compatible, and Anthropic all reuse the same API key, model, + backend, profile, base URL, and auth fields. + +config BASIC_DEMO_LLM_PROVIDER_OPENAI + bool "OpenAI" + +config BASIC_DEMO_LLM_PROVIDER_QWEN + bool "Qwen Compatible" + +config BASIC_DEMO_LLM_PROVIDER_ANTHROPIC + bool "Anthropic" + +config BASIC_DEMO_LLM_PROVIDER_CUSTOM + bool "Custom" + +endchoice + config BASIC_DEMO_LLM_API_KEY string "Default LLM API key" default "" help - Default API key used to populate the basic demo LLM settings. + Default API key used to populate the shared LLM settings. + Anthropic reuses this field instead of having a dedicated provider-specific key. config BASIC_DEMO_LLM_BACKEND_TYPE string "Default LLM backend type" - default "openai_compatible" + default "openai_compatible" if BASIC_DEMO_LLM_PROVIDER_OPENAI + default "openai_compatible" if BASIC_DEMO_LLM_PROVIDER_QWEN + default "anthropic" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC + default "custom" if BASIC_DEMO_LLM_PROVIDER_CUSTOM help - Default backend type for claw_core, for example openai_compatible or custom. + Default backend type for claw_core. + The provider preset above already fills this for standard providers. config BASIC_DEMO_LLM_PROFILE string "Default LLM profile" - default "qwen_compatible" + default "openai" if BASIC_DEMO_LLM_PROVIDER_OPENAI + default "qwen_compatible" if BASIC_DEMO_LLM_PROVIDER_QWEN + default "anthropic" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC + default "custom_openai_compatible" if BASIC_DEMO_LLM_PROVIDER_CUSTOM help - Default runtime profile for claw_core, for example openai, qwen_compatible, or custom_openai_compatible. + Default runtime profile for claw_core. + The provider preset above already fills this for standard providers. config BASIC_DEMO_LLM_MODEL string "Default LLM model" - default "qwen3-max-2026-01-23" + default "" if BASIC_DEMO_LLM_PROVIDER_OPENAI + default "qwen3-max-2026-01-23" if BASIC_DEMO_LLM_PROVIDER_QWEN + default "" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC + default "" if BASIC_DEMO_LLM_PROVIDER_CUSTOM help Default model name for claw_core requests. + Set this explicitly for the provider you selected above. config BASIC_DEMO_LLM_BASE_URL string "Default LLM base URL" - default "" + default "https://api.openai.com" if BASIC_DEMO_LLM_PROVIDER_OPENAI + default "https://dashscope.aliyuncs.com" if BASIC_DEMO_LLM_PROVIDER_QWEN + default "https://api.anthropic.com" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC + default "" if BASIC_DEMO_LLM_PROVIDER_CUSTOM help - Optional override for the selected LLM profile base URL. + Default base URL for the selected provider preset. + Leave the provider preset as Custom if you need to override this manually. config BASIC_DEMO_LLM_AUTH_TYPE string "Default LLM auth type" - default "bearer" + default "bearer" if BASIC_DEMO_LLM_PROVIDER_OPENAI + default "bearer" if BASIC_DEMO_LLM_PROVIDER_QWEN + default "none" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC + default "bearer" if BASIC_DEMO_LLM_PROVIDER_CUSTOM help Auth mode for the LLM backend, for example bearer, api-key, or none. + The provider preset above already fills this for standard providers. config BASIC_DEMO_LLM_TIMEOUT_MS string "Default LLM timeout ms" @@ -66,6 +109,18 @@ config BASIC_DEMO_QQ_APP_SECRET help Default QQ app secret used by the IM cap. +config BASIC_DEMO_FEISHU_APP_ID + string "Default Feishu app ID" + default "" + help + Default Feishu app ID used by the IM cap. + +config BASIC_DEMO_FEISHU_APP_SECRET + string "Default Feishu app secret" + default "" + help + Default Feishu app secret used by the IM cap. + config BASIC_DEMO_TG_BOT_TOKEN string "Default Telegram bot token" default "" diff --git a/application/basic_demo/main/app_clawgent.c b/application/basic_demo/main/app_clawgent.c index dc4746a..c25fc1d 100644 --- a/application/basic_demo/main/app_clawgent.c +++ b/application/basic_demo/main/app_clawgent.c @@ -5,11 +5,14 @@ */ #include "app_clawgent.h" +#include #include +#include #include "basic_demo_lua_modules.h" #include "cap_cli.h" #include "cap_files.h" +#include "cap_im_feishu.h" #include "cap_im_qq.h" #include "cap_im_tg.h" #include "cap_im_wechat.h" @@ -100,7 +103,7 @@ static esp_err_t init_skills(void) static esp_err_t init_capabilities(const basic_demo_settings_t *settings) { claw_cap_config_t cap_config = { - .max_capabilities = 32, + .max_capabilities = 48, .max_groups = 16, }; @@ -215,6 +218,14 @@ static esp_err_t init_capabilities(const basic_demo_settings_t *settings) }), TAG, "Failed to set WeChat attachment config"); + ESP_RETURN_ON_ERROR(cap_im_feishu_set_attachment_config( + &(cap_im_feishu_attachment_config_t) { + .storage_root_dir = BASIC_DEMO_IM_ATTACHMENT_ROOT, + .max_inbound_file_bytes = BASIC_DEMO_IM_ATTACHMENT_MAX_BYTES, + .enable_inbound_attachments = true, + }), + TAG, + "Failed to set Feishu attachment config"); if (settings->qq_app_id[0] && settings->qq_app_secret[0]) { ESP_RETURN_ON_ERROR(cap_im_qq_set_credentials(settings->qq_app_id, @@ -223,6 +234,13 @@ static esp_err_t init_capabilities(const basic_demo_settings_t *settings) "Failed to set QQ credentials"); } + if (settings->feishu_app_id[0] && settings->feishu_app_secret[0]) { + ESP_RETURN_ON_ERROR(cap_im_feishu_set_credentials(settings->feishu_app_id, + settings->feishu_app_secret), + TAG, + "Failed to set Feishu credentials"); + } + if (settings->tg_bot_token[0]) { ESP_RETURN_ON_ERROR(cap_im_tg_set_token(settings->tg_bot_token), TAG, @@ -254,6 +272,9 @@ static esp_err_t init_capabilities(const basic_demo_settings_t *settings) } ESP_RETURN_ON_ERROR(cap_im_qq_register_group(), TAG, "Failed to register QQ cap"); + ESP_RETURN_ON_ERROR(cap_im_feishu_register_group(), + TAG, + "Failed to register Feishu cap"); ESP_RETURN_ON_ERROR(cap_im_tg_register_group(), TAG, "Failed to register Telegram cap"); @@ -293,23 +314,56 @@ static esp_err_t init_capabilities(const basic_demo_settings_t *settings) return ESP_OK; } +static const char *basic_demo_llm_provider_name(const basic_demo_settings_t *settings) +{ + if (!settings) { + return "unknown"; + } + + if ((settings->llm_backend_type[0] && strcmp(settings->llm_backend_type, "anthropic") == 0) || + (settings->llm_profile[0] && strcmp(settings->llm_profile, "anthropic") == 0)) { + return "Anthropic"; + } + if (settings->llm_profile[0] && + (strcmp(settings->llm_profile, "qwen") == 0 || + strcmp(settings->llm_profile, "qwen_compatible") == 0)) { + return "Qwen Compatible"; + } + if (settings->llm_profile[0] && strcmp(settings->llm_profile, "openai") == 0) { + return "OpenAI"; + } + return "Custom"; +} + +static bool basic_demo_llm_is_configured(const basic_demo_settings_t *settings) +{ + return settings && + settings->llm_api_key[0] && + settings->llm_model[0] && + settings->llm_profile[0]; +} + esp_err_t app_clawgent_start(const basic_demo_settings_t *settings) { claw_core_config_t core_config = {0}; claw_event_router_config_t router_config = { .rules_path = BASIC_DEMO_AUTOMATION_RULES_PATH, - .task_stack_size = 6144, + .task_stack_size = 12 * 1024, .task_priority = 5, .task_core = tskNO_AFFINITY, .core_submit_timeout_ms = 1000, .core_receive_timeout_ms = 130000, - .default_route_messages_to_agent = true, + .default_route_messages_to_agent = false, }; + bool llm_enabled = false; if (!settings) { return ESP_ERR_INVALID_ARG; } + llm_enabled = basic_demo_llm_is_configured(settings); + router_config.default_route_messages_to_agent = llm_enabled; + ESP_RETURN_ON_ERROR(claw_event_router_init(&router_config), TAG, "Failed to init event router"); @@ -319,6 +373,9 @@ esp_err_t app_clawgent_start(const basic_demo_settings_t *settings) ESP_RETURN_ON_ERROR(claw_event_router_register_outbound_binding("qq", "qq_send_message"), TAG, "Failed to bind QQ outbound"); + ESP_RETURN_ON_ERROR(claw_event_router_register_outbound_binding("feishu", "feishu_send_message"), + TAG, + "Failed to bind Feishu outbound"); ESP_RETURN_ON_ERROR(claw_event_router_register_outbound_binding("telegram", "tg_send_message"), TAG, "Failed to bind Telegram outbound"); @@ -344,23 +401,39 @@ esp_err_t app_clawgent_start(const basic_demo_settings_t *settings) core_config.response_queue_len = 4; core_config.max_context_providers = 5; - ESP_RETURN_ON_ERROR(claw_core_init(&core_config), TAG, "Failed to init claw_core"); - ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_memory_long_term_provider), - TAG, - "Failed to add long-term memory provider"); - ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_memory_session_history_provider), - TAG, - "Failed to add session history provider"); - ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_skill_skills_list_provider), - TAG, - "Failed to add skills list provider"); - ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_skill_active_skill_docs_provider), - TAG, - "Failed to add active skill docs provider"); - ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_cap_tools_provider), - TAG, - "Failed to add cap tools provider"); - ESP_RETURN_ON_ERROR(claw_core_start(), TAG, "Failed to start claw_core"); + if (!llm_enabled) { + ESP_LOGW(TAG, + "LLM is not fully configured. Provider=%s profile=%s model=%s. " + "The demo will start without claw_core; ask, auto-route-to-agent, and image analysis stay disabled until LLM API key, profile, and model are set.", + basic_demo_llm_provider_name(settings), + settings->llm_profile[0] ? settings->llm_profile : "(empty)", + settings->llm_model[0] ? settings->llm_model : "(empty)"); + } else { + ESP_LOGI(TAG, + "Starting LLM provider=%s profile=%s backend=%s model=%s", + basic_demo_llm_provider_name(settings), + settings->llm_profile, + settings->llm_backend_type[0] ? settings->llm_backend_type : "(default)", + settings->llm_model); + ESP_RETURN_ON_ERROR(claw_core_init(&core_config), TAG, "Failed to init claw_core"); + ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_memory_long_term_provider), + TAG, + "Failed to add long-term memory provider"); + ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_memory_session_history_provider), + TAG, + "Failed to add session history provider"); + ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_skill_skills_list_provider), + TAG, + "Failed to add skills list provider"); + ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_skill_active_skill_docs_provider), + TAG, + "Failed to add active skill docs provider"); + ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_cap_tools_provider), + TAG, + "Failed to add cap tools provider"); + ESP_RETURN_ON_ERROR(claw_core_start(), TAG, "Failed to start claw_core"); + } + ESP_RETURN_ON_ERROR(claw_event_router_start(), TAG, "Failed to start event router"); ESP_RETURN_ON_ERROR(basic_demo_cli_start(), TAG, "Failed to start CLI"); diff --git a/application/basic_demo/main/basic_demo_cli.c b/application/basic_demo/main/basic_demo_cli.c index 7ae0a33..6d503a0 100644 --- a/application/basic_demo/main/basic_demo_cli.c +++ b/application/basic_demo/main/basic_demo_cli.c @@ -9,6 +9,7 @@ #include #include "cap_im_qq.h" +#include "cmd_cap_im_feishu.h" #include "cmd_cap_llm_inspect.h" #include "cmd_cap_mcp_client.h" #include "cmd_cap_mcp_server.h" @@ -687,6 +688,7 @@ esp_err_t basic_demo_cli_start(void) esp_console_register_help_command(); register_cap_im_qq(); + register_cap_im_feishu(); register_cap_im_tg(); register_cap_im_wechat(); register_cap_lua(); diff --git a/application/basic_demo/main/basic_demo_settings.c b/application/basic_demo/main/basic_demo_settings.c index 75ce9d1..7d3aa88 100644 --- a/application/basic_demo/main/basic_demo_settings.c +++ b/application/basic_demo/main/basic_demo_settings.c @@ -44,6 +44,8 @@ static void settings_load_defaults(basic_demo_settings_t *settings) settings_safe_copy(settings->llm_timeout_ms, sizeof(settings->llm_timeout_ms), BASIC_DEMO_LLM_TIMEOUT_MS); settings_safe_copy(settings->qq_app_id, sizeof(settings->qq_app_id), BASIC_DEMO_QQ_APP_ID); settings_safe_copy(settings->qq_app_secret, sizeof(settings->qq_app_secret), BASIC_DEMO_QQ_APP_SECRET); + settings_safe_copy(settings->feishu_app_id, sizeof(settings->feishu_app_id), BASIC_DEMO_FEISHU_APP_ID); + settings_safe_copy(settings->feishu_app_secret, sizeof(settings->feishu_app_secret), BASIC_DEMO_FEISHU_APP_SECRET); settings_safe_copy(settings->tg_bot_token, sizeof(settings->tg_bot_token), BASIC_DEMO_TG_BOT_TOKEN); settings_safe_copy(settings->wechat_token, sizeof(settings->wechat_token), BASIC_DEMO_WECHAT_TOKEN); settings_safe_copy(settings->wechat_base_url, sizeof(settings->wechat_base_url), BASIC_DEMO_WECHAT_BASE_URL); @@ -110,6 +112,8 @@ esp_err_t basic_demo_settings_load(basic_demo_settings_t *settings) { "llm_timeout_ms", settings->llm_timeout_ms, settings->llm_timeout_ms, sizeof(settings->llm_timeout_ms) }, { "qq_app_id", settings->qq_app_id, settings->qq_app_id, sizeof(settings->qq_app_id) }, { "qq_app_secret", settings->qq_app_secret, settings->qq_app_secret, sizeof(settings->qq_app_secret) }, + { "feishu_app_id", settings->feishu_app_id, settings->feishu_app_id, sizeof(settings->feishu_app_id) }, + { "feishu_app_secret", settings->feishu_app_secret, settings->feishu_app_secret, sizeof(settings->feishu_app_secret) }, { "tg_bot_token", settings->tg_bot_token, settings->tg_bot_token, sizeof(settings->tg_bot_token) }, { "wechat_token", settings->wechat_token, settings->wechat_token, sizeof(settings->wechat_token) }, { "wechat_base_url", settings->wechat_base_url, settings->wechat_base_url, sizeof(settings->wechat_base_url) }, @@ -169,6 +173,8 @@ esp_err_t basic_demo_settings_save(const basic_demo_settings_t *settings) { "llm_timeout_ms", NULL, (char *)settings->llm_timeout_ms, sizeof(settings->llm_timeout_ms) }, { "qq_app_id", NULL, (char *)settings->qq_app_id, sizeof(settings->qq_app_id) }, { "qq_app_secret", NULL, (char *)settings->qq_app_secret, sizeof(settings->qq_app_secret) }, + { "feishu_app_id", NULL, (char *)settings->feishu_app_id, sizeof(settings->feishu_app_id) }, + { "feishu_app_secret", NULL, (char *)settings->feishu_app_secret, sizeof(settings->feishu_app_secret) }, { "tg_bot_token", NULL, (char *)settings->tg_bot_token, sizeof(settings->tg_bot_token) }, { "wechat_token", NULL, (char *)settings->wechat_token, sizeof(settings->wechat_token) }, { "wechat_base_url", NULL, (char *)settings->wechat_base_url, sizeof(settings->wechat_base_url) }, diff --git a/application/basic_demo/main/basic_demo_settings.h b/application/basic_demo/main/basic_demo_settings.h index 98e1c5a..875c29f 100644 --- a/application/basic_demo/main/basic_demo_settings.h +++ b/application/basic_demo/main/basic_demo_settings.h @@ -7,7 +7,7 @@ #include "esp_err.h" -#define BASIC_DEMO_SETTINGS_STR_LEN 160 +#define BASIC_DEMO_SETTINGS_STR_LEN 320 #define BASIC_DEMO_SETTINGS_LUA_DIR_LEN 64 #define BASIC_DEMO_SETTINGS_TIMEZONE_LEN 32 @@ -22,6 +22,8 @@ #define BASIC_DEMO_LLM_TIMEOUT_MS CONFIG_BASIC_DEMO_LLM_TIMEOUT_MS #define BASIC_DEMO_QQ_APP_ID CONFIG_BASIC_DEMO_QQ_APP_ID #define BASIC_DEMO_QQ_APP_SECRET CONFIG_BASIC_DEMO_QQ_APP_SECRET +#define BASIC_DEMO_FEISHU_APP_ID CONFIG_BASIC_DEMO_FEISHU_APP_ID +#define BASIC_DEMO_FEISHU_APP_SECRET CONFIG_BASIC_DEMO_FEISHU_APP_SECRET #define BASIC_DEMO_TG_BOT_TOKEN CONFIG_BASIC_DEMO_TG_BOT_TOKEN #define BASIC_DEMO_WECHAT_TOKEN CONFIG_BASIC_DEMO_WECHAT_TOKEN #define BASIC_DEMO_WECHAT_BASE_URL CONFIG_BASIC_DEMO_WECHAT_BASE_URL @@ -43,6 +45,8 @@ typedef struct { char llm_timeout_ms[16]; char qq_app_id[32]; char qq_app_secret[BASIC_DEMO_SETTINGS_STR_LEN]; + char feishu_app_id[64]; + char feishu_app_secret[BASIC_DEMO_SETTINGS_STR_LEN]; char tg_bot_token[BASIC_DEMO_SETTINGS_STR_LEN]; char wechat_token[BASIC_DEMO_SETTINGS_STR_LEN]; char wechat_base_url[BASIC_DEMO_SETTINGS_STR_LEN]; diff --git a/application/basic_demo/main/config_http_server.c b/application/basic_demo/main/config_http_server.c index c25cba6..6573e1f 100644 --- a/application/basic_demo/main/config_http_server.c +++ b/application/basic_demo/main/config_http_server.c @@ -190,6 +190,8 @@ static esp_err_t settings_to_json(httpd_req_t *req) json_add_string(root, "llm_timeout_ms", settings.llm_timeout_ms); json_add_string(root, "qq_app_id", settings.qq_app_id); json_add_string(root, "qq_app_secret", settings.qq_app_secret); + json_add_string(root, "feishu_app_id", settings.feishu_app_id); + json_add_string(root, "feishu_app_secret", settings.feishu_app_secret); json_add_string(root, "tg_bot_token", settings.tg_bot_token); json_add_string(root, "wechat_token", settings.wechat_token); json_add_string(root, "wechat_base_url", settings.wechat_base_url); @@ -321,6 +323,8 @@ static esp_err_t config_post_handler(httpd_req_t *req) json_read_string(root, "llm_timeout_ms", settings.llm_timeout_ms, sizeof(settings.llm_timeout_ms)); json_read_string(root, "qq_app_id", settings.qq_app_id, sizeof(settings.qq_app_id)); json_read_string(root, "qq_app_secret", settings.qq_app_secret, sizeof(settings.qq_app_secret)); + json_read_string(root, "feishu_app_id", settings.feishu_app_id, sizeof(settings.feishu_app_id)); + json_read_string(root, "feishu_app_secret", settings.feishu_app_secret, sizeof(settings.feishu_app_secret)); json_read_string(root, "tg_bot_token", settings.tg_bot_token, sizeof(settings.tg_bot_token)); json_read_string(root, "wechat_token", settings.wechat_token, sizeof(settings.wechat_token)); json_read_string(root, "wechat_base_url", settings.wechat_base_url, sizeof(settings.wechat_base_url)); diff --git a/application/basic_demo/main/web/app.js b/application/basic_demo/main/web/app.js index fa8c585..e463ad0 100644 --- a/application/basic_demo/main/web/app.js +++ b/application/basic_demo/main/web/app.js @@ -10,6 +10,8 @@ const configFields = [ "llm_timeout_ms", "qq_app_id", "qq_app_secret", + "feishu_app_id", + "feishu_app_secret", "tg_bot_token", "wechat_token", "wechat_base_url", @@ -23,6 +25,26 @@ const configFields = [ let currentPath = "/"; let wechatLoginPollTimer = null; +const llmProviderPresets = { + openai: { + llm_backend_type: "openai_compatible", + llm_profile: "openai", + llm_base_url: "https://api.openai.com", + llm_auth_type: "bearer", + }, + qwen: { + llm_backend_type: "openai_compatible", + llm_profile: "qwen_compatible", + llm_base_url: "https://dashscope.aliyuncs.com", + llm_auth_type: "bearer", + }, + anthropic: { + llm_backend_type: "anthropic", + llm_profile: "anthropic", + llm_base_url: "https://api.anthropic.com", + llm_auth_type: "none", + }, +}; function showBanner(id, message, isError = false) { const banner = document.getElementById(id); @@ -55,6 +77,46 @@ function fillConfigForm(data) { input.value = data[field]; } }); + syncProviderPreset(); +} + +function detectProviderPreset() { + const backend = document.getElementById("llm_backend_type")?.value.trim(); + const profile = document.getElementById("llm_profile")?.value.trim(); + const baseUrl = document.getElementById("llm_base_url")?.value.trim(); + const authType = document.getElementById("llm_auth_type")?.value.trim(); + + const match = Object.entries(llmProviderPresets).find(([, preset]) => + preset.llm_backend_type === backend && + preset.llm_profile === profile && + preset.llm_base_url === baseUrl && + preset.llm_auth_type === authType, + ); + + return match ? match[0] : "custom"; +} + +function syncProviderPreset() { + const select = document.getElementById("llm_provider_preset"); + if (select) { + select.value = detectProviderPreset(); + } +} + +function applyProviderPreset(presetKey) { + const preset = llmProviderPresets[presetKey]; + if (!preset) { + syncProviderPreset(); + return; + } + + Object.entries(preset).forEach(([field, value]) => { + const input = document.getElementById(field); + if (input) { + input.value = value; + } + }); + syncProviderPreset(); } function humanSize(value) { @@ -114,6 +176,7 @@ async function saveConfig() { throw new Error(result.error || "Failed to save settings"); } showBanner("configMessage", result.message || "Settings saved"); + syncProviderPreset(); } catch (error) { showBanner("configMessage", error.message, true); } finally { @@ -388,6 +451,15 @@ async function deletePath(path) { function bindEvents() { document.getElementById("saveConfigButton").addEventListener("click", saveConfig); + document.getElementById("llm_provider_preset").addEventListener("change", (event) => { + applyProviderPreset(event.target.value); + }); + ["llm_backend_type", "llm_profile", "llm_base_url", "llm_auth_type"].forEach((field) => { + const input = document.getElementById(field); + if (input) { + input.addEventListener("input", syncProviderPreset); + } + }); document.getElementById("wechatLoginStartButton").addEventListener("click", startWechatLogin); document.getElementById("wechatLoginCancelButton").addEventListener("click", cancelWechatLogin); document.getElementById("refreshFilesButton").addEventListener("click", () => loadFiles().catch((error) => { diff --git a/application/basic_demo/main/web/index.html b/application/basic_demo/main/web/index.html index d8e7b37..5234aa3 100644 --- a/application/basic_demo/main/web/index.html +++ b/application/basic_demo/main/web/index.html @@ -46,29 +46,43 @@ - - - +
+ Advanced LLM options +
+ + + + +
+
+ + -

Changes are stored in NVS. Restart the device after updating Wi-Fi or core LLM settings.

+

Changes are stored in NVS. Choose an LLM provider first, then fill the shared API key and model fields. OpenAI, Qwen, and Anthropic all reuse the same LLM settings. Restart the device after updating Wi-Fi, Feishu, or core LLM settings.

diff --git a/application/basic_demo/main/web/styles.css b/application/basic_demo/main/web/styles.css index dcc2d43..d90de8c 100644 --- a/application/basic_demo/main/web/styles.css +++ b/application/basic_demo/main/web/styles.css @@ -147,11 +147,13 @@ label { } input, +select, button { font: inherit; } -input { +input, +select { width: 100%; padding: 13px 14px; border: 1px solid var(--line); @@ -160,11 +162,29 @@ input { color: var(--ink); } -input:focus { +input:focus, +select:focus { outline: 2px solid rgba(182, 70, 31, 0.22); border-color: rgba(182, 70, 31, 0.35); } +.advanced-panel { + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.72); +} + +.advanced-panel summary { + cursor: pointer; + color: var(--accent-deep); + font-weight: 600; +} + +.advanced-grid { + margin-top: 14px; +} + button { border: none; border-radius: 999px; diff --git a/components/claw_capabilities/cap_im_feishu/CMakeLists.txt b/components/claw_capabilities/cap_im_feishu/CMakeLists.txt new file mode 100644 index 0000000..337f669 --- /dev/null +++ b/components/claw_capabilities/cap_im_feishu/CMakeLists.txt @@ -0,0 +1,18 @@ +idf_component_register( + SRCS + "src/cap_im_feishu.c" + "src/cmd_cap_im_feishu.c" + INCLUDE_DIRS + "include" + REQUIRES + cap_im_attachment + claw_cap + claw_event_router + esp_event + esp_http_client + esp_timer + esp_websocket_client + freertos + json + console +) diff --git a/components/claw_capabilities/cap_im_feishu/include/cap_im_feishu.h b/components/claw_capabilities/cap_im_feishu/include/cap_im_feishu.h new file mode 100644 index 0000000..fd059ef --- /dev/null +++ b/components/claw_capabilities/cap_im_feishu/include/cap_im_feishu.h @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + const char *storage_root_dir; + size_t max_inbound_file_bytes; + bool enable_inbound_attachments; +} cap_im_feishu_attachment_config_t; + +esp_err_t cap_im_feishu_register_group(void); +esp_err_t cap_im_feishu_set_credentials(const char *app_id, const char *app_secret); +esp_err_t cap_im_feishu_set_attachment_config( + const cap_im_feishu_attachment_config_t *config); +esp_err_t cap_im_feishu_start(void); +esp_err_t cap_im_feishu_stop(void); +esp_err_t cap_im_feishu_send_text(const char *chat_id, const char *text); +esp_err_t cap_im_feishu_send_image(const char *chat_id, const char *path, const char *caption); +esp_err_t cap_im_feishu_send_file(const char *chat_id, const char *path, const char *caption); + +#ifdef __cplusplus +} +#endif diff --git a/components/claw_capabilities/cap_im_feishu/include/cmd_cap_im_feishu.h b/components/claw_capabilities/cap_im_feishu/include/cmd_cap_im_feishu.h new file mode 100644 index 0000000..ad21a05 --- /dev/null +++ b/components/claw_capabilities/cap_im_feishu/include/cmd_cap_im_feishu.h @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +void register_cap_im_feishu(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/claw_capabilities/cap_im_feishu/src/cap_im_feishu.c b/components/claw_capabilities/cap_im_feishu/src/cap_im_feishu.c new file mode 100644 index 0000000..aef88d9 --- /dev/null +++ b/components/claw_capabilities/cap_im_feishu/src/cap_im_feishu.c @@ -0,0 +1,2690 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_im_feishu.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cap_im_attachment.h" +#include "claw_cap.h" +#include "claw_event_router.h" +#include "cJSON.h" +#include "esp_crt_bundle.h" +#include "esp_event.h" +#include "esp_http_client.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "esp_websocket_client.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" + +#define CAP_IM_FEISHU_API_BASE "https://open.feishu.cn/open-apis" +#define CAP_IM_FEISHU_AUTH_URL CAP_IM_FEISHU_API_BASE "/auth/v3/tenant_access_token/internal" +#define CAP_IM_FEISHU_SEND_MSG_URL CAP_IM_FEISHU_API_BASE "/im/v1/messages" +#define CAP_IM_FEISHU_UPLOAD_IMAGE_URL CAP_IM_FEISHU_API_BASE "/im/v1/images" +#define CAP_IM_FEISHU_UPLOAD_FILE_URL CAP_IM_FEISHU_API_BASE "/im/v1/files" +#define CAP_IM_FEISHU_WS_CONFIG_URL "https://open.feishu.cn/callback/ws/endpoint" +#define CAP_IM_FEISHU_MULTIPART_BOUNDARY "----cap_im_feishu_boundary" + +#define CAP_IM_FEISHU_APP_ID_LEN 64 +#define CAP_IM_FEISHU_APP_SECRET_LEN 256 +#define CAP_IM_FEISHU_TOKEN_LEN 512 +#define CAP_IM_FEISHU_URL_LEN 512 +#define CAP_IM_FEISHU_PATH_LEN 256 +#define CAP_IM_FEISHU_NAME_LEN 96 +#define CAP_IM_FEISHU_EVENT_ID_LEN 96 +#define CAP_IM_FEISHU_MEDIA_KEY_LEN 256 +#define CAP_IM_FEISHU_MAX_HEADERS 16 +#define CAP_IM_FEISHU_MAX_RESPONSE 4096 +#define CAP_IM_FEISHU_MAX_CHUNK_LEN 1800 +#define CAP_IM_FEISHU_DEDUP_CACHE_SIZE 64 +#define CAP_IM_FEISHU_RECONNECT_DELAY_MS 3000 +#define CAP_IM_FEISHU_INITIAL_CONNECT_TIMEOUT_MS 15000 +#define CAP_IM_FEISHU_ATTACHMENT_STACK 8192 +#define CAP_IM_FEISHU_ATTACHMENT_QUEUE_LEN 8 + +static const char *TAG = "cap_im_feishu"; + +typedef struct { + char *buf; + size_t len; + size_t cap; +} cap_im_feishu_resp_t; + +typedef struct { + char key[32]; + char value[128]; +} cap_im_feishu_ws_header_t; + +typedef struct { + uint64_t seq_id; + uint64_t log_id; + int32_t service; + int32_t method; + cap_im_feishu_ws_header_t headers[CAP_IM_FEISHU_MAX_HEADERS]; + size_t header_count; + const uint8_t *payload; + size_t payload_len; +} cap_im_feishu_ws_frame_t; + +typedef struct { + char *chat_id; + char *sender_id; + char *message_id; + char *attachment_kind; + char *resource_key; + char *original_filename; + char *content_json; + char *content_type; +} cap_im_feishu_attachment_job_t; + +typedef struct { + char app_id[CAP_IM_FEISHU_APP_ID_LEN]; + char app_secret[CAP_IM_FEISHU_APP_SECRET_LEN]; + char tenant_token[CAP_IM_FEISHU_TOKEN_LEN]; + int64_t token_expire_time_ms; + char ws_url[CAP_IM_FEISHU_URL_LEN]; + int ws_ping_interval_ms; + int ws_reconnect_interval_ms; + int ws_reconnect_nonce_ms; + int ws_service_id; + bool ws_connected; + bool ws_ever_connected; + int64_t ws_disconnect_since_ms; + bool stop_requested; + bool enable_inbound_attachments; + size_t max_inbound_file_bytes; + char attachment_root_dir[CAP_IM_FEISHU_PATH_LEN]; + esp_websocket_client_handle_t ws_client; + TaskHandle_t ws_task; + TaskHandle_t attachment_task; + QueueHandle_t attachment_queue; + uint64_t seen_message_keys[CAP_IM_FEISHU_DEDUP_CACHE_SIZE]; + size_t seen_message_idx; +} cap_im_feishu_state_t; + +static cap_im_feishu_state_t s_feishu = { + .ws_ping_interval_ms = 120000, + .ws_reconnect_interval_ms = 30000, + .ws_reconnect_nonce_ms = 30000, +}; + +static int64_t cap_im_feishu_now_ms(void) +{ + return esp_timer_get_time() / 1000LL; +} + +static uint64_t cap_im_feishu_fnv1a64(const char *text) +{ + uint64_t hash = 1469598103934665603ULL; + + if (!text) { + return hash; + } + + while (*text) { + hash ^= (uint8_t)(*text++); + hash *= 1099511628211ULL; + } + + return hash; +} + +static bool cap_im_feishu_dedup_check_and_record(const char *message_id) +{ + uint64_t key = cap_im_feishu_fnv1a64(message_id); + size_t i; + + for (i = 0; i < CAP_IM_FEISHU_DEDUP_CACHE_SIZE; i++) { + if (s_feishu.seen_message_keys[i] == key) { + return true; + } + } + + s_feishu.seen_message_keys[s_feishu.seen_message_idx] = key; + s_feishu.seen_message_idx = (s_feishu.seen_message_idx + 1) % CAP_IM_FEISHU_DEDUP_CACHE_SIZE; + return false; +} + +static esp_err_t cap_im_feishu_resp_init(cap_im_feishu_resp_t *resp) +{ + if (!resp) { + return ESP_ERR_INVALID_ARG; + } + + resp->buf = calloc(1, CAP_IM_FEISHU_MAX_RESPONSE); + if (!resp->buf) { + return ESP_ERR_NO_MEM; + } + resp->cap = CAP_IM_FEISHU_MAX_RESPONSE; + resp->len = 0; + return ESP_OK; +} + +static esp_err_t cap_im_feishu_resp_append(cap_im_feishu_resp_t *resp, const char *data, size_t len) +{ + char *grown = NULL; + size_t cap = 0; + + if (!resp || !data) { + return ESP_ERR_INVALID_ARG; + } + + cap = resp->cap; + while (resp->len + len + 1 > cap) { + cap *= 2; + } + + if (cap != resp->cap) { + grown = realloc(resp->buf, cap); + if (!grown) { + return ESP_ERR_NO_MEM; + } + resp->buf = grown; + resp->cap = cap; + } + + memcpy(resp->buf + resp->len, data, len); + resp->len += len; + resp->buf[resp->len] = '\0'; + return ESP_OK; +} + +static void cap_im_feishu_resp_free(cap_im_feishu_resp_t *resp) +{ + if (!resp) { + return; + } + + free(resp->buf); + memset(resp, 0, sizeof(*resp)); +} + +static esp_err_t cap_im_feishu_http_event_handler(esp_http_client_event_t *evt) +{ + cap_im_feishu_resp_t *resp = (cap_im_feishu_resp_t *)evt->user_data; + + if (evt->event_id == HTTP_EVENT_ON_DATA) { + return cap_im_feishu_resp_append(resp, (const char *)evt->data, evt->data_len); + } + + return ESP_OK; +} + +static esp_err_t cap_im_feishu_http_json(const char *url, + const char *method, + const char *authorization, + const char *body, + char **out_response, + int *out_status) +{ + cap_im_feishu_resp_t resp = {0}; + esp_http_client_config_t config = {0}; + esp_http_client_handle_t client = NULL; + esp_err_t err; + + if (out_response) { + *out_response = NULL; + } + if (out_status) { + *out_status = 0; + } + if (!url || !method || !out_response || !out_status) { + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_feishu_resp_init(&resp); + if (err != ESP_OK) { + return err; + } + + config.url = url; + config.event_handler = cap_im_feishu_http_event_handler; + config.user_data = &resp; + config.timeout_ms = 15000; + config.buffer_size = 2048; + config.buffer_size_tx = 2048; + config.crt_bundle_attach = esp_crt_bundle_attach; + + client = esp_http_client_init(&config); + if (!client) { + cap_im_feishu_resp_free(&resp); + return ESP_FAIL; + } + + if (strcmp(method, "POST") == 0) { + esp_http_client_set_method(client, HTTP_METHOD_POST); + } else { + esp_http_client_set_method(client, HTTP_METHOD_GET); + } + esp_http_client_set_header(client, "Content-Type", "application/json; charset=utf-8"); + if (authorization && authorization[0]) { + esp_http_client_set_header(client, "Authorization", authorization); + } + if (body) { + esp_http_client_set_post_field(client, body, (int)strlen(body)); + } + + err = esp_http_client_perform(client); + *out_status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + if (err != ESP_OK) { + cap_im_feishu_resp_free(&resp); + return err; + } + + *out_response = resp.buf; + resp.buf = NULL; + cap_im_feishu_resp_free(&resp); + return ESP_OK; +} + +static esp_err_t cap_im_feishu_get_tenant_token(void) +{ + cJSON *body = NULL; + char *json_str = NULL; + char *response = NULL; + cJSON *root = NULL; + cJSON *code_json = NULL; + cJSON *token_json = NULL; + cJSON *expire_json = NULL; + int status = 0; + int64_t now_ms = cap_im_feishu_now_ms(); + esp_err_t err = ESP_OK; + + if (s_feishu.tenant_token[0] && now_ms + 300000 < s_feishu.token_expire_time_ms) { + return ESP_OK; + } + if (!s_feishu.app_id[0] || !s_feishu.app_secret[0]) { + return ESP_ERR_INVALID_STATE; + } + + body = cJSON_CreateObject(); + if (!body) { + return ESP_ERR_NO_MEM; + } + cJSON_AddStringToObject(body, "app_id", s_feishu.app_id); + cJSON_AddStringToObject(body, "app_secret", s_feishu.app_secret); + json_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!json_str) { + return ESP_ERR_NO_MEM; + } + + err = cap_im_feishu_http_json(CAP_IM_FEISHU_AUTH_URL, "POST", NULL, json_str, &response, &status); + free(json_str); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Feishu token request failed: %s", esp_err_to_name(err)); + return err; + } + if (status != 200) { + ESP_LOGE(TAG, "Feishu token request HTTP %d", status); + free(response); + return ESP_FAIL; + } + + root = cJSON_Parse(response); + free(response); + if (!root) { + ESP_LOGE(TAG, "Feishu token response parse failed"); + return ESP_FAIL; + } + + code_json = cJSON_GetObjectItem(root, "code"); + token_json = cJSON_GetObjectItem(root, "tenant_access_token"); + expire_json = cJSON_GetObjectItem(root, "expire"); + if (!cJSON_IsNumber(code_json) || code_json->valueint != 0 || + !cJSON_IsString(token_json) || !token_json->valuestring) { + ESP_LOGE(TAG, "Feishu token response invalid"); + cJSON_Delete(root); + return ESP_FAIL; + } + + strlcpy(s_feishu.tenant_token, token_json->valuestring, sizeof(s_feishu.tenant_token)); + s_feishu.token_expire_time_ms = now_ms + + (int64_t)(cJSON_IsNumber(expire_json) ? expire_json->valueint : 7200) * 1000LL; + ESP_LOGI(TAG, "Feishu tenant token refreshed"); + cJSON_Delete(root); + return ESP_OK; +} + +static esp_err_t cap_im_feishu_api_call(const char *url, const char *method, const char *body, char **out_response) +{ + char auth_header[CAP_IM_FEISHU_TOKEN_LEN + 16]; + int status = 0; + esp_err_t err; + + if (!url || !method || !out_response) { + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_feishu_get_tenant_token(); + if (err != ESP_OK) { + return err; + } + + snprintf(auth_header, sizeof(auth_header), "Bearer %s", s_feishu.tenant_token); + err = cap_im_feishu_http_json(url, method, auth_header, body, out_response, &status); + if (err != ESP_OK) { + return err; + } + if (status != 200) { + ESP_LOGE(TAG, "Feishu API call HTTP %d url=%s", status, url); + free(*out_response); + *out_response = NULL; + return ESP_FAIL; + } + return ESP_OK; +} + +static esp_err_t cap_im_feishu_validate_api_success_response(const char *response) +{ + cJSON *root = NULL; + cJSON *code_json = NULL; + esp_err_t err = ESP_FAIL; + + if (!response) { + return ESP_ERR_INVALID_ARG; + } + + root = cJSON_Parse(response); + if (!root) { + return ESP_FAIL; + } + + code_json = cJSON_GetObjectItem(root, "code"); + if (cJSON_IsNumber(code_json) && code_json->valueint == 0) { + err = ESP_OK; + } + cJSON_Delete(root); + return err; +} + +static bool cap_im_feishu_parse_query_param(const char *url, const char *key, char *out, size_t out_size) +{ + const char *q = NULL; + size_t key_len = 0; + + if (!url || !key || !out || out_size == 0) { + return false; + } + + q = strchr(url, '?'); + if (!q) { + return false; + } + q++; + key_len = strlen(key); + + while (*q) { + const char *eq = strchr(q, '='); + const char *amp = NULL; + size_t name_len = 0; + size_t value_len = 0; + size_t copy_len = 0; + + if (!eq) { + break; + } + amp = strchr(eq + 1, '&'); + name_len = (size_t)(eq - q); + if (name_len == key_len && strncmp(q, key, key_len) == 0) { + value_len = amp ? (size_t)(amp - (eq + 1)) : strlen(eq + 1); + copy_len = value_len < out_size - 1 ? value_len : out_size - 1; + memcpy(out, eq + 1, copy_len); + out[copy_len] = '\0'; + return true; + } + if (!amp) { + break; + } + q = amp + 1; + } + + return false; +} + +static const char *cap_im_feishu_basename(const char *path) +{ + const char *slash = NULL; + + if (!path || !path[0]) { + return ""; + } + + slash = strrchr(path, '/'); + return slash ? slash + 1 : path; +} + +static const char *cap_im_feishu_guess_upload_mime(const char *path, bool is_image) +{ + const char *extension = NULL; + + if (!path) { + return is_image ? "image/jpeg" : "application/octet-stream"; + } + + extension = strrchr(path, '.'); + if (!extension || !extension[1]) { + return is_image ? "image/jpeg" : "application/octet-stream"; + } + + extension++; + if (strcasecmp(extension, "jpg") == 0 || strcasecmp(extension, "jpeg") == 0) { + return "image/jpeg"; + } + if (strcasecmp(extension, "png") == 0) { + return "image/png"; + } + if (strcasecmp(extension, "gif") == 0) { + return "image/gif"; + } + if (strcasecmp(extension, "webp") == 0) { + return "image/webp"; + } + if (strcasecmp(extension, "txt") == 0) { + return "text/plain"; + } + if (strcasecmp(extension, "json") == 0) { + return "application/json"; + } + if (strcasecmp(extension, "pdf") == 0) { + return "application/pdf"; + } + + return is_image ? "image/jpeg" : "application/octet-stream"; +} + +static esp_err_t cap_im_feishu_http_client_write_all(esp_http_client_handle_t client, + const char *buf, + size_t len) +{ + size_t offset = 0; + + if (!client || (!buf && len > 0)) { + return ESP_ERR_INVALID_ARG; + } + + while (offset < len) { + int written = esp_http_client_write(client, buf + offset, (int)(len - offset)); + + if (written <= 0) { + return ESP_FAIL; + } + offset += (size_t)written; + } + + return ESP_OK; +} + +static esp_err_t cap_im_feishu_stream_file_to_http_client(esp_http_client_handle_t client, FILE *file) +{ + char buf[2048]; + + if (!client || !file) { + return ESP_ERR_INVALID_ARG; + } + + while (!feof(file)) { + size_t read_len = fread(buf, 1, sizeof(buf), file); + + if (read_len == 0) { + if (ferror(file)) { + return ESP_FAIL; + } + break; + } + if (cap_im_feishu_http_client_write_all(client, buf, read_len) != ESP_OK) { + return ESP_FAIL; + } + } + + return ESP_OK; +} + +static esp_err_t cap_im_feishu_pull_ws_config(void) +{ + cJSON *body = NULL; + char *json_str = NULL; + char *response = NULL; + cJSON *root = NULL; + cJSON *code_json = NULL; + cJSON *data_json = NULL; + cJSON *url_json = NULL; + cJSON *config_json = NULL; + char service_id[24] = {0}; + int status = 0; + esp_err_t err; + + body = cJSON_CreateObject(); + if (!body) { + return ESP_ERR_NO_MEM; + } + cJSON_AddStringToObject(body, "AppID", s_feishu.app_id); + cJSON_AddStringToObject(body, "AppSecret", s_feishu.app_secret); + json_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!json_str) { + return ESP_ERR_NO_MEM; + } + + err = cap_im_feishu_http_json(CAP_IM_FEISHU_WS_CONFIG_URL, "POST", NULL, json_str, &response, &status); + free(json_str); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Feishu WS config request failed: %s", esp_err_to_name(err)); + return err; + } + if (status != 200) { + ESP_LOGE(TAG, "Feishu WS config HTTP %d", status); + free(response); + return ESP_FAIL; + } + + root = cJSON_Parse(response); + free(response); + if (!root) { + ESP_LOGE(TAG, "Feishu WS config parse failed"); + return ESP_FAIL; + } + + code_json = cJSON_GetObjectItem(root, "code"); + data_json = cJSON_GetObjectItem(root, "data"); + url_json = cJSON_IsObject(data_json) ? cJSON_GetObjectItem(data_json, "URL") : NULL; + config_json = cJSON_IsObject(data_json) ? cJSON_GetObjectItem(data_json, "ClientConfig") : NULL; + if (!cJSON_IsNumber(code_json) || code_json->valueint != 0 || + !cJSON_IsString(url_json) || !url_json->valuestring) { + cJSON_Delete(root); + ESP_LOGE(TAG, "Feishu WS config response invalid"); + return ESP_FAIL; + } + + strlcpy(s_feishu.ws_url, url_json->valuestring, sizeof(s_feishu.ws_url)); + if (cap_im_feishu_parse_query_param(s_feishu.ws_url, "service_id", service_id, sizeof(service_id))) { + s_feishu.ws_service_id = atoi(service_id); + } + if (cJSON_IsObject(config_json)) { + cJSON *ping_json = cJSON_GetObjectItem(config_json, "PingInterval"); + cJSON *reconnect_json = cJSON_GetObjectItem(config_json, "ReconnectInterval"); + cJSON *nonce_json = cJSON_GetObjectItem(config_json, "ReconnectNonce"); + + if (cJSON_IsNumber(ping_json)) { + s_feishu.ws_ping_interval_ms = ping_json->valueint * 1000; + } + if (cJSON_IsNumber(reconnect_json)) { + s_feishu.ws_reconnect_interval_ms = reconnect_json->valueint * 1000; + } + if (cJSON_IsNumber(nonce_json)) { + s_feishu.ws_reconnect_nonce_ms = nonce_json->valueint * 1000; + } + } + + ESP_LOGI(TAG, "Feishu WS endpoint ready service_id=%d", s_feishu.ws_service_id); + cJSON_Delete(root); + return ESP_OK; +} + +static bool cap_im_feishu_pb_read_varint(const uint8_t *buf, size_t len, size_t *pos, uint64_t *out) +{ + uint64_t value = 0; + int shift = 0; + + while (*pos < len && shift <= 63) { + uint8_t byte = buf[(*pos)++]; + + value |= ((uint64_t)(byte & 0x7F)) << shift; + if ((byte & 0x80) == 0) { + *out = value; + return true; + } + shift += 7; + } + + return false; +} + +static bool cap_im_feishu_pb_skip_field(const uint8_t *buf, size_t len, size_t *pos, uint8_t wire_type) +{ + uint64_t size = 0; + + switch (wire_type) { + case 0: + return cap_im_feishu_pb_read_varint(buf, len, pos, &size); + case 1: + if (*pos + 8 > len) { + return false; + } + *pos += 8; + return true; + case 2: + if (!cap_im_feishu_pb_read_varint(buf, len, pos, &size)) { + return false; + } + if (*pos + (size_t)size > len) { + return false; + } + *pos += (size_t)size; + return true; + case 5: + if (*pos + 4 > len) { + return false; + } + *pos += 4; + return true; + default: + return false; + } +} + +static bool cap_im_feishu_pb_parse_header(const uint8_t *buf, + size_t len, + cap_im_feishu_ws_header_t *header) +{ + size_t pos = 0; + + memset(header, 0, sizeof(*header)); + while (pos < len) { + uint64_t tag = 0; + uint64_t size = 0; + uint32_t field = 0; + uint8_t wire_type = 0; + size_t copy_len = 0; + + if (!cap_im_feishu_pb_read_varint(buf, len, &pos, &tag)) { + return false; + } + field = (uint32_t)(tag >> 3); + wire_type = (uint8_t)(tag & 0x07); + if (wire_type != 2) { + if (!cap_im_feishu_pb_skip_field(buf, len, &pos, wire_type)) { + return false; + } + continue; + } + if (!cap_im_feishu_pb_read_varint(buf, len, &pos, &size) || pos + (size_t)size > len) { + return false; + } + if (field == 1) { + copy_len = (size_t)size < sizeof(header->key) - 1 ? (size_t)size : sizeof(header->key) - 1; + memcpy(header->key, buf + pos, copy_len); + header->key[copy_len] = '\0'; + } else if (field == 2) { + copy_len = (size_t)size < sizeof(header->value) - 1 ? (size_t)size : sizeof(header->value) - 1; + memcpy(header->value, buf + pos, copy_len); + header->value[copy_len] = '\0'; + } + pos += (size_t)size; + } + + return true; +} + +static bool cap_im_feishu_pb_parse_frame(const uint8_t *buf, + size_t len, + cap_im_feishu_ws_frame_t *frame) +{ + size_t pos = 0; + + memset(frame, 0, sizeof(*frame)); + while (pos < len) { + uint64_t tag = 0; + uint64_t value = 0; + uint64_t size = 0; + uint32_t field = 0; + uint8_t wire_type = 0; + + if (!cap_im_feishu_pb_read_varint(buf, len, &pos, &tag)) { + return false; + } + field = (uint32_t)(tag >> 3); + wire_type = (uint8_t)(tag & 0x07); + + if (field == 1 && wire_type == 0) { + if (!cap_im_feishu_pb_read_varint(buf, len, &pos, &frame->seq_id)) { + return false; + } + } else if (field == 2 && wire_type == 0) { + if (!cap_im_feishu_pb_read_varint(buf, len, &pos, &frame->log_id)) { + return false; + } + } else if (field == 3 && wire_type == 0) { + if (!cap_im_feishu_pb_read_varint(buf, len, &pos, &value)) { + return false; + } + frame->service = (int32_t)value; + } else if (field == 4 && wire_type == 0) { + if (!cap_im_feishu_pb_read_varint(buf, len, &pos, &value)) { + return false; + } + frame->method = (int32_t)value; + } else if (field == 5 && wire_type == 2) { + if (!cap_im_feishu_pb_read_varint(buf, len, &pos, &size) || pos + (size_t)size > len) { + return false; + } + if (frame->header_count < CAP_IM_FEISHU_MAX_HEADERS) { + cap_im_feishu_pb_parse_header(buf + pos, (size_t)size, &frame->headers[frame->header_count++]); + } + pos += (size_t)size; + } else if (field == 8 && wire_type == 2) { + if (!cap_im_feishu_pb_read_varint(buf, len, &pos, &size) || pos + (size_t)size > len) { + return false; + } + frame->payload = buf + pos; + frame->payload_len = (size_t)size; + pos += (size_t)size; + } else { + if (!cap_im_feishu_pb_skip_field(buf, len, &pos, wire_type)) { + return false; + } + } + } + + return true; +} + +static const char *cap_im_feishu_ws_header_value(const cap_im_feishu_ws_frame_t *frame, const char *key) +{ + size_t i; + + if (!frame || !key) { + return NULL; + } + + for (i = 0; i < frame->header_count; i++) { + if (strcmp(frame->headers[i].key, key) == 0) { + return frame->headers[i].value; + } + } + + return NULL; +} + +static bool cap_im_feishu_pb_write_varint(uint8_t *buf, size_t cap, size_t *pos, uint64_t value) +{ + do { + uint8_t byte = (uint8_t)(value & 0x7F); + + if (*pos >= cap) { + return false; + } + value >>= 7; + if (value) { + byte |= 0x80; + } + buf[(*pos)++] = byte; + } while (value); + + return true; +} + +static bool cap_im_feishu_pb_write_bytes(uint8_t *buf, + size_t cap, + size_t *pos, + uint32_t field, + const uint8_t *data, + size_t len) +{ + if (!cap_im_feishu_pb_write_varint(buf, cap, pos, ((uint64_t)field << 3) | 2) || + !cap_im_feishu_pb_write_varint(buf, cap, pos, len) || + *pos + len > cap) { + return false; + } + + memcpy(buf + *pos, data, len); + *pos += len; + return true; +} + +static bool cap_im_feishu_pb_write_string(uint8_t *buf, + size_t cap, + size_t *pos, + uint32_t field, + const char *text) +{ + return cap_im_feishu_pb_write_bytes(buf, + cap, + pos, + field, + (const uint8_t *)(text ? text : ""), + strlen(text ? text : "")); +} + +static bool cap_im_feishu_pb_write_header(uint8_t *buf, + size_t cap, + size_t *pos, + const cap_im_feishu_ws_header_t *header) +{ + uint8_t tmp[256]; + size_t tmp_pos = 0; + + if (!header) { + return false; + } + if (!cap_im_feishu_pb_write_string(tmp, sizeof(tmp), &tmp_pos, 1, header->key) || + !cap_im_feishu_pb_write_string(tmp, sizeof(tmp), &tmp_pos, 2, header->value)) { + return false; + } + + return cap_im_feishu_pb_write_bytes(buf, cap, pos, 5, tmp, tmp_pos); +} + +static int cap_im_feishu_ws_send_frame(const cap_im_feishu_ws_frame_t *frame, + const uint8_t *payload, + size_t payload_len, + int timeout_ms) +{ + uint8_t buf[1024]; + size_t pos = 0; + size_t i = 0; + + if (!frame || !s_feishu.ws_client) { + return -1; + } + + if (!cap_im_feishu_pb_write_varint(buf, sizeof(buf), &pos, ((uint64_t)1 << 3)) || + !cap_im_feishu_pb_write_varint(buf, sizeof(buf), &pos, frame->seq_id) || + !cap_im_feishu_pb_write_varint(buf, sizeof(buf), &pos, ((uint64_t)2 << 3)) || + !cap_im_feishu_pb_write_varint(buf, sizeof(buf), &pos, frame->log_id) || + !cap_im_feishu_pb_write_varint(buf, sizeof(buf), &pos, ((uint64_t)3 << 3)) || + !cap_im_feishu_pb_write_varint(buf, sizeof(buf), &pos, frame->service) || + !cap_im_feishu_pb_write_varint(buf, sizeof(buf), &pos, ((uint64_t)4 << 3)) || + !cap_im_feishu_pb_write_varint(buf, sizeof(buf), &pos, frame->method)) { + return -1; + } + + for (i = 0; i < frame->header_count; i++) { + if (!cap_im_feishu_pb_write_header(buf, sizeof(buf), &pos, &frame->headers[i])) { + return -1; + } + } + if (payload && payload_len > 0 && + !cap_im_feishu_pb_write_bytes(buf, sizeof(buf), &pos, 8, payload, payload_len)) { + return -1; + } + + return esp_websocket_client_send_bin(s_feishu.ws_client, (const char *)buf, pos, timeout_ms); +} + +static esp_err_t cap_im_feishu_publish_inbound_text(const char *chat_id, + const char *sender_id, + const char *message_id, + const char *content) +{ + if (!content || !content[0]) { + return ESP_OK; + } + + return claw_event_router_publish_message("feishu_gateway", + "feishu", + chat_id, + content, + sender_id, + message_id); +} + +static esp_err_t cap_im_feishu_publish_attachment_event(const char *chat_id, + const char *sender_id, + const char *message_id, + const char *content_type, + const char *payload_json) +{ + claw_event_t event = {0}; + + if (!chat_id || !message_id || !content_type || !payload_json) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(event.source_cap, "feishu_gateway", sizeof(event.source_cap)); + strlcpy(event.event_type, "attachment_saved", sizeof(event.event_type)); + strlcpy(event.source_channel, "feishu", sizeof(event.source_channel)); + strlcpy(event.chat_id, chat_id, sizeof(event.chat_id)); + strlcpy(event.content_type, content_type, sizeof(event.content_type)); + strlcpy(event.message_id, message_id, sizeof(event.message_id)); + if (sender_id && sender_id[0]) { + strlcpy(event.sender_id, sender_id, sizeof(event.sender_id)); + } + snprintf(event.event_id, sizeof(event.event_id), "feishu-attach-%" PRId64, cap_im_feishu_now_ms()); + event.timestamp_ms = cap_im_feishu_now_ms(); + event.session_policy = CLAW_EVENT_SESSION_POLICY_CHAT; + event.text = ""; + event.payload_json = (char *)payload_json; + return claw_event_router_publish(&event); +} + +static char *cap_im_feishu_strdup_or_empty(const char *value) +{ + return strdup(value ? value : ""); +} + +static void cap_im_feishu_free_attachment_job(cap_im_feishu_attachment_job_t *job) +{ + if (!job) { + return; + } + + free(job->chat_id); + free(job->sender_id); + free(job->message_id); + free(job->attachment_kind); + free(job->resource_key); + free(job->original_filename); + free(job->content_json); + free(job->content_type); + free(job); +} + +static cap_im_feishu_attachment_job_t *cap_im_feishu_make_attachment_job(const char *chat_id, + const char *sender_id, + const char *message_id, + const char *attachment_kind, + const char *resource_key, + const char *original_filename, + const char *content_json, + const char *content_type) +{ + cap_im_feishu_attachment_job_t *job = calloc(1, sizeof(*job)); + + if (!job) { + return NULL; + } + + job->chat_id = cap_im_feishu_strdup_or_empty(chat_id); + job->sender_id = cap_im_feishu_strdup_or_empty(sender_id); + job->message_id = cap_im_feishu_strdup_or_empty(message_id); + job->attachment_kind = cap_im_feishu_strdup_or_empty(attachment_kind); + job->resource_key = cap_im_feishu_strdup_or_empty(resource_key); + job->original_filename = cap_im_feishu_strdup_or_empty(original_filename); + job->content_json = cap_im_feishu_strdup_or_empty(content_json); + job->content_type = cap_im_feishu_strdup_or_empty(content_type); + if (!job->chat_id || !job->sender_id || !job->message_id || !job->attachment_kind || + !job->resource_key || !job->original_filename || !job->content_json || !job->content_type) { + cap_im_feishu_free_attachment_job(job); + return NULL; + } + + return job; +} + +static esp_err_t cap_im_feishu_ensure_dir(const char *path) +{ + struct stat st = {0}; + + if (!path || !path[0]) { + return ESP_ERR_INVALID_ARG; + } + + if (stat(path, &st) == 0) { + return S_ISDIR(st.st_mode) ? ESP_OK : ESP_FAIL; + } + + if (mkdir(path, 0755) != 0 && errno != EEXIST) { + return ESP_FAIL; + } + + return ESP_OK; +} + +static esp_err_t cap_im_feishu_ensure_parent_dirs(const char *path) +{ + char dir[CAP_IM_FEISHU_PATH_LEN]; + char *cursor = NULL; + char *slash = NULL; + char *create_from = NULL; + struct stat st = {0}; + + if (!path || !path[0]) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(dir, path, sizeof(dir)); + slash = strrchr(dir, '/'); + if (!slash) { + return ESP_OK; + } + *slash = '\0'; + + if (dir[0] != '/') { + return ESP_ERR_INVALID_ARG; + } + + for (cursor = dir + 1; *cursor; cursor++) { + if (*cursor != '/') { + continue; + } + + *cursor = '\0'; + if (stat(dir, &st) == 0 && S_ISDIR(st.st_mode)) { + create_from = cursor + 1; + } + *cursor = '/'; + } + + if (!create_from) { + return ESP_FAIL; + } + + for (cursor = create_from; *cursor; cursor++) { + if (*cursor == '/') { + *cursor = '\0'; + if (cap_im_feishu_ensure_dir(dir) != ESP_OK) { + *cursor = '/'; + return ESP_FAIL; + } + *cursor = '/'; + } + } + + return cap_im_feishu_ensure_dir(dir); +} + +static esp_err_t cap_im_feishu_save_attachment_metadata(const char *chat_id, + const char *sender_id, + const char *message_id, + const char *message_type, + const char *content_json) +{ + char saved_dir[CAP_IM_FEISHU_PATH_LEN]; + char saved_name[CAP_IM_FEISHU_NAME_LEN]; + char saved_path[CAP_IM_FEISHU_PATH_LEN]; + char *payload_json = NULL; + esp_err_t err; + + if (!s_feishu.enable_inbound_attachments || !s_feishu.attachment_root_dir[0] || + !chat_id || !message_id || !message_type || !content_json) { + return ESP_ERR_INVALID_STATE; + } + + err = cap_im_attachment_build_saved_paths(s_feishu.attachment_root_dir, + "feishu", + chat_id, + message_id, + message_type, + ".json", + saved_dir, + sizeof(saved_dir), + saved_name, + sizeof(saved_name), + saved_path, + sizeof(saved_path)); + if (err != ESP_OK) { + return err; + } + + err = cap_im_attachment_save_buffer_to_file(TAG, + saved_path, + (const unsigned char *)content_json, + strlen(content_json)); + if (err != ESP_OK) { + return err; + } + + payload_json = cap_im_attachment_build_payload_json( + &(cap_im_attachment_payload_config_t) { + .platform = "feishu", + .attachment_kind = message_type, + .saved_path = saved_path, + .saved_dir = saved_dir, + .saved_name = saved_name, + .original_filename = NULL, + .mime = "application/json", + .caption = NULL, + .source_key = "message_type", + .source_value = message_type, + .size_bytes = strlen(content_json), + .saved_at_ms = cap_im_feishu_now_ms(), + }); + if (!payload_json) { + ESP_LOGW(TAG, "Feishu metadata payload build failed message=%s path=%s", message_id, saved_path); + return ESP_OK; + } + + err = cap_im_feishu_publish_attachment_event(chat_id, + sender_id, + message_id, + message_type, + payload_json); + free(payload_json); + return err; +} + +static esp_err_t cap_im_feishu_extract_attachment_fields(const char *message_type, + const char *content_json, + char **out_resource_key, + char **out_original_filename) +{ + cJSON *root = NULL; + cJSON *resource_json = NULL; + cJSON *name_json = NULL; + + if (out_resource_key) { + *out_resource_key = NULL; + } + if (out_original_filename) { + *out_original_filename = NULL; + } + if (!message_type || !content_json || !out_resource_key || !out_original_filename) { + return ESP_ERR_INVALID_ARG; + } + + root = cJSON_Parse(content_json); + if (!root) { + return ESP_ERR_INVALID_RESPONSE; + } + + if (strcmp(message_type, "image") == 0) { + resource_json = cJSON_GetObjectItem(root, "image_key"); + } else if (strcmp(message_type, "file") == 0) { + resource_json = cJSON_GetObjectItem(root, "file_key"); + name_json = cJSON_GetObjectItem(root, "file_name"); + } + + if (!cJSON_IsString(resource_json) || !resource_json->valuestring || !resource_json->valuestring[0]) { + cJSON_Delete(root); + return ESP_ERR_NOT_FOUND; + } + + *out_resource_key = strdup(resource_json->valuestring); + if (cJSON_IsString(name_json) && name_json->valuestring && name_json->valuestring[0]) { + *out_original_filename = strdup(name_json->valuestring); + } else { + *out_original_filename = strdup(""); + } + cJSON_Delete(root); + if (!*out_resource_key || !*out_original_filename) { + free(*out_resource_key); + free(*out_original_filename); + *out_resource_key = NULL; + *out_original_filename = NULL; + return ESP_ERR_NO_MEM; + } + + return ESP_OK; +} + +static esp_err_t cap_im_feishu_download_attachment(const char *message_id, + const char *resource_key, + const char *resource_type, + const char *saved_path, + char *mime_buf, + size_t mime_buf_size, + size_t *out_bytes) +{ + esp_http_client_config_t config = {0}; + esp_http_client_handle_t client = NULL; + char auth_header[CAP_IM_FEISHU_TOKEN_LEN + 16]; + char url[CAP_IM_FEISHU_URL_LEN]; + FILE *file = NULL; + char read_buf[2048]; + size_t total_bytes = 0; + int content_length = 0; + int status = 0; + esp_err_t err; + + if (mime_buf && mime_buf_size > 0) { + mime_buf[0] = '\0'; + } + if (out_bytes) { + *out_bytes = 0; + } + if (!message_id || !resource_key || !resource_type || !saved_path) { + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_feishu_get_tenant_token(); + if (err != ESP_OK) { + ESP_LOGW(TAG, + "Feishu attachment token fetch failed message=%s kind=%s err=%s", + message_id, + resource_type, + esp_err_to_name(err)); + return err; + } + + snprintf(url, + sizeof(url), + CAP_IM_FEISHU_API_BASE "/im/v1/messages/%s/resources/%s?type=%s", + message_id, + resource_key, + resource_type); + snprintf(auth_header, sizeof(auth_header), "Bearer %s", s_feishu.tenant_token); + + config.url = url; + config.timeout_ms = 30000; + config.buffer_size = sizeof(read_buf); + config.buffer_size_tx = 1024; + config.crt_bundle_attach = esp_crt_bundle_attach; + + client = esp_http_client_init(&config); + if (!client) { + ESP_LOGE(TAG, "Feishu attachment HTTP client init failed message=%s", message_id); + return ESP_FAIL; + } + + esp_http_client_set_method(client, HTTP_METHOD_GET); + esp_http_client_set_header(client, "Authorization", auth_header); + + err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + ESP_LOGW(TAG, + "Feishu attachment open failed message=%s kind=%s err=%s url=%s", + message_id, + resource_type, + esp_err_to_name(err), + url); + esp_http_client_cleanup(client); + return err; + } + + content_length = esp_http_client_fetch_headers(client); + status = esp_http_client_get_status_code(client); + if (status < 200 || status >= 300) { + char error_buf[192] = {0}; + int error_len = esp_http_client_read(client, error_buf, sizeof(error_buf) - 1); + + if (error_len > 0) { + error_buf[error_len] = '\0'; + } else { + error_buf[0] = '\0'; + } + ESP_LOGW(TAG, + "Feishu attachment HTTP error message=%s kind=%s status=%d body=%s", + message_id, + resource_type, + status, + error_buf[0] ? error_buf : "(empty)"); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return ESP_FAIL; + } + if (content_length > 0 && + (size_t)content_length > (s_feishu.max_inbound_file_bytes ? s_feishu.max_inbound_file_bytes : + (2 * 1024 * 1024))) { + ESP_LOGW(TAG, + "Feishu attachment too large before read message=%s kind=%s content_length=%d limit=%u", + message_id, + resource_type, + content_length, + (unsigned)(s_feishu.max_inbound_file_bytes ? s_feishu.max_inbound_file_bytes : + (2 * 1024 * 1024))); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return ESP_ERR_INVALID_SIZE; + } + + err = cap_im_feishu_ensure_parent_dirs(saved_path); + if (err != ESP_OK) { + ESP_LOGW(TAG, + "Feishu attachment ensure dir failed message=%s path=%s err=%s", + message_id, + saved_path, + esp_err_to_name(err)); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return err; + } + + file = fopen(saved_path, "wb"); + if (!file) { + ESP_LOGW(TAG, "Feishu attachment open file failed path=%s errno=%d", saved_path, errno); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return ESP_FAIL; + } + + while (1) { + int read_len = esp_http_client_read(client, read_buf, sizeof(read_buf)); + + if (read_len < 0) { + ESP_LOGW(TAG, + "Feishu attachment read failed message=%s kind=%s after=%u", + message_id, + resource_type, + (unsigned)total_bytes); + err = ESP_FAIL; + break; + } + if (read_len == 0) { + err = ESP_OK; + break; + } + total_bytes += (size_t)read_len; + if (total_bytes > (s_feishu.max_inbound_file_bytes ? s_feishu.max_inbound_file_bytes : + (2 * 1024 * 1024))) { + ESP_LOGW(TAG, + "Feishu attachment exceeded limit while reading message=%s kind=%s bytes=%u limit=%u", + message_id, + resource_type, + (unsigned)total_bytes, + (unsigned)(s_feishu.max_inbound_file_bytes ? s_feishu.max_inbound_file_bytes : + (2 * 1024 * 1024))); + err = ESP_ERR_INVALID_SIZE; + break; + } + if (fwrite(read_buf, 1, (size_t)read_len, file) != (size_t)read_len) { + ESP_LOGW(TAG, + "Feishu attachment fwrite failed path=%s errno=%d bytes=%u", + saved_path, + errno, + (unsigned)total_bytes); + err = ESP_FAIL; + break; + } + } + + fclose(file); + esp_http_client_close(client); + esp_http_client_cleanup(client); + if (err != ESP_OK || total_bytes == 0) { + ESP_LOGW(TAG, + "Feishu attachment download failed final message=%s kind=%s err=%s bytes=%u", + message_id, + resource_type, + esp_err_to_name(err != ESP_OK ? err : ESP_FAIL), + (unsigned)total_bytes); + remove(saved_path); + return err != ESP_OK ? err : ESP_FAIL; + } + + if (mime_buf && mime_buf_size > 0 && !mime_buf[0]) { + if (strcmp(resource_type, "image") == 0) { + strlcpy(mime_buf, "image/jpeg", mime_buf_size); + } else if (strcmp(resource_type, "file") == 0) { + strlcpy(mime_buf, "application/octet-stream", mime_buf_size); + } + } + + if (out_bytes) { + *out_bytes = total_bytes; + } + return ESP_OK; +} + +static esp_err_t cap_im_feishu_save_attachment(const char *chat_id, + const char *sender_id, + const char *message_id, + const char *attachment_kind, + const char *resource_key, + const char *original_filename, + const char *content_json, + const char *content_type) +{ + char saved_dir[CAP_IM_FEISHU_PATH_LEN]; + char saved_name[CAP_IM_FEISHU_NAME_LEN]; + char saved_path[CAP_IM_FEISHU_PATH_LEN]; + char mime[64] = {0}; + const char *extension = NULL; + char *payload_json = NULL; + size_t bytes = 0; + esp_err_t err; + + if (!chat_id || !message_id || !attachment_kind || !resource_key || !content_type) { + return ESP_ERR_INVALID_ARG; + } + + if ((!original_filename || !original_filename[0]) && strcmp(attachment_kind, "image") == 0) { + extension = ".jpg"; + } else { + extension = cap_im_attachment_guess_extension(NULL, + original_filename && original_filename[0] ? original_filename : NULL, + NULL); + } + err = cap_im_attachment_build_saved_paths(s_feishu.attachment_root_dir, + "feishu", + chat_id, + message_id, + attachment_kind, + extension, + saved_dir, + sizeof(saved_dir), + saved_name, + sizeof(saved_name), + saved_path, + sizeof(saved_path)); + if (err != ESP_OK) { + return err; + } + + err = cap_im_feishu_download_attachment(message_id, + resource_key, + attachment_kind, + saved_path, + mime, + sizeof(mime), + &bytes); + if (err != ESP_OK) { + ESP_LOGW(TAG, + "Feishu attachment download failed message=%s kind=%s err=%s, fallback to metadata", + message_id, + attachment_kind, + esp_err_to_name(err)); + return cap_im_feishu_save_attachment_metadata(chat_id, + sender_id, + message_id, + attachment_kind, + content_json); + } + + payload_json = cap_im_attachment_build_payload_json( + &(cap_im_attachment_payload_config_t) { + .platform = "feishu", + .attachment_kind = attachment_kind, + .saved_path = saved_path, + .saved_dir = saved_dir, + .saved_name = saved_name, + .original_filename = original_filename, + .mime = mime[0] ? mime : NULL, + .caption = NULL, + .source_key = "resource_key", + .source_value = resource_key, + .size_bytes = bytes, + .saved_at_ms = cap_im_feishu_now_ms(), + }); + if (!payload_json) { + ESP_LOGW(TAG, "Feishu attachment payload build failed message=%s path=%s", message_id, saved_path); + return ESP_OK; + } + + err = cap_im_feishu_publish_attachment_event(chat_id, + sender_id, + message_id, + content_type, + payload_json); + free(payload_json); + return err; +} + +static void cap_im_feishu_attachment_task(void *arg) +{ + cap_im_feishu_attachment_job_t *job = NULL; + + (void)arg; + + while (1) { + if (xQueueReceive(s_feishu.attachment_queue, &job, pdMS_TO_TICKS(200)) == pdTRUE) { + if (job) { + esp_err_t err = cap_im_feishu_save_attachment(job->chat_id, + job->sender_id, + job->message_id, + job->attachment_kind, + job->resource_key, + job->original_filename, + job->content_json, + job->content_type); + + if (err != ESP_OK) { + ESP_LOGW(TAG, + "Feishu attachment save failed message=%s kind=%s err=%s", + job->message_id, + job->attachment_kind, + esp_err_to_name(err)); + } + cap_im_feishu_free_attachment_job(job); + } + continue; + } + + if (s_feishu.stop_requested) { + break; + } + } + + s_feishu.attachment_task = NULL; + vTaskDelete(NULL); +} + +static void cap_im_feishu_queue_attachment(const char *chat_id, + const char *sender_id, + const char *message_id, + const char *attachment_kind, + const char *content_json) +{ + char *resource_key = NULL; + char *original_filename = NULL; + cap_im_feishu_attachment_job_t *job = NULL; + esp_err_t err; + + if (!s_feishu.enable_inbound_attachments || !s_feishu.attachment_queue || !content_json || !content_json[0]) { + return; + } + + err = cap_im_feishu_extract_attachment_fields(attachment_kind, + content_json, + &resource_key, + &original_filename); + if (err != ESP_OK) { + ESP_LOGW(TAG, + "Feishu attachment parse failed message=%s kind=%s err=%s", + message_id, + attachment_kind, + esp_err_to_name(err)); + return; + } + + job = cap_im_feishu_make_attachment_job(chat_id, + sender_id, + message_id, + attachment_kind, + resource_key, + original_filename, + content_json, + attachment_kind); + free(resource_key); + free(original_filename); + if (!job) { + ESP_LOGW(TAG, "Feishu attachment alloc failed message=%s", message_id); + return; + } + + if (xQueueSend(s_feishu.attachment_queue, &job, 0) != pdTRUE) { + ESP_LOGW(TAG, "Feishu attachment queue full message=%s kind=%s", message_id, attachment_kind); + cap_im_feishu_free_attachment_job(job); + } +} + +static char *cap_im_feishu_extract_text(const char *content_json) +{ + cJSON *root = NULL; + cJSON *text_json = NULL; + char *copy = NULL; + + if (!content_json || !content_json[0]) { + return NULL; + } + + root = cJSON_Parse(content_json); + if (!root) { + return NULL; + } + text_json = cJSON_GetObjectItem(root, "text"); + if (cJSON_IsString(text_json) && text_json->valuestring && text_json->valuestring[0]) { + copy = strdup(text_json->valuestring); + } + cJSON_Delete(root); + return copy; +} + +static void cap_im_feishu_handle_message_event(cJSON *event) +{ + cJSON *message = NULL; + cJSON *sender = NULL; + cJSON *sender_id_json = NULL; + cJSON *open_id_json = NULL; + cJSON *message_id_json = NULL; + cJSON *chat_id_json = NULL; + cJSON *chat_type_json = NULL; + cJSON *message_type_json = NULL; + cJSON *content_json = NULL; + const char *message_id = NULL; + const char *chat_id = NULL; + const char *chat_type = "p2p"; + const char *message_type = "text"; + const char *sender_id = ""; + const char *route_id = NULL; + + if (!event) { + return; + } + + message = cJSON_GetObjectItem(event, "message"); + if (!cJSON_IsObject(message)) { + return; + } + + sender = cJSON_GetObjectItem(event, "sender"); + sender_id_json = cJSON_IsObject(sender) ? cJSON_GetObjectItem(sender, "sender_id") : NULL; + open_id_json = cJSON_IsObject(sender_id_json) ? cJSON_GetObjectItem(sender_id_json, "open_id") : NULL; + message_id_json = cJSON_GetObjectItem(message, "message_id"); + chat_id_json = cJSON_GetObjectItem(message, "chat_id"); + chat_type_json = cJSON_GetObjectItem(message, "chat_type"); + message_type_json = cJSON_GetObjectItem(message, "message_type"); + content_json = cJSON_GetObjectItem(message, "content"); + + if (!cJSON_IsString(chat_id_json) || !chat_id_json->valuestring || + !cJSON_IsString(content_json) || !content_json->valuestring) { + return; + } + + message_id = cJSON_IsString(message_id_json) ? message_id_json->valuestring : ""; + chat_id = chat_id_json->valuestring; + if (cJSON_IsString(chat_type_json) && chat_type_json->valuestring) { + chat_type = chat_type_json->valuestring; + } + if (cJSON_IsString(message_type_json) && message_type_json->valuestring) { + message_type = message_type_json->valuestring; + } + if (cJSON_IsString(open_id_json) && open_id_json->valuestring) { + sender_id = open_id_json->valuestring; + } + + if (message_id[0] && cap_im_feishu_dedup_check_and_record(message_id)) { + ESP_LOGD(TAG, "Skip duplicate Feishu message %s", message_id); + return; + } + + route_id = chat_id; + if (strcmp(chat_type, "p2p") == 0 && sender_id[0]) { + route_id = sender_id; + } + + if (strcmp(message_type, "text") == 0) { + char *text = cap_im_feishu_extract_text(content_json->valuestring); + char *clean = NULL; + esp_err_t err; + + if (!text) { + ESP_LOGW(TAG, "Feishu text content parse failed message=%s", message_id); + return; + } + + clean = text; + if (strncmp(clean, "@_user_1 ", 9) == 0) { + clean += 9; + } + while (*clean == ' ' || *clean == '\n') { + clean++; + } + if (!clean[0]) { + free(text); + return; + } + + err = cap_im_feishu_publish_inbound_text(route_id, sender_id, message_id, clean); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Feishu inbound publish failed: %s", esp_err_to_name(err)); + } else { + ESP_LOGI(TAG, "Feishu text inbound chat=%s route=%s message=%s", chat_id, route_id, message_id); + } + free(text); + return; + } + + if (strcmp(message_type, "image") == 0 || strcmp(message_type, "file") == 0) { + cap_im_feishu_queue_attachment(route_id, + sender_id, + message_id, + message_type, + content_json->valuestring); + return; + } + + ESP_LOGI(TAG, "Ignore Feishu message_type=%s message=%s", message_type, message_id); +} + +static void cap_im_feishu_process_ws_event_json(const char *json, size_t len) +{ + cJSON *root = NULL; + cJSON *event = NULL; + cJSON *header = NULL; + cJSON *event_type = NULL; + + if (!json || len == 0) { + return; + } + + root = cJSON_ParseWithLength(json, len); + if (!root) { + ESP_LOGW(TAG, "Feishu WS JSON parse failed"); + return; + } + + event = cJSON_GetObjectItem(root, "event"); + header = cJSON_GetObjectItem(root, "header"); + event_type = cJSON_IsObject(header) ? cJSON_GetObjectItem(header, "event_type") : NULL; + if (cJSON_IsObject(event) && + (!event_type || (cJSON_IsString(event_type) && + strcmp(event_type->valuestring, "im.message.receive_v1") == 0))) { + cap_im_feishu_handle_message_event(event); + } + + cJSON_Delete(root); +} + +static void cap_im_feishu_handle_ws_frame(const uint8_t *buf, size_t len) +{ + cap_im_feishu_ws_frame_t frame = {0}; + const char *type = NULL; + + if (!cap_im_feishu_pb_parse_frame(buf, len, &frame)) { + ESP_LOGW(TAG, "Feishu WS frame parse failed"); + return; + } + + type = cap_im_feishu_ws_header_value(&frame, "type"); + if (frame.method == 0) { + if (type && strcmp(type, "pong") == 0 && frame.payload && frame.payload_len > 0) { + cJSON *cfg = cJSON_ParseWithLength((const char *)frame.payload, frame.payload_len); + + if (cfg) { + cJSON *ping_json = cJSON_GetObjectItem(cfg, "PingInterval"); + + if (cJSON_IsNumber(ping_json)) { + s_feishu.ws_ping_interval_ms = ping_json->valueint * 1000; + } + cJSON_Delete(cfg); + } + } + return; + } + + if (!type || strcmp(type, "event") != 0 || !frame.payload || frame.payload_len == 0) { + return; + } + + cap_im_feishu_process_ws_event_json((const char *)frame.payload, frame.payload_len); + + { + const char ack_payload[] = "{\"code\":200}"; + cap_im_feishu_ws_frame_t ack = frame; + + cap_im_feishu_ws_send_frame(&ack, (const uint8_t *)ack_payload, strlen(ack_payload), 1000); + } +} + +static void cap_im_feishu_ws_event_handler(void *arg, + esp_event_base_t base, + int32_t event_id, + void *event_data) +{ + static uint8_t *rx_buf = NULL; + static size_t rx_cap = 0; + esp_websocket_event_data_t *event = (esp_websocket_event_data_t *)event_data; + + (void)arg; + (void)base; + + if (event_id == WEBSOCKET_EVENT_CONNECTED) { + s_feishu.ws_connected = true; + s_feishu.ws_ever_connected = true; + s_feishu.ws_disconnect_since_ms = 0; + ESP_LOGI(TAG, "Feishu WS connected"); + return; + } + + if (event_id == WEBSOCKET_EVENT_DISCONNECTED) { + s_feishu.ws_connected = false; + if (s_feishu.ws_ever_connected && s_feishu.ws_disconnect_since_ms == 0) { + s_feishu.ws_disconnect_since_ms = cap_im_feishu_now_ms(); + } + ESP_LOGW(TAG, "Feishu WS disconnected"); + return; + } + + if (event_id != WEBSOCKET_EVENT_DATA || !event || event->op_code != WS_TRANSPORT_OPCODES_BINARY) { + return; + } + + { + size_t need = event->payload_offset + event->data_len; + + if (event->payload_offset == 0) { + free(rx_buf); + rx_cap = event->payload_len > need ? event->payload_len : need; + rx_buf = malloc(rx_cap); + if (!rx_buf) { + rx_cap = 0; + return; + } + } else if (!rx_buf || need > rx_cap) { + return; + } + + memcpy(rx_buf + event->payload_offset, event->data_ptr, event->data_len); + if (need >= event->payload_len) { + cap_im_feishu_handle_ws_frame(rx_buf, event->payload_len); + free(rx_buf); + rx_buf = NULL; + rx_cap = 0; + } + } +} + +static void cap_im_feishu_ws_task(void *arg) +{ + (void)arg; + + while (!s_feishu.stop_requested) { + int64_t connect_started_ms = 0; + int64_t last_ping_ms = 0; + + if (cap_im_feishu_pull_ws_config() != ESP_OK) { + if (s_feishu.stop_requested) { + break; + } + vTaskDelay(pdMS_TO_TICKS(CAP_IM_FEISHU_RECONNECT_DELAY_MS)); + continue; + } + + { + esp_websocket_client_config_t ws_config = { + .uri = s_feishu.ws_url, + .buffer_size = 2048, + .task_stack = 16 * 1024, + .reconnect_timeout_ms = s_feishu.ws_reconnect_interval_ms, + .network_timeout_ms = 10000, + .disable_auto_reconnect = false, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + + s_feishu.ws_client = esp_websocket_client_init(&ws_config); + } + if (!s_feishu.ws_client) { + if (s_feishu.stop_requested) { + break; + } + vTaskDelay(pdMS_TO_TICKS(CAP_IM_FEISHU_RECONNECT_DELAY_MS)); + continue; + } + + s_feishu.ws_connected = false; + s_feishu.ws_ever_connected = false; + s_feishu.ws_disconnect_since_ms = 0; + esp_websocket_register_events(s_feishu.ws_client, + WEBSOCKET_EVENT_ANY, + cap_im_feishu_ws_event_handler, + NULL); + if (esp_websocket_client_start(s_feishu.ws_client) != ESP_OK) { + ESP_LOGE(TAG, "Feishu WS start failed"); + esp_websocket_client_destroy(s_feishu.ws_client); + s_feishu.ws_client = NULL; + if (s_feishu.stop_requested) { + break; + } + vTaskDelay(pdMS_TO_TICKS(CAP_IM_FEISHU_RECONNECT_DELAY_MS)); + continue; + } + + connect_started_ms = cap_im_feishu_now_ms(); + while (s_feishu.ws_client && !s_feishu.stop_requested) { + int64_t now_ms = cap_im_feishu_now_ms(); + + if (s_feishu.ws_connected && now_ms - last_ping_ms >= s_feishu.ws_ping_interval_ms) { + cap_im_feishu_ws_frame_t ping = {0}; + + ping.service = s_feishu.ws_service_id; + ping.header_count = 1; + strlcpy(ping.headers[0].key, "type", sizeof(ping.headers[0].key)); + strlcpy(ping.headers[0].value, "ping", sizeof(ping.headers[0].value)); + cap_im_feishu_ws_send_frame(&ping, NULL, 0, 1000); + last_ping_ms = now_ms; + } + + if (!s_feishu.ws_ever_connected) { + if (!esp_websocket_client_is_connected(s_feishu.ws_client) && + now_ms - connect_started_ms >= CAP_IM_FEISHU_INITIAL_CONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Feishu WS initial connect timeout"); + break; + } + } else if (!esp_websocket_client_is_connected(s_feishu.ws_client) && !s_feishu.ws_connected) { + if (s_feishu.ws_disconnect_since_ms == 0) { + s_feishu.ws_disconnect_since_ms = now_ms; + } + if (now_ms - s_feishu.ws_disconnect_since_ms >= + s_feishu.ws_reconnect_interval_ms + s_feishu.ws_reconnect_nonce_ms + 5000) { + ESP_LOGW(TAG, "Feishu WS reconnect grace expired"); + break; + } + } + + vTaskDelay(pdMS_TO_TICKS(200)); + } + + if (s_feishu.ws_client) { + esp_websocket_client_stop(s_feishu.ws_client); + esp_websocket_client_destroy(s_feishu.ws_client); + s_feishu.ws_client = NULL; + } + s_feishu.ws_connected = false; + if (!s_feishu.stop_requested) { + vTaskDelay(pdMS_TO_TICKS(CAP_IM_FEISHU_RECONNECT_DELAY_MS)); + } + } + + s_feishu.ws_client = NULL; + s_feishu.ws_task = NULL; + vTaskDelete(NULL); +} + +static esp_err_t cap_im_feishu_gateway_init(void) +{ + if (!s_feishu.app_id[0] || !s_feishu.app_secret[0]) { + ESP_LOGW(TAG, "Feishu credentials not configured"); + return ESP_OK; + } + + ESP_LOGI(TAG, "Feishu configured (app_id=%.8s...)", s_feishu.app_id); + return ESP_OK; +} + +static esp_err_t cap_im_feishu_gateway_start(void) +{ + return cap_im_feishu_start(); +} + +static esp_err_t cap_im_feishu_gateway_stop(void) +{ + return cap_im_feishu_stop(); +} + +static esp_err_t cap_im_feishu_send_message_content(const char *chat_id, + const char *msg_type, + const char *content_json) +{ + const char *id_type = NULL; + cJSON *body = NULL; + char *body_str = NULL; + char *response = NULL; + char url[CAP_IM_FEISHU_URL_LEN]; + esp_err_t err; + + if (!chat_id || !chat_id[0] || !msg_type || !msg_type[0] || !content_json || !content_json[0]) { + return ESP_ERR_INVALID_ARG; + } + if (!s_feishu.app_id[0] || !s_feishu.app_secret[0]) { + return ESP_ERR_INVALID_STATE; + } + + id_type = strncmp(chat_id, "ou_", 3) == 0 ? "open_id" : "chat_id"; + snprintf(url, sizeof(url), "%s?receive_id_type=%s", CAP_IM_FEISHU_SEND_MSG_URL, id_type); + + body = cJSON_CreateObject(); + if (!body) { + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(body, "receive_id", chat_id); + cJSON_AddStringToObject(body, "msg_type", msg_type); + cJSON_AddStringToObject(body, "content", content_json); + body_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!body_str) { + return ESP_ERR_NO_MEM; + } + + err = cap_im_feishu_api_call(url, "POST", body_str, &response); + free(body_str); + if (err != ESP_OK) { + return err; + } + + err = cap_im_feishu_validate_api_success_response(response); + free(response); + return err; +} + +static esp_err_t cap_im_feishu_upload_media(const char *path, + bool is_image, + char *out_key, + size_t out_key_size) +{ + struct stat st = {0}; + FILE *file = NULL; + esp_http_client_config_t config = {0}; + esp_http_client_handle_t client = NULL; + cap_im_feishu_resp_t resp = {0}; + const char *file_name = NULL; + const char *mime = NULL; + const char *url = NULL; + const char *field_name = NULL; + const char *key_name = NULL; + char auth_header[CAP_IM_FEISHU_TOKEN_LEN + 16]; + char content_type[128]; + char preamble1[256]; + char preamble2[384]; + char part_file[384]; + char closing[64]; + int preamble1_len = 0; + int preamble2_len = 0; + int part_file_len = 0; + int closing_len = 0; + size_t content_length = 0; + int status = 0; + esp_err_t err = ESP_FAIL; + + if (out_key && out_key_size > 0) { + out_key[0] = '\0'; + } + if (!path || !path[0] || !out_key || out_key_size == 0) { + return ESP_ERR_INVALID_ARG; + } + if (!s_feishu.app_id[0] || !s_feishu.app_secret[0]) { + return ESP_ERR_INVALID_STATE; + } + if (stat(path, &st) != 0) { + return errno == ENOENT ? ESP_ERR_NOT_FOUND : ESP_FAIL; + } + if (!S_ISREG(st.st_mode)) { + return ESP_ERR_INVALID_ARG; + } + if (st.st_size <= 0) { + return ESP_ERR_INVALID_SIZE; + } + + err = cap_im_feishu_get_tenant_token(); + if (err != ESP_OK) { + return err; + } + + file_name = cap_im_feishu_basename(path); + mime = cap_im_feishu_guess_upload_mime(path, is_image); + url = is_image ? CAP_IM_FEISHU_UPLOAD_IMAGE_URL : CAP_IM_FEISHU_UPLOAD_FILE_URL; + field_name = is_image ? "image" : "file"; + key_name = is_image ? "image_key" : "file_key"; + snprintf(auth_header, sizeof(auth_header), "Bearer %s", s_feishu.tenant_token); + + if (is_image) { + preamble1_len = snprintf(preamble1, + sizeof(preamble1), + "--" CAP_IM_FEISHU_MULTIPART_BOUNDARY "\r\n" + "Content-Disposition: form-data; name=\"image_type\"\r\n\r\n" + "message\r\n"); + } else { + preamble1_len = snprintf(preamble1, + sizeof(preamble1), + "--" CAP_IM_FEISHU_MULTIPART_BOUNDARY "\r\n" + "Content-Disposition: form-data; name=\"file_type\"\r\n\r\n" + "stream\r\n"); + preamble2_len = snprintf(preamble2, + sizeof(preamble2), + "--" CAP_IM_FEISHU_MULTIPART_BOUNDARY "\r\n" + "Content-Disposition: form-data; name=\"file_name\"\r\n\r\n" + "%s\r\n", + file_name); + } + part_file_len = snprintf(part_file, + sizeof(part_file), + "--" CAP_IM_FEISHU_MULTIPART_BOUNDARY "\r\n" + "Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n" + "Content-Type: %s\r\n\r\n", + field_name, + file_name, + mime); + closing_len = snprintf(closing, sizeof(closing), + "\r\n--" CAP_IM_FEISHU_MULTIPART_BOUNDARY "--\r\n"); + if (preamble1_len <= 0 || part_file_len <= 0 || closing_len <= 0 || + preamble1_len >= (int)sizeof(preamble1) || + preamble2_len >= (int)sizeof(preamble2) || + part_file_len >= (int)sizeof(part_file) || + closing_len >= (int)sizeof(closing)) { + return ESP_ERR_INVALID_SIZE; + } + + content_length = (size_t)preamble1_len + (size_t)preamble2_len + (size_t)part_file_len + + (size_t)st.st_size + (size_t)closing_len; + + file = fopen(path, "rb"); + if (!file) { + return errno == ENOENT ? ESP_ERR_NOT_FOUND : ESP_FAIL; + } + err = cap_im_feishu_resp_init(&resp); + if (err != ESP_OK) { + fclose(file); + return err; + } + + config.url = url; + config.timeout_ms = 30000; + config.buffer_size = 2048; + config.buffer_size_tx = 2048; + config.crt_bundle_attach = esp_crt_bundle_attach; + client = esp_http_client_init(&config); + if (!client) { + fclose(file); + cap_im_feishu_resp_free(&resp); + return ESP_FAIL; + } + + snprintf(content_type, + sizeof(content_type), + "multipart/form-data; boundary=" CAP_IM_FEISHU_MULTIPART_BOUNDARY); + esp_http_client_set_method(client, HTTP_METHOD_POST); + esp_http_client_set_header(client, "Authorization", auth_header); + esp_http_client_set_header(client, "Content-Type", content_type); + + err = esp_http_client_open(client, (int)content_length); + if (err == ESP_OK) { + err = cap_im_feishu_http_client_write_all(client, preamble1, (size_t)preamble1_len); + } + if (err == ESP_OK && preamble2_len > 0) { + err = cap_im_feishu_http_client_write_all(client, preamble2, (size_t)preamble2_len); + } + if (err == ESP_OK) { + err = cap_im_feishu_http_client_write_all(client, part_file, (size_t)part_file_len); + } + if (err == ESP_OK) { + err = cap_im_feishu_stream_file_to_http_client(client, file); + } + if (err == ESP_OK) { + err = cap_im_feishu_http_client_write_all(client, closing, (size_t)closing_len); + } + if (err == ESP_OK && esp_http_client_fetch_headers(client) < 0) { + err = ESP_FAIL; + } + + status = esp_http_client_get_status_code(client); + if (err == ESP_OK) { + while (1) { + char buf[512]; + int read_len = esp_http_client_read(client, buf, sizeof(buf)); + + if (read_len < 0) { + err = ESP_FAIL; + break; + } + if (read_len == 0) { + break; + } + err = cap_im_feishu_resp_append(&resp, buf, (size_t)read_len); + if (err != ESP_OK) { + break; + } + } + } + + esp_http_client_close(client); + esp_http_client_cleanup(client); + fclose(file); + + if (err != ESP_OK) { + ESP_LOGW(TAG, + "Feishu %s upload failed path=%s err=%s", + is_image ? "image" : "file", + path, + esp_err_to_name(err)); + cap_im_feishu_resp_free(&resp); + return err; + } + if (status != 200) { + ESP_LOGW(TAG, + "Feishu %s upload HTTP %d path=%s body=%s", + is_image ? "image" : "file", + status, + path, + resp.buf ? resp.buf : ""); + cap_im_feishu_resp_free(&resp); + return ESP_FAIL; + } + + { + cJSON *root = cJSON_Parse(resp.buf); + cJSON *data_json = NULL; + cJSON *key_json = NULL; + + if (!root) { + cap_im_feishu_resp_free(&resp); + return ESP_FAIL; + } + if (!cJSON_IsNumber(cJSON_GetObjectItem(root, "code")) || + cJSON_GetObjectItem(root, "code")->valueint != 0) { + ESP_LOGW(TAG, + "Feishu %s upload response invalid path=%s body=%s", + is_image ? "image" : "file", + path, + resp.buf ? resp.buf : ""); + cJSON_Delete(root); + cap_im_feishu_resp_free(&resp); + return ESP_FAIL; + } + + data_json = cJSON_GetObjectItem(root, "data"); + key_json = cJSON_IsObject(data_json) ? cJSON_GetObjectItem(data_json, key_name) : + cJSON_GetObjectItem(root, key_name); + if (!cJSON_IsString(key_json) || !key_json->valuestring || !key_json->valuestring[0]) { + cJSON_Delete(root); + cap_im_feishu_resp_free(&resp); + return ESP_ERR_NOT_FOUND; + } + + strlcpy(out_key, key_json->valuestring, out_key_size); + cJSON_Delete(root); + } + + cap_im_feishu_resp_free(&resp); + return ESP_OK; +} + +static esp_err_t cap_im_feishu_send_media_internal(const char *chat_id, + const char *path, + const char *caption, + bool is_image) +{ + cJSON *content = NULL; + char *content_str = NULL; + char media_key[CAP_IM_FEISHU_MEDIA_KEY_LEN]; + esp_err_t err; + + err = cap_im_feishu_upload_media(path, is_image, media_key, sizeof(media_key)); + if (err != ESP_OK) { + return err; + } + + content = cJSON_CreateObject(); + if (!content) { + return ESP_ERR_NO_MEM; + } + cJSON_AddStringToObject(content, is_image ? "image_key" : "file_key", media_key); + content_str = cJSON_PrintUnformatted(content); + cJSON_Delete(content); + if (!content_str) { + return ESP_ERR_NO_MEM; + } + + err = cap_im_feishu_send_message_content(chat_id, + is_image ? "image" : "file", + content_str); + free(content_str); + if (err != ESP_OK) { + return err; + } + + if (caption && caption[0]) { + err = cap_im_feishu_send_text(chat_id, caption); + if (err != ESP_OK) { + return err; + } + } + + ESP_LOGI(TAG, + "Feishu %s send success to %s: %s", + is_image ? "image" : "file", + chat_id, + path); + return ESP_OK; +} + +static esp_err_t cap_im_feishu_send_message_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + cJSON *chat_id_json = NULL; + cJSON *message_json = NULL; + const char *chat_id = NULL; + const char *message = NULL; + esp_err_t err; + + (void)ctx; + + if (!input_json || !output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "{\"ok\":false,\"error\":\"invalid json\"}"); + return ESP_ERR_INVALID_ARG; + } + + chat_id_json = cJSON_GetObjectItem(root, "chat_id"); + message_json = cJSON_GetObjectItem(root, "message"); + if (cJSON_IsString(chat_id_json) && chat_id_json->valuestring && chat_id_json->valuestring[0]) { + chat_id = chat_id_json->valuestring; + } else if (ctx && ctx->chat_id && ctx->chat_id[0]) { + chat_id = ctx->chat_id; + } + message = cJSON_IsString(message_json) ? message_json->valuestring : NULL; + if (!chat_id || !chat_id[0] || !message || !message[0]) { + cJSON_Delete(root); + snprintf(output, + output_size, + "{\"ok\":false,\"error\":\"chat_id and message are required (chat_id may come from ctx)\"}"); + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_feishu_send_text(chat_id, message); + cJSON_Delete(root); + if (err != ESP_OK) { + snprintf(output, output_size, "{\"ok\":false,\"error\":\"%s\"}", esp_err_to_name(err)); + return err; + } + + snprintf(output, output_size, "{\"ok\":true}"); + return ESP_OK; +} + +static esp_err_t cap_im_feishu_send_media_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size, + bool is_image) +{ + cJSON *root = NULL; + cJSON *chat_id_json = NULL; + cJSON *path_json = NULL; + cJSON *caption_json = NULL; + const char *chat_id = NULL; + const char *path = NULL; + const char *caption = NULL; + esp_err_t err; + + if (!input_json || !output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + root = cJSON_Parse(input_json ? input_json : "{}"); + if (!root) { + snprintf(output, output_size, "{\"ok\":false,\"error\":\"invalid json\"}"); + return ESP_ERR_INVALID_ARG; + } + + chat_id_json = cJSON_GetObjectItem(root, "chat_id"); + path_json = cJSON_GetObjectItem(root, "path"); + caption_json = cJSON_GetObjectItem(root, "caption"); + if (cJSON_IsString(chat_id_json) && chat_id_json->valuestring && chat_id_json->valuestring[0]) { + chat_id = chat_id_json->valuestring; + } else if (ctx && ctx->chat_id && ctx->chat_id[0]) { + chat_id = ctx->chat_id; + } + if (cJSON_IsString(path_json) && path_json->valuestring && path_json->valuestring[0]) { + path = path_json->valuestring; + } + if (cJSON_IsString(caption_json) && caption_json->valuestring) { + caption = caption_json->valuestring; + } + + if (!chat_id || !path) { + cJSON_Delete(root); + snprintf(output, + output_size, + "{\"ok\":false,\"error\":\"chat_id and path are required (chat_id may come from ctx)\"}"); + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_feishu_send_media_internal(chat_id, path, caption, is_image); + cJSON_Delete(root); + if (err != ESP_OK) { + snprintf(output, output_size, "{\"ok\":false,\"error\":\"%s\"}", esp_err_to_name(err)); + return err; + } + + snprintf(output, output_size, "{\"ok\":true}"); + return ESP_OK; +} + +static esp_err_t cap_im_feishu_send_image_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + return cap_im_feishu_send_media_execute(input_json, ctx, output, output_size, true); +} + +static esp_err_t cap_im_feishu_send_file_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + return cap_im_feishu_send_media_execute(input_json, ctx, output, output_size, false); +} + +static const claw_cap_descriptor_t s_feishu_descriptors[] = { + { + .id = "feishu_gateway", + .name = "feishu_gateway", + .family = "im", + .description = "Feishu WebSocket gateway event source.", + .kind = CLAW_CAP_KIND_EVENT_SOURCE, + .cap_flags = CLAW_CAP_FLAG_EMITS_EVENTS | CLAW_CAP_FLAG_SUPPORTS_LIFECYCLE, + .input_schema_json = "{\"type\":\"object\"}", + .init = cap_im_feishu_gateway_init, + .start = cap_im_feishu_gateway_start, + .stop = cap_im_feishu_gateway_stop, + }, + { + .id = "feishu_send_message", + .name = "feishu_send_message", + .family = "im", + .description = "Send a text message to a Feishu chat_id or user open_id.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"chat_id\":{\"type\":\"string\"},\"message\":{\"type\":\"string\"}},\"required\":[\"chat_id\",\"message\"]}", + .execute = cap_im_feishu_send_message_execute, + }, + { + .id = "feishu_send_image", + .name = "feishu_send_image", + .family = "im", + .description = "Send an image file from a local path to a Feishu chat.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"chat_id\":{\"type\":\"string\"},\"path\":{\"type\":\"string\"},\"caption\":{\"type\":\"string\"}},\"required\":[\"path\"]}", + .execute = cap_im_feishu_send_image_execute, + }, + { + .id = "feishu_send_file", + .name = "feishu_send_file", + .family = "im", + .description = "Send a file from a local path to a Feishu chat.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"chat_id\":{\"type\":\"string\"},\"path\":{\"type\":\"string\"},\"caption\":{\"type\":\"string\"}},\"required\":[\"path\"]}", + .execute = cap_im_feishu_send_file_execute, + }, +}; + +static const claw_cap_group_t s_feishu_group = { + .group_id = "cap_im_feishu", + .descriptors = s_feishu_descriptors, + .descriptor_count = sizeof(s_feishu_descriptors) / sizeof(s_feishu_descriptors[0]), +}; + +esp_err_t cap_im_feishu_register_group(void) +{ + if (claw_cap_group_exists(s_feishu_group.group_id)) { + return ESP_OK; + } + + return claw_cap_register_group(&s_feishu_group); +} + +esp_err_t cap_im_feishu_set_credentials(const char *app_id, const char *app_secret) +{ + if (!app_id || !app_secret) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(s_feishu.app_id, app_id, sizeof(s_feishu.app_id)); + strlcpy(s_feishu.app_secret, app_secret, sizeof(s_feishu.app_secret)); + s_feishu.tenant_token[0] = '\0'; + s_feishu.token_expire_time_ms = 0; + return ESP_OK; +} + +esp_err_t cap_im_feishu_set_attachment_config(const cap_im_feishu_attachment_config_t *config) +{ + if (!config) { + return ESP_ERR_INVALID_ARG; + } + + s_feishu.enable_inbound_attachments = config->enable_inbound_attachments; + s_feishu.max_inbound_file_bytes = config->max_inbound_file_bytes; + if (config->storage_root_dir) { + strlcpy(s_feishu.attachment_root_dir, + config->storage_root_dir, + sizeof(s_feishu.attachment_root_dir)); + } else { + s_feishu.attachment_root_dir[0] = '\0'; + } + return ESP_OK; +} + +esp_err_t cap_im_feishu_start(void) +{ + BaseType_t ok; + + if (!s_feishu.app_id[0] || !s_feishu.app_secret[0]) { + ESP_LOGW(TAG, "Feishu not configured, skip start"); + return ESP_OK; + } + if (s_feishu.ws_task) { + return ESP_OK; + } + if (!s_feishu.attachment_queue) { + s_feishu.attachment_queue = xQueueCreate(CAP_IM_FEISHU_ATTACHMENT_QUEUE_LEN, + sizeof(cap_im_feishu_attachment_job_t *)); + if (!s_feishu.attachment_queue) { + return ESP_ERR_NO_MEM; + } + } + if (!s_feishu.attachment_task) { + ok = xTaskCreate(cap_im_feishu_attachment_task, + "feishu_attach", + CAP_IM_FEISHU_ATTACHMENT_STACK, + NULL, + 5, + &s_feishu.attachment_task); + if (ok != pdPASS) { + vQueueDelete(s_feishu.attachment_queue); + s_feishu.attachment_queue = NULL; + s_feishu.attachment_task = NULL; + return ESP_ERR_NO_MEM; + } + } + + s_feishu.stop_requested = false; + ok = xTaskCreate(cap_im_feishu_ws_task, + "feishu_ws", + 8 * 1024, + NULL, + 5, + &s_feishu.ws_task); + if (ok != pdPASS) { + if (s_feishu.attachment_task) { + vTaskDelete(s_feishu.attachment_task); + s_feishu.attachment_task = NULL; + } + if (s_feishu.attachment_queue) { + vQueueDelete(s_feishu.attachment_queue); + s_feishu.attachment_queue = NULL; + } + s_feishu.ws_task = NULL; + return ESP_ERR_NO_MEM; + } + + return ESP_OK; +} + +esp_err_t cap_im_feishu_stop(void) +{ + TickType_t deadline = 0; + + if (!s_feishu.ws_task && !s_feishu.attachment_task) { + return ESP_OK; + } + + s_feishu.stop_requested = true; + if (s_feishu.ws_client) { + esp_websocket_client_stop(s_feishu.ws_client); + } + + deadline = xTaskGetTickCount() + pdMS_TO_TICKS(5000); + while ((s_feishu.ws_task || s_feishu.attachment_task) && xTaskGetTickCount() < deadline) { + vTaskDelay(pdMS_TO_TICKS(100)); + } + if (s_feishu.ws_task || s_feishu.attachment_task) { + ESP_LOGW(TAG, "Feishu stop timed out"); + return ESP_ERR_TIMEOUT; + } + if (s_feishu.attachment_queue) { + cap_im_feishu_attachment_job_t *job = NULL; + + while (xQueueReceive(s_feishu.attachment_queue, &job, 0) == pdTRUE) { + cap_im_feishu_free_attachment_job(job); + } + vQueueDelete(s_feishu.attachment_queue); + s_feishu.attachment_queue = NULL; + } + + s_feishu.ws_client = NULL; + s_feishu.ws_connected = false; + return ESP_OK; +} + +esp_err_t cap_im_feishu_send_text(const char *chat_id, const char *text) +{ + size_t offset = 0; + size_t text_len = 0; + + if (!chat_id || !chat_id[0] || !text || !text[0]) { + return ESP_ERR_INVALID_ARG; + } + if (!s_feishu.app_id[0] || !s_feishu.app_secret[0]) { + return ESP_ERR_INVALID_STATE; + } + + text_len = strlen(text); + + while (offset < text_len) { + size_t chunk_len = text_len - offset; + cJSON *content = NULL; + char *content_str = NULL; + char *segment = NULL; + esp_err_t err; + + if (chunk_len > CAP_IM_FEISHU_MAX_CHUNK_LEN) { + chunk_len = CAP_IM_FEISHU_MAX_CHUNK_LEN; + } + + segment = calloc(1, chunk_len + 1); + if (!segment) { + return ESP_ERR_NO_MEM; + } + memcpy(segment, text + offset, chunk_len); + segment[chunk_len] = '\0'; + + content = cJSON_CreateObject(); + if (!content) { + free(segment); + cJSON_Delete(content); + return ESP_ERR_NO_MEM; + } + cJSON_AddStringToObject(content, "text", segment); + content_str = cJSON_PrintUnformatted(content); + cJSON_Delete(content); + free(segment); + if (!content_str) { + return ESP_ERR_NO_MEM; + } + + err = cap_im_feishu_send_message_content(chat_id, "text", content_str); + free(content_str); + if (err != ESP_OK) { + return err; + } + offset += chunk_len; + } + + return ESP_OK; +} + +esp_err_t cap_im_feishu_send_image(const char *chat_id, const char *path, const char *caption) +{ + if (!chat_id || !chat_id[0] || !path || !path[0]) { + return ESP_ERR_INVALID_ARG; + } + + return cap_im_feishu_send_media_internal(chat_id, path, caption, true); +} + +esp_err_t cap_im_feishu_send_file(const char *chat_id, const char *path, const char *caption) +{ + if (!chat_id || !chat_id[0] || !path || !path[0]) { + return ESP_ERR_INVALID_ARG; + } + + return cap_im_feishu_send_media_internal(chat_id, path, caption, false); +} diff --git a/components/claw_capabilities/cap_im_feishu/src/cmd_cap_im_feishu.c b/components/claw_capabilities/cap_im_feishu/src/cmd_cap_im_feishu.c new file mode 100644 index 0000000..2af0771 --- /dev/null +++ b/components/claw_capabilities/cap_im_feishu/src/cmd_cap_im_feishu.c @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cmd_cap_im_feishu.h" + +#include + +#include "argtable3/argtable3.h" +#include "cap_im_feishu.h" +#include "esp_console.h" + +static struct { + struct arg_lit *set_credentials; + struct arg_lit *start; + struct arg_lit *stop; + struct arg_str *send_text; + struct arg_str *send_image; + struct arg_str *send_file; + struct arg_str *app_id; + struct arg_str *app_secret; + struct arg_str *text; + struct arg_str *path; + struct arg_str *caption; + struct arg_end *end; +} feishu_args; + +static int cmd_feishu_set_credentials(const char *app_id, const char *app_secret) +{ + esp_err_t err = cap_im_feishu_set_credentials(app_id, app_secret); + + if (err != ESP_OK) { + printf("feishu_set_credentials failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Feishu credentials updated\n"); + return 0; +} + +static int cmd_feishu_start(void) +{ + esp_err_t err = cap_im_feishu_start(); + + if (err != ESP_OK) { + printf("feishu_start failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Feishu gateway started\n"); + return 0; +} + +static int cmd_feishu_stop(void) +{ + esp_err_t err = cap_im_feishu_stop(); + + if (err != ESP_OK) { + printf("feishu_stop failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Feishu gateway stopped\n"); + return 0; +} + +static int cmd_feishu_send_text(const char *chat_id, const char *text) +{ + esp_err_t err = cap_im_feishu_send_text(chat_id, text); + + if (err != ESP_OK) { + printf("feishu_send_text failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Feishu text sent\n"); + return 0; +} + +static int cmd_feishu_send_image(const char *chat_id, const char *path, const char *caption) +{ + esp_err_t err = cap_im_feishu_send_image(chat_id, path, caption); + + if (err != ESP_OK) { + printf("feishu_send_image failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Feishu image sent\n"); + return 0; +} + +static int cmd_feishu_send_file(const char *chat_id, const char *path, const char *caption) +{ + esp_err_t err = cap_im_feishu_send_file(chat_id, path, caption); + + if (err != ESP_OK) { + printf("feishu_send_file failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Feishu file sent\n"); + return 0; +} + +static int feishu_func(int argc, char **argv) +{ + int nerrors = arg_parse(argc, argv, (void **)&feishu_args); + int operation_count; + + if (nerrors != 0) { + arg_print_errors(stderr, feishu_args.end, argv[0]); + return 1; + } + + operation_count = feishu_args.set_credentials->count + + feishu_args.start->count + + feishu_args.stop->count + + feishu_args.send_text->count + + feishu_args.send_image->count + + feishu_args.send_file->count; + if (operation_count != 1) { + printf("Exactly one operation must be specified\n"); + return 1; + } + + if (feishu_args.set_credentials->count) { + if (!feishu_args.app_id->count || !feishu_args.app_secret->count) { + printf("'--set-credentials' requires '--app-id' and '--app-secret'\n"); + return 1; + } + return cmd_feishu_set_credentials(feishu_args.app_id->sval[0], feishu_args.app_secret->sval[0]); + } + + if (feishu_args.start->count) { + return cmd_feishu_start(); + } + + if (feishu_args.stop->count) { + return cmd_feishu_stop(); + } + + if (feishu_args.send_text->count) { + if (!feishu_args.text->count) { + printf("'--send-text' requires '--text'\n"); + return 1; + } + return cmd_feishu_send_text(feishu_args.send_text->sval[0], feishu_args.text->sval[0]); + } + + if (!feishu_args.path->count) { + printf("'--send-image' and '--send-file' require '--path'\n"); + return 1; + } + + if (feishu_args.send_image->count) { + return cmd_feishu_send_image(feishu_args.send_image->sval[0], + feishu_args.path->sval[0], + feishu_args.caption->count ? feishu_args.caption->sval[0] : NULL); + } + + return cmd_feishu_send_file(feishu_args.send_file->sval[0], + feishu_args.path->sval[0], + feishu_args.caption->count ? feishu_args.caption->sval[0] : NULL); +} + +void register_cap_im_feishu(void) +{ + feishu_args.set_credentials = arg_lit0(NULL, "set-credentials", "Set Feishu app credentials"); + feishu_args.start = arg_lit0(NULL, "start", "Start the Feishu gateway"); + feishu_args.stop = arg_lit0(NULL, "stop", "Stop the Feishu gateway"); + feishu_args.send_text = arg_str0(NULL, "send-text", "", "Send text to a Feishu chat"); + feishu_args.send_image = arg_str0(NULL, "send-image", "", "Send an image to a Feishu chat"); + feishu_args.send_file = arg_str0(NULL, "send-file", "", "Send a file to a Feishu chat"); + feishu_args.app_id = arg_str0(NULL, "app-id", "", "Feishu app ID"); + feishu_args.app_secret = arg_str0(NULL, "app-secret", "", "Feishu app secret"); + feishu_args.text = arg_str0(NULL, "text", "", "Text content"); + feishu_args.path = arg_str0(NULL, "path", "", "Local file path"); + feishu_args.caption = arg_str0(NULL, "caption", "", "Optional caption sent as text"); + feishu_args.end = arg_end(11); + + const esp_console_cmd_t feishu_cmd = { + .command = "feishu", + .help = "Feishu operation.\n" + "Examples:\n" + " feishu --set-credentials --app-id cli_xxx --app-secret sec_xxx\n" + " feishu --start\n" + " feishu --stop\n" + " feishu --send-text ou_xxx --text \"hello\"\n" + " feishu --send-image ou_xxx --path /fatfs/data/inbox/pic.jpg --caption \"look\"\n" + " feishu --send-file ou_xxx --path /fatfs/data/docs/readme.txt\n", + .func = feishu_func, + .argtable = &feishu_args, + }; + + ESP_ERROR_CHECK(esp_console_cmd_register(&feishu_cmd)); +} diff --git a/components/claw_capabilities/cap_mcp_client/CMakeLists.txt b/components/claw_capabilities/cap_mcp_client/CMakeLists.txt index 0b9ef7c..d14f54d 100644 --- a/components/claw_capabilities/cap_mcp_client/CMakeLists.txt +++ b/components/claw_capabilities/cap_mcp_client/CMakeLists.txt @@ -9,7 +9,9 @@ idf_component_register( "src" REQUIRES claw_cap + cap_mcp_server esp_http_client + esp_netif json mdns console diff --git a/components/claw_capabilities/cap_mcp_client/src/cap_mcp_discover_core.c b/components/claw_capabilities/cap_mcp_client/src/cap_mcp_discover_core.c index 3d3c670..52594d4 100644 --- a/components/claw_capabilities/cap_mcp_client/src/cap_mcp_discover_core.c +++ b/components/claw_capabilities/cap_mcp_client/src/cap_mcp_discover_core.c @@ -9,6 +9,8 @@ #include #include +#include "cap_mcp_server.h" +#include "esp_netif.h" #include "esp_log.h" #include "lwip/ip_addr.h" #include "mdns.h" @@ -58,6 +60,139 @@ static const char *cap_mcp_pick_ip_string(const mdns_result_t *result, return NULL; } +static esp_err_t cap_mcp_append_device(cJSON *devices, + const char *instance, + const char *hostname, + const char *ip, + const char *url_host, + uint16_t port, + const char *endpoint) +{ + cJSON *device = NULL; + char server_url[320]; + char url[384]; + const char *host_for_url = NULL; + + if (!devices || !hostname || !hostname[0] || !endpoint || !endpoint[0] || port == 0) { + return ESP_ERR_INVALID_ARG; + } + + host_for_url = (url_host && url_host[0]) ? url_host : ((ip && ip[0]) ? ip : hostname); + snprintf(server_url, sizeof(server_url), "http://%s:%u", host_for_url, (unsigned int)port); + snprintf(url, sizeof(url), "%s/%s", server_url, endpoint); + + device = cJSON_CreateObject(); + if (!device) { + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(device, "instance", (instance && instance[0]) ? instance : "(unknown)"); + cJSON_AddStringToObject(device, "hostname", hostname); + cJSON_AddStringToObject(device, "ip", (ip && ip[0]) ? ip : "(unresolved)"); + cJSON_AddNumberToObject(device, "port", port); + cJSON_AddStringToObject(device, "endpoint", endpoint); + cJSON_AddStringToObject(device, "server_url", server_url); + cJSON_AddStringToObject(device, "url", url); + cJSON_AddItemToArray(devices, device); + return ESP_OK; +} + +static bool cap_mcp_devices_has_match(const cJSON *devices, + const char *hostname, + uint16_t port, + const char *endpoint) +{ + const cJSON *device = NULL; + + if (!cJSON_IsArray((cJSON *)devices) || !hostname || !hostname[0] || !endpoint || !endpoint[0]) { + return false; + } + + cJSON_ArrayForEach(device, (cJSON *)devices) { + const cJSON *hostname_item = cJSON_GetObjectItemCaseSensitive((cJSON *)device, "hostname"); + const cJSON *port_item = cJSON_GetObjectItemCaseSensitive((cJSON *)device, "port"); + const cJSON *endpoint_item = cJSON_GetObjectItemCaseSensitive((cJSON *)device, "endpoint"); + + if (!cJSON_IsString((cJSON *)hostname_item) || !cJSON_IsNumber((cJSON *)port_item) || + !cJSON_IsString((cJSON *)endpoint_item)) { + continue; + } + if (strcmp(hostname_item->valuestring, hostname) == 0 && + (uint16_t)port_item->valueint == port && + strcmp(endpoint_item->valuestring, endpoint) == 0) { + return true; + } + } + + return false; +} + +static const char *cap_mcp_get_self_ip_string(char *buf, size_t buf_size) +{ + esp_netif_t *netif = NULL; + esp_netif_ip_info_t ip_info = {0}; + + if (!buf || buf_size == 0) { + return NULL; + } + buf[0] = '\0'; + + netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (!netif) { + return NULL; + } + if (esp_netif_get_ip_info(netif, &ip_info) != ESP_OK || ip_info.ip.addr == 0) { + return NULL; + } + + snprintf(buf, buf_size, IPSTR, IP2STR(&ip_info.ip)); + return buf; +} + +static esp_err_t cap_mcp_append_self_device_if_needed(cJSON *devices, size_t *found) +{ + cap_mcp_server_config_t config = {0}; + bool started = false; + char hostname_local[96]; + char ip_buf[64]; + const char *hostname = NULL; + const char *instance = NULL; + const char *endpoint = NULL; + const char *ip = NULL; + esp_err_t err; + + if (!devices || !found) { + return ESP_ERR_INVALID_ARG; + } + + err = cap_mcp_server_get_config(&config, &started); + if (err != ESP_OK || !started) { + return err; + } + + hostname = (config.hostname && config.hostname[0]) ? config.hostname : "clawgent"; + instance = (config.instance_name && config.instance_name[0]) ? config.instance_name : "Clawgent"; + endpoint = (config.endpoint && config.endpoint[0]) ? config.endpoint : CAP_MCP_DEFAULT_ENDPOINT; + ip = cap_mcp_get_self_ip_string(ip_buf, sizeof(ip_buf)); + + if (cap_mcp_devices_has_match(devices, hostname, config.server_port, endpoint)) { + return ESP_OK; + } + + snprintf(hostname_local, sizeof(hostname_local), "%s.local", hostname); + err = cap_mcp_append_device(devices, + instance, + hostname, + ip, + ip ? ip : hostname_local, + config.server_port, + endpoint); + if (err == ESP_OK) { + (*found)++; + } + return err; +} + static esp_err_t cap_mcp_parse_discover_options(const char *input_json, int *timeout_ms, bool *include_self) @@ -139,10 +274,7 @@ esp_err_t cap_mcp_discover_services(const char *input_json, cJSON **result_out) const char *endpoint; const char *hostname; const char *instance; - const char *host_for_url; - cJSON *device; - char server_url[320]; - char url[384]; + esp_err_t append_err; if (!include_self && result->hostname && strcmp(result->hostname, "clawgent") == 0) { continue; @@ -157,29 +289,30 @@ esp_err_t cap_mcp_discover_services(const char *input_json, cJSON **result_out) hostname = (result->hostname && result->hostname[0]) ? result->hostname : "(unknown)"; instance = (result->instance_name && result->instance_name[0]) ? result->instance_name : "(unknown)"; - host_for_url = (ip && ip[0]) ? ip : hostname; - device = cJSON_CreateObject(); - if (!device) { + append_err = cap_mcp_append_device(devices, + instance, + hostname, + ip, + NULL, + result->port, + endpoint); + if (append_err != ESP_OK) { mdns_query_results_free(results); cJSON_Delete(root); - return ESP_ERR_NO_MEM; + return append_err; } - - snprintf(server_url, sizeof(server_url), "http://%s:%u", host_for_url, result->port); - snprintf(url, sizeof(url), "%s/%s", server_url, endpoint); - cJSON_AddStringToObject(device, "instance", instance); - cJSON_AddStringToObject(device, "hostname", hostname); - cJSON_AddStringToObject(device, "ip", ip ? ip : "(unresolved)"); - cJSON_AddNumberToObject(device, "port", result->port); - cJSON_AddStringToObject(device, "endpoint", endpoint); - cJSON_AddStringToObject(device, "server_url", server_url); - cJSON_AddStringToObject(device, "url", url); - cJSON_AddItemToArray(devices, device); found++; } mdns_query_results_free(results); + if (include_self) { + err = cap_mcp_append_self_device_if_needed(devices, &found); + if (err != ESP_OK) { + cJSON_Delete(root); + return err; + } + } cJSON_AddNumberToObject(root, "count", (double)found); cJSON_AddItemToObject(root, "devices", devices); *result_out = root; diff --git a/components/claw_capabilities/cap_mcp_server/include/cap_mcp_server.h b/components/claw_capabilities/cap_mcp_server/include/cap_mcp_server.h index 0646342..4492a9e 100644 --- a/components/claw_capabilities/cap_mcp_server/include/cap_mcp_server.h +++ b/components/claw_capabilities/cap_mcp_server/include/cap_mcp_server.h @@ -5,6 +5,7 @@ */ #pragma once +#include #include #include "esp_err.h" @@ -23,6 +24,7 @@ typedef struct { esp_err_t cap_mcp_server_register_group(void); esp_err_t cap_mcp_server_set_config(const cap_mcp_server_config_t *config); +esp_err_t cap_mcp_server_get_config(cap_mcp_server_config_t *config, bool *started); #ifdef __cplusplus } diff --git a/components/claw_capabilities/cap_mcp_server/src/cap_mcp_server.c b/components/claw_capabilities/cap_mcp_server/src/cap_mcp_server.c index f276fca..4d2005d 100644 --- a/components/claw_capabilities/cap_mcp_server/src/cap_mcp_server.c +++ b/components/claw_capabilities/cap_mcp_server/src/cap_mcp_server.c @@ -492,3 +492,21 @@ esp_err_t cap_mcp_server_set_config(const cap_mcp_server_config_t *config) return ESP_OK; } + +esp_err_t cap_mcp_server_get_config(cap_mcp_server_config_t *config, bool *started) +{ + if (!config) { + return ESP_ERR_INVALID_ARG; + } + + config->hostname = s_config.hostname; + config->instance_name = s_config.instance_name; + config->endpoint = s_config.endpoint; + config->server_port = s_config.server_port; + config->ctrl_port = s_config.ctrl_port; + if (started) { + *started = s_started; + } + + return ESP_OK; +} diff --git a/components/claw_modules/claw_core/CMakeLists.txt b/components/claw_modules/claw_core/CMakeLists.txt index 16b801f..e8549c8 100644 --- a/components/claw_modules/claw_core/CMakeLists.txt +++ b/components/claw_modules/claw_core/CMakeLists.txt @@ -4,6 +4,7 @@ idf_component_register( "src/claw_core_llm.c" "src/llm/claw_llm_runtime.c" "src/llm/claw_llm_http_transport.c" + "src/llm/backends/claw_llm_backend_anthropic.c" "src/llm/backends/claw_llm_backend_openai_compatible.c" "src/llm/backends/claw_llm_backend_custom.c" "src/llm/media/claw_media_pipeline.c" diff --git a/components/claw_modules/claw_core/src/llm/backends/claw_llm_backend_anthropic.c b/components/claw_modules/claw_core/src/llm/backends/claw_llm_backend_anthropic.c new file mode 100644 index 0000000..346f584 --- /dev/null +++ b/components/claw_modules/claw_core/src/llm/backends/claw_llm_backend_anthropic.c @@ -0,0 +1,914 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "llm/backends/claw_llm_backend_anthropic.h" + +#include +#include +#include +#include +#include + +#include "llm/claw_llm_http_transport.h" +#include "llm/media/claw_media_pipeline.h" + +#define CLAW_LLM_ANTHROPIC_VERSION "2023-06-01" +#define CLAW_LLM_ANTHROPIC_MAX_TOKENS 8192 + +typedef struct { + char *api_key; + char *model; + char *base_url; + uint32_t timeout_ms; + size_t image_max_bytes; +} anthropic_backend_ctx_t; + +static char *dup_printf(const char *fmt, ...) +{ + va_list args; + va_list copy; + int needed; + char *buf; + + va_start(args, fmt); + va_copy(copy, args); + needed = vsnprintf(NULL, 0, fmt, copy); + va_end(copy); + if (needed < 0) { + va_end(args); + return NULL; + } + + buf = calloc(1, (size_t)needed + 1); + if (!buf) { + va_end(args); + return NULL; + } + + vsnprintf(buf, (size_t)needed + 1, fmt, args); + va_end(args); + return buf; +} + +static char *dup_n_string(const char *value, size_t len) +{ + char *copy = NULL; + + if (!value) { + return NULL; + } + + copy = calloc(1, len + 1); + if (!copy) { + return NULL; + } + memcpy(copy, value, len); + copy[len] = '\0'; + return copy; +} + +static char *join_url(const char *base_url, const char *path) +{ + bool base_has_slash; + bool path_has_slash; + + if (!base_url || !path) { + return NULL; + } + + base_has_slash = base_url[0] && base_url[strlen(base_url) - 1] == '/'; + path_has_slash = path[0] == '/'; + if (base_has_slash && path_has_slash) { + return dup_printf("%s%s", base_url, path + 1); + } + if (!base_has_slash && !path_has_slash) { + return dup_printf("%s/%s", base_url, path); + } + return dup_printf("%s%s", base_url, path); +} + +static esp_err_t dup_json_string(cJSON *json, char **out_value) +{ + if (!json || !cJSON_IsString(json) || !json->valuestring || !out_value) { + return ESP_ERR_INVALID_ARG; + } + + *out_value = strdup(json->valuestring); + return *out_value ? ESP_OK : ESP_ERR_NO_MEM; +} + +static cJSON *anthropic_make_text_block(const char *text) +{ + cJSON *block = NULL; + + if (!text || !text[0]) { + return NULL; + } + + block = cJSON_CreateObject(); + if (!block) { + return NULL; + } + cJSON_AddStringToObject(block, "type", "text"); + cJSON_AddStringToObject(block, "text", text); + return block; +} + +static cJSON *anthropic_make_tool_use_block(cJSON *tool_call) +{ + cJSON *block = NULL; + cJSON *id_json = NULL; + cJSON *function_json = NULL; + cJSON *name_json = NULL; + cJSON *args_json = NULL; + cJSON *input_json = NULL; + + if (!tool_call || !cJSON_IsObject(tool_call)) { + return NULL; + } + + id_json = cJSON_GetObjectItem(tool_call, "id"); + function_json = cJSON_GetObjectItem(tool_call, "function"); + name_json = cJSON_IsObject(function_json) ? cJSON_GetObjectItem(function_json, "name") : NULL; + args_json = cJSON_IsObject(function_json) ? cJSON_GetObjectItem(function_json, "arguments") : NULL; + if (!cJSON_IsString(id_json) || !cJSON_IsString(name_json)) { + return NULL; + } + + block = cJSON_CreateObject(); + if (!block) { + return NULL; + } + cJSON_AddStringToObject(block, "type", "tool_use"); + cJSON_AddStringToObject(block, "id", id_json->valuestring); + cJSON_AddStringToObject(block, "name", name_json->valuestring); + + if (cJSON_IsString(args_json) && args_json->valuestring && args_json->valuestring[0]) { + input_json = cJSON_Parse(args_json->valuestring); + } + if (!input_json) { + input_json = cJSON_CreateObject(); + } + if (!input_json) { + cJSON_Delete(block); + return NULL; + } + cJSON_AddItemToObject(block, "input", input_json); + return block; +} + +static cJSON *anthropic_make_tool_result_message(const char *tool_call_id, const char *content) +{ + cJSON *message = NULL; + cJSON *blocks = NULL; + cJSON *block = NULL; + + if (!tool_call_id || !tool_call_id[0]) { + return NULL; + } + + message = cJSON_CreateObject(); + blocks = cJSON_CreateArray(); + block = cJSON_CreateObject(); + if (!message || !blocks || !block) { + cJSON_Delete(message); + cJSON_Delete(blocks); + cJSON_Delete(block); + return NULL; + } + + cJSON_AddStringToObject(message, "role", "user"); + cJSON_AddStringToObject(block, "type", "tool_result"); + cJSON_AddStringToObject(block, "tool_use_id", tool_call_id); + cJSON_AddStringToObject(block, "content", content ? content : ""); + cJSON_AddBoolToObject(block, "is_error", false); + cJSON_AddItemToArray(blocks, block); + cJSON_AddItemToObject(message, "content", blocks); + return message; +} + +static cJSON *anthropic_duplicate_supported_block(cJSON *block) +{ + cJSON *type_json = NULL; + + if (!block || !cJSON_IsObject(block)) { + return NULL; + } + + type_json = cJSON_GetObjectItem(block, "type"); + if (!cJSON_IsString(type_json) || !type_json->valuestring) { + return NULL; + } + if (strcmp(type_json->valuestring, "text") == 0 || + strcmp(type_json->valuestring, "tool_use") == 0 || + strcmp(type_json->valuestring, "tool_result") == 0) { + return cJSON_Duplicate(block, true); + } + return NULL; +} + +static cJSON *convert_messages_to_anthropic(cJSON *messages) +{ + cJSON *out = NULL; + cJSON *msg = NULL; + + out = cJSON_CreateArray(); + if (!out) { + return NULL; + } + if (!messages || !cJSON_IsArray(messages)) { + return out; + } + + cJSON_ArrayForEach(msg, messages) { + cJSON *role_json = cJSON_GetObjectItem(msg, "role"); + cJSON *content_json = cJSON_GetObjectItem(msg, "content"); + const char *role = cJSON_IsString(role_json) ? role_json->valuestring : NULL; + cJSON *out_message = NULL; + cJSON *blocks = NULL; + cJSON *tool_calls = NULL; + cJSON *tool_call = NULL; + + if (!role || !role[0]) { + continue; + } + + if (strcmp(role, "tool") == 0) { + cJSON *tool_call_id = cJSON_GetObjectItem(msg, "tool_call_id"); + cJSON *tool_message = anthropic_make_tool_result_message( + cJSON_IsString(tool_call_id) ? tool_call_id->valuestring : NULL, + cJSON_IsString(content_json) ? content_json->valuestring : ""); + if (!tool_message) { + cJSON_Delete(out); + return NULL; + } + cJSON_AddItemToArray(out, tool_message); + continue; + } + + if (strcmp(role, "assistant") != 0 && strcmp(role, "user") != 0) { + continue; + } + + out_message = cJSON_CreateObject(); + blocks = cJSON_CreateArray(); + if (!out_message || !blocks) { + cJSON_Delete(out_message); + cJSON_Delete(blocks); + cJSON_Delete(out); + return NULL; + } + + cJSON_AddStringToObject(out_message, "role", role); + + if (cJSON_IsString(content_json) && content_json->valuestring && content_json->valuestring[0]) { + cJSON *text_block = anthropic_make_text_block(content_json->valuestring); + + if (!text_block) { + cJSON_Delete(out_message); + cJSON_Delete(blocks); + cJSON_Delete(out); + return NULL; + } + cJSON_AddItemToArray(blocks, text_block); + } else if (cJSON_IsArray(content_json)) { + cJSON *block = NULL; + + cJSON_ArrayForEach(block, content_json) { + cJSON *dup = anthropic_duplicate_supported_block(block); + + if (dup) { + cJSON_AddItemToArray(blocks, dup); + } + } + } + + if (strcmp(role, "assistant") == 0) { + tool_calls = cJSON_GetObjectItem(msg, "tool_calls"); + if (cJSON_IsArray(tool_calls)) { + cJSON_ArrayForEach(tool_call, tool_calls) { + cJSON *tool_block = anthropic_make_tool_use_block(tool_call); + + if (!tool_block) { + cJSON_Delete(out_message); + cJSON_Delete(blocks); + cJSON_Delete(out); + return NULL; + } + cJSON_AddItemToArray(blocks, tool_block); + } + } + } + + if (cJSON_GetArraySize(blocks) == 0) { + cJSON_Delete(out_message); + cJSON_Delete(blocks); + continue; + } + + cJSON_AddItemToObject(out_message, "content", blocks); + cJSON_AddItemToArray(out, out_message); + } + + return out; +} + +static cJSON *convert_tools_to_anthropic(const char *tools_json) +{ + cJSON *parsed = NULL; + cJSON *out = NULL; + cJSON *item = NULL; + + if (!tools_json || !tools_json[0]) { + return NULL; + } + + parsed = cJSON_Parse(tools_json); + if (!parsed || !cJSON_IsArray(parsed)) { + cJSON_Delete(parsed); + return NULL; + } + + out = cJSON_CreateArray(); + if (!out) { + cJSON_Delete(parsed); + return NULL; + } + + cJSON_ArrayForEach(item, parsed) { + cJSON *tool = NULL; + cJSON *function_json = NULL; + cJSON *name_json = NULL; + cJSON *desc_json = NULL; + cJSON *schema_json = NULL; + + if (cJSON_IsObject(item)) { + cJSON *type_json = cJSON_GetObjectItem(item, "type"); + + if (cJSON_IsString(type_json) && type_json->valuestring && + strcmp(type_json->valuestring, "function") == 0) { + function_json = cJSON_GetObjectItem(item, "function"); + name_json = cJSON_IsObject(function_json) ? cJSON_GetObjectItem(function_json, "name") : NULL; + desc_json = cJSON_IsObject(function_json) ? cJSON_GetObjectItem(function_json, "description") : NULL; + schema_json = cJSON_IsObject(function_json) ? cJSON_GetObjectItem(function_json, "parameters") : NULL; + } else { + name_json = cJSON_GetObjectItem(item, "name"); + desc_json = cJSON_GetObjectItem(item, "description"); + schema_json = cJSON_GetObjectItem(item, "input_schema"); + } + } + + if (!cJSON_IsString(name_json) || !name_json->valuestring || !name_json->valuestring[0]) { + continue; + } + + tool = cJSON_CreateObject(); + if (!tool) { + cJSON_Delete(parsed); + cJSON_Delete(out); + return NULL; + } + + cJSON_AddStringToObject(tool, "name", name_json->valuestring); + if (cJSON_IsString(desc_json) && desc_json->valuestring) { + cJSON_AddStringToObject(tool, "description", desc_json->valuestring); + } + if (schema_json) { + cJSON_AddItemToObject(tool, "input_schema", cJSON_Duplicate(schema_json, true)); + } else { + cJSON_AddItemToObject(tool, "input_schema", cJSON_CreateObject()); + } + cJSON_AddItemToArray(out, tool); + } + + cJSON_Delete(parsed); + return out; +} + +static esp_err_t parse_data_url(const char *data_url, char **out_mime, char **out_data) +{ + const char *prefix = "data:"; + const char *mime_end = NULL; + const char *data_start = NULL; + + if (out_mime) { + *out_mime = NULL; + } + if (out_data) { + *out_data = NULL; + } + if (!data_url || strncmp(data_url, prefix, strlen(prefix)) != 0 || !out_mime || !out_data) { + return ESP_ERR_INVALID_ARG; + } + + mime_end = strstr(data_url, ";base64,"); + if (!mime_end) { + return ESP_ERR_INVALID_RESPONSE; + } + data_start = mime_end + strlen(";base64,"); + if (!data_start[0]) { + return ESP_ERR_INVALID_RESPONSE; + } + + *out_mime = dup_n_string(data_url + strlen(prefix), (size_t)(mime_end - (data_url + strlen(prefix)))); + *out_data = strdup(data_start); + if (!*out_mime || !*out_data) { + free(*out_mime); + free(*out_data); + *out_mime = NULL; + *out_data = NULL; + return ESP_ERR_NO_MEM; + } + + return ESP_OK; +} + +static esp_err_t parse_chat_response(const char *body, + claw_llm_response_t *out_response, + char **out_error_message) +{ + cJSON *root = NULL; + cJSON *content = NULL; + cJSON *block = NULL; + size_t tool_count = 0; + size_t tool_index = 0; + size_t total_text_len = 0; + char *text = NULL; + + if (!body || !out_response || !out_error_message) { + return ESP_ERR_INVALID_ARG; + } + + memset(out_response, 0, sizeof(*out_response)); + root = cJSON_Parse(body); + if (!root) { + *out_error_message = dup_printf("Failed to parse LLM JSON response"); + return ESP_FAIL; + } + + content = cJSON_GetObjectItem(root, "content"); + if (!cJSON_IsArray(content)) { + cJSON_Delete(root); + *out_error_message = dup_printf("LLM response missing content"); + return ESP_FAIL; + } + + cJSON_ArrayForEach(block, content) { + cJSON *type_json = cJSON_GetObjectItem(block, "type"); + + if (!cJSON_IsString(type_json) || !type_json->valuestring) { + continue; + } + if (strcmp(type_json->valuestring, "text") == 0) { + cJSON *text_json = cJSON_GetObjectItem(block, "text"); + + if (cJSON_IsString(text_json) && text_json->valuestring) { + total_text_len += strlen(text_json->valuestring); + } + } else if (strcmp(type_json->valuestring, "tool_use") == 0) { + tool_count++; + } + } + + if (total_text_len > 0) { + size_t offset = 0; + + text = calloc(1, total_text_len + 1); + if (!text) { + cJSON_Delete(root); + *out_error_message = dup_printf("Out of memory copying LLM text"); + return ESP_ERR_NO_MEM; + } + + cJSON_ArrayForEach(block, content) { + cJSON *type_json = cJSON_GetObjectItem(block, "type"); + cJSON *text_json = NULL; + size_t len = 0; + + if (!cJSON_IsString(type_json) || strcmp(type_json->valuestring, "text") != 0) { + continue; + } + text_json = cJSON_GetObjectItem(block, "text"); + if (!cJSON_IsString(text_json) || !text_json->valuestring) { + continue; + } + len = strlen(text_json->valuestring); + memcpy(text + offset, text_json->valuestring, len); + offset += len; + } + + out_response->text = text; + text = NULL; + } + + if (tool_count > 0) { + out_response->tool_calls = calloc(tool_count, sizeof(claw_llm_tool_call_t)); + if (!out_response->tool_calls) { + cJSON_Delete(root); + *out_error_message = dup_printf("Out of memory copying tool calls"); + return ESP_ERR_NO_MEM; + } + out_response->tool_call_count = tool_count; + + cJSON_ArrayForEach(block, content) { + cJSON *type_json = cJSON_GetObjectItem(block, "type"); + cJSON *dst_input = NULL; + char *input_json = NULL; + claw_llm_tool_call_t *dst = NULL; + esp_err_t err = ESP_OK; + + if (!cJSON_IsString(type_json) || strcmp(type_json->valuestring, "tool_use") != 0) { + continue; + } + + dst = &out_response->tool_calls[tool_index]; + err = dup_json_string(cJSON_GetObjectItem(block, "id"), &dst->id); + if (err == ESP_OK) { + err = dup_json_string(cJSON_GetObjectItem(block, "name"), &dst->name); + } + if (err == ESP_OK) { + dst_input = cJSON_GetObjectItem(block, "input"); + if (dst_input) { + input_json = cJSON_PrintUnformatted(dst_input); + } else { + input_json = strdup("{}"); + } + if (!input_json) { + err = ESP_ERR_NO_MEM; + } else { + dst->arguments_json = input_json; + input_json = NULL; + } + } + if (err != ESP_OK) { + free(input_json); + cJSON_Delete(root); + *out_error_message = dup_printf("Out of memory copying tool call"); + return err; + } + tool_index++; + } + } + + cJSON_Delete(root); + if (!out_response->text && out_response->tool_call_count == 0) { + *out_error_message = dup_printf("LLM returned empty response"); + return ESP_FAIL; + } + return ESP_OK; +} + +static esp_err_t build_chat_body(const anthropic_backend_ctx_t *ctx, + const claw_llm_chat_request_t *request, + char **out_post_data, + char **out_error_message) +{ + cJSON *body = NULL; + cJSON *messages = NULL; + cJSON *tools = NULL; + char *post_data = NULL; + + body = cJSON_CreateObject(); + if (!body) { + *out_error_message = dup_printf("Out of memory building request"); + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(body, "model", ctx->model); + cJSON_AddNumberToObject(body, "max_tokens", CLAW_LLM_ANTHROPIC_MAX_TOKENS); + cJSON_AddStringToObject(body, "system", request->system_prompt ? request->system_prompt : ""); + + messages = convert_messages_to_anthropic(request->messages); + if (!messages) { + cJSON_Delete(body); + *out_error_message = dup_printf("Out of memory converting messages"); + return ESP_ERR_NO_MEM; + } + cJSON_AddItemToObject(body, "messages", messages); + + tools = convert_tools_to_anthropic(request->tools_json); + if (request->tools_json && request->tools_json[0] && !tools) { + cJSON_Delete(body); + *out_error_message = dup_printf("Invalid tools JSON"); + return ESP_ERR_INVALID_ARG; + } + if (tools && cJSON_GetArraySize(tools) > 0) { + cJSON *tool_choice = cJSON_CreateObject(); + + if (!tool_choice) { + cJSON_Delete(tools); + cJSON_Delete(body); + *out_error_message = dup_printf("Out of memory building tool choice"); + return ESP_ERR_NO_MEM; + } + cJSON_AddItemToObject(body, "tools", tools); + cJSON_AddStringToObject(tool_choice, "type", "auto"); + cJSON_AddItemToObject(body, "tool_choice", tool_choice); + } else { + cJSON_Delete(tools); + } + + post_data = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!post_data) { + *out_error_message = dup_printf("Out of memory serializing request"); + return ESP_ERR_NO_MEM; + } + + *out_post_data = post_data; + return ESP_OK; +} + +static esp_err_t anthropic_init(const claw_llm_runtime_config_t *config, + const claw_llm_model_profile_t *profile, + void **out_backend_ctx, + char **out_error_message) +{ + anthropic_backend_ctx_t *ctx = NULL; + const char *base_url = NULL; + + if (!config || !profile || !out_backend_ctx || !out_error_message) { + return ESP_ERR_INVALID_ARG; + } + if (!config->api_key || !config->api_key[0]) { + *out_error_message = dup_printf("LLM API key is empty"); + return ESP_ERR_INVALID_ARG; + } + if (!config->model || !config->model[0]) { + *out_error_message = dup_printf("LLM model is empty"); + return ESP_ERR_INVALID_ARG; + } + + base_url = (config->base_url && config->base_url[0]) ? config->base_url : profile->default_base_url; + if (!base_url || !base_url[0]) { + *out_error_message = dup_printf("LLM base_url is empty"); + return ESP_ERR_INVALID_ARG; + } + + ctx = calloc(1, sizeof(*ctx)); + if (!ctx) { + *out_error_message = dup_printf("Out of memory allocating backend context"); + return ESP_ERR_NO_MEM; + } + + ctx->api_key = strdup(config->api_key); + ctx->model = strdup(config->model); + ctx->base_url = strdup(base_url); + ctx->timeout_ms = config->timeout_ms ? config->timeout_ms : profile->default_timeout_ms; + ctx->image_max_bytes = config->image_max_bytes ? config->image_max_bytes : profile->default_image_max_bytes; + if (!ctx->api_key || !ctx->model || !ctx->base_url) { + free(ctx->api_key); + free(ctx->model); + free(ctx->base_url); + free(ctx); + *out_error_message = dup_printf("Out of memory copying backend config"); + return ESP_ERR_NO_MEM; + } + + *out_backend_ctx = ctx; + return ESP_OK; +} + +static esp_err_t anthropic_chat(void *backend_ctx, + const claw_llm_model_profile_t *profile, + const claw_llm_chat_request_t *request, + claw_llm_response_t *out_response, + char **out_error_message) +{ + anthropic_backend_ctx_t *ctx = (anthropic_backend_ctx_t *)backend_ctx; + claw_llm_http_json_request_t http_request = {0}; + claw_llm_http_response_t http_response = {0}; + char *post_data = NULL; + char *url = NULL; + esp_err_t err; + const claw_llm_http_header_t headers[] = { + { .name = "x-api-key", .value = ctx ? ctx->api_key : NULL }, + { .name = "anthropic-version", .value = CLAW_LLM_ANTHROPIC_VERSION }, + }; + + if (!ctx || !profile || !request || !request->messages || !out_response || !out_error_message) { + return ESP_ERR_INVALID_ARG; + } + + err = build_chat_body(ctx, request, &post_data, out_error_message); + if (err != ESP_OK) { + return err; + } + + url = join_url(ctx->base_url, profile->chat_path); + if (!url) { + free(post_data); + *out_error_message = dup_printf("Out of memory building API URL"); + return ESP_ERR_NO_MEM; + } + + http_request.url = url; + http_request.body = post_data; + http_request.auth_type = "none"; + http_request.timeout_ms = ctx->timeout_ms; + http_request.headers = headers; + http_request.header_count = sizeof(headers) / sizeof(headers[0]); + + err = claw_llm_http_post_json(&http_request, &http_response, out_error_message); + free(url); + free(post_data); + if (err != ESP_OK) { + return err; + } + + err = parse_chat_response(http_response.body, out_response, out_error_message); + claw_llm_http_response_free(&http_response); + return err; +} + +static esp_err_t anthropic_infer_media(void *backend_ctx, + const claw_llm_model_profile_t *profile, + const claw_llm_media_request_t *request, + char **out_text, + char **out_error_message) +{ + anthropic_backend_ctx_t *ctx = (anthropic_backend_ctx_t *)backend_ctx; + claw_media_prepared_t prepared = {0}; + claw_llm_response_t response = {0}; + claw_llm_http_json_request_t http_request = {0}; + claw_llm_http_response_t http_response = {0}; + cJSON *body = NULL; + cJSON *messages = NULL; + cJSON *user_msg = NULL; + cJSON *content = NULL; + cJSON *text_block = NULL; + cJSON *image_block = NULL; + cJSON *source = NULL; + char *mime = NULL; + char *base64_data = NULL; + char *post_data = NULL; + char *url = NULL; + esp_err_t err; + const claw_llm_http_header_t headers[] = { + { .name = "x-api-key", .value = ctx ? ctx->api_key : NULL }, + { .name = "anthropic-version", .value = CLAW_LLM_ANTHROPIC_VERSION }, + }; + + if (out_text) { + *out_text = NULL; + } + if (out_error_message) { + *out_error_message = NULL; + } + if (!ctx || !profile || !request || !out_text || !out_error_message) { + return ESP_ERR_INVALID_ARG; + } + if (!profile->supports_vision) { + *out_error_message = dup_printf("Selected profile does not support media inference"); + return ESP_ERR_NOT_SUPPORTED; + } + if (!request->user_prompt || !request->user_prompt[0] || !request->media || request->media_count == 0) { + *out_error_message = dup_printf("media request is incomplete"); + return ESP_ERR_INVALID_ARG; + } + + err = claw_media_prepare_asset(&request->media[0], + profile, + ctx->image_max_bytes, + &prepared, + out_error_message); + if (err != ESP_OK) { + return err; + } + if (prepared.kind != CLAW_MEDIA_PREPARED_KIND_DATA_URL) { + err = ESP_ERR_NOT_SUPPORTED; + *out_error_message = dup_printf("Anthropic backend requires local image data"); + goto cleanup; + } + + err = parse_data_url(prepared.payload, &mime, &base64_data); + if (err != ESP_OK) { + *out_error_message = dup_printf("Failed to prepare Anthropic image payload"); + goto cleanup; + } + + body = cJSON_CreateObject(); + messages = cJSON_CreateArray(); + user_msg = cJSON_CreateObject(); + content = cJSON_CreateArray(); + text_block = cJSON_CreateObject(); + image_block = cJSON_CreateObject(); + source = cJSON_CreateObject(); + if (!body || !messages || !user_msg || !content || !text_block || !image_block || !source) { + err = ESP_ERR_NO_MEM; + *out_error_message = dup_printf("Out of memory building media request"); + goto cleanup; + } + + cJSON_AddStringToObject(body, "model", ctx->model); + cJSON_AddNumberToObject(body, "max_tokens", CLAW_LLM_ANTHROPIC_MAX_TOKENS); + cJSON_AddStringToObject(body, "system", request->system_prompt ? request->system_prompt : ""); + + cJSON_AddStringToObject(user_msg, "role", "user"); + cJSON_AddStringToObject(text_block, "type", "text"); + cJSON_AddStringToObject(text_block, "text", request->user_prompt); + cJSON_AddItemToArray(content, text_block); + text_block = NULL; + + cJSON_AddStringToObject(image_block, "type", "image"); + cJSON_AddStringToObject(source, "type", "base64"); + cJSON_AddStringToObject(source, "media_type", mime); + cJSON_AddStringToObject(source, "data", base64_data); + cJSON_AddItemToObject(image_block, "source", source); + source = NULL; + cJSON_AddItemToArray(content, image_block); + image_block = NULL; + + cJSON_AddItemToObject(user_msg, "content", content); + content = NULL; + cJSON_AddItemToArray(messages, user_msg); + user_msg = NULL; + cJSON_AddItemToObject(body, "messages", messages); + messages = NULL; + + post_data = cJSON_PrintUnformatted(body); + if (!post_data) { + err = ESP_ERR_NO_MEM; + *out_error_message = dup_printf("Out of memory serializing media request"); + goto cleanup; + } + + url = join_url(ctx->base_url, profile->chat_path); + if (!url) { + err = ESP_ERR_NO_MEM; + *out_error_message = dup_printf("Out of memory building API URL"); + goto cleanup; + } + + http_request.url = url; + http_request.body = post_data; + http_request.auth_type = "none"; + http_request.timeout_ms = ctx->timeout_ms; + http_request.headers = headers; + http_request.header_count = sizeof(headers) / sizeof(headers[0]); + + err = claw_llm_http_post_json(&http_request, &http_response, out_error_message); + if (err != ESP_OK) { + goto cleanup; + } + + err = parse_chat_response(http_response.body, &response, out_error_message); + if (err != ESP_OK) { + goto cleanup; + } + if (!response.text || !response.text[0]) { + err = ESP_FAIL; + *out_error_message = dup_printf("LLM returned empty media response"); + goto cleanup; + } + + *out_text = response.text; + response.text = NULL; + err = ESP_OK; + +cleanup: + free(mime); + free(base64_data); + free(post_data); + free(url); + cJSON_Delete(body); + cJSON_Delete(messages); + cJSON_Delete(user_msg); + cJSON_Delete(content); + cJSON_Delete(text_block); + cJSON_Delete(image_block); + cJSON_Delete(source); + claw_llm_http_response_free(&http_response); + claw_llm_response_free(&response); + claw_media_prepared_free(&prepared); + return err; +} + +static void anthropic_deinit(void *backend_ctx) +{ + anthropic_backend_ctx_t *ctx = (anthropic_backend_ctx_t *)backend_ctx; + + if (!ctx) { + return; + } + + free(ctx->api_key); + free(ctx->model); + free(ctx->base_url); + free(ctx); +} + +const claw_llm_backend_vtable_t *claw_llm_backend_anthropic_vtable(void) +{ + static const claw_llm_backend_vtable_t vtable = { + .id = "anthropic", + .init = anthropic_init, + .chat = anthropic_chat, + .infer_media = anthropic_infer_media, + .deinit = anthropic_deinit, + }; + + return &vtable; +} diff --git a/components/claw_modules/claw_core/src/llm/backends/claw_llm_backend_anthropic.h b/components/claw_modules/claw_core/src/llm/backends/claw_llm_backend_anthropic.h new file mode 100644 index 0000000..c48fad9 --- /dev/null +++ b/components/claw_modules/claw_core/src/llm/backends/claw_llm_backend_anthropic.h @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "llm/claw_llm_runtime.h" + +const claw_llm_backend_vtable_t *claw_llm_backend_anthropic_vtable(void); diff --git a/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.c b/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.c index 17c6ab0..57d5e04 100644 --- a/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.c +++ b/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.c @@ -224,6 +224,18 @@ esp_err_t claw_llm_http_post_json(const claw_llm_http_json_request_t *request, if (auth_header_value) { esp_http_client_set_header(client, auth_header_name(request->auth_type), auth_header_value); } + if (request->headers && request->header_count > 0) { + size_t i; + + for (i = 0; i < request->header_count; i++) { + const claw_llm_http_header_t *header = &request->headers[i]; + + if (!header->name || !header->name[0] || !header->value) { + continue; + } + esp_http_client_set_header(client, header->name, header->value); + } + } esp_http_client_set_post_field(client, request->body, (int)strlen(request->body)); err = esp_http_client_perform(client); diff --git a/components/claw_modules/claw_core/src/llm/claw_llm_runtime.c b/components/claw_modules/claw_core/src/llm/claw_llm_runtime.c index 7aa97fe..e3686bf 100644 --- a/components/claw_modules/claw_core/src/llm/claw_llm_runtime.c +++ b/components/claw_modules/claw_core/src/llm/claw_llm_runtime.c @@ -9,6 +9,7 @@ #include #include "llm/backends/claw_llm_backend_custom.h" +#include "llm/backends/claw_llm_backend_anthropic.h" #include "llm/backends/claw_llm_backend_openai_compatible.h" #define CLAW_LLM_DEFAULT_TIMEOUT_MS (120 * 1000) @@ -58,6 +59,18 @@ static const claw_llm_model_profile_t s_profiles[] = { .supports_vision = true, .image_remote_url_only = false, }, + { + .id = "anthropic", + .default_backend_type = "anthropic", + .default_base_url = "https://api.anthropic.com", + .chat_path = "/v1/messages", + .max_tokens_field = "max_tokens", + .default_timeout_ms = CLAW_LLM_DEFAULT_TIMEOUT_MS, + .default_image_max_bytes = CLAW_LLM_DEFAULT_IMAGE_MAX_BYTES, + .supports_tools = true, + .supports_vision = true, + .image_remote_url_only = false, + }, { .id = "custom_backend", .default_backend_type = "custom", @@ -80,6 +93,9 @@ static const claw_llm_backend_vtable_t *find_backend(const char *id) if (strcmp(id, "custom") == 0) { return claw_llm_backend_custom_vtable(); } + if (strcmp(id, "anthropic") == 0) { + return claw_llm_backend_anthropic_vtable(); + } return NULL; } @@ -99,6 +115,9 @@ static const char *normalize_profile_id(const char *profile_id) if (strcmp(profile_id, "openai") == 0) { return "openai"; } + if (strcmp(profile_id, "claude") == 0) { + return "anthropic"; + } return profile_id; } diff --git a/components/claw_modules/claw_core/src/llm/claw_llm_types.h b/components/claw_modules/claw_core/src/llm/claw_llm_types.h index 0fab7e7..c22900a 100644 --- a/components/claw_modules/claw_core/src/llm/claw_llm_types.h +++ b/components/claw_modules/claw_core/src/llm/claw_llm_types.h @@ -88,12 +88,19 @@ typedef struct { size_t media_count; } claw_llm_media_request_t; +typedef struct { + const char *name; + const char *value; +} claw_llm_http_header_t; + typedef struct { const char *url; const char *body; const char *api_key; const char *auth_type; uint32_t timeout_ms; + const claw_llm_http_header_t *headers; + size_t header_count; } claw_llm_http_json_request_t; typedef struct {