diff --git a/examples/messaging/echo_bot/CMakeLists.txt b/examples/messaging/echo_bot/CMakeLists.txt new file mode 100644 index 000000000..32fc51b03 --- /dev/null +++ b/examples/messaging/echo_bot/CMakeLists.txt @@ -0,0 +1,38 @@ +## +# @file CMakeLists.txt +# @brief Echo bot: standalone messaging app (Telegram/Discord/Feishu), no external app refs. +#/ + +set(APP_PATH ${CMAKE_CURRENT_LIST_DIR}) +get_filename_component(APP_NAME ${APP_PATH} NAME) + +set(APP_SRCS + ${APP_PATH}/src/tuya_app_main.c + ${APP_PATH}/src/cli_echo.c + ${APP_PATH}/src/wifi/wifi_manager.c +) + +set(APP_INC + ${APP_PATH}/include + ${APP_PATH}/src +) + +######################################## +# Target Configure +######################################## +add_library(${EXAMPLE_LIB}) + +target_sources(${EXAMPLE_LIB} + PRIVATE + ${APP_SRCS} +) + +target_include_directories(${EXAMPLE_LIB} + PRIVATE + ${APP_INC} +) + +######################################## +# Add subdirectory +######################################## +add_subdirectory(${APP_PATH}/IM) diff --git a/examples/messaging/echo_bot/IM/.gitignore b/examples/messaging/echo_bot/IM/.gitignore new file mode 100644 index 000000000..92b02a86a --- /dev/null +++ b/examples/messaging/echo_bot/IM/.gitignore @@ -0,0 +1 @@ +im_secrets.h diff --git a/examples/messaging/echo_bot/IM/CMakeLists.txt b/examples/messaging/echo_bot/IM/CMakeLists.txt new file mode 100644 index 000000000..10793ee71 --- /dev/null +++ b/examples/messaging/echo_bot/IM/CMakeLists.txt @@ -0,0 +1,35 @@ +## +# @file CMakeLists.txt +# @brief Echo bot IM component: message_bus + channels + proxy + certs. +#/ + +set(IM_PATH ${CMAKE_CURRENT_LIST_DIR}) +# get_filename_component(APP_PATH ${IM_PATH}/.. ABSOLUTE) + +set(IM_SRCS + ${IM_PATH}/im_utils.c + ${IM_PATH}/bus/message_bus.c + ${IM_PATH}/channels/telegram_bot.c + ${IM_PATH}/channels/discord_bot.c + ${IM_PATH}/channels/feishu_bot.c + ${IM_PATH}/proxy/http_proxy.c + ${IM_PATH}/certs/tls_cert_bundle.c + ${IM_PATH}/certs/ca_bundle_mini.c +) + +set(IM_INC + ${IM_PATH} +) + +######################################## +# Target Configure +######################################## +target_sources(${EXAMPLE_LIB} + PRIVATE + ${IM_SRCS} +) + +target_include_directories(${EXAMPLE_LIB} + PRIVATE + ${IM_INC} +) diff --git a/examples/messaging/echo_bot/IM/README.md b/examples/messaging/echo_bot/IM/README.md new file mode 100644 index 000000000..da60fca4c --- /dev/null +++ b/examples/messaging/echo_bot/IM/README.md @@ -0,0 +1,307 @@ +# IM Component + +[English](#overview) | [中文](#概述) + +## Overview + +A self-contained, reusable instant-messaging component for [TuyaOpen](https://github.com/tuya/TuyaOpen). It provides a unified message bus, multi-channel drivers (Telegram / Discord / Feishu), HTTP/SOCKS5 proxy tunneling, and TLS certificate management. + +The entire `IM/` directory can be copied into any TuyaOpen project. The only external dependency is `tal_api.h` from the TuyaOpen platform SDK. + +### Supported Channels + +| Channel | Protocol | Inbound | Outbound | +|---|---|---|---| +| **Telegram** | HTTPS long-poll (`getUpdates`) | Text, document metadata | Text (Markdown + plain fallback) | +| **Discord** | WebSocket Gateway (v10) | Text, attachment metadata | Text (REST API) | +| **Feishu (Lark)** | WebSocket + Protobuf frames | Text, post, interactive card, share | Text (REST API) | + +### Key Design + +- **`im_platform.h`** — Platform adapter layer. All IM code goes through this single header for logging (`IM_LOG*`), memory (`im_malloc` / `im_free` / `im_calloc` / `im_realloc` / `im_strdup`), and KV storage (`im_kv_*`). Porting to a non-TuyaOpen platform only requires reimplementing this file. +- **`im_api.h`** — Single-include convenience header that pulls in the entire public API. +- **Message bus** — Lock-free inbound/outbound queues (`im_msg_t`) decouple channel drivers from application logic. +- **Proxy** — Transparent HTTP CONNECT / SOCKS5 tunneling; channel drivers automatically use it when configured. +- **TLS certs** — Queries Tuya iot-dns for domain certificates, falls back to a built-in CA bundle for public hosts (Telegram, Discord, Feishu). + +## Directory Structure + +``` +IM/ +├── im_platform.h # Platform adapter (logging, memory, KV storage) +├── im_config.h # Compile-time defaults (secrets, timeouts, NVS keys) +├── im_api.h # Single-include public API header +├── im_utils.h / .c # Shared utilities (string, HTTP parse, JSON, FNV hash) +├── bus/ +│ └── message_bus.h / .c # Inbound / outbound message queues (im_msg_t) +├── channels/ +│ ├── telegram_bot.h / .c # Telegram Bot — HTTPS long-poll + send +│ ├── discord_bot.h / .c # Discord Bot — WebSocket Gateway + REST send +│ └── feishu_bot.h / .c # Feishu Bot — WebSocket + Protobuf + REST send +├── proxy/ +│ └── http_proxy.h / .c # HTTP CONNECT / SOCKS5 proxy tunnel +├── certs/ +│ ├── tls_cert_bundle.h/.c# Domain cert query (iot-dns + builtin CA fallback) +│ └── ca_bundle_mini.h/.c # Builtin CA certificate bundle (ISRG Root X1 etc.) +└── CMakeLists.txt # Auto-registers sources & includes into ${EXAMPLE_LIB} +``` + +## Configuration + +Configuration is layered, from lowest to highest priority: + +| Layer | File / Mechanism | Description | +|---|---|---| +| **Defaults** | `im_config.h` | API hosts, timeouts, thread stacks, NVS key names | +| **Compile-time secrets** | `im_secrets.h` (git-ignored) | Bot tokens, app IDs, proxy settings | +| **Runtime overrides** | NVS (KV storage) | CLI or programmatic `im_kv_set_string()` calls | + +### `im_secrets.h` Example + +```c +// Telegram +#define IM_SECRET_TG_TOKEN "123456:ABC-DEF..." +#define IM_SECRET_CHANNEL_MODE "telegram" + +// Discord +#define IM_SECRET_DC_TOKEN "MTIz..." +#define IM_SECRET_DC_CHANNEL_ID "1234567890" + +// Feishu +#define IM_SECRET_FS_APP_ID "cli_xxxx" +#define IM_SECRET_FS_APP_SECRET "xxxx" +#define IM_SECRET_FS_ALLOW_FROM "ou_xxxx,ou_yyyy" + +// Proxy (optional) +#define IM_SECRET_PROXY_HOST "192.168.1.100" +#define IM_SECRET_PROXY_PORT "7890" +#define IM_SECRET_PROXY_TYPE "http" // "http" or "socks5" +``` + +## Integration + +### CMake + +In your project's `CMakeLists.txt`, add the IM subdirectory **after** `add_library`: + +```cmake +add_library(${EXAMPLE_LIB}) +# ... your sources ... +add_subdirectory(${APP_PATH}/IM) +``` + +The IM `CMakeLists.txt` automatically registers all sources and include paths into `${EXAMPLE_LIB}`. + +### Code + +Include the single convenience header, or individual modules as needed: + +```c +#include "im_api.h" +``` + +### Typical Init Sequence + +```c +// 1. Initialize subsystems +message_bus_init(); +http_proxy_init(); +telegram_bot_init(); // or discord_bot_init() / feishu_bot_init() + +// 2. Start channel driver (spawns background thread) +telegram_bot_start(); + +// 3. Consume inbound messages +while (1) { + im_msg_t msg = {0}; + if (message_bus_pop_inbound(&msg, timeout_ms) == OPRT_OK) { + // msg.channel = "telegram" / "discord" / "feishu" + // msg.chat_id = sender / chat identifier + // msg.content = message text (caller must free) + process(msg); + free(msg.content); + } +} +``` + +### Public API Summary + +| Module | Function | Description | +|---|---|---| +| **message_bus** | `message_bus_init()` | Initialize inbound & outbound queues | +| | `message_bus_push_inbound(msg)` | Push a message to the inbound queue | +| | `message_bus_pop_inbound(msg, ms)` | Pop from inbound (blocks up to `ms`) | +| | `message_bus_push_outbound(msg)` | Push a message to the outbound queue | +| | `message_bus_pop_outbound(msg, ms)` | Pop from outbound (blocks up to `ms`) | +| **telegram** | `telegram_bot_init()` | Load token from secrets / NVS | +| | `telegram_bot_start()` | Start long-poll thread | +| | `telegram_send_message(chat_id, text)` | Send text to a chat | +| | `telegram_set_token(token)` | Update token at runtime (persists to NVS) | +| **discord** | `discord_bot_init()` | Load token & channel from secrets / NVS | +| | `discord_bot_start()` | Start WebSocket Gateway thread | +| | `discord_send_message(channel_id, text)` | Send text to a channel | +| | `discord_set_token(token)` | Update token at runtime | +| | `discord_set_channel_id(id)` | Update default channel at runtime | +| **feishu** | `feishu_bot_init()` | Load app credentials from secrets / NVS | +| | `feishu_bot_start()` | Obtain tenant token & start WebSocket thread | +| | `feishu_send_message(chat_id, text)` | Send text to a user or group | +| | `feishu_set_app_id(id)` | Update App ID at runtime | +| | `feishu_set_app_secret(secret)` | Update App Secret at runtime | +| | `feishu_set_allow_from(csv)` | Update allowed sender list | +| **proxy** | `http_proxy_init()` | Load proxy settings from secrets / NVS | +| | `http_proxy_is_enabled()` | Check if proxy is configured | +| | `http_proxy_set(host, port, type)` | Configure proxy at runtime | +| | `http_proxy_clear()` | Remove proxy configuration | + +--- + +## 概述 + +IM 是一个独立的、可复用的即时通讯组件,基于 [TuyaOpen](https://github.com/tuya/TuyaOpen) 构建。它提供统一的消息总线、多通道驱动(Telegram / Discord / 飞书)、HTTP/SOCKS5 代理隧道和 TLS 证书管理。 + +整个 `IM/` 目录可直接复制到任意 TuyaOpen 项目中使用,唯一的外部依赖是 TuyaOpen 平台 SDK 提供的 `tal_api.h`。 + +### 支持的通道 + +| 通道 | 协议 | 入站消息 | 出站消息 | +|---|---|---|---| +| **Telegram** | HTTPS 长轮询(`getUpdates`) | 文本、文档元数据 | 文本(Markdown + 纯文本回退) | +| **Discord** | WebSocket Gateway (v10) | 文本、附件元数据 | 文本(REST API) | +| **飞书** | WebSocket + Protobuf 帧 | 文本、富文本、卡片消息、分享 | 文本(REST API) | + +### 核心设计 + +- **`im_platform.h`** — 平台适配层。所有 IM 代码通过此头文件统一调用日志(`IM_LOG*`)、内存(`im_malloc` / `im_free` / `im_calloc` / `im_realloc` / `im_strdup`)和 KV 存储(`im_kv_*`)。移植到非 TuyaOpen 平台时只需重新实现此文件。 +- **`im_api.h`** — 聚合头文件,一次引入即可访问全部公开 API。 +- **消息总线** — 入站/出站队列(`im_msg_t`)将通道驱动与应用逻辑解耦。 +- **代理** — 透明的 HTTP CONNECT / SOCKS5 隧道;配置后通道驱动自动使用。 +- **TLS 证书** — 通过 Tuya iot-dns 查询域名证书,对公共域名(Telegram、Discord、飞书)回退到内置 CA 证书包。 + +## 目录结构 + +``` +IM/ +├── im_platform.h # 平台适配层(日志、内存、KV 存储) +├── im_config.h # 编译期默认配置(密钥、超时、NVS 键名) +├── im_api.h # 聚合头文件,一次引入全部 API +├── im_utils.h / .c # 公共工具(字符串、HTTP 解析、JSON、FNV 哈希) +├── bus/ +│ └── message_bus.h / .c # 入站 / 出站消息队列(im_msg_t) +├── channels/ +│ ├── telegram_bot.h / .c # Telegram Bot — HTTPS 长轮询 + 发送 +│ ├── discord_bot.h / .c # Discord Bot — WebSocket Gateway + REST 发送 +│ └── feishu_bot.h / .c # 飞书 Bot — WebSocket + Protobuf + REST 发送 +├── proxy/ +│ └── http_proxy.h / .c # HTTP CONNECT / SOCKS5 代理隧道 +├── certs/ +│ ├── tls_cert_bundle.h/.c# 域名证书查询(iot-dns + 内置 CA 回退) +│ └── ca_bundle_mini.h/.c # 内置 CA 证书包(ISRG Root X1 等) +└── CMakeLists.txt # 自动注册源文件和头文件路径到 ${EXAMPLE_LIB} +``` + +## 配置 + +配置分为三层,优先级从低到高: + +| 层级 | 文件 / 机制 | 说明 | +|---|---|---| +| **默认值** | `im_config.h` | API 地址、超时时间、线程栈大小、NVS 键名 | +| **编译期密钥** | `im_secrets.h`(不纳入版本控制) | Bot Token、App ID、代理设置 | +| **运行时覆盖** | NVS(KV 存储) | 通过 CLI 或代码调用 `im_kv_set_string()` | + +### `im_secrets.h` 示例 + +```c +// Telegram +#define IM_SECRET_TG_TOKEN "123456:ABC-DEF..." +#define IM_SECRET_CHANNEL_MODE "telegram" + +// Discord +#define IM_SECRET_DC_TOKEN "MTIz..." +#define IM_SECRET_DC_CHANNEL_ID "1234567890" + +// 飞书 +#define IM_SECRET_FS_APP_ID "cli_xxxx" +#define IM_SECRET_FS_APP_SECRET "xxxx" +#define IM_SECRET_FS_ALLOW_FROM "ou_xxxx,ou_yyyy" + +// 代理(可选) +#define IM_SECRET_PROXY_HOST "192.168.1.100" +#define IM_SECRET_PROXY_PORT "7890" +#define IM_SECRET_PROXY_TYPE "http" // "http" 或 "socks5" +``` + +## 集成 + +### CMake + +在项目的 `CMakeLists.txt` 中,**在 `add_library` 之后**添加 IM 子目录: + +```cmake +add_library(${EXAMPLE_LIB}) +# ... 你的源文件 ... +add_subdirectory(${APP_PATH}/IM) +``` + +IM 的 `CMakeLists.txt` 会自动将所有源文件和头文件路径注册到 `${EXAMPLE_LIB}`。 + +### 代码引用 + +引入聚合头文件或按需引入单独模块: + +```c +#include "im_api.h" +``` + +### 典型初始化流程 + +```c +// 1. 初始化子系统 +message_bus_init(); +http_proxy_init(); +telegram_bot_init(); // 或 discord_bot_init() / feishu_bot_init() + +// 2. 启动通道驱动(会创建后台线程) +telegram_bot_start(); + +// 3. 消费入站消息 +while (1) { + im_msg_t msg = {0}; + if (message_bus_pop_inbound(&msg, timeout_ms) == OPRT_OK) { + // msg.channel = "telegram" / "discord" / "feishu" + // msg.chat_id = 发送者 / 聊天标识 + // msg.content = 消息文本(调用方负责释放) + process(msg); + free(msg.content); + } +} +``` + +### 公开 API 一览 + +| 模块 | 函数 | 说明 | +|---|---|---| +| **message_bus** | `message_bus_init()` | 初始化入站和出站队列 | +| | `message_bus_push_inbound(msg)` | 推送消息到入站队列 | +| | `message_bus_pop_inbound(msg, ms)` | 从入站队列取出(阻塞等待 `ms` 毫秒) | +| | `message_bus_push_outbound(msg)` | 推送消息到出站队列 | +| | `message_bus_pop_outbound(msg, ms)` | 从出站队列取出(阻塞等待 `ms` 毫秒) | +| **telegram** | `telegram_bot_init()` | 从密钥文件 / NVS 加载 Token | +| | `telegram_bot_start()` | 启动长轮询线程 | +| | `telegram_send_message(chat_id, text)` | 向指定聊天发送文本 | +| | `telegram_set_token(token)` | 运行时更新 Token(持久化到 NVS) | +| **discord** | `discord_bot_init()` | 从密钥文件 / NVS 加载 Token 和频道 | +| | `discord_bot_start()` | 启动 WebSocket Gateway 线程 | +| | `discord_send_message(channel_id, text)` | 向指定频道发送文本 | +| | `discord_set_token(token)` | 运行时更新 Token | +| | `discord_set_channel_id(id)` | 运行时更新默认频道 | +| **feishu** | `feishu_bot_init()` | 从密钥文件 / NVS 加载应用凭证 | +| | `feishu_bot_start()` | 获取 Tenant Token 并启动 WebSocket 线程 | +| | `feishu_send_message(chat_id, text)` | 向用户或群组发送文本 | +| | `feishu_set_app_id(id)` | 运行时更新 App ID | +| | `feishu_set_app_secret(secret)` | 运行时更新 App Secret | +| | `feishu_set_allow_from(csv)` | 运行时更新允许的发送者列表 | +| **proxy** | `http_proxy_init()` | 从密钥文件 / NVS 加载代理设置 | +| | `http_proxy_is_enabled()` | 检查代理是否已配置 | +| | `http_proxy_set(host, port, type)` | 运行时配置代理 | +| | `http_proxy_clear()` | 清除代理配置 | diff --git a/examples/messaging/echo_bot/IM/bus/message_bus.c b/examples/messaging/echo_bot/IM/bus/message_bus.c new file mode 100644 index 000000000..83a100b64 --- /dev/null +++ b/examples/messaging/echo_bot/IM/bus/message_bus.c @@ -0,0 +1,50 @@ +#include "bus/message_bus.h" +#include "im_config.h" + +static const char *TAG = "bus"; + +static QUEUE_HANDLE s_inbound_queue = NULL; +static QUEUE_HANDLE s_outbound_queue = NULL; + +OPERATE_RET message_bus_init(void) +{ + if (s_inbound_queue && s_outbound_queue) return OPRT_OK; + OPERATE_RET rt = tal_queue_create_init(&s_inbound_queue, sizeof(im_msg_t), IM_BUS_QUEUE_LEN); + if (rt != OPRT_OK) { + IM_LOGE(TAG, "create inbound queue failed: %d", rt); + return rt; + } + rt = tal_queue_create_init(&s_outbound_queue, sizeof(im_msg_t), IM_BUS_QUEUE_LEN); + if (rt != OPRT_OK) { + IM_LOGE(TAG, "create outbound queue failed: %d", rt); + tal_queue_free(s_inbound_queue); + s_inbound_queue = NULL; + return rt; + } + IM_LOGI(TAG, "message bus initialized, queue=%d", IM_BUS_QUEUE_LEN); + return OPRT_OK; +} + +OPERATE_RET message_bus_push_inbound(const im_msg_t *msg) +{ + if (!s_inbound_queue || !msg) return OPRT_INVALID_PARM; + return tal_queue_post(s_inbound_queue, (void *)msg, 1000); +} + +OPERATE_RET message_bus_pop_inbound(im_msg_t *msg, uint32_t timeout_ms) +{ + if (!s_inbound_queue || !msg) return OPRT_INVALID_PARM; + return tal_queue_fetch(s_inbound_queue, msg, timeout_ms == UINT32_MAX ? QUEUE_WAIT_FOREVER : timeout_ms); +} + +OPERATE_RET message_bus_push_outbound(const im_msg_t *msg) +{ + if (!s_outbound_queue || !msg) return OPRT_INVALID_PARM; + return tal_queue_post(s_outbound_queue, (void *)msg, 1000); +} + +OPERATE_RET message_bus_pop_outbound(im_msg_t *msg, uint32_t timeout_ms) +{ + if (!s_outbound_queue || !msg) return OPRT_INVALID_PARM; + return tal_queue_fetch(s_outbound_queue, msg, timeout_ms == UINT32_MAX ? QUEUE_WAIT_FOREVER : timeout_ms); +} diff --git a/examples/messaging/echo_bot/IM/bus/message_bus.h b/examples/messaging/echo_bot/IM/bus/message_bus.h new file mode 100644 index 000000000..71b7670f2 --- /dev/null +++ b/examples/messaging/echo_bot/IM/bus/message_bus.h @@ -0,0 +1,21 @@ +#ifndef __MESSAGE_BUS_H__ +#define __MESSAGE_BUS_H__ + +#include "im_platform.h" + +#define IM_CHAN_TELEGRAM "telegram" +#define IM_CHAN_DISCORD "discord" +#define IM_CHAN_FEISHU "feishu" +typedef struct { + char channel[16]; + char chat_id[96]; + char *content; +} im_msg_t; + +OPERATE_RET message_bus_init(void); +OPERATE_RET message_bus_push_inbound(const im_msg_t *msg); +OPERATE_RET message_bus_pop_inbound(im_msg_t *msg, uint32_t timeout_ms); +OPERATE_RET message_bus_push_outbound(const im_msg_t *msg); +OPERATE_RET message_bus_pop_outbound(im_msg_t *msg, uint32_t timeout_ms); + +#endif /* __MESSAGE_BUS_H__ */ diff --git a/examples/messaging/echo_bot/IM/certs/ca_bundle_mini.c b/examples/messaging/echo_bot/IM/certs/ca_bundle_mini.c new file mode 100644 index 000000000..3c2d3d915 --- /dev/null +++ b/examples/messaging/echo_bot/IM/certs/ca_bundle_mini.c @@ -0,0 +1,199 @@ +#include "certs/ca_bundle_mini.h" + +const uint8_t g_im_ca_bundle_mini_pem[] = + "# C = US, ST = Arizona, L = Scottsdale, O = \"GoDaddy.com, Inc.\", OU = http://certs.godaddy.com/repository/, CN " + "= Go Daddy Secure Certificate Authority - G2\n" + "-----BEGIN CERTIFICATE-----\n" + "MIIE0DCCA7igAwIBAgIBBzANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMx\n" + "EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoT\n" + "EUdvRGFkZHkuY29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRp\n" + "ZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTExMDUwMzA3MDAwMFoXDTMxMDUwMzA3\n" + "MDAwMFowgbQxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQH\n" + "EwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjEtMCsGA1UE\n" + "CxMkaHR0cDovL2NlcnRzLmdvZGFkZHkuY29tL3JlcG9zaXRvcnkvMTMwMQYDVQQD\n" + "EypHbyBEYWRkeSBTZWN1cmUgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IC0gRzIwggEi\n" + "MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC54MsQ1K92vdSTYuswZLiBCGzD\n" + "BNliF44v/z5lz4/OYuY8UhzaFkVLVat4a2ODYpDOD2lsmcgaFItMzEUz6ojcnqOv\n" + "K/6AYZ15V8TPLvQ/MDxdR/yaFrzDN5ZBUY4RS1T4KL7QjL7wMDge87Am+GZHY23e\n" + "cSZHjzhHU9FGHbTj3ADqRay9vHHZqm8A29vNMDp5T19MR/gd71vCxJ1gO7GyQ5HY\n" + "pDNO6rPWJ0+tJYqlxvTV0KaudAVkV4i1RFXULSo6Pvi4vekyCgKUZMQWOlDxSq7n\n" + "eTOvDCAHf+jfBDnCaQJsY1L6d8EbyHSHyLmTGFBUNUtpTrw700kuH9zB0lL7AgMB\n" + "AAGjggEaMIIBFjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNV\n" + "HQ4EFgQUQMK9J47MNIMwojPX+2yz8LQsgM4wHwYDVR0jBBgwFoAUOpqFBxBnKLbv\n" + "9r0FQW4gwZTaD94wNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8v\n" + "b2NzcC5nb2RhZGR5LmNvbS8wNQYDVR0fBC4wLDAqoCigJoYkaHR0cDovL2NybC5n\n" + "b2RhZGR5LmNvbS9nZHJvb3QtZzIuY3JsMEYGA1UdIAQ/MD0wOwYEVR0gADAzMDEG\n" + "CCsGAQUFBwIBFiVodHRwczovL2NlcnRzLmdvZGFkZHkuY29tL3JlcG9zaXRvcnkv\n" + "MA0GCSqGSIb3DQEBCwUAA4IBAQAIfmyTEMg4uJapkEv/oV9PBO9sPpyIBslQj6Zz\n" + "91cxG7685C/b+LrTW+C05+Z5Yg4MotdqY3MxtfWoSKQ7CC2iXZDXtHwlTxFWMMS2\n" + "RJ17LJ3lXubvDGGqv+QqG+6EnriDfcFDzkSnE3ANkR/0yBOtg2DZ2HKocyQetawi\n" + "DsoXiWJYRBuriSUBAA/NxBti21G00w9RKpv0vHP8ds42pM3Z2Czqrpv1KrKQ0U11\n" + "GIo/ikGQI31bS/6kA1ibRrLDYGCD+H1QQc7CoZDDu+8CL9IVVO5EFdkKrqeKM+2x\n" + "LXY2JtwE65/3YR8V3Idv7kaWKK2hJn0KCacuBKONvPi8BDAB\n" + "-----END CERTIFICATE-----\n" + "\n" + "\n" + "# C = US, ST = Arizona, L = Scottsdale, O = \"GoDaddy.com, Inc.\", CN = Go Daddy Root Certificate Authority - G2\n" + "-----BEGIN CERTIFICATE-----\n" + "MIIEfTCCA2WgAwIBAgIDG+cVMA0GCSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVT\n" + "MSEwHwYDVQQKExhUaGUgR28gRGFkZHkgR3JvdXAsIEluYy4xMTAvBgNVBAsTKEdv\n" + "IERhZGR5IENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTQwMTAx\n" + "MDcwMDAwWhcNMzEwNTMwMDcwMDAwWjCBgzELMAkGA1UEBhMCVVMxEDAOBgNVBAgT\n" + "B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoTEUdvRGFkZHku\n" + "Y29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRpZmljYXRlIEF1\n" + "dGhvcml0eSAtIEcyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv3Fi\n" + "CPH6WTT3G8kYo/eASVjpIoMTpsUgQwE7hPHmhUmfJ+r2hBtOoLTbcJjHMgGxBT4H\n" + "Tu70+k8vWTAi56sZVmvigAf88xZ1gDlRe+X5NbZ0TqmNghPktj+pA4P6or6KFWp/\n" + "3gvDthkUBcrqw6gElDtGfDIN8wBmIsiNaW02jBEYt9OyHGC0OPoCjM7T3UYH3go+\n" + "6118yHz7sCtTpJJiaVElBWEaRIGMLKlDliPfrDqBmg4pxRyp6V0etp6eMAo5zvGI\n" + "gPtLXcwy7IViQyU0AlYnAZG0O3AqP26x6JyIAX2f1PnbU21gnb8s51iruF9G/M7E\n" + "GwM8CetJMVxpRrPgRwIDAQABo4IBFzCCARMwDwYDVR0TAQH/BAUwAwEB/zAOBgNV\n" + "HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFDqahQcQZyi27/a9BUFuIMGU2g/eMB8GA1Ud\n" + "IwQYMBaAFNLEsNKR1EwRcbNhyz2h/t2oatTjMDQGCCsGAQUFBwEBBCgwJjAkBggr\n" + "BgEFBQcwAYYYaHR0cDovL29jc3AuZ29kYWRkeS5jb20vMDIGA1UdHwQrMCkwJ6Al\n" + "oCOGIWh0dHA6Ly9jcmwuZ29kYWRkeS5jb20vZ2Ryb290LmNybDBGBgNVHSAEPzA9\n" + "MDsGBFUdIAAwMzAxBggrBgEFBQcCARYlaHR0cHM6Ly9jZXJ0cy5nb2RhZGR5LmNv\n" + "bS9yZXBvc2l0b3J5LzANBgkqhkiG9w0BAQsFAAOCAQEAWQtTvZKGEacke+1bMc8d\n" + "H2xwxbhuvk679r6XUOEwf7ooXGKUwuN+M/f7QnaF25UcjCJYdQkMiGVnOQoWCcWg\n" + "OJekxSOTP7QYpgEGRJHjp2kntFolfzq3Ms3dhP8qOCkzpN1nsoX+oYggHFCJyNwq\n" + "9kIDN0zmiN/VryTyscPfzLXs4Jlet0lUIDyUGAzHHFIYSaRt4bNYC8nY7NmuHDKO\n" + "KHAN4v6mF56ED71XcLNa6R+ghlO773z/aQvgSMO3kwvIClTErF0UZzdsyqUvMQg3\n" + "qm5vjLyb4lddJIGvl5echK1srDdMZvNhkREg5L4wn3qkKQmw4TRfZHcYQFHfjDCm\n" + "rw==\n" + "-----END CERTIFICATE-----\n" + "\n" + "\n" + "# C = US, O = \"The Go Daddy Group, Inc.\", OU = Go Daddy Class 2 Certification Authority\n" + "-----BEGIN CERTIFICATE-----\n" + "MIIEADCCAuigAwIBAgIBADANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEh\n" + "MB8GA1UEChMYVGhlIEdvIERhZGR5IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBE\n" + "YWRkeSBDbGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA0MDYyOTE3\n" + "MDYyMFoXDTM0MDYyOTE3MDYyMFowYzELMAkGA1UEBhMCVVMxITAfBgNVBAoTGFRo\n" + "ZSBHbyBEYWRkeSBHcm91cCwgSW5jLjExMC8GA1UECxMoR28gRGFkZHkgQ2xhc3Mg\n" + "MiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASAwDQYJKoZIhvcNAQEBBQADggEN\n" + "ADCCAQgCggEBAN6d1+pXGEmhW+vXX0iG6r7d/+TvZxz0ZWizV3GgXne77ZtJ6XCA\n" + "PVYYYwhv2vLM0D9/AlQiVBDYsoHUwHU9S3/Hd8M+eKsaA7Ugay9qK7HFiH7Eux6w\n" + "wdhFJ2+qN1j3hybX2C32qRe3H3I2TqYXP2WYktsqbl2i/ojgC95/5Y0V4evLOtXi\n" + "EqITLdiOr18SPaAIBQi2XKVlOARFmR6jYGB0xUGlcmIbYsUfb18aQr4CUWWoriMY\n" + "avx4A6lNf4DD+qta/KFApMoZFv6yyO9ecw3ud72a9nmYvLEHZ6IVDd2gWMZEewo+\n" + "YihfukEHU1jPEX44dMX4/7VpkI+EdOqXG68CAQOjgcAwgb0wHQYDVR0OBBYEFNLE\n" + "sNKR1EwRcbNhyz2h/t2oatTjMIGNBgNVHSMEgYUwgYKAFNLEsNKR1EwRcbNhyz2h\n" + "/t2oatTjoWekZTBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMYVGhlIEdvIERhZGR5\n" + "IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRpZmlj\n" + "YXRpb24gQXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQAD\n" + "ggEBADJL87LKPpH8EsahB4yOd6AzBhRckB4Y9wimPQoZ+YeAEW5p5JYXMP80kWNy\n" + "OO7MHAGjHZQopDH2esRU1/blMVgDoszOYtuURXO1v0XJJLXVggKtI3lpjbi2Tc7P\n" + "TMozI+gciKqdi0FuFskg5YmezTvacPd+mSYgFFQlq25zheabIZ0KbIIOqPjCDPoQ\n" + "HmyW74cNxA9hi63ugyuV+I6ShHI56yDqg+2DzZduCLzrTia2cyvk0/ZM/iZx4mER\n" + "dEr/VxqHD3VILs9RaRegAhJhldXRQLIQTO7ErBBDpqWeCtWVYpoNz4iCxTIM5Cuf\n" + "ReYNnyicsbkqWletNw+vHX/bvZ8=\n" + "-----END CERTIFICATE-----\n" + "\n" + "\n" + "# C = US, O = Google Trust Services, CN = WE1\n" + "-----BEGIN CERTIFICATE-----\n" + "MIICnzCCAiWgAwIBAgIQf/MZd5csIkp2FV0TttaF4zAKBggqhkjOPQQDAzBHMQsw\n" + "CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU\n" + "MBIGA1UEAxMLR1RTIFJvb3QgUjQwHhcNMjMxMjEzMDkwMDAwWhcNMjkwMjIwMTQw\n" + "MDAwWjA7MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZp\n" + "Y2VzMQwwCgYDVQQDEwNXRTEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARvzTr+\n" + "Z1dHTCEDhUDCR127WEcPQMFcF4XGGTfn1XzthkubgdnXGhOlCgP4mMTG6J7/EFmP\n" + "LCaY9eYmJbsPAvpWo4H+MIH7MA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggr\n" + "BgEFBQcDAQYIKwYBBQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU\n" + "kHeSNWfE/6jMqeZ72YB5e8yT+TgwHwYDVR0jBBgwFoAUgEzW63T/STaj1dj8tT7F\n" + "avCUHYwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzAChhhodHRwOi8vaS5wa2ku\n" + "Z29vZy9yNC5jcnQwKwYDVR0fBCQwIjAgoB6gHIYaaHR0cDovL2MucGtpLmdvb2cv\n" + "ci9yNC5jcmwwEwYDVR0gBAwwCjAIBgZngQwBAgEwCgYIKoZIzj0EAwMDaAAwZQIx\n" + "AOcCq1HW90OVznX+0RGU1cxAQXomvtgM8zItPZCuFQ8jSBJSjz5keROv9aYsAm5V\n" + "sQIwJonMaAFi54mrfhfoFNZEfuNMSQ6/bIBiNLiyoX46FohQvKeIoJ99cx7sUkFN\n" + "7uJW\n" + "-----END CERTIFICATE-----\n" + "\n" + "\n" + "# C = US, O = Google Trust Services LLC, CN = GTS Root R4\n" + "-----BEGIN CERTIFICATE-----\n" + "MIIDejCCAmKgAwIBAgIQf+UwvzMTQ77dghYQST2KGzANBgkqhkiG9w0BAQsFADBX\n" + "MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEQMA4GA1UE\n" + "CxMHUm9vdCBDQTEbMBkGA1UEAxMSR2xvYmFsU2lnbiBSb290IENBMB4XDTIzMTEx\n" + "NTAzNDMyMVoXDTI4MDEyODAwMDA0MlowRzELMAkGA1UEBhMCVVMxIjAgBgNVBAoT\n" + "GUdvb2dsZSBUcnVzdCBTZXJ2aWNlcyBMTEMxFDASBgNVBAMTC0dUUyBSb290IFI0\n" + "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE83Rzp2iLYK5DuDXFgTB7S0md+8Fhzube\n" + "Rr1r1WEYNa5A3XP3iZEwWus87oV8okB2O6nGuEfYKueSkWpz6bFyOZ8pn6KY019e\n" + "WIZlD6GEZQbR3IvJx3PIjGov5cSr0R2Ko4H/MIH8MA4GA1UdDwEB/wQEAwIBhjAd\n" + "BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zAd\n" + "BgNVHQ4EFgQUgEzW63T/STaj1dj8tT7FavCUHYwwHwYDVR0jBBgwFoAUYHtmGkUN\n" + "l8qJUC99BM00qP/8/UswNgYIKwYBBQUHAQEEKjAoMCYGCCsGAQUFBzAChhpodHRw\n" + "Oi8vaS5wa2kuZ29vZy9nc3IxLmNydDAtBgNVHR8EJjAkMCKgIKAehhxodHRwOi8v\n" + "Yy5wa2kuZ29vZy9yL2dzcjEuY3JsMBMGA1UdIAQMMAowCAYGZ4EMAQIBMA0GCSqG\n" + "SIb3DQEBCwUAA4IBAQAYQrsPBtYDh5bjP2OBDwmkoWhIDDkic574y04tfzHpn+cJ\n" + "odI2D4SseesQ6bDrarZ7C30ddLibZatoKiws3UL9xnELz4ct92vID24FfVbiI1hY\n" + "+SW6FoVHkNeWIP0GCbaM4C6uVdF5dTUsMVs/ZbzNnIdCp5Gxmx5ejvEau8otR/Cs\n" + "kGN+hr/W5GvT1tMBjgWKZ1i4//emhA1JG1BbPzoLJQvyEotc03lXjTaCzv8mEbep\n" + "8RqZ7a2CPsgRbuvTPBwcOMBBmuFeU88+FSBX6+7iP0il8b4Z0QFqIwwMHfs/L6K1\n" + "vepuoxtGzi4CZ68zJpiq1UvSqTbFJjtbD4seiMHl\n" + "-----END CERTIFICATE-----\n" + "\n" + "\n" + "# C = US, O = DigiCert Inc, OU = www.digicert.com, CN = RapidSSL TLS RSA CA G1\n" + "-----BEGIN CERTIFICATE-----\n" + "MIIEszCCA5ugAwIBAgIQCyWUIs7ZgSoVoE6ZUooO+jANBgkqhkiG9w0BAQsFADBh\n" + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" + "d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH\n" + "MjAeFw0xNzExMDIxMjI0MzNaFw0yNzExMDIxMjI0MzNaMGAxCzAJBgNVBAYTAlVT\n" + "MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n" + "b20xHzAdBgNVBAMTFlJhcGlkU1NMIFRMUyBSU0EgQ0EgRzEwggEiMA0GCSqGSIb3\n" + "DQEBAQUAA4IBDwAwggEKAoIBAQC/uVklRBI1FuJdUEkFCuDL/I3aJQiaZ6aibRHj\n" + "ap/ap9zy1aYNrphe7YcaNwMoPsZvXDR+hNJOo9gbgOYVTPq8gXc84I75YKOHiVA4\n" + "NrJJQZ6p2sJQyqx60HkEIjzIN+1LQLfXTlpuznToOa1hyTD0yyitFyOYwURM+/CI\n" + "8FNFMpBhw22hpeAQkOOLmsqT5QZJYeik7qlvn8gfD+XdDnk3kkuuu0eG+vuyrSGr\n" + "5uX5LRhFWlv1zFQDch/EKmd163m6z/ycx/qLa9zyvILc7cQpb+k7TLra9WE17YPS\n" + "n9ANjG+ECo9PDW3N9lwhKQCNvw1gGoguyCQu7HE7BnW8eSSFAgMBAAGjggFmMIIB\n" + "YjAdBgNVHQ4EFgQUDNtsgkkPSmcKuBTuesRIUojrVjgwHwYDVR0jBBgwFoAUTiJU\n" + "IBiV5uNu5g/6+rkS7QYXjzkwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsG\n" + "AQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMDQGCCsGAQUFBwEB\n" + "BCgwJjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEIGA1Ud\n" + "HwQ7MDkwN6A1oDOGMWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEds\n" + "b2JhbFJvb3RHMi5jcmwwYwYDVR0gBFwwWjA3BglghkgBhv1sAQEwKjAoBggrBgEF\n" + "BQcCARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzALBglghkgBhv1sAQIw\n" + "CAYGZ4EMAQIBMAgGBmeBDAECAjANBgkqhkiG9w0BAQsFAAOCAQEAGUSlOb4K3Wtm\n" + "SlbmE50UYBHXM0SKXPqHMzk6XQUpCheF/4qU8aOhajsyRQFDV1ih/uPIg7YHRtFi\n" + "CTq4G+zb43X1T77nJgSOI9pq/TqCwtukZ7u9VLL3JAq3Wdy2moKLvvC8tVmRzkAe\n" + "0xQCkRKIjbBG80MSyDX/R4uYgj6ZiNT/Zg6GI6RofgqgpDdssLc0XIRQEotxIZcK\n" + "zP3pGJ9FCbMHmMLLyuBd+uCWvVcF2ogYAawufChS/PT61D9rqzPRS5I2uqa3tmIT\n" + "44JhJgWhBnFMb7AGQkvNq9KNS9dd3GWc17H/dXa1enoxzWjE0hBdFjxPhUb0W3wi\n" + "8o34/m8Fxw==\n" + "-----END CERTIFICATE-----\n" + "\n" + "\n" + "# C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert Global Root G2\n" + "-----BEGIN CERTIFICATE-----\n" + "MIIEgjCCA2qgAwIBAgIQBEbB7LuEYrWpF3L5qhjmezANBgkqhkiG9w0BAQsFADBh\n" + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" + "d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD\n" + "QTAeFw0yMzA4MTUwMDAwMDBaFw0zMTExMDkyMzU5NTlaMGExCzAJBgNVBAYTAlVT\n" + "MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n" + "b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG\n" + "9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI\n" + "2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx\n" + "1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ\n" + "q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz\n" + "tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ\n" + "vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo4IBNDCC\n" + "ATAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUTiJUIBiV5uNu5g/6+rkS7QYX\n" + "jzkwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUwDgYDVR0PAQH/BAQD\n" + "AgGGMHYGCCsGAQUFBwEBBGowaDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGln\n" + "aWNlcnQuY29tMEAGCCsGAQUFBzAChjRodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5j\n" + "b20vRGlnaUNlcnRHbG9iYWxSb290Q0EuY3J0MEIGA1UdHwQ7MDkwN6A1oDOGMWh0\n" + "dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RDQS5jcmww\n" + "EQYDVR0gBAowCDAGBgRVHSAAMA0GCSqGSIb3DQEBCwUAA4IBAQCVDWYaZnQ4F6Sq\n" + "Pvh0L4BW3CeDbvUrwuPypKgRvKk6RMY0hEN5CJKWtxXfQMF955d9k/7duBnEE8GM\n" + "pa4hhYiuaj5aITNR65I8QwUsOUibwLzOjTpGE9T524+I5UgbmsIVrQxZrQnT5EmR\n" + "3IDdtX+rJHASEgCTQeyEB3fR+Afiqtup45mJ502+lfLbLjJ/boMeQljP02L/JEL7\n" + "k0WEihHGwa35Caz0z3CpFXf7FbJbcsQGl5QoySIPVXBWiDTw4Syf13zJy/uw4WO2\n" + "gykqig+4k13VV8cppGXlzctCxvctARpBnwZgMO/OgssuDHNAsT2S9Q54bpJVC8OB\n" + "QYzCl1hF\n" + "-----END CERTIFICATE-----\n" + "\n" + "\n"; + +const size_t g_im_ca_bundle_mini_pem_len = sizeof(g_im_ca_bundle_mini_pem); diff --git a/examples/messaging/echo_bot/IM/certs/ca_bundle_mini.h b/examples/messaging/echo_bot/IM/certs/ca_bundle_mini.h new file mode 100644 index 000000000..c631efd0b --- /dev/null +++ b/examples/messaging/echo_bot/IM/certs/ca_bundle_mini.h @@ -0,0 +1,18 @@ +#ifndef __CA_BUNDLE_MINI_H__ +#define __CA_BUNDLE_MINI_H__ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +extern const uint8_t g_im_ca_bundle_mini_pem[]; +extern const size_t g_im_ca_bundle_mini_pem_len; + +#ifdef __cplusplus +} +#endif + +#endif /* __CA_BUNDLE_MINI_H__ */ diff --git a/examples/messaging/echo_bot/IM/certs/tls_cert_bundle.c b/examples/messaging/echo_bot/IM/certs/tls_cert_bundle.c new file mode 100644 index 000000000..7dfe1ac6e --- /dev/null +++ b/examples/messaging/echo_bot/IM/certs/tls_cert_bundle.c @@ -0,0 +1,256 @@ +#include "certs/tls_cert_bundle.h" + +#include + +#include "certs/ca_bundle_mini.h" +#include "iotdns.h" +#include "im_config.h" +#include "tal_api.h" + +static const char *TAG = "tls_bundle"; + +#define IM_TLS_CERT_QUERY_RETRY_COUNT 3 +#define IM_TLS_CERT_QUERY_RETRY_BASE_MS 400 +#define IM_TLS_CERT_QUERY_RETRY_MAX_MS 1600 +#define IM_TLS_CERT_FAIL_CACHE_SLOTS 8 +#define IM_TLS_CERT_FAIL_RETRY_INTERVAL_MS (5 * 60 * 1000) +#define IM_TLS_CERT_FAIL_LOG_INTERVAL_MS (60 * 1000) + +typedef struct { + char host[96]; + OPERATE_RET last_rt; + uint32_t failed_at_ms; + uint32_t last_log_ms; + bool used; +} im_tls_fail_cache_t; + +static im_tls_fail_cache_t s_fail_cache[IM_TLS_CERT_FAIL_CACHE_SLOTS] = {0}; +static uint8_t s_fail_cache_next_slot = 0; + +static void extract_host(const char *host_or_url, char *host, size_t host_size) +{ + if (!host || host_size == 0) { + return; + } + host[0] = '\0'; + if (!host_or_url || host_or_url[0] == '\0') { + return; + } + + const char *begin = host_or_url; + const char *scheme = strstr(host_or_url, "://"); + if (scheme) { + begin = scheme + 3; + } + + size_t copy = strcspn(begin, "/:"); + if (copy >= host_size) { + copy = host_size - 1; + } + memcpy(host, begin, copy); + host[copy] = '\0'; +} + +/* Hosts that use public CAs; use builtin bundle only to avoid iot-dns (h6.iot-dns.com) + * when device cannot reach Tuya DNS (e.g. firewall / no route). */ +static const char *const s_builtin_only_hosts[] = { + "api.telegram.org", + "discord.com", + "gateway.discord.gg", + "open.feishu.cn", +}; +static const size_t s_builtin_only_hosts_count = + sizeof(s_builtin_only_hosts) / sizeof(s_builtin_only_hosts[0]); + +static bool is_builtin_only_host(const char *host) +{ + if (!host || host[0] == '\0') { + return false; + } + for (size_t i = 0; i < s_builtin_only_hosts_count; i++) { + if (strcmp(host, s_builtin_only_hosts[i]) == 0) { + return true; + } + } + return false; +} + +static bool should_retry_iotdns_query(OPERATE_RET rt) +{ + return (rt == OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR || rt == OPRT_RESOURCE_NOT_READY || rt == OPRT_TIMEOUT); +} + +static uint32_t cert_query_retry_delay_ms(uint32_t attempt) +{ + uint32_t delay = IM_TLS_CERT_QUERY_RETRY_BASE_MS; + while (attempt > 0 && delay < IM_TLS_CERT_QUERY_RETRY_MAX_MS) { + if (delay > (IM_TLS_CERT_QUERY_RETRY_MAX_MS / 2)) { + delay = IM_TLS_CERT_QUERY_RETRY_MAX_MS; + break; + } + delay <<= 1; + attempt--; + } + if (delay > IM_TLS_CERT_QUERY_RETRY_MAX_MS) { + delay = IM_TLS_CERT_QUERY_RETRY_MAX_MS; + } + return delay; +} + +static im_tls_fail_cache_t *fail_cache_find(const char *host) +{ + if (!host || host[0] == '\0') { + return NULL; + } + for (uint32_t i = 0; i < IM_TLS_CERT_FAIL_CACHE_SLOTS; i++) { + if (s_fail_cache[i].used && strcmp(s_fail_cache[i].host, host) == 0) { + return &s_fail_cache[i]; + } + } + return NULL; +} + +static void fail_cache_clear(const char *host) +{ + im_tls_fail_cache_t *slot = fail_cache_find(host); + if (!slot) { + return; + } + memset(slot, 0, sizeof(*slot)); +} + +static void fail_cache_save(const char *host, OPERATE_RET rt, uint32_t now_ms) +{ + if (!host || host[0] == '\0') { + return; + } + + im_tls_fail_cache_t *slot = fail_cache_find(host); + if (!slot) { + slot = &s_fail_cache[s_fail_cache_next_slot]; + s_fail_cache_next_slot = (uint8_t)((s_fail_cache_next_slot + 1) % IM_TLS_CERT_FAIL_CACHE_SLOTS); + memset(slot, 0, sizeof(*slot)); + } + + snprintf(slot->host, sizeof(slot->host), "%s", host); + slot->last_rt = rt; + slot->failed_at_ms = now_ms; + slot->used = true; +} + +static bool fail_cache_hit(const char *host, OPERATE_RET *cached_rt, uint32_t now_ms) +{ + im_tls_fail_cache_t *slot = fail_cache_find(host); + if (!slot) { + return false; + } + + if ((uint32_t)(now_ms - slot->failed_at_ms) >= IM_TLS_CERT_FAIL_RETRY_INTERVAL_MS) { + memset(slot, 0, sizeof(*slot)); + return false; + } + + if (cached_rt) { + *cached_rt = slot->last_rt; + } + + if ((slot->last_log_ms == 0) || ((uint32_t)(now_ms - slot->last_log_ms) >= IM_TLS_CERT_FAIL_LOG_INTERVAL_MS)) { + IM_LOGD(TAG, "skip iotdns host=%s due to fail cache rt=%d age_ms=%u", host, slot->last_rt, + (unsigned)(now_ms - slot->failed_at_ms)); + slot->last_log_ms = now_ms; + } + + return true; +} + +static OPERATE_RET im_tls_load_builtin_ca_bundle(uint8_t **cacert, size_t *cacert_len) +{ + if (!cacert || !cacert_len) { + return OPRT_INVALID_PARM; + } + if (g_im_ca_bundle_mini_pem_len == 0) { + return OPRT_NOT_FOUND; + } + if (strstr((const char *)g_im_ca_bundle_mini_pem, "-----BEGIN CERTIFICATE-----") == NULL) { + return OPRT_INVALID_PARM; + } + + uint8_t *buf = im_malloc(g_im_ca_bundle_mini_pem_len); + if (!buf) { + return OPRT_MALLOC_FAILED; + } + + memcpy(buf, g_im_ca_bundle_mini_pem, g_im_ca_bundle_mini_pem_len); + *cacert = buf; + *cacert_len = g_im_ca_bundle_mini_pem_len; + return OPRT_OK; +} + +OPERATE_RET im_tls_query_domain_certs(const char *host_or_url, uint8_t **cacert, size_t *cacert_len) +{ + if (!host_or_url || !cacert || !cacert_len) { + return OPRT_INVALID_PARM; + } + + *cacert = NULL; + *cacert_len = 0; + + char host[96] = {0}; + extract_host(host_or_url, host, sizeof(host)); + uint32_t now_ms = tal_system_get_millisecond(); + + /* For well-known public hosts, use builtin CA only to avoid requesting h6.iot-dns.com + * when the device cannot reach Tuya DNS (avoids Transport/HTTPNetworkError logs). */ + if (is_builtin_only_host(host)) { + OPERATE_RET builtin_rt = im_tls_load_builtin_ca_bundle(cacert, cacert_len); + if (builtin_rt == OPRT_OK && *cacert && *cacert_len > 0) { + fail_cache_clear(host); + return OPRT_OK; + } + } + + OPERATE_RET cached_rt = OPRT_COM_ERROR; + if (fail_cache_hit(host, &cached_rt, now_ms)) { + return (cached_rt == OPRT_OK) ? OPRT_COM_ERROR : cached_rt; + } + + OPERATE_RET rt = OPRT_COM_ERROR; + for (uint32_t attempt = 0; attempt < IM_TLS_CERT_QUERY_RETRY_COUNT; attempt++) { + uint8_t *iotdns_cert = NULL; + uint16_t iotdns_cert_len = 0; + + rt = tuya_iotdns_query_domain_certs((char *)host_or_url, &iotdns_cert, &iotdns_cert_len); + if (rt == OPRT_OK && iotdns_cert && iotdns_cert_len > 0) { + *cacert = iotdns_cert; + *cacert_len = iotdns_cert_len; + fail_cache_clear(host); + return OPRT_OK; + } + + if (iotdns_cert) { + im_free(iotdns_cert); + } + + if (attempt + 1 >= IM_TLS_CERT_QUERY_RETRY_COUNT || !should_retry_iotdns_query(rt)) { + break; + } + + uint32_t delay_ms = cert_query_retry_delay_ms(attempt); + IM_LOGD(TAG, "iotdns cert query retry %u/%u host=%s rt=%d delay=%u", (unsigned)(attempt + 1), + (unsigned)IM_TLS_CERT_QUERY_RETRY_COUNT, host_or_url, rt, (unsigned)delay_ms); + tal_system_sleep(delay_ms); + } + + OPERATE_RET builtin_rt = im_tls_load_builtin_ca_bundle(cacert, cacert_len); + if (builtin_rt == OPRT_OK && *cacert && *cacert_len > 0) { + fail_cache_clear(host); + IM_LOGD(TAG, "iotdns cert unavailable host=%s rt=%d, fallback to builtin ca bundle len=%zu", host, rt, + *cacert_len); + return OPRT_OK; + } + + OPERATE_RET final_rt = (rt == OPRT_OK) ? OPRT_COM_ERROR : rt; + fail_cache_save(host, final_rt, now_ms); + IM_LOGD(TAG, "iotdns cert unavailable host=%s rt=%d, builtin_ca_rt=%d", host, rt, builtin_rt); + return final_rt; +} diff --git a/examples/messaging/echo_bot/IM/certs/tls_cert_bundle.h b/examples/messaging/echo_bot/IM/certs/tls_cert_bundle.h new file mode 100644 index 000000000..a2fdda76b --- /dev/null +++ b/examples/messaging/echo_bot/IM/certs/tls_cert_bundle.h @@ -0,0 +1,8 @@ +#ifndef __TLS_CERT_BUNDLE_H__ +#define __TLS_CERT_BUNDLE_H__ + +#include "im_platform.h" + +OPERATE_RET im_tls_query_domain_certs(const char *host_or_url, uint8_t **cacert, size_t *cacert_len); + +#endif /* __TLS_CERT_BUNDLE_H__ */ diff --git a/examples/messaging/echo_bot/IM/channels/discord_bot.c b/examples/messaging/echo_bot/IM/channels/discord_bot.c new file mode 100644 index 000000000..5dacbd5c1 --- /dev/null +++ b/examples/messaging/echo_bot/IM/channels/discord_bot.c @@ -0,0 +1,1304 @@ +#include "channels/discord_bot.h" + +#include "bus/message_bus.h" +#include "cJSON.h" +#include "im_config.h" +#include "im_utils.h" +#include "proxy/http_proxy.h" +#include "certs/tls_cert_bundle.h" +#include "tuya_transporter.h" +#include "tuya_tls.h" + +#include "mbedtls/ssl.h" +#include "tal_network.h" + +#include +#include +#include + +static const char *TAG = "discord"; + +static char s_bot_token[160] = {0}; +static char s_channel_id[32] = {0}; /* Optional default outbound channel */ +static THREAD_HANDLE s_gateway_thread = NULL; + +typedef enum { + DC_CONN_NONE = 0, + DC_CONN_PROXY, + DC_CONN_DIRECT, +} dc_conn_mode_t; + +typedef struct { + dc_conn_mode_t mode; + proxy_conn_t *proxy; + tuya_transporter_t tcp; + tuya_tls_hander tls; + int socket_fd; + uint8_t *rx_buf; + size_t rx_cap; + size_t rx_len; +} dc_gateway_conn_t; + +#define DC_HOST IM_DC_API_HOST +#define DC_HTTP_TIMEOUT_MS 10000 +#define DC_HTTP_RESP_BUF_SIZE (16 * 1024) +#define DC_PROXY_READ_SLICE_MS 1000 +#define DC_PROXY_READ_TOTAL_MS 15000 + +static OPERATE_RET dc_direct_open(dc_gateway_conn_t *conn, const char *host, int port, int timeout_ms) +{ + if (!conn || !host || port <= 0 || timeout_ms <= 0) { + return OPRT_INVALID_PARM; + } + + conn->tcp = tuya_transporter_create(TRANSPORT_TYPE_TCP, NULL); + if (!conn->tcp) { + return OPRT_COM_ERROR; + } + conn->mode = DC_CONN_DIRECT; + + tuya_tcp_config_t cfg = {0}; + cfg.isReuse = TRUE; + cfg.isDisableNagle = TRUE; + cfg.sendTimeoutMs = timeout_ms; + cfg.recvTimeoutMs = timeout_ms; + (void)tuya_transporter_ctrl(conn->tcp, TUYA_TRANSPORTER_SET_TCP_CONFIG, &cfg); + + OPERATE_RET rt = tuya_transporter_connect(conn->tcp, (char *)host, port, timeout_ms); + if (rt != OPRT_OK) { + return rt; + } + + rt = tuya_transporter_ctrl(conn->tcp, TUYA_TRANSPORTER_GET_TCP_SOCKET, &conn->socket_fd); + if (rt != OPRT_OK || conn->socket_fd < 0) { + return OPRT_SOCK_ERR; + } + + uint8_t *cacert = NULL; + size_t cacert_len = 0; + bool verify_peer = false; + + rt = im_tls_query_domain_certs(host, &cacert, &cacert_len); + if (rt == OPRT_OK && cacert && cacert_len > 0) { + verify_peer = true; + } else { + IM_LOGD(TAG, "tls cert unavailable for %s, fallback to TLS no-verify mode rt=%d", host, rt); + } + if (verify_peer && cacert_len > (size_t)INT_MAX) { + IM_LOGW(TAG, "tls cert too large for tuya_tls host=%s len=%zu, fallback to no-verify", host, cacert_len); + verify_peer = false; + } + + conn->tls = tuya_tls_connect_create(); + if (!conn->tls) { + if (cacert) { + im_free(cacert); + } + return OPRT_MALLOC_FAILED; + } + + int timeout_s = timeout_ms / 1000; + if (timeout_s <= 0) { + timeout_s = 1; + } + + tuya_tls_config_t cfg_tls = { + .mode = TUYA_TLS_SERVER_CERT_MODE, + .hostname = (char *)host, + .port = (uint16_t)port, + .timeout = (uint32_t)timeout_s, + .verify = verify_peer, + .ca_cert = verify_peer ? (char *)cacert : NULL, + .ca_cert_size = verify_peer ? (int)cacert_len : 0, + }; + (void)tuya_tls_config_set(conn->tls, &cfg_tls); + + rt = tuya_tls_connect(conn->tls, (char *)host, port, conn->socket_fd, timeout_s); + if (cacert) { + im_free(cacert); + } + if (rt != OPRT_OK) { + return rt; + } + + conn->mode = DC_CONN_DIRECT; + return OPRT_OK; +} + +static OPERATE_RET dc_conn_ensure_rx_buf(dc_gateway_conn_t *conn, size_t min_cap) +{ + if (!conn || min_cap == 0) { + return OPRT_INVALID_PARM; + } + + if (conn->rx_buf && conn->rx_cap >= min_cap) { + return OPRT_OK; + } + + uint8_t *buf = im_malloc(min_cap); + if (!buf) { + return OPRT_MALLOC_FAILED; + } + + if (conn->rx_buf) { + im_free(conn->rx_buf); + } + + conn->rx_buf = buf; + conn->rx_cap = min_cap; + conn->rx_len = 0; + return OPRT_OK; +} + +static void dc_conn_close(dc_gateway_conn_t *conn) +{ + if (!conn) { + return; + } + + if (conn->mode == DC_CONN_PROXY) { + if (conn->proxy) { + proxy_conn_close(conn->proxy); + conn->proxy = NULL; + } + } else if (conn->mode == DC_CONN_DIRECT) { + if (conn->tls) { + (void)tuya_tls_disconnect(conn->tls); + tuya_tls_connect_destroy(conn->tls); + conn->tls = NULL; + } + if (conn->tcp) { + (void)tuya_transporter_close(conn->tcp); + (void)tuya_transporter_destroy(conn->tcp); + conn->tcp = NULL; + } + conn->socket_fd = -1; + } + + conn->mode = DC_CONN_NONE; + if (conn->rx_buf) { + im_free(conn->rx_buf); + conn->rx_buf = NULL; + } + conn->rx_cap = 0; + conn->rx_len = 0; +} + +static OPERATE_RET dc_conn_open(dc_gateway_conn_t *conn, const char *host, int port, int timeout_ms) +{ + if (!conn || !host || port <= 0 || timeout_ms <= 0) { + return OPRT_INVALID_PARM; + } + + memset(conn, 0, sizeof(*conn)); + conn->socket_fd = -1; + + if (http_proxy_is_enabled()) { + conn->proxy = proxy_conn_open(host, port, timeout_ms); + if (!conn->proxy) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + conn->mode = DC_CONN_PROXY; + return OPRT_OK; + } + + OPERATE_RET rt = dc_direct_open(conn, host, port, timeout_ms); + if (rt != OPRT_OK) { + dc_conn_close(conn); + return rt; + } + + return OPRT_OK; +} + +static int dc_conn_write(dc_gateway_conn_t *conn, const uint8_t *data, int len) +{ + if (!conn || !data || len <= 0) { + return -1; + } + + if (conn->mode == DC_CONN_PROXY) { + return proxy_conn_write(conn->proxy, (const char *)data, len); + } + + if (conn->mode != DC_CONN_DIRECT || !conn->tls) { + return -1; + } + + int sent = 0; + while (sent < len) { + int n = tuya_tls_write(conn->tls, (uint8_t *)data + sent, (uint32_t)(len - sent)); + if (n <= 0) { + return -1; + } + sent += n; + } + + return sent; +} + +static int dc_conn_read(dc_gateway_conn_t *conn, uint8_t *buf, int len, int timeout_ms) +{ + if (!conn || !buf || len <= 0 || timeout_ms <= 0) { + return -1; + } + + if (conn->mode == DC_CONN_PROXY) { + return proxy_conn_read(conn->proxy, (char *)buf, len, timeout_ms); + } + + if (conn->mode != DC_CONN_DIRECT || !conn->tls || conn->socket_fd < 0) { + return -1; + } + + TUYA_FD_SET_T readfds; + tal_net_fd_zero(&readfds); + tal_net_fd_set(conn->socket_fd, &readfds); + int ready = tal_net_select(conn->socket_fd + 1, &readfds, NULL, NULL, timeout_ms); + if (ready < 0) { + return -1; + } + if (ready == 0) { + return OPRT_RESOURCE_NOT_READY; + } + + int n = tuya_tls_read(conn->tls, buf, (uint32_t)len); + if (n > 0) { + return n; + } + if (n == 0 || n == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) { + return 0; + } + if (n == OPRT_RESOURCE_NOT_READY || n == MBEDTLS_ERR_SSL_WANT_READ || n == MBEDTLS_ERR_SSL_WANT_WRITE || + n == MBEDTLS_ERR_SSL_TIMEOUT || n == -100) { + return OPRT_RESOURCE_NOT_READY; + } + + return n; +} + +static OPERATE_RET dc_ws_send_frame(dc_gateway_conn_t *conn, uint8_t opcode, const uint8_t *payload, size_t payload_len) +{ + if (!conn || (payload_len > 0 && !payload)) { + return OPRT_INVALID_PARM; + } + + size_t header_len = 0; + uint8_t header[14] = {0}; + + header[0] = (uint8_t)(0x80 | (opcode & 0x0F)); + + if (payload_len <= 125) { + header[1] = (uint8_t)(0x80 | payload_len); + header_len = 2; + } else if (payload_len <= 0xFFFF) { + header[1] = (uint8_t)(0x80 | 126); + header[2] = (uint8_t)((payload_len >> 8) & 0xFF); + header[3] = (uint8_t)(payload_len & 0xFF); + header_len = 4; + } else { + header[1] = (uint8_t)(0x80 | 127); + uint64_t plen64 = (uint64_t)payload_len; + for (int i = 0; i < 8; i++) { + header[2 + i] = (uint8_t)((plen64 >> (56 - i * 8)) & 0xFF); + } + header_len = 10; + } + + uint32_t m = (uint32_t)tal_system_get_random(0xFFFFFFFFu); + uint8_t mask[4] = { + (uint8_t)(m & 0xFF), + (uint8_t)((m >> 8) & 0xFF), + (uint8_t)((m >> 16) & 0xFF), + (uint8_t)((m >> 24) & 0xFF), + }; + + size_t frame_len = header_len + 4 + payload_len; + uint8_t *frame = im_malloc(frame_len); + if (!frame) { + return OPRT_MALLOC_FAILED; + } + + memcpy(frame, header, header_len); + memcpy(frame + header_len, mask, 4); + for (size_t i = 0; i < payload_len; i++) { + frame[header_len + 4 + i] = (uint8_t)(payload[i] ^ mask[i % 4]); + } + + int n = dc_conn_write(conn, frame, (int)frame_len); + im_free(frame); + + return (n == (int)frame_len) ? OPRT_OK : OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; +} + +static OPERATE_RET dc_ws_handshake(dc_gateway_conn_t *conn) +{ + if (!conn) { + return OPRT_INVALID_PARM; + } + + OPERATE_RET rt = dc_conn_ensure_rx_buf(conn, IM_DC_GATEWAY_RX_BUF_SIZE); + if (rt != OPRT_OK) { + return rt; + } + + const char *ws_key = "dGhlIHNhbXBsZSBub25jZQ=="; + char req[768] = {0}; + int req_len = snprintf(req, sizeof(req), + "GET " IM_DC_GATEWAY_PATH " HTTP/1.1\r\n" + "Host: " IM_DC_GATEWAY_HOST "\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: %s\r\n" + "Sec-WebSocket-Version: 13\r\n" + "User-Agent: IM/1.0\r\n\r\n", + ws_key); + if (req_len <= 0 || req_len >= (int)sizeof(req)) { + return OPRT_BUFFER_NOT_ENOUGH; + } + + if (dc_conn_write(conn, (const uint8_t *)req, req_len) != req_len) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + char header[2048] = {0}; + int total = 0; + int header_end = -1; + uint32_t start_ms = tal_system_get_millisecond(); + + while ((int)(tal_system_get_millisecond() - start_ms) < DC_HTTP_TIMEOUT_MS && total < (int)sizeof(header) - 1) { + int n = dc_conn_read(conn, (uint8_t *)header + total, (int)sizeof(header) - total - 1, 1000); + if (n == OPRT_RESOURCE_NOT_READY) { + continue; + } + if (n <= 0) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + total += n; + header[total] = '\0'; + header_end = im_find_header_end(header, total); + if (header_end > 0) { + break; + } + } + + if (header_end <= 0) { + return OPRT_TIMEOUT; + } + + uint16_t status = im_parse_http_status(header); + if (status != 101) { + IM_LOGE(TAG, "discord gateway handshake failed http=%u", status); + return OPRT_COM_ERROR; + } + + size_t remain = (size_t)(total - header_end); + conn->rx_len = 0; + if (remain > 0) { + if (remain > conn->rx_cap) { + return OPRT_BUFFER_NOT_ENOUGH; + } + memcpy(conn->rx_buf, header + header_end, remain); + conn->rx_len = remain; + } + + IM_LOGI(TAG, "discord gateway handshake success"); + return OPRT_OK; +} + +static OPERATE_RET dc_ws_decode_one_frame(dc_gateway_conn_t *conn, uint8_t *opcode, uint8_t **payload, + size_t *payload_len, size_t *consumed) +{ + if (!conn || !opcode || !payload || !payload_len || !consumed) { + return OPRT_INVALID_PARM; + } + if (!conn->rx_buf || conn->rx_cap == 0) { + return OPRT_INVALID_PARM; + } + + if (conn->rx_len < 2) { + return OPRT_RESOURCE_NOT_READY; + } + + const uint8_t *buf = conn->rx_buf; + bool masked = (buf[1] & 0x80) != 0; + uint64_t plen = (uint64_t)(buf[1] & 0x7F); + size_t off = 2; + + if (plen == 126) { + if (conn->rx_len < off + 2) { + return OPRT_RESOURCE_NOT_READY; + } + plen = (uint64_t)((buf[off] << 8) | buf[off + 1]); + off += 2; + } else if (plen == 127) { + if (conn->rx_len < off + 8) { + return OPRT_RESOURCE_NOT_READY; + } + plen = 0; + for (int i = 0; i < 8; i++) { + plen = (plen << 8) | buf[off + i]; + } + off += 8; + } + + if (masked && conn->rx_len < off + 4) { + return OPRT_RESOURCE_NOT_READY; + } + + if (conn->rx_cap <= 16 || plen > (uint64_t)(conn->rx_cap - 16)) { + return OPRT_MSG_OUT_OF_LIMIT; + } + + size_t frame_len = off + (masked ? 4 : 0) + (size_t)plen; + if (conn->rx_len < frame_len) { + return OPRT_RESOURCE_NOT_READY; + } + + uint8_t mask[4] = {0}; + if (masked) { + memcpy(mask, buf + off, 4); + off += 4; + } + + uint8_t *data = im_malloc((size_t)plen + 1); + if (!data) { + return OPRT_MALLOC_FAILED; + } + + if (plen > 0) { + memcpy(data, buf + off, (size_t)plen); + if (masked) { + for (size_t i = 0; i < (size_t)plen; i++) { + data[i] = (uint8_t)(data[i] ^ mask[i % 4]); + } + } + } + data[plen] = '\0'; + + *opcode = (uint8_t)(buf[0] & 0x0F); + *payload = data; + *payload_len = (size_t)plen; + *consumed = frame_len; + + return OPRT_OK; +} + +static void dc_ws_consume_rx(dc_gateway_conn_t *conn, size_t consumed) +{ + if (!conn || consumed == 0 || consumed > conn->rx_len) { + return; + } + + if (consumed < conn->rx_len) { + memmove(conn->rx_buf, conn->rx_buf + consumed, conn->rx_len - consumed); + } + conn->rx_len -= consumed; +} + +static OPERATE_RET dc_ws_poll_frame(dc_gateway_conn_t *conn, int wait_ms, uint8_t *opcode, uint8_t **payload, + size_t *payload_len) +{ + if (!conn || !opcode || !payload || !payload_len) { + return OPRT_INVALID_PARM; + } + + OPERATE_RET ensure_rt = dc_conn_ensure_rx_buf(conn, IM_DC_GATEWAY_RX_BUF_SIZE); + if (ensure_rt != OPRT_OK) { + return ensure_rt; + } + + *payload = NULL; + *payload_len = 0; + + size_t consumed = 0; + OPERATE_RET rt = dc_ws_decode_one_frame(conn, opcode, payload, payload_len, &consumed); + if (rt == OPRT_OK) { + dc_ws_consume_rx(conn, consumed); + return OPRT_OK; + } + if (rt != OPRT_RESOURCE_NOT_READY) { + return rt; + } + + uint8_t tmp[1024] = {0}; + int n = dc_conn_read(conn, tmp, sizeof(tmp), wait_ms); + if (n == OPRT_RESOURCE_NOT_READY) { + return OPRT_RESOURCE_NOT_READY; + } + if (n <= 0) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + if (conn->rx_len + (size_t)n > conn->rx_cap) { + return OPRT_BUFFER_NOT_ENOUGH; + } + + memcpy(conn->rx_buf + conn->rx_len, tmp, (size_t)n); + conn->rx_len += (size_t)n; + + consumed = 0; + rt = dc_ws_decode_one_frame(conn, opcode, payload, payload_len, &consumed); + if (rt == OPRT_OK) { + dc_ws_consume_rx(conn, consumed); + } + return rt; +} + +static OPERATE_RET dc_gateway_send_identify(dc_gateway_conn_t *conn) +{ + /* Discord IDENTIFY frame payload includes "op":2. */ + char payload[512] = {0}; + int n = snprintf(payload, sizeof(payload), + "{\"op\":2,\"d\":{\"token\":\"%s\",\"intents\":%u,\"properties\":{\"os\":\"tuyaopen\",\"browser\":" + "\"im_bot\",\"device\":\"im_bot\"}}}", + s_bot_token, (unsigned)IM_DC_GATEWAY_INTENTS); + if (n <= 0 || n >= (int)sizeof(payload)) { + return OPRT_BUFFER_NOT_ENOUGH; + } + + IM_LOGI(TAG, "discord gateway identify sent intents=%u", (unsigned)IM_DC_GATEWAY_INTENTS); + return dc_ws_send_frame(conn, 0x1, (const uint8_t *)payload, (size_t)n); +} + +static OPERATE_RET dc_gateway_send_heartbeat(dc_gateway_conn_t *conn, int64_t seq) +{ + char payload[96] = {0}; + int n = 0; + if (seq >= 0) { + n = snprintf(payload, sizeof(payload), "{\"op\":1,\"d\":%lld}", (long long)seq); + } else { + n = snprintf(payload, sizeof(payload), "{\"op\":1,\"d\":null}"); + } + + if (n <= 0 || n >= (int)sizeof(payload)) { + return OPRT_BUFFER_NOT_ENOUGH; + } + + return dc_ws_send_frame(conn, 0x1, (const uint8_t *)payload, (size_t)n); +} + +static void publish_inbound_discord(const char *channel_id, const char *content) +{ + if (!channel_id || !content || channel_id[0] == '\0') { + return; + } + + im_msg_t in = {0}; + strncpy(in.channel, IM_CHAN_DISCORD, sizeof(in.channel) - 1); + strncpy(in.chat_id, channel_id, sizeof(in.chat_id) - 1); + in.content = im_strdup(content); + if (!in.content) { + return; + } + + OPERATE_RET rt = message_bus_push_inbound(&in); + if (rt != OPRT_OK) { + IM_LOGW(TAG, "push inbound failed rt=%d", rt); + im_free(in.content); + } +} + +static void handle_message_create_event(cJSON *event) +{ + if (!cJSON_IsObject(event)) { + return; + } + + cJSON *author = cJSON_GetObjectItem(event, "author"); + cJSON *bot = author ? cJSON_GetObjectItem(author, "bot") : NULL; + if (cJSON_IsTrue(bot)) { + return; + } + + const char *channel_id = cJSON_GetStringValue(cJSON_GetObjectItem(event, "channel_id")); + const char *message_id = cJSON_GetStringValue(cJSON_GetObjectItem(event, "id")); + const char *content = cJSON_GetStringValue(cJSON_GetObjectItem(event, "content")); + cJSON *attachments = cJSON_GetObjectItem(event, "attachments"); + bool has_attachments = cJSON_IsArray(attachments) && cJSON_GetArraySize(attachments) > 0; + + if (!channel_id || channel_id[0] == '\0') { + return; + } + + if (content && content[0] != '\0') { + IM_LOGI(TAG, "rx inbound_text channel=%s chat=%s len=%u", IM_CHAN_DISCORD, channel_id, + (unsigned)strlen(content)); + } + + if (has_attachments) { + cJSON *first = cJSON_GetArrayItem(attachments, 0); + const char *file_name = im_json_str(first, "filename", ""); + const char *mime_type = im_json_str(first, "content_type", ""); + uint32_t file_size = im_json_uint(first, "size", 0); + IM_LOGI(TAG, "rx attachment chat=%s message_id=%s name=%s mime=%s size=%u", channel_id, + message_id ? message_id : "", file_name, mime_type, (unsigned)file_size); + } + + if (!content || content[0] == '\0') { + if (has_attachments) { + content = "[non-text message]"; + } else { + return; + } + } + + im_safe_copy(s_channel_id, sizeof(s_channel_id), channel_id); + publish_inbound_discord(channel_id, content); +} + +static OPERATE_RET handle_gateway_payload(dc_gateway_conn_t *conn, const char *json_str, int64_t *seq, + uint32_t *heartbeat_ms, uint32_t *next_heartbeat_ms) +{ + if (!conn || !json_str || !seq || !heartbeat_ms || !next_heartbeat_ms) { + return OPRT_INVALID_PARM; + } + + cJSON *root = cJSON_Parse(json_str); + if (!root) { + return OPRT_CR_CJSON_ERR; + } + + cJSON *op_item = cJSON_GetObjectItem(root, "op"); + int op = cJSON_IsNumber(op_item) ? (int)op_item->valuedouble : -1; + + cJSON *seq_item = cJSON_GetObjectItem(root, "s"); + if (cJSON_IsNumber(seq_item)) { + *seq = (int64_t)seq_item->valuedouble; + } + + OPERATE_RET rt = OPRT_OK; + + if (op == 10) { + cJSON *d = cJSON_GetObjectItem(root, "d"); + cJSON *hb = d ? cJSON_GetObjectItem(d, "heartbeat_interval") : NULL; + if (cJSON_IsNumber(hb) && hb->valuedouble > 0) { + *heartbeat_ms = (uint32_t)hb->valuedouble; + if (*heartbeat_ms < 1000) { + *heartbeat_ms = 1000; + } + *next_heartbeat_ms = tal_system_get_millisecond() + *heartbeat_ms; + IM_LOGI(TAG, "discord gateway HELLO heartbeat=%u ms", (unsigned)*heartbeat_ms); + } + + rt = dc_gateway_send_identify(conn); + } else if (op == 11) { + IM_LOGD(TAG, "discord gateway HEARTBEAT_ACK"); + } else if (op == 1) { + rt = dc_gateway_send_heartbeat(conn, *seq); + if (rt == OPRT_OK && *heartbeat_ms > 0) { + *next_heartbeat_ms = tal_system_get_millisecond() + *heartbeat_ms; + } + } else if (op == 7 || op == 9) { + IM_LOGW(TAG, "discord gateway requested reconnect op=%d", op); + rt = OPRT_COM_ERROR; + } else if (op == 0) { + const char *event_type = cJSON_GetStringValue(cJSON_GetObjectItem(root, "t")); + cJSON *d = cJSON_GetObjectItem(root, "d"); + + if (event_type && strcmp(event_type, "READY") == 0) { + const char *uid = NULL; + const char *uname = NULL; + cJSON *user = d ? cJSON_GetObjectItem(d, "user") : NULL; + if (user) { + uid = cJSON_GetStringValue(cJSON_GetObjectItem(user, "id")); + uname = cJSON_GetStringValue(cJSON_GetObjectItem(user, "username")); + } + IM_LOGI(TAG, "discord gateway READY user=%s(%s)", uname ? uname : "", uid ? uid : ""); + } else if (event_type && strcmp(event_type, "MESSAGE_CREATE") == 0) { + handle_message_create_event(d); + } + } + + cJSON_Delete(root); + return rt; +} + +static void discord_gateway_task(void *arg) +{ + (void)arg; + + IM_LOGI(TAG, "discord gateway task started"); + + while (1) { + if (s_bot_token[0] == '\0') { + tal_system_sleep(3000); + continue; + } + + dc_gateway_conn_t *conn = im_calloc(1, sizeof(dc_gateway_conn_t)); + if (!conn) { + tal_system_sleep(IM_DC_GATEWAY_RECONNECT_MS); + continue; + } + + OPERATE_RET rt = dc_conn_open(conn, IM_DC_GATEWAY_HOST, 443, DC_HTTP_TIMEOUT_MS); + if (rt != OPRT_OK) { + IM_LOGW(TAG, "discord gateway connect failed rt=%d", rt); + im_free(conn); + tal_system_sleep(IM_DC_GATEWAY_RECONNECT_MS); + continue; + } + + rt = dc_ws_handshake(conn); + if (rt != OPRT_OK) { + IM_LOGW(TAG, "discord gateway handshake failed rt=%d", rt); + dc_conn_close(conn); + im_free(conn); + tal_system_sleep(IM_DC_GATEWAY_RECONNECT_MS); + continue; + } + + int64_t seq = -1; + uint32_t heartbeat_ms = 0; + uint32_t next_heartbeat_ms = 0; + + while (1) { + if (heartbeat_ms > 0) { + uint32_t now = tal_system_get_millisecond(); + if ((int32_t)(now - next_heartbeat_ms) >= 0) { + OPERATE_RET hb_rt = dc_gateway_send_heartbeat(conn, seq); + if (hb_rt != OPRT_OK) { + IM_LOGW(TAG, "discord gateway heartbeat failed rt=%d", hb_rt); + break; + } + next_heartbeat_ms = now + heartbeat_ms; + } + } + + uint8_t opcode = 0; + uint8_t *payload = NULL; + size_t payload_len = 0; + rt = dc_ws_poll_frame(conn, 500, &opcode, &payload, &payload_len); + if (rt == OPRT_RESOURCE_NOT_READY) { + continue; + } + if (rt != OPRT_OK) { + im_free(payload); + IM_LOGW(TAG, "discord gateway poll failed rt=%d", rt); + break; + } + + if (opcode == 0x1) { + if (payload && payload_len > 0) { + OPERATE_RET hrt = + handle_gateway_payload(conn, (const char *)payload, &seq, &heartbeat_ms, &next_heartbeat_ms); + if (hrt != OPRT_OK) { + im_free(payload); + break; + } + } + } else if (opcode == 0x8) { + int close_code = -1; + const char *close_reason = ""; + if (payload && payload_len >= 2) { + close_code = ((int)payload[0] << 8) | (int)payload[1]; + if (payload_len > 2) { + close_reason = (const char *)(payload + 2); + } + } + im_free(payload); + IM_LOGW(TAG, "discord gateway closed by peer code=%d reason=%.120s", close_code, close_reason); + break; + } else if (opcode == 0x9) { + (void)dc_ws_send_frame(conn, 0xA, payload, payload_len); + } + + im_free(payload); + } + + dc_conn_close(conn); + im_free(conn); + tal_system_sleep(IM_DC_GATEWAY_RECONNECT_MS); + } +} + +static OPERATE_RET dc_http_call_via_proxy(const char *path, const char *method, const char *post_data, char *resp_buf, + size_t resp_buf_size, uint16_t *status_code) +{ + proxy_conn_t *conn = proxy_conn_open(DC_HOST, 443, DC_HTTP_TIMEOUT_MS); + if (!conn) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + int body_len = post_data ? (int)strlen(post_data) : 0; + char req_header[1024] = {0}; + int req_len; + if (post_data) { + req_len = snprintf(req_header, sizeof(req_header), + "%s %s HTTP/1.1\r\n" + "Host: %s\r\n" + "Authorization: Bot %s\r\n" + "User-Agent: IM/1.0\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %d\r\n" + "Connection: close\r\n\r\n", + method, path, DC_HOST, s_bot_token, body_len); + } else { + req_len = snprintf(req_header, sizeof(req_header), + "%s %s HTTP/1.1\r\n" + "Host: %s\r\n" + "Authorization: Bot %s\r\n" + "User-Agent: IM/1.0\r\n" + "Connection: close\r\n\r\n", + method, path, DC_HOST, s_bot_token); + } + + if (req_len <= 0 || req_len >= (int)sizeof(req_header)) { + proxy_conn_close(conn); + return OPRT_BUFFER_NOT_ENOUGH; + } + + if (proxy_conn_write(conn, req_header, req_len) != req_len) { + proxy_conn_close(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + if (body_len > 0 && proxy_conn_write(conn, post_data, body_len) != body_len) { + proxy_conn_close(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + size_t raw_cap = 4096; + size_t raw_len = 0; + char *raw = im_calloc(1, raw_cap); + if (!raw) { + proxy_conn_close(conn); + return OPRT_MALLOC_FAILED; + } + + uint32_t wait_begin_ms = tal_system_get_millisecond(); + while (1) { + if (raw_len + 1024 >= raw_cap) { + size_t new_cap = raw_cap * 2; + char *tmp = im_realloc(raw, new_cap); + if (!tmp) { + im_free(raw); + proxy_conn_close(conn); + return OPRT_MALLOC_FAILED; + } + raw = tmp; + raw_cap = new_cap; + } + + int n = proxy_conn_read(conn, raw + raw_len, (int)(raw_cap - raw_len - 1), DC_PROXY_READ_SLICE_MS); + if (n == OPRT_RESOURCE_NOT_READY) { + if ((int)(tal_system_get_millisecond() - wait_begin_ms) >= DC_PROXY_READ_TOTAL_MS) { + im_free(raw); + proxy_conn_close(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + continue; + } + if (n < 0) { + if (raw_len > 0) { + break; + } + im_free(raw); + proxy_conn_close(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + if (n == 0) { + break; + } + + raw_len += (size_t)n; + raw[raw_len] = '\0'; + wait_begin_ms = tal_system_get_millisecond(); + } + proxy_conn_close(conn); + + if (raw_len == 0) { + im_free(raw); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + if (status_code) { + *status_code = im_parse_http_status(raw); + } + + resp_buf[0] = '\0'; + char *body = strstr(raw, "\r\n\r\n"); + if (body) { + body += 4; + size_t body_len_sz = strlen(body); + size_t copy = (body_len_sz < resp_buf_size - 1) ? body_len_sz : (resp_buf_size - 1); + memcpy(resp_buf, body, copy); + resp_buf[copy] = '\0'; + } + im_free(raw); + + return OPRT_OK; +} + +static OPERATE_RET dc_http_call_direct(const char *path, const char *method, const char *post_data, char *resp_buf, + size_t resp_buf_size, uint16_t *status_code) +{ + dc_gateway_conn_t *conn = im_calloc(1, sizeof(dc_gateway_conn_t)); + if (!conn) { + return OPRT_MALLOC_FAILED; + } + + OPERATE_RET rt = dc_direct_open(conn, DC_HOST, 443, DC_HTTP_TIMEOUT_MS); + if (rt != OPRT_OK) { + dc_conn_close(conn); + im_free(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + int body_len = post_data ? (int)strlen(post_data) : 0; + char req_header[1024] = {0}; + int req_len; + if (post_data) { + req_len = snprintf(req_header, sizeof(req_header), + "%s %s HTTP/1.1\r\n" + "Host: %s\r\n" + "Authorization: Bot %s\r\n" + "User-Agent: IM/1.0\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %d\r\n" + "Connection: close\r\n\r\n", + method, path, DC_HOST, s_bot_token, body_len); + } else { + req_len = snprintf(req_header, sizeof(req_header), + "%s %s HTTP/1.1\r\n" + "Host: %s\r\n" + "Authorization: Bot %s\r\n" + "User-Agent: IM/1.0\r\n" + "Connection: close\r\n\r\n", + method, path, DC_HOST, s_bot_token); + } + if (req_len <= 0 || req_len >= (int)sizeof(req_header)) { + dc_conn_close(conn); + im_free(conn); + return OPRT_BUFFER_NOT_ENOUGH; + } + + if (dc_conn_write(conn, (const uint8_t *)req_header, req_len) != req_len) { + dc_conn_close(conn); + im_free(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + if (body_len > 0 && dc_conn_write(conn, (const uint8_t *)post_data, body_len) != body_len) { + dc_conn_close(conn); + im_free(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + size_t raw_cap = 4096; + size_t raw_len = 0; + char *raw = im_calloc(1, raw_cap); + if (!raw) { + dc_conn_close(conn); + im_free(conn); + return OPRT_MALLOC_FAILED; + } + + uint32_t wait_begin_ms = tal_system_get_millisecond(); + while (1) { + if (raw_len + 1024 >= raw_cap) { + size_t new_cap = raw_cap * 2; + char *tmp = im_realloc(raw, new_cap); + if (!tmp) { + im_free(raw); + dc_conn_close(conn); + im_free(conn); + return OPRT_MALLOC_FAILED; + } + raw = tmp; + raw_cap = new_cap; + } + + int n = dc_conn_read(conn, (uint8_t *)raw + raw_len, (int)(raw_cap - raw_len - 1), DC_PROXY_READ_SLICE_MS); + if (n == OPRT_RESOURCE_NOT_READY) { + if ((int)(tal_system_get_millisecond() - wait_begin_ms) >= DC_PROXY_READ_TOTAL_MS) { + im_free(raw); + dc_conn_close(conn); + im_free(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + continue; + } + if (n < 0) { + if (raw_len > 0) { + break; + } + im_free(raw); + dc_conn_close(conn); + im_free(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + if (n == 0) { + break; + } + + raw_len += (size_t)n; + raw[raw_len] = '\0'; + wait_begin_ms = tal_system_get_millisecond(); + } + dc_conn_close(conn); + im_free(conn); + + if (raw_len == 0) { + im_free(raw); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + if (status_code) { + *status_code = im_parse_http_status(raw); + } + + resp_buf[0] = '\0'; + char *body = strstr(raw, "\r\n\r\n"); + if (body) { + body += 4; + size_t body_len_sz = strlen(body); + size_t copy = (body_len_sz < resp_buf_size - 1) ? body_len_sz : (resp_buf_size - 1); + memcpy(resp_buf, body, copy); + resp_buf[copy] = '\0'; + } + + im_free(raw); + return OPRT_OK; +} + +static OPERATE_RET dc_http_call(const char *path, const char *method, const char *post_data, char *resp_buf, + size_t resp_buf_size, uint16_t *status_code) +{ + if (!path || !method || !resp_buf || resp_buf_size == 0) { + return OPRT_INVALID_PARM; + } + if (s_bot_token[0] == '\0') { + return OPRT_NOT_FOUND; + } + + if (http_proxy_is_enabled()) { + return dc_http_call_via_proxy(path, method, post_data, resp_buf, resp_buf_size, status_code); + } + + return dc_http_call_direct(path, method, post_data, resp_buf, resp_buf_size, status_code); +} + +static uint32_t parse_retry_after_ms(const char *json_str) +{ + if (!json_str || json_str[0] == '\0') { + return 0; + } + + cJSON *root = cJSON_Parse(json_str); + if (!root) { + return 0; + } + + uint32_t retry_ms = 0; + cJSON *retry_after = cJSON_GetObjectItem(root, "retry_after"); + if (cJSON_IsNumber(retry_after) && retry_after->valuedouble > 0) { + double value = retry_after->valuedouble; + if (value < 1000.0) { + value *= 1000.0; + } + if (value > 120000.0) { + value = 120000.0; + } + retry_ms = (uint32_t)value; + } + + cJSON_Delete(root); + return retry_ms; +} + +static void parse_message_id(const char *json_str, char *msg_id, size_t msg_id_size) +{ + if (!msg_id || msg_id_size == 0) { + return; + } + msg_id[0] = '\0'; + if (!json_str || json_str[0] == '\0') { + return; + } + + cJSON *root = cJSON_Parse(json_str); + if (!root) { + return; + } + + const char *id = cJSON_GetStringValue(cJSON_GetObjectItem(root, "id")); + if (id && id[0] != '\0') { + im_safe_copy(msg_id, msg_id_size, id); + } + cJSON_Delete(root); +} + +OPERATE_RET discord_bot_init(void) +{ + if (IM_SECRET_DC_TOKEN[0] != '\0') { + im_safe_copy(s_bot_token, sizeof(s_bot_token), IM_SECRET_DC_TOKEN); + } + if (IM_SECRET_DC_CHANNEL_ID[0] != '\0') { + im_safe_copy(s_channel_id, sizeof(s_channel_id), IM_SECRET_DC_CHANNEL_ID); + } + + char tmp[160] = {0}; + if (im_kv_get_string(IM_NVS_DC, IM_NVS_KEY_DC_TOKEN, tmp, sizeof(tmp)) == OPRT_OK && tmp[0] != '\0') { + im_safe_copy(s_bot_token, sizeof(s_bot_token), tmp); + } + + memset(tmp, 0, sizeof(tmp)); + if (im_kv_get_string(IM_NVS_DC, IM_NVS_KEY_DC_CHANNEL_ID, tmp, sizeof(tmp)) == OPRT_OK && tmp[0] != '\0') { + im_safe_copy(s_channel_id, sizeof(s_channel_id), tmp); + } + + IM_LOGI(TAG, "discord init credential=%s default_channel=%s", s_bot_token[0] ? "configured" : "empty", + s_channel_id[0] ? "configured" : "empty"); + return OPRT_OK; +} + +OPERATE_RET discord_bot_start(void) +{ + if (s_bot_token[0] == '\0') { + return OPRT_NOT_FOUND; + } + + if (s_gateway_thread) { + return OPRT_OK; + } + + THREAD_CFG_T cfg = {0}; + cfg.stackDepth = IM_DC_POLL_STACK; + cfg.priority = THREAD_PRIO_1; + cfg.thrdname = "im_dc_gw"; + + OPERATE_RET rt = tal_thread_create_and_start(&s_gateway_thread, NULL, NULL, discord_gateway_task, NULL, &cfg); + if (rt != OPRT_OK) { + IM_LOGE(TAG, "create discord gateway thread failed: %d", rt); + return rt; + } + + return OPRT_OK; +} + +OPERATE_RET discord_send_message(const char *channel_id, const char *text) +{ + if (!text) { + return OPRT_INVALID_PARM; + } + if (s_bot_token[0] == '\0') { + return OPRT_NOT_FOUND; + } + + const char *target_channel = channel_id; + if (!target_channel || target_channel[0] == '\0') { + target_channel = s_channel_id; + } + if (!target_channel || target_channel[0] == '\0') { + return OPRT_NOT_FOUND; + } + + size_t text_len = strlen(text); + size_t offset = 0; + bool all_ok = true; + + while (offset < text_len || (text_len == 0 && offset == 0)) { + size_t chunk = text_len - offset; + if (chunk > IM_DC_MAX_MSG_LEN) { + chunk = IM_DC_MAX_MSG_LEN; + } + if (text_len == 0) { + chunk = 0; + } + + char *segment = im_calloc(1, chunk + 1); + if (!segment) { + return OPRT_MALLOC_FAILED; + } + if (chunk > 0) { + memcpy(segment, text + offset, chunk); + } + segment[chunk] = '\0'; + + cJSON *body = cJSON_CreateObject(); + if (!body) { + im_free(segment); + return OPRT_MALLOC_FAILED; + } + cJSON_AddStringToObject(body, "content", segment); + char *json = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!json) { + im_free(segment); + return OPRT_MALLOC_FAILED; + } + + char path[320] = {0}; + snprintf(path, sizeof(path), IM_DC_API_BASE "/channels/%s/messages", target_channel); + + char *resp = im_malloc(DC_HTTP_RESP_BUF_SIZE); + if (!resp) { + cJSON_free(json); + im_free(segment); + return OPRT_MALLOC_FAILED; + } + memset(resp, 0, DC_HTTP_RESP_BUF_SIZE); + + uint16_t status = 0; + OPERATE_RET rt = dc_http_call(path, "POST", json, resp, DC_HTTP_RESP_BUF_SIZE, &status); + if (rt == OPRT_OK && (status == 200 || status == 201)) { + char msg_id[40] = {0}; + parse_message_id(resp, msg_id, sizeof(msg_id)); + IM_LOGI(TAG, "discord send success channel=%s bytes=%u message_id=%s", target_channel, (unsigned)chunk, + msg_id[0] ? msg_id : "unknown"); + } else if (status == 429) { + uint32_t retry_ms = parse_retry_after_ms(resp); + if (retry_ms == 0) { + retry_ms = 2000; + } + IM_LOGW(TAG, "discord send rate limited, retry in %u ms", (unsigned)retry_ms); + tal_system_sleep(retry_ms); + memset(resp, 0, DC_HTTP_RESP_BUF_SIZE); + status = 0; + rt = dc_http_call(path, "POST", json, resp, DC_HTTP_RESP_BUF_SIZE, &status); + if (rt == OPRT_OK && (status == 200 || status == 201)) { + char msg_id[40] = {0}; + parse_message_id(resp, msg_id, sizeof(msg_id)); + IM_LOGI(TAG, "discord send success channel=%s bytes=%u message_id=%s", target_channel, + (unsigned)chunk, msg_id[0] ? msg_id : "unknown"); + } else { + all_ok = false; + IM_LOGE(TAG, "discord send failed channel=%s rt=%d http=%u", target_channel, rt, status); + } + } else { + all_ok = false; + IM_LOGE(TAG, "discord send failed channel=%s rt=%d http=%u", target_channel, rt, status); + } + + im_free(resp); + cJSON_free(json); + im_free(segment); + if (text_len == 0) { + break; + } + offset += chunk; + } + + return all_ok ? OPRT_OK : OPRT_COM_ERROR; +} + +OPERATE_RET discord_set_token(const char *token) +{ + if (!token) { + return OPRT_INVALID_PARM; + } + + im_safe_copy(s_bot_token, sizeof(s_bot_token), token); + return im_kv_set_string(IM_NVS_DC, IM_NVS_KEY_DC_TOKEN, token); +} + +OPERATE_RET discord_set_channel_id(const char *channel_id) +{ + if (!channel_id) { + return OPRT_INVALID_PARM; + } + + im_safe_copy(s_channel_id, sizeof(s_channel_id), channel_id); + return im_kv_set_string(IM_NVS_DC, IM_NVS_KEY_DC_CHANNEL_ID, channel_id); +} diff --git a/examples/messaging/echo_bot/IM/channels/discord_bot.h b/examples/messaging/echo_bot/IM/channels/discord_bot.h new file mode 100644 index 000000000..6dc89d974 --- /dev/null +++ b/examples/messaging/echo_bot/IM/channels/discord_bot.h @@ -0,0 +1,12 @@ +#ifndef __DISCORD_BOT_H__ +#define __DISCORD_BOT_H__ + +#include "im_platform.h" + +OPERATE_RET discord_bot_init(void); +OPERATE_RET discord_bot_start(void); +OPERATE_RET discord_send_message(const char *channel_id, const char *text); +OPERATE_RET discord_set_token(const char *token); +OPERATE_RET discord_set_channel_id(const char *channel_id); + +#endif /* __DISCORD_BOT_H__ */ diff --git a/examples/messaging/echo_bot/IM/channels/feishu_bot.c b/examples/messaging/echo_bot/IM/channels/feishu_bot.c new file mode 100644 index 000000000..44db047ab --- /dev/null +++ b/examples/messaging/echo_bot/IM/channels/feishu_bot.c @@ -0,0 +1,2673 @@ +#include "channels/feishu_bot.h" + +#include "bus/message_bus.h" +#include "cJSON.h" +#include "http_client_interface.h" +#include "im_config.h" +#include "im_utils.h" +#include "proxy/http_proxy.h" +#include "certs/tls_cert_bundle.h" +#include "tuya_tls.h" +#include "tuya_transporter.h" + +#include "mbedtls/ssl.h" + +#include +#include +#include + +static const char *TAG = "feishu"; + +static char s_app_id[96] = {0}; +static char s_app_secret[160] = {0}; +static char s_allow_from[512] = {0}; +static char s_tenant_token[512] = {0}; +static uint32_t s_tenant_expire_ms = 0; +static uint8_t *s_fs_cacert = NULL; +static size_t s_fs_cacert_len = 0; +static THREAD_HANDLE s_ws_thread = NULL; + +#define FS_HOST IM_FS_API_HOST +#define FS_HTTP_TIMEOUT_MS 10000 +#define FS_HTTP_RESP_BUF_SIZE (16 * 1024) +#define FS_TOKEN_SAFETY_MARGIN_S 60 +#define FS_WS_RX_BUF_SIZE (64 * 1024) +#define FS_WS_DEFAULT_RECONNECT_MS 5000 +#define FS_WS_DEFAULT_PING_MS (120 * 1000) +/* Poll wait (ms) for receiving frames; smaller = lower latency, too small = more CPU spin */ +#define FS_WS_POLL_WAIT_MS 150 +#define FS_WS_FRAME_MAX_HEADERS 32 +#define FS_WS_FRAME_MAX_KEY 64 +#define FS_WS_FRAME_MAX_VALUE 256 +#define FS_DEDUP_CACHE_SIZE 512 +#define FS_MAX_FRAG_PARTS 8 +#ifndef IM_FS_POLL_STACK +#define IM_FS_POLL_STACK (16 * 1024) +#endif + +/* -------- allow_from -------- */ + +static char *trim_ws(char *s) +{ + if (!s) { + return s; + } + while (*s && isspace((unsigned char)*s)) { + s++; + } + + char *end = s + strlen(s); + while (end > s && isspace((unsigned char)*(end - 1))) { + *(--end) = '\0'; + } + + return s; +} + +static char *strip_optional_quotes(char *s) +{ + if (!s) { + return s; + } + + size_t len = strlen(s); + if (len >= 2) { + char first = s[0]; + char last = s[len - 1]; + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + s[len - 1] = '\0'; + return s + 1; + } + } + + return s; +} + +static bool sender_allowed_token(const char *allow_id, const char *sender_ids) +{ + if (!allow_id || allow_id[0] == '\0' || !sender_ids || sender_ids[0] == '\0') { + return false; + } + + if (strcmp(allow_id, sender_ids) == 0) { + return true; + } + + char sender_csv[384] = {0}; + im_safe_copy(sender_csv, sizeof(sender_csv), sender_ids); + + char *saveptr = NULL; + char *tok = strtok_r(sender_csv, "|", &saveptr); + while (tok) { + char *id = strip_optional_quotes(trim_ws(tok)); + if (id[0] != '\0' && strcmp(id, allow_id) == 0) { + return true; + } + tok = strtok_r(NULL, "|", &saveptr); + } + + return false; +} + +static bool sender_allowed(const char *sender_ids) +{ + if (!sender_ids || sender_ids[0] == '\0') { + return false; + } + + if (s_allow_from[0] == '\0') { + return true; + } + + char csv[sizeof(s_allow_from)] = {0}; + im_safe_copy(csv, sizeof(csv), s_allow_from); + + char *saveptr = NULL; + char *tok = strtok_r(csv, ",", &saveptr); + while (tok) { + char *id = strip_optional_quotes(trim_ws(tok)); + if (id[0] != '\0' && sender_allowed_token(id, sender_ids)) { + return true; + } + tok = strtok_r(NULL, ",", &saveptr); + } + + return false; +} + +static void append_sender_id(char *sender_ids, size_t sender_ids_size, const char *id) +{ + if (!sender_ids || sender_ids_size == 0 || !id || id[0] == '\0') { + return; + } + + size_t used = strlen(sender_ids); + if (used >= sender_ids_size - 1) { + return; + } + + int n = snprintf(sender_ids + used, sender_ids_size - used, "%s%s", used ? "|" : "", id); + if (n < 0) { + sender_ids[used] = '\0'; + } +} + +/* -------- dedup ring buffer -------- */ + +typedef struct { + uint64_t keys[FS_DEDUP_CACHE_SIZE]; + size_t idx; +} dedup_ring_t; + +static dedup_ring_t s_seen_msg = {0}; +static dedup_ring_t s_seen_event = {0}; + +static bool dedup_contains(const dedup_ring_t *ring, uint64_t key) +{ + for (size_t i = 0; i < FS_DEDUP_CACHE_SIZE; i++) { + if (ring->keys[i] == key) { + return true; + } + } + return false; +} + +static void dedup_insert(dedup_ring_t *ring, uint64_t key) +{ + ring->keys[ring->idx] = key; + ring->idx = (ring->idx + 1) % FS_DEDUP_CACHE_SIZE; +} + +/* -------- TLS + HTTP -------- */ + +static OPERATE_RET ensure_fs_cert(const char *host) +{ + if (!host || host[0] == '\0') { + return OPRT_INVALID_PARM; + } + if (strcmp(host, FS_HOST) != 0) { + return OPRT_OK; + } + if (s_fs_cacert && s_fs_cacert_len > 0) { + return OPRT_OK; + } + + im_free(s_fs_cacert); + s_fs_cacert = NULL; + s_fs_cacert_len = 0; + + OPERATE_RET rt = im_tls_query_domain_certs(host, &s_fs_cacert, &s_fs_cacert_len); + if (rt != OPRT_OK || !s_fs_cacert || s_fs_cacert_len == 0) { + im_free(s_fs_cacert); + s_fs_cacert = NULL; + s_fs_cacert_len = 0; + IM_LOGW(TAG, "cert unavailable for %s, fallback to TLS no-verify mode rt=%d", host, rt); + } + return OPRT_OK; +} + +static OPERATE_RET fs_http_call_via_proxy(const char *host, const char *path, const char *method, const char *body, + const char *bearer_token, char *resp_buf, size_t resp_buf_size, + uint16_t *status_code) +{ + proxy_conn_t *conn = proxy_conn_open(host, 443, FS_HTTP_TIMEOUT_MS); + if (!conn) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + int body_len = body ? (int)strlen(body) : 0; + char auth_line[700] = {0}; + if (bearer_token && bearer_token[0]) { + snprintf(auth_line, sizeof(auth_line), "Authorization: Bearer %s\r\n", bearer_token); + } + + char req_header[1400] = {0}; + int req_len = snprintf(req_header, sizeof(req_header), + "%s %s HTTP/1.1\r\n" + "Host: %s\r\n" + "User-Agent: IM/1.0\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %d\r\n" + "%s" + "Connection: close\r\n" + "\r\n", + method, path, host, body_len, auth_line); + + if (req_len <= 0 || req_len >= (int)sizeof(req_header)) { + proxy_conn_close(conn); + return OPRT_BUFFER_NOT_ENOUGH; + } + + if (proxy_conn_write(conn, req_header, req_len) != req_len) { + proxy_conn_close(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + if (body_len > 0 && proxy_conn_write(conn, body, body_len) != body_len) { + proxy_conn_close(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + size_t raw_cap = 4096; + size_t raw_len = 0; + char *raw = im_calloc(1, raw_cap); + if (!raw) { + proxy_conn_close(conn); + return OPRT_MALLOC_FAILED; + } + + uint32_t wait_begin_ms = tal_system_get_millisecond(); + while (1) { + if (raw_len + 1024 >= raw_cap) { + size_t new_cap = raw_cap * 2; + char *tmp = im_realloc(raw, new_cap); + if (!tmp) { + im_free(raw); + proxy_conn_close(conn); + return OPRT_MALLOC_FAILED; + } + raw = tmp; + raw_cap = new_cap; + } + + int n = proxy_conn_read(conn, raw + raw_len, (int)(raw_cap - raw_len - 1), 1000); + if (n == OPRT_RESOURCE_NOT_READY) { + if ((int)(tal_system_get_millisecond() - wait_begin_ms) >= 15000) { + im_free(raw); + proxy_conn_close(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + continue; + } + if (n < 0) { + if (raw_len > 0) { + break; + } + im_free(raw); + proxy_conn_close(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + if (n == 0) { + break; + } + + raw_len += (size_t)n; + raw[raw_len] = '\0'; + wait_begin_ms = tal_system_get_millisecond(); + } + + proxy_conn_close(conn); + + if (raw_len == 0) { + im_free(raw); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + if (status_code) { + *status_code = im_parse_http_status(raw); + } + + resp_buf[0] = '\0'; + char *body_ptr = strstr(raw, "\r\n\r\n"); + if (body_ptr) { + body_ptr += 4; + size_t copy = strlen(body_ptr); + if (copy > resp_buf_size - 1) { + copy = resp_buf_size - 1; + } + memcpy(resp_buf, body_ptr, copy); + resp_buf[copy] = '\0'; + } + + im_free(raw); + return OPRT_OK; +} + +static OPERATE_RET fs_http_call_direct(const char *host, const char *path, const char *method, const char *body, + const char *bearer_token, char *resp_buf, size_t resp_buf_size, + uint16_t *status_code) +{ + OPERATE_RET rt = ensure_fs_cert(host); + if (rt != OPRT_OK) { + return rt; + } + + http_client_header_t headers[3] = {0}; + uint8_t header_count = 0; + headers[header_count++] = (http_client_header_t){.key = "Content-Type", .value = "application/json"}; + + char auth[640] = {0}; + if (bearer_token && bearer_token[0] != '\0') { + snprintf(auth, sizeof(auth), "Bearer %s", bearer_token); + headers[header_count++] = (http_client_header_t){.key = "Authorization", .value = auth}; + } + + const uint8_t *body_ptr = (const uint8_t *)(body ? body : ""); + size_t body_len = body ? strlen(body) : 0; + + const uint8_t *cacert = NULL; + size_t cacert_len = 0; + if (strcmp(host, FS_HOST) == 0) { + cacert = s_fs_cacert; + cacert_len = s_fs_cacert_len; + } + bool tls_no_verify = (cacert == NULL || cacert_len == 0); + + http_client_response_t response = {0}; + http_client_status_t http_rt = http_client_request( + &(const http_client_request_t){ + .cacert = cacert, + .cacert_len = cacert_len, + .tls_no_verify = tls_no_verify, + .host = host, + .port = 443, + .method = method, + .path = path, + .headers = headers, + .headers_count = header_count, + .body = body_ptr, + .body_length = body_len, + .timeout_ms = FS_HTTP_TIMEOUT_MS, + }, + &response); + if (http_rt != HTTP_CLIENT_SUCCESS) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + if (status_code) { + *status_code = response.status_code; + } + + resp_buf[0] = '\0'; + if (response.body && response.body_length > 0) { + size_t copy = (response.body_length < resp_buf_size - 1) ? response.body_length : (resp_buf_size - 1); + memcpy(resp_buf, response.body, copy); + resp_buf[copy] = '\0'; + } + + http_client_free(&response); + return OPRT_OK; +} + +static OPERATE_RET fs_http_call(const char *host, const char *path, const char *method, const char *body, + const char *bearer_token, char *resp_buf, size_t resp_buf_size, uint16_t *status_code) +{ + if (!host || !path || !method || !resp_buf || resp_buf_size == 0) { + return OPRT_INVALID_PARM; + } + + if (http_proxy_is_enabled()) { + return fs_http_call_via_proxy(host, path, method, body, bearer_token, resp_buf, resp_buf_size, status_code); + } + + return fs_http_call_direct(host, path, method, body, bearer_token, resp_buf, resp_buf_size, status_code); +} + +static bool fs_response_ok(const char *json_str, const char **out_msg) +{ + static char s_last_err_msg[128] = {0}; + s_last_err_msg[0] = '\0'; + + if (out_msg) { + *out_msg = NULL; + } + if (!json_str || json_str[0] == '\0') { + return false; + } + + cJSON *root = cJSON_Parse(json_str); + if (!root) { + return false; + } + + bool ok = false; + cJSON *code = cJSON_GetObjectItem(root, "code"); + if (cJSON_IsNumber(code) && (int)code->valuedouble == 0) { + ok = true; + } + + if (!ok && out_msg) { + cJSON *msg = cJSON_GetObjectItem(root, "msg"); + if (cJSON_IsString(msg) && msg->valuestring) { + im_safe_copy(s_last_err_msg, sizeof(s_last_err_msg), msg->valuestring); + *out_msg = s_last_err_msg; + } + } + + cJSON_Delete(root); + return ok; +} + +/* -------- tenant token -------- */ + +static bool tenant_token_valid(void) +{ + if (s_tenant_token[0] == '\0' || s_tenant_expire_ms == 0) { + return false; + } + return ((int32_t)(s_tenant_expire_ms - tal_system_get_millisecond()) > 0); +} + +static OPERATE_RET refresh_tenant_token(void) +{ + if (s_app_id[0] == '\0' || s_app_secret[0] == '\0') { + return OPRT_NOT_FOUND; + } + + cJSON *body = cJSON_CreateObject(); + if (!body) { + return OPRT_MALLOC_FAILED; + } + cJSON_AddStringToObject(body, "app_id", s_app_id); + cJSON_AddStringToObject(body, "app_secret", s_app_secret); + char *json = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!json) { + return OPRT_MALLOC_FAILED; + } + + char *resp = im_malloc(FS_HTTP_RESP_BUF_SIZE); + if (!resp) { + cJSON_free(json); + return OPRT_MALLOC_FAILED; + } + memset(resp, 0, FS_HTTP_RESP_BUF_SIZE); + + uint16_t status = 0; + OPERATE_RET rt = fs_http_call(FS_HOST, "/open-apis/auth/v3/tenant_access_token/internal", "POST", json, NULL, resp, + FS_HTTP_RESP_BUF_SIZE, &status); + cJSON_free(json); + if (rt != OPRT_OK || status != 200) { + im_free(resp); + return OPRT_COM_ERROR; + } + + cJSON *root = cJSON_Parse(resp); + if (!root) { + im_free(resp); + return OPRT_CR_CJSON_ERR; + } + + OPERATE_RET out = OPRT_COM_ERROR; + cJSON *code = cJSON_GetObjectItem(root, "code"); + cJSON *token = cJSON_GetObjectItem(root, "tenant_access_token"); + cJSON *expire = cJSON_GetObjectItem(root, "expire"); + if (cJSON_IsNumber(code) && (int)code->valuedouble == 0 && cJSON_IsString(token) && token->valuestring && + cJSON_IsNumber(expire) && expire->valuedouble > 0) { + uint32_t expire_s = (uint32_t)expire->valuedouble; + if (expire_s > FS_TOKEN_SAFETY_MARGIN_S) { + expire_s -= FS_TOKEN_SAFETY_MARGIN_S; + } + im_safe_copy(s_tenant_token, sizeof(s_tenant_token), token->valuestring); + s_tenant_expire_ms = tal_system_get_millisecond() + expire_s * 1000u; + out = OPRT_OK; + } + + cJSON_Delete(root); + im_free(resp); + return out; +} + +static OPERATE_RET ensure_tenant_token(void) +{ + if (tenant_token_valid()) { + return OPRT_OK; + } + return refresh_tenant_token(); +} + +/* -------- ws endpoint -------- */ + +typedef struct { + char url[768]; + int reconnect_count; + uint32_t reconnect_interval_ms; + uint32_t reconnect_nonce_ms; + uint32_t ping_interval_ms; +} fs_ws_conf_t; + +static int json_int2(cJSON *obj, const char *k1, const char *k2, int defv) +{ + cJSON *item = cJSON_GetObjectItem(obj, k1); + if (!item && k2) { + item = cJSON_GetObjectItem(obj, k2); + } + if (!cJSON_IsNumber(item)) { + return defv; + } + return (int)item->valuedouble; +} + +static const char *json_str2(cJSON *obj, const char *k1, const char *k2) +{ + const char *v = im_json_str(obj, k1, NULL); + return v ? v : im_json_str(obj, k2, NULL); +} + +static OPERATE_RET fs_fetch_ws_conf(fs_ws_conf_t *conf) +{ + if (!conf) { + return OPRT_INVALID_PARM; + } + memset(conf, 0, sizeof(*conf)); + conf->reconnect_count = -1; + conf->reconnect_interval_ms = FS_WS_DEFAULT_RECONNECT_MS; + conf->reconnect_nonce_ms = 30000; + conf->ping_interval_ms = FS_WS_DEFAULT_PING_MS; + + cJSON *body = cJSON_CreateObject(); + if (!body) { + return OPRT_MALLOC_FAILED; + } + cJSON_AddStringToObject(body, "AppID", s_app_id); + cJSON_AddStringToObject(body, "AppSecret", s_app_secret); + char *json = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!json) { + return OPRT_MALLOC_FAILED; + } + + char *resp = im_malloc(FS_HTTP_RESP_BUF_SIZE); + if (!resp) { + cJSON_free(json); + return OPRT_MALLOC_FAILED; + } + memset(resp, 0, FS_HTTP_RESP_BUF_SIZE); + + uint16_t status = 0; + OPERATE_RET rt = + fs_http_call(FS_HOST, "/callback/ws/endpoint", "POST", json, NULL, resp, FS_HTTP_RESP_BUF_SIZE, &status); + cJSON_free(json); + if (rt != OPRT_OK || status != 200) { + im_free(resp); + return OPRT_COM_ERROR; + } + + cJSON *root = cJSON_Parse(resp); + im_free(resp); + if (!root) { + return OPRT_CR_CJSON_ERR; + } + + OPERATE_RET out = OPRT_COM_ERROR; + cJSON *code = cJSON_GetObjectItem(root, "code"); + cJSON *data = cJSON_GetObjectItem(root, "data"); + if (cJSON_IsNumber(code) && (int)code->valuedouble == 0 && cJSON_IsObject(data)) { + const char *url = json_str2(data, "URL", "url"); + if (url && url[0] != '\0') { + im_safe_copy(conf->url, sizeof(conf->url), url); + + cJSON *cc = cJSON_GetObjectItem(data, "ClientConfig"); + if (!cc) { + cc = cJSON_GetObjectItem(data, "client_config"); + } + if (cJSON_IsObject(cc)) { + int rc = json_int2(cc, "ReconnectCount", "reconnect_count", conf->reconnect_count); + int ri = + json_int2(cc, "ReconnectInterval", "reconnect_interval", (int)(conf->reconnect_interval_ms / 1000)); + int rn = json_int2(cc, "ReconnectNonce", "reconnect_nonce", (int)(conf->reconnect_nonce_ms / 1000)); + int pi = json_int2(cc, "PingInterval", "ping_interval", (int)(conf->ping_interval_ms / 1000)); + + conf->reconnect_count = rc; + if (ri > 0) { + conf->reconnect_interval_ms = (uint32_t)ri * 1000u; + } + if (rn > 0) { + conf->reconnect_nonce_ms = (uint32_t)rn * 1000u; + } + if (pi > 0) { + conf->ping_interval_ms = (uint32_t)pi * 1000u; + } + } + out = OPRT_OK; + } + } + + cJSON_Delete(root); + return out; +} + +static int fs_query_param_int(const char *path, const char *key, int defv) +{ + if (!path || !key || key[0] == '\0') { + return defv; + } + + char pattern[64] = {0}; + snprintf(pattern, sizeof(pattern), "%s=", key); + const char *p = strstr(path, pattern); + if (!p) { + return defv; + } + p += strlen(pattern); + return atoi(p); +} + +static OPERATE_RET fs_parse_ws_url(const char *url, char *host, size_t host_size, uint16_t *port, char *path, + size_t path_size, int *service_id) +{ + if (!url || !host || host_size == 0 || !port || !path || path_size == 0 || !service_id) { + return OPRT_INVALID_PARM; + } + + host[0] = '\0'; + path[0] = '\0'; + *service_id = 0; + + const char *p = NULL; + uint16_t default_port = 443; + + if (strncmp(url, "wss://", 6) == 0) { + p = url + 6; + default_port = 443; + } else if (strncmp(url, "ws://", 5) == 0) { + p = url + 5; + default_port = 80; + } else { + return OPRT_INVALID_PARM; + } + + const char *host_begin = p; + while (*p && *p != '/' && *p != '?') { + p++; + } + + const char *host_end = p; + const char *colon = NULL; + for (const char *q = host_begin; q < host_end; q++) { + if (*q == ':') { + colon = q; + break; + } + } + + if (colon) { + size_t host_len = (size_t)(colon - host_begin); + if (host_len == 0 || host_len >= host_size) { + return OPRT_BUFFER_NOT_ENOUGH; + } + memcpy(host, host_begin, host_len); + host[host_len] = '\0'; + + int parsed_port = atoi(colon + 1); + if (parsed_port <= 0 || parsed_port > 65535) { + return OPRT_INVALID_PARM; + } + *port = (uint16_t)parsed_port; + } else { + size_t host_len = (size_t)(host_end - host_begin); + if (host_len == 0 || host_len >= host_size) { + return OPRT_BUFFER_NOT_ENOUGH; + } + memcpy(host, host_begin, host_len); + host[host_len] = '\0'; + *port = default_port; + } + + if (*p == '\0') { + im_safe_copy(path, path_size, "/"); + } else { + im_safe_copy(path, path_size, p); + } + + *service_id = fs_query_param_int(path, "service_id", 0); + return OPRT_OK; +} + +/* -------- ws low-level conn -------- */ + +typedef enum { + FS_CONN_NONE = 0, + FS_CONN_PROXY, + FS_CONN_DIRECT, +} fs_conn_mode_t; + +typedef struct { + fs_conn_mode_t mode; + proxy_conn_t *proxy; + tuya_transporter_t tcp; + tuya_tls_hander tls; + int socket_fd; + uint8_t rx_buf[FS_WS_RX_BUF_SIZE]; + size_t rx_len; +} fs_ws_conn_t; + +static OPERATE_RET fs_direct_open(fs_ws_conn_t *conn, const char *host, int port, int timeout_ms) +{ + if (!conn || !host || port <= 0 || timeout_ms <= 0) { + return OPRT_INVALID_PARM; + } + + conn->tcp = tuya_transporter_create(TRANSPORT_TYPE_TCP, NULL); + if (!conn->tcp) { + return OPRT_COM_ERROR; + } + conn->mode = FS_CONN_DIRECT; + + tuya_tcp_config_t cfg = {0}; + cfg.isReuse = TRUE; + cfg.isDisableNagle = TRUE; + cfg.sendTimeoutMs = timeout_ms; + cfg.recvTimeoutMs = timeout_ms; + (void)tuya_transporter_ctrl(conn->tcp, TUYA_TRANSPORTER_SET_TCP_CONFIG, &cfg); + + OPERATE_RET rt = tuya_transporter_connect(conn->tcp, (char *)host, port, timeout_ms); + if (rt != OPRT_OK) { + return rt; + } + + rt = tuya_transporter_ctrl(conn->tcp, TUYA_TRANSPORTER_GET_TCP_SOCKET, &conn->socket_fd); + if (rt != OPRT_OK || conn->socket_fd < 0) { + return OPRT_SOCK_ERR; + } + + uint8_t *cacert = NULL; + size_t cacert_len = 0; + bool verify_peer = false; + + rt = im_tls_query_domain_certs(host, &cacert, &cacert_len); + if (rt == OPRT_OK && cacert && cacert_len > 0) { + verify_peer = true; + } else { + IM_LOGW(TAG, "ws cert unavailable for %s, fallback to no-verify mode rt=%d", host, rt); + } + if (verify_peer && cacert_len > (size_t)INT_MAX) { + IM_LOGW(TAG, "ws cert too large for tuya_tls host=%s len=%zu, fallback to no-verify", host, cacert_len); + verify_peer = false; + } + + conn->tls = tuya_tls_connect_create(); + if (!conn->tls) { + if (cacert) { + im_free(cacert); + } + return OPRT_MALLOC_FAILED; + } + + int timeout_s = timeout_ms / 1000; + if (timeout_s <= 0) { + timeout_s = 1; + } + + tuya_tls_config_t cfg_tls = { + .mode = TUYA_TLS_SERVER_CERT_MODE, + .hostname = (char *)host, + .port = (uint16_t)port, + .timeout = (uint32_t)timeout_s, + .verify = verify_peer, + .ca_cert = verify_peer ? (char *)cacert : NULL, + .ca_cert_size = verify_peer ? (int)cacert_len : 0, + }; + (void)tuya_tls_config_set(conn->tls, &cfg_tls); + + rt = tuya_tls_connect(conn->tls, (char *)host, port, conn->socket_fd, timeout_s); + if (cacert) { + im_free(cacert); + } + if (rt != OPRT_OK) { + return rt; + } + + return OPRT_OK; +} + +static void fs_conn_close(fs_ws_conn_t *conn) +{ + if (!conn) { + return; + } + + if (conn->mode == FS_CONN_PROXY) { + if (conn->proxy) { + proxy_conn_close(conn->proxy); + conn->proxy = NULL; + } + } else if (conn->mode == FS_CONN_DIRECT) { + if (conn->tls) { + (void)tuya_tls_disconnect(conn->tls); + tuya_tls_connect_destroy(conn->tls); + conn->tls = NULL; + } + if (conn->tcp) { + (void)tuya_transporter_close(conn->tcp); + (void)tuya_transporter_destroy(conn->tcp); + conn->tcp = NULL; + } + conn->socket_fd = -1; + } + + conn->mode = FS_CONN_NONE; + conn->rx_len = 0; +} + +static OPERATE_RET fs_conn_open(fs_ws_conn_t *conn, const char *host, int port, int timeout_ms) +{ + if (!conn || !host || port <= 0 || timeout_ms <= 0) { + return OPRT_INVALID_PARM; + } + + memset(conn, 0, sizeof(*conn)); + conn->socket_fd = -1; + + if (http_proxy_is_enabled()) { + conn->proxy = proxy_conn_open(host, port, timeout_ms); + if (!conn->proxy) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + conn->mode = FS_CONN_PROXY; + return OPRT_OK; + } + + OPERATE_RET rt = fs_direct_open(conn, host, port, timeout_ms); + if (rt != OPRT_OK) { + fs_conn_close(conn); + return rt; + } + + return OPRT_OK; +} + +static int fs_conn_write(fs_ws_conn_t *conn, const uint8_t *data, int len) +{ + if (!conn || !data || len <= 0) { + return -1; + } + + if (conn->mode == FS_CONN_PROXY) { + return proxy_conn_write(conn->proxy, (const char *)data, len); + } + + if (conn->mode != FS_CONN_DIRECT || !conn->tls) { + return -1; + } + + int sent = 0; + while (sent < len) { + int n = tuya_tls_write(conn->tls, (uint8_t *)data + sent, (uint32_t)(len - sent)); + if (n <= 0) { + return -1; + } + sent += n; + } + + return sent; +} + +/* + * Layout-compatible prefix of the internal tuya_mbedtls_context_t (tuya_tls.c) + * so we can call mbedtls_ssl_get_bytes_avail() to detect data already + * decrypted but not yet consumed by the application. + */ +typedef struct { + tuya_tls_config_t _cfg; + mbedtls_ssl_context ssl_ctx; +} fs_tls_compat_t; + +static size_t fs_tls_bytes_avail(tuya_tls_hander tls) +{ + if (!tls) { + return 0; + } + return mbedtls_ssl_get_bytes_avail(&((fs_tls_compat_t *)tls)->ssl_ctx); +} + +static int fs_conn_read(fs_ws_conn_t *conn, uint8_t *buf, int len, int timeout_ms) +{ + if (!conn || !buf || len <= 0 || timeout_ms <= 0) { + return -1; + } + + if (conn->mode == FS_CONN_PROXY) { + return proxy_conn_read(conn->proxy, (char *)buf, len, timeout_ms); + } + + if (conn->mode != FS_CONN_DIRECT || !conn->tls || conn->socket_fd < 0) { + return -1; + } + + /* + * mbedtls decrypts a full TLS record at once but may return only part of + * it to the caller; the remainder stays in the SSL context's internal + * buffer. select() only monitors the raw TCP socket and is blind to + * that buffered plaintext, so it would block until the *next* TCP + * segment arrives — causing multi-second stalls. + * + * Fix: skip select() when mbedtls already has data ready. + */ + if (fs_tls_bytes_avail(conn->tls) == 0) { + TUYA_FD_SET_T readfds; + tal_net_fd_zero(&readfds); + tal_net_fd_set(conn->socket_fd, &readfds); + int ready = tal_net_select(conn->socket_fd + 1, &readfds, NULL, NULL, timeout_ms); + if (ready < 0) { + return -1; + } + if (ready == 0) { + return OPRT_RESOURCE_NOT_READY; + } + } + + int n = tuya_tls_read(conn->tls, buf, (uint32_t)len); + if (n > 0) { + return n; + } + if (n == 0 || n == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) { + return 0; + } + if (n == OPRT_RESOURCE_NOT_READY || n == MBEDTLS_ERR_SSL_WANT_READ || n == MBEDTLS_ERR_SSL_WANT_WRITE || + n == MBEDTLS_ERR_SSL_TIMEOUT || n == -100) { + return OPRT_RESOURCE_NOT_READY; + } + + return n; +} + +/* -------- ws frame codec -------- */ + +static OPERATE_RET fs_ws_send_frame(fs_ws_conn_t *conn, uint8_t opcode, const uint8_t *payload, size_t payload_len) +{ + if (!conn || (payload_len > 0 && !payload)) { + return OPRT_INVALID_PARM; + } + + size_t header_len = 0; + uint8_t header[14] = {0}; + + header[0] = (uint8_t)(0x80 | (opcode & 0x0F)); + + if (payload_len <= 125) { + header[1] = (uint8_t)(0x80 | payload_len); + header_len = 2; + } else if (payload_len <= 0xFFFF) { + header[1] = (uint8_t)(0x80 | 126); + header[2] = (uint8_t)((payload_len >> 8) & 0xFF); + header[3] = (uint8_t)(payload_len & 0xFF); + header_len = 4; + } else { + header[1] = (uint8_t)(0x80 | 127); + uint64_t plen64 = (uint64_t)payload_len; + for (int i = 0; i < 8; i++) { + header[2 + i] = (uint8_t)((plen64 >> (56 - i * 8)) & 0xFF); + } + header_len = 10; + } + + uint32_t m = (uint32_t)tal_system_get_random(0xFFFFFFFFu); + uint8_t mask[4] = { + (uint8_t)(m & 0xFF), + (uint8_t)((m >> 8) & 0xFF), + (uint8_t)((m >> 16) & 0xFF), + (uint8_t)((m >> 24) & 0xFF), + }; + + size_t frame_len = header_len + 4 + payload_len; + uint8_t *frame = im_malloc(frame_len); + if (!frame) { + return OPRT_MALLOC_FAILED; + } + + memcpy(frame, header, header_len); + memcpy(frame + header_len, mask, 4); + for (size_t i = 0; i < payload_len; i++) { + frame[header_len + 4 + i] = (uint8_t)(payload[i] ^ mask[i % 4]); + } + + int n = fs_conn_write(conn, frame, (int)frame_len); + im_free(frame); + + return (n == (int)frame_len) ? OPRT_OK : OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; +} + +static OPERATE_RET fs_ws_handshake(fs_ws_conn_t *conn, const char *host, const char *path) +{ + if (!conn || !host || !path) { + return OPRT_INVALID_PARM; + } + + const char *ws_key = "dGhlIHNhbXBsZSBub25jZQ=="; + char req[1024] = {0}; + int req_len = snprintf(req, sizeof(req), + "GET %s HTTP/1.1\r\n" + "Host: %s\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: %s\r\n" + "Sec-WebSocket-Version: 13\r\n" + "User-Agent: IM/1.0\r\n\r\n", + path, host, ws_key); + if (req_len <= 0 || req_len >= (int)sizeof(req)) { + return OPRT_BUFFER_NOT_ENOUGH; + } + + if (fs_conn_write(conn, (const uint8_t *)req, req_len) != req_len) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + char header[4096] = {0}; + int total = 0; + int header_end = -1; + uint32_t start_ms = tal_system_get_millisecond(); + + while ((int)(tal_system_get_millisecond() - start_ms) < FS_HTTP_TIMEOUT_MS && total < (int)sizeof(header) - 1) { + int n = fs_conn_read(conn, (uint8_t *)header + total, (int)sizeof(header) - total - 1, 1000); + if (n == OPRT_RESOURCE_NOT_READY) { + continue; + } + if (n <= 0) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + total += n; + header[total] = '\0'; + header_end = im_find_header_end(header, total); + if (header_end > 0) { + break; + } + } + + if (header_end <= 0) { + return OPRT_TIMEOUT; + } + + uint16_t status = im_parse_http_status(header); + if (status != 101) { + IM_LOGE(TAG, "feishu ws handshake failed http=%u", status); + return OPRT_COM_ERROR; + } + + size_t remain = (size_t)(total - header_end); + conn->rx_len = 0; + if (remain > 0) { + if (remain > sizeof(conn->rx_buf)) { + return OPRT_BUFFER_NOT_ENOUGH; + } + memcpy(conn->rx_buf, header + header_end, remain); + conn->rx_len = remain; + } + + IM_LOGI(TAG, "feishu ws handshake success!"); + return OPRT_OK; +} + +static OPERATE_RET fs_ws_decode_one_frame(fs_ws_conn_t *conn, uint8_t *opcode, uint8_t **payload, size_t *payload_len, + size_t *consumed) +{ + if (!conn || !opcode || !payload || !payload_len || !consumed) { + return OPRT_INVALID_PARM; + } + + if (conn->rx_len < 2) { + return OPRT_RESOURCE_NOT_READY; + } + + const uint8_t *buf = conn->rx_buf; + bool masked = (buf[1] & 0x80) != 0; + uint64_t plen = (uint64_t)(buf[1] & 0x7F); + size_t off = 2; + + if (plen == 126) { + if (conn->rx_len < off + 2) { + return OPRT_RESOURCE_NOT_READY; + } + plen = (uint64_t)((buf[off] << 8) | buf[off + 1]); + off += 2; + } else if (plen == 127) { + if (conn->rx_len < off + 8) { + return OPRT_RESOURCE_NOT_READY; + } + plen = 0; + for (int i = 0; i < 8; i++) { + plen = (plen << 8) | buf[off + i]; + } + off += 8; + } + + if (masked && conn->rx_len < off + 4) { + return OPRT_RESOURCE_NOT_READY; + } + + if (plen > (uint64_t)(FS_WS_RX_BUF_SIZE - 16)) { + return OPRT_MSG_OUT_OF_LIMIT; + } + + size_t frame_len = off + (masked ? 4 : 0) + (size_t)plen; + if (conn->rx_len < frame_len) { + return OPRT_RESOURCE_NOT_READY; + } + + uint8_t mask[4] = {0}; + if (masked) { + memcpy(mask, buf + off, 4); + off += 4; + } + + uint8_t *data = im_malloc((size_t)plen + 1); + if (!data) { + return OPRT_MALLOC_FAILED; + } + + if (plen > 0) { + memcpy(data, buf + off, (size_t)plen); + if (masked) { + for (size_t i = 0; i < (size_t)plen; i++) { + data[i] = (uint8_t)(data[i] ^ mask[i % 4]); + } + } + } + data[plen] = '\0'; + + *opcode = (uint8_t)(buf[0] & 0x0F); + *payload = data; + *payload_len = (size_t)plen; + *consumed = frame_len; + + return OPRT_OK; +} + +static void fs_ws_consume_rx(fs_ws_conn_t *conn, size_t consumed) +{ + if (!conn || consumed == 0 || consumed > conn->rx_len) { + return; + } + + if (consumed < conn->rx_len) { + memmove(conn->rx_buf, conn->rx_buf + consumed, conn->rx_len - consumed); + } + conn->rx_len -= consumed; +} + +static OPERATE_RET fs_ws_poll_frame(fs_ws_conn_t *conn, int wait_ms, uint8_t *opcode, uint8_t **payload, + size_t *payload_len) +{ + if (!conn || !opcode || !payload || !payload_len) { + return OPRT_INVALID_PARM; + } + + *payload = NULL; + *payload_len = 0; + + size_t consumed = 0; + OPERATE_RET rt = fs_ws_decode_one_frame(conn, opcode, payload, payload_len, &consumed); + if (rt == OPRT_OK) { + fs_ws_consume_rx(conn, consumed); + return OPRT_OK; + } + if (rt != OPRT_RESOURCE_NOT_READY) { + return rt; + } + + uint8_t tmp[1024] = {0}; + int n = fs_conn_read(conn, tmp, sizeof(tmp), wait_ms); + if (n == OPRT_RESOURCE_NOT_READY) { + return OPRT_RESOURCE_NOT_READY; + } + if (n <= 0) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + if (conn->rx_len + (size_t)n > sizeof(conn->rx_buf)) { + return OPRT_BUFFER_NOT_ENOUGH; + } + + memcpy(conn->rx_buf + conn->rx_len, tmp, (size_t)n); + conn->rx_len += (size_t)n; + + consumed = 0; + rt = fs_ws_decode_one_frame(conn, opcode, payload, payload_len, &consumed); + if (rt == OPRT_OK) { + fs_ws_consume_rx(conn, consumed); + } + return rt; +} + +/* -------- protobuf frame codec -------- */ + +typedef struct { + char key[FS_WS_FRAME_MAX_KEY]; + char value[FS_WS_FRAME_MAX_VALUE]; +} fs_pb_header_t; + +typedef struct { + uint64_t seq_id; + uint64_t log_id; + int32_t service; + int32_t method; + fs_pb_header_t headers[FS_WS_FRAME_MAX_HEADERS]; + size_t header_count; + uint8_t *payload; + size_t payload_len; +} fs_pb_frame_t; + +typedef struct { + uint8_t *data; + size_t len; + size_t cap; +} fs_buf_t; + +static void fs_pb_frame_init(fs_pb_frame_t *f) +{ + if (!f) { + return; + } + memset(f, 0, sizeof(*f)); +} + +static void fs_pb_frame_free(fs_pb_frame_t *f) +{ + if (!f) { + return; + } + im_free(f->payload); + f->payload = NULL; + f->payload_len = 0; +} + +static fs_pb_frame_t *fs_pb_frame_new(void) +{ + return im_calloc(1, sizeof(fs_pb_frame_t)); +} + +static void fs_pb_frame_delete(fs_pb_frame_t *f) +{ + if (!f) { + return; + } + fs_pb_frame_free(f); + im_free(f); +} + +static bool pb_read_varint(const uint8_t *buf, size_t len, size_t *off, uint64_t *out) +{ + if (!buf || !off || !out) { + return false; + } + + uint64_t value = 0; + int shift = 0; + while (*off < len && shift <= 63) { + uint8_t b = buf[(*off)++]; + value |= ((uint64_t)(b & 0x7F) << shift); + if ((b & 0x80) == 0) { + *out = value; + return true; + } + shift += 7; + } + + return false; +} + +static bool pb_skip_field(const uint8_t *buf, size_t len, size_t *off, uint8_t wire) +{ + if (!buf || !off) { + return false; + } + + uint64_t v = 0; + switch (wire) { + case 0: + return pb_read_varint(buf, len, off, &v); + case 1: + if (*off + 8 > len) { + return false; + } + *off += 8; + return true; + case 2: + if (!pb_read_varint(buf, len, off, &v)) { + return false; + } + if (v > len || *off > len - (size_t)v) { + return false; + } + *off += (size_t)v; + return true; + case 5: + if (*off + 4 > len) { + return false; + } + *off += 4; + return true; + default: + return false; + } +} + +static bool fs_pb_parse_header(const uint8_t *buf, size_t len, fs_pb_header_t *hdr) +{ + if (!buf || !hdr) { + return false; + } + + hdr->key[0] = '\0'; + hdr->value[0] = '\0'; + + size_t off = 0; + while (off < len) { + uint64_t tag = 0; + if (!pb_read_varint(buf, len, &off, &tag)) { + return false; + } + + uint32_t field = (uint32_t)(tag >> 3); + uint8_t wire = (uint8_t)(tag & 0x07); + + if (wire == 2 && (field == 1 || field == 2)) { + uint64_t slen = 0; + if (!pb_read_varint(buf, len, &off, &slen)) { + return false; + } + if (slen > len || off > len - (size_t)slen) { + return false; + } + + size_t copy = (size_t)slen; + if (field == 1) { + if (copy > sizeof(hdr->key) - 1) { + copy = sizeof(hdr->key) - 1; + } + memcpy(hdr->key, buf + off, copy); + hdr->key[copy] = '\0'; + } else { + if (copy > sizeof(hdr->value) - 1) { + copy = sizeof(hdr->value) - 1; + } + memcpy(hdr->value, buf + off, copy); + hdr->value[copy] = '\0'; + } + + off += (size_t)slen; + } else { + if (!pb_skip_field(buf, len, &off, wire)) { + return false; + } + } + } + + return true; +} + +static bool fs_pb_parse_frame(const uint8_t *buf, size_t len, fs_pb_frame_t *frame) +{ + if (!buf || !frame) { + return false; + } + + fs_pb_frame_init(frame); + + size_t off = 0; + while (off < len) { + uint64_t tag = 0; + if (!pb_read_varint(buf, len, &off, &tag)) { + goto __FAIL; + } + + uint32_t field = (uint32_t)(tag >> 3); + uint8_t wire = (uint8_t)(tag & 0x07); + uint64_t v = 0; + + switch (field) { + case 1: /* seq_id */ + case 2: /* log_id */ + case 3: /* service */ + case 4: /* method */ + if (wire != 0 || !pb_read_varint(buf, len, &off, &v)) { + goto __FAIL; + } + if (field == 1) frame->seq_id = v; + else if (field == 2) frame->log_id = v; + else if (field == 3) frame->service = (int32_t)v; + else frame->method = (int32_t)v; + break; + case 5: { /* header */ + if (wire != 2) { + goto __FAIL; + } + uint64_t mlen = 0; + if (!pb_read_varint(buf, len, &off, &mlen) || mlen > len || off > len - (size_t)mlen) { + goto __FAIL; + } + if (frame->header_count < FS_WS_FRAME_MAX_HEADERS) { + (void)fs_pb_parse_header(buf + off, (size_t)mlen, &frame->headers[frame->header_count]); + frame->header_count++; + } + off += (size_t)mlen; + break; + } + case 8: { /* payload */ + if (wire != 2) { + goto __FAIL; + } + uint64_t blen = 0; + if (!pb_read_varint(buf, len, &off, &blen) || blen > len || off > len - (size_t)blen) { + goto __FAIL; + } + im_free(frame->payload); + frame->payload = NULL; + frame->payload_len = 0; + if (blen > 0) { + frame->payload = im_malloc((size_t)blen + 1); + if (!frame->payload) { + goto __FAIL; + } + memcpy(frame->payload, buf + off, (size_t)blen); + frame->payload[blen] = '\0'; + frame->payload_len = (size_t)blen; + } + off += (size_t)blen; + break; + } + default: + if (!pb_skip_field(buf, len, &off, wire)) { + goto __FAIL; + } + break; + } + } + return true; + +__FAIL: + fs_pb_frame_free(frame); + return false; +} + +static bool fs_buf_reserve(fs_buf_t *b, size_t need) +{ + if (!b) { + return false; + } + if (b->len + need <= b->cap) { + return true; + } + + size_t new_cap = b->cap ? b->cap : 128; + while (new_cap < b->len + need) { + new_cap *= 2; + } + + uint8_t *p = im_realloc(b->data, new_cap); + if (!p) { + return false; + } + + b->data = p; + b->cap = new_cap; + return true; +} + +static bool fs_buf_append(fs_buf_t *b, const uint8_t *data, size_t n) +{ + if (!b || (n > 0 && !data)) { + return false; + } + if (!fs_buf_reserve(b, n)) { + return false; + } + + if (n > 0) { + memcpy(b->data + b->len, data, n); + } + b->len += n; + return true; +} + +static bool pb_write_varint(fs_buf_t *b, uint64_t v) +{ + uint8_t tmp[10] = {0}; + size_t n = 0; + do { + uint8_t c = (uint8_t)(v & 0x7F); + v >>= 7; + if (v) { + c |= 0x80; + } + tmp[n++] = c; + } while (v && n < sizeof(tmp)); + + return fs_buf_append(b, tmp, n); +} + +static bool pb_write_key(fs_buf_t *b, uint32_t field, uint8_t wire) +{ + return pb_write_varint(b, ((uint64_t)field << 3) | wire); +} + +static bool pb_write_bytes_field(fs_buf_t *b, uint32_t field, const uint8_t *data, size_t n) +{ + if (!pb_write_key(b, field, 2)) { + return false; + } + if (!pb_write_varint(b, (uint64_t)n)) { + return false; + } + return fs_buf_append(b, data, n); +} + +static bool pb_write_str_field(fs_buf_t *b, uint32_t field, const char *s) +{ + if (!s) { + s = ""; + } + return pb_write_bytes_field(b, field, (const uint8_t *)s, strlen(s)); +} + +static bool fs_pb_encode_frame(const fs_pb_frame_t *frame, uint8_t **out, size_t *out_len) +{ + if (!frame || !out || !out_len) { + return false; + } + + fs_buf_t b = {0}; + + if (!pb_write_key(&b, 1, 0) || !pb_write_varint(&b, frame->seq_id)) { + goto __FAIL; + } + if (!pb_write_key(&b, 2, 0) || !pb_write_varint(&b, frame->log_id)) { + goto __FAIL; + } + if (!pb_write_key(&b, 3, 0) || !pb_write_varint(&b, (uint64_t)frame->service)) { + goto __FAIL; + } + if (!pb_write_key(&b, 4, 0) || !pb_write_varint(&b, (uint64_t)frame->method)) { + goto __FAIL; + } + + for (size_t i = 0; i < frame->header_count; i++) { + fs_buf_t hb = {0}; + if (!pb_write_str_field(&hb, 1, frame->headers[i].key) || + !pb_write_str_field(&hb, 2, frame->headers[i].value)) { + im_free(hb.data); + goto __FAIL; + } + + if (!pb_write_key(&b, 5, 2) || !pb_write_varint(&b, (uint64_t)hb.len) || !fs_buf_append(&b, hb.data, hb.len)) { + im_free(hb.data); + goto __FAIL; + } + + im_free(hb.data); + } + + if (frame->payload && frame->payload_len > 0) { + if (!pb_write_bytes_field(&b, 8, frame->payload, frame->payload_len)) { + goto __FAIL; + } + } + + *out = b.data; + *out_len = b.len; + return true; + +__FAIL: + im_free(b.data); + return false; +} + +static const char *fs_pb_get_header(const fs_pb_frame_t *frame, const char *key) +{ + if (!frame || !key) { + return NULL; + } + + for (size_t i = 0; i < frame->header_count; i++) { + if (strcmp(frame->headers[i].key, key) == 0) { + return frame->headers[i].value; + } + } + return NULL; +} + +/* -------- payload split combine -------- */ + +typedef struct { + bool active; + char message_id[96]; + uint32_t sum; + bool got[FS_MAX_FRAG_PARTS]; + uint8_t *parts[FS_MAX_FRAG_PARTS]; + size_t lens[FS_MAX_FRAG_PARTS]; + uint32_t expire_ms; +} fs_frag_state_t; + +static fs_frag_state_t s_frag = {0}; + +static void fs_frag_clear(void) +{ + for (size_t i = 0; i < FS_MAX_FRAG_PARTS; i++) { + im_free(s_frag.parts[i]); + s_frag.parts[i] = NULL; + s_frag.lens[i] = 0; + s_frag.got[i] = false; + } + s_frag.active = false; + s_frag.message_id[0] = '\0'; + s_frag.sum = 0; + s_frag.expire_ms = 0; +} + +static OPERATE_RET fs_frag_merge(const char *message_id, uint32_t sum, uint32_t seq, const uint8_t *payload, + size_t payload_len, uint8_t **out_payload, size_t *out_len) +{ + if (!out_payload || !out_len || !payload) { + return OPRT_INVALID_PARM; + } + + *out_payload = NULL; + *out_len = 0; + + if (sum <= 1) { + uint8_t *copy = im_malloc(payload_len + 1); + if (!copy) { + return OPRT_MALLOC_FAILED; + } + memcpy(copy, payload, payload_len); + copy[payload_len] = '\0'; + *out_payload = copy; + *out_len = payload_len; + return OPRT_OK; + } + + if (!message_id || message_id[0] == '\0' || sum > FS_MAX_FRAG_PARTS || seq >= sum) { + return OPRT_INVALID_PARM; + } + + uint32_t now = tal_system_get_millisecond(); + if (!s_frag.active || strcmp(s_frag.message_id, message_id) != 0 || s_frag.sum != sum || + (int32_t)(now - s_frag.expire_ms) >= 0) { + fs_frag_clear(); + s_frag.active = true; + im_safe_copy(s_frag.message_id, sizeof(s_frag.message_id), message_id); + s_frag.sum = sum; + } + + if (!s_frag.got[seq]) { + uint8_t *part = im_malloc(payload_len + 1); + if (!part) { + return OPRT_MALLOC_FAILED; + } + memcpy(part, payload, payload_len); + part[payload_len] = '\0'; + + s_frag.parts[seq] = part; + s_frag.lens[seq] = payload_len; + s_frag.got[seq] = true; + } + s_frag.expire_ms = now + 5000; + + for (uint32_t i = 0; i < sum; i++) { + if (!s_frag.got[i]) { + return OPRT_RESOURCE_NOT_READY; + } + } + + size_t total = 0; + for (uint32_t i = 0; i < sum; i++) { + total += s_frag.lens[i]; + } + + uint8_t *merged = im_malloc(total + 1); + if (!merged) { + fs_frag_clear(); + return OPRT_MALLOC_FAILED; + } + + size_t off = 0; + for (uint32_t i = 0; i < sum; i++) { + memcpy(merged + off, s_frag.parts[i], s_frag.lens[i]); + off += s_frag.lens[i]; + } + merged[total] = '\0'; + + fs_frag_clear(); + *out_payload = merged; + *out_len = total; + return OPRT_OK; +} + +/* -------- message parsing -------- */ + +static void append_text(char *out, size_t out_size, const char *text) +{ + if (!out || out_size == 0 || !text || text[0] == '\0') { + return; + } + + size_t cur = strlen(out); + if (cur >= out_size - 1) { + return; + } + + if (cur > 0 && out[cur - 1] != ' ' && out[cur - 1] != '\n') { + int n = snprintf(out + cur, out_size - cur, " "); + if (n <= 0) { + return; + } + cur += (size_t)n; + } + + snprintf(out + cur, out_size - cur, "%s", text); +} + +static void append_prefixed(char *out, size_t out_size, const char *prefix, const char *text) +{ + if (!text || text[0] == '\0') { + return; + } + + char buf[384] = {0}; + snprintf(buf, sizeof(buf), "%s%s", prefix ? prefix : "", text); + append_text(out, out_size, buf); +} + +static void parse_post_block(cJSON *lang_obj, char *out, size_t out_size) +{ + if (!cJSON_IsObject(lang_obj) || !out || out_size == 0) { + return; + } + + const char *title = json_str2(lang_obj, "title", NULL); + if (title && title[0] != '\0') { + append_text(out, out_size, title); + } + + cJSON *content = cJSON_GetObjectItem(lang_obj, "content"); + if (!cJSON_IsArray(content)) { + return; + } + + cJSON *block = NULL; + cJSON_ArrayForEach(block, content) + { + if (!cJSON_IsArray(block)) { + continue; + } + + cJSON *elem = NULL; + cJSON_ArrayForEach(elem, block) + { + if (!cJSON_IsObject(elem)) { + continue; + } + const char *tag = json_str2(elem, "tag", NULL); + if (!tag) { + continue; + } + + if (strcmp(tag, "text") == 0 || strcmp(tag, "a") == 0) { + const char *txt = json_str2(elem, "text", NULL); + if (txt) { + append_text(out, out_size, txt); + } + } else if (strcmp(tag, "at") == 0) { + const char *uname = json_str2(elem, "user_name", NULL); + if (uname && uname[0] != '\0') { + char atbuf[96] = {0}; + snprintf(atbuf, sizeof(atbuf), "@%s", uname); + append_text(out, out_size, atbuf); + } + } + } + } +} + +static void parse_interactive_node(cJSON *node, char *out, size_t out_size); + +static void parse_interactive_element(cJSON *element, char *out, size_t out_size) +{ + if (!cJSON_IsObject(element) || !out || out_size == 0) { + return; + } + + const char *tag = json_str2(element, "tag", NULL); + if (!tag || tag[0] == '\0') { + return; + } + + if (strcmp(tag, "markdown") == 0 || strcmp(tag, "lark_md") == 0) { + append_text(out, out_size, json_str2(element, "content", NULL)); + return; + } + + if (strcmp(tag, "div") == 0) { + cJSON *txt = cJSON_GetObjectItem(element, "text"); + if (cJSON_IsObject(txt)) { + append_text(out, out_size, json_str2(txt, "content", "text")); + } else if (cJSON_IsString(txt)) { + append_text(out, out_size, txt->valuestring); + } + + cJSON *fields = cJSON_GetObjectItem(element, "fields"); + if (cJSON_IsArray(fields)) { + cJSON *field = NULL; + cJSON_ArrayForEach(field, fields) + { + cJSON *field_text = cJSON_GetObjectItem(field, "text"); + if (cJSON_IsObject(field_text)) { + append_text(out, out_size, json_str2(field_text, "content", "text")); + } + } + } + return; + } + + if (strcmp(tag, "a") == 0) { + append_prefixed(out, out_size, "link: ", json_str2(element, "href", NULL)); + append_text(out, out_size, json_str2(element, "text", NULL)); + return; + } + + if (strcmp(tag, "button") == 0) { + cJSON *txt = cJSON_GetObjectItem(element, "text"); + if (cJSON_IsObject(txt)) { + append_text(out, out_size, json_str2(txt, "content", "text")); + } else if (cJSON_IsString(txt)) { + append_text(out, out_size, txt->valuestring); + } + + const char *url = json_str2(element, "url", NULL); + if (!url) { + cJSON *multi = cJSON_GetObjectItem(element, "multi_url"); + if (cJSON_IsObject(multi)) { + url = json_str2(multi, "url", NULL); + } + } + append_prefixed(out, out_size, "link: ", url); + return; + } + + if (strcmp(tag, "img") == 0) { + cJSON *alt = cJSON_GetObjectItem(element, "alt"); + if (cJSON_IsObject(alt)) { + const char *alt_text = json_str2(alt, "content", "text"); + if (alt_text && alt_text[0] != '\0') { + append_text(out, out_size, alt_text); + } else { + append_text(out, out_size, "[image]"); + } + } else { + append_text(out, out_size, "[image]"); + } + return; + } + + if (strcmp(tag, "plain_text") == 0) { + append_text(out, out_size, json_str2(element, "content", NULL)); + return; + } + + if (strcmp(tag, "note") == 0) { + cJSON *elements = cJSON_GetObjectItem(element, "elements"); + if (cJSON_IsArray(elements)) { + cJSON *e = NULL; + cJSON_ArrayForEach(e, elements) + { + parse_interactive_element(e, out, out_size); + } + } + return; + } + + if (strcmp(tag, "column_set") == 0) { + cJSON *columns = cJSON_GetObjectItem(element, "columns"); + if (cJSON_IsArray(columns)) { + cJSON *col = NULL; + cJSON_ArrayForEach(col, columns) + { + cJSON *elements = cJSON_GetObjectItem(col, "elements"); + if (cJSON_IsArray(elements)) { + cJSON *e = NULL; + cJSON_ArrayForEach(e, elements) + { + parse_interactive_element(e, out, out_size); + } + } + } + } + return; + } + + cJSON *elements = cJSON_GetObjectItem(element, "elements"); + if (cJSON_IsArray(elements)) { + cJSON *e = NULL; + cJSON_ArrayForEach(e, elements) + { + parse_interactive_element(e, out, out_size); + } + } +} + +static void parse_interactive_node(cJSON *node, char *out, size_t out_size) +{ + if (!node || !out || out_size == 0) { + return; + } + + if (cJSON_IsString(node)) { + cJSON *parsed = cJSON_Parse(node->valuestring); + if (parsed) { + parse_interactive_node(parsed, out, out_size); + cJSON_Delete(parsed); + } else { + append_text(out, out_size, node->valuestring); + } + return; + } + + if (!cJSON_IsObject(node)) { + return; + } + + cJSON *title = cJSON_GetObjectItem(node, "title"); + if (cJSON_IsObject(title)) { + append_prefixed(out, out_size, "title: ", json_str2(title, "content", "text")); + } else if (cJSON_IsString(title)) { + append_prefixed(out, out_size, "title: ", title->valuestring); + } + + cJSON *elements = cJSON_GetObjectItem(node, "elements"); + if (cJSON_IsArray(elements)) { + cJSON *e = NULL; + cJSON_ArrayForEach(e, elements) + { + parse_interactive_element(e, out, out_size); + } + } + + cJSON *card = cJSON_GetObjectItem(node, "card"); + if (cJSON_IsObject(card)) { + parse_interactive_node(card, out, out_size); + } + + cJSON *header = cJSON_GetObjectItem(node, "header"); + if (cJSON_IsObject(header)) { + cJSON *header_title = cJSON_GetObjectItem(header, "title"); + if (cJSON_IsObject(header_title)) { + append_prefixed(out, out_size, "title: ", json_str2(header_title, "content", "text")); + } else if (cJSON_IsString(header_title)) { + append_prefixed(out, out_size, "title: ", header_title->valuestring); + } + } +} + +static void parse_share_card_content(const char *msg_type, cJSON *obj, char *out, size_t out_size) +{ + if (!msg_type || !out || out_size == 0) { + return; + } + + static const struct { + const char *type; + const char *label; + const char *id_key; + } share_types[] = { + {"share_chat", "shared chat", "chat_id" }, + {"share_user", "shared user", "user_id" }, + {"share_calendar_event", "shared calendar event", "event_key"}, + {"merge_forward", "merged forward messages", NULL }, + {"system", "system message", NULL }, + }; + + for (size_t i = 0; i < sizeof(share_types) / sizeof(share_types[0]); i++) { + if (strcmp(msg_type, share_types[i].type) != 0) { + continue; + } + if (share_types[i].id_key && cJSON_IsObject(obj)) { + const char *val = json_str2(obj, share_types[i].id_key, NULL); + if (val && val[0] != '\0') { + snprintf(out, out_size, "[%s: %s]", share_types[i].label, val); + return; + } + } + snprintf(out, out_size, "[%s]", share_types[i].label); + return; + } + + snprintf(out, out_size, "[%s]", msg_type); +} + +static void extract_message_text(const char *msg_type, const char *content_json, char *out, size_t out_size) +{ + if (!out || out_size == 0) { + return; + } + out[0] = '\0'; + + if (!msg_type) { + return; + } + + if (!content_json || content_json[0] == '\0') { + snprintf(out, out_size, "[%s]", msg_type); + return; + } + + cJSON *obj = cJSON_Parse(content_json); + if (!obj) { + snprintf(out, out_size, "[%s]", msg_type); + return; + } + + if (strcmp(msg_type, "text") == 0) { + const char *text = json_str2(obj, "text", NULL); + if (text) { + im_safe_copy(out, out_size, text); + } + } else if (strcmp(msg_type, "post") == 0) { + if (cJSON_GetObjectItem(obj, "content")) { + parse_post_block(obj, out, out_size); + } else { + static const char *post_langs[] = {"zh_cn", "en_us", "ja_jp"}; + for (size_t i = 0; i < sizeof(post_langs) / sizeof(post_langs[0]) && out[0] == '\0'; i++) { + parse_post_block(cJSON_GetObjectItem(obj, post_langs[i]), out, out_size); + } + } + } else if (strcmp(msg_type, "interactive") == 0) { + parse_interactive_node(obj, out, out_size); + if (out[0] == '\0') { + snprintf(out, out_size, "[interactive message]"); + } + } else if (strcmp(msg_type, "share_chat") == 0 || strcmp(msg_type, "share_user") == 0 || + strcmp(msg_type, "share_calendar_event") == 0 || strcmp(msg_type, "merge_forward") == 0 || + strcmp(msg_type, "system") == 0) { + parse_share_card_content(msg_type, obj, out, out_size); + } else { + snprintf(out, out_size, "[%s]", msg_type); + } + + cJSON_Delete(obj); +} + +static void publish_inbound_feishu(const char *chat_id, const char *text) +{ + if (!chat_id || !text || chat_id[0] == '\0' || text[0] == '\0') { + return; + } + + im_msg_t in = {0}; + strncpy(in.channel, IM_CHAN_FEISHU, sizeof(in.channel) - 1); + strncpy(in.chat_id, chat_id, sizeof(in.chat_id) - 1); + in.content = im_strdup(text); + if (!in.content) { + return; + } + OPERATE_RET rt = message_bus_push_inbound(&in); + if (rt != OPRT_OK) { + IM_LOGW(TAG, "push inbound failed rt=%d", rt); + im_free(in.content); + } +} + +static void handle_event_payload(const uint8_t *payload, size_t payload_len) +{ + if (!payload || payload_len == 0) { + return; + } + + cJSON *root = cJSON_ParseWithLength((const char *)payload, payload_len); + if (!root) { + IM_LOGW(TAG, "[feishu] event payload parse failed len=%u", (unsigned)payload_len); + return; + } + + const char *event_type = NULL; + const char *event_id = NULL; + cJSON *header = cJSON_GetObjectItem(root, "header"); + if (cJSON_IsObject(header)) { + event_type = json_str2(header, "event_type", NULL); + event_id = json_str2(header, "event_id", NULL); + } + if (!event_type) { + event_type = json_str2(root, "type", NULL); + } + + if (!event_type || strcmp(event_type, "im.message.receive_v1") != 0) { + IM_LOGD(TAG, "[feishu] skip event type=%s", event_type ? event_type : "(null)"); + cJSON_Delete(root); + return; + } + + /* Dedup by event_id: same push may be redelivered (e.g. after reconnect) */ + if (event_id && event_id[0] != '\0') { + uint64_t ev_key = im_fnv1a64(event_id); + if (dedup_contains(&s_seen_event, ev_key)) { + IM_LOGI(TAG, "[feishu] duplicate event_id dropped event_id=%s", event_id); + cJSON_Delete(root); + return; + } + } + + cJSON *event = cJSON_GetObjectItem(root, "event"); + cJSON *sender = event ? cJSON_GetObjectItem(event, "sender") : NULL; + cJSON *message = event ? cJSON_GetObjectItem(event, "message") : NULL; + if (!cJSON_IsObject(sender) || !cJSON_IsObject(message)) { + IM_LOGW(TAG, "[feishu] event missing sender or message object"); + cJSON_Delete(root); + return; + } + + const char *sender_type = json_str2(sender, "sender_type", NULL); + if (sender_type && strcmp(sender_type, "bot") == 0) { + cJSON_Delete(root); + return; + } + + cJSON *sender_id_obj = cJSON_GetObjectItem(sender, "sender_id"); + const char *sender_open_id = NULL; + const char *sender_user_id = NULL; + const char *sender_union_id = NULL; + if (cJSON_IsObject(sender_id_obj)) { + sender_open_id = json_str2(sender_id_obj, "open_id", NULL); + sender_user_id = json_str2(sender_id_obj, "user_id", NULL); + sender_union_id = json_str2(sender_id_obj, "union_id", NULL); + } + + char sender_identity[384] = {0}; + append_sender_id(sender_identity, sizeof(sender_identity), sender_open_id); + append_sender_id(sender_identity, sizeof(sender_identity), sender_user_id); + append_sender_id(sender_identity, sizeof(sender_identity), sender_union_id); + if (sender_identity[0] == '\0') { + IM_LOGW(TAG, "feishu sender id missing, drop message"); + cJSON_Delete(root); + return; + } + + if (!sender_open_id || sender_open_id[0] == '\0') { + IM_LOGW(TAG, "feishu sender open_id missing, drop message"); + cJSON_Delete(root); + return; + } + + if (!sender_allowed(sender_identity)) { + IM_LOGW(TAG, "feishu access denied sender=%s", sender_identity); + cJSON_Delete(root); + return; + } + + /* Dedup by message_id: same user message may be repeated/replayed */ + const char *message_id = json_str2(message, "message_id", NULL); + if (message_id && message_id[0]) { + uint64_t msg_key = im_fnv1a64(message_id); + if (dedup_contains(&s_seen_msg, msg_key)) { + IM_LOGI(TAG, "[feishu] duplicate message_id dropped message_id=%s", message_id); + if (event_id && event_id[0] != '\0') { + dedup_insert(&s_seen_event, im_fnv1a64(event_id)); + } + cJSON_Delete(root); + return; + } + dedup_insert(&s_seen_msg, msg_key); + } + if (event_id && event_id[0] != '\0') { + dedup_insert(&s_seen_event, im_fnv1a64(event_id)); + } + + const char *chat_id = json_str2(message, "chat_id", NULL); + const char *chat_type = json_str2(message, "chat_type", NULL); + const char *msg_type = json_str2(message, "message_type", NULL); + const char *content_json = json_str2(message, "content", NULL); + + char text[2048] = {0}; + extract_message_text(msg_type ? msg_type : "unknown", content_json, text, sizeof(text)); + if (text[0] == '\0') { + IM_LOGW(TAG, "[feishu] message text empty msg_type=%s (unsupported or empty content)", + msg_type ? msg_type : "null"); + cJSON_Delete(root); + return; + } + + const char *reply_to = NULL; + if (chat_type && strcmp(chat_type, "group") == 0) { + reply_to = chat_id; + } else { + reply_to = sender_open_id; + } + + if (!reply_to || reply_to[0] == '\0') { + cJSON_Delete(root); + return; + } + + IM_LOGI(TAG, "[feishu] inbound chat=%s type=%s len=%u", reply_to, msg_type ? msg_type : "?", + (unsigned)strlen(text)); + + publish_inbound_feishu(reply_to, text); + cJSON_Delete(root); +} + +/* -------- ws message handler -------- */ + +static OPERATE_RET send_pb_frame(fs_ws_conn_t *conn, const fs_pb_frame_t *frame) +{ + uint8_t *bin = NULL; + size_t bin_len = 0; + if (!fs_pb_encode_frame(frame, &bin, &bin_len)) { + return OPRT_COM_ERROR; + } + + OPERATE_RET rt = fs_ws_send_frame(conn, 0x2, bin, bin_len); + im_free(bin); + return rt; +} + +static OPERATE_RET send_ping_frame(fs_ws_conn_t *conn, int service_id) +{ + fs_pb_frame_t *ping = fs_pb_frame_new(); + if (!ping) { + return OPRT_MALLOC_FAILED; + } + + ping->seq_id = 0; + ping->log_id = 0; + ping->service = service_id; + ping->method = 0; + ping->header_count = 1; + im_safe_copy(ping->headers[0].key, sizeof(ping->headers[0].key), "type"); + im_safe_copy(ping->headers[0].value, sizeof(ping->headers[0].value), "ping"); + + OPERATE_RET rt = send_pb_frame(conn, ping); + fs_pb_frame_delete(ping); + return rt; +} + +static void handle_control_pb_frame(const fs_pb_frame_t *frame, uint32_t *ping_interval_ms) +{ + const char *type = fs_pb_get_header(frame, "type"); + if (!type) { + return; + } + + if (strcmp(type, "pong") == 0 && frame->payload && frame->payload_len > 0) { + cJSON *obj = cJSON_ParseWithLength((const char *)frame->payload, frame->payload_len); + if (obj) { + int p = json_int2(obj, "PingInterval", "ping_interval", -1); + if (p > 0) { + *ping_interval_ms = (uint32_t)p * 1000u; + } + cJSON_Delete(obj); + } + } +} + +static int pb_header_int(const fs_pb_frame_t *frame, const char *key, int defv) +{ + const char *v = fs_pb_get_header(frame, key); + return v ? atoi(v) : defv; +} + +static OPERATE_RET handle_data_pb_frame(fs_ws_conn_t *conn, const fs_pb_frame_t *frame) +{ + const char *type = fs_pb_get_header(frame, "type"); + const char *msg_id = fs_pb_get_header(frame, "message_id"); + uint32_t sum = (uint32_t)pb_header_int(frame, "sum", 1); + uint32_t seq = (uint32_t)pb_header_int(frame, "seq", 0); + + uint8_t *payload = NULL; + size_t payload_len = 0; + OPERATE_RET rt = fs_frag_merge(msg_id, sum, seq, frame->payload ? frame->payload : (const uint8_t *)"", + frame->payload_len, &payload, &payload_len); + if (rt == OPRT_RESOURCE_NOT_READY) { + return OPRT_OK; + } + if (rt != OPRT_OK) { + return rt; + } + + /* Feishu requires ACK within 3s or it will resend. Send ACK before business logic to avoid timeout from HTTP etc. in handle_event_payload. */ + static const char ack_ok[] = "{\"code\":200}"; + fs_pb_frame_t *ack = fs_pb_frame_new(); + if (!ack) { + im_free(payload); + return OPRT_MALLOC_FAILED; + } + ack->seq_id = frame->seq_id; + ack->log_id = frame->log_id; + ack->service = frame->service; + ack->method = frame->method; + ack->header_count = frame->header_count; + if (ack->header_count > FS_WS_FRAME_MAX_HEADERS) { + ack->header_count = FS_WS_FRAME_MAX_HEADERS; + } + for (size_t i = 0; i < ack->header_count; i++) { + im_safe_copy(ack->headers[i].key, sizeof(ack->headers[i].key), frame->headers[i].key); + im_safe_copy(ack->headers[i].value, sizeof(ack->headers[i].value), frame->headers[i].value); + } + ack->payload = (uint8_t *)ack_ok; + ack->payload_len = sizeof(ack_ok) - 1; + rt = send_pb_frame(conn, ack); + ack->payload = NULL; + ack->payload_len = 0; + fs_pb_frame_delete(ack); + if (rt != OPRT_OK) { + im_free(payload); + return rt; + } + + if (payload && payload_len > 0) { + if (type && strcmp(type, "event") == 0) { + handle_event_payload(payload, payload_len); + } else { + IM_LOGI(TAG, "[feishu] ws data frame type=%s (only type=event handled for messages)", + type ? type : "(null)"); + } + } + im_free(payload); + return OPRT_OK; +} + +/* -------- ws main loop -------- */ + +static void feishu_ws_task(void *arg) +{ + (void)arg; + + while (1) { + if (s_app_id[0] == '\0' || s_app_secret[0] == '\0') { + tal_system_sleep(3000); + continue; + } + + OPERATE_RET rt = ensure_tenant_token(); + if (rt != OPRT_OK) { + IM_LOGW(TAG, "ensure tenant credential failed rt=%d", rt); + tal_system_sleep(FS_WS_DEFAULT_RECONNECT_MS); + continue; + } + + fs_ws_conf_t conf; + rt = fs_fetch_ws_conf(&conf); + if (rt != OPRT_OK) { + IM_LOGW(TAG, "fetch ws endpoint failed rt=%d", rt); + tal_system_sleep(FS_WS_DEFAULT_RECONNECT_MS); + continue; + } + + uint32_t reconnect_ms = conf.reconnect_interval_ms ? conf.reconnect_interval_ms : FS_WS_DEFAULT_RECONNECT_MS; + + char ws_host[128] = {0}; + char ws_path[640] = {0}; + uint16_t ws_port = 443; + int service_id = 0; + + rt = fs_parse_ws_url(conf.url, ws_host, sizeof(ws_host), &ws_port, ws_path, sizeof(ws_path), &service_id); + if (rt != OPRT_OK) { + IM_LOGW(TAG, "parse ws endpoint failed rt=%d", rt); + tal_system_sleep(reconnect_ms); + continue; + } + + fs_ws_conn_t *conn = im_calloc(1, sizeof(fs_ws_conn_t)); + if (!conn) { + tal_system_sleep(reconnect_ms); + continue; + } + + rt = fs_conn_open(conn, ws_host, ws_port, FS_HTTP_TIMEOUT_MS); + if (rt != OPRT_OK) { + IM_LOGW(TAG, "feishu ws connect failed rt=%d", rt); + im_free(conn); + tal_system_sleep(reconnect_ms); + continue; + } + + rt = fs_ws_handshake(conn, ws_host, ws_path); + if (rt != OPRT_OK) { + IM_LOGW(TAG, "feishu ws handshake failed rt=%d", rt); + fs_conn_close(conn); + im_free(conn); + tal_system_sleep(reconnect_ms); + continue; + } + + uint32_t ping_interval_ms = conf.ping_interval_ms ? conf.ping_interval_ms : FS_WS_DEFAULT_PING_MS; + uint32_t next_ping_ms = tal_system_get_millisecond() + ping_interval_ms; + + IM_LOGI(TAG, "feishu ws online!"); + + while (1) { + uint32_t now = tal_system_get_millisecond(); + if ((int32_t)(now - next_ping_ms) >= 0) { + OPERATE_RET ping_rt = send_ping_frame(conn, service_id); + if (ping_rt != OPRT_OK) { + IM_LOGW(TAG, "feishu ws ping failed rt=%d", ping_rt); + break; + } + next_ping_ms = now + ping_interval_ms; + } + + uint8_t opcode = 0; + uint8_t *payload = NULL; + size_t payload_len = 0; + rt = fs_ws_poll_frame(conn, FS_WS_POLL_WAIT_MS, &opcode, &payload, &payload_len); + if (rt == OPRT_RESOURCE_NOT_READY) { + continue; + } + if (rt != OPRT_OK) { + im_free(payload); + IM_LOGW(TAG, "feishu ws poll failed rt=%d", rt); + break; + } + + if (opcode == 0x2 && payload && payload_len > 0) { + fs_pb_frame_t *pb = fs_pb_frame_new(); + if (!pb) { + im_free(payload); + break; + } + + if (fs_pb_parse_frame(payload, payload_len, pb)) { + if (pb->method == 0) { + handle_control_pb_frame(pb, &ping_interval_ms); + } else if (pb->method == 1) { + OPERATE_RET hrt = handle_data_pb_frame(conn, pb); + if (hrt != OPRT_OK) { + fs_pb_frame_delete(pb); + im_free(payload); + break; + } + } + } + fs_pb_frame_delete(pb); + } else if (opcode == 0x8) { + im_free(payload); + IM_LOGW(TAG, "feishu ws closed by peer"); + break; + } else if (opcode == 0x9) { + (void)fs_ws_send_frame(conn, 0xA, payload, payload_len); + } + + im_free(payload); + } + + fs_conn_close(conn); + im_free(conn); + tal_system_sleep(reconnect_ms); + } +} + +/* -------- public APIs -------- */ + +OPERATE_RET feishu_bot_init(void) +{ + if (IM_SECRET_FS_APP_ID[0] != '\0') { + im_safe_copy(s_app_id, sizeof(s_app_id), IM_SECRET_FS_APP_ID); + } + if (IM_SECRET_FS_APP_SECRET[0] != '\0') { + im_safe_copy(s_app_secret, sizeof(s_app_secret), IM_SECRET_FS_APP_SECRET); + } +#ifdef IM_SECRET_FS_ALLOW_FROM + if (IM_SECRET_FS_ALLOW_FROM[0] != '\0') { + im_safe_copy(s_allow_from, sizeof(s_allow_from), IM_SECRET_FS_ALLOW_FROM); + } +#endif + + char tmp[512] = {0}; + if (im_kv_get_string(IM_NVS_FS, IM_NVS_KEY_FS_APP_ID, tmp, sizeof(tmp)) == OPRT_OK && tmp[0] != '\0') { + im_safe_copy(s_app_id, sizeof(s_app_id), tmp); + } + + memset(tmp, 0, sizeof(tmp)); + if (im_kv_get_string(IM_NVS_FS, IM_NVS_KEY_FS_APP_SECRET, tmp, sizeof(tmp)) == OPRT_OK && tmp[0] != '\0') { + im_safe_copy(s_app_secret, sizeof(s_app_secret), tmp); + } + +#ifdef IM_NVS_KEY_FS_ALLOW_FROM + memset(tmp, 0, sizeof(tmp)); + if (im_kv_get_string(IM_NVS_FS, IM_NVS_KEY_FS_ALLOW_FROM, tmp, sizeof(tmp)) == OPRT_OK && tmp[0] != '\0') { + im_safe_copy(s_allow_from, sizeof(s_allow_from), tmp); + } +#endif + + s_tenant_token[0] = '\0'; + s_tenant_expire_ms = 0; + fs_frag_clear(); + + IM_LOGI(TAG, "feishu init credentials=%s allow_from=%s", + (s_app_id[0] && s_app_secret[0]) ? "configured" : "empty", s_allow_from[0] ? "configured" : "open"); + + return OPRT_OK; +} + +OPERATE_RET feishu_bot_start(void) +{ + if (s_app_id[0] == '\0' || s_app_secret[0] == '\0') { + return OPRT_NOT_FOUND; + } + + OPERATE_RET rt = ensure_tenant_token(); + if (rt != OPRT_OK) { + IM_LOGW(TAG, "initial tenant credential fetch failed rt=%d", rt); + return rt; + } + + if (s_ws_thread) { + return OPRT_OK; + } + + THREAD_CFG_T cfg = {0}; + cfg.stackDepth = IM_FS_POLL_STACK; + cfg.priority = THREAD_PRIO_1; + cfg.thrdname = "im_fs_ws"; + + rt = tal_thread_create_and_start(&s_ws_thread, NULL, NULL, feishu_ws_task, NULL, &cfg); + if (rt != OPRT_OK) { + s_ws_thread = NULL; + return rt; + } + + IM_LOGI(TAG, "feishu ws service started"); + return OPRT_OK; +} + +OPERATE_RET feishu_send_message(const char *chat_id, const char *text) +{ + if (!chat_id || !text) { + return OPRT_INVALID_PARM; + } + if (s_app_id[0] == '\0' || s_app_secret[0] == '\0') { + return OPRT_NOT_FOUND; + } + + OPERATE_RET rt = ensure_tenant_token(); + if (rt != OPRT_OK) { + return rt; + } + + const char *rid_type = (strncmp(chat_id, "oc_", 3) == 0) ? "chat_id" : "open_id"; + + cJSON *content = cJSON_CreateObject(); + if (!content) { + return OPRT_MALLOC_FAILED; + } + cJSON_AddStringToObject(content, "text", text); + char *content_json = cJSON_PrintUnformatted(content); + cJSON_Delete(content); + if (!content_json) { + return OPRT_MALLOC_FAILED; + } + + cJSON *body = cJSON_CreateObject(); + if (!body) { + cJSON_free(content_json); + return OPRT_MALLOC_FAILED; + } + cJSON_AddStringToObject(body, "receive_id", chat_id); + cJSON_AddStringToObject(body, "msg_type", "text"); + cJSON_AddStringToObject(body, "content", content_json); + char *body_json = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + cJSON_free(content_json); + if (!body_json) { + return OPRT_MALLOC_FAILED; + } + + char path[192] = {0}; + snprintf(path, sizeof(path), "/open-apis/im/v1/messages?receive_id_type=%s", rid_type); + + char *resp = im_malloc(FS_HTTP_RESP_BUF_SIZE); + if (!resp) { + cJSON_free(body_json); + return OPRT_MALLOC_FAILED; + } + memset(resp, 0, FS_HTTP_RESP_BUF_SIZE); + + uint16_t status = 0; + rt = fs_http_call(FS_HOST, path, "POST", body_json, s_tenant_token, resp, FS_HTTP_RESP_BUF_SIZE, &status); + + const char *err_msg = NULL; + bool ok = (rt == OPRT_OK && status == 200 && fs_response_ok(resp, &err_msg)); + if (!ok) { + if (refresh_tenant_token() == OPRT_OK) { + memset(resp, 0, FS_HTTP_RESP_BUF_SIZE); + status = 0; + rt = fs_http_call(FS_HOST, path, "POST", body_json, s_tenant_token, resp, FS_HTTP_RESP_BUF_SIZE, &status); + ok = (rt == OPRT_OK && status == 200 && fs_response_ok(resp, &err_msg)); + } + } + + cJSON_free(body_json); + + if (!ok) { + IM_LOGE(TAG, "feishu send failed rid=%s type=%s rt=%d http=%u", chat_id, rid_type, rt, status); + } else { + IM_LOGI(TAG, "feishu send success rid=%s type=%s bytes=%u", chat_id, rid_type, (unsigned)strlen(text)); + } + im_free(resp); + + return ok ? OPRT_OK : OPRT_COM_ERROR; +} + +OPERATE_RET feishu_set_app_id(const char *app_id) +{ + if (!app_id) { + return OPRT_INVALID_PARM; + } + im_safe_copy(s_app_id, sizeof(s_app_id), app_id); + s_tenant_token[0] = '\0'; + s_tenant_expire_ms = 0; + return im_kv_set_string(IM_NVS_FS, IM_NVS_KEY_FS_APP_ID, app_id); +} + +OPERATE_RET feishu_set_app_secret(const char *app_secret) +{ + if (!app_secret) { + return OPRT_INVALID_PARM; + } + im_safe_copy(s_app_secret, sizeof(s_app_secret), app_secret); + s_tenant_token[0] = '\0'; + s_tenant_expire_ms = 0; + return im_kv_set_string(IM_NVS_FS, IM_NVS_KEY_FS_APP_SECRET, app_secret); +} + +OPERATE_RET feishu_set_allow_from(const char *allow_from_csv) +{ + if (!allow_from_csv) { + return OPRT_INVALID_PARM; + } + + im_safe_copy(s_allow_from, sizeof(s_allow_from), allow_from_csv); +#ifdef IM_NVS_KEY_FS_ALLOW_FROM + return im_kv_set_string(IM_NVS_FS, IM_NVS_KEY_FS_ALLOW_FROM, allow_from_csv); +#else + return OPRT_OK; +#endif +} diff --git a/examples/messaging/echo_bot/IM/channels/feishu_bot.h b/examples/messaging/echo_bot/IM/channels/feishu_bot.h new file mode 100644 index 000000000..1f4b95545 --- /dev/null +++ b/examples/messaging/echo_bot/IM/channels/feishu_bot.h @@ -0,0 +1,13 @@ +#ifndef __FEISHU_BOT_H__ +#define __FEISHU_BOT_H__ + +#include "im_platform.h" + +OPERATE_RET feishu_bot_init(void); +OPERATE_RET feishu_bot_start(void); +OPERATE_RET feishu_send_message(const char *chat_id, const char *text); +OPERATE_RET feishu_set_app_id(const char *app_id); +OPERATE_RET feishu_set_app_secret(const char *app_secret); +OPERATE_RET feishu_set_allow_from(const char *allow_from_csv); + +#endif /* __FEISHU_BOT_H__ */ diff --git a/examples/messaging/echo_bot/IM/channels/telegram_bot.c b/examples/messaging/echo_bot/IM/channels/telegram_bot.c new file mode 100644 index 000000000..fb70c9ba9 --- /dev/null +++ b/examples/messaging/echo_bot/IM/channels/telegram_bot.c @@ -0,0 +1,668 @@ +#include "channels/telegram_bot.h" + +#include "bus/message_bus.h" +#include "cJSON.h" +#include "http_client_interface.h" +#include "im_config.h" +#include "proxy/http_proxy.h" +#include "certs/tls_cert_bundle.h" +#include "im_utils.h" + +#include + +static const char *TAG = "telegram"; +static char s_bot_token[128] = {0}; +static int64_t s_update_offset = 0; +static int64_t s_last_saved_offset = -1; +static uint32_t s_last_offset_save_ms = 0; +static THREAD_HANDLE s_poll_thread = NULL; +static uint8_t *s_tg_cacert = NULL; +static size_t s_tg_cacert_len = 0; +static bool s_tg_tls_no_verify = false; + +#define TG_HOST IM_TG_API_HOST +#define TG_HTTP_TIMEOUT_MS ((IM_TG_POLL_TIMEOUT_S + 5) * 1000) +#define TG_HTTP_RESP_BUF_SIZE (16 * 1024) +#define TG_PROXY_READ_SLICE_MS 1000 +#define TG_PROXY_READ_TOTAL_MS ((IM_TG_POLL_TIMEOUT_S + 20) * 1000) +#define TG_PROXY_LONGPOLL_TIMEOUT_S 20 +#define TG_OFFSET_NVS_KEY "update_offset" +#define TG_DEDUP_CACHE_SIZE 64 +#define TG_OFFSET_SAVE_INTERVAL_MS (5 * 1000) +#define TG_OFFSET_SAVE_STEP 10 + +static uint64_t s_seen_msg_keys[TG_DEDUP_CACHE_SIZE] = {0}; +static size_t s_seen_msg_idx = 0; + +static uint64_t make_msg_key(const char *chat_id, int msg_id) +{ + uint64_t h = im_fnv1a64(chat_id); + return (h << 16) ^ (uint64_t)(msg_id & 0xFFFF) ^ ((uint64_t)msg_id << 32); +} + +static bool seen_msg_contains(uint64_t key) +{ + for (size_t i = 0; i < TG_DEDUP_CACHE_SIZE; i++) { + if (s_seen_msg_keys[i] == key) { + return true; + } + } + return false; +} + +static void seen_msg_insert(uint64_t key) +{ + s_seen_msg_keys[s_seen_msg_idx] = key; + s_seen_msg_idx = (s_seen_msg_idx + 1) % TG_DEDUP_CACHE_SIZE; +} + +static void save_update_offset_if_needed(bool force) +{ + if (s_update_offset <= 0) { + return; + } + + uint32_t now_ms = tal_system_get_millisecond(); + bool should_save = force; + if (!should_save && s_last_saved_offset >= 0) { + if ((s_update_offset - s_last_saved_offset) >= TG_OFFSET_SAVE_STEP) { + should_save = true; + } else if ((int)(now_ms - s_last_offset_save_ms) >= TG_OFFSET_SAVE_INTERVAL_MS) { + should_save = true; + } + } else if (!should_save) { + should_save = true; + } + + if (!should_save) { + return; + } + + char offset_buf[24] = {0}; + snprintf(offset_buf, sizeof(offset_buf), "%lld", (long long)s_update_offset); + if (im_kv_set_string(IM_NVS_TG, TG_OFFSET_NVS_KEY, offset_buf) == OPRT_OK) { + s_last_saved_offset = s_update_offset; + s_last_offset_save_ms = now_ms; + } +} + +static OPERATE_RET ensure_tg_cert(void) +{ + if (s_tg_cacert && s_tg_cacert_len > 0) { + s_tg_tls_no_verify = false; + return OPRT_OK; + } + + OPERATE_RET rt = im_tls_query_domain_certs(TG_HOST, &s_tg_cacert, &s_tg_cacert_len); + if (rt != OPRT_OK || !s_tg_cacert || s_tg_cacert_len == 0) { + if (s_tg_cacert) { + im_free(s_tg_cacert); + } + s_tg_cacert = NULL; + s_tg_cacert_len = 0; + s_tg_tls_no_verify = true; + IM_LOGD(TAG, "cert unavailable for %s, fallback to TLS no-verify mode", TG_HOST); + return OPRT_OK; + } + + s_tg_tls_no_verify = false; + return OPRT_OK; +} + +static OPERATE_RET tg_http_call_via_proxy(const char *path, const char *post_data, char *resp_buf, size_t resp_buf_size, + uint16_t *status_code) +{ + proxy_conn_t *conn = proxy_conn_open(TG_HOST, 443, TG_HTTP_TIMEOUT_MS); + if (!conn) { + IM_LOGE(TAG, "proxy open failed host=%s", TG_HOST); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + int body_len = post_data ? (int)strlen(post_data) : 0; + char req_header[768] = {0}; + int req_len = 0; + if (post_data) { + req_len = snprintf(req_header, sizeof(req_header), + "POST %s HTTP/1.1\r\n" + "Host: %s\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %d\r\n" + "Connection: close\r\n\r\n", + path, TG_HOST, body_len); + } else { + req_len = snprintf(req_header, sizeof(req_header), + "GET %s HTTP/1.1\r\n" + "Host: %s\r\n" + "Connection: close\r\n\r\n", + path, TG_HOST); + } + if (req_len <= 0 || req_len >= (int)sizeof(req_header)) { + proxy_conn_close(conn); + return OPRT_BUFFER_NOT_ENOUGH; + } + + if (proxy_conn_write(conn, req_header, req_len) != req_len) { + proxy_conn_close(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + if (body_len > 0 && proxy_conn_write(conn, post_data, body_len) != body_len) { + proxy_conn_close(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + size_t raw_cap = 4096; + size_t raw_len = 0; + char *raw = im_calloc(1, raw_cap); + if (!raw) { + proxy_conn_close(conn); + return OPRT_MALLOC_FAILED; + } + + uint32_t wait_begin_ms = tal_system_get_millisecond(); + while (1) { + if (raw_len + 1024 >= raw_cap) { + size_t new_cap = raw_cap * 2; + char *tmp = im_realloc(raw, new_cap); + if (!tmp) { + im_free(raw); + proxy_conn_close(conn); + return OPRT_MALLOC_FAILED; + } + raw = tmp; + raw_cap = new_cap; + } + + int n = proxy_conn_read(conn, raw + raw_len, (int)(raw_cap - raw_len - 1), TG_PROXY_READ_SLICE_MS); + if (n == OPRT_RESOURCE_NOT_READY) { + if ((int)(tal_system_get_millisecond() - wait_begin_ms) >= TG_PROXY_READ_TOTAL_MS) { + im_free(raw); + proxy_conn_close(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + continue; + } + if (n < 0) { + if (raw_len > 0) { + IM_LOGW(TAG, "proxy read closed with rt=%d, parse partial response len=%u", n, (unsigned)raw_len); + break; + } + im_free(raw); + proxy_conn_close(conn); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + if (n == 0) { + break; + } + + raw_len += (size_t)n; + raw[raw_len] = '\0'; + wait_begin_ms = tal_system_get_millisecond(); + } + proxy_conn_close(conn); + + if (raw_len == 0) { + im_free(raw); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + if (status_code) { + *status_code = im_parse_http_status(raw); + } + + resp_buf[0] = '\0'; + char *body = strstr(raw, "\r\n\r\n"); + if (body) { + body += 4; + size_t body_len_sz = strlen(body); + size_t copy = (body_len_sz < resp_buf_size - 1) ? body_len_sz : (resp_buf_size - 1); + memcpy(resp_buf, body, copy); + resp_buf[copy] = '\0'; + } + im_free(raw); + + return OPRT_OK; +} + +static OPERATE_RET tg_http_call_direct(const char *path, const char *post_data, char *resp_buf, size_t resp_buf_size, + uint16_t *status_code) +{ + OPERATE_RET rt = ensure_tg_cert(); + if (rt != OPRT_OK) { + return rt; + } + + http_client_header_t headers[1] = {0}; + uint8_t header_count = 0; + if (post_data) { + headers[header_count++] = (http_client_header_t){ + .key = "Content-Type", + .value = "application/json", + }; + } + + http_client_response_t response = {0}; + http_client_status_t http_rt = http_client_request( + &(const http_client_request_t){ + .cacert = s_tg_cacert, + .cacert_len = s_tg_cacert_len, + .tls_no_verify = s_tg_tls_no_verify, + .host = TG_HOST, + .port = 443, + .method = post_data ? "POST" : "GET", + .path = path, + .headers = headers, + .headers_count = header_count, + .body = (const uint8_t *)(post_data ? post_data : ""), + .body_length = post_data ? strlen(post_data) : 0, + .timeout_ms = TG_HTTP_TIMEOUT_MS, + }, + &response); + if (http_rt != HTTP_CLIENT_SUCCESS) { + IM_LOGE(TAG, "http request failed: %d", http_rt); + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + if (status_code) { + *status_code = response.status_code; + } + + resp_buf[0] = '\0'; + if (response.body && response.body_length > 0) { + size_t copy = (response.body_length < resp_buf_size - 1) ? response.body_length : (resp_buf_size - 1); + memcpy(resp_buf, response.body, copy); + resp_buf[copy] = '\0'; + } + + http_client_free(&response); + return OPRT_OK; +} + +static OPERATE_RET tg_http_call(const char *path, const char *post_data, char *resp_buf, size_t resp_buf_size, + uint16_t *status_code) +{ + if (!path || !resp_buf || resp_buf_size == 0) { + return OPRT_INVALID_PARM; + } + + if (http_proxy_is_enabled()) { + return tg_http_call_via_proxy(path, post_data, resp_buf, resp_buf_size, status_code); + } + + return tg_http_call_direct(path, post_data, resp_buf, resp_buf_size, status_code); +} + +static bool tg_response_is_ok(const char *json_str, const char **out_desc) +{ + if (out_desc) { + *out_desc = NULL; + } + if (!json_str || json_str[0] == '\0') { + return false; + } + + bool ok = false; + cJSON *root = cJSON_Parse(json_str); + if (root) { + cJSON *ok_field = cJSON_GetObjectItem(root, "ok"); + ok = cJSON_IsTrue(ok_field); + if (!ok && out_desc) { + cJSON *desc = cJSON_GetObjectItem(root, "description"); + if (cJSON_IsString(desc) && desc->valuestring) { + *out_desc = desc->valuestring; + } + } + cJSON_Delete(root); + return ok; + } + + if (strstr(json_str, "\"ok\":true") != NULL) { + return true; + } + + return false; +} + +static void process_updates(const char *json_str) +{ + cJSON *root = cJSON_Parse(json_str); + if (!root) { + return; + } + + cJSON *ok = cJSON_GetObjectItem(root, "ok"); + cJSON *result = cJSON_GetObjectItem(root, "result"); + if (!cJSON_IsTrue(ok) || !cJSON_IsArray(result)) { + cJSON_Delete(root); + return; + } + + cJSON *update = NULL; + cJSON_ArrayForEach(update, result) + { + int64_t uid = -1; + cJSON *update_id = cJSON_GetObjectItem(update, "update_id"); + if (cJSON_IsNumber(update_id)) { + uid = (int64_t)update_id->valuedouble; + } + if (uid >= 0) { + if (uid < s_update_offset) { + continue; + } + s_update_offset = uid + 1; + save_update_offset_if_needed(false); + } + + cJSON *message = cJSON_GetObjectItem(update, "message"); + cJSON *chat = message ? cJSON_GetObjectItem(message, "chat") : NULL; + cJSON *chat_id = chat ? cJSON_GetObjectItem(chat, "id") : NULL; + if (!message || !chat_id) { + continue; + } + + char chat_id_str[32] = {0}; + if (cJSON_IsString(chat_id) && chat_id->valuestring) { + im_safe_copy(chat_id_str, sizeof(chat_id_str), chat_id->valuestring); + } else if (cJSON_IsNumber(chat_id)) { + snprintf(chat_id_str, sizeof(chat_id_str), "%.0f", chat_id->valuedouble); + } else { + continue; + } + + int msg_id_val = -1; + cJSON *message_id = cJSON_GetObjectItem(message, "message_id"); + if (cJSON_IsNumber(message_id)) { + msg_id_val = (int)message_id->valuedouble; + } + if (msg_id_val >= 0) { + uint64_t msg_key = make_msg_key(chat_id_str, msg_id_val); + if (seen_msg_contains(msg_key)) { + IM_LOGW(TAG, "drop duplicate update_id=%" PRId64 " chat=%s message_id=%d", uid, chat_id_str, + msg_id_val); + continue; + } + seen_msg_insert(msg_key); + } + + cJSON *text = cJSON_GetObjectItem(message, "text"); + if (cJSON_IsString(text) && text->valuestring) { + IM_LOGI(TAG, "rx inbound_text channel=%s chat=%s len=%u", IM_CHAN_TELEGRAM, chat_id_str, + (unsigned)strlen(text->valuestring)); + } + + cJSON *document = cJSON_GetObjectItem(message, "document"); + if (cJSON_IsObject(document)) { + const char *file_name = im_json_str(document, "file_name", ""); + const char *mime_type = im_json_str(document, "mime_type", ""); + uint32_t file_size = im_json_uint(document, "file_size", 0); + IM_LOGI(TAG, "rx document chat=%s name=%s mime=%s size=%u", chat_id_str, file_name, mime_type, + (unsigned)file_size); + } + + if (!cJSON_IsString(text) || !text->valuestring) { + continue; + } + + im_msg_t msg = {0}; + strncpy(msg.channel, IM_CHAN_TELEGRAM, sizeof(msg.channel) - 1); + strncpy(msg.chat_id, chat_id_str, sizeof(msg.chat_id) - 1); + msg.content = im_strdup(text->valuestring); + if (!msg.content) { + continue; + } + + OPERATE_RET rt = message_bus_push_inbound(&msg); + if (rt != OPRT_OK) { + IM_LOGW(TAG, "push inbound failed rt=%d", rt); + im_free(msg.content); + } + } + + save_update_offset_if_needed(false); + cJSON_Delete(root); +} + +static void telegram_poll_task(void *arg) +{ + (void)arg; + uint32_t fail_delay_ms = IM_TG_FAIL_BASE_MS; + char *resp = im_malloc(TG_HTTP_RESP_BUF_SIZE); + if (!resp) { + IM_LOGE(TAG, "alloc telegram poll resp buffer failed"); + return; + } + + IM_LOGI(TAG, "telegram poll task started host=%s", TG_HOST); + + while (1) { + if (s_bot_token[0] == '\0') { + fail_delay_ms = IM_TG_FAIL_BASE_MS; + tal_system_sleep(3000); + continue; + } + + int poll_timeout_s = IM_TG_POLL_TIMEOUT_S; + if (http_proxy_is_enabled() && poll_timeout_s > TG_PROXY_LONGPOLL_TIMEOUT_S) { + poll_timeout_s = TG_PROXY_LONGPOLL_TIMEOUT_S; + } + + char path[320] = {0}; + int n = snprintf(path, sizeof(path), "/bot%s/getUpdates?offset=%lld&timeout=%d", s_bot_token, + (long long)s_update_offset, poll_timeout_s); + if (n <= 0 || (size_t)n >= sizeof(path)) { + IM_LOGE(TAG, "getUpdates path too long"); + fail_delay_ms = IM_TG_FAIL_BASE_MS; + tal_system_sleep(3000); + continue; + } + + memset(resp, 0, TG_HTTP_RESP_BUF_SIZE); + uint16_t status = 0; + OPERATE_RET rt = tg_http_call(path, NULL, resp, TG_HTTP_RESP_BUF_SIZE, &status); + if (rt != OPRT_OK || status != 200) { + IM_LOGD(TAG, "getUpdates failed rt=%d http=%u retry_in_ms=%u", rt, status, fail_delay_ms); + tal_system_sleep(fail_delay_ms); + if (fail_delay_ms < IM_TG_FAIL_MAX_MS) { + uint32_t next_delay = fail_delay_ms << 1; + fail_delay_ms = (next_delay > IM_TG_FAIL_MAX_MS) ? IM_TG_FAIL_MAX_MS : next_delay; + } + continue; + } + + fail_delay_ms = IM_TG_FAIL_BASE_MS; + process_updates(resp); + } + + im_free(resp); +} + +OPERATE_RET telegram_bot_init(void) +{ + if (IM_SECRET_TG_TOKEN[0] != '\0') { + im_safe_copy(s_bot_token, sizeof(s_bot_token), IM_SECRET_TG_TOKEN); + } + + char tmp[128] = {0}; + if (im_kv_get_string(IM_NVS_TG, IM_NVS_KEY_TG_TOKEN, tmp, sizeof(tmp)) == OPRT_OK) { + im_safe_copy(s_bot_token, sizeof(s_bot_token), tmp); + } + + memset(tmp, 0, sizeof(tmp)); + if (im_kv_get_string(IM_NVS_TG, TG_OFFSET_NVS_KEY, tmp, sizeof(tmp)) == OPRT_OK && tmp[0] != '\0') { + long long offset = strtoll(tmp, NULL, 10); + if (offset > 0) { + s_update_offset = offset; + s_last_saved_offset = offset; + IM_LOGI(TAG, "loaded telegram update offset: %lld", offset); + } + } + + IM_LOGI(TAG, "telegram init credential=%s", s_bot_token[0] ? "configured" : "empty"); + return OPRT_OK; +} + +OPERATE_RET telegram_bot_start(void) +{ + if (s_bot_token[0] == '\0') { + return OPRT_NOT_FOUND; + } + + if (s_poll_thread) { + return OPRT_OK; + } + + THREAD_CFG_T cfg = {0}; + cfg.stackDepth = IM_TG_POLL_STACK; + cfg.priority = THREAD_PRIO_1; + cfg.thrdname = "im_tg_poll"; + + OPERATE_RET rt = tal_thread_create_and_start(&s_poll_thread, NULL, NULL, telegram_poll_task, NULL, &cfg); + if (rt != OPRT_OK) { + IM_LOGE(TAG, "create poll thread failed: %d", rt); + return rt; + } + + return OPRT_OK; +} + +OPERATE_RET telegram_send_message(const char *chat_id, const char *text) +{ + if (!chat_id || !text) { + return OPRT_INVALID_PARM; + } + + if (s_bot_token[0] == '\0') { + return OPRT_NOT_FOUND; + } + + size_t text_len = strlen(text); + size_t offset = 0; + bool all_ok = true; + + while (offset < text_len || (text_len == 0 && offset == 0)) { + size_t chunk = text_len - offset; + if (chunk > IM_TG_MAX_MSG_LEN) { + chunk = IM_TG_MAX_MSG_LEN; + } + if (text_len == 0) { + chunk = 0; + } + + char *segment = im_calloc(1, chunk + 1); + if (!segment) { + return OPRT_MALLOC_FAILED; + } + if (chunk > 0) { + memcpy(segment, text + offset, chunk); + } + segment[chunk] = '\0'; + + cJSON *body = cJSON_CreateObject(); + if (!body) { + im_free(segment); + return OPRT_MALLOC_FAILED; + } + cJSON_AddStringToObject(body, "chat_id", chat_id); + cJSON_AddStringToObject(body, "text", segment); + cJSON_AddStringToObject(body, "parse_mode", "Markdown"); + char *json = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + + char path[256] = {0}; + int n = snprintf(path, sizeof(path), "/bot%s/sendMessage", s_bot_token); + if (n <= 0 || (size_t)n >= sizeof(path)) { + im_free(segment); + cJSON_free(json); + return OPRT_BUFFER_NOT_ENOUGH; + } + + char *resp = im_malloc(TG_HTTP_RESP_BUF_SIZE); + if (!resp) { + im_free(segment); + cJSON_free(json); + return OPRT_MALLOC_FAILED; + } + memset(resp, 0, TG_HTTP_RESP_BUF_SIZE); + + uint16_t status = 0; + OPERATE_RET rt = OPRT_MALLOC_FAILED; + bool sent_ok = false; + bool markdown_failed = false; + const char *desc = NULL; + + if (json) { + IM_LOGD(TAG, "send telegram chunk bytes=%u", (unsigned)chunk); + rt = tg_http_call(path, json, resp, TG_HTTP_RESP_BUF_SIZE, &status); + if (rt == OPRT_OK && status == 200) { + sent_ok = tg_response_is_ok(resp, &desc); + if (!sent_ok) { + markdown_failed = true; + IM_LOGI(TAG, "markdown rejected rt=%d status=%u", rt, status); + } + } + } + cJSON_free(json); + + if (!sent_ok) { + cJSON *body2 = cJSON_CreateObject(); + if (!body2) { + im_free(resp); + im_free(segment); + return OPRT_MALLOC_FAILED; + } + cJSON_AddStringToObject(body2, "chat_id", chat_id); + cJSON_AddStringToObject(body2, "text", segment); + char *json2 = cJSON_PrintUnformatted(body2); + cJSON_Delete(body2); + + if (!json2) { + im_free(resp); + im_free(segment); + return OPRT_MALLOC_FAILED; + } + + memset(resp, 0, TG_HTTP_RESP_BUF_SIZE); + status = 0; + desc = NULL; + rt = tg_http_call(path, json2, resp, TG_HTTP_RESP_BUF_SIZE, &status); + cJSON_free(json2); + if (rt == OPRT_OK && status == 200) { + sent_ok = tg_response_is_ok(resp, &desc); + } + if (!sent_ok) { + IM_LOGE(TAG, "plain send failed rt=%d status=%u", rt, status); + all_ok = false; + } else if (markdown_failed) { + IM_LOGI(TAG, "plain-text fallback succeeded"); + } + } + + if (sent_ok) { + IM_LOGD(TAG, "telegram send success bytes=%u", (unsigned)chunk); + } else { + all_ok = false; + } + + im_free(resp); + im_free(segment); + if (text_len == 0) { + break; + } + offset += chunk; + } + + return all_ok ? OPRT_OK : OPRT_COM_ERROR; +} + +OPERATE_RET telegram_set_token(const char *token) +{ + if (!token) { + return OPRT_INVALID_PARM; + } + + im_safe_copy(s_bot_token, sizeof(s_bot_token), token); + s_update_offset = 0; + s_last_saved_offset = -1; + s_last_offset_save_ms = 0; + (void)im_kv_del(IM_NVS_TG, TG_OFFSET_NVS_KEY); + return im_kv_set_string(IM_NVS_TG, IM_NVS_KEY_TG_TOKEN, token); +} diff --git a/examples/messaging/echo_bot/IM/channels/telegram_bot.h b/examples/messaging/echo_bot/IM/channels/telegram_bot.h new file mode 100644 index 000000000..a86ded7fb --- /dev/null +++ b/examples/messaging/echo_bot/IM/channels/telegram_bot.h @@ -0,0 +1,11 @@ +#ifndef __TELEGRAM_BOT_H__ +#define __TELEGRAM_BOT_H__ + +#include "im_platform.h" + +OPERATE_RET telegram_bot_init(void); +OPERATE_RET telegram_bot_start(void); +OPERATE_RET telegram_send_message(const char *chat_id, const char *text); +OPERATE_RET telegram_set_token(const char *token); + +#endif /* __TELEGRAM_BOT_H__ */ diff --git a/examples/messaging/echo_bot/IM/im_api.h b/examples/messaging/echo_bot/IM/im_api.h new file mode 100644 index 000000000..2b1b4239f --- /dev/null +++ b/examples/messaging/echo_bot/IM/im_api.h @@ -0,0 +1,16 @@ +#ifndef __IM_API_H__ +#define __IM_API_H__ + +/* + * IM component public API — include this single header to access everything. + */ + +#include "im_platform.h" +#include "im_config.h" +#include "bus/message_bus.h" +#include "channels/telegram_bot.h" +#include "channels/discord_bot.h" +#include "channels/feishu_bot.h" +#include "proxy/http_proxy.h" + +#endif /* __IM_API_H__ */ diff --git a/examples/messaging/echo_bot/IM/im_config.h b/examples/messaging/echo_bot/IM/im_config.h new file mode 100644 index 000000000..76c26739e --- /dev/null +++ b/examples/messaging/echo_bot/IM/im_config.h @@ -0,0 +1,113 @@ +#ifndef __IM_CONFIG_H__ +#define __IM_CONFIG_H__ + +/* + * IM component compile-time defaults. + * Override any value by defining it in im_secrets.h (not tracked by VCS). + */ +#if __has_include("im_secrets.h") +#include "im_secrets.h" +#endif + +/* ---- Secrets (override in im_secrets.h) ---- */ + +#ifndef IM_SECRET_TG_TOKEN +#define IM_SECRET_TG_TOKEN "" +#endif +#ifndef IM_SECRET_DC_TOKEN +#define IM_SECRET_DC_TOKEN "" +#endif +#ifndef IM_SECRET_DC_CHANNEL_ID +#define IM_SECRET_DC_CHANNEL_ID "" +#endif +#ifndef IM_SECRET_FS_APP_ID +#define IM_SECRET_FS_APP_ID "" +#endif +#ifndef IM_SECRET_FS_APP_SECRET +#define IM_SECRET_FS_APP_SECRET "" +#endif +#ifndef IM_SECRET_FS_ALLOW_FROM +#define IM_SECRET_FS_ALLOW_FROM "" +#endif +#ifndef IM_SECRET_CHANNEL_MODE +#define IM_SECRET_CHANNEL_MODE "telegram" +#endif +#ifndef IM_SECRET_PROXY_HOST +#define IM_SECRET_PROXY_HOST "" +#endif +#ifndef IM_SECRET_PROXY_PORT +#define IM_SECRET_PROXY_PORT "" +#endif +#ifndef IM_SECRET_PROXY_TYPE +#define IM_SECRET_PROXY_TYPE "http" +#endif + +/* ---- Telegram ---- */ + +#ifndef IM_TG_API_HOST +#define IM_TG_API_HOST "api.telegram.org" +#endif +#define IM_TG_POLL_TIMEOUT_S 30 +#define IM_TG_MAX_MSG_LEN 4096 +#define IM_TG_POLL_STACK (12 * 1024) +#define IM_TG_POLL_PRIO 5 +#define IM_TG_FAIL_BASE_MS 2000 +#define IM_TG_FAIL_MAX_MS 60000 + +/* ---- Discord ---- */ + +#ifndef IM_DC_API_HOST +#define IM_DC_API_HOST "discord.com" +#endif +#define IM_DC_API_BASE "/api/v10" +#define IM_DC_MAX_MSG_LEN 2000 +#define IM_DC_POLL_STACK (12 * 1024) +#define IM_DC_FAIL_BASE_MS 2000 +#define IM_DC_FAIL_MAX_MS 60000 +#define IM_DC_LAST_MSG_SAVE_INTERVAL_MS (5 * 1000) +#ifndef IM_DC_GATEWAY_HOST +#define IM_DC_GATEWAY_HOST "gateway.discord.gg" +#endif +#ifndef IM_DC_GATEWAY_PATH +#define IM_DC_GATEWAY_PATH "/?v=10&encoding=json" +#endif +#define IM_DC_GATEWAY_INTENTS 37377 +#define IM_DC_GATEWAY_RX_BUF_SIZE (64 * 1024) +#define IM_DC_GATEWAY_RECONNECT_MS 5000 + +/* ---- Feishu ---- */ + +#ifndef IM_FS_API_HOST +#define IM_FS_API_HOST "open.feishu.cn" +#endif +#ifndef IM_FS_POLL_STACK +#define IM_FS_POLL_STACK (16 * 1024) +#endif + +/* ---- Message bus / outbound ---- */ + +#define IM_BUS_QUEUE_LEN 16 +#define IM_OUTBOUND_STACK (12 * 1024) +#define IM_OUTBOUND_PRIO 5 + +/* ---- NVS namespaces & keys ---- */ + +#define IM_NVS_TG "tg_config" +#define IM_NVS_DC "dc_config" +#define IM_NVS_FS "fs_config" +#define IM_NVS_BOT "bot_config" +#define IM_NVS_PROXY "proxy_config" + +#define IM_NVS_KEY_TG_TOKEN "bot_token" +#define IM_NVS_KEY_DC_TOKEN "bot_token" +#define IM_NVS_KEY_DC_CHANNEL_ID "channel_id" +#define IM_NVS_KEY_DC_LAST_MSG_ID "last_msg_id" +#define IM_NVS_KEY_FS_APP_ID "app_id" +#define IM_NVS_KEY_FS_APP_SECRET "app_secret" +#define IM_NVS_KEY_FS_ALLOW_FROM "allow_from" +#define IM_NVS_KEY_CHANNEL_MODE "channel_mode" +#define IM_NVS_KEY_PROXY_HOST "host" +#define IM_NVS_KEY_PROXY_PORT "port" +#define IM_NVS_KEY_PROXY_TYPE "proxy_type" + +#endif /* __IM_CONFIG_H__ */ diff --git a/examples/messaging/echo_bot/IM/im_platform.h b/examples/messaging/echo_bot/IM/im_platform.h new file mode 100644 index 000000000..9fb4abefb --- /dev/null +++ b/examples/messaging/echo_bot/IM/im_platform.h @@ -0,0 +1,90 @@ +#ifndef __IM_PLATFORM_H__ +#define __IM_PLATFORM_H__ + +/* + * IM component platform adapter. + * Wraps tal_api.h with IM-specific log macros, memory helpers, and KV helpers. + * Host applications provide tal_api.h through their platform SDK. + */ + +#include "tal_api.h" + +#include +#include +#include +#include +#include +#include + +/* ---- Logging ---- */ + +#define IM_LOGE(tag, fmt, ...) PR_ERR("[%s] " fmt, tag, ##__VA_ARGS__) +#define IM_LOGW(tag, fmt, ...) PR_WARN("[%s] " fmt, tag, ##__VA_ARGS__) +#define IM_LOGI(tag, fmt, ...) PR_INFO("[%s] " fmt, tag, ##__VA_ARGS__) +#define IM_LOGD(tag, fmt, ...) PR_DEBUG("[%s] " fmt, tag, ##__VA_ARGS__) + +/* ---- Memory ---- */ + +#define im_malloc(size) tal_malloc(size) +#define im_calloc(nmemb, size) tal_calloc(nmemb, size) +#define im_realloc(ptr, size) tal_realloc(ptr, size) +#define im_free(ptr) tal_free(ptr) + +static inline char *im_strdup(const char *s) +{ + if (!s) return NULL; + size_t len = strlen(s) + 1; + char *p = (char *)im_malloc(len); + if (p) memcpy(p, s, len); + return p; +} + +/* ---- KV storage ---- */ + +#ifndef IM_KV_PREFIX +#define IM_KV_PREFIX "im" +#endif + +static inline void im_build_kv_key(const char *ns, const char *key, char *out, size_t out_size) +{ + if (!out || out_size == 0) return; + snprintf(out, out_size, "%s.%s.%s", IM_KV_PREFIX, ns ? ns : "", key ? key : ""); +} + +static inline OPERATE_RET im_kv_set_string(const char *ns, const char *key, const char *value) +{ + if (!key || !value) return OPRT_INVALID_PARM; + char full_key[64] = {0}; + im_build_kv_key(ns, key, full_key, sizeof(full_key)); + return tal_kv_set(full_key, (const uint8_t *)value, strlen(value) + 1); +} + +static inline OPERATE_RET im_kv_get_string(const char *ns, const char *key, char *out, size_t out_size) +{ + if (!key || !out || out_size == 0) return OPRT_INVALID_PARM; + char full_key[64] = {0}; + im_build_kv_key(ns, key, full_key, sizeof(full_key)); + uint8_t *buf = NULL; + size_t len = 0; + OPERATE_RET rt = tal_kv_get(full_key, &buf, &len); + if (rt != OPRT_OK || !buf || len == 0) { + out[0] = '\0'; + if (buf) tal_kv_free(buf); + return (rt == OPRT_OK) ? OPRT_NOT_FOUND : rt; + } + size_t copy_len = (len < out_size - 1) ? len : (out_size - 1); + memcpy(out, buf, copy_len); + out[copy_len] = '\0'; + tal_kv_free(buf); + return OPRT_OK; +} + +static inline OPERATE_RET im_kv_del(const char *ns, const char *key) +{ + if (!key) return OPRT_INVALID_PARM; + char full_key[64] = {0}; + im_build_kv_key(ns, key, full_key, sizeof(full_key)); + return tal_kv_del(full_key); +} + +#endif /* __IM_PLATFORM_H__ */ diff --git a/examples/messaging/echo_bot/IM/im_utils.c b/examples/messaging/echo_bot/IM/im_utils.c new file mode 100644 index 000000000..3701a83af --- /dev/null +++ b/examples/messaging/echo_bot/IM/im_utils.c @@ -0,0 +1,69 @@ +#include "im_utils.h" + +void im_safe_copy(char *dst, size_t dst_size, const char *src) +{ + if (!dst || dst_size == 0) { + return; + } + if (!src) { + dst[0] = '\0'; + return; + } + snprintf(dst, dst_size, "%s", src); +} + +uint16_t im_parse_http_status(const char *raw_resp) +{ + if (!raw_resp || strncmp(raw_resp, "HTTP/", 5) != 0) { + return 0; + } + const char *sp = strchr(raw_resp, ' '); + return sp ? (uint16_t)atoi(sp + 1) : 0; +} + +int im_find_header_end(const char *buf, int len) +{ + if (!buf || len < 4) { + return -1; + } + for (int i = 0; i <= len - 4; i++) { + if (buf[i] == '\r' && buf[i + 1] == '\n' && buf[i + 2] == '\r' && buf[i + 3] == '\n') { + return i + 4; + } + } + return -1; +} + +uint64_t im_fnv1a64(const char *s) +{ + uint64_t h = 14695981039346656037ULL; + if (!s) { + return h; + } + while (*s) { + h ^= (unsigned char)(*s++); + h *= 1099511628211ULL; + } + return h; +} + +const char *im_json_str(cJSON *obj, const char *key, const char *fallback) +{ + cJSON *item = obj ? cJSON_GetObjectItem(obj, key) : NULL; + return (cJSON_IsString(item) && item->valuestring) ? item->valuestring : fallback; +} + +int im_json_int(cJSON *obj, const char *key, int fallback) +{ + cJSON *item = obj ? cJSON_GetObjectItem(obj, key) : NULL; + return cJSON_IsNumber(item) ? (int)item->valuedouble : fallback; +} + +uint32_t im_json_uint(cJSON *obj, const char *key, uint32_t fallback) +{ + cJSON *item = obj ? cJSON_GetObjectItem(obj, key) : NULL; + if (!cJSON_IsNumber(item) || item->valuedouble < 0) { + return fallback; + } + return (uint32_t)item->valuedouble; +} diff --git a/examples/messaging/echo_bot/IM/im_utils.h b/examples/messaging/echo_bot/IM/im_utils.h new file mode 100644 index 000000000..4259b1f59 --- /dev/null +++ b/examples/messaging/echo_bot/IM/im_utils.h @@ -0,0 +1,16 @@ +#ifndef __IM_UTILS_H__ +#define __IM_UTILS_H__ + +#include "im_platform.h" +#include "cJSON.h" + +void im_safe_copy(char *dst, size_t dst_size, const char *src); +uint16_t im_parse_http_status(const char *raw_resp); +int im_find_header_end(const char *buf, int len); +uint64_t im_fnv1a64(const char *s); + +const char *im_json_str(cJSON *obj, const char *key, const char *fallback); +int im_json_int(cJSON *obj, const char *key, int fallback); +uint32_t im_json_uint(cJSON *obj, const char *key, uint32_t fallback); + +#endif /* __IM_UTILS_H__ */ diff --git a/examples/messaging/echo_bot/IM/proxy/http_proxy.c b/examples/messaging/echo_bot/IM/proxy/http_proxy.c new file mode 100644 index 000000000..8e7178ec4 --- /dev/null +++ b/examples/messaging/echo_bot/IM/proxy/http_proxy.c @@ -0,0 +1,523 @@ +#include "proxy/http_proxy.h" + +#include "im_config.h" +#include "tal_network.h" +#include "certs/tls_cert_bundle.h" +#include "tuya_transporter.h" +#include "tuya_tls.h" +#include "mbedtls/ssl.h" +#include + +#include "im_utils.h" + +struct proxy_conn { + tuya_transporter_t tcp; + tuya_tls_hander tls; + int socket_fd; +}; + +static const char *TAG = "proxy"; +static char s_proxy_host[64] = {0}; +static uint16_t s_proxy_port = 0; +static char s_proxy_type[8] = "http"; /* http | socks5 */ + +static bool proxy_type_valid(const char *type) +{ + return type && (strcmp(type, "http") == 0 || strcmp(type, "socks5") == 0); +} + +static void proxy_type_set(const char *type) +{ + if (type && strcmp(type, "socks5") == 0) { + memcpy(s_proxy_type, "socks5", sizeof("socks5")); + } else { + memcpy(s_proxy_type, "http", sizeof("http")); + } +} + +static bool proxy_type_is_socks5(void) +{ + return strcmp(s_proxy_type, "socks5") == 0; +} + +static int proxy_write_all_tcp(tuya_transporter_t tcp, const void *data, int len, int timeout_ms) +{ + int sent = 0; + while (sent < len) { + int n = tuya_transporter_write(tcp, (uint8_t *)data + sent, len - sent, timeout_ms); + if (n <= 0) { + return -1; + } + sent += n; + } + return sent; +} + +static int proxy_read_exact_tcp(tuya_transporter_t tcp, uint8_t *buf, int len, int timeout_ms) +{ + int got = 0; + uint32_t start_ms = tal_system_get_millisecond(); + + while (got < len) { + uint32_t now_ms = tal_system_get_millisecond(); + if ((int)(now_ms - start_ms) >= timeout_ms) { + return -1; + } + + int remain_ms = timeout_ms - (int)(now_ms - start_ms); + if (remain_ms < 50) { + remain_ms = 50; + } + + int n = tuya_transporter_read(tcp, buf + got, len - got, remain_ms); + if (n == OPRT_RESOURCE_NOT_READY) { + tal_system_sleep(10); + continue; + } + if (n <= 0) { + return -1; + } + got += n; + } + + return got; +} + +static int proxy_read_headers(tuya_transporter_t tcp, char *buf, int size, int timeout_ms) +{ + if (!buf || size <= 4) { + return -1; + } + + int total = 0; + uint32_t start_ms = tal_system_get_millisecond(); + while (total < size - 1) { + uint32_t now_ms = tal_system_get_millisecond(); + if ((int)(now_ms - start_ms) >= timeout_ms) { + break; + } + int remain_ms = timeout_ms - (int)(now_ms - start_ms); + if (remain_ms < 50) { + remain_ms = 50; + } + + int n = tuya_transporter_read(tcp, (uint8_t *)buf + total, size - total - 1, remain_ms); + if (n == OPRT_RESOURCE_NOT_READY) { + tal_system_sleep(10); + continue; + } + if (n <= 0) { + return -1; + } + total += n; + buf[total] = '\0'; + if (im_find_header_end(buf, total) > 0) { + return total; + } + } + + return -1; +} + +static OPERATE_RET proxy_open_transport(proxy_conn_t *conn, int timeout_ms) +{ + if (!conn) { + return OPRT_INVALID_PARM; + } + + conn->tcp = tuya_transporter_create(TRANSPORT_TYPE_TCP, NULL); + if (!conn->tcp) { + IM_LOGE(TAG, "create tcp transporter failed"); + return OPRT_COM_ERROR; + } + + tuya_tcp_config_t cfg = {0}; + cfg.isReuse = TRUE; + cfg.isDisableNagle = TRUE; + cfg.sendTimeoutMs = timeout_ms; + cfg.recvTimeoutMs = timeout_ms; + (void)tuya_transporter_ctrl(conn->tcp, TUYA_TRANSPORTER_SET_TCP_CONFIG, &cfg); + + OPERATE_RET rt = tuya_transporter_connect(conn->tcp, s_proxy_host, s_proxy_port, timeout_ms); + if (rt != OPRT_OK) { + IM_LOGE(TAG, "connect proxy failed %s:%u rt=%d", s_proxy_host, s_proxy_port, rt); + tuya_transporter_destroy(conn->tcp); + conn->tcp = NULL; + return rt; + } + + return OPRT_OK; +} + +static OPERATE_RET open_http_connect_tunnel(proxy_conn_t *conn, const char *host, int port, int timeout_ms) +{ + char req[512] = {0}; + int req_len = snprintf(req, sizeof(req), + "CONNECT %s:%d HTTP/1.1\r\n" + "Host: %s:%d\r\n" + "Proxy-Connection: Keep-Alive\r\n" + "Connection: Keep-Alive\r\n\r\n", + host, port, host, port); + if (req_len <= 0 || req_len >= (int)sizeof(req)) { + return OPRT_BUFFER_NOT_ENOUGH; + } + + if (proxy_write_all_tcp(conn->tcp, req, req_len, timeout_ms) != req_len) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + char header[1024] = {0}; + int hdr_len = proxy_read_headers(conn->tcp, header, sizeof(header), timeout_ms); + if (hdr_len <= 0) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + int code = im_parse_http_status(header); + if (code != 200) { + IM_LOGE(TAG, "CONNECT rejected code=%d", code); + return OPRT_COM_ERROR; + } + + return OPRT_OK; +} + +static OPERATE_RET open_socks5_tunnel(proxy_conn_t *conn, const char *host, int port, int timeout_ms) +{ + if (!conn || !conn->tcp || !host || port <= 0) { + return OPRT_INVALID_PARM; + } + + size_t host_len = strlen(host); + if (host_len == 0 || host_len > 255) { + IM_LOGE(TAG, "invalid socks5 host len=%u", (unsigned)host_len); + return OPRT_INVALID_PARM; + } + + uint8_t greeting[3] = {0x05, 0x01, 0x00}; + if (proxy_write_all_tcp(conn->tcp, greeting, sizeof(greeting), timeout_ms) != (int)sizeof(greeting)) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + uint8_t greet_resp[2] = {0}; + if (proxy_read_exact_tcp(conn->tcp, greet_resp, sizeof(greet_resp), timeout_ms) != (int)sizeof(greet_resp)) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + if (greet_resp[0] != 0x05 || greet_resp[1] != 0x00) { + IM_LOGE(TAG, "SOCKS5 greeting rejected ver=%u method=%u", greet_resp[0], greet_resp[1]); + return OPRT_COM_ERROR; + } + + uint8_t req[4 + 1 + 255 + 2] = {0}; + size_t req_len = 0; + req[req_len++] = 0x05; + req[req_len++] = 0x01; + req[req_len++] = 0x00; + req[req_len++] = 0x03; + req[req_len++] = (uint8_t)host_len; + memcpy(req + req_len, host, host_len); + req_len += host_len; + req[req_len++] = (uint8_t)((port >> 8) & 0xFF); + req[req_len++] = (uint8_t)(port & 0xFF); + + if (proxy_write_all_tcp(conn->tcp, req, (int)req_len, timeout_ms) != (int)req_len) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + uint8_t resp_head[4] = {0}; + if (proxy_read_exact_tcp(conn->tcp, resp_head, sizeof(resp_head), timeout_ms) != (int)sizeof(resp_head)) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + + if (resp_head[0] != 0x05 || resp_head[1] != 0x00) { + IM_LOGE(TAG, "SOCKS5 connect rejected ver=%u rep=%u", resp_head[0], resp_head[1]); + return OPRT_COM_ERROR; + } + + int addr_tail_len = 0; + if (resp_head[3] == 0x01) { + addr_tail_len = 4 + 2; + } else if (resp_head[3] == 0x04) { + addr_tail_len = 16 + 2; + } else if (resp_head[3] == 0x03) { + uint8_t name_len = 0; + if (proxy_read_exact_tcp(conn->tcp, &name_len, 1, timeout_ms) != 1) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + addr_tail_len = name_len + 2; + } else { + IM_LOGE(TAG, "SOCKS5 unknown atyp=%u", resp_head[3]); + return OPRT_COM_ERROR; + } + + if (addr_tail_len > 0) { + uint8_t tmp[300] = {0}; + if (addr_tail_len > (int)sizeof(tmp)) { + return OPRT_BUFFER_NOT_ENOUGH; + } + if (proxy_read_exact_tcp(conn->tcp, tmp, addr_tail_len, timeout_ms) != addr_tail_len) { + return OPRT_LINK_CORE_HTTP_CLIENT_SEND_ERROR; + } + } + + return OPRT_OK; +} + +OPERATE_RET http_proxy_init(void) +{ + if (IM_SECRET_PROXY_HOST[0] != '\0') { + snprintf(s_proxy_host, sizeof(s_proxy_host), "%s", IM_SECRET_PROXY_HOST); + } + + if (IM_SECRET_PROXY_PORT[0] != '\0') { + s_proxy_port = (uint16_t)atoi(IM_SECRET_PROXY_PORT); + } + + if (proxy_type_valid(IM_SECRET_PROXY_TYPE)) { + proxy_type_set(IM_SECRET_PROXY_TYPE); + } + + char tmp[64] = {0}; + if (im_kv_get_string(IM_NVS_PROXY, IM_NVS_KEY_PROXY_HOST, tmp, sizeof(tmp)) == OPRT_OK) { + snprintf(s_proxy_host, sizeof(s_proxy_host), "%s", tmp); + } + + memset(tmp, 0, sizeof(tmp)); + if (im_kv_get_string(IM_NVS_PROXY, IM_NVS_KEY_PROXY_PORT, tmp, sizeof(tmp)) == OPRT_OK) { + s_proxy_port = (uint16_t)atoi(tmp); + } + + memset(tmp, 0, sizeof(tmp)); + if (im_kv_get_string(IM_NVS_PROXY, IM_NVS_KEY_PROXY_TYPE, tmp, sizeof(tmp)) == OPRT_OK) { + if (proxy_type_valid(tmp)) { + proxy_type_set(tmp); + } + } + + if (http_proxy_is_enabled()) { + IM_LOGI(TAG, "proxy configured: %s:%u (%s)", s_proxy_host, s_proxy_port, s_proxy_type); + } else { + IM_LOGI(TAG, "proxy not configured"); + } + + return OPRT_OK; +} + +bool http_proxy_is_enabled(void) +{ + return s_proxy_host[0] != '\0' && s_proxy_port > 0; +} + +OPERATE_RET http_proxy_set(const char *host, uint16_t port, const char *type) +{ + if (!host || port == 0) { + return OPRT_INVALID_PARM; + } + + const char *proxy_type = type ? type : "http"; + if (!proxy_type_valid(proxy_type)) { + return OPRT_INVALID_PARM; + } + + snprintf(s_proxy_host, sizeof(s_proxy_host), "%s", host); + s_proxy_port = port; + proxy_type_set(proxy_type); + + char port_buf[16] = {0}; + snprintf(port_buf, sizeof(port_buf), "%u", (unsigned)port); + + OPERATE_RET rt = im_kv_set_string(IM_NVS_PROXY, IM_NVS_KEY_PROXY_HOST, host); + if (rt != OPRT_OK) { + return rt; + } + + rt = im_kv_set_string(IM_NVS_PROXY, IM_NVS_KEY_PROXY_PORT, port_buf); + if (rt != OPRT_OK) { + return rt; + } + + return im_kv_set_string(IM_NVS_PROXY, IM_NVS_KEY_PROXY_TYPE, proxy_type); +} + +OPERATE_RET http_proxy_clear(void) +{ + s_proxy_host[0] = '\0'; + s_proxy_port = 0; + proxy_type_set("http"); + + (void)im_kv_del(IM_NVS_PROXY, IM_NVS_KEY_PROXY_HOST); + (void)im_kv_del(IM_NVS_PROXY, IM_NVS_KEY_PROXY_PORT); + (void)im_kv_del(IM_NVS_PROXY, IM_NVS_KEY_PROXY_TYPE); + return OPRT_OK; +} + +proxy_conn_t *proxy_conn_open(const char *host, int port, int timeout_ms) +{ + if (!host || port <= 0 || timeout_ms <= 0) { + return NULL; + } + if (!http_proxy_is_enabled()) { + IM_LOGW(TAG, "proxy not configured"); + return NULL; + } + + proxy_conn_t *conn = im_calloc(1, sizeof(proxy_conn_t)); + if (!conn) { + return NULL; + } + + OPERATE_RET rt = proxy_open_transport(conn, timeout_ms); + if (rt != OPRT_OK) { + im_free(conn); + return NULL; + } + + if (proxy_type_is_socks5()) { + rt = open_socks5_tunnel(conn, host, port, timeout_ms); + } else { + rt = open_http_connect_tunnel(conn, host, port, timeout_ms); + } + + if (rt != OPRT_OK) { + IM_LOGE(TAG, "open %s tunnel failed host=%s:%d rt=%d", s_proxy_type, host, port, rt); + tuya_transporter_close(conn->tcp); + tuya_transporter_destroy(conn->tcp); + im_free(conn); + return NULL; + } + + rt = tuya_transporter_ctrl(conn->tcp, TUYA_TRANSPORTER_GET_TCP_SOCKET, &conn->socket_fd); + if (rt != OPRT_OK || conn->socket_fd < 0) { + IM_LOGE(TAG, "get proxy socket failed rt=%d fd=%d", rt, conn->socket_fd); + tuya_transporter_close(conn->tcp); + tuya_transporter_destroy(conn->tcp); + im_free(conn); + return NULL; + } + + uint8_t *cacert = NULL; + size_t cacert_len = 0; + bool verify_peer = false; + rt = im_tls_query_domain_certs(host, &cacert, &cacert_len); + if (rt == OPRT_OK && cacert && cacert_len > 0) { + verify_peer = true; + } else { + IM_LOGW(TAG, "proxy tls cert unavailable for %s, fallback to no-verify mode rt=%d", host, rt); + } + if (verify_peer && cacert_len > (size_t)INT_MAX) { + IM_LOGW(TAG, "proxy tls cert too large host=%s len=%zu, fallback to no-verify", host, cacert_len); + verify_peer = false; + } + + conn->tls = tuya_tls_connect_create(); + if (!conn->tls) { + IM_LOGE(TAG, "create tls handler failed"); + if (cacert) { + im_free(cacert); + } + tuya_transporter_close(conn->tcp); + tuya_transporter_destroy(conn->tcp); + im_free(conn); + return NULL; + } + + int timeout_s = timeout_ms / 1000; + if (timeout_s <= 0) { + timeout_s = 1; + } + + tuya_tls_config_t cfg_tls = { + .mode = TUYA_TLS_SERVER_CERT_MODE, + .hostname = (char *)host, + .port = (uint32_t)port, + .timeout = timeout_s, + .verify = verify_peer, + .ca_cert = verify_peer ? (char *)cacert : NULL, + .ca_cert_size = verify_peer ? (int)cacert_len : 0, + }; + + (void)tuya_tls_config_set(conn->tls, &cfg_tls); + rt = tuya_tls_connect(conn->tls, (char *)host, port, conn->socket_fd, timeout_s); + if (cacert) { + im_free(cacert); + } + if (rt != OPRT_OK) { + IM_LOGE(TAG, "proxy tls connect failed host=%s rt=%d", host, rt); + tuya_tls_connect_destroy(conn->tls); + conn->tls = NULL; + tuya_transporter_close(conn->tcp); + tuya_transporter_destroy(conn->tcp); + im_free(conn); + return NULL; + } + + IM_LOGI(TAG, "proxy tunnel ready %s:%d via %s:%u (%s)", host, port, s_proxy_host, s_proxy_port, s_proxy_type); + return conn; +} + +int proxy_conn_write(proxy_conn_t *conn, const char *data, int len) +{ + if (!conn || !conn->tls || !data || len <= 0) { + return -1; + } + + int sent = 0; + while (sent < len) { + int n = tuya_tls_write(conn->tls, (uint8_t *)data + sent, (uint32_t)(len - sent)); + if (n <= 0) { + return -1; + } + sent += n; + } + + return sent; +} + +int proxy_conn_read(proxy_conn_t *conn, char *buf, int len, int timeout_ms) +{ + if (!conn || !conn->tls || conn->socket_fd < 0 || !buf || len <= 0 || timeout_ms <= 0) { + return -1; + } + + TUYA_FD_SET_T readfds; + tal_net_fd_zero(&readfds); + tal_net_fd_set(conn->socket_fd, &readfds); + int ready = tal_net_select(conn->socket_fd + 1, &readfds, NULL, NULL, timeout_ms); + if (ready < 0) { + return -1; + } + if (ready == 0) { + return OPRT_RESOURCE_NOT_READY; + } + + int n = tuya_tls_read(conn->tls, (uint8_t *)buf, (uint32_t)len); + if (n > 0) { + return n; + } + if (n == 0 || n == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) { + return 0; + } + if (n == OPRT_RESOURCE_NOT_READY || n == MBEDTLS_ERR_SSL_WANT_READ || n == MBEDTLS_ERR_SSL_WANT_WRITE || + n == MBEDTLS_ERR_SSL_TIMEOUT || n == -100) { + return OPRT_RESOURCE_NOT_READY; + } + return n; +} + +void proxy_conn_close(proxy_conn_t *conn) +{ + if (!conn) { + return; + } + if (conn->tls) { + (void)tuya_tls_disconnect(conn->tls); + tuya_tls_connect_destroy(conn->tls); + conn->tls = NULL; + } + if (conn->tcp) { + (void)tuya_transporter_close(conn->tcp); + (void)tuya_transporter_destroy(conn->tcp); + conn->tcp = NULL; + } + conn->socket_fd = -1; + im_free(conn); +} diff --git a/examples/messaging/echo_bot/IM/proxy/http_proxy.h b/examples/messaging/echo_bot/IM/proxy/http_proxy.h new file mode 100644 index 000000000..4807a6c16 --- /dev/null +++ b/examples/messaging/echo_bot/IM/proxy/http_proxy.h @@ -0,0 +1,18 @@ +#ifndef __HTTP_PROXY_H__ +#define __HTTP_PROXY_H__ + +#include "im_platform.h" + +typedef struct proxy_conn proxy_conn_t; + +OPERATE_RET http_proxy_init(void); +bool http_proxy_is_enabled(void); +OPERATE_RET http_proxy_set(const char *host, uint16_t port, const char *type); +OPERATE_RET http_proxy_clear(void); + +proxy_conn_t *proxy_conn_open(const char *host, int port, int timeout_ms); +int proxy_conn_write(proxy_conn_t *conn, const char *data, int len); +int proxy_conn_read(proxy_conn_t *conn, char *buf, int len, int timeout_ms); +void proxy_conn_close(proxy_conn_t *conn); + +#endif /* __HTTP_PROXY_H__ */ diff --git a/examples/messaging/echo_bot/README.md b/examples/messaging/echo_bot/README.md new file mode 100644 index 000000000..f2f6e6aeb --- /dev/null +++ b/examples/messaging/echo_bot/README.md @@ -0,0 +1,357 @@ +# Echo Bot + +[English](#overview) | [中文](#概述) + +## Overview + +Echo Bot is a standalone messaging demo built on [TuyaOpen](https://github.com/tuya/TuyaOpen). It connects to **Telegram**, **Discord**, or **Feishu (Lark)** and echoes back every message it receives — no LLM, no cloud AI, just a clean round-trip through the IM channel. + +Use it to verify your bot credentials, test network/proxy connectivity, or as a starting point for building your own messaging integrations on TuyaOpen-supported hardware (T5AI, ESP32, Raspberry Pi, Linux, etc.). + +### Features + +- **Multi-channel support** — Telegram (long-poll), Discord (WebSocket Gateway), Feishu (WebSocket + Protobuf). +- **Runtime configuration** — Switch channels, set tokens, and configure proxy via CLI commands. All settings persist in NVS (KV storage). +- **HTTP / SOCKS5 proxy** — Route all API traffic through a proxy when direct access is unavailable. +- **WiFi management** — Connect, scan, and switch WiFi networks from the serial console (on WiFi-capable targets). +- **Cross-platform** — Runs natively on Linux as a regular ELF binary, or on embedded targets like Tuya T5AI. + +### Architecture + +``` ++-----------+ inbound queue +-----------+ outbound queue +------------+ +| Channel | ---( im_msg_t )-----→ | Echo Loop | ---( im_msg_t )-----→ | Outbound | +| Drivers | | (copy) | | Dispatcher | +| TG/DC/FS | ←---( send_message )-- +-----------+ +------------+ ++-----------+ | + send_message(channel) +``` + +Inbound messages arrive from the active channel driver, pass through a message bus queue, get copied by the echo loop, and are dispatched back to the same channel. + +### Project Structure + +``` +echo_bot/ +├── CMakeLists.txt # Top-level build script +├── app_default.config # Default board config (T5AI) +├── config/ +│ ├── Linux.config # Linux-specific overrides +│ └── T5AI.config # T5AI-specific overrides +├── include/ +│ ├── echo_base.h # App-level platform adapter (logging, KV helpers) +│ ├── echo_config.h # App-level compile-time config (WiFi defaults) +│ └── wifi_manager.h # WiFi management API +├── src/ +│ ├── tuya_app_main.c # Entry point, channel init, echo loop +│ ├── cli_echo.c # CLI command handlers +│ ├── echo_cli.h # CLI init declaration +│ └── wifi/ +│ └── wifi_manager.c # WiFi connect / scan / status +└── IM/ # Reusable IM component (see IM/README.md) + ├── im_platform.h # Platform adapter (logging, memory, KV) + ├── im_config.h # IM compile-time defaults & secrets + ├── im_api.h # Single-header include for all IM APIs + ├── im_utils.h / .c # Shared utilities (string, HTTP, JSON, hash) + ├── bus/ + │ └── message_bus.h / .c # Inbound / outbound message queues + ├── channels/ + │ ├── telegram_bot.h / .c # Telegram Bot driver + │ ├── discord_bot.h / .c # Discord Bot driver + │ └── feishu_bot.h / .c # Feishu Bot driver + ├── proxy/ + │ └── http_proxy.h / .c # HTTP CONNECT / SOCKS5 tunnel + ├── certs/ + │ ├── tls_cert_bundle.h/.c# Domain cert query (iot-dns + builtin CA) + │ └── ca_bundle_mini.h/.c # Builtin CA certificate bundle + └── CMakeLists.txt +``` + +### Prerequisites + +- TuyaOpen SDK cloned and environment initialized (`. ./export.sh` from repo root). +- A bot token / app credential for at least one channel: + - **Telegram**: Bot token from [@BotFather](https://t.me/BotFather). + - **Discord**: Bot token from [Discord Developer Portal](https://discord.com/developers/applications), plus a channel ID. + - **Feishu**: App ID & App Secret from [Feishu Open Platform](https://open.feishu.cn/), with Event Subscription enabled. + +### Build + +```bash +cd /path/to/TuyaOpen +. ./export.sh +cd examples/messaging/echo_bot + +# Build for default target +tos.py build +``` + +Output binaries are placed in `dist/`. + +### Configure Secrets (Compile-Time) + +Create `IM/im_secrets.h` (git-ignored) to embed credentials at compile time: + +```c +#define IM_SECRET_TG_TOKEN "123456:ABC-DEF..." +#define IM_SECRET_CHANNEL_MODE "telegram" +``` + +Or for Feishu: + +```c +#define IM_SECRET_FS_APP_ID "cli_xxxx" +#define IM_SECRET_FS_APP_SECRET "xxxx" +#define IM_SECRET_FS_ALLOW_FROM "ou_xxxx,ou_yyyy" +#define IM_SECRET_CHANNEL_MODE "feishu" +``` + +See `IM/im_config.h` for the full list of overridable macros. + +### Run + +**Linux:** + +```bash +./dist/echo_bot_1.0.0/echo_bot_1.0.0 +``` + +**Embedded (T5AI etc.):** + +Flash the firmware via `tos.py flash`, then open a serial console (460800 baud). + +### CLI Commands + +Once running, the following commands are available via serial console (embedded) or stdin (Linux): + +| Command | Description | +|---|---| +| `help` | List all available commands | +| `set_wifi ` | Set WiFi credentials (embedded only) | +| `wifi_status` | Show WiFi connection status and IP | +| `wifi_scan` | Scan and list nearby WiFi APs | +| `set_channel_mode ` | Switch channel: `telegram`, `discord`, or `feishu` | +| `set_tg_token ` | Set Telegram bot token | +| `set_dc_token ` | Set Discord bot token | +| `set_dc_channel ` | Set Discord default channel ID | +| `set_fs_appid ` | Set Feishu App ID | +| `set_fs_appsecret ` | Set Feishu App Secret | +| `set_fs_allow ` | Set Feishu allowed sender open_ids (comma-separated) | +| `config_show` | Display current configuration (tokens are masked) | +| `push_outbound ` | Manually send a message to a channel | +| `restart` | Reboot the device | + +All `set_*` commands persist to NVS and take effect after `restart`. + +### Quick Start Example (Telegram on Linux) + +```bash +# 1. Build +cd examples/messaging/echo_bot && tos.py build -t Linux + +# 2. Create secrets file +cat > IM/im_secrets.h << 'EOF' +#define IM_SECRET_TG_TOKEN "123456:ABC-your-token" +#define IM_SECRET_CHANNEL_MODE "telegram" +EOF + +# 3. Rebuild and run +tos.py build -t Linux +./dist/echo_bot_1.0.0/echo_bot_1.0.0 + +# 4. Send a message to your bot in Telegram — it replies with the same text. +``` + +### Proxy Support + +If your device cannot directly reach Telegram / Discord / Feishu APIs, configure an HTTP or SOCKS5 proxy: + +**Compile-time** (in `IM/im_secrets.h`): + +```c +#define IM_SECRET_PROXY_HOST "192.168.1.100" +#define IM_SECRET_PROXY_PORT "7890" +#define IM_SECRET_PROXY_TYPE "http" // "http" or "socks5" +``` + +**Runtime** (via CLI — not yet exposed, but settable via KV): + +Proxy settings are stored in NVS under `proxy_config` namespace with keys `host`, `port`, `proxy_type`. + +--- + +## 概述 + +Echo Bot 是基于 [TuyaOpen](https://github.com/tuya/TuyaOpen) 构建的独立即时通讯演示项目。它连接到 **Telegram**、**Discord** 或**飞书**,并原样回复收到的每一条消息——无 LLM、无云端 AI,仅验证消息通道的完整收发链路。 + +可用于验证 Bot 凭证、测试网络/代理连通性,或作为在 TuyaOpen 支持的硬件(T5AI、ESP32、树莓派、Linux 等)上构建自定义消息集成的起点。 + +### 功能特性 + +- **多通道支持** — Telegram(长轮询)、Discord(WebSocket Gateway)、飞书(WebSocket + Protobuf)。 +- **运行时配置** — 通过 CLI 命令切换通道、设置 Token、配置代理,所有设置持久化到 NVS(KV 存储)。 +- **HTTP / SOCKS5 代理** — 当无法直接访问 API 时,可通过代理路由所有流量。 +- **WiFi 管理** — 在支持 WiFi 的目标上,通过串口控制台连接、扫描和切换 WiFi 网络。 +- **跨平台** — 在 Linux 上以普通 ELF 可执行文件原生运行,或在 Tuya T5AI 等嵌入式目标上运行。 + +### 架构 + +``` ++-----------+ 入站队列 +-----------+ 出站队列 +------------+ +| 通道驱动 | ---( im_msg_t )-----→ | Echo 循环 | ---( im_msg_t )-----→ | 出站分发器 | +| TG/DC/FS | | (复制消息) | | | +| | ←---( send_message )-- +-----------+ +------------+ ++-----------+ | + send_message(channel) +``` + +入站消息从活跃的通道驱动到达,经过消息总线队列,由 Echo 循环复制后,通过出站分发器发回同一通道。 + +### 项目结构 + +``` +echo_bot/ +├── CMakeLists.txt # 顶层构建脚本 +├── app_default.config # 默认板级配置(T5AI) +├── config/ +│ ├── Linux.config # Linux 平台配置覆盖 +│ └── T5AI.config # T5AI 平台配置覆盖 +├── include/ +│ ├── echo_base.h # 应用层平台适配(日志、KV 辅助) +│ ├── echo_config.h # 应用层编译期配置(WiFi 默认值) +│ └── wifi_manager.h # WiFi 管理接口 +├── src/ +│ ├── tuya_app_main.c # 入口、通道初始化、Echo 循环 +│ ├── cli_echo.c # CLI 命令处理 +│ ├── echo_cli.h # CLI 初始化声明 +│ └── wifi/ +│ └── wifi_manager.c # WiFi 连接 / 扫描 / 状态 +└── IM/ # 可复用 IM 组件(详见 IM/README.md) + ├── im_platform.h # 平台适配层(日志、内存、KV) + ├── im_config.h # IM 编译期默认值与密钥 + ├── im_api.h # 聚合头文件,一次引入所有 IM API + ├── im_utils.h / .c # 公共工具(字符串、HTTP、JSON、哈希) + ├── bus/ + │ └── message_bus.h / .c # 入站 / 出站消息队列 + ├── channels/ + │ ├── telegram_bot.h / .c # Telegram Bot 驱动 + │ ├── discord_bot.h / .c # Discord Bot 驱动 + │ └── feishu_bot.h / .c # 飞书 Bot 驱动 + ├── proxy/ + │ └── http_proxy.h / .c # HTTP CONNECT / SOCKS5 隧道 + ├── certs/ + │ ├── tls_cert_bundle.h/.c# 域名证书查询(iot-dns + 内置 CA 回退) + │ └── ca_bundle_mini.h/.c # 内置 CA 证书包 + └── CMakeLists.txt +``` + +### 前置条件 + +- 已克隆 TuyaOpen SDK 并初始化环境(在仓库根目录执行 `. ./export.sh`)。 +- 至少拥有一个通道的 Bot 凭证: + - **Telegram**:通过 [@BotFather](https://t.me/BotFather) 获取 Bot Token。 + - **Discord**:从 [Discord 开发者门户](https://discord.com/developers/applications) 获取 Bot Token,并准备一个频道 ID。 + - **飞书**:从[飞书开放平台](https://open.feishu.cn/) 获取 App ID 和 App Secret,并开启事件订阅。 + +### 编译 + +```bash +cd /path/to/TuyaOpen +. ./export.sh +cd examples/messaging/echo_bot + +# 默认目标编译 +tos.py build +``` + +产物输出在 `dist/` 目录。 + +### 配置密钥(编译期) + +创建 `IM/im_secrets.h`(已被 git 忽略),在编译时嵌入凭证: + +```c +#define IM_SECRET_TG_TOKEN "123456:ABC-DEF..." +#define IM_SECRET_CHANNEL_MODE "telegram" +``` + +或飞书配置: + +```c +#define IM_SECRET_FS_APP_ID "cli_xxxx" +#define IM_SECRET_FS_APP_SECRET "xxxx" +#define IM_SECRET_FS_ALLOW_FROM "ou_xxxx,ou_yyyy" +#define IM_SECRET_CHANNEL_MODE "feishu" +``` + +完整可覆盖宏列表参见 `IM/im_config.h`。 + +### 运行 + +**Linux 平台:** + +```bash +./dist/echo_bot_1.0.0/echo_bot_1.0.0 +``` + +**嵌入式平台(T5AI 等):** + +通过 `tos.py flash` 烧录固件,然后打开串口控制台(波特率 460800)。 + +### CLI 命令 + +运行后,可通过串口控制台(嵌入式)或标准输入(Linux)使用以下命令: + +| 命令 | 说明 | +|---|---| +| `help` | 列出所有可用命令 | +| `set_wifi ` | 设置 WiFi 凭证(仅嵌入式平台) | +| `wifi_status` | 显示 WiFi 连接状态和 IP 地址 | +| `wifi_scan` | 扫描并列出附近 WiFi 热点 | +| `set_channel_mode ` | 切换通道:`telegram`、`discord` 或 `feishu` | +| `set_tg_token ` | 设置 Telegram Bot Token | +| `set_dc_token ` | 设置 Discord Bot Token | +| `set_dc_channel ` | 设置 Discord 默认频道 ID | +| `set_fs_appid ` | 设置飞书 App ID | +| `set_fs_appsecret ` | 设置飞书 App Secret | +| `set_fs_allow ` | 设置飞书允许的发送者 open_id(逗号分隔) | +| `config_show` | 显示当前配置(Token 会脱敏显示) | +| `push_outbound ` | 手动向指定通道发送一条消息 | +| `restart` | 重启设备 | + +所有 `set_*` 命令会持久化到 NVS,`restart` 后生效。 + +### 快速上手示例(Linux + Telegram) + +```bash +# 1. 编译 +cd examples/messaging/echo_bot && tos.py build -t Linux + +# 2. 创建密钥文件 +cat > IM/im_secrets.h << 'EOF' +#define IM_SECRET_TG_TOKEN "123456:ABC-你的Token" +#define IM_SECRET_CHANNEL_MODE "telegram" +EOF + +# 3. 重新编译并运行 +tos.py build -t Linux +./dist/echo_bot_1.0.0/echo_bot_1.0.0 + +# 4. 在 Telegram 中给你的 Bot 发消息,它会回复相同内容。 +``` + +### 代理支持 + +如果设备无法直接访问 Telegram / Discord / 飞书 API,可配置 HTTP 或 SOCKS5 代理: + +**编译期配置**(在 `IM/im_secrets.h` 中): + +```c +#define IM_SECRET_PROXY_HOST "192.168.1.100" +#define IM_SECRET_PROXY_PORT "7890" +#define IM_SECRET_PROXY_TYPE "http" // "http" 或 "socks5" +``` + +**运行时配置**: + +代理设置存储在 NVS 的 `proxy_config` 命名空间下,键名为 `host`、`port`、`proxy_type`。 diff --git a/examples/messaging/echo_bot/app_default.config b/examples/messaging/echo_bot/app_default.config new file mode 100755 index 000000000..1ae7e8b16 --- /dev/null +++ b/examples/messaging/echo_bot/app_default.config @@ -0,0 +1,11 @@ +CONFIG_BOARD_CHOICE_T5AI=y +CONFIG_TUYA_T5AI_BOARD_EX_MODULE_NONE=y +CONFIG_ENABLE_MBEDTLS_SSL_MAX_FRAGMENT_LENGTH=y +CONFIG_ENABLE_MBEDTLS_SSL_MAX_CONTENT_LEN=4096 +CONFIG_ENABLE_CUSTOM_CONFIG=y +CONFIG_ENABLE_MBEDTLS_SHA512_C=y +CONFIG_ENABLE_MBEDTLS_SHA384_C=y +CONFIG_ENABLE_MBEDTLS_ECP_DP_SECP256R1_ENABLED=y +CONFIG_ENABLE_MBEDTLS_KEY_EXCHANGE_ECDHE_PSK=y +CONFIG_ENABLE_MBEDTLS_ECP_DP_SECP384R1_ENABLED=y +CONFIG_ENABLE_MBEDTLS_ECP_DP_CURVE25519_ENABLED=y diff --git a/examples/messaging/echo_bot/config/Linux.config b/examples/messaging/echo_bot/config/Linux.config new file mode 100644 index 000000000..116fb038d --- /dev/null +++ b/examples/messaging/echo_bot/config/Linux.config @@ -0,0 +1,12 @@ +# CONFIG_ENABLE_GPIO is not set +# CONFIG_ENABLE_I2C is not set +# CONFIG_ENABLE_SPI is not set +CONFIG_ENABLE_UART=y +CONFIG_ENABLE_BT_SERVICE=y +CONFIG_AI_HEAP_IN_PSRAM=y +CONFIG_ENABLE_MBEDTLS_SSL_MAX_CONTENT_LEN=10240 +CONFIG_ENABLE_CUSTOM_CONFIG=y +CONFIG_ENABLE_MBEDTLS_SSL_MAX_FRAGMENT_LENGTH=y +CONFIG_ENABLE_MBEDTLS_SHA384_C=y +CONFIG_ENABLE_MBEDTLS_ECP_DP_SECP384R1_ENABLED=y +CONFIG_ENABLE_MBEDTLS_ECP_DP_CURVE25519_ENABLED=y diff --git a/examples/messaging/echo_bot/config/T5AI.config b/examples/messaging/echo_bot/config/T5AI.config new file mode 100755 index 000000000..1ae7e8b16 --- /dev/null +++ b/examples/messaging/echo_bot/config/T5AI.config @@ -0,0 +1,11 @@ +CONFIG_BOARD_CHOICE_T5AI=y +CONFIG_TUYA_T5AI_BOARD_EX_MODULE_NONE=y +CONFIG_ENABLE_MBEDTLS_SSL_MAX_FRAGMENT_LENGTH=y +CONFIG_ENABLE_MBEDTLS_SSL_MAX_CONTENT_LEN=4096 +CONFIG_ENABLE_CUSTOM_CONFIG=y +CONFIG_ENABLE_MBEDTLS_SHA512_C=y +CONFIG_ENABLE_MBEDTLS_SHA384_C=y +CONFIG_ENABLE_MBEDTLS_ECP_DP_SECP256R1_ENABLED=y +CONFIG_ENABLE_MBEDTLS_KEY_EXCHANGE_ECDHE_PSK=y +CONFIG_ENABLE_MBEDTLS_ECP_DP_SECP384R1_ENABLED=y +CONFIG_ENABLE_MBEDTLS_ECP_DP_CURVE25519_ENABLED=y diff --git a/examples/messaging/echo_bot/include/echo_base.h b/examples/messaging/echo_bot/include/echo_base.h new file mode 100644 index 000000000..7d9ebda1b --- /dev/null +++ b/examples/messaging/echo_bot/include/echo_base.h @@ -0,0 +1,63 @@ +#ifndef __ECHO_BASE_H__ +#define __ECHO_BASE_H__ + +#include "tal_api.h" + +#include +#include +#include +#include +#include +#include + +#define ECHO_LOGE(tag, fmt, ...) PR_ERR("[%s] " fmt, tag, ##__VA_ARGS__) +#define ECHO_LOGW(tag, fmt, ...) PR_WARN("[%s] " fmt, tag, ##__VA_ARGS__) +#define ECHO_LOGI(tag, fmt, ...) PR_INFO("[%s] " fmt, tag, ##__VA_ARGS__) +#define ECHO_LOGD(tag, fmt, ...) PR_DEBUG("[%s] " fmt, tag, ##__VA_ARGS__) + +#define ECHO_WAIT_FOREVER 0xFFFFFFFFu + +static inline void echo_build_kv_key(const char *ns, const char *key, char *out, size_t out_size) +{ + if (!out || out_size == 0) return; + snprintf(out, out_size, "echo.%s.%s", ns ? ns : "", key ? key : ""); +} + +static inline OPERATE_RET echo_kv_set_string(const char *ns, const char *key, const char *value) +{ + if (!key || !value) return OPRT_INVALID_PARM; + char full_key[64] = {0}; + echo_build_kv_key(ns, key, full_key, sizeof(full_key)); + size_t len = strlen(value) + 1; + return tal_kv_set(full_key, (const uint8_t *)value, len); +} + +static inline OPERATE_RET echo_kv_get_string(const char *ns, const char *key, char *out, size_t out_size) +{ + if (!key || !out || out_size == 0) return OPRT_INVALID_PARM; + char full_key[64] = {0}; + echo_build_kv_key(ns, key, full_key, sizeof(full_key)); + uint8_t *buf = NULL; + size_t len = 0; + OPERATE_RET rt = tal_kv_get(full_key, &buf, &len); + if (rt != OPRT_OK || !buf || len == 0) { + out[0] = '\0'; + if (buf) tal_kv_free(buf); + return (rt == OPRT_OK) ? OPRT_NOT_FOUND : rt; + } + size_t copy_len = (len < out_size - 1) ? len : (out_size - 1); + memcpy(out, buf, copy_len); + out[copy_len] = '\0'; + tal_kv_free(buf); + return OPRT_OK; +} + +static inline OPERATE_RET echo_kv_del(const char *ns, const char *key) +{ + if (!key) return OPRT_INVALID_PARM; + char full_key[64] = {0}; + echo_build_kv_key(ns, key, full_key, sizeof(full_key)); + return tal_kv_del(full_key); +} + +#endif /* __ECHO_BASE_H__ */ diff --git a/examples/messaging/echo_bot/include/echo_config.h b/examples/messaging/echo_bot/include/echo_config.h new file mode 100644 index 000000000..1088f414e --- /dev/null +++ b/examples/messaging/echo_bot/include/echo_config.h @@ -0,0 +1,24 @@ +#ifndef __ECHO_CONFIG_H__ +#define __ECHO_CONFIG_H__ + +/* Echo bot app-level config. For IM settings see IM/im_config.h. */ +#if __has_include("echo_secrets.h") +#include "echo_secrets.h" +#endif + +#ifndef ECHO_SECRET_WIFI_SSID +#define ECHO_SECRET_WIFI_SSID "your_wifi_ssid" +#endif +#ifndef ECHO_SECRET_WIFI_PASS +#define ECHO_SECRET_WIFI_PASS "your_wifi_password" +#endif + +#define ECHO_WIFI_MAX_RETRY 10 +#define ECHO_WIFI_RETRY_BASE_MS 1000 +#define ECHO_WIFI_RETRY_MAX_MS 30000 + +#define ECHO_NVS_WIFI "wifi_config" +#define ECHO_NVS_KEY_SSID "ssid" +#define ECHO_NVS_KEY_PASS "password" + +#endif /* __ECHO_CONFIG_H__ */ diff --git a/examples/messaging/echo_bot/include/wifi_manager.h b/examples/messaging/echo_bot/include/wifi_manager.h new file mode 100644 index 000000000..2615b945b --- /dev/null +++ b/examples/messaging/echo_bot/include/wifi_manager.h @@ -0,0 +1,19 @@ +#ifndef __WIFI_MANAGER_H__ +#define __WIFI_MANAGER_H__ + +#include "echo_base.h" + +typedef void (*wifi_scan_result_cb_t)(uint32_t index, uint32_t total, const char *ssid, uint8_t channel, int rssi, + uint8_t security, const char *bssid, void *user_data); + +OPERATE_RET wifi_manager_init(void); +OPERATE_RET wifi_manager_start(void); +OPERATE_RET wifi_manager_wait_connected(uint32_t timeout_ms); +bool wifi_manager_is_connected(void); +const char *wifi_manager_get_ip(void); +const char *wifi_manager_get_target_ssid(void); +OPERATE_RET wifi_manager_set_credentials(const char *ssid, const char *password); +void wifi_manager_set_scan_result_cb(wifi_scan_result_cb_t cb, void *user_data); +OPERATE_RET wifi_manager_scan_and_print(void); + +#endif /* __WIFI_MANAGER_H__ */ diff --git a/examples/messaging/echo_bot/src/cli_echo.c b/examples/messaging/echo_bot/src/cli_echo.c new file mode 100644 index 000000000..33052f261 --- /dev/null +++ b/examples/messaging/echo_bot/src/cli_echo.c @@ -0,0 +1,262 @@ +/** + * @file cli_echo.c + * @brief Minimal CLI for echo_bot: WiFi, channel switch, push_outbound, config_show, restart. + */ + +#include "echo_cli.h" +#include "im_api.h" +#include "echo_config.h" +#include "tal_cli.h" +#include "wifi_manager.h" + +#include +#include +#include + +static bool s_inited = false; + +static void cli_echof(const char *fmt, ...) +{ + char line[512] = {0}; + va_list ap; + va_start(ap, fmt); + vsnprintf(line, sizeof(line), fmt, ap); + va_end(ap); + tal_cli_echo(line); +} + +static void mask_copy(const char *src, char *out, size_t out_size) +{ + if (!out || out_size == 0) return; + if (!src || src[0] == '\0') { + snprintf(out, out_size, "(empty)"); + return; + } + size_t len = strlen(src); + if (len <= 4) + snprintf(out, out_size, "****"); + else + snprintf(out, out_size, "%.4s****", src); +} + +typedef OPERATE_RET (*kv_getter_t)(const char *, const char *, char *, size_t); + +static void print_cfg(const char *label, const char *ns, const char *key, const char *build_val, bool mask, + kv_getter_t getter) +{ + char kv_val[128] = {0}; + const char *val = "(empty)"; + if (getter(ns, key, kv_val, sizeof(kv_val)) == OPRT_OK && kv_val[0] != '\0') + val = kv_val; + else if (build_val && build_val[0] != '\0') + val = build_val; + char buf[128] = {0}; + if (mask) + mask_copy(val, buf, sizeof(buf)); + else + snprintf(buf, sizeof(buf), "%s", val); + cli_echof("%-14s: %s", label, buf); +} + +static bool valid_channel_mode(const char *mode) +{ + return mode && (strcmp(mode, "telegram") == 0 || strcmp(mode, "discord") == 0 || strcmp(mode, "feishu") == 0); +} + +static void cmd_help(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + cli_echof("help | set_wifi | wifi_status | wifi_scan"); + cli_echof("set_tg_token | set_dc_token | set_dc_channel "); + cli_echof("set_fs_appid | set_fs_appsecret | set_fs_allow "); + cli_echof("set_channel_mode | push_outbound "); + cli_echof("config_show | restart"); +} + +static void cmd_set_wifi(int argc, char *argv[]) +{ + if (argc < 3) { + cli_echof("usage: set_wifi "); + return; + } + cli_echof("set_wifi rt=%d", wifi_manager_set_credentials(argv[1], argv[2])); +} + +static void cmd_wifi_status(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + cli_echof("connected: %s ip: %s", wifi_manager_is_connected() ? "yes" : "no", wifi_manager_get_ip()); +} + +static void scan_cb(uint32_t index, uint32_t total, const char *ssid, uint8_t channel, int rssi, + uint8_t security, const char *bssid, void *user_data) +{ + (void)total; + (void)user_data; + cli_echof("ap[%u] ssid=%s ch=%u rssi=%d", (unsigned)index, ssid ? ssid : "", + (unsigned)channel, rssi); +} + +static void cmd_wifi_scan(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + wifi_manager_set_scan_result_cb(scan_cb, NULL); + cli_echof("wifi_scan rt=%d", wifi_manager_scan_and_print()); + wifi_manager_set_scan_result_cb(NULL, NULL); +} + +static void cmd_set_tg_token(int argc, char *argv[]) +{ + if (argc < 2) { + cli_echof("usage: set_tg_token "); + return; + } + cli_echof("set_tg_token rt=%d", telegram_set_token(argv[1])); +} + +static void cmd_set_dc_token(int argc, char *argv[]) +{ + if (argc < 2) { + cli_echof("usage: set_dc_token "); + return; + } + cli_echof("set_dc_token rt=%d", discord_set_token(argv[1])); +} + +static void cmd_set_dc_channel(int argc, char *argv[]) +{ + if (argc < 2) { + cli_echof("usage: set_dc_channel "); + return; + } + cli_echof("set_dc_channel rt=%d", discord_set_channel_id(argv[1])); +} + +static void cmd_set_channel_mode(int argc, char *argv[]) +{ + if (argc < 2) { + cli_echof("usage: set_channel_mode "); + return; + } + if (!valid_channel_mode(argv[1])) { + cli_echof("invalid mode: %s", argv[1]); + return; + } + cli_echof("set_channel_mode rt=%d", im_kv_set_string(IM_NVS_BOT, IM_NVS_KEY_CHANNEL_MODE, argv[1])); +} + +static void cmd_set_fs_appid(int argc, char *argv[]) +{ + if (argc < 2) { + cli_echof("usage: set_fs_appid "); + return; + } + cli_echof("set_fs_appid rt=%d", feishu_set_app_id(argv[1])); +} + +static void cmd_set_fs_appsecret(int argc, char *argv[]) +{ + if (argc < 2) { + cli_echof("usage: set_fs_appsecret "); + return; + } + cli_echof("set_fs_appsecret rt=%d", feishu_set_app_secret(argv[1])); +} + +static void cmd_set_fs_allow(int argc, char *argv[]) +{ + if (argc < 2) { + cli_echof("usage: set_fs_allow "); + return; + } + cli_echof("set_fs_allow rt=%d", feishu_set_allow_from(argv[1])); +} + +static void cmd_push_outbound(int argc, char *argv[]) +{ + if (argc < 4) { + cli_echof("usage: push_outbound "); + return; + } + size_t len = 0; + for (int i = 3; i < argc; i++) len += strlen(argv[i]) + (i > 3 ? 1 : 0); + char *content = tal_malloc(len + 1); + if (!content) { + cli_echof("push_outbound oom"); + return; + } + content[0] = '\0'; + for (int i = 3; i < argc; i++) { + if (i > 3) strcat(content, " "); + strcat(content, argv[i]); + } + im_msg_t msg = {0}; + strncpy(msg.channel, argv[1], sizeof(msg.channel) - 1); + msg.channel[sizeof(msg.channel) - 1] = '\0'; + strncpy(msg.chat_id, argv[2], sizeof(msg.chat_id) - 1); + msg.chat_id[sizeof(msg.chat_id) - 1] = '\0'; + msg.content = content; + OPERATE_RET rt = message_bus_push_outbound(&msg); + if (rt != OPRT_OK) { + cli_echof("push_outbound failed: %d", rt); + tal_free(content); + } else { + cli_echof("push_outbound ok"); + } +} + +static void cmd_config_show(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + cli_echof("=== Echo Bot Config ==="); + print_cfg("WiFi SSID", ECHO_NVS_WIFI, ECHO_NVS_KEY_SSID, ECHO_SECRET_WIFI_SSID, false, echo_kv_get_string); + print_cfg("WiFi Pass", ECHO_NVS_WIFI, ECHO_NVS_KEY_PASS, ECHO_SECRET_WIFI_PASS, true, echo_kv_get_string); + print_cfg("TG Token", IM_NVS_TG, IM_NVS_KEY_TG_TOKEN, IM_SECRET_TG_TOKEN, true, im_kv_get_string); + print_cfg("DC Token", IM_NVS_DC, IM_NVS_KEY_DC_TOKEN, IM_SECRET_DC_TOKEN, true, im_kv_get_string); + print_cfg("DC Channel", IM_NVS_DC, IM_NVS_KEY_DC_CHANNEL_ID, IM_SECRET_DC_CHANNEL_ID, false, im_kv_get_string); + print_cfg("FS AppID", IM_NVS_FS, IM_NVS_KEY_FS_APP_ID, IM_SECRET_FS_APP_ID, true, im_kv_get_string); + print_cfg("FS Secret", IM_NVS_FS, IM_NVS_KEY_FS_APP_SECRET, IM_SECRET_FS_APP_SECRET, true, im_kv_get_string); + print_cfg("FS Allow", IM_NVS_FS, IM_NVS_KEY_FS_ALLOW_FROM, IM_SECRET_FS_ALLOW_FROM, false, im_kv_get_string); + print_cfg("ChannelMode", IM_NVS_BOT, IM_NVS_KEY_CHANNEL_MODE, IM_SECRET_CHANNEL_MODE, false, im_kv_get_string); + cli_echof("======================"); +} + +static void cmd_restart(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + cli_echof("restarting..."); + tal_system_reset(); +} + +static const cli_cmd_t s_cmds[] = { + {.name = "help", .help = "List commands", .func = cmd_help}, + {.name = "set_wifi", .help = "Set WiFi SSID and password", .func = cmd_set_wifi}, + {.name = "wifi_status", .help = "Show WiFi status", .func = cmd_wifi_status}, + {.name = "wifi_scan", .help = "Scan WiFi APs", .func = cmd_wifi_scan}, + {.name = "set_tg_token", .help = "Set Telegram bot token", .func = cmd_set_tg_token}, + {.name = "set_dc_token", .help = "Set Discord bot token", .func = cmd_set_dc_token}, + {.name = "set_dc_channel", .help = "Set Discord channel ID", .func = cmd_set_dc_channel}, + {.name = "set_channel_mode", .help = "Set channel: telegram|discord|feishu", .func = cmd_set_channel_mode}, + {.name = "set_fs_appid", .help = "Set Feishu app_id", .func = cmd_set_fs_appid}, + {.name = "set_fs_appsecret", .help = "Set Feishu app_secret", .func = cmd_set_fs_appsecret}, + {.name = "set_fs_allow", .help = "Set Feishu allow_from open_id CSV", .func = cmd_set_fs_allow}, + {.name = "push_outbound", .help = "Push message to outbound bus", .func = cmd_push_outbound}, + {.name = "config_show", .help = "Show config", .func = cmd_config_show}, + {.name = "restart", .help = "Restart device", .func = cmd_restart}, +}; + +OPERATE_RET echo_cli_init(void) +{ + if (s_inited) return OPRT_OK; + OPERATE_RET rt = tal_cli_init(); + if (rt != OPRT_OK) return rt; + rt = tal_cli_cmd_register(s_cmds, sizeof(s_cmds) / sizeof(s_cmds[0])); + if (rt != OPRT_OK) return rt; + s_inited = true; + return OPRT_OK; +} diff --git a/examples/messaging/echo_bot/src/echo_cli.h b/examples/messaging/echo_bot/src/echo_cli.h new file mode 100644 index 000000000..5b0ca7f6d --- /dev/null +++ b/examples/messaging/echo_bot/src/echo_cli.h @@ -0,0 +1,8 @@ +#ifndef __ECHO_CLI_H__ +#define __ECHO_CLI_H__ + +#include "echo_base.h" + +OPERATE_RET echo_cli_init(void); + +#endif /* __ECHO_CLI_H__ */ diff --git a/examples/messaging/echo_bot/src/tuya_app_main.c b/examples/messaging/echo_bot/src/tuya_app_main.c new file mode 100644 index 000000000..192171182 --- /dev/null +++ b/examples/messaging/echo_bot/src/tuya_app_main.c @@ -0,0 +1,267 @@ +/** + * @file tuya_app_main.c + * @brief Echo bot: WiFi + channel switch (telegram/discord/feishu) + echo reply. + * Receives message -> replies with the same content. No LLM/agent. + */ + +#include "echo_cli.h" +#include "echo_base.h" +#include "im_api.h" +#include "echo_config.h" +#include "wifi_manager.h" + +#include "netmgr.h" +#include "tal_fs.h" +#include "tal_system.h" +#include "tkl_output.h" +#include "tuya_register_center.h" +#include "tuya_tls.h" + +#include +#include +#include + +#if defined(ENABLE_LIBLWIP) && (ENABLE_LIBLWIP == 1) +#include "lwip_init.h" +#endif + +static const char *TAG = "echo_bot"; +static THREAD_HANDLE s_outbound_thd = NULL; +static THREAD_HANDLE s_echo_thd = NULL; + +typedef enum { + ECHO_MODE_TELEGRAM = 0, + ECHO_MODE_DISCORD, + ECHO_MODE_FEISHU, +} echo_channel_mode_t; + +static bool str_ieq(const char *a, const char *b) +{ + if (!a || !b) return false; + while (*a && *b) { + if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return false; + a++; + b++; + } + return *a == '\0' && *b == '\0'; +} + +static echo_channel_mode_t parse_channel_mode(const char *mode) +{ + if (!mode || mode[0] == '\0') return ECHO_MODE_TELEGRAM; + if (str_ieq(mode, "telegram")) return ECHO_MODE_TELEGRAM; + if (str_ieq(mode, "discord")) return ECHO_MODE_DISCORD; + if (str_ieq(mode, "feishu")) return ECHO_MODE_FEISHU; + return ECHO_MODE_TELEGRAM; +} + +static const char *channel_mode_str(echo_channel_mode_t mode) +{ + switch (mode) { + case ECHO_MODE_TELEGRAM: return "telegram"; + case ECHO_MODE_DISCORD: return "discord"; + case ECHO_MODE_FEISHU: return "feishu"; + default: return "feishu"; + } +} + +static echo_channel_mode_t load_channel_mode(void) +{ + char mode_buf[24] = {0}; + if (IM_SECRET_CHANNEL_MODE[0] != '\0') { + snprintf(mode_buf, sizeof(mode_buf), "%s", IM_SECRET_CHANNEL_MODE); + } else { + snprintf(mode_buf, sizeof(mode_buf), "telegram"); + } + char kv_mode[24] = {0}; + if (im_kv_get_string(IM_NVS_BOT, IM_NVS_KEY_CHANNEL_MODE, kv_mode, sizeof(kv_mode)) == OPRT_OK && + kv_mode[0] != '\0') { + snprintf(mode_buf, sizeof(mode_buf), "%s", kv_mode); + } + if (!(str_ieq(mode_buf, "telegram") || str_ieq(mode_buf, "discord") || str_ieq(mode_buf, "feishu"))) { + snprintf(mode_buf, sizeof(mode_buf), "telegram"); + } + return parse_channel_mode(mode_buf); +} + +static void outbound_dispatch_task(void *arg) +{ + (void)arg; + ECHO_LOGI(TAG, "outbound dispatcher started"); + while (1) { + im_msg_t msg = {0}; + if (message_bus_pop_outbound(&msg, ECHO_WAIT_FOREVER) != OPRT_OK) continue; + if (strcmp(msg.channel, IM_CHAN_TELEGRAM) == 0) { + (void)telegram_send_message(msg.chat_id, msg.content ? msg.content : ""); + } else if (strcmp(msg.channel, IM_CHAN_DISCORD) == 0) { + (void)discord_send_message(msg.chat_id, msg.content ? msg.content : ""); + } else if (strcmp(msg.channel, IM_CHAN_FEISHU) == 0) { + (void)feishu_send_message(msg.chat_id, msg.content ? msg.content : ""); + } else if (strcmp(msg.channel, "system") == 0) { + ECHO_LOGI(TAG, "system msg: %s", msg.content ? msg.content : ""); + } + free(msg.content); + } +} + +#define ECHO_INBOUND_POLL_MS 100 + +static void echo_loop_task(void *arg) +{ + (void)arg; + ECHO_LOGI(TAG, "echo loop started"); + while (1) { + im_msg_t in = {0}; + if (message_bus_pop_inbound(&in, ECHO_INBOUND_POLL_MS) != OPRT_OK) continue; + im_msg_t out = {0}; + strncpy(out.channel, in.channel, sizeof(out.channel) - 1); + out.channel[sizeof(out.channel) - 1] = '\0'; + strncpy(out.chat_id, in.chat_id, sizeof(out.chat_id) - 1); + out.chat_id[sizeof(out.chat_id) - 1] = '\0'; + out.content = in.content ? strdup(in.content) : strdup(""); + if (out.content) { + if (message_bus_push_outbound(&out) != OPRT_OK) { + free(out.content); + } + } + tal_free(in.content); + } +} + +static OPERATE_RET start_outbound_dispatcher(void) +{ + if (s_outbound_thd) return OPRT_OK; + THREAD_CFG_T cfg = {0}; + cfg.stackDepth = IM_OUTBOUND_STACK; + cfg.priority = THREAD_PRIO_1; + cfg.thrdname = "echo_out"; + return tal_thread_create_and_start(&s_outbound_thd, NULL, NULL, outbound_dispatch_task, NULL, &cfg); +} + +static OPERATE_RET start_echo_loop(void) +{ + if (s_echo_thd) return OPRT_OK; + THREAD_CFG_T cfg = {0}; + cfg.stackDepth = 8 * 1024; + cfg.priority = THREAD_PRIO_1; + cfg.thrdname = "echo_loop"; + return tal_thread_create_and_start(&s_echo_thd, NULL, NULL, echo_loop_task, NULL, &cfg); +} + +static void start_channel_services(echo_channel_mode_t mode) +{ + (void)start_outbound_dispatcher(); + (void)start_echo_loop(); + + bool enable_tg = (mode == ECHO_MODE_TELEGRAM); + bool enable_dc = (mode == ECHO_MODE_DISCORD); + bool enable_fs = (mode == ECHO_MODE_FEISHU); + + if (enable_tg) { + OPERATE_RET rt = telegram_bot_start(); + if (rt != OPRT_OK && rt != OPRT_NOT_FOUND) + ECHO_LOGW(TAG, "telegram_bot_start failed: %d", rt); + } + if (enable_dc) { + OPERATE_RET rt = discord_bot_start(); + if (rt != OPRT_OK && rt != OPRT_NOT_FOUND) + ECHO_LOGW(TAG, "discord_bot_start failed: %d", rt); + } + if (enable_fs) { + OPERATE_RET rt = feishu_bot_start(); + if (rt != OPRT_OK && rt != OPRT_NOT_FOUND) + ECHO_LOGW(TAG, "feishu_bot_start failed: %d", rt); + } + ECHO_LOGI(TAG, "channel mode=%s", channel_mode_str(mode)); +} + +static void runtime_init(void) +{ + static bool inited = false; + if (inited) return; + cJSON_InitHooks(&(cJSON_Hooks){.malloc_fn = tal_malloc, .free_fn = tal_free}); + (void)tal_log_init(TAL_LOG_LEVEL_INFO, 1024, (TAL_LOG_OUTPUT_CB)tkl_log_output); + (void)tal_kv_init(&(tal_kv_cfg_t){.seed = "echo_bot_seed", .key = "echo_bot_key"}); + (void)tal_sw_timer_init(); + (void)tal_workq_init(); + (void)tuya_tls_init(); + (void)tuya_register_center_init(); + inited = true; +} + +static void network_init(void) +{ +#if defined(ENABLE_WIRED) && (ENABLE_WIRED == 1) + (void)netmgr_init(NETCONN_WIRED); +#elif defined(ENABLE_CELLULAR) && (ENABLE_CELLULAR == 1) + (void)netmgr_init(NETCONN_CELLULAR); +#endif +} + +int user_main(void) +{ + runtime_init(); + ECHO_LOGI(TAG, "Echo bot start, heap=%d", tal_system_get_free_heap_size()); + + (void)message_bus_init(); +#if defined(ENABLE_WIFI) && (ENABLE_WIFI == 1) + (void)wifi_manager_init(); +#endif + (void)http_proxy_init(); + (void)telegram_bot_init(); + (void)discord_bot_init(); + (void)feishu_bot_init(); + +#if OPERATING_SYSTEM != SYSTEM_LINUX + (void)echo_cli_init(); +#endif + +#if defined(ENABLE_LIBLWIP) && (ENABLE_LIBLWIP == 1) + TUYA_LwIP_Init(); +#endif + network_init(); + +#if defined(ENABLE_WIFI) && (ENABLE_WIFI == 1) + if (wifi_manager_start() == OPRT_OK) { + if (wifi_manager_wait_connected(30000) == OPRT_OK) { + echo_channel_mode_t mode = load_channel_mode(); + start_channel_services(mode); + } else { + ECHO_LOGW(TAG, "WiFi timeout; set_wifi then restart"); + } + } +#else + echo_channel_mode_t mode = load_channel_mode(); + start_channel_services(mode); +#endif + + ECHO_LOGI(TAG, "Echo bot ready"); + while (1) { + tal_system_sleep(1000); + } + return 0; +} + +#if OPERATING_SYSTEM == SYSTEM_LINUX +int main(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + return user_main(); +} +#else +static THREAD_HANDLE s_main_thd = NULL; +static void main_thd_entry(void *arg) +{ + (void)arg; + user_main(); +} +void tuya_app_main(void) +{ + THREAD_CFG_T cfg = {0}; + cfg.stackDepth = 1024 * 6; + cfg.priority = THREAD_PRIO_1; + cfg.thrdname = "echo_main"; + (void)tal_thread_create_and_start(&s_main_thd, NULL, NULL, main_thd_entry, NULL, &cfg); +} +#endif diff --git a/examples/messaging/echo_bot/src/wifi/wifi_manager.c b/examples/messaging/echo_bot/src/wifi/wifi_manager.c new file mode 100644 index 000000000..2d55b05dc --- /dev/null +++ b/examples/messaging/echo_bot/src/wifi/wifi_manager.c @@ -0,0 +1,406 @@ +#include "wifi_manager.h" + +#include "echo_config.h" + +static const char *TAG = "wifi"; +static wifi_scan_result_cb_t s_scan_result_cb = NULL; +static void *s_scan_result_cb_ctx = NULL; + +void wifi_manager_set_scan_result_cb(wifi_scan_result_cb_t cb, void *user_data) +{ + s_scan_result_cb = cb; + s_scan_result_cb_ctx = user_data; +} + +#if defined(ENABLE_WIFI) && (ENABLE_WIFI == 1) + +#include "tal_wifi.h" + +#ifndef WIFI_SSID_LEN +#define WIFI_SSID_LEN 32 +#endif + +#ifndef WIFI_PASSWD_LEN +#define WIFI_PASSWD_LEN 64 +#endif + +static volatile bool s_connected = false; +static bool s_wifi_inited = false; +static char s_ip_str[40] = "0.0.0.0"; +static char s_target_ssid[WIFI_SSID_LEN + 1] = {0}; +static char s_target_pass[WIFI_PASSWD_LEN + 1] = {0}; +static volatile bool s_retry_pending = false; +static uint32_t s_retry_count = 0; +static uint64_t s_next_retry_ms = 0; + +static void reset_link_state(void) +{ + s_connected = false; + snprintf(s_ip_str, sizeof(s_ip_str), "0.0.0.0"); +} + +static void reset_retry_state(void) +{ + s_retry_pending = false; + s_retry_count = 0; + s_next_retry_ms = 0; +} + +static uint32_t wifi_retry_delay_ms(uint32_t retry_count) +{ + uint32_t delay = ECHO_WIFI_RETRY_BASE_MS; + while (retry_count > 0 && delay < ECHO_WIFI_RETRY_MAX_MS) { + if (delay > (ECHO_WIFI_RETRY_MAX_MS / 2)) { + delay = ECHO_WIFI_RETRY_MAX_MS; + break; + } + delay <<= 1; + retry_count--; + } + if (delay > ECHO_WIFI_RETRY_MAX_MS) { + delay = ECHO_WIFI_RETRY_MAX_MS; + } + return delay; +} + +static void schedule_wifi_retry(const char *reason) +{ + if (s_target_ssid[0] == '\0') { + return; + } + if (s_retry_count >= ECHO_WIFI_MAX_RETRY) { + s_retry_pending = false; + ECHO_LOGW(TAG, "wifi retry exhausted (%u), reason=%s", (unsigned)ECHO_WIFI_MAX_RETRY, + reason ? reason : "unknown"); + return; + } + + uint32_t delay_ms = wifi_retry_delay_ms(s_retry_count); + s_next_retry_ms = tal_time_get_posix_ms() + delay_ms; + s_retry_pending = true; + ECHO_LOGW(TAG, "schedule wifi retry %u/%u in %u ms, reason=%s", (unsigned)(s_retry_count + 1), + (unsigned)ECHO_WIFI_MAX_RETRY, (unsigned)delay_ms, reason ? reason : "unknown"); +} + +static void update_ip_from_wifi(void) +{ + NW_IP_S ip = {0}; + if (tal_wifi_get_ip(WF_STATION, &ip) != OPRT_OK) { + snprintf(s_ip_str, sizeof(s_ip_str), "0.0.0.0"); + return; + } + +#ifdef nwipstr + snprintf(s_ip_str, sizeof(s_ip_str), "%s", ip.nwipstr); +#else + snprintf(s_ip_str, sizeof(s_ip_str), "%s", ip.ip); +#endif +} + +static void wifi_event_callback(WF_EVENT_E event, void *arg) +{ + (void)arg; + + switch (event) { + case WFE_CONNECTED: + s_connected = true; + reset_retry_state(); + update_ip_from_wifi(); + ECHO_LOGI(TAG, "wifi connected, ip=%s", s_ip_str); + break; + + case WFE_CONNECT_FAILED: + reset_link_state(); + ECHO_LOGW(TAG, "wifi connect failed"); + schedule_wifi_retry("connect_failed"); + break; + + case WFE_DISCONNECTED: + reset_link_state(); + ECHO_LOGW(TAG, "wifi disconnected"); + schedule_wifi_retry("disconnected"); + break; + + default: + break; + } +} + +OPERATE_RET wifi_manager_init(void) +{ + reset_link_state(); + if (s_wifi_inited) { + return OPRT_OK; + } + + OPERATE_RET rt = tal_wifi_init(wifi_event_callback); + if (rt != OPRT_OK) { + ECHO_LOGW(TAG, "tal_wifi_init failed: %d", rt); + return rt; + } + + s_wifi_inited = true; + return OPRT_OK; +} + +OPERATE_RET wifi_manager_start(void) +{ + OPERATE_RET rt = wifi_manager_init(); + if (rt != OPRT_OK) { + return rt; + } + + char ssid[WIFI_SSID_LEN + 1] = {0}; + char pass[WIFI_PASSWD_LEN + 1] = {0}; + + (void)echo_kv_get_string(ECHO_NVS_WIFI, ECHO_NVS_KEY_SSID, ssid, sizeof(ssid)); + (void)echo_kv_get_string(ECHO_NVS_WIFI, ECHO_NVS_KEY_PASS, pass, sizeof(pass)); + + if (ssid[0] == '\0' && ECHO_SECRET_WIFI_SSID[0] != '\0') { + snprintf(ssid, sizeof(ssid), "%s", ECHO_SECRET_WIFI_SSID); + snprintf(pass, sizeof(pass), "%s", ECHO_SECRET_WIFI_PASS); + } + + if (ssid[0] == '\0') { + return OPRT_NOT_FOUND; + } + + reset_link_state(); + reset_retry_state(); + + snprintf(s_target_ssid, sizeof(s_target_ssid), "%s", ssid); + snprintf(s_target_pass, sizeof(s_target_pass), "%s", pass); + + // Keep the same visible connect print style as official STA example. + PR_NOTICE("connect wifi ssid: %s", s_target_ssid); + + rt = tal_wifi_set_work_mode(WWM_STATION); + if (rt != OPRT_OK) { + ECHO_LOGW(TAG, "tal_wifi_set_work_mode station failed: %d", rt); + return rt; + } + + ECHO_LOGI(TAG, "connecting wifi ssid=%s", s_target_ssid); + rt = tal_wifi_station_connect((int8_t *)ssid, (int8_t *)pass); + if (rt != OPRT_OK) { + ECHO_LOGW(TAG, "tal_wifi_station_connect failed: %d", rt); + schedule_wifi_retry("connect_call_failed"); + } + + return OPRT_OK; +} + +OPERATE_RET wifi_manager_wait_connected(uint32_t timeout_ms) +{ + uint64_t start = tal_time_get_posix_ms(); + while (1) { + if (s_connected) { + update_ip_from_wifi(); + return OPRT_OK; + } + + uint64_t now = tal_time_get_posix_ms(); + if (s_retry_pending && now >= s_next_retry_ms) { + s_retry_pending = false; + if (s_retry_count < ECHO_WIFI_MAX_RETRY) { + s_retry_count++; + ECHO_LOGI(TAG, "retrying wifi connect attempt %u/%u ssid=%s", (unsigned)s_retry_count, + (unsigned)ECHO_WIFI_MAX_RETRY, s_target_ssid); + OPERATE_RET rt = tal_wifi_station_connect((int8_t *)s_target_ssid, (int8_t *)s_target_pass); + if (rt != OPRT_OK) { + ECHO_LOGW(TAG, "retry connect call failed: %d", rt); + schedule_wifi_retry("retry_call_failed"); + } + } + } + + if (timeout_ms != UINT32_MAX) { + if (now > start && (now - start) >= timeout_ms) { + s_connected = false; + snprintf(s_ip_str, sizeof(s_ip_str), "0.0.0.0"); + return OPRT_TIMEOUT; + } + } + + tal_system_sleep(200); + } +} + +bool wifi_manager_is_connected(void) +{ + if (s_connected) { + update_ip_from_wifi(); + } + + return s_connected; +} + +const char *wifi_manager_get_ip(void) +{ + if (wifi_manager_is_connected()) { + return s_ip_str; + } + + return "0.0.0.0"; +} + +const char *wifi_manager_get_target_ssid(void) +{ + return s_target_ssid[0] ? s_target_ssid : ""; +} + +OPERATE_RET wifi_manager_set_credentials(const char *ssid, const char *password) +{ + if (!ssid || !password) { + return OPRT_INVALID_PARM; + } + + OPERATE_RET rt = echo_kv_set_string(ECHO_NVS_WIFI, ECHO_NVS_KEY_SSID, ssid); + if (rt != OPRT_OK) { + return rt; + } + + rt = echo_kv_set_string(ECHO_NVS_WIFI, ECHO_NVS_KEY_PASS, password); + if (rt != OPRT_OK) { + return rt; + } + + return OPRT_OK; +} + +OPERATE_RET wifi_manager_scan_and_print(void) +{ + OPERATE_RET rt = wifi_manager_init(); + if (rt != OPRT_OK) { + ECHO_LOGW(TAG, "wifi scan init failed: %d", rt); + return rt; + } + + WF_WK_MD_E mode = WWM_POWERDOWN; + rt = tal_wifi_get_work_mode(&mode); + if (rt != OPRT_OK) { + ECHO_LOGW(TAG, "wifi scan get mode failed: %d", rt); + return rt; + } + if (mode != WWM_STATION && mode != WWM_STATIONAP) { + rt = tal_wifi_set_work_mode(WWM_STATION); + if (rt != OPRT_OK) { + ECHO_LOGW(TAG, "wifi scan set station mode failed: %d", rt); + return rt; + } + tal_system_sleep(200); + } + + AP_IF_S *ap_list = NULL; + uint32_t ap_num = 0; + + for (int attempt = 0; attempt < 2; attempt++) { + rt = tal_wifi_all_ap_scan(&ap_list, &ap_num); + if (rt == OPRT_OK && ap_num > 0) { + break; + } + + if (ap_list) { + (void)tal_wifi_release_ap(ap_list); + ap_list = NULL; + } + ap_num = 0; + + if (attempt == 0) { + tal_system_sleep(300); + } + } + + if (rt != OPRT_OK) { + ECHO_LOGW(TAG, "wifi scan failed: %d", rt); + return rt; + } + + if (ap_num == 0 || !ap_list) { + ECHO_LOGW(TAG, "wifi scan found 0 ap(s)"); + return OPRT_NOT_FOUND; + } + + ECHO_LOGI(TAG, "wifi scan found %u ap(s)", (unsigned)ap_num); + + for (uint32_t i = 0; i < ap_num; i++) { + AP_IF_S *ap = &ap_list[i]; + char ssid[WIFI_SSID_LEN + 1] = {0}; + size_t ssid_len = ap->s_len; + if (ssid_len == 0) { + ssid_len = strnlen((const char *)ap->ssid, WIFI_SSID_LEN); + } + if (ssid_len > WIFI_SSID_LEN) { + ssid_len = WIFI_SSID_LEN; + } + if (ssid_len > 0) { + memcpy(ssid, ap->ssid, ssid_len); + ssid[ssid_len] = '\0'; + } + + char bssid[18] = {0}; + snprintf(bssid, sizeof(bssid), "%02X:%02X:%02X:%02X:%02X:%02X", ap->bssid[0], ap->bssid[1], ap->bssid[2], + ap->bssid[3], ap->bssid[4], ap->bssid[5]); + + ECHO_LOGI(TAG, "ap[%u] ssid=%s ch=%u rssi=%d sec=%u bssid=%s", (unsigned)i, ssid[0] ? ssid : "", + (unsigned)ap->channel, (int)ap->rssi, (unsigned)ap->security, bssid); + + if (s_scan_result_cb) { + s_scan_result_cb(i, ap_num, ssid[0] ? ssid : "", ap->channel, (int)ap->rssi, ap->security, bssid, + s_scan_result_cb_ctx); + } + } + + (void)tal_wifi_release_ap(ap_list); + return OPRT_OK; +} + +#else + +OPERATE_RET wifi_manager_init(void) +{ + ECHO_LOGW(TAG, "wifi disabled (ENABLE_WIFI!=1)"); + return OPRT_NOT_SUPPORTED; +} + +OPERATE_RET wifi_manager_start(void) +{ + return OPRT_NOT_SUPPORTED; +} + +OPERATE_RET wifi_manager_wait_connected(uint32_t timeout_ms) +{ + (void)timeout_ms; + return OPRT_NOT_SUPPORTED; +} + +bool wifi_manager_is_connected(void) +{ + return false; +} + +const char *wifi_manager_get_ip(void) +{ + return "0.0.0.0"; +} + +OPERATE_RET wifi_manager_set_credentials(const char *ssid, const char *password) +{ + if (!ssid || !password) { + return OPRT_INVALID_PARM; + } + // still allow persisting creds even if WiFi feature is off + OPERATE_RET rt = echo_kv_set_string(ECHO_NVS_WIFI, ECHO_NVS_KEY_SSID, ssid); + if (rt != OPRT_OK) { + return rt; + } + return echo_kv_set_string(ECHO_NVS_WIFI, ECHO_NVS_KEY_PASS, password); +} + +OPERATE_RET wifi_manager_scan_and_print(void) +{ + ECHO_LOGW(TAG, "wifi scan disabled (ENABLE_WIFI!=1)"); + return OPRT_NOT_SUPPORTED; +} + +#endif