master
Clement Bois 2019-06-14 17:10:20 +02:00
parent 73315a2d77
commit f07baecee4
22 changed files with 653 additions and 610 deletions

View File

@ -1,14 +1,4 @@
# Minimal options
INSTANCE=mastodon.social
TOKEN=xxxxxxxxxxxxxxxxxx
DATA_STATUS=yyyyyyyyyyyy
# Basic options
VISIBILITY=unlisted
TARGET=global
# TARGET_STATUSES=id1,id2,id3
TIMEOUT=60000
# Moderation
MODERATION=self
# MODERATION_LIMIT=5
DOMAIN=mastodon.social
TOKEN=xxxxxxxxxxxxxxxx
ROOT_STATUS=yyyyyyyyyy
TIMEOUT=60000

View File

@ -1,30 +0,0 @@
{
"env": {
"node": true
},
"extends": "eslint:recommended",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2018
},
"rules": {
"indent": [
"error",
2,
{
"SwitchCase": 1
}
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"never"
]
}
}

4
.gitignore vendored
View File

@ -1,2 +1,4 @@
*dist
*node_modules
.env
.env
.package-lock

121
README.md
View File

@ -11,8 +11,10 @@ A pretty simple random message bot using [Mastodon](https://joinmastodon.org) st
- Create an account for this bot. Please mark it as bot in configuration page.
- In settings page, create a new application with read write permission and copy the private key.
- Create a status to store messages (could be private) and copy his id. *Called `data status` after*
- Reply to this status to add content. (Removing @acct, to limit self spam)
- Create a status to store actions (could be private) and copy its id. *Called `root status` after*
- Create a status to store content (could be private) and copy its id. *Called `data status` after*
- Reply to this status to add content. (Removing @acct, to limit self spam)
- Reply to `root status` with options tags *(See `action status`)* like `#botodon #global #data_from_{DATA_STATUS_ID}`
### Botodon
@ -23,65 +25,80 @@ npm install
cp .env.sample .env
```
Edit `.env` at least set `INSTANCE=domain.tld`, `TOKEN={ACCOUNT_PRIVATE_KEY}` and `DATA_STATUS={STATUS_ID}`
In `.env` set
- `DOMAIN={INSTANCE_DOMAIN}`
- `TOKEN={ACCOUNT_PRIVATE_KEY}`
- `ROOT_STATUS={ROOT_STATUS_ID}`
Run `node index.js` with cron.
Run `npm run start` with cron.
Enjoy
## Options
## Main ideas
`.env` file currently contains all need options but will maybe a day be moved to mastodon statuses.
It uses statuses tags as configuration like `#xxx_yyy`. So content doesn't really matter except obviously for `content statuses`
key | format | description
There is 3 *types* of statuses:
- *The* `root status` contains global options. See [Root status](#root-status)
- `action status` contains action to run and is descendant of `root status`. See [Action status](#action-status)
- `data status` is a data source like a folder containing `content statuses`
- `content status` is a *normal* status its content *(including medias)* will send
### Root status
Its a folder containing `action statuses` and global options as tags.
tag | description
-- | --
botodon | **Required** Enable tags processing
async | Enable actions parallel processing
deep | Use replies to actions as actions
shared | **Unsafe** Include other users actions
### Action status
It describe an action to run
#### Sample
```
#botodon #global #data_from_xxxxxxxxxxxx
```
Send standard status with random content from `xxxxxxxxxxxx` status
#### Options
tag | default | description
-- | -- | --
INSTANCE | Domain name | Mastodon instance domain name
TOKEN | Private key | Bot account application private key
DATA_STATUS | Status id | Messages storage status
VISIBILITY | [visibility](https://docs.joinmastodon.org/api/entities/#visibility) | Bot message(s) visibility
TARGET | See [Target](#target) | Define statuses targets
TARGET_STATUSES | Statuses id (comma separated) | Used with some `TARGET` options
TIMEOUT | milliseconds | API timeout
MODERATION | See [Moderation](#moderation) | Define moderation method
MODERATION_LIMIT | int | Used with `MODERATION=favourites_count`
botodon | false | **Required** Enable tags processing
**Data source** | | content send
data_from_**ID** | empty | Add a source of content: `data status` id
data_deep | false | Use replies to content as content
data_shared | false | **Warning** Include other users content
data_tagged_**TAG** | empty | Require content to have this tag
data_favourited | false | Require content to be fav by bot account
data_favourites_**N** | false | Require at least N favourites
data_last_**N** | disabled | Only include N last statuses
data_weighted | false | Use `favourites_count` as promotional random weight
data_same | false | Send same content for all targets
**Targets** | | target accounts
global | false | Send without target
followers | false | Send to each followers
followers_of_**ID** | empty | Send to each followers of given account id **Don't be evil**
replies_to_**ID** | empty | Send to each repliers of given status id
replies_deep | false | Include replies to replies
replies_visibility | false | Use reply visibility
favourites_**ID** | empty | Send ti each use who fav given status id
visibility | unlisted | Visibility to use. One of `public`, `unlisted`, `private`, `direct`
Note: malformed `.env` could produce unexpected behaviors
### Target
Define statuses targets with `TARGET` option:
- `none`: nothing send *(default)*
- `global`: single without target
- `self`: single with self as target
- `followers`: one to each follower
- `replies`: use direct replies to `TARGET_STATUSES`
- `replies_deep`: use deep replies to `TARGET_STATUSES`
- `replies_smart`: same as `replies` but override `VISIBILITY with reply visibility`
- `favourites`: use favourites of `TARGET_STATUSES`
- `favourites_smart`: use favourites on `TARGET_STATUSES` by using tags as options
#### Favourites Smart
**WIP**
### Moderation
You could let community produce messages by making your `data status` public or unlisted.
For moderation, simply use `MODERATION` option:
- `none`: no check *(default)*
- `self`: one your messages
- `favourited`: bot account need to fav valid messages
- `favourites_count`: need at least the number of fav defined with `MODERATION_LIMIT` option
## Multiple bots
Add `.env.XXX` files and run `BOT_NAME=XXX node index.js`
## Notes
### Limits
## Limits
Mastodon context API use an arbitrary limit of `4096` since [#7564](https://github.com/tootsuite/mastodon/pull/7564), it's so the limit were new replies are excluded.
@ -89,6 +106,8 @@ Followers soft limit of `999` *(must include pagination)* and hard limit of `750
## TODO
every thing
add selection mode
multiple target
- Add abstract and inherit on deep actions
- Add actions time validators
- Use followers pagination
- Add options on `data status`
- Add move pick options

132
index.js
View File

@ -1,132 +0,0 @@
const path = require('path')
require('dotenv').config({ path: path.resolve(process.cwd(), process.env.BOT_NAME ? `.env.${process.env.BOT_NAME}` : '.env') })
const rp = require('request-promise-native')
// Load env
const ENV = {
instance: 'INSTANCE',
token: 'TOKEN',
data_status_id: 'DATA_STATUS',
data_deep: 'DATA_DEEP',
visibility: 'VISIBILITY',
target_mode: 'TARGET',
target_data: 'TARGET_STATUSES',
timeout: 'TIMEOUT',
moderation: 'MODERATION',
moderation_limit: 'MODERATION_LIMIT'
}
function objectMap(object, mapFn) {
return Object.keys(object).reduce(function (result, key) {
result[key] = mapFn(object[key])
return result
}, {})
}
const options = objectMap(ENV, key => process.env[key])
// Setup request
const request = rp.defaults({
auth: {
bearer: options.token
},
baseUrl: `https://${options.instance}/api/v1/`,
timeout: options.timeout,
json: true
})
async function run(R, { data_deep, data_status_id, moderation, moderation_limit, target_mode, target_data, visibility }) {
// verify_credentials
const me = await R.get('accounts/verify_credentials')
console.debug(`Logged as ${me.acct}`)
if (!me.bot) {
console.warn('Please set account as bot !')
}
// load data
const { descendants: dataContext } = await R.get(`statuses/${data_status_id}/context`)
const content = dataContext.filter(s => {
if (!data_deep && s.in_reply_to_id !== data_status_id) {
return false
}
switch (moderation) {
case 'self':
return s.account.id === me.id
case 'favourited':
return s.favourited
case 'favourites_count':
return s.favourites_count >= moderation_limit
default:
return true
}
})
console.debug(`Found ${content.length} messages (of ${content.length})`)
if (!content.length) {
throw 'Any message found'
}
// find targets
let targets = []
switch (target_mode) {
case 'global':
targets.push({ visibility })
break
case 'self':
targets.push({ acct: me.acct, visibility })
break
case 'followers':
const followers = await R.get(`accounts/${me.id}/followers`, { qs: { limit: 999 } })
targets.push(...followers.map(({ acct }) => ({ acct, visibility })))
break
case 'replies':
case 'replies_deep':
case 'replies_smart':
const allReplies = await Promise.all(target_data.split(',').map(id => {
console.log(id)
return R.get(`statuses/${id}/context`)
}))
}
console.log(targets)
}
run(request, options)
/*
Object.entries(process.env)
.filter(e => e[0].startsWith(TOKEN))
.forEach(e => {
const lang = e[0].substring(TOKEN.length + 1)
const visi = process.env[`${VISIBILITY}_${lang}`] || process.env[VISIBILITY]
const M = new Mastodon({
access_token: process.env[`${TOKEN}_${lang}`] || process.env[TOKEN],
api_url: `${process.env[`${URL}_${lang}`] || process.env[URL]}/api/v1/`,
timeout_ms: 60 * 1000
})
M.get('accounts/verify_credentials').then(
me => {
if (me.data.error) {
console.error(me.data.error)
return
}
M.get(`accounts/${me.data.id}/followers`, { limit: 9999 }).then(fol => {
for (const follow of fol.data) {
const messages = database[Math.floor(Math.random() * database.length)]
const text = lang.length > 0 ? messages[lang] : '\n' + Object.entries(messages).map(m => `[${m[0]}] ${m[1]}`).join('\n\n')
M.post('statuses', { status: `@${follow.acct} ${text}`, visibility: visi })
}
})
})
})
*/

379
package-lock.json generated
View File

@ -1,379 +0,0 @@
{
"name": "botodon",
"version": "0.0.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"ajv": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz",
"integrity": "sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==",
"requires": {
"fast-deep-equal": "^2.0.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
}
},
"asn1": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz",
"integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==",
"requires": {
"safer-buffer": "~2.1.0"
}
},
"assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
"integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg="
},
"aws4": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz",
"integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ=="
},
"bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=",
"requires": {
"tweetnacl": "^0.14.3"
}
},
"caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"requires": {
"delayed-stream": "~1.0.0"
}
},
"core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
},
"dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
"integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
"requires": {
"assert-plus": "^1.0.0"
}
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
},
"dotenv": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.0.0.tgz",
"integrity": "sha512-30xVGqjLjiUOArT4+M5q9sYdvuR4riM6yK9wMcas9Vbp6zZa+ocC9dp6QoftuhTPhFAiLK/0C5Ni2nou/Bk8lg=="
},
"ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
"integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=",
"requires": {
"jsbn": "~0.1.0",
"safer-buffer": "^2.1.0"
}
},
"extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
"integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU="
},
"fast-deep-equal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
"integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk="
},
"fast-json-stable-stringify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz",
"integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I="
},
"forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
"integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE="
},
"form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
}
},
"getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
"integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
"requires": {
"assert-plus": "^1.0.0"
}
},
"har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
"integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI="
},
"har-validator": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz",
"integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==",
"requires": {
"ajv": "^6.5.5",
"har-schema": "^2.0.0"
}
},
"http-signature": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
"integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=",
"requires": {
"assert-plus": "^1.0.0",
"jsprim": "^1.2.2",
"sshpk": "^1.7.0"
}
},
"is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo="
},
"isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo="
},
"jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM="
},
"json-schema": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
"integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM="
},
"json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
},
"json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
},
"jsprim": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
"integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=",
"requires": {
"assert-plus": "1.0.0",
"extsprintf": "1.3.0",
"json-schema": "0.2.3",
"verror": "1.10.0"
}
},
"lodash": {
"version": "4.17.11",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz",
"integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg=="
},
"mime-db": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz",
"integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA=="
},
"mime-types": {
"version": "2.1.24",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz",
"integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==",
"requires": {
"mime-db": "1.40.0"
}
},
"oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="
},
"performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
},
"psl": {
"version": "1.1.32",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.1.32.tgz",
"integrity": "sha512-MHACAkHpihU/REGGPLj4sEfc/XKW2bheigvHO1dUqjaKigMp1C8+WLQYRGgeKFMsw5PMfegZcaN8IDXK/cD0+g=="
},
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
},
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
"integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="
},
"request": {
"version": "2.88.0",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz",
"integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==",
"requires": {
"aws-sign2": "~0.7.0",
"aws4": "^1.8.0",
"caseless": "~0.12.0",
"combined-stream": "~1.0.6",
"extend": "~3.0.2",
"forever-agent": "~0.6.1",
"form-data": "~2.3.2",
"har-validator": "~5.1.0",
"http-signature": "~1.2.0",
"is-typedarray": "~1.0.0",
"isstream": "~0.1.2",
"json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.19",
"oauth-sign": "~0.9.0",
"performance-now": "^2.1.0",
"qs": "~6.5.2",
"safe-buffer": "^5.1.2",
"tough-cookie": "~2.4.3",
"tunnel-agent": "^0.6.0",
"uuid": "^3.3.2"
}
},
"request-promise-core": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.2.tgz",
"integrity": "sha512-UHYyq1MO8GsefGEt7EprS8UrXsm1TxEvFUX1IMTuSLU2Rh7fTIdFtl8xD7JiEYiWU2dl+NYAjCTksTehQUxPag==",
"requires": {
"lodash": "^4.17.11"
}
},
"request-promise-native": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.7.tgz",
"integrity": "sha512-rIMnbBdgNViL37nZ1b3L/VfPOpSi0TqVDQPAvO6U14lMzOLrt5nilxCQqtDKhZeDiW0/hkCXGoQjhgJd/tCh6w==",
"requires": {
"request-promise-core": "1.1.2",
"stealthy-require": "^1.1.1",
"tough-cookie": "^2.3.3"
}
},
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"sshpk": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
"integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==",
"requires": {
"asn1": "~0.2.3",
"assert-plus": "^1.0.0",
"bcrypt-pbkdf": "^1.0.0",
"dashdash": "^1.12.0",
"ecc-jsbn": "~0.1.1",
"getpass": "^0.1.1",
"jsbn": "~0.1.0",
"safer-buffer": "^2.0.2",
"tweetnacl": "~0.14.0"
}
},
"stealthy-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz",
"integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks="
},
"tough-cookie": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz",
"integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==",
"requires": {
"psl": "^1.1.24",
"punycode": "^1.4.1"
},
"dependencies": {
"punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4="
}
}
},
"tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
"requires": {
"safe-buffer": "^5.0.1"
}
},
"tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
},
"uri-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
"integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
"requires": {
"punycode": "^2.1.0"
}
},
"uuid": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz",
"integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA=="
},
"verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
"integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
"requires": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
}
}
}
}

