Merge branch 'main' into pr-832

This commit is contained in:
Re-bin
2026-02-20 15:47:16 +00:00
7 changed files with 89 additions and 33 deletions

View File

@@ -105,8 +105,9 @@ class BaseChannel(ABC):
""" """
if not self.is_allowed(sender_id): if not self.is_allowed(sender_id):
logger.warning( logger.warning(
f"Access denied for sender {sender_id} on channel {self.name}. " "Access denied for sender {} on channel {}. "
f"Add them to allowFrom list in config to grant access." "Add them to allowFrom list in config to grant access.",
sender_id, self.name,
) )
return return

View File

@@ -58,7 +58,8 @@ class NanobotDingTalkHandler(CallbackHandler):
if not content: if not content:
logger.warning( 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" return AckMessage.STATUS_OK, "OK"
@@ -126,7 +127,8 @@ class DingTalkChannel(BaseChannel):
self._http = httpx.AsyncClient() self._http = httpx.AsyncClient()
logger.info( 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) credential = Credential(self.config.client_id, self.config.client_secret)
self._client = DingTalkStreamClient(credential) self._client = DingTalkStreamClient(credential)

View File

@@ -17,6 +17,29 @@ from nanobot.config.schema import DiscordConfig
DISCORD_API_BASE = "https://discord.com/api/v10" DISCORD_API_BASE = "https://discord.com/api/v10"
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB 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 not content:
return []
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 <= 0:
pos = cut.rfind(' ')
if pos <= 0:
pos = max_len
chunks.append(content[:pos])
content = content[pos:].lstrip()
return chunks
class DiscordChannel(BaseChannel): class DiscordChannel(BaseChannel):
@@ -79,15 +102,30 @@ class DiscordChannel(BaseChannel):
return return
url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages" 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}"} headers = {"Authorization": f"Bot {self.config.token}"}
try: try:
chunks = _split_message(msg.content or "")
if not chunks:
return
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}
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]
) -> bool:
"""Send a single Discord API payload with retry on rate-limit. Returns True on success."""
for attempt in range(3): for attempt in range(3):
try: try:
response = await self._http.post(url, headers=headers, json=payload) response = await self._http.post(url, headers=headers, json=payload)
@@ -98,14 +136,13 @@ class DiscordChannel(BaseChannel):
await asyncio.sleep(retry_after) await asyncio.sleep(retry_after)
continue continue
response.raise_for_status() response.raise_for_status()
return return True
except Exception as e: except Exception as e:
if attempt == 2: if attempt == 2:
logger.error("Error sending Discord message: {}", e) logger.error("Error sending Discord message: {}", e)
else: else:
await asyncio.sleep(1) await asyncio.sleep(1)
finally: return False
await self._stop_typing(msg.chat_id)
async def _gateway_loop(self) -> None: async def _gateway_loop(self) -> None:
"""Main gateway loop: identify, heartbeat, dispatch events.""" """Main gateway loop: identify, heartbeat, dispatch events."""

View File

@@ -84,11 +84,24 @@ class SlackChannel(BaseChannel):
channel_type = slack_meta.get("channel_type") channel_type = slack_meta.get("channel_type")
# Only reply in thread for channel/group messages; DMs don't use threads # Only reply in thread for channel/group messages; DMs don't use threads
use_thread = thread_ts and channel_type != "im" use_thread = thread_ts and channel_type != "im"
thread_ts_param = thread_ts if use_thread else None
if msg.content:
await self._web_client.chat_postMessage( await self._web_client.chat_postMessage(
channel=msg.chat_id, channel=msg.chat_id,
text=self._to_mrkdwn(msg.content), text=self._to_mrkdwn(msg.content),
thread_ts=thread_ts if use_thread else None, thread_ts=thread_ts_param,
) )
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("Failed to upload file {}: {}", media_path, e)
except Exception as e: except Exception as e:
logger.error("Error sending Slack message: {}", e) logger.error("Error sending Slack message: {}", e)

View File

@@ -111,7 +111,7 @@ class LiteLLMProvider(LLMProvider):
def _supports_cache_control(self, model: str) -> bool: def _supports_cache_control(self, model: str) -> bool:
"""Return True when the provider supports cache_control on content blocks.""" """Return True when the provider supports cache_control on content blocks."""
if self._gateway is not None: if self._gateway is not None:
return False return self._gateway.supports_prompt_caching
spec = find_by_model(model) spec = find_by_model(model)
return spec is not None and spec.supports_prompt_caching return spec is not None and spec.supports_prompt_caching

View File

@@ -100,6 +100,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="https://openrouter.ai/api/v1", default_api_base="https://openrouter.ai/api/v1",
strip_model_prefix=False, strip_model_prefix=False,
model_overrides=(), model_overrides=(),
supports_prompt_caching=True,
), ),
# AiHubMix: global gateway, OpenAI-compatible interface. # AiHubMix: global gateway, OpenAI-compatible interface.

View File

@@ -154,6 +154,7 @@ class SessionManager:
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
metadata_line = { metadata_line = {
"_type": "metadata", "_type": "metadata",
"key": session.key,
"created_at": session.created_at.isoformat(), "created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(), "updated_at": session.updated_at.isoformat(),
"metadata": session.metadata, "metadata": session.metadata,
@@ -186,8 +187,9 @@ class SessionManager:
if first_line: if first_line:
data = json.loads(first_line) data = json.loads(first_line)
if data.get("_type") == "metadata": if data.get("_type") == "metadata":
key = data.get("key") or path.stem.replace("_", ":", 1)
sessions.append({ sessions.append({
"key": path.stem.replace("_", ":"), "key": key,
"created_at": data.get("created_at"), "created_at": data.get("created_at"),
"updated_at": data.get("updated_at"), "updated_at": data.get("updated_at"),
"path": str(path) "path": str(path)