├── HEADER.txt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src ├── main │ ├── resources │ │ ├── dis4irc-versioning.txt │ │ └── logback.xml │ └── kotlin │ │ └── io │ │ └── zachbr │ │ └── dis4irc │ │ ├── bridge │ │ ├── message │ │ │ ├── PlatformType.kt │ │ │ ├── PlatformSource.kt │ │ │ ├── Destination.kt │ │ │ ├── PlatformSender.kt │ │ │ ├── DiscordContentBase.kt │ │ │ ├── PlatformMessage.kt │ │ │ └── BridgeMessage.kt │ │ ├── command │ │ │ ├── api │ │ │ │ └── Executor.kt │ │ │ ├── CommandManager.kt │ │ │ └── executors │ │ │ │ ├── PinnedMessagesCommand.kt │ │ │ │ └── StatsCommand.kt │ │ ├── pier │ │ │ ├── Pier.kt │ │ │ ├── irc │ │ │ │ ├── Extensions.kt │ │ │ │ ├── IrcExtrasListener.kt │ │ │ │ ├── IrcConnectionListener.kt │ │ │ │ ├── IrcMessageListener.kt │ │ │ │ ├── IrcJoinQuitListener.kt │ │ │ │ ├── IrcPier.kt │ │ │ │ └── IrcMessageFormatter.kt │ │ │ └── discord │ │ │ │ ├── DiscordMsgListener.kt │ │ │ │ ├── DiscordJoinQuitListener.kt │ │ │ │ ├── Extensions.kt │ │ │ │ └── DiscordPier.kt │ │ ├── mutator │ │ │ ├── mutators │ │ │ │ ├── StripAntiPingCharacters.kt │ │ │ │ └── TranslateFormatting.kt │ │ │ ├── api │ │ │ │ └── Mutator.kt │ │ │ └── MutatorManager.kt │ │ ├── ChannelMappingManager.kt │ │ ├── StatisticsManager.kt │ │ ├── ConfigurationData.kt │ │ └── Bridge.kt │ │ ├── util │ │ ├── WrappingLongArray.kt │ │ ├── Versioning.kt │ │ ├── StringUtil.kt │ │ ├── DiscordSpoilerExtension.kt │ │ └── AtomicFileUtil.kt │ │ ├── config │ │ ├── Configuration.kt │ │ └── ConfigurationUtils.kt │ │ └── Dis4IRC.kt └── test │ └── kotlin │ └── io │ └── zachbr │ └── dis4irc │ └── bridge │ ├── pier │ ├── irc │ │ ├── IrcPierTest.kt │ │ └── IrcEncodedCutterTest.kt │ └── discord │ │ └── DiscordPierTest.kt │ ├── message │ └── MessageTest.kt │ ├── util │ └── WrappingLongArrayTest.kt │ └── mutator │ └── mutators │ └── TranslateFormattingTest.kt ├── settings.gradle.kts ├── Dockerfile ├── .github └── workflows │ ├── gradle.yml │ └── container-publish.yml ├── LICENSE.md ├── docs ├── Registering-A-Discord-Application.md └── Getting-Started.md ├── gradlew.bat ├── README.md ├── .gitignore ├── CHANGELOG.md └── gradlew /HEADER.txt: -------------------------------------------------------------------------------- 1 | This file is part of ${name}. 2 | 3 | Copyright (c) ${name} contributors 4 | 5 | MIT License 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zachbr/Dis4IRC/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/dis4irc-versioning.txt: -------------------------------------------------------------------------------- 1 | Name: ${projectName} 2 | Version: ${projectVersion} 3 | Suffix: ${projectSuffix} 4 | Source-Repo: ${projectSourceRepo} 5 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | mavenCentral() 4 | maven("https://plugins.gradle.org/m2/") 5 | maven("https://maven.neoforged.net/releases/") 6 | } 7 | } 8 | rootProject.name = "Dis4IRC" 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:21-jre-alpine 2 | COPY build/libs/Dis4IRC-*.jar /opt/dis4irc/app.jar 3 | RUN mkdir /data 4 | WORKDIR /data 5 | VOLUME ["/data"] 6 | ENV JAVA_OPTS="-Xmx512m" 7 | ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -jar /opt/dis4irc/app.jar"] 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/message/PlatformType.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.message 10 | 11 | enum class PlatformType { 12 | /** 13 | * Discord platform 14 | */ 15 | DISCORD, 16 | /** 17 | * IRC platform 18 | */ 19 | IRC 20 | } 21 | -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | [%d{HH:mm:ss}] [%logger] [%level] - %msg%n 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/message/PlatformSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.message 10 | 11 | sealed interface PlatformSource { 12 | val channelName: String 13 | } 14 | 15 | data class IrcSource( 16 | override val channelName: String 17 | ) : PlatformSource 18 | 19 | data class DiscordSource( 20 | override val channelName: String, 21 | val channelId: Long 22 | ) : PlatformSource 23 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/command/api/Executor.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.command.api 10 | 11 | import io.zachbr.dis4irc.bridge.message.PlatformMessage 12 | 13 | interface Executor { 14 | /** 15 | * Perform some action when a command is executed 16 | * 17 | * @return your desired output or null if you desire no output 18 | */ 19 | fun onCommand(command: PlatformMessage): String? 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | name: Java CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-java@v4 13 | with: 14 | distribution: 'temurin' 15 | java-version: '21' 16 | cache: 'gradle' 17 | - name: Build with Gradle 18 | run: ./gradlew build --no-daemon 19 | - name: Upload build artifacts 20 | uses: actions/upload-artifact@v4 21 | with: 22 | name: dis4irc-jar 23 | path: build/libs/Dis4IRC-*.jar 24 | if-no-files-found: error 25 | -------------------------------------------------------------------------------- /src/test/kotlin/io/zachbr/dis4irc/bridge/pier/irc/IrcPierTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.irc 10 | 11 | import org.junit.jupiter.api.Assertions.assertEquals 12 | import org.junit.jupiter.api.Test 13 | 14 | class IrcPierTest { 15 | @Test 16 | fun testAntiPing() { 17 | assertEquals("k\u200Bit\u200Bte\u200Bn", IrcMessageFormatter.rebuildWithAntiPing("kitten")) 18 | assertEquals("k\u200Bit\u200Bte\u200Bn \u200B\uD83C\uDF57\u200B", IrcMessageFormatter.rebuildWithAntiPing("kitten \uD83C\uDF57")) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/message/Destination.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.message 10 | 11 | enum class Destination { 12 | /** 13 | * Only send back to the source 14 | */ 15 | ORIGIN, 16 | /** 17 | * Only send to the opposite side of the bridge 18 | */ 19 | OPPOSITE, 20 | /** 21 | * Send to both sides of the bridge 22 | */ 23 | BOTH, 24 | /** 25 | * Only send to IRC 26 | */ 27 | IRC, 28 | /** 29 | * Only send to Discord 30 | */ 31 | DISCORD 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/message/PlatformSender.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.message 10 | 11 | sealed interface PlatformSender { 12 | val displayName: String 13 | } 14 | 15 | data class IrcSender( 16 | override val displayName: String, 17 | val nickServAccount: String? 18 | ) : PlatformSender 19 | 20 | data class DiscordSender( 21 | override val displayName: String, 22 | val userId: Long 23 | ) : PlatformSender 24 | 25 | object BridgeSender : PlatformSender { 26 | override val displayName: String = "Bridge" 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/message/DiscordContentBase.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.message 10 | 11 | sealed interface DiscordContentBase { 12 | var contents: String 13 | val attachments: List 14 | val embeds: List 15 | } 16 | 17 | data class Embed( 18 | var string: String?, 19 | val imageUrl: String? 20 | ) 21 | 22 | data class DiscordMessageSnapshot( 23 | override var contents: String, 24 | override val attachments: List = emptyList(), 25 | override val embeds: List = emptyList() 26 | ) : DiscordContentBase 27 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/pier/Pier.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier 10 | 11 | import io.zachbr.dis4irc.bridge.message.BridgeMessage 12 | 13 | interface Pier { 14 | 15 | /** 16 | * Starts a pier, connecting it to whatever backend it needs, and readying it for use 17 | */ 18 | fun start() 19 | 20 | /** 21 | * Called when the bridge is to be safely shutdown 22 | */ 23 | fun onShutdown() 24 | 25 | /** 26 | * Sends a message through this pier 27 | */ 28 | fun sendMessage(targetChan: String, msg: BridgeMessage) 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/pier/irc/Extensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.irc 10 | 11 | import io.zachbr.dis4irc.bridge.message.IrcSender 12 | import io.zachbr.dis4irc.bridge.message.IrcSource 13 | import org.kitteh.irc.client.library.element.Channel 14 | import org.kitteh.irc.client.library.element.User 15 | import java.util.Optional 16 | 17 | fun Channel.asBridgeSource(): IrcSource = IrcSource(this.name) 18 | fun User.asBridgeSender(): IrcSender = IrcSender(this.nick, this.account.toNullable()) 19 | fun Optional.toNullable(): T? = this.orElse(null) 20 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/mutator/mutators/StripAntiPingCharacters.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.mutator.mutators 10 | 11 | import io.zachbr.dis4irc.bridge.message.IrcSource 12 | import io.zachbr.dis4irc.bridge.message.PlatformMessage 13 | import io.zachbr.dis4irc.bridge.mutator.api.Mutator 14 | import io.zachbr.dis4irc.bridge.pier.irc.IrcMessageFormatter 15 | 16 | /** 17 | * Strips anti-ping characters. 18 | */ 19 | class StripAntiPingCharacters : Mutator { 20 | 21 | override fun mutate(message: PlatformMessage): Mutator.LifeCycle { 22 | if (message.source is IrcSource) { 23 | message.contents = message.contents.replace(IrcMessageFormatter.ANTI_PING_CHAR.toString(), "") 24 | } 25 | return Mutator.LifeCycle.CONTINUE 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/mutator/api/Mutator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.mutator.api 10 | 11 | import io.zachbr.dis4irc.bridge.message.PlatformMessage 12 | 13 | /** 14 | * A mutator takes the given message contents and alters it in some way before returning it 15 | */ 16 | interface Mutator { 17 | /** 18 | * Called on a given message to mutate the contents 19 | * 20 | * @return how to proceed with the message's lifecycle 21 | */ 22 | fun mutate(message: PlatformMessage): LifeCycle 23 | 24 | /** 25 | * Mutator Life Cycle control types 26 | */ 27 | enum class LifeCycle { 28 | /** 29 | * Continue the lifecycle by passing this message onto the next 30 | */ 31 | CONTINUE, 32 | /** 33 | * Stop the lifecycle and discard the message entirely 34 | */ 35 | STOP_AND_DISCARD, 36 | /** 37 | * Stop the lifecycle and return the message as it exists currently 38 | */ 39 | RETURN_EARLY 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright (c) Dis4IRC Contributors 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/util/WrappingLongArray.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.util 10 | 11 | /** 12 | * Light wrapper around LongArrays to remove old entries as new ones are added 13 | */ 14 | class WrappingLongArray(private val size: Int) { 15 | private val backing = LongArray(size) 16 | private var index = 0 17 | private var hasWrappedAround = false 18 | 19 | /** 20 | * Adds an element to the underlying array 21 | * 22 | * If this array is fully populated, the oldest element is replaced with the newly added element 23 | */ 24 | fun add(e: Long) { 25 | wrapIndex() 26 | backing[index] = e 27 | index += 1 28 | } 29 | 30 | /** 31 | * Returns a copy of the backing LongArray up to the latest element that has been populated 32 | * 33 | * This does NOT return a full size array unless it has been fully populated 34 | */ 35 | fun toLongArray(): LongArray { 36 | return if (hasWrappedAround) { 37 | backing.copyOf() 38 | } else { 39 | backing.copyOf(index) 40 | } 41 | } 42 | 43 | private fun wrapIndex() { 44 | if (index == size) { 45 | index = 0 46 | hasWrappedAround = true 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/message/PlatformMessage.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.message 10 | 11 | import java.time.Instant 12 | import java.time.OffsetDateTime 13 | 14 | sealed interface PlatformMessage { 15 | var contents: String 16 | val sender: PlatformSender 17 | val source: PlatformSource 18 | val receiveTimestamp: Instant 19 | } 20 | 21 | data class IrcMessage( 22 | override var contents: String, 23 | override val sender: IrcSender, 24 | override val source: IrcSource, 25 | override val receiveTimestamp: Instant 26 | ) : PlatformMessage 27 | 28 | data class DiscordMessage( 29 | override var contents: String, 30 | override val sender: DiscordSender, 31 | override val source: DiscordSource, 32 | override val receiveTimestamp: Instant, 33 | override val attachments: List = emptyList(), 34 | override val embeds: List = emptyList(), 35 | val sentTimestamp: OffsetDateTime, 36 | val snapshots: List = emptyList(), 37 | val referencedMessage: DiscordMessage? = null 38 | ) : PlatformMessage, DiscordContentBase 39 | 40 | data class CommandMessage( 41 | override var contents: String, 42 | override val sender: BridgeSender, 43 | override val source: PlatformSource, 44 | override val receiveTimestamp: Instant 45 | ) : PlatformMessage 46 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/util/Versioning.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.util 10 | 11 | import java.util.jar.Manifest 12 | 13 | private const val JAR_PATH_TO_VERSIONING_INFO = "dis4irc-versioning.txt" 14 | 15 | /** 16 | * Fetches and provides versioning information from the manifest specified in [JAR_PATH_TO_VERSIONING_INFO] 17 | */ 18 | object Versioning { 19 | /** 20 | * Gets the build version of this jar 21 | */ 22 | val version: String 23 | 24 | /** 25 | * Gets the build suffix, this may be the git hash or a value specified at build time 26 | */ 27 | val suffix: String 28 | 29 | /** 30 | * Gets the source repo of this project 31 | */ 32 | val sourceRepo: String 33 | 34 | init { 35 | val resources = this.javaClass.classLoader.getResources(JAR_PATH_TO_VERSIONING_INFO) 36 | var verOut = "Unknown version" 37 | var suffixOut = "Unknown suffix" 38 | var repoOut = "Unknown source repo" 39 | 40 | if (resources.hasMoreElements()) { 41 | resources.nextElement().openStream().use { 42 | with(Manifest(it).mainAttributes) { 43 | if (getValue("Name") != "Dis4IRC") { 44 | return@use 45 | } 46 | 47 | verOut = getValue("Version") 48 | suffixOut = getValue("Suffix") 49 | repoOut = getValue("Source-Repo") 50 | } 51 | } 52 | } 53 | 54 | version = verOut 55 | suffix = suffixOut 56 | sourceRepo = repoOut 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/util/StringUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.util 10 | 11 | /** 12 | * Counts the number of occurrences of the substring in the base string 13 | */ 14 | fun countSubstring(baseStr: String, subStr: String): Int { 15 | if (baseStr.isEmpty()) { 16 | return 0 17 | } 18 | 19 | var count = 0 20 | var index = 0 21 | while (index != -1) { 22 | index = baseStr.indexOf(subStr, index) 23 | if (index != -1) { 24 | count++ 25 | index += subStr.length 26 | } 27 | } 28 | 29 | return count 30 | } 31 | 32 | /** 33 | * Given a string, find the target and replace it, optionally requiring whitespace separation to replace 34 | */ 35 | fun replaceTarget(base: CharSequence, target: String, replacement: CharSequence, requireSeparation: Boolean = false): String { 36 | var out = base 37 | 38 | fun isWhiteSpace(i: Int): Boolean { 39 | return i == -1 || i == out.length || !requireSeparation || out[i].isWhitespace() 40 | } 41 | 42 | fun isTarget(i: Int): Boolean { 43 | return i > 0 && out[i - 1] == '<' 44 | } 45 | 46 | var start = out.indexOf(target, 0) 47 | while (start > -1) { 48 | val end = start + target.length 49 | val nextSearchStart = start + replacement.length 50 | 51 | if (isWhiteSpace(start - 1) && isWhiteSpace(end) && !isTarget(start)) { 52 | out = out.replaceRange(start, start + target.length, replacement) 53 | } 54 | 55 | start = out.indexOf(target, nextSearchStart) 56 | } 57 | 58 | return out.toString() 59 | } 60 | 61 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/util/DiscordSpoilerExtension.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.util 10 | 11 | import org.commonmark.Extension 12 | import org.commonmark.node.CustomNode 13 | import org.commonmark.node.Node 14 | import org.commonmark.parser.Parser 15 | import org.commonmark.parser.delimiter.DelimiterProcessor 16 | import org.commonmark.parser.delimiter.DelimiterRun 17 | 18 | private const val DELIMITER = '|' 19 | private const val DELIMITER_LENGTH = 2 20 | 21 | class DiscordSpoiler : CustomNode() 22 | 23 | class DiscordSpoilerExtension private constructor() : Parser.ParserExtension { 24 | 25 | companion object { 26 | @JvmStatic fun create(): Extension = DiscordSpoilerExtension() 27 | } 28 | 29 | override fun extend(parserBuilder: Parser.Builder) { 30 | parserBuilder.customDelimiterProcessor(SpoilerDelimiterProcessor()) 31 | } 32 | } 33 | 34 | private class SpoilerDelimiterProcessor : DelimiterProcessor { 35 | override fun getOpeningCharacter() = DELIMITER 36 | override fun getClosingCharacter() = DELIMITER 37 | override fun getMinLength() = DELIMITER_LENGTH 38 | 39 | override fun process(openingRun: DelimiterRun, closingRun: DelimiterRun): Int { 40 | val opener = openingRun.opener 41 | val closer = closingRun.closer 42 | val spoiler = DiscordSpoiler() 43 | 44 | var node: Node? = opener.next 45 | while (node != null && node !== closer) { 46 | val next = node.next 47 | spoiler.appendChild(node) 48 | node = next 49 | } 50 | 51 | opener.insertAfter(spoiler) 52 | return DELIMITER_LENGTH 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/pier/discord/DiscordMsgListener.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.discord 10 | 11 | import net.dv8tion.jda.api.entities.channel.ChannelType 12 | import net.dv8tion.jda.api.events.message.MessageReceivedEvent 13 | import net.dv8tion.jda.api.hooks.ListenerAdapter 14 | import java.time.Instant 15 | 16 | /** 17 | * Responsible for listening to incoming discord messages and filtering garbage 18 | */ 19 | class DiscordMsgListener(private val pier: DiscordPier) : ListenerAdapter() { 20 | private val logger = pier.logger 21 | 22 | override fun onMessageReceived(event: MessageReceivedEvent) { 23 | // don't bridge DMs on this handler (later?) 24 | if (event.message.channelType != ChannelType.TEXT) { 25 | return 26 | } 27 | 28 | // don't bridge itself 29 | val source = event.channel.asTextChannel().asPlatformSource() // if we bridge DMs in the future, this needs to change 30 | if (pier.isThisBot(source, event.author.idLong)) { 31 | return 32 | } 33 | 34 | // don't bridge empty messages (discord does this on join) 35 | val message = event.message 36 | if (message.contentDisplay.isEmpty() 37 | && message.attachments.isEmpty() 38 | && message.stickers.isEmpty() 39 | && message.embeds.isEmpty() 40 | && message.messageSnapshots.isEmpty()) { 41 | return 42 | } 43 | 44 | val receiveInstant = Instant.now() 45 | logger.debug("DISCORD MSG ${event.channel.name} ${event.author.name}: ${event.message.contentStripped}") 46 | pier.sendToBridge(message.toPlatformMessage(logger, receiveInstant)) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/container-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build Container 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | push: 8 | branches: 9 | - master 10 | 11 | permissions: 12 | contents: read 13 | packages: write 14 | 15 | jobs: 16 | build-and-push: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Check out source 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Java 24 | uses: actions/setup-java@v4 25 | with: 26 | distribution: temurin 27 | java-version: '21' # match docker jre version 28 | cache: gradle 29 | 30 | - name: Build with Gradle 31 | run: ./gradlew clean build --no-daemon 32 | 33 | - name: Log in to GHCR 34 | uses: docker/login-action@v2 35 | with: 36 | registry: ghcr.io 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Normalize image name 41 | shell: bash 42 | run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV 43 | 44 | # releases 45 | - name: Build & push release images 46 | if: github.event_name == 'release' 47 | uses: docker/build-push-action@v3 48 | with: 49 | context: . 50 | file: Dockerfile 51 | push: true 52 | tags: | 53 | ghcr.io/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} 54 | ghcr.io/${{ env.IMAGE_NAME }}:latest 55 | 56 | # edge 57 | - name: Build & push edge image 58 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' 59 | uses: docker/build-push-action@v3 60 | with: 61 | context: . 62 | file: Dockerfile 63 | push: true 64 | tags: | 65 | ghcr.io/${{ env.IMAGE_NAME }}:edge 66 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/ChannelMappingManager.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge 10 | 11 | import io.zachbr.dis4irc.bridge.message.DiscordSource 12 | import io.zachbr.dis4irc.bridge.message.IrcSource 13 | import io.zachbr.dis4irc.bridge.message.PlatformSource 14 | import java.util.Locale 15 | 16 | /** 17 | * Responsible for maintaining the channel-to-channel mappings between IRC and Discord 18 | */ 19 | class ChannelMappingManager(conf: BridgeConfiguration) { 20 | private val discord2Irc = HashMap() 21 | private val irc2Discord: Map 22 | 23 | init { 24 | for (mapping in conf.channelMappings) { 25 | discord2Irc[mapping.discordChannel] = mapping.ircChannel.lowercase(Locale.ENGLISH) 26 | } 27 | 28 | // reverse 29 | irc2Discord = discord2Irc.entries.associateBy({ it.value }) { it.key } 30 | } 31 | 32 | /** 33 | * Gets the opposite channel mapping for the given channel 34 | */ 35 | internal fun getMappingFor(source: PlatformSource): String? { 36 | return when (source) { 37 | is IrcSource -> ircMappingByName(source.channelName) 38 | is DiscordSource -> discordMappingByName(source.channelId.toString()) ?: discordMappingByName(source.channelName) 39 | } 40 | } 41 | 42 | /** 43 | * Gets the IRC channel to bridge to based on the given string 44 | */ 45 | private fun discordMappingByName(discordId: String): String? { 46 | return discord2Irc[discordId] 47 | } 48 | 49 | /** 50 | * Gets the discord channel identifier to bridge to based on the IRC channel name 51 | */ 52 | private fun ircMappingByName(ircChannel: String): String? { 53 | return irc2Discord[ircChannel.lowercase(Locale.ENGLISH)] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/pier/irc/IrcExtrasListener.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.irc 10 | 11 | import io.zachbr.dis4irc.bridge.message.IrcMessage 12 | import io.zachbr.dis4irc.bridge.message.IrcSender 13 | import net.engio.mbassy.listener.Handler 14 | import org.kitteh.irc.client.library.event.channel.ChannelModeEvent 15 | import org.kitteh.irc.client.library.event.channel.ChannelTopicEvent 16 | import java.time.Instant 17 | 18 | class IrcExtrasListener(private val pier: IrcPier) { 19 | private val logger = pier.logger 20 | 21 | @Handler 22 | fun onModeChange(event: ChannelModeEvent) { 23 | val receiveInstant = Instant.now() 24 | val sender = IrcSender("IRC", null) 25 | val msgContent = "${event.actor.name} changed channel modes: ${event.statusList.asString}" 26 | logger.debug("IRC MODE CHANGE {}", event.channel) 27 | 28 | val source = event.channel.asBridgeSource() 29 | val message = IrcMessage(msgContent, sender, source, receiveInstant) 30 | pier.sendToBridge(message) 31 | } 32 | 33 | @Handler 34 | fun onTopicChange(event: ChannelTopicEvent) { 35 | val receiveInstant = Instant.now() 36 | if (!event.isNew) { 37 | return // changes only - don't broadcast on channel join 38 | } 39 | 40 | val sender = IrcSender("IRC", null) 41 | val topicSetter = event.newTopic.setter.toNullable() 42 | val setterPrefix = if (topicSetter != null) " set by ${topicSetter.name}" else "" 43 | val topicValue = event.newTopic.value.orElse("Unknown topic") 44 | val msgContent = "Topic$setterPrefix: $topicValue" 45 | logger.debug("IRC TOPIC$setterPrefix: $topicValue") 46 | 47 | val source = event.channel.asBridgeSource() 48 | val message = IrcMessage(msgContent, sender, source, receiveInstant) 49 | pier.sendToBridge(message) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/pier/irc/IrcConnectionListener.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.irc 10 | 11 | import net.engio.mbassy.listener.Handler 12 | import org.kitteh.irc.client.library.event.connection.ClientConnectionClosedEvent 13 | import org.kitteh.irc.client.library.event.connection.ClientConnectionEstablishedEvent 14 | 15 | private const val LAST_DISCONNECT_DELTA = 30_000 // ms 16 | private const val NUM_DISCONNECT_THRESHOLD = 4 17 | 18 | class IrcConnectionListener(private val pier: IrcPier) { 19 | private val logger = pier.logger 20 | private var lastDisconnect = System.currentTimeMillis() 21 | private var numRecentDisconnects = -1 22 | 23 | @Handler 24 | fun onConnectionEstablished(event: ClientConnectionEstablishedEvent) { 25 | logger.info("Connected to IRC!") 26 | this.pier.runPostConnectTasks() 27 | } 28 | 29 | @Handler 30 | fun onConnectionClosed(event: ClientConnectionClosedEvent) { 31 | logger.warn("IRC connection closed: ${event.cause.toNullable()?.localizedMessage ?: "null reason"}") 32 | 33 | val now = System.currentTimeMillis() 34 | val shouldReconnect: Boolean 35 | if (now - lastDisconnect < LAST_DISCONNECT_DELTA) { 36 | numRecentDisconnects++ 37 | 38 | shouldReconnect = numRecentDisconnects <= NUM_DISCONNECT_THRESHOLD 39 | logger.debug("Reconnect: $shouldReconnect, numRecentDisconnects: $numRecentDisconnects") 40 | } else { 41 | numRecentDisconnects = 0 42 | shouldReconnect = true 43 | logger.debug("RESET: Reconnect: $shouldReconnect, numRecentDisconnects: $numRecentDisconnects") 44 | } 45 | 46 | lastDisconnect = now 47 | event.setAttemptReconnect(shouldReconnect) 48 | if (!shouldReconnect) { 49 | this.pier.signalShutdown(inErr = true) // a disconnected bridge is a worthless bridge 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/message/BridgeMessage.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.message 10 | 11 | import io.zachbr.dis4irc.bridge.mutator.api.Mutator 12 | 13 | data class BridgeMessage( 14 | val message: PlatformMessage, 15 | var destination: Destination = Destination.OPPOSITE, 16 | private val appliedMutators: MutableSet> = HashSet() 17 | ) { 18 | /** 19 | * Gets whether the message should be sent to the given platform. 20 | */ 21 | fun shouldSendTo(platform: PlatformType): Boolean { 22 | return when (this.destination) { 23 | Destination.BOTH -> true 24 | Destination.IRC -> platform == PlatformType.IRC 25 | Destination.DISCORD -> platform == PlatformType.DISCORD 26 | Destination.ORIGIN -> { 27 | when (message) { 28 | is DiscordMessage -> platform == PlatformType.DISCORD 29 | is IrcMessage -> platform == PlatformType.IRC 30 | is CommandMessage -> false // originate from bridge, if same, nowhere to bridge to // TODO: send to logger? 31 | } 32 | } 33 | Destination.OPPOSITE -> { 34 | when (message) { 35 | is DiscordMessage -> platform == PlatformType.IRC 36 | is IrcMessage -> platform == PlatformType.DISCORD 37 | is CommandMessage -> false // originate from bridge, if opposite, what is opposite? // TODO: send to logger? 38 | } 39 | } 40 | } 41 | } 42 | 43 | fun originatesFromBridgeItself(): Boolean = message.sender == BridgeSender 44 | fun markMutatorApplied(clazz: Class) = appliedMutators.add(clazz) 45 | fun hasAlreadyApplied(clazz: Class): Boolean = appliedMutators.contains(clazz) 46 | internal fun getAppliedMutators(): MutableSet> = this.appliedMutators 47 | } 48 | -------------------------------------------------------------------------------- /docs/Registering-A-Discord-Application.md: -------------------------------------------------------------------------------- 1 | # Registering a Discord API Application 2 | 3 | To use Dis4IRC, you will need a bot token from Discord API. 4 | 5 | To get a bot token, you will need to register a new application. 6 | 7 | Let's get started. 8 | 9 | ------- 10 | 11 | ## Creating an Application 12 | Start by going to the [Discord Developer Portal](https://discord.com/developers/) 13 | and logging in with your Discord account. 14 | 15 | Next, on the main portal page, select the large "Create an application" placeholder. 16 | 17 | ![create-an-app](https://i.imgur.com/s4lyWlO.png) 18 | 19 | Give your application a name that makes sense to you, I'll be using "Bridge". 20 | 21 | If prompted, save your changes. 22 | 23 | ## Creating a Bot 24 | 25 | Then click the "Bot" tab in the sidebar on the left. 26 | 27 | ![application-settings](https://i.imgur.com/l1aOYvV.png) 28 | 29 | Select "Add bot" on the right side. 30 | 31 | ![add-a-bot](https://i.imgur.com/mE1Lt7K.png) 32 | 33 | Give your bot a username, if you aren't using webhooks, you'll see this name a lot. 34 | 35 | You can also give it an icon, if you aren't using webhooks you'll see this a lot too. 36 | 37 | Now click the "Copy" button under the "Token" section. That is your bot's Discord API token that 38 | you should paste into the config file. 39 | 40 | ![copy-token](https://i.imgur.com/vBsNirQ.png) 41 | 42 | ## Gateway Intents 43 | 44 | You're almost done, just one more thing. Scroll down, under "Privileged Gateway Intents" and make sure that 45 | the both the "Server Members Intent" and the "Message Content Intent" is set to **On**. 46 | 47 | Dis4IRC requires the "Server Members Intent" to properly cache the member list for things like pings from IRC. Dis4IRC 48 | requires the "Message Content Intent" to access message content now that Discord has added the command system. 49 | 50 | ![gateway-intents](https://i.imgur.com/cBZfGRm.png) 51 | 52 | If you do not enable these intents, you will receive an error message like this on start up: 53 | ``` 54 | CloseCode(4014 / Disallowed intents. Your bot might not be eligible to request a privileged intent such as GUILD_PRESENCES or GUILD_MEMBERS.) 55 | ``` 56 | 57 | Back to the [Getting Started page](https://github.com/zachbr/Dis4IRC/blob/master/docs/Getting-Started.md) 58 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/pier/discord/DiscordJoinQuitListener.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.discord 10 | 11 | import io.zachbr.dis4irc.bridge.message.DiscordMessage 12 | import io.zachbr.dis4irc.bridge.message.DiscordSender 13 | import io.zachbr.dis4irc.bridge.message.DiscordSource 14 | import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent 15 | import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent 16 | import net.dv8tion.jda.api.hooks.ListenerAdapter 17 | import java.time.Instant 18 | import java.time.OffsetDateTime 19 | 20 | class DiscordJoinQuitListener(private val pier: DiscordPier) : ListenerAdapter() { 21 | private val logger = pier.logger 22 | 23 | override fun onGuildMemberJoin(event: GuildMemberJoinEvent) { 24 | val channel = event.guild.systemChannel 25 | val source = channel?.asPlatformSource() ?: DiscordSource("Unknown", 0L) // "Unknown" and 0 for legacy reasons 26 | 27 | // don't bridge itself 28 | if (pier.isThisBot(source, event.user.idLong)) { 29 | return 30 | } 31 | 32 | val receiveInstant = Instant.now() 33 | logger.debug("DISCORD JOIN ${event.user.name}") 34 | 35 | val sender = DiscordSender("Discord", 0L) 36 | val message = DiscordMessage("${event.user.name} has joined the Discord", sender, source, receiveInstant, sentTimestamp = OffsetDateTime.now()) 37 | pier.sendToBridge(message) 38 | } 39 | 40 | override fun onGuildMemberRemove(event: GuildMemberRemoveEvent) { 41 | val channel = event.guild.systemChannel 42 | val source = channel?.asPlatformSource() ?: DiscordSource("Unknown", 0L) // "Unknown" and 0 for legacy reasons 43 | 44 | // don't bridge itself 45 | if (pier.isThisBot(source, event.user.idLong)) { 46 | return 47 | } 48 | 49 | val receiveInstant = Instant.now() 50 | logger.debug("DISCORD PART ${event.user.name}") 51 | 52 | val sender = DiscordSender("Discord", 0L) 53 | val message = DiscordMessage("${event.user.name} has left the Discord", sender, source, receiveInstant, sentTimestamp = OffsetDateTime.now()) 54 | pier.sendToBridge(message) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/config/Configuration.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.config 10 | 11 | import io.zachbr.dis4irc.logger 12 | import org.spongepowered.configurate.CommentedConfigurationNode 13 | import org.spongepowered.configurate.ConfigurationOptions 14 | import org.spongepowered.configurate.hocon.HoconConfigurationLoader 15 | import org.spongepowered.configurate.loader.HeaderMode 16 | import java.io.IOException 17 | import java.nio.file.Path 18 | import java.nio.file.Paths 19 | 20 | private const val HEADER = "Dis4IRC Configuration File" 21 | 22 | /** 23 | * Responsible for interacting with the underlying configuration system 24 | */ 25 | class Configuration(pathIn: String) { 26 | 27 | /** 28 | * The config file for use 29 | */ 30 | private val configPath: Path = Paths.get(pathIn) 31 | 32 | /** 33 | * Our configuration loader 34 | */ 35 | private val configurationLoader: HoconConfigurationLoader = HoconConfigurationLoader.builder() 36 | .path(configPath) 37 | .defaultOptions(ConfigurationOptions.defaults().header(HEADER)) 38 | .headerMode(HeaderMode.PRESET) 39 | .build() 40 | 41 | /** 42 | * Root configuration node 43 | */ 44 | private var rootNode: CommentedConfigurationNode 45 | 46 | /** 47 | * Reads configuration file and prepares for use 48 | */ 49 | init { 50 | try { 51 | rootNode = configurationLoader.load() 52 | } catch (ex: IOException) { 53 | logger.error("Could not load configuration! $ex") 54 | throw ex 55 | } 56 | } 57 | 58 | /** 59 | * Writes config back to file 60 | */ 61 | internal fun saveConfig(): Boolean { 62 | try { 63 | configurationLoader.save(rootNode) 64 | } catch (ex: IOException) { 65 | logger.error("Could not save configuration file!") 66 | ex.printStackTrace() 67 | return false 68 | } 69 | 70 | return true 71 | } 72 | 73 | /** 74 | * Gets a child node from the root node 75 | */ 76 | internal fun getNode(vararg keys: String): CommentedConfigurationNode { 77 | return rootNode.node(*keys) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/pier/irc/IrcMessageListener.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.irc 10 | 11 | import io.zachbr.dis4irc.bridge.message.IrcMessage 12 | import net.engio.mbassy.listener.Handler 13 | import org.kitteh.irc.client.library.event.channel.ChannelCtcpEvent 14 | import org.kitteh.irc.client.library.event.channel.ChannelMessageEvent 15 | import org.kitteh.irc.client.library.event.user.PrivateMessageEvent 16 | import org.kitteh.irc.client.library.event.user.PrivateNoticeEvent 17 | import org.kitteh.irc.client.library.util.Format 18 | import java.time.Instant 19 | 20 | private const val CTCP_ACTION = "ACTION" 21 | 22 | /** 23 | * Responsible for listening to incoming IRC messages and filtering garbage 24 | */ 25 | class IrcMessageListener(private val pier: IrcPier) { 26 | private val logger = pier.logger 27 | 28 | @Handler 29 | fun onMessage(event: ChannelMessageEvent) { 30 | // ignore messages sent by this bot 31 | if (event.actor.nick == pier.getBotNick()) { 32 | return 33 | } 34 | 35 | val receiveInstant = Instant.now() 36 | logger.debug("IRC MSG ${event.channel.name} ${event.actor.nick}: ${event.message}") 37 | 38 | val sender = event.actor.asBridgeSender() 39 | val source = event.channel.asBridgeSource() 40 | val message = IrcMessage(event.message, sender, source, receiveInstant) 41 | pier.sendToBridge(message) 42 | } 43 | 44 | @Handler 45 | fun onChannelCtcp(event: ChannelCtcpEvent) { 46 | // ignore messages sent by this bot 47 | if (event.actor.nick == pier.getBotNick()) { 48 | return 49 | } 50 | 51 | val receveInstant = Instant.now() 52 | logger.debug("IRC CTCP ${event.channel.name} ${event.actor.nick}: ${event.message}") 53 | 54 | // if it's not an action we probably don't care 55 | if (!event.message.startsWith(CTCP_ACTION)) { 56 | return 57 | } 58 | 59 | // add italic code and strip off the ctcp type info 60 | val messageText = "${Format.ITALIC}${event.message.substring(CTCP_ACTION.length + 1)}" 61 | 62 | val sender = event.actor.asBridgeSender() 63 | val source = event.channel.asBridgeSource() 64 | val message = IrcMessage(messageText, sender, source, receveInstant) 65 | pier.sendToBridge(message) 66 | } 67 | 68 | @Handler 69 | fun onPrivateMessage(event: PrivateMessageEvent) { 70 | logger.debug("IRC PRIVMSG ${event.actor.nick}: ${event.message}") 71 | } 72 | 73 | @Handler 74 | fun onPrivateNotice(event: PrivateNoticeEvent) { 75 | logger.debug("IRC NOTICE ${event.actor.nick}: ${event.message}") 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/StatisticsManager.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge 10 | 11 | import io.zachbr.dis4irc.bridge.message.BridgeMessage 12 | import io.zachbr.dis4irc.bridge.message.CommandMessage 13 | import io.zachbr.dis4irc.bridge.message.DiscordMessage 14 | import io.zachbr.dis4irc.bridge.message.IrcMessage 15 | import io.zachbr.dis4irc.util.WrappingLongArray 16 | import org.json.JSONObject 17 | import java.math.BigInteger 18 | import java.time.Duration 19 | import java.time.Instant 20 | import java.util.concurrent.TimeUnit 21 | 22 | /** 23 | * Responsible for keeping and providing various statistics for the bridge 24 | */ 25 | class StatisticsManager(private val bridge: Bridge) { 26 | private val messageTimings = WrappingLongArray(1000) 27 | private var totalFromIrc = BigInteger.valueOf(0) 28 | private var totalFromDiscord = BigInteger.valueOf(0) 29 | 30 | /** 31 | * Processes a message, adding it to whatever statistic counters it needs 32 | */ 33 | fun processMessage(bMessage: BridgeMessage, sentInstant: Instant) { 34 | // don't count bot, command, etc messages 35 | if (bMessage.originatesFromBridgeItself()) { 36 | return 37 | } 38 | 39 | val message = bMessage.message 40 | when (message) { 41 | is DiscordMessage -> totalFromDiscord++ 42 | is IrcMessage -> totalFromIrc++ 43 | is CommandMessage -> return // command messages originate on bridge, don't need to count 44 | } 45 | 46 | val difference = Duration.between(message.receiveTimestamp, sentInstant).toNanos() 47 | messageTimings.add(difference) 48 | bridge.logger.debug("Message from ${message.source.channelName} ${message.sender.displayName} took ${TimeUnit.NANOSECONDS.toMillis(difference)}ms to handle") 49 | } 50 | 51 | /** 52 | * Gets the total count of messages sent from IRC since the bridge was started 53 | */ 54 | fun getTotalFromIrc(): BigInteger { 55 | return totalFromIrc 56 | } 57 | 58 | /** 59 | * Gets the total count of messages sent from Discord since the bridge was started 60 | */ 61 | fun getTotalFromDiscord(): BigInteger { 62 | return totalFromDiscord 63 | } 64 | 65 | /** 66 | * Gets an array containing the unsorted message 67 | */ 68 | fun getMessageTimings(): LongArray { 69 | return messageTimings.toLongArray() 70 | } 71 | 72 | fun writeData(json: JSONObject): JSONObject { 73 | json.put("irc", totalFromIrc) 74 | json.put("discord", totalFromDiscord) 75 | return json 76 | } 77 | 78 | fun readSavedData(json: JSONObject) { 79 | val ircLoaded: BigInteger = json.optBigInteger("irc", BigInteger.ZERO) 80 | val discordLoaded: BigInteger = json.optBigInteger("discord", BigInteger.ZERO) 81 | totalFromIrc += ircLoaded 82 | totalFromDiscord += discordLoaded 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/command/CommandManager.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.command 10 | 11 | import io.zachbr.dis4irc.bridge.Bridge 12 | import io.zachbr.dis4irc.bridge.command.api.Executor 13 | import io.zachbr.dis4irc.bridge.command.executors.PinnedMessagesCommand 14 | import io.zachbr.dis4irc.bridge.command.executors.StatsCommand 15 | import io.zachbr.dis4irc.bridge.message.BridgeMessage 16 | import io.zachbr.dis4irc.bridge.message.BridgeSender 17 | import io.zachbr.dis4irc.bridge.message.CommandMessage 18 | import io.zachbr.dis4irc.bridge.message.Destination 19 | import org.spongepowered.configurate.CommentedConfigurationNode 20 | import java.time.Instant 21 | 22 | const val COMMAND_PREFIX: String = "!" 23 | 24 | /** 25 | * Responsible for managing, looking up, and delegating to command executors 26 | */ 27 | class CommandManager(private val bridge: Bridge, config: CommentedConfigurationNode) { 28 | private val executorsByCommand = HashMap() 29 | private val logger = bridge.logger 30 | 31 | init { 32 | val statsNode = config.node("stats", "enabled") 33 | if (statsNode.virtual()) { 34 | statsNode.set("true") 35 | } 36 | if (statsNode.boolean) { 37 | registerExecutor("stats", StatsCommand(bridge)) 38 | } 39 | 40 | val pinnedNode = config.node("pinned", "enabled") 41 | if (pinnedNode.virtual()) { 42 | pinnedNode.set("true") 43 | } 44 | if (pinnedNode.boolean) { 45 | registerExecutor("pinned", PinnedMessagesCommand(bridge)) 46 | } 47 | } 48 | 49 | /** 50 | * Registers an executor to the given command 51 | */ 52 | private fun registerExecutor(name: String, executor: Executor) { 53 | executorsByCommand[name] = executor 54 | } 55 | 56 | /** 57 | * Gets the executor for the given command 58 | */ 59 | private fun getExecutorFor(name: String): Executor? { 60 | return executorsByCommand[name] 61 | } 62 | 63 | /** 64 | * Process a command message, passing it off to the registered executor 65 | */ 66 | fun processCommandMessage(command: BridgeMessage) { 67 | val platMessage = command.message 68 | val split = platMessage.contents.split(" ") 69 | val trigger = split[0].substring(COMMAND_PREFIX.length, split[0].length) // strip off command prefix 70 | val executor = getExecutorFor(trigger) ?: return 71 | 72 | logger.debug("Passing command to executor: {}", executor) 73 | val result = executor.onCommand(platMessage) 74 | if (result != null) { 75 | // submit as new message 76 | val resultMessage = BridgeMessage(CommandMessage(result, BridgeSender, platMessage.source, Instant.now())) 77 | resultMessage.destination = Destination.BOTH // theoretically the source message was bridged, so the result should go to both places 78 | bridge.submitMessage(resultMessage) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/test/kotlin/io/zachbr/dis4irc/bridge/message/MessageTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.message 10 | 11 | import org.junit.jupiter.api.Assertions.assertFalse 12 | import org.junit.jupiter.api.Assertions.assertTrue 13 | import org.junit.jupiter.api.Test 14 | import java.time.Instant 15 | import java.time.OffsetDateTime 16 | 17 | class MessageTest { 18 | @Test 19 | fun testShouldSendTo() { 20 | // test from IRC 21 | val ircSender = IrcSender("SomeSender", null) 22 | val ircSource = IrcSource("#some-channel") 23 | val ircMessage = IrcMessage("Test", ircSender, ircSource, Instant.now()) 24 | val ircBridgeMsg = BridgeMessage(ircMessage) 25 | 26 | ircBridgeMsg.destination = Destination.DISCORD 27 | assertFalse(ircBridgeMsg.shouldSendTo(PlatformType.IRC)) 28 | assertTrue(ircBridgeMsg.shouldSendTo(PlatformType.DISCORD)) 29 | 30 | ircBridgeMsg.destination = Destination.IRC 31 | assertTrue(ircBridgeMsg.shouldSendTo(PlatformType.IRC)) 32 | assertFalse(ircBridgeMsg.shouldSendTo(PlatformType.DISCORD)) 33 | 34 | ircBridgeMsg.destination = Destination.ORIGIN 35 | assertTrue(ircBridgeMsg.shouldSendTo(PlatformType.IRC)) 36 | assertFalse(ircBridgeMsg.shouldSendTo(PlatformType.DISCORD)) 37 | 38 | ircBridgeMsg.destination = Destination.OPPOSITE 39 | assertFalse(ircBridgeMsg.shouldSendTo(PlatformType.IRC)) 40 | assertTrue(ircBridgeMsg.shouldSendTo(PlatformType.DISCORD)) 41 | 42 | ircBridgeMsg.destination = Destination.BOTH 43 | assertTrue(ircBridgeMsg.shouldSendTo(PlatformType.IRC)) 44 | assertTrue(ircBridgeMsg.shouldSendTo(PlatformType.DISCORD)) 45 | 46 | // test from Discord 47 | val discordSource = DiscordSource("some-channel", 1L) 48 | val discordSender = DiscordSender("SomeSender", 0L) 49 | val discordMessage = DiscordMessage("Test", discordSender, discordSource, Instant.now(), sentTimestamp = OffsetDateTime.now()) 50 | val discordBridgeMsg = BridgeMessage(discordMessage) 51 | 52 | discordBridgeMsg.destination = Destination.DISCORD 53 | assertFalse(discordBridgeMsg.shouldSendTo(PlatformType.IRC)) 54 | assertTrue(discordBridgeMsg.shouldSendTo(PlatformType.DISCORD)) 55 | 56 | discordBridgeMsg.destination = Destination.IRC 57 | assertTrue(discordBridgeMsg.shouldSendTo(PlatformType.IRC)) 58 | assertFalse(discordBridgeMsg.shouldSendTo(PlatformType.DISCORD)) 59 | 60 | discordBridgeMsg.destination = Destination.ORIGIN 61 | assertFalse(discordBridgeMsg.shouldSendTo(PlatformType.IRC)) 62 | assertTrue(discordBridgeMsg.shouldSendTo(PlatformType.DISCORD)) 63 | 64 | discordBridgeMsg.destination = Destination.OPPOSITE 65 | assertTrue(discordBridgeMsg.shouldSendTo(PlatformType.IRC)) 66 | assertFalse(discordBridgeMsg.shouldSendTo(PlatformType.DISCORD)) 67 | 68 | discordBridgeMsg.destination = Destination.BOTH 69 | assertTrue(discordBridgeMsg.shouldSendTo(PlatformType.IRC)) 70 | assertTrue(discordBridgeMsg.shouldSendTo(PlatformType.DISCORD)) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/util/AtomicFileUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.util 10 | 11 | import java.io.IOException 12 | import java.io.OutputStream 13 | import java.nio.file.AtomicMoveNotSupportedException 14 | import java.nio.file.Files 15 | import java.nio.file.Path 16 | import java.nio.file.StandardCopyOption 17 | import java.util.* 18 | import java.util.function.Consumer 19 | 20 | private const val MAX_RETRY_ATTEMPTS = 3 21 | private const val WINDOWS_ATTR_HIDDEN = "dos:hidden" 22 | 23 | object AtomicFileUtil { 24 | private val osIsWindows: Boolean 25 | 26 | init { 27 | val os = System.getProperty("os.name").lowercase(Locale.ENGLISH) 28 | osIsWindows = os.contains("win") 29 | } 30 | 31 | fun writeAtomic(destination: Path, consumer: Consumer): Path { 32 | // resolve destination 33 | var targetFile = destination.toAbsolutePath() 34 | if (Files.exists(targetFile)) { 35 | try { 36 | targetFile = targetFile.toRealPath() 37 | } catch (ex: IOException) { 38 | // ignore 39 | } 40 | } 41 | 42 | // create and write temp file as hidden file 43 | val tempFile = Files.createTempFile(targetFile.parent, ".", "-" + targetFile.toAbsolutePath().fileName.toString() + ".tmp") 44 | // windows is a special snowflake 45 | if (osIsWindows) { 46 | setWindowsHiddenAttr(tempFile, true) 47 | } 48 | 49 | // accept data 50 | val dataOut = Files.newOutputStream(tempFile) 51 | consumer.accept(dataOut) 52 | dataOut.close() 53 | 54 | // atomic move 55 | try { 56 | Files.move(tempFile, targetFile, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) 57 | } catch (ex: AccessDeniedException) { 58 | // This is really only here to retry the move in the case that Windows (or some other process) holds a lock 59 | // on the file that prevents us from completing the move. 60 | for (attempt in 1..MAX_RETRY_ATTEMPTS) { 61 | try { 62 | Thread.sleep(10 * attempt.toLong()) 63 | Files.move(tempFile, targetFile, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) 64 | } catch (ex: AccessDeniedException) { 65 | if (attempt == MAX_RETRY_ATTEMPTS) { 66 | throw ex 67 | } 68 | } 69 | } 70 | } catch (ex: AtomicMoveNotSupportedException) { 71 | // Cthulhu help us, just move normally 72 | Files.move(tempFile, targetFile, StandardCopyOption.REPLACE_EXISTING) 73 | } 74 | 75 | // the main file is not hidden so we need to unset that attribute 76 | if (osIsWindows) { 77 | setWindowsHiddenAttr(targetFile, false) 78 | } 79 | 80 | return targetFile 81 | } 82 | 83 | private fun setWindowsHiddenAttr(path: Path, value: Boolean) { 84 | try { 85 | Files.setAttribute(path, WINDOWS_ATTR_HIDDEN, value) 86 | } catch (ex: IOException) { 87 | // ignore 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/test/kotlin/io/zachbr/dis4irc/bridge/util/WrappingLongArrayTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.util 10 | 11 | import io.zachbr.dis4irc.util.WrappingLongArray 12 | import org.junit.jupiter.api.Assertions.assertEquals 13 | import org.junit.jupiter.api.Test 14 | 15 | class WrappingLongArrayTest { 16 | @Test 17 | fun testUnderPopulated() { 18 | val size = 50 19 | val population = 27 20 | val evicting = WrappingLongArray(size) 21 | 22 | // populate 23 | for (i in 0 until population) { 24 | evicting.add(i.toLong()) 25 | } 26 | 27 | val arrayOut = evicting.toLongArray() 28 | // assert size - we care about population 29 | assertEquals(population, arrayOut.size) 30 | 31 | // assert contents correct 32 | for (i in 0 until population) { 33 | assertEquals(i.toLong(), arrayOut[i]) 34 | } 35 | } 36 | 37 | @Test 38 | fun testFullyPopulated() { 39 | val size = 50 40 | val evicting = WrappingLongArray(size) 41 | 42 | // populate 43 | for (i in 0 until size) { 44 | evicting.add(i.toLong()) 45 | } 46 | 47 | val arrayOut = evicting.toLongArray() 48 | // assert size correct 49 | assertEquals(size, arrayOut.size) 50 | 51 | // assert contents correct 52 | for (i in 0 until size) { 53 | assertEquals(i.toLong(), arrayOut[i]) 54 | } 55 | } 56 | 57 | @Test 58 | fun testOverPopulated() { 59 | val size = 50 60 | val modifier = 5 61 | val evicting = WrappingLongArray(size) 62 | 63 | // populate 64 | for (i in 0 until size * modifier) { 65 | evicting.add(i.toLong()) 66 | } 67 | 68 | val arrayOut = evicting.toLongArray() 69 | // assert size correct 70 | assertEquals(size, arrayOut.size) 71 | 72 | // assert contents correct 73 | for (i in 0 until size) { 74 | val expected = size * (modifier - 1) + i 75 | assertEquals(expected.toLong(), arrayOut[i]) 76 | } 77 | } 78 | 79 | @Test 80 | fun testOverPopulatedBy1() { 81 | val size = 50 82 | val evicting = WrappingLongArray(size) 83 | 84 | // populate 85 | for (i in 0 until size + 1) { // over size 86 | evicting.add(i.toLong()) 87 | } 88 | 89 | val arrayOut = evicting.toLongArray() 90 | // assert size correct 91 | assertEquals(size, arrayOut.size) 92 | // assert correct overflow 93 | assertEquals(50, arrayOut[0]) 94 | assertEquals(1, arrayOut[1]) 95 | } 96 | 97 | @Test 98 | fun testUnderPopulatedBy1() { 99 | val size = 50 100 | val evicting = WrappingLongArray(size) 101 | 102 | // populate 103 | for (i in 0 until size - 1) { // under size 104 | evicting.add(i.toLong()) 105 | } 106 | 107 | val arrayOut = evicting.toLongArray() 108 | // assert size correct 109 | assertEquals(size - 1, arrayOut.size) 110 | // assert no overflow 111 | assertEquals(0, arrayOut[0]) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/pier/irc/IrcJoinQuitListener.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.irc 10 | 11 | import io.zachbr.dis4irc.bridge.message.IrcMessage 12 | import io.zachbr.dis4irc.bridge.message.IrcSender 13 | import net.engio.mbassy.listener.Handler 14 | import org.kitteh.irc.client.library.event.channel.ChannelJoinEvent 15 | import org.kitteh.irc.client.library.event.channel.ChannelPartEvent 16 | import org.kitteh.irc.client.library.event.channel.UnexpectedChannelLeaveViaKickEvent 17 | import org.kitteh.irc.client.library.event.user.UserQuitEvent 18 | import java.time.Instant 19 | 20 | class IrcJoinQuitListener(private val pier: IrcPier) { 21 | private val logger = pier.logger 22 | 23 | @Handler 24 | fun onUserJoinChan(event: ChannelJoinEvent) { 25 | // don't log our own joins 26 | if (event.user.nick == pier.getBotNick()) { 27 | return 28 | } 29 | 30 | val receiveInstant = Instant.now() 31 | logger.debug("IRC JOIN ${event.channel.name} ${event.user.nick}") 32 | 33 | val sender = IrcSender("IRC", null) 34 | val source = event.channel.asBridgeSource() 35 | val msgContent = "${event.user.nick} (${event.user.userString}@${event.user.host}) has joined ${event.channel.name}" 36 | val message = IrcMessage(msgContent, sender, source, receiveInstant) 37 | pier.sendToBridge(message) 38 | } 39 | 40 | @Handler 41 | fun onUserLeaveChan(event: ChannelPartEvent) { 42 | // don't log our own leaving 43 | if (event.user.nick == pier.getBotNick()) { 44 | return 45 | } 46 | 47 | val receiveInstant = Instant.now() 48 | logger.debug("IRC PART ${event.channel.name} ${event.user.nick}") 49 | 50 | val sender = IrcSender("IRC", null) 51 | val source = event.channel.asBridgeSource() 52 | val msgContent = "${event.user.nick} (${event.user.userString}@${event.user.host}) has left ${event.channel.name}" 53 | val message = IrcMessage(msgContent, sender, source, receiveInstant) 54 | pier.sendToBridge(message) 55 | } 56 | 57 | @Handler 58 | fun onUserKicked(event: UnexpectedChannelLeaveViaKickEvent) { 59 | // don't log our own quitting 60 | if (event.user.nick == pier.getBotNick()) { 61 | return 62 | } 63 | 64 | val receiveInstant = Instant.now() 65 | logger.debug("IRC KICK ${event.channel.name} ${event.target.nick} by ${event.user.nick}") 66 | 67 | val sender = IrcSender("IRC", null) 68 | val source = event.channel.asBridgeSource() 69 | val msgContent = "${event.user.nick} kicked ${event.target.nick} (${event.target.userString}@${event.target.host}) (${event.message})" 70 | val message = IrcMessage(msgContent, sender, source, receiveInstant) 71 | pier.sendToBridge(message) 72 | } 73 | 74 | @Handler 75 | fun onUserQuit(event: UserQuitEvent) { 76 | val receiveInstant = Instant.now() 77 | val sender = IrcSender("IRC", null) 78 | val msgContent = "${event.user.nick} (${event.user.userString}@${event.user.host}) has quit" 79 | logger.debug("IRC QUIT ${event.user.nick}") 80 | 81 | for (channel in event.user.channels) { 82 | val chan = event.client.getChannel(channel).toNullable() ?: continue 83 | 84 | val source = chan.asBridgeSource() 85 | val message = IrcMessage(msgContent, sender, source, receiveInstant) 86 | pier.sendToBridge(message) 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/mutator/MutatorManager.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.mutator 10 | 11 | import io.zachbr.dis4irc.bridge.Bridge 12 | import io.zachbr.dis4irc.bridge.message.BridgeMessage 13 | import io.zachbr.dis4irc.bridge.message.DiscordMessage 14 | import io.zachbr.dis4irc.bridge.message.PlatformMessage 15 | import io.zachbr.dis4irc.bridge.mutator.api.Mutator 16 | import io.zachbr.dis4irc.bridge.mutator.mutators.PasteLongMessages 17 | import io.zachbr.dis4irc.bridge.mutator.mutators.StripAntiPingCharacters 18 | import io.zachbr.dis4irc.bridge.mutator.mutators.TranslateFormatting 19 | import org.spongepowered.configurate.CommentedConfigurationNode 20 | 21 | class MutatorManager(bridge: Bridge, config: CommentedConfigurationNode) { 22 | private val mutators = HashMap, Mutator>() 23 | 24 | init { 25 | registerMutator(StripAntiPingCharacters()) 26 | registerMutator(PasteLongMessages(bridge, config.node("paste-service"))) 27 | registerMutator(TranslateFormatting()) 28 | } 29 | 30 | private fun registerMutator(mutator: Mutator) { 31 | mutators[mutator.javaClass] = mutator 32 | } 33 | 34 | /** 35 | * Applies all registered mutators to the bridge message and any referenced messages. 36 | */ 37 | internal fun applyMutators(bMessage: BridgeMessage): BridgeMessage? { 38 | val platformMessage = bMessage.message 39 | if (platformMessage is DiscordMessage && platformMessage.referencedMessage != null) { 40 | runMutatorLoop(platformMessage.referencedMessage, mutableSetOf()) // separate tracking 41 | } 42 | 43 | val finalLifeCycle = runMutatorLoop(platformMessage, bMessage.getAppliedMutators()) 44 | return when (finalLifeCycle) { 45 | Mutator.LifeCycle.STOP_AND_DISCARD -> null 46 | else -> bMessage // Covers CONTINUE and RETURN_EARLY 47 | } 48 | } 49 | 50 | /** 51 | * Bypass function to intentionally call a mutator on a platform message. 52 | * (No tracking of previously applied) 53 | */ 54 | internal fun applyMutator(clazz: Class, message: PlatformMessage) { 55 | val mutator = mutators[clazz] ?: throw NoSuchElementException("No mutator with class type: ${clazz.simpleName}") 56 | mutator.mutate(message) 57 | } 58 | 59 | /** 60 | * Tuns the entire loop of mutators on a single message part. 61 | * 62 | * @param message The platform message to mutate. 63 | * @param appliedSet The set used to track which mutators have run for this task. 64 | * @return The final lifecycle state after the loop finishes or is interrupted. 65 | */ 66 | private fun runMutatorLoop(message: PlatformMessage, appliedSet: MutableSet>): Mutator.LifeCycle { 67 | for (mutator in mutators.values) { 68 | val state = applySingleMutator(mutator, message, appliedSet) 69 | if (state != Mutator.LifeCycle.CONTINUE) { 70 | return state 71 | } 72 | } 73 | return Mutator.LifeCycle.CONTINUE 74 | } 75 | 76 | /** 77 | * Applies a single mutator to the given bridge message. 78 | */ 79 | private fun applySingleMutator(mutator: Mutator, message: PlatformMessage, appliedSet: MutableSet>): Mutator.LifeCycle { 80 | if (appliedSet.contains(mutator.javaClass)) { 81 | return Mutator.LifeCycle.CONTINUE 82 | } 83 | 84 | val state = mutator.mutate(message) 85 | appliedSet.add(mutator.javaClass) 86 | return state 87 | } 88 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/command/executors/PinnedMessagesCommand.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.command.executors 10 | 11 | import io.zachbr.dis4irc.bridge.Bridge 12 | import io.zachbr.dis4irc.bridge.command.api.Executor 13 | import io.zachbr.dis4irc.bridge.message.IrcSource 14 | import io.zachbr.dis4irc.bridge.message.PlatformMessage 15 | import io.zachbr.dis4irc.bridge.mutator.mutators.TranslateFormatting 16 | import io.zachbr.dis4irc.bridge.pier.irc.IrcMessageFormatter 17 | import java.time.format.DateTimeFormatter 18 | import java.time.format.FormatStyle 19 | import java.util.Locale 20 | import kotlin.math.min 21 | 22 | private const val PAGE_SIZE = 5 23 | private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) 24 | private val timeFormatter = DateTimeFormatter.ofPattern("hh:mm a", Locale.getDefault()) 25 | 26 | class PinnedMessagesCommand(private val bridge: Bridge) : Executor { 27 | override fun onCommand(command: PlatformMessage): String? { 28 | if (command.source !is IrcSource) { 29 | bridge.logger.debug("Ignoring request for pinned messages because it originates from Discord") 30 | return null 31 | } 32 | 33 | val args = command.contents.split(' ') 34 | var requestedPage = 1 35 | if (args.size > 1) { 36 | requestedPage = try { 37 | args[1].toInt() 38 | } catch (_: NumberFormatException) { 39 | bridge.ircConn.sendNotice(command.sender.displayName, "Invalid page number provided.") 40 | return null 41 | } 42 | } 43 | 44 | if (requestedPage < 1) { 45 | bridge.ircConn.sendNotice(command.sender.displayName, "Page number must be 1 or greater.") 46 | return null 47 | } 48 | 49 | val mappedChannel = bridge.channelMappings.getMappingFor(command.source) ?: throw IllegalStateException("No mapping for source channel: ${command.source}?!?") 50 | bridge.discordConn.getPinnedMessages(mappedChannel) { pinned -> 51 | if (pinned.isNullOrEmpty()) { 52 | bridge.ircConn.sendNotice(command.sender.displayName, "There are no pinned messages for ${command.source.channelName}.") 53 | return@getPinnedMessages 54 | } 55 | 56 | val totalMessages = pinned.size 57 | val totalPages = (totalMessages + PAGE_SIZE - 1) / PAGE_SIZE 58 | if (requestedPage > totalPages) { 59 | bridge.ircConn.sendNotice(command.sender.displayName, "Page $requestedPage does not exist. Total pages: $totalPages") 60 | return@getPinnedMessages 61 | } 62 | 63 | val startIndex = (requestedPage - 1) * PAGE_SIZE 64 | val endIndex = min(startIndex + PAGE_SIZE, totalMessages) 65 | val pageMessages = pinned.subList(startIndex, endIndex) 66 | 67 | bridge.ircConn.sendNotice(command.sender.displayName, "--- Pinned Messages (Page $requestedPage/$totalPages) ---") 68 | for (msg in pageMessages) { 69 | bridge.mutatorManager.applyMutator(TranslateFormatting::class.java, msg) 70 | val msgContent = StringBuilder() 71 | .append(msg.sentTimestamp.format(dateFormatter)) 72 | .append(" at ") 73 | .append(msg.sentTimestamp.format(timeFormatter)) 74 | .append(' ') 75 | .append(IrcMessageFormatter.format(msg, bridge.config).joinToString(" ")) 76 | 77 | bridge.ircConn.sendNotice(command.sender.displayName, msgContent.toString()) 78 | } 79 | } 80 | 81 | return null // don't send a message publicly 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/test/kotlin/io/zachbr/dis4irc/bridge/mutator/mutators/TranslateFormattingTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.mutator.mutators 10 | 11 | import io.zachbr.dis4irc.bridge.message.DiscordMessage 12 | import io.zachbr.dis4irc.bridge.message.DiscordSender 13 | import io.zachbr.dis4irc.bridge.message.DiscordSource 14 | import io.zachbr.dis4irc.bridge.message.IrcMessage 15 | import io.zachbr.dis4irc.bridge.message.IrcSender 16 | import io.zachbr.dis4irc.bridge.message.IrcSource 17 | import org.junit.jupiter.api.Assertions.assertEquals 18 | import org.junit.jupiter.api.Test 19 | import org.kitteh.irc.client.library.util.Format 20 | import java.time.Instant 21 | import java.time.OffsetDateTime 22 | 23 | class TranslateFormattingTest { 24 | @Test 25 | fun ircToDiscord() { 26 | this.testIrcToDiscord("[Test] test pushed **1** new commit to master: __https://example.com/example__", "[\u000313Test\u000F] \u000315test\u000F pushed \u00021\u000F new commit to \u000306master\u000F: \u000302\u001Fhttps://example.com/example\u000F") 27 | this.testIrcToDiscord("abc123", Format.RED.toString() + "abc123") 28 | this.testIrcToDiscord("***This is a*** test.", Format.BOLD.toString() + Format.ITALIC.toString() + "This is a" + Format.RESET.toString() + " test.") 29 | this.testIrcToDiscord("kitten**bold**", Format.MAGENTA.toString() + "kitten" + Format.BOLD.toString() + "bold") 30 | this.testIrcToDiscord("**boldkitten**unbold", Format.BOLD.toString() + "bold" + Format.MAGENTA.toString() + "kitten" + Format.BOLD.toString() + "unbold") 31 | this.testIrcToDiscord("destroy", "\u000311,08d\u000302,07e\u000312,06s\u000306,07t\u000313,02r\u000305,09o\u000304,07y\u000F") 32 | this.testIrcToDiscord("d1e2s3t4r5o6y7", "\u000307,11d\u000308,101\u000303,11e\u000309,132\u000310,07s\u000311,093\u000302,09t\u000312,084\u000306,03r\u000313,115\u000305,12o\u000304,136\u000307,13y\u000308,027\u000F") 33 | this.testIrcToDiscord("**bold__underline__**__tacos__", "\u0002bold\u001Funderline\u0002tacos") 34 | this.testIrcToDiscord("**bold**__underline__`monospaced`__*~~striked~~*__", "\u0002bold\u0002\u001Funderline\u0011monospaced\u0011\u001D\u001Estriked\u001E") 35 | } 36 | 37 | @Test 38 | fun discordToIrc() { 39 | this.testDiscordToIrc("${Format.BOLD}Test bold${Format.BOLD}", "**Test bold**") 40 | this.testDiscordToIrc("${Format.ITALIC}Test italics${Format.ITALIC}", "*Test italics*") 41 | this.testDiscordToIrc("${Format.UNDERLINE}Test underlines${Format.UNDERLINE}", "__Test underlines__") 42 | this.testDiscordToIrc("${IrcFormattingCodes.STRIKETHROUGH}Test strikethrough${IrcFormattingCodes.STRIKETHROUGH}", "~~Test strikethrough~~") 43 | this.testDiscordToIrc("${Format.COLOR_CHAR}${IrcColorCodes.BLACK},${IrcColorCodes.BLACK}Test spoiler${Format.COLOR_CHAR}", "||Test spoiler||") 44 | this.testDiscordToIrc("${IrcFormattingCodes.MONOSPACE}Test inline code${IrcFormattingCodes.MONOSPACE}", "`Test inline code`") 45 | 46 | //this.testDiscordToIrc("Attached${Format.COLOR_CHAR}${IrcColorCodes.BLACK},${IrcColorCodes.BLACK}together${Format.COLOR_CHAR}", "Attached||together||") // TODO - fix bug in case 47 | this.testDiscordToIrc("¯\\_(ツ)_/¯", "¯\\_(ツ)_/¯") 48 | } 49 | 50 | private fun testIrcToDiscord(expected: String, string: String) { 51 | val message = IrcMessage(string, IrcSender("Test", null), IrcSource("#test"), Instant.now()) 52 | val mutator = TranslateFormatting() 53 | mutator.mutate(message) 54 | assertEquals(expected, message.contents) 55 | } 56 | 57 | private fun testDiscordToIrc(expected: String, input: String) { 58 | val message = DiscordMessage(input, DiscordSender("Test", 10L), DiscordSource("#test", 5L), Instant.now(), sentTimestamp = OffsetDateTime.now()) 59 | val mutator = TranslateFormatting() 60 | mutator.mutate(message) 61 | assertEquals(expected, message.contents) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/ConfigurationData.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge 10 | 11 | import org.spongepowered.configurate.CommentedConfigurationNode 12 | import java.util.regex.Pattern 13 | 14 | val MATCH_ALL_INDIV = Regex(".") 15 | /** 16 | * Main bridge configuration data class 17 | */ 18 | data class BridgeConfiguration( 19 | val bridgeName: String, 20 | val announceJoinsQuits: Boolean, 21 | val announceExtras: Boolean, 22 | val channelMappings: List, 23 | val irc: IrcConfiguration, 24 | val discord: DiscordConfiguration, 25 | val rawNode: CommentedConfigurationNode 26 | ) { 27 | /** toString with no sensitive info */ 28 | fun toLoggable(): String { 29 | return "BridgeConfiguration(" + 30 | "bridgeName='$bridgeName', " + 31 | "announceJoinsQuits=$announceJoinsQuits, " + 32 | "announceExtras=$announceExtras, " + 33 | "channelMappings=$channelMappings, " + 34 | irc.toLoggable() + " " + 35 | discord.toLoggable() + " " + 36 | "rawNode=(hash) ${rawNode.hashCode()})" 37 | } 38 | } 39 | 40 | data class IrcConfiguration ( 41 | val hostname: String, 42 | val password: String?, 43 | val port: Int, 44 | val sslEnabled: Boolean, 45 | val allowInvalidCerts: Boolean, 46 | val nickName: String, 47 | val userName: String, 48 | val realName: String, 49 | val antiPing: Boolean, 50 | val useNickNameColor: Boolean, 51 | val noPrefixRegex: Pattern?, 52 | val announceForwardedCommands: Boolean, 53 | val discordReplyContextLimit: Int, 54 | val startupRawCommands: List, 55 | val sendDiscordEmbeds: Boolean 56 | ) { 57 | fun toLoggable(): String { 58 | return "IrcConfiguration(" + 59 | "hostname='$hostname', " + 60 | "password=${password?.replace(MATCH_ALL_INDIV, "*")}, " + 61 | "port=$port, " + 62 | "sslEnabled=$sslEnabled, " + 63 | "allowInvalidCerts=$allowInvalidCerts, " + 64 | "nickName='$nickName', " + 65 | "userName='$userName', " + 66 | "realName='$realName', " + 67 | "antiPing=$antiPing, " + 68 | "useNickNameColor=$useNickNameColor, " + 69 | "noPrefixRegex=$noPrefixRegex, " + 70 | "announceForwardedCommands=$announceForwardedCommands, " + 71 | "discordReplyContextLimit=$discordReplyContextLimit, " + 72 | "startupRawCommands=$startupRawCommands" + 73 | "sendDiscordEmbeds=$sendDiscordEmbeds" + 74 | ")" 75 | } 76 | } 77 | 78 | data class DiscordConfiguration( 79 | val apiKey: String, 80 | val webHooks: List, 81 | val activityType: String, 82 | val activityDesc: String, 83 | val activityUrl: String, 84 | val onlineStatus: String, 85 | val suppressUrlPreview: Boolean 86 | ) { 87 | fun toLoggable(): String { 88 | return "DiscordConfiguration(" + 89 | "apiKey='${apiKey.replace(MATCH_ALL_INDIV, "*")}', " + 90 | "webHooks=${webHooks.map { it.toLoggable() }}, " + 91 | "activityType=${activityType}, " + 92 | "activityDesc=${activityDesc}, " + 93 | "activityUrl=${activityUrl}, " + 94 | "onlineStatus=${onlineStatus}" + 95 | "suppressUrlPreview=${suppressUrlPreview}" + 96 | ")" 97 | } 98 | } 99 | 100 | /** 101 | * Simple channel-to-channel configuration data class 102 | */ 103 | data class ChannelMapping( 104 | val discordChannel: String, 105 | val ircChannel: String 106 | ) 107 | 108 | /** 109 | * Simple discord-to-webhook configuration data class 110 | */ 111 | data class WebhookMapping( 112 | val discordChannel: String, 113 | val webhookUrl: String 114 | ) { 115 | /** toString with no sensitive info */ 116 | fun toLoggable(): String { 117 | return "WebhookMapping(discordChannel='$discordChannel', webhookUrl='${webhookUrl.substring(0, 60)}')" 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/command/executors/StatsCommand.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.command.executors 10 | 11 | import io.zachbr.dis4irc.bridge.Bridge 12 | import io.zachbr.dis4irc.bridge.command.api.Executor 13 | import io.zachbr.dis4irc.bridge.message.PlatformMessage 14 | import java.lang.management.ManagementFactory 15 | import java.math.BigDecimal 16 | import java.math.BigInteger 17 | import java.math.MathContext 18 | import java.math.RoundingMode 19 | import java.util.concurrent.TimeUnit 20 | import java.util.concurrent.atomic.AtomicLong 21 | 22 | private const val EXEC_DELAY_MILLIS = 60_000 23 | 24 | class StatsCommand(private val bridge: Bridge) : Executor { 25 | private val percentageContext = MathContext(4, RoundingMode.HALF_UP) 26 | private var lastExecution = AtomicLong(0L) 27 | 28 | private fun isRateLimited(): Boolean { 29 | val now = System.currentTimeMillis() 30 | val last = lastExecution.get() 31 | 32 | if (now - last > EXEC_DELAY_MILLIS) { 33 | if (lastExecution.compareAndSet(last, now)) { 34 | return false 35 | } 36 | } 37 | return true 38 | } 39 | 40 | override fun onCommand(command: PlatformMessage): String? { 41 | if (isRateLimited()) { 42 | return null 43 | } 44 | 45 | val sortedTimings = bridge.statsManager.getMessageTimings().apply { sort() } // avoid copy 46 | val meanMillis = TimeUnit.NANOSECONDS.toMillis(mean(sortedTimings)) 47 | val medianMillis = TimeUnit.NANOSECONDS.toMillis(median(sortedTimings)) 48 | 49 | val uptime = ManagementFactory.getRuntimeMXBean().uptime 50 | val uptimeStr = convertMillisToPretty(uptime) 51 | 52 | val fromIrc = bridge.statsManager.getTotalFromIrc() 53 | val fromDiscord = bridge.statsManager.getTotalFromDiscord() 54 | 55 | val fromIrcPercent = percent(fromIrc, fromDiscord + fromIrc) 56 | val fromDiscordPercent = BigDecimal.valueOf(100).subtract(fromIrcPercent, percentageContext) 57 | 58 | return "Uptime: $uptimeStr\n" + 59 | "Message Handling: ${meanMillis}ms / ${medianMillis}ms (mean/median)\n" + 60 | "Messages from IRC: $fromIrc (${fromIrcPercent.toDouble()}%)\n" + 61 | "Messages from Discord: $fromDiscord (${fromDiscordPercent.toDouble()}%)" 62 | } 63 | 64 | private fun percent(value: BigInteger, total: BigInteger): BigDecimal { 65 | if (total == BigInteger.ZERO || value == total) { 66 | return BigDecimal.valueOf(100) 67 | } 68 | 69 | return BigDecimal(value) 70 | .multiply(BigDecimal.valueOf(100)) 71 | .divide(BigDecimal(total), percentageContext) 72 | } 73 | 74 | /** 75 | * Gets the mean of a given array 76 | */ 77 | private fun mean(a: LongArray): Long { 78 | var sum = 0L 79 | for (i in a.indices) { 80 | sum += a[i] 81 | } 82 | 83 | return sum / a.size 84 | } 85 | 86 | /** 87 | * Gets the median of a given sorted array 88 | */ 89 | private fun median(a: LongArray): Long { 90 | val middle = a.size / 2 91 | 92 | return if (a.size % 2 == 1) { 93 | a[middle] 94 | } else { 95 | (a[middle - 1] + a[middle]) / 2 96 | } 97 | } 98 | 99 | /** 100 | * Converts the given amount of milliseconds to a presentable elapsed time string 101 | */ 102 | private fun convertMillisToPretty(diffMillis: Long): String { 103 | var left = diffMillis 104 | 105 | val secondsInMilli: Long = 1000 106 | val minutesInMilli = secondsInMilli * 60 107 | val hoursInMilli = minutesInMilli * 60 108 | val daysInMilli = hoursInMilli * 24 109 | 110 | val elapsedDays = left / daysInMilli 111 | left %= daysInMilli 112 | 113 | val elapsedHours = left / hoursInMilli 114 | left %= hoursInMilli 115 | 116 | val elapsedMinutes = left / minutesInMilli 117 | left %= minutesInMilli 118 | 119 | val elapsedSeconds = left / secondsInMilli 120 | 121 | return "$elapsedDays days, $elapsedHours hours, $elapsedMinutes minutes, $elapsedSeconds seconds" 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Dis4IRC 2 | ======= 3 | A modern Discord <-> IRC Bridge 4 | 5 | Published under the [MIT License](https://github.com/zachbr/Dis4IRC/blob/master/LICENSE.md). 6 | 7 | Features 8 | -------- 9 | * Markdown and Modern IRC Client Features 10 | * Paste support for long messages 11 | * Channel Join/Quit broadcasts 12 | * Discord webhook support 13 | * Non-prefixed messages for other IRC bots to handle 14 | * IRC anti-ping zero width character in usernames 15 | * User, channel, role, and emote mentions 16 | * Sticker and emote support (as images or animated viewer) 17 | * Reply bridging with context 18 | 19 | Getting Started 20 | --------------- 21 | Please see the [Getting Started page](https://github.com/zachbr/Dis4IRC/blob/master/docs/Getting-Started.md). 22 | 23 | Downloads 24 | --------- 25 | * Release versions can be found on [GitHub releases](https://github.com/zachbr/Dis4IRC/releases). 26 | * The latest build from source can be found as a GitHub actions artifact. 27 | * Users authenticated with GitHub can access builds [here](https://github.com/zachbr/Dis4IRC/actions?query=event%3Apush+is%3Asuccess+branch%3Amaster). 28 | * Unauthenticated users can access the [latest version here](https://nightly.link/zachbr/Dis4IRC/workflows/gradle/master/dis4irc-jar). 29 | 30 | Docker/Container 31 | ---------------- 32 | Releases are also available as container images. Each release version gets a tag as well as: 33 | - `latest`: always tracks the current release version. 34 | - `edge`: tracks the primary development branch. 35 | 36 | Example Config 37 | -------------- 38 | ```hocon 39 | # Dis4IRC Configuration File 40 | 41 | # A list of bridges that Dis4IRC should start up 42 | # Each bridge can bridge multiple channels between a single IRC and Discord Server 43 | bridges { 44 | # A bridge is a single bridged connection operating in its own space away from all the other bridges 45 | # Most people will only need this one default bridge 46 | default { 47 | # Relay joins, quits, parts, and kicks 48 | announce-joins-and-quits=false 49 | # Relay extra verbose information you don't really need like topics and mode changes. 50 | announce-extras=false 51 | # Mappings are the channel <-> channel bridging configurations 52 | channel-mappings { 53 | "712345611123456811"="#bridgedChannel" 54 | } 55 | # Your discord API key you registered your bot with 56 | discord-api-key="NTjhWZj1MTq0L10gMDU0MSQ1.Zpj02g.4QiWlNw9W5xd150qXsC3e-oc156" 57 | # Match a channel id to a webhook URL to enable webhooks for that channel 58 | discord-webhooks { 59 | "712345611123456811"="https://discordapp.com/api/webhooks/712345611123456811/blahblahurl" 60 | } 61 | # Discord-specific configuration options 62 | discord-options { 63 | # Descriptor text to show in the client. An empty string will show nothing. This may not update immediately. 64 | activity-desc=IRC 65 | # Activity type to report to Discord clients. Acceptable values are DEFAULT, LISTENING, STREAMING, WATCHING, COMPETING 66 | activity-type=DEFAULT 67 | # Additional URL field used by certain activity types. Restricted to certain URLs depending on the activity type. 68 | activity-url="" 69 | # Online status indicator. Acceptable values are ONLINE, IDLE, DO_NOT_DISTURB, INVISIBLE 70 | online-status=ONLINE 71 | } 72 | commands { 73 | pinned { 74 | enabled="true" 75 | } 76 | stats { 77 | enabled="true" 78 | } 79 | } 80 | # Configuration for connecting to the IRC server 81 | irc { 82 | anti-ping=true 83 | nickname=TestBridge2 84 | # Messages that match this regular expression will be passed to IRC without a user prefix 85 | no-prefix-regex="^\\.[A-Za-z0-9]" 86 | # Sets the max context length to use for messages that are Discord replies. 0 to disable. 87 | discord-reply-context-limit=90 88 | # A list of __raw__ irc messages to send 89 | init-commands-list=[ 90 | "PRIVMSG NICKSERV info", 91 | "PRIVMSG NICKSERV help" 92 | ] 93 | port="6697" 94 | realname=BridgeBot 95 | send-discord-embeds=true 96 | server="irc.esper.net" 97 | # Controls whether bridged nicknames will use color 98 | use-nickname-colors=true 99 | use-ssl=true 100 | username=BridgeBot 101 | } 102 | mutators { 103 | paste-service { 104 | max-message-length=450 105 | max-new-lines=4 106 | # Number of days before paste expires. Use 0 to never expire. 107 | paste-expiration-in-days=7 108 | } 109 | } 110 | } 111 | } 112 | debug-logging=true 113 | 114 | ``` 115 | 116 | Obligatory 117 | ---------- 118 | ![xkcd #1782](https://imgs.xkcd.com/comics/team_chat.png) 119 | 120 | The Name 121 | -------- 122 | The name is a typo of a typo of a bad idea of a misspoken phrase. 123 | Let's just not go there :p 124 | 125 | Built using 126 | ----------- 127 | * [KittehIRCClientLib](https://github.com/KittehOrg/KittehIRCClientLib) 128 | * [JDA (Java Discord API)](https://github.com/DV8FromTheWorld/JDA) 129 | * [Configurate](https://github.com/SpongePowered/configurate) 130 | 131 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/Bridge.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge 10 | 11 | import io.zachbr.dis4irc.Dis4IRC 12 | import io.zachbr.dis4irc.bridge.command.COMMAND_PREFIX 13 | import io.zachbr.dis4irc.bridge.command.CommandManager 14 | import io.zachbr.dis4irc.bridge.message.BridgeMessage 15 | import io.zachbr.dis4irc.bridge.message.CommandMessage 16 | import io.zachbr.dis4irc.bridge.message.DiscordMessage 17 | import io.zachbr.dis4irc.bridge.message.DiscordSource 18 | import io.zachbr.dis4irc.bridge.message.IrcMessage 19 | import io.zachbr.dis4irc.bridge.message.IrcSource 20 | import io.zachbr.dis4irc.bridge.message.PlatformType 21 | import io.zachbr.dis4irc.bridge.mutator.MutatorManager 22 | import io.zachbr.dis4irc.bridge.pier.discord.DiscordPier 23 | import io.zachbr.dis4irc.bridge.pier.irc.IrcPier 24 | import org.json.JSONObject 25 | import org.slf4j.LoggerFactory 26 | import java.time.Instant 27 | 28 | /** 29 | * Responsible for the connection between Discord and IRC, including message processing hand-offs 30 | */ 31 | class Bridge(private val main: Dis4IRC, internal val config: BridgeConfiguration) { 32 | internal val logger = LoggerFactory.getLogger(config.bridgeName) ?: throw IllegalStateException("Could not init logger") 33 | 34 | internal val channelMappings = ChannelMappingManager(config) 35 | internal val statsManager = StatisticsManager(this) 36 | private val commandManager = CommandManager(this, config.rawNode.node("commands")) 37 | internal val mutatorManager = MutatorManager(this, config.rawNode.node("mutators")) 38 | 39 | internal val discordConn = DiscordPier(this) 40 | internal val ircConn = IrcPier(this) 41 | 42 | /** 43 | * Connects to IRC and Discord 44 | */ 45 | fun startBridge() { 46 | logger.debug(config.toLoggable()) 47 | 48 | try { 49 | discordConn.start() 50 | ircConn.start() 51 | } catch (ex: Exception) { // just catch everything - "conditions that a reasonable application might want to catch" 52 | logger.error("Unable to initialize bridge connections: $ex") 53 | ex.printStackTrace() 54 | this.shutdown(inErr = true) 55 | return 56 | } 57 | 58 | logger.info("Bridge initialized and running") 59 | } 60 | 61 | /** 62 | * Bridges communication between the two piers 63 | */ 64 | internal fun submitMessage(bMessage: BridgeMessage) { 65 | // don't process a message that has no destination 66 | val bridgeTarget = channelMappings.getMappingFor(bMessage.message.source) ?: run { 67 | logger.debug("Discarding message with no bridge target from: {}", bMessage.message.source) 68 | return 69 | } 70 | 71 | val mutatedMessage = mutatorManager.applyMutators(bMessage) ?: return 72 | val mutatedPlatformMsg = mutatedMessage.message 73 | 74 | // we only send across the bridge (to the relevant mapping) or back to the same source currently 75 | val ircSendTarget: String 76 | val discordSendTarget: String 77 | when (mutatedPlatformMsg) { 78 | is IrcMessage -> { 79 | ircSendTarget = mutatedPlatformMsg.source.channelName 80 | discordSendTarget = bridgeTarget 81 | } 82 | is DiscordMessage -> { 83 | ircSendTarget = bridgeTarget 84 | discordSendTarget = mutatedPlatformMsg.source.channelId.toString() 85 | } 86 | is CommandMessage -> { 87 | when (mutatedPlatformMsg.source) { 88 | is IrcSource -> { 89 | ircSendTarget = mutatedPlatformMsg.source.channelName 90 | discordSendTarget = bridgeTarget 91 | } 92 | is DiscordSource -> { 93 | ircSendTarget = bridgeTarget 94 | discordSendTarget = mutatedPlatformMsg.source.channelId.toString() 95 | } 96 | } 97 | } 98 | } 99 | 100 | if (mutatedMessage.shouldSendTo(PlatformType.IRC)) { 101 | ircConn.sendMessage(ircSendTarget, mutatedMessage) 102 | } 103 | 104 | if (mutatedMessage.shouldSendTo(PlatformType.DISCORD)) { 105 | discordConn.sendMessage(discordSendTarget, mutatedMessage) 106 | } 107 | 108 | if (mutatedMessage.message.contents.startsWith(COMMAND_PREFIX) && !mutatedMessage.originatesFromBridgeItself()) { 109 | commandManager.processCommandMessage(mutatedMessage) 110 | } 111 | } 112 | 113 | /** 114 | * Clean up and disconnect from the IRC and Discord platforms 115 | */ 116 | internal fun shutdown(inErr: Boolean = false) { 117 | logger.debug("Stopping bridge...") 118 | 119 | discordConn.onShutdown() 120 | ircConn.onShutdown() 121 | 122 | logger.info("Bridge stopped") 123 | main.notifyOfBridgeShutdown(this, inErr) 124 | } 125 | 126 | internal fun persistData(json: JSONObject): JSONObject { 127 | json.put("statistics", statsManager.writeData(JSONObject())) 128 | return json 129 | } 130 | 131 | internal fun readSavedData(json: JSONObject) { 132 | if (json.has("statistics")) { 133 | statsManager.readSavedData(json.getJSONObject("statistics")) 134 | } 135 | } 136 | 137 | /** 138 | * Adds a message's handling time to the bridge's collection for monitoring purposes 139 | */ 140 | fun updateStatistics(message: BridgeMessage, sendInstant: Instant) { 141 | statsManager.processMessage(message, sendInstant) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/pier/irc/IrcPier.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.irc 10 | 11 | import io.netty.handler.ssl.util.InsecureTrustManagerFactory 12 | import io.zachbr.dis4irc.bridge.Bridge 13 | import io.zachbr.dis4irc.bridge.message.BridgeMessage 14 | import io.zachbr.dis4irc.bridge.message.PlatformMessage 15 | import io.zachbr.dis4irc.bridge.pier.Pier 16 | import org.kitteh.irc.client.library.Client 17 | import org.kitteh.irc.client.library.Client.Builder.Server.SecurityType 18 | import org.kitteh.irc.client.library.element.Channel 19 | import org.slf4j.Logger 20 | import java.time.Instant 21 | import java.util.regex.Pattern 22 | 23 | class IrcPier(private val bridge: Bridge) : Pier { 24 | internal val logger: Logger = bridge.logger 25 | private lateinit var ircConn: Client 26 | private var antiPing: Boolean = false 27 | private var noPrefix: Pattern? = null 28 | private var referenceLengthLimit: Int = 90 29 | 30 | override fun start() { 31 | logger.info("Connecting to IRC Server") 32 | 33 | // configure IRC 34 | val builder = Client.builder() 35 | .nick(bridge.config.irc.nickName) 36 | .user(bridge.config.irc.userName) 37 | .realName(bridge.config.irc.realName) 38 | .server() 39 | .host(bridge.config.irc.hostname) 40 | .port(bridge.config.irc.port, if (bridge.config.irc.sslEnabled) SecurityType.SECURE else SecurityType.INSECURE) 41 | .password(bridge.config.irc.password) 42 | 43 | if (bridge.config.irc.allowInvalidCerts) { 44 | logger.warn("Allowing invalid TLS certificates for IRC. This is not recommended.") 45 | builder.secureTrustManagerFactory(InsecureTrustManagerFactory.INSTANCE) 46 | } 47 | 48 | // connect 49 | ircConn = builder.then().buildAndConnect() 50 | ircConn.client.exceptionListener.setConsumer { bridge.logger.warn("Exception from IRC API:", it)} 51 | 52 | // listeners 53 | ircConn.eventManager.registerEventListener(IrcConnectionListener(this)) 54 | ircConn.eventManager.registerEventListener(IrcMessageListener(this)) 55 | 56 | if (bridge.config.announceJoinsQuits) { 57 | ircConn.eventManager.registerEventListener(IrcJoinQuitListener(this)) 58 | } 59 | 60 | if (bridge.config.announceExtras) { 61 | ircConn.eventManager.registerEventListener(IrcExtrasListener(this)) 62 | } 63 | 64 | noPrefix = bridge.config.irc.noPrefixRegex 65 | antiPing = bridge.config.irc.antiPing 66 | referenceLengthLimit = bridge.config.irc.discordReplyContextLimit 67 | } 68 | 69 | override fun onShutdown() { 70 | if (this::ircConn.isInitialized) { 71 | ircConn.shutdown("Exiting...") 72 | } 73 | } 74 | 75 | override fun sendMessage(targetChan: String, msg: BridgeMessage) { 76 | if (!this::ircConn.isInitialized) { 77 | logger.error("IRC Connection has not been initialized yet!") 78 | return 79 | } 80 | 81 | val channel = getChannelByName(targetChan) 82 | if (channel == null) { 83 | logger.error("Unable to get or join $targetChan to send message from ${msg.message.sender.displayName}") 84 | logger.debug(msg.toString()) 85 | return 86 | } 87 | 88 | val ircLines = IrcMessageFormatter.format(msg.message, bridge.config) 89 | ircLines.forEach { line -> 90 | channel.sendMultiLineMessage(line) 91 | } 92 | 93 | if (ircLines.isNotEmpty()) { 94 | bridge.updateStatistics(msg, Instant.now()) 95 | } 96 | } 97 | 98 | fun sendNotice(targetUser: String, message: String) { 99 | if (!this::ircConn.isInitialized) { 100 | logger.error("IRC Connection has not been initialized yet!") 101 | return 102 | } 103 | 104 | for (line in message.split("\n")) { 105 | ircConn.sendMultiLineNotice(targetUser, line) 106 | } 107 | } 108 | 109 | /** 110 | * Gets a channel by name, joining it if necessary 111 | */ 112 | private fun getChannelByName(name: String): Channel? { 113 | val chan = ircConn.getChannel(name).toNullable() 114 | if (chan == null) { 115 | logger.warn("Bridge not in expected channel $name, was it kicked?") 116 | logger.debug("Attempting to rejoin $name") 117 | ircConn.addChannel(name) 118 | } 119 | 120 | return chan ?: ircConn.getChannel(name).toNullable() 121 | } 122 | 123 | /** 124 | * Gets the IRC bot user's nickname 125 | */ 126 | fun getBotNick(): String { 127 | return ircConn.nick 128 | } 129 | 130 | /** 131 | * Sends a message to the bridge for processing 132 | */ 133 | fun sendToBridge(message: PlatformMessage) { 134 | bridge.submitMessage(BridgeMessage(message)) 135 | } 136 | 137 | /** 138 | * Signals the bridge that the pier needs to shut down 139 | */ 140 | fun signalShutdown(inErr: Boolean) { 141 | this.bridge.shutdown(inErr) 142 | } 143 | 144 | /** 145 | * Handle tasks that need to be done after connection but before the bot can start being used 146 | */ 147 | fun runPostConnectTasks() { 148 | // execute any startup commands 149 | for (command in bridge.config.irc.startupRawCommands) { 150 | logger.debug("Sending raw init command: $command") 151 | ircConn.sendRawLine(command) 152 | } 153 | 154 | // join all mapped channels 155 | for (mapping in bridge.config.channelMappings) { 156 | logger.debug("Joining ${mapping.ircChannel}") 157 | ircConn.addChannel(mapping.ircChannel) 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/test/kotlin/io/zachbr/dis4irc/bridge/pier/irc/IrcEncodedCutterTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.irc 10 | 11 | import org.junit.jupiter.api.Assertions.assertEquals 12 | import org.junit.jupiter.api.Test 13 | import org.kitteh.irc.client.library.util.Cutter 14 | 15 | const val ZERO_WIDTH_SPACE = 0x200B.toChar() 16 | 17 | // Test for GH-64. Originally for our own implementation, now just verify the underlying IRC library fix. 18 | class IrcEncodedCutterTest { 19 | private val cutter = Cutter.DefaultWordCutter() 20 | 21 | @Test 22 | fun testCutterFastPath() { 23 | val input = "The quick brown fox jumps over the lazy dog" 24 | val size = 999 // beyond 25 | val output = cutter.split(input, size) 26 | assertEquals(input, output[0]) 27 | } 28 | 29 | @Test 30 | fun testCutterAccuracy() { 31 | val input = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Porta nibh venenatis cras sed felis eget velit aliquet sagittis. Ut sem nulla pharetra diam sit amet nisl suscipit. Tempor commodo ullamcorper a lacus vestibulum sed. Felis bibendum ut tristique et egestas quis ipsum suspendisse ultrices. Tristique senectus et netus et malesuada fames ac turpis. Augue ut lectus arcu bibendum. Eget lorem dolor sed viverra. Consequat semper viverra nam libero. Est ante in nibh mauris. Sed viverra ipsum nunc aliquet bibendum. Sed odio morbi quis commodo odio aenean sed adipiscing diam. Dignissim sodales ut eu sem integer vitae justo eget. Volutpat blandit aliquam etiam erat. Elementum eu facilisis sed odio morbi quis. Vel pharetra vel turpis nunc eget lorem. Amet venenatis urna cursus eget nunc scelerisque viverra mauris in." 32 | val size = 150 33 | val output = cutter.split(input, size) 34 | 35 | val expected0 = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Porta nibh venenatis cras" 36 | val expected1 = "sed felis eget velit aliquet sagittis. Ut sem nulla pharetra diam sit amet nisl suscipit. Tempor commodo ullamcorper a lacus vestibulum sed. Felis" 37 | val expected2 = "bibendum ut tristique et egestas quis ipsum suspendisse ultrices. Tristique senectus et netus et malesuada fames ac turpis. Augue ut lectus arcu" 38 | val expected3 = "bibendum. Eget lorem dolor sed viverra. Consequat semper viverra nam libero. Est ante in nibh mauris. Sed viverra ipsum nunc aliquet bibendum. Sed" 39 | val expected4 = "odio morbi quis commodo odio aenean sed adipiscing diam. Dignissim sodales ut eu sem integer vitae justo eget. Volutpat blandit aliquam etiam erat." 40 | val expected5 = "Elementum eu facilisis sed odio morbi quis. Vel pharetra vel turpis nunc eget lorem. Amet venenatis urna cursus eget nunc scelerisque viverra mauris" 41 | val expected6 = "in." 42 | 43 | assertEquals(expected0, output[0]) 44 | assertEquals(expected1, output[1]) 45 | assertEquals(expected2, output[2]) 46 | assertEquals(expected3, output[3]) 47 | assertEquals(expected4, output[4]) 48 | assertEquals(expected5, output[5]) 49 | assertEquals(expected6, output[6]) 50 | } 51 | 52 | @Test 53 | fun testCutterNoSpaces() { 54 | val input = "IconfessthatIdonotentirelyapproveofthisConstitutionatpresent;but,sir,IamnotsureIshallneverapproveit,for,havinglivedlong,Ihaveexperiencedmanyinstancesofbeingobliged,bybetterinformationorfullerconsideration,tochangeopinionsevenonimportantsubjects,whichIoncethoughtright,butfoundtobeotherwise" 55 | val size = 100 56 | val output = cutter.split(input, size) 57 | 58 | val expected0 = "IconfessthatIdonotentirelyapproveofthisConstitutionatpresent;but,sir,IamnotsureIshallneverapproveit," 59 | val expected1 = "for,havinglivedlong,Ihaveexperiencedmanyinstancesofbeingobliged,bybetterinformationorfullerconsidera" 60 | val expected2 = "tion,tochangeopinionsevenonimportantsubjects,whichIoncethoughtright,butfoundtobeotherwise" 61 | 62 | assertEquals(expected0, output[0]) 63 | assertEquals(expected1, output[1]) 64 | assertEquals(expected2, output[2]) 65 | } 66 | 67 | @Test 68 | fun testCutterUnicode() { 69 | val input = " Faust complained about having two souls in his breast, but I harbor a whole crowd of them and they quarrel. It is like being in a republic." 70 | val size = 30 71 | val output = cutter.split(input, size) 72 | 73 | val expected0 = " Faust complained" 74 | val expected1 = "about having two souls in his" 75 | val expected2 = "breast, but I harbor a whole" 76 | val expected3 = "crowd of them and they" 77 | val expected4 = "quarrel. It is like being in a" 78 | val expected5 = "republic." 79 | 80 | assertEquals(expected0, output[0]) 81 | assertEquals(expected1, output[1]) 82 | assertEquals(expected2, output[2]) 83 | assertEquals(expected3, output[3]) 84 | assertEquals(expected4, output[4]) 85 | assertEquals(expected5, output[5]) 86 | } 87 | 88 | @Test 89 | fun testCutterUnicodeEmoji() { 90 | val input = " Faust complained about having two souls in his breast \uD83D\uDE14\uD83D\uDC65, but I harbor a whole crowd of them \uD83D\uDE05\uD83D\uDC65 and they quarrel. It is like being in a republic. \uD83E\uDD14\uD83C\uDFDB\uFE0F\uD83C\uDFAD" 91 | val size = 45 92 | val output = cutter.split(input, size) 93 | 94 | val expected0 = " Faust complained about having two" 95 | val expected1 = "souls in his breast \uD83D\uDE14\uD83D\uDC65, but I harbor a" 96 | val expected2 = "whole crowd of them \uD83D\uDE05\uD83D\uDC65 and they" 97 | val expected3 = "quarrel. It is like being in a republic." 98 | val expected4 = "\uD83E\uDD14\uD83C\uDFDB\uFE0F\uD83C\uDFAD" 99 | 100 | assertEquals(expected0, output[0]) 101 | assertEquals(expected1, output[1]) 102 | assertEquals(expected2, output[2]) 103 | assertEquals(expected3, output[3]) 104 | assertEquals(expected4, output[4]) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/test/kotlin/io/zachbr/dis4irc/bridge/pier/discord/DiscordPierTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.discord 10 | 11 | import io.zachbr.dis4irc.util.replaceTarget 12 | import org.junit.jupiter.api.Assertions.* 13 | import org.junit.jupiter.api.Test 14 | 15 | class DiscordPierTest { 16 | @Test 17 | fun testReplaceTarget() { 18 | // test no separation 19 | val noSepBase = ":potato::potato::potato:" 20 | val noSepReplaceSpaces = ":potato: :potato: :potato:" 21 | val noSepTarget = ":potato:" 22 | val noSepReplace = ":taco:" 23 | 24 | assertEquals(":taco::taco::taco:", replaceTarget(noSepBase, noSepTarget, noSepReplace)) 25 | assertEquals(":taco: :taco: :taco:", replaceTarget(noSepReplaceSpaces, noSepTarget, noSepReplace)) 26 | 27 | // test require separation 28 | val noLeadingChars = "@Z750 some text" 29 | val middleOfStr = "some text @Z750 some more" 30 | val endOfStr = "some text @Z750" 31 | val failNoSep = "some text@Z750more text" 32 | val mixedCase = "@Z750 should replace but@Z750should not" 33 | 34 | val target = "@Z750" 35 | val replacement = "12345" 36 | 37 | assertEquals(noLeadingChars.replace(target, replacement), replaceTarget(noLeadingChars, target, replacement, requireSeparation = true)) 38 | assertEquals(middleOfStr.replace(target, replacement), replaceTarget(middleOfStr, target, replacement, requireSeparation = true)) 39 | assertEquals(endOfStr.replace(target, replacement), replaceTarget(endOfStr, target, replacement, requireSeparation = true)) 40 | assertEquals(failNoSep, replaceTarget(failNoSep, target, replacement, requireSeparation = true)) 41 | assertEquals("12345 should replace but@Z750should not", replaceTarget(mixedCase, target, replacement, requireSeparation = true)) 42 | } 43 | 44 | @Test 45 | fun testReplaceRepeatingTarget() { 46 | // test emotes 47 | val singleEmote = "Have you seen :5950x:" 48 | val singleReplacement = "<:5950x:831359320453021696>" 49 | assertEquals("Have you seen <:5950x:831359320453021696>", replaceTarget(singleEmote, ":5950x:", singleReplacement)) 50 | 51 | val multipleEmoteSep = "More is better :5950x: :5950x: :5950x:" 52 | assertEquals("More is better <:5950x:831359320453021696> <:5950x:831359320453021696> <:5950x:831359320453021696>", replaceTarget(multipleEmoteSep, ":5950x:", singleReplacement, true)) 53 | 54 | val multipleEmoteNoSep = "More is better :5950x::5950x::5950x:" 55 | assertEquals("More is better <:5950x:831359320453021696><:5950x:831359320453021696><:5950x:831359320453021696>", replaceTarget(multipleEmoteNoSep, ":5950x:", singleReplacement, false)) 56 | // test requireSeparation flag, below should not be replaced 57 | assertNotEquals("More is better <:5950x:831359320453021696><:5950x:831359320453021696><:5950x:831359320453021696>", replaceTarget(multipleEmoteNoSep, ":5950x:", singleReplacement, true)) 58 | } 59 | 60 | @Test 61 | fun testUsernameValidation() { 62 | val minimumAcceptedLength = 2 63 | val maximumAcceptedLength = 32 64 | 65 | val tooShort = "1" // length = 1 66 | val tooLong = "123456789012345678901234567890123" // length = 33 67 | val okay = "SomeUsername" 68 | 69 | val edgeCaseLower = "12" // length = 2 70 | val edgeCaseHigher = "12345678901234567890123456789012" // length = 32 71 | 72 | assertEquals(edgeCaseLower, enforceSenderName(edgeCaseLower)) 73 | assertEquals(edgeCaseHigher, enforceSenderName(edgeCaseHigher)) 74 | assertEquals(okay, enforceSenderName(okay)) 75 | 76 | assertTrue(enforceSenderName(tooShort).length >= minimumAcceptedLength) 77 | assertTrue(enforceSenderName(tooLong).length <= maximumAcceptedLength) 78 | } 79 | 80 | @Test 81 | fun testUrlSuppressionWithBrackets() { 82 | val bare = "https://google.com" 83 | assertEquals("", wrapUrlsInBrackets(bare)) 84 | 85 | val leading = "$bare and some text" 86 | assertEquals(" and some text", wrapUrlsInBrackets(leading)) 87 | 88 | val trailing = "Some text and $bare" 89 | assertEquals("Some text and ", wrapUrlsInBrackets(trailing)) 90 | 91 | val ignored = "This case should be unchanged: don't touch." 92 | assertEquals(ignored, wrapUrlsInBrackets(ignored)) 93 | 94 | val withParam = "https://www.google.com/search?q=tacos+near+me&oq=tacos+near+me&sourceid=chrome&ie=UTF-8" 95 | assertEquals("", wrapUrlsInBrackets(withParam)) 96 | 97 | val withParamLeading = "$withParam seems like a good idea" 98 | assertEquals(" seems like a good idea", wrapUrlsInBrackets(withParamLeading)) 99 | 100 | val withParamTrailing = "Have you seen $withParam" 101 | assertEquals("Have you seen ", wrapUrlsInBrackets(withParamTrailing)) 102 | 103 | val withParamTrailingPunctuation = "$withParamTrailing??" 104 | assertEquals("Have you seen ??", wrapUrlsInBrackets(withParamTrailingPunctuation)) 105 | 106 | val mixedNoSpace = "What is up withhttps://google.com" // yes the discord client handles this, no I am not sure why 107 | assertEquals("What is up with", wrapUrlsInBrackets(mixedNoSpace)) 108 | 109 | val mixedNoSpaceWithPuncutation = "$mixedNoSpace?" 110 | assertEquals("What is up with?", wrapUrlsInBrackets(mixedNoSpaceWithPuncutation)) 111 | 112 | val mixedNoSpaceWithMorePuncutation = "$mixedNoSpace?!?!?!?!?" 113 | assertEquals("What is up with?!?!?!?!?", wrapUrlsInBrackets(mixedNoSpaceWithMorePuncutation)) 114 | 115 | val multiple = "Has anyone found $withParam nearby? Anyone at all? I tried using $bare but it's not working!" 116 | assertEquals("Has anyone found nearby? Anyone at all? I tried using but it's not working!", wrapUrlsInBrackets(multiple)) 117 | 118 | val alreadyWrappedIgnore = "Has anyone found nearby? Anyone at all? I tried using but it's not working!" 119 | assertEquals(alreadyWrappedIgnore, wrapUrlsInBrackets(alreadyWrappedIgnore)) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/pier/irc/IrcMessageFormatter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.irc 10 | 11 | import io.zachbr.dis4irc.bridge.BridgeConfiguration 12 | import io.zachbr.dis4irc.bridge.IrcConfiguration 13 | import io.zachbr.dis4irc.bridge.message.BridgeSender 14 | import io.zachbr.dis4irc.bridge.message.DiscordContentBase 15 | import io.zachbr.dis4irc.bridge.message.DiscordMessage 16 | import io.zachbr.dis4irc.bridge.message.PlatformMessage 17 | import io.zachbr.dis4irc.bridge.message.PlatformSender 18 | import org.kitteh.irc.client.library.util.Format 19 | import kotlin.math.abs 20 | 21 | object IrcMessageFormatter { 22 | const val ANTI_PING_CHAR = 0x200B.toChar() // zero width space 23 | private val NICK_COLORS = arrayOf("10", "06", "03", "07", "12", "11", "13", "09", "02") 24 | 25 | /** 26 | * Takes a bridge message and returns a list of lines for sending to IRC. 27 | */ 28 | fun format(platMessage: PlatformMessage, config: BridgeConfiguration): List { 29 | // Only really need to format messages from Discord (currently?) 30 | // Other messages we can just hand back as provided. 31 | if (platMessage !is DiscordMessage) { 32 | return platMessage.contents.split("\n") 33 | } 34 | 35 | val lines = mutableListOf() 36 | 37 | // discord replies 38 | if (platMessage.referencedMessage != null && config.irc.discordReplyContextLimit > 0) { 39 | lines.add(formatReplyHeader(platMessage.referencedMessage, config.irc)) 40 | } 41 | 42 | // primary content 43 | // forwards will only send as a forward, with zero primary content so we don't need to worry about that. 44 | if (platMessage.snapshots.isNotEmpty()) { 45 | lines.addAll(formatForward(platMessage, config.irc)) 46 | } else if (platMessage.contents.isNotBlank() || platMessage.attachments.isNotEmpty() || platMessage.embeds.isNotEmpty()) { 47 | lines.addAll(formatStandardMessage(platMessage, config.irc)) 48 | } 49 | 50 | return lines 51 | } 52 | 53 | /** 54 | * Creates the prefix for each message that tells you who sent it. 55 | * e.g. ": " 56 | */ 57 | fun createSenderPrefix(sender: PlatformSender, antiPing: Boolean, useColor: Boolean, withAsciiAngleBracket: Boolean = true): String { 58 | if (sender is BridgeSender) { 59 | return "" 60 | } 61 | 62 | var nameOut = sender.displayName 63 | if (antiPing) { 64 | nameOut = rebuildWithAntiPing(nameOut) 65 | } 66 | 67 | if (useColor) { 68 | val color = getColorCodeForName(sender.displayName) 69 | nameOut = Format.COLOR_CHAR + color + nameOut + Format.RESET 70 | } 71 | 72 | return if (withAsciiAngleBracket) { 73 | "<$nameOut>" 74 | } else { 75 | nameOut 76 | } 77 | } 78 | 79 | /** 80 | * Rebuilds a string with the [ANTI_PING_CHAR] character placed strategically. 81 | */ 82 | fun rebuildWithAntiPing(nick: String): String { 83 | val builder = StringBuilder() 84 | val length = nick.length 85 | for (i in nick.indices) { 86 | builder.append(nick[i]) 87 | if (i + 1 >= length || !Character.isSurrogatePair(nick[i], nick[i + 1])) { 88 | if (i % 2 == 0) { 89 | builder.append(ANTI_PING_CHAR) 90 | } 91 | } 92 | } 93 | return builder.toString() 94 | } 95 | 96 | /** 97 | * Creates the `Reply to "Sender: context"` line. 98 | */ 99 | private fun formatReplyHeader(replyTo: DiscordMessage, config: IrcConfiguration): String { 100 | val limit = config.discordReplyContextLimit 101 | var context = replyTo.contents.replace("\n", " ") 102 | if (limit > 0 && context.length > limit) { 103 | context = context.substring(0, limit - 1) + "..." 104 | } 105 | val refSender = createSenderPrefix(replyTo.sender, config.antiPing, config.useNickNameColor, withAsciiAngleBracket = false) 106 | return "Reply to \"$refSender: $context\"" 107 | } 108 | 109 | /** 110 | * Formats a forwarded message. 111 | */ 112 | private fun formatForward(msg: DiscordMessage, config: IrcConfiguration): List { 113 | val lines = mutableListOf() 114 | val forwarderName = createSenderPrefix(msg.sender, config.antiPing, config.useNickNameColor, withAsciiAngleBracket = false) 115 | lines.add("$forwarderName forwarded a message:") 116 | 117 | // forwards currently only contain one message 118 | val snapshot = msg.snapshots.first() 119 | val formattedContent = formatContentBlock(snapshot, config) 120 | lines.addAll(formattedContent.trim().split("\n")) 121 | 122 | return lines 123 | } 124 | 125 | /** 126 | * Formats a standard message, handling prefixes. 127 | */ 128 | private fun formatStandardMessage(msg: DiscordMessage, config: IrcConfiguration): List { 129 | val content = formatContentBlock(msg, config) 130 | 131 | return content.trim().split("\n").map { line -> 132 | val noPrefixPattern = config.noPrefixRegex 133 | if (noPrefixPattern == null || !noPrefixPattern.matcher(line).find()) { 134 | val messagePrefix = createSenderPrefix(msg.sender, config.antiPing, config.useNickNameColor) 135 | "$messagePrefix $line" 136 | } else { 137 | line 138 | } 139 | } 140 | } 141 | 142 | /** 143 | * Helper to handle the same types of formatting across "types" and reduce duplication. 144 | */ 145 | private fun formatContentBlock(block: DiscordContentBase, config: IrcConfiguration): String { 146 | var content = block.contents 147 | val attachments = block.attachments.toMutableList() // Create a mutable copy 148 | 149 | if (config.sendDiscordEmbeds) { 150 | block.embeds.forEach { embed -> 151 | embed.string?.takeIf { it.isNotBlank() }?.let { 152 | content += " $it" 153 | } 154 | embed.imageUrl?.takeIf { it.isNotBlank() }?.let { 155 | attachments.add(it) 156 | } 157 | } 158 | } 159 | 160 | // Append all attachments 161 | if (attachments.isNotEmpty()) { 162 | content += " " + attachments.joinToString(" ") 163 | } 164 | 165 | return content 166 | } 167 | 168 | 169 | /** 170 | * Determines the color code to use for the provided nickname. 171 | * https://github.com/korobi/Web/blob/master/src/Korobi/WebBundle/IRC/Parser/NickColours.php 172 | */ 173 | private fun getColorCodeForName(nick: String): String { 174 | var index = 0 175 | nick.toCharArray().forEach { index += it.code.toByte() } 176 | return NICK_COLORS[abs(index) % NICK_COLORS.size] 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /docs/Getting-Started.md: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | Dis4IRC requires **Java 11** or newer to run, so make sure you have that 5 | installed before proceeding. 6 | 7 | Startup 8 | ------- 9 | 10 | To get started with Dis4IRC, start by downloading or building a jar. Next, run 11 | Dis4IRC from the commandline. Dis4IRC will generate a config and then complain 12 | that it's missing some important information and exit. 13 | 14 | It should look like this: 15 | ```bash 16 | $ java -jar Dis4IRC-1.7.0.jar 17 | [18:23:19] [init] [INFO] - Dis4IRC v1.7.0-bd593a0 18 | [18:23:19] [init] [INFO] - Source available at https://github.com/zachbr/Dis4IRC 19 | [18:23:19] [init] [INFO] - Available under the MIT License 20 | [18:23:19] [init] [INFO] - Loading config from: config.hocon 21 | [18:23:19] [init] [INFO] - Log level set to INFO 22 | [18:23:19] [init] [INFO] - Starting bridge: default 23 | [18:23:19] [init] [ERROR] - Discord API key left empty for bridge: default 24 | Exception in thread "main" java.lang.IllegalArgumentException: Cannot start default bridge with above configuration errors! 25 | at io.zachbr.dis4irc.config.ConfigurationUtilsKt.toBridgeConfiguration(ConfigurationUtils.kt:238) 26 | at io.zachbr.dis4irc.Dis4IRC.startBridge(Dis4IRC.kt:132) 27 | at io.zachbr.dis4irc.Dis4IRC.(Dis4IRC.kt:103) 28 | at io.zachbr.dis4irc.Dis4IRCKt.main(Dis4IRC.kt:35) 29 | ``` 30 | 31 | As you can see, there was an error reading the Discord API key from the config file, 32 | that's fine as we haven't added one yet. Let's do that. You'll find the config file 33 | in your current working directory with the name `config.hocon` 34 | 35 | Let's open it up and configure Dis4IRC. 36 | 37 | Discord API 38 | ----------- 39 | 40 | First, you need to setup a Discord API application and get a bot token, for more information 41 | on how to do that, see the other docs page on that: [Registering a Discord API Application](https://github.com/zachbr/Dis4IRC/blob/master/docs/Registering-A-Discord-Application.md) 42 | 43 | Once you have your Discord API application setup, copy its bot token into your config 44 | file. It goes with the `discord-api-key` field. It should look like this when you're 45 | done. 46 | 47 | ```hocon 48 | # Your discord API key you registered your bot with 49 | discord-api-key="your-key-exactly-here" 50 | ``` 51 | 52 | IRC Server 53 | ---------- 54 | 55 | Now that we've finished with that, we need to tell Dis4IRC which IRC server you want to 56 | bridge to. The exact values here will depend on your IRC server but should be similar to 57 | what you would put into a normal IRC client. 58 | 59 | For the purposes of this introduction, we only care about getting you connected to a server, 60 | so we'll skip some of the customization options and just focus on the server setup. 61 | 62 | Start by changing the server field to your IRC server's hostname or IP address. 63 | Next, configure the port you want the bridge to connect on, `6667` (no-ssl) and `6697` (ssl) 64 | are the typical values for most IRC servers 65 | 66 | If your IRC server does not support SSL or you're connecting on an unencrypted port, you 67 | should change `use-ssl` to `false` to tell te bot not to use SSL when connecting. 68 | 69 | Finally, set the `nickname` field to whatever you want the bot to appear as when speaking. 70 | 71 | Channel Mappings 72 | ---------------- 73 | 74 | Now that we have connections to both Discord and IRC, we need to tell the bot what channels it should 75 | be bridging. It is strongly recommended you use Discord channel *IDs* rather than channel names for this. 76 | 77 | To get a Discord channel's ID, right click on the channel name in your client and click `Copy ID` at the 78 | bottom of the context menu. If you don't see it there, go into your Discord client settings, in the "Appearance" 79 | settings, and scroll to the bottom under "Advanced", enable "Developer Mode". You should now be able to copy 80 | channel IDs. 81 | 82 | Next, paste that Discord channel ID on the right side of the `channel-mappings` sub-entry. It will be used as a 83 | key, and the IRC channel name will be used as a value. It is important that when you add the IRC channel name 84 | that you **include** the `#` preceding the channel name. 85 | 86 | It should look something like this: 87 | ```hocon 88 | # Mappings are the channel <-> channel bridging configurations 89 | channel-mappings { 90 | "421260739970334720"="#channel-to-bridge" 91 | } 92 | ``` 93 | 94 | Configuring WebHooks 95 | --------------------- 96 | 97 | You can optionally enable channel-based webhooks for the Discord side of the bridge. 98 | This will allow the bot to blend more seamlessly into your channels and allow avatars to show up for users with 99 | matching names on both sides of the bridge. 100 | 101 | We need to start out by setting up a new webhook in Discord for each channel you want to use with webhooks. 102 | To do so, hover your mouse on the channel name and click the little gear icon that appears next to it, as if you 103 | were going to edit the channel. Click the "Webhooks" tab in the channel settings, then click the purple "Create Webhook" 104 | button. Name it however you can best remember what its for, make sure the channel matches the channel you want to 105 | bridge, then copy the webhook URL at the bottom. 106 | 107 | Now we need to add that webhook URL to the config. Find the `discord-webhooks` section, and paste the URL into the 108 | right side of the field. Now, paste your discord channel ID from the `channel-mappings` section into the left side. 109 | 110 | **It is important that the channel ID in the `discord-webhooks` section matches the channel ID in the `channel-mappings` 111 | section exactly.** If it does not, it will not register properly. The log will also warn you if there's any issues using 112 | your webhook. 113 | 114 | If you **are** using webhooks, your config should look like this: 115 | ```hocon 116 | # Match a channel id to a webhook URL to enable webhooks for that channel 117 | discord-webhooks { 118 | "421260739970334720"="https://discordapp.com/api/webhooks/421260739970334720/the-rest-of-your-url" 119 | } 120 | ``` 121 | 122 | **If you don't want to use any webhooks** you must delete the default entry in that section or the config validation 123 | will complain. 124 | 125 | If you're **not** using webhooks, your config should look like this: 126 | ```hocon 127 | # Match a channel id to a webhook URL to enable webhooks for that channel 128 | discord-webhooks { 129 | } 130 | ``` 131 | 132 | Starting It Up 133 | -------------- 134 | 135 | At this point, you should be ready to start the bridge again. Watch the console output closely, if there's any 136 | mistakes in the config it should alert you to them before exiting again. If you get one, don't worry. Just read 137 | the info and correct your config, then start it up again. 138 | 139 | The bridge should have connected to the IRC server and joined the mapped channels you specified but it won't be 140 | in your Discord server yet, we'll have to tell it to join. That's easy though. 141 | 142 | In your console log you should see a line with an invite link, copy and paste that link into a browser and then 143 | give the bot permission to join your Guild. It should look something like this:∂ 144 | 145 | ``` 146 | [19:48:51] [default] [INFO] - Discord Bot Invite URL: https://discordapp.com/oauth2/authorize?scope=bot&client_id=yourbotidhere 147 | ``` 148 | 149 | And then you should be all good to go! If you aren't using webhooks you will need to make sure the bot has the 150 | permissions and/or roles required to speak in your channels and guild. If you are using webhooks, you should be 151 | all set. 152 | 153 | [Docs Index](https://github.com/zachbr/Dis4IRC/tree/master/docs) 154 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project Specific Files 2 | 3 | # Bridge configs 4 | *.hocon 5 | bridge-data.dat 6 | 7 | # End project specific files 8 | 9 | # Created by https://www.gitignore.io/api/vim,code,emacs,linux,macos,windows,eclipse,netbeans,sublimetext,intellij+all,gradle 10 | 11 | ### Code ### 12 | # Visual Studio Code - https://code.visualstudio.com/ 13 | .settings/ 14 | .vscode/ 15 | tsconfig.json 16 | jsconfig.json 17 | 18 | ### Eclipse ### 19 | 20 | .metadata 21 | bin/ 22 | tmp/ 23 | *.tmp 24 | *.bak 25 | *.swp 26 | *~.nib 27 | local.properties 28 | .loadpath 29 | .recommenders 30 | 31 | # External tool builders 32 | .externalToolBuilders/ 33 | 34 | # Locally stored "Eclipse launch configurations" 35 | *.launch 36 | 37 | # PyDev specific (Python IDE for Eclipse) 38 | *.pydevproject 39 | 40 | # CDT-specific (C/C++ Development Tooling) 41 | .cproject 42 | 43 | # CDT- autotools 44 | .autotools 45 | 46 | # Java annotation processor (APT) 47 | .factorypath 48 | 49 | # PDT-specific (PHP Development Tools) 50 | .buildpath 51 | 52 | # sbteclipse plugin 53 | .target 54 | 55 | # Tern plugin 56 | .tern-project 57 | 58 | # TeXlipse plugin 59 | .texlipse 60 | 61 | # STS (Spring Tool Suite) 62 | .springBeans 63 | 64 | # Code Recommenders 65 | .recommenders/ 66 | 67 | # Annotation Processing 68 | .apt_generated/ 69 | 70 | # Scala IDE specific (Scala & Java development for Eclipse) 71 | .cache-main 72 | .scala_dependencies 73 | .worksheet 74 | 75 | ### Eclipse Patch ### 76 | # Eclipse Core 77 | .project 78 | 79 | # JDT-specific (Eclipse Java Development Tools) 80 | .classpath 81 | 82 | # Annotation Processing 83 | .apt_generated 84 | 85 | .sts4-cache/ 86 | 87 | ### Emacs ### 88 | # -*- mode: gitignore; -*- 89 | *~ 90 | \#*\# 91 | /.emacs.desktop 92 | /.emacs.desktop.lock 93 | *.elc 94 | auto-save-list 95 | tramp 96 | .\#* 97 | 98 | # Org-mode 99 | .org-id-locations 100 | *_archive 101 | 102 | # flymake-mode 103 | *_flymake.* 104 | 105 | # eshell files 106 | /eshell/history 107 | /eshell/lastdir 108 | 109 | # elpa packages 110 | /elpa/ 111 | 112 | # reftex files 113 | *.rel 114 | 115 | # AUCTeX auto folder 116 | /auto/ 117 | 118 | # cask packages 119 | .cask/ 120 | dist/ 121 | 122 | # Flycheck 123 | flycheck_*.el 124 | 125 | # server auth directory 126 | /server/ 127 | 128 | # projectiles files 129 | .projectile 130 | 131 | # directory configuration 132 | .dir-locals.el 133 | 134 | ### Intellij+all ### 135 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 136 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 137 | 138 | # User-specific stuff 139 | .idea/**/workspace.xml 140 | .idea/**/tasks.xml 141 | .idea/**/usage.statistics.xml 142 | .idea/**/dictionaries 143 | .idea/**/shelf 144 | 145 | # Generated files 146 | .idea/**/contentModel.xml 147 | 148 | # Sensitive or high-churn files 149 | .idea/**/dataSources/ 150 | .idea/**/dataSources.ids 151 | .idea/**/dataSources.local.xml 152 | .idea/**/sqlDataSources.xml 153 | .idea/**/dynamic.xml 154 | .idea/**/uiDesigner.xml 155 | .idea/**/dbnavigator.xml 156 | 157 | # Gradle 158 | .idea/**/gradle.xml 159 | .idea/**/libraries 160 | 161 | # Gradle and Maven with auto-import 162 | # When using Gradle or Maven with auto-import, you should exclude module files, 163 | # since they will be recreated, and may cause churn. Uncomment if using 164 | # auto-import. 165 | # .idea/modules.xml 166 | # .idea/*.iml 167 | # .idea/modules 168 | 169 | # CMake 170 | cmake-build-*/ 171 | 172 | # Mongo Explorer plugin 173 | .idea/**/mongoSettings.xml 174 | 175 | # File-based project format 176 | *.iws 177 | 178 | # IntelliJ 179 | out/ 180 | 181 | # mpeltonen/sbt-idea plugin 182 | .idea_modules/ 183 | 184 | # JIRA plugin 185 | atlassian-ide-plugin.xml 186 | 187 | # Cursive Clojure plugin 188 | .idea/replstate.xml 189 | 190 | # Crashlytics plugin (for Android Studio and IntelliJ) 191 | com_crashlytics_export_strings.xml 192 | crashlytics.properties 193 | crashlytics-build.properties 194 | fabric.properties 195 | 196 | # Editor-based Rest Client 197 | .idea/httpRequests 198 | 199 | # Android studio 3.1+ serialized cache file 200 | .idea/caches/build_file_checksums.ser 201 | 202 | ### Intellij+all Patch ### 203 | # Ignores the whole .idea folder and all .iml files 204 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 205 | 206 | .idea/ 207 | 208 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 209 | 210 | *.iml 211 | modules.xml 212 | .idea/misc.xml 213 | *.ipr 214 | 215 | ### Linux ### 216 | 217 | # temporary files which can be created if a process still has a handle open of a deleted file 218 | .fuse_hidden* 219 | 220 | # KDE directory preferences 221 | .directory 222 | 223 | # Linux trash folder which might appear on any partition or disk 224 | .Trash-* 225 | 226 | # .nfs files are created when an open file is removed but is still being accessed 227 | .nfs* 228 | 229 | ### macOS ### 230 | # General 231 | .DS_Store 232 | .AppleDouble 233 | .LSOverride 234 | 235 | # Icon must end with two \r 236 | Icon 237 | 238 | # Thumbnails 239 | ._* 240 | 241 | # Files that might appear in the root of a volume 242 | .DocumentRevisions-V100 243 | .fseventsd 244 | .Spotlight-V100 245 | .TemporaryItems 246 | .Trashes 247 | .VolumeIcon.icns 248 | .com.apple.timemachine.donotpresent 249 | 250 | # Directories potentially created on remote AFP share 251 | .AppleDB 252 | .AppleDesktop 253 | Network Trash Folder 254 | Temporary Items 255 | .apdisk 256 | 257 | ### NetBeans ### 258 | nbproject/private/ 259 | build/ 260 | nbbuild/ 261 | nbdist/ 262 | .nb-gradle/ 263 | 264 | ### SublimeText ### 265 | # Cache files for Sublime Text 266 | *.tmlanguage.cache 267 | *.tmPreferences.cache 268 | *.stTheme.cache 269 | 270 | # Workspace files are user-specific 271 | *.sublime-workspace 272 | 273 | # Project files should be checked into the repository, unless a significant 274 | # proportion of contributors will probably not be using Sublime Text 275 | # *.sublime-project 276 | 277 | # SFTP configuration file 278 | sftp-config.json 279 | 280 | # Package control specific files 281 | Package Control.last-run 282 | Package Control.ca-list 283 | Package Control.ca-bundle 284 | Package Control.system-ca-bundle 285 | Package Control.cache/ 286 | Package Control.ca-certs/ 287 | Package Control.merged-ca-bundle 288 | Package Control.user-ca-bundle 289 | oscrypto-ca-bundle.crt 290 | bh_unicode_properties.cache 291 | 292 | # Sublime-github package stores a github token in this file 293 | # https://packagecontrol.io/packages/sublime-github 294 | GitHub.sublime-settings 295 | 296 | ### Vim ### 297 | # Swap 298 | [._]*.s[a-v][a-z] 299 | [._]*.sw[a-p] 300 | [._]s[a-rt-v][a-z] 301 | [._]ss[a-gi-z] 302 | [._]sw[a-p] 303 | 304 | # Session 305 | Session.vim 306 | 307 | # Temporary 308 | .netrwhist 309 | # Auto-generated tag files 310 | tags 311 | # Persistent undo 312 | [._]*.un~ 313 | 314 | ### Windows ### 315 | # Windows thumbnail cache files 316 | Thumbs.db 317 | ehthumbs.db 318 | ehthumbs_vista.db 319 | 320 | # Dump file 321 | *.stackdump 322 | 323 | # Folder config file 324 | [Dd]esktop.ini 325 | 326 | # Recycle Bin used on file shares 327 | $RECYCLE.BIN/ 328 | 329 | # Windows Installer files 330 | *.cab 331 | *.msi 332 | *.msix 333 | *.msm 334 | *.msp 335 | 336 | # Windows shortcuts 337 | *.lnk 338 | 339 | ### Gradle ### 340 | .gradle 341 | /build/ 342 | 343 | # Ignore Gradle GUI config 344 | gradle-app.setting 345 | 346 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 347 | !gradle-wrapper.jar 348 | 349 | # Cache of project 350 | .gradletasknamecache 351 | 352 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 353 | # gradle/wrapper/gradle-wrapper.properties 354 | 355 | 356 | # End of https://www.gitignore.io/api/vim,code,emacs,linux,macos,windows,eclipse,netbeans,sublimetext,intellij+all,gradle 357 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/pier/discord/Extensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.discord 10 | 11 | import io.zachbr.dis4irc.bridge.message.Embed 12 | import io.zachbr.dis4irc.bridge.message.DiscordMessageSnapshot 13 | import io.zachbr.dis4irc.bridge.message.DiscordSender 14 | import io.zachbr.dis4irc.bridge.message.DiscordSource 15 | import net.dv8tion.jda.api.entities.Message 16 | import net.dv8tion.jda.api.entities.MessageEmbed 17 | import net.dv8tion.jda.api.entities.channel.ChannelType 18 | import net.dv8tion.jda.api.entities.channel.concrete.TextChannel 19 | import net.dv8tion.jda.api.entities.sticker.Sticker 20 | import org.slf4j.Logger 21 | import java.net.URLEncoder 22 | import java.time.Instant 23 | 24 | const val DISCORD_STICKER_MEDIA_URL = "https://media.discordapp.net/stickers/%%ID%%.%%FILETYPE%%?size=256" 25 | const val LOTTIE_PLAYER_BASE_URL = "https://lottie.zachbr.io" 26 | const val CDN_DISCORDAPP_STICKERS_URL_LENGTH = "https://cdn.discordapp.com/stickers/".length 27 | 28 | fun TextChannel.asPlatformSource(): DiscordSource = DiscordSource(this.name, this.idLong) 29 | 30 | fun Message.toPlatformMessage(logger: Logger, receiveInstant: Instant = Instant.now(), shouldResolveReference: Boolean = true) : io.zachbr.dis4irc.bridge.message.DiscordMessage { 31 | // We need to get the guild member in order to grab their display name 32 | val guildMember = this.guild.getMember(this.author) 33 | if (guildMember == null && !this.author.isBot) { 34 | logger.debug("Cannot get Discord guild member from user information: {}!", this.author) 35 | } 36 | 37 | // handle attachments 38 | val attachmentUrls = parseAttachments(this.attachments) 39 | 40 | // handle custom emojis 41 | var messageText = this.contentDisplay 42 | for (customEmoji in this.mentions.customEmojis) { 43 | attachmentUrls.add(customEmoji.imageUrl) 44 | } 45 | 46 | // handle stickers 47 | // todo refactor 48 | for (sticker in this.stickers) { 49 | if (messageText.isNotEmpty()) { 50 | messageText += " " 51 | } 52 | messageText += sticker.name 53 | 54 | val url = when (sticker.formatType) { 55 | Sticker.StickerFormat.LOTTIE -> makeLottieViewerUrl(sticker.iconUrl) 56 | Sticker.StickerFormat.APNG, Sticker.StickerFormat.PNG -> DISCORD_STICKER_MEDIA_URL.replace("%%ID%%", sticker.id).replace("%%FILETYPE%%", "png") 57 | Sticker.StickerFormat.UNKNOWN -> null 58 | else -> { 59 | logger.debug("Unhandled sticker format type: {}", sticker.formatType) 60 | null 61 | } 62 | } 63 | 64 | if (url != null) { 65 | attachmentUrls.add(url) 66 | } else { 67 | messageText += " " 68 | } 69 | } 70 | 71 | // embeds - this only works with channel embeds, not for listening to slash commands and interaction hooks 72 | val parsedEmbeds = parseEmbeds(embeds) 73 | 74 | // discord replies 75 | var platformMsgRef: io.zachbr.dis4irc.bridge.message.DiscordMessage? = null 76 | val discordMsgRef = this.referencedMessage 77 | if (shouldResolveReference && discordMsgRef != null) { 78 | platformMsgRef = discordMsgRef.toPlatformMessage(logger, receiveInstant, shouldResolveReference = false) // do not endlessly resolve references 79 | } 80 | 81 | // forwards 82 | val snapshots = ArrayList() 83 | for (snapshot in this.messageSnapshots) { 84 | val snapshotAttachmentUrls = parseAttachments(snapshot.attachments) 85 | for (customEmoji in this.mentions.customEmojis) { 86 | snapshotAttachmentUrls.add(customEmoji.imageUrl) 87 | } 88 | 89 | var snapshotText = snapshot.contentRaw 90 | val snapshotEmbeds = parseEmbeds(snapshot.embeds) 91 | // todo refactor 92 | for (sticker in snapshot.stickers) { 93 | if (snapshotText.isNotEmpty()) { 94 | snapshotText += " " 95 | } 96 | snapshotText += sticker.name 97 | 98 | val url = when (sticker.formatType) { 99 | Sticker.StickerFormat.LOTTIE -> makeLottieViewerUrl(sticker.iconUrl) 100 | Sticker.StickerFormat.APNG, Sticker.StickerFormat.PNG -> DISCORD_STICKER_MEDIA_URL.replace("%%ID%%", sticker.id).replace("%%FILETYPE%%", "png") 101 | Sticker.StickerFormat.UNKNOWN -> null 102 | else -> { 103 | logger.debug("Unhandled sticker format type: {}", sticker.formatType) 104 | null 105 | } 106 | } 107 | 108 | if (url != null) { 109 | snapshotAttachmentUrls.add(url) 110 | } else { 111 | snapshotText += " " 112 | } 113 | } 114 | 115 | snapshots.add(DiscordMessageSnapshot( 116 | snapshotText, 117 | snapshotAttachmentUrls, 118 | snapshotEmbeds 119 | )) 120 | } 121 | 122 | val displayName = guildMember?.effectiveName ?: this.author.name // webhooks won't have an effective name 123 | val sender = DiscordSender(displayName, this.author.idLong) 124 | if (this.channelType != ChannelType.TEXT) { 125 | logger.debug("Encountered unsupported channel type: {}", channelType) // TODO: probably a nicer way to handle this (FIXME: Support other types?) 126 | } 127 | val channel = this.channel.asTextChannel().asPlatformSource() 128 | return io.zachbr.dis4irc.bridge.message.DiscordMessage( 129 | messageText, 130 | sender, 131 | channel, 132 | receiveInstant, 133 | attachmentUrls, 134 | parsedEmbeds, 135 | timeCreated, 136 | snapshots, 137 | platformMsgRef 138 | ) 139 | } 140 | 141 | fun makeLottieViewerUrl(discordCdnUrl: String): String? { 142 | if (discordCdnUrl.length <= CDN_DISCORDAPP_STICKERS_URL_LENGTH) { 143 | return null 144 | } 145 | 146 | val resourcePath = discordCdnUrl.substring(CDN_DISCORDAPP_STICKERS_URL_LENGTH) 147 | val proxyString = "/stickers/$resourcePath" 148 | val encodedString = URLEncoder.encode(proxyString, "UTF-8") // has to use look up for Java 8 compat 149 | 150 | return "$LOTTIE_PLAYER_BASE_URL?p=$encodedString" 151 | } 152 | 153 | fun parseEmbeds(embeds: List): List { 154 | val parsed = ArrayList() 155 | for (embed in embeds) { 156 | val strBuilder = StringBuilder() 157 | val imageUrl = embed.image?.url 158 | 159 | if (embed.title != null) { 160 | strBuilder.append(embed.title) 161 | } 162 | 163 | if (embed.title != null && embed.description != null) { 164 | strBuilder.append(": ") 165 | } 166 | 167 | if (embed.description != null) { 168 | strBuilder.append(embed.description) 169 | } 170 | 171 | if (embed.title != null || embed.description != null) { 172 | strBuilder.append('\n') 173 | } 174 | 175 | val fieldsCount = embed.fields.count() 176 | for ((fi, field) in embed.fields.withIndex()) { 177 | if (field.name != null) { 178 | strBuilder.append(field.name) 179 | } 180 | 181 | if (field.name != null && field.value != null) { 182 | strBuilder.append(": ") 183 | } 184 | 185 | if (field.value != null) { 186 | strBuilder.append(field.value) 187 | } 188 | 189 | if (fi < fieldsCount - 1) { 190 | strBuilder.append('\n') 191 | } 192 | } 193 | 194 | parsed.add(Embed(strBuilder.toString(), imageUrl)) 195 | } 196 | 197 | return parsed 198 | } 199 | 200 | fun parseAttachments(attachments: List) : MutableList { 201 | val attachmentUrls = ArrayList() 202 | for (attachment in attachments) { 203 | var url = attachment.url 204 | if (attachment.isImage) { 205 | url = attachment.proxyUrl 206 | } 207 | 208 | attachmentUrls.add(url) 209 | } 210 | return attachmentUrls 211 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | User-relevant changes to the software, see the full commit log for all changes. 3 | [Downloads](https://github.com/zachbr/Dis4IRC/releases) 4 | 5 | ## 1.7.0 - `bd593a0` 6 | [Commits since 1.6.5](https://github.com/zachbr/Dis4IRC/compare/v1.6.5...v1.7.0) 7 | * Internal refactors to ensure we're treating each platform's messages correctly 8 | * Adds a warning message at startup for those who are not on Java 21 or higher. 9 | * As the Java ecosystem continues to evolve, it's likely we'll be forced to bump 10 | the required version at some point. This gives some advance warning. 11 | * For more information, see [the commit notes](https://github.com/zachbr/Dis4IRC/commit/f2c73f068c01fa138148dbf41a50c73b039a0eb3). 12 | * Adds an option to suppress previews for URLs bridged to Discord. 13 | * Improvements to the pinned messages command. 14 | * Updates to the underlying Discord library and other libraries. 15 | 16 | ## 1.6.5 - `96b6acc` 17 | [Commits since 1.6.4](https://github.com/zachbr/Dis4IRC/compare/v1.6.4...v1.6.5) 18 | * Adds basic support for Discord Message Forwards. 19 | * Updates to the underlying Discord library and other libraries. 20 | * Container images are now published at `ghcr.io/zachbr/dis4irc` 21 | * A tagged version for the specific release version. 22 | * A `latest` tag updated to the current release version. 23 | * An `edge` tag that tracks development on the primary branch. 24 | 25 | ## 1.6.4 - `df1e4ff` 26 | [Commits since 1.6.3](https://github.com/zachbr/Dis4IRC/compare/v1.6.3...v1.6.4) 27 | * Fixes an issue with mentions being bridged incorrectly. Thank you sqyyy-jar! 28 | * A fix for compiling the project when git is not available or the repo is not included. 29 | * An update to the underlying Discord library. 30 | 31 | ## 1.6.3 - `28d68cb` 32 | [Commits since 1.6.2](https://github.com/zachbr/Dis4IRC/compare/v1.6.2...v1.6.3) 33 | * Updates to the Discord library and the other underlying libraries. 34 | * Improved avatar lookup for IRC users when messages are bridged to Discord. 35 | 36 | ## 1.6.2 - `65965e8` 37 | [Commits since 1.6.1](https://github.com/zachbr/Dis4IRC/compare/v1.6.1...v1.6.2) 38 | * Updates to the Discord library and the other underlying libraries. 39 | * Adds a configuration option for whether to bridge channel embeds from Discord. 40 | 41 | ## 1.6.1 - `3cfdf07` 42 | [Commits since 1.6.0](https://github.com/zachbr/Dis4IRC/compare/v1.6.0...v1.6.1) 43 | * Updates to the Discord library to better handle the username changes. 44 | * Adds support for bridging *channel* embeds. 45 | * Updates the IRC message splitter to better handle certain unicode. 46 | 47 | ## 1.6.0 - `c71aaca` 48 | [Commits since 1.5.0](https://github.com/zachbr/Dis4IRC/compare/v1.5.0...v1.6.0) 49 | * Better handling of mentions, emoji, and other string replacements. 50 | * Updates to the Discord library to better support the latest Discord changes. 51 | * The logging system has been switched to Logback from Log4j. 52 | 53 | ## 1.5.0 - `630d6ae` 54 | [Commits since 1.4.2](https://github.com/zachbr/Dis4IRC/compare/v1.4.2...v1.5.0) 55 | * Update various libraries, including the one used for Discord. 56 | * Add the ability to set the activity and online status of the bot. 57 | * For more information, see [the commit notes](https://github.com/zachbr/Dis4IRC/commit/7530afc662dd9ab671dc35b4db1d035ef11193de). 58 | * Add the ability to disable nickname coloring in IRC. 59 | * Use color in more places when enabled in IRC. 60 | * Fixes a few miscellaneous bugs. 61 | 62 | ## 1.4.2 - `9cbba83` 63 | [Commits since 1.4.1](https://github.com/zachbr/Dis4IRC/compare/v1.4.1...v1.4.2) 64 | * Update Log4J library (again) to 2.17.0 out of an abundance of caution. 65 | * There are currently no known exploits affecting Dis4IRC's default logging configuration in 1.4.0 or 1.4.1. However, 66 | out of an abundance of caution this update further expands upon protections added in the previous release. 67 | 68 | ## 1.4.1 - `328d4e7` 69 | [Commits since 1.4.0](https://github.com/zachbr/Dis4IRC/compare/v1.4.0...v1.4.1) 70 | * Update Log4J library to 2.16.0 out of an abundance of caution. 71 | * There are currently no known exploits affecting Dis4IRC's default logging configuration in 1.4.0. However, out of an 72 | abundance of caution this update further expands upon protections added in the previous release. 73 | 74 | ## 1.4.0 - `d3a4512` 75 | [Commits since 1.3.0](https://github.com/zachbr/Dis4IRC/compare/v1.3.0...v1.4.0) 76 | * Update Log4J logging library to 2.15.0 to resolve CVE-2021-44228. 77 | * Discord library updates to better support platform API changes. 78 | * Support for bridging Discord stickers 79 | * Dis4IRC now leaves mention triggers from Discord in bridged messages. This can offer additional context to IRC users. 80 | * Better support for newer versions of Java. 81 | 82 | ## 1.3.0 - `0ac94b1` 83 | [Commits since 1.2.0](https://github.com/zachbr/Dis4IRC/compare/v1.2.0...v1.3.0) 84 | * IRC library updates - Should fix issues with reconnecting to IRC servers. 85 | * Discord replies will now bridge with context. The maximum length is controlled by a new settings option `discord-reply-context-limit`. Set it to 0 to disable it. 86 | * Messages from IRC will now strip the Anti-Ping characters before being posted to Discord. This can make it easier to copy paste names when pinging Discord users. 87 | * Bridge data is now saved atomically, making it more resilient to system issues. 88 | * Discord roles and channels can now be mentioned from IRC. 89 | * Updates to JDA to better support new discord features since 1.2.0. 90 | 91 | ## 1.2.0 - `7766b34` 92 | [Commits since 1.1.0](https://github.com/zachbr/Dis4IRC/compare/v1.1.0...v1.2.0) 93 | * Discord library updates - **IMPORTANT**: You will be required to update to this version before November 7th 2020. That 94 | is when Discord will remove its old discordapp.com API endpoint. 95 | 96 | As part of this update, Please note that Dis4IRC 97 | **REQUIRES** the `GUILD_MEMBERS` privileged intent in order to properly cache members at runtime. 98 | **For instructions on adding the needed intent in the Discord Developer Console, please click [here](https://github.com/zachbr/Dis4IRC/blob/master/docs/Registering-A-Discord-Application.md#gateway-intents).** 99 | * The webhook system now takes advantage of Discord API's _Allowed Mentions_ system, making it harder to abuse mentions. 100 | * IRC users can now request all pinned messages from the bridged Discord channel using the `pinned` command. 101 | * All bridge statistics are now persisted to disk, allowing you to restart the bridge without losing message statistics. 102 | * Commands like `pinned` and `stats` can now be entirely disabled in the config file. 103 | * The expiration time on pastes submitted by the paste service can now be configured. 104 | * The IRC library was updated, fixing a reconnect issue with unstable connections. 105 | 106 | ## 1.1.0 - `5a3a45e` 107 | [Commits since 1.0.2](https://github.com/zachbr/Dis4IRC/compare/v1.0.2...v1.1.0) 108 | * The build date has been removed from the jar to support [reproducible builds](https://en.wikipedia.org/wiki/Reproducible_builds). 109 | * The stats command will now show a percentage for each side of the bridge. 110 | * The bridge will now exit in error if it cannot connect at startup. 111 | * No-prefix messages can now optionally send an additional message with the triggering user's name. 112 | * Better error messages for startup and connection failures. 113 | * Fixes for mixed case IRC channel mappings. 114 | * Fixes for startup IRC commands and additional logging. 115 | * Fix for IRC nickname coloring issue. 116 | * Add user quit and user kick relaying support. 117 | * Updates to the underlying IRC and Discord libraries. 118 | 119 | ## 1.0.2 - 2019-01-02T23:30:28Z - `d4c6204` 120 | [Commits since 1.0.1](https://github.com/zachbr/Dis4IRC/compare/v1.0.1...v1.0.2) 121 | * Hotfix - Do not re-save config at startup as a workaround for [GH-19](https://github.com/zachbr/Dis4IRC/issues/19). 122 | 123 | ## 1.0.1 - 2018-12-31T05:16:47Z - `54f47af` 124 | [Commits since 1.0.0](https://github.com/zachbr/Dis4IRC/compare/v1.0.0...v1.0.1) 125 | * Better handling of whitespace-only messages for Discord. 126 | * Statistics command now has a 60s rate limit on use. 127 | * Respects guild-specific bot display name with webhooks. 128 | * Markdown parser ignores messages shorter than 3 characters. 129 | 130 | ## 1.0.0 - 2018-11-22T01:43:07Z - `068f468` 131 | * Initial Release. 132 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/Dis4IRC.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc 10 | 11 | import ch.qos.logback.classic.Level 12 | import ch.qos.logback.classic.LoggerContext 13 | import io.zachbr.dis4irc.bridge.Bridge 14 | import io.zachbr.dis4irc.config.Configuration 15 | import io.zachbr.dis4irc.config.makeDefaultNode 16 | import io.zachbr.dis4irc.config.toBridgeConfiguration 17 | import io.zachbr.dis4irc.util.AtomicFileUtil 18 | import io.zachbr.dis4irc.util.Versioning 19 | import org.json.JSONObject 20 | import org.slf4j.Logger 21 | import org.slf4j.LoggerFactory 22 | import org.spongepowered.configurate.CommentedConfigurationNode 23 | import java.io.InputStreamReader 24 | import java.io.OutputStreamWriter 25 | import java.nio.file.Files 26 | import java.nio.file.Path 27 | import java.nio.file.Paths 28 | import java.nio.file.StandardOpenOption 29 | import java.util.* 30 | import java.util.zip.GZIPInputStream 31 | import java.util.zip.GZIPOutputStream 32 | import kotlin.system.exitProcess 33 | 34 | fun main(args: Array) { 35 | Dis4IRC(args) 36 | } 37 | val logger: Logger = LoggerFactory.getLogger("init") ?: throw IllegalStateException("Unable to init logger!") 38 | val SAVED_DATA_PATH: Path = Paths.get("./bridge-data.dat") 39 | 40 | class Dis4IRC(args: Array) { 41 | private var configPath: String = "config.hocon" 42 | private val bridgesByName = HashMap() 43 | private val bridgesInErr = HashMap() 44 | private var shuttingDown = false 45 | 46 | init { 47 | parseArguments(args) 48 | 49 | logger.info("Dis4IRC v${Versioning.version}-${Versioning.suffix}") 50 | logger.info("Source available at ${Versioning.sourceRepo}") 51 | logger.info("Available under the MIT License") 52 | 53 | // future versions will require a newer version of Java (21) 54 | val javaVer = Runtime.version().feature() 55 | if (javaVer < 21) { 56 | logger.info("") 57 | logger.info("======================================================") 58 | logger.info("Future versions of Dis4IRC will require Java 21 or newer.") 59 | logger.info("You appear to be running Java $javaVer.") 60 | logger.info("") 61 | logger.info("On Linux, your distribution likely already offers newer packages. If your") 62 | logger.info("distribution does not offer newer packages or you are not on Linux, you can use") 63 | logger.info("a (free) release package from a vendor such as:") 64 | logger.info(" Eclipse Temurin, Amazon Corretto, Microsoft OpenJDK, BellSoft Liberica") 65 | logger.info("======================================================") 66 | logger.info("") 67 | } 68 | 69 | logger.info("Loading config from: $configPath") 70 | val config = Configuration(configPath) 71 | 72 | // 73 | // Logging 74 | // 75 | 76 | val logLevel = config.getNode("log-level") 77 | if (logLevel.virtual()) { 78 | logLevel.comment("Sets the minimum amount of detail sent to the log. Acceptable values are: ERROR, WARN, INFO, DEBUG, TRACE") 79 | logLevel.set("INFO") 80 | } 81 | 82 | val legacyLogDebugNode = config.getNode("debug-logging") 83 | if (!legacyLogDebugNode.virtual()) { 84 | if (legacyLogDebugNode.boolean) { 85 | logLevel.set("DEBUG") 86 | } 87 | 88 | legacyLogDebugNode.set(null) 89 | } 90 | 91 | var requestedLogLevel = logLevel.string?.uppercase() 92 | val validLogLevels = setOf("ALL", "TRACE", "DEBUG", "INFO", "WARN", "ERROR") 93 | if (requestedLogLevel == null || !validLogLevels.contains(requestedLogLevel)) { 94 | logger.warn("Invalid log level specified: $requestedLogLevel - Resetting to INFO") 95 | requestedLogLevel = "INFO" 96 | logLevel.set(requestedLogLevel) 97 | } 98 | 99 | val desiredLevel = Level.toLevel(requestedLogLevel, Level.INFO) 100 | updateLoggerToLevel(desiredLevel) 101 | 102 | // 103 | // Bridges 104 | // 105 | 106 | val bridgesNode = config.getNode("bridges") 107 | if (bridgesNode.virtual()) { 108 | bridgesNode.comment( 109 | "A list of bridges that Dis4IRC should start up\n" + 110 | "Each bridge can bridge multiple channels between a single IRC and Discord Server" 111 | ) 112 | 113 | bridgesNode.node("default").makeDefaultNode() 114 | config.saveConfig() 115 | logger.debug("Default config written to $configPath") 116 | } 117 | 118 | if (bridgesNode.isMap) { 119 | bridgesNode.childrenMap().forEach { startBridge(it.value) } 120 | } else { 121 | logger.error("No bridge configurations found!") 122 | } 123 | 124 | kotlin.runCatching { loadBridgeData(SAVED_DATA_PATH) }.onFailure { 125 | logger.error("Unable to load bridge data: $it") 126 | it.printStackTrace() 127 | } 128 | 129 | // re-save config now that bridges have init'd to hopefully update the file with any defaults 130 | config.saveConfig() 131 | 132 | Runtime.getRuntime().addShutdownHook(Thread { 133 | shuttingDown = true 134 | kotlin.runCatching { saveBridgeData(SAVED_DATA_PATH) }.onFailure { 135 | logger.error("Unable to save bridge data: $it") 136 | it.printStackTrace() 137 | } 138 | ArrayList(bridgesByName.values).forEach { it.shutdown() } 139 | }) 140 | } 141 | 142 | /** 143 | * Initializes and starts a bridge instance 144 | */ 145 | private fun startBridge(node: CommentedConfigurationNode) { 146 | logger.info("Starting bridge: ${node.key()}") 147 | 148 | val bridgeConf = node.toBridgeConfiguration() 149 | val bridge = Bridge(this, bridgeConf) 150 | 151 | if (bridgesByName[bridgeConf.bridgeName] != null) { 152 | throw IllegalArgumentException("Cannot register multiple bridges with the same name!") 153 | } 154 | 155 | bridgesByName[bridgeConf.bridgeName] = bridge 156 | 157 | bridge.startBridge() 158 | } 159 | 160 | internal fun notifyOfBridgeShutdown(bridge: Bridge, inErr: Boolean) { 161 | val name = bridge.config.bridgeName 162 | bridgesByName.remove(name) ?: throw IllegalArgumentException("Unknown bridge: $name has shutdown, why wasn't it tracked?") 163 | 164 | if (inErr) { 165 | bridgesInErr[name] = bridge 166 | } 167 | 168 | if (!shuttingDown && bridgesByName.size == 0) { 169 | logger.info("No bridges running - Exiting") 170 | 171 | kotlin.runCatching { saveBridgeData(SAVED_DATA_PATH) }.onFailure { 172 | logger.error("Unable to save bridge data: $it") 173 | it.printStackTrace() 174 | } 175 | 176 | val anyErr = bridgesInErr.isNotEmpty() 177 | val exitCode = if (anyErr) 1 else 0 178 | if (anyErr) { 179 | val errBridges: String = bridgesInErr.keys.joinToString(", ") 180 | logger.warn("The following bridges exited in error: $errBridges") 181 | } 182 | 183 | exitProcess(exitCode) 184 | } 185 | } 186 | 187 | private fun parseArguments(args: Array) { 188 | for ((i, arg) in args.withIndex()) { 189 | when (arg.lowercase(Locale.ENGLISH)) { 190 | "-v","--version" -> { 191 | printVersionInfo(minimal = true) 192 | exitProcess(0) 193 | } 194 | "--about" -> { 195 | printVersionInfo(minimal = false) 196 | exitProcess(0) 197 | } 198 | "-c", "--config" -> { 199 | if (args.size >= i + 2) { 200 | configPath = args[i + 1] 201 | } 202 | } 203 | } 204 | } 205 | } 206 | 207 | private fun printVersionInfo(minimal: Boolean = false) { 208 | // can't use logger, this has to be bare bones without prefixes or timestamps 209 | println("Dis4IRC v${Versioning.version}-${Versioning.suffix}") 210 | if (minimal) { 211 | return 212 | } 213 | 214 | println("Source available at ${Versioning.sourceRepo}") 215 | println("Available under the MIT License") 216 | } 217 | 218 | private fun loadBridgeData(path: Path) { 219 | if (Files.notExists(path)) return 220 | 221 | logger.debug("Loading bridge data from {}", path) 222 | val json: JSONObject = Files.newInputStream(path, StandardOpenOption.READ).use { 223 | val compressedIn = GZIPInputStream(it) 224 | val textIn = InputStreamReader(compressedIn, Charsets.UTF_8) 225 | return@use JSONObject(textIn.readText()) 226 | } 227 | 228 | bridgesByName.forEach { entry -> 229 | if (json.has(entry.key)) { 230 | entry.value.readSavedData(json.getJSONObject((entry.key))) 231 | } 232 | } 233 | } 234 | 235 | private fun saveBridgeData(path: Path) { 236 | logger.debug("Saving bridge data to {}", path) 237 | val json = JSONObject() 238 | 239 | val bridges = TreeSet(Comparator { b1: Bridge, b2: Bridge -> // maintain consistent order 240 | return@Comparator b1.config.bridgeName.compareTo(b2.config.bridgeName) 241 | }) 242 | bridges.addAll(bridgesByName.values) 243 | bridges.addAll(bridgesInErr.values) 244 | for (bridge in bridges) { 245 | json.put(bridge.config.bridgeName, bridge.persistData(JSONObject())) 246 | } 247 | 248 | AtomicFileUtil.writeAtomic(path) { 249 | val compressedOut = GZIPOutputStream(it) 250 | val textOut = OutputStreamWriter(compressedOut, Charsets.UTF_8) 251 | textOut.write(json.toString()) 252 | // seeing some oddities with IJ's run config, these are probably not needed 253 | textOut.flush() 254 | compressedOut.close() 255 | } 256 | } 257 | 258 | private fun updateLoggerToLevel(level: Level) { 259 | val logContext = LoggerFactory.getILoggerFactory() as LoggerContext 260 | // each logger uses its closest ancestor to decide a log level, however some may have already been started 261 | // just set it for all of them 262 | for (logger in logContext.loggerList) { 263 | logger.level = level 264 | } 265 | 266 | logger.info("Log level set to $level") 267 | } 268 | } 269 | 270 | 271 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/config/ConfigurationUtils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.config 10 | 11 | import io.zachbr.dis4irc.bridge.* 12 | import io.zachbr.dis4irc.logger 13 | import org.spongepowered.configurate.CommentedConfigurationNode 14 | import org.spongepowered.configurate.ConfigurationNode 15 | import java.util.regex.Pattern 16 | 17 | /** 18 | * Creates a "default" node, pre-populated with settings 19 | * 20 | * @throws IllegalArgumentException if called from any node other than direct children of the bridges node 21 | */ 22 | fun CommentedConfigurationNode.makeDefaultNode() { 23 | if (this.parent()?.key() != "bridges") { 24 | throw IllegalArgumentException("Cannot make default node from anything but a direct child of bridges node!") 25 | } 26 | 27 | this.comment( 28 | "A bridge is a single bridged connection operating in its own space away from all the other bridges\n" + 29 | "Most people will only need this one default bridge" 30 | ) 31 | 32 | val ircBaseNode = this.node("irc") 33 | ircBaseNode.comment("Configuration for connecting to the IRC server") 34 | 35 | val ircServerNode = ircBaseNode.node("server") 36 | ircServerNode.set("127.0.0.1") 37 | 38 | val ircServerPass = ircBaseNode.node("password") 39 | ircServerPass.set(null) 40 | 41 | val ircPortNode = ircBaseNode.node("port") 42 | ircPortNode.set("6697") 43 | 44 | val ircSslEnabled = ircBaseNode.node("use-ssl") 45 | ircSslEnabled.set(true) 46 | 47 | val ircAllowInvalidCerts = ircBaseNode.node("allow-invalid-ssl-certs") 48 | ircAllowInvalidCerts.set(false) 49 | 50 | val ircNickName = ircBaseNode.node("nickname") 51 | ircNickName.set("BridgeBot") 52 | 53 | val ircUserName = ircBaseNode.node("username") 54 | ircUserName.set("BridgeBot") 55 | 56 | val ircRealName = ircBaseNode.node("realname") 57 | ircRealName.set("BridgeBot") 58 | 59 | val ircAntiPing = ircBaseNode.node("anti-ping") 60 | ircAntiPing.set(true) 61 | 62 | val ircUseNickNameColor = ircBaseNode.node("use-nickname-colors") 63 | ircUseNickNameColor.set(true) 64 | ircUseNickNameColor.comment("Controls whether bridged nicknames will use color") 65 | 66 | val announceForwards = ircBaseNode.node("announce-forwarded-messages-sender") 67 | announceForwards.set(false) 68 | 69 | val noPrefixString = ircBaseNode.node("no-prefix-regex") 70 | noPrefixString.set(null) 71 | noPrefixString.comment("Messages that match this regular expression will be passed to IRC without a user prefix") 72 | 73 | val discordReplyContextLimit = ircBaseNode.node("discord-reply-context-limit") 74 | discordReplyContextLimit.set(90) 75 | discordReplyContextLimit.comment("Sets the max context length to use for messages that are Discord replies. 0 to disable.") 76 | 77 | val ircCommandsList = ircBaseNode.node("init-commands-list") 78 | ircCommandsList.set(arrayListOf("PRIVMSG NICKSERV info", "PRIVMSG NICKSERV help")) 79 | ircCommandsList.comment("A list of __raw__ irc messages to send") 80 | 81 | val ircSendDiscordEmbeds = ircBaseNode.node("send-discord-embeds") 82 | ircSendDiscordEmbeds.set(true) 83 | ircSendDiscordEmbeds.comment("Whether to send Discord channel embeds to IRC.") 84 | 85 | val discordApiKey = this.node("discord-api-key") 86 | discordApiKey.comment("Your discord API key you registered your bot with") 87 | discordApiKey.set("") 88 | 89 | val discordWebhookParent = this.node("discord-webhooks") 90 | discordWebhookParent.comment("Match a channel id to a webhook URL to enable webhooks for that channel") 91 | 92 | val discordWebhook = discordWebhookParent.node("discord-channel-id") 93 | discordWebhook.set("https://webhook-url") 94 | 95 | val announceJoinsQuits = this.node("announce-joins-and-quits") 96 | announceJoinsQuits.set(false) 97 | 98 | val announceExtras = this.node("announce-extras") 99 | announceExtras.set(false) 100 | 101 | val mappingsNode = this.node("channel-mappings") 102 | mappingsNode.comment("Mappings are the channel <-> channel bridging configurations") 103 | 104 | val discordChannelNode = mappingsNode.node("discord-channel-id") 105 | discordChannelNode.set("irc-channel-name") 106 | 107 | val discordOptionsNode = this.node("discord-options") 108 | discordOptionsNode.comment("Discord-specific configuration options") 109 | 110 | val discordActivityTypeNode = discordOptionsNode.node("activity-type") 111 | discordActivityTypeNode.set("PLAYING") 112 | discordActivityTypeNode.comment("Activity type to report to Discord clients. Acceptable values are PLAYING, LISTENING, STREAMING, WATCHING, COMPETING") 113 | 114 | val discordActivityDescNode = discordOptionsNode.node("activity-desc") 115 | discordActivityDescNode.set("IRC") 116 | discordActivityDescNode.comment("Descriptor text to show in the client. An empty string will show nothing. This may not update immediately.") 117 | 118 | val discordActivityUrlNode = discordOptionsNode.node("activity-url") 119 | discordActivityUrlNode.set("") 120 | discordActivityUrlNode.comment("Additional URL field used by certain activity types. Restricted to certain URLs depending on the activity type.") 121 | 122 | val discordStatusNode = discordOptionsNode.node("online-status") 123 | discordStatusNode.set("ONLINE") 124 | discordStatusNode.comment("Online status indicator. Acceptable values are ONLINE, IDLE, DO_NOT_DISTURB, INVISIBLE") 125 | 126 | val discordSuppressUrlPreviews = discordOptionsNode.node("suppress-url-previews") 127 | discordSuppressUrlPreviews.set(false) 128 | discordSuppressUrlPreviews.comment("Suppresses URL previews for messages bridged to Discord.") 129 | } 130 | 131 | /** 132 | * Converts a given node into a BridgeConfiguration instance 133 | * 134 | * @throws IllegalArgumentException if called from any node other than direct children of the bridges node 135 | */ 136 | fun CommentedConfigurationNode.toBridgeConfiguration(): BridgeConfiguration { 137 | if (this.parent()?.key() != "bridges") { 138 | throw IllegalArgumentException("Cannot make bridge configuration from anything but a direct child of bridges node!") 139 | } 140 | 141 | fun getStringNonNull(errMsg: String, vararg path: String): String { 142 | val node = this.node(*path) 143 | return node.string ?: throw IllegalArgumentException(errMsg) 144 | } 145 | 146 | val bridgeName = this.key() as String 147 | 148 | val ircHost = getStringNonNull("IRC hostname cannot be null in $bridgeName", "irc", "server") 149 | val ircPass = this.node("irc", "password").string // nullable 150 | val ircPort = this.node("irc", "port").int 151 | val ircUseSsl = this.node("irc", "use-ssl").boolean 152 | val ircAllowBadSsl = this.node("irc", "allow-invalid-ssl-certs").boolean 153 | val ircNickName = getStringNonNull("IRC nickname cannot be null in $bridgeName!", "irc", "nickname") 154 | val ircUserName = getStringNonNull("IRC username cannot be null in $bridgeName!", "irc", "username") 155 | val ircRealName = getStringNonNull("IRC realname cannot be null in $bridgeName!", "irc", "realname") 156 | val ircAntiPing = this.node("irc", "anti-ping").boolean 157 | val ircUseNickNameColorNode = this.node("irc", "use-nickname-colors") 158 | if (ircUseNickNameColorNode.virtual()) { 159 | ircUseNickNameColorNode.set(true) 160 | } 161 | val ircUseNickNameColor = ircUseNickNameColorNode.boolean 162 | val ircNoPrefix = this.node("irc", "no-prefix-regex").string // nullable 163 | val ircAnnounceForwards = this.node("irc", "announce-forwarded-messages-sender").boolean 164 | val ircDiscordReplyContextLimit = this.node("irc", "discord-reply-context-limit").int 165 | val ircCommandsChildren = this.node("irc", "init-commands-list").childrenList() 166 | val ircSendDiscordEmbedsNode = this.node("irc", "send-discord-embeds") 167 | if (ircSendDiscordEmbedsNode.virtual()) { 168 | ircSendDiscordEmbedsNode.set(true) 169 | } 170 | val ircSendDiscordEmbeds = ircSendDiscordEmbedsNode.boolean 171 | val discordApiKey = getStringNonNull("Discord API key cannot be null in $bridgeName!", "discord-api-key") 172 | val announceJoinsQuits = this.node("announce-joins-and-quits").boolean 173 | val announceExtras = this.node("announce-extras").boolean 174 | var discordActivityType = this.node("discord-options", "activity-type").string 175 | var discordActivityDesc = this.node("discord-options", "activity-desc").string 176 | var discordActivityUrl = this.node("discord-options", "activity-url").string 177 | var discordOnlineStatus = this.node("discord-options", "online-status").string 178 | 179 | // config overhaul overdue - this is awful 180 | val discordSuppressUrlPreviewsNode = this.node("discord-options", "suppress-url-previews") 181 | if (discordSuppressUrlPreviewsNode.virtual()) { 182 | discordSuppressUrlPreviewsNode.set(false) 183 | discordSuppressUrlPreviewsNode.comment("Suppresses URL previews for messages bridged to Discord.") 184 | } 185 | val discordSuppressUrlPreviews = discordSuppressUrlPreviewsNode.boolean 186 | 187 | val webhookMappings = ArrayList() 188 | for (webhookNode in this.node("discord-webhooks").childrenMap().values) { 189 | val mapping = webhookNode.toWebhookMapping() 190 | if (mapping != null) { 191 | webhookMappings.add(mapping) 192 | } 193 | } 194 | 195 | val ircCommandsList = ArrayList() 196 | for (node in ircCommandsChildren) { 197 | val command = node.string ?: continue 198 | ircCommandsList.add(command) 199 | } 200 | 201 | val channelMappings = ArrayList() 202 | for (mappingNode in this.node("channel-mappings").childrenMap().values) { 203 | channelMappings.add(mappingNode.toChannelMapping()) 204 | } 205 | 206 | if (discordActivityType == null || discordActivityType == "DEFAULT") { 207 | discordActivityType = "PLAYING" 208 | this.node("discord-options", "activity-type").set("PLAYING") // Migrate JDA v4 "DEFAULT" to JDA v5 "PLAYING" 209 | } 210 | 211 | if (discordActivityDesc == null) { 212 | discordActivityDesc = "IRC" 213 | } 214 | 215 | if (discordActivityUrl == null) { 216 | discordActivityUrl = "" 217 | } 218 | 219 | if (discordOnlineStatus == null) { 220 | discordOnlineStatus = "ONLINE" 221 | } 222 | 223 | var validated = true 224 | fun validateStringNotEmpty(string: String, errMsg: String) { 225 | if (string.trim().isEmpty()) { 226 | logger.error("$errMsg for bridge: $bridgeName") 227 | validated = false 228 | } 229 | } 230 | 231 | validateStringNotEmpty(ircHost, "IRC hostname left empty") 232 | validateStringNotEmpty(ircNickName, "IRC nickname left empty") 233 | validateStringNotEmpty(ircUserName, "IRC username left empty") 234 | validateStringNotEmpty(ircRealName, "IRC realname left empty") 235 | validateStringNotEmpty(discordApiKey, "Discord API key left empty") 236 | validateStringNotEmpty(discordOnlineStatus, "Discord online-status left empty") 237 | 238 | if (ircPass != null) { 239 | validateStringNotEmpty(ircPass, "IRC pass cannot be left empty") 240 | } 241 | 242 | var ircNoPrefixPattern: Pattern? = null 243 | if (ircNoPrefix != null) { 244 | validateStringNotEmpty(ircNoPrefix, "IRC no prefix cannot be left empty") 245 | ircNoPrefixPattern = Pattern.compile(ircNoPrefix) 246 | } 247 | 248 | if (ircPort == 0) { 249 | logger.error("IRC server port invalid for bridge: $bridgeName") 250 | validated = false 251 | } 252 | 253 | if (channelMappings.size == 0) { 254 | logger.error("No channel mappings defined for bridge: $bridgeName") 255 | validated = false 256 | } 257 | 258 | if (!validated) { 259 | throw IllegalArgumentException("Cannot start $bridgeName bridge with above configuration errors!") 260 | } 261 | 262 | val discordConfig = DiscordConfiguration(discordApiKey, webhookMappings, discordActivityType, discordActivityDesc, discordActivityUrl, discordOnlineStatus, discordSuppressUrlPreviews) 263 | val ircConfig = IrcConfiguration(ircHost, ircPass, ircPort, ircUseSsl, ircAllowBadSsl, ircNickName, ircUserName, 264 | ircRealName, ircAntiPing, ircUseNickNameColor, ircNoPrefixPattern, ircAnnounceForwards, ircDiscordReplyContextLimit, 265 | ircCommandsList, ircSendDiscordEmbeds) 266 | 267 | return BridgeConfiguration( 268 | bridgeName, 269 | announceJoinsQuits, 270 | announceExtras, 271 | channelMappings, 272 | ircConfig, 273 | discordConfig, 274 | this 275 | ) 276 | } 277 | 278 | /** 279 | * Converts a given node into a channel mapping configuration instance 280 | */ 281 | fun ConfigurationNode.toChannelMapping(): ChannelMapping { 282 | val discordChannel: String = this.key() as String 283 | val ircChannel: String = this.string ?: throw IllegalArgumentException("IRC channel mapping cannot be null") 284 | 285 | return ChannelMapping(discordChannel, ircChannel) 286 | } 287 | 288 | /** 289 | * Converts a given node into a webhook mapping configuration instance 290 | */ 291 | fun ConfigurationNode.toWebhookMapping(): WebhookMapping? { 292 | val discordChannel: String = this.key() as String 293 | val webhookUrl: String = this.string ?: return null 294 | 295 | return WebhookMapping(discordChannel, webhookUrl) 296 | } 297 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/mutator/mutators/TranslateFormatting.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.mutator.mutators 10 | 11 | import io.zachbr.dis4irc.bridge.message.CommandMessage 12 | import io.zachbr.dis4irc.bridge.message.DiscordMessage 13 | import io.zachbr.dis4irc.bridge.message.DiscordSource 14 | import io.zachbr.dis4irc.bridge.message.IrcMessage 15 | import io.zachbr.dis4irc.bridge.message.IrcSource 16 | import io.zachbr.dis4irc.bridge.message.PlatformMessage 17 | import io.zachbr.dis4irc.bridge.mutator.api.Mutator 18 | import io.zachbr.dis4irc.util.DiscordSpoiler 19 | import io.zachbr.dis4irc.util.DiscordSpoilerExtension 20 | import org.commonmark.ext.gfm.strikethrough.Strikethrough 21 | import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension 22 | import org.commonmark.node.* 23 | import org.commonmark.parser.Parser 24 | import org.commonmark.renderer.NodeRenderer 25 | import org.commonmark.renderer.text.TextContentNodeRendererContext 26 | import org.commonmark.renderer.text.TextContentRenderer 27 | import java.util.* 28 | 29 | private const val MINIMUM_DISCORD_MESSAGE_LENGTH = 3 30 | // I haven't seen it be an issue but the back of my head says it could be, so remove dashes from this key 31 | private val UNIQUE_KEY_STR = UUID.randomUUID().toString().replace("-", "") 32 | 33 | /** 34 | * Translates Discord's markdown formatting to the IRC formatting codes and vice versa 35 | */ 36 | class TranslateFormatting : Mutator { 37 | private val markdownParser = Parser.Builder() 38 | .extensions(listOf(StrikethroughExtension.create(), DiscordSpoilerExtension.create())) 39 | .build() 40 | 41 | private val ircMarkdownRenderer = TextContentRenderer.builder() 42 | .nodeRendererFactory { context -> IrcRenderer(context) } 43 | .build() 44 | 45 | override fun mutate(message: PlatformMessage): Mutator.LifeCycle { 46 | when (message) { 47 | is CommandMessage -> {} // nothing to do here 48 | is IrcMessage -> message.contents = formatForDiscord(message.contents) 49 | is DiscordMessage -> { 50 | message.contents = formatForIrc(message.contents) 51 | 52 | for (embed in message.embeds) { 53 | embed.string = embed.string?.let { formatForIrc(it) } 54 | } 55 | } 56 | } 57 | 58 | return Mutator.LifeCycle.CONTINUE 59 | } 60 | 61 | /** 62 | * Takes a message from IRC and translates the formatting to Discord compatible rendering chars 63 | */ 64 | private fun formatForDiscord(message: String): String { 65 | val stack = DiscordStack(message) 66 | return stack.toString() 67 | } 68 | 69 | /** 70 | * Takes a message from Discord and translates the formatting to IRC compatible rendering chars 71 | */ 72 | private fun formatForIrc(message: String): String { 73 | // no-op short messages that the markdown parser would incorrectly interpret 74 | // as part of a larger text section 75 | if (message.length < MINIMUM_DISCORD_MESSAGE_LENGTH) { 76 | return message 77 | } 78 | 79 | // poor shrug man needs special handling to be spared the markdown parser 80 | val shrugMan = "¯\\_(ツ)_/¯" 81 | val shrugKey = UNIQUE_KEY_STR 82 | val out = message.replace(shrugMan, shrugKey) 83 | 84 | // render as markdown 85 | val parsed = markdownParser.parse(out) 86 | val rendered = ircMarkdownRenderer.render(parsed) 87 | 88 | // put shrug man back 89 | return rendered.replace(shrugKey, shrugMan) 90 | } 91 | } 92 | 93 | /** 94 | * Custom renderer to take standard markdown (plus strikethrough) and emit IRC compatible formatting codes 95 | */ 96 | class IrcRenderer(context: TextContentNodeRendererContext) : AbstractVisitor(), NodeRenderer { 97 | private val textContent = context.writer 98 | private val spoilerFormatCodeSequence = "${IrcFormattingCodes.COLOR}${IrcColorCodes.BLACK},${IrcColorCodes.BLACK}" 99 | 100 | override fun render(node: Node?) { 101 | node?.accept(this) 102 | } 103 | 104 | override fun getNodeTypes(): HashSet> { 105 | return HashSet( 106 | listOf ( 107 | Document::class.java, 108 | Heading::class.java, 109 | Paragraph::class.java, 110 | BlockQuote::class.java, 111 | BulletList::class.java, 112 | FencedCodeBlock::class.java, 113 | HtmlBlock::class.java, 114 | ThematicBreak::class.java, 115 | IndentedCodeBlock::class.java, 116 | Link::class.java, 117 | ListItem::class.java, 118 | OrderedList::class.java, 119 | Image::class.java, 120 | Emphasis::class.java, 121 | StrongEmphasis::class.java, 122 | Text::class.java, 123 | Code::class.java, 124 | HtmlInline::class.java, 125 | SoftLineBreak::class.java, 126 | HardLineBreak::class.java 127 | ) 128 | ) 129 | } 130 | 131 | override fun visit(blockQuote: BlockQuote?) { 132 | textContent.write("> ") // don't strip this off as part of parse, block quotes aren't a thing in discord 133 | visitChildren(blockQuote) 134 | } 135 | 136 | override fun visit(bulletList: BulletList?) { 137 | visitChildren(bulletList) 138 | } 139 | 140 | override fun visit(code: Code?) { 141 | textContent.write(IrcFormattingCodes.MONOSPACE.char) 142 | textContent.write(code?.literal) 143 | textContent.write(IrcFormattingCodes.MONOSPACE.char) 144 | } 145 | 146 | override fun visit(document: Document?) { 147 | visitChildren(document) 148 | } 149 | 150 | override fun visit(emphasis: Emphasis?) { 151 | textContent.write(IrcFormattingCodes.ITALICS.char) 152 | visitChildren(emphasis) 153 | textContent.write(IrcFormattingCodes.ITALICS.char) 154 | } 155 | 156 | override fun visit(fencedCodeBlock: FencedCodeBlock?) { 157 | textContent.write(IrcFormattingCodes.MONOSPACE.char) 158 | textContent.write(fencedCodeBlock?.literal) 159 | textContent.write(IrcFormattingCodes.MONOSPACE.char) 160 | } 161 | 162 | override fun visit(hardLineBreak: HardLineBreak?) { 163 | textContent.line() 164 | } 165 | 166 | override fun visit(heading: Heading?) { 167 | visitChildren(heading) 168 | } 169 | 170 | override fun visit(thematicBreak: ThematicBreak?) { 171 | visitChildren(thematicBreak) 172 | } 173 | 174 | override fun visit(htmlInline: HtmlInline?) { 175 | textContent.write(htmlInline?.literal) 176 | } 177 | 178 | override fun visit(htmlBlock: HtmlBlock?) { 179 | textContent.write(IrcFormattingCodes.MONOSPACE.char) 180 | textContent.write(htmlBlock?.literal) 181 | textContent.write(IrcFormattingCodes.MONOSPACE.char) 182 | } 183 | 184 | override fun visit(image: Image?) { 185 | textContent.write(image?.destination) 186 | } 187 | 188 | override fun visit(indentedCodeBlock: IndentedCodeBlock?) { 189 | textContent.write(IrcFormattingCodes.MONOSPACE.char) 190 | textContent.write(indentedCodeBlock?.literal) 191 | textContent.write(IrcFormattingCodes.MONOSPACE.char) 192 | } 193 | 194 | override fun visit(link: Link?) { 195 | textContent.write(link?.destination) 196 | } 197 | 198 | override fun visit(listItem: ListItem?) { 199 | // discord doesn't do anything fancy for lists, neither should we, present them as they are 200 | textContent.write("- ") 201 | 202 | visitChildren(listItem) 203 | 204 | // spaces between list items 205 | if (listItem?.parent?.lastChild != listItem) { 206 | textContent.write(" ") 207 | } 208 | } 209 | 210 | override fun visit(orderedList: OrderedList?) { 211 | visitChildren(orderedList) 212 | } 213 | 214 | override fun visit(paragraph: Paragraph?) { 215 | visitChildren(paragraph) 216 | } 217 | 218 | override fun visit(softLineBreak: SoftLineBreak?) { 219 | textContent.line() 220 | } 221 | 222 | override fun visit(strongEmphasis: StrongEmphasis?) { 223 | val wrapper: Char = when (strongEmphasis?.openingDelimiter) { 224 | DiscordFormattingCodes.BOLD.code -> IrcFormattingCodes.BOLD.char 225 | DiscordFormattingCodes.UNDERLINE.code -> IrcFormattingCodes.UNDERLINE.char 226 | else -> throw IllegalArgumentException("Unknown strong emphasis delimiter: ${strongEmphasis?.openingDelimiter}") 227 | } 228 | textContent.write(wrapper) 229 | visitChildren(strongEmphasis) 230 | textContent.write(wrapper) 231 | } 232 | 233 | override fun visit(text: Text?) { 234 | textContent.write(text?.literal) 235 | } 236 | 237 | override fun visit(customNode: CustomNode?) { 238 | when (customNode) { 239 | is Strikethrough -> { 240 | textContent.write(IrcFormattingCodes.STRIKETHROUGH.char) 241 | visitChildren(customNode) 242 | textContent.write(IrcFormattingCodes.STRIKETHROUGH.char) 243 | } 244 | is DiscordSpoiler -> { 245 | textContent.write(spoilerFormatCodeSequence) 246 | visitChildren(customNode) 247 | textContent.write(IrcFormattingCodes.COLOR.char) 248 | } 249 | else -> { 250 | throw IllegalArgumentException("Unknown custom node: $customNode") 251 | } 252 | } 253 | } 254 | 255 | } 256 | 257 | /** 258 | * General discord (markdown) formatting codes 259 | */ 260 | enum class DiscordFormattingCodes(val code: String) { 261 | BOLD("**"), 262 | ITALICS("*"), 263 | UNDERLINE("__"), 264 | STRIKETHROUGH("~~"), 265 | MONOSPACE_PARA("```"), 266 | MONOSPACE("`"); 267 | 268 | override fun toString(): String = code 269 | } 270 | 271 | /** 272 | * Based on info from https://modern.ircdocs.horse/formatting.html#characters 273 | */ 274 | enum class IrcFormattingCodes(val char: Char) { 275 | COLOR(0x03.toChar()), 276 | BOLD(0x02.toChar()), 277 | ITALICS(0x1D.toChar()), 278 | UNDERLINE(0x1F.toChar()), 279 | STRIKETHROUGH(0x1E.toChar()), 280 | MONOSPACE(0x11.toChar()), 281 | RESET(0x0F.toChar()); 282 | 283 | private val code: String = char.toString() 284 | override fun toString(): String = code 285 | } 286 | 287 | /** 288 | * Based on info from https://modern.ircdocs.horse/formatting.html#colors 289 | */ 290 | enum class IrcColorCodes(private val code: String) { // always use 2 digit codes 291 | WHITE("00"), 292 | BLACK("01"), 293 | DEFAULT("99"); // this is not well supported by clients 294 | 295 | override fun toString(): String = code 296 | } 297 | 298 | class DiscordStack(string: String) { 299 | private val builder = StringBuilder() 300 | private val stack = Stack() 301 | private var isColor = false 302 | private var digits = 0 303 | 304 | init { 305 | string.toCharArray().forEach { character -> 306 | when(character) { 307 | IrcFormattingCodes.COLOR.char -> this.pushColor() 308 | IrcFormattingCodes.BOLD.char -> this.pushFormat(DiscordFormattingCodes.BOLD) 309 | IrcFormattingCodes.ITALICS.char -> this.pushFormat(DiscordFormattingCodes.ITALICS) 310 | IrcFormattingCodes.UNDERLINE.char -> this.pushFormat(DiscordFormattingCodes.UNDERLINE) 311 | IrcFormattingCodes.STRIKETHROUGH.char -> this.pushFormat(DiscordFormattingCodes.STRIKETHROUGH) 312 | IrcFormattingCodes.MONOSPACE.char -> this.pushMonospace() 313 | IrcFormattingCodes.RESET.char -> this.pushReset() 314 | else -> this.push(character) 315 | } 316 | } 317 | } 318 | 319 | private fun push(character: Char) { 320 | if (this.isColor) { 321 | if (character.isDigit()) { 322 | this.digits++ 323 | if (this.digits >= 3) { 324 | this.resetColor() 325 | } else { 326 | return 327 | } 328 | } else if (this.digits > 0 && character == ',') { 329 | this.digits = 0 330 | return 331 | } else { 332 | this.resetColor() 333 | } 334 | } 335 | 336 | this.builder.append(character) 337 | } 338 | 339 | private fun pushColor() { 340 | this.isColor = true 341 | } 342 | 343 | private fun resetColor() { 344 | this.isColor = false 345 | this.digits = 0 346 | } 347 | 348 | private fun pushFormat(format: DiscordFormattingCodes) { 349 | val peekMatch = this.peekMatch(format) 350 | val contained = this.toggleFormat(format) 351 | val repush = contained && !peekMatch 352 | if (repush) { 353 | this.pushStack() 354 | } 355 | this.builder.append(format) 356 | if (repush) { 357 | this.pushStack() 358 | } 359 | } 360 | 361 | private fun peekMatch(format: DiscordFormattingCodes): Boolean { 362 | return !this.stack.isEmpty() && this.stack.peek() == format 363 | } 364 | 365 | private fun pushMonospace() { 366 | val contained = this.toggleFormat(DiscordFormattingCodes.MONOSPACE) 367 | if (!contained) { 368 | this.pushStack() 369 | } else { 370 | this.builder.append(DiscordFormattingCodes.MONOSPACE) 371 | this.pushStack() 372 | } 373 | } 374 | 375 | private fun toggleFormat(format: DiscordFormattingCodes): Boolean { 376 | val contained = this.stack.contains(format) 377 | if (contained) { 378 | this.stack.remove(format) 379 | } else { 380 | this.stack.push(format) 381 | } 382 | return contained 383 | } 384 | 385 | private fun pushStack() { 386 | this.stack.forEach { format -> this.builder.append(format) } 387 | } 388 | 389 | private fun pushReset() { 390 | while (!this.stack.isEmpty()) { 391 | this.builder.append(this.stack.pop()) 392 | } 393 | } 394 | 395 | override fun toString(): String { 396 | this.pushReset() // reset is not required, simulate one to finish 397 | return this.builder.toString() 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /src/main/kotlin/io/zachbr/dis4irc/bridge/pier/discord/DiscordPier.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Dis4IRC. 3 | * 4 | * Copyright (c) Dis4IRC contributors 5 | * 6 | * MIT License 7 | */ 8 | 9 | package io.zachbr.dis4irc.bridge.pier.discord 10 | 11 | import club.minnced.discord.webhook.WebhookClient 12 | import club.minnced.discord.webhook.WebhookClientBuilder 13 | import club.minnced.discord.webhook.send.AllowedMentions 14 | import club.minnced.discord.webhook.send.WebhookMessageBuilder 15 | import club.minnced.discord.webhook.util.WebhookErrorHandler 16 | import io.zachbr.dis4irc.bridge.Bridge 17 | import io.zachbr.dis4irc.bridge.message.BridgeMessage 18 | import io.zachbr.dis4irc.bridge.message.BridgeSender 19 | import io.zachbr.dis4irc.bridge.message.DiscordMessage 20 | import io.zachbr.dis4irc.bridge.message.DiscordSource 21 | import io.zachbr.dis4irc.bridge.message.PlatformMessage 22 | import io.zachbr.dis4irc.bridge.message.PlatformSource 23 | import io.zachbr.dis4irc.bridge.pier.Pier 24 | import io.zachbr.dis4irc.util.replaceTarget 25 | import net.dv8tion.jda.api.JDA 26 | import net.dv8tion.jda.api.JDABuilder 27 | import net.dv8tion.jda.api.entities.Activity 28 | import net.dv8tion.jda.api.entities.Activity.ActivityType 29 | import net.dv8tion.jda.api.entities.Guild 30 | import net.dv8tion.jda.api.entities.Member 31 | import net.dv8tion.jda.api.entities.channel.concrete.TextChannel 32 | import net.dv8tion.jda.api.requests.GatewayIntent 33 | import net.dv8tion.jda.api.requests.restaction.pagination.PinnedMessagePaginationAction 34 | import net.dv8tion.jda.api.utils.MemberCachePolicy 35 | import net.dv8tion.jda.api.utils.cache.CacheFlag 36 | import org.slf4j.Logger 37 | import java.time.Instant 38 | 39 | private const val ZERO_WIDTH_SPACE = 0x200B.toChar() 40 | 41 | class DiscordPier(private val bridge: Bridge) : Pier { 42 | internal val logger: Logger = bridge.logger 43 | private val webhookMap = HashMap() 44 | private var botAvatarUrl: String? = null 45 | private lateinit var discordApi: JDA 46 | 47 | override fun start() { 48 | logger.info("Connecting to Discord API...") 49 | 50 | val intents = listOf(GatewayIntent.GUILD_MESSAGES, GatewayIntent.GUILD_MEMBERS, GatewayIntent.GUILD_EXPRESSIONS, GatewayIntent.MESSAGE_CONTENT) 51 | val discordApiBuilder = JDABuilder.createLight(bridge.config.discord.apiKey, intents) 52 | .setMemberCachePolicy(MemberCachePolicy.ALL) // so we can cache invisible members and ping people not online 53 | .enableCache(CacheFlag.EMOJI, CacheFlag.STICKER) 54 | .addEventListeners(DiscordMsgListener(this)) 55 | .setStatus(getEnumFromString(bridge.config.discord.onlineStatus, "Unknown online-status specified")) 56 | 57 | if (bridge.config.discord.activityDesc.isNotBlank()) { 58 | val activityType: ActivityType = getEnumFromString(bridge.config.discord.activityType, "Unknown activity-type specified") 59 | discordApiBuilder.setActivity(Activity.of(activityType, bridge.config.discord.activityDesc, bridge.config.discord.activityUrl)) 60 | } 61 | 62 | if (bridge.config.announceJoinsQuits) { 63 | discordApiBuilder.addEventListeners(DiscordJoinQuitListener(this)) 64 | } 65 | 66 | discordApi = discordApiBuilder 67 | .build() 68 | .awaitReady() 69 | 70 | // init webhooks 71 | if (bridge.config.discord.webHooks.isNotEmpty()) { 72 | logger.info("Initializing Discord webhooks") 73 | val webhookErrorHandler = WebhookErrorHandler { client, message, throwable -> 74 | logger.error("Webhook ${client.id}: $message") 75 | throwable?.printStackTrace() 76 | } 77 | 78 | for (hook in bridge.config.discord.webHooks) { 79 | val webhook: WebhookClient 80 | try { 81 | webhook = WebhookClientBuilder(hook.webhookUrl).build() 82 | } catch (ex: IllegalArgumentException) { 83 | logger.error("Webhook for ${hook.discordChannel} with url ${hook.webhookUrl} is not valid!") 84 | ex.printStackTrace() 85 | continue 86 | } 87 | 88 | webhook.setErrorHandler(webhookErrorHandler) 89 | webhookMap[hook.discordChannel] = webhook 90 | logger.info("Webhook for ${hook.discordChannel} registered") 91 | } 92 | } 93 | 94 | botAvatarUrl = discordApi.selfUser.avatarUrl 95 | 96 | logger.info("Discord Bot Invite URL: ${discordApi.getInviteUrl()}") 97 | logger.info("Connected to Discord!") 98 | } 99 | 100 | override fun onShutdown() { 101 | // shutdown can be called when discord fails to init 102 | if (this::discordApi.isInitialized) { 103 | discordApi.shutdownNow() 104 | } 105 | 106 | for (client in webhookMap.values) { 107 | client.close() 108 | } 109 | } 110 | 111 | override fun sendMessage(targetChan: String, msg: BridgeMessage) { 112 | if (!this::discordApi.isInitialized) { 113 | logger.error("Discord Connection has not been initialized yet!") 114 | return 115 | } 116 | 117 | val channel = getTextChannelBy(targetChan) 118 | if (channel == null) { 119 | logger.error("Unable to get a discord channel for: $targetChan | Is bot present?") 120 | return 121 | } 122 | 123 | val webhook = webhookMap[targetChan] 124 | val guild = channel.guild 125 | val platMessage = msg.message 126 | 127 | // make sure to replace clearly separated mentions first to not replace partial mentions 128 | replaceMentions(guild, platMessage, true) 129 | 130 | // replace mentions but don't require separation to find some previously missed, non-separated ones 131 | replaceMentions(guild, platMessage, false) 132 | 133 | // convert emotes to show properly 134 | for (emoji in guild.emojiCache) { 135 | val mentionTrigger = ":${emoji.name}:" 136 | platMessage.contents = replaceTarget(platMessage.contents, mentionTrigger, emoji.asMention) 137 | } 138 | 139 | // Discord won't broadcast messages that are just whitespace 140 | if (platMessage.contents.trim() == "") { 141 | platMessage.contents = "$ZERO_WIDTH_SPACE" 142 | } 143 | 144 | if (bridge.config.discord.suppressUrlPreview) { 145 | platMessage.contents = wrapUrlsInBrackets(platMessage.contents) 146 | } 147 | 148 | if (webhook != null) { 149 | sendMessageWebhook(guild, webhook, msg) 150 | } else { 151 | sendMessageOldStyle(channel, msg) 152 | } 153 | 154 | bridge.updateStatistics(msg, Instant.now()) 155 | } 156 | 157 | private fun sendMessageOldStyle(discordChannel: TextChannel, bMessage: BridgeMessage) { 158 | if (!discordChannel.canTalk()) { 159 | logger.warn("Bridge cannot speak in ${discordChannel.name} to send message: $bMessage") 160 | return 161 | } 162 | 163 | val platMessage = bMessage.message 164 | val senderName = enforceSenderName(platMessage.sender.displayName) 165 | val prefix = if (bMessage.originatesFromBridgeItself()) "" else "<$senderName> " 166 | 167 | discordChannel.sendMessage("$prefix${platMessage.contents}").queue() 168 | } 169 | 170 | private fun sendMessageWebhook(guild: Guild, webhook: WebhookClient, bMessage: BridgeMessage) { 171 | val platMessage = bMessage.message 172 | val guildUser = getMemberByUserNameOrDisplayName(platMessage.sender.displayName, guild) 173 | var avatarUrl = guildUser?.effectiveAvatarUrl 174 | 175 | var senderName = enforceSenderName(platMessage.sender.displayName) 176 | // if sender is command, use bot's actual name and avatar if possible 177 | if (platMessage.sender == BridgeSender) { 178 | senderName = guild.getMember(discordApi.selfUser)?.effectiveName ?: senderName 179 | avatarUrl = botAvatarUrl ?: avatarUrl 180 | } 181 | 182 | val message = WebhookMessageBuilder() 183 | .setContent(platMessage.contents) 184 | .setUsername(senderName) 185 | .setAvatarUrl(avatarUrl) 186 | .setAllowedMentions( 187 | AllowedMentions() 188 | .withParseUsers(true) 189 | .withParseRoles(true) 190 | ) 191 | .build() 192 | 193 | webhook.send(message) 194 | } 195 | 196 | /** 197 | * Checks if the message came from this bot 198 | */ 199 | fun isThisBot(source: PlatformSource, userSnowflake: Long): Boolean { 200 | if (source !is DiscordSource) { 201 | return false 202 | } 203 | 204 | // check against bot user directly 205 | if (userSnowflake == discordApi.selfUser.idLong) { 206 | return true 207 | } 208 | 209 | // check against webclients 210 | val webhook = webhookMap[source.channelId.toString()] ?: webhookMap[source.channelName] 211 | if (webhook != null) { 212 | return userSnowflake == webhook.id 213 | } 214 | 215 | // nope 216 | return false 217 | } 218 | 219 | /** 220 | * Sends a message to the bridge for processing 221 | */ 222 | fun sendToBridge(message: DiscordMessage) { 223 | // try and resolve local snapshot mentions before they go across the bridge 224 | if (message.snapshots.isNotEmpty()) { 225 | for (snapshot in message.snapshots) { 226 | val textChannel = getTextChannelBy(message.source.channelId.toString()) ?: continue 227 | snapshot.contents = parseMentionableToNames(textChannel.guild, snapshot.contents, requireSeparation = true) 228 | snapshot.contents = parseMentionableToNames(textChannel.guild, snapshot.contents, requireSeparation = false) 229 | } 230 | } 231 | 232 | bridge.submitMessage(BridgeMessage(message)) 233 | } 234 | 235 | /** 236 | * Gets the pinned messages from the specified discord channel or null if the channel cannot be found 237 | */ 238 | fun getPinnedMessages(channelId: String, callback: (List?) -> Unit) { 239 | getTextChannelBy(channelId)?.let { channel -> 240 | channel.retrievePinnedMessages().queue { pinnedMessages -> 241 | val platformMessages = pinnedMessages.map { it.message.toPlatformMessage(logger) } 242 | callback(platformMessages) 243 | } 244 | } ?: callback(null) 245 | } 246 | 247 | /** 248 | * Gets a text channel by snowflake ID or string 249 | */ 250 | private fun getTextChannelBy(string: String): TextChannel? { 251 | val byId = discordApi.getTextChannelById(string) 252 | if (byId != null) { 253 | return byId 254 | } 255 | 256 | val byName = discordApi.getTextChannelsByName(string, false) 257 | return if (byName.isNotEmpty()) byName.first() else null 258 | } 259 | 260 | private fun replaceMentions(guild: Guild, msg: PlatformMessage, requireSeparation: Boolean) { 261 | // convert name use to proper mentions 262 | for (member in guild.memberCache) { 263 | val mentionTrigger = "@${member.effectiveName}" // require @ prefix 264 | msg.contents = replaceTarget(msg.contents, mentionTrigger, member.asMention, requireSeparation) 265 | } 266 | 267 | // convert role use to proper mentions 268 | for (role in guild.roleCache) { 269 | if (!role.isMentionable) { 270 | continue 271 | } 272 | 273 | val mentionTrigger = "@${role.name}" // require @ prefix 274 | msg.contents = replaceTarget(msg.contents, mentionTrigger, role.asMention, requireSeparation) 275 | } 276 | 277 | // convert text channels to mentions 278 | for (guildChannel in guild.textChannelCache) { 279 | val mentionTrigger = "#${guildChannel.name}" 280 | msg.contents = replaceTarget(msg.contents, mentionTrigger, guildChannel.asMention, requireSeparation) 281 | } 282 | } 283 | 284 | private fun parseMentionableToNames(guild: Guild, contents: String, requireSeparation: Boolean): String { 285 | var newContents = contents 286 | 287 | // convert name use to proper mentions 288 | for (member in guild.memberCache) { 289 | newContents = replaceTarget(newContents, member.asMention, member.effectiveName, requireSeparation) 290 | } 291 | 292 | // convert role use to proper mentions 293 | for (role in guild.roleCache) { 294 | if (!role.isMentionable) { 295 | continue 296 | } 297 | 298 | newContents = replaceTarget(newContents, role.asMention, role.name, requireSeparation) 299 | } 300 | 301 | // convert text channels to mentions 302 | for (guildChannel in guild.textChannelCache) { 303 | newContents = replaceTarget(newContents, guildChannel.asMention, guildChannel.name, requireSeparation) 304 | } 305 | 306 | return newContents; 307 | } 308 | 309 | private fun getMemberByUserNameOrDisplayName(name: String, guild: Guild, ignoreCase: Boolean = true): Member? { 310 | // check by username first 311 | var matchingUsers = guild.getMembersByName(name, ignoreCase) 312 | // if no results, check by their nickname instead 313 | if (matchingUsers.isEmpty()) { 314 | matchingUsers = guild.getMembersByNickname(name, ignoreCase) 315 | } 316 | // if we still don't have any results, fire off a findMembers call to look it up (and cache it for later) 317 | // this won't help us with this specific call (we don't really want to wait around for this task to come back), 318 | // but it will help us with future calls to this and other functions, so the next time they talk, we'll have it. 319 | if (matchingUsers.isEmpty()) { 320 | guild.findMembers { it.user.name.equals(name, ignoreCase) || it.nickname.equals(name, ignoreCase) } 321 | .onSuccess { logger.debug("Cached ${it.size} results for user lookup: $name") } 322 | } 323 | 324 | return matchingUsers.firstOrNull() 325 | } 326 | 327 | /** 328 | * Gets an enum from the given string or throw an IllegalArgumentException with the given error message 329 | */ 330 | private inline fun > getEnumFromString(userInput: String, errorMessage: String): T { 331 | val inputUpper = userInput.uppercase() 332 | for (poss in T::class.java.enumConstants) { 333 | if (poss.name.uppercase() == inputUpper) { 334 | return poss 335 | } 336 | } 337 | 338 | throw IllegalArgumentException("$errorMessage: $userInput") 339 | } 340 | } 341 | 342 | private const val NICK_ENFORCEMENT_CHAR = "-" 343 | 344 | /** 345 | * Ensures name is within Discord's requirements 346 | */ 347 | fun enforceSenderName(name: String): String { 348 | if (name.length < 2) { 349 | return NICK_ENFORCEMENT_CHAR + name + NICK_ENFORCEMENT_CHAR 350 | } 351 | 352 | if (name.length > 32) { 353 | return name.substring(0, 32) 354 | } 355 | 356 | return name 357 | } 358 | 359 | /** 360 | * Wraps URLs within a string in angle brackets. 361 | * 362 | * @param text string to process 363 | * @return string with URLs wrapped in <>. 364 | */ 365 | fun wrapUrlsInBrackets(text: String): String { 366 | // discord only links for http[s]:// URLs 367 | val urlRegex = """(? 370 | val originalUrl = match.value 371 | val lastUrlCharIndex = originalUrl.indexOfLast { it !in ".,;:'!?)" } 372 | 373 | if (lastUrlCharIndex == -1) { 374 | return@replace originalUrl 375 | } 376 | 377 | val url = originalUrl.substring(0, lastUrlCharIndex + 1) 378 | val suffix = originalUrl.substring(lastUrlCharIndex + 1) 379 | 380 | return@replace "<${url}>${suffix}" 381 | } 382 | } 383 | 384 | --------------------------------------------------------------------------------