├── .flake8 ├── .gitignore ├── LICENSE.md ├── README.md ├── mercury.el ├── mercury ├── __main__.py ├── _api.py ├── _debug.py ├── _messenger.py ├── _server.py ├── _store.py └── _util.py ├── poetry.lock └── pyproject.toml /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | 3 | ignore = E501 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2019–2022 [Radian LLC](https://radian.codes) and 4 | contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mercury 2 | 3 | Mercury was to be an [Emacs](https://www.gnu.org/software/emacs/) 4 | interface to [Facebook Messenger](https://www.messenger.com/), SMS 5 | (via [Pushbullet](https://www.pushbullet.com/)), and 6 | [Signal](https://signal.org/). Unfortunately, I lost momentum on the 7 | project, although I still think it would be extremely cool. 8 | 9 | ## Message-passing API 10 | 11 | Communication between the Emacs Lisp frontend and the Python backend 12 | is done by message passing on stdio. Each message is a single line 13 | containing a JSON object. 14 | 15 | General message protocol: 16 | 17 | * map: 18 | * `type`: string, message type identifier 19 | * `id`: string, identifier to match server responses to their 20 | original messages 21 | * `data`: map, contents dependent on message type 22 | * `error`: string, error message (or null if request successful) 23 | * notes: 24 | * only used for response messages 25 | 26 | Request-response messages: 27 | 28 | * `addAccount`: 29 | * message: 30 | * `service`: string, must be `messenger` 31 | * `name`: string, human-readable name for the account 32 | * response: 33 | * `id`: string, account ID 34 | * `removeAccount`: 35 | * message: 36 | * `id`: string, account ID 37 | * `getAccounts`: 38 | * response: 39 | * map: 40 | * keys: strings, account IDs 41 | * values: maps 42 | * `service`: string, always `messenger` 43 | * `name`: string, human-readable name for the account 44 | * `loginRequired`: boolean 45 | * `loginFields`: list 46 | * values: maps 47 | * `field`: string, internal identifier used in 48 | response 49 | * `name`: string, display name 50 | * `private`: boolean, whether this field 51 | should be treated as a password 52 | * `login`: 53 | * message: 54 | * `aid`: string, account ID 55 | * `fields`: map 56 | * keys: strings, as in `field` key under `getAccounts` 57 | response 58 | * values: strings, as provided by the user 59 | * `logout`: 60 | * message: 61 | * `aid`: string, account ID 62 | * `getConversations`: 63 | * message: 64 | * `aid`: string, account ID 65 | * `limit`: integer, how many conversations to retrieve (or 66 | null for default) 67 | * `offset`: integer, how many conversations to skip (or null 68 | or omitted for default, skipping no conversations) 69 | * response: 70 | * `conversations`: list, sorted by `timestamp` descending 71 | * values: maps 72 | * `id`: string, conversation ID 73 | * `name`: string, service-provided display name for 74 | conversation 75 | * `timestamp`: integer, most recent message timestamp 76 | (may be more recent than any message we have if we 77 | haven't fetched the most recent message yet) 78 | * `participants`: list 79 | * values: maps 80 | * `id`: string, user ID 81 | * `name`: string, display name 82 | * `you`: boolean, whether you are this user 83 | * `getMessages`: 84 | * message: 85 | * `aid`: string, account ID 86 | * `cid`: string, conversation ID 87 | * `limit`: integer, how many messages to retrieve (or null or 88 | omitted for default) 89 | * `offset`: integer, how many messages to skip (or null or 90 | omitted for default, skipping no messages) 91 | * response: 92 | * `messages`: list, sorted by `timestamp` 93 | * values: maps 94 | * `id`: string, message ID 95 | * `type`: string, either `text` or `image` or `file` or 96 | `unsupported` 97 | * `content`: string, message contents for `text` or URL 98 | for `image` and `file` or description for `unsupported` 99 | * `timestamp`: integer, time at which message was sent 100 | * `sender`: map 101 | * `id`: string, user ID 102 | * `name`: string, name 103 | * `you`: boolean, whether you are this user 104 | * `participants`: list 105 | * values: maps 106 | * `id`: string, user ID 107 | * `name`: string, display name 108 | * `you`: boolean, whether you are this user 109 | * `lastSeenMessage`: string, message ID, or null if 110 | the message has not been fetched yet 111 | * `sendMessage`: 112 | * message: 113 | * `aid`: string, account ID 114 | * `cid`: string, conversation ID 115 | * `type`: string, either `text` or `image` or `file` 116 | * `content`: string, message contents for `text` or filename for 117 | `image` and `file` 118 | * response: empty 119 | 120 | Notification messages: 121 | 122 | * `resyncNeeded`: empty 123 | * `receivedMessage`: 124 | * `id`: string, message ID 125 | * `type`: string, either `text` or `image` or `file` or 126 | `unsupported` 127 | * `content`: string, message contents for `text` or URL 128 | for `image` and `file` or description for `unsupported` 129 | * `timestamp`: integer, time at which message was sent 130 | * `sender`: map 131 | * `id`: string, user ID 132 | * `name`: string, name 133 | * `you`: boolean, whether you are this user 134 | * `receivedReadReceipt`: 135 | * `id`: string, message ID 136 | * `conversation`: map 137 | * `id`: string, conversation ID 138 | * `name`: string, display name of conversation 139 | * `user`: map 140 | * `id`: string, user ID 141 | * `name`: string, display name of user 142 | 143 | ## Service client API 144 | 145 | Any API method may throw `LoginRequiredError` upon authentication 146 | failure or invalid session, or `ServiceError` upon bad data or 147 | unexpected error from upstream API. Other errors should only be thrown 148 | in case of programmer error and will result in the use of lethal force 149 | by Mercury on the service client. 150 | 151 | * `get_session()`: 152 | * returns: string, as used for `restore_session` (or null if no 153 | active session) 154 | * `restore_session(session)`: 155 | * `session`: string, as returned by `get_session` 156 | * returns: nothing 157 | * notes: 158 | * should validate session and throw `LoginRequiredError` if 159 | it's not valid 160 | * `get_login_fields()`: 161 | * returns: list 162 | * values: maps 163 | * `field`: string, internal identifier used in response 164 | * `name`: string, display name 165 | * `private`: boolean, whether this field should be treated 166 | as a password 167 | * `login(fields)`: 168 | * `fields`: map 169 | * keys: strings, as returned by `get_login_fields` under 170 | `field` 171 | * values: strings, values filled in by the user 172 | * preconditions: 173 | * `logout()` has been called 174 | * `logout()`: 175 | * returns: nothing 176 | * notes: 177 | * should silently do nothing if already logged out or there is 178 | no existing session 179 | * `get_you()`: 180 | * returns: string, user ID of you 181 | * `get_users(uids)`: 182 | * `uids`: list 183 | * values: strings, user IDs 184 | * returns: map 185 | * keys: strings, user IDs 186 | * values: maps 187 | * `name`: string, display name of user 188 | * `get_conversations(before)`: 189 | * `before`: timestamp at which to start retrieving conversations 190 | (or null to retrieve most recently updated conversations) 191 | * returns: map 192 | * `conversations`: list, sorted by `timestamp` descending 193 | * length is unspecified but should only be zero if there 194 | are no matching conversations 195 | * values: maps 196 | * `id`: string, conversation ID 197 | * `name`: string, conversation display name 198 | * `timestamp`: integer, most recent message timestamp 199 | (may be more recent than any message we have if we 200 | haven't fetched the most recent message yet) 201 | * `participants`: map 202 | * keys: strings, user IDs 203 | * values: maps 204 | * `lastSeenMessage`: string, message ID (or 205 | null or omitted if there's no update to 206 | report) 207 | * `messages`: list, sorted by `timestamp` descending, 208 | or omitted 209 | * values: maps 210 | * `id`: string, message ID 211 | * `type`: string, either `text` or `image` or 212 | `file` or `unsupported` 213 | * `content`: string, message contents for 214 | `text` or URL for `image` and `file` or 215 | description for `unsupported` 216 | * `timestamp`: integer, time at which message 217 | was sent 218 | * `users`: map or omitted 219 | * keys: strings, user IDs 220 | * values: maps 221 | * `name`: string, display name of user (or null or 222 | omitted if this information is not available) 223 | * `get_messages(before)`: 224 | * `before`: timestamp at which to start retrieving messages (or 225 | null to retrieve most recent messages) 226 | * returns: map 227 | * `messages`: list, sorted by `timestamp` descending 228 | * length is unspecified but should only be zero if there are 229 | no matching messages 230 | * values: maps 231 | * `id`: string, message ID 232 | * `type`: string, either `text` or `image` or `file` or 233 | `unsupported` 234 | * `content`: string, message contents for `text` or URL 235 | for `image` and `file` or description for `unsupported` 236 | * `timestamp`: integer, time at which message was sent 237 | * `sender`: string, user ID 238 | * `participants`: map or omitted 239 | * keys: strings, user IDs 240 | * notes: 241 | * does not need to include all participants, only 242 | ones for which data should be updated 243 | * values: maps 244 | * `lastSeenMessage`: string, message ID (or null or 245 | omitted if there's no update to report) 246 | * `users`: map or omitted 247 | * keys: strings, user IDs 248 | * values: maps 249 | * `name`: string, display name of user (or null or 250 | omitted if this information is not available) 251 | * `send_message(cid, type, content)`: 252 | * `cid`: string, conversation ID 253 | * `type`: string, either `text` or `image` or `file` 254 | * `content`: string, message contents for `text` or filename for 255 | `image` and `file` 256 | * returns: nothing 257 | 258 | ## Store format 259 | 260 | All timestamps are milliseconds since the epoch. 261 | 262 | * `$MERCURY_SESSION_FILE` or `$XDG_CONFIG_HOME/mercury/sessions.json`: 263 | map 264 | * `sessions`: map 265 | * keys: strings, account IDs 266 | * values: JSON objects 267 | * `$MERCURY_DATA_FILE` or `$XDG_CONFIG_HOME/mercury/data.json`: map 268 | * `version`: integer, schema version 269 | * `accounts`: map 270 | * keys: strings, account IDs 271 | * values: maps 272 | * `name`: string, account display name 273 | * `users`: map 274 | * keys: strings, user IDs 275 | * values: maps 276 | * `name`: user display name 277 | * `conversations`: list, sorted by `timestamp` descending 278 | * values: maps 279 | * `id`: string, conversation ID 280 | * `name`: string, conversation display name 281 | * `timestamp`: integer, most recent message 282 | timestamp (may be more recent than any message 283 | we have if we haven't fetched the most recent 284 | message yet) 285 | * `participants`: map 286 | * keys: strings, user IDs 287 | * values: maps 288 | * `lastSeenMessage`: string, message ID 289 | (or null if we haven't fetched the 290 | message yet) 291 | * `messages`: list, sorted by `timestamp` descending 292 | * values: maps 293 | * `id`: string, message ID 294 | * `type`: string, either `text` or `image` or 295 | `file` or `unsupported` 296 | * `content`: string, message contents for 297 | `text` or URL for `image` and `file` or 298 | description for `unsupported` 299 | * `timestamp`: integer, time at which message 300 | was sent 301 | * `sender`: string, user ID 302 | -------------------------------------------------------------------------------- /mercury.el: -------------------------------------------------------------------------------- 1 | ;;; mercury.el --- Emacs interface to FB Messenger. -*- lexical-binding: t -*- 2 | 3 | ;; Copyright (C) 2019-2022 Radian LLC 4 | 5 | ;; Author: Radian LLC 6 | ;; Created: 30 Apr 2019 7 | ;; Homepage: https://github.com/radian-software/mercury 8 | ;; Keywords: applications 9 | ;; Package-Requires: ((emacs "25.2")) 10 | ;; Version: 0 11 | 12 | ;;; Commentary: 13 | 14 | ;; TODO 15 | 16 | ;; Please see for more 17 | ;; information. 18 | 19 | ;;; Code: 20 | 21 | ;; To see the outline of this file, run M-x outline-minor-mode and 22 | ;; then press C-c @ C-t. To also show the top-level functions and 23 | ;; variable declarations in each section, run M-x occur with the 24 | ;; following query: ^;;;;* \|^( 25 | 26 | (require 'cl-lib) 27 | (require 'json) 28 | (require 'seq) 29 | (require 'subr-x) 30 | 31 | (defgroup mercury nil 32 | "Emacs interface to Facebook Messenger." 33 | :group 'applications 34 | :prefix "mercury-") 35 | 36 | (defcustom mercury-directory 37 | (expand-file-name 38 | "mercury" 39 | (expand-file-name 40 | "var" 41 | user-emacs-directory)) 42 | "Directory in which Mercury files are stored. 43 | These include your session cookies, your messages history, and 44 | the Mercury Python virtualenv." 45 | :type 'directory) 46 | 47 | (defvar mercury--source-dir 48 | (file-name-directory 49 | (file-truename 50 | (concat 51 | (file-name-sans-extension 52 | (or load-file-name buffer-file-name)) 53 | ".el"))) 54 | "Directory containing the Mercury source repository.") 55 | 56 | (defun mercury--expand-file-name (&rest names) 57 | "Run `expand-file-name' on each of NAMES, starting from `mercury-directory'. 58 | Example: 59 | 60 | (mercury--expand-file-name \"foo\" \"bar\") 61 | ;; => \"/home/you/.emacs.d/var/mercury/foo/bar\"" 62 | (let ((dir mercury-directory)) 63 | (dolist (name names) 64 | (setq dir (expand-file-name name dir))) 65 | dir)) 66 | 67 | (defvar mercury--server-buffer-name " *mercury-server*" 68 | "Name of buffer to use for Mercury server.") 69 | 70 | (defmacro mercury--server-with-buffer (&rest body) 71 | "Exec BODY with the Mercury server buffer current. 72 | Disable read-only status and go to the end of the buffer for the 73 | duration of BODY." 74 | (declare (indent defun)) 75 | `(with-current-buffer (get-buffer-create mercury--server-buffer-name) 76 | (special-mode) 77 | (save-excursion 78 | (let ((inhibit-read-only t)) 79 | ,@body)))) 80 | 81 | (defun mercury--prepend-path (val path) 82 | "Prepend VAL to colon-delimited PATH." 83 | (if path 84 | (concat val ":" path) 85 | val)) 86 | 87 | (defmacro mercury--server-with-env (&rest body) 88 | "Exec BODY in context of Mercury virtualenv." 89 | (declare (indent defun)) 90 | `(let* ((virtualenv-bin (mercury--expand-file-name "virtualenv" "bin")) 91 | (process-environment 92 | (cl-list* 93 | (concat "PATH=" (mercury--prepend-path 94 | virtualenv-bin (getenv "PATH"))) 95 | (concat "PYTHONPATH=" (mercury--prepend-path 96 | mercury--source-dir (getenv "PYTHONPATH"))))) 97 | (exec-path (cons virtualenv-bin exec-path))) 98 | ,@body)) 99 | 100 | (defun mercury--server-install () 101 | "If missing or outdated, install Mercury into a virtualenv." 102 | (let* ((virtualenv (mercury--expand-file-name "virtualenv")) 103 | (repo-lockfile 104 | (with-temp-buffer 105 | (insert-file-contents-literally 106 | (mercury--expand-file-name mercury--source-dir "poetry.lock")) 107 | (buffer-string))) 108 | (installed-lockfile 109 | (with-temp-buffer 110 | (ignore-errors 111 | (insert-file-contents-literally 112 | (mercury--expand-file-name virtualenv "poetry.lock")) 113 | (buffer-string))))) 114 | (unless (and (equal repo-lockfile installed-lockfile) 115 | (mercury--server-with-buffer 116 | (mercury--server-with-env 117 | (= 0 (call-process 118 | "python" nil t nil 119 | "-m" "mercury" "--no-load-session"))))) 120 | (mercury--server-with-buffer 121 | (ignore-errors 122 | (delete-directory virtualenv 'recursive)) 123 | (make-directory mercury-directory 'parents) 124 | (unless (= 0 (call-process "python3" nil t nil 125 | "-m" "venv" virtualenv)) 126 | (error "Failed to create virtualenv")) 127 | (mercury--server-with-env 128 | (let ((requirements nil)) 129 | (with-temp-buffer 130 | (insert repo-lockfile) 131 | (goto-char (point-min)) 132 | (while (re-search-forward 133 | (concat 134 | "name = \"\\(.+?\\)\"" 135 | "[[:ascii:][:nonascii:]]+?" 136 | "version = \"\\(.+?\\)\"") 137 | nil 'noerror) 138 | (push 139 | (format "%s==%s" (match-string 1) (match-string 2)) 140 | requirements))) 141 | (unless (= 0 (apply 142 | #'call-process 143 | "pip" nil t nil 144 | (cons "install" requirements))) 145 | (error "Failed to install dependencies"))) 146 | (let* ((lib-dirs (file-expand-wildcards 147 | (mercury--expand-file-name 148 | "virtualenv" "lib" "*")))) 149 | (unless (= 1 (length lib-dirs)) 150 | (error "Directory %S has unexpected contents" 151 | (mercury--expand-file-name 152 | "virtualenv" "lib"))) 153 | (make-symbolic-link 154 | (expand-file-name "mercury" mercury--source-dir) 155 | (mercury--expand-file-name 156 | (car lib-dirs) "site-packages" "mercury"))) 157 | (unless (= 0 (call-process 158 | "python" nil t nil "-m" "mercury" "--no-load-session")) 159 | ;; TODO: suggest checking *mercury-server* buffer. 160 | (error "Installed Mercury into virtualenv, but it isn't working")) 161 | (copy-file 162 | (mercury--expand-file-name mercury--source-dir "poetry.lock") 163 | (mercury--expand-file-name virtualenv "poetry.lock"))))))) 164 | 165 | (defvar mercury--server-output "" 166 | "Collected output from stdout of the Mercury server. 167 | This is used by the filter function to keep track of output in 168 | the case that it does not receive a full line of output in a 169 | single call.") 170 | 171 | (defvar mercury--server-handlers nil 172 | "Alist of handlers for messages from the Mercury server. 173 | Only the values are significant for the server; they are the 174 | handlers. When the server sends a message, it is decoded from 175 | JSON into an alist, and then the resulting object is passed to 176 | each handler in turn until one of them returns non-nil.") 177 | 178 | (defun mercury--server-filter (proc string) 179 | "Process filter for the Mercury server. 180 | PROC is the Mercury server process, and STRING is the data that 181 | was sent to stdout by the server." 182 | (setq mercury--server-output (concat mercury--server-output string)) 183 | (mercury--server-with-buffer 184 | (while (string-match "\\(.*\\)\n" mercury--server-output) 185 | (let ((line (match-string 1 mercury--server-output))) 186 | (setq mercury--server-output (substring mercury--server-output (match-end 0))) 187 | (let ((moving (= (point) (process-mark proc)))) 188 | (save-excursion 189 | (goto-char (process-mark proc)) 190 | (let ((inhibit-read-only t)) 191 | (when (string-prefix-p "{" line) 192 | (insert "-> ")) 193 | (insert line "\n")) 194 | (set-marker (process-mark proc) (point))) 195 | (when moving 196 | (goto-char (process-mark proc)))) 197 | (when (string-prefix-p "{" line) 198 | (let ((resp (json-read-from-string line))) 199 | (when-let ((err (alist-get 'error resp))) 200 | (error "Mercury server error: %s" err)) 201 | (cl-dolist (cell mercury--server-handlers) 202 | (when (funcall (cdr cell) resp) 203 | (cl-return))))))))) 204 | 205 | (defvar mercury--server-process nil 206 | "Mercury server process object. Communicates on stdio.") 207 | 208 | (defun mercury--server-stop () 209 | "Stop the Mercury server, if it is running." 210 | (when (process-live-p mercury--server-process) 211 | (process-send-eof mercury--server-process) 212 | (accept-process-output mercury--server-process 0.1 nil 'just-this-one) 213 | (when (process-live-p mercury--server-process) 214 | (interrupt-process mercury--server-process) 215 | (accept-process-output mercury--server-process 0.1 nil 'just-this-one) 216 | (when (process-live-p mercury--server-process) 217 | (kill-process mercury--server-process))))) 218 | 219 | (defun mercury--server-start () 220 | "Start the Mercury server, if it is not already running." 221 | (unless (process-live-p mercury--server-process) 222 | (mercury--server-install) 223 | (mercury--server-with-buffer 224 | (mercury--server-with-env 225 | (setq mercury--server-process 226 | (make-process 227 | :name "mercury" 228 | :buffer (current-buffer) 229 | :command '("python" "-m" "mercury") 230 | :noquery t 231 | :filter #'mercury--server-filter)))))) 232 | 233 | (defun mercury--server-send-message (msg) 234 | "Send a MSG to the server. 235 | MSG is converted to JSON before being fed to the server on stdin. 236 | If the server has not been started, then start it first." 237 | (mercury--server-start) 238 | (let ((line (json-encode msg))) 239 | (mercury--server-with-buffer 240 | (let ((moving (= (point) (process-mark mercury--server-process)))) 241 | (save-excursion 242 | (goto-char (process-mark mercury--server-process)) 243 | (insert "<- " line "\n") 244 | (set-marker (process-mark mercury--server-process) (point))) 245 | (when moving 246 | (goto-char (process-mark mercury--server-process))) 247 | (process-send-string mercury--server-process line) 248 | (process-send-string mercury--server-process "\n"))))) 249 | 250 | (defvar mercury--message-counter 0 251 | "Value used for the `id' of the next message to the server.") 252 | 253 | (defun mercury--server-get-response (msg callback) 254 | "Send a MSG to the server, and invoke CALLBACK with the response. 255 | An `id' attribute is added automatically to the message, 256 | overwriting any existing one. The CALLBACK is invoked with the 257 | same buffer current as is current when this function is invoked." 258 | (let ((id mercury--message-counter) 259 | (buffer (current-buffer))) 260 | (cl-incf mercury--message-counter) 261 | (setf (alist-get id mercury--server-handlers) 262 | (lambda (resp) 263 | (when (eq id (alist-get 'id resp)) 264 | (prog1 t 265 | (setf (alist-get id mercury--server-handlers nil 'remove) nil) 266 | (with-current-buffer (if (buffer-live-p buffer) 267 | buffer 268 | (current-buffer)) 269 | (funcall callback resp)))))) 270 | (setf (alist-get 'id msg) id) 271 | (mercury--server-send-message msg))) 272 | 273 | (defcustom mercury-thread-list-block-size 20 274 | "How many threads to fetch at a time for the thread list buffer. 275 | The default value matches the number of threads that can be 276 | fetched from Facebook Messenger with a single API call." 277 | :type 'integer) 278 | 279 | (defvar-local mercury--thread-list nil 280 | "Data for the thread list displayed in the current buffer. 281 | The format is as returned by the Mercury server.") 282 | 283 | (defun mercury--thread-list-redisplay () 284 | "Recompute the text in the current (thread list) buffer. 285 | This function uses the value of `mercury--thread-list'." 286 | ;; TODO: restore scroll position 287 | (let ((inhibit-read-only t) 288 | (line (line-number-at-pos)) 289 | (column (current-column))) 290 | (erase-buffer) 291 | (seq-do (lambda (thread) 292 | (insert 293 | (propertize 294 | (concat (alist-get 'name thread) "\n") 295 | 'mercury-thread-id (alist-get 'id thread) 296 | 'mercury-thread-name (alist-get 'name thread)))) 297 | (reverse mercury--thread-list)) 298 | (goto-char (point-min)) 299 | (forward-line (1- line)) 300 | (move-to-column column)) 301 | (message "Refreshing...done")) 302 | 303 | (defun mercury-thread-list-refresh (&optional more) 304 | "Reset the current buffer to match the Mercury server's thread list. 305 | With prefix arg MORE, display more threads than are currently 306 | shown, according to the value of 307 | `mercury-thread-list-block-size'." 308 | (interactive "P") 309 | (unless (derived-mode-p 'mercury-thread-list-mode) 310 | (user-error "Not in a Mercury thread list buffer")) 311 | (message "Refreshing...") 312 | (mercury--server-get-response 313 | `((message . getThreads) 314 | (numThreads . ,(+ mercury-thread-list-block-size 315 | (if more (length mercury--thread-list) 0)))) 316 | (lambda (resp) 317 | (setq mercury--thread-list (alist-get 'threads resp)) 318 | (mercury--thread-list-redisplay)))) 319 | 320 | (define-derived-mode mercury-mode special-mode "Mercury" 321 | "Major mode for Mercury.") 322 | 323 | (define-derived-mode mercury-thread-list-mode mercury-mode "Mercury" 324 | "Major mode to list Mercury threads.") 325 | 326 | (defcustom mercury-thread-list-keys 327 | '(("g" . mercury-thread-list-refresh) 328 | ("RET" . mercury-view-thread)) 329 | "Alist of keys for `mercury-thread-list-mode-map'. 330 | You must set this variable before loading Mercury in order for 331 | your setting to take effect. The keys are strings for `kbd', and 332 | the values are functions." 333 | :type '(alist :key-type string :value-type function)) 334 | 335 | (map-apply (lambda (keys func) 336 | (define-key mercury-thread-list-mode-map (kbd keys) func)) 337 | mercury-thread-list-keys) 338 | 339 | (defcustom mercury-thread-list-buffer-name "*mercury threads*" 340 | "Name for Mercury thread list buffer." 341 | :type 'string) 342 | 343 | ;;;###autoload 344 | (defun mercury-thread-list () 345 | "Pop to Mercury thread list buffer, creating it if necessary." 346 | (interactive) 347 | (with-current-buffer (get-buffer-create mercury-thread-list-buffer-name) 348 | (mercury-thread-list-mode) 349 | (pop-to-buffer (current-buffer)))) 350 | 351 | (defcustom mercury-thread-buffer-name-format "*mercury: %s*" 352 | "Format string for name of Mercury thread buffers. 353 | This is formatted with one string argument, the name of the 354 | thread." 355 | :type 'string) 356 | 357 | (define-derived-mode mercury-thread-mode mercury-mode "Mercury" 358 | "Major mode to view a Mercury thread.") 359 | 360 | (defcustom mercury-thread-keys 361 | '(("g" . mercury-thread-refresh)) 362 | "Alist of keys for `mercury-thread-mode-map'. 363 | You must set this variable before loading Mercury in order for 364 | your setting to take effect. The keys are strings for `kbd', and 365 | the values are functions." 366 | :type '(alist :key-type string :value-type function)) 367 | 368 | (map-apply (lambda (keys func) 369 | (define-key mercury-thread-mode-map (kbd keys) func)) 370 | mercury-thread-keys) 371 | 372 | (defvar-local mercury-thread-id nil 373 | "ID of thread displayed in current `mercury-thread-mode' buffer.") 374 | 375 | (defun mercury-view-thread () 376 | "Pop to Mercury thread buffer, creating it if necessary. 377 | The thread information is taken from the text properties at 378 | point (which should be in a `mercury-thread-list-mode' buffer)." 379 | (interactive) 380 | (let ((id (get-text-property (point) 'mercury-thread-id)) 381 | (name (get-text-property (point) 'mercury-thread-name))) 382 | (unless (and id name) 383 | (user-error "Not in a Mercury thread list")) 384 | (with-current-buffer (get-buffer-create 385 | (format mercury-thread-buffer-name-format name)) 386 | (mercury-thread-mode) 387 | (setq-local mercury-thread-id id) 388 | (pop-to-buffer (current-buffer))))) 389 | 390 | (defvar-local mercury--message-list nil 391 | "Data for the thread messages displayed in the current buffer. 392 | The format is as returned by the Mercury server.") 393 | 394 | (defun mercury--thread-redisplay () 395 | "Recompute the text in the current (thread) buffer. 396 | This function uses the value of `mercury--message-list'." 397 | ;; TODO: restore scroll position 398 | (let ((inhibit-read-only t) 399 | (line (line-number-at-pos)) 400 | (column (current-column))) 401 | (erase-buffer) 402 | (seq-do (lambda (message) 403 | (insert (alist-get 'text thread) "\n")) 404 | mercury--message-list) 405 | (goto-char (point-min)) 406 | (forward-line (1- line)) 407 | (move-to-column column)) 408 | (message "Refreshing...done")) 409 | 410 | (defun mercury-thread-refresh (&optional more) 411 | "Reset the current buffer to match the Mercury server's message list. 412 | With prefix arg MORE, display more messages than are currently 413 | shown, according to the value of 414 | `mercury-thread-list-block-size'." 415 | (interactive "P") 416 | (unless (derived-mode-p 'mercury-thread-mode) 417 | (user-error "Not in a Mercury thread buffer")) 418 | (message "Refreshing...") 419 | (mercury--server-get-response 420 | `((message . getMessages) 421 | (numThreads . ,(+ mercury-thread-list-block-size 422 | (if more (length mercury--thread-list) 0)))) 423 | (lambda (resp) 424 | (setq mercury--message-list (alist-get 'threads resp)) 425 | (mercury--thread-redisplay)))) 426 | 427 | ;;;; Closing remarks 428 | 429 | (provide 'mercury) 430 | 431 | ;; Local Variables: 432 | ;; indent-tabs-mode: nil 433 | ;; outline-regexp: ";;;;* " 434 | ;; End: 435 | 436 | ;;; mercury.el ends here 437 | -------------------------------------------------------------------------------- /mercury/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import sys 4 | 5 | from mercury import _server as mercury_server 6 | 7 | 8 | def send_message(message): 9 | json.dump(message, sys.stdout) 10 | print() 11 | 12 | 13 | def main(): 14 | parser = argparse.ArgumentParser( 15 | description="Emacs interface to Facebook Messenger" 16 | ) 17 | parser.add_argument( 18 | "--version", action="version", version="%(prog) pre-release version" 19 | ) 20 | args = parser.parse_args() 21 | server = mercury_server.Server(send_message) 22 | while True: 23 | try: 24 | line = input() 25 | except EOFError: 26 | break 27 | if not line: 28 | continue 29 | try: 30 | message = json.loads(line) 31 | except json.JSONDecodeError: 32 | continue 33 | if not isinstance(message, dict): 34 | continue 35 | server.handle_message(message) 36 | sys.exit(0) 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /mercury/_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Internal API that each Mercury backend implements. Not to be confused 3 | with the actual web APIs that we are scripting against (made by 4 | Facebook etc.) and also not to be confused with the message-passing 5 | API that the Mercury server uses to communicate with a client over 6 | stdio. We define this API so that writing a backend is as simple as 7 | possible (anything that doesn't have to be backend-specific is handled 8 | at a higher level, such as message persistence and error correction). 9 | """ 10 | 11 | import abc 12 | 13 | 14 | class LoginRequiredError(Exception): 15 | """ 16 | Exception to throw when we can't complete the operation because we 17 | need some login credentials. 18 | """ 19 | 20 | pass 21 | 22 | 23 | class ServiceError(Exception): 24 | """ 25 | Exception to throw when we get an upstream API error unexpectedly, 26 | or the API returns bad data. 27 | """ 28 | 29 | def __init__(self, fmt, *args, **kwargs): 30 | super().__init__(fmt.format(*args, **kwargs)) 31 | 32 | 33 | class Service(abc.ABC): 34 | @abc.abstractmethod 35 | def __init__(self): 36 | """ 37 | Construct a new instance of the service. It shouldn't need to know 38 | its own account ID, as that stuff is dealt with at a higher 39 | level. This shouldn't be doing anything fancy. 40 | """ 41 | pass 42 | 43 | @abc.abstractmethod 44 | def get_session(self): 45 | """ 46 | Return information that can be passed to restore_session, a 47 | string. If there's no active session, return None. 48 | """ 49 | pass 50 | 51 | @abc.abstractmethod 52 | def restore_session(self, session): 53 | """ 54 | Try to restore the session from the given account session data, a 55 | string. Throw LoginRequiredError if the auth fails. 56 | """ 57 | pass 58 | 59 | @abc.abstractmethod 60 | def get_login_fields(self): 61 | """ 62 | Return a list of fields that are required to do a login from 63 | scratch. The return value must always be the same. 64 | """ 65 | pass 66 | 67 | @abc.abstractmethod 68 | def login(self, fields): 69 | """ 70 | Given values for the fields returned by get_login_fields (a map 71 | where the keys are the "field" values from get_login_fields 72 | and the values are the strings provided by the user), try to 73 | do a login. Throw LoginRequiredError if the auth fails, 74 | ServiceError on unexpected error. 75 | """ 76 | pass 77 | 78 | @abc.abstractmethod 79 | def logout(self): 80 | """ 81 | Terminate the existing session, if any. Throw ServiceError if 82 | something goes wrong. 83 | """ 84 | pass 85 | 86 | @abc.abstractmethod 87 | def get_you(self): 88 | """ 89 | Return the user ID that the service uses to represent you. If this 90 | isn't how the upstream API works, this can be something fake 91 | that will work with the implementation of get_users. 92 | """ 93 | pass 94 | 95 | @abc.abstractmethod 96 | def get_users(self, uids): 97 | """ 98 | Given a list of user IDs, return a map from those user IDs to 99 | dictionaries with a "name" key. 100 | """ 101 | pass 102 | 103 | @abc.abstractmethod 104 | def get_conversations(self, before): 105 | """ 106 | Return data about conversations, and possibly also users, 107 | messages, and read receipts. 108 | """ 109 | pass 110 | 111 | @abc.abstractmethod 112 | def get_messages(self, tid, before): 113 | """ 114 | Return data about messages, and possibly also users and read 115 | receipts. 116 | """ 117 | pass 118 | 119 | @abc.abstractmethod 120 | def send_message(self, cid, mtype, content): 121 | """ 122 | Send a message. This is synchronous; if the send fails, throw 123 | ServiceError. 124 | """ 125 | -------------------------------------------------------------------------------- /mercury/_debug.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from mercury import _messenger as messenger 5 | 6 | 7 | def get_service(): 8 | service = messenger.MessengerService() 9 | try: 10 | with open(os.path.expanduser("~/.fbchat")) as f: 11 | cookies = json.load(f)["cookies"] 12 | except (FileNotFoundError, json.JSONDecodeError): 13 | pass 14 | else: 15 | service.restore_session(cookies) 16 | return service 17 | -------------------------------------------------------------------------------- /mercury/_messenger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Backend for Facebook Messenger . 3 | """ 4 | 5 | import functools 6 | import json 7 | 8 | import fbchat 9 | from fbchat import FBchatException 10 | 11 | from mercury import _api as api 12 | from mercury._api import LoginRequiredError, ServiceError 13 | from mercury._util import log 14 | 15 | 16 | def natural_language_join(names): 17 | """ 18 | Given ["x", "y", "z"], return "x, y, and z". 19 | """ 20 | names = list(names) 21 | if len(names) == 0: 22 | raise ValueError("Nobody") 23 | elif len(names) == 1: 24 | return names[0] 25 | elif len(names) == 2: 26 | return names[0] + " and " + names[1] 27 | else: 28 | return ", ".join(names[:-1]) + ", and " + names[-1] 29 | 30 | 31 | def wrap_fbchat(*, require_login): 32 | """ 33 | Decorator that turns FBchatException into ServiceError and throws 34 | LoginRequiredError if there's no client initialized (unless 35 | require_login is false). There's currently no support for 36 | detecting if the session times out or we've been forcibly logged 37 | out and we get an auth error (although I'm not sure Messenger ever 38 | does time out sessions). 39 | """ 40 | 41 | def decorator(fn): 42 | @functools.wraps(fn) 43 | def wrapped(self, *args, **kwargs): 44 | if require_login and self.client is None: 45 | raise LoginRequiredError 46 | try: 47 | return fn(self, *args, **kwargs) 48 | except fbchat.FBchatException as e: 49 | raise ServiceError from e 50 | 51 | return wrapped 52 | 53 | return decorator 54 | 55 | 56 | class MessengerService(api.Service): 57 | """ 58 | Backend for Facebook Messenger. 59 | """ 60 | 61 | def __init__(self): 62 | self.client = None 63 | 64 | def get_session(self): 65 | if self.client is None: 66 | return None 67 | return json.dumps(self.client.getSession()) 68 | 69 | @wrap_fbchat(require_login=False) 70 | def restore_session(self, session): 71 | try: 72 | log("fbchat: restoreSession(...)") 73 | self.client = fbchat.Client(None, None, session_cookies=json.loads(session)) 74 | except (FBchatException, json.JSONDecodeError): 75 | raise LoginRequiredError 76 | 77 | def get_login_fields(self): 78 | return [ 79 | { 80 | "field": "email", 81 | "name": "Email address", 82 | "identifiable": True, 83 | "private": False, 84 | }, 85 | { 86 | "field": "password", 87 | "name": "Password", 88 | "identifiable": False, 89 | "private": True, 90 | }, 91 | ] 92 | 93 | @wrap_fbchat(require_login=False) 94 | def login(self, fields): 95 | log("fbchat: login({}, ...)", repr(fields["email"])) 96 | self.client = fbchat.Client(fields["email"], fields["password"]) 97 | 98 | @wrap_fbchat(require_login=False) 99 | def logout(self): 100 | if self.client is None: 101 | return 102 | log("fbchat: logout()") 103 | if not self.client.logout(): 104 | raise ServiceError("failed to log out") 105 | self.client = None 106 | 107 | @wrap_fbchat(require_login=True) 108 | def get_you(self): 109 | return self.client.uid 110 | 111 | @wrap_fbchat(require_login=True) 112 | def get_users(self, uids): 113 | display_uids = list(uids) 114 | if len(uids) > 10: 115 | display_uids[10:] = ["..."] 116 | log("fbchat: fetchUserInfo({})", ", ".join(display_uids)) 117 | results = { 118 | u.uid: {"name": u.name} for u in self.client.fetchUserInfo(*uids).values() 119 | } 120 | if set(results) != set(uids): 121 | raise ServiceError("got info back for wrong users") 122 | return results 123 | 124 | def _get_participants(self, fb_thread): 125 | if fb_thread.type == fbchat.ThreadType.GROUP: 126 | return fb_thread.participants 127 | elif fb_thread.type == fbchat.ThreadType.USER: 128 | return {self.get_you(), fb_thread.uid} 129 | else: 130 | return set() 131 | 132 | @wrap_fbchat(require_login=True) 133 | def get_conversations(self, before): 134 | log("fbchat: fetchThreadList(before={})", before) 135 | fb_threads = self.client.fetchThreadList(before=before) 136 | # Figure out what user IDs we have to fetch info for in order 137 | # to correctly assign thread names. 138 | all_uids = { 139 | p for t in fb_threads if not t.name for p in self._get_participants(t) 140 | } 141 | user_info = self.get_users(all_uids) if all_uids else {} 142 | threads = [] 143 | for fb_thread in fb_threads: 144 | participants = self._get_participants(fb_thread) 145 | if not participants: 146 | continue 147 | name = fb_thread.name or natural_language_join( 148 | sorted( 149 | user_info[p]["name"] for p in participants if p != self.client.uid 150 | ) 151 | ) 152 | threads.append( 153 | { 154 | "id": fb_thread.uid, 155 | "name": name, 156 | "participants": {uid: {} for uid in participants}, 157 | "timestamp": int(fb_thread.last_message_timestamp), 158 | } 159 | ) 160 | return {"conversations": threads, "users": user_info} 161 | 162 | def _convert_message(self, fb_message, user_info): 163 | base = { 164 | "id": fb_message.uid, 165 | "timestamp": int(fb_message.timestamp), 166 | "sender": { 167 | "id": fb_message.author, 168 | "name": user_info.get( 169 | fb_message.author, "User ID " + str(fb_message.author) 170 | ), 171 | }, 172 | } 173 | messages = [] 174 | return messages 175 | 176 | @wrap_fbchat(require_login=True) 177 | def get_messages(self, tid, before): 178 | log("fbchat: fetchThreadMessages({}, before={})", tid, before) 179 | fb_messages = self.client.fetchThreadMessages(thread_id=tid, before=before) 180 | messages = [] 181 | participant_info = {} 182 | for fb_message in fb_messages: 183 | base = { 184 | "id": fb_message.uid, 185 | "timestamp": int(fb_message.timestamp), 186 | "sender": fb_message.author, 187 | } 188 | if fb_message.text: 189 | messages.append({"type": "text", "content": fb_message.text, **base}) 190 | for attachment in fb_message.attachments: 191 | if isinstance(attachment, fbchat.FileAttachment): 192 | messages.append({"type": "file", "content": attachment.url, **base}) 193 | elif isinstance(attachment, fbchat.ImageAttachment): 194 | log("fbchat: fetchImageUrl({})", attachment.uid) 195 | messages.append( 196 | { 197 | "type": "image", 198 | "content": self.client.fetchImageUrl(attachment.uid), 199 | **base, 200 | } 201 | ) 202 | else: 203 | messages.append( 204 | { 205 | "type": "unsupported", 206 | "unsupported": type(attachment).__name__, 207 | **base, 208 | } 209 | ) 210 | for p in fb_message.read_by: 211 | if p not in participant_info: 212 | participant_info[p] = {"lastSeenMessage": fb_message.uid} 213 | return {"messages": messages, "participants": participant_info} 214 | 215 | @wrap_fbchat(require_login=True) 216 | def send_message(self, cid, mtype, content): 217 | # TODO: do I need to specify the thread_type in order to send 218 | # to a group? 219 | if mtype == "text": 220 | if len(content) > 80: 221 | display_content = repr(content[:80]) + "..." 222 | else: 223 | display_content = repr(content) 224 | log("fbchat: send({}, thread_id={})", display_content, cid) 225 | self.client.send(fbchat.Message(text=content), thread_id=cid) 226 | elif mtype == "image" or mtype == "file": 227 | log("fbchat: sendLocalFiles({}, thread_id={})", repr(content), cid) 228 | self.client.sendLocalFiles([content], thread_id=cid) 229 | else: 230 | raise ValueError("uh oh, unknown message type: {}".format(mtype)) 231 | -------------------------------------------------------------------------------- /mercury/_server.py: -------------------------------------------------------------------------------- 1 | from mercury._api import LoginRequiredError, ServiceError 2 | from mercury._messenger import MessengerService 3 | from mercury import _store as store 4 | from mercury import _util as util 5 | 6 | 7 | class ClientError(Exception): 8 | pass 9 | 10 | 11 | class Server: 12 | def __init__(self, send_msg): 13 | self.send_msg = send_msg 14 | self.service = MessengerService() 15 | self.logged_in = False 16 | session = store.get_session("messenger") 17 | if session is not None: 18 | try: 19 | self.service.restore_session(session) 20 | self.logged_in = True 21 | except (LoginRequiredError, ServiceError): 22 | # We don't have a message to respond to, so just let 23 | # it go. Not a big deal. 24 | pass 25 | 26 | def _get_aid(self, data): 27 | aid = data.get("aid") 28 | if not isinstance(aid, str): 29 | raise ClientError("account ID missing or not a string") 30 | if aid != "messenger": 31 | raise ClientError("no account with ID: {}".format(aid)) 32 | return aid 33 | 34 | def _handle_message(self, mtype, data): 35 | if mtype == "addAccount": 36 | raise ClientError("addAccount not yet implemented") 37 | if mtype == "removeAccount": 38 | raise ClientError("removeAccount not yet implemented") 39 | if mtype == "getAccounts": 40 | return { 41 | "messenger": { 42 | "service": "messenger", 43 | "name": "Messenger", 44 | "loginRequired": not self.logged_in, 45 | "loginFields": self.service.get_login_fields(), 46 | } 47 | } 48 | if mtype == "login": 49 | self._get_aid(data) 50 | try: 51 | self.service.logout() 52 | except (ClientError, LoginRequiredError): 53 | pass 54 | fields = data.get("fields") 55 | if not isinstance(fields, dict): 56 | raise ClientError("login fields missing or not a map") 57 | for key, value in fields.items(): 58 | if not (isinstance(key, str) and isinstance(value, str)): 59 | raise ClientError("login fields include non-strings") 60 | if set(fields) != set(f["field"] for f in self.service.get_login_fields()): 61 | raise ClientError("login fields do not match required field names") 62 | self.service.login(fields) 63 | self.logged_in = True 64 | store.set_session("messenger", self.service.get_session()) 65 | return {} 66 | if mtype == "logout": 67 | self._get_aid() 68 | self.service.logout() 69 | self.logged_in = False 70 | store.set_session("messenger", None) 71 | return {} 72 | if mtype == "getConversations": 73 | self._get_aid(data) 74 | limit = data.get("limit") 75 | if not isinstance(limit, (int, type(None))): 76 | raise ClientError("limit is not an integer") 77 | if limit is not None and limit <= 0: 78 | return {"conversations": []} 79 | offset = data.get("offset", 0) 80 | if not isinstance(offset, int): 81 | raise ClientError("offset is not an integer") 82 | if offset < 0: 83 | limit += offset 84 | offset = 0 85 | account_data = store.get_account_data("messenger") 86 | if account_data is None: 87 | account_data = {"name": "Messenger", "users": {}, "conversations": []} 88 | existing_account_cids = {c["id"] for c in account_data["conversations"]} 89 | you = self.service.get_you() 90 | users_with_data_needed = set() 91 | users_with_data_fetched = set() 92 | service_data = self.service.get_conversations(before=None) 93 | if account_data["conversations"] and not service_data["conversations"]: 94 | raise ServiceError("upstream forgot about all our conversations") 95 | elif service_data["conversations"]: 96 | for conversation in service_data["conversations"]: 97 | conversation["new"] = True 98 | while True: 99 | if account_data["conversations"] and min( 100 | c["timestamp"] for c in service_data["conversations"] 101 | ) >= max(c["timestamp"] for c in account_data["conversations"]): 102 | fetching_new_conversations = False 103 | before = min( 104 | c["timestamp"] for c in service_data["conversations"] 105 | ) 106 | elif ( 107 | len( 108 | set( 109 | c["id"] 110 | for c in service_data["conversations"] 111 | + account_data["conversations"] 112 | ) 113 | ) 114 | < (limit or 0) + offset 115 | ): 116 | fetching_new_conversations = False 117 | before = min( 118 | c["timestamp"] 119 | for c in service_data["conversations"] 120 | + account_data["conversations"] 121 | ) 122 | else: 123 | break 124 | older_service_data = self.service.get_conversations(before=before) 125 | if not older_service_data["conversations"]: 126 | break 127 | existing_service_cids = { 128 | c["id"] for c in service_data["conversations"] 129 | } 130 | for conversation in older_service_data["conversations"]: 131 | if conversation["id"] not in existing_service_cids: 132 | conversation["new"] = fetching_new_conversations 133 | service_data["conversations"].append(conversation) 134 | for uid, user in older_service_data.get("users", {}).items(): 135 | if "users" not in service_data: 136 | service_data["users"] = {} 137 | if uid not in service_data["users"]: 138 | service_data["users"][uid] = {} 139 | name = user.get("name") 140 | if name: 141 | service_data["users"][uid]["name"] = name 142 | users_with_data_fetched.add(uid) 143 | if len(set(c["id"] for c in service_data["conversations"])) != len( 144 | service_data["conversations"] 145 | ): 146 | raise ServiceError("upstream returned non-unique conversation IDs") 147 | prepend_conversations = [] 148 | append_conversations = [] 149 | cids_to_remove = set() 150 | for conversation in service_data["conversations"]: 151 | assert not conversation.get( 152 | "messages" 153 | ), "can't handle eager message fetch yet" 154 | cid = conversation["id"] 155 | if cid in existing_account_cids: 156 | existing_conversation = next( 157 | c for c in account_data["conversations"] if c["id"] == cid 158 | ) 159 | existing_conversation["name"] = conversation["name"] 160 | existing_conversation["timestamp"] = conversation["timestamp"] 161 | for uid in list(existing_conversation["participants"]): 162 | if uid not in conversation["participants"]: 163 | existing_conversation["participants"].pop(uid) 164 | if uid not in existing_conversation["participants"]: 165 | existing_conversation["participants"][uid] = {} 166 | user = conversation["participants"][uid] 167 | last_seen_message = user.get("lastSeenMessage") 168 | if last_seen_message: 169 | existing_conversation["participants"][uid][ 170 | "lastSeenMessage" 171 | ] = last_seen_message 172 | existing_conversation["participants"] = { 173 | uid: { 174 | "lastSeenMessage": participant.get("lastSeenMessage") 175 | or existing_conversation["participants"].get(uid, {})[ 176 | "lastSeenMessage" 177 | ] 178 | } 179 | for uid, participant in conversation.get( 180 | "participants", {} 181 | ).items() 182 | } 183 | ( 184 | prepend_conversations 185 | if conversation["new"] 186 | else append_conversations 187 | ).append(existing_conversation) 188 | cids_to_remove.add(cid) 189 | else: 190 | ( 191 | prepend_conversations 192 | if conversation["new"] 193 | else append_conversations 194 | ).append( 195 | { 196 | "id": conversation["id"], 197 | "name": conversation["name"], 198 | "timestamp": conversation["timestamp"], 199 | "participants": { 200 | uid: { 201 | "lastSeenMessage": participant.get( 202 | "lastSeenMessage" 203 | ) 204 | } 205 | for uid, participant in conversation[ 206 | "participants" 207 | ].items() 208 | }, 209 | "messages": [], 210 | } 211 | ) 212 | users_with_data_needed.update(conversation["participants"]) 213 | account_data["conversations"] = ( 214 | prepend_conversations 215 | + [ 216 | c 217 | for c in account_data["conversations"] 218 | if c["id"] not in cids_to_remove 219 | ] 220 | + append_conversations 221 | ) 222 | extra_user_info = self.service.get_users( 223 | users_with_data_needed - users_with_data_fetched 224 | ) 225 | for uid, user in extra_user_info.items(): 226 | if "users" not in service_data: 227 | service_data["users"] = {} 228 | service_data["users"][uid] = {"name": user["name"]} 229 | for uid, user in service_data["users"].items(): 230 | if "users" not in account_data: 231 | account_data["users"] = {} 232 | account_data["users"][uid] = {"name": user["name"]} 233 | result = { 234 | "conversations": [ 235 | { 236 | "id": c["id"], 237 | "name": c["name"], 238 | "timestamp": c["timestamp"], 239 | "participants": sorted( 240 | ( 241 | { 242 | "id": uid, 243 | "name": account_data["users"][uid]["name"], 244 | "you": uid == you, 245 | } 246 | for uid, p in c["participants"].items() 247 | ), 248 | key=lambda p: p["name"], 249 | ), 250 | } 251 | for c in account_data["conversations"] 252 | ][offset : offset + limit] 253 | } 254 | store.set_account_data("messenger", account_data) 255 | return result 256 | if mtype == "getMessages": 257 | raise ClientError("getMessages not yet implemented") 258 | if mtype == "sendMessage": 259 | raise ClientError("sendMessage not yet implemented") 260 | raise ClientError("unknown message type: {}".format(mtype)) 261 | 262 | def handle_message(self, client_msg): 263 | try: 264 | if not isinstance(client_msg, dict): 265 | raise ClientError("message not a map") 266 | mid = client_msg.get("id") 267 | mtype = client_msg.get("type") 268 | data = client_msg.get("data") 269 | if not isinstance(mid, str): 270 | raise ClientError("message ID missing or not a string") 271 | if not isinstance(mtype, str): 272 | raise ClientError("message type missing or not a string") 273 | if not isinstance(data, dict): 274 | raise ClientError("message data missing or not a map") 275 | data = self._handle_message(mtype, data) 276 | self.send_msg({"type": "response", "id": mid, "error": None, "data": data}) 277 | except ClientError as e: 278 | self.send_msg( 279 | {"type": "response", "id": mid, "error": "client error: {}".format(e)} 280 | ) 281 | except ServiceError as e: 282 | self.send_msg( 283 | { 284 | "type": "response", 285 | "id": mid, 286 | "error": "unexpected error: {}".format(e), 287 | } 288 | ) 289 | except LoginRequiredError: 290 | self._ask_for_login() 291 | self.send_msg({"type": "response", "id": mid, "error": "login required"}) 292 | -------------------------------------------------------------------------------- /mercury/_store.py: -------------------------------------------------------------------------------- 1 | """ 2 | Read and write the files on disk that have session data and saved 3 | messages from each account. 4 | """ 5 | 6 | import copy 7 | import json 8 | import operator 9 | import string 10 | 11 | import atomicwrites 12 | 13 | from mercury import _util as util 14 | 15 | # Name of file containing session data, i.e. cookies and such. This is 16 | # saved so that you don't have to keep logging in every time you 17 | # restart Mercury. This is stored in a separate file because you might 18 | # want to back up your messages file but the cookies are sensitive 19 | # information that should probably be stored somewhere else. 20 | _SESSIONS_FILE = util.MERCURY_DIR / "sessions.json" 21 | 22 | # Name of file containing all the messages we've downloaded ever. This 23 | # is saved so that we can show you existing messages instantly, 24 | # instead of having to fetch them from the server like a normal 25 | # webapp. 26 | _MESSAGES_FILE = util.MERCURY_DIR / "messages.json" 27 | 28 | # Global variable containing the session data read from 29 | # _SESSIONS_FILE. It's a dictionary mapping session IDs to arbitrary 30 | # string dictionaries. See _read_sessions for the exact format. 31 | _global_sessions = None 32 | 33 | 34 | def _read_sessions(): 35 | """ 36 | Read the session data from disk, if not already cached. Throw an 37 | assert error if it's malformed. Don't return anything. 38 | """ 39 | global _global_sessions 40 | if _global_sessions is not None: 41 | return 42 | try: 43 | with open(_SESSIONS_FILE) as f: 44 | _global_sessions = json.load(f) 45 | except FileNotFoundError: 46 | _global_sessions = {} 47 | assert isinstance(_global_sessions, dict) 48 | for key, val in _global_sessions.items(): 49 | assert isinstance(key, str) 50 | assert isinstance(val, str) 51 | 52 | 53 | def _write_sessions(): 54 | """ 55 | Write the current session data to disk. You need to call 56 | _read_sessions first. 57 | """ 58 | global _global_sessions 59 | _SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True) 60 | with atomicwrites.atomic_write(_SESSIONS_FILE, overwrite=True) as f: 61 | json.dump(_global_sessions, f, indent=2) 62 | f.write("\n") 63 | 64 | 65 | def get_session(aid): 66 | """ 67 | Return the session data for the given account ID. If there's no 68 | session data for that account ID, return None. 69 | """ 70 | global _global_sessions 71 | assert isinstance(aid, str) 72 | _read_sessions() 73 | return _global_sessions.get(aid) 74 | 75 | 76 | def set_session(aid, session): 77 | """ 78 | Make a copy of the given session data and set it for the given 79 | account ID. 80 | """ 81 | global _global_sessions 82 | assert isinstance(aid, str) 83 | assert isinstance(aid, (str, type(None))) 84 | _read_sessions() 85 | if session is not None: 86 | _global_sessions[aid] = session 87 | else: 88 | _global_sessions.pop(aid) 89 | _write_sessions() 90 | 91 | 92 | # The account data we read from disk, including all of the messages 93 | # and such. It's a dictionary where the "accounts" key is a dictionary 94 | # mapping account IDs to dictionaries with information about the users 95 | # and conversations in the account. See _read_account_data for the 96 | # exact format. 97 | _global_account_data = None 98 | 99 | 100 | def _assert_valid_account_data(account_data): 101 | """ 102 | Throw an assert error if the data for the given account is 103 | malformed. The data is one value in the overall account data 104 | dictionary (under the "accounts" key). 105 | """ 106 | assert isinstance(account_data, dict) 107 | assert isinstance(account_data["name"], str) 108 | assert isinstance(account_data["users"], dict) 109 | for uid, user in account_data["users"].items(): 110 | assert isinstance(uid, str) 111 | assert isinstance(user, dict) 112 | assert isinstance(user["name"], str) 113 | assert isinstance(account_data["conversations"], list) 114 | for conversation in account_data["conversations"]: 115 | assert isinstance(conversation, dict) 116 | assert isinstance(conversation["id"], str) 117 | assert isinstance(conversation["name"], str) 118 | assert isinstance(conversation["timestamp"], int) 119 | assert isinstance(conversation["participants"], dict) 120 | for uid, participant in conversation["participants"].items(): 121 | assert isinstance(uid, str) 122 | assert isinstance(participant, dict) 123 | assert isinstance(participant["lastSeenMessage"], (str, type(None))) 124 | assert uid in account_data["users"] 125 | assert isinstance(conversation["messages"], list) 126 | for message in conversation["messages"]: 127 | assert isinstance(message, dict) 128 | assert isinstance(message["id"], str) 129 | assert message["type"] in {"text", "image", "file"} 130 | assert isinstance(message["content"], str) 131 | assert isinstance(message["timestamp"], int) 132 | assert isinstance(message["sender"], str) 133 | assert len(set(m["id"] for m in conversation["messages"])) == len( 134 | conversation["messages"] 135 | ) 136 | assert util.is_sorted(conversation["messages"], key=lambda m: -m["timestamp"]) 137 | assert len(set(c["id"] for c in account_data["conversations"])) == len( 138 | account_data["conversations"] 139 | ) 140 | # Unfortunately, because of Messenger, we can't guarantee that 141 | # conversations are sorted by timestamp. Rip. 142 | 143 | 144 | def _read_account_data(): 145 | """ 146 | Read the account data from disk, if it's not already cached. Don't 147 | return anything. Throw an assert error if the data is malformed. 148 | """ 149 | global _global_account_data 150 | if _global_account_data is not None: 151 | return 152 | try: 153 | with open(_MESSAGES_FILE) as f: 154 | _global_account_data = json.load(f) 155 | except FileNotFoundError: 156 | _global_account_data = {"version": 1, "accounts": {}} 157 | assert isinstance(_global_account_data, dict) 158 | assert isinstance(_global_account_data["version"], int) 159 | assert _global_account_data["version"] == 1 160 | assert isinstance(_global_account_data["accounts"], dict) 161 | for key, val in _global_account_data["accounts"].items(): 162 | assert isinstance(key, str) 163 | _assert_valid_account_data(val) 164 | 165 | 166 | def _write_account_data(): 167 | """ 168 | Write the current account data to disk. You need to call 169 | _read_account_data first. 170 | """ 171 | global _global_account_data 172 | _MESSAGES_FILE.parent.mkdir(parents=True, exist_ok=True) 173 | with atomicwrites.atomic_write(_MESSAGES_FILE, overwrite=True) as f: 174 | json.dump(_global_account_data, f, indent=2) 175 | f.write("\n") 176 | 177 | 178 | def get_account_data(aid): 179 | """ 180 | Return a (deep) copy of all the account data for the given account 181 | ID. 182 | """ 183 | global _global_account_data 184 | assert isinstance(aid, str) 185 | _read_account_data() 186 | return copy.deepcopy(_global_account_data["accounts"].get(aid)) 187 | 188 | 189 | def set_account_data(aid, account_data): 190 | """ 191 | Update the account data for the given account ID to point at a 192 | (deep) copy of the provided data. 193 | """ 194 | global _global_account_data 195 | assert isinstance(aid, str) 196 | _assert_valid_account_data(account_data) 197 | _read_account_data() 198 | _global_account_data["accounts"][aid] = copy.deepcopy(account_data) 199 | _write_account_data() 200 | -------------------------------------------------------------------------------- /mercury/_util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Your standard util module for anything that's needed by more than one 3 | other module. 4 | """ 5 | 6 | import sys 7 | import time 8 | 9 | import xdg 10 | 11 | 12 | # Directory where all the Mercury files are stored by default. 13 | MERCURY_DIR = xdg.XDG_DATA_HOME / "mercury" 14 | 15 | # Timestamp for when module is imported, used to display relative 16 | # times in log messages. 17 | START_TIME = time.time() 18 | 19 | 20 | def is_sorted(lst, key=id): 21 | """ 22 | Check if the list is in sorted order, optionally mapping every 23 | element using the provided key first. Return a boolean. 24 | """ 25 | return all(key(lst[i]) <= key(lst[i + 1]) for i in range(len(lst) - 1)) 26 | 27 | 28 | def merge_sorted_seqs(left, right, key=id): 29 | """ 30 | Merge sequences in sorted order. left and right are iterables 31 | which should be sorted according to the provided key. Return an 32 | iterable of all the items from both sequences interleaved in 33 | sorted order. 34 | """ 35 | sentinel = object() 36 | left_iter = iter(left) 37 | right_iter = iter(right) 38 | cur_left = next(left_iter, sentinel) 39 | cur_right = next(right_iter, sentinel) 40 | while cur_left is not sentinel or cur_right is not sentinel: 41 | if cur_right is sentinel or key(cur_left) <= key(cur_right): 42 | yield cur_left 43 | cur_left = next(left_iter, sentinel) 44 | else: 45 | yield cur_right 46 | cur_right = next(right_iter, sentinel) 47 | 48 | 49 | def log(fmt, *args, **kwargs): 50 | """ 51 | Log message to stderr with relative timestamp. Same arguments as 52 | format. 53 | """ 54 | print( 55 | "[{:09.2f}s]".format(time.time() - START_TIME), 56 | fmt.format(*args, **kwargs), 57 | file=sys.stderr, 58 | ) 59 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "aenum" 3 | version = "2.2.1" 4 | description = "Advanced Enumerations (compatible with Python's stdlib Enum), NamedTuples, and NamedConstants" 5 | category = "main" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "atomicwrites" 11 | version = "1.3.0" 12 | description = "Atomic file writes." 13 | category = "main" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 16 | 17 | [[package]] 18 | name = "attrs" 19 | version = "19.2.0" 20 | description = "Classes Without Boilerplate" 21 | category = "main" 22 | optional = false 23 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 24 | 25 | [package.extras] 26 | azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] 27 | dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] 28 | docs = ["sphinx", "zope.interface"] 29 | tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 30 | 31 | [[package]] 32 | name = "beautifulsoup4" 33 | version = "4.8.0" 34 | description = "Screen-scraping library" 35 | category = "main" 36 | optional = false 37 | python-versions = "*" 38 | 39 | [package.dependencies] 40 | soupsieve = ">=1.2" 41 | 42 | [package.extras] 43 | html5lib = ["html5lib"] 44 | lxml = ["lxml"] 45 | 46 | [[package]] 47 | name = "certifi" 48 | version = "2019.9.11" 49 | description = "Python package for providing Mozilla's CA Bundle." 50 | category = "main" 51 | optional = false 52 | python-versions = "*" 53 | 54 | [[package]] 55 | name = "charset-normalizer" 56 | version = "2.0.12" 57 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 58 | category = "main" 59 | optional = false 60 | python-versions = ">=3.5.0" 61 | 62 | [package.extras] 63 | unicode_backport = ["unicodedata2"] 64 | 65 | [[package]] 66 | name = "fbchat" 67 | version = "1.8.3" 68 | description = "Facebook Chat (Messenger) for Python" 69 | category = "main" 70 | optional = false 71 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4.0" 72 | 73 | [package.dependencies] 74 | aenum = ">=2.0,<3.0" 75 | attrs = ">=18.2" 76 | beautifulsoup4 = ">=4.0,<5.0" 77 | requests = ">=2.19,<3.0" 78 | 79 | [package.extras] 80 | docs = ["sphinx (>=2.0,<3.0)", "sphinxcontrib-spelling (>=4.0,<5.0)"] 81 | lint = ["black"] 82 | test = ["pytest (>=4.0,<5.0)", "six (>=1.0,<2.0)"] 83 | tools = ["bump2version (>=0.5.0,<0.6.0)"] 84 | 85 | [[package]] 86 | name = "idna" 87 | version = "2.8" 88 | description = "Internationalized Domain Names in Applications (IDNA)" 89 | category = "main" 90 | optional = false 91 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 92 | 93 | [[package]] 94 | name = "requests" 95 | version = "2.27.1" 96 | description = "Python HTTP for Humans." 97 | category = "main" 98 | optional = false 99 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 100 | 101 | [package.dependencies] 102 | certifi = ">=2017.4.17" 103 | charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} 104 | idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} 105 | urllib3 = ">=1.21.1,<1.27" 106 | 107 | [package.extras] 108 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] 109 | use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] 110 | 111 | [[package]] 112 | name = "soupsieve" 113 | version = "1.9.4" 114 | description = "A modern CSS selector implementation for Beautiful Soup." 115 | category = "main" 116 | optional = false 117 | python-versions = "*" 118 | 119 | [[package]] 120 | name = "urllib3" 121 | version = "1.26.5" 122 | description = "HTTP library with thread-safe connection pooling, file post, and more." 123 | category = "main" 124 | optional = false 125 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 126 | 127 | [package.extras] 128 | brotli = ["brotlipy (>=0.6.0)"] 129 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 130 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 131 | 132 | [[package]] 133 | name = "xdg" 134 | version = "4.0.1" 135 | description = "Variables defined by the XDG Base Directory Specification" 136 | category = "main" 137 | optional = false 138 | python-versions = ">=3.6,<4.0" 139 | 140 | [metadata] 141 | lock-version = "1.1" 142 | python-versions = ">=3.6, <4.0" 143 | content-hash = "fcca21cb6ad18d039ab488237e9cef8458045b110146d8cb763408b437708171" 144 | 145 | [metadata.files] 146 | aenum = [ 147 | {file = "aenum-2.2.1-py2-none-any.whl", hash = "sha256:e4dab068cbe00295bbc3660cd562221b008687d0f7a4c40fc2dd7a80002126a7"}, 148 | {file = "aenum-2.2.1-py3-none-any.whl", hash = "sha256:0e3589654ef090784971f7778dcb74b08c9b4ef80b33267c00f82ddeedac179a"}, 149 | {file = "aenum-2.2.1.tar.gz", hash = "sha256:b12a7be3d89b270f266f8643aaa126404e5cdc0929bd6f09548b8eaed85e2aa1"}, 150 | ] 151 | atomicwrites = [ 152 | {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"}, 153 | {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"}, 154 | ] 155 | attrs = [ 156 | {file = "attrs-19.2.0-py2.py3-none-any.whl", hash = "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2"}, 157 | {file = "attrs-19.2.0.tar.gz", hash = "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396"}, 158 | ] 159 | beautifulsoup4 = [ 160 | {file = "beautifulsoup4-4.8.0-py2-none-any.whl", hash = "sha256:05668158c7b85b791c5abde53e50265e16f98ad601c402ba44d70f96c4159612"}, 161 | {file = "beautifulsoup4-4.8.0-py3-none-any.whl", hash = "sha256:f040590be10520f2ea4c2ae8c3dae441c7cfff5308ec9d58a0ec0c1b8f81d469"}, 162 | {file = "beautifulsoup4-4.8.0.tar.gz", hash = "sha256:25288c9e176f354bf277c0a10aa96c782a6a18a17122dba2e8cec4a97e03343b"}, 163 | ] 164 | certifi = [ 165 | {file = "certifi-2019.9.11-py2.py3-none-any.whl", hash = "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"}, 166 | {file = "certifi-2019.9.11.tar.gz", hash = "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50"}, 167 | ] 168 | charset-normalizer = [ 169 | {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, 170 | {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, 171 | ] 172 | fbchat = [ 173 | {file = "fbchat-1.8.3-py2.py3-none-any.whl", hash = "sha256:399d5faa9c99b26d12eae33cc90dbf69660c830f3c298b61d54e9a501f0270b3"}, 174 | {file = "fbchat-1.8.3.tar.gz", hash = "sha256:710efbc71c3d253ac05b4bf3cb1e82b0d4276e7c671a0f8a32f82c6a184150a1"}, 175 | ] 176 | idna = [ 177 | {file = "idna-2.8-py2.py3-none-any.whl", hash = "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"}, 178 | {file = "idna-2.8.tar.gz", hash = "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407"}, 179 | ] 180 | requests = [ 181 | {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, 182 | {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, 183 | ] 184 | soupsieve = [ 185 | {file = "soupsieve-1.9.4-py2.py3-none-any.whl", hash = "sha256:b91d676b330a0ebd5b21719cb6e9b57c57d433671f65b9c28dd3461d9a1ed0b6"}, 186 | {file = "soupsieve-1.9.4.tar.gz", hash = "sha256:605f89ad5fdbfefe30cdc293303665eff2d188865d4dbe4eb510bba1edfbfce3"}, 187 | ] 188 | urllib3 = [ 189 | {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"}, 190 | {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"}, 191 | ] 192 | xdg = [ 193 | {file = "xdg-4.0.1-py3-none-any.whl", hash = "sha256:bf9032b027e3061d38c362a21b14dcf057a5b5a4906956f8e8278cefdf73f38b"}, 194 | {file = "xdg-4.0.1.tar.gz", hash = "sha256:c939c99def394cbaf765a3ee55efd6ea7e4c5eaed8d9ebc2d03af84ba35dec57"}, 195 | ] 196 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "mercury" 3 | version = "0" 4 | description = "Emacs interface to Facebook Messenger" 5 | authors = ["Radian LLC "] 6 | 7 | [tool.poetry.dependencies] 8 | python = ">=3.6, <4.0" 9 | fbchat = "^1.6" 10 | atomicwrites = "^1.3" 11 | xdg = "^4.0" 12 | 13 | [tool.poetry.dev-dependencies] 14 | 15 | [build-system] 16 | requires = ["poetry>=0.12"] 17 | build-backend = "poetry.masonry.api" 18 | --------------------------------------------------------------------------------