View File

@ -2,9 +2,17 @@
"name": "botodon",
"version": "0.0.1",
"description": "A pretty simple random message bot using mastodon",
"main": "index.js",
"main": "dist/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"start": "npm run build && npm run launch",
"build": "tsc",
"launch": "node dist/index.js",
"dev": "ts-node src/index.ts",
"lint": "tslint --project .",
"action-add": "ts-node src/tools/action/add.ts",
"action-clear": "ts-node src/tools/action/clear.ts",
"action-list": "ts-node src/tools/action/list.ts",
"data-push": "ts-node src/tools/data/push.ts"
},
"keywords": [
"mastodon",
@ -15,6 +23,19 @@
"dependencies": {
"dotenv": "^8.0.0",
"request": "^2.88.0",
"request-promise-native": "^1.0.7"
"request-promise-native": "^1.0.7",
"winston": "^3.2.1"
},
"devDependencies": {
"@types/dotenv": "^6.1.1",
"@types/node": "^12.0.8",
"@types/request": "^2.48.1",
"@types/request-promise-native": "^1.0.16",
"@types/winston": "^2.4.4",
"ts-node": "^8.2.0",
"tslint": "^5.17.0",
"tslint-config-prettier": "^1.18.0",
"tslint-plugin-prettier": "^2.0.1",
"typescript": "^3.5.2"
}
}

