fix(restart): deliver completion after channel reconnects (#4931)

This commit is contained in:
chengyongru
2026-07-15 01:08:39 +08:00
committed by GitHub
parent 37165b0db0
commit 88c38e9b38
19 changed files with 284 additions and 55 deletions
+6 -4
View File
@@ -710,10 +710,11 @@ class DingTalkChannel(BaseChannel):
"""Send a message through DingTalk."""
token = await self._get_access_token()
if not token:
return
raise RuntimeError("DingTalk access token unavailable")
if msg.content and msg.content.strip():
await self._send_markdown_text(token, msg.chat_id, msg.content.strip())
if not await self._send_markdown_text(token, msg.chat_id, msg.content.strip()):
raise RuntimeError("DingTalk text message was not delivered")
for media_ref in msg.media or []:
ok = await self._send_media_ref(token, msg.chat_id, media_ref)
@@ -722,11 +723,12 @@ class DingTalkChannel(BaseChannel):
self.logger.error("media send failed for {}", media_ref)
# Send visible fallback so failures are observable by the user.
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
await self._send_markdown_text(
if not await self._send_markdown_text(
token,
msg.chat_id,
f"[Attachment send failed: {filename}]",
)
):
raise RuntimeError("DingTalk attachment fallback was not delivered")
async def _on_message(
self,
+2 -3
View File
@@ -264,7 +264,7 @@ if DISCORD_AVAILABLE:
channel = await self.fetch_channel(channel_id)
except Exception as e:
self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
return
raise
reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
sent_media = False
@@ -466,8 +466,7 @@ class DiscordChannel(BaseChannel):
"""Send a message through Discord using discord.py."""
client = self._client
if client is None or not client.is_ready():
self.logger.warning("client not ready; dropping outbound message")
return
raise RuntimeError("Discord client is not ready")
is_progress = isinstance(msg.event, ProgressEvent)
+8 -1
View File
@@ -2407,7 +2407,14 @@ class FeishuChannel(BaseChannel):
if ok:
return
# Fall back to regular send if reply fails
self._send_message_sync(receive_id_type, msg.chat_id, m_type, content)
message_id = self._send_message_sync(
receive_id_type,
msg.chat_id,
m_type,
content,
)
if not message_id:
raise RuntimeError(f"Feishu {m_type} message was not delivered")
for file_path in msg.media:
if not os.path.isfile(file_path):
+68 -14
View File
@@ -28,7 +28,11 @@ from nanobot.channels._feishu_instances import ChannelInstanceSpec, feishu_insta
from nanobot.channels.base import BaseChannel
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
from nanobot.config.schema import Config
from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message
from nanobot.utils.restart import (
RestartNotice,
consume_restart_notice_from_env,
format_restart_completed_message,
)
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
@@ -46,6 +50,8 @@ def _default_webui_dist() -> Path | None:
# Retry delays for message sending (exponential backoff: 1s, 2s, 4s)
_SEND_RETRY_DELAYS = (1, 2, 4)
_RESTART_NOTICE_START_TIMEOUT_S = 30.0
_RESTART_NOTICE_START_POLL_S = 0.25
_BOOL_CAMEL_ALIASES: dict[str, str] = {
"send_progress": "sendProgress",
@@ -476,15 +482,40 @@ class ChannelManager:
# Wait for all to complete (they should run forever)
await asyncio.gather(*tasks, return_exceptions=True)
def _notify_restart_done_if_needed(self) -> None:
"""Send restart completion message when runtime env markers are present."""
def _notify_restart_done_if_needed(self) -> asyncio.Task[None] | None:
"""Schedule restart completion after the target channel starts."""
notice = consume_restart_notice_from_env()
if not notice:
return
return None
return asyncio.create_task(self._send_restart_notice_when_started(notice))
async def _send_restart_notice_when_started(
self,
notice: RestartNotice,
*,
timeout_s: float = _RESTART_NOTICE_START_TIMEOUT_S,
poll_s: float = _RESTART_NOTICE_START_POLL_S,
) -> None:
"""Deliver a restart notice after the target channel starts."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
target = self.channels.get(notice.channel)
if not target:
if target is None:
logger.warning("Restart notice target channel is not enabled: {}", notice.channel)
return
asyncio.create_task(self._send_with_retry(
while not target.is_running:
remaining = deadline - loop.time()
if remaining <= 0:
logger.warning(
"Restart notice target did not start: {}:{}",
notice.channel,
notice.chat_id,
)
return
await asyncio.sleep(min(poll_s, remaining))
await self._send_with_retry(
target,
OutboundMessage(
channel=notice.channel,
@@ -492,7 +523,8 @@ class ChannelManager:
content=format_restart_completed_message(notice.started_at_raw),
metadata=dict(notice.metadata or {}),
),
))
deadline=deadline,
)
async def stop_all(self) -> None:
"""Stop all channels and the dispatcher."""
@@ -791,30 +823,52 @@ class ChannelManager:
merged = replace_outbound_event(first_msg, final_event, content=combined_content)
return merged, non_matching
async def _send_with_retry(self, channel: BaseChannel, msg: OutboundMessage) -> None:
async def _send_with_retry(
self,
channel: BaseChannel,
msg: OutboundMessage,
*,
deadline: float | None = None,
) -> None:
"""Send a message with retry on failure using exponential backoff.
When deadline is provided, retry until that monotonic time instead of
stopping at the configured attempt limit.
Note: CancelledError is re-raised to allow graceful shutdown.
"""
max_attempts = max(self.config.channels.send_max_retries, 1)
attempt = 0
for attempt in range(max_attempts):
while True:
attempt += 1
try:
await self._send_once(channel, msg)
return # Send succeeded
except asyncio.CancelledError:
raise # Propagate cancellation for graceful shutdown
except Exception as e:
if attempt == max_attempts - 1:
loop = asyncio.get_running_loop()
exhausted = (
attempt >= max_attempts
if deadline is None
else loop.time() >= deadline
)
if exhausted:
logger.exception(
"Failed to send to {} after {} attempts",
msg.channel, max_attempts
msg.channel, attempt,
)
return
delay = _SEND_RETRY_DELAYS[min(attempt, len(_SEND_RETRY_DELAYS) - 1)]
delay = _SEND_RETRY_DELAYS[min(attempt - 1, len(_SEND_RETRY_DELAYS) - 1)]
if deadline is not None:
delay = min(delay, max(0.0, deadline - loop.time()))
attempt_label = str(attempt)
if deadline is None:
attempt_label = f"{attempt}/{max_attempts}"
logger.warning(
"Send to {} failed (attempt {}/{}): {}, retrying in {}s",
msg.channel, attempt + 1, max_attempts, type(e).__name__, delay
"Send to {} failed (attempt {}): {}, retrying in {}s",
msg.channel, attempt_label, type(e).__name__, delay,
)
try:
await asyncio.sleep(delay)
+4 -2
View File
@@ -559,7 +559,7 @@ class MatrixChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send outbound content; clear typing for non-progress messages."""
if not self.client:
return
raise RuntimeError("Matrix client not initialized")
text = msg.content or ""
candidates = self._collect_outbound_media_candidates(msg.media)
relates_to = self._build_thread_relates_to(msg.metadata)
@@ -582,7 +582,9 @@ class MatrixChannel(BaseChannel):
content = _build_matrix_text_content(text)
if relates_to:
content["m.relates_to"] = relates_to
await self._send_room_content(msg.chat_id, content)
response = await self._send_room_content(msg.chat_id, content)
if isinstance(response, RoomSendError):
raise RuntimeError(f"Matrix message was not delivered: {response}")
finally:
if not is_progress:
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
+1 -2
View File
@@ -430,8 +430,7 @@ class NapcatChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
if self._ws is None:
logger.warning("napcat: not connected, dropping outbound message")
return
raise RuntimeError("napcat: not connected")
kind, _, target = msg.chat_id.partition(":")
if kind not in ("private", "group") or not target:
+2 -2
View File
@@ -243,8 +243,7 @@ class QQChannel(BaseChannel):
"""Send attachments first, then text."""
try:
if not self._client:
self.logger.warning("client not initialized")
return
raise RuntimeError("QQ client not initialized")
msg_id = msg.metadata.get("message_id")
chat_type = self._chat_type_cache.get(msg.chat_id, "c2c")
@@ -284,6 +283,7 @@ class QQChannel(BaseChannel):
raise
except Exception:
self.logger.exception("Error sending message to chat_id={}", msg.chat_id)
raise
async def _send_text_only(
self,
+2 -2
View File
@@ -493,8 +493,7 @@ class WecomChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through WeCom."""
if not self._client:
self.logger.warning("client not initialized")
return
raise RuntimeError("WeCom client not initialized")
try:
content = (msg.content or "").strip()
@@ -553,3 +552,4 @@ class WecomChannel(BaseChannel):
except Exception:
self.logger.exception("Error sending message to chat_id={}", msg.chat_id)
raise
+9 -2
View File
@@ -9,6 +9,8 @@ from contextlib import suppress
from dataclasses import dataclass, field
from typing import Any
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL"
RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID"
RESTART_NOTIFY_METADATA_ENV = "NANOBOT_RESTART_NOTIFY_METADATA"
@@ -40,9 +42,14 @@ def set_restart_notice_to_env(
os.environ[RESTART_NOTIFY_CHANNEL_ENV] = channel
os.environ[RESTART_NOTIFY_CHAT_ID_ENV] = chat_id
os.environ[RESTART_STARTED_AT_ENV] = str(time.time())
if metadata:
persisted_metadata = dict(metadata or {})
persisted_metadata.pop(WEBUI_TURN_METADATA_KEY, None)
if persisted_metadata:
try:
os.environ[RESTART_NOTIFY_METADATA_ENV] = json.dumps(metadata, default=str)
os.environ[RESTART_NOTIFY_METADATA_ENV] = json.dumps(
persisted_metadata,
default=str,
)
except (TypeError, ValueError):
os.environ.pop(RESTART_NOTIFY_METADATA_ENV, None)
else: