├── .github └── workflows │ └── test.yml ├── .gitignore ├── Cask ├── Makefile ├── README.in.rst ├── README.rst ├── features ├── inbox.feature ├── rpc.feature ├── step-definitions │ └── nnreddit-steps.el └── support │ └── env.el ├── lisp ├── nnreddit-pkg.el ├── nnreddit-pkg.el.in └── nnreddit.el ├── nnreddit ├── VERSION ├── __init__.py ├── __main__.py ├── authenticated_reddit.py ├── pylintrc └── templates │ ├── index.html │ ├── mailcap │ └── rtv.cfg ├── requirements-dev.txt ├── requirements.txt ├── screenshot.png ├── setup.py ├── tests ├── __init__.py ├── __main__.py ├── cassettes │ ├── cancel.json │ ├── canonical.json │ ├── inbox.json │ ├── loose.json │ ├── post.json │ ├── random.json │ ├── refresh_token.json │ ├── scan.json │ ├── subscribe.json │ ├── supersede.json │ ├── test_oauth.yaml │ └── vote.json ├── conftest.py ├── nnreddit-test.el ├── recorded.py ├── share │ └── nnreddit │ │ └── refresh-token ├── test-uncacheable.el └── test_oauth.py └── tools ├── install-virtualenv.sh ├── package-lint.sh ├── readme-sed.sh ├── recipe └── retry.sh /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - '**.md' 7 | - '**.rst' 8 | push: 9 | paths-ignore: 10 | - '**.md' 11 | - '**.rst' 12 | branches-ignore: 13 | - 'master' 14 | 15 | jobs: 16 | build: 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | matrix: 20 | os: [ubuntu-latest] 21 | emacs_version: [27.2, 28.2, 29.3] 22 | python_version: [3.8] 23 | include: 24 | - os: macos-12 25 | emacs_version: 29.3 26 | python_version: 3.8 27 | 28 | steps: 29 | - uses: actions/checkout@v2 30 | 31 | - uses: actions/setup-python@v2 32 | with: 33 | python-version: ${{ matrix.python_version }} 34 | 35 | - uses: purcell/setup-emacs@master 36 | with: 37 | version: ${{ matrix.emacs_version }} 38 | 39 | - uses: actions/cache@v2 40 | if: startsWith(runner.os, 'Linux') 41 | with: 42 | path: ~/.cache/pip 43 | key: ${{ runner.os }}-pip-000 44 | 45 | - uses: actions/cache@v2 46 | if: startsWith(runner.os, 'macOS') 47 | with: 48 | path: ~/Library/Caches/pip 49 | key: ${{ runner.os }}-pip-000 50 | 51 | - uses: actions/cache@v2 52 | with: 53 | path: ~/local 54 | key: ${{ runner.os }}-local-000 55 | 56 | - uses: actions/cache@v2 57 | with: 58 | path: ~/.emacs.d 59 | key: emacs.d 60 | 61 | - name: apt-get 62 | if: startsWith(runner.os, 'Linux') 63 | run: | 64 | sudo apt-get -yq update 65 | DEBIAN_FRONTEND=noninteractive sudo apt-get -yq install gnutls-bin sharutils dirmngr libreadline-dev libcurl4-openssl-dev virtualenv 66 | DEBIAN_FRONTEND=noninteractive sudo apt-get -yq install python3.8-minimal python3.8-distutils python3.8-venv 67 | 68 | - uses: actions/cache@v2 69 | id: cache-cask-packages 70 | with: 71 | path: .cask 72 | key: cache-cask-packages-000 73 | 74 | - uses: actions/cache@v2 75 | id: cache-cask-executable 76 | with: 77 | path: ~/.cask 78 | key: cache-cask-executable-000 79 | 80 | - uses: conao3/setup-cask@master 81 | if: steps.cache-cask-executable.outputs.cache-hit != 'true' 82 | with: 83 | version: snapshot 84 | 85 | - name: paths 86 | run: | 87 | echo "$HOME/local/bin" >> $GITHUB_PATH 88 | echo "$HOME/.cask/bin" >> $GITHUB_PATH 89 | echo "$HOME/.local/bin" >> $GITHUB_PATH 90 | echo "LD_LIBRARY_PATH=$HOME/.local/lib" >> $GITHUB_ENV 91 | 92 | - name: test 93 | run: | 94 | make test-run 95 | make test 96 | continue-on-error: ${{ matrix.emacs_version == 'snapshot' }} 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | venv-nnreddit-test 3 | .ecukes* 4 | .cask 5 | .newsrc* 6 | *.pyc 7 | dist 8 | todo.org 9 | mimeapps.list 10 | *autoloads.el 11 | super-secret-refresh-token 12 | \#* 13 | .\#* 14 | todo.org 15 | ert-profile* 16 | tests/log 17 | nnreddit.egg-info 18 | -------------------------------------------------------------------------------- /Cask: -------------------------------------------------------------------------------- 1 | (source gnu) 2 | (source melpa) 3 | 4 | (package-descriptor "lisp/nnreddit-pkg.el") 5 | (files "lisp/*.el" "setup.py" "requirements.txt" "nnreddit") 6 | 7 | (development 8 | (depends-on "ert-runner") 9 | (depends-on "package-lint") 10 | (depends-on "ecukes") 11 | (depends-on "f")) 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | EMACS ?= $(shell which emacs) 3 | export PYTHON ?= python 4 | ifeq ($(shell command -v $(PYTHON) 2>/dev/null),) 5 | $(error $(PYTHON) not found) 6 | endif 7 | ifeq ($(shell expr $$($(PYTHON) --version 2>&1 | cut -d' ' -f2) \< 3),1) 8 | $(error Set PYTHON to python3) 9 | endif 10 | ifeq ($(shell expr $$($(PYTHON) --version 2>&1 | cut -d'.' -f2) \< 11),0) 11 | $(error Set PYTHON to an older python3) 12 | endif 13 | SRC=$(shell cask files) 14 | PKBUILD=2.3 15 | ELCFILES = $(SRC:.el=.elc) 16 | CASK_DIR := $(shell EMACS=$(EMACS) cask package-directory || exit 1) 17 | export TEST_PYTHON ?= $(PYTHON) 18 | ifeq ($(shell command -v $(TEST_PYTHON) 2>/dev/null),) 19 | $(error $(TEST_PYTHON) not found) 20 | endif 21 | 22 | .DEFAULT_GOAL := test-compile 23 | 24 | .PHONY: cask 25 | cask: $(CASK_DIR) 26 | 27 | $(CASK_DIR): Cask 28 | cask install 29 | touch $(CASK_DIR) 30 | 31 | lisp/nnreddit-pkg.el: nnreddit/VERSION lisp/nnreddit-pkg.el.in 32 | sed 's/VERSION/"$(shell cat $<)"/' lisp/nnreddit-pkg.el.in > $@ 33 | 34 | .PHONY: autoloads 35 | autoloads: lisp/nnreddit-pkg.el 36 | cask emacs -Q --batch -l package --eval "(let ((v (format \"%s.%s\" emacs-major-version emacs-minor-version))) (custom-set-variables (backquote (package-user-dir ,(concat \".cask/\" v)))))" -f package-initialize --eval "(package-generate-autoloads \"nnreddit\" \"./lisp\")" 37 | 38 | README.rst: README.in.rst lisp/nnreddit.el 39 | grep ';;' lisp/nnreddit.el \ 40 | | awk '/;;;\s*Commentary/{within=1;next}/;;;\s*/{within=0}within' \ 41 | | sed -e 's/^\s*;;*\s*//g' \ 42 | | tools/readme-sed.sh "COMMENTARY" README.in.rst > README.rst 43 | 44 | .PHONY: clean 45 | clean: 46 | cask clean-elc 47 | pyclean nnreddit 48 | $(PYTHON) setup.py clean 49 | rm -f tests/log/* 50 | rm -rf tests/test-install 51 | 52 | .PHONY: pylint 53 | ifeq ($(shell expr $$($(PYTHON) --version 2>&1 | cut -d'.' -f2) \> 9),1) 54 | pylint: 55 | $(PYTHON) -m pip -q install --user pylint 56 | $(PYTHON) -m pylint nnreddit --rcfile=nnreddit/pylintrc 57 | else 58 | pylint: 59 | @echo forgoing pylint 60 | endif 61 | 62 | .PHONY: test-compile 63 | test-compile: cask autoloads pylint 64 | sh -e tools/package-lint.sh lisp/nnreddit.el 65 | ! (cask eval "(let ((byte-compile-error-on-warn t) (bytecomp--inhibit-lexical-cookie-warning t) (byte-compile--suppressed-warnings (quote ((obsolete define-package))))) (cask-cli/build))" 2>&1 | egrep -a "(Warning|Error):") 66 | cask clean-elc 67 | 68 | define SET_GITHUB_ACTOR = 69 | GITHUB_ACTOR := $(shell if [ -z ${GITHUB_ACTOR} ]; then git config user.name; else echo ${GITHUB_ACTOR} ; fi) 70 | endef 71 | 72 | define SET_GITHUB_ACTOR_REPOSITORY = 73 | GITHUB_ACTOR_REPOSITORY := $(GITHUB_ACTOR)/$(shell basename `git rev-parse --show-toplevel`) 74 | endef 75 | 76 | define SET_GITHUB_HEAD_REF = 77 | GITHUB_HEAD_REF := $(shell if [ -z ${GITHUB_HEAD_REF} ]; then git rev-parse --abbrev-ref HEAD; else echo ${GITHUB_HEAD_REF} ; fi) 78 | endef 79 | 80 | define SET_GITHUB_SHA = 81 | GITHUB_SHA := $(shell if [ -z ${GITHUB_SHA} ] ; then git rev-parse origin/${GITHUB_HEAD_REF}; else echo ${GITHUB_SHA}; fi) 82 | endef 83 | 84 | define SET_GITHUB_COMMIT = 85 | GITHUB_COMMIT := $(shell if git show -s --format=%s "${GITHUB_SHA}" | egrep -q "^Merge .* into" ; then git show -s --format=%s "${GITHUB_SHA}" | cut -d " " -f2 ; else echo "${GITHUB_SHA}" ; fi) 86 | endef 87 | 88 | .PHONY: test-install-vars 89 | test-install-vars: 90 | $(eval $(call SET_GITHUB_ACTOR)) 91 | $(eval $(call SET_GITHUB_ACTOR_REPOSITORY)) 92 | $(eval $(call SET_GITHUB_HEAD_REF)) 93 | $(eval $(call SET_GITHUB_SHA)) 94 | $(eval $(call SET_GITHUB_COMMIT)) 95 | git show -s --format=%s $(GITHUB_COMMIT) 96 | git show -s --format=%s $(GITHUB_SHA) 97 | 98 | .PHONY: test-install-defunct 99 | test-install-defunct: test-install-vars 100 | mkdir -p tests/test-install 101 | if [ ! -s "tests/test-install/$(PKBUILD).tar.gz" ] ; then \ 102 | cd tests/test-install ; curl -sLOk https://github.com/melpa/package-build/archive/$(PKBUILD).tar.gz ; fi 103 | cd tests/test-install ; tar xfz $(PKBUILD).tar.gz 104 | cd tests/test-install ; rm -f $(PKBUILD).tar.gz 105 | cd tests/test-install/package-build-$(PKBUILD) ; make -s loaddefs 106 | mkdir -p tests/test-install/recipes 107 | cd tests/test-install/recipes ; curl -sfLOk https://raw.githubusercontent.com/melpa/melpa/master/recipes/nnreddit || cp -f ../../../tools/recipe ./nnreddit 108 | ! ( $(EMACS) -Q --batch -L tests/test-install/package-build-$(PKBUILD) \ 109 | --eval "(require 'package-build)" \ 110 | --eval "(require 'subr-x)" \ 111 | -f package-initialize \ 112 | --eval "(add-to-list 'package-archives '(\"melpa\" . \"http://melpa.org/packages/\"))" \ 113 | --eval "(package-refresh-contents)" \ 114 | --eval "(setq rcp (package-recipe-lookup \"nnreddit\"))" \ 115 | --eval "(unless (file-exists-p package-build-archive-dir) \ 116 | (make-directory package-build-archive-dir))" \ 117 | --eval "(let* ((my-repo \"$(GITHUB_ACTOR_REPOSITORY)\") \ 118 | (my-branch \"$(GITHUB_HEAD_REF)\") \ 119 | (my-commit \"$(GITHUB_COMMIT)\")) \ 120 | (oset rcp :repo my-repo) \ 121 | (oset rcp :branch my-branch) \ 122 | (oset rcp :commit my-commit))" \ 123 | --eval "(package-build--package rcp (package-build--checkout rcp))" \ 124 | --eval "(package-install-file (car (file-expand-wildcards (concat package-build-archive-dir \"nnreddit*.tar\"))))" 2>&1 | egrep -ia "error: |fatal" ) 125 | 126 | .PHONY: test-venv 127 | test-venv: test-install-defunct 128 | $(EMACS) -Q --batch -f package-initialize \ 129 | --eval "(custom-set-variables (quote (gnus-verbose 8)))" \ 130 | --eval "(require (quote nnreddit))" \ 131 | --eval "nnreddit-venv" 132 | 133 | define TESTRUN 134 | --eval "(custom-set-variables \ 135 | (quote (gnus-select-method (quote (nnreddit \"\")))) \ 136 | (backquote (venv-location ,(file-name-as-directory (make-temp-file \"testrun-\" t)))) \ 137 | (quote (nnreddit-python-command \"$(PYTHON)\"))\ 138 | (quote (gnus-verbose 8)) \ 139 | (quote (nnreddit-log-rpc t)))" \ 140 | --eval "(setq debug-on-error t)" \ 141 | --eval "(fset (quote gnus-y-or-n-p) (function ignore))" 142 | endef 143 | 144 | .PHONY: test-run 145 | test-run: cask autoloads 146 | cask emacs -Q --batch \ 147 | $(TESTRUN) \ 148 | --eval "(require 'nnreddit)" \ 149 | --eval "(cl-assert (nnreddit-rpc-get))" \ 150 | --eval "(sleep-for 0 7300)" \ 151 | -f nnreddit-dump-diagnostics \ 152 | --eval "(cl-assert nnreddit-processes)" 153 | 154 | .PHONY: test-run-interactive 155 | test-run-interactive: cask autoloads 156 | cask emacs -Q \ 157 | $(TESTRUN) \ 158 | -f gnus 159 | 160 | .PHONY: test-unit 161 | test-unit: 162 | PYTHON=$(TEST_PYTHON) cask exec ert-runner -L . -L tests tests/test*.el 163 | 164 | .PHONY: test 165 | test: test-compile test-unit test-int 166 | 167 | .PHONY: test-int 168 | test-int: 169 | @>/dev/null expr $$($(TEST_PYTHON) --version 2>&1 | cut -d'.' -f2) \< 9 || { echo "need python less than 3.9" ; exit -1; } 170 | rm -rf venv-nnreddit-test 171 | $(TEST_PYTHON) -m venv venv-nnreddit-test 172 | ( \ 173 | source venv-nnreddit-test/bin/activate; \ 174 | python -m pip -q install -r requirements-dev.txt; \ 175 | python -m pytest tests/test_oauth.py; \ 176 | rm -f tests/.newsrc.eld; \ 177 | cask exec ecukes --reporter magnars --tags "~@inbox"; \ 178 | rm -f tests/.newsrc.eld; \ 179 | cask exec ecukes --reporter magnars --tags "@inbox"; \ 180 | ) 181 | 182 | .PHONY: dist-clean 183 | dist-clean: 184 | rm -rf dist 185 | 186 | .PHONY: dist 187 | dist: dist-clean 188 | cask package 189 | 190 | .PHONY: install 191 | install: dist autoloads 192 | $(EMACS) -Q --batch -f package-initialize \ 193 | --eval "(add-to-list 'package-archives '(\"melpa\" . \"http://melpa.org/packages/\"))" \ 194 | --eval "(package-refresh-contents)" \ 195 | --eval "(package-install-file (car (file-expand-wildcards \"dist/nnreddit*.tar\")))" 196 | -------------------------------------------------------------------------------- /README.in.rst: -------------------------------------------------------------------------------- 1 | |build-status| |melpa-dev| 2 | 3 | .. COMMENTARY (see Makefile) 4 | 5 | .. |build-status| 6 | image:: https://github.com/dickmao/nnreddit/workflows/CI/badge.svg?branch=dev 7 | :target: https://github.com/dickmao/nnreddit/actions 8 | :alt: Build Status 9 | .. |melpa-dev| 10 | image:: https://melpa.org/packages/nnreddit-badge.svg 11 | :target: http://melpa.org/#/nnreddit 12 | :alt: MELPA current version 13 | .. |melpa-stable| 14 | image:: http://melpa-stable.milkbox.net/packages/ein-badge.svg 15 | :target: http://melpa-stable.milkbox.net/#/ein 16 | :alt: MELPA stable version 17 | 18 | .. image:: https://github.com/dickmao/gnus-imap-walkthrough/blob/master/thumbnail.png 19 | :target: https://youtu.be/DMpZtC98F_M 20 | :alt: Replacing Thunderbird With Gnus 21 | 22 | .. image:: screenshot.png 23 | .. |--| unicode:: U+2013 .. en dash 24 | .. |---| unicode:: U+2014 .. em dash, trimming surrounding whitespace 25 | :trim: 26 | 27 | Install 28 | ======= 29 | Alas, you'll need Cask_. Then, 30 | 31 | :: 32 | 33 | git clone https://github.com/dickmao/nnreddit.git 34 | PYTHON=python3.8 make install 35 | 36 | Also see Troubleshooting_. 37 | 38 | Usage 39 | ===== 40 | In your ``.emacs`` or ``init.el``, use ONE of the following: 41 | 42 | :: 43 | 44 | ;; Applies to first-time Gnus users 45 | (custom-set-variables '(gnus-select-method (quote (nnreddit "")))) 46 | 47 | or, if you're an existing Gnus user, 48 | 49 | :: 50 | 51 | ;; Applies to existing Gnus users 52 | (add-to-list 'gnus-secondary-select-methods '(nnreddit "")) 53 | 54 | Then ``M-x gnus``. 55 | 56 | Initial setup should guide you through OAuth and find your existing subreddit subscriptions. 57 | 58 | Select a subreddit via ``RET``. Rapidly catch yourself up via ``N`` and ``P``. Instantly catch-up with ``c``. 59 | 60 | Create a post via ``a``. 61 | 62 | Reply to articles with ``f`` or ``r``. Include original with ``F``. 63 | 64 | Vote articles via ``R -`` (down), ``R =`` (up), or ``R 0`` (retract). 65 | 66 | From the ``*Group*`` buffer, press ``g`` to refresh all subreddits. ``M-g`` on a particular subreddit to refresh individually. 67 | 68 | From the summary buffer, ``/o`` redisplays articles already read. ``x`` undisplays them. 69 | 70 | ``S s`` edits articles. 71 | 72 | ``S c`` cancels articles. 73 | 74 | ``R g [subreddit]`` takes you to an *unsubscribed* subreddit. 75 | 76 | You can subscribe to it via the ``u`` keybinding from the ``*Group*`` buffer [1]_. 77 | 78 | Gnus beginners may find the interface bewildering. In particular, subreddits with no unread articles do not display. Use ``L`` to bring them out of hiding. 79 | 80 | | 81 | 82 | .. [1] Gnus users are familiar with the tragedy of ``u`` doing double duty as subscriber and unsubscriber. ``u`` is really a toggle even though the attached command is ``gnus-group-unsubscribe-current-group`` |---| if that doesn't trigger your UX sensibility, then never mind I mentioned it. 83 | 84 | .. _walkthrough: https://github.com/dickmao/gnus-imap-walkthrough 85 | .. _Cask: https://github.com/cask/cask#installation 86 | .. _Getting started: http://melpa.org/#/getting-started 87 | .. _ELPASO: http://github.com/dickmao/elpaso 88 | .. _virtualenv: https://virtualenv.pypa.io/en/stable 89 | .. _PRAW: https://github.com/praw-dev/praw/pull/1094 90 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |build-status| |melpa-dev| 2 | 3 | 4 | .. |build-status| 5 | image:: https://github.com/dickmao/nnreddit/workflows/CI/badge.svg?branch=dev 6 | :target: https://github.com/dickmao/nnreddit/actions 7 | :alt: Build Status 8 | .. |melpa-dev| 9 | image:: https://melpa.org/packages/nnreddit-badge.svg 10 | :target: http://melpa.org/#/nnreddit 11 | :alt: MELPA current version 12 | .. |melpa-stable| 13 | image:: http://melpa-stable.milkbox.net/packages/ein-badge.svg 14 | :target: http://melpa-stable.milkbox.net/#/ein 15 | :alt: MELPA stable version 16 | 17 | .. image:: https://github.com/dickmao/gnus-imap-walkthrough/blob/master/thumbnail.png 18 | :target: https://youtu.be/DMpZtC98F_M 19 | :alt: Replacing Thunderbird With Gnus 20 | 21 | .. image:: screenshot.png 22 | .. |--| unicode:: U+2013 .. en dash 23 | .. |---| unicode:: U+2014 .. em dash, trimming surrounding whitespace 24 | :trim: 25 | 26 | Install 27 | ======= 28 | Alas, you'll need Cask_. Then, 29 | 30 | :: 31 | 32 | git clone https://github.com/dickmao/nnreddit.git 33 | PYTHON=python3.8 make install 34 | 35 | Also see Troubleshooting_. 36 | 37 | Usage 38 | ===== 39 | In your ``.emacs`` or ``init.el``, use ONE of the following: 40 | 41 | :: 42 | 43 | ;; Applies to first-time Gnus users 44 | (custom-set-variables '(gnus-select-method (quote (nnreddit "")))) 45 | 46 | or, if you're an existing Gnus user, 47 | 48 | :: 49 | 50 | ;; Applies to existing Gnus users 51 | (add-to-list 'gnus-secondary-select-methods '(nnreddit "")) 52 | 53 | Then ``M-x gnus``. 54 | 55 | Initial setup should guide you through OAuth and find your existing subreddit subscriptions. 56 | 57 | Select a subreddit via ``RET``. Rapidly catch yourself up via ``N`` and ``P``. Instantly catch-up with ``c``. 58 | 59 | Create a post via ``a``. 60 | 61 | Reply to articles with ``f`` or ``r``. Include original with ``F``. 62 | 63 | Vote articles via ``R -`` (down), ``R =`` (up), or ``R 0`` (retract). 64 | 65 | From the ``*Group*`` buffer, press ``g`` to refresh all subreddits. ``M-g`` on a particular subreddit to refresh individually. 66 | 67 | From the summary buffer, ``/o`` redisplays articles already read. ``x`` undisplays them. 68 | 69 | ``S s`` edits articles. 70 | 71 | ``S c`` cancels articles. 72 | 73 | ``R g [subreddit]`` takes you to an *unsubscribed* subreddit. 74 | 75 | You can subscribe to it via the ``u`` keybinding from the ``*Group*`` buffer [1]_. 76 | 77 | Gnus beginners may find the interface bewildering. In particular, subreddits with no unread articles do not display. Use ``L`` to bring them out of hiding. 78 | 79 | | 80 | 81 | .. [1] Gnus users are familiar with the tragedy of ``u`` doing double duty as subscriber and unsubscriber. ``u`` is really a toggle even though the attached command is ``gnus-group-unsubscribe-current-group`` |---| if that doesn't trigger your UX sensibility, then never mind I mentioned it. 82 | 83 | .. _walkthrough: https://github.com/dickmao/gnus-imap-walkthrough 84 | .. _Cask: https://github.com/cask/cask#installation 85 | .. _Getting started: http://melpa.org/#/getting-started 86 | .. _ELPASO: http://github.com/dickmao/elpaso 87 | .. _virtualenv: https://virtualenv.pypa.io/en/stable 88 | .. _PRAW: https://github.com/praw-dev/praw/pull/1094 89 | -------------------------------------------------------------------------------- /features/inbox.feature: -------------------------------------------------------------------------------- 1 | Feature: 20201124 2 | 3 | @inbox 4 | Scenario: Get some inbox messages 5 | When begin recording "inbox" 6 | Given gnus start 7 | And I go to word "nnreddit:/u/nnreddit-user" 8 | And I press "RET" 9 | And I should be in buffer "*Summary nnreddit:/u/nnreddit-user*" 10 | And I go to word "Roblox" 11 | And I press "RET" 12 | And I switch to buffer "*Article nnreddit:/u/nnreddit-user*" 13 | And I should see "Nice!" 14 | And emacs26 cannot do action chain "f m" 15 | Then protected see message "emacs26 cannot do action chain: Followup from inbox not implemented" 16 | Then end recording "inbox" 17 | -------------------------------------------------------------------------------- /features/rpc.feature: -------------------------------------------------------------------------------- 1 | Feature: 20201123 2 | 3 | @refresh_token 4 | Scenario: Do not know how to betamax initial oauth handshake 5 | When begin recording "refresh_token" 6 | Given gnus start 7 | Then end recording "refresh_token" 8 | 9 | @random 10 | Scenario: random subreddit 11 | When begin recording "random" 12 | Given gnus start 13 | And rpc "random_subreddit" returns "preppers" 14 | Then end recording "random" 15 | 16 | @subscribe 17 | Scenario: subscribe and unsubscribe 18 | When begin recording "subscribe" 19 | Given gnus start 20 | And I goto group "test" 21 | And I press "q" 22 | Then I should be in buffer "*Group*" 23 | And I go to word "test" 24 | And I press "u" 25 | And I open latest "log/test_py" 26 | Then I wait for buffer to say "('action', 'sub')" 27 | And I switch to buffer "*Group*" 28 | And I go to word "test" 29 | And I press "u" 30 | And I open latest "log/test_py" 31 | Then I wait for buffer to say "('action', 'unsub')" 32 | Then end recording "subscribe" 33 | 34 | @scan 35 | Scenario: selecting group does not rescan, but M-g does 36 | Given gnus stop 37 | When begin recording "scan" 38 | Given gnus start 39 | And I clear buffer "*Messages*" 40 | And I go to word "emacs" 41 | And I press "M-g" 42 | And I switch to buffer "*Messages*" 43 | And I should see pattern "nnreddit-request-scan: emacs" 44 | And I switch to buffer "*Group*" 45 | And I clear buffer "*Messages*" 46 | And I go to word "emacs" 47 | And I press "RET" 48 | And I should be in buffer "*Summary nnreddit:emacs*" 49 | And I switch to buffer "*Messages*" 50 | And I should not see pattern "nnreddit-request-scan: emacs" 51 | And I switch to buffer "*Group*" 52 | And I go to word "orgmode" 53 | And I press "RET" 54 | And I should be in buffer "*Summary nnreddit:orgmode*" 55 | And I switch to buffer "*Messages*" 56 | And I should not see pattern "nnreddit-request-scan: orgmode" 57 | Then end recording "scan" 58 | 59 | @vote 60 | Scenario: Voting from summary and article buffers 61 | Given gnus stop 62 | When begin recording "vote" 63 | Given gnus start 64 | And I go to word "PostPreview" 65 | And I press "RET" 66 | And I should be in buffer "*Summary nnreddit:PostPreview*" 67 | And I go to word "Plasky" 68 | And I press "R =" 69 | Then protected see message "Open the article before voting" 70 | And I press "RET" 71 | And I press "R =" 72 | And I switch to buffer "*Article nnreddit:PostPreview*" 73 | And I should see "Score: 2 +1" 74 | And I press "R -" 75 | And I should see "Score: 2 -1" 76 | Then end recording "vote" 77 | 78 | @post 79 | Scenario: message-send-and-exit 80 | When begin recording "post" 81 | Given gnus start 82 | And I go to word "PostPreview" 83 | And I press "RET" 84 | And I should be in buffer "*Summary nnreddit:PostPreview*" 85 | And emacs26 cannot do action chain "a t" 86 | Then I should be in buffer "*unsent posting on PostPreview*" 87 | And I type "test baby test baby 123" 88 | And I press "M->" 89 | And I type "this is a test" 90 | And I press "C-c C-c" 91 | And I should be in buffer "*Summary nnreddit:PostPreview*" 92 | Then end recording "post" 93 | 94 | @loose 95 | Scenario: Reply to a loose thread 96 | Given gnus stop 97 | When begin recording "loose" 98 | Given gnus start 99 | And I go to word "PostPreview" 100 | And I press "RET" 101 | And I should be in buffer "*Summary nnreddit:PostPreview*" 102 | And I go to string "Re: Testing inline image links" 103 | And emacs26 cannot do action chain "f r" 104 | Then I should be in buffer like "*unsent followup" 105 | And I should see "Reply-Root: yes" 106 | And I press "M->" 107 | And I type "this is a test" 108 | And I press "C-c C-c" 109 | And I should be in buffer "*Summary nnreddit:PostPreview*" 110 | Then end recording "loose" 111 | 112 | @cancel 113 | Scenario: cancel post 114 | When begin recording "cancel" 115 | Given gnus start 116 | And I go to word "PostPreview" 117 | And I press "RET" 118 | And I should be in buffer "*Summary nnreddit:PostPreview*" 119 | And I go to string "Re: Testing inline image links" 120 | And I press "C-n" 121 | And I press "S c" 122 | And I open latest "log/test_py" 123 | And I wait for buffer to say "api/del" 124 | And I wait for buffer to say "('id', 't1_eqwoano')" 125 | Then end recording "cancel" 126 | 127 | @supersede 128 | Scenario: supersede post 129 | Given gnus stop 130 | When begin recording "supersede" 131 | Given gnus start 132 | And I go to word "PostPreview" 133 | And I press "RET" 134 | And I should be in buffer "*Summary nnreddit:PostPreview*" 135 | And I go to word "aegisninja" 136 | And I press "C-n" 137 | And I press "S s" 138 | Then I should be in buffer "*unsent supersede*" 139 | And I type "edit: " 140 | And I press "C-c C-c" 141 | And I open latest "log/test_py" 142 | And I wait for buffer to say "api/editusertext" 143 | And I wait for buffer to say "('thing_id', 't1_eqwe7dx')" 144 | Then end recording "supersede" 145 | 146 | @canonical 147 | Scenario: Going to hongkong make me really go to HongKong 148 | Given gnus stop 149 | When begin recording "canonical" 150 | Given gnus start 151 | And eval "(setq minibuffer-history nil)" 152 | And I goto group "hongkong" 153 | And I press "q" 154 | Then I should be in buffer "*Group*" 155 | And I should see "HongKong" 156 | Then end recording "canonical" 157 | 158 | @browser 159 | Scenario: Verify user message if no refresh token present 160 | Given gnus stop 161 | And I hide tokens 162 | And gnus try start 163 | And I switch to buffer "*Messages*" 164 | Then I should see "nnreddit-default: Please check your browser." 165 | And I unhide tokens 166 | And I kill all rpc processes 167 | -------------------------------------------------------------------------------- /features/step-definitions/nnreddit-steps.el: -------------------------------------------------------------------------------- 1 | (When "I kill all rpc processes$" 2 | (lambda () 3 | (nnreddit-request-close))) 4 | 5 | (When "I hide tokens$" 6 | (lambda () 7 | (setq nnreddit--python-module-extra-args '("--token-file" "/dev/null")))) 8 | 9 | (When "^eval \"\\(.*\\)\"$" 10 | (lambda (command) 11 | (eval (car (read-from-string command))))) 12 | 13 | (When "I unhide tokens$" 14 | (lambda () 15 | (setq nnreddit--python-module-extra-args nil))) 16 | 17 | (When "^rpc \"\\(.*\\)\" returns \"\\(.*\\)\"$" 18 | (lambda (command result) 19 | (should (string= result (nnreddit-rpc-call nil nil command))))) 20 | 21 | (When "^I should be in buffer like \"\\(.+\\)\"$" 22 | (lambda (prefix) 23 | (should (string-prefix-p prefix (buffer-name))))) 24 | 25 | (When "^I goto group \"\\(.*\\)\"$" 26 | (lambda (group) 27 | (Given "I start an action chain") 28 | (And "I press \"R g\"") 29 | (And "I type \"%s\"" group) 30 | (And "I execute the action chain") 31 | (Then "I should be in buffer like \"*Summary nnreddit:\""))) 32 | 33 | (When "^I go to string \"\\(.+\\)\"$" 34 | (lambda (string) 35 | (goto-char (point-min)) 36 | (let ((search (re-search-forward string nil t)) 37 | (message "Can not go to string '%s' since it does not exist in the current buffer: %s")) 38 | (cl-assert search nil message string (buffer-string))) 39 | (backward-char (length string)))) 40 | 41 | (When "^I clear buffer \"\\(.*\\)\"$" 42 | (lambda (buffer) 43 | (with-current-buffer buffer 44 | (let ((inhibit-read-only t)) 45 | (erase-buffer))))) 46 | 47 | (When "^I dump buffer" 48 | (lambda () (message "%s" (buffer-string)))) 49 | 50 | (Then "^protected see message \"\\(.+\\)\"$" 51 | (lambda (message) 52 | (let ((msg "Expected '%s' to be included in the list of printed messages, but was not.")) 53 | (setq message (s-replace "\\\"" "\"" message)) 54 | (cl-assert (-contains? (-map (lambda (s) (if (stringp s) (s-trim s) "")) ecukes-message-log) message) nil msg message)))) 55 | 56 | (When "^gnus \\(try \\)?start\\(\\)$" 57 | (lambda (demote _workaround) 58 | (aif (get-buffer gnus-group-buffer) 59 | (switch-to-buffer it) 60 | (if-demote demote 61 | (When "I call \"gnus\"") 62 | (Then "I should be in buffer \"%s\"" gnus-group-buffer))))) 63 | 64 | (When "^gnus stop$" 65 | (lambda () 66 | (aif (get-buffer gnus-group-buffer) 67 | (progn (switch-to-buffer it) 68 | (And "I press \"q\"") 69 | (switch-to-buffer "*scratch*"))))) 70 | 71 | (When "^begin recording \"\\(.+\\)\"$" 72 | (lambda (cassette-prefix) 73 | (should (nnreddit-rpc-call nil nil "recording_begin" cassette-prefix)))) 74 | 75 | (When "^end recording \"\\(.+\\)\"$" 76 | (lambda (cassette-prefix) 77 | (should (nnreddit-rpc-call nil nil "recording_end" cassette-prefix)))) 78 | 79 | (When "^end recordings$" 80 | (lambda () 81 | (should (nnreddit-rpc-call nil nil "recording_end")))) 82 | 83 | (When "^I open latest \"\\(.+\\)\"$" 84 | (lambda (relative-prefix) 85 | (let* ((prefix (concat (file-name-as-directory gnus-home-directory) 86 | relative-prefix)) 87 | (dir (file-name-directory prefix)) 88 | (base (file-name-base prefix)) 89 | (alist 90 | (directory-files-and-attributes dir t (regexp-quote base) t)) 91 | (sofar (cl-first alist)) 92 | (most-recent (dolist (cand alist (car sofar)) 93 | (if (> (float-time (nth 5 (cdr cand))) 94 | (float-time (nth 5 (cdr sofar)))) 95 | (setq sofar cand))))) 96 | (find-file-literally most-recent)))) 97 | 98 | (When "^I wait \\([.0-9]+\\) seconds?$" 99 | (lambda (seconds) 100 | (sleep-for (string-to-number seconds)))) 101 | 102 | (When "^I wait for buffer to\\( not\\)? say \"\\(.+\\)\"$" 103 | (lambda (negate bogey) 104 | (nnreddit-test-wait-for 105 | (lambda () 106 | (let ((says (s-contains? (s-replace "\\n" "\n" bogey) (buffer-string)))) 107 | (revert-buffer :ignore-auto :noconfirm) 108 | (if negate (not says) says))) 109 | nil 5000 1000))) 110 | 111 | ;; (When "^I scuzz \"\\(.+\\)\"$" 112 | ;; (lambda (buffer) 113 | ;; (let ((v (vconcat [?\C-x ?b] (string-to-vector buffer)))) 114 | ;; (princ (format "holla %s %s %s" (string-to-vector buffer) v (key-binding buffer))) 115 | ;; (execute-kbd-macro (string-to-vector buffer)) 116 | ;; (execute-kbd-macro v)))) 117 | 118 | (When "^emacs26 cannot do action chain \"\\(.+\\)\"$" 119 | (lambda (keys) 120 | (let ((vkeys (seq-concatenate 'vector (mapcar #'string-to-char (split-string keys "[ ]"))))) 121 | (condition-case err 122 | (execute-kbd-macro vkeys) 123 | (error (message "emacs26 cannot do action chain: %s" 124 | (error-message-string err))))))) 125 | -------------------------------------------------------------------------------- /features/support/env.el: -------------------------------------------------------------------------------- 1 | (require 'ert) 2 | (require 'cl-lib) 3 | (require 'espuds) 4 | (require 'f) 5 | 6 | (let* ((support-path (f-dirname load-file-name)) 7 | (root-path (f-parent (f-parent support-path)))) 8 | (add-to-list 'load-path (concat root-path "/lisp")) 9 | (add-to-list 'load-path (concat root-path "/tests"))) 10 | 11 | (require 'nnreddit-test) 12 | 13 | (defvar nnreddit--current-feature) 14 | (add-hook 'ecukes-reporter-before-feature-hook 15 | (lambda (feature) 16 | (-when-let* ((intro (ecukes-feature-intro feature)) 17 | (header (ecukes-intro-header intro))) 18 | (setq nnreddit--current-feature header)))) 19 | 20 | (defmacro if-demote (demote &rest forms) 21 | (declare (debug t) (indent 1)) 22 | `(if ,demote 23 | (with-demoted-errors "demoted: %s" 24 | ,@forms) 25 | ,@forms)) 26 | 27 | (defun cleanup () 28 | (let* ((newsrc-file (if (boundp 'gnus-current-startup-file) 29 | (symbol-value 'gnus-current-startup-file) 30 | (when (boundp 'gnus-dot-newsrc) 31 | (symbol-value 'gnus-dot-newsrc)))) 32 | (quick-file (concat newsrc-file ".eld"))) 33 | (when (file-exists-p quick-file) 34 | (message "Deleting %s" quick-file) 35 | (delete-file quick-file)))) 36 | 37 | (Setup 38 | (custom-set-variables '(gnus-background-get-unread-articles nil) 39 | '(canlock-password "huh?"))) 40 | 41 | (After 42 | (setq nnreddit--whoami nil)) 43 | 44 | (Teardown 45 | (cleanup)) 46 | 47 | (Fail 48 | (if noninteractive 49 | (with-demoted-errors "demote: %s" 50 | (Then "end recordings") 51 | (Teardown)) 52 | (backtrace) 53 | (keyboard-quit))) ;; useful to prevent emacs from quitting 54 | -------------------------------------------------------------------------------- /lisp/nnreddit-pkg.el: -------------------------------------------------------------------------------- 1 | (define-package "nnreddit" 2 | "0.2.1" 3 | "Gnus Backend For Reddit" 4 | '((emacs "25.1") 5 | (request "0.3.3") 6 | (anaphora "1.0.4") 7 | (dash "2.18.1") 8 | (json-rpc "0.0.1") 9 | (virtualenvwrapper "20151123") 10 | (s "1.6.1")) 11 | :url "https://github.com/dickmao/nnreddit") 12 | -------------------------------------------------------------------------------- /lisp/nnreddit-pkg.el.in: -------------------------------------------------------------------------------- 1 | (define-package "nnreddit" 2 | VERSION 3 | "Gnus Backend For Reddit" 4 | '((emacs "25.1") 5 | (request "0.3.3") 6 | (anaphora "1.0.4") 7 | (dash "2.18.1") 8 | (json-rpc "0.0.1") 9 | (virtualenvwrapper "20151123") 10 | (s "1.6.1")) 11 | :url "https://github.com/dickmao/nnreddit") 12 | -------------------------------------------------------------------------------- /lisp/nnreddit.el: -------------------------------------------------------------------------------- 1 | ;;; nnreddit.el --- Gnus backend for reddit -*- lexical-binding: t; coding: utf-8 -*- 2 | 3 | ;; Copyright (C) 2019 The Authors of nnreddit.el 4 | 5 | ;; Authors: dickmao 6 | ;; Keywords: news 7 | ;; URL: https://github.com/dickmao/nnreddit 8 | 9 | ;; This file is NOT part of GNU Emacs. 10 | 11 | ;; This program is free software: you can redistribute it and/or modify 12 | ;; it under the terms of the GNU General Public License as published by 13 | ;; the Free Software Foundation, either version 3 of the License, or 14 | ;; (at your option) any later version. 15 | 16 | ;; This program is distributed in the hope that it will be useful, 17 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | ;; GNU General Public License for more details. 20 | 21 | ;; You should have received a copy of the GNU General Public License 22 | ;; along with nnreddit.el. If not, see . 23 | 24 | ;;; Commentary: 25 | 26 | ;; A Gnus backend for Reddit. 27 | 28 | ;;; Code: 29 | 30 | ;; Gnus Reddit 31 | ;; ---- ------ 32 | ;; list subscribed subreddits 33 | ;; group subreddit 34 | ;; threads threads 35 | ;; root article link or submission 36 | ;; articles {root article, comments} 37 | 38 | (require 'nnoo) 39 | (require 'gnus) 40 | (require 'gnus-start) 41 | (require 'gnus-art) 42 | (require 'gnus-sum) 43 | (require 'gnus-msg) 44 | (require 'gnus-cite) 45 | (require 'gnus-srvr) 46 | (require 'gnus-cache) 47 | (require 'gnus-bcklg) 48 | (require 'gnus-score) 49 | (require 'python) 50 | (require 'subr-x) 51 | (require 'json-rpc) 52 | (require 'mm-url) 53 | (require 'cl-lib) 54 | (require 'virtualenvwrapper) 55 | (require 'anaphora) 56 | (require 'request) 57 | (require 'url-http) 58 | (require 'gnus-topic) 59 | 60 | (defvar nnreddit--groups nil 61 | "Someone asked to avoid re-requesting joined subreddits.") 62 | 63 | (nnoo-declare nnreddit) 64 | 65 | (eval-when-compile 66 | (require 'subr-x) 67 | (unless (fboundp 'libxml-parse-xml-region) 68 | (display-warning 'nnreddit "nnreddit requires libxml support"))) 69 | 70 | (defalias 'nnreddit-string-trim-right 71 | (lambda (string &optional regexp) 72 | "Trim STRING of trailing string matching REGEXP. 73 | REGEXP defaults to \"[ \\t\\n\\r]+\"." 74 | (if (string-match (concat "\\(?:" (or regexp "[ \t\n\r]+") "\\)\\'") string) 75 | (replace-match "" t t string) 76 | string))) 77 | 78 | (defgroup nnreddit nil "A Gnus backend for Reddit." 79 | :group 'gnus) 80 | 81 | (defvar nnreddit--whoami nil "To populate with reddit login.") 82 | 83 | (defcustom nnreddit-max-render-bytes 300e3 84 | "`quoted-printable-encode-region' bogs when spyware gets out of hand." 85 | :type 'integer 86 | :group 'nnreddit) 87 | 88 | (defcustom nnreddit-render-submission t 89 | "If non-nil, follow link upon `gnus-summary-select-article'. 90 | 91 | Otherwise, just display link." 92 | :type 'boolean 93 | :group 'nnreddit) 94 | 95 | (defmacro nnreddit--gethash (string hashtable) 96 | "Get corresponding value of STRING from HASHTABLE. 97 | Starting in emacs-src commit c1b63af, Gnus moved from obarrays to 98 | normal hashtables." 99 | `(,(if (fboundp 'gnus-gethash-safe) 100 | 'gnus-gethash-safe 101 | 'gethash) 102 | ,string ,hashtable)) 103 | 104 | (defmacro nnreddit--sethash (string value hashtable) 105 | "Set corresponding value of STRING to VALUE in HASHTABLE. 106 | Starting in emacs-src commit c1b63af, Gnus moved from obarrays to 107 | normal hashtables." 108 | `(,(if (fboundp 'gnus-sethash) 109 | 'gnus-sethash 110 | 'puthash) 111 | ,string ,value ,hashtable)) 112 | 113 | (defcustom nnreddit-python-command "python3.8" 114 | "Python executable name." 115 | :type (append '(choice) 116 | (let (result) 117 | (dolist (py '("python3.7" "python3.8" "python3.9" "python3.10") 118 | result) 119 | (setq result (append result `((const :tag ,py ,py)))))) 120 | '((string :tag "Other"))) 121 | :set (lambda (symbol value) 122 | (set-default symbol value) 123 | (unless (string-match-p 124 | "\\b3" 125 | (shell-command-to-string (format "%s --version" value))) 126 | ;; emacs's arcane custom infra swallows `error' here 127 | (display-warning 'nnreddit 128 | "nnreddit-python-command: must customize to python 3.x" 129 | :error))) 130 | :group 'nnreddit) 131 | 132 | (defcustom nnreddit-venv 133 | (let* ((library-directory (file-name-directory (locate-library "nnreddit"))) 134 | (parent-directory (file-name-directory (directory-file-name library-directory))) 135 | (requirements-directory 136 | (file-name-directory (locate-library "requirements.txt" nil 137 | (list library-directory parent-directory)))) 138 | (defacto-version (file-name-nondirectory 139 | (directory-file-name requirements-directory))) 140 | (venv-id (concat defacto-version "-" nnreddit-python-command)) 141 | (result (concat (file-name-as-directory venv-location) venv-id)) 142 | (requirements (concat requirements-directory "requirements.txt")) 143 | (install-args (if (file-exists-p requirements) 144 | (list "-r" requirements) 145 | (list "virtualenv"))) 146 | (already-in-venv 147 | (not (zerop (apply #'call-process nnreddit-python-command 148 | nil nil nil 149 | (list 150 | "-c" 151 | "import sys; sys.exit(hasattr(sys, 'real_prefix'))"))))) 152 | (pip-args (append (list "-m" "pip" "install") 153 | (unless already-in-venv (list "--user")) 154 | install-args)) 155 | (pip-status 156 | (apply #'call-process nnreddit-python-command nil nil nil 157 | pip-args))) 158 | (gnus-message 7 "nnreddit-venv: %s %s" nnreddit-python-command 159 | (mapconcat 'identity pip-args " ")) 160 | (cond ((numberp pip-status) 161 | (unless (zerop pip-status) 162 | (gnus-message 3 "nnreddit-venv: pip install exit %s" pip-status))) 163 | (t (gnus-message 3 "nnreddit-venv: pip install signal %s" pip-status))) 164 | (gnus-message 7 "nnreddit-venv: %s" result) 165 | (unless (file-exists-p venv-location) 166 | (make-directory venv-location)) 167 | (cond ((member venv-id (split-string (venv-list-virtualenvs))) result) 168 | (t (gnus-message 5 "nnreddit-venv: installing venv to %s..." result) 169 | (condition-case err 170 | (progn 171 | (venv-mkvirtualenv-using nnreddit-python-command venv-id) 172 | (venv-with-virtualenv-shell-command 173 | venv-id 174 | ;; `python` and not `nnreddit-python-command` because 175 | ;; venv normalizes the executable to `python`. 176 | (format "cd %s && python setup.py install" requirements-directory)) 177 | (gnus-message 5 "nnreddit-venv: installing venv to %s...done" result) 178 | result) 179 | (error (when (venv-is-valid venv-id) 180 | (condition-case rmerr 181 | (venv-rmvirtualenv venv-id) 182 | (error (gnus-message 3 (format "venv-rmvirtualenv: %s" 183 | (error-message-string rmerr)))))) 184 | (gnus-message 3 (format "nnreddit-venv: %s" 185 | (error-message-string err))) 186 | "/dev/null"))))) 187 | "Full path to venv directory. 188 | 189 | To facilitate upgrades, the name gloms a de facto version (the directory 190 | name where this file resides) and the `nnreddit-python-command'." 191 | :type '(choice (string :tag "Directory" (get (quote nnreddit-env) (quote standard-value))) 192 | (const :tag "Development" nil)) 193 | :group 'nnreddit) 194 | 195 | (defmacro nnreddit-define-keys (km prefix parent &rest binds) 196 | "Define keymap KM with prefix PREFIX and parent PARENT with bindings BINDS." 197 | (declare (indent defun)) 198 | (if (get 'gnus-define-keys 'byte-obsolete-info) 199 | `(progn 200 | (setq ,km nil) 201 | (define-prefix-command ',km) 202 | (define-key ,parent ,prefix ,km) 203 | (cl-loop for (k v) on ',binds by (function cddr) 204 | do (define-key ,km k v))) 205 | `(gnus-define-keys (,km ,prefix ,parent) ,@binds))) 206 | 207 | (defvar nnreddit-group-mode-map (make-sparse-keymap)) 208 | 209 | (nnreddit-define-keys nnreddit-group-mode-map "R" gnus-group-mode-map 210 | "g" nnreddit-goto-group) 211 | 212 | (defvar nnreddit-summary-voting-map 213 | (let ((map (make-sparse-keymap))) 214 | map) 215 | "Voting map.") 216 | 217 | (defvar nnreddit-summary-mode-map 218 | (let ((map (make-sparse-keymap))) 219 | (define-key map "r" 'gnus-summary-followup) 220 | (define-prefix-command 'nnreddit-summary-voting-map) 221 | (define-key map "R" 'nnreddit-summary-voting-map) 222 | (define-key nnreddit-summary-voting-map "0" 'nnreddit-novote) 223 | (define-key nnreddit-summary-voting-map "-" 'nnreddit-downvote) 224 | (define-key nnreddit-summary-voting-map "=" 'nnreddit-upvote) 225 | (define-key nnreddit-summary-voting-map "+" 'nnreddit-upvote) 226 | map)) 227 | 228 | (defvar nnreddit-article-mode-map 229 | (copy-keymap nnreddit-summary-mode-map)) ;; how does Gnus do this? 230 | 231 | (defcustom nnreddit-log-rpc nil 232 | "Turn on PRAW logging." 233 | :type 'boolean 234 | :group 'nnreddit) 235 | 236 | (defcustom nnreddit-rpc-request-timeout 60 237 | "Timeout for talking to PRAW." 238 | :type 'integer 239 | :group 'nnreddit) 240 | 241 | (defcustom nnreddit-localhost "127.0.0.1" 242 | "Some users keep their browser in a separate domain. 243 | Do not set this to \"localhost\" as a numeric IP is required 244 | for the oauth handshake." 245 | :type 'string 246 | :group 'nnreddit) 247 | 248 | (defvar nnreddit-rpc-log-filename nil) 249 | 250 | (defvar nnreddit--python-module-extra-args nil "Primarily for testing.") 251 | 252 | (define-minor-mode nnreddit-article-mode 253 | "Minor mode for nnreddit articles. 254 | Disallow `gnus-article-reply-with-original'. 255 | 256 | \\{gnus-article-mode-map}" 257 | :lighter " Reddit" 258 | :keymap nnreddit-article-mode-map) 259 | 260 | (define-minor-mode nnreddit-summary-mode 261 | "Disallow \"reply\" commands in `gnus-summary-mode-map'. 262 | 263 | \\{nnreddit-summary-mode-map}" 264 | :lighter " Reddit" 265 | :keymap nnreddit-summary-mode-map) 266 | 267 | (define-minor-mode nnreddit-group-mode 268 | "Add `R-g' go-to-subreddit binding to *Group*. 269 | 270 | \\{nnreddit-group-mode-map}" 271 | :keymap nnreddit-group-mode-map 272 | :interactive (gnus-group-mode)) 273 | 274 | (cl-defun nnreddit-novote () 275 | "Retract vote." 276 | (interactive) 277 | (nnreddit-vote-current-article 0)) 278 | 279 | (cl-defun nnreddit-downvote () 280 | "Downvote the article in current buffer." 281 | (interactive) 282 | (nnreddit-vote-current-article -1)) 283 | 284 | (cl-defun nnreddit-upvote () 285 | "Upvote the article in current buffer." 286 | (interactive) 287 | (nnreddit-vote-current-article 1)) 288 | 289 | (defvar nnreddit--seq-map-indexed 290 | (if (fboundp 'seq-map-indexed) 291 | #'seq-map-indexed 292 | (lambda (function sequence) 293 | (let ((index 0)) 294 | (seq-map (lambda (elt) 295 | (prog1 296 | (funcall function elt index) 297 | (setq index (1+ index)))) 298 | sequence))))) 299 | 300 | (defmacro nnreddit--normalize-server () 301 | "Disallow \"server\" from being empty string, which is unsettling. 302 | Normalize it to \"nnreddit-default\"." 303 | `(let ((canonical "nnreddit-default")) 304 | (when (equal server "") 305 | (setq server nil)) 306 | (unless server 307 | (setq server canonical)) 308 | (unless (string= server canonical) 309 | (error "`nnreddit--normalize-server': multiple servers unsupported!")))) 310 | 311 | (defvar nnreddit-headers-hashtb (gnus-make-hashtable) 312 | "Group -> merged submissions and comments sorted by created time.") 313 | 314 | (defvar nnreddit-refs-hashtb (gnus-make-hashtable) 315 | "Who replied to whom (global over all entries).") 316 | 317 | (defvar nnreddit-authors-hashtb (gnus-make-hashtable) 318 | "For fast lookup of parent-author (global over all entries).") 319 | 320 | (defsubst nnreddit-get-headers (group) 321 | "List headers from GROUP." 322 | (nnreddit--gethash group nnreddit-headers-hashtb)) 323 | 324 | (defun nnreddit-find-header (group id) 325 | "O(n) search of GROUP headers for ID." 326 | (-when-let* ((headers (nnreddit-get-headers group)) 327 | (found (seq-position headers id 328 | (lambda (plst id) 329 | (equal id (plist-get plst :id)))))) 330 | (nnreddit--get-header (1+ found) group))) 331 | 332 | (defsubst nnreddit-refs-for (name &optional depth) 333 | "Get message ancestry for NAME up to DEPTH." 334 | (unless depth 335 | (setq depth most-positive-fixnum)) 336 | (when (> depth 0) 337 | (nreverse (cl-loop with parent-id = (nnreddit--gethash name nnreddit-refs-hashtb) 338 | for level = 0 then level 339 | for name = parent-id then 340 | (nnreddit--gethash name nnreddit-refs-hashtb) 341 | until (null name) 342 | collect name 343 | until (>= (cl-incf level) depth))))) 344 | 345 | (defsubst nnreddit-sort-append-headers (group &rest lvp) 346 | "Append to hashed headers of GROUP the LVP (list of vector of plists)." 347 | (nnreddit--sethash group (nconc (nnreddit-get-headers group) 348 | (apply #'nnreddit--sort-headers lvp)) 349 | nnreddit-headers-hashtb)) 350 | 351 | (defvar nnreddit-directory (nnheader-concat gnus-directory "reddit") 352 | "Where to retrieve last read state.") 353 | 354 | (defvar nnreddit-processes nil 355 | "Garbage collect PRAW processes.") 356 | 357 | (nnoo-define-basics nnreddit) 358 | 359 | (defsubst nnreddit-rpc-call (server generator_kwargs method &rest args) 360 | "Make jsonrpc call to SERVER with GENERATOR_KWARGS using METHOD ARGS. 361 | Process stays the same, but the jsonrpc connection (a cheap struct) gets 362 | reinstantiated with every call." 363 | (nnreddit--normalize-server) 364 | (-when-let* ((proc (nnreddit-rpc-get server)) 365 | (connection (json-rpc--create :process proc 366 | :host nnreddit-localhost 367 | :id-counter 0))) 368 | (condition-case-unless-debug err 369 | (apply #'nnreddit-rpc-request connection generator_kwargs method args) 370 | (error (gnus-message 3 "nnreddit-rpc-call: %s" (error-message-string err)) 371 | nil)))) 372 | 373 | (defsubst nnreddit--populate-whoami () 374 | "Get login name from PRAW user_attr." 375 | (unless nnreddit--whoami 376 | (setq nnreddit--whoami 377 | (aand (nnreddit-rpc-call nil nil "user_attr" "name") 378 | (and (stringp it) (not (zerop (length it))) it)))) 379 | nnreddit--whoami) 380 | 381 | (defvar nnreddit--current-feature) 382 | (defmacro nnreddit--test-supports-inbox (&rest body) 383 | "Run BODY if not testing or testfile later than 20201124." 384 | `(when (or (not (boundp 'nnreddit--current-feature)) 385 | (>= (string-to-number nnreddit--current-feature) 20201124)) 386 | ,@body)) 387 | 388 | (defun nnreddit--inbox-realname () 389 | "Return /u/[nnreddit--whoami]." 390 | (nnreddit--test-supports-inbox (nnreddit--populate-whoami)) 391 | (when (stringp nnreddit--whoami) (concat "/u/" nnreddit--whoami))) 392 | 393 | (defun nnreddit-goto-group (realname) 394 | "Jump to the REALNAME subreddit." 395 | (interactive (list (read-no-blanks-input "Subreddit: r/"))) 396 | (let* ((canonical (nnreddit-rpc-call nil nil "canonical_spelling" realname)) 397 | (group (gnus-group-full-name canonical (list "nnreddit")))) 398 | (if group 399 | (progn (gnus-activate-group group t) 400 | (gnus-group-read-group t t group)) 401 | (gnus-message 3 "nnreddit-goto-group: failed canonical_spelling of %s" realname)))) 402 | 403 | (defsubst nnreddit--current-article-number () 404 | "`gnus-article-current' is a global variable that gets clobbered." 405 | (or (cdr gnus-message-group-art) 406 | (and (gnus-buffer-live-p gnus-summary-buffer) 407 | (with-current-buffer gnus-summary-buffer 408 | (cdr gnus-article-current))))) 409 | 410 | (defsubst nnreddit--current-group () 411 | "`gnus-article-current' is a global variable that gets clobbered." 412 | (or (car gnus-message-group-art) 413 | (with-current-buffer gnus-summary-buffer 414 | (car gnus-article-current)))) 415 | 416 | (defun nnreddit-vote-current-article (vote) 417 | "VOTE is +1, -1, 0." 418 | (unless gnus-newsgroup-name 419 | (error "No current newgroup")) 420 | (if-let ((article-number (or (nnreddit--current-article-number) 421 | (with-current-buffer gnus-summary-buffer 422 | (gnus-summary-article-number))))) 423 | (let* ((header (nnreddit--get-header 424 | article-number 425 | (gnus-group-real-name (or (nnreddit--current-group) 426 | gnus-newsgroup-name)))) 427 | (orig-score (format "%s" (plist-get header :score))) 428 | (new-score (if (zerop vote) orig-score 429 | (concat orig-score " " 430 | (if (> vote 0) "+" "") 431 | (format "%s" vote)))) 432 | (article-name (plist-get header :name))) 433 | (save-excursion 434 | (save-window-excursion 435 | (with-current-buffer gnus-summary-buffer 436 | (if (eq (gnus-summary-article-number) (cdr gnus-article-current)) 437 | (progn (with-current-buffer gnus-article-buffer 438 | (let ((inhibit-read-only t)) 439 | (nnheader-replace-header "Score" new-score))) 440 | (nnreddit-rpc-call nil nil "vote" article-name vote)) 441 | (message "Open the article before voting")))))) 442 | (error "No current article"))) 443 | 444 | (defsubst nnreddit--gate (&optional group) 445 | "Apply our minor modes only when the following conditions hold for GROUP." 446 | (unless group 447 | (setq group gnus-newsgroup-name)) 448 | (and (stringp group) 449 | (listp (gnus-group-method group)) 450 | (eq 'nnreddit (car (gnus-group-method group))))) 451 | 452 | (defsubst nnreddit--message-gate () 453 | "In `message-mode', `gnus-newsgroup-name' could be anything. 454 | So we cannot use `nnreddit--gate'." 455 | (nnreddit--gate (car-safe gnus-message-group-art))) 456 | 457 | (defun nnreddit-update-subscription (group level oldlevel &optional _previous) 458 | "Nnreddit `gnus-group-change-level' callback of GROUP to LEVEL from OLDLEVEL." 459 | (when (nnreddit--gate group) 460 | (let ((old-subbed-p (<= oldlevel gnus-level-subscribed)) 461 | (new-subbed-p (<= level gnus-level-subscribed))) 462 | (unless (eq old-subbed-p new-subbed-p) 463 | ;; afaict, praw post() doesn't return status 464 | (setq nnreddit--groups nil) 465 | (if new-subbed-p 466 | (nnreddit-rpc-call nil nil "subscribe" (gnus-group-real-name group)) 467 | (nnreddit-rpc-call nil nil "unsubscribe" (gnus-group-real-name group))))))) 468 | 469 | (defun nnreddit-rpc-kill (&optional server) 470 | "Kill the jsonrpc process named SERVER." 471 | (interactive (list nil)) 472 | (nnreddit--normalize-server) 473 | (let (new-processes) 474 | (mapc (lambda (proc) (if (and server (not (string= server (process-name proc)))) 475 | (push proc new-processes) 476 | (delete-process proc))) 477 | nnreddit-processes) 478 | (setq nnreddit-processes new-processes))) 479 | 480 | (deffoo nnreddit-request-close () 481 | (nnreddit-close-server) 482 | t) 483 | 484 | (deffoo nnreddit-request-type (_group &optional _article) 485 | 'news) 486 | 487 | (deffoo nnreddit-server-opened (&optional server) 488 | (nnreddit--normalize-server) 489 | (setq nnreddit-processes 490 | (cl-remove-if-not (lambda (proc) (string= server (process-name proc))) 491 | nnreddit-processes))) 492 | 493 | (deffoo nnreddit-status-message (&optional server) 494 | (nnreddit--normalize-server) 495 | "") 496 | 497 | (deffoo nnreddit-open-server (_server &optional _defs) 498 | t) 499 | 500 | (deffoo nnreddit-close-group (_group &optional server) 501 | (nnreddit--normalize-server) 502 | t) 503 | 504 | (defmacro nnreddit--with-group (group &rest body) 505 | "Disambiguate GROUP if it's empty and execute BODY." 506 | (declare (debug (form &rest form)) 507 | (indent 1)) 508 | `(when-let ((group (or ,group (gnus-group-real-name gnus-newsgroup-name))) 509 | (gnus-newsgroup-name (unless (zerop (length group)) 510 | (gnus-group-full-name group "nnreddit:")))) 511 | ,@body)) 512 | 513 | (defun nnreddit--get-header (article-number &optional group) 514 | "Get header indexed ARTICLE-NUMBER for GROUP." 515 | (nnreddit--with-group group 516 | (let ((headers (nnreddit-get-headers group))) 517 | (elt headers (1- article-number))))) 518 | 519 | (defun nnreddit--get-body (name &optional group server) 520 | "Get full text of submission or comment NAME for GROUP at SERVER." 521 | (nnreddit--normalize-server) 522 | (if name 523 | (nnreddit--with-group group 524 | (nnreddit-rpc-call server nil "body" group name)) 525 | (gnus-message 3 "nnreddit--get-body: null name\n%s" 526 | (with-temp-buffer 527 | (backtrace) 528 | (buffer-string))))) 529 | 530 | (defsubst nnreddit-hack-name-to-id (name) 531 | "Get x from t1_x (NAME)." 532 | (cl-subseq name 3)) 533 | 534 | (defsubst nnreddit--br-tagify (body) 535 | "Reddit-html BODY shies away from
. Should it?" 536 | (replace-regexp-in-string "\n" "
" body)) 537 | 538 | (defsubst nnreddit--citation-wrap (author body) 539 | "Cite AUTHOR using `gnus-message-cite-prefix-regexp' before displaying BODY. 540 | 541 | Originally written by Paul Issartel." 542 | (with-temp-buffer 543 | (insert body) 544 | (mm-url-remove-markup) 545 | (mm-url-decode-entities) 546 | (fill-region (point-min) (point-max)) 547 | (let* ((trimmed-1 (replace-regexp-in-string "\\(\\s-\\|\n\\)+$" "" (buffer-string))) 548 | (trimmed (replace-regexp-in-string "^\\(\\s-\\|\n\\)+" "" trimmed-1))) 549 | (concat author " wrote:
\n" 550 | "
\n"
 551 |               (cl-subseq (replace-regexp-in-string "\n" "\n> " (concat "\n" trimmed)) 1)
 552 |               "\n
\n\n")))) 553 | 554 | (defun nnreddit-add-entry (hashtb e field) 555 | "Add to HASHTB the pair consisting of entry E's name to its FIELD." 556 | (nnreddit--sethash (plist-get e :name) (plist-get e field) hashtb)) 557 | 558 | (defun nnreddit--filter-after (after-this vop) 559 | "Get elements created AFTER-THIS in VOP (vector of plists)." 560 | (cl-loop for elt-idx in (funcall nnreddit--seq-map-indexed 561 | (lambda (elt idx) (cons elt idx)) vop) 562 | until (>= (plist-get (car elt-idx) :created_utc) after-this) 563 | finally return (seq-drop vop (or (cdr elt-idx) 0)))) 564 | 565 | (defsubst nnreddit--base10 (base36) 566 | "Convert BASE36 reddit name encoding to a base10 integer." 567 | (apply #'+ (funcall nnreddit--seq-map-indexed 568 | (lambda (elt idx) 569 | (* (expt 36 idx) 570 | (if (>= elt ?a) (+ 10 (- elt ?a)) (- elt ?0)))) 571 | (reverse base36)))) 572 | 573 | (deffoo nnreddit-request-group-scan (group &optional server _info) 574 | "\\[gnus-group-get-new-news-this-group] from *Group* calls this. 575 | Set flag for the ensuing `nnreddit-request-group' to avoid going out 576 | to PRAW yet again." 577 | (nnreddit--normalize-server) 578 | (nnreddit--with-group group 579 | (gnus-message 5 "nnreddit-request-group-scan: scanning %s..." group) 580 | (gnus-activate-group gnus-newsgroup-name t) 581 | (gnus-message 5 "nnreddit-request-group-scan: scanning %s...done" group) 582 | t)) 583 | 584 | (defsubst nnreddit--shift-ranges (delta ranges) 585 | "Shift back by DELTA the elements of RANGES, removing any negative entries." 586 | (cl-remove-if-not (lambda (e) 587 | (cond ((numberp e) (> e 0)) 588 | (t (> (cdr e) 0)))) 589 | (mapcar (lambda (e) 590 | (cond ((numberp e) (- e delta)) 591 | (t `(,(max 1 (- (car e) delta)) . 592 | ,(- (cdr e) delta))))) 593 | ranges))) 594 | 595 | ;; gnus-group-select-group 596 | ;; gnus-group-read-group 597 | ;; gnus-summary-read-group 598 | ;; gnus-summary-read-group-1 599 | ;; gnus-summary-setup-buffer 600 | ;; sets gnus-newsgroup-name 601 | ;; gnus-select-newsgroup 602 | ;; gnus-request-group 603 | ;; nnreddit-request-group 604 | (deffoo nnreddit-request-group (group &optional server _fast info) 605 | (nnreddit--normalize-server) 606 | (nnreddit--with-group group 607 | (let* ((info 608 | (or info 609 | (gnus-get-info gnus-newsgroup-name) 610 | (list gnus-newsgroup-name 611 | gnus-level-default-subscribed 612 | nil nil 613 | (gnus-method-simplify (gnus-group-method gnus-newsgroup-name))))) 614 | (params (gnus-info-params info)) 615 | (newsrc-read-ranges (gnus-info-read info)) 616 | (newsrc-mark-ranges (gnus-info-marks info)) 617 | (newsrc-seen-cons (gnus-group-parameter-value params 'last-seen t)) 618 | (newsrc-seen-index (car newsrc-seen-cons)) 619 | (newsrc-seen-id (cdr newsrc-seen-cons)) 620 | (headers (nnreddit-get-headers group)) 621 | (num-headers (length headers)) 622 | (status (format "211 %d %d %d %s" num-headers 1 num-headers group))) 623 | (gnus-message 7 "nnreddit-request-group: %s" status) 624 | (nnheader-insert "%s\n" status) 625 | 626 | ;; remind myself how this works: 627 | ;; old-praw (1 - 20=emkdjrx) 628 | ;; read-ranges (1 - 10) (15 - 20) 629 | ;; unread-ranges (11, 12, 13, 14) 630 | ;; new-praw (12 13 14 15 16 17 18 19 20 - 100) 631 | ;; 20=emkdjrx in old-praw is 9=emkdjrx in new-praw. index shift is 20-9=+11 632 | ;; new-unread-ranges (0, 1, 2, 3) 633 | ;; new-read-ranges (4 - 9) 634 | (when (gnus-group-entry gnus-newsgroup-name) 635 | ;; seen-indices are one-indexed ! 636 | (let* ((newsrc-seen-index-now 637 | (if (or (not (stringp newsrc-seen-id)) 638 | (zerop (nnreddit--base10 newsrc-seen-id))) 639 | 1 640 | (cl-loop with cand 641 | for plst in (reverse headers) 642 | for i = (length headers) then (1- i) 643 | if (= (nnreddit--base10 (plist-get plst :id)) 644 | (nnreddit--base10 newsrc-seen-id)) 645 | do (gnus-message 7 "nnreddit-request-group: exact=%s" i) 646 | and return i ;; do not go to finally 647 | end 648 | if (> (nnreddit--base10 (plist-get plst :id)) 649 | (nnreddit--base10 newsrc-seen-id)) 650 | do (gnus-message 7 "nnreddit-request-group: cand=%s" 651 | (setq cand i)) 652 | end 653 | finally return (or cand 0)))) 654 | (updated-seen-index (- num-headers 655 | (aif (seq-position 656 | (reverse headers) nil 657 | (lambda (plst _e) 658 | (not (plist-get plst :title)))) 659 | it 660 | -1))) 661 | (updated-seen-id (awhen (nth (1- updated-seen-index) headers) 662 | (plist-get it :id))) 663 | (delta (if newsrc-seen-index 664 | (max 0 (- newsrc-seen-index newsrc-seen-index-now)) 665 | 0)) 666 | (newsrc-read-ranges-shifted 667 | (nnreddit--shift-ranges delta newsrc-read-ranges)) 668 | (newsrc-mark-ranges-shifted 669 | (mapcar (lambda (what-ranges) 670 | (cl-case (car what-ranges) 671 | (seen `(seen (1 . ,num-headers))) 672 | (t (cons (car what-ranges) 673 | (nnreddit--shift-ranges delta (cdr what-ranges)))))) 674 | newsrc-mark-ranges))) 675 | (gnus-message 7 "nnreddit-request-group: seen-id=%s seen-index=%s -> %s" 676 | newsrc-seen-id newsrc-seen-index newsrc-seen-index-now) 677 | (gnus-message 7 "nnreddit-request-group: seen-id-to-be=%s seen-index-to-be=%s delta=%d" 678 | updated-seen-id updated-seen-index delta) 679 | (gnus-message 7 "nnreddit-request-group: read-ranges=%s shifted-read-ranges=%s" 680 | newsrc-read-ranges newsrc-read-ranges-shifted) 681 | (gnus-message 7 "nnreddit-request-group: mark-ranges=%s shifted-mark-ranges=%s" 682 | newsrc-mark-ranges newsrc-mark-ranges-shifted) 683 | (setf (gnus-info-read info) newsrc-read-ranges-shifted) 684 | (gnus-info-set-marks info newsrc-mark-ranges-shifted) 685 | (when updated-seen-id 686 | (while (assq 'last-seen params) 687 | (gnus-alist-pull 'last-seen params)) 688 | (gnus-info-set-params 689 | info 690 | (cons `(last-seen ,updated-seen-index . ,updated-seen-id) params) 691 | t)) 692 | (unless (listp (gnus-info-method info)) 693 | (gnus-info-set-method info (gnus-group-method gnus-newsgroup-name) t)) 694 | (gnus-set-info gnus-newsgroup-name info) 695 | (gnus-message 7 "nnreddit-request-group: new info=%s" info)))) 696 | t)) 697 | 698 | (deffoo nnreddit-request-scan (&optional group server) 699 | (nnreddit--normalize-server) 700 | (when group 701 | (nnreddit--with-group group 702 | (cond ((string= group (nnreddit--inbox-realname)) 703 | (let ((inbox (nnreddit-rpc-call server nil "inboxes" nnreddit--whoami))) 704 | (gnus-message 5 "nnreddit-request-scan: %s: +%s inbox" 705 | group (length inbox)) 706 | (seq-doseq (e inbox) 707 | (nnreddit-add-entry nnreddit-refs-hashtb e :parent_id) 708 | (nnreddit-add-entry nnreddit-authors-hashtb e :author)) 709 | (nnreddit-sort-append-headers group inbox))) 710 | (t 711 | (let* ((comments (nnreddit-rpc-call server nil "comments" group)) 712 | (raw-submissions (nnreddit-rpc-call server nil "submissions" group)) 713 | (submissions (if (zerop (length comments)) 714 | raw-submissions 715 | (nnreddit--filter-after 716 | (- (plist-get (aref comments 0) :created_utc) 7200) 717 | raw-submissions)))) 718 | (seq-doseq (e comments) 719 | (nnreddit-add-entry nnreddit-refs-hashtb e :parent_id)) ;; :parent_id is fullname 720 | (seq-doseq (e (vconcat submissions comments)) 721 | (nnreddit-add-entry nnreddit-authors-hashtb e :author)) 722 | (gnus-message 5 "nnreddit-request-scan: %s: +%s comments +%s submissions" 723 | group (length comments) (length submissions)) 724 | (nnreddit-sort-append-headers group submissions comments))))))) 725 | 726 | (defsubst nnreddit--make-message-id (fullname) 727 | "Construct a valid Gnus message id from FULLNAME." 728 | (format "<%s@reddit.com>" fullname)) 729 | 730 | (defsubst nnreddit--make-references (fullname) 731 | "Construct a space delimited string of message ancestors of FULLNAME." 732 | (mapconcat (lambda (ref) (nnreddit--make-message-id ref)) 733 | (nnreddit-refs-for fullname) " ")) 734 | 735 | (defsubst nnreddit--make-header (article-number &optional group) 736 | "Construct full headers of articled indexed ARTICLE-NUMBER in GROUP." 737 | (let* ((header (nnreddit--get-header article-number group)) 738 | (score (plist-get header :score)) 739 | (num-comments (plist-get header :num_comments))) 740 | (make-full-mail-header 741 | article-number 742 | (or (plist-get header :title) 743 | (concat "Re: " (plist-get header :link_title))) 744 | (plist-get header :author) 745 | (format-time-string "%a, %d %h %Y %T %z (%Z)" (plist-get header :created_utc)) 746 | (nnreddit--make-message-id (plist-get header :name)) 747 | (nnreddit--make-references (plist-get header :name)) 748 | 0 0 nil 749 | (append `((X-Reddit-Name . ,(plist-get header :name))) 750 | `((X-Reddit-ID . ,(plist-get header :id))) 751 | (awhen (plist-get header :permalink) 752 | `((X-Reddit-Permalink . ,it))) 753 | (and (integerp score) 754 | `((X-Reddit-Score . ,(number-to-string score)))) 755 | (and (integerp num-comments) 756 | `((X-Reddit-Comments . ,(number-to-string num-comments)))))))) 757 | 758 | (cl-defun nnreddit--request-error (caller 759 | &key response symbol-status error-thrown 760 | &allow-other-keys 761 | &aux (response-status 762 | (request-response-status-code response))) 763 | "Refer to CALLER when reporting a submit error. 764 | Also report http code of RESPONSE, which is distinct from SYMBOL-STATUS, 765 | and ERROR-THROWN. The http code is stored in RESPONSE-STATUS." 766 | (gnus-message 3 "%s %s: http status %s, %s" 767 | caller symbol-status response-status 768 | (error-message-string error-thrown))) 769 | 770 | (cl-defun nnreddit--request (caller 771 | url 772 | &rest attributes &key parser (backend 'url-retrieve) 773 | &allow-other-keys) 774 | "Prefix errors with CALLER when executing synchronous request to URL. 775 | Request shall contain ATTRIBUTES, one of which is PARSER of the response, 776 | if provided (shall default to verbatim dump of response, if not). 777 | BACKEND can be curl (defaults to `url-retrieve')." 778 | (unless parser 779 | (setq attributes (nconc attributes (list :parser #'buffer-string)))) 780 | (setq attributes (cl-loop for (k v) on attributes by (function cddr) 781 | unless (eq k :backend) 782 | collect k and collect v)) 783 | (let ((request-backend backend)) 784 | (apply #'request url 785 | :sync t 786 | :error (apply-partially #'nnreddit--request-error caller) 787 | attributes))) 788 | 789 | (cl-defun nnreddit--content-handler 790 | (&key data response &allow-other-keys 791 | &aux (header (request-response--raw-header response))) 792 | "Wrap DATA in uri if RESPONSE has HEADER that is image." 793 | (let* ((_ (string-match "Content-Type:\\s-*\\([[:graph:]]+\\)" header)) 794 | (content-type (match-string 1 header))) 795 | (cl-destructuring-bind (type _subtype) (split-string content-type "/") 796 | (cond ((string= type "image") 797 | (format "" 798 | content-type 799 | (base64-encode-string (encode-coding-string data 'binary) t))) 800 | ((string= type "text") data) 801 | (t (error "`nnreddit--content-handler': passing on %s" content-type)))))) 802 | 803 | (defmacro nnreddit--concat (thus-far &rest add) 804 | "Assign to THUS-FAR the catenation of itself and ADD." 805 | `(setq ,thus-far (apply #'concat ,thus-far (list ,@add)))) 806 | 807 | (deffoo nnreddit-request-article (article-number &optional group server buffer) 808 | (nnreddit--normalize-server) 809 | (nnreddit--with-group group 810 | (with-current-buffer (or buffer nntp-server-buffer) 811 | (erase-buffer) 812 | (let* ((header (nnreddit--get-header article-number group)) 813 | (mail-header (nnreddit--make-header article-number)) 814 | (score (cdr (assq 'X-Reddit-Score (mail-header-extra mail-header)))) 815 | (permalink (cdr (assq 'X-Reddit-Permalink (mail-header-extra mail-header)))) 816 | (body (awhen (plist-get header :name) 817 | (nnreddit--get-body it group server)))) 818 | (when body 819 | (insert 820 | "Newsgroups: " group "\n" 821 | "Subject: " (mail-header-subject mail-header) "\n" 822 | "From: " (or (mail-header-from mail-header) "nobody") "\n" 823 | "Date: " (mail-header-date mail-header) "\n" 824 | "Message-ID: " (mail-header-id mail-header) "\n" 825 | "References: " (mail-header-references mail-header) "\n" 826 | (if permalink 827 | (format "Archived-at: \n" 828 | permalink) 829 | "") 830 | "Score: " score "\n" 831 | "\n") 832 | (mml-insert-multipart "alternative") 833 | (mml-insert-tag 'part 'type "text/html" 834 | 'disposition "inline" 835 | 'charset "utf-8") 836 | (save-excursion (mml-insert-tag '/part)) 837 | (-when-let* 838 | ((parent-name (plist-get header :parent_id)) ;; parent_id is full 839 | (parent-author (or (nnreddit--gethash parent-name nnreddit-authors-hashtb) 840 | "Someone")) 841 | (parent-body (nnreddit--get-body parent-name group server))) 842 | (insert (nnreddit--citation-wrap parent-author parent-body))) 843 | (aif (and nnreddit-render-submission 844 | (eq (plist-get header :is_self) :json-false) 845 | (plist-get header :url)) 846 | (condition-case err 847 | (nnreddit--request 848 | "nnreddit-request-article" it 849 | :success 850 | (lambda (&rest args) 851 | (let ((data (apply #'nnreddit--content-handler args))) 852 | (if (> (length data) nnreddit-max-render-bytes) 853 | (insert (nnreddit--br-tagify body)) 854 | (insert data))))) 855 | (error (gnus-message 5 "nnreddit-request-article: %s %s" 856 | it (error-message-string err)) 857 | (insert (nnreddit--br-tagify body)))) 858 | (insert (nnreddit--br-tagify body))) 859 | (insert "\n") 860 | (if (mml-validate) 861 | (message-encode-message-body) 862 | (gnus-message 2 "nnreddit-request-article: Invalid mml:\n%s" 863 | (buffer-string))) 864 | (cons group article-number)))))) 865 | 866 | (deffoo nnreddit-request-head (_id &optional _group _server) 867 | "Demur. 868 | Since `gnus-summary-refer-article' calls 869 | `gnus-summary-select-article' on an on-demand retrieval, 870 | `nnreddit--get-body' won't cut it." 871 | nil) 872 | 873 | (deffoo nnreddit-retrieve-headers (article-numbers &optional group server _fetch-old) 874 | (nnreddit--normalize-server) 875 | (nnreddit--with-group group 876 | (with-current-buffer nntp-server-buffer 877 | (erase-buffer) 878 | (dolist (i article-numbers) 879 | (nnheader-insert-nov (nnreddit--make-header i group))) 880 | 'nov))) 881 | 882 | (defsubst nnreddit--earliest-among (indices lvp) 883 | "Return (list-to-iterate . next-earliest) from INDICES. 884 | INDICES are thus far iterators. 885 | LVP is a list of vectors of plists. 886 | Used in the interleaving of submissions and comments." 887 | (let (earliest next-earliest) 888 | (dolist (plst-idx 889 | (cl-remove-if-not #'car 890 | (funcall nnreddit--seq-map-indexed 891 | (lambda (plst idx) (cons plst idx)) 892 | (seq-mapn 893 | (lambda (v i) 894 | (if (< i (length v)) (aref v i))) 895 | lvp indices))) 896 | (list (cdr earliest) 897 | (awhen next-earliest 898 | (plist-get (car it) :created_utc)))) 899 | (cond ((null earliest) 900 | (setq earliest plst-idx)) 901 | ((< (plist-get (car plst-idx) :created_utc) 902 | (plist-get (car earliest) :created_utc)) 903 | (setq next-earliest earliest) 904 | (setq earliest plst-idx)) 905 | ((null next-earliest) 906 | (setq next-earliest plst-idx)))))) 907 | 908 | (defun nnreddit--sort-headers (&rest lvp) 909 | "Sort headers for LVP (list of vectors of plists)." 910 | (let* ((indices (make-list (length lvp) 0)) 911 | result) 912 | (while (not (equal indices (mapcar #'length lvp))) 913 | (cl-destructuring-bind (to-iterate bogey-created) 914 | (nnreddit--earliest-among indices lvp) 915 | (cl-loop with arr = (elt lvp to-iterate) 916 | for j in (number-sequence (elt indices to-iterate) (1- (length arr))) 917 | for plst = (aref arr j) 918 | for created = (plist-get plst :created_utc) 919 | until (> created (or bogey-created most-positive-fixnum)) 920 | do (cl-incf (elt indices to-iterate)) 921 | do (push plst result)))) 922 | (nreverse result))) 923 | 924 | (deffoo nnreddit-close-server (&optional server _defs) 925 | (nnreddit--normalize-server) 926 | (condition-case err 927 | (progn (nnreddit-rpc-kill server) t) 928 | (error 929 | (gnus-message 2 "nnreddit-close-server: %s" (error-message-string err)) 930 | nil))) 931 | 932 | (deffoo nnreddit-request-list (&optional server) 933 | (nnreddit--normalize-server) 934 | (with-current-buffer nntp-server-buffer 935 | (let ((groups (delq 936 | nil 937 | (or nnreddit--groups 938 | (setq nnreddit--groups 939 | (append (nnreddit-rpc-call server nil "user_subreddits") 940 | (nnreddit--test-supports-inbox 941 | (list (nnreddit--inbox-realname)))))))) 942 | (newsrc (cl-mapcan (lambda (info) 943 | (when (and (equal "nnreddit:" (gnus-info-method info)) 944 | (<= (gnus-info-level info) 945 | gnus-level-subscribed)) 946 | (list (gnus-info-group info)))) 947 | gnus-newsrc-alist))) 948 | (mapc (lambda (realname) 949 | (let ((group (gnus-group-full-name realname '("nnreddit" (or server ""))))) 950 | (erase-buffer) 951 | (gnus-message 5 "nnreddit-request-list: scanning %s..." realname) 952 | (gnus-activate-group group t) 953 | (gnus-message 5 "nnreddit-request-list: scanning %s...done" realname) 954 | (when (> (gnus-group-level group) gnus-level-subscribed) 955 | (gnus-group-unsubscribe-group group gnus-level-default-subscribed t)) 956 | (setq newsrc (cl-remove group newsrc :test #'string=)))) 957 | groups) 958 | (mapc (lambda (fullname) 959 | (gnus-message 4 "nnreddit-request-list: missing subscription %s" fullname) 960 | (nnreddit-rpc-call nil nil "subscribe" (gnus-group-real-name fullname)) 961 | (gnus-activate-group fullname t)) 962 | newsrc) 963 | (erase-buffer) 964 | (mapc (lambda (group) 965 | (insert (format "%s %d 1 y\n" group 966 | (length (nnreddit-get-headers group))))) 967 | groups))) 968 | t) 969 | 970 | (defun nnreddit-sentinel (process event) 971 | "Wipe headers state when PROCESS dies from EVENT." 972 | (unless (string= "open" (substring event 0 4)) 973 | (gnus-message 2 "nnreddit-sentinel: process %s %s" 974 | (car (process-command process)) 975 | (replace-regexp-in-string "\n$" "" event)) 976 | (setq nnreddit-headers-hashtb (gnus-make-hashtable)) 977 | (setq nnreddit-processes (cl-remove-if (lambda (other) (string= (process-name process) 978 | (process-name other))) 979 | nnreddit-processes)) 980 | (gnus-backlog-shutdown))) 981 | 982 | (defun nnreddit--message-user (server beg end _prev-len) 983 | "Message SERVER related alert with `buffer-substring' from BEG to END." 984 | (let ((string (buffer-substring beg end)) 985 | (magic "::user::")) 986 | (when (string-prefix-p magic string) 987 | (message "%s: %s" server (nnreddit-string-trim-right 988 | (substring string (length magic)) 989 | "\n"))))) 990 | 991 | (defsubst nnreddit--install-failed () 992 | "If we can't install the virtualenv then all bets are off." 993 | (string= nnreddit-venv "/dev/null")) 994 | 995 | (defun nnreddit-dump-diagnostics (&optional server) 996 | "Makefile recipe test-run. SERVER is usually nnreddit-default." 997 | (nnreddit--normalize-server) 998 | (dolist (b `(,byte-compile-log-buffer 999 | ,gnus-group-buffer 1000 | "*Messages*" 1001 | ,(format " *%s*" server) 1002 | ,(format " *%s-stderr*" server))) 1003 | (when (buffer-live-p (get-buffer b)) 1004 | (princ (format "\nBuffer: %s\n%s\n\n" b (with-current-buffer b (buffer-string))) 1005 | #'external-debugging-output)))) 1006 | 1007 | (defsubst nnreddit--cripple-fight-p () 1008 | "The albatross of a single-threaded event loop hangs heavy on gnus and exwm. 1009 | As a result, each deadlocks the other in a race to the bottom between two 1010 | relics of the 1990s." 1011 | (and (boundp 'exwm--connection) exwm--connection)) 1012 | 1013 | (defun nnreddit-rpc-get (&optional server) 1014 | "Retrieve the PRAW process for SERVER." 1015 | (nnreddit--normalize-server) 1016 | (unless (nnreddit--install-failed) 1017 | (let ((proc (get-buffer-process (get-buffer-create (format " *%s*" server))))) 1018 | (unless proc 1019 | (let* ((nnreddit-el-dir (directory-file-name (file-name-directory (locate-library "nnreddit")))) 1020 | (nnreddit-py-dir (directory-file-name 1021 | (if (string= "lisp" (file-name-base nnreddit-el-dir)) 1022 | (file-name-directory nnreddit-el-dir) 1023 | nnreddit-el-dir))) 1024 | (python-shell-extra-pythonpaths (list nnreddit-py-dir)) 1025 | (process-environment 1026 | (cons (concat "PATH=" (getenv "PATH")) 1027 | (funcall (if (fboundp 'python-shell--calculate-process-environment) 1028 | (symbol-function 'python-shell--calculate-process-environment) 1029 | (symbol-function 'python-shell-calculate-process-environment))))) 1030 | (python-executable (if nnreddit-venv 1031 | (format "%s/bin/python" nnreddit-venv) 1032 | (executable-find nnreddit-python-command))) 1033 | (python-module (if (featurep 'nnreddit-test) "tests" "nnreddit")) 1034 | (praw-command (append (list python-executable "-m" python-module) 1035 | nnreddit--python-module-extra-args))) 1036 | (unless (featurep 'nnreddit-test) 1037 | (setq praw-command (append praw-command (list "--localhost" nnreddit-localhost))) 1038 | (when nnreddit-log-rpc 1039 | (setq nnreddit-rpc-log-filename 1040 | (concat (file-name-as-directory temporary-file-directory) 1041 | "nnreddit-rpc-log.")) 1042 | (setq praw-command (append praw-command 1043 | (list "--log" nnreddit-rpc-log-filename))))) 1044 | (setq proc (make-process :name server 1045 | :buffer (get-buffer-create (format " *%s*" server)) 1046 | :command praw-command 1047 | :connection-type 'pipe 1048 | :noquery t 1049 | :sentinel #'nnreddit-sentinel 1050 | :stderr (get-buffer-create (format " *%s-stderr*" server)))) 1051 | (with-current-buffer (get-buffer-create (format " *%s-stderr*" server)) 1052 | (add-hook 'after-change-functions 1053 | (apply-partially 'nnreddit--message-user server) 1054 | nil t))) 1055 | (push proc nnreddit-processes) 1056 | (when (nnreddit--cripple-fight-p) 1057 | (error "`nnreddit-rpc-get': Under EXWM, authentication must be out-of-band"))) 1058 | proc))) 1059 | 1060 | (defmacro nnreddit--with-mutex (mtx &rest body) 1061 | "If capable of threading, lock with MTX and execute BODY." 1062 | (declare (indent 1)) 1063 | (if (fboundp 'with-mutex) 1064 | `(with-mutex ,mtx ,@body) 1065 | `(progn ,@body))) 1066 | 1067 | (defvar nnreddit--mutex-rpc-request (when (fboundp 'make-mutex) 1068 | (make-mutex "nnreddit--mutex-rpc-request")) 1069 | "Only one jsonrpc output buffer, so avoid two requests using at the same time.") 1070 | 1071 | (defun nnreddit-rpc-request (connection kwargs method &rest args) 1072 | "Send to CONNECTION a request with generator KWARGS calling METHOD ARGS. 1073 | Library `json-rpc--request' assumes HTTP transport which jsonrpyc does not, 1074 | so we make our own." 1075 | (unless (hash-table-p kwargs) 1076 | (setq kwargs #s(hash-table))) 1077 | (let* ((id (cl-incf (json-rpc-id-counter connection))) 1078 | (request `(:method ,method 1079 | :id ,id 1080 | :params (:args ,(apply json-array-type args) :kwargs ,kwargs))) 1081 | (proc (json-rpc-process (json-rpc-ensure connection))) 1082 | (encoded (json-encode (append '(:jsonrpc "2.0") request))) 1083 | (json-object-type 'plist) 1084 | (json-key-type 'keyword) 1085 | (iteration-seconds 6)) 1086 | (nnreddit--with-mutex nnreddit--mutex-rpc-request 1087 | (with-current-buffer (process-buffer proc) 1088 | (erase-buffer) 1089 | (gnus-message 7 "nnreddit-rpc-request: send %s" encoded) 1090 | (process-send-string proc (concat encoded "\n")) 1091 | (cl-loop repeat (/ nnreddit-rpc-request-timeout iteration-seconds) 1092 | with result 1093 | until (or (not (json-rpc-live-p connection)) 1094 | (and (not (zerop (length (buffer-string)))) 1095 | (condition-case err 1096 | (setq result (json-read-from-string (buffer-string))) 1097 | (error 1098 | (let* ((resp (if (< (length (buffer-string)) 100) 1099 | (buffer-string) 1100 | (format "%s...%s" 1101 | (cl-subseq (buffer-string) 0 50) 1102 | (cl-subseq (buffer-string) -50))))) 1103 | (setq result 1104 | `(:error ,(format "%s on %s" 1105 | (error-message-string err) 1106 | resp)))) 1107 | nil)))) 1108 | do (when (fboundp 'set-process-thread) 1109 | (set-process-thread proc nil)) 1110 | do (accept-process-output proc iteration-seconds 0) 1111 | finally return 1112 | (cond ((null result) 1113 | (error "`nnreddit-rpc-request': response timed out")) 1114 | ((plist-get result :error) 1115 | (error "`nnreddit-rpc-request': %s" (plist-get result :error))) 1116 | (t 1117 | (gnus-message 7 "`nnreddit-rpc-request': recv ...%s" 1118 | (cl-subseq (buffer-string) 1119 | (- (min (length (buffer-string)) 50)))) 1120 | (plist-get result :result)))))))) 1121 | 1122 | (defsubst nnreddit--extract-name (from) 1123 | "String match on something looking like t1_es076hd in FROM." 1124 | (and (stringp from) (string-match "\\(t[0-9]+_[a-z0-9]+\\)" from) (match-string 1 from))) 1125 | 1126 | ;; C-c C-c from followup buffer 1127 | ;; message-send-and-exit 1128 | ;; message-send 1129 | ;; message-send-method-alist=message-send-news-function=message-send-news 1130 | ;; gnus-request-post 1131 | ;; nnreddit-request-post 1132 | (deffoo nnreddit-request-post (&optional server) 1133 | (nnreddit--normalize-server) 1134 | (let* ((ret t) 1135 | (kwargs (make-hash-table)) 1136 | (title (or (message-fetch-field "Subject") 1137 | (error "`nnreddit-request-post': no subject field"))) 1138 | (link (message-fetch-field "Link")) 1139 | (reply-p (not (null message-reply-headers))) 1140 | (edit-name (nnreddit--extract-name (message-fetch-field "Supersedes"))) 1141 | (cancel-name (nnreddit--extract-name (message-fetch-field "Control"))) 1142 | (root-p (message-fetch-field "Reply-Root")) 1143 | (article-number (nnreddit--current-article-number)) 1144 | (group (if (numberp article-number) 1145 | (gnus-group-real-name (nnreddit--current-group)) 1146 | (or (message-fetch-field "Newsgroups") 1147 | (error "`nnreddit-request-post': no newsgroups field")))) 1148 | (header (when (numberp article-number) 1149 | (nnreddit--get-header article-number group))) 1150 | (body 1151 | (save-excursion 1152 | (save-restriction 1153 | (message-goto-body) 1154 | (narrow-to-region (point) (point-max)) 1155 | (buffer-string))))) 1156 | (cond (cancel-name (nnreddit-rpc-call server nil "remove" cancel-name)) 1157 | (edit-name (nnreddit-rpc-call server nil "edit" edit-name body)) 1158 | (reply-p (if (and header (plist-get header :name)) 1159 | (nnreddit-rpc-call server nil "reply" 1160 | (plist-get header :name) 1161 | body (stringp root-p)) 1162 | (backtrace) 1163 | (error "`nnreddit-request-post': no current article, header=%s name=%s" 1164 | header 1165 | (when header (plist-get header :name))))) 1166 | (link (let* ((parsed-url (url-generic-parse-url link)) 1167 | (host (url-host parsed-url))) 1168 | (if (and (stringp host) (not (zerop (length host)))) 1169 | (progn 1170 | (puthash 'url link kwargs) 1171 | (nnreddit-rpc-call server kwargs "submit" group title)) 1172 | ;; gnus-error might be better here 1173 | (error "`nnreddit-request-post': invalid url \"%s\"" link) 1174 | (setq ret nil)))) 1175 | (t (puthash 'selftext body kwargs) 1176 | (nnreddit-rpc-call server kwargs "submit" group title))) 1177 | ret)) 1178 | 1179 | (defun nnreddit--browse-root (&rest _args) 1180 | "What happens when I click on Subject." 1181 | (-when-let* ((article-number (nnreddit--current-article-number)) 1182 | (group (gnus-group-real-name (nnreddit--current-group))) 1183 | (header (nnreddit--get-header article-number group)) 1184 | (permalink (plist-get header :permalink))) 1185 | (cl-loop for name in (nnreddit-refs-for (plist-get header :name)) 1186 | for header1 = (nnreddit-find-header 1187 | group (nnreddit-hack-name-to-id name)) 1188 | for permalink1 = (plist-get header1 :permalink) 1189 | until permalink1 1190 | finally (browse-url (format "https://www.reddit.com%s" 1191 | (or permalink1 permalink "")))))) 1192 | 1193 | (defun nnreddit--header-button-alist () 1194 | "Construct a buffer-local `gnus-header-button-alist' for nnreddit." 1195 | (let* ((result (copy-alist gnus-header-button-alist)) 1196 | (references-value (assoc-default "References" result 1197 | (lambda (x y) (string-match-p y x)))) 1198 | (references-key (car (rassq references-value result)))) 1199 | (setq result (cl-delete "^Subject:" result :test (lambda (x y) (cl-search x (car y))))) 1200 | (setq result (cl-delete references-key result :test (lambda (x y) (cl-search x (car y))))) 1201 | (push (append '("^\\(Message-I[Dd]\\|^In-Reply-To\\):") references-value) result) 1202 | (push '("^Subject:" ".+" 0 (>= gnus-button-browse-level 0) 1203 | nnreddit--browse-root 0) 1204 | result) 1205 | result)) 1206 | 1207 | (defun nnreddit-sort-by-number-of-articles-in-thread (t1 t2) 1208 | "Whichever of the T1 or T2 has the most articles." 1209 | (> (gnus-summary-number-of-articles-in-thread t1) 1210 | (gnus-summary-number-of-articles-in-thread t2))) 1211 | 1212 | (defun nnreddit-gather-threads-by-references (threads) 1213 | "Gather THREADS by root reference, and don't be incomprehensible or buggy. 1214 | The built-in `gnus-gather-threads-by-references' is both." 1215 | (cl-flet ((special-case 1216 | (thread) 1217 | (let ((header (cl-first thread))) 1218 | (if (stringp header) 1219 | thread 1220 | (list (mail-header-subject header) thread)))) 1221 | (has-refs 1222 | (thread) 1223 | (let ((header (cl-first thread))) 1224 | (gnus-split-references (mail-header-references header))))) 1225 | (let ((threads-by-ref (gnus-make-hashtable)) 1226 | (separated (-separate #'has-refs threads)) 1227 | result) 1228 | (dolist (thread (cl-second separated)) 1229 | (let* ((header (cl-first thread)) 1230 | (id (mail-header-id header)) 1231 | (thread-special (special-case thread))) 1232 | (push thread-special result) 1233 | (nnreddit--sethash id thread-special threads-by-ref))) 1234 | (dolist (thread (cl-first separated)) 1235 | (let* ((header (cl-first thread)) 1236 | (refs (gnus-split-references (mail-header-references header))) 1237 | (ref-thread (cl-some (lambda (ref) 1238 | (nnreddit--gethash ref threads-by-ref)) 1239 | refs))) 1240 | (if ref-thread 1241 | (setcdr ref-thread (nconc (cdr ref-thread) (list thread))) 1242 | (setq ref-thread (special-case thread)) 1243 | (push ref-thread result) 1244 | (nnreddit--sethash (car refs) ref-thread threads-by-ref)))) 1245 | (nreverse result)))) 1246 | 1247 | (defun nnreddit--fallback-link () 1248 | "Cannot render submission." 1249 | (when-let ((current-group (nnreddit--current-group)) 1250 | (current-article (nnreddit--current-article-number))) 1251 | (let* ((group (gnus-group-real-name current-group)) 1252 | (header (nnreddit--get-header current-article group)) 1253 | (body (awhen (plist-get header :name) (nnreddit--get-body it group)))) 1254 | (with-current-buffer gnus-original-article-buffer 1255 | (article-goto-body) 1256 | (delete-region (point) (point-max)) 1257 | (when body 1258 | (insert (nnreddit--br-tagify body))))))) 1259 | 1260 | (defalias 'nnreddit--display-article 1261 | (lambda (article &optional all-headers _header) 1262 | (condition-case err 1263 | (gnus-article-prepare article all-headers) 1264 | (error 1265 | (if nnreddit-render-submission 1266 | (progn 1267 | (gnus-message 7 "nnreddit--display-article: '%s' (falling back...)" 1268 | (error-message-string err)) 1269 | (nnreddit--fallback-link) 1270 | (gnus-article-prepare article all-headers)) 1271 | (error (error-message-string err)))))) 1272 | "In case of shr failures, dump original link.") 1273 | 1274 | (defsubst nnreddit--dense-time (time*) 1275 | "Convert TIME to a floating point number. 1276 | Written by John Wiegley (https://github.com/jwiegley/dot-emacs)." 1277 | (let ((time (if (fboundp 'time-convert) 1278 | (funcall #'time-convert time* 'list) 1279 | (identity time*)))) 1280 | (+ (* (car time) 65536.0) 1281 | (cadr time) 1282 | (/ (or (car (cdr (cdr time))) 0) 1000000.0)))) 1283 | 1284 | (defalias 'nnreddit--format-time-elapsed 1285 | (lambda (header) 1286 | (condition-case nil 1287 | (let ((date (mail-header-date header))) 1288 | (if (> (length date) 0) 1289 | (let* 1290 | ((then (nnreddit--dense-time 1291 | (apply #'encode-time (parse-time-string date)))) 1292 | (now (nnreddit--dense-time (current-time))) 1293 | (diff (- now then)) 1294 | (str 1295 | (cond 1296 | ((>= diff (* 86400.0 7.0 52.0)) 1297 | (if (>= diff (* 86400.0 7.0 52.0 10.0)) 1298 | (format "%3dY" (floor (/ diff (* 86400.0 7.0 52.0)))) 1299 | (format "%3.1fY" (/ diff (* 86400.0 7.0 52.0))))) 1300 | ((>= diff (* 86400.0 30.0)) 1301 | (if (>= diff (* 86400.0 30.0 10.0)) 1302 | (format "%3dM" (floor (/ diff (* 86400.0 30.0)))) 1303 | (format "%3.1fM" (/ diff (* 86400.0 30.0))))) 1304 | ((>= diff (* 86400.0 7.0)) 1305 | (if (>= diff (* 86400.0 7.0 10.0)) 1306 | (format "%3dw" (floor (/ diff (* 86400.0 7.0)))) 1307 | (format "%3.1fw" (/ diff (* 86400.0 7.0))))) 1308 | ((>= diff 86400.0) 1309 | (if (>= diff (* 86400.0 10.0)) 1310 | (format "%3dd" (floor (/ diff 86400.0))) 1311 | (format "%3.1fd" (/ diff 86400.0)))) 1312 | ((>= diff 3600.0) 1313 | (if (>= diff (* 3600.0 10.0)) 1314 | (format "%3dh" (floor (/ diff 3600.0))) 1315 | (format "%3.1fh" (/ diff 3600.0)))) 1316 | ((>= diff 60.0) 1317 | (if (>= diff (* 60.0 10.0)) 1318 | (format "%3dm" (floor (/ diff 60.0))) 1319 | (format "%3.1fm" (/ diff 60.0)))) 1320 | (t 1321 | (format "%3ds" (floor diff))))) 1322 | (stripped 1323 | (replace-regexp-in-string "\\.0" "" str))) 1324 | (concat (cond 1325 | ((= 2 (length stripped)) " ") 1326 | ((= 3 (length stripped)) " ") 1327 | (t "")) 1328 | stripped)))) 1329 | ;; print some spaces and pretend nothing happened. 1330 | (error " "))) 1331 | "Return time elapsed since HEADER was sent. 1332 | 1333 | Written by John Wiegley (https://github.com/jwiegley/dot-emacs).") 1334 | 1335 | ;; Evade package-lint! 1336 | (fset 'gnus-user-format-function-S 1337 | (symbol-function 'nnreddit--format-time-elapsed)) 1338 | 1339 | (add-to-list 1340 | 'gnus-parameters 1341 | `("^nnreddit" 1342 | (gnus-refer-article-method 'current) 1343 | (gnus-summary-make-false-root 'adopt) 1344 | (gnus-cite-hide-absolute 5) 1345 | (gnus-cite-hide-percentage 0) 1346 | (gnus-cited-lines-visible '(2 . 2)) 1347 | (gnus-auto-extend-newsgroup nil) 1348 | (gnus-add-timestamp-to-message t) 1349 | (gnus-summary-line-format "%3t%U%R%uS %I%(%*%-10,10f %s%)\n") 1350 | (gnus-thread-sort-functions (quote (nnreddit-sort-by-number-of-articles-in-thread))) 1351 | (gnus-subthread-sort-functions (quote (gnus-thread-sort-by-number))) 1352 | (gnus-summary-display-article-function 1353 | (quote ,(symbol-function 'nnreddit--display-article))) 1354 | (gnus-header-button-alist 1355 | (quote ,(nnreddit--header-button-alist))) 1356 | (gnus-visible-headers ,(concat gnus-visible-headers "\\|^Score:")))) 1357 | 1358 | (nnoo-define-skeleton nnreddit) 1359 | 1360 | (defun nnreddit-article-mode-activate () 1361 | "Augment the `gnus-article-mode-map' conditionally." 1362 | (when (nnreddit--gate) 1363 | (nnreddit-article-mode))) 1364 | 1365 | (defun nnreddit-summary-mode-activate () 1366 | "Shadow some bindings in `gnus-summary-mode-map' conditionally." 1367 | (when (nnreddit--gate) 1368 | (nnreddit-summary-mode))) 1369 | 1370 | (defun nnreddit-group-mode-activate () 1371 | "Augment the variable `gnus-group-mode-map' unconditionally." 1372 | (if gnus-group-change-level-function 1373 | (add-function :after gnus-group-change-level-function 1374 | #'nnreddit-update-subscription) 1375 | (setq gnus-group-change-level-function #'nnreddit-update-subscription))) 1376 | 1377 | (defun nnreddit--who-am-i () 1378 | "User@reddit.com for matching in `message-is-yours-p'." 1379 | (concat (if (and noninteractive (not nnreddit--whoami)) 1380 | "nnreddit-user" 1381 | nnreddit--whoami) 1382 | "@reddit.com")) 1383 | 1384 | (defun nnreddit--fix-from () 1385 | "Must fix the From header, always." 1386 | (when (nnreddit--message-gate) 1387 | (save-excursion 1388 | (message-replace-header 1389 | "From" 1390 | (nnreddit--who-am-i))))) 1391 | 1392 | ;; I believe I did try buffer-localizing hooks, and it wasn't sufficient 1393 | (add-hook 'gnus-article-mode-hook 'nnreddit-article-mode-activate) 1394 | (add-hook 'gnus-group-mode-hook 'nnreddit-group-mode-activate) 1395 | (add-hook 'gnus-summary-mode-hook 'nnreddit-summary-mode-activate) 1396 | (add-hook 'gnus-message-setup-hook #'nnreddit--fix-from) 1397 | 1398 | ;; `gnus-newsgroup-p' requires valid method post-mail to return t 1399 | (add-to-list 'gnus-valid-select-methods '("nnreddit" post-mail) t) 1400 | 1401 | ;; Add prompting for replying to thread root to gnus-summary-followup. 1402 | ;; The interactive spec of gnus-summary-followup is putatively preserved. 1403 | (let* ((prompt-loose 1404 | (lambda (f &rest args) 1405 | (cond ((nnreddit--gate) 1406 | (nnreddit--with-group nil 1407 | (when (string= group (nnreddit--inbox-realname)) 1408 | (error "Followup from inbox not implemented"))) 1409 | (or (-when-let* 1410 | ((article-number (gnus-summary-article-number)) 1411 | (header (nnreddit--get-header article-number)) 1412 | (root-name (car (nnreddit-refs-for (plist-get header :name)))) 1413 | (rootless (or (not (stringp root-name)) 1414 | (not (string-prefix-p "t3_" root-name)) 1415 | (not (nnreddit-find-header 1416 | (gnus-group-real-name gnus-newsgroup-name) 1417 | (nnreddit-hack-name-to-id root-name))))) 1418 | (reply-root (read-char-choice 1419 | "Reply loose thread [m]essage or [r]oot: " '(?m ?r))) 1420 | (q-root (eq reply-root ?r))) 1421 | (let* ((link-header (apply-partially #'message-add-header 1422 | "Reply-Root: yes")) 1423 | (add-link-header (apply-partially #'add-hook 1424 | 'message-header-setup-hook 1425 | link-header)) 1426 | (remove-link-header (apply-partially #'remove-hook 1427 | 'message-header-setup-hook 1428 | link-header))) 1429 | (funcall add-link-header) 1430 | (condition-case err 1431 | (progn 1432 | (apply f args) 1433 | (funcall remove-link-header)) 1434 | (error (funcall remove-link-header) 1435 | (error (error-message-string err))))) 1436 | t) 1437 | (apply f args))) 1438 | (t (apply f args))))) 1439 | (advise-gnus-summary-followup 1440 | (lambda () 1441 | (add-function :around (symbol-function 'gnus-summary-followup) prompt-loose))) 1442 | (suspend-prompt-loose 1443 | (lambda (f &rest args) 1444 | (cond ((nnreddit--gate) 1445 | (remove-function (symbol-function 'gnus-summary-followup) prompt-loose) 1446 | (unwind-protect 1447 | (apply f args) 1448 | (funcall advise-gnus-summary-followup))) 1449 | (t (apply f args))))) 1450 | (advise-gnus-summary-cancel-article 1451 | (lambda () 1452 | (add-function :around (symbol-function 'gnus-summary-cancel-article) 1453 | suspend-prompt-loose)))) 1454 | (funcall advise-gnus-summary-cancel-article) 1455 | (funcall advise-gnus-summary-followup)) 1456 | 1457 | (add-function 1458 | :around (symbol-function 'message-supersede) 1459 | (lambda (f &rest args) 1460 | (cond ((nnreddit--message-gate) 1461 | (add-function :override 1462 | (symbol-function 'mml-insert-mml-markup) 1463 | 'ignore) 1464 | (unwind-protect 1465 | (prog1 (apply f args) 1466 | (remove-function (symbol-function 'mml-insert-mml-markup) 'ignore) 1467 | (save-excursion 1468 | (save-restriction 1469 | (nnreddit--fix-from) 1470 | (message-goto-body) 1471 | (narrow-to-region (point) (point-max)) 1472 | (goto-char (point-max)) 1473 | (mm-inline-text-html nil) 1474 | (delete-region (point-min) (point))))) 1475 | (remove-function (symbol-function 'mml-insert-mml-markup) 'ignore))) 1476 | (t (apply f args))))) 1477 | 1478 | (add-function 1479 | :around (symbol-function 'message-send-news) 1480 | (lambda (f &rest args) 1481 | (cond ((nnreddit--message-gate) 1482 | (let* ((dont-ask (lambda (prompt) 1483 | (when (cl-search "mpty article" prompt) t))) 1484 | (link-p (message-fetch-field "Link")) 1485 | (message-shoot-gnksa-feet (if link-p t message-shoot-gnksa-feet)) 1486 | (message-inhibit-body-encoding t)) 1487 | (unwind-protect 1488 | (progn 1489 | (when link-p 1490 | (add-function :before-until (symbol-function 'y-or-n-p) dont-ask)) 1491 | (apply f args)) 1492 | (remove-function (symbol-function 'y-or-n-p) dont-ask)))) 1493 | (t (apply f args))))) 1494 | 1495 | (add-function 1496 | :around (symbol-function 'gnus-summary-post-news) 1497 | (lambda (f &rest args) 1498 | (cond ((nnreddit--gate) 1499 | (let* ((nnreddit-post-type (read-char-choice "[l]ink / [t]ext: " '(?l ?t))) 1500 | (link-header (apply-partially #'message-add-header "Link: https://")) 1501 | (add-link-header (apply-partially #'add-hook 1502 | 'message-header-setup-hook 1503 | link-header)) 1504 | (remove-link-header (apply-partially #'remove-hook 1505 | 'message-header-setup-hook 1506 | link-header))) 1507 | (cl-case nnreddit-post-type 1508 | (?l (funcall add-link-header))) 1509 | (unwind-protect 1510 | (prog1 (apply f args)) 1511 | (funcall remove-link-header)))) 1512 | (t (apply f args))))) 1513 | 1514 | (add-function 1515 | :filter-return (symbol-function 'message-make-fqdn) 1516 | (lambda (val) 1517 | (if (and (nnreddit--message-gate) 1518 | (cl-search "--so-tickle-me" val)) 1519 | "reddit.com" 1520 | val))) 1521 | 1522 | (add-function 1523 | :around (symbol-function 'message-is-yours-p) 1524 | (lambda (f &rest args) 1525 | (let ((concat-func (lambda (f &rest args) 1526 | (let ((fetched (apply f args))) 1527 | (if (string= (car args) "from") 1528 | (concat fetched "@reddit.com") 1529 | fetched))))) 1530 | (when (nnreddit--message-gate) 1531 | (add-function :around 1532 | (symbol-function 'message-fetch-field) 1533 | concat-func) 1534 | (add-function :override 1535 | (symbol-function 'message-make-from) 1536 | #'nnreddit--who-am-i)) 1537 | (unwind-protect 1538 | (apply f args) 1539 | (remove-function (symbol-function 'message-fetch-field) concat-func) 1540 | (remove-function (symbol-function 'message-make-from) #'nnreddit--who-am-i))))) 1541 | 1542 | (add-function 1543 | :around (symbol-function 'url-http-generic-filter) 1544 | (lambda (f &rest args) 1545 | (cond ((nnreddit--gate) 1546 | (condition-case err 1547 | (apply f args) 1548 | (error (gnus-message 7 "url-http-generic-filter: %s" 1549 | (error-message-string err))))) 1550 | (t (apply f args))))) 1551 | 1552 | ;; the let'ing to nil of `gnus-summary-display-article-function' 1553 | ;; in `gnus-summary-select-article' dates back to antiquity. 1554 | (add-function 1555 | :around (symbol-function 'gnus-summary-display-article) 1556 | (lambda (f &rest args) 1557 | (cond ((nnreddit--gate) 1558 | (let ((gnus-summary-display-article-function 1559 | (symbol-function 'nnreddit--display-article))) 1560 | (apply f args))) 1561 | (t (apply f args))))) 1562 | 1563 | ;; Lars rejected my change for vectorizing `gnus-group-change-level-functions' 1564 | (add-function 1565 | :after (symbol-function 'gnus-topic-change-level) 1566 | (lambda (&rest args) 1567 | ;; nnreddit-update-subscription calls nnreddit--gate 1568 | (apply #'nnreddit-update-subscription args))) 1569 | 1570 | ;; disallow caching as the article numbering is wont to change 1571 | ;; after PRAW restarts! 1572 | (setq gnus-uncacheable-groups 1573 | (aif gnus-uncacheable-groups 1574 | (format "\\(%s\\)\\|\\(^nnreddit\\)" it) 1575 | "^nnreddit")) 1576 | 1577 | (provide 'nnreddit) 1578 | 1579 | ;;; nnreddit.el ends here 1580 | -------------------------------------------------------------------------------- /nnreddit/VERSION: -------------------------------------------------------------------------------- 1 | 0.2.1 2 | -------------------------------------------------------------------------------- /nnreddit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dickmao/nnreddit/c3ce69a8864d8333a390dcb20f78de3ba2c4111f/nnreddit/__init__.py -------------------------------------------------------------------------------- /nnreddit/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Kicks things off via 3 | 4 | python -m nnreddit 5 | 6 | """ 7 | 8 | import os 9 | import sys 10 | import argparse 11 | import jsonrpyc 12 | 13 | from .authenticated_reddit import AuthenticatedReddit 14 | 15 | parser = argparse.ArgumentParser() 16 | parser.add_argument("--log", help="log filename") 17 | parser.add_argument("--localhost", help="ip or hostname of localhost", default='127.0.0.1') 18 | args = parser.parse_args() 19 | 20 | stdin = sys.stdin 21 | stdout = sys.stdout 22 | if __name__ != "__main__": 23 | sys.stdout = sys.stderr = open(os.devnull, mode="w", encoding="utf-8") 24 | 25 | jsonrpyc.RPC(target=AuthenticatedReddit(check_for_updates=False, 26 | log_prefix=args.log, 27 | localhost=args.localhost), 28 | stdin=stdin, stdout=stdout) 29 | -------------------------------------------------------------------------------- /nnreddit/authenticated_reddit.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=wrong-import-position 3 | 4 | from __future__ import unicode_literals 5 | from __future__ import print_function 6 | 7 | import os 8 | import re 9 | import random 10 | import webbrowser 11 | import json 12 | import functools 13 | import sys 14 | import logging 15 | from time import time 16 | import datetime 17 | from multiprocessing import Process 18 | import rtv.config 19 | 20 | rtv.config.TEMPLATES = os.path.join(os.path.dirname(__file__), 'templates') 21 | rtv.config.DEFAULT_CONFIG = os.path.join(rtv.config.TEMPLATES, 'rtv.cfg') 22 | rtv.config.DEFAULT_MAILCAP = os.path.join(rtv.config.TEMPLATES, 'mailcap') 23 | rtv.config.HISTORY = os.path.join(rtv.config.XDG_DATA_HOME, 'nnreddit', 'history.log') 24 | rtv.config.TOKEN = os.path.join(rtv.config.XDG_DATA_HOME, 'nnreddit', 'refresh-token') 25 | 26 | import praw 27 | from praw import Reddit 28 | from prawcore.sessions import session 29 | from prawcore import Authorizer 30 | from rtv.oauth import OAuthHTTPServer, OAuthHandler 31 | from rtv.exceptions import BrowserError 32 | from rtv import docs 33 | 34 | testing = (sys.modules['__main__'].__package__ == 'tests') 35 | 36 | if testing: 37 | from tests.recorded import recording_begin 38 | from tests.recorded import recording_end 39 | else: 40 | def recording_begin(*_args): 41 | pass 42 | def recording_end(*_args): 43 | pass 44 | def recorded(func): 45 | """Intercept point for Betamax""" 46 | @functools.wraps(func) 47 | def wrapper(*args, **kwargs): 48 | return func(*args, **kwargs) 49 | return wrapper 50 | 51 | with open(os.path.join(os.path.dirname(__file__), 'VERSION'), encoding="utf-8") as version_file: 52 | version = version_file.read().strip() 53 | 54 | class AuthenticatedReddit(Reddit): 55 | @staticmethod 56 | def open_url_silent(url): 57 | stdout, stderr = os.dup(1), os.dup(2) 58 | null = os.open(os.devnull, os.O_RDWR) 59 | try: 60 | os.dup2(null, 1) 61 | os.dup2(null, 2) 62 | webbrowser.open_new_tab(url) 63 | finally: 64 | try: 65 | os.close(null) 66 | except OSError: 67 | pass 68 | os.dup2(stdout, 1) 69 | os.dup2(stderr, 2) 70 | 71 | def __init__(self, **kwargs): 72 | log_level = kwargs.pop('log_level', logging.NOTSET) 73 | logging.basicConfig(level=log_level, 74 | filename='/dev/null', 75 | format='%(asctime)s %(levelname)s %(message)s', 76 | datefmt="%Y-%m-%d %H:%M:%S") 77 | log_prefix = kwargs.pop('log_prefix', None) 78 | if log_prefix: 79 | stamp = datetime.datetime.fromtimestamp(time()).strftime('%Y%m%d.%H%M%S') 80 | logging.getLogger().addHandler(logging.FileHandler(log_prefix + stamp)) 81 | 82 | localhost = kwargs.pop('localhost', '127.0.0.1') 83 | default_kwargs = { 84 | 'history_file': rtv.config.HISTORY, 85 | 'token_file': rtv.config.TOKEN, 86 | 'redirect_uri': 'http://' + localhost + ':17973', 87 | 'client_id': '5oagOpX2_NKVDej_iuZjFA', 88 | 'client_secret': '', 89 | 'redirect_port': 17973, 90 | 'user_agent': praw.const.USER_AGENT_FORMAT.\ 91 | format(':'.join([os.uname()[0], 'nnreddit', version])), 92 | } 93 | default_kwargs = { k: v for k,v in default_kwargs.items() if k not in kwargs } 94 | kwargs.update(default_kwargs) 95 | cfg = rtv.config.Config(**kwargs) 96 | cfg.load_history() 97 | cfg.load_refresh_token() 98 | cfg.config['refresh_token'] = cfg.refresh_token 99 | logging.getLogger().debug("Refresh token: %s", cfg.token_file) 100 | 101 | super().__init__(**cfg.config) 102 | 103 | if not cfg.refresh_token: 104 | self._core \ 105 | = self._authorized_core \ 106 | = session(Authorizer(self._core._authorizer._authenticator)) 107 | state = str(random.randint(0, 65000)) 108 | url = self._authorized_core._authorizer._authenticator.\ 109 | authorize_url('permanent', ['edit', 110 | 'history', 111 | 'identity', 112 | 'mysubreddits', 113 | 'privatemessages', 114 | 'read', 115 | 'report', 116 | 'save', 117 | 'submit', 118 | 'subscribe', 119 | 'vote'], state) 120 | 121 | docs_sub = re.compile(r'reddit terminal viewer', re.IGNORECASE) 122 | docs.OAUTH_SUCCESS = docs_sub.sub('nnreddit', docs.OAUTH_SUCCESS) 123 | docs.OAUTH_ACCESS_DENIED = docs_sub.sub('nnreddit', docs.OAUTH_ACCESS_DENIED) 124 | print("::user::Please check your browser.", file=sys.stderr) 125 | if cfg.token_file == "/dev/null": 126 | cfg.refresh_token = None 127 | else: 128 | proc = Process(target=self.open_url_silent, args=(url,)) 129 | proc.start() 130 | try: 131 | proc.join(7) 132 | if proc.is_alive(): 133 | raise BrowserError( 134 | 'Timeout waiting for browser to open') 135 | finally: 136 | try: 137 | proc.terminate() 138 | except OSError: 139 | pass 140 | server = OAuthHTTPServer(('', cfg.config['redirect_port']), OAuthHandler) 141 | server.serve_forever() 142 | self._authorized_core._authorizer.authorize(OAuthHandler.params['code']) 143 | cfg.refresh_token = self._authorized_core._authorizer.refresh_token 144 | cfg.save_refresh_token() 145 | if 'history_size' in cfg.config: 146 | cfg.save_history() 147 | 148 | self._bodies = {} 149 | self._stream_comm = {} 150 | self._stream_subm = {} 151 | self._stream_inbox = None 152 | 153 | 154 | @staticmethod 155 | def make_dict(reddit_base): 156 | with_nulls = json.dumps(vars(reddit_base), skipkeys=True, default=lambda o: 157 | str(o) if hasattr(o, 'STR_FIELD') else None) 158 | without_nulls = {k:v for k,v in json.loads(with_nulls).items() 159 | if v is not None} 160 | return without_nulls 161 | 162 | @staticmethod 163 | def collect_dicts(stream): 164 | result = [] 165 | for i in stream: 166 | if i is None: 167 | break 168 | result.append(AuthenticatedReddit.make_dict(i)) 169 | return result 170 | 171 | def recording_begin(self, cassette): 172 | recording_begin(self, cassette) 173 | return True 174 | 175 | def recording_end(self, cassette=None): 176 | recording_end(cassette) 177 | return True 178 | 179 | def random_subreddit(self, *, nsfw: bool=False): 180 | above = super().random_subreddit(nsfw=nsfw) 181 | return above.display_name 182 | 183 | def search(self, query, **generator_kwargs): 184 | return [ x.display_name for x in self.subreddits.search(query, **generator_kwargs) ] 185 | 186 | def popular(self, **generator_kwargs): 187 | return [ x.display_name for x in self.subreddits.popular(**generator_kwargs) ] 188 | 189 | def subscribe(self, display_name): 190 | self.subreddit(display_name).subscribe() 191 | 192 | def unsubscribe(self, display_name): 193 | self.subreddit(display_name).unsubscribe() 194 | 195 | def submit(self, display_name, title, **kwargs): 196 | self.subreddit(display_name).submit(title, **kwargs) 197 | 198 | def reply(self, name, body, q_reply_root): 199 | (mytype, myid) = name.split("_", 1) 200 | parent = None 201 | if mytype == self.config.kinds['submission']: 202 | parent = self.submission(myid) 203 | elif mytype == self.config.kinds['comment']: 204 | parent = self.comment(myid) 205 | if q_reply_root and parent.link_id: 206 | (_, root_id) = parent.link_id.split("_", 1) 207 | parent = self.submission(root_id) 208 | else: 209 | raise ValueError('Unexpected name {} with type {}'.format(name, mytype)) 210 | parent.reply(body) 211 | 212 | def edit(self, name, body): 213 | (mytype, myid) = name.split("_", 1) 214 | editable = None 215 | if mytype == self.config.kinds['submission']: 216 | editable = self.submission(myid) 217 | elif mytype == self.config.kinds['comment']: 218 | editable = self.comment(myid) 219 | else: 220 | raise ValueError('Unexpected name {} with type {}'.format(name, mytype)) 221 | editable.edit(body) 222 | 223 | def remove(self, name): 224 | (mytype, myid) = name.split("_", 1) 225 | editable = None 226 | if mytype == self.config.kinds['submission']: 227 | editable = self.submission(myid) 228 | elif mytype == self.config.kinds['comment']: 229 | editable = self.comment(myid) 230 | else: 231 | raise ValueError('Unexpected name {} with type {}'.format(name, mytype)) 232 | editable.delete() 233 | 234 | def comments(self, display_name): 235 | if display_name not in self._stream_comm: 236 | self._stream_comm[display_name] = self.subreddit(display_name).\ 237 | stream.comments(pause_after=0) 238 | if display_name not in self._bodies: 239 | self._bodies[display_name] = {} 240 | dicts = self.collect_dicts(self._stream_comm.get(display_name)) 241 | for dic in dicts: 242 | if 'body_html' in dic: 243 | self._bodies[display_name][dic['id']] = dic['body_html'] 244 | else: 245 | self._bodies[display_name][dic['id']] = 'Wow, such empty' 246 | for k in list(dic): 247 | if k.startswith('body'): 248 | del dic[k] 249 | return dicts 250 | 251 | def vote(self, name, vote): 252 | (mytype, myid) = name.split("_", 1) 253 | votable = None 254 | if mytype == self.config.kinds['submission']: 255 | votable = self.submission(myid) 256 | elif mytype == self.config.kinds['comment']: 257 | votable = self.comment(myid) 258 | else: 259 | raise ValueError('Unexpected name {} with type {}'.format(name, mytype)) 260 | try: 261 | if vote == 0: 262 | votable.clear_vote() 263 | elif vote < 0: 264 | votable.downvote() 265 | else: 266 | votable.upvote() 267 | except AttributeError as exc: 268 | raise AttributeError('{} un-votable: {}'.format(name, str(exc))) from exc 269 | 270 | def body(self, display_name, name): 271 | (mytype, myid) = name.split("_", 1) 272 | 273 | result = None 274 | cached = self._bodies.get(display_name) 275 | if cached: 276 | result = cached.get(myid) 277 | if not result: 278 | if mytype == self.config.kinds['submission']: 279 | result = self.submission(myid).selftext_html 280 | else: 281 | result = self.comment(myid).body_html 282 | return result 283 | 284 | def canonical_spelling(self, display_name): 285 | lazy = self.subreddit(display_name) 286 | # pragma pylint: disable=protected-access 287 | lazy._fetch() 288 | # pragma pylint: enable=protected-access 289 | return lazy.display_name 290 | 291 | def submissions(self, display_name): 292 | if display_name not in self._stream_subm: 293 | self._stream_subm[display_name] = self.subreddit(display_name).\ 294 | stream.submissions(pause_after=0) 295 | if display_name not in self._bodies: 296 | self._bodies[display_name] = {} 297 | dicts = self.collect_dicts(self._stream_subm.get(display_name)) 298 | for dic in dicts: 299 | if 'selftext_html' in dic: 300 | self._bodies[display_name][dic['id']] = dic['selftext_html'] 301 | elif 'url' in dic: 302 | self._bodies[display_name][dic['id']] \ 303 | = ''.join(['
', '

', 304 | '{0}'.format(dic.get('url')), 305 | '

']) 306 | else: 307 | self._bodies[display_name][dic['id']] = 'Wow, such empty' 308 | for k in list(dic): 309 | if k.startswith('selftext'): 310 | del dic[k] 311 | return dicts 312 | 313 | def user_subreddits(self): 314 | return [sr.display_name for sr in self.user.subreddits()] 315 | 316 | def user_attr(self, attr): 317 | return getattr(self.user.me(), attr) 318 | 319 | def inboxes(self, inbox_name): 320 | if not self._stream_inbox: 321 | self._stream_inbox = self.inbox.stream(pause_after=0) 322 | if inbox_name not in self._bodies: 323 | self._bodies[inbox_name] = {} 324 | dicts = self.collect_dicts(self._stream_inbox) 325 | dicts = [d for d in dicts if d.get('type') == 'comment_reply' or 326 | d.get('type') == 'post_reply'] 327 | for dic in dicts: 328 | if 'body_html' in dic: 329 | self._bodies[inbox_name][dic['id']] = dic['body_html'] 330 | else: 331 | self._bodies[inbox_name][dic['id']] = 'Wow, such empty' 332 | for k in list(dic): 333 | if k.startswith('body'): 334 | del dic[k] 335 | return dicts 336 | -------------------------------------------------------------------------------- /nnreddit/pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Analyse import fallback blocks. This can be used to support both Python 2 and 4 | # 3 compatible code, which means that the block might have code that exists 5 | # only in one or another interpreter, leading to false positives when analysed. 6 | analyse-fallback-blocks=no 7 | 8 | # Load and enable all available extensions. Use --list-extensions to see a list 9 | # all available extensions. 10 | #enable-all-extensions= 11 | 12 | # In error mode, messages with a category besides ERROR or FATAL are 13 | # suppressed, and no reports are done by default. Error mode is compatible with 14 | # disabling specific errors. 15 | #errors-only= 16 | 17 | # Always return a 0 (non-error) status code, even if lint errors are found. 18 | # This is primarily useful in continuous integration scripts. 19 | #exit-zero= 20 | 21 | # A comma-separated list of package or module names from where C extensions may 22 | # be loaded. Extensions are loading into the active Python interpreter and may 23 | # run arbitrary code. 24 | extension-pkg-allow-list= 25 | 26 | # A comma-separated list of package or module names from where C extensions may 27 | # be loaded. Extensions are loading into the active Python interpreter and may 28 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 29 | # for backward compatibility.) 30 | extension-pkg-whitelist= 31 | 32 | # Return non-zero exit code if any of these messages/categories are detected, 33 | # even if score is above --fail-under value. Syntax same as enable. Messages 34 | # specified are enabled, while categories only check already-enabled messages. 35 | fail-on= 36 | 37 | # Specify a score threshold under which the program will exit with error. 38 | fail-under=10 39 | 40 | # Interpret the stdin as a python script, whose filename needs to be passed as 41 | # the module_or_package argument. 42 | #from-stdin= 43 | 44 | # Files or directories to be skipped. They should be base names, not paths. 45 | ignore=CVS 46 | 47 | # Add files or directories matching the regular expressions patterns to the 48 | # ignore-list. The regex matches against paths and can be in Posix or Windows 49 | # format. Because '\' represents the directory delimiter on Windows systems, it 50 | # can't be used as an escape character. 51 | ignore-paths= 52 | 53 | # Files or directories matching the regular expression patterns are skipped. 54 | # The regex matches against base names, not paths. The default value ignores 55 | # Emacs file locks 56 | ignore-patterns=^\.# 57 | 58 | # List of module names for which member attributes should not be checked 59 | # (useful for modules/projects where namespaces are manipulated during runtime 60 | # and thus existing member attributes cannot be deduced by static analysis). It 61 | # supports qualified module names, as well as Unix pattern matching. 62 | ignored-modules= 63 | 64 | # Python code to execute, usually for sys.path manipulation such as 65 | # pygtk.require(). 66 | #init-hook= 67 | 68 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 69 | # number of processors available to use, and will cap the count on Windows to 70 | # avoid hangs. 71 | jobs=1 72 | 73 | # Control the amount of potential inferred values when inferring a single 74 | # object. This can help the performance when dealing with large functions or 75 | # complex, nested conditions. 76 | limit-inference-results=100 77 | 78 | # List of plugins (as comma separated values of python module names) to load, 79 | # usually to register additional checkers. 80 | load-plugins= 81 | 82 | # Pickle collected data for later comparisons. 83 | persistent=yes 84 | 85 | # Minimum Python version to use for version dependent checks. Will default to 86 | # the version used to run pylint. 87 | py-version=3.10 88 | 89 | # Discover python modules and packages in the file system subtree. 90 | recursive=no 91 | 92 | # When enabled, pylint would attempt to guess common misconfiguration and emit 93 | # user-friendly hints instead of false-positive error messages. 94 | suggestion-mode=yes 95 | 96 | # Allow loading of arbitrary C extensions. Extensions are imported into the 97 | # active Python interpreter and may run arbitrary code. 98 | unsafe-load-any-extension=no 99 | 100 | # In verbose mode, extra non-checker-related info will be displayed. 101 | #verbose= 102 | 103 | 104 | [BASIC] 105 | 106 | # Naming style matching correct argument names. 107 | argument-naming-style=snake_case 108 | 109 | # Regular expression matching correct argument names. Overrides argument- 110 | # naming-style. If left empty, argument names will be checked with the set 111 | # naming style. 112 | #argument-rgx= 113 | 114 | # Naming style matching correct attribute names. 115 | attr-naming-style=snake_case 116 | 117 | # Regular expression matching correct attribute names. Overrides attr-naming- 118 | # style. If left empty, attribute names will be checked with the set naming 119 | # style. 120 | #attr-rgx= 121 | 122 | # Bad variable names which should always be refused, separated by a comma. 123 | bad-names=foo, 124 | bar, 125 | baz, 126 | toto, 127 | tutu, 128 | tata 129 | 130 | # Bad variable names regexes, separated by a comma. If names match any regex, 131 | # they will always be refused 132 | bad-names-rgxs= 133 | 134 | # Naming style matching correct class attribute names. 135 | class-attribute-naming-style=any 136 | 137 | # Regular expression matching correct class attribute names. Overrides class- 138 | # attribute-naming-style. If left empty, class attribute names will be checked 139 | # with the set naming style. 140 | #class-attribute-rgx= 141 | 142 | # Naming style matching correct class constant names. 143 | class-const-naming-style=UPPER_CASE 144 | 145 | # Regular expression matching correct class constant names. Overrides class- 146 | # const-naming-style. If left empty, class constant names will be checked with 147 | # the set naming style. 148 | #class-const-rgx= 149 | 150 | # Naming style matching correct class names. 151 | class-naming-style=PascalCase 152 | 153 | # Regular expression matching correct class names. Overrides class-naming- 154 | # style. If left empty, class names will be checked with the set naming style. 155 | #class-rgx= 156 | 157 | # Naming style matching correct constant names. 158 | const-naming-style=UPPER_CASE 159 | 160 | # Regular expression matching correct constant names. Overrides const-naming- 161 | # style. If left empty, constant names will be checked with the set naming 162 | # style. 163 | #const-rgx= 164 | 165 | # Minimum line length for functions/classes that require docstrings, shorter 166 | # ones are exempt. 167 | docstring-min-length=-1 168 | 169 | # Naming style matching correct function names. 170 | function-naming-style=snake_case 171 | 172 | # Regular expression matching correct function names. Overrides function- 173 | # naming-style. If left empty, function names will be checked with the set 174 | # naming style. 175 | #function-rgx= 176 | 177 | # Good variable names which should always be accepted, separated by a comma. 178 | good-names=i, 179 | j, 180 | k, 181 | ex, 182 | Run, 183 | _ 184 | 185 | # Good variable names regexes, separated by a comma. If names match any regex, 186 | # they will always be accepted 187 | good-names-rgxs= 188 | 189 | # Include a hint for the correct naming format with invalid-name. 190 | include-naming-hint=no 191 | 192 | # Naming style matching correct inline iteration names. 193 | inlinevar-naming-style=any 194 | 195 | # Regular expression matching correct inline iteration names. Overrides 196 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 197 | # with the set naming style. 198 | #inlinevar-rgx= 199 | 200 | # Naming style matching correct method names. 201 | method-naming-style=snake_case 202 | 203 | # Regular expression matching correct method names. Overrides method-naming- 204 | # style. If left empty, method names will be checked with the set naming style. 205 | #method-rgx= 206 | 207 | # Naming style matching correct module names. 208 | module-naming-style=snake_case 209 | 210 | # Regular expression matching correct module names. Overrides module-naming- 211 | # style. If left empty, module names will be checked with the set naming style. 212 | #module-rgx= 213 | 214 | # Colon-delimited sets of names that determine each other's naming style when 215 | # the name regexes allow several styles. 216 | name-group= 217 | 218 | # Regular expression which should only match function or class names that do 219 | # not require a docstring. 220 | no-docstring-rgx=^_ 221 | 222 | # List of decorators that produce properties, such as abc.abstractproperty. Add 223 | # to this list to register other decorators that produce valid properties. 224 | # These decorators are taken in consideration only for invalid-name. 225 | property-classes=abc.abstractproperty 226 | 227 | # Regular expression matching correct type variable names. If left empty, type 228 | # variable names will be checked with the set naming style. 229 | #typevar-rgx= 230 | 231 | # Naming style matching correct variable names. 232 | variable-naming-style=snake_case 233 | 234 | # Regular expression matching correct variable names. Overrides variable- 235 | # naming-style. If left empty, variable names will be checked with the set 236 | # naming style. 237 | #variable-rgx= 238 | 239 | 240 | [CLASSES] 241 | 242 | # Warn about protected attribute access inside special methods 243 | check-protected-access-in-special-methods=no 244 | 245 | # List of method names used to declare (i.e. assign) instance attributes. 246 | defining-attr-methods=__init__, 247 | __new__, 248 | setUp, 249 | __post_init__ 250 | 251 | # List of member names, which should be excluded from the protected access 252 | # warning. 253 | exclude-protected=_asdict, 254 | _fields, 255 | _replace, 256 | _source, 257 | _make 258 | 259 | # List of valid names for the first argument in a class method. 260 | valid-classmethod-first-arg=cls 261 | 262 | # List of valid names for the first argument in a metaclass class method. 263 | valid-metaclass-classmethod-first-arg=cls 264 | 265 | 266 | [DESIGN] 267 | 268 | # List of regular expressions of class ancestor names to ignore when counting 269 | # public methods (see R0903) 270 | exclude-too-few-public-methods= 271 | 272 | # List of qualified class names to ignore when counting class parents (see 273 | # R0901) 274 | ignored-parents= 275 | 276 | # Maximum number of arguments for function / method. 277 | max-args=5 278 | 279 | # Maximum number of attributes for a class (see R0902). 280 | max-attributes=7 281 | 282 | # Maximum number of boolean expressions in an if statement (see R0916). 283 | max-bool-expr=5 284 | 285 | # Maximum number of branch for function / method body. 286 | max-branches=12 287 | 288 | # Maximum number of locals for function / method body. 289 | max-locals=15 290 | 291 | # Maximum number of parents for a class (see R0901). 292 | max-parents=7 293 | 294 | # Maximum number of public methods for a class (see R0904). 295 | max-public-methods=20 296 | 297 | # Maximum number of return / yield for function / method body. 298 | max-returns=6 299 | 300 | # Maximum number of statements in function / method body. 301 | max-statements=50 302 | 303 | # Minimum number of public methods for a class (see R0903). 304 | min-public-methods=2 305 | 306 | 307 | [EXCEPTIONS] 308 | 309 | # Exceptions that will emit a warning when caught. 310 | overgeneral-exceptions=BaseException, 311 | Exception 312 | 313 | 314 | [FORMAT] 315 | 316 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 317 | expected-line-ending-format= 318 | 319 | # Regexp for a line that is allowed to be longer than the limit. 320 | ignore-long-lines=^\s*(# )??$ 321 | 322 | # Number of spaces of indent required inside a hanging or continued line. 323 | indent-after-paren=4 324 | 325 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 326 | # tab). 327 | indent-string=' ' 328 | 329 | # Maximum number of characters on a single line. 330 | max-line-length=100 331 | 332 | # Maximum number of lines in a module. 333 | max-module-lines=1000 334 | 335 | # Allow the body of a class to be on the same line as the declaration if body 336 | # contains single statement. 337 | single-line-class-stmt=no 338 | 339 | # Allow the body of an if to be on the same line as the test if there is no 340 | # else. 341 | single-line-if-stmt=no 342 | 343 | 344 | [IMPORTS] 345 | 346 | # List of modules that can be imported at any level, not just the top level 347 | # one. 348 | allow-any-import-level= 349 | 350 | # Allow wildcard imports from modules that define __all__. 351 | allow-wildcard-with-all=no 352 | 353 | # Deprecated modules which should not be used, separated by a comma. 354 | deprecated-modules= 355 | 356 | # Output a graph (.gv or any supported image format) of external dependencies 357 | # to the given file (report RP0402 must not be disabled). 358 | ext-import-graph= 359 | 360 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 361 | # external) dependencies to the given file (report RP0402 must not be 362 | # disabled). 363 | import-graph= 364 | 365 | # Output a graph (.gv or any supported image format) of internal dependencies 366 | # to the given file (report RP0402 must not be disabled). 367 | int-import-graph= 368 | 369 | # Force import order to recognize a module as part of the standard 370 | # compatibility libraries. 371 | known-standard-library= 372 | 373 | # Force import order to recognize a module as part of a third party library. 374 | known-third-party=enchant 375 | 376 | # Couples of modules and preferred modules, separated by a comma. 377 | preferred-modules= 378 | 379 | 380 | [LOGGING] 381 | 382 | # The type of string formatting that logging methods do. `old` means using % 383 | # formatting, `new` is for `{}` formatting. 384 | logging-format-style=old 385 | 386 | # Logging modules to check that the string format arguments are in logging 387 | # function parameter format. 388 | logging-modules=logging 389 | 390 | 391 | [MESSAGES CONTROL] 392 | 393 | # Only show warnings with the listed confidence levels. Leave empty to show 394 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 395 | # UNDEFINED. 396 | confidence=HIGH, 397 | CONTROL_FLOW, 398 | INFERENCE, 399 | INFERENCE_FAILURE, 400 | UNDEFINED 401 | 402 | # Disable the message, report, category or checker with the given id(s). You 403 | # can either give multiple identifiers separated by comma (,) or put this 404 | # option multiple times (only on the command line, not in the configuration 405 | # file where it should appear only once). You can also use "--disable=all" to 406 | # disable everything first and then re-enable specific checks. For example, if 407 | # you want to run only the similarities checker, you can use "--disable=all 408 | # --enable=similarities". If you want to run only the classes checker, but have 409 | # no Warning level messages displayed, use "--disable=all --enable=classes 410 | # --disable=W". 411 | disable=raw-checker-failed, 412 | bad-inline-option, 413 | locally-disabled, 414 | file-ignored, 415 | suppressed-message, 416 | useless-suppression, 417 | deprecated-pragma, 418 | use-symbolic-message-instead, 419 | missing-module-docstring, 420 | consider-using-with, 421 | no-member, 422 | missing-function-docstring, 423 | missing-class-docstring, 424 | consider-using-f-string, 425 | too-many-public-methods, 426 | 427 | 428 | # Enable the message, report, category or checker with the given id(s). You can 429 | # either give multiple identifier separated by comma (,) or put this option 430 | # multiple time (only on the command line, not in the configuration file where 431 | # it should appear only once). See also the "--disable" option for examples. 432 | enable=c-extension-no-member 433 | 434 | 435 | [METHOD_ARGS] 436 | 437 | # List of qualified names (i.e., library.method) which require a timeout 438 | # parameter e.g. 'requests.api.get,requests.api.post' 439 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 440 | 441 | 442 | [MISCELLANEOUS] 443 | 444 | # List of note tags to take in consideration, separated by a comma. 445 | notes=FIXME, 446 | XXX, 447 | TODO 448 | 449 | # Regular expression of note tags to take in consideration. 450 | notes-rgx= 451 | 452 | 453 | [REFACTORING] 454 | 455 | # Maximum number of nested blocks for function / method body 456 | max-nested-blocks=5 457 | 458 | # Complete name of functions that never returns. When checking for 459 | # inconsistent-return-statements if a never returning function is called then 460 | # it will be considered as an explicit return statement and no message will be 461 | # printed. 462 | never-returning-functions=sys.exit,argparse.parse_error 463 | 464 | 465 | [REPORTS] 466 | 467 | # Python expression which should return a score less than or equal to 10. You 468 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 469 | # 'convention', and 'info' which contain the number of messages in each 470 | # category, as well as 'statement' which is the total number of statements 471 | # analyzed. This score is used by the global evaluation report (RP0004). 472 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 473 | 474 | # Template used to display messages. This is a python new-style format string 475 | # used to format the message information. See doc for all details. 476 | msg-template= 477 | 478 | # Set the output format. Available formats are text, parseable, colorized, json 479 | # and msvs (visual studio). You can also give a reporter class, e.g. 480 | # mypackage.mymodule.MyReporterClass. 481 | #output-format= 482 | 483 | # Tells whether to display a full report or only the messages. 484 | reports=no 485 | 486 | # Activate the evaluation score. 487 | score=yes 488 | 489 | 490 | [SIMILARITIES] 491 | 492 | # Comments are removed from the similarity computation 493 | ignore-comments=yes 494 | 495 | # Docstrings are removed from the similarity computation 496 | ignore-docstrings=yes 497 | 498 | # Imports are removed from the similarity computation 499 | ignore-imports=yes 500 | 501 | # Signatures are removed from the similarity computation 502 | ignore-signatures=yes 503 | 504 | # Minimum lines number of a similarity. 505 | min-similarity-lines=4 506 | 507 | 508 | [SPELLING] 509 | 510 | # Limits count of emitted suggestions for spelling mistakes. 511 | max-spelling-suggestions=4 512 | 513 | # Spelling dictionary name. Available dictionaries: none. To make it work, 514 | # install the 'python-enchant' package. 515 | spelling-dict= 516 | 517 | # List of comma separated words that should be considered directives if they 518 | # appear at the beginning of a comment and should not be checked. 519 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 520 | 521 | # List of comma separated words that should not be checked. 522 | spelling-ignore-words= 523 | 524 | # A path to a file that contains the private dictionary; one word per line. 525 | spelling-private-dict-file= 526 | 527 | # Tells whether to store unknown words to the private dictionary (see the 528 | # --spelling-private-dict-file option) instead of raising a message. 529 | spelling-store-unknown-words=no 530 | 531 | 532 | [STRING] 533 | 534 | # This flag controls whether inconsistent-quotes generates a warning when the 535 | # character used as a quote delimiter is used inconsistently within a module. 536 | check-quote-consistency=no 537 | 538 | # This flag controls whether the implicit-str-concat should generate a warning 539 | # on implicit string concatenation in sequences defined over several lines. 540 | check-str-concat-over-line-jumps=no 541 | 542 | 543 | [TYPECHECK] 544 | 545 | # List of decorators that produce context managers, such as 546 | # contextlib.contextmanager. Add to this list to register other decorators that 547 | # produce valid context managers. 548 | contextmanager-decorators=contextlib.contextmanager 549 | 550 | # List of members which are set dynamically and missed by pylint inference 551 | # system, and so shouldn't trigger E1101 when accessed. Python regular 552 | # expressions are accepted. 553 | generated-members= 554 | 555 | # Tells whether to warn about missing members when the owner of the attribute 556 | # is inferred to be None. 557 | ignore-none=yes 558 | 559 | # This flag controls whether pylint should warn about no-member and similar 560 | # checks whenever an opaque object is returned when inferring. The inference 561 | # can return multiple potential results while evaluating a Python object, but 562 | # some branches might not be evaluated, which results in partial inference. In 563 | # that case, it might be useful to still emit no-member and other checks for 564 | # the rest of the inferred objects. 565 | ignore-on-opaque-inference=yes 566 | 567 | # List of symbolic message names to ignore for Mixin members. 568 | ignored-checks-for-mixins=no-member, 569 | not-async-context-manager, 570 | not-context-manager, 571 | attribute-defined-outside-init 572 | 573 | # List of class names for which member attributes should not be checked (useful 574 | # for classes with dynamically set attributes). This supports the use of 575 | # qualified names. 576 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 577 | 578 | # Show a hint with possible names when a member name was not found. The aspect 579 | # of finding the hint is based on edit distance. 580 | missing-member-hint=yes 581 | 582 | # The minimum edit distance a name should have in order to be considered a 583 | # similar match for a missing member name. 584 | missing-member-hint-distance=1 585 | 586 | # The total number of similar names that should be taken in consideration when 587 | # showing a hint for a missing member. 588 | missing-member-max-choices=1 589 | 590 | # Regex pattern to define which classes are considered mixins. 591 | mixin-class-rgx=.*[Mm]ixin 592 | 593 | # List of decorators that change the signature of a decorated function. 594 | signature-mutators= 595 | 596 | 597 | [VARIABLES] 598 | 599 | # List of additional names supposed to be defined in builtins. Remember that 600 | # you should avoid defining new builtins when possible. 601 | additional-builtins= 602 | 603 | # Tells whether unused global variables should be treated as a violation. 604 | allow-global-unused-variables=yes 605 | 606 | # List of names allowed to shadow builtins 607 | allowed-redefined-builtins= 608 | 609 | # List of strings which can identify a callback function by name. A callback 610 | # name must start or end with one of those strings. 611 | callbacks=cb_, 612 | _cb 613 | 614 | # A regular expression matching the name of dummy variables (i.e. expected to 615 | # not be used). 616 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 617 | 618 | # Argument names that match this expression will be ignored. 619 | ignored-argument-names=_.*|^ignored_|^unused_ 620 | 621 | # Tells whether we should check for unused import in __init__ files. 622 | init-import=no 623 | 624 | # List of qualified module names which can have objects that can redefine 625 | # builtins. 626 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 627 | -------------------------------------------------------------------------------- /nnreddit/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Nnreddit OAuth2 Helper 5 | 6 | 26 | 27 | 28 | ${message} 29 | 30 | 31 | -------------------------------------------------------------------------------- /nnreddit/templates/mailcap: -------------------------------------------------------------------------------- 1 | # Example mailcap file for Reddit Terminal Viewer 2 | # https://github.com/michael-lazar/rtv/ 3 | # 4 | # Copy the contents of this file to {HOME}/.mailcap, or point to it using $MAILCAPS 5 | # Then launch RTV using the --enable-media flag. All shell commands defined in 6 | # this file depend on external programs that must be installed on your system. 7 | # 8 | # HELP REQUESTED! If you come up with your own commands (especially for OS X) 9 | # and would like to share, please post an issue on the GitHub tracker and we 10 | # can get them added to this file as references. 11 | # 12 | # 13 | # Mailcap 101 14 | # - The first entry with a matching MIME type will be executed, * is a wildcard 15 | # - %s will be replaced with the image or video url 16 | # - Add ``test=test -n "$DISPLAY"`` if your command opens a new window 17 | # - Add ``needsterminal`` for commands that use the terminal 18 | # - Add ``copiousoutput`` for commands that dump text to stdout 19 | 20 | ############################################################################### 21 | # Commands below this point will open media in a separate window without 22 | # pausing execution of RTV. 23 | ############################################################################### 24 | 25 | # Feh is a simple and effective image viewer 26 | # Note that rtv returns a list of urls for imgur albums, so we don't put quotes 27 | # around the `%s` 28 | image/x-imgur-album; feh -g 640x480 %s; test=test -n "$DISPLAY" 29 | image/gif; mpv '%s' --autofit 640x480 --loop=inf; test=test -n "$DISPLAY" 30 | image/*; feh -g 640x480 '%s'; test=test -n "$DISPLAY" 31 | 32 | # Youtube videos are assigned a custom mime-type, which can be streamed with 33 | # vlc or youtube-dl. 34 | video/x-youtube; vlc '%s' --width 640 --height 480; test=test -n "$DISPLAY" 35 | video/x-youtube; mpv --ytdl-format=bestvideo+bestaudio/best '%s' --autofit 640x480; test=test -n "$DISPLAY" 36 | 37 | # Mpv is a simple and effective video streamer 38 | video/*; mpv '%s' --autofit 640x480 --loop=inf; test=test -n "$DISPLAY" 39 | 40 | ############################################################################### 41 | # Commands below this point will attempt to display media directly in the 42 | # terminal when a desktop is not available (e.g. inside of an SSH session) 43 | ############################################################################### 44 | 45 | # View images directly in your terminal with iTerm2 46 | # curl -L https://iterm2.com/misc/install_shell_integration_and_utilities.sh | bash 47 | # image/*; bash -c '[[ "%s" == http* ]] && (curl -s %s | ~/.iterm2/imgcat) || ~/.iterm2/imgcat %s' && read -n 1; needsterminal 48 | 49 | # View true images in the terminal, supported by rxvt-unicode, xterm and st 50 | # Requires the w3m-img package 51 | # image/*; w3m -o 'ext_image_viewer=off' '%s'; needsterminal 52 | 53 | # Don't have a solution for albums yet 54 | image/x-imgur-album; echo 55 | 56 | # 256 color images using half-width unicode characters 57 | # Much higher quality that img2txt, but must be built from source 58 | # https://github.com/rossy/img2xterm 59 | image/*; curl -s '%s' | convert -resize 80x80 - jpg:/tmp/rtv.jpg && img2xterm /tmp/rtv.jpg; needsterminal; copiousoutput 60 | 61 | # Display images in classic ascii using img2txt and lib-caca 62 | image/*; curl -s '%s' | convert - jpg:/tmp/rtv.jpg && img2txt -f utf8 /tmp/rtv.jpg; needsterminal; copiousoutput 63 | 64 | # Full motion videos - requires a framebuffer to view 65 | video/x-youtube; mpv -vo drm -quiet '%s'; needsterminal 66 | video/*; mpv -vo drm -quiet '%s'; needsterminal 67 | 68 | # Ascii videos 69 | # video/x-youtube; youtube-dl -q -o - '%s' | mplayer -cache 8192 -vo caca -quiet -; needsterminal 70 | # video/*; wget '%s' -O - | mplayer -cache 8192 -vo caca -quiet -; needsterminal 71 | -------------------------------------------------------------------------------- /nnreddit/templates/rtv.cfg: -------------------------------------------------------------------------------- 1 | ; Reddit Terminal Viewer Configuration File 2 | ; https://github.com/michael-lazar/rtv 3 | ; 4 | ; This file should be placed in $XDG_CONFIG/rtv/rtv.cfg 5 | ; If $XDG_CONFIG is not set, use ~/.config/rtv/rtv.cfg 6 | 7 | [rtv] 8 | ################## 9 | # General Settings 10 | ################## 11 | 12 | ; Turn on ascii-only mode to disable all unicode characters. 13 | ; This may be necessary for compatibility with some terminal browsers. 14 | ascii = False 15 | 16 | ; Turn on monochrome mode to disable color. 17 | monochrome = False 18 | 19 | ; Flash when an invalid action is executed. 20 | flash = True 21 | 22 | ; Enable debugging by logging all HTTP requests and errors to the given file. 23 | ;log = /tmp/rtv.log 24 | 25 | ; Default subreddit that will be opened when the program launches. 26 | subreddit = front 27 | ;subreddit = python 28 | ;subreddit = python+linux+programming 29 | ;subreddit = all 30 | 31 | ; Allow rtv to store reddit authentication credentials between sessions. 32 | persistent = True 33 | 34 | ; Automatically log in on startup, if credentials are available. 35 | autologin = True 36 | 37 | ; Clear any stored credentials when the program starts. 38 | clear_auth = False 39 | 40 | ; Maximum number of opened links that will be saved in the history file. 41 | history_size = 200 42 | 43 | ; Open external links using programs defined in the mailcap config. 44 | enable_media = False 45 | 46 | ; Maximum number of columns for a comment 47 | max_comment_cols = 120 48 | 49 | ; Maximum number of columns for pager 50 | ;max_pager_cols = 70 51 | 52 | ; Hide username if logged in, display "Logged in" instead 53 | hide_username = False 54 | 55 | ; Color theme, use "rtv --list-themes" to view a list of valid options. 56 | ; This can be an absolute filepath, or the name of a theme file that has 57 | ; been installed into either the custom of default theme paths. 58 | ;theme = molokai 59 | 60 | ################ 61 | # OAuth Settings 62 | ################ 63 | ; This sections defines the paramaters that will be used during the OAuth 64 | ; authentication process. rtv is registered as an "installed app", 65 | ; see https://github.com/reddit/reddit/wiki/OAuth2 for more information. 66 | 67 | ; These settings are defined at https://www.reddit.com/prefs/apps and should 68 | ; not be altered unless you are defining your own developer application. 69 | oauth_client_id = E2oEtRQfdfAfNQ 70 | oauth_client_secret = praw_gapfill 71 | oauth_redirect_uri = http://127.0.0.1:65000/ 72 | 73 | ; Port that the rtv webserver will listen on. This should match the redirect 74 | ; uri defined above. 75 | oauth_redirect_port = 65000 76 | 77 | ; Access permissions that will be requested. 78 | oauth_scope = edit,history,identity,mysubreddits,privatemessages,read,report,save,submit,subscribe,vote 79 | 80 | ; This is a separate token for the imgur api. It's used to extract images 81 | ; from imgur links and albums so they can be opened with mailcap. 82 | ; See https://imgur.com/account/settings/apps to generate your own key. 83 | imgur_client_id = 93396265f59dec9 84 | 85 | [bindings] 86 | ############## 87 | # Key Bindings 88 | ############## 89 | ; If you would like to define custom bindings, copy this section into your 90 | ; config file with the [bindings] heading. All commands must be bound to at 91 | ; least one key for the config to be valid. 92 | ; 93 | ; 1.) Plain keys can be represented by either uppercase/lowercase characters 94 | ; or the hexadecimal numbers referring their ascii codes. For reference, see 95 | ; https://en.wikipedia.org/wiki/ASCII#ASCII_printable_code_chart 96 | ; e.g. Q, q, 1, ? 97 | ; e.g. 0x20 (space), 0x3c (less-than sign) 98 | ; 99 | ; 2.) Special ascii control codes should be surrounded with <>. For reference, 100 | ; see https://en.wikipedia.org/wiki/ASCII#ASCII_control_code_chart 101 | ; e.g. (enter), (escape) 102 | ; 103 | ; 3.) Other special keys are defined by curses, they should be surrounded by <> 104 | ; and prefixed with KEY_. For reference, see 105 | ; https://docs.python.org/2/library/curses.html#constants 106 | ; e.g. (left arrow), , (page down) 107 | ; 108 | ; Notes: 109 | ; - Curses is unreliable and should always be used in conjunction 110 | ; with . 111 | ; - Use 0x20 for the space key. 112 | ; - A subset of Ctrl modifiers are available through the ascii control codes. 113 | ; For example, Ctrl-D will trigger an signal. See the table above for 114 | ; a complete reference. 115 | 116 | ; Base page 117 | EXIT = q 118 | FORCE_EXIT = Q 119 | HELP = ? 120 | SORT_1 = 1 121 | SORT_2 = 2 122 | SORT_3 = 3 123 | SORT_4 = 4 124 | SORT_5 = 5 125 | SORT_6 = 6 126 | SORT_7 = 7 127 | MOVE_UP = k, 128 | MOVE_DOWN = j, 129 | PREVIOUS_THEME = 130 | NEXT_THEME = 131 | PAGE_UP = m, , 132 | PAGE_DOWN = n, , 133 | PAGE_TOP = gg 134 | PAGE_BOTTOM = G 135 | UPVOTE = a 136 | DOWNVOTE = z 137 | LOGIN = u 138 | DELETE = d 139 | EDIT = e 140 | INBOX = i 141 | REFRESH = r, 142 | PROMPT = / 143 | SAVE = w 144 | COPY_PERMALINK = y 145 | COPY_URL = Y 146 | PRIVATE_MESSAGE = C 147 | SUBSCRIPTIONS = s 148 | MULTIREDDITS = S 149 | 150 | ; Submission page 151 | SUBMISSION_TOGGLE_COMMENT = 0x20 152 | SUBMISSION_OPEN_IN_BROWSER = o, , 153 | SUBMISSION_POST = c 154 | SUBMISSION_EXIT = h, 155 | SUBMISSION_OPEN_IN_PAGER = l, 156 | SUBMISSION_OPEN_IN_URLVIEWER = b 157 | SUBMISSION_GOTO_PARENT = K 158 | SUBMISSION_GOTO_SIBLING = J 159 | 160 | ; Subreddit page 161 | SUBREDDIT_SEARCH = f 162 | SUBREDDIT_POST = c 163 | SUBREDDIT_OPEN = l, 164 | SUBREDDIT_OPEN_IN_BROWSER = o, , 165 | SUBREDDIT_FRONTPAGE = p 166 | SUBREDDIT_HIDE = 0x20 167 | 168 | ; Subscription page 169 | SUBSCRIPTION_SELECT = l, , , 170 | SUBSCRIPTION_EXIT = h, s, S, , 171 | 172 | ; Inbox page 173 | INBOX_VIEW_CONTEXT = l, 174 | INBOX_OPEN_SUBMISSION = o, , 175 | INBOX_REPLY = c 176 | INBOX_MARK_READ = w 177 | INBOX_EXIT = h, , 178 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | praw==7.3.0 2 | rtv==1.26.0 3 | jsonrpyc==1.1.0 4 | vcrpy==2.0.1 5 | betamax==0.8.1 6 | betamax-serializers==0.2.0 7 | pytest==4.5.0 8 | urllib3==1.26.19 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | virtualenv 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dickmao/nnreddit/c3ce69a8864d8333a390dcb20f78de3ba2c4111f/screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """nnreddit setup.py.""" 2 | 3 | import re 4 | from codecs import open 5 | from os import path 6 | from setuptools import setup 7 | 8 | 9 | PACKAGE_NAME = "nnreddit" 10 | HERE = path.abspath(path.dirname(__file__)) 11 | 12 | # https://packaging.python.org/guides/single-sourcing-package-version/#single-sourcing-the-version 13 | # I chose method #4 as it touts multi-language access, i.e., elisp and python 14 | # Method #1a via setup.cfg is cleanest, though. 15 | with open(path.join('nnreddit', 'VERSION')) as version_file: 16 | version = version_file.read().strip() 17 | 18 | setup( 19 | name=PACKAGE_NAME, 20 | author="dickmao", 21 | description="PRAW nnreddit backend", 22 | license="GPLv3", 23 | packages=[PACKAGE_NAME], 24 | version=version, 25 | package_data={ 26 | "nnreddit": ["templates/*"], 27 | }, 28 | install_requires=[ 29 | "jsonrpyc>=1.1.0", 30 | "rtv>=1.26.0", 31 | "praw>=7.3.0", 32 | "urllib3<=2.2.3", 33 | ], 34 | ) 35 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dickmao/nnreddit/c3ce69a8864d8333a390dcb20f78de3ba2c4111f/tests/__init__.py -------------------------------------------------------------------------------- /tests/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Kicks things off via 3 | 4 | python -m nnreddit 5 | 6 | """ 7 | 8 | import os 9 | import sys 10 | import jsonrpyc 11 | import argparse 12 | from tempfile import mkstemp 13 | 14 | parser = argparse.ArgumentParser() 15 | parser.add_argument("--token-file", help="refresh token filename") 16 | args = parser.parse_args() 17 | 18 | os.environ["XDG_DATA_HOME"] = os.path.join(os.path.dirname(__file__), 'share') 19 | try: 20 | os.makedirs(os.environ["XDG_DATA_HOME"]) 21 | except OSError: 22 | if not os.path.isdir(os.environ["XDG_DATA_HOME"]): 23 | raise 24 | 25 | from nnreddit.authenticated_reddit import AuthenticatedReddit 26 | from rtv.config import TOKEN 27 | 28 | stdin = sys.stdin 29 | stdout = sys.stdout 30 | if __name__ != "__main__": 31 | sys.stdout = sys.stderr = open(os.devnull, "w") 32 | 33 | logdir = os.path.join(os.path.dirname(__file__), 'log') 34 | try: 35 | os.makedirs(logdir) 36 | except OSError: 37 | if not os.path.isdir(logdir): 38 | raise 39 | 40 | super_secret = os.path.join(os.path.dirname(TOKEN), 'super-secret-refresh-token') 41 | kwargs = { 'token_file': args.token_file if args.token_file else (super_secret if os.path.exists(super_secret) else TOKEN), 42 | 'history_file': mkstemp(dir='/var/tmp')[1], 43 | } 44 | jsonrpyc.RPC(target=AuthenticatedReddit(log_prefix=os.path.join(logdir, 'test_py.'), 45 | decode_html_entities=False, 46 | disable_update_check=True, 47 | check_for_updates=False, 48 | **kwargs), 49 | stdin=stdin, stdout=stdout) 50 | -------------------------------------------------------------------------------- /tests/cassettes/cancel.json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [{"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Accept-Encoding": ["identity"], "Accept": ["*/*"], "User-Agent": ["Linux:nnreddit:0.1.0 PRAW/6.2.0 PRAW/6.2.0 prawcore/1.0.1"], "Connection": ["keep-alive"], "Cookie": ["edgebucket=hbJw9F3ioxDtTELuFS"], "Authorization": ["bearer **********"]}, "method": "GET", "uri": "https://oauth.reddit.com/comments/blfat0/?sort=best&raw_json=1&limit=2048"}, "response": {"body": {"string": "{\"message\": \"Unauthorized\", \"error\": 401}", "encoding": "UTF-8"}, "headers": {"Content-Length": ["41"], "x-xss-protection": ["1; mode=block"], "X-Cache": ["MISS"], "x-content-type-options": ["nosniff"], "access-control-expose-headers": ["X-Moose"], "Accept-Ranges": ["bytes"], "X-Timer": ["S1561588500.108061,VS0,VE41"], "X-Served-By": ["cache-ewr18145-EWR"], "X-Moose": ["majestic"], "access-control-allow-origin": ["*"], "Connection": ["keep-alive"], "Via": ["1.1 varnish"], "X-Cache-Hits": ["0"], "x-ua-compatible": ["IE=edge"], "Strict-Transport-Security": ["max-age=15552000; includeSubDomains; preload"], "cache-control": ["max-age=0, must-revalidate"], "Date": ["Wed, 26 Jun 2019 22:35:00 GMT"], "x-frame-options": ["SAMEORIGIN"], "Server": ["snooserv"], "Content-Type": ["application/json; charset=UTF-8"], "www-authenticate": ["Bearer realm=\"reddit\", error=\"invalid_token\""]}, "status": {"message": "Unauthorized", "code": 401}, "url": "https://oauth.reddit.com/comments/blfat0/?sort=best&raw_json=1&limit=2048"}, "recorded_at": "2019-06-26T22:35:00"}, {"request": {"body": {"string": "grant_type=refresh_token&refresh_token=**********", "encoding": "utf-8"}, "headers": {"Content-Length": ["79"], "Accept-Encoding": ["identity"], "Accept": ["*/*"], "User-Agent": ["Linux:nnreddit:0.1.0 PRAW/6.2.0 PRAW/6.2.0 prawcore/1.0.1"], "Connection": ["keep-alive"], "Cookie": ["edgebucket=hbJw9F3ioxDtTELuFS"], "Content-Type": ["application/x-www-form-urlencoded"], "Authorization": ["**********"]}, "method": "POST", "uri": "https://www.reddit.com/api/v1/access_token"}, "response": {"body": {"string": "{\"access_token\": \"**********\", \"token_type\": \"bearer\", \"expires_in\": 3600, \"scope\": \"edit history identity mysubreddits privatemessages read report save submit subscribe vote\"}", "encoding": "UTF-8"}, "headers": {"Content-Length": ["206"], "x-xss-protection": ["1; mode=block"], "X-Cache": ["MISS"], "x-content-type-options": ["nosniff"], "Accept-Ranges": ["bytes"], "X-Timer": ["S1561588502.977129,VS0,VE51"], "X-Served-By": ["cache-lga21928-LGA"], "X-Moose": ["majestic"], "Connection": ["keep-alive"], "Via": ["1.1 varnish"], "X-Cache-Hits": ["0"], "Strict-Transport-Security": ["max-age=15552000; includeSubDomains; preload"], "cache-control": ["max-age=0, must-revalidate"], "Date": ["Wed, 26 Jun 2019 22:35:02 GMT"], "x-frame-options": ["SAMEORIGIN"], "Server": ["snooserv"], "Content-Type": ["application/json; charset=UTF-8"]}, "status": {"message": "OK", "code": 200}, "url": "https://www.reddit.com/api/v1/access_token"}, "recorded_at": "2019-06-26T22:35:02"}, {"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Accept-Encoding": ["identity"], "Accept": ["*/*"], "User-Agent": ["Linux:nnreddit:0.1.0 PRAW/6.2.0 PRAW/6.2.0 prawcore/1.0.1"], "Connection": ["keep-alive"], "Cookie": ["edgebucket=hbJw9F3ioxDtTELuFS"], "Authorization": ["bearer **********"]}, "method": "GET", "uri": "https://oauth.reddit.com/comments/blfat0/?sort=best&raw_json=1&limit=2048"}, "response": {"body": {"string": "[{\"kind\": \"Listing\", \"data\": {\"modhash\": null, \"dist\": 1, \"children\": [{\"kind\": \"t3\", \"data\": {\"approved_at_utc\": null, \"subreddit\": \"PostPreview\", \"selftext\": \" [https://icdn8.digitaltrends.com/image/history-of-virtual-reality-super-cockpit-720x720.jpg](https://icdn8.digitaltrends.com/image/history-of-virtual-reality-super-cockpit-720x720.jpg)\", \"user_reports\": [], \"saved\": false, \"mod_reason_title\": null, \"gilded\": 0, \"clicked\": false, \"title\": \"Testing inline image links. Is it a url link or an inline image render?\", \"link_flair_richtext\": [], \"subreddit_name_prefixed\": \"r/PostPreview\", \"hidden\": false, \"pwls\": null, \"link_flair_css_class\": null, \"downs\": 0, \"thumbnail_height\": null, \"parent_whitelist_status\": null, \"hide_score\": false, \"name\": \"t3_blfat0\", \"quarantine\": false, \"link_flair_text_color\": \"dark\", \"upvote_ratio\": 1.0, \"author_flair_background_color\": null, \"subreddit_type\": \"public\", \"ups\": 2, \"total_awards_received\": 0, \"media_embed\": {}, \"thumbnail_width\": null, \"author_flair_template_id\": null, \"is_original_content\": false, \"author_fullname\": \"t2_250hgx\", \"secure_media\": null, \"is_reddit_media_domain\": false, \"is_meta\": false, \"category\": null, \"secure_media_embed\": {}, \"link_flair_text\": null, \"can_mod_post\": false, \"score\": 2, \"approved_by\": null, \"thumbnail\": \"self\", \"edited\": false, \"author_flair_css_class\": null, \"author_flair_richtext\": [], \"gildings\": {}, \"post_hint\": \"self\", \"content_categories\": null, \"is_self\": true, \"mod_note\": null, \"created\": 1557193781.0, \"link_flair_type\": \"text\", \"wls\": null, \"banned_by\": null, \"author_flair_type\": \"text\", \"domain\": \"self.PostPreview\", \"selftext_html\": \"\\u003C!-- SC_OFF --\\u003E\\u003Cdiv class=\\\"md\\\"\\u003E\\u003Cp\\u003E\\u003Ca href=\\\"https://icdn8.digitaltrends.com/image/history-of-virtual-reality-super-cockpit-720x720.jpg\\\"\\u003Ehttps://icdn8.digitaltrends.com/image/history-of-virtual-reality-super-cockpit-720x720.jpg\\u003C/a\\u003E\\u003C/p\\u003E\\n\\u003C/div\\u003E\\u003C!-- SC_ON --\\u003E\", \"likes\": null, \"suggested_sort\": \"confidence\", \"banned_at_utc\": null, \"view_count\": null, \"archived\": false, \"no_follow\": true, \"is_crosspostable\": true, \"pinned\": false, \"over_18\": false, \"preview\": {\"images\": [{\"source\": {\"url\": \"https://external-preview.redd.it/VS-btwZZkqPDL2Vd66P5CJRiy5_y97gubmfmabrWvwk.jpg?auto=webp\\u0026s=d3c10ad19e8b8eca7d67c9c02e5e3855da850f74\", \"width\": 720, \"height\": 480}, \"resolutions\": [{\"url\": \"https://external-preview.redd.it/VS-btwZZkqPDL2Vd66P5CJRiy5_y97gubmfmabrWvwk.jpg?width=108\\u0026crop=smart\\u0026auto=webp\\u0026s=7114703deabd7dabcdb76a4a297e1d9972e2b894\", \"width\": 108, \"height\": 72}, {\"url\": \"https://external-preview.redd.it/VS-btwZZkqPDL2Vd66P5CJRiy5_y97gubmfmabrWvwk.jpg?width=216\\u0026crop=smart\\u0026auto=webp\\u0026s=68c8d4a2411529b4604cc99596a75cd05521c732\", \"width\": 216, \"height\": 144}, {\"url\": \"https://external-preview.redd.it/VS-btwZZkqPDL2Vd66P5CJRiy5_y97gubmfmabrWvwk.jpg?width=320\\u0026crop=smart\\u0026auto=webp\\u0026s=1723b2ec798f9d1ec140cc51eea133f1568769dd\", \"width\": 320, \"height\": 213}, {\"url\": \"https://external-preview.redd.it/VS-btwZZkqPDL2Vd66P5CJRiy5_y97gubmfmabrWvwk.jpg?width=640\\u0026crop=smart\\u0026auto=webp\\u0026s=edead561607977d7d32b995e9fa3827e494870c5\", \"width\": 640, \"height\": 426}], \"variants\": {}, \"id\": \"K2EIcIbKe-BpkJJ7Ks_tiI6dFeXtEYZt2OiHV5_IJzo\"}], \"enabled\": false}, \"all_awardings\": [], \"media\": null, \"media_only\": false, \"can_gild\": true, \"spoiler\": false, \"locked\": false, \"author_flair_text\": null, \"visited\": false, \"num_reports\": null, \"distinguished\": null, \"subreddit_id\": \"t5_2waml\", \"mod_reason_by\": null, \"removal_reason\": null, \"link_flair_background_color\": \"\", \"id\": \"blfat0\", \"is_robot_indexable\": true, \"report_reasons\": null, \"author\": \"MeddyEvalNight\", \"num_crossposts\": 0, \"num_comments\": 2, \"send_replies\": true, \"contest_mode\": false, \"author_patreon_flair\": false, \"author_flair_text_color\": null, \"permalink\": \"/r/PostPreview/comments/blfat0/testing_inline_image_links_is_it_a_url_link_or_an/\", \"whitelist_status\": null, \"stickied\": false, \"url\": \"https://www.reddit.com/r/PostPreview/comments/blfat0/testing_inline_image_links_is_it_a_url_link_or_an/\", \"subreddit_subscribers\": 721, \"created_utc\": 1557164981.0, \"mod_reports\": [], \"is_video\": false}}], \"after\": null, \"before\": null}}, {\"kind\": \"Listing\", \"data\": {\"modhash\": null, \"dist\": null, \"children\": [{\"kind\": \"t1\", \"data\": {\"total_awards_received\": 0, \"approved_at_utc\": null, \"ups\": 1, \"mod_reason_by\": null, \"banned_by\": null, \"author_flair_type\": \"text\", \"removal_reason\": null, \"link_id\": \"t3_blfat0\", \"author_flair_template_id\": null, \"likes\": null, \"no_follow\": true, \"replies\": \"\", \"user_reports\": [], \"saved\": false, \"id\": \"emnwgbf\", \"banned_at_utc\": null, \"mod_reason_title\": null, \"gilded\": 0, \"archived\": false, \"report_reasons\": null, \"author\": \"MeddyEvalNight\", \"can_mod_post\": false, \"send_replies\": true, \"parent_id\": \"t3_blfat0\", \"score\": 1, \"author_fullname\": \"t2_250hgx\", \"approved_by\": null, \"all_awardings\": [], \"subreddit_id\": \"t5_2waml\", \"body\": \"Not what I wanted... next test.\", \"edited\": false, \"author_flair_css_class\": null, \"is_submitter\": true, \"downs\": 0, \"author_flair_richtext\": [], \"author_patreon_flair\": false, \"collapsed_reason\": null, \"body_html\": \"\\u003Cdiv class=\\\"md\\\"\\u003E\\u003Cp\\u003ENot what I wanted... next test.\\u003C/p\\u003E\\n\\u003C/div\\u003E\", \"stickied\": false, \"subreddit_type\": \"public\", \"can_gild\": true, \"gildings\": {}, \"author_flair_text_color\": null, \"score_hidden\": false, \"permalink\": \"/r/PostPreview/comments/blfat0/testing_inline_image_links_is_it_a_url_link_or_an/emnwgbf/\", \"num_reports\": null, \"locked\": false, \"name\": \"t1_emnwgbf\", \"created\": 1557193820.0, \"subreddit\": \"PostPreview\", \"author_flair_text\": null, \"collapsed\": false, \"created_utc\": 1557165020.0, \"subreddit_name_prefixed\": \"r/PostPreview\", \"controversiality\": 0, \"depth\": 0, \"author_flair_background_color\": null, \"mod_reports\": [], \"mod_note\": null, \"distinguished\": null}}, {\"kind\": \"t1\", \"data\": {\"total_awards_received\": 0, \"approved_at_utc\": null, \"ups\": 1, \"mod_reason_by\": null, \"banned_by\": null, \"author_flair_type\": \"text\", \"removal_reason\": null, \"link_id\": \"t3_blfat0\", \"author_flair_template_id\": null, \"likes\": null, \"no_follow\": true, \"replies\": \"\", \"user_reports\": [], \"saved\": false, \"id\": \"emny9vc\", \"banned_at_utc\": null, \"mod_reason_title\": null, \"gilded\": 0, \"archived\": false, \"report_reasons\": null, \"author\": \"MeddyEvalNight\", \"can_mod_post\": false, \"send_replies\": true, \"parent_id\": \"t3_blfat0\", \"score\": 1, \"author_fullname\": \"t2_250hgx\", \"approved_by\": null, \"all_awardings\": [], \"subreddit_id\": \"t5_2waml\", \"body\": \"Image was rendered as a link , but really wanted an inline render of an already hosted image - similar to html \\u003Cimg/\\u003E . It looks like an inline rendered image needs to be uploaded\\n\\n\\u0026#x200B;\\n\\n[Video link](https://www.youtube.com/watch?v=yd98RGxad0U)\", \"edited\": false, \"author_flair_css_class\": null, \"is_submitter\": true, \"downs\": 0, \"author_flair_richtext\": [], \"author_patreon_flair\": false, \"collapsed_reason\": null, \"body_html\": \"\\u003Cdiv class=\\\"md\\\"\\u003E\\u003Cp\\u003EImage was rendered as a link , but really wanted an inline render of an already hosted image - similar to html \\u0026lt;img/\\u0026gt; . It looks like an inline rendered image needs to be uploaded\\u003C/p\\u003E\\n\\n\\u003Cp\\u003E\\u0026#x200B;\\u003C/p\\u003E\\n\\n\\u003Cp\\u003E\\u003Ca href=\\\"https://www.youtube.com/watch?v=yd98RGxad0U\\\"\\u003EVideo link\\u003C/a\\u003E\\u003C/p\\u003E\\n\\u003C/div\\u003E\", \"stickied\": false, \"subreddit_type\": \"public\", \"can_gild\": true, \"gildings\": {}, \"author_flair_text_color\": null, \"score_hidden\": false, \"permalink\": \"/r/PostPreview/comments/blfat0/testing_inline_image_links_is_it_a_url_link_or_an/emny9vc/\", \"num_reports\": null, \"locked\": false, \"name\": \"t1_emny9vc\", \"created\": 1557194874.0, \"subreddit\": \"PostPreview\", \"author_flair_text\": null, \"collapsed\": false, \"created_utc\": 1557166074.0, \"subreddit_name_prefixed\": \"r/PostPreview\", \"controversiality\": 0, \"depth\": 0, \"author_flair_background_color\": null, \"mod_reports\": [], \"mod_note\": null, \"distinguished\": null}}], \"after\": null, \"before\": null}}]", "encoding": "UTF-8"}, "headers": {"Content-Length": ["8171"], "X-Cache-Hits": ["0"], "x-xss-protection": ["1; mode=block"], "x-content-type-options": ["nosniff"], "X-Moose": ["majestic"], "x-ua-compatible": ["IE=edge"], "cache-control": ["private, s-maxage=0, max-age=0, must-revalidate, max-age=0, must-revalidate"], "Date": ["Wed, 26 Jun 2019 22:35:02 GMT"], "x-ratelimit-remaining": ["586.0"], "Strict-Transport-Security": ["max-age=15552000; includeSubDomains; preload"], "X-Cache": ["MISS"], "Accept-Ranges": ["bytes"], "expires": ["-1"], "Server": ["snooserv"], "Connection": ["keep-alive"], "X-Served-By": ["cache-ewr18145-EWR"], "x-ratelimit-used": ["14"], "Via": ["1.1 varnish"], "X-Timer": ["S1561588502.046138,VS0,VE143"], "Vary": ["accept-encoding"], "x-frame-options": ["SAMEORIGIN"], "Content-Type": ["application/json; charset=UTF-8"], "x-ratelimit-reset": ["298"]}, "status": {"message": "OK", "code": 200}, "url": "https://oauth.reddit.com/comments/blfat0/?sort=best&raw_json=1&limit=2048"}, "recorded_at": "2019-06-26T22:35:02"}, {"request": {"body": {"string": "api_type=json&id=t1_eqwoano", "encoding": "utf-8"}, "headers": {"Content-Length": ["27"], "Accept-Encoding": ["identity"], "Accept": ["*/*"], "User-Agent": ["Linux:nnreddit:0.1.0 PRAW/6.2.0 PRAW/6.2.0 prawcore/1.0.1"], "Connection": ["keep-alive"], "Cookie": ["edgebucket=hbJw9F3ioxDtTELuFS"], "Content-Type": ["application/x-www-form-urlencoded"], "Authorization": ["bearer **********"]}, "method": "POST", "uri": "https://oauth.reddit.com/api/del/?raw_json=1"}, "response": {"body": {"string": "{}", "encoding": "UTF-8"}, "headers": {"Content-Length": ["2"], "X-Cache-Hits": ["0"], "x-xss-protection": ["1; mode=block"], "x-content-type-options": ["nosniff"], "X-Moose": ["majestic"], "x-ua-compatible": ["IE=edge"], "cache-control": ["private, s-maxage=0, max-age=0, must-revalidate, max-age=0, must-revalidate"], "Date": ["Wed, 26 Jun 2019 22:35:02 GMT"], "x-ratelimit-remaining": ["585.0"], "Strict-Transport-Security": ["max-age=15552000; includeSubDomains; preload"], "X-Cache": ["MISS"], "Accept-Ranges": ["bytes"], "expires": ["-1"], "Server": ["snooserv"], "Connection": ["keep-alive"], "X-Served-By": ["cache-ewr18145-EWR"], "x-ratelimit-used": ["15"], "Via": ["1.1 varnish"], "X-Timer": ["S1561588503.720492,VS0,VE153"], "x-frame-options": ["SAMEORIGIN"], "Content-Type": ["application/json; charset=UTF-8"], "x-ratelimit-reset": ["298"]}, "status": {"message": "OK", "code": 200}, "url": "https://oauth.reddit.com/api/del/?raw_json=1"}, "recorded_at": "2019-06-26T22:35:02"}], "recorded_with": "betamax/0.8.1"} -------------------------------------------------------------------------------- /tests/cassettes/test_oauth.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: !!python/unicode code=**********&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%3A17973 4 | headers: 5 | Accept: ['*/*'] 6 | Accept-Encoding: ['gzip, deflate'] 7 | !!python/unicode Authorization: [!!python/unicode '**********'] 8 | Connection: [keep-alive] 9 | Content-Length: ['106'] 10 | Content-Type: [application/x-www-form-urlencoded] 11 | User-Agent: ['Linux:nnreddit:0.1.0 PRAW/6.2.0 PRAW/6.2.0 prawcore/1.0.1'] 12 | method: POST 13 | uri: https://www.reddit.com/api/v1/access_token 14 | response: 15 | body: {string: !!python/unicode '{"access_token": "**********", "token_type": 16 | "bearer", "expires_in": 3600, "refresh_token": "**********", "scope": "edit 17 | history identity mysubreddits privatemessages read report save submit subscribe 18 | vote"}'} 19 | headers: 20 | accept-ranges: [bytes] 21 | cache-control: ['max-age=0, must-revalidate'] 22 | connection: [keep-alive] 23 | content-length: ['267'] 24 | content-type: [application/json; charset=UTF-8] 25 | date: ['Fri, 07 Jun 2019 04:38:25 GMT'] 26 | server: [snooserv] 27 | set-cookie: [edgebucket=y1FPYeim4TI9gnt5xY; Domain=reddit.com; Max-Age=63071999; 28 | Path=/; secure] 29 | strict-transport-security: [max-age=15552000; includeSubDomains; preload] 30 | via: [1.1 varnish] 31 | x-cache: [MISS] 32 | x-cache-hits: ['0'] 33 | x-content-type-options: [nosniff] 34 | x-frame-options: [SAMEORIGIN] 35 | x-moose: [majestic] 36 | x-served-by: [cache-ewr18125-EWR] 37 | x-timer: ['S1559882305.241736,VS0,VE62'] 38 | x-xss-protection: [1; mode=block] 39 | status: {code: 200, message: OK} 40 | version: 1 41 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # The following is a derivative work of 4 | # https://github.com/michael-lazar/rtv 5 | # license under MIT License. 6 | 7 | from __future__ import unicode_literals 8 | 9 | import os 10 | import logging 11 | import json 12 | from functools import partial 13 | 14 | import pytest 15 | from vcr import VCR 16 | from six.moves.urllib.parse import urlparse, parse_qs 17 | from tempfile import mkstemp 18 | 19 | os.environ["XDG_DATA_HOME"] = os.path.join(os.path.dirname(__file__), 'share') 20 | try: 21 | os.makedirs(os.environ["XDG_DATA_HOME"]) 22 | except OSError: 23 | if not os.path.isdir(os.environ["XDG_DATA_HOME"]): 24 | raise 25 | 26 | from nnreddit.authenticated_reddit import AuthenticatedReddit 27 | from rtv.config import TOKEN 28 | 29 | try: 30 | from unittest import mock 31 | except ImportError: 32 | import mock 33 | 34 | # Turn on autospec by default for convenience 35 | patch = partial(mock.patch, autospec=True) 36 | 37 | # Turn on logging, but disable vcr from spamming 38 | logging.basicConfig( 39 | level=logging.DEBUG, 40 | format='%(asctime)s:%(levelname)s:%(filename)s:%(lineno)d:%(message)s') 41 | for name in ['vcr.matchers', 'vcr.stubs']: 42 | logging.getLogger(name).disabled = True 43 | 44 | def pytest_addoption(parser): 45 | super_secret = os.path.join(os.path.dirname(TOKEN), 'super-secret-refresh-token') 46 | parser.addoption('--record-mode', dest='record_mode', default='none') 47 | parser.addoption('--token-file', dest='token_file', 48 | default=(super_secret if os.path.exists(super_secret) else TOKEN)) 49 | 50 | @pytest.fixture(scope='session') 51 | def vcr(request): 52 | def auth_matcher(r1, r2): 53 | return (r1.headers.get('authorization') == \ 54 | r2.headers.get('authorization')) 55 | 56 | def uri_with_query_matcher(r1, r2): 57 | "URI matcher that allows query params to appear in any order" 58 | p1, p2 = urlparse(r1.uri), urlparse(r2.uri) 59 | return (p1[:3] == p2[:3] and \ 60 | parse_qs(p1.query, True) == parse_qs(p2.query, True)) 61 | 62 | # Use `none` to use the recorded requests, and `once` to delete existing 63 | # cassettes and re-record. 64 | record_mode = request.config.option.record_mode 65 | assert record_mode in ('once', 'none') 66 | 67 | cassette_dir = os.path.join(os.path.dirname(__file__), 'cassettes') 68 | if not os.path.exists(cassette_dir): 69 | os.makedirs(cassette_dir) 70 | 71 | def scrub(tokens, replacement=''): 72 | def before_record_response(response): 73 | dikt = json.loads(response['body']['string'].decode('utf-8')) 74 | for token in tokens: 75 | dikt[token] = replacement 76 | response['body']['string'] = json.dumps(dikt) 77 | return response 78 | return before_record_response 79 | 80 | # https://github.com/kevin1024/vcrpy/pull/196 81 | vcr = VCR( 82 | record_mode=request.config.option.record_mode, 83 | filter_headers=[('Authorization', '**********')], 84 | filter_post_data_parameters=[('refresh_token', '**********'), 85 | ('code', '**********')], 86 | match_on=['method', 'uri_with_query', 'auth', 'body'], 87 | before_record_response=scrub(['access_token', 'refresh_token'], '**********'), 88 | cassette_library_dir=cassette_dir) 89 | vcr.register_matcher('auth', auth_matcher) 90 | vcr.register_matcher('uri_with_query', uri_with_query_matcher) 91 | return vcr 92 | 93 | @pytest.yield_fixture() 94 | def reddit(vcr, request): 95 | cassette_name = '%s.yaml' % request.node.name 96 | 97 | # Clear the cassette before running the test 98 | recording = (request.config.option.record_mode == 'once') 99 | if recording: 100 | filename = os.path.join(vcr.cassette_library_dir, cassette_name) 101 | if os.path.exists(filename): 102 | os.remove(filename) 103 | 104 | with vcr.use_cassette(cassette_name): 105 | logdir = os.path.join(os.path.dirname(__file__), 'log') 106 | try: 107 | os.makedirs(logdir) 108 | except OSError: 109 | if not os.path.isdir(logdir): 110 | raise 111 | kwargs = { 'token_file': ( \ 112 | mkstemp(dir='/var/tmp')[1] 113 | if recording else request.config.option.token_file ), 114 | 'history_file': mkstemp(dir='/var/tmp')[1], 115 | 'log_prefix': os.path.join(logdir, 'test_vcr.'), 116 | 'check_for_updates': False 117 | } 118 | reddit = AuthenticatedReddit(decode_html_entities=False, 119 | disable_update_check=True, 120 | **kwargs) 121 | yield reddit 122 | -------------------------------------------------------------------------------- /tests/nnreddit-test.el: -------------------------------------------------------------------------------- 1 | ;;; nnreddit-test.el --- Test utilities for nnreddit -*- lexical-binding: t; coding: utf-8 -*- 2 | 3 | ;; The following is a derivative work of 4 | ;; https://github.com/millejoh/emacs-ipython-notebook 5 | ;; licensed under GNU General Public License v3.0. 6 | 7 | (custom-set-variables 8 | '(gnus-before-startup-hook (quote (toggle-debug-on-error))) 9 | '(nnreddit-venv nil) 10 | `(nnreddit-python-command ,(or (getenv "PYTHON") "python")) 11 | '(auto-revert-verbose nil) 12 | '(auto-revert-stop-on-user-input nil) 13 | '(gnus-read-active-file nil) 14 | `(gnus-home-directory ,(file-name-directory load-file-name)) 15 | '(gnus-use-dribble-file nil) 16 | '(gnus-read-newsrc-file nil) 17 | '(gnus-save-killed-list nil) 18 | '(gnus-save-newsrc-file nil) 19 | '(gnus-secondary-select-methods (quote ((nnreddit "")))) 20 | '(gnus-select-method (quote (nnnil))) 21 | '(gnus-message-highlight-citation nil) 22 | '(gnus-verbose 8) 23 | '(gnus-interactive-exit (quote quiet))) 24 | 25 | (require 'nnreddit) 26 | (require 'cl-lib) 27 | (require 'ert) 28 | (require 'message) 29 | 30 | (with-eval-after-load "python" 31 | (setq python-indent-guess-indent-offset-verbose nil)) 32 | 33 | (defun nnreddit-test-wait-for (predicate &optional predargs ms interval continue) 34 | "Wait until PREDICATE function returns non-`nil'. 35 | PREDARGS is argument list for the PREDICATE function. 36 | MS is milliseconds to wait. INTERVAL is polling interval in milliseconds." 37 | (let* ((int (aif interval it (aif ms (max 300 (/ ms 10)) 300))) 38 | (count (max 1 (if ms (truncate (/ ms int)) 25)))) 39 | (unless (or (cl-loop repeat count 40 | when (apply predicate predargs) 41 | return t 42 | do (sleep-for 0 int)) 43 | continue) 44 | (error "Timeout: %s" predicate)))) 45 | 46 | (mapc (lambda (sf) 47 | (add-function 48 | :around (symbol-function sf) 49 | (lambda (f &rest args) 50 | (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest args) t)) 51 | ((symbol-function 'y-or-n-p) (lambda (&rest args) t))) 52 | (apply f args))))) 53 | '(message-cancel-news message-send-news find-file-noselect)) 54 | 55 | (provide 'nnreddit-test) 56 | -------------------------------------------------------------------------------- /tests/recorded.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # The following is a derivative work of 4 | # https://github.com/praw-dev/praw 5 | # licensed under BSD 2-Clause "Simplified" License. 6 | 7 | from betamax import Betamax 8 | from betamax.cassette.cassette import Placeholder 9 | import functools 10 | import json 11 | import logging 12 | from six.moves.urllib.parse import parse_qs 13 | 14 | __recordings__ = {} 15 | 16 | def scrub(interaction, current_cassette): 17 | request = interaction.data.get('request') or {} 18 | response = interaction.data.get('response') or {} 19 | 20 | # Exit early if the request did not return 200 OK because that's the 21 | # only time we want to look for tokens 22 | if not response or response['status']['code'] != 200: 23 | return 24 | 25 | for what in [r for r in [request, response] if r]: 26 | auths = what['headers'].get('Authorization') or [] 27 | for auth in auths: 28 | current_cassette.placeholders.append( 29 | Placeholder(placeholder='**********', replace=auth) 30 | ) 31 | 32 | body_string = what['body']['string'] 33 | try: 34 | dikt = json.loads(body_string) 35 | except: 36 | dikt = { k: v[0] for k,v in parse_qs(body_string).items() } 37 | for token in ['access_token', 'refresh_token']: 38 | if token in dikt: 39 | current_cassette.placeholders.append( 40 | Placeholder(placeholder='**********', replace=dikt[token]) 41 | ) 42 | 43 | with Betamax.configure() as config: 44 | config.cassette_library_dir = 'tests/cassettes' 45 | config.before_record(callback=scrub) 46 | 47 | def recorded(func): 48 | """Intercept point for Betamax. As a decorator for an 49 | AuthenticatedReddit method, it disallows reentrant calls to that 50 | method under record_mode: once.""" 51 | @functools.wraps(func) 52 | def wrapper(*args, **kwargs): 53 | reddit = args[0] 54 | http = reddit._core._requestor._http 55 | 56 | # Disable response compression in order to see the response bodies in 57 | # the betamax cassettes. 58 | http.headers["Accept-Encoding"] = "identity" 59 | 60 | with Betamax(http).use_cassette(func.__name__): 61 | return func(*args, **kwargs) 62 | return wrapper 63 | 64 | def recording_begin(reddit, cassette): 65 | if cassette in __recordings__: 66 | raise RuntimeError('Recording {} already in progress!'.format(cassette)) 67 | 68 | http = reddit._core._requestor._http 69 | 70 | # what praw does to prevent compression obscuring response bodies 71 | http.headers["Accept-Encoding"] = "identity" 72 | 73 | __recordings__[cassette] = Betamax(http).use_cassette(cassette).__enter__() 74 | 75 | def recording_end(cassette=None): 76 | if cassette and cassette not in __recordings__: 77 | raise RuntimeError('Recording {} not in progress!'.format(cassette)) 78 | 79 | if cassette is None: 80 | [c.__exit__() for c in __recordings__.values()] 81 | else: 82 | __recordings__[cassette].__exit__() 83 | del __recordings__[cassette] 84 | -------------------------------------------------------------------------------- /tests/share/nnreddit/refresh-token: -------------------------------------------------------------------------------- 1 | stub 2 | -------------------------------------------------------------------------------- /tests/test-uncacheable.el: -------------------------------------------------------------------------------- 1 | (require 'nnreddit-test) 2 | 3 | (ert-deftest nnreddit-should-not-cache () 4 | (should (string-match gnus-uncacheable-groups "nnreddit:emacs"))) 5 | 6 | -------------------------------------------------------------------------------- /tests/test_oauth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | try: 6 | from unittest import mock 7 | except ImportError: 8 | import mock 9 | 10 | def test_oauth(reddit): 11 | assert reddit._authorized_core is not None 12 | -------------------------------------------------------------------------------- /tools/install-virtualenv.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Create virtualenvs for python{2,3} for Travis CI on OSX 4 | 5 | set -x 6 | 7 | WORKDIR=${HOME}/local 8 | 9 | . tools/retry.sh 10 | 11 | if [ "x$TRAVIS_OS_NAME" = "xosx" ]; then 12 | brew list pyenv-virtualenv || HOMEBREW_NO_AUTO_UPDATE=1 brew install pyenv-virtualenv 13 | 14 | case "${TOXENV}" in 15 | py27) 16 | pyenv install -s 2.7.13 17 | pyenv virtualenv -f 2.7.13 py27 18 | ;; 19 | py35) 20 | pyenv install -s 3.5.2 21 | pyenv virtualenv -f 3.5.2 py35 22 | ;; 23 | esac 24 | fi 25 | -------------------------------------------------------------------------------- /tools/package-lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | . tools/retry.sh 4 | 5 | export EMACS="${EMACS:=emacs}" 6 | export BASENAME=$(basename "$1") 7 | 8 | ( cask emacs -Q --batch \ 9 | --visit "$1" \ 10 | --eval "(checkdoc-eval-current-buffer)" \ 11 | --eval "(princ (with-current-buffer checkdoc-diagnostic-buffer \ 12 | (buffer-string)))" \ 13 | 2>&1 | egrep -a "^$BASENAME:" ) && false 14 | 15 | # Reduce purity via: 16 | # --eval "(fset 'package-lint--check-defs-prefix (symbol-function 'ignore))" \ 17 | PKG_MAIN=$(cask files | egrep -- "pkg.el$") 18 | travis_retry cask emacs -Q --batch \ 19 | -l package-lint \ 20 | --eval "(let ((v (format \"%s.%s\" emacs-major-version emacs-minor-version))) (custom-set-variables (backquote (package-user-dir ,(concat \".cask/\" v)))))" \ 21 | -f package-initialize \ 22 | --eval "(push (quote (\"melpa\" . \"http://melpa.org/packages/\")) \ 23 | package-archives)" \ 24 | --eval "(setq package-lint-main-file (if (zerop (length \"${PKG_MAIN}\")) nil \"${PKG_MAIN}\"))" \ 25 | --eval "(package-refresh-contents)" \ 26 | -f package-lint-batch-and-exit "$1" 27 | -------------------------------------------------------------------------------- /tools/readme-sed.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # by mklement0 https://stackoverflow.com/a/29613573/5132008 4 | 5 | # Define sample multi-line literal. 6 | input=`cat` 7 | replace="$input" 8 | if [ ! -z "$3" ]; then 9 | replace=$(awk "/$3/,EOF { print \" \" \$0 }" <<<"$input") 10 | fi 11 | 12 | # Escape it for use as a Sed replacement string. 13 | IFS= read -d '' -r < <(sed -e ':a' -e '$!{N;ba' -e '}' -e 's/[&/\]/\\&/g; s/\n/\\&/g' <<<"$replace") 14 | replaceEscaped=${REPLY%$'\n'} 15 | 16 | # If ok, outputs $replace as is. 17 | sed "/$1/c\\$replaceEscaped" $2 18 | -------------------------------------------------------------------------------- /tools/recipe: -------------------------------------------------------------------------------- 1 | (nnreddit :repo "dickmao/nnreddit" :fetcher github 2 | :files ("lisp/*.el" "setup.py" "requirements.txt" "nnreddit")) 3 | -------------------------------------------------------------------------------- /tools/retry.sh: -------------------------------------------------------------------------------- 1 | # Copied retry logic from Travis CI [http://bit.ly/2jPDCtV] 2 | # Author: gonewest818 https://github.com/clojure-emacs/cider/pull/2139 3 | 4 | ANSI_RED="\033[31;1m" 5 | ANSI_GREEN="\033[32;1m" 6 | ANSI_RESET="\033[0m" 7 | ANSI_CLEAR="\033[0K" 8 | 9 | travis_retry() { 10 | local result=0 11 | local count=1 12 | while [ $count -le 3 ]; do 13 | [ $result -ne 0 ] && { 14 | echo -e "\n${ANSI_RED}The command \"$@\" failed. Retrying, $count of 3.${ANSI_RESET}\n" >&2 15 | } 16 | "$@" 17 | result=$? 18 | [ $result -eq 0 ] && break 19 | count=$(($count + 1)) 20 | sleep 1 21 | done 22 | 23 | [ $count -gt 3 ] && { 24 | echo -e "\n${ANSI_RED}The command \"$@\" failed 3 times.${ANSI_RESET}\n" >&2 25 | } 26 | 27 | return $result 28 | } 29 | --------------------------------------------------------------------------------