162
src/App.ts Normal file
View File

@ -0,0 +1,162 @@
import { VisibilityType } from 'mastodon'
import Rest from './Rest'
import { Action, ActionConfig, ActionConfigData, RootConfig, VISIBILITIES } from './types/config'
import Logger from './utils/Logger'
import matcher from './utils/matcher'
import Selector from './utils/Selector'
export default class App {
constructor(private rest: Rest) { }
async run(rootId: string) {
this.load(rootId, this.process.bind(this))
}
async load(rootId: string, process: (me: string, action: Action) => Promise<any>) {
const { id: me } = await this.login()
const config = await this.loadRoot(rootId, me)
Logger.debug('Root config', config)
if (!config.botodon) {
Logger.error('Root status requires #botodon tag')
return
}
for (const action of await this.loadActions(rootId, config.deep, config.shared ? undefined : me)) {
const promise = process(me, action)
if (!config.async) {
await promise
}
}
}
async login() {
const me = await this.rest.getMe()
Logger.debug(`Logged as ${me.acct}`)
if (!me.bot) {
Logger.warn('Please set account as bot !')
}
return me
}
async loadRoot(id: string, me: string) {
const status = await this.rest.getStatus(id)
if (status.account.id !== me) {
Logger.warn('Root status isn\'t yours')
}
return matcher<RootConfig>({
async: false,
botodon: false,
deep: false,
shared: false
}, status.tags.map(t => t.name).reverse())
}
async loadActions(id: string, deep: boolean, account?: string) {
const lines: Action[] = (await this.rest.getReplies(id, deep, account))
.map(s => ({ id: s.id, tags: s.tags.map(t => t.name) }))
Logger.debug(`Found ${lines.length} action(s)`)
if (!lines.length) {
Logger.error('Root status is empty !')
}
return lines
}
async process(me: string, action: Action) {
const config = matcher<ActionConfig>({
botodon: false,
data: {
from: [],
deep: false,
shared: false,
tagged: [],
favourited: false,
favourites: 0,
last: -1,
weighted: false,
same: false
},
global: false,
followers: false,
followers_of: [],
replies: {
to: [],
deep: false,
visibility: false
},
favourites: [],
visibility: 'unlisted'
}, action.tags.reverse())
Logger.debug(`Action ${action.id} config`, config)
if (!config.botodon) {
Logger.error(`Action status ${action.id} requires #botodon tag`)
return
}
if (!VISIBILITIES.includes(config.visibility)) {
Logger.error(`Action status ${action.id}: invalid visibility ${config.visibility}`)
return
}
// Data
const datas = (await Promise.all(config.data.from.map(id => this.loadData(id, config.data, me))))
.reduce((a, b) => a.concat(b))
if (!datas.length) {
Logger.error(`Action ${action.id}: Any content`)
return
}
const selector = new Selector(datas, config.data.same, s => config.data.weighted ? s.favourites_count : 1)
// Targets
// TODO: progressive send (limit memory usage)
const targets: Array<{acct?: string, visibility: VisibilityType}> = []
if (config.global) {
targets.push({ visibility: config.visibility })
}
if (config.followers) {
config.followers_of.push(me)
}
for await (const followers of config.followers_of.map(id => this.rest.getFollowers(id))) {
targets.push(...followers.map(({ acct }) => ({ acct, visibility: config.visibility })))
}
for await (const replies of config.replies.to.map(id => this.rest.getReplies(id, config.replies.deep))) {
targets.push(...replies.map(({ account: { acct }, visibility }) => ({
acct, visibility: config.replies.visibility ? visibility : config.visibility
})))
}
for await (const fav of config.favourites.map(id => this.rest.getFavouritedBy(id))) {
targets.push(...fav.map(({ acct }) => ({ acct, visibility: config.visibility })))
}
Logger.debug(`Action ${action.id}: ${targets.length} target(s)`)
if (!targets.length) {
Logger.warn(`Action ${action.id}: Any target`)
}
for (const target of targets) {
const next = selector.next()
await this.rest.postStatus({
media_ids: next.media_attachments.map(m => m.id),
sensitive: next.sensitive,
spoiler_text: next.spoiler_text,
status: `${target.acct ? `@${target.acct} ` : ''}${next.content.replace(/<[^>]*>?/gm, '')}`,
visibility: target.visibility
})
}
Logger.debug(`Action ${action.id}: done`)
}
async loadData(id: string, config: ActionConfigData, me: string) {
const replies = await this.rest.getReplies(id, config.deep, config.shared ? undefined : me)
return replies
.filter(s => !config.favourited || s.favourited)
.filter(s => s.favourites_count >= config.favourites)
.filter(s => config.tagged.every(tag => s.tags.map(t => t.name).includes(tag)))
.slice(0, config.last === -1 ? undefined : config.last)
}
}

