patch 7827ddfc286acd03ba80f6dfef6efed92fc0457a Author: E. Bosch Date: Sun Jun 21 23:41:03 CEST 2026 * Update year in copyright notices patch 6493c04f9d8bdb7b1d2c037fdd66c337a7a91af5 Author: E. Bosch Date: Sun May 31 00:26:34 CEST 2026 * utils: Modify sanitize_filename(): convert invalid chars to hex representation, this makes the filenames deterministic for the same session, and helps to not download a file more than once patch dd18dbebc3877faa7fe1c7f1257fb3a4048aab32 Author: E. Bosch Date: Sat May 30 22:25:32 CEST 2026 * utils: In add_filename() don't put a final dot if there is no extension in the filename patch feebc9eac596c484be6e5f67934196e64d52beeb Author: E. Bosch Date: Sat May 30 21:33:57 CEST 2026 * telegram: Refactor download media routine optimized to download files only once utils: Add session tokens for unnamed files patch 4009bc07c6261c0ea2cb1a4112f8aaf266cbd65d Author: E. Bosch Date: Sat Jul 26 01:56:12 CEST 2025 * emoji2emoticon: Add emoji "rolling on the floor laughing" to convert to ASCII patch 10725c4fff915a1c95e95b5f998f26c97b146d5e Author: E. Bosch Date: Wed Jan 22 00:35:42 CET 2025 * telegram: Try to fix ChatAdminRequiredError when getting participants from a channel reported in https://github.com/prsai/irgramd/issues/1 patch 0c96892ec68150c96ef7ba50ec4e2db3db2296e4 Author: E. Bosch Date: Thu Jan 16 22:00:14 CET 2025 * exclam: Fix send a reaction when there was already a previous reaction from other user on the same message, for some reason the 'message' attribute is not received 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 diff -rN -u old-irgramd/LICENSE new-irgramd/LICENSE --- old-irgramd/LICENSE 2026-06-23 13:22:39.124590501 +0200 +++ new-irgramd/LICENSE 2026-06-23 13:22:39.132590492 +0200 @@ -1,7 +1,7 @@ MIT License Copyright (c) 2019 Peter Bui -Copyright (c) 2020-2024 E. Bosch +Copyright (c) 2020-2026 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 2026-06-23 13:22:39.128590497 +0200 +++ new-irgramd/README.md 2026-06-23 13:22:39.132590492 +0200 @@ -50,7 +50,7 @@ - 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 @@ -125,7 +125,7 @@ ## License Copyright (c) 2019 Peter Bui -Copyright (c) 2020-2024 E. Bosch +Copyright (c) 2020-2026 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. diff -rN -u old-irgramd/emoji2emoticon.py new-irgramd/emoji2emoticon.py --- old-irgramd/emoji2emoticon.py 2026-06-23 13:22:39.128590497 +0200 +++ new-irgramd/emoji2emoticon.py 2026-06-23 13:22:39.132590492 +0200 @@ -1,7 +1,7 @@ # irgramd: IRC-Telegram gateway # emoji2emoticon.py: UTF-8 Emoji to ASCII emoticon replacement # -# (C) Copyright 2019,2023 E. Bosch +# (C) Copyright 2019,2023,2025 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. @@ -85,10 +85,15 @@ '\U0001f643': '"(:"', '\U0001f644': '"o o,"', '\U0001f914': '":-L"', + '\U0001f923': '"X__D"', '\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 2026-06-23 13:22:39.128590497 +0200 +++ new-irgramd/exclam.py 2026-06-23 13:22:39.132590492 +0200 @@ -1,26 +1,30 @@ # irgramd: IRC-Telegram gateway # exclam.py: IRC exclamation command handlers # -# Copyright (c) 2023, 2024 E. Bosch +# Copyright (c) 2023-2025 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 os -from telethon.errors.rpcerrorlist import MessageNotModifiedError, MessageAuthorRequiredError +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), - '!upl': (self.handle_command_upl, 1, 2, 2), + '!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 @@ -188,3 +192,33 @@ '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 = getattr(update.updates[0], 'message', None) + reply = bool(self.tmp_tg_msg) + 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 2026-06-23 13:22:39.128590497 +0200 +++ new-irgramd/include.py 2026-06-23 13:22:39.132590492 +0200 @@ -1,7 +1,7 @@ # irgramd: IRC-Telegram gateway # include.py: Constants and other definitions to be included in other files # -# Copyright (c) 2020-2022 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. @@ -11,3 +11,4 @@ VERSION = '0.2' NICK_MAX_LENGTH = 20 CHAN_MAX_LENGTH = 50 +MAX_LINE = 400 diff -rN -u old-irgramd/irc.py new-irgramd/irc.py --- old-irgramd/irc.py 2026-06-23 13:22:39.128590497 +0200 +++ new-irgramd/irc.py 2026-06-23 13:22:39.132590492 +0200 @@ -2,7 +2,7 @@ # irc.py: IRC server side implementation # # 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. @@ -18,7 +18,7 @@ # Local modules -from include import VERSION, CHAN_MAX_LENGTH, 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): @@ -532,11 +532,7 @@ 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/service.py new-irgramd/service.py --- old-irgramd/service.py 2026-06-23 13:22:39.128590497 +0200 +++ new-irgramd/service.py 2026-06-23 13:22:39.132590492 +0200 @@ -1,7 +1,7 @@ # irgramd: IRC-Telegram gateway # service.py: IRC service/control command handlers # -# Copyright (c) 2022,2023 E. Bosch +# Copyright (c) 2022-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. @@ -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: diff -rN -u old-irgramd/telegram.py new-irgramd/telegram.py --- old-irgramd/telegram.py 2026-06-23 13:22:39.128590497 +0200 +++ new-irgramd/telegram.py 2026-06-23 13:22:39.132590492 +0200 @@ -2,7 +2,7 @@ # telegram.py: Interface to Telethon Telegram library # # Copyright (c) 2019 Peter Bui -# Copyright (c) 2020-2024 E. Bosch +# Copyright (c) 2020-2026 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. @@ -15,14 +15,15 @@ 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_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 -from utils import get_highlighted, fix_braces, format_timestamp, pretty, current_date +from utils import get_highlighted, fix_braces, format_timestamp, pretty, current_date, token import emoji2emoticon as e # Test IP table @@ -43,6 +44,8 @@ self.notice_size = settings['download_notice'] * 1048576 self.media_dir = settings['media_dir'] self.media_url = settings['media_url'] + if self.media_url[-1:] != '/': + self.media_url += '/' self.upload_dir = settings['upload_dir'] self.api_id = settings['api_id'] self.api_hash = settings['api_hash'] @@ -58,7 +61,8 @@ self.geo_url = settings['geo_url'] if not settings['emoji_ascii']: e.emo = {} - self.media_cn = 0 + self.token = token('+0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz') + self.filename_token = self.token.gen_token(5) self.irc = irc self.authorized = False self.id = None @@ -176,16 +180,21 @@ self.irc.iid_to_tid[chan] = chat.id self.irc.irc_channels[chan] = set() # Add users from the channel - async for user in self.telegram_client.iter_participants(chat.id): - user_nick = self.set_ircuser_from_telegram(user) - 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): - self.irc.irc_channels_ops[chan].add(user_nick) - # Add creator users as founders in irc - elif isinstance(user.participant, tgty.ChatParticipantCreator): - self.irc.irc_channels_founder[chan].add(user_nick) + try: + async for user in self.telegram_client.iter_participants(chat.id): + user_nick = self.set_ircuser_from_telegram(user) + 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) 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) or \ + isinstance(user.participant, tgty.ChannelParticipantCreator): + self.irc.irc_channels_founder[chan].add(user_nick) + except: + self.logger.warning('Not possible to get participants of channel %s', channel) def get_telegram_nick(self, user): nick = (user.username @@ -307,8 +316,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) @@ -505,8 +522,9 @@ self.sorted_len_usernames.sort(key=lambda k: len(k), reverse=True) def format_reaction(self, msg, message_rendered, edition_case, reaction): - if len(message_rendered) > self.quote_len and not msg.is_reply: - text_old = '{}...'.format(message_rendered[:self.quote_len]) + 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 @@ -517,7 +535,7 @@ 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) + user = self.get_irc_user_from_telegram(msg.sender_id) react_action = '-' react_icon = '' return text_old, '{}{}'.format(react_action, react_icon), user @@ -555,9 +573,10 @@ # Reactions else: - if self.last_reaction == reaction.date: - return - self.last_reaction = reaction.date + if reaction: + if self.last_reaction == reaction.date: + return + self.last_reaction = reaction.date action = 'React' text_old, edition_react, user = self.format_reaction(event.message, message_rendered, edition_case, reaction) @@ -572,9 +591,9 @@ 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 self.last_reaction != react.date: + 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) @@ -1012,30 +1031,25 @@ if not self.download: return '' if filename: - idd_file = add_filename(filename, mid) - new_file = sanitize_filename(idd_file) - new_path = os.path.join(self.telegram_media_dir, new_file) - if os.path.exists(new_path): - local_path = new_path + aux_file = filename + else: + if hasattr(message, 'file') and message.file is not None: + aux_file = self.filename_token + message.file.ext else: - await self.notice_downloading(size, relay_attr) - local_path = await message.download_media(new_path) - if not local_path: return '' + aux_file = self.filename_token + + idd_file = add_filename(aux_file, mid) + new_file = sanitize_filename(idd_file) + new_path = os.path.join(self.telegram_media_dir, new_file) + if os.path.exists(new_path) and (size == 0 or size == os.path.getsize(new_path)): + local_path = new_path else: await self.notice_downloading(size, relay_attr) - local_path = await message.download_media(self.telegram_media_dir) + local_path = await message.download_media(new_path) if not local_path: return '' - filetype = os.path.splitext(local_path)[1] - gen_file = str(self.media_cn) + filetype - idd_file = add_filename(gen_file, mid) - new_file = sanitize_filename(idd_file) - self.media_cn += 1 - new_path = os.path.join(self.telegram_media_dir, new_file) if local_path != new_path: os.replace(local_path, new_path) - if self.media_url[-1:] != '/': - self.media_url += '/' return self.media_url + new_file async def notice_downloading(self, size, relay_attr): diff -rN -u old-irgramd/utils.py new-irgramd/utils.py --- old-irgramd/utils.py 2026-06-23 13:22:39.132590492 +0200 +++ new-irgramd/utils.py 2026-06-23 13:22:39.136590488 +0200 @@ -2,7 +2,7 @@ # utils.py: Helper functions # # Copyright (c) 2019 Peter Bui -# Copyright (c) 2020-2024 E. Bosch +# Copyright (c) 2020-2026 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. @@ -14,12 +14,15 @@ import zoneinfo import difflib import logging +import random # Constants -FILENAME_INVALID_CHARS = re.compile('[/{}<>()"\'\\|&#%?]') +FILENAME_INVALID_CHARS = re.compile('[\0-\x1F/{}<>"\'\\|*&#%?\x7F]') SIMPLE_URL = re.compile('http(|s)://[^ ]+') +from include import MAX_LINE + # Utilities class command: @@ -61,9 +64,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() @@ -85,22 +87,23 @@ return messages_limited def sanitize_filename(fn): - 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 hexize(m): + return '-{:x}-'.format(ord(m.group(0))) + + new_fn = FILENAME_INVALID_CHARS.sub(hexize, fn) + return new_fn.lstrip('-').replace(' ','_') def add_filename(filename, add): if add: aux = filename.rsplit('.', 1) name = aux[0] + last_dot = '.' try: ext = aux[1] except: ext = '' - return '{}-{}.{}'.format(name, add, ext) + last_dot = '' + return '{}-{}{}{}'.format(name, add, last_dot, ext) else: return filename @@ -239,3 +242,15 @@ def pretty(object): return object.stringify() if LOGL.debug and object else object + +class token: + def __init__(self, alpha): + self.alpha = alpha + self.long_alpha = len(alpha) + + def gen_token(self, long_token): + if long_token == 1: + return self.alpha[random.randrange(self.long_alpha)] + else: + aux = self.gen_token(long_token - 1) + return aux + self.alpha[random.randrange(self.long_alpha)]