Add webhooks & reformat a bit

This commit is contained in:
Jack Kingsman
2026-03-05 19:10:29 -08:00
parent 5ecb63fde9
commit e3e4e0b839
7 changed files with 1086 additions and 61 deletions

View File

@@ -20,10 +20,32 @@ def _register_module_types() -> None:
from app.fanout.bot import BotModule
from app.fanout.mqtt_community import MqttCommunityModule
from app.fanout.mqtt_private import MqttPrivateModule
from app.fanout.webhook import WebhookModule
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
_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:
@@ -37,21 +59,9 @@ def _scope_matches_message(scope: dict, data: dict) -> bool:
msg_type = data.get("type", "")
conversation_key = data.get("conversation_key", "")
if msg_type == "CHAN":
channels = messages.get("channels", "none")
if channels == "all":
return True
if channels == "none":
return False
if isinstance(channels, list):
return conversation_key in channels
return _matches_filter(messages.get("channels", "none"), conversation_key)
elif msg_type == "PRIV":
contacts = messages.get("contacts", "none")
if contacts == "all":
return True
if contacts == "none":
return False
if isinstance(contacts, list):
return conversation_key in contacts
return _matches_filter(messages.get("contacts", "none"), conversation_key)
return False

79
app/fanout/webhook.py Normal file
View File

@@ -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"

View File

@@ -12,7 +12,7 @@ from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__)
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}$")
@@ -65,12 +65,32 @@ def _validate_bot_config(config: dict) -> 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:
"""Enforce type-specific scope constraints. Returns normalized scope."""
if config_type == "mqtt_community":
return {"messages": "none", "raw_packets": "all"}
if config_type == "bot":
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
messages = scope.get("messages", "all")
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)
elif body.type == "bot":
_validate_bot_config(body.config)
elif body.type == "webhook":
_validate_webhook_config(body.config)
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)
elif existing["type"] == "bot":
_validate_bot_config(config_to_validate)
elif existing["type"] == "webhook":
_validate_webhook_config(config_to_validate)
updated = await FanoutConfigRepository.update(config_id, **kwargs)
if updated is None:

View File

@@ -6,7 +6,7 @@ import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { cn } from '@/lib/utils';
import { api } from '../../api';
import type { FanoutConfig, HealthStatus } from '../../types';
import type { Channel, Contact, FanoutConfig, HealthStatus } from '../../types';
const BotCodeEditor = lazy(() =>
import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
@@ -16,12 +16,14 @@ const TYPE_LABELS: Record<string, string> = {
mqtt_private: 'Private MQTT',
mqtt_community: 'Community MQTT',
bot: 'Bot',
webhook: 'Webhook',
};
const TYPE_OPTIONS = [
{ value: 'mqtt_private', label: 'Private MQTT' },
{ value: 'mqtt_community', label: 'Community MQTT' },
{ value: 'bot', label: 'Bot' },
{ value: 'webhook', label: 'Webhook' },
];
const DEFAULT_BOT_CODE = `def bot(
@@ -62,18 +64,20 @@ const DEFAULT_BOT_CODE = `def bot(
return "[BOT] Plong!"
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) {
if (status === 'connected')
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';
}
function getStatusLabel(status: string | undefined, type?: string) {
if (status === 'connected') return type === 'bot' ? 'Active' : 'Connected';
if (status === 'disconnected') return 'Disconnected';
return 'Inactive';
}
function MqttPrivateConfigEditor({
config,
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({
health,
onHealthRefresh,
@@ -373,7 +735,6 @@ export function SettingsFanoutSection({
const [editScope, setEditScope] = useState<Record<string, unknown>>({});
const [editName, setEditName] = useState('');
const [busy, setBusy] = useState(false);
const [addingType, setAddingType] = useState<string | null>(null);
const loadConfigs = useCallback(async () => {
try {
@@ -438,10 +799,6 @@ export function SettingsFanoutSection({
}
};
const handleAddStart = (type: string) => {
setAddingType(type);
};
const handleAddCreate = async (type: string) => {
const defaults: Record<string, Record<string, unknown>> = {
mqtt_private: {
@@ -462,11 +819,18 @@ export function SettingsFanoutSection({
bot: {
code: DEFAULT_BOT_CODE,
},
webhook: {
url: '',
method: 'POST',
headers: {},
secret: '',
},
};
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' },
webhook: { messages: 'all', raw_packets: 'none' },
};
try {
@@ -478,7 +842,6 @@ export function SettingsFanoutSection({
enabled: false,
});
await loadConfigs();
setAddingType(null);
handleEdit(created);
toast.success('Integration created');
} catch (err) {
@@ -533,6 +896,15 @@ export function SettingsFanoutSection({
<BotConfigEditor config={editConfig} onChange={setEditConfig} />
)}
{editingConfig.type === 'webhook' && (
<WebhookConfigEditor
config={editConfig}
scope={editScope}
onChange={setEditConfig}
onScopeChange={setEditScope}
/>
)}
<Separator />
<div className="flex gap-2">
@@ -554,11 +926,21 @@ export function SettingsFanoutSection({
Integrations are an experimental feature in open beta.
</div>
{configs.length === 0 ? (
<div className="text-center py-8 border border-dashed border-input rounded-md">
<p className="text-muted-foreground mb-4">No integrations configured</p>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Add:</span>
{TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map((opt) => (
<Button
key={opt.value}
variant="outline"
size="sm"
onClick={() => handleAddCreate(opt.value)}
>
{opt.label}
</Button>
))}
</div>
{configs.length > 0 && (
<div className="space-y-2">
{configs.map((cfg) => {
const statusEntry = health?.fanout_statuses?.[cfg.id];
@@ -609,33 +991,6 @@ export function SettingsFanoutSection({
})}
</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>
);
}

View File

@@ -13,7 +13,7 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
radio: '📻 Radio',
local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging',
fanout: '📤 Fanout & Forwarding',
fanout: '📤 MQTT & Forwarding',
statistics: '📊 Statistics',
about: 'About',
};

View File

@@ -56,6 +56,26 @@ class TestScopeMatchesMessage:
scope = {"messages": {"contacts": ["pk1"]}}
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:
def test_all_matches(self):
@@ -626,3 +646,106 @@ class TestMigration037:
assert row[0] == 0
finally:
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"}

View File

@@ -401,3 +401,437 @@ class TestFanoutMqttIntegration:
assert len(mqtt_broker.published) == 1
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