mirror of https://github.com/ppy/osu
370 lines
15 KiB
C#
370 lines
15 KiB
C#
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
|
// See the LICENCE file in the repository root for full licence text.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
|
|
#nullable enable
|
|
|
|
namespace osu.Game.Online.Chat
|
|
{
|
|
public static class MessageFormatter
|
|
{
|
|
// [[Performance Points]] -> wiki:Performance Points (https://osu.ppy.sh/wiki/Performance_Points)
|
|
private static readonly Regex wiki_regex = new Regex(@"\[\[(?<text>[^\]]+)\]\]");
|
|
|
|
// (test)[https://osu.ppy.sh/b/1234] -> test (https://osu.ppy.sh/b/1234)
|
|
private static readonly Regex old_link_regex = new Regex(@"\((?<text>(((?<=\\)[\(\)])|[^\(\)])*(((?<open>\()(((?<=\\)[\(\)])|[^\(\)])*)+((?<close-open>\))(((?<=\\)[\(\)])|[^\(\)])*)+)*(?(open)(?!)))\)\[(?<url>[a-z]+://[^ ]+)\]");
|
|
|
|
// [https://osu.ppy.sh/b/1234 Beatmap [Hard] (poop)] -> Beatmap [hard] (poop) (https://osu.ppy.sh/b/1234)
|
|
private static readonly Regex new_link_regex = new Regex(@"\[(?<url>[a-z]+://[^ ]+) (?<text>(((?<=\\)[\[\]])|[^\[\]])*(((?<open>\[)(((?<=\\)[\[\]])|[^\[\]])*)+((?<close-open>\])(((?<=\\)[\[\]])|[^\[\]])*)+)*(?(open)(?!)))\]");
|
|
|
|
// [test](https://osu.ppy.sh/b/1234) -> test (https://osu.ppy.sh/b/1234) aka correct markdown format
|
|
private static readonly Regex markdown_link_regex = new Regex(@"\[(?<text>(((?<=\\)[\[\]])|[^\[\]])*(((?<open>\[)(((?<=\\)[\[\]])|[^\[\]])*)+((?<close-open>\])(((?<=\\)[\[\]])|[^\[\]])*)+)*(?(open)(?!)))\]\((?<url>[a-z]+://[^ ]+)(\s+(?<title>""([^""]|(?<=\\)"")*""))?\)");
|
|
|
|
// advanced, RFC-compatible regular expression that matches any possible URL, *but* allows certain invalid characters that are widely used
|
|
// This is in the format (<required>, [optional]):
|
|
// http[s]://<domain>.<tld>[:port][/path][?query][#fragment]
|
|
private static readonly Regex advanced_link_regex = new Regex(
|
|
// protocol
|
|
@"(?<link>[a-z]*?:\/\/" +
|
|
// domain + tld
|
|
@"(?<domain>(?:[a-z0-9]\.|[a-z0-9][a-z0-9-]*[a-z0-9]\.)*[a-z0-9-]*[a-z0-9]" +
|
|
// port (optional)
|
|
@"(?::\d+)?)" +
|
|
// path (optional)
|
|
@"(?<path>(?:(?:\/+(?:[a-z0-9$_\.\+!\*\',;:\(\)@&~=-]|%[0-9a-f]{2})*)*" +
|
|
// query (optional)
|
|
@"(?:\?(?:[a-z0-9$_\+!\*\',;:\(\)@&=\/~-]|%[0-9a-f]{2})*)?)?" +
|
|
// fragment (optional)
|
|
@"(?:#(?:[a-z0-9$_\+!\*\',;:\(\)@&=\/~-]|%[0-9a-f]{2})*)?)?)",
|
|
RegexOptions.IgnoreCase);
|
|
|
|
// 00:00:000 (1,2,3) - test
|
|
// regex from https://github.com/ppy/osu-web/blob/651a9bac2b60d031edd7e33b8073a469bf11edaa/resources/assets/coffee/_classes/beatmap-discussion-helper.coffee#L10
|
|
private static readonly Regex time_regex = new Regex(@"\b(((\d{2,}):([0-5]\d)[:.](\d{3}))(\s\((?:\d+[,|])*\d+\))?)");
|
|
|
|
// #osu
|
|
private static readonly Regex channel_regex = new Regex(@"(#[a-zA-Z]+[a-zA-Z0-9]+)");
|
|
|
|
// Unicode emojis
|
|
private static readonly Regex emoji_regex = new Regex(@"(\uD83D[\uDC00-\uDE4F])");
|
|
|
|
/// <summary>
|
|
/// The root URL for the website, used for chat link matching.
|
|
/// </summary>
|
|
public static string WebsiteRootUrl
|
|
{
|
|
get => websiteRootUrl;
|
|
set => websiteRootUrl = value
|
|
.Trim('/') // trim potential trailing slash/
|
|
.Split('/').Last(); // only keep domain name, ignoring protocol.
|
|
}
|
|
|
|
private static string websiteRootUrl = "osu.ppy.sh";
|
|
|
|
private static void handleMatches(Regex regex, string display, string link, MessageFormatterResult result, int startIndex = 0, LinkAction? linkActionOverride = null, char[]? escapeChars = null)
|
|
{
|
|
int captureOffset = 0;
|
|
|
|
foreach (Match m in regex.Matches(result.Text, startIndex))
|
|
{
|
|
int index = m.Index - captureOffset;
|
|
|
|
string? displayText = string.Format(display,
|
|
m.Groups[0],
|
|
m.Groups["text"].Value,
|
|
m.Groups["url"].Value).Trim();
|
|
|
|
string linkText = string.Format(link,
|
|
m.Groups[0],
|
|
m.Groups["text"].Value,
|
|
m.Groups["url"].Value).Trim();
|
|
|
|
if (displayText.Length == 0 || linkText.Length == 0) continue;
|
|
|
|
// Remove backslash escapes in front of the characters provided in escapeChars
|
|
if (escapeChars != null)
|
|
displayText = escapeChars.Aggregate(displayText, (current, c) => current.Replace($"\\{c}", c.ToString()));
|
|
|
|
// Check for encapsulated links
|
|
if (result.Links.Find(l => (l.Index <= index && l.Index + l.Length >= index + m.Length) || (index <= l.Index && index + m.Length >= l.Index + l.Length)) == null)
|
|
{
|
|
result.Text = result.Text.Remove(index, m.Length).Insert(index, displayText);
|
|
|
|
// since we just changed the line display text, offset any already processed links.
|
|
result.Links.ForEach(l => l.Index -= l.Index > index ? m.Length - displayText.Length : 0);
|
|
|
|
var details = GetLinkDetails(linkText);
|
|
result.Links.Add(new Link(linkText, index, displayText.Length, linkActionOverride ?? details.Action, details.Argument));
|
|
|
|
// adjust the offset for processing the current matches group.
|
|
captureOffset += m.Length - displayText.Length;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void handleAdvanced(Regex regex, MessageFormatterResult result, int startIndex = 0)
|
|
{
|
|
foreach (Match m in regex.Matches(result.Text, startIndex))
|
|
{
|
|
int index = m.Index;
|
|
string? linkText = m.Groups["link"].Value;
|
|
int indexLength = linkText.Length;
|
|
|
|
var details = GetLinkDetails(linkText);
|
|
var link = new Link(linkText, index, indexLength, details.Action, details.Argument);
|
|
|
|
// sometimes an already-processed formatted link can reduce to a simple URL, too
|
|
// (example: [mean example - https://osu.ppy.sh](https://osu.ppy.sh))
|
|
// therefore we need to check if any of the pre-existing links contains the raw one we found
|
|
if (result.Links.All(existingLink => !existingLink.Overlaps(link)))
|
|
result.Links.Add(link);
|
|
}
|
|
}
|
|
|
|
public static LinkDetails GetLinkDetails(string url)
|
|
{
|
|
string[]? args = url.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
args[0] = args[0].TrimEnd(':');
|
|
|
|
switch (args[0])
|
|
{
|
|
case "http":
|
|
case "https":
|
|
// length > 3 since all these links need another argument to work
|
|
if (args.Length > 3 && args[1].EndsWith(WebsiteRootUrl, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
string mainArg = args[3];
|
|
|
|
switch (args[2])
|
|
{
|
|
// old site only
|
|
case "b":
|
|
case "beatmaps":
|
|
{
|
|
string trimmed = mainArg.Split('?').First();
|
|
if (int.TryParse(trimmed, out int id))
|
|
return new LinkDetails(LinkAction.OpenBeatmap, id.ToString());
|
|
|
|
break;
|
|
}
|
|
|
|
case "s":
|
|
case "beatmapsets":
|
|
case "d":
|
|
{
|
|
if (mainArg == "discussions")
|
|
// handle discussion links externally for now
|
|
return new LinkDetails(LinkAction.External, url);
|
|
|
|
if (args.Length > 4 && int.TryParse(args[4], out int id))
|
|
// https://osu.ppy.sh/beatmapsets/1154158#osu/2768184
|
|
return new LinkDetails(LinkAction.OpenBeatmap, id.ToString());
|
|
|
|
// https://osu.ppy.sh/beatmapsets/1154158#whatever
|
|
string trimmed = mainArg.Split('#').First();
|
|
if (int.TryParse(trimmed, out id))
|
|
return new LinkDetails(LinkAction.OpenBeatmapSet, id.ToString());
|
|
|
|
break;
|
|
}
|
|
|
|
case "u":
|
|
case "users":
|
|
return new LinkDetails(LinkAction.OpenUserProfile, mainArg);
|
|
|
|
case "wiki":
|
|
return new LinkDetails(LinkAction.OpenWiki, string.Join('/', args.Skip(3)));
|
|
|
|
case "home":
|
|
if (mainArg != "changelog")
|
|
// handle link other than changelog as external for now
|
|
return new LinkDetails(LinkAction.External, url);
|
|
|
|
switch (args.Length)
|
|
{
|
|
case 4:
|
|
// https://osu.ppy.sh/home/changelog
|
|
return new LinkDetails(LinkAction.OpenChangelog, string.Empty);
|
|
|
|
case 6:
|
|
// https://osu.ppy.sh/home/changelog/lazer/2021.1006
|
|
return new LinkDetails(LinkAction.OpenChangelog, $"{args[4]}/{args[5]}");
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
break;
|
|
|
|
case "osu":
|
|
// every internal link also needs some kind of argument
|
|
if (args.Length < 3)
|
|
break;
|
|
|
|
LinkAction linkType;
|
|
|
|
switch (args[1])
|
|
{
|
|
case "chan":
|
|
linkType = LinkAction.OpenChannel;
|
|
break;
|
|
|
|
case "edit":
|
|
linkType = LinkAction.OpenEditorTimestamp;
|
|
break;
|
|
|
|
case "b":
|
|
linkType = LinkAction.OpenBeatmap;
|
|
break;
|
|
|
|
case "s":
|
|
case "dl":
|
|
linkType = LinkAction.OpenBeatmapSet;
|
|
break;
|
|
|
|
case "spectate":
|
|
linkType = LinkAction.Spectate;
|
|
break;
|
|
|
|
case "u":
|
|
linkType = LinkAction.OpenUserProfile;
|
|
break;
|
|
|
|
default:
|
|
return new LinkDetails(LinkAction.External, url);
|
|
}
|
|
|
|
return new LinkDetails(linkType, args[2]);
|
|
|
|
case "osump":
|
|
return new LinkDetails(LinkAction.JoinMultiplayerMatch, args[1]);
|
|
}
|
|
|
|
return new LinkDetails(LinkAction.External, url);
|
|
}
|
|
|
|
private static MessageFormatterResult format(string toFormat, int startIndex = 0, int space = 3)
|
|
{
|
|
var result = new MessageFormatterResult(toFormat);
|
|
|
|
// handle the [link display] format
|
|
handleMatches(new_link_regex, "{1}", "{2}", result, startIndex, escapeChars: new[] { '[', ']' });
|
|
|
|
// handle the standard markdown []() format
|
|
handleMatches(markdown_link_regex, "{1}", "{2}", result, startIndex, escapeChars: new[] { '[', ']' });
|
|
|
|
// handle the ()[] link format
|
|
handleMatches(old_link_regex, "{1}", "{2}", result, startIndex, escapeChars: new[] { '(', ')' });
|
|
|
|
// handle wiki links
|
|
handleMatches(wiki_regex, "{1}", $"https://{WebsiteRootUrl}/wiki/{{1}}", result, startIndex);
|
|
|
|
// handle bare links
|
|
handleAdvanced(advanced_link_regex, result, startIndex);
|
|
|
|
// handle editor times
|
|
handleMatches(time_regex, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}edit/{{0}}", result, startIndex, LinkAction.OpenEditorTimestamp);
|
|
|
|
// handle channels
|
|
handleMatches(channel_regex, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}chan/{{0}}", result, startIndex, LinkAction.OpenChannel);
|
|
|
|
string empty = "";
|
|
while (space-- > 0)
|
|
empty += "\0";
|
|
|
|
handleMatches(emoji_regex, empty, "{0}", result, startIndex);
|
|
|
|
return result;
|
|
}
|
|
|
|
public static Message FormatMessage(Message inputMessage)
|
|
{
|
|
var result = format(inputMessage.Content);
|
|
|
|
inputMessage.DisplayContent = result.Text;
|
|
|
|
// Sometimes, regex matches are not in order
|
|
result.Links.Sort();
|
|
inputMessage.Links = result.Links;
|
|
return inputMessage;
|
|
}
|
|
|
|
public static MessageFormatterResult FormatText(string text)
|
|
{
|
|
var result = format(text);
|
|
|
|
result.Links.Sort();
|
|
|
|
return result;
|
|
}
|
|
|
|
public class MessageFormatterResult
|
|
{
|
|
public List<Link> Links = new List<Link>();
|
|
public string Text;
|
|
public string OriginalText;
|
|
|
|
public MessageFormatterResult(string text)
|
|
{
|
|
OriginalText = Text = text;
|
|
}
|
|
}
|
|
}
|
|
|
|
public class LinkDetails
|
|
{
|
|
public readonly LinkAction Action;
|
|
|
|
public readonly object Argument;
|
|
|
|
public LinkDetails(LinkAction action, object argument)
|
|
{
|
|
Action = action;
|
|
Argument = argument;
|
|
}
|
|
}
|
|
|
|
public enum LinkAction
|
|
{
|
|
External,
|
|
OpenBeatmap,
|
|
OpenBeatmapSet,
|
|
OpenChannel,
|
|
OpenEditorTimestamp,
|
|
JoinMultiplayerMatch,
|
|
Spectate,
|
|
OpenUserProfile,
|
|
SearchBeatmapSet,
|
|
OpenWiki,
|
|
Custom,
|
|
OpenChangelog,
|
|
}
|
|
|
|
public class Link : IComparable<Link>
|
|
{
|
|
public string Url;
|
|
public int Index;
|
|
public int Length;
|
|
public LinkAction Action;
|
|
public object Argument;
|
|
|
|
public Link(string url, int startIndex, int length, LinkAction action, object argument)
|
|
{
|
|
Url = url;
|
|
Index = startIndex;
|
|
Length = length;
|
|
Action = action;
|
|
Argument = argument;
|
|
}
|
|
|
|
public bool Overlaps(Link otherLink) => Index < otherLink.Index + otherLink.Length && otherLink.Index < Index + Length;
|
|
|
|
public int CompareTo(Link otherLink) => Index > otherLink.Index ? 1 : -1;
|
|
}
|
|
}
|