diff --git a/matrix-commander.py b/matrix-commander.py index dfadc8f..05b74ba 100644 --- a/matrix-commander.py +++ b/matrix-commander.py @@ -1,765 +1,89 @@ #!/usr/bin/env python3 -r"""matrix-commander.py. +r"""matrix_commander.py. -0123456789012345678901234567890123456789012345678901234567890123456789012345678 -0000000000111111111122222222223333333333444444444455555555556666666666777777777 - -[]( -https://github.com/poljar/matrix-nio) - - - -# matrix-commander - -Simple but convenient CLI-based Matrix client app for sending, receiving, -creating rooms, inviting, verifying, and so much more. - -- `matrix-commander` is a simple command-line [Matrix](https://matrix.org/) - client. -- It is a simple but convenient app to - - send Matrix text messages as well as text, image, audio, video or - other arbitrary files - - listen to and receive Matrix messages - - perform Matrix emoji verification - - create rooms - - invite to rooms -- It exclusively offers a command-line interface (CLI). -- Hence the word-play: matrix-command(lin)er -- There is no GUI and there are no windows (except for pop-up windows in - OS notification) -- It uses the [matrix-nio](https://github.com/poljar/matrix-nio/) SDK -- Both `matrix-nio` and `matrix-commander` are written in Python 3 - -# Summary - -This program is a simple but convenient app to send and receive Matrix -messages from the CLI in various different ways. - -Use cases for this program could be -- a bot or part of a bot, -- to send alerts, -- combine it with cron to publish periodic data, -- send yourself daily/weekly reminders via a cron job -- send yourself a daily song from your music collection -- a trivial way to fire off some instant messages from the command line -- to automate sending via programs and scripts -- a "blogger" who frequently sends messages and images to the same - room(s) could use it -- a person could write a diary or run a gratitutde journal by - sending messages to her/his own room -- as educational material that showcases the use of the `matrix-nio` SDK - -# Give it a Star -If you like it, use it, fork it, make a Pull Request or contribute. -Please give it a :star: on Github right now so others find it more easily. -:heart: - -# First Run, Set Up, Credentials File, End-to-end Encryption - -This program on the first run creates a credentials.json file. -The credentials.json file stores: homeserver, user id, -access token, device id, and room id. On the first run -it asks some questions, creates the token and device id -and stores everything in the credentials.json file. - -Since the credentials file holds an access token it -should be protected and secured. One can use different -credential files for different users or different rooms. - -On creation the credentials file will always be created in the local -directory, so the users sees it right away. This is fine if you have -only one or a few credential files, but for better maintainability -it is suggested to place your credentials files into directory -$HOME/.config/matrix-commander/. When the program looks for -a credentials file it will first look in local directory and then -as secondary choice it will look in directory -$HOME/.config/matrix-commander/. - -If you want to re-use an existing device id and an existing -access token, you can do so as well, just manually edit the -credentials file. However, for end-to-end encryption this will -NOT work. - -End-to-end encryption (e2ee) is enabled by default. It cannot be turned off. -Wherever possible end-to-end encryption will be used. For e2ee to work -efficiently a `store` directory is needed to store e2ee data persistently. -The default location for the store directory is a local directory named -`store`. Alternatively, as a secondary choice the program looks for a store -directory in $HOME/.local/shared/matrix-commander/store/. The user can always -specify a different location via the --store argument. If needed the `store` -directory will be created on the first run. - -From the second time the program is run, and on all -future runs it will use the homeserver, user id -and access token found in the credentials file to log -into the Matrix account. Now this program can be used -to easily send simple text messages, images, and so forth -to the preconfigured room. - -# Sending - -Messages to send can be provided -1) in the command line (-m or --message) -2) as input from the keyboard -3) through a pipe from stdin (|), i.e. piped in from another program. - -For sending messages the program supports various text formats: -1) text: default -2) html: HTML formated text -3) markdown: MarkDown formatted text -4) code: used a block of fixed-sized font, ideal for ASCII art or - tables, bash outputs, etc. -5) notification -6) split: splits messages into multiple units at given pattern - -Photos and images that can be sent. That includes files like -.jpg, .gif, .png or .svg. - -Arbirtary files like .txt, .pdf, .doc, audio files like .mp3 -or video files like .mp4 can also be sent. - -# Listening, Receiving - -One can listen to one or multiple rooms. Received messages will be displayed -on the screen. If desired, optionally, you can be notified of incoming -messages through the operating system standard notification system, usually a -small pop-up window. - -Messages can be received or listened to various ways: -1) Forever: the program runs forever, listens forever, and prints all - messages as they arrive in real-time. -2) Once: the program prints all the messages that are waiting in the queue, - i.e. all messages that have been sent in, and after printing them the - program terminates. -3) Tail: prints the last N read or unread messages of one or multiple - specified rooms and after printing them the program terminates. - -When listening to messages you can also choose to download and decrypt -media. Say, someone is sending a song. The mp3 file can be downloaded -and automatically decrypted for you. - -# Verification - -The program can accept verification request and verify other devices -via emojis. Do do so use the --verify option and the program will -await incoming verification request and act accordingly. - -# Room Operations, Actions on Rooms - -The program can create rooms, join, leave and forget rooms. -It can also send invitations to join rooms to -others (given that user has the appropriate permissions) as -well as ban, unban and kick other users from rooms. - -# Summary, TLDR - -This simple Matrix client written in Python allows you to send and -receive messages and verify other devices. End-to-end encryption is enabled -by default and cannot be turned off. - -# Dependencies - -- Python 3.8 or higher (3.7 will NOT work) installed -- libolm-dev must be installed as it is required by matrix-nio - - libolm-dev on Debian/Ubuntu, libolm-devel on Fedora, libolm on MacOS -- matrix-nio must be installed, see https://github.com/poljar/matrix-nio - - pip3 install --user --upgrade matrix-nio[e2e] -- python3 package markdown must be installed to support MarkDown format - - pip3 install --user --upgrade markdown -- python3 package python_magic must be installed to support image sending - - pip3 install --user --upgrade python_magic -- if (and only if) you want OS notification support, then the python3 - package notify2 and dbus-python should be installed - - pip3 install --user --upgrade dbus-python # optional - - pip3 install --user --upgrade notify2 # optional -- python3 package urllib must be installed to support media download - - pip3 install --user --upgrade urllib -- the matrix-commander.py file must be installed, and should have - execution permissions - - chmod 755 matrix-commander.py -- for a full list or requirements look at the `requirements.txt` file - - run `pip install -r requirements.txt` to automatically install - all required Python packages - - if you e.g. run on a headless server and don't want dbus-python and - notify2, please remove the corresponding 2 lines from - the `requirements.txt` file - -# Examples of calling `matrix-commander` - -``` -$ matrix-commander.py # first run; this will configure everything -$ # this created a credentials.json file, and a store directory -$ # optionally, if you want you can move credentials to app config directory -$ mkdir $HOME/.config/matrix-commander # optional -$ mv -i credentials.json $HOME/.config/matrix-commander/ -$ # optionally, if you want you can move store to the app share directory -$ mkdir $HOME/.local/share/matrix-commander # optional -$ mv -i store $HOME/.local/share/matrix-commander/ -$ # Now you are ready to run program for a second time -$ # Let us verify the device/room to where we want to send messages -$ # The other device will issue a "verify by emoji" request -$ matrix-commander.py --verify -$ # Now program is both configured and verified, let us send the first message -$ matrix-commander.py -m "First message!" -$ matrix-commander.py --debug # turn debugging on -$ matrix-commander.py --help # print help -$ matrix-commander.py # this will ask user for message to send -$ matrix-commander.py --message "Hello World!" # sends provided message -$ echo "Hello World" | matrix-commander.py # pipe input msg into program -$ matrix-commander.py -m msg1 -m msg2 # sends 2 messages -$ matrix-commander.py -m msg1 msg2 msg3 # sends 3 messages -$ df -h | matrix-commander.py --code # formatting for code/tables -$ matrix-commander.py -m "BOLD and ITALIC" --html -$ matrix-commander.py -m "- bullet1" --markdown -$ # take input from an RSS feed and split large RSS entries into multiple -$ # Matrix messages wherever the pattern "\n\n\n" is found -$ rssfeed | matrix-commander.py --split "\n\n\n" -$ matrix-commander.py --credentials usr1room2.json # select credentials file -$ matrix-commander.py --store /var/storage/ # select store directory -$ # Send to a specific room -$ matrix-commander.py -m "hi" --room '!YourRoomId:example.org' -$ # some shells require the ! of the room id to be escaped with \ -$ matrix-commander.py -m "hi" --room "\!YourRoomId:example.org" -$ # Send to multiple rooms -$ matrix-commander.py -m "hi" -r '!r1:example.org' '!r2:example.org' -$ # Send to multiple rooms, another way -$ matrix-commander.py -m "hi" -r '!r1:example.org' -r '!r2:example.org' -$ # send 2 images and 1 text -$ matrix-commander.py -i photo1.jpg photo2.img -m "Do you like my 2 photos?" -$ # send 1 image and no text -$ matrix-commander.py -i photo1.jpg -m "" -$ # send 1 audio and 1 text to 2 rooms -$ matrix-commander.py -a song.mp3 -m "Do you like this song?" \ - -r '!someroom1:example.com' '!someroom2:example.com' -$ # send a .pdf file and a video with a text -$ matrix-commander.py -f example.pdf video.mp4 -m "Here are the promised files" -$ # listen forever, get msgs in real-time and notify me via OS -$ matrix-commander.py --listen forever --os-notify -$ # listen forever, and show me also my own messages -$ matrix-commander.py --listen forever --listen-self -$ # listen once, get any new messages and quit -$ matrix-commander.py --listen once --listen-self -$ matrix-commander.py --listen once --listen-self | process-in-other-app -$ # listen to tail, get the last N messages and quit -$ matrix-commander.py --listen tail --tail 10 --listen-self -$ # listen to tail, another way of specifying it -$ matrix-commander.py --tail 10 --listen-self | process-in-other-app -$ # get the very last message -$ matrix-commander.py --tail 1 --listen-self -$ # listen to (get) all messages, old and new, and process them in another app -$ matrix-commander.py --listen all | process-in-other-app -$ # listen to (get) all messages, including own -$ matrix-commander.py --listen all --listen-self -$ # rename device-name, sometimes also called display-name -$ matrix-commander.py --rename-device "my new name" -$ # download and decrypt media files like images, audio, PDF, etc. -$ # and store downloaded files in directory "mymedia" -$ matrix-commander.py --listen forever --listen-self --download-media mymedia -$ # create rooms without name and topic, just with alias, use a simple alias -$ matrix-commander.py --room-create roomAlias1 -$ # don't use a well formed alias like '#roomAlias1:example.com' as it will -$ # confuse the server! -$ # BAD: matrix-commander.py --room-create roomAlias1 '#roomAlias1:example.com' -$ matrix-commander.py --room-create roomAlias2 -$ # create rooms with name and topic -$ matrix-commander.py --room-create roomAlias3 --name 'Fancy Room' \ - --topic 'All about Matrix' -$ matrix-commander.py --room-create roomAlias4 roomAlias5 \ - --name 'Fancy Room 4' -name 'Cute Room 5' \ - --topic 'All about Matrix 4' 'All about Nio 5' -$ # join rooms -$ matrix-commander.py --room-join '!someroomId1:example.com' \ - '!someroomId2:example.com' '#roomAlias1:example.com' -$ # leave rooms -$ matrix-commander.py --room-leave '#roomAlias1:example.com' \ - '!someroomId2:example.com' -$ # forget rooms, you have to first leave a room before you forget it -$ matrix-commander.py --room-forget '#roomAlias1:example.com' -$ # invite users to rooms -$ matrix-commander.py --room-invite '#roomAlias1:example.com' \ - --user '@user1:example.com' '@user2:example.com' -$ # ban users from rooms -$ matrix-commander.py --room-ban '!someroom1:example.com' \ - '!someroom2:example.com' \ - --user '@user1:example.com' '@user2:example.com' -$ # unban users from rooms, remember after unbanning you have to invite again -$ matrix-commander.py --room-unban '!someroom1:example.com' \ - '!someroom2:example.com' \ - --user '@user1:example.com' '@user2:example.com' -$ # kick users from rooms -$ matrix-commander.py --room-kick '!someroom1:example.com' \ - '#roomAlias2:example.com' \ - --user '@user1:example.com' '@user2:example.com' -$ # set log levels, INFO for matrix-commander and ERROR for modules below -$ matrix-commander.py -m "test" --log-level INFO ERROR -$ # example of how to quote text correctly, e.g. JSON text -$ matrix-commander -m '{title: "hello", message: "here it is"}' -$ matrix-commander -m "{title: \"hello\", message: \"here it is\"}" -$ matrix-commander -m "{title: \"${TITLE}\", message: \"${MSG}\"}" -$ matrix-commander -m "Don't do this" -$ matrix-commander -m 'He said "No" to me.' -``` - -# Usage -``` -usage: matrix-commander.py [-h] [-d] [--log-level LOG_LEVEL [LOG_LEVEL ...]] - [-c CREDENTIALS] [-r ROOM [ROOM ...]] - [--room-create ROOM_CREATE [ROOM_CREATE ...]] - [--room-join ROOM_JOIN [ROOM_JOIN ...]] - [--room-leave ROOM_LEAVE [ROOM_LEAVE ...]] - [--room-forget ROOM_FORGET [ROOM_FORGET ...]] - [--room-invite ROOM_INVITE [ROOM_INVITE ...]] - [--room-ban ROOM_BAN [ROOM_BAN ...]] - [--room-unban ROOM_UNBAN [ROOM_UNBAN ...]] - [--room-kick ROOM_KICK [ROOM_KICK ...]] - [--user USER [USER ...]] [--name NAME [NAME ...]] - [--topic TOPIC [TOPIC ...]] - [-m MESSAGE [MESSAGE ...]] [-i IMAGE [IMAGE ...]] - [-a AUDIO [AUDIO ...]] [-f FILE [FILE ...]] [-w] - [-z] [-k] [-p SPLIT] [-j CONFIG] [--proxy PROXY] - [-n] [-e] [-s STORE] [-l [LISTEN]] [-t [TAIL]] [-y] - [--print-event-id] [-u [DOWNLOAD_MEDIA]] [-o] - [-v [VERIFY]] [-x RENAME_DEVICE] [--version] - -Welcome to matrix-commander, a Matrix CLI client. ─── On first run this -program will configure itself. On further runs this program implements a -simple Matrix CLI client that can send messages, listen to messages, verify -devices, etc. It can send one or multiple message to one or multiple Matrix -rooms. The text messages can be of various formats such as "text", "html", -"markdown" or "code". Images, audio or arbitrary files can be sent as well. -For receiving there are three main options: listen forever, listen once and -quit, and get the last N messages and quit. Emoji verification is built-in -which can be used to verify devices. End-to-end encryption is enabled by -default and cannot be turned off. ─── See dependencies in source code or in -README.md on Github. For even more explications and examples also read the -documentation provided in the top portion of the source code and in the -GithubREADME.md file. - -optional arguments: - -h, --help show this help message and exit - -d, --debug Print debug information. If used once, only the log - level of matrix-commander is set to DEBUG. If used - twice ("-d -d" or "-dd") then log levels of both - matrix-commander and underlying modules are set to - DEBUG. "-d" is a shortcut for "--log-level DEBUG". See - also --log-level. "-d" takes precedence over "--log- - level". - --log-level LOG_LEVEL [LOG_LEVEL ...] - Set the log level(s). Possible values are "DEBUG", - "INFO", "WARNING", "ERROR", and "CRITICAL". If - --log_level is used with one level argument, only the - log level of matrix-commander is set to the specified - value. If --log_level is used with two level argument - (e.g. "--log-level WARNING ERROR") then log levels of - both matrix-commander and underlying modules are set - to the specified values. See also --debug. - -c CREDENTIALS, --credentials CREDENTIALS - On first run, information about homeserver, user, room - id, etc. will be written to a credentials file. By - default, this file is "credentials.json". On further - runs the credentials file is read to permit logging - into the correct Matrix account and sending messages - to the preconfigured room. If this option is provided, - the provided file name will be used as credentials - file instead of the default one. - -r ROOM [ROOM ...], --room ROOM [ROOM ...] - Send to this room or these rooms. None, one or - multiple rooms can be specified. The default room is - provided in credentials file. If a room (or multiple - ones) is (or are) provided in the arguments, then it - (or they) will be used instead of the one from the - credentials file. The user must have access to the - specified room in order to send messages there. - Messages cannot be sent to arbitrary rooms. When - specifying the room id some shells require the - exclamation mark to be escaped with a backslash. - --room-create ROOM_CREATE [ROOM_CREATE ...] - Create this room or these rooms. One or multiple room - aliases can be specified. The room (or multiple ones) - provided in the arguments will be created. The user - must be permitted to create rooms.Combine --room- - create with --name and --topic to add names and topics - to the room(s) to be created. - --room-join ROOM_JOIN [ROOM_JOIN ...] - Join this room or these rooms. One or multiple room - aliases can be specified. The room (or multiple ones) - provided in the arguments will be joined. The user - must have permissions to join these rooms. - --room-leave ROOM_LEAVE [ROOM_LEAVE ...] - Leave this room or these rooms. One or multiple room - aliases can be specified. The room (or multiple ones) - provided in the arguments will be left. - --room-forget ROOM_FORGET [ROOM_FORGET ...] - After leaving a room you should (most likely) forget - the room. Forgetting a room removes the users' room - history. One or multiple room aliases can be - specified. The room (or multiple ones) provided in the - arguments will be forgotten. If all users forget a - room, the room can eventually be deleted on the - server. - --room-invite ROOM_INVITE [ROOM_INVITE ...] - Invite one ore more users to join one or more rooms. - Specify the user(s) as arguments to --user. Specify - the rooms as arguments to this option, i.e. as - arguments to --room-invite. The user must have - permissions to invite users. - --room-ban ROOM_BAN [ROOM_BAN ...] - Ban one ore more users from one or more rooms. Specify - the user(s) as arguments to --user. Specify the rooms - as arguments to this option, i.e. as arguments to - --room-ban. The user must have permissions to ban - users. - --room-unban ROOM_UNBAN [ROOM_UNBAN ...] - Unban one ore more users from one or more rooms. - Specify the user(s) as arguments to --user. Specify - the rooms as arguments to this option, i.e. as - arguments to --room-unban. The user must have - permissions to unban users. - --room-kick ROOM_KICK [ROOM_KICK ...] - Kick one ore more users from one or more rooms. - Specify the user(s) as arguments to --user. Specify - the rooms as arguments to this option, i.e. as - arguments to --room-kick. The user must have - permissions to kick users. - --user USER [USER ...] - Specify one or multiple users. This option is only - meaningful in combination with options like --room- - invite, --room-ban, --room-unban, --room-kick. This - option --user specifies the users to be used with - these other room commands (like invite, ban, etc.) - --name NAME [NAME ...] - Specify one or multiple names. This option is only - meaningful in combination with option --room-create. - This option --name specifies the names to be used with - the command --room-create. - --topic TOPIC [TOPIC ...] - Specify one or multiple topics. This option is only - meaningful in combination with option --room-create. - This option --topic specifies the topics to be used - with the command --room-create. - -m MESSAGE [MESSAGE ...], --message MESSAGE [MESSAGE ...] - Send this message. If not specified, and no input - piped in from stdin, then message will be read from - stdin, i.e. keyboard. This option can be used multiple - time to send multiple messages. If there is data is - piped into this program, then first data from the pipe - is published, then messages from this option are - published. - -i IMAGE [IMAGE ...], --image IMAGE [IMAGE ...] - Send this image. This option can be used multiple time - to send multiple images. First images are send, then - text messages are send. - -a AUDIO [AUDIO ...], --audio AUDIO [AUDIO ...] - Send this audio file. This option can be used multiple - time to send multiple audio files. First audios are - send, then text messages are send. - -f FILE [FILE ...], --file FILE [FILE ...] - Send this file (e.g. PDF, DOC, MP4). This option can - be used multiple time to send multiple files. First - files are send, then text messages are send. - -w, --html Send message as format "HTML". If not specified, - message will be sent as format "TEXT". E.g. that - allows some text to be bold, etc. Only a subset of - HTML tags are accepted by Matrix. - -z, --markdown Send message as format "MARKDOWN". If not specified, - message will be sent as format "TEXT". E.g. that - allows sending of text formated in MarkDown language. - -k, --code Send message as format "CODE". If not specified, - message will be sent as format "TEXT". If both --html - and --code are specified then --code takes priority. - This is useful for sending ASCII-art or tabbed output - like tables as a fixed-sized font will be used for - display. - -p SPLIT, --split SPLIT - If set, split the message(s) into multiple messages - wherever the string specified with --split occurs. - E.g. One pipes a stream of RSS articles into the - program and the articles are separated by three - newlines. Then with --split set to "\n\n\n" each - article will be printed in a separate message. By - default, i.e. if not set, no messages will be split. - -j CONFIG, --config CONFIG - Location of a config file. By default, no config file - is used. If this option is provided, the provided file - name will be used to read configuration from. - --proxy PROXY Optionally specify a proxy for connectivity. By - default, i.e. if this option is not set, no proxy is - used. If this option is used a proxy URL must be - provided. The provided proxy URL will be used for the - HTTP connection to the server. The proxy supports - SOCKS4(a), SOCKS5, and HTTP (tunneling). Examples of - valid URLs are "http://10.10.10.10:8118" or - "socks5://user:password@127.0.0.1:1080". - -n, --notice Send message as notice. If not specified, message will - be sent as text. - -e, --encrypted Send message end-to-end encrypted. Encryption is - always turned on and will always be used where - possible. It cannot be turned off. This flag does - nothing as encryption is turned on with or without - this argument. - -s STORE, --store STORE - Path to directory to be used as "store" for encrypted - messaging. By default, this directory is "./store/". - Since encryption is always enabled, a store is always - needed. If this option is provided, the provided - directory name will be used as persistent storage - directory instead of the default one. Preferably, for - multiple executions of this program use the same store - for the same device. The store directory can be shared - between multiple different devices and users. - -l [LISTEN], --listen [LISTEN] - The --listen option takes one argument. There are - several choices: "never", "once", "forever", "tail", - and "all". By default, --listen is set to "never". So, - by default no listening will be done. Set it to - "forever" to listen for and print incoming messages to - stdout. "--listen forever" will listen to all messages - on all rooms forever. To stop listening "forever", use - Control-C on the keyboard or send a signal to the - process or service. The PID for signaling can be found - in a PID file in directory "/home/user/.run". "-- - listen once" will get all the messages from all rooms - that are currently queued up. So, with "once" the - program will start, print waiting messages (if any) - and then stop. The timeout for "once" is set to 10 - seconds. So, be patient, it might take up to that - amount of time. "tail" reads and prints the last N - messages from the specified rooms, then quits. The - number N can be set with the --tail option. With - "tail" some messages read might be old, i.e. already - read before, some might be new, i.e. never read - before. It prints the messages and then the program - stops. Messages are sorted, last-first. Look at --tail - as that option is related to --listen tail. The option - "all" gets all messages available, old and new. Unlike - "once" and "forever" that listen in ALL rooms, "tail" - and "all" listen only to the room specified in the - credentials file or the --room options. Furthermore, - when listening to messages, no messages will be sent. - Hence, when listening, --message must not be used and - piped input will be ignored. - -t [TAIL], --tail [TAIL] - The --tail option reads and prints up to the last N - messages from the specified rooms, then quits. It - takes one argument, an integer, which we call N here. - If there are fewer than N messages in a room, it reads - and prints up to N messages. It gets the last N - messages in reverse order. It print the newest message - first, and the oldest message last. If --listen-self - is not set it will print less than N messages in many - cases because N messages are obtained, but some of - them are discarded by default if they are from the - user itself. Look at --listen as this option is - related to --tail.Furthermore, when tailing messages, - no messages will be sent. Hence, when tailing or - listening, --message must not be used and piped input - will be ignored. - -y, --listen-self If set and listening, then program will listen to and - print also the messages sent by its own user. By - default messages from oneself are not printed. - --print-event-id If set and listening, then program will print also the - event id foreach message or other event. - -u [DOWNLOAD_MEDIA], --download-media [DOWNLOAD_MEDIA] - If set and listening, then program will download - received media files (e.g. image, audio, video, text, - PDF files). media will be downloaded to local - directory. By default, media will be downloaded to is - "./media/". You can overwrite default with your - preferred directory. If media is encrypted it will be - decrypted and stored decrypted. By default media files - will not be downloaded. - -o, --os-notify If set and listening, then program will attempt to - visually notify of arriving messages through the - operating system. By default there is no notification - via OS. - -v [VERIFY], --verify [VERIFY] - Perform verification. By default, no verification is - performed. Possible values are: "emoji". If - verification is desired, run this program in the - foreground (not as a service) and without a pipe. - Verification questions will be printed on stdout and - the user has to respond via the keyboard to accept or - reject verification. Once verification is complete, - stop the program and run it as a service again. Don't - send messages or files when you verify. - -x RENAME_DEVICE, --rename-device RENAME_DEVICE - Rename the current device to the new device name - provided. No other operations like sending, listening, - or verifying are allowed when renaming the device. - --version Print version information. After printing version - information program will continue to run. This is - useful for having version number in the log files. -``` - -# Features - -- CLI, Command Line Interface -- Python 3 -- Uses nio-template -- End-to-end encryption -- Storage for End-to-end encryption -- Storage of credentials -- Supports access token instead of password -- Sending messages -- Sending notices -- Sending formatted messages -- Sending MarkDown messages -- Message splitting before sending -- Sending Code-formatted messages -- Sending to one room -- Sending to multiple rooms -- Sending image files (photos, etc.) -- Sending of media files (music, videos, etc.) -- Sending of arbitrary files (PDF, xls, doc, txt, etc.) -- Receiving messages forever -- Receiving messages once -- Receiving last messages -- Receiving or skipping its own messages -- Receiving and downloading media files - - including automatic decryption -- Creating new rooms -- Joining rooms -- Leaving rooms -- Forgetting rooms -- Inviting other users to rooms -- Banning from rooms -- Unbanning from rooms -- Kicking from rooms -- Supports renaming of device -- Supports notification via OS of received messages -- Supports periodic execution via crontab -- Supports room aliases -- Provides PID files -- Logging (at various levels) -- In-source documentation -- Can be run as a service - -# For Developers - -- Don't change tabbing, spacing, or formating as file is automatically - sorted, linted and formated. -- `pylama:format=pep8:linters=pep8` -- first `isort` import sorter -- then `flake8` linter/formater -- then `black` linter/formater -- linelength: 79 - - isort matrix-commander.py - - flake8 matrix-commander.py - - python3 -m black --line-length 79 matrix-commander.py - -# License - -This program is free software: you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -When preparing to package `matrix-commander` for NIX the question -came up if `matrix-commander` is GPL3Only or GPL3Plus. GPL3PLus was -deemed to be better. As such the license was changed from GPL3Only -to GPL3Plus on May 25, 2021. Versions before this date are licensed -under GPL3. Versions on or after this date are GPL3Plus, i.e. -GPL3 or later. - -See [GPL3 at FSF](https://www.fsf.org/licensing/). - - -# Things to do, Things missing - -- see [Issues](https://github.com/8go/matrix-commander/issues) on Github - -# Final Remarks - -- Thanks to all of you who already have contributed! So appreciated! - - :heart: and :thumbsup: to @fyfe, @berlincount, @ezwen, @Scriptkiddi, - @pelzvieh, etc. -- Enjoy! -- Pull requests are welcome :heart: +For help and documentation, please read the README.md file. +Online available at: +https://github.com/8go/matrix-commander/blob/master/README.md """ +# 234567890123456789012345678901234567890123456789012345678901234567890123456789 +# 000000001111111111222222222233333333334444444444555555555566666666667777777777 + # automatically sorted by isort, # then formatted by black --line-length 79 + + import argparse +import ast import asyncio import datetime +import errno import getpass import json import logging import os import re # regular expression import select +import shutil +import ssl +import subprocess import sys import tempfile import textwrap +import time import traceback import urllib.request import uuid -from urllib.parse import urlparse +from importlib import metadata +from os import R_OK, access +from os.path import isfile +from ssl import SSLContext +from typing import Literal, Optional, Union +from urllib.parse import quote, urlparse +from uuid import uuid4 import aiofiles import aiofiles.os +import emoji import magic -from aiohttp import ClientConnectorError +from aiohttp import ClientConnectorError, ClientSession, TCPConnector, web from markdown import markdown -from nio import ( - AsyncClient, - AsyncClientConfig, - EnableEncryptionBuilder, - JoinError, - KeyVerificationCancel, - KeyVerificationEvent, - KeyVerificationKey, - KeyVerificationMac, - KeyVerificationStart, - LocalProtocolError, - LoginResponse, - MatrixRoom, - MessageDirection, - ProfileGetAvatarResponse, - RedactedEvent, - RedactionEvent, - RoomAliasEvent, - RoomBanError, - RoomCreateError, - RoomEncryptedAudio, - RoomEncryptedFile, - RoomEncryptedImage, - RoomEncryptedMedia, - RoomEncryptedVideo, - RoomEncryptionEvent, - RoomForgetError, - RoomInviteError, - RoomKickError, - RoomLeaveError, - RoomMemberEvent, - RoomMessage, - RoomMessageAudio, - RoomMessageEmote, - RoomMessageFile, - RoomMessageFormatted, - RoomMessageImage, - RoomMessageMedia, - RoomMessageNotice, - RoomMessagesError, - RoomMessageText, - RoomMessageUnknown, - RoomMessageVideo, - RoomNameEvent, - RoomReadMarkersError, - RoomResolveAliasError, - RoomUnbanError, - SyncError, - SyncResponse, - ToDeviceError, - UnknownEvent, - UpdateDeviceError, - UploadResponse, - crypto, -) +from nio import (AsyncClient, AsyncClientConfig, BaseRoomKeyRequest, + ContentRepositoryConfigError, DeleteDevicesAuthResponse, + DeleteDevicesError, DevicesError, DiscoveryInfoError, + DownloadError, DummyEvent, EnableEncryptionBuilder, + EncryptedToDeviceEvent, EncryptionError, ErrorResponse, Event, + ForwardedRoomKeyEvent, InviteMemberEvent, JoinedMembersError, + JoinedRoomsError, JoinError, KeyVerificationAccept, + KeyVerificationCancel, KeyVerificationEvent, + KeyVerificationKey, KeyVerificationMac, KeyVerificationStart, + LocalProtocolError, LoginInfoError, LoginResponse, + LogoutError, MatrixRoom, MessageDirection, OlmEvent, + PresenceGetError, PresenceSetError, ProfileGetAvatarResponse, + ProfileGetDisplayNameError, ProfileGetError, + ProfileSetAvatarResponse, ProfileSetDisplayNameError, + RedactedEvent, RedactionEvent, RoomAliasEvent, RoomBanError, + RoomCreateError, RoomDeleteAliasResponse, RoomEncryptedAudio, + RoomEncryptedFile, RoomEncryptedImage, RoomEncryptedMedia, + RoomEncryptedVideo, RoomEncryptionEvent, RoomForgetError, + RoomGetStateResponse, RoomGetVisibilityResponse, + RoomInviteError, RoomKeyEvent, RoomKeyRequest, + RoomKeyRequestCancellation, RoomKickError, RoomLeaveError, + RoomMemberEvent, RoomMessage, RoomMessageAudio, + RoomMessageEmote, RoomMessageFile, RoomMessageFormatted, + RoomMessageImage, RoomMessageMedia, RoomMessageNotice, + RoomMessagesError, RoomMessageText, RoomMessageUnknown, + RoomMessageVideo, RoomNameEvent, RoomPreset, + RoomPutAliasResponse, RoomReadMarkersError, RoomRedactError, + RoomResolveAliasError, RoomResolveAliasResponse, + RoomSendError, RoomUnbanError, RoomVisibility, SyncError, + SyncResponse, ToDeviceError, ToDeviceEvent, ToDeviceMessage, + UnknownEvent, UnknownToDeviceEvent, UpdateDeviceError, + UploadError, UploadResponse, crypto, responses) from PIL import Image +from xdg import BaseDirectory try: import notify2 @@ -768,20 +92,28 @@ try: except ImportError: HAVE_NOTIFY = False +try: + from nio import GetOpenIDTokenError + + HAVE_OPENID = True +except ImportError: + HAVE_OPENID = False # version number -VERSION = "2021-May-25" -# matrix-commander -PROG_WITHOUT_EXT = os.path.splitext(os.path.basename(__file__))[0] -# matrix-commander.py -PROG_WITH_EXT = os.path.basename(__file__) +VERSION = "2025-06-17" +VERSIONNR = "8.0.5" +# matrix-commander; for backwards compitability replace _ with - +PROG_WITHOUT_EXT = os.path.splitext(os.path.basename(__file__))[0].replace( + "_", "-" +) +# matrix-commander.py; for backwards compitability replace _ with - +PROG_WITH_EXT = os.path.basename(__file__).replace("_", "-") # file to store credentials in case you want to run program multiple times CREDENTIALS_FILE_DEFAULT = "credentials.json" # login credentials JSON file # e.g. ~/.config/matrix-commander/ -CREDENTIALS_DIR_LASTRESORT = ( - os.path.expanduser("~/.config/") - + os.path.splitext(os.path.basename(__file__))[0] -) +CREDENTIALS_DIR_LASTRESORT = os.path.expanduser( + BaseDirectory.xdg_config_home + "/" # "~/.config/" +) + os.path.splitext(os.path.basename(__file__))[0].replace("_", "-") # directory to be used by end-to-end encrypted protocol for persistent storage STORE_DIR_DEFAULT = "./store/" # e.g. ~/.local/share/matrix-commander/ @@ -790,8 +122,10 @@ STORE_DIR_DEFAULT = "./store/" # e.g. ~/.local/share/matrix-commander/store/ as actual persistent store dir STORE_PATH_LASTRESORT = os.path.normpath( ( - os.path.expanduser("~/.local/share/") - + os.path.splitext(os.path.basename(__file__))[0] + os.path.expanduser( + BaseDirectory.xdg_data_home + "/" + ) # ~/.local/share/ + + os.path.splitext(os.path.basename(__file__))[0].replace("_", "-") ) ) # e.g. ~/.local/share/matrix-commander/store/ @@ -809,18 +143,354 @@ PID_DIR_DEFAULT = os.path.normpath(os.path.expanduser("~/.run/")) PID_FILE_DEFAULT = os.path.normpath( PID_DIR_DEFAULT + "/" + PROG_WITHOUT_EXT + "." + str(uuid.uuid4()) + ".pid" ) -EMOJI = "emoji" # verification type +DEFAULT_LOG_LEVEL_LOWER_MODULE = logging.WARNING +# verification type, wait for incoming verification request +VERIFY_EMOJI = "emoji" +# verification type, send an outgoing verification request +VERIFY_EMOJI_REQ = "emojireq" +VERIFY_MANUAL = "manual" # verification type +VERIFY_DEFAULT = VERIFY_EMOJI +PRINT = "print" # version type +CHECK = "check" # version type ONCE = "once" # listening type NEVER = "never" # listening type FOREVER = "forever" # listening type ALL = "all" # listening type TAIL = "tail" # listening type +DEFAULT_SEPARATOR = " " # used for sperating columns in print outputs +SEP = DEFAULT_SEPARATOR LISTEN_DEFAULT = NEVER TAIL_UNUSED_DEFAULT = 0 # get 0 if --tail is not specified TAIL_USED_DEFAULT = 10 # get the last 10 msgs by default with --tail VERIFY_UNUSED_DEFAULT = None # use None if --verify is not specified -VERIFY_USED_DEFAULT = "emoji" # use emoji by default with --verify -RENAME_DEVICE_UNUSED_DEFAULT = None # use None if -m is not specified +VERIFY_USED_DEFAULT = VERIFY_DEFAULT # use 'emoji' by default with --verify +VERSION_UNUSED_DEFAULT = None # use None if --version is not specified +VERSION_USED_DEFAULT = PRINT # use 'print' by default with --version +SET_DEVICE_NAME_UNUSED_DEFAULT = None # use None if option is not specified +SET_DISPLAY_NAME_UNUSED_DEFAULT = None # use None option not used +NO_SSL_UNUSED_DEFAULT = None # use None if --no-ssl is not given +SSL_CERTIFICATE_DEFAULT = None # use None if --ssl-certificate is not given +MXC_ID_PLACEHOLDER = "__mxc_id__" +HOMESERVER_PLACEHOLDER = "__homeserver__" # like https://matrix.example.org +HOSTNAME_PLACEHOLDER = "__hostname__" # like matrix.example.org +ACCESS_TOKEN_PLACEHOLDER = "__access_token__" +USER_ID_PLACEHOLDER = "__user_id__" # like @ mc: matrix.example.com +DEVICE_ID_PLACEHOLDER = "__device_id__" +ROOM_ID_PLACEHOLDER = "__room_id__" +SYNC_FULL = "full" # sync with full_state=True for send actions +# SYNC_PARTIAL = "full" # sync with full_state=False for send actions +SYNC_OFF = "off" # no sync is done for send actions +SYNC_DEFAULT = SYNC_FULL +# text, intended for human consumption +OUTPUT_TEXT = "text" +# json, as close to as what NIO API provides, a few convenient fields added +# transport_response removed +OUTPUT_JSON = "json" +# json-max, json format, like "json" but with transport_response object added +OUTPUT_JSON_MAX = "json-max" +# json-spec, json format, if and only if output adheres 100% to Matrix +# Specification will the data be printed. Currently, only --listen (--tail) +# adhere to Spec and hence print a JSON object. All other print nothing. +OUTPUT_JSON_SPEC = "json-spec" +OUTPUT_DEFAULT = OUTPUT_TEXT + +# source, use media file name as provided by sender +MEDIA_NAME_SOURCE = "source" +# clean up source name. Use source name but with unusual chars replaced with _ +MEDIA_NAME_CLEAN = "clean" +# ignore source provided name, use event-id as media file name +# Looks like this $rsad57dafs57asfag45gsFjdTXW1dsfroBiO2IsidKk' +MEDIA_NAME_EVENTID = "eventid" +# ignore source provided name, use current time at receiver as media file name +# Looks like this '20231012_152234_266600', date_time_microseconds +MEDIA_NAME_TIME = "time" +# defaults to "clean" +MEDIA_NAME_DEFAULT = MEDIA_NAME_CLEAN +# chars allowed in a clean name: alphanumerical and these +MEDIA_NAME_CLEAN_CHARS = "._- ~$" + +# location of README.md file if it is not found on local harddisk +# used for --manual +README_FILE_RAW_URL = ( + "https://raw.githubusercontent.com/8go/matrix-commander/master/README.md" +) +INVITES_LIST = "list" +INVITES_JOIN = "join" +INVITES_LIST_JOIN = "list+join" +INVITES_UNUSED_DEFAULT = None # use None if --room-invites is not specified +INVITES_USED_DEFAULT = ( + INVITES_LIST # use 'list' by default with --room-invites +) + +# increment this number and use new incremented number for next warning +# last unique Wxxx warning number used: W114: +# increment this number and use new incremented number for next error +# last unique Exxx error number used: E258: + + +class LooseVersion: + """Version numbering and comparison. + See https://github.com/effigies/looseversion/blob/main/looseversion.py. + Argument 'other' must be of type LooseVersion. + """ + + component_re = re.compile(r"(\d+ | [a-z]+ | \.)", re.VERBOSE) + + def __init__(self, vstring=None): + if vstring: + self.parse(vstring) + + def __eq__(self, other): + return self._cmp(other) == 0 + + def __lt__(self, other): + return self._cmp(other) < 0 + + def __le__(self, other): + return self._cmp(other) <= 0 + + def __gt__(self, other): + return self._cmp(other) > 0 + + def __ge__(self, other): + return self._cmp(other) >= 0 + + def parse(self, vstring): + self.vstring = vstring + components = [ + x for x in self.component_re.split(vstring) if x and x != "." + ] + for i, obj in enumerate(components): + try: + components[i] = int(obj) + except ValueError: + pass + self.version = components + + def __str__(self): + return self.vstring + + def __repr__(self): + return "LooseVersion ('%s')" % str(self) + + def _cmp(self, other): + if self.version == other.version: + return 0 + if self.version < other.version: + return -1 + if self.version > other.version: + return 1 + + +class MatrixCommanderError(Exception): + pass + + +class MatrixCommanderWarning(Warning): + pass + + +class GlobalState: + """Keep global variables. + + Trivial class to help keep some global state. + """ + + def __init__(self): + """Store global state.""" + self.log: logging.Logger = None # logger object + self.pa: argparse.Namespace = None # parsed arguments + # to which logic (message, image, audio, file, event) is + # stdin pipe assigned? + self.stdin_use: str = "none" + # 1) ssl None means default SSL context will be used. + # 2) ssl False means SSL certificate validation will be skipped + # 3) ssl a valid SSLContext means that the specified context will be + # used. This is useful to using local SSL certificate. + self.ssl: Union[None, SSLContext, bool] = None + self.client: Union[None, AsyncClient] = None + self.credentials: Union[None, dict] = None + self.send_action = False # argv contains send action + self.listen_action = False # argv contains listen action + self.room_action = False # argv contains room action + self.set_action = False # argv contains set action + self.get_action = False # argv contains get action + self.setget_action = False # argv contains set or get action + self.err_count = 0 # how many errors have occurred so far + self.warn_count = 0 # how many warnings have occurred so far + + +# Convert None to "", useful when reporting values to stdout +# Should only be called with a) None or b) a string. +# We want to avoid situation where we would print: name = None +def zn(str): + return str or "" + + +def get_qualifiedclassname(obj): + klass = obj.__class__ + module = klass.__module__ + if module == "builtins": + return klass.__qualname__ # avoid outputs like 'builtins.str' + return module + "." + klass.__qualname__ + + +def privacy_filter(dirty: str) -> str: + """Remove private info from string""" + # homeserver = urlparse(gs.credentials["homeserver"]) + # server_name = homeserver.netloc + # clean = dirty.replace(server_name, "your.homeserver.org") + return dirty.replace(gs.credentials["access_token"], "***") + + +def print_output( + option: Literal["text", "json", "json-max", "json-spec"], + *, + text: str, + json_: dict = None, + json_max: dict = None, + json_spec: dict = None, +) -> None: + """Print output according to which option is specified with --output""" + # json_ has the underscore to avoid a name clash with the module json + results = { + OUTPUT_TEXT: text, + OUTPUT_JSON: json_, + OUTPUT_JSON_MAX: json_max, + OUTPUT_JSON_SPEC: json_spec, + } + if results[option] is None: + if option == OUTPUT_JSON_SPEC: + gs.log.debug( + "Are you sure you wanted to use --output json-spec? " + "Most outputs will be empty." + ) + return + if option == OUTPUT_TEXT: + print(results[option], flush=True) + elif option == OUTPUT_JSON_SPEC: + print(json.dumps(results[option]), flush=True) + else: # OUTPUT_JSON or OUTPUT_JSON_MAX + print(json.dumps(results[option], default=obj_to_dict), flush=True) + + +def obj_to_dict(obj): + """Return dict of object + + Useful for json.dump() dict-to-json conversion. + """ + if gs.pa.verbose > 1: # 2+ + gs.log.debug(f"obj_to_dict: {obj.__class__}") + gs.log.debug(f"obj_to_dict: {obj.__class__.__name__}") + gs.log.debug(f"obj_to_dict: {get_qualifiedclassname(obj)}") + # summary: shortcut: just these 2: RequestInfo and ClientResponse + # if get_qualifiedclassname(obj) == "aiohttp.client_reqrep.RequestInfo": + # return {obj.__class__.__name__: str(obj)} + # if get_qualifiedclassname(obj) == "aiohttp.client_reqrep.ClientResponse": + # return {obj.__class__.__name__: str(obj)} + # details, one by one: + # if get_qualifiedclassname(obj) == "collections.deque": + # return {obj.__class__.__name__: str(obj)} + # if get_qualifiedclassname(obj) == "aiohttp.helpers.TimerContext": + # return {obj.__class__.__name__: str(obj)} + # if get_qualifiedclassname(obj) == "asyncio.events.TimerHandle": + # return {obj.__class__.__name__: str(obj)} + # if get_qualifiedclassname(obj) =="multidict._multidict.CIMultiDictProxy": + # return {obj.__class__.__name__: str(obj)} + # if get_qualifiedclassname(obj) == "aiosignal.Signal": + # return {obj.__class__.__name__: str(obj)} + # this one is crucial, it make the serialization circular reference. + if get_qualifiedclassname(obj) == "aiohttp.streams.StreamReader": + return {obj.__class__.__name__: str(obj)} + # these four are crucial, they make the serialization circular reference. + if ( + get_qualifiedclassname(obj) + == "asyncio.unix_events._UnixSelectorEventLoop" + ): + return {obj.__class__.__name__: str(obj)} + if get_qualifiedclassname(obj) == "aiohttp.tracing.Trace": + return {obj.__class__.__name__: str(obj)} + if get_qualifiedclassname(obj) == "aiohttp.tracing.TraceConfig": + return {obj.__class__.__name__: str(obj)} + # avoid "keys must be str, int, float, bool or None" errors + if get_qualifiedclassname(obj) == "aiohttp.connector.TCPConnector": + return {obj.__class__.__name__: str(obj)} + + if hasattr(obj, "__dict__"): + if ( + "inbound_group_store" in obj.__dict__ + and "session_store" in obj.__dict__ + and "outbound_group_sessions" in obj.__dict__ + ): + # "olm" is hige, 1MB+, 20K lines of JSON + # grab only some items + # "olm": { + # "user_id": "@xxx:xxx.xxx.xxx", + # "device_id": "xxx", + # "uploaded_key_count": 50, + # "users_for_key_query": { + # "set": "..." + # }, + # "device_store": { + # ... want + # }, + # "session_store": { + # ... dont want, too long + # }, + # "inbound_group_store": { + # ... dont want, 20K lines, too long + # }, + # "outbound_group_sessions": {}, + # "tracked_users": { + # "set": "set()" + # }, + dictcopy = {} + for key in [ + "user_id", + "device_id", + "uploaded_key_count", + "users_for_key_query", + "device_store", + "outbound_group_sessions", + "tracked_users", + "outgoing_key_requests", + "received_key_requests", + "key_requests_waiting_for_session", + "key_request_devices_no_session", + "key_request_from_untrusted", + "wedged_devices", + "key_re_requests_events", + "key_verifications", + "outgoing_to_device_messages", + "message_index_store", + "store", + ]: + dictcopy.update({key: obj.__dict__[key]}) + if gs.pa.verbose > 1: # 2+ + gs.log.debug( + f"{obj} is not serializable, simplifying to {dictcopy}." + ) + return dictcopy + if gs.pa.verbose > 1: # 2+ + gs.log.debug( + f"{obj} is not serializable, using its available dictionary " + f"{obj.__dict__}." + ) + return obj.__dict__ + else: + # gs.log.debug( + # f"Object {obj} ({type(obj)}) has no class dictionary. " + # "Cannot be converted to JSON object. " + # "Will be converted to JSON string." + # ) + # simple types like yarl.URL do not have a __dict__ + # get the class name as string, create a dict with classname and value + if gs.pa.verbose > 1: # 2+ + gs.log.debug( + f"{obj} is not serializable, simplifying to key value pair " + f"key '{obj.__class__.__name__}' and value '{str(obj)}'." + ) + return {obj.__class__.__name__: str(obj)} def choose_available_filename(filename): @@ -847,11 +517,124 @@ def choose_available_filename(filename): return filename -async def download_mxc(client: AsyncClient, url: str): - """Download MXC resource.""" - mxc = urlparse(url) - response = await client.download(mxc.netloc, mxc.path.strip("/")) - return response.body +def derive_media_filename_with_path(event): + """Derive file name under which to store a given media file. + + Depending on --download-media-name derive the corresponding file + name under which to store the downloaded media file. Note that + the file name giveb be the source, i.e. the sender, cannot be trusted. + The source can specify and provide any string, even invalid file + names or names containing backslash or slash and similar. + + Adds path as given in --download-media to file name. + + As last step function adds a sequential number, iff necessary, to assure + that the file does not yet exist and that no file is overwritten + (if multiple media files have the same name). + """ + method = gs.pa.download_media_name + if method == MEDIA_NAME_SOURCE: + newfn = event.body + elif method == MEDIA_NAME_EVENTID: + newfn = event.event_id + elif method == MEDIA_NAME_TIME: + # e.g. '20231012_152234_266600' (YYYYMMDD_HHMMSS_MICROSECONDS) + newfn = "{date:%Y%m%d_%H%M%S_%f}".format(date=datetime.datetime.now()) + else: + # event.body is not trustworthy + # and can contain garbage characters + # such as / or \ which will cause file open + # to fail. Replace those. + newfn = "".join( + [ + x if (x.isalnum() or x in MEDIA_NAME_CLEAN_CHARS) else "_" + for x in event.body + ] + ) + gs.log.debug(f"Media file name method is: {method}") + gs.log.debug(f"New file name for media is: {newfn}") + filename_with_path = choose_available_filename( + os.path.join(gs.pa.download_media, newfn) + ) + gs.log.debug( + f"Unique file name for media with path is: {filename_with_path}" + ) + return filename_with_path + + +async def synchronize(client: AsyncClient) -> SyncResponse: + """Synchronize with server, e.g. in order to get rooms. + + Arguments: + --------- + client : Client + + Returns: None + + Raises exception on error. + """ + try: + resp = await client.sync(timeout=10000, full_state=True) + except ClientConnectorError as e: + err = ( + "E100: " + "sync() failed. Do you have connectivity to internet? " + f"ClientConnectorError {e}" + ) + raise MatrixCommanderError(err) from e + except Exception as e: + err = "E101: " f"sync() failed. Exception {e}" + raise MatrixCommanderError(err) from e + if isinstance(resp, SyncError): + err = "E102: " f"sync failed with resp = {privacy_filter(str(resp))}" + raise MatrixCommanderError(err) from None + return resp + + +async def download_mxc( + client: AsyncClient, mxc: str, filename: Optional[str] = None +): + """Download MXC resource. + + Arguments: + --------- + client : Client + mxc : str + string representing URL like mxc://matrix.org/someRandomKey + filename : str + optional name of file for storing download + """ + nio_version = metadata.version("matrix-nio") + # version incompatibility between matrix-nio 0.19.0 and 0.20+ + # https://matrix.example.com/Abc123 + # server_name = "matrix.example.com" + # media_id = "Abc123" + # matrix-nio v0.19.0 has: download(server_name: str, media_id: str, ..) + # convert mxc to server_name and media_id + # v0.20+ : resp = await client.download(mxc=mxc, filename=filename) + # v0.19- : resp = await client.download( + # server_name=server_name, media_id=media_id, + # filename=filename) + gs.log.debug(f"download_mxc input mxc is {mxc}.") + if nio_version.startswith("0.1"): # like 0.19 + gs.log.info( + f"You are running matrix-nio version {nio_version}. " + "You should be running version 0.20+. Update if necessary. " + ) + url = urlparse(mxc) + gs.log.debug(f"download_mxc input url is {url}.") + response = await client.download( + server_name=url.netloc, + media_id=url.path.strip("/"), + filename=filename, + ) + else: + gs.log.debug( + f"You are running matrix-nio version {nio_version}. Great!" + ) + response = await client.download(mxc=mxc, filename=filename) + gs.log.debug(f"download_mxc response is {response}.") + return response class Callbacks(object): @@ -861,6 +644,106 @@ class Callbacks(object): """Store AsyncClient.""" self.client = client + async def invite_callback(self, room, event): + """Handle an incoming invite event. + + If an invite is received, then list or join the room specified + in the invite. + """ + try: + gs.log.debug( + f"invite_callback(): for room {room} received this " + f"event: type: {type(event)}, " + f"event: {event}" + ) + # There are MULTIPLE events received! + # event 1: + # InviteMemberEvent(source={'type': 'm.room.member', + # 'state_key': '@jane:matrix.example.com', + # 'sender': '@jane:matrix.example.com'}, + # sender='@jane:matrix.example.com', + # state_key='@jane:matrix.example.com', + # membership='join', + # prev_membership=None, + # content={'membership': 'join', 'displayname': 'M', + # 'avatar_url': '...'}, prev_content=None) + # event 2: + # InviteMemberEvent(source={'type': 'm.room.member', + # 'sender': '@jane:matrix.example.com', + # 'state_key': '@john:matrix.example.com', + # 'origin_server_ts': 1681986390778, + # 'unsigned': {'replaces_state': '$xxx', + # 'prev_content': {'membership': 'leave'}, + # 'prev_sender': '@john:matrix.example.com', 'age': 13037}, + # 'event_id': 'xxx'}, + # sender='@jane:matrix.example.com', + # state_key='@john:matrix.example.com', + # membership='invite', + # prev_membership='leave', + # content={'membership': 'invite', 'displayname': 'bot', + # 'avatar_url': 'xxx'}, prev_content={'membership': 'leave'}) + + gs.log.debug( + f"Got invite event for room {room.room_id} from " + f"{event.sender}. " + f"Event shows membership as '{event.membership}'." + ) + + if event.membership == "invite": + gs.log.debug( + "Event will be processed because it shows " + f"membership as '{event.membership}'." + ) + # list + if ( + gs.pa.room_invites == INVITES_LIST + or gs.pa.room_invites == INVITES_LIST_JOIN + ): + # output format controlled via --output flag + text = ( + f"{room.room_id}{SEP}m.room.member" + f"{SEP}{event.membership}" + ) + # we use the dictionary. + json_max = {"room_id": room.room_id} + json_max.update({"event": "m.room.member"}) + json_max.update({"membership": event.membership}) + json_ = json_max.copy() + json_spec = None + print_output( + gs.pa.output, + text=text, + json_=json_, + json_max=json_max, + json_spec=json_spec, + ) + + # join + if ( + gs.pa.room_invites == INVITES_JOIN + or gs.pa.room_invites == INVITES_LIST_JOIN + ): + result = await self.client.join(room.room_id) + if isinstance(result, JoinError): + gs.log.error( + f"E249: Error joining room {room.room_id}: " + f"{result.message}", + ) + gs.err_count += 1 + else: + # Successfully joined room + gs.log.info( + f"Joined room {room.room_id} successfully." + ) + else: + gs.log.debug( + "Event will be skipped because it shows " + f"membership as '{event.membership}'." + ) + + except BaseException: + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) + # according to pylama: function too complex: C901 # noqa: C901 async def message_callback(self, room: MatrixRoom, event): # noqa: C901 """Handle all events of type RoomMessage. @@ -868,82 +751,101 @@ class Callbacks(object): Includes events like RoomMessageText, RoomMessageImage, etc. """ try: - logger.debug( + gs.log.debug( f"message_callback(): for room {room} received this " f"event: type: {type(event)}, event_id: {event.event_id}, " f"event: {event}" ) - if not pargs.listen_self: + if not gs.pa.listen_self: if event.sender == self.client.user: try: - logger.debug( + gs.log.debug( f"Skipping message sent by myself: {event.body}" ) except AttributeError: # does not have .body - logger.debug( + gs.log.debug( f"Skipping message sent by myself: {event}" ) return # millisec since 1970 - logger.debug(f"event.server_timestamp = {event.server_timestamp}") + gs.log.debug(f"event.server_timestamp = {event.server_timestamp}") timestamp = datetime.datetime.fromtimestamp( int(event.server_timestamp / 1000) ) # sec since 1970 event_datetime = timestamp.strftime("%Y-%m-%d %H:%M:%S") # e.g. 2020-08-06 17:30:18 - logger.debug(f"event_datetime = {event_datetime}") + gs.log.debug(f"event_datetime = {event_datetime}") if isinstance(event, RoomMessageMedia): # for all media events - media_mxc = event.url - media_url = await self.client.mxc_to_http(media_mxc) - logger.debug(f"HTTP URL of media is : {media_url}") - msg_url = " [" + media_url + "]" - if pargs.download_media != "": - # download unencrypted media file - media_data = await download_mxc(self.client, media_mxc) - filename = choose_available_filename( - os.path.join(pargs.download_media, event.body) - ) - async with aiofiles.open(filename, "wb") as f: - await f.write(media_data) - # Set atime and mtime of file to event timestamp - os.utime( - filename, - ns=((event.server_timestamp * 1000000,) * 2), + mxc = event.url # media mxc + url = await self.client.mxc_to_http(mxc) # media url + gs.log.debug(f"HTTP URL of media is : {url}") + msg_url = " [" + url + "]" + if gs.pa.download_media != "": + # download unencrypted/plain media file + resp = await download_mxc(self.client, mxc) + if isinstance(resp, DownloadError): + gs.log.error( + "E105: " + f"download of URI '{mxc}' to local file " + f"failed with response {privacy_filter(str(resp))}" ) - msg_url += f" [Downloaded media file to {filename}]" + gs.err_count += 1 + msg_url += " [Download of media file failed]" + else: + media_data = resp.body + filename = derive_media_filename_with_path(event) + async with aiofiles.open(filename, "wb") as f: + await f.write(media_data) + # Set atime and mtime of file to event timestamp + os.utime( + filename, + ns=((event.server_timestamp * 1000000,) * 2), + ) + msg_url += f" [Downloaded media file to {filename}]" if isinstance(event, RoomEncryptedMedia): # for all e2e media - media_mxc = event.url - media_url = await self.client.mxc_to_http(media_mxc) - logger.debug(f"HTTP URL of media is : {media_url}") - msg_url = " [" + media_url + "]" - if pargs.download_media != "": + mxc = event.url # media mxc + url = await self.client.mxc_to_http(mxc) # media url + gs.log.debug(f"HTTP URL of media is : {url}") + msg_url = " [" + url + "]" + if gs.pa.download_media != "": # download encrypted media file - media_data = await download_mxc(self.client, media_mxc) - filename = choose_available_filename( - os.path.join(pargs.download_media, event.body) - ) - async with aiofiles.open(filename, "wb") as f: - await f.write( - crypto.attachments.decrypt_attachment( - media_data, - event.source["content"]["file"]["key"]["k"], - event.source["content"]["file"]["hashes"][ - "sha256" - ], - event.source["content"]["file"]["iv"], + resp = await download_mxc(self.client, mxc) + if isinstance(resp, DownloadError): + gs.log.error( + "E106: " + f"download of URI '{mxc}' to local file " + f"failed with response {privacy_filter(str(resp))}" + ) + gs.err_count += 1 + msg_url += " [Download of media file failed]" + else: + media_data = resp.body + filename = derive_media_filename_with_path(event) + async with aiofiles.open(filename, "wb") as f: + await f.write( + crypto.attachments.decrypt_attachment( + media_data, + event.source["content"]["file"]["key"][ + "k" + ], + event.source["content"]["file"]["hashes"][ + "sha256" + ], + event.source["content"]["file"]["iv"], + ) ) + # Set atime and mtime of file to event timestamp + os.utime( + filename, + ns=((event.server_timestamp * 1000000,) * 2), + ) + msg_url += ( + " [Downloaded and decrypted media " + f"file to {filename}]" ) - # Set atime and mtime of file to event timestamp - os.utime( - filename, - ns=((event.server_timestamp * 1000000,) * 2), - ) - msg_url += ( - f" [Downloaded and decrypted media file to {filename}]" - ) if isinstance(event, RoomMessageAudio): msg = "Received audio: " + event.body + msg_url @@ -962,6 +864,10 @@ class Callbacks(object): msg = event.body # Extract the message text elif isinstance(event, RoomMessageUnknown): msg = "Received room message of unknown type: " + event.msgtype + try: + msg += " with content body " + str(event.content['body']) + except Exception: + msg += " with content " + str(event.content) elif isinstance(event, RoomMessageVideo): msg = "Received video: " + event.body + msg_url elif isinstance(event, RoomEncryptedAudio): @@ -1035,27 +941,50 @@ class Callbacks(object): # content = download_url # else: # content = "\n{{ " + event['type'] + " event }}\n" - logger.debug(f"type(msg) = {type(msg)}. msg is a string") + gs.log.debug(f"type(msg) = {type(msg)}. msg is a string") sender_nick = room.user_name(event.sender) if not sender_nick: # convert @foo:mat.io into foo - sender_nick = event.sender.split(":")[0][1:] + sender_nick = user_id_to_short_user_name(event.sender) room_nick = room.display_name - if not room_nick or room_nick == "Empty Room" or room_nick == "": + if room_nick in (None, "", "Empty Room"): room_nick = "Undetermined" - if pargs.print_event_id: + if gs.pa.print_event_id: event_id_detail = f" | {event.event_id}" else: event_id_detail = "" + # Prevent faking messages by prefixing each line of a multiline + # message with space. + fixed_msg = re.sub("\n", "\n ", msg) complete_msg = ( "Message received for room " f"{room_nick} [{room.room_id}] | " f"sender {sender_nick} " f"[{event.sender}] | {event_datetime}" - f"{event_id_detail} | {msg}" + f"{event_id_detail} | {fixed_msg}" ) - logger.debug(complete_msg) - print(complete_msg, flush=True) - if pargs.os_notify: + gs.log.debug(complete_msg) + # output format controlled via --output flag + text = complete_msg # print the received message + json_ = {"source": event.source} + json_.update({"room": room}) + json_.update({"room_display_name": room.display_name}) + json_.update({"sender_nick": sender_nick}) + json_.update({"event_datetime": event_datetime}) + json_max = event.__dict__ + json_max.update({"room": room}) + json_max.update({"room_display_name": room.display_name}) + json_max.update({"sender_nick": sender_nick}) + json_max.update({"event_datetime": event_datetime}) + json_spec = event.source + print_output( + gs.pa.output, + text=text, + json_=json_, + json_max=json_max, + json_spec=json_spec, + ) + + if gs.pa.os_notify: avatar_url = await get_avatar_url(self.client, event.sender) notify( f"From {room.user_name(event.sender)}", @@ -1064,16 +993,293 @@ class Callbacks(object): ) except BaseException: - logger.debug(traceback.format_exc()) + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) # according to linter: function is too complex, C901 async def to_device_callback(self, event): # noqa: C901 """Handle events sent to device.""" + gs.log.debug(f"to_device_callback(): {event}") + + # Added Aug 2024, see + # https://matrix-nio.readthedocs.io/en/latest/nio.html#nio.Client.get_active_key_requests + key_share_cb(event) # TODO TOFIX : shall I leave this code in? + try: client = self.client - if isinstance(event, KeyVerificationStart): # first step - """first step: receive KeyVerificationStart + if ( + event.source["type"] == "m.key.verification.request" + ): # new first step + """New first step in new flow: receive a request proposing + a set of verification methods, and in this case respond + saying we only support SAS verification. + """ + gs.log.info( + "Got 'verification request'." + "Waiting for other device to accept SAS method..." + ) + # send back 'ready' + # see: https://spec.matrix.org/v1.9/client-server-api/#mroommessagemkeyverificationrequest + txid = event.source["content"]["transaction_id"] + recipient = event.sender + recipient_device = event.source["content"]["from_device"] + kvr_event = ToDeviceMessage( + type="m.key.verification.ready", + recipient=recipient, + recipient_device=recipient_device, + content={ + "from_device": gs.client.device_id, + "methods": [ + "m.sas.v1" + ], # we accept only emoji as type, not QR code + "transaction_id": txid, + }, + ) + resp = await gs.client.to_device(kvr_event, txid) + if isinstance(resp, ToDeviceError): + gs.log.error( + f"to_device() for m.key.verification.ready failed with {resp}. " + "Could not send a key verification ready msg." + ) + gs.log.debug( + f"A verification invitation was sent to user {recipient} " + f"on device {recipient_device} with transaction_id {txid}." + ) + + elif ( + event.source["type"] == "m.key.verification.ready" + ): # new first step + """New first step in new flow: receive a request proposing + a set of verification methods, and in this case respond + saying we only support SAS verification. + """ + gs.log.info( + "Got 'verification ready'. " + "Waiting for other device to accept SAS method..." + ) + # # TODO TOFIX + # # After "ready" I am awaiting a "start" + # if "m.sas.v1" not in event.source["content"]["methods"]: + # gs.log.error( + # "Other device does not support SAS authentication. " + # f"Methods: {event.source['content']['methods']}." + # ) + # return + # assert client.device_id is not None + # assert client.user_id is not None + # txid = event.source["content"]["transaction_id"] + # ready_event = ToDeviceMessage( + # type="m.key.verification.ready", + # recipient=event.sender, + # recipient_device=event.source["content"]["from_device"], + # content={ + # "from_device": client.device_id, + # "methods": ["m.sas.v1"], + # "transaction_id": txid, + # }, + # ) + # resp = await client.to_device(ready_event, txid) + # if isinstance(resp, ToDeviceError): + # gs.log.error( + # f"to_device failed with {resp}. " + # "Could not propose to use SAS for verification." + # ) + elif event.source["type"] == "m.key.verification.start": + gs.log.info( + "Got 'verification start'. " + "Waiting for other device to accept SAS method..." + ) + gs.log.info( + "We started verification = " + f"{client.key_verifications[event.transaction_id].we_started_it}" + ) + # now accept + if "emoji" not in event.short_authentication_string: + gs.log.error( + "E107: " + "Other device does not support emoji verification. " + f"{event.short_authentication_string}." + ) + return + # TODO TOFIX this was replaced by sas_accept_verification(), delete it for sure? + # resp = await client.accept_key_verification( + # event.transaction_id + # ) + sas = client.key_verifications[event.transaction_id] + todevice_msg = sas.accept_verification() + gs.log.debug(f"accept msg is: {todevice_msg}") + resp = await client.to_device(todevice_msg) + if isinstance(resp, ToDeviceError): + gs.log.error( + "E108: " + "accept_key_verification failed with error " + f"'{privacy_filter(str(resp))}'." + ) + + elif event.source["type"] == "m.key.verification.key": + gs.log.info("Got 'verification key'. ") + gs.log.info( + "Handshake initiator? We started verification = " + f"{client.key_verifications[event.transaction_id].we_started_it}" + ) + # now send key back + sas = client.key_verifications[event.transaction_id] + gs.log.debug(f"sas {sas} {sas.verified_devices}") + + todevice_msg = sas.share_key() + gs.log.debug(f"shared key msg is: {todevice_msg}") + resp = await client.to_device(todevice_msg) + if isinstance(resp, ToDeviceError): + gs.log.error( + "E109: " + "to_device failed with error " + f"'{privacy_filter(str(resp))}'." + ) + + # sas = client.key_verifications[event.transaction_id] + + print( + f"{sas.get_emoji()}", + file=sys.stdout, + flush=True, + ) + + yn = input("Do the emojis match? (Y/N) (C for Cancel) ") + if yn.lower() == "y": + print( + "Match! The verification for this " + "device will be accepted.", + file=sys.stdout, + flush=True, + ) + sas.accept_sas() + # # TODO TOFIX + # # confirm_short_auth_string() sends sas.get_mac() + # # we now send the MAC manually after we receive peer's MAC + # resp = await client.confirm_short_auth_string( + # event.transaction_id + # ) + # if isinstance(resp, ToDeviceError): + # gs.log.error( + # "E111: " + # "confirm_short_auth_string failed with " + # f"error '{privacy_filter(str(resp))}'." + # ) + + elif yn.lower() == "n": # no, don't match, reject + print( + "No match! Device will NOT be verified. " + "Verification will be rejected.", + file=sys.stderr, + flush=True, + ) + resp = await client.cancel_key_verification( + event.transaction_id, reject=True + ) + if isinstance(resp, ToDeviceError): + gs.log.error( + "E112: " + "cancel_key_verification failed with " + f"'{privacy_filter(str(resp))}'." + ) + else: # C or anything for cancel + print( + "Cancelled by user! Verification will be cancelled.", + file=sys.stderr, + flush=True, + ) + resp = await client.cancel_key_verification( + event.transaction_id, reject=False + ) + if isinstance(resp, ToDeviceError): + gs.log.error( + "E113: " + "cancel_key_verification failed with " + f"'{privacy_filter(str(resp))}'." + ) + + elif event.source["type"] == "m.key.verification.mac": + gs.log.info("Got 'verification mac'. ") + # now send mac back + sas = client.key_verifications[event.transaction_id] + gs.log.debug(f"sas {sas}") + try: + todevice_msg = sas.get_mac() + gs.log.debug(f"mac msg is {todevice_msg}") + todevice_msg = client.confirm_key_verification( + event.transaction_id + ) + gs.log.debug(f"mac msg is {todevice_msg}") + except LocalProtocolError as e: + # e.g. it might have been cancelled by ourselves + gs.log.error( + "E114: " + f"Cancelled or protocol error: Reason: {e}.\n" + f"Verification with {event.sender} not concluded. " + "Try again?" + ) + else: + # TODO TOFIX as of Sept 1, 2024, when Element in browser + # receives our MAC it gives error: + # code='m.key_mismatch', + # reason='The expected key did not match the verified one' + resp = await client.to_device(todevice_msg) + if isinstance(resp, ToDeviceError): + gs.log.error( + "E115: " + "to_device failed with error " + f"'{privacy_filter(str(resp))}'." + ) + else: + gs.log.debug( + f"verified devices {sas.verified_devices}" + ) + gs.log.debug("to_device() sent mac successfully.") + + elif event.source["type"] == "m.key.verification.done": + # Extra step in new flow: once we have completed the SAS + # verification successfully, send a 'done' to-device event + # to the other device to assert that the verification was + # successful. + try: + txid = event.source["content"]["transaction_id"] + except Exception as e: + gs.log.warning(f"Got exception {e}. Trying something different.") + txid = event.transaction_id + + sas = client.key_verifications[txid] + + done_message = ToDeviceMessage( + type="m.key.verification.done", + recipient=event.sender, + recipient_device=sas.other_olm_device.device_id, + content={ + "transaction_id": txid, + }, + ) + resp = await client.to_device(done_message, sas.transaction_id) + if isinstance(resp, ToDeviceError): + client.log.error( + f"Communicating 'verification done' failed with {resp}" + ) + + elif event.source["type"] == "m.key.verification.cancel": + gs.log.info( + "Got 'verification cancel' from " + f"sender {event.sender}, " + f"transaction_id {event.transaction_id}, " + f"code {event.code} and " + f"reason {event.reason}." + ) + print( + "To give up hit Control-C.", + file=sys.stdout, + flush=True, + ) + # ignore, nothing to do + + elif isinstance(event, KeyVerificationStart): # old first step + """old first step: receive KeyVerificationStart KeyVerificationStart( source={'content': {'method': 'm.sas.v1', @@ -1103,8 +1309,9 @@ class Callbacks(object): """ if "emoji" not in event.short_authentication_string: - print( - "Other device does not support emoji verification " + gs.log.error( + "E107: " + "Other device does not support emoji verification. " f"{event.short_authentication_string}." ) return @@ -1112,14 +1319,22 @@ class Callbacks(object): event.transaction_id ) if isinstance(resp, ToDeviceError): - print(f"accept_key_verification failed with {resp}") + gs.log.error( + "E108: " + "accept_key_verification failed with error " + f"'{privacy_filter(str(resp))}'." + ) sas = client.key_verifications[event.transaction_id] todevice_msg = sas.share_key() resp = await client.to_device(todevice_msg) if isinstance(resp, ToDeviceError): - print(f"to_device failed with {resp}") + gs.log.error( + "E109: " + "to_device failed with error " + f"'{privacy_filter(str(resp))}'." + ) elif isinstance(event, KeyVerificationCancel): # anytime """at any time: receive KeyVerificationCancel @@ -1139,7 +1354,8 @@ class Callbacks(object): # client.cancel_key_verification(tx_id, reject=False) # here. The SAS flow is already cancelled. # We only need to inform the user. - print( + gs.log.error( + "E110: " f"Verification has been cancelled by {event.sender} " f'for reason "{event.reason}".' ) @@ -1159,36 +1375,61 @@ class Callbacks(object): """ sas = client.key_verifications[event.transaction_id] - print(f"{sas.get_emoji()}") + print( + f"{sas.get_emoji()}", + file=sys.stdout, + flush=True, + ) yn = input("Do the emojis match? (Y/N) (C for Cancel) ") if yn.lower() == "y": print( "Match! The verification for this " - "device will be accepted." + "device will be accepted.", + file=sys.stdout, + flush=True, ) resp = await client.confirm_short_auth_string( event.transaction_id ) if isinstance(resp, ToDeviceError): - print(f"confirm_short_auth_string failed with {resp}") + gs.log.error( + "E111: " + "confirm_short_auth_string failed with " + f"error '{privacy_filter(str(resp))}'." + ) + elif yn.lower() == "n": # no, don't match, reject print( - "No match! Device will NOT be verified " - "by rejecting verification." + "No match! Device will NOT be verified. " + "Verification will be rejected.", + file=sys.stderr, + flush=True, ) resp = await client.cancel_key_verification( event.transaction_id, reject=True ) if isinstance(resp, ToDeviceError): - print(f"cancel_key_verification failed with {resp}") + gs.log.error( + "E112: " + "cancel_key_verification failed with " + f"'{privacy_filter(str(resp))}'." + ) else: # C or anything for cancel - print("Cancelled by user! Verification will be cancelled.") + print( + "Cancelled by user! Verification will be cancelled.", + file=sys.stderr, + flush=True, + ) resp = await client.cancel_key_verification( event.transaction_id, reject=False ) if isinstance(resp, ToDeviceError): - print(f"cancel_key_verification failed with {resp}") + gs.log.error( + "E113: " + "cancel_key_verification failed with " + f"'{privacy_filter(str(resp))}'." + ) elif isinstance(event, KeyVerificationMac): # third step """Third step is to receive KeyVerificationMac @@ -1211,7 +1452,8 @@ class Callbacks(object): todevice_msg = sas.get_mac() except LocalProtocolError as e: # e.g. it might have been cancelled by ourselves - print( + gs.log.error( + "E114: " f"Cancelled or protocol error: Reason: {e}.\n" f"Verification with {event.sender} not concluded. " "Try again?" @@ -1219,28 +1461,44 @@ class Callbacks(object): else: resp = await client.to_device(todevice_msg) if isinstance(resp, ToDeviceError): - print(f"to_device failed with {resp}") - print( - f"sas.we_started_it = {sas.we_started_it}\n" - f"sas.sas_accepted = {sas.sas_accepted}\n" - f"sas.canceled = {sas.canceled}\n" - f"sas.timed_out = {sas.timed_out}\n" - f"sas.verified = {sas.verified}\n" - f"sas.verified_devices = {sas.verified_devices}\n" - ) - print( - "Emoji verification was successful!\n" - "Hit Control-C to stop the program or " - "initiate another Emoji verification from " - "another device or room." - ) - else: + gs.log.error( + "E115: " + "to_device failed with error " + f"'{privacy_filter(str(resp))}'." + ) + else: + gs.log.debug("to_device() sent mac successfully.") + elif ( + event.source["type"] == "m.key.verification.done" + ): # new final step + # Final step, other device acknowledges verification success. + txid = event.source["content"]["transaction_id"] + sas = client.key_verifications[txid] + + gs.log.info( + f"sas.we_started_it = {sas.we_started_it}\n" + f"sas.sas_accepted = {sas.sas_accepted}\n" + f"sas.canceled = {sas.canceled}\n" + f"sas.timed_out = {sas.timed_out}\n" + f"sas.verified = {sas.verified}\n" + f"sas.verified_devices = {sas.verified_devices}\n" + ) print( + "Emoji verification was successful!\n" + "Verify with other devices or hit Control-C to " + "continue.", + file=sys.stdout, + flush=True, + ) + + else: + gs.log.error( + "E116: " f"Received unexpected event type {type(event)}. " f"Event is {event}. Event will be ignored." ) except BaseException: - print(traceback.format_exc()) + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) def notify(title: str, content: str, image_url: str): @@ -1250,12 +1508,14 @@ def notify(title: str, content: str, image_url: str): operating system notifications, ignore it. """ if not HAVE_NOTIFY: - logger.warning( + gs.log.warning( + "W100: " "notify2 or dbus is not installed. Notifications will not be " - "displayed.\n" + "displayed. " "Make sure that notify2 and dbus are installed or remove the " "--os-notify option." ) + gs.warn_count += 1 return try: if image_url: @@ -1268,25 +1528,15 @@ def notify(title: str, content: str, image_url: str): avatar_file = "notification-message-IM" notify2.init(PROG_WITHOUT_EXT) notify2.Notification(title, content, avatar_file).show() - logger.debug(f"Showed notification for {title}.") - except Exception: - logger.debug(f"Showing notification for {title} failed.") - print(traceback.format_exc()) + gs.log.debug(f"Showed notification for {title}.") + except Exception as e: + gs.log.debug( + f"Showing notification for {title} failed. Exception: {e}" + f"\nHere is the traceback:\n{traceback.format_exc()}" + ) pass -def is_room_alias(room_id: str) -> bool: - """Determine if room identifier is a room alias. - - Alias are of syntax: #somealias:someserver - - """ - if room_id and len(room_id) > 3 and room_id[0] == "#": - return True - else: - return False - - async def get_avatar_url(client: AsyncClient, user_id: str) -> str: """Get https avatar URL for user user_id. @@ -1295,14 +1545,19 @@ async def get_avatar_url(client: AsyncClient, user_id: str) -> str: avatar_url = None # default resp = await client.get_avatar(user_id) if isinstance(resp, ProfileGetAvatarResponse): - logger.debug(f"ProfileGetAvatarResponse. Response is: {resp}") + gs.log.debug( + "ProfileGetAvatarResponse. Response is: " + f"{privacy_filter(str(resp))}" + ) avatar_mxc = resp.avatar_url - logger.debug(f"avatar_mxc is {avatar_mxc}") + gs.log.debug(f"avatar_mxc is {avatar_mxc}") if avatar_mxc: # could be None if no avatar avatar_url = await client.mxc_to_http(avatar_mxc) else: - logger.info(f"Failed getting avatar from server. {resp}") - logger.debug(f"avatar_url is {avatar_url}") + gs.log.info( + f"Failed getting avatar from server. {privacy_filter(str(resp))}" + ) + gs.log.debug(f"avatar_url is {avatar_url}") return avatar_url @@ -1321,18 +1576,18 @@ def create_pid_file() -> None: try: if not os.path.exists(PID_DIR_DEFAULT): os.mkdir(PID_DIR_DEFAULT) - logger.debug(f"Create directory {PID_DIR_DEFAULT} for PID file.") + gs.log.debug(f"Create directory {PID_DIR_DEFAULT} for PID file.") pid = os.getpid() - logger.debug(f"Trying to create a PID file to store process id {pid}.") + gs.log.debug(f"Trying to create a PID file to store process id {pid}.") with open(PID_FILE_DEFAULT, "w") as f: # overwrite f.write(str(pid)) f.close() - logger.debug( + gs.log.debug( f'Successfully created PID file "{PID_FILE_DEFAULT}" ' f"to store process id {pid}." ) except Exception: - logger.debug( + gs.log.debug( f'Failed to create PID file "{PID_FILE_DEFAULT}" ' f"to store process id {os.getpid()}." ) @@ -1347,15 +1602,51 @@ def delete_pid_file() -> None: try: os.remove(PID_FILE_DEFAULT) except Exception: - logger.debug(f'Failed to remove PID file "{PID_FILE_DEFAULT}".') + gs.log.debug(f'Failed to remove PID file "{PID_FILE_DEFAULT}".') def cleanup() -> None: """Cleanup before quiting program.""" - logger.debug("Cleanup: cleaning up.") + gs.log.debug("Cleanup: cleaning up.") delete_pid_file() +def credentials_exist(credentials_file_path: Optional[str] = None) -> bool: + """Determine if credentials file already exists.""" + if not credentials_file_path: + credentials_file_path = determine_credentials_file() + return os.path.exists(credentials_file_path) + + +def store_exists(store_dir_path: Optional[str] = None) -> bool: + """Determine if store dir already exists.""" + if not store_dir_path: + store_dir_path = determine_store_dir() + return os.path.isdir(store_dir_path) + + +def store_create(store_dir_path: Optional[str] = None) -> None: + """Create store dir.""" + if not store_dir_path: + store_dir_path = determine_store_dir() + os.makedirs(store_dir_path) + gs.log.info( + f"The persistent storage directory {store_dir_path} " + "was created for you." + ) + + +def store_delete(store_dir_path: Optional[str] = None) -> None: + """Delete store dir.""" + if not store_dir_path: + store_dir_path = determine_store_dir() + os.rmdir(store_dir_path) + gs.log.info( + f"The persistent storage directory {store_dir_path} " + "was deleted for you." + ) + + def write_credentials_to_disk( homeserver, user_id, device_id, access_token, room_id, credentials_file ) -> None: @@ -1415,8 +1706,11 @@ def read_credentials_from_disk(credentials_file) -> dict: """ # open the file in read-only mode + gs.log.debug("Starting to read credentials file.") with open(credentials_file, "r") as f: - return json.load(f) + cdict = json.load(f) + gs.log.debug("Finished reading credentials file.") + return cdict def determine_credentials_file() -> str: @@ -1448,42 +1742,42 @@ def determine_credentials_file() -> str: directory $HOME/.config/matrix-commander/ """ - credentials_file = pargs.credentials # default location - if (not os.path.isfile(pargs.credentials)) and ( - pargs.credentials == os.path.basename(pargs.credentials) + credentials_file = gs.pa.credentials # default location + if (not os.path.isfile(gs.pa.credentials)) and ( + gs.pa.credentials == os.path.basename(gs.pa.credentials) ): - logger.debug( + gs.log.debug( "Credentials file does not exist locally. " "File name has no path." ) - credentials_file = CREDENTIALS_DIR_LASTRESORT + "/" + pargs.credentials - logger.debug( + credentials_file = CREDENTIALS_DIR_LASTRESORT + "/" + gs.pa.credentials + gs.log.debug( f'Trying path "{credentials_file}" as last resort. ' "Suggesting to look for it there." ) if os.path.isfile(credentials_file): - logger.debug( + gs.log.debug( "We found the file. It exists in the last resort " f'directory "{credentials_file}". ' "Suggesting to use this one." ) else: - logger.debug( + gs.log.debug( "File does not exists either in the last resort " "directory or the local directory. " "File not found anywhere. One will have to be " "created. So we suggest the local directory." ) - credentials_file = pargs.credentials + credentials_file = gs.pa.credentials else: - if os.path.isfile(pargs.credentials): - logger.debug( + if os.path.isfile(gs.pa.credentials): + gs.log.debug( "Credentials file existed. " "So this is the one we suggest to use. " f"file: {credentials_file}" ) else: - logger.debug( + gs.log.debug( "Credentials file was specified with full path. " "So we suggest that one. " f"file: {credentials_file}" @@ -1502,10 +1796,10 @@ def determine_store_dir() -> str: Returns filename with full path (a dir) or None. For historic reasons: - If -e encrypted is NOT turned on, return None. + If --encrypted (encrypted) is NOT turned on, return None. The store path will be looked for the following way: - pargs.store provides either default value or user specified value + gs.pa.store provides either default value or user specified value a) First looked at default/specified value. If dir exists, use it, end of search. b) if last-resort store dir exists, use it, end of search. @@ -1521,42 +1815,32 @@ def determine_store_dir() -> str: If not found anywhere, it will return default/specified value. """ - if not pargs.store: + if not gs.pa.store: return None - if not pargs.encrypted: + if not gs.pa.encrypted: return None - pargs_store_norm = os.path.normpath(pargs.store) # normailzed for humans - text2 = ( - "It will need to be verified.\n" - "The store directory will be created in the " - f'directory "{pargs_store_norm}". Optionally, consider moving ' - "the persistent storage directory files inside " - f'"{pargs_store_norm}" into ' - f'the directory "{STORE_DIR_LASTRESORT}" ' - "for a more consistent experience." - ) - if os.path.isdir(pargs.store): - logger.debug( + pargs_store_norm = os.path.normpath(gs.pa.store) # normailzed for humans + if os.path.isdir(gs.pa.store): + gs.log.debug( "Found an existing store in directory " f'"{pargs_store_norm}" (local or arguments). ' "It will be used." ) return pargs_store_norm - if pargs.store != STORE_DIR_DEFAULT and pargs.store != os.path.basename( - pargs.store + if gs.pa.store != STORE_DIR_DEFAULT and gs.pa.store != os.path.basename( + gs.pa.store ): - text1 = ( + gs.log.debug( f'Store directory "{pargs_store_norm}" was specified by ' - "user, it is a directory with path, but it " - "does not exist. Hence it will be created there. " + "user, it is a directory with a path, but the directory " + "does not exist. " ) - logger.info(text1 + text2) - print(text1 + text2) - return pargs_store_norm # create in the specified, directory with path - if pargs.store == STORE_DIR_DEFAULT and os.path.isdir( + # fall through towards ending of function to print and return value + # create in the specified, directory with path + if gs.pa.store == STORE_DIR_DEFAULT and os.path.isdir( STORE_DIR_LASTRESORT ): - logger.debug( + gs.log.debug( "Store was not found in default local directory. " "But found an existing store directory in " f'"{STORE_DIR_LASTRESORT}" directory. ' @@ -1564,31 +1848,279 @@ def determine_store_dir() -> str: ) return STORE_DIR_LASTRESORT - if pargs.store == os.path.basename(pargs.store): - logger.debug( + if gs.pa.store == os.path.basename(gs.pa.store): + gs.log.debug( f'Store directory "{pargs_store_norm}" is just a name ' "without a path. Already looked locally, but not found " "locally. So now looking for it in last-resort path." ) last_resort = os.path.normpath( - STORE_PATH_LASTRESORT + "/" + pargs.store + STORE_PATH_LASTRESORT + "/" + gs.pa.store ) if os.path.isdir(last_resort): - logger.debug( + gs.log.debug( "Found an existing store directory in " f'"{last_resort}" directory. It will be used.' ) return last_resort - text1 = ( - "Could not find existing store directory anywhere. " - "A new one will be created. " + + gs.log.debug( + "Store directory was not found anywhere. Hence, we will suggest " + f'"{pargs_store_norm}" (local directory) as store directory.' ) - logger.debug(text1 + text2) - print(textwrap.fill(textwrap.dedent(text1 + text2).strip(), width=79)) return pargs_store_norm # create in the specified, local dir without path -def determine_rooms(room_id) -> list: +async def determine_dm_rooms( + users: list, client: AsyncClient, credentials: dict +) -> list: + """Determine the rooms to send to. + + Users can be specified with --user for send and listen operations. + These rooms we label DM (direct messaging) rooms. + By that we means rooms that only have 2 members, and these two + members being the sender and the recipient in question. + We do not care about 'is_group' or 'is_direct' flags (hints). + + If given a user and known the sender, we try to find a matching room. + There might be 0, 1, or more matching rooms. If 0, then giver error + and the user should run --room-invite first. if 1 found, use it. + If more than 1 found, just use 1 of them arbitrarily. + + The steps are: + - get all rooms where sender is member + - get all members to these rooms + - check if there is a room with just 2 members and them + being sender and recipient (user from users arg) + + In order to match a user to a RoomMember we allow 3 choices: + - user_id: perfect match, is unique, full user id, e.g. "@user:example.org" + - user_id without homeserver domain: partial user id, e.g. "@user" + this partial user will be completed by adding the homeserver of the + sender to the end, i.e. assuming that sender and receiver are on the + same homeserver. + - display name: be careful, display names are NOT unique, you could be + mistaken and by error send to the wrong person. + '--joined-members "*"' shows you the display names in the middle column + + Arguments: + --------- + users: list(str): list of user_ids + try to find a matching DM room for each user + client: AsyncClient: client, allows as to query the server + credentials: dict: allows to get the user_id of sender + + Returns a list of found DM rooms. List may be empty if no matches were + found. + + """ + rooms = [] + if not users: + gs.log.debug(f"Room(s) from --user: {users}, no users were specified.") + return rooms + sender = credentials["user_id"] # who am i + gs.log.debug(f"Trying to get members for all rooms of sender: {sender}") + resp = await client.joined_rooms() + if isinstance(resp, JoinedRoomsError): + gs.log.error( + "E117: " + f"joined_rooms failed with {privacy_filter(str(resp))}. " + "Not able to " + "get all rooms. " + f"Not able to find DM rooms for sender {sender}. " + f"Not able to send to receivers {users}." + ) + gs.err_count += 1 + senderrooms = [] + else: + gs.log.debug( + f"joined_rooms successful with {privacy_filter(str(resp))}" + ) + senderrooms = resp.rooms + room_found_for_users = [] + for room in senderrooms: + resp = await client.joined_members(room) + if isinstance(resp, JoinedMembersError): + gs.log.error( + "E118: " + f"joined_members failed with {privacy_filter(str(resp))}. " + "Not able to " + f"get room members for room {room}. " + f"Not able to find DM rooms for sender {sender}. " + f"Not able to send to some of these receivers {users}." + ) + gs.err_count += 1 + else: + # resp.room_id + # resp.members = List[RoomMember] ; RoomMember + # member.user_id + # member.display_name + # member.avatar_url + gs.log.debug( + f"joined_members successful with {privacy_filter(str(resp))}" + ) + if resp.members and len(resp.members) == 2: + if resp.members[0].user_id == sender: + # sndr = resp.members[0] + rcvr = resp.members[1] + elif resp.members[1].user_id == sender: + # sndr = resp.members[1] + rcvr = resp.members[0] + else: + # sndr = None + rcvr = None + gs.log.error( + "E119: " + f"Sender does not match {privacy_filter(str(resp))}" + ) + gs.err_count += 1 + for user in users: + if rcvr and ( + user == rcvr.user_id + or short_user_name_to_user_id(user, credentials) + == rcvr.user_id + or user == rcvr.display_name + ): + room_found_for_users.append(user) + rooms.append(resp.room_id) + for user in users: + if user not in room_found_for_users: + gs.log.error( + "E120: " + "Room(s) were specified for a DM (direct messaging) " + "send operation via --room. But no DM room was " + f"found for user '{user}'. " + "Try setting up a room first via --room-create and " + "--room-invite option or --room-dm-create." + ) + gs.err_count += 1 + rooms = list(dict.fromkeys(rooms)) # remove duplicates in list + gs.log.debug( + f"Found these DM room(s) for these users: " + f"users: {users}, rooms: {rooms}" + ) + return rooms + + +async def determine_dm_rooms_for_user( + user: str, client: AsyncClient, credentials: dict +) -> list: + """Determine the DM rooms for one user. + + These rooms we label DM (direct messaging) rooms. + By that we means rooms that only have 2 members, and these two + members being the sender and the recipient in question. + We do not care about 'is_group' or 'is_direct' flags (hints). + + If given a user and known the sender, we try to find a matching room. + There might be 0, 1, or more matching rooms. If 0, then giver error + and the user should run --room-invite first. if 1 found, use it. + If more than 1 found, just use 1 of them arbitrarily. + + The steps are: + - get all rooms where sender is member + - get all members to these rooms + - check if there is a room with just 2 members and them + being sender and recipient (user from users arg) + + In order to match a user to a RoomMember we allow 3 choices: + - user_id: perfect match, is unique, full user id, e.g. "@user:example.org" + - user_id without homeserver domain: partial user id, e.g. "@user" + this partial user will be completed by adding the homeserver of the + sender to the end, i.e. assuming that sender and receiver are on the + same homeserver. + - display name: be careful, display names are NOT unique, you could be + mistaken and by error send to the wrong person. + '--joined-members "*"' shows you the display names in the middle column + + Arguments: + --------- + users: list(str): list of user_ids + try to find a matching DM room for each user + client: AsyncClient: client, allows as to query the server + credentials: dict: allows to get the user_id of sender + + Returns a list of found DM rooms. List may be empty if no matches were + found. + + """ + rooms = [] + if not user: + gs.log.debug(f"Room(s) from user: {user}, no user was specified.") + return rooms + sender = credentials["user_id"] # who am i + gs.log.debug(f"Trying to get members for all rooms of sender: {sender}") + resp = await client.joined_rooms() + if isinstance(resp, JoinedRoomsError): + gs.log.error( + "E249: " + f"joined_rooms failed with {privacy_filter(str(resp))}. " + "Not able to " + "get all rooms. " + f"Not able to find DM rooms for sender {sender}. " + ) + gs.err_count += 1 + senderrooms = [] + else: + gs.log.debug( + f"joined_rooms successful with {privacy_filter(str(resp))}" + ) + senderrooms = resp.rooms + for room in senderrooms: + resp = await client.joined_members(room) + if isinstance(resp, JoinedMembersError): + gs.log.error( + "E250: " + f"joined_members failed with {privacy_filter(str(resp))}. " + "Not able to " + f"get room members for room {room}. " + f"Not able to find DM rooms for sender {sender}. " + f"Not able to know if DM room for user {user} exists." + ) + gs.err_count += 1 + else: + # resp.room_id + # resp.members = List[RoomMember] ; RoomMember + # member.user_id + # member.display_name + # member.avatar_url + gs.log.debug( + f"joined_members successful with {privacy_filter(str(resp))}" + ) + if resp.members and len(resp.members) == 2: + if resp.members[0].user_id == sender: + # sndr = resp.members[0] + rcvr = resp.members[1] + elif resp.members[1].user_id == sender: + # sndr = resp.members[1] + rcvr = resp.members[0] + else: + # sndr = None + rcvr = None + gs.log.error( + "E251: " + f"Sender does not match {privacy_filter(str(resp))}" + ) + gs.err_count += 1 + if rcvr and ( + user == rcvr.user_id + or short_user_name_to_user_id(user, credentials) + == rcvr.user_id + or user == rcvr.display_name + ): + rooms.append(resp.room_id) + rooms = list(dict.fromkeys(rooms)) # remove duplicates in list + if not rooms: + gs.log.debug(f"No DM room found for user {user}.") + gs.log.debug( + f"Found these DM room(s) for this user: user: {user}, rooms: {rooms}" + ) + return rooms + + +async def determine_rooms( + room_id: str, client: AsyncClient, credentials: dict +) -> list: """Determine the room to send to. Arguments: @@ -1598,28 +2130,89 @@ def determine_rooms(room_id) -> list: Look at room from credentials file and at rooms from command line and prepares a definite list of rooms. + New: Also look at --user. For DM (direct messaging), destinations + are specified via --user. For every user found, see if there is a + "DM" room, a room with only 2 members (sender and recipient). + If such a "DM" room is found, add it to the general rooms list + that is returned. + + Mixing and matching of --room and --user is possible. + --room R1 R2 --user U1 U2 might lead to 4 rooms in total. + If no "DM" room is found then give error and tell user to do + --room-invite first. + Return list of rooms to send to. Returned list is never empty. """ - if not pargs.room: - logger.debug( + if not gs.pa.room and not gs.pa.user: + gs.log.debug( "Room id was provided via credentials file. " - "No rooms given in commands line. " + "No rooms given in commands line. " + "No users given in command line for DM rooms. " f'Setting rooms to "{room_id}".' ) return [room_id] # list of 1 - else: - rooms = [] - for room in pargs.room: + rooms = [] + if gs.pa.room: + for room in gs.pa.room: room_id = room.replace(r"\!", "!") # remove possible escape rooms.append(room_id) - logger.debug( - "Room(s) were provided via command line. " - "Overwriting room id from credentials file " - f'with rooms "{rooms}" ' - "from command line." + gs.log.debug(f"Room(s) from --room: {rooms}") + rooms += await determine_dm_rooms(gs.pa.user, client, credentials) + gs.log.debug( + "Room(s) or user(s) were provided via command line. " + "Overwriting room id from credentials file " + f'with rooms "{rooms}" ' + "from command line." + ) + return rooms + + +async def map_roominfo_to_roomid(client: AsyncClient, info: str) -> str: + """Attempt to convert room info to room_id. + + Arguments: + --------- + client : nio client + info : str + can be a canonical alias in the form of '#someRoomAlias:example.com' + can be a canonical room_id in the form of '!someRoomId:example.com' + can be a short alias in the form of 'someRoomAlias' + can be a short alias in the form of '#someRoomAlias' + can be a short room id in the form of '!someRoomId' + + Return corresponding full room_id (!id:sample.com) or or raises exception. + + """ + ri = info.strip() + ri = ri.replace(r"\!", "!") # remove possible escape + if ( + ri in (None, "", "!", "#") + or ri.startswith(":") + or ri.count(":") > 1 + or ri.startswith("@") + or "#" in ri[1:] + or any(elem in ri for elem in "[]{} ") # does it contain bad chars? + or ( + not ri.startswith("!") and not ri.startswith("#") and ":" in ri + ) # alias:sample.com + ): + err = ( + "E121: " + f"Invalid room specification. '{info}' ({ri}) is neither " + "a valid room id nor a valid room alias." ) - return rooms + raise MatrixCommanderError(err) from None + if not ri.startswith("!"): + # 'someRoomAlias' or '#someRoomAlias' or '#someRoomAlias:sample.com' + if ":" not in ri: # 'someRoomAlias' or '#someRoomAlias' + ri = short_room_alias_to_room_alias(ri, gs.credentials) + ri = await map_roomalias_to_roomid(client, ri) + return ri + if ":" not in ri: + # '!someRoomId' + ri = ri + ":" + default_homeserver(gs.credentials) + return ri async def map_roomalias_to_roomid(client, alias) -> str: @@ -1628,7 +2221,7 @@ async def map_roomalias_to_roomid(client, alias) -> str: Arguments: --------- client : nio client - alias : can be an alias in the form of '#someRoomALias:example.com' + alias : can be an alias in the form of '#someRoomAlias:example.com' can also be a room_id in the form of '!someRoomId:example.com' room_id : room from credentials file @@ -1643,45 +2236,289 @@ async def map_roomalias_to_roomid(client, alias) -> str: if is_room_alias(alias): resp = await client.room_resolve_alias(alias) if isinstance(resp, RoomResolveAliasError): - logger.error( - f"room_resolve_alias for alias {alias} failed with {resp}. " + gs.log.error( + "E122: " + f"room_resolve_alias for alias {alias} failed with " + f"{privacy_filter(str(resp))}. " f"Trying operation with input {alias} anyway. Might fail." ) + gs.err_count += 1 else: ret = resp.room_id - logger.debug( + gs.log.debug( f'Mapped room alias "{alias}" to room id "{ret}". ' f"({resp.room_alias}, {resp.room_id})." ) return ret -async def create_rooms(client, room_aliases, names, topics): - """Create one or multiple rooms. +def default_homeserver(credentials: dict): + """Get the default homeserver (domain) from the credentials file. + Use the user_id, not the room_id. The room_id could be on a + different server owned by someone else. user_id makes more sense. + """ + user = credentials["user_id"] # who am i + homeserver = user.partition(":")[2] + return homeserver # matrix.example.com + + +def short_room_alias_to_room_alias(short_room_alias: str, credentials: dict): + """Convert 'SomeRoomAlias' to ''#SomeToomAlias:matrix.example.com'. + Converts short canonical local room alias to full room alias. + """ + if short_room_alias in (None, ""): + err = "E124: " "Invalid room alias. Alias is none or empty." + raise MatrixCommanderError(err) from None + if short_room_alias[0] == "#": + ret = short_room_alias + ":" + default_homeserver(credentials) + else: + ret = "#" + short_room_alias + ":" + default_homeserver(credentials) + return ret + + +def room_alias_to_short_room_alias(room_alias: str, credentials: dict): + """Convert '#SomeToomAlias:matrix.example.com' to 'SomeRoomAlias'. + Converts full room alias to short canonical local room alias. + """ + return room_alias.split(":")[0][1:] + + +def user_id_to_short_user_name(user_id: str): + """Convert '@someuser:matrix.example.com' to 'someuser'. + Convert full user_id to user nick name. + """ + return user_id.split(":")[0][1:] + + +def short_user_name_to_user_id(short_user: str, credentials: dict): + """Convert 'someuser' to '@someuser:matrix.example.com'. + Convert user nick name to full user_id. + """ + return "@" + short_user + ":" + default_homeserver(credentials) + + +def is_room_alias(room_id: str) -> bool: + """Determine if room identifier is a room alias. + + Room aliases are of syntax: #somealias:someserver + This is not an exhaustive check! + + """ + if ( + room_id + and len(room_id) > 3 + and (room_id[0] == "#") + and ("#" not in room_id[1:]) + and (":" in room_id) + and room_id.count(":") == 1 + and (" " not in room_id) + and not any(elem in room_id for elem in "[]{} ") # contains bad chars? + ): + return True + else: + return False + + +def is_room_id(room_id: str) -> bool: + """Determine if room identifier is a valid room id. + + Room ids are of syntax: !somealias:someserver + This is not an exhaustive check! + + """ + if ( + room_id + and len(room_id) > 3 + and (room_id[0] == "!") + and ("#" not in room_id) + and (":" in room_id) + and room_id.count(":") == 1 + and (" " not in room_id) + ): + return True + else: + return False + + +def is_room(room_id: str) -> bool: + """Determine if room id is a valid room id or a valid room alias. + + This is not an exhaustive check! + + """ + return is_room_id(room_id) or is_room_alias(room_id) + + +def is_short_room_alias(room_id: str) -> bool: + """Determine if room identifier is a local part of canonical room alias. + + Local parts of canonical room aliases are of syntax: somealias + + Now also allowing #somealias + + """ + if ( + room_id + and len(room_id) > 0 + and room_id != "#" + and (":" not in room_id) + and ("#" not in room_id[1:]) + and (not room_id.startswith("!")) + and (not room_id.startswith("@")) + and (" " not in room_id) + ): + return True + else: + return False + + +def is_user_id(user_id: str) -> bool: + """Determine if user identifier is a valid user id. + + User ids are of syntax: @someuser:someserver + This is not an exhaustive check! + + """ + if ( + user_id + and len(user_id) > 3 + and (user_id[0] == "@") + and (":" in user_id) + and (" " not in user_id) + ): + return True + else: + return False + + +def is_short_user_id(user_id: str) -> bool: + """Determine if user identifier is a valid short user id. + + Short user ids are of syntax: someuser + This is not an exhaustive check! + + """ + if ( + user_id + and len(user_id) > 0 + and (":" not in user_id) + and ("@" not in user_id) + and (" " not in user_id) + ): + return True + else: + return False + + +def is_partial_user_id(user_id: str) -> bool: + """Determine if user identifier is a valid abbreviated user id. + + Abbrev. user ids are of syntax: @someuser + This is not an exhaustive check! + + """ + if ( + user_id + and len(user_id) > 1 + and (user_id[0] == "@") + and (":" not in user_id) + and (" " not in user_id) + ): + return True + else: + return False + + +def is_user(user_id: str) -> bool: + """Determine if user id is a valid user id or a valid short user id. + + This is not an exhaustive check! + + """ + return ( + is_user_id(user_id) + or is_partial_user_id(user_id) + or is_short_user_id(user_id) + ) + + +async def action_room_dm_create(client: AsyncClient, credentials: dict): + """Create a direct message (DM) room while already being logged in. + + After creating the private DM room it invites the other user to it. Arguments: --------- - client : nio client - room_aliases : list of room aliases in the form of "sampleAlias" - These aliases will then be used by the server and - the server creates the definite alias in the form - of "#sampleAlias:example.com" from it. - Do not attempt to use "#sampleAlias:example.com" - as it will confuse the server. - names : list of names for rooms - topics : list of room topics - + client: AsyncClient: nio client, allows as to query the server + credentials: dict: allows to get the user_id of sender, etc """ + # users : list of users to create DM rooms with + # room_aliases : list of room aliases in the form of "sampleAlias" + # These aliases will then be used by the server and + # the server creates the definite alias in the form + # of "#sampleAlias:example.com" from it. + # We permit "#sampleAlias:example.com" and downscale it to + # "sampleAlias". + # names : list of names for rooms + # topic : room topics + + users = gs.pa.room_dm_create + room_aliases = gs.pa.alias + names = gs.pa.name + topics = gs.pa.topic try: index = 0 - logger.debug( - f'Trying to create rooms with room aliases "{room_aliases}", ' + gs.log.debug( + f'Trying to create DM rooms with users "{users}", ' + f'room aliases "{room_aliases}", ' f'names "{names}", and topics "{topics}".' ) - for alias in room_aliases: - alias = alias.replace(r"\!", "!") # remove possible escape - # alias is a true alias, not a room id - # "alias1" will be converted into "#alias1:example.com" + gs.log.debug( + "Option --room-dm-create-allow-duplicates has value " + f"{gs.pa.room_dm_create_allow_duplicates}." + ) + for user in users: + # see Issue #140 + if not gs.pa.room_dm_create_allow_duplicates: + existing_dm_rooms = await determine_dm_rooms_for_user( + user, client, credentials + ) + if existing_dm_rooms: + room_id = existing_dm_rooms[0] + gs.log.info( + f'DM room(s) with user "{user}" ' + "already exist(s). These DM rooms were found: " + f"{existing_dm_rooms}. " + "Not creating a new room. " + "Ignoring --room-dm-create for this " + f"user {user}." + ) + # output format controlled via --output flag + text = f"{room_id}" + # Object of type RoomCreateResponse is not JSON + # serializable, hence we use the dictionary. + json_max = {} # empty dict + # resp has only 1 useful useful member: room_id + json_max.update({"room_id": room_id}) # add dict items + json_ = json_max.copy() + json_spec = None + print_output( + gs.pa.output, + text=text, + json_=json_, + json_max=json_max, + json_spec=json_spec, + ) + continue + try: + alias = room_aliases[index] + alias = alias.replace(r"\!", "!") # remove possible escape + # alias is a true alias, not a room id + # if by mistake user has given full room alias, shorten it + if is_room_alias(alias): + alias = room_alias_to_short_room_alias(alias, credentials) + except (IndexError, TypeError): + alias = "" try: name = names[index] except (IndexError, TypeError): @@ -1690,181 +2527,504 @@ async def create_rooms(client, room_aliases, names, topics): topic = topics[index] except (IndexError, TypeError): topic = "" - logger.debug( - f'Creating room with room alias "{alias}", ' - f'name "{name}", and topic "{topic}".' + alias = alias.strip() + alias = None if alias == "" else alias + name = name.strip() + name = None if name == "" else name + topic = topic.strip() + topic = None if topic == "" else topic + if gs.pa.plain: + encrypt = False + initial_state = () + else: + encrypt = True + initial_state = [EnableEncryptionBuilder().as_dict()] + gs.log.debug( + f'Creating DM room with user "{user}", ' + f'room alias "{alias}", ' + f'name "{name}", topic "{topic}" and ' + f'encrypted "{encrypt}".' ) + # nio's room_create does NOT accept "#foo:example.com" resp = await client.room_create( - alias=alias, + alias=alias, # desired canonical alias local part, e.g. foo + visibility=RoomVisibility.private, + is_direct=True, + preset=RoomPreset.private_chat, + invite={user}, # invite the user to the DM name=name, # room name topic=topic, # room topic - initial_state=[EnableEncryptionBuilder().as_dict()], + initial_state=initial_state, ) + # "alias1" will create a "#alias1:example.com" if isinstance(resp, RoomCreateError): - logger.error(f"Room_create failed with {resp}") + gs.log.error( + "E125: " + "Room_create failed with response: " + f"{privacy_filter(str(resp))}" + ) + gs.err_count += 1 else: - logger.info(f'Created room "{alias}".') + if alias: + full_alias = short_room_alias_to_room_alias( + alias, credentials + ) + else: + full_alias = None + gs.log.info( + f'Created DM room with room id "{resp.room_id}", ' + f'short alias "{zn(alias)}", ' + f'full alias "{zn(full_alias)}" and ' + f'encrypted "{encrypt}".' + ) + # output format controlled via --output flag + text = ( + f"{resp.room_id}{SEP}{zn(alias)}{SEP}{zn(full_alias)}" + f"{SEP}{zn(name)}{SEP}{zn(topic)}{SEP}{encrypt}" + ) + # Object of type RoomCreateResponse is not JSON + # serializable, hence we use the dictionary. + json_max = resp.__dict__ + # resp has only 1 useful useful member: room_id + json_max.update({"alias": alias}) # add dict items + json_max.update({"alias_full": full_alias}) + json_max.update({"name": name}) + json_max.update({"topic": topic}) + json_max.update({"encrypted": encrypt}) + json_ = json_max.copy() + json_.pop("transport_response") + json_spec = None + print_output( + gs.pa.output, + text=text, + json_=json_, + json_max=json_max, + json_spec=json_spec, + ) index = index + 1 except Exception: - logger.error("Room creation failed. Sorry.") - logger.debug("Here is the traceback.\n" + traceback.format_exc()) + gs.log.error("E126: " "DM room creation failed. Sorry.") + gs.err_count += 1 + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) -async def join_rooms(client, rooms): +async def action_room_create(client: AsyncClient, credentials: dict): + """Create one or multiple rooms while already being logged in. + + Arguments: + --------- + client: AsyncClient: nio client, allows as to query the server + credentials: dict: allows to get the user_id of sender, etc + """ + # room_aliases : list of room aliases in the form of "sampleAlias" + # These aliases will then be used by the server and + # the server creates the definite alias in the form + # of "#sampleAlias:example.com" from it. + # We permit "#sampleAlias:example.com" and downscale it to + # "sampleAlias". + # names : list of names for rooms + # topics : list of room topics + + room_aliases = gs.pa.room_create + names = gs.pa.name + topics = gs.pa.topic + try: + index = 0 + gs.log.debug( + f'Trying to create rooms with room aliases "{room_aliases}", ' + f'names "{names}", and topics "{topics}".' + ) + for alias in room_aliases: + alias = alias.replace(r"\!", "!") # remove possible escape + # alias is a true alias, not a room id + # if by mistake user has given full room alias, shorten it + if is_room_alias(alias): + alias = room_alias_to_short_room_alias(alias, credentials) + try: + name = names[index] + except (IndexError, TypeError): + name = "" + try: + topic = topics[index] + except (IndexError, TypeError): + topic = "" + alias = alias.strip() + alias = None if alias == "" else alias + name = name.strip() + name = None if name == "" else name + topic = topic.strip() + topic = None if topic == "" else topic + if gs.pa.plain: + encrypt = False + initial_state = () + else: + encrypt = True + initial_state = [EnableEncryptionBuilder().as_dict()] + gs.log.debug( + f'Creating room with room alias "{alias}", ' + f'name "{name}", topic "{topic}" and ' + f'encrypted "{encrypt}".' + ) + # nio's room_create does NOT accept "#foo:example.com" + resp = await client.room_create( + alias=alias, # desired canonical alias local part, e.g. foo + name=name, # room name + topic=topic, # room topic + initial_state=initial_state, + ) + # "alias1" will create a "#alias1:example.com" + if isinstance(resp, RoomCreateError): + gs.log.error( + "E127: " + "Room_create failed with response: " + f"{privacy_filter(str(resp))}" + ) + gs.err_count += 1 + else: + if alias: + full_alias = short_room_alias_to_room_alias( + alias, credentials + ) + else: + full_alias = None + gs.log.info( + f'Created room with room id "{resp.room_id}", ' + f'short alias "{zn(alias)}", ' + f'full alias "{zn(full_alias)}" and ' + f'encrypted "{encrypt}".' + ) + # output format controlled via --output flag + text = ( + f"{resp.room_id}{SEP}{zn(alias)}{SEP}{zn(full_alias)}" + f"{SEP}{zn(name)}{SEP}{zn(topic)}{SEP}{encrypt}" + ) + # Object of type RoomCreateResponse is not JSON + # serializable, hence we use the dictionary. + json_max = resp.__dict__ + # resp has only 1 useful useful member: room_id + json_max.update({"alias": alias}) # add dict items + json_max.update({"alias_full": full_alias}) + json_max.update({"name": name}) + json_max.update({"topic": topic}) + json_max.update({"encrypted": encrypt}) + json_ = json_max.copy() + json_.pop("transport_response") + json_spec = None + print_output( + gs.pa.output, + text=text, + json_=json_, + json_max=json_max, + json_spec=json_spec, + ) + index = index + 1 + except Exception: + gs.log.error("E128: " "Room creation failed. Sorry.") + gs.err_count += 1 + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) + + +async def action_room_join(client, credentials): """Join one or multiple rooms.""" + rooms = gs.pa.room_join try: for room_id in rooms: # room_id can be #roomAlias or !roomId - room_id = room_id.replace(r"\!", "!") # remove possible escape - logger.debug(f'Joining room with room alias "{room_id}".') - room_id = await map_roomalias_to_roomid(client, room_id) + gs.log.debug(f'Preparing to join room "{room_id}".') + room_id = await map_roominfo_to_roomid(client, room_id) + gs.log.debug(f'Joining room "{room_id}".') resp = await client.join(room_id) if isinstance(resp, JoinError): - logger.error(f"join failed with {resp}") + gs.log.error( + "E129: " f"join failed with {privacy_filter(str(resp))}" + ) + gs.err_count += 1 else: - logger.info(f'Joined room "{room_id}" successfully.') + gs.log.info(f'Joined room "{room_id}" successfully.') except Exception: - logger.error("Joining rooms failed. Sorry.") - logger.debug("Here is the traceback.\n" + traceback.format_exc()) + gs.log.error("E130: " "Joining rooms failed. Sorry.") + gs.err_count += 1 + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) -async def leave_rooms(client, rooms): +async def action_room_leave(client, credentials): """Leave one or multiple rooms.""" + rooms = gs.pa.room_leave try: for room_id in rooms: # room_id can be #roomAlias or !roomId - room_id = room_id.replace(r"\!", "!") # remove possible escape - logger.debug(f'Leaving room with room alias "{room_id}".') - room_id = await map_roomalias_to_roomid(client, room_id) + gs.log.debug(f'Preparing to leave room "{room_id}".') + room_id = await map_roominfo_to_roomid(client, room_id) + gs.log.debug(f'Leaving room "{room_id}".') resp = await client.room_leave(room_id) if isinstance(resp, RoomLeaveError): - logger.error(f"Leave failed with {resp}") + gs.log.error( + "E131: " f"Leave failed with {privacy_filter(str(resp))}" + ) + gs.err_count += 1 else: - logger.info(f'Left room "{room_id}".') + gs.log.info(f'Left room "{room_id}".') except Exception: - logger.error("Room leave failed. Sorry.") - logger.debug("Here is the traceback.\n" + traceback.format_exc()) + gs.log.error("E132: " "Room leave failed. Sorry.") + gs.err_count += 1 + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) -async def forget_rooms(client, rooms): +async def action_room_forget(client, credentials): """Forget one or multiple rooms.""" + rooms = gs.pa.room_forget try: for room_id in rooms: # room_id can be #roomAlias or !roomId - room_id = room_id.replace(r"\!", "!") # remove possible escape - logger.debug(f'Forgetting room with room alias "{room_id}".') - room_id = await map_roomalias_to_roomid(client, room_id) + gs.log.debug(f'Preparing to forget room "{room_id}".') + room_id = await map_roominfo_to_roomid(client, room_id) + gs.log.debug(f'Forgetting room "{room_id}".') resp = await client.room_forget(room_id) if isinstance(resp, RoomForgetError): - logger.error(f"Forget failed with {resp}") + gs.log.error( + "E133: " f"Forget failed with {privacy_filter(str(resp))}" + ) + gs.err_count += 1 else: - logger.info(f'Forgot room "{room_id}".') + gs.log.info(f'Forgot room "{room_id}".') except Exception: - logger.error("Room forget failed. Sorry.") - logger.debug("Here is the traceback.\n" + traceback.format_exc()) + gs.log.error("E134: " "Room forget failed. Sorry.") + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) -async def invite_to_rooms(client, rooms, users): +async def action_room_invite(client, credentials): """Invite one or multiple users to one or multiple rooms.""" + rooms = gs.pa.room_invite + users = gs.pa.user try: for room_id in rooms: # room_id can be #roomAlias or !roomId - room_id = room_id.replace(r"\!", "!") # remove possible escape - room_id = await map_roomalias_to_roomid(client, room_id) + gs.log.debug(f'Preparing to invite to room "{room_id}".') + room_id = await map_roominfo_to_roomid(client, room_id) + gs.log.debug(f'Inviting to room "{room_id}".') for user in users: - logger.debug( + gs.log.debug( f'Inviting user "{user}" to room with ' f'room alias "{room_id}".' ) resp = await client.room_invite(room_id, user) if isinstance(resp, RoomInviteError): - logger.error(f"room_invite failed with {resp}") + gs.log.error( + "E135: " + f"room_invite failed with {privacy_filter(str(resp))}" + ) + gs.err_count += 1 else: - logger.info( + gs.log.info( f'User "{user}" was successfully invited ' f'to room "{room_id}".' ) except Exception: - logger.error("User invite failed. Sorry.") - logger.debug("Here is the traceback.\n" + traceback.format_exc()) + gs.log.error("E136: " "User invite failed. Sorry.") + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) -async def ban_from_rooms(client, rooms, users): +async def action_room_ban(client, credentials): """Ban one or multiple users from one or multiple rooms.""" + rooms = gs.pa.room_ban + users = gs.pa.user try: for room_id in rooms: # room_id can be #roomAlias or !roomId - room_id = room_id.replace(r"\!", "!") # remove possible escape - room_id = await map_roomalias_to_roomid(client, room_id) + gs.log.debug(f'Preparing to ban in room "{room_id}".') + room_id = await map_roominfo_to_roomid(client, room_id) + gs.log.debug(f'Banning to room "{room_id}".') for user in users: - logger.debug( + gs.log.debug( f'Banning user "{user}" from room with ' f'room alias "{room_id}".' ) resp = await client.room_ban(room_id, user) if isinstance(resp, RoomBanError): - logger.error(f"room_ban failed with {resp}") + gs.log.error( + "E137: " + f"room_ban failed with {privacy_filter(str(resp))}" + ) + gs.err_count += 1 else: - logger.info( + gs.log.info( f'User "{user}" was successfully banned ' f'from room "{room_id}".' ) except Exception: - logger.error("User ban failed. Sorry.") - logger.debug("Here is the traceback.\n" + traceback.format_exc()) + gs.log.error("E138: " "User ban failed. Sorry.") + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) -async def unban_from_rooms(client, rooms, users): +async def action_room_unban(client, credentials): """Unban one or multiple users from one or multiple rooms.""" + rooms = gs.pa.room_unban + users = gs.pa.user try: for room_id in rooms: # room_id can be #roomAlias or !roomId - room_id = room_id.replace(r"\!", "!") # remove possible escape - room_id = await map_roomalias_to_roomid(client, room_id) + gs.log.debug(f'Preparing to unban in room "{room_id}".') + room_id = await map_roominfo_to_roomid(client, room_id) + gs.log.debug(f'Unbanning to room "{room_id}".') for user in users: - logger.debug( + gs.log.debug( f'Unbanning user "{user}" from room with ' f'room alias "{room_id}".' ) resp = await client.room_unban(room_id, user) if isinstance(resp, RoomUnbanError): - logger.error(f"room_unban failed with {resp}") + gs.log.error( + "E139: " + f"room_unban failed with {privacy_filter(str(resp))}" + ) + gs.err_count += 1 else: - logger.info( + gs.log.info( f'User "{user}" was successfully unbanned ' f'from room "{room_id}".' ) except Exception: - logger.error("User unban failed. Sorry.") - logger.debug("Here is the traceback.\n" + traceback.format_exc()) + gs.log.error("E140: " "User unban failed. Sorry.") + gs.err_count += 1 + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) -async def kick_from_rooms(client, rooms, users): +async def action_room_kick(client, credentials): """Kick one or multiple users from one or multiple rooms.""" + rooms = gs.pa.room_kick + users = gs.pa.user try: for room_id in rooms: # room_id can be #roomAlias or !roomId - room_id = room_id.replace(r"\!", "!") # remove possible escape - room_id = await map_roomalias_to_roomid(client, room_id) + gs.log.debug(f'Preparing to kicking off room "{room_id}".') + room_id = await map_roominfo_to_roomid(client, room_id) + gs.log.debug(f'Kicking off room "{room_id}".') for user in users: - logger.debug( + gs.log.debug( f'Kicking user "{user}" from room with ' f'room alias "{room_id}".' ) resp = await client.room_kick(room_id, user) if isinstance(resp, RoomKickError): - logger.error(f"room_kick failed with {resp}") + gs.log.error( + "E141: " + f"room_kick failed with {privacy_filter(str(resp))}" + ) + gs.err_count += 1 else: - logger.info( + gs.log.info( f'User "{user}" was successfully kicked ' f'from room "{room_id}".' ) except Exception: - logger.error("User kick failed. Sorry.") - logger.debug("Here is the traceback.\n" + traceback.format_exc()) + gs.log.error("E142: " "User kick failed. Sorry.") + gs.err_count += 1 + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) -async def send_file(client, rooms, file): +# according to linter: function is too complex, C901 +async def send_event(client, rooms, event): # noqa: C901 + """Process event. + + Arguments: + --------- + client : Client + rooms : list + list of room_id-s + event : str + file name of event from --event argument + + """ + if not rooms: + gs.log.info( + "No rooms are given. This should not happen. " + "Maybe your DM rooms specified via --user were not found. " + "This file is being dropped and NOT sent." + ) + return + + if event == "-": # - means read as pipe from stdin + jsondata = sys.stdin.buffer.read().decode() # binary read + else: + with open(event, "r") as file: + jsondata = file.read() + gs.log.debug( + f"{len(jsondata)} bytes of event data read from file {event}." + ) + gs.log.debug(f"Event {event} contains this JSON data: {jsondata}") + + if not jsondata.strip(): + gs.log.debug( + "Event is empty. This event is being dropped and NOT sent." + ) + return + + try: + content_json = json.loads(jsondata) + message_type = content_json["type"] + content = content_json["content"] + except Exception: + gs.log.error( + "E143: " + "Event is not a valid JSON object or not of Matrix JSON format. " + "This event is being dropped and NOT sent." + ) + gs.err_count += 1 + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) + return + + try: + for room_id in rooms: + room_id = await map_roominfo_to_roomid(client, room_id) + resp = await client.room_send( + room_id, + message_type=message_type, + content=content, + ignore_unverified_devices=True, + ) + if isinstance(resp, RoomSendError): + gs.log.error( + "E144: " + "room_send failed with error " + f"'{privacy_filter(str(resp))}'." + ) + # gs.err_count += 1 # not needed, will raise exception + # in following line of code + gs.log.info( + f'This event was sent: "{event}" to room "{resp.room_id}" ' + f'as event "{resp.event_id}".' + ) + if gs.pa.print_event_id: + # output format controlled via --output flag + text = f"{resp.event_id}{SEP}{resp.room_id}{SEP}{event}" + # Object of type RoomCreateResponse is not JSON + # serializable, hence we use the dictionary. + json_max = resp.__dict__ + json_max.update({"event": event}) # add dict items + json_ = json_max.copy() + json_.pop("transport_response") + json_spec = None + print_output( + gs.pa.output, + text=text, + json_=json_, + json_max=json_max, + json_spec=json_spec, + ) + gs.log.debug( + f'This event was sent: "{event}" ({content}) ' + f'to room "{room_id}". ' + f"Response: event_id={resp.event_id}, room_id={resp.room_id}, " + f"full response: {privacy_filter(str(resp))}. " + ) + except Exception: + gs.log.error("E145: " f"Event send of file {event} failed. Sorry.") + gs.err_count += 1 + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) + + +# according to linter: function is too complex, C901 +async def send_file(client, rooms, file): # noqa: C901 """Process file. Upload file to server and then send link to rooms. @@ -1912,26 +3072,46 @@ async def send_file(client, rooms, file): """ if not rooms: - logger.info( + gs.log.info( "No rooms are given. This should not happen. " - "This file is being droppend and NOT sent." + "Maybe your DM rooms specified via --user were not found. " + "This file is being dropped and NOT sent." ) return + + # for more comments on how to treat pipe on stdin please read the + # comments in send_image() + + if file == "-": # - means read as pipe from stdin + isPipe = True + fin_buf = sys.stdin.buffer.read() + len_fin_buf = len(fin_buf) + file = "mc-" + str(uuid.uuid4()) + ".tmp" + gs.log.debug( + f"{len_fin_buf} bytes of file data read from stdin. " + f'Temporary file "{file}" was created for file.' + ) + fout = open(file, "wb") + fout.write(fin_buf) + fout.close() + else: + isPipe = False + if not os.path.isfile(file): - logger.debug( + gs.log.debug( f"File {file} is not a file. Doesn't exist or " - "is a directory." - "This file is being droppend and NOT sent." + "is a directory. " + "This file is being dropped and NOT sent." ) return # # restrict to "txt", "pdf", "mp3", "ogg", "wav", ... # if not re.match("^.pdf$|^.txt$|^.doc$|^.xls$|^.mobi$|^.mp3$", # os.path.splitext(file)[1].lower()): - # logger.debug(f"File {file} is not a permitted file type. Should be " + # gs.log.debug(f"File {file} is not a permitted file type. Should be " # ".pdf, .txt, .doc, .xls, .mobi or .mp3 ... " # f"[{os.path.splitext(file)[1].lower()}]" - # "This file is being droppend and NOT sent.") + # "This file is being dropped and NOT sent.") # return # 'application/pdf' "plain/text" "audio/ogg" @@ -1939,10 +3119,10 @@ async def send_file(client, rooms, file): # if ((not mime_type.startswith("application/")) and # (not mime_type.startswith("plain/")) and # (not mime_type.startswith("audio/"))): - # logger.debug(f"File {file} does not have an accepted mime type. " + # gs.log.debug(f"File {file} does not have an accepted mime type. " # "Should be something like application/pdf. " # f"Found mime type {mime_type}. " - # "This file is being droppend and NOT sent.") + # "This file is being dropped and NOT sent.") # return # first do an upload of file, see upload() documentation @@ -1959,20 +3139,21 @@ async def send_file(client, rooms, file): encrypt=True, ) if isinstance(resp, UploadResponse): - logger.debug( - f"File was uploaded successfully to server. Response is: {resp}" + gs.log.debug( + "File was uploaded successfully to server. Response is: " + f"{privacy_filter(str(resp))}" ) else: - logger.info( + gs.log.info( f"The program {PROG_WITH_EXT} failed to upload. " "Please retry. This could be temporary issue on " "your server. " "Sorry." ) - logger.info( + gs.log.info( f'file="{file}"; mime_type="{mime_type}"; ' - f'filessize="{file_stat.st_size}"' - f"Failed to upload: {resp}" + f'filessize="{file_stat.st_size}"; ' + f"Failed to upload: Server response: {privacy_filter(str(resp))}" ) # determine msg_type: @@ -1996,18 +3177,61 @@ async def send_file(client, rooms, file): }, } + if isPipe: + # rm temp file + os.remove(file) + try: for room_id in rooms: - await client.room_send( - room_id, message_type="m.room.message", content=content + room_id = await map_roominfo_to_roomid(client, room_id) + resp = await client.room_send( + room_id, + message_type="m.room.message", + content=content, + ignore_unverified_devices=True, + ) + if isinstance(resp, RoomSendError): + gs.log.error( + "E146: " + "room_send failed with error " + f"'{privacy_filter(str(resp))}'." + ) + # gs.err_count += 1 # not needed, will raise exception + # in following line of code + gs.log.info( + f'This file was sent: "{file}" to room "{resp.room_id}" ' + f'as event "{resp.event_id}".' + ) + if gs.pa.print_event_id: + # output format controlled via --output flag + text = f"{resp.event_id}{SEP}{resp.room_id}{SEP}{file}" + # Object of type RoomCreateResponse is not JSON + # serializable, hence we use the dictionary. + json_max = resp.__dict__ + json_max.update({"file": file}) # add dict items + json_ = json_max.copy() + json_.pop("transport_response") + json_spec = None + print_output( + gs.pa.output, + text=text, + json_=json_, + json_max=json_max, + json_spec=json_spec, + ) + gs.log.debug( + f'This file was sent: "{file}" to room "{room_id}". ' + f"Response: event_id={resp.event_id}, room_id={resp.room_id}, " + f"full response: {privacy_filter(str(resp))}. " ) - logger.info(f'This file was sent: "{file}" to room "{room_id}".') except Exception: - logger.error(f"File send of file {file} failed. Sorry.") - logger.debug("Here is the traceback.\n" + traceback.format_exc()) + gs.log.error("E147: " f"File send of file {file} failed. Sorry.") + gs.err_count += 1 + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) -async def send_image(client, rooms, image): +# according to linter: function is too complex, C901 +async def send_image(client, rooms, image): # noqa: C901 """Process image. Arguments: @@ -2053,52 +3277,105 @@ async def send_image(client, rooms, image): """ if not rooms: - logger.info( + gs.log.warning( + "W101: " "No rooms are given. This should not happen. " - "This image is being droppend and NOT sent." - ) - return - if not os.path.isfile(image): - logger.debug( - f"Image file {image} is not a file. Doesn't exist or " - "is a directory." + "Maybe your DM rooms specified via --user were not found. " "This image is being dropped and NOT sent." ) + gs.warn_count += 1 + return + + # how to treat pipe on stdin? + # aiofiles.open(sys.stdin, "r+b") does not work, wrong type. + # aiofiles.open(sys.stdin.buffer, "r+b") does not work, wrong type. + # aiofiles.open('/dev/stdin', mode='rb') fails with error: + # io.UnsupportedOperation: File or stream is not seekable + # stdin, _ = await aioconsole.get_standard_streams() also failes + # Hence I see no way to directly hand stdin to aiofiles. + # Problem: I cannot combine the 3 things: + # stdin + aiofiles + nio.AsyncClient.upload() + # Since I could not overcome this problem I generate a temporary file + + if image == "-": # - means read as pipe from stdin + isPipe = True + fin_buf = sys.stdin.buffer.read() + len_fin_buf = len(fin_buf) + image = "mc-" + str(uuid.uuid4()) + ".tmp" + gs.log.debug( + f"{len_fin_buf} bytes of image data read from stdin. " + f'Temporary file "{image}" was created for image.' + ) + fout = open(image, "wb") + fout.write(fin_buf) + fout.close() + else: + isPipe = False + + if not os.path.isfile(image): + gs.log.warning( + "W102: " + f"Image file {image} is not a file. Doesn't exist or " + "is a directory. " + "This image is being dropped and NOT sent." + ) + gs.warn_count += 1 return # "bmp", "gif", "jpg", "jpeg", "png", "pbm", "pgm", "ppm", "xbm", "xpm", # "tiff", "webp", "svg", - if not re.match( + # svg files are not shown in Element, hence send SVG files as files with -f + if not isPipe and not re.match( "^.jpg$|^.jpeg$|^.gif$|^.png$|^.svg$", os.path.splitext(image)[1].lower(), ): - logger.debug( + gs.log.warning( + "W103: " f"Image file {image} is not an image file. Should be " ".jpg, .jpeg, .gif, or .png. " - f"[{os.path.splitext(image)[1].lower()}]" + f"Found [{os.path.splitext(image)[1].lower()}]. " "This image is being dropped and NOT sent." ) + gs.warn_count += 1 return # 'application/pdf' "image/jpeg" + # svg mime-type is "image/svg+xml" mime_type = magic.from_file(image, mime=True) + gs.log.debug(f"Image file mime-type is {mime_type}") if not mime_type.startswith("image/"): - logger.debug( + gs.log.warning( + "W104: " f"Image file {image} does not have an image mime type. " "Should be something like image/jpeg. " f"Found mime type {mime_type}. " - "This image is being droppend and NOT sent." + "This image is being dropped and NOT sent." ) + gs.warn_count += 1 return - im = Image.open(image) - (width, height) = im.size # im.size returns (width,height) tuple + if mime_type.startswith("image/svg"): + gs.log.warning( + "W105: " + "There is a bug in Element preventing previews of SVG images. " + "Alternatively you may send SVG files as files via -f." + ) + width = 100 # in pixel + height = 100 + # Python blurhash package does not work on SVG + # blurhash: some random colorful image + blurhash = "ULH_C:0HGF}B.$k:PLVG8z}$4;o?~IQ:9$yB" + blurhash = None # shows turning circle forever in Element due to bug + else: + im = Image.open(image) # this will fail for SVG files + (width, height) = im.size # im.size returns (width,height) tuple + blurhash = None # first do an upload of image, see upload() documentation # http://matrix-nio.readthedocs.io/en/latest/nio.html#nio.AsyncClient.upload # then send URI of upload to room - # Note that encrypted upload works even with unencrypted rooms; the + # Note that encrypted upload works even with unencrypted/plain rooms; the # decryption keys will not be protected, obviously, but no special # treatment is required. @@ -2109,24 +3386,24 @@ async def send_image(client, rooms, image): content_type=mime_type, # image/jpeg filename=os.path.basename(image), filesize=file_stat.st_size, - encrypt=True + encrypt=True, ) if isinstance(resp, UploadResponse): - logger.debug( + gs.log.debug( "Image was uploaded successfully to server. " - f"Response is: {resp}" + f"Response is: {privacy_filter(str(resp))}" ) else: - logger.info( + gs.log.info( f"The program {PROG_WITH_EXT} failed to upload. " "Please retry. This could be temporary issue on " "your server. " "Sorry." ) - logger.info( + gs.log.info( f'file="{image}"; mime_type="{mime_type}"; ' - f'filessize="{file_stat.st_size}"' - f"Failed to upload: {resp}" + f'filessize="{file_stat.st_size}"; ' + f"Failed to upload: Server response: {privacy_filter(str(resp))}" ) # TODO compute thumbnail, upload thumbnail to Server @@ -2137,10 +3414,11 @@ async def send_image(client, rooms, image): "info": { "size": file_stat.st_size, "mimetype": mime_type, - "thumbnail_info": None, # TODO + # "thumbnail_info": None, # TODO "w": width, # width in pixel "h": height, # height in pixel - "thumbnail_url": None, # TODO + # "thumbnail_url": None, # TODO + "xyz.amorgan.blurhash": blurhash, # "thumbnail_file": None, }, "msgtype": "m.image", @@ -2153,25 +3431,67 @@ async def send_image(client, rooms, image): }, } + if isPipe: + # rm temp file + os.remove(image) + try: for room_id in rooms: - await client.room_send( - room_id, message_type="m.room.message", content=content + room_id = await map_roominfo_to_roomid(client, room_id) + resp = await client.room_send( + room_id, + message_type="m.room.message", + content=content, + ignore_unverified_devices=True, ) - logger.debug( - f'This image file was sent: "{image}" to room "{room_id}".' + if isinstance(resp, RoomSendError): + gs.log.error( + "E148: " + "room_send failed with error " + f"'{privacy_filter(str(resp))}'." + ) + # gs.err_count += 1 # not needed, will raise exception + # in following line of code + gs.log.info( + f'This image file was sent: "{image}" ' + f'to room "{resp.room_id}" ' + f'as event "{resp.event_id}".' + ) + if gs.pa.print_event_id: + # output format controlled via --output flag + text = f"{resp.event_id}{SEP}{resp.room_id}{SEP}{image}" + # Object of type RoomCreateResponse is not JSON + # serializable, hence we use the dictionary. + json_max = resp.__dict__ + json_max.update({"image": image}) # add dict items + json_ = json_max.copy() + json_.pop("transport_response") + json_spec = None + print_output( + gs.pa.output, + text=text, + json_=json_, + json_max=json_max, + json_spec=json_spec, + ) + gs.log.debug( + f'This image file was sent: "{image}" ' + f'to room "{room_id}". ' + f"Response: event_id={resp.event_id}, room_id={resp.room_id}, " + f"full response: {privacy_filter(str(resp))}. " ) except Exception: - logger.error(f"Image send of file {image} failed. Sorry.") - logger.debug("Here is the traceback.\n" + traceback.format_exc()) + gs.log.error("E149: " f"Image send of file {image} failed. Sorry.") + gs.err_count += 1 + gs.log.debug("Here is the traceback.\n" + traceback.format_exc()) # according to linter: function is too complex, C901 async def send_message(client, rooms, message): # noqa: C901 """Process message. - Format messages according to instructions from command line arguments. - Then send all messages to all rooms. + Format message according to instructions from command line arguments. + Then send the one message to all rooms. Arguments: --------- @@ -2184,35 +3504,36 @@ async def send_message(client, rooms, message): # noqa: C901 """ if not rooms: - logger.info( + gs.log.info( "No rooms are given. This should not happen. " - "This text message is being droppend and NOT sent." + "Maybe your DM rooms specified via --user were not found. " + "This text message is being dropped and NOT sent." ) return # remove leading AND trailing newlines to beautify message = message.strip("\n") - if message == "" or message.strip() == "": - logger.debug( + if message.strip() == "": + gs.log.debug( "The message is empty. " - "This message is being droppend and NOT sent." + "This message is being dropped and NOT sent." ) return - if pargs.notice: + if gs.pa.notice: content = {"msgtype": "m.notice"} else: content = {"msgtype": "m.text"} - if pargs.code: - logger.debug('Sending message in format "code".') + if gs.pa.code: + gs.log.debug('Sending message in format "code".') formatted_message = "
" + message + "\n\n"
content["format"] = "org.matrix.custom.html" # add to dict
content["formatted_body"] = formatted_message
# next line: work-around for Element Android
message = "```\n" + message + "\n```" # to format it as code
- elif pargs.markdown:
- logger.debug(
+ elif gs.pa.markdown:
+ gs.log.debug(
"Converting message from MarkDown into HTML. "
'Sending message in format "markdown".'
)
@@ -2220,38 +3541,128 @@ async def send_message(client, rooms, message): # noqa: C901
formatted_message = markdown(message)
content["format"] = "org.matrix.custom.html" # add to dict
content["formatted_body"] = formatted_message
- elif pargs.html:
- logger.debug('Sending message in format "html".')
+ elif gs.pa.html:
+ gs.log.debug('Sending message in format "html".')
formatted_message = message # the same for the time being
content["format"] = "org.matrix.custom.html" # add to dict
content["formatted_body"] = formatted_message
+ elif gs.pa.emojize:
+ gs.log.debug('Sending message in format "emojized".')
+ formatted_message = emoji.emojize(
+ message
+ ) # convert emoji shortcodes if present
+ content["format"] = "org.matrix.custom.html" # add to dict
+ content["formatted_body"] = formatted_message
else:
- logger.debug('Sending message in format "text".')
+ gs.log.debug('Sending message in format "text".')
content["body"] = message
try:
for room_id in rooms:
- if is_room_alias(room_id):
- resp = await client.room_resolve_alias(room_id)
- if isinstance(resp, RoomResolveAliasError):
- print(f"room_resolve_alias failed with {resp}")
- room_id = resp.room_id
- logger.debug(
- f'Mapping room alias "{resp.room_alias}" to '
- f'room id "{resp.room_id}".'
- )
- await client.room_send(
+ room_id = await map_roominfo_to_roomid(client, room_id)
+ resp = await client.room_send(
room_id,
message_type="m.room.message",
content=content,
ignore_unverified_devices=True,
)
- logger.info(
- f'This message was sent: "{message}" to room "{room_id}".'
+ if isinstance(resp, RoomSendError):
+ gs.log.error(
+ "E150: "
+ "room_send failed with error "
+ f"'{privacy_filter(str(resp))}'."
+ )
+ # gs.err_count += 1 # not needed, will raise exception
+ # in following line of code
+ gs.log.info(
+ f'This message was sent: "{message}" to room "{resp.room_id}" '
+ f'as event "{resp.event_id}".'
+ )
+ if gs.pa.print_event_id:
+ # output format controlled via --output flag
+ text = f"{resp.event_id}{SEP}{resp.room_id}{SEP}{message}"
+ # Object of type RoomCreateResponse is not JSON
+ # serializable, hence we use the dictionary.
+ json_max = resp.__dict__
+ json_max.update({"message": message}) # add dict items
+ json_ = json_max.copy()
+ json_.pop("transport_response")
+ json_spec = None
+ print_output(
+ gs.pa.output,
+ text=text,
+ json_=json_,
+ json_max=json_max,
+ json_spec=json_spec,
+ )
+ gs.log.debug(
+ f'This message was sent: "{message}" to room "{room_id}". '
+ f"Response: event_id={resp.event_id}, room_id={resp.room_id}, "
+ f"full response: {privacy_filter(str(resp))}. "
)
except Exception:
- logger.error("Message send failed. Sorry.")
- logger.debug("Here is the traceback.\n" + traceback.format_exc())
+ gs.log.error("E151: " "Message send failed. Sorry.")
+ gs.err_count += 1
+ gs.log.debug("Here is the traceback.\n" + traceback.format_exc())
+
+
+async def stream_messages_from_pipe(client, rooms):
+ """Read input from pipe if available.
+
+ Read pipe line by line. For each line received, immediately
+ send it.
+
+ Arguments:
+ ---------
+ client : Client
+ rooms : list of room_ids
+
+ """
+ stdin_ready = select.select(
+ [
+ sys.stdin,
+ ],
+ [],
+ [],
+ 0.0,
+ )[ # noqa
+ 0
+ ] # noqa
+ if not stdin_ready:
+ gs.log.debug(
+ "stdin is not ready for streaming. "
+ "A pipe could be used, but pipe could be empty, "
+ "stdin could also be a keyboard."
+ )
+ else:
+ gs.log.debug(
+ "stdin is ready. Something "
+ "is definitely piped into program from stdin. "
+ "Reading message from stdin pipe."
+ )
+ if ((not stdin_ready) and (not sys.stdin.isatty())) or stdin_ready:
+ if not sys.stdin.isatty():
+ gs.log.debug(
+ "Pipe was definitely used, but pipe might be empty. "
+ "Trying to read from pipe in any case."
+ )
+ try:
+ for line in sys.stdin:
+ await send_message(client, rooms, line)
+ gs.log.debug("Using data from stdin pipe stream as message.")
+ except EOFError: # EOF when reading a line
+ gs.log.debug(
+ "Reading from stdin resulted in EOF. This can happen "
+ "when a pipe was used, but the pipe is empty. "
+ "No message will be generated."
+ )
+ except UnicodeDecodeError:
+ gs.log.info(
+ "Reading from stdin resulted in UnicodeDecodeError. This "
+ "can happen if you try to pipe binary data for a text "
+ "message. For a text message only pipe text via stdin, "
+ "not binary data. No message will be generated."
+ )
def get_messages_from_pipe() -> list:
@@ -2263,24 +3674,31 @@ def get_messages_from_pipe() -> list:
Currently there is at most 1 msg in the returned list.
"""
messages = []
- stdin_ready = select.select([sys.stdin,], [], [], 0.0)[ # noqa
+ stdin_ready = select.select(
+ [
+ sys.stdin,
+ ],
+ [],
+ [],
+ 0.0,
+ )[ # noqa
0
] # noqa
if not stdin_ready:
- logger.debug(
- "stdin is not ready. "
+ gs.log.debug(
+ "stdin is not ready for reading. "
"A pipe could be used, but pipe could be empty, "
"stdin could also be a keyboard."
)
else:
- logger.debug(
+ gs.log.debug(
"stdin is ready. Something "
- "is definitely piped into program from stdin."
+ "is definitely piped into program from stdin. "
"Reading message from stdin pipe."
)
if ((not stdin_ready) and (not sys.stdin.isatty())) or stdin_ready:
if not sys.stdin.isatty():
- logger.debug(
+ gs.log.debug(
"Pipe was definitely used, but pipe might be empty. "
"Trying to read from pipe in any case."
)
@@ -2288,14 +3706,21 @@ def get_messages_from_pipe() -> list:
try:
for line in sys.stdin:
message += line
- logger.debug("Using data from stdin pipe as message.")
+ gs.log.debug("Using data from stdin pipe as message.")
messages.append(message)
except EOFError: # EOF when reading a line
- logger.debug(
+ gs.log.debug(
"Reading from stdin resulted in EOF. This can happen "
"when a pipe was used, but the pipe is empty. "
"No message will be generated."
)
+ except UnicodeDecodeError:
+ gs.log.info(
+ "Reading from stdin resulted in UnicodeDecodeError. This "
+ "can happen if you try to pipe binary data for a text "
+ "message. For a text message only pipe text via stdin, "
+ "not binary data. No message will be generated."
+ )
return messages
@@ -2304,10 +3729,13 @@ def get_messages_from_keyboard() -> list:
If there is a message provided via --message argument, no message
will be read from keyboard.
+ If there are other send operations like --image, --file, etc. are
+ used, no message will be read from keyboard.
If there is a message provided via stdin input pipe, no message
will be read from keyboard.
In short, we only read from keyboard as last resort, if no messages are
- specified or provided anywhere.
+ specified or provided anywhere and no other send-operations like
+ --image, --event, etc. are performed.
Return [] if no input available on keyboard.
Return ["some-msg"] if input is availble on keyboard.
@@ -2315,39 +3743,58 @@ def get_messages_from_keyboard() -> list:
Currently there is at most 1 msg in the returned list.
"""
messages = []
- if pargs.message:
- logger.debug(
+ if gs.pa.message:
+ gs.log.debug(
"Don't read from keyboard because there are "
"messages provided in arguments with -m."
)
return messages # return empty list because mesgs in -m
- stdin_ready = select.select([sys.stdin,], [], [], 0.0)[ # noqa
+ if (
+ gs.pa.image
+ or gs.pa.audio
+ or gs.pa.file
+ or gs.pa.event
+ or gs.pa.version
+ ):
+ gs.log.debug(
+ "Don't read from keyboard because there are "
+ "other send operations or --version provided in arguments."
+ )
+ return messages # return empty list because mesgs in -m
+ stdin_ready = select.select(
+ [
+ sys.stdin,
+ ],
+ [],
+ [],
+ 0.0,
+ )[ # noqa
0
] # noqa
if not stdin_ready:
- logger.debug(
- "stdin is not ready. "
+ gs.log.debug(
+ "stdin is not ready for keyboard interaction. "
"A pipe could be used, but pipe could be empty, "
"stdin could also be a keyboard."
)
else:
- logger.debug(
+ gs.log.debug(
"stdin is ready. Something "
- "is definitely piped into program from stdin."
+ "is definitely piped into program from stdin. "
"Reading message from stdin pipe."
)
if (not stdin_ready) and (sys.stdin.isatty()):
# because sys.stdin.isatty() is true
- logger.debug(
+ gs.log.debug(
"No pipe was used, so read input from keyboard. "
"Reading message from keyboard"
)
try:
message = input("Enter message to send: ")
- logger.debug("Using data from stdin keyboard as message.")
+ gs.log.debug("Using data from stdin keyboard as message.")
messages.append(message)
except EOFError: # EOF when reading a line
- logger.debug(
+ gs.log.debug(
"Reading from stdin resulted in EOF. "
"Reading from keyboard failed. "
"No message will be generated."
@@ -2367,19 +3814,23 @@ async def send_messages_and_files(client, rooms, messages):
messages : list of messages to send
"""
- if pargs.image:
- for image in pargs.image:
+ if gs.pa.image:
+ for image in gs.pa.image:
await send_image(client, rooms, image)
- if pargs.audio:
- for audio in pargs.audio:
+ if gs.pa.audio:
+ for audio in gs.pa.audio:
# audio file can be sent like other files
await send_file(client, rooms, audio)
- if pargs.file:
- for file in pargs.file:
+ if gs.pa.file:
+ for file in gs.pa.file:
await send_file(client, rooms, file)
+ if gs.pa.event:
+ for event in gs.pa.event:
+ await send_event(client, rooms, event)
+
for message in messages:
await send_message(client, rooms, message)
@@ -2396,143 +3847,66 @@ async def process_arguments_and_input(client, rooms):
rooms : list of room_ids
"""
- messages_from_pipe = get_messages_from_pipe()
+ streaming = False
+ messages_from_pipe = []
+ if gs.stdin_use == "none": # STDIN is unused
+ messages_from_pipe = get_messages_from_pipe()
messages_from_keyboard = get_messages_from_keyboard()
- if not pargs.message:
+ if not gs.pa.message:
messages_from_commandline = []
else:
- messages_from_commandline = pargs.message
+ messages_from_commandline = []
+ for m in gs.pa.message:
+ if m == "\\-": # escaped -
+ messages_from_commandline += ["-"]
+ elif m == "\\_": # escaped _
+ messages_from_commandline += ["_"]
+ elif m == "-":
+ # stdin pipe, read and process everything in pipe as 1 msg
+ messages_from_commandline += get_messages_from_pipe()
+ elif m == "_":
+ # streaming via pipe on stdin
+ # stdin pipe, read and process everything in pipe line by line
+ streaming = True
+ else:
+ messages_from_commandline += [m]
- logger.debug(f"Messages from pipe: {messages_from_pipe}")
- logger.debug(f"Messages from keyboard: {messages_from_keyboard}")
- logger.debug(f"Messages from command-line: {messages_from_commandline}")
+ gs.log.debug(f"Messages from pipe: {messages_from_pipe}")
+ gs.log.debug(f"Messages from keyboard: {messages_from_keyboard}")
+ gs.log.debug(f"Messages from command-line: {messages_from_commandline}")
messages_all = (
messages_from_commandline + messages_from_pipe + messages_from_keyboard
) # keyboard at end
# loop thru all msgs and split them
- if pargs.split:
- # pargs.split can have escape characters, it has to be de-escaped
- decoded_string = bytes(pargs.split, "utf-8").decode("unicode_escape")
- logger.debug(f'String used for splitting is: "{decoded_string}"')
+ if gs.pa.split:
+ # gs.pa.split can have escape characters, it has to be de-escaped
+ decoded_string = bytes(gs.pa.split, "utf-8").decode("unicode_escape")
+ gs.log.debug(f'String used for splitting is: "{decoded_string}"')
messages_all_split = []
for m in messages_all:
messages_all_split += m.split(decoded_string)
- else: # not pargs.split
+ else: # not gs.pa.split
messages_all_split = messages_all
await send_messages_and_files(client, rooms, messages_all_split)
+ # now we are done with all the usual sends, now we start streaming
+ if streaming:
+ await stream_messages_from_pipe(client, rooms)
-async def create_credentials_file(
- credentials_file: str, store_dir: str
-) -> None:
- """Log in, create credentials file, log out and exit.
-
- Arguments:
- ---------
- credentials_file: str : location of credentials file
- store_dir: str : location of persistent storage store directory
-
- """
- text = f"""
- Credentials file \"{pargs.credentials}\" was not found.
- First time use? Setting up new credentials?
- Asking for homeserver, user, password and
- room id to create a credentials file."""
- print(textwrap.fill(textwrap.dedent(text).strip(), width=79))
- homeserver = "https://matrix.example.org"
- homeserver = input(f"Enter URL of your homeserver: [{homeserver}] ")
- if not (
- homeserver.startswith("https://") or homeserver.startswith("http://")
- ):
- homeserver = "https://" + homeserver
- user_id = "@user:example.org"
- user_id = input(f"Enter your full user ID: [{user_id}] ")
- device_name = PROG_WITHOUT_EXT
- device_name = input(f"Choose a name for this device: [{device_name}] ")
- if device_name == "":
- device_name = PROG_WITHOUT_EXT # default
- room_id = "!SomeRoomIdString:example.org"
- room_id = input(f"Enter your room ID: [{room_id}] ")
-
- # Configuration options for the AsyncClient
- client_config = AsyncClientConfig(
- max_limit_exceeded=0,
- max_timeouts=0,
- store_sync_tokens=True,
- encryption_enabled=True,
- )
-
- if not os.path.exists(store_dir):
- os.makedirs(store_dir)
- logger.info(
- f"The persistent storage directory {store_dir} "
- "was created for you."
- )
-
- if pargs.proxy:
- logger.info(f"Proxy {pargs.proxy} will be used.")
-
- try:
- # Initialize the matrix client
- client = AsyncClient(
- homeserver,
- user_id,
- store_path=store_dir,
- config=client_config,
- proxy=pargs.proxy,
- )
-
- pw = getpass.getpass()
- resp = await client.login(pw, device_name=device_name)
- # check that we logged in succesfully
- if isinstance(resp, LoginResponse):
- # when writing, always write to primary location (e.g. .)
- write_credentials_to_disk(
- homeserver,
- resp.user_id,
- resp.device_id,
- resp.access_token,
- room_id,
- pargs.credentials,
- )
- text = f"""
- Log in using a password was successful.
- Credentials were stored in file \"{pargs.credentials}\".
- Run program \"{PROG_WITH_EXT}\" again to
- login with credentials and to send a message.
- If you plan on having many credential files, consider
- moving them to directory \"{CREDENTIALS_DIR_LASTRESORT}\"."""
- print(textwrap.fill(textwrap.dedent(text).strip(), width=79))
- else:
- logger.info(
- f"The program {PROG_WITH_EXT} failed. "
- "Most likely wrong credentials were entered."
- "Sorry."
- )
- logger.info(
- f'homeserver="{homeserver}"; user="{user_id}"; '
- f'room_id="{room_id}"'
- f"Failed to log in: {resp}"
- )
- finally:
- if client:
- await client.close()
- cleanup()
- sys.exit(1)
-
-
-def login_using_credentials_file(
- credentials_file: str, store_dir: str
+async def login_using_credentials_file(
+ credentials_file: Optional[str] = None, store_dir: Optional[str] = None
) -> (AsyncClient, dict):
"""Log in by using available credentials file.
Arguments:
---------
credentials_file: str : location of credentials file
+ compute it if not provided
store_dir: str : location of persistent storage store directory
+ compute it if not provided
Returns
-------
@@ -2540,8 +3914,29 @@ def login_using_credentials_file(
dict : the credentials dictionary from the credentials file
"""
- credentials = read_credentials_from_disk(credentials_file)
+ if not credentials_file:
+ credentials_file = determine_credentials_file()
+ if not store_dir:
+ store_dir = determine_store_dir()
+
+ if not credentials_exist(credentials_file):
+ raise MatrixCommanderError(
+ "E153: "
+ "Credentials file was not found. Provide credentials file or "
+ "use --login to create a credentials file."
+ ) from None
+ if not store_exists(store_dir):
+ raise MatrixCommanderError(
+ "E154: "
+ "Store directory was not found. Provide store directory or "
+ "use --login to create a store directory."
+ ) from None
+
+ credentials = read_credentials_from_disk(credentials_file)
+ gs.credentials = credentials
+
+ gs.log.debug("About to configure Matrix Async Client.")
# Configuration options for the AsyncClient
client_config = AsyncClientConfig(
max_limit_exceeded=0,
@@ -2549,6 +3944,7 @@ def login_using_credentials_file(
store_sync_tokens=True,
encryption_enabled=True,
)
+ gs.log.debug("About to initialize Matrix Async Client.")
# Initialize the matrix client based on credentials from file
client = AsyncClient(
credentials["homeserver"],
@@ -2556,21 +3952,73 @@ def login_using_credentials_file(
device_id=credentials["device_id"],
store_path=store_dir,
config=client_config,
- proxy=pargs.proxy,
+ ssl=gs.ssl,
+ proxy=gs.pa.proxy,
)
+ if gs.pa.proxy:
+ gs.log.debug(f"Proxy {gs.pa.proxy} will be used for connectivity.")
+
+ gs.log.debug("About to restore login.")
+ # restore_login() always returns None, on success or failure
+ # restore_login() does not go to the server, it just sets some local values
+ # TODO: performance
+ # restore_login() is a slow operation. 1.5s to 2s. Why?
+ # Because it is reading the store file database.
+ # Setting store_sync_tokens=False above will not make it go any faster.
client.restore_login(
user_id=credentials["user_id"],
device_id=credentials["device_id"],
access_token=credentials["access_token"],
)
- # room_id = credentials['room_id']
- logger.debug(
- "Logged in using stored credentials from "
- f'credentials file "{credentials_file}".'
+ gs.log.debug("Finished restoring login.")
+ gs.log.debug(
+ "Login will be using stored credentials from "
+ f'credentials file "{credentials_file}". '
+ f'room_id = {credentials["room_id"]}, '
+ f'device_id = {credentials["device_id"]}, '
+ f'access_token = {credentials["access_token"][0:1]}***'
+ f'{credentials["access_token"][-1:]}.'
)
- if pargs.proxy:
- logger.debug(f"Proxy {pargs.proxy} will be used for connectivity.")
- logger.debug(f"Logged_in() = {client.logged_in}")
+ if gs.pa.debug > 0:
+ gs.log.debug("About to connect to server to verify connection.")
+ # gs.log.debug(f"Logged_in()={client.logged_in}") is always True.
+ # Just because client.logged_in is True does not mean we are logged in.
+ # That just means the data structure is filled.
+ # How to know if login was successful?
+ # Do an actual API call against the server. E.g. whoami.
+ # We don't want to do this always for performance reasons, so we only
+ # do it in debug mode.
+ try:
+ resp = await client.whoami()
+ except Exception as e:
+ await client.close()
+ client = None
+ credentials = None
+ raise (e)
+ if isinstance(resp, responses.WhoamiError):
+ gs.log.error(
+ "E155: "
+ "restore_login failed. Did you perform --logout "
+ "before? Looks like your access-token expired. Maybe "
+ "delete credentials file and store and perform a "
+ f"new --login. Response is: {privacy_filter(str(resp))}"
+ )
+ gs.err_count += 1
+ await client.close()
+ client = None
+ credentials = None
+ else:
+ gs.log.debug(
+ "restore_login successful. Successfully "
+ f"logged in as user {resp.user_id} via restore_login. "
+ f"Response is: {privacy_filter(str(resp))}"
+ )
+ else:
+ pass
+ # login might or might not fail later,
+ # if it fails some exception will be raised, the exception text
+ # might not explain the problem well, but this way we speed up
+ # performance by issuing one API less against the server.
return (client, credentials)
@@ -2586,17 +4034,55 @@ async def listen_forever(client: AsyncClient) -> None:
RedactionEvent,
),
)
+ if gs.pa.room_invites:
+ gs.log.debug(
+ "Registering to listen to events of type "
+ "InviteMemberEvent. Listening to room invites."
+ )
+ client.add_event_callback(
+ callbacks.invite_callback, (InviteMemberEvent,)
+ )
print(
- "This program is ready and listening for its Matrix messages."
- " To stop program type Control-C on keyboard or send signal"
- f" to process {os.getpid()}. PID can also be found in "
+ "This program is ready and listening for its Matrix messages. "
+ "To stop program type Control-C on keyboard or send signal "
+ f"to process {os.getpid()}. PID can also be found in "
f'file "{PID_FILE_DEFAULT}".',
+ file=sys.stderr,
flush=True,
)
# the sync_loop will be terminated by user hitting Control-C to stop
await client.sync_forever(timeout=30000, full_state=True)
+async def listen_invites_once(client: AsyncClient) -> None:
+ """Listen once exclusively for room invites, then quit.
+
+ Get all the room invitations that are currently queued up and waiting.
+ List them or join these rooms. Then leave.
+ """
+ # Set up event callbacks
+ callbacks = Callbacks(client)
+ gs.log.debug(
+ "Registering to listen to events of type "
+ "InviteMemberEvent. Listening to room invites."
+ )
+ client.add_event_callback(callbacks.invite_callback, (InviteMemberEvent,))
+ # We want to get out quickly, so we reduced timeout to 10 sec.
+ # We want to get messages and quit, so we call sync() instead of
+ # sync_forever().
+ resp = await client.sync(timeout=10000, full_state=False)
+ if isinstance(resp, SyncResponse):
+ gs.log.debug(
+ f"Sync successful. Response is: {privacy_filter(str(resp))}"
+ )
+ else:
+ gs.log.error(
+ "E160: " f"Sync failed. Error is: {privacy_filter(str(resp))}"
+ )
+ # sync() forces the message_callback() to fire
+ # for each new message presented in the sync().
+
+
async def listen_once(client: AsyncClient) -> None:
"""Listen once, then quit.
@@ -2606,14 +4092,26 @@ async def listen_once(client: AsyncClient) -> None:
# Set up event callbacks
callbacks = Callbacks(client)
client.add_event_callback(callbacks.message_callback, (RoomMessage,))
+ if gs.pa.room_invites:
+ gs.log.debug(
+ "Registering to listen to events of type "
+ "InviteMemberEvent. Listening to room invites."
+ )
+ client.add_event_callback(
+ callbacks.invite_callback, (InviteMemberEvent,)
+ )
# We want to get out quickly, so we reduced timeout to 10 sec.
# We want to get messages and quit, so we call sync() instead of
# sync_forever().
resp = await client.sync(timeout=10000, full_state=False)
if isinstance(resp, SyncResponse):
- logger.debug(f"Sync successful. Response is: {resp}")
+ gs.log.debug(
+ f"Sync successful. Response is: {privacy_filter(str(resp))}"
+ )
else:
- logger.info(f"Sync failed. Error is: {resp}")
+ gs.log.error(
+ "E160: " f"Sync failed. Error is: {privacy_filter(str(resp))}"
+ )
# sync() forces the message_callback() to fire
# for each new message presented in the sync().
@@ -2699,9 +4197,9 @@ async def listen_once_alternative(client: AsyncClient) -> None:
"""
resp_s = await client.sync(timeout=10000, full_state=False)
# this prints a summary of all new messages currently waiting in the queue
- logger.debug(f"sync response = {type(resp_s)} :: {resp_s}")
- logger.debug(f"sync next_batch = (str) {resp_s.next_batch}")
- logger.debug(f"sync rooms = (nio.responses.Rooms) {resp_s.rooms}")
+ gs.log.debug(f"sync response = {type(resp_s)} :: {resp_s}")
+ gs.log.debug(f"sync next_batch = (str) {resp_s.next_batch}")
+ gs.log.debug(f"sync rooms = (nio.responses.Rooms) {resp_s.rooms}")
# Set up event callbacks
callbacks = Callbacks(client)
# Note: we are NOT registering a callback funtion!
@@ -2709,7 +4207,7 @@ async def listen_once_alternative(client: AsyncClient) -> None:
for room_id, room_info in resp_s.rooms.join.items():
event_list = room_info.timeline.events
for event in event_list:
- logger.debug(f"sending event to callback = {event}.")
+ gs.log.debug(f"sending event to callback = {event}.")
# because of full_state=False in sync() the
# rooms object is not fully populated and missing the
# room names.
@@ -2723,8 +4221,9 @@ async def listen_once_alternative(client: AsyncClient) -> None:
read_event=last_event.event_id,
)
if isinstance(resp, RoomReadMarkersError):
- logger.debug(
- f"room_read_markers failed with response = {resp}."
+ gs.log.debug(
+ "room_read_markers failed with response "
+ f"{privacy_filter(str(resp))}."
)
@@ -2751,27 +4250,15 @@ async def listen_tail( # noqa: C901
"""
# we call sync() to get the next_batch marker
# we set full_state=True to get all room_ids
- try:
- resp_s = await client.sync(timeout=10000, full_state=True)
- except ClientConnectorError:
- logger.info("sync() failed. Do you have connectivity to internet?")
- logger.debug(traceback.format_exc())
- return
- except Exception:
- logger.info("sync() failed.")
- logger.debug(traceback.format_exc())
- return
- if isinstance(resp_s, SyncError):
- logger.debug(f"sync failed with resp = {resp_s}")
- return
+ resp_s = await synchronize(client) # sync() to get rooms
# this prints a summary of all new messages currently waiting in the queue
- logger.debug(f"sync response = {type(resp_s)} :: {resp_s}")
- logger.debug(f"client.next_batch after = (str) {client.next_batch}")
- logger.debug(f"sync next_batch = (str) {resp_s.next_batch}")
- logger.debug(f"sync rooms = (nio.responses.Rooms) {resp_s.rooms}")
- logger.debug(f"client.rooms = {client.rooms}")
+ gs.log.debug(f"sync response = {type(resp_s)} :: {resp_s}")
+ gs.log.debug(f"client.next_batch after = (str) {client.next_batch}")
+ gs.log.debug(f"sync next_batch = (str) {resp_s.next_batch}")
+ gs.log.debug(f"sync rooms = (nio.responses.Rooms) {resp_s.rooms}")
+ gs.log.debug(f"client.rooms = {client.rooms}")
if not resp_s.rooms.join: # no Rooms!
- logger.debug(f"sync returned no rooms = {resp_s.rooms.join}")
+ gs.log.debug(f"sync returned no rooms = {resp_s.rooms.join}")
return
# Set up event callbacks
@@ -2783,10 +4270,9 @@ async def listen_tail( # noqa: C901
# room_id = list(client.rooms.keys())[0] # first room_id from dict
# get rooms as specified by the user thru args or credential file
- rooms = determine_rooms(credentials["room_id"])
- logger.debug(f"Rooms are: {rooms}")
-
- limit = pargs.tail
+ rooms = await determine_rooms(credentials["room_id"], client, credentials)
+ limit = gs.pa.tail
+ gs.log.debug(f"Rooms are: {rooms}, limit is {limit}")
# To loop over all rooms, one can loop through the join dictionary. i.e.
# for room_id, room_info in resp_s.rooms.join.items(): # loop all rooms
for room_id in rooms: # loop only over user specified rooms
@@ -2794,18 +4280,27 @@ async def listen_tail( # noqa: C901
room_id, start=resp_s.next_batch, limit=limit
)
if isinstance(resp, RoomMessagesError):
- logger.debug("room_messages failed with resp = {resp}")
+ gs.log.warning(
+ "W106: "
+ f"room_messages failed with response "
+ f"{privacy_filter(str(resp))}. "
+ "Processing continues."
+ )
+ gs.warn_count += 1
continue # skip this room
- logger.debug(f"room_messages response = {type(resp)} :: {resp}.")
- logger.debug(f"room_messages room_id = {resp.room_id}.")
- logger.debug(f"room_messages start = (str) {resp.start}.")
- logger.debug(f"room_messages end = (str) :: {resp.end}.")
- logger.debug(f"room_messages chunk = (list) :: {resp.chunk}.")
+ gs.log.debug(
+ f"room_messages response = {type(resp)} :: "
+ f"{privacy_filter(str(resp))}."
+ )
+ gs.log.debug(f"room_messages room_id = {resp.room_id}.")
+ gs.log.debug(f"room_messages start = (str) {resp.start}.")
+ gs.log.debug(f"room_messages end = (str) :: {resp.end}.")
+ gs.log.debug(f"room_messages chunk = (list) :: {resp.chunk}.")
# chunk is just a list of RoomMessage events like this example:
# chunk=[RoomMessageText(...)]
for event in resp.chunk:
- logger.debug(f"sending event to callback = {event}.")
+ gs.log.debug(f"sending event to callback = {event}.")
if client.rooms and client.rooms[room_id]:
room = client.rooms[room_id]
else:
@@ -2813,15 +4308,16 @@ async def listen_tail( # noqa: C901
await callbacks.message_callback(room, event)
if resp.chunk: # list not empty
# order is reversed, first element is timewise the newest
- first_event = resp.chunk[1]
+ first_event = resp.chunk[0]
resp = await client.room_read_markers(
room_id=room_id,
fully_read_event=first_event.event_id,
read_event=first_event.event_id,
)
if isinstance(resp, RoomReadMarkersError):
- logger.debug(
- f"room_read_markers failed with response = {resp}."
+ gs.log.debug(
+ "room_read_markers failed with response "
+ f"{privacy_filter(str(resp))}."
)
@@ -2855,23 +4351,58 @@ async def read_all_events_in_direction(
"""
all_events = []
current_start_token = start_token
+ # is capped at 1000 at server side
+ # 10 seems too small, i.e. too slow
+ # 100 to 500 seem good values, depends on network speed, server load, ...
+ # example run: 250-->7min30s, 500-->4min30s
+ max_msg_per_pull = 500
while True:
- resp = await client.room_messages(
- room_id, current_start_token, limit=500, direction=direction
- )
+ try:
+ resp = await client.room_messages(
+ room_id,
+ current_start_token,
+ limit=max_msg_per_pull,
+ direction=direction,
+ )
+ except Exception as e:
+ # during testing I observed that sometimes an exception is raised,
+ # but e is empty. Stacktrace had asyncio.exceptions.TimeoutError.
+ gs.log.error(
+ "E161: "
+ "Error during getting messages. "
+ "But program will continue anyway, despite the error. "
+ "Not all messages might have been retrieved from server. "
+ f"Be warned! Got {len(all_events)} messages so far."
+ f"Exception: {type(e)} {e}"
+ )
+ gs.err_count += 1
+ gs.log.debug("Here is the traceback.\n" + traceback.format_exc())
+ break
if isinstance(resp, RoomMessagesError):
- logger.debug("room_messages failed with resp = {resp}")
+ gs.err_count += 1
+ gs.log.error(
+ "E162: "
+ f"room_messages failed with resp = {privacy_filter(str(resp))}"
+ )
break # skip to end of function
- logger.debug(f"Received {len(resp.chunk)} events.")
- logger.debug(f"room_messages response = {type(resp)} :: {resp}.")
- logger.debug(f"room_messages room_id = {resp.room_id}.")
- logger.debug(f"room_messages start = (str) {resp.start}.")
- logger.debug(f"room_messages end = (str) :: {resp.end}.")
- logger.debug(f"room_messages chunk = (list) :: {resp.chunk}.")
+ gs.log.debug(f"Got {len(all_events)+len(resp.chunk)} messages so far.")
+ gs.log.debug(f"Received {len(resp.chunk)} events.")
+ gs.log.debug(
+ f"room_messages response = {type(resp)} :: "
+ f"{privacy_filter(str(resp))}."
+ )
+ gs.log.debug(f"room_messages room_id = {resp.room_id}.")
+ gs.log.debug(f"room_messages start = (str) {resp.start}.")
+ gs.log.debug(f"room_messages end = (str) :: {resp.end}.")
+ gs.log.debug(f"room_messages chunk = (list) :: {resp.chunk}.")
# resp.chunk is just a list of RoomMessage events like this example:
# chunk=[RoomMessageText(...)]
current_start_token = resp.end
if len(resp.chunk) == 0:
+ gs.log.debug(
+ "All messages have been retrieved from server successfully. "
+ f"{len(all_events)} messages were pulled from server."
+ )
break
all_events = all_events + resp.chunk
return all_events
@@ -2897,27 +4428,15 @@ async def listen_all( # noqa: C901
"""
# we call sync() to get the next_batch marker
# we set full_state=True to get all room_ids
- try:
- resp_s = await client.sync(timeout=10000, full_state=True)
- except ClientConnectorError:
- logger.info("sync() failed. Do you have connectivity to internet?")
- logger.debug(traceback.format_exc())
- return
- except Exception:
- logger.info("sync() failed.")
- logger.debug(traceback.format_exc())
- return
- if isinstance(resp_s, SyncError):
- logger.debug(f"sync failed with resp = {resp_s}")
- return
+ resp_s = await synchronize(client) # sync() to get rooms
# this prints a summary of all new messages currently waiting in the queue
- logger.debug(f"sync response = {type(resp_s)} :: {resp_s}")
- logger.debug(f"client.next_batch after = (str) {client.next_batch}")
- logger.debug(f"sync next_batch = (str) {resp_s.next_batch}")
- logger.debug(f"sync rooms = (nio.responses.Rooms) {resp_s.rooms}")
- logger.debug(f"client.rooms = {client.rooms}")
+ gs.log.debug(f"sync response = {type(resp_s)} :: {resp_s}")
+ gs.log.debug(f"client.next_batch after = (str) {client.next_batch}")
+ gs.log.debug(f"sync next_batch = (str) {resp_s.next_batch}")
+ gs.log.debug(f"sync rooms = (nio.responses.Rooms) {resp_s.rooms}")
+ gs.log.debug(f"client.rooms = {client.rooms}")
if not resp_s.rooms.join: # no Rooms!
- logger.debug(f"sync returned no rooms = {resp_s.rooms.join}")
+ gs.log.debug(f"sync returned no rooms = {resp_s.rooms.join}")
return
# Set up event callbacks
@@ -2929,8 +4448,8 @@ async def listen_all( # noqa: C901
# room_id = list(client.rooms.keys())[0] # first room_id from dict
# get rooms as specified by the user thru args or credential file
- rooms = determine_rooms(credentials["room_id"])
- logger.debug(f"Rooms are: {rooms}")
+ rooms = await determine_rooms(credentials["room_id"], client, credentials)
+ gs.log.debug(f"Rooms are: {rooms}")
# To loop over all rooms, one can loop through the join dictionary. i.e.
# for room_id, room_info in resp_s.rooms.join.items(): # loop all rooms
@@ -2948,7 +4467,7 @@ async def listen_all( # noqa: C901
all_events = back_events[::-1] + front_events
for event in all_events:
- logger.debug(f"sending event to callback = {event}.")
+ gs.log.debug(f"sending event to callback = {event}.")
if client.rooms and client.rooms[room_id]:
room = client.rooms[room_id]
else:
@@ -2962,472 +4481,3472 @@ async def listen_all( # noqa: C901
read_event=last_event.event_id,
)
if isinstance(resp, RoomReadMarkersError):
- logger.debug(
- f"room_read_markers failed with response = {resp}."
+ gs.log.error(
+ "E163: "
+ "room_read_markers failed with response "
+ f"{privacy_filter(str(resp))}."
)
-async def main_listen() -> None:
- """Use credentials to log in and listen."""
- credentials_file = determine_credentials_file()
- store_dir = determine_store_dir()
- if not os.path.isfile(credentials_file):
- logger.debug(
- "Credentials file must be created first before one can verify."
+async def action_listen() -> None:
+ """Listen while being logged in."""
+ if not gs.client and not gs.credentials:
+ gs.log.error(
+ "E164: " "Client or credentials not set. Skipping action."
)
- cleanup()
- sys.exit(1)
- logger.debug("Credentials file does exist.")
+ gs.err_count += 1
+ return
try:
- client, credentials = login_using_credentials_file(
- credentials_file, store_dir
- )
# Sync encryption keys with the server
# Required for participating in encrypted rooms
- if client.should_upload_keys:
- await client.keys_upload()
- logger.debug(f"Listening type: {pargs.listen}")
- if pargs.listen == FOREVER:
- await listen_forever(client)
- elif pargs.listen == ONCE:
- await listen_once(client)
- # could use 'await listen_once_alternative(client)'
+ if gs.client.should_upload_keys:
+ await gs.client.keys_upload()
+ gs.log.debug(f"Listening type: {gs.pa.listen}")
+ if gs.pa.listen == FOREVER:
+ await listen_forever(gs.client)
+ elif gs.pa.listen == ONCE:
+ await listen_once(gs.client)
+ # could use 'await listen_once_alternative(gs.client)'
# as an alternative implementation
- elif pargs.listen == TAIL:
- await listen_tail(client, credentials)
- elif pargs.listen == ALL:
- await listen_all(client, credentials)
+ elif gs.pa.listen == TAIL:
+ await listen_tail(gs.client, gs.credentials)
+ elif gs.pa.listen == ALL:
+ await listen_all(gs.client, gs.credentials)
else:
- logger.error(
- f'Unrecognized listening type "{pargs.listen}". '
- "Closing client."
+ gs.log.error(
+ "E165: "
+ f'Unrecognized listening type "{gs.pa.listen}". '
+ "Skipping listening."
)
- finally:
- if client:
- await client.close()
+ gs.err_count += 1
+ except Exception as e:
+ gs.log.error(
+ "E166: "
+ "Error during listening. Continuing despite error. "
+ f"Exception: {e}"
+ )
+ gs.log.debug("Here is the traceback.\n" + traceback.format_exc())
+ gs.err_count += 1
-async def main_rename_device() -> None:
- """Use credentials to log in and rename the device name of itself."""
- credentials_file = determine_credentials_file()
- store_dir = determine_store_dir()
- if not os.path.isfile(credentials_file):
- logger.debug(
- "Credentials file must be created first before one can verify."
+async def action_set_device_name(
+ client: AsyncClient, credentials: dict
+) -> None:
+ """Set, rename the device name of itself while already being logged in."""
+ content = {"device_name": gs.pa.set_device_name}
+ resp = await client.update_device(credentials["device_id"], content)
+ if isinstance(resp, UpdateDeviceError):
+ gs.log.error(
+ "E167: " f"update_device failed with {privacy_filter(str(resp))}"
)
- cleanup()
- sys.exit(1)
- logger.debug("Credentials file does exist.")
- try:
- client, credentials = login_using_credentials_file(
- credentials_file, store_dir
+ gs.err_count += 1
+ else:
+ gs.log.debug(
+ f"update_device successful with {privacy_filter(str(resp))}"
)
- content = {"display_name": pargs.rename_device}
- resp = await client.update_device(credentials["device_id"], content)
- if isinstance(resp, UpdateDeviceError):
- logger.error(f"update_device failed with {resp}")
+
+
+async def action_set_display_name(
+ client: AsyncClient, credentials: dict
+) -> None:
+ """Set, rename the logged in user's display name. Change my own
+ display name.
+ Rename the user by changing display name.
+ Assumes that user is already logged in.
+ """
+ resp = await client.set_displayname(gs.pa.set_display_name)
+ if isinstance(resp, ProfileSetDisplayNameError):
+ gs.log.error(
+ "E168: " f"set_displayname failed with {privacy_filter(str(resp))}"
+ )
+ gs.err_count += 1
+ else:
+ gs.log.debug(
+ f"set_displayname successful with {privacy_filter(str(resp))}"
+ )
+
+
+async def action_get_display_name(
+ client: AsyncClient, credentials: dict
+) -> None:
+ """Get display name(s) while already logged in."""
+ if not gs.pa.user:
+ # get display name of myself
+ whoami = credentials["user_id"]
+ users = [whoami]
+ else:
+ users = gs.pa.user
+ users = list(dict.fromkeys(users)) # remove duplicates in list
+ for user in users:
+ resp = await client.get_displayname(user)
+ if isinstance(resp, ProfileGetDisplayNameError):
+ gs.log.error(
+ "E169: "
+ f"get_displayname failed with {privacy_filter(str(resp))}"
+ )
+ gs.err_count += 1
else:
- logger.debug(f"update_device successful with {resp}")
- finally:
- if client:
- await client.close()
-
-
-# according to pylama: function too complex: C901 # noqa: C901
-
-
-async def main_room_actions() -> None: # noqa: C901
- """Perform various room actions such as create, join, etc."""
- credentials_file = determine_credentials_file()
- store_dir = determine_store_dir()
- if not os.path.isfile(credentials_file):
- logger.debug(
- "Credentials file must be created first before one can verify."
- )
- cleanup()
- sys.exit(1)
- logger.debug("Credentials file does exist.")
- try:
- client, credentials = login_using_credentials_file(
- credentials_file, store_dir
- )
- if pargs.room_create:
- await create_rooms(
- client, pargs.room_create, pargs.name, pargs.topic
+ gs.log.debug(
+ f"get_displayname successful with {privacy_filter(str(resp))}"
)
- if pargs.room_join:
- await join_rooms(client, pargs.room_join)
- if pargs.room_leave:
- await leave_rooms(client, pargs.room_leave)
- if pargs.room_forget:
- await forget_rooms(client, pargs.room_forget)
- if pargs.room_invite and pargs.user:
- await invite_to_rooms(client, pargs.room_invite, pargs.user)
- if pargs.room_ban and pargs.user:
- await ban_from_rooms(client, pargs.room_ban, pargs.user)
- if pargs.room_unban and pargs.user:
- await unban_from_rooms(client, pargs.room_unban, pargs.user)
- if pargs.room_kick and pargs.user:
- await kick_from_rooms(client, pargs.room_kick, pargs.user)
- if (
- pargs.room_invite
- or pargs.room_ban
- or pargs.room_unban
- or pargs.room_kick
- ) and not pargs.user:
- logger.warning(
- "No room action(s) were performed because no users "
- "were specified. Use --user option to specify users."
+ # resp.displayname is str or None (has no display name)
+ if not resp.displayname:
+ displayname = "" # means no display name is set
+ else:
+ displayname = resp.displayname
+ # output format controlled via --output flag
+ text = f"{user}{SEP}{displayname}"
+ # Object of type RoomCreateResponse is not JSON
+ # serializable, hence we use the dictionary.
+ json_max = resp.__dict__
+ json_max.update({"user": user}) # add dict items
+ json_ = json_max.copy()
+ json_.pop("transport_response")
+ json_spec = None
+ print_output(
+ gs.pa.output,
+ text=text,
+ json_=json_,
+ json_max=json_max,
+ json_spec=json_spec,
)
- logger.debug(
- "Room action(s) were performed or attempted. "
- "We close the client and quit"
- )
- finally:
- if client:
- await client.close()
-async def main_verify() -> None:
- """Use credentials to log in and verify."""
- credentials_file = determine_credentials_file()
- store_dir = determine_store_dir()
- if not os.path.isfile(credentials_file):
- logger.debug(
- "Credentials file must be created first before one can verify."
+async def action_set_presence(client: AsyncClient, credentials: dict) -> None:
+ """Set the logged in user's presence. Change my own presence.
+ Assumes that user is already logged in.
+ """
+ state = gs.pa.set_presence.strip().lower()
+ gs.log.debug(f"Setting presence to {state} [{gs.pa.set_presence}].")
+ resp = await client.set_presence(state)
+ if isinstance(resp, PresenceSetError):
+ gs.log.error(
+ "E170: " f"set_presence failed with {privacy_filter(str(resp))}"
)
- cleanup()
- sys.exit(1)
- logger.debug("Credentials file does exist.")
+ gs.err_count += 1
+ else:
+ gs.log.debug(
+ f"set_presence successful with {privacy_filter(str(resp))}"
+ )
+
+
+async def action_get_presence(client: AsyncClient, credentials: dict) -> None:
+ """Get presence(s) while already logged in."""
+ if not gs.pa.user:
+ # get presence name of myself
+ whoami = credentials["user_id"]
+ users = [whoami]
+ else:
+ users = gs.pa.user
+ users = list(dict.fromkeys(users)) # remove duplicates in list
+ for user in users:
+ resp = await client.get_presence(user)
+ if isinstance(resp, PresenceGetError):
+ gs.log.error(
+ "E171: "
+ f"get_presence failed with {privacy_filter(str(resp))}"
+ )
+ gs.err_count += 1
+ else:
+ gs.log.debug(
+ f"get_presence successful with {privacy_filter(str(resp))}"
+ )
+ if not resp.last_active_ago:
+ last_active_ago = 0 # means currently_active is not set
+ else:
+ last_active_ago = resp.last_active_ago
+ if not resp.currently_active:
+ currently_active = False # means currently_active is not set
+ else:
+ currently_active = resp.currently_active
+ if not resp.status_msg:
+ status_msg = "" # means no status_msg is set
+ else:
+ status_msg = resp.status_msg
+ # output format controlled via --output flag
+ text = (
+ f"{resp.user_id}{SEP}{resp.presence}{SEP}{last_active_ago}"
+ f"{SEP}{currently_active}{SEP}{status_msg}"
+ )
+ # Object of type xxxResponse is not JSON
+ # serializable, hence we use the dictionary.
+ json_max = resp.__dict__
+ # json_max.update({"key": value}) # add dict items
+ json_ = json_max.copy()
+ json_.pop("transport_response")
+ json_spec = None
+ print_output(
+ gs.pa.output,
+ text=text,
+ json_=json_,
+ json_max=json_max,
+ json_spec=json_spec,
+ )
+
+
+async def action_upload(client: AsyncClient, credentials: dict) -> None:
+ """Upload one or more files to content repository of Matrix server.
+ Assumes that user is already logged in.
+ """
+ for filename in gs.pa.upload:
+ filename = filename.strip()
+ encrypt = False if gs.pa.plain else True
+ mime_type = magic.from_file(filename, mime=True)
+ file_stat = await aiofiles.os.stat(filename)
+ async with aiofiles.open(filename, "r+b") as f:
+ resp, decryption_dict = await client.upload(
+ f,
+ content_type=mime_type, # e.g. application/pdf
+ filename=os.path.basename(filename),
+ encrypt=encrypt,
+ filesize=file_stat.st_size,
+ )
+ if isinstance(resp, UploadError):
+ gs.log.error(
+ "E172: "
+ "Failed to upload. "
+ f'file="{filename}"; mime_type="{mime_type}"; '
+ f"filessize={file_stat.st_size}; encrypt={encrypt}; "
+ f"Server response: {privacy_filter(str(resp))}"
+ )
+ gs.err_count += 1
+ else:
+ gs.log.debug(
+ f"File {filename}, mime={mime_type}, "
+ f"{file_stat.st_size} bytes, encrypt={encrypt} "
+ "was successfully uploaded to server. Response is: "
+ f"{privacy_filter(str(resp))}."
+ )
+ gs.log.debug(
+ f"URI of uploaded file {filename} is: {resp.content_uri}"
+ )
+ gs.log.debug(
+ f"Decryption key (dictionary) of uploaded file {filename} is: "
+ "'*** hidden to prevent leaks'" # f"{decryption_dict}"
+ )
+ # decryption_dict will be None in case of plain-text
+ # the URI and keys will be needed later. So this print is a must
+ # output format controlled via --output flag
+ text = f"{resp.content_uri}{SEP}{decryption_dict}"
+ # Object of type xxxResponse is not JSON
+ # serializable, hence we use the dictionary.
+ json_max = resp.__dict__
+ json_max.update(
+ {"decryption_dict": decryption_dict}
+ ) # add dict items
+ json_ = json_max.copy()
+ json_.pop("transport_response")
+ json_spec = None
+ print_output(
+ gs.pa.output,
+ text=text,
+ json_=json_,
+ json_max=json_max,
+ json_spec=json_spec,
+ )
+
+
+async def action_delete_mxc(client: AsyncClient, credentials: dict) -> None:
+ """Delete one or more files from content repository of Matrix server.
+ Assumes that user is already logged in.
+ """
+ # see: https://docs.aiohttp.org/en/stable/client_quickstart.html
+ # we must emulate a curl like this:
+ # curl -XDELETE "https://SERVERHERE/_synapse/admin/v1/media/SERVERHERE/
+ # MXCIDHERE?access_token=ACCESS_TOKEN_HERE"
+ for mxc in gs.pa.delete_mxc:
+ mxc = mxc.strip()
+ gs.log.debug(f"Preparing to delete MXC {mxc}.")
+ # we allow mxc to be a) mxc://server/mxc-id or just mxc-id
+ if urlparse(mxc).scheme == "mxc":
+ mxc = urlparse(mxc).path.replace("/", "")
+ gs.log.debug(f"Preparing to delete MXC ID {mxc}.")
+ if gs.pa.access_token:
+ at = gs.pa.access_token
+ gs.log.debug("Using access token from --access-token argument.")
+ else:
+ at = credentials["access_token"]
+ gs.log.debug("Using access token from credentials file.")
+ srv_full = credentials["homeserver"] # https://example.matrix.org
+ srv_host = urlparse(srv_full).hostname # example.matrix.org
+ rest = (
+ srv_full
+ + "/_synapse/admin/v1/media/"
+ + srv_host
+ + "/"
+ + mxc
+ + "?access_token="
+ + at
+ )
+ gs.log.debug(f"Issuing REST Matrix API call: DELETE {rest}")
+ connector = TCPConnector(ssl=gs.ssl) # setting sslcontext
+ async with ClientSession(connector=connector) as session: # aiohttp
+ async with session.delete(rest) as resp:
+ status = resp.status # int, 200 success
+ txt = await resp.text() # str in dict format
+ if status != 200:
+ # txt is str like this:
+ # {"errcode":"M_FORBIDDEN","error":"You are not a server admin"}
+ gs.log.error(
+ "E173: "
+ f"Failed to delete object (mxc) '{mxc}' from server "
+ f"'{srv_full}'. Failed with error code {status} and "
+ f"error text {txt}."
+ )
+ gs.err_count += 1
+ else:
+ gs.log.debug(
+ f"MXC object {mxc} was successfully deleted from server "
+ f"{srv_full}. Response is: {txt}."
+ )
+
+
+async def action_delete_mxc_before(
+ client: AsyncClient, credentials: dict
+) -> None:
+ """Delete files older and larger from content repository of Matrix server.
+ Assumes that user is already logged in.
+ """
+ # https://matrix-org.github.io/synapse/latest/admin_api/
+ # media_admin_api.html#delete-local-media-by-date-or-size
+ # POST /_synapse/admin/v1/media/