Merge branch 'rewrite' into main
continuous-integration/drone/push Build is passing Details

This commit is contained in:
tastytea 2019-12-28 08:18:22 +01:00
commit 1d6f4eb172
Signed by: tastytea
GPG Key ID: CFC39497F1B26E07
26 changed files with 1679 additions and 913 deletions

View File

@ -1,123 +1,106 @@
pipeline:
download:
image: plugins/download
pull: true
source: https://schlomp.space/attachments/e1c1e64b-1192-4037-aad4-95238ad648b0
destination: mastodon-cpp.deb
# -*- fill-column: 1000 -*-
kind: pipeline
name: build x86_64
gcc8:
image: debian:stretch-slim
pull: true
environment:
- LANG=C.utf8
commands:
- rm /etc/apt/apt.conf.d/docker-clean
- apt-get update -q
- echo "APT::Default-Release \"stretch\";" >> /etc/apt/apt.conf.d/00default_release
- echo "deb http://deb.debian.org/debian sid main" >> /etc/apt/sources.list.d/sid.list
- echo "deb http://ppa.launchpad.net/ubuntu-toolchain-r/test/ubuntu xenial main" >> /etc/apt/sources.list.d/ubuntu-toolchain-r.list
- apt-get install -qy gnupg
- gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 0x60c317803a41ba51845e371a1e9377a2ba9ef27f
- gpg --armor --export 0x60c317803a41ba51845e371a1e9377a2ba9ef27f | apt-key add -
- apt-get update -q
- apt-get install -qy build-essential cmake pkg-config
- apt-get install -qy -t xenial g++-8
- update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-8 100
- update-alternatives --set g++ /usr/bin/g++-8
- apt-get install -qy libcurl4-openssl-dev libjsoncpp-dev libboost-all-dev
- apt-get install -qy -t sid libcurlpp-dev
- dpkg -i mastodon-cpp.deb
- rm -rf build && mkdir -p build && cd build
- cmake ..
- make VERBOSE=1
- make install DESTDIR=install
volumes:
- /var/cache/debian-package-cache:/var/cache/apt/archives
volumes:
- name: debian-package-cache
host:
path: /var/cache/debian-package-cache
gcc7:
image: debian:stretch-slim
pull: true
environment:
- LANG=C.utf8
commands:
- rm /etc/apt/apt.conf.d/docker-clean
- apt-get update -q
- echo "APT::Default-Release \"stretch\";" >> /etc/apt/apt.conf.d/00default_release
- echo "deb http://deb.debian.org/debian sid main" >> /etc/apt/sources.list.d/sid.list
- echo "deb http://ppa.launchpad.net/ubuntu-toolchain-r/test/ubuntu xenial main" >> /etc/apt/sources.list.d/ubuntu-toolchain-r.list
- apt-get install -qy gnupg
- gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 0x60c317803a41ba51845e371a1e9377a2ba9ef27f
- gpg --armor --export 0x60c317803a41ba51845e371a1e9377a2ba9ef27f | apt-key add -
- apt-get update -q
- apt-get install -qy build-essential cmake pkg-config
- apt-get install -qy -t xenial g++-7
- update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-7 100
- update-alternatives --set g++ /usr/bin/g++-7
- apt-get install -qy libcurl4-openssl-dev libjsoncpp-dev libboost-all-dev
- apt-get install -qy -t sid libcurlpp-dev
- dpkg -i mastodon-cpp.deb
- rm -rf build && mkdir -p build && cd build
- cmake ..
- make VERBOSE=1
- make install DESTDIR=install
volumes:
- /var/cache/debian-package-cache:/var/cache/apt/archives
trigger:
event:
exclude:
- tag
gcc5:
image: debian:stretch-slim
pull: true
environment:
- LANG=C.utf8
commands:
- rm /etc/apt/apt.conf.d/docker-clean
- apt-get update -q
- echo "APT::Default-Release \"stretch\";" >> /etc/apt/apt.conf.d/00default_release
- echo "deb http://deb.debian.org/debian sid main" >> /etc/apt/sources.list.d/sid.list
- echo "deb http://ppa.launchpad.net/ubuntu-toolchain-r/test/ubuntu xenial main" >> /etc/apt/sources.list.d/ubuntu-toolchain-r.list
- apt-get install -qy gnupg
- gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 0x60c317803a41ba51845e371a1e9377a2ba9ef27f
- gpg --armor --export 0x60c317803a41ba51845e371a1e9377a2ba9ef27f | apt-key add -
- apt-get update -q
- apt-get install -qy build-essential cmake pkg-config
- apt-get install -qy -t xenial g++-5
- update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-5 100
- update-alternatives --set g++ /usr/bin/g++-5
- apt-get install -qy libcurl4-openssl-dev libjsoncpp-dev libboost-all-dev
- apt-get install -qy -t sid libcurlpp-dev
- dpkg -i mastodon-cpp.deb
- rm -rf build && mkdir -p build && cd build
- cmake ..
- make VERBOSE=1
- make install DESTDIR=install
volumes:
- /var/cache/debian-package-cache:/var/cache/apt/archives
steps:
- name: download mastodon-cpp for buster
image: plugins/download
settings:
source: https://schlomp.space/tastytea/mastodon-cpp/releases/download/0.111.5/libmastodon-cpp_0.111.5-0_buster_amd64.deb
destination: mastodon-cpp_buster.deb
gcc6:
image: debian:stretch-slim
pull: true
environment:
- LANG=C.utf8
commands:
- rm /etc/apt/apt.conf.d/docker-clean
- echo "APT::Default-Release \"stretch\";" >> /etc/apt/apt.conf.d/00default_release
- echo "deb http://deb.debian.org/debian sid main" >> /etc/apt/sources.list.d/sid.list
- apt-get update -q
- apt-get install -qy build-essential cmake pkg-config
- apt-get install -qy libcurl4-openssl-dev libjsoncpp-dev libboost-all-dev
- apt-get install -qy -t sid libcurlpp-dev
- dpkg -i mastodon-cpp.deb
- rm -rf build && mkdir -p build && cd build
- cmake ..
- make VERBOSE=1
- make install DESTDIR=install
volumes:
- /var/cache/debian-package-cache:/var/cache/apt/archives
# - name: download mastodon-cpp for bionic
# image: plugins/download
# settings:
# source: https://schlomp.space/tastytea/mastodon-cpp/releases/download/0.111.5/libmastodon-cpp_0.111.5-0_bionic_amd64.deb
# destination: mastodon-cpp_bionic.deb
notify:
image: drillster/drone-email
pull: true
# Workaround until the debian packages are fixed.
- name: download restclient-cpp source
image: plugins/download
settings:
source: https://github.com/mrtazz/restclient-cpp/archive/0.5.1.tar.gz
destination: restclient-cpp.tar.gz
- name: GCC 8 on Debian buster
image: debian:buster-slim
pull: always
environment:
CXX: g++-8
CXXFLAGS: -pipe -O2
DEBIAN_FRONTEND: noninteractive
LANG: C.utf8
commands:
- rm /etc/apt/apt.conf.d/docker-clean
- alias apt-get='rm -f /var/cache/apt/archives/lock && apt-get'
- apt-get update -q
- echo "deb https://packagecloud.io/mrtazz/restclient-cpp/debian/ buster main" > /etc/apt/sources.list.d/restclient-cpp.list
- apt-get install -qq debian-archive-keyring curl gnupg apt-transport-https
- curl -L https://packagecloud.io/mrtazz/restclient-cpp/gpgkey | apt-key add -
- apt-get update -q
- apt-get install -qq build-essential cmake asciidoc
- apt-get install -qq libboost-filesystem-dev libboost-log-dev libboost-regex-dev libjsoncpp-dev catch libcurl4-openssl-dev restclient-cpp libpoco-dev
- apt-get -qq install ./mastodon-cpp_buster.deb
- tar -xf restclient-cpp.tar.gz
- cd restclient-cpp-*
- cmake -DCMAKE_INSTALL_PREFIX=/usr .
- cmake --build .
- make install
- cd ..
- rm -rf build && mkdir -p build && cd build
- cmake ..
- make VERBOSE=1
- make install DESTDIR=install
volumes:
- name: debian-package-cache
path: /var/cache/apt/archives
# - name: GCC 7 on Ubuntu bionic
# image: ubuntu:bionic
# pull: always
# environment:
# CXX: g++-7
# CXXFLAGS: -pipe -O2
# DEBIAN_FRONTEND: noninteractive
# LANG: C.utf8
# commands:
# - rm /etc/apt/apt.conf.d/docker-clean
# - alias apt-get='rm -f /var/cache/apt/archives/lock && apt-get'
# - apt-get update -q
# - echo "deb https://packagecloud.io/mrtazz/restclient-cpp/ubuntu/ bionic main" > /etc/apt/sources.list.d/restclient-cpp.list
# - apt-get install -qq curl gnupg apt-transport-https
# - curl -L https://packagecloud.io/mrtazz/restclient-cpp/gpgkey | apt-key add -
# - apt-get update -q
# - apt-get install -qq build-essential cmake asciidoc
# - apt-get install -qq libboost-filesystem-dev libboost-log-dev libboost-regex-dev libjsoncpp-dev catch libcurl4-openssl-dev restclient-cpp libpoco-dev
# - apt-get -qq install ./mastodon-cpp_bionic.deb
# - rm -rf build && mkdir -p build && cd build
# - cmake ..
# - make VERBOSE=1
# - make install DESTDIR=install
# volumes:
# - name: debian-package-cache
# path: /var/cache/apt/archives
- name: notify
image: drillster/drone-email
pull: always
settings:
host: cryptoparty-celle.de
secrets: [ email_username, email_password ]
from: drone@tzend.de
when:
status: [ changed, failure ]
username:
from_secret: email_username
password:
from_secret: email_password
when:
status: [ changed, failure ]

