Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/channels/channel.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
17 changes: 17 additions & 0 deletions src/channels/channel_common.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
57 changes: 39 additions & 18 deletions src/channels/discord.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
135 changes: 114 additions & 21 deletions src/channels/telegram.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
17 changes: 17 additions & 0 deletions tests/test_channel.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
20 changes: 20 additions & 0 deletions tests/test_telegram.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down
Loading