├── .gitignore ├── requirements.txt ├── run.py ├── README.md ├── devsechan ├── avatar.py ├── __init__.py ├── irc.py └── discord.py ├── config.yaml.example ├── LICENSE └── .github └── workflows └── codeql-analysis.yml /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .env/ 3 | config.yaml -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | confuse 2 | bottom 3 | discord -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from devsechan import DevSEChan 2 | 3 | if __name__ == '__main__': 4 | bot = DevSEChan() 5 | bot.run() 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # devse-chan 2 | 3 | Bot used on DevSE's Discord and IRC channel 4 | 5 | ## DevSE 6 | 7 | - Site: https://devse.wiki 8 | - Discord: https://discord.com/invite/3XjkM6q 9 | - Libera.chat: [#devse](https://web.libera.chat/?#devse) 10 | -------------------------------------------------------------------------------- /devsechan/avatar.py: -------------------------------------------------------------------------------- 1 | def nick_to_hexcolor(nick): 2 | hash = 0 3 | for c in nick: 4 | hash = ord(c) + ((hash << 5) - hash) 5 | color = '' 6 | for i in range(3): 7 | val = (hash >> (i * 8)) & 0xFF 8 | color += ('00' + hex(val)[2:])[-2:] 9 | return color 10 | 11 | def gen_avatar_from_nick(nick): 12 | return f"https://eu.ui-avatars.com/api/?background={nick_to_hexcolor(nick)}&name={nick}" 13 | -------------------------------------------------------------------------------- /config.yaml.example: -------------------------------------------------------------------------------- 1 | discord: 2 | token: secret 3 | channel: chan_id 4 | channel-log: chan_id 5 | channel-welcome: chan_id 6 | webhook: http://secret/ 7 | messages: 8 | welcome: "`${date}_$time`: Bonjour **$name** !" 9 | goodbye: "`${date}_$time`: **$name** nous a quitté :^(" 10 | 11 | irc: 12 | nick: devse-chan 13 | username: devse 14 | host: irc.libera.chat 15 | port: 6667 16 | ssl: false 17 | channel: '#devse' 18 | -------------------------------------------------------------------------------- /devsechan/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | import confuse 4 | import re 5 | from devsechan.irc import IRC 6 | from devsechan.discord import Discord 7 | 8 | 9 | class DevSEChan: 10 | 11 | def __init__(self): 12 | self.config = confuse.Configuration('devsechan') 13 | self.loop = asyncio.get_event_loop() 14 | self.irc = IRC(self, self.config['irc']) 15 | self.discord = Discord(self, self.config['discord']) 16 | 17 | async def to_discord(self, nick, message): 18 | await self.discord.send(nick, message) 19 | 20 | def to_irc(self, author, msg_list): 21 | for msg in msg_list: 22 | if len(msg) > 0: 23 | time.sleep(1) 24 | self.irc.send(author, msg) 25 | time.sleep(2) 26 | 27 | def run(self): 28 | self.loop.create_task(self.irc.start()) 29 | self.loop.create_task(self.discord.start()) 30 | self.loop.run_forever() 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, d0p1 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /devsechan/irc.py: -------------------------------------------------------------------------------- 1 | from re import M 2 | import bottom 3 | import asyncio 4 | import platform 5 | 6 | 7 | class IRC: 8 | 9 | def __init__(self, parent, config): 10 | self.config = config 11 | self.irc = bottom.Client(host=config['host'].get(), port=config['port'].get(), ssl=config['ssl'].get()) 12 | 13 | @self.irc.on('CLIENT_CONNECT') 14 | async def connect(**kwargs): 15 | self.irc.send('NICK', nick=config['nick'].get()) 16 | self.irc.send('USER', user=config['username'].get(), realname='https://devse.wiki/') 17 | 18 | done, pending = await asyncio.wait( 19 | [self.irc.wait('RPL_ENDOFMOTD'), self.irc.wait('ERR_NOMOTD')], 20 | return_when=asyncio.FIRST_COMPLETED) 21 | for future in pending: 22 | future.cancel() 23 | 24 | # FIXME: maybe a cleaner way to do this with confuse (maybe I'll just drop confuse) 25 | try: 26 | self.irc.send('PRIVMSG', target="nickserv", message=f"IDENTIFY {config['nickserv'].get()}") 27 | except BaseException: 28 | pass 29 | self.irc.send('JOIN', channel=config['channel'].get()) 30 | 31 | @self.irc.on('privmsg') 32 | async def irc_message(nick, target, message, **kwargs): 33 | if nick == config['nick'].get(): 34 | return 35 | if target == config['nick'].get(): 36 | if message == '\001VERSION\001': 37 | def gnuify(x): return 'GNU/Linux' if x == 'Linux' else x 38 | self.irc.send( 39 | 'NOTICE', 40 | target=nick, 41 | message=f"\001VERSION devse-chan on {gnuify(platform.system())}\001") 42 | elif message == '\001SOURCE\001': 43 | self.irc.send( 44 | 'NOTICE', 45 | target=nick, 46 | message='\001SOURCE https://github.com/d0p1s4m4/devse-chan\001') 47 | elif target != config['channel'].get(): 48 | return 49 | await parent.to_discord(nick, message) 50 | 51 | @self.irc.on('PING') 52 | async def irc_ping(message, **kwargs): 53 | self.irc.send('PONG', message=message) 54 | 55 | def send(self, nick, message): 56 | self.irc.send('PRIVMSG', target=self.config['channel'].get(), message=f"<\x036{nick}\x0F> {message}") 57 | 58 | async def start(self): 59 | return await self.irc.connect() 60 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '34 11 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /devsechan/discord.py: -------------------------------------------------------------------------------- 1 | from time import strftime 2 | import discord 3 | from discord import Webhook, AsyncWebhookAdapter 4 | from string import Template 5 | from datetime import datetime 6 | from devsechan import avatar 7 | import aiohttp 8 | import re 9 | 10 | 11 | class Discord: 12 | 13 | def __init__(self, parent, config): 14 | self.config = config 15 | 16 | intents = discord.Intents.default() 17 | intents.members = True 18 | intents.message_content = True 19 | self.bot = discord.Client(intents=intents) 20 | self.log_channel = None 21 | self.welcome_channel = None 22 | self.guild = None 23 | 24 | @self.bot.event 25 | async def on_message(message): 26 | if message.author == self.bot.user: 27 | return 28 | if message.webhook_id: 29 | return 30 | if message.channel.id != self.config['channel'].get(): 31 | return 32 | parent.to_irc(message.author, 33 | self.__format_message_for_irc(message)) 34 | 35 | def __dump_message_data(message): 36 | data = [] 37 | 38 | if len(message.clean_content) > 0: 39 | data.append( 40 | f" {message.clean_content.replace('```', '´´´')}") 41 | 42 | for attachment in message.attachments: 43 | data.append(f" {attachment.url}") 44 | 45 | return data 46 | 47 | @self.bot.event 48 | async def on_message_edit(before, after): 49 | # ignore webhook 50 | if before.webhook_id: 51 | return 52 | data = ["```markdown", "# Message Edited", 53 | f"[{before.created_at}](#{before.channel})", 54 | f"< {before.author} >", ""] 55 | 56 | data += __dump_message_data(before) 57 | data += ["", ""] 58 | data += __dump_message_data(after) 59 | data.append("```") 60 | 61 | await self.log_channel.send("\n".join(data)) 62 | 63 | @self.bot.event 64 | async def on_message_delete(message): 65 | if message.channel == self.log_channel: 66 | return 67 | 68 | data = ["```markdown", "# Message Deleted", 69 | f"[{message.created_at}](#{message.channel})", 70 | f"< {message.author} >"] 71 | 72 | data += __dump_message_data(message) 73 | data.append("```") 74 | 75 | await self.log_channel.send("\n".join(data)) 76 | 77 | @self.bot.event 78 | async def on_member_join(member): 79 | msg = Template(self.config['messages']['welcome'].get()) 80 | now = datetime.utcnow() 81 | m = msg.substitute(name=member.display_name, 82 | time=now.strftime("%H:%M:%S UTC"), 83 | date=now.strftime("%Y-%m-%d")) 84 | await self.welcome_channel.send(m) 85 | 86 | @self.bot.event 87 | async def on_member_remove(member): 88 | msg = Template(self.config['messages']['goodbye'].get()) 89 | now = datetime.utcnow() 90 | m = msg.substitute(name=member.display_name, 91 | time=now.strftime("%H:%M:%S UTC"), 92 | date=now.strftime("%Y-%m-%d")) 93 | await self.welcome_channel.send(m) 94 | 95 | @ self.bot.event 96 | async def on_ready(): 97 | channel = self.bot.get_channel(self.config['channel'].get()) 98 | self.guild = channel.guild 99 | self.welcome_channel = self.bot.get_channel( 100 | self.config['channel-welcome'].get()) 101 | self.log_channel = self.bot.get_channel( 102 | self.config['channel-log'].get()) 103 | 104 | async def send(self, nick, message): 105 | avatar_url = None 106 | member = self.__member_from_nick(nick) 107 | if member is not None: 108 | avatar_url = member.avatar_url 109 | if avatar_url is None: 110 | avatar_url = avatar.gen_avatar_from_nick(nick) 111 | message = self.__format_message_for_discord(message) 112 | async with aiohttp.ClientSession() as session: 113 | webhook = Webhook.from_url( 114 | self.config['webhook'].get(), 115 | adapter=AsyncWebhookAdapter(session)) 116 | await webhook.send(message, username=nick, avatar_url=avatar_url) 117 | 118 | def __member_from_nick(self, nick): 119 | if self.guild is None: 120 | return None 121 | return self.guild.get_member_named(nick) 122 | 123 | def __format_message_for_discord(self, message): 124 | message = self.__convert_irc_mentions_to_discord(message) 125 | return message 126 | 127 | def __convert_irc_mentions_to_discord(self, message): 128 | mentions_regex = r"(?<=@)[a-zA-Z0-9]*" 129 | mentions_match = re.finditer(mentions_regex, message, re.MULTILINE) 130 | 131 | for mention in mentions_match: 132 | nick = mention.group() 133 | member = self.__member_from_nick(nick) 134 | if member is not None: 135 | message = message.replace('@' + nickname, f"<@{member.id}>") 136 | return message 137 | 138 | def __format_message_for_irc(self, message): 139 | content = message.content 140 | for user in message.mentions: 141 | substitute = f"@{user.name}#{user.discriminator}" 142 | content = content.replace(f"<@!{user.id}>", substitute) 143 | content = content.replace(f"<@{user.id}>", substitute) 144 | for channel in message.channel_mentions: 145 | content = content.replace(f"<#{channel.id}>", f"#{channel.name}") 146 | msg_list = content.split('\n') 147 | attachments_count = len(message.attachments) 148 | for i in range(attachments_count): 149 | msg_list.append(message.attachments[i].url) 150 | return msg_list 151 | 152 | async def start(self): 153 | return await self.bot.start(self.config['token'].get()) 154 | --------------------------------------------------------------------------------