48
src/Rest.ts Normal file
View File

@ -0,0 +1,48 @@
import { RequestPromiseAPI } from 'request-promise-native'
import { Account, Context, Status, StatusPost } from './types/mastodon'
export default class Rest {
constructor(readonly api: RequestPromiseAPI) {}
async getMe(): Promise<Account> {
return this.api.get('accounts/verify_credentials')
}
async getStatus(id: string): Promise<Status> {
return this.api.get(`statuses/${id}`)
}
async getContext(id: string): Promise<Context> {
return this.api.get(`statuses/${id}/context`)
}
async getDescendants(id: string) {
return this.getContext(id)
.then(context => context.descendants)
}
async getReplies(id: string, deep: boolean, account?: string) {
return this.getDescendants(id).then(replies => replies
.filter(s => deep || s.in_reply_to_id === id)
.filter(s => !account || s.account.id === account))
}
async getFollowers(id: string): Promise<Account[]> {
// TODO: use link header
return this.api.get(`account/${id}/followers`, { qs: { limit: 999 } })
}
async getFavouritedBy(id: string): Promise<Account[]> {
// TODO: use link header
return this.api.get(`statuses/${id}/favourited_by`, { qs: { limit: 999 } })
}
async postStatus(status: StatusPost): Promise<Status> {
return this.api.post('statuses', { body: status })
}
async deleteStatus(id: string) {
return this.api.delete(`statuses/${id}`)
}
}

