patch 89bc0957a4fb31bf8066e338bc4548cd2d52c821 Author: E. Bosch Date: Sat Nov 2 20:20:45 CET 2024 * telegram, irc: Set topic in IRC with Telegram channel/chat description patch 86ef89f9f9220ecde8477db2aa673f94f32656ab Author: E. Bosch Date: Sat Oct 26 20:43:28 CEST 2024 * telegram: Fix op and founder detection in channels patch 6d99fce9643fbdda1594f99331f4a4642ecc7f0f Author: E. Bosch Date: Sat Oct 26 20:35:11 CEST 2024 * telegram: Fix handler for next reactions when the event is empty patch efaf8df4b3fa314997615e2fc07a3decb3a7cfba Author: E. Bosch Date: Sat Oct 26 13:01:02 CEST 2024 * exclam: Reorder handler list so commands are listed ordered in help patch 0cfc7e59b24fb1a1b279fc593f8d04d0648e3880 Author: E. Bosch Date: Mon Oct 21 00:54:16 CEST 2024 * README update patch abf1d31ddcf3cddd55844900065a3c3dd6bf9c67 Author: E. Bosch Date: Sun Oct 20 02:32:44 CEST 2024 * exclam: Add "-" parameter to "!react" to remove a reaction patch 5c1687a16a1e49af7b5f70e9449c447bbf7c9df9 Author: E. Bosch Date: Sat Oct 19 22:56:31 CEST 2024 * telegram: On emoticon->emoji conversions for reactions, when several emoticons can be mapped to an emoji, keep first elements that are more probable allowed for reactions patch 7db9d9a07af099b00a32a6ee0197c400a52240e5 Author: E. Bosch Date: Sun Oct 13 23:00:24 CEST 2024 * telegram: Limit text length for reactions to twice as replies patch 42fe2f72d41ed36616e6a19b9388eec356679e38 Author: E. Bosch Date: Sat Oct 12 23:17:45 CEST 2024 * README update patch 873004141f83b60da113e8967a2148d9d33008be Author: E. Bosch Date: Thu Oct 10 23:11:40 CEST 2024 * exclam: Add reaction (!react) command to send emoticon->emoji as Telegram reaction patch 69a63d9f5f49ece35c5d552ba8b3081c8277490d Author: E. Bosch Date: Mon Oct 7 00:07:54 CEST 2024 * telegram, service: Move initial help to service module add a line for "equivalent command" patch c1ffe716a42ea01ca345d7a756b685d7174f99c5 Author: E. Bosch Date: Sun Oct 6 23:59:23 CEST 2024 * telegram: Fix regression in delete reaction event patch beacde93a685dd954f9823dc0a6fea4594b2c1e4 Author: E. Bosch Date: Sat Sep 28 02:53:06 CEST 2024 * telegram: Avoid duplicated reactions events in some cases patch b2f8fe9251a26c43e16ba1aadff8d71e64a5a7e9 Author: E. Bosch Date: Fri Sep 27 11:05:53 CEST 2024 * telegram: Add handler for next reactions (2nd, 3rd, etc.) that don't come from the same events as 1st (why?!) patch 8770a66d55d4d1c34e009fe5d0078f77c3be4d34 Author: E. Bosch Date: Wed Sep 25 01:36:06 CEST 2024 * telegram: Fix in reaction handler patch 7e550077a65e4737ca30a81e0decbcb4db0485a4 Author: E. Bosch Date: Fri Sep 20 23:50:13 CEST 2024 * telegram: Don't truncate text for reactions of replies patch f9ff84bf789b6bd5109c5953590ca41c438fe123 Author: E. Bosch Date: Sun Sep 15 23:50:10 CEST 2024 * telegram: Minor improvement in debug of relay methods patch 1d527812923bdf50653bb371185bec70c2abad40 Author: E. Bosch Date: Sun Sep 15 01:23:24 CEST 2024 * telegram: Improve a bit reactions handler patch b43b2bc6a4e9dcf0eaddb66ea3fd5abf7c95082b Author: E. Bosch Date: Sat Sep 7 23:20:27 CEST 2024 * Fix typo in a constant patch 6aaf9a2af5898f8b6ec1027a3ccb7e85f6893f22 Author: E. Bosch Date: Sun Sep 1 01:01:19 CEST 2024 * Increase virtual version to 0.2 Remove alpha status patch a39c65dc932ee95b44b5a759cad3e413177fc5aa Author: E. Bosch Date: Fri Aug 30 21:53:13 CEST 2024 * telegram: Add a cache of "volatile" events (delete, edit, react) to be shown in history patch 95e72ac9b26835162b8ba997c5ff99edfd5d464e Author: E. Bosch Date: Fri Aug 30 19:00:53 CEST 2024 * utils: Small optimization in pretty() patch 799dcf8a6a7c8346af93e7f17841baf08db70e7c Author: E. Bosch Date: Fri Aug 30 01:58:06 CEST 2024 * utils: Add current_date() shortcut patch 6c991a90a37dcc5a96906992b5c4df41e7f68991 Author: E. Bosch Date: Thu Aug 29 23:06:59 CEST 2024 * Add trailing commas (and some spacing) patch 6057cbb6c30094c80cba9f6326a5b513a9ab540c Author: E. Bosch Date: Sun Aug 25 01:21:01 CEST 2024 * utils, telegram: Add pretty() function to print readable objects in debug patch 0fd4392905932f144e14844f164d801a22b68467 Author: E. Bosch Date: Sun Aug 18 14:01:26 CEST 2024 * exclam: Add re-upload (!reupl) command to upload files/media as a reply to a message patch 477b15fc239d17cccf626d042db2f323e1fa1b4b Author: E. Bosch Date: Sun Aug 18 13:58:56 CEST 2024 * exclam: Check valid range for message IDs patch 35206dbcb8c561df88e525160e5d6a50dad07481 Author: E. Bosch Date: Thu Aug 15 01:30:54 CEST 2024 * Handle replies to deleted messages (maybe this case is only given from history) patch ca113b48abe430eb11d500a5eb086edd15f23b0d Author: E. Bosch Date: Sun Apr 28 19:45:06 CEST 2024 * Update copyright year in LICENSE patch 3ad99bad13beb07c34a9a992b026ed923f39b047 Author: E. Bosch Date: Sun Apr 28 13:16:24 CEST 2024 * README update patch c5d6314d751bbaca2ba4d24f4af0465af751ac40 Author: E. Bosch Date: Sun Apr 28 00:28:22 CEST 2024 * utils: Fix when a filename has no extension patch 5d8ba95f7bbee459c4a9c7a0d524894ae8836c83 Author: E. Bosch Date: Sat Apr 27 20:32:49 CEST 2024 * exclam: Add command indicator to error messages patch b287d9843e3a4854c7ab0dc15558546ab7fd4c86 Author: E. Bosch Date: Sun Apr 21 21:19:29 CEST 2024 * README update patch d523591db91c8bf0ceb6ea65bac6358e5080f35a Author: E. Bosch Date: Sun Apr 21 20:59:04 CEST 2024 * exclam: !upl: Add support for HTTP/HTTPS URL for file upload patch 18c87eca10fbc53fd73e8adf6b4da64dd4f24109 Author: E. Bosch Date: Fri Apr 19 01:11:38 CEST 2024 * service: Disable by now the help for subcommands "archive" and "delete" from command "dialog" as they are not really implemented yet patch ca68ae9cfb315331dd08f8033ea2270e8a93e626 Author: E. Bosch Date: Sun Apr 14 22:48:30 CEST 2024 * exclam: Add upload (!upl) command to upload files/media to chats/channels Add "upload_dir" option to define the local directory to pick the files up, by default "~/.cache/irgramd/upload" patch fa015f3a5b1ea9fe2b6c068491481d57837ecdc5 Author: E. Bosch Date: Sun Apr 14 02:13:34 CEST 2024 * telegram: Use directory ".cache/irgramd/media" instead of ".config/irgramd/media" by default (relative to home directory) Added "cache_dir" option to override the default "~/.cache/irgramd" patch 170328f9bde0160c50f56b98cbf51c8726ab4d69 Author: E. Bosch Date: Sun Apr 7 19:48:33 CEST 2024 * telegram: Fix a corner case in forward handler when saved_from_peer is not present patch 734e8c9f78627a6536e3ff52bd2db4879737830d Author: E. Bosch Date: Sun Apr 7 19:08:52 CEST 2024 * README update patch 4bb5866f7a3278949670e153444b9b1db74344ad Author: E. Bosch Date: Sun Apr 7 19:07:04 CEST 2024 * exclam: Add forward (!fwd) command to forward messages to other channels or chats patch aa90a8fae2fb0ed855e80f146dc88cbf7069a2dd Author: E. Bosch Date: Sun Dec 31 01:26:30 CET 2023 * README update patch f7068578a6b2806e38c5c5bfc1c03b8e59a14455 Author: E. Bosch Date: Wed Dec 20 01:50:56 CET 2023 * Fix logging system. Remove logging options from tornado.log that were not working correctly in this setup and use the new options from irgramd ("log_file" and "log_level"). Defer first logs to be included in log file opened later. Improve option error handling. patch 987c7436cd18763a950f5799ef5fac6e7cb127e4 Author: E. Bosch Date: Mon Dec 18 21:18:42 CET 2023 * telegram, utils: Replace invalid characters in filenames with number sequences instead of just removing. This will prevent some filename collisions in corner cases. diff -rN -u old-irgramd/LICENSE new-irgramd/LICENSE --- old-irgramd/LICENSE 2024-11-22 21:15:44.436786391 +0100 +++ new-irgramd/LICENSE 2024-11-22 21:15:44.444786378 +0100 @@ -1,7 +1,7 @@ MIT License Copyright (c) 2019 Peter Bui -Copyright (c) 2020-2023 E. Bosch +Copyright (c) 2020-2024 E. Bosch Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff -rN -u old-irgramd/README.md new-irgramd/README.md --- old-irgramd/README.md 2024-11-22 21:15:44.440786384 +0100 +++ new-irgramd/README.md 2024-11-22 21:15:44.444786378 +0100 @@ -13,7 +13,7 @@ **irgramd was forked from [pbui/irtelegramd], was heavily modified and currently is a project on its own** -**irgramd is under active development in alpha state, though usable, several +**irgramd is under active development, though usable, several planned features are not implemented yet** ## How it works @@ -45,12 +45,12 @@ - Channels, groups and private chats - Users and channels mapped in IRC - Messages (receive, send) -- Media in messages (receive, download) +- Media in messages (receive, download, upload) - Replies (receive, send) -- Forwards (receive) +- Forwards (receive, send) - Deletions (receive, do) - Editions (receive, do) -- Reactions (receive) +- Reactions (receive, send, remove) - Polls (receive, show) - Actions [pin message, channel photo] (receive) - Dialogs management @@ -105,9 +105,9 @@ ./irgramd -In background (without logs): +In background (with logs): - ./irgramd --logging=none & + ./irgramd --log-file=irgramd.log & ## Notes @@ -116,10 +116,16 @@ (e.g. in Linux be in the shadow group or equivalent). The dependency is totally optional, if not used, the module pyPAM is not needed. +## Inspired by + +- [telegramircd] +- [ibotg] +- [bitlbee] + ## License Copyright (c) 2019 Peter Bui -Copyright (c) 2020-2023 E. Bosch +Copyright (c) 2020-2024 E. Bosch Use of this source code is governed by a MIT style license that can be found in the LICENSE file included in this project. @@ -137,3 +143,6 @@ [aioconsole]: https://github.com/vxgmichel/aioconsole [pyPAM]: https://packages.debian.org/bullseye/python3-pam [BNC]: https://en.wikipedia.org/wiki/BNC_(software) +[telegramircd]: https://github.com/prsai/telegramircd +[ibotg]: https://github.com/prsai/ibotg +[bitlbee]: https://www.bitlbee.org diff -rN -u old-irgramd/emoji2emoticon.py new-irgramd/emoji2emoticon.py --- old-irgramd/emoji2emoticon.py 2024-11-22 21:15:44.440786384 +0100 +++ new-irgramd/emoji2emoticon.py 2024-11-22 21:15:44.448786371 +0100 @@ -86,9 +86,13 @@ '\U0001f644': '"o o,"', '\U0001f914': '":-L"', '\U0001f92b': '":-o-m"', - '\U0001f970': '":)e>"' + '\U0001f970': '":)e>"', } +emo_inv = { '-': None } +for k in reversed(emo): + emo_inv[emo[k][1:-1]] = k + def replace_mult(line, emo): for utf_emo in emo: if utf_emo in line: diff -rN -u old-irgramd/exclam.py new-irgramd/exclam.py --- old-irgramd/exclam.py 2024-11-22 21:15:44.440786384 +0100 +++ new-irgramd/exclam.py 2024-11-22 21:15:44.448786371 +0100 @@ -1,22 +1,30 @@ # irgramd: IRC-Telegram gateway # exclam.py: IRC exclamation command handlers # -# Copyright (c) 2023 E. Bosch +# Copyright (c) 2023, 2024 E. Bosch # # Use of this source code is governed by a MIT style license that # can be found in the LICENSE file included in this project. -from telethon.errors.rpcerrorlist import MessageNotModifiedError, MessageAuthorRequiredError +import os +from telethon.tl.functions.messages import SendReactionRequest +from telethon import types as tgty +from telethon.errors.rpcerrorlist import MessageNotModifiedError, MessageAuthorRequiredError, ReactionInvalidError from utils import command, HELP +from emoji2emoticon import emo_inv class exclam(command): def __init__(self, telegram): self.commands = \ { # Command Handler Arguments Min Max Maxsplit - '!re': (self.handle_command_re, 2, 2, 2), - '!ed': (self.handle_command_ed, 2, 2, 2), '!del': (self.handle_command_del, 1, 1, -1), + '!ed': (self.handle_command_ed, 2, 2, 2), + '!fwd': (self.handle_command_fwd, 2, 2, -1), + '!re': (self.handle_command_re, 2, 2, 2), + '!react': (self.handle_command_react, 2, 2, -1), + '!reupl': (self.handle_command_reupl, 2, 3, 3), + '!upl': (self.handle_command_upl, 1, 2, 2), } self.tg = telegram self.irc = telegram.irc @@ -34,7 +42,7 @@ async def check_msg(self, cid): id = self.tg.mid.id_to_num_offset(self.tmp_telegram_id, cid) - if id is None: + if id is None or id < -2147483648 or id > 2147483647: chk_msg = None else: chk_msg = await self.tg.telegram_client.get_messages(entity=self.tmp_telegram_id, ids=id) @@ -47,7 +55,7 @@ self.tmp_tg_msg = await self.tg.telegram_client.send_message(self.tmp_telegram_id, msg, reply_to=id) reply = True else: - reply = ('Unknown message to reply',) + reply = ('!re: Unknown message to reply',) else: # HELP.brief or HELP.desc (first line) reply = (' !re Reply to a message',) if help == HELP.desc: # rest of HELP.desc @@ -55,7 +63,7 @@ ( ' !re ', 'Reply with to a message with on current', - 'channel/chat.' + 'channel/chat.', ) return reply @@ -69,7 +77,7 @@ self.tmp_tg_msg = ed_msg reply = True except MessageAuthorRequiredError: - reply = ('Not the author of the message to edit',) + reply = ('!ed: Not the author of the message to edit',) else: reply = True else: @@ -81,7 +89,7 @@ ( ' !ed ', 'Edit a message with on current channel/chat,', - ' replaces the current message.' + ' replaces the current message.', ) return reply @@ -91,7 +99,7 @@ if del_msg is not None: deleted = await self.tg.telegram_client.delete_messages(self.tmp_telegram_id, del_msg) if deleted[0].pts_count == 0: - reply = ('Not possible to delete',) + reply = ('!del: Not possible to delete',) else: self.tmp_tg_msg = None reply = None @@ -106,3 +114,111 @@ 'Delete a message with on current channel/chat' ) return reply + + async def handle_command_fwd(self, cid=None, chat=None, help=None): + if not help: + id, chk_msg = await self.check_msg(cid) + if chk_msg is not None: + async def send_fwd(tgt_ent, id): + from_ent = await self.tg.telegram_client.get_entity(self.tmp_telegram_id) + self.tmp_tg_msg = await self.tg.telegram_client.forward_messages(tgt_ent, id, from_ent) + return self.tmp_tg_msg + + tgt = chat.lower() + if tgt in self.irc.iid_to_tid: + tgt_ent = await self.tg.telegram_client.get_entity(self.irc.iid_to_tid[tgt]) + msg = await send_fwd(tgt_ent, id) + # echo fwded message + await self.tg.handle_telegram_message(event=None, message=msg) + reply = True + elif tgt in (u.irc_nick.lower() for u in self.irc.users.values() if u.stream): + tgt_ent = await self.tg.telegram_client.get_me() + await send_fwd(tgt_ent, id) + reply = True + else: + reply = ('!fwd: Unknown chat to forward',) + else: + reply = ('Unknown message to forward',) + else: # HELP.brief or HELP.desc (first line) + reply = (' !fwd Forward a message',) + if help == HELP.desc: # rest of HELP.desc + reply += \ + ( + ' !fwd ', + 'Forward a message with to channel/chat.' + ) + return reply + + async def handle_command_upl(self, file=None, caption=None, help=None, re_id=None): + if not help: + try: + if file[:8] == 'https://' or file[:7] == 'http://': + file_path = file + else: + file_path = os.path.join(self.tg.telegram_upload_dir, file) + self.tmp_tg_msg = await self.tg.telegram_client.send_file(self.tmp_telegram_id, file_path, caption=caption, reply_to=re_id) + reply = True + except: + cmd = '!reupl' if re_id else '!upl' + reply = ('{}: Error uploading'.format(cmd),) + else: # HELP.brief or HELP.desc (first line) + reply = (' !upl Upload a file to current channel/chat',) + if help == HELP.desc: # rest of HELP.desc + reply += \ + ( + ' !upl []', + 'Upload the file referenced by to current', + 'channel/chat, the file must be present in "upload"', + 'irgramd local directory or be an external HTTP/HTTPS URL.', + ) + return reply + + async def handle_command_reupl(self, cid=None, file=None, caption=None, help=None): + if not help: + id, chk_msg = await self.check_msg(cid) + if chk_msg is not None: + reply = await self.handle_command_upl(file, caption, re_id=id) + else: + reply = ('!reupl: Unknown message to reply',) + else: # HELP.brief or HELP.desc (first line) + reply = (' !reupl Reply to a message with an upload',) + if help == HELP.desc: # rest of HELP.desc + reply += \ + ( + ' !reupl []', + 'Reply with the upload of to a message with', + ' on current channel/chat. The file must be', + 'present in "upload" irgramd local directory or be an external', + 'HTTP/HTTPS URL.', + ) + return reply + + async def handle_command_react(self, cid=None, act=None, help=None): + if not help: + id, chk_msg = await self.check_msg(cid) + if chk_msg is not None: + if act in emo_inv: + utf8_emo = emo_inv[act] + reaction = [ tgty.ReactionEmoji(emoticon=utf8_emo) ] if utf8_emo else None + try: + update = await self.tg.telegram_client(SendReactionRequest(self.tmp_telegram_id, id, reaction=reaction)) + except ReactionInvalidError: + reply = ('!react: Reaction not allowed',) + else: + self.tmp_tg_msg = update.updates[0].message + reply = True + else: + reply = ('!react: Unknown reaction',) + else: + reply = ('!react: Unknown message to react',) + else: # HELP.brief or HELP.desc (first line) + reply = (' !react React to a message',) + if help == HELP.desc: # rest of HELP.desc + reply += \ + ( + ' !react |-', + 'React with to a message with ,', + 'irgramd will translate emoticon to closest emoji.', + 'Use - to remove a previous reaction.', + ) + return reply diff -rN -u old-irgramd/include.py new-irgramd/include.py --- old-irgramd/include.py 2024-11-22 21:15:44.440786384 +0100 +++ new-irgramd/include.py 2024-11-22 21:15:44.448786371 +0100 @@ -8,6 +8,7 @@ # Constants -VERSION = '0.1' +VERSION = '0.2' NICK_MAX_LENGTH = 20 -CHAN_MAX_LENGHT = 50 +CHAN_MAX_LENGTH = 50 +MAX_LINE = 400 diff -rN -u old-irgramd/irc.py new-irgramd/irc.py --- old-irgramd/irc.py 2024-11-22 21:15:44.440786384 +0100 +++ new-irgramd/irc.py 2024-11-22 21:15:44.448786371 +0100 @@ -18,7 +18,7 @@ # Local modules -from include import VERSION, CHAN_MAX_LENGHT, NICK_MAX_LENGTH +from include import VERSION, CHAN_MAX_LENGTH, NICK_MAX_LENGTH, MAX_LINE from irc_replies import irc_codes from utils import chunks, set_replace, split_lines from service import service @@ -259,7 +259,7 @@ real_chan = self.get_realcaps_name(chan) users_count = len(self.irc_channels[chan]) topic = await self.tg.get_channel_topic(chan, [None]) - await self.reply_code(user, 'RPL_LIST', (real_chan, users_count, topic)) + await self.reply_code(user, 'RPL_LIST', (real_chan, users_count, topic[:MAX_LINE])) await self.reply_code(user, 'RPL_LISTEND') async def handle_irc_names(self, user, channels): @@ -529,14 +529,10 @@ await self.reply_code(user, 'RPL_ENDOFMOTD') async def send_isupport(self, user): - await self.reply_code(user, 'RPL_ISUPPORT', (CHAN_MAX_LENGHT, NICK_MAX_LENGTH)) + await self.reply_code(user, 'RPL_ISUPPORT', (CHAN_MAX_LENGTH, NICK_MAX_LENGTH)) async def send_help(self, user): - for line in ( - 'Welcome to irgramd service', - 'use /msg {} help'.format(self.service_user.irc_nick), - 'to get help', - ): + for line in self.service.initial_help(): await self.send_msg(self.service_user, None, line, user) async def check_telegram_auth(self, user): @@ -616,7 +612,7 @@ founder = list(self.irc_channels_founder[chan])[0] else: founder = self.service_user.irc_nick - await self.reply_code(user, 'RPL_TOPIC', (channel, topic)) + await self.reply_code(user, 'RPL_TOPIC', (channel, topic[:MAX_LINE])) await self.reply_code(user, 'RPL_TOPICWHOTIME', (channel, founder, timestamp)) async def irc_namelist(self, user, channel): diff -rN -u old-irgramd/irgramd new-irgramd/irgramd --- old-irgramd/irgramd 2024-11-22 21:15:44.440786384 +0100 +++ new-irgramd/irgramd 2024-11-22 21:15:44.448786371 +0100 @@ -3,7 +3,7 @@ # irgramd: IRC-Telegram gateway - Main file # # Copyright (c) 2019 Peter Bui -# Copyright (c) 2020-2023 E. Bosch +# Copyright (c) 2020-2024 E. Bosch # # Use of this source code is governed by a MIT style license that # can be found in the LICENSE file included in this project. @@ -20,6 +20,7 @@ from irc import IRCHandler from telegram import TelegramHandler +from utils import parse_loglevel # IRC Telegram Daemon @@ -65,10 +66,20 @@ # Main Execution if __name__ == '__main__': - logger = logging.getLogger() + # Remove tornado.log options (ugly hacks but these must not be defined) + tornado.options.options.logging = None + tornado_log_options = tuple(x for x in tornado.options.options._options.keys() if x != 'help' and x != 'logging') + for opt in tornado_log_options: + del tornado.options.options._options[opt] + # and reuse "--logging" to document empty "--" ;) + tornado.options.options._options['logging'].help = 'Stop parsing options' + for att in ('name', 'metavar', 'group_name', 'default'): + setattr(tornado.options.options._options['logging'], att, '') + # Define irgramd options tornado.options.define('api_hash', default=None, metavar='HASH', help='Telegram API Hash for your account (obtained from https://my.telegram.org/apps)') tornado.options.define('api_id', type=int, default=None, metavar='ID', help='Telegram API ID for your account (obtained from https://my.telegram.org/apps)') tornado.options.define('ask_code', default=False, help='Ask authentication code (sent by Telegram) in console instead of "code" service command in IRC') + tornado.options.define('cache_dir', default='~/.cache/irgramd', metavar='PATH', help='Cache directory where telegram media is saved by default') tornado.options.define('char_in_encoding', default='utf-8', metavar='ENCODING', help='Character input encoding for IRC') tornado.options.define('char_out_encoding', default='utf-8', metavar='ENCODING', help='Character output encoding for IRC') tornado.options.define('config', default='irgramdrc', metavar='CONFIGFILE', help='Config file absolute or relative to `config_dir` (command line options override it)') @@ -82,7 +93,9 @@ tornado.options.define('irc_nicks', type=str, multiple=True, metavar='nick,..', help='List of nicks allowed for IRC, if `pam` and optionally `pam_group` are set, PAM authentication will be used instead') tornado.options.define('irc_password', default='', metavar='PASSWORD', help='Password for IRC authentication, if `pam` is set, PAM authentication will be used instead') tornado.options.define('irc_port', type=int, default=None, metavar='PORT', help='Port to listen on for IRC. (default 6667, default with TLS 6697)') - tornado.options.define('media_dir', default=None, metavar='PATH', help='Directory where Telegram media files are downloaded, default "media" in `config_dir`') + tornado.options.define('log_file', default=None, metavar='PATH', help='File where logs are appended, if not set will be stderr') + tornado.options.define('log_level', default='INFO', metavar='DEBUG|INFO|WARNING|ERROR|CRITICAL|NONE', help='The log level (and any higher to it) that will be logged') + tornado.options.define('media_dir', default=None, metavar='PATH', help='Directory where Telegram media files are downloaded, default "media" in `cache_dir`') tornado.options.define('media_url', default=None, metavar='BASE_URL', help='Base URL for media files, should be configured in the external (to irgramd) webserver') tornado.options.define('pam', default=False, help='Use PAM for IRC authentication, if not set you should set `irc_password`') tornado.options.define('pam_group', default=None, metavar='GROUP', help='Unix group allowed if `pam` enabled, if empty any user is allowed') @@ -97,27 +110,52 @@ tornado.options.define('tls', default=False, help='Use TLS/SSL encrypted connection for IRC server') tornado.options.define('tls_cert', default=None, metavar='CERTFILE', help='IRC server certificate chain for TLS/SSL, also can contain private key if not defined with `tls_key`') tornado.options.define('tls_key', default=None, metavar='KEYFILE', help='IRC server private key for TLS/SSL') - # parse cmd line first time to get --config and --config_dir - tornado.options.parse_command_line() + tornado.options.define('upload_dir', default=None, metavar='PATH', help='Directory where files to upload are picked up, default "upload" in `cache_dir`') + try: + # parse cmd line first time to get --config and --config_dir + tornado.options.parse_command_line() + except Exception as exc: + print(exc) + exit(1) config_file = os.path.expanduser(tornado.options.options.config) config_dir = os.path.expanduser(tornado.options.options.config_dir) if not os.path.exists(config_dir): os.makedirs(config_dir) - logger.info('Configuration Directory: %s', config_dir) + defered_logs = [(logging.INFO, 'Configuration Directory: %s', config_dir)] if not os.path.isabs(config_file): config_file = os.path.join(config_dir, config_file) if os.path.isfile(config_file): - logger.info('Using configuration file: %s', config_file) - tornado.options.parse_config_file(config_file) + defered_logs.append((logging.INFO, 'Using configuration file: %s', config_file)) + try: + tornado.options.parse_config_file(config_file) + except Exception as exc: + print(exc) + exit(1) else: - logger.warning('Configuration file not present, using only command line options and defaults') + defered_logs.append((logging.WARNING, 'Configuration file not present, using only command line options and defaults')) # parse cmd line second time to override file options tornado.options.parse_command_line() options = tornado.options.options.as_dict() options['config_dir'] = config_dir + # configure logging + loglevel = parse_loglevel(options['log_level']) + if loglevel == False: + print("Option 'log_level' requires one of these values: {}".format(tornado.options.options._options['log-level'].metavar)) + exit(1) + logger_formats = { 'datefmt':'%Y-%m-%d %H:%M:%S', 'format':'[%(levelname).1s %(asctime)s %(module)s:%(lineno)d] %(message)s' } + logger = logging.getLogger() + if options['log_file']: + logging.basicConfig(filename=options['log_file'], level=loglevel, **logger_formats) + else: + logging.basicConfig(level=loglevel, **logger_formats) + + for log in defered_logs: + logger.log(*log) + + # main loop irc_server = IRCTelegramd(logger, options) loop = asyncio.new_event_loop() loop.run_until_complete(irc_server.run(options)) diff -rN -u old-irgramd/service.py new-irgramd/service.py --- old-irgramd/service.py 2024-11-22 21:15:44.444786378 +0100 +++ new-irgramd/service.py 2024-11-22 21:15:44.448786371 +0100 @@ -25,6 +25,14 @@ self.irc = telegram.irc self.tmp_ircnick = None + def initial_help(self): + return ( + 'Welcome to irgramd service', + 'use /msg {} help'.format(self.irc.service_user.irc_nick), + 'or equivalent in your IRC client', + 'to get help', + ) + async def handle_command_code(self, code=None, help=None): if not help: if self.ask_code: @@ -91,8 +99,8 @@ ' dialog [id]', 'Manage conversations (dialogs) established in Telegram, the', 'following subcommands are available:', - ' archive Archive the dialog specified by id', - ' delete Delete the dialog specified by id', +# ' archive Archive the dialog specified by id', +# ' delete Delete the dialog specified by id', ' list Show all dialogs', ) return reply @@ -249,7 +257,7 @@ ( ' mark_read ', 'Mark all messages on (channel or user) as read, this also will', - 'reset the number of mentions to you on .' + 'reset the number of mentions to you on .', ) return reply diff -rN -u old-irgramd/telegram.py new-irgramd/telegram.py --- old-irgramd/telegram.py 2024-11-22 21:15:44.444786378 +0100 +++ new-irgramd/telegram.py 2024-11-22 21:15:44.452786365 +0100 @@ -2,34 +2,35 @@ # telegram.py: Interface to Telethon Telegram library # # Copyright (c) 2019 Peter Bui -# Copyright (c) 2020-2023 E. Bosch +# Copyright (c) 2020-2024 E. Bosch # # Use of this source code is governed by a MIT style license that # can be found in the LICENSE file included in this project. import logging import os -import datetime import re import aioconsole import asyncio import collections import telethon from telethon import types as tgty, utils as tgutils -from telethon.tl.functions.messages import GetMessagesReactionsRequest +from telethon.tl.functions.messages import GetMessagesReactionsRequest, GetFullChatRequest +from telethon.tl.functions.channels import GetFullChannelRequest # Local modules -from include import CHAN_MAX_LENGHT, NICK_MAX_LENGTH +from include import CHAN_MAX_LENGTH, NICK_MAX_LENGTH from irc import IRCUser -from utils import sanitize_filename, add_filename, is_url_equiv, extract_url, get_human_size, get_human_duration, get_highlighted, fix_braces, format_timestamp +from utils import sanitize_filename, add_filename, is_url_equiv, extract_url, get_human_size, get_human_duration +from utils import get_highlighted, fix_braces, format_timestamp, pretty, current_date import emoji2emoticon as e # Test IP table TEST_IPS = { 1: '149.154.175.10', 2: '149.154.167.40', - 3: '149.154.175.117' + 3: '149.154.175.117', } # Telegram @@ -38,10 +39,12 @@ def __init__(self, irc, settings): self.logger = logging.getLogger() self.config_dir = settings['config_dir'] + self.cache_dir = settings['cache_dir'] self.download = settings['download_media'] self.notice_size = settings['download_notice'] * 1048576 self.media_dir = settings['media_dir'] self.media_url = settings['media_url'] + self.upload_dir = settings['upload_dir'] self.api_id = settings['api_id'] self.api_hash = settings['api_hash'] self.phone = settings['phone'] @@ -66,16 +69,24 @@ self.webpending = {} self.refwd_me = False self.cache = collections.OrderedDict() + self.volatile_cache = collections.OrderedDict() + self.prev_id = {} self.sorted_len_usernames = [] + self.last_reaction = None # Set event to be waited by irc.check_telegram_auth() self.auth_checked = asyncio.Event() async def initialize_telegram(self): # Setup media folder - self.telegram_media_dir = self.media_dir or os.path.join(self.config_dir, 'media') + self.telegram_media_dir = os.path.expanduser(self.media_dir or os.path.join(self.cache_dir, 'media')) if not os.path.exists(self.telegram_media_dir): os.makedirs(self.telegram_media_dir) + # Setup upload folder + self.telegram_upload_dir = os.path.expanduser(self.upload_dir or os.path.join(self.cache_dir, 'upload')) + if not os.path.exists(self.telegram_upload_dir): + os.makedirs(self.telegram_upload_dir) + # Setup session folder self.telegram_session_dir = os.path.join(self.config_dir, 'session') if not os.path.exists(self.telegram_session_dir): @@ -95,10 +106,10 @@ # Register Telegram callbacks callbacks = ( (self.handle_telegram_message , telethon.events.NewMessage), - (self.handle_raw, telethon.events.Raw), + (self.handle_raw , telethon.events.Raw), (self.handle_telegram_chat_action, telethon.events.ChatAction), (self.handle_telegram_deleted , telethon.events.MessageDeleted), - (self.handle_telegram_edited, telethon.events.MessageEdited), + (self.handle_telegram_edited , telethon.events.MessageEdited), ) for handler, event in callbacks: self.telegram_client.add_event_handler(handler, event) @@ -171,10 +182,12 @@ if not user.is_self: self.irc.irc_channels[chan].add(user_nick) # Add admin users as ops in irc - if isinstance(user.participant, tgty.ChatParticipantAdmin): + if isinstance(user.participant, tgty.ChatParticipantAdmin) or \ + isinstance(user.participant, tgty.ChannelParticipantAdmin): self.irc.irc_channels_ops[chan].add(user_nick) # Add creator users as founders in irc - elif isinstance(user.participant, tgty.ChatParticipantCreator): + elif isinstance(user.participant, tgty.ChatParticipantCreator) or \ + isinstance(user.participant, tgty.ChannelParticipantCreator): self.irc.irc_channels_founder[chan].add(user_nick) def get_telegram_nick(self, user): @@ -279,7 +292,7 @@ idle = 0 elif isinstance(user.status,tgty.UserStatusOffline): last = user.status.was_online - current = datetime.datetime.now(datetime.timezone.utc) + current = current_date() idle = int((current - last).total_seconds()) elif isinstance(user.status,tgty.UserStatusLastWeek): idle = 604800 @@ -297,8 +310,16 @@ else: entity = await self.telegram_client.get_entity(tid) entity_cache[0] = entity + if isinstance(entity, tgty.Channel): + full = await self.telegram_client(GetFullChannelRequest(channel=entity)) + elif isinstance(entity, tgty.Chat): + full = await self.telegram_client(GetFullChatRequest(chat_id=tid)) + else: + return '' entity_type = self.get_entity_type(entity, format='long') - return 'Telegram ' + entity_type + ' ' + str(tid) + ': ' + entity.title + topic = full.full_chat.about + sep = ': ' if topic else '' + return entity_type + sep + topic async def get_channel_creation(self, channel, entity_cache): tid = self.get_tid(channel) @@ -380,7 +401,9 @@ ) async def get_reactions(m): react = await self.telegram_client(GetMessagesReactionsRequest(m.peer_id, id=[m.id])) - return react.updates[0].reactions.recent_reactions + updates = react.updates + r = next((x for x in updates if type(x) is tgty.UpdateMessageReactions), None) + return r.reactions.recent_reactions if r else None react = None if msg.reactions is None: @@ -390,7 +413,7 @@ case = 'edition' else: case = 'react-del' - elif react := next((x for x in reactions if x.date == msg.edit_date), None): + elif react := max(reactions, key=lambda y: y.date): case = 'react-add' else: if msg_edited(msg): @@ -401,17 +424,36 @@ return case, react def to_cache(self, id, mid, message, proc_message, user, chan, media): - if len(self.cache) >= 10000: - self.cache.popitem(last=False) + self.limit_cache(self.cache) self.cache[id] = { 'mid': mid, 'text': message, 'rendered_text': proc_message, 'user': user, 'channel': chan, - 'media': media + 'media': media, } + def to_volatile_cache(self, prev_id, id, ev, user, chan, date): + if chan in prev_id: + prid = prev_id[chan] if chan else prev_id[user] + self.limit_cache(self.volatile_cache) + elem = { + 'id': id, + 'rendered_event': ev, + 'user': user, + 'channel': chan, + 'date': date, + } + if prid not in self.volatile_cache: + self.volatile_cache[prid] = [elem] + else: + self.volatile_cache[prid].append(elem) + + def limit_cache(self, cache): + if len(cache) >= 10000: + cache.popitem(last=False) + def replace_mentions(self, text, me_nick='', received=True): # For received replace @mention to ~mention~ # For sent replace mention: to @mention @@ -473,8 +515,27 @@ self.sorted_len_usernames.append(username) self.sorted_len_usernames.sort(key=lambda k: len(k), reverse=True) + def format_reaction(self, msg, message_rendered, edition_case, reaction): + react_quote_len = self.quote_len * 2 + if len(message_rendered) > react_quote_len: + text_old = '{}...'.format(message_rendered[:react_quote_len]) + text_old = fix_braces(text_old) + else: + text_old = message_rendered + + if edition_case == 'react-add': + user = self.get_irc_user_from_telegram(reaction.peer_id.user_id) + emoji = reaction.reaction.emoticon + react_action = '+' + react_icon = e.emo[emoji] if emoji in e.emo else emoji + elif edition_case == 'react-del': + user = self.get_irc_user_from_telegram(msg.sender_id) + react_action = '-' + react_icon = '' + return text_old, '{}{}'.format(react_action, react_icon), user + async def handle_telegram_edited(self, event): - self.logger.debug('Handling Telegram Message Edited: %s', event) + self.logger.debug('Handling Telegram Message Edited: %s', pretty(event)) id = event.message.id mid = self.mid.num_to_id_offset(event.message.peer_id, id) @@ -506,32 +567,45 @@ # Reactions else: + if reaction: + if self.last_reaction == reaction.date: + return + self.last_reaction = reaction.date action = 'React' - if len(message_rendered) > self.quote_len: - text_old = '{}...'.format(message_rendered[:self.quote_len]) - text_old = fix_braces(text_old) - else: - text_old = message_rendered - - if edition_case == 'react-add': - user = self.get_irc_user_from_telegram(reaction.peer_id.user_id) - emoji = reaction.reaction.emoticon - react_action = '+' - react_icon = e.emo[emoji] if emoji in e.emo else emoji - elif edition_case == 'react-del': - user = self.get_irc_user_from_telegram(event.sender_id) - react_action = '-' - react_icon = '' - edition_react = '{}{}'.format(react_action, react_icon) + text_old, edition_react, user = self.format_reaction(event.message, message_rendered, edition_case, reaction) text = '|{} {}| {}'.format(action, text_old, edition_react) chan = await self.relay_telegram_message(event, user, text) self.to_cache(id, mid, message, message_rendered, user, chan, event.message.media) + self.to_volatile_cache(self.prev_id, id, text, user, chan, current_date()) + + async def handle_next_reaction(self, event): + self.logger.debug('Handling Telegram Next Reaction (2nd, 3rd, ...): %s', pretty(event)) + + reactions = event.reactions.recent_reactions + react = max(reactions, key=lambda y: y.date) if reactions else None + + if react and self.last_reaction != react.date: + self.last_reaction = react.date + id = event.msg_id + msg = await self.telegram_client.get_messages(entity=event.peer, ids=id) + mid = self.mid.num_to_id_offset(msg.peer_id, id) + message = self.filters(msg.message) + message_rendered = await self.render_text(msg, mid, upd_to_webpend=None) + + text_old, edition_react, user = self.format_reaction(msg, message_rendered, edition_case='react-add', reaction=react) + + text = '|React {}| {}'.format(text_old, edition_react) + + chan = await self.relay_telegram_message(msg, user, text) + + self.to_cache(id, mid, message, message_rendered, user, chan, msg.media) + self.to_volatile_cache(self.prev_id, id, text, user, chan, current_date()) async def handle_telegram_deleted(self, event): - self.logger.debug('Handling Telegram Message Deleted: %s', event) + self.logger.debug('Handling Telegram Message Deleted: %s', pretty(event)) for deleted_id in event.original_update.messages: if deleted_id in self.cache: @@ -540,20 +614,24 @@ user = self.cache[deleted_id]['user'] chan = self.cache[deleted_id]['channel'] await self.relay_telegram_message(message=None, user=user, text=text, channel=chan) + self.to_volatile_cache(self.prev_id, deleted_id, text, user, chan, current_date()) else: text = 'Message id {} deleted not in cache'.format(deleted_id) await self.relay_telegram_private_message(self.irc.service_user, text) async def handle_raw(self, update): - self.logger.debug('Handling Telegram Raw Event: %s', update) + self.logger.debug('Handling Telegram Raw Event: %s', pretty(update)) if isinstance(update, tgty.UpdateWebPage) and isinstance(update.webpage, tgty.WebPage): message = self.webpending.pop(update.webpage.id, None) if message: await self.handle_telegram_message(event=None, message=message, upd_to_webpend=update.webpage) + elif isinstance(update, tgty.UpdateMessageReactions): + await self.handle_next_reaction(update) + async def handle_telegram_message(self, event, message=None, upd_to_webpend=None, history=False): - self.logger.debug('Handling Telegram Message: %s', event or message) + self.logger.debug('Handling Telegram Message: %s', pretty(event or message)) msg = event.message if event else message @@ -562,8 +640,11 @@ text = await self.render_text(msg, mid, upd_to_webpend, user) text_send = self.set_history_timestamp(text, history, msg.date, msg.action) chan = await self.relay_telegram_message(msg, user, text_send) + await self.history_search_volatile(history, msg.id) self.to_cache(msg.id, mid, msg.message, text, user, chan, msg.media) + peer = chan if chan else user + self.prev_id[peer] = msg.id self.refwd_me = False @@ -602,6 +683,17 @@ res = text return res + async def history_search_volatile(self, history, id): + if history: + if id in self.volatile_cache: + for item in self.volatile_cache[id]: + user = item['user'] + text = item['rendered_event'] + chan = item['channel'] + date = item['date'] + text_send = self.set_history_timestamp(text, history=True, date=date, action=False) + await self.relay_telegram_message(None, user, text_send, chan) + async def relay_telegram_message(self, message, user, text, channel=None): private = (message and message.is_private) or (not message and not channel) action = (message and message.action) @@ -613,7 +705,7 @@ return chan async def relay_telegram_private_message(self, user, message, action=None): - self.logger.debug('Handling Telegram Private Message: %s, %s', user, message) + self.logger.debug('Relaying Telegram Private Message: %s, %s', user, message) if action: await self.irc.send_action(user, None, message) @@ -621,14 +713,14 @@ await self.irc.send_msg(user, None, message) async def relay_telegram_channel_message(self, message, user, text, channel, action): - self.logger.debug('Handling Telegram Channel Message: %s', message or text) - if message: entity = await message.get_chat() chan = await self.get_irc_channel_from_telegram_id(message.chat_id, entity) else: chan = channel + self.logger.debug('Relaying Telegram Channel Message: %s, %s', chan, text) + if action: await self.irc.send_action(user, chan, text) else: @@ -637,7 +729,7 @@ return chan async def handle_telegram_chat_action(self, event): - self.logger.debug('Handling Telegram Chat Action: %s', event) + self.logger.debug('Handling Telegram Chat Action: %s', pretty(event)) try: tid = event.action_message.to_id.channel_id @@ -686,18 +778,33 @@ space = ' ' trunc = '' replied = await message.get_reply_message() - replied_msg = replied.message - cid = self.mid.num_to_id_offset(replied.peer_id, replied.id) + if replied: + replied_msg = replied.message + cid = self.mid.num_to_id_offset(replied.peer_id, replied.id) + replied_user = self.get_irc_user_from_telegram(replied.sender_id) + else: + replied_id = message.reply_to.reply_to_msg_id + cid = self.mid.num_to_id_offset(message.peer_id, replied_id) + if replied_id in self.cache: + text = self.cache[replied_id]['text'] + replied_user = self.cache[replied_id]['user'] + sp = ' ' + else: + text = '' + replied_user = '' + sp = '' + replied_msg = '|Deleted|{}{}'.format(sp, text) if not replied_msg: replied_msg = '' space = '' elif len(replied_msg) > self.quote_len: replied_msg = replied_msg[:self.quote_len] trunc = '...' - replied_user = self.get_irc_user_from_telegram(replied.sender_id) if replied_user is None: replied_nick = '{}' self.refwd_me = True + elif replied_user == '': + replied_nick = '' else: replied_nick = replied_user.irc_nick @@ -712,8 +819,8 @@ secondary_name = saved_peer_name else: # if it's from me I want to know who was the destination of a message (user) - if self.refwd_me: - secondary_name = self.get_irc_user_from_telegram(message.fwd_from.saved_from_peer.user_id).irc_nick + if self.refwd_me and (saved_from_peer := message.fwd_from.saved_from_peer) is not None: + secondary_name = self.get_irc_user_from_telegram(saved_from_peer.user_id).irc_nick else: secondary_name = '' space2 = '' diff -rN -u old-irgramd/utils.py new-irgramd/utils.py --- old-irgramd/utils.py 2024-11-22 21:15:44.444786378 +0100 +++ new-irgramd/utils.py 2024-11-22 21:15:44.452786365 +0100 @@ -2,7 +2,7 @@ # utils.py: Helper functions # # Copyright (c) 2019 Peter Bui -# Copyright (c) 2020-2023 E. Bosch +# Copyright (c) 2020-2024 E. Bosch # # Use of this source code is governed by a MIT style license that # can be found in the LICENSE file included in this project. @@ -13,12 +13,15 @@ import datetime import zoneinfo import difflib +import logging # Constants FILENAME_INVALID_CHARS = re.compile('[/{}<>()"\'\\|&#%?]') SIMPLE_URL = re.compile('http(|s)://[^ ]+') +from include import MAX_LINE + # Utilities class command: @@ -42,6 +45,9 @@ desc = 1 brief = 2 +class LOGL: + debug = False + def chunks(iterable, n, fillvalue=None): ''' Return iterable consisting of a sequence of n-length chunks ''' args = [iter(iterable)] * n @@ -57,9 +63,8 @@ return (x + mark if n != length else x for n, x in enumerate(items, start=1)) def split_lines(message): - MAX = 400 messages_limited = [] - wr = textwrap.TextWrapper(width=MAX) + wr = textwrap.TextWrapper(width=MAX_LINE) # Split when Telegram original message has breaks messages = message.splitlines() @@ -81,13 +86,21 @@ return messages_limited def sanitize_filename(fn): - return FILENAME_INVALID_CHARS.sub('', fn).strip('-').replace(' ','_') + cn = str(sanitize_filename.cn) + new_fn, ns = FILENAME_INVALID_CHARS.subn(cn, fn) + if ns: + sanitize_filename.cn += 1 + return new_fn.strip('-').replace(' ','_') +sanitize_filename.cn = 0 def add_filename(filename, add): if add: aux = filename.rsplit('.', 1) name = aux[0] - ext = aux[1] + try: + ext = aux[1] + except: + ext = '' return '{}-{}.{}'.format(name, add, ext) else: return filename @@ -143,7 +156,7 @@ return res def compact_date(date, tz): - delta = datetime.datetime.now(datetime.timezone.utc) - date + delta = current_date() - date date_local = date.astimezone(zoneinfo.ZoneInfo(tz)) if delta.days < 1: @@ -155,6 +168,9 @@ return compact_date +def current_date(): + return datetime.datetime.now(datetime.timezone.utc) + def get_highlighted(a, b): awl = len(a.split()) bwl = len(b.split()) @@ -209,3 +225,18 @@ def format_timestamp(format, tz, date): date_local = date.astimezone(zoneinfo.ZoneInfo(tz)) return date_local.strftime(format) + +def parse_loglevel(level): + levelu = level.upper() + if levelu == 'DEBUG': + LOGL.debug = True + if levelu == 'NONE': + l = None + elif levelu in ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'): + l = getattr(logging, levelu) + else: + l = False + return l + +def pretty(object): + return object.stringify() if LOGL.debug and object else object