Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add notifications history webhook to plugins #1440

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
10 changes: 10 additions & 0 deletions app/lib/backend/preferences.dart
Original file line number Diff line number Diff line change
Expand Up @@ -436,4 +436,14 @@ class SharedPreferencesUtil {
String get customAuthPassword => getString('customAuthPassword') ?? '';

set customAuthPassword(String value) => saveString('customAuthPassword', value);

// Notifications History Webhook
static const String _notificationsHistoryToggledKey = 'notifications_history_toggled';
static const String _webhookNotificationsHistoryKey = 'webhook_notifications_history';

bool get notificationsHistoryToggled => _preferences?.getBool(_notificationsHistoryToggledKey) ?? false;
set notificationsHistoryToggled(bool value) => _preferences?.setBool(_notificationsHistoryToggledKey, value);

String get webhookNotificationsHistory => _preferences?.getString(_webhookNotificationsHistoryKey) ?? '';
set webhookNotificationsHistory(String value) => _preferences?.setString(_webhookNotificationsHistoryKey, value);
}
50 changes: 25 additions & 25 deletions app/lib/backend/schema/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,35 +78,46 @@ class AuthStep {
}

class ExternalIntegration {
String triggersOn;
String webhookUrl;
String? setupCompletedUrl;
String setupInstructionsFilePath;
bool isInstructionsUrl;
List<AuthStep> authSteps;
final String? triggersOn;
final String webhookUrl;
final String? setupCompletedUrl;
final String setupInstructionsFilePath;
final bool isInstructionsUrl;
final List<AuthStep> authSteps;
final String? notificationsHistoryWebhook;

ExternalIntegration({
required this.triggersOn,
this.triggersOn,
required this.webhookUrl,
required this.setupCompletedUrl,
this.setupCompletedUrl,
required this.setupInstructionsFilePath,
required this.isInstructionsUrl,
this.authSteps = const [],
this.isInstructionsUrl = false,
required this.authSteps,
this.notificationsHistoryWebhook,
});

factory ExternalIntegration.fromJson(Map<String, dynamic> json) {
return ExternalIntegration(
triggersOn: json['triggers_on'],
webhookUrl: json['webhook_url'],
setupCompletedUrl: json['setup_completed_url'],
isInstructionsUrl: json['is_instructions_url'] ?? false,
setupInstructionsFilePath: json['setup_instructions_file_path'],
authSteps: json['auth_steps'] == null
? []
: (json['auth_steps'] ?? []).map<AuthStep>((e) => AuthStep.fromJson(e)).toList(),
isInstructionsUrl: json['is_instructions_url'] ?? false,
authSteps: List<AuthStep>.from(json['auth_steps'].map((x) => AuthStep.fromJson(x))),
notificationsHistoryWebhook: json['notifications_history_webhook'],
);
}

Map<String, dynamic> toJson() => {
'triggers_on': triggersOn,
'webhook_url': webhookUrl,
'setup_completed_url': setupCompletedUrl,
'setup_instructions_file_path': setupInstructionsFilePath,
'is_instructions_url': isInstructionsUrl,
'auth_steps': authSteps.map((x) => x.toJson()).toList(),
'notifications_history_webhook': notificationsHistoryWebhook,
};

String getTriggerOnString() {
switch (triggersOn) {
case 'memory_creation':
Expand All @@ -117,17 +128,6 @@ class ExternalIntegration {
return 'Unknown';
}
}

toJson() {
return {
'triggers_on': triggersOn,
'webhook_url': webhookUrl,
'setup_completed_url': setupCompletedUrl,
'is_instructions_url': isInstructionsUrl,
'setup_instructions_file_path': setupInstructionsFilePath,
'auth_steps': authSteps.map((e) => e.toJson()).toList(),
};
}
}

class AppUsageHistory {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:friend_private/pages/apps/providers/add_app_provider.dart';
import 'package:friend_private/utils/other/validators.dart';
import 'package:provider/provider.dart';

class NotificationsHistoryWebhookField extends StatelessWidget {
const NotificationsHistoryWebhookField({super.key});

@override
Widget build(BuildContext context) {
return Consumer<AddAppProvider>(builder: (context, provider, child) {
if (!provider.isCapabilitySelectedById('proactive_notification')) {
return const SizedBox.shrink();
}
return Column(
children: [
const SizedBox(
height: 12,
),
Container(
decoration: BoxDecoration(
color: Colors.grey.shade900,
borderRadius: BorderRadius.circular(12.0),
),
padding: const EdgeInsets.all(14.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
'Notifications History Webhook URL (Optional)',
style: TextStyle(color: Colors.grey.shade300, fontSize: 16),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0),
margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6),
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(10.0),
),
width: double.infinity,
child: TextFormField(
validator: (value) {
if (value != null && value.isNotEmpty && !isValidUrl(value)) {
return 'Please enter a valid URL';
}
return null;
},
controller: provider.notificationsHistoryWebhookController,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: 'https://your-domain.com/notifications-webhook',
),
),
),
Padding(
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
child: Text(
'Receive notifications sent by this app to users',
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
),
),
],
),
),
],
);
});
}
}
21 changes: 20 additions & 1 deletion app/lib/pages/settings/developer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,26 @@ class _DeveloperSettingsPageState extends State<DeveloperSettingsPage> {
),
const SizedBox(height: 16),
],
onSectionEnabledChanged: provider.onTranscriptsToggled),
onSectionEnabledChanged: provider.onTranscriptsToggled,
),
ToggleSectionWidget(
isSectionEnabled: provider.notificationsHistoryToggled,
sectionTitle: 'Notifications History',
sectionDescription: 'Triggers when a notification is sent.',
options: [
TextField(
controller: provider.webhookNotificationsHistory,
obscureText: false,
autocorrect: false,
enabled: true,
enableSuggestions: false,
decoration: _getTextFieldDecoration('Endpoint URL'),
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
],
onSectionEnabledChanged: provider.onNotificationsHistoryToggled,
),
ToggleSectionWidget(
isSectionEnabled: provider.audioBytesToggled,
sectionTitle: 'Realtime Audio Bytes',
Expand Down
30 changes: 29 additions & 1 deletion app/lib/providers/developer_mode_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ class DeveloperModeProvider extends BaseProvider {
final TextEditingController webhookAudioBytesDelay = TextEditingController();
final TextEditingController webhookWsAudioBytes = TextEditingController();
final TextEditingController webhookDaySummary = TextEditingController();
final TextEditingController webhookNotificationsHistory = TextEditingController();

bool conversationEventsToggled = false;
bool transcriptsToggled = false;
bool audioBytesToggled = false;
bool daySummaryToggled = false;
bool notificationsHistoryToggled = false;

bool savingSettingsLoading = false;

Expand Down Expand Up @@ -71,23 +73,37 @@ class DeveloperModeProvider extends BaseProvider {
notifyListeners();
}

void onNotificationsHistoryToggled(bool value) async {
notificationsHistoryToggled = value;
SharedPreferencesUtil().notificationsHistoryToggled = value;
if (value) {
await enableWebhook(type: 'notifications_history');
} else {
await disableWebhook(type: 'notifications_history');
}
notifyListeners();
}

Future getWebhooksStatus() async {
var res = await webhooksStatus();
if (res == null) {
conversationEventsToggled = false;
transcriptsToggled = false;
audioBytesToggled = false;
daySummaryToggled = false;
notificationsHistoryToggled = false;
} else {
conversationEventsToggled = res['memory_created'];
transcriptsToggled = res['realtime_transcript'];
audioBytesToggled = res['audio_bytes'];
daySummaryToggled = res['day_summary'];
notificationsHistoryToggled = res['notifications_history'];
}
SharedPreferencesUtil().conversationEventsToggled = conversationEventsToggled;
SharedPreferencesUtil().transcriptsToggled = transcriptsToggled;
SharedPreferencesUtil().audioBytesToggled = audioBytesToggled;
SharedPreferencesUtil().daySummaryToggled = daySummaryToggled;
SharedPreferencesUtil().notificationsHistoryToggled = notificationsHistoryToggled;
notifyListeners();
}

Expand All @@ -105,6 +121,7 @@ class DeveloperModeProvider extends BaseProvider {
transcriptsToggled = SharedPreferencesUtil().transcriptsToggled;
audioBytesToggled = SharedPreferencesUtil().audioBytesToggled;
daySummaryToggled = SharedPreferencesUtil().daySummaryToggled;
notificationsHistoryToggled = SharedPreferencesUtil().notificationsHistoryToggled;

await Future.wait([
getWebhooksStatus(),
Expand Down Expand Up @@ -132,6 +149,10 @@ class DeveloperModeProvider extends BaseProvider {
webhookDaySummary.text = url;
SharedPreferencesUtil().webhookDaySummary = url;
}),
getUserWebhookUrl(type: 'notifications_history').then((url) {
webhookNotificationsHistory.text = url;
SharedPreferencesUtil().webhookNotificationsHistory = url;
}),
]);
// getUserWebhookUrl(type: 'audio_bytes_websocket').then((url) => webhookWsAudioBytes.text = url);
setIsLoading(false);
Expand Down Expand Up @@ -184,6 +205,11 @@ class DeveloperModeProvider extends BaseProvider {
setIsLoading(false);
return;
}
if (webhookNotificationsHistory.text.isNotEmpty && !isValidUrl(webhookNotificationsHistory.text)) {
AppSnackbar.showSnackbarError('Invalid notifications history webhook URL');
setIsLoading(false);
return;
}

// if (webhookWsAudioBytes.text.isNotEmpty && !isValidWebSocketUrl(webhookWsAudioBytes.text)) {
// AppSnackbar.showSnackbarError('Invalid audio bytes websocket URL');
Expand All @@ -198,14 +224,16 @@ class DeveloperModeProvider extends BaseProvider {
var w2 = setUserWebhookUrl(type: 'realtime_transcript', url: webhookOnTranscriptReceived.text.trim());
var w3 = setUserWebhookUrl(type: 'memory_created', url: webhookOnConversationCreated.text.trim());
var w4 = setUserWebhookUrl(type: 'day_summary', url: webhookDaySummary.text.trim());
var w5 = setUserWebhookUrl(type: 'notifications_history', url: webhookNotificationsHistory.text.trim());
// var w4 = setUserWebhookUrl(type: 'audio_bytes_websocket', url: webhookWsAudioBytes.text.trim());
try {
Future.wait([w1, w2, w3, w4]);
Future.wait([w1, w2, w3, w4, w5]);
prefs.webhookAudioBytes = webhookAudioBytes.text;
prefs.webhookAudioBytesDelay = webhookAudioBytesDelay.text;
prefs.webhookOnTranscriptReceived = webhookOnTranscriptReceived.text;
prefs.webhookOnConversationCreated = webhookOnConversationCreated.text;
prefs.webhookDaySummary = webhookDaySummary.text;
prefs.webhookNotificationsHistory = webhookNotificationsHistory.text;
} catch (e) {
Logger.error('Error occurred while updating endpoints: $e');
}
Expand Down
1 change: 1 addition & 0 deletions backend/models/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ class WebhookType(str, Enum):
realtime_transcript = 'realtime_transcript'
memory_created = 'memory_created',
day_summary = 'day_summary'
notifications_history = 'notifications_history'
16 changes: 16 additions & 0 deletions backend/utils/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from models.memory import Memory, MemorySource
from models.notification_message import NotificationMessage
from models.plugin import Plugin, UsageHistoryType
from utils.webhooks import notifications_history_webhook
from utils.apps import get_available_apps, weighted_rating
from utils.notifications import send_notification
from utils.llm import (
Expand Down Expand Up @@ -317,6 +318,18 @@ def _process_proactive_notification(uid: str, token: str, plugin: App, data):
print(f"Plugins {plugin.id}, message too short", uid)
return None

# Create notification message
notification_message = NotificationMessage(
plugin_id=plugin.id,
from_integration='plugin',
type='proactive',
notification_type='info',
text=message,
)

# Send to notifications history webhook
notifications_history_webhook(uid, notification_message)

# send notification
send_plugin_notification(token, plugin.name, plugin.id, message)

Expand Down Expand Up @@ -404,4 +417,7 @@ def send_plugin_notification(token: str, app_name: str, app_id: str, message: st
navigate_to=f'/chat/{app_id}',
)

# Send to notifications history webhook
notifications_history_webhook(token, ai_message)

send_notification(token, app_name + ' says', message, NotificationMessage.get_message_as_dict(ai_message))
37 changes: 37 additions & 0 deletions backend/utils/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
from typing import List

import requests
from models.notification_message import NotificationMessage
import websockets

from database.redis_db import get_user_webhook_db, user_webhook_status_db, disable_user_webhook_db, \
enable_user_webhook_db, set_user_webhook_db
from database.apps import get_public_apps_db, get_private_apps_db
from models.memory import Memory
from models.users import WebhookType
import database.notifications as notification_db
Expand Down Expand Up @@ -165,3 +167,38 @@ def webhook_first_time_setup(uid: str, wType: WebhookType) -> bool:

def send_webhook_notification(token: str, message: str):
send_notification(token, "Webhook" + ' says', message)

def notifications_history_webhook(uid: str, notification: NotificationMessage):
"""Send notification to webhook if configured"""
# First try app-specific webhook if notification is from an app
if notification.plugin_id:
# Get app from both public and private apps
apps = get_public_apps_db(uid) + get_private_apps_db(uid)
app = next((app for app in apps if app['id'] == notification.plugin_id), None)

if app and app.get('external_integration') and app['external_integration'].get('notifications_history_webhook'):
try:
requests.post(
app['external_integration']['notifications_history_webhook'],
json=notification.dict(),
params={'uid': uid},
timeout=5
)
return
except Exception as e:
print(f"Error sending notification to app webhook: {e}")

# Fallback to global webhook from developer settings
webhook_url = get_user_webhook_db(uid, WebhookType.notifications_history)
if not webhook_url:
return

try:
requests.post(
webhook_url,
json=notification.dict(),
params={'uid': uid},
timeout=5
)
except Exception as e:
print(f"Error sending notification to global webhook: {e}")
Loading