mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-08 22:35:10 +02:00
Move bots into Fanout & Forwarding
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -155,23 +154,6 @@ async def on_contact_message(event: "Event") -> None:
|
||||
if contact:
|
||||
await ContactRepository.update_last_contacted(sender_pubkey, received_at)
|
||||
|
||||
# Run bot if enabled
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Fanout module wrapping bot execution logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotModule(FanoutModule):
|
||||
"""Wraps a single bot's code execution and response routing.
|
||||
|
||||
Each BotModule represents one bot configuration. It receives decoded
|
||||
messages via ``on_message``, executes the bot's Python code in a
|
||||
background task (after a 2-second settle delay), and sends any response
|
||||
back through the radio.
|
||||
"""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "Bot") -> None:
|
||||
super().__init__(config_id, config)
|
||||
self._name = name
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
"""Kick off bot execution in a background task so we don't block dispatch."""
|
||||
asyncio.create_task(self._run_for_message(data))
|
||||
|
||||
async def _run_for_message(self, data: dict) -> None:
|
||||
from app.bot import BOT_EXECUTION_TIMEOUT, execute_bot_code, process_bot_response
|
||||
|
||||
code = self.config.get("code", "")
|
||||
if not code or not code.strip():
|
||||
return
|
||||
|
||||
msg_type = data.get("type", "")
|
||||
is_dm = msg_type == "PRIV"
|
||||
|
||||
# Extract bot parameters from broadcast data
|
||||
if is_dm:
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
sender_key = conversation_key
|
||||
is_outgoing = data.get("outgoing", False)
|
||||
message_text = data.get("text", "")
|
||||
channel_key = None
|
||||
channel_name = None
|
||||
|
||||
# Look up sender name from contacts
|
||||
from app.repository import ContactRepository
|
||||
|
||||
contact = await ContactRepository.get_by_key(conversation_key)
|
||||
sender_name = contact.name if contact else None
|
||||
else:
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
sender_key = None
|
||||
is_outgoing = False
|
||||
sender_name = data.get("sender_name")
|
||||
channel_key = conversation_key
|
||||
|
||||
# Look up channel name
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
channel = await ChannelRepository.get_by_key(conversation_key)
|
||||
channel_name = channel.name if channel else None
|
||||
|
||||
# Strip "sender: " prefix from channel message text
|
||||
text = data.get("text", "")
|
||||
if sender_name and text.startswith(f"{sender_name}: "):
|
||||
message_text = text[len(f"{sender_name}: ") :]
|
||||
else:
|
||||
message_text = text
|
||||
|
||||
sender_timestamp = data.get("sender_timestamp")
|
||||
path_value = data.get("path")
|
||||
# Message model serializes paths as list of dicts; extract first path string
|
||||
if path_value is None:
|
||||
paths = data.get("paths")
|
||||
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
|
||||
|
||||
# Wait for message to settle (allows retransmissions to be deduped)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Execute bot code in thread pool with timeout
|
||||
from app.bot import _bot_executor, _bot_semaphore
|
||||
|
||||
async with _bot_semaphore:
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
_bot_executor,
|
||||
execute_bot_code,
|
||||
code,
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path_value,
|
||||
is_outgoing,
|
||||
),
|
||||
timeout=BOT_EXECUTION_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Bot '%s' execution timed out", self._name)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("Bot '%s' execution error: %s", self._name, e)
|
||||
return
|
||||
|
||||
if response:
|
||||
await process_bot_response(response, is_dm, sender_key or "", channel_key)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return "connected"
|
||||
+14
-1
@@ -17,11 +17,13 @@ def _register_module_types() -> None:
|
||||
"""Lazily populate the type registry to avoid circular imports."""
|
||||
if _MODULE_TYPES:
|
||||
return
|
||||
from app.fanout.bot import BotModule
|
||||
from app.fanout.mqtt_community import MqttCommunityModule
|
||||
from app.fanout.mqtt_private import MqttPrivateModule
|
||||
|
||||
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
|
||||
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
|
||||
_MODULE_TYPES["bot"] = BotModule
|
||||
|
||||
|
||||
def _scope_matches_message(scope: dict, data: dict) -> bool:
|
||||
@@ -80,13 +82,24 @@ class FanoutManager:
|
||||
config_blob = cfg["config"]
|
||||
scope = cfg["scope"]
|
||||
|
||||
# Skip bot modules when bots are disabled server-wide
|
||||
if config_type == "bot":
|
||||
from app.config import settings as server_settings
|
||||
|
||||
if server_settings.disable_bots:
|
||||
logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
|
||||
return
|
||||
|
||||
cls = _MODULE_TYPES.get(config_type)
|
||||
if cls is None:
|
||||
logger.warning("Unknown fanout type %r for config %s, skipping", config_type, config_id)
|
||||
return
|
||||
|
||||
try:
|
||||
module = cls(config_id, config_blob)
|
||||
if config_type == "bot":
|
||||
module = cls(config_id, config_blob, name=cfg.get("name", "Bot"))
|
||||
else:
|
||||
module = cls(config_id, config_blob)
|
||||
await module.start()
|
||||
self._modules[config_id] = (module, scope)
|
||||
logger.info(
|
||||
|
||||
@@ -289,6 +289,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 36)
|
||||
applied += 1
|
||||
|
||||
# Migration 37: Migrate bots from app_settings to fanout_configs
|
||||
if version < 37:
|
||||
logger.info("Applying migration 37: migrate bots to fanout_configs")
|
||||
await _migrate_037_bots_to_fanout(conn)
|
||||
await set_version(conn, 37)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -2149,3 +2156,61 @@ async def _migrate_036_create_fanout_configs(conn: aiosqlite.Connection) -> None
|
||||
logger.info("Migrated community MQTT settings to fanout_configs")
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_037_bots_to_fanout(conn: aiosqlite.Connection) -> None:
|
||||
"""Migrate bots from app_settings.bots JSON to fanout_configs rows."""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
try:
|
||||
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
|
||||
row = await cursor.fetchone()
|
||||
except Exception:
|
||||
row = None
|
||||
|
||||
if row is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
bots_json = row["bots"] or "[]"
|
||||
try:
|
||||
bots = json.loads(bots_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
bots = []
|
||||
|
||||
if not bots:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
import time
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
# Use sort_order starting at 200 to place bots after MQTT configs (0-99)
|
||||
for i, bot in enumerate(bots):
|
||||
bot_name = bot.get("name") or f"Bot {i + 1}"
|
||||
bot_enabled = bool(bot.get("enabled", False))
|
||||
bot_code = bot.get("code", "")
|
||||
|
||||
config_blob = json.dumps({"code": bot_code})
|
||||
scope = json.dumps({"messages": "all", "raw_packets": "none"})
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
|
||||
VALUES (?, 'bot', ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
bot_name,
|
||||
1 if bot_enabled else 0,
|
||||
config_blob,
|
||||
scope,
|
||||
200 + i,
|
||||
now,
|
||||
),
|
||||
)
|
||||
logger.info("Migrated bot '%s' to fanout_configs (enabled=%s)", bot_name, bot_enabled)
|
||||
|
||||
await conn.commit()
|
||||
|
||||
+2
-38
@@ -195,7 +195,7 @@ async def create_message_from_decrypted(
|
||||
# Use "is not None" to include empty string (direct/0-hop messages)
|
||||
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
|
||||
|
||||
# Broadcast new message to connected clients
|
||||
# Broadcast new message to connected clients (and fanout modules when realtime)
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
@@ -212,24 +212,6 @@ async def create_message_from_decrypted(
|
||||
realtime=trigger_bot,
|
||||
)
|
||||
|
||||
# Run bot if enabled (for incoming channel messages, not historical decryption)
|
||||
if trigger_bot:
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
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
|
||||
|
||||
|
||||
@@ -318,7 +300,7 @@ async def create_dm_message_from_decrypted(
|
||||
# Build paths array for broadcast
|
||||
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
|
||||
|
||||
# Broadcast new message to connected clients
|
||||
# Broadcast new message to connected clients (and fanout modules when realtime)
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
@@ -339,24 +321,6 @@ 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 all real-time DMs, including our own outgoing messages)
|
||||
if trigger_bot:
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=contact.name if contact else None,
|
||||
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=outgoing,
|
||||
)
|
||||
)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
|
||||
+25
-3
@@ -6,13 +6,13 @@ import re
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings as server_settings
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
||||
|
||||
# Valid types in Phase 1
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community"}
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot"}
|
||||
|
||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||
|
||||
@@ -51,11 +51,26 @@ def _validate_mqtt_community_config(config: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _validate_bot_config(config: dict) -> None:
|
||||
"""Validate bot config blob (syntax-check the code)."""
|
||||
code = config.get("code", "")
|
||||
if not code or not code.strip():
|
||||
raise HTTPException(status_code=400, detail="Bot code cannot be empty")
|
||||
try:
|
||||
compile(code, "<bot_code>", "exec")
|
||||
except SyntaxError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bot code has syntax error at line {e.lineno}: {e.msg}",
|
||||
) from None
|
||||
|
||||
|
||||
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
||||
if config_type == "mqtt_community":
|
||||
# Community MQTT always: no messages, all raw packets
|
||||
return {"messages": "none", "raw_packets": "all"}
|
||||
if config_type == "bot":
|
||||
return {"messages": "all", "raw_packets": "none"}
|
||||
# For mqtt_private, validate scope values
|
||||
messages = scope.get("messages", "all")
|
||||
if messages not in ("all", "none") and not isinstance(messages, dict):
|
||||
@@ -81,6 +96,9 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
||||
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
|
||||
)
|
||||
|
||||
if body.type == "bot" and server_settings.disable_bots:
|
||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
||||
|
||||
# Only validate config when creating as enabled — disabled configs
|
||||
# are drafts the user hasn't finished configuring yet.
|
||||
if body.enabled:
|
||||
@@ -88,6 +106,8 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
||||
_validate_mqtt_private_config(body.config)
|
||||
elif body.type == "mqtt_community":
|
||||
_validate_mqtt_community_config(body.config)
|
||||
elif body.type == "bot":
|
||||
_validate_bot_config(body.config)
|
||||
|
||||
scope = _enforce_scope(body.type, body.scope)
|
||||
|
||||
@@ -134,6 +154,8 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
||||
_validate_mqtt_private_config(config_to_validate)
|
||||
elif existing["type"] == "mqtt_community":
|
||||
_validate_mqtt_community_config(config_to_validate)
|
||||
elif existing["type"] == "bot":
|
||||
_validate_bot_config(config_to_validate)
|
||||
|
||||
updated = await FanoutConfigRepository.update(config_id, **kwargs)
|
||||
if updated is None:
|
||||
|
||||
+1
-35
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
@@ -176,25 +175,9 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
)
|
||||
|
||||
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
|
||||
# Fanout modules (including bots) are triggered via broadcast_event's realtime dispatch.
|
||||
broadcast_event("message", message.model_dump())
|
||||
|
||||
# Trigger bots for outgoing DMs (runs in background, doesn't block response)
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=None,
|
||||
sender_key=db_contact.public_key.lower(),
|
||||
message_text=request.text,
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=now,
|
||||
path=None,
|
||||
is_outgoing=True,
|
||||
)
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
@@ -335,23 +318,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
sender_key=our_public_key,
|
||||
)
|
||||
|
||||
# Trigger bots for outgoing channel messages (runs in background, doesn't block response)
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=radio_name or None,
|
||||
sender_key=None,
|
||||
message_text=request.text,
|
||||
is_dm=False,
|
||||
channel_key=channel_key_upper,
|
||||
channel_name=db_channel.name,
|
||||
sender_timestamp=now,
|
||||
path=None,
|
||||
is_outgoing=True,
|
||||
)
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
|
||||
+2
-37
@@ -2,38 +2,16 @@ import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings as server_settings
|
||||
from app.models import AppSettings, BotConfig
|
||||
from app.models import AppSettings
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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 {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,
|
||||
@@ -56,10 +34,6 @@ class AppSettingsUpdate(BaseModel):
|
||||
ge=0,
|
||||
description="Periodic advertisement interval in seconds (0 = disabled, minimum 3600)",
|
||||
)
|
||||
bots: list[BotConfig] | None = Field(
|
||||
default=None,
|
||||
description="List of bot configurations",
|
||||
)
|
||||
flood_scope: str | None = Field(
|
||||
default=None,
|
||||
description="Outbound flood scope / region name (empty = disabled)",
|
||||
@@ -140,15 +114,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
logger.info("Updating advert_interval to %d", interval)
|
||||
kwargs["advert_interval"] = interval
|
||||
|
||||
if update.bots is not None:
|
||||
if server_settings.disable_bots:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Bot system disabled by server configuration"
|
||||
)
|
||||
validate_all_bots(update.bots)
|
||||
logger.info("Updating bots (count=%d)", len(update.bots))
|
||||
kwargs["bots"] = update.bots
|
||||
|
||||
# Block lists
|
||||
if update.blocked_keys is not None:
|
||||
kwargs["blocked_keys"] = [k.lower() for k in update.blocked_keys]
|
||||
|
||||
+1
-2
@@ -84,9 +84,8 @@ frontend/src/
|
||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot
|
||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
|
||||
│ │ ├── SettingsMqttSection.tsx # MQTT broker config, TLS, publish toggles
|
||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||
│ │ ├── SettingsBotSection.tsx # Bot list, code editor, add/delete/reset
|
||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links
|
||||
│ │ └── ThemeSelector.tsx # Color theme picker
|
||||
|
||||
@@ -13,7 +13,6 @@ import { SettingsRadioSection } from './settings/SettingsRadioSection';
|
||||
import { SettingsLocalSection } from './settings/SettingsLocalSection';
|
||||
import { SettingsFanoutSection } from './settings/SettingsFanoutSection';
|
||||
import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
|
||||
import { SettingsBotSection } from './settings/SettingsBotSection';
|
||||
import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
|
||||
import { SettingsAboutSection } from './settings/SettingsAboutSection';
|
||||
|
||||
@@ -80,7 +79,6 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
local: false,
|
||||
fanout: false,
|
||||
database: false,
|
||||
bot: false,
|
||||
statistics: false,
|
||||
about: false,
|
||||
});
|
||||
@@ -217,26 +215,15 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('bot') && (
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('bot')}
|
||||
{isSectionVisible('bot') && appSettings && (
|
||||
<SettingsBotSection
|
||||
appSettings={appSettings}
|
||||
health={health}
|
||||
isMobileLayout={isMobileLayout}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('fanout') && (
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('fanout')}
|
||||
{isSectionVisible('fanout') && (
|
||||
<SettingsFanoutSection health={health} className={sectionContentClass} />
|
||||
<SettingsFanoutSection
|
||||
health={health}
|
||||
onHealthRefresh={onHealthRefresh}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import type { AppSettings, AppSettingsUpdate, BotConfig, HealthStatus } from '../../types';
|
||||
|
||||
const BotCodeEditor = lazy(() =>
|
||||
import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
|
||||
);
|
||||
|
||||
const DEFAULT_BOT_CODE = `def bot(
|
||||
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,
|
||||
is_outgoing: bool = False,
|
||||
) -> str | list[str] | None:
|
||||
"""
|
||||
Process messages and optionally return a reply.
|
||||
|
||||
Args:
|
||||
sender_name: Display name of sender (may be None)
|
||||
sender_key: 64-char hex public key (None 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)
|
||||
is_outgoing: True if this is our own outgoing message
|
||||
|
||||
Returns:
|
||||
None for no reply, a string for a single reply,
|
||||
or a list of strings to send multiple messages in order
|
||||
"""
|
||||
# Don't reply to our own outgoing messages
|
||||
if is_outgoing:
|
||||
return None
|
||||
|
||||
# Example: Only respond in #bot channel to "!pling" command
|
||||
if channel_name == "#bot" and "!pling" in message_text.lower():
|
||||
return "[BOT] Plong!"
|
||||
return None`;
|
||||
|
||||
export function SettingsBotSection({
|
||||
appSettings,
|
||||
health,
|
||||
isMobileLayout,
|
||||
onSaveAppSettings,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
health: HealthStatus | null;
|
||||
isMobileLayout: boolean;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const [bots, setBots] = useState<BotConfig[]>([]);
|
||||
const [expandedBotId, setExpandedBotId] = useState<string | null>(null);
|
||||
const [editingNameId, setEditingNameId] = useState<string | null>(null);
|
||||
const [editingNameValue, setEditingNameValue] = useState('');
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBots(appSettings.bots || []);
|
||||
}, [appSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSaveAppSettings({ bots });
|
||||
toast.success('Bot settings saved');
|
||||
} catch (err) {
|
||||
console.error('Failed to save bot settings:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to save';
|
||||
setError(errorMsg);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setBusy(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)));
|
||||
};
|
||||
|
||||
if (health?.bots_disabled) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-muted-foreground">Bot system disabled by server startup flag.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/30 rounded-md">
|
||||
<p className="text-sm text-destructive">
|
||||
<strong>Experimental:</strong> This is an alpha feature and introduces automated message
|
||||
sending to your radio; unexpected behavior may occur. Use with caution, and please report
|
||||
any bugs!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-warning/10 border border-warning/30 rounded-md">
|
||||
<p className="text-sm text-warning">
|
||||
<strong>Security Warning:</strong> This feature executes arbitrary Python code on the
|
||||
server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-warning/10 border border-warning/30 rounded-md">
|
||||
<p className="text-sm text-warning">
|
||||
<strong>Don't wreck the mesh!</strong> Bots process ALL messages, including their
|
||||
own. Be careful of creating infinite loops!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Bots</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBot}>
|
||||
+ New Bot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{bots.map((bot) => (
|
||||
<div key={bot.id} className="border border-input rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80 w-full text-left"
|
||||
aria-expanded={expandedBotId === bot.id}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('input, [data-bot-control]')) return;
|
||||
setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
|
||||
}}
|
||||
>
|
||||
<span className="text-muted-foreground" aria-hidden="true">
|
||||
{expandedBotId === bot.id ? '▼' : '▶'}
|
||||
</span>
|
||||
|
||||
{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
|
||||
aria-label="Bot name"
|
||||
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 text-left"
|
||||
data-bot-control
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEditingName(bot);
|
||||
}}
|
||||
title="Click to rename"
|
||||
>
|
||||
{bot.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<label
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
data-bot-control
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bot.enabled}
|
||||
onChange={() => handleToggleBotEnabled(bot.id)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
aria-label={`Enable ${bot.name}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Enabled</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-bot-control
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteBot(bot.id);
|
||||
}}
|
||||
title="Delete bot"
|
||||
aria-label={`Delete ${bot.name}`}
|
||||
>
|
||||
<span aria-hidden="true">🗑</span>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
{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>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-64 md:h-96 rounded-md border border-input bg-code-editor-bg flex items-center justify-center text-muted-foreground">
|
||||
Loading editor...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BotCodeEditor
|
||||
value={bot.code}
|
||||
onChange={(code) => handleBotCodeChange(bot.id, code)}
|
||||
id={`bot-code-${bot.id}`}
|
||||
height={isMobileLayout ? '256px' : '384px'}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<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 per bot.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Note:</strong> Bots respond to all messages, including your own. For channel
|
||||
messages, <code>sender_key</code> is <code>None</code>. Multiple enabled bots run
|
||||
serially, with a two-second delay between messages to prevent repeater collision.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save Bot Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -8,24 +8,68 @@ import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api';
|
||||
import type { FanoutConfig, HealthStatus } from '../../types';
|
||||
|
||||
const BotCodeEditor = lazy(() =>
|
||||
import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
|
||||
);
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
mqtt_private: 'Private MQTT',
|
||||
mqtt_community: 'Community MQTT',
|
||||
bot: 'Bot',
|
||||
};
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'mqtt_private', label: 'Private MQTT' },
|
||||
{ value: 'mqtt_community', label: 'Community MQTT' },
|
||||
{ value: 'bot', label: 'Bot' },
|
||||
];
|
||||
|
||||
const DEFAULT_BOT_CODE = `def bot(
|
||||
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,
|
||||
is_outgoing: bool = False,
|
||||
) -> str | list[str] | None:
|
||||
"""
|
||||
Process messages and optionally return a reply.
|
||||
|
||||
Args:
|
||||
sender_name: Display name of sender (may be None)
|
||||
sender_key: 64-char hex public key (None 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)
|
||||
is_outgoing: True if this is our own outgoing message
|
||||
|
||||
Returns:
|
||||
None for no reply, a string for a single reply,
|
||||
or a list of strings to send multiple messages in order
|
||||
"""
|
||||
# Don't reply to our own outgoing messages
|
||||
if is_outgoing:
|
||||
return None
|
||||
|
||||
# Example: Only respond in #bot channel to "!pling" command
|
||||
if channel_name == "#bot" and "!pling" in message_text.lower():
|
||||
return "[BOT] Plong!"
|
||||
return None`;
|
||||
|
||||
function getStatusColor(status: string | undefined) {
|
||||
if (status === 'connected')
|
||||
return 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]';
|
||||
return 'bg-muted-foreground';
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string | undefined) {
|
||||
if (status === 'connected') return 'Connected';
|
||||
function getStatusLabel(status: string | undefined, type?: string) {
|
||||
if (status === 'connected') return type === 'bot' ? 'Active' : 'Connected';
|
||||
if (status === 'disconnected') return 'Disconnected';
|
||||
return 'Inactive';
|
||||
}
|
||||
@@ -47,6 +91,11 @@ function MqttPrivateConfigEditor({
|
||||
Forward mesh data to your own MQTT broker for home automation, logging, or alerting.
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
Outgoing messages (DMs and group messages) will be reported to private MQTT brokers in
|
||||
decrypted/plaintext form.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-mqtt-host">Broker Host</Label>
|
||||
@@ -234,11 +283,88 @@ function MqttCommunityConfigEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function BotConfigEditor({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const code = (config.code as string) || '';
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/30 rounded-md">
|
||||
<p className="text-sm text-destructive">
|
||||
<strong>Experimental:</strong> This is an alpha feature and introduces automated message
|
||||
sending to your radio; unexpected behavior may occur. Use with caution, and please report
|
||||
any bugs!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-warning/10 border border-warning/30 rounded-md">
|
||||
<p className="text-sm text-warning">
|
||||
<strong>Security Warning:</strong> This feature executes arbitrary Python code on the
|
||||
server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-warning/10 border border-warning/30 rounded-md">
|
||||
<p className="text-sm text-warning">
|
||||
<strong>Don't wreck the mesh!</strong> Bots process ALL messages, including their
|
||||
own. Be careful of creating infinite loops!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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={() => onChange({ ...config, code: DEFAULT_BOT_CODE })}
|
||||
>
|
||||
Reset to Example
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-64 md:h-96 rounded-md border border-input bg-code-editor-bg flex items-center justify-center text-muted-foreground">
|
||||
Loading editor...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BotCodeEditor value={code} onChange={(c) => onChange({ ...config, code: c })} />
|
||||
</Suspense>
|
||||
|
||||
<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 per bot.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Note:</strong> Bots respond to all messages, including your own. For channel
|
||||
messages, <code>sender_key</code> is <code>None</code>. Multiple enabled bots run
|
||||
serially, with a two-second delay between messages to prevent repeater collision.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsFanoutSection({
|
||||
health,
|
||||
onHealthRefresh,
|
||||
className,
|
||||
}: {
|
||||
health: HealthStatus | null;
|
||||
onHealthRefresh?: () => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const [configs, setConfigs] = useState<FanoutConfig[]>([]);
|
||||
@@ -266,6 +392,7 @@ export function SettingsFanoutSection({
|
||||
try {
|
||||
await api.updateFanoutConfig(cfg.id, { enabled: !cfg.enabled });
|
||||
await loadConfigs();
|
||||
if (onHealthRefresh) await onHealthRefresh();
|
||||
toast.success(cfg.enabled ? 'Integration disabled' : 'Integration enabled');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update');
|
||||
@@ -332,10 +459,14 @@ export function SettingsFanoutSection({
|
||||
iata: '',
|
||||
email: '',
|
||||
},
|
||||
bot: {
|
||||
code: DEFAULT_BOT_CODE,
|
||||
},
|
||||
};
|
||||
const defaultScopes: Record<string, Record<string, unknown>> = {
|
||||
mqtt_private: { messages: 'all', raw_packets: 'all' },
|
||||
mqtt_community: { messages: 'none', raw_packets: 'all' },
|
||||
bot: { messages: 'all', raw_packets: 'none' },
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -398,6 +529,10 @@ export function SettingsFanoutSection({
|
||||
<MqttCommunityConfigEditor config={editConfig} onChange={setEditConfig} />
|
||||
)}
|
||||
|
||||
{editingConfig.type === 'bot' && (
|
||||
<BotConfigEditor config={editConfig} onChange={setEditConfig} />
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -416,8 +551,7 @@ export function SettingsFanoutSection({
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 px-4 py-3 text-sm text-warning">
|
||||
MQTT support is an experimental feature in open beta. All publishing uses QoS 0
|
||||
(at-most-once delivery).
|
||||
Integrations are an experimental feature in open beta.
|
||||
</div>
|
||||
|
||||
{configs.length === 0 ? (
|
||||
@@ -453,11 +587,11 @@ export function SettingsFanoutSection({
|
||||
|
||||
<div
|
||||
className={cn('w-2 h-2 rounded-full transition-colors', getStatusColor(status))}
|
||||
title={getStatusLabel(status)}
|
||||
title={getStatusLabel(status, cfg.type)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
{cfg.enabled ? getStatusLabel(status) : 'Disabled'}
|
||||
{cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
@@ -480,16 +614,18 @@ export function SettingsFanoutSection({
|
||||
<div className="border border-input rounded-md p-3 space-y-2">
|
||||
<Label>Select integration type:</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
variant={addingType === opt.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleAddCreate(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
{TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
|
||||
(opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
variant={addingType === opt.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleAddCreate(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setAddingType(null)}>
|
||||
Cancel
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
export type SettingsSection =
|
||||
| 'radio'
|
||||
| 'local'
|
||||
| 'database'
|
||||
| 'bot'
|
||||
| 'fanout'
|
||||
| 'statistics'
|
||||
| 'about';
|
||||
export type SettingsSection = 'radio' | 'local' | 'database' | 'fanout' | 'statistics' | 'about';
|
||||
|
||||
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
|
||||
'radio',
|
||||
'local',
|
||||
'database',
|
||||
'bot',
|
||||
'fanout',
|
||||
'statistics',
|
||||
'about',
|
||||
@@ -21,7 +13,6 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
||||
radio: '📻 Radio',
|
||||
local: '🖥️ Local Configuration',
|
||||
database: '🗄️ Database & Messaging',
|
||||
bot: '🤖 Bots',
|
||||
fanout: '📤 Fanout & Forwarding',
|
||||
statistics: '📊 Statistics',
|
||||
about: 'About',
|
||||
|
||||
@@ -236,10 +236,9 @@ describe('SettingsModal', () => {
|
||||
it('renders selected section from external sidebar nav on desktop mode', async () => {
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'bot',
|
||||
desktopSection: 'fanout',
|
||||
});
|
||||
|
||||
expect(screen.getByText('No bots configured')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Local Configuration/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -278,7 +277,7 @@ describe('SettingsModal', () => {
|
||||
<SettingsModal
|
||||
open
|
||||
externalSidebarNav
|
||||
desktopSection="bot"
|
||||
desktopSection="fanout"
|
||||
config={baseConfig}
|
||||
health={baseHealth}
|
||||
appSettings={baseSettings}
|
||||
|
||||
@@ -255,7 +255,6 @@ export interface AppSettingsUpdate {
|
||||
auto_decrypt_dm_on_advert?: boolean;
|
||||
sidebar_sort_order?: 'recent' | 'alpha';
|
||||
advert_interval?: number;
|
||||
bots?: BotConfig[];
|
||||
flood_scope?: string;
|
||||
blocked_keys?: string[];
|
||||
blocked_names?: string[];
|
||||
|
||||
+2
-1
@@ -29,11 +29,12 @@ def cleanup_test_db_dir():
|
||||
async def test_db():
|
||||
"""Create an in-memory test database with schema + migrations."""
|
||||
from app.repository import channels, contacts, messages, raw_packets, settings
|
||||
from app.repository import fanout as fanout_repo
|
||||
|
||||
db = Database(":memory:")
|
||||
await db.connect()
|
||||
|
||||
submodules = [contacts, channels, messages, raw_packets, settings]
|
||||
submodules = [contacts, channels, messages, raw_packets, settings, fanout_repo]
|
||||
originals = [(mod, mod.db) for mod in submodules]
|
||||
|
||||
for mod in submodules:
|
||||
|
||||
@@ -183,13 +183,6 @@ export function markAllRead(): Promise<{ status: string; timestamp: number }> {
|
||||
|
||||
export type Favorite = { type: string; id: string };
|
||||
|
||||
export interface BotConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
favorites: Favorite[];
|
||||
@@ -197,7 +190,6 @@ export interface AppSettings {
|
||||
sidebar_sort_order: string;
|
||||
last_message_times: Record<string, number>;
|
||||
preferences_migrated: boolean;
|
||||
bots: BotConfig[];
|
||||
advert_interval: number;
|
||||
}
|
||||
|
||||
@@ -212,6 +204,50 @@ export function updateSettings(patch: Partial<AppSettings>): Promise<AppSettings
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fanout ---
|
||||
|
||||
export interface FanoutConfig {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export function getFanoutConfigs(): Promise<FanoutConfig[]> {
|
||||
return fetchJson('/fanout');
|
||||
}
|
||||
|
||||
export function createFanoutConfig(body: {
|
||||
type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
scope?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
}): Promise<FanoutConfig> {
|
||||
return fetchJson('/fanout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateFanoutConfig(
|
||||
id: string,
|
||||
patch: Partial<{ name: string; config: Record<string, unknown>; scope: Record<string, unknown>; enabled: boolean }>
|
||||
): Promise<FanoutConfig> {
|
||||
return fetchJson(`/fanout/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteFanoutConfig(id: string): Promise<{ deleted: boolean }> {
|
||||
return fetchJson(`/fanout/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/**
|
||||
|
||||
+24
-20
@@ -1,6 +1,12 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ensureFlightlessChannel, getSettings, updateSettings } from '../helpers/api';
|
||||
import type { BotConfig } from '../helpers/api';
|
||||
import {
|
||||
ensureFlightlessChannel,
|
||||
getFanoutConfigs,
|
||||
createFanoutConfig,
|
||||
deleteFanoutConfig,
|
||||
updateFanoutConfig,
|
||||
} from '../helpers/api';
|
||||
import type { FanoutConfig } from '../helpers/api';
|
||||
|
||||
const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
if channel_name == "#flightless" and "!e2etest" in message_text.lower():
|
||||
@@ -8,45 +14,43 @@ const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_
|
||||
return None`;
|
||||
|
||||
test.describe('Bot functionality', () => {
|
||||
let originalBots: BotConfig[];
|
||||
let createdBotId: string | null = null;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureFlightlessChannel();
|
||||
const settings = await getSettings();
|
||||
originalBots = settings.bots ?? [];
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Restore original bot config
|
||||
try {
|
||||
await updateSettings({ bots: originalBots });
|
||||
} catch {
|
||||
console.warn('Failed to restore bot config');
|
||||
// Clean up the bot we created
|
||||
if (createdBotId) {
|
||||
try {
|
||||
await deleteFanoutConfig(createdBotId);
|
||||
} catch {
|
||||
console.warn('Failed to delete test bot');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('create a bot via API, verify it in UI, trigger it, and verify response', async ({
|
||||
page,
|
||||
}) => {
|
||||
// --- Step 1: Create and enable bot via API ---
|
||||
// CodeMirror is difficult to drive via Playwright (contenteditable, lazy-loaded),
|
||||
// so we set the bot code via the REST API and verify it through the UI.
|
||||
const testBot: BotConfig = {
|
||||
id: crypto.randomUUID(),
|
||||
// --- Step 1: Create and enable bot via fanout API ---
|
||||
const bot = await createFanoutConfig({
|
||||
type: 'bot',
|
||||
name: 'E2E Test Bot',
|
||||
config: { code: BOT_CODE },
|
||||
enabled: true,
|
||||
code: BOT_CODE,
|
||||
};
|
||||
await updateSettings({ bots: [...originalBots, testBot] });
|
||||
});
|
||||
createdBotId = bot.id;
|
||||
|
||||
// --- Step 2: Verify bot appears in settings UI ---
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Connected')).toBeVisible();
|
||||
|
||||
await page.getByText('Settings').click();
|
||||
await page.getByRole('button', { name: /🤖 Bots/ }).click();
|
||||
await page.getByRole('button', { name: /Fanout/ }).click();
|
||||
|
||||
// The bot name should be visible in the bot list
|
||||
// The bot name should be visible in the integration list
|
||||
await expect(page.getByText('E2E Test Bot')).toBeVisible();
|
||||
|
||||
// Exit settings page mode
|
||||
|
||||
+2
-14
@@ -162,16 +162,10 @@ class TestMessagesEndpoint:
|
||||
return_value=MagicMock(type=EventType.MSG_SENT, payload={})
|
||||
)
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with (
|
||||
patch("app.dependencies.radio_manager") as mock_rm,
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.asyncio.create_task", side_effect=_capture_task),
|
||||
patch("app.routers.messages.broadcast_event", create=True) as mock_broadcast,
|
||||
patch("app.routers.messages.broadcast_event") as mock_broadcast,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
@@ -206,17 +200,11 @@ class TestMessagesEndpoint:
|
||||
mock_mc.commands.set_channel = AsyncMock(return_value=ok_result)
|
||||
mock_mc.commands.send_chan_msg = AsyncMock(return_value=ok_result)
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with (
|
||||
patch("app.dependencies.radio_manager") as mock_rm,
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.asyncio.create_task", side_effect=_capture_task),
|
||||
patch("app.routers.messages.broadcast_event", create=True) as mock_broadcast,
|
||||
patch("app.routers.messages.broadcast_event") as mock_broadcast,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
+15
-35
@@ -745,69 +745,49 @@ class TestMultipleBots:
|
||||
|
||||
|
||||
class TestBotCodeValidation:
|
||||
"""Test bot code syntax validation on save."""
|
||||
"""Test bot code syntax validation via fanout router."""
|
||||
|
||||
def test_valid_code_passes(self):
|
||||
"""Valid Python code passes validation."""
|
||||
from app.routers.settings import validate_bot_code
|
||||
from app.routers.fanout import _validate_bot_config
|
||||
|
||||
# Should not raise
|
||||
validate_bot_code("def bot(): return 'hello'")
|
||||
_validate_bot_config({"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
|
||||
from app.routers.fanout import _validate_bot_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_bot_code("def bot(:\n return 'broken'")
|
||||
_validate_bot_config({"code": "def bot(:\n return 'broken'"})
|
||||
|
||||
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."""
|
||||
def test_empty_code_raises(self):
|
||||
"""Empty code raises HTTPException."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.settings import validate_bot_code
|
||||
from app.routers.fanout import _validate_bot_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_bot_code("def bot(:\n return 'broken'", bot_name="My Test Bot")
|
||||
_validate_bot_config({"code": ""})
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "My Test Bot" in exc_info.value.detail
|
||||
assert "empty" 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(" ")
|
||||
|
||||
def test_validate_all_bots(self):
|
||||
"""validate_all_bots validates all bots' code."""
|
||||
def test_missing_code_raises(self):
|
||||
"""Missing code key raises HTTPException."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.settings import validate_all_bots
|
||||
from app.routers.fanout import _validate_bot_config
|
||||
|
||||
# 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)
|
||||
_validate_bot_config({})
|
||||
|
||||
assert "Bad Bot" in exc_info.value.detail
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
class TestBotMessageRateLimiting:
|
||||
|
||||
+24
-26
@@ -2,7 +2,7 @@
|
||||
|
||||
Verifies that when disable_bots=True:
|
||||
- run_bot_for_message() exits immediately without any work
|
||||
- PATCH /api/settings with bots returns 403
|
||||
- POST /api/fanout with type=bot returns 403
|
||||
- Health endpoint includes bots_disabled=True
|
||||
"""
|
||||
|
||||
@@ -14,8 +14,8 @@ from fastapi import HTTPException
|
||||
from app.bot import run_bot_for_message
|
||||
from app.config import Settings
|
||||
from app.models import BotConfig
|
||||
from app.routers.fanout import FanoutConfigCreate, create_fanout_config
|
||||
from app.routers.health import build_health_data
|
||||
from app.routers.settings import AppSettingsUpdate, update_settings
|
||||
|
||||
|
||||
class TestDisableBotsConfig:
|
||||
@@ -78,19 +78,20 @@ class TestDisableBotsBotExecution:
|
||||
mock_exec.assert_called_once()
|
||||
|
||||
|
||||
class TestDisableBotsSettingsEndpoint:
|
||||
"""Test that bot settings updates are rejected when bots are disabled."""
|
||||
class TestDisableBotsFanoutEndpoint:
|
||||
"""Test that bot creation via fanout router is rejected when bots are disabled."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_update_returns_403_when_disabled(self, test_db):
|
||||
"""PATCH /api/settings with bots field returns 403."""
|
||||
with patch("app.routers.settings.server_settings", MagicMock(disable_bots=True)):
|
||||
async def test_bot_create_returns_403_when_disabled(self, test_db):
|
||||
"""POST /api/fanout with type=bot returns 403."""
|
||||
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_settings(
|
||||
AppSettingsUpdate(
|
||||
bots=[
|
||||
BotConfig(id="1", name="Bot", enabled=True, code="def bot(**k): pass")
|
||||
]
|
||||
await create_fanout_config(
|
||||
FanoutConfigCreate(
|
||||
type="bot",
|
||||
name="Test Bot",
|
||||
config={"code": "def bot(**k): pass"},
|
||||
enabled=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -98,22 +99,19 @@ class TestDisableBotsSettingsEndpoint:
|
||||
assert "disabled" in exc_info.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_bot_update_allowed_when_disabled(self, test_db):
|
||||
"""Other settings can still be updated when bots are disabled."""
|
||||
with patch("app.routers.settings.server_settings", MagicMock(disable_bots=True)):
|
||||
result = await update_settings(AppSettingsUpdate(max_radio_contacts=50))
|
||||
assert result.max_radio_contacts == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_update_allowed_when_not_disabled(self, test_db):
|
||||
"""Bot updates work normally when disable_bots is False."""
|
||||
with patch("app.routers.settings.server_settings", MagicMock(disable_bots=False)):
|
||||
result = await update_settings(
|
||||
AppSettingsUpdate(
|
||||
bots=[BotConfig(id="1", name="Bot", enabled=False, code="def bot(**k): pass")]
|
||||
async def test_mqtt_create_allowed_when_bots_disabled(self, test_db):
|
||||
"""Non-bot fanout configs can still be created when bots are disabled."""
|
||||
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
|
||||
# Create as disabled so fanout_manager.reload_config is not called
|
||||
result = await create_fanout_config(
|
||||
FanoutConfigCreate(
|
||||
type="mqtt_private",
|
||||
name="Test MQTT",
|
||||
config={"broker_host": "localhost", "broker_port": 1883},
|
||||
enabled=False,
|
||||
)
|
||||
)
|
||||
assert len(result.bots) == 1
|
||||
assert result["type"] == "mqtt_private"
|
||||
|
||||
|
||||
class TestDisableBotsHealthEndpoint:
|
||||
|
||||
@@ -5,7 +5,7 @@ delivery confirmation, contact message handling, and event registration.
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -217,43 +217,12 @@ class TestContactMessageCLIFiltering:
|
||||
messages = await MessageRepository.get_all()
|
||||
assert len(messages) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_message_schedules_bot_in_background(self, test_db):
|
||||
"""Normal messages should schedule bot execution without blocking."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event"),
|
||||
patch("app.event_handlers.asyncio.create_task", side_effect=_capture_task) as mock_task,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock) as mock_bot,
|
||||
):
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
"pubkey_prefix": "abc123def456",
|
||||
"text": "Hello, bot",
|
||||
"txt_type": 0,
|
||||
"sender_timestamp": 1700000000,
|
||||
}
|
||||
|
||||
await on_contact_message(MockEvent())
|
||||
|
||||
mock_task.assert_called_once()
|
||||
mock_bot.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_message_still_processed(self, test_db):
|
||||
"""Normal messages (txt_type=0) are still processed normally."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
@@ -278,10 +247,7 @@ class TestContactMessageCLIFiltering:
|
||||
"""Broadcast payload should have acked as integer 0, not boolean False."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
@@ -326,10 +292,7 @@ class TestContactMessageCLIFiltering:
|
||||
"sender_name",
|
||||
}
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
@@ -380,10 +343,7 @@ class TestContactMessageCLIFiltering:
|
||||
"""Messages without txt_type field are treated as normal (not filtered)."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event"),
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
with patch("app.event_handlers.broadcast_event"):
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
@@ -422,10 +382,7 @@ class TestContactMessageCLIFiltering:
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
|
||||
@@ -526,3 +526,103 @@ class TestMigration036:
|
||||
assert row[0] == 0
|
||||
finally:
|
||||
await db.disconnect()
|
||||
|
||||
|
||||
async def _setup_db_with_fanout_table():
|
||||
"""Create a DB with app_settings + fanout_configs tables for migration 37 tests."""
|
||||
from app.migrations import _migrate_036_create_fanout_configs
|
||||
|
||||
db = Database(":memory:")
|
||||
await db.connect()
|
||||
|
||||
await db.conn.execute(_create_app_settings_table_sql())
|
||||
await db.conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
|
||||
await db.conn.commit()
|
||||
await _migrate_036_create_fanout_configs(db.conn)
|
||||
return db
|
||||
|
||||
|
||||
class TestMigration037:
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_creates_bot_from_settings(self):
|
||||
"""Migration should create a fanout_configs row for each bot in app_settings."""
|
||||
from app.migrations import _migrate_037_bots_to_fanout
|
||||
|
||||
db = await _setup_db_with_fanout_table()
|
||||
try:
|
||||
bots_json = json.dumps(
|
||||
[
|
||||
{
|
||||
"id": "bot-1",
|
||||
"name": "EchoBot",
|
||||
"enabled": True,
|
||||
"code": "def bot(**k): return 'echo'",
|
||||
},
|
||||
{
|
||||
"id": "bot-2",
|
||||
"name": "Quiet",
|
||||
"enabled": False,
|
||||
"code": "def bot(**k): pass",
|
||||
},
|
||||
]
|
||||
)
|
||||
await db.conn.execute("UPDATE app_settings SET bots = ? WHERE id = 1", (bots_json,))
|
||||
await db.conn.commit()
|
||||
|
||||
await _migrate_037_bots_to_fanout(db.conn)
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM fanout_configs WHERE type = 'bot' ORDER BY sort_order"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
assert len(rows) == 2
|
||||
|
||||
# First bot
|
||||
assert rows[0]["name"] == "EchoBot"
|
||||
assert bool(rows[0]["enabled"])
|
||||
config0 = json.loads(rows[0]["config"])
|
||||
assert config0["code"] == "def bot(**k): return 'echo'"
|
||||
scope0 = json.loads(rows[0]["scope"])
|
||||
assert scope0["messages"] == "all"
|
||||
assert scope0["raw_packets"] == "none"
|
||||
assert rows[0]["sort_order"] == 200
|
||||
|
||||
# Second bot
|
||||
assert rows[1]["name"] == "Quiet"
|
||||
assert not bool(rows[1]["enabled"])
|
||||
assert rows[1]["sort_order"] == 201
|
||||
finally:
|
||||
await db.disconnect()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_skips_when_no_bots(self):
|
||||
"""Migration should not create rows when there are no bots."""
|
||||
from app.migrations import _migrate_037_bots_to_fanout
|
||||
|
||||
db = await _setup_db_with_fanout_table()
|
||||
try:
|
||||
await _migrate_037_bots_to_fanout(db.conn)
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) FROM fanout_configs WHERE type = 'bot'")
|
||||
row = await cursor.fetchone()
|
||||
assert row[0] == 0
|
||||
finally:
|
||||
await db.disconnect()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_handles_empty_bots_array(self):
|
||||
"""Migration handles bots=[] gracefully."""
|
||||
from app.migrations import _migrate_037_bots_to_fanout
|
||||
|
||||
db = await _setup_db_with_fanout_table()
|
||||
try:
|
||||
await db.conn.execute("UPDATE app_settings SET bots = '[]' WHERE id = 1")
|
||||
await db.conn.commit()
|
||||
|
||||
await _migrate_037_bots_to_fanout(db.conn)
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) FROM fanout_configs WHERE type = 'bot'")
|
||||
row = await cursor.fetchone()
|
||||
assert row[0] == 0
|
||||
finally:
|
||||
await db.disconnect()
|
||||
|
||||
+26
-26
@@ -100,8 +100,8 @@ class TestMigration001:
|
||||
# Run migrations
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 36 # All migrations run
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 37 # All migrations run
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# 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 == 36 # All migrations run
|
||||
assert applied1 == 37 # All migrations run
|
||||
assert applied2 == 0 # No migrations on second run
|
||||
assert await get_version(conn) == 36
|
||||
assert await get_version(conn) == 37
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -246,8 +246,8 @@ class TestMigration001:
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
# All migrations applied (version incremented) but no error
|
||||
assert applied == 36
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 37
|
||||
assert await get_version(conn) == 37
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -374,10 +374,10 @@ class TestMigration013:
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
# Run migration 13 (plus 14-36 which also run)
|
||||
# Run migration 13 (plus 14-37 which also run)
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 24
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 25
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify bots array was created with migrated data
|
||||
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
|
||||
@@ -497,7 +497,7 @@ class TestMigration018:
|
||||
assert await cursor.fetchone() is not None
|
||||
|
||||
await run_migrations(conn)
|
||||
assert await get_version(conn) == 36
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify autoindex is gone
|
||||
cursor = await conn.execute(
|
||||
@@ -575,8 +575,8 @@ class TestMigration018:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 19 # Migrations 18-36 run (18+19 skip internally)
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 20 # Migrations 18-37 run (18+19 skip internally)
|
||||
assert await get_version(conn) == 37
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -648,7 +648,7 @@ class TestMigration019:
|
||||
assert await cursor.fetchone() is not None
|
||||
|
||||
await run_migrations(conn)
|
||||
assert await get_version(conn) == 36
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify autoindex is gone
|
||||
cursor = await conn.execute(
|
||||
@@ -714,8 +714,8 @@ class TestMigration020:
|
||||
assert (await cursor.fetchone())[0] == "delete"
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 17 # Migrations 20-36
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 18 # Migrations 20-37
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify WAL mode
|
||||
cursor = await conn.execute("PRAGMA journal_mode")
|
||||
@@ -745,7 +745,7 @@ class TestMigration020:
|
||||
await set_version(conn, 20)
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 16 # Migrations 21-36 still run
|
||||
assert applied == 17 # Migrations 21-37 still run
|
||||
|
||||
# Still WAL + INCREMENTAL
|
||||
cursor = await conn.execute("PRAGMA journal_mode")
|
||||
@@ -803,8 +803,8 @@ class TestMigration028:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 10
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify payload_hash column is now BLOB
|
||||
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
@@ -873,8 +873,8 @@ class TestMigration028:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 9 # Version still bumped
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 10 # Version still bumped
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify data unchanged
|
||||
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
|
||||
@@ -923,8 +923,8 @@ class TestMigration032:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify all columns exist with correct defaults
|
||||
cursor = await conn.execute(
|
||||
@@ -996,8 +996,8 @@ class TestMigration034:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 3
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify column exists with correct default
|
||||
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
|
||||
@@ -1039,8 +1039,8 @@ class TestMigration033:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
cursor = await conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
|
||||
|
||||
@@ -509,40 +509,6 @@ class TestAckPipeline:
|
||||
class TestCreateMessageFromDecrypted:
|
||||
"""Test the shared message creation function used by both real-time and historical decryption."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedules_bot_in_background(self, test_db, captured_broadcasts):
|
||||
"""Bot execution is scheduled and does not block channel message persistence."""
|
||||
from app.packet_processor import create_message_from_decrypted
|
||||
|
||||
packet_id, _ = await RawPacketRepository.create(b"test_packet_bot_channel", 1700000000)
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.packet_processor.broadcast_event", mock_broadcast),
|
||||
patch(
|
||||
"app.packet_processor.asyncio.create_task", side_effect=_capture_task
|
||||
) as mock_task,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock) as mock_bot,
|
||||
):
|
||||
msg_id = await create_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
channel_key="ABC123DEF456",
|
||||
sender="BotTrigger",
|
||||
message_text="Hello from channel",
|
||||
timestamp=1700000000,
|
||||
received_at=1700000001,
|
||||
trigger_bot=True,
|
||||
)
|
||||
|
||||
assert msg_id is not None
|
||||
mock_task.assert_called_once()
|
||||
mock_bot.assert_called_once()
|
||||
assert mock_bot.await_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_message_and_broadcasts(self, test_db, captured_broadcasts):
|
||||
"""create_message_from_decrypted creates message and broadcasts correctly."""
|
||||
@@ -760,48 +726,6 @@ class TestCreateDMMessageFromDecrypted:
|
||||
FACE12_PUB = "FACE123334789E2B81519AFDBC39A3C9EB7EA3457AD367D3243597A484847E46"
|
||||
A1B2C3_PUB = "a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedules_bot_in_background(self, test_db, captured_broadcasts):
|
||||
"""Bot execution is scheduled and does not block DM persistence."""
|
||||
from app.decoder import DecryptedDirectMessage
|
||||
from app.packet_processor import create_dm_message_from_decrypted
|
||||
|
||||
packet_id, _ = await RawPacketRepository.create(b"test_packet_bot_dm", 1700000000)
|
||||
decrypted = DecryptedDirectMessage(
|
||||
timestamp=1700000000,
|
||||
flags=0,
|
||||
message="Hello from DM",
|
||||
dest_hash="fa",
|
||||
src_hash="a1",
|
||||
)
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.packet_processor.broadcast_event", mock_broadcast),
|
||||
patch(
|
||||
"app.packet_processor.asyncio.create_task", side_effect=_capture_task
|
||||
) as mock_task,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock) as mock_bot,
|
||||
):
|
||||
msg_id = await create_dm_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
decrypted=decrypted,
|
||||
their_public_key=self.A1B2C3_PUB,
|
||||
our_public_key=self.FACE12_PUB,
|
||||
received_at=1700000001,
|
||||
outgoing=False,
|
||||
trigger_bot=True,
|
||||
)
|
||||
|
||||
assert msg_id is not None
|
||||
mock_task.assert_called_once()
|
||||
mock_bot.assert_called_once()
|
||||
assert mock_bot.await_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_dm_message_and_broadcasts(self, test_db, captured_broadcasts):
|
||||
"""create_dm_message_from_decrypted creates message and broadcasts correctly."""
|
||||
|
||||
+36
-118
@@ -1,4 +1,4 @@
|
||||
"""Tests for bot triggering on outgoing messages sent via the messages router."""
|
||||
"""Tests for outgoing message sending via the messages router."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
@@ -76,77 +76,36 @@ async def _insert_contact(public_key, name="Alice"):
|
||||
)
|
||||
|
||||
|
||||
class TestOutgoingDMBotTrigger:
|
||||
"""Test that sending a DM triggers bots with is_outgoing=True."""
|
||||
class TestOutgoingDMBroadcast:
|
||||
"""Test that outgoing DMs are broadcast via broadcast_event for fanout dispatch."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_triggers_bot(self, test_db):
|
||||
"""Sending a DM creates a background task to run bots."""
|
||||
async def test_send_dm_broadcasts_outgoing(self, test_db):
|
||||
"""Sending a DM broadcasts the message with outgoing=True for fanout dispatch."""
|
||||
mc = _make_mc()
|
||||
pub_key = "ab" * 32
|
||||
await _insert_contact(pub_key, "Alice")
|
||||
|
||||
broadcasts = []
|
||||
|
||||
def capture_broadcast(event_type, data):
|
||||
broadcasts.append({"type": event_type, "data": data})
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
|
||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||
):
|
||||
request = SendDirectMessageRequest(destination=pub_key, text="!lasttime Alice")
|
||||
await send_direct_message(request)
|
||||
|
||||
# Let the background task run
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_bot.assert_called_once()
|
||||
call_kwargs = mock_bot.call_args[1]
|
||||
assert call_kwargs["message_text"] == "!lasttime Alice"
|
||||
assert call_kwargs["is_dm"] is True
|
||||
assert call_kwargs["is_outgoing"] is True
|
||||
assert call_kwargs["sender_key"] == pub_key
|
||||
assert call_kwargs["channel_key"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_bot_does_not_block_response(self, test_db):
|
||||
"""Bot trigger runs in background and doesn't delay the message response."""
|
||||
mc = _make_mc()
|
||||
pub_key = "ab" * 32
|
||||
await _insert_contact(pub_key, "Alice")
|
||||
|
||||
# Bot that would take a long time
|
||||
async def _slow(**kw):
|
||||
await asyncio.sleep(10)
|
||||
|
||||
slow_bot = AsyncMock(side_effect=_slow)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.bot.run_bot_for_message", new=slow_bot),
|
||||
):
|
||||
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
|
||||
# This should return immediately, not wait 10 seconds
|
||||
message = await send_direct_message(request)
|
||||
assert message.text == "Hello"
|
||||
assert message.outgoing is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_passes_no_sender_name(self, test_db):
|
||||
"""Outgoing DMs pass sender_name=None (we are the sender)."""
|
||||
mc = _make_mc()
|
||||
pub_key = "cd" * 32
|
||||
await _insert_contact(pub_key, "Bob")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
|
||||
):
|
||||
request = SendDirectMessageRequest(destination=pub_key, text="test")
|
||||
await send_direct_message(request)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
call_kwargs = mock_bot.call_args[1]
|
||||
assert call_kwargs["sender_name"] is None
|
||||
msg_broadcasts = [b for b in broadcasts if b["type"] == "message"]
|
||||
assert len(msg_broadcasts) == 1
|
||||
data = msg_broadcasts[0]["data"]
|
||||
assert data["text"] == "!lasttime Alice"
|
||||
assert data["outgoing"] is True
|
||||
assert data["type"] == "PRIV"
|
||||
assert data["conversation_key"] == pub_key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_ambiguous_prefix_returns_409(self, test_db):
|
||||
@@ -167,77 +126,37 @@ class TestOutgoingDMBotTrigger:
|
||||
assert "ambiguous" in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
class TestOutgoingChannelBotTrigger:
|
||||
"""Test that sending a channel message triggers bots with is_outgoing=True."""
|
||||
class TestOutgoingChannelBroadcast:
|
||||
"""Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_msg_triggers_bot(self, test_db):
|
||||
"""Sending a channel message creates a background task to run bots."""
|
||||
async def test_send_channel_msg_broadcasts_outgoing(self, test_db):
|
||||
"""Sending a channel message broadcasts with outgoing=True for fanout dispatch."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "aa" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#general")
|
||||
|
||||
broadcasts = []
|
||||
|
||||
def capture_broadcast(event_type, data):
|
||||
broadcasts.append({"type": event_type, "data": data})
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
|
||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||
):
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="!lasttime5 someone")
|
||||
await send_channel_message(request)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_bot.assert_called_once()
|
||||
call_kwargs = mock_bot.call_args[1]
|
||||
assert call_kwargs["message_text"] == "!lasttime5 someone"
|
||||
assert call_kwargs["is_dm"] is False
|
||||
assert call_kwargs["is_outgoing"] is True
|
||||
assert call_kwargs["channel_key"] == chan_key.upper()
|
||||
assert call_kwargs["channel_name"] == "#general"
|
||||
assert call_kwargs["sender_name"] == "MyNode"
|
||||
assert call_kwargs["sender_key"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_msg_no_radio_name(self, test_db):
|
||||
"""When radio has no name, sender_name is None."""
|
||||
mc = _make_mc(name="")
|
||||
chan_key = "bb" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#test")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
|
||||
):
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
|
||||
await send_channel_message(request)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
call_kwargs = mock_bot.call_args[1]
|
||||
assert call_kwargs["sender_name"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_msg_bot_does_not_block_response(self, test_db):
|
||||
"""Bot trigger runs in background and doesn't delay the message response."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "cc" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#slow")
|
||||
|
||||
async def _slow(**kw):
|
||||
await asyncio.sleep(10)
|
||||
|
||||
slow_bot = AsyncMock(side_effect=_slow)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=slow_bot),
|
||||
):
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="test")
|
||||
message = await send_channel_message(request)
|
||||
assert message.outgoing is True
|
||||
msg_broadcasts = [b for b in broadcasts if b["type"] == "message"]
|
||||
assert len(msg_broadcasts) == 1
|
||||
data = msg_broadcasts[0]["data"]
|
||||
assert data["outgoing"] is True
|
||||
assert data["type"] == "CHAN"
|
||||
assert data["conversation_key"] == chan_key.upper()
|
||||
assert data["sender_name"] == "MyNode"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_msg_response_includes_current_ack_count(self, test_db):
|
||||
@@ -250,7 +169,7 @@ class TestOutgoingChannelBotTrigger:
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="acked now")
|
||||
message = await send_channel_message(request)
|
||||
@@ -277,7 +196,6 @@ class TestOutgoingChannelBotTrigger:
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||
):
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import AppSettings, BotConfig
|
||||
from app.models import AppSettings
|
||||
from app.repository import AppSettingsRepository
|
||||
from app.routers.settings import (
|
||||
AppSettingsUpdate,
|
||||
@@ -53,21 +52,6 @@ class TestUpdateSettings:
|
||||
assert isinstance(result, AppSettings)
|
||||
assert result.max_radio_contacts == 200 # default
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_bot_syntax_returns_400(self):
|
||||
bad_bot = BotConfig(
|
||||
id="bot-1",
|
||||
name="BadBot",
|
||||
enabled=True,
|
||||
code="def bot(:\n return 'x'\n",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_settings(AppSettingsUpdate(bots=[bad_bot]))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "syntax error" in exc.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flood_scope_round_trip(self, test_db):
|
||||
"""Flood scope should be saved and retrieved correctly."""
|
||||
|
||||
Reference in New Issue
Block a user