diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..896b90c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +/.env +/.git/** diff --git a/.dorpsgek.yml b/.dorpsgek.yml new file mode 100644 index 0000000..579e99e --- /dev/null +++ b/.dorpsgek.yml @@ -0,0 +1,9 @@ +notifications: + global: + irc: + - openttd + - openttd.notice + + pull-request: + issue: + tag-created: diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..a5caa0c --- /dev/null +++ b/.flake8 @@ -0,0 +1,8 @@ +[flake8] +max-line-length = 120 +inline-quotes = double + +# We use 'black' for coding style; these next two warnings are not PEP-8 +# compliant. +# E231 is a bug in 'black', see https://github.com/psf/black/issues/1202 +ignore = E203, E231, W503 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d0b6444 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + rebase-strategy: "disabled" + open-pull-requests-limit: 10 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f4842b1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + push: + branches: + - main + release: + types: + - published + +jobs: + publish_image: + name: Publish image + uses: OpenTTD/actions/.github/workflows/publish-image.yml@v3 + + # Currently this project is not deployed yet. + # deploy: + # name: Deploy + # needs: + # - publish_image + + # uses: OpenTTD/actions/.github/workflows/aws-deployment.yml@v3 + # with: + # is_staging: ${{ github.ref == 'refs/heads/main' }} + # name: Dibridge + # url_production: https://weblogs.openttd.org + # url_staging: https://weblogs-staging.openttd.org + # digest: ${{ needs.publish_image.outputs.digest }} + # version: ${{ needs.publish_image.outputs.version }} + # secrets: + # AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + # AWS_REGION: ${{ secrets.AWS_REGION }} + # AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..a8b4427 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,94 @@ +name: Testing + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + codeql: + name: Security and Quality + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Python 3.8 + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Install dependencies + run: python -m pip install -r requirements.txt + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: python + queries: security-and-quality + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + + docker: + name: Docker build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup QEMU + uses: docker/setup-qemu-action@v2 + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Build + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + + flake8: + name: Flake8 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Flake8 + uses: TrueBrain/actions-flake8@v2 + with: + path: dibridge + + black: + name: Black + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Python 3.8 + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Set up packages + run: | + python -m pip install --upgrade pip + pip install black + - name: Black + run: | + black -l 120 --check dibridge + + check_annotations: + name: Check Annotations + needs: + - docker + - flake8 + - black + # not codeql, as that reports its own status + + if: always() && github.event_name == 'pull_request' + + runs-on: ubuntu-latest + + steps: + - name: Check annotations + uses: OpenTTD/actions/annotation-check@v2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..925a3a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +/.env diff --git a/.version b/.version new file mode 100644 index 0000000..38f8e88 --- /dev/null +++ b/.version @@ -0,0 +1 @@ +dev diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a0f4389 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.10-slim + +ARG BUILD_VERSION="dev" + +# In order to install a non-release dependency, we need git. +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /code + +COPY requirements.txt \ + LICENSE \ + README.md \ + /code/ +# Needed for Sentry to know what version we are running +RUN echo "${BUILD_VERSION}" > /code/.version + +RUN pip --no-cache-dir install -U pip \ + && pip --no-cache-dir install -r requirements.txt + +# Validate that what was installed was what was expected +RUN pip freeze 2>/dev/null > requirements.installed \ + && diff -u --strip-trailing-cr requirements.txt requirements.installed 1>&2 \ + || ( echo "!! ERROR !! requirements.txt defined different packages or versions for installation" \ + && exit 1 ) 1>&2 + +COPY dibridge /code/dibridge + +ENTRYPOINT ["python", "-m", "dibridge"] +CMD [] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..80c6496 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# dibridge: an Discord <-> IRC Bridge + +Sometimes you have parts of your community that don't want to leave IRC. +But other parts are active on Discord. +What do you do? + +Bridge the two! + +This server logs in to both IRC and Discord, and forward messages between the two. + +This server is very limited, as in: it only bridges a single Discord channel with a single IRC channel. +If you want to bridge multiple, you will have to run more than one server. + +## Usage + +``` +Usage: python -m dibridge [OPTIONS] + +Options: + --sentry-dsn TEXT Sentry DSN. + --sentry-environment TEXT Environment we are running in. + --discord-token TEXT Discord bot token to authenticate [required] + --discord-channel-id INTEGER Discord channel ID to relay to [required] + --irc-host TEXT IRC host to connect to [required] + --irc-port INTEGER IRC port to connect to + --irc-nick TEXT IRC nick to use [required] + --irc-channel TEXT IRC channel to relay to [required] + -h, --help Show this message and exit. +``` + +You can also set environment variables instead of using the options. +`IRC_DISCORD_BRIDGE_DISCORD_TOKEN` for example sets the `--discord-token`. +It is strongly advised to use environment variables for secrets and tokens. + +## Development + +```bash +python3 -m venv .env +.env/bin/pip install -r requirements.txt +.env/bin/python -m dibridge --help +``` + +## Why yet-another-bridge + +OpenTTD has been using IRC ever since the project started. +As such, many old-timers really like being there, everyone mostly knows each other, etc. + +On the other hand, it isn't the most friendly platform to great new players with questions, to share screenshots, etc. +Discord does deliver that, but that means the community is split in two. + +So, we needed a bridge to .. bridge that gap. + +Now there are several ways about this. + +First, one can just close IRC and say: go to Discord. +This is not the most popular choice, as a few people would rather die on the sword than switch. +As OpenTTD, we like to be inclusive. +So not an option. + +Second, we can bridge IRC and Discord, so we can read on Discord what is going on on IRC, and participate without actually opening an IRC client. +This is a much better option. + +Now there are a few projects that already do this. +But all of them don't exactly fit our needs. + +- https://github.com/qaisjp/go-discord-irc: awesome project and rather stable. + But it has either of two modes: + - Have a single user presence on IRC relaying everything. + - Have every user on Discord present on IRC, each with their own connection. + We like the second option, but there are thousands of users on Discord. + This will not go well. + This bridge solves that issue by only creating an IRC connection once someone talks in the Discord channel that is being bridged. + That way, the amount of connections to IRC are reduced as much as possible. +- https://github.com/42wim/matterbridge: can truly connnect everything. + But that is instantly the downfall: it connects everything. + The complexity is just too high to maintain long-term. + Additionally, it uses a single user presence on IRC. +- https://github.com/reactiflux/discord-irc: not sure if it is actually still supported, but otherwise looks mature. + But, the main drawback: it doesn't use Discord's method of imitation a user, and instead uses a single user presence on Discord. + That is really annoying really quick. + +So after running out of existing options, it was time to build our own. +And this repository is a consequence of that action. + +Codewise, thanks to the awesome [irc](https://github.com/jaraco/irc) and [discord.py](https://github.com/Rapptz/discord.py), it is relative trivial. +A bit ironic that the oldest of the two (IRC), is the hardest to implement. diff --git a/dibridge/__main__.py b/dibridge/__main__.py new file mode 100644 index 0000000..9ef04a9 --- /dev/null +++ b/dibridge/__main__.py @@ -0,0 +1,36 @@ +import click +import logging +import threading + +from openttd_helpers import click_helper +from openttd_helpers.logging_helper import click_logging +from openttd_helpers.sentry_helper import click_sentry + +from . import discord +from . import irc + +log = logging.getLogger(__name__) + + +@click_helper.command() +@click_logging # Should always be on top, as it initializes the logging +@click_sentry +@click.option("--discord-token", help="Discord bot token to authenticate", required=True) +@click.option("--discord-channel-id", help="Discord channel ID to relay to", required=True, type=int) +@click.option("--irc-host", help="IRC host to connect to", required=True) +@click.option("--irc-port", help="IRC port to connect to", default=6667, type=int) +@click.option("--irc-nick", help="IRC nick to use", required=True) +@click.option("--irc-channel", help="IRC channel to relay to", required=True) +def main(discord_token, discord_channel_id, irc_host, irc_port, irc_nick, irc_channel): + thread_d = threading.Thread(target=discord.start, args=[discord_token, discord_channel_id]) + thread_i = threading.Thread(target=irc.start, args=[irc_host, irc_port, irc_nick, f"#{irc_channel}"]) + + thread_d.start() + thread_i.start() + + thread_d.join() + thread_i.join() + + +if __name__ == "__main__": + main(auto_envvar_prefix="IRC_DISCORD_BRIDGE") diff --git a/dibridge/discord.py b/dibridge/discord.py new file mode 100644 index 0000000..192fbae --- /dev/null +++ b/dibridge/discord.py @@ -0,0 +1,82 @@ +import asyncio +import discord +import logging +import sys + +from . import relay + +log = logging.getLogger(__name__) + + +class RelayDiscord(discord.Client): + def __init__(self, channel_id): + # We need many intents: + # - messages, to receive messages. + # - guilds, to get the channel. + # - presences, to see when a user goes offline. + # - members, as otherwise 'presences' doesn't work. + # - message_content, as we actually want to know the message content. + intents = discord.Intents(messages=True, guilds=True, presences=True, members=True, message_content=True) + super().__init__(intents=intents) + + self._channel_id = channel_id + + self.loop = asyncio.get_event_loop() + + async def on_ready(self): + # Check if we have access to the channel. + self._channel = self.get_channel(self._channel_id) + if not self._channel: + log.error("Discord channel ID %s not found", self._channel_id) + asyncio.run_coroutine_threadsafe(relay.IRC.stop(), relay.IRC.loop) + sys.exit(1) + + # Make sure there is a webhook on the channel to use for relaying. + if not await self._channel.webhooks(): + await self._channel.create_webhook(name="ircbridge") + self._channel_webhook = (await self._channel.webhooks())[0] + + log.info("Logged on to Discord as '%s'", self.user) + + async def send_message(self, irc_username, message): + await self._channel_webhook.send( + message, + username=irc_username, + suppress_embeds=True, + avatar_url=f"https://robohash.org/${irc_username}.png?set=set4", + ) + + async def send_message_self(self, message): + await self._channel.send(message) + + async def update_presence(self, status): + await self.change_presence( + activity=discord.Activity(type=discord.ActivityType.watching, name=status), + status=discord.Status.online, + ) + + async def on_message(self, message): + # Only monitor the indicated channel. + if message.channel.id != self._channel_id: + return + # We don't care what bots have to say. + if message.author.bot: + return + # We don't care if it isn't a normal message. + if message.type != discord.MessageType.default: + return + + asyncio.run_coroutine_threadsafe(relay.IRC.send_message(message.author.name, message.content), relay.IRC.loop) + + async def on_error(self, event, *args, **kwargs): + log.exception("on_error(%s): %r / %r", event, args, kwargs) + + async def stop(self): + sys.exit(1) + + +def start(token, channel_id): + asyncio.set_event_loop(asyncio.new_event_loop()) + + relay.DISCORD = RelayDiscord(channel_id) + relay.DISCORD.run(token) diff --git a/dibridge/irc.py b/dibridge/irc.py new file mode 100644 index 0000000..5a11c4f --- /dev/null +++ b/dibridge/irc.py @@ -0,0 +1,136 @@ +import asyncio +import irc.client_aio +import logging +import sys +import time + +from . import relay + +log = logging.getLogger(__name__) + + +class IRCRelay(irc.client_aio.AioSimpleIRCClient): + def __init__(self, host, port, nickname, channel): + irc.client.SimpleIRCClient.__init__(self) + + self.loop = asyncio.get_event_loop() + + self._nickname = nickname + self._joined = False + self._tell_once = True + self._channel = channel + + # List of users when they have last spoken. + self._users_spoken = {} + + self.connect(host, port, nickname) + + async def send_message(self, discord_username, content): + # If we aren't connected to IRC yet, tell this to the Discord users; but only once. + if not self._joined: + if self._tell_once: + self._tell_once = False + asyncio.run_coroutine_threadsafe( + relay.DISCORD.send_message_self( + ":warning: IRC bridge isn't active; messages will not be delivered :warning:" + ), + relay.DISCORD.loop, + ) + return + + self._client.privmsg(self._channel, f"<{discord_username}> {content}") + + def on_nicknameinuse(self, _, event): + log.error("Nickname already in use: %r", event) + # TODO -- Pick another name + + def on_welcome(self, client, event): + self._client = client + self._client.join(self._channel) + + def on_privmsg(self, _, event): + # TODO -- Consider relaying private messages too. Can be useful to identify with NickServ etc. + pass + + def on_pubmsg(self, _, event): + if event.target != self._channel: + return + + self._users_spoken[event.source.nick] = time.time() + + asyncio.run_coroutine_threadsafe( + relay.DISCORD.send_message(event.source.nick, event.arguments[0]), relay.DISCORD.loop + ) + + def on_action(self, _, event): + if event.target != self._channel: + return + + asyncio.run_coroutine_threadsafe( + relay.DISCORD.send_message(event.source.nick, f"_{event.arguments[0]}_"), relay.DISCORD.loop + ) + + def on_join(self, _client, event): + if event.target != self._channel: + return + + if event.source.nick == self._nickname: + if not self._tell_once: + asyncio.run_coroutine_threadsafe( + relay.DISCORD.send_message_self(":white_check_mark: IRC bridge is now active :white_check_mark: "), + relay.DISCORD.loop, + ) + + log.info("Joined %s on IRC", self._channel) + self._joined = True + self._tell_once = True + + asyncio.run_coroutine_threadsafe( + relay.DISCORD.update_presence("#openttd on IRC"), + relay.DISCORD.loop, + ) + + def on_part(self, _client, event): + if event.target != self._channel: + return + self._left(event.source.nick) + + def on_kick(self, _client, event): + if event.target != self._channel: + return + self._left(event.arguments[0]) + + def _left(self, nick): + # If we left the channel, rejoin. + if nick == self._nickname: + self._joined = False + self._client.join(self._channel) + return + + # If the user spoken recently, show on Discord the user left. + if self._users_spoken.get(nick, 0) > time.time() - 60 * 10: + self._users_spoken.pop(nick) + asyncio.run_coroutine_threadsafe( + relay.DISCORD.send_message(nick, "/me left the IRC channel"), relay.DISCORD.loop + ) + + def on_disconnect(self, _client, event): + log.error("Disconnected from IRC: %s", event.arguments[0]) + self._joined = False + # The library will reconnect us. + + async def stop(self): + sys.exit(1) + + +def start(host, port, name, channel): + asyncio.set_event_loop(asyncio.new_event_loop()) + + relay.IRC = IRCRelay(host, port, name, channel) + + log.info("Connecting to IRC ...") + try: + relay.IRC.start() + finally: + relay.IRC.connection.disconnect() + relay.IRC.reactor.loop.close() diff --git a/dibridge/relay.py b/dibridge/relay.py new file mode 100644 index 0000000..7a5735b --- /dev/null +++ b/dibridge/relay.py @@ -0,0 +1,2 @@ +DISCORD = None +IRC = None diff --git a/requirements.base b/requirements.base new file mode 100644 index 0000000..60c5b8d --- /dev/null +++ b/requirements.base @@ -0,0 +1,3 @@ +irc +discord.py +openttd-helpers diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1e6a8ec --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +aiohttp==3.7.4.post0 +async-timeout==3.0.1 +attrs==22.1.0 +certifi==2022.6.15 +chardet==4.0.0 +click==8.1.3 +discord.py @ git+https://github.com/Rapptz/discord.py@7e3b08871badb8328413a50b25ab58b84d5ab692 +idna==3.3 +irc==20.1.0 +jaraco.classes==3.2.2 +jaraco.collections==3.5.2 +jaraco.context==4.1.2 +jaraco.functools==3.5.1 +jaraco.logging==3.1.0 +jaraco.stream==3.0.3 +jaraco.text==3.8.1 +more-itertools==8.13.0 +multidict==6.0.2 +openttd-helpers==1.0.1 +pytz==2022.1 +sentry-sdk==1.9.2 +tempora==5.0.2 +typing_extensions==4.3.0 +urllib3==1.26.11 +yarl==1.8.1