5
src/index.ts Normal file
View File

@ -0,0 +1,5 @@
import App from './App'
import { rest, rootStatus } from './prepare'
const app = new App(rest)
app.run(rootStatus)

17
src/prepare.ts Normal file
View File

@ -0,0 +1,17 @@
import dotenv from 'dotenv'
import path from 'path'
import rp from 'request-promise-native'
import Rest from './Rest'
dotenv.config({ path: path.resolve(process.cwd(), process.env.BOT_NAME ? `.env.${process.env.BOT_NAME}` : '.env') })
export const rest = new Rest(rp.defaults({
auth: {
bearer: process.env.TOKEN
},
baseUrl: `https://${process.env.DOMAIN}/api/v1/`,
timeout: Number.parseInt(process.env.TIMEOUT, undefined),
json: true
}))
export const rootStatus = process.env.ROOT_STATUS

18
src/tools/action/add.ts Normal file
View File

@ -0,0 +1,18 @@
import { rest, rootStatus } from '../../prepare'
import Logger from '../../utils/Logger'
async function run() {
const content = process.argv[2]
if (!content) {
Logger.error('require content')
return
}
Logger.info(`adding action: "${content}"`)
const status = await rest.postStatus({
in_reply_to_id: rootStatus,
status: content,
visibility: 'private'
})
Logger.info('action added', { id: status.id, tags: status.tags.map(t => t.name).reverse() })
}
run()

