From e39bbaa9be85e57020be9735051e5f8044f53ed1 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 20 Feb 2026 09:54:21 +0000 Subject: [PATCH 1/8] feat(slack): add media file upload support Use files_upload_v2 API to upload media attachments in Slack messages. This enables the message tool's media parameter to work correctly when sending images or other files through the Slack channel. Requires files:write OAuth scope. --- nanobot/channels/slack.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index dca5055..d29f1e1 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -84,11 +84,26 @@ class SlackChannel(BaseChannel): channel_type = slack_meta.get("channel_type") # Only reply in thread for channel/group messages; DMs don't use threads use_thread = thread_ts and channel_type != "im" - await self._web_client.chat_postMessage( - channel=msg.chat_id, - text=self._to_mrkdwn(msg.content), - thread_ts=thread_ts if use_thread else None, - ) + thread_ts_param = thread_ts if use_thread else None + + # Send text message if content is present + if msg.content: + await self._web_client.chat_postMessage( + channel=msg.chat_id, + text=self._to_mrkdwn(msg.content), + thread_ts=thread_ts_param, + ) + + # Upload media files if present + for media_path in msg.media or []: + try: + await self._web_client.files_upload_v2( + channel=msg.chat_id, + file=media_path, + thread_ts=thread_ts_param, + ) + except Exception as e: + logger.error(f"Failed to upload file {media_path}: {e}") except Exception as e: logger.error(f"Error sending Slack message: {e}") From 4c75e1673fb546a9da3e3730a91fb6fe0165e70b Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:55:22 -0300 Subject: [PATCH 2/8] fix: split Discord messages exceeding 2000-character limit Discord's API rejects messages longer than 2000 characters with HTTP 400. Previously, long agent responses were silently lost after retries exhausted. Adds _split_message() (matching Telegram's approach) to chunk content at line boundaries before sending. Only the first chunk carries the reply reference. Retry logic extracted to _send_payload() for reuse across chunks. Closes #898 --- nanobot/channels/discord.py | 74 ++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 8baecbf..c073a05 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -17,6 +17,27 @@ from nanobot.config.schema import DiscordConfig DISCORD_API_BASE = "https://discord.com/api/v10" MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB +MAX_MESSAGE_LEN = 2000 # Discord message character limit + + +def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: + """Split content into chunks within max_len, preferring line breaks.""" + if len(content) <= max_len: + return [content] + chunks: list[str] = [] + while content: + if len(content) <= max_len: + chunks.append(content) + break + cut = content[:max_len] + pos = cut.rfind('\n') + if pos == -1: + pos = cut.rfind(' ') + if pos == -1: + pos = max_len + chunks.append(content[:pos]) + content = content[pos:].lstrip() + return chunks class DiscordChannel(BaseChannel): @@ -79,34 +100,43 @@ class DiscordChannel(BaseChannel): return url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages" - payload: dict[str, Any] = {"content": msg.content} - - if msg.reply_to: - payload["message_reference"] = {"message_id": msg.reply_to} - payload["allowed_mentions"] = {"replied_user": False} - headers = {"Authorization": f"Bot {self.config.token}"} try: - for attempt in range(3): - try: - response = await self._http.post(url, headers=headers, json=payload) - if response.status_code == 429: - data = response.json() - retry_after = float(data.get("retry_after", 1.0)) - logger.warning("Discord rate limited, retrying in {}s", retry_after) - await asyncio.sleep(retry_after) - continue - response.raise_for_status() - return - except Exception as e: - if attempt == 2: - logger.error("Error sending Discord message: {}", e) - else: - await asyncio.sleep(1) + chunks = _split_message(msg.content or "") + for i, chunk in enumerate(chunks): + payload: dict[str, Any] = {"content": chunk} + + # Only set reply reference on the first chunk + if i == 0 and msg.reply_to: + payload["message_reference"] = {"message_id": msg.reply_to} + payload["allowed_mentions"] = {"replied_user": False} + + await self._send_payload(url, headers, payload) finally: await self._stop_typing(msg.chat_id) + async def _send_payload( + self, url: str, headers: dict[str, str], payload: dict[str, Any] + ) -> None: + """Send a single Discord API payload with retry on rate-limit.""" + for attempt in range(3): + try: + response = await self._http.post(url, headers=headers, json=payload) + if response.status_code == 429: + data = response.json() + retry_after = float(data.get("retry_after", 1.0)) + logger.warning("Discord rate limited, retrying in {}s", retry_after) + await asyncio.sleep(retry_after) + continue + response.raise_for_status() + return + except Exception as e: + if attempt == 2: + logger.error("Error sending Discord message: {}", e) + else: + await asyncio.sleep(1) + async def _gateway_loop(self) -> None: """Main gateway loop: identify, heartbeat, dispatch events.""" if not self._ws: From f19baa8fc40f5a2a66c07d4e02847280dfbd55da Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 10:01:38 -0300 Subject: [PATCH 3/8] fix: convert remaining f-string logger calls to loguru native format Follow-up to #864. Three f-string logger calls in base.py and dingtalk.py were missed in the original sweep. These can cause KeyError if interpolated values contain curly braces, since loguru interprets them as format placeholders. --- nanobot/channels/base.py | 5 +++-- nanobot/channels/dingtalk.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 30fcd1a..3a5a785 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -105,8 +105,9 @@ class BaseChannel(ABC): """ if not self.is_allowed(sender_id): logger.warning( - f"Access denied for sender {sender_id} on channel {self.name}. " - f"Add them to allowFrom list in config to grant access." + "Access denied for sender {} on channel {}. " + "Add them to allowFrom list in config to grant access.", + sender_id, self.name, ) return diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index b7263b3..09c7714 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -58,7 +58,8 @@ class NanobotDingTalkHandler(CallbackHandler): if not content: logger.warning( - f"Received empty or unsupported message type: {chatbot_msg.message_type}" + "Received empty or unsupported message type: {}", + chatbot_msg.message_type, ) return AckMessage.STATUS_OK, "OK" @@ -126,7 +127,8 @@ class DingTalkChannel(BaseChannel): self._http = httpx.AsyncClient() logger.info( - f"Initializing DingTalk Stream Client with Client ID: {self.config.client_id}..." + "Initializing DingTalk Stream Client with Client ID: {}...", + self.config.client_id, ) credential = Credential(self.config.client_id, self.config.client_secret) self._client = DingTalkStreamClient(credential) From 4cbd8572504320f9ef54948db9d5fbbca7130e68 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 10:09:04 -0300 Subject: [PATCH 4/8] fix: handle edge cases in message splitting and send failure - _split_message: return empty list for empty/None content instead of a list with one empty string (Discord rejects empty content) - _split_message: use pos <= 0 fallback to prevent empty chunks when content starts with a newline or space - _send_payload: return bool to indicate success/failure - send: abort remaining chunks when a chunk fails to send, preventing partial/corrupted message delivery --- nanobot/channels/discord.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index c073a05..5a1cf5c 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -22,6 +22,8 @@ MAX_MESSAGE_LEN = 2000 # Discord message character limit def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: """Split content into chunks within max_len, preferring line breaks.""" + if not content: + return [] if len(content) <= max_len: return [content] chunks: list[str] = [] @@ -31,9 +33,9 @@ def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: break cut = content[:max_len] pos = cut.rfind('\n') - if pos == -1: + if pos <= 0: pos = cut.rfind(' ') - if pos == -1: + if pos <= 0: pos = max_len chunks.append(content[:pos]) content = content[pos:].lstrip() @@ -104,6 +106,9 @@ class DiscordChannel(BaseChannel): try: chunks = _split_message(msg.content or "") + if not chunks: + return + for i, chunk in enumerate(chunks): payload: dict[str, Any] = {"content": chunk} @@ -112,14 +117,18 @@ class DiscordChannel(BaseChannel): payload["message_reference"] = {"message_id": msg.reply_to} payload["allowed_mentions"] = {"replied_user": False} - await self._send_payload(url, headers, payload) + if not await self._send_payload(url, headers, payload): + break # Abort remaining chunks on failure finally: await self._stop_typing(msg.chat_id) async def _send_payload( self, url: str, headers: dict[str, str], payload: dict[str, Any] - ) -> None: - """Send a single Discord API payload with retry on rate-limit.""" + ) -> bool: + """Send a single Discord API payload with retry on rate-limit. + + Returns True on success, False if all attempts failed. + """ for attempt in range(3): try: response = await self._http.post(url, headers=headers, json=payload) @@ -130,12 +139,13 @@ class DiscordChannel(BaseChannel): await asyncio.sleep(retry_after) continue response.raise_for_status() - return + return True except Exception as e: if attempt == 2: logger.error("Error sending Discord message: {}", e) else: await asyncio.sleep(1) + return False async def _gateway_loop(self) -> None: """Main gateway loop: identify, heartbeat, dispatch events.""" From b286457c854fd3d60228b680ec0dbc8d29286303 Mon Sep 17 00:00:00 2001 From: tercerapersona Date: Fri, 20 Feb 2026 11:34:50 -0300 Subject: [PATCH 5/8] add Openrouter prompt caching via cache_control --- nanobot/providers/registry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 445d977..ecf092f 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -100,6 +100,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="https://openrouter.ai/api/v1", strip_model_prefix=False, model_overrides=(), + supports_prompt_caching=True, ), # AiHubMix: global gateway, OpenAI-compatible interface. From cc04bc4dd1806f30e2f622a2628ddb40afc84fe7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:14:45 +0000 Subject: [PATCH 6/8] fix: check gateway's supports_prompt_caching instead of always returning False --- nanobot/providers/litellm_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index edeb5c6..58c9ac2 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -111,7 +111,7 @@ class LiteLLMProvider(LLMProvider): def _supports_cache_control(self, model: str) -> bool: """Return True when the provider supports cache_control on content blocks.""" if self._gateway is not None: - return False + return self._gateway.supports_prompt_caching spec = find_by_model(model) return spec is not None and spec.supports_prompt_caching From 6bcfbd9610687875c53ce65e64538e7200913591 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:19:18 +0000 Subject: [PATCH 7/8] style: remove redundant comments and use loguru native format --- nanobot/channels/slack.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 26a3966..4fc1f41 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -86,7 +86,6 @@ class SlackChannel(BaseChannel): use_thread = thread_ts and channel_type != "im" thread_ts_param = thread_ts if use_thread else None - # Send text message if content is present if msg.content: await self._web_client.chat_postMessage( channel=msg.chat_id, @@ -94,7 +93,6 @@ class SlackChannel(BaseChannel): thread_ts=thread_ts_param, ) - # Upload media files if present for media_path in msg.media or []: try: await self._web_client.files_upload_v2( @@ -103,7 +101,7 @@ class SlackChannel(BaseChannel): thread_ts=thread_ts_param, ) except Exception as e: - logger.error(f"Failed to upload file {media_path}: {e}") + logger.error("Failed to upload file {}: {}", media_path, e) except Exception as e: logger.error("Error sending Slack message: {}", e) From b853222c8755bfb3405735b13bc2c1deae20d437 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:26:12 +0000 Subject: [PATCH 8/8] style: trim _send_payload docstring --- nanobot/channels/discord.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 5a1cf5c..1d2d7a6 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -125,10 +125,7 @@ class DiscordChannel(BaseChannel): async def _send_payload( self, url: str, headers: dict[str, str], payload: dict[str, Any] ) -> bool: - """Send a single Discord API payload with retry on rate-limit. - - Returns True on success, False if all attempts failed. - """ + """Send a single Discord API payload with retry on rate-limit. Returns True on success.""" for attempt in range(3): try: response = await self._http.post(url, headers=headers, json=payload)