Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha
## [Unreleased]

### Fixed
- Telegram `getUpdates` requests a single update per poll and skips a message without a numeric sender id, so a backlog larger than the 256 KiB read buffer (or a channel/anonymous post) no longer pins the poll offset and stalls the inbox.
- Telegram `sendMessage` delivery is verified from the HTTP status code and the `ok` body field, so an API refusal (HTTP 400/429) is no longer reported as a successful send and acknowledged as delivered.
- Long Telegram and Discord replies are chunked to the platform limits (4096/2000 bytes, UTF-8 boundary-safe) through the shared `channel_send_chunked` helper, so a reply over the cap is delivered as several messages instead of failing the whole send; chunk posts are paced for the ~1 msg/s Telegram chat rate, and a failed mid-reply post logs its chunk index and byte offset.
- Live config reload reaches the Anthropic provider (`provider_anthropic_set_live_config`, guarded like OpenAI), so dashboard/SIGHUP reloads update model and `max_tokens` instead of staying on the config captured at init.
- Parallel tool results are grouped into one `user` message (one `tool_result` block per call, per the Messages API contract), fixing Anthropic 400s on parallel tool calls.
- The local provider re-probes on the next request after an unreachable startup, at most once every 15 seconds, so a down `llama-server` no longer costs synchronous curl probes on every agent turn.
Expand Down
59 changes: 59 additions & 0 deletions src/channels/channel.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,65 @@ 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.
* Fallback: when the first rune is wider than @p max_bytes, the piece is
* @p max_bytes bytes (that rune is split); unreachable with the 2000/4096
* channel caps, documented so the finite cut is not mistaken for a bug.
*
* 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);

/**
* Post one chunk of a channel_send_chunked() send. NUL-terminated piece,
* never empty and never longer than the max_bytes of the driving call.
* @param piece NUL-terminated text piece (valid until the callback returns).
* @param ctx Caller context passed through unchanged. May be NULL.
* @return 0 when the platform accepted the piece, non-zero on failure.
*/
typedef int (*channel_chunk_post_fn)(const char *piece, void *ctx);

/** Position where a chunked send stopped; zeroed by the call unless it failed. */
typedef struct channel_chunk_fail {
size_t index; /**< Zero-based index of the chunk that failed to post. */
size_t offset; /**< Byte offset of the failed chunk inside the sent text. */
} channel_chunk_fail_t;

/**
* Deliver @p text via @p post in chunks of at most @p max_bytes, cut on UTF-8
* rune boundaries via channel_outbound_chunk_len(). Each piece is copied into
* one internal buffer and NUL-terminated, so plain C-string platform posts
* (Telegram sendMessage query, Discord JSON `content`) work unchanged.
*
* Limits are BYTES even where a platform counts characters (Telegram's 4096
* is characters): conservative by design, so CJK/emoji-heavy replies split
* earlier than strictly required. Do not "fix" this to count code points.
*
* Example: `channel_send_chunked(text, 2000, post_fn, &ctx, &fail)` posts up
* to 2000-byte pieces until done; when `post_fn` returns non-zero the send
* stops and `fail` reports the failing chunk index and byte offset.
*
* @param text NUL-terminated text. NULL returns -1; empty posts nothing.
* @param max_bytes Maximum chunk size in bytes. 0 returns -1.
* @param post Callback that delivers one piece; non-zero fails the send.
* @param ctx Caller context passed through to @p post. May be NULL.
* @param fail Optional out: chunk index and byte offset where the send
* stopped. Zeroed when the call does not fail that way.
* @return 0 when every chunk was posted, -1 on invalid arguments, OOM, or
* a failed @p post (with @p fail, when given, pointing at the chunk
* that failed).
*/
int channel_send_chunked(const char *text, size_t max_bytes,
channel_chunk_post_fn post, void *ctx,
channel_chunk_fail_t *fail);

