diff --git a/src/main/java/de/strifel/VTools/listeners/MarkdownString.java b/src/main/java/de/strifel/VTools/listeners/MarkdownString.java new file mode 100644 index 0000000..156204e --- /dev/null +++ b/src/main/java/de/strifel/VTools/listeners/MarkdownString.java @@ -0,0 +1,222 @@ +package de.strifel.VTools.listeners; + +import com.pengrad.telegrambot.model.Message; +import com.pengrad.telegrambot.model.MessageEntity; + +import java.util.*; +import java.util.function.BiFunction; + +public class MarkdownString { + + private MarkdownString(){ + throw new IllegalStateException(); + } + + /** + * 这里的 format 假设 BiFunction 的输入都是转义过的。 + */ + private static final Map> formats; + + static { + Map> map = new EnumMap<>(MessageEntity.Type.class); + + map.put(MessageEntity.Type.bold, (s, e) -> { + assert e.type() == MessageEntity.Type.bold; + return s.insert(0, "*").append("*"); + }); + map.put(MessageEntity.Type.strikethrough, (s, e) -> { + assert e.type() == MessageEntity.Type.strikethrough; + return s.insert(0, "~").append("~"); + }); + map.put(MessageEntity.Type.spoiler, (s, e) -> { + assert e.type() == MessageEntity.Type.spoiler; + return s.insert(0, "||").append("||"); + }); + + + map.put(MessageEntity.Type.italic, (s, e) -> { + assert e.type() == MessageEntity.Type.italic; + return s.insert(0, "\r_\r").append("\r_\r"); // todo: 精简一下 到底哪里需要加\r + }); + map.put(MessageEntity.Type.underline, (s, e) -> { + assert e.type() == MessageEntity.Type.underline; + return s.insert(0, "\r__\r").append("\r__\r"); + }); + + // https://core.telegram.org/bots/api#formatting-options + // 上面这几个套娃友好,自身随意套娃,但不能和等宽组合 + + + map.put(MessageEntity.Type.code, (s, e) -> { + assert e.type() == MessageEntity.Type.code; + return s.insert(0, "`").append("`"); + }); + map.put(MessageEntity.Type.pre, (rawStr, messageEntity) -> { + assert messageEntity.type() == MessageEntity.Type.pre; + rawStr.insert(0, "\n"); + String language = messageEntity.language(); + if (language != null && language.length() > 0) { + rawStr.insert(0, language); + } + return rawStr.insert(0, "```").append("\n```"); + }); + + // 等宽(pre 和 code)完全禁止套娃 + + map.put(MessageEntity.Type.text_link, (s, e) -> { + assert e.type() == MessageEntity.Type.text_link; + return s.insert(0, "[").append("](").append(escapeStr(e.url())).append(")"); + }); + map.put(MessageEntity.Type.text_mention, (s, e) -> { + assert e.type() == MessageEntity.Type.text_mention; + if (e.user() == null || e.user().id() == null) { + return s; // 没有id时爆炸( + } + return s.insert(0, "[").append("](").append("tg://user?id=").append(e.user().id()).append(")"); + }); + + // 链接类不能和链接类套娃,但是可以和加粗等套娃友好的组合 + + formats = Collections.unmodifiableMap(map); + } + + + private static class StringBlock { + private final String text; + private Map entities; + private final int offset; + + StringBlock(String text, int offset) { + this.text = text; + this.offset = offset; + this.entities = new EnumMap<>(MessageEntity.Type.class); + } + + private String getMarkdownTextWithoutLink() { // todo: 压缩 *text1**_text2_* 为 *text1_text2_* + // 链接类不能和链接类套娃 + assert !((entities.containsKey(MessageEntity.Type.text_link)) && (entities.containsKey(MessageEntity.Type.text_mention))); + // 等宽(pre 和 code)完全禁止套娃 + assert !((entities.containsKey(MessageEntity.Type.pre) || entities.containsKey(MessageEntity.Type.code)) && (entities.size() > 1)); + + StringBuilder s = new StringBuilder(escapeStr(text)); + for (var entry : entities.entrySet()) { + s = formats.get(entry.getKey()).apply(s, entry.getValue()); // 无用赋值...理论上。 + } + return s.toString(); + } + } + + public static String markdownString(Message message) { + + List stringBlocks = createStringBlocks(message); + + StringBuilder s = new StringBuilder(); + for (int i = 0; i < stringBlocks.size(); i++) { + var stringBlock = stringBlocks.get(i); + var entities = stringBlock.entities; + String withoutLink = stringBlock.getMarkdownTextWithoutLink(); + if (entities.containsKey(MessageEntity.Type.text_mention) || + entities.containsKey(MessageEntity.Type.text_link)) { + assert (!entities.containsKey(MessageEntity.Type.text_mention) || + !entities.containsKey(MessageEntity.Type.text_link)); + StringBuilder linkText = new StringBuilder(); + MessageEntity entity = entities.containsKey(MessageEntity.Type.text_mention) ? + stringBlock.entities.get(MessageEntity.Type.text_mention) : + stringBlock.entities.get(MessageEntity.Type.text_link); + assert entity.offset() == stringBlock.offset; + int end = entity.length() + entity.offset(); + + while (true) { + linkText.append(stringBlock.getMarkdownTextWithoutLink()); + if (stringBlock.text.length() + stringBlock.offset == end) { + break; + } + assert (stringBlock.text.length() + stringBlock.offset < end); + i++; + stringBlock = stringBlocks.get(i); + } + + s.append(formats.get(entity.type()).apply(linkText, entity)); + } else { + s.append(withoutLink); + } + } + + return s.toString(); + } + + /** + * @return 一组 StringBlock,被完全切开的文本,每部分都和相邻部分格式不一样,顺序的。 + */ + private static List createStringBlocks(Message message) { + List stringBlocks = new ArrayList<>(); + Set splitPoints = new HashSet<>(); + + // 定位切割点 + for (MessageEntity entity : message.entities()) { + if (formats.containsKey(entity.type())) { + Integer offset = entity.offset(); + Integer length = entity.length(); + splitPoints.add(offset); + splitPoints.add(offset + length); + } + } + + // 切割文本 + String text = message.text(); + List sortedSplitPoints = new ArrayList<>(splitPoints); + Collections.sort(sortedSplitPoints); + + int start = 0; + for (int splitPoint : sortedSplitPoints) { + if (splitPoint == 0) continue;// 跳过第一个空的 + stringBlocks.add(new StringBlock(text.substring(start, splitPoint), start)); + start = splitPoint; + } + if (start < text.length()) { + stringBlocks.add(new StringBlock(text.substring(start), start)); + } + + // 给文本重新赋格式(MessageEntity) + for (MessageEntity entity : message.entities()) { + for (StringBlock stringBlock : stringBlocks) { + int blockStart = stringBlock.offset; + int blockEnd = blockStart + stringBlock.text.length(); + + int entityStart = entity.offset(); + int entityEnd = entityStart + entity.length(); + + assert (blockStart < blockEnd) && (entityStart < entityEnd); + if (blockStart > entityEnd || blockEnd < entityStart) { + continue; + } + assert (blockStart >= entityStart) && (blockEnd <= entityEnd); + MessageEntity old = stringBlock.entities.put(entity.type(), entity); + assert (old == null); + } + } + return stringBlocks; + } + + private static final boolean[] SHOULD_ESCAPE = new boolean[128]; + + static { + // In all other places characters '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!' must be escaped with the preceding character '\'. + char[] chars = {'_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'}; + for (char c : chars) { + SHOULD_ESCAPE[c] = true; + } + } + + public static String escapeStr(String s) { + StringBuilder r = new StringBuilder(); + for (String character : s.split("")) { + int codePoint = character.codePointAt(0); + if (codePoint < 128 && SHOULD_ESCAPE[codePoint]) { + r.append('\\'); + } + r.append(character); + } + return r.toString(); + } +} diff --git a/src/main/java/de/strifel/VTools/listeners/TGBridge.java b/src/main/java/de/strifel/VTools/listeners/TGBridge.java index 0bd272f..8dfe7a0 100644 --- a/src/main/java/de/strifel/VTools/listeners/TGBridge.java +++ b/src/main/java/de/strifel/VTools/listeners/TGBridge.java @@ -6,6 +6,7 @@ import com.pengrad.telegrambot.UpdatesListener; import com.pengrad.telegrambot.model.Message; import com.pengrad.telegrambot.model.Update; import com.pengrad.telegrambot.model.User; +import com.pengrad.telegrambot.model.request.ParseMode; import com.pengrad.telegrambot.request.EditMessageText; import com.pengrad.telegrambot.request.GetChat; import com.pengrad.telegrambot.request.SendMessage; @@ -51,6 +52,9 @@ public class TGBridge { private long backoffSec = 1L; private static final String DIVIDER = "----------\n"; + /** + * markdown escaped + */ private String pinNote; public TGBridge(@NotNull VTools plugin) { @@ -96,75 +100,80 @@ public class TGBridge { bot.setUpdatesListener(updates -> { backoffSec = 1L; for (Update update : updates) { - try { - if (update != null && - update.message() != null && - update.message().chat() != null && - update.message().chat().id() == CHAT_ID && - update.message().from() != null - ) { - if (update.message().text() != null && !update.message().text().isEmpty()) { - String msg = update.message().text(); - if (msg.startsWith("/")) { - String[] s = msg.split("(@[A-Za-z0-9_]bot)?[\t \n]+", 2); - String command = s[0]; - @Nullable String arg = s.length == 2 ? s[1] : null; - switch (command) { - case "/list" -> outbound(genOnlineStatus()); - case "/setpin" -> { - boolean shouldApplyEdit = true; - boolean needUpdate = false; - Message replyTo = update.message().replyToMessage(); - if (replyTo == null) { - if (arg == null || arg.length() == 0) - outbound(""" - usage: - use "/setpin" reply a message that from the bot to set that message to pin-message, - or use "/setpin " to update current pinned message."""); - } else if (replyTo.from() == null || replyTo.from().id() != BOT_ID || replyTo.messageId() <= 0) { - outbound("must reply a message that from the bot (or reply nothing)."); - shouldApplyEdit = false; - } else { - readOldPinnedMessage(replyTo); - if (arg == null || arg.length() == 0) { - outbound("done"); - }else { - ONLINE_STATUS_MESSAGE_ID = replyTo.messageId(); - } - needUpdate = true; - } - if (shouldApplyEdit && arg != null && arg.length() > 0) { - outbound("done. old pinned note: \n" + pinNote); - pinNote = arg; - needUpdate = true; + try { + if (update == null || update.message() == null) break; + Message message = update.message(); + if (message.chat() == null || message.chat().id() != CHAT_ID || message.from() == null) { + break; + } + if (message.text() != null && !message.text().isEmpty()) { + String msgText = message.text(); + if (msgText.startsWith("/")) { + String[] s = msgText.split("(@[A-Za-z0-9_]bot)?[\t \n]+", 2); + String command = s[0]; + @Nullable String arg = s.length == 2 ? s[1] : null; + switch (command) { + case "/list" -> outbound(genOnlineStatus(), ParseMode.Markdown); + case "/setpin" -> { + Message replyTo = message.replyToMessage(); + if (arg == null || arg.length() == 0) { + if (replyTo == null) { + outbound(""" + usage: + use "/setpin" reply a message that from the bot to set that message to pin-message, + or use "/setpin " to update current pinned message."""); + break; } - if (needUpdate) updateOnlineStatus(); + ONLINE_STATUS_MESSAGE_ID = replyTo.messageId(); + updateOnlineStatus(); + break; } - case "/genpin" -> outbound(genPinMessage(), - (sendMessage, sendResponse) -> { - if (!sendResponse.isOk()) { - plugin.logger.error(String.format("sendMessage error %d: %s", sendResponse.errorCode(), sendResponse.description())); - } else { - int messageId = sendResponse.message().messageId(); - ONLINE_STATUS_MESSAGE_ID = messageId > 0 ? messageId : ONLINE_STATUS_MESSAGE_ID; - } - } - ); + if (replyTo != null && (replyTo.from() == null || replyTo.from().id() != BOT_ID || replyTo.messageId() <= 0)) { + outbound("must reply a message that from the bot (or reply nothing)."); + break; + } + + String markdownString = MarkdownString.markdownString(message); + + String shouldBeCommand = markdownString.substring(0, "/setpin ".length()); + if (!shouldBeCommand.matches("/setpin[\t \n]")) { + outbound("\"/setpin\" must be plain text."); + break; + } + + + outbound("old pinned note: \n" + pinNote, ParseMode.Markdown); + + pinNote = markdownString.substring("/setpin ".length()); + if (replyTo != null) { + ONLINE_STATUS_MESSAGE_ID = replyTo.messageId(); + } + updateOnlineStatus(); } + case "/genpin" -> outbound(genPinMessage(), + (sendMessage, sendResponse) -> { + if (!sendResponse.isOk()) { + plugin.logger.error(String.format("sendMessage error %d: %s", sendResponse.errorCode(), sendResponse.description())); + } else { + int messageId = sendResponse.message().messageId(); + ONLINE_STATUS_MESSAGE_ID = messageId > 0 ? messageId : ONLINE_STATUS_MESSAGE_ID; + } + }, ParseMode.Markdown + ); } - tgInbound(update.message().from(), msg); - } else if (update.message().sticker() != null) { - tgInbound(update.message().from(), "[sticker]"); - } else if (update.message().photo() != null) { - tgInbound(update.message().from(), "[photo]"); - } else if (update.message().audio() != null) { - tgInbound(update.message().from(), "[audio]"); - } else if (update.message().voice() != null) { - tgInbound(update.message().from(), "[voice]"); - } else if (update.message().document() != null) { - tgInbound(update.message().from(), "[document]"); } + tgInbound(message.from(), msgText); + } else if (message.sticker() != null) { + tgInbound(message.from(), "[sticker]"); + } else if (message.photo() != null) { + tgInbound(message.from(), "[photo]"); + } else if (message.audio() != null) { + tgInbound(message.from(), "[audio]"); + } else if (message.voice() != null) { + tgInbound(message.from(), "[voice]"); + } else if (message.document() != null) { + tgInbound(message.from(), "[document]"); } } catch (Exception e) { plugin.logger.error("handling update", e); @@ -182,37 +191,43 @@ public class TGBridge { }); } - private boolean getPinnedMessage(){ - + private boolean getPinnedMessage() { try { GetChatResponse response = bot.execute(new GetChat(CHAT_ID)); Message pinnedMessage = response.chat().pinnedMessage(); - if(pinnedMessage.from().id()==BOT_ID){ + if (pinnedMessage.from().id() == BOT_ID) { readOldPinnedMessage(pinnedMessage); updateOnlineStatus(); } - }catch (RuntimeException e){ + } catch (RuntimeException e) { plugin.logger.error("get group info failed."); throw e; } return true; } - private void readOldPinnedMessage(Message message){ + private void readOldPinnedMessage(Message message) { ONLINE_STATUS_MESSAGE_ID = message.messageId(); - String text = message.text(); - String[] s = text.split(DIVIDER, 2); - pinNote = s.length == 2 ? s[1] : "(use \"/setpin\" to set note here)"; + String markdownText = MarkdownString.markdownString(message); + + String[] s = markdownText.split(DIVIDER, 2); + pinNote = (s.length == 2) ? + s[1] : + "\r_\r" + MarkdownString.escapeStr("(use \"/setpin\" to set note here)") + "\r_\r"; } + /** + * @return markdown escaped str + */ private String genPinMessage() { - String onlineStatus = genOnlineStatus(); - if (pinNote != null && pinNote.length() > 1) { - return onlineStatus + "\n" + DIVIDER + pinNote; - } else { - return onlineStatus; - } + return (pinNote != null && pinNote.length() != 0) ? + genOnlineStatus() + "\n" + DIVIDER + pinNote : + genOnlineStatus(); } + + /** + * @return markdown escaped str + */ private String genOnlineStatus() { ArrayList out = new ArrayList<>(); String fmt = server.getAllPlayers().size() > 1 ? "%d players are currently connected to the proxy." : "%d player is currently connected to the proxy."; @@ -220,8 +235,8 @@ public class TGBridge { List registeredServers = new ArrayList<>(server.getAllServers()); for (RegisteredServer registeredServer : registeredServers) { LinkedList onServer = new LinkedList<>(); - for (Player player:registeredServer.getPlayersConnected()){ - if(!lastDisconnect.equals(player.getUsername())){ + for (Player player : registeredServer.getPlayersConnected()) { + if (!lastDisconnect.equals(player.getUsername())) { onServer.add(player); } } @@ -233,7 +248,7 @@ public class TGBridge { ); } } - return String.join("\n", out); + return MarkdownString.escapeStr(String.join("\n", out)); } protected void tgInbound(User user, String content) { @@ -248,25 +263,34 @@ public class TGBridge { } } - protected void outbound(String content) { - outbound(content, null); + + protected void outbound(String content, ParseMode parseMode) { + outbound(content, (sendMessage, sendResponse) -> { + if (!sendResponse.isOk()) { + plugin.logger.error(String.format("sendMessage error %d: %s", sendResponse.errorCode(), sendResponse.description())); + } + }, parseMode); } - protected void outbound(String content, @Nullable BiConsumer onResponse) { + protected void outbound(String content) {outbound(content, (ParseMode) null);} + + + protected void outbound(String content, @NotNull BiConsumer onResponse) {outbound(content, onResponse, null);} + + protected void outbound(String content, @NotNull BiConsumer onResponse, ParseMode parseMode) { if (bot == null) return; if (content.length() > 4000) { content = content.substring(0, 4000); } - bot.execute(new SendMessage(CHAT_ID, content), new Callback() { + + SendMessage sendMessage = new SendMessage(CHAT_ID, content); + if (parseMode != null) { + sendMessage.parseMode(parseMode); + } + bot.execute(sendMessage, new Callback() { @Override public void onResponse(SendMessage sendMessage, SendResponse sendResponse) { - if (onResponse == null) { - if (!sendResponse.isOk()) { - plugin.logger.error(String.format("sendMessage error %d: %s", sendResponse.errorCode(), sendResponse.description())); - } - } else { - onResponse.accept(sendMessage, sendResponse); - } + onResponse.accept(sendMessage, sendResponse); } @Override @@ -289,8 +313,8 @@ public class TGBridge { if (event.getPreviousServer().isEmpty()) { if (!event.getPlayer().hasPermission("vtools.globalchat.bypassbridge.join")) { String username = event.getPlayer().getUsername(); - if(lastDisconnect.equals(username)){ - lastDisconnect=""; + if (lastDisconnect.equals(username)) { + lastDisconnect = ""; } joinLeftAnnounce(String.format("%s joined the proxy", username)); } @@ -299,11 +323,12 @@ public class TGBridge { } private String lastDisconnect = ""; + @Subscribe public void onDisconnect(DisconnectEvent event) { if (!event.getPlayer().hasPermission("vtools.globalchat.bypassbridge.join")) { String username = event.getPlayer().getUsername(); - if(username!=null) { + if (username != null) { lastDisconnect = username; } joinLeftAnnounce(String.format("%s left the proxy", username)); @@ -313,15 +338,15 @@ public class TGBridge { @Subscribe - public void onServerPostConnect(ServerPostConnectEvent event){ + public void onServerPostConnect(ServerPostConnectEvent event) { updateRequests.add(new UpdateRequest()); } - private boolean PROXY_SHUT_DOWN = false; - private static class UpdateRequest {} + private static class UpdateRequest { + } private LinkedBlockingQueue updateRequests = new LinkedBlockingQueue<>(); @@ -350,7 +375,7 @@ public class TGBridge { } } }).start(); - new Thread(()->{ + new Thread(() -> { while (true) { if (PROXY_SHUT_DOWN) { return; @@ -360,7 +385,7 @@ public class TGBridge { oldestMessage = announceQueue.take(); } catch (InterruptedException ignored) {} - if(!currentAnnounce.isValid()){ + if (!currentAnnounce.isValid()) { currentAnnounce = new JoinLeftAnnounceMessage(oldestMessage); continue; } @@ -381,16 +406,16 @@ public class TGBridge { } protected boolean setOnlineStatusNotAvailable() { - return editOnlineStatusMessage("(proxy already shutdown)"); + return editOnlineStatusMessage("(proxy already shutdown)"); } - protected boolean editOnlineStatusMessage(String text) { + protected boolean editOnlineStatusMessage(String markdownText) { if (ONLINE_STATUS_MESSAGE_ID < 1) { return true; } BaseResponse response; try { - response = bot.execute(new EditMessageText(CHAT_ID, ONLINE_STATUS_MESSAGE_ID, text)); + response = bot.execute(new EditMessageText(CHAT_ID, ONLINE_STATUS_MESSAGE_ID, markdownText).parseMode(ParseMode.Markdown)); } catch (RuntimeException e) {return false;} return response != null && response.isOk(); } @@ -422,24 +447,25 @@ public class TGBridge { } SendResponse response; try { - response = bot.execute(new SendMessage(CHAT_ID, text.toString())); + response = bot.execute(new SendMessage(CHAT_ID, text.toString())); } catch (RuntimeException e) { messageId = -1; return; } - if(response.isOk() == false){ + if (response.isOk() == false) { messageId = -1; return; } messageId = response.message().messageId(); } - protected JoinLeftAnnounceMessage(){ + protected JoinLeftAnnounceMessage() { messageId = 0; time = 0; text = new StringBuilder(); } //dummy + private void addLines(List messages) { for (String message : messages) { text.append('\n').append(message); @@ -448,7 +474,7 @@ public class TGBridge { } private void updateAnnounceMessage() { - if(!isValid()){ + if (!isValid()) { plugin.logger.error("message should only push to a valid object"); return; } @@ -465,7 +491,7 @@ public class TGBridge { private JoinLeftAnnounceMessage currentAnnounce = new JoinLeftAnnounceMessage(); private LinkedBlockingQueue announceQueue = new LinkedBlockingQueue<>(); - private void joinLeftAnnounce(String message){ + private void joinLeftAnnounce(String message) { announceQueue.add(message); }