ForcePilot/backend/package/yuxi/channel/extensions/zulip/monitor.py
Kris d5e36d33b7 feat(channel): 添加 Zalo OA、Zoom Chat 和 Zulip 渠道扩展
新增 Zalo OA、Zoom Chat、Zulip 三个渠道扩展。

Zalo OA 渠道扩展主要模块:sidecar_client, config, gateway, outbound, streaming, pairing, security, auth, dedupe, directory, monitor, status, session, reactions, tools

Zoom Chat 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, crypto, dedupe, actions, media, mentions, monitor, status, session, reactions, threading

Zulip 渠道扩展主要模块:client, config, gateway, outbound, streaming, pairing, security, monitor, status
2026-05-21 12:06:26 +08:00

143 lines
4.8 KiB
Python

from __future__ import annotations
import logging
import time
from yuxi.channel.extensions.zulip.format import clean_mentions
from yuxi.channel.extensions.zulip.security import ZulipSecurity
logger = logging.getLogger(__name__)
_SEEN_TTL = 300_000
_SEEN_MAX = 2000
class ZulipMonitor:
def __init__(self, account_id: str, account: dict):
self.account_id = account_id
self.account = account
self.security = ZulipSecurity()
self._seen: dict[int, float] = {}
def convert_zulip_message_to_unified(self, msg: dict) -> dict | None:
msg_id = msg.get("id")
msg_type = msg.get("type", "")
content = msg.get("content", "")
sender_id = msg.get("sender_id")
sender_email = msg.get("sender_email", "")
sender_name = msg.get("sender_full_name", "")
display_recipient = msg.get("display_recipient", "")
subject = msg.get("subject", "")
timestamp = msg.get("timestamp", 0)
stream_id = msg.get("stream_id")
if not msg_id or not content:
return None
if self._is_duplicate(msg_id):
logger.debug("Duplicate Zulip message %s, dropping", msg_id)
return None
if msg_type == "stream":
stream_name = display_recipient if isinstance(display_recipient, str) else ""
if not self.security.is_stream_allowed(stream_name, self.account.get("stream_allowlist", [])):
logger.debug("Stream %s not in allowlist, dropping", stream_name)
return None
chat_type = "group"
group_id = f"stream:{stream_id or display_recipient}"
elif msg_type == "private":
allowed, reason = self.security.check_dm_access(sender_email, self.account)
if not allowed:
logger.info("DM access denied for %s: %s", sender_email, reason)
return {"status": "blocked", "reason": reason}
chat_type = "direct"
group_id = None
else:
return None
flags = msg.get("flags", [])
was_mentioned = "mentioned" in flags or "wildcard_mentioned" in flags
normalized = clean_mentions(content)
unified = {
"message_id": str(msg_id),
"sender": {
"id": str(sender_id),
"name": sender_name,
"email": sender_email,
"kind": "direct" if msg_type == "private" else "group",
},
"content": normalized,
"content_type": "text/markdown",
"chat_type": chat_type,
"timestamp": timestamp,
"account_id": self.account_id,
"channel_id": "zulip",
"was_mentioned": was_mentioned,
"raw": msg,
}
if group_id:
unified["group"] = {
"id": group_id,
"name": display_recipient if isinstance(display_recipient, str) else "",
}
if subject:
unified["thread"] = {
"id": subject,
"name": subject,
}
if msg.get("reactions"):
unified["reactions"] = msg["reactions"]
if msg.get("edit_history"):
unified["edit_history"] = msg["edit_history"]
return unified
def _is_duplicate(self, msg_id: int) -> bool:
now = time.monotonic()
if msg_id in self._seen:
return True
self._seen[msg_id] = now
self._evict(now)
return False
def _evict(self, now: float) -> None:
if len(self._seen) <= _SEEN_MAX:
return
cutoff = now - _SEEN_TTL / 1000.0
expired = [k for k, v in self._seen.items() if v < cutoff]
for k in expired:
del self._seen[k]
def convert_reaction_event_to_unified(self, event: dict) -> dict | None:
op = event.get("op")
if op not in ("add", "remove"):
return None
return {
"type": "reaction_" + op,
"message_id": str(event.get("message_id")),
"user_id": str(event.get("user_id")),
"emoji_name": event.get("emoji_name", ""),
"emoji_code": event.get("emoji_code", ""),
"reaction_type": event.get("reaction_type", ""),
"channel_id": "zulip",
}
def convert_edit_event_to_unified(self, event: dict) -> dict | None:
message_id = event.get("message_id")
if not message_id:
return None
return {
"type": "message_updated",
"message_id": str(message_id),
"user_id": str(event.get("user_id", "")),
"edit_timestamp": event.get("edit_timestamp", 0),
"orig_content": event.get("orig_content", ""),
"orig_subject": event.get("orig_subject", ""),
"channel_id": "zulip",
}