|
# retoor <retoor@molodetz.nl>
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import random
|
|
import traceback
|
|
import time
|
|
from aiohttp import web
|
|
from snek.system.model import now
|
|
from snek.system.view import BaseView
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def safe_get(obj, key, default=None):
|
|
if obj is None:
|
|
return default
|
|
try:
|
|
if isinstance(obj, dict):
|
|
return obj.get(key, default)
|
|
if hasattr(obj, "fields") and hasattr(obj, "__getitem__"):
|
|
val = obj[key]
|
|
return val if val is not None else default
|
|
return getattr(obj, key, default)
|
|
except (KeyError, TypeError, AttributeError):
|
|
return default
|
|
|
|
|
|
def safe_str(obj):
|
|
if obj is None:
|
|
return ""
|
|
try:
|
|
return str(obj)
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
class RPCView(BaseView):
|
|
class RPCApi:
|
|
def __init__(self, view, ws):
|
|
self.view = view
|
|
self.app = safe_get(view, "app")
|
|
self.services = safe_get(self.app, "services") if self.app else None
|
|
self.ws = ws
|
|
self.user_session = {}
|
|
self._scheduled_tasks = []
|
|
self._finalize_task = None
|
|
self._is_closed = False
|
|
|
|
async def _session_ensure(self):
|
|
try:
|
|
uid = await self.view.session_get("uid") if self.view else None
|
|
if uid and uid not in self.user_session:
|
|
self.user_session[uid] = {"said_hello": False}
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to ensure session: {safe_str(ex)}")
|
|
|
|
async def session_get(self, key, default=None):
|
|
try:
|
|
await self._session_ensure()
|
|
uid = self.user_uid
|
|
if not uid or uid not in self.user_session:
|
|
return default
|
|
return self.user_session[uid].get(key, default)
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to get session key {key}: {safe_str(ex)}")
|
|
return default
|
|
|
|
async def session_set(self, key, value):
|
|
try:
|
|
await self._session_ensure()
|
|
uid = self.user_uid
|
|
if not uid:
|
|
return False
|
|
if uid not in self.user_session:
|
|
self.user_session[uid] = {}
|
|
self.user_session[uid][key] = value
|
|
return True
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to set session key {key}: {safe_str(ex)}")
|
|
return False
|
|
|
|
async def db_insert(self, table_name, record):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not table_name or not isinstance(table_name, str):
|
|
raise ValueError("Invalid table name")
|
|
if not record or not isinstance(record, dict):
|
|
raise ValueError("Invalid record")
|
|
return await self.services.db.insert(self.user_uid, table_name, record)
|
|
|
|
async def db_update(self, table_name, record):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not table_name or not isinstance(table_name, str):
|
|
raise ValueError("Invalid table name")
|
|
if not record or not isinstance(record, dict):
|
|
raise ValueError("Invalid record")
|
|
return await self.services.db.update(self.user_uid, table_name, record)
|
|
|
|
async def set_typing(self, channel_uid, color=None):
|
|
logger.debug(f"set_typing: called channel_uid={channel_uid}, user_uid={self.user_uid}, color={color}")
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
logger.warning(f"set_typing: invalid channel_uid: {channel_uid}")
|
|
return False
|
|
try:
|
|
user = await self.services.user.get(uid=self.user_uid)
|
|
if not user:
|
|
logger.warning(f"set_typing: user not found: {self.user_uid}")
|
|
return False
|
|
if not color:
|
|
color = safe_get(user, "color", "#000000")
|
|
logger.debug(f"set_typing: broadcasting typing indicator to channel={channel_uid}, user={safe_get(user, 'username')}")
|
|
result = await self.services.socket.broadcast(
|
|
channel_uid,
|
|
{
|
|
"channel_uid": channel_uid,
|
|
"event": "set_typing",
|
|
"data": {
|
|
"event": "set_typing",
|
|
"user_uid": safe_get(user, "uid", ""),
|
|
"username": safe_get(user, "username", ""),
|
|
"nick": safe_get(user, "nick", ""),
|
|
"channel_uid": channel_uid,
|
|
"color": color,
|
|
},
|
|
},
|
|
)
|
|
logger.debug(f"set_typing: broadcast result={result}")
|
|
return result
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to set typing: {safe_str(ex)}")
|
|
return False
|
|
|
|
async def db_delete(self, table_name, record):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not table_name or not isinstance(table_name, str):
|
|
raise ValueError("Invalid table name")
|
|
if not record or not isinstance(record, dict):
|
|
raise ValueError("Invalid record")
|
|
return await self.services.db.delete(self.user_uid, table_name, record)
|
|
|
|
async def db_get(self, table_name, record):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not table_name or not isinstance(table_name, str):
|
|
raise ValueError("Invalid table name")
|
|
return await self.services.db.get(self.user_uid, table_name, record)
|
|
|
|
async def db_find(self, table_name, record):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not table_name or not isinstance(table_name, str):
|
|
raise ValueError("Invalid table name")
|
|
return await self.services.db.find(self.user_uid, table_name, record)
|
|
|
|
async def db_upsert(self, table_name, record, keys):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not table_name or not isinstance(table_name, str):
|
|
raise ValueError("Invalid table name")
|
|
if not record or not isinstance(record, dict):
|
|
raise ValueError("Invalid record")
|
|
return await self.services.db.upsert(
|
|
self.user_uid, table_name, record, keys
|
|
)
|
|
|
|
async def db_query(self, sql, args):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not sql or not isinstance(sql, str):
|
|
raise ValueError("Invalid SQL query")
|
|
return await self.services.db.query(self.user_uid, sql, args)
|
|
|
|
@property
|
|
def user_uid(self):
|
|
try:
|
|
if not self.view or not hasattr(self.view, "session"):
|
|
return None
|
|
session = self.view.session
|
|
if session is None:
|
|
return None
|
|
if hasattr(session, "get"):
|
|
return session.get("uid")
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
@property
|
|
def request(self):
|
|
return safe_get(self.view, "request")
|
|
|
|
def _require_login(self):
|
|
if not self.is_logged_in:
|
|
raise PermissionError("Not logged in")
|
|
|
|
def _require_services(self):
|
|
if not self.services:
|
|
raise RuntimeError("Services not available")
|
|
|
|
@property
|
|
def is_logged_in(self):
|
|
try:
|
|
if not self.view or not hasattr(self.view, "session"):
|
|
return False
|
|
session = self.view.session
|
|
if session is None:
|
|
return False
|
|
return bool(session.get("logged_in", False))
|
|
except Exception:
|
|
return False
|
|
|
|
async def mark_as_read(self, channel_uid):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
return False
|
|
try:
|
|
await self.services.channel_member.mark_as_read(channel_uid, self.user_uid)
|
|
return True
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to mark as read: {safe_str(ex)}")
|
|
return False
|
|
|
|
async def login(self, username, password):
|
|
self._require_services()
|
|
if not username or not isinstance(username, str):
|
|
raise ValueError("Invalid username")
|
|
if not password or not isinstance(password, str):
|
|
raise ValueError("Invalid password")
|
|
try:
|
|
success = await self.services.user.validate_login(username, password)
|
|
if not success:
|
|
raise PermissionError("Invalid username or password")
|
|
user = await self.services.user.get(username=username)
|
|
if not user:
|
|
raise PermissionError("User not found")
|
|
if not self.view or not hasattr(self.view, "session") or self.view.session is None:
|
|
raise RuntimeError("Session not available")
|
|
self.view.session["uid"] = safe_get(user, "uid")
|
|
self.view.session["logged_in"] = True
|
|
self.view.session["username"] = safe_get(user, "username", "")
|
|
self.view.session["user_nick"] = safe_get(user, "nick", "")
|
|
record = dict(user.record) if hasattr(user, "record") else {}
|
|
record.pop("password", None)
|
|
record.pop("deleted_at", None)
|
|
user_uid = None
|
|
if self.view and hasattr(self.view, "session") and self.view.session:
|
|
user_uid = self.view.session.get("uid") if hasattr(self.view.session, "get") else None
|
|
if user_uid:
|
|
await self.services.socket.add(self.ws, user_uid)
|
|
try:
|
|
async for subscription in self.services.channel_member.find(
|
|
user_uid=user_uid,
|
|
deleted_at=None,
|
|
is_banned=False,
|
|
):
|
|
if subscription and subscription["channel_uid"]:
|
|
await self.services.socket.subscribe(
|
|
self.ws,
|
|
subscription["channel_uid"],
|
|
user_uid,
|
|
)
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to subscribe to channels: {safe_str(ex)}")
|
|
return record
|
|
except (PermissionError, ValueError):
|
|
raise
|
|
except Exception as ex:
|
|
logger.error(f"Login error: {safe_str(ex)}")
|
|
raise PermissionError("Login failed")
|
|
|
|
async def search_user(self, query):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not query or not isinstance(query, str):
|
|
return []
|
|
try:
|
|
results = await self.services.user.search(query)
|
|
return [safe_get(user, "username", "") for user in (results or []) if user]
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to search user: {safe_str(ex)}")
|
|
return []
|
|
|
|
async def get_user(self, user_uid=None):
|
|
self._require_login()
|
|
self._require_services()
|
|
try:
|
|
if not user_uid:
|
|
user_uid = self.user_uid
|
|
if not user_uid:
|
|
return None
|
|
user = await self.services.user.get(uid=user_uid)
|
|
if not user:
|
|
return None
|
|
record = dict(user.record) if hasattr(user, "record") else {}
|
|
record.pop("password", None)
|
|
record.pop("deleted_at", None)
|
|
if user_uid != self.user_uid:
|
|
record.pop("email", None)
|
|
return record
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to get user: {safe_str(ex)}")
|
|
return None
|
|
|
|
async def get_messages(self, channel_uid, offset=0, timestamp=None):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
return []
|
|
try:
|
|
offset = int(offset) if offset else 0
|
|
if offset < 0:
|
|
offset = 0
|
|
messages = []
|
|
result = await self.services.channel_message.offset(
|
|
channel_uid, offset, timestamp or None
|
|
)
|
|
for message in (result or []):
|
|
if message:
|
|
try:
|
|
extended_dict = await self.services.channel_message.to_extended_dict(message)
|
|
if extended_dict:
|
|
messages.append(extended_dict)
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to extend message: {safe_str(ex)}")
|
|
return messages
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to get messages: {safe_str(ex)}")
|
|
return []
|
|
|
|
async def get_first_unread_message_uid(self, channel_uid):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
return None
|
|
try:
|
|
channel_member = await self.services.channel_member.get(
|
|
channel_uid=channel_uid, user_uid=self.user_uid
|
|
)
|
|
if not channel_member:
|
|
return None
|
|
last_read_at = safe_get(channel_member, "last_read_at")
|
|
if not last_read_at:
|
|
return None
|
|
async for message in self.services.channel_message.query(
|
|
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid AND created_at > :last_read_at AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1",
|
|
{"channel_uid": channel_uid, "last_read_at": last_read_at}
|
|
):
|
|
return safe_get(message, "uid")
|
|
return None
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to get first unread message: {safe_str(ex)}")
|
|
return None
|
|
|
|
async def get_channels(self):
|
|
self._require_login()
|
|
self._require_services()
|
|
channels = []
|
|
try:
|
|
async for subscription in self.services.channel_member.find(
|
|
user_uid=self.user_uid, is_banned=False
|
|
):
|
|
if not subscription:
|
|
continue
|
|
try:
|
|
channel_uid = subscription["channel_uid"]
|
|
if not channel_uid:
|
|
continue
|
|
channel = await self.services.channel.get(uid=channel_uid)
|
|
if not channel:
|
|
continue
|
|
color = None
|
|
try:
|
|
last_message = await channel.get_last_message()
|
|
if last_message:
|
|
last_message_user = await last_message.get_user()
|
|
color = safe_get(last_message_user, "color") if last_message_user else None
|
|
except Exception:
|
|
pass
|
|
channels.append({
|
|
"name": subscription["label"] or "",
|
|
"uid": channel_uid,
|
|
"tag": channel["tag"] or "",
|
|
"new_count": subscription["new_count"] or 0,
|
|
"is_moderator": subscription["is_moderator"] or False,
|
|
"is_read_only": subscription["is_read_only"] or False,
|
|
"color": color,
|
|
})
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to process channel subscription: {safe_str(ex)}")
|
|
return channels
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to get channels: {safe_str(ex)}")
|
|
return []
|
|
|
|
async def write_container(self, channel_uid, content, timeout=3):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
raise ValueError("Invalid channel_uid")
|
|
try:
|
|
channel_member = await self.services.channel_member.get(
|
|
channel_uid=channel_uid, user_uid=self.user_uid
|
|
)
|
|
if not channel_member:
|
|
raise PermissionError("Not allowed")
|
|
container_name = await self.services.container.get_container_name(channel_uid)
|
|
if not container_name:
|
|
raise RuntimeError("Container not found")
|
|
timeout = max(1, min(int(timeout) if timeout else 3, 30))
|
|
|
|
class SessionCall:
|
|
def __init__(self, app, channel_uid, container_name):
|
|
self.app = app
|
|
self.channel_uid = channel_uid
|
|
self.container_name = container_name
|
|
self.time_last_output = time.time()
|
|
self.output = b""
|
|
|
|
async def stdout_event_handler(self, data):
|
|
try:
|
|
self.time_last_output = time.time()
|
|
if data:
|
|
self.output += data if isinstance(data, bytes) else data.encode("utf-8", "ignore")
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
async def communicate(self, content, timeout=3):
|
|
try:
|
|
if self.app and hasattr(self.app, "services") and self.app.services:
|
|
await self.app.services.container.add_event_listener(
|
|
self.container_name, "stdout", self.stdout_event_handler
|
|
)
|
|
await self.app.services.container.write_stdin(self.channel_uid, content)
|
|
start_time = time.time()
|
|
while time.time() - self.time_last_output < timeout:
|
|
if time.time() - start_time > timeout * 2:
|
|
break
|
|
await asyncio.sleep(0.1)
|
|
await self.app.services.container.remove_event_listener(
|
|
self.container_name, "stdout", self.stdout_event_handler
|
|
)
|
|
return self.output
|
|
except Exception as ex:
|
|
logger.warning(f"Container communication error: {safe_str(ex)}")
|
|
return self.output
|
|
|
|
sc = SessionCall(self.app, channel_uid, container_name)
|
|
result = await sc.communicate(content, timeout)
|
|
return result.decode("utf-8", "ignore") if result else ""
|
|
except (PermissionError, ValueError):
|
|
raise
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to write container: {safe_str(ex)}")
|
|
return ""
|
|
|
|
async def get_container(self, channel_uid):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
return None
|
|
try:
|
|
channel_member = await self.services.channel_member.get(
|
|
channel_uid=channel_uid, user_uid=self.user_uid
|
|
)
|
|
if not channel_member:
|
|
raise PermissionError("Not allowed")
|
|
container = await self.services.container.get(channel_uid)
|
|
if not container:
|
|
return None
|
|
deploy = safe_get(container, "deploy", {})
|
|
resources = safe_get(deploy, "resources", {})
|
|
limits = safe_get(resources, "limits", {})
|
|
return {
|
|
"name": await self.services.container.get_container_name(channel_uid),
|
|
"cpus": safe_get(limits, "cpus", "0.5"),
|
|
"memory": safe_get(limits, "memory", "256M"),
|
|
"image": "ubuntu:latest",
|
|
"volumes": [],
|
|
"status": safe_get(container, "status", "unknown"),
|
|
}
|
|
except PermissionError:
|
|
raise
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to get container: {safe_str(ex)}")
|
|
return None
|
|
|
|
async def _finalize_message_task(self, message_uid):
|
|
if not message_uid:
|
|
return False
|
|
try:
|
|
for _ in range(7):
|
|
await asyncio.sleep(1)
|
|
if not self._finalize_task:
|
|
return False
|
|
except asyncio.CancelledError:
|
|
logger.debug("Finalization cancelled")
|
|
return False
|
|
except Exception as ex:
|
|
logger.warning(f"Finalization wait error: {safe_str(ex)}")
|
|
return False
|
|
self._finalize_task = None
|
|
try:
|
|
if not self.services:
|
|
return False
|
|
message = await self.services.channel_message.get(message_uid, is_final=False)
|
|
if not message:
|
|
return False
|
|
if safe_get(message, "user_uid") != self.user_uid:
|
|
return False
|
|
if safe_get(message, "is_final", False):
|
|
return True
|
|
await self.services.chat.finalize(safe_get(message, "uid"))
|
|
return True
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to finalize message: {safe_str(ex)}")
|
|
return False
|
|
|
|
async def _queue_finalize_message(self, message_uid):
|
|
if not message_uid:
|
|
return
|
|
try:
|
|
if self._finalize_task:
|
|
self._finalize_task.cancel()
|
|
try:
|
|
await self._finalize_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
self._finalize_task = asyncio.create_task(
|
|
self._finalize_message_task(message_uid)
|
|
)
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to queue finalize: {safe_str(ex)}")
|
|
|
|
async def send_message(self, channel_uid, message, is_final=True):
|
|
logger.debug(f"send_message called: channel_uid={channel_uid}, message_len={len(str(message)) if message else 0}, is_final={is_final}, user_uid={self.user_uid}")
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
logger.warning(f"send_message: invalid channel_uid: {channel_uid}")
|
|
return None
|
|
if message is None:
|
|
logger.debug("send_message: message is None")
|
|
return None
|
|
try:
|
|
message_text = safe_str(message).strip()
|
|
logger.debug(f"send_message: processing message text, length={len(message_text)}, is_final={is_final}")
|
|
if not is_final:
|
|
logger.debug(f"send_message: checking for existing non-final message in channel={channel_uid}")
|
|
check_message = await self.services.channel_message.get(
|
|
channel_uid=channel_uid, user_uid=self.user_uid, is_final=False, deleted_at=None
|
|
)
|
|
if check_message:
|
|
msg_uid = safe_get(check_message, "uid")
|
|
logger.debug(f"send_message: found existing non-final message uid={msg_uid}, updating text")
|
|
await self._queue_finalize_message(msg_uid)
|
|
return await self.update_message_text(msg_uid, message_text)
|
|
if not message_text:
|
|
logger.debug("send_message: empty message text after strip")
|
|
return None
|
|
logger.info(f"send_message: sending new message to channel={channel_uid}, is_final={is_final}")
|
|
result = await self.services.chat.send(
|
|
self.user_uid, channel_uid, message_text, is_final
|
|
)
|
|
if not result:
|
|
logger.warning(f"send_message: chat.send returned None for channel={channel_uid}")
|
|
return None
|
|
result_uid = safe_get(result, "uid")
|
|
result_is_final = safe_get(result, "is_final", True)
|
|
logger.info(f"send_message: message sent successfully, uid={result_uid}, is_final={result_is_final}")
|
|
if not result_is_final:
|
|
await self._queue_finalize_message(result_uid)
|
|
elif self._finalize_task:
|
|
self._finalize_task.cancel()
|
|
return result_uid
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to send message: {safe_str(ex)}", exc_info=True)
|
|
return None
|
|
|
|
async def start_container(self, channel_uid):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
raise ValueError("Invalid channel_uid")
|
|
channel_member = await self.services.channel_member.get(
|
|
channel_uid=channel_uid, user_uid=self.user_uid
|
|
)
|
|
if not channel_member:
|
|
raise PermissionError("Not allowed")
|
|
return await self.services.container.start(channel_uid)
|
|
|
|
async def stop_container(self, channel_uid):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
raise ValueError("Invalid channel_uid")
|
|
channel_member = await self.services.channel_member.get(
|
|
channel_uid=channel_uid, user_uid=self.user_uid
|
|
)
|
|
if not channel_member:
|
|
raise PermissionError("Not allowed")
|
|
return await self.services.container.stop(channel_uid)
|
|
|
|
async def get_container_status(self, channel_uid):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
return None
|
|
try:
|
|
channel_member = await self.services.channel_member.get(
|
|
channel_uid=channel_uid, user_uid=self.user_uid
|
|
)
|
|
if not channel_member:
|
|
raise PermissionError("Not allowed")
|
|
return await self.services.container.get_status(channel_uid)
|
|
except PermissionError:
|
|
raise
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to get container status: {safe_str(ex)}")
|
|
return None
|
|
|
|
|
|
|
|
async def finalize_message(self, message_uid):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not message_uid or not isinstance(message_uid, str):
|
|
return False
|
|
try:
|
|
message = await self.services.channel_message.get(message_uid)
|
|
if not message:
|
|
return False
|
|
if safe_get(message, "user_uid") != self.user_uid:
|
|
raise PermissionError("Not allowed")
|
|
if not safe_get(message, "is_final", False):
|
|
await self.services.chat.finalize(safe_get(message, "uid"))
|
|
return True
|
|
except PermissionError:
|
|
raise
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to finalize message: {safe_str(ex)}")
|
|
return False
|
|
|
|
async def update_message_text(self, message_uid, text):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not message_uid or not isinstance(message_uid, str):
|
|
return {"error": "Invalid message_uid", "success": False}
|
|
try:
|
|
message = await self.services.channel_message.get(message_uid)
|
|
if not message:
|
|
return {"error": "Message not found", "success": False}
|
|
if safe_get(message, "is_final", False):
|
|
return {"error": "Message is final", "success": False}
|
|
if safe_get(message, "deleted_at"):
|
|
return {"error": "Message is deleted", "success": False}
|
|
if safe_get(message, "user_uid") != self.user_uid:
|
|
raise PermissionError("Not allowed")
|
|
seconds_since_update = 0
|
|
try:
|
|
if hasattr(message, "get_seconds_since_last_update"):
|
|
seconds_since_update = message.get_seconds_since_last_update()
|
|
except Exception:
|
|
pass
|
|
if seconds_since_update > 8:
|
|
return {
|
|
"error": "Message too old",
|
|
"seconds_since_last_update": seconds_since_update,
|
|
"success": False,
|
|
}
|
|
text_str = safe_str(text) if text else ""
|
|
if not text_str:
|
|
message["deleted_at"] = now()
|
|
else:
|
|
message["deleted_at"] = None
|
|
message["message"] = text_str
|
|
await self.services.channel_message.save(message)
|
|
record = dict(message.record) if hasattr(message, "record") else {}
|
|
record["text"] = safe_get(message, "message", "")
|
|
record["message_uid"] = message_uid
|
|
channel_uid = safe_get(message, "channel_uid")
|
|
if channel_uid:
|
|
try:
|
|
await self.services.socket.broadcast(
|
|
channel_uid,
|
|
{
|
|
"channel_uid": channel_uid,
|
|
"event": "update_message_text",
|
|
"data": record,
|
|
},
|
|
)
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to broadcast message update: {safe_str(ex)}")
|
|
return {"success": True}
|
|
except PermissionError:
|
|
raise
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to update message text: {safe_str(ex)}")
|
|
return {"error": "Update failed", "success": False}
|
|
|
|
async def clear_channel(self, channel_uid):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
raise ValueError("Invalid channel_uid")
|
|
user = await self.services.user.get(uid=self.user_uid)
|
|
if not user or not safe_get(user, "is_admin", False):
|
|
raise PermissionError("Not allowed")
|
|
channel = await self.services.channel.get(uid=channel_uid)
|
|
if not channel:
|
|
raise ValueError("Channel not found")
|
|
channel["history_start"] = now()
|
|
await self.services.channel.save(channel)
|
|
return await self.services.channel_message.clear(channel_uid)
|
|
|
|
async def echo(self, *args):
|
|
return args
|
|
|
|
async def query(self, *args):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not args or len(args) == 0:
|
|
return []
|
|
query_str = args[0]
|
|
if not query_str or not isinstance(query_str, str):
|
|
return []
|
|
lowercase = query_str.lower()
|
|
forbidden_keywords = ["drop", "alter", "update", "delete", "replace", "insert", "truncate"]
|
|
if any(keyword in lowercase for keyword in forbidden_keywords) and "select" not in lowercase:
|
|
raise PermissionError("Not allowed")
|
|
try:
|
|
results = []
|
|
async for record in self.services.channel.query(query_str):
|
|
if record:
|
|
record_dict = dict(record)
|
|
record_dict.pop("email", None)
|
|
record_dict.pop("password", None)
|
|
record_dict.pop("message", None)
|
|
record_dict.pop("html", None)
|
|
results.append(record_dict)
|
|
return results
|
|
except Exception as ex:
|
|
logger.warning(f"Query failed: {safe_str(ex)}")
|
|
return []
|
|
|
|
async def __call__(self, data):
|
|
call_id = None
|
|
start_time = time.time()
|
|
try:
|
|
if self._is_closed:
|
|
logger.debug("RPC call on closed connection")
|
|
return
|
|
if not data or not isinstance(data, dict):
|
|
logger.warning("Invalid RPC data received")
|
|
return
|
|
call_id = data.get("callId")
|
|
method_name = data.get("method")
|
|
args = data.get("args", [])
|
|
logger.debug(f"RPC call received: method={method_name}, callId={call_id}, args_count={len(args) if args else 0}, user_uid={self.user_uid}")
|
|
if not method_name or not isinstance(method_name, str):
|
|
logger.warning(f"Invalid method name: {method_name}")
|
|
await self._send_json({"callId": call_id, "success": False, "data": "Invalid method"})
|
|
return
|
|
if method_name.startswith("_"):
|
|
logger.warning(f"Attempted to call private method: {method_name}")
|
|
await self._send_json({"callId": call_id, "success": False, "data": "Not allowed"})
|
|
return
|
|
if args is None:
|
|
args = []
|
|
elif not isinstance(args, (list, tuple)):
|
|
args = [args]
|
|
safe_method_name = method_name.replace(".", "_")
|
|
if not hasattr(self, safe_method_name):
|
|
logger.warning(f"Method not found: {method_name}")
|
|
await self._send_json({"callId": call_id, "success": False, "data": "Method not found"})
|
|
return
|
|
method = getattr(self, safe_method_name, None)
|
|
if not method or not callable(method):
|
|
logger.warning(f"Method not callable: {method_name}")
|
|
await self._send_json({"callId": call_id, "success": False, "data": "Method not callable"})
|
|
return
|
|
success = True
|
|
result = None
|
|
try:
|
|
logger.debug(f"Executing RPC method: {method_name}")
|
|
result = await method(*args)
|
|
elapsed = (time.time() - start_time) * 1000
|
|
logger.info(f"RPC method completed: method={method_name}, callId={call_id}, elapsed={elapsed:.2f}ms, success=True")
|
|
except (PermissionError, ValueError) as ex:
|
|
result = {"error": safe_str(ex)}
|
|
success = False
|
|
elapsed = (time.time() - start_time) * 1000
|
|
logger.warning(f"RPC method permission/value error: method={method_name}, callId={call_id}, elapsed={elapsed:.2f}ms, error={safe_str(ex)}")
|
|
except Exception as ex:
|
|
result = {"error": "Internal error"}
|
|
success = False
|
|
elapsed = (time.time() - start_time) * 1000
|
|
logger.exception(f"RPC method failed: method={method_name}, callId={call_id}, elapsed={elapsed:.2f}ms, error={safe_str(ex)}")
|
|
if result != "noresponse":
|
|
await self._send_json({"callId": call_id, "success": success, "data": result})
|
|
except Exception as ex:
|
|
logger.exception(f"RPC call failed: {safe_str(ex)}")
|
|
try:
|
|
await self._send_json({"callId": call_id, "success": False, "data": "Internal error"})
|
|
except Exception:
|
|
pass
|
|
|
|
async def _send_json(self, obj):
|
|
if self._is_closed:
|
|
return False
|
|
if not self.ws:
|
|
return False
|
|
try:
|
|
json_str = json.dumps(obj, default=str)
|
|
await self.ws.send_str(json_str)
|
|
return True
|
|
except ConnectionResetError:
|
|
self._is_closed = True
|
|
logger.debug("Connection reset during send")
|
|
except Exception as ex:
|
|
self._is_closed = True
|
|
logger.warning(f"Failed to send JSON: {safe_str(ex)}")
|
|
try:
|
|
if self.services and hasattr(self.services, "socket"):
|
|
await self.services.socket.delete(self.ws)
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
async def get_online_users(self, channel_uid):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
return []
|
|
try:
|
|
results = []
|
|
async for record in self.services.channel.get_recent_users(channel_uid):
|
|
if record:
|
|
results.append(record)
|
|
return sorted(results, key=lambda x: safe_get(x, "nick", ""))
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to get online users: {safe_str(ex)}")
|
|
return []
|
|
|
|
async def echo_raw(self, obj):
|
|
if not self.ws:
|
|
return "noresponse"
|
|
try:
|
|
await self.ws.send_json(obj if obj else {})
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to echo: {safe_str(ex)}")
|
|
return "noresponse"
|
|
|
|
async def get_recent_users(self, channel_uid):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
return []
|
|
try:
|
|
results = []
|
|
async for record in self.services.channel.get_recent_users(channel_uid):
|
|
if record:
|
|
results.append({
|
|
"uid": safe_get(record, "uid", ""),
|
|
"username": safe_get(record, "username", ""),
|
|
"nick": safe_get(record, "nick", ""),
|
|
"last_ping": safe_get(record, "last_ping"),
|
|
})
|
|
return results
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to get recent users: {safe_str(ex)}")
|
|
return []
|
|
|
|
async def get_users(self, channel_uid):
|
|
self._require_login()
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
return []
|
|
try:
|
|
results = []
|
|
async for record in self.services.channel.get_users(channel_uid):
|
|
if record:
|
|
results.append({
|
|
"uid": safe_get(record, "uid", ""),
|
|
"username": safe_get(record, "username", ""),
|
|
"nick": safe_get(record, "nick", ""),
|
|
"last_ping": safe_get(record, "last_ping"),
|
|
})
|
|
return results
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to get users: {safe_str(ex)}")
|
|
return []
|
|
|
|
async def _schedule(self, seconds, call):
|
|
if not call:
|
|
return
|
|
try:
|
|
seconds = max(0, min(float(seconds) if seconds else 0, 300))
|
|
await asyncio.sleep(seconds)
|
|
if self.services and self.user_uid and not self._is_closed:
|
|
await self.services.socket.send_to_user(self.user_uid, call)
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception as ex:
|
|
logger.warning(f"Schedule failed: {safe_str(ex)}")
|
|
|
|
async def ping(self, call_id=None, *args):
|
|
try:
|
|
if self.user_uid and self.services:
|
|
user = await self.services.user.get(uid=self.user_uid)
|
|
if user:
|
|
user["last_ping"] = now()
|
|
await self.services.user.save(user)
|
|
return {"pong": args if args else []}
|
|
except Exception as ex:
|
|
logger.warning(f"Ping failed: {safe_str(ex)}")
|
|
return {"pong": []}
|
|
|
|
async def stars_render(self, channel_uid, message):
|
|
self._require_services()
|
|
if not channel_uid or not isinstance(channel_uid, str):
|
|
return False
|
|
try:
|
|
users = await self.get_online_users(channel_uid)
|
|
for user in (users or []):
|
|
if not user:
|
|
continue
|
|
user_uid = safe_get(user, "uid")
|
|
if not user_uid:
|
|
continue
|
|
try:
|
|
await self.services.socket.send_to_user(
|
|
user_uid,
|
|
{
|
|
"event": "stars_render",
|
|
"data": {
|
|
"channel_uid": channel_uid,
|
|
"message": safe_str(message) if message else "",
|
|
},
|
|
},
|
|
)
|
|
except Exception as ex:
|
|
logger.debug(f"Failed to send stars_render to user: {safe_str(ex)}")
|
|
return True
|
|
except Exception as ex:
|
|
logger.warning(f"stars_render failed: {safe_str(ex)}")
|
|
return False
|
|
|
|
async def get(self):
|
|
ws = None
|
|
rpc = None
|
|
scheduled_tasks = []
|
|
|
|
async def schedule(uid, seconds, call):
|
|
if not uid or not call:
|
|
return
|
|
try:
|
|
seconds = max(0, min(float(seconds) if seconds else 0, 300))
|
|
await asyncio.sleep(seconds)
|
|
if self.services:
|
|
await self.services.socket.send_to_user(uid, call)
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception as ex:
|
|
logger.debug(f"Schedule failed: {safe_str(ex)}")
|
|
|
|
try:
|
|
ws = web.WebSocketResponse(heartbeat=30.0, autoping=True)
|
|
await ws.prepare(self.request)
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to prepare WebSocket: {safe_str(ex)}")
|
|
return web.Response(status=500, text="WebSocket initialization failed")
|
|
|
|
user_uid = None
|
|
try:
|
|
session = getattr(self.request, "session", None)
|
|
if session and session.get("logged_in"):
|
|
user_uid = session.get("uid")
|
|
if user_uid and self.services:
|
|
await self.services.socket.add(ws, user_uid)
|
|
try:
|
|
async for subscription in self.services.channel_member.find(
|
|
user_uid=user_uid,
|
|
deleted_at=None,
|
|
is_banned=False,
|
|
):
|
|
if subscription:
|
|
channel_uid = safe_get(subscription, "channel_uid")
|
|
if channel_uid:
|
|
await self.services.socket.subscribe(ws, channel_uid, user_uid)
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to subscribe to channels: {safe_str(ex)}")
|
|
except Exception as ex:
|
|
logger.warning(f"Session initialization failed: {safe_str(ex)}")
|
|
|
|
try:
|
|
app = getattr(self.request, "app", None)
|
|
uptime_seconds = getattr(app, "uptime_seconds", 999) if app else 999
|
|
if not scheduled_tasks and uptime_seconds < 15 and user_uid:
|
|
jitter = random.uniform(0, 3)
|
|
task1 = asyncio.create_task(schedule(
|
|
user_uid, jitter,
|
|
{"event": "refresh", "data": {"message": "Finishing deployment"}},
|
|
))
|
|
scheduled_tasks.append(task1)
|
|
uptime = getattr(app, "uptime", "") if app else ""
|
|
task2 = asyncio.create_task(schedule(
|
|
user_uid, 15 + jitter,
|
|
{"event": "deployed", "data": {"uptime": uptime}},
|
|
))
|
|
scheduled_tasks.append(task2)
|
|
except Exception as ex:
|
|
logger.debug(f"Deployment schedule failed: {safe_str(ex)}")
|
|
|
|
rpc = RPCView.RPCApi(self, ws)
|
|
try:
|
|
async for msg in ws:
|
|
if msg is None:
|
|
continue
|
|
try:
|
|
if msg.type == web.WSMsgType.TEXT:
|
|
try:
|
|
data = msg.json()
|
|
if data:
|
|
await rpc(data)
|
|
except json.JSONDecodeError as ex:
|
|
logger.warning(f"Invalid JSON received: {safe_str(ex)}")
|
|
except Exception as ex:
|
|
logger.warning(f"RPC call failed: {safe_str(ex)}")
|
|
elif msg.type == web.WSMsgType.BINARY:
|
|
logger.debug("Binary message received, ignoring")
|
|
elif msg.type == web.WSMsgType.ERROR:
|
|
logger.debug(f"WebSocket error: {safe_str(ws.exception())}")
|
|
break
|
|
elif msg.type == web.WSMsgType.CLOSE:
|
|
logger.debug("WebSocket close message received")
|
|
break
|
|
elif msg.type == web.WSMsgType.CLOSING:
|
|
logger.debug("WebSocket closing")
|
|
break
|
|
elif msg.type == web.WSMsgType.CLOSED:
|
|
logger.debug("WebSocket closed")
|
|
break
|
|
except Exception as ex:
|
|
logger.warning(f"Message processing error: {safe_str(ex)}")
|
|
except asyncio.CancelledError:
|
|
logger.debug("WebSocket handler cancelled")
|
|
except ConnectionResetError:
|
|
logger.debug("Connection reset by peer")
|
|
except Exception as ex:
|
|
logger.warning(f"WebSocket loop error: {safe_str(ex)}")
|
|
finally:
|
|
if rpc:
|
|
rpc._is_closed = True
|
|
if rpc._finalize_task:
|
|
try:
|
|
rpc._finalize_task.cancel()
|
|
except Exception:
|
|
pass
|
|
for task in rpc._scheduled_tasks:
|
|
try:
|
|
if task and not task.done():
|
|
task.cancel()
|
|
except Exception:
|
|
pass
|
|
for task in scheduled_tasks:
|
|
try:
|
|
if task and not task.done():
|
|
task.cancel()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if self.services and ws:
|
|
await self.services.socket.delete(ws)
|
|
except Exception as ex:
|
|
logger.debug(f"Socket cleanup failed: {safe_str(ex)}")
|
|
try:
|
|
if ws and not ws.closed:
|
|
await ws.close()
|
|
except Exception:
|
|
pass
|
|
return ws
|