Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
from opc.channels.base import BaseChannel
from opc.channels.manager import ChannelManager
__all__ = ["BaseChannel", "ChannelManager"]
+203
View File
@@ -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
+117
View File
@@ -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())
+148
View File
@@ -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)
+267
View File
@@ -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}"
+188
View File
@@ -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)
+120
View File
@@ -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)
+168
View File
@@ -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}}
+169
View File
@@ -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)
+155
View File
@@ -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
+138
View File
@@ -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",
)]
+101
View File
@@ -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,
)
+40
View File
@@ -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,
)
+178
View File
@@ -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()
+129
View File
@@ -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
+111
View File
@@ -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)