1
0
Fork 0
forked from mc/VTools

Compare commits

..

30 commits

Author SHA1 Message Date
85e1bf1ca4 修理最后一分钟不更新置顶的顺序问题,以及使用shutdown命令双重提示的问题 2023-12-25 00:03:28 +08:00
d647b99465 tp修改权限,find输出可点击 2023-12-24 23:35:45 +08:00
115364f202 增加补全 2023-12-24 23:35:08 +08:00
5a975a1195 调用systemd来关机 2023-12-21 18:18:08 +08:00
513ba7ad8c fix assert 2023-12-21 18:12:15 +08:00
7581ef4595 分开关机信息,合并邻近的日志输出和join/left,增加永久性关闭关机计时器(直到下次开机)的隐藏命令 2023-12-21 17:52:27 +08:00
cba3248484 split package name and re-write server closer 2023-12-21 15:58:21 +08:00
1a3c8872d1 整理代码 2023-12-21 15:53:37 +08:00
f36607df8d readme 2023-12-21 15:53:13 +08:00
49a5dd76e7 join/left消息堆叠时检测是否已经跨分钟 2023-12-08 18:02:31 +08:00
c178415969 自动关机增加计时器 2023-12-08 17:46:01 +08:00
564e1eb5fa 关机时发tg消息 2023-12-08 01:21:23 +08:00
0c17f2cd80 支持转发附件消息的描述,支持显示是否是回复信息。 2023-12-06 23:57:05 +08:00
301931866a 应edison要求,仅listen localhost 2023-11-15 23:15:02 +08:00
afae76d9dd 给mention bot一个获取玩家列表的http接口 2023-11-15 22:18:20 +08:00
780834a922 取消置顶消息可编辑检测 2023-11-12 18:35:58 +08:00
6fa5ab6824 gitignore: ignore dependency-reduced-pom.xml 2023-11-12 14:45:25 +08:00
b7e4b668fe 自动关机 2023-11-12 14:18:24 +08:00
425210963f 一堆bug 2023-11-02 06:47:47 +08:00
e4c91adac0 反解析md,没测试 2023-11-02 05:04:55 +08:00
c2ee4fca4f 维护一个置顶 2023-10-28 17:11:02 +08:00
52b3a5016d Merge pull request 'master' (#1) from mc/VTools:master into master
Reviewed-on: #1
2023-10-28 15:23:27 +08:00
6b3509b4a2 Merge remote-tracking branch 'refs/remotes/origin/master' 2023-05-29 00:15:24 +08:00
d1f9dc803d 不优雅的fix bug 2023-05-29 00:12:32 +08:00
706c5fade5 Merge pull request '合并join消息、增加一个置顶消息来显示在线用户' (#1) from NaAlOH4/VTools:master into master
Reviewed-on: mc/VTools#1
2023-05-28 18:01:12 +08:00
aebff1a12a 合并一分钟内的进入/离开消息,尝试修理离开时置顶消息混乱的问题 2023-05-28 17:42:51 +08:00
a57cd87fff add a message that always show online status 2023-05-28 15:05:38 +08:00
d1955da813
fix /server 2023-05-28 14:17:08 +08:00
7cfb423c70
/server override 2023-05-28 12:42:39 +08:00
19f86511e0
tgbot /list 2023-05-28 11:19:16 +08:00
16 changed files with 1312 additions and 129 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
out/ out/
target/ target/
*.iml *.iml
/dependency-reduced-pom.xml

View file

@ -1,6 +1,8 @@
# VTools # VTools
Tools for Velocity proxy server. Tools for Velocity proxy server.
add telegram/auto shutdown support for spec server.
## Commands ## Commands
|Command|What is does?|Permission| |Command|What is does?|Permission|
|-------|-------------|----------| |-------|-------------|----------|

View file

@ -0,0 +1,65 @@
package com.alpt.vtools.listeners;
import com.google.gson.Gson;
import com.sun.net.httpserver.HttpServer;
import com.velocitypowered.api.proxy.Player;
import de.strifel.VTools.VTools;
import org.jetbrains.annotations.TestOnly;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.Collection;
public class OnlinePlayerQueryService {
public static OnlinePlayerQueryService INSTANCE;
private final VTools plugin;
private OnlinePlayerQueryService(VTools plugin) {
this.plugin = plugin;
}
private static final Gson gson = new Gson();
public static OnlinePlayerQueryService createInstance(VTools plugin) {
if (INSTANCE != null) return INSTANCE;
INSTANCE = new OnlinePlayerQueryService(plugin);
INSTANCE.register();
return INSTANCE;
}
private void register() {
int port = Integer.parseInt(plugin.getConfigOrDefault("http_service_port", "17611"));
try {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
server.createContext("/api/getOnlinePlayers", exchange -> {
Collection<Player> players = plugin.getServer().getAllPlayers();
ArrayList<String> playerNames = new ArrayList<>(players.size());
for (Player player : players) {
playerNames.add(player.getUsername());
}
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, 0);
try (OutputStream os = exchange.getResponseBody()) {
String response = gson.toJson(playerNames);
os.write(response.getBytes());
}
});
server.start();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@TestOnly
public static void main(String[] args) {
ArrayList<String> arrayList = new ArrayList<>();
arrayList.add("jerry");
System.out.println(gson.toJson(arrayList));
}
}

View file

@ -0,0 +1,210 @@
package com.alpt.vtools.listeners;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.DisconnectEvent;
import com.velocitypowered.api.event.player.ServerConnectedEvent;
import com.velocitypowered.api.proxy.ProxyServer;
import de.strifel.VTools.VTools;
import de.strifel.VTools.listeners.TGBridge;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.LinkedBlockingQueue;
public class ServerCloser {
private static final long MINUTE = 60L * 1000;
private final VTools plugin;
private final ProxyServer server;
private final LinkedBlockingQueue<Counter> lock = new LinkedBlockingQueue<>();
public static ServerCloser INSTANCE;
private ServerCloser(VTools plugin) {
this.plugin = plugin;
this.server = plugin.getServer();
}
private void close() {
if (!INSTANCE.server.getAllPlayers().isEmpty()) {
plugin.logger.error("ServerCloser: 定时器到点时发现服务器有人。这不应发生,因为定时器本应该被打断。");
TGBridge.error("ServerCloser: #bug @NaAlOH4 定时器到点时发现服务器有人。这不应发生,因为定时器本应该被打断。");
return;
}
String apiUrl = plugin.getConfigOrDefault("azure_api_url", "https://example.com/");
try {
Runtime.getRuntime().exec(new String[]{"systemd-run", "--description=pymcd_stop", "--quiet", "--user", "--collect", "-p", "StandardInput=data", "-p", "StandardInputData=c2V0IC1lCnA9IiQocGdyZXAgLWYgcHltY2QucHkgLUFvKSIKa2lsbCAtSU5UICIkcCIKdGFpbCAtLXBpZD0iJHAiIC1mIC9kZXYvbnVsbApjdXJsIC1zIC1IICJDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24iIC1kICJ7XCJhY3Rpb25cIjpcInN0b3BcIn0iICIkMSIK", "bash", "-s", apiUrl});
} catch (IOException ignored) {}
}
private boolean firstInit;
public static ServerCloser createInstance(VTools plugin) {
if (INSTANCE != null) return INSTANCE;
INSTANCE = new ServerCloser(plugin);
INSTANCE.server.getEventManager().register(plugin, INSTANCE);
INSTANCE.firstInit = true;
INSTANCE.update();
return INSTANCE;
}
@SuppressWarnings("java:S106")
@Subscribe
public void onDisconnect(DisconnectEvent event) {
update();
}
@Subscribe
public void onServerConnected(ServerConnectedEvent event) {
update();
}
private void update() {
if (server.getAllPlayers().isEmpty()) {
initCountdown();
return;
}
boolean canceledSomething = false;
synchronized (lock) {
while (!lock.isEmpty()) {
lock.poll().cancel();
canceledSomething = true;
}
}
if (canceledSomething) {
plugin.logger.info("ServerCloser: 有玩家在线,干掉任何可能的关机计时器。");
}
TGBridge.setShuttingDown(-1);
}
private void initCountdown() {
startCountdown(firstInit);
firstInit = false;
}
private void startCountdown(boolean isFirstInit) {
synchronized (lock) {
while (!lock.isEmpty()) {
lock.poll().cancel();
}
lock.add(new Counter(INSTANCE, isFirstInit ? 60 : 15).start());
}
}
public void fastShutdown() {
synchronized (lock) {
while (!lock.isEmpty()) {
lock.poll().cancel();
}
lock.add(new Counter(INSTANCE, 1).reason("收到关机命令已经过了 %s 分钟,即将关机。").noLastWarning().start());
}
}
public void slowShutdown() {
synchronized (lock) {
while (!lock.isEmpty()) {
lock.poll().cancel();
}
lock.add(new Counter(INSTANCE, 60).reason("虽然关机被取消,但 %s 分钟内依旧没有玩家上线,即将关机。").start());
}
}
public void noShutdown() {
synchronized (lock) {
while (!lock.isEmpty()) {
lock.poll().cancel();
}
}
}
private static class Counter {
private final ServerCloser instance;
private final int totalMin;
private int minLeft;
private boolean canceled = false;
private String reason;
private static final String DEFAULT_REASON = "距离上一个玩家离开已经过了 %s 分钟,即将关机。";
private boolean lastWarning = true;
protected Counter(ServerCloser instance, int minute) {
this.instance = instance;
totalMin = minute;
minLeft = minute;
this.reason = DEFAULT_REASON;
}
public Counter reason(String reason) {
this.reason = reason;
return this;
}
public Counter noLastWarning() {
lastWarning = false;
return this;
}
protected synchronized void cancel() {
canceled = true;
this.notifyAll();
}
private boolean started = false;
private final Object startLock = new Object();
protected Counter start() {
synchronized (startLock) {
if (started) return this;
started = true;
}
new Thread(this::run).start();
return this;
}
private synchronized void run() {
while (true) {
if (canceled) return;
TGBridge.setShuttingDown(minLeft);
switch (minLeft) {
case 1 -> {
String msg = "服务器即将在一分钟后关机,使用 /fuck 以取消。";
instance.plugin.logger.info(msg);
if (lastWarning) {
TGBridge.log(msg);
}
}
case 0 -> {
String msg = "ServerCloser: " + reason.formatted(totalMin);
instance.plugin.logger.info(msg);
TGBridge.log(msg);
instance.close();
canceled = true;
}
case -1 -> {
instance.plugin.logger.error("ServerCloser: 定时器写炸了。");
TGBridge.error("ServerCloser: #bug @NaAlOH4 定时器写炸了。");
canceled = true;
}
default -> instance.plugin.logger.info("服务器即将在 {} 分钟后关机", minLeft);
}
try {
this.wait(MINUTE);
} catch (InterruptedException ignored) {}
if (canceled) return;
if (!instance.server.getAllPlayers().isEmpty()) {
instance.plugin.logger.error("ServerCloser: 定时器发现服务器有人。这不应发生,因为定时器本应该被直接打断。");
TGBridge.error("ServerCloser: #bug @NaAlOH4 定时器发现服务器有人。这不应发生,因为定时器本应该被直接打断。");
canceled = true;
}
if (canceled) return;
minLeft--;
}
}
}
}

View file

@ -0,0 +1,241 @@
package com.alpt.vtools.utils;
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<MessageEntity.Type, BiFunction<StringBuilder, MessageEntity, StringBuilder>> formats;
static {
Map<MessageEntity.Type, BiFunction<StringBuilder, MessageEntity, StringBuilder>> 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 {
@Override
public String toString() {
return "StringBlock{" +
"text='" + text + '\'' +
", entities=" + entities +
", offset=" + offset +
'}';
}
private final String text;
private Map<MessageEntity.Type, MessageEntity> 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()) {
BiFunction<StringBuilder, MessageEntity, StringBuilder> repeater = (sb, e) -> sb;
s = formats.getOrDefault(entry.getKey(), repeater).apply(s, entry.getValue()); // 无用赋值...理论上
}
return s.toString();
}
}
public static String markdownString(Message message) {
if (message.entities() == null || message.entities().length == 0) return message.text();
List<StringBlock> 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;
}
if (stringBlock.text.length() + stringBlock.offset > end) {
throw new IllegalStateException("奶冰可爱捏");
}
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<StringBlock> createStringBlocks(Message message) {
List<StringBlock> stringBlocks = new ArrayList<>();
Set<Integer> 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<Integer> 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()) {
if (!formats.containsKey(entity.type())) {
continue;
}
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) : String.format("%s,%s,%s,%s", blockStart, blockEnd, entityStart, 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();
}
}

View file

@ -1,5 +1,7 @@
package de.strifel.VTools; package de.strifel.VTools;
import com.alpt.vtools.listeners.OnlinePlayerQueryService;
import com.alpt.vtools.listeners.ServerCloser;
import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.plugin.Plugin; import com.velocitypowered.api.plugin.Plugin;
@ -9,11 +11,17 @@ import de.strifel.VTools.commands.*;
import de.strifel.VTools.listeners.*; import de.strifel.VTools.listeners.*;
import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextColor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.yaml.snakeyaml.Yaml;
import javax.inject.Inject; import javax.inject.Inject;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
@Plugin(id = "vtools", name="VTools", version="1.0-SNAPSHOT", description="Some commands!", authors="unnamed") @Plugin(id = "vtools", name = "VTools", version = "1.0-SNAPSHOT", description = "Some commands!", authors = "unnamed")
public class VTools { public class VTools {
private final ProxyServer server; private final ProxyServer server;
public final Logger logger; public final Logger logger;
@ -40,10 +48,48 @@ public class VTools {
server.getCommandManager().register("staffchat", new CommandStaffChat(server), "sc"); server.getCommandManager().register("staffchat", new CommandStaffChat(server), "sc");
server.getCommandManager().register("restart", new CommandRestart(server)); server.getCommandManager().register("restart", new CommandRestart(server));
server.getCommandManager().register("tps", new CommandTp(server), "jump"); server.getCommandManager().register("tps", new CommandTp(server), "jump");
server.getCommandManager().register("server", new CommandServer(server), "serverv");
server.getCommandManager().register("servers", new CommandServers(server), "allservers"); server.getCommandManager().register("servers", new CommandServers(server), "allservers");
loadConfig();
new TGBridge(this).register(); new TGBridge(this).register();
new PlayerStatus(this).register(); new PlayerStatus(this).register();
new GlobalChat(this).register(); new GlobalChat(this).register();
ServerCloser.createInstance(this);
OnlinePlayerQueryService.createInstance(this);
}
private Map<String, String> config = new HashMap<>();
public String getConfig(String k) {
return config.get(k);
}
public String getConfigOrDefault(String k, String v) {
return config.getOrDefault(k, v);
}
private void loadConfig() {
try {
File configDir = dataDirectory.toFile();
if (!configDir.exists()) {
configDir.mkdir();
}
File configFile = new File(configDir, "config.yaml");
if (!configFile.exists()) {
String defVal = """
chat_id: "0"
token: ""
azure_api_url: "https://example.com/"
http_service_port: 17611
""";
Files.write(Path.of(configFile.toURI()), defVal.getBytes(StandardCharsets.UTF_8));
}
String configStr = Files.readString(Path.of(configFile.toURI()), StandardCharsets.UTF_8);
Yaml yaml = new Yaml();
config = yaml.load(configStr);
} catch (Exception e) {
logger.error("parsing config", e);
}
} }
public ProxyServer getServer() { public ProxyServer getServer() {

View file

@ -5,6 +5,8 @@ import com.velocitypowered.api.command.SimpleCommand;
import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.ProxyServer;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -29,7 +31,12 @@ public class CommandFind implements SimpleCommand {
if (strings.length == 1) { if (strings.length == 1) {
Optional<Player> player = server.getPlayer(strings[0]); Optional<Player> player = server.getPlayer(strings[0]);
if (player.isPresent() && player.get().getCurrentServer().isPresent()) { if (player.isPresent() && player.get().getCurrentServer().isPresent()) {
commandSource.sendMessage(Component.text("Player " + strings[0] + " is on " + player.get().getCurrentServer().get().getServerInfo().getName() + "!").color(COLOR_YELLOW)); String serverName = player.get().getCurrentServer().get().getServerInfo().getName();
commandSource.sendMessage(Component.empty()
.append(Component.text("Player " + strings[0] + " is on ").color(COLOR_YELLOW))
.append(Component.text(serverName).clickEvent(ClickEvent.runCommand("/server " + serverName)).color(NamedTextColor.GRAY))
.append(Component.text("!").color(COLOR_YELLOW))
);
} else { } else {
commandSource.sendMessage(Component.text("The player is not online!").color(COLOR_YELLOW)); commandSource.sendMessage(Component.text("The player is not online!").color(COLOR_YELLOW));
} }

View file

@ -54,7 +54,7 @@ public class CommandGlobalChat implements SimpleCommand {
@Override @Override
public List<String> suggest(Invocation invocation) { public List<String> suggest(Invocation invocation) {
return new ArrayList<String>(); return new ArrayList<>();
} }
@Override @Override

View file

@ -1,6 +1,5 @@
package de.strifel.VTools.commands; package de.strifel.VTools.commands;
import com.velocitypowered.api.command.CommandSource;
import com.velocitypowered.api.command.SimpleCommand; import com.velocitypowered.api.command.SimpleCommand;
import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.ProxyServer;
@ -19,7 +18,6 @@ public class CommandRestart implements SimpleCommand {
@Override @Override
public void execute(SimpleCommand.Invocation invocation) { public void execute(SimpleCommand.Invocation invocation) {
CommandSource commandSource = invocation.source();
String[] strings = invocation.arguments(); String[] strings = invocation.arguments();
if (strings.length > 0) { if (strings.length > 0) {
@ -33,7 +31,7 @@ public class CommandRestart implements SimpleCommand {
@Override @Override
public List<String> suggest(SimpleCommand.Invocation invocation) { public List<String> suggest(SimpleCommand.Invocation invocation) {
return new ArrayList<String>(); return new ArrayList<>();
} }
@Override @Override

View file

@ -9,10 +9,7 @@ import com.velocitypowered.api.proxy.ServerConnection;
import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.proxy.server.RegisteredServer;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import java.util.ArrayList; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -39,23 +36,20 @@ public class CommandSend implements SimpleCommand {
for (Player player : server.getAllPlayers()) { for (Player player : server.getAllPlayers()) {
oPlayer.add(player); oPlayer.add(player);
} }
} } else if (strings[0].equals("current")) {
else if (strings[0].equals("current")) {
if (commandSource instanceof Player) { if (commandSource instanceof Player) {
Player playerSource = (Player)commandSource; Player playerSource = (Player) commandSource;
Optional<ServerConnection> conn = playerSource.getCurrentServer(); Optional<ServerConnection> conn = playerSource.getCurrentServer();
if (conn.isPresent()) { if (conn.isPresent()) {
for (Player player : conn.get().getServer().getPlayersConnected()) { for (Player player : conn.get().getServer().getPlayersConnected()) {
oPlayer.add(player); oPlayer.add(player);
} }
} }
} } else {
else {
commandSource.sendMessage(Component.text("Command is only for players.").color(COLOR_RED)); commandSource.sendMessage(Component.text("Command is only for players.").color(COLOR_RED));
return; return;
} }
} } else {
else {
Optional<Player> p = server.getPlayer(strings[0]); Optional<Player> p = server.getPlayer(strings[0]);
if (p.isPresent()) { if (p.isPresent()) {
oPlayer.add(p.get()); oPlayer.add(p.get());
@ -94,9 +88,9 @@ public class CommandSend implements SimpleCommand {
} }
})); }));
String sendResults = results.isEmpty() ? "nothing" : results.entrySet().stream().map( String sendResults = results.isEmpty() ? "nothing" : results.entrySet().stream().map(
entry -> String.format("%s : %d", entry.getKey(), entry.getValue()) entry -> String.format("%s : %d", entry.getKey(), entry.getValue())
).collect(Collectors.joining("\n")); ).collect(Collectors.joining("\n"));
commandSource.sendMessage(Component.text(String.format("Send Results:\n%s", sendResults)).color(COLOR_YELLOW)); commandSource.sendMessage(Component.text("Send Results:%n%s".formatted(sendResults)).color(COLOR_YELLOW));
}).start(); }).start();
} else { } else {
commandSource.sendMessage(Component.text("The server or user does not exist!").color(COLOR_RED)); commandSource.sendMessage(Component.text("The server or user does not exist!").color(COLOR_RED));
@ -108,21 +102,26 @@ public class CommandSend implements SimpleCommand {
public List<String> suggest(SimpleCommand.Invocation invocation) { public List<String> suggest(SimpleCommand.Invocation invocation) {
String[] currentArgs = invocation.arguments(); String[] currentArgs = invocation.arguments();
switch (currentArgs.length) {
List<String> arg = new ArrayList<String>(); case 0, 1 -> {
if (currentArgs.length <= 1) { List<String> args = new ArrayList<>(server.getPlayerCount() + 2);
arg.add("all"); args.add("all");
arg.add("current"); args.add("current");
for (Player player : server.getAllPlayers()) { for (Player player : server.getAllPlayers()) {
arg.add(player.getUsername()); args.add(player.getUsername());
}
return currentArgs.length==0?args:
args.stream().filter(arg->arg.regionMatches(true,0,currentArgs[0],0,currentArgs[0].length())).toList();
} }
return arg; case 2 -> {
} else if (currentArgs.length == 2) { return server.getAllServers().stream().map(s -> s.getServerInfo().getName())
for (RegisteredServer server : server.getAllServers()) { .filter(name -> name.regionMatches(true, 0, currentArgs[1], 0, currentArgs[1].length()))
arg.add(server.getServerInfo().getName()); .toList();
}
default -> {
return new ArrayList<>(0);
} }
} }
return arg;
} }
public boolean hasPermission(SimpleCommand.Invocation invocation) { public boolean hasPermission(SimpleCommand.Invocation invocation) {

View file

@ -0,0 +1,41 @@
package de.strifel.VTools.commands;
import com.velocitypowered.api.command.SimpleCommand;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import de.strifel.VTools.commands.utils.ServerUtil;
import net.kyori.adventure.text.Component;
import static de.strifel.VTools.VTools.COLOR_RED;
public class CommandServer extends ServerUtil implements SimpleCommand {
private final ProxyServer server;
public CommandServer(ProxyServer server) {
super(server);
this.server = server;
}
@Override
public void execute(final SimpleCommand.Invocation invocation) {
if (invocation.source() instanceof Player) {
if (
invocation.source().hasPermission("velocity.command.server") ||
server.getAllPlayers().stream().anyMatch(p -> p.hasPermission("vtools.send") && p.hasPermission("vtools.send.recvmsg"))) {
super.execute(invocation);
}
else {
invocation.source().sendMessage(Component.text("Supervisor offline.").color(COLOR_RED));
}
}
else {
invocation.source().sendMessage(Component.text("Command is only for players.").color(COLOR_RED));
}
}
@Override
public boolean hasPermission(Invocation commandInvocation) {
return commandInvocation.source().hasPermission("vtools.server.auto");
}
}

View file

@ -34,7 +34,7 @@ public class CommandServers implements SimpleCommand {
@Override @Override
public List<String> suggest(SimpleCommand.Invocation invocation) { public List<String> suggest(SimpleCommand.Invocation invocation) {
return new ArrayList<String>(); return new ArrayList<>();
} }
@Override @Override

View file

@ -26,11 +26,11 @@ public class CommandTp implements SimpleCommand {
CommandSource commandSource = commandInvocation.source(); CommandSource commandSource = commandInvocation.source();
String[] strings = commandInvocation.arguments(); String[] strings = commandInvocation.arguments();
if (commandSource instanceof Player) { if (commandSource instanceof Player playerCommandSource) {
if (strings.length == 1) { if (strings.length == 1) {
Optional<Player> player = server.getPlayer(strings[0]); Optional<Player> player = server.getPlayer(strings[0]);
if (player.isPresent()) { if (player.isPresent()) {
player.get().getCurrentServer().ifPresent(serverConnection -> ((Player) commandSource).createConnectionRequest(serverConnection.getServer()).fireAndForget()); player.get().getCurrentServer().ifPresent(serverConnection -> playerCommandSource.createConnectionRequest(serverConnection.getServer()).fireAndForget());
commandSource.sendMessage(Component.text("Connecting to the server of " + strings[0]).color(COLOR_YELLOW)); commandSource.sendMessage(Component.text("Connecting to the server of " + strings[0]).color(COLOR_YELLOW));
} else { } else {
commandSource.sendMessage(Component.text("Player does not exists.").color(COLOR_RED)); commandSource.sendMessage(Component.text("Player does not exists.").color(COLOR_RED));
@ -56,6 +56,6 @@ public class CommandTp implements SimpleCommand {
@Override @Override
public boolean hasPermission(Invocation commandInvocation) { public boolean hasPermission(Invocation commandInvocation) {
return commandInvocation.source().hasPermission("VTools.tps"); return commandInvocation.source().hasPermission("VTools.tps") || commandInvocation.source().hasPermission("vtools.server.auto");
} }
} }

View file

@ -0,0 +1,176 @@
package de.strifel.VTools.commands.utils;
import static net.kyori.adventure.text.event.HoverEvent.showText;
import com.google.common.collect.ImmutableList;
import com.velocitypowered.api.command.CommandSource;
import com.velocitypowered.api.command.SimpleCommand;
import com.velocitypowered.api.permission.Tristate;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.ServerConnection;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import com.velocitypowered.api.proxy.server.ServerInfo;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.TranslatableComponent;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
// https://github.com/PaperMC/Velocity/blob/40b76c633276fcd6aea165baeae74039b2d059c4/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ServerCommand.java
/**
* Implements Velocity's {@code /server} command.
*/
public abstract class ServerUtil implements SimpleCommand {
public static final int MAX_SERVERS_TO_LIST = 50;
private final ProxyServer server;
public ServerUtil(ProxyServer server) {
this.server = server;
}
@Override
public void execute(final SimpleCommand.Invocation invocation) {
final CommandSource source = invocation.source();
final String[] args = invocation.arguments();
if (!(source instanceof Player)) {
source.sendMessage(CommandMessages.PLAYERS_ONLY);
return;
}
Player player = (Player) source;
if (args.length == 1) {
// Trying to connect to a server.
String serverName = args[0];
Optional<RegisteredServer> toConnect = server.getServer(serverName);
if (toConnect.isEmpty()) {
player.sendMessage(CommandMessages.SERVER_DOES_NOT_EXIST.args(Component.text(serverName)));
return;
}
player.createConnectionRequest(toConnect.get()).fireAndForget();
} else {
outputServerInformation(player);
}
}
private void outputServerInformation(Player executor) {
String currentServer = executor.getCurrentServer().map(ServerConnection::getServerInfo)
.map(ServerInfo::getName).orElse("<unknown>");
executor.sendMessage(Component.translatable(
"velocity.command.server-current-server",
NamedTextColor.YELLOW,
Component.text(currentServer)));
List<RegisteredServer> servers = BuiltinCommandUtil.sortedServerList(server);
if (servers.size() > MAX_SERVERS_TO_LIST) {
executor.sendMessage(Component.translatable(
"velocity.command.server-too-many", NamedTextColor.RED));
return;
}
// Assemble the list of servers as components
TextComponent.Builder serverListBuilder = Component.text()
.append(Component.translatable("velocity.command.server-available",
NamedTextColor.YELLOW))
.append(Component.space());
for (int i = 0; i < servers.size(); i++) {
RegisteredServer rs = servers.get(i);
serverListBuilder.append(formatServerComponent(currentServer, rs));
if (i != servers.size() - 1) {
serverListBuilder.append(Component.text(", ", NamedTextColor.GRAY));
}
}
executor.sendMessage(serverListBuilder.build());
}
private TextComponent formatServerComponent(String currentPlayerServer, RegisteredServer server) {
ServerInfo serverInfo = server.getServerInfo();
TextComponent serverTextComponent = Component.text(serverInfo.getName());
int connectedPlayers = server.getPlayersConnected().size();
TranslatableComponent playersTextComponent;
if (connectedPlayers == 1) {
playersTextComponent = Component.translatable(
"velocity.command.server-tooltip-player-online");
} else {
playersTextComponent = Component.translatable(
"velocity.command.server-tooltip-players-online");
}
playersTextComponent = playersTextComponent.args(Component.text(connectedPlayers));
if (serverInfo.getName().equals(currentPlayerServer)) {
serverTextComponent = serverTextComponent.color(NamedTextColor.GREEN)
.hoverEvent(
showText(
Component.translatable("velocity.command.server-tooltip-current-server")
.append(Component.newline())
.append(playersTextComponent))
);
} else {
serverTextComponent = serverTextComponent.color(NamedTextColor.GRAY)
.clickEvent(ClickEvent.runCommand("/server " + serverInfo.getName()))
.hoverEvent(
showText(
Component.translatable("velocity.command.server-tooltip-offer-connect-server")
.append(Component.newline())
.append(playersTextComponent))
);
}
return serverTextComponent;
}
@Override
public List<String> suggest(final SimpleCommand.Invocation invocation) {
final String[] currentArgs = invocation.arguments();
Stream<String> possibilities = server.getAllServers().stream()
.map(rs -> rs.getServerInfo().getName());
if (currentArgs.length == 0) {
return possibilities.collect(Collectors.toList());
} else if (currentArgs.length == 1) {
return possibilities
.filter(name -> name.regionMatches(true, 0, currentArgs[0], 0, currentArgs[0].length()))
.collect(Collectors.toList());
} else {
return ImmutableList.of();
}
}
@Override
public boolean hasPermission(final SimpleCommand.Invocation invocation) {
return invocation.source().getPermissionValue("velocity.command.server") != Tristate.FALSE;
}
}
// https://github.com/PaperMC/Velocity/blob/b0862d2d16c4ba7560d3f24c824d78793ac3d9e0/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/CommandMessages.java
class CommandMessages {
public static final TranslatableComponent PLAYERS_ONLY = Component.translatable(
"velocity.command.players-only", NamedTextColor.RED);
public static final TranslatableComponent SERVER_DOES_NOT_EXIST = Component.translatable(
"velocity.command.server-does-not-exist", NamedTextColor.RED);
}
// https://github.com/PaperMC/Velocity/blob/b0862d2d16c4ba7560d3f24c824d78793ac3d9e0/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/BuiltinCommandUtil.java
class BuiltinCommandUtil {
private BuiltinCommandUtil() {
throw new AssertionError();
}
static List<RegisteredServer> sortedServerList(ProxyServer proxy) {
List<RegisteredServer> servers = new ArrayList<>(proxy.getAllServers());
servers.sort(Comparator.comparing(RegisteredServer::getServerInfo));
return Collections.unmodifiableList(servers);
}
}

View file

@ -1,47 +1,67 @@
package de.strifel.VTools.listeners; package de.strifel.VTools.listeners;
import com.google.common.collect.ImmutableList; import com.alpt.vtools.listeners.ServerCloser;
import com.alpt.vtools.utils.MarkdownString;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.pengrad.telegrambot.Callback; import com.pengrad.telegrambot.Callback;
import com.pengrad.telegrambot.TelegramBot; import com.pengrad.telegrambot.TelegramBot;
import com.pengrad.telegrambot.UpdatesListener; import com.pengrad.telegrambot.UpdatesListener;
import com.pengrad.telegrambot.model.Message;
import com.pengrad.telegrambot.model.Update; import com.pengrad.telegrambot.model.Update;
import com.pengrad.telegrambot.model.User; 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; import com.pengrad.telegrambot.request.SendMessage;
import com.pengrad.telegrambot.response.BaseResponse;
import com.pengrad.telegrambot.response.GetChatResponse;
import com.pengrad.telegrambot.response.SendResponse; import com.pengrad.telegrambot.response.SendResponse;
import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.DisconnectEvent; import com.velocitypowered.api.event.connection.DisconnectEvent;
import com.velocitypowered.api.event.player.ServerConnectedEvent; import com.velocitypowered.api.event.player.ServerConnectedEvent;
import com.velocitypowered.api.event.player.ServerPostConnectEvent;
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; import com.velocitypowered.api.event.proxy.ProxyShutdownEvent;
import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.proxy.server.RegisteredServer;
import de.strifel.VTools.VTools; import de.strifel.VTools.VTools;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import org.yaml.snakeyaml.Yaml; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException; import java.io.IOException;
import java.io.File; import java.util.*;
import java.nio.charset.StandardCharsets; import java.util.concurrent.LinkedBlockingQueue;
import java.nio.file.Files; import java.util.function.BiConsumer;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class TGBridge { public class TGBridge {
private static final long MINUTE = 60_000;
private final VTools plugin; private final VTools plugin;
private final ProxyServer server; private final ProxyServer server;
@SuppressWarnings("java:S3008")
protected static TGBridge INSTANCE = null; protected static TGBridge INSTANCE = null;
private TelegramBot bot; private TelegramBot bot;
private String TOKEN = ""; private String TOKEN = "";
private HashSet<Long> CHAT_IDS = new HashSet<>(); private long CHAT_ID = 0L;
private int ONLINE_STATUS_MESSAGE_ID = -1;
private long backoffSec = 1L; private long backoffSec = 1L;
public TGBridge(VTools plugin) { private static final String DIVIDER = "————————\n";
/**
* markdown escaped
*/
private String pinNote;
@SuppressWarnings("java:S3010")
public TGBridge(@NotNull VTools plugin) {
if (INSTANCE != null) {
throw new IllegalStateException();
}
INSTANCE = this; INSTANCE = this;
this.plugin = plugin; this.plugin = plugin;
this.server = plugin.getServer(); this.server = plugin.getServer();
@ -50,85 +70,141 @@ public class TGBridge {
public void register() { public void register() {
server.getEventManager().register(plugin, this); server.getEventManager().register(plugin, this);
botInit(); botInit();
initUpdateThread();
} }
private void loadConfig() { private void loadConfig() {
try { synchronized (this) {
File configDir = plugin.dataDirectory.toFile(); CHAT_ID = Long.parseLong(plugin.getConfigOrDefault("chat_id", "0"));
if (!configDir.exists()) { TOKEN = plugin.getConfigOrDefault("token", "");
configDir.mkdir();
}
File configFile = new File(configDir, "config.yaml");
if (!configFile.exists()) {
Files.write(Path.of(configFile.toURI()), "chat_id:\n- \"0\"\ntoken: \"\"\n".getBytes(StandardCharsets.UTF_8));
}
String configStr = Files.readString(Path.of(configFile.toURI()), StandardCharsets.UTF_8);
Yaml yaml = new Yaml();
Map<String, Object> config = yaml.load(configStr);
synchronized (this) {
((List<String>)config.getOrDefault("chat_id", List.of())).stream().forEach(s -> this.CHAT_IDS.add(Long.parseLong(s)));
this.CHAT_IDS.removeIf(id -> id == 0L);
this.TOKEN = (String)config.getOrDefault("token", "");
}
} catch (Exception e) {
plugin.logger.error("parsing config", e);
} }
} }
private void botInit() { private void botInit() {
loadConfig(); loadConfig();
if (TOKEN.isEmpty() || CHAT_IDS.isEmpty()) return; if (TOKEN.isEmpty() || CHAT_ID == 0L) return;
bot = new TelegramBot(TOKEN); bot = new TelegramBot(TOKEN);
getPinnedMessage();
bot.setUpdatesListener(updates -> { bot.setUpdatesListener(updates -> {
backoffSec = 1L; backoffSec = 1L;
for (Update update : updates) { for (Update update : updates) {
try {
if (update != null &&
update.message() != null &&
update.message().chat() != null &&
CHAT_IDS.contains(update.message().chat().id()) &&
update.message().from() != null
) {
if (update.message().text() != null && !update.message().text().isEmpty()) {
String msg = update.message().text();
if (msg.equals("/list")) {
ArrayList<String> 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.";
out.add(String.format(fmt, server.getAllPlayers().size()));
List<RegisteredServer> servers = new ArrayList<>(server.getAllServers());
for (RegisteredServer server : servers) {
List<Player> onServer = ImmutableList.copyOf(server.getPlayersConnected());
if (!onServer.isEmpty()) {
out.add(String.format("[%s] (%d): %s",
server.getServerInfo().getName(),
onServer.size(),
onServer.stream().map(Player::getUsername).collect(Collectors.joining(", ")))
);
try {
if (update == null || update.message() == null) continue;
Message message = update.message();
if (message.chat() == null || message.chat().id() != CHAT_ID || message.from() == null) continue;
mergeMessage.abort();
String text = "";
Message replyTo = message.replyToMessage();
if (replyTo != null) {
text += "[reply > ";
String replyText = "";
String replyType = getMessageType(replyTo);
if (replyTo.text() != null) {
replyText += replyTo.text();
}
if (replyType != null) {
replyText += replyType;
if (replyTo.caption() != null) {
replyText += " \"" + replyTo.caption() + "\"";
}
}
if (replyText.equals("")) {
replyText = "";
}
text += replyText + "] ";
}
if (message.text() != null && !message.text().isEmpty()) {
String msgText = message.text();
if (msgText.startsWith("/")) {
String[] s = msgText.split("((@\\w+bot)|(@\\w+bot)?[\t \n]+)", 2);
String command = s[0];
@Nullable String arg = s.length == 2 ? s[1] : null;
System.out.println(command);
switch (command) {
case "/list" -> outbound(genOnlineStatus(), ParseMode.MarkdownV2);
case "/setpin" -> {
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 <note>" to update current pinned message.""");
continue;
}
ONLINE_STATUS_MESSAGE_ID = replyTo.messageId();
updateOnlineStatus();
continue;
}
if (replyTo != null && (replyTo.from() == null || replyTo.messageId() <= 0)) {
outbound("must reply a message that from the bot (or reply nothing).");
continue;
}
String markdownString = MarkdownString.markdownString(message);
String shouldBeCommand = markdownString.substring(0, "/setpin ".length());
if (!shouldBeCommand.matches("/setpin[\t \n]")) {
outbound("\"/setpin\" must be plain text.");
continue;
}
outbound("old pinned note: \n" + pinNote, ParseMode.MarkdownV2);
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.MarkdownV2
);
case "/shutdown" -> {
if (server.getAllPlayers().isEmpty()) {
ServerCloser.INSTANCE.fastShutdown();
outbound("服务器即将在一分钟后关机,使用 /fuck 以取消。");
} else {
outbound("still player online, can't shutdown.");
} }
} }
outbound(String.join("\n", out)); case "/fuck" -> {
if (server.getAllPlayers().isEmpty()) {
if("fuck".equalsIgnoreCase(arg)){
ServerCloser.INSTANCE.noShutdown();
outbound("shutdown timer disabled until next player join & left.");
}else {
ServerCloser.INSTANCE.slowShutdown();
outbound("shutdown timer has been set to 60 minutes.");
}
} else {
outbound("still player online, will not shutdown.");
}
}
default -> {}
} }
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]");
} }
text += msgText;
} }
} String messageType = getMessageType(message);
catch (Exception e) { if (messageType != null) {
text += "[" + messageType + (message.caption() != null ? " \"" + message.caption() + "\"]" : "]");
}
if (!text.equals("")) {
tgInbound(message.from(), text);
}
} catch (Exception e) {
plugin.logger.error("handling update", e); plugin.logger.error("handling update", e);
} }
} }
@ -136,7 +212,7 @@ public class TGBridge {
}, (e) -> { }, (e) -> {
plugin.logger.error("getting update", e); plugin.logger.error("getting update", e);
plugin.logger.error(String.format("waiting %ds before getting another update", backoffSec)); plugin.logger.error(String.format("waiting %ds before getting another update", backoffSec));
try { Thread.sleep(backoffSec * 1000); } catch (InterruptedException ignored) {} try {Thread.sleep(backoffSec * 1000);} catch (InterruptedException ignored) {}
backoffSec *= 2L; backoffSec *= 2L;
if (backoffSec > 3600) { if (backoffSec > 3600) {
backoffSec = 3600; backoffSec = 3600;
@ -144,6 +220,101 @@ public class TGBridge {
}); });
} }
private static String getMessageType(Message message) {
if (message.sticker() != null) {
String emoji = message.sticker().emoji();
return emoji != null ? "sticker " + emoji : "sticker";
}
if (message.photo() != null) {
return "photo";
}
if (message.audio() != null) {
return "audio";
}
if (message.voice() != null) {
return "voice";
}
if (message.document() != null) {
return "document";
}
return null;
}
private boolean getPinnedMessage() {
try {
GetChatResponse response = bot.execute(new GetChat(CHAT_ID));
Message pinnedMessage = response.chat().pinnedMessage();
readOldPinnedMessage(pinnedMessage);
updateOnlineStatus();
} catch (RuntimeException e) {
plugin.logger.error("get group info failed.");
throw e;
}
return true;
}
private void readOldPinnedMessage(Message message) {
ONLINE_STATUS_MESSAGE_ID = message.messageId();
String markdownText = MarkdownString.markdownString(message);
String[] s = markdownText.split(DIVIDER, 2);
pinNote = (s.length == 2) ?
s[1] :
"\r_\r" + MarkdownString.escapeStr("(use \"/setpin\" <note> to set note here)") + "\r_\r";
}
/**
* @return markdown escaped str
*/
private String genPinMessage() {
return (pinNote != null && pinNote.length() != 0) ?
genOnlineStatus() + "\n\n" + DIVIDER + pinNote :
genOnlineStatus();
}
/**
* @return markdown escaped str
*/
private int shutdownCountMinutes = -1;
public static void setShuttingDown(int minute) {
INSTANCE.shutdownCountMinutes = minute;
INSTANCE.updateOnlineStatus();
}
private String genOnlineStatus() {
ArrayList<String> out = new ArrayList<>();
int playerCount = server.getAllPlayers().size();
out.add(switch (playerCount) {
case 0 -> "nobody here\\.";
case 1 -> "only one player online\\.";
default -> playerCount + " players online\\.";
});
List<RegisteredServer> registeredServers = new ArrayList<>(server.getAllServers());
for (RegisteredServer registeredServer : registeredServers) {
LinkedList<Player> onServer = new LinkedList<>();
for (Player player : registeredServer.getPlayersConnected()) {
if (!lastDisconnect.equals(player.getUsername())) {
onServer.add(player);
}
}
if (!onServer.isEmpty()) {
out.add(
String.format("\\[%s\\] \\(%d\\): %s",
"`" + MarkdownString.escapeStr(registeredServer.getServerInfo().getName()) + "`",
onServer.size(),
onServer.stream().map(player -> "`" + MarkdownString.escapeStr(player.getUsername()) + "`").collect(Collectors.joining(", ")))
);
}
}
String result = String.join("\n", out);
if (shutdownCountMinutes < 0) return result;
if (shutdownCountMinutes == 0 || PROXY_SHUT_DOWN) return "server already shutdown\\.%n%s".formatted(result);
return "server will shutdown after %s minute\\.%n%s".formatted(shutdownCountMinutes, result);
}
protected void tgInbound(User user, String content) { protected void tgInbound(User user, String content) {
inbound(String.format("[tg] <%s> %s", user.lastName() == null ? user.firstName() : String.format("%s %s", user.firstName(), user.lastName()), content)); inbound(String.format("[tg] <%s> %s", user.lastName() == null ? user.firstName() : String.format("%s %s", user.firstName(), user.lastName()), content));
} }
@ -156,26 +327,50 @@ public class TGBridge {
} }
} }
protected void outbound(String content) {
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);
}
public static void error(String context) {
INSTANCE.appendMessage("*" + MarkdownString.escapeStr(context) + "*");
}
public static void log(String context) {
INSTANCE.appendMessage("_" + MarkdownString.escapeStr(context) + "_");
}
protected void outbound(String content) {outbound(content, (ParseMode) null);}
protected void outbound(String content, @NotNull BiConsumer<SendMessage, SendResponse> onResponse) {outbound(content, onResponse, null);}
protected void outbound(String content, @NotNull BiConsumer<SendMessage, SendResponse> onResponse, ParseMode parseMode) {
if (bot == null) return; if (bot == null) return;
if (content.length() > 4000) { if (content.length() > 4000) {
content = content.substring(0, 4000); content = content.substring(0, 4000);
} }
for (long CHAT_ID : CHAT_IDS) {
bot.execute(new SendMessage(CHAT_ID, content), new Callback<SendMessage, SendResponse>() {
@Override
public void onResponse(SendMessage sendMessage, SendResponse sendResponse) {
if (!sendResponse.isOk()) {
plugin.logger.error(String.format("sendMessage error %d: %s", sendResponse.errorCode(), sendResponse.description()));
}
}
@Override SendMessage sendMessage = new SendMessage(CHAT_ID, content);
public void onFailure(SendMessage sendMessage, IOException e) { if (parseMode != null) {
plugin.logger.error("sending message", e); boolean a = sendMessage == sendMessage.parseMode(parseMode);
} assert a;
});
} }
bot.execute(sendMessage, new Callback<SendMessage, SendResponse>() {
@Override
public void onResponse(SendMessage sendMessage, SendResponse sendResponse) {
onResponse.accept(sendMessage, sendResponse);
}
@Override
public void onFailure(SendMessage sendMessage, IOException e) {
plugin.logger.error("sending message", e);
}
});
} }
@Subscribe @Subscribe
@ -183,21 +378,219 @@ public class TGBridge {
if (bot == null) return; if (bot == null) return;
bot.removeGetUpdatesListener(); bot.removeGetUpdatesListener();
bot.shutdown(); bot.shutdown();
PROXY_SHUT_DOWN = true;
} }
@Subscribe @Subscribe
public void onServerConnected(ServerConnectedEvent event) { public void onServerConnected(ServerConnectedEvent event) {
if (event.getPreviousServer().isEmpty()) { if (event.getPreviousServer().isEmpty() && !event.getPlayer().hasPermission("vtools.globalchat.bypassbridge.join")) {
if (!event.getPlayer().hasPermission("vtools.globalchat.bypassbridge.join")) { String username = event.getPlayer().getUsername();
outbound(String.format("%s joined the proxy", event.getPlayer().getUsername())); if (lastDisconnect.equals(username)) {
lastDisconnect = "";
} }
joinLeftAnnounce(String.format("`%s` joined the server\\.", MarkdownString.escapeStr(username)));
} }
updateRequests.add(new UpdateRequest());
} }
private String lastDisconnect = "";
@Subscribe @Subscribe
public void onDisconnect(DisconnectEvent event) { public void onDisconnect(DisconnectEvent event) {
if (!event.getPlayer().hasPermission("vtools.globalchat.bypassbridge.join")) { if (!event.getPlayer().hasPermission("vtools.globalchat.bypassbridge.join")) {
outbound(String.format("%s left the proxy", event.getPlayer().getUsername())); String username = event.getPlayer().getUsername();
if (username != null) {
lastDisconnect = username;
joinLeftAnnounce(String.format("`%s` left the server\\.", MarkdownString.escapeStr(username)));
}
}
updateRequests.add(new UpdateRequest());
}
@Subscribe
public void onServerPostConnect(ServerPostConnectEvent event) {
updateRequests.add(new UpdateRequest());
}
private boolean PROXY_SHUT_DOWN = false;
private static class UpdateRequest {}
private LinkedBlockingQueue<UpdateRequest> updateRequests = new LinkedBlockingQueue<>();
@SuppressWarnings("java:S3776")
private void initUpdateThread() {
new Thread(() -> {
while (true) {
if (PROXY_SHUT_DOWN) {
setOnlineStatusNotAvailable();
return;
}
UpdateRequest oldestRequest = null;
try {
oldestRequest = updateRequests.take();
} catch (InterruptedException ignored) {}
if (oldestRequest == null) {
plugin.logger.warn("updateRequests.take() return a null value, why?");
sleep(10000);
continue;
}
updateRequests.clear();
if (!updateOnlineStatus()) {
updateRequests.add(oldestRequest); // 更新失败 回去吧您内
sleep(10000);
}
}
}).start();
new Thread(() -> {
while (true) {
if (PROXY_SHUT_DOWN) {
return;
}
try {
String oldestMessage = announceQueue.take();
appendMessage(oldestMessage);
} catch (InterruptedException ignored) {}
}
}).start();
}
private static final Object mergeMessageLock = new Object();
private void appendMessage(String message) {
synchronized (mergeMessageLock) {
if (!mergeMessage.isValid()) {
mergeMessage = new MergeMessage(message);
} else {
mergeMessage.addLines(message);
}
} }
} }
private static void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ignored) {}
}
protected boolean updateOnlineStatus() {
return editOnlineStatusMessage(genPinMessage());
}
protected boolean setOnlineStatusNotAvailable() {
return updateOnlineStatus();
// return editOnlineStatusMessage("proxy already shutdown");
}
private static final Gson prettyGson = new GsonBuilder().setPrettyPrinting().create();
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, markdownText).parseMode(ParseMode.MarkdownV2).disableWebPagePreview(true));
if (!response.isOk()) {
if (response.description().equals("Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message")) {
return true;
}
String responseJSON = prettyGson.toJson(response);
plugin.logger.warn("update failed: {}", responseJSON);
}
return response.isOk();
} catch (RuntimeException e) {
e.printStackTrace();
return false;
}
}
private class MergeMessage {
private int messageId;
private long time;
private long timeMinute;
private StringBuilder text;
private boolean abort = false;
boolean isValid() {
if (abort || messageId < 1) return false;
long current = System.currentTimeMillis();
if (current / MINUTE != timeMinute) return false;
long dt = current - time;
return dt <= 30_000 && dt >= 0;
}
private void abort() {
abort = true;
}
protected MergeMessage(String firstMessage) {
text = new StringBuilder(firstMessage);
time = System.currentTimeMillis();
timeMinute = time / MINUTE;
send();
}
private void send() {
if (bot == null) {
messageId = -1;
return;
}
SendResponse response;
try {
response = bot.execute(new SendMessage(CHAT_ID, text.toString()).parseMode(ParseMode.MarkdownV2));
} catch (RuntimeException e) {
messageId = -1;
return;
}
if (!response.isOk()) {
messageId = -1;
return;
}
messageId = response.message().messageId();
}
protected MergeMessage() {
messageId = 0;
time = 0;
timeMinute = 0;
text = new StringBuilder();
abort = false;
} //dummy
private void addLines(String... messages) {
for (String message : messages) {
text.append('\n').append(message);
}
update();
}
private void update() {
if (!isValid()) {
plugin.logger.error("message should only push to a valid object");
return;
}
if (bot == null) {
messageId = -1;
return;
}
try {
bot.execute(new EditMessageText(CHAT_ID, messageId, text.toString()).parseMode(ParseMode.MarkdownV2));
} catch (RuntimeException ignored) {}
}
}
private MergeMessage mergeMessage = new MergeMessage();
private LinkedBlockingQueue<String> announceQueue = new LinkedBlockingQueue<>();
private void joinLeftAnnounce(String message) {
announceQueue.add(message);
}
} }

View file

@ -7,6 +7,7 @@ import com.velocitypowered.api.proxy.server.RegisteredServer;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.TranslatableComponent; import net.kyori.adventure.text.TranslatableComponent;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import java.util.ArrayList; import java.util.ArrayList;
@ -53,7 +54,10 @@ public class GlistUtil {
TextComponent.Builder builder = Component.text() TextComponent.Builder builder = Component.text()
.append(Component.text("[" + server.getServerInfo().getName() + "] ", .append(Component.text("[" + server.getServerInfo().getName() + "] ",
NamedTextColor.DARK_AQUA)) NamedTextColor.DARK_AQUA)
.clickEvent(ClickEvent.runCommand("/server " + server.getServerInfo().getName()))
)
.append(Component.text("(" + onServer.size() + ")", NamedTextColor.GRAY)) .append(Component.text("(" + onServer.size() + ")", NamedTextColor.GRAY))
.append(Component.text(": ")) .append(Component.text(": "))
.resetStyle(); .resetStyle();