View File

@ -0,0 +1,8 @@
import App from '../../App'
import { rest, rootStatus } from '../../prepare'
async function run() {
const app = new App(rest)
app.load(rootStatus, (_, { id }) => rest.deleteStatus(id))
}
run()

12
src/tools/action/list.ts Normal file
View File

@ -0,0 +1,12 @@
import App from '../../App'
import { rest, rootStatus } from '../../prepare'
import Logger from '../../utils/Logger'
async function run() {
const app = new App(rest)
app.load(rootStatus, (_, action) => {
Logger.info(JSON.stringify(action))
return Promise.resolve()
})
}
run()

22
src/tools/data/push.ts Normal file
View File

@ -0,0 +1,22 @@
import fs from 'fs'
import { rest, rootStatus } from '../../prepare'
import Logger from '../../utils/Logger'
async function run() {
const [,, file, title] = process.argv
if (!file) {
Logger.error('require file')
return
}
const data = JSON.parse(fs.readFileSync(file, 'utf8'))
const ok = data.map((l: any) => Object.entries(l).map(([lang, message]) => `[${lang}] ${message}`).join('\n'))
Logger.info('data', ok)
/*
const status = await rest.postStatus({
in_reply_to_id: rootStatus,
status: content,
visibility: 'private'
})
Logger.info('action added', { id: status.id, tags: status.tags.map(t => t.name).reverse() })*/
}
run()

