mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-09 06:45:02 +02:00
Add multibot functionality
This commit is contained in:
+45
-44
@@ -21,10 +21,10 @@ from fastapi import HTTPException
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Limit concurrent bot executions to prevent resource exhaustion
|
||||
_bot_semaphore = asyncio.Semaphore(3)
|
||||
_bot_semaphore = asyncio.Semaphore(100)
|
||||
|
||||
# Dedicated thread pool for bot execution (separate from default executor)
|
||||
_bot_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="bot_")
|
||||
_bot_executor = ThreadPoolExecutor(max_workers=100, thread_name_prefix="bot_")
|
||||
|
||||
# Timeout for bot code execution (seconds)
|
||||
BOT_EXECUTION_TIMEOUT = 10
|
||||
@@ -225,10 +225,11 @@ async def run_bot_for_message(
|
||||
is_outgoing: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Run the bot for an incoming message if enabled.
|
||||
Run all enabled bots for an incoming message.
|
||||
|
||||
This is the main entry point called by message handlers after
|
||||
a message is successfully decrypted and stored.
|
||||
a message is successfully decrypted and stored. Bots run serially,
|
||||
and errors in one bot don't prevent others from running.
|
||||
|
||||
Args:
|
||||
sender_name: Display name of the sender
|
||||
@@ -245,23 +246,18 @@ async def run_bot_for_message(
|
||||
if is_outgoing:
|
||||
return
|
||||
|
||||
# Early check if bot is enabled (will re-check after sleep)
|
||||
# Early check if any bots are 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")
|
||||
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
|
||||
if not enabled_bots:
|
||||
return
|
||||
|
||||
async with _bot_semaphore:
|
||||
logger.debug(
|
||||
"Running bot for message from %s (is_dm=%s)",
|
||||
"Running %d bot(s) for message from %s (is_dm=%s)",
|
||||
len(enabled_bots),
|
||||
sender_name or (sender_key[:12] if sender_key else "unknown"),
|
||||
is_dm,
|
||||
)
|
||||
@@ -269,38 +265,43 @@ async def run_bot_for_message(
|
||||
# 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)
|
||||
# Re-check settings after sleep (user may have changed bot config)
|
||||
settings = await AppSettingsRepository.get()
|
||||
if not settings.bot_enabled or not settings.bot_code:
|
||||
logger.debug("Bot disabled during wait, skipping")
|
||||
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
|
||||
if not enabled_bots:
|
||||
logger.debug("All bots disabled during wait, skipping")
|
||||
return
|
||||
|
||||
# Execute bot code in a dedicated thread pool with timeout
|
||||
# Run each enabled bot serially
|
||||
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
|
||||
for bot in enabled_bots:
|
||||
logger.debug("Executing bot '%s'", bot.name)
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
_bot_executor,
|
||||
execute_bot_code,
|
||||
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 '%s' execution timed out after %ds", bot.name, BOT_EXECUTION_TIMEOUT
|
||||
)
|
||||
continue # Continue to next bot
|
||||
except Exception as e:
|
||||
logger.warning("Bot '%s' execution error: %s", bot.name, e)
|
||||
continue # Continue to next bot
|
||||
|
||||
# Send response if any
|
||||
if response:
|
||||
await process_bot_response(response, is_dm, sender_key or "", channel_key)
|
||||
# Send response if any
|
||||
if response:
|
||||
await process_bot_response(response, is_dm, sender_key or "", channel_key)
|
||||
|
||||
@@ -121,6 +121,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 12)
|
||||
applied += 1
|
||||
|
||||
# Migration 13: Convert bot_enabled/bot_code to bots JSON array
|
||||
if version < 13:
|
||||
logger.info("Applying migration 13: convert to multi-bot format")
|
||||
await _migrate_013_convert_to_multi_bot(conn)
|
||||
await set_version(conn, 13)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -715,3 +722,74 @@ async def _migrate_012_add_bot_settings(conn: aiosqlite.Connection) -> None:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_013_convert_to_multi_bot(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Convert single bot_enabled/bot_code to multi-bot format.
|
||||
|
||||
Adds a 'bots' TEXT column storing a JSON array of bot configs:
|
||||
[{"id": "uuid", "name": "Bot 1", "enabled": true, "code": "..."}]
|
||||
|
||||
If existing bot_code is non-empty OR bot_enabled is true, migrates
|
||||
to a single bot named "Bot 1". Otherwise, creates empty array.
|
||||
|
||||
Attempts to drop the old bot_enabled and bot_code columns.
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
# Add new bots column
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings ADD COLUMN bots TEXT DEFAULT '[]'")
|
||||
logger.debug("Added bots column to app_settings")
|
||||
except aiosqlite.OperationalError as e:
|
||||
if "duplicate column" in str(e).lower():
|
||||
logger.debug("bots column already exists, skipping")
|
||||
else:
|
||||
raise
|
||||
|
||||
# Migrate existing bot data
|
||||
cursor = await conn.execute("SELECT bot_enabled, bot_code FROM app_settings WHERE id = 1")
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row:
|
||||
bot_enabled = bool(row[0]) if row[0] is not None else False
|
||||
bot_code = row[1] or ""
|
||||
|
||||
# If there's existing bot data, migrate it
|
||||
if bot_code.strip() or bot_enabled:
|
||||
bots = [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": "Bot 1",
|
||||
"enabled": bot_enabled,
|
||||
"code": bot_code,
|
||||
}
|
||||
]
|
||||
bots_json = json.dumps(bots)
|
||||
logger.info("Migrating existing bot to multi-bot format: enabled=%s", bot_enabled)
|
||||
else:
|
||||
bots_json = "[]"
|
||||
|
||||
await conn.execute(
|
||||
"UPDATE app_settings SET bots = ? WHERE id = 1",
|
||||
(bots_json,),
|
||||
)
|
||||
|
||||
# Try to drop old columns (SQLite 3.35.0+ only)
|
||||
for column in ["bot_enabled", "bot_code"]:
|
||||
try:
|
||||
await conn.execute(f"ALTER TABLE app_settings DROP COLUMN {column}")
|
||||
logger.debug("Dropped %s column from app_settings", column)
|
||||
except aiosqlite.OperationalError as e:
|
||||
error_msg = str(e).lower()
|
||||
if "no such column" in error_msg:
|
||||
logger.debug("app_settings.%s already dropped, skipping", column)
|
||||
elif "syntax error" in error_msg or "drop column" in error_msg:
|
||||
# SQLite version doesn't support DROP COLUMN - harmless, column stays
|
||||
logger.debug("SQLite doesn't support DROP COLUMN, %s column will remain", column)
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
+12
-7
@@ -232,6 +232,15 @@ class Favorite(BaseModel):
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
|
||||
|
||||
class BotConfig(BaseModel):
|
||||
"""Configuration for a single bot."""
|
||||
|
||||
id: str = Field(description="UUID for stable identity across renames/reorders")
|
||||
name: str = Field(description="User-editable name")
|
||||
enabled: bool = Field(default=False, description="Whether this bot is enabled")
|
||||
code: str = Field(default="", description="Python code for this bot")
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
"""Application settings stored in the database."""
|
||||
|
||||
@@ -266,11 +275,7 @@ 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",
|
||||
bots: list[BotConfig] = Field(
|
||||
default_factory=list,
|
||||
description="List of bot configurations",
|
||||
)
|
||||
|
||||
+31
-13
@@ -7,7 +7,16 @@ from typing import Any, Literal
|
||||
|
||||
from app.database import db
|
||||
from app.decoder import PayloadType, extract_payload, get_packet_payload_type
|
||||
from app.models import AppSettings, Channel, Contact, Favorite, Message, MessagePath, RawPacket
|
||||
from app.models import (
|
||||
AppSettings,
|
||||
BotConfig,
|
||||
Channel,
|
||||
Contact,
|
||||
Favorite,
|
||||
Message,
|
||||
MessagePath,
|
||||
RawPacket,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -682,7 +691,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, bot_enabled, bot_code
|
||||
advert_interval, last_advert_time, bots
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
@@ -718,6 +727,20 @@ class AppSettingsRepository:
|
||||
)
|
||||
last_message_times = {}
|
||||
|
||||
# Parse bots JSON
|
||||
bots: list[BotConfig] = []
|
||||
if row["bots"]:
|
||||
try:
|
||||
bots_data = json.loads(row["bots"])
|
||||
bots = [BotConfig(**b) for b in bots_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse bots JSON, using empty list: %s (data=%r)",
|
||||
e,
|
||||
row["bots"][:100] if row["bots"] else None,
|
||||
)
|
||||
bots = []
|
||||
|
||||
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
||||
sort_order = row["sidebar_sort_order"]
|
||||
if sort_order not in ("recent", "alpha"):
|
||||
@@ -732,8 +755,7 @@ 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 "",
|
||||
bots=bots,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -746,8 +768,7 @@ 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,
|
||||
bots: list[BotConfig] | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
@@ -786,13 +807,10 @@ 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 bots is not None:
|
||||
updates.append("bots = ?")
|
||||
bots_json = json.dumps([b.model_dump() for b in bots])
|
||||
params.append(bots_json)
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
|
||||
+16
-17
@@ -4,14 +4,14 @@ from typing import Literal
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.models import AppSettings, BotConfig
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def validate_bot_code(code: str) -> None:
|
||||
def validate_bot_code(code: str, bot_name: str | None = None) -> None:
|
||||
"""Validate bot code syntax. Raises HTTPException on error."""
|
||||
if not code or not code.strip():
|
||||
return # Empty code is valid (disables bot)
|
||||
@@ -19,12 +19,19 @@ def validate_bot_code(code: str) -> None:
|
||||
try:
|
||||
compile(code, "<bot_code>", "exec")
|
||||
except SyntaxError as e:
|
||||
name_part = f"'{bot_name}' " if bot_name else ""
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bot code syntax error at line {e.lineno}: {e.msg}",
|
||||
detail=f"Bot {name_part}has syntax error at line {e.lineno}: {e.msg}",
|
||||
) from None
|
||||
|
||||
|
||||
def validate_all_bots(bots: list[BotConfig]) -> None:
|
||||
"""Validate all bots' code syntax. Raises HTTPException on first error."""
|
||||
for bot in bots:
|
||||
validate_bot_code(bot.code, bot.name)
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
max_radio_contacts: int | None = Field(
|
||||
default=None,
|
||||
@@ -45,13 +52,9 @@ class AppSettingsUpdate(BaseModel):
|
||||
ge=0,
|
||||
description="Periodic advertisement interval in seconds (0 = disabled)",
|
||||
)
|
||||
bot_enabled: bool | None = Field(
|
||||
bots: list[BotConfig] | 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",
|
||||
description="List of bot configurations",
|
||||
)
|
||||
|
||||
|
||||
@@ -116,14 +119,10 @@ 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 update.bots is not None:
|
||||
validate_all_bots(update.bots)
|
||||
logger.info("Updating bots (count=%d)", len(update.bots))
|
||||
kwargs["bots"] = update.bots
|
||||
|
||||
if kwargs:
|
||||
return await AppSettingsRepository.update(**kwargs)
|
||||
|
||||
-612
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+585
File diff suppressed because one or more lines are too long
+1
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-B3-iYEDN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-H2C92sGV.css">
|
||||
<script type="module" crossorigin src="/assets/index-DEJuRmNA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CgLxtL22.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+238
-31
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@michaelhart/meshcore-decoder": "^0.2.7",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@@ -15,7 +17,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",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
@@ -23,11 +25,9 @@
|
||||
"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"
|
||||
@@ -291,7 +291,6 @@
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -339,6 +338,112 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
|
||||
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz",
|
||||
"integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-python": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
|
||||
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.3.2",
|
||||
"@codemirror/language": "^6.8.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/python": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
|
||||
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.3.tgz",
|
||||
"integrity": "sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.35.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
|
||||
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
|
||||
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/theme-one-dark": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.39.11",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.11.tgz",
|
||||
"integrity": "sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"dev": true,
|
||||
@@ -691,6 +796,47 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
|
||||
"integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
|
||||
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/python": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
|
||||
"integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@michaelhart/meshcore-decoder": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@michaelhart/meshcore-decoder/-/meshcore-decoder-0.2.7.tgz",
|
||||
@@ -1524,12 +1670,6 @@
|
||||
"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,
|
||||
@@ -1821,6 +1961,59 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/codemirror-extensions-basic-setup": {
|
||||
"version": "4.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.4.tgz",
|
||||
"integrity": "sha512-YzNwkm0AbPv1EXhCHYR5v0nqfemG2jEB0Z3Att4rBYqKrlG7AA9Rhjc3IyBaOzsBu18wtrp9/+uhTyu7TXSRng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@codemirror/autocomplete": ">=6.0.0",
|
||||
"@codemirror/commands": ">=6.0.0",
|
||||
"@codemirror/language": ">=6.0.0",
|
||||
"@codemirror/lint": ">=6.0.0",
|
||||
"@codemirror/search": ">=6.0.0",
|
||||
"@codemirror/state": ">=6.0.0",
|
||||
"@codemirror/view": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/react-codemirror": {
|
||||
"version": "4.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.4.tgz",
|
||||
"integrity": "sha512-ipO067oyfUw+DVaXhQCxkB0ZD9b7RnY+ByrprSYSKCHaULvJ3sqWYC/Zen6zVQ8/XC4o5EPBfatGiX20kC7XGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.6",
|
||||
"@codemirror/commands": "^6.1.0",
|
||||
"@codemirror/state": "^6.1.1",
|
||||
"@codemirror/theme-one-dark": "^6.0.0",
|
||||
"@uiw/codemirror-extensions-basic-setup": "4.25.4",
|
||||
"codemirror": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": ">=7.11.0",
|
||||
"@codemirror/state": ">=6.0.0",
|
||||
"@codemirror/theme-one-dark": ">=6.0.0",
|
||||
"@codemirror/view": ">=6.0.0",
|
||||
"codemirror": ">=6.0.0",
|
||||
"react": ">=17.0.0",
|
||||
"react-dom": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"dev": true,
|
||||
@@ -2308,6 +2501,21 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"license": "MIT",
|
||||
@@ -2352,6 +2560,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3962,15 +4176,6 @@
|
||||
"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,
|
||||
@@ -4089,16 +4294,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -4361,6 +4556,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"license": "MIT",
|
||||
@@ -5181,6 +5382,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"dev": true,
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"format:check": "prettier --check src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@michaelhart/meshcore-decoder": "^0.2.7",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@@ -22,7 +24,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",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
@@ -30,11 +32,9 @@
|
||||
"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,11 +1,11 @@
|
||||
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 CodeMirror from '@uiw/react-codemirror';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
BotConfig,
|
||||
HealthStatus,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
@@ -139,8 +139,10 @@ export function SettingsModal({
|
||||
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);
|
||||
const [bots, setBots] = useState<BotConfig[]>([]);
|
||||
const [expandedBotId, setExpandedBotId] = useState<string | null>(null);
|
||||
const [editingNameId, setEditingNameId] = useState<string | null>(null);
|
||||
const [editingNameValue, setEditingNameValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
@@ -160,11 +162,7 @@ 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);
|
||||
}
|
||||
setBots(appSettings.bots || []);
|
||||
}
|
||||
}, [appSettings]);
|
||||
|
||||
@@ -403,17 +401,69 @@ export function SettingsModal({
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await onSaveAppSettings({ bot_enabled: botEnabled, bot_code: botCode });
|
||||
await onSaveAppSettings({ bots });
|
||||
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');
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to save';
|
||||
setError(errorMsg);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddBot = () => {
|
||||
const newBot: BotConfig = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `Bot ${bots.length + 1}`,
|
||||
enabled: false,
|
||||
code: DEFAULT_BOT_CODE,
|
||||
};
|
||||
setBots([...bots, newBot]);
|
||||
setExpandedBotId(newBot.id);
|
||||
};
|
||||
|
||||
const handleDeleteBot = (botId: string) => {
|
||||
const bot = bots.find((b) => b.id === botId);
|
||||
if (bot && bot.code.trim() && bot.code !== DEFAULT_BOT_CODE) {
|
||||
if (!confirm(`Delete "${bot.name}"? This will remove all its code.`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setBots(bots.filter((b) => b.id !== botId));
|
||||
if (expandedBotId === botId) {
|
||||
setExpandedBotId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleBotEnabled = (botId: string) => {
|
||||
setBots(bots.map((b) => (b.id === botId ? { ...b, enabled: !b.enabled } : b)));
|
||||
};
|
||||
|
||||
const handleBotCodeChange = (botId: string, code: string) => {
|
||||
setBots(bots.map((b) => (b.id === botId ? { ...b, code } : b)));
|
||||
};
|
||||
|
||||
const handleStartEditingName = (bot: BotConfig) => {
|
||||
setEditingNameId(bot.id);
|
||||
setEditingNameValue(bot.name);
|
||||
};
|
||||
|
||||
const handleFinishEditingName = () => {
|
||||
if (editingNameId && editingNameValue.trim()) {
|
||||
setBots(
|
||||
bots.map((b) => (b.id === editingNameId ? { ...b, name: editingNameValue.trim() } : b))
|
||||
);
|
||||
}
|
||||
setEditingNameId(null);
|
||||
setEditingNameValue('');
|
||||
};
|
||||
|
||||
const handleResetBotCode = (botId: string) => {
|
||||
setBots(bots.map((b) => (b.id === botId ? { ...b, code: DEFAULT_BOT_CODE } : b)));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[50vw] sm:min-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
@@ -645,6 +695,25 @@ export function SettingsModal({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Send Advertisement</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send a flood advertisement to announce your presence on the mesh network.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleAdvertise}
|
||||
disabled={advertising || !health?.radio_connected}
|
||||
className="w-full bg-yellow-600 hover:bg-yellow-700 text-white"
|
||||
>
|
||||
{advertising ? 'Sending...' : 'Send Advertisement'}
|
||||
</Button>
|
||||
{!health?.radio_connected && (
|
||||
<p className="text-sm text-destructive">Radio not connected</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
</TabsContent>
|
||||
|
||||
@@ -827,56 +896,136 @@ export function SettingsModal({
|
||||
</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 className="flex justify-between items-center">
|
||||
<Label>Bots</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBot}>
|
||||
+ New Bot
|
||||
</Button>
|
||||
</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
|
||||
{bots.length === 0 ? (
|
||||
<div className="text-center py-8 border border-dashed border-input rounded-md">
|
||||
<p className="text-muted-foreground mb-4">No bots configured</p>
|
||||
<Button type="button" variant="outline" onClick={handleAddBot}>
|
||||
Create your first bot
|
||||
</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 className="space-y-2">
|
||||
{bots.map((bot) => (
|
||||
<div key={bot.id} className="border border-input rounded-md overflow-hidden">
|
||||
{/* Bot header row */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80"
|
||||
onClick={(e) => {
|
||||
// Don't toggle if clicking on interactive elements
|
||||
if ((e.target as HTMLElement).closest('input, button')) return;
|
||||
setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
|
||||
}}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{expandedBotId === bot.id ? '▼' : '▶'}
|
||||
</span>
|
||||
|
||||
{/* Bot name (click to edit) */}
|
||||
{editingNameId === bot.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editingNameValue}
|
||||
onChange={(e) => setEditingNameValue(e.target.value)}
|
||||
onBlur={handleFinishEditingName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleFinishEditingName();
|
||||
if (e.key === 'Escape') {
|
||||
setEditingNameId(null);
|
||||
setEditingNameValue('');
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="px-2 py-0.5 text-sm bg-background border border-input rounded flex-1 max-w-[200px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-medium flex-1 hover:text-primary cursor-text"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEditingName(bot);
|
||||
}}
|
||||
title="Click to rename"
|
||||
>
|
||||
{bot.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Enabled checkbox */}
|
||||
<label
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bot.enabled}
|
||||
onChange={() => handleToggleBotEnabled(bot.id)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Enabled</span>
|
||||
</label>
|
||||
|
||||
{/* Delete button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteBot(bot.id);
|
||||
}}
|
||||
title="Delete bot"
|
||||
>
|
||||
🗑
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Bot expanded content */}
|
||||
{expandedBotId === bot.id && (
|
||||
<div className="p-3 space-y-3 border-t border-input">
|
||||
<div className="flex items-center justify-between">
|
||||
<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.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleResetBotCode(bot.id)}
|
||||
>
|
||||
Reset to Example
|
||||
</Button>
|
||||
</div>
|
||||
<CodeMirror
|
||||
value={bot.code}
|
||||
onChange={(code) => handleBotCodeChange(bot.id, code)}
|
||||
extensions={[python()]}
|
||||
theme={oneDark}
|
||||
height="256px"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: true,
|
||||
}}
|
||||
className="rounded-md border border-input overflow-hidden text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
@@ -884,11 +1033,12 @@ export function SettingsModal({
|
||||
the server environment.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Limits:</strong> 10 second timeout, max 3 concurrent executions.
|
||||
<strong>Limits:</strong> 10 second timeout per bot.
|
||||
</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>.
|
||||
<strong>Note:</strong> Bots only respond to incoming messages, not your own. For
|
||||
channel messages, <code>sender_key</code> is <code>None</code>. Multiple enabled
|
||||
bots run serially.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -126,6 +126,13 @@ export interface Favorite {
|
||||
id: string; // channel key or contact public key
|
||||
}
|
||||
|
||||
export interface BotConfig {
|
||||
id: string; // UUID for stable identity across renames/reorders
|
||||
name: string; // User-editable name
|
||||
enabled: boolean; // Whether this bot is enabled
|
||||
code: string; // Python code for this bot
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
favorites: Favorite[];
|
||||
@@ -134,8 +141,7 @@ export interface AppSettings {
|
||||
last_message_times: Record<string, number>;
|
||||
preferences_migrated: boolean;
|
||||
advert_interval: number;
|
||||
bot_enabled: boolean;
|
||||
bot_code: string;
|
||||
bots: BotConfig[];
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
@@ -143,8 +149,7 @@ export interface AppSettingsUpdate {
|
||||
auto_decrypt_dm_on_advert?: boolean;
|
||||
sidebar_sort_order?: 'recent' | 'alpha';
|
||||
advert_interval?: number;
|
||||
bot_enabled?: boolean;
|
||||
bot_code?: string;
|
||||
bots?: BotConfig[];
|
||||
}
|
||||
|
||||
export interface MigratePreferencesRequest {
|
||||
|
||||
+265
-12
@@ -13,6 +13,7 @@ from app.bot import (
|
||||
process_bot_response,
|
||||
run_bot_for_message,
|
||||
)
|
||||
from app.models import BotConfig
|
||||
|
||||
|
||||
class TestExecuteBotCode:
|
||||
@@ -359,12 +360,13 @@ class TestRunBotForMessage:
|
||||
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."""
|
||||
async def test_skips_when_no_enabled_bots(self):
|
||||
"""Bot is not triggered when no bots are enabled."""
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.bot_enabled = False
|
||||
mock_settings.bot_code = "def bot(): pass"
|
||||
mock_settings.bots = [
|
||||
BotConfig(id="1", name="Bot 1", enabled=False, code="def bot(): pass")
|
||||
]
|
||||
mock_repo.get = AsyncMock(return_value=mock_settings)
|
||||
|
||||
with patch("app.bot.execute_bot_code") as mock_exec:
|
||||
@@ -379,12 +381,33 @@ class TestRunBotForMessage:
|
||||
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."""
|
||||
async def test_skips_when_bots_array_empty(self):
|
||||
"""Bot is not triggered when bots array is empty."""
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.bot_enabled = True
|
||||
mock_settings.bot_code = ""
|
||||
mock_settings.bots = []
|
||||
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_bot_with_empty_code(self):
|
||||
"""Bot with empty code is skipped even if enabled."""
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.bots = [
|
||||
BotConfig(id="1", name="Empty Bot", enabled=True, code=""),
|
||||
BotConfig(id="2", name="Whitespace Bot", enabled=True, code=" "),
|
||||
]
|
||||
mock_repo.get = AsyncMock(return_value=mock_settings)
|
||||
|
||||
with patch("app.bot.execute_bot_code") as mock_exec:
|
||||
@@ -405,12 +428,14 @@ class TestRunBotForMessage:
|
||||
# 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_enabled.bots = [
|
||||
BotConfig(id="1", name="Bot 1", enabled=True, 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_settings_disabled.bots = [
|
||||
BotConfig(id="1", name="Bot 1", enabled=False, code="def bot(): return 'hi'")
|
||||
]
|
||||
|
||||
mock_repo.get = AsyncMock(side_effect=[mock_settings_enabled, mock_settings_disabled])
|
||||
|
||||
@@ -433,6 +458,199 @@ class TestRunBotForMessage:
|
||||
mock_exec.assert_not_called()
|
||||
|
||||
|
||||
class TestMultipleBots:
|
||||
"""Test multiple bots functionality."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_semaphore(self):
|
||||
"""Reset semaphore state between tests."""
|
||||
while _bot_semaphore.locked():
|
||||
_bot_semaphore.release()
|
||||
yield
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_rate_limit_state(self):
|
||||
"""Reset rate limiting state between tests."""
|
||||
bot_module._last_bot_send_time = 0.0
|
||||
yield
|
||||
bot_module._last_bot_send_time = 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_bots_execute_serially(self):
|
||||
"""Multiple enabled bots execute serially in order."""
|
||||
executed_bots = []
|
||||
|
||||
def mock_execute(code, *args, **kwargs):
|
||||
# Extract bot identifier from the code
|
||||
if "Bot 1" in code:
|
||||
executed_bots.append("Bot 1")
|
||||
return "Response 1"
|
||||
elif "Bot 2" in code:
|
||||
executed_bots.append("Bot 2")
|
||||
return "Response 2"
|
||||
return None
|
||||
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.bots = [
|
||||
BotConfig(id="1", name="Bot 1", enabled=True, code="# Bot 1\ndef bot(): pass"),
|
||||
BotConfig(id="2", name="Bot 2", enabled=True, code="# Bot 2\ndef bot(): pass"),
|
||||
]
|
||||
mock_repo.get = AsyncMock(return_value=mock_settings)
|
||||
|
||||
with (
|
||||
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
|
||||
patch("app.bot.execute_bot_code", side_effect=mock_execute),
|
||||
patch("app.bot.process_bot_response", new_callable=AsyncMock),
|
||||
):
|
||||
await run_bot_for_message(
|
||||
sender_name="Alice",
|
||||
sender_key="abc123" + "0" * 58,
|
||||
message_text="Hello",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
)
|
||||
|
||||
# Both bots should have executed in order
|
||||
assert executed_bots == ["Bot 1", "Bot 2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_bots_are_skipped(self):
|
||||
"""Disabled bots in the array are skipped."""
|
||||
executed_bots = []
|
||||
|
||||
def mock_execute(code, *args, **kwargs):
|
||||
if "Bot 1" in code:
|
||||
executed_bots.append("Bot 1")
|
||||
elif "Bot 2" in code:
|
||||
executed_bots.append("Bot 2")
|
||||
elif "Bot 3" in code:
|
||||
executed_bots.append("Bot 3")
|
||||
return None
|
||||
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.bots = [
|
||||
BotConfig(id="1", name="Bot 1", enabled=True, code="# Bot 1\ndef bot(): pass"),
|
||||
BotConfig(id="2", name="Bot 2", enabled=False, code="# Bot 2\ndef bot(): pass"),
|
||||
BotConfig(id="3", name="Bot 3", enabled=True, code="# Bot 3\ndef bot(): pass"),
|
||||
]
|
||||
mock_repo.get = AsyncMock(return_value=mock_settings)
|
||||
|
||||
with (
|
||||
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
|
||||
patch("app.bot.execute_bot_code", side_effect=mock_execute),
|
||||
):
|
||||
await run_bot_for_message(
|
||||
sender_name="Alice",
|
||||
sender_key="abc123" + "0" * 58,
|
||||
message_text="Hello",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
)
|
||||
|
||||
# Only enabled bots should have executed
|
||||
assert executed_bots == ["Bot 1", "Bot 3"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_in_one_bot_doesnt_stop_others(self):
|
||||
"""Error in one bot doesn't prevent other bots from running."""
|
||||
executed_bots = []
|
||||
|
||||
def mock_execute(code, *args, **kwargs):
|
||||
if "Bot 1" in code:
|
||||
executed_bots.append("Bot 1")
|
||||
raise ValueError("Bot 1 crashed!")
|
||||
elif "Bot 2" in code:
|
||||
executed_bots.append("Bot 2")
|
||||
return "Response 2"
|
||||
elif "Bot 3" in code:
|
||||
executed_bots.append("Bot 3")
|
||||
return "Response 3"
|
||||
return None
|
||||
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.bots = [
|
||||
BotConfig(id="1", name="Bot 1", enabled=True, code="# Bot 1\ndef bot(): pass"),
|
||||
BotConfig(id="2", name="Bot 2", enabled=True, code="# Bot 2\ndef bot(): pass"),
|
||||
BotConfig(id="3", name="Bot 3", enabled=True, code="# Bot 3\ndef bot(): pass"),
|
||||
]
|
||||
mock_repo.get = AsyncMock(return_value=mock_settings)
|
||||
|
||||
with (
|
||||
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
|
||||
patch("app.bot.execute_bot_code", side_effect=mock_execute),
|
||||
patch("app.bot.process_bot_response", new_callable=AsyncMock) as mock_respond,
|
||||
):
|
||||
await run_bot_for_message(
|
||||
sender_name="Alice",
|
||||
sender_key="abc123" + "0" * 58,
|
||||
message_text="Hello",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
)
|
||||
|
||||
# All bots should have been attempted
|
||||
assert executed_bots == ["Bot 1", "Bot 2", "Bot 3"]
|
||||
|
||||
# Responses from successful bots should have been sent
|
||||
assert mock_respond.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_in_one_bot_doesnt_stop_others(self):
|
||||
"""Timeout in one bot doesn't prevent other bots from running."""
|
||||
executed_bots = []
|
||||
|
||||
async def mock_wait_for(coro, timeout):
|
||||
result = await coro
|
||||
# Simulate timeout for Bot 2
|
||||
if len(executed_bots) == 2 and executed_bots[-1] == "Bot 2":
|
||||
raise asyncio.TimeoutError()
|
||||
return result
|
||||
|
||||
def mock_execute(code, *args, **kwargs):
|
||||
if "Bot 1" in code:
|
||||
executed_bots.append("Bot 1")
|
||||
return "Response 1"
|
||||
elif "Bot 2" in code:
|
||||
executed_bots.append("Bot 2")
|
||||
return "Response 2" # This will be "timed out"
|
||||
elif "Bot 3" in code:
|
||||
executed_bots.append("Bot 3")
|
||||
return "Response 3"
|
||||
return None
|
||||
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.bots = [
|
||||
BotConfig(id="1", name="Bot 1", enabled=True, code="# Bot 1\ndef bot(): pass"),
|
||||
BotConfig(id="2", name="Bot 2", enabled=True, code="# Bot 2\ndef bot(): pass"),
|
||||
BotConfig(id="3", name="Bot 3", enabled=True, code="# Bot 3\ndef bot(): pass"),
|
||||
]
|
||||
mock_repo.get = AsyncMock(return_value=mock_settings)
|
||||
|
||||
with (
|
||||
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
|
||||
patch("app.bot.execute_bot_code", side_effect=mock_execute),
|
||||
patch("app.bot.asyncio.wait_for", side_effect=mock_wait_for),
|
||||
patch("app.bot.process_bot_response", new_callable=AsyncMock) as mock_respond,
|
||||
):
|
||||
await run_bot_for_message(
|
||||
sender_name="Alice",
|
||||
sender_key="abc123" + "0" * 58,
|
||||
message_text="Hello",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
)
|
||||
|
||||
# All bots should have been attempted
|
||||
assert executed_bots == ["Bot 1", "Bot 2", "Bot 3"]
|
||||
|
||||
# Only responses from non-timed-out bots (Bot 1 and Bot 3)
|
||||
assert mock_respond.call_count == 2
|
||||
|
||||
|
||||
class TestBotCodeValidation:
|
||||
"""Test bot code syntax validation on save."""
|
||||
|
||||
@@ -455,6 +673,18 @@ class TestBotCodeValidation:
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "syntax error" in exc_info.value.detail.lower()
|
||||
|
||||
def test_syntax_error_includes_bot_name(self):
|
||||
"""Syntax error message includes bot name when provided."""
|
||||
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'", bot_name="My Test Bot")
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "My Test Bot" in exc_info.value.detail
|
||||
|
||||
def test_empty_code_passes(self):
|
||||
"""Empty code passes validation (disables bot)."""
|
||||
from app.routers.settings import validate_bot_code
|
||||
@@ -463,6 +693,29 @@ class TestBotCodeValidation:
|
||||
validate_bot_code("")
|
||||
validate_bot_code(" ")
|
||||
|
||||
def test_validate_all_bots(self):
|
||||
"""validate_all_bots validates all bots' code."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.settings import validate_all_bots
|
||||
|
||||
# Valid bots should pass
|
||||
valid_bots = [
|
||||
BotConfig(id="1", name="Bot 1", enabled=True, code="def bot(): return 'hi'"),
|
||||
BotConfig(id="2", name="Bot 2", enabled=False, code="def bot(): return 'hello'"),
|
||||
]
|
||||
validate_all_bots(valid_bots) # Should not raise
|
||||
|
||||
# Invalid code should raise with bot name
|
||||
invalid_bots = [
|
||||
BotConfig(id="1", name="Good Bot", enabled=True, code="def bot(): return 'hi'"),
|
||||
BotConfig(id="2", name="Bad Bot", enabled=True, code="def bot(:"),
|
||||
]
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_all_bots(invalid_bots)
|
||||
|
||||
assert "Bad Bot" in exc_info.value.detail
|
||||
|
||||
|
||||
class TestBotMessageRateLimiting:
|
||||
"""Test bot message rate limiting for repeater compatibility."""
|
||||
|
||||
+101
-7
@@ -100,8 +100,8 @@ class TestMigration001:
|
||||
# Run migrations
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 12 # All 11 migrations run
|
||||
assert await get_version(conn) == 12
|
||||
assert applied == 13 # All 13 migrations run
|
||||
assert await get_version(conn) == 13
|
||||
|
||||
# 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 == 12 # All 11 migrations run
|
||||
assert applied1 == 13 # All 13 migrations run
|
||||
assert applied2 == 0 # No migrations on second run
|
||||
assert await get_version(conn) == 12
|
||||
assert await get_version(conn) == 13
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -245,9 +245,9 @@ class TestMigration001:
|
||||
# Run migrations - should not fail
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
# All 11 migrations applied (version incremented) but no error
|
||||
assert applied == 12
|
||||
assert await get_version(conn) == 12
|
||||
# All 13 migrations applied (version incremented) but no error
|
||||
assert applied == 13
|
||||
assert await get_version(conn) == 13
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -337,3 +337,97 @@ class TestMigration001:
|
||||
assert row["last_read_at"] is None
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class TestMigration013:
|
||||
"""Test migration 013: convert bot_enabled/bot_code to multi-bot format."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_converts_existing_bot_to_array(self):
|
||||
"""Migration converts existing bot_enabled/bot_code to bots array."""
|
||||
import json
|
||||
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
conn.row_factory = aiosqlite.Row
|
||||
try:
|
||||
# Set version to 12 (just before migration 13)
|
||||
await set_version(conn, 12)
|
||||
|
||||
# Create app_settings with old bot columns
|
||||
await conn.execute("""
|
||||
CREATE TABLE app_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
max_radio_contacts INTEGER DEFAULT 50,
|
||||
favorites TEXT DEFAULT '[]',
|
||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
|
||||
sidebar_sort_order TEXT DEFAULT 'recent',
|
||||
last_message_times TEXT DEFAULT '{}',
|
||||
preferences_migrated INTEGER DEFAULT 0,
|
||||
advert_interval INTEGER DEFAULT 0,
|
||||
last_advert_time INTEGER DEFAULT 0,
|
||||
bot_enabled INTEGER DEFAULT 0,
|
||||
bot_code TEXT DEFAULT ''
|
||||
)
|
||||
""")
|
||||
await conn.execute(
|
||||
"INSERT INTO app_settings (id, bot_enabled, bot_code) VALUES (1, 1, 'def bot(): return \"hello\"')"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
# Run migration 13
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 1
|
||||
assert await get_version(conn) == 13
|
||||
|
||||
# Verify bots array was created with migrated data
|
||||
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
|
||||
row = await cursor.fetchone()
|
||||
bots = json.loads(row["bots"])
|
||||
|
||||
assert len(bots) == 1
|
||||
assert bots[0]["name"] == "Bot 1"
|
||||
assert bots[0]["enabled"] is True
|
||||
assert bots[0]["code"] == 'def bot(): return "hello"'
|
||||
assert "id" in bots[0] # Should have a UUID
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_creates_empty_array_when_no_bot(self):
|
||||
"""Migration creates empty bots array when no existing bot data."""
|
||||
import json
|
||||
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
conn.row_factory = aiosqlite.Row
|
||||
try:
|
||||
await set_version(conn, 12)
|
||||
|
||||
await conn.execute("""
|
||||
CREATE TABLE app_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
max_radio_contacts INTEGER DEFAULT 50,
|
||||
favorites TEXT DEFAULT '[]',
|
||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
|
||||
sidebar_sort_order TEXT DEFAULT 'recent',
|
||||
last_message_times TEXT DEFAULT '{}',
|
||||
preferences_migrated INTEGER DEFAULT 0,
|
||||
advert_interval INTEGER DEFAULT 0,
|
||||
last_advert_time INTEGER DEFAULT 0,
|
||||
bot_enabled INTEGER DEFAULT 0,
|
||||
bot_code TEXT DEFAULT ''
|
||||
)
|
||||
""")
|
||||
await conn.execute(
|
||||
"INSERT INTO app_settings (id, bot_enabled, bot_code) VALUES (1, 0, '')"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
await run_migrations(conn)
|
||||
|
||||
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
|
||||
row = await cursor.fetchone()
|
||||
bots = json.loads(row["bots"])
|
||||
|
||||
assert bots == []
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
Reference in New Issue
Block a user