;; -*- lexical-binding: t; -*- ;; Copyright (C) 2024 Free Software Foundation, Inc. ;; Author: Yuchen Pei ;; 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 . (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) (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 (push `(post . ((id_str . ,.id_str) (created_at . ,.created_at) (full_text . ,.full_text) (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))) 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-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--relative-time-description .created_at) .full_text (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-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))))))) (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: 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)