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. patch 3ba3cb2a3d628290c5d421637e779869c00fba7d Author: E. Bosch Date: Sun Dec 17 03:45:05 CET 2023 * telegram: Remove characters '#', '%' and '?' from filenames, are not valid for static files in HTTP URLs. patch 59776df1810cf643a8b7d70ab0502afde6a5c47f Author: E. Bosch Date: Sun Dec 17 02:49:18 CET 2023 * telegram: Add compact message IDs to filenames of media, this will prevent most of the possible collisions of media with the same filename patch 50155e1ef1b508f47640d4f570da84407efac9cb Author: E. Bosch Date: Fri Dec 15 00:00:44 CET 2023 * telegram: Improve metadata shown for audio and recording/voice media, and get filename from them patch 17b3e7fb0ee1d0153c40740e2934a2030bd9b0ba Author: E. Bosch Date: Thu Dec 7 21:16:12 CET 2023 * README update patch ec320fd2c408d1239ba7e980e42318e1ab50bb21 Author: E. Bosch Date: Sun Dec 3 00:12:34 CET 2023 * Correct OpenStreetMap URL in irgramdrc.sample patch 3976770a9424b2085ba1260328b3adb5ce6b1ebe Author: E. Bosch Date: Sat Dec 2 20:41:44 CET 2023 * telegram: Add target nick in private messages sent from the self user from another Telegram client patch d9d472cb672a8172ee1d54241f20899754fb0640 Author: E. Bosch Date: Tue Nov 28 23:53:52 CET 2023 * README update patch 9c73d6fcff003a7d9b4f5f90df4f6dc71d84ae9d Author: E. Bosch Date: Tue Nov 28 22:38:01 CET 2023 * service: Add absolute ID as argument to get command patch c36c361baf23612e31780ba510278ceef611b7ec Author: E. Bosch Date: Sun Nov 26 23:13:52 CET 2023 * telegram: Add support for the change of channel photo action including download of the new photo mapped to CTCP action on IRC patch 9c00648eaf7c048826dc4fe6bdbd509eabe57f47 Author: E. Bosch Date: Sun Nov 26 20:07:44 CET 2023 * telegram: Add support for pin message action irc: Add support for sending CTCP actions (me) to map actions from Telegram patch 583b2fd653aa6786aba38cb9a00c6e990f3a1d11 Author: E. Bosch Date: Sun Nov 19 00:54:47 CET 2023 * telegram: Change add symbol in editions from "_" to "+" patch 8167f7dfd2712a9a103504fe7763e2a497adf723 Author: E. Bosch Date: Sun Nov 19 00:43:04 CET 2023 * telegram: Refactor download media code. If named media files already downloaded, don't try to download again patch 24a50b886f4cef2e1da003b74921982f5da0941c Author: E. Bosch Date: Mon Oct 16 00:24:53 CEST 2023 * telegram: Add option "download_notice" to show the start of a big download patch 1fb88a2962a05a4a46ccec54dc3ae3b65f73ab78 Author: E. Bosch Date: Sun Oct 15 22:06:49 CEST 2023 * telegram: Add option "download_media" to control if media files must be downloaded patch a62e1a9973921a97198519dd5a74e1a4472b9364 Author: E. Bosch Date: Wed Oct 11 00:35:28 CEST 2023 * telegram: Support for showing question and options of polls patch 400207de8aa3ca0b2dfc8ff1eeaafeddc8221070 Author: E. Bosch Date: Sun Sep 17 20:57:11 CEST 2023 * README update patch 2ab08fbae8ec48e123e86a498c287e7afcd173e1 Author: E. Bosch Date: Sat Jul 22 20:32:19 CEST 2023 * telegram: Add compact ID to all replies patch 505cd255e7a1cea83385a48467c6d1d099b3bc5d Author: E. Bosch Date: Fri Jul 21 01:12:18 CEST 2023 * telegram: Add option for geo custom URL patch f83be7c1c04d7dcb5ebd0119895eafbc6d784ecb Author: E. Bosch Date: Thu Jul 20 04:46:06 CEST 2023 * README update patch ee2b02319b5759d67df9fcc08fdafbd57a8fcc80 Author: E. Bosch Date: Mon Jul 17 02:37:21 CEST 2023 * exclam: Add delete (!del) command to delete messages patch 1de628ed1063db8129e0c142d5fe08907b27930a Author: E. Bosch Date: Sun Jul 16 20:09:53 CEST 2023 * README update patch cbe1c8b9a949b8eb5b466496eb7eab3b2f3874bd Author: E. Bosch Date: Sun Jul 16 20:02:03 CEST 2023 * README update patch fe0c125aa83d95b5472c0bc599e883c9b47213f6 Author: E. Bosch Date: Tue Jul 11 23:54:41 CEST 2023 * exclam: Fix indentation patch 74e097435eeb72086c6ed3f43a98ce3f5463d1c6 Author: E. Bosch Date: Tue Jul 11 01:48:40 CEST 2023 * exclam: Add edit (!ed) command to modify already sent messages patch a8d4d79f7ef62a0938f42c2895c99e5d9341d50c Author: E. Bosch Date: Mon Jul 10 00:56:15 CEST 2023 * README update patch 93ee900b41a7ba1e912d3897d242f714e866de09 Author: E. Bosch Date: Mon Jul 10 00:52:02 CEST 2023 * Add configuration file sample (irgramdrc.sample) patch 274ec21ecc7d6c159237df2648668f54b65d5332 Author: E. Bosch Date: Sat Jul 8 01:58:26 CEST 2023 * telegram, irc: Refactor and improve routine for conversion of mentions patch 153aba3773e6ca3c7c505212ac1aeac19f15e68d Author: E. Bosch Date: Tue Jun 27 03:00:57 CEST 2023 * Fix typos in README patch 7225855530de2f42820f61b5a8f1083269aab749 Author: E. Bosch Date: Mon Jun 26 22:55:35 CEST 2023 * README update patch 1b816235b1b9baeae5f394f01c379bbb2e0136ce Author: E. Bosch Date: Mon Jun 26 22:23:59 CEST 2023 * README update patch ae837b8af1788904bfa4ed7430d331028475e8e3 Author: E. Bosch Date: Mon Jun 26 00:29:50 CEST 2023 * Remove trailing spaces patch 5b2c938f7967a3345435fe21b2127999d0975f50 Author: E. Bosch Date: Mon Jun 26 00:17:22 CEST 2023 * Add exclam module to handle channel/chat commands begining with exclamation mark (!) Implement reply (!re) as first exclam command patch cbec6bc5a68bcadaaea6113355f12056d10cf577 Author: E. Bosch Date: Fri Jun 23 23:49:58 CEST 2023 * telegram: Fix: in forwards when the original user is unknown use the ID patch dd182f24b1eb76b357ab4ab84363ca55669f7b97 Author: E. Bosch Date: Thu Jun 22 21:57:16 CEST 2023 * Move parse_command() to a new class, the code will be reused for the future exclam module patch 3ec451218b80bdd3c409b4aec91ae94ef4fa5c38 Author: E. Bosch Date: Thu Jun 15 20:08:03 CEST 2023 * telegram, irc: Add conversion of "mention:" (IRC style) to "@mention" in sent messages patch eb5b15eba474680cab8764d0242229617d960e38 Author: E. Bosch Date: Thu Jun 15 01:15:43 CEST 2023 * irc: Add log for user registered (authorized in IRC) patch 5da8c71bd90c689771ee2afddbb6f16a663c7c35 Author: E. Bosch Date: Wed Jun 14 00:43:05 CEST 2023 * emoji2emoticon: Add emoji "thinking face" to convert to ASCII patch 38a5f5ab57d1d819c40ed65f00e86c2cf6a9042f Author: E. Bosch Date: Sun Jun 11 22:27:57 CEST 2023 * emoji2emoticon: Add emoji "shushing face" to convert to ASCII patch 746b8dec8a7b53ef2750501c320aaa627d5ae7fe Author: E. Bosch Date: Sun Jun 11 00:44:55 CEST 2023 * irc: Separate character encoding options as input and output patch 7300fb2b6fde17386971ea497343afae8526fb4c Author: E. Bosch Date: Sat Jun 10 22:31:22 CEST 2023 * telegram, irc: Add conversion of mentions for self @username as well as other mentions in self messages [saved messages] patch da6b06a9a974df34a72286bd8b93194e712490d5 Author: E. Bosch Date: Thu Jun 8 00:26:21 CEST 2023 * telegram: Refactor forward handle and related functions Fix the use of saved_from_peer attribute and other improvements patch d6b83b6ed9dfd2c1692e79da8b54046f96e059f5 Author: E. Bosch Date: Tue Jun 6 00:09:40 CEST 2023 * telegram: Add "media_dir" option to set the download media directory outside of "config_dir" that didn't make sense, but keep it for compatibility if "media_dir" is not set patch 4c771fc4851d84b5ae451e1cd9280223c3c5b014 Author: E. Bosch Date: Thu Jun 1 19:55:14 CEST 2023 * telegram: Fix media contact attributes patch d147fb4ac2eb91490933ee7ba89ecffcde0255c1 Author: E. Bosch Date: Sat May 20 23:39:03 CEST 2023 * telegram: Add filters for received messages, in these filters: Include existing emoji to ASCII function Add conversion of "@mention" to "~mention~" as "@user" is used to denote channel operator in most IRC clients patch 00e75e93c744727f1f30cb8e409d53b495c500ed Author: E. Bosch Date: Tue May 16 23:13:51 CEST 2023 * service: Refactor "get" command handle, make it robust when compact id has not been initialized yet patch 72bb23b64e2392ff01f7e060670e98093cfe0839 Author: E. Bosch Date: Mon May 15 21:27:05 CEST 2023 * telegram: As messages are referenced by peer and id (in "get" and future commands), make the compact IDs more compact with offsets relative to peers (users or channels) patch 3fe74b2337a9d66bddd47052220c413836cb05f9 Author: E. Bosch Date: Sun May 14 00:39:42 CEST 2023 * service: Add "get" command to retrieve a specific message patch 720468dce764675132030772fcb93f4bb5b00724 Author: E. Bosch Date: Mon May 8 01:12:09 CEST 2023 * service: Add more indentation in brief help descriptions patch 3ddecf1fe8d1534ab6af10d2b3602245541d4234 Author: E. Bosch Date: Mon May 8 01:04:52 CEST 2023 * service: Add "mark_read" command to mark chats as read and reset mentions patch 940936aea4bf440b777cf726b9c88f21fc54f131 Author: E. Bosch Date: Sun May 7 11:49:05 CEST 2023 * service: In dialog list: Use timezone option to convert the date for "last" field patch ecba573e04bccde171413d8064bd44c957952ad2 Author: E. Bosch Date: Sun May 7 02:24:08 CEST 2023 * telegram: In highlight of editions, use dash (-) instead of dot (.) for marking words deleted, that represents strikethrough. Add some comments to the code patch 5c25f44f55e071966feef397617ebfeea52dd88c Author: E. Bosch Date: Sun May 7 01:31:34 CEST 2023 * telegram: Add an option to enable and control format of timestamps for history messages Add timezone option to convert timestamps patch 2f799847198dd8bd7070520f56803c063d74d524 Author: E. Bosch Date: Sat May 6 21:04:28 CEST 2023 * telegram: Fix arguments in call to relay_telegram_message() in deleted handle This should have been changed in patch "telegram: Change interface for received messages ..." patch 18bfe4820f2f732b9af4d882c9e60191cf435256 Author: E. Bosch Date: Fri May 5 00:23:04 CEST 2023 * telegram: Add support for retrieving history of messages, this is implemented by service command "history" patch 5588befb5af1bef24d1309002c728e1cd2799fe6 Author: E. Bosch Date: Thu May 4 22:04:15 CEST 2023 * telegram: Change event/message if statement by an expression patch 7fbbd73b77d52696b7d3ebdee564c07cea280f8a Author: E. Bosch Date: Tue May 2 23:37:17 CEST 2023 * telegram: Change interface for received messages, use message instead event object if possible, this will be necessary to inject messages from future history functionality patch 0d2a17b54534226b14e45db9a6f5116bd2c3c41b Author: E. Bosch Date: Sun Apr 30 00:53:29 CEST 2023 * telegram: Make more robust the forward handler, take into account if the user has a privacy option to only show the name patch 4f4de75d90e58e3985a7cbd8f6aad6ace683a406 Author: E. Bosch Date: Sat Apr 29 23:02:02 CEST 2023 * telegram: Add conversion of UTF-8 emojis to ASCII emoticons for all messages (not only reactions) patch 0bfa5e100cf36ddf987dd40df98b4fdbaa90728f Author: E. Bosch Date: Sat Apr 29 20:47:52 CEST 2023 * Add "emoji_ascii" option to control if emojis are converted to ASCII emoticons patch 22812451b9c8e65dc2d08c04bd4cbde65d8c9b23 Author: E. Bosch Date: Thu Apr 27 23:01:01 CEST 2023 * README update patch e9b83df5a08d4ddb130b53ef26f389438e3dc75d Author: E. Bosch Date: Wed Apr 26 21:20:04 CEST 2023 * README update patch 9fdc513df921e5f956b93feb4f061add2c55c548 Author: E. Bosch Date: Wed Apr 26 20:45:17 CEST 2023 * telegram: Rename option 'reply_length' as 'quote_length' and use it for quotes in reactions as well (not only for replies) patch 1d3ba8cce2f25104ebe89e202143ac27bfec87fa Author: E. Bosch Date: Tue Apr 25 22:34:42 CEST 2023 * telegram: Add support for showing reactions to messages Add conversion of UTF-8 emojis to ASCII emoticons patch 5b3071c8f389341108b84aad6b91351a118cddb0 Author: E. Bosch Date: Thu Apr 20 01:49:15 CEST 2023 * telegram: Relocate is_bot() method, to have get_* methods together patch 515b30d8c49e9ec8619b0c0e3249d093e88a2a74 Author: E. Bosch Date: Sun Apr 16 01:13:07 CEST 2023 * telegram: Add support for showing editions of messages, including highlight of differences patch 7f549d8b7544443878875c310fea6f1fb6385364 Author: E. Bosch Date: Wed Apr 12 00:43:22 CEST 2023 * telegram: Fix recognition of Telegram user, group, channel types patch e664cfbb6157d6f9eab1db5d836213317fe1c2e5 Author: E. Bosch Date: Tue Apr 11 09:45:58 CEST 2023 * Add compact message ids in messages sent from IRC that are echoed to IRC, add those messages to cache patch cee19ae7322cdc3235ad93d1666e9d44669ed16d Author: E. Bosch Date: Mon Apr 10 23:31:19 CEST 2023 * telegram: Add support for showing deleted messages patch d4f07b39c63d6cfefbf3f3c7bd91fac6969ce39d Author: E. Bosch Date: Sun Apr 9 01:07:35 CEST 2023 * telegram: Refactorize relay message functions, this will be necessary for deleted message support patch 1a0d9f40779a40ea3c7e9967b71d8142ceba0081 Author: E. Bosch Date: Sat Apr 8 20:56:09 CEST 2023 * telegram: Rename some functions: use "relay" instead of "handle", to clarify patch 677c32d4755b0de6d30000202b99de435ca742d3 Author: E. Bosch Date: Sat Apr 8 12:30:10 CEST 2023 * telegram: Add message cache patch 69fd83158f7bf48b9ab8ffc3cf18d7938cbe2967 Author: E. Bosch Date: Fri Mar 31 22:46:58 CEST 2023 * telegram: Add logging in handle_raw() patch d7fd113735674f001a113114fd8562b160458b86 Author: E. Bosch Date: Fri Mar 31 00:32:55 CEST 2023 * Remove obsolete dockerfile patch e1aef39b9e3f2cbe017b3c1ea324ccc9c330a73a Author: E. Bosch Date: Fri Mar 31 00:31:39 CEST 2023 * Update copyright year in license patch 945c376d09b4d456a8da3a5cc9b3ef17a0735abb Author: E. Bosch Date: Wed Mar 29 21:50:59 CEST 2023 * Update year in copyright notices patch aedc28b24f83d3fb3266bb7effe4cb7dcb3f7414 Author: E. Bosch Date: Wed Mar 29 21:07:06 CEST 2023 * telegram: Add 'reply_length' option to limit the length of the text refered in replies patch 0f6013389cd9efdee9025e5483d1f2d4cde460a8 Author: E. Bosch Date: Tue Mar 28 23:32:36 CEST 2023 * telegram: Add support for showing replies and forwards of messages patch 5f288223526f5888b98269a37b8c36df0be8f38d Author: E. Bosch Date: Fri Mar 24 23:45:37 CET 2023 * Use named parameter full_join in join_irc_channel(), remove default value False not needed patch 830950349c796059b0bbe9759c277846252d6db6 Author: E. Bosch Date: Wed Mar 22 22:15:41 CET 2023 * telegram: Use positive IDs when checking if a channel already exists when a new message is received patch 99ff6d5f15836e4bd53dfba9c49b1bcaf8a41b4b Author: E. Bosch Date: Sun Mar 19 02:40:30 CET 2023 * Fix deprecation warning about the use of asyncio.get_event_loop(), use asyncio.new_event_loop() instead patch a032f79bcbacc71d08e813af1e4b8b513111f34e Author: E. Bosch Date: Sun Mar 19 02:15:39 CET 2023 * service: Fix compact date/time for more than 1 year, in dialog list patch 3c474f69222f523a7b93853ead954940374688ad Author: E. Bosch Date: Sat Mar 18 05:25:42 CET 2023 * service: Use positive ids in dialog list, if not, channel names are not shown correctly patch 039674332424c9792bcc0ef23d43f47dd7189644 Author: E. Bosch Date: Sat Mar 18 05:12:31 CET 2023 * service: In dialog list: support entries with unknown names (shouldn't happen) patch 6ee9aa156a4ffb387c5d160d53ba72cc34d6e1bb Author: E. Bosch Date: Sat Mar 18 04:43:35 CET 2023 * telegram: Support (different) channels with same name patch d168c6b0635df9185312cd63f8332647d85bac4b Author: E. Bosch Date: Sat Mar 18 04:42:34 CET 2023 * telegram: Support empty (only containing self user) chats patch 08302dbf9ee8df43f79b27308551499d73115a72 Author: E. Bosch Date: Sat Mar 18 00:08:10 CET 2023 * telegram: Fix: use positive chat ids, it seems necessary at least in Telethon v1.24.0 patch 24d0a1e56c3957fbf4257882a52f2b98706b9fcd Author: E. Bosch Date: Fri Mar 17 23:03:05 CET 2023 * telegram: Minor optimization in set_irc_channel_from_telegram() patch ca97990016d86bdb491bbead36be23125a3548a9 Author: E. Bosch Date: Thu Mar 16 01:06:33 CET 2023 * irc: If there is no owner of a group in Telegram, in IRC show the service user as owner/founder patch 2e88501e2cdbf503df49404f4b4fffeed574658b Author: E. Bosch Date: Sat Mar 19 23:26:56 CET 2022 * service: Add dialog command (only with list subcommand by now) patch 60471321f48dd5fba8292ee22e9ee6a38f469508 Author: E. Bosch Date: Sat Mar 19 22:26:45 CET 2022 * service: Remove hostname from TelegramServ for compactness patch 062bc6053a000038e9edcc0af8974851f9fe753d Author: E. Bosch Date: Sat Mar 19 06:11:26 CET 2022 * Wait for Telegram authentication is checked before the "not authorized yet" message is given on IRC (by TelegramServ). This is convenient if an IRC client connects faster than Telegram connection is established when irgramd is started, so it won't give a fake "not authorized yet" message. patch 232723b9dcfbfc20de95b31bc610a8de971a95c3 Author: E. Bosch Date: Fri Mar 18 22:08:29 CET 2022 * service: Send reply messages from service only to the IRC connected user that sent the command patch 712de2f8a9af0ab75b8b3f05f7564e1987fe0453 Author: E. Bosch Date: Fri Mar 18 20:57:45 CET 2022 * irc: Add character encoding selection patch a9bce1e704e2e0a7c711e4b984e87b2919384554 Author: E. Bosch Date: Tue Mar 8 22:58:55 CET 2022 * service: Add code command, add ask_code option patch c659a2d6a4a69276aaa838c05e8fd6f3988cefdc Author: E. Bosch Date: Tue Mar 8 21:57:51 CET 2022 * irc: Add initial help message from service patch 2d85c1f72cbe23f45c514c3100ebe57bee0d576b Author: E. Bosch Date: Tue Mar 8 21:28:02 CET 2022 * service: Include brief description for help command itself patch 093705f786069ded6ba780f3dc2b53c928b6abbe Author: E. Bosch Date: Sun Mar 6 02:36:51 CET 2022 * irc: Add help functionality for service commands. Use tuples for single or multiple lines (output) from commands. patch 42b71525bc8af2e7a875fa0e091e5d62220a5937 Author: E. Bosch Date: Thu Mar 3 23:55:06 CET 2022 * irc: Fix and rename options for IRC address and port patch ad20db76c932d5cec9aa18df25d91abeabcb7f51 Author: E. Bosch Date: Thu Mar 3 23:07:36 CET 2022 * Fix translation of channel name from Telegram to IRC patch 2f1d894e6e9861771f4330aadef00d20279d4525 Author: E. Bosch Date: Wed Mar 2 20:18:03 CET 2022 * irc: Add class for service/control command parsing and handlers, by now with stub help command patch 7312031fd0700656a913ba6662c0c84d7a08ebc9 Author: E. Bosch Date: Tue Mar 1 20:02:36 CET 2022 * irc: Reorder handlers from most to least probability of use (small optimization) patch f8db901e130865d95c9a0a50ed1e0abe85a5a67a Author: E. Bosch Date: Sun Feb 27 01:05:16 CET 2022 * irc: Fix whois handler when user doesn't exist patch 6017f51ba8b0e51288cb9ed128f5caa6aa304088 Author: E. Bosch Date: Sun Feb 27 00:47:48 CET 2022 * irc: Add service/control user (TelegramServ), by now without functions patch e1a17b912b5984786b1fe73f14faa785efe8920e Author: E. Bosch Date: Wed Feb 23 20:32:39 CET 2022 * Update README patch 219dba6a312f4a8d40668f28f81baa8a8a535bfe Author: E. Bosch Date: Tue Feb 22 02:37:33 CET 2022 * telegram: Support to connect to test environment Add options to configure this patch a5b9bc679af09407eae949907dde1f04548d899b Author: E. Bosch Date: Sun Feb 20 02:49:33 CET 2022 * Fix copyright lines in README patch dcead852a97cd7cc87af9a757725173dcdf112ee Author: E. Bosch Date: Sun Feb 20 02:25:27 CET 2022 * Add copyright headers/notices patch fb97160021d62fa832132bda177b4d15df393334 Author: E. Bosch Date: Sun Feb 20 00:01:27 CET 2022 * telegram: In initial mapping: force the addition of self user just in case is not in dialogs (never sent self messages [saved messages]) patch 3a9740db19a230cfa53758a4d89dbe82ba024ff5 Author: E. Bosch Date: Sat Feb 19 02:10:00 CET 2022 * telegram: Improve web media handling patch 2637f19f7173e59977cd377994b39816084d3b84 Author: E. Bosch Date: Thu Feb 17 00:34:56 CET 2022 * irc: Define IRC connection log as info level, add disconnection log as info as well patch 8d22777b1339caf300a5b2e9b76f7d791ff0ec76 Author: E. Bosch Date: Wed Feb 16 23:22:04 CET 2022 * telegram: Add login code support entered by console during authorization check, add phone number option in config, required by login code patch cd15d391cfdd3f1db483a5235ac7233eb912e03e Author: E. Bosch Date: Wed Feb 16 02:18:10 CET 2022 * telegram: Add options for API_ID and API_HASH patch cbc19fde23bb1d3fff242dae13a40ab86514c3de Author: E. Bosch Date: Wed Feb 16 01:58:19 CET 2022 * Order config options patch 7dacfafb25c3114f77d91fb0a790658a144a6664 Author: E. Bosch Date: Sun Feb 13 02:52:08 CET 2022 * Remove unused tornado.httpclient import and ioloop initialization patch 6a0451271e91ed7c081a631b49780c0f3ed761e7 Author: E. Bosch Date: Sat Feb 12 03:02:43 CET 2022 * irc: Add special case localhost for hostname use FQDN if possible instead of simple hostname patch 6acc2599b3961c177cac87db79969190e17ec6ee Author: E. Bosch Date: Sat Feb 12 01:29:36 CET 2022 * irc: Improve MODE command hanlder Add support for empty banlist response diff -rN -u old-irgramd/Dockerfile new-irgramd/Dockerfile --- old-irgramd/Dockerfile 2024-10-23 06:36:30.410944281 +0200 +++ new-irgramd/Dockerfile 1970-01-01 01:00:00.000000000 +0100 @@ -1,14 +0,0 @@ -FROM alpine:latest -MAINTAINER Peter Bui - -RUN apk update && \ - apk add python3 py3-pip - -RUN pip3 install telethon tornado==5.1.1 - -RUN wget -O - https://gitlab.com/pbui/irtelegramd/-/archive/master/irtelegramd-master.tar.gz | tar xzvf - - -COPY irtelegramd.py /irtelegramd-master - -EXPOSE 6667 -ENTRYPOINT ["/irtelegramd-master/irtelegramd.py", "--address=0.0.0.0", "--config_dir=/var/lib/irtelegramd"] diff -rN -u old-irgramd/LICENSE new-irgramd/LICENSE --- old-irgramd/LICENSE 2024-10-23 06:36:30.410944281 +0200 +++ new-irgramd/LICENSE 2024-10-23 06:36:30.418944268 +0200 @@ -1,6 +1,7 @@ MIT License Copyright (c) 2019 Peter Bui +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-10-23 06:36:30.410944281 +0200 +++ new-irgramd/README.md 2024-10-23 06:36:30.418944268 +0200 @@ -1,16 +1,148 @@ # irgramd - IRC <-> Telegram Gateway irgramd is a gateway that allows connecting from an [IRC] client to -[Telegram] as a regular user (not bot) +[Telegram] as a regular user (not bot). irgramd is written in [python] (version 3), it acts as an IRC server where an IRC client can connect and on the other side it's a Telegram client -using the [Telethon] library +using the [Telethon] library. -**[ This is a fork from [pbui/irtelegramd] to resume the development ]** +**[irgramd primary repository] is in [darcs] version control system, github +is used as [project management and secondary repository]** + +**irgramd was forked from [pbui/irtelegramd], was heavily modified and +currently is a project on its own** + +**irgramd is under active development, though usable, several +planned features are not implemented yet** + +## How it works + +Configure your IRC client to connect to irgramd (running on the same host or +on a remote host) then you will see in your IRC client the Telegram groups +as IRC channels and Telegram users as IRC users, if you send a message to a +user or channel in IRC it will go to the corresponding user or group in +Telegram, and the same from Telegram to IRC. + +The users on Telegram using the official or other clients will see you with +your regular Telegram user account and will be indistinguishable for them +whether you are using irgramd or another Telegram client. + +Several IRC clients can connect to irgramd but they will see the same +Telegram account, this allows connecting to the same Telegram account from +different IRC clients on different locations or devices, so one irgramd +instance only connects to one Telegram account, if you want to connect to +several Telegram accounts you will need to run several irgramd instances. If +all IRC clients are disconnected, irgramd will remain connected to Telegram. + +irgramd can also be seen as a kind of bouncer ([BNC]), with the difference +that instead of talking IRC protocol on the client side, it talks Telegram +protocol (MTProto), and can hide the IP and location of the IRC client (if +executed in a different host). + +## Features + +- Channels, groups and private chats +- Users and channels mapped in IRC +- Messages (receive, send) +- Media in messages (receive, download, upload) +- Replies (receive, send) +- Forwards (receive, send) +- Deletions (receive, do) +- Editions (receive, do) +- Reactions (receive, send, remove) +- Polls (receive, show) +- Actions [pin message, channel photo] (receive) +- Dialogs management +- History +- Authentication and TLS for IRC +- Multiple connections from IRC + +## Requirements + +- [python] (>= v3.9) +- [telethon] (tested with v1.28.5) +- [tornado] (tested with v6.1.0) +- [aioconsole] (tested with v0.6.1) +- [pyPAM] (optional, tested with v0.4.2-13.4 from deb, [legacy web](https://web.archive.org/web/20110316070059/http://www.pangalactic.org/PyPAM/)) + +## Instalation + +### From darcs + + darcs clone https://src.presi.org/repos/darcs/irgramd + chmod +x irgramd/irgramd + +### From git + + git clone https://github.com/prsai/irgramd.git + chmod +x irgramd/irgramd + +## Configuration + +From irgramd directory `./irgramd --help` will show all configuration +options available, these options can be used directy in the command line or +in a file. + +When used in command line the separator is `-` (dash) with two leading +dashes, example: `--api-hash`. + +When used in a file the separator is `_` (underscore) without two leading +dashes nor underscores, example: `api_hash`. The syntax of this file is just +Python so strings are surrounded by quotes (`'`) and lists by brackets (`[]`). + +A sample of the configuration file is provided, copy it to the default +configuration location: + + mkdir -p ~/.config/irgramd + cp irgramd/irgramdrc.sample ~/.config/irgramd/irgramdrc + +And modified it with your API IDs and preferences. + +## Usage + +From irgramd directory, in foreground: + + ./irgramd + +In background (with logs): + + ./irgramd --log-file=irgramd.log & + +## Notes + +PAM authentication: it allows to authenticate IRC users from the system in +Unix/Linux. The user that executes irgramd must have permissions to use PAM +(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-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. [IRC]: https://en.wikipedia.org/wiki/Internet_Relay_Chat [Telegram]: https://telegram.org/ [python]: https://www.python.org/ [Telethon]: https://github.com/LonamiWebs/Telethon +[irgramd primary repository]: https://src.presi.org/darcs/irgramd +[darcs]: http://darcs.net +[project management and secondary repository]: https://github.com/prsai/irgramd [pbui/irtelegramd]: https://github.com/pbui/irtelegramd +[python]: https://www.python.org +[tornado]: https://www.tornadoweb.org +[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 1970-01-01 01:00:00.000000000 +0100 +++ new-irgramd/emoji2emoticon.py 2024-10-23 06:36:30.418944268 +0200 @@ -0,0 +1,100 @@ +# irgramd: IRC-Telegram gateway +# emoji2emoticon.py: UTF-8 Emoji to ASCII emoticon replacement +# +# (C) Copyright 2019,2023 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. + +emo = { + '\U0000270c': '"V"', + '\U00002764': '"<3"', + '\U0001f389': '"<*``"', + '\U0001f44c': '"ok"', + '\U0001f44d': '"+1"', + '\U0001f44e': '"-1"', + '\U0001f44f': '"m_m"', + '\U0001f525': '"\^^^/"', + '\U0001f600': '":D"', + '\U0001f601': '"xD"', + '\U0001f602': '"x_D"', + '\U0001f603': '":D"', + '\U0001f604': '"xD"', + '\U0001f605': '"x`D"', + '\U0001f606': '"xD"', + '\U0001f607': '"O:)"', + '\U0001f608': '"}:)"', + '\U0001f609': '";)"', + '\U0001f60a': '"x)"', + '\U0001f60b': '"xP"', + '\U0001f60c': '":)"', + '\U0001f60d': '"E>)"', + '\U0001f60e': '"B)"', + '\U0001f60f': '"- -,"', + '\U0001f610': '":|"', + '\U0001f611': '":|"', + '\U0001f612': '"-. -."', + '\U0001f613': '":`|"', + '\U0001f614': '":|"', + '\U0001f615': '":/"', + '\U0001f616': '":S"', + '\U0001f617': '":*"', + '\U0001f618': '":**"', + '\U0001f619': '"x*"', + '\U0001f61a': '"x*"', + '\U0001f61b': '":P"', + '\U0001f61c': '";P"', + '\U0001f61d': '"xP"', + '\U0001f61e': '":("', + '\U0001f61f': '":(("', + '\U0001f620': '":("', + '\U0001f621': '":("', + '\U0001f622': '":_("', + '\U0001f623': '"x("', + '\U0001f624': '":<("', + '\U0001f625': '":`("', + '\U0001f626': '":(|"', + '\U0001f627': '":(||"', + '\U0001f628': '"||:("', + '\U0001f629': '"::("', + '\U0001f62a': '":`("', + '\U0001f62b': '"x("', + '\U0001f62c': '":E"', + '\U0001f62d': '":__(|"', + '\U0001f62e': '":O"', + '\U0001f62f': '":o"', + '\U0001f630': '":`O"', + '\U0001f631': '":O>"', + '\U0001f632': '"8-O"', + '\U0001f633': '":8|"', + '\U0001f634': '":.zz"', + '\U0001f635': '"6)"', + '\U0001f636': '":"', + '\U0001f637': '":W"', + '\U0001f638': '">:D"', + '\U0001f639': '":_D"', + '\U0001f63a': '">:D"', + '\U0001f63b': '">E>D"', + '\U0001f63c': '">- -,"', + '\U0001f63d': '">:*"', + '\U0001f63e': '">:("', + '\U0001f63f': '">:_("', + '\U0001f640': '">:(|"', + '\U0001f641': '":("', + '\U0001f642': '":)"', + '\U0001f643': '"(:"', + '\U0001f644': '"o o,"', + '\U0001f914': '":-L"', + '\U0001f92b': '":-o-m"', + '\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: + line = line.replace(utf_emo, emo[utf_emo]) + return line diff -rN -u old-irgramd/exclam.py new-irgramd/exclam.py --- old-irgramd/exclam.py 1970-01-01 01:00:00.000000000 +0100 +++ new-irgramd/exclam.py 2024-10-23 06:36:30.418944268 +0200 @@ -0,0 +1,224 @@ +# irgramd: IRC-Telegram gateway +# exclam.py: IRC exclamation command handlers +# +# 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. + +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), + '!fwd': (self.handle_command_fwd, 2, 2, -1), + '!upl': (self.handle_command_upl, 1, 2, 2), + '!reupl': (self.handle_command_reupl, 2, 3, 3), + '!react': (self.handle_command_react, 2, 2, -1), + } + self.tg = telegram + self.irc = telegram.irc + self.tmp_ircnick = None + self.tmp_telegram_id = None + self.tmp_tg_msg = None + + async def command(self, message, telegram_id, user): + self.tmp_telegram_id = telegram_id + res = await self.parse_command(message, nick=None) + if isinstance(res, tuple): + await self.irc.send_msg(self.irc.service_user, None, res[0], user) + res = False + return res, self.tmp_tg_msg + + async def check_msg(self, cid): + id = self.tg.mid.id_to_num_offset(self.tmp_telegram_id, cid) + 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) + return id, chk_msg + + async def handle_command_re(self, cid=None, msg=None, help=None): + if not help: + id, chk_msg = await self.check_msg(cid) + if chk_msg is not None: + self.tmp_tg_msg = await self.tg.telegram_client.send_message(self.tmp_telegram_id, msg, reply_to=id) + reply = True + else: + 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 + reply += \ + ( + ' !re ', + 'Reply with to a message with on current', + 'channel/chat.', + ) + return reply + + async def handle_command_ed(self, cid=None, new_msg=None, help=None): + if not help: + id, ed_msg = await self.check_msg(cid) + if ed_msg is not None: + try: + self.tmp_tg_msg = await self.tg.telegram_client.edit_message(ed_msg, new_msg) + except MessageNotModifiedError: + self.tmp_tg_msg = ed_msg + reply = True + except MessageAuthorRequiredError: + reply = ('!ed: Not the author of the message to edit',) + else: + reply = True + else: + reply = ('Unknown message to edit',) + else: # HELP.brief or HELP.desc (first line) + reply = (' !ed Edit a message',) + if help == HELP.desc: # rest of HELP.desc + reply += \ + ( + ' !ed ', + 'Edit a message with on current channel/chat,', + ' replaces the current message.', + ) + return reply + + async def handle_command_del(self, cid=None, help=None): + if not help: + id, del_msg = await self.check_msg(cid) + 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 = ('!del: Not possible to delete',) + else: + self.tmp_tg_msg = None + reply = None + else: + reply = ('Unknown message to delete',) + else: # HELP.brief or HELP.desc (first line) + reply = (' !del Delete a message',) + if help == HELP.desc: # rest of HELP.desc + reply += \ + ( + ' !del ', + '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-10-23 06:36:30.414944274 +0200 +++ new-irgramd/include.py 2024-10-23 06:36:30.418944268 +0200 @@ -1,6 +1,13 @@ +# irgramd: IRC-Telegram gateway +# include.py: Constants and other definitions to be included in other files +# +# Copyright (c) 2020-2022 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. # Constants -VERSION = '0.1' +VERSION = '0.2' NICK_MAX_LENGTH = 20 -CHAN_MAX_LENGHT = 50 +CHAN_MAX_LENGTH = 50 diff -rN -u old-irgramd/irc.py new-irgramd/irc.py --- old-irgramd/irc.py 2024-10-23 06:36:30.414944274 +0200 +++ new-irgramd/irc.py 2024-10-23 06:36:30.418944268 +0200 @@ -1,3 +1,11 @@ +# irgramd: IRC-Telegram gateway +# irc.py: IRC server side implementation +# +# Copyright (c) 2019 Peter Bui +# Copyright (c) 2020-2023 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 collections import logging @@ -6,14 +14,15 @@ import string import time -import tornado.httpclient import tornado.ioloop # Local modules -from include import VERSION, CHAN_MAX_LENGHT, NICK_MAX_LENGTH +from include import VERSION, CHAN_MAX_LENGTH, NICK_MAX_LENGTH from irc_replies import irc_codes from utils import chunks, set_replace, split_lines +from service import service +from exclam import exclam # Constants @@ -48,8 +57,7 @@ class IRCHandler(object): def __init__(self, settings): self.logger = logging.getLogger() - self.ioloop = tornado.ioloop.IOLoop.current() - self.hostname = socket.gethostname() + self.hostname = socket.getfqdn() self.conf = settings self.users = {} @@ -60,7 +68,7 @@ async def run(self, stream, address): user = IRCUser(stream, address) - self.logger.debug('Running client connection from %s:%s', address[0], address[1]) + self.logger.info('Running IRC client connection from %s:%s', address[0], address[1]) while True: try: @@ -69,12 +77,13 @@ user.stream = None reason = user.close_reason if user.close_reason else ':Client disconnect' await self.send_users_irc(user, 'QUIT', (reason,)) + self.logger.info('Closing IRC client connection from %s:%s', address[0], address[1]) if user in self.users.values(): del self.users[user.irc_nick.lower()] user.del_from_channels(self) del user break - message = message.decode().replace('\r','\n') + message = message.decode(self.conf['char_in_encoding'], errors='replace').replace('\r','\n') self.logger.debug(message) for pattern, handler, register_required, num_params_required in self.irc_handlers: @@ -100,29 +109,32 @@ def set_telegram(self, tg): self.tg = tg + self.service = service(self.conf, self.tg) + self.exclam = exclam(self.tg) # IRC def initialize_irc(self): - self.irc_handlers = ( + self.irc_handlers = \ + ( # pattern handle register_required num_params_required + (IRC_PRIVMSG_RX, self.handle_irc_privmsg, True, ALL_PARAMS), + (IRC_PING_RX, self.handle_irc_ping, True, ALL_PARAMS), (IRC_JOIN_RX, self.handle_irc_join, True, ALL_PARAMS), - (IRC_LIST_RX, self.handle_irc_list, True, 0), (IRC_MODE_RX, self.handle_irc_mode, True, 1), - (IRC_MOTD_RX, self.handle_irc_motd, True, 0), (IRC_NAMES_RX, self.handle_irc_names, True, ALL_PARAMS), - (IRC_NICK_RX, self.handle_irc_nick, False, ALL_PARAMS), - (IRC_PART_RX, self.handle_irc_part, True, 1), - (IRC_PASS_RX, self.handle_irc_pass, False, ALL_PARAMS), - (IRC_PING_RX, self.handle_irc_ping, True, ALL_PARAMS), - (IRC_PRIVMSG_RX, self.handle_irc_privmsg, True, ALL_PARAMS), - (IRC_QUIT_RX, self.handle_irc_quit, False, 0), (IRC_TOPIC_RX, self.handle_irc_topic, True, ALL_PARAMS), - (IRC_USER_RX, self.handle_irc_user, False, ALL_PARAMS), (IRC_USERHOST_RX, self.handle_irc_userhost, True, 1), - (IRC_VERSION_RX, self.handle_irc_version, True, 0), + (IRC_PART_RX, self.handle_irc_part, True, 1), (IRC_WHO_RX, self.handle_irc_who, True, ALL_PARAMS), (IRC_WHOIS_RX, self.handle_irc_whois, True, ALL_PARAMS), + (IRC_LIST_RX, self.handle_irc_list, True, 0), + (IRC_NICK_RX, self.handle_irc_nick, False, ALL_PARAMS), + (IRC_MOTD_RX, self.handle_irc_motd, True, 0), + (IRC_USER_RX, self.handle_irc_user, False, ALL_PARAMS), + (IRC_QUIT_RX, self.handle_irc_quit, False, 0), + (IRC_VERSION_RX, self.handle_irc_version, True, 0), + (IRC_PASS_RX, self.handle_irc_pass, False, ALL_PARAMS), ) self.iid_to_tid = {} self.irc_channels = collections.defaultdict(set) @@ -130,11 +142,14 @@ self.irc_channels_founder = collections.defaultdict(set) self.start_time = time.strftime('%a %d %b %Y %H:%M:%S %z') + self.service_user = IRCUser(None, ('Services',''), self.conf['service_user'], + 'Control', 'Telegram Service', is_service=True) + self.users[self.conf['service_user'].lower()] = self.service_user async def send_irc_command(self, user, command): self.logger.debug('Send IRC Command: %s', command) command = command + '\r\n' - user.stream.write(command.encode()) + user.stream.write(command.encode(self.conf['char_out_encoding'], errors='replace')) # IRC handlers @@ -170,8 +185,7 @@ user.irc_nick = nick self.users[ni] = user if not user.registered and user.irc_username: - user.registered = True - await self.send_greeting(user) + await self.register(user) else: if user.registered: await self.reply_code(user, 'ERR_ERRONEUSNICKNAME', (nick,)) @@ -201,8 +215,7 @@ user.irc_username = username user.irc_realname = realname if user.irc_nick: - user.registered = True - await self.send_greeting(user) + await self.register(user) async def handle_irc_join(self, user, channels): self.logger.debug('Handling JOIN: %s', channels) @@ -214,7 +227,7 @@ else: for channel in channels.split(','): if channel.lower() in self.irc_channels.keys(): - await self.join_irc_channel(user, channel, True) + await self.join_irc_channel(user, channel, full_join=True) else: await self.reply_code(user, 'ERR_NOSUCHCHANNEL', (channel,)) @@ -258,25 +271,35 @@ async def handle_irc_mode(self, user, target, mode, arguments): self.logger.debug('Handling MODE: %s, %s, %s', target, mode, arguments) - if not mode: - tgt = target.lower() - if tgt in self.users.keys(): - if tgt == user.irc_nick: - await self.mode_user(user, user, False) - else: - await self.reply_code(user, 'ERR_USERSDONTMATCH') - elif tgt[0] == '#': - if tgt in self.irc_channels.keys(): - await self.mode_channel(user, target, False) - else: - await self.reply_code(user, 'ERR_NOSUCHCHANNEL', (target,)) + is_user = False + is_channel = False + + tgt = target.lower() + if tgt in self.users.keys(): + if tgt == user.irc_nick: + is_user = True else: - await self.reply_code(user, 'ERR_NOSUCHNICK', (target,)) + await self.reply_code(user, 'ERR_USERSDONTMATCH') + elif tgt[0] == '#': + if tgt in self.irc_channels.keys(): + is_channel = True + else: + await self.reply_code(user, 'ERR_NOSUCHCHANNEL', (target,)) + else: + await self.reply_code(user, 'ERR_NOSUCHNICK', (target,)) + + if not mode: + if is_user: + await self.mode_user(user, user, False) + if is_channel: + await self.mode_channel(user, target, False) + elif mode == 'b' and is_channel: + await self.reply_code(user, 'RPL_ENDOFBANLIST', (target,)) async def handle_irc_motd(self, user, target): self.logger.debug('Handling MOTD: %s', target) - if not target or target == self.hostname: + if not target or target == self.gethostname(user): await self.send_motd(user) else: await self.reply_code(user, 'ERR_NOSUCHSERVER', (target,)) @@ -291,7 +314,7 @@ async def handle_irc_ping(self, user, payload): self.logger.debug('Handling PING: %s', payload) - await self.reply_command(user, SRV, 'PONG', (self.hostname, payload)) + await self.reply_command(user, SRV, 'PONG', (self.gethostname(user), payload)) async def handle_irc_who(self, user, target): self.logger.debug('Handling WHO: %s', target) @@ -310,7 +333,7 @@ usr = self.users[usr.lower()] op = self.get_irc_op(usr.irc_nick, chan) await self.reply_code(user, 'RPL_WHOREPLY', (chan, usr.irc_username, - usr.address, self.hostname, usr.irc_nick, op, usr.irc_realname + usr.address, self.gethostname(user), usr.irc_nick, op, usr.irc_realname )) await self.reply_code(user, 'RPL_ENDOFWHO', (chan,)) @@ -318,11 +341,11 @@ self.logger.debug('Handling WHOIS: %s', nicks) for nick in nicks.split(','): ni = nick.lower() - real_ni = self.users[ni].irc_nick if ni in self.users.keys(): usr = self.users[ni] + real_ni = usr.irc_nick await self.reply_code(user, 'RPL_WHOISUSER', (real_ni, usr.irc_username, usr.address, usr.irc_realname)) - await self.reply_code(user, 'RPL_WHOISSERVER', (real_ni, self.hostname)) + await self.reply_code(user, 'RPL_WHOISSERVER', (real_ni, self.gethostname(user))) chans = usr.get_channels(self) if chans: await self.reply_code(user, 'RPL_WHOISCHANNELS', (real_ni, chans)) idle = await self.tg.get_telegram_idle(ni) @@ -334,10 +357,12 @@ ))) if await self.tg.is_bot(ni): await self.reply_code(user, 'RPL_WHOISBOT', (real_ni,)) - elif usr.tls or not usr.stream: + elif usr.tls or (not usr.stream and not usr.is_service): proto = 'TLS' if usr.tls else 'MTProto' - server = self.hostname if usr.stream else 'Telegram' + server = self.gethostname(user) if usr.stream else 'Telegram' await self.reply_code(user, 'RPL_WHOISSECURE', (real_ni, proto, server)) + if usr.is_service: + await self.reply_code(user, 'RPL_WHOISSERVICE', (real_ni,)) await self.reply_code(user, 'RPL_ENDOFWHOIS', (real_ni,)) else: await self.reply_code(user, 'ERR_NOSUCHNICK', (nick,)) @@ -346,8 +371,8 @@ self.logger.debug('Handling VERSION: %s', target) tgt = target.lower() - if not tgt or tgt == self.hostname or tgt in self.users.keys(): - await self.reply_code(user, 'RPL_VERSION', (VERSION, self.hostname)) + if not tgt or tgt == self.gethostname(user) or tgt in self.users.keys(): + await self.reply_code(user, 'RPL_VERSION', (VERSION, self.gethostname(user))) await self.send_isupport(user) else: await self.reply_code(user, 'ERR_NOSUCHSERVER', (target,)) @@ -356,22 +381,47 @@ self.logger.debug('Handling PRIVMSG: %s, %s', target, message) tgl = target.lower() + if self.service_user.irc_nick.lower() == tgl: + reply = await self.service.parse_command(message, user.irc_nick) + for reply_line in reply: + await self.send_msg(self.service_user, None, reply_line, user) + return + defered_send = None # Echo channel messages from IRC to other IRC connections # because they won't receive event from Telegram + # used defered_send function when id is known if tgl in self.irc_channels.keys(): - await self.send_msg_others(user, tgl, message) + chan = tgl + defered_send = self.send_msg_others + defered_target = chan + else: + chan = None if tgl == user.irc_nick: tgt = self.tg.tg_username.lower() # Echo message to the user him/herself in IRC # because no event will be received from Telegram - await self.send_msg(user, None, message) + # used defered_send function when id is known + defered_send = self.send_msg + defered_target = None else: tgt = tgl if tgt in self.iid_to_tid: + message = self.tg.replace_mentions(message, me_nick='', received=False) telegram_id = self.iid_to_tid[tgt] - await self.tg.telegram_client.send_message(telegram_id, message) + if message[0] == '!': + cont, tg_msg = await self.exclam.command(message, telegram_id, user) + else: + tg_msg = await self.tg.telegram_client.send_message(telegram_id, message) + cont = True + if cont: + mid = self.tg.mid.num_to_id_offset(telegram_id, tg_msg.id) + text = '[{}] {}'.format(mid, message) + self.tg.to_cache(tg_msg.id, mid, text, message, user, chan, media=None) + + if defered_send: + await defered_send(user, defered_target, text) else: await self.reply_code(user, 'ERR_NOSUCHNICK', (target,)) @@ -383,15 +433,24 @@ user.stream.close() # IRC functions + async def register(self, user): + self.logger.info('Registered IRC user "%s" from %s:%s', user.irc_nick, user.address, user.port) + + user.registered = True + await self.send_greeting(user) + await self.send_help(user) + await self.check_telegram_auth(user) - async def send_msg(self, source, target, message): + async def send_msg(self, source, target, message, selfuser=None): messages = split_lines(message) tgt = target.lower() if target else '' is_chan = tgt in self.irc_channels.keys() # source None (False): it's self Telegram user, see [1] source_mask = source.get_irc_mask() if source else '' for msg in messages: - if is_chan: + if selfuser: + irc_users = (selfuser,) + elif is_chan: irc_users = (u for u in self.users.values() if u.stream and u.irc_nick in self.irc_channels[tgt]) else: irc_users = (u for u in self.users.values() if u.stream) @@ -411,15 +470,24 @@ for irc_user in irc_users: await self.send_privmsg(irc_user, source_mask, target, message) + async def send_action(self, source, target, message): + action_message = '\x01ACTION {}\x01'.format(message) + await self.send_msg(source, target, action_message) + async def send_privmsg(self, user, source_mask, target, msg): # reference [1] src_mask = source_mask if source_mask else user.get_irc_mask() # target None (False): it's private, not a channel tgt = target if target else user.irc_nick + if self.tg.refwd_me: + msg = msg.format(user.irc_nick) + # replace self @username and other mentions for self messages sent by this instance of irgramd + msg = self.tg.replace_mentions(msg, user.irc_nick) + await self.send_irc_command(user, ':{} PRIVMSG {} :{}'.format(src_mask, tgt, msg)) async def reply_command(self, user, prfx, comm, params): - prefix = self.hostname if prfx == SRV else prfx.get_irc_mask() + prefix = self.gethostname(user) if prfx == SRV else prfx.get_irc_mask() p = len(params) if p == 1: fstri = ':{} {} {}' @@ -432,22 +500,22 @@ if params: nick = client if client else user.irc_nick rest = tail.format(*params) - stri = ':{} {} {} {}'.format(self.hostname, num, nick, rest) + stri = ':{} {} {} {}'.format(self.gethostname(user), num, nick, rest) else: - stri = ':{} {} {} :{}'.format(self.hostname, num, user.irc_nick, tail) + stri = ':{} {} {} :{}'.format(self.gethostname(user), num, user.irc_nick, tail) await self.send_irc_command(user, stri) async def send_greeting(self, user): await self.reply_code(user, 'RPL_WELCOME', (user.irc_nick,)) - await self.reply_code(user, 'RPL_YOURHOST', (self.hostname, VERSION)) + await self.reply_code(user, 'RPL_YOURHOST', (self.gethostname(user), VERSION)) await self.reply_code(user, 'RPL_CREATED', (self.start_time,)) - await self.reply_code(user, 'RPL_MYINFO', (self.hostname, VERSION)) + await self.reply_code(user, 'RPL_MYINFO', (self.gethostname(user), VERSION)) await self.send_isupport(user) await self.send_motd(user) await self.mode_user(user, user, True) async def send_motd(self, user): - await self.reply_code(user, 'RPL_MOTDSTART', (self.hostname,)) + await self.reply_code(user, 'RPL_MOTDSTART', (self.gethostname(user),)) await self.reply_code(user, 'RPL_MOTD', ('Welcome to the irgramd server',)) await self.reply_code(user, 'RPL_MOTD', ('',)) await self.reply_code(user, 'RPL_MOTD', ('This is not a normal IRC server, it\'s a gateway that',)) @@ -461,7 +529,24 @@ 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 self.service.initial_help(): + await self.send_msg(self.service_user, None, line, user) + + async def check_telegram_auth(self, user): + await self.tg.auth_checked.wait() + if not self.tg.authorized and not self.tg.ask_code: + for line in ( + '----', + 'Your Telegram account is not authorized yet,', + 'you must supply the code that Telegram sent to your phone', + 'or another client that is currently connected', + 'use /msg {} code '.format(self.service_user.irc_nick), + 'e.g. /msg {} code 12345'.format(self.service_user.irc_nick), + ): + await self.send_msg(self.service_user, user.irc_nick, line) async def send_users_irc(self, prfx, command, params): for usr in [x for x in self.users.values() if x.stream]: @@ -484,7 +569,7 @@ else: await self.reply_code(user, 'RPL_CHANNELMODEIS', (channel, modes,'')) - async def join_irc_channel(self, user, channel, full_join=False): + async def join_irc_channel(self, user, channel, full_join): entity_cache = [None] chan = channel.lower() real_chan = self.get_realcaps_name(chan) @@ -523,7 +608,10 @@ chan = channel.lower() topic = await self.tg.get_channel_topic(chan, entity_cache) timestamp = await self.tg.get_channel_creation(chan, entity_cache) - founder = list(self.irc_channels_founder[chan])[0] + if self.irc_channels_founder[chan]: + 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_TOPICWHOTIME', (channel, founder, timestamp)) @@ -554,10 +642,15 @@ npn = num_params_required return npn + def gethostname(self, user): + return 'localhost' if user.from_localhost else self.hostname + class IRCUser(object): - def __init__(self, stream, address, irc_nick=None, username='', realname=None): + def __init__(self, stream, address, irc_nick=None, username='', realname=None, is_service=False): self.stream = stream self.address = address[0] + self.port = str(address[1]) + self.from_localhost = True if address[0].split('.')[0] == '127' else False self.irc_nick = irc_nick self.irc_username = str(username) self.irc_realname = realname @@ -567,6 +660,7 @@ self.oper = False self.tls = False self.bot = None + self.is_service = is_service self.close_reason = '' def get_irc_mask(self): diff -rN -u old-irgramd/irc_replies.py new-irgramd/irc_replies.py --- old-irgramd/irc_replies.py 2024-10-23 06:36:30.414944274 +0200 +++ new-irgramd/irc_replies.py 2024-10-23 06:36:30.418944268 +0200 @@ -1,3 +1,10 @@ +# irgramd: IRC-Telegram gateway +# irc_replies.py: IRC reply and error codes +# +# Copyright (c) 2020-2022 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. irc_codes = \ { @@ -8,6 +15,7 @@ 'RPL_ISUPPORT': ('005', 'CASEMAPPING=ascii CHANLIMIT=#&+: CHANTYPES=&#+ CHANMODES=,,,nt CHANNELLEN={} NICKLEN={} SAFELIST :are supported by this server'), 'RPL_UMODEIS': ('221', ':{}'), 'RPL_USERHOST': ('302', ':{}'), + 'RPL_WHOISSERVICE': ('310', '{} :is an irgramd service'), 'RPL_WHOISUSER': ('311', '{} {} {} * :{}'), 'RPL_WHOISSERVER': ('312', '{} {} :irgramd gateway'), 'RPL_WHOISOPERATOR': ('313', '{} :is an irgramd operator'), @@ -28,6 +36,7 @@ 'RPL_WHOREPLY': ('352', '{} {} {} {} {} H{} :0 {}'), 'RPL_NAMREPLY': ('353', '{} {} :{}'), 'RPL_ENDOFNAMES': ('366', '{} :End of NAME reply'), + 'RPL_ENDOFBANLIST': ('368', '{} :End of channel ban list'), 'RPL_MOTDSTART': ('375', ':- {} Message of the day - '), 'RPL_MOTD': ('372', ':- {}'), 'RPL_ENDOFMOTD': ('376', 'End of MOTD command'), diff -rN -u old-irgramd/irgramd new-irgramd/irgramd --- old-irgramd/irgramd 2024-10-23 06:36:30.414944274 +0200 +++ new-irgramd/irgramd 2024-10-23 06:36:30.422944261 +0200 @@ -1,4 +1,12 @@ #!/usr/bin/env python3 +# +# irgramd: IRC-Telegram gateway - Main file +# +# Copyright (c) 2019 Peter Bui +# 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 @@ -12,13 +20,14 @@ from irc import IRCHandler from telegram import TelegramHandler +from utils import parse_loglevel # IRC Telegram Daemon class IRCTelegramd(tornado.tcpserver.TCPServer): def __init__(self, logger, settings): self.logger = logger - effective_port = settings['port'] + effective_port = settings['irc_port'] if settings['tls']: if not settings['tls_cert']: # error @@ -36,7 +45,7 @@ tornado.tcpserver.TCPServer.__init__(self, ssl_options=tls_context) - self.address = settings['address'] or '127.0.0.1' + self.address = settings['irc_address'] self.port = effective_port self.irc_handler = None self.tg_handler = None @@ -57,40 +66,97 @@ # 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)') - tornado.options.define('address', default=None, metavar='ADDRESS', help='Address to listen on.') - tornado.options.define('port', default=None, metavar='PORT', help='Port to listen on. (default 6667, default with TLS 6697)') tornado.options.define('config_dir', default='~/.config/irgramd', metavar='PATH', help='Configuration directory where telegram session info is saved') - 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') + tornado.options.define('download_media', default=True, help='Enable download of any media (photos, documents, etc.), if not set only a message of media will be shown') + tornado.options.define('download_notice', default=10, metavar='SIZE (MiB)', help='Enable a notice when a download starts if its size is greater than SIZE, this is useful when a download takes some time to be completed') + tornado.options.define('emoji_ascii', default=False, help='Replace emoji with ASCII emoticons') + tornado.options.define('geo_url', type=str, default=None, metavar='TEMPLATE_URL', help='Use custom URL for showing geo latitude/longitude location, eg. OpenStreetMap') + tornado.options.define('hist_timestamp_format', metavar='DATETIME_FORMAT', help='Format string for timestamps in history, see https://www.strfti.me') + tornado.options.define('irc_address', default='127.0.0.1', metavar='ADDRESS', help='Address to listen on for IRC') + 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('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') - 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') - # parse cmd line first time to get --config and --config_dir - tornado.options.parse_command_line() + tornado.options.define('phone', default=None, metavar='PHONE_NUMBER', help='Phone number associated with the Telegram account to receive the authorization codes if necessary') + tornado.options.define('quote_length', default=50, metavar='LENGTH', help='Max length of the text quoted in replies and reactions, if longer is truncated') + tornado.options.define('service_user', default='TelegramServ', metavar='SERVICE_NICK', help='Nick of the service/control user, must be a nick not used by a real Telegram user') + tornado.options.define('test', default=False, help='Connect to Telegram test environment') + tornado.options.define('test_datacenter', default=2, metavar='DATACENTER_NUMBER', help='Datacenter to connect to Telegram test environment') + tornado.options.define('test_host', default=None, metavar='HOST_IP', help='Host to connect to Telegram test environment (default: use a internal table depending on datacenter)') + tornado.options.define('test_port', default=443, metavar='PORT', help='Port to connect to Telegram test environment') + tornado.options.define('timezone', default='UTC', metavar='TIMEZONE', help='Timezone to use for dates (timestamps in history, last in dialogs, etc.)') + 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') + 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) - asyncio.get_event_loop().run_until_complete(irc_server.run(options)) - asyncio.get_event_loop().run_forever() + loop = asyncio.new_event_loop() + loop.run_until_complete(irc_server.run(options)) + loop.run_forever() diff -rN -u old-irgramd/irgramdrc.sample new-irgramd/irgramdrc.sample --- old-irgramd/irgramdrc.sample 1970-01-01 01:00:00.000000000 +0100 +++ new-irgramd/irgramdrc.sample 2024-10-23 06:36:30.422944261 +0200 @@ -0,0 +1,21 @@ +api_id=XXXXXX +api_hash='XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' +phone='XXXXXXXXXXX' +#ask_code=True +#irc_address='0.0.0.0' +#tls=True +#tls_cert='/path/to/certificate' +#tls_key='/path/to/key' +irc_nicks=['XXXXX', 'XXXXXX'] +media_url='https://server/token/' +#pam=True +#pam_group='XXXXX' +#char_in_encoding='iso-8859-1' +#char_out_encoding='iso-8859-1' +#emoji_ascii=True +hist_timestamp_format='[%m-%d %H:%M]' +#timezone='Europe/Madrid' +#geo url OpenStreetMap +#geo_url='https://osm.org/?mlat={lat}&mlon={long}&zoom=15' +#geo url Google Maps +#geo_url='https://maps.google.com/?q={lat},{long}' diff -rN -u old-irgramd/service.py new-irgramd/service.py --- old-irgramd/service.py 1970-01-01 01:00:00.000000000 +0100 +++ new-irgramd/service.py 2024-10-23 06:36:30.422944261 +0200 @@ -0,0 +1,271 @@ +# irgramd: IRC-Telegram gateway +# service.py: IRC service/control command handlers +# +# Copyright (c) 2022,2023 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 utils import compact_date, command, HELP +from telethon import utils as tgutils + +class service(command): + def __init__(self, settings, telegram): + self.commands = \ + { # Command Handler Arguments Min Max Maxsplit + 'code': (self.handle_command_code, 1, 1, -1), + 'dialog': (self.handle_command_dialog, 1, 2, -1), + 'get': (self.handle_command_get, 2, 2, -1), + 'help': (self.handle_command_help, 0, 1, -1), + 'history': (self.handle_command_history, 1, 3, -1), + 'mark_read': (self.handle_command_mark_read, 1, 1, -1), + } + self.ask_code = settings['ask_code'] + self.tg = telegram + 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: + reply = ('Code will be asked on console',) + elif code.isdigit(): + try: + await self.tg.telegram_client.sign_in(code=code) + except: + reply = ('Invalid code',) + else: + reply = ('Valid code', 'Telegram account authorized') + await self.tg.continue_auth() + else: # not isdigit + reply = ('Code must be numeric',) + + else: # HELP.brief or HELP.desc (first line) + reply = (' code Enter authorization code',) + if help == HELP.desc: # rest of HELP.desc + reply += \ + ( + ' code ', + 'Enter authorization code sent by Telegram to the phone or to', + 'another client connected.', + 'This authorization code usually is only needed the first time', + 'that irgramd connects to Telegram with a given account.', + ) + return reply + + async def handle_command_dialog(self, command=None, id=None, help=None): + if not help: + if command == 'archive': + pass + elif command == 'delete': + pass + elif command == 'list': + reply = \ + ( + 'Dialogs:', + ' {:<11} {:<9} {:<9} {:5} {:<3} {:<4} {:<6} {}'.format( + 'Id', 'Unread', 'Mentions', 'Type', 'Pin', 'Arch', 'Last', 'Name'), + ) + async for dialog in self.tg.telegram_client.iter_dialogs(): + id, type = tgutils.resolve_id(dialog.id) + unr = dialog.unread_count + men = dialog.unread_mentions_count + ty = self.tg.get_entity_type(dialog.entity, format='short') + pin = 'Yes' if dialog.pinned else 'No' + arch = 'Yes' if dialog.archived else 'No' + last = compact_date(dialog.date, self.tg.timezone) + if id == self.tg.id: + name_in_irc = self.tmp_ircnick + else: + name_in_irc = self.tg.get_irc_name_from_telegram_id(id) + + reply += (' {:<11d} {:<9d} {:<9d} {:5} {:<3} {:<4} {:<6} {}'.format( + id, unr, men, ty, pin, arch, last, name_in_irc), + ) + + else: # HELP.brief or HELP.desc (first line) + reply = (' dialog Manage conversations (dialogs)',) + if help == HELP.desc: # rest of HELP.desc + reply += \ + ( + ' 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', + ' list Show all dialogs', + ) + return reply + + async def handle_command_get(self, peer=None, mid=None, help=None): + if not help: + msg = None + peer_id, reply = self.get_peer_id(peer.lower()) + if reply: return reply + else: reply = () + + # If the ID starts with '=' is absolute ID, not compact ID + # character '=' is not used by compact IDs + if mid[0] == '=': + id = int(mid[1:]) + else: + id = self.tg.mid.id_to_num_offset(peer_id, mid) + if id is not None: + msg = await self.tg.telegram_client.get_messages(entity=peer_id, ids=id) + if msg is not None: + await self.tg.handle_telegram_message(event=None, message=msg, history=True) + else: + reply = ('Message not found',) + return reply + + else: # HELP.brief or HELP.desc (first line) + reply = (' get Get a message by id and peer',) + if help == HELP.desc: # rest of HELP.desc + reply += \ + ( + ' get ', + 'Get one message from peer with the compact or absolute ID', + ) + return reply + + async def handle_command_help(self, help_command=None, help=None): + + start_help = ('*** Telegram Service Help ***',) + end_help = ('*** End of Help ***',) + + if help == HELP.brief: + help_text = (' help This help',) + elif not help_command or help_command == 'help': + help_text = start_help + help_text += \ + ( + 'This service contains specific Telegram commands that irgramd', + 'cannot map to IRC commands. The following commands are available:', + ) + for command in self.commands.values(): + handler = command[0] + help_text += await handler(help=HELP.brief) + help_text += \ + ( + 'The commands begining with ! (exclamation) must be used directly', + 'in channels or chats. The following ! commands are available:', + ) + for command in self.irc.exclam.commands.values(): + handler = command[0] + help_text += await handler(help=HELP.brief) + help_text += \ + ( + 'If you need more information about a specific command you can use', + 'help ', + ) + help_text += end_help + elif help_command in (all_commands := dict(**self.commands, **self.irc.exclam.commands)).keys(): + handler = all_commands[help_command][0] + help_text = start_help + help_text += await handler(help=HELP.desc) + help_text += end_help + else: + help_text = ('help: Unknown command',) + return help_text + + async def handle_command_history(self, peer=None, limit='10', add_unread=None, help=None): + if not help: + async def get_unread(tgt): + async for dialog in self.tg.telegram_client.iter_dialogs(): + id, type = tgutils.resolve_id(dialog.id) + if id in self.tg.tid_to_iid.keys(): + name = self.tg.tid_to_iid[id] + if tgt == name.lower(): + count = dialog.unread_count + reply = None + break + else: + count = None + reply = ('Unknown unread',) + return count, reply + + def conv_int(num_str): + if num_str.isdigit(): + n = int(num_str) + err = None + else: + n = None + err = ('Invalid argument',) + return n, err + + tgt = peer.lower() + peer_id, reply = self.get_peer_id(tgt) + if reply: return reply + + if limit == 'unread': + add_unread = '0' if add_unread is None else add_unread + add_unread_int, reply = conv_int(add_unread) + if reply: return reply + + li, reply = await get_unread(tgt) + if reply: return reply + li += add_unread_int + elif add_unread is not None: + reply = ('Wrong number of arguments',) + return reply + elif limit == 'all': + li = None + else: + li, reply = conv_int(limit) + if reply: return reply + + his = await self.tg.telegram_client.get_messages(peer_id, limit=li) + for msg in reversed(his): + await self.tg.handle_telegram_message(event=None, message=msg, history=True) + reply = () + return reply + + else: # HELP.brief or HELP.desc (first line) + reply = (' history Get messages from history',) + if help == HELP.desc: # rest of HELP.desc + reply += \ + ( + ' history [|all|unread []]', + 'Get last number of messages already sent to ', + '(channel or user). If not set is 10.', + 'Instead of , "unread" is for messages not marked as read,', + 'optionally number of previous messages to the first unread.', + 'Instead of , "all" is for retrieving all available messages', + 'in .', + ) + return reply + + async def handle_command_mark_read(self, peer=None, help=None): + if not help: + peer_id, reply = self.get_peer_id(peer.lower()) + if reply: return reply + + await self.tg.telegram_client.send_read_acknowledge(peer_id, clear_mentions=True) + reply = ('',) + else: # HELP.brief or HELP.desc (first line) + reply = (' mark_read Mark messages as read',) + if help == HELP.desc: # rest of HELP.desc + reply += \ + ( + ' mark_read ', + 'Mark all messages on (channel or user) as read, this also will', + 'reset the number of mentions to you on .', + ) + return reply + + def get_peer_id(self, tgt): + if tgt in self.irc.users or tgt in self.irc.irc_channels: + peer_id = self.tg.get_tid(tgt) + reply = None + else: + peer_id = None + reply = ('Unknown user or channel',) + return peer_id, reply diff -rN -u old-irgramd/telegram.py new-irgramd/telegram.py --- old-irgramd/telegram.py 2024-10-23 06:36:30.414944274 +0200 +++ new-irgramd/telegram.py 2024-10-23 06:36:30.422944261 +0200 @@ -1,25 +1,36 @@ +# irgramd: IRC-Telegram gateway +# telegram.py: Interface to Telethon Telegram library +# +# Copyright (c) 2019 Peter Bui +# 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 +from telethon import types as tgty, utils as tgutils +from telethon.tl.functions.messages import GetMessagesReactionsRequest # 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, remove_slash, remove_http_s, get_human_size, get_human_duration - -# Configuration - -# GET API_ID and API_HASH from https://my.telegram.org/apps -# AND PUT HERE BEFORE RUNNING irgramd - -TELEGRAM_API_ID = -TELEGRAM_API_HASH = '' - +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', + } # Telegram @@ -27,7 +38,26 @@ 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'] + self.test = settings['test'] + self.test_dc = settings['test_datacenter'] + self.test_ip = settings['test_host'] if settings['test_host'] else TEST_IPS[self.test_dc] + self.test_port = settings['test_port'] + self.ask_code = settings['ask_code'] + self.quote_len = settings['quote_length'] + self.hist_fmt = settings['hist_timestamp_format'] + self.timezone = settings['timezone'] + self.geo_url = settings['geo_url'] + if not settings['emoji_ascii']: + e.emo = {} self.media_cn = 0 self.irc = irc self.authorized = False @@ -36,23 +66,38 @@ self.channels_date = {} self.mid = mesg_id('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$%+./_~') 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 = 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): os.makedirs(self.telegram_session_dir) # Construct Telegram client - telegram_session = os.path.join(self.telegram_session_dir, 'telegram') - self.telegram_client = telethon.TelegramClient(telegram_session, - TELEGRAM_API_ID, TELEGRAM_API_HASH - ) + if self.test: + self.telegram_client = telethon.TelegramClient(None, self.api_id, self.api_hash) + self.telegram_client.session.set_dc(self.test_dc, self.test_ip, self.test_port) + else: + telegram_session = os.path.join(self.telegram_session_dir, 'telegram') + self.telegram_client = telethon.TelegramClient(telegram_session, self.api_id, self.api_hash) # Initialize Telegram ID to IRC nick mapping self.tid_to_iid = {} @@ -60,24 +105,49 @@ # 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), ) for handler, event in callbacks: self.telegram_client.add_event_handler(handler, event) # Start Telegram client - await self.telegram_client.connect() + if self.test: + await self.telegram_client.start(self.phone, code_callback=lambda: str(self.test_dc) * 5) + else: + await self.telegram_client.connect() - if await self.telegram_client.is_user_authorized(): - self.authorized = True - await self.init_mapping() + while not await self.telegram_client.is_user_authorized(): + self.logger.info('Telegram account not authorized') + await self.telegram_client.send_code_request(self.phone) + self.auth_checked.set() + if not self.ask_code: + return + self.logger.info('You must provide the Login code that Telegram will ' + 'sent you via SMS or another connected client') + code = await aioconsole.ainput('Login code: ') + try: + await self.telegram_client.sign_in(code=code) + except: + pass + + await self.continue_auth() + + async def continue_auth(self): + self.logger.info('Telegram account authorized') + self.authorized = True + self.auth_checked.set() + await self.init_mapping() async def init_mapping(self): # Update IRC <-> Telegram mapping tg_user = await self.telegram_client.get_me() self.id = tg_user.id self.tg_username = self.get_telegram_nick(tg_user) + self.add_sorted_len_usernames(self.tg_username) + self.set_ircuser_from_telegram(tg_user) async for dialog in self.telegram_client.iter_dialogs(): chat = dialog.entity if isinstance(chat, tgty.User): @@ -90,8 +160,9 @@ tg_nick = self.get_telegram_nick(user) tg_ni = tg_nick.lower() if not user.is_self: - irc_user = IRCUser(None, ('Telegram',), tg_nick, user.id, self.get_telegram_display_name(user)) + irc_user = IRCUser(None, ('Telegram',''), tg_nick, user.id, self.get_telegram_display_name(user)) self.irc.users[tg_ni] = irc_user + self.add_sorted_len_usernames(tg_ni) self.tid_to_iid[user.id] = tg_nick self.irc.iid_to_tid[tg_ni] = user.id else: @@ -101,8 +172,9 @@ async def set_irc_channel_from_telegram(self, chat): channel = self.get_telegram_channel(chat) self.tid_to_iid[chat.id] = channel - self.irc.iid_to_tid[channel.lower()] = chat.id chan = channel.lower() + 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) @@ -134,13 +206,49 @@ return self.get_telegram_display_name(tg_user) def get_telegram_channel(self, chat): - return '#' + chat.title.replace(' ', '-') + chan = '#' + chat.title.replace(' ', '-').replace(',', '-') + while chan.lower() in self.irc.iid_to_tid: + chan += '_' + return chan def get_irc_user_from_telegram(self, tid): nick = self.tid_to_iid[tid] if nick == self.tg_username: return None return self.irc.users[nick.lower()] + def get_irc_name_from_telegram_id(self, tid): + if tid in self.tid_to_iid.keys(): + name_in_irc = self.tid_to_iid[tid] + else: + name_in_irc = '' + return name_in_irc + + async def get_irc_name_from_telegram_forward(self, fwd, saved): + from_id = fwd.saved_from_peer if saved else fwd.from_id + if from_id is None: + # telegram user has privacy options to show only the name + # or was a broadcast from a channel (no user) + name = fwd.from_name + else: + peer_id, type = self.get_peer_id_and_type(from_id) + if type == 'user': + try: + user = self.get_irc_user_from_telegram(peer_id) + except: + name = str(peer_id) + else: + if user is None: + name = '{}' + self.refwd_me = True + else: + name = user.irc_nick + else: + try: + name = await self.get_irc_channel_from_telegram_id(peer_id) + except: + name = '' + return name + async def get_irc_nick_from_telegram_id(self, tid, entity=None): if tid not in self.tid_to_iid: user = entity or await self.telegram_client.get_entity(tid) @@ -151,13 +259,14 @@ return self.tid_to_iid[tid] async def get_irc_channel_from_telegram_id(self, tid, entity=None): - if tid not in self.tid_to_iid: + rtid, type = tgutils.resolve_id(tid) + if rtid not in self.tid_to_iid: chat = entity or await self.telegram_client.get_entity(tid) channel = self.get_telegram_channel(chat) - self.tid_to_iid[tid] = channel - self.irc.iid_to_tid[channel] = tid + self.tid_to_iid[rtid] = channel + self.irc.iid_to_tid[channel] = rtid - return self.tid_to_iid[tid] + return self.tid_to_iid[rtid] async def get_telegram_channel_participants(self, tid): channel = self.tid_to_iid[tid] @@ -171,6 +280,8 @@ return nicks async def get_telegram_idle(self, irc_nick, tid=None): + if self.irc.users[irc_nick].is_service: + return None tid = self.get_tid(irc_nick, tid) user = await self.telegram_client.get_entity(tid) if isinstance(user.status,tgty.UserStatusRecently) or \ @@ -178,7 +289,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 @@ -188,18 +299,6 @@ idle = None return idle - async def is_bot(self, irc_nick, tid=None): - if self.irc.users[irc_nick].stream: - bot = False - else: - bot = self.irc.users[irc_nick].bot - if bot == None: - tid = self.get_tid(irc_nick, tid) - user = await self.telegram_client.get_entity(tid) - bot = user.bot - self.irc.users[irc_nick].bot = bot - return bot - async def get_channel_topic(self, channel, entity_cache): tid = self.get_tid(channel) # entity_cache should be a list to be a persistent and by reference value @@ -208,7 +307,7 @@ else: entity = await self.telegram_client.get_entity(tid) entity_cache[0] = entity - entity_type = self.get_entity_type(entity) + entity_type = self.get_entity_type(entity, format='long') return 'Telegram ' + entity_type + ' ' + str(tid) + ': ' + entity.title async def get_channel_creation(self, channel, entity_cache): @@ -236,52 +335,390 @@ tid = self.id return tid - def get_entity_type(self, entity): - return type(entity).__name__ + def get_entity_type(self, entity, format): + if isinstance(entity, tgty.User): + short = long = 'User' + elif isinstance(entity, tgty.Chat): + short = 'Chat' + long = 'Chat/Basic Group' + elif isinstance(entity, tgty.Channel): + if entity.broadcast: + short = 'Broad' + long = 'Broadcast Channel' + elif entity.megagroup: + short = 'Mega' + long = 'Super/Megagroup Channel' + elif entity.gigagroup: + short = 'Giga' + long = 'Broadcast Gigagroup Channel' + + return short if format == 'short' else long + + def get_peer_id_and_type(self, peer): + if isinstance(peer, tgty.PeerChannel): + id = peer.channel_id + type = 'chan' + elif isinstance(peer, tgty.PeerChat): + id = peer.chat_id + type = 'chan' + elif isinstance(peer, tgty.PeerUser): + id = peer.user_id + type = 'user' + else: + id = peer + type = '' + return id, type + + async def is_bot(self, irc_nick, tid=None): + user = self.irc.users[irc_nick] + if user.stream or user.is_service: + bot = False + else: + bot = user.bot + if bot == None: + tid = self.get_tid(irc_nick, tid) + tg_user = await self.telegram_client.get_entity(tid) + bot = tg_user.bot + user.bot = bot + return bot + + async def edition_case(self, msg): + def msg_edited(m): + return m.id in self.cache and \ + ( m.message != self.cache[m.id]['text'] + or m.media != self.cache[m.id]['media'] + ) + async def get_reactions(m): + react = await self.telegram_client(GetMessagesReactionsRequest(m.peer_id, id=[m.id])) + 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: + case = 'edition' + elif (reactions := await get_reactions(msg)) is None: + if msg_edited(msg): + case = 'edition' + else: + case = 'react-del' + elif react := max(reactions, key=lambda y: y.date): + case = 'react-add' + else: + if msg_edited(msg): + case = 'edition' + else: + case = 'react-del' + react = None + return case, react + + def to_cache(self, id, mid, message, proc_message, user, chan, media): + self.limit_cache(self.cache) + self.cache[id] = { + 'mid': mid, + 'text': message, + 'rendered_text': proc_message, + 'user': user, + 'channel': chan, + '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 + rargs = {} + def repl_mentioned(text, me_nick, received, mark, repl_pref, repl_suff): + new_text = text + + for user in self.sorted_len_usernames: + if user == self.tg_username: + if me_nick: + username = me_nick + else: + continue + else: + username = self.irc.users[user].irc_nick + + if received: + mention = mark + user + mention_case = mark + username + else: # sent + mention = user + mark + mention_case = username + mark + replcmnt = repl_pref + username + repl_suff + + # Start of the text + for ment in (mention, mention_case): + if new_text.startswith(ment): + new_text = new_text.replace(ment, replcmnt, 1) + + # Next words (with space as separator) + mention = ' ' + mention + mention_case = ' ' + mention_case + replcmnt = ' ' + replcmnt + new_text = new_text.replace(mention, replcmnt).replace(mention_case, replcmnt) + + return new_text + + if received: + mark = '@' + rargs['repl_pref'] = '~' + rargs['repl_suff'] = '~' + else: # sent + mark = ':' + rargs['repl_pref'] = '@' + rargs['repl_suff'] = '' + + if text.find(mark) != -1: + text_replaced = repl_mentioned(text, me_nick, received, mark, **rargs) + else: + text_replaced = text + return text_replaced + + def filters(self, text): + filtered = e.replace_mult(text, e.emo) + filtered = self.replace_mentions(filtered) + return filtered + + def add_sorted_len_usernames(self, username): + 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', pretty(event)) + + id = event.message.id + mid = self.mid.num_to_id_offset(event.message.peer_id, id) + fmid = '[{}]'.format(mid) + message = self.filters(event.message.message) + message_rendered = await self.render_text(event.message, mid, upd_to_webpend=None) + + edition_case, reaction = await self.edition_case(event.message) + if edition_case == 'edition': + action = 'Edited' + user = self.get_irc_user_from_telegram(event.sender_id) + if id in self.cache: + t = self.filters(self.cache[id]['text']) + rt = self.cache[id]['rendered_text'] + + ht, is_ht = get_highlighted(t, message) + else: + rt = fmid + is_ht = False + + if is_ht: + edition_react = ht + text_old = fmid + else: + edition_react = message + text_old = rt + if user is None: + self.refwd_me = True + + # Reactions + else: + 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) + + 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 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', pretty(event)) + + for deleted_id in event.original_update.messages: + if deleted_id in self.cache: + recovered_text = self.cache[deleted_id]['rendered_text'] + text = '|Deleted| {}'.format(recovered_text) + 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', pretty(update)) + if isinstance(update, tgty.UpdateWebPage) and isinstance(update.webpage, tgty.WebPage): - event = self.webpending.pop(update.webpage.id, None) - if event: - await self.handle_telegram_message(event, update.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, upd_to_webpend=None): - self.logger.debug('Handling Telegram Message: %s', event) + async def handle_telegram_message(self, event, message=None, upd_to_webpend=None, history=False): + self.logger.debug('Handling Telegram Message: %s', pretty(event or message)) - if self.mid.mesg_base is None: - self.mid.mesg_base = event.message.id + msg = event.message if event else message - user = self.get_irc_user_from_telegram(event.sender_id) - mid = self.mid.num_to_id(event.message.id - self.mid.mesg_base) + user = self.get_irc_user_from_telegram(msg.sender_id) + mid = self.mid.num_to_id_offset(msg.peer_id, msg.id) + 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 + + async def render_text(self, message, mid, upd_to_webpend, user=None): if upd_to_webpend: - text = await self.handle_webpage(upd_to_webpend, event.message) - elif event.message.media: - text = await self.handle_telegram_media(event) + text = await self.handle_webpage(upd_to_webpend, message, mid) + elif message.media: + text = await self.handle_telegram_media(message, user, mid) + else: + text = message.message + + if message.action: + final_text = await self.handle_telegram_action(message, mid) + return final_text + elif message.is_reply: + refwd_text = await self.handle_telegram_reply(message) + elif message.forward: + refwd_text = await self.handle_telegram_forward(message) + else: + refwd_text = '' + + target_mine = self.handle_target_mine(message.peer_id, user) + + final_text = '[{}] {}{}{}'.format(mid, target_mine, refwd_text, text) + final_text = self.filters(final_text) + return final_text + + def set_history_timestamp(self, text, history, date, action): + if history and self.hist_fmt: + timestamp = format_timestamp(self.hist_fmt, self.timezone, date) + if action: + res = '{} {}'.format(text, timestamp) + else: + res = '{} {}'.format(timestamp, text) + else: + 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) + if private: + await self.relay_telegram_private_message(user, text, action) + chan = None else: - text = event.message.message + chan = await self.relay_telegram_channel_message(message, user, text, channel, action) + return chan - message = '[{}] {}'.format(mid, text) + async def relay_telegram_private_message(self, user, message, action=None): + self.logger.debug('Relaying Telegram Private Message: %s, %s', user, message) - if event.message.is_private: - await self.handle_telegram_private_message(user, message) + if action: + await self.irc.send_action(user, None, message) else: - await self.handle_telegram_channel_message(event, user, message) + await self.irc.send_msg(user, None, message) - async def handle_telegram_private_message(self, user, message): - self.logger.debug('Handling Telegram Private Message: %s, %s', user, message) + async def relay_telegram_channel_message(self, message, user, text, channel, action): + if message: + entity = await message.get_chat() + chan = await self.get_irc_channel_from_telegram_id(message.chat_id, entity) + else: + chan = channel - await self.irc.send_msg(user, None, message) + self.logger.debug('Relaying Telegram Channel Message: %s, %s', chan, text) - async def handle_telegram_channel_message(self, event, user, message): - self.logger.debug('Handling Telegram Channel Message: %s', event) + if action: + await self.irc.send_action(user, chan, text) + else: + await self.irc.send_msg(user, chan, text) - entity = await event.message.get_chat() - channel = await self.get_irc_channel_from_telegram_id(event.message.chat_id, entity) - await self.irc.send_msg(user, channel, message) + 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 @@ -300,7 +737,7 @@ irc_nick = await self.get_irc_nick_from_telegram_id(event.action_message.sender_id) if event.user_added or event.user_joined: - await self.irc.join_irc_channel(irc_nick, irc_channel, False) + await self.irc.join_irc_channel(irc_nick, irc_channel, full_join=False) elif event.user_kicked or event.user_left: await self.irc.part_irc_channel(irc_nick, irc_channel) @@ -311,57 +748,141 @@ channel = self.get_telegram_channel(chat) self.tid_to_iid[chat.id] = channel self.irc.iid_to_tid[channel] = chat.id - await self.irc.join_irc_channel(self.irc.irc_nick, channel, True) + await self.irc.join_irc_channel(self.irc.irc_nick, channel, full_join=True) + + async def handle_telegram_action(self, message, mid): + if isinstance(message.action, tgty.MessageActionPinMessage): + replied = await message.get_reply_message() + cid = self.mid.num_to_id_offset(replied.peer_id, replied.id) + action_text = 'has pinned message [{}]'.format(cid) + elif isinstance(message.action, tgty.MessageActionChatEditPhoto): + _, media_type = self.scan_photo_attributes(message.action.photo) + photo_url = await self.download_telegram_media(message, mid) + action_text = 'has changed chat [{}] {}'.format(media_type, photo_url) + else: + action_text = '' + return action_text - async def handle_telegram_media(self, event): - message = event.message + async def handle_telegram_reply(self, message): + space = ' ' + trunc = '' + replied = await message.get_reply_message() + 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 = '...' + if replied_user is None: + replied_nick = '{}' + self.refwd_me = True + elif replied_user == '': + replied_nick = '' + else: + replied_nick = replied_user.irc_nick + + return '|Re {}: [{}]{}{}{}| '.format(replied_nick, cid, space, replied_msg, trunc) + + async def handle_telegram_forward(self, message): + space = space2 = ' ' + if not (forwarded_peer_name := await self.get_irc_name_from_telegram_forward(message.fwd_from, saved=False)): + space = '' + saved_peer_name = await self.get_irc_name_from_telegram_forward(message.fwd_from, saved=True) + if saved_peer_name and saved_peer_name != forwarded_peer_name: + 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 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 = '' + + return '|Fwd{}{}{}{}| '.format(space, forwarded_peer_name, space2, secondary_name) + + async def handle_telegram_media(self, message, user, mid): caption = ' | {}'.format(message.message) if message.message else '' to_download = True media_url_or_data = '' + size = 0 + filename = None + + def scan_doc_attributes(document): + attrib_file = attrib_av = filename = None + size = document.size + h_size = get_human_size(size) + for x in document.attributes: + if isinstance(x, tgty.DocumentAttributeVideo) or isinstance(x, tgty.DocumentAttributeAudio): + attrib_av = x + if isinstance(x, tgty.DocumentAttributeFilename): + attrib_file = x + filename = attrib_file.file_name if attrib_file else None + + return size, h_size, attrib_av, filename if isinstance(message.media, tgty.MessageMediaWebPage): to_download = False if isinstance(message.media.webpage, tgty.WebPage): # web - return await self.handle_webpage(message.media.webpage, message) + return await self.handle_webpage(message.media.webpage, message, mid) elif isinstance(message.media.webpage, tgty.WebPagePending): media_type = 'webpending' media_url_or_data = message.message caption = '' - self.webpending[message.media.webpage.id] = event + self.webpending[message.media.webpage.id] = message else: media_type = 'webunknown' media_url_or_data = message.message caption = '' elif message.photo: - size = message.media.photo.sizes[-1] - if hasattr(size, 'w') and hasattr(size, 'h'): - media_type = 'photo:{}x{}'.format(size.w, size.h) - else: - media_type = 'photo' - elif message.audio: media_type = 'audio' - elif message.voice: media_type = 'rec' + size, media_type = self.scan_photo_attributes(message.media.photo) + elif message.audio: + size, h_size, attrib_audio, filename = scan_doc_attributes(message.media.document) + dur = get_human_duration(attrib_audio.duration) if attrib_audio else '' + per = attrib_audio.performer or '' + tit = attrib_audio.title or '' + theme = ',{}/{}'.format(per, tit) if per or tit else '' + media_type = 'audio:{},{}{}'.format(h_size, dur, theme) + elif message.voice: + size, _, attrib_audio, filename = scan_doc_attributes(message.media.document) + dur = get_human_duration(attrib_audio.duration) if attrib_audio else '' + media_type = 'rec:{}'.format(dur) elif message.video: - size = get_human_size(message.media.document.size) - attrib = next(x for x in message.media.document.attributes if isinstance(x, tgty.DocumentAttributeVideo)) - dur = get_human_duration(attrib.duration) - media_type = 'video:{},{}'.format(size, dur) + size, h_size, attrib_video, filename = scan_doc_attributes(message.media.document) + dur = get_human_duration(attrib_video.duration) if attrib_video else '' + media_type = 'video:{},{}'.format(h_size, dur) elif message.video_note: media_type = 'videorec' elif message.gif: media_type = 'anim' elif message.sticker: media_type = 'sticker' elif message.document: - size = get_human_size(message.media.document.size) - media_type = 'file:{}'.format(size) + size, h_size, _, filename = scan_doc_attributes(message.media.document) + media_type = 'file:{}'.format(h_size) elif message.contact: media_type = 'contact' caption = '' to_download = False - if message.media.contact.first_name: - media_url_or_data += message.media.contact.first_name + ' ' - if message.media.contact.last_name: - media_url_or_data += message.media.contact.last_name + ' ' - if message.media.contact.phone_number: - media_url_or_data += message.media.contact.phone_number + if message.media.first_name: + media_url_or_data += message.media.first_name + ' ' + if message.media.last_name: + media_url_or_data += message.media.last_name + ' ' + if message.media.phone_number: + media_url_or_data += message.media.phone_number elif message.game: media_type = 'game' @@ -374,7 +895,12 @@ media_type = 'geo' caption = '' to_download = False - media_url_or_data = 'lat: {}, long: {}'.format(message.media.geo.lat, message.media.geo.long) + if self.geo_url: + geo_url = ' | ' + self.geo_url + else: + geo_url = '' + lat_long_template = 'lat: {lat}, long: {long}' + geo_url + media_url_or_data = lat_long_template.format(lat=message.media.geo.lat, long=message.media.geo.long) elif message.invoice: media_type = 'invoice' @@ -386,7 +912,7 @@ media_type = 'poll' caption = '' to_download = False - media_url_or_data = '' + media_url_or_data = self.handle_poll(message.media.poll) elif message.venue: media_type = 'venue' @@ -400,19 +926,37 @@ media_url_or_data = message.message if to_download: - media_url_or_data = await self.download_telegram_media(message) + relay_attr = (message, user, mid, media_type) + media_url_or_data = await self.download_telegram_media(message, mid, filename, size, relay_attr) return self.format_media(media_type, media_url_or_data, caption) - async def handle_webpage(self, webpage, message): + def handle_poll(self, poll): + text = poll.question + for ans in poll.answers: + text += '\n* ' + ans.text + return text + + def handle_target_mine(self, target, user): + # Add the target of messages sent by self user (me) + # received in other clients + target_id, target_type = self.get_peer_id_and_type(target) + if user is None and target_type == 'user' and target_id != self.id: + # self user^ + # as sender + irc_id = self.get_irc_name_from_telegram_id(target_id) + target_mine = '[T: {}] '.format(irc_id) + else: + target_mine = '' + return target_mine + + async def handle_webpage(self, webpage, message, mid): media_type = 'web' - logo = await self.download_telegram_media(message) - if webpage.url != webpage.display_url \ - and remove_slash(webpage.url) != webpage.display_url \ - and remove_http_s(webpage.url) != webpage.display_url: - media_url_or_data = '{} | {}'.format(webpage.url, webpage.display_url) + logo = await self.download_telegram_media(message, mid) + if is_url_equiv(webpage.url, webpage.display_url): + url_data = webpage.url else: - media_url_or_data = webpage.url + url_data = '{} | {}'.format(webpage.url, webpage.display_url) if message: # sometimes the 1st line of message contains the title, don't repeat it message_line = message.message.splitlines()[0] @@ -420,8 +964,18 @@ title = webpage.title else: title = '' + # extract the URL in the message, don't repeat it + message_url = extract_url(message.message) + if is_url_equiv(message_url, webpage.url): + if is_url_equiv(message_url, webpage.display_url): + media_url_or_data = message.message + else: + media_url_or_data = '{} | {}'.format(message.message, webpage.display_url) + else: + media_url_or_data = '{} | {}'.format(message.message, url_data) else: title = webpage.title + media_url_or_data = url_data if title and logo: caption = ' | {} | {}'.format(title, logo) @@ -437,30 +991,66 @@ def format_media(self, media_type, media_url_or_data, caption): return '[{}] {}{}'.format(media_type, media_url_or_data, caption) - async def download_telegram_media(self, message): - local_path = await message.download_media(self.telegram_media_dir) - if not local_path: return '' + def scan_photo_attributes(self, photo): + size = 0 + sizes = photo.sizes + ph_size = sizes[-1] + if isinstance(ph_size, tgty.PhotoSizeProgressive): + size = ph_size.sizes[-1] + else: + for x in sizes: + if isinstance(x, tgty.PhotoSize): + if x.size > size: + size = x.size + ph_size = x + if hasattr(ph_size, 'w') and hasattr(ph_size, 'h'): + media_type = 'photo:{}x{}'.format(ph_size.w, ph_size.h) + else: + media_type = 'photo' + + return size, media_type - if message.document: - new_file = sanitize_filename(os.path.basename(local_path)) + async def download_telegram_media(self, message, mid, filename=None, size=0, relay_attr=None): + 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 + else: + await self.notice_downloading(size, relay_attr) + local_path = await message.download_media(new_path) + if not local_path: return '' else: + await self.notice_downloading(size, relay_attr) + local_path = await message.download_media(self.telegram_media_dir) + if not local_path: return '' filetype = os.path.splitext(local_path)[1] - new_file = str(self.media_cn) + filetype + 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) - 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): + if relay_attr and size > self.notice_size: + message, user, mid, media_type = relay_attr + await self.relay_telegram_message(message, user, '[{}] [{}] [Downloading]'.format(mid, media_type)) + class mesg_id: def __init__(self, alpha): self.alpha = alpha self.base = len(alpha) self.alphaval = { i:v for v, i in enumerate(alpha) } - self.mesg_base = None + self.mesg_base = {} def num_to_id(self, num, neg=''): if num < 0: return self.num_to_id(-num, '-') @@ -471,6 +1061,12 @@ else: return neg + self.alpha[high] + self.alpha[low] + def num_to_id_offset(self, peer, num): + peer_id = self.get_peer_id(peer) + if peer_id not in self.mesg_base: + self.mesg_base[peer_id] = num + return self.num_to_id(num - self.mesg_base[peer_id]) + def id_to_num(self, id, n=1): if id: if id[0] == '-': return self.id_to_num(id[1:], -1) @@ -479,3 +1075,23 @@ return sum + aux else: return 0 + + def id_to_num_offset(self, peer, mid): + peer_id = self.get_peer_id(peer) + if peer_id in self.mesg_base: + id_rel = self.id_to_num(mid) + id = id_rel + self.mesg_base[peer_id] + else: + id = None + return id + + def get_peer_id(self, peer): + if isinstance(peer, tgty.PeerChannel): + id = peer.channel_id + elif isinstance(peer, tgty.PeerChat): + id = peer.chat_id + elif isinstance(peer, tgty.PeerUser): + id = peer.user_id + else: + id = peer + return id diff -rN -u old-irgramd/utils.py new-irgramd/utils.py --- old-irgramd/utils.py 2024-10-23 06:36:30.414944274 +0200 +++ new-irgramd/utils.py 2024-10-23 06:36:30.422944261 +0200 @@ -1,14 +1,51 @@ +# irgramd: IRC-Telegram gateway +# utils.py: Helper functions +# +# Copyright (c) 2019 Peter Bui +# 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 itertools import textwrap import re +import datetime +import zoneinfo +import difflib +import logging # Constants -FILENAME_INVALID_CHARS = re.compile('[/{}<>()"\'\\|&]') +FILENAME_INVALID_CHARS = re.compile('[/{}<>()"\'\\|&#%?]') +SIMPLE_URL = re.compile('http(|s)://[^ ]+') # Utilities +class command: + async def parse_command(self, line, nick): + command = line.partition(' ')[0].lower() + self.tmp_ircnick = nick + if command in self.commands.keys(): + handler, min_args, max_args, maxsplit = self.commands[command] + words = line.split(maxsplit=maxsplit)[1:] + num_words = len(words) + if num_words < min_args or num_words > max_args: + reply = ('Wrong number of arguments',) + else: + reply = await handler(*words) + else: + reply = ('Unknown command',) + + return reply + +class HELP: + 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 @@ -48,7 +85,24 @@ 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] + try: + ext = aux[1] + except: + ext = '' + return '{}-{}.{}'.format(name, add, ext) + else: + return filename def remove_slash(url): return url[:-1] if url[-1:] == '/' else url @@ -62,6 +116,16 @@ surl = url return remove_slash(surl) +def is_url_equiv(url1, url2): + if url1 and url2: + return url1 == url2 or remove_slash(remove_http_s(url1)) == remove_slash(remove_http_s(url2)) + else: + return False + +def extract_url(text): + url = SIMPLE_URL.search(text) + return url.group() if url else None + def get_human_size(size): human_units = ('', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') @@ -87,5 +151,91 @@ if h > 0: res = str(h) + 'h' if m > 0: res += str(m) + 'm' - if s > 0: res += str(s) + 's' + if s > 0 or duration < 60: res += str(s) + 's' return res + +def compact_date(date, tz): + delta = current_date() - date + date_local = date.astimezone(zoneinfo.ZoneInfo(tz)) + + if delta.days < 1: + compact_date = date_local.strftime('%H:%M') + elif delta.days < 365: + compact_date = date_local.strftime('%d-%b') + else: + compact_date = date_local.strftime('%Y') + + 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()) + delta_size = abs(awl - bwl) + highlighted = True + + if not a: + res = '> {}'.format(b) + elif delta_size > 5: + res = b + highlighted = False + else: + al = a.split(' ') + bl = b.split(' ') + diff = difflib.ndiff(al, bl) + ld = list(diff) + res = '' + d = '' + eq = 0 + + for i in ld: + if i == '- ' or i[0] == '?': + continue + elif i == ' ' or i == '+ ': + res += ' ' + continue + # deletion of words + elif i[0] == '-': + res += '-{}- '.format(i[2:]) + # addition of words + elif i[0] == '+': + res += '+{}+ '.format(i[2:]) + else: + res += '{} '.format(i[2:]) + eq += 1 + + delta_eq = bwl - eq + if delta_eq > 3: + res = b + highlighted = False + + return res, highlighted + +def fix_braces(text): + # Remove braces not closed, if the text was truncated + if text.endswith(' {...'): + subtext = text[:-5] + if not '{}' in subtext: + return '{}...'.format(subtext) + return text + +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