├── 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 |
--------------------------------------------------------------------------------