forked from bkinnightskytw/code_work_spawner
335 lines
9.8 KiB
Dart
335 lines
9.8 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import '../config/config.dart';
|
|
|
|
typedef NotificationLogger = void Function(String message);
|
|
typedef DesktopNotificationCommandRunner =
|
|
Future<ProcessResult> Function({
|
|
required String executable,
|
|
required List<String> arguments,
|
|
});
|
|
|
|
class NotificationContext {
|
|
const NotificationContext({
|
|
required this.projectKey,
|
|
required this.repo,
|
|
required this.issueNumber,
|
|
required this.issueTitle,
|
|
required this.issueUrl,
|
|
required this.trigger,
|
|
this.mode,
|
|
this.responderId,
|
|
this.decision,
|
|
this.summary,
|
|
this.errorSummary,
|
|
});
|
|
|
|
final String projectKey;
|
|
final String repo;
|
|
final int issueNumber;
|
|
final String issueTitle;
|
|
final String issueUrl;
|
|
final String trigger;
|
|
final String? mode;
|
|
final String? responderId;
|
|
final String? decision;
|
|
final String? summary;
|
|
final String? errorSummary;
|
|
}
|
|
|
|
abstract interface class Notifier {
|
|
Future<void> notify({
|
|
required NotificationEvent event,
|
|
required NotificationContext context,
|
|
});
|
|
}
|
|
|
|
enum DesktopNotificationPlatform { linux, macos, windows, unsupported }
|
|
|
|
class DesktopNotificationCommand {
|
|
const DesktopNotificationCommand({
|
|
required this.executable,
|
|
required this.arguments,
|
|
});
|
|
|
|
final String executable;
|
|
final List<String> arguments;
|
|
}
|
|
|
|
class NotifierRegistry {
|
|
NotifierRegistry({required this.notifiersByEvent, required this.log});
|
|
|
|
final Map<NotificationEvent, List<Notifier>> notifiersByEvent;
|
|
final NotificationLogger log;
|
|
|
|
factory NotifierRegistry.fromConfigs(
|
|
List<NotificationConfig> configs, {
|
|
required NotificationLogger log,
|
|
}) {
|
|
final notifiersByEvent = <NotificationEvent, List<Notifier>>{};
|
|
for (final config in configs) {
|
|
if (!config.enabled) {
|
|
continue;
|
|
}
|
|
|
|
final notifier = switch (config.type) {
|
|
NotificationType.discordWebhook => DiscordWebhookNotifier(config),
|
|
NotificationType.desktop => DesktopNotifier(config),
|
|
};
|
|
for (final event in config.events) {
|
|
notifiersByEvent.putIfAbsent(event, () => <Notifier>[]).add(notifier);
|
|
}
|
|
}
|
|
return NotifierRegistry(notifiersByEvent: notifiersByEvent, log: log);
|
|
}
|
|
|
|
Future<void> notify({
|
|
required NotificationEvent event,
|
|
required NotificationContext context,
|
|
}) async {
|
|
final notifiers = notifiersByEvent[event];
|
|
if (notifiers == null || notifiers.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
for (final notifier in notifiers) {
|
|
try {
|
|
await notifier.notify(event: event, context: context);
|
|
} catch (error) {
|
|
log('notification_failed event=${event.name} error=$error');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class DiscordWebhookNotifier implements Notifier {
|
|
DiscordWebhookNotifier(this.config, {HttpClient? httpClient})
|
|
: _httpClient = httpClient ?? HttpClient();
|
|
|
|
final NotificationConfig config;
|
|
final HttpClient _httpClient;
|
|
|
|
@override
|
|
Future<void> notify({
|
|
required NotificationEvent event,
|
|
required NotificationContext context,
|
|
}) async {
|
|
final webhookUrl = config.webhookUrl;
|
|
if (webhookUrl == null || webhookUrl.isEmpty) {
|
|
throw const FormatException(
|
|
'discordWebhook notifier requires webhookUrl.',
|
|
);
|
|
}
|
|
|
|
final uri = Uri.parse(webhookUrl);
|
|
final request = await _httpClient.postUrl(uri);
|
|
request.headers.contentType = ContentType.json;
|
|
request.write(
|
|
jsonEncode({
|
|
'content': formatDiscordNotificationMessage(
|
|
event: event,
|
|
context: context,
|
|
),
|
|
if (config.username != null && config.username!.isNotEmpty)
|
|
'username': config.username,
|
|
if (config.avatarUrl != null && config.avatarUrl!.isNotEmpty)
|
|
'avatar_url': config.avatarUrl,
|
|
}),
|
|
);
|
|
|
|
final response = await request.close();
|
|
final responseBody = await utf8.decoder.bind(response).join();
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
throw HttpException(
|
|
'Discord webhook returned ${response.statusCode}: $responseBody',
|
|
uri: uri,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
class DesktopNotifier implements Notifier {
|
|
DesktopNotifier(
|
|
this.config, {
|
|
DesktopNotificationPlatform? platform,
|
|
DesktopNotificationCommandRunner? commandRunner,
|
|
}) : _platform = platform ?? detectDesktopNotificationPlatform(),
|
|
_commandRunner = commandRunner ?? _runDesktopNotificationCommand;
|
|
|
|
final NotificationConfig config;
|
|
final DesktopNotificationPlatform _platform;
|
|
final DesktopNotificationCommandRunner _commandRunner;
|
|
|
|
@override
|
|
Future<void> notify({
|
|
required NotificationEvent event,
|
|
required NotificationContext context,
|
|
}) async {
|
|
final command = _buildCommand(event: event, context: context);
|
|
final result = await _commandRunner(
|
|
executable: command.executable,
|
|
arguments: command.arguments,
|
|
);
|
|
if (result.exitCode != 0) {
|
|
throw ProcessException(
|
|
command.executable,
|
|
command.arguments,
|
|
'${result.stdout}\n${result.stderr}',
|
|
result.exitCode,
|
|
);
|
|
}
|
|
}
|
|
|
|
DesktopNotificationCommand _buildCommand({
|
|
required NotificationEvent event,
|
|
required NotificationContext context,
|
|
}) {
|
|
final title = 'code_work_spawner: ${notificationHeadline(event)}';
|
|
final body = formatDesktopNotificationMessage(
|
|
event: event,
|
|
context: context,
|
|
);
|
|
return switch (_platform) {
|
|
DesktopNotificationPlatform.linux => DesktopNotificationCommand(
|
|
executable: 'notify-send',
|
|
arguments: [title, body],
|
|
),
|
|
DesktopNotificationPlatform.macos => DesktopNotificationCommand(
|
|
executable: 'osascript',
|
|
arguments: [
|
|
'-e',
|
|
'display notification ${_appleScriptLiteral(body)} '
|
|
'with title ${_appleScriptLiteral(title)}',
|
|
],
|
|
),
|
|
DesktopNotificationPlatform.windows => DesktopNotificationCommand(
|
|
executable: 'powershell',
|
|
arguments: [
|
|
'-NoProfile',
|
|
'-Command',
|
|
_buildWindowsToastCommand(title: title, body: body),
|
|
],
|
|
),
|
|
DesktopNotificationPlatform.unsupported => throw UnsupportedError(
|
|
'Desktop notifications are not supported on this platform.',
|
|
),
|
|
};
|
|
}
|
|
}
|
|
|
|
DesktopNotificationPlatform detectDesktopNotificationPlatform() {
|
|
if (Platform.isLinux) {
|
|
return DesktopNotificationPlatform.linux;
|
|
}
|
|
if (Platform.isMacOS) {
|
|
return DesktopNotificationPlatform.macos;
|
|
}
|
|
if (Platform.isWindows) {
|
|
return DesktopNotificationPlatform.windows;
|
|
}
|
|
return DesktopNotificationPlatform.unsupported;
|
|
}
|
|
|
|
Future<ProcessResult> _runDesktopNotificationCommand({
|
|
required String executable,
|
|
required List<String> arguments,
|
|
}) {
|
|
return Process.run(executable, arguments);
|
|
}
|
|
|
|
String notificationHeadline(NotificationEvent event) {
|
|
return switch (event) {
|
|
NotificationEvent.mentionDetected => 'Mention detected',
|
|
NotificationEvent.replyPosted => 'Reply posted',
|
|
NotificationEvent.noReply => 'No reply',
|
|
NotificationEvent.responderFailed => 'Responder failed',
|
|
};
|
|
}
|
|
|
|
List<String> _notificationDetailLines(NotificationContext context) {
|
|
return <String>[
|
|
'${context.projectKey}#${context.issueNumber} ${context.issueTitle}',
|
|
context.issueUrl,
|
|
'repo: ${context.repo}',
|
|
'trigger: ${context.trigger}',
|
|
if (context.mode != null) 'mode: ${context.mode}',
|
|
if (context.responderId != null) 'responder: ${context.responderId}',
|
|
if (context.decision != null) 'decision: ${context.decision}',
|
|
if (context.summary != null && context.summary!.isNotEmpty)
|
|
'summary: ${context.summary}',
|
|
if (context.errorSummary != null && context.errorSummary!.isNotEmpty)
|
|
'error: ${context.errorSummary}',
|
|
];
|
|
}
|
|
|
|
String formatDesktopNotificationMessage({
|
|
required NotificationEvent event,
|
|
required NotificationContext context,
|
|
}) {
|
|
return '${notificationHeadline(event)}\n${_notificationDetailLines(context).join('\n')}';
|
|
}
|
|
|
|
String formatDiscordNotificationMessage({
|
|
required NotificationEvent event,
|
|
required NotificationContext context,
|
|
}) {
|
|
final details = <String>[
|
|
'**${notificationHeadline(event)}**',
|
|
'`${context.projectKey}#${context.issueNumber}` ${context.issueTitle}',
|
|
context.issueUrl,
|
|
'repo: `${context.repo}`',
|
|
'trigger: `${context.trigger}`',
|
|
if (context.mode != null) 'mode: `${context.mode}`',
|
|
if (context.responderId != null) 'responder: `${context.responderId}`',
|
|
if (context.decision != null) 'decision: `${context.decision}`',
|
|
if (context.summary != null && context.summary!.isNotEmpty)
|
|
'summary: ${context.summary}',
|
|
if (context.errorSummary != null && context.errorSummary!.isNotEmpty)
|
|
'error: ${context.errorSummary}',
|
|
];
|
|
return details.join('\n');
|
|
}
|
|
|
|
String _appleScriptLiteral(String value) {
|
|
return '"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"';
|
|
}
|
|
|
|
String _buildWindowsToastCommand({
|
|
required String title,
|
|
required String body,
|
|
}) {
|
|
final escapedTitle = _escapeXmlText(title);
|
|
final escapedBody = _escapeXmlText(body);
|
|
return r'''
|
|
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
|
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
|
$template = @"
|
|
<toast>
|
|
<visual>
|
|
<binding template="ToastGeneric">
|
|
<text>''' +
|
|
escapedTitle +
|
|
r'''</text>
|
|
<text>''' +
|
|
escapedBody +
|
|
r'''</text>
|
|
</binding>
|
|
</visual>
|
|
</toast>
|
|
"@
|
|
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
$xml.LoadXml($template)
|
|
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
|
$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('code_work_spawner')
|
|
$notifier.Show($toast)
|
|
''';
|
|
}
|
|
|
|
String _escapeXmlText(String value) {
|
|
return value
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>');
|
|
}
|