mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-01 15:32:29 +02:00
Add room server messaging endpoints and database functions
- Implemented functions to retrieve, post, delete, and clear messages in room servers. - Added API endpoints for room message retrieval, posting, and management. - added OpenAPI documentation to include new room server functionalities.
This commit is contained in:
@@ -1044,6 +1044,90 @@ class SQLiteHandler:
|
||||
logger.error(f"Failed to get room clients: {e}")
|
||||
return []
|
||||
|
||||
def get_room_message_count(self, room_hash: str) -> int:
|
||||
"""Get total number of messages in a room."""
|
||||
try:
|
||||
with sqlite3.connect(self.sqlite_path) as conn:
|
||||
cursor = conn.execute("""
|
||||
SELECT COUNT(*) FROM room_messages WHERE room_hash = ?
|
||||
""", (room_hash,))
|
||||
return cursor.fetchone()[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get room message count: {e}")
|
||||
return 0
|
||||
|
||||
def get_room_messages(self, room_hash: str, limit: int = 50, offset: int = 0) -> List[Dict]:
|
||||
"""Get messages from a room with pagination."""
|
||||
try:
|
||||
with sqlite3.connect(self.sqlite_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute("""
|
||||
SELECT * FROM room_messages
|
||||
WHERE room_hash = ?
|
||||
ORDER BY post_timestamp DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", (room_hash, limit, offset))
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get room messages: {e}")
|
||||
return []
|
||||
|
||||
def get_messages_since(self, room_hash: str, since_timestamp: float, limit: int = 50) -> List[Dict]:
|
||||
"""Get messages posted after a specific timestamp."""
|
||||
try:
|
||||
with sqlite3.connect(self.sqlite_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute("""
|
||||
SELECT * FROM room_messages
|
||||
WHERE room_hash = ? AND post_timestamp > ?
|
||||
ORDER BY post_timestamp DESC
|
||||
LIMIT ?
|
||||
""", (room_hash, since_timestamp, limit))
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get messages since timestamp: {e}")
|
||||
return []
|
||||
|
||||
def get_unsynced_count(self, room_hash: str, client_pubkey: str, sync_since: float) -> int:
|
||||
"""Get count of unsynced messages for a client."""
|
||||
try:
|
||||
with sqlite3.connect(self.sqlite_path) as conn:
|
||||
cursor = conn.execute("""
|
||||
SELECT COUNT(*) FROM room_messages
|
||||
WHERE room_hash = ?
|
||||
AND author_pubkey != ?
|
||||
AND post_timestamp > ?
|
||||
""", (room_hash, client_pubkey, sync_since))
|
||||
return cursor.fetchone()[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get unsynced count: {e}")
|
||||
return 0
|
||||
|
||||
def delete_room_message(self, room_hash: str, message_id: int) -> bool:
|
||||
"""Delete a specific message by ID."""
|
||||
try:
|
||||
with sqlite3.connect(self.sqlite_path) as conn:
|
||||
cursor = conn.execute("""
|
||||
DELETE FROM room_messages
|
||||
WHERE room_hash = ? AND id = ?
|
||||
""", (room_hash, message_id))
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete message: {e}")
|
||||
return False
|
||||
|
||||
def clear_room_messages(self, room_hash: str) -> int:
|
||||
"""Clear all messages from a room."""
|
||||
try:
|
||||
with sqlite3.connect(self.sqlite_path) as conn:
|
||||
cursor = conn.execute("""
|
||||
DELETE FROM room_messages WHERE room_hash = ?
|
||||
""", (room_hash,))
|
||||
return cursor.rowcount
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear room messages: {e}")
|
||||
return 0
|
||||
|
||||
def cleanup_old_messages(self, room_hash: str, keep_count: int = 32) -> int:
|
||||
"""Keep only the most recent N messages per room."""
|
||||
try:
|
||||
|
||||
@@ -36,6 +36,9 @@ DB_ERROR_RETRY_DELAY = 60 # Wait 1 minute on DB error (seconds)
|
||||
# Backoff strategy for failed pushes (seconds)
|
||||
RETRY_BACKOFF_SCHEDULE = [0, 30, 300, 3600] # 0s, 30s, 5min, 1hr
|
||||
|
||||
# Special author pubkey for server/system messages (not filtered from any client)
|
||||
SERVER_AUTHOR_PUBKEY = bytes(32) # 32 zeros - use for system announcements that go to ALL clients
|
||||
|
||||
# Global rate limiter (shared across all rooms)
|
||||
_global_push_limiter = None
|
||||
_global_push_lock = asyncio.Lock()
|
||||
@@ -148,7 +151,8 @@ class RoomServer:
|
||||
client_pubkey: bytes,
|
||||
message_text: str,
|
||||
sender_timestamp: int,
|
||||
txt_type: int = TXT_TYPE_PLAIN
|
||||
txt_type: int = TXT_TYPE_PLAIN,
|
||||
allow_server_author: bool = False
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
|
||||
@@ -237,11 +237,13 @@ class TextHelper:
|
||||
txt_type = 0 # TXT_TYPE_PLAIN by default
|
||||
|
||||
# Store message to room database
|
||||
# SECURITY: Radio messages cannot use server author key
|
||||
await room_server.add_post(
|
||||
client_pubkey=sender_pubkey,
|
||||
message_text=message_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
txt_type=txt_type
|
||||
txt_type=txt_type,
|
||||
allow_server_author=False # Block server key from radio
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -59,6 +59,16 @@ logger = logging.getLogger("HTTPServer")
|
||||
# POST /api/acl_remove_client {"public_key": "...", "identity_hash": "0x42"} - Remove client from ACL
|
||||
# GET /api/acl_stats - Overall ACL statistics
|
||||
|
||||
# Room Server
|
||||
# GET /api/room_messages?room_name=General&limit=50&offset=0 - Get messages from room
|
||||
# GET /api/room_messages?room_hash=0x42&limit=50 - Get messages by hash
|
||||
# POST /api/room_post_message {"room_name": "General", "message": "Hello", "author_pubkey": "abc123"} - Post message
|
||||
# GET /api/room_stats?room_name=General - Get room statistics
|
||||
# GET /api/room_stats - Get all rooms statistics
|
||||
# GET /api/room_clients?room_name=General - Get clients synced to room
|
||||
# DELETE /api/room_message?room_name=General&message_id=123 - Delete specific message
|
||||
# DELETE /api/room_messages?room_name=General - Clear all messages in room
|
||||
|
||||
# Common Parameters
|
||||
# hours - Time range (default: 24)
|
||||
# resolution - 'average', 'max', 'min' (default: 'average')
|
||||
@@ -1929,4 +1939,599 @@ class APIEndpoints:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ACL stats: {e}")
|
||||
return self._error(e)
|
||||
return self._error(e)
|
||||
|
||||
# ======================
|
||||
# Room Server Endpoints
|
||||
# ======================
|
||||
|
||||
def _get_room_server_by_name_or_hash(self, room_name=None, room_hash=None):
|
||||
"""Helper to get room server instance and metadata by name or hash."""
|
||||
if not self.daemon_instance or not hasattr(self.daemon_instance, 'text_helper'):
|
||||
raise Exception("Text helper not available")
|
||||
|
||||
text_helper = self.daemon_instance.text_helper
|
||||
if not text_helper or not hasattr(text_helper, 'room_servers'):
|
||||
raise Exception("Room servers not initialized")
|
||||
|
||||
identity_manager = text_helper.identity_manager
|
||||
|
||||
# Find by name first
|
||||
if room_name:
|
||||
identities = identity_manager.get_identities_by_type("room_server")
|
||||
for name, identity, config in identities:
|
||||
if name == room_name:
|
||||
hash_byte = identity.get_public_key()[0]
|
||||
room_server = text_helper.room_servers.get(hash_byte)
|
||||
if room_server:
|
||||
return {
|
||||
'room_server': room_server,
|
||||
'name': name,
|
||||
'hash': hash_byte,
|
||||
'identity': identity,
|
||||
'config': config
|
||||
}
|
||||
raise Exception(f"Room '{room_name}' not found")
|
||||
|
||||
# Find by hash
|
||||
if room_hash:
|
||||
if isinstance(room_hash, str):
|
||||
if room_hash.startswith('0x'):
|
||||
hash_byte = int(room_hash, 16)
|
||||
else:
|
||||
hash_byte = int(room_hash)
|
||||
else:
|
||||
hash_byte = room_hash
|
||||
|
||||
room_server = text_helper.room_servers.get(hash_byte)
|
||||
if room_server:
|
||||
# Find name
|
||||
identities = identity_manager.get_identities_by_type("room_server")
|
||||
for name, identity, config in identities:
|
||||
if identity.get_public_key()[0] == hash_byte:
|
||||
return {
|
||||
'room_server': room_server,
|
||||
'name': name,
|
||||
'hash': hash_byte,
|
||||
'identity': identity,
|
||||
'config': config
|
||||
}
|
||||
# Found server but no name match
|
||||
return {
|
||||
'room_server': room_server,
|
||||
'name': f"Room_0x{hash_byte:02X}",
|
||||
'hash': hash_byte,
|
||||
'identity': None,
|
||||
'config': {}
|
||||
}
|
||||
raise Exception(f"Room with hash {room_hash} not found")
|
||||
|
||||
raise Exception("Must provide room_name or room_hash")
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def room_messages(self, room_name=None, room_hash=None, limit=50, offset=0, since_timestamp=None):
|
||||
"""
|
||||
Get messages from a room server.
|
||||
|
||||
Parameters:
|
||||
room_name: Name of the room
|
||||
room_hash: Hash of room identity (alternative to name)
|
||||
limit: Max messages to return (default 50)
|
||||
offset: Skip first N messages (default 0)
|
||||
since_timestamp: Only return messages after this timestamp
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"room_name": "General",
|
||||
"room_hash": "0x42",
|
||||
"messages": [
|
||||
{
|
||||
"id": 1,
|
||||
"author_pubkey": "abc123...",
|
||||
"author_prefix": "abc1",
|
||||
"post_timestamp": 1234567890.0,
|
||||
"sender_timestamp": 1234567890,
|
||||
"message_text": "Hello world",
|
||||
"txt_type": 0,
|
||||
"created_at": 1234567890.0
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
"total": 100,
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
room_info = self._get_room_server_by_name_or_hash(room_name, room_hash)
|
||||
room_server = room_info['room_server']
|
||||
|
||||
# Get messages from database
|
||||
db = room_server.db
|
||||
room_hash_str = f"0x{room_info['hash']:02X}"
|
||||
|
||||
# Get total count
|
||||
total_count = db.get_room_message_count(room_hash_str)
|
||||
|
||||
# Get messages
|
||||
if since_timestamp:
|
||||
messages = db.get_messages_since(
|
||||
room_hash=room_hash_str,
|
||||
since_timestamp=float(since_timestamp),
|
||||
limit=int(limit)
|
||||
)
|
||||
else:
|
||||
messages = db.get_room_messages(
|
||||
room_hash=room_hash_str,
|
||||
limit=int(limit),
|
||||
offset=int(offset)
|
||||
)
|
||||
|
||||
# Format messages with author prefix
|
||||
formatted_messages = []
|
||||
for msg in messages:
|
||||
author_pubkey = msg['author_pubkey']
|
||||
formatted_msg = {
|
||||
'id': msg['id'],
|
||||
'author_pubkey': author_pubkey,
|
||||
'author_prefix': author_pubkey[:8] if author_pubkey else '',
|
||||
'post_timestamp': msg['post_timestamp'],
|
||||
'sender_timestamp': msg['sender_timestamp'],
|
||||
'message_text': msg['message_text'],
|
||||
'txt_type': msg['txt_type'],
|
||||
'created_at': msg.get('created_at', msg['post_timestamp'])
|
||||
}
|
||||
formatted_messages.append(formatted_msg)
|
||||
|
||||
return self._success({
|
||||
'room_name': room_info['name'],
|
||||
'room_hash': room_hash_str,
|
||||
'messages': formatted_messages,
|
||||
'count': len(formatted_messages),
|
||||
'total': total_count,
|
||||
'limit': int(limit),
|
||||
'offset': int(offset)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting room messages: {e}", exc_info=True)
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@cherrypy.tools.json_in()
|
||||
def room_post_message(self):
|
||||
"""
|
||||
Post a message to a room server.
|
||||
|
||||
POST Body:
|
||||
{
|
||||
"room_name": "General", // or "room_hash": "0x42"
|
||||
"message": "Hello world",
|
||||
"author_pubkey": "abc123...", // hex string, or "server" for system messages
|
||||
"txt_type": 0 // optional, default 0
|
||||
}
|
||||
|
||||
Special Values for author_pubkey:
|
||||
- "server" or "system": Uses SERVER_AUTHOR_PUBKEY (all zeros), message goes to ALL clients
|
||||
- Any other hex string: Normal behavior, message NOT sent to that client
|
||||
|
||||
Returns:
|
||||
{"success": true, "data": {"message_id": 123}}
|
||||
"""
|
||||
try:
|
||||
self._require_post()
|
||||
|
||||
data = cherrypy.request.json
|
||||
room_name = data.get('room_name')
|
||||
room_hash = data.get('room_hash')
|
||||
message = data.get('message')
|
||||
author_pubkey = data.get('author_pubkey')
|
||||
txt_type = data.get('txt_type', 0)
|
||||
|
||||
if not message:
|
||||
return self._error("message is required")
|
||||
if not author_pubkey:
|
||||
return self._error("author_pubkey is required")
|
||||
|
||||
# Convert author_pubkey to bytes
|
||||
try:
|
||||
# Special case: "server" or "system" = all zeros (goes to ALL clients)
|
||||
if isinstance(author_pubkey, str) and author_pubkey.lower() in ('server', 'system'):
|
||||
author_bytes = bytes(32) # 32 zeros
|
||||
author_pubkey = author_bytes.hex()
|
||||
elif isinstance(author_pubkey, str):
|
||||
author_bytes = bytes.fromhex(author_pubkey)
|
||||
else:
|
||||
author_bytes = bytes(author_pubkey)
|
||||
except Exception as e:
|
||||
return self._error(f"Invalid author_pubkey: {e}")
|
||||
|
||||
# Get room server
|
||||
room_info = self._get_room_server_by_name_or_hash(room_name, room_hash)
|
||||
room_server = room_info['room_server']
|
||||
|
||||
# Add post to room (will be distributed asynchronously)
|
||||
import asyncio
|
||||
if self.event_loop:
|
||||
sender_timestamp = int(time.time())
|
||||
# SECURITY: API is allowed to use server author key (for system messages)
|
||||
# TODO: Add authentication/authorization check before allowing this
|
||||
is_server_author = (author_bytes == bytes(32))
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
room_server.add_post(
|
||||
client_pubkey=author_bytes,
|
||||
message_text=message,
|
||||
sender_timestamp=sender_timestamp,
|
||||
txt_type=txt_type,
|
||||
allow_server_author=is_server_author # Allow server key from API
|
||||
),
|
||||
self.event_loop
|
||||
)
|
||||
success = future.result(timeout=5)
|
||||
|
||||
if success:
|
||||
# Get the message ID (last inserted)
|
||||
db = room_server.db
|
||||
room_hash_str = f"0x{room_info['hash']:02X}"
|
||||
messages = db.get_room_messages(room_hash_str, limit=1, offset=0)
|
||||
message_id = messages[0]['id'] if messages else None
|
||||
|
||||
is_server_msg = author_pubkey == "0" * 64
|
||||
|
||||
return self._success({
|
||||
'message_id': message_id,
|
||||
'room_name': room_info['name'],
|
||||
'room_hash': room_hash_str,
|
||||
'queued_for_distribution': True,
|
||||
'is_server_message': is_server_msg,
|
||||
'author_filter_note': 'Server messages go to ALL clients' if is_server_msg else 'Message will NOT be sent to author'
|
||||
})
|
||||
else:
|
||||
return self._error("Failed to add message (rate limit or validation error)")
|
||||
else:
|
||||
return self._error("Event loop not available")
|
||||
|
||||
except cherrypy.HTTPError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error posting room message: {e}", exc_info=True)
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def room_stats(self, room_name=None, room_hash=None):
|
||||
"""
|
||||
Get statistics for one or all room servers.
|
||||
|
||||
Parameters:
|
||||
room_name: Name of specific room (optional)
|
||||
room_hash: Hash of specific room (optional)
|
||||
|
||||
If no parameters, returns stats for all rooms.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"room_name": "General",
|
||||
"room_hash": "0x42",
|
||||
"total_messages": 100,
|
||||
"total_clients": 5,
|
||||
"active_clients": 3,
|
||||
"max_posts": 32,
|
||||
"sync_running": true,
|
||||
"clients": [
|
||||
{
|
||||
"pubkey": "abc123...",
|
||||
"pubkey_prefix": "abc1",
|
||||
"sync_since": 1234567890.0,
|
||||
"unsynced_count": 2,
|
||||
"pending_ack": false,
|
||||
"push_failures": 0,
|
||||
"last_activity": 1234567890.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if not self.daemon_instance or not hasattr(self.daemon_instance, 'text_helper'):
|
||||
return self._error("Text helper not available")
|
||||
|
||||
text_helper = self.daemon_instance.text_helper
|
||||
|
||||
# Get all rooms if no specific room requested
|
||||
if not room_name and not room_hash:
|
||||
all_rooms = []
|
||||
for hash_byte, room_server in text_helper.room_servers.items():
|
||||
# Find room name
|
||||
room_name_found = f"Room_0x{hash_byte:02X}"
|
||||
identities = text_helper.identity_manager.get_identities_by_type("room_server")
|
||||
for name, identity, config in identities:
|
||||
if identity.get_public_key()[0] == hash_byte:
|
||||
room_name_found = name
|
||||
break
|
||||
|
||||
db = room_server.db
|
||||
room_hash_str = f"0x{hash_byte:02X}"
|
||||
|
||||
# Get basic stats
|
||||
total_messages = db.get_room_message_count(room_hash_str)
|
||||
all_clients_sync = db.get_all_room_clients(room_hash_str)
|
||||
active_clients = sum(1 for c in all_clients_sync if c.get('last_activity', 0) > 0)
|
||||
|
||||
all_rooms.append({
|
||||
'room_name': room_name_found,
|
||||
'room_hash': room_hash_str,
|
||||
'total_messages': total_messages,
|
||||
'total_clients': len(all_clients_sync),
|
||||
'active_clients': active_clients,
|
||||
'max_posts': room_server.max_posts,
|
||||
'sync_running': room_server._running
|
||||
})
|
||||
|
||||
return self._success({
|
||||
'rooms': all_rooms,
|
||||
'total_rooms': len(all_rooms)
|
||||
})
|
||||
|
||||
# Get specific room stats
|
||||
room_info = self._get_room_server_by_name_or_hash(room_name, room_hash)
|
||||
room_server = room_info['room_server']
|
||||
db = room_server.db
|
||||
room_hash_str = f"0x{room_info['hash']:02X}"
|
||||
|
||||
# Get message count
|
||||
total_messages = db.get_room_message_count(room_hash_str)
|
||||
|
||||
# Get client sync states
|
||||
all_clients_sync = db.get_all_room_clients(room_hash_str)
|
||||
|
||||
# Get ACL for this room
|
||||
acl = None
|
||||
if room_info['hash'] in text_helper.acl_dict:
|
||||
acl = text_helper.acl_dict[room_info['hash']]
|
||||
|
||||
# Format client info
|
||||
clients_info = []
|
||||
active_count = 0
|
||||
for client_sync in all_clients_sync:
|
||||
pubkey_hex = client_sync['client_pubkey']
|
||||
pubkey_bytes = bytes.fromhex(pubkey_hex)
|
||||
|
||||
# Check if still in ACL
|
||||
in_acl = False
|
||||
if acl:
|
||||
acl_clients = acl.get_all_clients()
|
||||
in_acl = any(c.id.get_public_key() == pubkey_bytes for c in acl_clients)
|
||||
|
||||
unsynced_count = db.get_unsynced_count(
|
||||
room_hash=room_hash_str,
|
||||
client_pubkey=pubkey_hex,
|
||||
sync_since=client_sync.get('sync_since', 0)
|
||||
)
|
||||
|
||||
is_active = client_sync.get('last_activity', 0) > 0
|
||||
if is_active:
|
||||
active_count += 1
|
||||
|
||||
clients_info.append({
|
||||
'pubkey': pubkey_hex,
|
||||
'pubkey_prefix': pubkey_hex[:8],
|
||||
'sync_since': client_sync.get('sync_since', 0),
|
||||
'unsynced_count': unsynced_count,
|
||||
'pending_ack': client_sync.get('pending_ack_crc', 0) != 0,
|
||||
'pending_ack_crc': client_sync.get('pending_ack_crc', 0),
|
||||
'push_failures': client_sync.get('push_failures', 0),
|
||||
'last_activity': client_sync.get('last_activity', 0),
|
||||
'in_acl': in_acl,
|
||||
'is_active': is_active
|
||||
})
|
||||
|
||||
return self._success({
|
||||
'room_name': room_info['name'],
|
||||
'room_hash': room_hash_str,
|
||||
'total_messages': total_messages,
|
||||
'total_clients': len(all_clients_sync),
|
||||
'active_clients': active_count,
|
||||
'max_posts': room_server.max_posts,
|
||||
'sync_running': room_server._running,
|
||||
'next_push_time': room_server.next_push_time,
|
||||
'last_cleanup_time': room_server.last_cleanup_time,
|
||||
'clients': clients_info
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting room stats: {e}", exc_info=True)
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def room_clients(self, room_name=None, room_hash=None):
|
||||
"""
|
||||
Get list of clients synced to a room.
|
||||
|
||||
Parameters:
|
||||
room_name: Name of the room
|
||||
room_hash: Hash of room identity
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"room_name": "General",
|
||||
"room_hash": "0x42",
|
||||
"clients": [...]
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Reuse room_stats logic but return only clients
|
||||
stats = self.room_stats(room_name=room_name, room_hash=room_hash)
|
||||
if stats.get('success') and 'clients' in stats.get('data', {}):
|
||||
data = stats['data']
|
||||
return self._success({
|
||||
'room_name': data['room_name'],
|
||||
'room_hash': data['room_hash'],
|
||||
'clients': data['clients'],
|
||||
'total': len(data['clients']),
|
||||
'active': data['active_clients']
|
||||
})
|
||||
else:
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting room clients: {e}")
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def room_message(self, room_name=None, room_hash=None, message_id=None):
|
||||
"""
|
||||
Delete a specific message from a room.
|
||||
|
||||
Parameters:
|
||||
room_name: Name of the room
|
||||
room_hash: Hash of room identity
|
||||
message_id: ID of message to delete
|
||||
|
||||
Returns:
|
||||
{"success": true}
|
||||
"""
|
||||
try:
|
||||
if cherrypy.request.method != "DELETE":
|
||||
cherrypy.response.status = 405
|
||||
return self._error("Method not allowed. Use DELETE.")
|
||||
|
||||
if not message_id:
|
||||
return self._error("message_id is required")
|
||||
|
||||
room_info = self._get_room_server_by_name_or_hash(room_name, room_hash)
|
||||
room_server = room_info['room_server']
|
||||
db = room_server.db
|
||||
room_hash_str = f"0x{room_info['hash']:02X}"
|
||||
|
||||
# Delete message
|
||||
deleted = db.delete_room_message(room_hash_str, int(message_id))
|
||||
|
||||
if deleted:
|
||||
return self._success({
|
||||
'deleted': True,
|
||||
'message_id': int(message_id),
|
||||
'room_name': room_info['name']
|
||||
})
|
||||
else:
|
||||
return self._error("Message not found or already deleted")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting room message: {e}")
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def room_messages_clear(self, room_name=None, room_hash=None):
|
||||
"""
|
||||
Clear all messages from a room.
|
||||
|
||||
Parameters:
|
||||
room_name: Name of the room
|
||||
room_hash: Hash of room identity
|
||||
|
||||
Returns:
|
||||
{"success": true, "data": {"deleted_count": 123}}
|
||||
"""
|
||||
try:
|
||||
if cherrypy.request.method != "DELETE":
|
||||
cherrypy.response.status = 405
|
||||
return self._error("Method not allowed. Use DELETE.")
|
||||
|
||||
room_info = self._get_room_server_by_name_or_hash(room_name, room_hash)
|
||||
room_server = room_info['room_server']
|
||||
db = room_server.db
|
||||
room_hash_str = f"0x{room_info['hash']:02X}"
|
||||
|
||||
# Get count before deleting
|
||||
count_before = db.get_room_message_count(room_hash_str)
|
||||
|
||||
# Clear all messages
|
||||
deleted = db.clear_room_messages(room_hash_str)
|
||||
|
||||
return self._success({
|
||||
'deleted_count': deleted or count_before,
|
||||
'room_name': room_info['name'],
|
||||
'room_hash': room_hash_str
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing room messages: {e}")
|
||||
return self._error(e)
|
||||
|
||||
# ======================
|
||||
# OpenAPI Documentation
|
||||
# ======================
|
||||
|
||||
@cherrypy.expose
|
||||
def openapi(self):
|
||||
"""Serve OpenAPI specification in YAML format."""
|
||||
import os
|
||||
spec_path = os.path.join(os.path.dirname(__file__), 'openapi.yaml')
|
||||
try:
|
||||
with open(spec_path, 'r') as f:
|
||||
spec_content = f.read()
|
||||
cherrypy.response.headers['Content-Type'] = 'application/x-yaml'
|
||||
return spec_content
|
||||
except FileNotFoundError:
|
||||
cherrypy.response.status = 404
|
||||
return "OpenAPI spec not found"
|
||||
except Exception as e:
|
||||
cherrypy.response.status = 500
|
||||
return f"Error loading OpenAPI spec: {e}"
|
||||
|
||||
@cherrypy.expose
|
||||
def docs(self):
|
||||
"""Serve Swagger UI for interactive API documentation."""
|
||||
swagger_html = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pyMC Repeater API Documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; }
|
||||
.topbar { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
SwaggerUIBundle({
|
||||
url: '/api/openapi',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout",
|
||||
tryItOutEnabled: true,
|
||||
filter: true,
|
||||
displayRequestDuration: true,
|
||||
persistAuthorization: true
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
cherrypy.response.headers['Content-Type'] = 'text/html'
|
||||
return swagger_html
|
||||
@@ -0,0 +1,639 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: pyMC Repeater API
|
||||
description: |
|
||||
REST API for pyMC Repeater - LoRa mesh network repeater with room server functionality.
|
||||
|
||||
## Features
|
||||
- System statistics and monitoring
|
||||
- Packet history and analysis
|
||||
- Identity management
|
||||
- Access Control Lists (ACL)
|
||||
- Room server messaging
|
||||
- CAD calibration
|
||||
- Noise floor monitoring
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: pyMC Repeater
|
||||
url: https://github.com/yourusername/pymc_repeater
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8080/api
|
||||
description: Local development server
|
||||
- url: http://{host}:8080/api
|
||||
description: Custom host
|
||||
variables:
|
||||
host:
|
||||
default: localhost
|
||||
description: Repeater IP address
|
||||
|
||||
tags:
|
||||
- name: System
|
||||
description: System statistics and control
|
||||
- name: Packets
|
||||
description: Packet history and statistics
|
||||
- name: Charts
|
||||
description: Graph data and RRD metrics
|
||||
- name: Noise Floor
|
||||
description: Noise floor monitoring
|
||||
- name: CAD Calibration
|
||||
description: Channel Activity Detection calibration
|
||||
- name: Identities
|
||||
description: Identity management
|
||||
- name: ACL
|
||||
description: Access Control Lists
|
||||
- name: Room Server
|
||||
description: Room server messaging
|
||||
|
||||
paths:
|
||||
/stats:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get system statistics
|
||||
description: Returns repeater uptime, packet counts, and version information
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
uptime_secs:
|
||||
type: integer
|
||||
example: 3600
|
||||
packets_received:
|
||||
type: integer
|
||||
example: 150
|
||||
packets_sent:
|
||||
type: integer
|
||||
example: 120
|
||||
version:
|
||||
type: string
|
||||
example: "1.0.0"
|
||||
core_version:
|
||||
type: string
|
||||
example: "0.5.0"
|
||||
|
||||
/send_advert:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Send repeater advertisement
|
||||
description: Manually trigger sending a repeater advertisement packet
|
||||
responses:
|
||||
'200':
|
||||
description: Advertisement sent
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuccessResponse'
|
||||
'405':
|
||||
description: Method not allowed
|
||||
|
||||
/set_mode:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Set repeater mode
|
||||
description: Switch between forward and monitor modes
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [mode]
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
enum: [forward, monitor]
|
||||
example: forward
|
||||
responses:
|
||||
'200':
|
||||
description: Mode changed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuccessResponse'
|
||||
|
||||
/packet_stats:
|
||||
get:
|
||||
tags: [Packets]
|
||||
summary: Get packet statistics
|
||||
parameters:
|
||||
- name: hours
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 24
|
||||
description: Time range in hours
|
||||
responses:
|
||||
'200':
|
||||
description: Packet statistics
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/packet_by_hash:
|
||||
get:
|
||||
tags: [Packets]
|
||||
summary: Get packet by hash
|
||||
parameters:
|
||||
- name: packet_hash
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Packet hash to lookup
|
||||
responses:
|
||||
'200':
|
||||
description: Packet details
|
||||
|
||||
/noise_floor_history:
|
||||
get:
|
||||
tags: [Noise Floor]
|
||||
summary: Get noise floor history
|
||||
parameters:
|
||||
- name: hours
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 24
|
||||
responses:
|
||||
'200':
|
||||
description: Noise floor history data
|
||||
|
||||
/noise_floor_stats:
|
||||
get:
|
||||
tags: [Noise Floor]
|
||||
summary: Get noise floor statistics
|
||||
parameters:
|
||||
- name: hours
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 24
|
||||
responses:
|
||||
'200':
|
||||
description: Noise floor stats
|
||||
|
||||
/cad_calibration_start:
|
||||
post:
|
||||
tags: [CAD Calibration]
|
||||
summary: Start CAD calibration
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
samples:
|
||||
type: integer
|
||||
default: 8
|
||||
delay:
|
||||
type: integer
|
||||
default: 100
|
||||
responses:
|
||||
'200':
|
||||
description: Calibration started
|
||||
|
||||
/cad_calibration_stop:
|
||||
post:
|
||||
tags: [CAD Calibration]
|
||||
summary: Stop CAD calibration
|
||||
responses:
|
||||
'200':
|
||||
description: Calibration stopped
|
||||
|
||||
/identities:
|
||||
get:
|
||||
tags: [Identities]
|
||||
summary: List all identities
|
||||
responses:
|
||||
'200':
|
||||
description: List of identities
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
identities:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Identity'
|
||||
|
||||
/identity:
|
||||
get:
|
||||
tags: [Identities]
|
||||
summary: Get specific identity
|
||||
parameters:
|
||||
- name: name
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Identity details
|
||||
|
||||
/create_identity:
|
||||
post:
|
||||
tags: [Identities]
|
||||
summary: Create new identity
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name, type]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
identity_key:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [repeater, room_server]
|
||||
settings:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Identity created
|
||||
|
||||
/acl_info:
|
||||
get:
|
||||
tags: [ACL]
|
||||
summary: Get ACL configuration
|
||||
description: Returns ACL settings and statistics for all identities
|
||||
responses:
|
||||
'200':
|
||||
description: ACL information
|
||||
|
||||
/acl_clients:
|
||||
get:
|
||||
tags: [ACL]
|
||||
summary: List authenticated clients
|
||||
parameters:
|
||||
- name: identity_hash
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
example: "0x42"
|
||||
- name: identity_name
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Client list
|
||||
|
||||
/acl_remove_client:
|
||||
post:
|
||||
tags: [ACL]
|
||||
summary: Remove client from ACL
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [public_key, identity_hash]
|
||||
properties:
|
||||
public_key:
|
||||
type: string
|
||||
identity_hash:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Client removed
|
||||
|
||||
/acl_stats:
|
||||
get:
|
||||
tags: [ACL]
|
||||
summary: Get ACL statistics
|
||||
responses:
|
||||
'200':
|
||||
description: ACL statistics
|
||||
|
||||
/room_messages:
|
||||
get:
|
||||
tags: [Room Server]
|
||||
summary: Get room messages
|
||||
description: Retrieve messages from a room with pagination
|
||||
parameters:
|
||||
- name: room_name
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
example: General
|
||||
- name: room_hash
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
example: "0x42"
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 50
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
- name: since_timestamp
|
||||
in: query
|
||||
schema:
|
||||
type: number
|
||||
format: float
|
||||
responses:
|
||||
'200':
|
||||
description: Messages retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
room_name:
|
||||
type: string
|
||||
room_hash:
|
||||
type: string
|
||||
messages:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RoomMessage'
|
||||
count:
|
||||
type: integer
|
||||
total:
|
||||
type: integer
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
|
||||
/room_post_message:
|
||||
post:
|
||||
tags: [Room Server]
|
||||
summary: Post message to room
|
||||
description: |
|
||||
Add a new message to a room server. Message will be distributed to all synced clients.
|
||||
|
||||
**Special author values:**
|
||||
- `"server"` or `"system"` - System message, goes to ALL clients
|
||||
- Any hex string - Normal message, NOT sent to that client
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [message, author_pubkey]
|
||||
properties:
|
||||
room_name:
|
||||
type: string
|
||||
example: General
|
||||
room_hash:
|
||||
type: string
|
||||
example: "0x42"
|
||||
message:
|
||||
type: string
|
||||
maxLength: 160
|
||||
example: "Hello from API"
|
||||
author_pubkey:
|
||||
type: string
|
||||
example: "abc123def456..."
|
||||
description: "Hex string or 'server' for system messages"
|
||||
txt_type:
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
'200':
|
||||
description: Message posted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
message_id:
|
||||
type: integer
|
||||
room_name:
|
||||
type: string
|
||||
room_hash:
|
||||
type: string
|
||||
queued_for_distribution:
|
||||
type: boolean
|
||||
is_server_message:
|
||||
type: boolean
|
||||
author_filter_note:
|
||||
type: string
|
||||
|
||||
/room_stats:
|
||||
get:
|
||||
tags: [Room Server]
|
||||
summary: Get room statistics
|
||||
description: Get detailed statistics for one or all rooms
|
||||
parameters:
|
||||
- name: room_name
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: room_hash
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Room statistics
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
room_name:
|
||||
type: string
|
||||
room_hash:
|
||||
type: string
|
||||
total_messages:
|
||||
type: integer
|
||||
total_clients:
|
||||
type: integer
|
||||
active_clients:
|
||||
type: integer
|
||||
max_posts:
|
||||
type: integer
|
||||
example: 32
|
||||
sync_running:
|
||||
type: boolean
|
||||
clients:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RoomClient'
|
||||
|
||||
/room_clients:
|
||||
get:
|
||||
tags: [Room Server]
|
||||
summary: Get room clients
|
||||
parameters:
|
||||
- name: room_name
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: room_hash
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Client list
|
||||
|
||||
/room_message:
|
||||
delete:
|
||||
tags: [Room Server]
|
||||
summary: Delete specific message
|
||||
parameters:
|
||||
- name: room_name
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: room_hash
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: message_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Message deleted
|
||||
|
||||
/room_messages_clear:
|
||||
delete:
|
||||
tags: [Room Server]
|
||||
summary: Clear all room messages
|
||||
description: ⚠️ Destructive operation - cannot be undone!
|
||||
parameters:
|
||||
- name: room_name
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: room_hash
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Messages cleared
|
||||
|
||||
components:
|
||||
schemas:
|
||||
SuccessResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: false
|
||||
error:
|
||||
type: string
|
||||
|
||||
Identity:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [repeater, room_server]
|
||||
hash:
|
||||
type: string
|
||||
public_key:
|
||||
type: string
|
||||
|
||||
RoomMessage:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
author_pubkey:
|
||||
type: string
|
||||
author_prefix:
|
||||
type: string
|
||||
post_timestamp:
|
||||
type: number
|
||||
format: float
|
||||
sender_timestamp:
|
||||
type: integer
|
||||
message_text:
|
||||
type: string
|
||||
txt_type:
|
||||
type: integer
|
||||
created_at:
|
||||
type: number
|
||||
format: float
|
||||
|
||||
RoomClient:
|
||||
type: object
|
||||
properties:
|
||||
pubkey:
|
||||
type: string
|
||||
pubkey_prefix:
|
||||
type: string
|
||||
sync_since:
|
||||
type: number
|
||||
format: float
|
||||
unsynced_count:
|
||||
type: integer
|
||||
pending_ack:
|
||||
type: boolean
|
||||
push_failures:
|
||||
type: integer
|
||||
last_activity:
|
||||
type: number
|
||||
format: float
|
||||
in_acl:
|
||||
type: boolean
|
||||
is_active:
|
||||
type: boolean
|
||||
|
||||
securitySchemes:
|
||||
# Future: API key authentication
|
||||
ApiKeyAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-API-Key
|
||||
description: API key authentication (not yet implemented)
|
||||
|
||||
# Future security - currently open
|
||||
# security:
|
||||
# - ApiKeyAuth: []
|
||||
Reference in New Issue
Block a user