274 lines
9.3 KiB
Python
274 lines
9.3 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from yuxi.channel.capabilities import ChannelCapabilities
|
|
from yuxi.channel.extensions.base import BaseChannelPlugin
|
|
from yuxi.channel.protocols import SessionResolution
|
|
|
|
from .config import EmailSmtpConfigAdapter
|
|
from .dedupe import EmailDeduplicator
|
|
from .format import build_email_system_prompt
|
|
from .gateway import EmailSmtpGatewayAdapter
|
|
from .outbound import EmailSmtpOutboundAdapter
|
|
from .pairing import EmailSmtpPairingAdapter
|
|
from .security import EmailSmtpSecurityAdapter
|
|
from .session import EmailSmtpSessionAdapter
|
|
from .status import EmailSmtpStatusAdapter
|
|
from .streaming import EmailSmtpStreamingAdapter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EmailSmtpPlugin(BaseChannelPlugin):
|
|
id = "email-smtp"
|
|
name = "\u90ae\u4ef6 (SMTP/IMAP)"
|
|
order = 80
|
|
label = "\u90ae\u4ef6"
|
|
aliases = ["email", "smtp", "imap", "mail"]
|
|
|
|
def __init__(self):
|
|
self._config = EmailSmtpConfigAdapter()
|
|
self._outbound = EmailSmtpOutboundAdapter()
|
|
self._dedupe = EmailDeduplicator()
|
|
self._gateway = EmailSmtpGatewayAdapter(self._outbound, dedupe=self._dedupe)
|
|
self._security = EmailSmtpSecurityAdapter()
|
|
self._pairing = EmailSmtpPairingAdapter()
|
|
self._session = EmailSmtpSessionAdapter()
|
|
self._streaming = EmailSmtpStreamingAdapter(self._outbound)
|
|
self._status = EmailSmtpStatusAdapter()
|
|
|
|
@property
|
|
def capabilities(self) -> ChannelCapabilities:
|
|
return ChannelCapabilities(
|
|
chat_types=["direct"],
|
|
message_types=["text"],
|
|
reactions=False,
|
|
typing_indicator=False,
|
|
threads=True,
|
|
edit=False,
|
|
unsend=False,
|
|
reply=True,
|
|
media=True,
|
|
native_commands=False,
|
|
polls=False,
|
|
streaming=True,
|
|
streaming_mode="block",
|
|
block_streaming=True,
|
|
block_streaming_chunk_min_chars=800,
|
|
block_streaming_chunk_max_chars=1200,
|
|
block_streaming_chunk_break_preference="paragraph",
|
|
)
|
|
|
|
def list_account_ids(self, config: dict) -> list[str]:
|
|
return self._config.list_account_ids(config)
|
|
|
|
async def resolve_account(self, account_id: str) -> dict:
|
|
return self._config.resolve_account(account_id)
|
|
|
|
def is_configured(self, account: dict) -> bool:
|
|
return self._config.is_configured(account)
|
|
|
|
def is_enabled(self, account: dict) -> bool:
|
|
return self._config.is_enabled(account)
|
|
|
|
def disabled_reason(self, account: dict) -> str:
|
|
return self._config.disabled_reason(account)
|
|
|
|
def describe_account(self, account: dict) -> dict:
|
|
return self._config.describe_account(account)
|
|
|
|
async def start(self, ctx) -> object:
|
|
return await self._gateway.start(ctx)
|
|
|
|
async def stop(self, ctx) -> None:
|
|
await self._gateway.stop(ctx)
|
|
|
|
async def send_text(
|
|
self,
|
|
target_id: str,
|
|
content: str,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
cc_list: list[str] | None = None,
|
|
bcc_list: list[str] | None = None,
|
|
) -> None:
|
|
await self._outbound.send_text(
|
|
target_id,
|
|
content,
|
|
reply_to_id=reply_to_id,
|
|
thread_id=thread_id,
|
|
account_id=account_id,
|
|
cc_list=cc_list,
|
|
bcc_list=bcc_list,
|
|
)
|
|
|
|
async def send_media(
|
|
self,
|
|
target_id: str,
|
|
media_url: str,
|
|
media_type: str,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
await self._outbound.send_media(
|
|
target_id,
|
|
media_url,
|
|
media_type,
|
|
reply_to_id=reply_to_id,
|
|
thread_id=thread_id,
|
|
account_id=account_id,
|
|
)
|
|
|
|
async def probe(self, account: dict) -> bool:
|
|
return await self._status.probe(account)
|
|
|
|
def build_summary(self, snapshot: object) -> dict:
|
|
return self._status.build_summary(snapshot)
|
|
|
|
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
|
|
return self._security.check_allowlist(peer_id)
|
|
|
|
def resolve_dm_policy(self) -> dict:
|
|
return self._security.resolve_dm_policy()
|
|
|
|
async def generate_code(self, peer_id: str) -> str:
|
|
return self._pairing.generate_code(peer_id)
|
|
|
|
async def verify_code(self, peer_id: str, code: str) -> bool:
|
|
return self._pairing.verify_code(peer_id, code)
|
|
|
|
def normalize_allow_entry(self, entry: str) -> str:
|
|
return self._pairing.normalize_allow_entry(entry)
|
|
|
|
def is_duplicate(self, key: str) -> bool:
|
|
return self._dedupe.is_duplicate(key)
|
|
|
|
def mark_seen(self, key: str) -> None:
|
|
self._dedupe.mark(key)
|
|
|
|
def build_system_prompt(self, context) -> str | None:
|
|
return build_email_system_prompt()
|
|
|
|
def build_context_note(self, context) -> str:
|
|
return ""
|
|
|
|
def extract_thread_id(self, msg: object) -> str | None:
|
|
if hasattr(msg, "message_thread_id"):
|
|
return msg.message_thread_id
|
|
return None
|
|
|
|
def resolve_session(self, msg: object) -> SessionResolution:
|
|
from yuxi.channel.message.models import PeerKind
|
|
|
|
if hasattr(msg, "sender") and hasattr(msg.sender, "kind"):
|
|
if msg.sender.kind == PeerKind.DIRECT:
|
|
session = self._session.resolve_session(msg)
|
|
return SessionResolution(
|
|
kind="direct",
|
|
conversation_id=msg.sender.id,
|
|
thread_id=session.thread_id,
|
|
label=session.label,
|
|
)
|
|
gid = msg.group.id if hasattr(msg, "group") and msg.group and msg.group.id else "unknown"
|
|
return SessionResolution(kind="group", conversation_id=gid)
|
|
|
|
@property
|
|
def streaming_mode(self) -> str:
|
|
return self._streaming.streaming_mode
|
|
|
|
@property
|
|
def preview_stream_throttle_ms(self) -> int:
|
|
return self._streaming.preview_stream_throttle_ms
|
|
|
|
@property
|
|
def block_streaming_enabled(self) -> bool:
|
|
return self._streaming.block_streaming_enabled
|
|
|
|
@property
|
|
def block_streaming_chunk_min_chars(self) -> int:
|
|
return self._streaming.block_streaming_chunk_min_chars
|
|
|
|
@property
|
|
def block_streaming_chunk_max_chars(self) -> int:
|
|
return self._streaming.block_streaming_chunk_max_chars
|
|
|
|
@property
|
|
def block_streaming_chunk_break_preference(self) -> str:
|
|
return self._streaming.block_streaming_chunk_break_preference
|
|
|
|
@property
|
|
def block_streaming_coalesce_defaults(self) -> dict | None:
|
|
return self._streaming.block_streaming_coalesce_defaults
|
|
|
|
def create_block_chunker(self) -> object:
|
|
return self._streaming.create_block_chunker()
|
|
|
|
def create_draft_stream_session(self, target_id: str) -> object:
|
|
return self._streaming.create_draft_stream_session(target_id)
|
|
|
|
@property
|
|
def channel_format_instructions(self) -> str | None:
|
|
return (
|
|
"You are sending email replies via SMTP. "
|
|
"Use plain paragraphs separated by blank lines. "
|
|
"Use **bold** for emphasis. Do not use Markdown tables or code blocks. "
|
|
"Each reply should be concise and focused on the user's question. "
|
|
"Do not include greetings like 'Hello' or 'Dear' at the start. "
|
|
"Sign-off is optional - the system will add its own footer."
|
|
)
|
|
|
|
@property
|
|
def ping_interval_ms(self) -> int:
|
|
return 5 * 60 * 1000
|
|
|
|
async def on_ping(self, account: dict) -> bool:
|
|
try:
|
|
imap_client = self._gateway.get_imap_client(account.get("account_id", "default"))
|
|
if imap_client and imap_client._mailbox:
|
|
await asyncio.to_thread(imap_client._mailbox.noop)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None:
|
|
client = self._gateway.get_imap_client(account_id)
|
|
if client:
|
|
await client.disconnect()
|
|
|
|
async def on_account_removed(self, account_id: str) -> None:
|
|
client = self._gateway.get_imap_client(account_id)
|
|
if client:
|
|
await client.disconnect()
|
|
|
|
async def on_message_received(self, msg: object) -> object | None:
|
|
return msg
|
|
|
|
async def on_message_sending(self, msg: object, content: str) -> str | None:
|
|
logger.debug("Sending to %s: %s...", getattr(msg, "target_id", "unknown"), content[:100])
|
|
return content
|
|
|
|
async def before_send_attempt(self, target_id: str, content: str) -> None:
|
|
logger.debug("Before send attempt to %s", target_id)
|
|
|
|
async def after_send_success(self, target_id: str, receipt: object) -> None:
|
|
logger.info("Sent successfully to %s", target_id)
|
|
|
|
async def after_send_failure(self, target_id: str, error: str) -> None:
|
|
logger.error("Send failed to %s: %s", target_id, error)
|
|
|
|
def config_schema(self) -> dict:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"accounts": {
|
|
"type": "object",
|
|
"description": "\u591a\u8d26\u6237\u914d\u7f6e",
|
|
},
|
|
},
|
|
}
|