Merge branch 'main' into pr-832
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,34 +102,48 @@ 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:
|
||||||
for attempt in range(3):
|
chunks = _split_message(msg.content or "")
|
||||||
try:
|
if not chunks:
|
||||||
response = await self._http.post(url, headers=headers, json=payload)
|
return
|
||||||
if response.status_code == 429:
|
|
||||||
data = response.json()
|
for i, chunk in enumerate(chunks):
|
||||||
retry_after = float(data.get("retry_after", 1.0))
|
payload: dict[str, Any] = {"content": chunk}
|
||||||
logger.warning("Discord rate limited, retrying in {}s", retry_after)
|
|
||||||
await asyncio.sleep(retry_after)
|
# Only set reply reference on the first chunk
|
||||||
continue
|
if i == 0 and msg.reply_to:
|
||||||
response.raise_for_status()
|
payload["message_reference"] = {"message_id": msg.reply_to}
|
||||||
return
|
payload["allowed_mentions"] = {"replied_user": False}
|
||||||
except Exception as e:
|
|
||||||
if attempt == 2:
|
if not await self._send_payload(url, headers, payload):
|
||||||
logger.error("Error sending Discord message: {}", e)
|
break # Abort remaining chunks on failure
|
||||||
else:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
finally:
|
finally:
|
||||||
await self._stop_typing(msg.chat_id)
|
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):
|
||||||
|
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 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:
|
async def _gateway_loop(self) -> None:
|
||||||
"""Main gateway loop: identify, heartbeat, dispatch events."""
|
"""Main gateway loop: identify, heartbeat, dispatch events."""
|
||||||
if not self._ws:
|
if not self._ws:
|
||||||
|
|||||||
@@ -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"
|
||||||
await self._web_client.chat_postMessage(
|
thread_ts_param = thread_ts if use_thread else None
|
||||||
channel=msg.chat_id,
|
|
||||||
text=self._to_mrkdwn(msg.content),
|
if msg.content:
|
||||||
thread_ts=thread_ts if use_thread else None,
|
await self._web_client.chat_postMessage(
|
||||||
)
|
channel=msg.chat_id,
|
||||||
|
text=self._to_mrkdwn(msg.content),
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user