From 2cb94a3055d9e1f09a128e600a94a8e9b9860bba Mon Sep 17 00:00:00 2001 From: loop Date: Fri, 3 Apr 2026 14:46:39 +0800 Subject: [PATCH] feat: the initial commit --- .gitignore | 41 + .gitlab-ci.yml | 33 + .gitlab/ci/build.yml | 8 +- .gitlab/ci/build_apps.py | 6 +- .gitlab/ci/rules.yml | 149 + README.md | 235 ++ application/.build-rules.yml | 8 + application/basic_demo/CMakeLists.txt | 11 + .../fatfs_image/automation/automations.json | 97 + .../basic_demo/fatfs_image/lua/hello.lua | 1 + .../basic_demo/fatfs_image/memory/MEMORY.md | 3 + .../fatfs_image/skills/im_send_file.md | 57 + .../fatfs_image/skills/im_send_picture.md | 53 + .../basic_demo/fatfs_image/skills/lua_run.md | 46 + .../fatfs_image/skills/lua_write.md | 64 + .../fatfs_image/skills/qq_send_file.md | 48 + .../fatfs_image/skills/skills_list.json | 34 + .../basic_demo/fatfs_image/skills/weather.md | 18 + application/basic_demo/main/CMakeLists.txt | 46 + application/basic_demo/main/Kconfig.projbuild | 117 + application/basic_demo/main/app_clawgent.c | 368 +++ application/basic_demo/main/app_clawgent.h | 19 + application/basic_demo/main/basic_demo_cli.c | 682 +++++ .../basic_demo/main/basic_demo_lua_modules.c | 22 + .../basic_demo/main/basic_demo_lua_modules.h | 18 + .../basic_demo/main/basic_demo_settings.c | 193 ++ .../basic_demo/main/basic_demo_settings.h | 58 + application/basic_demo/main/basic_demo_wifi.c | 132 + application/basic_demo/main/basic_demo_wifi.h | 17 + .../basic_demo/main/config_http_server.c | 835 ++++++ .../basic_demo/main/config_http_server.h | 16 + application/basic_demo/main/idf_component.yml | 3 + .../main/lua_module/lua_module_gpio.c | 81 + .../main/lua_module/lua_module_gpio.h | 18 + .../main/lua_module/lua_module_led_strip.c | 146 + .../main/lua_module/lua_module_led_strip.h | 10 + application/basic_demo/main/main.c | 126 + application/basic_demo/main/web/app.js | 446 +++ application/basic_demo/main/web/index.html | 182 ++ application/basic_demo/main/web/styles.css | 292 ++ application/basic_demo/partitions.csv | 8 + application/basic_demo/sdkconfig.defaults | 32 + components/cap_cli/CMakeLists.txt | 11 + components/cap_cli/include/cap_cli.h | 35 + components/cap_cli/src/cap_cli.c | 426 +++ components/cap_files/CMakeLists.txt | 9 + components/cap_files/include/cap_files.h | 19 + components/cap_files/src/cap_files.c | 619 ++++ components/cap_im_attachment/CMakeLists.txt | 10 + .../include/cap_im_attachment.h | 66 + .../cap_im_attachment/src/cap_im_attachment.c | 472 +++ components/cap_im_qq/CMakeLists.txt | 18 + components/cap_im_qq/idf_component.yml | 2 + components/cap_im_qq/include/cap_im_qq.h | 36 + components/cap_im_qq/include/cmd_cap_im_qq.h | 16 + components/cap_im_qq/src/cap_im_qq.c | 1899 ++++++++++++ components/cap_im_qq/src/cmd_cap_im_qq.c | 202 ++ components/cap_im_tg/CMakeLists.txt | 16 + components/cap_im_tg/idf_component.yml | 1 + components/cap_im_tg/include/cap_im_tg.h | 36 + components/cap_im_tg/include/cmd_cap_im_tg.h | 16 + components/cap_im_tg/src/cap_im_tg.c | 1496 ++++++++++ components/cap_im_tg/src/cmd_cap_im_tg.c | 194 ++ components/cap_im_wechat/CMakeLists.txt | 16 + .../cap_im_wechat/include/cap_im_wechat.h | 64 + .../cap_im_wechat/include/cmd_cap_im_wechat.h | 16 + components/cap_im_wechat/src/cap_im_wechat.c | 2587 +++++++++++++++++ .../cap_im_wechat/src/cmd_cap_im_wechat.c | 198 ++ components/cap_llm_inspect/CMakeLists.txt | 12 + .../cap_llm_inspect/include/cap_llm_inspect.h | 18 + .../include/cmd_cap_llm_inspect.h | 16 + .../cap_llm_inspect/src/cap_llm_inspect.c | 109 + .../cap_llm_inspect/src/cmd_cap_llm_inspect.c | 93 + components/cap_lua/CMakeLists.txt | 19 + components/cap_lua/idf_component.yml | 2 + components/cap_lua/include/cap_lua.h | 50 + components/cap_lua/include/cmd_cap_lua.h | 16 + components/cap_lua/src/cap_lua.c | 912 ++++++ components/cap_lua/src/cap_lua_async.c | 387 +++ components/cap_lua/src/cap_lua_internal.h | 69 + components/cap_lua/src/cap_lua_module_delay.c | 32 + components/cap_lua/src/cap_lua_runtime.c | 256 ++ components/cap_lua/src/cap_lua_storage.c | 106 + components/cap_lua/src/cmd_cap_lua.c | 173 ++ components/cap_mcp_client/CMakeLists.txt | 16 + components/cap_mcp_client/idf_component.yml | 2 + .../cap_mcp_client/include/cap_mcp_client.h | 18 + .../include/cmd_cap_mcp_client.h | 16 + .../cap_mcp_client/src/cap_mcp_client.c | 284 ++ .../cap_mcp_client/src/cap_mcp_client_core.c | 436 +++ .../src/cap_mcp_client_internal.h | 18 + .../src/cap_mcp_discover_core.c | 187 ++ .../cap_mcp_client/src/cmd_cap_mcp_client.c | 169 ++ components/cap_mcp_server/CMakeLists.txt | 15 + components/cap_mcp_server/idf_component.yml | 3 + .../cap_mcp_server/include/cap_mcp_server.h | 29 + .../include/cmd_cap_mcp_server.h | 16 + .../cap_mcp_server/src/cap_mcp_server.c | 494 ++++ .../cap_mcp_server/src/cmd_cap_mcp_server.c | 143 + components/cap_skill/CMakeLists.txt | 12 + components/cap_skill/include/cap_skill.h | 18 + components/cap_skill/include/cmd_cap_skill.h | 16 + components/cap_skill/src/cap_skill.c | 213 ++ components/cap_skill/src/cmd_cap_skill.c | 146 + components/cap_time/CMakeLists.txt | 11 + components/cap_time/idf_component.yml | 1 + components/cap_time/include/cap_time.h | 19 + components/cap_time/include/cmd_cap_time.h | 16 + components/cap_time/src/cap_time.c | 242 ++ components/cap_time/src/cmd_cap_time.c | 88 + components/cap_web_search/CMakeLists.txt | 12 + components/cap_web_search/idf_component.yml | 1 + .../cap_web_search/include/cap_web_search.h | 20 + .../include/cmd_cap_web_search.h | 16 + .../cap_web_search/src/cap_web_search.c | 455 +++ .../cap_web_search/src/cmd_cap_web_search.c | 90 + components/claw_cap/CMakeLists.txt | 11 + components/claw_cap/include/claw_cap.h | 160 + components/claw_cap/src/claw_cap.c | 1414 +++++++++ components/claw_core/CMakeLists.txt | 18 + components/claw_core/include/claw_core.h | 126 + components/claw_core/src/claw_core.c | 1092 +++++++ components/claw_core/src/claw_core_llm.c | 180 ++ components/claw_core/src/claw_core_llm.h | 44 + .../llm/backends/claw_llm_backend_custom.c | 149 + .../llm/backends/claw_llm_backend_custom.h | 10 + .../claw_llm_backend_openai_compatible.c | 524 ++++ .../claw_llm_backend_openai_compatible.h | 10 + .../src/llm/claw_llm_http_transport.c | 264 ++ .../src/llm/claw_llm_http_transport.h | 13 + .../claw_core/src/llm/claw_llm_runtime.c | 311 ++ .../claw_core/src/llm/claw_llm_runtime.h | 53 + components/claw_core/src/llm/claw_llm_types.h | 102 + .../src/llm/media/claw_media_pipeline.c | 229 ++ .../src/llm/media/claw_media_pipeline.h | 15 + components/claw_event_router/CMakeLists.txt | 13 + .../include/claw_event_router.h | 121 + .../include/cmd_claw_event_router.h | 16 + .../claw_event_router/src/claw_event_router.c | 1987 +++++++++++++ .../src/cmd_claw_event_router.c | 196 ++ components/claw_memory/CMakeLists.txt | 8 + components/claw_memory/include/claw_memory.h | 41 + components/claw_memory/src/claw_memory.c | 772 +++++ components/claw_skill/CMakeLists.txt | 8 + components/claw_skill/include/claw_skill.h | 45 + components/claw_skill/src/claw_skill.c | 953 ++++++ schemas/automations.schema.json | 199 ++ 147 files changed, 27827 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .gitlab/ci/rules.yml create mode 100644 README.md create mode 100644 application/.build-rules.yml create mode 100644 application/basic_demo/CMakeLists.txt create mode 100644 application/basic_demo/fatfs_image/automation/automations.json create mode 100644 application/basic_demo/fatfs_image/lua/hello.lua create mode 100644 application/basic_demo/fatfs_image/memory/MEMORY.md create mode 100644 application/basic_demo/fatfs_image/skills/im_send_file.md create mode 100644 application/basic_demo/fatfs_image/skills/im_send_picture.md create mode 100644 application/basic_demo/fatfs_image/skills/lua_run.md create mode 100644 application/basic_demo/fatfs_image/skills/lua_write.md create mode 100644 application/basic_demo/fatfs_image/skills/qq_send_file.md create mode 100644 application/basic_demo/fatfs_image/skills/skills_list.json create mode 100644 application/basic_demo/fatfs_image/skills/weather.md create mode 100644 application/basic_demo/main/CMakeLists.txt create mode 100644 application/basic_demo/main/Kconfig.projbuild create mode 100644 application/basic_demo/main/app_clawgent.c create mode 100644 application/basic_demo/main/app_clawgent.h create mode 100644 application/basic_demo/main/basic_demo_cli.c create mode 100644 application/basic_demo/main/basic_demo_lua_modules.c create mode 100644 application/basic_demo/main/basic_demo_lua_modules.h create mode 100644 application/basic_demo/main/basic_demo_settings.c create mode 100644 application/basic_demo/main/basic_demo_settings.h create mode 100644 application/basic_demo/main/basic_demo_wifi.c create mode 100644 application/basic_demo/main/basic_demo_wifi.h create mode 100644 application/basic_demo/main/config_http_server.c create mode 100644 application/basic_demo/main/config_http_server.h create mode 100644 application/basic_demo/main/idf_component.yml create mode 100644 application/basic_demo/main/lua_module/lua_module_gpio.c create mode 100644 application/basic_demo/main/lua_module/lua_module_gpio.h create mode 100644 application/basic_demo/main/lua_module/lua_module_led_strip.c create mode 100644 application/basic_demo/main/lua_module/lua_module_led_strip.h create mode 100644 application/basic_demo/main/main.c create mode 100644 application/basic_demo/main/web/app.js create mode 100644 application/basic_demo/main/web/index.html create mode 100644 application/basic_demo/main/web/styles.css create mode 100644 application/basic_demo/partitions.csv create mode 100644 application/basic_demo/sdkconfig.defaults create mode 100644 components/cap_cli/CMakeLists.txt create mode 100644 components/cap_cli/include/cap_cli.h create mode 100644 components/cap_cli/src/cap_cli.c create mode 100644 components/cap_files/CMakeLists.txt create mode 100644 components/cap_files/include/cap_files.h create mode 100644 components/cap_files/src/cap_files.c create mode 100644 components/cap_im_attachment/CMakeLists.txt create mode 100644 components/cap_im_attachment/include/cap_im_attachment.h create mode 100644 components/cap_im_attachment/src/cap_im_attachment.c create mode 100644 components/cap_im_qq/CMakeLists.txt create mode 100644 components/cap_im_qq/idf_component.yml create mode 100644 components/cap_im_qq/include/cap_im_qq.h create mode 100644 components/cap_im_qq/include/cmd_cap_im_qq.h create mode 100644 components/cap_im_qq/src/cap_im_qq.c create mode 100644 components/cap_im_qq/src/cmd_cap_im_qq.c create mode 100644 components/cap_im_tg/CMakeLists.txt create mode 100644 components/cap_im_tg/idf_component.yml create mode 100644 components/cap_im_tg/include/cap_im_tg.h create mode 100644 components/cap_im_tg/include/cmd_cap_im_tg.h create mode 100644 components/cap_im_tg/src/cap_im_tg.c create mode 100644 components/cap_im_tg/src/cmd_cap_im_tg.c create mode 100644 components/cap_im_wechat/CMakeLists.txt create mode 100644 components/cap_im_wechat/include/cap_im_wechat.h create mode 100644 components/cap_im_wechat/include/cmd_cap_im_wechat.h create mode 100644 components/cap_im_wechat/src/cap_im_wechat.c create mode 100644 components/cap_im_wechat/src/cmd_cap_im_wechat.c create mode 100644 components/cap_llm_inspect/CMakeLists.txt create mode 100644 components/cap_llm_inspect/include/cap_llm_inspect.h create mode 100644 components/cap_llm_inspect/include/cmd_cap_llm_inspect.h create mode 100644 components/cap_llm_inspect/src/cap_llm_inspect.c create mode 100644 components/cap_llm_inspect/src/cmd_cap_llm_inspect.c create mode 100644 components/cap_lua/CMakeLists.txt create mode 100644 components/cap_lua/idf_component.yml create mode 100644 components/cap_lua/include/cap_lua.h create mode 100644 components/cap_lua/include/cmd_cap_lua.h create mode 100644 components/cap_lua/src/cap_lua.c create mode 100644 components/cap_lua/src/cap_lua_async.c create mode 100644 components/cap_lua/src/cap_lua_internal.h create mode 100644 components/cap_lua/src/cap_lua_module_delay.c create mode 100644 components/cap_lua/src/cap_lua_runtime.c create mode 100644 components/cap_lua/src/cap_lua_storage.c create mode 100644 components/cap_lua/src/cmd_cap_lua.c create mode 100644 components/cap_mcp_client/CMakeLists.txt create mode 100644 components/cap_mcp_client/idf_component.yml create mode 100644 components/cap_mcp_client/include/cap_mcp_client.h create mode 100644 components/cap_mcp_client/include/cmd_cap_mcp_client.h create mode 100644 components/cap_mcp_client/src/cap_mcp_client.c create mode 100644 components/cap_mcp_client/src/cap_mcp_client_core.c create mode 100644 components/cap_mcp_client/src/cap_mcp_client_internal.h create mode 100644 components/cap_mcp_client/src/cap_mcp_discover_core.c create mode 100644 components/cap_mcp_client/src/cmd_cap_mcp_client.c create mode 100644 components/cap_mcp_server/CMakeLists.txt create mode 100644 components/cap_mcp_server/idf_component.yml create mode 100644 components/cap_mcp_server/include/cap_mcp_server.h create mode 100644 components/cap_mcp_server/include/cmd_cap_mcp_server.h create mode 100644 components/cap_mcp_server/src/cap_mcp_server.c create mode 100644 components/cap_mcp_server/src/cmd_cap_mcp_server.c create mode 100644 components/cap_skill/CMakeLists.txt create mode 100644 components/cap_skill/include/cap_skill.h create mode 100644 components/cap_skill/include/cmd_cap_skill.h create mode 100644 components/cap_skill/src/cap_skill.c create mode 100644 components/cap_skill/src/cmd_cap_skill.c create mode 100644 components/cap_time/CMakeLists.txt create mode 100644 components/cap_time/idf_component.yml create mode 100644 components/cap_time/include/cap_time.h create mode 100644 components/cap_time/include/cmd_cap_time.h create mode 100644 components/cap_time/src/cap_time.c create mode 100644 components/cap_time/src/cmd_cap_time.c create mode 100644 components/cap_web_search/CMakeLists.txt create mode 100644 components/cap_web_search/idf_component.yml create mode 100644 components/cap_web_search/include/cap_web_search.h create mode 100644 components/cap_web_search/include/cmd_cap_web_search.h create mode 100644 components/cap_web_search/src/cap_web_search.c create mode 100644 components/cap_web_search/src/cmd_cap_web_search.c create mode 100644 components/claw_cap/CMakeLists.txt create mode 100644 components/claw_cap/include/claw_cap.h create mode 100644 components/claw_cap/src/claw_cap.c create mode 100644 components/claw_core/CMakeLists.txt create mode 100644 components/claw_core/include/claw_core.h create mode 100644 components/claw_core/src/claw_core.c create mode 100644 components/claw_core/src/claw_core_llm.c create mode 100644 components/claw_core/src/claw_core_llm.h create mode 100644 components/claw_core/src/llm/backends/claw_llm_backend_custom.c create mode 100644 components/claw_core/src/llm/backends/claw_llm_backend_custom.h create mode 100644 components/claw_core/src/llm/backends/claw_llm_backend_openai_compatible.c create mode 100644 components/claw_core/src/llm/backends/claw_llm_backend_openai_compatible.h create mode 100644 components/claw_core/src/llm/claw_llm_http_transport.c create mode 100644 components/claw_core/src/llm/claw_llm_http_transport.h create mode 100644 components/claw_core/src/llm/claw_llm_runtime.c create mode 100644 components/claw_core/src/llm/claw_llm_runtime.h create mode 100644 components/claw_core/src/llm/claw_llm_types.h create mode 100644 components/claw_core/src/llm/media/claw_media_pipeline.c create mode 100644 components/claw_core/src/llm/media/claw_media_pipeline.h create mode 100644 components/claw_event_router/CMakeLists.txt create mode 100644 components/claw_event_router/include/claw_event_router.h create mode 100644 components/claw_event_router/include/cmd_claw_event_router.h create mode 100644 components/claw_event_router/src/claw_event_router.c create mode 100644 components/claw_event_router/src/cmd_claw_event_router.c create mode 100644 components/claw_memory/CMakeLists.txt create mode 100644 components/claw_memory/include/claw_memory.h create mode 100644 components/claw_memory/src/claw_memory.c create mode 100644 components/claw_skill/CMakeLists.txt create mode 100644 components/claw_skill/include/claw_skill.h create mode 100644 components/claw_skill/src/claw_skill.c create mode 100644 schemas/automations.schema.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b21fbde --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Build output +build/ +dist/ +tmp/ +releases/ + +# ESP-IDF +sdkconfig +sdkconfig.old +dependencies.lock +managed_components/ + +# IDE / Editor +.vscode/ +.devcontainer/ +.idea/ +.clangd +*.swp +*.swo +*~ + +# Environment +.env +# Cache / Compiled +.cache +*.pyc +*.bin + +# MCP / Memov +.mcp.json +.mem/ +.memignore +# OS +.DS_Store +Thumbs.db +references/ +.venv/ + +.codex +openspec +AGENTS.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..f20e3bb --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,33 @@ +spec: + inputs: + force_push_to_github: + type: boolean + default: false + +--- + +stages: + - build + +workflow: + rules: + # Disable those non-protected push triggered pipelines + - if: '$CI_COMMIT_REF_NAME != "master" && $CI_COMMIT_BRANCH !~ /^release\/v/ && $CI_COMMIT_TAG !~ /^v\d+\.\d+(\.\d+)?($|-)/ && $CI_PIPELINE_SOURCE == "push"' + when: never + # when running merged result pipelines, it would create a temp commit id. use $CI_MERGE_REQUEST_SOURCE_BRANCH_SHA instead of $CI_COMMIT_SHA. + # Please use PIPELINE_COMMIT_SHA at all places that require a commit sha + - if: $CI_OPEN_MERGE_REQUESTS != null + variables: + PIPELINE_COMMIT_SHA: $CI_MERGE_REQUEST_SOURCE_BRANCH_SHA + - if: $CI_OPEN_MERGE_REQUESTS == null + variables: + PIPELINE_COMMIT_SHA: $CI_COMMIT_SHA + - when: always + +variables: + IOT_SOLUTION_PATH: "$CI_PROJECT_DIR" + CI_FORCE_PUSH_TO_GITHUB: "$[[ inputs.force_push_to_github ]]" + +include: + - '.gitlab/ci/rules.yml' + - '.gitlab/ci/build.yml' diff --git a/.gitlab/ci/build.yml b/.gitlab/ci/build.yml index b03b0d6..7b02f15 100644 --- a/.gitlab/ci/build.yml +++ b/.gitlab/ci/build.yml @@ -44,7 +44,7 @@ script: - pip install idf_build_apps - | - python build_apps.py ${EXAMPLE_DIR} \ + python .gitlab/ci/build_apps.py ${EXAMPLE_DIR} \ --config ${EXAMPLE_CONFIG} \ -t all \ -r ${BUILD_RECURSIVE} \ @@ -77,10 +77,10 @@ - IMAGE: espressif/idf:release-v5.5 - IMAGE: espressif/idf:release-v6.0 -build_example_ai_esp_dl_human_activity_recognition: +build_application_basic_demo: extends: - .build_examples_template - - .rules:build:example_ai_esp_dl_human_activity_recognition + - .rules:build:application_basic_demo - .build_idf_active_release_version variables: - EXAMPLE_DIR: examples/ai/esp_dl/human_activity_recognition + EXAMPLE_DIR: application/basic_demo diff --git a/.gitlab/ci/build_apps.py b/.gitlab/ci/build_apps.py index 9a9f299..e11964e 100644 --- a/.gitlab/ci/build_apps.py +++ b/.gitlab/ci/build_apps.py @@ -35,7 +35,7 @@ logger = logging.getLogger('idf_build_apps') IDF_PATH = os.getenv('IDF_PATH', '') BOARD_NAME = 'default' -PROJECT_ROOT = Path(__file__).parent.parent.absolute() +PROJECT_ROOT = Path(__file__).parent.parent.parent.absolute() APPS_BUILD_PER_JOB = 30 IGNORE_WARNINGS = [ r'memory region \`iram_loader_seg\' not declared', @@ -255,9 +255,7 @@ def get_cmake_apps( no_preserve=False, default_build_targets=default_build_targets, manifest_files=[ - str(Path(PROJECT_ROOT) /'components'/'.build-rules.yml'), - str(Path(PROJECT_ROOT) /'examples'/'.build-rules.yml'), - str(Path(PROJECT_ROOT) /'tools'/'.build-rules.yml'), + str(Path(PROJECT_ROOT) /'application'/'.build-rules.yml'), ], build_system=CustomApp, ) diff --git a/.gitlab/ci/rules.yml b/.gitlab/ci/rules.yml new file mode 100644 index 0000000..515a5d5 --- /dev/null +++ b/.gitlab/ci/rules.yml @@ -0,0 +1,149 @@ +############ +# Patterns # +############ + +# build system, if changed, build all apps +.patterns-build_system: &patterns-build_system + # For test + # - "tools/build_apps.py" + - "conftest.py" + - "pytest.ini" + +# components folder, in the alphabetic order +.patterns-components_cap_cli: &patterns-components_cap_cli + - "components/cap_cli/**/*" + +.patterns-components_cap_files: &patterns-components_cap_files + - "components/cap_files/**/*" + +.patterns-components_cap_im_attachment: &patterns-components_cap_im_attachment + - "components/cap_im_attachment/**/*" + +.patterns-components_cap_im_qq: &patterns-components_cap_im_qq + - "components/cap_im_qq/**/*" + +.patterns-components_cap_im_tg: &patterns-components_cap_im_tg + - "components/cap_im_tg/**/*" + +.patterns-components_cap_im_wechat: &patterns-components_cap_im_wechat + - "components/cap_im_wechat/**/*" + +.patterns-components_cap_llm_inspect: &patterns-components_cap_llm_inspect + - "components/cap_llm_inspect/**/*" + +.patterns-components_cap_lua: &patterns-components_cap_lua + - "components/cap_lua/**/*" + +.patterns-components_cap_mcp_client: &patterns-components_cap_mcp_client + - "components/cap_mcp_client/**/*" + +.patterns-components_cap_mcp_server: &patterns-components_cap_mcp_server + - "components/cap_mcp_server/**/*" + +.patterns-components_cap_skill: &patterns-components_cap_skill + - "components/cap_skill/**/*" + +.patterns-components_cap_time: &patterns-components_cap_time + - "components/cap_time/**/*" + +.patterns-components_cap_web_search: &patterns-components_cap_web_search + - "components/cap_web_search/**/*" + +.patterns-components_claw_cap: &patterns-components_claw_cap + - "components/claw_cap/**/*" + +.patterns-components_claw_core: &patterns-components_claw_core + - "components/claw_core/**/*" + +.patterns-components_claw_event_router: &patterns-components_claw_event_router + - "components/claw_event_router/**/*" + +.patterns-components_claw_memory: &patterns-components_claw_memory + - "components/claw_memory/**/*" + +.patterns-components_claw_skill: &patterns-components_claw_skill + - "components/claw_skill/**/*" + +# application folder, in the alphabetic order +.patterns-application_basic_demo: &patterns-application_basic_demo + - "application/basic_demo/**/*" + +.patterns-yml: &patterns-yml + - "**/*.yml" + +############## +# if anchors # +############## +.if-protected: &if-protected + if: '($CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_BRANCH =~ /^release\/v/ || $CI_COMMIT_TAG =~ /^v\d+\.\d+(\.\d+)?($|-)/)' + +.if-dev-push: &if-dev-push + if: '$CI_COMMIT_REF_NAME != "master" && $CI_COMMIT_BRANCH !~ /^release\/v/ && $CI_COMMIT_TAG !~ /^v\d+\.\d+(\.\d+)?($|-)/ && ($CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "merge_request_event")' + +################## +# Auto Generated # +################## +.if-trigger-job: &if-trigger-job + if: "$BOT_DYNAMIC_TEST_JOBS && $BOT_DYNAMIC_TEST_JOBS =~ $CI_JOB_NAME" + +.if-label-build: &if-label-build + if: '$BOT_LABEL_BUILD || $CI_MERGE_REQUEST_LABELS =~ /^(?:[^,\n\r]+,)*build(?:,[^,\n\r]+)*$/i' + +.if-label-target_test: &if-label-target_test + if: '$BOT_LABEL_TARGET_TEST || $CI_MERGE_REQUEST_LABELS =~ /^(?:[^,\n\r]+,)*target_test(?:,[^,\n\r]+)*$/i' + +.if-label-build_docs: &if-label-build_docs + if: '$BOT_LABEL_BUILD_DOCS || $CI_MERGE_REQUEST_LABELS =~ /^(?:[^,\n\r]+,)*build_docs(?:,[^,\n\r]+)*$/i' + +.if-label-pre_check: &if-label-pre_check + if: '$BOT_LABEL_PRE_CHECK || $CI_MERGE_REQUEST_LABELS =~ /^(?:[^,\n\r]+,)*pre_check(?:,[^,\n\r]+)*$/i' + +.if_label-deploy: &if-label-deploy + if: '$BOT_LABEL_DEPLOY || $CI_MERGE_REQUEST_LABELS =~ /^(?:[^,\n\r]+,)*deploy(?:,[^,\n\r]+)*$/i' + +# rules for applications +.rules:build:application_basic_demo: + rules: + - <<: *if-protected + - <<: *if-label-build + - <<: *if-trigger-job + - <<: *if-dev-push + changes: *patterns-build_system + - <<: *if-dev-push + changes: *patterns-components_cap_cli + - <<: *if-dev-push + changes: *patterns-components_cap_files + - <<: *if-dev-push + changes: *patterns-components_cap_im_attachment + - <<: *if-dev-push + changes: *patterns-components_cap_im_qq + - <<: *if-dev-push + changes: *patterns-components_cap_im_tg + - <<: *if-dev-push + changes: *patterns-components_cap_im_wechat + - <<: *if-dev-push + changes: *patterns-components_cap_llm_inspect + - <<: *if-dev-push + changes: *patterns-components_cap_lua + - <<: *if-dev-push + changes: *patterns-components_cap_mcp_client + - <<: *if-dev-push + changes: *patterns-components_cap_mcp_server + - <<: *if-dev-push + changes: *patterns-components_cap_skill + - <<: *if-dev-push + changes: *patterns-components_cap_time + - <<: *if-dev-push + changes: *patterns-components_cap_web_search + - <<: *if-dev-push + changes: *patterns-components_claw_cap + - <<: *if-dev-push + changes: *patterns-components_claw_core + - <<: *if-dev-push + changes: *patterns-components_claw_event_router + - <<: *if-dev-push + changes: *patterns-components_claw_memory + - <<: *if-dev-push + changes: *patterns-components_claw_skill + - <<: *if-dev-push + changes: *patterns-application_basic_demo diff --git a/README.md b/README.md new file mode 100644 index 0000000..b30e3f4 --- /dev/null +++ b/README.md @@ -0,0 +1,235 @@ +# ESP-Clawgent + +**事件驱动的 OpenClaw,为嵌入式场景量身定制。** + +`ESP-Clawgent` 是一套运行于 ESP32 上的事件驱动 AI assistant,适用于需要长期运行、可持续扩展、功能持续迭代的设备侧 AI 场景 + +- 事件驱动:不局限于消息输入,多种外部事件都可以触发 Agent Loop +- 组件化:可按需裁切模块 +- 离线自动化:在无网络条件下执行本地规则 +- 自编程:内嵌 Lua 解释器,可由 AI 自主规划实现功能 +- 渐进式工具披露:当前 agent 能做什么,由已加载和已激活的 skills 决定 +- 超低资源占用:ESP32-C 系列上也能流畅运行 + +## 1. What is ESP-Clawgent + +项目包含示例与基础功能组件。 + +- `application/basic_demo/`:当前主应用,用于将这些模块装配成可运行固件 + +`components/` 包含以下核心部分: + +- `claw_core`:负责完整的 agent 执行流 +- `claw_cap`:负责能力注册、调度和工具调用 +- `claw_memory`:负责会话历史和长期记忆 +- `claw_skill`:负责技能加载、技能文档和激活态技能上下文 +- `claw_event_router`:负责标准事件接入、规则路由、Agent/脚本分发和出站路由 +- `cap_*`:负责拓展具体能力,比如 QQ、Telegram、文件、时间、Web Search、MCP、Lua 等 + +## 2. How it works + +主启动入口位于 `application/basic_demo/main/main.c`。 + +设备启动后,整体流程如下: + +1. 初始化 NVS,加载设备配置 +2. 挂载 FATFS 到 `/fatfs/data` +3. 初始化 Wi-Fi 和本地 HTTP 配置服务 +4. 进入 `app_clawgent_start()` +5. 初始化 event router、memory、skills、capabilities +6. 初始化并启动 `claw_core` +7. 启动 CLI,开始响应请求和事件 + +当前运行时依赖以下本地目录: + +- `/fatfs/data/sessions`:会话历史 +- `/fatfs/data/memory/MEMORY.md`:长期记忆 +- `/fatfs/data/skills`:skills 文档和清单 +- `/fatfs/data/lua`:Lua 脚本 +- `/fatfs/data/automation/automations.json`:自动化规则 +- `/fatfs/data/inbox`:消息附件存储目录 + +### Event-driven + +`ESP-Clawgent` 的核心是“收到事件,触发行为”。 + +事件可以来自: + +- 即时通讯入口 +- 配置界面 +- 本地事件规则 +- 文件或附件输入 +- 后续扩展的 cap 事件源 + +行为可以是: + +- 调模型 +- 调工具 +- 读写本地文件 +- 执行 Lua +- 触发事件路由链路 +- 给外部 IM 回消息 + +### Progressive tool exposure + +工具不会默认一次性全部暴露给模型。 + +`claw_core` 在运行时会接入这些上下文提供器: + +- 长期记忆 +- 会话历史 +- skills list +- 已激活 skill 的文档 +- 当前 cap 工具描述 + +初始情况下,仅暴露由 `skills_list.json` 声明的基础能力。 + +- skill 没加载,模型看不到对应能力的说明 +- skill 没激活,模型拿不到对应文档上下文 +- 当前会话能做什么,是逐步展开的 + + +### Self-programming with Lua + +项目内嵌 Lua 解释器,支持 Lua 脚本编辑与运行。 + +这使得以下能力成为可能: + +- 把设备逻辑写成 Lua +- 把某些 agent 行为抽成脚本 +- 让 assistant 在现有能力之上继续组合出新的功能 + +许多扩展可以先在 Lua 层完成,无需重新烧录固件。只要向 LLM 提出具体需求,系统即可直接生成并实现对应功能。 + +## 3. Project architecture + +仓库结构如下: + +```text +esp-clawgent-master/ +├── components/ +│ ├── claw_core/ +│ ├── claw_cap/ +│ ├── claw_event_router/ +│ ├── claw_memory/ +│ ├── claw_skill/ +│ └── cap_*/ +└── application/ + └── basic_demo/ + ├── main/ + └── build/ +``` + +### Runtime layers + +- Application layer + 负责启动、配置、文件系统、Wi-Fi、HTTP 配置页和整机装配 +- Core layer + `claw_core` 负责 agent 执行流 +- cap layer + `claw_cap` 和各类 `cap_*` 负责工具和具体能力实现 +- Event routing layer + `claw_event_router` 负责统一事件入口、规则匹配、脚本/agent 调度和出站分发 +- Memory layer + `claw_memory` 负责持久化上下文 +- Skill layer + `claw_skill` 负责按 skill 控制上下文与功能暴露 + +### Current capabilities + +当前 `basic_demo` 已接入的能力包括: + +- `cap_im_qq` +- `cap_im_tg` +- `cap_files` +- `cap_lua` +- `cap_mcp_client` +- `cap_mcp_server` +- `cap_skill` +- `cap_time` +- `cap_llm_inspect` +- `cap_web_search` + +### Design style + +项目的架构关键词包括: + +- event-driven +- componentized +- local-first + +联网并不是系统运行的唯一前提。无网络时,本地事件路由、Lua、文件系统和既有记忆仍可继续工作。 + +## 4. Quick Start + +### Prerequisites + +- ESP-IDF 环境已安装并导出 +- 建议使用 `ESP-IDF v5.5.1` +- 默认目标芯片为 `esp32s3` + +```bash +. /esp-idf/export.sh +``` + +### Build + +所有 ESP-IDF 命令都在 `application/basic_demo/` 下执行: + +```bash +cd application/basic_demo +idf.py set-target esp32s3 +idf.py build +``` + +### Configure + +当前 Demo 的关键配置包括: + +- Wi-Fi SSID / Password +- LLM API Key / Provider / Model +- QQ App ID / App Secret +- Telegram Bot Token +- Brave / Tavily Search Key +- Timezone + +可以通过 `menuconfig` 调整编译期默认值: + +```bash +cd application/basic_demo +idf.py menuconfig +``` + +设备运行后,配置也会通过 NVS 持久化。 + +### Notes on Keys + +- IM bot token:可通过 Telegram 的 [@BotFather](https://t.me/BotFather) 或 [QQ Bot](https://q.qq.com/qqbot/openclaw/login.html) 获取 +- LLM API key:可使用 [Anthropic Console](https://console.anthropic.com)、[OpenAI Platform](https://platform.openai.com) 或 [阿里云百炼](https://bailian.console.aliyun.com/#/api-key) 提供的 Key + + +### Flash + +```bash +cd application/basic_demo +idf.py flash monitor +``` + +如果串口不是默认值: + +```bash +cd application/basic_demo +idf.py flash monitor -p /dev/ttyUSB0 +``` + +### First boot + +首次启动后,通常会看到以下阶段: + +- NVS 初始化 +- FATFS 挂载 +- 设置加载 +- Wi-Fi 和本地配置服务启动 +- memory / skills / capabilities 初始化 +- `claw_core` 启动 +- CLI 启动 diff --git a/application/.build-rules.yml b/application/.build-rules.yml new file mode 100644 index 0000000..2510c00 --- /dev/null +++ b/application/.build-rules.yml @@ -0,0 +1,8 @@ +# Note: All operators are binary operators. For more than two operands, you may use the nested parentheses trick. +# For example: +# * A == 1 or (B == 2 and C in [1,2,3]) +# * (A == 1 and B == 2) or (C not in ["3", "4", 5]) + +application/basic_demo: + enable: + - if: INCLUDE_DEFAULT == 1 diff --git a/application/basic_demo/CMakeLists.txt b/application/basic_demo/CMakeLists.txt new file mode 100644 index 0000000..fab6c65 --- /dev/null +++ b/application/basic_demo/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "../../components") +if(NOT DEFINED IDF_TARGET) + set(IDF_TARGET "esp32s3" CACHE STRING "ESP-IDF target") +endif() + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(basic_demo) + +fatfs_create_spiflash_image(storage fatfs_image FLASH_IN_PROJECT) diff --git a/application/basic_demo/fatfs_image/automation/automations.json b/application/basic_demo/fatfs_image/automation/automations.json new file mode 100644 index 0000000..76b3fc2 --- /dev/null +++ b/application/basic_demo/fatfs_image/automation/automations.json @@ -0,0 +1,97 @@ +[ + { + "id": "im_hello_clawgent", + "description": "Intercept IM hello! clawgent and reply directly.", + "enabled": true, + "ack": "{{event.source_channel}} hello intercepted", + "match": { + "event_type": "message", + "event_key": "text", + "content_type": "text", + "text": "hello! clawgent" + }, + "actions": [ + { + "type": "send_message", + "input": { + "channel": "{{event.source_channel}}", + "chat_id": "{{event.chat_id}}", + "message": "Nice to meet you!" + } + } + ] + }, + { + "id": "im_attachment_saved_reply", + "description": "Reply when an IM attachment is saved.", + "enabled": true, + "ack": "{{event.source_channel}} attachment saved", + "match": { + "event_type": "attachment_saved" + }, + "actions": [ + { + "type": "send_message", + "input": { + "channel": "{{event.source_channel}}", + "chat_id": "{{event.chat_id}}", + "message": "File received from {{event.source_channel}}" + } + } + ] + }, + { + "id": "mcp_state_report_to_script", + "description": "Route MCP state reports into Lua for deterministic handling.", + "enabled": true, + "ack": "mcp state report scripted", + "match": { + "event_type": "mcp_device_state_report", + "source_cap": "mcp_server", + "source_channel": "mcp" + }, + "actions": [ + { + "type": "run_script", + "input": { + "path": "/fatfs/data/lua/hello.lua", + "async": false, + "event_type": "{{event.event_type}}", + "source_channel": "{{event.source_channel}}", + "text": "{{event.text}}", + "payload_json": "{{event.payload_json}}", + "chat_id": "{{event.chat_id}}" + } + } + ] + }, + { + "id": "im_any_message_agent", + "description": "Route IM text messages to the agent.", + "enabled": true, + "consume_on_match": true, + "ack": "{{event.source_channel}} routed to agent", + "match": { + "event_type": "message", + "event_key": "text", + "content_type": "text" + }, + "actions": [ + { + "type": "run_agent", + "input": { + "target_channel": "{{event.source_channel}}", + "session_policy": "chat" + } + }, + { + "type": "send_message", + "input": { + "channel": "{{event.source_channel}}", + "chat_id": "{{event.chat_id}}", + "message": "{{last.output}}" + } + } + ] + } +] diff --git a/application/basic_demo/fatfs_image/lua/hello.lua b/application/basic_demo/fatfs_image/lua/hello.lua new file mode 100644 index 0000000..1c98aa9 --- /dev/null +++ b/application/basic_demo/fatfs_image/lua/hello.lua @@ -0,0 +1 @@ +print("hello lua!") diff --git a/application/basic_demo/fatfs_image/memory/MEMORY.md b/application/basic_demo/fatfs_image/memory/MEMORY.md new file mode 100644 index 0000000..e3a37de --- /dev/null +++ b/application/basic_demo/fatfs_image/memory/MEMORY.md @@ -0,0 +1,3 @@ +# Long-term Memory + +(empty - Clawgent will write memories here as it learns) diff --git a/application/basic_demo/fatfs_image/skills/im_send_file.md b/application/basic_demo/fatfs_image/skills/im_send_file.md new file mode 100644 index 0000000..c90cbb3 --- /dev/null +++ b/application/basic_demo/fatfs_image/skills/im_send_file.md @@ -0,0 +1,57 @@ +# IM File Return + +Use this skill when the user wants the device to send an existing local non-image file back to a chat. + +## 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`. + +## Available tools +- `list_dir`: inspect device storage and confirm the file path +- `read_file`: inspect small text files before sending when needed +- `cap_cli`: run `cap call ''` to invoke the transport-specific send capability indirectly + +## WeChat status +- This firmware variant exposes WeChat text and image send, but does not expose `wechat_send_file`. +- If the user asks to send a generic non-image file to WeChat, explain that the current WeChat path only supports text and image send. +- If the requested asset is actually an image, switch to the `im_send_picture` skill and use `wechat_send_image` through `cap_cli`. + +## Path guidance +- Prefer real local paths that already exist on the device. +- Common roots in this demo are `/spiffs`, `/spiffs/lua`, `/spiffs/qq`, `/spiffs/tg`, or application-managed subdirectories. +- Use `list_dir` first if the exact path is unknown. +- Use `read_file` only for small text inspection, not for binary payloads. + +## 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 ''`. +- 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. +- 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`. + +## Examples + +Send a file to a Telegram chat through `cap_cli`: +```text +cap call tg_send_file '{"chat_id":"123456789","path":"/spiffs/reports/status.json","caption":"Latest status report."}' +``` + +Send a file to a QQ group through `cap_cli`: +```text +cap call qq_send_file '{"chat_id":"group1234567890","path":"/spiffs/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. +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. + +## Notes +- This skill only sends files that already exist on the device filesystem. +- Do not use this skill for `.jpg`, `.jpeg`, `.png`, `.gif`, or `.webp`; use the im_send_picture skill instead. +- If the user wants WeChat delivery and the asset is an image, use `wechat_send_image` through the picture skill instead. diff --git a/application/basic_demo/fatfs_image/skills/im_send_picture.md b/application/basic_demo/fatfs_image/skills/im_send_picture.md new file mode 100644 index 0000000..f1e8630 --- /dev/null +++ b/application/basic_demo/fatfs_image/skills/im_send_picture.md @@ -0,0 +1,53 @@ +# IM Picture Return + +Use this skill when the user wants the device to send an existing local image back to a chat. + +## 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`. + +## Available tools +- `list_dir`: inspect device storage and confirm the image path +- `cap_cli`: run `cap call ''` to invoke the transport-specific send capability indirectly + +## Path guidance +- Prefer real local paths that already exist on the device. +- Common roots in this demo are `/spiffs`, `/spiffs/qq`, `/spiffs/tg`, `/spiffs/wechat`, `/spiffs/captures`, or application-managed subdirectories. +- Use `list_dir` first if the exact path is unknown. + +## 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 ''`. +- 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. +- 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. + +## Examples + +Send an image to a Telegram chat through `cap_cli`: +```text +cap call tg_send_image '{"chat_id":"123456","path":"/spiffs/captures/latest.jpg","caption":"Here is the image."}' +``` + +Send an image to a QQ chat through `cap_cli`: +```text +cap call qq_send_image '{"chat_id":"group123","path":"/spiffs/qq/capture.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."}' +``` + +## 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`. +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. + +## Notes +- This skill only sends images that already exist on the device filesystem. +- If the user wants to send `.txt`, `.json`, `.log`, `.csv`, or archives, use the im_send_file skill instead. +- In this firmware variant, WeChat supports text and image send, but generic non-image file send is not exposed through the current capability surface. diff --git a/application/basic_demo/fatfs_image/skills/lua_run.md b/application/basic_demo/fatfs_image/skills/lua_run.md new file mode 100644 index 0000000..e147b25 --- /dev/null +++ b/application/basic_demo/fatfs_image/skills/lua_run.md @@ -0,0 +1,46 @@ +# Lua Script Execution + +Use this skill when the user wants to see existing Lua scripts, run one, or inspect async execution jobs. + +## Current Managed Scripts +At the moment the image includes: +- `hello.lua` + +Current script content summary: +- `hello.lua`: prints `hello lua!` + +## Listing Scripts +Use `lua_list_scripts` to inspect the current managed script set. +- Optional input: `prefix` +- Example: list everything with `{}` or filter a subdirectory with `{"prefix":"effects"}` + +## Running a Script Synchronously +Use `lua_run_script` when the user wants immediate output. +- Required: `path` +- Optional: `args`, `timeout_ms` +- Prefer relative paths such as `hello.lua` + +Example: +```json +{ + "path": "hello.lua" +} +``` + +If the script expects structured inputs, pass them through `args`. The runtime exposes them to Lua as the global `args`. + +## Running a Script Asynchronously +Use `lua_run_script_async` for long-running or continuous scripts. +- Required: `path` +- Optional: `args`, `timeout_ms` +- Returns a `job_id` + +After starting an async script: +- Use `lua_list_async_jobs` to see all jobs or filter by status +- Use `lua_get_async_job` with the returned `job_id` to inspect one job + +## Execution Notes +- Paths must resolve under `/spiffs/lua` and end with `.lua`. +- Prefer `lua_run_script` for short scripts that should finish and return text. +- Prefer `lua_run_script_async` for loops, animations, watchers, or long-running device behaviors. +- If the user asks to run a script that does not exist yet, switch to the Lua authoring flow first. diff --git a/application/basic_demo/fatfs_image/skills/lua_write.md b/application/basic_demo/fatfs_image/skills/lua_write.md new file mode 100644 index 0000000..1dd964d --- /dev/null +++ b/application/basic_demo/fatfs_image/skills/lua_write.md @@ -0,0 +1,64 @@ +# Lua Script Authoring + +Use this skill when the user wants to write, generate, or modify a managed Lua script for this device. + +## Runtime Constraints +- Managed Lua scripts must stay under `/spiffs/lua`. +- The script path must end with `.lua`. +- Prefer relative paths such as `blink.lua` or `rainbow.lua`; the runtime resolves them under `/spiffs/lua`. +- Use `lua_write_script` to save or overwrite script content. + +## Available Lua Modules +The runtime includes these built-in and application-registered modules: + +### `delay` +- `delay.delay_ms(ms)` +- Use for short blocking delays inside a script. + +### `storage` +- `storage.mkdir(path)` +- `storage.write_file(path, content)` +- `storage.read_file(path)` +- Use only for files the script needs to manage. + +### `gpio` +- `gpio.set_direction(pin, mode)` +- `gpio.set_level(pin, level)` +- `gpio.get_level(pin)` +- Supported modes: `input`, `output`, `input_output`, `output_od`, `input_output_od`, `disable` + +### `led_strip` +- `local strip = led_strip.new(gpio_pin, max_leds)` +- `strip:set_pixel(index, r, g, b)` +- `strip:refresh()` +- `strip:clear()` +- `strip:close()` +- This is for WS2812-style LED strips on a GPIO pin. + +## Writing Guidance +- Write plain Lua script files, not markdown or pseudocode. +- Keep dependencies limited to standard Lua plus the modules listed above. +- Prefer small scripts with a clear entry flow and explicit comments for pin usage. +- If the script touches GPIO or LED hardware, state the pin numbers and expected electrical behavior in comments. +- If a requested peripheral is not covered by `gpio` or `led_strip`, say that the current runtime does not expose that peripheral module. + +## Example Shape +```lua +local gpio = require("gpio") +local delay = require("delay") + +gpio.set_direction(2, "output") + +while true do + gpio.set_level(2, 1) + delay.delay_ms(500) + gpio.set_level(2, 0) + delay.delay_ms(500) +end +``` + +## Save Rule +When the script is ready, call `lua_write_script` with: +- `path`: relative `.lua` path under `/spiffs/lua` +- `content`: full Lua source +- `overwrite`: `true` only when replacing an existing script intentionally diff --git a/application/basic_demo/fatfs_image/skills/qq_send_file.md b/application/basic_demo/fatfs_image/skills/qq_send_file.md new file mode 100644 index 0000000..0459611 --- /dev/null +++ b/application/basic_demo/fatfs_image/skills/qq_send_file.md @@ -0,0 +1,48 @@ +# QQ File Return + +Use this skill when the user wants the device to send a local file or image back to a QQ chat. + +## When to use +- The user asks to send back a file, attachment, image, photo, log, or generated output through QQ. +- The target conversation is already the active QQ chat, or the user provides an explicit QQ `chat_id`. + +## Available tools +- `list_dir`: inspect device storage and confirm the file path +- `read_file`: inspect small text files before sending when needed +- `cap_cli`: run `cap call qq_send_image ''` or `cap call qq_send_file ''` + +## Path guidance +- Prefer real local paths already stored on the device. +- Common roots in this demo are `/spiffs`, `/spiffs/qq`, `/spiffs/lua`, or other application-managed storage paths. +- Use `list_dir` first if the exact file path is unknown. +- Use `read_file` only for small text inspection, not for binary payloads. + +## Sending rules +- Use `qq_send_image` for image files such as `.jpg`, `.jpeg`, `.png`, `.gif`, or `.webp`. +- Use `qq_send_file` for non-image files such as `.txt`, `.json`, `.log`, `.csv`, or archives. +- Execute the chosen QQ capability through `cap_cli` as `cap call ''`. +- Pass `caption` only when the user wants an accompanying message. +- The JSON payload should include an explicit QQ `chat_id`, `path`, and optional `caption`. +- The second argument of `cap call` must be one complete JSON string. Do not rewrite it as `--chat_id` flags or `key=value`. + +## Examples + +Send an image to a QQ chat through `cap_cli`: +```text +cap call qq_send_image '{"chat_id":"group123","path":"/spiffs/qq/capture.jpg","caption":"Here is the image."}' +``` + +Send a file to a QQ group through `cap_cli`: +```text +cap call qq_send_file '{"chat_id":"group1234567890","path":"/spiffs/reports/status.json","caption":"Latest status report."}' +``` + +## Workflow +1. Confirm the target file exists with `list_dir` if needed. +2. Choose `qq_send_image` or `qq_send_file` based on file type. +3. Execute the QQ capability through `cap_cli` with `cap call ''`. +4. Tell the user whether the send succeeded. + +## Notes +- This skill only sends files that already exist on the device filesystem. +- QQ generic file delivery may depend on platform-side enablement. If `qq_send_file` fails, prefer falling back to `qq_send_image` for images or explain that QQ rejected generic file upload. diff --git a/application/basic_demo/fatfs_image/skills/skills_list.json b/application/basic_demo/fatfs_image/skills/skills_list.json new file mode 100644 index 0000000..37359fc --- /dev/null +++ b/application/basic_demo/fatfs_image/skills/skills_list.json @@ -0,0 +1,34 @@ +{ + "skills": [ + { + "id": "weather", + "file": "weather.md", + "title": "Weather", + "summary": "Get current weather and forecast information with web search." + }, + { + "id": "lua_write", + "file": "lua_write.md", + "title": "Lua Script Authoring", + "summary": "Write managed Lua scripts with the correct runtime modules, path rules, and syntax constraints." + }, + { + "id": "lua_run", + "file": "lua_run.md", + "title": "Lua Script Execution", + "summary": "List and run managed Lua scripts, including async execution and job inspection." + }, + { + "id": "im_send_file", + "file": "im_send_file.md", + "title": "IM Send File", + "summary": "Send an existing local non-image file to a Telegram or QQ chat." + }, + { + "id": "im_send_picture", + "file": "im_send_picture.md", + "title": "IM Send Picture", + "summary": "Send an existing local image to a Telegram or QQ chat." + } + ] +} diff --git a/application/basic_demo/fatfs_image/skills/weather.md b/application/basic_demo/fatfs_image/skills/weather.md new file mode 100644 index 0000000..8e217e5 --- /dev/null +++ b/application/basic_demo/fatfs_image/skills/weather.md @@ -0,0 +1,18 @@ +# Weather 天气 + +Get current weather and forecasts through `cap_cli`. Support weather, forecast, temperature, 天气, 预报, 温度 queries. + +## When to use +When the user asks about weather, temperature, forecasts, 天气, 温度, or 预报. + +## How to use +1. Use `cap_cli` to run `time --now` and learn the current date +2. Use `cap_cli` to run `web_search --query "weather in [city] today"` +3. Extract temperature, conditions, and forecast from results +4. Present in a concise, friendly format + +## Example +User: "What's the weather in Tokyo?" +-> cap_cli: `time --now` +-> cap_cli: `web_search --query "weather Tokyo today February 2026"` +-> "Tokyo: 8C, partly cloudy. High 12C, low 4C. Light wind from the north." diff --git a/application/basic_demo/main/CMakeLists.txt b/application/basic_demo/main/CMakeLists.txt new file mode 100644 index 0000000..d3b57f1 --- /dev/null +++ b/application/basic_demo/main/CMakeLists.txt @@ -0,0 +1,46 @@ +idf_component_register( + SRCS + "app_clawgent.c" + "main.c" + "basic_demo_cli.c" + "basic_demo_lua_modules.c" + "basic_demo_settings.c" + "basic_demo_wifi.c" + "config_http_server.c" + "lua_module/lua_module_gpio.c" + "lua_module/lua_module_led_strip.c" + INCLUDE_DIRS + "." + REQUIRES + cap_cli + cap_files + cap_im_qq + cap_im_tg + cap_im_wechat + cap_llm_inspect + cap_lua + cap_mcp_client + cap_mcp_server + cap_skill + cap_time + cap_web_search + claw_event_router + claw_cap + claw_core + claw_memory + claw_skill + console + driver + esp_event + esp_http_server + esp_netif + esp_wifi + fatfs + json + nvs_flash + wear_levelling + EMBED_TXTFILES + "web/index.html" + "web/styles.css" + "web/app.js" +) diff --git a/application/basic_demo/main/Kconfig.projbuild b/application/basic_demo/main/Kconfig.projbuild new file mode 100644 index 0000000..d2a9d60 --- /dev/null +++ b/application/basic_demo/main/Kconfig.projbuild @@ -0,0 +1,117 @@ +menu "Basic Demo Config" + +config BASIC_DEMO_WIFI_SSID + string "Default Wi-Fi SSID" + default "" + help + Default Wi-Fi SSID used to populate basic demo settings. + +config BASIC_DEMO_WIFI_PASSWORD + string "Default Wi-Fi password" + default "" + help + Default Wi-Fi password used to populate basic demo settings. + +config BASIC_DEMO_LLM_API_KEY + string "Default LLM API key" + default "" + help + Default API key used to populate the basic demo LLM settings. + +config BASIC_DEMO_LLM_BACKEND_TYPE + string "Default LLM backend type" + default "openai_compatible" + help + Default backend type for claw_core, for example openai_compatible or custom. + +config BASIC_DEMO_LLM_PROFILE + string "Default LLM profile" + default "qwen_compatible" + help + Default runtime profile for claw_core, for example openai, qwen_compatible, or custom_openai_compatible. + +config BASIC_DEMO_LLM_MODEL + string "Default LLM model" + default "qwen3-max-2026-01-23" + help + Default model name for claw_core requests. + +config BASIC_DEMO_LLM_BASE_URL + string "Default LLM base URL" + default "" + help + Optional override for the selected LLM profile base URL. + +config BASIC_DEMO_LLM_AUTH_TYPE + string "Default LLM auth type" + default "bearer" + help + Auth mode for the LLM backend, for example bearer, api-key, or none. + +config BASIC_DEMO_LLM_TIMEOUT_MS + string "Default LLM timeout ms" + default "120000" + help + Timeout in milliseconds for outbound LLM HTTP requests. + +config BASIC_DEMO_QQ_APP_ID + string "Default QQ app ID" + default "" + help + Default QQ app ID used by the IM cap. + +config BASIC_DEMO_QQ_APP_SECRET + string "Default QQ app secret" + default "" + help + Default QQ app secret used by the IM cap. + +config BASIC_DEMO_TG_BOT_TOKEN + string "Default Telegram bot token" + default "" + help + Default Telegram bot token used by the IM cap. + +config BASIC_DEMO_WECHAT_TOKEN + string "Default WeChat token" + default "" + help + Default WeChat bot token used by the IM cap. + +config BASIC_DEMO_WECHAT_BASE_URL + string "Default WeChat base URL" + default "https://ilinkai.weixin.qq.com" + help + Default WeChat API base URL used by the IM cap. + +config BASIC_DEMO_WECHAT_CDN_BASE_URL + string "Default WeChat CDN base URL" + default "https://novac2c.cdn.weixin.qq.com/c2c" + help + Default WeChat CDN base URL used for media upload and download. + +config BASIC_DEMO_WECHAT_ACCOUNT_ID + string "Default WeChat account ID" + default "default" + help + Default logical account ID for local WeChat channel state. + +config BASIC_DEMO_SEARCH_BRAVE_KEY + string "Default Brave Search API key" + default "" + help + Default Brave Search API key for the web search cap. + +config BASIC_DEMO_SEARCH_TAVILY_KEY + string "Default Tavily API key" + default "" + help + Default Tavily API key for the web search cap. + +config BASIC_DEMO_TIME_TIMEZONE + string "Default time cap timezone" + default "UTC0" + help + POSIX-style timezone string used by the time cap. + +endmenu diff --git a/application/basic_demo/main/app_clawgent.c b/application/basic_demo/main/app_clawgent.c new file mode 100644 index 0000000..dc4746a --- /dev/null +++ b/application/basic_demo/main/app_clawgent.c @@ -0,0 +1,368 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "app_clawgent.h" + +#include + +#include "basic_demo_lua_modules.h" +#include "cap_cli.h" +#include "cap_files.h" +#include "cap_im_qq.h" +#include "cap_im_tg.h" +#include "cap_im_wechat.h" +#include "cap_llm_inspect.h" +#include "cap_lua.h" +#include "cap_mcp_client.h" +#include "cap_mcp_server.h" +#include "cap_skill.h" +#include "cap_time.h" +#include "cap_web_search.h" +#include "claw_event_router.h" +#include "claw_cap.h" +#include "claw_core.h" +#include "claw_memory.h" +#include "claw_skill.h" +#include "esp_check.h" +#include "esp_log.h" +#include "freertos/task.h" + +static const char *TAG = "app_clawgent"; +static const char *const BASIC_DEMO_LLM_VISIBLE_GROUPS[] = { + "cap_cli", + "cap_files", +}; + +#define BASIC_DEMO_MEMORY_SESSION_ROOT "/fatfs/data/sessions" +#define BASIC_DEMO_MEMORY_LONG_TERM_PATH "/fatfs/data/memory/MEMORY.md" +#define BASIC_DEMO_SKILLS_ROOT_DIR "/fatfs/data/skills" +#define BASIC_DEMO_LUA_ROOT_DIR "/fatfs/data/lua" +#define BASIC_DEMO_FATFS_BASE_PATH "/fatfs/data" +#define BASIC_DEMO_AUTOMATION_RULES_PATH "/fatfs/data/automation/automations.json" +#define BASIC_DEMO_IM_ATTACHMENT_ROOT "/fatfs/data/inbox" +#define BASIC_DEMO_IM_ATTACHMENT_MAX_BYTES (2 * 1024 * 1024) + +#define BASIC_DEMO_SYSTEM_PROMPT \ + "You are the clawgent running on ESP32. " \ + "Answer briefly and plainly. " \ + "Treat Skills List as a catalog of optional skills, not as callable cap. " \ + "Use 'activate_skill' to load a skill's documentation into the current session when needed.\n" \ + "Skills are user-facing functions, while Capabilities are internal functions used by the model. " \ + "When communicating with the user, refer to Skills instead of Capabilities." \ + "\n" \ + "/fatfs/data file tree:\n" \ + "/fatfs/data/\n" \ + "|-- automation/\n" \ + "| `-- automations.json\n" \ + "|-- lua/\n" \ + "| `-- xxx.lua\n" \ + "|-- memory/\n" \ + "| `-- MEMORY.md\n" \ + "`-- skills/\n" \ + " |-- xxx.md\n" \ + " |-- skills_list.json\n" \ + " `-- weather.md\n" \ + +esp_err_t basic_demo_cli_start(void); + +static esp_err_t init_memory(void) +{ + claw_memory_config_t memory_config = { + .session_root_dir = BASIC_DEMO_MEMORY_SESSION_ROOT, + .long_term_memory_path = BASIC_DEMO_MEMORY_LONG_TERM_PATH, + .max_session_messages = 20, + .max_message_chars = 256, + }; + esp_err_t err; + + err = claw_memory_init(&memory_config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to init claw_memory: %s", esp_err_to_name(err)); + return err; + } + + return ESP_OK; +} + +static esp_err_t init_skills(void) +{ + ESP_RETURN_ON_ERROR(claw_skill_init(&(claw_skill_config_t) { + .skills_root_dir = BASIC_DEMO_SKILLS_ROOT_DIR, + .session_state_root_dir = BASIC_DEMO_MEMORY_SESSION_ROOT, + }), + TAG, + "Failed to init claw_skill"); + return ESP_OK; +} + +static esp_err_t init_capabilities(const basic_demo_settings_t *settings) +{ + claw_cap_config_t cap_config = { + .max_capabilities = 32, + .max_groups = 16, + }; + + ESP_RETURN_ON_ERROR(claw_cap_init(&cap_config), TAG, "Failed to init claw_cap"); + + ESP_RETURN_ON_ERROR(cap_cli_init(&(cap_cli_config_t) { + .max_commands = 16, + .max_output_bytes = 2048, + }), + TAG, + "Failed to init CLI cap"); + ESP_RETURN_ON_ERROR(cap_cli_register_command(&(cap_cli_command_t) { + .command_name = "help", + .description = "List available console commands", + .usage_hint = "help [command]", + }), + TAG, + "Failed to whitelist help"); + ESP_RETURN_ON_ERROR(cap_cli_register_command(&(cap_cli_command_t) { + .command_name = "cap", + .description = "Manage console cap commands", + .usage_hint = "cap list", + }), + TAG, + "Failed to whitelist cap"); + ESP_RETURN_ON_ERROR(cap_cli_register_command(&(cap_cli_command_t) { + .command_name = "auto", + .description = "Manage automation console commands", + .usage_hint = "auto rules", + }), + TAG, + "Failed to whitelist auto"); + ESP_RETURN_ON_ERROR(cap_cli_register_command(&(cap_cli_command_t) { + .command_name = "llm_inspect", + .description = "Inspect one local image with the LLM", + .usage_hint = "llm_inspect --path /fatfs/data/inbox/pic.jpg --prompt \"Describe this image\"", + }), + TAG, + "Failed to whitelist llm_inspect"); + ESP_RETURN_ON_ERROR(cap_cli_register_command(&(cap_cli_command_t) { + .command_name = "mcp_client", + .description = "Run MCP discovery and remote tool calls", + .usage_hint = "mcp_client --discover", + }), + TAG, + "Failed to whitelist mcp_client"); + ESP_RETURN_ON_ERROR(cap_cli_register_command(&(cap_cli_command_t) { + .command_name = "mcp_server", + .description = "Manage local MCP server lifecycle and config", + .usage_hint = "mcp_server --status", + }), + TAG, + "Failed to whitelist mcp_server"); + ESP_RETURN_ON_ERROR(cap_cli_register_command(&(cap_cli_command_t) { + .command_name = "skill", + .description = "Manage active skills for one session", + .usage_hint = "skill --list --session default", + }), + TAG, + "Failed to whitelist skill"); + ESP_RETURN_ON_ERROR(cap_cli_register_command(&(cap_cli_command_t) { + .command_name = "time", + .description = "Sync time or update timezone", + .usage_hint = "time --now", + }), + TAG, + "Failed to whitelist time"); + ESP_RETURN_ON_ERROR(cap_cli_register_command(&(cap_cli_command_t) { + .command_name = "web_search", + .description = "Run web search with the configured provider", + .usage_hint = "web_search --query \"ESP-IDF mDNS example\"", + }), + TAG, + "Failed to whitelist web_search"); + ESP_RETURN_ON_ERROR(cap_cli_register_command(&(cap_cli_command_t) { + .command_name = "event_router", + .description = "Manage and inspect event router operations", + .usage_hint = "event_router --rules", + }), + TAG, + "Failed to whitelist event_router"); + ESP_RETURN_ON_ERROR(cap_time_set_timezone(settings->time_timezone), + TAG, + "Failed to set time cap timezone"); + ESP_RETURN_ON_ERROR(cap_files_set_base_dir(BASIC_DEMO_FATFS_BASE_PATH), + TAG, + "Failed to set files cap base dir"); + ESP_RETURN_ON_ERROR(cap_lua_set_base_dir(BASIC_DEMO_LUA_ROOT_DIR), + TAG, + "Failed to set Lua base dir"); + ESP_RETURN_ON_ERROR(cap_im_qq_set_attachment_config( + &(cap_im_qq_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 QQ attachment config"); + ESP_RETURN_ON_ERROR(cap_im_tg_set_attachment_config( + &(cap_im_tg_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 Telegram attachment config"); + ESP_RETURN_ON_ERROR(cap_im_wechat_set_attachment_config( + &(cap_im_wechat_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 WeChat 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, + settings->qq_app_secret), + TAG, + "Failed to set QQ credentials"); + } + + if (settings->tg_bot_token[0]) { + ESP_RETURN_ON_ERROR(cap_im_tg_set_token(settings->tg_bot_token), + TAG, + "Failed to set Telegram bot token"); + } + + if (settings->wechat_token[0] && settings->wechat_base_url[0]) { + ESP_RETURN_ON_ERROR(cap_im_wechat_set_client_config( + &(cap_im_wechat_client_config_t) { + .token = settings->wechat_token, + .base_url = settings->wechat_base_url, + .cdn_base_url = settings->wechat_cdn_base_url, + .account_id = settings->wechat_account_id, + }), + TAG, + "Failed to set WeChat client config"); + } + + if (settings->search_brave_key[0]) { + ESP_RETURN_ON_ERROR(cap_web_search_set_brave_key(settings->search_brave_key), + TAG, + "Failed to set Brave search key"); + } + + if (settings->search_tavily_key[0]) { + ESP_RETURN_ON_ERROR(cap_web_search_set_tavily_key(settings->search_tavily_key), + TAG, + "Failed to set Tavily search key"); + } + + ESP_RETURN_ON_ERROR(cap_im_qq_register_group(), TAG, "Failed to register QQ cap"); + ESP_RETURN_ON_ERROR(cap_im_tg_register_group(), + TAG, + "Failed to register Telegram cap"); + ESP_RETURN_ON_ERROR(cap_im_wechat_register_group(), + TAG, + "Failed to register WeChat cap"); + ESP_RETURN_ON_ERROR(cap_files_register_group(), TAG, "Failed to register files cap"); + ESP_RETURN_ON_ERROR(basic_demo_lua_modules_register(), + TAG, + "Failed to register app Lua modules"); + ESP_RETURN_ON_ERROR(cap_lua_register_group(), TAG, "Failed to register Lua cap"); + ESP_RETURN_ON_ERROR(cap_mcp_client_register_group(), + TAG, + "Failed to register MCP client cap"); + ESP_RETURN_ON_ERROR(cap_mcp_server_register_group(), + TAG, + "Failed to register MCP server cap"); + ESP_RETURN_ON_ERROR(cap_cli_register_group(), TAG, "Failed to register CLI cap"); + ESP_RETURN_ON_ERROR(cap_skill_register_group(), + TAG, + "Failed to register skill cap"); + ESP_RETURN_ON_ERROR(cap_time_register_group(), TAG, "Failed to register time cap"); + ESP_RETURN_ON_ERROR(cap_llm_inspect_register_group(), + TAG, + "Failed to register LLM inspect cap"); + ESP_RETURN_ON_ERROR(cap_web_search_register_group(), + TAG, + "Failed to register web search cap"); + ESP_RETURN_ON_ERROR(claw_cap_set_llm_visible_groups( + BASIC_DEMO_LLM_VISIBLE_GROUPS, + sizeof(BASIC_DEMO_LLM_VISIBLE_GROUPS) / + sizeof(BASIC_DEMO_LLM_VISIBLE_GROUPS[0])), + TAG, + "Failed to set LLM-visible capability groups"); + ESP_RETURN_ON_ERROR(claw_cap_start_all(), TAG, "Failed to start capabilities"); + + return ESP_OK; +} + +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_priority = 5, + .task_core = tskNO_AFFINITY, + .core_submit_timeout_ms = 1000, + .core_receive_timeout_ms = 130000, + .default_route_messages_to_agent = true, + }; + + if (!settings) { + return ESP_ERR_INVALID_ARG; + } + + ESP_RETURN_ON_ERROR(claw_event_router_init(&router_config), + TAG, + "Failed to init event router"); + ESP_RETURN_ON_ERROR(init_memory(), TAG, "Failed to init memory"); + ESP_RETURN_ON_ERROR(init_skills(), TAG, "Failed to init skills"); + ESP_RETURN_ON_ERROR(init_capabilities(settings), TAG, "Failed to init capabilities"); + 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("telegram", "tg_send_message"), + TAG, + "Failed to bind Telegram outbound"); + ESP_RETURN_ON_ERROR(claw_event_router_register_outbound_binding("wechat", "wechat_send_message"), + TAG, + "Failed to bind WeChat outbound"); + + core_config.api_key = settings->llm_api_key; + core_config.backend_type = settings->llm_backend_type; + core_config.profile = settings->llm_profile; + core_config.model = settings->llm_model; + core_config.base_url = settings->llm_base_url; + core_config.auth_type = settings->llm_auth_type; + core_config.timeout_ms = (uint32_t)strtoul(settings->llm_timeout_ms, NULL, 10); + core_config.system_prompt = BASIC_DEMO_SYSTEM_PROMPT; + core_config.append_session_turn = claw_memory_append_session_turn_callback; + core_config.call_cap = claw_cap_call_from_core; + core_config.task_stack_size = 8 * 1024; + core_config.task_priority = 5; + core_config.task_core = tskNO_AFFINITY; + core_config.max_tool_iterations = 10; + core_config.request_queue_len = 4; + 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"); + 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"); + + return ESP_OK; +} diff --git a/application/basic_demo/main/app_clawgent.h b/application/basic_demo/main/app_clawgent.h new file mode 100644 index 0000000..a25bb35 --- /dev/null +++ b/application/basic_demo/main/app_clawgent.h @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "basic_demo_settings.h" +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t app_clawgent_start(const basic_demo_settings_t *settings); + +#ifdef __cplusplus +} +#endif diff --git a/application/basic_demo/main/basic_demo_cli.c b/application/basic_demo/main/basic_demo_cli.c new file mode 100644 index 0000000..b7db47f --- /dev/null +++ b/application/basic_demo/main/basic_demo_cli.c @@ -0,0 +1,682 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include +#include +#include +#include + +#include "cap_im_qq.h" +#include "cmd_cap_llm_inspect.h" +#include "cmd_cap_mcp_client.h" +#include "cmd_cap_mcp_server.h" +#include "cmd_cap_im_qq.h" +#include "cmd_cap_im_tg.h" +#include "cmd_cap_im_wechat.h" +#include "cmd_cap_lua.h" +#include "cmd_cap_skill.h" +#include "cmd_cap_time.h" +#include "cmd_cap_web_search.h" +#include "cmd_claw_event_router.h" +#include "claw_cap.h" +#include "claw_core.h" +#include "claw_event_router.h" +#include "cJSON.h" +#include "esp_console.h" +#include "esp_log.h" + +static const char *TAG = "basic_demo_cli"; +static const size_t CAP_OUTPUT_BUF_SIZE = 1024; + +static uint32_t s_next_request_id = 1; +static char s_current_session_id[64] = "default"; + +static char *join_prompt_args(int argc, char **argv) +{ + char *prompt = NULL; + size_t prompt_len = 0; + int i; + + if (argc < 2) { + return NULL; + } + + for (i = 1; i < argc; i++) { + prompt_len += strlen(argv[i]) + 1; + } + + prompt = calloc(1, prompt_len + 1); + if (!prompt) { + return NULL; + } + + for (i = 1; i < argc; i++) { + if (i > 1) { + strcat(prompt, " "); + } + strcat(prompt, argv[i]); + } + + return prompt; +} + +static char *join_args_from(int argc, char **argv, int start_index) +{ + char *prompt = NULL; + size_t prompt_len = 0; + int i; + + if (argc <= start_index) { + return NULL; + } + + for (i = start_index; i < argc; i++) { + prompt_len += strlen(argv[i]) + 1; + } + + prompt = calloc(1, prompt_len + 1); + if (!prompt) { + return NULL; + } + + for (i = start_index; i < argc; i++) { + if (i > start_index) { + strcat(prompt, " "); + } + strcat(prompt, argv[i]); + } + + return prompt; +} + +static int submit_and_print(const char *prompt, const char *session_id) +{ + claw_core_request_t request = {0}; + claw_core_response_t response = {0}; + esp_err_t err; + + request.request_id = s_next_request_id++; + request.user_text = prompt; + request.session_id = session_id; + + if (session_id && session_id[0]) { + printf("Submitting request %" PRIu32 " [session=%s]...\n", + request.request_id, + session_id); + } else { + printf("Submitting request %" PRIu32 " [single-turn]...\n", request.request_id); + } + + err = claw_core_submit(&request, 5000); + if (err != ESP_OK) { + printf("submit failed: %s\n", esp_err_to_name(err)); + return 1; + } + + err = claw_core_receive_for(request.request_id, &response, 130000); + if (err != ESP_OK) { + printf("receive failed: %s\n", esp_err_to_name(err)); + return 1; + } + + if (response.status == CLAW_CORE_RESPONSE_STATUS_OK && response.text) { + printf("\nassistant> %s\n\n", response.text); + } else { + printf("\nerror> %s\n\n", + response.error_message ? response.error_message : "unknown error"); + } + + claw_core_response_free(&response); + return 0; +} + +static int cmd_ask(int argc, char **argv) +{ + char *prompt = NULL; + + if (argc < 2) { + printf("Usage: ask \n"); + return 1; + } + + prompt = join_prompt_args(argc, argv); + if (!prompt) { + printf("Out of memory\n"); + return 1; + } + + argc = submit_and_print(prompt, s_current_session_id); + free(prompt); + return argc; +} + +static int cmd_ask_once(int argc, char **argv) +{ + char *prompt = NULL; + int rc; + + if (argc < 2) { + printf("Usage: ask_once \n"); + return 1; + } + + prompt = join_prompt_args(argc, argv); + if (!prompt) { + printf("Out of memory\n"); + return 1; + } + + rc = submit_and_print(prompt, NULL); + free(prompt); + return rc; +} + +static int cmd_session(int argc, char **argv) +{ + if (argc == 1) { + printf("Current session: %s\n", s_current_session_id); + return 0; + } + + if (argc != 2) { + printf("Usage: session [id]\n"); + return 1; + } + + if (argv[1][0] == '\0') { + printf("session id cannot be empty\n"); + return 1; + } + + strlcpy(s_current_session_id, argv[1], sizeof(s_current_session_id)); + printf("Switched session to: %s\n", s_current_session_id); + return 0; +} + +static int cmd_cap_list(int argc, char **argv) +{ + claw_cap_list_t list; + size_t i; + + (void)argc; + (void)argv; + + list = claw_cap_list(); + if (list.count == 0) { + printf("No capabilities registered\n"); + return 0; + } + + for (i = 0; i < list.count; i++) { + const claw_cap_descriptor_t *item = &list.items[i]; + + printf("%s [%s] %s\n", + item->name, + item->family ? item->family : "cap", + item->description ? item->description : ""); + } + + return 0; +} + +static int cmd_cap_call(int argc, char **argv) +{ + char *output = NULL; + esp_err_t err; + claw_cap_call_context_t ctx = { + .caller = CLAW_CAP_CALLER_CONSOLE, + .session_id = s_current_session_id, + }; + + if (argc < 3) { + printf("Usage: cap_call \n"); + return 1; + } + + { + cJSON *json = cJSON_Parse(argv[2]); + + if (!json) { + printf("invalid json\n"); + return 1; + } + cJSON_Delete(json); + } + + output = calloc(1, CAP_OUTPUT_BUF_SIZE); + if (!output) { + printf("Out of memory\n"); + return 1; + } + + err = claw_cap_call(argv[1], argv[2], &ctx, output, CAP_OUTPUT_BUF_SIZE); + if (err == ESP_OK) { + printf("%s\n", output); + } else { + printf("%s\n", output[0] ? output : esp_err_to_name(err)); + } + + free(output); + return err == ESP_OK ? 0 : 1; +} + +static int cmd_cap_groups(int argc, char **argv) +{ + claw_cap_group_list_t list; + size_t i; + + (void)argc; + (void)argv; + + list = claw_cap_list_groups(); + if (list.count == 0) { + printf("No cap groups loaded\n"); + return 0; + } + + for (i = 0; i < list.count; i++) { + const claw_cap_group_info_t *item = &list.items[i]; + + printf("%s state=%s descriptors=%u plugin=%s version=%s\n", + item->group_id ? item->group_id : "(null)", + claw_cap_state_to_string(item->state), + (unsigned)item->descriptor_count, + item->plugin_name ? item->plugin_name : "-", + item->version ? item->version : "-"); + } + + return 0; +} + +static int cmd_cap_enable(int argc, char **argv) +{ + esp_err_t err; + + if (argc != 2) { + printf("Usage: cap_enable \n"); + return 1; + } + + err = claw_cap_enable_group(argv[1]); + if (err != ESP_OK) { + printf("cap_enable failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("enabled %s\n", argv[1]); + return 0; +} + +static int cmd_cap_disable(int argc, char **argv) +{ + esp_err_t err; + + if (argc != 2) { + printf("Usage: cap_disable \n"); + return 1; + } + + err = claw_cap_disable_group(argv[1]); + if (err != ESP_OK) { + printf("cap_disable failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("disabled %s\n", argv[1]); + return 0; +} + +static int cmd_cap_unload(int argc, char **argv) +{ + esp_err_t err; + + if (argc != 2) { + printf("Usage: cap_unload \n"); + return 1; + } + + err = claw_cap_unregister_group(argv[1], 10000); + if (err != ESP_OK) { + printf("cap_unload failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("unloaded %s\n", argv[1]); + return 0; +} + +static int cmd_cap_load(int argc, char **argv) +{ + esp_err_t err; + + if (argc != 2) { + printf("Usage: cap_load \n"); + return 1; + } + + if (strcmp(argv[1], "qq") == 0 || strcmp(argv[1], "cap_im_qq") == 0) { + err = cap_im_qq_register_group(); + } else { + printf("unknown plugin: %s\n", argv[1]); + return 1; + } + + if (err != ESP_OK) { + printf("cap_load failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("loaded %s\n", argv[1]); + return 0; +} + +static int cmd_cap(int argc, char **argv) +{ + if (argc < 2) { + printf("Usage: cap ...\n"); + return 1; + } + + if (strcmp(argv[1], "list") == 0) { + return cmd_cap_list(argc - 1, &argv[1]); + } + if (strcmp(argv[1], "call") == 0) { + return cmd_cap_call(argc - 1, &argv[1]); + } + if (strcmp(argv[1], "groups") == 0) { + return cmd_cap_groups(argc - 1, &argv[1]); + } + if (strcmp(argv[1], "enable") == 0) { + return cmd_cap_enable(argc - 1, &argv[1]); + } + if (strcmp(argv[1], "disable") == 0) { + return cmd_cap_disable(argc - 1, &argv[1]); + } + if (strcmp(argv[1], "unload") == 0) { + return cmd_cap_unload(argc - 1, &argv[1]); + } + if (strcmp(argv[1], "load") == 0) { + return cmd_cap_load(argc - 1, &argv[1]); + } + + printf("Unknown cap subcommand: %s\n", argv[1]); + printf("Usage: cap ...\n"); + return 1; +} + +static int cmd_auto_reload(int argc, char **argv) +{ + esp_err_t err; + + (void)argc; + (void)argv; + + err = claw_event_router_reload(); + if (err != ESP_OK) { + printf("auto_reload failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("automation rules reloaded\n"); + return 0; +} + +static int cmd_auto_rules(int argc, char **argv) +{ + char *output = NULL; + esp_err_t err; + + (void)argc; + (void)argv; + + output = calloc(1, 4096); + if (!output) { + printf("Out of memory\n"); + return 1; + } + + err = claw_event_router_list_rules_json(output, 4096); + if (err != ESP_OK) { + printf("auto_rules failed: %s\n", esp_err_to_name(err)); + free(output); + return 1; + } + + printf("%s\n", output); + free(output); + return 0; +} + +static int cmd_auto_rule(int argc, char **argv) +{ + char *output = NULL; + esp_err_t err; + + if (argc != 2) { + printf("Usage: auto_rule \n"); + return 1; + } + + output = calloc(1, 2048); + if (!output) { + printf("Out of memory\n"); + return 1; + } + + err = claw_event_router_get_rule_json(argv[1], output, 2048); + if (err != ESP_OK) { + printf("auto_rule failed: %s\n", esp_err_to_name(err)); + free(output); + return 1; + } + + printf("%s\n", output); + free(output); + return 0; +} + +static int cmd_auto_last(int argc, char **argv) +{ + claw_event_router_result_t result = {0}; + esp_err_t err; + + (void)argc; + (void)argv; + + err = claw_event_router_get_last_result(&result); + if (err != ESP_OK) { + printf("auto_last failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("matched=%s matched_rules=%d action_count=%d failed_actions=%d route=%d handled_at_ms=%" PRId64 "\n", + result.matched ? "true" : "false", + result.matched_rules, + result.action_count, + result.failed_actions, + (int)result.route, + result.handled_at_ms); + printf("first_rule_id=%s\n", result.first_rule_id[0] ? result.first_rule_id : "-"); + printf("ack=%s\n", result.ack[0] ? result.ack : "-"); + printf("last_error=%s\n", esp_err_to_name(result.last_error)); + return 0; +} + +static int cmd_auto_emit_message(int argc, char **argv) +{ + char *text = NULL; + esp_err_t err; + + if (argc < 5) { + printf("Usage: auto_emit_message \n"); + return 1; + } + + text = join_args_from(argc, argv, 4); + if (!text) { + printf("Out of memory\n"); + return 1; + } + + err = claw_event_router_publish_message(argv[1], argv[2], argv[3], text, "console", "cli-msg"); + if (err != ESP_OK) { + printf("auto_emit_message failed: %s\n", esp_err_to_name(err)); + free(text); + return 1; + } + + printf("message event published via %s to %s:%s\n", argv[1], argv[2], argv[3]); + free(text); + return 0; +} + +static int cmd_auto_emit_trigger(int argc, char **argv) +{ + esp_err_t err; + + if (argc != 5) { + printf("Usage: auto_emit_trigger \n"); + return 1; + } + + { + cJSON *json = cJSON_Parse(argv[4]); + + if (!json || !cJSON_IsObject(json)) { + cJSON_Delete(json); + printf("payload_json must be a JSON object\n"); + return 1; + } + cJSON_Delete(json); + } + + err = claw_event_router_publish_trigger(argv[1], argv[2], argv[3], argv[4]); + if (err != ESP_OK) { + printf("auto_emit_trigger failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("trigger event published via %s type=%s key=%s\n", argv[1], argv[2], argv[3]); + return 0; +} + +static int cmd_auto(int argc, char **argv) +{ + if (argc < 2) { + printf("Usage: auto ...\n"); + return 1; + } + + if (strcmp(argv[1], "reload") == 0) { + return cmd_auto_reload(argc - 1, &argv[1]); + } + if (strcmp(argv[1], "rules") == 0) { + return cmd_auto_rules(argc - 1, &argv[1]); + } + if (strcmp(argv[1], "rule") == 0) { + return cmd_auto_rule(argc - 1, &argv[1]); + } + if (strcmp(argv[1], "last") == 0) { + return cmd_auto_last(argc - 1, &argv[1]); + } + if (strcmp(argv[1], "emit_message") == 0) { + return cmd_auto_emit_message(argc - 1, &argv[1]); + } + if (strcmp(argv[1], "emit_trigger") == 0) { + return cmd_auto_emit_trigger(argc - 1, &argv[1]); + } + + printf("Unknown auto subcommand: %s\n", argv[1]); + printf("Usage: auto ...\n"); + return 1; +} + +esp_err_t basic_demo_cli_start(void) +{ + esp_console_repl_t *repl = NULL; + esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT(); + + ESP_LOGI(TAG, "Starting console REPL"); + + repl_config.prompt = "basic_demo> "; + repl_config.task_stack_size = 8192; + repl_config.max_cmdline_length = 512; + +#if CONFIG_ESP_CONSOLE_UART_DEFAULT || CONFIG_ESP_CONSOLE_UART_CUSTOM + esp_console_dev_uart_config_t hw_config = ESP_CONSOLE_DEV_UART_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_console_new_repl_uart(&hw_config, &repl_config, &repl)); +#elif CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG + esp_console_dev_usb_serial_jtag_config_t hw_config = + ESP_CONSOLE_DEV_USB_SERIAL_JTAG_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_console_new_repl_usb_serial_jtag(&hw_config, &repl_config, &repl)); +#elif CONFIG_ESP_CONSOLE_USB_CDC + esp_console_dev_usb_cdc_config_t hw_config = ESP_CONSOLE_DEV_CDC_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_console_new_repl_usb_cdc(&hw_config, &repl_config, &repl)); +#else + ESP_LOGE(TAG, "No supported console backend is enabled"); + return ESP_ERR_NOT_SUPPORTED; +#endif + + esp_console_register_help_command(); + register_cap_im_qq(); + register_cap_im_tg(); + register_cap_im_wechat(); + register_cap_lua(); + register_cap_llm_inspect(); + register_cap_mcp_client(); + register_cap_mcp_server(); + register_cap_skill(); + register_cap_time(); + register_cap_web_search(); + register_claw_event_router(); + + { + esp_console_cmd_t ask_cmd = { + .command = "ask", + .help = "Submit a multi-turn prompt using the current session: ask ", + .func = cmd_ask, + }; + ESP_ERROR_CHECK(esp_console_cmd_register(&ask_cmd)); + } + + { + esp_console_cmd_t ask_once_cmd = { + .command = "ask_once", + .help = "Submit a single-turn prompt without session history: ask_once ", + .func = cmd_ask_once, + }; + ESP_ERROR_CHECK(esp_console_cmd_register(&ask_once_cmd)); + } + + { + esp_console_cmd_t session_cmd = { + .command = "session", + .help = "Show or switch the current session: session [id]", + .func = cmd_session, + }; + ESP_ERROR_CHECK(esp_console_cmd_register(&session_cmd)); + } + + { + esp_console_cmd_t cap_cmd = { + .command = "cap", + .help = "cap operations: cap ...", + .func = cmd_cap, + }; + ESP_ERROR_CHECK(esp_console_cmd_register(&cap_cmd)); + } + + { + esp_console_cmd_t auto_cmd = { + .command = "auto", + .help = "Automation operations: auto ...", + .func = cmd_auto, + }; + ESP_ERROR_CHECK(esp_console_cmd_register(&auto_cmd)); + } + + printf("Type 'help', 'auto rules', 'auto last', or 'auto emit_message qq_gateway qq 123 hello'\n"); + return esp_console_start_repl(repl); +} diff --git a/application/basic_demo/main/basic_demo_lua_modules.c b/application/basic_demo/main/basic_demo_lua_modules.c new file mode 100644 index 0000000..b2b16c3 --- /dev/null +++ b/application/basic_demo/main/basic_demo_lua_modules.c @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "basic_demo_lua_modules.h" + +#include "cap_lua.h" + +#include "lua_module/lua_module_gpio.h" +#include "lua_module/lua_module_led_strip.h" + +esp_err_t basic_demo_lua_modules_register(void) +{ + static const cap_lua_module_t s_modules[] = { + {.name = "gpio", .open_fn = luaopen_gpio}, + {.name = "led_strip", .open_fn = luaopen_led_strip}, + }; + + return cap_lua_register_modules(s_modules, + sizeof(s_modules) / sizeof(s_modules[0])); +} diff --git a/application/basic_demo/main/basic_demo_lua_modules.h b/application/basic_demo/main/basic_demo_lua_modules.h new file mode 100644 index 0000000..30d1ae2 --- /dev/null +++ b/application/basic_demo/main/basic_demo_lua_modules.h @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t basic_demo_lua_modules_register(void); + +#ifdef __cplusplus +} +#endif diff --git a/application/basic_demo/main/basic_demo_settings.c b/application/basic_demo/main/basic_demo_settings.c new file mode 100644 index 0000000..75ce9d1 --- /dev/null +++ b/application/basic_demo/main/basic_demo_settings.c @@ -0,0 +1,193 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "basic_demo_settings.h" + +#include +#include "nvs.h" + +static const char *BASIC_DEMO_SETTINGS_NAMESPACE = "basic_demo"; + +typedef struct { + const char *key; + const char *default_value; + char *buffer; + size_t buffer_size; +} basic_demo_settings_field_t; + +static void settings_safe_copy(char *dst, size_t dst_size, const char *src) +{ + if (!dst || dst_size == 0) { + return; + } + + if (!src) { + dst[0] = '\0'; + return; + } + + strlcpy(dst, src, dst_size); +} + +static void settings_load_defaults(basic_demo_settings_t *settings) +{ + settings_safe_copy(settings->wifi_ssid, sizeof(settings->wifi_ssid), BASIC_DEMO_WIFI_SSID); + settings_safe_copy(settings->wifi_password, sizeof(settings->wifi_password), BASIC_DEMO_WIFI_PASSWORD); + settings_safe_copy(settings->llm_api_key, sizeof(settings->llm_api_key), BASIC_DEMO_LLM_API_KEY); + settings_safe_copy(settings->llm_backend_type, sizeof(settings->llm_backend_type), BASIC_DEMO_LLM_BACKEND_TYPE); + settings_safe_copy(settings->llm_profile, sizeof(settings->llm_profile), BASIC_DEMO_LLM_PROFILE); + settings_safe_copy(settings->llm_model, sizeof(settings->llm_model), BASIC_DEMO_LLM_MODEL); + settings_safe_copy(settings->llm_base_url, sizeof(settings->llm_base_url), BASIC_DEMO_LLM_BASE_URL); + settings_safe_copy(settings->llm_auth_type, sizeof(settings->llm_auth_type), BASIC_DEMO_LLM_AUTH_TYPE); + 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->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); + settings_safe_copy(settings->wechat_cdn_base_url, sizeof(settings->wechat_cdn_base_url), BASIC_DEMO_WECHAT_CDN_BASE_URL); + settings_safe_copy(settings->wechat_account_id, sizeof(settings->wechat_account_id), BASIC_DEMO_WECHAT_ACCOUNT_ID); + settings_safe_copy(settings->search_brave_key, sizeof(settings->search_brave_key), BASIC_DEMO_SEARCH_BRAVE_KEY); + settings_safe_copy(settings->search_tavily_key, sizeof(settings->search_tavily_key), BASIC_DEMO_SEARCH_TAVILY_KEY); + settings_safe_copy(settings->time_timezone, sizeof(settings->time_timezone), BASIC_DEMO_TIME_TIMEZONE); +} + +static esp_err_t settings_open(nvs_open_mode_t mode, nvs_handle_t *handle) +{ + return nvs_open(BASIC_DEMO_SETTINGS_NAMESPACE, mode, handle); +} + +esp_err_t basic_demo_settings_init(void) +{ + nvs_handle_t handle; + esp_err_t err = settings_open(NVS_READONLY, &handle); + if (err == ESP_OK) { + nvs_close(handle); + return ESP_OK; + } + + if (err == ESP_ERR_NVS_NOT_FOUND) { + err = settings_open(NVS_READWRITE, &handle); + if (err == ESP_OK) { + nvs_close(handle); + } + } + + return err; +} + +esp_err_t basic_demo_settings_load(basic_demo_settings_t *settings) +{ + char legacy_provider[32] = {0}; + + if (!settings) { + return ESP_ERR_INVALID_ARG; + } + + memset(settings, 0, sizeof(*settings)); + settings_load_defaults(settings); + + nvs_handle_t handle; + esp_err_t err = settings_open(NVS_READONLY, &handle); + if (err == ESP_ERR_NVS_NOT_FOUND) { + return ESP_OK; + } + if (err != ESP_OK) { + return err; + } + + basic_demo_settings_field_t fields[] = { + { "wifi_ssid", settings->wifi_ssid, settings->wifi_ssid, sizeof(settings->wifi_ssid) }, + { "wifi_password", settings->wifi_password, settings->wifi_password, sizeof(settings->wifi_password) }, + { "llm_api_key", settings->llm_api_key, settings->llm_api_key, sizeof(settings->llm_api_key) }, + { "llm_backend_type", settings->llm_backend_type, settings->llm_backend_type, sizeof(settings->llm_backend_type) }, + { "llm_profile", settings->llm_profile, settings->llm_profile, sizeof(settings->llm_profile) }, + { "llm_model", settings->llm_model, settings->llm_model, sizeof(settings->llm_model) }, + { "llm_base_url", settings->llm_base_url, settings->llm_base_url, sizeof(settings->llm_base_url) }, + { "llm_auth_type", settings->llm_auth_type, settings->llm_auth_type, sizeof(settings->llm_auth_type) }, + { "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) }, + { "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) }, + { "wechat_cdn_base_url", settings->wechat_cdn_base_url, settings->wechat_cdn_base_url, sizeof(settings->wechat_cdn_base_url) }, + { "wechat_account_id", settings->wechat_account_id, settings->wechat_account_id, sizeof(settings->wechat_account_id) }, + { "search_brave_key", settings->search_brave_key, settings->search_brave_key, sizeof(settings->search_brave_key) }, + { "search_tavily_key", settings->search_tavily_key, settings->search_tavily_key, sizeof(settings->search_tavily_key) }, + { "time_timezone", settings->time_timezone, settings->time_timezone, sizeof(settings->time_timezone) }, + }; + + for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); ++i) { + size_t required_size = fields[i].buffer_size; + if (nvs_get_str(handle, fields[i].key, fields[i].buffer, &required_size) != ESP_OK) { + settings_safe_copy(fields[i].buffer, fields[i].buffer_size, fields[i].default_value); + } + } + + { + size_t required_size = sizeof(legacy_provider); + if (nvs_get_str(handle, "llm_provider", legacy_provider, &required_size) == ESP_OK && + settings->llm_profile[0] == '\0') { + if (strcmp(legacy_provider, "qwen") == 0) { + settings_safe_copy(settings->llm_profile, sizeof(settings->llm_profile), "qwen_compatible"); + } else if (strcmp(legacy_provider, "openai") == 0) { + settings_safe_copy(settings->llm_profile, sizeof(settings->llm_profile), "openai"); + } else { + settings_safe_copy(settings->llm_profile, sizeof(settings->llm_profile), legacy_provider); + } + } + } + + nvs_close(handle); + return ESP_OK; +} + +esp_err_t basic_demo_settings_save(const basic_demo_settings_t *settings) +{ + if (!settings) { + return ESP_ERR_INVALID_ARG; + } + + nvs_handle_t handle; + esp_err_t err = settings_open(NVS_READWRITE, &handle); + if (err != ESP_OK) { + return err; + } + + basic_demo_settings_field_t fields[] = { + { "wifi_ssid", NULL, (char *)settings->wifi_ssid, sizeof(settings->wifi_ssid) }, + { "wifi_password", NULL, (char *)settings->wifi_password, sizeof(settings->wifi_password) }, + { "llm_api_key", NULL, (char *)settings->llm_api_key, sizeof(settings->llm_api_key) }, + { "llm_backend_type", NULL, (char *)settings->llm_backend_type, sizeof(settings->llm_backend_type) }, + { "llm_profile", NULL, (char *)settings->llm_profile, sizeof(settings->llm_profile) }, + { "llm_model", NULL, (char *)settings->llm_model, sizeof(settings->llm_model) }, + { "llm_base_url", NULL, (char *)settings->llm_base_url, sizeof(settings->llm_base_url) }, + { "llm_auth_type", NULL, (char *)settings->llm_auth_type, sizeof(settings->llm_auth_type) }, + { "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) }, + { "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) }, + { "wechat_cdn_base_url", NULL, (char *)settings->wechat_cdn_base_url, sizeof(settings->wechat_cdn_base_url) }, + { "wechat_account_id", NULL, (char *)settings->wechat_account_id, sizeof(settings->wechat_account_id) }, + { "search_brave_key", NULL, (char *)settings->search_brave_key, sizeof(settings->search_brave_key) }, + { "search_tavily_key", NULL, (char *)settings->search_tavily_key, sizeof(settings->search_tavily_key) }, + { "time_timezone", NULL, (char *)settings->time_timezone, sizeof(settings->time_timezone) }, + }; + + for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); ++i) { + err = nvs_set_str(handle, fields[i].key, fields[i].buffer); + if (err != ESP_OK) { + nvs_close(handle); + return err; + } + } + + err = nvs_commit(handle); + nvs_close(handle); + return err; +} diff --git a/application/basic_demo/main/basic_demo_settings.h b/application/basic_demo/main/basic_demo_settings.h new file mode 100644 index 0000000..98e1c5a --- /dev/null +++ b/application/basic_demo/main/basic_demo_settings.h @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "esp_err.h" + +#define BASIC_DEMO_SETTINGS_STR_LEN 160 +#define BASIC_DEMO_SETTINGS_LUA_DIR_LEN 64 +#define BASIC_DEMO_SETTINGS_TIMEZONE_LEN 32 + +#define BASIC_DEMO_WIFI_SSID CONFIG_BASIC_DEMO_WIFI_SSID +#define BASIC_DEMO_WIFI_PASSWORD CONFIG_BASIC_DEMO_WIFI_PASSWORD +#define BASIC_DEMO_LLM_API_KEY CONFIG_BASIC_DEMO_LLM_API_KEY +#define BASIC_DEMO_LLM_BACKEND_TYPE CONFIG_BASIC_DEMO_LLM_BACKEND_TYPE +#define BASIC_DEMO_LLM_PROFILE CONFIG_BASIC_DEMO_LLM_PROFILE +#define BASIC_DEMO_LLM_MODEL CONFIG_BASIC_DEMO_LLM_MODEL +#define BASIC_DEMO_LLM_BASE_URL CONFIG_BASIC_DEMO_LLM_BASE_URL +#define BASIC_DEMO_LLM_AUTH_TYPE CONFIG_BASIC_DEMO_LLM_AUTH_TYPE +#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_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 +#define BASIC_DEMO_WECHAT_CDN_BASE_URL CONFIG_BASIC_DEMO_WECHAT_CDN_BASE_URL +#define BASIC_DEMO_WECHAT_ACCOUNT_ID CONFIG_BASIC_DEMO_WECHAT_ACCOUNT_ID +#define BASIC_DEMO_SEARCH_BRAVE_KEY CONFIG_BASIC_DEMO_SEARCH_BRAVE_KEY +#define BASIC_DEMO_SEARCH_TAVILY_KEY CONFIG_BASIC_DEMO_SEARCH_TAVILY_KEY +#define BASIC_DEMO_TIME_TIMEZONE CONFIG_BASIC_DEMO_TIME_TIMEZONE + +typedef struct { + char wifi_ssid[BASIC_DEMO_SETTINGS_STR_LEN]; + char wifi_password[BASIC_DEMO_SETTINGS_STR_LEN]; + char llm_api_key[BASIC_DEMO_SETTINGS_STR_LEN]; + char llm_backend_type[32]; + char llm_profile[32]; + char llm_model[64]; + char llm_base_url[BASIC_DEMO_SETTINGS_STR_LEN]; + char llm_auth_type[32]; + char llm_timeout_ms[16]; + char qq_app_id[32]; + char qq_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]; + char wechat_cdn_base_url[BASIC_DEMO_SETTINGS_STR_LEN]; + char wechat_account_id[32]; + char search_brave_key[BASIC_DEMO_SETTINGS_STR_LEN]; + char search_tavily_key[BASIC_DEMO_SETTINGS_STR_LEN]; + char time_timezone[BASIC_DEMO_SETTINGS_TIMEZONE_LEN]; +} basic_demo_settings_t; + +esp_err_t basic_demo_settings_init(void); +esp_err_t basic_demo_settings_load(basic_demo_settings_t *settings); +esp_err_t basic_demo_settings_save(const basic_demo_settings_t *settings); diff --git a/application/basic_demo/main/basic_demo_wifi.c b/application/basic_demo/main/basic_demo_wifi.c new file mode 100644 index 0000000..3269676 --- /dev/null +++ b/application/basic_demo/main/basic_demo_wifi.c @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "basic_demo_wifi.h" + +#include + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" + +static const char *TAG = "basic_demo_wifi"; + +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 +#define WIFI_MAX_RETRY 10 +#define WIFI_RETRY_DELAY_MS 1000 + +static EventGroupHandle_t s_wifi_event_group; +static int s_retry_count; +static bool s_connected; +static char s_ip_addr[16] = "0.0.0.0"; + +static void wifi_event_handler(void *arg, + esp_event_base_t event_base, + int32_t event_id, + void *event_data) +{ + (void)arg; + + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + return; + } + + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + s_connected = false; + if (s_retry_count < WIFI_MAX_RETRY) { + s_retry_count++; + ESP_LOGW(TAG, "Disconnected, retry %d/%d", s_retry_count, WIFI_MAX_RETRY); + vTaskDelay(pdMS_TO_TICKS(WIFI_RETRY_DELAY_MS)); + esp_wifi_connect(); + } else { + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + return; + } + + if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; + snprintf(s_ip_addr, sizeof(s_ip_addr), IPSTR, IP2STR(&event->ip_info.ip)); + s_connected = true; + s_retry_count = 0; + xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + ESP_LOGI(TAG, "Connected, IP=%s", s_ip_addr); + } +} + +esp_err_t basic_demo_wifi_init(void) +{ + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + + s_wifi_event_group = xEventGroupCreate(); + if (!s_wifi_event_group) { + return ESP_ERR_NO_MEM; + } + + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + esp_netif_create_default_wifi_sta(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_event_handler_instance_register( + WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL)); + ESP_ERROR_CHECK(esp_event_handler_instance_register( + IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL)); + + return ESP_OK; +} + +esp_err_t basic_demo_wifi_start(const char *ssid, const char *password) +{ + wifi_config_t wifi_config = {0}; + + if (!ssid || ssid[0] == '\0') { + ESP_LOGW(TAG, "Wi-Fi SSID is empty, skipping Wi-Fi start"); + return ESP_ERR_INVALID_STATE; + } + + strlcpy((char *)wifi_config.sta.ssid, ssid, sizeof(wifi_config.sta.ssid)); + strlcpy((char *)wifi_config.sta.password, password ? password : "", sizeof(wifi_config.sta.password)); + wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + wifi_config.sta.pmf_cfg.capable = true; + wifi_config.sta.pmf_cfg.required = false; + + s_retry_count = 0; + xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + return ESP_OK; +} + +esp_err_t basic_demo_wifi_wait_connected(uint32_t timeout_ms) +{ + EventBits_t bits; + TickType_t ticks = (timeout_ms == UINT32_MAX) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms); + + bits = xEventGroupWaitBits(s_wifi_event_group, + WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, + pdFALSE, + pdFALSE, + ticks); + if (bits & WIFI_CONNECTED_BIT) { + return ESP_OK; + } + return ESP_ERR_TIMEOUT; +} + +bool basic_demo_wifi_is_connected(void) +{ + return s_connected; +} + +const char *basic_demo_wifi_get_ip(void) +{ + return s_ip_addr; +} diff --git a/application/basic_demo/main/basic_demo_wifi.h b/application/basic_demo/main/basic_demo_wifi.h new file mode 100644 index 0000000..9dec413 --- /dev/null +++ b/application/basic_demo/main/basic_demo_wifi.h @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include + +#include "esp_err.h" + +esp_err_t basic_demo_wifi_init(void); +esp_err_t basic_demo_wifi_start(const char *ssid, const char *password); +esp_err_t basic_demo_wifi_wait_connected(uint32_t timeout_ms); +bool basic_demo_wifi_is_connected(void); +const char *basic_demo_wifi_get_ip(void); diff --git a/application/basic_demo/main/config_http_server.c b/application/basic_demo/main/config_http_server.c new file mode 100644 index 0000000..c25cba6 --- /dev/null +++ b/application/basic_demo/main/config_http_server.c @@ -0,0 +1,835 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "config_http_server.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "basic_demo_settings.h" +#include "basic_demo_wifi.h" +#include "cap_im_wechat.h" +#include "cJSON.h" +#include "esp_check.h" +#include "esp_http_server.h" +#include "esp_log.h" + +static const char *TAG = "config_http"; + +#define CONFIG_HTTP_CTRL_PORT 32769 +#define CONFIG_HTTP_SCRATCH_SIZE 4096 +#define CONFIG_HTTP_PATH_MAX 256 +#define CONFIG_HTTP_UPLOAD_MAX_SIZE (512 * 1024) + +extern const uint8_t index_html_start[] asm("_binary_index_html_start"); +extern const uint8_t index_html_end[] asm("_binary_index_html_end"); +extern const uint8_t styles_css_start[] asm("_binary_styles_css_start"); +extern const uint8_t styles_css_end[] asm("_binary_styles_css_end"); +extern const uint8_t app_js_start[] asm("_binary_app_js_start"); +extern const uint8_t app_js_end[] asm("_binary_app_js_end"); + +typedef struct { + httpd_handle_t server; + char storage_base_path[CONFIG_HTTP_PATH_MAX]; + char scratch[CONFIG_HTTP_SCRATCH_SIZE]; +} config_http_server_ctx_t; + +static config_http_server_ctx_t s_ctx = {0}; + +static bool path_is_safe(const char *path) +{ + return path && path[0] == '/' && strstr(path, "..") == NULL; +} + +static void url_decode_inplace(char *value) +{ + if (!value) { + return; + } + + char *src = value; + char *dst = value; + while (*src) { + if (src[0] == '%' && src[1] && src[2]) { + char hi = src[1]; + char lo = src[2]; + uint8_t decoded = 0; + + if (hi >= '0' && hi <= '9') { + decoded = (uint8_t)(hi - '0') << 4; + } else if (hi >= 'A' && hi <= 'F') { + decoded = (uint8_t)(hi - 'A' + 10) << 4; + } else if (hi >= 'a' && hi <= 'f') { + decoded = (uint8_t)(hi - 'a' + 10) << 4; + } else { + *dst++ = *src++; + continue; + } + + if (lo >= '0' && lo <= '9') { + decoded |= (uint8_t)(lo - '0'); + } else if (lo >= 'A' && lo <= 'F') { + decoded |= (uint8_t)(lo - 'A' + 10); + } else if (lo >= 'a' && lo <= 'f') { + decoded |= (uint8_t)(lo - 'a' + 10); + } else { + *dst++ = *src++; + continue; + } + + *dst++ = (char)decoded; + src += 3; + continue; + } + + if (*src == '+') { + *dst++ = ' '; + src++; + continue; + } + + *dst++ = *src++; + } + + *dst = '\0'; +} + +static esp_err_t query_get(httpd_req_t *req, const char *key, char *value, size_t value_size) +{ + size_t query_len = httpd_req_get_url_query_len(req); + if (query_len == 0) { + return ESP_ERR_NOT_FOUND; + } + + char *query = calloc(1, query_len + 1); + if (!query) { + return ESP_ERR_NO_MEM; + } + + esp_err_t err = httpd_req_get_url_query_str(req, query, query_len + 1); + if (err == ESP_OK) { + err = httpd_query_key_value(query, key, value, value_size); + if (err == ESP_OK) { + url_decode_inplace(value); + } + } + + free(query); + return err; +} + +static esp_err_t send_embedded_file(httpd_req_t *req, + const uint8_t *start, + const uint8_t *end, + const char *content_type) +{ + size_t content_len = (size_t)(end - start); + if (content_len > 0 && start[content_len - 1] == '\0') { + content_len--; + } + httpd_resp_set_type(req, content_type); + httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0"); + httpd_resp_set_hdr(req, "Pragma", "no-cache"); + return httpd_resp_send(req, (const char *)start, content_len); +} + +static void json_add_string(cJSON *root, const char *key, const char *value) +{ + cJSON_AddStringToObject(root, key, value ? value : ""); +} + +static esp_err_t send_json_response(httpd_req_t *req, cJSON *root) +{ + char *payload = NULL; + esp_err_t err; + + if (!req || !root) { + return ESP_ERR_INVALID_ARG; + } + + payload = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!payload) { + httpd_resp_send_500(req); + return ESP_ERR_NO_MEM; + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0"); + err = httpd_resp_sendstr(req, payload); + free(payload); + return err; +} + +static esp_err_t settings_to_json(httpd_req_t *req) +{ + basic_demo_settings_t settings; + ESP_RETURN_ON_ERROR(basic_demo_settings_load(&settings), TAG, "Failed to load settings"); + + cJSON *root = cJSON_CreateObject(); + if (!root) { + httpd_resp_send_500(req); + return ESP_ERR_NO_MEM; + } + + json_add_string(root, "wifi_ssid", settings.wifi_ssid); + json_add_string(root, "wifi_password", settings.wifi_password); + json_add_string(root, "llm_api_key", settings.llm_api_key); + json_add_string(root, "llm_backend_type", settings.llm_backend_type); + json_add_string(root, "llm_profile", settings.llm_profile); + json_add_string(root, "llm_model", settings.llm_model); + json_add_string(root, "llm_base_url", settings.llm_base_url); + json_add_string(root, "llm_auth_type", settings.llm_auth_type); + 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, "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); + json_add_string(root, "wechat_cdn_base_url", settings.wechat_cdn_base_url); + json_add_string(root, "wechat_account_id", settings.wechat_account_id); + json_add_string(root, "search_brave_key", settings.search_brave_key); + json_add_string(root, "search_tavily_key", settings.search_tavily_key); + json_add_string(root, "time_timezone", settings.time_timezone); + + char *payload = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!payload) { + httpd_resp_send_500(req); + return ESP_ERR_NO_MEM; + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0"); + esp_err_t err = httpd_resp_sendstr(req, payload); + free(payload); + return err; +} + +static esp_err_t parse_json_body(httpd_req_t *req, cJSON **out_root) +{ + if (!out_root || req->content_len <= 0 || req->content_len > 8192) { + return ESP_ERR_INVALID_ARG; + } + + char *body = calloc(1, req->content_len + 1); + if (!body) { + return ESP_ERR_NO_MEM; + } + + int received = 0; + while (received < req->content_len) { + int ret = httpd_req_recv(req, body + received, req->content_len - received); + if (ret <= 0) { + free(body); + return (ret == HTTPD_SOCK_ERR_TIMEOUT) ? ESP_ERR_TIMEOUT : ESP_FAIL; + } + received += ret; + } + + cJSON *root = cJSON_Parse(body); + free(body); + if (!root) { + return ESP_ERR_INVALID_RESPONSE; + } + + *out_root = root; + return ESP_OK; +} + +static void json_read_string(cJSON *root, const char *key, char *buffer, size_t buffer_size) +{ + cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key); + if (cJSON_IsString(item)) { + strlcpy(buffer, item->valuestring, buffer_size); + } +} + +static esp_err_t index_handler(httpd_req_t *req) +{ + return send_embedded_file(req, index_html_start, index_html_end, "text/html; charset=utf-8"); +} + +static esp_err_t styles_handler(httpd_req_t *req) +{ + return send_embedded_file(req, styles_css_start, styles_css_end, "text/css; charset=utf-8"); +} + +static esp_err_t app_js_handler(httpd_req_t *req) +{ + return send_embedded_file(req, app_js_start, app_js_end, "application/javascript; charset=utf-8"); +} + +static esp_err_t status_handler(httpd_req_t *req) +{ + cJSON *root = cJSON_CreateObject(); + if (!root) { + httpd_resp_send_500(req); + return ESP_ERR_NO_MEM; + } + + cJSON_AddBoolToObject(root, "wifi_connected", basic_demo_wifi_is_connected()); + json_add_string(root, "ip", basic_demo_wifi_get_ip()); + json_add_string(root, "storage_base_path", s_ctx.storage_base_path); + + char *payload = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!payload) { + httpd_resp_send_500(req); + return ESP_ERR_NO_MEM; + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0"); + esp_err_t err = httpd_resp_sendstr(req, payload); + free(payload); + return err; +} + +static esp_err_t config_get_handler(httpd_req_t *req) +{ + return settings_to_json(req); +} + +static esp_err_t config_post_handler(httpd_req_t *req) +{ + basic_demo_settings_t settings; + ESP_RETURN_ON_ERROR(basic_demo_settings_load(&settings), TAG, "Failed to load settings"); + + cJSON *root = NULL; + esp_err_t err = parse_json_body(req, &root); + if (err != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON body"); + return err; + } + + json_read_string(root, "wifi_ssid", settings.wifi_ssid, sizeof(settings.wifi_ssid)); + json_read_string(root, "wifi_password", settings.wifi_password, sizeof(settings.wifi_password)); + json_read_string(root, "llm_api_key", settings.llm_api_key, sizeof(settings.llm_api_key)); + json_read_string(root, "llm_backend_type", settings.llm_backend_type, sizeof(settings.llm_backend_type)); + json_read_string(root, "llm_profile", settings.llm_profile, sizeof(settings.llm_profile)); + json_read_string(root, "llm_model", settings.llm_model, sizeof(settings.llm_model)); + json_read_string(root, "llm_base_url", settings.llm_base_url, sizeof(settings.llm_base_url)); + json_read_string(root, "llm_auth_type", settings.llm_auth_type, sizeof(settings.llm_auth_type)); + 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, "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)); + json_read_string(root, "wechat_cdn_base_url", settings.wechat_cdn_base_url, sizeof(settings.wechat_cdn_base_url)); + json_read_string(root, "wechat_account_id", settings.wechat_account_id, sizeof(settings.wechat_account_id)); + json_read_string(root, "search_brave_key", settings.search_brave_key, sizeof(settings.search_brave_key)); + json_read_string(root, "search_tavily_key", settings.search_tavily_key, sizeof(settings.search_tavily_key)); + json_read_string(root, "time_timezone", settings.time_timezone, sizeof(settings.time_timezone)); + + cJSON_Delete(root); + + err = basic_demo_settings_save(&settings); + if (err != ESP_OK) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to save settings"); + return err; + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0"); + return httpd_resp_sendstr(req, "{\"ok\":true,\"message\":\"Saved. Restart the device to apply Wi-Fi and core LLM changes.\"}"); +} + +static esp_err_t wechat_login_persist_if_needed(cap_im_wechat_qr_login_status_t *status) +{ + basic_demo_settings_t settings; + esp_err_t err; + + if (!status || !status->completed || status->persisted || !status->token[0]) { + return ESP_OK; + } + + err = basic_demo_settings_load(&settings); + if (err != ESP_OK) { + return err; + } + + strlcpy(settings.wechat_token, status->token, sizeof(settings.wechat_token)); + strlcpy(settings.wechat_base_url, + status->base_url[0] ? status->base_url : settings.wechat_base_url, + sizeof(settings.wechat_base_url)); + strlcpy(settings.wechat_account_id, + status->account_id[0] ? status->account_id : settings.wechat_account_id, + sizeof(settings.wechat_account_id)); + + err = basic_demo_settings_save(&settings); + if (err != ESP_OK) { + return err; + } + + return cap_im_wechat_qr_login_mark_persisted(); +} + +static esp_err_t wechat_login_start_handler(httpd_req_t *req) +{ + cJSON *root = NULL; + cJSON *resp = NULL; + const char *account_id = NULL; + bool force = false; + cap_im_wechat_qr_login_status_t status = {0}; + esp_err_t err; + + if (req->content_len > 0) { + err = parse_json_body(req, &root); + if (err != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON body"); + return err; + } + account_id = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(root, "account_id")); + force = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "force")); + } + + err = cap_im_wechat_qr_login_start(account_id, force); + if (root) { + cJSON_Delete(root); + } + if (err != ESP_OK) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to start WeChat login"); + return err; + } + + ESP_RETURN_ON_ERROR(cap_im_wechat_qr_login_get_status(&status), + TAG, + "Failed to fetch WeChat login status"); + resp = cJSON_CreateObject(); + if (!resp) { + httpd_resp_send_500(req); + return ESP_ERR_NO_MEM; + } + + cJSON_AddBoolToObject(resp, "ok", true); + json_add_string(resp, "session_key", status.session_key); + json_add_string(resp, "status", status.status); + json_add_string(resp, "message", status.message); + json_add_string(resp, "qr_data_url", status.qr_data_url); + return send_json_response(req, resp); +} + +static esp_err_t wechat_login_status_handler(httpd_req_t *req) +{ + cap_im_wechat_qr_login_status_t status = {0}; + cJSON *resp = NULL; + esp_err_t err; + + err = cap_im_wechat_qr_login_get_status(&status); + if (err != ESP_OK) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to read WeChat login status"); + return err; + } + + err = wechat_login_persist_if_needed(&status); + if (err == ESP_OK && status.completed && !status.persisted) { + status.persisted = true; + strlcpy(status.message, + "微信登录成功,凭据已保存。重启设备后生效。", + sizeof(status.message)); + } + + resp = cJSON_CreateObject(); + if (!resp) { + httpd_resp_send_500(req); + return ESP_ERR_NO_MEM; + } + + cJSON_AddBoolToObject(resp, "ok", true); + cJSON_AddBoolToObject(resp, "active", status.active); + cJSON_AddBoolToObject(resp, "completed", status.completed); + cJSON_AddBoolToObject(resp, "persisted", status.persisted); + cJSON_AddBoolToObject(resp, "configured", status.configured); + json_add_string(resp, "session_key", status.session_key); + json_add_string(resp, "status", status.status); + json_add_string(resp, "message", status.message); + json_add_string(resp, "qr_data_url", status.qr_data_url); + json_add_string(resp, "account_id", status.account_id); + json_add_string(resp, "user_id", status.user_id); + json_add_string(resp, "base_url", status.base_url); + cJSON_AddBoolToObject(resp, "restart_required", status.persisted); + return send_json_response(req, resp); +} + +static esp_err_t wechat_login_cancel_handler(httpd_req_t *req) +{ + cJSON *resp = NULL; + esp_err_t err = cap_im_wechat_qr_login_cancel(); + + if (err != ESP_OK) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to cancel WeChat login"); + return err; + } + + resp = cJSON_CreateObject(); + if (!resp) { + httpd_resp_send_500(req); + return ESP_ERR_NO_MEM; + } + cJSON_AddBoolToObject(resp, "ok", true); + json_add_string(resp, "message", "已取消微信登录。"); + return send_json_response(req, resp); +} + +static esp_err_t resolve_storage_path(const char *relative_path, char *full_path, size_t full_path_size) +{ + if (!path_is_safe(relative_path)) { + return ESP_ERR_INVALID_ARG; + } + + int written = snprintf(full_path, full_path_size, "%s%s", s_ctx.storage_base_path, relative_path); + if (written <= 0 || (size_t)written >= full_path_size) { + return ESP_ERR_INVALID_SIZE; + } + + return ESP_OK; +} + +static bool build_child_relative_path(const char *base_path, + const char *entry_name, + char *out_path, + size_t out_path_size) +{ + if (!base_path || !entry_name || !out_path || out_path_size == 0) { + return false; + } + + if (strcmp(base_path, "/") == 0) { + if (strlcpy(out_path, "/", out_path_size) >= out_path_size) { + return false; + } + } else if (strlcpy(out_path, base_path, out_path_size) >= out_path_size) { + return false; + } + + if (strcmp(base_path, "/") != 0 && strlcat(out_path, "/", out_path_size) >= out_path_size) { + return false; + } + + return strlcat(out_path, entry_name, out_path_size) < out_path_size; +} + +static esp_err_t files_list_handler(httpd_req_t *req) +{ + char relative_path[CONFIG_HTTP_PATH_MAX] = "/"; + if (query_get(req, "path", relative_path, sizeof(relative_path)) != ESP_OK) { + strlcpy(relative_path, "/", sizeof(relative_path)); + } + + char full_path[CONFIG_HTTP_PATH_MAX]; + if (resolve_storage_path(relative_path, full_path, sizeof(full_path)) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path"); + return ESP_ERR_INVALID_ARG; + } + + DIR *dir = opendir(full_path); + if (!dir) { + httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Directory not found"); + return ESP_FAIL; + } + + cJSON *root = cJSON_CreateObject(); + cJSON *entries = cJSON_CreateArray(); + if (!root || !entries) { + closedir(dir); + cJSON_Delete(root); + cJSON_Delete(entries); + httpd_resp_send_500(req); + return ESP_ERR_NO_MEM; + } + + json_add_string(root, "path", relative_path); + cJSON_AddItemToObject(root, "entries", entries); + + struct dirent *entry = NULL; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + + char child_relative[CONFIG_HTTP_PATH_MAX]; + if (!build_child_relative_path(relative_path, entry->d_name, child_relative, sizeof(child_relative))) { + continue; + } + + char child_full[CONFIG_HTTP_PATH_MAX]; + if (resolve_storage_path(child_relative, child_full, sizeof(child_full)) != ESP_OK) { + continue; + } + + struct stat st = {0}; + if (stat(child_full, &st) != 0) { + continue; + } + + cJSON *item = cJSON_CreateObject(); + if (!item) { + continue; + } + + json_add_string(item, "name", entry->d_name); + json_add_string(item, "path", child_relative); + cJSON_AddBoolToObject(item, "is_dir", S_ISDIR(st.st_mode)); + cJSON_AddNumberToObject(item, "size", (double)st.st_size); + cJSON_AddItemToArray(entries, item); + } + + closedir(dir); + + char *payload = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!payload) { + httpd_resp_send_500(req); + return ESP_ERR_NO_MEM; + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0"); + esp_err_t err = httpd_resp_sendstr(req, payload); + free(payload); + return err; +} + +static esp_err_t file_download_handler(httpd_req_t *req) +{ + const char *relative_path = req->uri + strlen("/files"); + if (!path_is_safe(relative_path)) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path"); + return ESP_ERR_INVALID_ARG; + } + + char full_path[CONFIG_HTTP_PATH_MAX]; + if (resolve_storage_path(relative_path, full_path, sizeof(full_path)) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path"); + return ESP_ERR_INVALID_ARG; + } + + struct stat st = {0}; + if (stat(full_path, &st) != 0 || S_ISDIR(st.st_mode)) { + httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found"); + return ESP_FAIL; + } + + FILE *file = fopen(full_path, "rb"); + if (!file) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to open file"); + return ESP_FAIL; + } + + httpd_resp_set_type(req, "application/octet-stream"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0"); + while (!feof(file)) { + size_t read_bytes = fread(s_ctx.scratch, 1, sizeof(s_ctx.scratch), file); + if (read_bytes > 0 && httpd_resp_send_chunk(req, s_ctx.scratch, read_bytes) != ESP_OK) { + fclose(file); + return ESP_FAIL; + } + } + + fclose(file); + return httpd_resp_send_chunk(req, NULL, 0); +} + +static esp_err_t files_upload_handler(httpd_req_t *req) +{ + char relative_path[CONFIG_HTTP_PATH_MAX] = {0}; + if (query_get(req, "path", relative_path, sizeof(relative_path)) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing path"); + return ESP_ERR_INVALID_ARG; + } + + if (req->content_len <= 0 || req->content_len > CONFIG_HTTP_UPLOAD_MAX_SIZE) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid upload size"); + return ESP_ERR_INVALID_SIZE; + } + + char full_path[CONFIG_HTTP_PATH_MAX]; + if (resolve_storage_path(relative_path, full_path, sizeof(full_path)) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path"); + return ESP_ERR_INVALID_ARG; + } + + char parent_path[CONFIG_HTTP_PATH_MAX]; + strlcpy(parent_path, full_path, sizeof(parent_path)); + char *slash = strrchr(parent_path, '/'); + if (!slash || slash == parent_path) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path"); + return ESP_ERR_INVALID_ARG; + } + *slash = '\0'; + + struct stat st = {0}; + if (stat(parent_path, &st) != 0 || !S_ISDIR(st.st_mode)) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Parent directory not found"); + return ESP_ERR_INVALID_STATE; + } + + FILE *file = fopen(full_path, "wb"); + if (!file) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to create file"); + return ESP_FAIL; + } + + int remaining = req->content_len; + while (remaining > 0) { + int chunk = remaining > (int)sizeof(s_ctx.scratch) ? (int)sizeof(s_ctx.scratch) : remaining; + int received = httpd_req_recv(req, s_ctx.scratch, chunk); + if (received <= 0) { + fclose(file); + unlink(full_path); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Upload failed"); + return ESP_FAIL; + } + + if (fwrite(s_ctx.scratch, 1, received, file) != (size_t)received) { + fclose(file); + unlink(full_path); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Write failed"); + return ESP_FAIL; + } + + remaining -= received; + } + + fclose(file); + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0"); + return httpd_resp_sendstr(req, "{\"ok\":true}"); +} + +static esp_err_t files_delete_handler(httpd_req_t *req) +{ + char relative_path[CONFIG_HTTP_PATH_MAX] = {0}; + if (query_get(req, "path", relative_path, sizeof(relative_path)) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing path"); + return ESP_ERR_INVALID_ARG; + } + + char full_path[CONFIG_HTTP_PATH_MAX]; + if (resolve_storage_path(relative_path, full_path, sizeof(full_path)) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path"); + return ESP_ERR_INVALID_ARG; + } + + struct stat st = {0}; + if (stat(full_path, &st) != 0) { + httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Path not found"); + return ESP_ERR_NOT_FOUND; + } + + int rc = S_ISDIR(st.st_mode) ? rmdir(full_path) : unlink(full_path); + if (rc != 0) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Delete failed"); + return ESP_FAIL; + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0"); + return httpd_resp_sendstr(req, "{\"ok\":true}"); +} + +static esp_err_t files_mkdir_handler(httpd_req_t *req) +{ + cJSON *root = NULL; + if (parse_json_body(req, &root) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON body"); + return ESP_ERR_INVALID_ARG; + } + + cJSON *path_item = cJSON_GetObjectItemCaseSensitive(root, "path"); + if (!cJSON_IsString(path_item) || !path_is_safe(path_item->valuestring)) { + cJSON_Delete(root); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path"); + return ESP_ERR_INVALID_ARG; + } + + char full_path[CONFIG_HTTP_PATH_MAX]; + esp_err_t err = resolve_storage_path(path_item->valuestring, full_path, sizeof(full_path)); + cJSON_Delete(root); + if (err != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path"); + return err; + } + + if (mkdir(full_path, 0775) != 0 && errno != EEXIST) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to create directory"); + return ESP_FAIL; + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0"); + return httpd_resp_sendstr(req, "{\"ok\":true}"); +} + +esp_err_t config_http_server_init(const char *storage_base_path) +{ + if (!storage_base_path || storage_base_path[0] != '/') { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(s_ctx.storage_base_path, storage_base_path, sizeof(s_ctx.storage_base_path)); + return ESP_OK; +} + +esp_err_t config_http_server_start(void) +{ + if (s_ctx.server) { + return ESP_OK; + } + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = BASIC_DEMO_HTTP_SERVER_PORT; + config.ctrl_port = CONFIG_HTTP_CTRL_PORT; + config.max_uri_handlers = 16; + config.stack_size = 8192; + config.uri_match_fn = httpd_uri_match_wildcard; + + ESP_RETURN_ON_ERROR(httpd_start(&s_ctx.server, &config), TAG, "Failed to start HTTP server"); + + httpd_uri_t handlers[] = { + { .uri = "/", .method = HTTP_GET, .handler = index_handler }, + { .uri = "/index.html", .method = HTTP_GET, .handler = index_handler }, + { .uri = "/styles.css", .method = HTTP_GET, .handler = styles_handler }, + { .uri = "/app.js", .method = HTTP_GET, .handler = app_js_handler }, + { .uri = "/api/status", .method = HTTP_GET, .handler = status_handler }, + { .uri = "/api/config", .method = HTTP_GET, .handler = config_get_handler }, + { .uri = "/api/config", .method = HTTP_POST, .handler = config_post_handler }, + { .uri = "/api/wechat/login/start", .method = HTTP_POST, .handler = wechat_login_start_handler }, + { .uri = "/api/wechat/login/status", .method = HTTP_GET, .handler = wechat_login_status_handler }, + { .uri = "/api/wechat/login/cancel", .method = HTTP_POST, .handler = wechat_login_cancel_handler }, + { .uri = "/api/files", .method = HTTP_GET, .handler = files_list_handler }, + { .uri = "/api/files", .method = HTTP_DELETE, .handler = files_delete_handler }, + { .uri = "/api/files/upload", .method = HTTP_POST, .handler = files_upload_handler }, + { .uri = "/api/files/mkdir", .method = HTTP_POST, .handler = files_mkdir_handler }, + { .uri = "/files/*", .method = HTTP_GET, .handler = file_download_handler }, + }; + + for (size_t i = 0; i < sizeof(handlers) / sizeof(handlers[0]); ++i) { + ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_ctx.server, &handlers[i]), + TAG, + "Failed to register URI handler"); + } + + ESP_LOGI(TAG, "HTTP server started on port %d", BASIC_DEMO_HTTP_SERVER_PORT); + return ESP_OK; +} + +esp_err_t config_http_server_stop(void) +{ + if (!s_ctx.server) { + return ESP_OK; + } + + ESP_RETURN_ON_ERROR(httpd_stop(s_ctx.server), TAG, "Failed to stop HTTP server"); + s_ctx.server = NULL; + return ESP_OK; +} diff --git a/application/basic_demo/main/config_http_server.h b/application/basic_demo/main/config_http_server.h new file mode 100644 index 0000000..09497ed --- /dev/null +++ b/application/basic_demo/main/config_http_server.h @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "esp_err.h" + +#ifndef BASIC_DEMO_HTTP_SERVER_PORT +#define BASIC_DEMO_HTTP_SERVER_PORT 80 +#endif + +esp_err_t config_http_server_init(const char *storage_base_path); +esp_err_t config_http_server_start(void); +esp_err_t config_http_server_stop(void); diff --git a/application/basic_demo/main/idf_component.yml b/application/basic_demo/main/idf_component.yml new file mode 100644 index 0000000..48d23d0 --- /dev/null +++ b/application/basic_demo/main/idf_component.yml @@ -0,0 +1,3 @@ +## IDF Component Manager Manifest File +dependencies: + espressif/led_strip: ^3.0.3 diff --git a/application/basic_demo/main/lua_module/lua_module_gpio.c b/application/basic_demo/main/lua_module/lua_module_gpio.c new file mode 100644 index 0000000..abb3cf1 --- /dev/null +++ b/application/basic_demo/main/lua_module/lua_module_gpio.c @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "lua_module/lua_module_gpio.h" + +#include + +#include "driver/gpio.h" +#include "esp_err.h" +#include "lauxlib.h" + +static gpio_mode_t gpio_mode_from_string(const char *mode) +{ + if (!mode || strcmp(mode, "input") == 0) { + return GPIO_MODE_INPUT; + } + if (strcmp(mode, "output") == 0) { + return GPIO_MODE_OUTPUT; + } + if (strcmp(mode, "input_output") == 0) { + return GPIO_MODE_INPUT_OUTPUT; + } + if (strcmp(mode, "output_od") == 0) { + return GPIO_MODE_OUTPUT_OD; + } + if (strcmp(mode, "input_output_od") == 0) { + return GPIO_MODE_INPUT_OUTPUT_OD; + } + return GPIO_MODE_DISABLE; +} + +static int lua_gpio_set_direction(lua_State *L) +{ + gpio_num_t pin = (gpio_num_t)luaL_checkinteger(L, 1); + const char *mode_str = luaL_checkstring(L, 2); + gpio_mode_t mode = gpio_mode_from_string(mode_str); + + if (mode == GPIO_MODE_DISABLE && strcmp(mode_str, "disable") != 0) { + return luaL_error(L, "invalid gpio mode: %s", mode_str); + } + + if (gpio_set_direction(pin, mode) != ESP_OK) { + return luaL_error(L, "gpio_set_direction failed"); + } + + return 0; +} + +static int lua_gpio_set_level(lua_State *L) +{ + gpio_num_t pin = (gpio_num_t)luaL_checkinteger(L, 1); + uint32_t level = (uint32_t)luaL_checkinteger(L, 2); + + if (gpio_set_level(pin, level ? 1 : 0) != ESP_OK) { + return luaL_error(L, "gpio_set_level failed"); + } + + return 0; +} + +static int lua_gpio_get_level(lua_State *L) +{ + gpio_num_t pin = (gpio_num_t)luaL_checkinteger(L, 1); + + lua_pushinteger(L, gpio_get_level(pin)); + return 1; +} + +int luaopen_gpio(lua_State *L) +{ + lua_newtable(L); + lua_pushcfunction(L, lua_gpio_set_direction); + lua_setfield(L, -2, "set_direction"); + lua_pushcfunction(L, lua_gpio_set_level); + lua_setfield(L, -2, "set_level"); + lua_pushcfunction(L, lua_gpio_get_level); + lua_setfield(L, -2, "get_level"); + return 1; +} diff --git a/application/basic_demo/main/lua_module/lua_module_gpio.h b/application/basic_demo/main/lua_module/lua_module_gpio.h new file mode 100644 index 0000000..0a9cf5c --- /dev/null +++ b/application/basic_demo/main/lua_module/lua_module_gpio.h @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "lua.h" + +#ifdef __cplusplus +extern "C" { +#endif + +int luaopen_gpio(lua_State *L); + +#ifdef __cplusplus +} +#endif diff --git a/application/basic_demo/main/lua_module/lua_module_led_strip.c b/application/basic_demo/main/lua_module/lua_module_led_strip.c new file mode 100644 index 0000000..0c06c48 --- /dev/null +++ b/application/basic_demo/main/lua_module/lua_module_led_strip.c @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "lua_module_led_strip.h" + +#include +#include "esp_err.h" +#include "lauxlib.h" +#include "led_strip.h" +#include "led_strip_rmt.h" +#include "led_strip_types.h" + +#define LUA_LED_STRIP_METATABLE "led_strip" +#define LUA_LED_STRIP_RMT_RES_HZ (10 * 1000 * 1000) + +typedef struct { + led_strip_handle_t strip; +} lua_led_strip_ud_t; + +static lua_led_strip_ud_t *lua_led_strip_get_ud(lua_State *L, int idx) +{ + lua_led_strip_ud_t *ud = (lua_led_strip_ud_t *)luaL_checkudata(L, idx, LUA_LED_STRIP_METATABLE); + if (!ud || !ud->strip) { + luaL_error(L, "led_strip: invalid or closed handle"); + } + return ud; +} + +static int lua_led_strip_gc(lua_State *L) +{ + lua_led_strip_ud_t *ud = (lua_led_strip_ud_t *)luaL_testudata(L, 1, LUA_LED_STRIP_METATABLE); + if (ud && ud->strip) { + led_strip_del(ud->strip); + ud->strip = NULL; + } + return 0; +} + +static int lua_led_strip_set_pixel(lua_State *L) +{ + lua_led_strip_ud_t *ud = lua_led_strip_get_ud(L, 1); + uint32_t index = (uint32_t)luaL_checkinteger(L, 2); + uint32_t r = (uint32_t)luaL_checkinteger(L, 3); + uint32_t g = (uint32_t)luaL_checkinteger(L, 4); + uint32_t b = (uint32_t)luaL_checkinteger(L, 5); + esp_err_t err = led_strip_set_pixel(ud->strip, index, r, g, b); + if (err != ESP_OK) { + return luaL_error(L, "led_strip set_pixel failed: %s", esp_err_to_name(err)); + } + return 0; +} + +static int lua_led_strip_refresh(lua_State *L) +{ + lua_led_strip_ud_t *ud = lua_led_strip_get_ud(L, 1); + esp_err_t err = led_strip_refresh(ud->strip); + if (err != ESP_OK) { + return luaL_error(L, "led_strip refresh failed: %s", esp_err_to_name(err)); + } + return 0; +} + +static int lua_led_strip_clear(lua_State *L) +{ + lua_led_strip_ud_t *ud = lua_led_strip_get_ud(L, 1); + esp_err_t err = led_strip_clear(ud->strip); + if (err != ESP_OK) { + return luaL_error(L, "led_strip clear failed: %s", esp_err_to_name(err)); + } + return 0; +} + +static int lua_led_strip_close(lua_State *L) +{ + lua_led_strip_ud_t *ud = (lua_led_strip_ud_t *)luaL_checkudata(L, 1, LUA_LED_STRIP_METATABLE); + if (ud->strip) { + led_strip_del(ud->strip); + ud->strip = NULL; + } + return 0; +} + +static int lua_led_strip_new(lua_State *L) +{ + int gpio = (int)luaL_checkinteger(L, 1); + int max_leds = (int)luaL_checkinteger(L, 2); + if (max_leds <= 0) { + return luaL_error(L, "max_leds must be positive"); + } + + led_strip_config_t strip_config = { + .strip_gpio_num = gpio, + .max_leds = (uint32_t)max_leds, + .led_model = LED_MODEL_WS2812, + .color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB, + .flags = { + .invert_out = false, + } + }; + led_strip_rmt_config_t rmt_config = { + .clk_src = RMT_CLK_SRC_DEFAULT, + .resolution_hz = LUA_LED_STRIP_RMT_RES_HZ, + .mem_block_symbols = 0, + .flags = { + .with_dma = 0, + } + }; + + led_strip_handle_t strip = NULL; + esp_err_t err = led_strip_new_rmt_device(&strip_config, &rmt_config, &strip); + if (err != ESP_OK) { + return luaL_error(L, "led_strip new failed: %s", esp_err_to_name(err)); + } + + lua_led_strip_ud_t *ud = (lua_led_strip_ud_t *)lua_newuserdata(L, sizeof(*ud)); + ud->strip = strip; + luaL_getmetatable(L, LUA_LED_STRIP_METATABLE); + lua_setmetatable(L, -2); + return 1; +} + +int luaopen_led_strip(lua_State *L) +{ + if (luaL_newmetatable(L, LUA_LED_STRIP_METATABLE)) { + lua_pushcfunction(L, lua_led_strip_gc); + lua_setfield(L, -2, "__gc"); + lua_pushvalue(L, -1); + lua_setfield(L, -2, "__index"); + lua_pushcfunction(L, lua_led_strip_set_pixel); + lua_setfield(L, -2, "set_pixel"); + lua_pushcfunction(L, lua_led_strip_refresh); + lua_setfield(L, -2, "refresh"); + lua_pushcfunction(L, lua_led_strip_clear); + lua_setfield(L, -2, "clear"); + lua_pushcfunction(L, lua_led_strip_close); + lua_setfield(L, -2, "close"); + } + lua_pop(L, 1); + + lua_newtable(L); + lua_pushcfunction(L, lua_led_strip_new); + lua_setfield(L, -2, "new"); + return 1; +} diff --git a/application/basic_demo/main/lua_module/lua_module_led_strip.h b/application/basic_demo/main/lua_module/lua_module_led_strip.h new file mode 100644 index 0000000..00372d5 --- /dev/null +++ b/application/basic_demo/main/lua_module/lua_module_led_strip.h @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "lua.h" + +int luaopen_led_strip(lua_State *L); diff --git a/application/basic_demo/main/main.c b/application/basic_demo/main/main.c new file mode 100644 index 0000000..1024329 --- /dev/null +++ b/application/basic_demo/main/main.c @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "app_clawgent.h" +#include "basic_demo_settings.h" +#include "basic_demo_wifi.h" +#include "config_http_server.h" +#include "esp_err.h" +#include "esp_log.h" +#include "esp_vfs_fat.h" +#include "freertos/task.h" +#include "nvs_flash.h" +#include "wear_levelling.h" + +static const char *TAG = "basic_demo"; +static basic_demo_settings_t s_settings = {0}; + +#define BASIC_DEMO_FATFS_BASE_PATH "/fatfs/data" +#define BASIC_DEMO_FATFS_PARTITION_LABEL "storage" + +static wl_handle_t s_wl_handle = WL_INVALID_HANDLE; +static TaskStatus_t s_task_status_snapshot[24]; + +static esp_err_t init_nvs(void) +{ + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + return err; +} + +static esp_err_t init_fatfs(void) +{ + esp_vfs_fat_mount_config_t mount_config = { + .format_if_mount_failed = true, + .max_files = 8, + .allocation_unit_size = 4096, + .disk_status_check_enable = false, + .use_one_fat = false, + }; + uint64_t total = 0; + uint64_t free_bytes = 0; + esp_err_t err; + + err = esp_vfs_fat_spiflash_mount_rw_wl(BASIC_DEMO_FATFS_BASE_PATH, + BASIC_DEMO_FATFS_PARTITION_LABEL, + &mount_config, + &s_wl_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to mount FATFS: %s", esp_err_to_name(err)); + return err; + } + + err = esp_vfs_fat_info(BASIC_DEMO_FATFS_BASE_PATH, &total, &free_bytes); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to query FATFS info: %s", esp_err_to_name(err)); + } else { + ESP_LOGI(TAG, "FATFS mounted total=%u used=%u", + (unsigned int)total, + (unsigned int)(total - free_bytes)); + } + + return ESP_OK; +} + +static void print_task_stack_info(void) +{ + UBaseType_t count = uxTaskGetSystemState(s_task_status_snapshot, + sizeof(s_task_status_snapshot) / sizeof(s_task_status_snapshot[0]), + NULL); + + for (UBaseType_t i = 0; i < count; i++) { + ESP_LOGI(TAG, + "Task %s %u", + s_task_status_snapshot[i].pcTaskName, + s_task_status_snapshot[i].usStackHighWaterMark); + } +} + +/* Periodic task: print internal free, minimum free, and PSRAM free every 20s */ +static void memory_monitor_task(void *arg) +{ + (void)arg; + while (1) { + vTaskDelay(pdMS_TO_TICKS(20000)); + size_t internal_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + size_t internal_min = heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL); + size_t psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + ESP_LOGI(TAG, "Memory: internal_free=%u bytes, internal_min_free=%u bytes, psram_free=%u bytes", + (unsigned)internal_free, (unsigned)internal_min, (unsigned)psram_free); + // print_task_stack_info(); + } +} + +void app_main(void) +{ + esp_log_level_set("esp-x509-crt-bundle", ESP_LOG_WARN); + + ESP_LOGI(TAG, "Starting basic_demo"); + ESP_ERROR_CHECK(init_nvs()); + ESP_ERROR_CHECK(basic_demo_settings_init()); + ESP_ERROR_CHECK(basic_demo_settings_load(&s_settings)); + ESP_ERROR_CHECK(init_fatfs()); + ESP_ERROR_CHECK(basic_demo_wifi_init()); + ESP_ERROR_CHECK(config_http_server_init(BASIC_DEMO_FATFS_BASE_PATH)); + + if (basic_demo_wifi_start(s_settings.wifi_ssid, s_settings.wifi_password) == ESP_OK) { + ESP_ERROR_CHECK(config_http_server_start()); + if (basic_demo_wifi_wait_connected(30000) == ESP_OK) { + ESP_LOGI(TAG, "Wi-Fi ready: %s", basic_demo_wifi_get_ip()); + } else { + ESP_LOGW(TAG, "Wi-Fi connection timed out"); + } + } else { + ESP_LOGW(TAG, "Continuing without Wi-Fi"); + } + + ESP_ERROR_CHECK(app_clawgent_start(&s_settings)); + + /* Start memory monitor: print internal free, min free, PSRAM free every 20s */ + // xTaskCreate(memory_monitor_task, "mem_mon", 8192, NULL, 1, NULL); +} diff --git a/application/basic_demo/main/web/app.js b/application/basic_demo/main/web/app.js new file mode 100644 index 0000000..fa8c585 --- /dev/null +++ b/application/basic_demo/main/web/app.js @@ -0,0 +1,446 @@ +const configFields = [ + "wifi_ssid", + "wifi_password", + "llm_api_key", + "llm_backend_type", + "llm_profile", + "llm_model", + "llm_base_url", + "llm_auth_type", + "llm_timeout_ms", + "qq_app_id", + "qq_app_secret", + "tg_bot_token", + "wechat_token", + "wechat_base_url", + "wechat_cdn_base_url", + "wechat_account_id", + "search_brave_key", + "search_tavily_key", + "lua_base_dir", + "time_timezone", +]; + +let currentPath = "/"; +let wechatLoginPollTimer = null; + +function showBanner(id, message, isError = false) { + const banner = document.getElementById(id); + banner.textContent = message; + banner.classList.remove("hidden", "error"); + if (isError) { + banner.classList.add("error"); + } +} + +function hideBanner(id) { + const banner = document.getElementById(id); + banner.classList.add("hidden"); + banner.classList.remove("error"); +} + +function readConfigForm() { + const payload = {}; + configFields.forEach((field) => { + const input = document.getElementById(field); + payload[field] = input ? input.value.trim() : ""; + }); + return payload; +} + +function fillConfigForm(data) { + configFields.forEach((field) => { + const input = document.getElementById(field); + if (input && typeof data[field] === "string") { + input.value = data[field]; + } + }); +} + +function humanSize(value) { + if (value < 1024) { + return `${value} B`; + } + if (value < 1024 * 1024) { + return `${(value / 1024).toFixed(1)} KB`; + } + return `${(value / (1024 * 1024)).toFixed(1)} MB`; +} + +function parentPath(path) { + if (path === "/") { + return "/"; + } + const parts = path.split("/").filter(Boolean); + parts.pop(); + return parts.length ? `/${parts.join("/")}` : "/"; +} + +function joinPath(base, name) { + return base === "/" ? `/${name}` : `${base}/${name}`; +} + +async function loadStatus() { + const response = await fetch("/api/status", { cache: "no-store" }); + const data = await response.json(); + document.getElementById("wifiStatus").textContent = data.wifi_connected ? "Wi-Fi connected" : "Wi-Fi offline"; + document.getElementById("ipAddress").textContent = `IP: ${data.ip || "-"}`; + document.getElementById("storagePath").textContent = `Storage: ${data.storage_base_path || "-"}`; +} + +async function loadConfig() { + hideBanner("configMessage"); + const response = await fetch("/api/config", { cache: "no-store" }); + if (!response.ok) { + throw new Error("Failed to load settings"); + } + fillConfigForm(await response.json()); +} + +async function saveConfig() { + const button = document.getElementById("saveConfigButton"); + button.disabled = true; + hideBanner("configMessage"); + + try { + const response = await fetch("/api/config", { + method: "POST", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(readConfigForm()), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error || "Failed to save settings"); + } + showBanner("configMessage", result.message || "Settings saved"); + } catch (error) { + showBanner("configMessage", error.message, true); + } finally { + button.disabled = false; + } +} + +function stopWechatLoginPolling() { + if (wechatLoginPollTimer) { + clearTimeout(wechatLoginPollTimer); + wechatLoginPollTimer = null; + } +} + +function renderWechatLoginStatus(data) { + const qrImage = document.getElementById("wechatLoginQr"); + const qrLink = document.getElementById("wechatLoginQrLink"); + const meta = document.getElementById("wechatLoginMeta"); + + if (data.qr_data_url) { + qrImage.src = `https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=${encodeURIComponent(data.qr_data_url)}`; + qrImage.classList.remove("hidden"); + qrLink.href = data.qr_data_url; + qrLink.textContent = data.qr_data_url; + qrLink.classList.remove("hidden"); + } else { + qrImage.removeAttribute("src"); + qrImage.classList.add("hidden"); + qrLink.removeAttribute("href"); + qrLink.textContent = ""; + qrLink.classList.add("hidden"); + } + + meta.textContent = data.status ? `Status: ${data.status}` : "Status: idle"; + if (data.message) { + showBanner("wechatLoginMessage", data.message, false); + } else { + hideBanner("wechatLoginMessage"); + } + + if (data.completed && data.persisted) { + document.getElementById("wechat_token").value = ""; + if (typeof data.base_url === "string" && data.base_url) { + document.getElementById("wechat_base_url").value = data.base_url; + } + if (typeof data.account_id === "string" && data.account_id) { + document.getElementById("wechat_account_id").value = data.account_id; + } + } +} + +async function pollWechatLoginStatus() { + try { + const response = await fetch("/api/wechat/login/status", { cache: "no-store" }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || "Failed to fetch WeChat login status"); + } + renderWechatLoginStatus(data); + if (data.active || (data.completed && !data.persisted)) { + wechatLoginPollTimer = setTimeout(pollWechatLoginStatus, 1500); + } else { + stopWechatLoginPolling(); + } + } catch (error) { + showBanner("wechatLoginMessage", error.message || "Failed to poll WeChat login status", true); + stopWechatLoginPolling(); + } +} + +async function startWechatLogin() { + const button = document.getElementById("wechatLoginStartButton"); + button.disabled = true; + hideBanner("wechatLoginMessage"); + + try { + const response = await fetch("/api/wechat/login/start", { + method: "POST", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + account_id: document.getElementById("wechat_account_id").value.trim(), + force: true, + }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || "Failed to start WeChat login"); + } + renderWechatLoginStatus(data); + stopWechatLoginPolling(); + wechatLoginPollTimer = setTimeout(pollWechatLoginStatus, 1000); + } catch (error) { + showBanner("wechatLoginMessage", error.message || "Failed to start WeChat login", true); + } finally { + button.disabled = false; + } +} + +async function cancelWechatLogin() { + try { + const response = await fetch("/api/wechat/login/cancel", { + method: "POST", + cache: "no-store", + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || "Failed to cancel WeChat login"); + } + renderWechatLoginStatus({ + status: "cancelled", + message: data.message || "已取消微信登录。", + qr_data_url: "", + completed: false, + persisted: false, + }); + stopWechatLoginPolling(); + } catch (error) { + showBanner("wechatLoginMessage", error.message || "Failed to cancel WeChat login", true); + } +} + +function renderFileRows(entries) { + const tbody = document.getElementById("fileTableBody"); + tbody.innerHTML = ""; + + if (!entries.length) { + const row = document.createElement("tr"); + row.innerHTML = "This folder is empty."; + tbody.appendChild(row); + return; + } + + entries + .sort((a, b) => Number(b.is_dir) - Number(a.is_dir) || a.name.localeCompare(b.name)) + .forEach((entry) => { + const row = document.createElement("tr"); + const typeLabel = entry.is_dir ? "Folder" : "File"; + const sizeLabel = entry.is_dir ? "-" : humanSize(entry.size || 0); + row.innerHTML = ` + ${entry.name} + ${typeLabel} + ${sizeLabel} + + `; + + const actions = row.querySelector(".actions"); + + if (entry.is_dir) { + const openButton = document.createElement("button"); + openButton.className = "link-button"; + openButton.textContent = "Open"; + openButton.onclick = () => { + currentPath = entry.path; + loadFiles().catch((error) => showBanner("fileMessage", error.message, true)); + }; + actions.appendChild(openButton); + } else { + const download = document.createElement("a"); + download.href = `/files${entry.path}`; + download.textContent = "Download"; + download.className = "link-button"; + download.target = "_blank"; + actions.appendChild(download); + } + + const deleteButton = document.createElement("button"); + deleteButton.className = "link-button"; + deleteButton.textContent = "Delete"; + deleteButton.onclick = async () => { + if (!window.confirm(`Delete ${entry.path}?`)) { + return; + } + await deletePath(entry.path); + }; + actions.appendChild(deleteButton); + + tbody.appendChild(row); + }); +} + +async function loadFiles() { + hideBanner("fileMessage"); + document.getElementById("currentPath").textContent = currentPath; + + const response = await fetch(`/api/files?path=${encodeURIComponent(currentPath)}`, { cache: "no-store" }); + if (!response.ok) { + throw new Error(await response.text() || "Failed to load file list"); + } + + const data = await response.json(); + currentPath = data.path || "/"; + document.getElementById("currentPath").textContent = currentPath; + renderFileRows(data.entries || []); +} + +async function uploadFile() { + const pathInput = document.getElementById("uploadPathInput"); + const fileInput = document.getElementById("uploadFileInput"); + const button = document.getElementById("uploadButton"); + const file = fileInput.files[0]; + const relativePath = pathInput.value.trim() || (file ? joinPath(currentPath, file.name) : ""); + + if (!file || !relativePath.startsWith("/")) { + showBanner("fileMessage", "Select a file and provide a target path that starts with /.", true); + return; + } + + button.disabled = true; + hideBanner("fileMessage"); + try { + const response = await fetch(`/api/files/upload?path=${encodeURIComponent(relativePath)}`, { + method: "POST", + cache: "no-store", + body: file, + }); + if (!response.ok) { + throw new Error(await response.text()); + } + pathInput.value = ""; + fileInput.value = ""; + showBanner("fileMessage", "Upload completed"); + await loadFiles(); + } catch (error) { + showBanner("fileMessage", error.message || "Upload failed", true); + } finally { + button.disabled = false; + } +} + +async function createFolder() { + const input = document.getElementById("newFolderInput"); + const name = input.value.trim(); + if (!name) { + showBanner("fileMessage", "Enter a folder name.", true); + return; + } + + try { + const response = await fetch("/api/files/mkdir", { + method: "POST", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: joinPath(currentPath, name) }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + input.value = ""; + showBanner("fileMessage", "Folder created"); + await loadFiles(); + } catch (error) { + showBanner("fileMessage", error.message || "Failed to create folder", true); + } +} + +async function deletePath(path) { + try { + const response = await fetch(`/api/files?path=${encodeURIComponent(path)}`, { + method: "DELETE", + cache: "no-store", + }); + if (!response.ok) { + throw new Error(await response.text()); + } + showBanner("fileMessage", "Delete completed"); + await loadFiles(); + } catch (error) { + showBanner("fileMessage", error.message || "Delete failed", true); + } +} + +function bindEvents() { + document.getElementById("saveConfigButton").addEventListener("click", saveConfig); + document.getElementById("wechatLoginStartButton").addEventListener("click", startWechatLogin); + document.getElementById("wechatLoginCancelButton").addEventListener("click", cancelWechatLogin); + document.getElementById("refreshFilesButton").addEventListener("click", () => loadFiles().catch((error) => { + showBanner("fileMessage", error.message, true); + })); + document.getElementById("upDirButton").addEventListener("click", () => { + currentPath = parentPath(currentPath); + loadFiles().catch((error) => showBanner("fileMessage", error.message, true)); + }); + document.getElementById("uploadButton").addEventListener("click", uploadFile); + document.getElementById("chooseFileButton").addEventListener("click", () => { + document.getElementById("uploadFileInput").click(); + }); + document.getElementById("createFolderButton").addEventListener("click", createFolder); + document.getElementById("uploadFileInput").addEventListener("change", (event) => { + const file = event.target.files[0]; + const selectedFileName = document.getElementById("selectedFileName"); + if (file) { + document.getElementById("uploadPathInput").value = joinPath(currentPath, file.name); + selectedFileName.textContent = file.name; + } else { + selectedFileName.textContent = "No file selected"; + } + }); +} + +async function bootstrap() { + bindEvents(); + try { + await loadStatus(); + } catch (error) { + showBanner("configMessage", error.message || "Failed to load device status", true); + } + + try { + await loadConfig(); + } catch (error) { + showBanner("configMessage", error.message || "Failed to load settings", true); + } + + try { + await pollWechatLoginStatus(); + } catch (error) { + showBanner("wechatLoginMessage", error.message || "Failed to load WeChat login status", true); + } + + try { + await loadFiles(); + } catch (error) { + showBanner("fileMessage", error.message || "Failed to load file list", true); + } +} + +bootstrap().catch((error) => { + showBanner("configMessage", error.message || "Failed to initialize the page", true); +}); diff --git a/application/basic_demo/main/web/index.html b/application/basic_demo/main/web/index.html new file mode 100644 index 0000000..d8e7b37 --- /dev/null +++ b/application/basic_demo/main/web/index.html @@ -0,0 +1,182 @@ + + + + + + Clawgent Settings + + + +
+
+
+

ESP Clawgent

+

Settings and file management

+

A clean control surface for credentials, runtime settings, and FATFS storage on your ESP device.

+
+
+

Device status

+

Loading

+

IP: -

+

Storage: -

+
+
+ +
+
+
+
+

Configuration

+

Credentials and runtime

+
+ +
+ +
+ + + + + + + + + + + + + + + + +
+
+
+

WeChat Login

+

QR code sign-in

+
+
+ + +
+
+ +

Status: idle

+ +

+

Generate a QR code here to obtain `wechat_token` automatically. The token is saved to NVS after confirmation. Restart the device to apply the new login.

+
+ + + + +
+

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

+
+ +
+
+
+

Files

+

FATFS file manager

+
+ +
+ +
+ + / +
+
+ + +
+
+ +
+ + + No file selected +
+ +
+
+ + + + + + + + + + +
NameTypeSizeActions
+
+
+
+
+ + + + diff --git a/application/basic_demo/main/web/styles.css b/application/basic_demo/main/web/styles.css new file mode 100644 index 0000000..dcc2d43 --- /dev/null +++ b/application/basic_demo/main/web/styles.css @@ -0,0 +1,292 @@ +:root { + --bg: #f7f7f2; + --panel: rgba(255, 255, 252, 0.92); + --panel-strong: #ffffff; + --ink: #151515; + --muted: #6b6f67; + --line: rgba(20, 20, 20, 0.08); + --accent: #0f6b4f; + --accent-deep: #0c5a42; + --accent-soft: rgba(15, 107, 79, 0.1); + --success: #0f6b4f; + --danger: #a33c2f; + --shadow: 0 20px 50px rgba(18, 20, 18, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + font-family: "Sohne", "Helvetica Neue", Arial, sans-serif; + background: + radial-gradient(circle at top left, rgba(193, 228, 214, 0.4), transparent 28%), + radial-gradient(circle at bottom right, rgba(226, 231, 221, 0.6), transparent 24%), + linear-gradient(180deg, #fbfbf8, #f3f3ed 58%, #efefe8); +} + +.shell { + width: min(1240px, calc(100% - 32px)); + margin: 24px auto; +} + +.hero, +.panel { + border: 1px solid var(--line); + border-radius: 24px; + background: var(--panel); + backdrop-filter: blur(12px); + box-shadow: var(--shadow); +} + +.hero { + display: grid; + grid-template-columns: 1.6fr 0.9fr; + gap: 24px; + padding: 28px; + margin-bottom: 24px; +} + +.eyebrow, +.panel-kicker { + margin: 0 0 8px; + color: var(--accent); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: clamp(2rem, 4vw, 3rem); + line-height: 1.02; + margin-bottom: 12px; + letter-spacing: -0.04em; +} + +.subtitle { + max-width: 50ch; + color: var(--muted); + line-height: 1.65; +} + +.status-card { + align-self: stretch; + padding: 20px; + border-radius: 18px; + background: linear-gradient(160deg, rgba(22, 28, 24, 0.94), rgba(34, 46, 40, 0.9)); + color: #f8fcf9; +} + +.status-label { + color: rgba(255, 247, 239, 0.7); + font-size: 0.8rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.status-value { + margin-top: 14px; + font-size: 1.8rem; + font-weight: 700; +} + +.status-meta { + margin-top: 10px; + color: rgba(255, 247, 239, 0.82); +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; +} + +.panel { + padding: 24px; +} + +.panel-head, +.toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; +} + +.panel-head { + margin-bottom: 18px; +} + +.toolbar { + margin-bottom: 14px; + flex-wrap: wrap; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +label { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 0.95rem; + color: var(--muted); +} + +input, +button { + font: inherit; +} + +input { + width: 100%; + padding: 13px 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.92); + color: var(--ink); +} + +input:focus { + outline: 2px solid rgba(182, 70, 31, 0.22); + border-color: rgba(182, 70, 31, 0.35); +} + +button { + border: none; + border-radius: 999px; + padding: 10px 16px; + cursor: pointer; + transition: transform 140ms ease, opacity 140ms ease, background 140ms ease; +} + +button:hover { + transform: translateY(-1px); +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.primary { + background: linear-gradient(135deg, var(--accent), #17805e); + color: #f7fffb; +} + +.ghost { + background: rgba(20, 20, 20, 0.04); + color: var(--ink); +} + +.banner { + padding: 12px 14px; + margin-bottom: 16px; + border-radius: 14px; + background: rgba(38, 97, 69, 0.12); + color: var(--success); +} + +.banner.error { + background: rgba(145, 47, 47, 0.12); + color: var(--danger); +} + +.hidden { + display: none; +} + +.note { + margin-top: 16px; + color: var(--muted); + line-height: 1.55; +} + +code { + padding: 6px 10px; + border-radius: 10px; + background: rgba(20, 20, 20, 0.05); +} + +.upload-row input[type="file"] { + display: none; +} + +.file-picker { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 9px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.92); +} + +.file-name { + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.table-wrap { + overflow: auto; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.8); +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 14px 16px; + text-align: left; + border-bottom: 1px solid var(--line); +} + +th { + color: var(--muted); + font-size: 0.82rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +td.actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.link-button { + padding: 0; + background: none; + color: var(--accent-deep); + text-decoration: underline; +} + +@media (max-width: 960px) { + .hero, + .grid, + .form-grid { + grid-template-columns: 1fr; + } +} diff --git a/application/basic_demo/partitions.csv b/application/basic_demo/partitions.csv new file mode 100644 index 0000000..6cc8b92 --- /dev/null +++ b/application/basic_demo/partitions.csv @@ -0,0 +1,8 @@ +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x6000 +otadata, data, ota, 0xF000, 0x2000 +phy_init, data, phy, 0x11000, 0x1000 +ota_0, app, ota_0, 0x20000, 0x200000 +ota_1, app, ota_1, 0x220000, 0x200000 +storage, data, fat, 0x420000, 0xBD0000 +coredump, data, coredump,0xFF0000, 0x10000 diff --git a/application/basic_demo/sdkconfig.defaults b/application/basic_demo/sdkconfig.defaults new file mode 100644 index 0000000..8781e58 --- /dev/null +++ b/application/basic_demo/sdkconfig.defaults @@ -0,0 +1,32 @@ +# This file was generated using idf.py save-defconfig. It can be edited manually. +# Espressif IoT Development Framework (ESP-IDF) 5.5.1 Project Minimal Configuration +# +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHMODE_QIO=y +CONFIG_ESPTOOLPY_FLASHFREQ_120M=y +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_HTTPD_WS_SUPPORT=y +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_120M=y +CONFIG_SPIRAM_MEMTEST=n +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=2048 +CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=98304 +CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY=y +CONFIG_SPIRAM_ALLOW_NOINIT_SEG_EXTERNAL_MEMORY=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=3 +CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=6 +CONFIG_ESP_WIFI_RX_BA_WIN=3 +CONFIG_FATFS_LFN_HEAP=y +CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=2048 +CONFIG_HEAP_TASK_TRACKING=n +CONFIG_LWIP_LOCAL_HOSTNAME="clawgent" +CONFIG_LWIP_MAX_SOCKETS=20 +CONFIG_LWIP_TCPIP_RECVMBOX_SIZE=16 +CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=4 +CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y +CONFIG_MBEDTLS_HARDWARE_AES=n +CONFIG_IDF_EXPERIMENTAL_FEATURES=y diff --git a/components/cap_cli/CMakeLists.txt b/components/cap_cli/CMakeLists.txt new file mode 100644 index 0000000..f74e395 --- /dev/null +++ b/components/cap_cli/CMakeLists.txt @@ -0,0 +1,11 @@ +idf_component_register( + SRCS + "src/cap_cli.c" + INCLUDE_DIRS + "include" + REQUIRES + claw_cap + console + freertos + json +) diff --git a/components/cap_cli/include/cap_cli.h b/components/cap_cli/include/cap_cli.h new file mode 100644 index 0000000..d9754f7 --- /dev/null +++ b/components/cap_cli/include/cap_cli.h @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define CAP_CLI_NAME "run_cli_command" + +typedef struct { + size_t max_commands; + size_t max_output_bytes; +} cap_cli_config_t; + +typedef struct { + const char *command_name; + const char *description; + const char *usage_hint; +} cap_cli_command_t; + +esp_err_t cap_cli_init(const cap_cli_config_t *config); +esp_err_t cap_cli_register_command(const cap_cli_command_t *command); +esp_err_t cap_cli_register_group(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_cli/src/cap_cli.c b/components/cap_cli/src/cap_cli.c new file mode 100644 index 0000000..faca410 --- /dev/null +++ b/components/cap_cli/src/cap_cli.c @@ -0,0 +1,426 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_cli.h" + +#include +#include +#include +#include + +#include "claw_cap.h" +#include "cJSON.h" +#include "esp_console.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +typedef struct { + char *command_name; + char *description; + char *usage_hint; +} cap_cli_entry_t; + +typedef struct { + bool initialized; + size_t capacity; + size_t count; + size_t max_output_bytes; + SemaphoreHandle_t mutex; + cap_cli_entry_t *entries; + char tool_description[1024]; +} cap_cli_state_t; + +static const char *TAG = "cap_cli"; +#define CAP_CLI_INPUT_SCHEMA_JSON \ + "{\"type\":\"object\",\"properties\":{\"command_line\":{\"type\":\"string\"," \ + "\"description\":\"Full ESP console CLI command line to execute. The first token must match an allowed command.\"}}," \ + "\"required\":[\"command_line\"]}" + +static cap_cli_state_t s_cli = {0}; + +static char *cap_cli_strdup(const char *src) +{ + if (!src) { + return NULL; + } + + return strdup(src); +} + +static void cap_cli_free_entry(cap_cli_entry_t *entry) +{ + if (!entry) { + return; + } + + free(entry->command_name); + free(entry->description); + free(entry->usage_hint); + memset(entry, 0, sizeof(*entry)); +} + +static int cap_cli_find_command_locked(const char *command_name) +{ + size_t i; + + if (!command_name || !command_name[0]) { + return -1; + } + + for (i = 0; i < s_cli.count; i++) { + if (s_cli.entries[i].command_name && + strcmp(s_cli.entries[i].command_name, command_name) == 0) { + return (int)i; + } + } + + return -1; +} + +static void cap_cli_refresh_description_locked(void) +{ + size_t i; + size_t len = 0; + + len = strlcpy(s_cli.tool_description, + "Run one allowed ESP console CLI command. Allowed commands:", + sizeof(s_cli.tool_description)); + if (len >= sizeof(s_cli.tool_description)) { + s_cli.tool_description[sizeof(s_cli.tool_description) - 1] = '\0'; + return; + } + + for (i = 0; i < s_cli.count; i++) { + const cap_cli_entry_t *entry = &s_cli.entries[i]; + int written; + + if (!entry->command_name || !entry->command_name[0]) { + continue; + } + + written = snprintf(s_cli.tool_description + len, + sizeof(s_cli.tool_description) - len, + "%s %s%s%s%s%s%s", + i == 0 ? "" : ",", + entry->command_name, + entry->description && entry->description[0] ? " (" : "", + entry->description && entry->description[0] ? entry->description : "", + entry->usage_hint && entry->usage_hint[0] ? "; use: " : "", + entry->usage_hint && entry->usage_hint[0] ? entry->usage_hint : "", + ((entry->description && entry->description[0]) || + (entry->usage_hint && entry->usage_hint[0])) ? ")" : ""); + if (written < 0) { + break; + } + if ((size_t)written >= sizeof(s_cli.tool_description) - len) { + len = sizeof(s_cli.tool_description) - 1; + break; + } + len += (size_t)written; + } + + if (s_cli.count == 0 && len < sizeof(s_cli.tool_description) - 8) { + strlcat(s_cli.tool_description, " (none)", sizeof(s_cli.tool_description)); + } +} + +static esp_err_t cap_cli_extract_command_line(const char *input_json, char **out_command_line) +{ + cJSON *root = NULL; + cJSON *command_line = NULL; + char *copied = NULL; + + if (!input_json || !out_command_line) { + return ESP_ERR_INVALID_ARG; + } + *out_command_line = NULL; + + root = cJSON_Parse(input_json); + if (!root) { + return ESP_ERR_INVALID_ARG; + } + + command_line = cJSON_GetObjectItem(root, "command_line"); + if (!cJSON_IsString(command_line) || !command_line->valuestring[0]) { + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + copied = cap_cli_strdup(command_line->valuestring); + cJSON_Delete(root); + if (!copied) { + return ESP_ERR_NO_MEM; + } + + *out_command_line = copied; + return ESP_OK; +} + +static esp_err_t cap_cli_parse_command_name(const char *command_line, + char *command_name, + size_t command_name_size) +{ + char *line_copy = NULL; + char *argv[8] = {0}; + size_t argc; + esp_err_t err = ESP_OK; + + if (!command_line || !command_line[0] || !command_name || command_name_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + line_copy = cap_cli_strdup(command_line); + if (!line_copy) { + return ESP_ERR_NO_MEM; + } + + argc = esp_console_split_argv(line_copy, argv, sizeof(argv) / sizeof(argv[0])); + if (argc == 0 || !argv[0] || !argv[0][0]) { + err = ESP_ERR_INVALID_ARG; + goto cleanup; + } + + if (strlcpy(command_name, argv[0], command_name_size) >= command_name_size) { + err = ESP_ERR_INVALID_SIZE; + goto cleanup; + } + +cleanup: + free(line_copy); + return err; +} + +static esp_err_t cap_cli_capture_run_locked(const char *command_line, + char **out_stdout_text, + esp_err_t *out_run_err, + int *out_cmd_ret) +{ + FILE *capture = NULL; + FILE *saved_stdout = NULL; + char *buffer = NULL; + size_t buffer_len = 0; + + if (!command_line || !out_stdout_text || !out_run_err || !out_cmd_ret) { + return ESP_ERR_INVALID_ARG; + } + *out_stdout_text = NULL; + + capture = open_memstream(&buffer, &buffer_len); + if (!capture) { + return ESP_FAIL; + } + + fflush(stdout); + saved_stdout = stdout; + stdout = capture; + + *out_run_err = esp_console_run(command_line, out_cmd_ret); + + fflush(stdout); + stdout = saved_stdout; + + if (fclose(capture) != 0) { + free(buffer); + return ESP_FAIL; + } + + if (!buffer) { + buffer = calloc(1, 1); + if (!buffer) { + return ESP_ERR_NO_MEM; + } + } + + *out_stdout_text = buffer; + return ESP_OK; +} + +static esp_err_t cap_cli_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + char *command_line = NULL; + char *stdout_text = NULL; + char command_name[64]; + const char *status; + const char *captured_output; + const char *truncated_suffix = ""; + size_t captured_len; + size_t keep_len; + esp_err_t err; + esp_err_t run_err = ESP_FAIL; + int cmd_ret = -1; + + (void)ctx; + + if (!output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + output[0] = '\0'; + + err = cap_cli_extract_command_line(input_json ? input_json : "{}", &command_line); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: command_line must be a non-empty string"); + return err; + } + + err = cap_cli_parse_command_name(command_line, command_name, sizeof(command_name)); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: failed to parse command_line"); + free(command_line); + return err; + } + + xSemaphoreTake(s_cli.mutex, portMAX_DELAY); + if (cap_cli_find_command_locked(command_name) < 0) { + xSemaphoreGive(s_cli.mutex); + snprintf(output, output_size, "Error: command is not allowed for cap_cli"); + free(command_line); + return ESP_ERR_NOT_FOUND; + } + + err = cap_cli_capture_run_locked(command_line, &stdout_text, &run_err, &cmd_ret); + xSemaphoreGive(s_cli.mutex); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: failed to execute CLI command"); + free(command_line); + return err; + } + + status = (run_err == ESP_OK && cmd_ret == 0) ? "ok" : "error"; + captured_output = (stdout_text && stdout_text[0]) ? stdout_text : "(none)"; + captured_len = strlen(captured_output); + keep_len = captured_len; + if (s_cli.max_output_bytes > 0 && keep_len > s_cli.max_output_bytes) { + keep_len = s_cli.max_output_bytes; + truncated_suffix = "\n[truncated]"; + } + + snprintf(output, + output_size, + "command: %s\nstatus: %s\nesp_err: %s\ncmd_ret: %d\noutput:\n%.*s%s", + command_line, + status, + esp_err_to_name(run_err), + cmd_ret, + (int)keep_len, + captured_output, + truncated_suffix); + + ESP_LOGI(TAG, "command=%s esp_err=%s cmd_ret=%d", command_line, esp_err_to_name(run_err), cmd_ret); + free(stdout_text); + free(command_line); + return run_err == ESP_OK && cmd_ret == 0 ? ESP_OK : (run_err != ESP_OK ? run_err : ESP_FAIL); +} + +static claw_cap_descriptor_t s_cli_descriptors[] = { + { + .id = CAP_CLI_NAME, + .name = CAP_CLI_NAME, + .family = "system", + .description = NULL, + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = CAP_CLI_INPUT_SCHEMA_JSON, + .execute = cap_cli_execute, + }, +}; + +static const claw_cap_group_t s_cli_group = { + .group_id = "cap_cli", + .descriptors = s_cli_descriptors, + .descriptor_count = sizeof(s_cli_descriptors) / sizeof(s_cli_descriptors[0]), +}; + +esp_err_t cap_cli_init(const cap_cli_config_t *config) +{ + cap_cli_entry_t *entries = NULL; + SemaphoreHandle_t mutex = NULL; + + if (!config || config->max_commands == 0 || config->max_output_bytes == 0) { + return ESP_ERR_INVALID_ARG; + } + if (s_cli.initialized) { + return ESP_OK; + } + + entries = calloc(config->max_commands, sizeof(*entries)); + if (!entries) { + return ESP_ERR_NO_MEM; + } + + mutex = xSemaphoreCreateMutex(); + if (!mutex) { + free(entries); + return ESP_ERR_NO_MEM; + } + + s_cli.initialized = true; + s_cli.capacity = config->max_commands; + s_cli.count = 0; + s_cli.max_output_bytes = config->max_output_bytes; + s_cli.entries = entries; + s_cli.mutex = mutex; + cap_cli_refresh_description_locked(); + s_cli_descriptors[0].description = s_cli.tool_description; + return ESP_OK; +} + +esp_err_t cap_cli_register_command(const cap_cli_command_t *command) +{ + cap_cli_entry_t entry = {0}; + int index; + + if (!s_cli.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (!command || !command->command_name || !command->command_name[0]) { + return ESP_ERR_INVALID_ARG; + } + + entry.command_name = cap_cli_strdup(command->command_name); + entry.description = cap_cli_strdup(command->description ? command->description : ""); + entry.usage_hint = cap_cli_strdup(command->usage_hint ? command->usage_hint : ""); + if (!entry.command_name || !entry.description || !entry.usage_hint) { + cap_cli_free_entry(&entry); + return ESP_ERR_NO_MEM; + } + + xSemaphoreTake(s_cli.mutex, portMAX_DELAY); + index = cap_cli_find_command_locked(command->command_name); + if (index >= 0) { + cap_cli_free_entry(&s_cli.entries[index]); + s_cli.entries[(size_t)index] = entry; + cap_cli_refresh_description_locked(); + xSemaphoreGive(s_cli.mutex); + return ESP_OK; + } + + if (s_cli.count >= s_cli.capacity) { + xSemaphoreGive(s_cli.mutex); + cap_cli_free_entry(&entry); + return ESP_ERR_NO_MEM; + } + + s_cli.entries[s_cli.count++] = entry; + cap_cli_refresh_description_locked(); + xSemaphoreGive(s_cli.mutex); + return ESP_OK; +} + +esp_err_t cap_cli_register_group(void) +{ + if (!s_cli.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (claw_cap_group_exists(s_cli_group.group_id)) { + return ESP_OK; + } + + s_cli_descriptors[0].description = s_cli.tool_description; + return claw_cap_register_group(&s_cli_group); +} diff --git a/components/cap_files/CMakeLists.txt b/components/cap_files/CMakeLists.txt new file mode 100644 index 0000000..0424b29 --- /dev/null +++ b/components/cap_files/CMakeLists.txt @@ -0,0 +1,9 @@ +idf_component_register( + SRCS + "src/cap_files.c" + INCLUDE_DIRS + "include" + REQUIRES + claw_cap + json +) diff --git a/components/cap_files/include/cap_files.h b/components/cap_files/include/cap_files.h new file mode 100644 index 0000000..9722c05 --- /dev/null +++ b/components/cap_files/include/cap_files.h @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t cap_files_register_group(void); +esp_err_t cap_files_set_base_dir(const char *base_dir); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_files/src/cap_files.c b/components/cap_files/src/cap_files.c new file mode 100644 index 0000000..b24613b --- /dev/null +++ b/components/cap_files/src/cap_files.c @@ -0,0 +1,619 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_files.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "claw_cap.h" +#include "esp_log.h" + +static const char *TAG = "cap_files"; + +#define CAP_FILES_DEFAULT_BASE_DIR "/fatfs/data" +#define CAP_FILES_MAX_FILE_SIZE (32 * 1024) + +static char s_files_base_dir[128] = CAP_FILES_DEFAULT_BASE_DIR; + +static bool cap_files_path_is_valid(const char *path) +{ + size_t base_len; + + if (!path || !path[0]) { + return false; + } + + if (strstr(path, "..") != NULL) { + return false; + } + + base_len = strlen(s_files_base_dir); + if (strncmp(path, s_files_base_dir, base_len) != 0) { + return false; + } + + return path[base_len] == '\0' || path[base_len] == '/'; +} + +static esp_err_t cap_files_resolve_path(const char *path, char *resolved, size_t resolved_size) +{ + int written; + + if (!path || !path[0] || !resolved || resolved_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + if (path[0] == '/') { + if (!cap_files_path_is_valid(path)) { + return ESP_ERR_INVALID_ARG; + } + strlcpy(resolved, path, resolved_size); + return ESP_OK; + } + + if (strstr(path, "..") != NULL) { + return ESP_ERR_INVALID_ARG; + } + + written = snprintf(resolved, resolved_size, "%s/%s", s_files_base_dir, path); + if (written < 0 || (size_t)written >= resolved_size) { + return ESP_ERR_INVALID_SIZE; + } + + if (!cap_files_path_is_valid(resolved)) { + return ESP_ERR_INVALID_ARG; + } + + return ESP_OK; +} + +static esp_err_t cap_files_ensure_dir(const char *path) +{ + struct stat st = {0}; + + if (stat(path, &st) == 0) { + return S_ISDIR(st.st_mode) ? ESP_OK : ESP_FAIL; + } + + if (mkdir(path, 0755) != 0 && errno != EEXIST) { + ESP_LOGE(TAG, "mkdir failed for %s: errno=%d", path, errno); + return ESP_FAIL; + } + + return ESP_OK; +} + +static esp_err_t cap_files_ensure_parent_dirs(const char *path) +{ + char dir[256]; + char *slash = NULL; + char *cursor = NULL; + size_t base_len; + + if (!cap_files_path_is_valid(path)) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(dir, path, sizeof(dir)); + slash = strrchr(dir, '/'); + if (!slash) { + return ESP_OK; + } + + base_len = strlen(s_files_base_dir); + if ((size_t)(slash - dir) <= base_len) { + return cap_files_ensure_dir(s_files_base_dir); + } + *slash = '\0'; + + if (cap_files_ensure_dir(s_files_base_dir) != ESP_OK) { + return ESP_FAIL; + } + + cursor = dir + base_len + 1; + while (*cursor) { + if (*cursor == '/') { + *cursor = '\0'; + if (cap_files_ensure_dir(dir) != ESP_OK) { + *cursor = '/'; + return ESP_FAIL; + } + *cursor = '/'; + } + cursor++; + } + + return cap_files_ensure_dir(dir); +} + +static esp_err_t cap_files_list_recursive(const char *dir_path, + const char *prefix, + char *output, + size_t output_size, + size_t *offset, + int *count) +{ + DIR *dir = NULL; + struct dirent *entry = NULL; + + dir = opendir(dir_path); + if (!dir) { + return ESP_FAIL; + } + + while ((entry = readdir(dir)) != NULL) { + char full_path[256]; + struct stat st = {0}; + + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + + if (snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, entry->d_name) >= (int)sizeof(full_path)) { + closedir(dir); + return ESP_ERR_INVALID_SIZE; + } + + if (!cap_files_path_is_valid(full_path)) { + continue; + } + + if (stat(full_path, &st) != 0) { + continue; + } + + if (S_ISDIR(st.st_mode)) { + esp_err_t err = cap_files_list_recursive(full_path, + prefix, + output, + output_size, + offset, + count); + if (err != ESP_OK) { + closedir(dir); + return err; + } + continue; + } + + if (prefix && strncmp(full_path, prefix, strlen(prefix)) != 0) { + continue; + } + + if (*offset < output_size - 1) { + int written = snprintf(output + *offset, output_size - *offset, "%s\n", full_path); + if (written < 0) { + closedir(dir); + return ESP_FAIL; + } + if ((size_t)written >= output_size - *offset) { + *offset = output_size - 1; + } else { + *offset += (size_t)written; + } + } + (*count)++; + } + + closedir(dir); + return ESP_OK; +} + +static esp_err_t cap_files_read_file_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *path = NULL; + char resolved_path[256]; + FILE *file = NULL; + size_t max_read; + size_t read_size; + + (void)ctx; + + root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON input"); + return ESP_ERR_INVALID_ARG; + } + + path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path")); + if (cap_files_resolve_path(path, resolved_path, sizeof(resolved_path)) != ESP_OK) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: path must stay under %s", s_files_base_dir); + return ESP_ERR_INVALID_ARG; + } + + file = fopen(resolved_path, "rb"); + if (!file) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: file not found: %s", resolved_path); + return ESP_ERR_NOT_FOUND; + } + + max_read = output_size - 1; + if (max_read > CAP_FILES_MAX_FILE_SIZE) { + max_read = CAP_FILES_MAX_FILE_SIZE; + } + + read_size = fread(output, 1, max_read, file); + output[read_size] = '\0'; + fclose(file); + cJSON_Delete(root); + return ESP_OK; +} + +static esp_err_t cap_files_write_file_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *path = NULL; + const char *content = NULL; + char resolved_path[256]; + FILE *file = NULL; + size_t content_len; + size_t written; + + (void)ctx; + + root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON input"); + return ESP_ERR_INVALID_ARG; + } + + path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path")); + content = cJSON_GetStringValue(cJSON_GetObjectItem(root, "content")); + if (cap_files_resolve_path(path, resolved_path, sizeof(resolved_path)) != ESP_OK) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: path must stay under %s", s_files_base_dir); + return ESP_ERR_INVALID_ARG; + } + if (!content) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: missing content"); + return ESP_ERR_INVALID_ARG; + } + + if (cap_files_ensure_parent_dirs(resolved_path) != ESP_OK) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: failed to create parent directories for %s", resolved_path); + return ESP_FAIL; + } + + file = fopen(resolved_path, "wb"); + if (!file) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: cannot open file for writing: %s", resolved_path); + return ESP_FAIL; + } + + content_len = strlen(content); + written = fwrite(content, 1, content_len, file); + fclose(file); + cJSON_Delete(root); + + if (written != content_len) { + snprintf(output, output_size, "Error: wrote %d of %d bytes to %s", + (int)written, + (int)content_len, + resolved_path); + return ESP_FAIL; + } + + snprintf(output, output_size, "OK: wrote %d bytes to %s", (int)written, resolved_path); + return ESP_OK; +} + +static esp_err_t cap_files_edit_file_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *path = NULL; + const char *old_string = NULL; + const char *new_string = NULL; + char resolved_path[256]; + FILE *file = NULL; + long file_size; + char *buffer = NULL; + char *result = NULL; + char *match = NULL; + size_t old_len; + size_t new_len; + size_t prefix_len; + size_t suffix_start; + size_t suffix_len; + size_t total_len; + + (void)ctx; + + root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON input"); + return ESP_ERR_INVALID_ARG; + } + + path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path")); + old_string = cJSON_GetStringValue(cJSON_GetObjectItem(root, "old_string")); + new_string = cJSON_GetStringValue(cJSON_GetObjectItem(root, "new_string")); + if (cap_files_resolve_path(path, resolved_path, sizeof(resolved_path)) != ESP_OK) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: path must stay under %s", s_files_base_dir); + return ESP_ERR_INVALID_ARG; + } + if (!old_string || !new_string) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: missing old_string or new_string"); + return ESP_ERR_INVALID_ARG; + } + + file = fopen(resolved_path, "rb"); + if (!file) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: file not found: %s", resolved_path); + return ESP_ERR_NOT_FOUND; + } + + fseek(file, 0, SEEK_END); + file_size = ftell(file); + fseek(file, 0, SEEK_SET); + if (file_size <= 0 || file_size > CAP_FILES_MAX_FILE_SIZE) { + fclose(file); + cJSON_Delete(root); + snprintf(output, output_size, "Error: file too large or empty (%ld bytes)", file_size); + return ESP_ERR_INVALID_SIZE; + } + + buffer = calloc(1, (size_t)file_size + 1); + if (!buffer) { + fclose(file); + cJSON_Delete(root); + snprintf(output, output_size, "Error: out of memory"); + return ESP_ERR_NO_MEM; + } + + if (fread(buffer, 1, (size_t)file_size, file) != (size_t)file_size) { + free(buffer); + fclose(file); + cJSON_Delete(root); + snprintf(output, output_size, "Error: failed to read %s", resolved_path); + return ESP_FAIL; + } + fclose(file); + + match = strstr(buffer, old_string); + if (!match) { + free(buffer); + cJSON_Delete(root); + snprintf(output, output_size, "Error: old_string not found in %s", resolved_path); + return ESP_ERR_NOT_FOUND; + } + + old_len = strlen(old_string); + new_len = strlen(new_string); + prefix_len = (size_t)(match - buffer); + suffix_start = prefix_len + old_len; + suffix_len = (size_t)file_size - suffix_start; + total_len = prefix_len + new_len + suffix_len; + + result = malloc(total_len + 1); + if (!result) { + free(buffer); + cJSON_Delete(root); + snprintf(output, output_size, "Error: out of memory"); + return ESP_ERR_NO_MEM; + } + + memcpy(result, buffer, prefix_len); + memcpy(result + prefix_len, new_string, new_len); + memcpy(result + prefix_len + new_len, buffer + suffix_start, suffix_len); + result[total_len] = '\0'; + free(buffer); + + file = fopen(resolved_path, "wb"); + if (!file) { + free(result); + cJSON_Delete(root); + snprintf(output, output_size, "Error: cannot open file for writing: %s", resolved_path); + return ESP_FAIL; + } + + if (fwrite(result, 1, total_len, file) != total_len) { + fclose(file); + free(result); + cJSON_Delete(root); + snprintf(output, output_size, "Error: failed to write %s", resolved_path); + return ESP_FAIL; + } + + fclose(file); + free(result); + cJSON_Delete(root); + snprintf(output, output_size, "OK: edited %s", resolved_path); + return ESP_OK; +} + +static esp_err_t cap_files_delete_file_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *path = NULL; + char resolved_path[256]; + struct stat st = {0}; + + (void)ctx; + + root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON input"); + return ESP_ERR_INVALID_ARG; + } + + path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path")); + if (cap_files_resolve_path(path, resolved_path, sizeof(resolved_path)) != ESP_OK) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: path must stay under %s", s_files_base_dir); + return ESP_ERR_INVALID_ARG; + } + + if (stat(resolved_path, &st) != 0 || !S_ISREG(st.st_mode)) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: file not found: %s", resolved_path); + return ESP_ERR_NOT_FOUND; + } + + if (unlink(resolved_path) != 0) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: failed to delete file: %s", resolved_path); + return ESP_FAIL; + } + + cJSON_Delete(root); + snprintf(output, output_size, "OK: deleted %s", resolved_path); + return ESP_OK; +} + +static esp_err_t cap_files_list_dir_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *prefix_value = NULL; + char resolved_prefix[256]; + const char *prefix = NULL; + size_t offset = 0; + int count = 0; + esp_err_t err; + + (void)ctx; + + output[0] = '\0'; + root = cJSON_Parse(input_json); + if (root) { + prefix_value = cJSON_GetStringValue(cJSON_GetObjectItem(root, "prefix")); + } + + if (prefix_value && prefix_value[0]) { + if (cap_files_resolve_path(prefix_value, resolved_prefix, sizeof(resolved_prefix)) != ESP_OK) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: prefix must stay under %s", s_files_base_dir); + return ESP_ERR_INVALID_ARG; + } + prefix = resolved_prefix; + } + + if (cap_files_ensure_dir(s_files_base_dir) != ESP_OK) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: cannot open %s", s_files_base_dir); + return ESP_FAIL; + } + + err = cap_files_list_recursive(s_files_base_dir, prefix, output, output_size, &offset, &count); + cJSON_Delete(root); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: failed to list files under %s", s_files_base_dir); + return err; + } + + if (count == 0) { + snprintf(output, output_size, "(no files found)"); + } + return ESP_OK; +} + +static const claw_cap_descriptor_t s_files_descriptors[] = { + { + .id = "read_file", + .name = "read_file", + .family = "files", + .description = "Read a UTF-8 text file under the managed FATFS base directory.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}", + .execute = cap_files_read_file_execute, + }, + { + .id = "write_file", + .name = "write_file", + .family = "files", + .description = "Create or overwrite a text file under the managed FATFS base directory.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"content\":{\"type\":\"string\"}},\"required\":[\"path\",\"content\"]}", + .execute = cap_files_write_file_execute, + }, + { + .id = "edit_file", + .name = "edit_file", + .family = "files", + .description = "Replace the first matching string inside a text file under the managed FATFS base directory.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"old_string\":{\"type\":\"string\"},\"new_string\":{\"type\":\"string\"}},\"required\":[\"path\",\"old_string\",\"new_string\"]}", + .execute = cap_files_edit_file_execute, + }, + { + .id = "delete_file", + .name = "delete_file", + .family = "files", + .description = "Delete a file under the managed FATFS base directory.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}", + .execute = cap_files_delete_file_execute, + }, + { + .id = "list_dir", + .name = "list_dir", + .family = "files", + .description = "Recursively list files under the managed FATFS base directory, optionally filtered by prefix.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = "{\"type\":\"object\",\"properties\":{\"prefix\":{\"type\":\"string\"}}}", + .execute = cap_files_list_dir_execute, + }, +}; + +static const claw_cap_group_t s_files_group = { + .group_id = "cap_files", + .descriptors = s_files_descriptors, + .descriptor_count = sizeof(s_files_descriptors) / sizeof(s_files_descriptors[0]), +}; + +esp_err_t cap_files_register_group(void) +{ + if (claw_cap_group_exists(s_files_group.group_id)) { + return ESP_OK; + } + + return claw_cap_register_group(&s_files_group); +} + +esp_err_t cap_files_set_base_dir(const char *base_dir) +{ + if (!base_dir || !base_dir[0] || base_dir[0] != '/') { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(s_files_base_dir, base_dir, sizeof(s_files_base_dir)); + return ESP_OK; +} diff --git a/components/cap_im_attachment/CMakeLists.txt b/components/cap_im_attachment/CMakeLists.txt new file mode 100644 index 0000000..90721c5 --- /dev/null +++ b/components/cap_im_attachment/CMakeLists.txt @@ -0,0 +1,10 @@ +idf_component_register( + SRCS + "src/cap_im_attachment.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_http_client + mbedtls + json +) diff --git a/components/cap_im_attachment/include/cap_im_attachment.h b/components/cap_im_attachment/include/cap_im_attachment.h new file mode 100644 index 0000000..8f54037 --- /dev/null +++ b/components/cap_im_attachment/include/cap_im_attachment.h @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + const char *platform; + const char *attachment_kind; + const char *saved_path; + const char *saved_dir; + const char *saved_name; + const char *original_filename; + const char *mime; + const char *caption; + const char *source_key; + const char *source_value; + size_t size_bytes; + int64_t saved_at_ms; +} cap_im_attachment_payload_config_t; + +const char *cap_im_attachment_ext_from_mime(const char *mime); +const char *cap_im_attachment_guess_extension(const char *path_or_url, + const char *original_filename, + const char *mime); +const char *cap_im_attachment_normalize_url(const char *url, + char *buf, + size_t buf_size); +esp_err_t cap_im_attachment_build_saved_paths(const char *root_dir, + const char *platform_dir, + const char *chat_id, + const char *message_id, + const char *kind, + const char *extension, + char *saved_dir, + size_t saved_dir_size, + char *saved_name, + size_t saved_name_size, + char *saved_path, + size_t saved_path_size); +esp_err_t cap_im_attachment_download_url_to_file(const char *log_tag, + const char *url, + const char *dest_path, + size_t max_bytes, + size_t *out_bytes); +esp_err_t cap_im_attachment_save_buffer_to_file(const char *log_tag, + const char *dest_path, + const unsigned char *buf, + size_t buf_len); +char *cap_im_attachment_build_payload_json( + const cap_im_attachment_payload_config_t *config); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_im_attachment/src/cap_im_attachment.c b/components/cap_im_attachment/src/cap_im_attachment.c new file mode 100644 index 0000000..65a265f --- /dev/null +++ b/components/cap_im_attachment/src/cap_im_attachment.c @@ -0,0 +1,472 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_im_attachment.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "esp_crt_bundle.h" +#include "esp_http_client.h" +#include "esp_log.h" + +#define CAP_IM_ATTACHMENT_PATH_BUF_SIZE 256 + +typedef struct { + FILE *file; + size_t bytes_written; + size_t max_bytes; + bool limit_hit; +} cap_im_attachment_download_t; + +static uint64_t cap_im_attachment_fnv1a64(const char *text) +{ + uint64_t hash = 1469598103934665603ULL; + + if (!text) { + return hash; + } + + while (*text) { + hash ^= (unsigned char)(*text++); + hash *= 1099511628211ULL; + } + + return hash; +} + +static void cap_im_attachment_sanitize_component(const char *src, + char *dst, + size_t dst_size) +{ + size_t written = 0; + + if (!dst || dst_size == 0) { + return; + } + dst[0] = '\0'; + if (!src || !src[0]) { + strlcpy(dst, "unknown", dst_size); + return; + } + + while (*src && written + 1 < dst_size) { + unsigned char ch = (unsigned char) * src++; + + if (isalnum(ch) || ch == '-' || ch == '_' || ch == '.') { + dst[written++] = (char)ch; + } else { + dst[written++] = '_'; + } + } + + dst[written] = '\0'; + if (written == 0) { + strlcpy(dst, "unknown", dst_size); + } +} + +static const char *cap_im_attachment_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_attachment_find_extension(const char *candidate) +{ + const char *basename = NULL; + const char *end = NULL; + const char *dot = NULL; + + if (!candidate || !candidate[0]) { + return NULL; + } + + basename = cap_im_attachment_basename(candidate); + end = basename + strcspn(basename, "?#"); + if (end <= basename) { + return NULL; + } + + for (dot = end; dot > basename; dot--) { + if (dot[-1] == '.') { + size_t ext_len = (size_t)(end - (dot - 1)); + + if (ext_len <= 1 || ext_len > 8) { + return NULL; + } + return dot - 1; + } + if (dot[-1] == '/' || dot[-1] == '\\') { + break; + } + } + + return NULL; +} + +const char *cap_im_attachment_ext_from_mime(const char *mime) +{ + if (!mime || !mime[0]) { + return ".bin"; + } + if (strcmp(mime, "image/jpeg") == 0) { + return ".jpg"; + } + if (strcmp(mime, "image/png") == 0) { + return ".png"; + } + if (strcmp(mime, "image/gif") == 0) { + return ".gif"; + } + if (strcmp(mime, "image/webp") == 0) { + return ".webp"; + } + if (strcmp(mime, "application/pdf") == 0) { + return ".pdf"; + } + if (strcmp(mime, "text/plain") == 0) { + return ".txt"; + } + return ".bin"; +} + +const char *cap_im_attachment_guess_extension(const char *path_or_url, + const char *original_filename, + const char *mime) +{ + const char *candidates[2] = {original_filename, path_or_url}; + size_t i; + + for (i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) { + const char *candidate = candidates[i]; + const char *dot = NULL; + + if (!candidate || !candidate[0]) { + continue; + } + dot = cap_im_attachment_find_extension(candidate); + if (dot && dot[1]) { + return dot; + } + } + + return cap_im_attachment_ext_from_mime(mime); +} + +const char *cap_im_attachment_normalize_url(const char *url, + char *buf, + size_t buf_size) +{ + if (!url || !url[0] || !buf || buf_size == 0) { + return NULL; + } + + if (strncmp(url, "//", 2) == 0) { + snprintf(buf, buf_size, "https:%s", url); + } else { + snprintf(buf, buf_size, "%s", url); + } + + return buf; +} + +static esp_err_t cap_im_attachment_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_attachment_ensure_parent_dirs(const char *path) +{ + char dir[CAP_IM_ATTACHMENT_PATH_BUF_SIZE]; + 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_attachment_ensure_dir(dir) != ESP_OK) { + return ESP_FAIL; + } + *cursor = '/'; + } + } + + return cap_im_attachment_ensure_dir(dir); +} + +esp_err_t cap_im_attachment_build_saved_paths(const char *root_dir, + const char *platform_dir, + const char *chat_id, + const char *message_id, + const char *kind, + const char *extension, + char *saved_dir, + size_t saved_dir_size, + char *saved_name, + size_t saved_name_size, + char *saved_path, + size_t saved_path_size) +{ + char safe_chat[64]; + uint64_t message_hash; + int written; + + if (!root_dir || !root_dir[0] || !platform_dir || !platform_dir[0] || !chat_id || + !message_id || !kind || !extension || !saved_dir || !saved_name || !saved_path) { + return ESP_ERR_INVALID_ARG; + } + + cap_im_attachment_sanitize_component(chat_id, safe_chat, sizeof(safe_chat)); + written = snprintf(saved_dir, saved_dir_size, "%s/%s/%s", root_dir, platform_dir, safe_chat); + if (written < 0 || (size_t)written >= saved_dir_size) { + return ESP_ERR_INVALID_SIZE; + } + + message_hash = cap_im_attachment_fnv1a64(message_id); + written = snprintf(saved_name, + saved_name_size, + "%s_%08" PRIx32 "_%s%s", + platform_dir, + (uint32_t)message_hash, + kind, + extension); + if (written < 0 || (size_t)written >= saved_name_size) { + return ESP_ERR_INVALID_SIZE; + } + + written = snprintf(saved_path, saved_path_size, "%s/%s", saved_dir, saved_name); + if (written < 0 || (size_t)written >= saved_path_size) { + return ESP_ERR_INVALID_SIZE; + } + + return ESP_OK; +} + +static esp_err_t cap_im_attachment_download_event_handler(esp_http_client_event_t *event) +{ + cap_im_attachment_download_t *dl = (cap_im_attachment_download_t *)event->user_data; + + if (!dl || event->event_id != HTTP_EVENT_ON_DATA || event->data_len <= 0) { + return ESP_OK; + } + + if (dl->bytes_written + (size_t)event->data_len > dl->max_bytes) { + dl->limit_hit = true; + return ESP_FAIL; + } + + if (fwrite(event->data, 1, (size_t)event->data_len, dl->file) != (size_t)event->data_len) { + return ESP_FAIL; + } + + dl->bytes_written += (size_t)event->data_len; + return ESP_OK; +} + +esp_err_t cap_im_attachment_download_url_to_file(const char *log_tag, + const char *url, + const char *dest_path, + size_t max_bytes, + size_t *out_bytes) +{ + esp_http_client_config_t config = {0}; + esp_http_client_handle_t client = NULL; + cap_im_attachment_download_t dl = {0}; + FILE *file = NULL; + esp_err_t err; + int status; + + if (!url || !url[0] || !dest_path || !dest_path[0]) { + return ESP_ERR_INVALID_ARG; + } + + if (cap_im_attachment_ensure_parent_dirs(dest_path) != ESP_OK) { + ESP_LOGW(log_tag, "attachment mkdir failed: path=%s errno=%d", dest_path, errno); + return ESP_FAIL; + } + + file = fopen(dest_path, "wb"); + if (!file) { + ESP_LOGW(log_tag, "attachment fopen failed: path=%s errno=%d", dest_path, errno); + return ESP_FAIL; + } + + dl.file = file; + dl.max_bytes = max_bytes ? max_bytes : (2 * 1024 * 1024); + + config.url = url; + config.event_handler = cap_im_attachment_download_event_handler; + config.user_data = &dl; + config.timeout_ms = 30000; + config.buffer_size = 2048; + config.buffer_size_tx = 1024; + config.crt_bundle_attach = esp_crt_bundle_attach; + + client = esp_http_client_init(&config); + if (!client) { + fclose(file); + remove(dest_path); + return ESP_FAIL; + } + + err = esp_http_client_perform(client); + status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + fclose(file); + + if (err != ESP_OK || status < 200 || status >= 300 || dl.limit_hit || dl.bytes_written == 0) { + ESP_LOGW(log_tag, + "attachment download failed: err=%s http=%d bytes=%u limit_hit=%d url=%s", + esp_err_to_name(err), + status, + (unsigned int)dl.bytes_written, + dl.limit_hit ? 1 : 0, + url); + remove(dest_path); + if (dl.limit_hit) { + return ESP_ERR_INVALID_SIZE; + } + return err != ESP_OK ? err : ESP_FAIL; + } + + if (out_bytes) { + *out_bytes = dl.bytes_written; + } + + return ESP_OK; +} + +esp_err_t cap_im_attachment_save_buffer_to_file(const char *log_tag, + const char *dest_path, + const unsigned char *buf, + size_t buf_len) +{ + FILE *file = NULL; + + if (!log_tag || !dest_path || !dest_path[0] || (!buf && buf_len > 0)) { + return ESP_ERR_INVALID_ARG; + } + + if (cap_im_attachment_ensure_parent_dirs(dest_path) != ESP_OK) { + ESP_LOGW(log_tag, "attachment mkdir failed: path=%s errno=%d", dest_path, errno); + return ESP_FAIL; + } + + file = fopen(dest_path, "wb"); + if (!file) { + ESP_LOGW(log_tag, "attachment fopen failed: path=%s errno=%d", dest_path, errno); + return ESP_FAIL; + } + + if (buf_len > 0 && fwrite(buf, 1, buf_len, file) != buf_len) { + fclose(file); + remove(dest_path); + ESP_LOGW(log_tag, "attachment fwrite failed: path=%s errno=%d", dest_path, errno); + return ESP_FAIL; + } + + fclose(file); + return ESP_OK; +} + +char *cap_im_attachment_build_payload_json( + const cap_im_attachment_payload_config_t *config) +{ + cJSON *payload = NULL; + char *payload_json = NULL; + + if (!config || !config->platform || !config->attachment_kind || !config->saved_path || + !config->saved_dir || !config->saved_name) { + return NULL; + } + + payload = cJSON_CreateObject(); + if (!payload) { + return NULL; + } + + cJSON_AddStringToObject(payload, "platform", config->platform); + cJSON_AddStringToObject(payload, "attachment_kind", config->attachment_kind); + cJSON_AddStringToObject(payload, "saved_path", config->saved_path); + cJSON_AddStringToObject(payload, "saved_dir", config->saved_dir); + cJSON_AddStringToObject(payload, "saved_name", config->saved_name); + cJSON_AddStringToObject(payload, + "original_filename", + config->original_filename ? config->original_filename : ""); + cJSON_AddStringToObject(payload, "mime", config->mime ? config->mime : ""); + cJSON_AddNumberToObject(payload, "size_bytes", (double)config->size_bytes); + cJSON_AddStringToObject(payload, "caption", config->caption ? config->caption : ""); + cJSON_AddNumberToObject(payload, "saved_at_ms", (double)config->saved_at_ms); + if (config->source_key && config->source_key[0]) { + cJSON_AddStringToObject(payload, + config->source_key, + config->source_value ? config->source_value : ""); + } + + payload_json = cJSON_PrintUnformatted(payload); + cJSON_Delete(payload); + return payload_json; +} diff --git a/components/cap_im_qq/CMakeLists.txt b/components/cap_im_qq/CMakeLists.txt new file mode 100644 index 0000000..23fe25d --- /dev/null +++ b/components/cap_im_qq/CMakeLists.txt @@ -0,0 +1,18 @@ +idf_component_register( + SRCS + "src/cap_im_qq.c" + "src/cmd_cap_im_qq.c" + INCLUDE_DIRS + "include" + REQUIRES + cap_im_attachment + claw_cap + claw_event_router + esp_http_client + esp_event + esp_timer + esp_websocket_client + freertos + json + console +) diff --git a/components/cap_im_qq/idf_component.yml b/components/cap_im_qq/idf_component.yml new file mode 100644 index 0000000..37b71ef --- /dev/null +++ b/components/cap_im_qq/idf_component.yml @@ -0,0 +1,2 @@ +dependencies: + espressif/esp_websocket_client: "^1.4.0" diff --git a/components/cap_im_qq/include/cap_im_qq.h b/components/cap_im_qq/include/cap_im_qq.h new file mode 100644 index 0000000..97a57d8 --- /dev/null +++ b/components/cap_im_qq/include/cap_im_qq.h @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include + +#include "claw_cap.h" +#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_qq_attachment_config_t; + +esp_err_t cap_im_qq_register_group(void); +esp_err_t cap_im_qq_set_credentials(const char *app_id, const char *app_secret); +esp_err_t cap_im_qq_set_attachment_config( + const cap_im_qq_attachment_config_t *config); +esp_err_t cap_im_qq_start(void); +esp_err_t cap_im_qq_stop(void); +esp_err_t cap_im_qq_send_text(const char *chat_id, const char *text); +esp_err_t cap_im_qq_send_image(const char *chat_id, const char *path, const char *caption); +esp_err_t cap_im_qq_send_file(const char *chat_id, const char *path, const char *caption); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_im_qq/include/cmd_cap_im_qq.h b/components/cap_im_qq/include/cmd_cap_im_qq.h new file mode 100644 index 0000000..8b18ee8 --- /dev/null +++ b/components/cap_im_qq/include/cmd_cap_im_qq.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_qq(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_im_qq/src/cap_im_qq.c b/components/cap_im_qq/src/cap_im_qq.c new file mode 100644 index 0000000..3b181b4 --- /dev/null +++ b/components/cap_im_qq/src/cap_im_qq.c @@ -0,0 +1,1899 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_im_qq.h" +#include "cap_im_attachment.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "claw_event_router.h" +#include "esp_crt_bundle.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" +#include "mbedtls/base64.h" + +static const char *TAG = "cap_im_qq"; + +#define CAP_IM_QQ_TOKEN_URL "https://bots.qq.com/app/getAppAccessToken" +#define CAP_IM_QQ_API_BASE "https://api.sgroup.qq.com" +#define CAP_IM_QQ_GATEWAY_URL CAP_IM_QQ_API_BASE "/gateway" +#define CAP_IM_QQ_WS_CONNECT_GRACE_MS 10000 +#define CAP_IM_QQ_RECONNECT_DELAY_MS 5000 +#define CAP_IM_QQ_MAX_MSG_LEN 1500 +#define CAP_IM_QQ_HTTP_RESP_INIT 2048 +#define CAP_IM_QQ_WS_TASK_STACK 6144 +#define CAP_IM_QQ_WS_CLIENT_STACK 8192 +#define CAP_IM_QQ_INBOUND_TASK_STACK 8192 +#define CAP_IM_QQ_WS_PRIO 5 +#define CAP_IM_QQ_INBOUND_QUEUE_LEN 8 +#define CAP_IM_QQ_DEDUP_CACHE_SIZE 64 +#define CAP_IM_QQ_PATH_BUF_SIZE 256 +#define CAP_IM_QQ_NAME_BUF_SIZE 96 + +#define CAP_IM_QQ_WS_OP_DISPATCH 0 +#define CAP_IM_QQ_WS_OP_HEARTBEAT 1 +#define CAP_IM_QQ_WS_OP_IDENTIFY 2 +#define CAP_IM_QQ_WS_OP_RECONNECT 7 +#define CAP_IM_QQ_WS_OP_INVALID_SESSION 9 +#define CAP_IM_QQ_WS_OP_HELLO 10 +#define CAP_IM_QQ_WS_OP_HEARTBEAT_ACK 11 + +#define CAP_IM_QQ_INTENTS ((1 << 30) | (1 << 25)) +#define CAP_IM_QQ_FILE_TYPE_IMAGE 1 +#define CAP_IM_QQ_FILE_TYPE_FILE 4 + +#ifndef BASIC_DEMO_QQ_APP_ID +#define BASIC_DEMO_QQ_APP_ID "" +#endif + +#ifndef BASIC_DEMO_QQ_APP_SECRET +#define BASIC_DEMO_QQ_APP_SECRET "" +#endif + +typedef struct { + char *buf; + size_t len; + size_t cap; +} cap_im_qq_http_resp_t; + +typedef struct { + FILE *file; + size_t bytes_written; + size_t max_bytes; + bool limit_hit; +} cap_im_qq_download_t; + +typedef struct { + char *frame; + size_t len; +} cap_im_qq_inbound_frame_t; + +typedef struct { + char app_id[64]; + char app_secret[128]; + char access_token[512]; + char attachment_root_dir[128]; + size_t max_inbound_file_bytes; + bool enable_inbound_attachments; + int64_t token_expire_time; + char ws_url[384]; + esp_websocket_client_handle_t ws_client; + TaskHandle_t ws_task; + TaskHandle_t inbound_task; + QueueHandle_t inbound_queue; + volatile int heartbeat_interval_ms; + volatile int last_seq; + volatile bool ws_connected; + volatile bool ws_identify_pending; + volatile bool ws_should_reconnect; + volatile bool stop_requested; + uint64_t seen_msg_keys[CAP_IM_QQ_DEDUP_CACHE_SIZE]; + size_t seen_msg_idx; +} cap_im_qq_state_t; + +static cap_im_qq_state_t s_qq = { + .app_id = BASIC_DEMO_QQ_APP_ID, + .app_secret = BASIC_DEMO_QQ_APP_SECRET, + .max_inbound_file_bytes = 2 * 1024 * 1024, + .heartbeat_interval_ms = 30000, + .last_seq = -1, +}; + +static int64_t cap_im_qq_now_ms(void) +{ + return esp_timer_get_time() / 1000LL; +} + +static uint64_t cap_im_qq_fnv1a64(const char *text) +{ + uint64_t hash = 1469598103934665603ULL; + + if (!text) { + return hash; + } + + while (*text) { + hash ^= (unsigned char)(*text++); + hash *= 1099511628211ULL; + } + + return hash; +} + +static bool cap_im_qq_dedup_check_and_record(const char *message_id) +{ + uint64_t key; + size_t i; + + if (!message_id || !message_id[0]) { + return false; + } + + key = cap_im_qq_fnv1a64(message_id); + for (i = 0; i < CAP_IM_QQ_DEDUP_CACHE_SIZE; i++) { + if (s_qq.seen_msg_keys[i] == key) { + return true; + } + } + + s_qq.seen_msg_keys[s_qq.seen_msg_idx] = key; + s_qq.seen_msg_idx = (s_qq.seen_msg_idx + 1) % CAP_IM_QQ_DEDUP_CACHE_SIZE; + return false; +} + +static const char *cap_im_qq_basename(const char *path) +{ + const char *slash = NULL; + + if (!path || !path[0]) { + return ""; + } + slash = strrchr(path, '/'); + return slash ? slash + 1 : path; +} + +static bool cap_im_qq_is_image_mime(const char *mime) +{ + return mime && strncmp(mime, "image/", 6) == 0; +} + +static esp_err_t cap_im_qq_http_event_handler(esp_http_client_event_t *event) +{ + cap_im_qq_http_resp_t *resp = (cap_im_qq_http_resp_t *)event->user_data; + + if (!resp || event->event_id != HTTP_EVENT_ON_DATA || event->data_len <= 0) { + return ESP_OK; + } + + if (resp->len + (size_t)event->data_len + 1 > resp->cap) { + char *tmp = NULL; + size_t new_cap = resp->cap * 2; + + if (new_cap < resp->len + (size_t)event->data_len + 1) { + new_cap = resp->len + (size_t)event->data_len + 1; + } + + tmp = realloc(resp->buf, new_cap); + if (!tmp) { + return ESP_ERR_NO_MEM; + } + resp->buf = tmp; + resp->cap = new_cap; + } + + memcpy(resp->buf + resp->len, event->data, event->data_len); + resp->len += (size_t)event->data_len; + resp->buf[resp->len] = '\0'; + return ESP_OK; +} + +static char *cap_im_qq_make_auth_header(void) +{ + char *auth = NULL; + int needed; + + needed = snprintf(NULL, 0, "QQBot %s", s_qq.access_token); + if (needed < 0) { + return NULL; + } + + auth = calloc(1, (size_t)needed + 1); + if (!auth) { + return NULL; + } + + snprintf(auth, (size_t)needed + 1, "QQBot %s", s_qq.access_token); + return auth; +} + +static void cap_im_qq_invalidate_token(void) +{ + s_qq.access_token[0] = '\0'; + s_qq.token_expire_time = 0; +} + +static bool cap_im_qq_is_token_invalid_response(const char *body) +{ + cJSON *root = NULL; + cJSON *code_json = NULL; + int code = 0; + bool invalid = false; + + if (!body || !body[0]) { + return false; + } + + root = cJSON_Parse(body); + if (!root) { + return false; + } + + code_json = cJSON_GetObjectItem(root, "code"); + if (!cJSON_IsNumber(code_json)) { + code_json = cJSON_GetObjectItem(root, "err_code"); + } + if (cJSON_IsNumber(code_json)) { + code = code_json->valueint; + invalid = (code == 11244); + } + + cJSON_Delete(root); + return invalid; +} + +static void cap_im_qq_log_http_failure(const char *stage, + esp_err_t err, + int status, + const char *body) +{ + char body_snippet[161]; + + if (!body || !body[0]) { + body_snippet[0] = '\0'; + } else { + snprintf(body_snippet, sizeof(body_snippet), "%.160s", body); + } + + ESP_LOGW(TAG, + "%s failed: err=%s http=%d body=%s", + stage ? stage : "QQ HTTP request", + esp_err_to_name(err), + status, + body_snippet); +} + +static esp_err_t cap_im_qq_get_access_token(void) +{ + cJSON *body = NULL; + char *json_str = NULL; + cap_im_qq_http_resp_t resp = {0}; + esp_http_client_config_t config = {0}; + esp_http_client_handle_t client = NULL; + esp_err_t err; + int status; + int64_t now = esp_timer_get_time() / 1000000LL; + + if (s_qq.app_id[0] == '\0' || s_qq.app_secret[0] == '\0') { + return ESP_ERR_INVALID_STATE; + } + + if (s_qq.access_token[0] != '\0' && s_qq.token_expire_time > now + 300) { + return ESP_OK; + } + + body = cJSON_CreateObject(); + if (!body) { + return ESP_ERR_NO_MEM; + } + cJSON_AddStringToObject(body, "appId", s_qq.app_id); + cJSON_AddStringToObject(body, "clientSecret", s_qq.app_secret); + json_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!json_str) { + return ESP_ERR_NO_MEM; + } + + resp.buf = calloc(1, CAP_IM_QQ_HTTP_RESP_INIT); + resp.cap = CAP_IM_QQ_HTTP_RESP_INIT; + if (!resp.buf) { + free(json_str); + return ESP_ERR_NO_MEM; + } + + config.url = CAP_IM_QQ_TOKEN_URL; + config.event_handler = cap_im_qq_http_event_handler; + config.user_data = &resp; + config.timeout_ms = 15000; + config.buffer_size = 1024; + config.buffer_size_tx = 1024; + config.crt_bundle_attach = esp_crt_bundle_attach; + + client = esp_http_client_init(&config); + if (!client) { + free(json_str); + free(resp.buf); + return ESP_FAIL; + } + + esp_http_client_set_method(client, HTTP_METHOD_POST); + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_http_client_set_post_field(client, json_str, strlen(json_str)); + + err = esp_http_client_perform(client); + status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + free(json_str); + + if (err != ESP_OK || status != 200) { + ESP_LOGE(TAG, + "QQ token request failed: err=%s http=%d body=%s", + esp_err_to_name(err), + status, + resp.buf ? resp.buf : ""); + free(resp.buf); + return err != ESP_OK ? err : ESP_FAIL; + } + + { + cJSON *root = cJSON_Parse(resp.buf); + cJSON *token_json; + cJSON *expires_json; + int expires_in; + + free(resp.buf); + if (!root) { + return ESP_FAIL; + } + + token_json = cJSON_GetObjectItem(root, "access_token"); + expires_json = cJSON_GetObjectItem(root, "expires_in"); + if (!cJSON_IsString(token_json) || !token_json->valuestring) { + cap_im_qq_log_http_failure("QQ token parse", ESP_FAIL, 200, resp.buf); + cJSON_Delete(root); + return ESP_FAIL; + } + + strlcpy(s_qq.access_token, token_json->valuestring, sizeof(s_qq.access_token)); + expires_in = cJSON_IsNumber(expires_json) ? expires_json->valueint : 7200; + s_qq.token_expire_time = now + expires_in - 300; + cJSON_Delete(root); + } + + return ESP_OK; +} + +static esp_err_t cap_im_qq_fetch_gateway_url(void) +{ + int attempt = 0; + +retry: + cap_im_qq_http_resp_t resp = {0}; + esp_http_client_config_t config = {0}; + esp_http_client_handle_t client = NULL; + char *auth = NULL; + esp_err_t err; + int status; + + err = cap_im_qq_get_access_token(); + if (err != ESP_OK) { + return err; + } + + resp.buf = calloc(1, CAP_IM_QQ_HTTP_RESP_INIT); + resp.cap = CAP_IM_QQ_HTTP_RESP_INIT; + if (!resp.buf) { + return ESP_ERR_NO_MEM; + } + + config.url = CAP_IM_QQ_GATEWAY_URL; + config.event_handler = cap_im_qq_http_event_handler; + config.user_data = &resp; + config.timeout_ms = 10000; + config.buffer_size = 1024; + config.crt_bundle_attach = esp_crt_bundle_attach; + + client = esp_http_client_init(&config); + if (!client) { + free(resp.buf); + return ESP_FAIL; + } + + auth = cap_im_qq_make_auth_header(); + if (!auth) { + esp_http_client_cleanup(client); + free(resp.buf); + return ESP_ERR_NO_MEM; + } + + esp_http_client_set_header(client, "Authorization", auth); + err = esp_http_client_perform(client); + status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + free(auth); + + if (err != ESP_OK || status != 200) { + if (attempt == 0 && cap_im_qq_is_token_invalid_response(resp.buf)) { + cap_im_qq_invalidate_token(); + free(resp.buf); + attempt++; + goto retry; + } + cap_im_qq_log_http_failure("QQ gateway fetch", err, status, resp.buf); + free(resp.buf); + return err != ESP_OK ? err : ESP_FAIL; + } + + { + cJSON *root = cJSON_Parse(resp.buf); + cJSON *url_json; + + free(resp.buf); + if (!root) { + return ESP_FAIL; + } + + url_json = cJSON_GetObjectItem(root, "url"); + if (!cJSON_IsString(url_json) || !url_json->valuestring) { + cap_im_qq_log_http_failure("QQ gateway parse", ESP_FAIL, 200, resp.buf); + cJSON_Delete(root); + return ESP_FAIL; + } + + strlcpy(s_qq.ws_url, url_json->valuestring, sizeof(s_qq.ws_url)); + cJSON_Delete(root); + } + + return ESP_OK; +} + +static esp_err_t cap_im_qq_ws_send_json(const char *json_str) +{ + int len; + int sent; + + if (!s_qq.ws_client || !json_str) { + return ESP_ERR_INVALID_STATE; + } + + len = (int)strlen(json_str); + sent = esp_websocket_client_send_text(s_qq.ws_client, json_str, len, pdMS_TO_TICKS(1000)); + return sent == len ? ESP_OK : ESP_FAIL; +} + +static esp_err_t cap_im_qq_ws_send_identify(void) +{ + cJSON *root = NULL; + cJSON *data = NULL; + char *json_str = NULL; + char *auth = NULL; + esp_err_t err; + + auth = cap_im_qq_make_auth_header(); + if (!auth) { + return ESP_ERR_NO_MEM; + } + + root = cJSON_CreateObject(); + data = cJSON_CreateObject(); + if (!root || !data) { + free(auth); + cJSON_Delete(root); + cJSON_Delete(data); + return ESP_ERR_NO_MEM; + } + + cJSON_AddNumberToObject(root, "op", CAP_IM_QQ_WS_OP_IDENTIFY); + cJSON_AddStringToObject(data, "token", auth); + cJSON_AddNumberToObject(data, "intents", CAP_IM_QQ_INTENTS); + cJSON_AddItemToObject(root, "d", data); + json_str = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + free(auth); + if (!json_str) { + return ESP_ERR_NO_MEM; + } + + err = cap_im_qq_ws_send_json(json_str); + free(json_str); + return err; +} + +static esp_err_t cap_im_qq_ws_send_heartbeat(void) +{ + cJSON *root = cJSON_CreateObject(); + char *json_str = NULL; + esp_err_t err; + + if (!root) { + return ESP_ERR_NO_MEM; + } + + cJSON_AddNumberToObject(root, "op", CAP_IM_QQ_WS_OP_HEARTBEAT); + if (s_qq.last_seq >= 0) { + cJSON_AddNumberToObject(root, "d", s_qq.last_seq); + } else { + cJSON_AddNullToObject(root, "d"); + } + + json_str = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json_str) { + return ESP_ERR_NO_MEM; + } + + err = cap_im_qq_ws_send_json(json_str); + free(json_str); + return err; +} + +static esp_err_t cap_im_qq_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("qq_gateway", + "qq", + chat_id, + content, + sender_id, + message_id); +} + +static esp_err_t cap_im_qq_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, "qq_gateway", sizeof(event.source_cap)); + strlcpy(event.event_type, "attachment_saved", sizeof(event.event_type)); + strlcpy(event.source_channel, "qq", sizeof(event.source_channel)); + strlcpy(event.chat_id, chat_id, sizeof(event.chat_id)); + if (sender_id && sender_id[0]) { + strlcpy(event.sender_id, sender_id, sizeof(event.sender_id)); + } + strlcpy(event.message_id, message_id, sizeof(event.message_id)); + strlcpy(event.content_type, content_type, sizeof(event.content_type)); + event.timestamp_ms = cap_im_qq_now_ms(); + event.session_policy = CLAW_EVENT_SESSION_POLICY_CHAT; + snprintf(event.event_id, sizeof(event.event_id), "qq-attach-%" PRId64, event.timestamp_ms); + event.text = ""; + event.payload_json = (char *)payload_json; + return claw_event_router_publish(&event); +} + +static esp_err_t cap_im_qq_save_attachment(const char *chat_id, + const char *sender_id, + const char *message_id, + const char *attachment_kind, + const char *url, + const char *original_filename, + const char *mime) +{ + char normalized_url[512]; + char saved_dir[CAP_IM_QQ_PATH_BUF_SIZE]; + char saved_name[CAP_IM_QQ_NAME_BUF_SIZE]; + char saved_path[CAP_IM_QQ_PATH_BUF_SIZE]; + const char *extension = NULL; + const char *content_type = NULL; + char *payload_json = NULL; + size_t bytes = 0; + esp_err_t err; + + if (!s_qq.enable_inbound_attachments || !s_qq.attachment_root_dir[0] || + !chat_id || !message_id || !attachment_kind || !url || !url[0]) { + return ESP_ERR_INVALID_STATE; + } + + if (!cap_im_attachment_normalize_url(url, normalized_url, sizeof(normalized_url))) { + return ESP_ERR_INVALID_ARG; + } + + extension = cap_im_attachment_guess_extension(normalized_url, original_filename, mime); + err = cap_im_attachment_build_saved_paths(s_qq.attachment_root_dir, + "qq", + 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_attachment_download_url_to_file(TAG, + normalized_url, + saved_path, + s_qq.max_inbound_file_bytes, + &bytes); + if (err != ESP_OK) { + return err; + } + + content_type = cap_im_qq_is_image_mime(mime) ? "image" : "file"; + payload_json = cap_im_attachment_build_payload_json( + &(cap_im_attachment_payload_config_t) { + .platform = "qq", + .attachment_kind = attachment_kind, + .saved_path = saved_path, + .saved_dir = saved_dir, + .saved_name = saved_name, + .original_filename = original_filename, + .mime = mime, + .caption = "", + .source_key = "attachment_url", + .source_value = normalized_url, + .size_bytes = bytes, + .saved_at_ms = cap_im_qq_now_ms(), + }); + if (!payload_json) { + ESP_LOGW(TAG, "QQ attachment payload build failed: message=%s path=%s", message_id, saved_path); + ESP_LOGI(TAG, "Saved QQ %s to %s (%u bytes)", attachment_kind, saved_path, (unsigned int)bytes); + return ESP_OK; + } + + err = cap_im_qq_publish_attachment_event(chat_id, + sender_id, + message_id, + content_type, + payload_json); + free(payload_json); + if (err != ESP_OK) { + ESP_LOGW(TAG, + "QQ attachment publish event failed: message=%s path=%s err=%s", + message_id, + saved_path, + esp_err_to_name(err)); + } + + ESP_LOGI(TAG, "Saved QQ %s to %s (%u bytes)", attachment_kind, saved_path, (unsigned int)bytes); + return ESP_OK; +} + +static void cap_im_qq_handle_attachments(cJSON *attachments, + const char *chat_id, + const char *sender_id, + const char *message_id) +{ + cJSON *item = NULL; + + if (!s_qq.enable_inbound_attachments) { + ESP_LOGI(TAG, "QQ attachments ignored for %s: inbound attachments disabled", message_id); + return; + } + if (!cJSON_IsArray(attachments)) { + ESP_LOGI(TAG, "QQ message %s has no attachment array", message_id); + return; + } + + ESP_LOGI(TAG, + "QQ message %s attachment_count=%d chat=%s", + message_id, + cJSON_GetArraySize(attachments), + chat_id ? chat_id : ""); + + cJSON_ArrayForEach(item, attachments) { + const char *url = NULL; + const char *filename = NULL; + const char *mime = NULL; + const char *kind = "file"; + cJSON *url_json = NULL; + cJSON *filename_json = NULL; + cJSON *mime_json = NULL; + + if (!cJSON_IsObject(item)) { + continue; + } + + url_json = cJSON_GetObjectItem(item, "url"); + filename_json = cJSON_GetObjectItem(item, "filename"); + mime_json = cJSON_GetObjectItem(item, "content_type"); + url = cJSON_IsString(url_json) ? url_json->valuestring : NULL; + filename = cJSON_IsString(filename_json) ? filename_json->valuestring : cap_im_qq_basename(url); + mime = cJSON_IsString(mime_json) ? mime_json->valuestring : "application/octet-stream"; + if (!url || !url[0]) { + ESP_LOGW(TAG, "QQ attachment missing url for message %s", message_id); + continue; + } + if (cap_im_qq_is_image_mime(mime)) { + kind = "image"; + } + + { + esp_err_t err = cap_im_qq_save_attachment(chat_id, + sender_id, + message_id, + kind, + url, + filename, + mime); + if (err != ESP_OK) { + ESP_LOGW(TAG, + "Failed to save QQ attachment message=%s err=%s url=%s", + message_id, + esp_err_to_name(err), + url); + } + } + } +} + +static void cap_im_qq_handle_dispatch(cJSON *data, const char *event_type) +{ + char chat_id[96] = {0}; + char sender_id[96] = {0}; + const char *content = NULL; + const char *message_id = NULL; + cJSON *attachments = NULL; + + if (!data || !event_type) { + return; + } + + if (strcmp(event_type, "C2C_MESSAGE_CREATE") == 0) { + cJSON *author = cJSON_GetObjectItem(data, "author"); + cJSON *openid = author ? cJSON_GetObjectItem(author, "user_openid") : NULL; + cJSON *content_json = cJSON_GetObjectItem(data, "content"); + cJSON *id_json = cJSON_GetObjectItem(data, "id"); + + if (!cJSON_IsString(openid) || !cJSON_IsString(id_json)) { + return; + } + snprintf(chat_id, sizeof(chat_id), "c2c:%s", openid->valuestring); + strlcpy(sender_id, openid->valuestring, sizeof(sender_id)); + content = cJSON_IsString(content_json) ? content_json->valuestring : ""; + message_id = id_json->valuestring; + attachments = cJSON_GetObjectItem(data, "attachments"); + } else if (strcmp(event_type, "GROUP_AT_MESSAGE_CREATE") == 0) { + cJSON *group = cJSON_GetObjectItem(data, "group_openid"); + cJSON *author = cJSON_GetObjectItem(data, "author"); + cJSON *member = author ? cJSON_GetObjectItem(author, "member_openid") : NULL; + cJSON *content_json = cJSON_GetObjectItem(data, "content"); + cJSON *id_json = cJSON_GetObjectItem(data, "id"); + + if (!cJSON_IsString(group) || !cJSON_IsString(id_json)) { + return; + } + snprintf(chat_id, sizeof(chat_id), "group:%s", group->valuestring); + if (cJSON_IsString(member)) { + strlcpy(sender_id, member->valuestring, sizeof(sender_id)); + } + content = cJSON_IsString(content_json) ? content_json->valuestring : ""; + message_id = id_json->valuestring; + attachments = cJSON_GetObjectItem(data, "attachments"); + } else { + ESP_LOGI(TAG, "QQ dispatch type %s is not handled", event_type); + return; + } + + if (cap_im_qq_dedup_check_and_record(message_id)) { + return; + } + + cap_im_qq_handle_attachments(attachments, chat_id, sender_id, message_id); + + if (content && content[0]) { + if (cap_im_qq_publish_inbound_text(chat_id, sender_id, message_id, content) == ESP_OK) { + ESP_LOGI(TAG, "QQ inbound %s: %.48s%s", chat_id, content, strlen(content) > 48 ? "..." : ""); + } else { + ESP_LOGW(TAG, "Failed to publish QQ inbound message"); + } + } +} + +static void cap_im_qq_log_stack_watermark(const char *label) +{ + UBaseType_t words = uxTaskGetStackHighWaterMark(NULL); + + ESP_LOGD(TAG, "%s stack_high_water=%u bytes", label, (unsigned int)(words * sizeof(StackType_t))); +} + +static void cap_im_qq_process_frame(const char *frame, size_t frame_len) +{ + cJSON *root; + cJSON *op_json; + cJSON *data_json; + cJSON *seq_json; + cJSON *type_json; + int op; + const char *dispatch_type; + + root = cJSON_ParseWithLength(frame, frame_len); + if (!root) { + ESP_LOGW(TAG, "QQ frame parse failed len=%u", (unsigned int)frame_len); + return; + } + + op_json = cJSON_GetObjectItem(root, "op"); + data_json = cJSON_GetObjectItem(root, "d"); + seq_json = cJSON_GetObjectItem(root, "s"); + type_json = cJSON_GetObjectItem(root, "t"); + + if (cJSON_IsNumber(seq_json)) { + s_qq.last_seq = seq_json->valueint; + } + + op = cJSON_IsNumber(op_json) ? op_json->valueint : -1; + dispatch_type = cJSON_IsString(type_json) ? type_json->valuestring : ""; + + switch (op) { + case CAP_IM_QQ_WS_OP_HELLO: + if (cJSON_IsObject(data_json)) { + cJSON *heartbeat_json = cJSON_GetObjectItem(data_json, "heartbeat_interval"); + + if (cJSON_IsNumber(heartbeat_json)) { + s_qq.heartbeat_interval_ms = heartbeat_json->valueint; + } + } + s_qq.ws_identify_pending = true; + s_qq.ws_connected = true; + break; + case CAP_IM_QQ_WS_OP_DISPATCH: + if (strcmp(dispatch_type, "READY") == 0) { + ESP_LOGI(TAG, "QQ gateway ready"); + } else { + cap_im_qq_handle_dispatch(data_json, dispatch_type); + } + break; + case CAP_IM_QQ_WS_OP_RECONNECT: + case CAP_IM_QQ_WS_OP_INVALID_SESSION: + s_qq.ws_should_reconnect = true; + break; + case CAP_IM_QQ_WS_OP_HEARTBEAT_ACK: + default: + break; + } + + cJSON_Delete(root); +} + +static void cap_im_qq_inbound_task(void *arg) +{ + cap_im_qq_inbound_frame_t item = {0}; + + (void)arg; + + while (1) { + if (xQueueReceive(s_qq.inbound_queue, &item, pdMS_TO_TICKS(200)) == pdTRUE) { + cap_im_qq_process_frame(item.frame, item.len); + free(item.frame); + item.frame = NULL; + cap_im_qq_log_stack_watermark("qq_inbound"); + continue; + } + + if (s_qq.stop_requested) { + break; + } + } + + s_qq.inbound_task = NULL; + vTaskDelete(NULL); +} + +static void cap_im_qq_ws_event_handler(void *arg, + esp_event_base_t base, + int32_t event_id, + void *event_data) +{ + esp_websocket_event_data_t *event = (esp_websocket_event_data_t *)event_data; + + (void)arg; + (void)base; + + if (event_id == WEBSOCKET_EVENT_CONNECTED) { + s_qq.ws_connected = true; + s_qq.ws_should_reconnect = false; + return; + } + if (event_id == WEBSOCKET_EVENT_DISCONNECTED) { + s_qq.ws_connected = false; + s_qq.ws_identify_pending = false; + s_qq.ws_should_reconnect = true; + return; + } + if (event_id != WEBSOCKET_EVENT_DATA || !event || + event->op_code != 0x01 || !event->data_ptr || event->data_len <= 0) { + return; + } + + if (s_qq.inbound_queue) { + cap_im_qq_inbound_frame_t item = { + .frame = calloc(1, (size_t)event->data_len + 1), + .len = (size_t)event->data_len, + }; + + if (!item.frame) { + ESP_LOGW(TAG, "QQ inbound queue alloc failed len=%d", event->data_len); + return; + } + + memcpy(item.frame, event->data_ptr, item.len); + if (xQueueSend(s_qq.inbound_queue, &item, 0) != pdTRUE) { + ESP_LOGW(TAG, "QQ inbound queue full, dropping frame len=%d", event->data_len); + free(item.frame); + } else { + } + } +} + +static esp_err_t cap_im_qq_api_post(const char *path, + const char *body_json, + char **out_response) +{ + int attempt = 0; + +retry: + cap_im_qq_http_resp_t resp = {0}; + esp_http_client_config_t config = {0}; + esp_http_client_handle_t client = NULL; + char *url = NULL; + char *auth = NULL; + esp_err_t err; + int needed; + int status; + + err = cap_im_qq_get_access_token(); + if (err != ESP_OK) { + return err; + } + + needed = snprintf(NULL, 0, "%s%s", CAP_IM_QQ_API_BASE, path); + if (needed < 0) { + return ESP_FAIL; + } + + url = calloc(1, (size_t)needed + 1); + resp.buf = calloc(1, 4096); + auth = cap_im_qq_make_auth_header(); + if (!url || !resp.buf || !auth) { + free(url); + free(resp.buf); + free(auth); + return ESP_ERR_NO_MEM; + } + resp.cap = 4096; + snprintf(url, (size_t)needed + 1, "%s%s", CAP_IM_QQ_API_BASE, path); + + config.url = url; + config.event_handler = cap_im_qq_http_event_handler; + config.user_data = &resp; + config.timeout_ms = 15000; + config.buffer_size = 1024; + config.buffer_size_tx = 2048; + config.crt_bundle_attach = esp_crt_bundle_attach; + + client = esp_http_client_init(&config); + if (!client) { + free(url); + free(resp.buf); + free(auth); + return ESP_FAIL; + } + + esp_http_client_set_method(client, HTTP_METHOD_POST); + esp_http_client_set_header(client, "Authorization", auth); + esp_http_client_set_header(client, "Content-Type", "application/json"); + if (body_json) { + esp_http_client_set_post_field(client, body_json, strlen(body_json)); + } + + err = esp_http_client_perform(client); + status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + free(url); + free(auth); + + if (err != ESP_OK) { + free(resp.buf); + return err; + } + + if (status < 200 || status >= 300) { + if (attempt == 0 && cap_im_qq_is_token_invalid_response(resp.buf)) { + cap_im_qq_invalidate_token(); + free(resp.buf); + attempt++; + goto retry; + } + if (out_response) { + *out_response = resp.buf; + } else { + free(resp.buf); + } + return ESP_FAIL; + } + + if (out_response) { + *out_response = resp.buf; + } else { + free(resp.buf); + } + return ESP_OK; +} + +static char *cap_im_qq_build_chat_path(const char *chat_id, const char *suffix) +{ + const char *format = NULL; + size_t prefix_len = 0; + char *path = NULL; + int needed; + + if (!chat_id || !suffix) { + return NULL; + } + + if (strncmp(chat_id, "c2c:", 4) == 0) { + format = "/v2/users/%s/%s"; + prefix_len = 4; + } else if (strncmp(chat_id, "group:", 6) == 0) { + format = "/v2/groups/%s/%s"; + prefix_len = 6; + } else { + return NULL; + } + + needed = snprintf(NULL, 0, format, chat_id + prefix_len, suffix); + if (needed < 0) { + return NULL; + } + + path = calloc(1, (size_t)needed + 1); + if (!path) { + return NULL; + } + + snprintf(path, (size_t)needed + 1, format, chat_id + prefix_len, suffix); + return path; +} + +static char *cap_im_qq_build_message_path(const char *chat_id) +{ + return cap_im_qq_build_chat_path(chat_id, "messages"); +} + +static char *cap_im_qq_build_file_path(const char *chat_id) +{ + return cap_im_qq_build_chat_path(chat_id, "files"); +} + +static char *cap_im_qq_file_to_base64(const char *path) +{ + struct stat st; + FILE *file = NULL; + unsigned char *raw_buf = NULL; + unsigned char *encoded_buf = NULL; + size_t raw_size; + size_t read_size; + size_t encoded_len = 0; + + if (!path || stat(path, &st) != 0) { + return NULL; + } + if (st.st_size <= 0) { + return NULL; + } + + raw_size = (size_t)st.st_size; + file = fopen(path, "rb"); + if (!file) { + return NULL; + } + + raw_buf = malloc(raw_size); + if (!raw_buf) { + fclose(file); + return NULL; + } + + read_size = fread(raw_buf, 1, raw_size, file); + fclose(file); + if (read_size != raw_size) { + free(raw_buf); + return NULL; + } + + if (mbedtls_base64_encode(NULL, 0, &encoded_len, raw_buf, read_size) != + MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) { + free(raw_buf); + return NULL; + } + + encoded_buf = malloc(encoded_len + 1); + if (!encoded_buf) { + free(raw_buf); + return NULL; + } + + if (mbedtls_base64_encode(encoded_buf, encoded_len, &encoded_len, raw_buf, read_size) != 0) { + free(raw_buf); + free(encoded_buf); + return NULL; + } + + free(raw_buf); + encoded_buf[encoded_len] = '\0'; + return (char *)encoded_buf; +} + +static esp_err_t cap_im_qq_send_media_message(const char *chat_id, + const char *file_info, + const char *caption) +{ + cJSON *body = NULL; + cJSON *media = NULL; + char *json_str = NULL; + char *path = NULL; + esp_err_t err; + + if (!chat_id || !file_info || !file_info[0]) { + return ESP_ERR_INVALID_ARG; + } + + body = cJSON_CreateObject(); + if (!body) { + return ESP_ERR_NO_MEM; + } + cJSON_AddNumberToObject(body, "msg_type", 7); + media = cJSON_CreateObject(); + if (!media) { + cJSON_Delete(body); + return ESP_ERR_NO_MEM; + } + cJSON_AddStringToObject(media, "file_info", file_info); + cJSON_AddItemToObject(body, "media", media); + if (caption && caption[0]) { + cJSON_AddStringToObject(body, "content", caption); + } + + json_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!json_str) { + return ESP_ERR_NO_MEM; + } + + path = cap_im_qq_build_message_path(chat_id); + if (!path) { + free(json_str); + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_qq_api_post(path, json_str, NULL); + free(path); + free(json_str); + return err; +} + +static esp_err_t cap_im_qq_upload_media(const char *chat_id, + const char *path, + uint32_t file_type, + char **out_file_info) +{ + struct stat st; + cJSON *body = NULL; + char *path_buf = NULL; + char *file_b64 = NULL; + char *json_str = NULL; + char *response = NULL; + cJSON *root = NULL; + cJSON *file_info_json = NULL; + esp_err_t err; + + if (!chat_id || !path || !path[0] || !out_file_info) { + return ESP_ERR_INVALID_ARG; + } + 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; + } + + file_b64 = cap_im_qq_file_to_base64(path); + if (!file_b64) { + ESP_LOGE(TAG, "Failed to encode QQ media file: %s", path); + return ESP_FAIL; + } + + body = cJSON_CreateObject(); + if (!body) { + free(file_b64); + return ESP_ERR_NO_MEM; + } + cJSON_AddNumberToObject(body, "file_type", (double)file_type); + cJSON_AddBoolToObject(body, "srv_send_msg", false); + cJSON_AddStringToObject(body, "file_data", file_b64); + json_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + free(file_b64); + if (!json_str) { + return ESP_ERR_NO_MEM; + } + + path_buf = cap_im_qq_build_file_path(chat_id); + if (!path_buf) { + free(json_str); + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_qq_api_post(path_buf, json_str, &response); + free(path_buf); + free(json_str); + if (err != ESP_OK) { + ESP_LOGE(TAG, "QQ media upload failed for %s: %s", chat_id, esp_err_to_name(err)); + free(response); + return err; + } + + root = cJSON_Parse(response); + free(response); + if (!root) { + ESP_LOGE(TAG, "QQ media upload response parse failed"); + return ESP_FAIL; + } + + file_info_json = cJSON_GetObjectItem(root, "file_info"); + if (!cJSON_IsString(file_info_json) || !file_info_json->valuestring || + !file_info_json->valuestring[0]) { + cJSON_Delete(root); + ESP_LOGE(TAG, "QQ media upload response missing file_info"); + return ESP_FAIL; + } + + *out_file_info = strdup(file_info_json->valuestring); + cJSON_Delete(root); + return *out_file_info ? ESP_OK : ESP_ERR_NO_MEM; +} + +static esp_err_t cap_im_qq_send_media(const char *chat_id, + const char *path, + const char *caption, + uint32_t file_type, + const char *kind) +{ + char *file_info = NULL; + char *resolved_caption = NULL; + esp_err_t err; + + if (!chat_id || !path || !path[0]) { + return ESP_ERR_INVALID_ARG; + } + if (s_qq.app_id[0] == '\0' || s_qq.app_secret[0] == '\0') { + return ESP_ERR_INVALID_STATE; + } + + err = cap_im_qq_upload_media(chat_id, path, file_type, &file_info); + if (err != ESP_OK) { + return err; + } + + if (file_type == CAP_IM_QQ_FILE_TYPE_FILE) { + const char *base_name = cap_im_qq_basename(path); + + if (caption && caption[0]) { + int needed = snprintf(NULL, 0, "%s\nfilename: %s", caption, base_name); + + if (needed < 0) { + free(file_info); + return ESP_FAIL; + } + resolved_caption = calloc(1, (size_t)needed + 1); + if (!resolved_caption) { + free(file_info); + return ESP_ERR_NO_MEM; + } + snprintf(resolved_caption, (size_t)needed + 1, "%s\nfilename: %s", caption, base_name); + } else if (base_name && base_name[0]) { + int needed = snprintf(NULL, 0, "filename: %s", base_name); + + if (needed < 0) { + free(file_info); + return ESP_FAIL; + } + resolved_caption = calloc(1, (size_t)needed + 1); + if (!resolved_caption) { + free(file_info); + return ESP_ERR_NO_MEM; + } + snprintf(resolved_caption, (size_t)needed + 1, "filename: %s", base_name); + } + } + + err = cap_im_qq_send_media_message(chat_id, + file_info, + resolved_caption ? resolved_caption : caption); + if (err != ESP_OK) { + ESP_LOGE(TAG, "QQ %s send failed for %s: %s", kind, chat_id, esp_err_to_name(err)); + free(resolved_caption); + free(file_info); + return err; + } + + ESP_LOGI(TAG, "QQ %s send success to %s: %s", kind, chat_id, path); + free(resolved_caption); + free(file_info); + return ESP_OK; +} + +static esp_err_t cap_im_qq_send_message_chunk(const char *chat_id, const char *message) +{ + cJSON *body = cJSON_CreateObject(); + char *json_str = NULL; + char *path = NULL; + esp_err_t err; + + if (!body) { + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(body, "content", message); + cJSON_AddNumberToObject(body, "msg_type", 0); + json_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!json_str) { + return ESP_ERR_NO_MEM; + } + + path = cap_im_qq_build_message_path(chat_id); + if (!path) { + free(json_str); + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_qq_api_post(path, json_str, NULL); + free(json_str); + free(path); + return err; +} + +static esp_err_t cap_im_qq_gateway_init(void) +{ + if (s_qq.app_id[0] == '\0' || s_qq.app_secret[0] == '\0') { + ESP_LOGW(TAG, "QQ credentials not configured"); + return ESP_OK; + } + + ESP_LOGI(TAG, "QQ configured (app_id=%.8s...)", s_qq.app_id); + return ESP_OK; +} + +static void cap_im_qq_ws_task(void *arg) +{ + (void)arg; + + while (!s_qq.stop_requested) { + esp_websocket_client_config_t ws_config = {0}; + int64_t connect_start_ms; + int64_t last_heartbeat_ms = 0; + + if (cap_im_qq_fetch_gateway_url() != ESP_OK) { + if (s_qq.stop_requested) { + break; + } + vTaskDelay(pdMS_TO_TICKS(CAP_IM_QQ_RECONNECT_DELAY_MS)); + continue; + } + + ws_config.uri = s_qq.ws_url; + ws_config.buffer_size = 1024; + ws_config.task_stack = CAP_IM_QQ_WS_CLIENT_STACK; + ws_config.task_prio = CAP_IM_QQ_WS_PRIO; + ws_config.reconnect_timeout_ms = CAP_IM_QQ_RECONNECT_DELAY_MS; + ws_config.network_timeout_ms = 10000; + ws_config.disable_auto_reconnect = true; + ws_config.crt_bundle_attach = esp_crt_bundle_attach; + + s_qq.ws_client = esp_websocket_client_init(&ws_config); + if (!s_qq.ws_client) { + if (s_qq.stop_requested) { + break; + } + vTaskDelay(pdMS_TO_TICKS(CAP_IM_QQ_RECONNECT_DELAY_MS)); + continue; + } + + s_qq.last_seq = -1; + s_qq.ws_connected = false; + s_qq.ws_identify_pending = false; + s_qq.ws_should_reconnect = false; + esp_websocket_register_events(s_qq.ws_client, + WEBSOCKET_EVENT_ANY, + cap_im_qq_ws_event_handler, + NULL); + esp_websocket_client_start(s_qq.ws_client); + connect_start_ms = esp_timer_get_time() / 1000LL; + + while (s_qq.ws_client && !s_qq.stop_requested) { + int64_t now_ms = esp_timer_get_time() / 1000LL; + + if (s_qq.ws_identify_pending) { + if (cap_im_qq_ws_send_identify() == ESP_OK) { + s_qq.ws_identify_pending = false; + last_heartbeat_ms = now_ms; + } else { + s_qq.ws_should_reconnect = true; + } + } else if (s_qq.ws_connected && + now_ms - last_heartbeat_ms >= s_qq.heartbeat_interval_ms) { + if (cap_im_qq_ws_send_heartbeat() != ESP_OK) { + s_qq.ws_should_reconnect = true; + } + last_heartbeat_ms = now_ms; + } + + if (s_qq.ws_should_reconnect) { + break; + } + if (!esp_websocket_client_is_connected(s_qq.ws_client) && !s_qq.ws_connected && + now_ms - connect_start_ms >= CAP_IM_QQ_WS_CONNECT_GRACE_MS) { + break; + } + + vTaskDelay(pdMS_TO_TICKS(200)); + } + + esp_websocket_client_stop(s_qq.ws_client); + esp_websocket_client_destroy(s_qq.ws_client); + s_qq.ws_client = NULL; + s_qq.ws_connected = false; + s_qq.ws_identify_pending = false; + s_qq.ws_should_reconnect = false; + if (s_qq.stop_requested) { + break; + } + vTaskDelay(pdMS_TO_TICKS(CAP_IM_QQ_RECONNECT_DELAY_MS)); + } + + s_qq.ws_task = NULL; + s_qq.ws_client = NULL; + vTaskDelete(NULL); +} + +static void cap_im_qq_reset_runtime_state(void) +{ + s_qq.ws_client = NULL; + s_qq.ws_task = NULL; + s_qq.inbound_task = NULL; + s_qq.ws_connected = false; + s_qq.ws_identify_pending = false; + s_qq.ws_should_reconnect = false; + s_qq.stop_requested = false; + s_qq.last_seq = -1; +} + +static esp_err_t cap_im_qq_gateway_start(void) +{ + BaseType_t ok; + + if (s_qq.app_id[0] == '\0' || s_qq.app_secret[0] == '\0') { + ESP_LOGW(TAG, "QQ not configured, skipping gateway start"); + return ESP_OK; + } + if (s_qq.ws_task) { + return ESP_OK; + } + s_qq.stop_requested = false; + if (!s_qq.inbound_queue) { + s_qq.inbound_queue = xQueueCreate(CAP_IM_QQ_INBOUND_QUEUE_LEN, + sizeof(cap_im_qq_inbound_frame_t)); + if (!s_qq.inbound_queue) { + return ESP_ERR_NO_MEM; + } + } + + ok = xTaskCreate(cap_im_qq_inbound_task, + "qq_inbound", + CAP_IM_QQ_INBOUND_TASK_STACK, + NULL, + CAP_IM_QQ_WS_PRIO, + &s_qq.inbound_task); + if (ok != pdPASS) { + vQueueDelete(s_qq.inbound_queue); + s_qq.inbound_queue = NULL; + s_qq.inbound_task = NULL; + return ESP_FAIL; + } + + ok = xTaskCreate(cap_im_qq_ws_task, + "qq_ws", + CAP_IM_QQ_WS_TASK_STACK, + NULL, + CAP_IM_QQ_WS_PRIO, + &s_qq.ws_task); + if (ok != pdPASS) { + s_qq.stop_requested = true; + s_qq.ws_task = NULL; + while (s_qq.inbound_task) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + vQueueDelete(s_qq.inbound_queue); + s_qq.inbound_queue = NULL; + return ESP_FAIL; + } + + return ESP_OK; +} + +static esp_err_t cap_im_qq_gateway_stop(void) +{ + TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(10000); + + if (!s_qq.ws_task) { + cap_im_qq_reset_runtime_state(); + return ESP_OK; + } + + s_qq.stop_requested = true; + s_qq.ws_should_reconnect = true; + if (s_qq.ws_client) { + esp_websocket_client_stop(s_qq.ws_client); + } + + while (s_qq.ws_task && xTaskGetTickCount() < deadline) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + + if (s_qq.ws_task) { + return ESP_ERR_TIMEOUT; + } + + while (s_qq.inbound_task && xTaskGetTickCount() < deadline) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + + if (s_qq.inbound_task) { + return ESP_ERR_TIMEOUT; + } + + if (s_qq.inbound_queue) { + cap_im_qq_inbound_frame_t item = {0}; + + while (xQueueReceive(s_qq.inbound_queue, &item, 0) == pdTRUE) { + free(item.frame); + } + vQueueDelete(s_qq.inbound_queue); + s_qq.inbound_queue = NULL; + } + + cap_im_qq_reset_runtime_state(); + return ESP_OK; +} + +static esp_err_t cap_im_qq_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; + cJSON *message_json; + const char *chat_id = NULL; + const char *message = NULL; + esp_err_t err; + + root = cJSON_Parse(input_json ? input_json : "{}"); + if (!root) { + snprintf(output, output_size, "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; + } + if (cJSON_IsString(message_json) && message_json->valuestring && message_json->valuestring[0]) { + message = message_json->valuestring; + } + + if (!chat_id || !message) { + ESP_LOGW(TAG, + "QQ outbound invalid args chat_id=%s message_present=%s input=%s", + chat_id ? chat_id : "(null)", + message ? "true" : "false", + input_json ? input_json : "(null)"); + cJSON_Delete(root); + snprintf(output, + output_size, + "Error: chat_id and message are required (chat_id may come from ctx)"); + return ESP_ERR_INVALID_ARG; + } + + ESP_LOGI(TAG, "QQ outbound text to %s: %.48s%s", + chat_id, + message, + strlen(message) > 48 ? "..." : ""); + + err = cap_im_qq_send_text(chat_id, message); + cJSON_Delete(root); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: %s", esp_err_to_name(err)); + return err; + } + + snprintf(output, output_size, "reply already sent to user"); + return ESP_OK; +} + +static esp_err_t cap_im_qq_send_media_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size, + uint32_t file_type, + const char *kind) +{ + cJSON *root = NULL; + cJSON *chat_id_json; + cJSON *path_json; + cJSON *caption_json; + const char *chat_id = NULL; + const char *path = NULL; + const char *caption = NULL; + esp_err_t err; + + root = cJSON_Parse(input_json ? input_json : "{}"); + if (!root) { + snprintf(output, output_size, "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, + "Error: chat_id and path are required (chat_id may come from ctx)"); + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_qq_send_media(chat_id, path, caption, file_type, kind); + cJSON_Delete(root); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: %s", esp_err_to_name(err)); + return err; + } + + snprintf(output, output_size, "reply already sent to user"); + return ESP_OK; +} + +static esp_err_t cap_im_qq_send_image_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + return cap_im_qq_send_media_execute(input_json, + ctx, + output, + output_size, + CAP_IM_QQ_FILE_TYPE_IMAGE, + "image"); +} + +static esp_err_t cap_im_qq_send_file_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + return cap_im_qq_send_media_execute(input_json, + ctx, + output, + output_size, + CAP_IM_QQ_FILE_TYPE_FILE, + "file"); +} + +static const claw_cap_descriptor_t s_qq_descriptors[] = { + { + .id = "qq_gateway", + .name = "qq_gateway", + .family = "im", + .description = "Official QQ Bot 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_qq_gateway_init, + .start = cap_im_qq_gateway_start, + .stop = cap_im_qq_gateway_stop, + }, + { + .id = "qq_send_message", + .name = "qq_send_message", + .family = "im", + .description = "Send a text message to an explicit QQ chat_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_qq_send_message_execute, + }, + { + .id = "qq_send_image", + .name = "qq_send_image", + .family = "im", + .description = "Send an image file from a local path to a QQ 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_qq_send_image_execute, + }, + { + .id = "qq_send_file", + .name = "qq_send_file", + .family = "im", + .description = "Send a file from a local path to a QQ 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_qq_send_file_execute, + }, +}; + +static const claw_cap_group_t s_qq_group = { + .group_id = "cap_im_qq", + .descriptors = s_qq_descriptors, + .descriptor_count = sizeof(s_qq_descriptors) / sizeof(s_qq_descriptors[0]), +}; + +esp_err_t cap_im_qq_register_group(void) +{ + if (claw_cap_group_exists(s_qq_group.group_id)) { + return ESP_OK; + } + return claw_cap_register_group(&s_qq_group); +} + +esp_err_t cap_im_qq_set_credentials(const char *app_id, const char *app_secret) +{ + if (!app_id || !app_secret) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(s_qq.app_id, app_id, sizeof(s_qq.app_id)); + strlcpy(s_qq.app_secret, app_secret, sizeof(s_qq.app_secret)); + s_qq.access_token[0] = '\0'; + s_qq.token_expire_time = 0; + return ESP_OK; +} + +esp_err_t cap_im_qq_set_attachment_config( + const cap_im_qq_attachment_config_t *config) +{ + if (!config) { + return ESP_ERR_INVALID_ARG; + } + + s_qq.enable_inbound_attachments = config->enable_inbound_attachments; + s_qq.max_inbound_file_bytes = config->max_inbound_file_bytes; + if (config->storage_root_dir) { + strlcpy(s_qq.attachment_root_dir, + config->storage_root_dir, + sizeof(s_qq.attachment_root_dir)); + } else { + s_qq.attachment_root_dir[0] = '\0'; + } + + return ESP_OK; +} + +esp_err_t cap_im_qq_start(void) +{ + return cap_im_qq_gateway_start(); +} + +esp_err_t cap_im_qq_stop(void) +{ + return cap_im_qq_gateway_stop(); +} + +esp_err_t cap_im_qq_send_text(const char *chat_id, const char *text) +{ + size_t text_len; + size_t offset = 0; + esp_err_t last_err = ESP_OK; + + if (!chat_id || !text || text[0] == '\0') { + return ESP_ERR_INVALID_ARG; + } + if (s_qq.app_id[0] == '\0' || s_qq.app_secret[0] == '\0') { + return ESP_ERR_INVALID_STATE; + } + + text_len = strlen(text); + while (offset < text_len) { + size_t chunk_len = text_len - offset; + char *chunk = NULL; + esp_err_t err; + + if (chunk_len > CAP_IM_QQ_MAX_MSG_LEN) { + chunk_len = CAP_IM_QQ_MAX_MSG_LEN; + } + + chunk = calloc(1, chunk_len + 1); + if (!chunk) { + return ESP_ERR_NO_MEM; + } + memcpy(chunk, text + offset, chunk_len); + + err = cap_im_qq_send_message_chunk(chat_id, chunk); + free(chunk); + if (err != ESP_OK) { + last_err = err; + } + + offset += chunk_len; + } + + return last_err; +} + +esp_err_t cap_im_qq_send_image(const char *chat_id, const char *path, const char *caption) +{ + return cap_im_qq_send_media(chat_id, + path, + caption, + CAP_IM_QQ_FILE_TYPE_IMAGE, + "image"); +} + +esp_err_t cap_im_qq_send_file(const char *chat_id, const char *path, const char *caption) +{ + return cap_im_qq_send_media(chat_id, + path, + caption, + CAP_IM_QQ_FILE_TYPE_FILE, + "file"); +} diff --git a/components/cap_im_qq/src/cmd_cap_im_qq.c b/components/cap_im_qq/src/cmd_cap_im_qq.c new file mode 100644 index 0000000..c22c349 --- /dev/null +++ b/components/cap_im_qq/src/cmd_cap_im_qq.c @@ -0,0 +1,202 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cmd_cap_im_qq.h" + +#include +#include + +#include "argtable3/argtable3.h" +#include "cap_im_qq.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; +} qq_args; + +static int cmd_qq_credentials(const char *app_id, const char *app_secret) +{ + esp_err_t err; + + err = cap_im_qq_set_credentials(app_id, app_secret); + if (err != ESP_OK) { + printf("qq_credentials failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("QQ credentials updated\n"); + return 0; +} + +static int cmd_qq_start(void) +{ + esp_err_t err; + + err = cap_im_qq_start(); + if (err != ESP_OK) { + printf("qq_start failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("QQ gateway started\n"); + return 0; +} + +static int cmd_qq_stop(void) +{ + esp_err_t err; + + err = cap_im_qq_stop(); + if (err != ESP_OK) { + printf("qq_stop failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("QQ gateway stopped\n"); + return 0; +} + +static int cmd_qq_send_text(const char *chat_id, const char *text) +{ + esp_err_t err; + + err = cap_im_qq_send_text(chat_id, text); + if (err != ESP_OK) { + printf("qq_send_text failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("QQ text sent\n"); + return 0; +} + +static int cmd_qq_send_image(const char *chat_id, const char *path, const char *caption) +{ + esp_err_t err; + + err = cap_im_qq_send_image(chat_id, path, caption); + if (err != ESP_OK) { + printf("qq_send_image failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("QQ image sent\n"); + return 0; +} + +static int cmd_qq_send_file(const char *chat_id, const char *path, const char *caption) +{ + esp_err_t err; + + err = cap_im_qq_send_file(chat_id, path, caption); + if (err != ESP_OK) { + printf("qq_send_file failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("QQ file sent\n"); + return 0; +} + +static int qq_func(int argc, char **argv) +{ + int nerrors = arg_parse(argc, argv, (void **) &qq_args); + if (nerrors != 0) { + arg_print_errors(stderr, qq_args.end, argv[0]); + return 1; + } + + int operation_count = qq_args.set_credentials->count + qq_args.start->count + qq_args.stop->count + + qq_args.send_text->count + qq_args.send_image->count + qq_args.send_file->count; + + if (operation_count != 1) { + printf("Exactly one operation must be specified\n"); + return 1; + } + + if (qq_args.set_credentials->count) { + if (!qq_args.app_id->count || !qq_args.app_secret->count) { + printf("'--set-credentials' requires '--app-id' and '--app-secret'\n"); + return 1; + } + + return cmd_qq_credentials(qq_args.app_id->sval[0], qq_args.app_secret->sval[0]); + } + + if (qq_args.start->count) { + return cmd_qq_start(); + } + + if (qq_args.stop->count) { + return cmd_qq_stop(); + } + + if (qq_args.send_text->count) { + if (!qq_args.text->count) { + printf("'--send-text' requires '--text'\n"); + return 1; + } + + return cmd_qq_send_text(qq_args.send_text->sval[0], qq_args.text->sval[0]); + } + + if (!qq_args.path->count) { + printf("'--send-image' and '--send-file' require '--path'\n"); + return 1; + } + + if (qq_args.send_image->count) { + return cmd_qq_send_image(qq_args.send_image->sval[0], + qq_args.path->sval[0], + qq_args.caption->count ? qq_args.caption->sval[0] : NULL); + } + + return cmd_qq_send_file(qq_args.send_file->sval[0], + qq_args.path->sval[0], + qq_args.caption->count ? qq_args.caption->sval[0] : NULL); +} + +void register_cap_im_qq(void) +{ + qq_args.set_credentials = arg_lit0("c", "set-credentials", "Set QQ bot credentials"); + qq_args.start = arg_lit0(NULL, "start", "Start the QQ bot gateway"); + qq_args.stop = arg_lit0(NULL, "stop", "Stop the QQ bot gateway"); + qq_args.send_text = arg_str0(NULL, "send-text", "", "Send text to a QQ chat"); + qq_args.send_image = arg_str0(NULL, "send-image", "", "Send an image to a QQ chat"); + qq_args.send_file = arg_str0(NULL, "send-file", "", "Send a file to a QQ chat"); + qq_args.app_id = arg_str0(NULL, "app-id", "", "QQ bot app id"); + qq_args.app_secret = arg_str0(NULL, "app-secret", "", "QQ bot app secret"); + qq_args.text = arg_str0("t", "text", "", "Text content"); + qq_args.path = arg_str0("p", "path", "", "Local file path"); + qq_args.caption = arg_str0(NULL, "caption", "", "Optional caption"); + qq_args.end = arg_end(8); + + const esp_console_cmd_t qq_cmd = { + .command = "qq", + .help = "QQ operation.\n" + "Examples:\n" + " qq --set-credentials --app-id 123 --app-secret abc\n" + " qq --start\n" + " qq --stop\n" + " qq --send-text group123 --text \"hello\"\n" + " qq --send-image group123 --path /spiffs/a.jpg --caption \"hi\"\n" + " qq --send-file group123 --path /spiffs/a.txt --caption \"file\"\n", + .func = qq_func, + .argtable = &qq_args, + }; + + ESP_ERROR_CHECK(esp_console_cmd_register(&qq_cmd)); +} diff --git a/components/cap_im_tg/CMakeLists.txt b/components/cap_im_tg/CMakeLists.txt new file mode 100644 index 0000000..b78c8da --- /dev/null +++ b/components/cap_im_tg/CMakeLists.txt @@ -0,0 +1,16 @@ +idf_component_register( + SRCS + "src/cap_im_tg.c" + "src/cmd_cap_im_tg.c" + INCLUDE_DIRS + "include" + REQUIRES + cap_im_attachment + claw_cap + claw_event_router + esp_http_client + esp_timer + freertos + json + console +) diff --git a/components/cap_im_tg/idf_component.yml b/components/cap_im_tg/idf_component.yml new file mode 100644 index 0000000..c093387 --- /dev/null +++ b/components/cap_im_tg/idf_component.yml @@ -0,0 +1 @@ +dependencies: {} diff --git a/components/cap_im_tg/include/cap_im_tg.h b/components/cap_im_tg/include/cap_im_tg.h new file mode 100644 index 0000000..aa26847 --- /dev/null +++ b/components/cap_im_tg/include/cap_im_tg.h @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include + +#include "claw_cap.h" +#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_tg_attachment_config_t; + +esp_err_t cap_im_tg_register_group(void); +esp_err_t cap_im_tg_set_token(const char *bot_token); +esp_err_t cap_im_tg_set_attachment_config( + const cap_im_tg_attachment_config_t *config); +esp_err_t cap_im_tg_start(void); +esp_err_t cap_im_tg_stop(void); +esp_err_t cap_im_tg_send_text(const char *chat_id, const char *text); +esp_err_t cap_im_tg_send_image(const char *chat_id, const char *path, const char *caption); +esp_err_t cap_im_tg_send_file(const char *chat_id, const char *path, const char *caption); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_im_tg/include/cmd_cap_im_tg.h b/components/cap_im_tg/include/cmd_cap_im_tg.h new file mode 100644 index 0000000..5e7df46 --- /dev/null +++ b/components/cap_im_tg/include/cmd_cap_im_tg.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_tg(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_im_tg/src/cap_im_tg.c b/components/cap_im_tg/src/cap_im_tg.c new file mode 100644 index 0000000..e66c6bd --- /dev/null +++ b/components/cap_im_tg/src/cap_im_tg.c @@ -0,0 +1,1496 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_im_tg.h" +#include "cap_im_attachment.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "claw_event_router.h" +#include "esp_crt_bundle.h" +#include "esp_http_client.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" + +static const char *TAG = "cap_im_tg"; + +#define CAP_IM_TG_API_BASE "https://api.telegram.org" +#define CAP_IM_TG_HTTP_RESP_INIT 2048 +#define CAP_IM_TG_MAX_MSG_LEN 4096 +#define CAP_IM_TG_POLL_TIMEOUT_S 20 +#define CAP_IM_TG_RETRY_DELAY_MS 3000 +#define CAP_IM_TG_TASK_STACK 6144 +#define CAP_IM_TG_ATTACHMENT_STACK 8192 +#define CAP_IM_TG_TASK_PRIO 5 +#define CAP_IM_TG_ATTACHMENT_QUEUE_LEN 8 +#define CAP_IM_TG_DEDUP_CACHE_SIZE 64 +#define CAP_IM_TG_PATH_BUF_SIZE 256 +#define CAP_IM_TG_NAME_BUF_SIZE 96 +#define CAP_IM_TG_MULTIPART_BOUNDARY "----cap_im_tg_boundary" + +#ifndef BASIC_DEMO_TG_BOT_TOKEN +#define BASIC_DEMO_TG_BOT_TOKEN "" +#endif + +typedef struct { + char *buf; + size_t len; + size_t cap; +} cap_im_tg_http_resp_t; + +typedef struct { + FILE *file; + size_t bytes_written; + size_t max_bytes; + bool limit_hit; +} cap_im_tg_download_t; + +typedef struct { + char *chat_id; + char *sender_id; + char *message_id; + char *attachment_kind; + char *file_id; + char *original_filename; + char *mime; + char *caption; + char *content_type; +} cap_im_tg_attachment_job_t; + +typedef struct { + char bot_token[192]; + char attachment_root_dir[128]; + size_t max_inbound_file_bytes; + bool enable_inbound_attachments; + TaskHandle_t poll_task; + TaskHandle_t attachment_task; + QueueHandle_t attachment_queue; + volatile bool stop_requested; + int64_t next_update_id; + uint64_t seen_update_keys[CAP_IM_TG_DEDUP_CACHE_SIZE]; + size_t seen_update_idx; +} cap_im_tg_state_t; + +static cap_im_tg_state_t s_tg = { + .bot_token = BASIC_DEMO_TG_BOT_TOKEN, + .max_inbound_file_bytes = 2 * 1024 * 1024, + .enable_inbound_attachments = false, + .next_update_id = 0, +}; + +static int64_t cap_im_tg_now_ms(void) +{ + return esp_timer_get_time() / 1000LL; +} + +static uint64_t cap_im_tg_fnv1a64(const char *text) +{ + uint64_t hash = 1469598103934665603ULL; + + if (!text) { + return hash; + } + + while (*text) { + hash ^= (unsigned char)(*text++); + hash *= 1099511628211ULL; + } + + return hash; +} + +static bool cap_im_tg_dedup_check_and_record(const char *update_key) +{ + uint64_t key; + size_t i; + + if (!update_key || !update_key[0]) { + return false; + } + + key = cap_im_tg_fnv1a64(update_key); + for (i = 0; i < CAP_IM_TG_DEDUP_CACHE_SIZE; i++) { + if (s_tg.seen_update_keys[i] == key) { + return true; + } + } + + s_tg.seen_update_keys[s_tg.seen_update_idx] = key; + s_tg.seen_update_idx = (s_tg.seen_update_idx + 1) % CAP_IM_TG_DEDUP_CACHE_SIZE; + return false; +} + +static esp_err_t cap_im_tg_http_event_handler(esp_http_client_event_t *event) +{ + cap_im_tg_http_resp_t *resp = (cap_im_tg_http_resp_t *)event->user_data; + + if (!resp || event->event_id != HTTP_EVENT_ON_DATA || event->data_len <= 0) { + return ESP_OK; + } + + if (resp->len + (size_t)event->data_len + 1 > resp->cap) { + char *tmp = NULL; + size_t new_cap = resp->cap * 2; + + if (new_cap < resp->len + (size_t)event->data_len + 1) { + new_cap = resp->len + (size_t)event->data_len + 1; + } + + tmp = realloc(resp->buf, new_cap); + if (!tmp) { + return ESP_ERR_NO_MEM; + } + + resp->buf = tmp; + resp->cap = new_cap; + } + + memcpy(resp->buf + resp->len, event->data, event->data_len); + resp->len += (size_t)event->data_len; + resp->buf[resp->len] = '\0'; + return ESP_OK; +} + +static esp_err_t cap_im_tg_api_call(const char *method, + const char *body_json, + char **out_response) +{ + cap_im_tg_http_resp_t resp = {0}; + esp_http_client_config_t config = {0}; + esp_http_client_handle_t client = NULL; + char *url = NULL; + int needed; + esp_err_t err; + int status; + + if (!method || !out_response) { + return ESP_ERR_INVALID_ARG; + } + if (s_tg.bot_token[0] == '\0') { + return ESP_ERR_INVALID_STATE; + } + + needed = snprintf(NULL, 0, "%s/bot%s/%s", CAP_IM_TG_API_BASE, s_tg.bot_token, method); + if (needed < 0) { + return ESP_FAIL; + } + + url = calloc(1, (size_t)needed + 1); + resp.buf = calloc(1, CAP_IM_TG_HTTP_RESP_INIT); + if (!url || !resp.buf) { + free(url); + free(resp.buf); + return ESP_ERR_NO_MEM; + } + + resp.cap = CAP_IM_TG_HTTP_RESP_INIT; + snprintf(url, (size_t)needed + 1, "%s/bot%s/%s", CAP_IM_TG_API_BASE, s_tg.bot_token, method); + + config.url = url; + config.event_handler = cap_im_tg_http_event_handler; + config.user_data = &resp; + config.timeout_ms = (CAP_IM_TG_POLL_TIMEOUT_S + 5) * 1000; + config.buffer_size = 1024; + config.buffer_size_tx = 2048; + config.crt_bundle_attach = esp_crt_bundle_attach; + + client = esp_http_client_init(&config); + if (!client) { + free(url); + free(resp.buf); + return ESP_FAIL; + } + + if (body_json) { + esp_http_client_set_method(client, HTTP_METHOD_POST); + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_http_client_set_post_field(client, body_json, strlen(body_json)); + } + + err = esp_http_client_perform(client); + status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + free(url); + + if (err != ESP_OK) { + free(resp.buf); + return err; + } + + if (status < 200 || status >= 300) { + ESP_LOGW(TAG, "Telegram API error %d: %s", status, resp.buf ? resp.buf : ""); + free(resp.buf); + return ESP_FAIL; + } + + *out_response = resp.buf; + return ESP_OK; +} + +static esp_err_t cap_im_tg_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("tg_gateway", + "telegram", + chat_id, + content, + sender_id, + message_id); +} + +static esp_err_t cap_im_tg_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, "tg_gateway", sizeof(event.source_cap)); + strlcpy(event.event_type, "attachment_saved", sizeof(event.event_type)); + strlcpy(event.source_channel, "telegram", sizeof(event.source_channel)); + strlcpy(event.chat_id, chat_id, sizeof(event.chat_id)); + if (sender_id && sender_id[0]) { + strlcpy(event.sender_id, sender_id, sizeof(event.sender_id)); + } + strlcpy(event.message_id, message_id, sizeof(event.message_id)); + strlcpy(event.content_type, content_type, sizeof(event.content_type)); + event.timestamp_ms = cap_im_tg_now_ms(); + event.session_policy = CLAW_EVENT_SESSION_POLICY_CHAT; + snprintf(event.event_id, sizeof(event.event_id), "tg-attach-%" PRId64, event.timestamp_ms); + event.text = ""; + event.payload_json = (char *)payload_json; + return claw_event_router_publish(&event); +} + +static void cap_im_tg_log_stack_watermark(const char *label) +{ + UBaseType_t words = uxTaskGetStackHighWaterMark(NULL); + + ESP_LOGD(TAG, "%s stack_high_water=%u bytes", label, (unsigned int)(words * sizeof(StackType_t))); +} + +static void cap_im_tg_free_attachment_job(cap_im_tg_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->file_id); + free(job->original_filename); + free(job->mime); + free(job->caption); + free(job->content_type); + free(job); +} + +static char *cap_im_tg_strdup_or_empty(const char *value) +{ + return strdup(value ? value : ""); +} + +static cap_im_tg_attachment_job_t *cap_im_tg_make_attachment_job( + const char *chat_id, + const char *sender_id, + const char *message_id, + const char *attachment_kind, + const char *file_id, + const char *original_filename, + const char *mime, + const char *caption, + const char *content_type) +{ + cap_im_tg_attachment_job_t *job = calloc(1, sizeof(*job)); + + if (!job) { + return NULL; + } + + job->chat_id = cap_im_tg_strdup_or_empty(chat_id); + job->sender_id = cap_im_tg_strdup_or_empty(sender_id); + job->message_id = cap_im_tg_strdup_or_empty(message_id); + job->attachment_kind = cap_im_tg_strdup_or_empty(attachment_kind); + job->file_id = cap_im_tg_strdup_or_empty(file_id); + job->original_filename = cap_im_tg_strdup_or_empty(original_filename); + job->mime = cap_im_tg_strdup_or_empty(mime); + job->caption = cap_im_tg_strdup_or_empty(caption); + job->content_type = cap_im_tg_strdup_or_empty(content_type); + if (!job->chat_id || !job->sender_id || !job->message_id || !job->attachment_kind || + !job->file_id || !job->original_filename || !job->mime || !job->caption || + !job->content_type) { + cap_im_tg_free_attachment_job(job); + return NULL; + } + + return job; +} + +static cJSON *cap_im_tg_select_best_photo(cJSON *message_json) +{ + cJSON *photos = cJSON_GetObjectItem(message_json, "photo"); + cJSON *item = NULL; + cJSON *selected = NULL; + + if (!cJSON_IsArray(photos)) { + return NULL; + } + + cJSON_ArrayForEach(item, photos) { + selected = item; + } + + return selected; +} + +static char *cap_im_tg_get_file_path(const char *file_id) +{ + char method[256]; + char *resp = NULL; + cJSON *root = NULL; + cJSON *ok_json; + cJSON *result_json; + cJSON *file_path_json; + char *copy = NULL; + + if (!file_id || !file_id[0]) { + return NULL; + } + + snprintf(method, sizeof(method), "getFile?file_id=%s", file_id); + if (cap_im_tg_api_call(method, NULL, &resp) != ESP_OK) { + return NULL; + } + + root = cJSON_Parse(resp); + free(resp); + if (!root) { + return NULL; + } + + ok_json = cJSON_GetObjectItem(root, "ok"); + result_json = cJSON_GetObjectItem(root, "result"); + file_path_json = cJSON_IsObject(result_json) ? cJSON_GetObjectItem(result_json, "file_path") : NULL; + if (cJSON_IsTrue(ok_json) && cJSON_IsString(file_path_json) && file_path_json->valuestring) { + copy = strdup(file_path_json->valuestring); + } + + cJSON_Delete(root); + return copy; +} + +static esp_err_t cap_im_tg_save_attachment(const char *chat_id, + const char *sender_id, + const char *message_id, + const char *attachment_kind, + const char *file_id, + const char *original_filename, + const char *mime, + const char *caption, + const char *content_type) +{ + char saved_dir[CAP_IM_TG_PATH_BUF_SIZE]; + char saved_name[CAP_IM_TG_NAME_BUF_SIZE]; + char saved_path[CAP_IM_TG_PATH_BUF_SIZE]; + char *remote_path = NULL; + char download_url[CAP_IM_TG_PATH_BUF_SIZE]; + const char *extension = NULL; + size_t bytes = 0; + char *payload_json = NULL; + esp_err_t err; + + if (!s_tg.enable_inbound_attachments || !s_tg.attachment_root_dir[0] || + !chat_id || !message_id || !attachment_kind || !file_id || !content_type) { + return ESP_ERR_INVALID_STATE; + } + + remote_path = cap_im_tg_get_file_path(file_id); + if (!remote_path) { + return ESP_FAIL; + } + + extension = cap_im_attachment_guess_extension(remote_path, original_filename, mime); + err = cap_im_attachment_build_saved_paths(s_tg.attachment_root_dir, + "telegram", + 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) { + free(remote_path); + return err; + } + + snprintf(download_url, sizeof(download_url), "%s/file/bot%s/%s", + CAP_IM_TG_API_BASE, s_tg.bot_token, remote_path); + err = cap_im_attachment_download_url_to_file(TAG, + download_url, + saved_path, + s_tg.max_inbound_file_bytes, + &bytes); + if (err != ESP_OK) { + free(remote_path); + return err; + } + + payload_json = cap_im_attachment_build_payload_json( + &(cap_im_attachment_payload_config_t) { + .platform = "telegram", + .attachment_kind = attachment_kind, + .saved_path = saved_path, + .saved_dir = saved_dir, + .saved_name = saved_name, + .original_filename = original_filename, + .mime = mime, + .caption = caption, + .source_key = "platform_file_id", + .source_value = file_id, + .size_bytes = bytes, + .saved_at_ms = cap_im_tg_now_ms(), + }); + if (!payload_json) { + free(remote_path); + ESP_LOGW(TAG, "Telegram attachment payload build failed: message=%s path=%s", + message_id, saved_path); + ESP_LOGI(TAG, "Saved Telegram %s to %s (%u bytes)", attachment_kind, saved_path, (unsigned int)bytes); + return ESP_OK; + } + + err = cap_im_tg_publish_attachment_event(chat_id, + sender_id, + message_id, + content_type, + payload_json); + free(payload_json); + free(remote_path); + if (err != ESP_OK) { + ESP_LOGW(TAG, + "Telegram attachment publish event failed: message=%s path=%s err=%s", + message_id, + saved_path, + esp_err_to_name(err)); + } + + ESP_LOGI(TAG, "Saved Telegram %s to %s (%u bytes)", attachment_kind, saved_path, (unsigned int)bytes); + return ESP_OK; +} + +static void cap_im_tg_attachment_task(void *arg) +{ + cap_im_tg_attachment_job_t *job = NULL; + + (void)arg; + + while (1) { + if (xQueueReceive(s_tg.attachment_queue, &job, pdMS_TO_TICKS(200)) == pdTRUE) { + if (job) { + esp_err_t err = cap_im_tg_save_attachment(job->chat_id, + job->sender_id, + job->message_id, + job->attachment_kind, + job->file_id, + job->original_filename, + job->mime, + job->caption, + job->content_type); + if (err != ESP_OK) { + ESP_LOGW(TAG, + "Failed to save Telegram %s message=%s err=%s", + job->attachment_kind, + job->message_id, + esp_err_to_name(err)); + } + cap_im_tg_free_attachment_job(job); + cap_im_tg_log_stack_watermark("tg_attachment"); + } + continue; + } + + if (s_tg.stop_requested) { + break; + } + } + + s_tg.attachment_task = NULL; + vTaskDelete(NULL); +} + +static void cap_im_tg_queue_attachment(const char *chat_id, + const char *sender_id, + const char *message_id, + const char *attachment_kind, + const char *file_id, + const char *original_filename, + const char *mime, + const char *caption, + const char *content_type) +{ + cap_im_tg_attachment_job_t *job = NULL; + + if (!s_tg.enable_inbound_attachments || !s_tg.attachment_queue || !file_id || !file_id[0]) { + return; + } + + job = cap_im_tg_make_attachment_job(chat_id, + sender_id, + message_id, + attachment_kind, + file_id, + original_filename, + mime, + caption, + content_type); + if (!job) { + ESP_LOGW(TAG, "Telegram attachment queue alloc failed message=%s", message_id); + return; + } + + if (xQueueSend(s_tg.attachment_queue, &job, 0) != pdTRUE) { + ESP_LOGW(TAG, + "Telegram attachment queue full message=%s kind=%s", + message_id, + attachment_kind); + cap_im_tg_free_attachment_job(job); + return; + } +} + +static void cap_im_tg_handle_update(cJSON *update_json) +{ + cJSON *update_id_json; + cJSON *message_json; + cJSON *chat_json; + cJSON *chat_id_json; + cJSON *from_json; + cJSON *from_id_json; + cJSON *message_id_json; + cJSON *text_json; + cJSON *document_json; + cJSON *photo_json; + const char *caption = NULL; + char update_key[32]; + char chat_id[32]; + char sender_id[32]; + char message_id[32]; + int64_t update_id; + + if (!cJSON_IsObject(update_json)) { + return; + } + + update_id_json = cJSON_GetObjectItem(update_json, "update_id"); + message_json = cJSON_GetObjectItem(update_json, "message"); + if (!cJSON_IsNumber(update_id_json) || !cJSON_IsObject(message_json)) { + return; + } + + update_id = (int64_t)update_id_json->valuedouble; + if (update_id >= s_tg.next_update_id) { + s_tg.next_update_id = update_id + 1; + } + + snprintf(update_key, sizeof(update_key), "%" PRId64, update_id); + if (cap_im_tg_dedup_check_and_record(update_key)) { + return; + } + + chat_json = cJSON_GetObjectItem(message_json, "chat"); + from_json = cJSON_GetObjectItem(message_json, "from"); + message_id_json = cJSON_GetObjectItem(message_json, "message_id"); + if (!cJSON_IsObject(chat_json) || !cJSON_IsNumber(message_id_json)) { + return; + } + + chat_id_json = cJSON_GetObjectItem(chat_json, "id"); + from_id_json = cJSON_IsObject(from_json) ? cJSON_GetObjectItem(from_json, "id") : NULL; + if (!cJSON_IsNumber(chat_id_json)) { + return; + } + + snprintf(chat_id, sizeof(chat_id), "%" PRId64, (int64_t)chat_id_json->valuedouble); + if (cJSON_IsNumber(from_id_json)) { + snprintf(sender_id, sizeof(sender_id), "%" PRId64, (int64_t)from_id_json->valuedouble); + } else { + sender_id[0] = '\0'; + } + snprintf(message_id, sizeof(message_id), "%" PRId64, (int64_t)message_id_json->valuedouble); + + caption = cJSON_GetStringValue(cJSON_GetObjectItem(message_json, "caption")); + photo_json = cap_im_tg_select_best_photo(message_json); + if (s_tg.enable_inbound_attachments && photo_json) { + const char *file_id = cJSON_GetStringValue(cJSON_GetObjectItem(photo_json, "file_id")); + + ESP_LOGI(TAG, "Telegram photo message=%s chat=%s", message_id, chat_id); + + if (file_id && file_id[0]) { + cap_im_tg_queue_attachment(chat_id, + sender_id, + message_id, + "photo", + file_id, + "photo.jpg", + "image/jpeg", + caption, + "image"); + } + } + + document_json = cJSON_GetObjectItem(message_json, "document"); + if (s_tg.enable_inbound_attachments && cJSON_IsObject(document_json)) { + const char *file_id = cJSON_GetStringValue(cJSON_GetObjectItem(document_json, "file_id")); + const char *file_name = cJSON_GetStringValue(cJSON_GetObjectItem(document_json, "file_name")); + const char *mime = cJSON_GetStringValue(cJSON_GetObjectItem(document_json, "mime_type")); + + ESP_LOGI(TAG, + "Telegram document message=%s chat=%s filename=%s", + message_id, + chat_id, + file_name ? file_name : ""); + + if (file_id && file_id[0]) { + cap_im_tg_queue_attachment(chat_id, + sender_id, + message_id, + "document", + file_id, + file_name, + mime, + caption, + "file"); + } + } + + text_json = cJSON_GetObjectItem(message_json, "text"); + if (cJSON_IsString(text_json) && text_json->valuestring && text_json->valuestring[0]) { + if (cap_im_tg_publish_inbound_text(chat_id, + sender_id, + message_id, + text_json->valuestring) == ESP_OK) { + ESP_LOGI(TAG, "Telegram inbound %s: %.48s%s", + chat_id, + text_json->valuestring, + strlen(text_json->valuestring) > 48 ? "..." : ""); + } else { + ESP_LOGW(TAG, "Failed to publish Telegram inbound message"); + } + } +} + +static esp_err_t cap_im_tg_poll_once(void) +{ + cJSON *body = NULL; + char *body_json = NULL; + char *resp = NULL; + cJSON *root = NULL; + cJSON *ok_json; + cJSON *result_json; + esp_err_t err; + int i; + + body = cJSON_CreateObject(); + if (!body) { + return ESP_ERR_NO_MEM; + } + + cJSON_AddNumberToObject(body, "timeout", CAP_IM_TG_POLL_TIMEOUT_S); + if (s_tg.next_update_id > 0) { + cJSON_AddNumberToObject(body, "offset", (double)s_tg.next_update_id); + } + + body_json = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!body_json) { + return ESP_ERR_NO_MEM; + } + + err = cap_im_tg_api_call("getUpdates", body_json, &resp); + free(body_json); + if (err != ESP_OK) { + return err; + } + + root = cJSON_Parse(resp); + free(resp); + if (!root) { + return ESP_FAIL; + } + + ok_json = cJSON_GetObjectItem(root, "ok"); + result_json = cJSON_GetObjectItem(root, "result"); + if (!cJSON_IsTrue(ok_json) || !cJSON_IsArray(result_json)) { + cJSON_Delete(root); + return ESP_FAIL; + } + + for (i = 0; i < cJSON_GetArraySize(result_json); i++) { + cap_im_tg_handle_update(cJSON_GetArrayItem(result_json, i)); + } + + cJSON_Delete(root); + return ESP_OK; +} + +static esp_err_t cap_im_tg_send_text_chunk(const char *chat_id, const char *message) +{ + cJSON *body = NULL; + char *body_json = NULL; + char *resp = NULL; + esp_err_t err; + + body = cJSON_CreateObject(); + if (!body) { + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(body, "chat_id", chat_id); + cJSON_AddStringToObject(body, "text", message); + body_json = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!body_json) { + return ESP_ERR_NO_MEM; + } + + err = cap_im_tg_api_call("sendMessage", body_json, &resp); + free(body_json); + free(resp); + return err; +} + +static const char *cap_im_tg_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_tg_guess_mime_type(const char *path, bool is_image) +{ + const char *ext = NULL; + + if (!path) { + return is_image ? "image/jpeg" : "application/octet-stream"; + } + + ext = strrchr(path, '.'); + if (!ext) { + return is_image ? "image/jpeg" : "application/octet-stream"; + } + + if (strcasecmp(ext, ".png") == 0) { + return "image/png"; + } + if (strcasecmp(ext, ".gif") == 0) { + return "image/gif"; + } + if (strcasecmp(ext, ".webp") == 0) { + return "image/webp"; + } + if (strcasecmp(ext, ".jpg") == 0 || strcasecmp(ext, ".jpeg") == 0) { + return "image/jpeg"; + } + if (strcasecmp(ext, ".pdf") == 0) { + return "application/pdf"; + } + if (strcasecmp(ext, ".txt") == 0) { + return "text/plain"; + } + if (strcasecmp(ext, ".json") == 0) { + return "application/json"; + } + + return is_image ? "image/jpeg" : "application/octet-stream"; +} + +static esp_err_t cap_im_tg_http_client_write_all(esp_http_client_handle_t client, + const char *data, + size_t len) +{ + size_t total = 0; + + if (!client || (!data && len > 0)) { + return ESP_ERR_INVALID_ARG; + } + + while (total < len) { + int written = esp_http_client_write(client, data + total, (int)(len - total)); + + if (written <= 0) { + return ESP_FAIL; + } + + total += (size_t)written; + } + + return ESP_OK; +} + +static esp_err_t cap_im_tg_stream_file_to_http_client(esp_http_client_handle_t client, FILE *file) +{ + char buf[1024]; + + if (!client || !file) { + return ESP_ERR_INVALID_ARG; + } + + while (!feof(file)) { + size_t nread = fread(buf, 1, sizeof(buf), file); + + if (nread > 0) { + esp_err_t err = cap_im_tg_http_client_write_all(client, buf, nread); + if (err != ESP_OK) { + return err; + } + } + + if (ferror(file)) { + return ESP_FAIL; + } + } + + return ESP_OK; +} + +static esp_err_t cap_im_tg_send_multipart_file(const char *method, + const char *field_name, + const char *chat_id, + const char *path, + const char *caption, + bool is_image) +{ + struct stat st; + FILE *file = NULL; + esp_http_client_config_t config = {0}; + esp_http_client_handle_t client = NULL; + cap_im_tg_http_resp_t resp = {0}; + char *url = NULL; + const char *mime = NULL; + const char *file_name = NULL; + char content_type[128]; + char part_chat[256]; + char part_caption[1024]; + char part_file[512]; + char closing[64]; + size_t content_length; + int needed; + int status; + int part_chat_len; + int part_caption_len = 0; + int part_file_len; + int closing_len; + esp_err_t err = ESP_FAIL; + + if (!method || !field_name || !chat_id || !chat_id[0] || !path || !path[0]) { + return ESP_ERR_INVALID_ARG; + } + if (s_tg.bot_token[0] == '\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; + } + + needed = snprintf(NULL, 0, "%s/bot%s/%s", CAP_IM_TG_API_BASE, s_tg.bot_token, method); + if (needed < 0) { + return ESP_FAIL; + } + + url = calloc(1, (size_t)needed + 1); + resp.buf = calloc(1, CAP_IM_TG_HTTP_RESP_INIT); + if (!url || !resp.buf) { + free(url); + free(resp.buf); + return ESP_ERR_NO_MEM; + } + resp.cap = CAP_IM_TG_HTTP_RESP_INIT; + snprintf(url, (size_t)needed + 1, "%s/bot%s/%s", CAP_IM_TG_API_BASE, s_tg.bot_token, method); + + mime = cap_im_tg_guess_mime_type(path, is_image); + file_name = cap_im_tg_basename(path); + + part_chat_len = snprintf(part_chat, sizeof(part_chat), + "--" CAP_IM_TG_MULTIPART_BOUNDARY "\r\n" + "Content-Disposition: form-data; name=\"chat_id\"\r\n\r\n" + "%s\r\n", + chat_id); + if (part_chat_len <= 0 || part_chat_len >= (int)sizeof(part_chat)) { + free(url); + free(resp.buf); + return ESP_ERR_INVALID_SIZE; + } + + if (caption && caption[0]) { + part_caption_len = snprintf(part_caption, sizeof(part_caption), + "--" CAP_IM_TG_MULTIPART_BOUNDARY "\r\n" + "Content-Disposition: form-data; name=\"caption\"\r\n\r\n" + "%s\r\n", + caption); + if (part_caption_len <= 0 || part_caption_len >= (int)sizeof(part_caption)) { + free(url); + free(resp.buf); + return ESP_ERR_INVALID_SIZE; + } + } + + part_file_len = snprintf(part_file, sizeof(part_file), + "--" CAP_IM_TG_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); + if (part_file_len <= 0 || part_file_len >= (int)sizeof(part_file)) { + free(url); + free(resp.buf); + return ESP_ERR_INVALID_SIZE; + } + + closing_len = snprintf(closing, sizeof(closing), + "\r\n--" CAP_IM_TG_MULTIPART_BOUNDARY "--\r\n"); + if (closing_len <= 0 || closing_len >= (int)sizeof(closing)) { + free(url); + free(resp.buf); + return ESP_ERR_INVALID_SIZE; + } + + content_length = (size_t)part_chat_len + (size_t)part_caption_len + + (size_t)part_file_len + (size_t)st.st_size + (size_t)closing_len; + + file = fopen(path, "rb"); + if (!file) { + free(url); + free(resp.buf); + return ESP_FAIL; + } + + config.url = url; + config.method = HTTP_METHOD_POST; + config.event_handler = cap_im_tg_http_event_handler; + config.user_data = &resp; + config.timeout_ms = (CAP_IM_TG_POLL_TIMEOUT_S + 5) * 1000; + 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); + free(url); + free(resp.buf); + return ESP_FAIL; + } + + snprintf(content_type, + sizeof(content_type), + "multipart/form-data; boundary=" CAP_IM_TG_MULTIPART_BOUNDARY); + 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_tg_http_client_write_all(client, part_chat, (size_t)part_chat_len); + } + if (err == ESP_OK && part_caption_len > 0) { + err = cap_im_tg_http_client_write_all(client, part_caption, (size_t)part_caption_len); + } + if (err == ESP_OK) { + err = cap_im_tg_http_client_write_all(client, part_file, (size_t)part_file_len); + } + if (err == ESP_OK) { + err = cap_im_tg_stream_file_to_http_client(client, file); + } + if (err == ESP_OK) { + err = cap_im_tg_http_client_write_all(client, closing, (size_t)closing_len); + } + if (err == ESP_OK) { + if (esp_http_client_fetch_headers(client) < 0) { + err = ESP_FAIL; + } + } + + status = esp_http_client_get_status_code(client); + esp_http_client_close(client); + esp_http_client_cleanup(client); + fclose(file); + free(url); + + if (err != ESP_OK) { + free(resp.buf); + return err; + } + if (status != 200) { + ESP_LOGE(TAG, "Telegram %s failed: http=%d body=%s", method, status, resp.buf ? resp.buf : ""); + free(resp.buf); + return ESP_FAIL; + } + + free(resp.buf); + return ESP_OK; +} + +static esp_err_t cap_im_tg_send_media(const char *chat_id, + const char *path, + const char *caption, + bool is_image) +{ + esp_err_t err; + const char *method = is_image ? "sendPhoto" : "sendDocument"; + const char *field_name = is_image ? "photo" : "document"; + + err = cap_im_tg_send_multipart_file(method, field_name, chat_id, path, caption, is_image); + if (err != ESP_OK) { + ESP_LOGE(TAG, + "Telegram %s send failed chat=%s path=%s err=%s", + is_image ? "image" : "file", + chat_id ? chat_id : "", + path ? path : "", + esp_err_to_name(err)); + return err; + } + + ESP_LOGI(TAG, + "Telegram %s send success to %s: %s", + is_image ? "image" : "file", + chat_id, + path); + return ESP_OK; +} + +static void cap_im_tg_poll_task(void *arg) +{ + (void)arg; + + while (!s_tg.stop_requested) { + if (cap_im_tg_poll_once() != ESP_OK) { + if (s_tg.stop_requested) { + break; + } + + ESP_LOGW(TAG, "Telegram polling failed, retrying"); + vTaskDelay(pdMS_TO_TICKS(CAP_IM_TG_RETRY_DELAY_MS)); + } + } + + s_tg.poll_task = NULL; + vTaskDelete(NULL); +} + +static esp_err_t cap_im_tg_gateway_init(void) +{ + if (s_tg.bot_token[0] == '\0') { + ESP_LOGW(TAG, "Telegram bot token not configured"); + return ESP_OK; + } + + ESP_LOGI(TAG, "Telegram configured"); + return ESP_OK; +} + +static esp_err_t cap_im_tg_gateway_start(void) +{ + BaseType_t ok; + + if (s_tg.bot_token[0] == '\0') { + ESP_LOGW(TAG, "Telegram not configured, skipping start"); + return ESP_OK; + } + if (s_tg.poll_task) { + return ESP_OK; + } + + s_tg.stop_requested = false; + if (!s_tg.attachment_queue) { + s_tg.attachment_queue = xQueueCreate(CAP_IM_TG_ATTACHMENT_QUEUE_LEN, + sizeof(cap_im_tg_attachment_job_t *)); + if (!s_tg.attachment_queue) { + return ESP_ERR_NO_MEM; + } + } + ok = xTaskCreate(cap_im_tg_attachment_task, + "tg_attach", + CAP_IM_TG_ATTACHMENT_STACK, + NULL, + CAP_IM_TG_TASK_PRIO, + &s_tg.attachment_task); + if (ok != pdPASS) { + vQueueDelete(s_tg.attachment_queue); + s_tg.attachment_queue = NULL; + s_tg.attachment_task = NULL; + return ESP_FAIL; + } + ok = xTaskCreate(cap_im_tg_poll_task, + "tg_poll", + CAP_IM_TG_TASK_STACK, + NULL, + CAP_IM_TG_TASK_PRIO, + &s_tg.poll_task); + if (ok != pdPASS) { + s_tg.stop_requested = true; + s_tg.poll_task = NULL; + while (s_tg.attachment_task) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + vQueueDelete(s_tg.attachment_queue); + s_tg.attachment_queue = NULL; + return ESP_FAIL; + } + + return ESP_OK; +} + +static esp_err_t cap_im_tg_gateway_stop(void) +{ + TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(5000); + + if (!s_tg.poll_task) { + return ESP_OK; + } + + s_tg.stop_requested = true; + while (s_tg.poll_task && xTaskGetTickCount() < deadline) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + + if (s_tg.poll_task) { + return ESP_ERR_TIMEOUT; + } + + while (s_tg.attachment_task && xTaskGetTickCount() < deadline) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + + if (s_tg.attachment_task) { + return ESP_ERR_TIMEOUT; + } + + if (s_tg.attachment_queue) { + cap_im_tg_attachment_job_t *job = NULL; + + while (xQueueReceive(s_tg.attachment_queue, &job, 0) == pdTRUE) { + cap_im_tg_free_attachment_job(job); + } + vQueueDelete(s_tg.attachment_queue); + s_tg.attachment_queue = NULL; + } + + return ESP_OK; +} + +static esp_err_t cap_im_tg_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; + cJSON *message_json; + const char *chat_id = NULL; + const char *message = NULL; + esp_err_t err; + + root = cJSON_Parse(input_json ? input_json : "{}"); + if (!root) { + snprintf(output, output_size, "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; + } + if (cJSON_IsString(message_json) && message_json->valuestring && message_json->valuestring[0]) { + message = message_json->valuestring; + } + + if (!chat_id || !message) { + cJSON_Delete(root); + snprintf(output, + output_size, + "Error: chat_id and message are required (chat_id may come from ctx)"); + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_tg_send_text(chat_id, message); + cJSON_Delete(root); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: %s", esp_err_to_name(err)); + return err; + } + + snprintf(output, output_size, "reply already sent to user"); + return ESP_OK; +} + +static esp_err_t cap_im_tg_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; + cJSON *path_json; + cJSON *caption_json; + const char *chat_id = NULL; + const char *path = NULL; + const char *caption = NULL; + esp_err_t err; + + root = cJSON_Parse(input_json ? input_json : "{}"); + if (!root) { + snprintf(output, output_size, "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, + "Error: chat_id and path are required (chat_id may come from ctx)"); + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_tg_send_media(chat_id, path, caption, is_image); + cJSON_Delete(root); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: %s", esp_err_to_name(err)); + return err; + } + + snprintf(output, output_size, "reply already sent to user"); + return ESP_OK; +} + +static esp_err_t cap_im_tg_send_image_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + return cap_im_tg_send_media_execute(input_json, ctx, output, output_size, true); +} + +static esp_err_t cap_im_tg_send_file_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + return cap_im_tg_send_media_execute(input_json, ctx, output, output_size, false); +} + +static const claw_cap_descriptor_t s_tg_descriptors[] = { + { + .id = "tg_gateway", + .name = "tg_gateway", + .family = "im", + .description = "Telegram bot polling 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_tg_gateway_init, + .start = cap_im_tg_gateway_start, + .stop = cap_im_tg_gateway_stop, + }, + { + .id = "tg_send_message", + .name = "tg_send_message", + .family = "im", + .description = "Send a text message to an explicit Telegram chat_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_tg_send_message_execute, + }, + { + .id = "tg_send_image", + .name = "tg_send_image", + .family = "im", + .description = "Send an image file from a local path to a Telegram 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_tg_send_image_execute, + }, + { + .id = "tg_send_file", + .name = "tg_send_file", + .family = "im", + .description = "Send a file from a local path to a Telegram 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_tg_send_file_execute, + }, +}; + +static const claw_cap_group_t s_tg_group = { + .group_id = "cap_im_tg", + .descriptors = s_tg_descriptors, + .descriptor_count = sizeof(s_tg_descriptors) / sizeof(s_tg_descriptors[0]), +}; + +esp_err_t cap_im_tg_register_group(void) +{ + if (claw_cap_group_exists(s_tg_group.group_id)) { + return ESP_OK; + } + + return claw_cap_register_group(&s_tg_group); +} + +esp_err_t cap_im_tg_set_token(const char *bot_token) +{ + if (!bot_token) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(s_tg.bot_token, bot_token, sizeof(s_tg.bot_token)); + s_tg.next_update_id = 0; + memset(s_tg.seen_update_keys, 0, sizeof(s_tg.seen_update_keys)); + s_tg.seen_update_idx = 0; + return ESP_OK; +} + +esp_err_t cap_im_tg_set_attachment_config( + const cap_im_tg_attachment_config_t *config) +{ + if (!config) { + return ESP_ERR_INVALID_ARG; + } + + s_tg.enable_inbound_attachments = config->enable_inbound_attachments; + s_tg.max_inbound_file_bytes = config->max_inbound_file_bytes; + if (config->storage_root_dir) { + strlcpy(s_tg.attachment_root_dir, + config->storage_root_dir, + sizeof(s_tg.attachment_root_dir)); + } else { + s_tg.attachment_root_dir[0] = '\0'; + } + + return ESP_OK; +} + +esp_err_t cap_im_tg_start(void) +{ + return cap_im_tg_gateway_start(); +} + +esp_err_t cap_im_tg_stop(void) +{ + return cap_im_tg_gateway_stop(); +} + +esp_err_t cap_im_tg_send_text(const char *chat_id, const char *text) +{ + size_t text_len; + size_t offset = 0; + esp_err_t last_err = ESP_OK; + + if (!chat_id || !text || text[0] == '\0') { + return ESP_ERR_INVALID_ARG; + } + if (s_tg.bot_token[0] == '\0') { + return ESP_ERR_INVALID_STATE; + } + + text_len = strlen(text); + while (offset < text_len) { + size_t chunk_len = text_len - offset; + char *chunk = NULL; + esp_err_t err; + + if (chunk_len > CAP_IM_TG_MAX_MSG_LEN) { + chunk_len = CAP_IM_TG_MAX_MSG_LEN; + } + + chunk = calloc(1, chunk_len + 1); + if (!chunk) { + return ESP_ERR_NO_MEM; + } + + memcpy(chunk, text + offset, chunk_len); + err = cap_im_tg_send_text_chunk(chat_id, chunk); + free(chunk); + if (err != ESP_OK) { + last_err = err; + } + + offset += chunk_len; + } + + return last_err; +} + +esp_err_t cap_im_tg_send_image(const char *chat_id, const char *path, const char *caption) +{ + return cap_im_tg_send_media(chat_id, path, caption, true); +} + +esp_err_t cap_im_tg_send_file(const char *chat_id, const char *path, const char *caption) +{ + return cap_im_tg_send_media(chat_id, path, caption, false); +} diff --git a/components/cap_im_tg/src/cmd_cap_im_tg.c b/components/cap_im_tg/src/cmd_cap_im_tg.c new file mode 100644 index 0000000..fb2da14 --- /dev/null +++ b/components/cap_im_tg/src/cmd_cap_im_tg.c @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cmd_cap_im_tg.h" + +#include + +#include "argtable3/argtable3.h" +#include "cap_im_tg.h" +#include "esp_console.h" + +static struct { + struct arg_lit *set_token; + 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 *token; + struct arg_str *text; + struct arg_str *path; + struct arg_str *caption; + struct arg_end *end; +} tg_args; + +static int cmd_tg_set_token(const char *token) +{ + esp_err_t err = cap_im_tg_set_token(token); + + if (err != ESP_OK) { + printf("tg_set_token failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Telegram bot token updated\n"); + return 0; +} + +static int cmd_tg_start(void) +{ + esp_err_t err = cap_im_tg_start(); + + if (err != ESP_OK) { + printf("tg_start failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Telegram gateway started\n"); + return 0; +} + +static int cmd_tg_stop(void) +{ + esp_err_t err = cap_im_tg_stop(); + + if (err != ESP_OK) { + printf("tg_stop failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Telegram gateway stopped\n"); + return 0; +} + +static int cmd_tg_send_text(const char *chat_id, const char *text) +{ + esp_err_t err = cap_im_tg_send_text(chat_id, text); + + if (err != ESP_OK) { + printf("tg_send_text failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Telegram text sent\n"); + return 0; +} + +static int cmd_tg_send_image(const char *chat_id, const char *path, const char *caption) +{ + esp_err_t err = cap_im_tg_send_image(chat_id, path, caption); + + if (err != ESP_OK) { + printf("tg_send_image failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Telegram image sent\n"); + return 0; +} + +static int cmd_tg_send_file(const char *chat_id, const char *path, const char *caption) +{ + esp_err_t err = cap_im_tg_send_file(chat_id, path, caption); + + if (err != ESP_OK) { + printf("tg_send_file failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Telegram file sent\n"); + return 0; +} + +static int tg_func(int argc, char **argv) +{ + int nerrors = arg_parse(argc, argv, (void **)&tg_args); + int operation_count; + + if (nerrors != 0) { + arg_print_errors(stderr, tg_args.end, argv[0]); + return 1; + } + + operation_count = tg_args.set_token->count + tg_args.start->count + tg_args.stop->count + + tg_args.send_text->count + tg_args.send_image->count + tg_args.send_file->count; + if (operation_count != 1) { + printf("Exactly one operation must be specified\n"); + return 1; + } + + if (tg_args.set_token->count) { + if (!tg_args.token->count) { + printf("'--set-token' requires '--token'\n"); + return 1; + } + + return cmd_tg_set_token(tg_args.token->sval[0]); + } + + if (tg_args.start->count) { + return cmd_tg_start(); + } + + if (tg_args.stop->count) { + return cmd_tg_stop(); + } + + if (tg_args.send_text->count) { + if (!tg_args.text->count) { + printf("'--send-text' requires '--text'\n"); + return 1; + } + + return cmd_tg_send_text(tg_args.send_text->sval[0], tg_args.text->sval[0]); + } + + if (!tg_args.path->count) { + printf("'--send-image' and '--send-file' require '--path'\n"); + return 1; + } + + if (tg_args.send_image->count) { + return cmd_tg_send_image(tg_args.send_image->sval[0], + tg_args.path->sval[0], + tg_args.caption->count ? tg_args.caption->sval[0] : NULL); + } + + return cmd_tg_send_file(tg_args.send_file->sval[0], + tg_args.path->sval[0], + tg_args.caption->count ? tg_args.caption->sval[0] : NULL); +} + +void register_cap_im_tg(void) +{ + tg_args.set_token = arg_lit0(NULL, "set-token", "Set Telegram bot token"); + tg_args.start = arg_lit0(NULL, "start", "Start the Telegram bot gateway"); + tg_args.stop = arg_lit0(NULL, "stop", "Stop the Telegram bot gateway"); + tg_args.send_text = arg_str0(NULL, "send-text", "", "Send text to a Telegram chat"); + tg_args.send_image = arg_str0(NULL, "send-image", "", "Send an image to a Telegram chat"); + tg_args.send_file = arg_str0(NULL, "send-file", "", "Send a file to a Telegram chat"); + tg_args.token = arg_str0("t", "token", "", "Telegram bot token"); + tg_args.text = arg_str0(NULL, "text", "", "Text content"); + tg_args.path = arg_str0("p", "path", "", "Local file path"); + tg_args.caption = arg_str0(NULL, "caption", "", "Optional caption"); + tg_args.end = arg_end(8); + + const esp_console_cmd_t tg_cmd = { + .command = "tg", + .help = "Telegram operation.\n" + "Examples:\n" + " tg --set-token --token 123456:abc\n" + " tg --start\n" + " tg --stop\n" + " tg --send-text 123456 --text \"hello\"\n" + " tg --send-image 123456 --path /spiffs/a.jpg --caption \"hi\"\n" + " tg --send-file 123456 --path /spiffs/a.txt --caption \"file\"\n", + .func = tg_func, + .argtable = &tg_args, + }; + + ESP_ERROR_CHECK(esp_console_cmd_register(&tg_cmd)); +} diff --git a/components/cap_im_wechat/CMakeLists.txt b/components/cap_im_wechat/CMakeLists.txt new file mode 100644 index 0000000..8385771 --- /dev/null +++ b/components/cap_im_wechat/CMakeLists.txt @@ -0,0 +1,16 @@ +idf_component_register( + SRCS + "src/cap_im_wechat.c" + "src/cmd_cap_im_wechat.c" + INCLUDE_DIRS + "include" + REQUIRES + cap_im_attachment + claw_cap + claw_event_router + esp_http_client + esp_timer + json + mbedtls + console +) diff --git a/components/cap_im_wechat/include/cap_im_wechat.h b/components/cap_im_wechat/include/cap_im_wechat.h new file mode 100644 index 0000000..1e074c1 --- /dev/null +++ b/components/cap_im_wechat/include/cap_im_wechat.h @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + const char *token; + const char *base_url; + const char *cdn_base_url; + const char *account_id; + const char *app_id; + const char *client_version; + const char *route_tag; +} cap_im_wechat_client_config_t; + +typedef struct { + const char *storage_root_dir; + size_t max_inbound_file_bytes; + bool enable_inbound_attachments; +} cap_im_wechat_attachment_config_t; + +typedef struct { + bool active; + bool configured; + bool completed; + bool persisted; + char session_key[64]; + char status[32]; + char message[160]; + char qr_data_url[256]; + char account_id[64]; + char user_id[96]; + char token[256]; + char base_url[160]; +} cap_im_wechat_qr_login_status_t; + +esp_err_t cap_im_wechat_register_group(void); +esp_err_t cap_im_wechat_set_client_config(const cap_im_wechat_client_config_t *config); +esp_err_t cap_im_wechat_set_attachment_config( + const cap_im_wechat_attachment_config_t *config); +esp_err_t cap_im_wechat_start(void); +esp_err_t cap_im_wechat_stop(void); +esp_err_t cap_im_wechat_send_text(const char *chat_id, const char *text); +esp_err_t cap_im_wechat_send_image(const char *chat_id, const char *path, const char *caption); +esp_err_t cap_im_wechat_qr_login_start(const char *account_id, bool force); +esp_err_t cap_im_wechat_qr_login_get_status(cap_im_wechat_qr_login_status_t *out_status); +esp_err_t cap_im_wechat_qr_login_cancel(void); +esp_err_t cap_im_wechat_qr_login_mark_persisted(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_im_wechat/include/cmd_cap_im_wechat.h b/components/cap_im_wechat/include/cmd_cap_im_wechat.h new file mode 100644 index 0000000..9fd43fb --- /dev/null +++ b/components/cap_im_wechat/include/cmd_cap_im_wechat.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_wechat(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_im_wechat/src/cap_im_wechat.c b/components/cap_im_wechat/src/cap_im_wechat.c new file mode 100644 index 0000000..32c5fbf --- /dev/null +++ b/components/cap_im_wechat/src/cap_im_wechat.c @@ -0,0 +1,2587 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_im_wechat.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cap_im_attachment.h" +#include "cJSON.h" +#include "claw_cap.h" +#include "claw_event_router.h" +#include "esp_crt_bundle.h" +#include "esp_err.h" +#include "esp_http_client.h" +#include "esp_log.h" +#include "esp_random.h" +#include "esp_timer.h" +#include "esp_check.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "mbedtls/aes.h" +#include "mbedtls/base64.h" +#include "mbedtls/md5.h" + +static const char *TAG = "cap_im_wechat"; + +#define CAP_IM_WECHAT_HTTP_RESP_INIT 2048 +#define CAP_IM_WECHAT_MAX_MSG_LEN 4000 +#define CAP_IM_WECHAT_POLL_TIMEOUT_MS 35000 +#define CAP_IM_WECHAT_RETRY_DELAY_MS 2000 +#define CAP_IM_WECHAT_TASK_STACK 6144 +#define CAP_IM_WECHAT_TASK_PRIO 5 +#define CAP_IM_WECHAT_DEDUP_CACHE_SIZE 64 +#define CAP_IM_WECHAT_CONTEXT_CACHE_SIZE 32 +#define CAP_IM_WECHAT_PATH_BUF_SIZE 256 +#define CAP_IM_WECHAT_NAME_BUF_SIZE 96 +#define CAP_IM_WECHAT_URL_BUF_SIZE 384 +#define CAP_IM_WECHAT_TOKEN_SIZE 256 +#define CAP_IM_WECHAT_BASE_URL_SIZE 160 +#define CAP_IM_WECHAT_ACCOUNT_ID_SIZE 64 +#define CAP_IM_WECHAT_APP_ID_SIZE 64 +#define CAP_IM_WECHAT_CLIENT_VERSION_SIZE 32 +#define CAP_IM_WECHAT_ROUTE_TAG_SIZE 64 +#define CAP_IM_WECHAT_CLIENT_ID_SIZE 48 +#define CAP_IM_WECHAT_BODY_BUF_SIZE 1536 +#define CAP_IM_WECHAT_STORAGE_BASE "/fatfs/data" +#define CAP_IM_WECHAT_DEFAULT_BASE_URL "https://ilinkai.weixin.qq.com" +#define CAP_IM_WECHAT_DEFAULT_CDN_BASE_URL "https://novac2c.cdn.weixin.qq.com/c2c" +#define CAP_IM_WECHAT_DEFAULT_APP_ID "bot" +#define CAP_IM_WECHAT_DEFAULT_CLIENT_VERSION "131329" +#define CAP_IM_WECHAT_QR_POLL_TIMEOUT_MS 35000 +#define CAP_IM_WECHAT_QR_START_TIMEOUT_MS 5000 +#define CAP_IM_WECHAT_QR_TTL_MS (5 * 60 * 1000) +#define CAP_IM_WECHAT_QR_MAX_REFRESH 3 + +typedef struct { + char *buf; + size_t len; + size_t cap; + char *encrypted_param; +} cap_im_wechat_http_resp_t; + +typedef struct { + char chat_id[72]; + char context_token[160]; +} cap_im_wechat_context_entry_t; + +typedef struct { + bool active; + bool completed; + bool persisted; + bool stop_requested; + char session_key[64]; + char account_id[64]; + char status[32]; + char message[160]; + char qrcode[96]; + char qr_data_url[256]; + char bot_token[CAP_IM_WECHAT_TOKEN_SIZE]; + char ilink_bot_id[64]; + char ilink_user_id[96]; + char base_url[CAP_IM_WECHAT_BASE_URL_SIZE]; + char current_api_base_url[CAP_IM_WECHAT_BASE_URL_SIZE]; + int64_t started_at_ms; + uint8_t refresh_count; +} cap_im_wechat_qr_state_t; + +typedef struct { + char token[CAP_IM_WECHAT_TOKEN_SIZE]; + char base_url[CAP_IM_WECHAT_BASE_URL_SIZE]; + char cdn_base_url[CAP_IM_WECHAT_BASE_URL_SIZE]; + char account_id[CAP_IM_WECHAT_ACCOUNT_ID_SIZE]; + char app_id[CAP_IM_WECHAT_APP_ID_SIZE]; + char client_version[CAP_IM_WECHAT_CLIENT_VERSION_SIZE]; + char route_tag[CAP_IM_WECHAT_ROUTE_TAG_SIZE]; + char attachment_root_dir[CAP_IM_WECHAT_PATH_BUF_SIZE]; + size_t max_inbound_file_bytes; + bool enable_inbound_attachments; + bool configured; + bool stop_requested; + int poll_timeout_ms; + char *sync_buf; + TaskHandle_t poll_task; + TaskHandle_t qr_task; + SemaphoreHandle_t lock; + uint64_t seen_msg_keys[CAP_IM_WECHAT_DEDUP_CACHE_SIZE]; + size_t seen_msg_idx; + cap_im_wechat_context_entry_t context_cache[CAP_IM_WECHAT_CONTEXT_CACHE_SIZE]; + size_t context_idx; + cap_im_wechat_qr_state_t qr; +} cap_im_wechat_state_t; + +static cap_im_wechat_state_t s_wechat = { + .base_url = CAP_IM_WECHAT_DEFAULT_BASE_URL, + .cdn_base_url = CAP_IM_WECHAT_DEFAULT_CDN_BASE_URL, + .account_id = "default", + .app_id = CAP_IM_WECHAT_DEFAULT_APP_ID, + .client_version = CAP_IM_WECHAT_DEFAULT_CLIENT_VERSION, + .max_inbound_file_bytes = 2 * 1024 * 1024, + .poll_timeout_ms = CAP_IM_WECHAT_POLL_TIMEOUT_MS, +}; + +static void cap_im_wechat_qr_reset_locked(void); +static esp_err_t cap_im_wechat_qr_fetch_code_locked(void); +static esp_err_t cap_im_wechat_qr_poll_once_locked(void); +static void cap_im_wechat_qr_task(void *arg); + +static int64_t cap_im_wechat_now_ms(void) +{ + return esp_timer_get_time() / 1000LL; +} + +static esp_err_t cap_im_wechat_lock(void) +{ + if (!s_wechat.lock) { + s_wechat.lock = xSemaphoreCreateMutex(); + if (!s_wechat.lock) { + return ESP_ERR_NO_MEM; + } + } + + return xSemaphoreTake(s_wechat.lock, pdMS_TO_TICKS(1000)) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; +} + +static void cap_im_wechat_unlock(void) +{ + if (s_wechat.lock) { + xSemaphoreGive(s_wechat.lock); + } +} + +static void cap_im_wechat_random_session_key(char *buf, size_t buf_size) +{ + uint32_t a = esp_random(); + uint32_t b = esp_random(); + + snprintf(buf, buf_size, "wxqr-%08" PRIx32 "%08" PRIx32, a, b); +} + +static uint64_t cap_im_wechat_fnv1a64(const char *text) +{ + uint64_t hash = 1469598103934665603ULL; + + if (!text) { + return hash; + } + + while (*text) { + hash ^= (unsigned char)(*text++); + hash *= 1099511628211ULL; + } + + return hash; +} + +static bool cap_im_wechat_dedup_check_and_record(const char *message_id) +{ + uint64_t key; + size_t i; + + if (!message_id || !message_id[0]) { + return false; + } + + key = cap_im_wechat_fnv1a64(message_id); + for (i = 0; i < CAP_IM_WECHAT_DEDUP_CACHE_SIZE; i++) { + if (s_wechat.seen_msg_keys[i] == key) { + return true; + } + } + + s_wechat.seen_msg_keys[s_wechat.seen_msg_idx] = key; + s_wechat.seen_msg_idx = + (s_wechat.seen_msg_idx + 1) % CAP_IM_WECHAT_DEDUP_CACHE_SIZE; + return false; +} + +static const char *cap_im_wechat_string_value(cJSON *item) +{ + return cJSON_IsString(item) && item->valuestring ? item->valuestring : NULL; +} + +static cJSON *cap_im_wechat_require_object(cJSON *root, const char *key) +{ + cJSON *item = NULL; + + if (!root || !key) { + return NULL; + } + + item = cJSON_GetObjectItemCaseSensitive(root, key); + return cJSON_IsObject(item) ? item : NULL; +} + +static cJSON *cap_im_wechat_require_array(cJSON *root, const char *key) +{ + cJSON *item = NULL; + + if (!root || !key) { + return NULL; + } + + item = cJSON_GetObjectItemCaseSensitive(root, key); + return cJSON_IsArray(item) ? item : NULL; +} + +static int cap_im_wechat_int_value(cJSON *item, int fallback) +{ + if (cJSON_IsNumber(item)) { + return item->valueint; + } + return fallback; +} + +static int64_t cap_im_wechat_int64_value(cJSON *item, int64_t fallback) +{ + if (cJSON_IsNumber(item)) { + return (int64_t)item->valuedouble; + } + if (cJSON_IsString(item) && item->valuestring && item->valuestring[0]) { + return strtoll(item->valuestring, NULL, 10); + } + return fallback; +} + +static void cap_im_wechat_context_remember(const char *chat_id, const char *context_token) +{ + size_t i; + size_t slot; + + if (!chat_id || !chat_id[0] || !context_token || !context_token[0]) { + return; + } + + for (i = 0; i < CAP_IM_WECHAT_CONTEXT_CACHE_SIZE; i++) { + if (strcmp(s_wechat.context_cache[i].chat_id, chat_id) == 0) { + strlcpy(s_wechat.context_cache[i].context_token, + context_token, + sizeof(s_wechat.context_cache[i].context_token)); + return; + } + } + + slot = s_wechat.context_idx; + s_wechat.context_idx = + (s_wechat.context_idx + 1) % CAP_IM_WECHAT_CONTEXT_CACHE_SIZE; + strlcpy(s_wechat.context_cache[slot].chat_id, + chat_id, + sizeof(s_wechat.context_cache[slot].chat_id)); + strlcpy(s_wechat.context_cache[slot].context_token, + context_token, + sizeof(s_wechat.context_cache[slot].context_token)); +} + +static const char *cap_im_wechat_context_lookup(const char *chat_id) +{ + size_t i; + + if (!chat_id || !chat_id[0]) { + return NULL; + } + + for (i = 0; i < CAP_IM_WECHAT_CONTEXT_CACHE_SIZE; i++) { + if (strcmp(s_wechat.context_cache[i].chat_id, chat_id) == 0 && + s_wechat.context_cache[i].context_token[0]) { + return s_wechat.context_cache[i].context_token; + } + } + + return NULL; +} + +static esp_err_t cap_im_wechat_http_event_handler(esp_http_client_event_t *event) +{ + cap_im_wechat_http_resp_t *resp = + (cap_im_wechat_http_resp_t *)event->user_data; + char *next = NULL; + size_t needed; + + if (!resp) { + return ESP_OK; + } + + if (event->event_id == HTTP_EVENT_ON_HEADER && event->header_key && + event->header_value && + strcasecmp(event->header_key, "x-encrypted-param") == 0) { + char *copy = strdup(event->header_value); + + if (!copy) { + return ESP_ERR_NO_MEM; + } + free(resp->encrypted_param); + resp->encrypted_param = copy; + return ESP_OK; + } + + if (event->event_id != HTTP_EVENT_ON_DATA || !event->data || event->data_len <= 0) { + return ESP_OK; + } + + needed = resp->len + (size_t)event->data_len + 1; + if (needed > resp->cap) { + size_t new_cap = resp->cap ? resp->cap : CAP_IM_WECHAT_HTTP_RESP_INIT; + + while (new_cap < needed) { + new_cap *= 2; + } + + next = realloc(resp->buf, new_cap); + if (!next) { + return ESP_ERR_NO_MEM; + } + + resp->buf = next; + resp->cap = new_cap; + } + + memcpy(resp->buf + resp->len, event->data, (size_t)event->data_len); + resp->len += (size_t)event->data_len; + resp->buf[resp->len] = '\0'; + return ESP_OK; +} + +static esp_err_t cap_im_wechat_resp_prepare(cap_im_wechat_http_resp_t *response) +{ + if (!response) { + return ESP_OK; + } + + if (!response->buf) { + response->buf = calloc(1, CAP_IM_WECHAT_HTTP_RESP_INIT); + if (!response->buf) { + return ESP_ERR_NO_MEM; + } + response->cap = CAP_IM_WECHAT_HTTP_RESP_INIT; + } + + response->len = 0; + response->buf[0] = '\0'; + free(response->encrypted_param); + response->encrypted_param = NULL; + return ESP_OK; +} + +static void cap_im_wechat_resp_cleanup(cap_im_wechat_http_resp_t *response) +{ + if (!response) { + return; + } + + free(response->buf); + response->buf = NULL; + response->len = 0; + response->cap = 0; + free(response->encrypted_param); + response->encrypted_param = NULL; +} + +static esp_err_t cap_im_wechat_build_x_wechat_uin(char *buf, size_t buf_size) +{ + uint32_t value = esp_random(); + char decimal[16]; + unsigned char encoded[32]; + size_t out_len = 0; + int ret; + + if (!buf || buf_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + snprintf(decimal, sizeof(decimal), "%" PRIu32, value); + ret = mbedtls_base64_encode(encoded, + sizeof(encoded), + &out_len, + (const unsigned char *)decimal, + strlen(decimal)); + if (ret != 0 || out_len + 1 > buf_size) { + return ESP_FAIL; + } + + memcpy(buf, encoded, out_len); + buf[out_len] = '\0'; + return ESP_OK; +} + +static esp_err_t cap_im_wechat_http_request(const char *url, + esp_http_client_method_t method, + const char *content_type, + const void *body, + size_t body_len, + int timeout_ms, + bool use_common_headers, + bool use_auth_headers, + cap_im_wechat_http_resp_t *response) +{ + esp_http_client_config_t config = {0}; + esp_http_client_handle_t client = NULL; + esp_err_t err; + int status; + + if (!url || !content_type) { + return ESP_ERR_INVALID_ARG; + } + + ESP_RETURN_ON_ERROR(cap_im_wechat_resp_prepare(response), TAG, "prepare response failed"); + + config.url = url; + config.method = method; + config.timeout_ms = timeout_ms; + config.crt_bundle_attach = esp_crt_bundle_attach; + config.event_handler = cap_im_wechat_http_event_handler; + config.user_data = response; + config.buffer_size = 1024; + config.buffer_size_tx = 2048; + + client = esp_http_client_init(&config); + if (!client) { + return ESP_ERR_NO_MEM; + } + + if (content_type && content_type[0]) { + esp_http_client_set_header(client, "Content-Type", content_type); + } + if (use_common_headers) { + char x_wechat_uin[64]; + + if (s_wechat.app_id[0]) { + esp_http_client_set_header(client, "iLink-App-Id", s_wechat.app_id); + } + if (s_wechat.client_version[0]) { + esp_http_client_set_header(client, "iLink-App-ClientVersion", s_wechat.client_version); + } + if (s_wechat.route_tag[0]) { + esp_http_client_set_header(client, "SKRouteTag", s_wechat.route_tag); + } + err = cap_im_wechat_build_x_wechat_uin(x_wechat_uin, sizeof(x_wechat_uin)); + if (err != ESP_OK) { + goto cleanup; + } + esp_http_client_set_header(client, "X-WECHAT-UIN", x_wechat_uin); + } + if (use_auth_headers) { + esp_http_client_set_header(client, "AuthorizationType", "ilink_bot_token"); + if (s_wechat.token[0]) { + char auth_header[CAP_IM_WECHAT_TOKEN_SIZE + 8]; + + snprintf(auth_header, sizeof(auth_header), "Bearer %s", s_wechat.token); + esp_http_client_set_header(client, "Authorization", auth_header); + } + } + if (body && body_len > 0) { + esp_http_client_set_post_field(client, body, (int)body_len); + } + + err = esp_http_client_perform(client); + if (err != ESP_OK) { + goto cleanup; + } + + status = esp_http_client_get_status_code(client); + err = ESP_OK; + if (status < 200 || status >= 300) { + ESP_LOGW(TAG, "WeChat HTTP %s failed: status=%d body=%s", url, status, + response && response->buf ? response->buf : ""); + err = ESP_FAIL; + } + +cleanup: + if (client) { + esp_http_client_cleanup(client); + } + return err; +} + +static esp_err_t cap_im_wechat_api_post(const char *endpoint, + cJSON *root, + int timeout_ms, + cap_im_wechat_http_resp_t *response) +{ + char url[CAP_IM_WECHAT_URL_BUF_SIZE]; + char *body_json = NULL; + esp_err_t err; + + if (!endpoint || !root || !s_wechat.configured) { + return ESP_ERR_INVALID_STATE; + } + + if (snprintf(url, sizeof(url), "%s/%s", s_wechat.base_url, endpoint) >= (int)sizeof(url)) { + return ESP_ERR_INVALID_SIZE; + } + + body_json = cJSON_PrintUnformatted(root); + if (!body_json) { + return ESP_ERR_NO_MEM; + } + + err = cap_im_wechat_http_request(url, + HTTP_METHOD_POST, + "application/json", + body_json, + strlen(body_json), + timeout_ms, + true, + true, + response); + free(body_json); + return err; +} + +static esp_err_t cap_im_wechat_http_get_json(const char *base_url, + const char *endpoint, + int timeout_ms, + cap_im_wechat_http_resp_t *response) +{ + char url[CAP_IM_WECHAT_URL_BUF_SIZE]; + + if (!base_url || !endpoint) { + return ESP_ERR_INVALID_ARG; + } + + if (snprintf(url, sizeof(url), "%s/%s", base_url, endpoint) >= (int)sizeof(url)) { + return ESP_ERR_INVALID_SIZE; + } + + return cap_im_wechat_http_request(url, + HTTP_METHOD_GET, + "application/json", + NULL, + 0, + timeout_ms, + true, + false, + response); +} + +static esp_err_t cap_im_wechat_add_base_info(cJSON *root) +{ + cJSON *base_info = NULL; + + if (!root) { + return ESP_ERR_INVALID_ARG; + } + + base_info = cJSON_AddObjectToObject(root, "base_info"); + if (!base_info) { + return ESP_ERR_NO_MEM; + } + + if (!cJSON_AddStringToObject(base_info, "channel_version", "esp-clawgent-wechat")) { + return ESP_ERR_NO_MEM; + } + + return ESP_OK; +} + +static esp_err_t cap_im_wechat_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("wechat_gateway", + "wechat", + chat_id, + content, + sender_id, + message_id); +} + +static esp_err_t cap_im_wechat_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, "wechat_gateway", sizeof(event.source_cap)); + strlcpy(event.event_type, "attachment_saved", sizeof(event.event_type)); + strlcpy(event.source_channel, "wechat", sizeof(event.source_channel)); + strlcpy(event.chat_id, chat_id, sizeof(event.chat_id)); + if (sender_id && sender_id[0]) { + strlcpy(event.sender_id, sender_id, sizeof(event.sender_id)); + } + strlcpy(event.message_id, message_id, sizeof(event.message_id)); + strlcpy(event.content_type, content_type, sizeof(event.content_type)); + event.timestamp_ms = cap_im_wechat_now_ms(); + event.session_policy = CLAW_EVENT_SESSION_POLICY_CHAT; + snprintf(event.event_id, sizeof(event.event_id), "wechat-attach-%" PRId64, event.timestamp_ms); + event.text = ""; + event.payload_json = (char *)payload_json; + return claw_event_router_publish(&event); +} + +static esp_err_t cap_im_wechat_download_buffer(const char *url, + size_t max_bytes, + unsigned char **out_buf, + size_t *out_len) +{ + cap_im_wechat_http_resp_t response = {0}; + esp_err_t err; + + if (!url || !out_buf || !out_len) { + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_wechat_http_request(url, + HTTP_METHOD_GET, + "", + NULL, + 0, + 30000, + false, + false, + &response); + if (err != ESP_OK) { + cap_im_wechat_resp_cleanup(&response); + return err; + } + + if (response.len > max_bytes) { + cap_im_wechat_resp_cleanup(&response); + return ESP_ERR_INVALID_SIZE; + } + + *out_buf = (unsigned char *)response.buf; + *out_len = response.len; + return ESP_OK; +} + +static bool cap_im_wechat_is_hex_string(const unsigned char *buf, size_t len) +{ + size_t i; + + if (!buf || len == 0) { + return false; + } + + for (i = 0; i < len; i++) { + if (!isxdigit(buf[i])) { + return false; + } + } + + return true; +} + +static esp_err_t cap_im_wechat_hex_decode(const char *hex, + unsigned char *out, + size_t out_size, + size_t *out_len) +{ + size_t hex_len; + size_t i; + + if (!hex || !out || !out_len) { + return ESP_ERR_INVALID_ARG; + } + + hex_len = strlen(hex); + if ((hex_len % 2) != 0 || out_size < (hex_len / 2)) { + return ESP_ERR_INVALID_SIZE; + } + + for (i = 0; i < hex_len / 2; i++) { + unsigned int value = 0; + + if (sscanf(hex + (i * 2), "%2x", &value) != 1) { + return ESP_FAIL; + } + out[i] = (unsigned char)value; + } + + *out_len = hex_len / 2; + return ESP_OK; +} + +static esp_err_t cap_im_wechat_parse_aes_key(const char *aes_key_base64, + unsigned char *key_buf, + size_t key_buf_size, + size_t *key_len) +{ + unsigned char decoded[64]; + size_t decoded_len = 0; + int ret; + + if (!aes_key_base64 || !aes_key_base64[0] || !key_buf || !key_len) { + return ESP_ERR_INVALID_ARG; + } + + ret = mbedtls_base64_decode(decoded, + sizeof(decoded), + &decoded_len, + (const unsigned char *)aes_key_base64, + strlen(aes_key_base64)); + if (ret != 0) { + return ESP_FAIL; + } + + if (decoded_len == 16) { + if (key_buf_size < 16) { + return ESP_ERR_INVALID_SIZE; + } + memcpy(key_buf, decoded, 16); + *key_len = 16; + return ESP_OK; + } + + if (decoded_len == 32 && cap_im_wechat_is_hex_string(decoded, decoded_len)) { + return cap_im_wechat_hex_decode((const char *)decoded, key_buf, key_buf_size, key_len); + } + + return ESP_FAIL; +} + +static esp_err_t cap_im_wechat_aes_ecb_crypt(const unsigned char *input, + size_t input_len, + const unsigned char *key, + size_t key_len, + bool encrypt, + unsigned char **out_buf, + size_t *out_len) +{ + mbedtls_aes_context aes; + unsigned char *buffer = NULL; + size_t padded_len; + size_t i; + int ret; + + if (!input || !key || key_len != 16 || !out_buf || !out_len) { + return ESP_ERR_INVALID_ARG; + } + + padded_len = encrypt ? ((input_len / 16) + 1) * 16 : input_len; + if ((padded_len % 16) != 0) { + return ESP_ERR_INVALID_SIZE; + } + + buffer = calloc(1, padded_len); + if (!buffer) { + return ESP_ERR_NO_MEM; + } + + memcpy(buffer, input, input_len); + if (encrypt) { + unsigned char pad = (unsigned char)(padded_len - input_len); + memset(buffer + input_len, pad, pad); + } + + mbedtls_aes_init(&aes); + ret = encrypt ? mbedtls_aes_setkey_enc(&aes, key, 128) : mbedtls_aes_setkey_dec(&aes, key, 128); + if (ret != 0) { + mbedtls_aes_free(&aes); + free(buffer); + return ESP_FAIL; + } + + for (i = 0; i < padded_len; i += 16) { + ret = mbedtls_aes_crypt_ecb(&aes, + encrypt ? MBEDTLS_AES_ENCRYPT : MBEDTLS_AES_DECRYPT, + buffer + i, + buffer + i); + if (ret != 0) { + mbedtls_aes_free(&aes); + free(buffer); + return ESP_FAIL; + } + } + mbedtls_aes_free(&aes); + + if (!encrypt) { + unsigned char pad = buffer[padded_len - 1]; + + if (pad == 0 || pad > 16 || pad > padded_len) { + free(buffer); + return ESP_FAIL; + } + for (i = 0; i < pad; i++) { + if (buffer[padded_len - 1 - i] != pad) { + free(buffer); + return ESP_FAIL; + } + } + *out_len = padded_len - pad; + } else { + *out_len = padded_len; + } + + *out_buf = buffer; + return ESP_OK; +} + +static esp_err_t cap_im_wechat_base64_encode(const unsigned char *input, + size_t input_len, + char *out, + size_t out_size) +{ + size_t out_len = 0; + int ret; + + if (!input || !out || out_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + ret = mbedtls_base64_encode((unsigned char *)out, + out_size, + &out_len, + input, + input_len); + if (ret != 0 || out_len + 1 > out_size) { + return ESP_FAIL; + } + + out[out_len] = '\0'; + return ESP_OK; +} + +static bool cap_im_wechat_url_char_is_unreserved(unsigned char ch) +{ + return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || + ch == '-' || ch == '_' || ch == '.' || ch == '~'; +} + +static esp_err_t cap_im_wechat_url_encode_dup(const char *input, char **out) +{ + static const char hex_digits[] = "0123456789ABCDEF"; + char *buf = NULL; + char *cursor = NULL; + size_t input_len; + size_t i; + + if (!input || !out) { + return ESP_ERR_INVALID_ARG; + } + + input_len = strlen(input); + buf = calloc(1, input_len * 3 + 1); + if (!buf) { + return ESP_ERR_NO_MEM; + } + + cursor = buf; + for (i = 0; i < input_len; i++) { + unsigned char ch = (unsigned char)input[i]; + + if (cap_im_wechat_url_char_is_unreserved(ch)) { + *cursor++ = (char)ch; + continue; + } + + *cursor++ = '%'; + *cursor++ = hex_digits[(ch >> 4) & 0x0F]; + *cursor++ = hex_digits[ch & 0x0F]; + } + + *cursor = '\0'; + *out = buf; + return ESP_OK; +} + +static esp_err_t cap_im_wechat_download_media_plaintext(const char *full_url, + const char *encrypted_query_param, + const char *aes_key_base64, + size_t max_bytes, + unsigned char **out_buf, + size_t *out_len) +{ + char *url = NULL; + char *encoded_param = NULL; + char *fallback_url = NULL; + unsigned char *encrypted_buf = NULL; + size_t encrypted_len = 0; + unsigned char key_buf[32]; + size_t key_len = 0; + esp_err_t err; + + if (!out_buf || !out_len) { + return ESP_ERR_INVALID_ARG; + } + + if (encrypted_query_param && encrypted_query_param[0]) { + err = cap_im_wechat_url_encode_dup(encrypted_query_param, &encoded_param); + if (err != ESP_OK) { + return err; + } + if (asprintf(&fallback_url, + "%s/download?encrypted_query_param=%s", + s_wechat.cdn_base_url, + encoded_param) < 0) { + free(encoded_param); + return ESP_ERR_NO_MEM; + } + free(encoded_param); + encoded_param = NULL; + } + + if (full_url && full_url[0]) { + url = strdup(full_url); + if (!url) { + free(fallback_url); + return ESP_ERR_NO_MEM; + } + } else if (fallback_url) { + url = fallback_url; + fallback_url = NULL; + } else { + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_wechat_download_buffer(url, + max_bytes + 32, + &encrypted_buf, + &encrypted_len); + free(url); + if (err != ESP_OK) { + if (full_url && full_url[0] && fallback_url) { + ESP_LOGW(TAG, "download via full_url failed, retrying encrypted_query_param URL"); + err = cap_im_wechat_download_buffer(fallback_url, + max_bytes + 32, + &encrypted_buf, + &encrypted_len); + } + free(fallback_url); + if (err != ESP_OK) { + ESP_LOGE(TAG, "download media failed"); + return err; + } + } + free(fallback_url); + + if (!aes_key_base64 || !aes_key_base64[0]) { + if (encrypted_len > max_bytes) { + free(encrypted_buf); + return ESP_ERR_INVALID_SIZE; + } + *out_buf = encrypted_buf; + *out_len = encrypted_len; + return ESP_OK; + } + + err = cap_im_wechat_parse_aes_key(aes_key_base64, key_buf, sizeof(key_buf), &key_len); + if (err != ESP_OK) { + free(encrypted_buf); + return err; + } + + err = cap_im_wechat_aes_ecb_crypt(encrypted_buf, + encrypted_len, + key_buf, + key_len, + false, + out_buf, + out_len); + free(encrypted_buf); + if (err != ESP_OK) { + return err; + } + + if (*out_len > max_bytes) { + free(*out_buf); + *out_buf = NULL; + *out_len = 0; + return ESP_ERR_INVALID_SIZE; + } + + return ESP_OK; +} + +static esp_err_t cap_im_wechat_save_attachment_buffer(const char *chat_id, + const char *sender_id, + const char *message_id, + const char *attachment_kind, + const char *content_type, + const char *original_filename, + const unsigned char *buf, + size_t buf_len) +{ + char saved_dir[CAP_IM_WECHAT_PATH_BUF_SIZE]; + char saved_name[CAP_IM_WECHAT_NAME_BUF_SIZE]; + char saved_path[CAP_IM_WECHAT_PATH_BUF_SIZE]; + const char *extension = NULL; + char *payload_json = NULL; + esp_err_t err; + + extension = cap_im_attachment_guess_extension(original_filename, + original_filename, + content_type); + err = cap_im_attachment_build_saved_paths(s_wechat.attachment_root_dir, + "wechat", + chat_id, + message_id, + attachment_kind, + extension ? extension : ".bin", + saved_dir, + sizeof(saved_dir), + saved_name, + sizeof(saved_name), + saved_path, + sizeof(saved_path)); + if (err != ESP_OK) { + return err; + } + + ESP_RETURN_ON_ERROR(cap_im_attachment_save_buffer_to_file(TAG, saved_path, buf, buf_len), + TAG, + "save attachment buffer failed"); + + payload_json = cap_im_attachment_build_payload_json( + &(cap_im_attachment_payload_config_t) { + .platform = "wechat", + .attachment_kind = attachment_kind, + .saved_path = saved_path, + .saved_dir = saved_dir, + .saved_name = saved_name, + .original_filename = original_filename, + .mime = content_type, + .caption = NULL, + .source_key = "message_id", + .source_value = message_id, + .size_bytes = buf_len, + .saved_at_ms = cap_im_wechat_now_ms(), + }); + if (!payload_json) { + return ESP_ERR_NO_MEM; + } + + err = cap_im_wechat_publish_attachment_event(chat_id, + sender_id, + message_id, + content_type, + payload_json); + free(payload_json); + return err; +} + +static const char *cap_im_wechat_item_aes_key(cJSON *item) +{ + cJSON *image_item = NULL; + cJSON *file_item = NULL; + cJSON *video_item = NULL; + cJSON *voice_item = NULL; + cJSON *media = NULL; + const char *value = NULL; + + image_item = cap_im_wechat_require_object(item, "image_item"); + if (image_item) { + value = cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(image_item, "aeskey")); + if (value && value[0]) { + static char encoded[96]; + unsigned char decoded[16]; + size_t decoded_len = 0; + + if (cap_im_wechat_hex_decode(value, decoded, sizeof(decoded), &decoded_len) == ESP_OK && + cap_im_wechat_base64_encode(decoded, decoded_len, encoded, sizeof(encoded)) == ESP_OK) { + return encoded; + } + } + media = cap_im_wechat_require_object(image_item, "media"); + if (media) { + return cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(media, "aes_key")); + } + } + + file_item = cap_im_wechat_require_object(item, "file_item"); + if (file_item) { + media = cap_im_wechat_require_object(file_item, "media"); + if (media) { + return cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(media, "aes_key")); + } + } + + video_item = cap_im_wechat_require_object(item, "video_item"); + if (video_item) { + media = cap_im_wechat_require_object(video_item, "media"); + if (media) { + return cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(media, "aes_key")); + } + } + + voice_item = cap_im_wechat_require_object(item, "voice_item"); + if (voice_item) { + media = cap_im_wechat_require_object(voice_item, "media"); + if (media) { + return cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(media, "aes_key")); + } + } + + return NULL; +} + +static const char *cap_im_wechat_item_full_url(cJSON *item) +{ + cJSON *media = NULL; + cJSON *holder = NULL; + + holder = cap_im_wechat_require_object(item, "image_item"); + if (!holder) { + holder = cap_im_wechat_require_object(item, "file_item"); + } + if (!holder) { + holder = cap_im_wechat_require_object(item, "video_item"); + } + if (!holder) { + holder = cap_im_wechat_require_object(item, "voice_item"); + } + if (!holder) { + return NULL; + } + + media = cap_im_wechat_require_object(holder, "media"); + if (!media) { + return NULL; + } + + return cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(media, "full_url")); +} + +static const char *cap_im_wechat_item_download_param(cJSON *item) +{ + cJSON *media = NULL; + cJSON *holder = NULL; + + holder = cap_im_wechat_require_object(item, "image_item"); + if (!holder) { + holder = cap_im_wechat_require_object(item, "file_item"); + } + if (!holder) { + holder = cap_im_wechat_require_object(item, "video_item"); + } + if (!holder) { + holder = cap_im_wechat_require_object(item, "voice_item"); + } + if (!holder) { + return NULL; + } + + media = cap_im_wechat_require_object(holder, "media"); + if (!media) { + return NULL; + } + + return cap_im_wechat_string_value( + cJSON_GetObjectItemCaseSensitive(media, "encrypt_query_param")); +} + +static const char *cap_im_wechat_item_file_name(cJSON *item) +{ + cJSON *file_item = cap_im_wechat_require_object(item, "file_item"); + + if (!file_item) { + return NULL; + } + + return cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(file_item, "file_name")); +} + +static esp_err_t cap_im_wechat_process_media_item(cJSON *item, + const char *chat_id, + const char *sender_id, + const char *message_id) +{ + const char *full_url = NULL; + const char *download_param = NULL; + const char *aes_key = NULL; + const char *file_name = NULL; + const char *kind = NULL; + const char *content_type = NULL; + unsigned char *buf = NULL; + size_t buf_len = 0; + esp_err_t err; + int type; + + if (!s_wechat.enable_inbound_attachments) { + return ESP_OK; + } + + type = cap_im_wechat_int_value(cJSON_GetObjectItemCaseSensitive(item, "type"), 0); + switch (type) { + case 2: + kind = "image"; + content_type = "image/jpeg"; + file_name = "image.jpg"; + break; + case 3: + kind = "voice"; + content_type = "audio/silk"; + file_name = "voice.silk"; + break; + case 4: + kind = "file"; + content_type = "application/octet-stream"; + file_name = cap_im_wechat_item_file_name(item); + break; + case 5: + kind = "video"; + content_type = "video/mp4"; + file_name = "video.mp4"; + break; + default: + return ESP_OK; + } + + full_url = cap_im_wechat_item_full_url(item); + download_param = cap_im_wechat_item_download_param(item); + aes_key = cap_im_wechat_item_aes_key(item); + + if ((!full_url || !full_url[0]) && (!download_param || !download_param[0])) { + ESP_LOGW(TAG, "wechat item missing media url"); + return ESP_FAIL; + } + + err = cap_im_wechat_download_media_plaintext(full_url, + download_param, + aes_key, + s_wechat.max_inbound_file_bytes, + &buf, + &buf_len); + if (err != ESP_OK) { + ESP_LOGW(TAG, "wechat media download failed: %s", esp_err_to_name(err)); + return err; + } + + err = cap_im_wechat_save_attachment_buffer(chat_id, + sender_id, + message_id, + kind, + content_type, + file_name, + buf, + buf_len); + free(buf); + return err; +} + +static void cap_im_wechat_append_text(char *buf, size_t buf_size, const char *text) +{ + if (!buf || !buf_size || !text || !text[0]) { + return; + } + + if (buf[0]) { + strlcat(buf, "\n", buf_size); + } + strlcat(buf, text, buf_size); +} + +static esp_err_t cap_im_wechat_process_message(cJSON *msg) +{ + cJSON *item_list = NULL; + cJSON *item = NULL; + char message_id_buf[32]; + char *text_buf = NULL; + const char *from_user_id = NULL; + const char *group_id = NULL; + const char *context_token = NULL; + const char *chat_id = NULL; + int item_count; + int i; + + if (!cJSON_IsObject(msg)) { + return ESP_ERR_INVALID_ARG; + } + + text_buf = calloc(1, CAP_IM_WECHAT_MAX_MSG_LEN + 1); + if (!text_buf) { + return ESP_ERR_NO_MEM; + } + + from_user_id = + cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(msg, "from_user_id")); + group_id = + cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(msg, "group_id")); + context_token = + cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(msg, "context_token")); + chat_id = (group_id && group_id[0]) ? group_id : from_user_id; + if (!chat_id || !chat_id[0]) { + free(text_buf); + return ESP_ERR_INVALID_RESPONSE; + } + + snprintf(message_id_buf, + sizeof(message_id_buf), + "%" PRId64, + cap_im_wechat_int64_value(cJSON_GetObjectItemCaseSensitive(msg, "message_id"), + cap_im_wechat_now_ms())); + if (cap_im_wechat_dedup_check_and_record(message_id_buf)) { + free(text_buf); + return ESP_OK; + } + + cap_im_wechat_context_remember(chat_id, context_token); + + item_list = cap_im_wechat_require_array(msg, "item_list"); + if (!item_list) { + free(text_buf); + return ESP_OK; + } + + item_count = cJSON_GetArraySize(item_list); + for (i = 0; i < item_count; i++) { + int type; + const char *text = NULL; + + item = cJSON_GetArrayItem(item_list, i); + if (!cJSON_IsObject(item)) { + continue; + } + + type = cap_im_wechat_int_value(cJSON_GetObjectItemCaseSensitive(item, "type"), 0); + if (type == 1) { + cJSON *text_item = cap_im_wechat_require_object(item, "text_item"); + if (text_item) { + text = cap_im_wechat_string_value( + cJSON_GetObjectItemCaseSensitive(text_item, "text")); + } + cap_im_wechat_append_text(text_buf, CAP_IM_WECHAT_MAX_MSG_LEN + 1, text); + } else if (type == 2 || type == 3 || type == 4 || type == 5) { + cap_im_wechat_process_media_item(item, chat_id, from_user_id, message_id_buf); + } + } + + { + esp_err_t err = cap_im_wechat_publish_inbound_text(chat_id, + from_user_id ? from_user_id : chat_id, + message_id_buf, + text_buf); + free(text_buf); + return err; + } +} + +static esp_err_t cap_im_wechat_poll_once(void) +{ + cap_im_wechat_http_resp_t response = {0}; + cJSON *root = NULL; + cJSON *msgs = NULL; + cJSON *msg = NULL; + esp_err_t err; + int i; + int msg_count; + + root = cJSON_CreateObject(); + if (!root) { + return ESP_ERR_NO_MEM; + } + + if (!cJSON_AddStringToObject(root, "get_updates_buf", s_wechat.sync_buf ? s_wechat.sync_buf : "")) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + err = cap_im_wechat_add_base_info(root); + if (err != ESP_OK) { + cJSON_Delete(root); + return err; + } + + err = cap_im_wechat_api_post("ilink/bot/getupdates", + root, + s_wechat.poll_timeout_ms + 5000, + &response); + cJSON_Delete(root); + if (err != ESP_OK) { + cap_im_wechat_resp_cleanup(&response); + return err; + } + + root = cJSON_Parse(response.buf); + cap_im_wechat_resp_cleanup(&response); + if (!root) { + return ESP_ERR_INVALID_RESPONSE; + } + + if (cap_im_wechat_int_value(cJSON_GetObjectItemCaseSensitive(root, "ret"), 0) != 0 || + cap_im_wechat_int_value(cJSON_GetObjectItemCaseSensitive(root, "errcode"), 0) != 0) { + ESP_LOGW(TAG, "wechat getupdates error: %s", cJSON_PrintUnformatted(root)); + cJSON_Delete(root); + return ESP_FAIL; + } + + s_wechat.poll_timeout_ms = + cap_im_wechat_int_value(cJSON_GetObjectItemCaseSensitive(root, + "longpolling_timeout_ms"), + CAP_IM_WECHAT_POLL_TIMEOUT_MS); + if (s_wechat.poll_timeout_ms <= 0) { + s_wechat.poll_timeout_ms = CAP_IM_WECHAT_POLL_TIMEOUT_MS; + } + + { + const char *next_sync = + cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, + "get_updates_buf")); + if (next_sync) { + char *dup = strdup(next_sync); + + if (!dup) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + free(s_wechat.sync_buf); + s_wechat.sync_buf = dup; + } + } + + msgs = cap_im_wechat_require_array(root, "msgs"); + if (!msgs) { + cJSON_Delete(root); + return ESP_OK; + } + + msg_count = cJSON_GetArraySize(msgs); + for (i = 0; i < msg_count; i++) { + msg = cJSON_GetArrayItem(msgs, i); + cap_im_wechat_process_message(msg); + } + + cJSON_Delete(root); + return ESP_OK; +} + +static void cap_im_wechat_poll_task(void *arg) +{ + (void)arg; + + while (!s_wechat.stop_requested) { + if (!s_wechat.configured) { + vTaskDelay(pdMS_TO_TICKS(5000)); + continue; + } + + if (cap_im_wechat_poll_once() != ESP_OK) { + ESP_LOGW(TAG, "WeChat polling failed, retrying"); + vTaskDelay(pdMS_TO_TICKS(CAP_IM_WECHAT_RETRY_DELAY_MS)); + } + } + + s_wechat.poll_task = NULL; + vTaskDelete(NULL); +} + +static esp_err_t cap_im_wechat_send_message_json(cJSON *msg_root) +{ + cJSON *root = NULL; + cap_im_wechat_http_resp_t response = {0}; + esp_err_t err; + + if (!msg_root) { + return ESP_ERR_INVALID_ARG; + } + + root = cJSON_CreateObject(); + if (!root) { + return ESP_ERR_NO_MEM; + } + + if (!cJSON_AddItemToObject(root, "msg", msg_root)) { + cJSON_Delete(msg_root); + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + + err = cap_im_wechat_add_base_info(root); + if (err != ESP_OK) { + cJSON_Delete(root); + return err; + } + + err = cap_im_wechat_api_post("ilink/bot/sendmessage", root, 15000, &response); + cJSON_Delete(root); + cap_im_wechat_resp_cleanup(&response); + return err; +} + +static void cap_im_wechat_build_client_id(char *buf, size_t buf_size) +{ + uint32_t a = esp_random(); + uint32_t b = esp_random(); + + snprintf(buf, buf_size, "espwx-%08" PRIx32 "%08" PRIx32, a, b); +} + +static esp_err_t cap_im_wechat_send_text_chunk(const char *chat_id, const char *chunk) +{ + cJSON *msg = NULL; + cJSON *item_list = NULL; + cJSON *item = NULL; + cJSON *text_item = NULL; + char client_id[CAP_IM_WECHAT_CLIENT_ID_SIZE]; + const char *context_token = NULL; + + if (!chat_id || !chat_id[0] || !chunk || !chunk[0] || !s_wechat.configured) { + return ESP_ERR_INVALID_ARG; + } + + msg = cJSON_CreateObject(); + if (!msg) { + return ESP_ERR_NO_MEM; + } + + cap_im_wechat_build_client_id(client_id, sizeof(client_id)); + cJSON_AddStringToObject(msg, "from_user_id", ""); + cJSON_AddStringToObject(msg, "to_user_id", chat_id); + cJSON_AddStringToObject(msg, "client_id", client_id); + cJSON_AddNumberToObject(msg, "message_type", 2); + cJSON_AddNumberToObject(msg, "message_state", 2); + context_token = cap_im_wechat_context_lookup(chat_id); + if (context_token) { + cJSON_AddStringToObject(msg, "context_token", context_token); + } + + item_list = cJSON_AddArrayToObject(msg, "item_list"); + item = cJSON_CreateObject(); + text_item = cJSON_CreateObject(); + if (!item_list || !item || !text_item) { + cJSON_Delete(msg); + return ESP_ERR_NO_MEM; + } + + cJSON_AddNumberToObject(item, "type", 1); + cJSON_AddStringToObject(text_item, "text", chunk); + cJSON_AddItemToObject(item, "text_item", text_item); + cJSON_AddItemToArray(item_list, item); + return cap_im_wechat_send_message_json(msg); +} + +static esp_err_t cap_im_wechat_read_file(const char *path, + unsigned char **out_buf, + size_t *out_len) +{ + FILE *file = NULL; + struct stat st = {0}; + unsigned char *buf = NULL; + + if (!path || !out_buf || !out_len) { + return ESP_ERR_INVALID_ARG; + } + + if (stat(path, &st) != 0 || st.st_size < 0) { + return ESP_FAIL; + } + + buf = calloc(1, (size_t)st.st_size); + if (!buf) { + return ESP_ERR_NO_MEM; + } + + file = fopen(path, "rb"); + if (!file) { + free(buf); + return ESP_FAIL; + } + + if (st.st_size > 0 && + fread(buf, 1, (size_t)st.st_size, file) != (size_t)st.st_size) { + fclose(file); + free(buf); + return ESP_FAIL; + } + + fclose(file); + *out_buf = buf; + *out_len = (size_t)st.st_size; + return ESP_OK; +} + +static esp_err_t cap_im_wechat_md5_hex(const unsigned char *buf, + size_t len, + char *out, + size_t out_size) +{ + unsigned char digest[16]; + size_t i; + + if (!buf || !out || out_size < 33) { + return ESP_ERR_INVALID_ARG; + } + + mbedtls_md5(buf, len, digest); + for (i = 0; i < sizeof(digest); i++) { + snprintf(out + (i * 2), out_size - (i * 2), "%02x", digest[i]); + } + return ESP_OK; +} + +static esp_err_t cap_im_wechat_random_hex(unsigned char *raw, + size_t raw_len, + char *hex, + size_t hex_size) +{ + size_t i; + + if (!raw || !hex || hex_size < (raw_len * 2 + 1)) { + return ESP_ERR_INVALID_ARG; + } + + esp_fill_random(raw, raw_len); + for (i = 0; i < raw_len; i++) { + snprintf(hex + (i * 2), hex_size - (i * 2), "%02x", raw[i]); + } + return ESP_OK; +} + +static esp_err_t cap_im_wechat_upload_ciphertext(const char *upload_full_url, + const char *upload_param, + const char *filekey, + const unsigned char *ciphertext, + size_t ciphertext_len, + char **encrypted_param) +{ + cap_im_wechat_http_resp_t response = {0}; + char *url = NULL; + char *encoded_upload_param = NULL; + char *encoded_filekey = NULL; + esp_err_t err; + + if (upload_full_url && upload_full_url[0]) { + url = strdup(upload_full_url); + if (!url) { + return ESP_ERR_NO_MEM; + } + } else if (upload_param && upload_param[0] && filekey && filekey[0]) { + err = cap_im_wechat_url_encode_dup(upload_param, &encoded_upload_param); + if (err != ESP_OK) { + return err; + } + err = cap_im_wechat_url_encode_dup(filekey, &encoded_filekey); + if (err != ESP_OK) { + free(encoded_upload_param); + return err; + } + if (asprintf(&url, + "%s/upload?encrypted_query_param=%s&filekey=%s", + s_wechat.cdn_base_url, + encoded_upload_param, + encoded_filekey) < 0) { + free(encoded_upload_param); + free(encoded_filekey); + return ESP_ERR_NO_MEM; + } + free(encoded_upload_param); + free(encoded_filekey); + } else { + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_wechat_http_request(url, + HTTP_METHOD_POST, + "application/octet-stream", + ciphertext, + ciphertext_len, + 30000, + false, + false, + &response); + free(url); + if (err == ESP_OK) { + if (!response.encrypted_param || !response.encrypted_param[0]) { + err = ESP_FAIL; + } else { + *encrypted_param = response.encrypted_param; + response.encrypted_param = NULL; + } + } + cap_im_wechat_resp_cleanup(&response); + return err; +} + +static esp_err_t cap_im_wechat_upload_file(const char *chat_id, + const char *file_path, + int media_type, + char **download_param, + char *aes_key_base64, + size_t aes_key_base64_size, + size_t *ciphertext_size, + size_t *plaintext_size) +{ + unsigned char *plaintext = NULL; + unsigned char *ciphertext = NULL; + unsigned char aes_key_raw[16]; + char aes_key_hex[33]; + char filekey_hex[33]; + char md5_hex[33]; + char *upload_full_url = NULL; + char *upload_param = NULL; + char upload_aes_key_base64[96]; + size_t plain_len = 0; + size_t cipher_len = 0; + cap_im_wechat_http_resp_t response = {0}; + cJSON *root = NULL; + esp_err_t err; + + if (!chat_id || !file_path || !download_param || !aes_key_base64 || !ciphertext_size || + !plaintext_size) { + return ESP_ERR_INVALID_ARG; + } + + ESP_RETURN_ON_ERROR(cap_im_wechat_read_file(file_path, &plaintext, &plain_len), + TAG, + "read upload file failed"); + ESP_RETURN_ON_ERROR(cap_im_wechat_md5_hex(plaintext, plain_len, md5_hex, sizeof(md5_hex)), + TAG, + "md5 failed"); + ESP_RETURN_ON_ERROR(cap_im_wechat_random_hex(aes_key_raw, + sizeof(aes_key_raw), + aes_key_hex, + sizeof(aes_key_hex)), + TAG, + "random aes key failed"); + ESP_RETURN_ON_ERROR(cap_im_wechat_base64_encode((const unsigned char *)aes_key_hex, + strlen(aes_key_hex), + upload_aes_key_base64, + sizeof(upload_aes_key_base64)), + TAG, + "base64 aes key failed"); + { + unsigned char filekey_raw[16]; + + ESP_RETURN_ON_ERROR(cap_im_wechat_random_hex(filekey_raw, + sizeof(filekey_raw), + filekey_hex, + sizeof(filekey_hex)), + TAG, + "random filekey failed"); + } + + root = cJSON_CreateObject(); + if (!root) { + free(plaintext); + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(root, "filekey", filekey_hex); + cJSON_AddNumberToObject(root, "media_type", media_type); + cJSON_AddStringToObject(root, "to_user_id", chat_id); + cJSON_AddNumberToObject(root, "rawsize", (double)plain_len); + cJSON_AddStringToObject(root, "rawfilemd5", md5_hex); + cJSON_AddNumberToObject(root, "filesize", (double)(((plain_len / 16) + 1) * 16)); + cJSON_AddBoolToObject(root, "no_need_thumb", 1); + cJSON_AddStringToObject(root, "aeskey", aes_key_hex); + err = cap_im_wechat_add_base_info(root); + if (err != ESP_OK) { + cJSON_Delete(root); + free(plaintext); + return err; + } + + err = cap_im_wechat_api_post("ilink/bot/getuploadurl", root, 15000, &response); + cJSON_Delete(root); + if (err != ESP_OK) { + free(plaintext); + cap_im_wechat_resp_cleanup(&response); + return err; + } + + root = cJSON_Parse(response.buf); + cap_im_wechat_resp_cleanup(&response); + if (!root) { + free(plaintext); + return ESP_ERR_INVALID_RESPONSE; + } + + upload_full_url = strdup(cap_im_wechat_string_value( + cJSON_GetObjectItemCaseSensitive(root, "upload_full_url")) ? + cap_im_wechat_string_value( + cJSON_GetObjectItemCaseSensitive(root, "upload_full_url")) : + ""); + upload_param = strdup(cap_im_wechat_string_value( + cJSON_GetObjectItemCaseSensitive(root, "upload_param")) ? + cap_im_wechat_string_value( + cJSON_GetObjectItemCaseSensitive(root, "upload_param")) : + ""); + cJSON_Delete(root); + if (!upload_full_url || !upload_param) { + free(upload_full_url); + free(upload_param); + free(plaintext); + return ESP_ERR_NO_MEM; + } + + err = cap_im_wechat_aes_ecb_crypt(plaintext, + plain_len, + aes_key_raw, + sizeof(aes_key_raw), + true, + &ciphertext, + &cipher_len); + free(plaintext); + if (err != ESP_OK) { + free(upload_full_url); + free(upload_param); + return err; + } + + err = cap_im_wechat_upload_ciphertext(upload_full_url[0] ? upload_full_url : NULL, + upload_param[0] ? upload_param : NULL, + filekey_hex, + ciphertext, + cipher_len, + download_param); + free(upload_full_url); + free(upload_param); + free(ciphertext); + if (err != ESP_OK) { + return err; + } + + strlcpy(aes_key_base64, upload_aes_key_base64, aes_key_base64_size); + *ciphertext_size = cipher_len; + *plaintext_size = plain_len; + return ESP_OK; +} + +static esp_err_t cap_im_wechat_send_image_message(const char *chat_id, + const char *download_param, + const char *aes_key_base64, + size_t ciphertext_size) +{ + cJSON *msg = NULL; + cJSON *item_list = NULL; + cJSON *item = NULL; + cJSON *image_item = NULL; + cJSON *media = NULL; + char client_id[CAP_IM_WECHAT_CLIENT_ID_SIZE]; + const char *context_token = NULL; + + msg = cJSON_CreateObject(); + if (!msg) { + return ESP_ERR_NO_MEM; + } + + cap_im_wechat_build_client_id(client_id, sizeof(client_id)); + cJSON_AddStringToObject(msg, "from_user_id", ""); + cJSON_AddStringToObject(msg, "to_user_id", chat_id); + cJSON_AddStringToObject(msg, "client_id", client_id); + cJSON_AddNumberToObject(msg, "message_type", 2); + cJSON_AddNumberToObject(msg, "message_state", 2); + context_token = cap_im_wechat_context_lookup(chat_id); + if (context_token) { + cJSON_AddStringToObject(msg, "context_token", context_token); + } + + item_list = cJSON_AddArrayToObject(msg, "item_list"); + item = cJSON_CreateObject(); + image_item = cJSON_CreateObject(); + media = cJSON_CreateObject(); + if (!item_list || !item || !image_item || !media) { + cJSON_Delete(msg); + return ESP_ERR_NO_MEM; + } + + cJSON_AddNumberToObject(item, "type", 2); + cJSON_AddStringToObject(media, "encrypt_query_param", download_param); + cJSON_AddStringToObject(media, "aes_key", aes_key_base64); + cJSON_AddNumberToObject(media, "encrypt_type", 1); + cJSON_AddItemToObject(image_item, "media", media); + cJSON_AddNumberToObject(image_item, "mid_size", (double)ciphertext_size); + cJSON_AddItemToObject(item, "image_item", image_item); + cJSON_AddItemToArray(item_list, item); + return cap_im_wechat_send_message_json(msg); +} + +static esp_err_t cap_im_wechat_send_message_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = 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) { + return ESP_ERR_INVALID_ARG; + } + + chat_id = cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, "chat_id")); + message = cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, "message")); + if (!chat_id || !message) { + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_wechat_send_text(chat_id, message); + cJSON_Delete(root); + if (err != ESP_OK) { + return err; + } + + strlcpy(output, "{\"ok\":true}", output_size); + return ESP_OK; +} + +static esp_err_t cap_im_wechat_send_image_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *chat_id = NULL; + const char *path = NULL; + const char *caption = 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) { + return ESP_ERR_INVALID_ARG; + } + + chat_id = cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, "chat_id")); + path = cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, "path")); + caption = cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, "caption")); + if (!chat_id || !path) { + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_wechat_send_image(chat_id, path, caption); + cJSON_Delete(root); + if (err != ESP_OK) { + return err; + } + + strlcpy(output, "{\"ok\":true}", output_size); + return ESP_OK; +} + +static esp_err_t cap_im_wechat_gateway_init(void) +{ + if (cap_im_wechat_lock() == ESP_OK) { + if (!s_wechat.qr.status[0]) { + cap_im_wechat_qr_reset_locked(); + } + cap_im_wechat_unlock(); + } + return ESP_OK; +} + +static esp_err_t cap_im_wechat_gateway_start(void) +{ + BaseType_t ok; + + if (s_wechat.poll_task) { + return ESP_OK; + } + + s_wechat.stop_requested = false; + ok = xTaskCreate(cap_im_wechat_poll_task, + "wechat_poll", + CAP_IM_WECHAT_TASK_STACK, + NULL, + CAP_IM_WECHAT_TASK_PRIO, + &s_wechat.poll_task); + if (ok != pdPASS) { + s_wechat.poll_task = NULL; + return ESP_FAIL; + } + + return ESP_OK; +} + +static esp_err_t cap_im_wechat_gateway_stop(void) +{ + TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(5000); + + s_wechat.stop_requested = true; + while (s_wechat.poll_task && xTaskGetTickCount() < deadline) { + vTaskDelay(pdMS_TO_TICKS(50)); + } + + return s_wechat.poll_task ? ESP_ERR_TIMEOUT : ESP_OK; +} + +static const claw_cap_descriptor_t s_wechat_descriptors[] = { + { + .id = "wechat_gateway", + .name = "wechat_gateway", + .family = "im", + .description = "WeChat long-poll 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_wechat_gateway_init, + .start = cap_im_wechat_gateway_start, + .stop = cap_im_wechat_gateway_stop, + }, + { + .id = "wechat_send_message", + .name = "wechat_send_message", + .family = "im", + .description = "Send a WeChat text message.", + .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_wechat_send_message_execute, + }, + { + .id = "wechat_send_image", + .name = "wechat_send_image", + .family = "im", + .description = "Send a WeChat image from a local path.", + .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\":[\"chat_id\",\"path\"]}", + .execute = cap_im_wechat_send_image_execute, + }, +}; + +static const claw_cap_group_t s_wechat_group = { + .group_id = "cap_im_wechat", + .descriptors = s_wechat_descriptors, + .descriptor_count = sizeof(s_wechat_descriptors) / sizeof(s_wechat_descriptors[0]), +}; + +esp_err_t cap_im_wechat_register_group(void) +{ + if (claw_cap_group_exists(s_wechat_group.group_id)) { + return ESP_OK; + } + + return claw_cap_register_group(&s_wechat_group); +} + +esp_err_t cap_im_wechat_set_client_config(const cap_im_wechat_client_config_t *config) +{ + if (!config || !config->token || !config->base_url) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(s_wechat.token, config->token, sizeof(s_wechat.token)); + strlcpy(s_wechat.base_url, + config->base_url[0] ? config->base_url : CAP_IM_WECHAT_DEFAULT_BASE_URL, + sizeof(s_wechat.base_url)); + strlcpy(s_wechat.cdn_base_url, + (config->cdn_base_url && config->cdn_base_url[0]) ? config->cdn_base_url : + CAP_IM_WECHAT_DEFAULT_CDN_BASE_URL, + sizeof(s_wechat.cdn_base_url)); + strlcpy(s_wechat.account_id, + (config->account_id && config->account_id[0]) ? config->account_id : "default", + sizeof(s_wechat.account_id)); + strlcpy(s_wechat.app_id, + (config->app_id && config->app_id[0]) ? config->app_id : + CAP_IM_WECHAT_DEFAULT_APP_ID, + sizeof(s_wechat.app_id)); + strlcpy(s_wechat.client_version, + (config->client_version && config->client_version[0]) ? config->client_version : + CAP_IM_WECHAT_DEFAULT_CLIENT_VERSION, + sizeof(s_wechat.client_version)); + strlcpy(s_wechat.route_tag, + (config->route_tag && config->route_tag[0]) ? config->route_tag : "", + sizeof(s_wechat.route_tag)); + s_wechat.configured = s_wechat.token[0] && s_wechat.base_url[0]; + return ESP_OK; +} + +esp_err_t cap_im_wechat_set_attachment_config( + const cap_im_wechat_attachment_config_t *config) +{ + if (!config || !config->storage_root_dir) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(s_wechat.attachment_root_dir, + config->storage_root_dir, + sizeof(s_wechat.attachment_root_dir)); + s_wechat.max_inbound_file_bytes = config->max_inbound_file_bytes; + s_wechat.enable_inbound_attachments = config->enable_inbound_attachments; + return ESP_OK; +} + +esp_err_t cap_im_wechat_start(void) +{ + return cap_im_wechat_gateway_start(); +} + +esp_err_t cap_im_wechat_stop(void) +{ + return cap_im_wechat_gateway_stop(); +} + +esp_err_t cap_im_wechat_qr_login_start(const char *account_id, bool force) +{ + BaseType_t ok; + esp_err_t err; + + err = cap_im_wechat_lock(); + if (err != ESP_OK) { + return err; + } + + if (s_wechat.qr.active && !force) { + cap_im_wechat_unlock(); + return ESP_OK; + } + + cap_im_wechat_qr_reset_locked(); + s_wechat.qr.active = true; + s_wechat.qr.persisted = false; + if (account_id && account_id[0]) { + strlcpy(s_wechat.qr.account_id, account_id, sizeof(s_wechat.qr.account_id)); + } + cap_im_wechat_random_session_key(s_wechat.qr.session_key, sizeof(s_wechat.qr.session_key)); + err = cap_im_wechat_qr_fetch_code_locked(); + if (err != ESP_OK) { + s_wechat.qr.active = false; + strlcpy(s_wechat.qr.status, "error", sizeof(s_wechat.qr.status)); + snprintf(s_wechat.qr.message, + sizeof(s_wechat.qr.message), + "拉取二维码失败: %s", + esp_err_to_name(err)); + cap_im_wechat_unlock(); + return err; + } + + if (!s_wechat.qr_task) { + ok = xTaskCreate(cap_im_wechat_qr_task, + "wechat_qr", + CAP_IM_WECHAT_TASK_STACK, + NULL, + CAP_IM_WECHAT_TASK_PRIO, + &s_wechat.qr_task); + if (ok != pdPASS) { + s_wechat.qr.active = false; + s_wechat.qr_task = NULL; + cap_im_wechat_unlock(); + return ESP_FAIL; + } + } + + cap_im_wechat_unlock(); + return ESP_OK; +} + +esp_err_t cap_im_wechat_qr_login_get_status(cap_im_wechat_qr_login_status_t *out_status) +{ + esp_err_t err; + + if (!out_status) { + return ESP_ERR_INVALID_ARG; + } + + err = cap_im_wechat_lock(); + if (err != ESP_OK) { + return err; + } + + memset(out_status, 0, sizeof(*out_status)); + out_status->active = s_wechat.qr.active; + out_status->configured = s_wechat.configured; + out_status->completed = s_wechat.qr.completed; + out_status->persisted = s_wechat.qr.persisted; + strlcpy(out_status->session_key, s_wechat.qr.session_key, sizeof(out_status->session_key)); + strlcpy(out_status->status, + s_wechat.qr.status[0] ? s_wechat.qr.status : "idle", + sizeof(out_status->status)); + strlcpy(out_status->message, s_wechat.qr.message, sizeof(out_status->message)); + strlcpy(out_status->qr_data_url, s_wechat.qr.qr_data_url, sizeof(out_status->qr_data_url)); + strlcpy(out_status->account_id, + s_wechat.qr.ilink_bot_id[0] ? s_wechat.qr.ilink_bot_id : s_wechat.qr.account_id, + sizeof(out_status->account_id)); + strlcpy(out_status->user_id, s_wechat.qr.ilink_user_id, sizeof(out_status->user_id)); + strlcpy(out_status->token, s_wechat.qr.bot_token, sizeof(out_status->token)); + strlcpy(out_status->base_url, + s_wechat.qr.base_url[0] ? s_wechat.qr.base_url : CAP_IM_WECHAT_DEFAULT_BASE_URL, + sizeof(out_status->base_url)); + cap_im_wechat_unlock(); + return ESP_OK; +} + +esp_err_t cap_im_wechat_qr_login_cancel(void) +{ + esp_err_t err = cap_im_wechat_lock(); + + if (err != ESP_OK) { + return err; + } + + s_wechat.qr.stop_requested = true; + s_wechat.qr.active = false; + strlcpy(s_wechat.qr.status, "cancelled", sizeof(s_wechat.qr.status)); + strlcpy(s_wechat.qr.message, "已取消微信登录。", sizeof(s_wechat.qr.message)); + cap_im_wechat_unlock(); + return ESP_OK; +} + +esp_err_t cap_im_wechat_qr_login_mark_persisted(void) +{ + esp_err_t err = cap_im_wechat_lock(); + + if (err != ESP_OK) { + return err; + } + + s_wechat.qr.persisted = true; + cap_im_wechat_unlock(); + return ESP_OK; +} + +esp_err_t cap_im_wechat_send_text(const char *chat_id, const char *text) +{ + size_t len; + size_t offset = 0; + esp_err_t last_err = ESP_OK; + + if (!chat_id || !chat_id[0] || !text || !text[0] || !s_wechat.configured) { + return ESP_ERR_INVALID_ARG; + } + + len = strlen(text); + while (offset < len) { + size_t chunk_len = len - offset; + char *chunk = NULL; + esp_err_t err; + + if (chunk_len > CAP_IM_WECHAT_MAX_MSG_LEN) { + chunk_len = CAP_IM_WECHAT_MAX_MSG_LEN; + } + + chunk = calloc(1, chunk_len + 1); + if (!chunk) { + return ESP_ERR_NO_MEM; + } + + memcpy(chunk, text + offset, chunk_len); + err = cap_im_wechat_send_text_chunk(chat_id, chunk); + free(chunk); + if (err != ESP_OK) { + last_err = err; + } + offset += chunk_len; + } + + return last_err; +} + +esp_err_t cap_im_wechat_send_image(const char *chat_id, const char *path, const char *caption) +{ + char *download_param = NULL; + char aes_key_base64[96]; + size_t ciphertext_size = 0; + size_t plaintext_size = 0; + esp_err_t err; + + if (!chat_id || !chat_id[0] || !path || !path[0] || !s_wechat.configured) { + return ESP_ERR_INVALID_ARG; + } + + if (caption && caption[0]) { + ESP_RETURN_ON_ERROR(cap_im_wechat_send_text(chat_id, caption), TAG, "send caption failed"); + } + + err = cap_im_wechat_upload_file(chat_id, + path, + 1, + &download_param, + aes_key_base64, + sizeof(aes_key_base64), + &ciphertext_size, + &plaintext_size); + if (err != ESP_OK) { + return err; + } + + (void)plaintext_size; + err = cap_im_wechat_send_image_message(chat_id, + download_param, + aes_key_base64, + ciphertext_size); + free(download_param); + return err; +} + +static void cap_im_wechat_qr_reset_locked(void) +{ + memset(&s_wechat.qr, 0, sizeof(s_wechat.qr)); + strlcpy(s_wechat.qr.status, "idle", sizeof(s_wechat.qr.status)); +} + +static esp_err_t cap_im_wechat_qr_fetch_code_locked(void) +{ + cap_im_wechat_http_resp_t response = {0}; + cJSON *root = NULL; + char endpoint[96]; + const char *qrcode = NULL; + const char *qrcode_img_content = NULL; + esp_err_t err; + + snprintf(endpoint, sizeof(endpoint), "ilink/bot/get_bot_qrcode?bot_type=3"); + err = cap_im_wechat_http_get_json(CAP_IM_WECHAT_DEFAULT_BASE_URL, + endpoint, + CAP_IM_WECHAT_QR_START_TIMEOUT_MS, + &response); + if (err != ESP_OK) { + cap_im_wechat_resp_cleanup(&response); + return err; + } + + root = cJSON_Parse(response.buf); + cap_im_wechat_resp_cleanup(&response); + if (!root) { + return ESP_ERR_INVALID_RESPONSE; + } + + qrcode = cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, "qrcode")); + qrcode_img_content = cap_im_wechat_string_value( + cJSON_GetObjectItemCaseSensitive(root, "qrcode_img_content")); + if (!qrcode || !qrcode_img_content) { + cJSON_Delete(root); + return ESP_ERR_INVALID_RESPONSE; + } + + strlcpy(s_wechat.qr.qrcode, qrcode, sizeof(s_wechat.qr.qrcode)); + strlcpy(s_wechat.qr.qr_data_url, qrcode_img_content, sizeof(s_wechat.qr.qr_data_url)); + strlcpy(s_wechat.qr.status, "waiting_scan", sizeof(s_wechat.qr.status)); + strlcpy(s_wechat.qr.message, "使用微信扫描二维码完成登录。", sizeof(s_wechat.qr.message)); + s_wechat.qr.started_at_ms = cap_im_wechat_now_ms(); + strlcpy(s_wechat.qr.current_api_base_url, + CAP_IM_WECHAT_DEFAULT_BASE_URL, + sizeof(s_wechat.qr.current_api_base_url)); + cJSON_Delete(root); + return ESP_OK; +} + +static esp_err_t cap_im_wechat_qr_poll_once_locked(void) +{ + cap_im_wechat_http_resp_t response = {0}; + cJSON *root = NULL; + char endpoint[CAP_IM_WECHAT_URL_BUF_SIZE]; + const char *status = NULL; + const char *redirect_host = NULL; + const char *bot_token = NULL; + const char *ilink_bot_id = NULL; + const char *ilink_user_id = NULL; + const char *baseurl = NULL; + esp_err_t err; + + if (!s_wechat.qr.active || !s_wechat.qr.qrcode[0]) { + return ESP_ERR_INVALID_STATE; + } + + snprintf(endpoint, + sizeof(endpoint), + "ilink/bot/get_qrcode_status?qrcode=%s", + s_wechat.qr.qrcode); + err = cap_im_wechat_http_get_json(s_wechat.qr.current_api_base_url[0] ? + s_wechat.qr.current_api_base_url : + CAP_IM_WECHAT_DEFAULT_BASE_URL, + endpoint, + CAP_IM_WECHAT_QR_POLL_TIMEOUT_MS, + &response); + if (err != ESP_OK) { + cap_im_wechat_resp_cleanup(&response); + return err; + } + + root = cJSON_Parse(response.buf); + cap_im_wechat_resp_cleanup(&response); + if (!root) { + return ESP_ERR_INVALID_RESPONSE; + } + + status = cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, "status")); + redirect_host = + cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, "redirect_host")); + bot_token = cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, "bot_token")); + ilink_bot_id = + cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, "ilink_bot_id")); + ilink_user_id = + cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, "ilink_user_id")); + baseurl = cap_im_wechat_string_value(cJSON_GetObjectItemCaseSensitive(root, "baseurl")); + + if (!status) { + cJSON_Delete(root); + return ESP_ERR_INVALID_RESPONSE; + } + + if (strcmp(status, "wait") == 0) { + strlcpy(s_wechat.qr.status, "waiting_scan", sizeof(s_wechat.qr.status)); + strlcpy(s_wechat.qr.message, "等待扫码。", sizeof(s_wechat.qr.message)); + } else if (strcmp(status, "scanned") == 0) { + strlcpy(s_wechat.qr.status, "scanned", sizeof(s_wechat.qr.status)); + strlcpy(s_wechat.qr.message, "已扫码,请在微信中确认。", sizeof(s_wechat.qr.message)); + } else if (strcmp(status, "scaned_but_redirect") == 0) { + if (redirect_host && redirect_host[0]) { + snprintf(s_wechat.qr.current_api_base_url, + sizeof(s_wechat.qr.current_api_base_url), + "https://%s", + redirect_host); + } + strlcpy(s_wechat.qr.status, "redirected", sizeof(s_wechat.qr.status)); + strlcpy(s_wechat.qr.message, "登录节点已切换,继续等待确认。", sizeof(s_wechat.qr.message)); + } else if (strcmp(status, "expired") == 0) { + strlcpy(s_wechat.qr.status, "expired", sizeof(s_wechat.qr.status)); + strlcpy(s_wechat.qr.message, "二维码已过期。", sizeof(s_wechat.qr.message)); + cJSON_Delete(root); + return ESP_ERR_TIMEOUT; + } else if (strcmp(status, "confirmed") == 0) { + strlcpy(s_wechat.qr.status, "confirmed", sizeof(s_wechat.qr.status)); + strlcpy(s_wechat.qr.message, "微信登录成功。", sizeof(s_wechat.qr.message)); + if (bot_token) { + strlcpy(s_wechat.qr.bot_token, bot_token, sizeof(s_wechat.qr.bot_token)); + } + if (ilink_bot_id) { + strlcpy(s_wechat.qr.ilink_bot_id, ilink_bot_id, sizeof(s_wechat.qr.ilink_bot_id)); + } + if (ilink_user_id) { + strlcpy(s_wechat.qr.ilink_user_id, ilink_user_id, sizeof(s_wechat.qr.ilink_user_id)); + } + if (baseurl && baseurl[0]) { + strlcpy(s_wechat.qr.base_url, baseurl, sizeof(s_wechat.qr.base_url)); + } else { + strlcpy(s_wechat.qr.base_url, + s_wechat.qr.current_api_base_url[0] ? s_wechat.qr.current_api_base_url : + CAP_IM_WECHAT_DEFAULT_BASE_URL, + sizeof(s_wechat.qr.base_url)); + } + s_wechat.qr.completed = true; + s_wechat.qr.active = false; + } else { + strlcpy(s_wechat.qr.status, "error", sizeof(s_wechat.qr.status)); + strlcpy(s_wechat.qr.message, "二维码状态未知。", sizeof(s_wechat.qr.message)); + } + + cJSON_Delete(root); + return ESP_OK; +} + +static void cap_im_wechat_qr_task(void *arg) +{ + (void)arg; + + while (1) { + bool should_stop = false; + bool should_break = false; + bool needs_refresh = false; + esp_err_t err; + + if (cap_im_wechat_lock() != ESP_OK) { + vTaskDelay(pdMS_TO_TICKS(200)); + continue; + } + + should_stop = s_wechat.qr.stop_requested || !s_wechat.qr.active; + if (should_stop) { + s_wechat.qr_task = NULL; + cap_im_wechat_unlock(); + break; + } + + if (cap_im_wechat_now_ms() - s_wechat.qr.started_at_ms > CAP_IM_WECHAT_QR_TTL_MS) { + if (s_wechat.qr.refresh_count + 1 >= CAP_IM_WECHAT_QR_MAX_REFRESH) { + strlcpy(s_wechat.qr.status, "expired", sizeof(s_wechat.qr.status)); + strlcpy(s_wechat.qr.message, + "二维码已多次过期,请重新生成。", + sizeof(s_wechat.qr.message)); + s_wechat.qr.active = false; + s_wechat.qr_task = NULL; + cap_im_wechat_unlock(); + break; + } + + s_wechat.qr.refresh_count++; + cap_im_wechat_unlock(); + err = cap_im_wechat_qr_fetch_code_locked(); + if (err != ESP_OK) { + if (cap_im_wechat_lock() == ESP_OK) { + strlcpy(s_wechat.qr.status, "error", sizeof(s_wechat.qr.status)); + strlcpy(s_wechat.qr.message, "刷新二维码失败。", sizeof(s_wechat.qr.message)); + s_wechat.qr.active = false; + s_wechat.qr_task = NULL; + cap_im_wechat_unlock(); + } + break; + } + vTaskDelay(pdMS_TO_TICKS(500)); + continue; + } + + cap_im_wechat_unlock(); + err = cap_im_wechat_qr_poll_once_locked(); + + if (cap_im_wechat_lock() != ESP_OK) { + vTaskDelay(pdMS_TO_TICKS(200)); + continue; + } + + if (err == ESP_ERR_TIMEOUT) { + if (s_wechat.qr.refresh_count + 1 >= CAP_IM_WECHAT_QR_MAX_REFRESH) { + s_wechat.qr.active = false; + s_wechat.qr_task = NULL; + cap_im_wechat_unlock(); + break; + } + s_wechat.qr.refresh_count++; + needs_refresh = true; + } else if (err != ESP_OK) { + strlcpy(s_wechat.qr.status, "error", sizeof(s_wechat.qr.status)); + snprintf(s_wechat.qr.message, + sizeof(s_wechat.qr.message), + "轮询扫码状态失败: %s", + esp_err_to_name(err)); + s_wechat.qr.active = false; + should_break = true; + } else if (!s_wechat.qr.active) { + should_break = true; + } + + cap_im_wechat_unlock(); + + if (needs_refresh) { + err = cap_im_wechat_qr_fetch_code_locked(); + if (err != ESP_OK) { + if (cap_im_wechat_lock() == ESP_OK) { + strlcpy(s_wechat.qr.status, "error", sizeof(s_wechat.qr.status)); + strlcpy(s_wechat.qr.message, "刷新二维码失败。", sizeof(s_wechat.qr.message)); + s_wechat.qr.active = false; + s_wechat.qr_task = NULL; + cap_im_wechat_unlock(); + } + break; + } + } + + if (should_break) { + if (cap_im_wechat_lock() == ESP_OK) { + s_wechat.qr_task = NULL; + cap_im_wechat_unlock(); + } + break; + } + + vTaskDelay(pdMS_TO_TICKS(1000)); + } + + vTaskDelete(NULL); +} diff --git a/components/cap_im_wechat/src/cmd_cap_im_wechat.c b/components/cap_im_wechat/src/cmd_cap_im_wechat.c new file mode 100644 index 0000000..6879858 --- /dev/null +++ b/components/cap_im_wechat/src/cmd_cap_im_wechat.c @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cmd_cap_im_wechat.h" + +#include + +#include "argtable3/argtable3.h" +#include "cap_im_wechat.h" +#include "esp_console.h" + +static struct { + struct arg_lit *set_config; + struct arg_lit *start; + struct arg_lit *stop; + struct arg_str *send_text; + struct arg_str *send_image; + struct arg_str *token; + struct arg_str *base_url; + struct arg_str *cdn_base_url; + struct arg_str *account_id; + struct arg_str *app_id; + struct arg_str *client_version; + struct arg_str *route_tag; + struct arg_str *text; + struct arg_str *path; + struct arg_str *caption; + struct arg_end *end; +} wechat_args; + +static int cmd_wechat_set_config(void) +{ + cap_im_wechat_client_config_t config = {0}; + esp_err_t err; + + if (!wechat_args.token->count || !wechat_args.base_url->count) { + printf("'--set-config' requires '--token' and '--base-url'\n"); + return 1; + } + + config.token = wechat_args.token->sval[0]; + config.base_url = wechat_args.base_url->sval[0]; + config.cdn_base_url = wechat_args.cdn_base_url->count ? wechat_args.cdn_base_url->sval[0] : NULL; + config.account_id = wechat_args.account_id->count ? wechat_args.account_id->sval[0] : NULL; + config.app_id = wechat_args.app_id->count ? wechat_args.app_id->sval[0] : NULL; + config.client_version = wechat_args.client_version->count ? wechat_args.client_version->sval[0] : + NULL; + config.route_tag = wechat_args.route_tag->count ? wechat_args.route_tag->sval[0] : NULL; + + err = cap_im_wechat_set_client_config(&config); + if (err != ESP_OK) { + printf("wechat_set_config failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("WeChat client config updated\n"); + return 0; +} + +static int cmd_wechat_start(void) +{ + esp_err_t err = cap_im_wechat_start(); + + if (err != ESP_OK) { + printf("wechat_start failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("WeChat gateway started\n"); + return 0; +} + +static int cmd_wechat_stop(void) +{ + esp_err_t err = cap_im_wechat_stop(); + + if (err != ESP_OK) { + printf("wechat_stop failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("WeChat gateway stopped\n"); + return 0; +} + +static int cmd_wechat_send_text(const char *chat_id, const char *text) +{ + esp_err_t err = cap_im_wechat_send_text(chat_id, text); + + if (err != ESP_OK) { + printf("wechat_send_text failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("WeChat text sent\n"); + return 0; +} + +static int cmd_wechat_send_image(const char *chat_id, const char *path, const char *caption) +{ + esp_err_t err = cap_im_wechat_send_image(chat_id, path, caption); + + if (err != ESP_OK) { + printf("wechat_send_image failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("WeChat image sent\n"); + return 0; +} + +static int wechat_func(int argc, char **argv) +{ + int nerrors = arg_parse(argc, argv, (void **)&wechat_args); + int operation_count; + + if (nerrors != 0) { + arg_print_errors(stderr, wechat_args.end, argv[0]); + return 1; + } + + operation_count = wechat_args.set_config->count + wechat_args.start->count + + wechat_args.stop->count + wechat_args.send_text->count + + wechat_args.send_image->count; + if (operation_count != 1) { + printf("Exactly one operation must be specified\n"); + return 1; + } + + if (wechat_args.set_config->count) { + return cmd_wechat_set_config(); + } + + if (wechat_args.start->count) { + return cmd_wechat_start(); + } + + if (wechat_args.stop->count) { + return cmd_wechat_stop(); + } + + if (wechat_args.send_text->count) { + if (!wechat_args.text->count) { + printf("'--send-text' requires '--text'\n"); + return 1; + } + + return cmd_wechat_send_text(wechat_args.send_text->sval[0], wechat_args.text->sval[0]); + } + + if (!wechat_args.path->count) { + printf("'--send-image' requires '--path'\n"); + return 1; + } + + return cmd_wechat_send_image(wechat_args.send_image->sval[0], + wechat_args.path->sval[0], + wechat_args.caption->count ? wechat_args.caption->sval[0] : NULL); +} + +void register_cap_im_wechat(void) +{ + wechat_args.set_config = arg_lit0("c", "set-config", "Set WeChat client config"); + wechat_args.start = arg_lit0(NULL, "start", "Start the WeChat gateway"); + wechat_args.stop = arg_lit0(NULL, "stop", "Stop the WeChat gateway"); + wechat_args.send_text = arg_str0(NULL, "send-text", "", "Send text to a WeChat chat"); + wechat_args.send_image = + arg_str0(NULL, "send-image", "", "Send an image to a WeChat chat"); + wechat_args.token = arg_str0(NULL, "token", "", "WeChat token"); + wechat_args.base_url = arg_str0(NULL, "base-url", "", "WeChat base URL"); + wechat_args.cdn_base_url = arg_str0(NULL, "cdn-base-url", "", "WeChat CDN base URL"); + wechat_args.account_id = arg_str0(NULL, "account-id", "", "WeChat account ID"); + wechat_args.app_id = arg_str0(NULL, "app-id", "", "WeChat app ID"); + wechat_args.client_version = + arg_str0(NULL, "client-version", "", "WeChat client version"); + wechat_args.route_tag = arg_str0(NULL, "route-tag", "", "WeChat route tag"); + wechat_args.text = arg_str0("t", "text", "", "Text content"); + wechat_args.path = arg_str0("p", "path", "", "Local file path"); + wechat_args.caption = arg_str0(NULL, "caption", "", "Optional caption"); + wechat_args.end = arg_end(10); + + const esp_console_cmd_t wechat_cmd = { + .command = "wechat", + .help = "WeChat operation.\n" + "Examples:\n" + " wechat --set-config --token abc --base-url https://ilinkai.weixin.qq.com\n" + " wechat --start\n" + " wechat --stop\n" + " wechat --send-text room123 --text \"hello\"\n" + " wechat --send-image room123 --path /spiffs/a.jpg --caption \"hi\"\n", + .func = wechat_func, + .argtable = &wechat_args, + }; + + ESP_ERROR_CHECK(esp_console_cmd_register(&wechat_cmd)); +} diff --git a/components/cap_llm_inspect/CMakeLists.txt b/components/cap_llm_inspect/CMakeLists.txt new file mode 100644 index 0000000..9356d80 --- /dev/null +++ b/components/cap_llm_inspect/CMakeLists.txt @@ -0,0 +1,12 @@ +idf_component_register( + SRCS + "src/cap_llm_inspect.c" + "src/cmd_cap_llm_inspect.c" + INCLUDE_DIRS + "include" + REQUIRES + claw_cap + json + claw_core + console +) diff --git a/components/cap_llm_inspect/include/cap_llm_inspect.h b/components/cap_llm_inspect/include/cap_llm_inspect.h new file mode 100644 index 0000000..c90b3f2 --- /dev/null +++ b/components/cap_llm_inspect/include/cap_llm_inspect.h @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t cap_llm_inspect_register_group(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_llm_inspect/include/cmd_cap_llm_inspect.h b/components/cap_llm_inspect/include/cmd_cap_llm_inspect.h new file mode 100644 index 0000000..422750b --- /dev/null +++ b/components/cap_llm_inspect/include/cmd_cap_llm_inspect.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_llm_inspect(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_llm_inspect/src/cap_llm_inspect.c b/components/cap_llm_inspect/src/cap_llm_inspect.c new file mode 100644 index 0000000..85d6cb6 --- /dev/null +++ b/components/cap_llm_inspect/src/cap_llm_inspect.c @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_llm_inspect.h" + +#include +#include +#include + +#include "cJSON.h" +#include "claw_cap.h" +#include "claw_core_llm.h" + +static const char *CAP_LLM_INSPECT_SYSTEM_PROMPT = + "You analyze local image files for the ESP32 clawgent. " + "Describe visible content plainly and briefly. " + "If the image is unclear, say what is uncertain instead of guessing."; + +static esp_err_t cap_llm_inspect_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + claw_media_asset_t asset = {0}; + claw_llm_media_request_t request = {0}; + cJSON *root = NULL; + cJSON *path_json = NULL; + cJSON *prompt_json = NULL; + char *analysis = NULL; + char *error_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, "Error: input must be a JSON object"); + return ESP_ERR_INVALID_ARG; + } + + path_json = cJSON_GetObjectItem(root, "path"); + prompt_json = cJSON_GetObjectItem(root, "prompt"); + if (!cJSON_IsString(path_json) || !path_json->valuestring[0] || + !cJSON_IsString(prompt_json) || !prompt_json->valuestring[0]) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: path and prompt are required"); + return ESP_ERR_INVALID_ARG; + } + + asset.kind = CLAW_MEDIA_ASSET_KIND_LOCAL_PATH; + asset.path = path_json->valuestring; + request.system_prompt = CAP_LLM_INSPECT_SYSTEM_PROMPT; + request.user_prompt = prompt_json->valuestring; + request.media = &asset; + request.media_count = 1; + err = claw_core_llm_infer_media(&request, &analysis, &error_message); + cJSON_Delete(root); + if (err != ESP_OK) { + snprintf(output, + output_size, + "Error: image analysis failed (%s)%s%s", + esp_err_to_name(err), + error_message ? ": " : "", + error_message ? error_message : ""); + free(error_message); + return err; + } + + snprintf(output, output_size, "%s", analysis ? analysis : ""); + free(analysis); + free(error_message); + return ESP_OK; +} + +static const claw_cap_descriptor_t s_llm_inspect_descriptors[] = { + { + .id = "inspect_image", + .name = "inspect_image", + .family = "system", + .description = + "Analyze a local image from an absolute path. Confirm the path first, then provide a prompt describing what to inspect.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"prompt\":{\"type\":\"string\"}},\"required\":[\"path\",\"prompt\"]}", + .execute = cap_llm_inspect_execute, + }, +}; + +static const claw_cap_group_t s_llm_inspect_group = { + .group_id = "cap_llm_inspect", + .descriptors = s_llm_inspect_descriptors, + .descriptor_count = sizeof(s_llm_inspect_descriptors) / sizeof(s_llm_inspect_descriptors[0]), +}; + +esp_err_t cap_llm_inspect_register_group(void) +{ + if (claw_cap_group_exists(s_llm_inspect_group.group_id)) { + return ESP_OK; + } + + return claw_cap_register_group(&s_llm_inspect_group); +} diff --git a/components/cap_llm_inspect/src/cmd_cap_llm_inspect.c b/components/cap_llm_inspect/src/cmd_cap_llm_inspect.c new file mode 100644 index 0000000..c0a0aa6 --- /dev/null +++ b/components/cap_llm_inspect/src/cmd_cap_llm_inspect.c @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cmd_cap_llm_inspect.h" + +#include +#include + +#include "argtable3/argtable3.h" +#include "cJSON.h" +#include "claw_cap.h" +#include "esp_console.h" + +static struct { + struct arg_str *path; + struct arg_str *prompt; + struct arg_end *end; +} inspect_args; + +static int llm_inspect_func(int argc, char **argv) +{ + cJSON *root = NULL; + char *input_json = NULL; + char *output = NULL; + esp_err_t err; + claw_cap_call_context_t ctx = { + .caller = CLAW_CAP_CALLER_CONSOLE, + }; + int nerrors = arg_parse(argc, argv, (void **)&inspect_args); + + if (nerrors != 0) { + arg_print_errors(stderr, inspect_args.end, argv[0]); + return 1; + } + + if (!inspect_args.path->count || !inspect_args.prompt->count) { + printf("'--path' and '--prompt' are required\n"); + return 1; + } + + root = cJSON_CreateObject(); + if (!root) { + printf("Out of memory\n"); + return 1; + } + + cJSON_AddStringToObject(root, "path", inspect_args.path->sval[0]); + cJSON_AddStringToObject(root, "prompt", inspect_args.prompt->sval[0]); + input_json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!input_json) { + printf("Out of memory\n"); + return 1; + } + + output = calloc(1, 2048); + if (!output) { + free(input_json); + printf("Out of memory\n"); + return 1; + } + + err = claw_cap_call("inspect_image", input_json, &ctx, output, 2048); + if (err != ESP_OK) { + printf("%s\n", output[0] ? output : esp_err_to_name(err)); + } else { + printf("%s\n", output); + } + + free(output); + free(input_json); + return err == ESP_OK ? 0 : 1; +} + +void register_cap_llm_inspect(void) +{ + inspect_args.path = arg_str1("p", "path", "", "Absolute local image path"); + inspect_args.prompt = arg_str1(NULL, "prompt", "", "Inspection prompt"); + inspect_args.end = arg_end(4); + + const esp_console_cmd_t inspect_cmd = { + .command = "llm_inspect", + .help = "Inspect a local image with the configured LLM.\n" + "Example:\n" + " llm_inspect --path /fatfs/data/inbox/pic.jpg --prompt \"Describe the screen contents\"\n", + .func = llm_inspect_func, + .argtable = &inspect_args, + }; + + ESP_ERROR_CHECK(esp_console_cmd_register(&inspect_cmd)); +} diff --git a/components/cap_lua/CMakeLists.txt b/components/cap_lua/CMakeLists.txt new file mode 100644 index 0000000..1cf53d6 --- /dev/null +++ b/components/cap_lua/CMakeLists.txt @@ -0,0 +1,19 @@ +idf_component_register( + SRCS + "src/cap_lua.c" + "src/cap_lua_runtime.c" + "src/cap_lua_async.c" + "src/cap_lua_module_delay.c" + "src/cap_lua_storage.c" + "src/cmd_cap_lua.c" + INCLUDE_DIRS + "include" + "src" + REQUIRES + claw_cap + esp_timer + freertos + georgik__lua + json + console +) diff --git a/components/cap_lua/idf_component.yml b/components/cap_lua/idf_component.yml new file mode 100644 index 0000000..96d277b --- /dev/null +++ b/components/cap_lua/idf_component.yml @@ -0,0 +1,2 @@ +dependencies: + georgik/lua: "^5.5.0~7" diff --git a/components/cap_lua/include/cap_lua.h b/components/cap_lua/include/cap_lua.h new file mode 100644 index 0000000..57e370a --- /dev/null +++ b/components/cap_lua/include/cap_lua.h @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include +#include + +#include "esp_err.h" +#include "lua.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + const char *name; + lua_CFunction open_fn; +} cap_lua_module_t; + +const char *cap_lua_get_base_dir(void); +esp_err_t cap_lua_register_group(void); +esp_err_t cap_lua_set_base_dir(const char *base_dir); +esp_err_t cap_lua_register_module(const char *name, lua_CFunction open_fn); +esp_err_t cap_lua_register_modules(const cap_lua_module_t *modules, size_t count); +esp_err_t cap_lua_list_scripts(const char *prefix, char *output, size_t output_size); +esp_err_t cap_lua_write_script(const char *path, + const char *content, + bool overwrite, + char *output, + size_t output_size); +esp_err_t cap_lua_run_script(const char *path, + const char *args_json, + uint32_t timeout_ms, + char *output, + size_t output_size); +esp_err_t cap_lua_run_script_async(const char *path, + const char *args_json, + uint32_t timeout_ms, + char *output, + size_t output_size); +esp_err_t cap_lua_list_jobs(const char *status, char *output, size_t output_size); +esp_err_t cap_lua_get_job(const char *job_id, char *output, size_t output_size); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_lua/include/cmd_cap_lua.h b/components/cap_lua/include/cmd_cap_lua.h new file mode 100644 index 0000000..64f4c32 --- /dev/null +++ b/components/cap_lua/include/cmd_cap_lua.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_lua(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_lua/src/cap_lua.c b/components/cap_lua/src/cap_lua.c new file mode 100644 index 0000000..67208ea --- /dev/null +++ b/components/cap_lua/src/cap_lua.c @@ -0,0 +1,912 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_lua.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "claw_cap.h" +#include "esp_check.h" +#include "esp_log.h" + +#include "cap_lua_internal.h" + +static const char *TAG = "cap_lua"; + +static char s_lua_base_dir[128] = CAP_LUA_DEFAULT_BASE_DIR; +static cap_lua_module_t s_modules[CAP_LUA_MAX_MODULES]; +static size_t s_module_count; +static bool s_builtin_modules_registered; +static bool s_module_registration_locked; + +static esp_err_t cap_lua_build_simple_request(const char *string_key, + const char *string_value, + const char *string_key2, + const char *string_value2, + bool has_bool, + const char *bool_key, + bool bool_value, + bool has_number, + const char *number_key, + uint32_t number_value, + char **json_out) +{ + cJSON *root = NULL; + + if (!json_out) { + return ESP_ERR_INVALID_ARG; + } + *json_out = NULL; + + root = cJSON_CreateObject(); + if (!root) { + return ESP_ERR_NO_MEM; + } + + if (string_key && string_value && !cJSON_AddStringToObject(root, string_key, string_value)) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + if (string_key2 && string_value2 && + !cJSON_AddStringToObject(root, string_key2, string_value2)) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + if (has_bool && bool_key && !cJSON_AddBoolToObject(root, bool_key, bool_value)) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + if (has_number && number_key && + !cJSON_AddNumberToObject(root, number_key, (double)number_value)) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + + *json_out = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + return *json_out ? ESP_OK : ESP_ERR_NO_MEM; +} + +const char *cap_lua_get_base_dir(void) +{ + return s_lua_base_dir; +} + +bool cap_lua_path_is_valid(const char *path) +{ + size_t base_len; + size_t path_len; + + if (!path) { + return false; + } + + base_len = strlen(s_lua_base_dir); + if (strncmp(path, s_lua_base_dir, base_len) != 0 || path[base_len] != '/') { + return false; + } + if (strstr(path, "..") != NULL) { + return false; + } + + path_len = strlen(path); + return path_len > 4 && strcmp(path + path_len - 4, ".lua") == 0; +} + +esp_err_t cap_lua_resolve_path(const char *path, char *resolved, size_t resolved_size) +{ + int written; + + if (!path || !path[0] || !resolved || resolved_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + if (path[0] == '/') { + if (!cap_lua_path_is_valid(path)) { + return ESP_ERR_INVALID_ARG; + } + strlcpy(resolved, path, resolved_size); + return ESP_OK; + } + + if (strstr(path, "..") != NULL || strchr(path, '/') != NULL) { + return ESP_ERR_INVALID_ARG; + } + + written = snprintf(resolved, resolved_size, "%s/%s", s_lua_base_dir, path); + if (written < 0 || (size_t)written >= resolved_size) { + return ESP_ERR_INVALID_SIZE; + } + if (!cap_lua_path_is_valid(resolved)) { + return ESP_ERR_INVALID_ARG; + } + + return ESP_OK; +} + +esp_err_t cap_lua_ensure_base_dir(void) +{ + if (mkdir(s_lua_base_dir, 0755) != 0 && errno != EEXIST) { + ESP_LOGE(TAG, "Failed to create Lua base dir %s", s_lua_base_dir); + return ESP_FAIL; + } + + return ESP_OK; +} + +static esp_err_t cap_lua_build_args_json(cJSON *root, char **args_json_out) +{ + cJSON *args = NULL; + cJSON *payload = NULL; + + if (!args_json_out) { + return ESP_ERR_INVALID_ARG; + } + *args_json_out = NULL; + + args = cJSON_GetObjectItem(root, "args"); + if (cJSON_IsObject(args) || cJSON_IsArray(args)) { + payload = cJSON_Duplicate(args, 1); + if (!payload) { + return ESP_ERR_NO_MEM; + } + *args_json_out = cJSON_PrintUnformatted(payload); + cJSON_Delete(payload); + if (!*args_json_out) { + return ESP_ERR_NO_MEM; + } + } + + return ESP_OK; +} + +static esp_err_t cap_lua_group_init(void) +{ + ESP_RETURN_ON_ERROR(cap_lua_register_builtin_modules(), + TAG, + "Failed to register builtin Lua modules"); + s_module_registration_locked = true; + ESP_RETURN_ON_ERROR(cap_lua_ensure_base_dir(), TAG, "Failed to create base dir"); + ESP_RETURN_ON_ERROR(cap_lua_runtime_init(), TAG, "Failed to init runtime"); + ESP_RETURN_ON_ERROR(cap_lua_async_init(), TAG, "Failed to init async runner"); + return ESP_OK; +} + +static esp_err_t cap_lua_group_start(void) +{ + return cap_lua_async_start(); +} + +static esp_err_t cap_lua_list_scripts_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *prefix = NULL; + DIR *dir = NULL; + struct dirent *entry = NULL; + size_t offset = 0; + int count = 0; + + (void)ctx; + + if (!output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + output[0] = '\0'; + + root = cJSON_Parse(input_json); + if (root) { + cJSON *prefix_item = cJSON_GetObjectItem(root, "prefix"); + if (cJSON_IsString(prefix_item) && prefix_item->valuestring[0]) { + prefix = prefix_item->valuestring; + } + } + + if (prefix && strncmp(prefix, s_lua_base_dir, strlen(s_lua_base_dir)) != 0) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: prefix must stay under %s", s_lua_base_dir); + return ESP_ERR_INVALID_ARG; + } + + dir = opendir(s_lua_base_dir); + if (!dir) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: cannot open %s", s_lua_base_dir); + return ESP_FAIL; + } + + while ((entry = readdir(dir)) != NULL && offset < output_size - 1) { + char full_path[384]; + + if (entry->d_name[0] == '.') { + continue; + } + + snprintf(full_path, sizeof(full_path), "%s/%s", s_lua_base_dir, entry->d_name); + if (!cap_lua_path_is_valid(full_path)) { + continue; + } + if (prefix && strncmp(full_path, prefix, strlen(prefix)) != 0) { + continue; + } + + offset += snprintf(output + offset, output_size - offset, "%s\n", full_path); + count++; + } + + closedir(dir); + cJSON_Delete(root); + if (count == 0) { + snprintf(output, output_size, "(no Lua scripts found)"); + } + return ESP_OK; +} + +static esp_err_t cap_lua_write_script_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *path = NULL; + const char *content = NULL; + char resolved_path[192]; + cJSON *overwrite_item = NULL; + bool overwrite = true; + struct stat st = {0}; + FILE *file = NULL; + size_t content_len = 0; + + (void)ctx; + + root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON input"); + return ESP_ERR_INVALID_ARG; + } + + path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path")); + content = cJSON_GetStringValue(cJSON_GetObjectItem(root, "content")); + overwrite_item = cJSON_GetObjectItem(root, "overwrite"); + if (cJSON_IsBool(overwrite_item)) { + overwrite = cJSON_IsTrue(overwrite_item); + } + + if (cap_lua_resolve_path(path, resolved_path, sizeof(resolved_path)) != ESP_OK) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: path must be a .lua file under %s", s_lua_base_dir); + return ESP_ERR_INVALID_ARG; + } + if (!content) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: missing content"); + return ESP_ERR_INVALID_ARG; + } + + content_len = strlen(content); + if (content_len > CAP_LUA_MAX_SCRIPT_SIZE) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: script exceeds %d bytes", CAP_LUA_MAX_SCRIPT_SIZE); + return ESP_ERR_INVALID_SIZE; + } + if (!overwrite && stat(resolved_path, &st) == 0) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: script already exists: %s", resolved_path); + return ESP_ERR_INVALID_STATE; + } + + if (cap_lua_ensure_base_dir() != ESP_OK) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: failed to ensure Lua base dir"); + return ESP_FAIL; + } + + file = fopen(resolved_path, "w"); + if (!file) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: cannot open %s for writing", resolved_path); + return ESP_FAIL; + } + if (fwrite(content, 1, content_len, file) != content_len) { + fclose(file); + cJSON_Delete(root); + snprintf(output, output_size, "Error: failed to write %s", resolved_path); + return ESP_FAIL; + } + + fclose(file); + cJSON_Delete(root); + snprintf(output, output_size, "OK: wrote Lua script %s (%d bytes)", resolved_path, (int)content_len); + return ESP_OK; +} + +static esp_err_t cap_lua_run_script_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *path = NULL; + char resolved_path[192]; + cJSON *timeout_item = NULL; + char *args_json = NULL; + uint32_t timeout_ms = 0; + esp_err_t err; + + (void)ctx; + + root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON input"); + return ESP_ERR_INVALID_ARG; + } + + path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path")); + if (cap_lua_resolve_path(path, resolved_path, sizeof(resolved_path)) != ESP_OK) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: path must be a .lua file under %s", s_lua_base_dir); + return ESP_ERR_INVALID_ARG; + } + + timeout_item = cJSON_GetObjectItem(root, "timeout_ms"); + if (timeout_item && (!cJSON_IsNumber(timeout_item) || timeout_item->valueint <= 0)) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: timeout_ms must be a positive integer"); + return ESP_ERR_INVALID_ARG; + } + if (cJSON_IsNumber(timeout_item)) { + timeout_ms = (uint32_t)timeout_item->valueint; + } + + err = cap_lua_build_args_json(root, &args_json); + cJSON_Delete(root); + if (err != ESP_OK) { + free(args_json); + snprintf(output, output_size, "Error: failed to prepare Lua args"); + return err; + } + + err = cap_lua_runtime_execute_file(resolved_path, + args_json, + timeout_ms, + output, + output_size); + free(args_json); + return err; +} + +static esp_err_t cap_lua_run_script_async_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *path = NULL; + char resolved_path[192]; + cJSON *timeout_item = NULL; + char *args_json = NULL; + uint32_t timeout_ms = 0; + cap_lua_async_job_t job = {0}; + char job_id[16] = {0}; + esp_err_t err; + + (void)ctx; + + root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON input"); + return ESP_ERR_INVALID_ARG; + } + + path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path")); + if (cap_lua_resolve_path(path, resolved_path, sizeof(resolved_path)) != ESP_OK) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: path must be a .lua file under %s", s_lua_base_dir); + return ESP_ERR_INVALID_ARG; + } + + timeout_item = cJSON_GetObjectItem(root, "timeout_ms"); + if (timeout_item && (!cJSON_IsNumber(timeout_item) || timeout_item->valueint <= 0)) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: timeout_ms must be a positive integer"); + return ESP_ERR_INVALID_ARG; + } + if (cJSON_IsNumber(timeout_item)) { + timeout_ms = (uint32_t)timeout_item->valueint; + } + + err = cap_lua_build_args_json(root, &args_json); + cJSON_Delete(root); + if (err != ESP_OK) { + free(args_json); + snprintf(output, output_size, "Error: failed to prepare Lua args"); + return err; + } + + strlcpy(job.path, resolved_path, sizeof(job.path)); + job.args_json = args_json; + job.timeout_ms = timeout_ms; + job.created_at = time(NULL); + err = cap_lua_async_submit(&job, job_id, sizeof(job_id)); + free(args_json); + if (err != ESP_OK) { + if (err == ESP_ERR_NO_MEM) { + snprintf(output, output_size, "Error: Lua async concurrency limit reached"); + } else if (err == ESP_ERR_INVALID_STATE) { + snprintf(output, output_size, "Error: Lua async runner is not ready"); + } else { + snprintf(output, output_size, "Error: failed to queue async Lua job (%s)", + esp_err_to_name(err)); + } + return err; + } + + snprintf(output, output_size, "Queued Lua job %s for %s", job_id, path); + return ESP_OK; +} + +static esp_err_t cap_lua_list_async_jobs_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *status = NULL; + esp_err_t err; + + (void)ctx; + + root = cJSON_Parse(input_json); + if (root) { + status = cJSON_GetStringValue(cJSON_GetObjectItem(root, "status")); + if (status && + strcmp(status, "all") != 0 && + strcmp(status, "queued") != 0 && + strcmp(status, "running") != 0 && + strcmp(status, "done") != 0 && + strcmp(status, "failed") != 0 && + strcmp(status, "timeout") != 0) { + cJSON_Delete(root); + snprintf(output, + output_size, + "Error: status must be one of all, queued, running, done, failed, timeout"); + return ESP_ERR_INVALID_ARG; + } + } + + err = cap_lua_async_list_jobs(status, output, output_size); + cJSON_Delete(root); + return err; +} + +static esp_err_t cap_lua_get_async_job_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *job_id = NULL; + esp_err_t err; + + (void)ctx; + + root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON input"); + return ESP_ERR_INVALID_ARG; + } + + job_id = cJSON_GetStringValue(cJSON_GetObjectItem(root, "job_id")); + if (!job_id || !job_id[0]) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: missing job_id"); + return ESP_ERR_INVALID_ARG; + } + + err = cap_lua_async_get_job(job_id, output, output_size); + cJSON_Delete(root); + return err; +} + +static const claw_cap_descriptor_t s_lua_descriptors[] = { + { + .id = "lua_list_scripts", + .name = "lua_list_scripts", + .family = "automation", + .description = "List managed Lua scripts under the configured Lua base directory.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"prefix\":{\"type\":\"string\"}}}", + .execute = cap_lua_list_scripts_execute, + }, + { + .id = "lua_write_script", + .name = "lua_write_script", + .family = "automation", + .description = "Write a managed Lua script under the configured Lua base directory.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"content\":{\"type\":\"string\"},\"overwrite\":{\"type\":\"boolean\"}},\"required\":[\"path\",\"content\"]}", + .execute = cap_lua_write_script_execute, + }, + { + .id = "lua_run_script", + .name = "lua_run_script", + .family = "automation", + .description = "Run a managed Lua script synchronously with optional args and timeout.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"args\":{\"type\":[\"object\",\"array\"]},\"timeout_ms\":{\"type\":\"integer\"}},\"required\":[\"path\"]}", + .execute = cap_lua_run_script_execute, + }, + { + .id = "lua_run_script_async", + .name = "lua_run_script_async", + .family = "automation", + .description = "Run a managed Lua script asynchronously and return a job identifier.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"args\":{\"type\":[\"object\",\"array\"]},\"timeout_ms\":{\"type\":\"integer\"}},\"required\":[\"path\"]}", + .execute = cap_lua_run_script_async_execute, + }, + { + .id = "lua_list_async_jobs", + .name = "lua_list_async_jobs", + .family = "automation", + .description = "List Lua async jobs by optional status filter.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"status\":{\"type\":\"string\"}}}", + .execute = cap_lua_list_async_jobs_execute, + }, + { + .id = "lua_get_async_job", + .name = "lua_get_async_job", + .family = "automation", + .description = "Get the status and summary for a specific Lua async job.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\"}},\"required\":[\"job_id\"]}", + .execute = cap_lua_get_async_job_execute, + }, +}; + +static const claw_cap_group_t s_lua_group = { + .group_id = "cap_lua", + .descriptors = s_lua_descriptors, + .descriptor_count = sizeof(s_lua_descriptors) / sizeof(s_lua_descriptors[0]), + .group_init = cap_lua_group_init, + .group_start = cap_lua_group_start, +}; + +esp_err_t cap_lua_register_group(void) +{ + if (claw_cap_group_exists(s_lua_group.group_id)) { + return ESP_OK; + } + + return claw_cap_register_group(&s_lua_group); +} + +esp_err_t cap_lua_list_scripts(const char *prefix, char *output, size_t output_size) +{ + char *input_json = NULL; + esp_err_t err; + + err = cap_lua_build_simple_request("prefix", + prefix, + NULL, + NULL, + false, + NULL, + false, + false, + NULL, + 0, + &input_json); + if (err != ESP_OK) { + return err; + } + + err = cap_lua_list_scripts_execute(input_json ? input_json : "{}", + NULL, + output, + output_size); + free(input_json); + return err; +} + +esp_err_t cap_lua_write_script(const char *path, + const char *content, + bool overwrite, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + char *input_json = NULL; + esp_err_t err = ESP_OK; + + if (!path || !content) { + return ESP_ERR_INVALID_ARG; + } + + root = cJSON_CreateObject(); + if (!root) { + return ESP_ERR_NO_MEM; + } + if (!cJSON_AddStringToObject(root, "path", path) || + !cJSON_AddStringToObject(root, "content", content) || + !cJSON_AddBoolToObject(root, "overwrite", overwrite)) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + + input_json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!input_json) { + return ESP_ERR_NO_MEM; + } + + err = cap_lua_write_script_execute(input_json, NULL, output, output_size); + free(input_json); + return err; +} + +esp_err_t cap_lua_run_script(const char *path, + const char *args_json, + uint32_t timeout_ms, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + cJSON *args = NULL; + char *input_json = NULL; + esp_err_t err = ESP_OK; + + if (!path) { + return ESP_ERR_INVALID_ARG; + } + + root = cJSON_CreateObject(); + if (!root) { + return ESP_ERR_NO_MEM; + } + if (!cJSON_AddStringToObject(root, "path", path)) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + if (args_json && args_json[0]) { + args = cJSON_Parse(args_json); + if (!args || (!cJSON_IsObject(args) && !cJSON_IsArray(args))) { + cJSON_Delete(args); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + cJSON_AddItemToObject(root, "args", args); + } + if (timeout_ms > 0 && !cJSON_AddNumberToObject(root, "timeout_ms", (double)timeout_ms)) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + + input_json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!input_json) { + return ESP_ERR_NO_MEM; + } + + err = cap_lua_run_script_execute(input_json, NULL, output, output_size); + free(input_json); + return err; +} + +esp_err_t cap_lua_run_script_async(const char *path, + const char *args_json, + uint32_t timeout_ms, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + cJSON *args = NULL; + char *input_json = NULL; + esp_err_t err = ESP_OK; + + if (!path) { + return ESP_ERR_INVALID_ARG; + } + + root = cJSON_CreateObject(); + if (!root) { + return ESP_ERR_NO_MEM; + } + if (!cJSON_AddStringToObject(root, "path", path)) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + if (args_json && args_json[0]) { + args = cJSON_Parse(args_json); + if (!args || (!cJSON_IsObject(args) && !cJSON_IsArray(args))) { + cJSON_Delete(args); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + cJSON_AddItemToObject(root, "args", args); + } + if (timeout_ms > 0 && !cJSON_AddNumberToObject(root, "timeout_ms", (double)timeout_ms)) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + + input_json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!input_json) { + return ESP_ERR_NO_MEM; + } + + err = cap_lua_run_script_async_execute(input_json, NULL, output, output_size); + free(input_json); + return err; +} + +esp_err_t cap_lua_list_jobs(const char *status, char *output, size_t output_size) +{ + char *input_json = NULL; + esp_err_t err; + + err = cap_lua_build_simple_request("status", + status, + NULL, + NULL, + false, + NULL, + false, + false, + NULL, + 0, + &input_json); + if (err != ESP_OK) { + return err; + } + + err = cap_lua_list_async_jobs_execute(input_json ? input_json : "{}", + NULL, + output, + output_size); + free(input_json); + return err; +} + +esp_err_t cap_lua_get_job(const char *job_id, char *output, size_t output_size) +{ + char *input_json = NULL; + esp_err_t err; + + err = cap_lua_build_simple_request("job_id", + job_id, + NULL, + NULL, + false, + NULL, + false, + false, + NULL, + 0, + &input_json); + if (err != ESP_OK) { + return err; + } + + err = cap_lua_get_async_job_execute(input_json ? input_json : "{}", + NULL, + output, + output_size); + free(input_json); + return err; +} + +esp_err_t cap_lua_set_base_dir(const char *base_dir) +{ + if (!base_dir || !base_dir[0]) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(s_lua_base_dir, base_dir, sizeof(s_lua_base_dir)); + return ESP_OK; +} + +esp_err_t cap_lua_register_module(const char *name, lua_CFunction open_fn) +{ + size_t i; + + if (!name || !name[0] || !open_fn) { + return ESP_ERR_INVALID_ARG; + } + if (s_module_registration_locked) { + return ESP_ERR_INVALID_STATE; + } + + for (i = 0; i < s_module_count; i++) { + if (strcmp(s_modules[i].name, name) == 0) { + return ESP_ERR_INVALID_STATE; + } + } + + if (s_module_count >= CAP_LUA_MAX_MODULES) { + return ESP_ERR_NO_MEM; + } + + s_modules[s_module_count].name = name; + s_modules[s_module_count].open_fn = open_fn; + s_module_count++; + return ESP_OK; +} + +esp_err_t cap_lua_register_modules(const cap_lua_module_t *modules, size_t count) +{ + size_t i; + esp_err_t err; + + if (!modules || count == 0) { + return ESP_ERR_INVALID_ARG; + } + + for (i = 0; i < count; i++) { + err = cap_lua_register_module(modules[i].name, modules[i].open_fn); + if (err != ESP_OK) { + return err; + } + } + + return ESP_OK; +} + +esp_err_t cap_lua_register_builtin_modules(void) +{ + static const cap_lua_module_t builtin_modules[] = { + {.name = "delay", .open_fn = luaopen_delay}, + {.name = "storage", .open_fn = luaopen_storage}, + }; + + if (s_builtin_modules_registered) { + return ESP_OK; + } + + ESP_RETURN_ON_ERROR(cap_lua_register_modules(builtin_modules, + sizeof(builtin_modules) / + sizeof(builtin_modules[0])), + TAG, + "Failed to register builtin Lua modules"); + s_builtin_modules_registered = true; + return ESP_OK; +} + +size_t cap_lua_get_module_count(void) +{ + return s_module_count; +} + +const cap_lua_module_t *cap_lua_get_module(size_t index) +{ + if (index >= s_module_count) { + return NULL; + } + + return &s_modules[index]; +} diff --git a/components/cap_lua/src/cap_lua_async.c b/components/cap_lua/src/cap_lua_async.c new file mode 100644 index 0000000..4678f4a --- /dev/null +++ b/components/cap_lua/src/cap_lua_async.c @@ -0,0 +1,387 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_lua_internal.h" + +#include +#include +#include +#include + +#include "esp_log.h" +#include "esp_random.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +static const char *TAG = "cap_lua_async"; + +typedef struct { + bool used; + cap_lua_job_status_t status; + char job_id[9]; + char path[192]; + char *args_json; + uint32_t timeout_ms; + time_t created_at; + time_t started_at; + time_t finished_at; + char summary[CAP_LUA_OUTPUT_SIZE]; + TaskHandle_t task_handle; +} cap_lua_job_record_t; + +typedef struct { + int slot; + char job_id[9]; + char path[192]; + char *args_json; + uint32_t timeout_ms; +} cap_lua_job_ctx_t; + +static SemaphoreHandle_t s_job_lock; +static cap_lua_job_record_t s_jobs[CAP_LUA_ASYNC_MAX_JOBS]; +static size_t s_running_jobs; +static bool s_runner_started; + +static const char *cap_lua_job_status_name(cap_lua_job_status_t status) +{ + switch (status) { + case CAP_LUA_JOB_QUEUED: + return "queued"; + case CAP_LUA_JOB_RUNNING: + return "running"; + case CAP_LUA_JOB_DONE: + return "done"; + case CAP_LUA_JOB_FAILED: + return "failed"; + case CAP_LUA_JOB_TIMEOUT: + return "timeout"; + default: + return "unknown"; + } +} + +static bool cap_lua_job_status_matches(cap_lua_job_status_t status, const char *filter) +{ + if (!filter || !filter[0] || strcmp(filter, "all") == 0) { + return true; + } + + return strcmp(cap_lua_job_status_name(status), filter) == 0; +} + +static void cap_lua_generate_job_id(char *job_id, size_t size) +{ + snprintf(job_id, size, "%08x", (unsigned)esp_random()); +} + +static int cap_lua_find_reusable_slot_locked(void) +{ + int oldest_terminal = -1; + int i; + + for (i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { + if (!s_jobs[i].used) { + return i; + } + if (s_jobs[i].status == CAP_LUA_JOB_DONE || + s_jobs[i].status == CAP_LUA_JOB_FAILED || + s_jobs[i].status == CAP_LUA_JOB_TIMEOUT) { + if (oldest_terminal < 0 || s_jobs[i].finished_at < s_jobs[oldest_terminal].finished_at) { + oldest_terminal = i; + } + } + } + + return oldest_terminal; +} + +static int cap_lua_find_slot_by_id_locked(const char *job_id) +{ + int i; + + for (i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { + if (s_jobs[i].used && strcmp(s_jobs[i].job_id, job_id) == 0) { + return i; + } + } + + return -1; +} + +static void cap_lua_finish_job(cap_lua_job_ctx_t *ctx, + bool ok, + bool timed_out, + const char *summary) +{ + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + return; + } + + if (ctx->slot >= 0 && + ctx->slot < CAP_LUA_ASYNC_MAX_JOBS && + s_jobs[ctx->slot].used && + strcmp(s_jobs[ctx->slot].job_id, ctx->job_id) == 0) { + s_jobs[ctx->slot].status = timed_out ? CAP_LUA_JOB_TIMEOUT : + (ok ? CAP_LUA_JOB_DONE : CAP_LUA_JOB_FAILED); + s_jobs[ctx->slot].finished_at = time(NULL); + s_jobs[ctx->slot].task_handle = NULL; + if (summary && summary[0]) { + strlcpy(s_jobs[ctx->slot].summary, summary, sizeof(s_jobs[ctx->slot].summary)); + } + } + + if (s_running_jobs > 0) { + s_running_jobs--; + } + + xSemaphoreGive(s_job_lock); +} + +static void cap_lua_job_task(void *arg) +{ + cap_lua_job_ctx_t *ctx = (cap_lua_job_ctx_t *)arg; + char *output = NULL; + esp_err_t err; + bool timed_out = false; + + if (!ctx) { + vTaskDelete(NULL); + return; + } + + output = calloc(1, CAP_LUA_OUTPUT_SIZE); + if (!output) { + cap_lua_finish_job(ctx, false, false, "failed to allocate output buffer"); + free(ctx->args_json); + free(ctx); + vTaskDelete(NULL); + return; + } + + err = cap_lua_runtime_execute_file(ctx->path, + ctx->args_json, + ctx->timeout_ms, + output, + CAP_LUA_OUTPUT_SIZE); + if (err != ESP_OK && strstr(output, "execution timed out") != NULL) { + timed_out = true; + } + + cap_lua_finish_job(ctx, err == ESP_OK, timed_out, output); + free(output); + free(ctx->args_json); + free(ctx); + vTaskDelete(NULL); +} + +esp_err_t cap_lua_async_init(void) +{ + if (!s_job_lock) { + s_job_lock = xSemaphoreCreateMutex(); + } + if (!s_job_lock) { + return ESP_ERR_NO_MEM; + } + + memset(s_jobs, 0, sizeof(s_jobs)); + s_running_jobs = 0; + s_runner_started = false; + return ESP_OK; +} + +esp_err_t cap_lua_async_start(void) +{ + if (!s_job_lock) { + return ESP_ERR_INVALID_STATE; + } + + s_runner_started = true; + return ESP_OK; +} + +esp_err_t cap_lua_async_submit(const cap_lua_async_job_t *job, + char *job_id_out, + size_t job_id_out_size) +{ + cap_lua_job_ctx_t *ctx = NULL; + int slot = -1; + time_t now = time(NULL); + + if (!job || !job->path[0]) { + return ESP_ERR_INVALID_ARG; + } + if (!s_job_lock || !s_runner_started) { + return ESP_ERR_INVALID_STATE; + } + + ctx = calloc(1, sizeof(*ctx)); + if (!ctx) { + return ESP_ERR_NO_MEM; + } + + cap_lua_generate_job_id(ctx->job_id, sizeof(ctx->job_id)); + strlcpy(ctx->path, job->path, sizeof(ctx->path)); + ctx->timeout_ms = job->timeout_ms; + if (job->args_json) { + ctx->args_json = strdup(job->args_json); + if (!ctx->args_json) { + free(ctx); + return ESP_ERR_NO_MEM; + } + } + + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + free(ctx->args_json); + free(ctx); + return ESP_ERR_TIMEOUT; + } + + if (s_running_jobs >= CAP_LUA_ASYNC_MAX_CONCURRENT) { + xSemaphoreGive(s_job_lock); + free(ctx->args_json); + free(ctx); + return ESP_ERR_NO_MEM; + } + + slot = cap_lua_find_reusable_slot_locked(); + if (slot < 0) { + xSemaphoreGive(s_job_lock); + free(ctx->args_json); + free(ctx); + return ESP_ERR_NO_MEM; + } + + memset(&s_jobs[slot], 0, sizeof(s_jobs[slot])); + s_jobs[slot].used = true; + s_jobs[slot].status = CAP_LUA_JOB_QUEUED; + s_jobs[slot].created_at = job->created_at ? job->created_at : now; + strlcpy(s_jobs[slot].job_id, ctx->job_id, sizeof(s_jobs[slot].job_id)); + strlcpy(s_jobs[slot].path, ctx->path, sizeof(s_jobs[slot].path)); + if (ctx->args_json) { + s_jobs[slot].args_json = strdup(ctx->args_json); + if (!s_jobs[slot].args_json) { + memset(&s_jobs[slot], 0, sizeof(s_jobs[slot])); + xSemaphoreGive(s_job_lock); + free(ctx->args_json); + free(ctx); + return ESP_ERR_NO_MEM; + } + } + s_jobs[slot].timeout_ms = job->timeout_ms; + ctx->slot = slot; + s_running_jobs++; + xSemaphoreGive(s_job_lock); + + if (xTaskCreate(cap_lua_job_task, + "cap_lua_async", + CAP_LUA_ASYNC_STACK, + ctx, + CAP_LUA_ASYNC_PRIO, + &s_jobs[slot].task_handle) != pdPASS) { + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) == pdTRUE) { + free(s_jobs[slot].args_json); + memset(&s_jobs[slot], 0, sizeof(s_jobs[slot])); + if (s_running_jobs > 0) { + s_running_jobs--; + } + xSemaphoreGive(s_job_lock); + } + free(ctx->args_json); + free(ctx); + return ESP_ERR_NO_MEM; + } + + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) == pdTRUE) { + s_jobs[slot].status = CAP_LUA_JOB_RUNNING; + s_jobs[slot].started_at = time(NULL); + xSemaphoreGive(s_job_lock); + } + + if (job_id_out && job_id_out_size > 0) { + strlcpy(job_id_out, ctx->job_id, job_id_out_size); + } + + ESP_LOGI(TAG, "Queued Lua async job %s for %s", ctx->job_id, ctx->path); + return ESP_OK; +} + +esp_err_t cap_lua_async_list_jobs(const char *status_filter, + char *output, + size_t output_size) +{ + size_t offset = 0; + int i; + int shown = 0; + + if (!output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + output[0] = '\0'; + + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + for (i = 0; i < CAP_LUA_ASYNC_MAX_JOBS && offset < output_size - 1; i++) { + int written; + + if (!s_jobs[i].used || !cap_lua_job_status_matches(s_jobs[i].status, status_filter)) { + continue; + } + + written = snprintf(output + offset, + output_size - offset, + "%s | %s | %s\n", + s_jobs[i].job_id, + cap_lua_job_status_name(s_jobs[i].status), + s_jobs[i].path); + if (written < 0 || (size_t)written >= output_size - offset) { + break; + } + + offset += (size_t)written; + shown++; + } + + xSemaphoreGive(s_job_lock); + if (shown == 0) { + snprintf(output, output_size, "(no Lua async jobs)"); + } + return ESP_OK; +} + +esp_err_t cap_lua_async_get_job(const char *job_id, + char *output, + size_t output_size) +{ + int slot = -1; + + if (!job_id || !job_id[0] || !output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + output[0] = '\0'; + + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + slot = cap_lua_find_slot_by_id_locked(job_id); + if (slot < 0) { + xSemaphoreGive(s_job_lock); + snprintf(output, output_size, "Error: Lua async job not found: %s", job_id); + return ESP_ERR_NOT_FOUND; + } + + snprintf(output, + output_size, + "job_id=%s\nstatus=%s\npath=%s\nsummary=%s", + s_jobs[slot].job_id, + cap_lua_job_status_name(s_jobs[slot].status), + s_jobs[slot].path, + s_jobs[slot].summary[0] ? s_jobs[slot].summary : "(empty)"); + xSemaphoreGive(s_job_lock); + return ESP_OK; +} diff --git a/components/cap_lua/src/cap_lua_internal.h b/components/cap_lua/src/cap_lua_internal.h new file mode 100644 index 0000000..108b98f --- /dev/null +++ b/components/cap_lua/src/cap_lua_internal.h @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include +#include +#include + +#include "cap_lua.h" +#include "esp_err.h" + +#define CAP_LUA_DEFAULT_BASE_DIR "/spiffs/lua" +#define CAP_LUA_MAX_SCRIPT_SIZE (16 * 1024) +#define CAP_LUA_OUTPUT_SIZE (4 * 1024) +#define CAP_LUA_MAX_EXEC_MS 60000 +#define CAP_LUA_ASYNC_MAX_JOBS 16 +#define CAP_LUA_ASYNC_MAX_CONCURRENT 4 +#define CAP_LUA_ASYNC_STACK (16 * 1024) +#define CAP_LUA_ASYNC_PRIO 4 +#define CAP_LUA_MAX_MODULES 16 + +typedef struct { + char path[192]; + char *args_json; + uint32_t timeout_ms; + time_t created_at; +} cap_lua_async_job_t; + +typedef enum { + CAP_LUA_JOB_QUEUED = 0, + CAP_LUA_JOB_RUNNING, + CAP_LUA_JOB_DONE, + CAP_LUA_JOB_FAILED, + CAP_LUA_JOB_TIMEOUT, +} cap_lua_job_status_t; + +const char *cap_lua_get_base_dir(void); +bool cap_lua_path_is_valid(const char *path); +esp_err_t cap_lua_resolve_path(const char *path, char *resolved, size_t resolved_size); +esp_err_t cap_lua_ensure_base_dir(void); + +esp_err_t cap_lua_runtime_init(void); +esp_err_t cap_lua_runtime_execute_file(const char *path, + const char *args_json, + uint32_t timeout_ms, + char *output, + size_t output_size); +esp_err_t cap_lua_register_builtin_modules(void); +size_t cap_lua_get_module_count(void); +const cap_lua_module_t *cap_lua_get_module(size_t index); + +esp_err_t cap_lua_async_init(void); +esp_err_t cap_lua_async_start(void); +esp_err_t cap_lua_async_submit(const cap_lua_async_job_t *job, + char *job_id_out, + size_t job_id_out_size); +esp_err_t cap_lua_async_list_jobs(const char *status_filter, + char *output, + size_t output_size); +esp_err_t cap_lua_async_get_job(const char *job_id, + char *output, + size_t output_size); + +int luaopen_delay(lua_State *L); +int luaopen_storage(lua_State *L); diff --git a/components/cap_lua/src/cap_lua_module_delay.c b/components/cap_lua/src/cap_lua_module_delay.c new file mode 100644 index 0000000..3e057a5 --- /dev/null +++ b/components/cap_lua/src/cap_lua_module_delay.c @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_lua_internal.h" + +#include + +#include "lauxlib.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static int cap_lua_delay_ms(lua_State *L) +{ + lua_Integer ms = luaL_checkinteger(L, 1); + + if (ms < 0) { + ms = 0; + } + + vTaskDelay(pdMS_TO_TICKS((uint32_t)ms)); + return 0; +} + +int luaopen_delay(lua_State *L) +{ + lua_newtable(L); + lua_pushcfunction(L, cap_lua_delay_ms); + lua_setfield(L, -2, "delay_ms"); + return 1; +} diff --git a/components/cap_lua/src/cap_lua_runtime.c b/components/cap_lua/src/cap_lua_runtime.c new file mode 100644 index 0000000..aa78d98 --- /dev/null +++ b/components/cap_lua/src/cap_lua_runtime.c @@ -0,0 +1,256 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_lua_internal.h" + +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "lauxlib.h" +#include "lualib.h" + +static const char *TAG = "cap_lua_rt"; + +typedef struct { + char *buf; + size_t size; + size_t len; + bool truncated; + int64_t deadline_us; +} cap_lua_exec_ctx_t; + +static void cap_lua_output_append(cap_lua_exec_ctx_t *ctx, + const char *text, + size_t len) +{ + size_t room; + size_t copy; + + if (!ctx || !ctx->buf || ctx->size == 0 || !text || len == 0) { + return; + } + + if (ctx->len >= ctx->size - 1) { + ctx->truncated = true; + return; + } + + room = ctx->size - 1 - ctx->len; + copy = len < room ? len : room; + memcpy(ctx->buf + ctx->len, text, copy); + ctx->len += copy; + ctx->buf[ctx->len] = '\0'; + if (copy < len) { + ctx->truncated = true; + } +} + +static void cap_lua_push_json_value(lua_State *L, const cJSON *item) +{ + cJSON *child = NULL; + int index = 1; + + if (!item || cJSON_IsNull(item)) { + lua_pushnil(L); + return; + } + if (cJSON_IsBool(item)) { + lua_pushboolean(L, cJSON_IsTrue(item)); + return; + } + if (cJSON_IsNumber(item)) { + lua_pushnumber(L, item->valuedouble); + return; + } + if (cJSON_IsString(item)) { + lua_pushstring(L, item->valuestring); + return; + } + if (cJSON_IsArray(item)) { + lua_newtable(L); + cJSON_ArrayForEach(child, item) { + cap_lua_push_json_value(L, child); + lua_rawseti(L, -2, index++); + } + return; + } + if (cJSON_IsObject(item)) { + lua_newtable(L); + cJSON_ArrayForEach(child, item) { + cap_lua_push_json_value(L, child); + lua_setfield(L, -2, child->string); + } + return; + } + + lua_pushnil(L); +} + +static int cap_lua_print_capture(lua_State *L) +{ + cap_lua_exec_ctx_t *ctx = (cap_lua_exec_ctx_t *)lua_touserdata( + L, lua_upvalueindex(1)); + int top = lua_gettop(L); + int i; + + for (i = 1; i <= top; i++) { + size_t len = 0; + const char *text = luaL_tolstring(L, i, &len); + + if (i > 1) { + cap_lua_output_append(ctx, "\t", 1); + } + cap_lua_output_append(ctx, text, len); + lua_pop(L, 1); + } + + cap_lua_output_append(ctx, "\n", 1); + return 0; +} + +static void cap_lua_timeout_hook(lua_State *L, lua_Debug *ar) +{ + cap_lua_exec_ctx_t *ctx = NULL; + + (void)ar; + + lua_getglobal(L, "__cap_lua_exec_ctx"); + ctx = (cap_lua_exec_ctx_t *)lua_touserdata(L, -1); + lua_pop(L, 1); + if (!ctx) { + return; + } + + if (esp_timer_get_time() > ctx->deadline_us) { + luaL_error(L, "execution timed out"); + } +} + +static void cap_lua_load_registered_modules(lua_State *L) +{ + size_t i; + + for (i = 0; i < cap_lua_get_module_count(); i++) { + const cap_lua_module_t *module = cap_lua_get_module(i); + + if (!module || !module->name || !module->open_fn) { + continue; + } + + luaL_requiref(L, module->name, module->open_fn, 1); + lua_pop(L, 1); + } +} + +static void cap_lua_set_args_global(lua_State *L, const char *args_json) +{ + cJSON *root = NULL; + + if (args_json && args_json[0]) { + root = cJSON_Parse(args_json); + } + + if (root) { + cap_lua_push_json_value(L, root); + cJSON_Delete(root); + } else { + lua_newtable(L); + } + + lua_setglobal(L, "args"); +} + +esp_err_t cap_lua_runtime_init(void) +{ + ESP_LOGI(TAG, + "Lua runtime ready: scripts=%s registered_modules=%u", + cap_lua_get_base_dir(), + (unsigned int)cap_lua_get_module_count()); + return ESP_OK; +} + +esp_err_t cap_lua_runtime_execute_file(const char *path, + const char *args_json, + uint32_t timeout_ms, + char *output, + size_t output_size) +{ + struct stat st = {0}; + lua_State *L = NULL; + uint32_t effective_timeout_ms = timeout_ms ? timeout_ms : CAP_LUA_MAX_EXEC_MS; + cap_lua_exec_ctx_t ctx = { + .buf = output, + .size = output_size, + .deadline_us = esp_timer_get_time() + ((int64_t)effective_timeout_ms * 1000), + }; + int status; + + if (!output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + output[0] = '\0'; + + if (!cap_lua_path_is_valid(path)) { + snprintf(output, + output_size, + "Error: Lua path must be under %s and end with .lua", + cap_lua_get_base_dir()); + return ESP_ERR_INVALID_ARG; + } + + if (stat(path, &st) != 0) { + snprintf(output, output_size, "Error: Lua script not found: %s", path); + return ESP_ERR_NOT_FOUND; + } + if (st.st_size <= 0 || st.st_size > CAP_LUA_MAX_SCRIPT_SIZE) { + snprintf(output, output_size, "Error: Lua script size invalid: %ld bytes", (long)st.st_size); + return ESP_ERR_INVALID_SIZE; + } + + L = luaL_newstate(); + if (!L) { + snprintf(output, output_size, "Error: failed to create Lua state"); + return ESP_ERR_NO_MEM; + } + + luaL_openlibs(L); + cap_lua_load_registered_modules(L); + lua_pushlightuserdata(L, &ctx); + lua_setglobal(L, "__cap_lua_exec_ctx"); + cap_lua_set_args_global(L, args_json); + lua_pushlightuserdata(L, &ctx); + lua_pushcclosure(L, cap_lua_print_capture, 1); + lua_setglobal(L, "print"); + lua_sethook(L, cap_lua_timeout_hook, LUA_MASKCOUNT, 1000); + + status = luaL_dofile(L, path); + if (status != LUA_OK) { + const char *msg = lua_tostring(L, -1); + if (ctx.len > 0) { + cap_lua_output_append(&ctx, "ERROR: ", 7); + } + cap_lua_output_append(&ctx, + msg ? msg : "unknown Lua error", + strlen(msg ? msg : "unknown Lua error")); + cap_lua_output_append(&ctx, "\n", 1); + lua_close(L); + return ESP_FAIL; + } + + if (ctx.len == 0) { + cap_lua_output_append(&ctx, "Lua script completed with no output.\n", 36); + } else if (ctx.truncated) { + cap_lua_output_append(&ctx, "[output truncated]\n", 19); + } + + lua_close(L); + return ESP_OK; +} diff --git a/components/cap_lua/src/cap_lua_storage.c b/components/cap_lua/src/cap_lua_storage.c new file mode 100644 index 0000000..ccb4e11 --- /dev/null +++ b/components/cap_lua/src/cap_lua_storage.c @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_lua_internal.h" + +#include +#include +#include +#include +#include + +#include "lauxlib.h" + +static int cap_lua_storage_mkdir(lua_State *L) +{ + const char *path = luaL_checkstring(L, 1); + + if (mkdir(path, 0755) != 0 && errno != EEXIST) { + return luaL_error(L, "mkdir failed for %s", path); + } + + lua_pushboolean(L, 1); + return 1; +} + +static int cap_lua_storage_write_file(lua_State *L) +{ + const char *path = luaL_checkstring(L, 1); + size_t content_len = 0; + const char *content = luaL_checklstring(L, 2, &content_len); + FILE *file = NULL; + + file = fopen(path, "w"); + if (!file) { + return luaL_error(L, "cannot open file for writing: %s", path); + } + + if (fwrite(content, 1, content_len, file) != content_len) { + fclose(file); + return luaL_error(L, "short write to %s", path); + } + + fclose(file); + lua_pushboolean(L, 1); + return 1; +} + +static int cap_lua_storage_read_file(lua_State *L) +{ + const char *path = luaL_checkstring(L, 1); + FILE *file = NULL; + long size = 0; + char *buf = NULL; + + file = fopen(path, "rb"); + if (!file) { + return luaL_error(L, "cannot open file for reading: %s", path); + } + + if (fseek(file, 0, SEEK_END) != 0) { + fclose(file); + return luaL_error(L, "seek failed for %s", path); + } + + size = ftell(file); + if (size < 0) { + fclose(file); + return luaL_error(L, "tell failed for %s", path); + } + + if (fseek(file, 0, SEEK_SET) != 0) { + fclose(file); + return luaL_error(L, "seek failed for %s", path); + } + + buf = calloc(1, (size_t)size + 1); + if (!buf) { + fclose(file); + return luaL_error(L, "failed to allocate read buffer"); + } + + if (size > 0 && fread(buf, 1, (size_t)size, file) != (size_t)size) { + free(buf); + fclose(file); + return luaL_error(L, "read failed for %s", path); + } + + fclose(file); + lua_pushlstring(L, buf, (size_t)size); + free(buf); + return 1; +} + +int luaopen_storage(lua_State *L) +{ + lua_newtable(L); + lua_pushcfunction(L, cap_lua_storage_mkdir); + lua_setfield(L, -2, "mkdir"); + lua_pushcfunction(L, cap_lua_storage_write_file); + lua_setfield(L, -2, "write_file"); + lua_pushcfunction(L, cap_lua_storage_read_file); + lua_setfield(L, -2, "read_file"); + return 1; +} diff --git a/components/cap_lua/src/cmd_cap_lua.c b/components/cap_lua/src/cmd_cap_lua.c new file mode 100644 index 0000000..fab6a1d --- /dev/null +++ b/components/cap_lua/src/cmd_cap_lua.c @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cmd_cap_lua.h" + +#include +#include + +#include "argtable3/argtable3.h" +#include "cap_lua.h" +#include "esp_console.h" + +static struct { + struct arg_lit *show_base_dir; + struct arg_lit *list; + struct arg_lit *write; + struct arg_lit *run; + struct arg_lit *run_async; + struct arg_lit *jobs; + struct arg_str *job; + struct arg_str *path; + struct arg_str *content; + struct arg_str *prefix; + struct arg_str *args_json; + struct arg_str *status; + struct arg_int *timeout_ms; + struct arg_lit *no_overwrite; + struct arg_end *end; +} lua_args; + +static void print_lua_result(const char *result) +{ + if (result && result[0]) { + printf("%s\n", result); + } +} + +static int lua_func(int argc, char **argv) +{ + int nerrors = arg_parse(argc, argv, (void **)&lua_args); + int operation_count; + char *result = NULL; + esp_err_t err = ESP_OK; + uint32_t timeout_ms = 0; + + if (nerrors != 0) { + arg_print_errors(stderr, lua_args.end, argv[0]); + return 1; + } + + operation_count = lua_args.show_base_dir->count + lua_args.list->count + lua_args.write->count + + lua_args.run->count + lua_args.run_async->count + lua_args.jobs->count + + lua_args.job->count; + if (operation_count != 1) { + printf("Exactly one operation must be specified\n"); + return 1; + } + + result = calloc(1, 4096); + if (!result) { + printf("failed to allocate output buffer\n"); + return 1; + } + + if (lua_args.timeout_ms->count) { + if (lua_args.timeout_ms->ival[0] <= 0) { + printf("'--timeout-ms' must be a positive integer\n"); + free(result); + return 1; + } + timeout_ms = (uint32_t)lua_args.timeout_ms->ival[0]; + } + + if (lua_args.show_base_dir->count) { + printf("%s\n", cap_lua_get_base_dir()); + free(result); + return 0; + } + + if (lua_args.list->count) { + err = cap_lua_list_scripts(lua_args.prefix->count ? lua_args.prefix->sval[0] : NULL, + result, + 4096); + } else if (lua_args.write->count) { + if (!lua_args.path->count || !lua_args.content->count) { + printf("'--write' requires '--path' and '--content'\n"); + free(result); + return 1; + } + err = cap_lua_write_script(lua_args.path->sval[0], + lua_args.content->sval[0], + lua_args.no_overwrite->count == 0, + result, + 4096); + } else if (lua_args.run->count) { + if (!lua_args.path->count) { + printf("'--run' requires '--path'\n"); + free(result); + return 1; + } + err = cap_lua_run_script(lua_args.path->sval[0], + lua_args.args_json->count ? lua_args.args_json->sval[0] : NULL, + timeout_ms, + result, + 4096); + } else if (lua_args.run_async->count) { + if (!lua_args.path->count) { + printf("'--run-async' requires '--path'\n"); + free(result); + return 1; + } + err = cap_lua_run_script_async(lua_args.path->sval[0], + lua_args.args_json->count ? lua_args.args_json->sval[0] : NULL, + timeout_ms, + result, + 4096); + } else if (lua_args.jobs->count) { + err = cap_lua_list_jobs(lua_args.status->count ? lua_args.status->sval[0] : NULL, + result, + 4096); + } else { + err = cap_lua_get_job(lua_args.job->sval[0], result, 4096); + } + + if (err != ESP_OK) { + print_lua_result(result); + printf("lua command failed: %s\n", esp_err_to_name(err)); + free(result); + return 1; + } + + print_lua_result(result); + free(result); + return 0; +} + +void register_cap_lua(void) +{ + lua_args.show_base_dir = arg_lit0(NULL, "base-dir", "Print the configured Lua base directory"); + lua_args.list = arg_lit0("l", "list", "List managed Lua scripts"); + lua_args.write = arg_lit0("w", "write", "Write a managed Lua script"); + lua_args.run = arg_lit0("r", "run", "Run a managed Lua script synchronously"); + lua_args.run_async = arg_lit0(NULL, "run-async", "Run a managed Lua script asynchronously"); + lua_args.jobs = arg_lit0(NULL, "jobs", "List async Lua jobs"); + lua_args.job = arg_str0(NULL, "job", "", "Show one async Lua job"); + lua_args.path = arg_str0("p", "path", "", "Lua file path relative to base dir or absolute"); + lua_args.content = arg_str0("c", "content", "", "Lua script content for write"); + lua_args.prefix = arg_str0(NULL, "prefix", "", "Optional absolute prefix filter for list"); + lua_args.args_json = arg_str0(NULL, "args-json", "", "JSON object/array passed to the script"); + lua_args.status = arg_str0(NULL, "status", "", "Job status filter: all|queued|running|done|failed|timeout"); + lua_args.timeout_ms = arg_int0("t", "timeout-ms", "", "Execution timeout in milliseconds"); + lua_args.no_overwrite = arg_lit0(NULL, "no-overwrite", "Fail when the script already exists"); + lua_args.end = arg_end(10); + + const esp_console_cmd_t lua_cmd = { + .command = "lua", + .help = "Lua script operations.\n" + "Examples:\n" + " lua --base-dir\n" + " lua --list\n" + " lua --write --path blink.lua --content \"print('hi')\"\n" + " lua --run --path blink.lua --args-json '{\"pin\":2}' --timeout-ms 3000\n" + " lua --run-async --path blink.lua\n" + " lua --jobs --status running\n" + " lua --job abcdef12\n", + .func = lua_func, + .argtable = &lua_args, + }; + + ESP_ERROR_CHECK(esp_console_cmd_register(&lua_cmd)); +} diff --git a/components/cap_mcp_client/CMakeLists.txt b/components/cap_mcp_client/CMakeLists.txt new file mode 100644 index 0000000..0b9ef7c --- /dev/null +++ b/components/cap_mcp_client/CMakeLists.txt @@ -0,0 +1,16 @@ +idf_component_register( + SRCS + "src/cap_mcp_client.c" + "src/cap_mcp_client_core.c" + "src/cap_mcp_discover_core.c" + "src/cmd_cap_mcp_client.c" + INCLUDE_DIRS + "include" + "src" + REQUIRES + claw_cap + esp_http_client + json + mdns + console +) diff --git a/components/cap_mcp_client/idf_component.yml b/components/cap_mcp_client/idf_component.yml new file mode 100644 index 0000000..45c52a4 --- /dev/null +++ b/components/cap_mcp_client/idf_component.yml @@ -0,0 +1,2 @@ +dependencies: + espressif/mdns: "^1.10.1" diff --git a/components/cap_mcp_client/include/cap_mcp_client.h b/components/cap_mcp_client/include/cap_mcp_client.h new file mode 100644 index 0000000..750cf56 --- /dev/null +++ b/components/cap_mcp_client/include/cap_mcp_client.h @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t cap_mcp_client_register_group(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_mcp_client/include/cmd_cap_mcp_client.h b/components/cap_mcp_client/include/cmd_cap_mcp_client.h new file mode 100644 index 0000000..327c932 --- /dev/null +++ b/components/cap_mcp_client/include/cmd_cap_mcp_client.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_mcp_client(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_mcp_client/src/cap_mcp_client.c b/components/cap_mcp_client/src/cap_mcp_client.c new file mode 100644 index 0000000..1eeafe4 --- /dev/null +++ b/components/cap_mcp_client/src/cap_mcp_client.c @@ -0,0 +1,284 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_mcp_client.h" + +#include + +#include "cJSON.h" +#include "claw_cap.h" +#include "mdns.h" + +#include "cap_mcp_client_internal.h" + +static esp_err_t cap_mcp_client_group_init(void) +{ + esp_err_t err = mdns_init(); + + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + return err; + } + + mdns_hostname_set("clawgent"); + mdns_instance_name_set("Clawgent"); + return ESP_OK; +} + +static void cap_mcp_extract_content_text(const cJSON *content, + char *output, + size_t output_size) +{ + const cJSON *item = NULL; + size_t offset = 0; + + if (!cJSON_IsArray(content) || output_size == 0) { + if (output_size > 0) { + output[0] = '\0'; + } + return; + } + + cJSON_ArrayForEach(item, content) { + cJSON *type = cJSON_GetObjectItem(item, "type"); + + if (!cJSON_IsString(type)) { + continue; + } + + if (strcmp(type->valuestring, "text") == 0) { + cJSON *text = cJSON_GetObjectItem(item, "text"); + if (cJSON_IsString(text) && text->valuestring) { + size_t len = strlen(text->valuestring); + size_t room = output_size - 1 - offset; + + if (room > 0) { + if (len > room) { + len = room; + } + memcpy(output + offset, text->valuestring, len); + offset += len; + } + } + } + + if (offset >= output_size - 1) { + break; + } + } + + output[offset] = '\0'; +} + +static esp_err_t cap_mcp_call_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *result = NULL; + const char *error_message = NULL; + cJSON *is_error = NULL; + esp_err_t err; + + (void)ctx; + + err = cap_mcp_call_remote_tool(input_json, &result); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: MCP request failed (%s)", esp_err_to_name(err)); + return err; + } + + error_message = cJSON_GetStringValue(cJSON_GetObjectItem(result, "error_message")); + if (error_message && error_message[0]) { + snprintf(output, output_size, "Error: %s", error_message); + cJSON_Delete(result); + return ESP_OK; + } + + cap_mcp_extract_content_text(cJSON_GetObjectItem(result, "content"), output, output_size); + if (output[0] == '\0') { + is_error = cJSON_GetObjectItem(result, "isError"); + if (cJSON_IsBool(is_error) && cJSON_IsTrue(is_error)) { + snprintf(output, output_size, "Error: Tool returned application error"); + } else { + snprintf(output, output_size, "(empty)"); + } + } + + cJSON_Delete(result); + return ESP_OK; +} + +static esp_err_t cap_mcp_list_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *result = NULL; + const char *error_message = NULL; + cJSON *tools_array = NULL; + cJSON *tool = NULL; + size_t offset = 0; + esp_err_t err; + + (void)ctx; + + err = cap_mcp_list_remote_tools(input_json, &result); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: MCP request failed (%s)", esp_err_to_name(err)); + return err; + } + + error_message = cJSON_GetStringValue(cJSON_GetObjectItem(result, "error_message")); + if (error_message && error_message[0]) { + snprintf(output, output_size, "Error: %s", error_message); + cJSON_Delete(result); + return ESP_OK; + } + + tools_array = cJSON_GetObjectItem(result, "tools"); + if (cJSON_IsArray(tools_array)) { + cJSON_ArrayForEach(tool, tools_array) { + const char *name = cJSON_GetStringValue(cJSON_GetObjectItem(tool, "name")); + const char *description = cJSON_GetStringValue(cJSON_GetObjectItem(tool, "description")); + int written = snprintf(output + offset, + output_size - offset, + "- %s: %s\n", + name ? name : "(no name)", + description ? description : ""); + + if (written < 0 || (size_t)written >= output_size - offset) { + offset = output_size - 1; + break; + } + offset += (size_t)written; + } + } + + cJSON *next_cursor = cJSON_GetObjectItem(result, "nextCursor"); + if (cJSON_IsString(next_cursor) && next_cursor->valuestring[0] && offset < output_size - 1) { + offset += snprintf(output + offset, + output_size - offset, + "\n(nextCursor: %s)", + next_cursor->valuestring); + } + if (offset == 0) { + snprintf(output, output_size, "(no tools)"); + } else if (offset >= output_size) { + output[output_size - 1] = '\0'; + } + + cJSON_Delete(result); + return ESP_OK; +} + +static esp_err_t cap_mcp_discover_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + cJSON *devices = NULL; + cJSON *device = NULL; + size_t offset = 0; + size_t found = 0; + esp_err_t err; + + (void)ctx; + + err = cap_mcp_discover_services(input_json, &root); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: mDNS MCP discovery failed (%s)", esp_err_to_name(err)); + return err; + } + + devices = cJSON_GetObjectItem(root, "devices"); + if (cJSON_IsArray(devices)) { + cJSON_ArrayForEach(device, devices) { + const char *instance = cJSON_GetStringValue(cJSON_GetObjectItem(device, "instance")); + const char *hostname = cJSON_GetStringValue(cJSON_GetObjectItem(device, "hostname")); + const char *ip = cJSON_GetStringValue(cJSON_GetObjectItem(device, "ip")); + const char *endpoint = cJSON_GetStringValue(cJSON_GetObjectItem(device, "endpoint")); + const char *url = cJSON_GetStringValue(cJSON_GetObjectItem(device, "url")); + cJSON *port = cJSON_GetObjectItem(device, "port"); + int written = snprintf(output + offset, + output_size - offset, + "instance=%s\nhostname=%s\nip=%s\nport=%u\nendpoint=%s\nurl=%s\n\n", + instance ? instance : "(unknown)", + hostname ? hostname : "(unknown)", + ip ? ip : "(unresolved)", + cJSON_IsNumber(port) ? (unsigned)port->valueint : 0, + endpoint ? endpoint : CAP_MCP_DEFAULT_ENDPOINT, + url ? url : "(unknown)"); + + if (written < 0 || (size_t)written >= output_size - offset) { + offset = output_size - 1; + break; + } + offset += (size_t)written; + found++; + } + } + + cJSON_Delete(root); + if (found == 0) { + snprintf(output, output_size, "(no mcp servers discovered)"); + } else if (offset >= 2) { + output[offset - 1] = '\0'; + } + + return ESP_OK; +} + +static const claw_cap_descriptor_t s_mcp_client_descriptors[] = { + { + .id = "mcp_list_tools", + .name = "mcp_list_tools", + .family = "mcp", + .description = "List tools from a remote MCP server.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"server_url\":{\"type\":\"string\"},\"endpoint\":{\"type\":\"string\"},\"cursor\":{\"type\":\"string\"}},\"required\":[\"server_url\"]}", + .execute = cap_mcp_list_execute, + }, + { + .id = "mcp_call_tool", + .name = "mcp_call_tool", + .family = "mcp", + .description = "Call a tool on a remote MCP server.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"server_url\":{\"type\":\"string\"},\"endpoint\":{\"type\":\"string\"},\"tool_name\":{\"type\":\"string\"},\"arguments\":{\"type\":\"object\"}},\"required\":[\"server_url\",\"tool_name\"]}", + .execute = cap_mcp_call_execute, + }, + { + .id = "mcp_discover", + .name = "mcp_discover", + .family = "mcp", + .description = "Discover MCP servers advertised on the local network.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"timeout_ms\":{\"type\":\"integer\"},\"include_self\":{\"type\":\"boolean\"}}}", + .execute = cap_mcp_discover_execute, + }, +}; + +static const claw_cap_group_t s_mcp_client_group = { + .group_id = "cap_mcp_client", + .descriptors = s_mcp_client_descriptors, + .descriptor_count = sizeof(s_mcp_client_descriptors) / sizeof(s_mcp_client_descriptors[0]), + .group_init = cap_mcp_client_group_init, +}; + +esp_err_t cap_mcp_client_register_group(void) +{ + if (claw_cap_group_exists(s_mcp_client_group.group_id)) { + return ESP_OK; + } + + return claw_cap_register_group(&s_mcp_client_group); +} diff --git a/components/cap_mcp_client/src/cap_mcp_client_core.c b/components/cap_mcp_client/src/cap_mcp_client_core.c new file mode 100644 index 0000000..d3ffaf1 --- /dev/null +++ b/components/cap_mcp_client/src/cap_mcp_client_core.c @@ -0,0 +1,436 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_mcp_client_internal.h" + +#include +#include + +#include "esp_crt_bundle.h" +#include "esp_http_client.h" +#include "esp_log.h" + +static const char *TAG = "mcp_client_core"; + +#define CAP_MCP_REQUEST_ID_CALL 1 +#define CAP_MCP_REQUEST_ID_LIST 2 +#define CAP_MCP_RESPONSE_BUF_SIZE (8 * 1024) +#define CAP_MCP_HTTP_TIMEOUT_MS 20000 + +typedef struct { + char *data; + size_t len; + size_t cap; +} cap_mcp_buf_t; + +static esp_err_t cap_mcp_http_event_handler(esp_http_client_event_t *event) +{ + cap_mcp_buf_t *buf = (cap_mcp_buf_t *)event->user_data; + size_t needed; + + if (!buf || event->event_id != HTTP_EVENT_ON_DATA) { + return ESP_OK; + } + + needed = buf->len + event->data_len; + if (needed < buf->cap) { + memcpy(buf->data + buf->len, event->data, event->data_len); + buf->len += event->data_len; + buf->data[buf->len] = '\0'; + } + return ESP_OK; +} + +static void cap_mcp_build_full_url(const char *server_url, + const char *endpoint, + char *full_url, + size_t full_url_size) +{ + size_t length = strnlen(server_url, 256); + + if (length == 0 || length >= full_url_size) { + full_url[0] = '\0'; + return; + } + + while (length > 0 && server_url[length - 1] == '/') { + length--; + } + memcpy(full_url, server_url, length); + full_url[length] = '\0'; + + if (endpoint && endpoint[0] != '\0') { + const char *trimmed = endpoint[0] == '/' ? endpoint + 1 : endpoint; + + if (*trimmed) { + snprintf(full_url + length, full_url_size - length, "/%s", trimmed); + } + } +} + +static esp_err_t cap_mcp_http_post(const char *url, + const char *body, + cap_mcp_buf_t *buf) +{ + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_POST, + .event_handler = cap_mcp_http_event_handler, + .user_data = buf, + .timeout_ms = CAP_MCP_HTTP_TIMEOUT_MS, + .buffer_size = 2048, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + esp_http_client_handle_t client = NULL; + esp_err_t err; + int status; + + client = esp_http_client_init(&config); + if (!client) { + return ESP_ERR_NO_MEM; + } + + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_http_client_set_header(client, "Accept", "application/json"); + esp_http_client_set_post_field(client, body, (int)strlen(body)); + err = esp_http_client_perform(client); + status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + + if (err != ESP_OK) { + return err; + } + if (status != 200) { + ESP_LOGW(TAG, "HTTP status %d", status); + return ESP_ERR_HTTP_CONNECT; + } + return ESP_OK; +} + +static esp_err_t cap_mcp_parse_common_input(const char *input_json, + char *server_url_buf, + size_t server_url_buf_size, + char *endpoint_buf, + size_t endpoint_buf_size, + char *cursor_buf, + size_t cursor_buf_size, + char *tool_name_buf, + size_t tool_name_buf_size, + cJSON **arguments_out) +{ + cJSON *input = cJSON_Parse(input_json); + + if (!input || !cJSON_IsObject(input)) { + cJSON_Delete(input); + return ESP_ERR_INVALID_ARG; + } + + cJSON *server_url_item = cJSON_GetObjectItem(input, "server_url"); + if (!cJSON_IsString(server_url_item) || !server_url_item->valuestring[0]) { + cJSON_Delete(input); + return ESP_ERR_INVALID_ARG; + } + strlcpy(server_url_buf, server_url_item->valuestring, server_url_buf_size); + + if (endpoint_buf && endpoint_buf_size > 0) { + const char *endpoint = CAP_MCP_DEFAULT_ENDPOINT; + cJSON *endpoint_item = cJSON_GetObjectItem(input, "endpoint"); + if (cJSON_IsString(endpoint_item) && endpoint_item->valuestring[0]) { + endpoint = endpoint_item->valuestring; + } + strlcpy(endpoint_buf, endpoint, endpoint_buf_size); + } + + if (cursor_buf && cursor_buf_size > 0) { + cJSON *cursor_item = cJSON_GetObjectItem(input, "cursor"); + + cursor_buf[0] = '\0'; + if (cJSON_IsString(cursor_item) && cursor_item->valuestring[0]) { + strlcpy(cursor_buf, cursor_item->valuestring, cursor_buf_size); + } + } + + if (tool_name_buf && tool_name_buf_size > 0) { + cJSON *tool_name_item = cJSON_GetObjectItem(input, "tool_name"); + if (!cJSON_IsString(tool_name_item) || !tool_name_item->valuestring[0]) { + cJSON_Delete(input); + return ESP_ERR_INVALID_ARG; + } + strlcpy(tool_name_buf, tool_name_item->valuestring, tool_name_buf_size); + } + + if (arguments_out) { + cJSON *arguments = cJSON_GetObjectItem(input, "arguments"); + + if (!arguments || !cJSON_IsObject(arguments)) { + *arguments_out = cJSON_CreateObject(); + } else { + *arguments_out = cJSON_Duplicate(arguments, 1); + } + + if (!*arguments_out) { + cJSON_Delete(input); + return ESP_ERR_NO_MEM; + } + } + + cJSON_Delete(input); + return ESP_OK; +} + +static esp_err_t cap_mcp_execute_json_rpc(const char *full_url, + cJSON *request, + cJSON **response_out) +{ + cap_mcp_buf_t response_buf = {0}; + char *body = NULL; + esp_err_t err; + + *response_out = NULL; + body = cJSON_PrintUnformatted(request); + if (!body) { + return ESP_FAIL; + } + + response_buf.data = malloc(CAP_MCP_RESPONSE_BUF_SIZE); + if (!response_buf.data) { + free(body); + return ESP_ERR_NO_MEM; + } + response_buf.cap = CAP_MCP_RESPONSE_BUF_SIZE; + response_buf.data[0] = '\0'; + + err = cap_mcp_http_post(full_url, body, &response_buf); + free(body); + if (err != ESP_OK) { + free(response_buf.data); + return err; + } + + *response_out = cJSON_Parse(response_buf.data); + free(response_buf.data); + if (!*response_out) { + return ESP_FAIL; + } + + return ESP_OK; +} + +esp_err_t cap_mcp_list_remote_tools(const char *input_json, cJSON **result_out) +{ + char server_url_buf[256]; + char endpoint_buf[64]; + char cursor_buf[128]; + char full_url[384]; + cJSON *params = NULL; + cJSON *request = NULL; + cJSON *response = NULL; + cJSON *root = NULL; + cJSON *tools_out = NULL; + cJSON *error_obj = NULL; + cJSON *result = NULL; + cJSON *tools_array = NULL; + cJSON *tool = NULL; + esp_err_t err; + + if (!input_json || !result_out) { + return ESP_ERR_INVALID_ARG; + } + *result_out = NULL; + + err = cap_mcp_parse_common_input(input_json, + server_url_buf, + sizeof(server_url_buf), + endpoint_buf, + sizeof(endpoint_buf), + cursor_buf, + sizeof(cursor_buf), + NULL, + 0, + NULL); + if (err != ESP_OK) { + return err; + } + + cap_mcp_build_full_url(server_url_buf, endpoint_buf, full_url, sizeof(full_url)); + if (full_url[0] == '\0') { + return ESP_ERR_INVALID_ARG; + } + + params = cJSON_CreateObject(); + request = cJSON_CreateObject(); + if (!params || !request) { + cJSON_Delete(params); + cJSON_Delete(request); + return ESP_ERR_NO_MEM; + } + + if (cursor_buf[0]) { + cJSON_AddStringToObject(params, "cursor", cursor_buf); + } + cJSON_AddStringToObject(request, "jsonrpc", "2.0"); + cJSON_AddStringToObject(request, "method", "tools/list"); + cJSON_AddItemToObject(request, "params", params); + cJSON_AddNumberToObject(request, "id", CAP_MCP_REQUEST_ID_LIST); + + err = cap_mcp_execute_json_rpc(full_url, request, &response); + cJSON_Delete(request); + if (err != ESP_OK) { + return err; + } + + root = cJSON_CreateObject(); + tools_out = cJSON_CreateArray(); + if (!root || !tools_out) { + cJSON_Delete(response); + cJSON_Delete(root); + cJSON_Delete(tools_out); + return ESP_ERR_NO_MEM; + } + + error_obj = cJSON_GetObjectItem(response, "error"); + if (cJSON_IsObject(error_obj)) { + cJSON *message = cJSON_GetObjectItem(error_obj, "message"); + + cJSON_AddStringToObject(root, + "error_message", + cJSON_IsString(message) ? message->valuestring : "Unknown MCP error"); + cJSON_AddItemToObject(root, "tools", tools_out); + cJSON_Delete(response); + *result_out = root; + return ESP_OK; + } + + result = cJSON_GetObjectItem(response, "result"); + if (!cJSON_IsObject(result)) { + cJSON_Delete(response); + cJSON_Delete(root); + return ESP_FAIL; + } + + tools_array = cJSON_GetObjectItem(result, "tools"); + if (cJSON_IsArray(tools_array)) { + cJSON_ArrayForEach(tool, tools_array) { + cJSON *duplicate = cJSON_Duplicate(tool, 1); + + if (!duplicate) { + cJSON_Delete(response); + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + cJSON_AddItemToArray(tools_out, duplicate); + } + } + cJSON_AddItemToObject(root, "tools", tools_out); + + cJSON *next_cursor = cJSON_GetObjectItem(result, "nextCursor"); + if (cJSON_IsString(next_cursor) && next_cursor->valuestring[0]) { + cJSON_AddStringToObject(root, "nextCursor", next_cursor->valuestring); + } + + cJSON_Delete(response); + *result_out = root; + return ESP_OK; +} + +esp_err_t cap_mcp_call_remote_tool(const char *input_json, cJSON **result_out) +{ + char server_url_buf[256]; + char endpoint_buf[64]; + char tool_name_buf[128]; + char full_url[384]; + cJSON *arguments = NULL; + cJSON *params = NULL; + cJSON *request = NULL; + cJSON *response = NULL; + cJSON *root = NULL; + cJSON *error_obj = NULL; + cJSON *result = NULL; + esp_err_t err; + + if (!input_json || !result_out) { + return ESP_ERR_INVALID_ARG; + } + *result_out = NULL; + + err = cap_mcp_parse_common_input(input_json, + server_url_buf, + sizeof(server_url_buf), + endpoint_buf, + sizeof(endpoint_buf), + NULL, + 0, + tool_name_buf, + sizeof(tool_name_buf), + &arguments); + if (err != ESP_OK) { + cJSON_Delete(arguments); + return err; + } + + cap_mcp_build_full_url(server_url_buf, endpoint_buf, full_url, sizeof(full_url)); + if (full_url[0] == '\0') { + cJSON_Delete(arguments); + return ESP_ERR_INVALID_ARG; + } + + params = cJSON_CreateObject(); + request = cJSON_CreateObject(); + if (!params || !request) { + cJSON_Delete(arguments); + cJSON_Delete(params); + cJSON_Delete(request); + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(params, "name", tool_name_buf); + cJSON_AddItemToObject(params, "arguments", arguments); + cJSON_AddStringToObject(request, "jsonrpc", "2.0"); + cJSON_AddStringToObject(request, "method", "tools/call"); + cJSON_AddItemToObject(request, "params", params); + cJSON_AddNumberToObject(request, "id", CAP_MCP_REQUEST_ID_CALL); + + err = cap_mcp_execute_json_rpc(full_url, request, &response); + cJSON_Delete(request); + if (err != ESP_OK) { + return err; + } + + root = cJSON_CreateObject(); + if (!root) { + cJSON_Delete(response); + return ESP_ERR_NO_MEM; + } + + error_obj = cJSON_GetObjectItem(response, "error"); + if (cJSON_IsObject(error_obj)) { + cJSON *message = cJSON_GetObjectItem(error_obj, "message"); + + cJSON_AddStringToObject(root, + "error_message", + cJSON_IsString(message) ? message->valuestring : "Unknown MCP error"); + cJSON_Delete(response); + *result_out = root; + return ESP_OK; + } + + result = cJSON_GetObjectItem(response, "result"); + if (!cJSON_IsObject(result)) { + cJSON_Delete(response); + cJSON_Delete(root); + return ESP_FAIL; + } + + cJSON *content = cJSON_GetObjectItem(result, "content"); + cJSON *is_error = cJSON_GetObjectItem(result, "isError"); + cJSON_AddItemToObject(root, "content", content ? cJSON_Duplicate(content, 1) : cJSON_CreateArray()); + if (cJSON_IsBool(is_error)) { + cJSON_AddBoolToObject(root, "isError", cJSON_IsTrue(is_error)); + } + + cJSON_Delete(response); + *result_out = root; + return ESP_OK; +} diff --git a/components/cap_mcp_client/src/cap_mcp_client_internal.h b/components/cap_mcp_client/src/cap_mcp_client_internal.h new file mode 100644 index 0000000..7ede0e5 --- /dev/null +++ b/components/cap_mcp_client/src/cap_mcp_client_internal.h @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "cJSON.h" +#include "esp_err.h" + +#define CAP_MCP_DEFAULT_ENDPOINT "mcp_server" +#define CAP_MCP_MDNS_SERVICE_TYPE "_mcp" +#define CAP_MCP_MDNS_SERVICE_PROTO "_tcp" +#define CAP_MCP_DISCOVER_TIMEOUT_MS 3000 + +esp_err_t cap_mcp_list_remote_tools(const char *input_json, cJSON **result_out); +esp_err_t cap_mcp_call_remote_tool(const char *input_json, cJSON **result_out); +esp_err_t cap_mcp_discover_services(const char *input_json, cJSON **result_out); diff --git a/components/cap_mcp_client/src/cap_mcp_discover_core.c b/components/cap_mcp_client/src/cap_mcp_discover_core.c new file mode 100644 index 0000000..3d3c670 --- /dev/null +++ b/components/cap_mcp_client/src/cap_mcp_discover_core.c @@ -0,0 +1,187 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_mcp_client_internal.h" + +#include +#include +#include + +#include "esp_log.h" +#include "lwip/ip_addr.h" +#include "mdns.h" + +static const char *TAG = "mcp_discover_core"; + +static const char *cap_mcp_find_txt_value(const mdns_result_t *result, const char *key) +{ + size_t i; + + if (!result || !key) { + return NULL; + } + + for (i = 0; i < result->txt_count; i++) { + if (result->txt[i].key && strcmp(result->txt[i].key, key) == 0) { + return result->txt[i].value; + } + } + + return NULL; +} + +static const char *cap_mcp_pick_ip_string(const mdns_result_t *result, + char *buf, + size_t buf_size) +{ + const mdns_ip_addr_t *addr = NULL; + + if (!buf || buf_size == 0) { + return NULL; + } + buf[0] = '\0'; + + if (!result || !result->addr) { + return NULL; + } + + addr = result->addr; + while (addr) { + if (ipaddr_ntoa_r((const ip_addr_t *)&addr->addr, buf, buf_size)) { + return buf; + } + addr = addr->next; + } + + return NULL; +} + +static esp_err_t cap_mcp_parse_discover_options(const char *input_json, + int *timeout_ms, + bool *include_self) +{ + cJSON *input = NULL; + + if (!timeout_ms || !include_self) { + return ESP_ERR_INVALID_ARG; + } + *timeout_ms = CAP_MCP_DISCOVER_TIMEOUT_MS; + *include_self = true; + + if (!input_json || !input_json[0]) { + return ESP_OK; + } + + input = cJSON_Parse(input_json); + if (!input || !cJSON_IsObject(input)) { + cJSON_Delete(input); + return ESP_ERR_INVALID_ARG; + } + + cJSON *timeout_item = cJSON_GetObjectItem(input, "timeout_ms"); + if (cJSON_IsNumber(timeout_item) && timeout_item->valueint > 0) { + *timeout_ms = timeout_item->valueint; + } + + cJSON *self_item = cJSON_GetObjectItem(input, "include_self"); + if (cJSON_IsBool(self_item)) { + *include_self = cJSON_IsTrue(self_item); + } + + cJSON_Delete(input); + return ESP_OK; +} + +esp_err_t cap_mcp_discover_services(const char *input_json, cJSON **result_out) +{ + int timeout_ms = CAP_MCP_DISCOVER_TIMEOUT_MS; + bool include_self = true; + mdns_result_t *results = NULL; + cJSON *root = NULL; + cJSON *devices = NULL; + size_t found = 0; + esp_err_t err; + + if (!result_out) { + return ESP_ERR_INVALID_ARG; + } + *result_out = NULL; + + err = cap_mcp_parse_discover_options(input_json, &timeout_ms, &include_self); + if (err != ESP_OK) { + return err; + } + + err = mdns_query_ptr(CAP_MCP_MDNS_SERVICE_TYPE, + CAP_MCP_MDNS_SERVICE_PROTO, + timeout_ms, + 20, + &results); + if (err != ESP_OK) { + ESP_LOGW(TAG, "mdns_query_ptr failed: %s", esp_err_to_name(err)); + return err; + } + + root = cJSON_CreateObject(); + devices = cJSON_CreateArray(); + if (!root || !devices) { + mdns_query_results_free(results); + cJSON_Delete(root); + cJSON_Delete(devices); + return ESP_ERR_NO_MEM; + } + + for (mdns_result_t *result = results; result; result = result->next) { + char ip_buf[64]; + const char *ip; + const char *endpoint; + const char *hostname; + const char *instance; + const char *host_for_url; + cJSON *device; + char server_url[320]; + char url[384]; + + if (!include_self && result->hostname && strcmp(result->hostname, "clawgent") == 0) { + continue; + } + + ip = cap_mcp_pick_ip_string(result, ip_buf, sizeof(ip_buf)); + endpoint = cap_mcp_find_txt_value(result, "endpoint"); + if (!endpoint || !endpoint[0]) { + endpoint = CAP_MCP_DEFAULT_ENDPOINT; + } + + 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) { + mdns_query_results_free(results); + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + + 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); + cJSON_AddNumberToObject(root, "count", (double)found); + cJSON_AddItemToObject(root, "devices", devices); + *result_out = root; + return ESP_OK; +} diff --git a/components/cap_mcp_client/src/cmd_cap_mcp_client.c b/components/cap_mcp_client/src/cmd_cap_mcp_client.c new file mode 100644 index 0000000..db968da --- /dev/null +++ b/components/cap_mcp_client/src/cmd_cap_mcp_client.c @@ -0,0 +1,169 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cmd_cap_mcp_client.h" + +#include +#include + +#include "argtable3/argtable3.h" +#include "cJSON.h" +#include "claw_cap.h" +#include "esp_console.h" + +static struct { + struct arg_lit *discover; + struct arg_lit *list_tools; + struct arg_lit *call_tool; + struct arg_str *server_url; + struct arg_str *endpoint; + struct arg_str *cursor; + struct arg_str *tool_name; + struct arg_str *arguments_json; + struct arg_int *timeout_ms; + struct arg_lit *include_self; + struct arg_end *end; +} mcp_client_args; + +static int mcp_client_call_cap(const char *cap_name, cJSON *root) +{ + char *input_json = NULL; + char *output = NULL; + esp_err_t err; + claw_cap_call_context_t ctx = { + .caller = CLAW_CAP_CALLER_CONSOLE, + }; + + input_json = cJSON_PrintUnformatted(root); + if (!input_json) { + printf("Out of memory\n"); + return 1; + } + + output = calloc(1, 4096); + if (!output) { + free(input_json); + printf("Out of memory\n"); + return 1; + } + + err = claw_cap_call(cap_name, input_json, &ctx, output, 4096); + if (err != ESP_OK) { + printf("%s\n", output[0] ? output : esp_err_to_name(err)); + } else { + printf("%s\n", output); + } + + free(output); + free(input_json); + return err == ESP_OK ? 0 : 1; +} + +static int mcp_client_func(int argc, char **argv) +{ + cJSON *root = NULL; + cJSON *arguments = NULL; + const char *cap_name = NULL; + int nerrors = arg_parse(argc, argv, (void **)&mcp_client_args); + int operation_count; + int rc; + + if (nerrors != 0) { + arg_print_errors(stderr, mcp_client_args.end, argv[0]); + return 1; + } + + operation_count = mcp_client_args.discover->count + mcp_client_args.list_tools->count + + mcp_client_args.call_tool->count; + if (operation_count != 1) { + printf("Exactly one operation must be specified\n"); + return 1; + } + + root = cJSON_CreateObject(); + if (!root) { + printf("Out of memory\n"); + return 1; + } + + if (mcp_client_args.discover->count) { + cap_name = "mcp_discover"; + if (mcp_client_args.timeout_ms->count) { + cJSON_AddNumberToObject(root, "timeout_ms", mcp_client_args.timeout_ms->ival[0]); + } + if (mcp_client_args.include_self->count) { + cJSON_AddBoolToObject(root, "include_self", true); + } + } else { + if (!mcp_client_args.server_url->count) { + cJSON_Delete(root); + printf("'--server-url' is required\n"); + return 1; + } + + cJSON_AddStringToObject(root, "server_url", mcp_client_args.server_url->sval[0]); + if (mcp_client_args.endpoint->count) { + cJSON_AddStringToObject(root, "endpoint", mcp_client_args.endpoint->sval[0]); + } + + if (mcp_client_args.list_tools->count) { + cap_name = "mcp_list_tools"; + if (mcp_client_args.cursor->count) { + cJSON_AddStringToObject(root, "cursor", mcp_client_args.cursor->sval[0]); + } + } else { + cap_name = "mcp_call_tool"; + if (!mcp_client_args.tool_name->count) { + cJSON_Delete(root); + printf("'--call-tool' requires '--tool-name'\n"); + return 1; + } + cJSON_AddStringToObject(root, "tool_name", mcp_client_args.tool_name->sval[0]); + + if (mcp_client_args.arguments_json->count) { + arguments = cJSON_Parse(mcp_client_args.arguments_json->sval[0]); + if (!arguments || !cJSON_IsObject(arguments)) { + cJSON_Delete(arguments); + cJSON_Delete(root); + printf("'--arguments-json' must be a JSON object\n"); + return 1; + } + cJSON_AddItemToObject(root, "arguments", arguments); + } + } + } + + rc = mcp_client_call_cap(cap_name, root); + cJSON_Delete(root); + return rc; +} + +void register_cap_mcp_client(void) +{ + mcp_client_args.discover = arg_lit0(NULL, "discover", "Discover MCP servers on the local network"); + mcp_client_args.list_tools = arg_lit0(NULL, "list-tools", "List tools from one remote MCP server"); + mcp_client_args.call_tool = arg_lit0(NULL, "call-tool", "Call one tool on a remote MCP server"); + mcp_client_args.server_url = arg_str0(NULL, "server-url", "", "Remote MCP server base URL"); + mcp_client_args.endpoint = arg_str0(NULL, "endpoint", "", "Remote MCP endpoint"); + mcp_client_args.cursor = arg_str0(NULL, "cursor", "", "Pagination cursor for list-tools"); + mcp_client_args.tool_name = arg_str0(NULL, "tool-name", "", "Remote MCP tool name"); + mcp_client_args.arguments_json = arg_str0(NULL, "arguments-json", "", "Remote MCP tool arguments JSON object"); + mcp_client_args.timeout_ms = arg_int0(NULL, "timeout-ms", "", "Discovery timeout in milliseconds"); + mcp_client_args.include_self = arg_lit0(NULL, "include-self", "Include the local device in discovery"); + mcp_client_args.end = arg_end(10); + + const esp_console_cmd_t mcp_client_cmd = { + .command = "mcp_client", + .help = "MCP client operations.\n" + "Examples:\n" + " mcp_client --discover --timeout-ms 3000\n" + " mcp_client --list-tools --server-url http://host.local:18791 --endpoint mcp_server\n" + " mcp_client --call-tool --server-url http://host.local:18791 --tool-name device.describe\n", + .func = mcp_client_func, + .argtable = &mcp_client_args, + }; + + ESP_ERROR_CHECK(esp_console_cmd_register(&mcp_client_cmd)); +} diff --git a/components/cap_mcp_server/CMakeLists.txt b/components/cap_mcp_server/CMakeLists.txt new file mode 100644 index 0000000..81b024b --- /dev/null +++ b/components/cap_mcp_server/CMakeLists.txt @@ -0,0 +1,15 @@ +idf_component_register( + SRCS + "src/cap_mcp_server.c" + "src/cmd_cap_mcp_server.c" + INCLUDE_DIRS + "include" + REQUIRES + claw_cap + claw_event_router + esp_http_server + espressif__mcp-c-sdk + espressif__mdns + json + console +) diff --git a/components/cap_mcp_server/idf_component.yml b/components/cap_mcp_server/idf_component.yml new file mode 100644 index 0000000..c4a5937 --- /dev/null +++ b/components/cap_mcp_server/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + espressif/mdns: "^1.10.1" + espressif/mcp-c-sdk: "^1.0.0" diff --git a/components/cap_mcp_server/include/cap_mcp_server.h b/components/cap_mcp_server/include/cap_mcp_server.h new file mode 100644 index 0000000..0646342 --- /dev/null +++ b/components/cap_mcp_server/include/cap_mcp_server.h @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + const char *hostname; + const char *instance_name; + const char *endpoint; + uint16_t server_port; + uint16_t ctrl_port; +} cap_mcp_server_config_t; + +esp_err_t cap_mcp_server_register_group(void); +esp_err_t cap_mcp_server_set_config(const cap_mcp_server_config_t *config); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_mcp_server/include/cmd_cap_mcp_server.h b/components/cap_mcp_server/include/cmd_cap_mcp_server.h new file mode 100644 index 0000000..017ed49 --- /dev/null +++ b/components/cap_mcp_server/include/cmd_cap_mcp_server.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_mcp_server(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_mcp_server/src/cap_mcp_server.c b/components/cap_mcp_server/src/cap_mcp_server.c new file mode 100644 index 0000000..f276fca --- /dev/null +++ b/components/cap_mcp_server/src/cap_mcp_server.c @@ -0,0 +1,494 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_mcp_server.h" + +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "claw_cap.h" +#include "claw_event_router.h" +#include "esp_check.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_mcp_engine.h" +#include "esp_mcp_mgr.h" +#include "esp_mcp_property.h" +#include "esp_mcp_tool.h" +#include "mdns.h" + +static const char *TAG = "cap_mcp_srv"; + +#define CAP_MCP_SERVER_DEFAULT_HOSTNAME "clawgent" +#define CAP_MCP_SERVER_DEFAULT_INSTANCE "Clawgent" +#define CAP_MCP_SERVER_DEFAULT_ENDPOINT "mcp_server" +#define CAP_MCP_SERVER_DEFAULT_SERVICE_TYPE "_mcp" +#define CAP_MCP_SERVER_DEFAULT_SERVICE_PROTO "_tcp" +#define CAP_MCP_SERVER_DEFAULT_PORT 18791 +#define CAP_MCP_SERVER_DEFAULT_CTRL_PORT 18792 + +typedef struct { + const char *name; + const char *description; + esp_mcp_value_t (*callback)(const esp_mcp_property_list_t *properties); + const char *property_names[6]; + size_t property_count; +} cap_mcp_server_tool_def_t; + +typedef struct { + char hostname[64]; + char instance_name[64]; + char endpoint[64]; + uint16_t server_port; + uint16_t ctrl_port; +} cap_mcp_server_runtime_config_t; + +static cap_mcp_server_runtime_config_t s_config = { + .hostname = CAP_MCP_SERVER_DEFAULT_HOSTNAME, + .instance_name = CAP_MCP_SERVER_DEFAULT_INSTANCE, + .endpoint = CAP_MCP_SERVER_DEFAULT_ENDPOINT, + .server_port = CAP_MCP_SERVER_DEFAULT_PORT, + .ctrl_port = CAP_MCP_SERVER_DEFAULT_CTRL_PORT, +}; +static esp_mcp_t *s_mcp; +static esp_mcp_mgr_handle_t s_mgr; +static bool s_tools_registered; +static bool s_started; + +static int cap_mcp_server_current_time_ms(void) +{ + struct timeval tv = {0}; + + gettimeofday(&tv, NULL); + return (int)((tv.tv_sec * 1000LL) + (tv.tv_usec / 1000)); +} + +static esp_mcp_value_t cap_mcp_server_result_json(cJSON *root) +{ + char *resp_json = NULL; + esp_mcp_value_t result; + + if (!root) { + return esp_mcp_value_create_bool(false); + } + + resp_json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!resp_json) { + return esp_mcp_value_create_bool(false); + } + + result = esp_mcp_value_create_string(resp_json); + free(resp_json); + return result; +} + +static esp_mcp_value_t cap_mcp_server_report_state_callback( + const esp_mcp_property_list_t *properties) +{ + const char *device_id = esp_mcp_property_list_get_property_string(properties, "device_id"); + const char *state_name = esp_mcp_property_list_get_property_string(properties, "state_name"); + const char *value = esp_mcp_property_list_get_property_string(properties, "value"); + claw_event_t event = {0}; + int timestamp_ms = 0; + cJSON *resp = NULL; + cJSON *payload = NULL; + char *payload_json = NULL; + + if (!device_id || !device_id[0] || !state_name || !state_name[0] || !value) { + ESP_LOGE(TAG, "device.report_state: missing required argument"); + return esp_mcp_value_create_bool(false); + } + + timestamp_ms = cap_mcp_server_current_time_ms(); + ESP_LOGI(TAG, + "state_report device_id=%s state_name=%s value=%s timestamp_ms=%d", + device_id, + state_name, + value, + timestamp_ms); + + payload = cJSON_CreateObject(); + if (!payload) { + return esp_mcp_value_create_bool(false); + } + cJSON_AddStringToObject(payload, "device_id", device_id); + cJSON_AddStringToObject(payload, "state_name", state_name); + cJSON_AddStringToObject(payload, "value", value); + cJSON_AddNumberToObject(payload, "timestamp_ms", timestamp_ms); + payload_json = cJSON_PrintUnformatted(payload); + cJSON_Delete(payload); + if (!payload_json) { + return esp_mcp_value_create_bool(false); + } + + strlcpy(event.event_id, "mcp-report-state", sizeof(event.event_id)); + strlcpy(event.source_cap, "mcp_server", sizeof(event.source_cap)); + strlcpy(event.event_type, "mcp_device_state_report", sizeof(event.event_type)); + strlcpy(event.source_channel, "mcp", sizeof(event.source_channel)); + strlcpy(event.content_type, "json", sizeof(event.content_type)); + strlcpy(event.message_id, state_name, sizeof(event.message_id)); + strlcpy(event.correlation_id, state_name, sizeof(event.correlation_id)); + event.timestamp_ms = timestamp_ms; + event.session_policy = CLAW_EVENT_SESSION_POLICY_TRIGGER; + event.payload_json = payload_json; + + if (claw_event_router_publish(&event) != ESP_OK) { + free(payload_json); + return esp_mcp_value_create_bool(false); + } + free(payload_json); + + resp = cJSON_CreateObject(); + if (!resp) { + return esp_mcp_value_create_bool(false); + } + + cJSON_AddBoolToObject(resp, "accepted", true); + cJSON_AddStringToObject(resp, "device_id", device_id); + cJSON_AddStringToObject(resp, "state_name", state_name); + cJSON_AddStringToObject(resp, "value", value); + cJSON_AddNumberToObject(resp, "timestamp_ms", timestamp_ms); + cJSON_AddStringToObject(resp, "event_type", "mcp_device_state_report"); + return cap_mcp_server_result_json(resp); +} + +static esp_mcp_value_t cap_mcp_server_describe_callback( + const esp_mcp_property_list_t *properties) +{ + cJSON *resp = NULL; + (void)properties; + + resp = cJSON_CreateObject(); + if (!resp) { + return esp_mcp_value_create_bool(false); + } + + cJSON_AddStringToObject(resp, "hostname", s_config.hostname); + cJSON_AddStringToObject(resp, "instance_name", s_config.instance_name); + cJSON_AddStringToObject(resp, "endpoint", s_config.endpoint); + cJSON_AddNumberToObject(resp, "server_port", s_config.server_port); + cJSON_AddBoolToObject(resp, "started", s_started); + return cap_mcp_server_result_json(resp); +} + +static esp_mcp_value_t cap_mcp_server_emit_event_callback( + const esp_mcp_property_list_t *properties) +{ + const char *event_type = esp_mcp_property_list_get_property_string(properties, "event_type"); + const char *text = esp_mcp_property_list_get_property_string(properties, "text"); + const char *target_channel = esp_mcp_property_list_get_property_string(properties, "target_channel"); + const char *target_endpoint = esp_mcp_property_list_get_property_string(properties, "target_endpoint"); + const char *payload_json = esp_mcp_property_list_get_property_string(properties, "payload_json"); + claw_event_t event = {0}; + cJSON *resp = NULL; + + if (!event_type || !event_type[0]) { + return esp_mcp_value_create_bool(false); + } + + strlcpy(event.event_id, "mcp-emit", sizeof(event.event_id)); + strlcpy(event.source_cap, "mcp_server", sizeof(event.source_cap)); + strlcpy(event.event_type, event_type, sizeof(event.event_type)); + strlcpy(event.source_channel, "mcp", sizeof(event.source_channel)); + strlcpy(event.content_type, (payload_json && payload_json[0]) ? "json" : "text", + sizeof(event.content_type)); + strlcpy(event.target_channel, target_channel ? target_channel : "", sizeof(event.target_channel)); + strlcpy(event.target_endpoint, target_endpoint ? target_endpoint : "", sizeof(event.target_endpoint)); + strlcpy(event.message_id, event_type, sizeof(event.message_id)); + strlcpy(event.correlation_id, event_type, sizeof(event.correlation_id)); + event.timestamp_ms = cap_mcp_server_current_time_ms(); + event.session_policy = CLAW_EVENT_SESSION_POLICY_TRIGGER; + event.text = (char *)(text ? text : ""); + event.payload_json = (char *)(payload_json && payload_json[0] ? payload_json : "{}"); + + if (claw_event_router_publish(&event) != ESP_OK) { + return esp_mcp_value_create_bool(false); + } + + resp = cJSON_CreateObject(); + if (!resp) { + return esp_mcp_value_create_bool(false); + } + cJSON_AddBoolToObject(resp, "accepted", true); + cJSON_AddStringToObject(resp, "event_type", event_type); + cJSON_AddStringToObject(resp, "target_channel", target_channel ? target_channel : ""); + cJSON_AddStringToObject(resp, "target_endpoint", target_endpoint ? target_endpoint : ""); + return cap_mcp_server_result_json(resp); +} + +static esp_err_t cap_mcp_server_register_tool( + const cap_mcp_server_tool_def_t *tool_def) +{ + esp_mcp_tool_t *tool = NULL; + size_t i = 0; + + ESP_RETURN_ON_FALSE(tool_def != NULL, ESP_ERR_INVALID_ARG, TAG, "tool def missing"); + + tool = esp_mcp_tool_create(tool_def->name, tool_def->description, tool_def->callback); + ESP_RETURN_ON_FALSE(tool != NULL, ESP_ERR_NO_MEM, TAG, "Failed to create MCP tool"); + + for (i = 0; i < tool_def->property_count; i++) { + esp_mcp_property_t *property = NULL; + + property = esp_mcp_property_create_with_string(tool_def->property_names[i], ""); + ESP_RETURN_ON_FALSE(property != NULL, ESP_ERR_NO_MEM, TAG, "Failed to create property"); + ESP_RETURN_ON_ERROR(esp_mcp_tool_add_property(tool, property), + TAG, + "Failed to add MCP property"); + } + + return esp_mcp_add_tool(s_mcp, tool); +} + +static esp_err_t cap_mcp_server_register_tools(void) +{ + static const cap_mcp_server_tool_def_t s_tool_defs[] = { + { + .name = "device.report_state", + .description = + "Receive a device state update. Provide device_id, state_name, and value.", + .callback = cap_mcp_server_report_state_callback, + .property_names = {"device_id", "state_name", "value"}, + .property_count = 3, + }, + { + .name = "device.describe", + .description = "Describe the local MCP server host state and endpoint.", + .callback = cap_mcp_server_describe_callback, + .property_count = 0, + }, + { + .name = "router.emit_event", + .description = "Emit a standard router event into clawgent. Provide event_type and optional text, target_channel, target_endpoint, payload_json.", + .callback = cap_mcp_server_emit_event_callback, + .property_names = {"event_type", "text", "target_channel", "target_endpoint", "payload_json"}, + .property_count = 5, + }, + }; + size_t i = 0; + esp_err_t err; + + if (s_tools_registered) { + return ESP_OK; + } + + for (i = 0; i < sizeof(s_tool_defs) / sizeof(s_tool_defs[0]); i++) { + err = cap_mcp_server_register_tool(&s_tool_defs[i]); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to register tool %s: %s", s_tool_defs[i].name, esp_err_to_name(err)); + return err; + } + } + + s_tools_registered = true; + return ESP_OK; +} + +static esp_err_t cap_mcp_server_register_mdns_service(void) +{ + mdns_txt_item_t txt[] = { + {"endpoint", s_config.endpoint}, + }; + esp_err_t err; + + err = mdns_service_add(s_config.instance_name, + CAP_MCP_SERVER_DEFAULT_SERVICE_TYPE, + CAP_MCP_SERVER_DEFAULT_SERVICE_PROTO, + s_config.server_port, + txt, + sizeof(txt) / sizeof(txt[0])); + if (err == ESP_OK || err == ESP_ERR_INVALID_STATE) { + if (err == ESP_OK) { + return ESP_OK; + } + } else { + return err; + } + + err = mdns_service_port_set(CAP_MCP_SERVER_DEFAULT_SERVICE_TYPE, + CAP_MCP_SERVER_DEFAULT_SERVICE_PROTO, + s_config.server_port); + if (err != ESP_OK) { + return err; + } + + return mdns_service_txt_set(CAP_MCP_SERVER_DEFAULT_SERVICE_TYPE, + CAP_MCP_SERVER_DEFAULT_SERVICE_PROTO, + txt, + sizeof(txt) / sizeof(txt[0])); +} + +static esp_err_t cap_mcp_server_descriptor_init(void) +{ + if (s_mcp) { + return ESP_OK; + } + + ESP_RETURN_ON_ERROR(esp_mcp_create(&s_mcp), TAG, "Failed to create MCP engine"); + ESP_RETURN_ON_ERROR(cap_mcp_server_register_tools(), TAG, "Failed to register MCP tools"); + return ESP_OK; +} + +static esp_err_t cap_mcp_server_descriptor_start(void) +{ + httpd_config_t http_config = HTTPD_DEFAULT_CONFIG(); + esp_mcp_mgr_config_t config; + esp_err_t err; + + if (s_started) { + ESP_LOGW(TAG, "MCP server already running"); + return ESP_OK; + } + + ESP_RETURN_ON_FALSE(s_mcp != NULL, ESP_ERR_INVALID_STATE, TAG, "MCP server not initialized"); + + err = mdns_init(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + return err; + } + + ESP_RETURN_ON_ERROR(mdns_hostname_set(s_config.hostname), TAG, "Failed to set mDNS hostname"); + ESP_RETURN_ON_ERROR(mdns_instance_name_set(s_config.instance_name), + TAG, + "Failed to set mDNS instance"); + + http_config.server_port = s_config.server_port; + http_config.ctrl_port = s_config.ctrl_port; + http_config.max_uri_handlers = 4; + http_config.stack_size = 8192; + + config.transport = esp_mcp_transport_http_server; + config.config = &http_config; + config.instance = s_mcp; + + err = esp_mcp_mgr_init(config, &s_mgr); + if (err != ESP_OK) { + return err; + } + + err = esp_mcp_mgr_start(s_mgr); + if (err != ESP_OK) { + esp_mcp_mgr_deinit(s_mgr); + s_mgr = 0; + return err; + } + + err = esp_mcp_mgr_register_endpoint(s_mgr, s_config.endpoint, NULL); + if (err != ESP_OK) { + esp_mcp_mgr_stop(s_mgr); + esp_mcp_mgr_deinit(s_mgr); + s_mgr = 0; + return err; + } + + err = cap_mcp_server_register_mdns_service(); + if (err != ESP_OK) { + esp_mcp_mgr_stop(s_mgr); + esp_mcp_mgr_deinit(s_mgr); + s_mgr = 0; + return err; + } + + s_started = true; + ESP_LOGI(TAG, + "MCP server ready: http://%s.local:%u/%s (ctrl_port=%u)", + s_config.hostname, + (unsigned int)s_config.server_port, + s_config.endpoint, + (unsigned int)s_config.ctrl_port); + return ESP_OK; +} + +static esp_err_t cap_mcp_server_descriptor_stop(void) +{ + esp_err_t ret = ESP_OK; + esp_err_t err; + + if (!s_started) { + return ESP_OK; + } + + mdns_service_remove(CAP_MCP_SERVER_DEFAULT_SERVICE_TYPE, + CAP_MCP_SERVER_DEFAULT_SERVICE_PROTO); + + if (s_mgr != 0) { + err = esp_mcp_mgr_stop(s_mgr); + if (err != ESP_OK && ret == ESP_OK) { + ret = err; + } + + err = esp_mcp_mgr_deinit(s_mgr); + if (err != ESP_OK && ret == ESP_OK) { + ret = err; + } + s_mgr = 0; + } + + s_started = false; + return ret; +} + +static const claw_cap_descriptor_t s_mcp_server_descriptors[] = { + { + .id = "mcp_server", + .name = "mcp_server", + .family = "mcp", + .description = "Lifecycle-managed local MCP server host.", + .kind = CLAW_CAP_KIND_HYBRID, + .cap_flags = CLAW_CAP_FLAG_SUPPORTS_LIFECYCLE, + .input_schema_json = "{\"type\":\"object\"}", + .init = cap_mcp_server_descriptor_init, + .start = cap_mcp_server_descriptor_start, + .stop = cap_mcp_server_descriptor_stop, + }, +}; + +static const claw_cap_group_t s_mcp_server_group = { + .group_id = "cap_mcp_server", + .descriptors = s_mcp_server_descriptors, + .descriptor_count = sizeof(s_mcp_server_descriptors) / sizeof(s_mcp_server_descriptors[0]), +}; + +esp_err_t cap_mcp_server_register_group(void) +{ + if (claw_cap_group_exists(s_mcp_server_group.group_id)) { + return ESP_OK; + } + + return claw_cap_register_group(&s_mcp_server_group); +} + +esp_err_t cap_mcp_server_set_config(const cap_mcp_server_config_t *config) +{ + if (!config) { + return ESP_ERR_INVALID_ARG; + } + if (s_started) { + return ESP_ERR_INVALID_STATE; + } + + if (config->hostname && config->hostname[0]) { + strlcpy(s_config.hostname, config->hostname, sizeof(s_config.hostname)); + } + if (config->instance_name && config->instance_name[0]) { + strlcpy(s_config.instance_name, config->instance_name, sizeof(s_config.instance_name)); + } + if (config->endpoint && config->endpoint[0]) { + strlcpy(s_config.endpoint, config->endpoint, sizeof(s_config.endpoint)); + } + if (config->server_port != 0) { + s_config.server_port = config->server_port; + } + if (config->ctrl_port != 0) { + s_config.ctrl_port = config->ctrl_port; + } + + return ESP_OK; +} diff --git a/components/cap_mcp_server/src/cmd_cap_mcp_server.c b/components/cap_mcp_server/src/cmd_cap_mcp_server.c new file mode 100644 index 0000000..751dded --- /dev/null +++ b/components/cap_mcp_server/src/cmd_cap_mcp_server.c @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cmd_cap_mcp_server.h" + +#include + +#include "argtable3/argtable3.h" +#include "cap_mcp_server.h" +#include "claw_cap.h" +#include "esp_console.h" + +static struct { + struct arg_lit *status; + struct arg_lit *enable; + struct arg_lit *disable; + struct arg_lit *set_config; + struct arg_str *hostname; + struct arg_str *instance_name; + struct arg_str *endpoint; + struct arg_int *server_port; + struct arg_int *ctrl_port; + struct arg_end *end; +} mcp_server_args; + +static int mcp_server_print_status(void) +{ + claw_cap_state_t group_state = CLAW_CAP_STATE_REGISTERED; + claw_cap_descriptor_info_t descriptor = {0}; + esp_err_t err; + + err = claw_cap_get_group_state("cap_mcp_server", &group_state); + if (err != ESP_OK) { + printf("mcp_server status failed: %s\n", esp_err_to_name(err)); + return 1; + } + + err = claw_cap_get_descriptor_state("mcp_server", &descriptor); + if (err != ESP_OK) { + printf("mcp_server descriptor status failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("group_id=%s state=%s\n", descriptor.group_id ? descriptor.group_id : "cap_mcp_server", + claw_cap_state_to_string(group_state)); + printf("descriptor=%s state=%s active_calls=%u\n", + descriptor.name ? descriptor.name : "mcp_server", + claw_cap_state_to_string(descriptor.state), + (unsigned)descriptor.active_calls); + return 0; +} + +static int mcp_server_func(int argc, char **argv) +{ + cap_mcp_server_config_t config = {0}; + esp_err_t err; + int nerrors = arg_parse(argc, argv, (void **)&mcp_server_args); + int operation_count; + + if (nerrors != 0) { + arg_print_errors(stderr, mcp_server_args.end, argv[0]); + return 1; + } + + operation_count = mcp_server_args.status->count + mcp_server_args.enable->count + + mcp_server_args.disable->count + mcp_server_args.set_config->count; + if (operation_count != 1) { + printf("Exactly one operation must be specified\n"); + return 1; + } + + if (mcp_server_args.status->count) { + return mcp_server_print_status(); + } + + if (mcp_server_args.enable->count) { + err = claw_cap_enable_group("cap_mcp_server"); + if (err != ESP_OK) { + printf("mcp_server enable failed: %s\n", esp_err_to_name(err)); + return 1; + } + printf("cap_mcp_server enabled\n"); + return 0; + } + + if (mcp_server_args.disable->count) { + err = claw_cap_disable_group("cap_mcp_server"); + if (err != ESP_OK) { + printf("mcp_server disable failed: %s\n", esp_err_to_name(err)); + return 1; + } + printf("cap_mcp_server disabled\n"); + return 0; + } + + config.hostname = mcp_server_args.hostname->count ? mcp_server_args.hostname->sval[0] : NULL; + config.instance_name = + mcp_server_args.instance_name->count ? mcp_server_args.instance_name->sval[0] : NULL; + config.endpoint = mcp_server_args.endpoint->count ? mcp_server_args.endpoint->sval[0] : NULL; + config.server_port = mcp_server_args.server_port->count ? + (uint16_t)mcp_server_args.server_port->ival[0] : 0; + config.ctrl_port = mcp_server_args.ctrl_port->count ? + (uint16_t)mcp_server_args.ctrl_port->ival[0] : 0; + + err = cap_mcp_server_set_config(&config); + if (err != ESP_OK) { + printf("mcp_server set-config failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("MCP server config updated\n"); + return 0; +} + +void register_cap_mcp_server(void) +{ + mcp_server_args.status = arg_lit0(NULL, "status", "Show MCP server group and descriptor state"); + mcp_server_args.enable = arg_lit0(NULL, "enable", "Enable and start the MCP server group"); + mcp_server_args.disable = arg_lit0(NULL, "disable", "Disable and stop the MCP server group"); + mcp_server_args.set_config = arg_lit0(NULL, "set-config", "Update MCP server config before start"); + mcp_server_args.hostname = arg_str0(NULL, "hostname", "", "mDNS hostname"); + mcp_server_args.instance_name = arg_str0(NULL, "instance-name", "", "mDNS instance name"); + mcp_server_args.endpoint = arg_str0(NULL, "endpoint", "", "HTTP endpoint path"); + mcp_server_args.server_port = arg_int0(NULL, "server-port", "", "HTTP server port"); + mcp_server_args.ctrl_port = arg_int0(NULL, "ctrl-port", "", "HTTP control port"); + mcp_server_args.end = arg_end(8); + + const esp_console_cmd_t mcp_server_cmd = { + .command = "mcp_server", + .help = "MCP server operations.\n" + "Examples:\n" + " mcp_server --status\n" + " mcp_server --set-config --hostname clawgent --endpoint mcp_server\n" + " mcp_server --disable\n" + " mcp_server --enable\n", + .func = mcp_server_func, + .argtable = &mcp_server_args, + }; + + ESP_ERROR_CHECK(esp_console_cmd_register(&mcp_server_cmd)); +} diff --git a/components/cap_skill/CMakeLists.txt b/components/cap_skill/CMakeLists.txt new file mode 100644 index 0000000..a3a2ccb --- /dev/null +++ b/components/cap_skill/CMakeLists.txt @@ -0,0 +1,12 @@ +idf_component_register( + SRCS + "src/cap_skill.c" + "src/cmd_cap_skill.c" + INCLUDE_DIRS + "include" + REQUIRES + claw_cap + claw_skill + json + console +) diff --git a/components/cap_skill/include/cap_skill.h b/components/cap_skill/include/cap_skill.h new file mode 100644 index 0000000..f2aa92f --- /dev/null +++ b/components/cap_skill/include/cap_skill.h @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t cap_skill_register_group(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_skill/include/cmd_cap_skill.h b/components/cap_skill/include/cmd_cap_skill.h new file mode 100644 index 0000000..c453a8d --- /dev/null +++ b/components/cap_skill/include/cmd_cap_skill.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_skill(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_skill/src/cap_skill.c b/components/cap_skill/src/cap_skill.c new file mode 100644 index 0000000..65342ff --- /dev/null +++ b/components/cap_skill/src/cap_skill.c @@ -0,0 +1,213 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_skill.h" + +#include +#include +#include +#include + +#include "cJSON.h" +#include "claw_cap.h" +#include "claw_skill.h" + +static const char *CAP_SKILL_ACTIVATE = "activate_skill"; +static const char *CAP_SKILL_DEACTIVATE = "deactivate_skill"; + +static void cap_skill_free_string_array(char **items, size_t count) +{ + size_t i; + + if (!items) { + return; + } + + for (i = 0; i < count; i++) { + free(items[i]); + } + free(items); +} + +static esp_err_t cap_skill_build_result(const char *action, + const char *session_id, + const char *skill_id, + char *output, + size_t output_size) +{ + char **active_skill_ids = NULL; + size_t active_skill_count = 0; + cJSON *root = NULL; + cJSON *active = NULL; + char *rendered = NULL; + esp_err_t err; + size_t i; + + if (!action || !output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + err = claw_skill_load_active_skill_ids(session_id, &active_skill_ids, &active_skill_count); + if (err != ESP_OK && err != ESP_ERR_NOT_FOUND) { + return err; + } + + root = cJSON_CreateObject(); + active = cJSON_CreateArray(); + if (!root || !active) { + cJSON_Delete(root); + cJSON_Delete(active); + cap_skill_free_string_array(active_skill_ids, active_skill_count); + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(root, "action", action); + cJSON_AddStringToObject(root, "session_id", session_id ? session_id : ""); + if (skill_id) { + cJSON_AddStringToObject(root, "skill_id", skill_id); + } + cJSON_AddBoolToObject(root, "ok", true); + for (i = 0; i < active_skill_count; i++) { + cJSON_AddItemToArray(active, cJSON_CreateString(active_skill_ids[i])); + } + cJSON_AddItemToObject(root, "active_skill_ids", active); + + rendered = cJSON_PrintUnformatted(root); + if (!rendered) { + cJSON_Delete(root); + cap_skill_free_string_array(active_skill_ids, active_skill_count); + return ESP_ERR_NO_MEM; + } + + snprintf(output, output_size, "%s", rendered); + free(rendered); + cJSON_Delete(root); + cap_skill_free_string_array(active_skill_ids, active_skill_count); + return ESP_OK; +} + +static esp_err_t cap_skill_activate_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + cJSON *skill_id_item = NULL; + char skill_id_buf[64] = {0}; + esp_err_t err; + + if (!ctx || !ctx->session_id || !ctx->session_id[0] || !output || output_size == 0) { + return ESP_ERR_INVALID_STATE; + } + + root = cJSON_Parse(input_json ? input_json : "{}"); + skill_id_item = root ? cJSON_GetObjectItemCaseSensitive(root, "skill_id") : NULL; + if (!cJSON_IsString(skill_id_item) || !skill_id_item->valuestring || !skill_id_item->valuestring[0]) { + cJSON_Delete(root); + snprintf(output, output_size, "{\"ok\":false,\"error\":\"skill_id is required\"}"); + return ESP_ERR_INVALID_ARG; + } + + snprintf(skill_id_buf, sizeof(skill_id_buf), "%s", skill_id_item->valuestring); + err = claw_skill_activate_for_session(ctx->session_id, skill_id_buf); + cJSON_Delete(root); + if (err != ESP_OK) { + snprintf(output, + output_size, + "{\"ok\":false,\"error\":\"failed to activate skill\",\"skill_id\":\"%s\"}", + skill_id_buf); + return err; + } + + return cap_skill_build_result(CAP_SKILL_ACTIVATE, + ctx->session_id, + skill_id_buf, + output, + output_size); +} + +static esp_err_t cap_skill_deactivate_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + cJSON *skill_id_item = NULL; + char skill_id_buf[64] = {0}; + esp_err_t err; + + if (!ctx || !ctx->session_id || !ctx->session_id[0] || !output || output_size == 0) { + return ESP_ERR_INVALID_STATE; + } + + root = cJSON_Parse(input_json ? input_json : "{}"); + skill_id_item = root ? cJSON_GetObjectItemCaseSensitive(root, "skill_id") : NULL; + if (!cJSON_IsString(skill_id_item) || !skill_id_item->valuestring || !skill_id_item->valuestring[0]) { + cJSON_Delete(root); + snprintf(output, output_size, "{\"ok\":false,\"error\":\"skill_id is required\"}"); + return ESP_ERR_INVALID_ARG; + } + snprintf(skill_id_buf, sizeof(skill_id_buf), "%s", skill_id_item->valuestring); + + if (strcmp(skill_id_buf, "all") == 0) { + err = claw_skill_clear_active_for_session(ctx->session_id); + } else { + err = claw_skill_deactivate_for_session(ctx->session_id, skill_id_buf); + } + cJSON_Delete(root); + if (err != ESP_OK) { + snprintf(output, + output_size, + "{\"ok\":false,\"error\":\"failed to deactivate skill\",\"skill_id\":\"%s\"}", + skill_id_buf); + return err; + } + + return cap_skill_build_result(CAP_SKILL_DEACTIVATE, + ctx->session_id, + skill_id_buf, + output, + output_size); +} + +static const claw_cap_descriptor_t s_skill_descriptors[] = { + { + .id = "activate_skill", + .name = "activate_skill", + .family = "skill", + .description = "Activate a skill by skill_id for the current session and load its skill documentation into the prompt.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"skill_id\":{\"type\":\"string\"}},\"required\":[\"skill_id\"]}", + .execute = cap_skill_activate_execute, + }, + { + .id = "deactivate_skill", + .name = "deactivate_skill", + .family = "skill", + .description = "Deactivate one skill by skill_id, or use all to clear active skills and remove their skill documentation from the prompt for the current session.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"skill_id\":{\"type\":\"string\"}},\"required\":[\"skill_id\"]}", + .execute = cap_skill_deactivate_execute, + }, +}; + +static const claw_cap_group_t s_skill_group = { + .group_id = "cap_skill", + .descriptors = s_skill_descriptors, + .descriptor_count = sizeof(s_skill_descriptors) / sizeof(s_skill_descriptors[0]), +}; + +esp_err_t cap_skill_register_group(void) +{ + if (claw_cap_group_exists(s_skill_group.group_id)) { + return ESP_OK; + } + + return claw_cap_register_group(&s_skill_group); +} diff --git a/components/cap_skill/src/cmd_cap_skill.c b/components/cap_skill/src/cmd_cap_skill.c new file mode 100644 index 0000000..5dd2a27 --- /dev/null +++ b/components/cap_skill/src/cmd_cap_skill.c @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cmd_cap_skill.h" + +#include +#include + +#include "argtable3/argtable3.h" +#include "claw_skill.h" +#include "esp_console.h" + +static struct { + struct arg_lit *list; + struct arg_lit *catalog; + struct arg_str *activate; + struct arg_str *deactivate; + struct arg_lit *clear; + struct arg_str *session; + struct arg_end *end; +} skill_args; + +static void free_string_array(char **items, size_t count) +{ + size_t i; + + if (!items) { + return; + } + + for (i = 0; i < count; i++) { + free(items[i]); + } + free(items); +} + +static int skill_func(int argc, char **argv) +{ + char **active_skill_ids = NULL; + size_t active_skill_count = 0; + const char *session_id; + esp_err_t err; + int nerrors = arg_parse(argc, argv, (void **)&skill_args); + int operation_count; + size_t i; + + if (nerrors != 0) { + arg_print_errors(stderr, skill_args.end, argv[0]); + return 1; + } + + operation_count = skill_args.list->count + skill_args.catalog->count + skill_args.clear->count + + skill_args.activate->count + skill_args.deactivate->count; + if (operation_count != 1) { + printf("Exactly one operation must be specified\n"); + return 1; + } + + session_id = skill_args.session->count ? skill_args.session->sval[0] : "default"; + + if (skill_args.catalog->count) { + char *buf = calloc(1, 4096); + + if (!buf) { + printf("Out of memory\n"); + return 1; + } + + err = claw_skill_read_skills_list(buf, 4096); + if (err != ESP_OK) { + printf("skill catalog failed: %s\n", esp_err_to_name(err)); + free(buf); + return 1; + } + + printf("%s\n", buf); + free(buf); + return 0; + } + + if (skill_args.activate->count) { + err = claw_skill_activate_for_session(session_id, skill_args.activate->sval[0]); + if (err != ESP_OK) { + printf("skill activate failed: %s\n", esp_err_to_name(err)); + return 1; + } + } else if (skill_args.deactivate->count) { + err = claw_skill_deactivate_for_session(session_id, skill_args.deactivate->sval[0]); + if (err != ESP_OK) { + printf("skill deactivate failed: %s\n", esp_err_to_name(err)); + return 1; + } + } else if (skill_args.clear->count) { + err = claw_skill_clear_active_for_session(session_id); + if (err != ESP_OK) { + printf("skill clear failed: %s\n", esp_err_to_name(err)); + return 1; + } + } + + err = claw_skill_load_active_skill_ids(session_id, &active_skill_ids, &active_skill_count); + if (err != ESP_OK && err != ESP_ERR_NOT_FOUND) { + printf("skill list failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("session=%s\n", session_id); + if (active_skill_count == 0) { + printf("(no active skills)\n"); + } else { + for (i = 0; i < active_skill_count; i++) { + printf("%s\n", active_skill_ids[i]); + } + } + + free_string_array(active_skill_ids, active_skill_count); + return 0; +} + +void register_cap_skill(void) +{ + skill_args.list = arg_lit0("l", "list", "List active skills for one session"); + skill_args.catalog = arg_lit0(NULL, "catalog", "Print the skills catalog JSON"); + skill_args.activate = arg_str0("a", "activate", "", "Activate one skill"); + skill_args.deactivate = arg_str0("d", "deactivate", "", "Deactivate one skill"); + skill_args.clear = arg_lit0(NULL, "clear", "Clear all active skills for one session"); + skill_args.session = arg_str0("s", "session", "", "Session id, defaults to 'default'"); + skill_args.end = arg_end(8); + + const esp_console_cmd_t skill_cmd = { + .command = "skill", + .help = "Skill operations.\n" + "Examples:\n" + " skill --catalog\n" + " skill --list --session default\n" + " skill --activate weather --session default\n" + " skill --deactivate weather --session default\n" + " skill --clear --session default\n", + .func = skill_func, + .argtable = &skill_args, + }; + + ESP_ERROR_CHECK(esp_console_cmd_register(&skill_cmd)); +} diff --git a/components/cap_time/CMakeLists.txt b/components/cap_time/CMakeLists.txt new file mode 100644 index 0000000..37d9215 --- /dev/null +++ b/components/cap_time/CMakeLists.txt @@ -0,0 +1,11 @@ +idf_component_register( + SRCS + "src/cap_time.c" + "src/cmd_cap_time.c" + INCLUDE_DIRS + "include" + REQUIRES + claw_cap + esp_http_client + console +) diff --git a/components/cap_time/idf_component.yml b/components/cap_time/idf_component.yml new file mode 100644 index 0000000..c093387 --- /dev/null +++ b/components/cap_time/idf_component.yml @@ -0,0 +1 @@ +dependencies: {} diff --git a/components/cap_time/include/cap_time.h b/components/cap_time/include/cap_time.h new file mode 100644 index 0000000..8afe0a5 --- /dev/null +++ b/components/cap_time/include/cap_time.h @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t cap_time_register_group(void); +esp_err_t cap_time_set_timezone(const char *timezone); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_time/include/cmd_cap_time.h b/components/cap_time/include/cmd_cap_time.h new file mode 100644 index 0000000..a8d0cb1 --- /dev/null +++ b/components/cap_time/include/cmd_cap_time.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_time(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_time/src/cap_time.c b/components/cap_time/src/cap_time.c new file mode 100644 index 0000000..f315c0e --- /dev/null +++ b/components/cap_time/src/cap_time.c @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_time.h" + +#include +#include +#include +#include +#include +#include + +#include "claw_cap.h" +#include "esp_crt_bundle.h" +#include "esp_http_client.h" +#include "esp_log.h" + +static const char *TAG = "cap_time"; + +#define CAP_TIME_SOURCE_URL "https://api.telegram.org/" +#define CAP_TIME_HTTP_TIMEOUT_MS 10000 + +static const char *s_month_names[] = { + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +}; + +typedef struct { + char timezone[64]; + char date_header[64]; +} cap_time_state_t; + +static cap_time_state_t s_time = { + .timezone = "UTC0", +}; + +static int cap_time_month_to_index(const char *month) +{ + size_t i; + + if (!month) { + return -1; + } + + for (i = 0; i < sizeof(s_month_names) / sizeof(s_month_names[0]); i++) { + if (strcmp(month, s_month_names[i]) == 0) { + return (int)i; + } + } + + return -1; +} + +static bool cap_time_parse_and_set_clock(const char *date_header, + char *output, + size_t output_size) +{ + int day = 0; + int year = 0; + int hour = 0; + int minute = 0; + int second = 0; + int month = -1; + char month_name[4] = {0}; + struct tm utc_tm = {0}; + struct timeval tv = {0}; + struct tm local_tm = {0}; + time_t epoch; + + if (!date_header || !output || output_size == 0) { + return false; + } + + if (sscanf(date_header, + "%*[^,], %d %3s %d %d:%d:%d", + &day, + month_name, + &year, + &hour, + &minute, + &second) != 6) { + return false; + } + + month = cap_time_month_to_index(month_name); + if (month < 0) { + return false; + } + + utc_tm.tm_mday = day; + utc_tm.tm_mon = month; + utc_tm.tm_year = year - 1900; + utc_tm.tm_hour = hour; + utc_tm.tm_min = minute; + utc_tm.tm_sec = second; + + setenv("TZ", "UTC0", 1); + tzset(); + epoch = mktime(&utc_tm); + if (epoch < 0) { + return false; + } + + tv.tv_sec = epoch; + if (settimeofday(&tv, NULL) != 0) { + return false; + } + + setenv("TZ", s_time.timezone[0] ? s_time.timezone : "UTC0", 1); + tzset(); + localtime_r(&epoch, &local_tm); + if (strftime(output, output_size, "%Y-%m-%d %H:%M:%S %Z (%A)", &local_tm) == 0) { + return false; + } + + return true; +} + +static esp_err_t cap_time_http_event_handler(esp_http_client_event_t *event) +{ + if (!event || !event->user_data) { + return ESP_OK; + } + + if (event->event_id == HTTP_EVENT_ON_HEADER && + event->header_key && + event->header_value && + strcasecmp(event->header_key, "Date") == 0) { + cap_time_state_t *state = (cap_time_state_t *)event->user_data; + + strlcpy(state->date_header, event->header_value, sizeof(state->date_header)); + } + + return ESP_OK; +} + +static esp_err_t cap_time_fetch_current(char *output, size_t output_size) +{ + esp_http_client_config_t config = { + .url = CAP_TIME_SOURCE_URL, + .method = HTTP_METHOD_HEAD, + .timeout_ms = CAP_TIME_HTTP_TIMEOUT_MS, + .crt_bundle_attach = esp_crt_bundle_attach, + .event_handler = cap_time_http_event_handler, + .user_data = &s_time, + }; + esp_http_client_handle_t client = NULL; + esp_err_t err; + + if (!output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + s_time.date_header[0] = '\0'; + client = esp_http_client_init(&config); + if (!client) { + return ESP_FAIL; + } + + err = esp_http_client_perform(client); + esp_http_client_cleanup(client); + if (err != ESP_OK) { + return err; + } + + if (s_time.date_header[0] == '\0') { + return ESP_ERR_NOT_FOUND; + } + + if (!cap_time_parse_and_set_clock(s_time.date_header, output, output_size)) { + return ESP_FAIL; + } + + return ESP_OK; +} + +static esp_err_t cap_time_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + esp_err_t err; + + (void)input_json; + (void)ctx; + + if (!output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + err = cap_time_fetch_current(output, output_size); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: failed to fetch time (%s)", esp_err_to_name(err)); + ESP_LOGE(TAG, "%s", output); + return err; + } + + ESP_LOGI(TAG, "Time synced: %s", output); + return ESP_OK; +} + +static const claw_cap_descriptor_t s_time_descriptors[] = { + { + .id = "get_current_time", + .name = "get_current_time", + .family = "system", + .description = "Fetch current network time, update the local clock, and return formatted local time.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = "{\"type\":\"object\"}", + .execute = cap_time_execute, + }, +}; + +static const claw_cap_group_t s_time_group = { + .group_id = "cap_time", + .descriptors = s_time_descriptors, + .descriptor_count = sizeof(s_time_descriptors) / sizeof(s_time_descriptors[0]), +}; + +esp_err_t cap_time_register_group(void) +{ + if (claw_cap_group_exists(s_time_group.group_id)) { + return ESP_OK; + } + + return claw_cap_register_group(&s_time_group); +} + +esp_err_t cap_time_set_timezone(const char *timezone) +{ + if (!timezone || !timezone[0]) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(s_time.timezone, timezone, sizeof(s_time.timezone)); + setenv("TZ", s_time.timezone, 1); + tzset(); + return ESP_OK; +} diff --git a/components/cap_time/src/cmd_cap_time.c b/components/cap_time/src/cmd_cap_time.c new file mode 100644 index 0000000..cf10886 --- /dev/null +++ b/components/cap_time/src/cmd_cap_time.c @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cmd_cap_time.h" + +#include +#include + +#include "argtable3/argtable3.h" +#include "cap_time.h" +#include "claw_cap.h" +#include "esp_console.h" + +static struct { + struct arg_lit *now; + struct arg_lit *set_timezone; + struct arg_str *timezone; + struct arg_end *end; +} time_args; + +static int time_func(int argc, char **argv) +{ + char output[256] = {0}; + esp_err_t err; + claw_cap_call_context_t ctx = { + .caller = CLAW_CAP_CALLER_CONSOLE, + }; + int nerrors = arg_parse(argc, argv, (void **)&time_args); + int operation_count; + + if (nerrors != 0) { + arg_print_errors(stderr, time_args.end, argv[0]); + return 1; + } + + operation_count = time_args.now->count + time_args.set_timezone->count; + if (operation_count != 1) { + printf("Exactly one operation must be specified\n"); + return 1; + } + + if (time_args.set_timezone->count) { + if (!time_args.timezone->count) { + printf("'--set-timezone' requires '--timezone'\n"); + return 1; + } + + err = cap_time_set_timezone(time_args.timezone->sval[0]); + if (err != ESP_OK) { + printf("time set-timezone failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("Timezone updated to %s\n", time_args.timezone->sval[0]); + return 0; + } + + err = claw_cap_call("get_current_time", "{}", &ctx, output, sizeof(output)); + if (err != ESP_OK) { + printf("%s\n", output[0] ? output : esp_err_to_name(err)); + return 1; + } + + printf("%s\n", output); + return 0; +} + +void register_cap_time(void) +{ + time_args.now = arg_lit0(NULL, "now", "Fetch current network time and sync the local clock"); + time_args.set_timezone = arg_lit0(NULL, "set-timezone", "Set local timezone"); + time_args.timezone = arg_str0("t", "timezone", "", "Timezone string, for example CST-8"); + time_args.end = arg_end(4); + + const esp_console_cmd_t time_cmd = { + .command = "time", + .help = "Time operations.\n" + "Examples:\n" + " time --now\n" + " time --set-timezone --timezone CST-8\n", + .func = time_func, + .argtable = &time_args, + }; + + ESP_ERROR_CHECK(esp_console_cmd_register(&time_cmd)); +} diff --git a/components/cap_web_search/CMakeLists.txt b/components/cap_web_search/CMakeLists.txt new file mode 100644 index 0000000..fdae32c --- /dev/null +++ b/components/cap_web_search/CMakeLists.txt @@ -0,0 +1,12 @@ +idf_component_register( + SRCS + "src/cap_web_search.c" + "src/cmd_cap_web_search.c" + INCLUDE_DIRS + "include" + REQUIRES + claw_cap + esp_http_client + json + console +) diff --git a/components/cap_web_search/idf_component.yml b/components/cap_web_search/idf_component.yml new file mode 100644 index 0000000..c093387 --- /dev/null +++ b/components/cap_web_search/idf_component.yml @@ -0,0 +1 @@ +dependencies: {} diff --git a/components/cap_web_search/include/cap_web_search.h b/components/cap_web_search/include/cap_web_search.h new file mode 100644 index 0000000..bc2bdee --- /dev/null +++ b/components/cap_web_search/include/cap_web_search.h @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t cap_web_search_register_group(void); +esp_err_t cap_web_search_set_brave_key(const char *api_key); +esp_err_t cap_web_search_set_tavily_key(const char *api_key); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_web_search/include/cmd_cap_web_search.h b/components/cap_web_search/include/cmd_cap_web_search.h new file mode 100644 index 0000000..a7ba327 --- /dev/null +++ b/components/cap_web_search/include/cmd_cap_web_search.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_web_search(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/cap_web_search/src/cap_web_search.c b/components/cap_web_search/src/cap_web_search.c new file mode 100644 index 0000000..16ec3d1 --- /dev/null +++ b/components/cap_web_search/src/cap_web_search.c @@ -0,0 +1,455 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cap_web_search.h" + +#include +#include +#include +#include + +#include "cJSON.h" +#include "claw_cap.h" +#include "esp_crt_bundle.h" +#include "esp_http_client.h" +#include "esp_log.h" + +static const char *TAG = "cap_web_search"; + +#define CAP_WEB_SEARCH_BUF_SIZE (16 * 1024) +#define CAP_WEB_SEARCH_RESULT_COUNT 5 + +typedef enum { + CAP_WEB_SEARCH_PROVIDER_NONE = 0, + CAP_WEB_SEARCH_PROVIDER_BRAVE, + CAP_WEB_SEARCH_PROVIDER_TAVILY, +} cap_web_search_provider_t; + +typedef struct { + char *data; + size_t len; + size_t cap; +} cap_web_search_buf_t; + +typedef struct { + char brave_key[128]; + char tavily_key[128]; + cap_web_search_provider_t provider; +} cap_web_search_state_t; + +static cap_web_search_state_t s_search = {0}; + +static void cap_web_search_refresh_provider(void) +{ + if (s_search.tavily_key[0]) { + s_search.provider = CAP_WEB_SEARCH_PROVIDER_TAVILY; + } else if (s_search.brave_key[0]) { + s_search.provider = CAP_WEB_SEARCH_PROVIDER_BRAVE; + } else { + s_search.provider = CAP_WEB_SEARCH_PROVIDER_NONE; + } +} + +static esp_err_t cap_web_search_http_event_handler(esp_http_client_event_t *event) +{ + cap_web_search_buf_t *buf = NULL; + + if (!event || event->event_id != HTTP_EVENT_ON_DATA || event->data_len <= 0) { + return ESP_OK; + } + + buf = (cap_web_search_buf_t *)event->user_data; + if (!buf || !buf->data) { + return ESP_OK; + } + + if (buf->len + (size_t)event->data_len + 1 > buf->cap) { + size_t new_cap = buf->cap * 2; + char *new_data = NULL; + + if (new_cap < buf->len + (size_t)event->data_len + 1) { + new_cap = buf->len + (size_t)event->data_len + 1; + } + + new_data = realloc(buf->data, new_cap); + if (!new_data) { + return ESP_ERR_NO_MEM; + } + + buf->data = new_data; + buf->cap = new_cap; + } + + memcpy(buf->data + buf->len, event->data, event->data_len); + buf->len += (size_t)event->data_len; + buf->data[buf->len] = '\0'; + return ESP_OK; +} + +static size_t cap_web_search_url_encode(const char *src, char *dst, size_t dst_size) +{ + static const char hex[] = "0123456789ABCDEF"; + size_t pos = 0; + + if (!src || !dst || dst_size == 0) { + return 0; + } + + while (*src && pos < dst_size - 1) { + unsigned char c = (unsigned char) * src; + + if ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~') { + dst[pos++] = (char)c; + } else if (c == ' ') { + dst[pos++] = '+'; + } else { + if (pos + 3 >= dst_size) { + break; + } + dst[pos++] = '%'; + dst[pos++] = hex[c >> 4]; + dst[pos++] = hex[c & 0x0F]; + } + src++; + } + + dst[pos] = '\0'; + return pos; +} + +static void cap_web_search_format_brave_results(cJSON *root, + char *output, + size_t output_size) +{ + cJSON *web = NULL; + cJSON *results = NULL; + cJSON *item = NULL; + size_t offset = 0; + int index = 0; + + web = cJSON_GetObjectItem(root, "web"); + results = web ? cJSON_GetObjectItem(web, "results") : NULL; + if (!cJSON_IsArray(results) || cJSON_GetArraySize(results) == 0) { + snprintf(output, output_size, "No web results found."); + return; + } + + cJSON_ArrayForEach(item, results) { + cJSON *title = NULL; + cJSON *url = NULL; + cJSON *description = NULL; + int written; + + if (index >= CAP_WEB_SEARCH_RESULT_COUNT || offset >= output_size - 1) { + break; + } + + title = cJSON_GetObjectItem(item, "title"); + url = cJSON_GetObjectItem(item, "url"); + description = cJSON_GetObjectItem(item, "description"); + written = snprintf(output + offset, + output_size - offset, + "%d. %s\n %s\n %s\n\n", + index + 1, + cJSON_IsString(title) ? title->valuestring : "(no title)", + cJSON_IsString(url) ? url->valuestring : "", + cJSON_IsString(description) ? description->valuestring : ""); + if (written < 0 || (size_t)written >= output_size - offset) { + output[output_size - 1] = '\0'; + return; + } + + offset += (size_t)written; + index++; + } +} + +static void cap_web_search_format_tavily_results(cJSON *root, + char *output, + size_t output_size) +{ + cJSON *results = NULL; + cJSON *item = NULL; + size_t offset = 0; + int index = 0; + + results = cJSON_GetObjectItem(root, "results"); + if (!cJSON_IsArray(results) || cJSON_GetArraySize(results) == 0) { + snprintf(output, output_size, "No web results found."); + return; + } + + cJSON_ArrayForEach(item, results) { + cJSON *title = NULL; + cJSON *url = NULL; + cJSON *content = NULL; + int written; + + if (index >= CAP_WEB_SEARCH_RESULT_COUNT || offset >= output_size - 1) { + break; + } + + title = cJSON_GetObjectItem(item, "title"); + url = cJSON_GetObjectItem(item, "url"); + content = cJSON_GetObjectItem(item, "content"); + written = snprintf(output + offset, + output_size - offset, + "%d. %s\n %s\n %s\n\n", + index + 1, + cJSON_IsString(title) ? title->valuestring : "(no title)", + cJSON_IsString(url) ? url->valuestring : "", + cJSON_IsString(content) ? content->valuestring : ""); + if (written < 0 || (size_t)written >= output_size - offset) { + output[output_size - 1] = '\0'; + return; + } + + offset += (size_t)written; + index++; + } +} + +static char *cap_web_search_build_tavily_payload(const char *query) +{ + cJSON *root = NULL; + char *payload = NULL; + + root = cJSON_CreateObject(); + if (!root) { + return NULL; + } + + cJSON_AddStringToObject(root, "query", query); + cJSON_AddNumberToObject(root, "max_results", CAP_WEB_SEARCH_RESULT_COUNT); + cJSON_AddBoolToObject(root, "include_answer", false); + cJSON_AddStringToObject(root, "search_depth", "basic"); + payload = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + return payload; +} + +static esp_err_t cap_web_search_brave_direct(const char *url, + cap_web_search_buf_t *buf) +{ + esp_http_client_config_t config = { + .url = url, + .event_handler = cap_web_search_http_event_handler, + .user_data = buf, + .timeout_ms = 15000, + .buffer_size = 4096, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + esp_http_client_handle_t client = NULL; + esp_err_t err; + int status; + + client = esp_http_client_init(&config); + if (!client) { + return ESP_FAIL; + } + + esp_http_client_set_header(client, "Accept", "application/json"); + esp_http_client_set_header(client, "X-Subscription-Token", s_search.brave_key); + err = esp_http_client_perform(client); + status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + if (err != ESP_OK) { + return err; + } + + if (status != 200) { + ESP_LOGE(TAG, "Brave search returned %d", status); + return ESP_FAIL; + } + + return ESP_OK; +} + +static esp_err_t cap_web_search_tavily_direct(const char *query, + cap_web_search_buf_t *buf) +{ + esp_http_client_config_t config = { + .url = "https://api.tavily.com/search", + .event_handler = cap_web_search_http_event_handler, + .user_data = buf, + .timeout_ms = 15000, + .buffer_size = 4096, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + esp_http_client_handle_t client = NULL; + char auth[192]; + char *payload = NULL; + esp_err_t err; + int status; + + payload = cap_web_search_build_tavily_payload(query); + if (!payload) { + return ESP_ERR_NO_MEM; + } + + client = esp_http_client_init(&config); + if (!client) { + free(payload); + return ESP_FAIL; + } + + snprintf(auth, sizeof(auth), "Bearer %s", s_search.tavily_key); + esp_http_client_set_method(client, HTTP_METHOD_POST); + esp_http_client_set_header(client, "Accept", "application/json"); + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_http_client_set_header(client, "Authorization", auth); + esp_http_client_set_post_field(client, payload, strlen(payload)); + err = esp_http_client_perform(client); + status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + free(payload); + if (err != ESP_OK) { + return err; + } + + if (status != 200) { + ESP_LOGE(TAG, "Tavily search returned %d", status); + return ESP_FAIL; + } + + return ESP_OK; +} + +static esp_err_t cap_web_search_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *input = NULL; + cJSON *query = NULL; + cap_web_search_buf_t buf = {0}; + cJSON *root = NULL; + esp_err_t err = ESP_OK; + + (void)ctx; + + if (!output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + cap_web_search_refresh_provider(); + if (s_search.provider == CAP_WEB_SEARCH_PROVIDER_NONE) { + snprintf(output, output_size, "Error: no search provider credentials configured"); + return ESP_ERR_INVALID_STATE; + } + + input = cJSON_Parse(input_json); + if (!input) { + snprintf(output, output_size, "Error: invalid input JSON"); + return ESP_ERR_INVALID_ARG; + } + + query = cJSON_GetObjectItem(input, "query"); + if (!cJSON_IsString(query) || !query->valuestring || !query->valuestring[0]) { + cJSON_Delete(input); + snprintf(output, output_size, "Error: missing query"); + return ESP_ERR_INVALID_ARG; + } + + buf.data = calloc(1, CAP_WEB_SEARCH_BUF_SIZE); + if (!buf.data) { + cJSON_Delete(input); + snprintf(output, output_size, "Error: out of memory"); + return ESP_ERR_NO_MEM; + } + buf.cap = CAP_WEB_SEARCH_BUF_SIZE; + + if (s_search.provider == CAP_WEB_SEARCH_PROVIDER_TAVILY) { + err = cap_web_search_tavily_direct(query->valuestring, &buf); + } else { + char encoded_query[256]; + char url[512]; + + cap_web_search_url_encode(query->valuestring, encoded_query, sizeof(encoded_query)); + snprintf(url, + sizeof(url), + "https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", + encoded_query, + CAP_WEB_SEARCH_RESULT_COUNT); + err = cap_web_search_brave_direct(url, &buf); + } + + cJSON_Delete(input); + if (err != ESP_OK) { + free(buf.data); + snprintf(output, output_size, "Error: search request failed (%s)", esp_err_to_name(err)); + return err; + } + + root = cJSON_Parse(buf.data); + free(buf.data); + if (!root) { + snprintf(output, output_size, "Error: failed to parse search results"); + return ESP_FAIL; + } + + if (s_search.provider == CAP_WEB_SEARCH_PROVIDER_TAVILY) { + cap_web_search_format_tavily_results(root, output, output_size); + } else { + cap_web_search_format_brave_results(root, output, output_size); + } + cJSON_Delete(root); + return ESP_OK; +} + +static const claw_cap_descriptor_t s_web_search_descriptors[] = { + { + .id = "web_search", + .name = "web_search", + .family = "system", + .description = "Search the web with the configured provider and return concise formatted results.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}},\"required\":[\"query\"]}", + .execute = cap_web_search_execute, + }, +}; + +static const claw_cap_group_t s_web_search_group = { + .group_id = "cap_web_search", + .descriptors = s_web_search_descriptors, + .descriptor_count = sizeof(s_web_search_descriptors) / sizeof(s_web_search_descriptors[0]), +}; + +esp_err_t cap_web_search_register_group(void) +{ + if (claw_cap_group_exists(s_web_search_group.group_id)) { + return ESP_OK; + } + + cap_web_search_refresh_provider(); + return claw_cap_register_group(&s_web_search_group); +} + +esp_err_t cap_web_search_set_brave_key(const char *api_key) +{ + if (!api_key) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(s_search.brave_key, api_key, sizeof(s_search.brave_key)); + cap_web_search_refresh_provider(); + return ESP_OK; +} + +esp_err_t cap_web_search_set_tavily_key(const char *api_key) +{ + if (!api_key) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(s_search.tavily_key, api_key, sizeof(s_search.tavily_key)); + cap_web_search_refresh_provider(); + return ESP_OK; +} diff --git a/components/cap_web_search/src/cmd_cap_web_search.c b/components/cap_web_search/src/cmd_cap_web_search.c new file mode 100644 index 0000000..ca778c2 --- /dev/null +++ b/components/cap_web_search/src/cmd_cap_web_search.c @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cmd_cap_web_search.h" + +#include +#include + +#include "argtable3/argtable3.h" +#include "cJSON.h" +#include "claw_cap.h" +#include "esp_console.h" + +static struct { + struct arg_str *query; + struct arg_end *end; +} web_search_args; + +static int web_search_func(int argc, char **argv) +{ + cJSON *root = NULL; + char *input_json = NULL; + char *output = NULL; + esp_err_t err; + claw_cap_call_context_t ctx = { + .caller = CLAW_CAP_CALLER_CONSOLE, + }; + int nerrors = arg_parse(argc, argv, (void **)&web_search_args); + + if (nerrors != 0) { + arg_print_errors(stderr, web_search_args.end, argv[0]); + return 1; + } + + if (!web_search_args.query->count) { + printf("'--query' is required\n"); + return 1; + } + + root = cJSON_CreateObject(); + if (!root) { + printf("Out of memory\n"); + return 1; + } + + cJSON_AddStringToObject(root, "query", web_search_args.query->sval[0]); + input_json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!input_json) { + printf("Out of memory\n"); + return 1; + } + + output = calloc(1, 4096); + if (!output) { + free(input_json); + printf("Out of memory\n"); + return 1; + } + + err = claw_cap_call("web_search", input_json, &ctx, output, 4096); + if (err != ESP_OK) { + printf("%s\n", output[0] ? output : esp_err_to_name(err)); + } else { + printf("%s\n", output); + } + + free(output); + free(input_json); + return err == ESP_OK ? 0 : 1; +} + +void register_cap_web_search(void) +{ + web_search_args.query = arg_str1("q", "query", "", "Search query"); + web_search_args.end = arg_end(4); + + const esp_console_cmd_t web_search_cmd = { + .command = "web_search", + .help = "Web search operation.\n" + "Example:\n" + " web_search --query \"ESP-IDF mDNS example\"\n", + .func = web_search_func, + .argtable = &web_search_args, + }; + + ESP_ERROR_CHECK(esp_console_cmd_register(&web_search_cmd)); +} diff --git a/components/claw_cap/CMakeLists.txt b/components/claw_cap/CMakeLists.txt new file mode 100644 index 0000000..49e90f6 --- /dev/null +++ b/components/claw_cap/CMakeLists.txt @@ -0,0 +1,11 @@ +idf_component_register( + SRCS + "src/claw_cap.c" + INCLUDE_DIRS + "include" + REQUIRES + claw_core + claw_skill + freertos + json +) diff --git a/components/claw_cap/include/claw_cap.h b/components/claw_cap/include/claw_cap.h new file mode 100644 index 0000000..bb4f8a9 --- /dev/null +++ b/components/claw_cap/include/claw_cap.h @@ -0,0 +1,160 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include +#include + +#include "claw_core.h" +#include "esp_err.h" +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + CLAW_CAP_KIND_CALLABLE = 0, + CLAW_CAP_KIND_EVENT_SOURCE = 1, + CLAW_CAP_KIND_HYBRID = 2, +} claw_cap_kind_t; + +typedef enum { + CLAW_CAP_CALLER_SYSTEM = 0, + CLAW_CAP_CALLER_AGENT = 1, + CLAW_CAP_CALLER_CONSOLE = 2, +} claw_cap_caller_t; + +typedef enum { + CLAW_CAP_FLAG_CALLABLE_BY_LLM = 1 << 0, + CLAW_CAP_FLAG_EMITS_EVENTS = 1 << 1, + CLAW_CAP_FLAG_SUPPORTS_LIFECYCLE = 1 << 2, + CLAW_CAP_FLAG_RESTRICTED = 1 << 3, +} claw_cap_flags_t; + +typedef enum { + CLAW_CAP_STATE_REGISTERED = 0, + CLAW_CAP_STATE_STARTED = 1, + CLAW_CAP_STATE_DISABLED = 2, + CLAW_CAP_STATE_DRAINING = 3, + CLAW_CAP_STATE_UNLOADING = 4, +} claw_cap_state_t; + +typedef struct { + const char *session_id; + const char *channel; + const char *chat_id; + const char *source_cap; + const char *correlation_id; + claw_cap_caller_t caller; +} claw_cap_call_context_t; + +typedef enum { + CLAW_CAP_EVENT_ROUTE_PASS = 0, + CLAW_CAP_EVENT_ROUTE_CONSUMED = 1, + CLAW_CAP_EVENT_ROUTE_ERROR = 2, +} claw_cap_event_route_t; + +typedef esp_err_t (*claw_cap_lifecycle_fn)(void); +typedef esp_err_t (*claw_cap_execute_fn)(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size); + +typedef struct { + const char *id; + const char *name; + const char *family; + const char *description; + claw_cap_kind_t kind; + uint32_t cap_flags; + const char *input_schema_json; + claw_cap_lifecycle_fn init; + claw_cap_lifecycle_fn start; + claw_cap_lifecycle_fn stop; + claw_cap_execute_fn execute; +} claw_cap_descriptor_t; + +typedef struct { + const claw_cap_descriptor_t *items; + size_t count; +} claw_cap_list_t; + +typedef struct { + const char *group_id; + const char *plugin_name; + const char *version; + const claw_cap_descriptor_t *descriptors; + size_t descriptor_count; + void *plugin_ctx; + claw_cap_lifecycle_fn group_init; + claw_cap_lifecycle_fn group_start; + claw_cap_lifecycle_fn group_stop; +} claw_cap_group_t; + +typedef struct { + const char *group_id; + const char *plugin_name; + const char *version; + claw_cap_state_t state; + size_t descriptor_count; +} claw_cap_group_info_t; + +typedef struct { + const claw_cap_group_info_t *items; + size_t count; +} claw_cap_group_list_t; + +typedef struct { + const char *id; + const char *name; + const char *group_id; + claw_cap_state_t state; + uint32_t active_calls; +} claw_cap_descriptor_info_t; + +typedef struct { + size_t max_capabilities; + size_t max_groups; +} claw_cap_config_t; + +esp_err_t claw_cap_init(const claw_cap_config_t *config); +esp_err_t claw_cap_register(const claw_cap_descriptor_t *descriptor); +esp_err_t claw_cap_register_group(const claw_cap_group_t *group); +esp_err_t claw_cap_start_all(void); +esp_err_t claw_cap_stop_all(void); +esp_err_t claw_cap_enable_group(const char *group_id); +esp_err_t claw_cap_disable_group(const char *group_id); +esp_err_t claw_cap_unregister_group(const char *group_id, uint32_t timeout_ms); +esp_err_t claw_cap_unregister(const char *id_or_name, uint32_t timeout_ms); +esp_err_t claw_cap_set_llm_visible_groups(const char *const *group_ids, size_t count); +bool claw_cap_group_exists(const char *group_id); +esp_err_t claw_cap_get_group_state(const char *group_id, claw_cap_state_t *state); +esp_err_t claw_cap_get_descriptor_state(const char *id_or_name, + claw_cap_descriptor_info_t *info); +const claw_cap_descriptor_t *claw_cap_find(const char *id_or_name); +claw_cap_list_t claw_cap_list(void); +claw_cap_group_list_t claw_cap_list_groups(void); +esp_err_t claw_cap_call(const char *id_or_name, + const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size); +esp_err_t claw_cap_call_from_core(const char *cap_name, + const char *input_json, + const claw_core_request_t *request, + char **out_output, + void *user_ctx); +char *claw_cap_build_llm_tools_json(const claw_cap_call_context_t *ctx, + bool wrap_for_responses_api); +char *claw_cap_build_catalog(void); +const char *claw_cap_state_to_string(claw_cap_state_t state); + +/* Exposes all LLM-visible tools for one request. */ +extern const claw_core_context_provider_t claw_cap_tools_provider; + +#ifdef __cplusplus +} +#endif diff --git a/components/claw_cap/src/claw_cap.c b/components/claw_cap/src/claw_cap.c new file mode 100644 index 0000000..e533f17 --- /dev/null +++ b/components/claw_cap/src/claw_cap.c @@ -0,0 +1,1414 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "claw_cap.h" + +#include +#include +#include +#include + +#include "cJSON.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +static const char *TAG = "claw_cap"; + +#define CLAW_CAP_DEFAULT_MAX_CAPABILITIES 16 +#define CLAW_CAP_DEFAULT_MAX_GROUPS 8 +#define CLAW_CAP_UNLOAD_POLL_MS 20 + +typedef struct { + bool occupied; + const claw_cap_group_t *group; + claw_cap_state_t state; + size_t *member_slots; + size_t member_count; + bool group_init_called; +} claw_cap_group_slot_t; + +typedef struct { + bool occupied; + claw_cap_descriptor_t descriptor; + size_t group_slot_index; + claw_cap_state_t state; + bool init_called; + uint32_t active_calls; +} claw_cap_descriptor_slot_t; + +typedef struct { + bool initialized; + bool started; + SemaphoreHandle_t mutex; + claw_cap_descriptor_slot_t *descriptor_slots; + claw_cap_group_slot_t *group_slots; + claw_cap_descriptor_t *descriptor_list_snapshot; + claw_cap_group_info_t *group_list_snapshot; + char **llm_visible_group_ids; + size_t llm_visible_group_count; + size_t descriptor_capacity; + size_t group_capacity; +} claw_cap_runtime_t; + +static claw_cap_runtime_t s_runtime = {0}; + +static bool claw_cap_is_llm_visible(const claw_cap_descriptor_slot_t *slot); +static bool claw_cap_group_is_llm_visible_locked(size_t group_slot_index); +static void claw_cap_clear_llm_visible_groups_locked(void); + +static char *claw_cap_strdup(const char *src) +{ + size_t len; + char *copy; + + if (!src) { + return NULL; + } + + len = strlen(src) + 1; + copy = malloc(len); + if (!copy) { + return NULL; + } + + memcpy(copy, src, len); + return copy; +} + +static void claw_cap_lock(void) +{ + xSemaphoreTake(s_runtime.mutex, portMAX_DELAY); +} + +static void claw_cap_unlock(void) +{ + xSemaphoreGive(s_runtime.mutex); +} + +char *claw_cap_build_llm_tools_json(const claw_cap_call_context_t *ctx, + bool wrap_for_responses_api) +{ + char *raw_tools_json = NULL; + cJSON *raw_tools = NULL; + cJSON *wrapped_tools = NULL; + cJSON *raw_tool = NULL; + + (void)ctx; + + if (!s_runtime.initialized) { + return NULL; + } + + raw_tools = cJSON_CreateArray(); + if (!raw_tools) { + return NULL; + } + + claw_cap_lock(); + for (size_t i = 0; i < s_runtime.descriptor_capacity; i++) { + claw_cap_descriptor_slot_t *slot = &s_runtime.descriptor_slots[i]; + cJSON *item = NULL; + cJSON *schema = NULL; + + if (!claw_cap_is_llm_visible(slot)) { + continue; + } + + item = cJSON_CreateObject(); + if (!item) { + claw_cap_unlock(); + cJSON_Delete(raw_tools); + return NULL; + } + + cJSON_AddStringToObject(item, "name", slot->descriptor.name); + cJSON_AddStringToObject(item, "description", + slot->descriptor.description ? slot->descriptor.description : ""); + schema = cJSON_Parse(slot->descriptor.input_schema_json ? + slot->descriptor.input_schema_json : "{\"type\":\"object\"}"); + if (!schema) { + schema = cJSON_CreateObject(); + } + cJSON_AddItemToObject(item, "input_schema", schema); + cJSON_AddItemToArray(raw_tools, item); + } + claw_cap_unlock(); + + if (!wrap_for_responses_api) { + raw_tools_json = cJSON_PrintUnformatted(raw_tools); + cJSON_Delete(raw_tools); + return raw_tools_json; + } + + wrapped_tools = cJSON_CreateArray(); + if (!wrapped_tools) { + cJSON_Delete(raw_tools); + return NULL; + } + + cJSON_ArrayForEach(raw_tool, raw_tools) { + cJSON *tool = cJSON_CreateObject(); + cJSON *function = cJSON_CreateObject(); + cJSON *name = cJSON_GetObjectItem(raw_tool, "name"); + cJSON *description = cJSON_GetObjectItem(raw_tool, "description"); + cJSON *input_schema = cJSON_GetObjectItem(raw_tool, "input_schema"); + cJSON *parameters = NULL; + + if (!tool || !function || !cJSON_IsString(name)) { + cJSON_Delete(tool); + cJSON_Delete(function); + cJSON_Delete(wrapped_tools); + cJSON_Delete(raw_tools); + return NULL; + } + + cJSON_AddStringToObject(tool, "type", "function"); + cJSON_AddStringToObject(function, "name", name->valuestring); + cJSON_AddStringToObject(function, "description", + cJSON_IsString(description) ? description->valuestring : ""); + parameters = input_schema ? cJSON_Duplicate(input_schema, true) : cJSON_CreateObject(); + if (!parameters) { + cJSON_Delete(tool); + cJSON_Delete(function); + cJSON_Delete(wrapped_tools); + cJSON_Delete(raw_tools); + return NULL; + } + cJSON_AddItemToObject(function, "parameters", parameters); + cJSON_AddItemToObject(tool, "function", function); + cJSON_AddItemToArray(wrapped_tools, tool); + } + + raw_tools_json = cJSON_PrintUnformatted(wrapped_tools); + cJSON_Delete(wrapped_tools); + cJSON_Delete(raw_tools); + return raw_tools_json; +} + +esp_err_t claw_cap_call_from_core(const char *cap_name, + const char *input_json, + const claw_core_request_t *request, + char **out_output, + void *user_ctx) +{ + claw_cap_call_context_t ctx = {0}; + char *output = NULL; + esp_err_t err; + const size_t output_size = 4096; + + (void)user_ctx; + + if (!cap_name || !out_output) { + return ESP_ERR_INVALID_ARG; + } + *out_output = NULL; + + output = calloc(1, output_size); + if (!output) { + return ESP_ERR_NO_MEM; + } + + if (request) { + ctx.session_id = request->session_id; + ctx.channel = request->source_channel; + ctx.chat_id = request->source_chat_id; + ctx.source_cap = request->source_cap; + ctx.caller = CLAW_CAP_CALLER_AGENT; + } + + err = claw_cap_call(cap_name, + input_json ? input_json : "{}", + &ctx, + output, + output_size); + if (err != ESP_OK && !output[0]) { + snprintf(output, output_size, "%s", esp_err_to_name(err)); + } + + *out_output = output; + return err; +} + +static esp_err_t claw_cap_tools_collect(const claw_core_request_t *request, + claw_core_context_t *out_context, + void *user_ctx) +{ + claw_cap_call_context_t ctx = {0}; + char *tools_json = NULL; + + (void)user_ctx; + + if (!request || !out_context) { + return ESP_ERR_INVALID_ARG; + } + + ctx.session_id = request->session_id; + ctx.channel = request->source_channel; + ctx.chat_id = request->source_chat_id; + ctx.source_cap = request->source_cap; + ctx.caller = CLAW_CAP_CALLER_AGENT; + + tools_json = claw_cap_build_llm_tools_json(&ctx, true); + if (!tools_json || !tools_json[0] || strcmp(tools_json, "[]") == 0) { + free(tools_json); + return ESP_ERR_NOT_FOUND; + } + + memset(out_context, 0, sizeof(*out_context)); + out_context->kind = CLAW_CORE_CONTEXT_KIND_TOOLS; + out_context->content = tools_json; + return ESP_OK; +} + +const claw_core_context_provider_t claw_cap_tools_provider = { + .name = "cap Tools", + .collect = claw_cap_tools_collect, + .user_ctx = NULL, +}; + +const char *claw_cap_state_to_string(claw_cap_state_t state) +{ + switch (state) { + case CLAW_CAP_STATE_REGISTERED: + return "registered"; + case CLAW_CAP_STATE_STARTED: + return "started"; + case CLAW_CAP_STATE_DISABLED: + return "disabled"; + case CLAW_CAP_STATE_DRAINING: + return "draining"; + case CLAW_CAP_STATE_UNLOADING: + return "unloading"; + default: + return "unknown"; + } +} + +static bool claw_cap_descriptor_is_available( + const claw_cap_descriptor_slot_t *slot) +{ + return slot && slot->occupied && + (slot->state == CLAW_CAP_STATE_REGISTERED || + slot->state == CLAW_CAP_STATE_STARTED); +} + +static bool claw_cap_descriptor_is_listable( + const claw_cap_descriptor_slot_t *slot) +{ + return claw_cap_descriptor_is_available(slot); +} + +static bool claw_cap_is_llm_visible( + const claw_cap_descriptor_slot_t *slot) +{ + if (!claw_cap_descriptor_is_available(slot)) { + return false; + } + if (!slot->descriptor.execute) { + return false; + } + if (slot->descriptor.kind != CLAW_CAP_KIND_CALLABLE && + slot->descriptor.kind != CLAW_CAP_KIND_HYBRID) { + return false; + } + if (!(slot->descriptor.cap_flags & CLAW_CAP_FLAG_CALLABLE_BY_LLM)) { + return false; + } + if (!claw_cap_group_is_llm_visible_locked(slot->group_slot_index)) { + return false; + } + return true; +} + +static bool claw_cap_group_is_llm_visible_locked(size_t group_slot_index) +{ + const char *group_id = NULL; + size_t i; + + if (s_runtime.llm_visible_group_count == 0) { + return true; + } + if (group_slot_index >= s_runtime.group_capacity || + !s_runtime.group_slots[group_slot_index].occupied || + !s_runtime.group_slots[group_slot_index].group || + !s_runtime.group_slots[group_slot_index].group->group_id) { + return false; + } + + group_id = s_runtime.group_slots[group_slot_index].group->group_id; + for (i = 0; i < s_runtime.llm_visible_group_count; i++) { + if (s_runtime.llm_visible_group_ids[i] && + strcmp(s_runtime.llm_visible_group_ids[i], group_id) == 0) { + return true; + } + } + + return false; +} + +static void claw_cap_clear_llm_visible_groups_locked(void) +{ + size_t i; + + if (!s_runtime.llm_visible_group_ids) { + s_runtime.llm_visible_group_count = 0; + return; + } + + for (i = 0; i < s_runtime.llm_visible_group_count; i++) { + free(s_runtime.llm_visible_group_ids[i]); + } + free(s_runtime.llm_visible_group_ids); + s_runtime.llm_visible_group_ids = NULL; + s_runtime.llm_visible_group_count = 0; +} + +static esp_err_t claw_cap_validate_descriptor( + const claw_cap_descriptor_t *descriptor) +{ + if (!descriptor || !descriptor->id || !descriptor->id[0] || + !descriptor->name || !descriptor->name[0]) { + return ESP_ERR_INVALID_ARG; + } + + if ((descriptor->kind == CLAW_CAP_KIND_CALLABLE || + descriptor->kind == CLAW_CAP_KIND_HYBRID) && + (descriptor->cap_flags & CLAW_CAP_FLAG_CALLABLE_BY_LLM) && + !descriptor->execute) { + return ESP_ERR_INVALID_ARG; + } + + return ESP_OK; +} + +static ssize_t claw_cap_find_group_slot_index_locked(const char *group_id) +{ + size_t i; + + if (!group_id || !group_id[0]) { + return -1; + } + + for (i = 0; i < s_runtime.group_capacity; i++) { + if (s_runtime.group_slots[i].occupied && + s_runtime.group_slots[i].group && + s_runtime.group_slots[i].group->group_id && + strcmp(s_runtime.group_slots[i].group->group_id, group_id) == 0) { + return (ssize_t)i; + } + } + + return -1; +} + +static ssize_t claw_cap_find_free_group_slot_locked(void) +{ + size_t i; + + for (i = 0; i < s_runtime.group_capacity; i++) { + if (!s_runtime.group_slots[i].occupied) { + return (ssize_t)i; + } + } + + return -1; +} + +static ssize_t claw_cap_find_descriptor_slot_index_locked(const char *id_or_name) +{ + size_t i; + + if (!id_or_name || !id_or_name[0]) { + return -1; + } + + for (i = 0; i < s_runtime.descriptor_capacity; i++) { + claw_cap_descriptor_slot_t *slot = &s_runtime.descriptor_slots[i]; + + if (!slot->occupied) { + continue; + } + if (strcmp(slot->descriptor.id, id_or_name) == 0 || + strcmp(slot->descriptor.name, id_or_name) == 0) { + return (ssize_t)i; + } + } + + return -1; +} + +static ssize_t claw_cap_find_free_descriptor_slot_locked(void) +{ + size_t i; + + for (i = 0; i < s_runtime.descriptor_capacity; i++) { + if (!s_runtime.descriptor_slots[i].occupied) { + return (ssize_t)i; + } + } + + return -1; +} + +static bool claw_cap_names_conflict_locked( + const claw_cap_descriptor_t *descriptor) +{ + size_t i; + + for (i = 0; i < s_runtime.descriptor_capacity; i++) { + claw_cap_descriptor_slot_t *slot = &s_runtime.descriptor_slots[i]; + + if (!slot->occupied) { + continue; + } + if (strcmp(slot->descriptor.id, descriptor->id) == 0 || + strcmp(slot->descriptor.name, descriptor->name) == 0) { + return true; + } + } + + return false; +} + +static size_t claw_cap_count_free_descriptor_slots_locked(void) +{ + size_t i; + size_t count = 0; + + for (i = 0; i < s_runtime.descriptor_capacity; i++) { + if (!s_runtime.descriptor_slots[i].occupied) { + count++; + } + } + + return count; +} + +static esp_err_t claw_cap_validate_group_locked(const claw_cap_group_t *group) +{ + size_t i; + size_t j; + + if (!group || !group->group_id || !group->group_id[0] || + !group->descriptors || group->descriptor_count == 0) { + return ESP_ERR_INVALID_ARG; + } + if (claw_cap_find_group_slot_index_locked(group->group_id) >= 0) { + return ESP_ERR_INVALID_STATE; + } + if (claw_cap_find_free_group_slot_locked() < 0) { + return ESP_ERR_NO_MEM; + } + if (claw_cap_count_free_descriptor_slots_locked() < group->descriptor_count) { + return ESP_ERR_NO_MEM; + } + + for (i = 0; i < group->descriptor_count; i++) { + esp_err_t err = claw_cap_validate_descriptor(&group->descriptors[i]); + + if (err != ESP_OK) { + return err; + } + if (claw_cap_names_conflict_locked(&group->descriptors[i])) { + return ESP_ERR_INVALID_STATE; + } + for (j = i + 1; j < group->descriptor_count; j++) { + const claw_cap_descriptor_t *left = &group->descriptors[i]; + const claw_cap_descriptor_t *right = &group->descriptors[j]; + + if (strcmp(left->id, right->id) == 0 || + strcmp(left->name, right->name) == 0) { + return ESP_ERR_INVALID_STATE; + } + } + } + + return ESP_OK; +} + +static esp_err_t claw_cap_start_group_locked(size_t group_slot_index) +{ + claw_cap_group_slot_t *group_slot = &s_runtime.group_slots[group_slot_index]; + size_t i; + + if (!group_slot->occupied || group_slot->state == CLAW_CAP_STATE_STARTED) { + return ESP_OK; + } + if (group_slot->state == CLAW_CAP_STATE_DRAINING || + group_slot->state == CLAW_CAP_STATE_UNLOADING) { + return ESP_ERR_INVALID_STATE; + } + + group_slot->state = CLAW_CAP_STATE_STARTED; + for (i = 0; i < group_slot->member_count; i++) { + claw_cap_descriptor_slot_t *slot = + &s_runtime.descriptor_slots[group_slot->member_slots[i]]; + + slot->state = CLAW_CAP_STATE_STARTED; + } + return ESP_OK; +} + +static esp_err_t claw_cap_start_group_callbacks(size_t group_slot_index) +{ + claw_cap_group_slot_t *group_slot = &s_runtime.group_slots[group_slot_index]; + size_t i; + + if (!group_slot->occupied) { + return ESP_ERR_NOT_FOUND; + } + + if (group_slot->group && group_slot->group->group_start) { + esp_err_t err = group_slot->group->group_start(); + + if (err != ESP_OK) { + return err; + } + } + + for (i = 0; i < group_slot->member_count; i++) { + claw_cap_descriptor_slot_t *slot = + &s_runtime.descriptor_slots[group_slot->member_slots[i]]; + + if (!slot->init_called && slot->descriptor.init) { + esp_err_t err = slot->descriptor.init(); + + if (err != ESP_OK) { + return err; + } + slot->init_called = true; + } + if (slot->descriptor.start) { + esp_err_t err = slot->descriptor.start(); + + if (err != ESP_OK) { + return err; + } + } + } + + return ESP_OK; +} + +static esp_err_t claw_cap_stop_group_callbacks(size_t group_slot_index) +{ + claw_cap_group_slot_t *group_slot = &s_runtime.group_slots[group_slot_index]; + esp_err_t first_err = ESP_OK; + size_t i; + + if (!group_slot->occupied) { + return ESP_ERR_NOT_FOUND; + } + + for (i = group_slot->member_count; i > 0; i--) { + claw_cap_descriptor_slot_t *slot = + &s_runtime.descriptor_slots[group_slot->member_slots[i - 1]]; + + if (slot->descriptor.stop) { + esp_err_t err = slot->descriptor.stop(); + + if (err != ESP_OK && first_err == ESP_OK) { + first_err = err; + } + } + } + + if (group_slot->group && group_slot->group->group_stop) { + esp_err_t err = group_slot->group->group_stop(); + + if (err != ESP_OK && first_err == ESP_OK) { + first_err = err; + } + } + + return first_err; +} + +static bool claw_cap_group_has_active_calls_locked(size_t group_slot_index) +{ + claw_cap_group_slot_t *group_slot = &s_runtime.group_slots[group_slot_index]; + size_t i; + + for (i = 0; i < group_slot->member_count; i++) { + claw_cap_descriptor_slot_t *slot = + &s_runtime.descriptor_slots[group_slot->member_slots[i]]; + + if (slot->active_calls > 0) { + return true; + } + } + + return false; +} + +static void claw_cap_clear_group_slot_locked(size_t group_slot_index) +{ + claw_cap_group_slot_t *group_slot = &s_runtime.group_slots[group_slot_index]; + size_t i; + + for (i = 0; i < group_slot->member_count; i++) { + size_t descriptor_slot_index = group_slot->member_slots[i]; + + memset(&s_runtime.descriptor_slots[descriptor_slot_index], 0, + sizeof(s_runtime.descriptor_slots[descriptor_slot_index])); + } + + free(group_slot->member_slots); + memset(group_slot, 0, sizeof(*group_slot)); +} + +static esp_err_t claw_cap_disable_group_locked(size_t group_slot_index) +{ + claw_cap_group_slot_t *group_slot = &s_runtime.group_slots[group_slot_index]; + size_t i; + + if (!group_slot->occupied) { + return ESP_ERR_NOT_FOUND; + } + if (group_slot->state == CLAW_CAP_STATE_DISABLED) { + return ESP_OK; + } + if (group_slot->state == CLAW_CAP_STATE_DRAINING || + group_slot->state == CLAW_CAP_STATE_UNLOADING) { + return ESP_ERR_INVALID_STATE; + } + + group_slot->state = CLAW_CAP_STATE_DISABLED; + for (i = 0; i < group_slot->member_count; i++) { + claw_cap_descriptor_slot_t *slot = + &s_runtime.descriptor_slots[group_slot->member_slots[i]]; + + slot->state = CLAW_CAP_STATE_DISABLED; + } + return ESP_OK; +} + +static esp_err_t claw_cap_enable_group_locked(size_t group_slot_index) +{ + claw_cap_group_slot_t *group_slot = &s_runtime.group_slots[group_slot_index]; + size_t i; + + if (!group_slot->occupied) { + return ESP_ERR_NOT_FOUND; + } + if (group_slot->state == CLAW_CAP_STATE_DRAINING || + group_slot->state == CLAW_CAP_STATE_UNLOADING) { + return ESP_ERR_INVALID_STATE; + } + if (s_runtime.started) { + group_slot->state = CLAW_CAP_STATE_STARTED; + for (i = 0; i < group_slot->member_count; i++) { + s_runtime.descriptor_slots[group_slot->member_slots[i]].state = + CLAW_CAP_STATE_STARTED; + } + } else { + group_slot->state = CLAW_CAP_STATE_REGISTERED; + for (i = 0; i < group_slot->member_count; i++) { + s_runtime.descriptor_slots[group_slot->member_slots[i]].state = + CLAW_CAP_STATE_REGISTERED; + } + } + return ESP_OK; +} + +static esp_err_t claw_cap_register_group_locked(const claw_cap_group_t *group, + size_t *out_group_slot_index) +{ + ssize_t group_slot_index; + claw_cap_group_slot_t *group_slot; + size_t i; + + group_slot_index = claw_cap_find_free_group_slot_locked(); + if (group_slot_index < 0) { + return ESP_ERR_NO_MEM; + } + + group_slot = &s_runtime.group_slots[group_slot_index]; + group_slot->member_slots = calloc(group->descriptor_count, sizeof(size_t)); + if (!group_slot->member_slots) { + return ESP_ERR_NO_MEM; + } + + group_slot->occupied = true; + group_slot->group = group; + group_slot->state = CLAW_CAP_STATE_REGISTERED; + group_slot->member_count = group->descriptor_count; + + if (group->group_init) { + esp_err_t err = group->group_init(); + + if (err != ESP_OK) { + free(group_slot->member_slots); + memset(group_slot, 0, sizeof(*group_slot)); + return err; + } + group_slot->group_init_called = true; + } + + for (i = 0; i < group->descriptor_count; i++) { + ssize_t descriptor_slot_index = claw_cap_find_free_descriptor_slot_locked(); + claw_cap_descriptor_slot_t *slot; + + if (descriptor_slot_index < 0) { + claw_cap_clear_group_slot_locked(group_slot_index); + return ESP_ERR_NO_MEM; + } + + slot = &s_runtime.descriptor_slots[descriptor_slot_index]; + slot->occupied = true; + slot->descriptor = group->descriptors[i]; + slot->group_slot_index = (size_t)group_slot_index; + slot->state = CLAW_CAP_STATE_REGISTERED; + group_slot->member_slots[i] = (size_t)descriptor_slot_index; + } + + if (out_group_slot_index) { + *out_group_slot_index = (size_t)group_slot_index; + } + return ESP_OK; +} + +esp_err_t claw_cap_init(const claw_cap_config_t *config) +{ + size_t descriptor_capacity; + size_t group_capacity; + + if (s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + + descriptor_capacity = config && config->max_capabilities ? + config->max_capabilities : CLAW_CAP_DEFAULT_MAX_CAPABILITIES; + group_capacity = config && config->max_groups ? + config->max_groups : CLAW_CAP_DEFAULT_MAX_GROUPS; + + s_runtime.descriptor_slots = calloc(descriptor_capacity, + sizeof(claw_cap_descriptor_slot_t)); + s_runtime.group_slots = calloc(group_capacity, sizeof(claw_cap_group_slot_t)); + s_runtime.descriptor_list_snapshot = calloc(descriptor_capacity, + sizeof(claw_cap_descriptor_t)); + s_runtime.group_list_snapshot = calloc(group_capacity, + sizeof(claw_cap_group_info_t)); + if (!s_runtime.descriptor_slots || !s_runtime.group_slots || + !s_runtime.descriptor_list_snapshot || !s_runtime.group_list_snapshot) { + free(s_runtime.descriptor_slots); + free(s_runtime.group_slots); + free(s_runtime.descriptor_list_snapshot); + free(s_runtime.group_list_snapshot); + memset(&s_runtime, 0, sizeof(s_runtime)); + return ESP_ERR_NO_MEM; + } + + s_runtime.mutex = xSemaphoreCreateMutex(); + if (!s_runtime.mutex) { + free(s_runtime.descriptor_slots); + free(s_runtime.group_slots); + free(s_runtime.descriptor_list_snapshot); + free(s_runtime.group_list_snapshot); + if (s_runtime.mutex) { + vSemaphoreDelete(s_runtime.mutex); + } + memset(&s_runtime, 0, sizeof(s_runtime)); + return ESP_ERR_NO_MEM; + } + + s_runtime.descriptor_capacity = descriptor_capacity; + s_runtime.group_capacity = group_capacity; + s_runtime.initialized = true; + ESP_LOGI(TAG, "Initialized runtime"); + return ESP_OK; +} + +esp_err_t claw_cap_set_llm_visible_groups(const char *const *group_ids, size_t count) +{ + char **copied_group_ids = NULL; + size_t i; + + if (!s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (count > 0 && !group_ids) { + return ESP_ERR_INVALID_ARG; + } + + if (count > 0) { + copied_group_ids = calloc(count, sizeof(char *)); + if (!copied_group_ids) { + return ESP_ERR_NO_MEM; + } + + for (i = 0; i < count; i++) { + if (!group_ids[i] || !group_ids[i][0]) { + while (i > 0) { + free(copied_group_ids[--i]); + } + free(copied_group_ids); + return ESP_ERR_INVALID_ARG; + } + + copied_group_ids[i] = claw_cap_strdup(group_ids[i]); + if (!copied_group_ids[i]) { + while (i > 0) { + free(copied_group_ids[--i]); + } + free(copied_group_ids); + return ESP_ERR_NO_MEM; + } + } + } + + claw_cap_lock(); + claw_cap_clear_llm_visible_groups_locked(); + s_runtime.llm_visible_group_ids = copied_group_ids; + s_runtime.llm_visible_group_count = count; + claw_cap_unlock(); + + ESP_LOGI(TAG, "Configured %u LLM-visible capability groups", (unsigned)count); + return ESP_OK; +} + +esp_err_t claw_cap_register(const claw_cap_descriptor_t *descriptor) +{ + claw_cap_group_t group = { + .group_id = descriptor ? descriptor->id : NULL, + .plugin_name = descriptor ? descriptor->name : NULL, + .version = "1", + .descriptors = descriptor, + .descriptor_count = descriptor ? 1 : 0, + }; + + return claw_cap_register_group(&group); +} + +esp_err_t claw_cap_register_group(const claw_cap_group_t *group) +{ + size_t group_slot_index = 0; + esp_err_t err; + + if (!s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + + claw_cap_lock(); + err = claw_cap_validate_group_locked(group); + if (err == ESP_OK) { + err = claw_cap_register_group_locked(group, &group_slot_index); + } + claw_cap_unlock(); + if (err != ESP_OK) { + return err; + } + + if (s_runtime.started) { + err = claw_cap_enable_group(group->group_id); + if (err != ESP_OK) { + return err; + } + } + + return ESP_OK; +} + +esp_err_t claw_cap_start_all(void) +{ + size_t i; + + if (!s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (s_runtime.started) { + return ESP_OK; + } + + s_runtime.started = true; + for (i = 0; i < s_runtime.group_capacity; i++) { + claw_cap_group_slot_t *group_slot = &s_runtime.group_slots[i]; + + if (!group_slot->occupied || group_slot->state == CLAW_CAP_STATE_DISABLED) { + continue; + } + + if (claw_cap_enable_group(group_slot->group->group_id) != ESP_OK) { + ESP_LOGW(TAG, "Failed to start group %s", group_slot->group->group_id); + } + } + + return ESP_OK; +} + +esp_err_t claw_cap_stop_all(void) +{ + esp_err_t first_err = ESP_OK; + size_t i; + + if (!s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + + for (i = 0; i < s_runtime.group_capacity; i++) { + claw_cap_group_slot_t *group_slot = &s_runtime.group_slots[i]; + + if (!group_slot->occupied || group_slot->state != CLAW_CAP_STATE_STARTED) { + continue; + } + if (claw_cap_disable_group(group_slot->group->group_id) != ESP_OK && + first_err == ESP_OK) { + first_err = ESP_FAIL; + } + } + + s_runtime.started = false; + return first_err; +} + +esp_err_t claw_cap_enable_group(const char *group_id) +{ + ssize_t group_slot_index; + esp_err_t err; + + if (!s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (!group_id || !group_id[0]) { + return ESP_ERR_INVALID_ARG; + } + + claw_cap_lock(); + group_slot_index = claw_cap_find_group_slot_index_locked(group_id); + if (group_slot_index < 0) { + claw_cap_unlock(); + return ESP_ERR_NOT_FOUND; + } + if (!s_runtime.started) { + err = claw_cap_enable_group_locked((size_t)group_slot_index); + claw_cap_unlock(); + return err; + } + if (s_runtime.group_slots[group_slot_index].state == CLAW_CAP_STATE_STARTED) { + claw_cap_unlock(); + return ESP_OK; + } + err = claw_cap_enable_group_locked((size_t)group_slot_index); + claw_cap_unlock(); + if (err != ESP_OK) { + return err; + } + + err = claw_cap_start_group_callbacks((size_t)group_slot_index); + if (err != ESP_OK) { + claw_cap_lock(); + claw_cap_disable_group_locked((size_t)group_slot_index); + claw_cap_unlock(); + return err; + } + + claw_cap_lock(); + err = claw_cap_start_group_locked((size_t)group_slot_index); + claw_cap_unlock(); + return err; +} + +esp_err_t claw_cap_disable_group(const char *group_id) +{ + ssize_t group_slot_index; + esp_err_t err = ESP_OK; + + if (!s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (!group_id || !group_id[0]) { + return ESP_ERR_INVALID_ARG; + } + + claw_cap_lock(); + group_slot_index = claw_cap_find_group_slot_index_locked(group_id); + if (group_slot_index < 0) { + claw_cap_unlock(); + return ESP_ERR_NOT_FOUND; + } + if (s_runtime.group_slots[group_slot_index].state == CLAW_CAP_STATE_DISABLED) { + claw_cap_unlock(); + return ESP_OK; + } + err = claw_cap_disable_group_locked((size_t)group_slot_index); + claw_cap_unlock(); + if (err != ESP_OK) { + return err; + } + + err = claw_cap_stop_group_callbacks((size_t)group_slot_index); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Group stop failed for %s: %s", group_id, esp_err_to_name(err)); + } + return ESP_OK; +} + +esp_err_t claw_cap_unregister_group(const char *group_id, uint32_t timeout_ms) +{ + ssize_t group_slot_index; + TickType_t deadline = xTaskGetTickCount() + + ((timeout_ms == UINT32_MAX) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms)); + esp_err_t stop_err = ESP_OK; + + if (!s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (!group_id || !group_id[0]) { + return ESP_ERR_INVALID_ARG; + } + + claw_cap_lock(); + group_slot_index = claw_cap_find_group_slot_index_locked(group_id); + if (group_slot_index < 0) { + claw_cap_unlock(); + return ESP_ERR_NOT_FOUND; + } + if (s_runtime.group_slots[group_slot_index].state == CLAW_CAP_STATE_UNLOADING) { + claw_cap_unlock(); + return ESP_ERR_INVALID_STATE; + } + s_runtime.group_slots[group_slot_index].state = CLAW_CAP_STATE_DRAINING; + { + size_t i; + claw_cap_group_slot_t *group_slot = &s_runtime.group_slots[group_slot_index]; + + for (i = 0; i < group_slot->member_count; i++) { + s_runtime.descriptor_slots[group_slot->member_slots[i]].state = + CLAW_CAP_STATE_DRAINING; + } + } + claw_cap_unlock(); + + while (true) { + bool active_calls; + + claw_cap_lock(); + active_calls = claw_cap_group_has_active_calls_locked((size_t)group_slot_index); + if (!active_calls) { + claw_cap_group_slot_t *group_slot = &s_runtime.group_slots[group_slot_index]; + size_t i; + + group_slot->state = CLAW_CAP_STATE_UNLOADING; + for (i = 0; i < group_slot->member_count; i++) { + s_runtime.descriptor_slots[group_slot->member_slots[i]].state = + CLAW_CAP_STATE_UNLOADING; + } + claw_cap_unlock(); + break; + } + claw_cap_unlock(); + + if (timeout_ms != UINT32_MAX && xTaskGetTickCount() >= deadline) { + return ESP_ERR_TIMEOUT; + } + vTaskDelay(pdMS_TO_TICKS(CLAW_CAP_UNLOAD_POLL_MS)); + } + + stop_err = claw_cap_stop_group_callbacks((size_t)group_slot_index); + + claw_cap_lock(); + claw_cap_clear_group_slot_locked((size_t)group_slot_index); + claw_cap_unlock(); + + return stop_err; +} + +esp_err_t claw_cap_unregister(const char *id_or_name, uint32_t timeout_ms) +{ + ssize_t descriptor_slot_index; + size_t group_slot_index; + + if (!s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (!id_or_name || !id_or_name[0]) { + return ESP_ERR_INVALID_ARG; + } + + claw_cap_lock(); + descriptor_slot_index = claw_cap_find_descriptor_slot_index_locked(id_or_name); + if (descriptor_slot_index < 0) { + claw_cap_unlock(); + return ESP_ERR_NOT_FOUND; + } + + group_slot_index = s_runtime.descriptor_slots[descriptor_slot_index].group_slot_index; + if (!s_runtime.group_slots[group_slot_index].occupied || + s_runtime.group_slots[group_slot_index].member_count != 1) { + claw_cap_unlock(); + return ESP_ERR_NOT_SUPPORTED; + } + claw_cap_unlock(); + + return claw_cap_unregister_group( + s_runtime.group_slots[group_slot_index].group->group_id, timeout_ms); +} + +bool claw_cap_group_exists(const char *group_id) +{ + bool exists; + + if (!s_runtime.initialized) { + return false; + } + + claw_cap_lock(); + exists = claw_cap_find_group_slot_index_locked(group_id) >= 0; + claw_cap_unlock(); + return exists; +} + +esp_err_t claw_cap_get_group_state(const char *group_id, + claw_cap_state_t *state) +{ + ssize_t group_slot_index; + + if (!s_runtime.initialized || !group_id || !state) { + return ESP_ERR_INVALID_ARG; + } + + claw_cap_lock(); + group_slot_index = claw_cap_find_group_slot_index_locked(group_id); + if (group_slot_index < 0) { + claw_cap_unlock(); + return ESP_ERR_NOT_FOUND; + } + *state = s_runtime.group_slots[group_slot_index].state; + claw_cap_unlock(); + return ESP_OK; +} + +esp_err_t claw_cap_get_descriptor_state(const char *id_or_name, + claw_cap_descriptor_info_t *info) +{ + ssize_t descriptor_slot_index; + claw_cap_descriptor_slot_t *slot; + claw_cap_group_slot_t *group_slot; + + if (!s_runtime.initialized || !id_or_name || !info) { + return ESP_ERR_INVALID_ARG; + } + + claw_cap_lock(); + descriptor_slot_index = claw_cap_find_descriptor_slot_index_locked(id_or_name); + if (descriptor_slot_index < 0) { + claw_cap_unlock(); + return ESP_ERR_NOT_FOUND; + } + slot = &s_runtime.descriptor_slots[descriptor_slot_index]; + group_slot = &s_runtime.group_slots[slot->group_slot_index]; + info->id = slot->descriptor.id; + info->name = slot->descriptor.name; + info->group_id = group_slot->group ? group_slot->group->group_id : NULL; + info->state = slot->state; + info->active_calls = slot->active_calls; + claw_cap_unlock(); + return ESP_OK; +} + +const claw_cap_descriptor_t *claw_cap_find(const char *id_or_name) +{ + const claw_cap_descriptor_t *result = NULL; + ssize_t descriptor_slot_index; + + if (!s_runtime.initialized || !id_or_name || !id_or_name[0]) { + return NULL; + } + + claw_cap_lock(); + descriptor_slot_index = claw_cap_find_descriptor_slot_index_locked(id_or_name); + if (descriptor_slot_index >= 0 && + claw_cap_descriptor_is_listable(&s_runtime.descriptor_slots[descriptor_slot_index])) { + result = &s_runtime.descriptor_slots[descriptor_slot_index].descriptor; + } + claw_cap_unlock(); + return result; +} + +claw_cap_list_t claw_cap_list(void) +{ + claw_cap_list_t list = {0}; + size_t i; + size_t count = 0; + + if (!s_runtime.initialized) { + return list; + } + + claw_cap_lock(); + for (i = 0; i < s_runtime.descriptor_capacity; i++) { + claw_cap_descriptor_slot_t *slot = &s_runtime.descriptor_slots[i]; + + if (!claw_cap_descriptor_is_listable(slot)) { + continue; + } + s_runtime.descriptor_list_snapshot[count++] = slot->descriptor; + } + claw_cap_unlock(); + + list.items = s_runtime.descriptor_list_snapshot; + list.count = count; + return list; +} + +claw_cap_group_list_t claw_cap_list_groups(void) +{ + claw_cap_group_list_t list = {0}; + size_t i; + size_t count = 0; + + if (!s_runtime.initialized) { + return list; + } + + claw_cap_lock(); + for (i = 0; i < s_runtime.group_capacity; i++) { + claw_cap_group_slot_t *slot = &s_runtime.group_slots[i]; + + if (!slot->occupied || !slot->group) { + continue; + } + + s_runtime.group_list_snapshot[count].group_id = slot->group->group_id; + s_runtime.group_list_snapshot[count].plugin_name = slot->group->plugin_name; + s_runtime.group_list_snapshot[count].version = slot->group->version; + s_runtime.group_list_snapshot[count].state = slot->state; + s_runtime.group_list_snapshot[count].descriptor_count = slot->member_count; + count++; + } + claw_cap_unlock(); + + list.items = s_runtime.group_list_snapshot; + list.count = count; + return list; +} + +esp_err_t claw_cap_call(const char *id_or_name, + const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + claw_cap_execute_fn execute = NULL; + const char *name = NULL; + ssize_t descriptor_slot_index; + esp_err_t err; + + if (!output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + output[0] = '\0'; + + claw_cap_lock(); + descriptor_slot_index = claw_cap_find_descriptor_slot_index_locked(id_or_name); + if (descriptor_slot_index < 0) { + claw_cap_unlock(); + snprintf(output, output_size, "Error: unknown cap '%s'", + id_or_name ? id_or_name : ""); + return ESP_ERR_NOT_FOUND; + } + + { + claw_cap_descriptor_slot_t *slot = &s_runtime.descriptor_slots[descriptor_slot_index]; + + if (!claw_cap_descriptor_is_available(slot) || !slot->descriptor.execute) { + claw_cap_unlock(); + snprintf(output, output_size, "Error: cap '%s' is not available", + id_or_name ? id_or_name : ""); + return ESP_ERR_INVALID_STATE; + } + if (ctx && ctx->caller == CLAW_CAP_CALLER_AGENT && + !claw_cap_is_llm_visible(slot)) { + claw_cap_unlock(); + snprintf(output, output_size, + "Error: cap '%s' is not exposed to the LLM.", + slot->descriptor.name); + return ESP_ERR_INVALID_STATE; + } + + slot->active_calls++; + execute = slot->descriptor.execute; + name = slot->descriptor.name; + } + claw_cap_unlock(); + + err = execute(input_json ? input_json : "{}", ctx, output, output_size); + + claw_cap_lock(); + if (descriptor_slot_index >= 0 && + descriptor_slot_index < (ssize_t)s_runtime.descriptor_capacity && + s_runtime.descriptor_slots[descriptor_slot_index].occupied && + s_runtime.descriptor_slots[descriptor_slot_index].active_calls > 0) { + s_runtime.descriptor_slots[descriptor_slot_index].active_calls--; + } + claw_cap_unlock(); + + if (err != ESP_OK && !output[0]) { + snprintf(output, output_size, "Error: %s failed: %s", + name ? name : "cap", + esp_err_to_name(err)); + } + + return err; +} + +char *claw_cap_build_catalog(void) +{ + char *buf = NULL; + size_t cap = 512; + size_t off = 0; + size_t i; + + if (!s_runtime.initialized) { + return NULL; + } + + buf = calloc(1, cap); + if (!buf) { + return NULL; + } + + off += snprintf(buf + off, cap - off, "Registered capabilities:\n"); + + claw_cap_lock(); + for (i = 0; i < s_runtime.descriptor_capacity; i++) { + claw_cap_descriptor_slot_t *slot = &s_runtime.descriptor_slots[i]; + int written; + + if (!claw_cap_descriptor_is_listable(slot)) { + continue; + } + + written = snprintf(buf + off, cap - off, "- %s [%s]: %s\n", + slot->descriptor.name, + slot->descriptor.family ? slot->descriptor.family : "cap", + slot->descriptor.description ? slot->descriptor.description : ""); + if (written < 0) { + claw_cap_unlock(); + free(buf); + return NULL; + } + if ((size_t)written >= cap - off) { + char *tmp = realloc(buf, cap * 2); + + if (!tmp) { + claw_cap_unlock(); + free(buf); + return NULL; + } + memset(tmp + cap, 0, cap); + buf = tmp; + cap *= 2; + i--; + continue; + } + off += (size_t)written; + } + claw_cap_unlock(); + + return buf; +} diff --git a/components/claw_core/CMakeLists.txt b/components/claw_core/CMakeLists.txt new file mode 100644 index 0000000..16b801f --- /dev/null +++ b/components/claw_core/CMakeLists.txt @@ -0,0 +1,18 @@ +idf_component_register( + SRCS + "src/claw_core.c" + "src/claw_core_llm.c" + "src/llm/claw_llm_runtime.c" + "src/llm/claw_llm_http_transport.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" + INCLUDE_DIRS + "include" + "src" + REQUIRES + esp_http_client + esp-tls + json + mbedtls +) diff --git a/components/claw_core/include/claw_core.h b/components/claw_core/include/claw_core.h new file mode 100644 index 0000000..830faf7 --- /dev/null +++ b/components/claw_core/include/claw_core.h @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include +#include + +#include "esp_err.h" +#include "freertos/FreeRTOS.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + CLAW_CORE_RESPONSE_STATUS_OK = 0, + CLAW_CORE_RESPONSE_STATUS_ERROR = 1, +} claw_core_response_status_t; + +typedef enum { + CLAW_CORE_COMPLETION_DONE = 0, +} claw_core_completion_type_t; + +typedef struct { + uint32_t request_id; + const char *session_id; + const char *user_text; + const char *source_channel; + const char *source_chat_id; + const char *source_sender_id; + const char *source_message_id; + const char *source_cap; + const char *target_channel; + const char *target_chat_id; +} claw_core_request_t; + +typedef esp_err_t (*claw_core_append_session_turn_fn)(const char *session_id, + const char *user_text, + const char *assistant_text, + void *user_ctx); + +typedef struct claw_core_response claw_core_response_t; + +typedef enum { + CLAW_CORE_CONTEXT_KIND_SYSTEM_PROMPT = 0, + CLAW_CORE_CONTEXT_KIND_MESSAGES = 1, + CLAW_CORE_CONTEXT_KIND_TOOLS = 2, +} claw_core_context_kind_t; + +typedef struct { + claw_core_context_kind_t kind; + char *content; +} claw_core_context_t; + +typedef esp_err_t (*claw_core_context_provider_collect_fn)( + const claw_core_request_t *request, + claw_core_context_t *out_context, + void *user_ctx); + +typedef struct { + const char *name; + claw_core_context_provider_collect_fn collect; + void *user_ctx; +} claw_core_context_provider_t; + +typedef esp_err_t (*claw_core_call_cap_fn)(const char *cap_name, + const char *input_json, + const claw_core_request_t *request, + char **out_output, + void *user_ctx); + +typedef struct { + const char *api_key; + const char *backend_type; + const char *profile; + const char *provider; + const char *model; + const char *base_url; + const char *auth_type; + uint32_t timeout_ms; + size_t image_max_bytes; + const char *system_prompt; + claw_core_append_session_turn_fn append_session_turn; + void *append_session_turn_user_ctx; + claw_core_call_cap_fn call_cap; + void *cap_user_ctx; + uint32_t task_stack_size; + UBaseType_t task_priority; + BaseType_t task_core; + uint32_t max_tool_iterations; + uint32_t request_queue_len; + uint32_t response_queue_len; + size_t max_context_providers; +} claw_core_config_t; + +struct claw_core_response { + uint32_t request_id; + claw_core_response_status_t status; + claw_core_completion_type_t completion_type; + char *target_channel; + char *target_chat_id; + char *text; + char *error_message; +}; + +esp_err_t claw_core_init(const claw_core_config_t *config); +esp_err_t claw_core_start(void); +esp_err_t claw_core_add_context_provider(const claw_core_context_provider_t *provider); +esp_err_t claw_core_call_cap(const char *cap_name, + const char *input_json, + const claw_core_request_t *request, + char **out_output); +esp_err_t claw_core_submit(const claw_core_request_t *request, uint32_t timeout_ms); +esp_err_t claw_core_receive(claw_core_response_t *response, uint32_t timeout_ms); +esp_err_t claw_core_receive_for(uint32_t request_id, + claw_core_response_t *response, + uint32_t timeout_ms); +void claw_core_response_free(claw_core_response_t *response); + +#ifdef __cplusplus +} +#endif diff --git a/components/claw_core/src/claw_core.c b/components/claw_core/src/claw_core.c new file mode 100644 index 0000000..51d4668 --- /dev/null +++ b/components/claw_core/src/claw_core.c @@ -0,0 +1,1092 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "claw_core.h" + +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "esp_log.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +#include "claw_core_llm.h" + +static const char *TAG = "claw_core"; + +#define CLAW_CORE_DEFAULT_STACK_SIZE (8 * 1024) +#define CLAW_CORE_DEFAULT_PRIORITY 5 +#define CLAW_CORE_DEFAULT_CORE tskNO_AFFINITY +#define CLAW_CORE_DEFAULT_REQUEST_Q 4 +#define CLAW_CORE_DEFAULT_RESPONSE_Q 4 +#define CLAW_CORE_DEFAULT_TOOL_ITERATIONS 10 +#define CLAW_CORE_LOG_SNIPPET_LEN 96 + +typedef struct { + claw_core_request_t view; + char *owned_session_id; + char *owned_user_text; + char *owned_source_channel; + char *owned_source_chat_id; + char *owned_source_sender_id; + char *owned_source_message_id; + char *owned_source_cap; + char *owned_target_channel; + char *owned_target_chat_id; +} claw_core_request_item_t; + +typedef struct { + claw_core_response_t view; +} claw_core_response_item_t; + +typedef struct claw_core_pending_response { + claw_core_response_item_t item; + struct claw_core_pending_response *next; +} claw_core_pending_response_t; + +typedef struct { + bool initialized; + bool started; + char *system_prompt; + claw_core_append_session_turn_fn append_session_turn; + void *append_session_turn_user_ctx; + claw_core_call_cap_fn call_cap; + void *cap_user_ctx; + claw_core_context_provider_t *context_providers; + size_t context_provider_count; + size_t context_provider_capacity; + uint32_t task_stack_size; + UBaseType_t task_priority; + BaseType_t task_core; + uint32_t max_tool_iterations; + QueueHandle_t request_queue; + QueueHandle_t response_queue; + TaskHandle_t task_handle; + SemaphoreHandle_t response_lock; + claw_core_pending_response_t *pending_head; + claw_core_pending_response_t *pending_tail; +} claw_core_state_t; + +static claw_core_state_t s_core = {0}; + +static char *dup_string(const char *src) +{ + if (!src) { + return NULL; + } + + return strdup(src); +} + +static const char *log_snippet(const char *text) +{ + return text ? text : ""; +} + +static const char *context_kind_to_string(claw_core_context_kind_t kind) +{ + switch (kind) { + case CLAW_CORE_CONTEXT_KIND_SYSTEM_PROMPT: + return "system_prompt"; + case CLAW_CORE_CONTEXT_KIND_MESSAGES: + return "messages"; + case CLAW_CORE_CONTEXT_KIND_TOOLS: + return "tools"; + default: + return "unknown"; + } +} + +static void log_tool_call_names(uint32_t request_id, const claw_core_llm_response_t *response) +{ + char buf[192] = {0}; + size_t off = 0; + size_t i; + + if (!response || response->tool_call_count == 0) { + return; + } + + for (i = 0; i < response->tool_call_count; i++) { + const char *name = response->tool_calls[i].name ? response->tool_calls[i].name : "(null)"; + int written = snprintf(buf + off, + sizeof(buf) - off, + "%s%s", + i == 0 ? "" : ",", + name); + + if (written < 0 || (size_t)written >= sizeof(buf) - off) { + off = sizeof(buf) - 1; + break; + } + off += (size_t)written; + } + + ESP_LOGI(TAG, "llm_tool_calls request=%" PRIu32 " count=%u names=%s%s", + request_id, + (unsigned)response->tool_call_count, + buf, + off >= sizeof(buf) - 1 ? "..." : ""); +} + +static void free_context_provider_storage(void) +{ + size_t i; + + for (i = 0; i < s_core.context_provider_count; i++) { + free((char *)s_core.context_providers[i].name); + s_core.context_providers[i].name = NULL; + } + free(s_core.context_providers); + s_core.context_providers = NULL; + s_core.context_provider_count = 0; + s_core.context_provider_capacity = 0; +} + +static void free_request_item(claw_core_request_item_t *item) +{ + if (!item) { + return; + } + + free(item->owned_session_id); + free(item->owned_user_text); + free(item->owned_source_channel); + free(item->owned_source_chat_id); + free(item->owned_source_sender_id); + free(item->owned_source_message_id); + free(item->owned_source_cap); + free(item->owned_target_channel); + free(item->owned_target_chat_id); + memset(item, 0, sizeof(*item)); +} + +static void free_response_item(claw_core_response_item_t *item) +{ + if (!item) { + return; + } + + free(item->view.target_channel); + free(item->view.target_chat_id); + free(item->view.text); + free(item->view.error_message); + memset(item, 0, sizeof(*item)); +} + +static esp_err_t push_response(claw_core_response_item_t *item) +{ + if (xQueueSend(s_core.response_queue, item, portMAX_DELAY) != pdTRUE) { + return ESP_FAIL; + } + + item->view.target_channel = NULL; + item->view.target_chat_id = NULL; + item->view.text = NULL; + item->view.error_message = NULL; + return ESP_OK; +} + +static esp_err_t enqueue_pending_response(claw_core_response_item_t *item) +{ + claw_core_pending_response_t *node = calloc(1, sizeof(*node)); + + if (!node) { + return ESP_ERR_NO_MEM; + } + + node->item = *item; + if (!s_core.pending_tail) { + s_core.pending_head = node; + } else { + s_core.pending_tail->next = node; + } + s_core.pending_tail = node; + memset(item, 0, sizeof(*item)); + return ESP_OK; +} + +static bool pop_pending_response(uint32_t request_id, + bool match_any, + claw_core_response_item_t *out_item) +{ + claw_core_pending_response_t *prev = NULL; + claw_core_pending_response_t *cur = s_core.pending_head; + + while (cur) { + if (match_any || cur->item.view.request_id == request_id) { + if (prev) { + prev->next = cur->next; + } else { + s_core.pending_head = cur->next; + } + if (s_core.pending_tail == cur) { + s_core.pending_tail = prev; + } + *out_item = cur->item; + free(cur); + return true; + } + prev = cur; + cur = cur->next; + } + + return false; +} + +static void move_response_item(claw_core_response_t *dst, claw_core_response_item_t *src) +{ + memset(dst, 0, sizeof(*dst)); + *dst = src->view; + memset(src, 0, sizeof(*src)); +} + +static esp_err_t append_user_message(cJSON *messages, const char *text) +{ + cJSON *user_msg = NULL; + + if (!messages || !text) { + return ESP_ERR_INVALID_ARG; + } + + user_msg = cJSON_CreateObject(); + if (!user_msg) { + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(user_msg, "role", "user"); + cJSON_AddStringToObject(user_msg, "content", text); + cJSON_AddItemToArray(messages, user_msg); + return ESP_OK; +} + +static esp_err_t append_message_array_json(cJSON *messages, const char *json_text) +{ + cJSON *parsed = NULL; + cJSON *item = NULL; + + if (!messages || !json_text || !json_text[0]) { + return ESP_ERR_INVALID_ARG; + } + + parsed = cJSON_Parse(json_text); + if (!parsed || !cJSON_IsArray(parsed)) { + cJSON_Delete(parsed); + return ESP_FAIL; + } + + cJSON_ArrayForEach(item, parsed) { + cJSON *dup = cJSON_Duplicate(item, true); + + if (!dup) { + cJSON_Delete(parsed); + return ESP_ERR_NO_MEM; + } + cJSON_AddItemToArray(messages, dup); + } + + cJSON_Delete(parsed); + return ESP_OK; +} + +static esp_err_t append_message_array(cJSON *messages, const cJSON *items) +{ + const cJSON *item = NULL; + + if (!messages || !items || !cJSON_IsArray((cJSON *)items)) { + return ESP_ERR_INVALID_ARG; + } + + cJSON_ArrayForEach(item, items) { + cJSON *dup = cJSON_Duplicate((cJSON *)item, true); + + if (!dup) { + return ESP_ERR_NO_MEM; + } + cJSON_AddItemToArray(messages, dup); + } + + return ESP_OK; +} + +static esp_err_t append_tool_array_json(cJSON *tools, const char *json_text) +{ + cJSON *parsed = NULL; + cJSON *item = NULL; + + if (!tools || !json_text || !json_text[0]) { + return ESP_ERR_INVALID_ARG; + } + + parsed = cJSON_Parse(json_text); + if (!parsed || !cJSON_IsArray(parsed)) { + cJSON_Delete(parsed); + return ESP_FAIL; + } + + cJSON_ArrayForEach(item, parsed) { + cJSON *dup = cJSON_Duplicate(item, true); + + if (!dup) { + cJSON_Delete(parsed); + return ESP_ERR_NO_MEM; + } + cJSON_AddItemToArray(tools, dup); + } + + cJSON_Delete(parsed); + return ESP_OK; +} + +static char *build_current_turn_prompt(const claw_core_request_t *request) +{ + size_t total_len; + char *text = NULL; + + if (!request) { + return NULL; + } + + total_len = 256; + total_len += request->source_cap ? strlen(request->source_cap) : 0; + total_len += request->source_channel ? strlen(request->source_channel) : 0; + total_len += request->source_chat_id ? strlen(request->source_chat_id) : 0; + total_len += request->source_sender_id ? strlen(request->source_sender_id) : 0; + total_len += request->source_message_id ? strlen(request->source_message_id) : 0; + + text = calloc(1, total_len); + if (!text) { + return NULL; + } + + snprintf(text, + total_len, + "## Current Turn Context\n" + "- request_id: %" PRIu32 "\n" + "- source_cap: %s\n" + "- source_channel: %s\n" + "- source_chat_id: %s\n" + "- source_sender_id: %s\n" + "- source_message_id: %s\n", + request->request_id, + request->source_cap ? request->source_cap : "(unknown)", + request->source_channel ? request->source_channel : "(unknown)", + request->source_chat_id ? request->source_chat_id : "(unknown)", + request->source_sender_id ? request->source_sender_id : "(unknown)", + request->source_message_id ? request->source_message_id : "(none)"); + return text; +} + +static esp_err_t append_prompt_section(char **prompt, + const char *section_name, + const char *content) +{ + char *grown = NULL; + size_t current_len; + size_t extra_len; + + if (!prompt || !*prompt || !section_name || !content || !content[0]) { + return ESP_ERR_INVALID_ARG; + } + + current_len = strlen(*prompt); + extra_len = strlen("\n\n## \n") + strlen(section_name) + strlen(content); + grown = realloc(*prompt, current_len + extra_len + 1); + if (!grown) { + return ESP_ERR_NO_MEM; + } + + *prompt = grown; + snprintf((*prompt) + current_len, + extra_len + 1, + "\n\n## %s\n%s", + section_name, + content); + return ESP_OK; +} + +static esp_err_t append_assistant_tool_calls(cJSON *messages, + const claw_core_llm_response_t *response) +{ + cJSON *assistant = NULL; + cJSON *tool_calls = NULL; + size_t i; + + if (!messages || !response) { + return ESP_ERR_INVALID_ARG; + } + + assistant = cJSON_CreateObject(); + if (!assistant) { + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(assistant, "role", "assistant"); + if (response->text && response->text[0]) { + cJSON_AddStringToObject(assistant, "content", response->text); + } else { + cJSON_AddNullToObject(assistant, "content"); + } + + tool_calls = cJSON_CreateArray(); + if (!tool_calls) { + cJSON_Delete(assistant); + return ESP_ERR_NO_MEM; + } + + for (i = 0; i < response->tool_call_count; i++) { + cJSON *tool_call = cJSON_CreateObject(); + cJSON *function = cJSON_CreateObject(); + + if (!tool_call || !function) { + cJSON_Delete(tool_call); + cJSON_Delete(function); + cJSON_Delete(tool_calls); + cJSON_Delete(assistant); + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(tool_call, "id", response->tool_calls[i].id); + cJSON_AddStringToObject(tool_call, "type", "function"); + cJSON_AddStringToObject(function, "name", response->tool_calls[i].name); + cJSON_AddStringToObject(function, "arguments", response->tool_calls[i].arguments_json); + cJSON_AddItemToObject(tool_call, "function", function); + cJSON_AddItemToArray(tool_calls, tool_call); + } + + cJSON_AddItemToObject(assistant, "tool_calls", tool_calls); + cJSON_AddItemToArray(messages, assistant); + return ESP_OK; +} + +static void claw_core_finish_from_plain_text(uint32_t request_id, + const claw_core_llm_response_t *llm_response, + claw_core_response_t *response) +{ + const char *text = (llm_response && llm_response->text) ? llm_response->text : ""; + + response->completion_type = CLAW_CORE_COMPLETION_DONE; + free(response->text); + response->text = dup_string(text); + free(response->error_message); + response->error_message = NULL; + + ESP_LOGI(TAG, "completion request=%" PRIu32 " status=done raw=%.96s%s", + request_id, + log_snippet(text), + strlen(text) > CLAW_CORE_LOG_SNIPPET_LEN ? "..." : ""); +} + +static esp_err_t append_tool_results_message(cJSON *runtime_messages, + const claw_core_llm_response_t *response, + const claw_core_request_t *request) +{ + size_t i; + + if (!runtime_messages || !response || !request) { + return ESP_ERR_INVALID_ARG; + } + + for (i = 0; i < response->tool_call_count; i++) { + char *tool_output = NULL; + cJSON *tool_message = NULL; + esp_err_t err; + + ESP_LOGI(TAG, "tool_call request=%" PRIu32 " name=%s args=%.96s%s", + request->request_id, + response->tool_calls[i].name ? response->tool_calls[i].name : "(null)", + log_snippet(response->tool_calls[i].arguments_json), + response->tool_calls[i].arguments_json && + strlen(response->tool_calls[i].arguments_json) > CLAW_CORE_LOG_SNIPPET_LEN ? + "..." : ""); + + err = claw_core_call_cap(response->tool_calls[i].name, + response->tool_calls[i].arguments_json, + request, + &tool_output); + if (err != ESP_OK && !tool_output) { + tool_output = dup_string(esp_err_to_name(err)); + } + if (!tool_output) { + return ESP_ERR_NO_MEM; + } + + ESP_LOGI(TAG, "tool_result request=%" PRIu32 " name=%s err=%s output=%.96s%s", + request->request_id, + response->tool_calls[i].name ? response->tool_calls[i].name : "(null)", + esp_err_to_name(err), + log_snippet(tool_output), + strlen(tool_output) > CLAW_CORE_LOG_SNIPPET_LEN ? "..." : ""); + + tool_message = cJSON_CreateObject(); + if (!tool_message) { + free(tool_output); + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(tool_message, "role", "tool"); + cJSON_AddStringToObject(tool_message, "tool_call_id", response->tool_calls[i].id); + cJSON_AddStringToObject(tool_message, "content", tool_output); + cJSON_AddItemToArray(runtime_messages, tool_message); + free(tool_output); + } + + return ESP_OK; +} + +static esp_err_t build_iteration_context(const claw_core_request_item_t *request, + const cJSON *runtime_messages, + char **out_system_prompt, + cJSON **out_messages, + char **out_tools_json) +{ + char *system_prompt = NULL; + char *turn_prompt = NULL; + cJSON *messages = NULL; + cJSON *tools = NULL; + size_t i; + esp_err_t err = ESP_OK; + + if (!request || !out_system_prompt || !out_messages || !out_tools_json) { + return ESP_ERR_INVALID_ARG; + } + + *out_system_prompt = NULL; + *out_messages = NULL; + *out_tools_json = NULL; + + system_prompt = dup_string(s_core.system_prompt); + messages = cJSON_CreateArray(); + tools = cJSON_CreateArray(); + if (!system_prompt || !messages || !tools) { + err = ESP_ERR_NO_MEM; + goto cleanup; + } + + for (i = 0; i < s_core.context_provider_count; i++) { + claw_core_context_t context = {0}; + const claw_core_context_provider_t *provider = &s_core.context_providers[i]; + size_t context_len; + + err = provider->collect(&request->view, &context, provider->user_ctx); + if (err == ESP_ERR_NOT_FOUND) { + continue; + } + if (err != ESP_OK) { + goto cleanup; + } + if (!context.content || !context.content[0]) { + free(context.content); + err = ESP_FAIL; + goto cleanup; + } + context_len = strlen(context.content); + ESP_LOGI(TAG, + "context_loaded request=%" PRIu32 " provider=%s context_kind=%s context_len=%u", + request->view.request_id, + provider->name, + context_kind_to_string(context.kind), + (unsigned)context_len); + + switch (context.kind) { + case CLAW_CORE_CONTEXT_KIND_SYSTEM_PROMPT: + err = append_prompt_section(&system_prompt, provider->name, context.content); + break; + case CLAW_CORE_CONTEXT_KIND_MESSAGES: + err = append_message_array_json(messages, context.content); + break; + case CLAW_CORE_CONTEXT_KIND_TOOLS: + err = append_tool_array_json(tools, context.content); + break; + default: + err = ESP_ERR_INVALID_ARG; + break; + } + free(context.content); + if (err != ESP_OK) { + goto cleanup; + } + } + + turn_prompt = build_current_turn_prompt(&request->view); + if (!turn_prompt) { + err = ESP_ERR_NO_MEM; + goto cleanup; + } + err = append_prompt_section(&system_prompt, "Core Request", turn_prompt); + free(turn_prompt); + turn_prompt = NULL; + if (err != ESP_OK) { + goto cleanup; + } + + err = append_user_message(messages, request->view.user_text); + if (err != ESP_OK) { + goto cleanup; + } + + if (runtime_messages && cJSON_GetArraySize((cJSON *)runtime_messages) > 0) { + err = append_message_array(messages, runtime_messages); + if (err != ESP_OK) { + goto cleanup; + } + } + + *out_tools_json = cJSON_GetArraySize(tools) > 0 ? cJSON_PrintUnformatted(tools) : NULL; + if (cJSON_GetArraySize(tools) > 0 && !*out_tools_json) { + err = ESP_ERR_NO_MEM; + goto cleanup; + } + + *out_system_prompt = system_prompt; + *out_messages = messages; + system_prompt = NULL; + messages = NULL; + err = ESP_OK; + +cleanup: + free(turn_prompt); + free(system_prompt); + cJSON_Delete(messages); + cJSON_Delete(tools); + if (err != ESP_OK) { + free(*out_tools_json); + *out_tools_json = NULL; + } + return err; +} + +static void claw_core_task(void *arg) +{ + (void)arg; + + while (true) { + claw_core_request_item_t request = {0}; + claw_core_response_item_t response = {0}; + cJSON *runtime_messages = NULL; + cJSON *messages = NULL; + char *system_prompt = NULL; + char *tools_json = NULL; + claw_core_llm_response_t llm_response = {0}; + uint32_t iteration = 0; + esp_err_t err = ESP_OK; + + if (xQueueReceive(s_core.request_queue, &request, portMAX_DELAY) != pdTRUE) { + continue; + } + + response.view.request_id = request.view.request_id; + response.view.status = CLAW_CORE_RESPONSE_STATUS_ERROR; + response.view.completion_type = CLAW_CORE_COMPLETION_DONE; + response.view.target_channel = dup_string(request.view.target_channel); + response.view.target_chat_id = dup_string(request.view.target_chat_id); + if ((request.view.target_channel && request.view.target_channel[0] && + !response.view.target_channel) || + (request.view.target_chat_id && request.view.target_chat_id[0] && + !response.view.target_chat_id)) { + response.view.error_message = dup_string("Failed to allocate response target"); + goto finish_request; + } + + runtime_messages = cJSON_CreateArray(); + if (!runtime_messages) { + response.view.error_message = dup_string("Failed to allocate runtime messages"); + goto finish_request; + } + + while (true) { + claw_core_llm_response_free(&llm_response); + free(system_prompt); + free(tools_json); + cJSON_Delete(messages); + system_prompt = NULL; + tools_json = NULL; + messages = NULL; + + err = build_iteration_context(&request, + runtime_messages, + &system_prompt, + &messages, + &tools_json); + if (err != ESP_OK) { + response.view.error_message = dup_string(esp_err_to_name(err)); + goto finish_request; + } + + err = claw_core_llm_chat_messages(system_prompt, + messages, + tools_json, + &llm_response, + &response.view.error_message); + if (err != ESP_OK) { + goto finish_request; + } + + if (llm_response.tool_call_count == 0) { + claw_core_finish_from_plain_text(request.view.request_id, + &llm_response, + &response.view); + err = ESP_OK; + break; + } + + log_tool_call_names(request.view.request_id, &llm_response); + + err = append_assistant_tool_calls(runtime_messages, &llm_response); + if (err != ESP_OK) { + response.view.error_message = dup_string(esp_err_to_name(err)); + goto finish_request; + } + + err = append_tool_results_message(runtime_messages, &llm_response, &request.view); + if (err != ESP_OK) { + response.view.error_message = dup_string(esp_err_to_name(err)); + goto finish_request; + } + + iteration++; + if (iteration >= s_core.max_tool_iterations) { + response.view.error_message = dup_string("cap tool iteration limit reached"); + err = ESP_ERR_INVALID_STATE; + goto finish_request; + } + } + + if (err == ESP_OK && response.view.text) { + response.view.status = CLAW_CORE_RESPONSE_STATUS_OK; + if (response.view.text[0] && + s_core.append_session_turn && + request.view.session_id && request.view.session_id[0]) { + err = s_core.append_session_turn(request.view.session_id, + request.view.user_text, + response.view.text, + s_core.append_session_turn_user_ctx); + if (err != ESP_OK) { + ESP_LOGW(TAG, "append_session_turn failed: %s", esp_err_to_name(err)); + } + } + } else if (!response.view.error_message) { + response.view.error_message = dup_string(esp_err_to_name(err)); + } + +finish_request: + if (push_response(&response) != ESP_OK) { + ESP_LOGE(TAG, "Failed to enqueue response for request_id=%" PRIu32, request.view.request_id); + free_response_item(&response); + } + + claw_core_llm_response_free(&llm_response); + cJSON_Delete(runtime_messages); + cJSON_Delete(messages); + free(system_prompt); + free(tools_json); + free_request_item(&request); + } +} + +esp_err_t claw_core_init(const claw_core_config_t *config) +{ + claw_core_llm_config_t llm_config = {0}; + char *llm_error = NULL; + esp_err_t err; + uint32_t request_queue_len; + uint32_t response_queue_len; + + if (!config || !config->system_prompt || !config->api_key || !config->model || + (!(config->profile && config->profile[0]) && !(config->provider && config->provider[0]))) { + return ESP_ERR_INVALID_ARG; + } + if (s_core.initialized) { + return ESP_ERR_INVALID_STATE; + } + + memset(&s_core, 0, sizeof(s_core)); + + s_core.system_prompt = dup_string(config->system_prompt); + if (!s_core.system_prompt) { + return ESP_ERR_NO_MEM; + } + s_core.append_session_turn = config->append_session_turn; + s_core.append_session_turn_user_ctx = config->append_session_turn_user_ctx; + s_core.call_cap = config->call_cap; + s_core.cap_user_ctx = config->cap_user_ctx; + + request_queue_len = config->request_queue_len ? config->request_queue_len : CLAW_CORE_DEFAULT_REQUEST_Q; + response_queue_len = config->response_queue_len ? config->response_queue_len : CLAW_CORE_DEFAULT_RESPONSE_Q; + s_core.task_stack_size = config->task_stack_size ? config->task_stack_size : CLAW_CORE_DEFAULT_STACK_SIZE; + s_core.task_priority = config->task_priority ? config->task_priority : CLAW_CORE_DEFAULT_PRIORITY; + s_core.task_core = config->task_core; + s_core.max_tool_iterations = config->max_tool_iterations ? + config->max_tool_iterations : CLAW_CORE_DEFAULT_TOOL_ITERATIONS; + s_core.context_provider_capacity = config->max_context_providers; + + if (s_core.context_provider_capacity > 0) { + s_core.context_providers = calloc(s_core.context_provider_capacity, + sizeof(claw_core_context_provider_t)); + if (!s_core.context_providers) { + free(s_core.system_prompt); + memset(&s_core, 0, sizeof(s_core)); + return ESP_ERR_NO_MEM; + } + } + + s_core.request_queue = xQueueCreate(request_queue_len, sizeof(claw_core_request_item_t)); + s_core.response_queue = xQueueCreate(response_queue_len, sizeof(claw_core_response_item_t)); + s_core.response_lock = xSemaphoreCreateMutex(); + if (!s_core.request_queue || !s_core.response_queue || !s_core.response_lock) { + free_context_provider_storage(); + free(s_core.system_prompt); + if (s_core.request_queue) { + vQueueDelete(s_core.request_queue); + } + if (s_core.response_queue) { + vQueueDelete(s_core.response_queue); + } + if (s_core.response_lock) { + vSemaphoreDelete(s_core.response_lock); + } + memset(&s_core, 0, sizeof(s_core)); + return ESP_ERR_NO_MEM; + } + + llm_config.api_key = config->api_key; + llm_config.backend_type = config->backend_type; + llm_config.profile = config->profile; + llm_config.provider = config->provider; + llm_config.model = config->model; + llm_config.base_url = config->base_url; + llm_config.auth_type = config->auth_type; + llm_config.timeout_ms = config->timeout_ms; + llm_config.image_max_bytes = config->image_max_bytes; + err = claw_core_llm_init(&llm_config, &llm_error); + if (err != ESP_OK) { + ESP_LOGE(TAG, "LLM init failed: %s", llm_error ? llm_error : esp_err_to_name(err)); + free(llm_error); + free_context_provider_storage(); + free(s_core.system_prompt); + vQueueDelete(s_core.request_queue); + vQueueDelete(s_core.response_queue); + vSemaphoreDelete(s_core.response_lock); + memset(&s_core, 0, sizeof(s_core)); + return err; + } + + s_core.initialized = true; + ESP_LOGI(TAG, "Initialized"); + return ESP_OK; +} + +esp_err_t claw_core_start(void) +{ + BaseType_t task_result; + + if (!s_core.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (s_core.started) { + return ESP_OK; + } + + if (s_core.task_core == tskNO_AFFINITY) { + task_result = xTaskCreate(claw_core_task, + "claw_core", + s_core.task_stack_size, + NULL, + s_core.task_priority, + &s_core.task_handle); + } else { + task_result = xTaskCreatePinnedToCore(claw_core_task, + "claw_core", + s_core.task_stack_size, + NULL, + s_core.task_priority, + &s_core.task_handle, + s_core.task_core); + } + + if (task_result != pdPASS) { + return ESP_FAIL; + } + + s_core.started = true; + ESP_LOGI(TAG, "Started worker task"); + return ESP_OK; +} + +esp_err_t claw_core_add_context_provider(const claw_core_context_provider_t *provider) +{ + claw_core_context_provider_t *slot = NULL; + + if (!s_core.initialized || s_core.started) { + return ESP_ERR_INVALID_STATE; + } + if (!provider || !provider->name || !provider->collect) { + return ESP_ERR_INVALID_ARG; + } + if (s_core.context_provider_count >= s_core.context_provider_capacity) { + return ESP_ERR_NO_MEM; + } + + slot = &s_core.context_providers[s_core.context_provider_count]; + slot->name = dup_string(provider->name); + if (!slot->name) { + return ESP_ERR_NO_MEM; + } + slot->collect = provider->collect; + slot->user_ctx = provider->user_ctx; + s_core.context_provider_count++; + return ESP_OK; +} + +esp_err_t claw_core_call_cap(const char *cap_name, + const char *input_json, + const claw_core_request_t *request, + char **out_output) +{ + if (!s_core.initialized || !s_core.call_cap) { + return ESP_ERR_INVALID_STATE; + } + + return s_core.call_cap(cap_name, + input_json, + request, + out_output, + s_core.cap_user_ctx); +} + +esp_err_t claw_core_submit(const claw_core_request_t *request, uint32_t timeout_ms) +{ + claw_core_request_item_t item = {0}; + TickType_t ticks; + + if (!s_core.started || !request || !request->user_text || request->user_text[0] == '\0') { + return s_core.started ? ESP_ERR_INVALID_ARG : ESP_ERR_INVALID_STATE; + } + + item.view.request_id = request->request_id; + item.owned_session_id = dup_string(request->session_id); + item.owned_user_text = dup_string(request->user_text); + item.owned_source_channel = dup_string(request->source_channel); + item.owned_source_chat_id = dup_string(request->source_chat_id); + item.owned_source_sender_id = dup_string(request->source_sender_id); + item.owned_source_message_id = dup_string(request->source_message_id); + item.owned_source_cap = dup_string(request->source_cap); + item.owned_target_channel = dup_string(request->target_channel); + item.owned_target_chat_id = dup_string(request->target_chat_id); + + item.view.session_id = item.owned_session_id; + item.view.user_text = item.owned_user_text; + item.view.source_channel = item.owned_source_channel; + item.view.source_chat_id = item.owned_source_chat_id; + item.view.source_sender_id = item.owned_source_sender_id; + item.view.source_message_id = item.owned_source_message_id; + item.view.source_cap = item.owned_source_cap; + item.view.target_channel = item.owned_target_channel; + item.view.target_chat_id = item.owned_target_chat_id; + + if ((request->session_id && !item.owned_session_id) || + (request->source_channel && !item.owned_source_channel) || + (request->source_chat_id && !item.owned_source_chat_id) || + (request->source_sender_id && !item.owned_source_sender_id) || + (request->source_message_id && !item.owned_source_message_id) || + (request->source_cap && !item.owned_source_cap) || + (request->target_channel && !item.owned_target_channel) || + (request->target_chat_id && !item.owned_target_chat_id) || + !item.owned_user_text) { + free_request_item(&item); + return ESP_ERR_NO_MEM; + } + + ticks = (timeout_ms == UINT32_MAX) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms); + if (xQueueSend(s_core.request_queue, &item, ticks) != pdTRUE) { + free_request_item(&item); + return ESP_ERR_TIMEOUT; + } + + return ESP_OK; +} + +esp_err_t claw_core_receive(claw_core_response_t *response, uint32_t timeout_ms) +{ + return claw_core_receive_for(0, response, timeout_ms); +} + +esp_err_t claw_core_receive_for(uint32_t request_id, + claw_core_response_t *response, + uint32_t timeout_ms) +{ + claw_core_response_item_t item = {0}; + TickType_t start_ticks; + bool match_any; + + if (!s_core.started || !response) { + return s_core.started ? ESP_ERR_INVALID_ARG : ESP_ERR_INVALID_STATE; + } + + if (xSemaphoreTake(s_core.response_lock, portMAX_DELAY) != pdTRUE) { + return ESP_FAIL; + } + start_ticks = xTaskGetTickCount(); + match_any = (request_id == 0); + + if (pop_pending_response(request_id, match_any, &item)) { + xSemaphoreGive(s_core.response_lock); + move_response_item(response, &item); + return ESP_OK; + } + + while (true) { + TickType_t wait_ticks; + TickType_t elapsed = xTaskGetTickCount() - start_ticks; + + if (timeout_ms == UINT32_MAX) { + wait_ticks = portMAX_DELAY; + } else { + TickType_t timeout_ticks = pdMS_TO_TICKS(timeout_ms); + + if (elapsed >= timeout_ticks) { + xSemaphoreGive(s_core.response_lock); + return ESP_ERR_TIMEOUT; + } + wait_ticks = timeout_ticks - elapsed; + } + + if (xQueueReceive(s_core.response_queue, &item, wait_ticks) != pdTRUE) { + xSemaphoreGive(s_core.response_lock); + return ESP_ERR_TIMEOUT; + } + + if (match_any || item.view.request_id == request_id) { + xSemaphoreGive(s_core.response_lock); + move_response_item(response, &item); + return ESP_OK; + } + + if (enqueue_pending_response(&item) != ESP_OK) { + free_response_item(&item); + } + } +} + +void claw_core_response_free(claw_core_response_t *response) +{ + if (!response) { + return; + } + + free(response->target_channel); + free(response->target_chat_id); + free(response->text); + free(response->error_message); + response->target_channel = NULL; + response->target_chat_id = NULL; + response->text = NULL; + response->error_message = NULL; +} diff --git a/components/claw_core/src/claw_core_llm.c b/components/claw_core/src/claw_core_llm.c new file mode 100644 index 0000000..15d855e --- /dev/null +++ b/components/claw_core/src/claw_core_llm.c @@ -0,0 +1,180 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "claw_core_llm.h" + +#include +#include +#include +#include + +static claw_llm_runtime_t *s_runtime = NULL; + +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; +} + +esp_err_t claw_core_llm_init(const claw_core_llm_config_t *config, char **out_error_message) +{ + claw_llm_runtime_config_t runtime_config = {0}; + + if (out_error_message) { + *out_error_message = NULL; + } + if (!config || !config->api_key || !config->model || !out_error_message) { + return ESP_ERR_INVALID_ARG; + } + + claw_llm_runtime_deinit(s_runtime); + s_runtime = NULL; + + runtime_config.api_key = config->api_key; + runtime_config.backend_type = config->backend_type; + runtime_config.profile = (config->profile && config->profile[0]) ? + config->profile : config->provider; + runtime_config.model = config->model; + runtime_config.base_url = config->base_url; + runtime_config.auth_type = config->auth_type; + runtime_config.timeout_ms = config->timeout_ms; + runtime_config.image_max_bytes = config->image_max_bytes; + return claw_llm_runtime_init(&s_runtime, &runtime_config, out_error_message); +} + +esp_err_t claw_core_llm_chat_messages(const char *system_prompt, + cJSON *messages, + const char *tools_json, + claw_core_llm_response_t *out_response, + char **out_error_message) +{ + claw_llm_chat_request_t request = {0}; + + if (!s_runtime) { + if (out_error_message) { + *out_error_message = dup_printf("LLM runtime is not initialized"); + } + return ESP_ERR_INVALID_STATE; + } + if (!system_prompt || !messages || !out_response || !out_error_message || !cJSON_IsArray(messages)) { + return ESP_ERR_INVALID_ARG; + } + + request.system_prompt = system_prompt; + request.messages = messages; + request.tools_json = tools_json; + return claw_llm_runtime_chat(s_runtime, &request, out_response, out_error_message); +} + +esp_err_t claw_core_llm_chat(const char *system_prompt, + const char *user_text, + char **out_text, + char **out_error_message) +{ + claw_core_llm_response_t response = {0}; + cJSON *messages = NULL; + cJSON *user_msg = NULL; + esp_err_t err; + + if (out_text) { + *out_text = NULL; + } + if (!system_prompt || !user_text || !out_text || !out_error_message) { + return ESP_ERR_INVALID_ARG; + } + + messages = cJSON_CreateArray(); + user_msg = cJSON_CreateObject(); + if (!messages || !user_msg) { + cJSON_Delete(messages); + cJSON_Delete(user_msg); + *out_error_message = dup_printf("Out of memory building messages"); + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(user_msg, "role", "user"); + cJSON_AddStringToObject(user_msg, "content", user_text); + cJSON_AddItemToArray(messages, user_msg); + + err = claw_core_llm_chat_messages(system_prompt, messages, NULL, &response, out_error_message); + cJSON_Delete(messages); + if (err != ESP_OK) { + claw_core_llm_response_free(&response); + return err; + } + if (response.tool_call_count > 0) { + claw_core_llm_response_free(&response); + *out_error_message = dup_printf("LLM returned unsupported tool calls"); + return ESP_ERR_NOT_SUPPORTED; + } + + *out_text = response.text; + response.text = NULL; + claw_core_llm_response_free(&response); + return ESP_OK; +} + +esp_err_t claw_core_llm_infer_media(const claw_llm_media_request_t *request, + char **out_text, + char **out_error_message) +{ + if (!s_runtime) { + if (out_error_message) { + *out_error_message = dup_printf("LLM runtime is not initialized"); + } + return ESP_ERR_INVALID_STATE; + } + return claw_llm_runtime_infer_media(s_runtime, request, out_text, out_error_message); +} + +esp_err_t claw_core_llm_analyze_image(const char *system_prompt, + const char *user_prompt, + const char *image_path, + char **out_text, + char **out_error_message) +{ + claw_media_asset_t asset = {0}; + claw_llm_media_request_t request = {0}; + + asset.kind = CLAW_MEDIA_ASSET_KIND_LOCAL_PATH; + asset.path = image_path; + + request.system_prompt = system_prompt; + request.user_prompt = user_prompt; + request.media = &asset; + request.media_count = 1; + return claw_core_llm_infer_media(&request, out_text, out_error_message); +} + +esp_err_t claw_core_llm_register_custom_backend(const claw_llm_custom_backend_registration_t *registration) +{ + return claw_llm_register_custom_backend(registration); +} + +void claw_core_llm_response_free(claw_core_llm_response_t *response) +{ + claw_llm_response_free(response); +} diff --git a/components/claw_core/src/claw_core_llm.h b/components/claw_core/src/claw_core_llm.h new file mode 100644 index 0000000..0066cc3 --- /dev/null +++ b/components/claw_core/src/claw_core_llm.h @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "llm/claw_llm_runtime.h" + +typedef struct { + const char *api_key; + const char *backend_type; + const char *profile; + const char *provider; + const char *model; + const char *base_url; + const char *auth_type; + uint32_t timeout_ms; + size_t image_max_bytes; +} claw_core_llm_config_t; + +typedef claw_llm_tool_call_t claw_core_llm_tool_call_t; +typedef claw_llm_response_t claw_core_llm_response_t; + +esp_err_t claw_core_llm_init(const claw_core_llm_config_t *config, char **out_error_message); +esp_err_t claw_core_llm_chat_messages(const char *system_prompt, + cJSON *messages, + const char *tools_json, + claw_core_llm_response_t *out_response, + char **out_error_message); +esp_err_t claw_core_llm_chat(const char *system_prompt, + const char *user_text, + char **out_text, + char **out_error_message); +esp_err_t claw_core_llm_analyze_image(const char *system_prompt, + const char *user_prompt, + const char *image_path, + char **out_text, + char **out_error_message); +esp_err_t claw_core_llm_infer_media(const claw_llm_media_request_t *request, + char **out_text, + char **out_error_message); +esp_err_t claw_core_llm_register_custom_backend(const claw_llm_custom_backend_registration_t *registration); +void claw_core_llm_response_free(claw_core_llm_response_t *response); diff --git a/components/claw_core/src/llm/backends/claw_llm_backend_custom.c b/components/claw_core/src/llm/backends/claw_llm_backend_custom.c new file mode 100644 index 0000000..bb609c8 --- /dev/null +++ b/components/claw_core/src/llm/backends/claw_llm_backend_custom.c @@ -0,0 +1,149 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "llm/backends/claw_llm_backend_custom.h" + +#include +#include + +typedef struct { + const claw_llm_backend_vtable_t *delegate; + void *delegate_ctx; +} custom_backend_ctx_t; + +typedef struct custom_backend_registration_node { + claw_llm_custom_backend_registration_t registration; + struct custom_backend_registration_node *next; +} custom_backend_registration_node_t; + +static custom_backend_registration_node_t *s_registrations = NULL; + +static const claw_llm_backend_vtable_t *find_custom_backend(const char *id) +{ + custom_backend_registration_node_t *node = s_registrations; + + while (node) { + if (strcmp(node->registration.id, id) == 0) { + return node->registration.vtable; + } + node = node->next; + } + + return NULL; +} + +esp_err_t claw_llm_register_custom_backend(const claw_llm_custom_backend_registration_t *registration) +{ + custom_backend_registration_node_t *node; + + if (!registration || !registration->id || !registration->vtable) { + return ESP_ERR_INVALID_ARG; + } + if (find_custom_backend(registration->id)) { + return ESP_ERR_INVALID_STATE; + } + + node = calloc(1, sizeof(*node)); + if (!node) { + return ESP_ERR_NO_MEM; + } + + node->registration = *registration; + node->next = s_registrations; + s_registrations = node; + return ESP_OK; +} + +static esp_err_t custom_backend_init(const claw_llm_runtime_config_t *config, + const claw_llm_model_profile_t *profile, + void **out_backend_ctx, + char **out_error_message) +{ + custom_backend_ctx_t *ctx; + const claw_llm_backend_vtable_t *delegate; + esp_err_t err; + + if (!config || !config->profile || !out_backend_ctx || !out_error_message) { + return ESP_ERR_INVALID_ARG; + } + + delegate = find_custom_backend(config->profile); + if (!delegate) { + *out_error_message = strdup("No custom backend is registered for the selected profile"); + return ESP_ERR_NOT_FOUND; + } + + ctx = calloc(1, sizeof(*ctx)); + if (!ctx) { + *out_error_message = strdup("Out of memory allocating custom backend"); + return ESP_ERR_NO_MEM; + } + + ctx->delegate = delegate; + err = delegate->init(config, profile, &ctx->delegate_ctx, out_error_message); + if (err != ESP_OK) { + free(ctx); + return err; + } + + *out_backend_ctx = ctx; + return ESP_OK; +} + +static esp_err_t custom_backend_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) +{ + custom_backend_ctx_t *ctx = (custom_backend_ctx_t *)backend_ctx; + + if (!ctx || !ctx->delegate || !ctx->delegate->chat) { + return ESP_ERR_INVALID_STATE; + } + + return ctx->delegate->chat(ctx->delegate_ctx, profile, request, out_response, out_error_message); +} + +static esp_err_t custom_backend_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) +{ + custom_backend_ctx_t *ctx = (custom_backend_ctx_t *)backend_ctx; + + if (!ctx || !ctx->delegate || !ctx->delegate->infer_media) { + return ESP_ERR_INVALID_STATE; + } + + return ctx->delegate->infer_media(ctx->delegate_ctx, profile, request, out_text, out_error_message); +} + +static void custom_backend_deinit(void *backend_ctx) +{ + custom_backend_ctx_t *ctx = (custom_backend_ctx_t *)backend_ctx; + + if (!ctx) { + return; + } + if (ctx->delegate && ctx->delegate->deinit) { + ctx->delegate->deinit(ctx->delegate_ctx); + } + free(ctx); +} + +const claw_llm_backend_vtable_t *claw_llm_backend_custom_vtable(void) +{ + static const claw_llm_backend_vtable_t vtable = { + .id = "custom", + .init = custom_backend_init, + .chat = custom_backend_chat, + .infer_media = custom_backend_infer_media, + .deinit = custom_backend_deinit, + }; + + return &vtable; +} diff --git a/components/claw_core/src/llm/backends/claw_llm_backend_custom.h b/components/claw_core/src/llm/backends/claw_llm_backend_custom.h new file mode 100644 index 0000000..f8f0042 --- /dev/null +++ b/components/claw_core/src/llm/backends/claw_llm_backend_custom.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_custom_vtable(void); diff --git a/components/claw_core/src/llm/backends/claw_llm_backend_openai_compatible.c b/components/claw_core/src/llm/backends/claw_llm_backend_openai_compatible.c new file mode 100644 index 0000000..5955c85 --- /dev/null +++ b/components/claw_core/src/llm/backends/claw_llm_backend_openai_compatible.c @@ -0,0 +1,524 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "llm/backends/claw_llm_backend_openai_compatible.h" + +#include +#include +#include +#include +#include + +#include "llm/claw_llm_http_transport.h" +#include "llm/media/claw_media_pipeline.h" + +typedef struct { + char *api_key; + char *model; + char *base_url; + char *auth_type; + uint32_t timeout_ms; + size_t image_max_bytes; +} openai_compatible_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 esp_err_t dup_tool_call_string(cJSON *json, char **out_value) +{ + if (!out_value || !json || !cJSON_IsString(json) || !json->valuestring) { + return ESP_ERR_INVALID_ARG; + } + *out_value = strdup(json->valuestring); + return *out_value ? ESP_OK : ESP_ERR_NO_MEM; +} + +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 parse_chat_response(const char *body, + claw_llm_response_t *out_response, + char **out_error_message) +{ + cJSON *root = NULL; + cJSON *choices; + cJSON *choice0; + cJSON *message; + cJSON *content; + cJSON *tool_calls; + cJSON *tool_call; + size_t tool_count = 0; + size_t tool_index = 0; + + 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; + } + + choices = cJSON_GetObjectItem(root, "choices"); + choice0 = choices && cJSON_IsArray(choices) ? cJSON_GetArrayItem(choices, 0) : NULL; + message = choice0 ? cJSON_GetObjectItem(choice0, "message") : NULL; + if (!message || !cJSON_IsObject(message)) { + cJSON_Delete(root); + *out_error_message = dup_printf("LLM response missing message"); + return ESP_FAIL; + } + + content = cJSON_GetObjectItem(message, "content"); + if (content && cJSON_IsString(content) && content->valuestring[0]) { + out_response->text = strdup(content->valuestring); + if (!out_response->text) { + cJSON_Delete(root); + *out_error_message = dup_printf("Out of memory copying LLM response"); + return ESP_ERR_NO_MEM; + } + } + + tool_calls = cJSON_GetObjectItem(message, "tool_calls"); + if (tool_calls && cJSON_IsArray(tool_calls)) { + tool_count = (size_t)cJSON_GetArraySize(tool_calls); + 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(tool_call, tool_calls) { + claw_llm_tool_call_t *dst = &out_response->tool_calls[tool_index]; + cJSON *id_json = cJSON_GetObjectItem(tool_call, "id"); + cJSON *function_json = cJSON_GetObjectItem(tool_call, "function"); + cJSON *name_json = function_json ? cJSON_GetObjectItem(function_json, "name") : NULL; + cJSON *args_json = function_json ? cJSON_GetObjectItem(function_json, "arguments") : NULL; + esp_err_t err; + + if (!id_json || !function_json || !name_json || !args_json) { + cJSON_Delete(root); + *out_error_message = dup_printf("Malformed tool call in LLM response"); + return ESP_FAIL; + } + + err = dup_tool_call_string(id_json, &dst->id); + if (err == ESP_OK) { + err = dup_tool_call_string(name_json, &dst->name); + } + if (err == ESP_OK) { + err = dup_tool_call_string(args_json, &dst->arguments_json); + } + if (err != ESP_OK) { + cJSON_Delete(root); + *out_error_message = dup_printf("Out of memory copying tool call"); + return err; + } + tool_index++; + } + } + + if (!out_response->text && out_response->tool_call_count == 0) { + cJSON_Delete(root); + *out_error_message = dup_printf("LLM returned empty text response"); + return ESP_FAIL; + } + + cJSON_Delete(root); + return ESP_OK; +} + +static esp_err_t build_chat_body(const openai_compatible_backend_ctx_t *ctx, + const claw_llm_model_profile_t *profile, + const claw_llm_chat_request_t *request, + char **out_post_data, + char **out_error_message) +{ + cJSON *body = NULL; + cJSON *messages = NULL; + cJSON *system_msg = NULL; + cJSON *item; + char *post_data = NULL; + + body = cJSON_CreateObject(); + messages = cJSON_CreateArray(); + system_msg = cJSON_CreateObject(); + if (!body || !messages || !system_msg) { + *out_error_message = dup_printf("Out of memory building request"); + cJSON_Delete(body); + cJSON_Delete(messages); + cJSON_Delete(system_msg); + return ESP_ERR_NO_MEM; + } + + cJSON_AddStringToObject(body, "model", ctx->model); + cJSON_AddNumberToObject(body, profile->max_tokens_field, 8192); + + cJSON_AddStringToObject(system_msg, "role", "system"); + cJSON_AddStringToObject(system_msg, "content", request->system_prompt); + cJSON_AddItemToArray(messages, system_msg); + system_msg = NULL; + + cJSON_ArrayForEach(item, request->messages) { + cJSON *dup = cJSON_Duplicate(item, true); + if (!dup) { + *out_error_message = dup_printf("Out of memory copying messages"); + cJSON_Delete(body); + cJSON_Delete(messages); + return ESP_ERR_NO_MEM; + } + cJSON_AddItemToArray(messages, dup); + } + + cJSON_AddItemToObject(body, "messages", messages); + messages = NULL; + + if (request->tools_json && request->tools_json[0]) { + cJSON *tools = cJSON_Parse(request->tools_json); + + if (!tools || !cJSON_IsArray(tools)) { + cJSON_Delete(tools); + cJSON_Delete(body); + *out_error_message = dup_printf("Invalid tools JSON"); + return ESP_ERR_INVALID_ARG; + } + cJSON_AddItemToObject(body, "tools", 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 openai_compatible_init(const claw_llm_runtime_config_t *config, + const claw_llm_model_profile_t *profile, + void **out_backend_ctx, + char **out_error_message) +{ + openai_compatible_backend_ctx_t *ctx; + const char *base_url; + const char *auth_type; + + 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; + auth_type = (config->auth_type && config->auth_type[0]) ? config->auth_type : "bearer"; + 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->auth_type = strdup(auth_type); + 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 || !ctx->auth_type) { + *out_error_message = dup_printf("Out of memory copying backend config"); + free(ctx->api_key); + free(ctx->model); + free(ctx->base_url); + free(ctx->auth_type); + free(ctx); + return ESP_ERR_NO_MEM; + } + + *out_backend_ctx = ctx; + return ESP_OK; +} + +static esp_err_t openai_compatible_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) +{ + openai_compatible_backend_ctx_t *ctx = (openai_compatible_backend_ctx_t *)backend_ctx; + claw_llm_http_json_request_t http_request = {0}; + claw_llm_http_response_t http_response = {0}; + char *url = NULL; + char *post_data = NULL; + esp_err_t err; + + if (!ctx || !profile || !request || !request->system_prompt || !request->messages || + !out_response || !out_error_message) { + return ESP_ERR_INVALID_ARG; + } + + err = build_chat_body(ctx, profile, 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.api_key = ctx->api_key; + http_request.auth_type = ctx->auth_type; + http_request.timeout_ms = ctx->timeout_ms; + + 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 openai_compatible_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) +{ + openai_compatible_backend_ctx_t *ctx = (openai_compatible_backend_ctx_t *)backend_ctx; + claw_media_prepared_t prepared = {0}; + claw_llm_response_t response = {0}; + cJSON *messages = NULL; + cJSON *system_msg = NULL; + cJSON *user_msg = NULL; + cJSON *content = NULL; + cJSON *text_block = NULL; + cJSON *image_block = NULL; + cJSON *image_value = NULL; + char *url = NULL; + char *post_data = NULL; + claw_llm_http_json_request_t http_request = {0}; + claw_llm_http_response_t http_response = {0}; + cJSON *body = NULL; + esp_err_t err; + + 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; + } + + body = cJSON_CreateObject(); + messages = cJSON_CreateArray(); + system_msg = cJSON_CreateObject(); + user_msg = cJSON_CreateObject(); + content = cJSON_CreateArray(); + text_block = cJSON_CreateObject(); + image_block = cJSON_CreateObject(); + image_value = cJSON_CreateObject(); + if (!body || !messages || !system_msg || !user_msg || !content || + !text_block || !image_block || !image_value) { + 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, profile->max_tokens_field, 8192); + + cJSON_AddStringToObject(system_msg, "role", "system"); + cJSON_AddStringToObject(system_msg, "content", request->system_prompt ? request->system_prompt : ""); + cJSON_AddItemToArray(messages, system_msg); + system_msg = NULL; + + 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_url"); + cJSON_AddStringToObject(image_value, "url", prepared.payload); + cJSON_AddItemToObject(image_block, "image_url", image_value); + image_value = 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.api_key = ctx->api_key; + http_request.auth_type = ctx->auth_type; + http_request.timeout_ms = ctx->timeout_ms; + + 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(url); + free(post_data); + claw_llm_http_response_free(&http_response); + claw_llm_response_free(&response); + claw_media_prepared_free(&prepared); + cJSON_Delete(body); + cJSON_Delete(messages); + cJSON_Delete(system_msg); + cJSON_Delete(user_msg); + cJSON_Delete(content); + cJSON_Delete(text_block); + cJSON_Delete(image_block); + cJSON_Delete(image_value); + return err; +} + +static void openai_compatible_deinit(void *backend_ctx) +{ + openai_compatible_backend_ctx_t *ctx = (openai_compatible_backend_ctx_t *)backend_ctx; + + if (!ctx) { + return; + } + + free(ctx->api_key); + free(ctx->model); + free(ctx->base_url); + free(ctx->auth_type); + free(ctx); +} + +const claw_llm_backend_vtable_t *claw_llm_backend_openai_compatible_vtable(void) +{ + static const claw_llm_backend_vtable_t vtable = { + .id = "openai_compatible", + .init = openai_compatible_init, + .chat = openai_compatible_chat, + .infer_media = openai_compatible_infer_media, + .deinit = openai_compatible_deinit, + }; + + return &vtable; +} diff --git a/components/claw_core/src/llm/backends/claw_llm_backend_openai_compatible.h b/components/claw_core/src/llm/backends/claw_llm_backend_openai_compatible.h new file mode 100644 index 0000000..39e15ce --- /dev/null +++ b/components/claw_core/src/llm/backends/claw_llm_backend_openai_compatible.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_openai_compatible_vtable(void); diff --git a/components/claw_core/src/llm/claw_llm_http_transport.c b/components/claw_core/src/llm/claw_llm_http_transport.c new file mode 100644 index 0000000..17c6ab0 --- /dev/null +++ b/components/claw_core/src/llm/claw_llm_http_transport.c @@ -0,0 +1,264 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "llm/claw_llm_http_transport.h" + +#include +#include +#include +#include + +#include "esp_crt_bundle.h" +#include "esp_http_client.h" + +#define CLAW_LLM_HTTP_RB_INITIAL_CAP 4096 + +typedef struct { + char *data; + size_t len; + size_t cap; +} response_buffer_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 esp_err_t response_buffer_init(response_buffer_t *buffer) +{ + if (!buffer) { + return ESP_ERR_INVALID_ARG; + } + + buffer->data = calloc(1, CLAW_LLM_HTTP_RB_INITIAL_CAP); + if (!buffer->data) { + return ESP_ERR_NO_MEM; + } + + buffer->cap = CLAW_LLM_HTTP_RB_INITIAL_CAP; + buffer->len = 0; + return ESP_OK; +} + +static esp_err_t response_buffer_append(response_buffer_t *buffer, const char *data, size_t len) +{ + char *grown; + size_t cap; + + if (!buffer || !data) { + return ESP_ERR_INVALID_ARG; + } + + cap = buffer->cap; + while (buffer->len + len + 1 > cap) { + cap *= 2; + } + + if (cap != buffer->cap) { + grown = realloc(buffer->data, cap); + if (!grown) { + return ESP_ERR_NO_MEM; + } + buffer->data = grown; + buffer->cap = cap; + } + + memcpy(buffer->data + buffer->len, data, len); + buffer->len += len; + buffer->data[buffer->len] = '\0'; + return ESP_OK; +} + +static void response_buffer_free(response_buffer_t *buffer) +{ + if (!buffer) { + return; + } + + free(buffer->data); + memset(buffer, 0, sizeof(*buffer)); +} + +static esp_err_t http_event_handler(esp_http_client_event_t *evt) +{ + response_buffer_t *buffer = (response_buffer_t *)evt->user_data; + + if (evt->event_id == HTTP_EVENT_ON_DATA) { + return response_buffer_append(buffer, (const char *)evt->data, evt->data_len); + } + + return ESP_OK; +} + +static char *build_auth_header_value(const char *auth_type, const char *api_key) +{ + const char *kind = auth_type ? auth_type : "bearer"; + + if (!api_key || !api_key[0]) { + return NULL; + } + if (strcmp(kind, "none") == 0) { + return NULL; + } + if (strcmp(kind, "api-key") == 0) { + return strdup(api_key); + } + + return dup_printf("Bearer %s", api_key); +} + +static const char *auth_header_name(const char *auth_type) +{ + if (auth_type && strcmp(auth_type, "api-key") == 0) { + return "X-API-Key"; + } + return "Authorization"; +} + +static char *parse_error_message_body(const char *body, int status) +{ + cJSON *root; + cJSON *error; + cJSON *message; + char *fallback; + + if (!body || !body[0]) { + return dup_printf("HTTP %d", status); + } + + root = cJSON_Parse(body); + if (!root) { + return dup_printf("HTTP %d: %.160s", status, body); + } + + error = cJSON_GetObjectItem(root, "error"); + if (error && cJSON_IsObject(error)) { + message = cJSON_GetObjectItem(error, "message"); + if (message && cJSON_IsString(message) && message->valuestring[0]) { + fallback = dup_printf("HTTP %d: %s", status, message->valuestring); + cJSON_Delete(root); + return fallback; + } + } + + message = cJSON_GetObjectItem(root, "message"); + if (message && cJSON_IsString(message) && message->valuestring[0]) { + fallback = dup_printf("HTTP %d: %s", status, message->valuestring); + cJSON_Delete(root); + return fallback; + } + + cJSON_Delete(root); + return dup_printf("HTTP %d: %.160s", status, body); +} + +esp_err_t claw_llm_http_post_json(const claw_llm_http_json_request_t *request, + claw_llm_http_response_t *out_response, + char **out_error_message) +{ + response_buffer_t buffer = {0}; + esp_http_client_config_t config = {0}; + esp_http_client_handle_t client = NULL; + char *auth_header_value = NULL; + int status_code = 0; + esp_err_t err; + + if (out_response) { + memset(out_response, 0, sizeof(*out_response)); + } + if (out_error_message) { + *out_error_message = NULL; + } + if (!request || !request->url || !request->body || !out_response || !out_error_message) { + return ESP_ERR_INVALID_ARG; + } + + err = response_buffer_init(&buffer); + if (err != ESP_OK) { + *out_error_message = dup_printf("Out of memory allocating HTTP buffer"); + return err; + } + + config.url = request->url; + config.event_handler = http_event_handler; + config.user_data = &buffer; + config.timeout_ms = request->timeout_ms; + config.buffer_size = 4096; + config.buffer_size_tx = 4096; + config.crt_bundle_attach = esp_crt_bundle_attach; + + client = esp_http_client_init(&config); + if (!client) { + *out_error_message = dup_printf("Failed to create HTTP client"); + err = ESP_FAIL; + goto cleanup; + } + + esp_http_client_set_method(client, HTTP_METHOD_POST); + esp_http_client_set_header(client, "Content-Type", "application/json"); + auth_header_value = build_auth_header_value(request->auth_type, request->api_key); + if (auth_header_value) { + esp_http_client_set_header(client, auth_header_name(request->auth_type), auth_header_value); + } + esp_http_client_set_post_field(client, request->body, (int)strlen(request->body)); + + err = esp_http_client_perform(client); + if (err != ESP_OK) { + *out_error_message = dup_printf("HTTP request failed: %s", esp_err_to_name(err)); + goto cleanup; + } + + status_code = esp_http_client_get_status_code(client); + if (status_code != 200) { + err = ESP_FAIL; + *out_error_message = parse_error_message_body(buffer.data, status_code); + goto cleanup; + } + + out_response->status_code = status_code; + out_response->body = buffer.data; + buffer.data = NULL; + err = ESP_OK; + +cleanup: + free(auth_header_value); + if (client) { + esp_http_client_cleanup(client); + } + response_buffer_free(&buffer); + return err; +} + +void claw_llm_http_response_free(claw_llm_http_response_t *response) +{ + if (!response) { + return; + } + + free(response->body); + memset(response, 0, sizeof(*response)); +} diff --git a/components/claw_core/src/llm/claw_llm_http_transport.h b/components/claw_core/src/llm/claw_llm_http_transport.h new file mode 100644 index 0000000..304ed26 --- /dev/null +++ b/components/claw_core/src/llm/claw_llm_http_transport.h @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "llm/claw_llm_types.h" + +esp_err_t claw_llm_http_post_json(const claw_llm_http_json_request_t *request, + claw_llm_http_response_t *out_response, + char **out_error_message); +void claw_llm_http_response_free(claw_llm_http_response_t *response); diff --git a/components/claw_core/src/llm/claw_llm_runtime.c b/components/claw_core/src/llm/claw_llm_runtime.c new file mode 100644 index 0000000..7aa97fe --- /dev/null +++ b/components/claw_core/src/llm/claw_llm_runtime.c @@ -0,0 +1,311 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "llm/claw_llm_runtime.h" + +#include +#include + +#include "llm/backends/claw_llm_backend_custom.h" +#include "llm/backends/claw_llm_backend_openai_compatible.h" + +#define CLAW_LLM_DEFAULT_TIMEOUT_MS (120 * 1000) +#define CLAW_LLM_DEFAULT_IMAGE_MAX_BYTES (512 * 1024) + +struct claw_llm_runtime { + claw_llm_runtime_config_t config; + const claw_llm_model_profile_t *profile; + const claw_llm_backend_vtable_t *backend; + void *backend_ctx; +}; + +static const claw_llm_model_profile_t s_profiles[] = { + { + .id = "openai", + .default_backend_type = "openai_compatible", + .default_base_url = "https://api.openai.com", + .chat_path = "/v1/chat/completions", + .max_tokens_field = "max_completion_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 = "qwen_compatible", + .default_backend_type = "openai_compatible", + .default_base_url = "https://dashscope.aliyuncs.com", + .chat_path = "/compatible-mode/v1/chat/completions", + .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_openai_compatible", + .default_backend_type = "openai_compatible", + .default_base_url = "https://api.openai.com", + .chat_path = "/v1/chat/completions", + .max_tokens_field = "max_completion_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", + .default_base_url = "", + .chat_path = "", + .max_tokens_field = "max_completion_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, + }, +}; + +static const claw_llm_backend_vtable_t *find_backend(const char *id) +{ + if (!id || strcmp(id, "openai_compatible") == 0) { + return claw_llm_backend_openai_compatible_vtable(); + } + if (strcmp(id, "custom") == 0) { + return claw_llm_backend_custom_vtable(); + } + return NULL; +} + +static char *dup_or_null(const char *value) +{ + return value ? strdup(value) : NULL; +} + +static const char *normalize_profile_id(const char *profile_id) +{ + if (!profile_id || !profile_id[0]) { + return claw_llm_profile_default()->id; + } + if (strcmp(profile_id, "qwen") == 0) { + return "qwen_compatible"; + } + if (strcmp(profile_id, "openai") == 0) { + return "openai"; + } + return profile_id; +} + +const claw_llm_model_profile_t *claw_llm_profile_find(const char *profile_id) +{ + size_t i; + const char *needle = normalize_profile_id(profile_id); + + for (i = 0; i < sizeof(s_profiles) / sizeof(s_profiles[0]); i++) { + if (strcmp(s_profiles[i].id, needle) == 0) { + return &s_profiles[i]; + } + } + + return NULL; +} + +const claw_llm_model_profile_t *claw_llm_profile_default(void) +{ + return &s_profiles[0]; +} + +static void runtime_config_free(claw_llm_runtime_config_t *config) +{ + if (!config) { + return; + } + + free((char *)config->api_key); + free((char *)config->backend_type); + free((char *)config->profile); + free((char *)config->model); + free((char *)config->base_url); + free((char *)config->auth_type); + memset(config, 0, sizeof(*config)); +} + +static esp_err_t runtime_config_copy(claw_llm_runtime_config_t *dst, + const claw_llm_runtime_config_t *src) +{ + dst->api_key = dup_or_null(src->api_key); + dst->backend_type = dup_or_null(src->backend_type); + dst->profile = dup_or_null(src->profile); + dst->model = dup_or_null(src->model); + dst->base_url = dup_or_null(src->base_url); + dst->auth_type = dup_or_null(src->auth_type); + dst->timeout_ms = src->timeout_ms; + dst->image_max_bytes = src->image_max_bytes; + + if ((src->api_key && !dst->api_key) || + (src->backend_type && !dst->backend_type) || + (src->profile && !dst->profile) || + (src->model && !dst->model) || + (src->base_url && !dst->base_url) || + (src->auth_type && !dst->auth_type)) { + runtime_config_free(dst); + return ESP_ERR_NO_MEM; + } + + return ESP_OK; +} + +esp_err_t claw_llm_runtime_init(claw_llm_runtime_t **out_runtime, + const claw_llm_runtime_config_t *config, + char **out_error_message) +{ + claw_llm_runtime_t *runtime; + const claw_llm_model_profile_t *profile; + const claw_llm_backend_vtable_t *backend; + const char *backend_type; + esp_err_t err; + + if (out_runtime) { + *out_runtime = NULL; + } + if (out_error_message) { + *out_error_message = NULL; + } + if (!out_runtime || !config || !config->api_key || !config->model || !out_error_message) { + return ESP_ERR_INVALID_ARG; + } + + profile = claw_llm_profile_find(config->profile); + if (!profile) { + *out_error_message = strdup("Unknown LLM profile"); + return ESP_ERR_NOT_SUPPORTED; + } + + backend_type = config->backend_type && config->backend_type[0] ? + config->backend_type : profile->default_backend_type; + backend = find_backend(backend_type); + if (!backend) { + *out_error_message = strdup("Unknown LLM backend type"); + return ESP_ERR_NOT_SUPPORTED; + } + + runtime = calloc(1, sizeof(*runtime)); + if (!runtime) { + *out_error_message = strdup("Out of memory allocating runtime"); + return ESP_ERR_NO_MEM; + } + + err = runtime_config_copy(&runtime->config, config); + if (err != ESP_OK) { + free(runtime); + *out_error_message = strdup("Out of memory copying runtime config"); + return err; + } + if (!runtime->config.profile) { + runtime->config.profile = strdup(profile->id); + } + if (!runtime->config.backend_type) { + runtime->config.backend_type = strdup(backend_type); + } + if (!runtime->config.base_url && profile->default_base_url[0]) { + runtime->config.base_url = strdup(profile->default_base_url); + } + if (!runtime->config.auth_type) { + runtime->config.auth_type = strdup("bearer"); + } + if (!runtime->config.timeout_ms) { + runtime->config.timeout_ms = profile->default_timeout_ms; + } + if (!runtime->config.image_max_bytes) { + runtime->config.image_max_bytes = profile->default_image_max_bytes; + } + if (!runtime->config.profile || !runtime->config.backend_type || !runtime->config.auth_type || + (profile->default_base_url[0] && !runtime->config.base_url)) { + runtime_config_free(&runtime->config); + free(runtime); + *out_error_message = strdup("Out of memory finalizing runtime config"); + return ESP_ERR_NO_MEM; + } + + runtime->profile = profile; + runtime->backend = backend; + + err = backend->init(&runtime->config, profile, &runtime->backend_ctx, out_error_message); + if (err != ESP_OK) { + runtime_config_free(&runtime->config); + free(runtime); + return err; + } + + *out_runtime = runtime; + return ESP_OK; +} + +esp_err_t claw_llm_runtime_chat(claw_llm_runtime_t *runtime, + const claw_llm_chat_request_t *request, + claw_llm_response_t *out_response, + char **out_error_message) +{ + if (!runtime || !runtime->backend || !runtime->backend->chat) { + return ESP_ERR_INVALID_STATE; + } + + return runtime->backend->chat(runtime->backend_ctx, + runtime->profile, + request, + out_response, + out_error_message); +} + +esp_err_t claw_llm_runtime_infer_media(claw_llm_runtime_t *runtime, + const claw_llm_media_request_t *request, + char **out_text, + char **out_error_message) +{ + if (!runtime || !runtime->backend || !runtime->backend->infer_media) { + return ESP_ERR_INVALID_STATE; + } + + return runtime->backend->infer_media(runtime->backend_ctx, + runtime->profile, + request, + out_text, + out_error_message); +} + +void claw_llm_runtime_deinit(claw_llm_runtime_t *runtime) +{ + if (!runtime) { + return; + } + + if (runtime->backend && runtime->backend->deinit) { + runtime->backend->deinit(runtime->backend_ctx); + } + runtime_config_free(&runtime->config); + free(runtime); +} + +void claw_llm_response_free(claw_llm_response_t *response) +{ + size_t i; + + if (!response) { + return; + } + + free(response->text); + for (i = 0; i < response->tool_call_count; i++) { + free(response->tool_calls[i].id); + free(response->tool_calls[i].name); + free(response->tool_calls[i].arguments_json); + } + free(response->tool_calls); + memset(response, 0, sizeof(*response)); +} diff --git a/components/claw_core/src/llm/claw_llm_runtime.h b/components/claw_core/src/llm/claw_llm_runtime.h new file mode 100644 index 0000000..13f8775 --- /dev/null +++ b/components/claw_core/src/llm/claw_llm_runtime.h @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "llm/claw_llm_types.h" + +typedef struct claw_llm_runtime claw_llm_runtime_t; + +typedef struct { + const char *id; + esp_err_t (*init)(const claw_llm_runtime_config_t *config, + const claw_llm_model_profile_t *profile, + void **out_backend_ctx, + char **out_error_message); + esp_err_t (*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); + esp_err_t (*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); + void (*deinit)(void *backend_ctx); +} claw_llm_backend_vtable_t; + +typedef struct { + const char *id; + const claw_llm_backend_vtable_t *vtable; +} claw_llm_custom_backend_registration_t; + +esp_err_t claw_llm_runtime_init(claw_llm_runtime_t **out_runtime, + const claw_llm_runtime_config_t *config, + char **out_error_message); +esp_err_t claw_llm_runtime_chat(claw_llm_runtime_t *runtime, + const claw_llm_chat_request_t *request, + claw_llm_response_t *out_response, + char **out_error_message); +esp_err_t claw_llm_runtime_infer_media(claw_llm_runtime_t *runtime, + const claw_llm_media_request_t *request, + char **out_text, + char **out_error_message); +void claw_llm_runtime_deinit(claw_llm_runtime_t *runtime); + +const claw_llm_model_profile_t *claw_llm_profile_find(const char *profile_id); +const claw_llm_model_profile_t *claw_llm_profile_default(void); + +esp_err_t claw_llm_register_custom_backend(const claw_llm_custom_backend_registration_t *registration); +void claw_llm_response_free(claw_llm_response_t *response); diff --git a/components/claw_core/src/llm/claw_llm_types.h b/components/claw_core/src/llm/claw_llm_types.h new file mode 100644 index 0000000..0fab7e7 --- /dev/null +++ b/components/claw_core/src/llm/claw_llm_types.h @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include +#include + +#include "cJSON.h" +#include "esp_err.h" + +typedef enum { + CLAW_MEDIA_ASSET_KIND_LOCAL_PATH = 0, + CLAW_MEDIA_ASSET_KIND_REMOTE_URL = 1, + CLAW_MEDIA_ASSET_KIND_INLINE_BYTES = 2, +} claw_media_asset_kind_t; + +typedef enum { + CLAW_MEDIA_PREPARED_KIND_DATA_URL = 0, + CLAW_MEDIA_PREPARED_KIND_REMOTE_URL = 1, +} claw_media_prepared_kind_t; + +typedef struct { + claw_media_asset_kind_t kind; + const char *path; + const char *url; + const uint8_t *bytes; + size_t byte_count; + const char *mime_type; +} claw_media_asset_t; + +typedef struct { + claw_media_prepared_kind_t kind; + char *payload; + char mime_type[32]; + size_t original_size; +} claw_media_prepared_t; + +typedef struct { + const char *id; + const char *default_backend_type; + const char *default_base_url; + const char *chat_path; + const char *max_tokens_field; + uint32_t default_timeout_ms; + size_t default_image_max_bytes; + bool supports_tools; + bool supports_vision; + bool image_remote_url_only; +} claw_llm_model_profile_t; + +typedef struct { + const char *api_key; + const char *backend_type; + const char *profile; + const char *model; + const char *base_url; + const char *auth_type; + uint32_t timeout_ms; + size_t image_max_bytes; +} claw_llm_runtime_config_t; + +typedef struct { + char *id; + char *name; + char *arguments_json; +} claw_llm_tool_call_t; + +typedef struct { + char *text; + claw_llm_tool_call_t *tool_calls; + size_t tool_call_count; +} claw_llm_response_t; + +typedef struct { + const char *system_prompt; + cJSON *messages; + const char *tools_json; +} claw_llm_chat_request_t; + +typedef struct { + const char *system_prompt; + const char *user_prompt; + const claw_media_asset_t *media; + size_t media_count; +} claw_llm_media_request_t; + +typedef struct { + const char *url; + const char *body; + const char *api_key; + const char *auth_type; + uint32_t timeout_ms; +} claw_llm_http_json_request_t; + +typedef struct { + char *body; + int status_code; +} claw_llm_http_response_t; diff --git a/components/claw_core/src/llm/media/claw_media_pipeline.c b/components/claw_core/src/llm/media/claw_media_pipeline.c new file mode 100644 index 0000000..a064286 --- /dev/null +++ b/components/claw_core/src/llm/media/claw_media_pipeline.c @@ -0,0 +1,229 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "llm/media/claw_media_pipeline.h" + +#include +#include +#include +#include +#include +#include + +#include "mbedtls/base64.h" + +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 const char *image_mime_from_path(const char *path) +{ + const char *dot; + + if (!path || !path[0]) { + return NULL; + } + + dot = strrchr(path, '.'); + if (!dot) { + return NULL; + } + + if (strcasecmp(dot, ".jpg") == 0 || strcasecmp(dot, ".jpeg") == 0) { + return "image/jpeg"; + } + if (strcasecmp(dot, ".png") == 0) { + return "image/png"; + } + if (strcasecmp(dot, ".gif") == 0) { + return "image/gif"; + } + if (strcasecmp(dot, ".webp") == 0) { + return "image/webp"; + } + + return NULL; +} + +static esp_err_t prepare_local_path_asset(const claw_media_asset_t *asset, + const claw_llm_model_profile_t *profile, + size_t image_max_bytes, + claw_media_prepared_t *out_prepared, + char **out_error_message) +{ + struct stat st = {0}; + FILE *file = NULL; + unsigned char *raw = NULL; + unsigned char *encoded = NULL; + size_t encoded_len = 0; + size_t prefix_len; + size_t read_len; + const char *mime; + char *data_url = NULL; + + if (!asset->path || !asset->path[0]) { + *out_error_message = dup_printf("media path is empty"); + return ESP_ERR_INVALID_ARG; + } + if (asset->path[0] != '/') { + *out_error_message = dup_printf("media path must be an absolute path"); + return ESP_ERR_INVALID_ARG; + } + + mime = asset->mime_type ? asset->mime_type : image_mime_from_path(asset->path); + if (!mime) { + *out_error_message = dup_printf("Only local jpg/jpeg/png/gif/webp files are supported"); + return ESP_ERR_NOT_SUPPORTED; + } + if (stat(asset->path, &st) != 0) { + *out_error_message = dup_printf("Media file not found: %s", asset->path); + return ESP_ERR_NOT_FOUND; + } + if (st.st_size <= 0) { + *out_error_message = dup_printf("Media file is empty: %s", asset->path); + return ESP_ERR_INVALID_SIZE; + } + if ((size_t)st.st_size > image_max_bytes) { + *out_error_message = dup_printf("Media file is too large (%ld bytes > %u bytes)", + (long)st.st_size, + (unsigned)image_max_bytes); + return ESP_ERR_INVALID_SIZE; + } + + file = fopen(asset->path, "rb"); + if (!file) { + *out_error_message = dup_printf("Failed to open media file: %s", asset->path); + return ESP_FAIL; + } + + raw = malloc((size_t)st.st_size); + if (!raw) { + *out_error_message = dup_printf("Out of memory reading media"); + fclose(file); + return ESP_ERR_NO_MEM; + } + + read_len = fread(raw, 1, (size_t)st.st_size, file); + fclose(file); + if (read_len != (size_t)st.st_size) { + free(raw); + *out_error_message = dup_printf("Failed to read full media file: %s", asset->path); + return ESP_FAIL; + } + + encoded_len = ((size_t)st.st_size + 2) / 3 * 4; + encoded = calloc(1, encoded_len + 1); + if (!encoded) { + free(raw); + *out_error_message = dup_printf("Out of memory encoding media"); + return ESP_ERR_NO_MEM; + } + + if (mbedtls_base64_encode(encoded, encoded_len + 1, &encoded_len, raw, (size_t)st.st_size) != 0) { + free(raw); + free(encoded); + *out_error_message = dup_printf("Failed to base64-encode media"); + return ESP_FAIL; + } + + prefix_len = strlen("data:") + strlen(mime) + strlen(";base64,"); + data_url = calloc(1, prefix_len + encoded_len + 1); + if (!data_url) { + free(raw); + free(encoded); + *out_error_message = dup_printf("Out of memory building media payload"); + return ESP_ERR_NO_MEM; + } + + snprintf(data_url, prefix_len + 1, "data:%s;base64,", mime); + memcpy(data_url + prefix_len, encoded, encoded_len); + data_url[prefix_len + encoded_len] = '\0'; + + out_prepared->kind = CLAW_MEDIA_PREPARED_KIND_DATA_URL; + out_prepared->payload = data_url; + out_prepared->original_size = (size_t)st.st_size; + strlcpy(out_prepared->mime_type, mime, sizeof(out_prepared->mime_type)); + + free(raw); + free(encoded); + (void)profile; + return ESP_OK; +} + +esp_err_t claw_media_prepare_asset(const claw_media_asset_t *asset, + const claw_llm_model_profile_t *profile, + size_t image_max_bytes, + claw_media_prepared_t *out_prepared, + char **out_error_message) +{ + if (out_prepared) { + memset(out_prepared, 0, sizeof(*out_prepared)); + } + if (out_error_message) { + *out_error_message = NULL; + } + if (!asset || !profile || !out_prepared || !out_error_message) { + return ESP_ERR_INVALID_ARG; + } + + if (asset->kind == CLAW_MEDIA_ASSET_KIND_REMOTE_URL) { + if (!asset->url || !asset->url[0]) { + *out_error_message = dup_printf("media url is empty"); + return ESP_ERR_INVALID_ARG; + } + out_prepared->kind = CLAW_MEDIA_PREPARED_KIND_REMOTE_URL; + out_prepared->payload = strdup(asset->url); + if (!out_prepared->payload) { + *out_error_message = dup_printf("Out of memory copying media URL"); + return ESP_ERR_NO_MEM; + } + return ESP_OK; + } + + if (asset->kind != CLAW_MEDIA_ASSET_KIND_LOCAL_PATH) { + *out_error_message = dup_printf("Unsupported media asset kind"); + return ESP_ERR_NOT_SUPPORTED; + } + + if (profile->image_remote_url_only) { + *out_error_message = dup_printf("Selected profile only supports remote image URLs"); + return ESP_ERR_NOT_SUPPORTED; + } + + return prepare_local_path_asset(asset, profile, image_max_bytes, out_prepared, out_error_message); +} + +void claw_media_prepared_free(claw_media_prepared_t *prepared) +{ + if (!prepared) { + return; + } + + free(prepared->payload); + memset(prepared, 0, sizeof(*prepared)); +} diff --git a/components/claw_core/src/llm/media/claw_media_pipeline.h b/components/claw_core/src/llm/media/claw_media_pipeline.h new file mode 100644 index 0000000..859a195 --- /dev/null +++ b/components/claw_core/src/llm/media/claw_media_pipeline.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "llm/claw_llm_types.h" + +esp_err_t claw_media_prepare_asset(const claw_media_asset_t *asset, + const claw_llm_model_profile_t *profile, + size_t image_max_bytes, + claw_media_prepared_t *out_prepared, + char **out_error_message); +void claw_media_prepared_free(claw_media_prepared_t *prepared); diff --git a/components/claw_event_router/CMakeLists.txt b/components/claw_event_router/CMakeLists.txt new file mode 100644 index 0000000..f8ec073 --- /dev/null +++ b/components/claw_event_router/CMakeLists.txt @@ -0,0 +1,13 @@ +idf_component_register( + SRCS + "src/claw_event_router.c" + "src/cmd_claw_event_router.c" + INCLUDE_DIRS + "include" + REQUIRES + claw_cap + claw_core + freertos + json + console +) diff --git a/components/claw_event_router/include/claw_event_router.h b/components/claw_event_router/include/claw_event_router.h new file mode 100644 index 0000000..9c3d5b9 --- /dev/null +++ b/components/claw_event_router/include/claw_event_router.h @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include +#include + +#include "claw_cap.h" +#include "esp_err.h" +#include "freertos/FreeRTOS.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define CLAW_EVENT_ROUTER_DEFAULT_RULES_PATH "/fatfs/data/automation/automations.json" + +typedef enum { + CLAW_EVENT_SESSION_POLICY_CHAT = 0, + CLAW_EVENT_SESSION_POLICY_TRIGGER = 1, + CLAW_EVENT_SESSION_POLICY_GLOBAL = 2, + CLAW_EVENT_SESSION_POLICY_EPHEMERAL = 3, + CLAW_EVENT_SESSION_POLICY_NOSAVE = 4, +} claw_event_session_policy_t; + +typedef struct { + char event_id[48]; + char source_cap[32]; + char event_type[32]; + char source_channel[16]; + char target_channel[16]; + char source_endpoint[64]; + char target_endpoint[96]; + char chat_id[96]; + char sender_id[96]; + char message_id[96]; + char correlation_id[96]; + char content_type[24]; + int64_t timestamp_ms; + claw_event_session_policy_t session_policy; + char *text; + char *payload_json; +} claw_event_t; + +typedef size_t (*claw_event_router_session_builder_fn)(const claw_event_t *event, + char *buf, + size_t buf_size, + void *user_ctx); + +typedef esp_err_t (*claw_event_router_outbound_resolver_fn)(const claw_event_t *event, + const char *target_channel, + const char *target_endpoint, + char *cap_name, + size_t cap_name_size, + void *user_ctx); + +typedef struct { + const char *rules_path; + size_t max_rules; + size_t max_actions_per_rule; + size_t cap_output_size; + uint32_t event_queue_len; + uint32_t task_stack_size; + UBaseType_t task_priority; + BaseType_t task_core; + uint32_t core_submit_timeout_ms; + uint32_t core_receive_timeout_ms; + bool default_route_messages_to_agent; + claw_event_router_session_builder_fn session_builder; + void *session_builder_user_ctx; + claw_event_router_outbound_resolver_fn outbound_resolver; + void *outbound_resolver_user_ctx; +} claw_event_router_config_t; + +typedef struct { + bool matched; + int matched_rules; + int action_count; + int failed_actions; + int64_t handled_at_ms; + char first_rule_id[64]; + char ack[256]; + claw_cap_event_route_t route; + esp_err_t last_error; +} claw_event_router_result_t; + +esp_err_t claw_event_router_init(const claw_event_router_config_t *config); +esp_err_t claw_event_router_start(void); +esp_err_t claw_event_router_stop(void); +esp_err_t claw_event_router_reload(void); +esp_err_t claw_event_router_publish(const claw_event_t *event); +esp_err_t claw_event_router_publish_message(const char *source_cap, + const char *channel, + const char *chat_id, + const char *text, + const char *sender_id, + const char *message_id); +esp_err_t claw_event_router_publish_trigger(const char *source_cap, + const char *event_type, + const char *event_key, + const char *payload_json); +esp_err_t claw_event_router_register_outbound_binding(const char *channel, + const char *cap_name); +esp_err_t claw_event_router_handle_event(const claw_event_t *event, + claw_event_router_result_t *out_result); +esp_err_t claw_event_router_list_rules_json(char *output, size_t output_size); +esp_err_t claw_event_router_get_rule_json(const char *id, char *output, size_t output_size); +esp_err_t claw_event_router_get_last_result(claw_event_router_result_t *out_result); +void claw_event_router_free_event(claw_event_t *event); +size_t claw_event_router_build_session_id(const claw_event_t *event, + char *buf, + size_t buf_size); +const char *claw_event_router_session_policy_to_string(claw_event_session_policy_t policy); + +#ifdef __cplusplus +} +#endif diff --git a/components/claw_event_router/include/cmd_claw_event_router.h b/components/claw_event_router/include/cmd_claw_event_router.h new file mode 100644 index 0000000..30341ac --- /dev/null +++ b/components/claw_event_router/include/cmd_claw_event_router.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_claw_event_router(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/claw_event_router/src/claw_event_router.c b/components/claw_event_router/src/claw_event_router.c new file mode 100644 index 0000000..cc58699 --- /dev/null +++ b/components/claw_event_router/src/claw_event_router.c @@ -0,0 +1,1987 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "claw_event_router.h" + +#include +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "claw_core.h" +#include "esp_log.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +static const char *TAG = "claw_event_router"; + +#define CLAW_EVENT_ROUTER_DEFAULT_MAX_RULES 32 +#define CLAW_EVENT_ROUTER_DEFAULT_MAX_ACTIONS 8 +#define CLAW_EVENT_ROUTER_DEFAULT_OUTPUT_SIZE 2048 +#define CLAW_EVENT_ROUTER_DEFAULT_QUEUE_LEN 16 +#define CLAW_EVENT_ROUTER_DEFAULT_STACK 8192 +#define CLAW_EVENT_ROUTER_DEFAULT_PRIO 5 +#define CLAW_EVENT_ROUTER_DEFAULT_SUBMIT 1000 +#define CLAW_EVENT_ROUTER_DEFAULT_RECEIVE 130000 +#define CLAW_EVENT_ROUTER_ID_SIZE 64 +#define CLAW_EVENT_ROUTER_DESC_SIZE 160 +#define CLAW_EVENT_ROUTER_ACK_SIZE 256 +#define CLAW_EVENT_ROUTER_FIELD_SIZE 96 +#define CLAW_EVENT_ROUTER_cap_SIZE 64 +#define CLAW_EVENT_ROUTER_BINDING_SIZE 16 + +typedef enum { + CLAW_EVENT_ROUTER_ACTION_CALL_cap = 0, + CLAW_EVENT_ROUTER_ACTION_RUN_AGENT = 1, + CLAW_EVENT_ROUTER_ACTION_RUN_SCRIPT = 2, + CLAW_EVENT_ROUTER_ACTION_SEND_MESSAGE = 3, + CLAW_EVENT_ROUTER_ACTION_EMIT_EVENT = 4, + CLAW_EVENT_ROUTER_ACTION_DROP = 5, +} claw_event_router_action_kind_t; + +typedef struct { + char event_type[CLAW_EVENT_ROUTER_FIELD_SIZE]; + char event_key[CLAW_EVENT_ROUTER_FIELD_SIZE]; + char source_cap[CLAW_EVENT_ROUTER_FIELD_SIZE]; + char channel[CLAW_EVENT_ROUTER_FIELD_SIZE]; + char chat_id[CLAW_EVENT_ROUTER_FIELD_SIZE]; + char content_type[CLAW_EVENT_ROUTER_FIELD_SIZE]; + char text[CLAW_EVENT_ROUTER_FIELD_SIZE]; +} claw_event_router_match_t; + +typedef struct { + claw_event_router_action_kind_t kind; + char cap[CLAW_EVENT_ROUTER_cap_SIZE]; + char *input_json; + claw_cap_caller_t caller; + bool capture_output; + bool fail_open; +} claw_event_router_action_t; + +typedef struct { + bool enabled; + bool consume_on_match; + char id[CLAW_EVENT_ROUTER_ID_SIZE]; + char description[CLAW_EVENT_ROUTER_DESC_SIZE]; + char ack[CLAW_EVENT_ROUTER_ACK_SIZE]; + char *vars_json; + claw_event_router_match_t match; + claw_event_router_action_t *actions; + size_t action_count; +} claw_event_router_rule_t; + +typedef struct { + char channel[24]; + char cap_name[CLAW_EVENT_ROUTER_cap_SIZE]; +} claw_event_router_binding_t; + +typedef struct { + bool initialized; + bool started; + bool stop_requested; + SemaphoreHandle_t mutex; + QueueHandle_t event_queue; + TaskHandle_t task_handle; + uint32_t next_request_id; + char rules_path[192]; + size_t max_rules; + size_t max_actions_per_rule; + size_t cap_output_size; + size_t binding_count; + claw_event_router_binding_t bindings[CLAW_EVENT_ROUTER_BINDING_SIZE]; + claw_event_router_rule_t *rules; + size_t rule_count; + claw_event_router_result_t last_result; + claw_event_router_config_t config; +} claw_event_router_runtime_t; + +static claw_event_router_runtime_t s_runtime = { + .rules_path = CLAW_EVENT_ROUTER_DEFAULT_RULES_PATH, + .max_rules = CLAW_EVENT_ROUTER_DEFAULT_MAX_RULES, + .max_actions_per_rule = CLAW_EVENT_ROUTER_DEFAULT_MAX_ACTIONS, + .cap_output_size = CLAW_EVENT_ROUTER_DEFAULT_OUTPUT_SIZE, + .next_request_id = 1000000, +}; + +static const char *claw_event_router_action_kind_to_string(claw_event_router_action_kind_t kind) +{ + switch (kind) { + case CLAW_EVENT_ROUTER_ACTION_CALL_cap: + return "call_cap"; + case CLAW_EVENT_ROUTER_ACTION_RUN_AGENT: + return "run_agent"; + case CLAW_EVENT_ROUTER_ACTION_RUN_SCRIPT: + return "run_script"; + case CLAW_EVENT_ROUTER_ACTION_SEND_MESSAGE: + return "send_message"; + case CLAW_EVENT_ROUTER_ACTION_EMIT_EVENT: + return "emit_event"; + case CLAW_EVENT_ROUTER_ACTION_DROP: + return "drop"; + default: + return "unknown"; + } +} + +static int64_t claw_event_router_now_ms(void) +{ + struct timeval tv = {0}; + + gettimeofday(&tv, NULL); + return ((int64_t)tv.tv_sec * 1000LL) + (tv.tv_usec / 1000LL); +} + +static void claw_event_router_lock(void) +{ + xSemaphoreTakeRecursive(s_runtime.mutex, portMAX_DELAY); +} + +static void claw_event_router_unlock(void) +{ + xSemaphoreGiveRecursive(s_runtime.mutex); +} + +static void claw_event_router_trim_copy(char *dst, size_t dst_size, const char *src) +{ + const char *start = src; + const char *end = NULL; + size_t len = 0; + + if (!dst || dst_size == 0) { + return; + } + dst[0] = '\0'; + if (!src) { + return; + } + + while (*start == ' ' || *start == '\t' || *start == '\r' || *start == '\n') { + start++; + } + end = start + strlen(start); + while (end > start && + (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r' || end[-1] == '\n')) { + end--; + } + + len = (size_t)(end - start); + if (len >= dst_size) { + len = dst_size - 1; + } + memcpy(dst, start, len); + dst[len] = '\0'; +} + +static esp_err_t claw_event_router_read_file(const char *path, char **out_buf) +{ + FILE *file = NULL; + long size = 0; + char *buf = NULL; + + if (!path || !out_buf) { + return ESP_ERR_INVALID_ARG; + } + *out_buf = NULL; + + file = fopen(path, "rb"); + if (!file) { + return ESP_ERR_NOT_FOUND; + } + if (fseek(file, 0, SEEK_END) != 0) { + fclose(file); + return ESP_FAIL; + } + size = ftell(file); + if (size < 0) { + fclose(file); + return ESP_FAIL; + } + if (fseek(file, 0, SEEK_SET) != 0) { + fclose(file); + return ESP_FAIL; + } + + buf = calloc(1, (size_t)size + 1); + if (!buf) { + fclose(file); + return ESP_ERR_NO_MEM; + } + if (size > 0 && fread(buf, 1, (size_t)size, file) != (size_t)size) { + fclose(file); + free(buf); + return ESP_FAIL; + } + + fclose(file); + *out_buf = buf; + return ESP_OK; +} + +static void claw_event_router_free_rules(claw_event_router_rule_t *rules, size_t rule_count) +{ + if (!rules) { + return; + } + + for (size_t i = 0; i < rule_count; i++) { + free(rules[i].vars_json); + for (size_t j = 0; j < rules[i].action_count; j++) { + free(rules[i].actions[j].input_json); + } + free(rules[i].actions); + } + free(rules); +} + +static bool claw_event_router_parse_caller(const char *value, claw_cap_caller_t *out) +{ + if (!out) { + return false; + } + if (!value || !value[0] || strcmp(value, "system") == 0) { + *out = CLAW_CAP_CALLER_SYSTEM; + return true; + } + if (strcmp(value, "agent") == 0) { + *out = CLAW_CAP_CALLER_AGENT; + return true; + } + if (strcmp(value, "console") == 0) { + *out = CLAW_CAP_CALLER_CONSOLE; + return true; + } + return false; +} + +static const char *claw_event_router_json_string_or_empty(const cJSON *obj, const char *field) +{ + const char *value = NULL; + + if (!obj || !field) { + return ""; + } + value = cJSON_GetStringValue(cJSON_GetObjectItem((cJSON *)obj, field)); + return value ? value : ""; +} + +static const char *claw_event_router_json_string_with_aliases(const cJSON *obj, + const char *primary, + const char *fallback) +{ + const char *value = NULL; + + if (!obj) { + return ""; + } + if (primary && primary[0]) { + value = cJSON_GetStringValue(cJSON_GetObjectItem((cJSON *)obj, primary)); + if (value && value[0]) { + return value; + } + } + if (fallback && fallback[0]) { + value = cJSON_GetStringValue(cJSON_GetObjectItem((cJSON *)obj, fallback)); + if (value && value[0]) { + return value; + } + } + return ""; +} + +static bool claw_event_router_parse_session_policy(const char *value, + claw_event_session_policy_t *out_policy) +{ + if (!out_policy) { + return false; + } + if (!value || !value[0] || strcmp(value, "chat") == 0) { + *out_policy = CLAW_EVENT_SESSION_POLICY_CHAT; + return true; + } + if (strcmp(value, "trigger") == 0) { + *out_policy = CLAW_EVENT_SESSION_POLICY_TRIGGER; + return true; + } + if (strcmp(value, "global") == 0) { + *out_policy = CLAW_EVENT_SESSION_POLICY_GLOBAL; + return true; + } + if (strcmp(value, "ephemeral") == 0) { + *out_policy = CLAW_EVENT_SESSION_POLICY_EPHEMERAL; + return true; + } + if (strcmp(value, "nosave") == 0) { + *out_policy = CLAW_EVENT_SESSION_POLICY_NOSAVE; + return true; + } + return false; +} + +const char *claw_event_router_session_policy_to_string(claw_event_session_policy_t policy) +{ + switch (policy) { + case CLAW_EVENT_SESSION_POLICY_CHAT: + return "chat"; + case CLAW_EVENT_SESSION_POLICY_TRIGGER: + return "trigger"; + case CLAW_EVENT_SESSION_POLICY_GLOBAL: + return "global"; + case CLAW_EVENT_SESSION_POLICY_EPHEMERAL: + return "ephemeral"; + case CLAW_EVENT_SESSION_POLICY_NOSAVE: + return "nosave"; + default: + return "chat"; + } +} + +static esp_err_t claw_event_router_parse_action(const cJSON *item, + claw_event_router_action_t *out_action) +{ + const char *type = NULL; + const char *cap = NULL; + const char *caller = NULL; + cJSON *input = NULL; + char *input_json = NULL; + + if (!cJSON_IsObject(item) || !out_action) { + return ESP_ERR_INVALID_ARG; + } + + memset(out_action, 0, sizeof(*out_action)); + type = cJSON_GetStringValue(cJSON_GetObjectItem((cJSON *)item, "type")); + caller = cJSON_GetStringValue(cJSON_GetObjectItem((cJSON *)item, "caller")); + cap = cJSON_GetStringValue(cJSON_GetObjectItem((cJSON *)item, "cap")); + input = cJSON_GetObjectItem((cJSON *)item, "input"); + + if (!type || !type[0]) { + return ESP_ERR_INVALID_ARG; + } + if (!claw_event_router_parse_caller(caller, &out_action->caller)) { + return ESP_ERR_INVALID_ARG; + } + + if (strcmp(type, "call_cap") == 0) { + out_action->kind = CLAW_EVENT_ROUTER_ACTION_CALL_cap; + if (!cap || !cap[0] || !input || !cJSON_IsObject(input)) { + return ESP_ERR_INVALID_ARG; + } + strlcpy(out_action->cap, cap, sizeof(out_action->cap)); + } else if (strcmp(type, "run_agent") == 0) { + out_action->kind = CLAW_EVENT_ROUTER_ACTION_RUN_AGENT; + if (!input || !cJSON_IsObject(input)) { + input = cJSON_CreateObject(); + if (!input) { + return ESP_ERR_NO_MEM; + } + input_json = cJSON_PrintUnformatted(input); + cJSON_Delete(input); + if (!input_json) { + return ESP_ERR_NO_MEM; + } + out_action->input_json = input_json; + out_action->capture_output = true; + out_action->fail_open = cJSON_IsTrue(cJSON_GetObjectItem((cJSON *)item, "fail_open")); + return ESP_OK; + } + } else if (strcmp(type, "run_script") == 0) { + out_action->kind = CLAW_EVENT_ROUTER_ACTION_RUN_SCRIPT; + if (!input || !cJSON_IsObject(input)) { + return ESP_ERR_INVALID_ARG; + } + } else if (strcmp(type, "send_message") == 0) { + out_action->kind = CLAW_EVENT_ROUTER_ACTION_SEND_MESSAGE; + if (!input || !cJSON_IsObject(input)) { + return ESP_ERR_INVALID_ARG; + } + } else if (strcmp(type, "emit_event") == 0) { + out_action->kind = CLAW_EVENT_ROUTER_ACTION_EMIT_EVENT; + if (!input || !cJSON_IsObject(input)) { + return ESP_ERR_INVALID_ARG; + } + } else if (strcmp(type, "drop") == 0) { + out_action->kind = CLAW_EVENT_ROUTER_ACTION_DROP; + input = cJSON_CreateObject(); + if (!input) { + return ESP_ERR_NO_MEM; + } + input_json = cJSON_PrintUnformatted(input); + cJSON_Delete(input); + if (!input_json) { + return ESP_ERR_NO_MEM; + } + out_action->input_json = input_json; + out_action->capture_output = false; + out_action->fail_open = cJSON_IsTrue(cJSON_GetObjectItem((cJSON *)item, "fail_open")); + return ESP_OK; + } else { + return ESP_ERR_INVALID_ARG; + } + + if (!input_json) { + input_json = cJSON_PrintUnformatted(input); + if (!input_json) { + return ESP_ERR_NO_MEM; + } + } + + out_action->capture_output = !cJSON_IsBool(cJSON_GetObjectItem((cJSON *)item, "capture_output")) || + cJSON_IsTrue(cJSON_GetObjectItem((cJSON *)item, "capture_output")); + out_action->fail_open = cJSON_IsTrue(cJSON_GetObjectItem((cJSON *)item, "fail_open")); + out_action->input_json = input_json; + return ESP_OK; +} + +static esp_err_t claw_event_router_parse_rule(const cJSON *item, + claw_event_router_rule_t *out_rule) +{ + const char *id = NULL; + const char *description = NULL; + const char *ack = NULL; + cJSON *match = NULL; + cJSON *actions = NULL; + cJSON *vars = NULL; + const char *event_type = NULL; + cJSON *action = NULL; + size_t action_count = 0; + + if (!cJSON_IsObject(item) || !out_rule) { + return ESP_ERR_INVALID_ARG; + } + + memset(out_rule, 0, sizeof(*out_rule)); + out_rule->enabled = true; + + id = cJSON_GetStringValue(cJSON_GetObjectItem((cJSON *)item, "id")); + description = cJSON_GetStringValue(cJSON_GetObjectItem((cJSON *)item, "description")); + ack = cJSON_GetStringValue(cJSON_GetObjectItem((cJSON *)item, "ack")); + match = cJSON_GetObjectItem((cJSON *)item, "match"); + actions = cJSON_GetObjectItem((cJSON *)item, "actions"); + vars = cJSON_GetObjectItem((cJSON *)item, "vars"); + + if (!id || !id[0] || !cJSON_IsObject(match) || !cJSON_IsArray(actions) || + cJSON_GetArraySize(actions) <= 0) { + return ESP_ERR_INVALID_ARG; + } + event_type = cJSON_GetStringValue(cJSON_GetObjectItem(match, "event_type")); + if (!event_type || !event_type[0]) { + return ESP_ERR_INVALID_ARG; + } + if (vars && !cJSON_IsObject(vars)) { + return ESP_ERR_INVALID_ARG; + } + + action_count = (size_t)cJSON_GetArraySize(actions); + if (action_count > s_runtime.max_actions_per_rule) { + return ESP_ERR_INVALID_SIZE; + } + + strlcpy(out_rule->id, id, sizeof(out_rule->id)); + strlcpy(out_rule->description, description ? description : "", sizeof(out_rule->description)); + strlcpy(out_rule->ack, ack ? ack : "", sizeof(out_rule->ack)); + strlcpy(out_rule->match.event_type, event_type, sizeof(out_rule->match.event_type)); + out_rule->enabled = !cJSON_IsBool(cJSON_GetObjectItem((cJSON *)item, "enabled")) || + cJSON_IsTrue(cJSON_GetObjectItem((cJSON *)item, "enabled")); + out_rule->consume_on_match = !cJSON_IsBool(cJSON_GetObjectItem((cJSON *)item, "consume_on_match")) || + cJSON_IsTrue(cJSON_GetObjectItem((cJSON *)item, "consume_on_match")); + + strlcpy(out_rule->match.event_key, + claw_event_router_json_string_or_empty(match, "event_key"), + sizeof(out_rule->match.event_key)); + strlcpy(out_rule->match.source_cap, + claw_event_router_json_string_or_empty(match, "source_cap"), + sizeof(out_rule->match.source_cap)); + strlcpy(out_rule->match.channel, + claw_event_router_json_string_with_aliases(match, "source_channel", "channel"), + sizeof(out_rule->match.channel)); + strlcpy(out_rule->match.chat_id, + claw_event_router_json_string_or_empty(match, "chat_id"), + sizeof(out_rule->match.chat_id)); + strlcpy(out_rule->match.content_type, + claw_event_router_json_string_or_empty(match, "content_type"), + sizeof(out_rule->match.content_type)); + strlcpy(out_rule->match.text, + claw_event_router_json_string_or_empty(match, "text"), + sizeof(out_rule->match.text)); + + if (vars) { + out_rule->vars_json = cJSON_PrintUnformatted(vars); + if (!out_rule->vars_json) { + return ESP_ERR_NO_MEM; + } + } + + out_rule->actions = calloc(action_count, sizeof(*out_rule->actions)); + if (!out_rule->actions) { + return ESP_ERR_NO_MEM; + } + + cJSON_ArrayForEach(action, actions) { + esp_err_t err = claw_event_router_parse_action(action, &out_rule->actions[out_rule->action_count]); + if (err != ESP_OK) { + return err; + } + out_rule->action_count++; + } + + return ESP_OK; +} + +static const char *claw_event_router_event_key(const claw_event_t *event) +{ + if (!event) { + return ""; + } + if (strcmp(event->event_type, "message") == 0) { + return "text"; + } + if (event->message_id[0]) { + return event->message_id; + } + return event->event_id; +} + +static bool claw_event_router_match_field(const char *expected, const char *actual) +{ + return !expected || !expected[0] || strcmp(expected, actual ? actual : "") == 0; +} + +static bool claw_event_router_rule_matches(const claw_event_router_rule_t *rule, + const claw_event_t *event) +{ + return rule && rule->enabled && + claw_event_router_match_field(rule->match.event_type, event->event_type) && + claw_event_router_match_field(rule->match.event_key, claw_event_router_event_key(event)) && + claw_event_router_match_field(rule->match.source_cap, event->source_cap) && + claw_event_router_match_field(rule->match.channel, event->source_channel) && + claw_event_router_match_field(rule->match.chat_id, event->chat_id) && + claw_event_router_match_field(rule->match.content_type, event->content_type) && + claw_event_router_match_field(rule->match.text, event->text); +} + +static cJSON *claw_event_router_build_event_context(const claw_event_t *event) +{ + cJSON *ctx = NULL; + cJSON *event_obj = NULL; + cJSON *payload_obj = NULL; + + if (!event) { + return NULL; + } + + ctx = cJSON_CreateObject(); + event_obj = cJSON_CreateObject(); + if (!ctx || !event_obj) { + cJSON_Delete(ctx); + cJSON_Delete(event_obj); + return NULL; + } + + cJSON_AddStringToObject(event_obj, "event_id", event->event_id); + cJSON_AddStringToObject(event_obj, "event_type", event->event_type); + cJSON_AddStringToObject(event_obj, "event_key", claw_event_router_event_key(event)); + cJSON_AddStringToObject(event_obj, "source_cap", event->source_cap); + cJSON_AddStringToObject(event_obj, "channel", event->source_channel); + cJSON_AddStringToObject(event_obj, "source_channel", event->source_channel); + cJSON_AddStringToObject(event_obj, "target_channel", event->target_channel); + cJSON_AddStringToObject(event_obj, "source_endpoint", event->source_endpoint); + cJSON_AddStringToObject(event_obj, "target_endpoint", event->target_endpoint); + cJSON_AddStringToObject(event_obj, "chat_id", event->chat_id); + cJSON_AddStringToObject(event_obj, "sender_id", event->sender_id); + cJSON_AddStringToObject(event_obj, "message_id", event->message_id); + cJSON_AddStringToObject(event_obj, "correlation_id", event->correlation_id); + cJSON_AddStringToObject(event_obj, "content_type", event->content_type); + cJSON_AddStringToObject(event_obj, "session_policy", + claw_event_router_session_policy_to_string(event->session_policy)); + cJSON_AddNumberToObject(event_obj, "timestamp_ms", (double)event->timestamp_ms); + cJSON_AddStringToObject(event_obj, "text", event->text ? event->text : ""); + + if (event->payload_json && event->payload_json[0]) { + payload_obj = cJSON_Parse(event->payload_json); + if (!cJSON_IsObject(payload_obj)) { + cJSON_Delete(payload_obj); + payload_obj = cJSON_CreateObject(); + } + } else { + payload_obj = cJSON_CreateObject(); + } + if (!payload_obj) { + cJSON_Delete(ctx); + cJSON_Delete(event_obj); + return NULL; + } + + cJSON_AddItemToObject(event_obj, "payload", payload_obj); + cJSON_AddItemToObject(ctx, "event", event_obj); + return ctx; +} + +static bool claw_event_router_lookup_string(const cJSON *ctx, + const char *path, + char *buf, + size_t buf_size) +{ + char path_buf[128]; + const cJSON *node = ctx; + char *segment = NULL; + + if (!ctx || !path || !path[0] || !buf || buf_size == 0) { + return false; + } + buf[0] = '\0'; + strlcpy(path_buf, path, sizeof(path_buf)); + segment = path_buf; + while (segment && segment[0]) { + char *dot = strchr(segment, '.'); + if (dot) { + *dot = '\0'; + } + if (!cJSON_IsObject(node)) { + return false; + } + node = cJSON_GetObjectItemCaseSensitive((cJSON *)node, segment); + if (!node) { + return false; + } + if (!dot) { + break; + } + segment = dot + 1; + } + + if (cJSON_IsString(node) && node->valuestring) { + strlcpy(buf, node->valuestring, buf_size); + return true; + } + if (cJSON_IsNumber(node)) { + snprintf(buf, buf_size, "%g", node->valuedouble); + return true; + } + if (cJSON_IsBool(node)) { + strlcpy(buf, cJSON_IsTrue(node) ? "true" : "false", buf_size); + return true; + } + if (cJSON_IsNull(node)) { + strlcpy(buf, "null", buf_size); + return true; + } + return false; +} + +static char *claw_event_router_render_string(const char *template_str, const cJSON *ctx) +{ + size_t in_len = 0; + size_t out_cap = 0; + size_t out_len = 0; + char *out = NULL; + + if (!template_str) { + return strdup(""); + } + + in_len = strlen(template_str); + out_cap = in_len + 32; + out = calloc(1, out_cap); + if (!out) { + return NULL; + } + + for (size_t i = 0; i < in_len;) { + if (i + 1 < in_len && template_str[i] == '{' && template_str[i + 1] == '{') { + size_t start = i + 2; + size_t end = start; + char key[96]; + char value[256]; + size_t key_len; + size_t value_len; + + while (end + 1 < in_len && + !(template_str[end] == '}' && template_str[end + 1] == '}')) { + end++; + } + if (end + 1 >= in_len) { + break; + } + + key_len = end - start; + if (key_len >= sizeof(key)) { + key_len = sizeof(key) - 1; + } + memcpy(key, template_str + start, key_len); + key[key_len] = '\0'; + claw_event_router_trim_copy(key, sizeof(key), key); + + value[0] = '\0'; + claw_event_router_lookup_string(ctx, key, value, sizeof(value)); + value_len = strlen(value); + if (out_len + value_len + 1 > out_cap) { + char *grown = realloc(out, out_len + value_len + 32); + if (!grown) { + free(out); + return NULL; + } + out = grown; + out_cap = out_len + value_len + 32; + } + memcpy(out + out_len, value, value_len); + out_len += value_len; + out[out_len] = '\0'; + i = end + 2; + continue; + } + + if (out_len + 2 > out_cap) { + char *grown = realloc(out, out_cap + 32); + if (!grown) { + free(out); + return NULL; + } + out = grown; + out_cap += 32; + } + out[out_len++] = template_str[i++]; + out[out_len] = '\0'; + } + + return out; +} + +static cJSON *claw_event_router_render_json(const cJSON *input, const cJSON *ctx) +{ + cJSON *out = NULL; + cJSON *child = NULL; + + if (!input) { + return cJSON_CreateNull(); + } + if (cJSON_IsObject(input)) { + out = cJSON_CreateObject(); + if (!out) { + return NULL; + } + cJSON_ArrayForEach(child, input) { + cJSON *rendered = claw_event_router_render_json(child, ctx); + if (!rendered) { + cJSON_Delete(out); + return NULL; + } + cJSON_AddItemToObject(out, child->string, rendered); + } + return out; + } + if (cJSON_IsArray(input)) { + out = cJSON_CreateArray(); + if (!out) { + return NULL; + } + cJSON_ArrayForEach(child, input) { + cJSON *rendered = claw_event_router_render_json(child, ctx); + if (!rendered) { + cJSON_Delete(out); + return NULL; + } + cJSON_AddItemToArray(out, rendered); + } + return out; + } + if (cJSON_IsString(input)) { + char *rendered = claw_event_router_render_string(input->valuestring, ctx); + if (!rendered) { + return NULL; + } + out = cJSON_CreateString(rendered); + free(rendered); + return out; + } + return cJSON_Duplicate((cJSON *)input, 1); +} + +static esp_err_t claw_event_router_clone_event(const claw_event_t *src, claw_event_t *dst) +{ + memset(dst, 0, sizeof(*dst)); + memcpy(dst, src, sizeof(*dst)); + dst->text = NULL; + dst->payload_json = NULL; + + if (src->text) { + dst->text = strdup(src->text); + if (!dst->text) { + return ESP_ERR_NO_MEM; + } + } + if (src->payload_json) { + dst->payload_json = strdup(src->payload_json); + if (!dst->payload_json) { + free(dst->text); + dst->text = NULL; + return ESP_ERR_NO_MEM; + } + } + return ESP_OK; +} + +void claw_event_router_free_event(claw_event_t *event) +{ + if (!event) { + return; + } + free(event->text); + free(event->payload_json); + memset(event, 0, sizeof(*event)); +} + +size_t claw_event_router_build_session_id(const claw_event_t *event, + char *buf, + size_t buf_size) +{ + if (!buf || buf_size == 0 || !event) { + return 0; + } + + switch (event->session_policy) { + case CLAW_EVENT_SESSION_POLICY_CHAT: + snprintf(buf, buf_size, "%s:%s", event->source_channel, event->chat_id); + break; + case CLAW_EVENT_SESSION_POLICY_TRIGGER: + snprintf(buf, buf_size, "trigger:%s:%s", + event->source_cap[0] ? event->source_cap : "system", + claw_event_router_event_key(event)); + break; + case CLAW_EVENT_SESSION_POLICY_GLOBAL: + snprintf(buf, buf_size, "global:%s", + event->source_cap[0] ? event->source_cap : "router"); + break; + case CLAW_EVENT_SESSION_POLICY_EPHEMERAL: + snprintf(buf, buf_size, "ephemeral:%s", event->event_id); + break; + case CLAW_EVENT_SESSION_POLICY_NOSAVE: + buf[0] = '\0'; + return 0; + default: + snprintf(buf, buf_size, "%s:%s", event->source_channel, event->chat_id); + break; + } + + return strlen(buf); +} + +static size_t claw_event_router_build_session_id_with_config(const claw_event_t *event, + char *buf, + size_t buf_size) +{ + if (s_runtime.config.session_builder) { + return s_runtime.config.session_builder(event, + buf, + buf_size, + s_runtime.config.session_builder_user_ctx); + } + return claw_event_router_build_session_id(event, buf, buf_size); +} + +static esp_err_t claw_event_router_default_outbound_resolver(const claw_event_t *event, + const char *target_channel, + const char *target_endpoint, + char *cap_name, + size_t cap_name_size) +{ + size_t i; + + (void)event; + (void)target_endpoint; + + if (!cap_name || cap_name_size == 0) { + return ESP_ERR_INVALID_ARG; + } + cap_name[0] = '\0'; + + claw_event_router_lock(); + for (i = 0; i < s_runtime.binding_count; i++) { + if (strcmp(s_runtime.bindings[i].channel, target_channel ? target_channel : "") == 0) { + strlcpy(cap_name, + s_runtime.bindings[i].cap_name, + cap_name_size); + claw_event_router_unlock(); + return ESP_OK; + } + } + claw_event_router_unlock(); + return ESP_ERR_NOT_FOUND; +} + +static esp_err_t claw_event_router_resolve_outbound_cap(const claw_event_t *event, + const char *target_channel, + const char *target_endpoint, + char *cap_name, + size_t cap_name_size) +{ + if (s_runtime.config.outbound_resolver) { + esp_err_t err = s_runtime.config.outbound_resolver(event, + target_channel, + target_endpoint, + cap_name, + cap_name_size, + s_runtime.config.outbound_resolver_user_ctx); + if (err != ESP_ERR_NOT_FOUND) { + return err; + } + } + + return claw_event_router_default_outbound_resolver(event, + target_channel, + target_endpoint, + cap_name, + cap_name_size); +} + +static void claw_event_router_update_last_output(cJSON *ctx, + const char *kind, + const char *target, + const char *status, + const char *output) +{ + cJSON *last_obj = NULL; + + last_obj = cJSON_GetObjectItemCaseSensitive(ctx, "last"); + if (!last_obj) { + last_obj = cJSON_CreateObject(); + if (!last_obj) { + return; + } + cJSON_AddItemToObject(ctx, "last", last_obj); + } + + cJSON_DeleteItemFromObjectCaseSensitive(last_obj, "kind"); + cJSON_DeleteItemFromObjectCaseSensitive(last_obj, "target"); + cJSON_DeleteItemFromObjectCaseSensitive(last_obj, "status"); + cJSON_DeleteItemFromObjectCaseSensitive(last_obj, "output"); + cJSON_AddStringToObject(last_obj, "kind", kind ? kind : ""); + cJSON_AddStringToObject(last_obj, "target", target ? target : ""); + cJSON_AddStringToObject(last_obj, "status", status ? status : ""); + cJSON_AddStringToObject(last_obj, "output", output ? output : ""); +} + +static const char *claw_event_router_get_ctx_string(const cJSON *ctx, + const char *group, + const char *field) +{ + const cJSON *obj = NULL; + const cJSON *item = NULL; + + if (!cJSON_IsObject((cJSON *)ctx) || !group || !field) { + return NULL; + } + obj = cJSON_GetObjectItemCaseSensitive((cJSON *)ctx, group); + if (!cJSON_IsObject((cJSON *)obj)) { + return NULL; + } + item = cJSON_GetObjectItemCaseSensitive((cJSON *)obj, field); + if (!cJSON_IsString((cJSON *)item) || !item->valuestring || !item->valuestring[0]) { + return NULL; + } + return item->valuestring; +} + +static esp_err_t claw_event_router_execute_cap_action( + const claw_event_router_rule_t *rule, + const claw_event_router_action_t *action, + const claw_event_t *event, + cJSON *ctx, + claw_event_router_result_t *result) +{ + cJSON *input_root = NULL; + cJSON *rendered_input = NULL; + char *input_json = NULL; + char *output = NULL; + char session_id[128] = {0}; + claw_cap_call_context_t call_ctx = {0}; + esp_err_t err = ESP_OK; + + input_root = cJSON_Parse(action->input_json); + if (!cJSON_IsObject(input_root)) { + cJSON_Delete(input_root); + return ESP_ERR_INVALID_ARG; + } + rendered_input = claw_event_router_render_json(input_root, ctx); + cJSON_Delete(input_root); + if (!rendered_input) { + return ESP_ERR_NO_MEM; + } + input_json = cJSON_PrintUnformatted(rendered_input); + cJSON_Delete(rendered_input); + if (!input_json) { + return ESP_ERR_NO_MEM; + } + + output = calloc(1, s_runtime.cap_output_size); + if (!output) { + free(input_json); + return ESP_ERR_NO_MEM; + } + + call_ctx.channel = event->source_channel; + call_ctx.chat_id = event->chat_id; + if (claw_event_router_build_session_id_with_config(event, session_id, sizeof(session_id)) > 0) { + call_ctx.session_id = session_id; + } + call_ctx.source_cap = "claw_event_router"; + call_ctx.correlation_id = event->correlation_id[0] ? event->correlation_id : event->message_id; + call_ctx.caller = action->caller; + + err = claw_cap_call(action->cap, + input_json, + &call_ctx, + output, + s_runtime.cap_output_size); + free(input_json); + + claw_event_router_update_last_output(ctx, + "cap", + action->cap, + err == ESP_OK ? "ok" : "error", + action->capture_output ? output : ""); + + if (result) { + result->action_count++; + if (err != ESP_OK) { + result->failed_actions++; + result->last_error = err; + } + } + + free(output); + if (err != ESP_OK && !action->fail_open) { + ESP_LOGW(TAG, "Rule %s action %s failed: %s", + rule->id, + action->cap, + esp_err_to_name(err)); + } + return err; +} + +static esp_err_t claw_event_router_execute_agent_action( + const claw_event_router_rule_t *rule, + const claw_event_router_action_t *action, + const claw_event_t *event, + cJSON *ctx, + claw_event_router_result_t *result) +{ + cJSON *input_root = NULL; + cJSON *rendered_input = NULL; + const char *text = NULL; + const char *target_channel = NULL; + const char *target_chat_id = NULL; + const char *session_policy = NULL; + claw_event_t agent_event = {0}; + claw_core_request_t request = {0}; + claw_core_response_t response = {0}; + char session_id[128] = {0}; + esp_err_t err; + + input_root = cJSON_Parse(action->input_json); + if (!cJSON_IsObject(input_root)) { + cJSON_Delete(input_root); + return ESP_ERR_INVALID_ARG; + } + rendered_input = claw_event_router_render_json(input_root, ctx); + cJSON_Delete(input_root); + if (!rendered_input) { + return ESP_ERR_NO_MEM; + } + + text = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "text")); + target_channel = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "target_channel")); + target_chat_id = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "target_chat_id")); + session_policy = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "session_policy")); + + agent_event = *event; + if (session_policy && session_policy[0]) { + claw_event_router_parse_session_policy(session_policy, &agent_event.session_policy); + } + + request.request_id = s_runtime.next_request_id++; + if (claw_event_router_build_session_id_with_config(&agent_event, session_id, sizeof(session_id)) > 0) { + request.session_id = session_id; + } + request.user_text = (text && text[0]) ? text : (event->text ? event->text : ""); + request.source_channel = event->source_channel; + request.source_chat_id = event->chat_id; + request.source_sender_id = event->sender_id; + request.source_message_id = event->message_id; + request.source_cap = event->source_cap; + request.target_channel = (target_channel && target_channel[0]) ? target_channel : event->source_channel; + request.target_chat_id = (target_chat_id && target_chat_id[0]) ? target_chat_id : event->chat_id; + + err = claw_core_submit(&request, s_runtime.config.core_submit_timeout_ms); + if (err == ESP_OK) { + err = claw_core_receive_for(request.request_id, + &response, + s_runtime.config.core_receive_timeout_ms); + } + + if (err == ESP_OK) { + claw_event_router_update_last_output(ctx, + "agent", + request.target_channel, + response.status == CLAW_CORE_RESPONSE_STATUS_OK ? "ok" : "error", + response.status == CLAW_CORE_RESPONSE_STATUS_OK ? + (response.text ? response.text : "") : + (response.error_message ? response.error_message : "")); + } else { + claw_event_router_update_last_output(ctx, "agent", request.target_channel, "error", + esp_err_to_name(err)); + } + + if (result) { + result->action_count++; + if (err != ESP_OK || response.status != CLAW_CORE_RESPONSE_STATUS_OK) { + result->failed_actions++; + result->last_error = err != ESP_OK ? err : ESP_FAIL; + } + } + + if (err != ESP_OK && !action->fail_open) { + ESP_LOGW(TAG, "Rule %s agent action failed: %s", rule->id, esp_err_to_name(err)); + } + + cJSON_Delete(rendered_input); + if (err == ESP_OK) { + claw_core_response_free(&response); + } + return err; +} + +static esp_err_t claw_event_router_execute_send_message_action( + const claw_event_router_rule_t *rule, + const claw_event_router_action_t *action, + const claw_event_t *event, + cJSON *ctx, + claw_event_router_result_t *result) +{ + cJSON *input_root = NULL; + cJSON *rendered_input = NULL; + const char *channel = NULL; + const char *chat_id = NULL; + const char *message = NULL; + char cap_name[CLAW_EVENT_ROUTER_cap_SIZE] = {0}; + char output[256] = {0}; + cJSON *payload_root = NULL; + char *payload = NULL; + claw_cap_call_context_t call_ctx = {0}; + esp_err_t err; + + input_root = cJSON_Parse(action->input_json); + if (!cJSON_IsObject(input_root)) { + cJSON_Delete(input_root); + return ESP_ERR_INVALID_ARG; + } + rendered_input = claw_event_router_render_json(input_root, ctx); + cJSON_Delete(input_root); + if (!rendered_input) { + return ESP_ERR_NO_MEM; + } + + channel = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "channel")); + chat_id = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "chat_id")); + message = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "message")); + ESP_LOGI(TAG, + "send_message rendered channel=%s chat_id=%s message_len=%u", + channel ? channel : "(null)", + chat_id ? chat_id : "(null)", + (unsigned int)(message ? strlen(message) : 0)); + if (!channel || !channel[0]) { + channel = event->target_channel[0] ? event->target_channel : event->source_channel; + } + if (!chat_id || !chat_id[0]) { + chat_id = event->target_endpoint[0] ? event->target_endpoint : event->chat_id; + } + if (!message || !message[0]) { + message = claw_event_router_get_ctx_string(ctx, "last", "output"); + ESP_LOGI(TAG, + "send_message fallback last.output message_len=%u", + (unsigned int)(message ? strlen(message) : 0)); + } + if (!message || !message[0]) { + cJSON_Delete(rendered_input); + return ESP_ERR_INVALID_ARG; + } + + err = claw_event_router_resolve_outbound_cap(event, + channel, + chat_id, + cap_name, + sizeof(cap_name)); + if (err != ESP_OK) { + ESP_LOGW(TAG, + "send_message resolve failed channel=%s chat_id=%s err=%s", + channel ? channel : "(null)", + chat_id ? chat_id : "(null)", + esp_err_to_name(err)); + cJSON_Delete(rendered_input); + return err; + } + ESP_LOGI(TAG, "send_message resolved cap=%s", cap_name); + + payload_root = cJSON_CreateObject(); + if (!payload_root) { + cJSON_Delete(rendered_input); + return ESP_ERR_NO_MEM; + } + cJSON_AddStringToObject(payload_root, "chat_id", chat_id); + cJSON_AddStringToObject(payload_root, "message", message); + payload = cJSON_PrintUnformatted(payload_root); + cJSON_Delete(payload_root); + if (!payload) { + cJSON_Delete(rendered_input); + return ESP_ERR_NO_MEM; + } + call_ctx.channel = channel; + call_ctx.chat_id = chat_id; + call_ctx.source_cap = "claw_event_router"; + call_ctx.caller = CLAW_CAP_CALLER_SYSTEM; + err = claw_cap_call(cap_name, payload, &call_ctx, output, sizeof(output)); + ESP_LOGI(TAG, + "send_message cap=%s err=%s output=%s", + cap_name, + esp_err_to_name(err), + output[0] ? output : "-"); + free(payload); + claw_event_router_update_last_output(ctx, + "send_message", + cap_name, + err == ESP_OK ? "ok" : "error", + err == ESP_OK ? message : output); + + if (result) { + result->action_count++; + if (err != ESP_OK) { + result->failed_actions++; + result->last_error = err; + } + } + if (err != ESP_OK && !action->fail_open) { + ESP_LOGW(TAG, "Rule %s send_message via %s failed: %s", + rule->id, + cap_name, + esp_err_to_name(err)); + } + + cJSON_Delete(rendered_input); + return err; +} + +static esp_err_t claw_event_router_execute_script_action( + const claw_event_router_rule_t *rule, + const claw_event_router_action_t *action, + const claw_event_t *event, + cJSON *ctx, + claw_event_router_result_t *result) +{ + claw_event_router_action_t local = *action; + char cap_name[CLAW_EVENT_ROUTER_cap_SIZE]; + cJSON *input_root = cJSON_Parse(action->input_json); + const char *async_value = NULL; + + if (!cJSON_IsObject(input_root)) { + cJSON_Delete(input_root); + return ESP_ERR_INVALID_ARG; + } + async_value = cJSON_GetStringValue(cJSON_GetObjectItem(input_root, "cap")); + (void)async_value; + strlcpy(cap_name, + cJSON_IsTrue(cJSON_GetObjectItem(input_root, "async")) ? + "lua_run_script_async" : "lua_run_script", + sizeof(cap_name)); + cJSON_Delete(input_root); + strlcpy(local.cap, cap_name, sizeof(local.cap)); + local.kind = CLAW_EVENT_ROUTER_ACTION_CALL_cap; + return claw_event_router_execute_cap_action(rule, &local, event, ctx, result); +} + +static esp_err_t claw_event_router_execute_emit_event_action( + const claw_event_router_action_t *action, + cJSON *ctx, + claw_event_router_result_t *result) +{ + cJSON *input_root = NULL; + cJSON *rendered_input = NULL; + claw_event_t event = {0}; + const char *value = NULL; + esp_err_t err; + + input_root = cJSON_Parse(action->input_json); + if (!cJSON_IsObject(input_root)) { + cJSON_Delete(input_root); + return ESP_ERR_INVALID_ARG; + } + rendered_input = claw_event_router_render_json(input_root, ctx); + cJSON_Delete(input_root); + if (!rendered_input) { + return ESP_ERR_NO_MEM; + } + + value = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "source_cap")); + strlcpy(event.source_cap, value ? value : "claw_event_router", sizeof(event.source_cap)); + value = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "event_type")); + strlcpy(event.event_type, value ? value : "trigger", sizeof(event.event_type)); + value = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "source_channel")); + strlcpy(event.source_channel, value ? value : "", sizeof(event.source_channel)); + value = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "chat_id")); + strlcpy(event.chat_id, value ? value : "", sizeof(event.chat_id)); + value = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "message_id")); + strlcpy(event.message_id, value ? value : "", sizeof(event.message_id)); + value = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "content_type")); + strlcpy(event.content_type, value ? value : "trigger", sizeof(event.content_type)); + value = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "text")); + event.text = (char *)(value ? value : ""); + value = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "payload_json")); + event.payload_json = (char *)(value ? value : "{}"); + event.timestamp_ms = claw_event_router_now_ms(); + value = cJSON_GetStringValue(cJSON_GetObjectItem(rendered_input, "session_policy")); + if (!claw_event_router_parse_session_policy(value, &event.session_policy)) { + event.session_policy = CLAW_EVENT_SESSION_POLICY_TRIGGER; + } + snprintf(event.event_id, sizeof(event.event_id), "evt-%" PRId64, event.timestamp_ms); + + err = claw_event_router_publish(&event); + if (result) { + result->action_count++; + if (err != ESP_OK) { + result->failed_actions++; + result->last_error = err; + } + } + + cJSON_Delete(rendered_input); + return err; +} + +static esp_err_t claw_event_router_execute_action(const claw_event_router_rule_t *rule, + const claw_event_router_action_t *action, + const claw_event_t *event, + cJSON *ctx, + claw_event_router_result_t *result) +{ + ESP_LOGI(TAG, + "event=%s rule=%s action=%s start", + event ? event->event_id : "-", + rule ? rule->id : "-", + action ? claw_event_router_action_kind_to_string(action->kind) : "-"); + switch (action->kind) { + case CLAW_EVENT_ROUTER_ACTION_CALL_cap: + return claw_event_router_execute_cap_action(rule, action, event, ctx, result); + case CLAW_EVENT_ROUTER_ACTION_RUN_AGENT: + return claw_event_router_execute_agent_action(rule, action, event, ctx, result); + case CLAW_EVENT_ROUTER_ACTION_RUN_SCRIPT: + return claw_event_router_execute_script_action(rule, action, event, ctx, result); + case CLAW_EVENT_ROUTER_ACTION_SEND_MESSAGE: + return claw_event_router_execute_send_message_action(rule, action, event, ctx, result); + case CLAW_EVENT_ROUTER_ACTION_EMIT_EVENT: + return claw_event_router_execute_emit_event_action(action, ctx, result); + case CLAW_EVENT_ROUTER_ACTION_DROP: + if (result) { + result->action_count++; + result->route = CLAW_CAP_EVENT_ROUTE_CONSUMED; + } + claw_event_router_update_last_output(ctx, "drop", "", "ok", ""); + return ESP_OK; + default: + return ESP_ERR_INVALID_ARG; + } +} + +static esp_err_t claw_event_router_run_default_agent(const claw_event_t *event, + claw_event_router_result_t *result) +{ + claw_event_router_action_t action = { + .kind = CLAW_EVENT_ROUTER_ACTION_RUN_AGENT, + .input_json = strdup("{}"), + .caller = CLAW_CAP_CALLER_SYSTEM, + .capture_output = true, + }; + cJSON *ctx = claw_event_router_build_event_context(event); + esp_err_t err; + + if (!action.input_json || !ctx) { + free(action.input_json); + cJSON_Delete(ctx); + return ESP_ERR_NO_MEM; + } + + err = claw_event_router_execute_agent_action(&(claw_event_router_rule_t) { + .id = "__default_agent__", + }, + &action, + event, + ctx, + result); + if (err == ESP_OK) { + claw_event_router_action_t send_action = { + .kind = CLAW_EVENT_ROUTER_ACTION_SEND_MESSAGE, + .input_json = strdup("{\"channel\":\"{{event.channel}}\",\"chat_id\":\"{{event.chat_id}}\",\"message\":\"{{last.output}}\"}"), + .caller = CLAW_CAP_CALLER_SYSTEM, + .capture_output = false, + }; + if (!send_action.input_json) { + err = ESP_ERR_NO_MEM; + } else { + err = claw_event_router_execute_send_message_action(&(claw_event_router_rule_t) { + .id = "__default_agent__", + }, + &send_action, + event, + ctx, + result); + } + free(send_action.input_json); + } + + cJSON_Delete(ctx); + free(action.input_json); + return err; +} + +static esp_err_t claw_event_router_process_event(const claw_event_t *event, + claw_event_router_result_t *out_result) +{ + claw_event_router_result_t local = {0}; + cJSON *ctx = NULL; + + if (!event) { + return ESP_ERR_INVALID_ARG; + } + + local.route = CLAW_CAP_EVENT_ROUTE_PASS; + local.handled_at_ms = claw_event_router_now_ms(); + strlcpy(local.ack, "processing", sizeof(local.ack)); + + claw_event_router_lock(); + s_runtime.last_result = local; + claw_event_router_unlock(); + + if (strcmp(event->source_cap, "claw_event_router") == 0) { + if (out_result) { + *out_result = local; + } + return ESP_OK; + } + + ESP_LOGI(TAG, + "processing event=%s type=%s source=%s channel=%s chat=%s", + event->event_id, + event->event_type, + event->source_cap, + event->source_channel, + event->chat_id); + + claw_event_router_lock(); + ctx = claw_event_router_build_event_context(event); + if (!ctx) { + claw_event_router_unlock(); + return ESP_ERR_NO_MEM; + } + + for (size_t i = 0; i < s_runtime.rule_count; i++) { + claw_event_router_rule_t *rule = &s_runtime.rules[i]; + cJSON *rule_obj = NULL; + cJSON *vars_obj = NULL; + esp_err_t rule_err = ESP_OK; + + if (!claw_event_router_rule_matches(rule, event)) { + continue; + } + + ESP_LOGI(TAG, + "event=%s matched rule=%s actions=%u", + event->event_id, + rule->id, + (unsigned int)rule->action_count); + + local.matched = true; + local.matched_rules++; + if (rule->consume_on_match) { + local.route = CLAW_CAP_EVENT_ROUTE_CONSUMED; + } + if (!local.first_rule_id[0]) { + strlcpy(local.first_rule_id, rule->id, sizeof(local.first_rule_id)); + } + + rule_obj = cJSON_CreateObject(); + if (!rule_obj) { + cJSON_Delete(ctx); + claw_event_router_unlock(); + return ESP_ERR_NO_MEM; + } + cJSON_AddStringToObject(rule_obj, "id", rule->id); + cJSON_DeleteItemFromObjectCaseSensitive(ctx, "rule"); + cJSON_AddItemToObject(ctx, "rule", rule_obj); + + cJSON_DeleteItemFromObjectCaseSensitive(ctx, "vars"); + vars_obj = rule->vars_json ? cJSON_Parse(rule->vars_json) : cJSON_CreateObject(); + if (!cJSON_IsObject(vars_obj)) { + cJSON_Delete(vars_obj); + vars_obj = cJSON_CreateObject(); + } + if (!vars_obj) { + cJSON_Delete(ctx); + claw_event_router_unlock(); + return ESP_ERR_NO_MEM; + } + cJSON_AddItemToObject(ctx, "vars", vars_obj); + + for (size_t j = 0; j < rule->action_count; j++) { + rule_err = claw_event_router_execute_action(rule, &rule->actions[j], event, ctx, &local); + ESP_LOGI(TAG, + "event=%s rule=%s action=%s done err=%s", + event->event_id, + rule->id, + claw_event_router_action_kind_to_string(rule->actions[j].kind), + esp_err_to_name(rule_err)); + if (rule_err != ESP_OK && !rule->actions[j].fail_open) { + break; + } + } + + if (rule->ack[0]) { + char *rendered_ack = claw_event_router_render_string(rule->ack, ctx); + if (rendered_ack) { + strlcpy(local.ack, rendered_ack, sizeof(local.ack)); + free(rendered_ack); + } + } + if (rule->consume_on_match) { + break; + } + } + + if (!local.matched && + s_runtime.config.default_route_messages_to_agent && + strcmp(event->event_type, "message") == 0 && + event->text && event->text[0]) { + esp_err_t err; + claw_event_router_result_t fallback = local; + + claw_event_router_unlock(); + cJSON_Delete(ctx); + err = claw_event_router_run_default_agent(event, &fallback); + claw_event_router_lock(); + s_runtime.last_result = fallback; + claw_event_router_unlock(); + if (out_result) { + *out_result = fallback; + } + return err; + } + + if (local.matched && !local.ack[0]) { + snprintf(local.ack, sizeof(local.ack), "matched:%s", + local.first_rule_id[0] ? local.first_rule_id : "(unknown)"); + } + s_runtime.last_result = local; + cJSON_Delete(ctx); + claw_event_router_unlock(); + + if (out_result) { + *out_result = local; + } + return ESP_OK; +} + +static void claw_event_router_task(void *arg) +{ + (void)arg; + + ESP_LOGI(TAG, "event router task started"); + + while (!s_runtime.stop_requested) { + claw_event_t event = {0}; + claw_event_router_result_t result = {0}; + + if (xQueueReceive(s_runtime.event_queue, &event, pdMS_TO_TICKS(250)) != pdTRUE) { + continue; + } + if (claw_event_router_process_event(&event, &result) != ESP_OK) { + ESP_LOGW(TAG, "Failed to process event %s", event.event_id); + } + claw_event_router_free_event(&event); + } + + s_runtime.task_handle = NULL; + s_runtime.started = false; + vTaskDelete(NULL); +} + +esp_err_t claw_event_router_init(const claw_event_router_config_t *config) +{ + if (s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + + if (!s_runtime.mutex) { + s_runtime.mutex = xSemaphoreCreateRecursiveMutex(); + } + if (!s_runtime.mutex) { + return ESP_ERR_NO_MEM; + } + + memset(&s_runtime.config, 0, sizeof(s_runtime.config)); + s_runtime.config.task_core = tskNO_AFFINITY; + if (config) { + s_runtime.config = *config; + } + if (config && config->rules_path && config->rules_path[0]) { + strlcpy(s_runtime.rules_path, config->rules_path, sizeof(s_runtime.rules_path)); + } + if (config && config->max_rules > 0) { + s_runtime.max_rules = config->max_rules; + } + if (config && config->max_actions_per_rule > 0) { + s_runtime.max_actions_per_rule = config->max_actions_per_rule; + } + if (config && config->cap_output_size > 0) { + s_runtime.cap_output_size = config->cap_output_size; + } + + s_runtime.event_queue = xQueueCreate( + config && config->event_queue_len ? config->event_queue_len : CLAW_EVENT_ROUTER_DEFAULT_QUEUE_LEN, + sizeof(claw_event_t)); + if (!s_runtime.event_queue) { + return ESP_ERR_NO_MEM; + } + + s_runtime.initialized = true; + ESP_LOGI(TAG, "Rules path: %s", s_runtime.rules_path); + return claw_event_router_reload(); +} + +esp_err_t claw_event_router_start(void) +{ + BaseType_t task_ok; + uint32_t stack_size; + UBaseType_t priority; + BaseType_t core; + + if (!s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (s_runtime.started) { + return ESP_OK; + } + + stack_size = s_runtime.config.task_stack_size ? + s_runtime.config.task_stack_size : CLAW_EVENT_ROUTER_DEFAULT_STACK; + priority = s_runtime.config.task_priority ? + s_runtime.config.task_priority : CLAW_EVENT_ROUTER_DEFAULT_PRIO; + core = s_runtime.config.task_core; + s_runtime.config.core_submit_timeout_ms = s_runtime.config.core_submit_timeout_ms ? + s_runtime.config.core_submit_timeout_ms : CLAW_EVENT_ROUTER_DEFAULT_SUBMIT; + s_runtime.config.core_receive_timeout_ms = s_runtime.config.core_receive_timeout_ms ? + s_runtime.config.core_receive_timeout_ms : CLAW_EVENT_ROUTER_DEFAULT_RECEIVE; + s_runtime.stop_requested = false; + + if (core == tskNO_AFFINITY) { + task_ok = xTaskCreate(claw_event_router_task, + "event_router", + stack_size, + NULL, + priority, + &s_runtime.task_handle); + } else { + task_ok = xTaskCreatePinnedToCore(claw_event_router_task, + "event_router", + stack_size, + NULL, + priority, + &s_runtime.task_handle, + core); + } + if (task_ok != pdPASS) { + s_runtime.task_handle = NULL; + return ESP_FAIL; + } + + s_runtime.started = true; + return ESP_OK; +} + +esp_err_t claw_event_router_stop(void) +{ + TickType_t deadline; + + if (!s_runtime.started || !s_runtime.task_handle) { + return ESP_OK; + } + + s_runtime.stop_requested = true; + deadline = xTaskGetTickCount() + pdMS_TO_TICKS(5000); + while (s_runtime.task_handle && xTaskGetTickCount() < deadline) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + + return s_runtime.task_handle ? ESP_ERR_TIMEOUT : ESP_OK; +} + +esp_err_t claw_event_router_reload(void) +{ + char *buf = NULL; + cJSON *root = NULL; + claw_event_router_rule_t *new_rules = NULL; + size_t new_rule_count = 0; + esp_err_t err; + + if (!s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + + err = claw_event_router_read_file(s_runtime.rules_path, &buf); + if (err == ESP_ERR_NOT_FOUND) { + claw_event_router_lock(); + claw_event_router_free_rules(s_runtime.rules, s_runtime.rule_count); + s_runtime.rules = NULL; + s_runtime.rule_count = 0; + claw_event_router_unlock(); + ESP_LOGI(TAG, "Rules file not found, starting empty"); + return ESP_OK; + } + if (err != ESP_OK) { + return err; + } + + root = cJSON_Parse(buf); + free(buf); + if (!cJSON_IsArray(root)) { + cJSON_Delete(root); + return ESP_ERR_INVALID_RESPONSE; + } + if ((size_t)cJSON_GetArraySize(root) > s_runtime.max_rules) { + cJSON_Delete(root); + return ESP_ERR_INVALID_SIZE; + } + + if (cJSON_GetArraySize(root) > 0) { + new_rules = calloc((size_t)cJSON_GetArraySize(root), sizeof(*new_rules)); + if (!new_rules) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + } + + cJSON *item = NULL; + cJSON_ArrayForEach(item, root) { + err = claw_event_router_parse_rule(item, &new_rules[new_rule_count]); + if (err != ESP_OK) { + claw_event_router_free_rules(new_rules, new_rule_count + 1); + cJSON_Delete(root); + return err; + } + new_rule_count++; + } + cJSON_Delete(root); + + claw_event_router_lock(); + claw_event_router_free_rules(s_runtime.rules, s_runtime.rule_count); + s_runtime.rules = new_rules; + s_runtime.rule_count = new_rule_count; + claw_event_router_unlock(); + + ESP_LOGI(TAG, "Loaded %u router rules", (unsigned int)new_rule_count); + return ESP_OK; +} + +esp_err_t claw_event_router_publish(const claw_event_t *event) +{ + claw_event_t cloned = {0}; + esp_err_t err; + + if (!s_runtime.initialized || !event || !event->source_cap[0] || !event->event_type[0]) { + return ESP_ERR_INVALID_ARG; + } + + err = claw_event_router_clone_event(event, &cloned); + if (err != ESP_OK) { + return err; + } + if (xQueueSend(s_runtime.event_queue, &cloned, pdMS_TO_TICKS(1000)) != pdTRUE) { + claw_event_router_free_event(&cloned); + return ESP_ERR_TIMEOUT; + } + + return ESP_OK; +} + +esp_err_t claw_event_router_publish_message(const char *source_cap, + const char *channel, + const char *chat_id, + const char *text, + const char *sender_id, + const char *message_id) +{ + claw_event_t event = {0}; + + if (!source_cap || !channel || !chat_id || !text) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(event.source_cap, source_cap, sizeof(event.source_cap)); + strlcpy(event.event_type, "message", sizeof(event.event_type)); + strlcpy(event.source_channel, channel, sizeof(event.source_channel)); + strlcpy(event.chat_id, chat_id, sizeof(event.chat_id)); + strlcpy(event.content_type, "text", sizeof(event.content_type)); + if (sender_id) { + strlcpy(event.sender_id, sender_id, sizeof(event.sender_id)); + } + if (message_id) { + strlcpy(event.message_id, message_id, sizeof(event.message_id)); + strlcpy(event.correlation_id, message_id, sizeof(event.correlation_id)); + } + event.timestamp_ms = claw_event_router_now_ms(); + event.session_policy = CLAW_EVENT_SESSION_POLICY_CHAT; + snprintf(event.event_id, sizeof(event.event_id), "msg-%" PRId64, event.timestamp_ms); + event.text = (char *)text; + return claw_event_router_publish(&event); +} + +esp_err_t claw_event_router_publish_trigger(const char *source_cap, + const char *event_type, + const char *event_key, + const char *payload_json) +{ + claw_event_t event = {0}; + + if (!source_cap || !event_type || !event_key) { + return ESP_ERR_INVALID_ARG; + } + + strlcpy(event.source_cap, source_cap, sizeof(event.source_cap)); + strlcpy(event.event_type, event_type, sizeof(event.event_type)); + strlcpy(event.message_id, event_key, sizeof(event.message_id)); + strlcpy(event.correlation_id, event_key, sizeof(event.correlation_id)); + strlcpy(event.content_type, "trigger", sizeof(event.content_type)); + event.timestamp_ms = claw_event_router_now_ms(); + event.session_policy = CLAW_EVENT_SESSION_POLICY_TRIGGER; + snprintf(event.event_id, sizeof(event.event_id), "evt-%" PRId64, event.timestamp_ms); + event.payload_json = (char *)(payload_json ? payload_json : "{}"); + return claw_event_router_publish(&event); +} + +esp_err_t claw_event_router_register_outbound_binding(const char *channel, + const char *cap_name) +{ + if (!s_runtime.initialized || !channel || !channel[0] || + !cap_name || !cap_name[0]) { + return ESP_ERR_INVALID_ARG; + } + + claw_event_router_lock(); + for (size_t i = 0; i < s_runtime.binding_count; i++) { + if (strcmp(s_runtime.bindings[i].channel, channel) == 0) { + strlcpy(s_runtime.bindings[i].cap_name, + cap_name, + sizeof(s_runtime.bindings[i].cap_name)); + claw_event_router_unlock(); + return ESP_OK; + } + } + if (s_runtime.binding_count >= CLAW_EVENT_ROUTER_BINDING_SIZE) { + claw_event_router_unlock(); + return ESP_ERR_NO_MEM; + } + strlcpy(s_runtime.bindings[s_runtime.binding_count].channel, + channel, + sizeof(s_runtime.bindings[s_runtime.binding_count].channel)); + strlcpy(s_runtime.bindings[s_runtime.binding_count].cap_name, + cap_name, + sizeof(s_runtime.bindings[s_runtime.binding_count].cap_name)); + s_runtime.binding_count++; + claw_event_router_unlock(); + return ESP_OK; +} + +esp_err_t claw_event_router_handle_event(const claw_event_t *event, + claw_event_router_result_t *out_result) +{ + return claw_event_router_process_event(event, out_result); +} + +esp_err_t claw_event_router_list_rules_json(char *output, size_t output_size) +{ + char *buf = NULL; + esp_err_t err; + + if (!output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + err = claw_event_router_read_file(s_runtime.rules_path, &buf); + if (err == ESP_ERR_NOT_FOUND) { + strlcpy(output, "[]", output_size); + return ESP_OK; + } + if (err != ESP_OK) { + return err; + } + + strlcpy(output, buf, output_size); + free(buf); + return ESP_OK; +} + +esp_err_t claw_event_router_get_rule_json(const char *id, char *output, size_t output_size) +{ + char *buf = NULL; + cJSON *root = NULL; + cJSON *item = NULL; + char *json = NULL; + esp_err_t err; + + if (!id || !id[0] || !output || output_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + err = claw_event_router_read_file(s_runtime.rules_path, &buf); + if (err != ESP_OK) { + return err; + } + root = cJSON_Parse(buf); + free(buf); + if (!cJSON_IsArray(root)) { + cJSON_Delete(root); + return ESP_ERR_INVALID_RESPONSE; + } + + cJSON_ArrayForEach(item, root) { + const char *rule_id = cJSON_GetStringValue(cJSON_GetObjectItem(item, "id")); + if (rule_id && strcmp(rule_id, id) == 0) { + json = cJSON_PrintUnformatted(item); + break; + } + } + cJSON_Delete(root); + if (!json) { + return ESP_ERR_NOT_FOUND; + } + + strlcpy(output, json, output_size); + free(json); + return ESP_OK; +} + +esp_err_t claw_event_router_get_last_result(claw_event_router_result_t *out_result) +{ + if (!out_result) { + return ESP_ERR_INVALID_ARG; + } + if (!s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + + claw_event_router_lock(); + *out_result = s_runtime.last_result; + claw_event_router_unlock(); + return ESP_OK; +} diff --git a/components/claw_event_router/src/cmd_claw_event_router.c b/components/claw_event_router/src/cmd_claw_event_router.c new file mode 100644 index 0000000..9137c00 --- /dev/null +++ b/components/claw_event_router/src/cmd_claw_event_router.c @@ -0,0 +1,196 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "cmd_claw_event_router.h" + +#include +#include +#include + +#include "argtable3/argtable3.h" +#include "cJSON.h" +#include "claw_event_router.h" +#include "esp_console.h" + +static struct { + struct arg_lit *reload; + struct arg_lit *rules; + struct arg_str *rule; + struct arg_lit *last; + struct arg_lit *emit_message; + struct arg_lit *emit_trigger; + struct arg_str *source_cap; + struct arg_str *channel; + struct arg_str *chat_id; + struct arg_str *text; + struct arg_str *event_type; + struct arg_str *event_key; + struct arg_str *payload_json; + struct arg_end *end; +} router_args; + +static int event_router_func(int argc, char **argv) +{ + claw_event_router_result_t result = {0}; + char *output = NULL; + esp_err_t err; + int nerrors = arg_parse(argc, argv, (void **)&router_args); + int operation_count; + + if (nerrors != 0) { + arg_print_errors(stderr, router_args.end, argv[0]); + return 1; + } + + operation_count = router_args.reload->count + router_args.rules->count + router_args.rule->count + + router_args.last->count + router_args.emit_message->count + + router_args.emit_trigger->count; + if (operation_count != 1) { + printf("Exactly one operation must be specified\n"); + return 1; + } + + if (router_args.reload->count) { + err = claw_event_router_reload(); + if (err != ESP_OK) { + printf("event_router reload failed: %s\n", esp_err_to_name(err)); + return 1; + } + printf("automation rules reloaded\n"); + return 0; + } + + if (router_args.rules->count || router_args.rule->count) { + output = calloc(1, router_args.rule->count ? 2048 : 4096); + if (!output) { + printf("Out of memory\n"); + return 1; + } + + if (router_args.rule->count) { + err = claw_event_router_get_rule_json(router_args.rule->sval[0], output, 2048); + } else { + err = claw_event_router_list_rules_json(output, 4096); + } + if (err != ESP_OK) { + printf("event_router rules failed: %s\n", esp_err_to_name(err)); + free(output); + return 1; + } + + printf("%s\n", output); + free(output); + return 0; + } + + if (router_args.last->count) { + err = claw_event_router_get_last_result(&result); + if (err != ESP_OK) { + printf("event_router last failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("matched=%s matched_rules=%d action_count=%d failed_actions=%d route=%d handled_at_ms=%" PRId64 "\n", + result.matched ? "true" : "false", + result.matched_rules, + result.action_count, + result.failed_actions, + (int)result.route, + result.handled_at_ms); + printf("first_rule_id=%s\n", result.first_rule_id[0] ? result.first_rule_id : "-"); + printf("ack=%s\n", result.ack[0] ? result.ack : "-"); + printf("last_error=%s\n", esp_err_to_name(result.last_error)); + return 0; + } + + if (router_args.emit_message->count) { + if (!router_args.source_cap->count || !router_args.channel->count || + !router_args.chat_id->count || !router_args.text->count) { + printf("'--emit-message' requires '--source-cap', '--channel', '--chat-id', and '--text'\n"); + return 1; + } + + err = claw_event_router_publish_message(router_args.source_cap->sval[0], + router_args.channel->sval[0], + router_args.chat_id->sval[0], + router_args.text->sval[0], + "console", + "cli-msg"); + if (err != ESP_OK) { + printf("event_router emit-message failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("message event published via %s to %s:%s\n", + router_args.source_cap->sval[0], + router_args.channel->sval[0], + router_args.chat_id->sval[0]); + return 0; + } + + if (!router_args.source_cap->count || !router_args.event_type->count || + !router_args.event_key->count || !router_args.payload_json->count) { + printf("'--emit-trigger' requires '--source-cap', '--event-type', '--event-key', and '--payload-json'\n"); + return 1; + } + + { + cJSON *json = cJSON_Parse(router_args.payload_json->sval[0]); + + if (!json || !cJSON_IsObject(json)) { + cJSON_Delete(json); + printf("'--payload-json' must be a JSON object\n"); + return 1; + } + cJSON_Delete(json); + } + + err = claw_event_router_publish_trigger(router_args.source_cap->sval[0], + router_args.event_type->sval[0], + router_args.event_key->sval[0], + router_args.payload_json->sval[0]); + if (err != ESP_OK) { + printf("event_router emit-trigger failed: %s\n", esp_err_to_name(err)); + return 1; + } + + printf("trigger event published via %s type=%s key=%s\n", + router_args.source_cap->sval[0], + router_args.event_type->sval[0], + router_args.event_key->sval[0]); + return 0; +} + +void register_claw_event_router(void) +{ + router_args.reload = arg_lit0(NULL, "reload", "Reload automation rules from disk"); + router_args.rules = arg_lit0(NULL, "rules", "List all automation rules"); + router_args.rule = arg_str0(NULL, "rule", "", "Show one automation rule"); + router_args.last = arg_lit0(NULL, "last", "Show the last event router result"); + router_args.emit_message = arg_lit0(NULL, "emit-message", "Publish a message event"); + router_args.emit_trigger = arg_lit0(NULL, "emit-trigger", "Publish a trigger event"); + router_args.source_cap = arg_str0(NULL, "source-cap", "", "Source capability name"); + router_args.channel = arg_str0(NULL, "channel", "", "Source channel name"); + router_args.chat_id = arg_str0(NULL, "chat-id", "", "Chat id"); + router_args.text = arg_str0(NULL, "text", "", "Message text"); + router_args.event_type = arg_str0(NULL, "event-type", "", "Trigger event type"); + router_args.event_key = arg_str0(NULL, "event-key", "", "Trigger event key"); + router_args.payload_json = arg_str0(NULL, "payload-json", "", "Trigger payload JSON object"); + router_args.end = arg_end(12); + + const esp_console_cmd_t router_cmd = { + .command = "event_router", + .help = "Event router operations.\n" + "Examples:\n" + " event_router --rules\n" + " event_router --rule sample-id\n" + " event_router --reload\n" + " event_router --emit-message --source-cap qq_gateway --channel qq --chat-id 123 --text hello\n", + .func = event_router_func, + .argtable = &router_args, + }; + + ESP_ERROR_CHECK(esp_console_cmd_register(&router_cmd)); +} diff --git a/components/claw_memory/CMakeLists.txt b/components/claw_memory/CMakeLists.txt new file mode 100644 index 0000000..b7545b5 --- /dev/null +++ b/components/claw_memory/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register( + SRCS + "src/claw_memory.c" + INCLUDE_DIRS + "include" + REQUIRES + claw_core +) diff --git a/components/claw_memory/include/claw_memory.h b/components/claw_memory/include/claw_memory.h new file mode 100644 index 0000000..9bf1b98 --- /dev/null +++ b/components/claw_memory/include/claw_memory.h @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include + +#include "claw_core.h" +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + const char *session_root_dir; + const char *long_term_memory_path; + size_t max_session_messages; + size_t max_message_chars; +} claw_memory_config_t; + +esp_err_t claw_memory_init(const claw_memory_config_t *config); +esp_err_t claw_memory_session_load(const char *session_id, char *buf, size_t size); +esp_err_t claw_memory_session_append(const char *session_id, + const char *user_text, + const char *assistant_text); +esp_err_t claw_memory_long_term_read(char *buf, size_t size); +esp_err_t claw_memory_long_term_write(const char *content); +esp_err_t claw_memory_append_session_turn_callback(const char *session_id, + const char *user_text, + const char *assistant_text, + void *user_ctx); + +extern const claw_core_context_provider_t claw_memory_long_term_provider; +extern const claw_core_context_provider_t claw_memory_session_history_provider; + +#ifdef __cplusplus +} +#endif diff --git a/components/claw_memory/src/claw_memory.c b/components/claw_memory/src/claw_memory.c new file mode 100644 index 0000000..fc7786c --- /dev/null +++ b/components/claw_memory/src/claw_memory.c @@ -0,0 +1,772 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "claw_memory.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "esp_log.h" + +static const char *TAG = "claw_memory"; +static const char *DEFAULT_LONG_TERM_MEMORY = + "# Long-term Memory\n\n" + "(empty - Clawgent will write memories here as it learns)\n"; + +#define CLAW_MEMORY_DEFAULT_MAX_SESSION_MESSAGES 20 +#define CLAW_MEMORY_DEFAULT_MAX_MESSAGE_CHARS 256 + +typedef struct { + int initialized; + char session_root_dir[128]; + char long_term_memory_path[128]; + size_t max_session_messages; + size_t max_message_chars; +} claw_memory_state_t; + +static claw_memory_state_t s_memory = {0}; + +static void safe_copy(char *dst, size_t dst_size, const char *src) +{ + size_t len; + + if (!dst || dst_size == 0) { + return; + } + if (!src) { + dst[0] = '\0'; + return; + } + + len = strnlen(src, dst_size - 1); + memcpy(dst, src, len); + dst[len] = '\0'; +} + +static char *alloc_zeroed(size_t size) +{ + if (size == 0) { + return NULL; + } + return calloc(1, size); +} + +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 size_t utf8_sequence_len(unsigned char ch) +{ + if (ch < 0x80) { + return 1; + } + if ((ch & 0xE0) == 0xC0) { + return 2; + } + if ((ch & 0xF0) == 0xE0) { + return 3; + } + if ((ch & 0xF8) == 0xF0) { + return 4; + } + return 0; +} + +static bool utf8_sequence_valid(const unsigned char *src, size_t len) +{ + size_t i; + + if (!src || len == 0) { + return false; + } + for (i = 1; i < len; i++) { + if (src[i] == '\0') { + return false; + } + if ((src[i] & 0xC0) != 0x80) { + return false; + } + } + return true; +} + +static size_t text_buffer_size(size_t max_chars) +{ + if (max_chars == 0) { + max_chars = CLAW_MEMORY_DEFAULT_MAX_MESSAGE_CHARS; + } + return (max_chars * 4) + 1; +} + +static void normalize_text(const char *src, char *dst, size_t dst_size, size_t max_chars) +{ + size_t off = 0; + size_t chars = 0; + + if (!dst || dst_size == 0) { + return; + } + dst[0] = '\0'; + if (!src) { + return; + } + + while (*src && off + 1 < dst_size && chars < max_chars) { + const unsigned char *cur = (const unsigned char *)src; + size_t seq_len = utf8_sequence_len(*cur); + + if (*cur < 0x80) { + char ch = (char) * cur++; + src = (const char *)cur; + if (ch == '\r' || ch == '\n' || ch == '\t') { + ch = ' '; + } + dst[off++] = ch; + chars++; + continue; + } + + if (seq_len == 0 || !utf8_sequence_valid(cur, seq_len)) { + src++; + continue; + } + if (off + seq_len >= dst_size) { + break; + } + memcpy(dst + off, cur, seq_len); + off += seq_len; + src += seq_len; + chars++; + } + dst[off] = '\0'; +} + +static void sanitize_session_id(const char *session_id, char *buf, size_t size) +{ + size_t off = 0; + + if (!buf || size == 0) { + return; + } + buf[0] = '\0'; + if (!session_id) { + return; + } + + while (*session_id && off + 1 < size) { + char ch = *session_id++; + if (isalnum((unsigned char)ch) || ch == '-' || ch == '_') { + buf[off++] = ch; + } else { + buf[off++] = '_'; + } + } + buf[off] = '\0'; +} + +static esp_err_t ensure_parent_dir(const char *path) +{ + char *tmp = NULL; + char *slash; + esp_err_t err = ESP_FAIL; + + if (!path || path[0] == '\0') { + return ESP_ERR_INVALID_ARG; + } + + tmp = strdup(path); + if (!tmp) { + return ESP_ERR_NO_MEM; + } + slash = strrchr(tmp, '/'); + if (!slash) { + free(tmp); + return ESP_OK; + } + *slash = '\0'; + if (tmp[0] == '\0') { + free(tmp); + return ESP_OK; + } + { + struct stat st; + if (stat(tmp, &st) == 0 && S_ISDIR(st.st_mode)) { + free(tmp); + return ESP_OK; + } + } + if (mkdir(tmp, 0755) == 0 || errno == EEXIST) { + free(tmp); + return ESP_OK; + } + { + char *probe_path = dup_printf("%s/.claw_memory_probe", tmp); + FILE *probe; + + probe = probe_path ? fopen(probe_path, "w") : NULL; + if (probe) { + fclose(probe); + remove(probe_path); + err = ESP_OK; + } + free(probe_path); + } + free(tmp); + return err; +} + +static esp_err_t ensure_dir(const char *path) +{ + struct stat st; + char *probe_path = NULL; + FILE *probe = NULL; + + if (!path || path[0] == '\0') { + return ESP_ERR_INVALID_ARG; + } + if (stat(path, &st) == 0 && S_ISDIR(st.st_mode)) { + return ESP_OK; + } + if (mkdir(path, 0755) == 0 || errno == EEXIST) { + return ESP_OK; + } + probe_path = dup_printf("%s/.claw_memory_probe", path); + if (!probe_path) { + return ESP_ERR_NO_MEM; + } + probe = fopen(probe_path, "w"); + if (probe) { + fclose(probe); + remove(probe_path); + free(probe_path); + return ESP_OK; + } + free(probe_path); + return ESP_FAIL; +} + +static char *session_path_dup(const char *session_id) +{ + char safe_session_id[48]; + uint32_t hash = 2166136261u; + const unsigned char *p = (const unsigned char *)session_id; + size_t len; + char *path; + + sanitize_session_id(session_id, safe_session_id, sizeof(safe_session_id)); + for (; p && *p; ++p) { + hash ^= *p; + hash *= 16777619u; + } + + len = strnlen(safe_session_id, sizeof(safe_session_id) - 1); + if (len > 24) { + safe_session_id[24] = '\0'; + } + + path = dup_printf("%s/s_%s_%08" PRIx32 ".log", + s_memory.session_root_dir, + safe_session_id[0] ? safe_session_id : "default", + hash); + return path; +} + +static void json_escape(const char *src, char *dst, size_t dst_size) +{ + size_t off = 0; + + if (!dst || dst_size == 0) { + return; + } + dst[0] = '\0'; + if (!src) { + return; + } + + while (*src && off + 1 < dst_size) { + char ch = *src++; + if (ch == '"' || ch == '\\') { + if (off + 2 >= dst_size) { + break; + } + dst[off++] = '\\'; + dst[off++] = ch; + } else { + dst[off++] = ch; + } + } + dst[off] = '\0'; +} + +static esp_err_t append_line(FILE *file, const char *role, const char *text) +{ + char *normalized = NULL; + size_t max_chars = s_memory.max_message_chars; + size_t normalized_size; + esp_err_t err = ESP_OK; + + if (!file || !role || !text) { + return ESP_ERR_INVALID_ARG; + } + + if (max_chars > CLAW_MEMORY_DEFAULT_MAX_MESSAGE_CHARS) { + max_chars = CLAW_MEMORY_DEFAULT_MAX_MESSAGE_CHARS; + } + normalized_size = text_buffer_size(max_chars); + normalized = alloc_zeroed(normalized_size); + if (!normalized) { + return ESP_ERR_NO_MEM; + } + normalize_text(text, normalized, normalized_size, max_chars); + fprintf(file, "%s\t%s\n", role, normalized); + free(normalized); + return err; +} + +static esp_err_t ensure_long_term_memory_file(const char *path) +{ + FILE *file; + + if (!path || path[0] == '\0') { + return ESP_ERR_INVALID_ARG; + } + + file = fopen(path, "r"); + if (file) { + fclose(file); + return ESP_OK; + } + + file = fopen(path, "w"); + if (!file) { + return ESP_FAIL; + } + + if (fputs(DEFAULT_LONG_TERM_MEMORY, file) < 0) { + fclose(file); + return ESP_FAIL; + } + + fclose(file); + return ESP_OK; +} + +static size_t session_history_json_size(void) +{ + size_t max_msgs = s_memory.max_session_messages; + size_t max_chars = s_memory.max_message_chars; + + if (max_msgs == 0) { + max_msgs = CLAW_MEMORY_DEFAULT_MAX_SESSION_MESSAGES; + } + if (max_chars == 0 || max_chars > CLAW_MEMORY_DEFAULT_MAX_MESSAGE_CHARS) { + max_chars = CLAW_MEMORY_DEFAULT_MAX_MESSAGE_CHARS; + } + + return (max_msgs * ((text_buffer_size(max_chars) * 2) + 64)) + 16; +} + +static esp_err_t read_file_dup(const char *path, char **out_buf) +{ + FILE *file = NULL; + long size = 0; + char *buf = NULL; + size_t read_bytes; + + if (!path || !out_buf) { + return ESP_ERR_INVALID_ARG; + } + *out_buf = NULL; + + file = fopen(path, "rb"); + if (!file) { + return ESP_ERR_NOT_FOUND; + } + if (fseek(file, 0, SEEK_END) != 0) { + fclose(file); + return ESP_FAIL; + } + size = ftell(file); + if (size < 0) { + fclose(file); + return ESP_FAIL; + } + if (fseek(file, 0, SEEK_SET) != 0) { + fclose(file); + return ESP_FAIL; + } + + buf = calloc(1, (size_t)size + 1); + if (!buf) { + fclose(file); + return ESP_ERR_NO_MEM; + } + + read_bytes = fread(buf, 1, (size_t)size, file); + fclose(file); + buf[read_bytes] = '\0'; + *out_buf = buf; + return ESP_OK; +} + +static esp_err_t claw_memory_session_load_json(const char *session_id, + char *buf, + size_t size) +{ + char *path = NULL; + FILE *file = NULL; + char *line = NULL; + char **roles = NULL; + char **contents = NULL; + char *escaped = NULL; + size_t count = 0; + size_t next = 0; + size_t i; + size_t max_msgs; + size_t max_chars; + size_t max_bytes; + size_t line_size; + size_t off = 0; + + if (!s_memory.initialized || !session_id || !buf || size == 0) { + return ESP_ERR_INVALID_STATE; + } + + max_msgs = s_memory.max_session_messages; + max_chars = s_memory.max_message_chars; + if (max_msgs == 0) { + max_msgs = CLAW_MEMORY_DEFAULT_MAX_SESSION_MESSAGES; + } + if (max_chars == 0 || max_chars > CLAW_MEMORY_DEFAULT_MAX_MESSAGE_CHARS) { + max_chars = CLAW_MEMORY_DEFAULT_MAX_MESSAGE_CHARS; + } + max_bytes = text_buffer_size(max_chars) - 1; + + path = session_path_dup(session_id); + line_size = max_bytes + 16; + line = alloc_zeroed(line_size); + escaped = alloc_zeroed((max_bytes * 2) + 1); + if (!path || !line || !escaped) { + free(path); + free(line); + free(escaped); + return ESP_ERR_NO_MEM; + } + + file = fopen(path, "r"); + if (!file) { + buf[0] = '\0'; + free(path); + free(line); + free(escaped); + return ESP_ERR_NOT_FOUND; + } + + roles = calloc(max_msgs, sizeof(char *)); + contents = calloc(max_msgs, sizeof(char *)); + if (!roles || !contents) { + fclose(file); + free(path); + free(line); + free(escaped); + free(roles); + free(contents); + return ESP_ERR_NO_MEM; + } + + for (i = 0; i < max_msgs; i++) { + roles[i] = calloc(1, 16); + contents[i] = calloc(1, max_bytes + 1); + if (!roles[i] || !contents[i]) { + fclose(file); + free(path); + free(line); + free(escaped); + for (i = 0; i < max_msgs; i++) { + free(roles[i]); + free(contents[i]); + } + free(roles); + free(contents); + return ESP_ERR_NO_MEM; + } + } + + while (fgets(line, (int)line_size, file)) { + char *tab = strchr(line, '\t'); + char *value; + if (!tab) { + ESP_LOGW(TAG, "Skipping malformed session line for %s: %.32s", + session_id, line); + continue; + } + *tab = '\0'; + value = tab + 1; + value[strcspn(value, "\r\n")] = '\0'; + safe_copy(roles[next], 16, line); + safe_copy(contents[next], max_bytes + 1, value); + next = (next + 1) % max_msgs; + if (count < max_msgs) { + count++; + } + } + fclose(file); + free(path); + + buf[0] = '\0'; + off += snprintf(buf + off, size - off, "["); + for (i = 0; i < count && off + 1 < size; i++) { + size_t idx = (count < max_msgs) ? i : ((next + i) % max_msgs); + const char *role = strcmp(roles[idx], "assistant") == 0 ? "assistant" : "user"; + + escaped[0] = '\0'; + json_escape(contents[idx], escaped, (max_bytes * 2) + 1); + off += snprintf(buf + off, size - off, + "%s{\"role\":\"%s\",\"content\":\"%s\"}", + (i == 0) ? "" : ",", + role, + escaped); + } + if (off + 2 <= size) { + snprintf(buf + off, size - off, "]"); + } else if (size > 0) { + buf[size - 1] = '\0'; + } + + for (i = 0; i < max_msgs; i++) { + free(roles[i]); + free(contents[i]); + } + free(roles); + free(contents); + free(line); + free(escaped); + + return ESP_OK; +} + +esp_err_t claw_memory_init(const claw_memory_config_t *config) +{ + if (!config || !config->session_root_dir || !config->long_term_memory_path) { + return ESP_ERR_INVALID_ARG; + } + + memset(&s_memory, 0, sizeof(s_memory)); + safe_copy(s_memory.session_root_dir, sizeof(s_memory.session_root_dir), config->session_root_dir); + safe_copy(s_memory.long_term_memory_path, sizeof(s_memory.long_term_memory_path), config->long_term_memory_path); + s_memory.max_session_messages = config->max_session_messages ? config->max_session_messages : + CLAW_MEMORY_DEFAULT_MAX_SESSION_MESSAGES; + s_memory.max_message_chars = config->max_message_chars ? config->max_message_chars : + CLAW_MEMORY_DEFAULT_MAX_MESSAGE_CHARS; + + if (ensure_dir(s_memory.session_root_dir) != ESP_OK) { + ESP_LOGE(TAG, "Failed to create session root: %s", s_memory.session_root_dir); + return ESP_FAIL; + } + if (ensure_parent_dir(s_memory.long_term_memory_path) != ESP_OK) { + ESP_LOGE(TAG, "Failed to prepare long-term memory path: %s", s_memory.long_term_memory_path); + return ESP_FAIL; + } + if (ensure_long_term_memory_file(s_memory.long_term_memory_path) != ESP_OK) { + ESP_LOGE(TAG, "Failed to prepare long-term memory file: %s", s_memory.long_term_memory_path); + return ESP_FAIL; + } + + s_memory.initialized = 1; + ESP_LOGI(TAG, "Initialized"); + return ESP_OK; +} + +esp_err_t claw_memory_session_load(const char *session_id, char *buf, size_t size) +{ + return claw_memory_session_load_json(session_id, buf, size); +} + +esp_err_t claw_memory_session_append(const char *session_id, + const char *user_text, + const char *assistant_text) +{ + char *path = NULL; + FILE *file = NULL; + esp_err_t err = ESP_OK; + + if (!s_memory.initialized || !session_id || !user_text || !assistant_text) { + return ESP_ERR_INVALID_STATE; + } + + path = session_path_dup(session_id); + if (!path) { + return ESP_ERR_NO_MEM; + } + file = fopen(path, "a"); + if (!file) { + ESP_LOGE(TAG, "Failed to open session file: %s", path); + free(path); + return ESP_FAIL; + } + + err = append_line(file, "user", user_text); + if (err == ESP_OK) { + err = append_line(file, "assistant", assistant_text); + } + fclose(file); + free(path); + return err; +} + +esp_err_t claw_memory_long_term_read(char *buf, size_t size) +{ + FILE *file = NULL; + size_t n; + + if (!s_memory.initialized || !buf || size == 0) { + return ESP_ERR_INVALID_STATE; + } + + file = fopen(s_memory.long_term_memory_path, "r"); + if (!file) { + buf[0] = '\0'; + return ESP_ERR_NOT_FOUND; + } + + n = fread(buf, 1, size - 1, file); + buf[n] = '\0'; + fclose(file); + return ESP_OK; +} + +esp_err_t claw_memory_long_term_write(const char *content) +{ + FILE *file = NULL; + + if (!s_memory.initialized || !content) { + return ESP_ERR_INVALID_STATE; + } + + file = fopen(s_memory.long_term_memory_path, "w"); + if (!file) { + return ESP_FAIL; + } + fputs(content, file); + fclose(file); + return ESP_OK; +} + +esp_err_t claw_memory_append_session_turn_callback(const char *session_id, + const char *user_text, + const char *assistant_text, + void *user_ctx) +{ + (void)user_ctx; + return claw_memory_session_append(session_id, user_text, assistant_text); +} + +static esp_err_t claw_memory_long_term_collect(const claw_core_request_t *request, + claw_core_context_t *out_context, + void *user_ctx) +{ + char *content = NULL; + esp_err_t err; + + (void)request; + (void)user_ctx; + + if (!out_context) { + return ESP_ERR_INVALID_ARG; + } + + memset(out_context, 0, sizeof(*out_context)); + err = read_file_dup(s_memory.long_term_memory_path, &content); + if (err != ESP_OK) { + return err; + } + if (!content[0]) { + free(content); + return ESP_ERR_NOT_FOUND; + } + + out_context->kind = CLAW_CORE_CONTEXT_KIND_SYSTEM_PROMPT; + out_context->content = content; + return ESP_OK; +} + +static esp_err_t claw_memory_session_history_collect(const claw_core_request_t *request, + claw_core_context_t *out_context, + void *user_ctx) +{ + char *content = NULL; + size_t content_size; + esp_err_t err; + + (void)user_ctx; + + if (!request || !out_context || !request->session_id || !request->session_id[0]) { + return ESP_ERR_NOT_FOUND; + } + + memset(out_context, 0, sizeof(*out_context)); + content_size = session_history_json_size(); + content = calloc(1, content_size); + if (!content) { + return ESP_ERR_NO_MEM; + } + + err = claw_memory_session_load_json(request->session_id, content, content_size); + if (err != ESP_OK) { + free(content); + return err; + } + if (!content[0] || strcmp(content, "[]") == 0) { + free(content); + return ESP_ERR_NOT_FOUND; + } + + out_context->kind = CLAW_CORE_CONTEXT_KIND_MESSAGES; + out_context->content = content; + return ESP_OK; +} + +const claw_core_context_provider_t claw_memory_long_term_provider = { + .name = "Long-term Memory", + .collect = claw_memory_long_term_collect, + .user_ctx = NULL, +}; + +const claw_core_context_provider_t claw_memory_session_history_provider = { + .name = "Session History", + .collect = claw_memory_session_history_collect, + .user_ctx = NULL, +}; diff --git a/components/claw_skill/CMakeLists.txt b/components/claw_skill/CMakeLists.txt new file mode 100644 index 0000000..5f99e2e --- /dev/null +++ b/components/claw_skill/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register( + SRCS + "src/claw_skill.c" + INCLUDE_DIRS + "include" + REQUIRES + claw_core +) diff --git a/components/claw_skill/include/claw_skill.h b/components/claw_skill/include/claw_skill.h new file mode 100644 index 0000000..2023ce2 --- /dev/null +++ b/components/claw_skill/include/claw_skill.h @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include + +#include "claw_core.h" +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + const char *skills_root_dir; + const char *session_state_root_dir; + size_t max_skill_files; + size_t max_file_bytes; +} claw_skill_config_t; + +esp_err_t claw_skill_init(const claw_skill_config_t *config); + +/* Renders the skill catalog used by the prompt layer. */ +esp_err_t claw_skill_read_skills_list(char *buf, size_t size); + +/* Loads the active skill ids for one session from persistent state. */ +esp_err_t claw_skill_load_active_skill_ids(const char *session_id, + char ***out_skill_ids, + size_t *out_skill_count); + +/* Changes only the active skill state for one session. */ +esp_err_t claw_skill_activate_for_session(const char *session_id, const char *skill_id); +esp_err_t claw_skill_deactivate_for_session(const char *session_id, const char *skill_id); +esp_err_t claw_skill_clear_active_for_session(const char *session_id); + +/* Prompt providers for the catalog and active skill documents. */ +extern const claw_core_context_provider_t claw_skill_skills_list_provider; +extern const claw_core_context_provider_t claw_skill_active_skill_docs_provider; + +#ifdef __cplusplus +} +#endif diff --git a/components/claw_skill/src/claw_skill.c b/components/claw_skill/src/claw_skill.c new file mode 100644 index 0000000..5a93843 --- /dev/null +++ b/components/claw_skill/src/claw_skill.c @@ -0,0 +1,953 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "claw_skill.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "esp_log.h" + +static const char *TAG = "claw_skill"; +static const char *SKILLS_LIST_FILE = "skills_list.json"; + +#define CLAW_SKILL_DEFAULT_MAX_FILES 32 +#define CLAW_SKILL_DEFAULT_MAX_BYTES 2048 +#define CLAW_SKILL_MAX_PATH 192 + +typedef struct { + char *id; + char *file; + char *title; + char *summary; +} claw_skill_registry_entry_t; + +typedef struct { + int initialized; + char skills_root_dir[CLAW_SKILL_MAX_PATH]; + char session_state_root_dir[CLAW_SKILL_MAX_PATH]; + size_t max_skill_files; + size_t max_file_bytes; + claw_skill_registry_entry_t *entries; + size_t entry_count; +} claw_skill_state_t; + +static claw_skill_state_t s_skill = {0}; + +static void safe_copy(char *dst, size_t dst_size, const char *src) +{ + size_t len; + + if (!dst || dst_size == 0) { + return; + } + if (!src) { + dst[0] = '\0'; + return; + } + + len = strnlen(src, dst_size - 1); + memcpy(dst, src, len); + dst[len] = '\0'; +} + +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 void free_string_array(char **items, size_t count) +{ + size_t i; + + if (!items) { + return; + } + + for (i = 0; i < count; i++) { + free(items[i]); + } + free(items); +} + +static void free_registry_entry(claw_skill_registry_entry_t *entry) +{ + if (!entry) { + return; + } + + free(entry->id); + free(entry->file); + free(entry->title); + free(entry->summary); + memset(entry, 0, sizeof(*entry)); +} + +static void claw_skill_reset(void) +{ + size_t i; + + for (i = 0; i < s_skill.entry_count; i++) { + free_registry_entry(&s_skill.entries[i]); + } + free(s_skill.entries); + memset(&s_skill, 0, sizeof(s_skill)); +} + +static bool is_markdown_skill_file(const char *name) +{ + size_t len; + + if (!name) { + return false; + } + + len = strlen(name); + return len > 3 && strcmp(name + len - 3, ".md") == 0; +} + +static bool skill_path_is_valid(const char *path) +{ + if (!path || !path[0]) { + return false; + } + if (path[0] == '/' || strstr(path, "..") != NULL) { + return false; + } + return strchr(path, '\\') == NULL; +} + +static char *build_skill_path_dup(const char *filename) +{ + return dup_printf("%s/%s", s_skill.skills_root_dir, filename); +} + +static esp_err_t 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; + } + return mkdir(path, 0755) == 0 ? ESP_OK : ESP_FAIL; +} + +static void sanitize_session_id(const char *session_id, char *buf, size_t size) +{ + size_t off = 0; + + if (!buf || size == 0) { + return; + } + buf[0] = '\0'; + if (!session_id) { + return; + } + + while (*session_id && off + 1 < size) { + char ch = *session_id++; + + if ((ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9')) { + buf[off++] = ch; + } else if (off == 0 || buf[off - 1] != '_') { + buf[off++] = '_'; + } + } + if (off > 0 && buf[off - 1] == '_') { + off--; + } + buf[off] = '\0'; +} + +static char *build_session_state_path_dup(const char *session_id) +{ + char safe_session_id[48]; + uint32_t hash = 2166136261u; + const unsigned char *p = (const unsigned char *)session_id; + size_t len; + + if (!session_id || !session_id[0] || !s_skill.session_state_root_dir[0]) { + return NULL; + } + + sanitize_session_id(session_id, safe_session_id, sizeof(safe_session_id)); + while (p && *p) { + hash ^= *p++; + hash *= 16777619u; + } + + len = strnlen(safe_session_id, sizeof(safe_session_id) - 1); + if (len > 24) { + safe_session_id[24] = '\0'; + } + + return dup_printf("%s/s_%s_%08" PRIx32 ".skills.json", + s_skill.session_state_root_dir, + safe_session_id[0] ? safe_session_id : "default", + hash); +} + +static esp_err_t read_file_dup(const char *path, char **out_data) +{ + FILE *file = NULL; + long size; + char *data = NULL; + size_t read_bytes; + + if (!path || !out_data) { + return ESP_ERR_INVALID_ARG; + } + *out_data = NULL; + + file = fopen(path, "rb"); + if (!file) { + return ESP_ERR_NOT_FOUND; + } + if (fseek(file, 0, SEEK_END) != 0) { + fclose(file); + return ESP_FAIL; + } + size = ftell(file); + if (size < 0) { + fclose(file); + return ESP_FAIL; + } + if (fseek(file, 0, SEEK_SET) != 0) { + fclose(file); + return ESP_FAIL; + } + + data = calloc(1, (size_t)size + 1); + if (!data) { + fclose(file); + return ESP_ERR_NO_MEM; + } + + read_bytes = fread(data, 1, (size_t)size, file); + fclose(file); + data[read_bytes] = '\0'; + *out_data = data; + return ESP_OK; +} + +static esp_err_t write_file_text(const char *path, const char *text) +{ + FILE *file = NULL; + + if (!path || !text) { + return ESP_ERR_INVALID_ARG; + } + + file = fopen(path, "wb"); + if (!file) { + return ESP_FAIL; + } + if (fputs(text, file) < 0) { + fclose(file); + return ESP_FAIL; + } + fclose(file); + return ESP_OK; +} + +static esp_err_t read_limited_file(const char *path, char *buf, size_t size) +{ + FILE *file = NULL; + size_t n; + + if (!path || !buf || size == 0) { + return ESP_ERR_INVALID_ARG; + } + + file = fopen(path, "rb"); + if (!file) { + return ESP_ERR_NOT_FOUND; + } + n = fread(buf, 1, size - 1, file); + fclose(file); + buf[n] = '\0'; + return ESP_OK; +} + +static esp_err_t json_dup_required_string(cJSON *object, const char *key, char **out_value) +{ + cJSON *item; + + if (!object || !key || !out_value) { + return ESP_ERR_INVALID_ARG; + } + *out_value = NULL; + + item = cJSON_GetObjectItemCaseSensitive(object, key); + if (!cJSON_IsString(item) || !item->valuestring || !item->valuestring[0]) { + return ESP_ERR_INVALID_ARG; + } + + *out_value = strdup(item->valuestring); + return *out_value ? ESP_OK : ESP_ERR_NO_MEM; +} + +static const claw_skill_registry_entry_t *claw_skill_find_entry(const char *skill_id) +{ + size_t i; + + if (!skill_id || !skill_id[0]) { + return NULL; + } + + for (i = 0; i < s_skill.entry_count; i++) { + if (strcmp(s_skill.entries[i].id, skill_id) == 0) { + return &s_skill.entries[i]; + } + } + + return NULL; +} + +static bool string_array_contains(const char *const *items, size_t count, const char *value) +{ + size_t i; + + if (!items || !value) { + return false; + } + + for (i = 0; i < count; i++) { + if (items[i] && strcmp(items[i], value) == 0) { + return true; + } + } + + return false; +} + +static esp_err_t push_unique_string(char ***items, size_t *count, const char *value) +{ + char **grown; + + if (!items || !count || !value || !value[0]) { + return ESP_ERR_INVALID_ARG; + } + if (string_array_contains((const char *const *) * items, *count, value)) { + return ESP_OK; + } + + grown = realloc(*items, sizeof(char *) * (*count + 1)); + if (!grown) { + return ESP_ERR_NO_MEM; + } + *items = grown; + (*items)[*count] = strdup(value); + if (!(*items)[*count]) { + return ESP_ERR_NO_MEM; + } + (*count)++; + return ESP_OK; +} + +static esp_err_t validate_registry_entry(claw_skill_registry_entry_t *entry) +{ + char *path = NULL; + FILE *file = NULL; + + if (!entry || !entry->id || !entry->file || !entry->title || !entry->summary) { + return ESP_ERR_INVALID_ARG; + } + if (!skill_path_is_valid(entry->file) || !is_markdown_skill_file(entry->file)) { + return ESP_ERR_INVALID_ARG; + } + + path = build_skill_path_dup(entry->file); + if (!path) { + return ESP_ERR_NO_MEM; + } + file = fopen(path, "rb"); + free(path); + if (!file) { + return ESP_ERR_NOT_FOUND; + } + fclose(file); + return ESP_OK; +} + +static esp_err_t load_registry_from_json(void) +{ + char *path = NULL; + char *json_text = NULL; + cJSON *root = NULL; + cJSON *skills = NULL; + cJSON *skill = NULL; + claw_skill_registry_entry_t *entries = NULL; + size_t entry_count = 0; + size_t entry_index = 0; + esp_err_t err = ESP_OK; + + path = build_skill_path_dup(SKILLS_LIST_FILE); + if (!path) { + return ESP_ERR_NO_MEM; + } + + err = read_file_dup(path, &json_text); + free(path); + if (err != ESP_OK) { + return err; + } + + root = cJSON_Parse(json_text); + free(json_text); + if (!root || !cJSON_IsObject(root)) { + cJSON_Delete(root); + return ESP_ERR_INVALID_STATE; + } + + skills = cJSON_GetObjectItemCaseSensitive(root, "skills"); + if (!cJSON_IsArray(skills)) { + err = ESP_ERR_INVALID_ARG; + goto cleanup; + } + + entry_count = (size_t)cJSON_GetArraySize(skills); + if (entry_count == 0 || entry_count > s_skill.max_skill_files) { + err = ESP_ERR_INVALID_ARG; + goto cleanup; + } + + entries = calloc(entry_count, sizeof(*entries)); + if (!entries) { + err = ESP_ERR_NO_MEM; + goto cleanup; + } + + cJSON_ArrayForEach(skill, skills) { + claw_skill_registry_entry_t *entry; + size_t i; + + if (!cJSON_IsObject(skill) || entry_index >= entry_count) { + err = ESP_ERR_INVALID_ARG; + goto cleanup; + } + + entry = &entries[entry_index]; + err = json_dup_required_string(skill, "id", &entry->id); + if (err == ESP_OK) { + err = json_dup_required_string(skill, "file", &entry->file); + } + if (err == ESP_OK) { + err = json_dup_required_string(skill, "title", &entry->title); + } + if (err == ESP_OK) { + err = json_dup_required_string(skill, "summary", &entry->summary); + } + if (err == ESP_OK) { + err = validate_registry_entry(entry); + } + if (err != ESP_OK) { + goto cleanup; + } + + for (i = 0; i < entry_index; i++) { + if (strcmp(entries[i].id, entry->id) == 0) { + err = ESP_ERR_INVALID_ARG; + goto cleanup; + } + } + + entry_index++; + } + + s_skill.entries = entries; + s_skill.entry_count = entry_count; + entries = NULL; + +cleanup: + if (entries) { + size_t i; + + for (i = 0; i < entry_count; i++) { + free_registry_entry(&entries[i]); + } + free(entries); + } + cJSON_Delete(root); + return err; +} + +static esp_err_t claw_skill_read_document(const claw_skill_registry_entry_t *entry, + char *buf, + size_t size) +{ + char *path = NULL; + esp_err_t err; + + if (!entry || !buf || size == 0) { + return ESP_ERR_INVALID_ARG; + } + + path = build_skill_path_dup(entry->file); + if (!path) { + return ESP_ERR_NO_MEM; + } + err = read_limited_file(path, buf, size); + free(path); + return err; +} + +static esp_err_t load_active_skill_ids_from_disk(const char *session_id, + char ***out_skill_ids, + size_t *out_skill_count) +{ + char *path = NULL; + char *json_text = NULL; + cJSON *root = NULL; + char **loaded = NULL; + size_t loaded_count = 0; + size_t i; + esp_err_t err; + + if (!out_skill_ids || !out_skill_count) { + return ESP_ERR_INVALID_ARG; + } + *out_skill_ids = NULL; + *out_skill_count = 0; + + if (!s_skill.initialized || !session_id || !session_id[0]) { + return ESP_ERR_INVALID_STATE; + } + + path = build_session_state_path_dup(session_id); + if (!path) { + return ESP_ERR_INVALID_ARG; + } + + err = read_file_dup(path, &json_text); + free(path); + if (err != ESP_OK) { + return err; + } + + root = cJSON_Parse(json_text); + free(json_text); + if (!cJSON_IsArray(root)) { + cJSON_Delete(root); + return ESP_ERR_INVALID_STATE; + } + + for (i = 0; i < (size_t)cJSON_GetArraySize(root); i++) { + cJSON *item = cJSON_GetArrayItem(root, (int)i); + + if (!cJSON_IsString(item) || !item->valuestring || !item->valuestring[0]) { + continue; + } + if (!claw_skill_find_entry(item->valuestring)) { + continue; + } + err = push_unique_string(&loaded, &loaded_count, item->valuestring); + if (err != ESP_OK) { + free_string_array(loaded, loaded_count); + cJSON_Delete(root); + return err; + } + } + + cJSON_Delete(root); + if (loaded_count == 0) { + free_string_array(loaded, loaded_count); + return ESP_ERR_NOT_FOUND; + } + + *out_skill_ids = loaded; + *out_skill_count = loaded_count; + return ESP_OK; +} + +static esp_err_t save_active_skill_ids_to_disk(const char *session_id, + const char *const *skill_ids, + size_t skill_count) +{ + char *path = NULL; + cJSON *root = NULL; + char *json_text = NULL; + esp_err_t err = ESP_OK; + size_t i; + + if (!s_skill.initialized || !session_id || !session_id[0]) { + return ESP_ERR_INVALID_STATE; + } + + path = build_session_state_path_dup(session_id); + if (!path) { + return ESP_ERR_INVALID_ARG; + } + + if (skill_count == 0) { + remove(path); + free(path); + return ESP_OK; + } + + root = cJSON_CreateArray(); + if (!root) { + free(path); + return ESP_ERR_NO_MEM; + } + + for (i = 0; i < skill_count; i++) { + cJSON *item; + + if (!skill_ids[i] || !skill_ids[i][0]) { + continue; + } + item = cJSON_CreateString(skill_ids[i]); + if (!item) { + err = ESP_ERR_NO_MEM; + goto cleanup; + } + cJSON_AddItemToArray(root, item); + } + + json_text = cJSON_PrintUnformatted(root); + if (!json_text) { + err = ESP_ERR_NO_MEM; + goto cleanup; + } + + err = write_file_text(path, json_text); + +cleanup: + free(path); + cJSON_Delete(root); + free(json_text); + return err; +} + +static esp_err_t claw_skill_render_skills_list(char *buf, size_t size) +{ + size_t i; + size_t off = 0; + + if (!s_skill.initialized || !buf || size == 0) { + return ESP_ERR_INVALID_STATE; + } + + buf[0] = '\0'; + off += snprintf(buf + off, size - off, "Available skills:\n"); + for (i = 0; i < s_skill.entry_count && off + 1 < size; i++) { + const claw_skill_registry_entry_t *entry = &s_skill.entries[i]; + + off += snprintf(buf + off, + size - off, + "- %s (%s): %s\n", + entry->title, + entry->id, + entry->summary); + } + + return ESP_OK; +} + +static esp_err_t claw_skill_build_prompt_block(const char *const *skill_ids, + size_t skill_count, + char *buf, + size_t size) +{ + size_t i; + size_t off = 0; + + if (!s_skill.initialized || !buf || size == 0) { + return ESP_ERR_INVALID_STATE; + } + + buf[0] = '\0'; + for (i = 0; i < skill_count && off + 1 < size; i++) { + const claw_skill_registry_entry_t *entry = claw_skill_find_entry(skill_ids[i]); + char *content = NULL; + esp_err_t err; + + if (!entry) { + return ESP_ERR_NOT_FOUND; + } + + content = calloc(1, s_skill.max_file_bytes + 1); + if (!content) { + return ESP_ERR_NO_MEM; + } + + err = claw_skill_read_document(entry, content, s_skill.max_file_bytes + 1); + if (err != ESP_OK) { + free(content); + return err; + } + + off += snprintf(buf + off, + size - off, + "%s### %s\n%s\n", + i == 0 ? "" : "\n", + entry->title, + content); + free(content); + } + + return ESP_OK; +} + +esp_err_t claw_skill_init(const claw_skill_config_t *config) +{ + esp_err_t err; + + if (!config || !config->skills_root_dir || !config->session_state_root_dir) { + return ESP_ERR_INVALID_ARG; + } + + claw_skill_reset(); + safe_copy(s_skill.skills_root_dir, sizeof(s_skill.skills_root_dir), config->skills_root_dir); + safe_copy(s_skill.session_state_root_dir, + sizeof(s_skill.session_state_root_dir), + config->session_state_root_dir); + s_skill.max_skill_files = config->max_skill_files ? config->max_skill_files : CLAW_SKILL_DEFAULT_MAX_FILES; + s_skill.max_file_bytes = config->max_file_bytes ? config->max_file_bytes : CLAW_SKILL_DEFAULT_MAX_BYTES; + + err = ensure_dir(s_skill.session_state_root_dir); + if (err != ESP_OK) { + claw_skill_reset(); + return err; + } + + err = load_registry_from_json(); + if (err != ESP_OK) { + claw_skill_reset(); + return err; + } + + s_skill.initialized = 1; + ESP_LOGI(TAG, "Initialized registry with %u skill(s)", (unsigned)s_skill.entry_count); + return ESP_OK; +} + +esp_err_t claw_skill_read_skills_list(char *buf, size_t size) +{ + return claw_skill_render_skills_list(buf, size); +} + +esp_err_t claw_skill_load_active_skill_ids(const char *session_id, + char ***out_skill_ids, + size_t *out_skill_count) +{ + return load_active_skill_ids_from_disk(session_id, out_skill_ids, out_skill_count); +} + +esp_err_t claw_skill_activate_for_session(const char *session_id, const char *skill_id) +{ + char **active = NULL; + size_t active_count = 0; + esp_err_t err; + + if (!s_skill.initialized || !session_id || !session_id[0] || !skill_id || !skill_id[0]) { + return ESP_ERR_INVALID_ARG; + } + if (!claw_skill_find_entry(skill_id)) { + return ESP_ERR_NOT_FOUND; + } + + err = load_active_skill_ids_from_disk(session_id, &active, &active_count); + if (err != ESP_OK && err != ESP_ERR_NOT_FOUND) { + return err; + } + + err = push_unique_string(&active, &active_count, skill_id); + if (err != ESP_OK) { + free_string_array(active, active_count); + return err; + } + + err = save_active_skill_ids_to_disk(session_id, (const char *const *)active, active_count); + free_string_array(active, active_count); + return err; +} + +esp_err_t claw_skill_deactivate_for_session(const char *session_id, const char *skill_id) +{ + char **active = NULL; + size_t active_count = 0; + size_t write_count = 0; + size_t i; + esp_err_t err; + + if (!s_skill.initialized || !session_id || !session_id[0] || !skill_id || !skill_id[0]) { + return ESP_ERR_INVALID_ARG; + } + + err = load_active_skill_ids_from_disk(session_id, &active, &active_count); + if (err == ESP_ERR_NOT_FOUND) { + return ESP_OK; + } + if (err != ESP_OK) { + return err; + } + + for (i = 0; i < active_count; i++) { + if (active[i] && strcmp(active[i], skill_id) != 0) { + active[write_count++] = active[i]; + } else { + free(active[i]); + active[i] = NULL; + } + } + + err = save_active_skill_ids_to_disk(session_id, (const char *const *)active, write_count); + for (i = 0; i < write_count; i++) { + free(active[i]); + } + free(active); + return err; +} + +esp_err_t claw_skill_clear_active_for_session(const char *session_id) +{ + if (!s_skill.initialized || !session_id || !session_id[0]) { + return ESP_ERR_INVALID_ARG; + } + return save_active_skill_ids_to_disk(session_id, NULL, 0); +} + +static esp_err_t claw_skill_skills_list_collect(const claw_core_request_t *request, + claw_core_context_t *out_context, + void *user_ctx) +{ + char *content = NULL; + size_t content_size; + esp_err_t err; + + (void)request; + (void)user_ctx; + + if (!out_context || !s_skill.initialized) { + return ESP_ERR_INVALID_ARG; + } + + content_size = 64; + for (size_t i = 0; i < s_skill.entry_count; i++) { + content_size += strlen(s_skill.entries[i].title ? s_skill.entries[i].title : ""); + content_size += strlen(s_skill.entries[i].id ? s_skill.entries[i].id : ""); + content_size += strlen(s_skill.entries[i].summary ? s_skill.entries[i].summary : ""); + content_size += 16; + } + + content = calloc(1, content_size + 1); + if (!content) { + return ESP_ERR_NO_MEM; + } + + err = claw_skill_render_skills_list(content, content_size + 1); + if (err != ESP_OK) { + free(content); + return err; + } + if (!content[0]) { + free(content); + return ESP_ERR_NOT_FOUND; + } + + memset(out_context, 0, sizeof(*out_context)); + out_context->kind = CLAW_CORE_CONTEXT_KIND_SYSTEM_PROMPT; + out_context->content = content; + return ESP_OK; +} + +static esp_err_t claw_skill_active_docs_collect(const claw_core_request_t *request, + claw_core_context_t *out_context, + void *user_ctx) +{ + char **active_skill_ids = NULL; + size_t active_skill_count = 0; + char *content = NULL; + size_t content_size; + esp_err_t err; + + (void)user_ctx; + + if (!request || !out_context || !request->session_id || !request->session_id[0]) { + return ESP_ERR_NOT_FOUND; + } + + err = load_active_skill_ids_from_disk(request->session_id, &active_skill_ids, &active_skill_count); + if (err != ESP_OK) { + return err; + } + if (active_skill_count == 0) { + free_string_array(active_skill_ids, active_skill_count); + return ESP_ERR_NOT_FOUND; + } + + content_size = (active_skill_count * (s_skill.max_file_bytes + 128)) + 1; + content = calloc(1, content_size); + if (!content) { + free_string_array(active_skill_ids, active_skill_count); + return ESP_ERR_NO_MEM; + } + + err = claw_skill_build_prompt_block((const char *const *)active_skill_ids, + active_skill_count, + content, + content_size); + free_string_array(active_skill_ids, active_skill_count); + if (err != ESP_OK) { + free(content); + return err; + } + if (!content[0]) { + free(content); + return ESP_ERR_NOT_FOUND; + } + + memset(out_context, 0, sizeof(*out_context)); + out_context->kind = CLAW_CORE_CONTEXT_KIND_SYSTEM_PROMPT; + out_context->content = content; + return ESP_OK; +} + +const claw_core_context_provider_t claw_skill_skills_list_provider = { + .name = "Skills List", + .collect = claw_skill_skills_list_collect, + .user_ctx = NULL, +}; + +const claw_core_context_provider_t claw_skill_active_skill_docs_provider = { + .name = "Active Skill Docs", + .collect = claw_skill_active_docs_collect, + .user_ctx = NULL, +}; diff --git a/schemas/automations.schema.json b/schemas/automations.schema.json new file mode 100644 index 0000000..297af29 --- /dev/null +++ b/schemas/automations.schema.json @@ -0,0 +1,199 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ESP-Clawgent Automation Rules", + "description": "Schema for ESP-Clawgent automation rules (automations.json)", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the rule" + }, + "description": { + "type": "string", + "description": "Optional description of the rule" + }, + "enabled": { + "type": "boolean", + "default": true, + "description": "Whether the rule is active" + }, + "consume_on_match": { + "type": "boolean", + "default": true, + "description": "If true, the event is marked as consumed and no further rules are processed for the same event" + }, + "ack": { + "type": "string", + "description": "Optional acknowledgment message template" + }, + "vars": { + "type": "object", + "description": "Rule-specific local variables", + "additionalProperties": true + }, + "match": { + "type": "object", + "required": ["event_type"], + "properties": { + "event_type": { + "type": "string", + "description": "The type of event to match (e.g., 'message', 'trigger')" + }, + "event_key": { + "type": "string", + "description": "Specific key to match within the event" + }, + "source_capability": { + "type": "string", + "description": "Capability that emitted the event" + }, + "channel": { + "type": "string", + "description": "Alias for source_channel" + }, + "source_channel": { + "type": "string", + "description": "Channel from which the event originated" + }, + "chat_id": { + "type": "string", + "description": "Chat or session identifier" + }, + "content_type": { + "type": "string", + "description": "Type of content in the event" + }, + "text": { + "type": "string", + "description": "Exact text or template to match" + } + }, + "additionalProperties": false + }, + "actions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["call_capability", "run_agent", "run_script", "send_message", "emit_event", "drop"], + "description": "The action to perform" + }, + "caller": { + "type": "string", + "enum": ["system", "agent", "console"], + "default": "system", + "description": "The security context for execution" + }, + "capability": { + "type": "string", + "description": "Specific capability name (required for call_capability)" + }, + "capture_output": { + "type": "boolean", + "default": true, + "description": "Whether to capture the output for subsequent actions" + }, + "fail_open": { + "type": "boolean", + "default": false, + "description": "If true, rule processing continues even if this action fails" + }, + "input": { + "type": "object", + "description": "Action configuration. Supports template rendering via {{...}}", + "additionalProperties": true + } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "call_capability" } } }, + "then": { "required": ["capability", "input"] } + }, + { + "if": { "properties": { "type": { "const": "send_message" } } }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "channel": { "type": "string" }, + "chat_id": { "type": "string" }, + "message": { "type": "string" } + } + } + } + } + }, + { + "if": { "properties": { "type": { "const": "run_agent" } } }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "target_channel": { "type": "string" }, + "target_chat_id": { "type": "string" }, + "session_policy": { + "type": "string", + "enum": ["chat", "trigger", "global", "ephemeral", "nosave"] + } + } + } + } + } + }, + { + "if": { "properties": { "type": { "const": "run_script" } } }, + "then": { + "required": ["input"], + "properties": { + "input": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "async": { "type": "boolean" } + }, + "required": ["path"] + } + } + } + }, + { + "if": { "properties": { "type": { "const": "emit_event" } } }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "source_capability": { "type": "string" }, + "event_type": { "type": "string" }, + "source_channel": { "type": "string" }, + "chat_id": { "type": "string" }, + "message_id": { "type": "string" }, + "content_type": { "type": "string" }, + "text": { "type": "string" }, + "payload_json": { "type": "string" }, + "session_policy": { + "type": "string", + "enum": ["chat", "trigger", "global", "ephemeral", "nosave"] + } + } + } + } + } + } + ] + } + } + }, + "required": ["id", "match", "actions"], + "additionalProperties": false + } +}