iil-platform-notifications 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iil_platform_notifications-0.1.0/.gitignore +50 -0
- iil_platform_notifications-0.1.0/PKG-INFO +79 -0
- iil_platform_notifications-0.1.0/README.md +60 -0
- iil_platform_notifications-0.1.0/platform_notifications/__init__.py +7 -0
- iil_platform_notifications-0.1.0/platform_notifications/apps.py +33 -0
- iil_platform_notifications-0.1.0/platform_notifications/channels/__init__.py +1 -0
- iil_platform_notifications-0.1.0/platform_notifications/channels/base.py +58 -0
- iil_platform_notifications-0.1.0/platform_notifications/channels/discord.py +64 -0
- iil_platform_notifications-0.1.0/platform_notifications/channels/email.py +49 -0
- iil_platform_notifications-0.1.0/platform_notifications/channels/sms.py +68 -0
- iil_platform_notifications-0.1.0/platform_notifications/channels/telegram.py +74 -0
- iil_platform_notifications-0.1.0/platform_notifications/channels/webhook.py +41 -0
- iil_platform_notifications-0.1.0/platform_notifications/migrations/0001_initial.py +123 -0
- iil_platform_notifications-0.1.0/platform_notifications/migrations/__init__.py +0 -0
- iil_platform_notifications-0.1.0/platform_notifications/models.py +63 -0
- iil_platform_notifications-0.1.0/platform_notifications/registry.py +70 -0
- iil_platform_notifications-0.1.0/platform_notifications/service.py +163 -0
- iil_platform_notifications-0.1.0/platform_notifications/tasks.py +101 -0
- iil_platform_notifications-0.1.0/pyproject.toml +40 -0
- iil_platform_notifications-0.1.0/tests/__init__.py +0 -0
- iil_platform_notifications-0.1.0/tests/conftest.py +11 -0
- iil_platform_notifications-0.1.0/tests/settings.py +19 -0
- iil_platform_notifications-0.1.0/tests/test_channels.py +116 -0
- iil_platform_notifications-0.1.0/tests/test_registry.py +89 -0
- iil_platform_notifications-0.1.0/tests/test_service.py +132 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*\.class
|
|
5
|
+
.venv/
|
|
6
|
+
venv/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
# Django
|
|
11
|
+
*.log
|
|
12
|
+
db.sqlite3
|
|
13
|
+
media/
|
|
14
|
+
staticfiles/
|
|
15
|
+
# Environment
|
|
16
|
+
.env
|
|
17
|
+
.env.local
|
|
18
|
+
.env.prod
|
|
19
|
+
|
|
20
|
+
# Secrets (ADR-045) — NEVER commit plaintext
|
|
21
|
+
secrets.env
|
|
22
|
+
!secrets.enc.env
|
|
23
|
+
# IDE
|
|
24
|
+
.idea/
|
|
25
|
+
.vscode/
|
|
26
|
+
*.swp
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
# Large files
|
|
31
|
+
*.sqlite3
|
|
32
|
+
*.log
|
|
33
|
+
output_batch/
|
|
34
|
+
app_backup/
|
|
35
|
+
docs_legacy/
|
|
36
|
+
# Sphinx build output
|
|
37
|
+
docs/_build/
|
|
38
|
+
*:Zone.Identifier
|
|
39
|
+
# Local Windsurf/MCP config — machine-specific, never commit
|
|
40
|
+
.windsurf/mcp_config.json
|
|
41
|
+
# Local infra plaintext secrets — NEVER commit
|
|
42
|
+
infra/*.env
|
|
43
|
+
infra/*.key
|
|
44
|
+
# Validate-ports CI draft (not yet integrated)
|
|
45
|
+
.github/workflows/validate-ports.yml
|
|
46
|
+
# Markdownlint local config override
|
|
47
|
+
.markdownlint.json
|
|
48
|
+
# Concept review drafts (local working copies, not for VCS)
|
|
49
|
+
docs/concepts/REVIEW-*
|
|
50
|
+
env_loader.py
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iil-platform-notifications
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Platform-wide multi-channel notification registry (ADR-088)
|
|
5
|
+
Author: Achim Dehnert
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: celery>=5.3
|
|
9
|
+
Requires-Dist: django>=4.2
|
|
10
|
+
Requires-Dist: httpx>=0.25
|
|
11
|
+
Requires-Dist: pydantic>=2.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest-django>=4.5; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
16
|
+
Provides-Extra: sms
|
|
17
|
+
Requires-Dist: twilio>=8.0; extra == 'sms'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# platform-notifications
|
|
21
|
+
|
|
22
|
+
> Platform-wide multi-channel notification registry (ADR-088)
|
|
23
|
+
|
|
24
|
+
## Overview
|
|
25
|
+
|
|
26
|
+
Unified notification system with channel abstraction, Celery-First
|
|
27
|
+
async delivery, audit logging, and thread-safe registry pattern.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install -e packages/platform-notifications
|
|
33
|
+
pip install -e "packages/platform-notifications[sms]" # for Twilio SMS
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from platform_notifications.service import NotificationService, Notification
|
|
40
|
+
|
|
41
|
+
NotificationService.send(Notification(
|
|
42
|
+
tenant_id=request.tenant_id,
|
|
43
|
+
channel="email",
|
|
44
|
+
recipient="user@example.com",
|
|
45
|
+
subject="Welcome",
|
|
46
|
+
body="Hello from the platform!",
|
|
47
|
+
source_app="wedding-hub",
|
|
48
|
+
source_event="rsvp_confirmation",
|
|
49
|
+
))
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Built-in Channels
|
|
53
|
+
|
|
54
|
+
- **email** — Django `send_mail`
|
|
55
|
+
- **sms** — Twilio (requires `twilio` extra)
|
|
56
|
+
- **webhook** — Generic HTTPS POST via `httpx`
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
# config/settings/base.py
|
|
62
|
+
PLATFORM_NOTIFICATIONS = {
|
|
63
|
+
"DEFAULT_CHANNELS": ["email"],
|
|
64
|
+
"RETRY_MAX": 3,
|
|
65
|
+
"RETRY_BACKOFF": True,
|
|
66
|
+
"RETRY_BACKOFF_MAX": 300,
|
|
67
|
+
"LOG_RETENTION_DAYS": 90,
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Database
|
|
72
|
+
|
|
73
|
+
Run: `python manage.py migrate platform_notifications`
|
|
74
|
+
|
|
75
|
+
## Related ADRs
|
|
76
|
+
|
|
77
|
+
- **ADR-088**: Notification Registry
|
|
78
|
+
- **ADR-045**: Secret Management (Twilio, Discord, Telegram)
|
|
79
|
+
- **ADR-072**: Schema Isolation (Row-Level deviation documented)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# platform-notifications
|
|
2
|
+
|
|
3
|
+
> Platform-wide multi-channel notification registry (ADR-088)
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Unified notification system with channel abstraction, Celery-First
|
|
8
|
+
async delivery, audit logging, and thread-safe registry pattern.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install -e packages/platform-notifications
|
|
14
|
+
pip install -e "packages/platform-notifications[sms]" # for Twilio SMS
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from platform_notifications.service import NotificationService, Notification
|
|
21
|
+
|
|
22
|
+
NotificationService.send(Notification(
|
|
23
|
+
tenant_id=request.tenant_id,
|
|
24
|
+
channel="email",
|
|
25
|
+
recipient="user@example.com",
|
|
26
|
+
subject="Welcome",
|
|
27
|
+
body="Hello from the platform!",
|
|
28
|
+
source_app="wedding-hub",
|
|
29
|
+
source_event="rsvp_confirmation",
|
|
30
|
+
))
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Built-in Channels
|
|
34
|
+
|
|
35
|
+
- **email** — Django `send_mail`
|
|
36
|
+
- **sms** — Twilio (requires `twilio` extra)
|
|
37
|
+
- **webhook** — Generic HTTPS POST via `httpx`
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# config/settings/base.py
|
|
43
|
+
PLATFORM_NOTIFICATIONS = {
|
|
44
|
+
"DEFAULT_CHANNELS": ["email"],
|
|
45
|
+
"RETRY_MAX": 3,
|
|
46
|
+
"RETRY_BACKOFF": True,
|
|
47
|
+
"RETRY_BACKOFF_MAX": 300,
|
|
48
|
+
"LOG_RETENTION_DAYS": 90,
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Database
|
|
53
|
+
|
|
54
|
+
Run: `python manage.py migrate platform_notifications`
|
|
55
|
+
|
|
56
|
+
## Related ADRs
|
|
57
|
+
|
|
58
|
+
- **ADR-088**: Notification Registry
|
|
59
|
+
- **ADR-045**: Secret Management (Twilio, Discord, Telegram)
|
|
60
|
+
- **ADR-072**: Schema Isolation (Row-Level deviation documented)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Django app configuration for platform-notifications."""
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PlatformNotificationsConfig(AppConfig):
|
|
7
|
+
"""Platform Notifications app config."""
|
|
8
|
+
|
|
9
|
+
name = "platform_notifications"
|
|
10
|
+
verbose_name = "Platform Notifications"
|
|
11
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
12
|
+
|
|
13
|
+
def ready(self) -> None:
|
|
14
|
+
"""Register built-in channels on app startup."""
|
|
15
|
+
from platform_notifications.channels.email import EmailChannel
|
|
16
|
+
from platform_notifications.channels.webhook import WebhookChannel
|
|
17
|
+
from platform_notifications.registry import ChannelRegistry
|
|
18
|
+
|
|
19
|
+
registry = ChannelRegistry.get_instance()
|
|
20
|
+
registry.register(EmailChannel())
|
|
21
|
+
registry.register(WebhookChannel())
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from platform_notifications.channels.sms import SmsChannel
|
|
25
|
+
registry.register(SmsChannel())
|
|
26
|
+
except ImportError:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
from platform_notifications.channels.discord import DiscordChannel
|
|
30
|
+
from platform_notifications.channels.telegram import TelegramChannel
|
|
31
|
+
|
|
32
|
+
registry.register(DiscordChannel())
|
|
33
|
+
registry.register(TelegramChannel())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Notification channel implementations."""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Base channel interface for notification channels (ADR-088)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChannelConfig(BaseModel):
|
|
11
|
+
"""Configuration for a notification channel."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(frozen=True)
|
|
14
|
+
|
|
15
|
+
max_retries: int = Field(
|
|
16
|
+
default=3, description="Max delivery retries"
|
|
17
|
+
)
|
|
18
|
+
retry_backoff: bool = Field(
|
|
19
|
+
default=True, description="Exponential backoff"
|
|
20
|
+
)
|
|
21
|
+
retry_backoff_max: int = Field(
|
|
22
|
+
default=300, description="Max backoff seconds"
|
|
23
|
+
)
|
|
24
|
+
timeout: int = Field(
|
|
25
|
+
default=30, description="Delivery timeout seconds"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BaseChannel(ABC):
|
|
30
|
+
"""Abstract base for notification channels."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
config: ChannelConfig
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self, config: ChannelConfig | None = None
|
|
37
|
+
) -> None:
|
|
38
|
+
self.config = config or ChannelConfig()
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def deliver(
|
|
42
|
+
self,
|
|
43
|
+
recipient: str,
|
|
44
|
+
subject: str,
|
|
45
|
+
body: str,
|
|
46
|
+
**kwargs: object,
|
|
47
|
+
) -> bool:
|
|
48
|
+
"""Deliver notification. Returns True on success."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def validate_recipient(self, recipient: str) -> bool:
|
|
53
|
+
"""Validate recipient format."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
def health_check(self) -> dict[str, bool | str]:
|
|
57
|
+
"""Check channel connectivity."""
|
|
58
|
+
return {"healthy": True, "channel": self.name}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Discord webhook notification channel (ADR-088).
|
|
2
|
+
|
|
3
|
+
Requires: DISCORD_WEBHOOK_URL in Django settings (ADR-045).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from platform_notifications.channels.base import BaseChannel
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DiscordChannel(BaseChannel):
|
|
18
|
+
"""Discord notifications via webhook URL."""
|
|
19
|
+
|
|
20
|
+
name = "discord"
|
|
21
|
+
|
|
22
|
+
def deliver(
|
|
23
|
+
self,
|
|
24
|
+
recipient: str,
|
|
25
|
+
subject: str,
|
|
26
|
+
body: str,
|
|
27
|
+
**kwargs: object,
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""Post message to Discord webhook.
|
|
30
|
+
|
|
31
|
+
recipient = webhook URL (per-channel or per-tenant).
|
|
32
|
+
"""
|
|
33
|
+
content = f"**{subject}**\n{body}" if subject else body
|
|
34
|
+
with httpx.Client(timeout=self.config.timeout) as client:
|
|
35
|
+
response = client.post(
|
|
36
|
+
recipient,
|
|
37
|
+
json={"content": content[:2000]},
|
|
38
|
+
)
|
|
39
|
+
response.raise_for_status()
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
def validate_recipient(self, recipient: str) -> bool:
|
|
43
|
+
"""Validate Discord webhook URL format."""
|
|
44
|
+
return (
|
|
45
|
+
recipient.startswith("https://discord.com/api/webhooks/")
|
|
46
|
+
or recipient.startswith("https://discordapp.com/api/webhooks/")
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def health_check(self) -> dict[str, bool | str]:
|
|
50
|
+
"""Check Discord webhook URL is configured."""
|
|
51
|
+
try:
|
|
52
|
+
from django.conf import settings
|
|
53
|
+
|
|
54
|
+
url = getattr(settings, "DISCORD_WEBHOOK_URL", "")
|
|
55
|
+
return {
|
|
56
|
+
"healthy": bool(url),
|
|
57
|
+
"channel": self.name,
|
|
58
|
+
}
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
return {
|
|
61
|
+
"healthy": False,
|
|
62
|
+
"channel": self.name,
|
|
63
|
+
"error": str(exc),
|
|
64
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Email notification channel (ADR-088)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from platform_notifications.channels.base import BaseChannel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EmailChannel(BaseChannel):
|
|
9
|
+
"""Email channel via Django send_mail."""
|
|
10
|
+
|
|
11
|
+
name = "email"
|
|
12
|
+
|
|
13
|
+
def deliver(
|
|
14
|
+
self,
|
|
15
|
+
recipient: str,
|
|
16
|
+
subject: str,
|
|
17
|
+
body: str,
|
|
18
|
+
**kwargs: object,
|
|
19
|
+
) -> bool:
|
|
20
|
+
"""Send email via Django mail backend."""
|
|
21
|
+
from django.core.mail import send_mail
|
|
22
|
+
|
|
23
|
+
send_mail(
|
|
24
|
+
subject,
|
|
25
|
+
body,
|
|
26
|
+
None,
|
|
27
|
+
[recipient],
|
|
28
|
+
fail_silently=False,
|
|
29
|
+
)
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
def validate_recipient(self, recipient: str) -> bool:
|
|
33
|
+
"""Validate email address format."""
|
|
34
|
+
from django.core.validators import validate_email
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
validate_email(recipient)
|
|
38
|
+
return True
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
def health_check(self) -> dict[str, bool | str]:
|
|
43
|
+
"""Check Django email backend is configured."""
|
|
44
|
+
from django.conf import settings
|
|
45
|
+
|
|
46
|
+
has_backend = bool(
|
|
47
|
+
getattr(settings, "EMAIL_BACKEND", None)
|
|
48
|
+
)
|
|
49
|
+
return {"healthy": has_backend, "channel": self.name}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""SMS notification channel via Twilio (ADR-088).
|
|
2
|
+
|
|
3
|
+
Requires: pip install platform-notifications[sms]
|
|
4
|
+
Secrets: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER (ADR-045)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from platform_notifications.channels.base import BaseChannel
|
|
12
|
+
|
|
13
|
+
E164_PATTERN = re.compile(r"^\+[1-9]\d{1,14}$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SmsChannel(BaseChannel):
|
|
17
|
+
"""SMS via Twilio. Replaces wedding-hub direct Twilio calls."""
|
|
18
|
+
|
|
19
|
+
name = "sms"
|
|
20
|
+
|
|
21
|
+
def deliver(
|
|
22
|
+
self,
|
|
23
|
+
recipient: str,
|
|
24
|
+
subject: str,
|
|
25
|
+
body: str,
|
|
26
|
+
**kwargs: object,
|
|
27
|
+
) -> bool:
|
|
28
|
+
"""Send SMS via Twilio API."""
|
|
29
|
+
from twilio.rest import Client # type: ignore[import-untyped]
|
|
30
|
+
|
|
31
|
+
client = Client(
|
|
32
|
+
self._get_account_sid(),
|
|
33
|
+
self._get_auth_token(),
|
|
34
|
+
)
|
|
35
|
+
client.messages.create(
|
|
36
|
+
body=body,
|
|
37
|
+
from_=self._get_from_number(),
|
|
38
|
+
to=recipient,
|
|
39
|
+
)
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
def validate_recipient(self, recipient: str) -> bool:
|
|
43
|
+
"""Validate E.164 phone number format."""
|
|
44
|
+
return bool(E164_PATTERN.match(recipient))
|
|
45
|
+
|
|
46
|
+
def health_check(self) -> dict[str, bool | str]:
|
|
47
|
+
"""Check Twilio credentials are configured."""
|
|
48
|
+
try:
|
|
49
|
+
self._get_account_sid()
|
|
50
|
+
return {"healthy": True, "channel": self.name}
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
return {
|
|
53
|
+
"healthy": False,
|
|
54
|
+
"channel": self.name,
|
|
55
|
+
"error": str(exc),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def _get_account_sid(self) -> str:
|
|
59
|
+
from django.conf import settings
|
|
60
|
+
return settings.TWILIO_ACCOUNT_SID # type: ignore[attr-defined]
|
|
61
|
+
|
|
62
|
+
def _get_auth_token(self) -> str:
|
|
63
|
+
from django.conf import settings
|
|
64
|
+
return settings.TWILIO_AUTH_TOKEN # type: ignore[attr-defined]
|
|
65
|
+
|
|
66
|
+
def _get_from_number(self) -> str:
|
|
67
|
+
from django.conf import settings
|
|
68
|
+
return settings.TWILIO_FROM_NUMBER # type: ignore[attr-defined]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Telegram Bot notification channel (ADR-088).
|
|
2
|
+
|
|
3
|
+
Requires: TELEGRAM_BOT_TOKEN in Django settings (ADR-045).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from platform_notifications.channels.base import BaseChannel
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
TELEGRAM_API_BASE = "https://api.telegram.org/bot"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TelegramChannel(BaseChannel):
|
|
20
|
+
"""Telegram notifications via Bot API.
|
|
21
|
+
|
|
22
|
+
recipient = chat_id (string or numeric).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name = "telegram"
|
|
26
|
+
|
|
27
|
+
def deliver(
|
|
28
|
+
self,
|
|
29
|
+
recipient: str,
|
|
30
|
+
subject: str,
|
|
31
|
+
body: str,
|
|
32
|
+
**kwargs: object,
|
|
33
|
+
) -> bool:
|
|
34
|
+
"""Send message via Telegram Bot API."""
|
|
35
|
+
token = self._get_bot_token()
|
|
36
|
+
text = f"*{subject}*\n{body}" if subject else body
|
|
37
|
+
url = f"{TELEGRAM_API_BASE}{token}/sendMessage"
|
|
38
|
+
|
|
39
|
+
with httpx.Client(timeout=self.config.timeout) as client:
|
|
40
|
+
response = client.post(
|
|
41
|
+
url,
|
|
42
|
+
json={
|
|
43
|
+
"chat_id": recipient,
|
|
44
|
+
"text": text[:4096],
|
|
45
|
+
"parse_mode": "Markdown",
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
response.raise_for_status()
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
def validate_recipient(self, recipient: str) -> bool:
|
|
52
|
+
"""Validate Telegram chat_id (numeric string, optionally negative)."""
|
|
53
|
+
cleaned = recipient.lstrip("-")
|
|
54
|
+
return cleaned.isdigit() and len(cleaned) <= 20
|
|
55
|
+
|
|
56
|
+
def health_check(self) -> dict[str, bool | str]:
|
|
57
|
+
"""Check Telegram bot token is configured."""
|
|
58
|
+
try:
|
|
59
|
+
self._get_bot_token()
|
|
60
|
+
return {"healthy": True, "channel": self.name}
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
return {
|
|
63
|
+
"healthy": False,
|
|
64
|
+
"channel": self.name,
|
|
65
|
+
"error": str(exc),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def _get_bot_token(self) -> str:
|
|
69
|
+
from django.conf import settings
|
|
70
|
+
|
|
71
|
+
token = getattr(settings, "TELEGRAM_BOT_TOKEN", "")
|
|
72
|
+
if not token:
|
|
73
|
+
raise ValueError("TELEGRAM_BOT_TOKEN not configured")
|
|
74
|
+
return token
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Generic webhook notification channel (ADR-088)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from platform_notifications.channels.base import BaseChannel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WebhookChannel(BaseChannel):
|
|
11
|
+
"""Generic HTTPS webhook channel."""
|
|
12
|
+
|
|
13
|
+
name = "webhook"
|
|
14
|
+
|
|
15
|
+
def deliver(
|
|
16
|
+
self,
|
|
17
|
+
recipient: str,
|
|
18
|
+
subject: str,
|
|
19
|
+
body: str,
|
|
20
|
+
**kwargs: object,
|
|
21
|
+
) -> bool:
|
|
22
|
+
"""POST notification payload to webhook URL."""
|
|
23
|
+
with httpx.Client(timeout=self.config.timeout) as client:
|
|
24
|
+
response = client.post(
|
|
25
|
+
recipient,
|
|
26
|
+
json={
|
|
27
|
+
"subject": subject,
|
|
28
|
+
"body": body,
|
|
29
|
+
**kwargs,
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
response.raise_for_status()
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
def validate_recipient(self, recipient: str) -> bool:
|
|
36
|
+
"""Validate HTTPS URL."""
|
|
37
|
+
return recipient.startswith("https://")
|
|
38
|
+
|
|
39
|
+
def health_check(self) -> dict[str, bool | str]:
|
|
40
|
+
"""Webhook health is always true (no persistent connection)."""
|
|
41
|
+
return {"healthy": True, "channel": self.name}
|