| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820 |
- ;; -*- lexical-binding: t; -*-
- ;; Copyright (C) 2024 Free Software Foundation, Inc.
- ;; Author: Yuchen Pei <id@ypei.org>
- ;; Package-Requires: ((emacs "29.4") (request "0.3.3"))
- ;; This file is part of exitter.
- ;; exitter is free software: you can redistribute it and/or modify it under
- ;; the terms of the GNU Affero General Public License as published by
- ;; the Free Software Foundation, either version 3 of the License, or
- ;; (at your option) any later version.
- ;; exitter is distributed in the hope that it will be useful, but WITHOUT
- ;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- ;; or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- ;; Public License for more details.
- ;; You should have received a copy of the GNU Affero General Public
- ;; License along with exitter. If not, see <https://www.gnu.org/licenses/>.
- (require 'request)
- (defvar exitter-url-endpoint "https://api.twitter.com/1.1")
- (defvar exitter-url-activate
- (format "%s/guest/activate.json" exitter-url-endpoint))
- (defvar exitter-url-task
- (format "%s/onboarding/task.json" exitter-url-endpoint))
- (defvar exitter-url-token
- (format "https://api.twitter.com/oauth2/token"))
- (defvar exitter-url-tweet-detail
- "https://api.twitter.com/graphql/3XDB26fBve-MmjHaWTUZxA/TweetDetail")
- (defvar exitter-tor-param "-x socks5://127.0.0.1:9050/")
- (defvar exitter-init-headers
- `(
- ("Content-Type" . "application/json")
- ("User-Agent" . "TwitterAndroid/10.10.0 (29950000-r-0) ONEPLUS+A3010/9 (OnePlus;ONEPLUS+A3010;OnePlus;OnePlus3;0;;1;2016)")
- ;; ("User-Agent" . "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36")
- ("X-Twitter-API-Version" . "5")
- ("X-Twitter-Client" . "TwitterAndroid")
- ("X-Twitter-Client-Version" . "10.10.0")
- ("OS-Version" . "28")
- ("System-User-Agent" . "Dalvik/2.1.0 (Linux; U; Android 9; ONEPLUS A3010 Build/PKQ1.181203.001)")
- ("X-Twitter-Active-User" . "yes")
- ))
- (defvar exitter-default-features
- (json-encode
- '(
- ("android_graphql_skip_api_media_color_palette" . :json-false)
- ("blue_business_profile_image_shape_enabled" . :json-false)
- ("creator_subscriptions_subscription_count_enabled" . :json-false)
- ("creator_subscriptions_tweet_preview_api_enabled" . t)
- ("freedom_of_speech_not_reach_fetch_enabled" . :json-false)
- ("graphql_is_translatable_rweb_tweet_is_translatable_enabled" . :json-false)
- ("hidden_profile_likes_enabled" . :json-false)
- ("highlights_tweets_tab_ui_enabled" . :json-false)
- ("interactive_text_enabled" . :json-false)
- ("longform_notetweets_consumption_enabled" . t)
- ("longform_notetweets_inline_media_enabled" . :json-false)
- ("longform_notetweets_richtext_consumption_enabled" . t)
- ("longform_notetweets_rich_text_read_enabled" . :json-false)
- ("responsive_web_edit_tweet_api_enabled" . :json-false)
- ("responsive_web_enhance_cards_enabled" . :json-false)
- ("responsive_web_graphql_exclude_directive_enabled" . t)
- ("responsive_web_graphql_skip_user_profile_image_extensions_enabled" . :json-false)
- ("responsive_web_graphql_timeline_navigation_enabled" . :json-false)
- ("responsive_web_media_download_video_enabled" . :json-false)
- ("responsive_web_text_conversations_enabled" . :json-false)
- ("responsive_web_twitter_article_tweet_consumption_enabled" . :json-false)
- ("responsive_web_twitter_blue_verified_badge_is_enabled" . t)
- ("rweb_lists_timeline_redesign_enabled" . t)
- ("spaces_2022_h2_clipping" . t)
- ("spaces_2022_h2_spaces_communities" . t)
- ("standardized_nudges_misinfo" . :json-false)
- ("subscriptions_verification_info_enabled" . t)
- ("subscriptions_verification_info_reason_enabled" . t)
- ("subscriptions_verification_info_verified_since_enabled" . t)
- ("super_follow_badge_privacy_enabled" . :json-false)
- ("super_follow_exclusive_tweet_notifications_enabled" . :json-false)
- ("super_follow_tweet_api_enabled" . :json-false)
- ("super_follow_user_api_enabled" . :json-false)
- ("tweet_awards_web_tipping_enabled" . :json-false)
- ("tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled" . :json-false)
- ("tweetypie_unmention_optimization_enabled" . :json-false)
- ("unified_cards_ad_metadata_container_dynamic_card_content_query_enabled" . :json-false)
- ("verified_phone_label_enabled" . :json-false)
- ("vibe_api_enabled" . :json-false)
- ("view_counts_everywhere_api_enabled" . :json-false)
- )))
- (defvar exitter-oauth-consumer-key nil)
- (defvar exitter-oauth-consumer-secret nil)
- (defvar exitter-access-token nil)
- (defvar exitter-username nil)
- (defvar exitter-password nil)
- (defvar exitter-email nil)
- (defvar exitter-oauth-token nil)
- (defvar exitter-oauth-token-secret nil)
- (defvar exitter-oauth-token-ctime nil)
- (defvar exitter-debug nil)
- ;;; for debugging
- (if exitter-debug
- (setq request-message-level 'blather)
- (setq request-message-level -1))
- ;;; Get an oauth2 bearer token
- ;;; https://developer.x.com/en/docs/authentication/api-reference/token
- (defun exitter-get-access-token ()
- (let ((oauth-consumer-key-secret
- (base64-encode-string
- (format "%s:%s" exitter-oauth-consumer-key
- exitter-oauth-consumer-secret)
- t)))
- (request exitter-url-token
- :headers `(("Authorization" .
- ,(format "Basic %s" oauth-consumer-key-secret))
- ("Content-Type" . "application/x-www-form-urlencoded"))
- :data "grant_type=client_credentials"
- :parser 'json-read
- :type "POST"
- :success (cl-function
- (lambda (&key data &allow-other-keys)
- ;; will generate exactly the same token as
- ;; `exitter-access-token'
- (print (alist-get 'access_token data))))
- :error
- (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
- (message "Got error: %S" error-thrown)))
- )))
- ;;; Use the twitter frontend flow and the oauth2 bearer token to
- ;;; obtain a user oauth token aka user access token.
- ;;; The official way of doing so is by oauth 1.0a documented at
- ;;; https://developer.x.com/en/docs/authentication/oauth-1-0a, but
- ;;; here we use the twitter frontend flow, similar to how one logs in
- ;;; the twitter web client, see also
- ;;; https://github.com/fa0311/TwitterFrontendFlow
- (defun exitter-get-guest-token ()
- (when exitter-debug (message "entering exitter-get-guest-token"))
- (request exitter-url-activate
- :headers `(("Authorization" . ,(format "Bearer %s" exitter-access-token)))
- :parser 'json-read
- :type "POST"
- :success (cl-function
- (lambda (&key data &allow-other-keys)
- (exitter-login-flow-token (alist-get 'guest_token data))))
- :error
- (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
- (message "Got error: %S" error-thrown)))
- ))
- (defun exitter-login-flow-token (guest-token)
- (when exitter-debug (message "entering exitter-login-flow-token"))
- (let ((headers `(,@exitter-init-headers
- ("Authorization" . ,(format "Bearer %s" exitter-access-token))
- ("X-Guest-Token" . ,guest-token)
- )))
- (request exitter-url-task
- :params '(("flow_name" . "login")
- ("lang" . "en"))
- :headers headers
- :data (json-encode
- '(("flow_token" . nil)
- ("input_flow_data" .
- (("country_code" . nil)
- ("flow_context" .
- (("referral_context" .
- (("referral_details" .
- "utm_source=google-play&utm_medium=organic")
- ("referrer_url" . "")))
- ("start_location" . (("location" . "deeplink")))
- ("request_variant" . nil)
- ("target_user_id" . 0)))))))
- :parser 'json-read
- :type "POST"
- :complete (cl-function
- (lambda (&key response &allow-other-keys)
- (let* ((att
- (request-response-header response "att"))
- (data
- (request-response-data response))
- (flow-token (alist-get 'flow_token data)))
- (unless (exitter-find-subtask data "LoginEnterUserIdentifier")
- (error "Subtask LoginEnterUserIdentifier not found"))
- ;; (pp data)
- ;; (message "flow-token: %s\natt: %s"
- ;; (alist-get 'flow_token data)
- ;; att)
- (when att
- (setq headers
- `(,@headers
- ("att" . ,att)
- ("cookie" . ,(format "att=%s" att)))))
- (exitter-enter-username flow-token headers))))
- :error
- (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
- (message "Got error: %S" error-thrown)))
- )))
- (defun exitter-find-subtask (data subtask-id)
- (when exitter-debug
- (message "entering exitter-find-subtask")
- (message "subtask-id: %s" subtask-id))
- (seq-find
- (lambda (subtask)
- (equal (alist-get 'subtask_id subtask) subtask-id))
- (alist-get 'subtasks data)))
- (defun exitter-report-error (&rest args &key error-thrown &allow-other-keys)
- (message "Got error: %S" error-thrown))
- (defun exitter-enter-username (flow-token headers)
- (when exitter-debug (message "entering exitter-enter-username"))
- (request exitter-url-task
- :params '(("lang" . "en"))
- :headers headers
- :data (json-encode
- `(("flow_token" . ,flow-token)
- ("subtask_inputs" .
- [(("enter_text" .
- (("suggestion_id" . nil)
- ("text" . ,exitter-username)
- ("link" . "next_link")))
- ("subtask_id" . "LoginEnterUserIdentifier"))])))
- :type "POST"
- :parser 'json-read
- :success (cl-function
- (lambda (&key data &allow-other-keys)
- (let ((new-flow-token
- (alist-get 'flow_token data)))
- (cond
- ((exitter-find-subtask data "LoginEnterPassword")
- (message "LoginEnterPassword")
- (exitter-enter-password new-flow-token headers))
- ((exitter-find-subtask data "LoginEnterAlternateIdentifierSubtask")
- (message "LoginEnterAlternateIdentifierSubtask")
- (exitter-enter-email new-flow-token headers))
- (t (message "Cannot find any matching subtasks"))))
- ))
- :error
- (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
- (message "Got error: %S" error-thrown)))))
- (defun exitter-enter-password (flow-token headers)
- (when exitter-debug (message "entering exitter-enter-password"))
- (request exitter-url-task
- :params '(("lang" . "en"))
- :headers headers
- :data (json-encode
- `(("flow_token" . ,flow-token)
- ("subtask_inputs" .
- [(("enter_password" .
- (("password" . ,exitter-password)
- ("link" . "next_link")))
- ("subtask_id" . "LoginEnterPassword"))])))
- :type "POST"
- :parser 'json-read
- :success (cl-function
- (lambda (&key data &allow-other-keys)
- (cond
- ((exitter-find-subtask data "LoginSuccessSubtask")
- (message "LoginSuccessSubtask")
- (let* ((subtask
- (exitter-find-subtask data "LoginSuccessSubtask"))
- (open-account (alist-get 'open_account subtask)))
- (setq exitter-oauth-token
- (alist-get 'oauth_token open-account)
- exitter-oauth-token-secret
- (alist-get 'oauth_token_secret open-account)
- exitter-oauth-token-ctime
- (format-time-string "%Y-%m-%d %a %H:%M:%S"
- (current-time)))))
- (t (message "Cannot find any matching subtasks"))) ))
- :error
- (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
- (message "Got error: %S" error-thrown)))
- ))
- (defun exitter-enter-email (flow-token headers)
- (when exitter-debug (message "entering exitter-enter-email"))
- (request exitter-url-task
- :params '(("lang" . "en"))
- :headers headers
- :data (json-encode
- `(("flow_token" . ,flow-token)
- ("subtask_inputs" .
- [(("enter_text" .
- (("text" . ,exitter-email)
- ("link" . "next_link")))
- ("subtask_id" . "LoginEnterAlternateIdentifierSubtask"))])))
- :type "POST"
- :parser 'json-read
- :success (cl-function
- (lambda (&key data &allow-other-keys)
- (let ((new-flow-token
- (alist-get 'flow_token data)))
- (cond
- ((exitter-find-subtask data "LoginEnterPassword")
- (message "LoginEnterPassword")
- (exitter-enter-password new-flow-token headers))
- (t (message "Cannot find any matching subtasks"))))
- ))
- :error
- (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
- (message "Got error: %S" error-thrown)))
- ))
- ;;; Example at
- ;;; https://developer.x.com/en/docs/authentication/oauth-1-0a/creating-a-signature
- ;;; should produce signature "Ls93hJiZbQ3akF3HF3x1Bz8%2FzU4%3D"
- (defun exitter-example-sign-oauth ()
- (exitter-get-sign-oauth
- "https://api.x.com/1.1/statuses/update.json" ;link
- '(("status" . "Hello Ladies + Gentlemen, a signed OAuth request!")
- ("include_entities" . "true")) ;url-params
- "POST" ;method
- "xvz1evFS4wEEPTGEFPHBog" ;oauth-consumer-key
- "kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw" ;oauth-consumer-secret
- "370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb" ;oauth-token
- "LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE" ;oauth-token-secret
- "kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg" ;oauth-nonce
- "1318622958" ;oauth-timestamp
- ))
- ;;; see
- ;;; https://developer.x.com/en/docs/authentication/oauth-1-0a/creating-a-signature
- ;;; https://developer.x.com/en/docs/authentication/oauth-1-0a/authorizing-a-request
- (defun exitter-get-sign-oauth (link url-params method &optional
- oauth-consumer-key oauth-consumer-secret
- oauth-token oauth-token-secret
- oauth-nonce oauth-timestamp)
- (let* ((oauth-params `(("oauth_consumer_key" .
- ,(or oauth-consumer-key exitter-oauth-consumer-key))
- ("oauth_nonce" . ,(or oauth-nonce (exitter-nonce)))
- ("oauth_signature_method" . "HMAC-SHA1")
- ("oauth_timestamp" . ,(or oauth-timestamp (format-time-string "%s" (current-time))))
- ("oauth_token" . ,(or oauth-token exitter-oauth-token))
- ("oauth_version" . "1.0")))
- (all-params (sort (seq-concatenate 'list url-params oauth-params)
- (lambda (a b) (string< (car a) (car b)))))
- (method-up (upcase method))
- (hexed-link (url-hexify-string link))
- (param-to-sign-unencoded
- (mapconcat
- (lambda (pair)
- (format "%s=%s" (car pair) (url-hexify-string (cdr pair))))
- all-params
- "&"))
- (param-to-sign
- (replace-regexp-in-string
- "&" "%26"
- (replace-regexp-in-string
- "=" "%3D"
- (replace-regexp-in-string
- "%" "%25"
- (replace-regexp-in-string
- "\\+" "%20"
- param-to-sign-unencoded)))))
- (to-sign (format "%s&%s&%s" method-up hexed-link param-to-sign))
- (signing-key (format "%s&%s"
- (or oauth-consumer-secret
- exitter-oauth-consumer-secret)
- (or oauth-token-secret
- exitter-oauth-token-secret)))
- (signature (url-hexify-string
- (base64-encode-string
- (exitter-hmac-sha1 (encode-coding-string signing-key 'utf-8)
- (encode-coding-string to-sign 'utf-8)))))
- )
- ;; (message "PARAM-TO-SIGN-UNENC: %s" (prin1-to-string param-to-sign-unencoded))
- ;; (message "PARAM-TO-SIGN: %s" (prin1-to-string param-to-sign))
- ;; (message "TO-SIGN: %s" (prin1-to-string to-sign))
- (format "OAuth realm=\"http://api.twitter.com/\", oauth_signature=\"%s\", %s"
- signature
- (mapconcat
- (lambda (pair)
- (format "%s=\"%s\"" (car pair) (cdr pair)))
- oauth-params
- ", "))))
- (defun exitter-do-fetch (link params &optional headers cb)
- (when exitter-debug (message "entering exitter-do-fetch"))
- (let ((authorization (exitter-get-sign-oauth link params "GET")))
- (request link
- :headers `(,@headers
- ("Connection" . "Keep-Alive")
- ("Authorization" . ,authorization)
- ("Content-Type" . "application/json")
- ("X-Twitter-Active-User" . "yes")
- ("Authority" . "api.twitter.com")
- ("Accept-Encoding" . "gzip")
- ("Accept-Language" . "en-US,en;q=0.9")
- ("Accept" . "*/*")
- ("DNT" . "1")
- ("User-Agent" .
- "TwitterAndroid/10.10.0 (29950000-r-0) ONEPLUS+A3010/9 \
- (OnePlus;ONEPLUS+A3010;OnePlus;OnePlus3;0;;1;2016)")
- ("X-Twitter-API-Version" . "5")
- ("X-Twitter-Client" . "TwitterAndroid")
- ("X-Twitter-Client-Version" . "10.10.0")
- ("OS-Version" . "28")
- ("System-User-Agent" .
- "Dalvik/2.1.0 (Linux; U; Android 9; \
- ONEPLUS A3010 Build/PKQ1.181203.001)")
- )
- :params params
- :type "GET"
- :parser 'json-read
- :success (if cb
- (cl-function
- (lambda (&key data &allow-other-keys)
- (funcall cb data)))
- (cl-function
- (lambda (&key data &allow-other-keys)
- (pp data))))
- :error
- (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
- (message "Got error: %S" error-thrown)))
- )))
- (defun exitter-get-tweet (id)
- (let ((variables (json-encode
- `(
- ("focalTweetId" . ,id)
- ;; ("referrer" . "tweet")
- ;; ("with_rux_injections" . :json-false)
- ("includePromotedContent" . :json-false)
- ;; ("withCommunity" . t)
- ("withQuickPromoteEligibilityTweetFields" . :json-false)
- ("includeHasBirdwatchNotes" . :json-false)
- ("withBirdwatchNotes" . :json-false)
- ("withVoice" . :json-false)
- ("withV2Timeline" . t)
- ))))
- (exitter-do-fetch
- exitter-url-tweet-detail
- `(("variables" . ,variables)
- ("features" . ,exitter-default-features))
- nil
- (lambda (data)
- ;; (pp data)
- (exitter-save-posts (exitter-filter-tweet-details data) id)))))
- ;;; transform alist
- (defun exitter-filter-tweet-details (resp)
- (vconcat
- (seq-map
- 'exitter-filter-tweet-entry
- (alist-get
- 'entries
- (let-alist resp
- (elt .data.threaded_conversation_with_injections_v2.instructions 0))))))
- ;;; .content.entryType == "TimelineTimelineItem":
- ;;; If further its .content.itemContent.itemType=="TimelineTweet",
- ;;; then this is a simple case and we could filter its
- ;;; .content.itemContent.tweet_results.result. Otherwise if its
- ;;; .content.itemContent.itemType=="TimelineTimelineCursor" then it is
- ;;; paging which we need to figure out later.
- ;;; .content.entryType == "TimelineTimelineModule":
- ;;; has ~items~ containing entries. But these entries do not have
- ;;; content nor entryType anywhere. Instead they each have an
- ;;; item.itemContent.tweet_results.result
- (defun exitter-filter-tweet-entry (entry)
- (let-alist entry
- (cond
- ((equal .content.entryType "TimelineTimelineItem")
- (let-alist .content.itemContent
- (pcase .itemType
- ("TimelineTweet"
- (exitter-filter-tweet-result .tweet_results.result))
- ("TimelineTimelineCursor"
- (message "TimelineTimelineCursor encountered. More tweets available"))
- (_
- (error "TimelineTimelineItem entry with unknown itemType: %s"
- (pp entry))))))
- ((equal .content.entryType "TimelineTimelineModule")
- (vconcat (seq-map 'exitter-filter-tweet-entry .content.items)))
- ((equal .item.itemContent.itemType "TimelineTweet")
- (exitter-filter-tweet-result .item.itemContent.tweet_results.result))
- ((equal .item.itemContent.itemType "TimelineTimelineCursor")
- (message "TimelineTimelineCursor encountered. More tweets available"))
- (t (error "Entry with unknown entryType or itemType: %s" (pp entry))))))
- (defun exitter-filter-tweet-result (result)
- (pcase (alist-get '__typename result)
- ("Tweet"
- (let (ret author quoted post)
- (let-alist result
- (let-alist .core.user_results.result.legacy
- (setq author
- `((screen_name . ,.screen_name)
- (name . ,.name))))
- (when .quoted_status_result
- (setq quoted
- (exitter-filter-tweet-result .quoted_status_result.result)))
- (let-alist .legacy
- (setq post
- `((id_str . ,.id_str)
- (created_at . ,.created_at)
- (full_text . ,.full_text)
- (urls . ,(vconcat .entities.urls .entities.media))
- (reply_count . ,.reply_count)
- (retweet_count . ,.retweet_count)
- (quote_count . ,.quote_count)
- (favorite_count . ,.favorite_count)
- (in_reply_to_status_id_str . ,.in_reply_to_status_id_str)
- (is_quote_status . ,.is_quote_status)
- (author . ,author)
- (quoted . ,quoted))))
- (when .note_tweet.is_expandable
- (let-alist .note_tweet.note_tweet_results.result
- (push `(expanded_text . ,.text) post)
- (push `(extra_urls . ,.entity_set.urls) post)))
- (push `(post . ,post) ret)
- )
- ret))
- ("TweetWithVisibilityResults"
- (message "Aaaaaaads"))
- (_
- (error "result with unknown __typename: %s" result))))
- ;;; renderer, similar to mastorg
- (require 'hierarchy)
- (defun exitter-post-make-parent-fn (posts)
- "Given a collection of POSTS, return a function that find the parent post."
- (lambda (post)
- (let ((id (alist-get 'in_reply_to_status_id_str post)))
- (seq-find
- (lambda (candidate)
- (equal (alist-get 'id_str candidate) id))
- posts))))
- ;;; Formatting functions
- (defun exitter-format-posts (filtered-details)
- "Format a post tree of post located at URL.
- Including ancestors and descendants, if any."
- (let ((posts-hier (hierarchy-new))
- (posts (exitter-vector-flatten filtered-details)))
- (hierarchy-add-trees
- posts-hier
- posts
- (exitter-post-make-parent-fn posts))
- (string-join
- (hierarchy-map 'exitter-format-post posts-hier 1)
- "\n")))
- (defun exitter-save-posts (filtered-details id)
- ;; (pp filtered-details)
- (exitter-save-text-and-switch-to-buffer
- (exitter-format-posts filtered-details)
- (format "~/Downloads/%s.org" id)))
- (defun exitter-post-url (user post-id)
- (format "https://x.com/%s/status/%s" user post-id))
- (defun exitter-make-org-link (link desc)
- (format "[[%s][%s]]" link desc))
- (defun exitter-format-post (post level)
- "Format a POST with indent LEVEL."
- (let-alist post
- (format "%s %s (@%s) %s\n\n%s%s\n\n⤷%s “%s ⇆%s ★%s\n"
- (make-string level ?*)
- .author.name
- .author.screen_name
- (exitter-make-org-link
- (exitter-post-url .author.screen_name .id_str)
- (exitter--relative-time-description .created_at))
- (exitter-replace-t-co-links (or .expanded_text .full_text)
- (or .extra_urls .urls))
- (if .quoted
- (format "\n\n----\n%s----"
- (replace-regexp-in-string
- "^." " \\&" (exitter-format-post .quoted.post 1)))
- "")
- .reply_count
- .quote_count
- .retweet_count
- .favorite_count
- )))
- (defun exitter-post-url-p (url)
- (string-match-p "^/[^/]+/status/\\([0-9]+\\)"
- (url-filename (url-generic-parse-url url))))
- (defun exitter-open-post (url)
- (interactive "sTwitter link: ")
- (let ((path-etc (url-filename (url-generic-parse-url url))))
- (unless (string-match "^/[^/]+/status/\\([0-9]+\\)" path-etc)
- (error "Not a valid x/twitter (or a frontend) url!"))
- (exitter-get-tweet (match-string 1 path-etc))))
- ;;; utilities
- ;;; code adapted from mastodon.el
- (defun exitter--human-duration (seconds &optional resolution)
- "Return a string describing SECONDS in a more human-friendly way.
- The return format is (STRING . RES) where RES is the resolution of
- this string, in seconds.
- RESOLUTION is the finest resolution, in seconds, to use for the
- second part of the output (defaults to 60, so that seconds are only
- displayed when the duration is smaller than a minute)."
- (cl-assert (>= seconds 0))
- (unless resolution (setq resolution 60))
- (let* ((units exitter--time-units)
- (n1 seconds) (unit1 (pop units)) (res1 1)
- n2 unit2 res2
- next)
- (while (and units (> (truncate (setq next (/ n1 (car units)))) 0))
- (setq unit2 unit1)
- (setq res2 res1)
- (setq n2 (- n1 (* (car units) (truncate n1 (car units)))))
- (setq n1 next)
- (setq res1 (truncate (* res1 (car units))))
- (pop units)
- (setq unit1 (pop units)))
- (setq n1 (truncate n1))
- (if n2 (setq n2 (truncate n2)))
- (cond
- ((null n2)
- ;; revert to old just now style for < 1 min:
- (cons "just now" 60))
- ;; (cons (format "%d %s%s" n1 unit1 (if (> n1 1) "s" ""))
- ;; (max resolution res1)))
- ((< (* res2 n2) resolution)
- (cons (format "%d %s%s" n1 unit1 (if (> n1 1) "s" ""))
- (max resolution res2)))
- ((< res2 resolution)
- (let ((n2 (/ (* resolution (/ (* n2 res2) resolution)) res2)))
- (cons (format "%d %s%s, %d %s%s"
- n1 unit1 (if (> n1 1) "s" "")
- n2 unit2 (if (> n2 1) "s" ""))
- resolution)))
- (t
- (cons (format "%d %s%s, %d %s%s"
- n1 unit1 (if (> n1 1) "s" "")
- n2 unit2 (if (> n2 1) "s" ""))
- (max res2 resolution))))))
- (defconst exitter--time-units
- '("sec" 60.0 ;; Use a float to convert `n' to float.
- "min" 60
- "hour" 24
- "day" 7
- "week" 4.345
- "month" 12
- "year"))
- (defun exitter--relative-time-details (timestamp &optional current-time)
- "Return cons of (DESCRIPTIVE STRING . NEXT-CHANGE) for the TIMESTAMP.
- Use the optional CURRENT-TIME as the current time (only used for
- reliable testing).
- The descriptive string is a human readable version relative to
- the current time while the next change timestamp give the first
- time that this description will change in the future.
- TIMESTAMP is assumed to be in the past."
- (let* ((time-difference (time-subtract current-time timestamp))
- (seconds-difference (float-time time-difference))
- (tmp (exitter--human-duration (max 0 seconds-difference))))
- ;; revert to old just now style for < 1 min
- (cons (concat (car tmp) (if (string= "just now" (car tmp)) "" " ago"))
- (time-add current-time (cdr tmp)))))
- (defun exitter--relative-time-description (time-string &optional current-time)
- "Return a string with a human readable TIME-STRING relative to the current time.
- Use the optional CURRENT-TIME as the current time (only used for
- reliable testing).
- E.g. this could return something like \"1 min ago\", \"yesterday\", etc.
- TIME-STAMP is assumed to be in the past."
- (car (exitter--relative-time-details
- (encode-time (parse-time-string time-string)) current-time)))
- (defun exitter-save-text-and-switch-to-buffer (text file-name)
- "Save TEXT to FILE-NAME and switch to buffer."
- (let ((buffer (find-file-noselect file-name))
- (coding-system-for-write 'utf-8))
- (with-current-buffer buffer
- (let ((inhibit-read-only t))
- (erase-buffer)
- (insert text))
- (goto-char (point-min))
- (save-buffer)
- (revert-buffer t t))
- (switch-to-buffer buffer)))
- (defun exitter-vector-flatten (v)
- (cond
- ((and (vectorp v) (length= v 0)) v)
- ((vectorp v)
- (vconcat (exitter-vector-flatten (elt v 0))
- (exitter-vector-flatten (subseq v 1 (length v)))))
- ((and (listp v) (alist-get 'post v) (vector (alist-get 'post v))))
- (t [])))
- (require 'bindat)
- (defun exitter-nonce ()
- (let ((xs))
- (dotimes (_ 32 xs)
- (setq xs (cons (random 256) xs)))
- (replace-regexp-in-string
- "[=/+]" ""
- (base64-encode-string
- (alist-get 'bs (bindat-unpack '((bs str 32)) (vconcat xs)))))))
- (defun exitter-replace-t-co-links (text urls)
- (with-temp-buffer
- (insert text)
- (goto-char (point-min))
- (while (re-search-forward "https://t.co" nil t)
- (pcase-let* ((`(,beg . ,end) (bounds-of-thing-at-point 'url))
- (new-url (exitter-get-redirect-url
- (buffer-substring-no-properties beg end)
- urls)))
- (delete-region beg end)
- (insert new-url)))
- (buffer-string)))
- (defun exitter-get-redirect-url (url urls)
- (let-alist (seq-find
- (lambda (info) (equal url (alist-get 'url info)))
- urls)
- (or .media_url_https .expanded_url url)))
- ;;; Probably not needed...
- ;; (defun exitter-get-redirect-url (url)
- ;; "Get redirect link of URL.
- ;; Sends a HEAD request."
- ;; (let* ((url-request-method "HEAD")
- ;; (url-max-redirections 0)
- ;; (buffer (url-retrieve-synchronously url))
- ;; (inhibit-message t))
- ;; (with-current-buffer buffer
- ;; (goto-char (point-min))
- ;; (when (re-search-forward "^Location: \\(.*\\)$" nil t)
- ;; (match-string 1)))))
- (require 'sha1)
- ;;; Source: https://www.emacswiki.org/emacs/HmacShaOne
- (defun exitter-hmac-sha1 (key message)
- "Return an HMAC-SHA1 authentication code for KEY and MESSAGE.
- KEY and MESSAGE must be unibyte strings. The result is a unibyte
- string. Use the function `encode-hex-string' or the function
- `base64-encode-string' to produce human-readable output.
- See URL:<http://en.wikipedia.org/wiki/HMAC> for more information
- on the HMAC-SHA1 algorithm.
- The Emacs multibyte representation actually uses a series of
- 8-bit values under the hood, so we could have allowed multibyte
- strings as arguments. However, internal 8-bit values don't
- correspond to any external representation \(at least for major
- version 22). This makes multibyte strings useless for generating
- hashes.
- Instead, callers must explicitly pick and use an encoding for
- their multibyte data. Most callers will want to use UTF-8
- encoding, which we can generate as follows:
- (let ((unibyte-key (encode-coding-string key 'utf-8 t))
- (unibyte-value (encode-coding-string value 'utf-8 t)))
- (hmac-sha1 unibyte-key unibyte-value))
- For keys and values that are already unibyte, the
- `encode-coding-string' calls just return the same string."
- (when (multibyte-string-p key)
- (error "key %s must be unibyte" key))
- (when (multibyte-string-p message)
- (error "message %s must be unibyte" message))
- ;; The key block is always exactly the block size of the hash
- ;; algorithm. If the key is too small, we pad it with zeroes (or
- ;; instead, we initialize the key block with zeroes and copy the
- ;; key onto the nulls). If the key is too large, we run it
- ;; through the hash algorithm and use the hashed value (strange
- ;; but true).
- (let ((+hmac-sha1-block-size-bytes+ 64)) ; SHA-1 uses 512-bit blocks
- (when (< +hmac-sha1-block-size-bytes+ (length key))
- (setq key (sha1 key nil nil t)))
- (let ((key-block (make-vector +hmac-sha1-block-size-bytes+ 0)))
- (dotimes (i (length key))
- (aset key-block i (aref key i)))
- (let ((opad (make-vector +hmac-sha1-block-size-bytes+ #x5c))
- (ipad (make-vector +hmac-sha1-block-size-bytes+ #x36)))
- (dotimes (i +hmac-sha1-block-size-bytes+)
- (aset ipad i (logxor (aref ipad i) (aref key-block i)))
- (aset opad i (logxor (aref opad i) (aref key-block i))))
- (when (fboundp 'unibyte-string)
- ;; `concat' of Emacs23 (and later?) generates a multi-byte
- ;; string from a vector of characters with eight bit.
- ;; Since `opad' and `ipad' must be unibyte, we have to
- ;; convert them by using `unibyte-string'.
- ;; We cannot use `string-as-unibyte' here because it encodes
- ;; bytes with the manner of UTF-8.
- (setq opad (apply 'unibyte-string (mapcar 'identity opad)))
- (setq ipad (apply 'unibyte-string (mapcar 'identity ipad))))
- (sha1 (concat opad
- (sha1 (concat ipad message)
- nil nil t))
- nil nil t)))))
- (provide 'exitter)
|