using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using XFEEDToolBox.Core.Data;
namespace XFEEDToolBox.Core.Notifications;
public sealed record GameAnnouncement(string Id,string Title,string Message,DateTimeOffset At,DateTimeOffset? MaintenanceAt=null)
{
public string Detail => MaintenanceAt is {} deadline ? $"预计维护时间 {deadline.ToLocalTime():HH:mm} · 以游戏后续公告为准。" : "";
public string ToastXml()=>new XElement("toast",new XAttribute("launch","action=announcement&page=home"),new XAttribute("duration","long"),
new XElement("visual",new XElement("binding",new XAttribute("template","ToastGeneric"),
new XElement("text",Title),new XElement("text",Message),new XElement("text",Detail))),
new XElement("audio",new XAttribute("src","ms-winsoundevent:Notification.Default"))).ToString(SaveOptions.DisableFormatting);
}
/// <summary>Only fresh server/system announcements, never ordinary commander or NPC chat.</summary>
public sealed partial class GameAnnouncementObserver
{
private readonly HashSet<string> seen=new(StringComparer.Ordinal);
[GeneratedRegex(@"(?<minutes>\d{1,3})\s*(?:minutes?\b|mins?\b|分钟|分鐘)",RegexOptions.IgnoreCase|RegexOptions.CultureInvariant)]
private static partial Regex MinutesPattern();
public GameAnnouncement? Observe(ObservedJournalEvent observed,DateTimeOffset now)
{
var data=observed.Data;if(data.Text("event")!="ReceiveText")return null;
var sender=data.Text("From");var channel=data.Text("Channel");
if(channel.Equals("npc",StringComparison.OrdinalIgnoreCase)
||!(channel.Equals("admin",StringComparison.OrdinalIgnoreCase)||channel.Equals("system",StringComparison.OrdinalIgnoreCase)
||sender.Equals("Server",StringComparison.OrdinalIgnoreCase)||sender.Equals("System",StringComparison.OrdinalIgnoreCase)))return null;
var at=DateTimeOffset.TryParse(data.Text("timestamp"),CultureInfo.InvariantCulture,DateTimeStyles.AssumeUniversal,out var timestamp)?timestamp:observed.ObservedAt;
if(now-at>TimeSpan.FromMinutes(2)||at>now.AddSeconds(1))return null;
var message=data.Text("Message_Localised",data.Text("Message")).Trim();
if(message.Length==0||message.Length>8000)return null;
var id=Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{at:O}\n{sender}\n{channel}\n{message}")));
lock(seen)
{
if(!seen.Add(id))return null;
if(seen.Count>4096){seen.Clear();seen.Add(id);}
}
var maintenance=message.Contains("maintenance",StringComparison.OrdinalIgnoreCase)||message.Contains("维护",StringComparison.Ordinal)||message.Contains("維護",StringComparison.Ordinal);
DateTimeOffset? deadline=null;
if(maintenance&&MinutesPattern().Match(message) is {Success:true} match&&int.TryParse(match.Groups["minutes"].Value,out var minutes)&&minutes is >0 and <=180)
deadline=at.AddMinutes(minutes);
return new(id,maintenance?"服务器维护公告":"游戏公告",message,at,deadline);
}
}
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using XFEEDToolBox.Core.Data;
namespace XFEEDToolBox.Core.Notifications;
public sealed record GameAnnouncement(string Id,string Title,string Message,DateTimeOffset At,DateTimeOffset? MaintenanceAt=null)
{
public string Detail => MaintenanceAt is {} deadline ? $"预计维护时间 {deadline.ToLocalTime():HH:mm} · 以游戏后续公告为准。" : "";
public string ToastXml()=>new XElement("toast",new XAttribute("launch","action=announcement&page=home"),new XAttribute("duration","long"),
new XElement("visual",new XElement("binding",new XAttribute("template","ToastGeneric"),
new XElement("text",Title),new XElement("text",Message),new XElement("text",Detail))),
new XElement("audio",new XAttribute("src","ms-winsoundevent:Notification.Default"))).ToString(SaveOptions.DisableFormatting);
}
/// <summary>Only fresh server/system announcements, never ordinary commander or NPC chat.</summary>
public sealed partial class GameAnnouncementObserver
{
private readonly HashSet<string> seen=new(StringComparer.Ordinal);
[GeneratedRegex(@"(?<minutes>\d{1,3})\s*(?:minutes?\b|mins?\b|分钟|分鐘)",RegexOptions.IgnoreCase|RegexOptions.CultureInvariant)]
private static partial Regex MinutesPattern();
public GameAnnouncement? Observe(ObservedJournalEvent observed,DateTimeOffset now)
{
var data=observed.Data;if(data.Text("event")!="ReceiveText")return null;
var sender=data.Text("From");var channel=data.Text("Channel");
if(channel.Equals("npc",StringComparison.OrdinalIgnoreCase)
||!(channel.Equals("admin",StringComparison.OrdinalIgnoreCase)||channel.Equals("system",StringComparison.OrdinalIgnoreCase)
||sender.Equals("Server",StringComparison.OrdinalIgnoreCase)||sender.Equals("System",StringComparison.OrdinalIgnoreCase)))return null;
var at=DateTimeOffset.TryParse(data.Text("timestamp"),CultureInfo.InvariantCulture,DateTimeStyles.AssumeUniversal,out var timestamp)?timestamp:observed.ObservedAt;
if(now-at>TimeSpan.FromMinutes(2)||at>now.AddSeconds(1))return null;
var message=data.Text("Message_Localised",data.Text("Message")).Trim();
if(message.Length==0||message.Length>8000)return null;
var id=Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{at:O}\n{sender}\n{channel}\n{message}")));
lock(seen)
{
if(!seen.Add(id))return null;
if(seen.Count>4096){seen.Clear();seen.Add(id);}
}
var maintenance=message.Contains("maintenance",StringComparison.OrdinalIgnoreCase)||message.Contains("维护",StringComparison.Ordinal)||message.Contains("維護",StringComparison.Ordinal);
DateTimeOffset? deadline=null;
if(maintenance&&MinutesPattern().Match(message) is {Success:true} match&&int.TryParse(match.Groups["minutes"].Value,out var minutes)&&minutes is >0 and <=180)
deadline=at.AddMinutes(minutes);
return new(id,maintenance?"服务器维护公告":"游戏公告",message,at,deadline);
}
}