├── requirements.txt ├── mypy.ini ├── config.py.example ├── .gitignore ├── README.org └── telegram2org.py /requirements.txt: -------------------------------------------------------------------------------- 1 | telethon 2 | orger 3 | pytz 4 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | namespace_packages = True 3 | pretty = True 4 | show_error_context = True 5 | show_error_codes = True 6 | show_column_numbers = True 7 | show_error_end = True 8 | warn_unused_ignores = True 9 | check_untyped_defs = True 10 | enable_error_code = possibly-undefined 11 | strict_equality = True 12 | 13 | # an example of suppressing 14 | # [mypy-my.config.repos.pdfannots.pdfannots] 15 | # ignore_errors = True 16 | -------------------------------------------------------------------------------- /config.py.example: -------------------------------------------------------------------------------- 1 | # copy this file into config.py and adjust the values as needed 2 | 3 | # see Telethon setup for these https://telethon.readthedocs.io/en/latest/ 4 | TG_APP_ID = 12345 5 | TG_APP_HASH = "g3ergegg" 6 | # especially useful in cron, where you can't enter the password 7 | TELETHON_SESSION = "/path/to/telethon/session/session.session" 8 | 9 | 10 | # private group you will forward messages into 11 | GROUP_NAME = 'Todos' 12 | 13 | 14 | # file tags for org file; can be None if you don't want a tag 15 | ORG_TAG = "telegram2org" 16 | 17 | 18 | # org mode uses local timezone and telegram uses UTC, so we have to specify it 19 | TIMEZONE = 'Europe/London' 20 | 21 | # optional mapping from contact usernames to org-mode tags 22 | NAME_TO_TAG = {} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | .hypothesis/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # dotenv 87 | .env 88 | 89 | # virtualenv 90 | .venv 91 | venv/ 92 | ENV/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # End of https://www.gitignore.io/api/python 105 | 106 | config.py 107 | rtm_tokens.py 108 | state 109 | -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | # -*- org-confirm-babel-evaluate: nil; -*- 2 | 3 | * Description 4 | 5 | #+begin_src python :exports results :results output drawer replace :python "with_secrets python3" 6 | import telegram2org; print(telegram2org.__doc__) 7 | #+end_src 8 | 9 | #+name: telegram2org_doc 10 | #+RESULTS: 11 | :results: 12 | 13 | Imagine a friend asked you for something, or sent you a link or a video, but you don't have time to process that right at the moment. 14 | 15 | Normally I'd share their message to my TODO list app so I can process it later. 16 | However, official Android app for Telegram doesn't have sharing capabilities. 17 | 18 | This is a tool that allows you to overcome this restriction by forwarding messages you want to 19 | remember about to a special private channel. Then it grabs the messages from this private channel and creates TODO items from it! 20 | 21 | That way you keep your focus while not being mean ignoring your friends' messages. 22 | 23 | :end: 24 | 25 | 26 | * Setting up 27 | 28 | ** Setup for telegram2org 29 | #+begin_src bash 30 | # Install dependencies 31 | pip install -r requirements.txt 32 | 33 | # Configure 34 | cp config.py.example config.py 35 | edit config.py # follow comments in the file to set up 36 | #+end_src 37 | 38 | ** Creating your "Todos" empty group 39 | Telegram requires you to add at least one person in a new group. To satisfy this requirement and also avoid spamming a random contact, you can [[https://www.reddit.com/r/Telegram/comments/l4p5me/how_to_create_a_groupchat_without_adding_any/][add a bot instead]]. 40 | 41 | I don't know of any no-op bots around waiting to be added, but you can create your own bot. I'll summarize the process, based on the [[https://core.telegram.org/bots#3-how-do-i-create-a-bot][bot documentation]] page: 42 | - open a chat with [[https://t.me/botfather][@BotFather]] 43 | - send it these two simple commands 44 | #+begin_src 45 | # Start the bot creation flow 46 | /start 47 | 48 | # Create a new bot 49 | /newbot 50 | 51 | # Enter a name for your bot, ending in "...Bot" and you're done. 52 | # Here's a python username gen: pip install random-username; random_username 53 | #+end_src 54 | 55 | Now you can create your group by inviting your newborn bot. You can also remove the bot from the group after this process. 56 | 57 | * Running 58 | #+begin_src bash 59 | ./telegram2org.py 60 | # Please enter your phone (or bot token): +1123567890 61 | # Please enter the code you received: 12345 62 | #+end_src 63 | 64 | The telegram client will ask for your phone number. After you enter the number, you'll receive a verification code in your Telegram app. Use that code in the second prompt. 65 | 66 | * Dependencies 67 | - [[https://telethon.readthedocs.io/en/latest][Telethon]] as Telegram clinet 68 | - [[https://github.com/karlicoss/orger][Orger]] for rendering 69 | -------------------------------------------------------------------------------- /telegram2org.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Imagine a friend asked you for something, or sent you a link or a video, but you don't have time to process that right at the moment. 4 | 5 | Normally I'd share their message to my TODO list app so I can process it later. 6 | However, official Android app for Telegram doesn't have sharing capabilities. 7 | 8 | This is a tool that allows you to overcome this restriction by forwarding messages you want to 9 | remember about to a special private channel. Then it grabs the messages from this private channel and creates TODO items from it! 10 | 11 | That way you keep your focus while not being mean ignoring your friends' messages. 12 | """ 13 | from __future__ import annotations 14 | 15 | import logging 16 | import re 17 | from datetime import datetime 18 | from itertools import groupby 19 | 20 | import pytz 21 | import telethon 22 | 23 | # telethon.sync is necessary to prevent using async api 24 | import telethon.sync # type: ignore[import-untyped] 25 | from orger import InteractiveView 26 | from orger.common import todo 27 | from orger.inorganic import link 28 | from telethon.tl.types import ( # type: ignore[import-untyped] 29 | InputMessagesFilterPinned, 30 | MessageMediaDocument, 31 | MessageMediaPhoto, 32 | MessageMediaVenue, 33 | MessageMediaWebPage, 34 | MessageService, 35 | WebPageEmpty, 36 | WebPagePending, 37 | ) 38 | 39 | from config import ( 40 | GROUP_NAME, 41 | NAME_TO_TAG, 42 | ORG_TAG, 43 | TELETHON_SESSION, 44 | TG_APP_HASH, 45 | TG_APP_ID, 46 | TIMEZONE, 47 | ) 48 | 49 | Timestamp = int 50 | From = str 51 | Lines = list[str] 52 | Tags = set[str] 53 | 54 | 55 | def format_group(group: list, logger) -> tuple[Timestamp, From, Tags, Lines]: 56 | date = int(group[0].date.timestamp()) 57 | 58 | def get_from(m) -> str: 59 | chat = m.get_chat() 60 | is_special_group = getattr(chat, 'title', None) == GROUP_NAME 61 | 62 | if is_special_group: 63 | fw = m.forward 64 | if fw is None: 65 | # this is just a message typed manually into the special chat 66 | return 'me' 67 | 68 | if fw.sender is None: 69 | if fw.chat is not None: 70 | return fw.chat.title 71 | else: 72 | return "ERROR UNKNOWN SENDER" 73 | u = fw.sender 74 | else: 75 | u = m.sender 76 | 77 | if u.username is not None: 78 | return u.username 79 | else: 80 | return f"{u.first_name} {u.last_name}" 81 | 82 | froms = [get_from(m) for m in group] 83 | tags = {NAME_TO_TAG[f] for f in froms if f in NAME_TO_TAG} 84 | 85 | from_ = ', '.join(link(url=f'https://t.me/{f}', title=f) for f in sorted(set(froms))) 86 | 87 | texts: list[str] = [] 88 | for m in group: 89 | texts.append(m.message) 90 | # TODO hmm, _text contains markdown? could convert it to org... 91 | # TODO use m.entities?? 92 | if m.media is None: 93 | continue 94 | e = m.media 95 | if isinstance(e, MessageMediaWebPage): 96 | page = e.webpage 97 | uu: str 98 | if isinstance(page, WebPageEmpty): 99 | uu = "*empty web page*" 100 | elif isinstance(page, WebPagePending): 101 | # doesn't have title/url 102 | uu = "*pending web page*" 103 | else: 104 | title = page.display_url if page.title is None else page.title 105 | uu = link(url=page.url, title=title) 106 | if page.description is not None: 107 | uu += ' ' + page.description 108 | texts.append(uu) 109 | elif isinstance(e, MessageMediaPhoto): 110 | # TODO no file location? :( 111 | texts.append("*PHOTO*") 112 | # print(vars(e)) 113 | elif isinstance(e, MessageMediaDocument): 114 | texts.append("*DOCUMENT*") 115 | # print(vars(e.document)) 116 | elif isinstance(e, MessageMediaVenue): 117 | texts.append("*VENUE* " + e.title) 118 | else: 119 | logger.error(f"Unknown media {type(e)}") 120 | # raise RuntimeError 121 | # TODO contribute 1 to exit code? or emit Error? 122 | 123 | # chat = dialog.name 124 | # mid = group[0].id 125 | # TODO ugh. doesn't seem to be possible to jump to private dialog :( 126 | # and couldn't get original forwarded message id from message object.. 127 | # in_context = f'https://t.me/{chat}/{mid}' 128 | # TODO detect by data-msg-id? 129 | texts.reverse() 130 | 131 | heading = from_ 132 | if len(group) == 1 and group[0].pinned: 133 | heading = 'pinned: ' + from_ # meh.. 134 | 135 | LIMIT = 400 136 | lines = '\n'.join(texts).splitlines() # meh 137 | for line in lines: 138 | if len(heading) + len(line) <= LIMIT: 139 | heading += " " + line 140 | else: 141 | break 142 | 143 | heading = re.sub(r'\s', ' ', heading) # TODO rely on inorganic for that? 144 | return (date, heading, tags, texts) 145 | 146 | 147 | def _fetch_tg_tasks(logger): 148 | client = telethon.TelegramClient(TELETHON_SESSION, TG_APP_ID, TG_APP_HASH) 149 | client.connect() 150 | client.start() 151 | 152 | messages = [] 153 | 154 | all_dialogs = client.get_dialogs() 155 | 156 | for dialog in all_dialogs: 157 | if not dialog.is_user: 158 | # skip channels -- they tend to have lots of irrelevant pinned messages 159 | continue 160 | pinned_messages = client.get_messages( 161 | dialog.input_entity, 162 | filter=InputMessagesFilterPinned, 163 | limit=1000, # TODO careful about the limit? 164 | ) 165 | messages.extend(pinned_messages) 166 | 167 | # handle multiple dialogs just in case.. it might happen if you converted to supergroup at some point or did something like that 168 | # seems like the old dialog is still in API even though it doesn't display in the app? 169 | todo_dialogs = [d for d in all_dialogs if d.name == GROUP_NAME] 170 | for todo_dialog in todo_dialogs: 171 | api_messages = client.get_messages(todo_dialog.input_entity, limit=1000000) # TODO careful about the limit? 172 | messages.extend(m for m in api_messages if not isinstance(m, MessageService)) # wtf is that... 173 | 174 | 175 | # group together multiple forwarded messages. not sure if there is a more robust way but that works well 176 | key = lambda f: f.date 177 | grouped = groupby(sorted(messages, key=key), key=key) 178 | tasks = [] 179 | for _, group in grouped: 180 | res = format_group(list(group), logger=logger) 181 | tasks.append(res) 182 | return tasks 183 | 184 | 185 | def fetch_tg_tasks(logger): 186 | try: 187 | # return [ 188 | # (1234, 'me', {}, [ 189 | # 'line 1', 190 | # 'line 2', 191 | # ]), 192 | # (24314, 'llll', {}, [ 193 | # 'something', 194 | # ]), 195 | # ] 196 | return _fetch_tg_tasks(logger=logger) 197 | except telethon.errors.rpcerrorlist.RpcMcgetFailError as e: 198 | logger.error("Telegram has internal issues...") 199 | logger.exception(e) 200 | # TODO backoff? 201 | if 'Telegram is having internal issues, please try again later' in str(e): 202 | logger.info('ignoring the exception, it just happens sometimes...') 203 | return [] 204 | else: 205 | raise e 206 | 207 | 208 | def make_header() -> str: 209 | parts = [] 210 | if ORG_TAG is not None: 211 | parts.append(f'#+FILETAGS: {ORG_TAG}') 212 | parts.append(f'# AUTOGENERATED by {__file__}') 213 | return '\n'.join(parts) 214 | 215 | 216 | class Telegram2Org(InteractiveView): 217 | def __init__(self, *args, **kwargs) -> None: 218 | kwargs['file_header'] = make_header() 219 | super().__init__(*args, **kwargs) 220 | 221 | def get_items(self): 222 | now = datetime.now(tz=pytz.timezone(TIMEZONE)) 223 | # TODO extract date from messages? 224 | for timestamp, name, tags, lines in fetch_tg_tasks(logger=self.logger): 225 | yield str(timestamp), todo( 226 | now, 227 | heading=name, 228 | tags=tags, 229 | body='\n'.join([*lines, '']), 230 | ) 231 | # TODO automatic tag map? 232 | 233 | 234 | def main() -> None: 235 | logging.getLogger('telethon.telegram_bare_client').setLevel(logging.INFO) 236 | logging.getLogger('telethon.extensions.tcp_client').setLevel(logging.INFO) 237 | Telegram2Org.main() 238 | 239 | 240 | if __name__ == '__main__': 241 | main() 242 | --------------------------------------------------------------------------------