mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-11 08:54:51 +02:00
Add automatic bot functionality
Add basic bot functionality
This commit is contained in:
+248
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Bot execution module for automatic message responses.
|
||||
|
||||
This module provides functionality for executing user-defined Python code
|
||||
in response to incoming messages. The user's code can process message data
|
||||
and optionally return a response string.
|
||||
|
||||
SECURITY WARNING: This executes arbitrary Python code provided by the user.
|
||||
It should only be enabled on trusted systems where the user understands
|
||||
the security implications.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Limit concurrent bot executions to prevent resource exhaustion
|
||||
_bot_semaphore = asyncio.Semaphore(3)
|
||||
|
||||
# Dedicated thread pool for bot execution (separate from default executor)
|
||||
_bot_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="bot_")
|
||||
|
||||
# Timeout for bot code execution (seconds)
|
||||
BOT_EXECUTION_TIMEOUT = 10
|
||||
|
||||
|
||||
def execute_bot_code(
|
||||
code: str,
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
message_text: str,
|
||||
is_dm: bool,
|
||||
channel_key: str | None,
|
||||
channel_name: str | None,
|
||||
sender_timestamp: int | None,
|
||||
path: str | None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Execute user-provided bot code with message context.
|
||||
|
||||
The code should define a function:
|
||||
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path)`
|
||||
that returns either None (no response) or a string (response message).
|
||||
|
||||
Args:
|
||||
code: Python code defining the bot function
|
||||
sender_name: Display name of the sender (may be None)
|
||||
sender_key: 64-char hex public key of sender for DMs, None for channel messages
|
||||
message_text: The message content
|
||||
is_dm: True for direct messages, False for channel messages
|
||||
channel_key: 32-char hex channel key for channel messages, None for DMs
|
||||
channel_name: Channel name (e.g. "#general" with hash), None for DMs
|
||||
sender_timestamp: Sender's timestamp from the message (may be None)
|
||||
path: Hex-encoded routing path (may be None)
|
||||
|
||||
Returns:
|
||||
Response string if the bot returns one, None otherwise.
|
||||
|
||||
Note: This executes arbitrary code. Only use with trusted input.
|
||||
"""
|
||||
if not code or not code.strip():
|
||||
return None
|
||||
|
||||
# Build execution namespace with allowed imports
|
||||
namespace: dict[str, Any] = {
|
||||
"__builtins__": __builtins__,
|
||||
}
|
||||
|
||||
try:
|
||||
# Execute the user's code to define the bot function
|
||||
exec(code, namespace)
|
||||
except Exception as e:
|
||||
logger.warning("Bot code compilation failed: %s", e)
|
||||
return None
|
||||
|
||||
# Check if bot function was defined
|
||||
if "bot" not in namespace or not callable(namespace["bot"]):
|
||||
logger.debug("Bot code does not define a callable 'bot' function")
|
||||
return None
|
||||
|
||||
bot_func = namespace["bot"]
|
||||
|
||||
try:
|
||||
# Call the bot function with message context
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
)
|
||||
|
||||
# Validate result
|
||||
if result is None:
|
||||
return None
|
||||
if isinstance(result, str):
|
||||
return result if result.strip() else None
|
||||
|
||||
logger.debug("Bot function returned non-string: %s", type(result))
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Bot function execution failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
async def process_bot_response(
|
||||
response: str,
|
||||
is_dm: bool,
|
||||
sender_key: str,
|
||||
channel_key: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Send the bot's response message using the existing message sending endpoints.
|
||||
|
||||
For DMs, sends a direct message back to the sender.
|
||||
For channel messages, sends to the same channel.
|
||||
|
||||
Args:
|
||||
response: The response text to send
|
||||
is_dm: Whether the original message was a DM
|
||||
sender_key: Public key of the original sender (for DM replies)
|
||||
channel_key: Channel key for channel message replies
|
||||
"""
|
||||
from app.models import SendChannelMessageRequest, SendDirectMessageRequest
|
||||
from app.routers.messages import send_channel_message, send_direct_message
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
try:
|
||||
if is_dm:
|
||||
logger.info("Bot sending DM reply to %s", sender_key[:12])
|
||||
request = SendDirectMessageRequest(destination=sender_key, text=response)
|
||||
message = await send_direct_message(request)
|
||||
# Broadcast to WebSocket (endpoint returns to HTTP caller, bot needs explicit broadcast)
|
||||
broadcast_event("message", message.model_dump())
|
||||
elif channel_key:
|
||||
logger.info("Bot sending channel reply to %s", channel_key[:8])
|
||||
request = SendChannelMessageRequest(channel_key=channel_key, text=response)
|
||||
message = await send_channel_message(request)
|
||||
# Broadcast to WebSocket
|
||||
broadcast_event("message", message.model_dump())
|
||||
else:
|
||||
logger.warning("Cannot send bot response: no destination")
|
||||
except HTTPException as e:
|
||||
logger.error("Bot failed to send response: %s", e.detail)
|
||||
except Exception as e:
|
||||
logger.error("Bot failed to send response: %s", e)
|
||||
|
||||
|
||||
async def run_bot_for_message(
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
message_text: str,
|
||||
is_dm: bool,
|
||||
channel_key: str | None,
|
||||
channel_name: str | None = None,
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | None = None,
|
||||
is_outgoing: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Run the bot for an incoming message if enabled.
|
||||
|
||||
This is the main entry point called by message handlers after
|
||||
a message is successfully decrypted and stored.
|
||||
|
||||
Args:
|
||||
sender_name: Display name of the sender
|
||||
sender_key: 64-char hex public key of sender (DMs only, None for channels)
|
||||
message_text: The message content
|
||||
is_dm: True for direct messages, False for channel messages
|
||||
channel_key: Channel key for channel messages
|
||||
channel_name: Channel name (e.g. "#general"), None for DMs
|
||||
sender_timestamp: Sender's timestamp from the message
|
||||
path: Hex-encoded routing path
|
||||
is_outgoing: Whether this is our own outgoing message (skip bot)
|
||||
"""
|
||||
# Don't respond to our own outgoing messages
|
||||
if is_outgoing:
|
||||
return
|
||||
|
||||
# Early check if bot is enabled (will re-check after sleep)
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
settings = await AppSettingsRepository.get()
|
||||
if not settings.bot_enabled or not settings.bot_code:
|
||||
return
|
||||
|
||||
# Try to acquire semaphore (limit concurrent bot executions)
|
||||
if not _bot_semaphore.locked():
|
||||
pass # Semaphore available
|
||||
else:
|
||||
logger.debug("Bot execution queue full, skipping message")
|
||||
return
|
||||
|
||||
async with _bot_semaphore:
|
||||
logger.debug(
|
||||
"Running bot for message from %s (is_dm=%s)",
|
||||
sender_name or (sender_key[:12] if sender_key else "unknown"),
|
||||
is_dm,
|
||||
)
|
||||
|
||||
# Wait for the initiating message's retransmissions to propagate through the mesh
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Re-check settings after sleep (user may have disabled bot)
|
||||
settings = await AppSettingsRepository.get()
|
||||
if not settings.bot_enabled or not settings.bot_code:
|
||||
logger.debug("Bot disabled during wait, skipping")
|
||||
return
|
||||
|
||||
# Execute bot code in a dedicated thread pool with timeout
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
_bot_executor,
|
||||
execute_bot_code,
|
||||
settings.bot_code,
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
),
|
||||
timeout=BOT_EXECUTION_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Bot execution timed out after %ds", BOT_EXECUTION_TIMEOUT)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("Bot execution error: %s", e)
|
||||
return
|
||||
|
||||
# Send response if any
|
||||
if response:
|
||||
await process_bot_response(response, is_dm, sender_key or "", channel_key)
|
||||
@@ -118,6 +118,21 @@ async def on_contact_message(event: "Event") -> None:
|
||||
if contact:
|
||||
await ContactRepository.update_last_contacted(contact.public_key, received_at)
|
||||
|
||||
# Run bot if enabled (for non-CLI messages)
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
await run_bot_for_message(
|
||||
sender_name=contact.name if contact else None,
|
||||
sender_key=sender_pubkey,
|
||||
message_text=payload.get("text", ""),
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=payload.get("sender_timestamp"),
|
||||
path=payload.get("path"),
|
||||
is_outgoing=False,
|
||||
)
|
||||
|
||||
|
||||
async def on_rx_log_data(event: "Event") -> None:
|
||||
"""Store raw RF packet data and process via centralized packet processor.
|
||||
|
||||
@@ -114,6 +114,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 11)
|
||||
applied += 1
|
||||
|
||||
# Migration 12: Add bot_enabled and bot_code columns to app_settings
|
||||
if version < 12:
|
||||
logger.info("Applying migration 12: add bot settings columns")
|
||||
await _migrate_012_add_bot_settings(conn)
|
||||
await set_version(conn, 12)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -680,3 +687,31 @@ async def _migrate_011_add_last_advert_time(conn: aiosqlite.Connection) -> None:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_012_add_bot_settings(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Add bot_enabled and bot_code columns to app_settings table.
|
||||
|
||||
This enables user-defined Python code to be executed when messages are received,
|
||||
allowing for custom bot responses.
|
||||
"""
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings ADD COLUMN bot_enabled INTEGER DEFAULT 0")
|
||||
logger.debug("Added bot_enabled column to app_settings")
|
||||
except aiosqlite.OperationalError as e:
|
||||
if "duplicate column" in str(e).lower():
|
||||
logger.debug("bot_enabled column already exists, skipping")
|
||||
else:
|
||||
raise
|
||||
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings ADD COLUMN bot_code TEXT DEFAULT ''")
|
||||
logger.debug("Added bot_code column to app_settings")
|
||||
except aiosqlite.OperationalError as e:
|
||||
if "duplicate column" in str(e).lower():
|
||||
logger.debug("bot_code column already exists, skipping")
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
@@ -263,3 +263,11 @@ class AppSettings(BaseModel):
|
||||
default=0,
|
||||
description="Unix timestamp of last advertisement sent (0 = never)",
|
||||
)
|
||||
bot_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether the message bot is enabled",
|
||||
)
|
||||
bot_code: str = Field(
|
||||
default="",
|
||||
description="Python code for the message bot function",
|
||||
)
|
||||
|
||||
+46
-2
@@ -47,6 +47,8 @@ async def create_message_from_decrypted(
|
||||
timestamp: int,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
channel_name: str | None = None,
|
||||
trigger_bot: bool = True,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted channel packet content.
|
||||
|
||||
@@ -56,11 +58,13 @@ async def create_message_from_decrypted(
|
||||
Args:
|
||||
packet_id: ID of the raw packet being processed
|
||||
channel_key: Hex string channel key
|
||||
channel_name: Channel name (e.g. "#general"), for bot context
|
||||
sender: Sender name (will be prefixed to message) or None
|
||||
message_text: The decrypted message content
|
||||
timestamp: Sender timestamp from the packet
|
||||
received_at: When the packet was received (defaults to now)
|
||||
path: Hex-encoded routing path (None for historical decryption)
|
||||
path: Hex-encoded routing path
|
||||
trigger_bot: Whether to trigger bot response (False for historical decryption)
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
@@ -162,6 +166,22 @@ async def create_message_from_decrypted(
|
||||
},
|
||||
)
|
||||
|
||||
# Run bot if enabled (for incoming channel messages, not historical decryption)
|
||||
if trigger_bot:
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
await run_bot_for_message(
|
||||
sender_name=sender,
|
||||
sender_key=None, # Channel messages don't have a sender public key
|
||||
message_text=message_text,
|
||||
is_dm=False,
|
||||
channel_key=channel_key_normalized,
|
||||
channel_name=channel_name,
|
||||
sender_timestamp=timestamp,
|
||||
path=path,
|
||||
is_outgoing=False,
|
||||
)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
@@ -173,6 +193,7 @@ async def create_dm_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
outgoing: bool = False,
|
||||
trigger_bot: bool = True,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted direct message packet content.
|
||||
|
||||
@@ -185,8 +206,9 @@ async def create_dm_message_from_decrypted(
|
||||
their_public_key: The contact's full 64-char public key (conversation_key)
|
||||
our_public_key: Our public key (to determine direction), or None
|
||||
received_at: When the packet was received (defaults to now)
|
||||
path: Hex-encoded routing path (None for historical decryption)
|
||||
path: Hex-encoded routing path
|
||||
outgoing: Whether this is an outgoing message (we sent it)
|
||||
trigger_bot: Whether to trigger bot response (False for historical decryption)
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
@@ -289,6 +311,26 @@ async def create_dm_message_from_decrypted(
|
||||
# Update contact's last_contacted timestamp (for sorting)
|
||||
await ContactRepository.update_last_contacted(conversation_key, received)
|
||||
|
||||
# Run bot if enabled (for incoming DMs only, not historical decryption or outgoing)
|
||||
if trigger_bot and not outgoing:
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
# Get contact name for the bot
|
||||
contact = await ContactRepository.get_by_key(their_public_key)
|
||||
sender_name = contact.name if contact else None
|
||||
|
||||
await run_bot_for_message(
|
||||
sender_name=sender_name,
|
||||
sender_key=their_public_key,
|
||||
message_text=decrypted.message,
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
path=path,
|
||||
is_outgoing=False,
|
||||
)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
@@ -341,6 +383,7 @@ async def run_historical_dm_decryption(
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
outgoing=outgoing,
|
||||
trigger_bot=False, # Historical decryption should not trigger bot
|
||||
)
|
||||
|
||||
if msg_id is not None:
|
||||
@@ -535,6 +578,7 @@ async def _process_group_text(
|
||||
msg_id = await create_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
channel_key=channel.key,
|
||||
channel_name=channel.name,
|
||||
sender=decrypted.sender,
|
||||
message_text=decrypted.message,
|
||||
timestamp=decrypted.timestamp,
|
||||
|
||||
+13
-1
@@ -682,7 +682,7 @@ class AppSettingsRepository:
|
||||
"""
|
||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||
sidebar_sort_order, last_message_times, preferences_migrated,
|
||||
advert_interval, last_advert_time
|
||||
advert_interval, last_advert_time, bot_enabled, bot_code
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
@@ -732,6 +732,8 @@ class AppSettingsRepository:
|
||||
preferences_migrated=bool(row["preferences_migrated"]),
|
||||
advert_interval=row["advert_interval"] or 0,
|
||||
last_advert_time=row["last_advert_time"] or 0,
|
||||
bot_enabled=bool(row["bot_enabled"]),
|
||||
bot_code=row["bot_code"] or "",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -744,6 +746,8 @@ class AppSettingsRepository:
|
||||
preferences_migrated: bool | None = None,
|
||||
advert_interval: int | None = None,
|
||||
last_advert_time: int | None = None,
|
||||
bot_enabled: bool | None = None,
|
||||
bot_code: str | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
@@ -782,6 +786,14 @@ class AppSettingsRepository:
|
||||
updates.append("last_advert_time = ?")
|
||||
params.append(last_advert_time)
|
||||
|
||||
if bot_enabled is not None:
|
||||
updates.append("bot_enabled = ?")
|
||||
params.append(1 if bot_enabled else 0)
|
||||
|
||||
if bot_code is not None:
|
||||
updates.append("bot_code = ?")
|
||||
params.append(bot_code)
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
await db.conn.execute(query, params)
|
||||
|
||||
@@ -63,11 +63,13 @@ async def _run_historical_channel_decryption(
|
||||
msg_id = await create_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
channel_key=channel_key_hex,
|
||||
channel_name=display_name,
|
||||
sender=result.sender,
|
||||
message_text=result.message,
|
||||
timestamp=result.timestamp,
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
trigger_bot=False, # Historical decryption should not trigger bot
|
||||
)
|
||||
|
||||
if msg_id is not None:
|
||||
|
||||
+32
-1
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import AppSettings
|
||||
@@ -11,6 +11,20 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def validate_bot_code(code: str) -> None:
|
||||
"""Validate bot code syntax. Raises HTTPException on error."""
|
||||
if not code or not code.strip():
|
||||
return # Empty code is valid (disables bot)
|
||||
|
||||
try:
|
||||
compile(code, "<bot_code>", "exec")
|
||||
except SyntaxError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bot code syntax error at line {e.lineno}: {e.msg}",
|
||||
) from None
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
max_radio_contacts: int | None = Field(
|
||||
default=None,
|
||||
@@ -31,6 +45,14 @@ class AppSettingsUpdate(BaseModel):
|
||||
ge=0,
|
||||
description="Periodic advertisement interval in seconds (0 = disabled)",
|
||||
)
|
||||
bot_enabled: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether the message bot is enabled",
|
||||
)
|
||||
bot_code: str | None = Field(
|
||||
default=None,
|
||||
description="Python code for the message bot function",
|
||||
)
|
||||
|
||||
|
||||
class FavoriteRequest(BaseModel):
|
||||
@@ -94,6 +116,15 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
logger.info("Updating advert_interval to %d", update.advert_interval)
|
||||
kwargs["advert_interval"] = update.advert_interval
|
||||
|
||||
if update.bot_enabled is not None:
|
||||
logger.info("Updating bot_enabled to %s", update.bot_enabled)
|
||||
kwargs["bot_enabled"] = update.bot_enabled
|
||||
|
||||
if update.bot_code is not None:
|
||||
validate_bot_code(update.bot_code)
|
||||
logger.info("Updating bot_code (length=%d)", len(update.bot_code))
|
||||
kwargs["bot_code"] = update.bot_code
|
||||
|
||||
if kwargs:
|
||||
return await AppSettingsRepository.update(**kwargs)
|
||||
|
||||
|
||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+602
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-542
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -13,8 +13,8 @@
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<script type="module" crossorigin src="/assets/index-DsqXtGVx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cxcg9Mr6.css">
|
||||
<script type="module" crossorigin src="/assets/index-DpuVn1aK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B-EWrA2Q.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+30
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.0",
|
||||
"dependencies": {
|
||||
"@michaelhart/meshcore-decoder": "^0.2.7",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -15,6 +15,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
@@ -22,9 +23,11 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"meshcore-hashtag-cracker": "^1.6.0",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
@@ -1521,6 +1524,12 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
|
||||
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"devOptional": true,
|
||||
@@ -3953,6 +3962,15 @@
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"dev": true,
|
||||
@@ -4071,6 +4089,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-simple-code-editor": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz",
|
||||
"integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
@@ -29,9 +30,11 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"meshcore-hashtag-cracker": "^1.6.0",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { highlight, languages } from 'prismjs';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/themes/prism-tomorrow.css';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
@@ -73,7 +77,7 @@ export function SettingsModal({
|
||||
onRefreshAppSettings,
|
||||
}: SettingsModalProps) {
|
||||
// Tab state
|
||||
type SettingsTab = 'radio' | 'identity' | 'serial' | 'database' | 'advertise';
|
||||
type SettingsTab = 'radio' | 'identity' | 'serial' | 'database' | 'advertise' | 'bot';
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('radio');
|
||||
|
||||
// Radio config state
|
||||
@@ -103,6 +107,31 @@ export function SettingsModal({
|
||||
// Advertisement interval state
|
||||
const [advertInterval, setAdvertInterval] = useState('0');
|
||||
|
||||
// Bot state
|
||||
const DEFAULT_BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
"""
|
||||
Process incoming messages and optionally return a reply.
|
||||
|
||||
Args:
|
||||
sender_name: Display name of sender (may be None)
|
||||
sender_key: 64-char hex public key (empty for channel msgs)
|
||||
message_text: The message content
|
||||
is_dm: True for direct messages, False for channel
|
||||
channel_key: 32-char hex key for channels, None for DMs
|
||||
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
|
||||
sender_timestamp: Sender's timestamp (unix seconds, may be None)
|
||||
path: Hex-encoded routing path (may be None)
|
||||
|
||||
Returns:
|
||||
None for no reply, or a string to send as reply
|
||||
"""
|
||||
# Example: Only respond in #bot channel to "!pling" command
|
||||
if channel_name == "#bot" and "!pling" in message_text.lower():
|
||||
return "[BOT] Plong!"
|
||||
return None`;
|
||||
const [botEnabled, setBotEnabled] = useState(false);
|
||||
const [botCode, setBotCode] = useState(DEFAULT_BOT_CODE);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setName(config.name);
|
||||
@@ -121,6 +150,11 @@ export function SettingsModal({
|
||||
setMaxRadioContacts(String(appSettings.max_radio_contacts));
|
||||
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
||||
setAdvertInterval(String(appSettings.advert_interval));
|
||||
setBotEnabled(appSettings.bot_enabled);
|
||||
// Only overwrite bot code if user has saved custom code
|
||||
if (appSettings.bot_code) {
|
||||
setBotCode(appSettings.bot_code);
|
||||
}
|
||||
}
|
||||
}, [appSettings]);
|
||||
|
||||
@@ -354,9 +388,25 @@ export function SettingsModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBotSettings = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await onSaveAppSettings({ bot_enabled: botEnabled, bot_code: botCode });
|
||||
toast.success('Bot settings saved');
|
||||
} catch (err) {
|
||||
console.error('Failed to save bot settings:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
toast.error('Failed to save settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="sm:max-w-[50vw] sm:min-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Radio & Settings</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
@@ -366,6 +416,7 @@ export function SettingsModal({
|
||||
{activeTab === 'serial' && 'View serial port connection and configure contact sync'}
|
||||
{activeTab === 'database' && 'View database statistics and clean up old packets'}
|
||||
{activeTab === 'advertise' && 'Send a flood advertisement to announce your presence'}
|
||||
{activeTab === 'bot' && 'Configure automatic message bot with Python code'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -377,12 +428,13 @@ export function SettingsModal({
|
||||
onValueChange={(v) => setActiveTab(v as SettingsTab)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsTrigger value="radio">Radio</TabsTrigger>
|
||||
<TabsTrigger value="identity">Identity</TabsTrigger>
|
||||
<TabsTrigger value="serial">Serial</TabsTrigger>
|
||||
<TabsTrigger value="database">Database</TabsTrigger>
|
||||
<TabsTrigger value="advertise">Advertise</TabsTrigger>
|
||||
<TabsTrigger value="bot">Bot</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Radio Config Tab */}
|
||||
@@ -747,6 +799,87 @@ export function SettingsModal({
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Bot Tab */}
|
||||
<TabsContent value="bot" className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md">
|
||||
<p className="text-sm text-yellow-500">
|
||||
<strong>Security Warning:</strong> This feature executes arbitrary Python code on
|
||||
the server. Only enable if you understand the security implications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={botEnabled}
|
||||
onChange={(e) => setBotEnabled(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium">Enable Message Bot</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="bot-code">Bot Code (Python)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setBotCode(DEFAULT_BOT_CODE)}
|
||||
disabled={!botEnabled}
|
||||
>
|
||||
Reset to Example
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Define a <code className="bg-muted px-1 rounded">bot()</code> function that
|
||||
receives message data and optionally returns a reply string.
|
||||
</p>
|
||||
<div
|
||||
className={`rounded-md border border-input bg-[#2d2d2d] overflow-auto h-64 ${!botEnabled ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
>
|
||||
<Editor
|
||||
value={botCode}
|
||||
onValueChange={setBotCode}
|
||||
highlight={(code) => highlight(code, languages.python, 'python')}
|
||||
padding={12}
|
||||
style={{
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: 13,
|
||||
minHeight: '100%',
|
||||
}}
|
||||
textareaId="bot-code"
|
||||
disabled={!botEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
<strong>Available:</strong> Standard Python libraries and any modules installed in
|
||||
the server environment.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Limits:</strong> 10 second timeout, max 3 concurrent executions.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Note:</strong> Bot only responds to incoming messages, not your own. For
|
||||
channel messages, <code>sender_key</code> is <code>None</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
|
||||
<Button onClick={handleSaveBotSettings} disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : 'Save Bot Settings'}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -134,6 +134,8 @@ export interface AppSettings {
|
||||
last_message_times: Record<string, number>;
|
||||
preferences_migrated: boolean;
|
||||
advert_interval: number;
|
||||
bot_enabled: boolean;
|
||||
bot_code: string;
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
@@ -141,6 +143,8 @@ export interface AppSettingsUpdate {
|
||||
auto_decrypt_dm_on_advert?: boolean;
|
||||
sidebar_sort_order?: 'recent' | 'alpha';
|
||||
advert_interval?: number;
|
||||
bot_enabled?: boolean;
|
||||
bot_code?: string;
|
||||
}
|
||||
|
||||
export interface MigratePreferencesRequest {
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Tests for the bot execution module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.bot import (
|
||||
_bot_semaphore,
|
||||
execute_bot_code,
|
||||
run_bot_for_message,
|
||||
)
|
||||
|
||||
|
||||
class TestExecuteBotCode:
|
||||
"""Test bot code execution."""
|
||||
|
||||
def test_valid_code_returning_string(self):
|
||||
"""Bot code that returns a string works correctly."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
return f"Hello, {sender_name}!"
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result == "Hello, Alice!"
|
||||
|
||||
def test_valid_code_returning_none(self):
|
||||
"""Bot code that returns None works correctly."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
return None
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_string_response_treated_as_none(self):
|
||||
"""Bot returning empty/whitespace string is treated as None."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
return " "
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_code_with_syntax_error(self):
|
||||
"""Bot code with syntax error returns None."""
|
||||
code = """
|
||||
def bot(sender_name:
|
||||
return "broken"
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_code_without_bot_function(self):
|
||||
"""Code that doesn't define 'bot' function returns None."""
|
||||
code = """
|
||||
def my_function():
|
||||
return "hello"
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_bot_not_callable(self):
|
||||
"""Code where 'bot' is not callable returns None."""
|
||||
code = """
|
||||
bot = "I'm a string, not a function"
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_bot_function_raises_exception(self):
|
||||
"""Bot function that raises exception returns None."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
raise ValueError("oops!")
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_bot_returns_non_string(self):
|
||||
"""Bot function returning non-string returns None."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
return 42
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_code_returns_none(self):
|
||||
"""Empty bot code returns None."""
|
||||
result = execute_bot_code(
|
||||
code="",
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_whitespace_only_code_returns_none(self):
|
||||
"""Whitespace-only bot code returns None."""
|
||||
result = execute_bot_code(
|
||||
code=" \n\t ",
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_bot_receives_all_parameters(self):
|
||||
"""Bot function receives all expected parameters."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
# Verify all params are accessible
|
||||
parts = [
|
||||
f"name={sender_name}",
|
||||
f"key={sender_key}",
|
||||
f"msg={message_text}",
|
||||
f"dm={is_dm}",
|
||||
f"ch_key={channel_key}",
|
||||
f"ch_name={channel_name}",
|
||||
f"ts={sender_timestamp}",
|
||||
f"path={path}",
|
||||
]
|
||||
return "|".join(parts)
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Bob",
|
||||
sender_key="def456",
|
||||
message_text="Test",
|
||||
is_dm=False,
|
||||
channel_key="AABBCCDD",
|
||||
channel_name="#test",
|
||||
sender_timestamp=12345,
|
||||
path="001122",
|
||||
)
|
||||
assert (
|
||||
result
|
||||
== "name=Bob|key=def456|msg=Test|dm=False|ch_key=AABBCCDD|ch_name=#test|ts=12345|path=001122"
|
||||
)
|
||||
|
||||
def test_channel_message_with_none_sender_key(self):
|
||||
"""Channel messages correctly pass None for sender_key."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
if sender_key is None and not is_dm:
|
||||
return "channel message detected"
|
||||
return "unexpected"
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Someone",
|
||||
sender_key=None, # Channel messages don't have sender key
|
||||
message_text="Test",
|
||||
is_dm=False,
|
||||
channel_key="AABBCCDD",
|
||||
channel_name="#general",
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result == "channel message detected"
|
||||
|
||||
|
||||
class TestRunBotForMessage:
|
||||
"""Test the main bot entry point."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_semaphore(self):
|
||||
"""Reset semaphore state between tests."""
|
||||
# Ensure semaphore is fully released
|
||||
while _bot_semaphore.locked():
|
||||
_bot_semaphore.release()
|
||||
yield
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_outgoing_messages(self):
|
||||
"""Bot is not triggered for outgoing messages."""
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
await run_bot_for_message(
|
||||
sender_name="Me",
|
||||
sender_key="abc123",
|
||||
message_text="Hello",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
is_outgoing=True,
|
||||
)
|
||||
|
||||
# Should not even check settings
|
||||
mock_repo.get.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_bot_disabled(self):
|
||||
"""Bot is not triggered when disabled in settings."""
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.bot_enabled = False
|
||||
mock_settings.bot_code = "def bot(): pass"
|
||||
mock_repo.get = AsyncMock(return_value=mock_settings)
|
||||
|
||||
with patch("app.bot.execute_bot_code") as mock_exec:
|
||||
await run_bot_for_message(
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hello",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
)
|
||||
|
||||
mock_exec.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_bot_code_empty(self):
|
||||
"""Bot is not triggered when code is empty."""
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.bot_enabled = True
|
||||
mock_settings.bot_code = ""
|
||||
mock_repo.get = AsyncMock(return_value=mock_settings)
|
||||
|
||||
with patch("app.bot.execute_bot_code") as mock_exec:
|
||||
await run_bot_for_message(
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hello",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
)
|
||||
|
||||
mock_exec.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rechecks_settings_after_sleep(self):
|
||||
"""Settings are re-checked after 2 second sleep."""
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
# First call: bot enabled
|
||||
# Second call (after sleep): bot disabled
|
||||
mock_settings_enabled = MagicMock()
|
||||
mock_settings_enabled.bot_enabled = True
|
||||
mock_settings_enabled.bot_code = "def bot(): return 'hi'"
|
||||
|
||||
mock_settings_disabled = MagicMock()
|
||||
mock_settings_disabled.bot_enabled = False
|
||||
mock_settings_disabled.bot_code = "def bot(): return 'hi'"
|
||||
|
||||
mock_repo.get = AsyncMock(side_effect=[mock_settings_enabled, mock_settings_disabled])
|
||||
|
||||
with (
|
||||
patch("app.bot.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
|
||||
patch("app.bot.execute_bot_code") as mock_exec,
|
||||
):
|
||||
await run_bot_for_message(
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hello",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
)
|
||||
|
||||
# Should have slept
|
||||
mock_sleep.assert_called_once_with(2)
|
||||
|
||||
# Should NOT have executed bot (disabled after sleep)
|
||||
mock_exec.assert_not_called()
|
||||
|
||||
|
||||
class TestBotCodeValidation:
|
||||
"""Test bot code syntax validation on save."""
|
||||
|
||||
def test_valid_code_passes(self):
|
||||
"""Valid Python code passes validation."""
|
||||
from app.routers.settings import validate_bot_code
|
||||
|
||||
# Should not raise
|
||||
validate_bot_code("def bot(): return 'hello'")
|
||||
|
||||
def test_syntax_error_raises(self):
|
||||
"""Syntax error in code raises HTTPException."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.settings import validate_bot_code
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_bot_code("def bot(:\n return 'broken'")
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "syntax error" in exc_info.value.detail.lower()
|
||||
|
||||
def test_empty_code_passes(self):
|
||||
"""Empty code passes validation (disables bot)."""
|
||||
from app.routers.settings import validate_bot_code
|
||||
|
||||
# Should not raise
|
||||
validate_bot_code("")
|
||||
validate_bot_code(" ")
|
||||
@@ -192,6 +192,7 @@ class TestContactMessageCLIFiltering:
|
||||
patch("app.event_handlers.MessageRepository") as mock_repo,
|
||||
patch("app.event_handlers.ContactRepository") as mock_contact_repo,
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
mock_repo.create = AsyncMock(return_value=42)
|
||||
mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None)
|
||||
@@ -220,6 +221,7 @@ class TestContactMessageCLIFiltering:
|
||||
patch("app.event_handlers.MessageRepository") as mock_repo,
|
||||
patch("app.event_handlers.ContactRepository") as mock_contact_repo,
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
mock_repo.create = AsyncMock(return_value=42)
|
||||
mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None)
|
||||
@@ -254,6 +256,7 @@ class TestContactMessageCLIFiltering:
|
||||
patch("app.event_handlers.MessageRepository") as mock_repo,
|
||||
patch("app.event_handlers.ContactRepository") as mock_contact_repo,
|
||||
patch("app.event_handlers.broadcast_event"),
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
mock_repo.create = AsyncMock(return_value=42)
|
||||
mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None)
|
||||
|
||||
@@ -100,8 +100,8 @@ class TestMigration001:
|
||||
# Run migrations
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 11 # All 11 migrations run
|
||||
assert await get_version(conn) == 11
|
||||
assert applied == 12 # All 11 migrations run
|
||||
assert await get_version(conn) == 12
|
||||
|
||||
# Verify columns exist by inserting and selecting
|
||||
await conn.execute(
|
||||
@@ -183,9 +183,9 @@ class TestMigration001:
|
||||
applied1 = await run_migrations(conn)
|
||||
applied2 = await run_migrations(conn)
|
||||
|
||||
assert applied1 == 11 # All 11 migrations run
|
||||
assert applied1 == 12 # All 11 migrations run
|
||||
assert applied2 == 0 # No migrations on second run
|
||||
assert await get_version(conn) == 11
|
||||
assert await get_version(conn) == 12
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -246,8 +246,8 @@ class TestMigration001:
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
# All 11 migrations applied (version incremented) but no error
|
||||
assert applied == 11
|
||||
assert await get_version(conn) == 11
|
||||
assert applied == 12
|
||||
assert await get_version(conn) == 12
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user