Initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
from opc.channels.base import BaseChannel
|
||||
from opc.channels.manager import ChannelManager
|
||||
|
||||
__all__ = ["BaseChannel", "ChannelManager"]
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Base channel interface for external messaging platforms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.session import ChannelSessionMapping
|
||||
from opc.core.models import SystemMessage, UserMessage
|
||||
from opc.layer0_interaction.message_bus import MessageBus
|
||||
|
||||
|
||||
class BaseChannel(ABC):
|
||||
name: str = "base"
|
||||
dependency_name: str | None = None
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
self.config = config
|
||||
self.bus = bus
|
||||
self._running = False
|
||||
self._started_at: datetime | None = None
|
||||
self._last_error: str = ""
|
||||
self._status_reason: str = ""
|
||||
self.last_outbound: dict[str, Any] | None = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def last_error(self) -> str:
|
||||
return self._last_error
|
||||
|
||||
def set_status_reason(self, reason: str = "") -> None:
|
||||
self._status_reason = reason.strip()
|
||||
|
||||
def set_last_error(self, error: Exception | str | None) -> None:
|
||||
self._last_error = str(error or "").strip()
|
||||
if self._last_error:
|
||||
logger.warning("{} runtime error: {}", self.name, self._last_error)
|
||||
else:
|
||||
logger.debug("{} runtime error cleared", self.name)
|
||||
|
||||
def mark_started(self) -> None:
|
||||
self._running = True
|
||||
self._started_at = datetime.now()
|
||||
self.set_last_error("")
|
||||
|
||||
def mark_stopped(self) -> None:
|
||||
self._running = False
|
||||
|
||||
def get_required_config_fields(self) -> list[str]:
|
||||
return []
|
||||
|
||||
def get_missing_config_fields(self) -> list[str]:
|
||||
missing: list[str] = []
|
||||
for field_name in self.get_required_config_fields():
|
||||
value = getattr(self.config, field_name, None)
|
||||
if value is None:
|
||||
missing.append(field_name)
|
||||
elif isinstance(value, str) and not value.strip():
|
||||
missing.append(field_name)
|
||||
elif isinstance(value, list) and not value:
|
||||
missing.append(field_name)
|
||||
return missing
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return not self.get_missing_config_fields()
|
||||
|
||||
def is_allowed(self, sender_id: str) -> bool:
|
||||
allow_list = getattr(self.config, "allow_from", [])
|
||||
if not allow_list:
|
||||
logger.warning("{}: allow_from is empty — all access denied", self.name)
|
||||
return False
|
||||
if "*" in allow_list:
|
||||
return True
|
||||
sender = str(sender_id)
|
||||
return sender in allow_list or any(part in allow_list for part in sender.split("|") if part)
|
||||
|
||||
def describe_capability(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"dependency": self.dependency_name,
|
||||
"running": self.is_running,
|
||||
"configured": self.is_configured(),
|
||||
"missing_config": self.get_missing_config_fields(),
|
||||
"last_error": self.last_error,
|
||||
"status_reason": self._status_reason,
|
||||
"started_at": self._started_at.isoformat() if self._started_at else "",
|
||||
}
|
||||
|
||||
def should_accept_inbound(self, normalized: dict[str, Any]) -> bool:
|
||||
sender_id = str(normalized.get("sender_id", "") or "")
|
||||
return bool(sender_id) and self.is_allowed(sender_id)
|
||||
|
||||
def build_session_key_override(self, normalized: dict[str, Any]) -> str | None:
|
||||
_ = normalized
|
||||
return None
|
||||
|
||||
def build_inbound_metadata(self, normalized: dict[str, Any], attachments: list[Any]) -> dict[str, Any]:
|
||||
metadata = dict(normalized.get("metadata", {}) or {})
|
||||
metadata.setdefault("chat_id", str(normalized.get("chat_id", "") or ""))
|
||||
metadata.setdefault("sender_id", str(normalized.get("sender_id", "") or ""))
|
||||
metadata.setdefault("reply_to", str(normalized.get("reply_to", "") or ""))
|
||||
metadata.setdefault("thread_id", str(normalized.get("thread_id", "") or ""))
|
||||
metadata["attachments"] = list(attachments or [])
|
||||
return metadata
|
||||
|
||||
def map_session(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
thread_id: str | None = None,
|
||||
reply_to: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, str]:
|
||||
route = ChannelSessionMapping.derive(
|
||||
channel=self.name,
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
reply_to=reply_to,
|
||||
metadata=metadata,
|
||||
)
|
||||
return {
|
||||
"session_id": route.session_id,
|
||||
"chat_id": route.chat_id,
|
||||
"thread_id": route.thread_id,
|
||||
"reply_to": route.reply_to,
|
||||
}
|
||||
|
||||
async def publish_inbound(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
attachments: list[Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
session_key: str | None = None,
|
||||
) -> None:
|
||||
if not self.is_allowed(sender_id):
|
||||
logger.warning("Access denied for sender {} on channel {}", sender_id, self.name)
|
||||
return
|
||||
meta = dict(metadata or {})
|
||||
route = self.map_session(
|
||||
chat_id=chat_id,
|
||||
thread_id=str(meta.get("thread_id", "") or ""),
|
||||
reply_to=str(meta.get("reply_to", "") or ""),
|
||||
metadata=meta,
|
||||
)
|
||||
await self.bus.publish_inbound(
|
||||
UserMessage(
|
||||
channel=self.name,
|
||||
user_id=str(sender_id),
|
||||
content=content,
|
||||
attachments=attachments or [],
|
||||
session_id=session_key or route["session_id"],
|
||||
metadata={
|
||||
"chat_id": route["chat_id"],
|
||||
"sender_id": str(sender_id),
|
||||
"reply_to": route["reply_to"],
|
||||
"thread_id": route["thread_id"],
|
||||
"attachments": list(attachments or []),
|
||||
**meta,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def publish_normalized(self, normalized: dict[str, Any]) -> bool:
|
||||
sender_id = str(normalized.get("sender_id", "") or "")
|
||||
chat_id = str(normalized.get("chat_id", "") or "")
|
||||
content = str(normalized.get("content", "") or "")
|
||||
attachments = list(normalized.get("attachments", []) or [])
|
||||
if not sender_id or not chat_id:
|
||||
logger.debug("{} inbound payload missing sender/chat identifiers: {}", self.name, normalized)
|
||||
return False
|
||||
if not self.should_accept_inbound(normalized):
|
||||
logger.debug("{} inbound payload rejected by policy: {}", self.name, normalized)
|
||||
return False
|
||||
await self.publish_inbound(
|
||||
sender_id=sender_id,
|
||||
chat_id=chat_id,
|
||||
content=content,
|
||||
attachments=attachments,
|
||||
metadata=self.build_inbound_metadata(normalized, attachments),
|
||||
session_key=self.build_session_key_override(normalized),
|
||||
)
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
async def start(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def stop(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def send(self, message: SystemMessage) -> None:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.provider_base import OptionalDependencyChannel
|
||||
from opc.core.models import SystemMessage
|
||||
|
||||
|
||||
class _DingTalkHandler:
|
||||
def __init__(self, channel: "DingTalkChannel"):
|
||||
self.channel = channel
|
||||
|
||||
async def process(self, message: Any) -> None:
|
||||
chatbot = self.channel._parse_chatbot_message(message)
|
||||
if chatbot is None:
|
||||
return
|
||||
await self.channel._on_message(
|
||||
content=str(getattr(getattr(chatbot, "text", None), "content", "") or getattr(chatbot, "content", "") or ""),
|
||||
sender_id=str(getattr(chatbot, "sender_staff_id", "") or ""),
|
||||
sender_name=str(getattr(chatbot, "sender_nick", "") or getattr(chatbot, "sender_staff_id", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
class DingTalkChannel(OptionalDependencyChannel):
|
||||
name = "dingtalk"
|
||||
required_package = "dingtalk_stream"
|
||||
delivery_mode = "socket"
|
||||
|
||||
def __init__(self, config: Any, bus: Any):
|
||||
super().__init__(config, bus)
|
||||
self._client: Any = None
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._access_token: str | None = None
|
||||
self._token_expiry: float = 0.0
|
||||
|
||||
def get_required_config_fields(self) -> list[str]:
|
||||
return ["client_id", "client_secret"]
|
||||
|
||||
def normalize_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
sender_id = str(payload.get("senderStaffId", "") or payload.get("sender_id", "") or "")
|
||||
return {
|
||||
"sender_id": sender_id,
|
||||
"chat_id": sender_id,
|
||||
"content": str(payload.get("text", {}).get("content", "") or payload.get("content", "") or ""),
|
||||
"thread_id": "",
|
||||
"reply_to": str(payload.get("msgId", "") or ""),
|
||||
"metadata": {"sender_name": str(payload.get("senderNick", "") or "")},
|
||||
}
|
||||
|
||||
async def start(self) -> None:
|
||||
await super().start()
|
||||
from dingtalk_stream import Credential, DingTalkStreamClient
|
||||
from dingtalk_stream.chatbot import ChatbotMessage
|
||||
|
||||
self._http = httpx.AsyncClient(timeout=30)
|
||||
self._client = DingTalkStreamClient(Credential(self.config.client_id, self.config.client_secret))
|
||||
self._client.register_callback_handler(ChatbotMessage.TOPIC, _DingTalkHandler(self))
|
||||
self._runner_task = asyncio.create_task(self._run_with_restarts(self._client.start, label="stream"))
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._http is not None:
|
||||
await self._http.aclose()
|
||||
self._http = None
|
||||
await super().stop()
|
||||
|
||||
def _parse_chatbot_message(self, message: Any) -> Any:
|
||||
try:
|
||||
from dingtalk_stream.chatbot import ChatbotMessage
|
||||
|
||||
data = getattr(message, "data", None)
|
||||
if isinstance(data, dict):
|
||||
return ChatbotMessage.from_dict(data)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _get_access_token(self) -> str | None:
|
||||
if self._access_token and time.time() < self._token_expiry:
|
||||
return self._access_token
|
||||
if self._http is None:
|
||||
return None
|
||||
resp = await self._http.post(
|
||||
"https://api.dingtalk.com/v1.0/oauth2/accessToken",
|
||||
json={"appKey": self.config.client_id, "appSecret": self.config.client_secret},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._access_token = data.get("accessToken")
|
||||
self._token_expiry = time.time() + int(data.get("expireIn", 7200)) - 60
|
||||
return self._access_token
|
||||
|
||||
async def _send_markdown_text(self, token: str, chat_id: str, content: str) -> None:
|
||||
assert self._http is not None
|
||||
url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
|
||||
headers = {"x-acs-dingtalk-access-token": token}
|
||||
payload = {"robotCode": self.config.client_id, "userIds": [chat_id], "msgKey": "sampleMarkdown", "msgParam": {"title": "OpenOPC", "text": content}}
|
||||
resp = await self._http.post(url, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None:
|
||||
payload = self.normalize_event({"content": content, "sender_id": sender_id, "senderNick": sender_name})
|
||||
await self.publish_normalized(payload)
|
||||
|
||||
async def send(self, message: SystemMessage) -> None:
|
||||
await super().send(message)
|
||||
token = await self._get_access_token()
|
||||
if not token:
|
||||
logger.warning("dingtalk token unavailable")
|
||||
return
|
||||
chat_id = str((message.metadata or {}).get("chat_id") or message.session_id or "")
|
||||
if message.content.strip():
|
||||
await self._send_markdown_text(token, chat_id, message.content.strip())
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.provider_base import SocketChannel
|
||||
from opc.core.models import SystemMessage
|
||||
|
||||
|
||||
class DiscordChannel(SocketChannel):
|
||||
name = "discord"
|
||||
required_package = "discord"
|
||||
|
||||
def __init__(self, config: Any, bus: Any):
|
||||
super().__init__(config, bus)
|
||||
self._client: Any = None
|
||||
self._bot_user_id: str | None = None
|
||||
|
||||
def get_required_config_fields(self) -> list[str]:
|
||||
return ["token"]
|
||||
|
||||
def normalize_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
author = payload.get("author", {})
|
||||
channel_id = str(payload.get("channel_id", "") or "")
|
||||
thread_id = ""
|
||||
if payload.get("is_thread"):
|
||||
thread_id = str(payload.get("thread_id", channel_id) or "")
|
||||
attachments = []
|
||||
for item in list(payload.get("attachments", []) or []):
|
||||
attachments.append(
|
||||
{
|
||||
"url": str(item.get("url", "") or ""),
|
||||
"filename": str(item.get("filename", "") or ""),
|
||||
"content_type": str(item.get("content_type", "") or ""),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"sender_id": str(author.get("id", "")),
|
||||
"chat_id": channel_id,
|
||||
"content": str(payload.get("content", "") or ""),
|
||||
"thread_id": thread_id,
|
||||
"reply_to": str((payload.get("message_reference") or {}).get("message_id", "") or ""),
|
||||
"attachments": attachments,
|
||||
"metadata": {
|
||||
"channel_type": str(payload.get("channel_type", "") or ""),
|
||||
"guild_id": str(payload.get("guild_id", "") or ""),
|
||||
"message_id": str(payload.get("id", "") or ""),
|
||||
"mentions_bot": bool(payload.get("mentions_bot", False)),
|
||||
},
|
||||
}
|
||||
|
||||
def should_accept_inbound(self, normalized: dict[str, Any]) -> bool:
|
||||
sender_id = str(normalized.get("sender_id", "") or "")
|
||||
if not sender_id or sender_id == self._bot_user_id:
|
||||
return False
|
||||
metadata = dict(normalized.get("metadata", {}) or {})
|
||||
channel_type = str(metadata.get("channel_type", "") or "")
|
||||
if channel_type == "dm":
|
||||
return self.is_allowed(sender_id)
|
||||
if self.config.group_policy == "open":
|
||||
return self.is_allowed(sender_id)
|
||||
if self.config.group_policy == "allowlist":
|
||||
return str(normalized.get("chat_id", "") or "") in list(getattr(self.config, "group_allow_from", []) or [])
|
||||
return bool(metadata.get("mentions_bot"))
|
||||
|
||||
async def run_socket_forever(self) -> None:
|
||||
import discord
|
||||
|
||||
self._client = self._build_client(discord)
|
||||
await self._client.start(self.config.token)
|
||||
|
||||
def _build_client(self, discord_module: Any) -> Any:
|
||||
intents = discord_module.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.guild_messages = True
|
||||
intents.dm_messages = True
|
||||
intents.guilds = True
|
||||
channel = self
|
||||
|
||||
class OPCDiscordClient(discord_module.Client):
|
||||
async def on_ready(self) -> None:
|
||||
channel._bot_user_id = str(self.user.id) if self.user else None
|
||||
logger.info("discord bot connected as {}", self.user)
|
||||
|
||||
async def on_message(self, message: Any) -> None:
|
||||
if not message or not getattr(message, "author", None):
|
||||
return
|
||||
payload = {
|
||||
"id": str(message.id),
|
||||
"author": {"id": str(message.author.id)},
|
||||
"channel_id": str(message.channel.id),
|
||||
"guild_id": str(getattr(message.guild, "id", "") or ""),
|
||||
"content": str(message.content or ""),
|
||||
"is_thread": bool(getattr(message.channel, "thread", None) or getattr(message.channel, "parent", None)),
|
||||
"thread_id": str(getattr(message.channel, "id", "") if isinstance(message.channel, discord_module.Thread) else ""),
|
||||
"channel_type": "dm" if isinstance(message.channel, discord_module.DMChannel) else "guild",
|
||||
"mentions_bot": bool(self.user and self.user in getattr(message, "mentions", [])),
|
||||
"attachments": [
|
||||
{
|
||||
"url": str(att.url),
|
||||
"filename": str(att.filename),
|
||||
"content_type": str(att.content_type or ""),
|
||||
}
|
||||
for att in list(getattr(message, "attachments", []) or [])
|
||||
],
|
||||
}
|
||||
if getattr(message, "reference", None) and getattr(message.reference, "message_id", None):
|
||||
payload["message_reference"] = {"message_id": str(message.reference.message_id)}
|
||||
await channel.publish_normalized(channel.normalize_event(payload))
|
||||
|
||||
return OPCDiscordClient(intents=intents)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._client is not None:
|
||||
try:
|
||||
await self._client.close()
|
||||
except Exception:
|
||||
logger.exception("discord client close failed")
|
||||
self._client = None
|
||||
await super().stop()
|
||||
|
||||
async def send(self, message: SystemMessage) -> None:
|
||||
await super().send(message)
|
||||
if self._client is None:
|
||||
logger.warning("discord client not connected")
|
||||
return
|
||||
metadata = dict(message.metadata or {})
|
||||
channel_id = int(str(metadata.get("chat_id") or message.session_id))
|
||||
channel = self._client.get_channel(channel_id) or await self._client.fetch_channel(channel_id)
|
||||
reference = None
|
||||
reply_to = str(metadata.get("reply_to", "") or "")
|
||||
if reply_to:
|
||||
try:
|
||||
reference = await channel.fetch_message(int(reply_to))
|
||||
except Exception:
|
||||
reference = None
|
||||
files = []
|
||||
for attachment in list(metadata.get("attachments", []) or []):
|
||||
if isinstance(attachment, str) and Path(attachment).is_file():
|
||||
import discord
|
||||
|
||||
files.append(discord.File(attachment))
|
||||
if files:
|
||||
await channel.send(content=message.content or None, files=files, reference=reference)
|
||||
elif message.content.strip():
|
||||
await channel.send(content=message.content, reference=reference)
|
||||
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import imaplib
|
||||
import smtplib
|
||||
import ssl
|
||||
from email import policy
|
||||
from email.header import decode_header, make_header
|
||||
from email.message import EmailMessage
|
||||
from email.parser import BytesParser
|
||||
from email.utils import parseaddr
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.provider_base import PollingChannel
|
||||
from opc.core.models import SystemMessage
|
||||
from opc.layer4_tools.output_budget import clip_text, persist_tool_result
|
||||
|
||||
|
||||
class EmailChannel(PollingChannel):
|
||||
name = "email"
|
||||
required_package = None
|
||||
|
||||
def __init__(self, config: Any, bus: Any):
|
||||
super().__init__(config, bus)
|
||||
self._processed_uids: set[str] = set()
|
||||
self._last_subject_by_chat: dict[str, str] = {}
|
||||
self._last_message_id_by_chat: dict[str, str] = {}
|
||||
|
||||
def get_required_config_fields(self) -> list[str]:
|
||||
return [
|
||||
"imap_host",
|
||||
"imap_username",
|
||||
"imap_password",
|
||||
"smtp_host",
|
||||
"smtp_username",
|
||||
"smtp_password",
|
||||
]
|
||||
|
||||
def get_poll_interval_seconds(self) -> float:
|
||||
return max(5.0, float(self.config.poll_interval_seconds))
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self.config.consent_granted:
|
||||
raise RuntimeError("email channel requires `consent_granted: true` before polling or sending")
|
||||
await super().start()
|
||||
|
||||
def normalize_email(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
sender = str(payload.get("from", "") or "")
|
||||
thread_id = str(payload.get("thread_id", "") or "")
|
||||
return {
|
||||
"sender_id": sender,
|
||||
"chat_id": thread_id or sender,
|
||||
"content": str(payload.get("body", "") or payload.get("subject", "")),
|
||||
"thread_id": thread_id,
|
||||
"reply_to": str(payload.get("message_id", "") or ""),
|
||||
"metadata": {
|
||||
"email_subject": str(payload.get("subject", "") or ""),
|
||||
"email_from": sender,
|
||||
"body_truncated": bool(payload.get("body_truncated", False)),
|
||||
"body_omitted_chars": int(payload.get("body_omitted_chars", 0) or 0),
|
||||
"full_body_path": str(payload.get("full_body_path", "") or ""),
|
||||
},
|
||||
}
|
||||
|
||||
async def poll_once(self) -> None:
|
||||
messages = await asyncio.to_thread(self._fetch_new_messages)
|
||||
for item in messages:
|
||||
sender = str(item.get("from", "") or "")
|
||||
if sender:
|
||||
subject = str(item.get("subject", "") or "")
|
||||
if subject:
|
||||
self._last_subject_by_chat[sender] = subject
|
||||
message_id = str(item.get("message_id", "") or "")
|
||||
if message_id:
|
||||
self._last_message_id_by_chat[sender] = message_id
|
||||
await self.publish_normalized(self.normalize_email(item))
|
||||
|
||||
async def send(self, message: SystemMessage) -> None:
|
||||
await super().send(message)
|
||||
if not self.config.consent_granted:
|
||||
logger.warning("skip email send because consent_granted is false")
|
||||
return
|
||||
to_addr = str((message.metadata or {}).get("chat_id") or message.session_id or "").strip()
|
||||
if not to_addr:
|
||||
logger.warning("email outbound missing recipient address")
|
||||
return
|
||||
|
||||
metadata = dict(message.metadata or {})
|
||||
is_reply = to_addr in self._last_subject_by_chat
|
||||
force_send = bool(metadata.get("force_send"))
|
||||
if is_reply and not self.config.auto_reply_enabled and not force_send:
|
||||
logger.info("skip automatic email reply to {} because auto_reply_enabled is false", to_addr)
|
||||
return
|
||||
|
||||
base_subject = self._last_subject_by_chat.get(to_addr, "OpenOPC reply")
|
||||
subject = str(metadata.get("subject", "") or "").strip() or self._reply_subject(base_subject)
|
||||
email_msg = EmailMessage()
|
||||
email_msg["From"] = self.config.from_address or self.config.smtp_username or self.config.imap_username
|
||||
email_msg["To"] = to_addr
|
||||
email_msg["Subject"] = subject
|
||||
email_msg.set_content(message.content or "")
|
||||
|
||||
in_reply_to = str(metadata.get("reply_to") or self._last_message_id_by_chat.get(to_addr, "") or "")
|
||||
if in_reply_to:
|
||||
email_msg["In-Reply-To"] = in_reply_to
|
||||
email_msg["References"] = in_reply_to
|
||||
|
||||
await asyncio.to_thread(self._smtp_send, email_msg)
|
||||
|
||||
def _smtp_send(self, email_msg: EmailMessage) -> None:
|
||||
timeout = 30
|
||||
if self.config.smtp_use_ssl:
|
||||
with smtplib.SMTP_SSL(self.config.smtp_host, self.config.smtp_port, timeout=timeout) as smtp:
|
||||
smtp.login(self.config.smtp_username, self.config.smtp_password)
|
||||
smtp.send_message(email_msg)
|
||||
return
|
||||
with smtplib.SMTP(self.config.smtp_host, self.config.smtp_port, timeout=timeout) as smtp:
|
||||
if self.config.smtp_use_tls:
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self.config.smtp_username, self.config.smtp_password)
|
||||
smtp.send_message(email_msg)
|
||||
|
||||
def _connect_imap(self) -> Any:
|
||||
if self.config.imap_use_ssl:
|
||||
return imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port)
|
||||
return imaplib.IMAP4(self.config.imap_host, self.config.imap_port)
|
||||
|
||||
def _fetch_new_messages(self) -> list[dict[str, Any]]:
|
||||
messages: list[dict[str, Any]] = []
|
||||
client = self._connect_imap()
|
||||
try:
|
||||
client.login(self.config.imap_username, self.config.imap_password)
|
||||
status, _ = client.select(self.config.imap_mailbox or "INBOX")
|
||||
if status != "OK":
|
||||
return messages
|
||||
status, data = client.search(None, "UNSEEN")
|
||||
if status != "OK" or not data:
|
||||
return messages
|
||||
for imap_id in data[0].split():
|
||||
status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
|
||||
if status != "OK" or not fetched:
|
||||
continue
|
||||
uid = self._extract_uid(fetched)
|
||||
if uid and uid in self._processed_uids:
|
||||
continue
|
||||
raw_bytes = self._extract_message_bytes(fetched)
|
||||
if not raw_bytes:
|
||||
continue
|
||||
parsed = self._parse_message_bytes(raw_bytes)
|
||||
if not parsed:
|
||||
continue
|
||||
if uid:
|
||||
self._processed_uids.add(uid)
|
||||
messages.append(parsed)
|
||||
if self.config.mark_seen:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _extract_uid(fetched: list[Any]) -> str:
|
||||
for item in fetched:
|
||||
if not isinstance(item, tuple):
|
||||
continue
|
||||
header = item[0]
|
||||
if isinstance(header, bytes):
|
||||
text = header.decode("utf-8", errors="ignore")
|
||||
if "UID " in text:
|
||||
return text.split("UID ", 1)[1].split(")", 1)[0].strip()
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_message_bytes(fetched: list[Any]) -> bytes:
|
||||
for item in fetched:
|
||||
if isinstance(item, tuple) and isinstance(item[1], bytes):
|
||||
return item[1]
|
||||
return b""
|
||||
|
||||
def _parse_message_bytes(self, raw_bytes: bytes) -> dict[str, Any] | None:
|
||||
message = BytesParser(policy=policy.default).parsebytes(raw_bytes)
|
||||
sender = parseaddr(message.get("From", ""))[1]
|
||||
if not sender:
|
||||
return None
|
||||
subject = self._decode_header_value(message.get("Subject", "")) or "(no subject)"
|
||||
body = self._extract_text_body(message).strip()
|
||||
if not body:
|
||||
body = subject
|
||||
body_clip = clip_text(
|
||||
body,
|
||||
limit=max(1, int(self.config.max_body_chars)),
|
||||
marker="email body preview truncated",
|
||||
)
|
||||
persisted = {}
|
||||
if body_clip.truncated:
|
||||
persisted = persist_tool_result(body, tool_name="email_body", extension="txt")
|
||||
thread_id = (
|
||||
str(message.get("Thread-Index", "") or "")
|
||||
or str(message.get("References", "") or "").split()[-1] if str(message.get("References", "") or "").split() else ""
|
||||
)
|
||||
if not thread_id:
|
||||
thread_id = str(message.get("Message-ID", "") or sender)
|
||||
return {
|
||||
"from": sender,
|
||||
"subject": subject,
|
||||
"body": body_clip.text,
|
||||
"body_truncated": body_clip.truncated,
|
||||
"body_omitted_chars": body_clip.omitted_chars,
|
||||
"full_body_path": persisted.get("full_output_path", ""),
|
||||
"message_id": str(message.get("Message-ID", "") or ""),
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _decode_header_value(value: str) -> str:
|
||||
try:
|
||||
return str(make_header(decode_header(value)))
|
||||
except Exception:
|
||||
return value or ""
|
||||
|
||||
def _extract_text_body(self, message: Any) -> str:
|
||||
if message.is_multipart():
|
||||
for part in message.walk():
|
||||
disposition = str(part.get_content_disposition() or "")
|
||||
content_type = str(part.get_content_type() or "")
|
||||
if disposition == "attachment":
|
||||
continue
|
||||
if content_type == "text/plain":
|
||||
payload = part.get_content()
|
||||
return payload if isinstance(payload, str) else str(payload)
|
||||
if content_type == "text/html":
|
||||
payload = part.get_content()
|
||||
if isinstance(payload, str):
|
||||
return self._html_to_text(payload)
|
||||
return ""
|
||||
payload = message.get_content()
|
||||
if isinstance(payload, str) and message.get_content_type() == "text/html":
|
||||
return self._html_to_text(payload)
|
||||
return payload if isinstance(payload, str) else str(payload)
|
||||
|
||||
@staticmethod
|
||||
def _html_to_text(value: str) -> str:
|
||||
text = value.replace("<br>", "\n").replace("<br/>", "\n").replace("</p>", "\n")
|
||||
text = html.unescape(text)
|
||||
inside = False
|
||||
out: list[str] = []
|
||||
for char in text:
|
||||
if char == "<":
|
||||
inside = True
|
||||
continue
|
||||
if char == ">":
|
||||
inside = False
|
||||
continue
|
||||
if not inside:
|
||||
out.append(char)
|
||||
return "".join(out).strip()
|
||||
|
||||
def _reply_subject(self, value: str) -> str:
|
||||
lowered = value.lower()
|
||||
prefix = self.config.subject_prefix or "Re: "
|
||||
return value if lowered.startswith(prefix.lower()) else f"{prefix}{value}"
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.provider_base import OptionalDependencyChannel
|
||||
from opc.core.models import SystemMessage
|
||||
|
||||
|
||||
class FeishuChannel(OptionalDependencyChannel):
|
||||
name = "feishu"
|
||||
required_package = "lark_oapi"
|
||||
delivery_mode = "socket"
|
||||
|
||||
def __init__(self, config: Any, bus: Any):
|
||||
super().__init__(config, bus)
|
||||
self._client: Any = None
|
||||
self._ws_client: Any = None
|
||||
self._ws_thread: threading.Thread | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._processed_message_ids: set[str] = set()
|
||||
|
||||
def get_required_config_fields(self) -> list[str]:
|
||||
return ["app_id", "app_secret"]
|
||||
|
||||
def normalize_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
event = payload.get("event", payload)
|
||||
sender = (event.get("sender") or {}).get("sender_id", {})
|
||||
message = event.get("message", {})
|
||||
raw_content = message.get("content", {})
|
||||
if isinstance(raw_content, str):
|
||||
try:
|
||||
raw_content = json.loads(raw_content)
|
||||
except json.JSONDecodeError:
|
||||
raw_content = {"text": raw_content}
|
||||
sender_id = str(sender.get("open_id", "") or "")
|
||||
chat_id = str(message.get("chat_id", "") or "")
|
||||
if str(message.get("chat_type", "") or "") == "p2p":
|
||||
chat_id = sender_id
|
||||
content = str(raw_content.get("text", "") or raw_content.get("content", "") or "")
|
||||
return {
|
||||
"sender_id": sender_id,
|
||||
"chat_id": chat_id,
|
||||
"content": content,
|
||||
"thread_id": str(message.get("thread_id", "") or ""),
|
||||
"reply_to": str(message.get("parent_id", "") or ""),
|
||||
"metadata": {
|
||||
"message_id": str(message.get("message_id", "") or ""),
|
||||
"chat_type": str(message.get("chat_type", "") or ""),
|
||||
"message_type": str(message.get("message_type", "") or ""),
|
||||
},
|
||||
}
|
||||
|
||||
async def start(self) -> None:
|
||||
await super().start()
|
||||
import lark_oapi as lark
|
||||
import lark_oapi.ws.client as lark_ws_client
|
||||
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._client = lark.Client.builder().app_id(self.config.app_id).app_secret(self.config.app_secret).build()
|
||||
event_handler = (
|
||||
lark.EventDispatcherHandler.builder(self.config.encrypt_key or "", self.config.verification_token or "")
|
||||
.register_p2_im_message_receive_v1(self._on_message_sync)
|
||||
.build()
|
||||
)
|
||||
self._ws_client = lark.ws.Client(
|
||||
self.config.app_id,
|
||||
self.config.app_secret,
|
||||
event_handler=event_handler,
|
||||
log_level=lark.LogLevel.INFO,
|
||||
)
|
||||
|
||||
def _run_ws() -> None:
|
||||
ws_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(ws_loop)
|
||||
lark_ws_client.loop = ws_loop
|
||||
try:
|
||||
while self.is_running:
|
||||
try:
|
||||
self._ws_client.start()
|
||||
except Exception as exc:
|
||||
self.set_last_error(exc)
|
||||
if self.is_running:
|
||||
import time
|
||||
|
||||
time.sleep(5)
|
||||
finally:
|
||||
ws_loop.close()
|
||||
|
||||
self._ws_thread = threading.Thread(target=_run_ws, daemon=True)
|
||||
self._ws_thread.start()
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.mark_stopped()
|
||||
await super().stop()
|
||||
|
||||
def _on_message_sync(self, data: Any) -> None:
|
||||
if self._loop and self._loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop)
|
||||
|
||||
async def _on_message(self, data: Any) -> None:
|
||||
event = getattr(data, "event", None)
|
||||
message = getattr(event, "message", None)
|
||||
sender = getattr(event, "sender", None)
|
||||
if message is None or sender is None:
|
||||
return
|
||||
message_id = str(getattr(message, "message_id", "") or "")
|
||||
if message_id and message_id in self._processed_message_ids:
|
||||
return
|
||||
if message_id:
|
||||
self._processed_message_ids.add(message_id)
|
||||
content = getattr(message, "content", "") or ""
|
||||
try:
|
||||
content_json = json.loads(content) if isinstance(content, str) and content else {}
|
||||
except json.JSONDecodeError:
|
||||
content_json = {"text": content}
|
||||
payload = {
|
||||
"event": {
|
||||
"sender": {"sender_id": {"open_id": getattr(getattr(sender, "sender_id", None), "open_id", "")}},
|
||||
"message": {
|
||||
"message_id": message_id,
|
||||
"chat_id": getattr(message, "chat_id", ""),
|
||||
"chat_type": getattr(message, "chat_type", ""),
|
||||
"message_type": getattr(message, "message_type", ""),
|
||||
"content": content_json,
|
||||
"thread_id": getattr(message, "thread_id", ""),
|
||||
"parent_id": getattr(message, "parent_id", ""),
|
||||
},
|
||||
}
|
||||
}
|
||||
await self.publish_normalized(self.normalize_event(payload))
|
||||
if message_id and self.config.react_emoji:
|
||||
await asyncio.to_thread(self._add_reaction_sync, message_id, self.config.react_emoji)
|
||||
|
||||
def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None:
|
||||
if self._client is None:
|
||||
return
|
||||
try:
|
||||
from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji
|
||||
|
||||
request = (
|
||||
CreateMessageReactionRequest.builder()
|
||||
.message_id(message_id)
|
||||
.request_body(
|
||||
CreateMessageReactionRequestBody.builder()
|
||||
.reaction_type(Emoji.builder().emoji_type(emoji_type).build())
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
self._client.im.v1.message_reaction.create(request)
|
||||
except Exception:
|
||||
logger.debug("feishu reaction add failed")
|
||||
|
||||
def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> None:
|
||||
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
|
||||
|
||||
request = (
|
||||
CreateMessageRequest.builder()
|
||||
.receive_id_type(receive_id_type)
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(receive_id)
|
||||
.msg_type(msg_type)
|
||||
.content(content)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response = self._client.im.v1.message.create(request)
|
||||
if not response.success():
|
||||
raise RuntimeError(f"feishu send failed: {response.code} {response.msg}")
|
||||
|
||||
async def send(self, message: SystemMessage) -> None:
|
||||
await super().send(message)
|
||||
if self._client is None:
|
||||
logger.warning("feishu client not connected")
|
||||
return
|
||||
metadata = dict(message.metadata or {})
|
||||
chat_id = str(metadata.get("chat_id") or message.session_id or "")
|
||||
receive_id_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
|
||||
if message.content.strip():
|
||||
body = json.dumps({"text": message.content.strip()}, ensure_ascii=False)
|
||||
await asyncio.to_thread(self._send_message_sync, receive_id_type, chat_id, "text", body)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Channel manager for OpenOPC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.base import BaseChannel
|
||||
from opc.channels.provider_registry import PROVIDER_SPECS, ordered_provider_specs
|
||||
from opc.core.config import OPCConfig
|
||||
from opc.core.models import SystemMessage
|
||||
from opc.layer0_interaction.message_bus import MessageBus
|
||||
|
||||
|
||||
class ChannelManager:
|
||||
def __init__(self, config: OPCConfig, bus: MessageBus):
|
||||
self.config = config
|
||||
self.bus = bus
|
||||
self.channels: dict[str, BaseChannel] = {}
|
||||
self.channel_errors: dict[str, str] = {}
|
||||
self._dispatch_task: asyncio.Task[Any] | None = None
|
||||
self._init_channels()
|
||||
|
||||
def _init_channels(self) -> None:
|
||||
for spec in ordered_provider_specs():
|
||||
cfg = getattr(self.config.channels, spec.name)
|
||||
if not cfg.enabled:
|
||||
continue
|
||||
channel = self._build_channel(spec.name, cfg, spec.module_name, spec.class_name)
|
||||
if channel is None:
|
||||
continue
|
||||
self.channels[spec.name] = channel
|
||||
logger.info("Channel configured: {}", spec.name)
|
||||
|
||||
def _build_channel(self, key: str, cfg: Any, module_name: str, class_name: str) -> BaseChannel | None:
|
||||
try:
|
||||
module = __import__(module_name, fromlist=[class_name])
|
||||
cls = getattr(module, class_name)
|
||||
return cls(cfg, self.bus)
|
||||
except Exception as e:
|
||||
logger.warning("Channel {} not available: {}", key, e)
|
||||
self.channel_errors[key] = str(e)
|
||||
return None
|
||||
|
||||
@property
|
||||
def enabled_channels(self) -> list[str]:
|
||||
return list(self.channels.keys())
|
||||
|
||||
def get_channel(self, name: str) -> BaseChannel | None:
|
||||
return self.channels.get(name)
|
||||
|
||||
def get_status(self, name: str) -> dict[str, Any]:
|
||||
spec = PROVIDER_SPECS[name]
|
||||
cfg = getattr(self.config.channels, name)
|
||||
enabled = bool(getattr(cfg, "enabled", False))
|
||||
channel = self.channels.get(name)
|
||||
capability = channel.describe_capability() if channel is not None else {
|
||||
"name": name,
|
||||
"delivery_mode": spec.delivery_mode,
|
||||
"available": False if spec.required_package and name in self.channel_errors else True,
|
||||
"configured": False,
|
||||
"missing_config": list(spec.required_config_fields),
|
||||
"running": False,
|
||||
"ready": False,
|
||||
"last_error": self.channel_errors.get(name, ""),
|
||||
}
|
||||
return {
|
||||
"name": name,
|
||||
"enabled": enabled,
|
||||
"delivery_mode": capability.get("delivery_mode", spec.delivery_mode),
|
||||
"available": capability.get("available", True),
|
||||
"configured": capability.get("configured", False),
|
||||
"ready": capability.get("ready", False),
|
||||
"running": capability.get("running", False),
|
||||
"missing_config": capability.get("missing_config", []),
|
||||
"last_error": capability.get("last_error", "") or self.channel_errors.get(name, ""),
|
||||
"bridge_required": spec.bridge_required,
|
||||
"extra_name": spec.extra_name,
|
||||
"required_package": spec.required_package,
|
||||
}
|
||||
|
||||
def get_all_statuses(self) -> list[dict[str, Any]]:
|
||||
return [self.get_status(spec.name) for spec in ordered_provider_specs()]
|
||||
|
||||
async def start_all(self) -> None:
|
||||
for name, channel in self.channels.items():
|
||||
try:
|
||||
await channel.start()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to start channel {}: {}", name, e)
|
||||
channel.set_last_error(e)
|
||||
if self._dispatch_task is None:
|
||||
self._dispatch_task = asyncio.create_task(self._dispatch_outbound())
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
if self._dispatch_task:
|
||||
self._dispatch_task.cancel()
|
||||
self._dispatch_task = None
|
||||
for channel in self.channels.values():
|
||||
try:
|
||||
await channel.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _dispatch_outbound(self) -> None:
|
||||
while True:
|
||||
message = await self.bus.get_response(timeout=1.0)
|
||||
if message is None:
|
||||
continue
|
||||
await self.dispatch_outbound(message)
|
||||
|
||||
async def dispatch_outbound(self, message: SystemMessage) -> None:
|
||||
channel_name = message.channel or self.config.system.default_channel
|
||||
channel = self.channels.get(channel_name)
|
||||
if channel is None:
|
||||
logger.debug("No channel dispatcher for {}", channel_name)
|
||||
return
|
||||
await channel.send(message)
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.provider_base import OptionalDependencyChannel
|
||||
from opc.core.models import SystemMessage
|
||||
|
||||
|
||||
class MatrixChannel(OptionalDependencyChannel):
|
||||
name = "matrix"
|
||||
required_package = "nio"
|
||||
delivery_mode = "polling"
|
||||
|
||||
def __init__(self, config: Any, bus: Any):
|
||||
super().__init__(config, bus)
|
||||
self.client: Any = None
|
||||
self._sync_task: asyncio.Task[Any] | None = None
|
||||
|
||||
def get_required_config_fields(self) -> list[str]:
|
||||
return ["homeserver", "access_token", "user_id"]
|
||||
|
||||
def normalize_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
content = payload.get("content", {})
|
||||
relates_to = dict(content.get("m.relates_to", {}) or {})
|
||||
thread_id = str(relates_to.get("event_id", "") or relates_to.get("m.in_reply_to", {}).get("event_id", "") or "")
|
||||
return {
|
||||
"sender_id": str(payload.get("sender", "")),
|
||||
"chat_id": str(payload.get("room_id", "")),
|
||||
"content": str(content.get("body", "") or ""),
|
||||
"thread_id": thread_id,
|
||||
"reply_to": str(payload.get("event_id", "") or ""),
|
||||
"metadata": {
|
||||
"matrix": {
|
||||
"event_id": str(payload.get("event_id", "") or ""),
|
||||
"relates_to": relates_to,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def should_accept_inbound(self, normalized: dict[str, Any]) -> bool:
|
||||
sender_id = str(normalized.get("sender_id", "") or "")
|
||||
if not sender_id or sender_id == self.config.user_id:
|
||||
return False
|
||||
return self.is_allowed(sender_id)
|
||||
|
||||
async def start(self) -> None:
|
||||
await super().start()
|
||||
from nio import AsyncClient, AsyncClientConfig, InviteEvent, RoomMessageText
|
||||
|
||||
store_path = Path(".opc") / "matrix-store"
|
||||
store_path.mkdir(parents=True, exist_ok=True)
|
||||
self.client = AsyncClient(
|
||||
homeserver=self.config.homeserver,
|
||||
user=self.config.user_id,
|
||||
store_path=str(store_path),
|
||||
config=AsyncClientConfig(store_sync_tokens=True, encryption_enabled=self.config.e2ee_enabled),
|
||||
)
|
||||
self.client.user_id = self.config.user_id
|
||||
self.client.access_token = self.config.access_token
|
||||
self.client.device_id = self.config.device_id
|
||||
self.client.add_event_callback(self._on_text_message, RoomMessageText)
|
||||
self.client.add_event_callback(self._on_room_invite, InviteEvent)
|
||||
self._sync_task = asyncio.create_task(self._sync_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.mark_stopped()
|
||||
if self.client is not None:
|
||||
try:
|
||||
self.client.stop_sync_forever()
|
||||
except Exception:
|
||||
pass
|
||||
if self._sync_task is not None:
|
||||
self._sync_task.cancel()
|
||||
try:
|
||||
await self._sync_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._sync_task = None
|
||||
if self.client is not None:
|
||||
try:
|
||||
await self.client.close()
|
||||
except Exception:
|
||||
logger.exception("matrix client close failed")
|
||||
self.client = None
|
||||
await super().stop()
|
||||
|
||||
async def _sync_loop(self) -> None:
|
||||
assert self.client is not None
|
||||
try:
|
||||
await self.client.sync_forever(timeout=30000, full_state=True)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.set_last_error(exc)
|
||||
raise
|
||||
|
||||
async def _on_room_invite(self, room: Any, event: Any) -> None:
|
||||
if self.client is None:
|
||||
return
|
||||
sender = str(getattr(event, "sender", "") or "")
|
||||
if not self.is_allowed(sender):
|
||||
return
|
||||
try:
|
||||
await self.client.join(room.room_id)
|
||||
except Exception:
|
||||
logger.exception("matrix join failed for {}", room.room_id)
|
||||
|
||||
async def _on_text_message(self, room: Any, event: Any) -> None:
|
||||
content = getattr(event, "source", {}).get("content", {})
|
||||
payload = {
|
||||
"sender": str(getattr(event, "sender", "") or ""),
|
||||
"room_id": str(getattr(room, "room_id", "") or ""),
|
||||
"event_id": str(getattr(event, "event_id", "") or ""),
|
||||
"content": content,
|
||||
}
|
||||
await self.publish_normalized(self.normalize_event(payload))
|
||||
|
||||
async def send(self, message: SystemMessage) -> None:
|
||||
await super().send(message)
|
||||
if self.client is None:
|
||||
logger.warning("matrix client not connected")
|
||||
return
|
||||
metadata = dict(message.metadata or {})
|
||||
room_id = str(metadata.get("chat_id") or message.session_id or "")
|
||||
content: dict[str, Any] = {
|
||||
"msgtype": "m.text",
|
||||
"body": message.content or "",
|
||||
}
|
||||
relates_to = self._build_thread_relates_to(metadata)
|
||||
if relates_to:
|
||||
content["m.relates_to"] = relates_to
|
||||
await self.client.room_send(room_id=room_id, message_type="m.room.message", content=content)
|
||||
for attachment in list(metadata.get("attachments", []) or []):
|
||||
if not isinstance(attachment, str):
|
||||
continue
|
||||
path = Path(attachment)
|
||||
if not path.is_file():
|
||||
continue
|
||||
with path.open("rb") as handle:
|
||||
upload_result = await self.client.upload(
|
||||
handle,
|
||||
content_type="application/octet-stream",
|
||||
filename=path.name,
|
||||
filesize=path.stat().st_size,
|
||||
)
|
||||
upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result
|
||||
mxc_url = getattr(upload_response, "content_uri", None)
|
||||
if not mxc_url:
|
||||
continue
|
||||
content = {"msgtype": "m.file", "body": path.name, "filename": path.name, "url": mxc_url}
|
||||
if relates_to:
|
||||
content["m.relates_to"] = relates_to
|
||||
await self.client.room_send(room_id=room_id, message_type="m.room.message", content=content)
|
||||
|
||||
@staticmethod
|
||||
def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
metadata = dict(metadata or {})
|
||||
thread_id = str(metadata.get("thread_id", "") or "")
|
||||
reply_to = str(metadata.get("reply_to", "") or "")
|
||||
if not thread_id and not reply_to:
|
||||
return None
|
||||
if thread_id:
|
||||
return {"rel_type": "m.thread", "event_id": thread_id}
|
||||
return {"m.in_reply_to": {"event_id": reply_to}}
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.provider_base import OptionalDependencyChannel
|
||||
from opc.core.models import SystemMessage
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MochatTarget:
|
||||
id: str
|
||||
is_panel: bool
|
||||
|
||||
|
||||
def resolve_mochat_target(raw: str) -> MochatTarget:
|
||||
trimmed = (raw or "").strip()
|
||||
if not trimmed:
|
||||
return MochatTarget(id="", is_panel=False)
|
||||
lowered = trimmed.lower()
|
||||
cleaned = trimmed
|
||||
forced_panel = False
|
||||
for prefix in ("mochat:", "group:", "channel:", "panel:"):
|
||||
if lowered.startswith(prefix):
|
||||
cleaned = trimmed[len(prefix):].strip()
|
||||
forced_panel = prefix in {"group:", "channel:", "panel:"}
|
||||
break
|
||||
return MochatTarget(id=cleaned, is_panel=forced_panel or not cleaned.startswith("session_"))
|
||||
|
||||
|
||||
class MochatChannel(OptionalDependencyChannel):
|
||||
name = "mochat"
|
||||
required_package = "socketio"
|
||||
delivery_mode = "bridge"
|
||||
|
||||
def __init__(self, config: Any, bus: Any):
|
||||
super().__init__(config, bus)
|
||||
self._socket: Any = None
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
|
||||
def get_required_config_fields(self) -> list[str]:
|
||||
return ["base_url", "claw_token", "agent_user_id"]
|
||||
|
||||
def normalize_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
message = payload.get("message", payload)
|
||||
return {
|
||||
"sender_id": str(message.get("from", message.get("sender_id", "")) or ""),
|
||||
"chat_id": str(message.get("conversation_id", message.get("room_id", "")) or ""),
|
||||
"content": str(message.get("text", message.get("content", "")) or ""),
|
||||
"thread_id": str(message.get("thread_id", "") or ""),
|
||||
"reply_to": str(message.get("reply_to", message.get("id", "")) or ""),
|
||||
"metadata": {"group_id": str(message.get("group_id", "") or "")},
|
||||
}
|
||||
|
||||
async def start(self) -> None:
|
||||
await super().start()
|
||||
self._http = httpx.AsyncClient(base_url=self.config.base_url.rstrip("/"), timeout=30)
|
||||
self._runner_task = asyncio.create_task(self._run_with_restarts(self._runtime_loop, label="mochat"))
|
||||
|
||||
async def _runtime_loop(self) -> None:
|
||||
try:
|
||||
if await self._start_socket_client():
|
||||
while self.is_running:
|
||||
await asyncio.sleep(1)
|
||||
return
|
||||
except Exception as exc:
|
||||
self.set_last_error(exc)
|
||||
await self._fallback_loop()
|
||||
|
||||
async def _start_socket_client(self) -> bool:
|
||||
import socketio
|
||||
|
||||
client = socketio.AsyncClient(
|
||||
reconnection=True,
|
||||
reconnection_attempts=self.config.max_retry_attempts or None,
|
||||
reconnection_delay=max(0.1, self.config.socket_reconnect_delay_ms / 1000.0),
|
||||
reconnection_delay_max=max(0.1, self.config.socket_max_reconnect_delay_ms / 1000.0),
|
||||
logger=False,
|
||||
engineio_logger=False,
|
||||
)
|
||||
|
||||
@client.on("claw.session.events")
|
||||
async def on_session_events(payload: dict[str, Any]) -> None:
|
||||
await self.publish_normalized(self.normalize_event(payload))
|
||||
|
||||
@client.on("claw.panel.events")
|
||||
async def on_panel_events(payload: dict[str, Any]) -> None:
|
||||
await self.publish_normalized(self.normalize_event(payload))
|
||||
|
||||
socket_url = (self.config.socket_url or self.config.base_url).strip().rstrip("/")
|
||||
socket_path = (self.config.socket_path or "/socket.io").strip().lstrip("/")
|
||||
await client.connect(
|
||||
socket_url,
|
||||
transports=["websocket"],
|
||||
socketio_path=socket_path,
|
||||
auth={"token": self.config.claw_token},
|
||||
wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0),
|
||||
)
|
||||
self._socket = client
|
||||
if self.config.sessions:
|
||||
await client.call(
|
||||
"com.claw.im.subscribeSessions",
|
||||
{"sessionIds": list(self.config.sessions), "limit": self.config.watch_limit},
|
||||
timeout=10,
|
||||
)
|
||||
if self.config.panels:
|
||||
await client.call("com.claw.im.subscribePanels", {"panelIds": list(self.config.panels)}, timeout=10)
|
||||
return True
|
||||
|
||||
async def _fallback_loop(self) -> None:
|
||||
while self.is_running:
|
||||
for session_id in list(self.config.sessions or []):
|
||||
try:
|
||||
payload = await self._post_json(
|
||||
"/api/claw/sessions/watch",
|
||||
{"sessionId": session_id, "timeoutMs": self.config.watch_timeout_ms, "limit": self.config.watch_limit},
|
||||
)
|
||||
await self.publish_normalized(self.normalize_event(payload))
|
||||
except Exception as exc:
|
||||
self.set_last_error(exc)
|
||||
await asyncio.sleep(max(1.0, self.config.refresh_interval_ms / 1000.0))
|
||||
|
||||
async def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
assert self._http is not None
|
||||
response = await self._http.post(path, json=payload, headers={"Authorization": f"Bearer {self.config.claw_token}"})
|
||||
response.raise_for_status()
|
||||
parsed = response.json()
|
||||
if isinstance(parsed, dict) and isinstance(parsed.get("data"), dict):
|
||||
return parsed["data"]
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._socket is not None:
|
||||
try:
|
||||
await self._socket.disconnect()
|
||||
except Exception:
|
||||
logger.exception("mochat socket close failed")
|
||||
self._socket = None
|
||||
if self._http is not None:
|
||||
await self._http.aclose()
|
||||
self._http = None
|
||||
await super().stop()
|
||||
|
||||
async def send(self, message: SystemMessage) -> None:
|
||||
await super().send(message)
|
||||
if self._http is None:
|
||||
logger.warning("mochat http client not initialized")
|
||||
return
|
||||
metadata = dict(message.metadata or {})
|
||||
target = resolve_mochat_target(str(metadata.get("chat_id") or message.session_id or ""))
|
||||
reply_to = str(metadata.get("reply_to", "") or "")
|
||||
if target.is_panel:
|
||||
payload: dict[str, Any] = {"panelId": target.id, "content": message.content}
|
||||
group_id = str(metadata.get("group_id", "") or "")
|
||||
if group_id:
|
||||
payload["groupId"] = group_id
|
||||
if reply_to:
|
||||
payload["replyTo"] = reply_to
|
||||
await self._post_json("/api/claw/groups/panels/send", payload)
|
||||
return
|
||||
payload = {"sessionId": target.id, "content": message.content}
|
||||
if reply_to:
|
||||
payload["replyTo"] = reply_to
|
||||
await self._post_json("/api/claw/sessions/send", payload)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Shared provider helpers for native OpenOPC channels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.base import BaseChannel
|
||||
from opc.core.models import SystemMessage
|
||||
|
||||
|
||||
class OptionalDependencyChannel(BaseChannel):
|
||||
required_package: str | None = None
|
||||
delivery_mode: str = "sdk"
|
||||
reconnect_delay_seconds: float = 5.0
|
||||
|
||||
def __init__(self, config: Any, bus: Any):
|
||||
super().__init__(config, bus)
|
||||
self._runner_task: asyncio.Task[Any] | None = None
|
||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
if not cls.required_package:
|
||||
return True
|
||||
try:
|
||||
__import__(cls.required_package)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def dependency_error(self) -> str:
|
||||
return f"{self.name} channel requires optional dependency `{self.required_package}`"
|
||||
|
||||
def get_required_config_fields(self) -> list[str]:
|
||||
return []
|
||||
|
||||
def config_error(self) -> str:
|
||||
missing = self.get_missing_config_fields()
|
||||
return f"{self.name} channel is missing required config fields: {', '.join(missing)}"
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
return self.is_available() and self.is_configured()
|
||||
|
||||
def describe_capability(self) -> dict[str, Any]:
|
||||
data = super().describe_capability()
|
||||
data.update(
|
||||
{
|
||||
"delivery_mode": self.delivery_mode,
|
||||
"available": self.is_available(),
|
||||
"ready": self.is_ready(),
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
def build_outbound_envelope(self, message: SystemMessage) -> dict[str, Any]:
|
||||
metadata = dict(message.metadata or {})
|
||||
return {
|
||||
"channel": self.name,
|
||||
"chat_id": str(metadata.get("chat_id") or message.session_id),
|
||||
"thread_id": str(metadata.get("thread_id") or ""),
|
||||
"reply_to": str(metadata.get("reply_to") or ""),
|
||||
"content": message.content,
|
||||
"attachments": list(metadata.get("attachments", []) or []),
|
||||
"message_type": message.message_type,
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self.is_available():
|
||||
raise RuntimeError(self.dependency_error())
|
||||
if not self.is_configured():
|
||||
raise RuntimeError(self.config_error())
|
||||
self.mark_started()
|
||||
logger.info("{} channel started", self.name)
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.mark_stopped()
|
||||
if self._runner_task:
|
||||
self._runner_task.cancel()
|
||||
try:
|
||||
await self._runner_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._runner_task = None
|
||||
if self._background_tasks:
|
||||
for task in list(self._background_tasks):
|
||||
task.cancel()
|
||||
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
||||
self._background_tasks.clear()
|
||||
logger.info("{} channel stopped", self.name)
|
||||
|
||||
async def send(self, message: SystemMessage) -> None:
|
||||
self.last_outbound = self.build_outbound_envelope(message)
|
||||
logger.info("{} outbound -> {} :: {}", self.name, self.last_outbound["chat_id"], message.content[:120])
|
||||
|
||||
def _track_task(self, task: asyncio.Task[Any]) -> asyncio.Task[Any]:
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return task
|
||||
|
||||
async def _run_with_restarts(self, callback: Any, *, label: str, delay_seconds: float | None = None) -> None:
|
||||
delay = self.reconnect_delay_seconds if delay_seconds is None else max(0.1, delay_seconds)
|
||||
while self.is_running:
|
||||
try:
|
||||
await callback()
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.set_last_error(exc)
|
||||
logger.warning("{} {} error: {}", self.name, label, exc)
|
||||
if not self.is_running:
|
||||
return
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
|
||||
class WebhookChannel(OptionalDependencyChannel):
|
||||
required_package = None
|
||||
delivery_mode = "webhook"
|
||||
|
||||
async def handle_webhook(self, payload: dict[str, Any]) -> None:
|
||||
await self.publish_normalized(payload)
|
||||
|
||||
|
||||
class PollingChannel(OptionalDependencyChannel):
|
||||
delivery_mode = "polling"
|
||||
|
||||
async def start(self) -> None:
|
||||
await super().start()
|
||||
self._runner_task = asyncio.create_task(self._run_with_restarts(self._polling_loop, label="polling"))
|
||||
|
||||
async def _polling_loop(self) -> None:
|
||||
while self.is_running:
|
||||
await self.poll_once()
|
||||
await asyncio.sleep(max(0.01, self.get_poll_interval_seconds()))
|
||||
|
||||
def get_poll_interval_seconds(self) -> float:
|
||||
return 1.0
|
||||
|
||||
async def poll_once(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SocketChannel(OptionalDependencyChannel):
|
||||
delivery_mode = "socket"
|
||||
|
||||
async def start(self) -> None:
|
||||
await super().start()
|
||||
self._runner_task = asyncio.create_task(self._run_with_restarts(self.run_socket_forever, label="socket"))
|
||||
|
||||
async def run_socket_forever(self) -> None:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Static metadata for built-in OpenOPC channel providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelProviderSpec:
|
||||
name: str
|
||||
module_name: str
|
||||
class_name: str
|
||||
delivery_mode: str
|
||||
extra_name: str
|
||||
required_package: str | None = None
|
||||
bridge_required: bool = False
|
||||
required_config_fields: tuple[str, ...] = field(default_factory=tuple)
|
||||
login_summary: str = ""
|
||||
|
||||
|
||||
PROVIDER_SPECS: dict[str, ChannelProviderSpec] = {
|
||||
"telegram": ChannelProviderSpec(
|
||||
name="telegram",
|
||||
module_name="opc.channels.telegram",
|
||||
class_name="TelegramChannel",
|
||||
delivery_mode="polling",
|
||||
extra_name="channels-telegram",
|
||||
required_package="telegram",
|
||||
required_config_fields=("token",),
|
||||
login_summary="Create a Telegram bot with BotFather, set `token`, then allow approved sender IDs in `allow_from`.",
|
||||
),
|
||||
"whatsapp": ChannelProviderSpec(
|
||||
name="whatsapp",
|
||||
module_name="opc.channels.whatsapp",
|
||||
class_name="WhatsAppChannel",
|
||||
delivery_mode="bridge",
|
||||
extra_name="channels-whatsapp",
|
||||
required_package="websockets",
|
||||
bridge_required=True,
|
||||
required_config_fields=("bridge_url",),
|
||||
login_summary="Start the WhatsApp bridge, pair via QR code, then configure `bridge_url`, optional `bridge_token`, and `allow_from`.",
|
||||
),
|
||||
"discord": ChannelProviderSpec(
|
||||
name="discord",
|
||||
module_name="opc.channels.discord",
|
||||
class_name="DiscordChannel",
|
||||
delivery_mode="socket",
|
||||
extra_name="channels-discord",
|
||||
required_package="discord",
|
||||
required_config_fields=("token",),
|
||||
login_summary="Create a Discord bot, set `token`, enable required gateway intents, and configure `group_policy` / `allow_from`.",
|
||||
),
|
||||
"feishu": ChannelProviderSpec(
|
||||
name="feishu",
|
||||
module_name="opc.channels.feishu",
|
||||
class_name="FeishuChannel",
|
||||
delivery_mode="socket",
|
||||
extra_name="channels-feishu",
|
||||
required_package="lark_oapi",
|
||||
required_config_fields=("app_id", "app_secret"),
|
||||
login_summary="Create a Feishu app, set `app_id` / `app_secret`, and configure `verification_token` / `encrypt_key` if your tenant requires them.",
|
||||
),
|
||||
"mochat": ChannelProviderSpec(
|
||||
name="mochat",
|
||||
module_name="opc.channels.mochat",
|
||||
class_name="MochatChannel",
|
||||
delivery_mode="bridge",
|
||||
extra_name="channels-mochat",
|
||||
required_package="socketio",
|
||||
bridge_required=True,
|
||||
required_config_fields=("base_url", "claw_token", "agent_user_id"),
|
||||
login_summary="Configure Mochat HTTP/Socket endpoints plus `claw_token` and `agent_user_id`; socket mode is preferred and HTTP watch fallback is automatic.",
|
||||
),
|
||||
"dingtalk": ChannelProviderSpec(
|
||||
name="dingtalk",
|
||||
module_name="opc.channels.dingtalk",
|
||||
class_name="DingTalkChannel",
|
||||
delivery_mode="socket",
|
||||
extra_name="channels-dingtalk",
|
||||
required_package="dingtalk_stream",
|
||||
required_config_fields=("client_id", "client_secret"),
|
||||
login_summary="Create a DingTalk Stream Mode app, set `client_id` / `client_secret`, and approve sender IDs in `allow_from`.",
|
||||
),
|
||||
"email": ChannelProviderSpec(
|
||||
name="email",
|
||||
module_name="opc.channels.email",
|
||||
class_name="EmailChannel",
|
||||
delivery_mode="polling",
|
||||
extra_name="channels-email",
|
||||
required_config_fields=("imap_host", "imap_username", "imap_password", "smtp_host", "smtp_username", "smtp_password"),
|
||||
login_summary="Configure IMAP/SMTP credentials, set `consent_granted: true`, then add approved sender addresses in `allow_from`.",
|
||||
),
|
||||
"slack": ChannelProviderSpec(
|
||||
name="slack",
|
||||
module_name="opc.channels.slack",
|
||||
class_name="SlackChannel",
|
||||
delivery_mode="socket",
|
||||
extra_name="channels-slack",
|
||||
required_package="slack_sdk",
|
||||
required_config_fields=("bot_token", "app_token"),
|
||||
login_summary="Create a Slack app with Socket Mode, set `bot_token` / `app_token`, then configure DM and group policies.",
|
||||
),
|
||||
"qq": ChannelProviderSpec(
|
||||
name="qq",
|
||||
module_name="opc.channels.qq",
|
||||
class_name="QQChannel",
|
||||
delivery_mode="socket",
|
||||
extra_name="channels-qq",
|
||||
required_package="botpy",
|
||||
required_config_fields=("app_id", "secret"),
|
||||
login_summary="Create a QQ bot application, set `app_id` / `secret`, and allow approved openids in `allow_from`.",
|
||||
),
|
||||
"matrix": ChannelProviderSpec(
|
||||
name="matrix",
|
||||
module_name="opc.channels.matrix",
|
||||
class_name="MatrixChannel",
|
||||
delivery_mode="polling",
|
||||
extra_name="channels-matrix",
|
||||
required_package="nio",
|
||||
required_config_fields=("homeserver", "access_token", "user_id"),
|
||||
login_summary="Create a Matrix access token, set `homeserver`, `access_token`, `user_id`, and optionally `device_id` for sync persistence.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def ordered_provider_specs() -> list[ChannelProviderSpec]:
|
||||
return [PROVIDER_SPECS[name] for name in (
|
||||
"telegram",
|
||||
"whatsapp",
|
||||
"discord",
|
||||
"feishu",
|
||||
"mochat",
|
||||
"dingtalk",
|
||||
"email",
|
||||
"slack",
|
||||
"qq",
|
||||
"matrix",
|
||||
)]
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.provider_base import OptionalDependencyChannel
|
||||
from opc.core.models import SystemMessage
|
||||
|
||||
|
||||
class QQChannel(OptionalDependencyChannel):
|
||||
name = "qq"
|
||||
required_package = "botpy"
|
||||
delivery_mode = "socket"
|
||||
|
||||
def __init__(self, config: Any, bus: Any):
|
||||
super().__init__(config, bus)
|
||||
self._client: Any = None
|
||||
self._processed_ids: deque[str] = deque(maxlen=1000)
|
||||
self._msg_seq: int = 1
|
||||
|
||||
def get_required_config_fields(self) -> list[str]:
|
||||
return ["app_id", "secret"]
|
||||
|
||||
def normalize_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
author = payload.get("author", {})
|
||||
sender = str(author.get("id", payload.get("openid", "")))
|
||||
return {
|
||||
"sender_id": sender,
|
||||
"chat_id": str(payload.get("group_openid", payload.get("channel_id", sender))),
|
||||
"content": str(payload.get("content", "") or ""),
|
||||
"thread_id": "",
|
||||
"reply_to": str(payload.get("id", "") or ""),
|
||||
"metadata": {"message_id": str(payload.get("id", "") or "")},
|
||||
}
|
||||
|
||||
async def start(self) -> None:
|
||||
await super().start()
|
||||
import botpy
|
||||
|
||||
self._client = self._build_client(botpy)
|
||||
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
|
||||
|
||||
def _build_client(self, botpy_module: Any) -> Any:
|
||||
intents = botpy_module.Intents(public_messages=True, direct_message=True)
|
||||
channel = self
|
||||
|
||||
class OPCQQClient(botpy_module.Client):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(intents=intents, ext_handlers=False)
|
||||
|
||||
async def on_ready(self) -> None:
|
||||
logger.info("qq bot ready")
|
||||
|
||||
async def on_c2c_message_create(self, message: Any) -> None:
|
||||
await channel._on_message(message)
|
||||
|
||||
async def on_direct_message_create(self, message: Any) -> None:
|
||||
await channel._on_message(message)
|
||||
|
||||
return OPCQQClient()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._client is not None:
|
||||
try:
|
||||
await self._client.close()
|
||||
except Exception:
|
||||
logger.exception("qq client close failed")
|
||||
self._client = None
|
||||
await super().stop()
|
||||
|
||||
async def _on_message(self, message: Any) -> None:
|
||||
if getattr(message, "id", "") in self._processed_ids:
|
||||
return
|
||||
self._processed_ids.append(getattr(message, "id", ""))
|
||||
author = getattr(message, "author", None)
|
||||
payload = {
|
||||
"id": str(getattr(message, "id", "") or ""),
|
||||
"author": {
|
||||
"id": str(getattr(author, "id", "") or getattr(author, "user_openid", "") or ""),
|
||||
},
|
||||
"content": str(getattr(message, "content", "") or ""),
|
||||
}
|
||||
await self.publish_normalized(self.normalize_event(payload))
|
||||
|
||||
async def send(self, message: SystemMessage) -> None:
|
||||
await super().send(message)
|
||||
if self._client is None:
|
||||
logger.warning("qq client not connected")
|
||||
return
|
||||
metadata = dict(message.metadata or {})
|
||||
chat_id = str(metadata.get("chat_id") or message.session_id or "")
|
||||
self._msg_seq += 1
|
||||
await self._client.api.post_c2c_message(
|
||||
openid=chat_id,
|
||||
msg_type=0,
|
||||
content=message.content,
|
||||
msg_id=str(metadata.get("reply_to", "") or metadata.get("message_id", "") or ""),
|
||||
msg_seq=self._msg_seq,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Session mapping helpers for external channels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionRoute:
|
||||
session_id: str
|
||||
chat_id: str
|
||||
thread_id: str = ""
|
||||
reply_to: str = ""
|
||||
|
||||
|
||||
class ChannelSessionMapping:
|
||||
@staticmethod
|
||||
def derive(
|
||||
*,
|
||||
channel: str,
|
||||
chat_id: str,
|
||||
thread_id: str | None = None,
|
||||
reply_to: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> SessionRoute:
|
||||
meta = metadata or {}
|
||||
resolved_chat = str(chat_id or meta.get("chat_id") or "")
|
||||
resolved_thread = str(thread_id or meta.get("thread_id") or "")
|
||||
resolved_reply = str(reply_to or meta.get("reply_to") or "")
|
||||
if resolved_thread:
|
||||
session_id = f"{channel}:{resolved_chat}:{resolved_thread}"
|
||||
else:
|
||||
session_id = f"{channel}:{resolved_chat}"
|
||||
return SessionRoute(
|
||||
session_id=session_id,
|
||||
chat_id=resolved_chat,
|
||||
thread_id=resolved_thread,
|
||||
reply_to=resolved_reply,
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.provider_base import SocketChannel
|
||||
from opc.core.models import SystemMessage
|
||||
|
||||
|
||||
class SlackChannel(SocketChannel):
|
||||
name = "slack"
|
||||
required_package = "slack_sdk"
|
||||
|
||||
def __init__(self, config: Any, bus: Any):
|
||||
super().__init__(config, bus)
|
||||
self._web_client: Any = None
|
||||
self._socket_client: Any = None
|
||||
self._bot_user_id: str | None = None
|
||||
|
||||
def get_required_config_fields(self) -> list[str]:
|
||||
return ["bot_token", "app_token"]
|
||||
|
||||
def normalize_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
event = payload.get("event", payload)
|
||||
text = str(event.get("text", "") or "")
|
||||
channel_type = str(event.get("channel_type", "") or "")
|
||||
thread_ts = str(event.get("thread_ts", "") or "")
|
||||
if self.config.reply_in_thread and channel_type != "im" and not thread_ts:
|
||||
thread_ts = str(event.get("ts", "") or "")
|
||||
return {
|
||||
"sender_id": str(event.get("user", "")),
|
||||
"chat_id": str(event.get("channel", "")),
|
||||
"content": self._strip_bot_mention(text),
|
||||
"thread_id": thread_ts if channel_type != "im" else "",
|
||||
"reply_to": str(event.get("ts", "") or ""),
|
||||
"metadata": {
|
||||
"slack": {
|
||||
"thread_ts": thread_ts,
|
||||
"channel_type": channel_type,
|
||||
"event": event,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def should_accept_inbound(self, normalized: dict[str, Any]) -> bool:
|
||||
sender_id = str(normalized.get("sender_id", "") or "")
|
||||
if not sender_id or sender_id == self._bot_user_id:
|
||||
return False
|
||||
meta = dict(normalized.get("metadata", {}) or {})
|
||||
slack_meta = dict(meta.get("slack", {}) or {})
|
||||
channel_type = str(slack_meta.get("channel_type", "") or "")
|
||||
chat_id = str(normalized.get("chat_id", "") or "")
|
||||
if channel_type == "im":
|
||||
if not self.config.dm.enabled:
|
||||
return False
|
||||
if self.config.dm.policy == "allowlist":
|
||||
return sender_id in list(self.config.dm.allow_from or [])
|
||||
return self.is_allowed(sender_id)
|
||||
if self.config.group_policy == "allowlist":
|
||||
return chat_id in list(self.config.group_allow_from or [])
|
||||
if self.config.group_policy == "mention":
|
||||
text = str(normalized.get("content", "") or "")
|
||||
raw_text = str(slack_meta.get("event", {}).get("text", "") or "")
|
||||
if text != raw_text:
|
||||
return True
|
||||
return False
|
||||
return self.is_allowed(sender_id)
|
||||
|
||||
def build_session_key_override(self, normalized: dict[str, Any]) -> str | None:
|
||||
meta = dict(normalized.get("metadata", {}) or {})
|
||||
slack_meta = dict(meta.get("slack", {}) or {})
|
||||
channel_type = str(slack_meta.get("channel_type", "") or "")
|
||||
thread_ts = str(slack_meta.get("thread_ts", "") or "")
|
||||
if thread_ts and channel_type != "im":
|
||||
return f"slack:{normalized.get('chat_id', '')}:{thread_ts}"
|
||||
return None
|
||||
|
||||
async def run_socket_forever(self) -> None:
|
||||
if self.config.mode != "socket":
|
||||
raise RuntimeError(f"unsupported slack mode: {self.config.mode}")
|
||||
from slack_sdk.socket_mode.websockets import SocketModeClient
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
|
||||
self._web_client = self._create_web_client()
|
||||
auth = await self._web_client.auth_test()
|
||||
self._bot_user_id = auth.get("user_id")
|
||||
self._socket_client = self._create_socket_client(self._web_client)
|
||||
self._socket_client.socket_mode_request_listeners.append(self._on_socket_request)
|
||||
await self._socket_client.connect()
|
||||
while self.is_running:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.mark_stopped()
|
||||
if self._socket_client is not None:
|
||||
try:
|
||||
await self._socket_client.close()
|
||||
except Exception:
|
||||
logger.exception("slack socket close failed")
|
||||
self._socket_client = None
|
||||
await super().stop()
|
||||
|
||||
def _create_web_client(self) -> Any:
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
|
||||
return AsyncWebClient(token=self.config.bot_token)
|
||||
|
||||
def _create_socket_client(self, web_client: Any) -> Any:
|
||||
from slack_sdk.socket_mode.websockets import SocketModeClient
|
||||
|
||||
return SocketModeClient(app_token=self.config.app_token, web_client=web_client)
|
||||
|
||||
async def send(self, message: SystemMessage) -> None:
|
||||
await super().send(message)
|
||||
if self._web_client is None:
|
||||
logger.warning("slack client not connected")
|
||||
return
|
||||
metadata = dict(message.metadata or {})
|
||||
chat_id = str(metadata.get("chat_id") or message.session_id or "")
|
||||
slack_meta = dict(metadata.get("slack", {}) or {})
|
||||
thread_ts = str(metadata.get("thread_id") or slack_meta.get("thread_ts") or "")
|
||||
channel_type = str(slack_meta.get("channel_type", "") or "")
|
||||
use_thread = bool(thread_ts and channel_type != "im" and self.config.reply_in_thread)
|
||||
if message.content.strip():
|
||||
await self._web_client.chat_postMessage(
|
||||
channel=chat_id,
|
||||
text=message.content,
|
||||
thread_ts=thread_ts if use_thread else None,
|
||||
)
|
||||
for attachment in list(metadata.get("attachments", []) or []):
|
||||
if isinstance(attachment, str):
|
||||
await self._web_client.files_upload_v2(
|
||||
channel=chat_id,
|
||||
file=attachment,
|
||||
thread_ts=thread_ts if use_thread else None,
|
||||
)
|
||||
|
||||
async def _on_socket_request(self, client: Any, req: Any) -> None:
|
||||
try:
|
||||
from slack_sdk.socket_mode.response import SocketModeResponse
|
||||
except Exception:
|
||||
class SocketModeResponse: # type: ignore[no-redef]
|
||||
def __init__(self, envelope_id: str):
|
||||
self.envelope_id = envelope_id
|
||||
|
||||
if getattr(req, "type", "") != "events_api":
|
||||
return
|
||||
await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id))
|
||||
payload = req.payload or {}
|
||||
event = payload.get("event") or {}
|
||||
event_type = str(event.get("type", "") or "")
|
||||
if event_type not in {"message", "app_mention"}:
|
||||
return
|
||||
if event.get("subtype"):
|
||||
return
|
||||
normalized = self.normalize_event(payload)
|
||||
normalized["metadata"]["slack"]["event_type"] = event_type
|
||||
normalized["content"] = self._strip_bot_mention(str(event.get("text", "") or ""))
|
||||
if not normalized["sender_id"] or not normalized["chat_id"]:
|
||||
return
|
||||
if self._web_client and event.get("ts") and self.config.react_emoji:
|
||||
try:
|
||||
await self._web_client.reactions_add(
|
||||
channel=normalized["chat_id"],
|
||||
name=self.config.react_emoji,
|
||||
timestamp=event.get("ts"),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("slack reactions_add failed")
|
||||
await self.publish_normalized(normalized)
|
||||
|
||||
def _strip_bot_mention(self, text: str) -> str:
|
||||
if not text or not self._bot_user_id:
|
||||
return text
|
||||
return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip()
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.provider_base import PollingChannel
|
||||
from opc.core.models import SystemMessage
|
||||
|
||||
_TELEGRAM_MAX_MESSAGE_LEN = 4000
|
||||
|
||||
|
||||
class TelegramChannel(PollingChannel):
|
||||
name = "telegram"
|
||||
required_package = "telegram"
|
||||
|
||||
def __init__(self, config: Any, bus: Any):
|
||||
super().__init__(config, bus)
|
||||
self._bot: Any = None
|
||||
self._update_offset: int = 0
|
||||
|
||||
def get_required_config_fields(self) -> list[str]:
|
||||
return ["token"]
|
||||
|
||||
def get_poll_interval_seconds(self) -> float:
|
||||
return 0.25
|
||||
|
||||
def normalize_update(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
message = payload.get("message", payload)
|
||||
chat = message.get("chat", {})
|
||||
user = message.get("from", {})
|
||||
attachments = []
|
||||
for key in ("photo", "document", "audio", "voice", "video"):
|
||||
if message.get(key):
|
||||
attachments.append({"type": key, "value": message.get(key)})
|
||||
text = str(message.get("text", "") or message.get("caption", "") or "")
|
||||
return {
|
||||
"sender_id": str(user.get("id", "")),
|
||||
"chat_id": str(chat.get("id", "")),
|
||||
"content": text,
|
||||
"thread_id": str(message.get("message_thread_id", "") or ""),
|
||||
"reply_to": str((message.get("reply_to_message") or {}).get("message_id", "") or ""),
|
||||
"attachments": attachments,
|
||||
"metadata": {
|
||||
"message_id": str(message.get("message_id", "") or ""),
|
||||
"chat_type": str(chat.get("type", "") or ""),
|
||||
},
|
||||
}
|
||||
|
||||
async def _ensure_bot(self) -> Any:
|
||||
if self._bot is not None:
|
||||
return self._bot
|
||||
from telegram import Bot
|
||||
|
||||
self._bot = Bot(token=self.config.token)
|
||||
return self._bot
|
||||
|
||||
async def poll_once(self) -> None:
|
||||
bot = await self._ensure_bot()
|
||||
updates = await bot.get_updates(
|
||||
offset=self._update_offset or None,
|
||||
timeout=20,
|
||||
allowed_updates=["message"],
|
||||
)
|
||||
for update in updates:
|
||||
self._update_offset = max(self._update_offset, int(update.update_id) + 1)
|
||||
message = getattr(update, "message", None)
|
||||
if message is None:
|
||||
continue
|
||||
payload = self.normalize_update(update.to_dict())
|
||||
await self.publish_normalized(payload)
|
||||
|
||||
async def send(self, message: SystemMessage) -> None:
|
||||
await super().send(message)
|
||||
bot = await self._ensure_bot()
|
||||
metadata = dict(message.metadata or {})
|
||||
chat_id = int(str(metadata.get("chat_id") or message.session_id))
|
||||
thread_id = str(metadata.get("thread_id", "") or "")
|
||||
reply_to = str(metadata.get("reply_to", "") or "")
|
||||
reply_to_message_id = None
|
||||
if reply_to:
|
||||
try:
|
||||
reply_to_message_id = int(reply_to)
|
||||
except ValueError:
|
||||
reply_to_message_id = None
|
||||
for chunk in _split_message(message.content or "", _TELEGRAM_MAX_MESSAGE_LEN):
|
||||
kwargs: dict[str, Any] = {"chat_id": chat_id, "text": chunk}
|
||||
if thread_id:
|
||||
try:
|
||||
kwargs["message_thread_id"] = int(thread_id)
|
||||
except ValueError:
|
||||
pass
|
||||
if self.config.reply_to_message and reply_to_message_id is not None:
|
||||
kwargs["reply_to_message_id"] = reply_to_message_id
|
||||
await bot.send_message(**kwargs)
|
||||
for attachment in list(metadata.get("attachments", []) or []):
|
||||
if not isinstance(attachment, str):
|
||||
continue
|
||||
path = Path(attachment)
|
||||
if not path.is_file():
|
||||
logger.warning("telegram attachment path not found: {}", path)
|
||||
continue
|
||||
with path.open("rb") as handle:
|
||||
kwargs = {"chat_id": chat_id, "document": handle}
|
||||
if thread_id:
|
||||
try:
|
||||
kwargs["message_thread_id"] = int(thread_id)
|
||||
except ValueError:
|
||||
pass
|
||||
if self.config.reply_to_message and reply_to_message_id is not None:
|
||||
kwargs["reply_to_message_id"] = reply_to_message_id
|
||||
await bot.send_document(**kwargs)
|
||||
|
||||
|
||||
def _split_message(text: str, max_len: int) -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
chunks: list[str] = []
|
||||
remaining = text
|
||||
while len(remaining) > max_len:
|
||||
split_at = remaining.rfind("\n", 0, max_len)
|
||||
if split_at <= 0:
|
||||
split_at = max_len
|
||||
chunks.append(remaining[:split_at].rstrip())
|
||||
remaining = remaining[split_at:].lstrip()
|
||||
if remaining:
|
||||
chunks.append(remaining)
|
||||
return chunks
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.channels.provider_base import SocketChannel
|
||||
from opc.core.models import SystemMessage
|
||||
|
||||
|
||||
class WhatsAppChannel(SocketChannel):
|
||||
name = "whatsapp"
|
||||
required_package = "websockets"
|
||||
delivery_mode = "bridge"
|
||||
|
||||
def __init__(self, config: Any, bus: Any):
|
||||
super().__init__(config, bus)
|
||||
self._ws: Any = None
|
||||
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
|
||||
|
||||
def get_required_config_fields(self) -> list[str]:
|
||||
return ["bridge_url"]
|
||||
|
||||
def normalize_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
message = payload.get("message", payload)
|
||||
sender = str(message.get("sender", message.get("from", "")) or "")
|
||||
if not sender:
|
||||
sender = str(payload.get("sender", payload.get("from", "")) or "")
|
||||
pn = str(message.get("pn", "") or "")
|
||||
if not pn:
|
||||
pn = str(payload.get("pn", "") or "")
|
||||
sender_id = pn or sender
|
||||
if "@" in sender_id:
|
||||
sender_id = sender_id.split("@", 1)[0]
|
||||
content = str(message.get("text", message.get("content", message.get("body", ""))) or "")
|
||||
attachments = list(message.get("media", []) or [])
|
||||
if attachments:
|
||||
for path in attachments:
|
||||
mime, _ = mimetypes.guess_type(path)
|
||||
tag = "image" if mime and mime.startswith("image/") else "file"
|
||||
content = f"{content}\n[{tag}: {path}]".strip() if content else f"[{tag}: {path}]"
|
||||
return {
|
||||
"sender_id": sender_id,
|
||||
"chat_id": str(message.get("chat_id", sender) or ""),
|
||||
"content": content,
|
||||
"thread_id": "",
|
||||
"reply_to": str(message.get("reply_to", message.get("id", "")) or ""),
|
||||
"attachments": attachments,
|
||||
"metadata": {
|
||||
"message_id": str(message.get("id", "") or ""),
|
||||
"is_group": bool(message.get("isGroup", False)),
|
||||
"raw_sender": sender,
|
||||
},
|
||||
}
|
||||
|
||||
async def run_socket_forever(self) -> None:
|
||||
import websockets
|
||||
|
||||
async with websockets.connect(self.config.bridge_url) as ws:
|
||||
self._ws = ws
|
||||
if self.config.bridge_token:
|
||||
await ws.send(json.dumps({"type": "auth", "token": self.config.bridge_token}))
|
||||
async for raw in ws:
|
||||
await self._handle_bridge_message(raw)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._ws is not None:
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception:
|
||||
logger.exception("whatsapp bridge close failed")
|
||||
self._ws = None
|
||||
await super().stop()
|
||||
|
||||
async def send(self, message: SystemMessage) -> None:
|
||||
await super().send(message)
|
||||
if self._ws is None:
|
||||
logger.warning("whatsapp bridge not connected")
|
||||
return
|
||||
metadata = dict(message.metadata or {})
|
||||
payload = {
|
||||
"type": "send",
|
||||
"to": str(metadata.get("chat_id") or message.session_id or ""),
|
||||
"text": message.content,
|
||||
"attachments": list(metadata.get("attachments", []) or []),
|
||||
}
|
||||
await self._ws.send(json.dumps(payload, ensure_ascii=False))
|
||||
|
||||
async def _handle_bridge_message(self, raw: str) -> None:
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("invalid whatsapp bridge payload: {}", raw[:100])
|
||||
return
|
||||
msg_type = str(payload.get("type", "") or "")
|
||||
if msg_type != "message":
|
||||
if msg_type == "error":
|
||||
self.set_last_error(str(payload.get("error", "bridge error")))
|
||||
return
|
||||
message_id = str(payload.get("id", "") or payload.get("message", {}).get("id", "") or "")
|
||||
if message_id:
|
||||
if message_id in self._processed_message_ids:
|
||||
return
|
||||
self._processed_message_ids[message_id] = None
|
||||
while len(self._processed_message_ids) > 1000:
|
||||
self._processed_message_ids.popitem(last=False)
|
||||
normalized = self.normalize_event(payload)
|
||||
await self.publish_normalized(normalized)
|
||||
Reference in New Issue
Block a user