commit baa0a2ae3bd2e0dfb65ab7d18bf858d3efcbea71 Author: MrAkells Date: Fri Apr 24 04:09:22 2026 +0300 initial commit diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..418e742 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/build/ +/.gradle/ +/run/ +/out/ +*.iml +*.ipr +*.iws +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..13aa58a --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# GTNH Telegram Achievements + +Forge-мод для Minecraft 1.7.10 / GTNH, который отправляет в Telegram сообщение, когда игрок завершает квест BetterQuesting. + +## Что делает + +- ловит завершение квестов BetterQuesting в GTNH через `QuestEvent.COMPLETED`; +- формирует текст сообщения по шаблону; +- отправляет уведомление в Telegram Bot API; +- читает `bot token` и `chat id` из конфига. + +## Требования + +- `Java 8` +- `Gradle 4.4.1+` +- доступ сервера Minecraft к `https://api.telegram.org` + +Важно: + +- Forge 1.7.10 обычно не собирается на современных JDK вроде `Java 25`. Для сборки и запуска dev-окружения используй именно `Java 8`. +- В проекте используется форк `ForgeGradle 1.2` от `anatawa12`, потому что он заметно практичнее для сборки старых 1.7.10-модов на современной инфраструктуре. + +## Сборка + +Используй wrapper, который уже лежит в проекте: + +```bash +./gradlew build +``` + +Готовый jar появится в `build/libs/`. + +## Настройка + +После первого запуска мода будет создан файл: + +```text +config/gtnhtelegram.cfg +``` + +Заполни в нем: + +```ini +enabled=true +sendQuestNotifications=true +sendRepeatableQuestNotifications=false +botToken=123456789:YOUR_BOT_TOKEN +chatId=-1001234567890 +questMessageFormat=[Minecraft] {player} завершил квест: {quest} +questBaseUrl=https://gtnhquestsbook.top/?id= +``` + +## Как получить `botToken` + +1. Открой Telegram и найди `@BotFather`. +2. Выполни `/newbot`. +3. Сохрани выданный токен. + +## Как получить `chatId` + +- Для личного чата удобно написать боту и получить `chat_id` через Bot API `getUpdates`. +- Для группы добавь туда бота, отправь сообщение в группу и посмотри `chat.id` в ответе `getUpdates`. +- У групп и супергрупп `chat_id` обычно отрицательный. + +## Проверка + +1. Собери мод. +2. Положи jar в `mods/`. +3. Запусти сервер или клиент с интегрированным сервером. +4. Заверши любой квест BetterQuesting. +5. Проверь, пришло ли сообщение в Telegram. + +## Ограничения + +- BetterQuesting-поддержка рассчитана на GTNH-форк BetterQuesting и собирается против `BetterQuesting-3.7.15-GTNH-dev.jar`. +- Мод отправляет только события завершения квестов BetterQuesting. +- `{quest}` в Telegram теперь подставляется как кликабельная HTML-ссылка на онлайн-квестбук, а сам `id` кодируется из BetterQuesting UUID в тот же base64-формат, который использует сайт. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..c13d930 --- /dev/null +++ b/build.gradle @@ -0,0 +1,67 @@ +buildscript { + repositories { + mavenCentral() + jcenter() + maven { + name = "forge" + url = "https://maven.minecraftforge.net" + } + } + dependencies { + classpath("com.anatawa12.forge:ForgeGradle:1.2-1.1.+") { + changing = true + } + } +} + +apply plugin: "forge" + +version = project.modVersion +group = project.modGroup +archivesBaseName = project.modBaseName + +sourceCompatibility = targetCompatibility = "1.7" + +compileJava { + sourceCompatibility = targetCompatibility = "1.7" + options.compilerArgs << "-Xlint:-options" +} + +minecraft { + version = project.minecraft_version + "-" + project.forge_version + runDir = "run" +} + +dependencies { + compileOnly files("libs/BetterQuesting-3.7.15-GTNH-dev.jar") +} + +processResources { + inputs.property "version", project.version + inputs.property "mcversion", project.minecraftVersion + inputs.property "modid", project.modId + inputs.property "modname", project.modName + + from(sourceSets.main.resources.srcDirs) { + include "mcmod.info" + expand( + "version": project.version, + "mcversion": project.minecraftVersion, + "modid": project.modId, + "modname": project.modName + ) + } + + from(sourceSets.main.resources.srcDirs) { + exclude "mcmod.info" + } +} + +jar { + manifest { + attributes( + "Implementation-Title": project.modName, + "Implementation-Version": project.version + ) + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..cfba6cb --- /dev/null +++ b/gradle.properties @@ -0,0 +1,9 @@ +modVersion=1.0.0 +modGroup=dev.mrakells.gtnhtelegram +modBaseName=gtnh-telegram +modId=gtnhtelegram +modName=GTNH Telegram Achievements +minecraftVersion=1.7.10 +minecraft_version=1.7.10 +forgeVersion=10.13.4.1614-1.7.10 +forge_version=10.13.4.1614-1.7.10 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..5c2d1cf Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5028f28 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..83f2acf --- /dev/null +++ b/gradlew @@ -0,0 +1,188 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..24467a1 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,100 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/libs/BetterQuesting-3.7.15-GTNH-dev.jar b/libs/BetterQuesting-3.7.15-GTNH-dev.jar new file mode 100644 index 0000000..dbefa33 Binary files /dev/null and b/libs/BetterQuesting-3.7.15-GTNH-dev.jar differ diff --git a/libs/BetterQuesting-3.8.30-GTNH-dev.jar b/libs/BetterQuesting-3.8.30-GTNH-dev.jar new file mode 100644 index 0000000..5c6f5db Binary files /dev/null and b/libs/BetterQuesting-3.8.30-GTNH-dev.jar differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..f68a67f --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = "gtnh-telegram" diff --git a/src/main/java/dev/mrakells/gtnhtelegram/BetterQuestingListener.java b/src/main/java/dev/mrakells/gtnhtelegram/BetterQuestingListener.java new file mode 100644 index 0000000..dfc49f7 --- /dev/null +++ b/src/main/java/dev/mrakells/gtnhtelegram/BetterQuestingListener.java @@ -0,0 +1,119 @@ +package dev.mrakells.gtnhtelegram; + +import betterquesting.api.api.QuestingAPI; +import betterquesting.api.events.QuestEvent; +import betterquesting.api.properties.NativeProps; +import betterquesting.api.questing.IQuest; +import betterquesting.api.questing.IQuestLine; +import betterquesting.questing.QuestDatabase; +import betterquesting.questing.QuestLineDatabase; +import com.google.common.io.BaseEncoding; +import cpw.mods.fml.common.eventhandler.SubscribeEvent; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.minecraft.entity.player.EntityPlayerMP; + +public class BetterQuestingListener { + @SubscribeEvent + public void onQuestCompleted(QuestEvent event) { + if (!ModConfig.enabled + || !ModConfig.sendQuestNotifications + || event == null + || event.getType() != QuestEvent.Type.COMPLETED) { + return; + } + + Set questIds = event.getQuestIDs(); + if (questIds == null || questIds.isEmpty()) { + return; + } + + EntityPlayerMP player = QuestingAPI.getPlayer(event.getPlayerID()); + String playerName = escapeHtml(player == null ? String.valueOf(event.getPlayerID()) : player.getCommandSenderName()); + String playerUuid = event.getPlayerID() == null ? "" : escapeHtml(event.getPlayerID().toString()); + + for (UUID questId : questIds) { + IQuest quest = QuestDatabase.INSTANCE.get(questId); + if (quest == null) { + continue; + } + + if (!ModConfig.sendRepeatableQuestNotifications && quest.getProperty(NativeProps.REPEAT_TIME) >= 0) { + continue; + } + + String questNameRaw = sanitize(quest.getProperty(NativeProps.NAME), questId.toString()); + if (isHiddenTriggerQuest(questNameRaw)) { + continue; + } + String questName = escapeHtml(questNameRaw); + String chapterName = escapeHtml(findChapterName(questId)); + String questUrl = escapeHtml(buildQuestUrl(questId)); + String questLink = "" + questName + ""; + String message = ModConfig.questMessageFormat + .replace("{player}", playerName) + .replace("{playerUuid}", playerUuid) + .replace("{quest}", questLink) + .replace("{questName}", questName) + .replace("{questId}", escapeHtml(questId.toString())) + .replace("{chapter}", chapterName); + message = message.replace("{questUrl}", questUrl); + + TelegramSender.send(message); + } + } + + private static String buildQuestUrl(UUID questId) { + String baseUrl = ModConfig.questBaseUrl == null ? "" : ModConfig.questBaseUrl.trim(); + return baseUrl + encodeQuestId(questId); + } + + private static String encodeQuestId(UUID questId) { + ByteBuffer buffer = ByteBuffer.allocate(16); + buffer.putLong(questId.getMostSignificantBits()); + buffer.putLong(questId.getLeastSignificantBits()); + return BaseEncoding.base64().encode(buffer.array()); + } + + private static String findChapterName(UUID questId) { + for (Map.Entry entry : QuestLineDatabase.INSTANCE.entrySet()) { + IQuestLine questLine = entry.getValue(); + if (questLine != null && questLine.containsKey(questId)) { + return sanitize(questLine.getProperty(NativeProps.NAME), ""); + } + } + + return ""; + } + + private static String sanitize(String value, String fallback) { + if (value == null) { + return fallback; + } + + String trimmed = stripFormattingCodes(value).trim(); + return trimmed.isEmpty() ? fallback : trimmed; + } + + private static boolean isHiddenTriggerQuest(String questName) { + return questName != null && questName.startsWith("Trigger:"); + } + + private static String stripFormattingCodes(String value) { + return value.replaceAll("\u00A7.", ""); + } + + private static String escapeHtml(String value) { + if (value == null) { + return ""; + } + + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } +} diff --git a/src/main/java/dev/mrakells/gtnhtelegram/GTNHTelegramMod.java b/src/main/java/dev/mrakells/gtnhtelegram/GTNHTelegramMod.java new file mode 100644 index 0000000..0924117 --- /dev/null +++ b/src/main/java/dev/mrakells/gtnhtelegram/GTNHTelegramMod.java @@ -0,0 +1,38 @@ +package dev.mrakells.gtnhtelegram; + +import cpw.mods.fml.common.Mod; +import cpw.mods.fml.common.Loader; +import cpw.mods.fml.common.event.FMLPreInitializationEvent; +import net.minecraftforge.common.MinecraftForge; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +@Mod( + modid = GTNHTelegramMod.MOD_ID, + name = GTNHTelegramMod.MOD_NAME, + version = GTNHTelegramMod.VERSION, + acceptableRemoteVersions = "*" +) +public class GTNHTelegramMod { + public static final String MOD_ID = "gtnhtelegram"; + public static final String MOD_NAME = "GTNH Telegram Achievements"; + public static final String VERSION = "1.0.0"; + + public static final Logger LOGGER = LogManager.getLogger(MOD_ID); + + @Mod.EventHandler + public void preInit(FMLPreInitializationEvent event) { + ModConfig.load(event.getSuggestedConfigurationFile()); + TelegramSender.configure( + ModConfig.botToken, + ModConfig.chatId, + ModConfig.connectTimeoutMs, + ModConfig.readTimeoutMs + ); + if (Loader.isModLoaded("betterquesting")) { + MinecraftForge.EVENT_BUS.register(new BetterQuestingListener()); + LOGGER.info("BetterQuesting integration enabled"); + } + LOGGER.info("Loaded {} with Telegram notifications {}", MOD_NAME, ModConfig.enabled ? "enabled" : "disabled"); + } +} diff --git a/src/main/java/dev/mrakells/gtnhtelegram/ModConfig.java b/src/main/java/dev/mrakells/gtnhtelegram/ModConfig.java new file mode 100644 index 0000000..67a595e --- /dev/null +++ b/src/main/java/dev/mrakells/gtnhtelegram/ModConfig.java @@ -0,0 +1,90 @@ +package dev.mrakells.gtnhtelegram; + +import java.io.File; +import java.io.File; +import net.minecraftforge.common.config.Configuration; + +public final class ModConfig { + public static boolean enabled = true; + public static boolean sendQuestNotifications = true; + public static boolean sendRepeatableQuestNotifications = false; + public static String botToken = ""; + public static String chatId = ""; + public static String questMessageFormat = "[Minecraft] {player} завершил квест: {quest}"; + public static String questBaseUrl = "https://gtnhquestsbook.top/?id="; + public static int connectTimeoutMs = 5000; + public static int readTimeoutMs = 5000; + + private ModConfig() { + } + + public static void load(File file) { + Configuration configuration = new Configuration(file); + + try { + configuration.load(); + enabled = configuration.getBoolean( + "enabled", + Configuration.CATEGORY_GENERAL, + true, + "Master switch for Telegram notifications." + ); + sendQuestNotifications = configuration.getBoolean( + "sendQuestNotifications", + Configuration.CATEGORY_GENERAL, + true, + "Send Telegram notifications when a BetterQuesting quest is completed." + ); + sendRepeatableQuestNotifications = configuration.getBoolean( + "sendRepeatableQuestNotifications", + Configuration.CATEGORY_GENERAL, + false, + "Send Telegram notifications for repeatable BetterQuesting quests too." + ); + botToken = configuration.getString( + "botToken", + Configuration.CATEGORY_GENERAL, + "", + "Telegram bot token from BotFather." + ).trim(); + chatId = configuration.getString( + "chatId", + Configuration.CATEGORY_GENERAL, + "", + "Telegram chat id that will receive notifications." + ).trim(); + questMessageFormat = configuration.getString( + "questMessageFormat", + Configuration.CATEGORY_GENERAL, + "[Minecraft] {player} завершил квест: {quest}", + "Quest message template. Supported placeholders: {player}, {quest}, {questName}, {questId}, {chapter}, {playerUuid}, {questUrl}." + ); + questBaseUrl = configuration.getString( + "questBaseUrl", + Configuration.CATEGORY_GENERAL, + "https://gtnhquestsbook.top/?id=", + "Base URL for online quest links. The encoded BetterQuesting id will be appended automatically." + ); + connectTimeoutMs = configuration.getInt( + "connectTimeoutMs", + Configuration.CATEGORY_GENERAL, + 5000, + 1000, + 30000, + "Telegram API connection timeout in milliseconds." + ); + readTimeoutMs = configuration.getInt( + "readTimeoutMs", + Configuration.CATEGORY_GENERAL, + 5000, + 1000, + 30000, + "Telegram API read timeout in milliseconds." + ); + } finally { + if (configuration.hasChanged()) { + configuration.save(); + } + } + } +} diff --git a/src/main/java/dev/mrakells/gtnhtelegram/TelegramSender.java b/src/main/java/dev/mrakells/gtnhtelegram/TelegramSender.java new file mode 100644 index 0000000..0b5da11 --- /dev/null +++ b/src/main/java/dev/mrakells/gtnhtelegram/TelegramSender.java @@ -0,0 +1,134 @@ +package dev.mrakells.gtnhtelegram; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class TelegramSender { + private static final Charset UTF_8 = StandardCharsets.UTF_8; + private static final AtomicBoolean MISSING_CONFIG_WARNING_SHOWN = new AtomicBoolean(false); + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "gtnhtelegram-sender"); + thread.setDaemon(true); + return thread; + } + }); + + private static volatile String botToken = ""; + private static volatile String chatId = ""; + private static volatile int connectTimeoutMs = 5000; + private static volatile int readTimeoutMs = 5000; + + private TelegramSender() { + } + + public static void configure(String token, String targetChatId, int connectTimeout, int readTimeout) { + botToken = token == null ? "" : token.trim(); + chatId = targetChatId == null ? "" : targetChatId.trim(); + connectTimeoutMs = connectTimeout; + readTimeoutMs = readTimeout; + MISSING_CONFIG_WARNING_SHOWN.set(false); + } + + public static void send(final String text) { + if (isBlank(botToken) || isBlank(chatId)) { + if (MISSING_CONFIG_WARNING_SHOWN.compareAndSet(false, true)) { + GTNHTelegramMod.LOGGER.warn("Telegram notifications skipped because botToken or chatId is not configured"); + } + return; + } + + EXECUTOR.submit(new Runnable() { + @Override + public void run() { + postMessage(text); + } + }); + } + + private static void postMessage(String text) { + HttpURLConnection connection = null; + + try { + URL url = new URL("https://api.telegram.org/bot" + botToken + "/sendMessage"); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setConnectTimeout(connectTimeoutMs); + connection.setReadTimeout(readTimeoutMs); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); + + String body = "chat_id=" + encode(chatId) + + "&text=" + encode(text) + + "&parse_mode=" + encode("HTML") + + "&disable_web_page_preview=true"; + byte[] payload = body.getBytes(UTF_8); + + OutputStream outputStream = connection.getOutputStream(); + try { + outputStream.write(payload); + outputStream.flush(); + } finally { + outputStream.close(); + } + + int responseCode = connection.getResponseCode(); + if (responseCode >= 400) { + GTNHTelegramMod.LOGGER.error( + "Telegram API returned HTTP {}: {}", + Integer.valueOf(responseCode), + readResponse(connection.getErrorStream()) + ); + } else { + readResponse(connection.getInputStream()); + } + } catch (Exception e) { + GTNHTelegramMod.LOGGER.error("Failed to send Telegram notification", e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private static String encode(String value) throws IOException { + return URLEncoder.encode(value, UTF_8.name()); + } + + private static String readResponse(InputStream stream) throws IOException { + if (stream == null) { + return ""; + } + + BufferedReader reader = new BufferedReader(new InputStreamReader(stream, UTF_8)); + StringBuilder builder = new StringBuilder(); + + try { + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + } finally { + reader.close(); + } + + return builder.toString(); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/src/main/resources/mcmod.info b/src/main/resources/mcmod.info new file mode 100644 index 0000000..7c56921 --- /dev/null +++ b/src/main/resources/mcmod.info @@ -0,0 +1,16 @@ +[ + { + "modid": "${modid}", + "name": "${modname}", + "description": "Sends Minecraft 1.7.10 achievement notifications to a Telegram chat.", + "version": "${version}", + "mcversion": "${mcversion}", + "url": "", + "updateUrl": "", + "authorList": ["mrakells"], + "credits": "", + "logoFile": "", + "screenshots": [], + "dependencies": [] + } +]