add webhook

This commit is contained in:
Genteure 2020-12-20 20:56:40 +08:00
parent 456d851847
commit f175aac1df
14 changed files with 214 additions and 88 deletions

View File

@ -5,12 +5,18 @@ root = true
# all files
[*]
indent_style = space
indent_size = 4
charset = utf-8
insert_final_newline = true
[NLog.{Debug,Release}.config]
indent_size = 2
[.csproj]
indent_size = 2
[*.cs]
@ -41,8 +47,8 @@ csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
#require members of object intializers to be on separate lines
csharp_new_line_before_members_in_object_initializers = true
#require braces to be on a new line for anonymous_types, lambdas, properties, accessors, methods, object_collection_array_initializers, control_blocks, and types (also known as "Allman" style)
csharp_new_line_before_open_brace = anonymous_types, lambdas, properties, accessors, methods, object_collection_array_initializers, control_blocks, types
#require braces to be on a new line for methods and types (also known as "Allman" style)
csharp_new_line_before_open_brace = all
#Formatting - organize using options
@ -80,7 +86,7 @@ csharp_preserve_single_line_statements = true
#Style - Code block preferences
#prefer curly braces even for one line of code
csharp_prefer_braces = true:suggestion
csharp_prefer_braces = false:suggestion
#Style - expression bodied member options
@ -90,8 +96,8 @@ csharp_style_expression_bodied_accessors = true:suggestion
csharp_style_expression_bodied_constructors = false:suggestion
#prefer expression-bodied members for indexers
csharp_style_expression_bodied_indexers = true:suggestion
#prefer block bodies for methods
csharp_style_expression_bodied_methods = false:suggestion
#prefer expression-bodied members for methods
csharp_style_expression_bodied_methods = true:suggestion
#prefer expression-bodied members for properties
csharp_style_expression_bodied_properties = true:suggestion
@ -149,11 +155,11 @@ csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
#Style - qualification options
#prefer events not to be prefaced with this. or Me. in Visual Basic
dotnet_style_qualification_for_event = false:suggestion
#prefer fields not to be prefaced with this. or Me. in Visual Basic
dotnet_style_qualification_for_field = false:suggestion
#prefer methods not to be prefaced with this. or Me. in Visual Basic
dotnet_style_qualification_for_method = false:suggestion
#prefer properties not to be prefaced with this. or Me. in Visual Basic
dotnet_style_qualification_for_property = false:suggestion
#prefer events to be prefaced with this. in C# or Me. in Visual Basic
dotnet_style_qualification_for_event = true:suggestion
#prefer fields to be prefaced with this. in C# or Me. in Visual Basic
dotnet_style_qualification_for_field = true:suggestion
#prefer methods to be prefaced with this. in C# or Me. in Visual Basic
dotnet_style_qualification_for_method = true:suggestion
#prefer properties to be prefaced with this. in C# or Me. in Visual Basic
dotnet_style_qualification_for_property = true:suggestion

View File

@ -1,16 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk" ToolsVersion="15.0">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>9.0</LangVersion>
<Version>0.0.0.0</Version>
<Authors>Genteure</Authors>
<Company>Genteure</Company>
<Copyright>Copyright © 2018 - 2021 Genteure</Copyright>
<AssemblyVersion>0.0.0.0</AssemblyVersion>
<FileVersion>0.0.0.0</FileVersion>
<FileUpgradeFlags>
</FileUpgradeFlags>
<UpgradeBackupLocation>
</UpgradeBackupLocation>
<OldToolsVersion>2.0</OldToolsVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

View File

@ -0,0 +1,64 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config;
using Newtonsoft.Json;
using NLog;
#nullable enable
namespace BililiveRecorder.Core.Callback
{
public class BasicWebhook
{
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private static readonly HttpClient client;
private readonly ConfigV1 Config;
static BasicWebhook()
{
client = new HttpClient();
client.DefaultRequestHeaders.Add("User-Agent", $"BililiveRecorder/{typeof(BasicWebhook).Assembly.GetName().Version}-{BuildInfo.HeadShaShort}");
}
public BasicWebhook(ConfigV1 config)
{
this.Config = config ?? throw new ArgumentNullException(nameof(config));
}
public async void Send(RecordEndData data)
{
var urls = this.Config.WebHookUrls;
if (string.IsNullOrWhiteSpace(urls)) return;
var dataStr = JsonConvert.SerializeObject(data, Formatting.None);
using var body = new ByteArrayContent(Encoding.UTF8.GetBytes(dataStr));
body.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
var tasks = urls
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => this.SendImplAsync(x, body));
await Task.WhenAll(tasks).ConfigureAwait(false);
}
private async Task SendImplAsync(string url, HttpContent data)
{
for (var i = 0; i < 3; i++)
try
{
var result = await client.PostAsync(url, data).ConfigureAwait(false);
result.EnsureSuccessStatusCode();
return;
}
catch (Exception ex)
{
logger.Warn(ex, "发送 Webhook 到 {url} 失败", url);
}
}
}
}

View File

@ -0,0 +1,18 @@
using System;
#nullable enable
namespace BililiveRecorder.Core.Callback
{
public class RecordEndData
{
public Guid EventRandomId { get; set; } = Guid.NewGuid();
public int RoomId { get; set; } = 0;
public string Name { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public long FileSize { get; set; }
public DateTimeOffset StartRecordTime { get; set; }
public DateTimeOffset EndRecordTime { get; set; }
}
}

View File

@ -148,6 +148,16 @@ namespace BililiveRecorder.Core.Config
set => SetField(ref _clip_filename_format, value);
}
/// <summary>
/// Webhook 地址 每行一个
/// </summary>
[JsonProperty("webhook_urls")]
public string WebHookUrls
{
get => _webhook_urls;
set => SetField(ref _webhook_urls, value);
}
#region INotifyPropertyChanged
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
@ -186,5 +196,7 @@ namespace BililiveRecorder.Core.Config
private uint _recordDanmakuFlushInterval = 20;
private string _liveApiHost = "https://api.live.bilibili.com";
private string _webhook_urls = "";
}
}

View File

