diff --git a/README.md b/README.md index e0181d8..414afcd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,53 @@ # mastodon-clippy -customisable clippy bot for mastodon +A customisable nodejs clippy bot for mastodon. + +`mastodon-clippy` notices when you are tooting about a topic that is bad for your health, and gently suggests you stop. + +See an example in action at [auspol_clippy](https://ausglam.space/@auspol_clippy). + +## configuration + +`mastodon-clippy` takes all configuration as ENV variables: + +* `CLIPPY_TOPIC` is the topic your clippy bot makes suggestions for. e.g. "auspol". +* `CLIPPY_DOMAIN` is the base domain for the Mastodon server your bot runs on _without a protocol_. e.g. "botsin.space" +* `CLIPPY_USER` is the username of the bot, e.g. "auspol_clippy". +* `CLIPPY_ACCESS_TOKEN` is the API access token for your bot. + +## auto-config + +Some settings for your bot account will be automatically set/overridden whenever the bot starts. These are: + +```json +locked: false, +bot: true, +discoverable: true, +source: { privacy: 'private' } +``` +That is, your bot must always: + +* accept new followers +* declare it is a bot +* be discoverable on the server suggestions page +* post messages in "followers only" mode + +## manual config + +It does not appear to be possible to use the API to set accounts to hide their social graph. users should be able to use your bot without other people necessarily knowing, but the bot needs to follow them in order to work. Therefore you should manually select `Hide your network` in `https://example.com/settings/preferences/other`. + +## setup + +You can use the example systemd file at `mastodon-clippy.service.example` tweaked to suit your setup. This will keep the bot running and set your environment variables as above. + +# running + +Start the bot with the traditional `node index.js`. + +## interacting with the bot + +To "sign up" for notification from your bot, users have two options: + +1. follow the bot account +2. send a toot to the bot with the word `START` in capital letters. e.g. `@auspol_clippy START` + +To "unsubscribe" from the bot, users can send a toot with the word `STOP` in capital letters. e.g. `@auspol_clippy STOP` \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..8c6f6ed --- /dev/null +++ b/index.js @@ -0,0 +1,156 @@ +/* + + Clippy bot for mastodon. + + (c) Hugh Rundle, licensed AGPL 3.0+ + Contact @hugh@ausglam.space + + NOTE: Since requesting users must be followed by the bot, they will "out" themselves by using it. + This does not appear to be fixable with the API, so bot owners should manually "Hide network". +*/ + +// require modules +const axios = require('axios') +const crypto = require('crypto') // built in node module requires v12.19 or higher +const WebSocket = require('ws') + +// clippy settings + +const access_token = process.env.CLIPPY_ACCESS_TOKEN +const topic = process.env.CLIPPY_TOPIC +const clippy = process.env.CLIPPY_USER +const domain = process.env.CLIPPY_DOMAIN + +// set authorization headers for all API calls +const headers = { + 'Authorization' : `Bearer ${access_token}` +} + +// set up bot account with correct settings +function initiateSettings() { + + let account = { + locked: false, + bot: true, + discoverable: true, + source: { privacy: 'private' } + } + + // update with the above settings + return axios.patch(`https://${domain}/api/v1/accounts/update_credentials`, account, { headers: headers }) + .catch( err => { + console.error('ERROR applying bot user settings: ', err.message) + }) +} + +function suggestion() { + + const n = crypto.randomInt(4) + + switch(n) { + case 0: + return 'How about logging off instead?'; + case 1: + return 'Would you like to delete your toot?'; + case 2: + return 'Can I help you take a walk outside?'; + case 3: + return 'You may like to reconsider your life choices.'; + } +} + +// send a message when someone toots about the topic +function sendResponse(rip, user) { + + let payload = { + 'status' : `@${user} It looks like you're posting about '${topic}'. ${suggestion()}`, + 'in_reply_to_id' : rip, + } + + axios.post(`https://${domain}/api/v1/statuses`, payload, { headers: headers }) + .catch( err => { + console.error(err.message) + }) + +} + +function followAction(id, action) { + + let url = `https://${domain}/api/v1/accounts/${id}/${action}` + + let payload = { + reblogs: false, + notify: false + } + + axios.post(url, payload, { headers: headers }) + .catch( err => { + console.error(err.message) + }) + +} + +// *********************** +// STREAMING USER TIMELINE +// This is where the action is! +// *********************** +const ws = new WebSocket(`wss://${domain}/api/v1/streaming?access_token=${access_token}&stream=user`) + +// make sure bot is set up correctly each time it starts +initiateSettings() + +// errors +ws.on('error', err => { + console.error(`WebSocket error: ${err.message}`) +}) + +// check updates and notifications in the stream +ws.on('message', msg => { + let packet = JSON.parse(msg) + let data = JSON.parse(packet.payload) + + // notifications + if (packet.event == 'notification') { + + // always follow back + if (data.type == 'follow') { + followAction(data.account.id, 'follow') + } + + if (data.type == 'mention') { + + let post = data.status.content + + // check start requests + if (post.match(/\bSTART\b/)) { + followAction(data.account.id, 'follow') + } + + // check stop requests + if (post.match(/\STOP\b/)) { + followAction(data.account.id, 'unfollow') + } + } + } + + // updates (posts) + if (packet.event == 'update') { + let rip = data.id + let user = data.account.username + + // exclude own toots to avoid an infinite loop + if (data.account.username !== clippy) { + if ( data.content.includes(topic) ) { + sendResponse(rip, user) + } + else if (data.spoiler_text == topic) { + sendResponse(rip, user) + } + else if (data.tags.includes(topic)) { + sendResponse(rip, user) + } + } + } +}) + + diff --git a/mastodon-clippy.service.example b/mastodon-clippy.service.example new file mode 100644 index 0000000..b67f009 --- /dev/null +++ b/mastodon-clippy.service.example @@ -0,0 +1,15 @@ +[Service] +Type=simple +ExecStart=/usr/bin/node index.js +Restart=always +RestartSec=15 +TimeoutSec=15 +KillMode=process +User=clippy +WorkingDirectory=/home/clippy/mastodon-clippy +Environment="CLIPPY_TOPIC=auspol" +Environment="CLIPPY_ACCESS_TOKEN=your-token-here" +Environment="CLIPPY_DOMAIN=example.com" +Environment="CLIPPY_USER=auspol_clippy" +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0f5deef --- /dev/null +++ b/package-lock.json @@ -0,0 +1,26 @@ +{ + "name": "mastodon-clippy", + "version": "0.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, + "follow-redirects": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz", + "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==" + }, + "ws": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz", + "integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d7f04d6 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "mastodon-clippy", + "version": "1.0.0", + "description": "Mastodon clippy bot", + "repository": "https://github.com/hughrun/mastodon-clippy.git", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Hugh Rundle (https://www.hughrundle.net)", + "license": "AGPL-3.0-or-later", + "dependencies": { + "axios": "^0.21.1", + "ws": "^7.4.5" + } +}