20
.editorconfig Normal file
View File

@ -0,0 +1,20 @@
# Configuration file for EditorConfig.
# More information is available under <https://editorconfig.org/>.
root = true
[*]
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 80
[*.?pp]
indent_size = 4
tab_width = 4
[{CMakeLists.txt,*.cmake}]
indent_size = 2
tab_width = 2

View File

@ -1,47 +1,40 @@
cmake_minimum_required (VERSION 3.6)
# Support version 3.9 and above, but use policy settings up to 3.14.
# 3.9 is needed for project description.
cmake_minimum_required(VERSION 3.9...3.14)
# Ranges are supported from 3.12, set policy to current for < 3.12.
if(${CMAKE_VERSION} VERSION_LESS 3.12)
cmake_policy(VERSION ${CMAKE_MAJOR_VERSION}.${CMAKE_MINOR_VERSION})
endif()
# Global build options.
set(CMAKE_BUILD_TYPE "Release" CACHE STRING "The type of build.")
option(BUILD_SHARED_LIBS "Build shared libraries." YES)
project (mastorss
VERSION 0.9.0
LANGUAGES CXX
)
VERSION 0.9.9999
DESCRIPTION "Another RSS to Mastodon bot."
LANGUAGES CXX)
include(GNUInstallDirs)
find_package(CURL REQUIRED)
find_package(Boost REQUIRED COMPONENTS system filesystem)
find_package(PkgConfig REQUIRED)
pkg_check_modules(CURLPP REQUIRED curlpp)
pkg_check_modules(JSONCPP REQUIRED jsoncpp)
list(APPEND CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/cmake")
set(CMAKE_CXX_STANDARD 14)
# Project build options.
option(WITH_MAN "Compile and install manpage." YES)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
include_directories(${PROJECT_SOURCE_DIR}/src)
include_directories(${PROJECT_BINARY_DIR})
include(debug_flags)
include_directories(${CURL_INCLUDE_DIR})
include_directories(${CURLPP_INCLUDE_DIR})
include_directories(${JSONCPP_INCLUDE_DIR})
include_directories(${Boost_INCLUDE_DIRS})
include(GNUInstallDirs)
link_directories(${CURL_LIBRARY_DIRS})
link_directories(${CURLPP_LIBRARY_DIRS})
link_directories(${JSONCPP_LIBRARY_DIRS})
link_directories(${Boost_LIBRARY_DIRS})
add_subdirectory(src)
add_definitions(${Boost_DEFINITIONS})
# Write version in header
configure_file (
"${PROJECT_SOURCE_DIR}/src/version.hpp.in"
"${PROJECT_BINARY_DIR}/version.hpp"
)
file(GLOB sources src/*.cpp)
add_executable(mastorss ${sources})
target_link_libraries(mastorss
mastodon-cpp ${CURLPP_LIBRARIES}
${JSONCPP_LIBRARIES} ${Boost_LIBRARIES} stdc++fs)
install(TARGETS mastorss DESTINATION ${CMAKE_INSTALL_BINDIR})
if(WITH_MAN)
add_subdirectory(man)
endif()
install(FILES watchwords.json
DESTINATION ${CMAKE_INSTALL_DATADIR}/mastorss)
DESTINATION "${CMAKE_INSTALL_DATADIR}/mastorss")
# include(cmake/packages.cmake)

49
CODE_OF_CONDUCT.adoc Normal file
View File

@ -0,0 +1,49 @@
:contact-coc: tastytea@tastytea.de
== Code of Conduct
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, education, ethnicity, gender identity and expression, level of
experience, nationality, personal appearance, race, religion, sex
characteristics, sexual identity and orientation or socio-economic status.
=== Examples
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language.
* Being respectful of differing viewpoints and experiences.
* Gracefully accepting constructive criticism.
* Focusing on what is best for the community.
* Showing empathy towards other community members.
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances.
* Trolling, insulting/derogatory comments, and personal attacks.
* Public or private harassment.
* Publishing others private information, such as a physical or electronic
address, without explicit permission.
=== Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at {contact-coc}.
All complaints will be reviewed and investigated and will result in a response
that is deemed necessary and appropriate to the circumstances. The project team
is obligated to maintain confidentiality with regard to the reporter of an
incident.
=== Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 1.4,
available at
https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

29
CONTRIBUTING.adoc Normal file
View File

@ -0,0 +1,29 @@
:project: mastorss
:uri-base: https://schlomp.space/tastytea/{project}
:uri-coc: {uri-base}/src/branch/main/CODE_OF_CONDUCT.adoc
:contact-email: tastytea@tastytea.de
:contact-xmpp: {contact-email}
:contact-fediverse: https://likeable.space/users/tastytea
== How to contribute
Read the link:{uri-coc}[Code of Conduct].
=== Reporting bugs or suggesting enhancements
Before reporting a bug, please
https://schlomp.space/tastytea/{project}/issues[perform a search] to see if the
problem has already been reported. If it has, add a comment to the existing
issue instead of opening a new one. Same for enhancements.
You can also contact me via mailto:{contact-email}[E-Mail],
link:xmpp:{contact-xmpp}[XMPP] or the {contact-fediverse}[Fediverse] if you
don't want to open an account.
=== Pull requests
Please use similar coding conventions as the rest of the project. The basic rule
to remember is to write code in the same style as the existing/surrounding code.
You can also send me your patches via mailto:{contact-email}[E-Mail], ideally
using `git format-patch` or `git send-email`.

70
README.adoc Normal file
View File

@ -0,0 +1,70 @@
:toc: preamble
:project: mastorss
:uri-base: https://schlomp.space/tastytea/{project}
:uri-branch-main: {uri-base}/src/branch/main
:uri-gcc: https://gcc.gnu.org/
:uri-cmake: https://cmake.org/
:uri-asciidoc: http://asciidoc.org/
:uri-catch: https://github.com/catchorg/Catch2
:uri-boost: https://www.boost.org/
:uri-mastodon-cpp: https://schlomp.space/tastytea/mastodon-cpp
:uri-jsoncpp: https://github.com/open-source-parsers/jsoncpp
:uri-restclient-cpp: http://code.mrtazz.com/restclient-cpp/
:uri-poco: https://pocoproject.org/
*mastorss* reads RSS feeds and posts the items via the Mastodon API. Does not
support Atom at the moment.
== Install
=== Dependencies
* Tested OS: Linux
* C++ compiler (tested: link:{uri-gcc}[gcc] 7/8/9)
* link:{uri-cmake}[cmake] (at least: 3.9)
* link:{uri-boost}[boost] (at least: 1.62)
* link:{uri-mastodon-cpp}[mastodon-cpp] (at least: 0.111.3)
** link:{uri-poco}[POCO] (at least: 1.7)
* link:{uri-jsoncpp}[jsoncpp] (at least: 1.7)
* link:{uri-restclient-cpp}[restclient-cpp] (at least: 0.5.1)
=== Get sourcecode
==== Development version
[source,shell]
--------------------------------------------------------------------------------
git clone https://schlomp.space/tastytea/mastorss.git
--------------------------------------------------------------------------------
=== Compile
[source,shell]
--------------------------------------------------------------------------------
mkdir build
cd build/
cmake ..
cmake --build .
--------------------------------------------------------------------------------
Install with `make install`.
== Usage
See link:{uri-branch-main}/man/mastorss.1.adoc[manpage].
=== Error codes
Same as
link:{uri-mastodon-cpp}/src/branch/master/README.adoc#_error_codes[mastodon-cpp],
plus:
[%autowidth,cols=">,<"]
|===========================================================
| Code | Explanation
| 1 | Could not send post for unknown reasons.
|===========================================================
include::{uri-base}/raw/branch/main/CONTRIBUTING.adoc[]

View File

@ -1,90 +0,0 @@
**mastorss** dumps RSS feeds into a mastodon account.
Supports RSS 2.0 but not RSS 0.92. Does not support Atom at the moment.
\<item\>s in feeds must have \<link\>, \<title\> and \<description\>.
The documentation is far from complete, sorry.
# Install
## Dependencies
* Tested OS: Linux
* C++ compiler (tested: gcc 5 / 6 / 7 / 8)
* [cmake](https://cmake.org/) (tested: 3.9 / 3.12)
* [boost](http://www.boost.org/) (tested: 1.65 / 1.62)
* [curlpp](http://www.curlpp.org/) (tested: 0.8)
* [mastodon-cpp](https://schlomp.space/tastytea/mastodon-cpp) (at least:
0.105.0)
* [jsoncpp](https://github.com/open-source-parsers/jsoncpp) (tested: 1.8 / 1.7)
## Get sourcecode
### Development version
git clone https://schlomp.space/tastytea/mastorss.git
## Compile
mkdir build
cd build/
cmake ..
make
## Install
Install with `make install`.
# Usage
Put `watchwords.json` into `~/.config/mastorss/`. Launch with profile name. The
first occurence of every watchword in an RSS item will be turned into a hashtag.
For profile-specific watchwords see the example in `watchwords.json`. In the
first run only the newest entry is tooted.
The profile can't be named "global".
## Example config file
${HOME}/.config/mastorss/config-example.json
{
"example": {
"instance": "botsin.space",
"feedurl": "https:\/\/example.com\/feed.rss",
"access_token": "123abc",
"max_size": 400,
"titles_only": false,
"skip":
[
"If the entry starts with this, skip it",
"Skip me too!"
],
"fixes":
[
"delete this",
"<p>[Rr]ead more(\.{3}|…)</p>"
],
"append": "#bot"
}
}
## Error codes
Same as
[mastodon-cpp](https://schlomp.space/tastytea/mastodon-cpp/src/branch/master/README.adoc#_error_codes),
plus:
| Code | Explanation |
| --------: |:----------------------------------------|
| 1 | Could not send post for unknown reasons |
# Copyright
``` text
Copyright © 2018, 2019 tastytea <tastytea@tastytea.de>.
License GPLv3: GNU GPL version 3 <https://www.gnu.org/licenses/gpl-3.0.html>.
This program comes with ABSOLUTELY NO WARRANTY. This is free software,
and you are welcome to redistribute it under certain conditions.
```

58
cmake/debug_flags.cmake Normal file
View File

@ -0,0 +1,58 @@
# Set compiler flags for Debug builds.
# Only has an effect on GCC/Clang >= 5.0.
set(DEBUG_CXXFLAGS "")
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang"
AND NOT CMAKE_CXX_COMPILER_VERSION VERSION_LESS "5")
list(APPEND DEBUG_CXXFLAGS
"-Wall"
"-Wextra"
"-Wpedantic"
"-Wuninitialized"
"-Wshadow"
"-Wnon-virtual-dtor"
"-Wconversion"
"-Wsign-conversion"
"-Wold-style-cast"
"-Wzero-as-null-pointer-constant"
"-Wmissing-declarations"
"-Wcast-align"
"-Wunused"
"-Woverloaded-virtual"
"-Wdouble-promotion"
"-Wformat=2"
"-ftrapv"
"-fsanitize=undefined"
"-g"
"-Og"
"-fno-omit-frame-pointer")
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
list(APPEND DEBUG_CXXFLAGS
"-Wlogical-op"
"-Wuseless-cast")
if(NOT CMAKE_CXX_COMPILER_VERSION VERSION_LESS "6")
list(APPEND DEBUG_CXXFLAGS
"-Wmisleading-indentation"
"-Wduplicated-cond"
"-Wnull-dereference")
if(NOT CMAKE_CXX_COMPILER_VERSION VERSION_LESS "7")
list(APPEND DEBUG_CXXFLAGS
"-Wduplicated-branches")
endif()
endif()
endif()
add_compile_options("$<$<CONFIG:Debug>:${DEBUG_CXXFLAGS}>")
set(DEBUG_LDFLAGS
"-fsanitize=undefined")
# add_link_options was introduced in version 3.13.
if(${CMAKE_VERSION} VERSION_LESS 3.13)
set(CMAKE_SHARED_LINKER_FLAGS_DEBUG "${DEBUG_LDFLAGS}")
else()
add_link_options("$<$<CONFIG:Debug>:${DEBUG_LDFLAGS}>")
endif()
else()
message(STATUS
"No additional compiler flags were set, "
"because your compiler was not anticipated.")
endif()

13
man/CMakeLists.txt Normal file
View File

@ -0,0 +1,13 @@
include(GNUInstallDirs)
add_custom_command(
OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}.1"
WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}"
DEPENDS "${PROJECT_NAME}.1.adoc"
COMMAND "${PROJECT_SOURCE_DIR}/man/build_manpage.sh"
ARGS "${PROJECT_VERSION}")
add_custom_target(man ALL DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}.1")
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}.1
DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)

13
man/build_manpage.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/sh
name="mastorss"
if [ -n "${1}" ]; then
dir=$(dirname "${0}")
version=${1}
cp -vf "${dir}/${name}.1.adoc" .
sed -Ei "s/(Revision: +)[0-9]+\.[0-9]\.[0-9]/\1${version}/" "${name}.1.adoc"
a2x --doctype manpage --format manpage --no-xmllint "${name}.1.adoc"
else
echo "usage: ${0} VERSION" >&2
fi

190
man/mastorss.1.adoc Normal file
View File

@ -0,0 +1,190 @@
= mastorss(1)
:doctype: manpage
:Author: tastytea
:Email: tastytea@tastytea.de
:Date: 2019-12-25
:Revision: 0.0.0
:man source: mastorss
:man manual: General Commands Manual
== NAME
mastorss - Another RSS to Mastodon bot.
== SYNOPSIS
*mastorss* [--help|--version] <profile>
== DESCRIPTION
*mastorss* reads RSS feeds and posts the items via the Mastodon API. Does not
support Atom at the moment.
== OPTIONS
*--help*::
Show help message.
*--version*::
Show version, copyright and license.
== USAGE
Put `watchwords.json` into `${XDG_CONFIG_HOME}/mastorss/`. Launch with profile
name. The first occurence of every watchword in an RSS item will be turned into
a hashtag. For profile-specific watchwords see the example in
`watchwords.json`. In the first run only the newest entry is posted.
The profile is the identifier for a feed and can't be named "global".
.Launch mastorss with the profile “example”.
================================================================================
[source,shellsession]
--------------------------------------------------------------------------------
% mastorss example
--------------------------------------------------------------------------------
================================================================================
=== Configuration
If the profile does not exist yet, a configuration will be created interactively
and then saved to `${XDG_CONFIG_HOME}/mastorss/config-<profile>.json`.
*access_token*::
The API token needed to communicate with the Mastodon API on the _instance_
you're using to post.
*append*::
This string will be appended to every post.
*feedurl*::
The URI of the source feed.
*fixes*::
Array of regular expressions that should be deleted from the text. For
information about the syntax see *perlre*(1).
*instance*::
Hostname of the instance you're using to post.
*interval*::
Time to wait between posts.
*max_size*::
Maximum number of characters a post can have.
*skip*::
Array of strings with titles of posts that should be skipped. Matched against
the beginning of the title; this means: “Good Day” will match “Good Day in
Happytown” but not “A Good Day in Happytown”.
*titles_as_cw*::
If true, the title will be used as the subject (also known as spoiler warning or
content warning) of the post.
*titles_only*::
If true, only post titles, no descriptions.
== EXAMPLES
=== Configuration file
[source,json]
--------------------------------------------------------------------------------
{
"example" :
{
"access_token" : "123abc",
"append" : "#bot",
"feedurl" : "https://example.com/feed.rss",
"fixes" :
[
"delete this",
"<p>[Rr]ead more(\.{3}|…)</p>"
],
"instance" : "newsbots.eu",
"interval" : 600,
"max_size" : 500,
"skip" :
[
"If the title starts with this, skip it",
"Skip me too!"
],
"titles_as_cw" : true,
"titles_only" : false
}
}
--------------------------------------------------------------------------------
== PROTOCOL SUPPORT
Currently only HTTP and HTTPS are supported.
// == PROXY SERVERS
// Since mastorss is built on libcurl, it respects the same proxy environment
// variables. See *curl*(1), section _ENVIRONMENT_.
// .Tunnel connections through tor.
// ================================================================================
// [source,shell]
// --------------------------------------------------------------------------------
// ALL_PROXY="socks4a://[::1]:9050" mastorss example
// --------------------------------------------------------------------------------
// ================================================================================
== PROXY SERVERS
mastorss supports HTTP proxies set via the environment variable
_http_proxy_. Accepted format: _\http://[user[:password]@]host[:port]/_ No SOCKS
proxy support yet, sorry.
.Use mastorss with a proxy server
================================================================================
[source,shellsession]
--------------------------------------------------------------------------------
% http_proxy="http://localhost:3128/" mastorss example
--------------------------------------------------------------------------------
================================================================================
== FILES
*Configuration file directory*::
`${XDG_CONFIG_HOME}/mastorss/`
`${XDG_CONFIG_HOME}` is usually `~/.config`.
== ERROR CODES
[cols=">,<"]
|===========================================================
| Code | Explanation
| 1 | No profile specified.
| 2 | Network error.
| 3 | File error.
| 4 | Mastodon API error.
| 9 | Unknown error.
|===========================================================
== DEBUGGING
Define the variable `MASTORSS_DEBUG` to enable debug output.
.Debug mastorss while using the profile “example”.
================================================================================
[source,shell]
--------------------------------------------------------------------------------
MASTORSS_DEBUG=1 mastorss example
--------------------------------------------------------------------------------
================================================================================
== REPORTING BUGS
Bugtracker: https://schlomp.space/tastytea/mastorss/issues
E-mail: tastytea@tastytea.de
== SEE ALSO
*perlre*(1), *crontab*(1), *crontab*(5)

40
src/CMakeLists.txt Normal file
View File

@ -0,0 +1,40 @@
include(GNUInstallDirs)
# The minimum versions should be in Debian oldstable, if possible.
find_package(Boost 1.62 REQUIRED COMPONENTS filesystem log regex)
find_package(jsoncpp REQUIRED CONFIG) # 1.7.4 (Debian buster) has no version.
find_package(CURL 7.52 REQUIRED)
find_package(Threads REQUIRED)
find_package(restclient-cpp 0.5 CONFIG)
find_package(mastodon-cpp REQUIRED CONFIG)
if(NOT ${restclient-cpp_FOUND})
find_file(restclient_h NAMES "restclient-cpp/restclient.h"
PATHS "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_INCLUDEDIR}")
if("${restclient_h}" STREQUAL "restclient_h-NOTFOUND")
message(FATAL_ERROR "Could not find restclient-cpp.")
else()
message(WARNING
"Your distribution of restclient-cpp doesn't contain the *Config.cmake "
"recipes, but the files seem to be in the standard directories. "
"Let's hope this works.")
endif()
endif()
# Write version in header.
configure_file (
"${PROJECT_SOURCE_DIR}/src/version.hpp.in"
"${PROJECT_BINARY_DIR}/version.hpp")
include_directories("${PROJECT_BINARY_DIR}")
file(GLOB sources *.cpp)
add_executable(mastorss ${sources})
target_link_libraries(mastorss
PRIVATE
jsoncpp restclient-cpp mastodon-cpp::mastodon-cpp
Boost::filesystem Boost::log Boost::regex)
if(BUILD_SHARED_LIBS)
target_compile_definitions(mastorss PRIVATE "BOOST_ALL_DYN_LINK=1")
endif()
install(TARGETS mastorss DESTINATION "${CMAKE_INSTALL_BINDIR}")

View File

@ -1,5 +1,5 @@
/* This file is part of mastorss.
* Copyright © 2018, 2019 tastytea <tastytea@tastytea.de>
* Copyright © 2019 tastytea <tastytea@tastytea.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,181 +14,261 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include <iostream>
#include <string>
#include <cstdint>
#include <fstream>
#include <sstream>
#include <experimental/filesystem>
#include <jsoncpp/json/json.h>
#include "config.hpp"
#include "exceptions.hpp"
#include <boost/log/trivial.hpp>
#include <mastodon-cpp/mastodon-cpp.hpp>
#include "mastorss.hpp"
using std::cout;
using std::cerr;
using std::cin;
using std::string;
namespace fs = std::experimental::filesystem;
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <utility>
std::uint16_t read_config(string &instance, string &access_token, string &feedurl)
namespace mastorss
{
bool config_changed = false;
using std::stoul;
using std::getenv;
using std::ifstream;
using std::ofstream;
using std::cin;
using std::cout;
using std::stringstream;
using std::getline;
using std::move;
// Read config file, get access token
std::ifstream file(filepath + "config-" + profile + ".json");
if (file.is_open())
std::ostream &operator <<(std::ostream &out, const ProfileData &data)
{
out << "access_token: \"" << data.access_token << "\", "
<< "append: \"" << data.append << "\", "
<< "feedurl: \"" << data.feedurl << "\", "
<< "fixes: [";
for (const auto &fix : data.fixes)
{
std::stringstream json;
json << file.rdbuf();
file.close();
json >> config;
out << '"' << fix << '"';
if (fix != *data.fixes.rbegin())
{
out << ", ";
}
}
out << "], "
<< "instance: \"" << data.instance << "\", "
<< "interval: " << data.interval << ", "
<< "last_guid: \"" << data.last_guid << "\", "
<< "max_size: " << data.max_size << ", "
<< "skip: [";
for (const auto &skip : data.skip)
{
out << '"' << skip << '"';
if (skip != *data.skip.rbegin())
{
out << ", ";
}
}
out << "], "
<< "titles_as_cw: " << data.titles_as_cw << ", "
<< "titles_only: " << data.titles_only;
instance = config[profile]["instance"].asString();
access_token = config[profile]["access_token"].asString();
feedurl = config[profile]["feedurl"].asString();
if (!config[profile]["max_size"].isNull())
{
max_size = config[profile]["max_size"].asUInt();
}
else
{
config[profile]["max_size"] = max_size;
config_changed = true;
}
return out;
}
Config::Config(string profile)
:_profile{move(profile)}
{
const fs::path filename = get_filename();
BOOST_LOG_TRIVIAL(debug) << "Config filename is: " << filename;
ifstream file{filename.c_str()};
if (file.good())
{
stringstream rawjson;
rawjson << file.rdbuf();
rawjson >> _json;
parse();
}
else
{
cout << "Config file not found. Building new one.\n";
fs::create_directory(filepath);
generate();
}
if (instance.empty())
{
cout << "Instance: ";
std::getline(cin, instance);
config[profile]["instance"] = instance;
config_changed = true;
}
if (access_token.empty())
{
cout << "No access token found.\n";
string client_id, client_secret, url;
Mastodon::API masto(instance, "");
Mastodon::return_call ret
= masto.register_app1("mastorss",
"urn:ietf:wg:oauth:2.0:oob",
"write",
"https://schlomp.space/tastytea/mastorss",
client_id,
client_secret,
url);
if (!ret)
{
string code;
cout << "Visit " << url << " to authorize this application.\n";
cout << "Insert code: ";
std::getline(cin, code);
ret = masto.register_app2(client_id,
client_secret,
"urn:ietf:wg:oauth:2.0:oob",
code,
access_token);
if (!ret)
{
config[profile]["access_token"] = access_token;
config_changed = true;
}
else
{
cerr << "Error code: " << ret.error_code << '\n';
return ret.error_code;
}
}
else
{
cerr << "Error code: " << ret.error_code << '\n';
return ret.error_code;
}
}
if (feedurl.empty())
{
cout << "feedurl: ";
std::getline(cin, feedurl);
config[profile]["feedurl"] = feedurl;
config_changed = true;
}
if (config[profile]["titles_only"].isNull())
{
string titles_only;
cout << "post only titles? [y/n]: ";
std::getline(cin, titles_only);
if (titles_only[0] == 'y')
{
config[profile]["titles_only"] = true;
}
else
{
config[profile]["titles_only"] = false;
}
config_changed = true;
}
if (config[profile]["titles_as_cw"].isNull())
{
string titles_as_cw;
cout << "Use titles as CW? [y/n]: ";
std::getline(cin, titles_as_cw);
if (titles_as_cw[0] == 'y')
{
config[profile]["titles_as_cw"] = true;
}
else
{
config[profile]["titles_as_cw"] = false;
}
config_changed = true;
}
if (config[profile]["append"].isNull())
{
string append;
cout << "Append this string to each post []: ";
std::getline(cin, append);
config[profile]["append"] = append;
config_changed = true;
}
if (config[profile]["interval"].isNull())
{
string interval;
cout << "Interval between posts in seconds [60]: ";
std::getline(cin, interval);
if (interval.empty())
{
interval = "60";
}
config[profile]["interval"] = Json::Value::UInt64(std::stoul(interval));
config_changed = true;
}
if (config_changed)
{
write_config();
}
return 0;
}
bool write_config()
fs::path Config::get_filename() const
{
std::ofstream outfile(filepath + "config-" + profile + ".json");
if (outfile.is_open())
{
outfile.write(config.toStyledString().c_str(),
config.toStyledString().length());
outfile.close();
char *envdir = getenv("XDG_CONFIG_HOME");
fs::path dir;
return true;
if (envdir != nullptr)
{
dir = envdir;
}
else
{
envdir = getenv("HOME");
if (envdir != nullptr)
{
dir = fs::path{envdir} /= ".config";
}
else
{
throw FileException{"Couldn't find configuration directory."};
}
}
return false;
dir /= "mastorss";
if (fs::create_directories(dir))
{
BOOST_LOG_TRIVIAL(debug) << "Created config dir: " << dir;
}
return dir /= "config-" + _profile + ".json";
}
void Config::generate()
{
string line;
cout << "Instance (domain): ";
getline(cin, line);
data.instance = line;
data.access_token = get_access_token(line);
cout << "URL of the feed: ";
std::getline(cin, line);
data.feedurl = line;
cout << "Post only titles? [y/n]: ";
std::getline(cin, line);
if (line[0] == 'y')
{
data.titles_only = true;
}
else
{
data.titles_only = false;
}
cout << "Post titles as cw? [y/n]: ";
std::getline(cin, line);
if (line[0] == 'y')
{
data.titles_as_cw = true;
}
else
{
data.titles_as_cw = false;
}
cout << "Append this string to each post: ";
std::getline(cin, line);
data.append = line;
cout << "Interval between posts in seconds [30]: ";
std::getline(cin, line);
if (line.empty())
{
line = "30";
}
data.interval = static_cast<uint32_t>(stoul(line));
cout << "Maximum size of posts [500]: ";
std::getline(cin, line);
if (line.empty())
{
line = "500";
}
data.max_size = stoul(line);
BOOST_LOG_TRIVIAL(debug) << "Generated configuration.";
write();
}
string Config::get_access_token(const string &instance) const
{
string client_id;
string client_secret;
string url;
Mastodon::API masto(instance, "");
auto ret = masto.register_app1("mastorss", "urn:ietf:wg:oauth:2.0:oob",
"write",
"https://schlomp.space/tastytea/mastorss",
client_id, client_secret, url);
if (ret)
{
string code;
string access_token;
cout << "Visit " << url << " to authorize this application.\n";
cout << "Insert code: ";
std::getline(cin, code);
ret = masto.register_app2(client_id, client_secret,
"urn:ietf:wg:oauth:2.0:oob",
code, access_token);
if (ret)
{
BOOST_LOG_TRIVIAL(debug) << "Got access token: " << access_token;
return access_token;
}
}
throw MastodonException{ret.error_code};
}
void Config::parse()
{
data.access_token = _json[_profile]["access_token"].asString();
data.append = _json[_profile]["append"].asString();
data.feedurl = _json[_profile]["feedurl"].asString();
for (const auto &fix : _json[_profile]["fixes"])
{
data.fixes.push_back(fix.asString());
}
data.instance = _json[_profile]["instance"].asString();
if (!_json[_profile]["interval"].isNull())
{
data.interval =
static_cast<uint32_t>(_json[_profile]["interval"].asUInt64());
}
data.last_guid = _json[_profile]["last_guid"].asString();
if (!_json[_profile]["max_size"].isNull())
{
data.max_size = _json[_profile]["max_size"].asUInt64();
}
for (const auto &skip : _json[_profile]["skip"])
{
data.skip.push_back(skip.asString());
}
data.titles_as_cw = _json[_profile]["titles_as_cw"].asBool();
data.titles_only = _json[_profile]["titles_only"].asBool();
BOOST_LOG_TRIVIAL(debug) << "Read config: " << data;
}
void Config::write()
{
_json[_profile]["access_token"] = data.access_token;
_json[_profile]["append"] = data.append;
_json[_profile]["feedurl"] = data.feedurl;
// Leave fixes.
_json[_profile]["instance"] = data.instance;
_json[_profile]["interval"] = data.interval;
_json[_profile]["last_guid"] = data.last_guid;
_json[_profile]["max_size"]
= static_cast<Json::Value::UInt64>(data.max_size);
// Leave skip.
_json[_profile]["titles_as_cw"] = data.titles_as_cw;
_json[_profile]["titles_only"] = data.titles_only;
ofstream file{get_filename().c_str()};
if (file.good())
{
file << _json.toStyledString();
}
BOOST_LOG_TRIVIAL(debug) << "Wrote config file.";
}
} // namespace mastorss

86
src/config.hpp Normal file
View File

@ -0,0 +1,86 @@
/* This file is part of mastorss.
* Copyright © 2019 tastytea <tastytea@tastytea.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef MASTORSS_CONFIG_HPP
#define MASTORSS_CONFIG_HPP
#include <boost/filesystem.hpp>
#include <jsoncpp/json/json.h>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace mastorss
{
namespace fs = boost::filesystem;
using std::uint32_t;
using std::string;
using std::string_view;
using std::vector;
/*!
* @brief The configuration for a profile as data structure.
*
* @since 0.10.0
*/
struct ProfileData
{
string access_token;
string append;
string feedurl;
vector<string> fixes;
string instance;
uint32_t interval{30};
string last_guid;
size_t max_size{500};
vector<string> skip;
bool titles_as_cw{false};
bool titles_only{false};
friend std::ostream &operator <<(std::ostream &out,
const ProfileData &data);
};
/*!
* @brief A configuration file.
*
* @since 0.10.0
*/
class Config
{
public:
explicit Config(string profile);
ProfileData data;
void write();
private:
const string _profile;
Json::Value _json;
[[nodiscard]]
fs::path get_filename() const;
void generate();
[[nodiscard]]
string get_access_token(const string &instance) const;
void parse();
};
} // namespace mastorss
#endif // MASTORSS_CONFIG_HPP

235
src/document.cpp Normal file
View File

@ -0,0 +1,235 @@
/* This file is part of mastorss.
* Copyright © 2019 tastytea <tastytea@tastytea.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "document.hpp"
#include "exceptions.hpp"
#include "version.hpp"
#include <boost/log/trivial.hpp>
#include <boost/property_tree/xml_parser.hpp>
#include <boost/regex.hpp>
#include <mastodon-cpp/mastodon-cpp.hpp>
#include <restclient-cpp/connection.h>
#include <list>
#include <sstream>
#include <string>
#include <utility>
namespace mastorss
{
using boost::regex;
using boost::regex_replace;
using std::list;
using std::istringstream;
using std::string;
using std::move;
bool operator !=(const Item &a, const Item &b)
{
return a.guid != b.guid;
}
Document::Document(Config &cfg)
: _cfg{cfg}
, _profile{cfg.data}
{
RestClient::init();
download();
}
Document::~Document()
{
RestClient::disable();
}
void Document::download(const string &uri)
{
RestClient::Connection connection{uri};
connection.SetUserAgent(string("mastorss/").append(version));
connection.FollowRedirects(false);
RestClient::Response response{connection.get("")};
switch (response.code)
{
case 200:
{
_raw_doc = response.body;
BOOST_LOG_TRIVIAL(debug) << "Downloaded feed: " << _profile.feedurl;
break;
}
case 301:
case 308:
{
_profile.feedurl = extract_location(response.headers);
if (_profile.feedurl.empty())
{
throw HTTPException{response.code};
}
BOOST_LOG_TRIVIAL(debug) << "Feed has new location (permanent): "
<< _profile.feedurl;
_cfg.write();
download();
break;
}
case 302:
case 303:
case 307:
{
const string newuri{extract_location(response.headers)};
if (newuri.empty())
{
throw HTTPException{response.code};
}
BOOST_LOG_TRIVIAL(debug) << "Feed has new location (temporary): "
<< _profile.feedurl;
download(newuri);
break;
}
case -1:
{
throw CURLException{errno};
}
default:
{
throw HTTPException{response.code};
}
}
}
void Document::download()
{
download(_profile.feedurl);
}
void Document::parse()
{
pt::ptree tree;
istringstream iss{_raw_doc};
pt::read_xml(iss, tree);
if (tree.front().first == "rss")
{
BOOST_LOG_TRIVIAL(debug) << "RSS detected.";
parse_rss(tree);
}
}
void Document::parse_rss(const pt::ptree &tree)
{
for (const auto &child : tree.get_child("rss.channel"))
{
if (child.first == "item")
{
const auto &rssitem = child.second;
string guid{rssitem.get<string>("guid")};
if (guid.empty()) // We hope either <guid> or <link> are present.
{
guid = rssitem.get<string>("link");
}
if (guid == _profile.last_guid)
{
break;
}
bool skipthis{false};
string title{rssitem.get<string>("title")};
for (const auto &skip : _profile.skip)
{
if (title.substr(0, skip.length()) == skip)
{
skipthis = true;
break;
}
}
if (skipthis)
{
BOOST_LOG_TRIVIAL(debug) << "Skipped GUID: " << guid;
continue;
}
Item item;
item.description = [&]
{
string desc
{remove_html(rssitem.get<string>("description"))};
for (const auto &fix : _profile.fixes)
{
desc = regex_replace(desc, regex{fix}, "");
}
return desc;
}();
item.guid = move(guid);
item.link = rssitem.get<string>("link");
item.title = move(title);
new_items.push_front(item);
BOOST_LOG_TRIVIAL(debug) << "Found GUID: " << item.guid;
if (_profile.last_guid.empty())
{
BOOST_LOG_TRIVIAL(debug) << "This is the first run.";
break;
}
}
}
}
string Document::remove_html(string html) const
{
html = Mastodon::unescape_html(html); // Decode HTML entities.
html = regex_replace(html, regex{"<p>"}, "\n\n");
const list re_list
{
regex{R"(<!\[CDATA\[)"}, // CDATA beginning.
regex{R"(\]\]>)"}, // CDATA end.
regex{"<[^>]+>"}, // HTML tags.
regex{R"(\r)"}, // Carriage return.
regex{"\\n[ \\t\u00a0]+\\n"}, // Whitespace between newlines.
regex{R"(^\n+)"} // Newlines at the beginning.
};
for (const regex &re : re_list)
{
html = regex_replace(html, re, "");
}
// Remove excess newlines.
html = regex_replace(html, regex{R"(\n{3,})"}, "\n\n");
// Replace single newlines with spaces (?<= is lookbehind, ?= is lookahead).
html = regex_replace(html, regex{R"((?<=[^\n])\n(?=[^\n]))"}, " ");
BOOST_LOG_TRIVIAL(debug) << "Converted HTML to text.";
return html;
}
string Document::extract_location(const RestClient::HeaderFields &headers) const
{
string location{headers.at("Location")};
if (location.empty())
{
location = headers.at("location");
}
return location;
}
} // namespace mastorss

83
src/document.hpp Normal file
View File

@ -0,0 +1,83 @@
/* This file is part of mastorss.
* Copyright © 2019 tastytea <tastytea@tastytea.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef MASTORSS_DOCUMENT_HPP
#define MASTORSS_DOCUMENT_HPP
#include "config.hpp"
#include <boost/property_tree/ptree.hpp>
#include <restclient-cpp/restclient.h>
#include <string>
#include <list>
namespace mastorss
{
namespace pt = boost::property_tree;
using std::string;
using std::list;
/*!
* @brief An Item of a feed.
*
* @since 0.10.0
*/
struct Item
{
string description;
string guid;
string link;
string title;
friend bool operator !=(const Item &a, const Item &b);
};
/*!
* @brief A feed.
*
* @since 0.10.0
*/
class Document
{
public:
explicit Document(Config &cfg);
~Document();
Document(const Document &other) = default;
Document &operator=(const Document &other) = delete;
Document(Document &&other) = default;
Document &operator=(Document &&other) = delete;
list<Item> new_items;
void download();
void download(const string &uri);
void parse();
private:
Config &_cfg;
ProfileData &_profile;
string _raw_doc;
void parse_rss(const pt::ptree &tree);
[[nodiscard]]
string remove_html(string html) const;
[[nodiscard]]
string extract_location(const RestClient::HeaderFields &headers) const;
};
} // namespace mastorss
#endif // MASTORSS_DOCUMENT_HPP

