├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── requirements.txt ├── setup.py └── src ├── __init__.py └── tootstream ├── __init__.py ├── toot.py └── toot_parser.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | .venv/ 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | 94 | # Ignore files created by the virtualenv 95 | tootstream/bin 96 | tootstream/include 97 | tootstream/share 98 | tootstream/pip-selfcheck.json 99 | 100 | .DS_Store 101 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## Released 8 | 9 | ## [0.4.0] - 2023-05-16 10 | 11 | ### Fixed 12 | - Toots with content warnings will not automatically display. Use the `show` command to display the contents. 13 | - Updating colors to be more consistent / better contrast. 14 | - Display no-content, image-only toots. 15 | - Use search with limit 1 when we only want one account, bypassing Mastodon's questionable search algorithm. 16 | - `view` command doesn't bring back a list of users that are close to what you were searching for. 17 | - edits no longer break `note` command. 18 | - Commands using `step` now find the original toot for a reblogged toot. 19 | 20 | ### Added 21 | - Filter support (list filters, toots with filters honor the filter settings). 22 | - `show` command, which shows the contents of a toot. 23 | - `next` and `prev` commands for pagination of the current timeline context. 24 | - `mute` now has time duration (30s, 1d, etc.). 25 | - `view` command now shows which user you are viewing and allows pagination. 26 | - `vote` command for voting in polls. 27 | - `user` command for showing a user profile. 28 | - Displays poll results, whether the poll is expired, and if the poll supports multiple votes (along with a URI). 29 | - Update the current prompt with the current context. 30 | - Added `mentions` command to just show mentions. 31 | - Added poll and update filters to `note` command. 32 | - Changed command to abort step from "a" to "q" to maintain consistency. 33 | - Allow favoriting / unfavoriting multiple toots at once. 34 | - `showthread` will show all toots in a thread with content warnings / filters removed. 35 | - Added `follow_request` to note; added `-r` to filter requests. 36 | 37 | ## Release 38 | 39 | ## [0.3.9.0] - 2023-02-26 40 | 41 | ### Fixed 42 | - Remove duplicate code (thanks to Jesse Weinstein) 43 | - Upgrade to Mastodon.py version to Mastodon.py 1.8.0 44 | 45 | ### Added 46 | - Added support for bookmarks (thanks to Jessee Weinstein) 47 | 48 | ## Release 49 | 50 | ## [0.3.8.1] - 2020-01-22 51 | 52 | ### Fixed 53 | - Upgrade to Mastodon.py 1.5.0 54 | - PEP8 code formatting 55 | 56 | ## Release 57 | 58 | ## [0.3.7] - 2019-07-20 59 | 60 | ### Fixed 61 | - Upgrade to Mastodon.py 1.4.5 62 | - Rudimentary support for polls (shows links to polls) 63 | - Update colored minimum version to 1.3.93 (Fixes GPL license incompatibility) 64 | - Support Pleroma FlakeIDs 65 | - Minor fix for stream command being closed without receiving a toot getting a Nonetype for handle 66 | 67 | ## Release 68 | 69 | ## [0.3.6] - 2018-09-29 70 | 71 | ### Added 72 | - Updated to Mastodon.py 1.3.1 (No additional features yet) 73 | - Added links command to show links in a toot and optionally open them in a browser 74 | - Added puburl command to show the public URL of a toot 75 | 76 | ### Fixed 77 | - Upgrade to Mastodon.py 1.3.1 fixes searching for users issue noted in 0.3.5 78 | - Spelling mistakes 79 | - Added better error message for streaming support not supported on older mastodon instances 80 | 81 | ## Release 82 | 83 | ## [0.3.5] - 2018-08-08 84 | 85 | ### Added 86 | - Updated to Mastodon.py 1.3 (no additional features yet) 87 | 88 | ### Fixed 89 | - List renames did not work 90 | 91 | 92 | ## Release 93 | 94 | ## [0.3.4] - 2018-05-30 95 | 96 | ### Added 97 | - Added ability to execute commands while streaming (toot, fav, rep, etc.) 98 | - Added step switch for stepping through the timelines (ex: home step, listhome step) 99 | - Execute commands on stepped toots (fav, boost, rep, etc.) 100 | - Added ability to show links and optionally open those links in a browser (see "help links" for details). 101 | - Display media links by default 102 | - Display message when no notifications are present 103 | 104 | ### Fixed 105 | - Privacy settings now default to server privacy settings for toots 106 | - CTRL-C in streaming adds a linefeed to preserve prompt spacing 107 | - Streaming now supports lists with spaces 108 | - Added broad exception handling so tootstream shouldn't crash while running commands. 109 | - Minor formatting fixes 110 | 111 | ## Release 112 | 113 | ## [0.3.3] - 2018-02-17 114 | 115 | ### Added 116 | - List support for servers that support it. (See ``help list`` for more details.) 117 | - Bumped to Mastodon.py 1.2.2 118 | 119 | ### Added (awaiting proper config) 120 | ( The following items are active but require a re-working of the configuration file to make active. Currently they are flags inside the ``toot_parser.py`` file. Intrepid explorers may find them.) 121 | - Added emoji shortcode (defaults to "off"). 122 | - Added emoji "demoji" to show shortcodes for emoji (defaults to off). 123 | 124 | ### Fixed 125 | - Fixed boosting private toots 126 | - Fixed message for boosting toots 127 | - Fixed leading / trailing whitespace from media filepath 128 | - Added better exception handling around streaming API 129 | 130 | ### 131 | 132 | ## Release 133 | 134 | ## [0.3.2] - 2017-12-23 135 | 136 | ### Added 137 | - Reworked the Tootstream Parser to add styling, link-shortening, link retrieval, and emoji code shortening 138 | - About shows current version of Tootstream and the connected instance 139 | - Notifications may now be filtered 140 | 141 | ### Fixed 142 | - Replies no longer include the logged-in user 143 | - Allow user to edit a toot when an API error occurs 144 | - Compatibility with Mastodon.py 1.2.1 145 | 146 | ## Release 147 | ## [0.3.1] - 2017-11-21 148 | 149 | ### Fixed 150 | - Compatibility with Mastodon 1.1.2 fix 151 | 152 | ## Release 153 | ## [0.3.0] - 2017-11-17 154 | ## Dedicated to the memory of Natalie Nguyen (aka Tipsy Tentacle). May she live on in our hearts and our changelog. 155 | ### Added 156 | - Upload media via a toot and set visibility 157 | - Set content warnings on a toot 158 | - Set visibility of a toot (public, unlisted, private, direct) 159 | - Thread and history commands for viewing a toot's thread 160 | - "Humanized" time formats for toots (how long ago did this occur from now?) 161 | - Clear out notifications / dismiss individual notifications 162 | 163 | #### Changed 164 | - Help is split into sections (Help, Toots, Timeline, Users, Discover, and Profile) 165 | - Can type "help section" to see the help for that section 166 | 167 | #### Fixed 168 | - Changed the glyphs so they are encoded 169 | - Python 3 requirement is now explicit 170 | 171 | ## Release 172 | ## [0.2.0] - 2017-10-17 173 | ### Added 174 | - Command auto-complete 175 | - Nickname autocomplete for local and federated users 176 | - View command: view the latest toots from a user 177 | - Search function 178 | - Followers / Following list 179 | - Block / Unblock function 180 | - Mute / Unmute function 181 | - Follow requests (accept / reject) 182 | - Bring up the default editor when no text is added for toot and rep commands 183 | - Added --profile command line option 184 | - Proper Python Packaging 185 | 186 | ### Changed 187 | - Using Mastodon.py 1.1.0 188 | - ``get_userid`` check API results list for exact match to user input 189 | - Many formatting changes (now using glyphs and content warning, timestamps on metions) 190 | - Refactored login and user prompts 191 | - Simplified the requirements to only include requirements for tootstream 192 | 193 | ### Fixed 194 | - Favorite / Boost/ Reply won't crash without ID 195 | - Local timeline actually shows local timeline 196 | - Accept / Reject Status fixed. 197 | - Configuration file more resilient 198 | - Empty toots could crash the program with later Mastodon.py 199 | 200 | ## Release 201 | ## [0.1.0] - 2017-05-02 202 | ### Added 203 | - Contribution guide 204 | - License 205 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # tootstream is an [OPEN Open Source Project](http://openopensource.org/) 2 | 3 | ----------------------------------------- 4 | 5 | ## Chat 6 | 7 | Discuss contributions to tootstream on [gitter](https://gitter.im/tootstream/Lobby?utm_source=share-link&utm_medium=link&utm_campaign=share-link)! 8 | 9 | ----------------------------------------- 10 | 11 | ## What? 12 | 13 | Individuals making significant and valuable contributions are given 14 | commit-access to the project to contribute as they see fit. This project 15 | is more like an open wiki than a standard guarded open source project. 16 | 17 | ## Rules 18 | 19 | There are a few basic ground-rules for contributors: 20 | 21 | 1. **No `--force` pushes** or modifying the Git history in any way. 22 | 1. **Non-master branches** ought to be used for ongoing work. 23 | 1. **External API changes and significant modifications** ought to be subject to an **internal pull-request** to solicit feedback from other contributors. 24 | 1. Internal pull-requests to solicit feedback are *encouraged* for any other non-trivial contribution but left to the discretion of the contributor. 25 | 1. Contributors should attempt to adhere to the prevailing code-style. 26 | 27 | ## Releases 28 | 29 | Declaring formal releases remains the prerogative of the project maintainer. 30 | 31 | ## Changes to this arrangement 32 | 33 | This is an experiment and feedback is welcome! This document may also be 34 | subject to pull-requests or changes by contributors where you believe 35 | you have something valuable to add or change. 36 | 37 | Get a copy of this manifesto as [markdown](https://raw.githubusercontent.com/openopensource/openopensource.github.io/master/Readme.md) and use it in your own projects. 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Sara Murray 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include *.md 3 | include *.rst 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tootstream 2 | 3 | A command line interface for interacting with Mastodon instances written in Python (requires Python 3). 4 | 5 | OAuth and 2FA are supported. 6 | 7 | Inspired by [Rainbowstream]( 8 | https://github.com/DTVD/rainbowstream). 9 | 10 | ## Demo 11 | 12 | [![tootstream displaying the Federated timeline](https://i.imgur.com/LqjUXpt.jpg)](https://asciinema.org/a/3m87j1s402ic2llfp517okpv2?t=7&speed=2) 13 | 14 | ## Install via pip 15 | 16 | 1: Create a virtual environment 17 | ``` 18 | $ virtualenv -p python3 /path/to/tootstream 19 | $ source /path/to/tootstream/bin/activate 20 | ``` 21 | 22 | 2: Install via pip 23 | ``` 24 | $ pip install tootstream 25 | ``` 26 | 27 | 3: See the *Usage* section for how to use Tootstream. 28 | 29 | ## Install for development 30 | 31 | 1: Clone this repo and enter the project directory through a virtual environment 32 | ``` 33 | $ git clone https://github.com/magicalraccoon/tootstream.git 34 | $ cd tootstream 35 | ``` 36 | 37 | 2: Create a Virtual Environment 38 | 39 | ``` 40 | # Create a virtual environment 41 | $ virtualenv -p python3 /path/to/tootstream 42 | $ source /path/to/tootstream/bin/activate 43 | ``` 44 | 45 | 3: Install the project 46 | ``` 47 | $ python3 setup.py install 48 | ``` 49 | 50 | 4: Close the environment with `$ deactivate` 51 | 52 | ## Usage 53 | 54 | 1: Return to your virtual environment 55 | ``` 56 | $ source /path/to/tootstream/bin/activate 57 | ``` 58 | 59 | 2: Run the program 60 | ``` 61 | $ tootstream 62 | ``` 63 | 64 | 3: Use the ``help`` command to see the available commands 65 | ``` 66 | [@myusername (default)]: help 67 | ``` 68 | 69 | 4: Exit the program when finished 70 | ``` 71 | [@myusername (default)]: quit 72 | 73 | ``` 74 | 75 | 5: Close the environment with `$ deactivate` 76 | 77 | ## Ubuntu and Unicode 78 | 79 | Tootstream relies heavily on Unicode fonts. The best experience can be had by installing the following package: 80 | 81 | ``` 82 | $ sudo apt-get install ttf-ancient-fonts 83 | ``` 84 | 85 | ## Configuration 86 | 87 | By default tootstream uses [configparser](https://docs.python.org/3/library/configparser.html) for configuration. The default configuration is stored in the default location for configparser (on the developer's machine this is under /home/myusername/.config/tootstream/tootstream.conf). 88 | 89 | At the moment tootstream only stores login information for each instance in the configuration file. Each instance is under its own section (the default configuration is under the ``[default]`` section). Multiple instances can be stored in the ``tootstream.conf`` file. (See "Using multiple instances") 90 | 91 | ## Using multiple instances 92 | 93 | Tootstream supports using accounts on multiple Mastodon instances. 94 | 95 | Use the ``--instance`` parameter to pass the server location (in the case of Mastodon.social we'd use ``--instance mastodon.social``). 96 | 97 | Use the ``--profile`` parameter to use a different named profile. (in the case of Mastodon.social we could call it ``mastodon.social`` and name the section using ``--profile mastodon.social``). 98 | 99 | By default tootstream uses the ``[default]`` profile. If this already has an instance associated with it then tootstream will default to using that instance. 100 | 101 | If you have already set up a profile you may use the ``--profile`` command-line switch to start tootstream with it. The ``--instance`` parameter is optional (and redundant). 102 | 103 | You may select a different configuration using ``--config`` and pass it the full-path to that file. 104 | 105 | ## Notes on networking 106 | 107 | Tootstream and Mastodon.py use the [requests](https://pypi.python.org/pypi/requests) library for communicating with the Mastodon instance. Any proxy settings you may need to communicate with the network will need to be in a format that the requests library understands. See the requests documentation for more details on what those environment variables should be. 108 | 109 | ## Contributing 110 | 111 | Contributions welcome! Please read the [contributing guidelines](CONTRIBUTING.md) before getting started. 112 | 113 | ## Code of Conduct 114 | 115 | This project is intended to be a safe, welcoming space for collaboration. All contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. Thank you for being kind to each other! 116 | 117 | ## License 118 | 119 | [MIT](LICENSE.md) 120 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click>=6.7 2 | Mastodon.py>=1.8.1 3 | colored>=1.3.93 4 | humanize>=0.5.1 5 | emoji>=0.4.5 6 | pytimeparse 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | setup( 3 | name="tootstream", 4 | version="0.5.0", 5 | python_requires=">=3", 6 | install_requires=[line.strip() for line in open('requirements.txt')], 7 | 8 | packages=find_packages('src'), 9 | package_dir={'': 'src'}, include_package_data=True, 10 | package_data={ 11 | }, 12 | 13 | author="Sara Murray", 14 | author_email="saramurray@protonmail.com", 15 | description="A command line interface for interacting with Mastodon instances", # nopep8 16 | long_description="A command line interface for interacting with Mastodon instances", # nopep8 17 | license="MIT", 18 | keywords="mastodon, mastodon.social, toot, tootstream", 19 | url="http://www.github.com/magicalraccoon/tootstream", 20 | entry_points={ 21 | 'console_scripts': 22 | ['tootstream=tootstream.toot:main'] 23 | } 24 | 25 | ) 26 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicalraccoon/tootstream/f01781f43e691820495bab0b02799c8c821baeb6/src/__init__.py -------------------------------------------------------------------------------- /src/tootstream/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicalraccoon/tootstream/f01781f43e691820495bab0b02799c8c821baeb6/src/tootstream/__init__.py -------------------------------------------------------------------------------- /src/tootstream/toot.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import datetime 3 | import os.path 4 | import re 5 | import configparser 6 | import random 7 | import readline 8 | import bisect 9 | import shutil 10 | from collections import OrderedDict 11 | import webbrowser 12 | import dateutil 13 | 14 | # Get the version of Tootstream 15 | import pkg_resources # part of setuptools 16 | import click 17 | from tootstream.toot_parser import TootParser 18 | from mastodon import Mastodon, StreamListener 19 | from colored import fg, bg, attr, stylize 20 | import humanize 21 | import emoji 22 | import pytimeparse 23 | 24 | 25 | version = pkg_resources.require("tootstream")[0].version 26 | 27 | # placeholder variable for converting emoji to shortcodes until we get it in config 28 | convert_emoji_to_shortcode = False 29 | 30 | # placeholder variable for showing media links until we get it in config 31 | show_media_links = True 32 | 33 | # Flag for whether we're streaming or not 34 | is_streaming = False 35 | 36 | # Looks best with black background. 37 | # TODO: Set color list in config file 38 | COLORS = list(range(19, 231)) 39 | GLYPHS = { 40 | # general icons, keys don't match any Mastodon dict keys 41 | "fave": "\U00002665", # Black Heart Suit 42 | "boost": "\U0000267a", # Recycling Symbol for generic materials 43 | "mentions": "\U0000270e", # Lower Right Pencil 44 | "toots": "\U0001f4ea", # mailbox (for toot counts) 45 | # next key matches key in user dict 46 | # lock (masto web uses U+F023 from FontAwesome) 47 | "locked": "\U0001f512", 48 | # next 2 keys match keys in toot dict indicating user has already faved/boosted 49 | "favourited": "\U00002605", # star '\U0001f31f' '\U00002b50' '\U00002605' 50 | "reblogged": "\U0001f1e7", # reginal-B '\U0001f1e7' (or reuse ♺?) 51 | # next 4 keys match possible values for toot['visibility'] 52 | "public": "\U0001f30e", # globe 53 | "unlisted": "\U0001f47b", # ghost '\U0001f47b' ... mute '\U0001f507' ?? 54 | "private": "\U0001f512", # lock 55 | # envelopes: '\U0001f4e7' '\U0001f4e9' '\U0001f48c' '\U00002709' 56 | "direct": "\U0001f4e7", 57 | # next 5 keys match keys in relationship{} 58 | "followed_by": "\U0001f43e", # pawprints '\U0001f43e' 59 | "following": "\U0001f463", # footprints '\U0001f463' 60 | # thumbsdown '\U0001f44e', big X '\U0000274c', stopsign '\U0001f6d1' 61 | "blocking": "\U0000274c", 62 | # mute-spkr '\U0001f507', mute-bell '\U0001f515', prohibited '\U0001f6ab' 63 | "muting": "\U0001f6ab", 64 | "requested": "\U00002753", # hourglass '\U0000231b', question '\U00002753' 65 | "voted": "\U00002714", # Checkmark 66 | # catchall 67 | "unknown": "\U0001f34d", 68 | } 69 | 70 | # reserved config sections (disallowed as profile names) 71 | RESERVED = ("theme", "global") 72 | 73 | 74 | class AlreadyPrintedException(Exception): 75 | """An exception that has already been shown to the user, so doesn't need to be printed again.""" 76 | 77 | pass 78 | 79 | 80 | class IdDict: 81 | """Represents a mapping of local (tootstream) ID's to global 82 | (mastodon) IDs.""" 83 | 84 | def __init__(self): 85 | self._map = [] 86 | 87 | def to_local(self, global_id): 88 | """Returns the local ID for a global ID""" 89 | try: 90 | return self._map.index(global_id) 91 | except ValueError: 92 | self._map.append(global_id) 93 | return len(self._map) - 1 94 | 95 | def to_global(self, local_id): 96 | """Returns the global ID for a local ID, or None if ID is invalid. 97 | Also prints an error message""" 98 | try: 99 | local_id = int(local_id) 100 | return self._map[local_id] 101 | except Exception: 102 | cprint("Invalid ID.", fg("red")) 103 | return None 104 | 105 | 106 | def redisplay_prompt(): 107 | print(readline.get_line_buffer(), end="", flush=True) 108 | readline.redisplay() 109 | 110 | 111 | class TootListener(StreamListener): 112 | def on_update(self, status): 113 | print() 114 | printToot(status) 115 | print() 116 | redisplay_prompt() 117 | 118 | 119 | IDS = IdDict() 120 | 121 | LAST_PAGE = None 122 | LAST_CONTEXT = None 123 | 124 | # Get the current width of the terminal 125 | terminal_size = shutil.get_terminal_size((80, 20)) 126 | toot_parser = TootParser( 127 | indent=" ", 128 | width=int(terminal_size.columns) - 2, 129 | convert_emoji_to_unicode=False, 130 | convert_emoji_to_shortcode=convert_emoji_to_shortcode, 131 | ) 132 | 133 | toot_listener = TootListener() 134 | 135 | 136 | ##################################### 137 | ######## UTILITY FUNCTIONS ######## 138 | ##################################### 139 | 140 | 141 | def find_original_toot_id(toot): 142 | """ Locates the original toot ID in case of a reblog""" 143 | reblog = toot.get("reblog") 144 | if reblog: 145 | original_toot = reblog 146 | else: 147 | original_toot = toot 148 | original_toot_id = original_toot.get("id") 149 | return IDS.to_local(original_toot_id) 150 | 151 | 152 | def rest_to_list(rest): 153 | rest = ",".join(rest.split()) 154 | rest = rest.replace(",,", ",") 155 | rest = [x.strip() for x in rest.split(",")] 156 | return rest 157 | 158 | 159 | def rest_limit(rest): 160 | rest_list = rest_to_list(rest) 161 | limit = None 162 | if len(rest_list) > 1: 163 | rest = rest_list.pop(0) 164 | limit = rest_list.pop() 165 | else: 166 | rest = rest_list[0] 167 | return limit, rest 168 | 169 | 170 | def update_prompt(username, context, profile): 171 | if context: 172 | prompt = f"[@{username} <{context}> ({profile})]: " 173 | else: 174 | prompt = f"[@{username} ({profile})]: " 175 | return prompt 176 | 177 | 178 | def list_support(mastodon, silent=False): 179 | lists_available = mastodon.verify_minimum_version("2.1.0") 180 | if lists_available is False and silent is False: 181 | cprint("List support is not available with this version of Mastodon", fg("red")) 182 | return lists_available 183 | 184 | 185 | def step_flag(rest): 186 | if "step" in rest: 187 | return True, rest.replace(" step", "") 188 | return False, rest 189 | 190 | 191 | def limit_flag(rest): 192 | if rest.isdigit(): 193 | return int(rest), rest 194 | return None, rest 195 | 196 | 197 | def get_content(toot): 198 | html = toot.get("content") 199 | if html is None: 200 | return "" 201 | toot_parser.parse(html) 202 | return toot_parser.get_text() 203 | 204 | 205 | def get_media_attachments(toot): 206 | out = [] 207 | nsfw = "CW " if toot.get("sensitive") else "" 208 | out.append( 209 | stylize( 210 | " " + nsfw + "media: " + str(len(toot.get("media_attachments"))), 211 | fg("magenta"), 212 | ) 213 | ) 214 | if show_media_links: 215 | for media in toot.get("media_attachments"): 216 | description = media.get("description") 217 | if description: 218 | toot_parser.reset() 219 | toot_parser.handle_data(" " + nsfw + " " + description) 220 | out.append(stylize(toot_parser.get_text(), fg("white"))) 221 | out.append(stylize(" " + nsfw + " " + media.url, fg("green"))) 222 | return out 223 | 224 | 225 | def get_poll(toot): 226 | poll = getattr(toot, "poll", None) 227 | if poll: 228 | poll_results = "" 229 | total_votes_count = poll.get("votes_count") 230 | poll_options = poll.get("options") 231 | own_votes = poll.get("own_votes") 232 | for i, poll_element in enumerate(poll_options): 233 | selected = " " 234 | poll_title = poll_element.get("title") 235 | poll_votes_count = poll_element.get("votes_count") 236 | if total_votes_count > 0: 237 | poll_percentage = (poll_votes_count / total_votes_count) * 100 238 | else: 239 | poll_percentage = 0 240 | if i in own_votes: 241 | selected = GLYPHS.get("voted") 242 | poll_results += f"{selected} {i}: {poll_title} ({poll_votes_count}: {poll_percentage:.2f}%)\n" 243 | poll_results += f" Total votes: {total_votes_count}" 244 | if poll.multiple: 245 | poll_results += f"\n (Multiple votes may be cast.)" 246 | if poll.expired: 247 | poll_results += f"\n Polling is over." 248 | uri = toot["uri"] 249 | return f" [poll] {poll['id']} ({uri})\n{poll_results}" 250 | 251 | 252 | def get_unique_userid(mastodon, rest, exact=True): 253 | """Get a unique user ID by limiting the search to the top result. 254 | params: 255 | rest: rest of the command 256 | exact: whether to do an exact search or not. 257 | Most commands should require precision, so 258 | 259 | """ 260 | # Check if the ID is already in numeric form 261 | try: 262 | userid = int(rest) 263 | return userid 264 | except ValueError: 265 | pass 266 | 267 | user_list = mastodon.account_search(rest, limit=1) 268 | if not user_list: 269 | raise Exception(f" username '{rest}' not found") 270 | return 271 | user = user_list.pop() 272 | if exact: 273 | username_check = rest.lstrip("@").strip() 274 | username_acct = user.get("acct").lstrip("@").strip() 275 | if username_check != username_acct: 276 | if "@" not in username_check: 277 | raise ValueError(" Please use a more exact username for this command.") 278 | else: 279 | raise Exception(f" {username_check} not found.") 280 | userid = user.get("id") 281 | return userid 282 | 283 | 284 | def get_list_id(mastodon, rest): 285 | """Get the ID for a list""" 286 | if not rest or not rest.strip(): 287 | raise Exception("List argument missing.") 288 | 289 | # maybe it's already an int 290 | try: 291 | return int(rest) 292 | except ValueError: 293 | pass 294 | 295 | lists = mastodon.lists() 296 | desired_title = rest.strip().lower() 297 | for item in lists: 298 | if item["title"].lower() == desired_title: 299 | return item["id"] 300 | 301 | raise Exception("List '{}' is not found.".format(rest)) 302 | 303 | 304 | def flaghandler(rest, initial, flags): 305 | """Parse input for flags.""" 306 | 307 | # initialize kwargs to default values 308 | kwargs = {k: initial for k in flags.values()} 309 | 310 | # token-grabbing loop 311 | # recognize separated (e.g. `-m -f`) as well as combined (`-mf`) 312 | while rest.startswith("-"): 313 | # get the next token 314 | (args, _, rest) = rest.partition(" ") 315 | # traditional unix "ignore flags after this" syntax 316 | if args == "--": 317 | break 318 | for k in flags.keys(): 319 | if k in args: 320 | kwargs[flags[k]] = not kwargs[flags[k]] 321 | 322 | return (rest, kwargs) 323 | 324 | 325 | def flaghandler_note(mastodon, rest): 326 | return flaghandler( 327 | rest, 328 | True, 329 | { 330 | "m": "mention", 331 | "f": "favourite", 332 | "b": "reblog", 333 | "F": "follow", 334 | "r": "follow_request", 335 | "p": "poll", 336 | "u": "update", 337 | }, 338 | ) 339 | 340 | 341 | def flaghandler_tootreply(mastodon, rest): 342 | """Parse input for flags and prompt user. On success, returns 343 | a tuple of the input string (minus flags) and a dict of keyword 344 | arguments for Mastodon.status_post(). On failure, returns 345 | (None, None).""" 346 | 347 | (rest, flags) = flaghandler( 348 | rest, False, {"v": "visibility", "c": "cw", "C": "noCW", "m": "media"} 349 | ) 350 | 351 | # if any flag is true, print a general usage message 352 | if True in flags.values(): 353 | print("Press Ctrl-C to abort and return to the main prompt.") 354 | 355 | # initialize kwargs to default values 356 | kwargs = { 357 | "sensitive": False, 358 | "media_ids": None, 359 | "spoiler_text": None, 360 | "visibility": "", 361 | } 362 | 363 | # visibility flag 364 | if flags["visibility"]: 365 | vis = input("Set visibility [(p)ublic/(u)nlisted/(pr)ivate/(d)irect/None]: ") 366 | vis = vis.lower() 367 | 368 | # default case; pass on through 369 | if vis == "" or vis.startswith("n"): 370 | pass 371 | # other cases: allow abbreviations 372 | elif vis.startswith("d"): 373 | kwargs["visibility"] = "direct" 374 | elif vis.startswith("u"): 375 | kwargs["visibility"] = "unlisted" 376 | elif vis.startswith("pr"): 377 | kwargs["visibility"] = "private" 378 | elif vis.startswith("p"): 379 | kwargs["visibility"] = "public" 380 | # unrecognized: abort 381 | else: 382 | cprint( 383 | "error: only 'public', 'unlisted', 'private', 'direct' are allowed", 384 | fg("red"), 385 | ) 386 | return (None, None) 387 | # end vis 388 | 389 | # cw/spoiler flag 390 | if flags["noCW"] and flags["cw"]: 391 | cprint("error: only one of -C and -c allowed", fg("red")) 392 | return (None, None) 393 | elif flags["noCW"]: 394 | # unset 395 | kwargs["spoiler_text"] = "" 396 | elif flags["cw"]: 397 | # prompt to set 398 | cw = input("Set content warning [leave blank for none]: ") 399 | 400 | # don't set if empty 401 | if cw: 402 | kwargs["spoiler_text"] = cw 403 | # end cw 404 | 405 | # media flag 406 | media = [] 407 | if flags["media"]: 408 | print("You can attach up to 4 files. A blank line will end filename input.") 409 | count = 0 410 | while count < 4: 411 | fname = input("add file {}: ".format(count + 1)) 412 | 413 | # break on empty line 414 | if not fname: 415 | break 416 | 417 | # expand paths and check file access 418 | fname = os.path.expanduser(fname).strip() 419 | if os.path.isfile(fname) and os.access(fname, os.R_OK): 420 | media.append(fname) 421 | count += 1 422 | else: 423 | raise Exception(f"error: cannot find file {fname}") 424 | 425 | # upload, verify 426 | if count: 427 | print("Attaching files:") 428 | c = 1 429 | kwargs["media_ids"] = [] 430 | for m in media: 431 | try: 432 | kwargs["media_ids"].append(mastodon.media_post(m)) 433 | except Exception as e: 434 | cprint( 435 | "{}: API error uploading file {}".format(type(e).__name__, m), 436 | fg("red"), 437 | ) 438 | return (None, None) 439 | print(" {}: {}".format(c, m)) 440 | c += 1 441 | 442 | # prompt for sensitivity 443 | nsfw = input("Mark sensitive media [y/N]: ") 444 | nsfw = nsfw.lower() 445 | if nsfw.startswith("y"): 446 | kwargs["sensitive"] = True 447 | # end media 448 | 449 | return (rest, kwargs) 450 | 451 | 452 | def print_toots( 453 | mastodon, 454 | listing, 455 | stepper=False, 456 | limit=None, 457 | ctx_name=None, 458 | add_completion=True, 459 | show_toot=False, 460 | sort_toots=True, 461 | ): 462 | """Print toot listings and allow context dependent commands. 463 | 464 | If `stepper` is True it lets user step through listings with 465 | enter key. Entering [q] aborts stepping. 466 | 467 | Commands that require a toot id or username are partially applied based on 468 | context (current toot in listing) so that only the remaining (if any) 469 | parameters are necessary. 470 | 471 | Args: 472 | mastodon: Mastodon instance 473 | listing: Iterable containing toots 474 | ctx_name (str, optional): Displayed in command prompt 475 | add_completion (bool, optional): Add toots to completion list 476 | show:toot (bool, optional): whether to show the toot by default or not 477 | 478 | Examples: 479 | >>> print_toots(mastodon, mastodon.timeline_home(), ctx_name='home') 480 | 481 | sort_toots is used to apply reversed (chronological) sort to the list of toots. 482 | Default is true; threading needs this to be false. 483 | """ 484 | if listing is None: 485 | cprint("No toots in current context.", fg("white") + bg("red")) 486 | return 487 | user = mastodon.account_verify_credentials() 488 | ctx = "" if ctx_name is None else " in {}".format(ctx_name) 489 | 490 | def say_error(*args, **kwargs): 491 | cprint( 492 | "Invalid command. Use 'help' for a list of commands. Press [enter] for next toot, [q] to abort.", 493 | fg("white") + bg("red"), 494 | ) 495 | 496 | if sort_toots: 497 | toot_list = enumerate(reversed(listing)) 498 | else: 499 | toot_list = enumerate(listing) 500 | 501 | for pos, toot in toot_list: 502 | printToot(toot, show_toot) 503 | if add_completion is True: 504 | completion_add(toot) 505 | 506 | if stepper: 507 | username = user.get("username") 508 | prompt = f"[@{username} {pos+1}/{len(listing)}{ctx}]: " 509 | command = None 510 | while command not in ["", "q"]: 511 | command = input(prompt).split(" ", 1) 512 | 513 | try: 514 | rest = command[1] 515 | except IndexError: 516 | rest = "" 517 | command = command[0] 518 | if command not in ["", "q"]: 519 | cmd_func = commands.get(command, say_error) 520 | if ( 521 | hasattr(cmd_func, "__argstr__") 522 | and cmd_func.__argstr__ is not None 523 | ): 524 | if cmd_func.__argstr__.startswith(""): 525 | rest = str(find_original_toot_id(toot)) + " " + rest 526 | if cmd_func.__argstr__.startswith(""): 527 | rest = "@" + toot["account"]["username"] + " " + rest 528 | cmd_func(mastodon, rest) 529 | 530 | if command == "q": 531 | break 532 | 533 | 534 | def toot_visibility(mastodon, flag_visibility=None, parent_visibility=None): 535 | """Return the visibility of a toot. 536 | We use the following precedence for flagging the privacy of a toot: 537 | flags > parent (if not public) > account settings 538 | """ 539 | 540 | default_visibility = mastodon.account_verify_credentials()["source"]["privacy"] 541 | if flag_visibility: 542 | return flag_visibility 543 | 544 | if parent_visibility and parent_visibility != "public": 545 | return parent_visibility 546 | 547 | return default_visibility 548 | 549 | 550 | ##################################### 551 | ######## COMPLETION ######## 552 | ##################################### 553 | 554 | completion_list = [] 555 | 556 | 557 | def complete(text, state): 558 | """Return the state-th potential completion for the name-fragment, text""" 559 | options = [name for name in completion_list if name.startswith(text)] 560 | if state < len(options): 561 | return options[state] + " " 562 | else: 563 | return None 564 | 565 | 566 | def completion_add(toot): 567 | """Add usernames (original author, mentions, booster) co completion_list""" 568 | if toot["reblog"]: 569 | username = "@" + toot["reblog"]["account"]["acct"] 570 | if username not in completion_list: 571 | bisect.insort(completion_list, username) 572 | username = "@" + toot["account"]["acct"] 573 | if username not in completion_list: 574 | bisect.insort(completion_list, username) 575 | for user in ["@" + user["acct"] for user in toot["mentions"]]: 576 | if user not in completion_list: 577 | bisect.insort(completion_list, username) 578 | 579 | 580 | ##################################### 581 | ######## CONFIG FUNCTIONS ######## 582 | ##################################### 583 | 584 | 585 | def parse_config(filename): 586 | """ 587 | Reads configuration from the specified file. 588 | On success, returns a ConfigParser object containing 589 | data from the file. If the file does not exist, 590 | returns an empty ConfigParser object. 591 | 592 | Exits the program with error if the specified file 593 | cannot be parsed to prevent damaging unknown files. 594 | """ 595 | if not os.path.isfile(filename): 596 | cprint("...No configuration found, generating...", fg("cyan")) 597 | config = configparser.ConfigParser() 598 | return config 599 | 600 | config = configparser.ConfigParser() 601 | try: 602 | config.read(filename) 603 | except configparser.Error: 604 | cprint( 605 | "This does not look like a valid configuration: {}".format(filename), 606 | fg("red"), 607 | ) 608 | sys.exit(1) 609 | 610 | return config 611 | 612 | 613 | def save_config(filename, config): 614 | """ 615 | Writes a ConfigParser object to the specified file. 616 | If the file does not exist, this will try to create 617 | it with mode 600 (user-rw-only). 618 | 619 | Errors while writing are reported to the user but 620 | will not exit the program. 621 | """ 622 | (dirpath, basename) = os.path.split(filename) 623 | if not (dirpath == "" or os.path.exists(dirpath)): 624 | os.makedirs(dirpath) 625 | 626 | # create as user-rw-only if possible 627 | if not os.path.exists(filename): 628 | try: 629 | os.open(filename, flags=os.O_CREAT | os.O_APPEND, mode=0o600) 630 | except Exception as e: 631 | cprint("Unable to create file {}: {}".format(filename, e), fg("red")) 632 | 633 | try: 634 | with open(filename, "w") as configfile: 635 | config.write(configfile) 636 | except os.error: 637 | cprint("Unable to write configuration to {}".format(filename), fg("red")) 638 | return 639 | 640 | 641 | def register_app(instance): 642 | """ 643 | Registers this client with a Mastodon instance. 644 | 645 | Returns valid credentials if success, likely 646 | raises a Mastodon exception otherwise. 647 | """ 648 | return Mastodon.create_app( 649 | "tootstream", 650 | scopes=["read", "write", "follow"], 651 | api_base_url="https://" + instance, 652 | ) 653 | 654 | 655 | def login(instance, client_id, client_secret): 656 | """ 657 | Login to a Mastodon instance. 658 | 659 | Returns a valid Mastodon token if success, likely 660 | raises a Mastodon exception otherwise. 661 | """ 662 | 663 | # temporary object to aquire the token 664 | mastodon = Mastodon( 665 | client_id=client_id, 666 | client_secret=client_secret, 667 | api_base_url="https://" + instance, 668 | ) 669 | 670 | print("Click the link to authorize login.") 671 | print(mastodon.auth_request_url(scopes=["read", "write", "follow"])) 672 | print() 673 | code = input("Enter the code you received >") 674 | 675 | return mastodon.log_in(code=code, scopes=["read", "write", "follow"]) 676 | 677 | 678 | def get_or_input_profile(config, profile, instance=None): 679 | """ 680 | Validate an existing profile or get user input 681 | to generate a new one. If the user is not logged in, 682 | the user will be prompted 3 times before giving up. 683 | 684 | On success, returns valid credentials: instance, 685 | client_id, client_secret, token. 686 | On failure, returns None, None, None, None. 687 | """ 688 | # shortcut for preexisting profiles 689 | if config.has_section(profile): 690 | try: 691 | return ( 692 | config[profile]["instance"], 693 | config[profile]["client_id"], 694 | config[profile]["client_secret"], 695 | config[profile]["token"], 696 | ) 697 | except Exception: 698 | pass 699 | else: 700 | config.add_section(profile) 701 | 702 | # no existing profile or it's incomplete 703 | if instance is not None: 704 | # Nothing to do, just use value passed on the command line 705 | pass 706 | elif "instance" in config[profile]: 707 | instance = config[profile]["instance"] 708 | else: 709 | cprint( 710 | " Which instance would you like to connect to? eg: 'mastodon.social'", 711 | fg("blue"), 712 | ) 713 | instance = input(" Instance: ") 714 | 715 | client_id = None 716 | if "client_id" in config[profile]: 717 | client_id = config[profile]["client_id"] 718 | 719 | client_secret = None 720 | if "client_secret" in config[profile]: 721 | client_secret = config[profile]["client_secret"] 722 | 723 | if client_id is None or client_secret == None: 724 | try: 725 | client_id, client_secret = register_app(instance) 726 | except Exception as e: 727 | cprint("{}: please try again later".format(type(e).__name__), fg("red")) 728 | return None, None, None, None 729 | 730 | token = None 731 | if "token" in config[profile]: 732 | token = config[profile]["token"] 733 | 734 | if token is None: 735 | for i in [1, 2, 3]: 736 | try: 737 | token = login(instance, client_id, client_secret) 738 | except Exception as e: 739 | cprint( 740 | "Error authorizing app. Did you enter the code correctly?", 741 | fg("red"), 742 | ) 743 | if token: 744 | break 745 | 746 | if not token: 747 | cprint("Giving up after 3 failed login attempts", fg("red")) 748 | return None, None, None, None 749 | 750 | return instance, client_id, client_secret, token 751 | 752 | 753 | ##################################### 754 | ######## OUTPUT FUNCTIONS ######## 755 | ##################################### 756 | def cprint(text, style, end="\n"): 757 | print(stylize(text, style), end=end) 758 | 759 | 760 | def format_username(user): 761 | """Get a user's account name including lock indicator.""" 762 | return "".join( 763 | ("@", user["acct"], (" {}".format(GLYPHS["locked"]) if user["locked"] else "")) 764 | ) 765 | 766 | 767 | def format_user_counts(user): 768 | """Get a user's toot/following/follower counts.""" 769 | countfmt = "{} :{}" 770 | return " ".join( 771 | ( 772 | countfmt.format(GLYPHS["toots"], user["statuses_count"]), 773 | countfmt.format(GLYPHS["following"], user["following_count"]), 774 | countfmt.format(GLYPHS["followed_by"], user["followers_count"]), 775 | ) 776 | ) 777 | 778 | 779 | def format_display_name(name): 780 | if convert_emoji_to_shortcode: 781 | name = emoji.demojize(name) 782 | return name 783 | return name 784 | 785 | 786 | def printUser(user): 787 | """Prints user data nicely with hardcoded colors.""" 788 | counts = stylize(format_user_counts(user), fg("blue")) 789 | 790 | print(format_username(user) + " " + counts) 791 | display_name = format_display_name(user["display_name"]) 792 | cprint(display_name, fg("cyan")) 793 | print(user["url"]) 794 | cprint(re.sub("<[^<]+?>", "", user["note"]), fg("red")) 795 | 796 | 797 | def printUsersShort(users): 798 | for user in users: 799 | if not user: 800 | continue 801 | userid = "(id:" + str(user["id"]) + ")" 802 | display_name = format_display_name(user["display_name"]) 803 | userdisp = "'" + str(display_name) + "'" 804 | userurl = str(user["url"]) 805 | cprint(" " + format_username(user), fg("green"), end=" ") 806 | cprint(" " + userid, fg("red"), end=" ") 807 | cprint(" " + userdisp, fg("cyan")) 808 | cprint(" " + userurl, fg("blue")) 809 | 810 | 811 | def format_time(time_event): 812 | """Return a formatted time and humanized time for a time event""" 813 | try: 814 | if not isinstance(time_event, datetime.datetime): 815 | time_event = dateutil.parser.parse(time_event) 816 | tz_info = time_event.tzinfo 817 | time_diff = datetime.datetime.now(tz_info) - time_event 818 | humanize_format = humanize.naturaltime(time_diff) 819 | time_format = datetime.datetime.strftime(time_event, "%F %X") 820 | return time_format + " (" + humanize_format + ")" 821 | except AttributeError: 822 | return "(Time format error)" 823 | 824 | 825 | def format_toot_nameline(toot, dnamestyle): 826 | """Get the display, usernames and timestamp for a typical toot printout. 827 | 828 | dnamestyle: a fg/bg/attr set applied to the display name with stylize()""" 829 | # name line: display name, user@instance, lock if locked, timestamp 830 | if not toot: 831 | return "" 832 | formatted_time = format_time(toot["created_at"]) 833 | 834 | display_name = format_display_name(toot["account"]["display_name"]) 835 | out = [ 836 | stylize(display_name, dnamestyle), 837 | stylize(format_username(toot["account"]), fg("green")), 838 | stylize(formatted_time, attr("dim")), 839 | ] 840 | return " ".join(out) 841 | 842 | 843 | def format_toot_idline(toot): 844 | """Get boost/faves counts, toot ID, visibility, and 845 | already-faved/boosted indicators for a typical toot printout.""" 846 | # id-and-counts line: boosted count, faved count, tootid, visibility, favourited-already, boosted-already 847 | if not toot: 848 | return "" 849 | reblogs_count = toot.get("reblogs_count", 0) 850 | favourites_count = toot.get("favourites_count", 0) 851 | visibility = toot.get("visibility") 852 | out = [] 853 | out.append(stylize(GLYPHS["boost"] + ":" + str(reblogs_count), fg("cyan"))) 854 | out.append(stylize(GLYPHS["fave"] + ":" + str(favourites_count), fg("yellow"))) 855 | out.append(stylize("id:" + str(IDS.to_local(toot.get("id"))), fg("white"))) 856 | if visibility: 857 | out.append(stylize("vis:" + GLYPHS[visibility], fg("blue"))) 858 | 859 | # app used to post. frequently empty 860 | if toot.get("application") and toot.get("application").get("name"): 861 | out.append( 862 | "".join( 863 | ( 864 | stylize("via ", fg("white")), 865 | stylize(toot["application"]["name"], fg("blue")), 866 | ) 867 | ) 868 | ) 869 | # some toots lack these next keys, use get() to avoid KeyErrors 870 | if toot.get("favourited"): 871 | out.append(stylize(GLYPHS["favourited"], fg("magenta"))) 872 | if toot.get("reblogged"): 873 | out.append(stylize(GLYPHS["reblogged"], fg("magenta"))) 874 | 875 | return " ".join(out) 876 | 877 | 878 | def printToot(toot, show_toot=False, dim=False): 879 | if not toot: 880 | return 881 | 882 | show_toot_text = True 883 | out = [] 884 | # if it's a boost, only output header line from toot 885 | # then get other data from toot['reblog'] 886 | if toot.get("reblog"): 887 | header = stylize(" Boosted by ", fg("yellow")) 888 | display_name = format_display_name(toot["account"]["display_name"]) 889 | name = " ".join((display_name, format_username(toot["account"]) + ":")) 890 | out.append(header + stylize(name, fg("blue"))) 891 | toot = toot["reblog"] 892 | 893 | # get the first two lines 894 | random.seed(toot["account"]["display_name"]) 895 | out += [ 896 | " " + format_toot_nameline(toot, fg(random.choice(COLORS))), 897 | " " + format_toot_idline(toot), 898 | ] 899 | 900 | if toot.get("spoiler_text", "") != "": 901 | # pass CW through get_content for wrapping/indenting 902 | faketoot = {"content": "[CW: " + toot["spoiler_text"] + "]"} 903 | out.append(stylize(get_content(faketoot), fg("red"))) 904 | show_toot_text = False 905 | 906 | if toot.get("filtered"): 907 | filter_titles = ", ".join([x["filter"]["title"] for x in toot.filtered]) 908 | faketoot = {"content": "[Filter: " + filter_titles + "]"} 909 | out.append(stylize(get_content(faketoot), fg("red"))) 910 | show_toot_text = False 911 | 912 | if show_toot_text or show_toot: 913 | out.append(get_content(toot)) 914 | 915 | if toot.get("status"): 916 | out.append(get_content(toot.get("status"))) 917 | if toot.get("status").get("media_attachments"): 918 | out.append("\n".join(get_media_attachments(toot.get("status")))) 919 | 920 | if toot.get("media_attachments") and (show_toot_text or show_toot): 921 | # simple version: output # of attachments. TODO: urls instead? 922 | out.append("\n".join(get_media_attachments(toot))) 923 | 924 | if toot.get("poll"): 925 | out.append(get_poll(toot)) 926 | 927 | if dim: 928 | cprint("\n".join(out), attr("dim")) 929 | else: 930 | print("\n".join(out)) 931 | print() 932 | 933 | 934 | def edittoot(text): 935 | global is_streaming 936 | if is_streaming: 937 | cprint( 938 | "Using the editor while streaming is unsupported at this time.", fg("red") 939 | ) 940 | return "" 941 | edited_message = click.edit(text) 942 | if edited_message: 943 | return edited_message 944 | return "" 945 | 946 | 947 | def printList(list_item): 948 | """Prints list entry nicely with hardcoded colors.""" 949 | cprint(list_item["title"], fg("cyan"), end=" ") 950 | cprint("(id: %s)" % list_item["id"], fg("red")) 951 | 952 | 953 | def printFilter(filter_item): 954 | """Prints filter entry nicely with hardcoded colors.""" 955 | cprint(filter_item["phrase"], fg("cyan"), end=" ") 956 | cprint("(id: %s," % filter_item["id"], fg("red"), end=" ") 957 | cprint("context: %s, " % filter_item["context"], fg("red"), end=" ") 958 | cprint("expires_at: %s, " % filter_item["expires_at"], fg("red"), end=" ") 959 | cprint("whole_word: %s)" % filter_item["whole_word"], fg("red")) 960 | 961 | 962 | ##################################### 963 | ######## DECORATORS ######## 964 | ##################################### 965 | commands = OrderedDict() 966 | 967 | 968 | def command(argstr=None, section=None): 969 | """Adds the function to the command list.""" 970 | 971 | def inner(func): 972 | commands[func.__name__] = func 973 | bisect.insort(completion_list, func.__name__) 974 | func.__argstr__ = argstr 975 | func.__section__ = section 976 | return func 977 | 978 | return inner 979 | 980 | 981 | ##################################### 982 | ######## BEGIN COMMAND BLOCK ######## 983 | ##################################### 984 | __friendly_cmd_error__ = 'Unable to comply. Command not found: "{}"' 985 | __friendly_help_header__ = """ 986 | Tootstream Help: 987 | =============== 988 | usage: {} {} 989 | 990 | {} 991 | """ 992 | 993 | 994 | @command("[]", "Help") 995 | def help(mastodon, rest): 996 | """List all commands or show detailed help. 997 | 998 | ex: 'help' shows list of help commands. 999 | 'help toot' shows additional information about the 'toot' command. 1000 | 'help discover' shows additional information about the 'discover' section of commands.""" 1001 | 1002 | # Fill out the available sections 1003 | sections = {} 1004 | for cmd, cmd_func in commands.items(): 1005 | sections[cmd_func.__section__.lower()] = 1 1006 | 1007 | section_filter = "" 1008 | 1009 | # argument case 1010 | if rest and rest != "": 1011 | 1012 | args = rest.split() 1013 | if args[0] in commands.keys(): 1014 | # Show Command Help 1015 | try: 1016 | cmd_func = commands[args[0]] 1017 | except Exception: 1018 | print(__friendly_cmd_error__.format(rest)) 1019 | return 1020 | 1021 | try: 1022 | cmd_args = cmd_func.__argstr__ 1023 | except Exception: 1024 | cmd_args = "" 1025 | # print a friendly header and the detailed help 1026 | print( 1027 | __friendly_help_header__.format( 1028 | cmd_func.__name__, cmd_args, cmd_func.__doc__ 1029 | ) 1030 | ) 1031 | return 1032 | 1033 | if args[0].lower() in sections.keys(): 1034 | # Set the section filter for the full command section 1035 | section_filter = args[0].lower() 1036 | else: 1037 | # Command not found. Exit. 1038 | print(__friendly_cmd_error__.format(rest)) 1039 | return 1040 | 1041 | # Show full list (with section filtering if appropriate) 1042 | section = "" 1043 | new_section = False 1044 | 1045 | for command, cmd_func in commands.items(): 1046 | # get only the docstring's first line for the column view 1047 | (cmd_doc, *_) = cmd_func.__doc__.partition("\n") 1048 | try: 1049 | cmd_args = cmd_func.__argstr__ 1050 | except Exception: 1051 | cmd_args = "" 1052 | 1053 | if cmd_func.__section__ != section: 1054 | section = cmd_func.__section__ 1055 | new_section = True 1056 | 1057 | if section_filter == "" or section_filter == section.lower(): 1058 | if new_section: 1059 | cprint( 1060 | "{section}:".format(section=section), 1061 | fg("white") + attr("bold") + attr("underline"), 1062 | ) 1063 | new_section = False 1064 | 1065 | print("{:>14} {:<15} {:<}".format(command, cmd_args, cmd_doc)) 1066 | 1067 | 1068 | @command("[]", "Toots") 1069 | def toot(mastodon, rest): 1070 | """Publish a toot. 1071 | 1072 | ex: 'toot Hello World' will publish 'Hello World'. 1073 | If no text is given then this will run the default editor. 1074 | 1075 | Toot visibility defaults to your account's settings. You can change 1076 | the defaults by logging into your instance in a browser and changing 1077 | Preferences > Post Privacy. 1078 | 1079 | ex: 'toot Hello World' 1080 | will publish 'Hello World' 1081 | 'toot -v Hello World' 1082 | prompt for visibility setting and publish 'Hello World' 1083 | 1084 | Options: 1085 | -v Prompt for visibility (public, unlisted, private, direct) 1086 | -c Prompt for Content Warning / spoiler text 1087 | -m Prompt for media files and Sensitive Media 1088 | """ 1089 | global is_streaming 1090 | posted = False 1091 | # Fill in Content fields first. 1092 | try: 1093 | (text, kwargs) = flaghandler_tootreply(mastodon, rest) 1094 | except KeyboardInterrupt: 1095 | # user abort, return to main prompt 1096 | print("") 1097 | return 1098 | 1099 | kwargs["visibility"] = toot_visibility( 1100 | mastodon, flag_visibility=kwargs["visibility"] 1101 | ) 1102 | 1103 | if text == "": 1104 | text = edittoot(text="") 1105 | 1106 | while posted is False: 1107 | try: 1108 | resp = mastodon.status_post(text, **kwargs) 1109 | cprint("You tooted: ", fg("white") + attr("bold"), end="\n") 1110 | if resp["sensitive"]: 1111 | cprint("CW: " + resp["spoiler_text"], fg("red")) 1112 | cprint(text, fg("magenta") + attr("bold") + attr("underline")) 1113 | posted = True 1114 | except Exception as e: 1115 | cprint("Received error: ", fg("red") + attr("bold"), end="") 1116 | cprint(e, fg("magenta") + attr("bold") + attr("underline")) 1117 | 1118 | # If we're streaming then we can't edit the toot, so assume that we posted. 1119 | if is_streaming is True: 1120 | posted = True 1121 | 1122 | if posted is False: 1123 | retry = input("Edit toot and re-try? [Y/N]: ") 1124 | if retry.lower() == "y": 1125 | text = edittoot(text=text) 1126 | else: 1127 | posted = True 1128 | 1129 | 1130 | @command(" []", "Toots") 1131 | def rep(mastodon, rest): 1132 | """Reply to a toot by ID. 1133 | 1134 | Reply visibility and content warnings default to the original toot's 1135 | settings. 1136 | 1137 | ex: 'rep 13 Hello again' 1138 | reply to toot 13 with 'Hello again' 1139 | 'rep -vc 13 Hello again' 1140 | same but prompt for visibility and spoiler changes 1141 | If no text is given then this will run the default editor. 1142 | 1143 | Options: 1144 | -v Prompt for visibility (public, unlisted, private, direct) 1145 | -c Prompt for Content Warning / spoiler text 1146 | -C No Content Warning (do not use original's CW) 1147 | -m Prompt for media files and Sensitive Media 1148 | 1149 | """ 1150 | 1151 | posted = False 1152 | try: 1153 | (text, kwargs) = flaghandler_tootreply(mastodon, rest) 1154 | except KeyboardInterrupt: 1155 | # user abort, return to main prompt 1156 | print("") 1157 | return 1158 | 1159 | (parent_id, _, text) = text.partition(" ") 1160 | parent_id = IDS.to_global(parent_id) 1161 | if parent_id is None: 1162 | msg = " No message to reply to." 1163 | cprint(msg, fg("red")) 1164 | return 1165 | 1166 | if not text: 1167 | text = edittoot(text="") 1168 | 1169 | if parent_id is None or not text: 1170 | return 1171 | 1172 | try: 1173 | parent_toot = mastodon.status(parent_id) 1174 | except Exception as e: 1175 | cprint("error searching for original: {}".format(type(e).__name__), fg("red")) 1176 | return 1177 | 1178 | # Handle mentions text at the beginning: 1179 | mentions_set = set() 1180 | for i in parent_toot["mentions"]: 1181 | mentions_set.add(i["acct"]) 1182 | mentions_set.add(parent_toot["account"]["acct"]) 1183 | 1184 | # Remove our account 1185 | # TODO: Better way to get this information? 1186 | my_user = mastodon.account_verify_credentials() 1187 | mentions_set.discard(my_user["username"]) 1188 | 1189 | # Format each using @username@host and add a space 1190 | mentions = ["@%s" % i for i in list(mentions_set)] 1191 | mentions = " ".join(mentions) 1192 | 1193 | # if user didn't set cw/spoiler, set it here 1194 | if kwargs["spoiler_text"] is None and parent_toot["spoiler_text"] != "": 1195 | kwargs["spoiler_text"] = parent_toot["spoiler_text"] 1196 | 1197 | kwargs["visibility"] = toot_visibility( 1198 | mastodon, 1199 | flag_visibility=kwargs["visibility"], 1200 | parent_visibility=parent_toot["visibility"], 1201 | ) 1202 | 1203 | while posted is False: 1204 | try: 1205 | reply_toot = mastodon.status_post( 1206 | "%s %s" % (mentions, text), in_reply_to_id=parent_id, **kwargs 1207 | ) 1208 | msg = " Replied with:\n" + get_content(reply_toot) 1209 | cprint(msg, attr("dim")) 1210 | posted = True 1211 | except Exception as e: 1212 | cprint("error while posting: {}".format(type(e).__name__), fg("red")) 1213 | 1214 | if posted is False: 1215 | retry = input("Edit toot and re-try? [Y/N]: ") 1216 | if retry.lower() == "y": 1217 | text = edittoot(text=text) 1218 | else: 1219 | posted = True 1220 | 1221 | 1222 | @command(" [votes]", "Toots") 1223 | def vote(mastodon, rest): 1224 | """Vote your toot by ID 1225 | 1226 | Example: 1227 | >>> vote 23 1 1228 | >>> vote 23 1,2,3 1229 | """ 1230 | try: 1231 | poll_id = None 1232 | toot_id, rest = rest.split(" ", 1) 1233 | global_id = IDS.to_global(toot_id) 1234 | poll = mastodon.status(global_id).get("poll") 1235 | if poll: 1236 | poll_id = poll.get("id") 1237 | if poll_id is None: 1238 | cprint(f" {toot_id} does not point to a valid poll.", fg("red")) 1239 | return 1240 | 1241 | if rest is None: 1242 | cprint("Note has no options.", fg("white") + bg("red")) 1243 | return 1244 | 1245 | vote_options = rest_to_list(rest) 1246 | if len(vote_options) > 1 and not poll.get("multiple"): 1247 | cprint("Too many votes cast.", fg("white") + bg("red")) 1248 | return 1249 | 1250 | mastodon.poll_vote(poll_id, vote_options) 1251 | print("Vote cast.") 1252 | except Exception as e: 1253 | cprint(f" {e}", fg("red")) 1254 | 1255 | 1256 | @command("", "Toots") 1257 | def delete(mastodon, rest): 1258 | """Deletes your toot by ID""" 1259 | rest = IDS.to_global(rest) 1260 | if rest is None: 1261 | return 1262 | mastodon.status_delete(rest) 1263 | print("Poof! It's gone.") 1264 | 1265 | 1266 | @command("", "Toots") 1267 | def boost(mastodon, rest): 1268 | """Boosts a toot by ID.""" 1269 | rest = IDS.to_global(rest) 1270 | if rest is None: 1271 | return 1272 | try: 1273 | mastodon.status_reblog(rest) 1274 | boosted = mastodon.status(rest) 1275 | msg = " You boosted:\n " + fg("white") + get_content(boosted) 1276 | cprint(msg, attr("dim")) 1277 | except Exception as e: 1278 | cprint("Received error: ", fg("red") + attr("bold"), end="") 1279 | cprint(e, fg("magenta") + attr("bold") + attr("underline")) 1280 | 1281 | 1282 | @command("", "Toots") 1283 | def unboost(mastodon, rest): 1284 | """Removes a boosted toot by ID.""" 1285 | rest = IDS.to_global(rest) 1286 | if rest is None: 1287 | return 1288 | mastodon.status_unreblog(rest) 1289 | unboosted = mastodon.status(rest) 1290 | msg = " Removed boost:\n " + get_content(unboosted) 1291 | cprint(msg, attr("dim")) 1292 | 1293 | 1294 | @command(" []", "Toots") 1295 | def fav(mastodon, rest): 1296 | """Favorites a toot by ID or IDs.""" 1297 | favorite_ids = rest_to_list(rest) 1298 | multiple = len(favorite_ids) > 1 1299 | for favorite_id in favorite_ids: 1300 | if favorite_id: 1301 | favorite_global_id = IDS.to_global(favorite_id) 1302 | if favorite_global_id is None: 1303 | cprint( 1304 | f" Can't favorite id {favorite_id}: Not found", 1305 | fg("red") + attr("bold"), 1306 | ) 1307 | next 1308 | faved = mastodon.status_favourite(favorite_global_id) 1309 | msg = f" Favorited ({favorite_id}):\n" + get_content(faved) 1310 | cprint(msg, attr("dim")) 1311 | if multiple: 1312 | print() 1313 | 1314 | 1315 | @command(" []", "Toots") 1316 | def unfav(mastodon, rest): 1317 | """Removes a favorite toot by ID or IDs.""" 1318 | favorite_ids = rest_to_list(rest) 1319 | multiple = len(favorite_ids) > 1 1320 | for favorite_id in favorite_ids: 1321 | if favorite_id: 1322 | favorite_global_id = IDS.to_global(favorite_id) 1323 | if favorite_global_id is None: 1324 | cprint( 1325 | f" Can't unfavorite id {favorite_id}: Not found", 1326 | fg("red") + attr("bold"), 1327 | ) 1328 | next 1329 | unfaved = mastodon.status_unfavourite(favorite_global_id) 1330 | msg = f" Removed favorite ({favorite_id}):\n" + get_content(unfaved) 1331 | cprint(msg, fg("yellow")) 1332 | if multiple: 1333 | print() 1334 | 1335 | 1336 | @command("", "Toots") 1337 | def show(mastodon, rest): 1338 | """Shows a toot by ID""" 1339 | rest = IDS.to_global(rest) 1340 | if rest is None: 1341 | return 1342 | printToot(mastodon.status(rest), show_toot=True) 1343 | 1344 | 1345 | @command("", "Filter") 1346 | def filters(mastodon, rest): 1347 | """Shows the filters that the user has created.""" 1348 | if not (list_support(mastodon)): 1349 | return 1350 | user_filters = mastodon.filters() 1351 | if len(user_filters) == 0: 1352 | cprint("No filters found", fg("red")) 1353 | return 1354 | for filter_item in user_filters: 1355 | printFilter(filter_item) 1356 | 1357 | 1358 | @command("", "Toots") 1359 | def bookmark(mastodon, rest): 1360 | """Bookmark a toot by ID.""" 1361 | rest = IDS.to_global(rest) 1362 | if rest is None: 1363 | return 1364 | mastodon.status_bookmark(rest) 1365 | item = mastodon.status(rest) 1366 | msg = " Bookmarked:\n" + get_content(item) 1367 | cprint(msg, fg("red")) 1368 | 1369 | 1370 | @command("", "Toots") 1371 | def unbookmark(mastodon, rest): 1372 | """Remove a bookmark from a toot by ID.""" 1373 | rest = IDS.to_global(rest) 1374 | if rest is None: 1375 | return 1376 | mastodon.status_unbookmark(rest) 1377 | item = mastodon.status(rest) 1378 | msg = " Removed bookmark: " + get_content(item) 1379 | cprint(msg, fg("yellow")) 1380 | 1381 | 1382 | @command("", "Toots") 1383 | def favthread(mastodon, rest): 1384 | """Favorites an entire thread 1385 | 1386 | ex: favthread 23 """ 1387 | 1388 | rest = IDS.to_global(rest) 1389 | if rest is None: 1390 | return 1391 | 1392 | ids = [] 1393 | 1394 | conversation = mastodon.status_context(rest) 1395 | ancestors = conversation.get('ancestors') 1396 | descendants = conversation.get('descendants') 1397 | 1398 | ancestor_ids = [i.id for i in ancestors] 1399 | descendant_ids = [i.id for i in descendants] 1400 | 1401 | ids = ancestor_ids + [rest] + descendant_ids 1402 | for favorite_global_id in ids: 1403 | faved = mastodon.status_favourite(favorite_global_id) 1404 | favorite_id = IDS.to_local(favorite_global_id) 1405 | msg = f" Favorited ({favorite_id}):\n" + get_content(faved) 1406 | cprint(msg, attr("dim")) 1407 | print() 1408 | 1409 | 1410 | @command("", "Toots") 1411 | def showhistory(mastodon, rest): 1412 | """Shows the history of the conversation for an ID with CWs/ Filters displayed""" 1413 | history(mastodon, rest, show_toot=True) 1414 | 1415 | 1416 | @command("", "Toots") 1417 | def history(mastodon, rest, show_toot=False): 1418 | """Shows the history of the conversation for an ID. 1419 | 1420 | ex: history 23""" 1421 | stepper, rest = step_flag(rest) 1422 | rest = IDS.to_global(rest) 1423 | if rest is None: 1424 | return 1425 | 1426 | try: 1427 | current_toot = mastodon.status(rest) 1428 | conversation = mastodon.status_context(rest) 1429 | print_toots( 1430 | mastodon, 1431 | conversation["ancestors"], 1432 | stepper, 1433 | ctx_name="Previous toots", 1434 | show_toot=show_toot, 1435 | sort_toots=False, 1436 | ) 1437 | 1438 | if stepper is False: 1439 | cprint("Current Toot:", fg("yellow")) 1440 | print_toots( 1441 | mastodon, 1442 | [current_toot], 1443 | stepper, 1444 | ctx_name="Current toot", 1445 | show_toot=show_toot, 1446 | ) 1447 | # printToot(current_toot) 1448 | # completion_add(current_toot) 1449 | except Exception as e: 1450 | cprint("{}: please try again later".format(type(e).__name__), fg("red")) 1451 | 1452 | 1453 | @command("", "Toots") 1454 | def showthread(mastodon, rest): 1455 | """Shows the complete thread of the conversation for an ID while showing CWs / filters. 1456 | 1457 | ex: showthread 23""" 1458 | thread(mastodon, rest, show_toot=True) 1459 | 1460 | 1461 | @command("", "Toots") 1462 | def thread(mastodon, rest, show_toot=False): 1463 | """Shows the complete thread of the conversation for an ID. 1464 | 1465 | ex: thread 23""" 1466 | 1467 | # Save the original "rest" so the history command can use it 1468 | original_rest = rest 1469 | stepper, rest = step_flag(rest) 1470 | 1471 | rest = IDS.to_global(rest) 1472 | if rest is None: 1473 | return 1474 | 1475 | try: 1476 | # First display the history 1477 | history(mastodon, original_rest, show_toot) 1478 | 1479 | # Then display the rest 1480 | # current_toot = mastodon.status(rest) 1481 | conversation = mastodon.status_context(rest) 1482 | print_toots( 1483 | mastodon, 1484 | conversation["descendants"], 1485 | stepper, 1486 | show_toot=show_toot, 1487 | sort_toots=False, 1488 | ) 1489 | 1490 | except Exception as e: 1491 | raise e 1492 | cprint("{}: please try again later".format(type(e).__name__), fg("red")) 1493 | 1494 | 1495 | @command("", "Toots") 1496 | def puburl(mastodon, rest): 1497 | """Shows the public URL of a toot, optionally open in browser. 1498 | 1499 | Example: 1500 | >>> puburl 29 # Shows url for toot 29 1501 | >>> puburl 29 open # Opens toot 29 in your browser 1502 | """ 1503 | 1504 | # replace whitespace sequences with a single space 1505 | args = " ".join(rest.split()) 1506 | args = args.split() 1507 | if len(args) < 1: 1508 | return 1509 | 1510 | status_id = IDS.to_global(args[0]) 1511 | if status_id is None: 1512 | return 1513 | 1514 | try: 1515 | toot = mastodon.status(status_id) 1516 | except Exception as e: 1517 | cprint("{}: please try again later".format(type(e).__name__), fg("red")) 1518 | else: 1519 | url = toot.get("url") 1520 | 1521 | if len(args) == 1: 1522 | # Print public url 1523 | print("{}".format(url)) 1524 | elif len(args) == 2 and args[1] == "open": 1525 | webbrowser.open(url) 1526 | else: 1527 | cprint("PubURL argument was not correct. Please try again.", fg("red")) 1528 | 1529 | 1530 | @command("", "Toots") 1531 | def links(mastodon, rest): 1532 | """Show URLs or any links in a toot, optionally open in browser. 1533 | 1534 | Use `links open` to open all link URLs or `links open ` to 1535 | open a specific link. 1536 | 1537 | Examples: 1538 | >>> links 23 # Shows links for toot 23 1539 | >>> links 23 open # opens all links for toot 23 in your browser 1540 | >>> links 23 open 1 # opens just the first link for toot 23 in your browser 1541 | """ 1542 | 1543 | # replace whitespace sequences with a single space 1544 | args = " ".join(rest.split()) 1545 | args = args.split() 1546 | if len(args) < 1: 1547 | return 1548 | 1549 | status_id = IDS.to_global(args[0]) 1550 | if status_id is None: 1551 | return 1552 | 1553 | try: 1554 | toot = mastodon.status(status_id) 1555 | toot_parser.parse(toot["content"]) 1556 | except Exception as e: 1557 | cprint("{}: please try again later".format(type(e).__name__), fg("red")) 1558 | else: 1559 | links = toot_parser.get_weblinks() 1560 | for media in toot.get("media_attachments"): 1561 | links.append(media.url) 1562 | 1563 | if len(args) == 1: 1564 | # Print links 1565 | for i, link in enumerate(links): 1566 | print("{}: {}".format(i + 1, link)) 1567 | else: 1568 | # Open links 1569 | link_num = None 1570 | if len(args) == 3 and args[1] == "open" and len(args[2]) > 0: 1571 | # Parse requested link number 1572 | link_num = int(args[2]) 1573 | if len(links) < link_num or link_num < 1: 1574 | cprint( 1575 | "Cannot open link {}. Toot contains {} weblinks".format( 1576 | link_num, len(links) 1577 | ), 1578 | fg("red"), 1579 | ) 1580 | else: 1581 | webbrowser.open(links[link_num - 1]) 1582 | 1583 | elif args[1] == "open": 1584 | for link in links: 1585 | webbrowser.open(link) 1586 | else: 1587 | cprint("Links argument was not correct. Please try again.", fg("red")) 1588 | 1589 | 1590 | @command("", "Timeline") 1591 | def home(mastodon, rest): 1592 | """Displays the Home timeline.""" 1593 | global LAST_PAGE, LAST_CONTEXT 1594 | stepper, rest = step_flag(rest) 1595 | limit, rest = limit_flag(rest) 1596 | LAST_PAGE = mastodon.timeline_home(limit=limit) 1597 | LAST_CONTEXT = "home" 1598 | print_toots(mastodon, LAST_PAGE, stepper, limit, ctx_name=LAST_CONTEXT) 1599 | 1600 | 1601 | @command("", "Timeline") 1602 | def fed(mastodon, rest): 1603 | """Displays the Federated timeline.""" 1604 | global LAST_PAGE, LAST_CONTEXT 1605 | stepper, rest = step_flag(rest) 1606 | limit, rest = limit_flag(rest) 1607 | LAST_PAGE = mastodon.timeline_public(limit=limit) 1608 | LAST_CONTEXT = "federated timeline" 1609 | print_toots(mastodon, LAST_PAGE, stepper, limit, ctx_name=LAST_CONTEXT) 1610 | 1611 | 1612 | @command("", "Timeline") 1613 | def local(mastodon, rest): 1614 | """Displays the Local timeline.""" 1615 | global LAST_PAGE, LAST_CONTEXT 1616 | stepper, rest = step_flag(rest) 1617 | limit, rest = limit_flag(rest) 1618 | LAST_PAGE = mastodon.timeline_local(limit=limit) 1619 | LAST_CONTEXT = "local timeline" 1620 | print_toots(mastodon, LAST_PAGE, stepper, limit, ctx_name=LAST_CONTEXT) 1621 | 1622 | 1623 | @command("", "Timeline") 1624 | def next(mastodon, rest): 1625 | """Displays the next page of paginated results.""" 1626 | global LAST_PAGE, LAST_CONTEXT 1627 | curr_page = LAST_PAGE 1628 | stepper, rest = step_flag(rest) 1629 | if LAST_PAGE: 1630 | LAST_PAGE = mastodon.fetch_next(LAST_PAGE) 1631 | if LAST_PAGE: 1632 | print_toots(mastodon, LAST_PAGE, stepper, ctx_name=LAST_CONTEXT) 1633 | return 1634 | else: 1635 | LAST_PAGE = curr_page 1636 | if LAST_CONTEXT: 1637 | cprint( 1638 | "No more toots in current context: " + LAST_CONTEXT, fg("white") + bg("red") 1639 | ) 1640 | else: 1641 | cprint("No current context.", fg("white") + bg("red")) 1642 | 1643 | 1644 | @command("", "Timeline") 1645 | def prev(mastodon, rest): 1646 | """Displays the previous page of paginated results.""" 1647 | global LAST_PAGE, LAST_CONTEXT 1648 | stepper, rest = step_flag(rest) 1649 | curr_page = LAST_PAGE 1650 | if LAST_PAGE: 1651 | LAST_PAGE = mastodon.fetch_previous(LAST_PAGE) 1652 | if LAST_PAGE: 1653 | print_toots(mastodon, LAST_PAGE, stepper, ctx_name=LAST_CONTEXT) 1654 | return 1655 | else: 1656 | LAST_PAGE = curr_page 1657 | if LAST_CONTEXT: 1658 | cprint( 1659 | "No more toots in current context: " + LAST_CONTEXT, fg("white") + bg("red") 1660 | ) 1661 | else: 1662 | cprint("No current context.", fg("white") + bg("red")) 1663 | 1664 | 1665 | @command("", "Timeline") 1666 | def stream(mastodon, rest): 1667 | """Streams a timeline. Specify home, fed, local, list, or a #hashtagname. 1668 | 1669 | Timeline 'list' requires a list name (ex: stream list listname). 1670 | 1671 | Commands may be typed while streaming (ex: fav 23). 1672 | 1673 | Only one stream may be running at a time. 1674 | 1675 | Use ctrl+c to end streaming""" 1676 | 1677 | global is_streaming 1678 | if is_streaming: 1679 | cprint("Already streaming. Press ctrl+c to end this stream.", fg("red")) 1680 | return 1681 | 1682 | cprint("Initializing stream...", style=fg("magenta")) 1683 | 1684 | def say_error(*args, **kwargs): 1685 | cprint( 1686 | "Invalid command. Use 'help' for a list of commands or press ctrl+c to end streaming.", 1687 | fg("white") + bg("red"), 1688 | ) 1689 | 1690 | try: 1691 | if rest == "home" or rest == "": 1692 | handle = mastodon.stream_user( 1693 | toot_listener, run_async=True, reconnect_async=True 1694 | ) 1695 | elif rest == "fed" or rest == "public": 1696 | handle = mastodon.stream_public( 1697 | toot_listener, run_async=True, reconnect_async=True 1698 | ) 1699 | elif rest == "local": 1700 | handle = mastodon.stream_local( 1701 | toot_listener, run_async=True, reconnect_async=True 1702 | ) 1703 | elif rest.startswith("list"): 1704 | # Remove list from the rest string 1705 | items = rest.split("list ") 1706 | if len(items) < 2: 1707 | print("list stream must have a list ID.") 1708 | return 1709 | item = get_list_id(mastodon, items[-1]) 1710 | handle = mastodon.stream_list( 1711 | item, toot_listener, run_async=True, reconnect_async=True 1712 | ) 1713 | elif rest.startswith("#"): 1714 | tag = rest[1:] 1715 | handle = mastodon.stream_hashtag( 1716 | tag, toot_listener, run_async=True, reconnect_async=True 1717 | ) 1718 | else: 1719 | handle = None 1720 | print( 1721 | "Only 'home', 'fed', 'local', 'list', and '#hashtag' streams are supported." 1722 | ) 1723 | except KeyboardInterrupt: 1724 | # Prevent the ^C from interfering with the prompt 1725 | print("\n") 1726 | except KeyError as e: 1727 | if getattr(e, "args", None) == ("urls",): 1728 | cprint( 1729 | "The Mastodon instance is too old for this version of streaming support.", 1730 | fg("red"), 1731 | ) 1732 | else: 1733 | cprint("Something went wrong: {}".format(e), fg("red")) 1734 | except Exception as e: 1735 | cprint("Something went wrong: {}".format(e), fg("red")) 1736 | else: 1737 | print("Use 'help' for a list of commands or press ctrl+c to end streaming.") 1738 | 1739 | if handle is not None: 1740 | is_streaming = True 1741 | command = None 1742 | while command != "abort": 1743 | try: 1744 | command = input().split(" ", 1) 1745 | except KeyboardInterrupt: 1746 | cprint( 1747 | "Wrapping up, this can take a couple of seconds...", 1748 | style=fg("magenta"), 1749 | ) 1750 | command = "abort" 1751 | else: 1752 | try: 1753 | rest_ = command[1] 1754 | except IndexError: 1755 | rest_ = "" 1756 | command = command[0] 1757 | cmd_func = commands.get(command, say_error) 1758 | cmd_func(mastodon, rest_) 1759 | try: 1760 | handle.close() 1761 | except AttributeError: 1762 | handle.running = False 1763 | pass # Trap for handle not getting set if no toots were received while streaming 1764 | is_streaming = False 1765 | 1766 | 1767 | @command("", "Timeline") 1768 | def mentions(mastodon, rest): 1769 | """Displays the Notifications timeline with only mentions 1770 | 1771 | ex: 'mentions'""" 1772 | note(mastodon, "-bfFpru") 1773 | 1774 | 1775 | @command("[]", "Timeline") 1776 | def note(mastodon, rest): 1777 | """Displays the Notifications timeline. 1778 | 1779 | ex: 'note' 1780 | will show all notifications 1781 | 'note -b' 1782 | will show all notifications minus boosts 1783 | 'note -f -F -b -u' (or 'note -fFb') 1784 | will only show mentions 1785 | 1786 | Options: 1787 | -b Filter boosts 1788 | -f Filter favorites 1789 | -F Filter follows 1790 | -m Filter mentions 1791 | -p Filter polls 1792 | -r Filter follow requests 1793 | -u Filter updates""" 1794 | 1795 | displayed_notification = False 1796 | 1797 | # Fill in Content fields first. 1798 | try: 1799 | (text, kwargs) = flaghandler_note(mastodon, rest) 1800 | except KeyboardInterrupt: 1801 | # user abort, return to main prompt 1802 | print("") 1803 | return 1804 | 1805 | notifications = ( 1806 | mastodon.notifications() 1807 | ) # TODO: Check if fetch_remaining should be used here 1808 | if not (len(notifications) > 0): 1809 | cprint("You don't have any notifications yet.", fg("magenta")) 1810 | return 1811 | 1812 | for note in reversed(notifications): 1813 | note_type = note.get("type") 1814 | note_status = note.get("status", {}) 1815 | note_created_at = note_status.get("created_at") 1816 | if note_created_at: 1817 | note_time = " " + stylize(format_time(note_created_at), attr("dim")) 1818 | note_media_attachments = note_status.get("media_attachments") 1819 | display_name = " " + format_display_name( 1820 | note.get("account").get("display_name") 1821 | ) 1822 | username = format_username(note.get("account")) 1823 | note_id = str(note.get("id")) 1824 | 1825 | random.seed(display_name) 1826 | 1827 | # Check if we should even display this note type 1828 | if kwargs[note_type]: 1829 | # Display Note ID 1830 | cprint(" note: " + note_id, fg("magenta")) 1831 | 1832 | # Mentions 1833 | if note_type == "mention": 1834 | displayed_notification = True 1835 | cprint(display_name + username, fg("magenta")) 1836 | print(" " + format_toot_idline(note_status) + " " + note_time) 1837 | cprint(get_content(note_status), attr("bold"), fg("white")) 1838 | print(stylize("", attr("dim"))) 1839 | if note_media_attachments: 1840 | print("\n".join(get_media_attachments(note_status))) 1841 | 1842 | # Follows 1843 | elif note_type == "follow": 1844 | displayed_notification = True 1845 | print(" ", end="") 1846 | cprint(display_name + username + " followed you!", fg("yellow")) 1847 | 1848 | elif note_type == "follow_request": 1849 | displayed_notification = True 1850 | cprint(display_name + username + " sent a follow request", fg("yellow")) 1851 | cprint( 1852 | " Use 'accept' or 'reject' to accept or reject the request", 1853 | fg("yellow"), 1854 | ) 1855 | 1856 | # Update 1857 | elif note_type in ["update", "favourite", "reblog", "poll"]: 1858 | displayed_notification = True 1859 | countsline = format_toot_idline(note_status) 1860 | content = get_content(note_status) 1861 | cprint(display_name + username, fg(random.choice(COLORS)), end="") 1862 | if note_type == "update": 1863 | cprint(f" updated their status:", fg("yellow")) 1864 | elif note_type == "reblog": 1865 | cprint(f" boosted your status:", fg("yellow")) 1866 | elif note_type == "poll": 1867 | cprint(f" ended their poll:", fg("yellow")) 1868 | else: 1869 | cprint(f" favorited your status:", fg("yellow")) 1870 | print(" " + countsline + stylize(note_time, attr("dim"))) 1871 | cprint(content, attr("dim")) 1872 | if getattr(note_status, "poll", None): 1873 | poll = get_poll(note_status) 1874 | cprint(poll, attr("dim")) 1875 | 1876 | print() 1877 | 1878 | if not displayed_notification: 1879 | cprint("No notifications of this type are available.", fg("magenta")) 1880 | 1881 | 1882 | @command("[]", "Timeline") 1883 | def dismiss(mastodon, rest): 1884 | """Dismisses notifications. 1885 | 1886 | ex: dismiss or dismiss 1234567 [890123] 1887 | 1888 | dismiss clears all notifications if no note ID is provided. 1889 | dismiss 1234567 will dismiss note ID 1234567. Dismiss accepts a list of IDs. 1890 | 1891 | The note ID is the id provided by the `note` command. 1892 | """ 1893 | try: 1894 | if rest == "": 1895 | mastodon.notifications_clear() 1896 | cprint(" All notifications were dismissed. ", fg("yellow")) 1897 | else: 1898 | if rest is None: 1899 | return 1900 | dismiss_ids = rest_to_list(rest) 1901 | for dismiss_id in dismiss_ids: 1902 | mastodon.notifications_dismiss(dismiss_id) 1903 | cprint(" Note " + dismiss_id + " was dismissed. ", fg("yellow")) 1904 | except Exception as e: 1905 | cprint("Something went wrong: {}".format(e), fg("red")) 1906 | 1907 | 1908 | @command("", "Users") 1909 | def block(mastodon, rest): 1910 | """Blocks a user by username or id. 1911 | 1912 | ex: block 23 1913 | block @user 1914 | block @user@instance.example.com""" 1915 | userid = get_unique_userid(mastodon, rest) 1916 | relations = mastodon.account_block(userid) 1917 | if relations["blocking"]: 1918 | cprint(" user " + str(userid) + " is now blocked", fg("blue")) 1919 | username = "@" + mastodon.account(userid)["acct"] 1920 | if username in completion_list: 1921 | completion_list.remove(username) 1922 | 1923 | 1924 | @command("", "Users") 1925 | def unblock(mastodon, rest): 1926 | """Unblocks a user by username or id. 1927 | 1928 | ex: unblock 23 1929 | unblock @user@instance.example.com""" 1930 | userid = get_unique_userid(mastodon, rest) 1931 | relations = mastodon.account_unblock(userid) 1932 | if not relations["blocking"]: 1933 | cprint(" user " + str(userid) + " is now unblocked", fg("blue")) 1934 | username = "@" + mastodon.account(userid)["acct"] 1935 | if username not in completion_list: 1936 | bisect.insort(completion_list, username) 1937 | 1938 | 1939 | @command("", "Users") 1940 | def follow(mastodon, rest): 1941 | """Follows an account by username or id. 1942 | 1943 | ex: follow 23 1944 | follow @user@instance.example.com""" 1945 | userid = get_unique_userid(mastodon, rest) 1946 | relations = mastodon.account_follow(userid) 1947 | if relations["following"]: 1948 | cprint(" user " + str(userid) + " is now followed", fg("blue")) 1949 | username = "@" + mastodon.account(userid)["acct"] 1950 | if username not in completion_list: 1951 | bisect.insort(completion_list, username) 1952 | 1953 | 1954 | @command("", "Users") 1955 | def unfollow(mastodon, rest): 1956 | """Unfollows an account by username or id. 1957 | 1958 | ex: unfollow 23 1959 | unfollow @user@instance.example.com""" 1960 | userid = get_unique_userid(mastodon, rest) 1961 | relations = mastodon.account_unfollow(userid) 1962 | if not relations.get("following"): 1963 | cprint(" user " + str(userid) + " is now unfollowed", fg("blue")) 1964 | username = "@" + mastodon.account(userid)["acct"] 1965 | if username in completion_list: 1966 | completion_list.remove(username) 1967 | 1968 | 1969 | @command(" []", "Users") 1970 | def mute(mastodon, rest): 1971 | """Mutes a user by username or id. 1972 | 1973 | ex: mute 23 1974 | mute @user@instance.example.com 1975 | mute @user 30s""" 1976 | mute_time = None 1977 | mute_seconds = None 1978 | if " " in rest: 1979 | username, mute_time = rest.split(" ") 1980 | else: 1981 | username = rest 1982 | if mute_time: 1983 | mute_seconds = pytimeparse.parse(mute_time) 1984 | userid = get_unique_userid(mastodon, username) 1985 | relations = mastodon.account_mute(userid, duration=mute_seconds) 1986 | if relations.get("muting"): 1987 | if mute_seconds: 1988 | cprint(" user " + username + " is now muted for " + mute_time, fg("blue")) 1989 | else: 1990 | cprint(" user " + username + " is now muted", fg("blue")) 1991 | 1992 | 1993 | @command("", "Users") 1994 | def unmute(mastodon, rest): 1995 | """Unmutes a user by username or id. 1996 | 1997 | ex: unmute 23 1998 | unmute @user@instance.example.com""" 1999 | userid = get_unique_userid(mastodon, rest) 2000 | relations = mastodon.account_unmute(userid) 2001 | username = rest 2002 | if not relations["muting"]: 2003 | cprint(" user " + username + " is now unmuted", fg("blue")) 2004 | 2005 | 2006 | @command("", "Discover") 2007 | def search(mastodon, rest): 2008 | """Search for a #tag or @user. 2009 | 2010 | ex: search #tagname 2011 | search @user 2012 | search @user@instance.example.com""" 2013 | global LAST_PAGE, LAST_CONTEXT 2014 | usage = str(" usage: search #tagname\n" + " search @username") 2015 | stepper, rest = step_flag(rest) 2016 | limit, rest = rest_limit(rest) 2017 | try: 2018 | indicator = rest[:1] 2019 | query = rest[1:] 2020 | except Exception: 2021 | cprint(usage, fg("red")) 2022 | return 2023 | 2024 | # @ user search 2025 | if indicator == "@" and not query == "": 2026 | users = mastodon.account_search(query, limit=limit) 2027 | 2028 | for user in users: 2029 | printUser(user) 2030 | # end @ 2031 | 2032 | # # hashtag search 2033 | elif indicator == "#" and not query == "": 2034 | LAST_PAGE = mastodon.timeline_hashtag(query, limit=limit) 2035 | LAST_CONTEXT = "search for #{}".format(query) 2036 | print_toots( 2037 | mastodon, LAST_PAGE, stepper, ctx_name=LAST_CONTEXT, add_completion=False 2038 | ) 2039 | # end # 2040 | 2041 | else: 2042 | raise ValueError(" Invalid format.\n" + usage) 2043 | return 2044 | 2045 | 2046 | @command(" []", "Discover") 2047 | def user(mastodon, rest): 2048 | """Displays profile information for another user 2049 | 2050 | : a userID, @username, or @user@instance 2051 | 2052 | ex: user 23 2053 | user @user 2054 | user @user@instance.example.com""" 2055 | userid = get_unique_userid(mastodon, rest, exact=False) 2056 | profile = mastodon.account(userid) 2057 | if profile: 2058 | printUser(profile) 2059 | return 2060 | raise Exception("user {rest} not found") 2061 | 2062 | 2063 | @command(" []", "Discover") 2064 | def view(mastodon, rest): 2065 | """Displays toots from another user. 2066 | 2067 | : a userID, @username, or @user@instance 2068 | : (optional) show N toots maximum 2069 | 2070 | ex: view 23 2071 | view @user 10 2072 | view @user@instance.example.com""" 2073 | global LAST_PAGE, LAST_CONTEXT 2074 | (user, _, count) = rest.partition(" ") 2075 | 2076 | # validate count argument 2077 | if not count: 2078 | count = None 2079 | else: 2080 | try: 2081 | count = int(count) 2082 | except ValueError: 2083 | raise ValueError(" invalid count: {count}") 2084 | 2085 | userid = get_unique_userid(mastodon, user, exact=False) 2086 | LAST_PAGE = mastodon.account_statuses(userid, limit=count) 2087 | LAST_CONTEXT = f"{user} timeline" 2088 | print_toots(mastodon, LAST_PAGE, ctx_name=LAST_CONTEXT, add_completion=False) 2089 | 2090 | 2091 | @command("", "Profile") 2092 | def info(mastodon, rest): 2093 | """Prints your user info.""" 2094 | user = mastodon.account_verify_credentials() 2095 | printUser(user) 2096 | 2097 | 2098 | @command("", "Profile") 2099 | def followers(mastodon, rest): 2100 | """Lists users who follow you.""" 2101 | # TODO: compare user['followers_count'] to len(users) 2102 | # request more from server if first call doesn't get full list 2103 | # TODO: optional username/userid to show another user's followers? 2104 | user = mastodon.account_verify_credentials() 2105 | limit, rest = limit_flag(rest) 2106 | users = mastodon.fetch_remaining(mastodon.account_followers(user["id"], limit=limit)) 2107 | if not users: 2108 | cprint(" Nobody follows you", fg("red")) 2109 | else: 2110 | cprint(" People who follow you ({}):".format(len(users)), fg("magenta")) 2111 | printUsersShort(users) 2112 | 2113 | 2114 | @command("", "Profile") 2115 | def following(mastodon, rest): 2116 | """Lists users you follow.""" 2117 | # TODO: compare user['following_count'] to len(users) 2118 | # request more from server if first call doesn't get full list 2119 | # TODO: optional username/userid to show another user's following? 2120 | user = mastodon.account_verify_credentials() 2121 | limit, rest = limit_flag(rest) 2122 | users = mastodon.fetch_remaining(mastodon.account_following(user["id"], limit=limit)) 2123 | if not users: 2124 | cprint(" You aren't following anyone", fg("red")) 2125 | else: 2126 | cprint(" People you follow ({}):".format(len(users)), fg("magenta")) 2127 | printUsersShort(users) 2128 | 2129 | 2130 | @command("", "Profile") 2131 | def blocks(mastodon, rest): 2132 | """Lists users you have blocked.""" 2133 | limit, rest = limit_flag(rest) 2134 | users = mastodon.fetch_remaining(mastodon.blocks(limit=limit)) 2135 | if not users: 2136 | cprint(" You haven't blocked anyone (... yet)", fg("red")) 2137 | else: 2138 | cprint(" You have blocked:", fg("magenta")) 2139 | printUsersShort(users) 2140 | 2141 | 2142 | @command("", "Profile") 2143 | def domainblocks(mastodon, rest): 2144 | """Lists domains you have blocked.""" 2145 | limit, rest = limit_flag(rest) 2146 | domains = mastodon.fetch_remaining(mastodon.domain_blocks(limit=limit)) 2147 | if not domains: 2148 | cprint(" You haven't blocked any domains (... yet)", fg("red")) 2149 | else: 2150 | cprint(" You have blocked:", fg("magenta")) 2151 | for domain in domains: 2152 | cprint(" " + domain, fg('cyan')) 2153 | 2154 | 2155 | @command("", "Profile") 2156 | def mutes(mastodon, rest): 2157 | """Lists users you have muted.""" 2158 | limit, rest = limit_flag(rest) 2159 | users = mastodon.fetch_remaining(mastodon.mutes(limit=limit)) 2160 | if not users: 2161 | cprint(" You haven't muted anyone (... yet)", fg("red")) 2162 | else: 2163 | cprint(" You have muted:", fg("magenta")) 2164 | printUsersShort(users) 2165 | 2166 | 2167 | @command("", "Profile") 2168 | def requests(mastodon, rest): 2169 | """Lists your incoming follow requests. 2170 | 2171 | Run 'accept id' to accept a request 2172 | or 'reject id' to reject.""" 2173 | users = mastodon.fetch_remaining(mastodon.follow_requests()) 2174 | if not users: 2175 | cprint(" You have no incoming requests", fg("red")) 2176 | else: 2177 | cprint(" These users want to follow you:", fg("magenta")) 2178 | printUsersShort(users) 2179 | cprint(" run 'accept ' to accept", fg("magenta")) 2180 | cprint(" or 'reject ' to reject", fg("magenta")) 2181 | 2182 | 2183 | @command("", "Profile") 2184 | def accept(mastodon, rest): 2185 | """Accepts a user's follow request by username or id. 2186 | 2187 | ex: accept 23 2188 | accept @user@instance.example.com""" 2189 | userid = get_unique_userid(mastodon, rest) 2190 | mastodon.follow_request_authorize(userid) 2191 | cprint(f" user {rest}'s follow request is accepted", fg("blue")) 2192 | 2193 | 2194 | @command("", "Profile") 2195 | def reject(mastodon, rest): 2196 | """Rejects a user's follow request by username or id. 2197 | 2198 | ex: reject 23 2199 | reject @user@instance.example.com""" 2200 | userid = get_unique_userid(mastodon, rest) 2201 | mastodon.follow_request_reject(userid) 2202 | cprint(f" user {rest}'s follow request is rejected", fg("blue")) 2203 | 2204 | 2205 | @command("", "Profile") 2206 | def faves(mastodon, rest): 2207 | """Displays posts you've favourited.""" 2208 | print_toots( 2209 | mastodon, mastodon.favourites(), ctx_name="favourites", add_completion=False 2210 | ) 2211 | 2212 | 2213 | @command("", "Profile") 2214 | def bookmarks(mastodon, rest): 2215 | """Displays posts you've bookmarked.""" 2216 | print_toots( 2217 | mastodon, mastodon.bookmarks(), ctx_name="bookmarks", add_completion=False 2218 | ) 2219 | 2220 | 2221 | @command("[]", "Profile") 2222 | def me(mastodon, rest): 2223 | """Displays toots you've tooted. 2224 | 2225 | : (optional) show N toots maximum""" 2226 | itme = mastodon.account_verify_credentials() 2227 | # no specific API for user's own timeline 2228 | # let view() do the work 2229 | view(mastodon, "{} {}".format(itme["id"], rest)) 2230 | 2231 | 2232 | me.__section__ = "Profile" 2233 | 2234 | 2235 | @command("", "Profile") 2236 | def about(mastodon, rest): 2237 | """Shows version information and connected instance""" 2238 | print("Tootstream version: %s" % version) 2239 | print("You are connected to ", end="") 2240 | cprint(mastodon.api_base_url, fg("green") + attr("bold")) 2241 | 2242 | 2243 | @command("", "Profile") 2244 | def quit(mastodon, rest): 2245 | """Ends the program.""" 2246 | sys.exit("Goodbye!") 2247 | 2248 | 2249 | @command("", "Profile") 2250 | def exit(mastodon, rest): 2251 | """Ends the program.""" 2252 | sys.exit("Goodbye!") 2253 | 2254 | 2255 | @command("", "List") 2256 | def lists(mastodon, rest): 2257 | """Shows the lists that the user has created.""" 2258 | if not (list_support(mastodon)): 2259 | return 2260 | user_lists = mastodon.lists() 2261 | if len(user_lists) == 0: 2262 | cprint("No lists found", fg("red")) 2263 | return 2264 | for list_item in user_lists: 2265 | printList(list_item) 2266 | 2267 | 2268 | @command("", "List") 2269 | def listcreate(mastodon, rest): 2270 | """Creates a list.""" 2271 | if not (list_support(mastodon)): 2272 | return 2273 | mastodon.list_create(rest) 2274 | cprint("List {} created.".format(rest), fg("green")) 2275 | 2276 | 2277 | @command(" ", "List") 2278 | def listrename(mastodon, rest): 2279 | """Rename a list. 2280 | ex: listrename oldlist newlist""" 2281 | if not (list_support(mastodon)): 2282 | return 2283 | rest = rest.strip() 2284 | if not rest: 2285 | cprint("Argument required.", fg("red")) 2286 | return 2287 | items = rest.split(" ") 2288 | if len(items) < 2: 2289 | cprint("Not enough arguments.", fg("red")) 2290 | return 2291 | 2292 | list_id = get_list_id(mastodon, items[0]) 2293 | updated_name = items[1] 2294 | 2295 | mastodon.list_update(list_id, updated_name) 2296 | cprint("Renamed {} to {}.".format(items[1], items[0]), fg("green")) 2297 | 2298 | 2299 | @command("", "List") 2300 | def listdestroy(mastodon, rest): 2301 | """Destroys a list. 2302 | ex: listdestroy listname 2303 | listdestroy 23""" 2304 | if not (list_support(mastodon)): 2305 | return 2306 | item = get_list_id(mastodon, rest) 2307 | 2308 | mastodon.list_delete(item) 2309 | cprint("List {} deleted.".format(rest), fg("green")) 2310 | 2311 | 2312 | @command("", "List") 2313 | def listhome(mastodon, rest): 2314 | """Show the toots from a list. 2315 | ex: listhome listname 2316 | listhome 23""" 2317 | global LAST_PAGE, LAST_CONTEXT 2318 | if not (list_support(mastodon)): 2319 | return 2320 | if not rest: 2321 | cprint("Argument required.", fg("red")) 2322 | return 2323 | stepper, rest = step_flag(rest) 2324 | limit, list_name = rest_limit(rest) 2325 | item = get_list_id(mastodon, list_name) 2326 | LAST_PAGE = mastodon.timeline_list(item, limit=limit) 2327 | LAST_CONTEXT = f"list ({list_name})" 2328 | print_toots(mastodon, LAST_PAGE, stepper, limit, ctx_name=LAST_CONTEXT) 2329 | 2330 | 2331 | @command("", "List") 2332 | def listaccounts(mastodon, rest): 2333 | """Show the accounts for the list. 2334 | ex: listaccounts listname 2335 | listaccounts 23""" 2336 | if not (list_support(mastodon)): 2337 | return 2338 | item = get_list_id(mastodon, rest) 2339 | list_accounts = mastodon.fetch_remaining(mastodon.list_accounts(item)) 2340 | 2341 | cprint("List: %s" % rest, fg("green")) 2342 | for user in list_accounts: 2343 | username = "@" + user.get("acct") 2344 | if username not in completion_list: 2345 | bisect.insort(completion_list, username) 2346 | printUser(user) 2347 | 2348 | 2349 | @command(" ", "List") 2350 | def listadd(mastodon, rest): 2351 | """Add user to list. 2352 | ex: listadd listname @user@instance.example.com 2353 | listadd 23 @user@instance.example.com""" 2354 | if not (list_support(mastodon)): 2355 | return 2356 | if not rest: 2357 | cprint("Argument required.", fg("red")) 2358 | return 2359 | items = rest.split(" ") 2360 | if len(items) < 2: 2361 | cprint("Not enough arguments.", fg("red")) 2362 | return 2363 | 2364 | list_id = get_list_id(mastodon, items[0]) 2365 | account_id = get_unique_userid(mastodon, items[1]) 2366 | mastodon.list_accounts_add(list_id, account_id) 2367 | cprint("Added {} to list {}.".format(items[1], items[0]), fg("green")) 2368 | 2369 | 2370 | @command(" ", "List") 2371 | def listremove(mastodon, rest): 2372 | """Remove user from list. 2373 | ex: listremove list user@instance.example.com 2374 | listremove 23 user@instance.example.com 2375 | listremove 23 42""" 2376 | if not (list_support(mastodon)): 2377 | return 2378 | if not rest: 2379 | cprint("Argument required.", fg("red")) 2380 | return 2381 | items = rest.split(" ") 2382 | if len(items) < 2: 2383 | cprint("Not enough arguments.", fg("red")) 2384 | return 2385 | 2386 | list_id = get_list_id(mastodon, items[0]) 2387 | account_id = get_unique_userid(mastodon, items[1]) 2388 | mastodon.list_accounts_delete(list_id, account_id) 2389 | cprint("Removed {} from list {}.".format(items[1], items[0]), fg("green")) 2390 | 2391 | 2392 | ##################################### 2393 | ######### END COMMAND BLOCK ######### 2394 | ##################################### 2395 | 2396 | 2397 | def authenticated(mastodon): 2398 | if not os.path.isfile(APP_CRED): 2399 | return False 2400 | if mastodon.account_verify_credentials().get("error"): 2401 | return False 2402 | return True 2403 | 2404 | 2405 | def get_mastodon(instance, config, profile): 2406 | configpath = os.path.expanduser(config) 2407 | if os.path.isfile(configpath) and not os.access(configpath, os.W_OK): 2408 | # warn the user before they're asked for input 2409 | cprint( 2410 | "Config file does not appear to be writable: {}".format(configpath), 2411 | fg("red"), 2412 | ) 2413 | 2414 | config = parse_config(configpath) 2415 | 2416 | # make sure profile name is legal 2417 | profile = re.sub(r"\s+", "", profile) # disallow whitespace 2418 | profile = profile.lower() # force to lowercase 2419 | if profile == "" or profile in RESERVED: 2420 | cprint("Invalid profile name: {}".format(profile), fg("red")) 2421 | sys.exit(1) 2422 | 2423 | if not config.has_section(profile): 2424 | config.add_section(profile) 2425 | 2426 | instance, client_id, client_secret, token = get_or_input_profile( 2427 | config, profile, instance 2428 | ) 2429 | 2430 | if not token: 2431 | cprint("Could not log you in. Please try again later.", fg("red")) 2432 | sys.exit(1) 2433 | 2434 | mastodon = Mastodon( 2435 | client_id=client_id, 2436 | client_secret=client_secret, 2437 | access_token=token, 2438 | api_base_url="https://" + instance, 2439 | ) 2440 | 2441 | # update config before writing 2442 | if "token" not in config[profile]: 2443 | config[profile] = { 2444 | "instance": instance, 2445 | "client_id": client_id, 2446 | "client_secret": client_secret, 2447 | "token": token, 2448 | } 2449 | 2450 | save_config(configpath, config) 2451 | return (mastodon, profile) 2452 | 2453 | 2454 | CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) 2455 | 2456 | 2457 | @click.command(context_settings=CONTEXT_SETTINGS) 2458 | @click.option( 2459 | "--instance", "-i", metavar="", help="Hostname of the instance to connect" 2460 | ) 2461 | @click.option( 2462 | "--config", 2463 | "-c", 2464 | metavar="", 2465 | type=click.Path(exists=False, readable=True), 2466 | default="~/.config/tootstream/tootstream.conf", 2467 | help="Location of alternate configuration file to load", 2468 | ) 2469 | @click.option( 2470 | "--profile", 2471 | "-P", 2472 | metavar="", 2473 | default="default", 2474 | help="Name of profile for saved credentials (default)", 2475 | ) 2476 | def main(instance, config, profile): 2477 | mastodon, profile = get_mastodon(instance, config, profile) 2478 | 2479 | def say_error(a, b): 2480 | return cprint( 2481 | "Invalid command. Use 'help' for a list of commands.", 2482 | fg("white") + bg("red"), 2483 | ) 2484 | 2485 | about(mastodon, "") 2486 | 2487 | print("Enter a command. Use 'help' for a list of commands.") 2488 | print("\n") 2489 | 2490 | user = mastodon.account_verify_credentials() 2491 | username = str(user.get("username")) 2492 | prompt = update_prompt(username=username, context=LAST_CONTEXT, profile=profile) 2493 | 2494 | # Completion setup stuff 2495 | if list_support(mastodon, silent=True): 2496 | for i in mastodon.lists(): 2497 | bisect.insort(completion_list, i["title"].lower()) 2498 | 2499 | for i in mastodon.account_following(user["id"], limit=80): 2500 | bisect.insort(completion_list, "@" + i["acct"]) 2501 | readline.set_completer(complete) 2502 | readline.parse_and_bind("tab: complete") 2503 | readline.set_completer_delims(" ") 2504 | 2505 | while True: 2506 | command = input(prompt).split(" ", 1) 2507 | rest = "" 2508 | try: 2509 | rest = command[1] 2510 | except IndexError: 2511 | pass 2512 | try: 2513 | command = command[0] 2514 | cmd_func = commands.get(command, say_error) 2515 | cmd_func(mastodon, rest) 2516 | except AlreadyPrintedException: 2517 | pass 2518 | except Exception as e: 2519 | cprint(e, fg("red")) 2520 | prompt = update_prompt(username=username, context=LAST_CONTEXT, profile=profile) 2521 | 2522 | 2523 | if __name__ == "__main__": 2524 | main() 2525 | -------------------------------------------------------------------------------- /src/tootstream/toot_parser.py: -------------------------------------------------------------------------------- 1 | import emoji 2 | from colored import attr 3 | from html.parser import HTMLParser 4 | from textwrap import TextWrapper 5 | 6 | 7 | def unique(sequence): 8 | seen = set() 9 | return [x for x in sequence if not (x in seen or seen.add(x))] 10 | 11 | 12 | def emoji_shortcode_to_unicode(text): 13 | """Convert standard emoji short codes to unicode emoji in 14 | the provided text. 15 | 16 | text - The text to parse. 17 | Returns the modified text. 18 | """ 19 | return emoji.emojize(text, use_aliases=True) 20 | 21 | 22 | def emoji_unicode_to_shortcodes(text): 23 | """Convert unicode emoji to standard emoji short codes.""" 24 | return emoji.demojize(text) 25 | 26 | 27 | def find_attr(name, attrs): 28 | """Find an attribute in an HTML tag by name. 29 | 30 | name - The attribute name to search for. 31 | attrs - The list of attributes to search. 32 | Returns the matching attribute or None. 33 | """ 34 | for attr, values in attrs: 35 | if attr == name: 36 | return values 37 | return None 38 | 39 | 40 | def has_class(value, attrs): 41 | """Return whether the HTML attributes contain a specific class name. 42 | 43 | value - The class type to search for. 44 | attrs - The list of attributes to search. 45 | Returns true if the specified class type was found. 46 | """ 47 | values = find_attr("class", attrs) 48 | if values is None: 49 | return False 50 | 51 | return values.find(value) >= 0 52 | 53 | 54 | class TootParser(HTMLParser): 55 | """ 56 | TootParser is used to parse HTML based toots and convert them into 57 | plain text versions. By default the returned text is equivalent to the 58 | source toot text with paragraph and br tags converted to line breaks. 59 | 60 | The text can optionally be indented by passing a string to the indent 61 | field which is prepended to every line in the source text. 62 | 63 | The text can also have text wrapping enabled by passing in a max width to 64 | the width parameter. Note that the text wrapping is not perfect right 65 | now and doesn't work well with terminal colors and a lot of unicode text 66 | on one line. 67 | 68 | Link shortening can be enabled by setting the shorten_links parameter. 69 | This shortens links by using the link shortening helper HTML embedded in 70 | the source toot. This means links embedded from sources other than 71 | mastodon may not be shortened. The shortened urls will look like 72 | example.org/areallylongur... 73 | 74 | Emoji short codes can optionally be converted into unicode based emoji by 75 | enabling the convert_emoji parameter. This parses standard emoji short 76 | code names and does not support custom emojo short codes. 77 | 78 | Styles can also optionally be applied to links found in the source text. 79 | Pass in the desired colored style to the link_style, mention_style, and 80 | hashtag_style parameters. 81 | 82 | To parse a toot, pass the toot source HTML to the parse() command. The 83 | source text can then be retrieved with the get_text() command. Parsed 84 | link urls can also be retrieved by calling the get_links() command. 85 | 86 | indent - A string to prepend to all lines in the output text. 87 | width - The maximum number of characters to allow in a line of text. 88 | shorten_links - Whether or not to shorten links. 89 | convert_emoji_to_unicode - Whether or not to convert emoji short codes to unicode. 90 | convert_emoji_to_shortcode - Whether or not to convert emoji unicode to short codes unicode. 91 | link_style - The colored style to apply to generic links. 92 | mention_style - The colored style to apply to mentions. 93 | hashtag_style - The colored style to apply to hashtags. 94 | 95 | """ 96 | 97 | def __init__( 98 | self, 99 | indent="", 100 | width=0, 101 | convert_emoji_to_unicode=False, 102 | convert_emoji_to_shortcode=False, 103 | shorten_links=False, 104 | link_style=None, 105 | mention_style=None, 106 | hashtag_style=None, 107 | ): 108 | 109 | super().__init__() 110 | self.reset() 111 | self.strict = False 112 | self.convert_charrefs = True 113 | 114 | self.indent = indent 115 | self.convert_emoji_to_unicode = convert_emoji_to_unicode 116 | self.convert_emoji_to_shortcode = convert_emoji_to_shortcode 117 | self.shorten_links = shorten_links 118 | self.link_style = link_style 119 | self.mention_style = mention_style 120 | self.hashtag_style = hashtag_style 121 | 122 | if width > 0: 123 | self.wrap = TextWrapper() 124 | self.wrap.initial_indent = indent 125 | self.wrap.subsequent_indent = indent 126 | self.wrap.width = width 127 | else: 128 | self.wrap = None 129 | 130 | def reset(self): 131 | """Resets the parser so a new toot can be parsed.""" 132 | super().reset() 133 | self.fed = [] 134 | self.lines = [] 135 | self.links = [] 136 | self.weblinks = [] 137 | self.cur_type = None 138 | self.hide = False 139 | self.ellipsis = False 140 | 141 | def pop_line(self): 142 | """Take the current text scratchpad and return it as a 143 | line of text and reset the scratchpad.""" 144 | line = "".join(self.fed) 145 | self.fed = [] 146 | return line 147 | 148 | def handle_data(self, data): 149 | """Processes plain text data. 150 | data - The text to process 151 | """ 152 | if self.hide: 153 | return 154 | 155 | if self.convert_emoji_to_unicode: 156 | data = emoji_shortcode_to_unicode(data) 157 | 158 | if self.convert_emoji_to_shortcode: 159 | data = emoji_unicode_to_shortcodes(data) 160 | 161 | self.fed.append(data) 162 | 163 | def parse_link(self, attrs): 164 | """Processes a link tag. 165 | attrs - A list of attributes contained in the link tag. 166 | """ 167 | 168 | # Save the link url 169 | self.links.append(find_attr("href", attrs)) 170 | 171 | if has_class("hashtag", attrs): 172 | self.cur_type = "hashtag" 173 | if self.hashtag_style != None: 174 | self.fed.append(self.hashtag_style) 175 | elif has_class("mention", attrs): 176 | self.cur_type = "mention" 177 | if self.mention_style != None: 178 | self.fed.append(self.mention_style) 179 | else: 180 | self.weblinks.append(find_attr("href", attrs)) 181 | self.cur_type = "link" 182 | if self.link_style != None: 183 | self.fed.append(self.link_style) 184 | 185 | def parse_span(self, attrs): 186 | """Processes a span tag. 187 | attrs - A list of attributes contained in the span tag. 188 | """ 189 | 190 | # Right now we only support spans used to shorten links. 191 | # Mastodon links contain