41
src/types/config.ts Normal file
View File

@ -0,0 +1,41 @@
import { VisibilityType } from 'mastodon'
export interface RootConfig {
botodon: boolean
async: boolean
deep: boolean
shared: boolean
}
export interface Action {
id: string,
tags: string[]
}
export interface ActionConfig {
botodon: boolean,
data: ActionConfigData
global: boolean
followers: boolean
followers_of: string[]
replies: {
to: string[]
deep: boolean
visibility: boolean
}
favourites: string[]
visibility: VisibilityType
}
export interface ActionConfigData {
from: string[]
deep: boolean
shared: boolean
tagged: string[]
favourited: boolean
favourites: number
last: number
weighted: boolean
same: boolean
}
export const VISIBILITIES = ['public', 'unlisted', 'private', 'direct']

96
src/types/mastodon.ts Normal file
View File

@ -0,0 +1,96 @@
export interface Emoji {
shortcode: string
static_url: string
}
export interface Account {
id: string
acct: string
bot: boolean
display_name: string
emojis: Emoji[]
}
export type VisibilityType = 'public' | 'unlisted' | 'private' | 'direct'
export interface Status {
id: string
uri: string
account: Account
content: string
created_at: string
emojis: Emoji[]
favourited: boolean
favourites_count: number
media_attachments: Media[]
sensitive: boolean
reblogged: boolean
reblogs_count: number
replies_count: number
in_reply_to_id?: string
reblog?: Status
spoiler_text?: string
card?: Card
poll?: Poll
tags: Tag[]
visibility: VisibilityType
}
export interface StatusPost {
status: string
in_reply_to_id?: string
media_ids?: string[]
sensitive?: boolean
spoiler_text?: string
visibility: VisibilityType
}
export type CardType = 'link' | 'photo' | 'video' | 'rich'
export interface Card {
url: string
title: string
description: string
image?: string
type: CardType
author_name?: string
author_url?: string
provider_name?: string
provider_url?: string
}
export interface PollOption {
title: string
votes_count?: number
}
export interface Poll {
id: string
expires_at?: string
expired: boolean
multiple: boolean
votes_count: number
options: PollOption[]
voted?: boolean
}
export interface PollVote {
id: number
poll: string
choices: string[]
}
export interface Tag {
name: string
}
export interface Media {
id: string
description: string
url: string
preview_url: string
type: string
}
export interface Context {
ancestors: Status[]
descendants: Status[]
}

