├── LICENSE ├── README.md ├── telegramirc.py └── telegramirc_example.toml /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Felix Yan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | telegramirc 2 | =========== 3 | 4 | Telegram as your IRC client. 5 | 6 | Features 7 | ======== 8 | 9 | - Tailored for using Telegram as your personal IRC client. This is not a group <=> channel forwarding bridge like many other projects. 10 | - Single Telegram Bot for Multiple IRC channels (with different Telegram groups) 11 | - Always online and search your IRC history with Telegram's builtin search. 12 | - Direct messages (/msg) and action (/me) 13 | - SASL Login 14 | - Password-protected IRC channels 15 | 16 | Dependencies 17 | ============ 18 | 19 | - aiogram (Telegram library) 20 | - pydle (IRC library) 21 | - tenacity 22 | - toml 23 | 24 | Usage 25 | ===== 26 | 27 | - Ask @BotFather for a Telegram bot, save its token 28 | - Copy telegramirc_example.toml to telegramirc.toml 29 | - Fill in an initial value (for example, 0) as telegram.fallback_chatid and comment out [channel.*] 30 | - Run telegramirc with your initial configuration (python telegramirc.py) 31 | - Talk to your bot on telegram, use /chatid to get your fallback chatid 32 | - Now for each channel you want to join, create a telegram group with you and your bot. Use /chatid inside the group for the corresponding chatid in [channel.*] sections below. 33 | 34 | TODO 35 | ==== 36 | 37 | - [ ] List online users in channel 38 | - [ ] DCC File transfer and other IRC extensions 39 | -------------------------------------------------------------------------------- /telegramirc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import html 3 | import logging 4 | import pydle 5 | import re 6 | import sys 7 | import toml 8 | from aiogram import Bot, Dispatcher, Router 9 | from aiogram.client.default import DefaultBotProperties 10 | from aiogram.enums import ParseMode 11 | from aiogram.filters import Command 12 | from tenacity import retry, stop_after_attempt 13 | 14 | if len(sys.argv) > 1: 15 | config = toml.load(sys.argv[1]) 16 | else: 17 | config = toml.load("telegramirc.toml") 18 | 19 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 20 | level=logging.INFO) 21 | 22 | irc_q = asyncio.Queue() 23 | tg_q = asyncio.Queue() 24 | i2t_map = {channel: config["channel"][channel]["chatid"] for channel in config["channel"]} 25 | t2i_map = {chatid: channel for channel, chatid in i2t_map.items()} 26 | 27 | 28 | async def telegram_serve(): 29 | bot = Bot(token=config["telegram"]["token"], default=DefaultBotProperties(parse_mode=ParseMode.HTML)) 30 | router = Router() 31 | 32 | @router.message(Command('start')) 33 | async def start(message): 34 | await message.reply("I'm a bot, please don't talk to me!") 35 | 36 | @router.message(Command('chatid')) 37 | async def chatid(message): 38 | await message.reply(str(message.chat.id)) 39 | 40 | @router.message(Command('msg')) 41 | async def msg(message): 42 | _, target, msg = message.text.split(" ", 2) 43 | logging.info(f'TG DM {message.chat.id} {target}: {msg}') 44 | if message.from_user.username == config["telegram"]["allowed_username"]: 45 | await irc_q.put((target, msg)) 46 | 47 | @router.message(Command('me')) 48 | async def me(message): 49 | _, msg = message.text.split(" ", 1) 50 | logging.info(f'TG {message.chat.id} {message.from_user.username} ACTION {msg}') 51 | if message.from_user.username == config["telegram"]["allowed_username"]: 52 | await irc_q.put((t2i_map[message.chat.id], ("ACTION", msg))) 53 | 54 | @router.message() 55 | async def handler(message): 56 | logging.info(f'TG {message.chat.id} {message.from_user.username}: {message.text}') 57 | text = message.text 58 | if message.reply_to_message: 59 | reply_to_text = message.reply_to_message.text[:40] 60 | if len(message.reply_to_message.text) > 40: 61 | reply_to_text += "..." 62 | if not reply_to_text.startswith("<"): # own message 63 | reply_to_text = f"<{message.reply_to_message.from_user.username}> {reply_to_text}" 64 | text = f"\"{reply_to_text}\" <- {text}" 65 | if message.from_user.username == config["telegram"]["allowed_username"]: 66 | await irc_q.put((t2i_map[message.chat.id], text)) 67 | 68 | @retry(stop=stop_after_attempt(20), reraise=True) 69 | async def send_message_with_retry(*arg, **kwargs): 70 | await bot.send_message(*arg, **kwargs) 71 | 72 | async def queue_watch(): 73 | while True: 74 | try: 75 | fwd_msgs = {} 76 | target, msg = await tg_q.get() 77 | fwd_msgs[target] = msg 78 | await asyncio.sleep(0.01) 79 | while not tg_q.empty(): 80 | target, msg = await tg_q.get() 81 | if target in fwd_msgs: 82 | fwd_msgs[target] += "\n" + msg 83 | else: 84 | fwd_msgs[target] = msg 85 | 86 | except: 87 | logging.warning(f"TG Failed to process messages: {str(fwd_msgs)}", exc_info=True) 88 | try: 89 | await tg_q.put((config["telegram"]["fallback_chatid"], f"TG Failed to process messages: {html.escape(str(fwd_msgs))}")) 90 | except: 91 | pass 92 | 93 | else: 94 | for target in fwd_msgs: 95 | try: 96 | await send_message_with_retry(target, fwd_msgs[target]) 97 | # except MessageIsTooLong: 98 | # logging.warning(f"TG Failed to send message: {fwd_msgs[target]} to {target}, possible loop detected, disabled retrying.", exc_info=True) 99 | except: 100 | logging.warning(f"TG Failed to send message: {fwd_msgs[target]} to {target}", exc_info=True) 101 | if target == config["telegram"]["fallback_chatid"] and "TG Failed to send message" in fwd_msgs[target]: 102 | logging.warning(f"TG Failed to send fallback message.") 103 | else: 104 | try: 105 | await tg_q.put((config["telegram"]["fallback_chatid"], f"TG Failed to send message: {html.escape(fwd_msgs[target])} to {target}")) 106 | except: 107 | pass 108 | 109 | asyncio.create_task(queue_watch()) 110 | 111 | dp = Dispatcher() 112 | dp.include_router(router) 113 | await dp.start_polling(bot) 114 | 115 | 116 | class IRCClient(pydle.Client): 117 | READ_TIMEOUT = 30 118 | AUTH_FLAG = False 119 | 120 | async def on_connect(self): 121 | await super().on_connect() 122 | logging.info('IRC Connected') 123 | 124 | if "nickserv_password_first" in config["irc"]: 125 | if config["irc"]["nickserv_password_first"]: 126 | await self.message("NICKSERV", f"identify {config['irc']['password']} {config['irc']['username']}") 127 | else: 128 | await self.message("NICKSERV", f"identify {config['irc']['username']} {config['irc']['password']}") 129 | logging.info('IRC NickServ Identify Attempted') 130 | 131 | asyncio.create_task(self.queue_watch()) 132 | 133 | if "wait_for_auth" in config["irc"]: 134 | while not self.AUTH_FLAG: 135 | await asyncio.sleep(1) 136 | 137 | for channel in config["channel"]: 138 | await self.join(channel, config["channel"][channel].get("key", None)) 139 | logging.info(f'IRC Joining channel {channel}') 140 | 141 | async def on_join(self, target, user): 142 | await super().on_join(target, user) 143 | logging.info(f"IRC Joined channel: {target} - {user}") 144 | if config["irc"].get("enable_join_part", False): 145 | if target in i2t_map: 146 | await tg_q.put((i2t_map[target], f"{user} joined channel.")) 147 | else: 148 | await tg_q.put((config["telegram"]["fallback_chatid"], f"IRC {user} joined {target}.")) 149 | 150 | async def on_part(self, target, user, message): 151 | await super().on_join(target, user) 152 | logging.info(f"IRC Left channel: {target} - {user} ({message})") 153 | if config["irc"].get("enable_join_part", False): 154 | if target in i2t_map: 155 | await tg_q.put((i2t_map[target], f"{user} left channel ({message}).")) 156 | else: 157 | await tg_q.put((config["telegram"]["fallback_chatid"], f"IRC {user} left {target} ({message}).")) 158 | 159 | async def on_kick(self, target, user, by, reason): 160 | await super().on_join(target, user) 161 | logging.info(f"IRC Left channel: {target} - {user} (kicked by {by}: {reason})") 162 | if config["irc"].get("enable_join_part", False): 163 | if target in i2t_map: 164 | await tg_q.put((i2t_map[target], f"{user} left channel (kicked by {by}: {reason}).")) 165 | else: 166 | await tg_q.put((config["telegram"]["fallback_chatid"], f"IRC {user} left {target} (kicked by {by}: {reason}).")) 167 | 168 | async def on_message(self, target, by, message): 169 | await super().on_message(target, by, message) 170 | if by == self.nickname: 171 | return 172 | 173 | logging.info(f'IRC {target} {by}: {message}') 174 | 175 | message = re.sub(f'(?{by}> {html.escape(message)}")) 179 | elif target == self.nickname: 180 | await tg_q.put((config["telegram"]["fallback_chatid"], f"IRC DM <{by}> {html.escape(message)}")) 181 | else: 182 | await tg_q.put((config["telegram"]["fallback_chatid"], f"IRC {target} <{by}> {html.escape(message)}")) 183 | 184 | # Attempt to reclaim nickname 185 | if self.nickname != config["irc"]["username"]: 186 | await self.set_nickname(config["irc"]["username"]) 187 | 188 | async def on_notice(self, target, by, message): 189 | await super().on_notice(target, by, message) 190 | if by == self.nickname: 191 | return 192 | 193 | if "wait_for_auth" in config["irc"] and by.lower() == "nickserv": 194 | if config["irc"]["wait_for_auth"] in message: 195 | logging.info('IRC NickServ Identify Successful') 196 | self.AUTH_FLAG = True 197 | 198 | logging.info(f'IRC NOTICE {target} {by}: {message}') 199 | if target == self.nickname: 200 | await tg_q.put((config["telegram"]["fallback_chatid"], f"IRC NOTICE <{by}> {html.escape(message)}")) 201 | else: 202 | await tg_q.put((config["telegram"]["fallback_chatid"], f"IRC NOTICE {target} <{by}> {html.escape(message)}")) 203 | 204 | async def on_ctcp(self, by, target, what, contents): 205 | await super().on_ctcp(by, target, what, contents) 206 | if by == self.nickname: 207 | return 208 | 209 | if target in i2t_map and what == "ACTION": 210 | await tg_q.put((i2t_map[target], f"{by} {html.escape(contents if contents else '')}")) 211 | else: 212 | await tg_q.put((config["telegram"]["fallback_chatid"], f"IRC CTCP {what} {target} <{by}> {html.escape(contents if contents else '')}")) 213 | 214 | async def queue_watch(self): 215 | while True: 216 | try: 217 | name, msg = await irc_q.get() 218 | if isinstance(msg, tuple): 219 | await self.ctcp(name, *msg) 220 | else: 221 | await self.message(name, msg) 222 | except: 223 | logging.warning(f"IRC Failed to send message: {msg} to {name}", exc_info=True) 224 | try: 225 | await tg_q.put((config["telegram"]["fallback_chatid"], f"IRC Failed to send message: {html.escape(msg)} to {name}")) 226 | except: 227 | pass 228 | 229 | 230 | async def irc_serve(): 231 | if config["irc"].get("password", None): 232 | client = IRCClient( 233 | config["irc"]["username"], 234 | sasl_username=config["irc"]["username"], 235 | sasl_password=config["irc"]["password"], 236 | sasl_identity=config["irc"]["username"] 237 | ) 238 | else: 239 | client = IRCClient(config["irc"]["username"]) 240 | 241 | await client.connect(config["irc"]["server"], tls=True, tls_verify=True) 242 | 243 | 244 | async def main(): 245 | await irc_serve() 246 | await telegram_serve() 247 | 248 | 249 | if __name__ == '__main__': 250 | try: 251 | asyncio.run(main()) 252 | except KeyboardInterrupt: 253 | pass 254 | -------------------------------------------------------------------------------- /telegramirc_example.toml: -------------------------------------------------------------------------------- 1 | # Use /chatid in the chat window to obtain chat id. 2 | # fallback_chatid should be set to the private chat between you and your bot. 3 | 4 | [telegram] 5 | token = 'your:bot-token' 6 | allowed_username = 'your-telegram-username' 7 | fallback_chatid = chatid-of-you-and-your-bot 8 | 9 | [irc] 10 | server = 'chat.freenode.net' 11 | username = 'irc-username' 12 | # Don't set password if you didn't register 13 | password = 'irc-password' 14 | # Set this for OFTC. Comment out to skip sending auth msg to nickserv 15 | # nickserv_password_first = true 16 | enable_join_part = false 17 | 18 | [channel."#channel-name"] 19 | chatid = corresponding-telegram-chatid 20 | 21 | [channel."#a-password-protected-channel"] 22 | chatid = corresponding-telegram-chatid 23 | key = "irc-channel-key" 24 | --------------------------------------------------------------------------------