osu/osu.Game/Online/Chat/MessageFormatter.cs

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

375 lines
15 KiB
C#
Raw Normal View History

// 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.
2018-04-13 09:19:50 +00:00
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using osu.Game.Online.API.Requests.Responses;
2018-04-13 09:19:50 +00:00
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>[^\]]+)\]\]");
2018-04-13 09:19:50 +00:00
// (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]+://[^ ]+)\]");
2018-04-13 09:19:50 +00:00
// [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)(?!)))\]");
2018-04-13 09:19:50 +00:00
2018-01-30 07:38:45 +00:00
// [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>""([^""]|(?<=\\)"")*""))?\)");
2018-04-13 09:19:50 +00:00
// 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]
2018-01-30 07:39:08 +00:00
private static readonly Regex advanced_link_regex = new Regex(
2019-02-28 04:31:40 +00:00
// 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);
2018-04-13 09:19:50 +00:00
// 00:00:000 (1,2,3) - test
2021-08-20 20:01:06 +00:00
// 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+\))?)");
2018-04-13 09:19:50 +00:00
// #osu
private static readonly Regex channel_regex = new Regex(@"(#[a-zA-Z]+[a-zA-Z0-9]+)");
2018-04-13 09:19:50 +00:00
// Unicode emojis
private static readonly Regex emoji_regex = new Regex(@"(\uD83D[\uDC00-\uDE4F])");
2018-04-13 09:19:50 +00:00
/// <summary>
/// The root URL for the website, used for chat link matching.
/// </summary>
public static string WebsiteRootUrl
{
2022-01-11 08:53:15 +00:00
get => websiteRootUrl;
set => websiteRootUrl = value
.Trim('/') // trim potential trailing slash/
.Split('/').Last(); // only keep domain name, ignoring protocol.
}
2021-02-12 06:03:53 +00:00
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;
2019-04-01 03:16:05 +00:00
foreach (Match m in regex.Matches(result.Text, startIndex))
{
int index = m.Index - captureOffset;
2018-04-13 09:19:50 +00:00
string displayText = string.Format(display,
2019-02-28 04:31:40 +00:00
m.Groups[0],
m.Groups["text"].Value,
m.Groups["url"].Value).Trim();
2018-04-13 09:19:50 +00:00
string linkText = string.Format(link,
2019-02-28 04:31:40 +00:00
m.Groups[0],
m.Groups["text"].Value,
m.Groups["url"].Value).Trim();
2018-04-13 09:19:50 +00:00
if (displayText.Length == 0 || linkText.Length == 0) continue;
2018-04-13 09:19:50 +00:00
// 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);
2018-04-13 09:19:50 +00:00
2020-05-05 01:31:11 +00:00
// 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);
2018-04-13 09:19:50 +00:00
var details = GetLinkDetails(linkText);
result.Links.Add(new Link(linkText, index, displayText.Length, linkActionOverride ?? details.Action, details.Argument));
2018-04-13 09:19:50 +00:00
2020-05-05 01:31:11 +00:00
// adjust the offset for processing the current matches group.
captureOffset += m.Length - displayText.Length;
}
}
}
2018-04-13 09:19:50 +00:00
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;
2018-04-13 09:19:50 +00:00
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);
}
}
2018-04-13 09:19:50 +00:00
public static LinkDetails GetLinkDetails(string url)
{
string[] args = url.Split('/', StringSplitOptions.RemoveEmptyEntries);
args[0] = args[0].TrimEnd(':');
2018-04-13 09:19:50 +00:00
switch (args[0])
{
case "http":
case "https":
// length > 3 since all these links need another argument to work
2022-01-11 08:53:15 +00:00
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;
}
2019-04-01 03:16:05 +00:00
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;
}
2019-04-01 03:16:05 +00:00
case "u":
2019-08-18 19:02:59 +00:00
case "users":
return getUserLink(mainArg);
2021-05-16 17:43:59 +00:00
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;
}
}
2018-04-13 09:19:50 +00:00
break;
2019-04-01 03:16:05 +00:00
case "osu":
// every internal link also needs some kind of argument
if (args.Length < 3)
break;
2018-04-13 09:19:50 +00:00
LinkAction linkType;
2019-04-01 03:16:05 +00:00
switch (args[1])
{
case "chan":
linkType = LinkAction.OpenChannel;
break;
2019-04-01 03:16:05 +00:00
case "edit":
linkType = LinkAction.OpenEditorTimestamp;
break;
2019-04-01 03:16:05 +00:00
case "b":
linkType = LinkAction.OpenBeatmap;
break;
2019-04-01 03:16:05 +00:00
case "s":
case "dl":
linkType = LinkAction.OpenBeatmapSet;
break;
2019-04-01 03:16:05 +00:00
case "spectate":
linkType = LinkAction.Spectate;
break;
2019-04-01 03:16:05 +00:00
case "u":
return getUserLink(args[2]);
2019-04-01 03:16:05 +00:00
default:
return new LinkDetails(LinkAction.External, url);
}
2018-04-13 09:19:50 +00:00
return new LinkDetails(linkType, args[2]);
2019-04-01 03:16:05 +00:00
case "osump":
return new LinkDetails(LinkAction.JoinMultiplayerMatch, args[1]);
}
return new LinkDetails(LinkAction.External, url);
}
2018-04-13 09:19:50 +00:00
private static LinkDetails getUserLink(string argument)
{
if (int.TryParse(argument, out int userId))
return new LinkDetails(LinkAction.OpenUserProfile, new APIUser { Id = userId });
return new LinkDetails(LinkAction.OpenUserProfile, new APIUser { Username = argument });
}
private static MessageFormatterResult format(string toFormat, int startIndex = 0, int space = 3)
{
var result = new MessageFormatterResult(toFormat);
2018-04-13 09:19:50 +00:00
// handle the [link display] format
handleMatches(new_link_regex, "{1}", "{2}", result, startIndex, escapeChars: new[] { '[', ']' });
2018-04-13 09:19:50 +00:00
2018-01-30 07:38:45 +00:00
// handle the standard markdown []() format
handleMatches(markdown_link_regex, "{1}", "{2}", result, startIndex, escapeChars: new[] { '[', ']' });
2018-04-13 09:19:50 +00:00
// handle the ()[] link format
handleMatches(old_link_regex, "{1}", "{2}", result, startIndex, escapeChars: new[] { '(', ')' });
2018-04-13 09:19:50 +00:00
// handle wiki links
2022-01-11 08:53:15 +00:00
handleMatches(wiki_regex, "{1}", $"https://{WebsiteRootUrl}/wiki/{{1}}", result, startIndex);
2018-04-13 09:19:50 +00:00
// handle bare links
handleAdvanced(advanced_link_regex, result, startIndex);
2018-04-13 09:19:50 +00:00
// handle editor times
handleMatches(time_regex, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}edit/{{0}}", result, startIndex, LinkAction.OpenEditorTimestamp);
2018-04-13 09:19:50 +00:00
// handle channels
handleMatches(channel_regex, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}chan/{{0}}", result, startIndex, LinkAction.OpenChannel);
2018-04-13 09:19:50 +00:00
2023-07-13 08:41:53 +00:00
// see: https://github.com/ppy/osu/pull/24190
result.Text = Regex.Replace(result.Text, emoji_regex.ToString(), "[emoji]");
return result;
}
2018-04-13 09:19:50 +00:00
public static Message FormatMessage(Message inputMessage)
{
var result = format(inputMessage.Content);
2018-04-13 09:19:50 +00:00
2018-01-09 15:11:45 +00:00
inputMessage.DisplayContent = result.Text;
2018-04-13 09:19:50 +00:00
// Sometimes, regex matches are not in order
result.Links.Sort();
inputMessage.Links = result.Links;
return inputMessage;
}
2018-04-13 09:19:50 +00:00
public static MessageFormatterResult FormatText(string text)
{
var result = format(text);
2018-04-13 09:19:50 +00:00
result.Links.Sort();
2018-04-13 09:19:50 +00:00
return result;
}
2018-04-13 09:19:50 +00:00
public class MessageFormatterResult
{
public List<Link> Links = new List<Link>();
public string Text;
public string OriginalText;
2018-04-13 09:19:50 +00:00
public MessageFormatterResult(string text)
{
OriginalText = Text = text;
}
}
}
2018-04-13 09:19:50 +00:00
public class LinkDetails
{
public readonly LinkAction Action;
public readonly object Argument;
2018-04-13 09:19:50 +00:00
public LinkDetails(LinkAction action, object argument)
{
Action = action;
Argument = argument;
}
}
2018-04-13 09:19:50 +00:00
public enum LinkAction
{
External,
OpenBeatmap,
OpenBeatmapSet,
OpenChannel,
OpenEditorTimestamp,
JoinMultiplayerMatch,
Spectate,
OpenUserProfile,
2020-01-30 22:41:50 +00:00
SearchBeatmapSet,
2021-05-16 17:43:59 +00:00
OpenWiki,
2020-01-30 04:30:25 +00:00
Custom,
2021-10-12 02:37:11 +00:00
OpenChangelog,
FilterBeatmapSetGenre,
FilterBeatmapSetLanguage,
}
2018-04-13 09:19:50 +00:00
public class Link : IComparable<Link>
{
public string Url;
public int Index;
public int Length;
public LinkAction Action;
public object Argument;
2018-04-13 09:19:50 +00:00
public Link(string url, int startIndex, int length, LinkAction action, object argument)
{
Url = url;
Index = startIndex;
Length = length;
Action = action;
Argument = argument;
}
2018-04-13 09:19:50 +00:00
public bool Overlaps(Link otherLink) => Index < otherLink.Index + otherLink.Length && otherLink.Index < Index + Length;
public int CompareTo(Link? otherLink) => Index > otherLink?.Index ? 1 : -1;
}
}