16
src/utils/Logger.ts Normal file
View File

@ -0,0 +1,16 @@
import { createLogger, format, transports } from 'winston'
const { colorize, combine, simple, prettyPrint } = format
const { Console } = transports
const isProd = process.env.NODE_ENV === 'production'
const Logger = createLogger({
format: combine(colorize(), simple()),
transports: [new Console({ level: isProd ? 'warn' : 'debug' })]
})
if (!isProd) {
Logger.debug('Logging initialized at debug level')
}
export default Logger

24
src/utils/Selector.ts Normal file
View File

@ -0,0 +1,24 @@
export default class Selector<T> {
private last: T
private index: number[]
constructor(private list: T[], private same: boolean, weight: (el: T) => number) {
this.index = list.map((s, idx) => Array(weight(s)).fill(idx)).reduce((a, b) => a.concat(b))
}
next() {
if (this.same) {
if (this.last === undefined) {
this.last = this.select()
}
return this.last
}
return this.select()
}
private select() {
return this.list[this.index[Math.floor(Math.random() * this.index.length)]]
}
}

42
src/utils/matcher.ts Normal file
View File

@ -0,0 +1,42 @@
export function match(params: any, tag: string) {
for (const key in params) {
if (params.hasOwnProperty(key)) {
const value = params[key]
if (value === false && key === tag) { // Activator
params[key] = true
}
if (tag.startsWith(`${key}_`)) {
const subTag = tag.substr(key.length + 1)
if (Array.isArray(value)) {
(params[key] as string[]).push(subTag)
} else {
switch (typeof value) {
case 'string':
params[key] = subTag
break
case 'number':
params[key] = Number(subTag)
break
case 'object':
params[key] = match(params[key], subTag)
break
default:
throw new Error('Bad type')
}
}
}
}
}
return params
}
/** Unsafe recursive matcher */
export default function matcher<T>(params: T, tags: string[]) {
for (const tag of tags) {
params = match(params, tag)
}
return params
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",
"noImplicitAny": true,
"moduleResolution": "node",
"sourceMap": false,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"*": [
"node_modules/*",
"src/types/*"
]
}
},
"include": [
"src/**/*"
]
}

20
tslint.json Normal file
View File

@ -0,0 +1,20 @@
{
"defaultSeverity": "warning",
"extends": ["tslint:recommended", "tslint-config-prettier", "tslint-plugin-prettier"],
"linterOptions": {
"exclude": [
"node_modules/**"
]
},
"rules": {
"trailing-comma": false,
"quotemark": [true, "single"],
"indent": [true, "spaces", 2],
"member-access": false,
"object-literal-sort-keys": false,
"arrow-parens": false,
"no-empty-interface": false,
"interface-name": false,
"semicolon": false
}
}