@ -1,5 +1,6 @@
using System.Net.Sockets;
using Autofac;
using BililiveRecorder.Core.Callback;
using BililiveRecorder.Core.Config;
namespace BililiveRecorder.Core
@ -15,6 +16,7 @@ namespace BililiveRecorder.Core
{
builder.RegisterType<ConfigV1>().AsSelf().InstancePerMatchingLifetimeScope("recorder_root");
builder.RegisterType<BililiveAPI>().AsSelf().InstancePerMatchingLifetimeScope("recorder_root");
builder.RegisterType<BasicWebhook>().AsSelf().InstancePerMatchingLifetimeScope("recorder_root");
builder.RegisterType<TcpClient>().AsSelf().ExternallyOwned();
builder.RegisterType<StreamMonitor>().As<IStreamMonitor>().ExternallyOwned();
builder.RegisterType<RecordedRoom>().As<IRecordedRoom>().ExternallyOwned();

View File

@ -1,7 +1,9 @@
using System;
using System.ComponentModel;
using BililiveRecorder.Core.Callback;
using BililiveRecorder.FlvProcessor;
#nullable enable
namespace BililiveRecorder.Core
{
public interface IRecordedRoom : INotifyPropertyChanged, IDisposable
@ -11,9 +13,12 @@ namespace BililiveRecorder.Core
int ShortRoomId { get; }
int RoomId { get; }
string StreamerName { get; }
string Title { get; }
event EventHandler<RecordEndData>? RecordEnded;
IStreamMonitor StreamMonitor { get; }
IFlvStreamProcessor Processor { get; }
IFlvStreamProcessor? Processor { get; }
bool IsMonitoring { get; }
bool IsRecording { get; }

View File

@ -1,3 +1,4 @@
using BililiveRecorder.Core.Callback;
using BililiveRecorder.Core.Config;
using BililiveRecorder.FlvProcessor;
using NLog;
@ -56,7 +57,6 @@ namespace BililiveRecorder.Core
TriggerPropertyChanged(nameof(StreamerName));
}
}
public string Title
{
get => _title;
@ -82,6 +82,9 @@ namespace BililiveRecorder.Core
}
}
private RecordEndData recordEndData;
public event EventHandler<RecordEndData> RecordEnded;
private readonly IBasicDanmakuWriter basicDanmakuWriter;
private readonly Func<IFlvStreamProcessor> newIFlvStreamProcessor;
private IFlvStreamProcessor _processor;
@ -191,10 +194,7 @@ namespace BililiveRecorder.Core
public bool Start()
{
if (disposedValue)
{
throw new ObjectDisposedException(nameof(RecordedRoom));
}
if (disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
var r = StreamMonitor.Start();
TriggerPropertyChanged(nameof(IsMonitoring));
@ -203,10 +203,7 @@ namespace BililiveRecorder.Core
public void Stop()
{
if (disposedValue)
{
throw new ObjectDisposedException(nameof(RecordedRoom));
}
if (disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
StreamMonitor.Stop();
TriggerPropertyChanged(nameof(IsMonitoring));
@ -214,41 +211,26 @@ namespace BililiveRecorder.Core
public void RefreshRoomInfo()
{
if (disposedValue)
{
throw new ObjectDisposedException(nameof(RecordedRoom));
}
if (disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
StreamMonitor.FetchRoomInfoAsync();
}
private void StreamMonitor_StreamStarted(object sender, StreamStartedArgs e)
{
lock (StartupTaskLock)
{
if (!IsRecording && (StartupTask?.IsCompleted ?? true))
{
StartupTask = _StartRecordAsync();
}
}
}
public void StartRecord()
{
if (disposedValue)
{
throw new ObjectDisposedException(nameof(RecordedRoom));
}
if (disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
StreamMonitor.Check(TriggerType.Manual);
}
public void StopRecord()
{
if (disposedValue)
{
throw new ObjectDisposedException(nameof(RecordedRoom));
}
if (disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
_retry = false;
try
@ -347,6 +329,16 @@ namespace BililiveRecorder.Core
Processor.ClipLengthPast = _config.ClipLengthPast;
Processor.CuttingNumber = _config.CuttingNumber;
Processor.StreamFinalized += (sender, e) => { basicDanmakuWriter.Disable(); };
Processor.FileFinalized += (sender, size) =>
{
if (recordEndData is null) return;
var data = recordEndData;
recordEndData = null;
data.EndRecordTime = DateTimeOffset.Now;
data.FileSize = size;
RecordEnded?.Invoke(this, data);
};
Processor.OnMetaData += (sender, e) =>
{
e.Metadata["BililiveRecorder"] = new Dictionary<string, object>()
@ -484,40 +476,43 @@ namespace BililiveRecorder.Core
}
// Called by API or GUI
public void Clip()
{
Processor?.Clip();
}
public void Clip() => Processor?.Clip();
public void Shutdown()
{
Dispose(true);
}
public void Shutdown() => Dispose(true);
private string GetStreamFilePath()
private (string fullPath, string relativePath) GetStreamFilePath()
{
string path = FormatFilename(_config.RecordFilenameFormat);
var path = FormatFilename(_config.RecordFilenameFormat);
// 有点脏的写法,不过凑合吧
if (_config.RecordDanmaku)
{
var xmlpath = Path.ChangeExtension(path, "xml");
var xmlpath = Path.ChangeExtension(path.fullPath, "xml");
basicDanmakuWriter.EnableWithPath(xmlpath, this);
}
recordEndData = new RecordEndData
{
RoomId = RoomId,
Title = Title,
Name = StreamerName,
StartRecordTime = DateTimeOffset.Now,
RelativePath = path.relativePath,
};
return path;
}
private string GetClipFilePath() => FormatFilename(_config.ClipFilenameFormat);
private string GetClipFilePath() => FormatFilename(_config.ClipFilenameFormat).fullPath;
private string FormatFilename(string formatString)
private (string fullPath, string relativePath) FormatFilename(string formatString)
{
DateTime now = DateTime.Now;
string date = now.ToString("yyyyMMdd");
string time = now.ToString("HHmmss");
string randomStr = random.Next(100, 999).ToString();
var filename = formatString
var relativePath = formatString
.Replace(@"{date}", date)
.Replace(@"{time}", time)
.Replace(@"{random}", randomStr)
@ -525,26 +520,28 @@ namespace BililiveRecorder.Core
.Replace(@"{title}", Title.RemoveInvalidFileName())
.Replace(@"{name}", StreamerName.RemoveInvalidFileName());
if (!filename.EndsWith(".flv", StringComparison.OrdinalIgnoreCase))
filename += ".flv";
if (!relativePath.EndsWith(".flv", StringComparison.OrdinalIgnoreCase))
relativePath += ".flv";
filename = filename.RemoveInvalidFileName(ignore_slash: true);
filename = Path.Combine(_config.WorkDirectory, filename);
filename = Path.GetFullPath(filename);
relativePath = relativePath.RemoveInvalidFileName(ignore_slash: true);
var fullPath = Path.Combine(_config.WorkDirectory, relativePath);
fullPath = Path.GetFullPath(fullPath);
if (!CheckPath(_config.WorkDirectory, Path.GetDirectoryName(filename)))
if (!CheckPath(_config.WorkDirectory, Path.GetDirectoryName(fullPath)))
{
logger.Log(RoomId, LogLevel.Warn, "录制文件位置超出允许范围,请检查设置。将写入到默认路径。");
filename = Path.Combine(_config.WorkDirectory, RoomId.ToString(), $"{RoomId}-{date}-{time}-{randomStr}.flv");
relativePath = Path.Combine(RoomId.ToString(), $"{RoomId}-{date}-{time}-{randomStr}.flv");
fullPath = Path.Combine(_config.WorkDirectory, relativePath);
}
if (new FileInfo(filename).Exists)
if (new FileInfo(relativePath).Exists)
{
logger.Log(RoomId, LogLevel.Warn, "录制文件名冲突,请检查设置。将写入到默认路径。");
filename = Path.Combine(_config.WorkDirectory, RoomId.ToString(), $"{RoomId}-{date}-{time}-{randomStr}.flv");
relativePath = Path.Combine(RoomId.ToString(), $"{RoomId}-{date}-{time}-{randomStr}.flv");
fullPath = Path.Combine(_config.WorkDirectory, relativePath);
}
return filename;
return (fullPath, relativePath);
}
private static bool CheckPath(string parent, string child)

View File

@ -7,6 +7,7 @@ using System.ComponentModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Core.Callback;
using BililiveRecorder.Core.Config;
using NLog;
@ -26,14 +27,17 @@ namespace BililiveRecorder.Core
public ConfigV1 Config { get; }
private BasicWebhook Webhook { get; }
public int Count => Rooms.Count;
public bool IsReadOnly => true;
public IRecordedRoom this[int index] => Rooms[index];
public Recorder(ConfigV1 config, Func<int, IRecordedRoom> iRecordedRoom)
public Recorder(ConfigV1 config, BasicWebhook webhook, Func<int, IRecordedRoom> iRecordedRoom)
{
newIRecordedRoom = iRecordedRoom;
Config = config;
newIRecordedRoom = iRecordedRoom ?? throw new ArgumentNullException(nameof(iRecordedRoom));
Config = config ?? throw new ArgumentNullException(nameof(config));
Webhook = webhook ?? throw new ArgumentNullException(nameof(webhook));
tokenSource = new CancellationTokenSource();
Repeat.Interval(TimeSpan.FromSeconds(3), DownloadWatchdog, tokenSource.Token);
@ -96,6 +100,7 @@ namespace BililiveRecorder.Core
}
logger.Debug("AddRoom 添加了 {roomid} 直播间 ", rr.RoomId);
rr.RecordEnded += this.RecordedRoom_RecordEnded;
Rooms.Add(rr);
}
catch (Exception ex)
@ -113,6 +118,7 @@ namespace BililiveRecorder.Core
if (rr is null) return;
if (!_valid) { throw new InvalidOperationException("Not Initialized"); }
rr.Shutdown();
rr.RecordEnded -= RecordedRoom_RecordEnded;
logger.Debug("RemoveRoom 移除了直播间 {roomid}", rr.RoomId);
Rooms.Remove(rr);
}
@ -133,6 +139,8 @@ namespace BililiveRecorder.Core
Rooms.Clear();
}
private void RecordedRoom_RecordEnded(object sender, RecordEndData e) => Webhook.Send(e);
public void SaveConfigToFile()
{
Config.RoomList = new List<RoomV1>();

View File

@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk" ToolsVersion="15.0">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>9.0</LangVersion>
<Version>0.0.0.0</Version>
<Authors>Genteure</Authors>
<Company>Genteure</Company>

View File

@ -1,9 +1,9 @@
using NLog;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using NLog;
namespace BililiveRecorder.FlvProcessor
{
@ -50,16 +50,16 @@ namespace BililiveRecorder.FlvProcessor
public int TotalMaxTimestamp { get; private set; } = 0;
public int CurrentMaxTimestamp { get => TotalMaxTimestamp - _writeTimeStamp; }
public DateTime StartDateTime { get; private set; } = DateTime.Now;
private readonly Func<IFlvClipProcessor> funcFlvClipProcessor;
private readonly Func<byte[], IFlvMetadata> funcFlvMetadata;
private readonly Func<IFlvTag> funcFlvTag;
private Func<string> GetStreamFileName;
private Func<(string fullPath, string relativePath)> GetStreamFileName;
private Func<string> GetClipFileName;
public event TagProcessedEvent TagProcessed;
public event EventHandler<long> FileFinalized;
public event StreamFinalizedEvent StreamFinalized;
public event FlvMetadataEvent OnMetaData;
@ -81,7 +81,7 @@ namespace BililiveRecorder.FlvProcessor
}
public IFlvStreamProcessor Initialize(Func<string> getStreamFileName, Func<string> getClipFileName, EnabledFeature enabledFeature, AutoCuttingMode autoCuttingMode)
public IFlvStreamProcessor Initialize(Func<(string fullPath, string relativePath)> getStreamFileName, Func<string> getClipFileName, EnabledFeature enabledFeature, AutoCuttingMode autoCuttingMode)
{
GetStreamFileName = getStreamFileName;
GetClipFileName = getClipFileName;
@ -93,10 +93,10 @@ namespace BililiveRecorder.FlvProcessor
private void OpenNewRecordFile()
{
string path = GetStreamFileName();
logger.Debug("打开新录制文件: " + path);
try { Directory.CreateDirectory(Path.GetDirectoryName(path)); } catch (Exception) { }
_targetFile = new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite);
var (fullPath, relativePath) = GetStreamFileName();
logger.Debug("打开新录制文件: " + fullPath);
try { Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); } catch (Exception) { }
_targetFile = new FileStream(fullPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read | FileShare.Delete);
if (_headerParsed)
{
@ -309,7 +309,6 @@ namespace BililiveRecorder.FlvProcessor
{
_baseTimeStamp = tag.TimeStamp;
_hasOffset = true;
StartDateTime = DateTime.Now;
logger.Debug("重设时间戳 {0} 毫秒", _baseTimeStamp);
}
}
@ -325,7 +324,6 @@ namespace BililiveRecorder.FlvProcessor
{
_baseTimeStamp = tag.TimeStamp;
_hasOffset = true;
StartDateTime = DateTime.Now;
logger.Debug("重设时间戳 {0} 毫秒", _baseTimeStamp);
}
}
@ -374,6 +372,7 @@ namespace BililiveRecorder.FlvProcessor
{
try
{
var fileSize = _targetFile?.Length ?? -1;
logger.Debug("正在关闭当前录制文件: " + _targetFile?.Name);
Metadata["duration"] = CurrentMaxTimestamp / 1000.0;
Metadata["lasttimestamp"] = (double)CurrentMaxTimestamp;
@ -383,6 +382,9 @@ namespace BililiveRecorder.FlvProcessor
// 11 for 1st tag header
_targetFile?.Seek(13 + 11, SeekOrigin.Begin);
_targetFile?.Write(metadata, 0, metadata.Length);
if (fileSize > 0)
FileFinalized?.Invoke(this, fileSize);
}
catch (IOException ex)
{

View File

@ -1,17 +1,18 @@
using System;
using System;
using System.Collections.ObjectModel;
#nullable enable
namespace BililiveRecorder.FlvProcessor
{
public interface IFlvStreamProcessor : IDisposable
{
event TagProcessedEvent TagProcessed;
event EventHandler<long> FileFinalized;
event StreamFinalizedEvent StreamFinalized;
event FlvMetadataEvent OnMetaData;
int TotalMaxTimestamp { get; }
int CurrentMaxTimestamp { get; }
DateTime StartDateTime { get; }
IFlvMetadata Metadata { get; set; }
ObservableCollection<IFlvClipProcessor> Clips { get; }
@ -19,7 +20,7 @@ namespace BililiveRecorder.FlvProcessor
uint ClipLengthFuture { get; set; }
uint CuttingNumber { get; set; }
IFlvStreamProcessor Initialize(Func<string> getStreamFileName, Func<string> getClipFileName, EnabledFeature enabledFeature, AutoCuttingMode autoCuttingMode);
IFlvStreamProcessor Initialize(Func<(string fullPath, string relativePath)> getStreamFileName, Func<string> getClipFileName, EnabledFeature enabledFeature, AutoCuttingMode autoCuttingMode);
IFlvClipProcessor Clip();
void AddBytes(byte[] data);
void FinallizeFile();

View File

@ -1,8 +1,10 @@
using System;
using System.ComponentModel;
using BililiveRecorder.Core;
using BililiveRecorder.Core.Callback;
using BililiveRecorder.FlvProcessor;
#nullable enable
namespace BililiveRecorder.WPF.MockData
{
#if DEBUG
@ -28,9 +30,11 @@ namespace BililiveRecorder.WPF.MockData
public string StreamerName { get; set; }
public IStreamMonitor StreamMonitor { get; set; }
public string Title { get; set; } = string.Empty;
public IFlvStreamProcessor Processor { get; set; }
public IStreamMonitor StreamMonitor { get; set; } = null!;
public IFlvStreamProcessor? Processor { get; set; }
public bool IsMonitoring { get; set; }
@ -48,7 +52,9 @@ namespace BililiveRecorder.WPF.MockData
public Guid Guid { get; } = Guid.NewGuid();
public event PropertyChangedEventHandler PropertyChanged;
public event PropertyChangedEventHandler? PropertyChanged;
public event EventHandler<RecordEndData>? RecordEnded;
public void Clip()
{

View File

@ -113,6 +113,13 @@
Visibility="{Binding ElementName=EnabledFeatureRecordOnlyRadioButton,Path=IsChecked,Converter={StaticResource InvertBooleanToVisibilityCollapsedConverter}}"/>
</StackPanel>
</GroupBox>
<GroupBox Header="Webhook">
<StackPanel MaxWidth="400" HorizontalAlignment="Left">
<TextBlock Text="Webhook 地址,一行一个"/>
<TextBox AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Visible"
Text="{Binding WebHookUrls,UpdateSourceTrigger=PropertyChanged,Delay=1000}" ui:TextBoxHelper.IsDeleteButtonVisible="False"/>
</StackPanel>
</GroupBox>
</ui:SimpleStackPanel>
</ScrollViewer>
</ui:Page>