自动关机增加计时器
This commit is contained in:
parent
564e1eb5fa
commit
c178415969
2 changed files with 126 additions and 49 deletions
|
@ -3,7 +3,6 @@ package de.strifel.VTools;
|
||||||
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.proxy.Player;
|
|
||||||
import com.velocitypowered.api.proxy.ProxyServer;
|
import com.velocitypowered.api.proxy.ProxyServer;
|
||||||
import de.strifel.VTools.listeners.TGBridge;
|
import de.strifel.VTools.listeners.TGBridge;
|
||||||
import okhttp3.*;
|
import okhttp3.*;
|
||||||
|
@ -14,13 +13,13 @@ import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Timer;
|
import java.util.Timer;
|
||||||
import java.util.TimerTask;
|
import java.util.TimerTask;
|
||||||
|
|
||||||
public class ServerCloser {
|
public class ServerCloser {
|
||||||
private static final long INACTIVITY_TIMEOUT_MILLISECOND = 15L * 60 * 1000; // 15 minutes
|
private static final long INACTIVITY_TIMEOUT_MILLISECOND = 15L * 60 * 1000; // 15 minutes
|
||||||
|
private static final long MINUTE = 60L * 1000; // 15 minutes
|
||||||
private static final OkHttpClient CLIENT = new OkHttpClient();
|
private static final OkHttpClient CLIENT = new OkHttpClient();
|
||||||
private String apiUrl;
|
private String apiUrl;
|
||||||
|
|
||||||
|
@ -67,7 +66,8 @@ public class ServerCloser {
|
||||||
for (int i = 0; i < 3; i++) {
|
for (int i = 0; i < 3; i++) {
|
||||||
httpSuccess = executeAzure();
|
httpSuccess = executeAzure();
|
||||||
if (httpSuccess) {
|
if (httpSuccess) {
|
||||||
TGBridge.log("关机命令发送成功,正在关闭 pymcd。");
|
TGBridge.log("ServerCloser: 向azure发送关机命令成功,正在关闭 pymcd.");
|
||||||
|
TGBridge.setShuttingDown(0);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -76,12 +76,14 @@ public class ServerCloser {
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
Runtime.getRuntime().exec("pkill pymcd");
|
Runtime.getRuntime().exec("pkill pymcd");
|
||||||
} catch (IOException ignored) {}
|
} catch (IOException e) {
|
||||||
// plugin.getServer().shutdown();
|
TGBridge.error("关闭 pymcd 时出现问题:" + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean firstInit;
|
private boolean firstInit;
|
||||||
|
private int countDown = -1;
|
||||||
|
|
||||||
public void register() {
|
public void register() {
|
||||||
server.getEventManager().register(plugin, this);
|
server.getEventManager().register(plugin, this);
|
||||||
|
@ -122,30 +124,62 @@ public class ServerCloser {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void update() {
|
private void update() {
|
||||||
Collection<Player> players = server.getAllPlayers();
|
var hasPlayer = !server.getAllPlayers().isEmpty();
|
||||||
|
|
||||||
if (!players.isEmpty()) {
|
if (hasPlayer) {
|
||||||
plugin.logger.info("ServerCloser: 有玩家在线,干掉任何可能的关机计时器。");
|
plugin.logger.info("ServerCloser: 有玩家在线,干掉任何可能的关机计时器。");
|
||||||
closeServerTimer.cancel();
|
closeServerTimer.cancel();
|
||||||
|
countDown = -1;
|
||||||
|
TGBridge.setShuttingDown(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
initCountdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initCountdown() {
|
||||||
|
countDown = firstInit ? 60 : 15;
|
||||||
|
firstInit = false;
|
||||||
|
startCountdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startCountdown() {
|
||||||
|
TGBridge.setShuttingDown(countDown);
|
||||||
Timer timer = new Timer();
|
Timer timer = new Timer();
|
||||||
timer.schedule(new TimerTask() {
|
timer.schedule(new TimerTask() {
|
||||||
@Override
|
@Override
|
||||||
|
@SuppressWarnings("java:S1199")
|
||||||
public void run() {
|
public void run() {
|
||||||
if (server.getAllPlayers().isEmpty()) {
|
{
|
||||||
plugin.logger.info("ServerCloser: 即将关机。");
|
countDown--;
|
||||||
close();
|
if (countDown < 0) {
|
||||||
} else {
|
TGBridge.error("ServerCloser: #bug @NaAlOH4 计时器写炸了");
|
||||||
plugin.logger.error("ServerCloser: 定时器到点时发现服务器有人。这不应发生,因为定时器本应该被打断。");
|
closeServerTimer.cancel();
|
||||||
TGBridge.error("ServerCloser: #bug @NaAlOH4 定时器到点时发现服务器有人。这不应发生,因为定时器本应该被打断。");
|
return;
|
||||||
}
|
}
|
||||||
closeServerTimer.cancel();
|
|
||||||
}
|
|
||||||
}, firstInit ? (4 * INACTIVITY_TIMEOUT_MILLISECOND) : INACTIVITY_TIMEOUT_MILLISECOND);
|
|
||||||
plugin.logger.info("ServerCloser: 将在 {} 后自动关机。", firstInit ? "1h" : "15min");
|
|
||||||
|
|
||||||
firstInit = false;
|
if (server.getAllPlayers().isEmpty()) {
|
||||||
|
switch (countDown) {
|
||||||
|
case 1 -> {
|
||||||
|
TGBridge.log("服务器即将在一分钟后关机");
|
||||||
|
startCountdown();
|
||||||
|
}
|
||||||
|
case 0 -> {
|
||||||
|
plugin.logger.info("ServerCloser: 距离上一个玩家离开已经过了%s,即将关机。".formatted(firstInit ? "一小时" : "15分钟"));
|
||||||
|
TGBridge.log("ServerCloser: 距离上一个玩家离开已经过了%s,即将关机。".formatted(firstInit ? "一小时" : "15分钟"));
|
||||||
|
close();
|
||||||
|
closeServerTimer.cancel();
|
||||||
|
}
|
||||||
|
default -> startCountdown();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
plugin.logger.error("ServerCloser: 定时器到点时发现服务器有人。这不应发生,因为定时器本应该被打断。");
|
||||||
|
TGBridge.error("ServerCloser: #bug @NaAlOH4 定时器到点时发现服务器有人。这不应发生,因为定时器本应该被打断。");
|
||||||
|
closeServerTimer.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, MINUTE);
|
||||||
|
plugin.logger.info("ServerCloser: 将在 {} 分钟后自动关机。", countDown);
|
||||||
|
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
closeServerTimer.cancel();
|
closeServerTimer.cancel();
|
||||||
|
@ -153,4 +187,5 @@ public class ServerCloser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package de.strifel.VTools.listeners;
|
package de.strifel.VTools.listeners;
|
||||||
|
|
||||||
|
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;
|
||||||
|
@ -40,6 +42,7 @@ import java.util.stream.Collectors;
|
||||||
public class TGBridge {
|
public class TGBridge {
|
||||||
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;
|
||||||
|
@ -56,7 +59,11 @@ public class TGBridge {
|
||||||
*/
|
*/
|
||||||
private String pinNote;
|
private String pinNote;
|
||||||
|
|
||||||
|
@SuppressWarnings("java:S3010")
|
||||||
public TGBridge(@NotNull VTools plugin) {
|
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();
|
||||||
|
@ -100,11 +107,12 @@ public class TGBridge {
|
||||||
for (Update update : updates) {
|
for (Update update : updates) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (update == null || update.message() == null) break;
|
if (update == null || update.message() == null) continue;
|
||||||
Message message = update.message();
|
Message message = update.message();
|
||||||
if (message.chat() == null || message.chat().id() != CHAT_ID || message.from() == null) {
|
if (message.chat() == null || message.chat().id() != CHAT_ID || message.from() == null) continue;
|
||||||
break;
|
|
||||||
}
|
currentAnnounce.abort();
|
||||||
|
|
||||||
String text = "";
|
String text = "";
|
||||||
Message replyTo = message.replyToMessage();
|
Message replyTo = message.replyToMessage();
|
||||||
if (replyTo != null) {
|
if (replyTo != null) {
|
||||||
|
@ -117,7 +125,7 @@ public class TGBridge {
|
||||||
if (replyType != null) {
|
if (replyType != null) {
|
||||||
replyText += replyType;
|
replyText += replyType;
|
||||||
if (replyTo.caption() != null) {
|
if (replyTo.caption() != null) {
|
||||||
replyText += " \"" + replyTo.caption()+"\"";
|
replyText += " \"" + replyTo.caption() + "\"";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (replyText.equals("")) {
|
if (replyText.equals("")) {
|
||||||
|
@ -141,15 +149,15 @@ public class TGBridge {
|
||||||
usage:
|
usage:
|
||||||
use "/setpin" reply a message that from the bot to set that message to pin-message,
|
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.""");
|
or use "/setpin <note>" to update current pinned message.""");
|
||||||
break;
|
continue;
|
||||||
}
|
}
|
||||||
ONLINE_STATUS_MESSAGE_ID = replyTo.messageId();
|
ONLINE_STATUS_MESSAGE_ID = replyTo.messageId();
|
||||||
updateOnlineStatus();
|
updateOnlineStatus();
|
||||||
break;
|
continue;
|
||||||
}
|
}
|
||||||
if (replyTo != null && (replyTo.from() == null || replyTo.messageId() <= 0)) {
|
if (replyTo != null && (replyTo.from() == null || replyTo.messageId() <= 0)) {
|
||||||
outbound("must reply a message that from the bot (or reply nothing).");
|
outbound("must reply a message that from the bot (or reply nothing).");
|
||||||
break;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
String markdownString = MarkdownString.markdownString(message);
|
String markdownString = MarkdownString.markdownString(message);
|
||||||
|
@ -157,7 +165,7 @@ public class TGBridge {
|
||||||
String shouldBeCommand = markdownString.substring(0, "/setpin ".length());
|
String shouldBeCommand = markdownString.substring(0, "/setpin ".length());
|
||||||
if (!shouldBeCommand.matches("/setpin[\t \n]")) {
|
if (!shouldBeCommand.matches("/setpin[\t \n]")) {
|
||||||
outbound("\"/setpin\" must be plain text.");
|
outbound("\"/setpin\" must be plain text.");
|
||||||
break;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -179,6 +187,7 @@ public class TGBridge {
|
||||||
}
|
}
|
||||||
}, ParseMode.MarkdownV2
|
}, ParseMode.MarkdownV2
|
||||||
);
|
);
|
||||||
|
default -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
text += msgText;
|
text += msgText;
|
||||||
|
@ -255,17 +264,29 @@ public class TGBridge {
|
||||||
*/
|
*/
|
||||||
private String genPinMessage() {
|
private String genPinMessage() {
|
||||||
return (pinNote != null && pinNote.length() != 0) ?
|
return (pinNote != null && pinNote.length() != 0) ?
|
||||||
genOnlineStatus() + "\n" + DIVIDER + pinNote :
|
genOnlineStatus() + "\n\n" + DIVIDER + pinNote :
|
||||||
genOnlineStatus();
|
genOnlineStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return markdown escaped str
|
* @return markdown escaped str
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
private int shutdownCountMinutes = -1;
|
||||||
|
|
||||||
|
public static void setShuttingDown(int minute) {
|
||||||
|
INSTANCE.shutdownCountMinutes = minute;
|
||||||
|
INSTANCE.updateOnlineStatus();
|
||||||
|
}
|
||||||
|
|
||||||
private String genOnlineStatus() {
|
private String genOnlineStatus() {
|
||||||
ArrayList<String> out = new ArrayList<>();
|
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\\.";
|
int playerCount = server.getAllPlayers().size();
|
||||||
out.add(String.format(fmt, 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());
|
List<RegisteredServer> registeredServers = new ArrayList<>(server.getAllServers());
|
||||||
for (RegisteredServer registeredServer : registeredServers) {
|
for (RegisteredServer registeredServer : registeredServers) {
|
||||||
LinkedList<Player> onServer = new LinkedList<>();
|
LinkedList<Player> onServer = new LinkedList<>();
|
||||||
|
@ -283,7 +304,10 @@ public class TGBridge {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return String.join("\n", out);
|
String result = String.join("\n", out);
|
||||||
|
if (shutdownCountMinutes < 0) return result;
|
||||||
|
if (shutdownCountMinutes == 0) return "server is 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) {
|
||||||
|
@ -310,6 +334,7 @@ public class TGBridge {
|
||||||
public static void error(String context) {
|
public static void error(String context) {
|
||||||
INSTANCE.outbound("*" + MarkdownString.escapeStr(context) + "*", ParseMode.MarkdownV2);
|
INSTANCE.outbound("*" + MarkdownString.escapeStr(context) + "*", ParseMode.MarkdownV2);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void log(String context) {
|
public static void log(String context) {
|
||||||
INSTANCE.outbound("_" + MarkdownString.escapeStr(context) + "_", ParseMode.MarkdownV2);
|
INSTANCE.outbound("_" + MarkdownString.escapeStr(context) + "_", ParseMode.MarkdownV2);
|
||||||
}
|
}
|
||||||
|
@ -353,14 +378,12 @@ public class TGBridge {
|
||||||
|
|
||||||
@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();
|
||||||
String username = event.getPlayer().getUsername();
|
if (lastDisconnect.equals(username)) {
|
||||||
if (lastDisconnect.equals(username)) {
|
lastDisconnect = "";
|
||||||
lastDisconnect = "";
|
|
||||||
}
|
|
||||||
joinLeftAnnounce(String.format("%s joined the proxy", username));
|
|
||||||
}
|
}
|
||||||
|
joinLeftAnnounce(String.format("%s joined the proxy", username));
|
||||||
}
|
}
|
||||||
updateRequests.add(new UpdateRequest());
|
updateRequests.add(new UpdateRequest());
|
||||||
}
|
}
|
||||||
|
@ -393,6 +416,7 @@ public class TGBridge {
|
||||||
|
|
||||||
private LinkedBlockingQueue<UpdateRequest> updateRequests = new LinkedBlockingQueue<>();
|
private LinkedBlockingQueue<UpdateRequest> updateRequests = new LinkedBlockingQueue<>();
|
||||||
|
|
||||||
|
@SuppressWarnings("java:S3776")
|
||||||
private void initUpdateThread() {
|
private void initUpdateThread() {
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
@ -406,15 +430,14 @@ public class TGBridge {
|
||||||
} catch (InterruptedException ignored) {}
|
} catch (InterruptedException ignored) {}
|
||||||
if (oldestRequest == null) {
|
if (oldestRequest == null) {
|
||||||
plugin.logger.warn("updateRequests.take() return a null value, why?");
|
plugin.logger.warn("updateRequests.take() return a null value, why?");
|
||||||
try {
|
sleep(1000);
|
||||||
Thread.sleep(1000);
|
|
||||||
} catch (InterruptedException ignored) {}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
updateRequests.clear();
|
updateRequests.clear();
|
||||||
|
|
||||||
if (!updateOnlineStatus()) {
|
if (!updateOnlineStatus()) {
|
||||||
updateRequests.add(oldestRequest); // 更新失败 回去吧您内
|
updateRequests.add(oldestRequest); // 更新失败 回去吧您内
|
||||||
|
sleep(1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).start();
|
}).start();
|
||||||
|
@ -444,6 +467,12 @@ public class TGBridge {
|
||||||
}).start();
|
}).start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void sleep(int millis) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(millis);
|
||||||
|
} catch (InterruptedException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
protected boolean updateOnlineStatus() {
|
protected boolean updateOnlineStatus() {
|
||||||
return editOnlineStatusMessage(genPinMessage());
|
return editOnlineStatusMessage(genPinMessage());
|
||||||
}
|
}
|
||||||
|
@ -452,6 +481,8 @@ public class TGBridge {
|
||||||
return editOnlineStatusMessage("(proxy already shutdown)");
|
return editOnlineStatusMessage("(proxy already shutdown)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final Gson prettyGson = new GsonBuilder().setPrettyPrinting().create();
|
||||||
|
|
||||||
protected boolean editOnlineStatusMessage(String markdownText) {
|
protected boolean editOnlineStatusMessage(String markdownText) {
|
||||||
if (ONLINE_STATUS_MESSAGE_ID < 1) {
|
if (ONLINE_STATUS_MESSAGE_ID < 1) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -459,8 +490,16 @@ public class TGBridge {
|
||||||
BaseResponse response;
|
BaseResponse response;
|
||||||
try {
|
try {
|
||||||
response = bot.execute(new EditMessageText(CHAT_ID, ONLINE_STATUS_MESSAGE_ID, markdownText).parseMode(ParseMode.MarkdownV2));
|
response = bot.execute(new EditMessageText(CHAT_ID, ONLINE_STATUS_MESSAGE_ID, markdownText).parseMode(ParseMode.MarkdownV2));
|
||||||
} catch (RuntimeException e) {return false;}
|
if (!response.isOk()) {
|
||||||
return response != null && response.isOk();
|
String responseJSON = prettyGson.toJson(response);
|
||||||
|
plugin.logger.warn("update failed: {}", responseJSON);
|
||||||
|
}
|
||||||
|
return response.isOk();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class JoinLeftAnnounceMessage {
|
private class JoinLeftAnnounceMessage {
|
||||||
|
@ -468,13 +507,16 @@ public class TGBridge {
|
||||||
private long time;
|
private long time;
|
||||||
|
|
||||||
private StringBuilder text;
|
private StringBuilder text;
|
||||||
|
private boolean abort = false;
|
||||||
|
|
||||||
boolean isValid() {
|
boolean isValid() {
|
||||||
if (messageId < 1) {
|
if (abort || messageId < 1) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
long dt = System.currentTimeMillis() - time;
|
long dt = System.currentTimeMillis() - time;
|
||||||
return dt <= 60_000 && dt >= 0;
|
return dt <= 30_000 && dt >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void abort() {
|
||||||
|
abort = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected JoinLeftAnnounceMessage(String firstMessage) {
|
protected JoinLeftAnnounceMessage(String firstMessage) {
|
||||||
|
@ -496,7 +538,7 @@ public class TGBridge {
|
||||||
messageId = -1;
|
messageId = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.isOk() == false) {
|
if (!response.isOk()) {
|
||||||
messageId = -1;
|
messageId = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue