├── .version ├── .gitignore ├── dibridge ├── relay.py ├── __main__.py ├── irc_puppet.py ├── discord.py └── irc.py ├── .dockerignore ├── requirements.base ├── .dorpsgek.yml ├── .flake8 ├── .github ├── workflows │ ├── release.yml │ ├── testing.yml │ └── preview.yml └── dependabot.yml ├── requirements.txt ├── Dockerfile ├── README.md └── LICENSE /.version: -------------------------------------------------------------------------------- 1 | dev 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pyc 3 | /.env 4 | -------------------------------------------------------------------------------- /dibridge/relay.py: -------------------------------------------------------------------------------- 1 | DISCORD = None 2 | IRC = None 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pyc 3 | /.env 4 | /.git/** 5 | -------------------------------------------------------------------------------- /requirements.base: -------------------------------------------------------------------------------- 1 | irc 2 | discord.py 3 | openttd-helpers 4 | -------------------------------------------------------------------------------- /.dorpsgek.yml: -------------------------------------------------------------------------------- 1 | notifications: 2 | global: 3 | irc: 4 | - openttd 5 | - openttd.notice 6 | 7 | pull-request: 8 | except-by: 9 | - dependabot\[bot\] 10 | issue: 11 | tag-created: 12 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | inline-quotes = double 4 | 5 | # Flake8 is not PEP-8 compliant with E203. See: 6 | # https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#flake8 7 | extend-ignore = E203 8 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | uses: OpenTTD/actions/.github/workflows/rw-entry-release-docker-nomad.yml@v5 12 | secrets: inherit 13 | with: 14 | service: dibridge-prod 15 | url: https://dibridge.openttd.org/ 16 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | testing: 13 | name: Testing 14 | uses: OpenTTD/actions/.github/workflows/rw-entry-testing-docker-py.yml@v5 15 | with: 16 | python-path: dibridge 17 | python-version: "3.10" 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | dependencies: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "monthly" 15 | groups: 16 | actions: 17 | patterns: 18 | - "*" 19 | -------------------------------------------------------------------------------- /.github/workflows/preview.yml: -------------------------------------------------------------------------------- 1 | name: Preview 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - labeled 7 | - synchronize 8 | branches: 9 | - main 10 | 11 | jobs: 12 | preview: 13 | if: ${{ (github.event.action == 'labeled' && github.event.label.name == 'preview') || (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'preview')) }} 14 | name: Preview 15 | uses: OpenTTD/actions/.github/workflows/rw-entry-preview-docker-nomad.yml@v5 16 | secrets: inherit 17 | with: 18 | service: dibridge-preview 19 | url: https://dibridge-preview.openttd.org/ 20 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohappyeyeballs==2.6.1 2 | aiohttp==3.12.13 3 | aiosignal==1.3.2 4 | async-timeout==5.0.1 5 | attrs==25.3.0 6 | autocommand==2.2.2 7 | backports.tarfile==1.2.0 8 | certifi==2025.6.15 9 | click==8.1.8 10 | discord.py==2.5.2 11 | frozenlist==1.7.0 12 | idna==3.10 13 | importlib_resources==6.5.2 14 | irc==20.5.0 15 | jaraco.collections==5.1.0 16 | jaraco.context==6.0.1 17 | jaraco.functools==4.1.0 18 | jaraco.logging==3.4.0 19 | jaraco.stream==3.0.4 20 | jaraco.text==4.0.0 21 | more-itertools==10.7.0 22 | multidict==6.5.0 23 | openttd-helpers==1.4.0 24 | propcache==0.3.2 25 | python-dateutil==2.9.0.post0 26 | pytz==2025.2 27 | sentry-sdk==2.30.0 28 | six==1.17.0 29 | tempora==5.8.0 30 | typing_extensions==4.14.0 31 | urllib3==2.4.0 32 | yarl==1.20.1 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | ARG BUILD_VERSION="dev" 4 | 5 | # In order to install a non-release dependency, we need git. 6 | RUN apt-get update && apt-get install -y --no-install-recommends \ 7 | git \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | WORKDIR /code 11 | 12 | COPY requirements.txt \ 13 | LICENSE \ 14 | README.md \ 15 | /code/ 16 | # Needed for Sentry to know what version we are running 17 | RUN echo "${BUILD_VERSION}" > /code/.version 18 | 19 | RUN pip --no-cache-dir install -U pip \ 20 | && pip --no-cache-dir install -r requirements.txt 21 | 22 | # Validate that what was installed was what was expected 23 | RUN pip freeze 2>/dev/null > requirements.installed \ 24 | && diff -u --strip-trailing-cr requirements.txt requirements.installed 1>&2 \ 25 | || ( echo "!! ERROR !! requirements.txt defined different packages or versions for installation" \ 26 | && exit 1 ) 1>&2 27 | 28 | COPY dibridge /code/dibridge 29 | 30 | ENTRYPOINT ["python", "-m", "dibridge"] 31 | CMD [] 32 | -------------------------------------------------------------------------------- /dibridge/__main__.py: -------------------------------------------------------------------------------- 1 | import click 2 | import ipaddress 3 | import logging 4 | import threading 5 | 6 | from openttd_helpers import click_helper 7 | from openttd_helpers.logging_helper import click_logging 8 | from openttd_helpers.sentry_helper import click_sentry 9 | 10 | from . import discord 11 | from . import irc 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | @click_helper.command() 17 | @click_logging # Should always be on top, as it initializes the logging 18 | @click_sentry 19 | @click.option("--discord-token", help="Discord bot token to authenticate.", required=True) 20 | @click.option("--discord-channel-id", help="Discord channel ID to relay to.", required=True, type=int) 21 | @click.option("--irc-host", help="IRC host to connect to.", required=True) 22 | @click.option("--irc-port", help="IRC SSL port to connect to.", default=6697, type=int) 23 | @click.option("--irc-nick", help="IRC nick to use.", required=True) 24 | @click.option("--irc-channel", help="IRC channel to relay to, without the first '#'.", required=True) 25 | @click.option("--irc-puppet-ip-range", help="An IPv6 CIDR range to use for IRC puppets. (2001:A:B:C:D::/80)") 26 | @click.option("--irc-puppet-postfix", help="Postfix to add to IRC puppet nicknames (default: none).", default="") 27 | @click.option("--irc-ignore-list", help="IRC nicknames to not relay messages for (comma separated, case-insensitive).") 28 | @click.option( 29 | "--irc-idle-timeout", 30 | help="IRC puppet idle timeout, in seconds (default: 2 days).", 31 | default=60 * 60 * 24 * 2, 32 | type=int, 33 | ) 34 | def main( 35 | discord_token, 36 | discord_channel_id, 37 | irc_host, 38 | irc_port, 39 | irc_nick, 40 | irc_channel, 41 | irc_puppet_ip_range, 42 | irc_puppet_postfix, 43 | irc_ignore_list, 44 | irc_idle_timeout, 45 | ): 46 | if irc_puppet_ip_range: 47 | irc_puppet_ip_range = ipaddress.ip_network(irc_puppet_ip_range) 48 | if irc_puppet_ip_range.num_addresses < 2**32: 49 | raise Exception("--irc-puppet-ip-range needs to be an IPv6 CIDR range of at least /96 or more.") 50 | 51 | if irc_ignore_list: 52 | irc_ignore_list = [nickname.strip().lower() for nickname in irc_ignore_list.split(",") if nickname.strip()] 53 | if not irc_ignore_list: 54 | irc_ignore_list = [] 55 | 56 | thread_d = threading.Thread(target=discord.start, args=[discord_token, discord_channel_id]) 57 | thread_i = threading.Thread( 58 | target=irc.start, 59 | args=[ 60 | irc_host, 61 | irc_port, 62 | irc_nick, 63 | f"#{irc_channel}", 64 | irc_puppet_ip_range, 65 | irc_puppet_postfix, 66 | irc_ignore_list, 67 | irc_idle_timeout, 68 | ], 69 | ) 70 | 71 | thread_d.start() 72 | thread_i.start() 73 | 74 | thread_d.join() 75 | thread_i.join() 76 | 77 | 78 | if __name__ == "__main__": 79 | main(auto_envvar_prefix="DIBRIDGE") 80 | -------------------------------------------------------------------------------- /dibridge/irc_puppet.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import irc.client_aio 3 | import logging 4 | import random 5 | import socket 6 | 7 | 8 | class IRCPuppet(irc.client_aio.AioSimpleIRCClient): 9 | def __init__(self, irc_host, irc_port, ipv6_address, nickname, username, channel, remove_puppet_func, idle_timeout): 10 | irc.client.SimpleIRCClient.__init__(self) 11 | 12 | self.loop = asyncio.get_event_loop() 13 | 14 | self._irc_host = irc_host 15 | self._irc_port = irc_port 16 | self._ipv6_address = ipv6_address 17 | self._nickname = nickname 18 | self._nickname_original = nickname 19 | self._nickname_iteration = 0 20 | self._username = username 21 | self._joined = False 22 | self._channel = channel 23 | self._pinger_task = None 24 | self._remove_puppet_func = remove_puppet_func 25 | self._idle_timeout = idle_timeout 26 | self._idle_task = None 27 | self._reconnect = True 28 | 29 | self._connected_event = asyncio.Event() 30 | self._connected_event.clear() 31 | 32 | self._log = logging.getLogger(f"{__name__}.{self._nickname}") 33 | 34 | def on_nicknameinuse(self, client, event): 35 | # First iteration, try adding a [d] (Discord, get it?). 36 | if self._nickname_iteration == 0: 37 | self._nickname = f"{self._nickname_original}[d]" 38 | self._nickname_iteration += 1 39 | client.nick(self._nickname) 40 | return 41 | 42 | # [d] is already in use, try adding a [1], [2], .. 43 | self._nickname = f"{self._nickname_original}[{self._nickname_iteration}]" 44 | self._nickname_iteration += 1 45 | client.nick(self._nickname) 46 | 47 | def on_welcome(self, client, event): 48 | self._client = client 49 | self._client.join(self._channel) 50 | 51 | if self._pinger_task: 52 | self._pinger_task.cancel() 53 | self._pinger_task = asyncio.create_task(self._pinger()) 54 | 55 | def on_privmsg(self, _, event): 56 | # TODO -- Consider relaying private messages too. Can be useful to identify with NickServ etc. 57 | pass 58 | 59 | # on_pubmsg is done by the IRCRelay, and not by the puppets. 60 | 61 | def on_join(self, _client, event): 62 | if event.target != self._channel: 63 | return 64 | 65 | if event.source.nick == self._nickname: 66 | self._log.info("Joined %s on IRC", self._channel) 67 | self._joined = True 68 | self._connected_event.set() 69 | 70 | def on_part(self, _client, event): 71 | if event.target != self._channel: 72 | return 73 | self._left(event.source.nick) 74 | 75 | def on_kick(self, _client, event): 76 | if event.target != self._channel: 77 | return 78 | self._left(event.arguments[0]) 79 | 80 | def on_kill(self, _client, event): 81 | # If a user is killed, the ops on IRC must have a good reason. 82 | # So we disconnect the bridge on our side too. 83 | self._log.info("Killed by server; removing puppet") 84 | 85 | self._reconnect = False 86 | asyncio.create_task(self._remove_puppet_func()) 87 | 88 | def on_nick(self, client, event): 89 | if event.source.nick == self._nickname: 90 | # Sometimes happens on a netsplit, or when a username is GHOSTed. 91 | # Most of the time the name is now something like Guest12345. 92 | # Try changing back to a name more in line with the user-name. 93 | self._log.info("Nickname changed to '%s' by server; trying to change it back", event.target) 94 | self._nickname = event.target 95 | asyncio.create_task(self.reclaim_nick()) 96 | 97 | def on_disconnect(self, _client, event): 98 | self._log.warning("Disconnected from IRC") 99 | self._joined = False 100 | self._connected_event.clear() 101 | if self._pinger_task: 102 | self._pinger_task.cancel() 103 | 104 | if self._reconnect: 105 | # Start a task to reconnect us. 106 | asyncio.create_task(self.connect()) 107 | 108 | def _left(self, nick): 109 | # If we left the channel, rejoin. 110 | if nick == self._nickname: 111 | self._joined = False 112 | self._connected_event.clear() 113 | self._client.join(self._channel) 114 | return 115 | 116 | async def _pinger(self): 117 | while True: 118 | await asyncio.sleep(120) 119 | self._client.ping("keep-alive") 120 | 121 | async def _idle_timeout_task(self): 122 | await asyncio.sleep(self._idle_timeout) 123 | 124 | self._reconnect = False 125 | self._client.disconnect("User went offline on Discord a while ago") 126 | await self._remove_puppet_func() 127 | 128 | async def reclaim_nick(self): 129 | # We sleep for a second, as it turns out, if we are quick enough to change 130 | # our name back, we win from people trying to reclaim their nick. Not the 131 | # nicest thing to do. 132 | await asyncio.sleep(1) 133 | 134 | self._nickname = self._nickname_original 135 | self._nickname_iteration = 0 136 | self._client.nick(self._nickname) 137 | 138 | async def connect(self): 139 | local_addr = (str(self._ipv6_address), 0) 140 | use_ssl = self._irc_port == 6697 141 | 142 | while self._reconnect: 143 | # As per RFC, getaddrinfo() sorts IPv6 results in some complicated way. 144 | # In result, even if the IRC host has multiple IPv6 addresses listed, 145 | # we will pick almost always the same one. This gives unneeded pressure 146 | # on a single host, instead of distributing the load. So instead, we do 147 | # the lookup ourselves, and pick a random one. 148 | try: 149 | ipv6s = await self.loop.getaddrinfo( 150 | self._irc_host, 151 | None, 152 | family=socket.AF_INET6, 153 | type=socket.SOCK_STREAM, 154 | proto=socket.IPPROTO_TCP, 155 | ) 156 | except socket.gaierror: 157 | ipv6s = [] 158 | 159 | if not ipv6s: 160 | self._log.warning("Failed DNS lookup, retrying in 5 seconds") 161 | # When we can't connect, try again in 5 seconds. 162 | await asyncio.sleep(5) 163 | continue 164 | 165 | irc_host_ipv6 = random.choice(ipv6s)[4][0] 166 | 167 | self._log.info( 168 | "Connecting to IRC from %s to %s (%s) ...", self._ipv6_address, self._irc_host, irc_host_ipv6 169 | ) 170 | 171 | try: 172 | await self.connection.connect( 173 | irc_host_ipv6, 174 | self._irc_port, 175 | self._nickname, 176 | username=self._username, 177 | # We force an IPv6 connection, as we need that for the puppet source address. 178 | connect_factory=irc.connection.AioFactory( 179 | family=socket.AF_INET6, 180 | local_addr=local_addr, 181 | ssl=use_ssl, 182 | server_hostname=self._irc_host if use_ssl else None, 183 | ), 184 | ) 185 | break 186 | except ConnectionRefusedError: 187 | self._log.warning("Connection refused, retrying in 5 seconds") 188 | # When we can't connect, try again in 5 seconds. 189 | await asyncio.sleep(5) 190 | 191 | async def start_idle_timeout(self): 192 | await self.stop_idle_timeout() 193 | self._idle_task = asyncio.create_task(self._idle_timeout_task()) 194 | 195 | async def stop_idle_timeout(self): 196 | if not self._idle_task: 197 | return 198 | 199 | self._idle_task.cancel() 200 | self._idle_task = None 201 | 202 | async def _reset_idle_timeout(self): 203 | if not self._idle_task: 204 | return 205 | 206 | # User is talking while appearing offline. Constantly reset the idle timeout. 207 | await self.stop_idle_timeout() 208 | await self.start_idle_timeout() 209 | 210 | def is_offline(self): 211 | return self._idle_task is not None 212 | 213 | async def send_message(self, content): 214 | await self._reset_idle_timeout() 215 | 216 | await self._connected_event.wait() 217 | self._client.privmsg(self._channel, content) 218 | 219 | async def send_action(self, content): 220 | await self._reset_idle_timeout() 221 | 222 | await self._connected_event.wait() 223 | self._client.action(self._channel, content) 224 | -------------------------------------------------------------------------------- /dibridge/discord.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | import logging 4 | import re 5 | import sys 6 | import textwrap 7 | 8 | from . import relay 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | # The maximum length of a message on IRC. This is different per network, 13 | # but 400 seems like a safe value for most modern IRC networks. 14 | IRC_MAX_LINE_LENGTH = 400 15 | 16 | 17 | class RelayDiscord(discord.Client): 18 | def __init__(self, channel_id): 19 | # We need many intents: 20 | # - messages, to receive messages. 21 | # - guilds, to get the channel. 22 | # - presences, to see when a user goes offline. 23 | # - members, as otherwise 'presences' doesn't work. 24 | # - message_content, as we actually want to know the message content. 25 | intents = discord.Intents(messages=True, guilds=True, presences=True, members=True, message_content=True) 26 | # Don't allow IRC users to be cheeky, and don't allow @everyone etc. 27 | allowed_mentions = discord.AllowedMentions.none() 28 | allowed_mentions.users = True 29 | super().__init__(intents=intents, allowed_mentions=allowed_mentions) 30 | 31 | self._status = None 32 | self._channel_id = channel_id 33 | self._commands = discord.app_commands.CommandTree(self) 34 | 35 | # Rebind the commands to the current client. 36 | self.command_status.binding = self 37 | 38 | # Add the commands we are listening too. 39 | self._commands.add_command(self.command_status) 40 | 41 | async def setup_hook(self): 42 | # Sync the commands, so Discord knows about them too. 43 | await self._commands.sync() 44 | 45 | async def on_ready(self): 46 | # Check if we have access to the channel. 47 | self._channel = self.get_channel(self._channel_id) 48 | if not self._channel: 49 | log.error("Discord channel ID %s not found", self._channel_id) 50 | relay.IRC.stop() 51 | sys.exit(1) 52 | 53 | # Make sure there is a webhook on the channel to use for relaying. 54 | for webhook in await self._channel.webhooks(): 55 | if webhook.token is not None: 56 | self._channel_webhook = webhook 57 | break 58 | else: 59 | self._channel_webhook = await self._channel.create_webhook( 60 | name="ircbridge", reason="To bridge IRC messages to Discord" 61 | ) 62 | 63 | if self._status: 64 | await self._update_presence(self._status) 65 | 66 | log.info("Logged on to Discord as '%s'", self.user) 67 | 68 | async def on_message(self, message): 69 | # Only monitor the indicated channel. 70 | if message.channel.id != self._channel_id: 71 | return 72 | # We don't care what bots have to say. 73 | if message.author.bot: 74 | return 75 | # We don't care if it isn't a message or a reply. 76 | if message.type not in (discord.MessageType.default, discord.MessageType.reply): 77 | return 78 | 79 | relay.IRC.update_status(message.author.id, message.author.status == discord.Status.offline) 80 | 81 | content = message.content 82 | 83 | if message.type == discord.MessageType.reply: 84 | author = message.reference.resolved.author 85 | content = f"{relay.IRC.get_irc_username(author.id, author.name)}: {content}" 86 | 87 | def replace_mention(prefix, postfix, id, name, content): 88 | identifer = f"{prefix}{id}{postfix}" 89 | 90 | # At the beginning of the line, on IRC it is custom to add a ": " behind the highlight. 91 | if ( 92 | content.startswith(f"{identifer}") 93 | and not content.startswith(f"{identifer}:") 94 | and content != f"{identifer}" 95 | ): 96 | return f"{name}: " + content[len(f"{identifer}") :].strip() 97 | 98 | # Otherwise it is just an inline replacement. 99 | return content.replace(f"{identifer}", name) 100 | 101 | # Replace all mentions in the message with the username (<@12345679>) 102 | for mention in message.mentions: 103 | content = replace_mention( 104 | "<@", ">", mention.id, relay.IRC.get_irc_username(mention.id, mention.name), content 105 | ) 106 | # Replace all channel mentions in the message with the channel name (<#123456789>). 107 | for channel in message.channel_mentions: 108 | content = replace_mention("<#", ">", channel.id, f"Discord channel #{channel.name}", content) 109 | # Replace all role mentions in the message with the role name (<@&123456789>). 110 | for role in message.role_mentions: 111 | content = replace_mention("<@&", ">", role.id, role.name, content) 112 | content = replace_mention("@", "", "everyone", "all", content) 113 | content = replace_mention("@", "", "here", "all", content) 114 | 115 | # Replace all emoji mentions in the message with the emoji name (<:emoji:123456789>). 116 | # (sadly, discord.py library doesn't have support for it) 117 | def find_emojis(content): 118 | return [{"id": id, "name": name} for name, id in re.findall(r"<:(\w+):([0-9]{15,20})>", content)] 119 | 120 | for emoji in find_emojis(content): 121 | content = replace_mention("<:", ">", f"{emoji['name']}:{emoji['id']}", f":{emoji['name']}:", content) 122 | 123 | # First, send any attachment as links. 124 | for attachment in message.attachments: 125 | relay.IRC.send_message(message.author.id, message.author.name, attachment.url) 126 | 127 | content = content.replace("\r\n", "\n").replace("\r", "\n").strip() 128 | 129 | # On Discord text between _ and _ is what IRC calls an action. 130 | # IRC has a limit on message size; if reached, make the action multi-line too. 131 | if ( 132 | content.startswith("_") 133 | and content.endswith("_") 134 | and len(content) > 2 135 | and "\n" not in content 136 | and len(content) < IRC_MAX_LINE_LENGTH 137 | ): 138 | relay.IRC.send_action(message.author.id, message.author.name, content[1:-1]) 139 | else: 140 | for full_line in content.split("\n"): 141 | # On Discord, you make code-blocks by starting and finishing with ```. 142 | # This is considered noise on IRC however. So we ignore those lines. 143 | if full_line == "```": 144 | continue 145 | 146 | # Split the message in lines of at most IRC_MAX_LINE_LENGTH characters, breaking on words. 147 | for line in textwrap.wrap(full_line.strip(), IRC_MAX_LINE_LENGTH): 148 | relay.IRC.send_message(message.author.id, message.author.name, line) 149 | 150 | async def on_presence_update(self, before, after): 151 | relay.IRC.update_status(after.id, after.status == discord.Status.offline) 152 | 153 | async def on_error(self, event, *args, **kwargs): 154 | log.exception("on_error(%s): %r / %r", event, args, kwargs) 155 | 156 | @discord.app_commands.command(name="status", description="Get the status of the bridge") 157 | async def command_status(self, interaction: discord.Interaction): 158 | status = f":green_circle: **Discord** listening in <#{self._channel_id}>\n" 159 | status += relay.IRC.get_status() 160 | 161 | await interaction.response.send_message(status, ephemeral=True) 162 | 163 | async def _send_message(self, irc_username, message): 164 | await self._channel_webhook.send( 165 | message, 166 | username=irc_username, 167 | suppress_embeds=True, 168 | avatar_url=f"https://robohash.org/${irc_username}.png?set=set4", 169 | ) 170 | 171 | async def _send_message_self(self, message): 172 | await self._channel.send(message) 173 | 174 | async def _update_presence(self, status): 175 | self._status = status 176 | await self.change_presence( 177 | activity=discord.Activity(type=discord.ActivityType.watching, name=status), 178 | status=discord.Status.online, 179 | ) 180 | 181 | async def _stop(self): 182 | sys.exit(1) 183 | 184 | # Thread safe wrapper around functions 185 | 186 | def send_message(self, irc_username, message): 187 | if self.loop == discord.utils.MISSING: 188 | log.warning(f"Can't relay message from {irc_username} to Discord: connection is down.") 189 | return 190 | 191 | asyncio.run_coroutine_threadsafe(self._send_message(irc_username, message), self.loop) 192 | 193 | def send_message_self(self, message): 194 | if self.loop == discord.utils.MISSING: 195 | log.warning("Can't relay status message to Discord: connection is down.") 196 | return 197 | 198 | asyncio.run_coroutine_threadsafe(self._send_message_self(message), self.loop) 199 | 200 | def update_presence(self, status): 201 | if self.loop == discord.utils.MISSING: 202 | log.warning(f"Can't update presence to {status}: connection is down.") 203 | return 204 | 205 | asyncio.run_coroutine_threadsafe(self._update_presence(status), self.loop) 206 | 207 | 208 | def start(token, channel_id): 209 | relay.DISCORD = RelayDiscord(channel_id) 210 | backoff = discord.backoff.ExponentialBackoff() 211 | 212 | while True: 213 | try: 214 | relay.DISCORD.run(token, log_handler=None) 215 | except Exception: 216 | retry = backoff.delay() 217 | log.exception("Discord client stopped unexpectedly; will reconnect in %.2f seconds", retry) 218 | 219 | asyncio.run(asyncio.sleep(retry)) 220 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dibridge: an Discord <-> IRC Bridge 2 | 3 | [![GitHub License](https://img.shields.io/github/license/OpenTTD/dibridge)](https://github.com/OpenTTD/dibridge/blob/main/LICENSE) 4 | 5 | Sometimes you have parts of your community that don't want to leave IRC. 6 | But other parts are active on Discord. 7 | What do you do? 8 | 9 | Bridge the two! 10 | 11 | This server logs in to both IRC and Discord, and forward messages between the two. 12 | 13 | This server is very limited, as in: it only bridges a single Discord channel with a single IRC channel. 14 | If you want to bridge multiple, you will have to run more than one server. 15 | 16 | ## TODO-list 17 | 18 | This software is currently in pre-alpha. 19 | Here is a list of things that still needs doing: 20 | 21 | - [ ] Set IRC status to away if user goes offline on Discord. 22 | - [ ] Show IRC joins if the user talked recently, left, but came back. 23 | - [ ] Investigate IRC private messages, if we can relay them to Discord and back. 24 | 25 | ## Implementation 26 | 27 | The idea behind this bridge is to be as native on Discord as on IRC. 28 | That on both sides, it is hard to notice you are not talking to a native user. 29 | 30 | For Discord, this means we use multi-presence. 31 | Every IRC user gets its own Discord user to talk to you, including its own avatar. 32 | Highlights on IRC, after you talked in the Discord channel, are converted to Discord highlights. 33 | In other words, it looks and feels like you are talking to a Disord user. 34 | 35 | For IRC, this also means we use multi-presence. 36 | Once you said something in the Discord channel, an IRC puppet is created with your name, that joins the IRC network. 37 | Highlights on Discord are converted to readable names on IRC, which you can use again to create a highlight on Discord. 38 | In other words, it looks and feels like you are talking to an IRC user. 39 | 40 | It is really important to make it feel as native as possible. 41 | This with the goal that the IRC population doesn't think strange about this, and that the Discord population can just do their thing. 42 | 43 | There are however some limitations: 44 | - Edits on Discord are not send to IRC. 45 | - Reactions on Discord are not send to IRC. 46 | - This bridges a single Discord channel to a single IRC channel, and no more. 47 | - On IRC you do not see who is online on Discord unless they said something. 48 | - On Discord you do not see who is online on IRC unless they said something. 49 | 50 | ## Usage 51 | 52 | ``` 53 | Usage: python -m dibridge [OPTIONS] 54 | 55 | Options: 56 | --sentry-dsn TEXT Sentry DSN. 57 | --sentry-environment TEXT Environment we are running in. 58 | --discord-token TEXT Discord bot token to authenticate. [required] 59 | --discord-channel-id INTEGER Discord channel ID to relay to. [required] 60 | --irc-host TEXT IRC host to connect to. [required] 61 | --irc-port INTEGER IRC SSL port to connect to. 62 | --irc-nick TEXT IRC nick to use. [required] 63 | --irc-channel TEXT IRC channel to relay to, without the first 64 | '#'. [required] 65 | --irc-puppet-ip-range TEXT An IPv6 CIDR range to use for IRC puppets. 66 | (2001:A:B:C:D::/80) 67 | --irc-puppet-postfix TEXT Postfix to add to IRC puppet nicknames 68 | (default: none). 69 | --irc-ignore-list TEXT IRC nicknames to not relay messages for (comma 70 | separated, case-insensitive). 71 | --irc-idle_timeout INTEGER IRC puppet idle timeout, in seconds (default: 72 | 2 days). 73 | -h, --help Show this message and exit. 74 | ``` 75 | 76 | You can also set environment variables instead of using the options. 77 | `DIBRIDGE_DISCORD_TOKEN` for example sets the `--discord-token`. 78 | It is strongly advised to use environment variables for secrets and tokens. 79 | 80 | ### Discord bot 81 | 82 | This application logs in as a Discord bot to get a presence on Discord. 83 | You have to create this bot yourself, by going to https://discord.com/developers/ and registering one. 84 | The Discord token can be found under `Bot`. 85 | 86 | Additionally, the bot uses the following intents: 87 | - `messages`: to read messages. 88 | - `guilds`: to read channel information. 89 | - `presences`: to know when a user goes offline. 90 | - `members`: to read member information. 91 | - `message_content`: to read message content. 92 | 93 | Some of these intents need additional permission on the bot's side, under `Privileged Gateway Intents`. 94 | Without those, this application will fail to start. 95 | 96 | After creating a bot, you need to invite this bot to your Discord channel. 97 | If you are not the owner of that channel, you would need to make the bot `Public` before the admin can add it. 98 | The bot needs at least `Send Messages`, `Read Messages` and `Manage Webhooks` permissions on the Discord server channel. 99 | These permissions need to be assigned on the Discord server itself, by someone with sufficient permissions to do so. 100 | 101 | ### IRC Puppet IP Range 102 | 103 | The more complicated setting in this row is `--irc-puppet-ip-range`, and needs some explaining. 104 | 105 | Without this setting, the bridge will join the IRC channel with a single user, and relays all messages via that single user. 106 | This means it sends things like: ` hi`. 107 | The problem with this is, that it isn't really giving this native IRC feel. 108 | Neither can you do `us` to quickly send a message to the username. 109 | 110 | A much better way is to join the IRC channel with a user for every person talking on Discord. 111 | But as most IRC networks do not allow connecting with multiple users from the same IP address (most networks allow 3 before blocking the 4th), we need a bit of a trick. 112 | 113 | `--irc-puppet-ip-range` defines a range of IP address to use. 114 | For every user talking on Discord, the bridge creates a new connection to the IRC channel with a unique IP address for that user from this range. 115 | 116 | In order for this to work, you do need to setup a few things. 117 | First of all, you need Linux 4.3+ for this to work. 118 | Next, you need to have an IPv6 prefix, of which you can delegate a part to this bridge. 119 | 120 | All decent ISPs these days can assign you an IPv6 prefix, mostly a `/64` or better. 121 | We only need a `/80` for this (or at least a `/96`), so that is fine. 122 | Similar, cloud providers also offer assigning IPv6 prefixes to VMs. 123 | For example [AWS allows you to assign a `/80` to a single VM](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-prefix-eni.html). 124 | 125 | Next, you need to make sure that this prefix is forwarded to the machine you are hosting the bridge on. 126 | For example: 127 | ```bash 128 | ip route add local ${prefix} dev eth0 129 | ip addr add local ${prefix} dev eth0 130 | ``` 131 | 132 | Where `${prefix}` is something like `2001:db8::/80`. 133 | Please use a part of the prefix assigned by your ISP, and not this example. 134 | 135 | Next, we need to tell the kernel to allow us to bind to IP addresses that are not local: 136 | ```bash 137 | sysctl -w net.ipv6.ip_nonlocal_bind=1 138 | ``` 139 | 140 | And that is it. 141 | Now we can call this bridge with, for example, `--irc-puppet-ip-range 2001:db8::/80`. 142 | IRC puppets will now use an IP in that range. 143 | 144 | And don't worry, the same Discord user will always get the same IPv6 (given the range stays the same). 145 | So if they get banned on IRC, they are done. 146 | 147 | ## Development 148 | 149 | ```bash 150 | python3 -m venv .env 151 | .env/bin/pip install -r requirements.txt 152 | .env/bin/python -m dibridge --help 153 | ``` 154 | 155 | ### IRC server 156 | 157 | To run a local IRC server to test with, one could do that with the following Docker statement: 158 | 159 | ```bash 160 | docker run --rm --name irc --net=host -p 6667:6667 hatamiarash7/irc-server --nofork --debug 161 | ``` 162 | 163 | The `--net=host` is useful in case you want to work with IRC Puppets. 164 | For example, one could add a local route for some random IPv6 addresses, and tell this bridge to use that to connect to the IRC server. 165 | A typical way of doing this would be: 166 | 167 | ```bash 168 | sysctl -w net.ipv6.ip_nonlocal_bind=1 169 | ip route add local 2001:db8:100::/80 dev lo 170 | ``` 171 | 172 | (don't forget to use as `--irc-host` something that also resolves to a local IPv6, like `localhost`) 173 | 174 | By default, the `oper` is named `dusty` with as password `IAmDusty`. 175 | 176 | ### Discord bot 177 | 178 | To connect to Discord, one could register their own Discord bot, invite it to a private server, and create a dedicated channel for testing. 179 | 180 | ## Why yet-another-bridge 181 | 182 | OpenTTD has been using IRC ever since the project started. 183 | As such, many old-timers really like being there, everyone mostly knows each other, etc. 184 | 185 | On the other hand, it isn't the most friendly platform to great new players with questions, to share screenshots, etc. 186 | Discord does deliver that, but that means the community is split in two. 187 | 188 | So, we needed to bridge that gap. 189 | 190 | Now there are several ways to go about this. 191 | 192 | First, one can just close IRC and say: go to Discord. 193 | This is not the most popular choice, as a few people would rather die on the sword than switch. 194 | And as OpenTTD, we like to be inclusive. 195 | So not an option. 196 | 197 | Second, we can bridge IRC and Discord, so we can read on Discord what happens on IRC, and participate without actually opening an IRC client. 198 | This is a much better option. 199 | 200 | Now there are a few projects that already do this. 201 | For example: 202 | - https://github.com/qaisjp/go-discord-irc 203 | - https://github.com/42wim/matterbridge 204 | - https://github.com/reactiflux/discord-irc 205 | 206 | Sadly, most of those only support a single presence on IRC. 207 | This is for our use-case rather annoying, as it makes it much more obvious that things are bridged. 208 | As people on IRC can be grumpy, they will not take kind of that. 209 | Additionally, things like user-highlighting etc won't work. 210 | 211 | The first one on the list does support it, but in such way that is impractical: every user on Discord gets an IRC puppet. 212 | That would be thousands of outgoing IRC connections. 213 | 214 | For example Matrix does do this properly: when you join the channel explicitly, it creates an IRC puppet. 215 | 216 | So, we needed something "in between". 217 | And that is what this repository delivers. 218 | 219 | Codewise, thanks to the awesome [irc](https://github.com/jaraco/irc) and [discord.py](https://github.com/Rapptz/discord.py), it is relative trivial. 220 | A bit ironic that the oldest of the two (IRC), is the hardest to implement. 221 | -------------------------------------------------------------------------------- /dibridge/irc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import irc.client_aio 3 | import functools 4 | import hashlib 5 | import logging 6 | import re 7 | import sys 8 | import time 9 | 10 | from openttd_helpers.asyncio_helper import enable_strong_referenced_tasks 11 | 12 | from .irc_puppet import IRCPuppet 13 | from . import relay 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | # When a user on IRC was talking but left within 10 minutes, announce 18 | # this on Discord. This to prevent Discord users thinking they can still 19 | # talk to someone if they are in an active conversation with them. 20 | LEFT_WHILE_TALKING_TIMEOUT = 60 * 10 21 | 22 | # By RFC, only these characters are allowed in a nickname. 23 | REGEX_NICKNAME_FILTER = r"[^a-zA-Z0-9_\-\[\]\{\}\|]" 24 | # By RFC, a nickname cannot start with a number or a dash. 25 | REGEX_NICKNAME_START_FILTER = r"^[0-9\-]+" 26 | # By implementation, a username is more strict than a nickname in what 27 | # it can start with. This filter is in addition to the nickname filters. 28 | REGEX_USERNAME_START_FILTER = r"^[_\[\]\{\}\|]+" 29 | 30 | 31 | class IRCRelay(irc.client_aio.AioSimpleIRCClient): 32 | def __init__(self, host, port, nickname, channel, puppet_ip_range, puppet_postfix, ignore_list, idle_timeout): 33 | irc.client.SimpleIRCClient.__init__(self) 34 | 35 | self._loop = asyncio.get_event_loop() 36 | 37 | self._host = host 38 | self._port = port 39 | self._nickname = nickname 40 | self._nickname_original = nickname 41 | self._nickname_iteration = 0 42 | self._joined = False 43 | self._tell_once = True 44 | self._channel = channel 45 | self._puppet_ip_range = puppet_ip_range 46 | self._puppet_postfix = puppet_postfix 47 | self._pinger_task = None 48 | self._ignore_list = ignore_list 49 | self._idle_timeout = idle_timeout 50 | 51 | # List of users when they have last spoken. 52 | self._users_spoken = {} 53 | 54 | self._puppets = {} 55 | 56 | def on_nicknameinuse(self, client, event): 57 | # Nickname is already in use, start adding numbers at the end to fix that. 58 | self._nickname_iteration += 1 59 | self._nickname = f"{self._nickname_original}[{self._nickname_iteration}]" 60 | client.nick(self._nickname) 61 | 62 | def on_welcome(self, client, event): 63 | self._client = client 64 | self._client.join(self._channel) 65 | 66 | if self._pinger_task: 67 | self._pinger_task.cancel() 68 | self._pinger_task = asyncio.create_task(self._pinger()) 69 | 70 | def on_privmsg(self, _, event): 71 | # TODO -- Consider relaying private messages too. Can be useful to identify with NickServ etc. 72 | pass 73 | 74 | def on_pubmsg(self, _, event): 75 | if event.target != self._channel: 76 | return 77 | if event.source.nick.lower() in self._ignore_list: 78 | return 79 | asyncio.create_task(self._relay_mesage(event.source.nick, event.arguments[0])) 80 | 81 | def on_action(self, _, event): 82 | if event.target != self._channel: 83 | return 84 | asyncio.create_task(self._relay_mesage(event.source.nick, f"_{event.arguments[0]}_")) 85 | 86 | def on_join(self, _client, event): 87 | if event.target != self._channel: 88 | return 89 | 90 | if event.source.nick == self._nickname: 91 | if not self._tell_once: 92 | relay.DISCORD.send_message_self(":white_check_mark: IRC bridge is now active :white_check_mark: ") 93 | log.info("Joined %s on IRC", self._channel) 94 | self._joined = True 95 | self._tell_once = True 96 | 97 | relay.DISCORD.update_presence(f"{self._channel} on IRC") 98 | 99 | def on_part(self, _client, event): 100 | if event.target != self._channel: 101 | return 102 | self._left(event.source.nick) 103 | 104 | def on_kick(self, _client, event): 105 | if event.target != self._channel: 106 | return 107 | self._left(event.arguments[0]) 108 | 109 | def on_quit(self, _client, event): 110 | self._left(event.source.nick) 111 | 112 | def on_disconnect(self, _client, event): 113 | log.error("Disconnected from IRC") 114 | self._joined = False 115 | if self._pinger_task: 116 | self._pinger_task.cancel() 117 | 118 | # Start a task to reconnect us. 119 | asyncio.create_task(self._connect()) 120 | 121 | def _left(self, nick): 122 | # If we left the channel, rejoin. 123 | if nick == self._nickname: 124 | self._joined = False 125 | self._client.join(self._channel) 126 | return 127 | 128 | # If the user spoken recently, show on Discord the user left. 129 | if self._users_spoken.get(nick, 0) > time.time() - LEFT_WHILE_TALKING_TIMEOUT: 130 | self._users_spoken.pop(nick) 131 | relay.DISCORD.send_message(nick, "_left the IRC channel_") 132 | 133 | async def _pinger(self): 134 | while True: 135 | await asyncio.sleep(120) 136 | self._client.ping("keep-alive") 137 | 138 | async def _connect(self): 139 | while True: 140 | # Additional constraints usernames have over nicknames. 141 | username = re.sub(REGEX_USERNAME_START_FILTER, "", self._nickname) 142 | 143 | try: 144 | await self.connection.connect( 145 | self._host, 146 | self._port, 147 | self._nickname, 148 | username=username, 149 | connect_factory=irc.connection.AioFactory(ssl=self._port == 6697), 150 | ) 151 | break 152 | except ConnectionRefusedError: 153 | log.warning("Connection refused, retrying in 5 seconds") 154 | # When we can't connect, try again in 5 seconds. 155 | await asyncio.sleep(5) 156 | 157 | async def _send_message(self, discord_id, discord_username, message, is_action=False): 158 | # If we aren't connected to IRC yet, tell this to the Discord users; but only once. 159 | if not self._joined: 160 | if self._tell_once: 161 | self._tell_once = False 162 | relay.DISCORD.send_message_self( 163 | ":warning: IRC bridge isn't active; messages will not be delivered :warning:" 164 | ) 165 | return 166 | 167 | if not self._puppet_ip_range: 168 | if is_action: 169 | message = f"/me {message}" 170 | self._client.privmsg(self._channel, f"<{discord_username}>: {message}") 171 | return 172 | 173 | if discord_id not in self._puppets: 174 | sanitized_discord_username = self._sanitize_discord_username(discord_username) 175 | ipv6_address = self._puppet_ip_range[self._generate_ipv6_bits(sanitized_discord_username)] 176 | 177 | irc_nickname = f"{sanitized_discord_username}{self._puppet_postfix}" 178 | irc_username = re.sub(REGEX_USERNAME_START_FILTER, "", irc_nickname) 179 | 180 | self._puppets[discord_id] = IRCPuppet( 181 | self._host, 182 | self._port, 183 | ipv6_address, 184 | irc_nickname, 185 | irc_username, 186 | self._channel, 187 | functools.partial(self._remove_puppet, discord_id), 188 | self._idle_timeout, 189 | ) 190 | asyncio.create_task(self._puppets[discord_id].connect()) 191 | 192 | if is_action: 193 | await self._puppets[discord_id].send_action(message) 194 | else: 195 | await self._puppets[discord_id].send_message(message) 196 | 197 | async def _relay_mesage(self, irc_username, message): 198 | for discord_id, puppet in self._puppets.items(): 199 | # Don't echo back talk done by our puppets. 200 | if puppet._nickname == irc_username: 201 | return 202 | 203 | # If the username is said as its own word, replace it with a Discord highlight. 204 | message = " ".join( 205 | [ 206 | ( 207 | re.sub(r"(?", part) 208 | if "://" not in part 209 | else part 210 | ) 211 | for part in message.split(" ") 212 | ] 213 | ) 214 | 215 | # On IRC, it is common to do "name: ", but on Discord you don't do that ": " part. 216 | if message.startswith(f"<@{discord_id}>: "): 217 | message = f"<@{discord_id}> " + message[len(f"<@{discord_id}>: ") :] 218 | 219 | self._users_spoken[irc_username] = time.time() 220 | relay.DISCORD.send_message(irc_username, message) 221 | 222 | def _sanitize_discord_username(self, discord_username): 223 | original_discord_username = discord_username 224 | 225 | discord_username = discord_username.strip() 226 | # Remove all characters not allowed in IRC nicknames. 227 | discord_username = re.sub(REGEX_NICKNAME_FILTER, "", discord_username) 228 | # Make sure a nicknames doesn't start with an invalid character. 229 | discord_username = re.sub(REGEX_NICKNAME_START_FILTER, "", discord_username) 230 | 231 | # On Discord you can create usernames that don't contain any character valid 232 | # on IRC, leaving an empty username. In that case we have no option but to 233 | # replace it with a default placeholder. To make sure the names are somewhat 234 | # stable over multiple runs, we use a partial of the SHA256 of the original 235 | # discord name. It is not perfect, but at least it is better than nothing. 236 | if discord_username == "": 237 | postfix = hashlib.sha256(original_discord_username.encode()).hexdigest() 238 | discord_username = f"discord_user_{postfix[0:8]}" 239 | 240 | # Make sure a username is no more than 20 character. 241 | # Depending on the IRC network, different lengths are allowed. 242 | discord_username = discord_username[:20] 243 | return discord_username 244 | 245 | def _generate_ipv6_bits(self, discord_username): 246 | # Based on the Discord username, generate N bits to add to the IPv6 address. 247 | # This way we do not have to persistently store any information, but every user 248 | # will always have the same IPv6. 249 | # For the N bits, we simply take the last N bits from the SHA-256 hash of the 250 | # username. Chances on collision are really low. 251 | # N here is the length of the IPv6 range assigned. 252 | return ( 253 | int(hashlib.sha256(discord_username.encode("utf-8")).hexdigest(), 16) % self._puppet_ip_range.num_addresses 254 | ) 255 | 256 | async def _stop(self): 257 | sys.exit(1) 258 | 259 | async def _remove_puppet(self, discord_id): 260 | self._puppets.pop(discord_id) 261 | 262 | # Thread safe wrapper around functions 263 | 264 | def get_status(self): 265 | if self._joined: 266 | status = f":green_circle: **IRC** listening on `{self._host}` in `{self._channel}`\n" 267 | else: 268 | status = ":red_circle: **IRC** not connected\n" 269 | if self._puppets: 270 | joined = len([True for puppet in self._puppets.values() if puppet._joined]) 271 | status += "\n" 272 | status += f"**{len(self._puppets)}** IRC connections, **{joined}** connected\n" 273 | return status 274 | 275 | def get_irc_username(self, discord_id, discord_username): 276 | if discord_id not in self._puppets: 277 | return self._sanitize_discord_username(discord_username) 278 | 279 | return self._puppets[discord_id]._nickname 280 | 281 | def update_status(self, discord_id, is_offline): 282 | if discord_id not in self._puppets: 283 | return 284 | 285 | if self._puppets[discord_id].is_offline() == is_offline: 286 | return 287 | 288 | if is_offline: 289 | # Start a timer to delete the puppet after timeout. 290 | asyncio.run_coroutine_threadsafe(self._puppets[discord_id].start_idle_timeout(), self._loop) 291 | else: 292 | # Stop the timer if the user comes back. 293 | asyncio.run_coroutine_threadsafe(self._puppets[discord_id].stop_idle_timeout(), self._loop) 294 | 295 | def send_message(self, discord_id, discord_username, message): 296 | asyncio.run_coroutine_threadsafe(self._send_message(discord_id, discord_username, message), self._loop) 297 | 298 | def send_action(self, discord_id, discord_username, message): 299 | asyncio.run_coroutine_threadsafe( 300 | self._send_message(discord_id, discord_username, message, is_action=True), self._loop 301 | ) 302 | 303 | def stop(self): 304 | asyncio.run_coroutine_threadsafe(self._stop(), self._loop) 305 | 306 | 307 | def start(host, port, name, channel, puppet_ip_range, puppet_postfix, ignore_list, idle_timeout): 308 | loop = asyncio.new_event_loop() 309 | asyncio.set_event_loop(loop) 310 | enable_strong_referenced_tasks(loop) 311 | 312 | relay.IRC = IRCRelay(host, port, name, channel, puppet_ip_range, puppet_postfix, ignore_list, idle_timeout) 313 | 314 | log.info("Connecting to IRC ...") 315 | asyncio.get_event_loop().run_until_complete(relay.IRC._connect()) 316 | try: 317 | relay.IRC.start() 318 | finally: 319 | relay.IRC.connection.disconnect() 320 | relay.IRC.reactor.loop.close() 321 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | --------------------------------------------------------------------------------