602 lines
23 KiB
Python
602 lines
23 KiB
Python
|
|
import logging
|
||
|
|
from urllib.parse import urlparse
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
from yuxi.channel.capabilities import ChannelCapabilities
|
||
|
|
from yuxi.channel.context import ChannelContext
|
||
|
|
from yuxi.channel.extensions.base import BaseChannelPlugin
|
||
|
|
from yuxi.channel.extensions.xmpp.accounts import (
|
||
|
|
has_configured_state,
|
||
|
|
list_xmpp_account_ids,
|
||
|
|
resolve_xmpp_account,
|
||
|
|
)
|
||
|
|
from yuxi.channel.extensions.xmpp.errors import classify_xmpp_error, is_retryable as _is_xmpp_retryable
|
||
|
|
from yuxi.channel.extensions.xmpp.gateway import XmppGateway
|
||
|
|
from yuxi.channel.extensions.xmpp.outbound import send_xmpp_text, send_xmpp_typing
|
||
|
|
from yuxi.channel.extensions.xmpp.pairing import (
|
||
|
|
generate_xmpp_pairing_code,
|
||
|
|
normalize_xmpp_allow_entry,
|
||
|
|
verify_xmpp_pairing_code,
|
||
|
|
)
|
||
|
|
from yuxi.channel.extensions.xmpp.security import (
|
||
|
|
check_xmpp_allowlist,
|
||
|
|
collect_xmpp_security_warnings,
|
||
|
|
)
|
||
|
|
from yuxi.channel.extensions.xmpp.status import build_xmpp_status_summary, probe_xmpp
|
||
|
|
from yuxi.channel.extensions.xmpp.streaming import chunk_text
|
||
|
|
from yuxi.channel.extensions.xmpp.types import ResolvedXmppAccount
|
||
|
|
|
||
|
|
logger = logging.getLogger("yuxi.channel.xmpp")
|
||
|
|
|
||
|
|
|
||
|
|
class XmppPlugin(BaseChannelPlugin):
|
||
|
|
id = "xmpp"
|
||
|
|
name = "XMPP / Jabber"
|
||
|
|
order = 85
|
||
|
|
label = "XMPP / Jabber (JID + 密码)"
|
||
|
|
aliases = ["jabber", "xmpp-chat"]
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self._config: dict = {}
|
||
|
|
self._gateway: XmppGateway | None = None
|
||
|
|
self._account: ResolvedXmppAccount | None = None
|
||
|
|
|
||
|
|
@property
|
||
|
|
def capabilities(self) -> ChannelCapabilities:
|
||
|
|
return ChannelCapabilities(
|
||
|
|
chat_types=["direct", "group"],
|
||
|
|
message_types=["text"],
|
||
|
|
reactions=False,
|
||
|
|
typing_indicator=True,
|
||
|
|
threads=True,
|
||
|
|
edit=False,
|
||
|
|
unsend=False,
|
||
|
|
reply=True,
|
||
|
|
media=True,
|
||
|
|
native_commands=True,
|
||
|
|
polls=False,
|
||
|
|
streaming=True,
|
||
|
|
streaming_mode="block",
|
||
|
|
block_streaming=True,
|
||
|
|
block_streaming_chunk_min_chars=800,
|
||
|
|
block_streaming_chunk_max_chars=2000,
|
||
|
|
)
|
||
|
|
|
||
|
|
# ── ConfigProtocol ──────────────────────────────────
|
||
|
|
|
||
|
|
def list_account_ids(self, config: dict) -> list[str]:
|
||
|
|
xmpp_cfg = config.get("channels", {}).get("xmpp", {})
|
||
|
|
return list_xmpp_account_ids(xmpp_cfg)
|
||
|
|
|
||
|
|
async def resolve_account(self, account_id: str) -> dict:
|
||
|
|
xmpp_cfg = self._config.get("channels", {}).get("xmpp", {})
|
||
|
|
account = resolve_xmpp_account(xmpp_cfg, account_id)
|
||
|
|
return {
|
||
|
|
"account_id": account.account_id,
|
||
|
|
"enabled": account.enabled,
|
||
|
|
"jid": account.jid,
|
||
|
|
"host": account.host,
|
||
|
|
"port": account.port,
|
||
|
|
"use_ssl": account.use_ssl,
|
||
|
|
"resource": account.resource,
|
||
|
|
"nick": account.nick,
|
||
|
|
"configured": account.is_configured,
|
||
|
|
"dm_policy": account.dm_policy,
|
||
|
|
"group_policy": account.group_policy,
|
||
|
|
"rooms": account.rooms,
|
||
|
|
"allow_from": account.allow_from,
|
||
|
|
}
|
||
|
|
|
||
|
|
def is_configured(self, account: dict) -> bool:
|
||
|
|
return bool(account.get("jid") and account.get("configured"))
|
||
|
|
|
||
|
|
def is_enabled(self, account: dict) -> bool:
|
||
|
|
return account.get("enabled", True)
|
||
|
|
|
||
|
|
def describe_account(self, account: dict, config: dict = None) -> dict:
|
||
|
|
jid = account.get("jid", "")
|
||
|
|
host = account.get("host", "")
|
||
|
|
result = {
|
||
|
|
"account_id": account.get("account_id", "default"),
|
||
|
|
"jid": jid,
|
||
|
|
"host": host or "(SRV auto-discovery)",
|
||
|
|
"nick": account.get("nick", "Bot"),
|
||
|
|
}
|
||
|
|
if account.get("rooms"):
|
||
|
|
result["rooms"] = account["rooms"]
|
||
|
|
return result
|
||
|
|
|
||
|
|
def disabled_reason(self, account: dict, config: dict = None) -> str:
|
||
|
|
if not account.get("enabled", True):
|
||
|
|
return "XMPP account is disabled in configuration"
|
||
|
|
if not account.get("jid"):
|
||
|
|
return "XMPP JID is not configured"
|
||
|
|
if not account.get("configured"):
|
||
|
|
return "XMPP account is not fully configured (jid + password required)"
|
||
|
|
return ""
|
||
|
|
|
||
|
|
def has_configured_state(self, config: dict) -> bool:
|
||
|
|
xmpp_cfg = config.get("channels", {}).get("xmpp", {})
|
||
|
|
return has_configured_state(xmpp_cfg)
|
||
|
|
|
||
|
|
def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str] | None:
|
||
|
|
xmpp_cfg = config.get("channels", {}).get("xmpp", {})
|
||
|
|
account = resolve_xmpp_account(xmpp_cfg, account_id or "default")
|
||
|
|
if account.allow_from:
|
||
|
|
return account.allow_from
|
||
|
|
return None
|
||
|
|
|
||
|
|
def config_schema(self) -> dict:
|
||
|
|
from yuxi.channel.extensions.xmpp.config import build_config_schema
|
||
|
|
|
||
|
|
return build_config_schema()
|
||
|
|
|
||
|
|
# ── GatewayProtocol ─────────────────────────────────
|
||
|
|
|
||
|
|
async def start(self, ctx: ChannelContext) -> object:
|
||
|
|
self._config = ctx.config
|
||
|
|
xmpp_cfg = ctx.config.get("channels", {}).get("xmpp", {})
|
||
|
|
account = resolve_xmpp_account(xmpp_cfg, ctx.account_id)
|
||
|
|
|
||
|
|
if not account.is_configured:
|
||
|
|
raise RuntimeError(f"XMPP account '{ctx.account_id}' not configured (jid + password required)")
|
||
|
|
|
||
|
|
self._account = account
|
||
|
|
self._gateway = XmppGateway(account)
|
||
|
|
|
||
|
|
async def on_inbound(unified_msg):
|
||
|
|
if unified_msg is None:
|
||
|
|
return
|
||
|
|
if not _check_xmpp_security(unified_msg, account):
|
||
|
|
return
|
||
|
|
if ctx.queue is not None:
|
||
|
|
await ctx.queue.put(unified_msg)
|
||
|
|
|
||
|
|
self._gateway.on_message = on_inbound
|
||
|
|
await self._gateway.connect()
|
||
|
|
|
||
|
|
logger.info("XMPP gateway started for %s", account.jid)
|
||
|
|
return self._gateway
|
||
|
|
|
||
|
|
async def stop(self, ctx: ChannelContext) -> None:
|
||
|
|
if self._gateway is not None:
|
||
|
|
await self._gateway.disconnect()
|
||
|
|
self._gateway = None
|
||
|
|
logger.info("XMPP gateway stopped")
|
||
|
|
|
||
|
|
# ── OutboundProtocol ────────────────────────────────
|
||
|
|
|
||
|
|
async def send_text(
|
||
|
|
self,
|
||
|
|
target_id: str,
|
||
|
|
content: str,
|
||
|
|
*,
|
||
|
|
reply_to_id: str | None = None,
|
||
|
|
thread_id: str | None = None,
|
||
|
|
account_id: str | None = None,
|
||
|
|
) -> None:
|
||
|
|
gateway = self._gateway
|
||
|
|
if gateway is None:
|
||
|
|
logger.error("XMPP gateway not available for send_text")
|
||
|
|
return
|
||
|
|
await send_xmpp_text(
|
||
|
|
gateway,
|
||
|
|
target_id,
|
||
|
|
content,
|
||
|
|
reply_to_id=reply_to_id,
|
||
|
|
thread_id=thread_id,
|
||
|
|
)
|
||
|
|
|
||
|
|
async def send_media(
|
||
|
|
self,
|
||
|
|
target_id: str,
|
||
|
|
media_url: str,
|
||
|
|
media_type: str = "file",
|
||
|
|
*,
|
||
|
|
reply_to_id: str | None = None,
|
||
|
|
thread_id: str | None = None,
|
||
|
|
account_id: str | None = None,
|
||
|
|
) -> None:
|
||
|
|
gateway = self._gateway
|
||
|
|
if gateway is None:
|
||
|
|
logger.error("XMPP gateway not available for send_media")
|
||
|
|
return
|
||
|
|
|
||
|
|
from yuxi.channel.extensions.xmpp.outbound import send_xmpp_file, send_xmpp_text
|
||
|
|
|
||
|
|
file_path = None
|
||
|
|
try:
|
||
|
|
if media_url.startswith(("http://", "https://")):
|
||
|
|
import tempfile
|
||
|
|
import os
|
||
|
|
|
||
|
|
async with httpx.AsyncClient() as client:
|
||
|
|
resp = await client.get(media_url)
|
||
|
|
resp.raise_for_status()
|
||
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=_ext_from_url(media_url)) as f:
|
||
|
|
f.write(resp.content)
|
||
|
|
file_path = f.name
|
||
|
|
else:
|
||
|
|
file_path = media_url
|
||
|
|
|
||
|
|
result = await send_xmpp_file(
|
||
|
|
gateway,
|
||
|
|
target_id,
|
||
|
|
file_path,
|
||
|
|
reply_to_id=reply_to_id,
|
||
|
|
thread_id=thread_id,
|
||
|
|
)
|
||
|
|
if not result.ok:
|
||
|
|
logger.warning("XMPP file upload failed, falling back to URL text: %s", result.error)
|
||
|
|
await send_xmpp_text(
|
||
|
|
gateway,
|
||
|
|
target_id,
|
||
|
|
f"[{media_type}]\n{media_url}",
|
||
|
|
reply_to_id=reply_to_id,
|
||
|
|
thread_id=thread_id,
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
logger.warning("XMPP file upload failed, falling back to URL text: %s", e)
|
||
|
|
await send_xmpp_text(
|
||
|
|
gateway,
|
||
|
|
target_id,
|
||
|
|
f"[{media_type}]\n{media_url}",
|
||
|
|
reply_to_id=reply_to_id,
|
||
|
|
thread_id=thread_id,
|
||
|
|
)
|
||
|
|
finally:
|
||
|
|
if file_path and media_url.startswith(("http://", "https://")):
|
||
|
|
import os
|
||
|
|
|
||
|
|
try:
|
||
|
|
os.unlink(file_path)
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
async def send_typing(self, target_id: str, thread_id: str | None = None) -> None:
|
||
|
|
gateway = self._gateway
|
||
|
|
if gateway is None:
|
||
|
|
return
|
||
|
|
await send_xmpp_typing(gateway, target_id, composing=True)
|
||
|
|
|
||
|
|
# ── StatusProtocol ──────────────────────────────────
|
||
|
|
|
||
|
|
async def probe(self, account: dict | None = None) -> bool:
|
||
|
|
if account is not None:
|
||
|
|
return await probe_xmpp(
|
||
|
|
account.get("jid", ""),
|
||
|
|
account.get("host", ""),
|
||
|
|
account.get("port", 5222),
|
||
|
|
account.get("use_ssl", False),
|
||
|
|
)
|
||
|
|
if self._gateway is not None:
|
||
|
|
return self._gateway.is_connected
|
||
|
|
return False
|
||
|
|
|
||
|
|
def build_summary(self, snapshot: object) -> dict:
|
||
|
|
return build_xmpp_status_summary(snapshot)
|
||
|
|
|
||
|
|
# ── SecurityProtocol ────────────────────────────────
|
||
|
|
|
||
|
|
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
|
||
|
|
account = self._account
|
||
|
|
if account is None:
|
||
|
|
return False
|
||
|
|
allowlist = account.allow_from if channel_type == "direct" else account.group_allow_from
|
||
|
|
return check_xmpp_allowlist(peer_id, allowlist)
|
||
|
|
|
||
|
|
def resolve_dm_policy(self) -> dict:
|
||
|
|
account = self._account
|
||
|
|
if account is None:
|
||
|
|
return {"mode": "pairing", "allow_from": []}
|
||
|
|
return {
|
||
|
|
"mode": account.dm_policy,
|
||
|
|
"allow_from": account.allow_from,
|
||
|
|
}
|
||
|
|
|
||
|
|
def collect_warnings(self, config: dict, account_id: str | None = None, account: dict | None = None) -> list[str]:
|
||
|
|
warnings = []
|
||
|
|
acct = self._account
|
||
|
|
if acct is None and account is not None:
|
||
|
|
jid = account.get("jid", "")
|
||
|
|
if not jid:
|
||
|
|
warnings.append("XMPP JID is not configured — DM and group chat will not work")
|
||
|
|
elif "@" not in jid:
|
||
|
|
warnings.append(f"XMPP JID '{jid}' does not appear to be a valid JID (missing @)")
|
||
|
|
elif acct is not None:
|
||
|
|
warnings.extend(collect_xmpp_security_warnings(acct))
|
||
|
|
return warnings
|
||
|
|
|
||
|
|
# ── PairingProtocol ─────────────────────────────────
|
||
|
|
|
||
|
|
@property
|
||
|
|
def id_label(self) -> str:
|
||
|
|
return "xmppJid"
|
||
|
|
|
||
|
|
async def generate_code(self, peer_id: str) -> str:
|
||
|
|
return await generate_xmpp_pairing_code(peer_id)
|
||
|
|
|
||
|
|
async def verify_code(self, peer_id: str, code: str) -> bool:
|
||
|
|
return await verify_xmpp_pairing_code(peer_id, code)
|
||
|
|
|
||
|
|
def normalize_allow_entry(self, entry: str) -> str:
|
||
|
|
return normalize_xmpp_allow_entry(entry)
|
||
|
|
|
||
|
|
# ── GroupsProtocol ──────────────────────────────────
|
||
|
|
|
||
|
|
def resolve_require_mention(self, ctx) -> bool | None:
|
||
|
|
account = self._account
|
||
|
|
if account is None:
|
||
|
|
return True
|
||
|
|
room_jid = getattr(ctx, "group_id", "") or getattr(ctx, "group_channel", "")
|
||
|
|
if not room_jid:
|
||
|
|
return True
|
||
|
|
room_config = account.room_configs.get(room_jid)
|
||
|
|
if room_config is not None:
|
||
|
|
return room_config.get("require_mention", True)
|
||
|
|
return True
|
||
|
|
|
||
|
|
async def list_groups(self, config: dict) -> list:
|
||
|
|
from yuxi.channel.protocols import DirectoryGroup
|
||
|
|
|
||
|
|
xmpp_cfg = config.get("channels", {}).get("xmpp", {})
|
||
|
|
account = resolve_xmpp_account(xmpp_cfg)
|
||
|
|
groups = []
|
||
|
|
seen = set()
|
||
|
|
for room in account.rooms:
|
||
|
|
if room in seen:
|
||
|
|
continue
|
||
|
|
seen.add(room)
|
||
|
|
groups.append(DirectoryGroup(id=room, display_name=room, kind="group"))
|
||
|
|
for room_jid in account.room_configs:
|
||
|
|
if room_jid in seen:
|
||
|
|
continue
|
||
|
|
seen.add(room_jid)
|
||
|
|
groups.append(DirectoryGroup(id=room_jid, display_name=room_jid, kind="group"))
|
||
|
|
return groups
|
||
|
|
|
||
|
|
# ── DirectoryProtocol ───────────────────────────────
|
||
|
|
|
||
|
|
async def list_peers(self, config: dict) -> list:
|
||
|
|
from yuxi.channel.protocols import DirectoryPeer
|
||
|
|
|
||
|
|
xmpp_cfg = config.get("channels", {}).get("xmpp", {})
|
||
|
|
account = resolve_xmpp_account(xmpp_cfg)
|
||
|
|
peers = []
|
||
|
|
seen = set()
|
||
|
|
for entry in account.allow_from:
|
||
|
|
if entry in seen or entry == "*":
|
||
|
|
continue
|
||
|
|
seen.add(entry)
|
||
|
|
peers.append(DirectoryPeer(id=entry, display_name=entry, kind="user"))
|
||
|
|
return peers
|
||
|
|
|
||
|
|
# ── MessagingProtocol ───────────────────────────────
|
||
|
|
|
||
|
|
def extract_thread_id(self, msg: object) -> str | None:
|
||
|
|
if hasattr(msg, "metadata") and isinstance(msg.metadata, dict):
|
||
|
|
tid = msg.metadata.get("thread_id")
|
||
|
|
if tid:
|
||
|
|
return tid
|
||
|
|
if hasattr(msg, "message_thread_id"):
|
||
|
|
return msg.message_thread_id
|
||
|
|
return None
|
||
|
|
|
||
|
|
def resolve_session(self, msg: object):
|
||
|
|
from yuxi.channel.message.models import PeerKind
|
||
|
|
from yuxi.channel.protocols import SessionResolution
|
||
|
|
|
||
|
|
if hasattr(msg, "sender") and hasattr(msg.sender, "kind"):
|
||
|
|
sender_id = msg.sender.id if hasattr(msg.sender, "id") else ""
|
||
|
|
label = msg.sender.display_name if hasattr(msg.sender, "display_name") else ""
|
||
|
|
|
||
|
|
if msg.sender.kind == PeerKind.DIRECT:
|
||
|
|
account = self._account
|
||
|
|
scope = account.dm_session_scope if account else "per-user"
|
||
|
|
if scope == "per-user":
|
||
|
|
conv_id = sender_id
|
||
|
|
else:
|
||
|
|
room_jid = ""
|
||
|
|
if hasattr(msg, "metadata") and isinstance(msg.metadata, dict):
|
||
|
|
room_jid = msg.metadata.get("room_jid", "")
|
||
|
|
conv_id = f"{sender_id}:{room_jid}" if room_jid else sender_id
|
||
|
|
return SessionResolution(kind="direct", conversation_id=conv_id, label=label)
|
||
|
|
|
||
|
|
gid = ""
|
||
|
|
if hasattr(msg, "group") and msg.group:
|
||
|
|
if hasattr(msg.group, "id"):
|
||
|
|
gid = msg.group.id
|
||
|
|
if hasattr(msg, "metadata") and isinstance(msg.metadata, dict):
|
||
|
|
gid = msg.metadata.get("room_jid", gid)
|
||
|
|
|
||
|
|
return SessionResolution(kind="group", conversation_id=gid or "unknown")
|
||
|
|
|
||
|
|
# ── AgentPromptProtocol ──────────────────────────────
|
||
|
|
|
||
|
|
def build_system_prompt(self, context) -> str | None:
|
||
|
|
group_name = getattr(context, "group_name", "") or ""
|
||
|
|
peer_name = getattr(context, "peer_name", "") or ""
|
||
|
|
lines = [
|
||
|
|
"You are interacting on XMPP (Jabber), a federated chat protocol.",
|
||
|
|
"XMPP supports plain text messages. Use simple text formatting.",
|
||
|
|
"Messages are split into chunks of 800-2000 characters for block streaming.",
|
||
|
|
"In MUC group chats, users must @mention the bot to trigger a reply.",
|
||
|
|
]
|
||
|
|
if peer_name:
|
||
|
|
lines.append(f"You are in a DM with: {peer_name}")
|
||
|
|
if group_name:
|
||
|
|
lines.append(f"Current MUC room: {group_name}")
|
||
|
|
return "\n".join(lines)
|
||
|
|
|
||
|
|
def build_context_note(self, context) -> str:
|
||
|
|
group_name = getattr(context, "group_name", "") or ""
|
||
|
|
peer_name = getattr(context, "peer_name", "") or ""
|
||
|
|
if group_name and peer_name:
|
||
|
|
return f"[XMPP | {group_name} | {peer_name}]"
|
||
|
|
if group_name:
|
||
|
|
return f"[XMPP | {group_name}]"
|
||
|
|
if peer_name:
|
||
|
|
return f"[XMPP | DM: {peer_name}]"
|
||
|
|
return "[XMPP]"
|
||
|
|
|
||
|
|
@property
|
||
|
|
def channel_format_instructions(self) -> str | None:
|
||
|
|
return (
|
||
|
|
"XMPP supports plain text messages. Use simple text formatting with "
|
||
|
|
"paragraphs separated by blank lines. Messages are split into chunks "
|
||
|
|
"of 800-2000 characters. No HTML or Markdown rendering is guaranteed."
|
||
|
|
)
|
||
|
|
|
||
|
|
# ── LifecycleProtocol ───────────────────────────────
|
||
|
|
|
||
|
|
@property
|
||
|
|
def config_prefixes(self) -> list[str]:
|
||
|
|
return ["channels.xmpp"]
|
||
|
|
|
||
|
|
async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None:
|
||
|
|
self._config = next_cfg
|
||
|
|
|
||
|
|
# ── ErrorHandlingProtocol ────────────────────────────
|
||
|
|
|
||
|
|
def classify_error(self, error: BaseException):
|
||
|
|
from yuxi.channel.protocols import ClassifiedError, ErrorSeverity
|
||
|
|
|
||
|
|
condition = getattr(error, "condition", "")
|
||
|
|
text = getattr(error, "text", str(error))
|
||
|
|
kind, message, retry_after = classify_xmpp_error(str(condition), str(text))
|
||
|
|
|
||
|
|
severity = {
|
||
|
|
"retryable": ErrorSeverity.RETRYABLE,
|
||
|
|
"auth": ErrorSeverity.FORBIDDEN,
|
||
|
|
"forbidden": ErrorSeverity.FORBIDDEN,
|
||
|
|
"not_found": ErrorSeverity.FATAL,
|
||
|
|
"not_acceptable": ErrorSeverity.FATAL,
|
||
|
|
"remote_server_error": ErrorSeverity.RETRYABLE,
|
||
|
|
"service_unavailable": ErrorSeverity.RETRYABLE,
|
||
|
|
"resource_constraint": ErrorSeverity.RATE_LIMITED,
|
||
|
|
"rate_limited": ErrorSeverity.RATE_LIMITED,
|
||
|
|
"fatal": ErrorSeverity.FATAL,
|
||
|
|
}.get(kind, ErrorSeverity.FATAL)
|
||
|
|
|
||
|
|
return ClassifiedError(
|
||
|
|
severity=severity,
|
||
|
|
retry_after_ms=int((retry_after or 0) * 1000),
|
||
|
|
error_message=message,
|
||
|
|
original_error=error,
|
||
|
|
)
|
||
|
|
|
||
|
|
def is_retryable(self, error: BaseException) -> bool:
|
||
|
|
condition = getattr(error, "condition", "")
|
||
|
|
kind, _, _ = classify_xmpp_error(str(condition), str(error))
|
||
|
|
return _is_xmpp_retryable(kind)
|
||
|
|
|
||
|
|
# ── DedupeProtocol ───────────────────────────────────
|
||
|
|
|
||
|
|
def is_duplicate(self, key: str) -> bool:
|
||
|
|
if self._gateway is not None:
|
||
|
|
return self._gateway.dedupe.has(key)
|
||
|
|
return False
|
||
|
|
|
||
|
|
def mark_seen(self, key: str) -> None:
|
||
|
|
if self._gateway is not None:
|
||
|
|
self._gateway.dedupe.add(key)
|
||
|
|
|
||
|
|
def reset(self) -> None:
|
||
|
|
if self._gateway is not None:
|
||
|
|
self._gateway.dedupe.__init__(max_size=2000, ttl_seconds=300)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def ttl_seconds(self) -> int:
|
||
|
|
return 300
|
||
|
|
|
||
|
|
# ── FormatProtocol ───────────────────────────────────
|
||
|
|
|
||
|
|
def sanitize_text(self, text: str, payload: object | None = None) -> str:
|
||
|
|
return text.strip()
|
||
|
|
|
||
|
|
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
|
||
|
|
return chunk_text(text, min_chars=limit // 2, max_chars=limit)
|
||
|
|
|
||
|
|
def markdown_to_native(self, md_text: str) -> dict | str:
|
||
|
|
import re
|
||
|
|
|
||
|
|
result = re.sub(r"\*\*(.+?)\*\*", r"\1", md_text)
|
||
|
|
result = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"\1", result)
|
||
|
|
result = re.sub(r"`([^`]+)`", r"\1", result)
|
||
|
|
result = re.sub(r"```[\s\S]*?```", "", result)
|
||
|
|
result = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", result)
|
||
|
|
result = re.sub(r"^#{1,6}\s+", "", result, flags=re.MULTILINE)
|
||
|
|
result = re.sub(r"^\s*[-*+]\s+", "• ", result, flags=re.MULTILINE)
|
||
|
|
result = re.sub(r"\n{3,}", "\n\n", result)
|
||
|
|
return result.strip()
|
||
|
|
|
||
|
|
# ── MentionsProtocol ─────────────────────────────────
|
||
|
|
|
||
|
|
def extract_mentions(self, raw_message: dict) -> list[str]:
|
||
|
|
from yuxi.channel.extensions.xmpp.stanza_utils import extract_muc_info
|
||
|
|
|
||
|
|
msg_type = raw_message.get("type", "")
|
||
|
|
_, muc_nick = extract_muc_info(raw_message, msg_type)
|
||
|
|
body = raw_message.get("body", "")
|
||
|
|
from_jid = str(raw_message.get("from", ""))
|
||
|
|
from_bare = from_jid.split("/")[0]
|
||
|
|
|
||
|
|
mentions = []
|
||
|
|
if muc_nick and body:
|
||
|
|
import re
|
||
|
|
|
||
|
|
found = re.findall(r"@(\S+)", body)
|
||
|
|
mentions.extend(found)
|
||
|
|
if "@" in from_bare:
|
||
|
|
mentions.append(from_bare)
|
||
|
|
return mentions
|
||
|
|
|
||
|
|
def strip_mentions(self, text: str, ctx: object, config: dict | None = None, agent_id: str | None = None) -> str:
|
||
|
|
import re
|
||
|
|
|
||
|
|
return re.sub(r"@\S+\s*", "", text).strip()
|
||
|
|
|
||
|
|
|
||
|
|
def _check_xmpp_security(unified_msg, account: ResolvedXmppAccount) -> bool:
|
||
|
|
sender_id = unified_msg.sender.id if hasattr(unified_msg, "sender") else ""
|
||
|
|
|
||
|
|
if unified_msg.group is None:
|
||
|
|
policy = account.dm_policy
|
||
|
|
if policy == "disabled":
|
||
|
|
logger.debug("XMPP DM rejected: DM policy is disabled")
|
||
|
|
return False
|
||
|
|
if policy == "allowlist":
|
||
|
|
if not check_xmpp_allowlist(sender_id, account.allow_from):
|
||
|
|
logger.debug("XMPP DM rejected: '%s' not in allowlist", sender_id)
|
||
|
|
return False
|
||
|
|
return True
|
||
|
|
|
||
|
|
policy = account.group_policy
|
||
|
|
if policy == "disabled":
|
||
|
|
logger.debug("XMPP group message rejected: group policy is disabled")
|
||
|
|
return False
|
||
|
|
|
||
|
|
was_mentioned = unified_msg.metadata.get("was_mentioned", False) if hasattr(unified_msg, "metadata") else False
|
||
|
|
if not was_mentioned:
|
||
|
|
logger.debug("XMPP group message rejected: @mention required")
|
||
|
|
return False
|
||
|
|
|
||
|
|
if policy == "allowlist":
|
||
|
|
gid = unified_msg.group.id if unified_msg.group else ""
|
||
|
|
allowlist = account.group_allow_from
|
||
|
|
room_config = account.room_configs.get(gid, {})
|
||
|
|
if room_config.get("allow_from"):
|
||
|
|
allowlist = room_config["allow_from"]
|
||
|
|
if not check_xmpp_allowlist(sender_id, allowlist):
|
||
|
|
logger.debug("XMPP group message rejected: '%s' not in allowlist for %s", sender_id, gid)
|
||
|
|
return False
|
||
|
|
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def _ext_from_url(url: str) -> str:
|
||
|
|
path = urlparse(url).path
|
||
|
|
ext = path.rsplit(".", 1)[-1] if "." in path else ""
|
||
|
|
return f".{ext}" if ext else ".bin"
|