diff --git a/.env.sample b/.env.sample index 4084393..604ae75 100644 --- a/.env.sample +++ b/.env.sample @@ -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 \ No newline at end of file +DOMAIN=mastodon.social +TOKEN=xxxxxxxxxxxxxxxx +ROOT_STATUS=yyyyyyyyyy +TIMEOUT=60000 \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index bc28534..0000000 --- a/.eslintrc.json +++ /dev/null @@ -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" - ] - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index ec8a804..9f1abcc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ +*dist *node_modules -.env \ No newline at end of file +.env +.package-lock \ No newline at end of file diff --git a/README.md b/README.md index a1205c1..9135214 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file +- Add abstract and inherit on deep actions +- Add actions time validators +- Use followers pagination +- Add options on `data status` +- Add move pick options diff --git a/index.js b/index.js deleted file mode 100644 index 951968b..0000000 --- a/index.js +++ /dev/null @@ -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 }) - } - }) - }) - }) -*/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 64d847c..0000000 --- a/package-lock.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/package.json b/package.json index 78e4552..4b3d841 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/App.ts b/src/App.ts new file mode 100644 index 0000000..31ca9c6 --- /dev/null +++ b/src/App.ts @@ -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) { + 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({ + 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({ + 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) + } + +} \ No newline at end of file diff --git a/src/Rest.ts b/src/Rest.ts new file mode 100644 index 0000000..455387f --- /dev/null +++ b/src/Rest.ts @@ -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 { + return this.api.get('accounts/verify_credentials') + } + + async getStatus(id: string): Promise { + return this.api.get(`statuses/${id}`) + } + + async getContext(id: string): Promise { + 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 { + // TODO: use link header + return this.api.get(`account/${id}/followers`, { qs: { limit: 999 } }) + } + + async getFavouritedBy(id: string): Promise { + // TODO: use link header + return this.api.get(`statuses/${id}/favourited_by`, { qs: { limit: 999 } }) + } + + async postStatus(status: StatusPost): Promise { + return this.api.post('statuses', { body: status }) + } + + async deleteStatus(id: string) { + return this.api.delete(`statuses/${id}`) + } + +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..66cb98b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +import App from './App' +import { rest, rootStatus } from './prepare' + +const app = new App(rest) +app.run(rootStatus) \ No newline at end of file diff --git a/src/prepare.ts b/src/prepare.ts new file mode 100644 index 0000000..c4c4d8e --- /dev/null +++ b/src/prepare.ts @@ -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 \ No newline at end of file diff --git a/src/tools/action/add.ts b/src/tools/action/add.ts new file mode 100644 index 0000000..2187a73 --- /dev/null +++ b/src/tools/action/add.ts @@ -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() \ No newline at end of file diff --git a/src/tools/action/clear.ts b/src/tools/action/clear.ts new file mode 100644 index 0000000..08c382a --- /dev/null +++ b/src/tools/action/clear.ts @@ -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() \ No newline at end of file diff --git a/src/tools/action/list.ts b/src/tools/action/list.ts new file mode 100644 index 0000000..0763b0f --- /dev/null +++ b/src/tools/action/list.ts @@ -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() \ No newline at end of file diff --git a/src/tools/data/push.ts b/src/tools/data/push.ts new file mode 100644 index 0000000..1ab0da5 --- /dev/null +++ b/src/tools/data/push.ts @@ -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() \ No newline at end of file diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 0000000..0d3c98d --- /dev/null +++ b/src/types/config.ts @@ -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'] \ No newline at end of file diff --git a/src/types/mastodon.ts b/src/types/mastodon.ts new file mode 100644 index 0000000..0fe7073 --- /dev/null +++ b/src/types/mastodon.ts @@ -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[] +} \ No newline at end of file diff --git a/src/utils/Logger.ts b/src/utils/Logger.ts new file mode 100644 index 0000000..7ea250b --- /dev/null +++ b/src/utils/Logger.ts @@ -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 \ No newline at end of file diff --git a/src/utils/Selector.ts b/src/utils/Selector.ts new file mode 100644 index 0000000..97f88fe --- /dev/null +++ b/src/utils/Selector.ts @@ -0,0 +1,24 @@ +export default class Selector { + + 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)]] + } + +} \ No newline at end of file diff --git a/src/utils/matcher.ts b/src/utils/matcher.ts new file mode 100644 index 0000000..f7e386b --- /dev/null +++ b/src/utils/matcher.ts @@ -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(params: T, tags: string[]) { + for (const tag of tags) { + params = match(params, tag) + } + return params +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d0392f9 --- /dev/null +++ b/tsconfig.json @@ -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/**/*" + ] +} \ No newline at end of file diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..7dd1778 --- /dev/null +++ b/tslint.json @@ -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 + } +}