209 lines
7.1 KiB
Python
209 lines
7.1 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
from datetime import datetime, UTC
|
||
|
|
|
||
|
|
from aiohttp import web
|
||
|
|
|
||
|
|
from yuxi.channel.context import ChannelContext
|
||
|
|
from yuxi.channel.extensions.nextcloud_talk.accounts import resolve_nextcloud_talk_account
|
||
|
|
from yuxi.channel.extensions.nextcloud_talk.config_schema import NextcloudTalkConfig
|
||
|
|
from yuxi.channel.extensions.nextcloud_talk.inbound import handle_nextcloud_talk_inbound
|
||
|
|
from yuxi.channel.extensions.nextcloud_talk.replay_guard import ReplayGuard
|
||
|
|
from yuxi.channel.extensions.nextcloud_talk.room_info import RoomInfoResolver
|
||
|
|
from yuxi.channel.extensions.nextcloud_talk.send import (
|
||
|
|
NextcloudTalkSendError,
|
||
|
|
send_message_nextcloud_talk,
|
||
|
|
)
|
||
|
|
from yuxi.channel.extensions.nextcloud_talk.types import NextcloudTalkInboundMessage
|
||
|
|
from yuxi.channel.extensions.nextcloud_talk.webhook_server import (
|
||
|
|
NextcloudTalkWebhookHandler,
|
||
|
|
create_nextcloud_talk_webhook_app,
|
||
|
|
)
|
||
|
|
from yuxi.channel.message.models import GroupContext, MessageType, PeerInfo, UnifiedMessage
|
||
|
|
from yuxi.channel.routing.models import PeerKind
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
class NextcloudTalkGateway:
|
||
|
|
def __init__(self):
|
||
|
|
self._runner: web.AppRunner | None = None
|
||
|
|
self._site: web.TCPSite | None = None
|
||
|
|
self._account: object | None = None
|
||
|
|
self._cfg: NextcloudTalkConfig | None = None
|
||
|
|
self._ctx: ChannelContext | None = None
|
||
|
|
|
||
|
|
async def start(self, ctx: ChannelContext) -> object:
|
||
|
|
self._ctx = ctx
|
||
|
|
channel_cfg = ctx.config.get("channels", {}).get("nextcloud-talk", {})
|
||
|
|
self._cfg = NextcloudTalkConfig(**channel_cfg)
|
||
|
|
self._account = resolve_nextcloud_talk_account(self._cfg)
|
||
|
|
|
||
|
|
if not self._account.configured:
|
||
|
|
raise RuntimeError("Nextcloud Talk gateway not configured")
|
||
|
|
|
||
|
|
replay_guard = ReplayGuard()
|
||
|
|
|
||
|
|
room_resolver = RoomInfoResolver(
|
||
|
|
base_url=self._account.base_url,
|
||
|
|
api_user=self._account.api_user,
|
||
|
|
api_password=self._account.api_password,
|
||
|
|
)
|
||
|
|
|
||
|
|
async def _send_callback(room_token: str, text: str) -> dict:
|
||
|
|
return await send_message_nextcloud_talk(
|
||
|
|
to=room_token,
|
||
|
|
text=text,
|
||
|
|
base_url=self._account.base_url,
|
||
|
|
secret=self._account.bot_secret,
|
||
|
|
dangerously_allow_private_network=self._cfg.dangerously_allow_private_network,
|
||
|
|
)
|
||
|
|
|
||
|
|
async def _inbound_handler(message: NextcloudTalkInboundMessage):
|
||
|
|
result = await handle_nextcloud_talk_inbound(
|
||
|
|
message=message,
|
||
|
|
cfg=self._cfg,
|
||
|
|
room_resolver=room_resolver,
|
||
|
|
)
|
||
|
|
if result is None:
|
||
|
|
return None
|
||
|
|
|
||
|
|
if result.get("action") == "pairing_challenge":
|
||
|
|
sender_id = result["sender_id"]
|
||
|
|
sender_name = result.get("sender_name", sender_id)
|
||
|
|
room_token = result["room_token"]
|
||
|
|
logger.info(
|
||
|
|
"Nextcloud Talk: pairing challenge for sender '%s'",
|
||
|
|
sender_id,
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
await _send_callback(
|
||
|
|
room_token,
|
||
|
|
f"Hi {sender_name}! Your ForcePilot access requires approval. "
|
||
|
|
f"Your Nextcloud user id: {sender_id}. "
|
||
|
|
f"Please ask an admin to add you to the allowlist.",
|
||
|
|
)
|
||
|
|
except NextcloudTalkSendError as e:
|
||
|
|
logger.warning("Nextcloud Talk: failed to send pairing message: %s", e)
|
||
|
|
return None
|
||
|
|
|
||
|
|
unified_msg = _to_unified_message(message, result, ctx)
|
||
|
|
if ctx.queue is not None:
|
||
|
|
await ctx.queue.put(unified_msg)
|
||
|
|
return result
|
||
|
|
|
||
|
|
handler = NextcloudTalkWebhookHandler(
|
||
|
|
secret=self._account.bot_secret,
|
||
|
|
replay_guard=replay_guard,
|
||
|
|
base_url=self._account.base_url,
|
||
|
|
inbound_handler=_inbound_handler,
|
||
|
|
send_callback=_send_callback,
|
||
|
|
auto_join_message=self._cfg.auto_join_message,
|
||
|
|
)
|
||
|
|
|
||
|
|
app = create_nextcloud_talk_webhook_app(handler, webhook_path=self._cfg.webhook_path)
|
||
|
|
|
||
|
|
self._runner = web.AppRunner(app)
|
||
|
|
await self._runner.setup()
|
||
|
|
|
||
|
|
self._site = web.TCPSite(
|
||
|
|
self._runner,
|
||
|
|
host=self._cfg.webhook_host,
|
||
|
|
port=self._cfg.webhook_port,
|
||
|
|
)
|
||
|
|
await self._site.start()
|
||
|
|
|
||
|
|
public_url = (
|
||
|
|
self._cfg.webhook_public_url
|
||
|
|
or f"http://{self._cfg.webhook_host}:{self._cfg.webhook_port}{self._cfg.webhook_path}"
|
||
|
|
)
|
||
|
|
logger.info(
|
||
|
|
"Nextcloud Talk webhook listening on %s:%d (public: %s)",
|
||
|
|
self._cfg.webhook_host,
|
||
|
|
self._cfg.webhook_port,
|
||
|
|
public_url,
|
||
|
|
)
|
||
|
|
|
||
|
|
await ctx.cancel_event.wait()
|
||
|
|
|
||
|
|
await self._cleanup()
|
||
|
|
|
||
|
|
async def stop(self, ctx: ChannelContext) -> None:
|
||
|
|
ctx.cancel_event.set()
|
||
|
|
await self._cleanup()
|
||
|
|
|
||
|
|
async def _cleanup(self) -> None:
|
||
|
|
if self._site:
|
||
|
|
await self._site.stop()
|
||
|
|
self._site = None
|
||
|
|
|
||
|
|
if self._runner:
|
||
|
|
await self._runner.cleanup()
|
||
|
|
self._runner = None
|
||
|
|
|
||
|
|
logger.info("Nextcloud Talk webhook stopped")
|
||
|
|
|
||
|
|
async def check_health(self) -> bool:
|
||
|
|
return self._site is not None
|
||
|
|
|
||
|
|
@property
|
||
|
|
def account(self):
|
||
|
|
return self._account
|
||
|
|
|
||
|
|
@property
|
||
|
|
def cfg(self) -> NextcloudTalkConfig | None:
|
||
|
|
return self._cfg
|
||
|
|
|
||
|
|
|
||
|
|
def _to_unified_message(
|
||
|
|
message: NextcloudTalkInboundMessage,
|
||
|
|
inbound_result: dict,
|
||
|
|
ctx: ChannelContext,
|
||
|
|
) -> UnifiedMessage:
|
||
|
|
chat_type = inbound_result.get("ChatType", "group")
|
||
|
|
peer_kind = PeerKind.DIRECT if chat_type == "direct" else PeerKind.GROUP
|
||
|
|
|
||
|
|
sender = PeerInfo(
|
||
|
|
kind=peer_kind,
|
||
|
|
id=message.sender_id,
|
||
|
|
display_name=message.sender_name,
|
||
|
|
)
|
||
|
|
|
||
|
|
group = None
|
||
|
|
if chat_type == "group":
|
||
|
|
group = GroupContext(
|
||
|
|
id=message.room_token,
|
||
|
|
name=message.room_name,
|
||
|
|
)
|
||
|
|
|
||
|
|
metadata = {
|
||
|
|
"room_token": message.room_token,
|
||
|
|
"room_name": message.room_name,
|
||
|
|
"sender_name": message.sender_name,
|
||
|
|
"media_type": message.media_type,
|
||
|
|
"content_format": "markdown" if message.media_type == "text/markdown" else "plain",
|
||
|
|
"was_mentioned": inbound_result.get("WasMentioned", False),
|
||
|
|
}
|
||
|
|
if message.reply_to_id:
|
||
|
|
metadata["reply_to_id"] = message.reply_to_id
|
||
|
|
if message.participant_type is not None:
|
||
|
|
metadata["participant_type"] = message.participant_type
|
||
|
|
|
||
|
|
group_system_prompt = inbound_result.get("GroupSystemPrompt")
|
||
|
|
if group_system_prompt:
|
||
|
|
metadata["group_system_prompt"] = group_system_prompt
|
||
|
|
|
||
|
|
return UnifiedMessage(
|
||
|
|
msg_id=message.message_id,
|
||
|
|
channel_type="nextcloud-talk",
|
||
|
|
account_id=ctx.account_id,
|
||
|
|
content=message.text,
|
||
|
|
sender=sender,
|
||
|
|
message_type=MessageType.TEXT,
|
||
|
|
group=group,
|
||
|
|
timestamp=datetime.now(UTC),
|
||
|
|
metadata=metadata,
|
||
|
|
conversation_label=inbound_result.get("ConversationLabel"),
|
||
|
|
)
|