diff --git a/src/channels/channel.h b/src/channels/channel.h index 73b4cd7..8654d91 100644 --- a/src/channels/channel.h +++ b/src/channels/channel.h @@ -37,6 +37,19 @@ typedef struct channel_incoming_msg { /** Clear and free incoming message fields. Safe to call repeatedly or on zeroed. */ void channel_incoming_msg_clear(channel_incoming_msg_t *msg); +/** + * Byte length of the next outbound piece of @p text. + * The piece is at most @p max_bytes and stops before a multibyte UTF-8 + * character that would not fit, so chat APIs do not see a split code point. + * + * Example: `channel_outbound_chunk_len("hello", 2)` returns 2. + * + * @param text NUL-terminated text. NULL returns 0. + * @param max_bytes Maximum piece size in bytes. 0 returns 0. + * @return Byte length of the next piece, or 0 when @p text is empty. + */ +size_t channel_outbound_chunk_len(const char *text, size_t max_bytes); + /** * Channel vtable: init, poll, send, cleanup. CLI and Telegram implement this. */ diff --git a/src/channels/channel_common.c b/src/channels/channel_common.c index 59b48f0..5a1b68e 100644 --- a/src/channels/channel_common.c +++ b/src/channels/channel_common.c @@ -31,6 +31,23 @@ const channel_t *channel_get_by_name(const char *name) return NULL; } +size_t channel_outbound_chunk_len(const char *text, size_t max_bytes) +{ + size_t len; + size_t n; + + if (!text || max_bytes == 0) + return 0; + len = strlen(text); + if (len <= max_bytes) + return len; + /* Walk back over UTF-8 continuation bytes so the cut sits on a boundary. */ + n = max_bytes; + while (n > 0 && ((unsigned char)text[n] & 0xC0) == 0x80) + n--; + return n > 0 ? n : max_bytes; +} + void channel_incoming_msg_clear(channel_incoming_msg_t *msg) { if (!msg) return; diff --git a/src/channels/discord.c b/src/channels/discord.c index 0b52b97..84f3455 100644 --- a/src/channels/discord.c +++ b/src/channels/discord.c @@ -862,25 +862,13 @@ static int discord_recipient_channel_id(const char *recipient, char *out, size_t return 0; } -static int discord_send(const char *recipient, const char *text, - const channel_attachment_t *attachments, size_t att_count) +static int discord_post_text(const char *channel_id, const char *text, const char *auth_hdr) { - struct discord_ctx *dc = &g_dc; - char channel_id[DISCORD_CHANNEL_ID_CAP]; cJSON *root; char *json_body; char url[DISCORD_URL_CAP]; - char auth_hdr[DISCORD_AUTH_HDR_CAP]; int attempt; - (void)attachments; - if (att_count > 0) - return -1; - if (!dc->token || !recipient || !text || text[0] == '\0') - return -1; - if (strlen(text) > (size_t)DISCORD_MSG_MAX_LEN) - return -1; - if (discord_recipient_channel_id(recipient, channel_id, sizeof(channel_id)) != 0) - return -1; + root = cJSON_CreateObject(); if (!root) return -1; @@ -893,10 +881,6 @@ static int discord_send(const char *recipient, const char *text, free(json_body); return -1; } - if (snprintf(auth_hdr, sizeof(auth_hdr), "Authorization: Bot %s", dc->token) >= (int)sizeof(auth_hdr)) { - free(json_body); - return -1; - } for (attempt = 0; attempt < DISCORD_SEND_MAX_ATTEMPTS; attempt++) { struct discord_hdr_ud hd = { -1.0 }; struct curl_slist *hdrs = NULL; @@ -949,9 +933,46 @@ static int discord_send(const char *recipient, const char *text, free(json_body); return -1; } + free(json_body); return -1; } +static int discord_send(const char *recipient, const char *text, + const channel_attachment_t *attachments, size_t att_count) +{ + struct discord_ctx *dc = &g_dc; + char channel_id[DISCORD_CHANNEL_ID_CAP]; + char auth_hdr[DISCORD_AUTH_HDR_CAP]; + const char *cursor; + + (void)attachments; + if (att_count > 0) + return -1; + if (!dc->token || !recipient || !text || text[0] == '\0') + return -1; + if (discord_recipient_channel_id(recipient, channel_id, sizeof(channel_id)) != 0) + return -1; + if (snprintf(auth_hdr, sizeof(auth_hdr), "Authorization: Bot %s", dc->token) >= + (int)sizeof(auth_hdr)) + return -1; + /* A single REST message is 2000 characters. Longer replies used to fail + * the whole send, so cron kept re-running the agent and the user saw nothing. */ + cursor = text; + while (cursor[0] != '\0') { + char piece[DISCORD_MSG_MAX_LEN + 1]; + size_t n = channel_outbound_chunk_len(cursor, (size_t)DISCORD_MSG_MAX_LEN); + + if (n == 0 || n > (size_t)DISCORD_MSG_MAX_LEN) + return -1; + memcpy(piece, cursor, n); + piece[n] = '\0'; + if (discord_post_text(channel_id, piece, auth_hdr) != 0) + return -1; + cursor += n; + } + return 0; +} + static void discord_cleanup(void) { struct discord_pending *pending; diff --git a/src/channels/telegram.c b/src/channels/telegram.c index 45e3da3..ab8d611 100644 --- a/src/channels/telegram.c +++ b/src/channels/telegram.c @@ -21,6 +21,8 @@ #define POLL_TIMEOUT_SEC 30 #define RESP_BUF_SIZE (256 * 1024) #define RESP_BUF_INIT 4096 +/* Bot API sendMessage text limit. Longer agent replies are sent as several messages. */ +#define TG_TEXT_MAX 4096 struct telegram_ctx { char *token; @@ -261,43 +263,129 @@ static int tg_poll(channel_incoming_msg_t *out, int timeout_ms) return 0; } -static int tg_send(const char *recipient, const char *text, - const channel_attachment_t *attachments, size_t att_count) +/* Curl success is not delivery. sendMessage reports refusal in HTTP status and ok. */ +static int telegram_response_ok(long http_code, const char *body) +{ + cJSON *root; + cJSON *ok_item; + + if (http_code < 200 || http_code >= 300) + return 0; + if (!body || body[0] == '\0') + return 0; + root = cJSON_Parse(body); + if (!root || !cJSON_IsObject(root)) { + if (root) + cJSON_Delete(root); + return 0; + } + ok_item = cJSON_GetObjectItemCaseSensitive(root, "ok"); + if (!cJSON_IsBool(ok_item) || !cJSON_IsTrue(ok_item)) { + cJSON_Delete(root); + return 0; + } + cJSON_Delete(root); + return 1; +} + +static int telegram_perform_post(CURL *curl, const char *url, const char *postfields) +{ + tg_buf_t resp; + long http_code = 0; + int ret = -1; + + resp.buf = malloc(RESP_BUF_INIT); + if (!resp.buf) + return -1; + resp.len = 0; + resp.cap = RESP_BUF_INIT; + resp.buf[0] = '\0'; + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postfields); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); + if (tg_curl_perform(curl, "sendMessage") == 0) { + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + if (telegram_response_ok(http_code, resp.buf)) + ret = 0; + else + fprintf(stderr, "shellclaw: telegram: sendMessage rejected HTTP %ld\n", + http_code); + } + free(resp.buf); + return ret; +} + +static int telegram_post_chunk(const char *chat_id, const char *text) { - (void)attachments; - (void)att_count; - if (!g_tg.initialized || !g_tg.token || !recipient || !text) return -1; - const char *chat_id = strchr(recipient, ':'); - if (chat_id) chat_id++; - else chat_id = recipient; char url[512]; - snprintf(url, sizeof(url), "%s%s/%s", TG_API_BASE, g_tg.token, TG_SEND_MSG); - CURL *curl = curl_easy_init(); - if (!curl) return -1; + CURL *curl; + char *escaped; + char *postfields; + int ret; + size_t need; + + curl = curl_easy_init(); + if (!curl) + return -1; tg_curl_harden(curl); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); - char *escaped = curl_easy_escape(curl, text, (int)strlen(text)); + snprintf(url, sizeof(url), "%s%s/%s", TG_API_BASE, g_tg.token, TG_SEND_MSG); + escaped = curl_easy_escape(curl, text, (int)strlen(text)); if (!escaped) { curl_easy_cleanup(curl); return -1; } - size_t need = strlen(chat_id) + strlen(escaped) + 32; - char *postfields = malloc(need); - int ret = -1; - if (postfields) { - snprintf(postfields, need, "chat_id=%s&text=%s", chat_id, escaped); - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postfields); - ret = (tg_curl_perform(curl, "sendMessage") == 0) ? 0 : -1; - free(postfields); + need = strlen(chat_id) + strlen(escaped) + 32; + postfields = malloc(need); + if (!postfields) { + curl_free(escaped); + curl_easy_cleanup(curl); + return -1; } + snprintf(postfields, need, "chat_id=%s&text=%s", chat_id, escaped); + ret = telegram_perform_post(curl, url, postfields); + free(postfields); curl_free(escaped); curl_easy_cleanup(curl); return ret; } +static int tg_send(const char *recipient, const char *text, + const channel_attachment_t *attachments, size_t att_count) +{ + const char *chat_id; + const char *cursor; + + (void)attachments; + (void)att_count; + if (!g_tg.initialized || !g_tg.token || !recipient || !text || text[0] == '\0') + return -1; + chat_id = strchr(recipient, ':'); + if (chat_id) + chat_id++; + else + chat_id = recipient; + if (chat_id[0] == '\0') + return -1; + cursor = text; + while (cursor[0] != '\0') { + char piece[TG_TEXT_MAX + 1]; + size_t n = channel_outbound_chunk_len(cursor, TG_TEXT_MAX); + + if (n == 0 || n > TG_TEXT_MAX) + return -1; + memcpy(piece, cursor, n); + piece[n] = '\0'; + if (telegram_post_chunk(chat_id, piece) != 0) + return -1; + cursor += n; + } + return 0; +} + static void tg_cleanup(void) { free(g_tg.token); @@ -335,4 +423,9 @@ void telegram_set_test_config(const config_t *cfg) { g_tg.cfg = cfg; } + +int telegram_response_ok_for_test(long http_code, const char *body) +{ + return telegram_response_ok(http_code, body); +} #endif diff --git a/tests/test_channel.c b/tests/test_channel.c index a6a7493..79d7d86 100644 --- a/tests/test_channel.c +++ b/tests/test_channel.c @@ -74,6 +74,22 @@ static void test_stub_cleanup(void) MU_ASSERT(1, "stub cleanup does not crash"); } +static void test_outbound_chunk_len(void) +{ + /* U+20AC EURO SIGN is the three-byte sequence e2 82 ac. */ + const char euro[] = "x\xE2\x82\xAC" "y"; + size_t n; + + MU_ASSERT(channel_outbound_chunk_len(NULL, 10) == 0, "NULL text is empty"); + MU_ASSERT(channel_outbound_chunk_len("", 10) == 0, "empty text is empty"); + MU_ASSERT(channel_outbound_chunk_len("hi", 0) == 0, "zero cap is empty"); + MU_ASSERT(channel_outbound_chunk_len("hello", 5) == 5, "exact fit stays one piece"); + MU_ASSERT(channel_outbound_chunk_len("hello", 2) == 2, "ascii splits on the cap"); + n = channel_outbound_chunk_len(euro, 2); + MU_ASSERT(n == 1, "multibyte character is not split"); + MU_ASSERT(channel_outbound_chunk_len(euro + n, 3) == 3, "next piece is the whole euro sign"); +} + int main(void) { MU_RUN(test_stub_channel_exists); @@ -82,6 +98,7 @@ int main(void) MU_RUN(test_incoming_msg_clear_safe); MU_RUN(test_stub_send_succeeds); MU_RUN(test_stub_cleanup); + MU_RUN(test_outbound_chunk_len); printf("%d tests run, %d failed\n", tests_run, tests_failed); return tests_failed ? 1 : 0; } diff --git a/tests/test_telegram.c b/tests/test_telegram.c index 0a4d611..be28458 100644 --- a/tests/test_telegram.c +++ b/tests/test_telegram.c @@ -13,6 +13,7 @@ /* Test-only entry points from telegram.c (compiled with -DSHELLCLAW_TEST) */ extern int telegram_parse_update_for_test(const char *json, channel_incoming_msg_t *out, long *update_id); extern void telegram_set_test_config(const config_t *cfg); +extern int telegram_response_ok_for_test(long http_code, const char *body); static int tests_run = 0; static int tests_failed = 0; @@ -207,6 +208,24 @@ static void test_parse_missing_from_field(void) remove("/tmp/test_tg_nofrom.toml"); } +static void test_send_response_requires_ok(void) +{ + const char *too_long = "{\"ok\":false,\"error_code\":400," + "\"description\":\"Bad Request: message is too long\"}"; + const char *accepted = "{\"ok\":true,\"result\":{\"message_id\":1}}"; + + MU_ASSERT(telegram_response_ok_for_test(400, too_long) == 0, + "HTTP 400 message-too-long is not delivery"); + MU_ASSERT(telegram_response_ok_for_test(200, "{\"ok\":false}") == 0, + "HTTP 200 with ok false is not delivery"); + MU_ASSERT(telegram_response_ok_for_test(200, "") == 0, + "empty body is not delivery"); + MU_ASSERT(telegram_response_ok_for_test(429, "{\"ok\":false}") == 0, + "HTTP 429 is not delivery"); + MU_ASSERT(telegram_response_ok_for_test(200, accepted) == 1, + "HTTP 200 with ok true is delivery"); +} + static void test_vtable_fields_set(void) { const channel_t *ch = channel_telegram_get(); @@ -227,6 +246,7 @@ int main(void) MU_RUN(test_parse_photo_with_caption); MU_RUN(test_parse_no_message_key); MU_RUN(test_parse_missing_from_field); + MU_RUN(test_send_response_requires_ok); MU_RUN(test_vtable_fields_set); printf("%d tests run, %d failed\n", tests_run, tests_failed); return tests_failed ? 1 : 0;