mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-12 03:33:03 +02:00
Add webhooks & reformat a bit
This commit is contained in:
+24
-14
@@ -20,10 +20,32 @@ def _register_module_types() -> None:
|
|||||||
from app.fanout.bot import BotModule
|
from app.fanout.bot import BotModule
|
||||||
from app.fanout.mqtt_community import MqttCommunityModule
|
from app.fanout.mqtt_community import MqttCommunityModule
|
||||||
from app.fanout.mqtt_private import MqttPrivateModule
|
from app.fanout.mqtt_private import MqttPrivateModule
|
||||||
|
from app.fanout.webhook import WebhookModule
|
||||||
|
|
||||||
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
|
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
|
||||||
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
|
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
|
||||||
_MODULE_TYPES["bot"] = BotModule
|
_MODULE_TYPES["bot"] = BotModule
|
||||||
|
_MODULE_TYPES["webhook"] = WebhookModule
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_filter(filter_value: Any, key: str) -> bool:
|
||||||
|
"""Check a single filter value (channels or contacts) against a key.
|
||||||
|
|
||||||
|
Supported shapes:
|
||||||
|
"all" -> True
|
||||||
|
"none" -> False
|
||||||
|
["key1", "key2"] -> key in list (only listed)
|
||||||
|
{"except": ["key1", "key2"]} -> key not in list (all except listed)
|
||||||
|
"""
|
||||||
|
if filter_value == "all":
|
||||||
|
return True
|
||||||
|
if filter_value == "none":
|
||||||
|
return False
|
||||||
|
if isinstance(filter_value, list):
|
||||||
|
return key in filter_value
|
||||||
|
if isinstance(filter_value, dict) and "except" in filter_value:
|
||||||
|
return key not in filter_value["except"]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _scope_matches_message(scope: dict, data: dict) -> bool:
|
def _scope_matches_message(scope: dict, data: dict) -> bool:
|
||||||
@@ -37,21 +59,9 @@ def _scope_matches_message(scope: dict, data: dict) -> bool:
|
|||||||
msg_type = data.get("type", "")
|
msg_type = data.get("type", "")
|
||||||
conversation_key = data.get("conversation_key", "")
|
conversation_key = data.get("conversation_key", "")
|
||||||
if msg_type == "CHAN":
|
if msg_type == "CHAN":
|
||||||
channels = messages.get("channels", "none")
|
return _matches_filter(messages.get("channels", "none"), conversation_key)
|
||||||
if channels == "all":
|
|
||||||
return True
|
|
||||||
if channels == "none":
|
|
||||||
return False
|
|
||||||
if isinstance(channels, list):
|
|
||||||
return conversation_key in channels
|
|
||||||
elif msg_type == "PRIV":
|
elif msg_type == "PRIV":
|
||||||
contacts = messages.get("contacts", "none")
|
return _matches_filter(messages.get("contacts", "none"), conversation_key)
|
||||||
if contacts == "all":
|
|
||||||
return True
|
|
||||||
if contacts == "none":
|
|
||||||
return False
|
|
||||||
if isinstance(contacts, list):
|
|
||||||
return conversation_key in contacts
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Fanout module for webhook (HTTP POST) delivery."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.fanout.base import FanoutModule
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookModule(FanoutModule):
|
||||||
|
"""Delivers message data to an HTTP endpoint via POST (or configurable method)."""
|
||||||
|
|
||||||
|
def __init__(self, config_id: str, config: dict) -> None:
|
||||||
|
super().__init__(config_id, config)
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
self._last_error: str | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0))
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
async def on_message(self, data: dict) -> None:
|
||||||
|
await self._send(data, event_type="message")
|
||||||
|
|
||||||
|
async def on_raw(self, data: dict) -> None:
|
||||||
|
await self._send(data, event_type="raw_packet")
|
||||||
|
|
||||||
|
async def _send(self, data: dict, *, event_type: str) -> None:
|
||||||
|
if not self._client:
|
||||||
|
return
|
||||||
|
|
||||||
|
url = self.config.get("url", "")
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
|
||||||
|
method = self.config.get("method", "POST").upper()
|
||||||
|
extra_headers = self.config.get("headers", {})
|
||||||
|
secret = self.config.get("secret", "")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Webhook-Event": event_type,
|
||||||
|
**extra_headers,
|
||||||
|
}
|
||||||
|
if secret:
|
||||||
|
headers["X-Webhook-Secret"] = secret
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self._client.request(method, url, json=data, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
self._last_error = None
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
self._last_error = f"HTTP {exc.response.status_code}"
|
||||||
|
logger.warning(
|
||||||
|
"Webhook %s returned %s for %s",
|
||||||
|
self.config_id,
|
||||||
|
exc.response.status_code,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
self._last_error = str(exc)
|
||||||
|
logger.warning("Webhook %s request error: %s", self.config_id, exc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
if not self.config.get("url"):
|
||||||
|
return "disconnected"
|
||||||
|
if self._last_error:
|
||||||
|
return "error"
|
||||||
|
return "connected"
|
||||||
+25
-1
@@ -12,7 +12,7 @@ from app.repository.fanout import FanoutConfigRepository
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
||||||
|
|
||||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot"}
|
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook"}
|
||||||
|
|
||||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||||
|
|
||||||
@@ -65,12 +65,32 @@ def _validate_bot_config(config: dict) -> None:
|
|||||||
) from None
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_webhook_config(config: dict) -> None:
|
||||||
|
"""Validate webhook config blob."""
|
||||||
|
url = config.get("url", "")
|
||||||
|
if not url:
|
||||||
|
raise HTTPException(status_code=400, detail="url is required for webhook")
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
raise HTTPException(status_code=400, detail="url must start with http:// or https://")
|
||||||
|
method = config.get("method", "POST").upper()
|
||||||
|
if method not in ("POST", "PUT", "PATCH"):
|
||||||
|
raise HTTPException(status_code=400, detail="method must be POST, PUT, or PATCH")
|
||||||
|
headers = config.get("headers", {})
|
||||||
|
if not isinstance(headers, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="headers must be a JSON object")
|
||||||
|
|
||||||
|
|
||||||
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||||
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
||||||
if config_type == "mqtt_community":
|
if config_type == "mqtt_community":
|
||||||
return {"messages": "none", "raw_packets": "all"}
|
return {"messages": "none", "raw_packets": "all"}
|
||||||
if config_type == "bot":
|
if config_type == "bot":
|
||||||
return {"messages": "all", "raw_packets": "none"}
|
return {"messages": "all", "raw_packets": "none"}
|
||||||
|
if config_type == "webhook":
|
||||||
|
messages = scope.get("messages", "all")
|
||||||
|
if messages not in ("all", "none") and not isinstance(messages, dict):
|
||||||
|
messages = "all"
|
||||||
|
return {"messages": messages, "raw_packets": "none"}
|
||||||
# For mqtt_private, validate scope values
|
# For mqtt_private, validate scope values
|
||||||
messages = scope.get("messages", "all")
|
messages = scope.get("messages", "all")
|
||||||
if messages not in ("all", "none") and not isinstance(messages, dict):
|
if messages not in ("all", "none") and not isinstance(messages, dict):
|
||||||
@@ -108,6 +128,8 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
|||||||
_validate_mqtt_community_config(body.config)
|
_validate_mqtt_community_config(body.config)
|
||||||
elif body.type == "bot":
|
elif body.type == "bot":
|
||||||
_validate_bot_config(body.config)
|
_validate_bot_config(body.config)
|
||||||
|
elif body.type == "webhook":
|
||||||
|
_validate_webhook_config(body.config)
|
||||||
|
|
||||||
scope = _enforce_scope(body.type, body.scope)
|
scope = _enforce_scope(body.type, body.scope)
|
||||||
|
|
||||||
@@ -156,6 +178,8 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
|||||||
_validate_mqtt_community_config(config_to_validate)
|
_validate_mqtt_community_config(config_to_validate)
|
||||||
elif existing["type"] == "bot":
|
elif existing["type"] == "bot":
|
||||||
_validate_bot_config(config_to_validate)
|
_validate_bot_config(config_to_validate)
|
||||||
|
elif existing["type"] == "webhook":
|
||||||
|
_validate_webhook_config(config_to_validate)
|
||||||
|
|
||||||
updated = await FanoutConfigRepository.update(config_id, **kwargs)
|
updated = await FanoutConfigRepository.update(config_id, **kwargs)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Separator } from '../ui/separator';
|
|||||||
import { toast } from '../ui/sonner';
|
import { toast } from '../ui/sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { api } from '../../api';
|
import { api } from '../../api';
|
||||||
import type { FanoutConfig, HealthStatus } from '../../types';
|
import type { Channel, Contact, FanoutConfig, HealthStatus } from '../../types';
|
||||||
|
|
||||||
const BotCodeEditor = lazy(() =>
|
const BotCodeEditor = lazy(() =>
|
||||||
import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
|
import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
|
||||||
@@ -16,12 +16,14 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
mqtt_private: 'Private MQTT',
|
mqtt_private: 'Private MQTT',
|
||||||
mqtt_community: 'Community MQTT',
|
mqtt_community: 'Community MQTT',
|
||||||
bot: 'Bot',
|
bot: 'Bot',
|
||||||
|
webhook: 'Webhook',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'mqtt_private', label: 'Private MQTT' },
|
{ value: 'mqtt_private', label: 'Private MQTT' },
|
||||||
{ value: 'mqtt_community', label: 'Community MQTT' },
|
{ value: 'mqtt_community', label: 'Community MQTT' },
|
||||||
{ value: 'bot', label: 'Bot' },
|
{ value: 'bot', label: 'Bot' },
|
||||||
|
{ value: 'webhook', label: 'Webhook' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_BOT_CODE = `def bot(
|
const DEFAULT_BOT_CODE = `def bot(
|
||||||
@@ -62,18 +64,20 @@ const DEFAULT_BOT_CODE = `def bot(
|
|||||||
return "[BOT] Plong!"
|
return "[BOT] Plong!"
|
||||||
return None`;
|
return None`;
|
||||||
|
|
||||||
|
function getStatusLabel(status: string | undefined, type?: string) {
|
||||||
|
if (status === 'connected') return type === 'bot' || type === 'webhook' ? 'Active' : 'Connected';
|
||||||
|
if (status === 'error') return 'Error';
|
||||||
|
if (status === 'disconnected') return 'Disconnected';
|
||||||
|
return 'Inactive';
|
||||||
|
}
|
||||||
|
|
||||||
function getStatusColor(status: string | undefined) {
|
function getStatusColor(status: string | undefined) {
|
||||||
if (status === 'connected')
|
if (status === 'connected')
|
||||||
return 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]';
|
return 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]';
|
||||||
|
if (status === 'error') return 'bg-destructive shadow-[0_0_6px_hsl(var(--destructive)/0.5)]';
|
||||||
return 'bg-muted-foreground';
|
return 'bg-muted-foreground';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusLabel(status: string | undefined, type?: string) {
|
|
||||||
if (status === 'connected') return type === 'bot' ? 'Active' : 'Connected';
|
|
||||||
if (status === 'disconnected') return 'Disconnected';
|
|
||||||
return 'Inactive';
|
|
||||||
}
|
|
||||||
|
|
||||||
function MqttPrivateConfigEditor({
|
function MqttPrivateConfigEditor({
|
||||||
config,
|
config,
|
||||||
scope,
|
scope,
|
||||||
@@ -358,6 +362,364 @@ function BotConfigEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScopeMode = 'all' | 'none' | 'only' | 'except';
|
||||||
|
|
||||||
|
function getScopeMode(value: unknown): ScopeMode {
|
||||||
|
if (value === 'all') return 'all';
|
||||||
|
if (value === 'none') return 'none';
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
// Check if either channels or contacts uses the {except: [...]} shape
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const ch = obj.channels;
|
||||||
|
const co = obj.contacts;
|
||||||
|
if (
|
||||||
|
(typeof ch === 'object' && ch !== null && !Array.isArray(ch)) ||
|
||||||
|
(typeof co === 'object' && co !== null && !Array.isArray(co))
|
||||||
|
) {
|
||||||
|
return 'except';
|
||||||
|
}
|
||||||
|
return 'only';
|
||||||
|
}
|
||||||
|
return 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the key list from a filter value, whether it's a plain list or {except: [...]} */
|
||||||
|
function getFilterKeys(filter: unknown): string[] {
|
||||||
|
if (Array.isArray(filter)) return filter as string[];
|
||||||
|
if (typeof filter === 'object' && filter !== null && 'except' in filter)
|
||||||
|
return ((filter as Record<string, unknown>).except as string[]) ?? [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeSelector({
|
||||||
|
scope,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
scope: Record<string, unknown>;
|
||||||
|
onChange: (scope: Record<string, unknown>) => void;
|
||||||
|
}) {
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getChannels().then(setChannels).catch(console.error);
|
||||||
|
|
||||||
|
// Paginate to fetch all contacts (API caps at 1000 per request)
|
||||||
|
(async () => {
|
||||||
|
const all: Contact[] = [];
|
||||||
|
const pageSize = 1000;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const page = await api.getContacts(pageSize, offset);
|
||||||
|
all.push(...page);
|
||||||
|
if (page.length < pageSize) break;
|
||||||
|
offset += pageSize;
|
||||||
|
}
|
||||||
|
setContacts(all);
|
||||||
|
})().catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const messages = scope.messages ?? 'all';
|
||||||
|
const mode = getScopeMode(messages);
|
||||||
|
const isListMode = mode === 'only' || mode === 'except';
|
||||||
|
|
||||||
|
const selectedChannels: string[] =
|
||||||
|
isListMode && typeof messages === 'object' && messages !== null
|
||||||
|
? getFilterKeys((messages as Record<string, unknown>).channels)
|
||||||
|
: [];
|
||||||
|
const selectedContacts: string[] =
|
||||||
|
isListMode && typeof messages === 'object' && messages !== null
|
||||||
|
? getFilterKeys((messages as Record<string, unknown>).contacts)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/** Wrap channel/contact key lists in the right shape for the current mode */
|
||||||
|
const buildMessages = (chKeys: string[], coKeys: string[]) => {
|
||||||
|
if (mode === 'except') {
|
||||||
|
return {
|
||||||
|
channels: { except: chKeys },
|
||||||
|
contacts: { except: coKeys },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { channels: chKeys, contacts: coKeys };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModeChange = (newMode: ScopeMode) => {
|
||||||
|
if (newMode === 'all' || newMode === 'none') {
|
||||||
|
onChange({ ...scope, messages: newMode });
|
||||||
|
} else if (newMode === 'only') {
|
||||||
|
onChange({ ...scope, messages: { channels: [], contacts: [] } });
|
||||||
|
} else {
|
||||||
|
onChange({
|
||||||
|
...scope,
|
||||||
|
messages: { channels: { except: [] }, contacts: { except: [] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleChannel = (key: string) => {
|
||||||
|
const current = [...selectedChannels];
|
||||||
|
const idx = current.indexOf(key);
|
||||||
|
if (idx >= 0) current.splice(idx, 1);
|
||||||
|
else current.push(key);
|
||||||
|
onChange({ ...scope, messages: buildMessages(current, selectedContacts) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleContact = (key: string) => {
|
||||||
|
const current = [...selectedContacts];
|
||||||
|
const idx = current.indexOf(key);
|
||||||
|
if (idx >= 0) current.splice(idx, 1);
|
||||||
|
else current.push(key);
|
||||||
|
onChange({ ...scope, messages: buildMessages(selectedChannels, current) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Non-repeater contacts only (type 0)
|
||||||
|
const filteredContacts = contacts.filter((c) => c.type === 0);
|
||||||
|
|
||||||
|
const modeDescriptions: Record<ScopeMode, string> = {
|
||||||
|
all: 'All messages',
|
||||||
|
none: 'No messages',
|
||||||
|
only: 'Only listed channels/contacts',
|
||||||
|
except: 'All except listed channels/contacts',
|
||||||
|
};
|
||||||
|
|
||||||
|
// For "except" mode, checked means the item is in the exclusion list (will be excluded)
|
||||||
|
const isChannelChecked = (key: string) =>
|
||||||
|
mode === 'except' ? selectedChannels.includes(key) : selectedChannels.includes(key);
|
||||||
|
const isContactChecked = (key: string) =>
|
||||||
|
mode === 'except' ? selectedContacts.includes(key) : selectedContacts.includes(key);
|
||||||
|
|
||||||
|
const listHint =
|
||||||
|
mode === 'only'
|
||||||
|
? 'Newly added channels or contacts will not be automatically included.'
|
||||||
|
: 'Newly added channels or contacts will be automatically included unless excluded here.';
|
||||||
|
|
||||||
|
const checkboxLabel = mode === 'except' ? 'exclude' : 'include';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Message Scope</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(['all', 'none', 'only', 'except'] as const).map((m) => (
|
||||||
|
<label key={m} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="scope-mode"
|
||||||
|
checked={mode === m}
|
||||||
|
onChange={() => handleModeChange(m)}
|
||||||
|
className="h-4 w-4 accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{modeDescriptions[m]}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isListMode && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-muted-foreground">{listHint}</p>
|
||||||
|
|
||||||
|
{channels.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">
|
||||||
|
Channels{' '}
|
||||||
|
<span className="text-muted-foreground font-normal">({checkboxLabel})</span>
|
||||||
|
</Label>
|
||||||
|
<span className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...scope,
|
||||||
|
messages: buildMessages(
|
||||||
|
channels.map((ch) => ch.key),
|
||||||
|
selectedContacts
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground">/</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({ ...scope, messages: buildMessages([], selectedContacts) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-32 overflow-y-auto border border-input rounded-md p-2 space-y-1">
|
||||||
|
{channels.map((ch) => (
|
||||||
|
<label key={ch.key} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChannelChecked(ch.key)}
|
||||||
|
onChange={() => toggleChannel(ch.key)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm truncate">{ch.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredContacts.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">
|
||||||
|
Contacts{' '}
|
||||||
|
<span className="text-muted-foreground font-normal">({checkboxLabel})</span>
|
||||||
|
</Label>
|
||||||
|
<span className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...scope,
|
||||||
|
messages: buildMessages(
|
||||||
|
selectedChannels,
|
||||||
|
filteredContacts.map((c) => c.public_key)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground">/</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({ ...scope, messages: buildMessages(selectedChannels, []) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-32 overflow-y-auto border border-input rounded-md p-2 space-y-1">
|
||||||
|
{filteredContacts.map((c) => (
|
||||||
|
<label key={c.public_key} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isContactChecked(c.public_key)}
|
||||||
|
onChange={() => toggleContact(c.public_key)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm truncate">
|
||||||
|
{c.name || c.public_key.substring(0, 12) + '...'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebhookConfigEditor({
|
||||||
|
config,
|
||||||
|
scope,
|
||||||
|
onChange,
|
||||||
|
onScopeChange,
|
||||||
|
}: {
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
scope: Record<string, unknown>;
|
||||||
|
onChange: (config: Record<string, unknown>) => void;
|
||||||
|
onScopeChange: (scope: Record<string, unknown>) => void;
|
||||||
|
}) {
|
||||||
|
const headersStr = JSON.stringify(config.headers ?? {}, null, 2);
|
||||||
|
const [headersText, setHeadersText] = useState(headersStr);
|
||||||
|
const [headersError, setHeadersError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleHeadersChange = (text: string) => {
|
||||||
|
setHeadersText(text);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
setHeadersError('Must be a JSON object');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHeadersError(null);
|
||||||
|
onChange({ ...config, headers: parsed });
|
||||||
|
} catch {
|
||||||
|
setHeadersError('Invalid JSON');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Send message data as JSON to an HTTP endpoint when messages are received.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fanout-webhook-url">URL</Label>
|
||||||
|
<Input
|
||||||
|
id="fanout-webhook-url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/webhook"
|
||||||
|
value={(config.url as string) || ''}
|
||||||
|
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fanout-webhook-method">HTTP Method</Label>
|
||||||
|
<select
|
||||||
|
id="fanout-webhook-method"
|
||||||
|
value={(config.method as string) || 'POST'}
|
||||||
|
onChange={(e) => onChange({ ...config, method: e.target.value })}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="POST">POST</option>
|
||||||
|
<option value="PUT">PUT</option>
|
||||||
|
<option value="PATCH">PATCH</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fanout-webhook-secret">Secret (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="fanout-webhook-secret"
|
||||||
|
type="password"
|
||||||
|
placeholder="Sent as X-Webhook-Secret header"
|
||||||
|
value={(config.secret as string) || ''}
|
||||||
|
onChange={(e) => onChange({ ...config, secret: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fanout-webhook-headers">Extra Headers (JSON)</Label>
|
||||||
|
<textarea
|
||||||
|
id="fanout-webhook-headers"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[60px]"
|
||||||
|
value={headersText}
|
||||||
|
onChange={(e) => handleHeadersChange(e.target.value)}
|
||||||
|
placeholder='{"Authorization": "Bearer ..."}'
|
||||||
|
/>
|
||||||
|
{headersError && <p className="text-xs text-destructive">{headersError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<ScopeSelector scope={scope} onChange={onScopeChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsFanoutSection({
|
export function SettingsFanoutSection({
|
||||||
health,
|
health,
|
||||||
onHealthRefresh,
|
onHealthRefresh,
|
||||||
@@ -373,7 +735,6 @@ export function SettingsFanoutSection({
|
|||||||
const [editScope, setEditScope] = useState<Record<string, unknown>>({});
|
const [editScope, setEditScope] = useState<Record<string, unknown>>({});
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [addingType, setAddingType] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadConfigs = useCallback(async () => {
|
const loadConfigs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -438,10 +799,6 @@ export function SettingsFanoutSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddStart = (type: string) => {
|
|
||||||
setAddingType(type);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddCreate = async (type: string) => {
|
const handleAddCreate = async (type: string) => {
|
||||||
const defaults: Record<string, Record<string, unknown>> = {
|
const defaults: Record<string, Record<string, unknown>> = {
|
||||||
mqtt_private: {
|
mqtt_private: {
|
||||||
@@ -462,11 +819,18 @@ export function SettingsFanoutSection({
|
|||||||
bot: {
|
bot: {
|
||||||
code: DEFAULT_BOT_CODE,
|
code: DEFAULT_BOT_CODE,
|
||||||
},
|
},
|
||||||
|
webhook: {
|
||||||
|
url: '',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {},
|
||||||
|
secret: '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const defaultScopes: Record<string, Record<string, unknown>> = {
|
const defaultScopes: Record<string, Record<string, unknown>> = {
|
||||||
mqtt_private: { messages: 'all', raw_packets: 'all' },
|
mqtt_private: { messages: 'all', raw_packets: 'all' },
|
||||||
mqtt_community: { messages: 'none', raw_packets: 'all' },
|
mqtt_community: { messages: 'none', raw_packets: 'all' },
|
||||||
bot: { messages: 'all', raw_packets: 'none' },
|
bot: { messages: 'all', raw_packets: 'none' },
|
||||||
|
webhook: { messages: 'all', raw_packets: 'none' },
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -478,7 +842,6 @@ export function SettingsFanoutSection({
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
await loadConfigs();
|
await loadConfigs();
|
||||||
setAddingType(null);
|
|
||||||
handleEdit(created);
|
handleEdit(created);
|
||||||
toast.success('Integration created');
|
toast.success('Integration created');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -533,6 +896,15 @@ export function SettingsFanoutSection({
|
|||||||
<BotConfigEditor config={editConfig} onChange={setEditConfig} />
|
<BotConfigEditor config={editConfig} onChange={setEditConfig} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingConfig.type === 'webhook' && (
|
||||||
|
<WebhookConfigEditor
|
||||||
|
config={editConfig}
|
||||||
|
scope={editScope}
|
||||||
|
onChange={setEditConfig}
|
||||||
|
onScopeChange={setEditScope}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -554,11 +926,21 @@ export function SettingsFanoutSection({
|
|||||||
Integrations are an experimental feature in open beta.
|
Integrations are an experimental feature in open beta.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{configs.length === 0 ? (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="text-center py-8 border border-dashed border-input rounded-md">
|
<span className="text-sm text-muted-foreground">Add:</span>
|
||||||
<p className="text-muted-foreground mb-4">No integrations configured</p>
|
{TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map((opt) => (
|
||||||
</div>
|
<Button
|
||||||
) : (
|
key={opt.value}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddCreate(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{configs.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{configs.map((cfg) => {
|
{configs.map((cfg) => {
|
||||||
const statusEntry = health?.fanout_statuses?.[cfg.id];
|
const statusEntry = health?.fanout_statuses?.[cfg.id];
|
||||||
@@ -609,33 +991,6 @@ export function SettingsFanoutSection({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{addingType ? (
|
|
||||||
<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.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
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button variant="outline" onClick={() => handleAddStart('mqtt_private')} className="w-full">
|
|
||||||
+ Add Integration
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
|||||||
radio: '📻 Radio',
|
radio: '📻 Radio',
|
||||||
local: '🖥️ Local Configuration',
|
local: '🖥️ Local Configuration',
|
||||||
database: '🗄️ Database & Messaging',
|
database: '🗄️ Database & Messaging',
|
||||||
fanout: '📤 Fanout & Forwarding',
|
fanout: '📤 MQTT & Forwarding',
|
||||||
statistics: '📊 Statistics',
|
statistics: '📊 Statistics',
|
||||||
about: 'About',
|
about: 'About',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ class TestScopeMatchesMessage:
|
|||||||
scope = {"messages": {"contacts": ["pk1"]}}
|
scope = {"messages": {"contacts": ["pk1"]}}
|
||||||
assert not _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk2"})
|
assert not _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk2"})
|
||||||
|
|
||||||
|
def test_dict_channels_except_excludes_listed(self):
|
||||||
|
scope = {"messages": {"channels": {"except": ["ch1"]}, "contacts": "all"}}
|
||||||
|
assert not _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
|
||||||
|
|
||||||
|
def test_dict_channels_except_includes_unlisted(self):
|
||||||
|
scope = {"messages": {"channels": {"except": ["ch1"]}, "contacts": "all"}}
|
||||||
|
assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch2"})
|
||||||
|
|
||||||
|
def test_dict_contacts_except_excludes_listed(self):
|
||||||
|
scope = {"messages": {"channels": "all", "contacts": {"except": ["pk1"]}}}
|
||||||
|
assert not _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk1"})
|
||||||
|
|
||||||
|
def test_dict_contacts_except_includes_unlisted(self):
|
||||||
|
scope = {"messages": {"channels": "all", "contacts": {"except": ["pk1"]}}}
|
||||||
|
assert _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk2"})
|
||||||
|
|
||||||
|
def test_dict_channels_except_empty_matches_all(self):
|
||||||
|
scope = {"messages": {"channels": {"except": []}}}
|
||||||
|
assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
|
||||||
|
|
||||||
|
|
||||||
class TestScopeMatchesRaw:
|
class TestScopeMatchesRaw:
|
||||||
def test_all_matches(self):
|
def test_all_matches(self):
|
||||||
@@ -626,3 +646,106 @@ class TestMigration037:
|
|||||||
assert row[0] == 0
|
assert row[0] == 0
|
||||||
finally:
|
finally:
|
||||||
await db.disconnect()
|
await db.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Webhook module unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebhookModule:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_disconnected_when_no_url(self):
|
||||||
|
from app.fanout.webhook import WebhookModule
|
||||||
|
|
||||||
|
mod = WebhookModule("test", {"url": ""})
|
||||||
|
await mod.start()
|
||||||
|
assert mod.status == "disconnected"
|
||||||
|
await mod.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_connected_with_url(self):
|
||||||
|
from app.fanout.webhook import WebhookModule
|
||||||
|
|
||||||
|
mod = WebhookModule("test", {"url": "http://localhost:9999/hook"})
|
||||||
|
await mod.start()
|
||||||
|
assert mod.status == "connected"
|
||||||
|
await mod.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_with_matching_scope(self):
|
||||||
|
"""WebhookModule dispatches through FanoutManager scope matching."""
|
||||||
|
manager = FanoutManager()
|
||||||
|
mod = StubModule()
|
||||||
|
scope = {"messages": {"channels": ["ch1"], "contacts": "none"}, "raw_packets": "none"}
|
||||||
|
manager._modules["test-webhook"] = (mod, scope)
|
||||||
|
|
||||||
|
await manager.broadcast_message({"type": "CHAN", "conversation_key": "ch1", "text": "yes"})
|
||||||
|
await manager.broadcast_message({"type": "CHAN", "conversation_key": "ch2", "text": "no"})
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "PRIV", "conversation_key": "pk1", "text": "dm no"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mod.message_calls) == 1
|
||||||
|
assert mod.message_calls[0]["text"] == "yes"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Webhook router validation tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebhookValidation:
|
||||||
|
def test_validate_webhook_config_requires_url(self):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.routers.fanout import _validate_webhook_config
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
_validate_webhook_config({"url": ""})
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert "url is required" in exc_info.value.detail
|
||||||
|
|
||||||
|
def test_validate_webhook_config_requires_http_scheme(self):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.routers.fanout import _validate_webhook_config
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
_validate_webhook_config({"url": "ftp://example.com"})
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
def test_validate_webhook_config_rejects_bad_method(self):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.routers.fanout import _validate_webhook_config
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
_validate_webhook_config({"url": "https://example.com/hook", "method": "DELETE"})
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert "method" in exc_info.value.detail
|
||||||
|
|
||||||
|
def test_validate_webhook_config_accepts_valid(self):
|
||||||
|
from app.routers.fanout import _validate_webhook_config
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
_validate_webhook_config(
|
||||||
|
{"url": "https://example.com/hook", "method": "POST", "headers": {}}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enforce_scope_webhook_strips_raw_packets(self):
|
||||||
|
from app.routers.fanout import _enforce_scope
|
||||||
|
|
||||||
|
scope = _enforce_scope("webhook", {"messages": "all", "raw_packets": "all"})
|
||||||
|
assert scope["raw_packets"] == "none"
|
||||||
|
assert scope["messages"] == "all"
|
||||||
|
|
||||||
|
def test_enforce_scope_webhook_preserves_selective(self):
|
||||||
|
from app.routers.fanout import _enforce_scope
|
||||||
|
|
||||||
|
scope = _enforce_scope(
|
||||||
|
"webhook",
|
||||||
|
{"messages": {"channels": ["ch1"], "contacts": "none"}, "raw_packets": "all"},
|
||||||
|
)
|
||||||
|
assert scope["raw_packets"] == "none"
|
||||||
|
assert scope["messages"] == {"channels": ["ch1"], "contacts": "none"}
|
||||||
|
|||||||
@@ -401,3 +401,437 @@ class TestFanoutMqttIntegration:
|
|||||||
|
|
||||||
assert len(mqtt_broker.published) == 1
|
assert len(mqtt_broker.published) == 1
|
||||||
assert "raw/" in mqtt_broker.published[0][0]
|
assert "raw/" in mqtt_broker.published[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Webhook capture HTTP server
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookCaptureServer:
|
||||||
|
"""Tiny HTTP server that captures POST requests for webhook testing."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.received: list[dict] = []
|
||||||
|
self._server: asyncio.Server | None = None
|
||||||
|
self.port: int = 0
|
||||||
|
|
||||||
|
async def start(self) -> int:
|
||||||
|
self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
|
||||||
|
self.port = self._server.sockets[0].getsockname()[1]
|
||||||
|
return self.port
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
if self._server:
|
||||||
|
self._server.close()
|
||||||
|
await self._server.wait_closed()
|
||||||
|
|
||||||
|
async def wait_for(self, count: int, timeout: float = 5.0) -> list[dict]:
|
||||||
|
deadline = asyncio.get_event_loop().time() + timeout
|
||||||
|
while len(self.received) < count:
|
||||||
|
if asyncio.get_event_loop().time() >= deadline:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
return list(self.received)
|
||||||
|
|
||||||
|
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||||
|
try:
|
||||||
|
# Read HTTP request line
|
||||||
|
request_line = await reader.readline()
|
||||||
|
if not request_line:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read headers
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
while True:
|
||||||
|
line = await reader.readline()
|
||||||
|
if line in (b"\r\n", b"\n", b""):
|
||||||
|
break
|
||||||
|
decoded = line.decode("utf-8", errors="replace").strip()
|
||||||
|
if ":" in decoded:
|
||||||
|
key, val = decoded.split(":", 1)
|
||||||
|
headers[key.strip().lower()] = val.strip()
|
||||||
|
|
||||||
|
# Read body
|
||||||
|
content_length = int(headers.get("content-length", "0"))
|
||||||
|
body = b""
|
||||||
|
if content_length > 0:
|
||||||
|
body = await reader.readexactly(content_length)
|
||||||
|
|
||||||
|
payload: dict = {}
|
||||||
|
if body:
|
||||||
|
try:
|
||||||
|
payload = json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
payload = {"_raw": body.decode("utf-8", errors="replace")}
|
||||||
|
|
||||||
|
self.received.append(
|
||||||
|
{
|
||||||
|
"method": request_line.decode().split()[0],
|
||||||
|
"headers": headers,
|
||||||
|
"body": payload,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send 200 OK
|
||||||
|
response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"
|
||||||
|
writer.write(response)
|
||||||
|
await writer.drain()
|
||||||
|
except (asyncio.IncompleteReadError, ConnectionError, OSError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def webhook_server():
|
||||||
|
server = WebhookCaptureServer()
|
||||||
|
await server.start()
|
||||||
|
yield server
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def _webhook_config(port: int, secret: str = "") -> dict:
|
||||||
|
return {
|
||||||
|
"url": f"http://127.0.0.1:{port}/hook",
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {},
|
||||||
|
"secret": secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Webhook integration tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFanoutWebhookIntegration:
|
||||||
|
"""End-to-end: real HTTP capture server <-> real WebhookModule."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_receives_message(self, webhook_server, integration_db):
|
||||||
|
"""An enabled webhook receives message data via HTTP POST."""
|
||||||
|
cfg = await FanoutConfigRepository.create(
|
||||||
|
config_type="webhook",
|
||||||
|
name="Test Hook",
|
||||||
|
config=_webhook_config(webhook_server.port),
|
||||||
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = FanoutManager()
|
||||||
|
try:
|
||||||
|
await manager.load_from_db()
|
||||||
|
await _wait_connected(manager, cfg["id"])
|
||||||
|
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "PRIV", "conversation_key": "pk1", "text": "hello webhook"}
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await webhook_server.wait_for(1)
|
||||||
|
finally:
|
||||||
|
await manager.stop_all()
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["body"]["text"] == "hello webhook"
|
||||||
|
assert results[0]["body"]["conversation_key"] == "pk1"
|
||||||
|
assert results[0]["headers"].get("x-webhook-event") == "message"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_sends_secret_header(self, webhook_server, integration_db):
|
||||||
|
"""Webhook sends X-Webhook-Secret when configured."""
|
||||||
|
cfg = await FanoutConfigRepository.create(
|
||||||
|
config_type="webhook",
|
||||||
|
name="Secret Hook",
|
||||||
|
config=_webhook_config(webhook_server.port, secret="my-secret-123"),
|
||||||
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = FanoutManager()
|
||||||
|
try:
|
||||||
|
await manager.load_from_db()
|
||||||
|
await _wait_connected(manager, cfg["id"])
|
||||||
|
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "CHAN", "conversation_key": "ch1", "text": "secret test"}
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await webhook_server.wait_for(1)
|
||||||
|
finally:
|
||||||
|
await manager.stop_all()
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["headers"].get("x-webhook-secret") == "my-secret-123"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_disabled_no_delivery(self, webhook_server, integration_db):
|
||||||
|
"""Disabled webhook should not deliver any messages."""
|
||||||
|
await FanoutConfigRepository.create(
|
||||||
|
config_type="webhook",
|
||||||
|
name="Disabled Hook",
|
||||||
|
config=_webhook_config(webhook_server.port),
|
||||||
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
|
enabled=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = FanoutManager()
|
||||||
|
try:
|
||||||
|
await manager.load_from_db()
|
||||||
|
assert len(manager._modules) == 0
|
||||||
|
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "PRIV", "conversation_key": "pk1", "text": "nope"}
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
finally:
|
||||||
|
await manager.stop_all()
|
||||||
|
|
||||||
|
assert len(webhook_server.received) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_scope_selective_channels(self, webhook_server, integration_db):
|
||||||
|
"""Webhook with selective scope only fires for matching channels."""
|
||||||
|
cfg = await FanoutConfigRepository.create(
|
||||||
|
config_type="webhook",
|
||||||
|
name="Selective Hook",
|
||||||
|
config=_webhook_config(webhook_server.port),
|
||||||
|
scope={"messages": {"channels": ["ch-yes"], "contacts": "none"}, "raw_packets": "none"},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = FanoutManager()
|
||||||
|
try:
|
||||||
|
await manager.load_from_db()
|
||||||
|
await _wait_connected(manager, cfg["id"])
|
||||||
|
|
||||||
|
# Matching channel — should deliver
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "CHAN", "conversation_key": "ch-yes", "text": "included"}
|
||||||
|
)
|
||||||
|
# Non-matching channel — should NOT deliver
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "CHAN", "conversation_key": "ch-no", "text": "excluded"}
|
||||||
|
)
|
||||||
|
# DM — contacts is "none", should NOT deliver
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "PRIV", "conversation_key": "pk1", "text": "dm excluded"}
|
||||||
|
)
|
||||||
|
|
||||||
|
await webhook_server.wait_for(1)
|
||||||
|
await asyncio.sleep(0.3) # wait for any stragglers
|
||||||
|
finally:
|
||||||
|
await manager.stop_all()
|
||||||
|
|
||||||
|
assert len(webhook_server.received) == 1
|
||||||
|
assert webhook_server.received[0]["body"]["text"] == "included"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_scope_selective_contacts(self, webhook_server, integration_db):
|
||||||
|
"""Webhook with selective scope only fires for matching contacts."""
|
||||||
|
cfg = await FanoutConfigRepository.create(
|
||||||
|
config_type="webhook",
|
||||||
|
name="Contact Hook",
|
||||||
|
config=_webhook_config(webhook_server.port),
|
||||||
|
scope={
|
||||||
|
"messages": {"channels": "none", "contacts": ["pk-yes"]},
|
||||||
|
"raw_packets": "none",
|
||||||
|
},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = FanoutManager()
|
||||||
|
try:
|
||||||
|
await manager.load_from_db()
|
||||||
|
await _wait_connected(manager, cfg["id"])
|
||||||
|
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "PRIV", "conversation_key": "pk-yes", "text": "dm included"}
|
||||||
|
)
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "PRIV", "conversation_key": "pk-no", "text": "dm excluded"}
|
||||||
|
)
|
||||||
|
|
||||||
|
await webhook_server.wait_for(1)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
finally:
|
||||||
|
await manager.stop_all()
|
||||||
|
|
||||||
|
assert len(webhook_server.received) == 1
|
||||||
|
assert webhook_server.received[0]["body"]["text"] == "dm included"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_scope_all_receives_everything(self, webhook_server, integration_db):
|
||||||
|
"""Webhook with scope messages='all' receives DMs and channel messages."""
|
||||||
|
cfg = await FanoutConfigRepository.create(
|
||||||
|
config_type="webhook",
|
||||||
|
name="All Hook",
|
||||||
|
config=_webhook_config(webhook_server.port),
|
||||||
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = FanoutManager()
|
||||||
|
try:
|
||||||
|
await manager.load_from_db()
|
||||||
|
await _wait_connected(manager, cfg["id"])
|
||||||
|
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "CHAN", "conversation_key": "ch1", "text": "channel msg"}
|
||||||
|
)
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "PRIV", "conversation_key": "pk1", "text": "dm msg"}
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await webhook_server.wait_for(2)
|
||||||
|
finally:
|
||||||
|
await manager.stop_all()
|
||||||
|
|
||||||
|
assert len(results) == 2
|
||||||
|
texts = {r["body"]["text"] for r in results}
|
||||||
|
assert "channel msg" in texts
|
||||||
|
assert "dm msg" in texts
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_scope_none_receives_nothing(self, webhook_server, integration_db):
|
||||||
|
"""Webhook with scope messages='none' receives nothing."""
|
||||||
|
cfg = await FanoutConfigRepository.create(
|
||||||
|
config_type="webhook",
|
||||||
|
name="None Hook",
|
||||||
|
config=_webhook_config(webhook_server.port),
|
||||||
|
scope={"messages": "none", "raw_packets": "none"},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = FanoutManager()
|
||||||
|
try:
|
||||||
|
await manager.load_from_db()
|
||||||
|
await _wait_connected(manager, cfg["id"])
|
||||||
|
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "PRIV", "conversation_key": "pk1", "text": "should not arrive"}
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
finally:
|
||||||
|
await manager.stop_all()
|
||||||
|
|
||||||
|
assert len(webhook_server.received) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_two_webhooks_both_receive(self, webhook_server, integration_db):
|
||||||
|
"""Two enabled webhooks both receive the same message."""
|
||||||
|
cfg_a = await FanoutConfigRepository.create(
|
||||||
|
config_type="webhook",
|
||||||
|
name="Hook A",
|
||||||
|
config=_webhook_config(webhook_server.port, secret="a"),
|
||||||
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
cfg_b = await FanoutConfigRepository.create(
|
||||||
|
config_type="webhook",
|
||||||
|
name="Hook B",
|
||||||
|
config=_webhook_config(webhook_server.port, secret="b"),
|
||||||
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = FanoutManager()
|
||||||
|
try:
|
||||||
|
await manager.load_from_db()
|
||||||
|
await _wait_connected(manager, cfg_a["id"])
|
||||||
|
await _wait_connected(manager, cfg_b["id"])
|
||||||
|
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "PRIV", "conversation_key": "pk1", "text": "multi"}
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await webhook_server.wait_for(2)
|
||||||
|
finally:
|
||||||
|
await manager.stop_all()
|
||||||
|
|
||||||
|
assert len(results) == 2
|
||||||
|
secrets = {r["headers"].get("x-webhook-secret") for r in results}
|
||||||
|
assert "a" in secrets
|
||||||
|
assert "b" in secrets
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_disable_stops_delivery(self, webhook_server, integration_db):
|
||||||
|
"""Disabling a webhook stops delivery immediately."""
|
||||||
|
cfg = await FanoutConfigRepository.create(
|
||||||
|
config_type="webhook",
|
||||||
|
name="Toggle Hook",
|
||||||
|
config=_webhook_config(webhook_server.port),
|
||||||
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = FanoutManager()
|
||||||
|
try:
|
||||||
|
await manager.load_from_db()
|
||||||
|
await _wait_connected(manager, cfg["id"])
|
||||||
|
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "PRIV", "conversation_key": "pk1", "text": "before disable"}
|
||||||
|
)
|
||||||
|
await webhook_server.wait_for(1)
|
||||||
|
assert len(webhook_server.received) == 1
|
||||||
|
|
||||||
|
# Disable
|
||||||
|
await FanoutConfigRepository.update(cfg["id"], enabled=False)
|
||||||
|
await manager.reload_config(cfg["id"])
|
||||||
|
assert cfg["id"] not in manager._modules
|
||||||
|
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "PRIV", "conversation_key": "pk2", "text": "after disable"}
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
finally:
|
||||||
|
await manager.stop_all()
|
||||||
|
|
||||||
|
assert len(webhook_server.received) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_scope_except_channels(self, webhook_server, integration_db):
|
||||||
|
"""Webhook with except-mode excludes listed channels, includes others."""
|
||||||
|
cfg = await FanoutConfigRepository.create(
|
||||||
|
config_type="webhook",
|
||||||
|
name="Except Hook",
|
||||||
|
config=_webhook_config(webhook_server.port),
|
||||||
|
scope={
|
||||||
|
"messages": {
|
||||||
|
"channels": {"except": ["ch-excluded"]},
|
||||||
|
"contacts": {"except": []},
|
||||||
|
},
|
||||||
|
"raw_packets": "none",
|
||||||
|
},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = FanoutManager()
|
||||||
|
try:
|
||||||
|
await manager.load_from_db()
|
||||||
|
await _wait_connected(manager, cfg["id"])
|
||||||
|
|
||||||
|
# Excluded channel — should NOT deliver
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "CHAN", "conversation_key": "ch-excluded", "text": "nope"}
|
||||||
|
)
|
||||||
|
# Non-excluded channel — should deliver
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "CHAN", "conversation_key": "ch-other", "text": "yes"}
|
||||||
|
)
|
||||||
|
# DM with empty except list — should deliver
|
||||||
|
await manager.broadcast_message(
|
||||||
|
{"type": "PRIV", "conversation_key": "pk1", "text": "dm yes"}
|
||||||
|
)
|
||||||
|
|
||||||
|
await webhook_server.wait_for(2)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
finally:
|
||||||
|
await manager.stop_all()
|
||||||
|
|
||||||
|
assert len(webhook_server.received) == 2
|
||||||
|
texts = {r["body"]["text"] for r in webhook_server.received}
|
||||||
|
assert "yes" in texts
|
||||||
|
assert "dm yes" in texts
|
||||||
|
assert "nope" not in texts
|
||||||
|
|||||||
Reference in New Issue
Block a user