Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""add notification channel types for apprise integration

Revision ID: a1b2c3d4e5f6
Revises: 122a6a3a110b
Create Date: 2026-04-21 10:00:00.000000

"""
from typing import Sequence, Union

from alembic import op


revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, Sequence[str], None] = "122a6a3a110b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


# ALTER TYPE ... ADD VALUE cannot run inside a transaction block in
# PostgreSQL < 12. Alembic wraps migrations in a transaction by default,
# so we pop out via autocommit_block().
_NEW_VALUES = ("DISCORD", "EMAIL", "WEBHOOK", "APPRISE_URL")


def upgrade() -> None:
with op.get_context().autocommit_block():
for value in _NEW_VALUES:
op.execute(
f"ALTER TYPE notificationchanneltype ADD VALUE IF NOT EXISTS '{value}'"
)


def downgrade() -> None:
# Postgres does not support removing enum values. A true downgrade would
# require creating a new enum type, migrating the column, and dropping
# the old type. Intentional no-op: forward-only migration.
pass
142 changes: 51 additions & 91 deletions backend/api/app/services/notification_channel_service.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,42 @@
import json
import uuid
import urllib.request
import urllib.error
from datetime import datetime, timezone

import apprise
from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.repositories import notification_channel_repo
from shared.enums import NotificationChannelType
from shared.models import NotificationChannel, User
from shared.notifications import (
ChannelConfigError,
capture_apprise_logs,
channel_to_apprise_url,
format_apprise_failure,
validate_channel_config,
)
from shared.schemas import (
NotificationChannelCreate,
NotificationChannelRead,
NotificationChannelUpdate,
)


def _validate_slack_config(config: dict) -> None:
"""Ensure Slack config_data has a valid webhook_url."""
webhook_url = config.get("webhook_url", "")
if not webhook_url:
def _validate_or_422(channel_type, config: dict) -> None:
try:
validate_channel_config(channel_type, config)
except ChannelConfigError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="config_data.webhook_url is required for Slack channels",
)
if not webhook_url.startswith("https://"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="config_data.webhook_url must start with https://",
detail=str(e),
)


async def create_notification_channel(
db: AsyncSession, user: User, body: NotificationChannelCreate
) -> NotificationChannelRead:
if body.channel_type == NotificationChannelType.SLACK:
_validate_slack_config(body.config_data)
# Pydantic already validated; this is the belt-and-braces check in case
# a caller bypasses the schema (e.g. direct ORM construction in tests).
_validate_or_422(body.channel_type, body.config_data)

channel = NotificationChannel(
name=body.name,
Expand Down Expand Up @@ -65,18 +65,16 @@ async def list_notification_channels(
async def update_notification_channel(
db: AsyncSession, channel_id: str, body: NotificationChannelUpdate
) -> NotificationChannelRead:
channel = await notification_channel_repo.get_by_id(
db, uuid.UUID(channel_id)
)
channel = await notification_channel_repo.get_by_id(db, uuid.UUID(channel_id))
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification channel not found",
)

fields = body.model_dump(exclude_unset=True)
if "config_data" in fields and channel.channel_type == NotificationChannelType.SLACK:
_validate_slack_config(fields["config_data"])
if "config_data" in fields:
_validate_or_422(channel.channel_type, fields["config_data"])

await notification_channel_repo.update(db, channel, fields)
await db.commit()
Expand All @@ -85,9 +83,7 @@ async def update_notification_channel(


async def delete_notification_channel(db: AsyncSession, channel_id: str) -> None:
channel = await notification_channel_repo.get_by_id(
db, uuid.UUID(channel_id)
)
channel = await notification_channel_repo.get_by_id(db, uuid.UUID(channel_id))
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
Expand All @@ -98,79 +94,43 @@ async def delete_notification_channel(db: AsyncSession, channel_id: str) -> None


async def test_notification_channel(db: AsyncSession, channel_id: str) -> dict:
"""Send a test notification and return the result."""
channel = await notification_channel_repo.get_by_id(
db, uuid.UUID(channel_id)
)
"""Send a test notification via Apprise and return the result."""
channel = await notification_channel_repo.get_by_id(db, uuid.UUID(channel_id))
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification channel not found",
)

if channel.channel_type == NotificationChannelType.SLACK:
_validate_slack_config(channel.config_data)
webhook_url = channel.config_data["webhook_url"]
payload = json.dumps(
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ":white_check_mark: *Test notification from Gitbacker*",
},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"Your notification channel *{channel.name}* "
"is working correctly."
),
},
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": (
f"Gitbacker | "
f"{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
),
}
],
},
]
}
).encode()

req = urllib.request.Request(
webhook_url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
_validate_or_422(channel.channel_type, channel.config_data)

try:
url = channel_to_apprise_url(channel.channel_type, channel.config_data)
except ChannelConfigError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e),
)

a = apprise.Apprise()
if not a.add(url):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Apprise could not parse the channel URL",
)

title = f"\u2705 Test notification from Gitbacker"
body = (
f"Your notification channel **{channel.name}** is working correctly.\n\n"
f"_Gitbacker \u00B7 "
f"{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}_"
)
with capture_apprise_logs() as records:
ok = a.notify(title=title, body=body, body_format=apprise.NotifyFormat.MARKDOWN)
if not ok:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Notification dispatch failed: {format_apprise_failure(records)}",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status != 200:
body = resp.read().decode(errors="replace")
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Slack returned {resp.status}: {body}",
)
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace") if e.fp else ""
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Slack webhook failed ({e.code}): {body}",
)
except urllib.error.URLError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Could not reach Slack: {e.reason}",
)

return {"status": "sent"}
1 change: 1 addition & 0 deletions backend/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies = [
"redis>=5.0",
"python-dotenv>=1.0",
"croniter>=6.0",
"apprise>=1.9",
"shared",
]

Expand Down
Loading
Loading