63
src/exceptions.cpp Normal file
View File

@ -0,0 +1,63 @@
/* This file is part of mastorss.
* Copyright © 2019 tastytea <tastytea@tastytea.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "exceptions.hpp"
#include <utility>
using namespace mastorss;
using std::to_string;
using std::move;
HTTPException::HTTPException(const int error)
: error_code{static_cast<uint16_t>(error)}
{}
const char *HTTPException::what() const noexcept
{
static const string error_string{"HTTP error: " + to_string(error_code)};
return error_string.c_str();
}
CURLException::CURLException(const int error)
: error_code{static_cast<uint16_t>(error)}
{}
const char *CURLException::what() const noexcept
{
static const string error_string{"libCURL error: " + to_string(error_code)};
return error_string.c_str();
}
MastodonException::MastodonException(const int error)
: error_code{static_cast<uint16_t>(error)}
{}
const char *MastodonException::what() const noexcept
{
static const string error_string{"Mastodon error: "
+ to_string(error_code)};
return error_string.c_str();
}
FileException::FileException(string message)
: _message{move(message)}
{}
const char *FileException::what() const noexcept
{
return _message.c_str();
}

76
src/exceptions.hpp Normal file
View File

@ -0,0 +1,76 @@
/* This file is part of mastorss.
* Copyright © 2019 tastytea <tastytea@tastytea.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef MASTORSS_EXCEPTIONS_HPP
#define MASTORSS_EXCEPTIONS_HPP
#include <cstdint>
#include <exception>
#include <string>
namespace mastorss
{
using std::uint16_t;
using std::exception;
using std::string;
class HTTPException : public exception
{
public:
const uint16_t error_code;
explicit HTTPException(int error);
[[nodiscard]]
const char *what() const noexcept override;
};
class CURLException : public exception
{
public:
const uint16_t error_code;
explicit CURLException(int error);
[[nodiscard]]
const char *what() const noexcept override;
};
class MastodonException : public exception
{
public:
const uint16_t error_code;
explicit MastodonException(int error);
[[nodiscard]]
const char *what() const noexcept override;
};
class FileException : public exception
{
public:
explicit FileException(string message);
[[nodiscard]]
const char *what() const noexcept override;
private:
const string _message;
};
} // namespace mastorss
#endif // MASTORSS_EXCEPTIONS_HPP

View File

@ -1,87 +0,0 @@
/* This file is part of mastorss.
* Copyright © 2018, 2019 tastytea <tastytea@tastytea.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include <string>
#include <cstdint>
#include <iostream>
#include <istream>
#include <ostream>
#include <sstream>
#include <curlpp/cURLpp.hpp>
#include <curlpp/Easy.hpp>
#include <curlpp/Options.hpp>
#include <curlpp/Exception.hpp>
#include <curlpp/Infos.hpp>
#include "mastorss.hpp"
using std::string;
using std::cerr;
namespace curlopts = curlpp::options;
void curlpp_init()
{
curlpp::initialize();
}
std::uint16_t http_get(const string &feedurl, string &answer,
const string &useragent)
{
try
{
std::ostringstream oss;
curlpp::Easy request;
request.setOpt<curlopts::Url>(feedurl);
request.setOpt<curlopts::UserAgent>(useragent);
request.setOpt<curlopts::HttpHeader>(
{
"Connection: close",
});
request.setOpt<curlopts::FollowLocation>(true);
request.setOpt<curlopts::WriteStream>(&oss);
request.perform();
std::uint16_t ret = curlpp::infos::ResponseCode::get(request);
if (ret == 200 || ret == 302 || ret == 307)
{ // OK or Found or Temporary Redirect
answer = oss.str();
}
else if (ret == 301 || ret == 308)
{ // Moved Permanently or Permanent Redirect
// FIXME: The new URL should be passed back somehow
answer = oss.str();
}
else
{
return ret;
}
return 0;
}
// TODO: More error codes
catch (curlpp::RuntimeError &e)
{
cerr << "RUNTIME ERROR: " << e.what() << std::endl;
return 0xffff;
}
catch (curlpp::LogicError &e)
{
cerr << "LOGIC ERROR: " << e.what() << std::endl;
return 0xffff;
}
return 0;
}

146
src/main.cpp Normal file
View File

@ -0,0 +1,146 @@
#include "config.hpp"
#include "document.hpp"
#include "exceptions.hpp"
#include "mastoapi.hpp"
#include "version.hpp"
#include <boost/log/core.hpp>
#include <boost/log/trivial.hpp>
#include <boost/log/utility/setup/console.hpp>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <stdexcept>
#include <string_view>
#include <thread>
#include <vector>
using namespace mastorss;
using std::chrono::seconds;
using std::getenv;
using std::cout;
using std::cerr;
using std::runtime_error;
using std::string_view;
using std::vector;
using std::this_thread::sleep_for;
namespace mastorss
{
namespace error
{
constexpr int noprofile = 1;
constexpr int network = 2;
constexpr int file = 3;
constexpr int mastodon = 4;
constexpr int unknown = 9;
} // namespace error
void print_version();
void print_help(const string_view &command);
void print_version()
{
cout << "mastorss " << version << "\n"
"Copyright (C) 2019 tastytea <tastytea@tastytea.de>\n"
"License GPLv3: GNU GPL version 3 "
"<https://www.gnu.org/licenses/gpl-3.0.html>.\n"
"This program comes with ABSOLUTELY NO WARRANTY. "
"This is free software,\n"
"and you are welcome to redistribute it under certain conditions.\n";
}
void print_help(const string_view &command)
{
cerr << "Usage: " << command << " [--version|--help] <profile>\n";
}
} // namespace mastorss
int main(int argc, char *argv[])
{
const vector<string_view> args(argv, argv + argc);
if (getenv("MASTORSS_DEBUG") == nullptr)
{
boost::log::core::get()->set_filter
(boost::log::trivial::severity >= boost::log::trivial::info);
}
else
{
boost::log::core::get()->set_filter
(boost::log::trivial::severity >= boost::log::trivial::debug);
}
if (args.size() == 1)
{
print_help(args[0]);
return error::noprofile;
}
if (args.size() > 1)
{
if (args[1] == "--version")
{
print_version();
}
else if (args[1] == "--help")
{
print_help(args[0]);
}
else
{
const string_view profile{args[1]};
BOOST_LOG_TRIVIAL(debug) << "Using profile: " << profile;
try
{
Config cfg{profile.data()};
Document doc{cfg};
doc.parse();
MastoAPI masto{cfg.data};
if (!doc.new_items.empty())
{
for (const auto &item : doc.new_items)
{
masto.post_item(item);
cfg.data.last_guid = item.guid;
if (item != *doc.new_items.rbegin())
{ // Don't sleep if this is the last item.
sleep_for(seconds(cfg.data.interval));
}
}
cfg.write();
}
}
catch (const FileException &e)
{
cerr << e.what() << '\n';
return error::file;
}
catch (const MastodonException &e)
{
cerr << e.what() << '\n';
return error::mastodon;
}
catch (const HTTPException &e)
{
cerr << e.what() << '\n';
return error::network;
}
catch (const CURLException &e)
{
cerr << e.what() << '\n';
return error::network;
}
catch (const runtime_error &e)
{
cerr << e.what() << '\n';
return error::unknown;
}
}
}
return 0;
}

89
src/mastoapi.cpp Normal file
View File

@ -0,0 +1,89 @@
/* This file is part of mastorss.
* Copyright © 2019 tastytea <tastytea@tastytea.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "exceptions.hpp"
#include "mastoapi.hpp"
#include <boost/log/trivial.hpp>
#include <string>
namespace mastorss
{
using std::string;
MastoAPI::MastoAPI(const ProfileData &data)
: _profile{data}
, _masto{_profile.instance, _profile.access_token}
{
}
void MastoAPI::post_item(const Item &item)
{
string status{[&]
{
if (_profile.titles_as_cw)
{
if (_profile.titles_only)
{
return string{};
}
return item.description;
}
string s{item.title};
if (!_profile.titles_only)
{
s.append("\n\n" + item.description);
}
return s;
}()};
status.append("\n\n" + item.link);
if (!_profile.append.empty())
{
const size_t len{status.size()};
const size_t len_append{_profile.append.size() + 2};
const size_t len_max{_profile.max_size};
if ((len + len_append) > len_max)
{
status.resize(len_max - len_append);
}
status.append("\n\n" + _profile.append);
}
Mastodon::parameters params{{"status", {status}}};
if (_profile.titles_as_cw)
{
params.push_back({"spoiler_text", {item.title}});
}
const auto ret = _masto.post(Mastodon::API::v1::statuses, params);
if (!ret)
{
if (ret.http_error_code != 200)
{
throw HTTPException{ret.http_error_code};
}
else
{
throw MastodonException{ret.error_code};
}
}
BOOST_LOG_TRIVIAL(debug) << "Posted status with GUID: " << item.guid;
}
} // namespace mastorss

40
src/mastoapi.hpp Normal file
View File

@ -0,0 +1,40 @@
/* This file is part of mastorss.
* Copyright © 2019 tastytea <tastytea@tastytea.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef MASTORSS_MASTOAPI_HPP
#define MASTORSS_MASTOAPI_HPP
#include "config.hpp"
#include "document.hpp"
#include <mastodon-cpp/mastodon-cpp.hpp>
namespace mastorss
{
class MastoAPI
{
public:
explicit MastoAPI(const ProfileData &data);
void post_item(const Item &item);
private:
const ProfileData &_profile;
Mastodon::API _masto;
};
} // namespace mastorss
#endif // MASTORSS_MASTOAPI_HPP

View File

@ -1,164 +0,0 @@
/* This file is part of mastorss.
* Copyright © 2018, 2019 tastytea <tastytea@tastytea.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include <iostream>
#include <vector>
#include <string>
#include <cstdlib> // getenv()
#include <cstdint>
#include <thread>
#include <chrono>
#include <jsoncpp/json/json.h>
#include <mastodon-cpp/mastodon-cpp.hpp>
#include <mastodon-cpp/easy/all.hpp>
#include "version.hpp"
#include "mastorss.hpp"
using namespace Mastodon;
using std::cout;
using std::cerr;
using std::cin;
using std::endl;
using std::string;
using std::this_thread::sleep_for;
using std::chrono::seconds;
// Initialize global variables
std::uint16_t max_size = 500;
const string filepath = string(getenv("HOME")) + "/.config/mastorss/";
Json::Value config;
std::string profile;
int main(int argc, char *argv[])
{
if (argc < 2)
{
cerr << "usage: " << argv[0] << " <profile> [max size]\n";
return 10;
}
if (argc == 3)
{
max_size = std::stoi(argv[2]);
}
string instance = "";
string access_token = "";
string feedurl = "";
profile = argv[1];
std::uint_fast16_t ret;
string answer;
std::vector<Mastodon::Easy::Status> entries;
read_config(instance, access_token, feedurl);
curlpp_init();
ret = http_get(feedurl, answer, "mastorss/" + (string)global::version);
if (ret != 0)
{
std::cerr << "Error code: " << ret << '\n';
std::cerr << answer << '\n';
return ret;
}
entries = parse_feed(answer);
string last_entry = config[profile]["last_entry"].asString();
if (last_entry.empty())
{
// If no last_entry is stored in the config file,
// make last_entry the second-newest entry.
last_entry = entries.at(1).content();
}
config[profile]["last_entry"] = entries.front().content();
bool new_content = false;
for (auto rit = entries.rbegin(); rit != entries.rend(); ++rit)
{
if (!new_content && (*rit).content().compare(last_entry) == 0)
{
// If the last entry is found in entries,
// start tooting in the next loop.
new_content = true;
continue;
}
else if (!new_content)
{
continue;
}
Easy::return_entity<Easy::Status> ret_status;
Mastodon::Easy::API masto(instance, access_token);
ret_status = masto.send_post(*rit);
if (!ret_status)
{
const uint8_t err = ret_status.error_code;
switch (err)
{
case 110:
{
cerr << "Error " << err << ": Timeout\n";
break;
}
case 111:
{
cerr << "Error " << err << ": Connection refused\n";
cerr << "HTTP Error " << ret_status.http_error_code << endl;
break;
}
case 113:
{
cerr << "Error " << err << ": Could not reach host.\n";
break;
}
case 192:
case 193:
{
cerr << "Error " << err << ": curlpp error\n";
break;
}
default:
{
cerr << "Error " << err << '\n';
cerr << "HTTP status " << ret_status.http_error_code << endl;
}
}
cerr << ret_status.entity.to_string() << '\n';
return ret;
}
if (!ret_status.entity.valid())
{
cerr << "Could not send post for unknown reasons.\n";
cerr << "Please file a bug at "
"<https://schlomp.space/tastytea/mastorss/issues>.\n";
return 1;
}
if (rit != entries.rend())
{ // Only sleep if this is not the last entry
sleep_for(seconds(config[profile]["interval"].asUInt64()));
}
}
// Write the new last_entry only if no error happened.
write_config();
return 0;
}

View File

@ -1,43 +0,0 @@
/* This file is part of mastorss.
* Copyright © 2018, 2019 tastytea <tastytea@tastytea.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef mastorss_HPP
#define mastorss_HPP
#include <cstdint>
#include <string>
#include <vector>
#include <jsoncpp/json/json.h>
#include <mastodon-cpp/easy/easy.hpp>
using std::string;
extern std::uint16_t max_size;
extern const string filepath;
extern Json::Value config;
extern std::string profile;
std::uint16_t read_config(string &instance, string &access_token, string &feedurl);
bool write_config();
std::vector<Mastodon::Easy::Status> parse_feed(const string &xml);
void individual_fixes(string &str);
std::uint16_t http_get(const string &feedurl,
string &answer, const string &useragent = "");
void curlpp_init();
#endif // mastorss_HPP

View File

@ -1,210 +0,0 @@
/* This file is part of mastorss.
* Copyright © 2018, 2019 tastytea <tastytea@tastytea.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include <iostream>
#include <vector>
#include <string>
#include <cstdint>
#include <regex>
#include <sstream>
#include <locale>
#include <codecvt>
#include <fstream>
#include <algorithm>
#include <iterator>
#include <jsoncpp/json/json.h>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/xml_parser.hpp>
#include <mastodon-cpp/mastodon-cpp.hpp>
#include <mastodon-cpp/easy/all.hpp>
#include "mastorss.hpp"
using std::cerr;
using std::string;
namespace pt = boost::property_tree;
std::vector<Mastodon::Easy::Status> parse_feed(const string &xml)
{
Json::Value list;
std::vector<string> watchwords;
std::ifstream file(filepath + "watchwords.json");
if (file.is_open())
{
std::stringstream json;
json << file.rdbuf();
file.close();
json >> list;
}
else
{
cerr << "WARNING: " << filepath << "watchwords.json not found or not readable.\n";
}
// Read profile-specific hashtags or fail silently
const Json::Value &tags_profile = list[profile]["tags"];
std::transform(tags_profile.begin(), tags_profile.end(),
std::back_inserter(watchwords),
[](const Json::Value &value)
{ return value.asString(); });
// Read global hashtags or fail silently
const Json::Value &tags_global = list["global"]["tags"];
std::transform(tags_global.begin(), tags_global.end(),
std::back_inserter(watchwords),
[](const Json::Value &value)
{ return value.asString(); });
pt::ptree rss;
std::istringstream iss(xml);
pt::read_xml(iss, rss);
std::vector<Mastodon::Easy::Status> ret;
for (const pt::ptree::value_type &chanchild : rss.get_child("rss.channel"))
{
if (chanchild.second.size() > 0)
{
if (string(chanchild.first.data()).compare("item") == 0)
{
string title = chanchild.second.get_child("title").data();
string link = chanchild.second.get_child("link").data();
string desc = chanchild.second.get_child("description").data();
Mastodon::Easy::Status status;
string content = "";
if (config[profile]["titles_as_cw"].asBool())
{
status.spoiler_text(Mastodon::unescape_html(title));
}
else
{
content = title;
}
if (!config[profile]["titles_only"].asBool())
{
if (!content.empty())
{
content += "\n\n";
}
content += desc;
// Shrink overly long texts, to speed up replace operations
if (content.length() > 2000)
{
content.resize(2000);
}
}
bool skipthis = false;
try
{
// Skip entries beginning with this text
for (const Json::Value &v : config[profile]["skip"])
{
const string skip = v.asString();
if (!skip.empty())
{
if (title.compare(0, skip.length(), skip) == 0)
{
skipthis = true;
break;
}
}
}
}
catch (const std::exception &e)
{
// Node not found, no problem
}
if (skipthis)
{
continue;
}
content = Mastodon::unescape_html(content);
// Try to turn the HTML into human-readable text
std::regex reparagraph("<p>");
std::regex recdata1("<!\\[CDATA\\[");
std::regex recdata2("\\]\\]>");
std::regex restrip("<[^>]*>");
individual_fixes(content);
content = std::regex_replace(content, reparagraph, "\n\n");
content = std::regex_replace(content, recdata1, "");
content = std::regex_replace(content, recdata2, "");
content = std::regex_replace(content, restrip, "");
// remove \r
content = std::regex_replace(content, std::regex("\\r"), "");
// replace NO-BREAK SPACE with space (UTF-8: 0xc2a0)
content = std::regex_replace(content, std::regex("\u00a0"), " ");
// remove whitespace between newlines
content = std::regex_replace(content, std::regex("\\n[ \t]+\\n"), "");
// remove excess newlines
content = std::regex_replace(content, std::regex("\\n{3,}"), "\n\n");
for (const string &hashtag : watchwords)
{
std::regex rehashtag("([[:space:][:punct:]]|^)(" + hashtag
+ ")([[:space:][:punct:]]|$)",
std::regex_constants::icase);
content = std::regex_replace(content, rehashtag, "$1#$2$3",
std::regex_constants::format_first_only);
}
// Why is this necessary? Why does ##hashtag happen?
content = std::regex_replace(content, std::regex("##"), "#");
uint16_t appendix_size = config[profile]["append"].asString().length();
if ((status.spoiler_text().size() + content.size() + link.size() + appendix_size)
> static_cast<std::uint16_t>(max_size - 4))
{
content.resize((max_size - status.spoiler_text().size()
- link.size() - appendix_size - 4));
content.resize(content.rfind(' ')); // Cut at word boundary
content += " […]";
}
// Remove trailing newlines
while (content.back() == '\n' ||
content.back() == '\r')
{
content.resize(content.length() - 1);
}
content += "\n\n" + link;
if (!config[profile]["append"].empty())
{
content += "\n\n" + config[profile]["append"].asString();
}
status.content(content);
ret.push_back(status);
}
}
}
return ret;
}
// Read regular expressions from the config file and delete all matches.
void individual_fixes(string &str)
{
for (const Json::Value &v : config[profile]["fixes"])
{
std::regex refix(v.asString());
str = std::regex_replace(str, refix, "");
}
}

View File

@ -1,9 +1,13 @@
#ifndef VERSION_HPP
#define VERSION_HPP
#ifndef MASTORSS_VERSION_HPP
#define MASTORSS_VERSION_HPP
namespace global
#include <string_view>
namespace mastorss
{
static constexpr char version[] = "@PROJECT_VERSION@";
}
using std::string_view;
#endif // VERSION_HPP
static constexpr string_view version = "@PROJECT_VERSION@";
} // namespace mastorss
#endif // MASTORSS_VERSION_HPP