Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Thumbs.db
*.sublime-workspace

# Project Specific
.worktrees/
.planning_old/
.mcp.json
new_templates/
Expand Down
42 changes: 42 additions & 0 deletions backend/app/api/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,48 @@ def add_chat_connection():
return jsonify({'connection': conn.to_dict()}), 201


@notifications_bp.route('/admin/chat-connections/<int:conn_id>', methods=['PUT'])
@jwt_required()
@admin_required
def update_chat_connection(conn_id):
"""Update mutable chat connection metadata and credentials."""
from app.services.chat_webhook_service import ChatWebhookService
data = request.get_json(silent=True)
if data is None and not request.is_json:
data = {}
try:
conn = ChatWebhookService.update(conn_id, data)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
if conn is None:
return jsonify({'error': 'Connection not found'}), 404
return jsonify({'success': True, 'connection': conn.to_dict()}), 200


@notifications_bp.route('/admin/chat-connections/<int:conn_id>/test', methods=['POST'])
@jwt_required()
@admin_required
def test_chat_connection(conn_id):
"""Send a synchronous test through one chat connection."""
from app.services.chat_webhook_service import ChatWebhookService
result = ChatWebhookService.test(conn_id)
if result is None:
return jsonify({'error': 'Connection not found'}), 404
return jsonify(result), 200 if result.get('success') else 400


@notifications_bp.route('/admin/chat-connections/<int:conn_id>/default', methods=['POST'])
@jwt_required()
@admin_required
def set_default_chat_connection(conn_id):
"""Select the active administrative default for a connection kind."""
from app.services.chat_webhook_service import ChatWebhookService
conn = ChatWebhookService.set_default(conn_id)
if conn is None:
return jsonify({'error': 'Connection not found'}), 404
return jsonify({'success': True, 'connection': conn.to_dict()}), 200


@notifications_bp.route('/admin/chat-connections/<int:conn_id>', methods=['DELETE'])
@jwt_required()
@admin_required
Expand Down
3 changes: 2 additions & 1 deletion backend/app/models/chat_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ class ChatWebhookConnection(db.Model):
categories_json = db.Column(db.Text)

is_active = db.Column(db.Boolean, default=True, nullable=False)
# The default connection for its kind (used when nothing category-matches).
# Administrative default for this kind. Category fan-out remains driven by
# active connections whose category filters match the notification.
is_default = db.Column(db.Boolean, default=False, index=True)
# True when created by the one-time import of legacy notifications.json config.
imported = db.Column(db.Boolean, default=False)
Expand Down
157 changes: 157 additions & 0 deletions backend/app/services/chat_webhook_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,109 @@ def add(cls, data):
db.session.commit()
return conn

@classmethod
def update(cls, conn_id, data):
"""Update mutable connection fields without exposing stored secrets.

Credential fields are patch-like: omitted values are preserved, empty
optional values clear the credential, and required destinations cannot
be cleared. The connection kind and default flag have dedicated
lifecycle semantics and cannot be changed here.
"""
if not isinstance(data, dict):
raise ValueError('request body must be an object')

conn = db.session.get(ChatWebhookConnection, conn_id)
if conn is None:
return None

if 'kind' in data:
kind = (data.get('kind') or '').strip().lower()
if kind != conn.kind:
raise ValueError('connection kind cannot be changed')
if 'is_default' in data:
raise ValueError('use the default endpoint to change is_default')

new_name = conn.name
new_categories_json = conn.categories_json
new_is_active = conn.is_active
new_credentials_json = conn.credentials_json

if 'name' in data:
new_name = str(data.get('name') or '').strip()
if not new_name:
raise ValueError('name is required')

if 'categories' in data:
categories = data.get('categories')
if not isinstance(categories, list):
raise ValueError('categories must be a list')
categories = [category for category in categories if category]
new_categories_json = json.dumps(categories) if categories else None

if 'is_active' in data:
new_is_active = bool(data.get('is_active'))

url_supplied = 'url' in data or 'webhook_url' in data
credential_supplied = url_supplied or any(
field in data for field in ('secret', 'chat_id', 'bot_token')
)
if credential_supplied:
credentials = conn.credentials()
if conn.kind == 'telegram':
if 'chat_id' in data:
chat_id = str(data.get('chat_id') or '').strip()
if not chat_id:
raise ValueError('telegram connection requires a chat_id')
credentials['chat_id'] = chat_id
if 'bot_token' in data:
bot_token = data.get('bot_token')
if bot_token in (None, ''):
credentials.pop('bot_token', None)
else:
credentials['bot_token'] = str(bot_token)
else:
if url_supplied:
url_value = data.get('url') if 'url' in data else data.get('webhook_url')
url = str(url_value or '').strip()
if not url:
raise ValueError(f'{conn.kind} connection requires a url')
credentials['url'] = url
if 'secret' in data:
secret = data.get('secret')
if secret in (None, ''):
credentials.pop('secret', None)
else:
credentials['secret'] = str(secret)

required = 'chat_id' if conn.kind == 'telegram' else 'url'
if not credentials.get(required): # pragma: no cover - corrupt legacy row
raise ValueError(f'{conn.kind} connection requires a {required}')
new_credentials_json = json.dumps({
key: encrypt_secret(str(value))
for key, value in credentials.items()
})

conn.name = new_name
conn.categories_json = new_categories_json
conn.is_active = new_is_active
conn.credentials_json = new_credentials_json
db.session.commit()
return conn

@classmethod
def set_default(cls, conn_id):
"""Select and activate the administrative default for one kind."""
conn = db.session.get(ChatWebhookConnection, conn_id)
if conn is None:
return None

for candidate in ChatWebhookConnection.query.filter_by(kind=conn.kind).all():
candidate.is_default = candidate.id == conn.id
conn.is_active = True
db.session.commit()
return conn

@classmethod
def delete(cls, conn_id):
"""Delete a connection. If it was its kind's default, promote the oldest
Expand Down Expand Up @@ -145,6 +248,60 @@ def active_for_category(category):
.all())
return [c for c in conns if c.matches_category(category)]

# ------------------------------------------------------------------
# Connection testing
# ------------------------------------------------------------------
@classmethod
def test(cls, conn_id):
"""Synchronously send a test through a connection's real formatter.

Inactive connections remain testable so an administrator can validate
a destination before enabling it. The transient notification is never
persisted; only the connection's latest test outcome is recorded.
"""
conn = db.session.get(ChatWebhookConnection, conn_id)
if conn is None:
return None

from app.notifications.models import Notification

message = 'This is a test notification from ServerKit.'
notification = Notification(
event_key='notification.test',
category='system',
severity=Notification.SEVERITY_TEST,
title='ServerKit test notification',
body=message,
audience=f'chat connection:{conn.id}',
created_at=datetime.utcnow(),
)
notification.set_data({'message': message})

try:
credentials = conn.credentials()
if conn.kind == 'webhook':
delivery_result = cls._deliver_webhook(
conn, credentials, notification)
else:
delivery_result = cls._deliver_chat(
conn, credentials, notification)
success = delivery_result.ok
except Exception: # pragma: no cover - defensive formatter guard
success = False

conn.last_tested_at = datetime.utcnow()
conn.last_test_ok = success
db.session.commit()

if success:
return {'success': True, 'message': 'Test notification sent'}
logger.warning(
'Chat connection test failed (id=%s, kind=%s)',
conn.id,
conn.kind,
)
return {'success': False, 'error': 'Test notification failed'}

# ------------------------------------------------------------------
# Delivery (called by the chat channel adapter for ``conn:<id>`` targets)
# ------------------------------------------------------------------
Expand Down
Loading