新增 Twitter 和 Viber 两个渠道扩展。 Twitter 渠道扩展功能模块: - auth: OAuth 认证管理 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session: 会话管理 - tweets: 推文管理 - social: 社交互动 - reactions: 表情反应 - media: 媒体资源处理 Viber 渠道扩展功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - rate_limiter: 速率限制 - media: 媒体资源处理
260 lines
9.4 KiB
Python
260 lines
9.4 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
|
|
import tweepy
|
|
|
|
from yuxi.channel.extensions.twitter.auth import create_tweepy_client
|
|
from yuxi.channel.extensions.twitter.config import TwitterConfigAdapter
|
|
from yuxi.channel.extensions.twitter.dedupe import get_deduplicator
|
|
from yuxi.channel.extensions.twitter.monitor import (
|
|
convert_dm_event_to_unified,
|
|
convert_group_event_to_unified,
|
|
)
|
|
from yuxi.channel.extensions.twitter.security import TwitterSecurity
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TwitterGateway:
|
|
MODE_WEBHOOK = "webhook"
|
|
MODE_POLLING = "polling"
|
|
|
|
def __init__(self):
|
|
self._config_adapter = TwitterConfigAdapter()
|
|
self._security = TwitterSecurity()
|
|
self._tasks: list[asyncio.Task] = []
|
|
self._running = False
|
|
self._queue: asyncio.Queue | None = None
|
|
self._abort_event: asyncio.Event | None = None
|
|
self._account: dict = {}
|
|
self._mode: str | None = None
|
|
self._client: tweepy.Client | None = None
|
|
|
|
async def start(self, ctx) -> object:
|
|
account = await self._resolve_account(ctx)
|
|
self._account = account
|
|
if not account.get("api_key") or not account.get("access_token"):
|
|
logger.warning(
|
|
"twitter account %s not configured, skipping start",
|
|
account.get("account_id"),
|
|
)
|
|
return {"running": False, "reason": "not-configured"}
|
|
|
|
self._queue = asyncio.Queue(maxsize=1000)
|
|
self._abort_event = asyncio.Event()
|
|
self._running = True
|
|
|
|
self._client = create_tweepy_client(account)
|
|
|
|
connection_mode = account.get("connection_mode", "auto")
|
|
webhook_url = account.get("webhook_url", "")
|
|
|
|
if connection_mode == "webhook" or (connection_mode == "auto" and webhook_url):
|
|
self._mode = self.MODE_WEBHOOK
|
|
task = asyncio.create_task(
|
|
self._start_webhook_mode(account, self._queue, self._abort_event)
|
|
)
|
|
else:
|
|
self._mode = self.MODE_POLLING
|
|
task = asyncio.create_task(
|
|
self._poll_loop(account, self._queue, self._abort_event)
|
|
)
|
|
|
|
self._tasks.append(task)
|
|
logger.info(
|
|
"twitter gateway started for account %s (mode=%s)",
|
|
account.get("account_id"),
|
|
self._mode,
|
|
)
|
|
return {
|
|
"running": True,
|
|
"account_id": account.get("account_id"),
|
|
"queue": self._queue,
|
|
"mode": self._mode,
|
|
}
|
|
|
|
async def stop(self, ctx) -> None:
|
|
self._running = False
|
|
if self._abort_event:
|
|
self._abort_event.set()
|
|
|
|
for task in self._tasks:
|
|
task.cancel()
|
|
self._tasks.clear()
|
|
|
|
if self._mode == self.MODE_WEBHOOK and self._account.get("webhook_url"):
|
|
await self._cleanup_webhook(self._account)
|
|
|
|
self._queue = None
|
|
self._abort_event = None
|
|
self._client = None
|
|
self._account = {}
|
|
self._mode = None
|
|
logger.info("twitter gateway stopped")
|
|
|
|
async def _poll_loop(
|
|
self, account: dict, queue: asyncio.Queue, abort_event: asyncio.Event
|
|
) -> None:
|
|
deduplicator = get_deduplicator(account.get("account_id", "default"))
|
|
self_user_id = account.get("user_id", "")
|
|
|
|
try:
|
|
me = await asyncio.to_thread(self._client.get_me)
|
|
me_data = me.get("data", {})
|
|
if me_data.get("id"):
|
|
self_user_id = str(me_data["id"])
|
|
except Exception:
|
|
logger.warning(
|
|
"Twitter: cannot get self_user_id from API, falling back to config"
|
|
)
|
|
|
|
since_id: str | None = None
|
|
interval = account.get("polling_interval_sec", 180)
|
|
|
|
while self._running and not abort_event.is_set():
|
|
try:
|
|
if not self._client:
|
|
self._client = create_tweepy_client(account)
|
|
|
|
kwargs: dict = {
|
|
"max_results": 50,
|
|
"dm_event_fields": [
|
|
"id",
|
|
"text",
|
|
"event_type",
|
|
"created_at",
|
|
"sender_id",
|
|
"participant_ids",
|
|
"dm_conversation_id",
|
|
"attachments",
|
|
"referenced_tweets",
|
|
],
|
|
"expansions": ["sender_id", "attachments.media_key"],
|
|
}
|
|
if since_id:
|
|
kwargs["since_id"] = since_id
|
|
|
|
response = await asyncio.to_thread(
|
|
self._client.get_dm_events,
|
|
**kwargs,
|
|
)
|
|
|
|
events = response.get("data", [])
|
|
if events:
|
|
for event in events:
|
|
event_type = event.get("event_type", "")
|
|
if event_type not in (
|
|
"MessageCreate",
|
|
"ParticipantsJoin",
|
|
"ParticipantsLeave",
|
|
):
|
|
continue
|
|
|
|
if event_type in ("ParticipantsJoin", "ParticipantsLeave"):
|
|
unified = convert_group_event_to_unified(
|
|
event, account.get("account_id", "default")
|
|
)
|
|
else:
|
|
sender_id = str(event.get("sender_id", ""))
|
|
if sender_id == self_user_id:
|
|
continue
|
|
unified = convert_dm_event_to_unified(
|
|
event, account.get("account_id", "default")
|
|
)
|
|
|
|
if unified:
|
|
event_id = str(event.get("id", ""))
|
|
if deduplicator.is_duplicate(event_id):
|
|
continue
|
|
try:
|
|
queue.put_nowait(unified)
|
|
except asyncio.QueueFull:
|
|
logger.warning(
|
|
"Twitter message queue full, dropping message"
|
|
)
|
|
|
|
since_id = str(events[-1].get("id", ""))
|
|
|
|
except tweepy.TooManyRequests:
|
|
logger.warning("Twitter polling rate limited, waiting %ds", interval)
|
|
await asyncio.sleep(interval)
|
|
except Exception:
|
|
logger.exception(
|
|
"Twitter polling error for account %s", account.get("account_id")
|
|
)
|
|
await asyncio.sleep(min(interval, 60))
|
|
|
|
try:
|
|
await asyncio.wait_for(abort_event.wait(), timeout=interval)
|
|
break
|
|
except TimeoutError:
|
|
pass
|
|
|
|
async def _start_webhook_mode(
|
|
self, account: dict, queue: asyncio.Queue, abort_event: asyncio.Event
|
|
) -> None:
|
|
from yuxi.channel.extensions.twitter.webhook import (
|
|
register_webhook,
|
|
subscribe_account_activity,
|
|
)
|
|
|
|
try:
|
|
await register_webhook(account)
|
|
await subscribe_account_activity(account)
|
|
logger.info(
|
|
"Twitter webhook registered and subscribed for account %s",
|
|
account.get("account_id"),
|
|
)
|
|
except Exception:
|
|
logger.exception("Twitter webhook setup failed, falling back to polling")
|
|
self._mode = self.MODE_POLLING
|
|
task = asyncio.create_task(self._poll_loop(account, queue, abort_event))
|
|
self._tasks.append(task)
|
|
return
|
|
|
|
while not abort_event.is_set():
|
|
await asyncio.sleep(1)
|
|
|
|
async def _cleanup_webhook(self, account: dict) -> None:
|
|
from yuxi.channel.extensions.twitter.webhook import unsubscribe_account_activity
|
|
|
|
try:
|
|
await unsubscribe_account_activity(account)
|
|
except Exception:
|
|
logger.exception("Twitter webhook cleanup failed")
|
|
|
|
async def handle_webhook_callback(self, body: bytes, signature: str) -> dict:
|
|
from yuxi.channel.extensions.twitter.webhook import verify_webhook_signature
|
|
from yuxi.channel.extensions.twitter.monitor import (
|
|
convert_webhook_event_to_unified,
|
|
)
|
|
|
|
account = self._account
|
|
if not verify_webhook_signature(signature, body, account.get("api_secret", "")):
|
|
logger.warning("Twitter webhook: invalid signature")
|
|
return {"status": "invalid_signature"}
|
|
|
|
data = json.loads(body)
|
|
for dm_event in data.get("direct_message_events", []):
|
|
unified = convert_webhook_event_to_unified(
|
|
dm_event,
|
|
account.get("user_id", ""),
|
|
account.get("account_id", "default"),
|
|
)
|
|
if unified and self._queue:
|
|
try:
|
|
self._queue.put_nowait(unified)
|
|
except asyncio.QueueFull:
|
|
logger.warning("Twitter webhook: queue full, dropping message")
|
|
|
|
return {"status": "ok"}
|
|
|
|
async def _resolve_account(self, ctx) -> dict:
|
|
config = getattr(ctx, "config", {}) if ctx else {}
|
|
account_id = getattr(ctx, "account_id", "default")
|
|
self._config_adapter.list_account_ids(config)
|
|
return await self._config_adapter.resolve_account(account_id)
|