Merge branch 'simp/r3-13-G' into simp/r3-13
This commit is contained in:
@@ -1,8 +1,5 @@
|
||||
"""BlueBubbles iMessage platform adapter.
|
||||
|
||||
Uses the local BlueBubbles macOS server for outbound REST sends and inbound
|
||||
webhooks: text, media attachments, typing indicators, and read receipts.
|
||||
"""
|
||||
"""BlueBubbles iMessage platform adapter: local BlueBubbles macOS server for outbound REST sends and
|
||||
inbound webhooks (text, media attachments, typing indicators, read receipts)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
@@ -28,9 +25,8 @@ from .media_cache import ext_for_mime
|
||||
from gateway.platforms.helpers import compile_mention_patterns, strip_markdown
|
||||
from utils import TRUTHY_STRINGS
|
||||
|
||||
# Historical BlueBubbles mime→ext maps, preserved verbatim as overrides for
|
||||
# the shared dispatch in gateway.platforms.media_cache. Both maps are
|
||||
# CLOSED: unlisted mimes fall back to .jpg / .mp3 (never mimetypes).
|
||||
# Historical BlueBubbles mime→ext maps, preserved verbatim as overrides for the shared dispatch in
|
||||
# gateway.platforms.media_cache. Both maps are CLOSED: unlisted mimes fall back to .jpg / .mp3.
|
||||
_BLUEBUBBLES_IMAGE_EXT_OVERRIDES = {
|
||||
"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp",
|
||||
"image/heic": ".jpg", "image/heif": ".jpg", "image/tiff": ".jpg", # historical mapping
|
||||
@@ -44,23 +40,20 @@ _BLUEBUBBLES_AUDIO_EXT_OVERRIDES = {
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_WEBHOOK_HOST = "127.0.0.1"
|
||||
# Webhook events are small JSON/form payloads; attachments come through the
|
||||
# REST API. 1 MiB keeps oversized/chunked bodies from buffering unbounded.
|
||||
# Webhook events are small JSON/form payloads (attachments come through the REST API); 1 MiB keeps
|
||||
# oversized/chunked bodies from buffering unbounded.
|
||||
_WEBHOOK_MAX_BODY_BYTES = 1_048_576
|
||||
DEFAULT_WEBHOOK_PORT = 8645
|
||||
DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"
|
||||
MAX_TEXT_LENGTH = 4000
|
||||
|
||||
# iMessage has no stable bot mention identity (unlike <@U...>/@botname/MXID),
|
||||
# so `require_mention: true` without custom aliases uses Hermes wake words.
|
||||
# iMessage has no stable bot mention identity (unlike <@U...>/@botname/MXID), so
|
||||
# `require_mention: true` without custom aliases uses Hermes wake words.
|
||||
DEFAULT_MENTION_PATTERNS = [r"(?<![\w@])@?hermes\s+agent\b[,:\-]?", r"(?<![\w@])@?hermes\b[,:\-]?"]
|
||||
|
||||
# Tapback associatedMessageType codes: 2000-2005 added, 3000-3005 removed
|
||||
# (love, like, dislike, laugh, emphasize, question).
|
||||
# Tapback associatedMessageType codes: 2000-2005 added, 3000-3005 removed (love, like, dislike, ...).
|
||||
_TAPBACK_CODES = {*range(2000, 2006), *range(3000, 3006)}
|
||||
|
||||
# Webhook event types that carry user messages
|
||||
_MESSAGE_EVENTS = {"new-message", "message", "updated-message"}
|
||||
_MESSAGE_EVENTS = {"new-message", "message", "updated-message"} # webhook event types carrying user messages
|
||||
|
||||
_PHONE_RE = re.compile(r"\+?\d{7,15}")
|
||||
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
|
||||
@@ -86,18 +79,15 @@ def check_bluebubbles_requirements() -> bool:
|
||||
|
||||
def _normalize_server_url(raw: str) -> str:
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
if not re.match(r"^https?://", value, flags=re.I):
|
||||
if value and not re.match(r"^https?://", value, flags=re.I):
|
||||
value = f"http://{value}"
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
def _closed_ext(mime: str, overrides: Dict[str, str], fallback: str) -> str:
|
||||
"""Historical maps were closed: unlisted mimes fall back without consulting mimetypes."""
|
||||
return ext_for_mime(
|
||||
mime, overrides=overrides, use_defaults=False, use_mimetypes=False, fallback=fallback
|
||||
) or fallback
|
||||
return ext_for_mime(mime, overrides=overrides, use_defaults=False, use_mimetypes=False,
|
||||
fallback=fallback) or fallback
|
||||
|
||||
|
||||
def _setting(extra: Dict[str, Any], key: str, env: str, default: str = "") -> Any:
|
||||
@@ -128,17 +118,15 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
self.password = extra.get("password") or _get_scoped_secret("BLUEBUBBLES_PASSWORD", "")
|
||||
self.webhook_host = _setting(extra, "webhook_host", "BLUEBUBBLES_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST)
|
||||
self.webhook_port = int(_setting(extra, "webhook_port", "BLUEBUBBLES_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT)))
|
||||
self.webhook_path = _setting(extra, "webhook_path", "BLUEBUBBLES_WEBHOOK_PATH", DEFAULT_WEBHOOK_PATH)
|
||||
if not str(self.webhook_path).startswith("/"):
|
||||
self.webhook_path = f"/{self.webhook_path}"
|
||||
path = str(_setting(extra, "webhook_path", "BLUEBUBBLES_WEBHOOK_PATH", DEFAULT_WEBHOOK_PATH))
|
||||
self.webhook_path = path if path.startswith("/") else f"/{path}"
|
||||
self.send_read_receipts = bool(extra.get("send_read_receipts", True))
|
||||
_require_mention = extra.get("require_mention")
|
||||
if _require_mention is None:
|
||||
_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
|
||||
self.require_mention = str(_require_mention).strip().lower() in TRUTHY_STRINGS
|
||||
self._mention_patterns = self._compile_mention_patterns(
|
||||
extra["mention_patterns"] if "mention_patterns" in extra else os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
|
||||
)
|
||||
extra["mention_patterns"] if "mention_patterns" in extra else os.getenv("BLUEBUBBLES_MENTION_PATTERNS"))
|
||||
self.client: Optional[httpx.AsyncClient] = None
|
||||
self._runner = None
|
||||
self._private_api_enabled: Optional[bool] = None
|
||||
@@ -148,30 +136,29 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
# --- API helpers ---
|
||||
|
||||
def _api_url(self, path: str) -> str:
|
||||
sep = "&" if "?" in path else "?"
|
||||
return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}"
|
||||
return f"{self.server_url}{path}{'&' if '?' in path else '?'}password={quote(self.password, safe='')}"
|
||||
|
||||
@staticmethod
|
||||
def _compile_mention_patterns(raw: Any) -> List[re.Pattern]:
|
||||
"""Compile group-mention wake words; ``raw`` is a list, a raw env string
|
||||
(JSON list or comma/newline-separated), or None (Hermes defaults)."""
|
||||
return compile_mention_patterns(raw, log_prefix="bluebubbles", defaults=DEFAULT_MENTION_PATTERNS, logger_=logger)
|
||||
"""Compile group-mention wake words; ``raw`` is a list, a raw env string (JSON list or
|
||||
comma/newline-separated), or None (Hermes defaults)."""
|
||||
return compile_mention_patterns(raw, log_prefix="bluebubbles", defaults=DEFAULT_MENTION_PATTERNS,
|
||||
logger_=logger)
|
||||
|
||||
def _message_matches_mention_patterns(self, text: str) -> bool:
|
||||
return bool(text) and any(pattern.search(text) for pattern in self._mention_patterns)
|
||||
|
||||
def _clean_mention_text(self, text: str) -> str:
|
||||
"""Strip a leading wake word only — patterns are regexes, so stripping
|
||||
anywhere later in the prompt could delete ordinary words."""
|
||||
if not text:
|
||||
return text
|
||||
"""Strip a leading wake word only — patterns are regexes, so stripping anywhere later in the
|
||||
prompt could delete ordinary words."""
|
||||
stripped = (text or "").lstrip()
|
||||
for pattern in self._mention_patterns:
|
||||
match = pattern.match(text.lstrip())
|
||||
if match:
|
||||
return text.lstrip()[match.end():].lstrip(" ,:-") or text
|
||||
if match := pattern.match(stripped):
|
||||
return stripped[match.end():].lstrip(" ,:-") or text
|
||||
return text
|
||||
|
||||
async def _api_json(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Authenticated request to the BlueBubbles REST API; raises on HTTP errors, returns decoded JSON."""
|
||||
assert self.client is not None
|
||||
res = await getattr(self.client, method)(self._api_url(path), **kwargs)
|
||||
res.raise_for_status()
|
||||
@@ -198,8 +185,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if not self._private_api_enabled or not self._helper_connected or not self.client:
|
||||
return False
|
||||
with suppress(Exception):
|
||||
guid = await self._resolve_chat_guid(chat_id)
|
||||
if guid:
|
||||
if guid := await self._resolve_chat_guid(chat_id):
|
||||
url = self._api_url(f"/api/v1/chat/{quote(guid, safe='')}/{action}")
|
||||
await getattr(self.client, method)(url, timeout=5)
|
||||
return True
|
||||
@@ -227,22 +213,21 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
logger.error("[bluebubbles] cannot reach server at %s: %s", self.server_url, exc)
|
||||
await self._close_client()
|
||||
return False
|
||||
# client_max_size makes aiohttp enforce the cap on every read path,
|
||||
# including chunked requests with no Content-Length.
|
||||
# client_max_size makes aiohttp enforce the cap on every read path, incl. chunked requests
|
||||
# with no Content-Length.
|
||||
app = web.Application(client_max_size=_WEBHOOK_MAX_BODY_BYTES)
|
||||
app.router.add_get("/health", lambda _: web.Response(text="ok"))
|
||||
app.router.add_post(self.webhook_path, self._handle_webhook)
|
||||
# The webhook auth value rides in the query string (BlueBubbles cannot
|
||||
# send custom headers) — keep it out of aiohttp access logs.
|
||||
# The webhook auth value rides in the query string (BlueBubbles cannot send custom headers)
|
||||
# — keep it out of aiohttp access logs.
|
||||
self._runner = web.AppRunner(app, access_log=None)
|
||||
await self._runner.setup()
|
||||
site = web.TCPSite(self._runner, self.webhook_host, self.webhook_port)
|
||||
await site.start()
|
||||
self._mark_connected()
|
||||
logger.info("[bluebubbles] webhook listening on http://%s:%s%s",
|
||||
self.webhook_host, self.webhook_port, self.webhook_path)
|
||||
# The server only sends events to webhooks registered via its API.
|
||||
await self._register_webhook()
|
||||
logger.info("[bluebubbles] webhook listening on http://%s:%s%s", self.webhook_host, self.webhook_port,
|
||||
self.webhook_path)
|
||||
await self._register_webhook() # the server only sends events to webhooks registered via its API
|
||||
# Plugin-registered native handlers (ctx.register_platform_handler).
|
||||
self._wire_plugin_handlers(None)
|
||||
return True
|
||||
@@ -267,16 +252,13 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
return f"http://{host}:{self.webhook_port}{self.webhook_path}"
|
||||
|
||||
def _webhook_register_url_with(self, password_param: str) -> str:
|
||||
base = self._webhook_url
|
||||
return f"{base}?password={password_param}" if self.password else base
|
||||
return f"{self._webhook_url}?password={password_param}" if self.password else self._webhook_url
|
||||
|
||||
@property
|
||||
def _webhook_register_url(self) -> str:
|
||||
"""Registered webhook URL, password embedded as a query param.
|
||||
|
||||
BlueBubbles posts to the exact registered URL and its registration
|
||||
API cannot set custom headers, so this is the only way to
|
||||
authenticate inbound webhooks without disabling auth."""
|
||||
"""Registered webhook URL with the password as a query param: BlueBubbles posts to the exact
|
||||
registered URL and cannot set custom headers, so this is the only way to authenticate inbound
|
||||
webhooks without disabling auth."""
|
||||
return self._webhook_register_url_with(quote(self.password, safe=""))
|
||||
|
||||
@property
|
||||
@@ -292,18 +274,17 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
return []
|
||||
|
||||
async def _register_webhook(self) -> bool:
|
||||
"""Register this webhook URL, reusing an existing registration if present
|
||||
(crash resilience — avoids duplicates after an unclean shutdown)."""
|
||||
"""Register this webhook URL, reusing an existing registration if present (crash resilience —
|
||||
avoids duplicates after an unclean shutdown)."""
|
||||
if not self.client:
|
||||
return False
|
||||
webhook_url = self._webhook_register_url
|
||||
log_url = self._webhook_register_url_for_log
|
||||
webhook_url, log_url = self._webhook_register_url, self._webhook_register_url_for_log
|
||||
if await self._find_registered_webhooks(webhook_url):
|
||||
logger.info("[bluebubbles] webhook already registered: %s", log_url)
|
||||
return True
|
||||
payload = {"url": webhook_url, "events": ["new-message", "updated-message"]}
|
||||
try:
|
||||
res = await self._api_post("/api/v1/webhook", payload)
|
||||
res = await self._api_post("/api/v1/webhook",
|
||||
{"url": webhook_url, "events": ["new-message", "updated-message"]})
|
||||
status = res.get("status", 0)
|
||||
if 200 <= status < 300:
|
||||
logger.info("[bluebubbles] webhook registered with server: %s", log_url)
|
||||
@@ -321,10 +302,8 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
removed = False
|
||||
try:
|
||||
for wh in await self._find_registered_webhooks(self._webhook_register_url):
|
||||
wh_id = wh.get("id")
|
||||
if wh_id:
|
||||
res = await self.client.delete(self._api_url(f"/api/v1/webhook/{wh_id}"))
|
||||
res.raise_for_status()
|
||||
if wh_id := wh.get("id"):
|
||||
(await self.client.delete(self._api_url(f"/api/v1/webhook/{wh_id}"))).raise_for_status()
|
||||
removed = True
|
||||
if removed:
|
||||
logger.info("[bluebubbles] webhook unregistered: %s", self._webhook_register_url_for_log)
|
||||
@@ -335,18 +314,13 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
# --- Chat GUID resolution ---
|
||||
|
||||
async def _resolve_chat_guid(self, target: str) -> Optional[str]:
|
||||
"""Resolve an email/phone to a chat GUID (raw ``a;-;b`` GUIDs pass through).
|
||||
|
||||
Matches strictly on ``chatIdentifier`` / ``identifier``. Participant
|
||||
membership is intentionally NOT a fallback: the same contact appears in
|
||||
a 1:1 DM and any number of groups, so a participant match could leak a
|
||||
DM reply into a group thread. Return ``None`` and let the caller create
|
||||
a fresh DM via ``_create_chat_for_handle``."""
|
||||
"""Resolve an email/phone to a chat GUID (raw ``a;-;b`` GUIDs pass through). Matches strictly on
|
||||
``chatIdentifier`` / ``identifier``; participant membership is intentionally NOT a fallback —
|
||||
the same contact appears in a 1:1 DM and any number of groups, so a participant match could
|
||||
leak a DM reply into a group thread. ``None`` lets the caller create a fresh DM."""
|
||||
target = (target or "").strip()
|
||||
if not target:
|
||||
return None
|
||||
if ";" in target:
|
||||
return target
|
||||
if not target or ";" in target:
|
||||
return target or None
|
||||
if target in self._guid_cache:
|
||||
self._guid_cache.move_to_end(target)
|
||||
return self._guid_cache[target]
|
||||
@@ -355,8 +329,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
for chat in payload.get("data", []) or []:
|
||||
if (chat.get("chatIdentifier") or chat.get("identifier")) != target:
|
||||
continue
|
||||
guid = chat.get("guid") or chat.get("chatGuid")
|
||||
if guid:
|
||||
if guid := chat.get("guid") or chat.get("chatGuid"):
|
||||
self._guid_cache[target] = guid
|
||||
while len(self._guid_cache) > _GUID_CACHE_SIZE:
|
||||
self._guid_cache.popitem(last=False)
|
||||
@@ -365,50 +338,43 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _create_chat_for_handle(self, address: str, message: str) -> SendResult:
|
||||
"""Create a new chat by sending the first message to *address*."""
|
||||
payload = {"addresses": [address], "message": message, "tempGuid": _temp_guid()}
|
||||
return await self._post_message("/api/v1/chat/new", payload)
|
||||
return await self._post_message(
|
||||
"/api/v1/chat/new", {"addresses": [address], "message": message, "tempGuid": _temp_guid()})
|
||||
|
||||
# --- Text sending ---
|
||||
|
||||
@staticmethod
|
||||
def truncate_message(content: str, max_length: int = MAX_TEXT_LENGTH) -> List[str]:
|
||||
# Base splitter minus "(1/3)" pagination suffixes — iMessage bubbles flow naturally.
|
||||
chunks = BasePlatformAdapter.truncate_message(content, max_length)
|
||||
return [_PAGINATION_SUFFIX_RE.sub("", c) for c in chunks]
|
||||
return [_PAGINATION_SUFFIX_RE.sub("", c) for c in BasePlatformAdapter.truncate_message(content, max_length)]
|
||||
|
||||
async def send(
|
||||
self, chat_id: str, content: str, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
text = self.format_message(content)
|
||||
if not text:
|
||||
return SendResult(success=False, error="BlueBubbles send requires text")
|
||||
# Each paragraph becomes its own iMessage bubble; truncate any still too long.
|
||||
paragraphs = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()]
|
||||
chunks: List[str] = []
|
||||
for para in (paragraphs or [text]):
|
||||
chunks.extend([para] if len(para) <= self.MAX_MESSAGE_LENGTH
|
||||
else self.truncate_message(para, max_length=self.MAX_MESSAGE_LENGTH))
|
||||
paragraphs = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()] or [text]
|
||||
chunks = [c for para in paragraphs for c in (
|
||||
[para] if len(para) <= self.MAX_MESSAGE_LENGTH else self.truncate_message(para, self.MAX_MESSAGE_LENGTH))]
|
||||
last = SendResult(success=True)
|
||||
for chunk in chunks:
|
||||
guid = await self._resolve_chat_guid(chat_id)
|
||||
if not guid:
|
||||
# If the target looks like an address, try creating a new chat
|
||||
if self._private_api_enabled and ("@" in chat_id or _ADDRESS_RE.match(chat_id)):
|
||||
if self._private_api_enabled and ("@" in chat_id or _ADDRESS_RE.match(chat_id)): # address → new chat
|
||||
return await self._create_chat_for_handle(chat_id, chunk)
|
||||
return SendResult(success=False, error=f"BlueBubbles chat not found for target: {chat_id}")
|
||||
payload: Dict[str, Any] = {"chatGuid": guid, "tempGuid": _temp_guid(), "message": chunk}
|
||||
if reply_to and self._private_api_enabled and self._helper_connected:
|
||||
payload.update(method="private-api", selectedMessageGuid=reply_to, partIndex=0)
|
||||
last = await self._post_message("/api/v1/message/text", payload)
|
||||
if not last.success:
|
||||
if not (last := await self._post_message("/api/v1/message/text", payload)).success:
|
||||
return last
|
||||
return last
|
||||
|
||||
# --- Media sending (outbound) ---
|
||||
|
||||
async def _send_attachment(
|
||||
self, chat_id: str, file_path: str, filename: Optional[str] = None,
|
||||
caption: Optional[str] = None, is_audio_message: bool = False) -> SendResult:
|
||||
async def _send_attachment(self, chat_id: str, file_path: str, filename: Optional[str] = None,
|
||||
caption: Optional[str] = None, is_audio_message: bool = False) -> SendResult:
|
||||
"""Send a file attachment via BlueBubbles multipart upload."""
|
||||
if not self.client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
@@ -419,34 +385,31 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error=f"Chat not found: {chat_id}")
|
||||
fname = filename or os.path.basename(file_path)
|
||||
try:
|
||||
# httpx's async multipart iterator reads file objects through a sync
|
||||
# chunk generator — read the bytes off the event-loop thread first.
|
||||
# httpx's async multipart iterator reads file objects through a sync chunk generator —
|
||||
# read the bytes off the event-loop thread first.
|
||||
payload = await asyncio.to_thread(Path(file_path).read_bytes)
|
||||
files = {"attachment": (fname, payload, "application/octet-stream")}
|
||||
data: Dict[str, str] = {"chatGuid": guid, "name": fname, "tempGuid": uuid.uuid4().hex}
|
||||
if is_audio_message:
|
||||
data["isAudioMessage"] = "true"
|
||||
res = await self.client.post(
|
||||
self._api_url("/api/v1/message/attachment"), files=files, data=data, timeout=120)
|
||||
res = await self.client.post(self._api_url("/api/v1/message/attachment"), data=data, timeout=120,
|
||||
files={"attachment": (fname, payload, "application/octet-stream")})
|
||||
res.raise_for_status()
|
||||
result = res.json()
|
||||
if caption:
|
||||
await self.send(chat_id, caption)
|
||||
if result.get("status") == 200:
|
||||
rdata = result.get("data") or {}
|
||||
msg_id = rdata.get("guid") if isinstance(rdata, dict) else None
|
||||
return SendResult(success=True, message_id=msg_id, raw_response=result)
|
||||
return SendResult(success=True, message_id=rdata.get("guid") if isinstance(rdata, dict) else None,
|
||||
raw_response=result)
|
||||
return SendResult(success=False, error=result.get("message", "Attachment upload failed"))
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_image(
|
||||
self, chat_id: str, image_url: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
async def send_image(self, chat_id: str, image_url: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
try:
|
||||
from gateway.platforms.base import cache_image_from_url
|
||||
local_path = await cache_image_from_url(image_url)
|
||||
return await self._send_attachment(chat_id, local_path, caption=caption)
|
||||
return await self._send_attachment(chat_id, await cache_image_from_url(image_url), caption=caption)
|
||||
except Exception:
|
||||
return await super().send_image(chat_id, image_url, caption, reply_to)
|
||||
|
||||
@@ -482,14 +445,12 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
is_group = ";+;" in (chat_id or "")
|
||||
info: Dict[str, Any] = {"name": chat_id, "type": "group" if is_group else "dm"}
|
||||
with suppress(Exception):
|
||||
guid = await self._resolve_chat_guid(chat_id)
|
||||
if guid:
|
||||
if guid := await self._resolve_chat_guid(chat_id):
|
||||
res = await self._api_get(f"/api/v1/chat/{quote(guid, safe='')}?with=participants")
|
||||
data = (res or {}).get("data", {})
|
||||
info["name"] = data.get("displayName") or data.get("chatIdentifier") or chat_id
|
||||
participants = [
|
||||
addr for p in data.get("participants", []) or [] if (addr := (p.get("address") or "").strip())
|
||||
]
|
||||
participants = [addr for p in data.get("participants", []) or []
|
||||
if (addr := (p.get("address") or "").strip())]
|
||||
if participants:
|
||||
info["participants"] = participants
|
||||
return info
|
||||
@@ -504,9 +465,8 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if not self.client:
|
||||
return None
|
||||
try:
|
||||
resp = await self.client.get(
|
||||
self._api_url(f"/api/v1/attachment/{quote(att_guid, safe='')}/download"),
|
||||
timeout=60, follow_redirects=True)
|
||||
resp = await self.client.get(self._api_url(f"/api/v1/attachment/{quote(att_guid, safe='')}/download"),
|
||||
timeout=60, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
mime = (att_meta.get("mimeType") or "").lower()
|
||||
@@ -515,8 +475,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if mime.startswith("audio/"):
|
||||
return cache_audio_from_bytes(data, _closed_ext(mime, _BLUEBUBBLES_AUDIO_EXT_OVERRIDES, ".mp3"))
|
||||
# Videos, documents, and everything else
|
||||
filename = att_meta.get("transferName", "") or f"file_{uuid.uuid4().hex[:8]}"
|
||||
return cache_document_from_bytes(data, filename)
|
||||
return cache_document_from_bytes(data, att_meta.get("transferName", "") or f"file_{uuid.uuid4().hex[:8]}")
|
||||
except Exception as exc:
|
||||
logger.warning("[bluebubbles] failed to download attachment %s: %s", _redact(att_guid), exc)
|
||||
return None
|
||||
@@ -527,10 +486,8 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
return item
|
||||
if isinstance(data, list) and (first := next((i for i in data if isinstance(i, dict)), None)):
|
||||
return first
|
||||
if isinstance(payload.get("message"), dict):
|
||||
return payload.get("message")
|
||||
return payload if isinstance(payload, dict) else None
|
||||
@@ -557,9 +514,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
msg_type = MessageType.TEXT
|
||||
for att in record.get("attachments") or []:
|
||||
att_guid = att.get("guid", "")
|
||||
if not att_guid:
|
||||
continue
|
||||
cached = await self._download_attachment(att_guid, att)
|
||||
cached = await self._download_attachment(att_guid, att) if att_guid else None
|
||||
if not cached:
|
||||
continue
|
||||
mime = (att.get("mimeType") or "").lower()
|
||||
@@ -568,8 +523,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
is_voice = mime.startswith("audio/") or (att.get("uti") or "").endswith("caf")
|
||||
msg_type = (MessageType.PHOTO if mime.startswith("image/") else MessageType.VOICE if is_voice
|
||||
else MessageType.VIDEO if mime.startswith("video/") else MessageType.DOCUMENT)
|
||||
# With multiple attachments, prefer PHOTO if any images present
|
||||
if len(media_urls) > 1 and any(m.split("/")[0] == "image" for m in media_types):
|
||||
if len(media_urls) > 1 and any(m.split("/")[0] == "image" for m in media_types): # any image → PHOTO
|
||||
msg_type = MessageType.PHOTO
|
||||
return media_urls, media_types, msg_type
|
||||
|
||||
@@ -579,23 +533,17 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
|
||||
def _resolve_chat_and_sender(self, payload: Dict[str, Any], record: Dict[str, Any]):
|
||||
"""Returns ``(chat_guid, chat_identifier, sender)`` from the many BlueBubbles payload shapes."""
|
||||
chat_guid = self._value(
|
||||
record.get("chatGuid"), payload.get("chatGuid"),
|
||||
record.get("chat_guid"), payload.get("chat_guid"), payload.get("guid"))
|
||||
chat_guid = self._value(record.get("chatGuid"), payload.get("chatGuid"), record.get("chat_guid"),
|
||||
payload.get("chat_guid"), payload.get("guid"))
|
||||
# BlueBubbles v1.9+ payloads omit top-level chatGuid; it's nested under data.chats[0].guid.
|
||||
_chats = record.get("chats") or []
|
||||
if not chat_guid and _chats and isinstance(_chats[0], dict):
|
||||
chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid")
|
||||
chat_identifier = self._value(
|
||||
record.get("chatIdentifier"), record.get("identifier"),
|
||||
payload.get("chatIdentifier"), payload.get("identifier"))
|
||||
chat_identifier = self._value(record.get("chatIdentifier"), record.get("identifier"),
|
||||
payload.get("chatIdentifier"), payload.get("identifier"))
|
||||
handle = record.get("handle")
|
||||
sender = (
|
||||
self._value(
|
||||
handle.get("address") if isinstance(handle, dict) else None,
|
||||
record.get("sender"), record.get("from"), record.get("address"))
|
||||
or chat_identifier
|
||||
or chat_guid)
|
||||
sender = (self._value(handle.get("address") if isinstance(handle, dict) else None, record.get("sender"),
|
||||
record.get("from"), record.get("address")) or chat_identifier or chat_guid)
|
||||
if not (chat_guid or chat_identifier) and sender:
|
||||
chat_identifier = sender
|
||||
return chat_guid, chat_identifier, sender
|
||||
@@ -611,15 +559,13 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
logger.error("[bluebubbles] webhook parse error: %s", exc)
|
||||
return web.json_response({"error": "invalid payload"}, status=400)
|
||||
event_type = self._value(payload.get("type"), payload.get("event")) or ""
|
||||
# Only process message events; silently acknowledge everything else
|
||||
if event_type and event_type not in _MESSAGE_EVENTS:
|
||||
if event_type and event_type not in _MESSAGE_EVENTS: # ack non-message events silently
|
||||
return _ok()
|
||||
record = self._extract_payload_record(payload) or {}
|
||||
if record.get("isFromMe") or record.get("fromMe") or record.get("is_from_me"):
|
||||
return _ok()
|
||||
# Skip tapback reactions delivered as messages
|
||||
assoc_type = record.get("associatedMessageType")
|
||||
if isinstance(assoc_type, int) and assoc_type in _TAPBACK_CODES:
|
||||
if isinstance(assoc_type, int) and assoc_type in _TAPBACK_CODES: # tapback reactions delivered as messages
|
||||
return _ok()
|
||||
text = self._value(record.get("text"), record.get("message"), record.get("body")) or ""
|
||||
media_urls, media_types, msg_type = await self._collect_attachments(record)
|
||||
@@ -635,10 +581,9 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
logger.debug("[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)")
|
||||
return _ok()
|
||||
text = self._clean_mention_text(text)
|
||||
source = self.build_source(
|
||||
chat_id=session_chat_id, chat_name=chat_identifier or sender,
|
||||
chat_type="group" if is_group else "dm", user_id=sender, user_name=sender,
|
||||
chat_id_alt=chat_identifier)
|
||||
source = self.build_source(chat_id=session_chat_id, chat_name=chat_identifier or sender,
|
||||
chat_type="group" if is_group else "dm", user_id=sender, user_name=sender,
|
||||
chat_id_alt=chat_identifier)
|
||||
event = MessageEvent(
|
||||
text=text, message_type=msg_type, source=source, raw_message=payload,
|
||||
message_id=self._value(record.get("guid"), record.get("messageGuid"), record.get("id")),
|
||||
@@ -647,7 +592,6 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
task = asyncio.create_task(self.handle_message(event))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
# Fire-and-forget read receipt
|
||||
if self.send_read_receipts and session_chat_id:
|
||||
if self.send_read_receipts and session_chat_id: # fire-and-forget read receipt
|
||||
asyncio.create_task(self.mark_read(session_chat_id))
|
||||
return _ok()
|
||||
|
||||
@@ -26,10 +26,9 @@ from gateway.platforms.base import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ``None`` → aiohttp binds one socket per address family (IPv4 + IPv6); the old
|
||||
# "0.0.0.0" default was unreachable over IPv6-only private networks. Pin a host
|
||||
# via extra.host. The all-interfaces default still requires
|
||||
# extra.allowed_source_cidrs (see _source_allowlist_required_but_missing).
|
||||
# ``None`` → aiohttp binds one socket per address family (IPv4 + IPv6); the old "0.0.0.0" default was
|
||||
# unreachable over IPv6-only private networks. Pin a host via extra.host. The all-interfaces default
|
||||
# still requires extra.allowed_source_cidrs (see _source_allowlist_required_but_missing).
|
||||
DEFAULT_HOST = None
|
||||
DEFAULT_PORT = 8646
|
||||
DEFAULT_WEBHOOK_PATH = "/msgraph/webhook"
|
||||
@@ -54,11 +53,9 @@ def _normalize_path(path: Any) -> str:
|
||||
|
||||
|
||||
def _parse_allowed_source_cidrs(raw: Any) -> list[ipaddress._BaseNetwork]:
|
||||
"""Parse the optional CIDR allowlist; empty/missing means "allow everything".
|
||||
|
||||
When populated, requests from source IPs outside every listed CIDR are
|
||||
rejected with 403 before the body is parsed (restrict to Microsoft
|
||||
Graph's published webhook source ranges in production)."""
|
||||
"""Parse the optional CIDR allowlist; empty/missing means "allow everything". When populated, source
|
||||
IPs outside every listed CIDR get 403 before the body is parsed (restrict to Microsoft Graph's
|
||||
published webhook source ranges in production)."""
|
||||
if isinstance(raw, str):
|
||||
candidates = raw.split(",")
|
||||
elif isinstance(raw, (list, tuple, set)):
|
||||
@@ -66,8 +63,7 @@ def _parse_allowed_source_cidrs(raw: Any) -> list[ipaddress._BaseNetwork]:
|
||||
else:
|
||||
return []
|
||||
networks: list[ipaddress._BaseNetwork] = []
|
||||
for chunk in candidates:
|
||||
chunk = chunk.strip()
|
||||
for chunk in (c.strip() for c in candidates):
|
||||
if not chunk:
|
||||
continue
|
||||
try:
|
||||
@@ -111,8 +107,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
self._webhook_path: str = _normalize_path(extra.get("webhook_path", DEFAULT_WEBHOOK_PATH))
|
||||
self._health_path: str = _normalize_path(extra.get("health_path", "/health"))
|
||||
self._accepted_resources: list[str] = [
|
||||
str(value).strip() for value in (extra.get("accepted_resources") or []) if str(value).strip()
|
||||
]
|
||||
str(value).strip() for value in (extra.get("accepted_resources") or []) if str(value).strip()]
|
||||
self._client_state: Optional[str] = _string_or_none(extra.get("client_state"))
|
||||
self._max_seen_receipts = max(1, int(extra.get("max_seen_receipts", DEFAULT_MAX_SEEN_RECEIPTS)))
|
||||
self._max_body_bytes = max(1, int(extra.get("max_body_bytes", DEFAULT_MAX_BODY_BYTES)))
|
||||
@@ -121,8 +116,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
self._notification_scheduler: Optional[NotificationScheduler] = None
|
||||
self._seen_receipts: set[str] = set()
|
||||
self._seen_receipt_order: deque[str] = deque()
|
||||
self._accepted_count = 0
|
||||
self._duplicate_count = 0
|
||||
self._accepted_count = self._duplicate_count = 0
|
||||
|
||||
def set_notification_scheduler(self, scheduler: Optional[NotificationScheduler]) -> None:
|
||||
self._notification_scheduler = scheduler
|
||||
@@ -137,12 +131,9 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
logger.error("[msgraph_webhook] Refusing to start without extra.client_state configured")
|
||||
return False
|
||||
if self._source_allowlist_required_but_missing():
|
||||
logger.error(
|
||||
"[msgraph_webhook] Refusing to start: binding to %s requires "
|
||||
"extra.allowed_source_cidrs. Configure the Microsoft Graph "
|
||||
"source CIDRs or bind to loopback (127.0.0.1/::1) behind a "
|
||||
"tunnel or reverse proxy.",
|
||||
self._host)
|
||||
logger.error("[msgraph_webhook] Refusing to start: binding to %s requires extra.allowed_source_cidrs. "
|
||||
"Configure the Microsoft Graph source CIDRs or bind to loopback (127.0.0.1/::1) behind a "
|
||||
"tunnel or reverse proxy.", self._host)
|
||||
return False
|
||||
app = web.Application(client_max_size=self._max_body_bytes)
|
||||
app.router.add_get(self._health_path, self._handle_health)
|
||||
@@ -164,9 +155,8 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
self._runner = None
|
||||
self._mark_disconnected()
|
||||
|
||||
async def send(
|
||||
self, chat_id: str, content: str, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
logger.info("[msgraph_webhook] Response for %s: %s", chat_id, content[:200])
|
||||
return SendResult(success=True)
|
||||
|
||||
@@ -181,12 +171,11 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
"accepted": self._accepted_count, "duplicates": self._duplicate_count})
|
||||
|
||||
async def _handle_validation(self, request: "web.Request") -> "web.Response":
|
||||
"""Graph subscription validation handshake: echo ``validationToken`` verbatim
|
||||
as text/plain. Bare GETs are rejected so the endpoint can't be enumerated."""
|
||||
"""Graph subscription validation handshake: echo ``validationToken`` verbatim as text/plain. Bare GETs
|
||||
are rejected so the endpoint can't be enumerated."""
|
||||
if not self._source_ip_allowed(request):
|
||||
return web.Response(status=403)
|
||||
validation_token = request.query.get("validationToken", "")
|
||||
if not validation_token:
|
||||
if not (validation_token := request.query.get("validationToken", "")):
|
||||
return web.Response(status=400)
|
||||
return web.Response(text=validation_token, content_type="text/plain")
|
||||
|
||||
@@ -198,12 +187,10 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
if not self._resource_accepted(str(notification.get("resource") or "")):
|
||||
return "other"
|
||||
if not self._verify_client_state(notification):
|
||||
# Bad clientState is an auth failure: a fully forged batch gets 403
|
||||
# so the sender stops retrying; legitimate Graph retries carry a
|
||||
# valid clientState and hit the accepted/duplicate paths.
|
||||
# Bad clientState is an auth failure: a fully forged batch gets 403 so the sender stops
|
||||
# retrying; legitimate Graph retries carry a valid clientState → accepted/duplicate paths.
|
||||
return "auth"
|
||||
explicit_id = str(notification.get("id") or "").strip()
|
||||
receipt_key = f"id:{explicit_id}" if explicit_id else None
|
||||
receipt_key = f"id:{explicit_id}" if (explicit_id := str(notification.get("id") or "").strip()) else None
|
||||
if receipt_key is not None:
|
||||
if receipt_key in self._seen_receipts:
|
||||
return "duplicate"
|
||||
@@ -216,8 +203,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
if not self._source_ip_allowed(request):
|
||||
return web.Response(status=403)
|
||||
# Graph never sends validationToken on POST, but tolerate clients replaying it in-band.
|
||||
validation_token = request.query.get("validationToken", "")
|
||||
if validation_token:
|
||||
if validation_token := request.query.get("validationToken", ""):
|
||||
return web.Response(text=validation_token, content_type="text/plain")
|
||||
status, notifications = await self._read_notifications(request)
|
||||
if status:
|
||||
@@ -226,9 +212,8 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
for raw_notification in notifications:
|
||||
counts[self._ingest_notification(raw_notification)] += 1
|
||||
self._duplicate_count += counts["duplicate"]
|
||||
# Anything ingested OR deduped → 202 with empty body (Graph acks; no
|
||||
# counter leak). Every item failed auth → 403 so forged POSTs get a
|
||||
# clear reject. Otherwise (malformed / resource not accepted) → 400.
|
||||
# Anything ingested OR deduped → 202 with empty body (Graph acks; no counter leak). Every item
|
||||
# failed auth → 403 so forged POSTs get a clear reject. Otherwise (malformed / not accepted) → 400.
|
||||
if counts["accepted"] or counts["duplicate"]:
|
||||
return web.Response(status=202)
|
||||
if counts["auth"] and not counts["other"]:
|
||||
@@ -254,13 +239,11 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return 400, []
|
||||
notifications = body.get("value") if isinstance(body, dict) else None
|
||||
if not isinstance(notifications, list):
|
||||
return 400, []
|
||||
return 0, notifications
|
||||
return (0, notifications) if isinstance(notifications, list) else (400, [])
|
||||
|
||||
def _source_ip_allowed(self, request: "web.Request") -> bool:
|
||||
"""Loopback-only binds may omit ``allowed_source_cidrs`` (local proxies,
|
||||
dev tunnels); network-accessible binds fail closed without one."""
|
||||
"""Loopback-only binds may omit ``allowed_source_cidrs`` (local proxies, dev tunnels);
|
||||
network-accessible binds fail closed without one."""
|
||||
if self._source_allowlist_required_but_missing():
|
||||
return False
|
||||
if not self._allowed_source_networks:
|
||||
@@ -275,25 +258,21 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
if not self._accepted_resources:
|
||||
return True
|
||||
resource = resource.strip().strip("/")
|
||||
for pattern in self._accepted_resources:
|
||||
pattern = pattern.strip().strip("/")
|
||||
if not pattern:
|
||||
continue
|
||||
for pattern in (p.strip().strip("/") for p in self._accepted_resources):
|
||||
if pattern.endswith("*"):
|
||||
pattern = pattern[:-1].rstrip("/")
|
||||
if _prefix_match(resource, pattern):
|
||||
if pattern and _prefix_match(resource, pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _verify_client_state(self, notification: Dict[str, Any]) -> bool:
|
||||
"""Timing-safe compare of the Graph-supplied clientState against the
|
||||
configured shared secret (``openssl rand -hex 32`` in the setup guide)."""
|
||||
"""Timing-safe compare of the Graph-supplied clientState against the configured shared secret
|
||||
(``openssl rand -hex 32`` in the setup guide)."""
|
||||
expected = self._client_state
|
||||
provided = _string_or_none(notification.get("clientState"))
|
||||
if expected is None or provided is None:
|
||||
return False
|
||||
# Compare as bytes: compare_digest raises TypeError on non-ASCII str,
|
||||
# and clientState comes from the request body.
|
||||
# Compare as bytes: compare_digest raises TypeError on non-ASCII str (clientState is request-controlled).
|
||||
return hmac.compare_digest(provided.encode(), expected.encode())
|
||||
|
||||
def _remember_receipt(self, receipt_key: str) -> None:
|
||||
@@ -305,9 +284,8 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
def _build_message_event(self, notification: Dict[str, Any], receipt_key: Optional[str]) -> MessageEvent:
|
||||
message_id = receipt_key or f"sha1:{sha1(json.dumps(notification, sort_keys=True).encode('utf-8')).hexdigest()}"
|
||||
source = self.build_source(
|
||||
chat_id=f"msgraph:{notification.get('subscriptionId', 'unknown')}",
|
||||
chat_name="msgraph/webhook", chat_type="webhook",
|
||||
user_id="msgraph", user_name="Microsoft Graph")
|
||||
chat_id=f"msgraph:{notification.get('subscriptionId', 'unknown')}", chat_name="msgraph/webhook",
|
||||
chat_type="webhook", user_id="msgraph", user_name="Microsoft Graph")
|
||||
return MessageEvent(
|
||||
text=self._render_prompt(notification), message_type=MessageType.TEXT, source=source,
|
||||
raw_message=notification, message_id=message_id, internal=True)
|
||||
|
||||
@@ -20,11 +20,11 @@ from gateway.platforms.qqbot.constants import FILE_UPLOAD_TIMEOUT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BIZ_CODE_DAILY_LIMIT = 40093002 # upload_prepare: daily cumulative limit
|
||||
_BIZ_CODE_DAILY_LIMIT = 40093002 # upload_prepare: daily cumulative limit
|
||||
_BIZ_CODE_PART_RETRYABLE = 40093001 # upload_part_finish: transient
|
||||
_DEFAULT_CONCURRENT_PARTS = 1
|
||||
_MAX_CONCURRENT_PARTS = 10
|
||||
_PART_UPLOAD_TIMEOUT = 300.0 # 5 minutes per COS PUT
|
||||
_PART_UPLOAD_TIMEOUT = 300.0 # 5 minutes per COS PUT
|
||||
_PART_UPLOAD_MAX_RETRIES = 2
|
||||
_PART_FINISH_RETRY_INTERVAL = 1.0
|
||||
_PART_FINISH_DEFAULT_TIMEOUT = 120.0
|
||||
@@ -33,12 +33,9 @@ _COMPLETE_UPLOAD_MAX_RETRIES = 2
|
||||
_COMPLETE_UPLOAD_BASE_DELAY = 2.0
|
||||
_MD5_10M_SIZE = 10_002_432 # first N bytes used for the ``md5_10m`` hash (per QQ API spec)
|
||||
|
||||
# ── Exceptions ──
|
||||
|
||||
class _UploadError(Exception):
|
||||
def __init__(self, file_name: str, file_size: int, message: str) -> None:
|
||||
self.file_name = file_name
|
||||
self.file_size = file_size
|
||||
self.file_name, self.file_size = file_name, file_size
|
||||
super().__init__(message)
|
||||
|
||||
@property
|
||||
@@ -59,18 +56,14 @@ class UploadFileTooLargeError(_UploadError):
|
||||
def __init__(self, file_name: str, file_size: int, limit_bytes: int = 0, message: str = "") -> None:
|
||||
self.limit_bytes = limit_bytes
|
||||
limit_str = f" ({format_size(limit_bytes)})" if limit_bytes else ""
|
||||
super().__init__(
|
||||
file_name, file_size,
|
||||
message or f"File {file_name!r} ({format_size(file_size)}) exceeds platform limit{limit_str}",
|
||||
)
|
||||
super().__init__(file_name, file_size,
|
||||
message or f"File {file_name!r} ({format_size(file_size)}) exceeds platform limit{limit_str}")
|
||||
|
||||
@property
|
||||
def limit_human(self) -> str:
|
||||
return format_size(self.limit_bytes) if self.limit_bytes else "unknown"
|
||||
|
||||
|
||||
# ── Prepare-response shape ──
|
||||
|
||||
@dataclass
|
||||
class _PreparePart:
|
||||
index: int
|
||||
@@ -90,35 +83,26 @@ class _PrepareResult:
|
||||
def _parse_prepare_response(raw: Dict[str, Any]) -> _PrepareResult:
|
||||
"""Parse upload_prepare response (either bare or wrapped in ``data``)."""
|
||||
src = raw.get("data") if isinstance(raw.get("data"), dict) else raw
|
||||
upload_id = str(src.get("upload_id", ""))
|
||||
if not upload_id:
|
||||
if not (upload_id := str(src.get("upload_id", ""))):
|
||||
raise ValueError(f"upload_prepare response missing upload_id: {str(raw)[:200]}")
|
||||
block_size = int(src.get("block_size", 0))
|
||||
raw_parts = src.get("parts") or src.get("part_list") or []
|
||||
if not isinstance(raw_parts, list) or not raw_parts:
|
||||
raise ValueError(f"upload_prepare response missing parts: {str(raw)[:200]}")
|
||||
parts = [
|
||||
_PreparePart(
|
||||
index=int(p.get("part_index") or p.get("index") or 0),
|
||||
presigned_url=str(p.get("presigned_url") or p.get("url") or ""),
|
||||
block_size=int(p.get("block_size", 0)))
|
||||
for p in raw_parts
|
||||
if isinstance(p, dict)]
|
||||
parts = [_PreparePart(index=int(p.get("part_index") or p.get("index") or 0),
|
||||
presigned_url=str(p.get("presigned_url") or p.get("url") or ""),
|
||||
block_size=int(p.get("block_size", 0)))
|
||||
for p in raw_parts if isinstance(p, dict)]
|
||||
return _PrepareResult(
|
||||
upload_id=upload_id,
|
||||
block_size=block_size,
|
||||
parts=parts,
|
||||
upload_id=upload_id, block_size=block_size, parts=parts,
|
||||
concurrency=int(src.get("concurrency", _DEFAULT_CONCURRENT_PARTS)) or _DEFAULT_CONCURRENT_PARTS,
|
||||
retry_timeout=float(src.get("retry_timeout", 0.0) or 0.0))
|
||||
|
||||
|
||||
def _api_path(chat_type: str, target_id: str, endpoint: str) -> str:
|
||||
base = "/v2/users" if chat_type == "c2c" else "/v2/groups"
|
||||
return f"{base}/{target_id}/{endpoint}"
|
||||
return f"{'/v2/users' if chat_type == 'c2c' else '/v2/groups'}/{target_id}/{endpoint}"
|
||||
|
||||
|
||||
# ── Chunked upload driver ──
|
||||
|
||||
@dataclass
|
||||
class _Job:
|
||||
"""Per-upload state shared by the part workers."""
|
||||
@@ -142,9 +126,8 @@ class ChunkedUploader:
|
||||
circular import; must raise RuntimeError with the biz_code in the message);
|
||||
``http_put`` is ``(url, data, headers) -> httpx-like response`` for COS PUTs."""
|
||||
|
||||
def __init__(
|
||||
self, api_request: Callable[..., Awaitable[Dict[str, Any]]], http_put: Callable[..., Awaitable[Any]],
|
||||
log_tag: str = "QQBot") -> None:
|
||||
def __init__(self, api_request: Callable[..., Awaitable[Dict[str, Any]]],
|
||||
http_put: Callable[..., Awaitable[Any]], log_tag: str = "QQBot") -> None:
|
||||
self._api_request = api_request
|
||||
self._http_put = http_put
|
||||
self._log_tag = log_tag
|
||||
@@ -158,24 +141,18 @@ class ChunkedUploader:
|
||||
Raises UploadDailyLimitExceededError (40093002), UploadFileTooLargeError, RuntimeError."""
|
||||
if chat_type not in {"c2c", "group"}:
|
||||
raise ValueError(f"ChunkedUploader: unsupported chat_type {chat_type!r}")
|
||||
|
||||
job = _Job(chat_type, target_id, file_path, Path(file_path).stat().st_size)
|
||||
logger.info(
|
||||
"[%s] Chunked upload start: file=%s size=%s type=%d",
|
||||
self._log_tag, file_name, format_size(job.file_size), file_type)
|
||||
|
||||
logger.info("[%s] Chunked upload start: file=%s size=%s type=%d", self._log_tag, file_name,
|
||||
format_size(job.file_size), file_type)
|
||||
# Hashing is blocking I/O → executor.
|
||||
hashes = await asyncio.get_running_loop().run_in_executor(None, _compute_file_hashes, file_path, job.file_size)
|
||||
prepare = await self._prepare(job, file_type, file_name, hashes)
|
||||
max_concurrent = min(prepare.concurrency, _MAX_CONCURRENT_PARTS)
|
||||
job.upload_id, job.block_size, job.total_parts = prepare.upload_id, prepare.block_size, len(prepare.parts)
|
||||
job.retry_timeout = min(
|
||||
prepare.retry_timeout if prepare.retry_timeout > 0 else _PART_FINISH_DEFAULT_TIMEOUT,
|
||||
_PART_FINISH_MAX_TIMEOUT)
|
||||
logger.info(
|
||||
"[%s] Prepared: upload_id=%s block_size=%s parts=%d concurrency=%d",
|
||||
self._log_tag, job.upload_id, format_size(job.block_size), job.total_parts, max_concurrent)
|
||||
|
||||
job.retry_timeout = min(prepare.retry_timeout if prepare.retry_timeout > 0 else _PART_FINISH_DEFAULT_TIMEOUT,
|
||||
_PART_FINISH_MAX_TIMEOUT)
|
||||
logger.info("[%s] Prepared: upload_id=%s block_size=%s parts=%d concurrency=%d", self._log_tag,
|
||||
job.upload_id, format_size(job.block_size), job.total_parts, max_concurrent)
|
||||
sem = asyncio.Semaphore(max(max_concurrent, 1))
|
||||
|
||||
async def _run(part: _PreparePart) -> None:
|
||||
@@ -187,42 +164,34 @@ class ChunkedUploader:
|
||||
return await self._complete(job)
|
||||
|
||||
async def _prepare(self, job: _Job, file_type: int, file_name: str, hashes: Dict[str, str]) -> _PrepareResult:
|
||||
body = {
|
||||
"file_type": file_type, "file_name": file_name, "file_size": job.file_size,
|
||||
"md5": hashes["md5"], "sha1": hashes["sha1"], "md5_10m": hashes["md5_10m"]}
|
||||
body = {"file_type": file_type, "file_name": file_name, "file_size": job.file_size,
|
||||
"md5": hashes["md5"], "sha1": hashes["sha1"], "md5_10m": hashes["md5_10m"]}
|
||||
try:
|
||||
raw = await self._post(job, "upload_prepare", body)
|
||||
except RuntimeError as exc:
|
||||
err_msg = str(exc)
|
||||
if f"{_BIZ_CODE_DAILY_LIMIT}" in err_msg:
|
||||
raise UploadDailyLimitExceededError(file_name, job.file_size, err_msg) from exc
|
||||
if f"{_BIZ_CODE_DAILY_LIMIT}" in str(exc):
|
||||
raise UploadDailyLimitExceededError(file_name, job.file_size, str(exc)) from exc
|
||||
raise
|
||||
return _parse_prepare_response(raw)
|
||||
|
||||
async def _upload_one_part(self, job: _Job, part: _PreparePart) -> None:
|
||||
"""PUT one part to COS, then call ``upload_part_finish``."""
|
||||
part_index, total_parts = part.index, job.total_parts
|
||||
# Per-part block_size wins; fall back to the response-level value.
|
||||
actual_block_size = part.block_size if part.block_size > 0 else job.block_size
|
||||
offset = (part_index - 1) * job.block_size
|
||||
length = min(actual_block_size, job.file_size - offset)
|
||||
|
||||
# Per-part block_size wins; fall back to the response-level value.
|
||||
length = min(part.block_size if part.block_size > 0 else job.block_size, job.file_size - offset)
|
||||
data = await asyncio.get_running_loop().run_in_executor(None, _read_file_chunk, job.file_path, offset, length)
|
||||
md5_hex = hashlib.md5(data).hexdigest()
|
||||
logger.debug(
|
||||
"[%s] Part %d/%d: uploading %s (offset=%d md5=%s)",
|
||||
self._log_tag, part_index, total_parts, format_size(length), offset, md5_hex)
|
||||
|
||||
logger.debug("[%s] Part %d/%d: uploading %s (offset=%d md5=%s)", self._log_tag, part_index, total_parts,
|
||||
format_size(length), offset, md5_hex)
|
||||
await self._put_to_presigned_url(part.presigned_url, data, part_index, total_parts)
|
||||
await self._part_finish_with_retry(job, part_index, length, md5_hex)
|
||||
|
||||
job.completed += 1
|
||||
logger.debug(
|
||||
"[%s] Part %d/%d done (%d/%d total)", self._log_tag, part_index, total_parts, job.completed, total_parts)
|
||||
logger.debug("[%s] Part %d/%d done (%d/%d total)", self._log_tag, part_index, total_parts, job.completed,
|
||||
total_parts)
|
||||
|
||||
async def _with_retries(
|
||||
self, attempt_fn: Callable[[], Awaitable[Any]], *, max_retries: int, base_delay: float, label: str,
|
||||
failure_label: str) -> Any:
|
||||
async def _with_retries(self, attempt_fn: Callable[[], Awaitable[Any]], *, max_retries: int, base_delay: float,
|
||||
label: str, failure_label: str) -> Any:
|
||||
"""Run *attempt_fn* up to ``max_retries + 1`` times with exponential backoff."""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(max_retries + 1):
|
||||
@@ -232,9 +201,8 @@ class ChunkedUploader:
|
||||
last_exc = exc
|
||||
if attempt < max_retries:
|
||||
delay = base_delay * (2 ** attempt)
|
||||
logger.warning(
|
||||
"[%s] %s attempt %d failed, retry in %.1fs: %s",
|
||||
self._log_tag, label, attempt + 1, delay, exc)
|
||||
logger.warning("[%s] %s attempt %d failed, retry in %.1fs: %s", self._log_tag, label, attempt + 1,
|
||||
delay, exc)
|
||||
await asyncio.sleep(delay)
|
||||
raise RuntimeError(f"{failure_label} failed after {max_retries + 1} attempts: {last_exc}")
|
||||
|
||||
@@ -242,9 +210,8 @@ class ChunkedUploader:
|
||||
"""PUT part data to a pre-signed COS URL with retry."""
|
||||
|
||||
async def _attempt() -> None:
|
||||
resp = await asyncio.wait_for(
|
||||
self._http_put(url, data=data, headers={"Content-Length": str(len(data))}),
|
||||
timeout=_PART_UPLOAD_TIMEOUT)
|
||||
resp = await asyncio.wait_for(self._http_put(url, data=data, headers={"Content-Length": str(len(data))}),
|
||||
timeout=_PART_UPLOAD_TIMEOUT)
|
||||
status = getattr(resp, "status_code", 0)
|
||||
if 200 <= status < 300:
|
||||
logger.debug("[%s] PUT part %d/%d: %d OK", self._log_tag, part_index, total_parts, status)
|
||||
@@ -255,16 +222,15 @@ class ChunkedUploader:
|
||||
body_preview = ""
|
||||
raise RuntimeError(f"COS PUT returned {status}: {body_preview}")
|
||||
|
||||
await self._with_retries(
|
||||
_attempt, max_retries=_PART_UPLOAD_MAX_RETRIES, base_delay=1.0,
|
||||
label=f"PUT part {part_index}/{total_parts}", failure_label=f"Part {part_index}/{total_parts} upload")
|
||||
await self._with_retries(_attempt, max_retries=_PART_UPLOAD_MAX_RETRIES, base_delay=1.0,
|
||||
label=f"PUT part {part_index}/{total_parts}",
|
||||
failure_label=f"Part {part_index}/{total_parts} upload")
|
||||
|
||||
async def _part_finish_with_retry(self, job: _Job, part_index: int, block_size: int, md5: str) -> None:
|
||||
"""Call ``upload_part_finish``, retrying on biz_code 40093001 until ``job.retry_timeout``."""
|
||||
body = {"upload_id": job.upload_id, "part_index": part_index, "block_size": block_size, "md5": md5}
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
attempt = 0
|
||||
start, attempt = loop.time(), 0
|
||||
while True:
|
||||
try:
|
||||
await self._post(job, "upload_part_finish", body)
|
||||
@@ -274,26 +240,21 @@ class ChunkedUploader:
|
||||
raise
|
||||
elapsed = loop.time() - start
|
||||
if elapsed >= job.retry_timeout:
|
||||
raise RuntimeError(
|
||||
f"upload_part_finish persistent retry timed out "
|
||||
f"after {job.retry_timeout:.0f}s ({attempt} retries): {exc}") from exc
|
||||
raise RuntimeError(f"upload_part_finish persistent retry timed out after "
|
||||
f"{job.retry_timeout:.0f}s ({attempt} retries): {exc}") from exc
|
||||
attempt += 1
|
||||
logger.debug(
|
||||
"[%s] part_finish retryable error, attempt %d, elapsed=%.1fs: %s",
|
||||
self._log_tag, attempt, elapsed, exc)
|
||||
logger.debug("[%s] part_finish retryable error, attempt %d, elapsed=%.1fs: %s", self._log_tag, attempt,
|
||||
elapsed, exc)
|
||||
await asyncio.sleep(_PART_FINISH_RETRY_INTERVAL)
|
||||
|
||||
async def _complete(self, job: _Job) -> Dict[str, Any]:
|
||||
"""Call ``complete_upload`` with retry — the ``/files`` endpoint (same as the simple
|
||||
URL upload) selects the chunked-completion path when only ``upload_id`` is sent."""
|
||||
"""Call ``complete_upload`` with retry — the ``/files`` endpoint (same as the simple URL upload)
|
||||
selects the chunked-completion path when only ``upload_id`` is sent."""
|
||||
return await self._with_retries(
|
||||
lambda: self._post(job, "files", {"upload_id": job.upload_id}),
|
||||
max_retries=_COMPLETE_UPLOAD_MAX_RETRIES, base_delay=_COMPLETE_UPLOAD_BASE_DELAY,
|
||||
label="complete_upload", failure_label="complete_upload")
|
||||
lambda: self._post(job, "files", {"upload_id": job.upload_id}), max_retries=_COMPLETE_UPLOAD_MAX_RETRIES,
|
||||
base_delay=_COMPLETE_UPLOAD_BASE_DELAY, label="complete_upload", failure_label="complete_upload")
|
||||
|
||||
|
||||
# ── Helpers (module-level for testability) ──
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Return a human-readable file size string (e.g. ``'12.3 MB'``)."""
|
||||
size = float(size_bytes)
|
||||
@@ -309,32 +270,23 @@ def _read_file_chunk(file_path: str, offset: int, length: int) -> bytes:
|
||||
with open(file_path, "rb") as fh:
|
||||
fh.seek(offset)
|
||||
data = fh.read(length)
|
||||
if len(data) != length:
|
||||
raise IOError(
|
||||
f"Short read from {file_path}: expected {length} bytes at "
|
||||
f"offset {offset}, got {len(data)} (file may be truncated)")
|
||||
return data
|
||||
if len(data) != length:
|
||||
raise IOError(f"Short read from {file_path}: expected {length} bytes at offset {offset}, got {len(data)} "
|
||||
f"(file may be truncated)")
|
||||
return data
|
||||
|
||||
|
||||
def _compute_file_hashes(file_path: str, file_size: int) -> Dict[str, str]:
|
||||
"""Compute md5, sha1, and md5_10m in a single pass."""
|
||||
md5 = hashlib.md5()
|
||||
sha1 = hashlib.sha1()
|
||||
md5_10m = hashlib.md5()
|
||||
"""Compute md5, sha1, and md5_10m in a single pass (for small files md5_10m is just the full md5)."""
|
||||
md5, sha1, md5_10m = hashlib.md5(), hashlib.sha1(), hashlib.md5()
|
||||
need_10m = file_size > _MD5_10M_SIZE
|
||||
bytes_read = 0
|
||||
with open(file_path, "rb") as fh:
|
||||
while chunk := fh.read(65536):
|
||||
md5.update(chunk)
|
||||
sha1.update(chunk)
|
||||
if need_10m:
|
||||
remaining = _MD5_10M_SIZE - bytes_read
|
||||
if remaining > 0:
|
||||
md5_10m.update(chunk[:remaining])
|
||||
if need_10m and (remaining := _MD5_10M_SIZE - bytes_read) > 0:
|
||||
md5_10m.update(chunk[:remaining])
|
||||
bytes_read += len(chunk)
|
||||
full_md5 = md5.hexdigest()
|
||||
return {
|
||||
"md5": full_md5,
|
||||
"sha1": sha1.hexdigest(),
|
||||
# For small files the "10m" hash is just the full md5.
|
||||
"md5_10m": md5_10m.hexdigest() if need_10m else full_md5}
|
||||
return {"md5": full_md5, "sha1": sha1.hexdigest(), "md5_10m": md5_10m.hexdigest() if need_10m else full_md5}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""QQ Bot inline keyboards + approval / update-prompt helpers.
|
||||
|
||||
A button click dispatches an ``INTERACTION_CREATE`` event carrying the button's
|
||||
``data``; the bot must ACK promptly via ``PUT /interactions/{id}`` or the user
|
||||
sees an error indicator. ``button_data`` formats: ``approve:<session_key>:<decision>``
|
||||
(decision = allow-once|allow-always|deny) and ``update_prompt:<answer>`` (y|n).
|
||||
Ported from WideLee's qqbot-agent-sdk v1.2.2 (authorship via Co-authored-by).
|
||||
"""
|
||||
"""QQ Bot inline keyboards + approval / update-prompt helpers. A button click dispatches an
|
||||
``INTERACTION_CREATE`` event carrying the button's ``data``; the bot must ACK promptly via
|
||||
``PUT /interactions/{id}`` or the user sees an error indicator. ``button_data`` formats:
|
||||
``approve:<session_key>:<decision>`` (allow-once|allow-always|deny) and ``update_prompt:<answer>`` (y|n).
|
||||
Ported from WideLee's qqbot-agent-sdk v1.2.2 (authorship via Co-authored-by)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,13 +13,10 @@ from typing import Any, Dict, List, Optional
|
||||
APPROVAL_BUTTON_PREFIX = "approve:"
|
||||
UPDATE_PROMPT_PREFIX = "update_prompt:"
|
||||
|
||||
# session_key may itself contain colons (e.g. agent:main:qqbot:c2c:OPENID),
|
||||
# so the session_key group is greedy but trails the decision.
|
||||
# session_key may itself contain colons (agent:main:qqbot:c2c:OPENID): greedy group, decision trails.
|
||||
_APPROVAL_DATA_RE = re.compile(r"^approve:(.+):(allow-once|allow-always|deny)$")
|
||||
_UPDATE_PROMPT_RE = re.compile(r"^update_prompt:(y|n)$")
|
||||
|
||||
# ── Keyboard dataclasses ──
|
||||
|
||||
def _to_dict(value: Any) -> Any:
|
||||
"""Serialize a dataclass tree in field-declaration order (the wire shape)."""
|
||||
if is_dataclass(value):
|
||||
@@ -31,8 +25,7 @@ def _to_dict(value: Any) -> Any:
|
||||
|
||||
|
||||
class _Serializable:
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return _to_dict(self)
|
||||
to_dict = _to_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -84,36 +77,28 @@ class InlineKeyboard(_Serializable):
|
||||
content: KeyboardContent = field(default_factory=KeyboardContent)
|
||||
|
||||
|
||||
# ── INTERACTION_CREATE parsing ──
|
||||
|
||||
def parse_approval_button_data(button_data: str) -> Optional[tuple[str, str]]:
|
||||
"""Parse approval ``button_data`` into ``(session_key, decision)`` or ``None``."""
|
||||
m = _APPROVAL_DATA_RE.match(button_data or "")
|
||||
return m.groups() if m else None
|
||||
return m.groups() if (m := _APPROVAL_DATA_RE.match(button_data or "")) else None
|
||||
|
||||
|
||||
def parse_update_prompt_button_data(button_data: str) -> Optional[str]:
|
||||
"""Parse update-prompt ``button_data`` into ``'y'`` / ``'n'`` or ``None``."""
|
||||
m = _UPDATE_PROMPT_RE.match(button_data or "")
|
||||
return m.group(1) if m else None
|
||||
return m.group(1) if (m := _UPDATE_PROMPT_RE.match(button_data or "")) else None
|
||||
|
||||
|
||||
# ── Keyboard builders ──
|
||||
|
||||
def _single_row_keyboard(group_id: str, *buttons: tuple) -> InlineKeyboard:
|
||||
"""One row of callback buttons from ``(id, label, visited_label, data, style)`` tuples."""
|
||||
row = KeyboardRow(buttons=[
|
||||
KeyboardButton(
|
||||
id=btn_id, group_id=group_id, action=KeyboardButtonAction(type=1, data=data),
|
||||
render_data=KeyboardButtonRenderData(label=label, visited_label=visited, style=style))
|
||||
KeyboardButton(id=btn_id, group_id=group_id, action=KeyboardButtonAction(type=1, data=data),
|
||||
render_data=KeyboardButtonRenderData(label=label, visited_label=visited, style=style))
|
||||
for btn_id, label, visited, data, style in buttons])
|
||||
return InlineKeyboard(content=KeyboardContent(rows=[row]))
|
||||
|
||||
|
||||
def build_approval_keyboard(session_key: str, *, allow_permanent: bool = True) -> InlineKeyboard:
|
||||
"""Build ``[✅ 允许一次] [⭐ 始终允许] [❌ 拒绝]`` (one group, so a click greys the rest).
|
||||
⭐ is hidden when persistent scope is unavailable; *session_key* is embedded in
|
||||
``button_data`` so the decision routes to the right approval."""
|
||||
"""Build ``[✅ 允许一次] [⭐ 始终允许] [❌ 拒绝]`` (one group, so a click greys the rest). ⭐ is hidden when
|
||||
persistent scope is unavailable; *session_key* rides in ``button_data`` so the decision routes correctly."""
|
||||
prefix = f"{APPROVAL_BUTTON_PREFIX}{session_key}"
|
||||
buttons = [("allow", "✅ 允许一次", "已允许", f"{prefix}:allow-once", 1)]
|
||||
if allow_permanent:
|
||||
@@ -124,18 +109,14 @@ def build_approval_keyboard(session_key: str, *, allow_permanent: bool = True) -
|
||||
|
||||
def build_update_prompt_keyboard() -> InlineKeyboard:
|
||||
"""Build a Yes/No keyboard for update confirmation prompts."""
|
||||
return _single_row_keyboard(
|
||||
"update_prompt",
|
||||
("yes", "✓ 确认", "已确认", f"{UPDATE_PROMPT_PREFIX}y", 1),
|
||||
("no", "✗ 取消", "已取消", f"{UPDATE_PROMPT_PREFIX}n", 0))
|
||||
return _single_row_keyboard("update_prompt", ("yes", "✓ 确认", "已确认", f"{UPDATE_PROMPT_PREFIX}y", 1),
|
||||
("no", "✗ 取消", "已取消", f"{UPDATE_PROMPT_PREFIX}n", 0))
|
||||
|
||||
|
||||
# ── ApprovalRequest + text builder ──
|
||||
|
||||
@dataclass
|
||||
class ApprovalRequest:
|
||||
"""Approval-request display data. ``command_preview`` / ``cwd`` are set for exec
|
||||
approvals, ``tool_name`` for plugin approvals; ``severity`` is ``'critical' | 'info' | ''``."""
|
||||
"""Approval-request display data. ``command_preview`` / ``cwd`` are set for exec approvals, ``tool_name``
|
||||
for plugin approvals; ``severity`` is ``'critical' | 'info' | ''``."""
|
||||
session_key: str
|
||||
title: str
|
||||
description: str = ""
|
||||
@@ -172,8 +153,6 @@ def build_approval_text(req: ApprovalRequest) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── INTERACTION_CREATE event shape ──
|
||||
|
||||
@dataclass
|
||||
class InteractionEvent:
|
||||
"""Parsed ``INTERACTION_CREATE`` payload (api-v2 event-emit docs)."""
|
||||
@@ -202,14 +181,11 @@ _SCENE_NAMES = {0: "guild", 1: "group", 2: "c2c"}
|
||||
def parse_interaction_event(raw: Dict[str, Any]) -> InteractionEvent:
|
||||
"""Parse a raw ``INTERACTION_CREATE`` dispatch payload (``d``)."""
|
||||
data_raw = raw.get("data") or {}
|
||||
resolved = data_raw.get("resolved") or {}
|
||||
scene_code = int(raw.get("chat_type", 0) or 0)
|
||||
resolved, scene_code = data_raw.get("resolved") or {}, int(raw.get("chat_type", 0) or 0)
|
||||
return InteractionEvent(
|
||||
id=str(raw.get("id", "")), type=int(data_raw.get("type", 0) or 0), chat_type=scene_code,
|
||||
scene=_SCENE_NAMES.get(scene_code, ""), group_openid=str(raw.get("group_openid", "")),
|
||||
group_member_openid=str(raw.get("group_member_openid", "")),
|
||||
user_openid=str(raw.get("user_openid", "")), channel_id=str(raw.get("channel_id", "")),
|
||||
guild_id=str(raw.get("guild_id", "")), button_data=str(resolved.get("button_data", "")),
|
||||
button_id=str(resolved.get("button_id", "")),
|
||||
resolver_user_id=str(resolved.get("user_id", "")),
|
||||
)
|
||||
group_member_openid=str(raw.get("group_member_openid", "")), user_openid=str(raw.get("user_openid", "")),
|
||||
channel_id=str(raw.get("channel_id", "")), guild_id=str(raw.get("guild_id", "")),
|
||||
button_data=str(resolved.get("button_data", "")), button_id=str(resolved.get("button_id", "")),
|
||||
resolver_user_id=str(resolved.get("user_id", "")))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""QQBot scan-to-configure (QR code onboard). Mirrors the Feishu pattern: synchronous
|
||||
HTTP + one entry-point ``qr_register()`` (create task → display QR → poll → decrypt
|
||||
credentials) against ``q.qq.com`` ``create_bind_task`` / ``poll_bind_result``."""
|
||||
"""QQBot scan-to-configure (QR code onboard). Mirrors the Feishu pattern: synchronous HTTP + one entry-point
|
||||
``qr_register()`` (create task → display QR → poll → decrypt) against ``q.qq.com`` bind-task endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,8 +10,7 @@ from typing import Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
from .constants import (
|
||||
ONBOARD_API_TIMEOUT, ONBOARD_CREATE_PATH, ONBOARD_POLL_INTERVAL, ONBOARD_POLL_PATH, PORTAL_HOST, QR_URL_TEMPLATE,
|
||||
)
|
||||
ONBOARD_API_TIMEOUT, ONBOARD_CREATE_PATH, ONBOARD_POLL_INTERVAL, ONBOARD_POLL_PATH, PORTAL_HOST, QR_URL_TEMPLATE)
|
||||
from .crypto import decrypt_secret, generate_bind_key
|
||||
from .utils import get_api_headers
|
||||
|
||||
@@ -71,7 +69,8 @@ def _create_bind_task(timeout: float = ONBOARD_API_TIMEOUT) -> Tuple[str, str]:
|
||||
def _poll_bind_result(task_id: str, timeout: float = ONBOARD_API_TIMEOUT) -> Tuple[BindStatus, str, str, str]:
|
||||
"""Poll *task_id*; returns ``(status, bot_appid, bot_encrypt_secret, user_openid)``."""
|
||||
d = _portal_post(ONBOARD_POLL_PATH, {"task_id": task_id}, timeout, "poll_bind_result failed").get("data", {})
|
||||
return BindStatus(d.get("status", 0)), str(d.get("bot_appid", "")), d.get("bot_encrypt_secret", ""), d.get("user_openid", "")
|
||||
return (BindStatus(d.get("status", 0)), str(d.get("bot_appid", "")), d.get("bot_encrypt_secret", ""),
|
||||
d.get("user_openid", ""))
|
||||
|
||||
|
||||
def build_connect_url(task_id: str) -> str:
|
||||
@@ -86,14 +85,12 @@ def qr_register(timeout_seconds: int = 600) -> Optional[dict]:
|
||||
"""Run the QR registration flow; returns ``{"app_id", "client_secret", "user_openid"}``
|
||||
or None on failure / expiry / cancellation. Unexpected errors propagate."""
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
|
||||
for refresh_count in range(_MAX_REFRESHES + 1):
|
||||
try:
|
||||
task_id, aes_key = _create_bind_task()
|
||||
except Exception as exc:
|
||||
logger.warning("[QQBot onboard] Failed to create bind task: %s", exc)
|
||||
return None
|
||||
|
||||
url = build_connect_url(task_id)
|
||||
print()
|
||||
if _render_qr(url):
|
||||
@@ -102,14 +99,12 @@ def qr_register(timeout_seconds: int = 600) -> Optional[dict]:
|
||||
print(f" Open this URL in QQ on your phone:\n {url}")
|
||||
print(" Tip: pip install qrcode to display a scannable QR code here")
|
||||
print()
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status, app_id, encrypted_secret, user_openid = _poll_bind_result(task_id)
|
||||
except Exception:
|
||||
time.sleep(ONBOARD_POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
if status == BindStatus.COMPLETED:
|
||||
client_secret = decrypt_secret(encrypted_secret, aes_key)
|
||||
print()
|
||||
@@ -117,17 +112,14 @@ def qr_register(timeout_seconds: int = 600) -> Optional[dict]:
|
||||
if user_openid:
|
||||
print(f" Scanner's OpenID: {user_openid}")
|
||||
return {"app_id": app_id, "client_secret": client_secret, "user_openid": user_openid}
|
||||
|
||||
if status == BindStatus.EXPIRED:
|
||||
if refresh_count >= _MAX_REFRESHES:
|
||||
logger.warning("[QQBot onboard] QR code expired %d times — giving up", _MAX_REFRESHES)
|
||||
return None
|
||||
print(f"\n QR code expired, refreshing... ({refresh_count + 1}/{_MAX_REFRESHES})")
|
||||
break # next for-loop iteration creates a new task
|
||||
|
||||
time.sleep(ONBOARD_POLL_INTERVAL)
|
||||
else:
|
||||
logger.warning("[QQBot onboard] Poll timed out after %ds", timeout_seconds)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
+136
-209
@@ -1,9 +1,6 @@
|
||||
"""Signal messenger platform adapter.
|
||||
|
||||
Connects to a signal-cli daemon in HTTP mode (``signal-cli daemon --http 127.0.0.1:8080``):
|
||||
inbound messages arrive via SSE, outbound messages/actions use JSON-RPC 2.0 over HTTP.
|
||||
Requires SIGNAL_HTTP_URL and SIGNAL_ACCOUNT.
|
||||
"""
|
||||
"""Signal messenger platform adapter for a signal-cli daemon in HTTP mode (``signal-cli daemon
|
||||
--http 127.0.0.1:8080``): inbound via SSE, outbound via JSON-RPC 2.0. Requires SIGNAL_HTTP_URL
|
||||
and SIGNAL_ACCOUNT."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
@@ -55,6 +52,11 @@ _MAGIC_EXTENSIONS = ((b"\x89PNG", ".png"), (b"\xff\xd8", ".jpg"), (b"GIF8", ".gi
|
||||
_MEDIA_TYPE_BY_MIME_PREFIX = (
|
||||
("audio/", MessageType.VOICE), ("image/", MessageType.PHOTO), ("video/", MessageType.VIDEO))
|
||||
_OUTCOME_REACTION = {ProcessingOutcome.SUCCESS: "✅", ProcessingOutcome.FAILURE: "❌"}
|
||||
# send_multiple_images skip reasons → logger.warning args (url, detail).
|
||||
_SKIP_IMAGE_LOG = {
|
||||
"download": lambda url, detail: ("Signal: failed to download image %s: %s", url, detail),
|
||||
"missing": lambda url, detail: ("Signal: image file not found for %s", url),
|
||||
"oversize": lambda url, detail: ("Signal: image too large (%d bytes), skipping %s", detail, url)}
|
||||
_QUOTE_AUTHOR_KEYS = (
|
||||
"author", "authorNumber", "authorUuid", "authorAci", "authorServiceId", "authorServiceIdString")
|
||||
|
||||
@@ -72,8 +74,7 @@ def _guess_extension(data: bytes) -> str:
|
||||
return ext
|
||||
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return ".webp"
|
||||
container = sniff_container(data)
|
||||
if container is not None:
|
||||
if (container := sniff_container(data)) is not None:
|
||||
return CONTAINER_TO_EXT[container]
|
||||
return ".zip" if data[:2] == b"PK" else ".bin"
|
||||
|
||||
@@ -107,10 +108,8 @@ def _remux_aac_to_m4a(aac_data: bytes) -> Optional[Tuple[bytes, str]]:
|
||||
src_path = src.name
|
||||
dst_path = src_path[:-4] + ".m4a"
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg, "-y", "-loglevel", "error", "-i", src_path,
|
||||
"-c:a", "copy", "-movflags", "+faststart", dst_path],
|
||||
capture_output=True, timeout=10)
|
||||
proc = subprocess.run([ffmpeg, "-y", "-loglevel", "error", "-i", src_path, "-c:a", "copy", "-movflags",
|
||||
"+faststart", dst_path], capture_output=True, timeout=10)
|
||||
if proc.returncode != 0:
|
||||
logger.warning("Signal: AAC→M4A remux failed (ffmpeg exit %d): %s",
|
||||
proc.returncode, proc.stderr.decode("utf-8", "replace")[:300])
|
||||
@@ -141,24 +140,20 @@ def _render_mentions(text: str, mentions: list) -> str:
|
||||
|
||||
|
||||
def _is_signal_service_id(value: str) -> bool:
|
||||
"""Return True if *value* already looks like a Signal service identifier."""
|
||||
"""Return True if *value* already looks like a Signal service identifier (PNI:/u: prefix or UUID)."""
|
||||
if not value:
|
||||
return False
|
||||
if value.startswith(("PNI:", "u:")):
|
||||
return True
|
||||
try:
|
||||
with suppress(ValueError, AttributeError, TypeError):
|
||||
uuid.UUID(value)
|
||||
return True
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _looks_like_e164_number(value: str) -> bool:
|
||||
"""Return True for a plausible E.164 phone number."""
|
||||
if not value or not value.startswith("+"):
|
||||
return False
|
||||
digits = value[1:]
|
||||
return digits.isdigit() and 7 <= len(digits) <= 15
|
||||
return bool(value) and value.startswith("+") and value[1:].isdigit() and 7 <= len(value) - 1 <= 15
|
||||
|
||||
|
||||
def check_signal_requirements() -> bool:
|
||||
@@ -189,15 +184,13 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
self.http_url = extra.get("http_url", "http://127.0.0.1:8080").rstrip("/")
|
||||
self.account = extra.get("account", "")
|
||||
self.ignore_stories = extra.get("ignore_stories", True)
|
||||
# Allowlists are per-profile (scoped reads); group policy derives from the group
|
||||
# allowlist's presence. Mention filter: only respond in groups when @mentioned.
|
||||
# Allowlists are per-profile (scoped reads); group policy derives from the group allowlist's
|
||||
# presence. The DM allowlist mirrors run.py's SIGNAL_ALLOWED_USERS so reaction hooks (which
|
||||
# fire before run.py's auth gate) can skip unauthorized senders; "*" = open.
|
||||
self.group_allow_from = set(_parse_comma_list(_sig_secret("SIGNAL_GROUP_ALLOWED_USERS", "")))
|
||||
_rm_cfg = extra.get("require_mention")
|
||||
self.require_mention = (
|
||||
bool(_rm_cfg) if _rm_cfg is not None
|
||||
else os.getenv("SIGNAL_REQUIRE_MENTION", "false").lower() in TRUTHY_STRINGS)
|
||||
# DM allowlist mirrors run.py's SIGNAL_ALLOWED_USERS so reaction hooks (which fire
|
||||
# before run.py's auth gate) can skip unauthorized senders. "*" = open.
|
||||
self.require_mention = (bool(_rm_cfg) if _rm_cfg is not None
|
||||
else os.getenv("SIGNAL_REQUIRE_MENTION", "false").lower() in TRUTHY_STRINGS)
|
||||
self.dm_allow_from = set(_parse_comma_list(_sig_secret("SIGNAL_ALLOWED_USERS", "*")))
|
||||
self.client: Optional[httpx.AsyncClient] = None
|
||||
self._sse_task: Optional[asyncio.Task] = None
|
||||
@@ -210,30 +203,28 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
self._last_sse_activity = 0.0
|
||||
self._sse_response: Optional[httpx.Response] = None
|
||||
self._account_normalized = self.account.strip()
|
||||
# Recently sent timestamps filter echo-backs (Note to Self / linked-device sync-sents).
|
||||
# LRU + TTL so a pending echo in a chatty group isn't evicted by many outbounds.
|
||||
# Recently sent timestamps filter echo-backs (Note to Self / linked-device sync-sents); LRU + TTL
|
||||
# so a pending echo in a chatty group isn't evicted by many outbounds.
|
||||
self._recent_sent_timestamps: "OrderedDict[int, float]" = OrderedDict()
|
||||
self._max_recent_timestamps = 512
|
||||
self._recent_sent_ttl_seconds = 300.0
|
||||
# Separate FIFO of outbound timestamps: Signal quote.id is the quoted message's
|
||||
# timestamp, so replies to this bot are recognised after the echo was consumed.
|
||||
# Separate FIFO of outbound timestamps: Signal quote.id is the quoted message's timestamp, so
|
||||
# replies to this bot are recognised after the echo was consumed.
|
||||
self._sent_message_timestamps: "OrderedDict[str, None]" = OrderedDict()
|
||||
self._max_sent_message_timestamps = 500
|
||||
# Best-effort number↔ACI/PNI UUID mapping so sends can upgrade a number to the UUID signal-cli prefers.
|
||||
self._recipient_uuid_by_number: Dict[str, str] = {}
|
||||
self._recipient_number_by_uuid: Dict[str, str] = {}
|
||||
self._recipient_cache_lock = asyncio.Lock()
|
||||
logger.info("Signal adapter initialized: url=%s account=%s groups=%s",
|
||||
self.http_url, redact_phone(self.account),
|
||||
"enabled" if self.group_allow_from else "disabled")
|
||||
logger.info("Signal adapter initialized: url=%s account=%s groups=%s", self.http_url,
|
||||
redact_phone(self.account), "enabled" if self.group_allow_from else "disabled")
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
"""Connect to signal-cli daemon and start SSE listener."""
|
||||
if not self.http_url or not self.account:
|
||||
logger.error("Signal: SIGNAL_HTTP_URL and SIGNAL_ACCOUNT are required")
|
||||
return False
|
||||
# Scoped lock prevents duplicate Signal listeners for the same phone.
|
||||
lock_acquired = False
|
||||
lock_acquired = False # scoped lock prevents duplicate Signal listeners for the same phone
|
||||
try:
|
||||
if not self._acquire_platform_lock('signal-phone', self.account, 'Signal account'):
|
||||
return False
|
||||
@@ -246,12 +237,12 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
try:
|
||||
resp = await self.client.get(f"{self.http_url}/api/v1/check", timeout=10.0)
|
||||
if resp.status_code != 200:
|
||||
logger.error("Signal: health check failed (status %d)", resp.status_code)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Signal: cannot reach signal-cli at %s: %s", self.http_url, e)
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
logger.error("Signal: health check failed (status %d)", resp.status_code)
|
||||
return False
|
||||
self._running = True
|
||||
self._last_sse_activity = time.time()
|
||||
self._sse_task = asyncio.create_task(self._sse_listener())
|
||||
@@ -266,11 +257,6 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if lock_acquired:
|
||||
self._release_platform_lock()
|
||||
|
||||
def _spawn_background(self, coro) -> None:
|
||||
task = asyncio.create_task(coro)
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
async def _close_client(self) -> None:
|
||||
if self.client:
|
||||
await self.client.aclose()
|
||||
@@ -299,10 +285,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
"""Dispatch one SSE line; keepalive comments (":") count as activity so the health monitor stays quiet."""
|
||||
if line.startswith(":"):
|
||||
self._last_sse_activity = time.time()
|
||||
elif line.startswith("data:"):
|
||||
data_str = line[5:].strip()
|
||||
if not data_str:
|
||||
return
|
||||
elif line.startswith("data:") and (data_str := line[5:].strip()):
|
||||
self._last_sse_activity = time.time()
|
||||
try:
|
||||
await self._handle_envelope(json.loads(data_str))
|
||||
@@ -318,8 +301,8 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
while self._running:
|
||||
try:
|
||||
logger.debug("Signal SSE: connecting to %s", url)
|
||||
headers = {"Accept": "text/event-stream"}
|
||||
async with self.client.stream("GET", url, headers=headers, timeout=None) as response:
|
||||
async with self.client.stream("GET", url, headers={"Accept": "text/event-stream"},
|
||||
timeout=None) as response:
|
||||
self._sse_response = response
|
||||
backoff = SSE_RETRY_DELAY_INITIAL # Reset on successful connection
|
||||
self._last_sse_activity = time.time()
|
||||
@@ -342,8 +325,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if self._running:
|
||||
logger.warning("Signal SSE: error: %s (reconnecting in %.0fs)", e, backoff)
|
||||
if self._running:
|
||||
# 20% jitter prevents thundering herd on reconnection
|
||||
await asyncio.sleep(backoff + backoff * 0.2 * random.random())
|
||||
await asyncio.sleep(backoff + backoff * 0.2 * random.random()) # 20% jitter vs thundering herd
|
||||
backoff = min(backoff * 2, SSE_RETRY_DELAY_MAX)
|
||||
self._sse_response = None
|
||||
|
||||
@@ -353,8 +335,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
|
||||
if not self._running:
|
||||
break
|
||||
elapsed = time.time() - self._last_sse_activity
|
||||
if elapsed <= HEALTH_CHECK_STALE_THRESHOLD:
|
||||
if (elapsed := time.time() - self._last_sse_activity) <= HEALTH_CHECK_STALE_THRESHOLD:
|
||||
continue
|
||||
logger.warning("Signal: SSE idle for %.0fs, checking daemon health", elapsed)
|
||||
try:
|
||||
@@ -363,8 +344,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
logger.warning("Signal: health check error: %s, forcing reconnect", e)
|
||||
self._force_reconnect()
|
||||
continue
|
||||
if resp.status_code == 200:
|
||||
# Daemon alive but SSE quiet — reset activity to avoid repeated warnings
|
||||
if resp.status_code == 200: # daemon alive but SSE quiet — reset activity to avoid repeated warnings
|
||||
self._last_sse_activity = time.time()
|
||||
logger.debug("Signal: daemon healthy, SSE idle")
|
||||
else:
|
||||
@@ -375,7 +355,9 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
"""Force SSE reconnection by closing the current response."""
|
||||
if self._sse_response and not self._sse_response.is_stream_consumed:
|
||||
with suppress(Exception):
|
||||
self._spawn_background(self._sse_response.aclose())
|
||||
task = asyncio.create_task(self._sse_response.aclose())
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
self._sse_response = None
|
||||
|
||||
def _unwrap_sync_message(self, envelope_data: dict) -> Optional[dict]:
|
||||
@@ -386,8 +368,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if not sent_msg or not isinstance(sent_msg, dict):
|
||||
return None
|
||||
dest = sent_msg.get("destinationNumber") or sent_msg.get("destination")
|
||||
sent_msg_group_id = (sent_msg.get("groupInfo") or {}).get("groupId")
|
||||
if dest != self._account_normalized and not sent_msg_group_id:
|
||||
if dest != self._account_normalized and not (sent_msg.get("groupInfo") or {}).get("groupId"):
|
||||
return None
|
||||
if self._consume_sent_timestamp(sent_msg.get("timestamp")):
|
||||
return None # echo of our own outbound reply
|
||||
@@ -399,19 +380,16 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
account_norm = self._account_normalized
|
||||
if self.require_mention:
|
||||
mentioned_in_text = account_norm and (f"@{account_norm}" in (text or ""))
|
||||
mentioned_in_metadata = any(
|
||||
m.get("number") == account_norm or m.get("uuid") == account_norm
|
||||
for m in (data_message.get("mentions") or []))
|
||||
mentioned_in_metadata = any(account_norm in (m.get("number"), m.get("uuid"))
|
||||
for m in (data_message.get("mentions") or []))
|
||||
if not mentioned_in_text and not mentioned_in_metadata:
|
||||
logger.debug("Signal: ignoring group message (require_mention=true, bot not mentioned)")
|
||||
return False, text
|
||||
if text and account_norm:
|
||||
text = text.replace(f"@{account_norm}", "")
|
||||
bot_uuid = self._recipient_uuid_by_number.get(account_norm)
|
||||
if bot_uuid:
|
||||
if bot_uuid := self._recipient_uuid_by_number.get(account_norm):
|
||||
text = text.replace(f"@{bot_uuid}", "")
|
||||
# Collapse only the doubled space the removal introduced; newlines are preserved.
|
||||
text = text.replace(" ", " ").strip()
|
||||
text = text.replace(" ", " ").strip() # collapse only the doubled space; newlines preserved
|
||||
return True, text
|
||||
|
||||
async def _collect_attachments(self, attachments_data: list) -> Tuple[List[str], List[str]]:
|
||||
@@ -442,9 +420,8 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
envelope_data = self._unwrap_sync_message(envelope_data)
|
||||
if envelope_data is None:
|
||||
return
|
||||
sender = (
|
||||
envelope_data.get("sourceNumber") or envelope_data.get("sourceUuid") or envelope_data.get("source")
|
||||
)
|
||||
sender = (envelope_data.get("sourceNumber") or envelope_data.get("sourceUuid")
|
||||
or envelope_data.get("source"))
|
||||
sender_name = envelope_data.get("sourceName", "")
|
||||
sender_uuid = envelope_data.get("sourceUuid", "")
|
||||
self._remember_recipient_identifiers(sender, sender_uuid)
|
||||
@@ -457,9 +434,8 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if self.ignore_stories and envelope_data.get("storyMessage"):
|
||||
return
|
||||
# Edited messages carry their updated dataMessage inside editMessage
|
||||
data_message = (
|
||||
envelope_data.get("dataMessage") or (envelope_data.get("editMessage") or {}).get("dataMessage")
|
||||
)
|
||||
data_message = (envelope_data.get("dataMessage")
|
||||
or (envelope_data.get("editMessage") or {}).get("dataMessage"))
|
||||
if not data_message:
|
||||
return
|
||||
group_info = data_message.get("groupInfo")
|
||||
@@ -469,8 +445,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
return
|
||||
chat_id = f"group:{group_id}" if is_group else sender
|
||||
text = data_message.get("message", "")
|
||||
mentions = data_message.get("mentions", [])
|
||||
if text and mentions:
|
||||
if text and (mentions := data_message.get("mentions", [])):
|
||||
text = _render_mentions(text, mentions)
|
||||
if is_group:
|
||||
mentioned, text = self._apply_group_mention_rules(text, data_message)
|
||||
@@ -485,19 +460,19 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
media_urls, media_types = [], []
|
||||
if attachments_data and not getattr(self, "ignore_attachments", False):
|
||||
media_urls, media_types = await self._collect_attachments(attachments_data)
|
||||
# Skip contentless envelopes (profile key updates, empty messages) that still
|
||||
# carry a dataMessage wrapper — otherwise msg='' triggers a full agent turn.
|
||||
# Skip contentless envelopes (profile key updates, empty messages) that still carry a
|
||||
# dataMessage wrapper — otherwise msg='' triggers a full agent turn.
|
||||
if (not text or not text.strip()) and not media_urls:
|
||||
logger.debug("Signal: skipping contentless envelope from %s (%d attachments)",
|
||||
redact_phone(sender), len(media_urls) if media_urls else 0)
|
||||
logger.debug("Signal: skipping contentless envelope from %s (%d attachments)", redact_phone(sender),
|
||||
len(media_urls) if media_urls else 0)
|
||||
return
|
||||
source = self.build_source(
|
||||
chat_id=chat_id, chat_name=group_info.get("groupName") if group_info else sender_name,
|
||||
chat_type="group" if is_group else "dm", user_id=sender,
|
||||
user_name=sender_name or sender, user_id_alt=sender_uuid if sender_uuid else None,
|
||||
chat_id_alt=group_id if is_group else None)
|
||||
# First matching MIME prefix wins; everything else (application/*, text/*, unknown)
|
||||
# is a DOCUMENT so run.py's document-context injection surfaces the cached path.
|
||||
# First matching MIME prefix wins; everything else (application/*, text/*, unknown) is a DOCUMENT
|
||||
# so run.py's document-context injection surfaces the cached path.
|
||||
msg_type = MessageType.TEXT if not media_types else next(
|
||||
(mt for prefix, mt in _MEDIA_TYPE_BY_MIME_PREFIX if any(m.startswith(prefix) for m in media_types)),
|
||||
MessageType.DOCUMENT)
|
||||
@@ -532,17 +507,15 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
|
||||
def _remember_recipient_identifiers(self, number: Optional[str], service_id: Optional[str]) -> None:
|
||||
"""Cache any number↔UUID mapping observed from Signal envelopes."""
|
||||
if not number or not service_id or not _is_signal_service_id(service_id):
|
||||
return
|
||||
self._recipient_uuid_by_number[number] = service_id
|
||||
self._recipient_number_by_uuid[service_id] = number
|
||||
if number and service_id and _is_signal_service_id(service_id):
|
||||
self._recipient_uuid_by_number[number] = service_id
|
||||
self._recipient_number_by_uuid[service_id] = number
|
||||
|
||||
@staticmethod
|
||||
def _extract_quote_author(quote_data: Any) -> Optional[str]:
|
||||
"""Return the best available Signal sender identifier from quote metadata."""
|
||||
if not isinstance(quote_data, dict):
|
||||
return None
|
||||
return next((str(quote_data[k]) for k in _QUOTE_AUTHOR_KEYS if quote_data.get(k)), None)
|
||||
keys = _QUOTE_AUTHOR_KEYS if isinstance(quote_data, dict) else ()
|
||||
return next((str(quote_data[k]) for k in keys if quote_data.get(k)), None)
|
||||
|
||||
def _quote_references_own_message(self, reply_to_id: Optional[str], reply_to_author: Optional[str]) -> bool:
|
||||
"""True when a Signal quote points at this adapter's outbound message."""
|
||||
@@ -550,22 +523,17 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
return True
|
||||
if not reply_to_author:
|
||||
return False
|
||||
author = str(reply_to_author).strip()
|
||||
if self._account_normalized and author == self._account_normalized:
|
||||
return True
|
||||
cached_uuid = self._recipient_uuid_by_number.get(self._account_normalized)
|
||||
if cached_uuid and author == cached_uuid:
|
||||
return True
|
||||
cached_number = self._recipient_number_by_uuid.get(author)
|
||||
return bool(cached_number and cached_number == self._account_normalized)
|
||||
author, acct = str(reply_to_author).strip(), self._account_normalized
|
||||
# Cached number↔UUID mappings are only ever stored with truthy keys and values.
|
||||
return bool(acct) and (author == acct or author == self._recipient_uuid_by_number.get(acct)
|
||||
or self._recipient_number_by_uuid.get(author) == acct)
|
||||
|
||||
def _remember_sent_message_timestamp(self, timestamp: Any) -> None:
|
||||
"""Keep a bounded cache of outbound Signal timestamps for quote matching."""
|
||||
if timestamp is None:
|
||||
return
|
||||
key = str(timestamp)
|
||||
# Re-insert to mark most-recently-used so eviction drops genuinely old entries.
|
||||
self._sent_message_timestamps.pop(key, None)
|
||||
self._sent_message_timestamps.pop(key, None) # re-insert as most-recently-used so eviction drops old ones
|
||||
self._sent_message_timestamps[key] = None
|
||||
while len(self._sent_message_timestamps) > self._max_sent_message_timestamps:
|
||||
self._sent_message_timestamps.popitem(last=False)
|
||||
@@ -578,8 +546,8 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
profile = contact.get("profile")
|
||||
if not service_id and isinstance(profile, dict):
|
||||
service_id = profile.get("serviceId") or profile.get("uuid")
|
||||
if service_id and _is_signal_service_id(service_id) and (
|
||||
contact.get("number") == phone_number or contact.get("recipient") == phone_number):
|
||||
if service_id and _is_signal_service_id(service_id) and phone_number in (
|
||||
contact.get("number"), contact.get("recipient")):
|
||||
return service_id
|
||||
return None
|
||||
|
||||
@@ -587,20 +555,17 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
"""Return the preferred Signal recipient identifier for a direct chat."""
|
||||
if not chat_id or chat_id.startswith("group:") or not _looks_like_e164_number(chat_id):
|
||||
return chat_id
|
||||
cached = self._recipient_uuid_by_number.get(chat_id)
|
||||
if cached:
|
||||
if cached := self._recipient_uuid_by_number.get(chat_id):
|
||||
return cached
|
||||
async with self._recipient_cache_lock:
|
||||
cached = self._recipient_uuid_by_number.get(chat_id)
|
||||
if cached:
|
||||
if cached := self._recipient_uuid_by_number.get(chat_id):
|
||||
return cached
|
||||
contacts = await self._rpc("listContacts", {"account": self.account, "allRecipients": True})
|
||||
if isinstance(contacts, list):
|
||||
for contact in contacts:
|
||||
number = contact.get("number") if isinstance(contact, dict) else None
|
||||
service_id = self._extract_contact_uuid(contact, chat_id)
|
||||
if number and service_id:
|
||||
self._remember_recipient_identifiers(number, service_id)
|
||||
for contact in contacts if isinstance(contacts, list) else ():
|
||||
number = contact.get("number") if isinstance(contact, dict) else None
|
||||
service_id = self._extract_contact_uuid(contact, chat_id)
|
||||
if number and service_id:
|
||||
self._remember_recipient_identifiers(number, service_id)
|
||||
return self._recipient_uuid_by_number.get(chat_id, chat_id)
|
||||
|
||||
async def _with_target(self, params: Dict[str, Any], chat_id: str, *, resolve: bool = True) -> Dict[str, Any]:
|
||||
@@ -616,8 +581,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
result = await self._rpc("getAttachment", {"account": self.account, "id": attachment_id})
|
||||
if not result:
|
||||
return None, ""
|
||||
# signal-cli returns {"data": "base64..."}
|
||||
if isinstance(result, dict):
|
||||
if isinstance(result, dict): # signal-cli returns {"data": "base64..."}
|
||||
result = result.get("data")
|
||||
if not result:
|
||||
logger.warning("Signal: attachment response missing 'data' key")
|
||||
@@ -632,18 +596,16 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
else cache_audio_from_bytes if _is_audio_ext(ext) else cache_document_from_bytes)
|
||||
return cache(raw_data, ext), ext
|
||||
|
||||
async def _rpc(
|
||||
self, method: str, params: dict, rpc_id: str = None, *,
|
||||
log_failures: bool = True, raise_on_rate_limit: bool = False, timeout: float = 30.0) -> Any:
|
||||
"""Send a JSON-RPC 2.0 request to signal-cli. ``log_failures=False`` logs failures at DEBUG
|
||||
(typing path: silence NETWORK_FAILURE spam); ``raise_on_rate_limit=True`` raises
|
||||
``SignalRateLimitError`` on a 429 / RateLimitException instead of swallowing it."""
|
||||
async def _rpc(self, method: str, params: dict, rpc_id: str = None, *, log_failures: bool = True,
|
||||
raise_on_rate_limit: bool = False, timeout: float = 30.0) -> Any:
|
||||
"""Send a JSON-RPC 2.0 request to signal-cli. ``log_failures=False`` logs failures at DEBUG (typing
|
||||
path: silence NETWORK_FAILURE spam); ``raise_on_rate_limit=True`` raises ``SignalRateLimitError``
|
||||
on a 429 / RateLimitException instead of swallowing it."""
|
||||
if not self.client:
|
||||
logger.warning("Signal: RPC called but client not connected")
|
||||
return None
|
||||
if rpc_id is None:
|
||||
rpc_id = f"{method}_{int(time.time() * 1000)}"
|
||||
payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": rpc_id}
|
||||
payload = {"jsonrpc": "2.0", "method": method, "params": params,
|
||||
"id": rpc_id if rpc_id is not None else f"{method}_{int(time.time() * 1000)}"}
|
||||
fail_level = logging.WARNING if log_failures else logging.DEBUG
|
||||
try:
|
||||
resp = await self.client.post(f"{self.http_url}/api/v1/rpc", json=payload, timeout=timeout)
|
||||
@@ -660,8 +622,8 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if isinstance(result, dict) and raise_on_rate_limit:
|
||||
for r in result.get("results") if isinstance(result.get("results"), list) else ():
|
||||
if isinstance(r, dict) and r.get("type") == "RATE_LIMIT_FAILURE":
|
||||
raise SignalRateLimitError(
|
||||
"Rate limit exceeded for recipient", retry_after=r.get("retryAfterSeconds"))
|
||||
raise SignalRateLimitError("Rate limit exceeded for recipient",
|
||||
retry_after=r.get("retryAfterSeconds"))
|
||||
return result
|
||||
except SignalRateLimitError:
|
||||
raise
|
||||
@@ -669,10 +631,8 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
logger.log(fail_level, "Signal RPC %s failed: %s", method, e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _markdown_to_signal(text: str) -> tuple[str, list[str]]:
|
||||
"""Backward-compatible wrapper around shared Signal formatting helper."""
|
||||
return markdown_to_signal(text)
|
||||
# Backward-compatible alias for the shared formatting helper.
|
||||
_markdown_to_signal = staticmethod(markdown_to_signal)
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""Plain-text fallback for the base-class send path; send() applies rich styles itself."""
|
||||
@@ -680,9 +640,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
|
||||
def _validate_send_result(self, result: Any) -> tuple[bool, Optional[str]]:
|
||||
"""Validate signal-cli send response results. Returns (success, error_message)."""
|
||||
if not result or not isinstance(result, dict):
|
||||
return True, None
|
||||
results = result.get("results")
|
||||
results = result.get("results") if isinstance(result, dict) else None
|
||||
for r in results if isinstance(results, list) else ():
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
@@ -709,26 +667,22 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
except (TypeError, ValueError):
|
||||
logger.debug("[Signal] Ignoring malformed textStyle range: %r", style_string)
|
||||
continue
|
||||
overlap_start = max(style_start, chunk_start)
|
||||
overlap_end = min(style_end, chunk_end)
|
||||
overlap_start, overlap_end = max(style_start, chunk_start), min(style_end, chunk_end)
|
||||
if overlap_start < overlap_end:
|
||||
adjusted.append(f"{overlap_start - chunk_start}:{overlap_end - overlap_start}:{style_type}")
|
||||
return adjusted
|
||||
|
||||
@classmethod
|
||||
def _split_signal_formatted_message(
|
||||
cls, plain_text: str, text_styles: list[str], max_length: int,
|
||||
) -> list[tuple[str, list[str]]]:
|
||||
cls, plain_text: str, text_styles: list[str], max_length: int) -> list[tuple[str, list[str]]]:
|
||||
"""Split converted Signal text into chunks, translating body ranges per chunk. Splitting after
|
||||
conversion keeps styles that cross a chunk boundary intact instead of leaking Markdown markers."""
|
||||
if utf16_len(plain_text) <= max_length:
|
||||
return [(plain_text, text_styles)]
|
||||
indicator_reserve = 10 # Mirrors BasePlatformAdapter.truncate_message().
|
||||
body_limit = max(1, max_length - indicator_reserve)
|
||||
body_limit = max(1, max_length - 10) # 10 = indicator reserve, mirrors truncate_message().
|
||||
offsets = cls._utf16_offsets(plain_text)
|
||||
chunks: list[tuple[str, list[str]]] = []
|
||||
start_idx = 0
|
||||
total_u16 = offsets[-1]
|
||||
start_idx, total_u16 = 0, offsets[-1]
|
||||
while offsets[start_idx] < total_u16:
|
||||
end_budget = min(total_u16, offsets[start_idx] + body_limit)
|
||||
end_idx = start_idx + 1
|
||||
@@ -744,8 +698,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _rpc_send(self, params: Dict[str, Any], fail_error: str) -> Tuple[Any, Optional[SendResult]]:
|
||||
"""Run a ``send`` RPC, validate and track it; ``(result, None)`` or ``(None, failed SendResult)``."""
|
||||
result = await self._rpc("send", params)
|
||||
if result is None:
|
||||
if (result := await self._rpc("send", params)) is None:
|
||||
return None, SendResult(success=False, error=fail_error)
|
||||
success, err_msg = self._validate_send_result(result)
|
||||
if not success:
|
||||
@@ -753,16 +706,14 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
self._track_sent_timestamp(result)
|
||||
return result, None
|
||||
|
||||
async def send(
|
||||
self, chat_id: str, content: str, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
"""Send a text message with native Signal formatting."""
|
||||
await self._stop_typing_indicator(chat_id)
|
||||
if not content or not content.strip():
|
||||
return SendResult(success=True, message_id=None)
|
||||
base_params = await self._with_target({"account": self.account}, chat_id)
|
||||
plain_message, message_styles = self._markdown_to_signal(content)
|
||||
chunks = self._split_signal_formatted_message(plain_message, message_styles, self.MAX_MESSAGE_LENGTH)
|
||||
chunks = self._split_signal_formatted_message(*self._markdown_to_signal(content), self.MAX_MESSAGE_LENGTH)
|
||||
last_result = None
|
||||
for idx, (plain_text, text_styles) in enumerate(chunks, start=1):
|
||||
params: Dict[str, Any] = dict(base_params, message=plain_text)
|
||||
@@ -770,8 +721,8 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
params["textStyle"] = text_styles[0]
|
||||
elif text_styles:
|
||||
params["textStyles"] = text_styles
|
||||
logger.info("[Signal] Sending response chunk %d/%d (%d chars) to %s",
|
||||
idx, len(chunks), len(plain_text), chat_id)
|
||||
logger.info("[Signal] Sending response chunk %d/%d (%d chars) to %s", idx, len(chunks), len(plain_text),
|
||||
chat_id)
|
||||
last_result, err = await self._rpc_send(params, "RPC send failed")
|
||||
if err:
|
||||
return err
|
||||
@@ -784,8 +735,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if not ts:
|
||||
return
|
||||
self._remember_sent_message_timestamp(ts)
|
||||
now = time.monotonic()
|
||||
recent = self._recent_sent_timestamps
|
||||
now, recent = time.monotonic(), self._recent_sent_timestamps
|
||||
recent.pop(ts, None) # re-insert to mark as most-recently-used
|
||||
recent[ts] = now
|
||||
# Drop entries older than TTL first, then enforce the hard cap.
|
||||
@@ -807,14 +757,12 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
return
|
||||
params = await self._with_target({"account": self.account}, chat_id)
|
||||
fails = self._typing_failures.get(chat_id, 0)
|
||||
result = await self._rpc("sendTyping", params, rpc_id="typing", log_failures=(fails == 0))
|
||||
if result is not None:
|
||||
if await self._rpc("sendTyping", params, rpc_id="typing", log_failures=(fails == 0)) is not None:
|
||||
self._typing_failures.pop(chat_id, None)
|
||||
self._typing_skip_until.pop(chat_id, None)
|
||||
return
|
||||
fails = self._typing_failures[chat_id] = fails + 1
|
||||
# After 3 consecutive failures back off exponentially (16s, 32s, 60s cap).
|
||||
if fails >= 3:
|
||||
if fails >= 3: # exponential backoff: 16s, 32s, 60s cap
|
||||
self._typing_skip_until[chat_id] = now + min(60.0, 16.0 * (2 ** (fails - 3)))
|
||||
|
||||
async def _resolve_image_path(self, image_url: str) -> Tuple[Optional[str], Optional[str], Any]:
|
||||
@@ -828,21 +776,19 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
return None, "download", e
|
||||
if not file_path or not Path(file_path).exists():
|
||||
return None, "missing", None
|
||||
file_size = Path(file_path).stat().st_size
|
||||
if file_size > SIGNAL_MAX_ATTACHMENT_SIZE:
|
||||
if (file_size := Path(file_path).stat().st_size) > SIGNAL_MAX_ATTACHMENT_SIZE:
|
||||
return None, "oversize", file_size
|
||||
return file_path, None, None
|
||||
|
||||
async def send_multiple_images(
|
||||
self, chat_id: str, images: List[Tuple[str, str]], metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0) -> None:
|
||||
async def send_multiple_images(self, chat_id: str, images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None, human_delay: float = 0.0) -> None:
|
||||
"""Send a batch of images via chunked Signal RPC calls. Alt texts are dropped (one shared body
|
||||
per send); bad images are skipped with a warning; ``human_delay`` is ignored (scheduler paces)."""
|
||||
if not images:
|
||||
return
|
||||
scheduler = get_scheduler()
|
||||
logger.info("Signal send_multiple_images: received %d image(s) for %s — scheduler state: %s",
|
||||
len(images), chat_id[:30], scheduler.state())
|
||||
logger.info("Signal send_multiple_images: received %d image(s) for %s — scheduler state: %s", len(images),
|
||||
chat_id[:30], scheduler.state())
|
||||
await self._stop_typing_indicator(chat_id)
|
||||
attachments: List[str] = []
|
||||
skipped = {"download": 0, "missing": 0, "oversize": 0}
|
||||
@@ -852,18 +798,12 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
attachments.append(file_path)
|
||||
continue
|
||||
skipped[reason] += 1
|
||||
if reason == "download":
|
||||
logger.warning("Signal: failed to download image %s: %s", image_url, detail)
|
||||
elif reason == "missing":
|
||||
logger.warning("Signal: image file not found for %s", image_url)
|
||||
else:
|
||||
logger.warning("Signal: image too large (%d bytes), skipping %s", detail, image_url)
|
||||
logger.warning(*_SKIP_IMAGE_LOG[reason](image_url, detail))
|
||||
if not attachments:
|
||||
logger.error("Signal: no valid images in batch of %d (download=%d missing=%d oversize=%d)",
|
||||
len(images), skipped["download"], skipped["missing"], skipped["oversize"])
|
||||
logger.error("Signal: no valid images in batch of %d (download=%d missing=%d oversize=%d)", len(images),
|
||||
skipped["download"], skipped["missing"], skipped["oversize"])
|
||||
return
|
||||
logger.info("Signal send_multiple_images: %d/%d images valid, sending in chunks",
|
||||
len(attachments), len(images))
|
||||
logger.info("Signal send_multiple_images: %d/%d images valid, sending in chunks", len(attachments), len(images))
|
||||
base_params = await self._with_target({"account": self.account, "message": ""}, chat_id)
|
||||
per = SIGNAL_MAX_ATTACHMENTS_PER_MSG
|
||||
att_batches = [attachments[i:i + per] for i in range(0, len(attachments), per)]
|
||||
@@ -874,14 +814,13 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
logger.debug("Signal batch %d/%d: %d attachments, estimated wait=%.1fs", idx, n_batches, n, estimated)
|
||||
if estimated >= SIGNAL_BATCH_PACING_NOTICE_THRESHOLD:
|
||||
await self._notify_batch_pacing(chat_id, idx, n_batches, estimated)
|
||||
await self._send_attachment_batch(
|
||||
scheduler, dict(base_params, attachments=att_batch), n, f"{idx}/{n_batches}")
|
||||
await self._send_attachment_batch(scheduler, dict(base_params, attachments=att_batch), n,
|
||||
f"{idx}/{n_batches}")
|
||||
|
||||
async def _send_attachment_batch(self, scheduler, params: Dict[str, Any], n: int, label: str) -> None:
|
||||
"""Send one attachment batch with rate-limit pacing and a single transient retry. Tokens are
|
||||
deducted only on validated success (None = server never accepted it); 429s feed the scheduler."""
|
||||
send_timeout = _signal_send_timeout(n)
|
||||
max_attempts = SIGNAL_RATE_LIMIT_MAX_ATTEMPTS
|
||||
send_timeout, max_attempts = _signal_send_timeout(n), SIGNAL_RATE_LIMIT_MAX_ATTEMPTS
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
await scheduler.acquire(n)
|
||||
t0 = time.monotonic()
|
||||
@@ -902,22 +841,21 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if success:
|
||||
self._track_sent_timestamp(result)
|
||||
await scheduler.report_rpc_duration(duration, n)
|
||||
logger.info("Signal batch %s: %d attachments sent in %.1fs (attempt %d/%d)",
|
||||
label, n, duration, attempt, max_attempts)
|
||||
logger.info("Signal batch %s: %d attachments sent in %.1fs (attempt %d/%d)", label, n, duration,
|
||||
attempt, max_attempts)
|
||||
return
|
||||
logger.error("Signal: RPC send failed for batch %s (%d attachments, attempt %d/%d, rpc_duration=%.1fs)%s",
|
||||
label, n, attempt, max_attempts, duration, f": {err_msg}" if result is not None else "")
|
||||
if attempt >= max_attempts:
|
||||
return
|
||||
backoff = 2.0 ** attempt
|
||||
logger.info("Signal: retrying batch %s after %.1fs backoff", label, backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
logger.info("Signal: retrying batch %s after %.1fs backoff", label, 2.0 ** attempt)
|
||||
await asyncio.sleep(2.0 ** attempt)
|
||||
|
||||
async def _notify_batch_pacing(self, chat_id: str, next_batch_idx: int, total_batches: int, wait_s: float) -> None:
|
||||
"""Tell the user about an inter-batch pacing wait over the notice threshold (best-effort)."""
|
||||
try:
|
||||
await self.send(chat_id, f"(More images coming — pausing ~{_format_wait(wait_s)} "
|
||||
f"for Signal rate limit, batch {next_batch_idx}/{total_batches}.)")
|
||||
await self.send(chat_id, f"(More images coming — pausing ~{_format_wait(wait_s)} for Signal rate limit, "
|
||||
f"batch {next_batch_idx}/{total_batches}.)")
|
||||
except Exception as e:
|
||||
logger.warning("Signal: failed to send pacing notice: %s", e)
|
||||
|
||||
@@ -928,21 +866,20 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if reason == "download":
|
||||
logger.warning("Signal: failed to download image: %s", detail)
|
||||
if reason:
|
||||
error = {"download": str(detail), "missing": "Image file not found",
|
||||
"oversize": f"Image too large ({detail} bytes)"}[reason]
|
||||
return SendResult(success=False, error=error)
|
||||
return SendResult(success=False, error={
|
||||
"download": str(detail), "missing": "Image file not found",
|
||||
"oversize": f"Image too large ({detail} bytes)"}[reason])
|
||||
return await self._send_file(chat_id, file_path, caption, "RPC send with attachment failed")
|
||||
|
||||
async def _send_file(self, chat_id: str, file_path: str, caption: Optional[str], fail_error: str) -> SendResult:
|
||||
"""Send one local file as a Signal attachment via the ``send`` RPC."""
|
||||
params = {"account": self.account, "message": caption or "", "attachments": [file_path]}
|
||||
await self._with_target(params, chat_id)
|
||||
params = await self._with_target(
|
||||
{"account": self.account, "message": caption or "", "attachments": [file_path]}, chat_id)
|
||||
_, err = await self._rpc_send(params, fail_error)
|
||||
return err or SendResult(success=True)
|
||||
|
||||
async def _send_attachment(
|
||||
self, chat_id: str, file_path: str, media_label: str, caption: Optional[str] = None,
|
||||
) -> SendResult:
|
||||
async def _send_attachment(self, chat_id: str, file_path: str, media_label: str,
|
||||
caption: Optional[str] = None) -> SendResult:
|
||||
"""Send any local file as a Signal attachment (shared by send_document/image_file/voice/video)."""
|
||||
await self._stop_typing_indicator(chat_id)
|
||||
try:
|
||||
@@ -1000,47 +937,37 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
async def remove_reaction(self, chat_id: str, target_author: str, target_timestamp: int) -> bool:
|
||||
"""Remove a reaction by sending an empty-string emoji."""
|
||||
return await self._send_reaction_rpc(chat_id, {
|
||||
"account": self.account, "emoji": "", "targetAuthor": target_author,
|
||||
"targetTimestamp": target_timestamp, "remove": True})
|
||||
"account": self.account, "emoji": "", "targetAuthor": target_author, "targetTimestamp": target_timestamp,
|
||||
"remove": True})
|
||||
|
||||
def _extract_reaction_target(self, event: MessageEvent) -> Optional[tuple]:
|
||||
"""Extract (target_author, target_timestamp) from a MessageEvent, or None."""
|
||||
raw = event.raw_message
|
||||
if isinstance(raw, dict) and raw.get("sender") and raw.get("timestamp_ms"):
|
||||
return (raw["sender"], raw["timestamp_ms"])
|
||||
return None
|
||||
ok = isinstance(raw, dict) and raw.get("sender") and raw.get("timestamp_ms")
|
||||
return (raw["sender"], raw["timestamp_ms"]) if ok else None
|
||||
|
||||
def _reactions_enabled(self, event: "MessageEvent" = None) -> bool:
|
||||
"""SIGNAL_REACTIONS env gate, then the DM allowlist: reactions fire before run.py's auth gate,
|
||||
so an unauthorized contact's 👀 would otherwise reveal a listening bot."""
|
||||
if os.getenv("SIGNAL_REACTIONS", "true").lower() in {"false", "0", "no"}:
|
||||
return False
|
||||
if event is not None:
|
||||
sender = getattr(getattr(event, "source", None), "user_id", None)
|
||||
if sender and "*" not in self.dm_allow_from and sender not in self.dm_allow_from:
|
||||
return False
|
||||
return True
|
||||
sender = getattr(getattr(event, "source", None), "user_id", None) if event is not None else None
|
||||
return not (sender and "*" not in self.dm_allow_from and sender not in self.dm_allow_from)
|
||||
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
"""React with 👀 when processing begins."""
|
||||
if not self._reactions_enabled(event):
|
||||
return
|
||||
target = self._extract_reaction_target(event)
|
||||
if target:
|
||||
if self._reactions_enabled(event) and (target := self._extract_reaction_target(event)):
|
||||
await self.send_reaction(event.source.chat_id, "👀", *target)
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, outcome: "ProcessingOutcome") -> None:
|
||||
"""Swap 👀 for ✅/❌; on CANCELLED the 👀 stays to keep reflecting "in progress" (matches Telegram)."""
|
||||
if outcome == ProcessingOutcome.CANCELLED or not self._reactions_enabled(event):
|
||||
return
|
||||
target = self._extract_reaction_target(event)
|
||||
if not target:
|
||||
if not (target := self._extract_reaction_target(event)):
|
||||
return
|
||||
chat_id = event.source.chat_id
|
||||
await self.remove_reaction(chat_id, *target)
|
||||
emoji = _OUTCOME_REACTION.get(outcome)
|
||||
if emoji:
|
||||
await self.send_reaction(chat_id, emoji, *target)
|
||||
await self.remove_reaction(event.source.chat_id, *target)
|
||||
if emoji := _OUTCOME_REACTION.get(outcome):
|
||||
await self.send_reaction(event.source.chat_id, emoji, *target)
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Get information about a chat/contact."""
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"""Shared Signal formatting helpers.
|
||||
|
||||
Markdown → Signal native formatting lives here so both the live adapter and the
|
||||
standalone send paths emit the same bodyRanges.
|
||||
"""
|
||||
"""Shared Signal formatting helpers: Markdown → Signal native formatting lives here so both the live
|
||||
adapter and the standalone send paths emit the same bodyRanges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,32 +22,24 @@ def _utf16_len(s: str) -> int:
|
||||
|
||||
|
||||
def _normalize_bullet_markers(source: str) -> str:
|
||||
"""Replace Markdown bullet markers with plain Unicode bullets.
|
||||
|
||||
Signal renders ``- item`` / ``* item`` literally. Fenced code blocks are kept
|
||||
byte-for-byte; list-looking lines inside code are code, not prose bullets."""
|
||||
"""Replace Markdown bullet markers with plain Unicode bullets (Signal renders ``- item`` literally).
|
||||
Fenced code blocks are kept byte-for-byte: list-looking lines inside code are code, not bullets."""
|
||||
parts = re.split(r"(```.*?```)", source, flags=re.DOTALL)
|
||||
for idx, part in enumerate(parts):
|
||||
if idx % 2 == 0:
|
||||
parts[idx] = re.sub(r"(?m)^([ \t]{0,3})[-*+]\s+", r"\1• ", part)
|
||||
return "".join(parts)
|
||||
return "".join(re.sub(r"(?m)^([ \t]{0,3})[-*+]\s+", r"\1• ", part) if idx % 2 == 0 else part
|
||||
for idx, part in enumerate(parts))
|
||||
|
||||
|
||||
def markdown_to_signal(text: str) -> tuple[str, list[str]]:
|
||||
"""Convert markdown to plain text + Signal textStyles list.
|
||||
|
||||
Signal uses ``bodyRanges`` (signal-cli ``textStyle`` / ``textStyles`` params) in
|
||||
the form ``start:length:STYLE``, positions in UTF-16 code units.
|
||||
"""Convert markdown to plain text + Signal textStyles list. Signal uses ``bodyRanges`` (signal-cli
|
||||
``textStyle`` / ``textStyles`` params) as ``start:length:STYLE`` with positions in UTF-16 code units.
|
||||
Supported styles: BOLD, ITALIC, STRIKETHROUGH, MONOSPACE."""
|
||||
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||
text = _normalize_bullet_markers(text)
|
||||
text = _normalize_bullet_markers(re.sub(r"\n{3,}", "\n\n", text).strip())
|
||||
styles: list[tuple[int, int, str]] = []
|
||||
while match := _CODE_BLOCK_RE.search(text):
|
||||
inner = match.group(1).rstrip("\n")
|
||||
styles.append((match.start(), len(inner), "MONOSPACE"))
|
||||
text = text[: match.start()] + inner + text[match.end() :]
|
||||
new_text = ""
|
||||
last_end = 0
|
||||
new_text, last_end = "", 0
|
||||
for match in _HEADING_RE.finditer(text):
|
||||
new_text += text[last_end : match.start()]
|
||||
eol = text.find("\n", match.end())
|
||||
@@ -71,10 +60,9 @@ def markdown_to_signal(text: str) -> tuple[str, list[str]]:
|
||||
all_matches.append((ms, me, match.start(1), match.end(1), style))
|
||||
occupied.append((ms, me))
|
||||
all_matches.sort()
|
||||
# Strip the markers, recording (pos, len) removals so earlier block/heading
|
||||
# ranges can be shifted, and capturing inline ranges in the stripped text.
|
||||
result = ""
|
||||
last_end = 0
|
||||
# Strip the markers, recording (pos, len) removals so earlier block/heading ranges can be
|
||||
# shifted, and capturing inline ranges in the stripped text.
|
||||
result, last_end = "", 0
|
||||
removals: list[tuple[int, int]] = []
|
||||
inline_styles: list[tuple[int, int, str]] = []
|
||||
for ms, me, g1s, g1e, style in all_matches:
|
||||
@@ -97,16 +85,12 @@ def markdown_to_signal(text: str) -> tuple[str, list[str]]:
|
||||
shift += min(remove_len, pos - remove_pos)
|
||||
return pos - shift
|
||||
|
||||
adjusted_prior = [
|
||||
(_adjust(start), _adjust(start + length) - _adjust(start), style)
|
||||
for start, length, style in styles
|
||||
if _adjust(start + length) > _adjust(start)]
|
||||
adjusted_prior = [(_adjust(start), _adjust(start + length) - _adjust(start), style)
|
||||
for start, length, style in styles if _adjust(start + length) > _adjust(start)]
|
||||
text = result + text[last_end:]
|
||||
style_strings: list[str] = []
|
||||
for cp_start, cp_len, style_type in sorted(adjusted_prior + inline_styles):
|
||||
if cp_start < 0 or cp_start + cp_len > len(text):
|
||||
continue
|
||||
u16_start = _utf16_len(text[:cp_start])
|
||||
u16_len = _utf16_len(text[cp_start : cp_start + cp_len])
|
||||
style_strings.append(f"{u16_start}:{u16_len}:{style_type}")
|
||||
if 0 <= cp_start and cp_start + cp_len <= len(text):
|
||||
u16_start, u16_len = _utf16_len(text[:cp_start]), _utf16_len(text[cp_start : cp_start + cp_len])
|
||||
style_strings.append(f"{u16_start}:{u16_len}:{style_type}")
|
||||
return text, style_strings
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
"""Signal attachment rate-limit scheduler.
|
||||
|
||||
Process-wide token-bucket simulator mirroring the per-account attachment rate limit
|
||||
signal-cli/Signal-Server enforce. Producers (``SignalAdapter.send_multiple_images`` and
|
||||
the ``send_message`` tool's Signal path) call ``acquire(n)`` before an attachment send;
|
||||
on a 429 they call ``feedback(retry_after, n)`` so the model recalibrates from the
|
||||
server's authoritative hint. Concurrent calls serialize through an ``asyncio.Lock``
|
||||
— FIFO fairness across sessions sharing one daemon.
|
||||
"""
|
||||
"""Signal attachment rate-limit scheduler: process-wide token-bucket simulator mirroring the per-account
|
||||
attachment rate limit signal-cli/Signal-Server enforce. Producers (``SignalAdapter.send_multiple_images``
|
||||
and the ``send_message`` tool's Signal path) call ``acquire(n)`` before an attachment send; on a 429 they
|
||||
call ``feedback(retry_after, n)`` so the model recalibrates from the server's authoritative hint.
|
||||
Concurrent calls serialize through an ``asyncio.Lock`` — FIFO fairness across sessions sharing one daemon."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,11 +16,11 @@ from agent.retry_utils import parse_retry_after_seconds
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SIGNAL_MAX_ATTACHMENTS_PER_MSG = 32 # per-message attachment cap (source: Signal-{Android,Desktop} source code)
|
||||
SIGNAL_RATE_LIMIT_BUCKET_CAPACITY = 50 # server-side token-bucket capacity for attachments rate limiting
|
||||
SIGNAL_MAX_ATTACHMENTS_PER_MSG = 32 # per-message attachment cap (Signal-{Android,Desktop} source)
|
||||
SIGNAL_RATE_LIMIT_BUCKET_CAPACITY = 50 # server-side token-bucket capacity for attachments
|
||||
SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER = 4 # fallback token refill interval for signal-cli < v0.14.3
|
||||
SIGNAL_RATE_LIMIT_MAX_ATTEMPTS = 2 # initial attempt + 1 retry
|
||||
SIGNAL_BATCH_PACING_NOTICE_THRESHOLD = 10.0 # if estimated waiting time > 10s, notify the user about the delay
|
||||
SIGNAL_BATCH_PACING_NOTICE_THRESHOLD = 10.0 # estimated wait above this → notify the user
|
||||
SIGNAL_RPC_ERROR_RATELIMIT = -5 # signal-cli (v0.14.3+) JSON-RPC error code for RateLimitException
|
||||
|
||||
|
||||
@@ -41,9 +37,8 @@ class SignalSchedulerError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# "Retry after 4 seconds" — libsignal-net's RetryLaterException string form, surfaced
|
||||
# when 429s hit during attachment upload (signal-cli wraps these as
|
||||
# AttachmentInvalidException rather than RateLimitException, so the typed path doesn't fire).
|
||||
# "Retry after 4 seconds" — libsignal-net's RetryLaterException string form, surfaced when 429s hit during
|
||||
# attachment upload (signal-cli wraps these as AttachmentInvalidException, so the typed path doesn't fire).
|
||||
_RETRY_AFTER_RE = re.compile(r"Retry after (\d+(?:\.\d+)?)\s*second", re.IGNORECASE)
|
||||
|
||||
|
||||
@@ -52,37 +47,28 @@ def _error_message(err: Any) -> str:
|
||||
|
||||
|
||||
def _extract_retry_after_seconds(err: Any) -> Optional[float]:
|
||||
"""Per-token Retry-After from a signal-cli rate-limit error, or None.
|
||||
|
||||
Sources, in order: ``error.data.response.results[*].retryAfterSeconds`` (signal-cli
|
||||
≥ v0.14.3), then "Retry after N seconds" parsed from the message (libsignal-net
|
||||
RetryLaterException wrapped as AttachmentInvalidException, structured field null)."""
|
||||
"""Per-token Retry-After from a signal-cli rate-limit error, or None. Sources, in order:
|
||||
``error.data.response.results[*].retryAfterSeconds`` (signal-cli ≥ v0.14.3), then "Retry after N
|
||||
seconds" parsed from the message (RetryLaterException wrapped as AttachmentInvalidException)."""
|
||||
if isinstance(err, dict):
|
||||
results = ((err.get("data") or {}).get("response") or {}).get("results") or []
|
||||
candidates = [
|
||||
parse_retry_after_seconds(r.get("retryAfterSeconds")) for r in results
|
||||
if isinstance(r, dict) and r.get("retryAfterSeconds")]
|
||||
candidates = [c for c in candidates if c is not None]
|
||||
if candidates:
|
||||
candidates = [parse_retry_after_seconds(r.get("retryAfterSeconds")) for r in results
|
||||
if isinstance(r, dict) and r.get("retryAfterSeconds")]
|
||||
if candidates := [c for c in candidates if c is not None]:
|
||||
return max(candidates)
|
||||
match = _RETRY_AFTER_RE.search(_error_message(err))
|
||||
return parse_retry_after_seconds(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _is_signal_rate_limit_error(err: Any) -> bool:
|
||||
"""True if a signal-cli RPC error reflects a rate-limit failure: the typed
|
||||
``RATELIMIT_ERROR`` code (≥ v0.14.3), legacy ``[429]`` / ``RateLimitException``
|
||||
substrings, or libsignal-net's ``RetryLaterException`` / "Retry after N seconds"
|
||||
leaked through AttachmentInvalidException during upload (substring is the only signal)."""
|
||||
"""True if a signal-cli RPC error reflects a rate-limit failure: the typed ``RATELIMIT_ERROR`` code
|
||||
(≥ v0.14.3), legacy ``[429]`` / ``RateLimitException`` substrings, or libsignal-net's
|
||||
``RetryLaterException`` / "Retry after N seconds" leaked through AttachmentInvalidException."""
|
||||
if isinstance(err, dict) and err.get("code") == SIGNAL_RPC_ERROR_RATELIMIT:
|
||||
return True
|
||||
message = _error_message(err)
|
||||
msg_lower = message.lower()
|
||||
return (
|
||||
"[429]" in message
|
||||
or "ratelimit" in msg_lower
|
||||
or "retrylaterexception" in msg_lower
|
||||
or "retry after" in msg_lower)
|
||||
return "[429]" in message or any(s in msg_lower for s in ("ratelimit", "retrylaterexception", "retry after"))
|
||||
|
||||
|
||||
def _format_wait(seconds: float) -> str:
|
||||
@@ -92,23 +78,19 @@ def _format_wait(seconds: float) -> str:
|
||||
|
||||
|
||||
def _signal_send_timeout(num_attachments: int) -> float:
|
||||
"""HTTP timeout for a Signal ``send`` RPC: signal-cli uploads attachments serially inside
|
||||
the call, so the default 30s truncates large batches mid-upload and logs a phantom
|
||||
failure even though the send completes. Scale at 5s/attachment with a 60s floor."""
|
||||
"""HTTP timeout for a Signal ``send`` RPC: signal-cli uploads attachments serially inside the call, so
|
||||
the default 30s truncates large batches mid-upload (phantom failure). 5s/attachment, 60s floor."""
|
||||
return 30.0 if num_attachments <= 0 else max(60.0, 5.0 * num_attachments)
|
||||
|
||||
|
||||
class SignalAttachmentScheduler:
|
||||
"""Process-wide token-bucket simulator for Signal attachment sends.
|
||||
"""Process-wide token-bucket simulator for Signal attachment sends: up to ``capacity`` tokens (default
|
||||
50 = Signal's server bucket), one per attachment, refilling at ``refill_rate``/s (calibrated from the
|
||||
server's per-token Retry-After once a 429 has been observed; default 1 token / 4s). ``acquire(n)``
|
||||
calls serialize through an ``asyncio.Lock`` — FIFO across sessions."""
|
||||
|
||||
Holds up to ``capacity`` tokens (default 50 = Signal's server bucket); each
|
||||
attachment consumes one. Tokens refill at ``refill_rate``/s, calibrated from the
|
||||
server's per-token Retry-After once a 429 has been observed (default 1 token / 4s).
|
||||
``acquire(n)`` calls serialize through an ``asyncio.Lock`` — FIFO across sessions."""
|
||||
|
||||
def __init__(
|
||||
self, capacity: float = float(SIGNAL_RATE_LIMIT_BUCKET_CAPACITY),
|
||||
default_retry_after: float = float(SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER)) -> None:
|
||||
def __init__(self, capacity: float = float(SIGNAL_RATE_LIMIT_BUCKET_CAPACITY),
|
||||
default_retry_after: float = float(SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER)) -> None:
|
||||
self.capacity = float(capacity)
|
||||
self.tokens = float(capacity)
|
||||
self.refill_rate = 1.0 / float(default_retry_after)
|
||||
@@ -118,10 +100,9 @@ class SignalAttachmentScheduler:
|
||||
def _projected_tokens(self, now: Optional[float] = None) -> float:
|
||||
"""Tokens the bucket would hold at ``now``, without mutating state."""
|
||||
elapsed = (time.monotonic() if now is None else now) - self.last_refill
|
||||
projected = self.tokens
|
||||
if elapsed > 0 and projected < self.capacity:
|
||||
projected = min(self.capacity, projected + elapsed * self.refill_rate)
|
||||
return projected
|
||||
if elapsed > 0 and self.tokens < self.capacity:
|
||||
return min(self.capacity, self.tokens + elapsed * self.refill_rate)
|
||||
return self.tokens
|
||||
|
||||
def _refill(self) -> None:
|
||||
now = time.monotonic()
|
||||
@@ -129,27 +110,22 @@ class SignalAttachmentScheduler:
|
||||
self.last_refill = now
|
||||
|
||||
def estimate_wait(self, n: int) -> float:
|
||||
"""Seconds until ``n`` tokens would be available (lock-free, informational —
|
||||
decides whether to emit a pacing notice *before* a possibly-blocking ``acquire``;
|
||||
races vs. concurrent acquires are benign)."""
|
||||
"""Seconds until ``n`` tokens would be available (lock-free, informational — decides whether to emit
|
||||
a pacing notice *before* a possibly-blocking ``acquire``; races vs. concurrent acquires are benign)."""
|
||||
deficit = n - self._projected_tokens()
|
||||
return 0.0 if deficit <= 0 else deficit / self.refill_rate
|
||||
|
||||
async def acquire(self, n: int) -> float:
|
||||
"""Block until at least ``n`` tokens are available; return the seconds slept.
|
||||
|
||||
Does **not** deduct tokens — the bucket is a read-only model of server-side
|
||||
capacity; call ``report_rpc_duration()`` after the RPC to sync. The lock is
|
||||
released during ``asyncio.sleep`` so other callers interleave, and the loop
|
||||
re-checks after each sleep in case the deadline was pessimistic. Signal's
|
||||
server is ground truth and will 429 (→ requeue) if the model drifts."""
|
||||
"""Block until at least ``n`` tokens are available; return the seconds slept. Does **not** deduct
|
||||
tokens — the bucket is a read-only model of server-side capacity; call ``report_rpc_duration()``
|
||||
after the RPC to sync. The lock is released during ``asyncio.sleep`` so other callers interleave,
|
||||
and the loop re-checks after each sleep in case the deadline was pessimistic. Signal's server is
|
||||
ground truth and will 429 (→ requeue) if the model drifts."""
|
||||
if n <= 0:
|
||||
return 0.0
|
||||
if n > self.capacity:
|
||||
raise SignalSchedulerError(
|
||||
f"Signal scheduler was called requesting {n} tokens (max is {self.capacity})")
|
||||
total_slept = 0.0
|
||||
first_pass = True
|
||||
raise SignalSchedulerError(f"Signal scheduler was called requesting {n} tokens (max is {self.capacity})")
|
||||
total_slept, first_pass = 0.0, True
|
||||
while True:
|
||||
async with self._lock:
|
||||
self._refill()
|
||||
@@ -161,53 +137,43 @@ class SignalAttachmentScheduler:
|
||||
deficit = n - self.tokens
|
||||
wait = deficit / self.refill_rate
|
||||
if first_pass:
|
||||
logger.info(
|
||||
"Signal scheduler: pausing %.1fs for %d tokens "
|
||||
"(available=%.1f, deficit=%.1f, refill=%.4f/s ≈ %.1fs/token)",
|
||||
wait, n, self.tokens, deficit, self.refill_rate, 1.0 / self.refill_rate)
|
||||
logger.info("Signal scheduler: pausing %.1fs for %d tokens (available=%.1f, deficit=%.1f, "
|
||||
"refill=%.4f/s ≈ %.1fs/token)", wait, n, self.tokens, deficit, self.refill_rate,
|
||||
1.0 / self.refill_rate)
|
||||
first_pass = False
|
||||
await asyncio.sleep(wait)
|
||||
total_slept += wait
|
||||
|
||||
async def report_rpc_duration(self, rpc_duration: float, n_attachments: int) -> None:
|
||||
"""Deduct ``n_attachments`` tokens for a completed send RPC.
|
||||
|
||||
No refill is credited for the upload window: Signal's server checks the bucket
|
||||
at RPC start and resumes refill only after the response, so crediting it causes
|
||||
cumulative drift that eventually triggers 429s. Advances ``last_refill``."""
|
||||
"""Deduct ``n_attachments`` tokens for a completed send RPC. No refill is credited for the upload
|
||||
window: Signal's server checks the bucket at RPC start and resumes refill only after the response,
|
||||
so crediting it causes cumulative drift that eventually triggers 429s. Advances ``last_refill``."""
|
||||
if n_attachments <= 0:
|
||||
return
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
token_before = self.tokens
|
||||
self.tokens = max(0.0, token_before - float(n_attachments))
|
||||
self.last_refill = now
|
||||
logger.log(
|
||||
logging.INFO if rpc_duration > 10 and n_attachments > 5 else logging.DEBUG,
|
||||
"Signal scheduler: RPC for %d att took %.1fs — "
|
||||
"tokens %.1f → %.1f (deducted=%d, no upload refill credited, refill=%.4fs⁻¹)",
|
||||
n_attachments, rpc_duration, token_before, self.tokens, n_attachments, self.refill_rate)
|
||||
self.last_refill = time.monotonic()
|
||||
logger.log(logging.INFO if rpc_duration > 10 and n_attachments > 5 else logging.DEBUG,
|
||||
"Signal scheduler: RPC for %d att took %.1fs — tokens %.1f → %.1f (deducted=%d, no upload refill "
|
||||
"credited, refill=%.4fs⁻¹)", n_attachments, rpc_duration, token_before, self.tokens, n_attachments,
|
||||
self.refill_rate)
|
||||
|
||||
def feedback(self, retry_after: Optional[float], n_attempted: int) -> None:
|
||||
"""Apply server feedback after a 429: empty the bucket and, when ``retry_after``
|
||||
(per-token refill window) is present, calibrate ``refill_rate`` from it."""
|
||||
if retry_after and retry_after > 0:
|
||||
new_rate = 1.0 / float(retry_after)
|
||||
if new_rate != self.refill_rate:
|
||||
logger.info("Signal scheduler: calibrating refill_rate to %.4f tokens/sec "
|
||||
"(server retry_after=%.1fs per token)", new_rate, retry_after)
|
||||
self.refill_rate = new_rate
|
||||
"""Apply server feedback after a 429: empty the bucket and, when ``retry_after`` (per-token refill
|
||||
window) is present, calibrate ``refill_rate`` from it."""
|
||||
if retry_after and retry_after > 0 and (new_rate := 1.0 / float(retry_after)) != self.refill_rate:
|
||||
logger.info("Signal scheduler: calibrating refill_rate to %.4f tokens/sec (server retry_after=%.1fs "
|
||||
"per token)", new_rate, retry_after)
|
||||
self.refill_rate = new_rate
|
||||
self.tokens = 0.0
|
||||
self.last_refill = time.monotonic()
|
||||
|
||||
def state(self) -> dict:
|
||||
"""Current scheduler state for diagnostic logging (read-only, doesn't advance ``last_refill``)."""
|
||||
return {
|
||||
"tokens": round(self._projected_tokens(), 1),
|
||||
"capacity": int(self.capacity),
|
||||
"refill_rate": round(self.refill_rate, 4),
|
||||
"refill_seconds_per_token": round(1.0 / self.refill_rate, 1) if self.refill_rate > 0 else float("inf"),
|
||||
}
|
||||
return {"tokens": round(self._projected_tokens(), 1), "capacity": int(self.capacity),
|
||||
"refill_rate": round(self.refill_rate, 4),
|
||||
"refill_seconds_per_token": round(1.0 / self.refill_rate, 1) if self.refill_rate > 0 else float("inf")}
|
||||
|
||||
|
||||
_scheduler: Optional[SignalAttachmentScheduler] = None
|
||||
|
||||
+161
-233
@@ -1,10 +1,10 @@
|
||||
"""Generic webhook platform adapter: aiohttp server that validates HMAC-signed POSTs (GitHub,
|
||||
GitLab, Svix, Linear, generic), renders payloads into agent prompts, and routes responses back
|
||||
(github_comment or any gateway platform). Routes live under platforms.webhook.extra.routes:
|
||||
events (header filter), secret (REQUIRED; "INSECURE_NO_AUTH" skips validation, loopback only),
|
||||
prompt template, skills, deliver/deliver_extra, deliver_only (rendered prompt IS the message).
|
||||
Per-route rate limiting, idempotency cache, body-size caps checked before reading. Generic HMAC
|
||||
V2 binds a timestamp for replay protection; body-only V1 is deprecated but accepted with a warning."""
|
||||
"""Generic webhook platform adapter: aiohttp server that validates HMAC-signed POSTs (GitHub, GitLab,
|
||||
Svix, Linear, generic), renders payloads into agent prompts, and routes responses back (github_comment
|
||||
or any gateway platform). Routes live under platforms.webhook.extra.routes: events (header filter),
|
||||
secret (REQUIRED; "INSECURE_NO_AUTH" skips validation, loopback only), prompt template, skills,
|
||||
deliver/deliver_extra, deliver_only (rendered prompt IS the message). Per-route rate limiting,
|
||||
idempotency cache, body-size caps checked before reading. Generic HMAC V2 binds a timestamp for
|
||||
replay protection; body-only V1 is deprecated but accepted with a warning."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
@@ -36,8 +36,8 @@ from gateway.response_filters import is_autonomous_silence_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel from _resolve_request_profile: a /p/<profile>/ prefix names a profile
|
||||
# this gateway does not serve (→ 404). Distinct from None (no prefix / default).
|
||||
# _resolve_request_profile sentinel: /p/<profile>/ names a profile this gateway does not serve (→ 404);
|
||||
# distinct from None (no prefix / default).
|
||||
_PROFILE_REJECTED = object()
|
||||
_UNPARSEABLE = object()
|
||||
|
||||
@@ -46,10 +46,9 @@ _BUILTIN_DELIVER_PLATFORMS = {
|
||||
"homeassistant", "email", "dingtalk", "feishu", "wecom", "wecom_callback", "weixin",
|
||||
"bluebubbles", "qqbot", "yuanbao"}
|
||||
|
||||
# ``None`` → aiohttp binds BOTH address families. "0.0.0.0" is IPv4-only and was
|
||||
# unreachable on IPv6-only networks (Fly.io 6PN); "::" becomes IPv6-only where the
|
||||
# kernel sets IPV6_V6ONLY=1, breaking the 127.0.0.1 health check. Users can pin a
|
||||
# host via ``platforms.webhook.extra.host``.
|
||||
# ``None`` → aiohttp binds BOTH address families. "0.0.0.0" is IPv4-only (unreachable on IPv6-only
|
||||
# networks such as Fly.io 6PN); "::" becomes IPv6-only where the kernel sets IPV6_V6ONLY=1, breaking
|
||||
# the 127.0.0.1 health check. Users can pin a host via ``platforms.webhook.extra.host``.
|
||||
DEFAULT_HOST = None
|
||||
DEFAULT_PORT = 8644
|
||||
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
|
||||
@@ -77,12 +76,17 @@ def _hex_hmac(secret: str, data: bytes) -> str:
|
||||
return hmac.new(secret.encode(), data, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _timestamp_age(raw: str) -> Optional[int]:
|
||||
"""Seconds between now and an integer timestamp header; None when unparseable."""
|
||||
def _timestamp_fresh(raw: str, stale_msg: str, *args) -> bool:
|
||||
"""True when integer timestamp header *raw* is within the replay window; unparseable → False,
|
||||
stale → warn ``stale_msg % args`` and False."""
|
||||
try:
|
||||
return abs(int(time.time()) - int(raw))
|
||||
age = abs(int(time.time()) - int(raw))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return False
|
||||
if age > _V2_REPLAY_WINDOW_SECONDS:
|
||||
logger.warning(stale_msg, *args)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_known_platform(name: str) -> bool:
|
||||
@@ -100,10 +104,8 @@ def _json_error(message: str, status: int) -> "web.Response":
|
||||
|
||||
|
||||
def _peek_session_id(store, session_key: str):
|
||||
"""Prefer the store's lock-held accessor; fall back to the private path only for
|
||||
older stores / test doubles that predate it."""
|
||||
peek = getattr(store, "peek_session_id", None)
|
||||
if callable(peek):
|
||||
"""Prefer the store's lock-held accessor; the private-path fallback is for older stores / test doubles."""
|
||||
if callable(peek := getattr(store, "peek_session_id", None)):
|
||||
return peek(session_key)
|
||||
if hasattr(store, "_ensure_loaded"):
|
||||
with suppress(Exception):
|
||||
@@ -121,11 +123,7 @@ def _validate_svix_signature(body: bytes, secret: str, msg_id: str, timestamp: s
|
||||
"""Svix-compatible signatures (AgentMail): base64 HMAC-SHA256 of "{id}.{timestamp}.{body}"."""
|
||||
if not (msg_id and timestamp and signature_header and secret):
|
||||
return False
|
||||
age = _timestamp_age(timestamp)
|
||||
if age is None:
|
||||
return False
|
||||
if age > _V2_REPLAY_WINDOW_SECONDS:
|
||||
logger.warning("[webhook] Svix signature timestamp outside replay window")
|
||||
if not _timestamp_fresh(timestamp, "[webhook] Svix signature timestamp outside replay window"):
|
||||
return False
|
||||
if secret.startswith("whsec_"):
|
||||
try:
|
||||
@@ -150,8 +148,7 @@ def _validate_svix_signature(body: bytes, secret: str, msg_id: str, timestamp: s
|
||||
class WebhookAdapter(BasePlatformAdapter):
|
||||
"""Generic webhook receiver that triggers agent runs from HTTP POSTs."""
|
||||
|
||||
# Event-triggered, no human present: the startup auto-resume turn must FINISH
|
||||
# the interrupted work instead of asking "what next?".
|
||||
# Event-triggered, no human present: startup auto-resume must FINISH the interrupted work, not ask "what next?".
|
||||
interactive_resume: bool = False
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
@@ -166,21 +163,18 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
self._dynamic_routes_mtime: float = 0.0
|
||||
self._routes: Dict[str, dict] = dict(self._static_routes)
|
||||
self._runner = None
|
||||
# Routes already warned about legacy V1 body-only signatures (once per route).
|
||||
self._v1_signature_warned: set[str] = set()
|
||||
# Keyed by session chat_id; read by EVERY send() (interim status messages AND
|
||||
# the final response) so never pop on send(). TTL-pruned on each POST.
|
||||
self._v1_signature_warned: set[str] = set() # routes already warned about legacy V1 (once per route)
|
||||
# Keyed by session chat_id; read by EVERY send() (interim status messages AND the final
|
||||
# response) so never pop on send(). TTL-pruned on each POST.
|
||||
self._delivery_info: Dict[str, dict] = {}
|
||||
self._delivery_info_created: Dict[str, float] = {}
|
||||
self._delivery_info_order: Deque[tuple[float, str]] = deque()
|
||||
# Gateway runner for cross-platform delivery (set externally).
|
||||
self.gateway_runner = None
|
||||
self.gateway_runner = None # set externally; needed for cross-platform delivery
|
||||
# Idempotency: TTL cache of recently processed delivery IDs.
|
||||
self._seen_deliveries: Dict[str, float] = {}
|
||||
self._idempotency_ttl: int = 3600 # 1 hour
|
||||
self._seen_deliveries_next_prune_at: float = 0.0
|
||||
# Rate limiting: per-route timestamps in a fixed window.
|
||||
self._rate_counts: Dict[str, Deque[float]] = {}
|
||||
self._rate_counts: Dict[str, Deque[float]] = {} # per-route hit timestamps in a fixed window
|
||||
self._rate_limit: int = int(extra.get("rate_limit", 30)) # per minute
|
||||
self._max_body_bytes: int = int(extra.get("max_body_bytes", 1_048_576)) # 1MB
|
||||
self._script_timeout_seconds: int = int(extra.get("script_timeout_seconds", DEFAULT_SCRIPT_TIMEOUT_SECONDS))
|
||||
@@ -189,62 +183,50 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
# --- Lifecycle ---
|
||||
|
||||
def _validate_route(self, name: str, route: dict) -> None:
|
||||
"""Startup validation: secret is required; INSECURE_NO_AUTH only on loopback
|
||||
(an unauthenticated route on a public interface is a footgun — crash early)."""
|
||||
"""Startup validation: secret required; INSECURE_NO_AUTH only on loopback (crash early on a public footgun)."""
|
||||
secret = route.get("secret", self._global_secret)
|
||||
if not secret:
|
||||
raise ValueError(
|
||||
f"[webhook] Route '{name}' has no HMAC secret. "
|
||||
f"Set 'secret' on the route or globally. "
|
||||
f"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'.")
|
||||
raise ValueError(f"[webhook] Route '{name}' has no HMAC secret. Set 'secret' on the route or globally. "
|
||||
f"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'.")
|
||||
if secret == _INSECURE_NO_AUTH and not _is_loopback_host(self._host):
|
||||
raise ValueError(
|
||||
f"[webhook] Route '{name}' uses INSECURE_NO_AUTH secret "
|
||||
f"but is bound to non-loopback host '{self._host}'. "
|
||||
f"INSECURE_NO_AUTH is for local testing only. "
|
||||
f"Refusing to start to prevent accidental exposure.")
|
||||
raise ValueError(f"[webhook] Route '{name}' uses INSECURE_NO_AUTH secret but is bound to non-loopback "
|
||||
f"host '{self._host}'. INSECURE_NO_AUTH is for local testing only. "
|
||||
f"Refusing to start to prevent accidental exposure.")
|
||||
if route.get("deliver_only"):
|
||||
deliver = route.get("deliver", "log")
|
||||
if not deliver or deliver == "log":
|
||||
raise ValueError(
|
||||
f"[webhook] Route '{name}' has deliver_only=true but "
|
||||
f"deliver is '{deliver}'. Direct delivery requires a "
|
||||
f"real target (telegram, discord, slack, github_comment, etc.).")
|
||||
raise ValueError(f"[webhook] Route '{name}' has deliver_only=true but deliver is '{deliver}'. Direct "
|
||||
f"delivery requires a real target (telegram, discord, slack, github_comment, etc.).")
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
self._reload_dynamic_routes()
|
||||
for name, route in self._routes.items():
|
||||
self._validate_route(name, route)
|
||||
# client_max_size enforces the cap on every read path, including chunked
|
||||
# bodies without Content-Length that bypass the header check.
|
||||
# client_max_size enforces the cap on every read path, including chunked bodies without
|
||||
# Content-Length that bypass the header check.
|
||||
app = web.Application(client_max_size=self._max_body_bytes)
|
||||
app.router.add_get("/health", self._handle_health)
|
||||
app.router.add_post("/webhooks/{route_name}", self._handle_webhook)
|
||||
# /p/<profile>/ prefix routes the event to that profile (only honored when
|
||||
# gateway.multiplex_profiles is on; the handler validates).
|
||||
# /p/<profile>/ routes the event to that profile (honored only under gateway.multiplex_profiles).
|
||||
app.router.add_post("/p/{profile}/webhooks/{route_name}", self._handle_webhook)
|
||||
self._runner = web.AppRunner(app)
|
||||
await self._runner.setup()
|
||||
# SO_REUSEADDR: on macOS (BSD) two wildcard/specific sockets can silently split traffic
|
||||
# while both report success → disable. On Linux it only permits rebinding past TIME_WAIT
|
||||
# (a quick restart would otherwise fail to bind for ~60s) → keep the default.
|
||||
site = web.TCPSite(
|
||||
self._runner, self._host, self._port, reuse_address=False if sys.platform == "darwin" else None,
|
||||
)
|
||||
# SO_REUSEADDR: on macOS (BSD) two wildcard/specific sockets can silently split traffic while
|
||||
# both report success → disable. On Linux it only permits rebinding past TIME_WAIT (a quick
|
||||
# restart would otherwise fail to bind for ~60s) → keep the default.
|
||||
site = web.TCPSite(self._runner, self._host, self._port,
|
||||
reuse_address=False if sys.platform == "darwin" else None)
|
||||
try:
|
||||
await site.start()
|
||||
except OSError as exc:
|
||||
await self._runner.cleanup()
|
||||
self._runner = None
|
||||
logger.error(
|
||||
"[webhook] Could not bind %s:%d: %s. "
|
||||
"Set a different host or port in config.yaml under platforms.webhook.extra.",
|
||||
self._host or "all IPv4+IPv6 interfaces", self._port, exc)
|
||||
logger.error("[webhook] Could not bind %s:%d: %s. Set a different host or port in config.yaml under "
|
||||
"platforms.webhook.extra.", self._host or "all IPv4+IPv6 interfaces", self._port, exc)
|
||||
return False
|
||||
self._mark_connected()
|
||||
route_names = ", ".join(self._routes.keys()) or "(none configured)"
|
||||
logger.info("[webhook] Listening on %s:%d — routes: %s",
|
||||
self._host or "* (all interfaces, IPv4+IPv6)", self._port, route_names)
|
||||
logger.info("[webhook] Listening on %s:%d — routes: %s", self._host or "* (all interfaces, IPv4+IPv6)",
|
||||
self._port, ", ".join(self._routes.keys()) or "(none configured)")
|
||||
self._wire_plugin_handlers(None)
|
||||
return True
|
||||
|
||||
@@ -255,14 +237,13 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
self._mark_disconnected()
|
||||
logger.info("[webhook] Disconnected")
|
||||
|
||||
async def send(
|
||||
self, chat_id: str, content: str, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
"""Deliver the agent's response to the destination stored for ``chat_id``
|
||||
(``webhook:{route}:{delivery_id}``) — read with ``.get()``, never popped."""
|
||||
# Autonomous lane (no human reader): the loose marker matcher shared with cron —
|
||||
# marker on its own first/last line — because models add a sentence explaining
|
||||
# why they stayed quiet, which the interactive exact-match rule would deliver.
|
||||
# Autonomous lane (no human reader): the loose marker matcher shared with cron (marker on its own
|
||||
# first/last line), because models add a sentence explaining why they stayed quiet, which the
|
||||
# interactive exact-match rule would deliver.
|
||||
if is_autonomous_silence_response(content):
|
||||
logger.info("[webhook] Response for %s is a silence marker — not delivering", chat_id)
|
||||
return SendResult(success=True)
|
||||
@@ -279,14 +260,15 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error=f"Unknown deliver type: {deliver_type}")
|
||||
|
||||
def _prune_delivery_info(self, now: float) -> None:
|
||||
"""Drop delivery_info entries older than the idempotency TTL (bounds the dict
|
||||
by ``rate_limit * TTL`` even when runs never produce a final response)."""
|
||||
created = self._delivery_info_created
|
||||
if len(self._delivery_info_order) < len(created):
|
||||
self._delivery_info_order = deque((at, key) for key, at in sorted(created.items(), key=lambda kv: kv[1]))
|
||||
"""Drop delivery_info entries older than the idempotency TTL (bounds the dict by ``rate_limit * TTL``
|
||||
even when runs never produce a final response)."""
|
||||
created, order = self._delivery_info_created, self._delivery_info_order
|
||||
if len(order) < len(created):
|
||||
order = self._delivery_info_order = deque(
|
||||
(at, key) for key, at in sorted(created.items(), key=lambda kv: kv[1]))
|
||||
cutoff = now - self._idempotency_ttl
|
||||
while self._delivery_info_order and self._delivery_info_order[0][0] < cutoff:
|
||||
created_at, key = self._delivery_info_order.popleft()
|
||||
while order and order[0][0] < cutoff:
|
||||
created_at, key = order.popleft()
|
||||
if created.get(key) == created_at:
|
||||
self._delivery_info.pop(key, None)
|
||||
created.pop(key, None)
|
||||
@@ -302,8 +284,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
|
||||
def _record_rate_limit_hit(self, route_name: str, now: float) -> bool:
|
||||
"""Return True if route is still within limit after recording this hit."""
|
||||
window = self._rate_counts.get(route_name)
|
||||
if not isinstance(window, deque):
|
||||
if not isinstance(window := self._rate_counts.get(route_name), deque):
|
||||
window = self._rate_counts[route_name] = deque(window or ())
|
||||
cutoff = now - _RATE_WINDOW_SECONDS
|
||||
while window and window[0] < cutoff:
|
||||
@@ -315,8 +296,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
|
||||
def _record_delivery_id(self, delivery_id: str, now: float) -> bool:
|
||||
"""Return True when this delivery should be processed."""
|
||||
seen_at = self._seen_deliveries.get(delivery_id)
|
||||
if seen_at is not None and now - seen_at < self._idempotency_ttl:
|
||||
if (seen_at := self._seen_deliveries.get(delivery_id)) is not None and now - seen_at < self._idempotency_ttl:
|
||||
return False
|
||||
if seen_at is not None:
|
||||
self._seen_deliveries.pop(delivery_id, None)
|
||||
@@ -329,17 +309,15 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
return {"name": chat_id, "type": "webhook"}
|
||||
|
||||
def toolsets_for_source(self, source) -> Optional[List[str]]:
|
||||
"""Per-route ``toolsets`` override (config.yaml or a manual key in
|
||||
webhook_subscriptions.json — deliberately NOT settable via `hermes webhook
|
||||
subscribe`, so an agent-created subscription cannot self-grant tools)."""
|
||||
"""Per-route ``toolsets`` override (config.yaml or a manual key in webhook_subscriptions.json —
|
||||
deliberately NOT settable via `hermes webhook subscribe`, so an agent-created subscription
|
||||
cannot self-grant tools)."""
|
||||
parts = str(getattr(source, "chat_id", "") or "").split(":", 2)
|
||||
if len(parts) < 2 or parts[0] != "webhook":
|
||||
return None
|
||||
route_config = self._routes.get(parts[1])
|
||||
if not isinstance(route_config, dict):
|
||||
return None
|
||||
toolsets = route_config.get("toolsets")
|
||||
if not isinstance(toolsets, list) or not toolsets:
|
||||
toolsets = route_config.get("toolsets") if isinstance(route_config, dict) else None
|
||||
if not isinstance(toolsets, list):
|
||||
return None
|
||||
return [str(t).strip() for t in toolsets if str(t).strip()] or None
|
||||
|
||||
@@ -350,21 +328,16 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
return web.json_response({"status": "ok", "platform": "webhook"})
|
||||
|
||||
def _dynamic_route_allowed(self, name: str, route: dict) -> bool:
|
||||
"""An empty effective secret would make _handle_webhook skip HMAC validation →
|
||||
reject such dynamic routes; INSECURE_NO_AUTH is loopback-only."""
|
||||
"""An empty effective secret would make _handle_webhook skip HMAC validation → reject such
|
||||
dynamic routes; INSECURE_NO_AUTH is loopback-only."""
|
||||
effective_secret = route.get("secret", self._global_secret)
|
||||
if not effective_secret:
|
||||
logger.warning(
|
||||
"[webhook] Dynamic route '%s' skipped: 'secret' is "
|
||||
"missing or empty. Set a valid HMAC secret, or use "
|
||||
"'%s' to explicitly disable auth (testing only).",
|
||||
name, _INSECURE_NO_AUTH)
|
||||
logger.warning("[webhook] Dynamic route '%s' skipped: 'secret' is missing or empty. Set a valid HMAC "
|
||||
"secret, or use '%s' to explicitly disable auth (testing only).", name, _INSECURE_NO_AUTH)
|
||||
return False
|
||||
if effective_secret == _INSECURE_NO_AUTH and not _is_loopback_host(self._host):
|
||||
logger.warning(
|
||||
"[webhook] Dynamic route '%s' skipped: INSECURE_NO_AUTH "
|
||||
"is only allowed on loopback hosts. Current host: '%s'.",
|
||||
name, self._host)
|
||||
logger.warning("[webhook] Dynamic route '%s' skipped: INSECURE_NO_AUTH is only allowed on loopback "
|
||||
"hosts. Current host: '%s'.", name, self._host)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -374,8 +347,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
subs_path = get_hermes_home() / _DYNAMIC_ROUTES_FILENAME
|
||||
if not subs_path.exists():
|
||||
if self._dynamic_routes:
|
||||
self._dynamic_routes = {}
|
||||
self._routes = dict(self._static_routes)
|
||||
self._dynamic_routes, self._routes = {}, dict(self._static_routes)
|
||||
logger.debug("[webhook] Dynamic subscriptions file removed, cleared dynamic routes")
|
||||
return
|
||||
try:
|
||||
@@ -385,14 +357,12 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
data = json.loads(subs_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
# Static routes take precedence.
|
||||
self._dynamic_routes = {
|
||||
k: v for k, v in data.items()
|
||||
if k not in self._static_routes and self._dynamic_route_allowed(k, v)}
|
||||
self._dynamic_routes = { # static routes take precedence
|
||||
k: v for k, v in data.items() if k not in self._static_routes and self._dynamic_route_allowed(k, v)}
|
||||
self._routes = {**self._dynamic_routes, **self._static_routes}
|
||||
self._dynamic_routes_mtime = mtime
|
||||
logger.info("[webhook] Reloaded %d dynamic route(s): %s",
|
||||
len(self._dynamic_routes), ", ".join(self._dynamic_routes.keys()) or "(none)")
|
||||
logger.info("[webhook] Reloaded %d dynamic route(s): %s", len(self._dynamic_routes),
|
||||
", ".join(self._dynamic_routes.keys()) or "(none)")
|
||||
except Exception as e:
|
||||
logger.error("[webhook] Failed to reload dynamic routes: %s", e)
|
||||
|
||||
@@ -405,9 +375,8 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
return None
|
||||
cfg = getattr(self.gateway_runner, "config", None)
|
||||
if not getattr(cfg, "multiplex_profiles", False):
|
||||
# Only a self-referential prefix may fall through to the bare route;
|
||||
# anything else fails closed (silently ignoring the prefix served the
|
||||
# owner's routes under another profile's URL).
|
||||
# Only a self-referential prefix may fall through to the bare route; anything else fails
|
||||
# closed (silently ignoring the prefix served the owner's routes under another profile's URL).
|
||||
with suppress(Exception):
|
||||
from hermes_cli.profiles import profile_matches_home
|
||||
if profile_matches_home(profile):
|
||||
@@ -424,10 +393,10 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
@staticmethod
|
||||
def _route_allows_profile(route_config: dict, request_profile: Optional[str]) -> bool:
|
||||
"""Omitting ``profile`` binds a route to default; an explicit null/blank/non-string fails closed."""
|
||||
configured_profile = route_config.get("profile") if "profile" in route_config else "default"
|
||||
if not isinstance(configured_profile, str) or not configured_profile.strip():
|
||||
configured = route_config.get("profile") if "profile" in route_config else "default"
|
||||
if not isinstance(configured, str) or not configured.strip():
|
||||
return False
|
||||
return configured_profile.strip() == (request_profile or "default")
|
||||
return configured.strip() == (request_profile or "default")
|
||||
|
||||
@staticmethod
|
||||
def _profile_scope(profile: Optional[str]):
|
||||
@@ -438,25 +407,22 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
from hermes_cli.profiles import get_profile_dir
|
||||
return _profile_runtime_scope(get_profile_dir(profile))
|
||||
|
||||
async def _read_authenticated_body(
|
||||
self, request: "web.Request", route_name: str, route_config: dict
|
||||
) -> "tuple[Optional[bytes], Optional[web.Response]]":
|
||||
async def _read_authenticated_body(self, request: "web.Request", route_name: str,
|
||||
route_config: dict) -> "tuple[Optional[bytes], Optional[web.Response]]":
|
||||
"""Auth-before-body: size-cap, read, then HMAC-validate. Returns ``(body, None)`` or ``(None, response)``."""
|
||||
if (request.content_length or 0) > self._max_body_bytes:
|
||||
return None, _json_error("Payload too large", 413)
|
||||
try:
|
||||
raw_body = await request.read()
|
||||
except web.HTTPRequestEntityTooLarge:
|
||||
# aiohttp's client_max_size tripped — chunked or lying Content-Length.
|
||||
except web.HTTPRequestEntityTooLarge: # client_max_size tripped — chunked or lying Content-Length
|
||||
return None, _json_error("Payload too large", 413)
|
||||
except Exception as e:
|
||||
logger.error("[webhook] Failed to read body: %s", e)
|
||||
return None, _json_error("Bad request", 400)
|
||||
if len(raw_body) > self._max_body_bytes:
|
||||
# Defense in depth if the server-level limit was bypassed or misconfigured.
|
||||
if len(raw_body) > self._max_body_bytes: # defense in depth if the server-level limit was bypassed
|
||||
return None, _json_error("Payload too large", 413)
|
||||
# Missing/empty secrets fail closed here too (not only in connect()), so
|
||||
# direct handler reuse cannot become an unauthenticated dispatch surface.
|
||||
# Missing/empty secrets fail closed here too (not only in connect()), so direct handler reuse
|
||||
# cannot become an unauthenticated dispatch surface.
|
||||
secret = route_config.get("secret", self._global_secret)
|
||||
if not secret:
|
||||
logger.error("[webhook] Route %s has no HMAC secret; refusing request", route_name)
|
||||
@@ -478,17 +444,14 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
except Exception:
|
||||
return _UNPARSEABLE
|
||||
|
||||
async def _handle_deliver_only(
|
||||
self, prompt: str, payload: Any, route_config: dict, route_name: str, event_type: str, delivery_id: str
|
||||
) -> "web.Response":
|
||||
"""deliver_only: the rendered prompt IS the message — skip the agent, reuse
|
||||
the same auth/rate-limit/idempotency/template pipeline."""
|
||||
delivery = {
|
||||
"deliver": route_config.get("deliver", "log"),
|
||||
"deliver_extra": self._render_delivery_extra(route_config.get("deliver_extra", {}), payload),
|
||||
"payload": payload}
|
||||
logger.info("[webhook] direct-deliver event=%s route=%s target=%s msg_len=%d delivery=%s",
|
||||
event_type, route_name, delivery["deliver"], len(prompt), delivery_id)
|
||||
async def _handle_deliver_only(self, prompt: str, payload: Any, route_config: dict, route_name: str,
|
||||
event_type: str, delivery_id: str) -> "web.Response":
|
||||
"""deliver_only: the rendered prompt IS the message — skip the agent, reuse the same
|
||||
auth/rate-limit/idempotency/template pipeline."""
|
||||
delivery = {"deliver": route_config.get("deliver", "log"), "payload": payload,
|
||||
"deliver_extra": self._render_delivery_extra(route_config.get("deliver_extra", {}), payload)}
|
||||
logger.info("[webhook] direct-deliver event=%s route=%s target=%s msg_len=%d delivery=%s", event_type,
|
||||
route_name, delivery["deliver"], len(prompt), delivery_id)
|
||||
failed = {"status": "error", "error": "Delivery failed", "delivery_id": delivery_id}
|
||||
try:
|
||||
result = await self._direct_deliver(prompt, delivery)
|
||||
@@ -496,18 +459,16 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
logger.exception("[webhook] direct-deliver failed route=%s delivery=%s", route_name, delivery_id)
|
||||
return web.json_response(failed, status=502)
|
||||
if result.success:
|
||||
return web.json_response(
|
||||
{"status": "delivered", "route": route_name, "target": delivery["deliver"], "delivery_id": delivery_id},
|
||||
status=200)
|
||||
return web.json_response({"status": "delivered", "route": route_name, "target": delivery["deliver"],
|
||||
"delivery_id": delivery_id}, status=200)
|
||||
# Target rejected it — 502 with a generic error (don't leak adapter detail).
|
||||
logger.warning("[webhook] direct-deliver target rejected route=%s target=%s error=%s",
|
||||
route_name, delivery["deliver"], result.error)
|
||||
logger.warning("[webhook] direct-deliver target rejected route=%s target=%s error=%s", route_name,
|
||||
delivery["deliver"], result.error)
|
||||
return web.json_response(failed, status=502)
|
||||
|
||||
def _resolve_route(self, request: "web.Request") -> "tuple[str, Optional[dict], Any, Optional[web.Response]]":
|
||||
"""Route + profile lookup for a POST; ``(route_name, route_config, profile, error_response)``."""
|
||||
# Hot-reload dynamic subscriptions on each request (mtime-gated, cheap)
|
||||
self._reload_dynamic_routes()
|
||||
self._reload_dynamic_routes() # hot-reload dynamic subscriptions (mtime-gated, cheap)
|
||||
route_name = request.match_info.get("route_name", "")
|
||||
route_config = self._routes.get(route_name)
|
||||
profile = self._resolve_request_profile(request)
|
||||
@@ -519,16 +480,16 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
logger.warning("[webhook] Route %s is not authorized for profile %r", route_name, profile or "default")
|
||||
# Same as unknown-route so profile mismatches can't enumerate route bindings.
|
||||
return route_name, None, profile, _json_error(f"Unknown route: {route_name}", 404)
|
||||
# Disabled routes stay in the subscriptions file (dashboard can re-enable)
|
||||
# but reject events. Only an explicit ``enabled: false`` turns a route off.
|
||||
# Disabled routes stay in the subscriptions file (dashboard can re-enable) but reject events.
|
||||
# Only an explicit ``enabled: false`` turns a route off.
|
||||
if route_config.get("enabled", True) is False:
|
||||
return route_name, None, profile, _json_error(f"Route disabled: {route_name}", 403)
|
||||
return route_name, route_config, profile, None
|
||||
|
||||
@staticmethod
|
||||
def _apply_skills(prompt: str, skills: list) -> str:
|
||||
"""Inject the first matching skill via build_skill_invocation_message() directly —
|
||||
/skill-name slash commands would be intercepted by the command parser."""
|
||||
"""Inject the first matching skill via build_skill_invocation_message() directly — /skill-name slash
|
||||
commands would be intercepted by the command parser."""
|
||||
try:
|
||||
from agent.skill_commands import build_skill_invocation_message, get_skill_commands
|
||||
skill_cmds = get_skill_commands()
|
||||
@@ -557,16 +518,13 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
payload = self._parse_body(raw_body)
|
||||
if payload is _UNPARSEABLE:
|
||||
return _json_error("Cannot parse body", 400)
|
||||
event_type = (
|
||||
request.headers.get("X-GitHub-Event", "")
|
||||
or request.headers.get("X-GitLab-Event", "")
|
||||
or payload.get("event_type", "")
|
||||
or payload.get("type", "")
|
||||
or "unknown")
|
||||
headers = request.headers
|
||||
event_type = (headers.get("X-GitHub-Event", "") or headers.get("X-GitLab-Event", "")
|
||||
or payload.get("event_type", "") or payload.get("type", "") or "unknown")
|
||||
allowed_events = route_config.get("events", [])
|
||||
if allowed_events and event_type not in allowed_events:
|
||||
logger.debug("[webhook] Ignoring event %s for route %s (allowed: %s)",
|
||||
event_type, route_name, allowed_events)
|
||||
logger.debug("[webhook] Ignoring event %s for route %s (allowed: %s)", event_type, route_name,
|
||||
allowed_events)
|
||||
return web.json_response({"status": "ignored", "event": event_type})
|
||||
if not self._route_processor.route_filters_match(route_config, payload, event_type, request.headers):
|
||||
logger.info("[webhook] filtered event=%s route=%s", event_type, route_name)
|
||||
@@ -585,14 +543,11 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
return web.json_response({"status": "ignored", "reason": "script", "route": route_name})
|
||||
payload = transformed_payload or payload
|
||||
prompt = self._render_prompt(route_config.get("prompt", ""), payload, event_type, route_name)
|
||||
skills = route_config.get("skills", [])
|
||||
if skills:
|
||||
if skills := route_config.get("skills", []):
|
||||
prompt = self._apply_skills(prompt, skills)
|
||||
headers = request.headers
|
||||
delivery_id = headers.get("X-GitHub-Delivery", headers.get(
|
||||
"svix-id", headers.get("X-Request-ID", str(int(time.time() * 1000)))))
|
||||
# Idempotency: skip duplicate deliveries (webhook retries).
|
||||
now = time.time()
|
||||
now = time.time() # idempotency: skip duplicate deliveries (webhook retries)
|
||||
if not self._record_delivery_id(delivery_id, now):
|
||||
logger.info("[webhook] Skipping duplicate delivery %s", delivery_id)
|
||||
return web.json_response({"status": "duplicate", "delivery_id": delivery_id}, status=200)
|
||||
@@ -601,13 +556,10 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
return self._dispatch_agent_run(request, route_config, route_name, profile, payload, prompt, event_type,
|
||||
delivery_id, now)
|
||||
|
||||
def _dispatch_agent_run(
|
||||
self, request, route_config: dict, route_name: str, profile, payload: Any, prompt: str,
|
||||
event_type: str, delivery_id: str, now: float,
|
||||
) -> "web.Response":
|
||||
def _dispatch_agent_run(self, request, route_config: dict, route_name: str, profile, payload: Any, prompt: str,
|
||||
event_type: str, delivery_id: str, now: float) -> "web.Response":
|
||||
"""Record delivery info, spawn the agent run, and return 202 immediately."""
|
||||
# delivery_id in the session key → concurrent webhooks on one route get
|
||||
# independent agent runs (not queued/interrupted).
|
||||
# delivery_id in the session key → concurrent webhooks on one route get independent runs.
|
||||
session_chat_id = f"webhook:{route_name}:{delivery_id}"
|
||||
self._delivery_info[session_chat_id] = {
|
||||
"deliver": route_config.get("deliver", "log"),
|
||||
@@ -615,35 +567,33 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
self._delivery_info_created[session_chat_id] = now
|
||||
self._delivery_info_order.append((now, session_chat_id))
|
||||
self._prune_delivery_info(now)
|
||||
source = self.build_source(
|
||||
chat_id=session_chat_id, chat_name=f"webhook/{route_name}", chat_type="webhook",
|
||||
user_id=f"webhook:{route_name}", user_name=route_name)
|
||||
source = self.build_source(chat_id=session_chat_id, chat_name=f"webhook/{route_name}", chat_type="webhook",
|
||||
user_id=f"webhook:{route_name}", user_name=route_name)
|
||||
if profile and isinstance(profile, str):
|
||||
source.profile = profile
|
||||
event = MessageEvent(
|
||||
text=prompt, message_type=MessageType.TEXT, source=source, raw_message=payload, message_id=delivery_id)
|
||||
logger.info("[webhook] %s event=%s route=%s prompt_len=%d delivery=%s",
|
||||
request.method, event_type, route_name, len(prompt), delivery_id)
|
||||
# The per-delivery session is closed by ``on_processing_complete`` once the run
|
||||
# finishes (``handle_message`` is fire-and-forget, so nothing can be closed here).
|
||||
event = MessageEvent(text=prompt, message_type=MessageType.TEXT, source=source, raw_message=payload,
|
||||
message_id=delivery_id)
|
||||
logger.info("[webhook] %s event=%s route=%s prompt_len=%d delivery=%s", request.method, event_type, route_name,
|
||||
len(prompt), delivery_id)
|
||||
# The per-delivery session is closed by ``on_processing_complete`` once the run finishes
|
||||
# (``handle_message`` is fire-and-forget, so nothing can be closed here).
|
||||
task = asyncio.create_task(self.handle_message(event))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return web.json_response(
|
||||
{"status": "accepted", "route": route_name, "event": event_type, "delivery_id": delivery_id}, status=202)
|
||||
return web.json_response({"status": "accepted", "route": route_name, "event": event_type,
|
||||
"delivery_id": delivery_id}, status=202)
|
||||
|
||||
async def on_processing_complete(self, event: "MessageEvent", outcome: Any) -> None:
|
||||
"""Close the one-shot per-delivery session: ``prune_sessions`` only reaps rows with ``ended_at``
|
||||
set, so unclosed webhook sessions leak unbounded. Fires at the true end of the run (success,
|
||||
failure, cancellation); ``end_session()`` is first-reason-wins."""
|
||||
"""Close the one-shot per-delivery session: ``prune_sessions`` only reaps rows with ``ended_at`` set, so
|
||||
unclosed webhook sessions leak unbounded. Fires at the true end of the run; ``end_session()`` is
|
||||
first-reason-wins."""
|
||||
await self._end_webhook_session(event, event.source.chat_id)
|
||||
|
||||
async def _end_webhook_session(self, event: "MessageEvent", session_chat_id: str) -> None:
|
||||
"""Mark the per-delivery session ended via ``SessionDB.end_session`` (never a hand-written UPDATE),
|
||||
resolving session_id from the SAME source the run was keyed on."""
|
||||
runner = self.gateway_runner
|
||||
session_db = getattr(runner, "_session_db", None)
|
||||
store = getattr(runner, "session_store", None)
|
||||
session_db, store = getattr(runner, "_session_db", None), getattr(runner, "session_store", None)
|
||||
key_fn = getattr(runner, "_session_key_for_source", None)
|
||||
if runner is None or session_db is None or store is None or key_fn is None:
|
||||
return
|
||||
@@ -676,32 +626,25 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
return _validate_svix_signature(body, secret, *svix)
|
||||
# Linear (any header case): hex HMAC of the body. GitHub: sha256=<hex>. GitLab: plain token.
|
||||
for provided, expected in (
|
||||
(_header("linear-signature"), lambda: _hex_hmac(secret, body)),
|
||||
(headers.get("X-Hub-Signature-256", ""), lambda: "sha256=" + _hex_hmac(secret, body)),
|
||||
(headers.get("X-Gitlab-Token", ""), lambda: secret),
|
||||
):
|
||||
(_header("linear-signature"), lambda: _hex_hmac(secret, body)),
|
||||
(headers.get("X-Hub-Signature-256", ""), lambda: "sha256=" + _hex_hmac(secret, body)),
|
||||
(headers.get("X-Gitlab-Token", ""), lambda: secret)):
|
||||
if provided:
|
||||
return _hmac_str_equal(provided, expected())
|
||||
route_name = request.match_info.get("route_name", "")
|
||||
# Generic V2: X-Webhook-Signature-V2 = hex HMAC-SHA256 of "<timestamp>.<body>",
|
||||
# X-Webhook-Timestamp required. Presence of the V2 header COMMITS to V2 — it
|
||||
# must not fall through to V1 on a missing/bad timestamp, or an attacker could
|
||||
# strip the timestamp from a captured mixed V1+V2 request and replay it
|
||||
# against the still-present body-only V1 signature.
|
||||
# Generic V2: X-Webhook-Signature-V2 = hex HMAC-SHA256 of "<timestamp>.<body>", X-Webhook-Timestamp
|
||||
# required. Presence of the V2 header COMMITS to V2 — it must not fall through to V1 on a
|
||||
# missing/bad timestamp, or an attacker could strip the timestamp from a captured mixed V1+V2
|
||||
# request and replay it against the still-present body-only V1 signature.
|
||||
v2_sig = headers.get("X-Webhook-Signature-V2", "")
|
||||
if v2_sig:
|
||||
v2_timestamp = headers.get("X-Webhook-Timestamp", "")
|
||||
if not v2_timestamp:
|
||||
logger.warning(
|
||||
"[webhook] Route '%s' sent X-Webhook-Signature-V2 with "
|
||||
"no X-Webhook-Timestamp — rejecting rather than falling back to legacy V1",
|
||||
route_name)
|
||||
logger.warning("[webhook] Route '%s' sent X-Webhook-Signature-V2 with no X-Webhook-Timestamp — "
|
||||
"rejecting rather than falling back to legacy V1", route_name)
|
||||
return False
|
||||
age = _timestamp_age(v2_timestamp)
|
||||
if age is None:
|
||||
return False
|
||||
if age > _V2_REPLAY_WINDOW_SECONDS:
|
||||
logger.warning("[webhook] Route '%s' generic HMAC V2 timestamp outside replay window", route_name)
|
||||
if not _timestamp_fresh(
|
||||
v2_timestamp, "[webhook] Route '%s' generic HMAC V2 timestamp outside replay window", route_name):
|
||||
return False
|
||||
return _hmac_str_equal(v2_sig, _hex_hmac(secret, v2_timestamp.encode() + b"." + body))
|
||||
# Generic V1 (legacy, deprecated): body-only HMAC → replays indefinitely.
|
||||
@@ -709,12 +652,9 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
if generic_sig:
|
||||
if route_name not in self._v1_signature_warned:
|
||||
self._v1_signature_warned.add(route_name)
|
||||
logger.warning(
|
||||
"[webhook] Route '%s' uses legacy body-only HMAC (no "
|
||||
"timestamp), which is vulnerable to replay attacks. Add "
|
||||
"an 'X-Webhook-Timestamp' header and switch to "
|
||||
"'X-Webhook-Signature-V2' (HMAC-SHA256 of '<timestamp>.<body>').",
|
||||
route_name)
|
||||
logger.warning("[webhook] Route '%s' uses legacy body-only HMAC (no timestamp), which is vulnerable "
|
||||
"to replay attacks. Add an 'X-Webhook-Timestamp' header and switch to "
|
||||
"'X-Webhook-Signature-V2' (HMAC-SHA256 of '<timestamp>.<body>').", route_name)
|
||||
return _hmac_str_equal(generic_sig, _hex_hmac(secret, body))
|
||||
logger.debug("[webhook] Secret configured but no signature header found")
|
||||
return False
|
||||
@@ -739,25 +679,21 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
if not isinstance(value, dict):
|
||||
return f"{{{key}}}"
|
||||
value = value.get(part, f"{{{key}}}")
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, indent=2)[:2000]
|
||||
return str(value)
|
||||
return json.dumps(value, indent=2)[:2000] if isinstance(value, (dict, list)) else str(value)
|
||||
|
||||
return _TEMPLATE_KEY_RE.sub(_resolve, template)
|
||||
|
||||
def _render_delivery_extra(self, extra: dict, payload: dict) -> dict:
|
||||
"""Render delivery_extra template values with payload data."""
|
||||
return {
|
||||
key: self._render_prompt(value, payload, "", "") if isinstance(value, str) else value
|
||||
for key, value in extra.items()}
|
||||
return {key: self._render_prompt(value, payload, "", "") if isinstance(value, str) else value
|
||||
for key, value in extra.items()}
|
||||
|
||||
# --- Response delivery ---
|
||||
|
||||
async def _direct_deliver(self, content: str, delivery: dict) -> SendResult:
|
||||
"""deliver_only: dispatch *content* to the same delivery helpers agent-mode ``send()`` uses."""
|
||||
deliver_type = delivery.get("deliver", "log")
|
||||
if deliver_type == "log":
|
||||
# Startup validation rejects deliver_only + log, but guard defensively.
|
||||
if deliver_type == "log": # startup validation rejects deliver_only + log; guard defensively
|
||||
logger.info("[webhook] direct-deliver log-only: %s", content[:200])
|
||||
return SendResult(success=True)
|
||||
if deliver_type == "github_comment":
|
||||
@@ -767,13 +703,11 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
async def _deliver_github_comment(self, content: str, delivery: dict) -> SendResult:
|
||||
"""Post agent response as a GitHub PR/issue comment via ``gh`` CLI."""
|
||||
extra = delivery.get("deliver_extra", {})
|
||||
repo = extra.get("repo", "")
|
||||
pr_number = extra.get("pr_number", "")
|
||||
repo, pr_number = extra.get("repo", ""), extra.get("pr_number", "")
|
||||
if not repo or not pr_number:
|
||||
logger.error("[webhook] github_comment delivery missing repo or pr_number")
|
||||
return SendResult(success=False, error="Missing repo or pr_number")
|
||||
# Input validation (prevent CLI argument injection).
|
||||
try:
|
||||
try: # input validation (prevent CLI argument injection)
|
||||
pr_int = int(pr_number)
|
||||
if pr_int <= 0:
|
||||
raise ValueError("non-positive")
|
||||
@@ -784,11 +718,10 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
logger.error("[webhook] invalid repo format: %r", repo)
|
||||
return SendResult(success=False, error="Invalid repo format")
|
||||
try:
|
||||
# Off-loop: `gh` does network I/O up to its 30s timeout; inline it froze
|
||||
# every adapter and timer on the gateway event loop.
|
||||
# Off-loop: `gh` does network I/O up to its 30s timeout; inline it froze every adapter and
|
||||
# timer on the gateway event loop.
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
["gh", "pr", "comment", str(pr_int), "--repo", repo, "--body", content],
|
||||
subprocess.run, ["gh", "pr", "comment", str(pr_int), "--repo", repo, "--body", content],
|
||||
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30)
|
||||
if result.returncode == 0:
|
||||
logger.info("[webhook] Posted comment on %s#%s", repo, pr_number)
|
||||
@@ -803,10 +736,8 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
def _find_adapter(self, target_platform: Platform):
|
||||
"""Default adapters first; multiplex may park a platform only on a secondary
|
||||
profile (_profile_adapters), so fall back to those."""
|
||||
adapter = self.gateway_runner.adapters.get(target_platform)
|
||||
if adapter:
|
||||
"""Default adapters first; multiplex may park a platform only on a secondary profile (_profile_adapters)."""
|
||||
if adapter := self.gateway_runner.adapters.get(target_platform):
|
||||
return adapter
|
||||
for amap in (getattr(self.gateway_runner, "_profile_adapters", None) or {}).values():
|
||||
if isinstance(amap, dict) and amap.get(target_platform) is not None:
|
||||
@@ -821,8 +752,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
target_platform = Platform(platform_name)
|
||||
except ValueError:
|
||||
return SendResult(success=False, error=f"Unknown platform: {platform_name}")
|
||||
adapter = self._find_adapter(target_platform)
|
||||
if not adapter:
|
||||
if not (adapter := self._find_adapter(target_platform)):
|
||||
return SendResult(success=False, error=f"Platform {platform_name} not connected")
|
||||
extra = delivery.get("deliver_extra", {})
|
||||
chat_id = extra.get("chat_id", "")
|
||||
@@ -831,7 +761,5 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
if not home:
|
||||
return SendResult(success=False, error=f"No chat_id or home channel for {platform_name}")
|
||||
chat_id = home.chat_id
|
||||
# thread_id from deliver_extra so Telegram forum topics work.
|
||||
thread_id = extra.get("message_thread_id") or extra.get("thread_id")
|
||||
metadata = {"thread_id": thread_id} if thread_id else None
|
||||
return await adapter.send(chat_id, content, metadata=metadata)
|
||||
thread_id = extra.get("message_thread_id") or extra.get("thread_id") # Telegram forum topics
|
||||
return await adapter.send(chat_id, content, metadata={"thread_id": thread_id} if thread_id else None)
|
||||
|
||||
Reference in New Issue
Block a user