/**
* Channel vtable: init, poll, send, cleanup. CLI and Telegram implement this.
*/
Expand Down
71 changes: 70 additions & 1 deletion src/channels/channel_common.c
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* @file channel_common.c
* @brief Shared channel utilities: channel_incoming_msg_clear, channel registry.
* @brief Shared channel utilities: chunked send, incoming msg clear, registry.
*/

#include "channels/channel.h"
Expand Down Expand Up @@ -31,6 +31,75 @@ 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the leading character is wider than max_bytes this returns max_bytes and splits the code point (chunk_len("\xF0\x9F\x98\x80", 3) returns 3). Unreachable with the current caps, but it contradicts the header contract. Document it or cover it with a test.

}

int channel_send_chunked(const char *text, size_t max_bytes,
channel_chunk_post_fn post, void *ctx,
channel_chunk_fail_t *fail)
{
char *piece;
const char *cursor;
size_t index;

if (fail) {
fail->index = 0;
fail->offset = 0;
}
if (!text || max_bytes == 0 || !post)
return -1;
/* One reused, always-NUL-terminated piece buffer: platform posts take
* plain C strings, so the chunk copy lives here instead of per caller. */
piece = malloc(max_bytes + 1);
if (!piece)
return -1;
cursor = text;
index = 0;
while (cursor[0] != '\0') {
size_t n = channel_outbound_chunk_len(cursor, max_bytes);

if (n == 0 || n > max_bytes) {
/* Fail-safe only: chunk_len guarantees 0 < n <= max_bytes
* for non-empty cursor, so a future bounds bug stops the
* send instead of walking past the piece buffer. */
if (fail) {
fail->index = index;
fail->offset = (size_t)(cursor - text);
}
free(piece);
return -1;
}
memcpy(piece, cursor, n);
piece[n] = '\0';
if (post(piece, ctx) != 0) {
if (fail) {
fail->index = index;
fail->offset = (size_t)(cursor - text);
}
free(piece);
return -1;
}
cursor += n;
index++;
}
free(piece);
return 0;
}

void channel_incoming_msg_clear(channel_incoming_msg_t *msg)
{
if (!msg) return;
Expand Down
65 changes: 47 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,54 @@ static int discord_send(const char *recipient, const char *text,
free(json_body);
return -1;
}
free(json_body);
return -1;
}

struct discord_chunk_ctx {
char channel_id[DISCORD_CHANNEL_ID_CAP];
char auth_hdr[DISCORD_AUTH_HDR_CAP];
};

static int discord_post_piece(const char *piece, void *userdata)
{
const struct discord_chunk_ctx *ctx = (const struct discord_chunk_ctx *)userdata;

return discord_post_text(ctx->channel_id, piece, ctx->auth_hdr);
}

static int discord_send(const char *recipient, const char *text,
const channel_attachment_t *attachments, size_t att_count)
{
struct discord_chunk_ctx ctx;
struct discord_ctx *dc = &g_dc;
channel_chunk_fail_t fail;

(void)attachments;
if (att_count > 0)
return -1;
if (!dc->token || !recipient || !text || text[0] == '\0')
return -1;
/* A single REST message is capped at 2000 characters, so a longer
* reply is delivered as several messages. */
if (discord_recipient_channel_id(recipient, ctx.channel_id, sizeof(ctx.channel_id)) != 0)
return -1;
if (snprintf(ctx.auth_hdr, sizeof(ctx.auth_hdr), "Authorization: Bot %s", dc->token) >=
(int)sizeof(ctx.auth_hdr))
return -1;
if (channel_send_chunked(text, (size_t)DISCORD_MSG_MAX_LEN, discord_post_piece, &ctx,
&fail) != 0) {
/* At-least-once delivery: dispatch skips the cron ack when send
* fails, so a re-run repeats already-delivered chunks. Log where
* the reply stopped so the duplicate boundary is traceable. */
fprintf(stderr,
"shellclaw: discord: send failed at chunk %zu (byte offset %zu)\n",
fail.index, fail.offset);
return -1;
}
return 0;
}

static void discord_cleanup(void)
{
struct discord_pending *pending;
Expand Down
Loading
Loading